diff --git a/src/cerbero_bite/runtime/market_snapshot_cycle.py b/src/cerbero_bite/runtime/market_snapshot_cycle.py index 244809f..854725e 100644 --- a/src/cerbero_bite/runtime/market_snapshot_cycle.py +++ b/src/cerbero_bite/runtime/market_snapshot_cycle.py @@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any from cerbero_bite.clients._exceptions import McpError from cerbero_bite.state import connect, transaction -from cerbero_bite.state.models import MarketSnapshotRecord +from cerbero_bite.state.models import DvolSnapshot, MarketSnapshotRecord if TYPE_CHECKING: from cerbero_bite.runtime.dependencies import RuntimeContext @@ -181,6 +181,21 @@ async def collect_market_snapshot( try: with transaction(conn): ctx.repository.record_market_snapshot(conn, record) + # Mirror ETH spot+DVOL into dvol_history so monitor_cycle's + # return_4h lookup has local samples even in data-only mode. + if ( + record.asset == "ETH" + and record.spot is not None + and record.dvol is not None + ): + ctx.repository.record_dvol_snapshot( + conn, + DvolSnapshot( + timestamp=record.timestamp, + dvol=record.dvol, + eth_spot=record.spot, + ), + ) finally: conn.close() persisted += 1 diff --git a/tests/unit/test_market_snapshot_cycle.py b/tests/unit/test_market_snapshot_cycle.py index 12d6782..ec09cd5 100644 --- a/tests/unit/test_market_snapshot_cycle.py +++ b/tests/unit/test_market_snapshot_cycle.py @@ -164,3 +164,48 @@ async def test_returns_zero_for_empty_assets(tmp_path: Path) -> None: ctx = _ctx(tmp_path) n = await collect_market_snapshot(ctx, assets=(), now=_now()) assert n == 0 + + +def _read_dvol_history(ctx: MagicMock) -> list[dict]: + import sqlite3 + + conn = connect(ctx.db_path) + conn.row_factory = sqlite3.Row + try: + rows = conn.execute( + "SELECT * FROM dvol_history ORDER BY timestamp" + ).fetchall() + finally: + conn.close() + return [dict(r) for r in rows] + + +@pytest.mark.asyncio +async def test_eth_snapshot_mirrors_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") + + +@pytest.mark.asyncio +async def test_btc_only_snapshot_does_not_touch_dvol_history( + tmp_path: Path, +) -> None: + ctx = _ctx(tmp_path) + await collect_market_snapshot(ctx, assets=("BTC",), now=_now()) + assert _read_dvol_history(ctx) == [] + + +@pytest.mark.asyncio +async def test_eth_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. + assert _read_dvol_history(ctx) == []