Spaces:
Sleeping
Sleeping
| """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 | |
| 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" | |