feat(state): dvol_history multi-asset (ETH+BTC) + backfill ETH legacy rows

Migration 0006 promuove dvol_history da PK=(timestamp) mono-ETH a
PK=(timestamp, asset), rinomina eth_spot -> spot, e backfilla con
asset='ETH' le righe storiche. market_snapshot_cycle ora scrive sia
per ETH che per BTC; monitor_cycle resta ETH-only via WHERE asset='ETH'
nella lookup di return_4h.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-12 13:38:34 +00:00
parent 76d1a4a32d
commit 19695e4730
10 changed files with 111 additions and 33 deletions
+15 -9
View File
@@ -38,7 +38,9 @@ def _ctx(tmp_path: Path) -> MagicMock:
# Default: every feed succeeds with sane mock values.
ctx.deribit = MagicMock()
ctx.deribit.spot_perp_price = AsyncMock(return_value=Decimal("3000"))
ctx.deribit.spot_perp_price = AsyncMock(
side_effect=lambda asset: Decimal("65000") if asset == "BTC" else Decimal("3000")
)
ctx.deribit.latest_dvol = AsyncMock(return_value=Decimal("55"))
ctx.deribit.realized_vol = AsyncMock(
return_value={
@@ -181,31 +183,35 @@ def _read_dvol_history(ctx: MagicMock) -> list[dict]:
@pytest.mark.asyncio
async def test_eth_snapshot_mirrors_into_dvol_history(tmp_path: Path) -> None:
async def test_snapshot_mirrors_each_asset_into_dvol_history(tmp_path: Path) -> None:
ctx = _ctx(tmp_path)
await collect_market_snapshot(ctx, assets=("ETH", "BTC"), now=_now())
rows = _read_dvol_history(ctx)
assert len(rows) == 1
assert Decimal(str(rows[0]["dvol"])) == Decimal("55")
assert Decimal(str(rows[0]["eth_spot"])) == Decimal("3000")
by_asset = {r["asset"]: r for r in rows}
assert set(by_asset) == {"ETH", "BTC"}
assert Decimal(str(by_asset["ETH"]["spot"])) == Decimal("3000")
assert Decimal(str(by_asset["BTC"]["spot"])) == Decimal("65000")
@pytest.mark.asyncio
async def test_btc_only_snapshot_does_not_touch_dvol_history(
async def test_btc_only_snapshot_mirrors_into_dvol_history(
tmp_path: Path,
) -> None:
ctx = _ctx(tmp_path)
await collect_market_snapshot(ctx, assets=("BTC",), now=_now())
assert _read_dvol_history(ctx) == []
rows = _read_dvol_history(ctx)
assert len(rows) == 1
assert rows[0]["asset"] == "BTC"
assert Decimal(str(rows[0]["spot"])) == Decimal("65000")
@pytest.mark.asyncio
async def test_eth_snapshot_skips_dvol_history_when_dvol_missing(
async def test_snapshot_skips_dvol_history_when_dvol_missing(
tmp_path: Path,
) -> None:
ctx = _ctx(tmp_path)
ctx.deribit.latest_dvol = AsyncMock(side_effect=RuntimeError("no dvol"))
await collect_market_snapshot(ctx, assets=("ETH",), now=_now())
# market_snapshots row still persisted, but dvol_history must stay empty
# because its schema enforces NOT NULL on dvol/eth_spot.
# because its schema enforces NOT NULL on dvol/spot.
assert _read_dvol_history(ctx) == []
+33 -2
View File
@@ -277,15 +277,46 @@ def test_record_dvol_snapshot_replaces_on_duplicate_timestamp(
ts = datetime(2026, 4, 27, 14, 0, tzinfo=UTC)
with transaction(conn):
repo.record_dvol_snapshot(
conn, DvolSnapshot(timestamp=ts, dvol=Decimal("50"), eth_spot=Decimal("3000"))
conn,
DvolSnapshot(
timestamp=ts, asset="ETH", dvol=Decimal("50"), spot=Decimal("3000")
),
)
repo.record_dvol_snapshot(
conn, DvolSnapshot(timestamp=ts, dvol=Decimal("55"), eth_spot=Decimal("3050"))
conn,
DvolSnapshot(
timestamp=ts, asset="ETH", dvol=Decimal("55"), spot=Decimal("3050")
),
)
rows = conn.execute("SELECT COUNT(*) FROM dvol_history").fetchone()
assert rows[0] == 1
def test_record_dvol_snapshot_keeps_assets_distinct_on_same_timestamp(
conn: sqlite3.Connection, repo: Repository
) -> None:
ts = datetime(2026, 4, 27, 14, 0, tzinfo=UTC)
with transaction(conn):
repo.record_dvol_snapshot(
conn,
DvolSnapshot(
timestamp=ts, asset="ETH", dvol=Decimal("50"), spot=Decimal("3000")
),
)
repo.record_dvol_snapshot(
conn,
DvolSnapshot(
timestamp=ts, asset="BTC", dvol=Decimal("45"), spot=Decimal("65000")
),
)
rows = conn.execute(
"SELECT asset, dvol, spot FROM dvol_history ORDER BY asset"
).fetchall()
assert [r["asset"] for r in rows] == ["BTC", "ETH"]
assert Decimal(str(rows[0]["spot"])) == Decimal("65000")
assert Decimal(str(rows[1]["spot"])) == Decimal("3000")
def test_manual_action_enqueue_consume_cycle(
conn: sqlite3.Connection, repo: Repository
) -> None: