SimpleChatbot / hermes_overlay /tests /test_binance_public_fallback.py
Amin
Deploy: HermesFace finalized project to HF Space
2e658e7
Raw
History Blame Contribute Delete
11.9 kB
"""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