"""Focused tests for the DS4 merge-nesting fix in dual_datasource_client.py. Verifies: (1) DS4-owned fields are read from ds4["data"], not ds4 top level; (2) DS2 fallback still fires only when a DS4 data field is genuinely missing; (3) DS2 still cannot override a present/valid DS4 field once read from the correct nested path; (4) top-level metadata (dataState, noTradeGuard) is unaffected by the fix, since it was never part of the bug. Mocked HTTP only -- no live network calls. """ import asyncio import os import sys from datetime import datetime, timezone import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # hermes_overlay/ import trading.dual_datasource_client as ddc @pytest.fixture(autouse=True) def _disable_live_binance(monkeypatch): async def _unavailable(symbol, needed): return {}, [] monkeypatch.setattr(ddc.binance_public, "get_binance_public_snapshot", _unavailable) class _FakeResponse: def __init__(self, payload, status_code=200): self._payload = payload self.status_code = status_code def raise_for_status(self): pass def json(self): return self._payload def _make_fake_client(ds4_payload, ds2_payloads=None, ds4_raises=False): ds2_payloads = ds2_payloads or {} class _FakeClient: async def __aenter__(self): return self async def __aexit__(self, *a): return False async def get(self, url, timeout=None, **_kwargs): if "short-hunter/snapshot" in url: if ds4_raises: raise RuntimeError("boom") return _FakeResponse(ds4_payload) for key, payload in ds2_payloads.items(): if key in url: return _FakeResponse(payload) return _FakeResponse({}) return _FakeClient() def _nested_ds4_payload(**overrides): """A realistic DS4 envelope: metadata top-level, market fields under data.""" base = { "success": True, "symbol": "BTCUSDT", "dataState": "live", "noTradeGuard": False, "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "warnings": [], "errors": [], "data": { # contractType/status let _contract_verification() mark this a # verified Futures instrument (see docs/DATA_PIPELINE_AND_CONTRACTS.md # and docs/DEVELOPER_GUIDE.md: DS4 owns Futures verification, and a # bare {"symbol": ...} is deliberately treated as unverified). "contract": {"symbol": "XBTUSDTM", "contractType": "PERPETUAL", "status": "TRADING"}, "ticker": {"lastPrice": 64364.4, "change24h": None}, "ohlcv": [ {"timestamp": i, "open": 64360, "high": 64370, "low": 64350, "close": 64364.4, "volume": 5} for i in range(4) ], "orderbook": {"bids": [[64364.4, 1]], "asks": [[64364.5, 1]]}, "funding": {"currentFundingRate": -8.2e-05}, "openInterest": {"openInterest": 27000928.0, "history": []}, "indicators": {"rsi14": 49.3}, "sentiment": None, }, } base.update(overrides) return base def test_merge_reads_futures_fields_from_nested_data(monkeypatch): payload = _nested_ds4_payload() monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload)) result = asyncio.run(ddc.get_market_context("BTCUSDT")) merged = result["merged"] assert merged["ticker"] == {"lastPrice": 64364.4, "change24h": None} assert merged["funding"] == {"currentFundingRate": -8.2e-05} assert merged["openInterest"] == {"openInterest": 27000928.0, "history": []} assert merged["orderbook"] == {"bids": [[64364.4, 1]], "asks": [[64364.5, 1]]} assert merged["contract"] == { "symbol": "XBTUSDTM", "contractType": "PERPETUAL", "status": "TRADING", } assert result["sources"]["ticker"] == "datasource4" assert result["sources"]["funding"] == "datasource4" assert result["noTradeGuard"] is False def test_ds2_fallback_fires_only_for_genuinely_missing_nested_field(monkeypatch): payload = _nested_ds4_payload() payload["data"]["orderbook"] = None # DS4 genuinely missing this field ds2_payloads = {"trading/orderbook": {"bids": [[1, 1]], "asks": [[1, 1]]}} monkeypatch.setattr( ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload, ds2_payloads) ) result = asyncio.run(ddc.get_market_context("BTCUSDT")) merged = result["merged"] assert merged["orderbook"] == {"bids": [[1, 1]], "asks": [[1, 1]]} assert result["sources"]["orderbook"] == "datasource2" assert any("orderbook" in w and "Datasource 2 fallback" in w for w in result["warnings"]) # untouched DS4-owned fields still come from DS4, not DS2 assert result["sources"]["ticker"] == "datasource4" def test_ds2_cannot_override_valid_nested_ds4_orderbook(monkeypatch): payload = _nested_ds4_payload() # DS4 orderbook present and valid ds2_payloads = {"trading/orderbook": {"bids": [[999, 1]], "asks": [[999, 1]]}} monkeypatch.setattr( ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload, ds2_payloads) ) result = asyncio.run(ddc.get_market_context("BTCUSDT")) merged = result["merged"] assert merged["orderbook"] == {"bids": [[64364.4, 1]], "asks": [[64364.5, 1]]} assert result["sources"]["orderbook"] == "datasource4" def test_ds4_data_key_missing_entirely_is_treated_as_no_data(monkeypatch): payload = { "success": False, "symbol": "BTCUSDT", "dataState": "PARTIAL", "noTradeGuard": False, "timestamp": "x", "warnings": [], "errors": [], # no "data" key at all -- malformed response } monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload)) result = asyncio.run(ddc.get_market_context("BTCUSDT")) assert result["sources"]["ticker"] == "unavailable" assert result["merged"]["ticker"] is None assert result["noTradeGuard"] is True # missing critical fields after merge assert any("Missing required Futures fields" in r for r in result["noTradeReasons"]) def test_top_level_metadata_unaffected_by_nesting_fix(monkeypatch): """dataState/noTradeGuard were never part of the bug -- must stay top-level reads.""" payload = _nested_ds4_payload(dataState="PARTIAL") monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload)) result = asyncio.run(ddc.get_market_context("BTCUSDT")) assert result["noTradeGuard"] is True assert any("dataState='PARTIAL'" in r for r in result["noTradeReasons"]) # Stale DS4 data remains visible in primary for diagnostics, but is not # silently promoted into the merged current-market view. assert result["primary"]["data"]["ticker"] == {"lastPrice": 64364.4, "change24h": None} assert result["merged"]["ticker"] is None assert result["sources"]["ticker"] == "unavailable"