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