"""Minimal smoke tests for the Binance public Futures fallback. Covers exactly the four cases requested: 1. DS4 fully valid -> Binance is never called. 2. A DS4 field missing -> Binance fills only that field. 3. ATR / open-interest-change calculation (pure, no HTTP). 4. DS4 noTradeGuard=true stays enforced even if Binance fills gaps. Mocked HTTP / mocked fallback call only -- no live network calls. """ import asyncio import os import sys import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # hermes_overlay/ import trading.dual_datasource_client as ddc import trading.binance_public_client as bpc import trading.trade_cycle as trade_cycle 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 test_active_contract_catalog_handles_public_exchange_info(monkeypatch): async def fake_get_json(_client, path, params): assert path == "/fapi/v1/exchangeInfo" return bpc._RequestResult(data={"serverTime": 1_700_000_000_000, "symbols": [ {"symbol": "BTCUSDT", "baseAsset": "BTC", "quoteAsset": "USDT", "status": "TRADING", "contractType": "PERPETUAL"}, {"symbol": "ETHUSDT_260925", "baseAsset": "ETH", "quoteAsset": "USDT", "status": "TRADING", "contractType": "CURRENT_QUARTER"}, {"symbol": "OLDUSDT", "baseAsset": "OLD", "quoteAsset": "USDT", "status": "SETTLING", "contractType": "PERPETUAL"}, ]}) monkeypatch.setattr(bpc, "_get_json", fake_get_json) result = asyncio.run(bpc.get_binance_active_contracts_result()) assert not result["errors"] assert [item["symbol"] for item in result["data"]] == ["BTCUSDT"] assert result["data"][0]["futuresVerified"] is True assert result["data"][0]["source"] == "binance_public" 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 _full_ds4_payload(**data_overrides): """A DS4 envelope with every Binance-suppliable field present and valid (including a fabricated `atr`, which real DS4 never actually returns -- included here only to isolate the 'DS4 already has it' branch).""" data = { "contract": {"symbol": "XBTUSDTM"}, "ticker": {"lastPrice": 64364.4, "change24h": 0.5, "volume24h": 123456.0}, "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": [ {"sumOpenInterest": "27000000", "timestamp": 1}, {"sumOpenInterest": "27000928", "timestamp": 2}, ], }, "indicators": {"rsi14": 49.3}, "sentiment": None, "atr": 55.5, } data.update(data_overrides) return { "success": True, "symbol": "BTCUSDT", "dataState": "live", "noTradeGuard": False, "timestamp": "2026-07-20T11:08:19Z", "data": data, } # --------------------------------------------------------------------------- # 1. DS4 valid -> Binance not used # --------------------------------------------------------------------------- def test_ds4_fully_valid_binance_not_called(monkeypatch): calls = [] async def _fake_binance(symbol, needed): calls.append(needed) return {}, [] monkeypatch.setattr(ddc.binance_public, "get_binance_public_snapshot", _fake_binance) payload = _full_ds4_payload() monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload)) result = asyncio.run(ddc.get_market_context("BTCUSDT")) assert calls == [] assert result["sources"]["funding"] == "datasource4" assert result["sources"]["atr"] == "datasource4" # --------------------------------------------------------------------------- # 2. DS4 field missing -> Binance fills it # --------------------------------------------------------------------------- def test_ds4_missing_field_filled_by_binance(monkeypatch): # "funding" is one of the market-data fields (ticker/ohlcv/funding/openInterest) # that get_market_context() requests together via get_binance_public_market_data; # only openInterestChange/atr/orderbook go through get_binance_public_result with # an explicit `needed` set. See dual_datasource_client.get_market_context(). calls = [] async def _fake_market_data(symbol, interval, limit): calls.append(symbol) return { "data": {"funding": {"currentFundingRate": -0.0001, "source": "binance_public"}}, "errors": [], "warnings": [], "meta": {}, } monkeypatch.setattr(ddc.binance_public, "get_binance_public_market_data", _fake_market_data) payload = _full_ds4_payload(funding=None) # DS4 genuinely missing funding monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload)) result = asyncio.run(ddc.get_market_context("BTCUSDT")) assert len(calls) == 1 assert result["merged"]["funding"] == {"currentFundingRate": -0.0001, "source": "binance_public"} assert result["sources"]["funding"] == "binance_public" assert any("filled from Binance public fallback" in w for w in result["warnings"]) # untouched fields stay on DS4 assert result["sources"]["ticker"] == "datasource4" def test_ds4_price_only_ticker_is_supplemented_with_public_24h_summary(monkeypatch): async def _fake_market_data(symbol, interval, limit): return { "data": { "ticker": { "lastPrice": 65001.0, "change24h": 1.25, "change24hFraction": 0.0125, "volume24h": 123456789.0, "source": "binance_public", } }, "errors": [], "warnings": [], "meta": {}, } monkeypatch.setattr(ddc.binance_public, "get_binance_public_market_data", _fake_market_data) payload = _full_ds4_payload(ticker={"lastPrice": 64999.0}) monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload)) result = asyncio.run(ddc.get_market_context("BTCUSDT")) assert result["merged"]["ticker"]["lastPrice"] == 64999.0 assert result["merged"]["ticker"]["change24h"] == pytest.approx(1.25) assert result["merged"]["ticker"]["volume24h"] == pytest.approx(123456789.0) assert result["sources"]["ticker"] == "datasource4" assert result["sources"]["ticker.change24h"] == "binance_public" assert result["sources"]["ticker.volume24h"] == "binance_public" # --------------------------------------------------------------------------- # 3. ATR / open-interest-change calculation (pure functions, no HTTP) # --------------------------------------------------------------------------- def test_atr_calculated_from_valid_mark_candles(): candles = [ {"timestamp": i, "open": 100, "high": 101 + (i % 3), "low": 99 - (i % 2), "close": 100 + (i % 2), "volume": 1} for i in range(bpc.ATR_PERIOD + 1) ] atr = bpc._calculate_atr(candles) assert atr is not None assert atr > 0 def test_atr_none_when_insufficient_candles(): candles = [{"timestamp": 1, "open": 100, "high": 101, "low": 99, "close": 100, "volume": 1}] assert bpc._calculate_atr(candles) is None def test_oi_change_calculated_from_valid_history(): history = [ {"sumOpenInterest": "1000", "timestamp": 1}, {"sumOpenInterest": "1100", "timestamp": 2}, ] change = bpc._calculate_oi_change(history) assert change == pytest.approx(0.1) def test_oi_change_none_when_history_too_short(): assert bpc._calculate_oi_change([{"sumOpenInterest": "1000", "timestamp": 1}]) is None assert bpc._calculate_oi_change([]) is None assert bpc._calculate_oi_change(None) is None def test_ds4_missing_oi_history_filled_by_binance(monkeypatch): # openInterestChange is not one of the market-data fields, so it is # requested via get_binance_public_result(symbol, needed_fields). calls = [] async def _fake_result(symbol, needed_fields=None): calls.append(frozenset(needed_fields) if needed_fields is not None else frozenset()) return { "data": { "openInterestChange": { "changePercent": 0.1, "history": [ {"sumOpenInterest": 1000.0, "timestamp": 1}, {"sumOpenInterest": 1100.0, "timestamp": 2}, ], "source": "binance_public", } }, "errors": [], "warnings": [], "meta": {}, } monkeypatch.setattr(ddc.binance_public, "get_binance_public_result", _fake_result) payload = _full_ds4_payload() payload["data"]["openInterest"] = {"openInterest": 27000928.0, "history": []} monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload)) result = asyncio.run(ddc.get_market_context("BTCUSDT")) assert calls == [frozenset({"openInterestChange"})] assert result["merged"]["openInterest"]["changePercent"] == pytest.approx(0.1) assert result["sources"]["openInterest"] == "datasource4" assert result["sources"]["openInterest.changePercent"] == "binance_public" # --------------------------------------------------------------------------- # 4. DS4 noTradeGuard=true remains enforced even if Binance fills gaps # --------------------------------------------------------------------------- def test_notradeguard_enforced_even_when_binance_fills_all_gaps(monkeypatch): async def _fake_market_data(symbol, interval, limit): # Pretend Binance successfully supplies everything that was missing # (here just "funding" -- the rest of the fixture's DS4 data is valid). return { "data": {"funding": {"currentFundingRate": -0.0001, "source": "binance_public"}}, "errors": [], "warnings": [], "meta": {}, } monkeypatch.setattr(ddc.binance_public, "get_binance_public_market_data", _fake_market_data) payload = _full_ds4_payload(funding=None) payload["noTradeGuard"] = True # DS4 itself says NO_TRADE monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload)) result = asyncio.run(ddc.get_market_context("BTCUSDT")) # Binance did fill the gap... assert result["sources"]["funding"] == "binance_public" # ...but the DS4-authoritative guard still wins. assert result["noTradeGuard"] is True assert any("noTradeGuard=true" in r for r in result["noTradeReasons"]) async def _guarded_context(symbol): return result monkeypatch.setattr(trade_cycle, "get_market_context", _guarded_context) cycle = asyncio.run(trade_cycle.run_futures_cycle("BTCUSDT", execute=False)) assert cycle["decision"] == "NO_TRADE" assert cycle["executed"] is False