Your Name commited on
Commit
8d4d2fb
·
1 Parent(s): 4fc5db8

Fix DS4 candle-limit freshness and completeness handling

Browse files
hermes_overlay/tests/test_candle_limit_completeness.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression tests for the DS4 candle-limit clamp / completeness fix.
2
+
3
+ Datasource 4's Short Hunter snapshot endpoint caps OHLCV history at
4
+ DS4_MAX_OHLCV_CANDLES (200) candles per request. Requesting a wider
5
+ window than DS4 can fill used to make it return the OLDEST candles in
6
+ that window, which made the freshest returned candle look stale even
7
+ though fresher data existed. The fix clamps the *request window* to
8
+ the provider cap while still reporting the caller's original requested
9
+ limit, so a 240-candle request against a 200-candle provider cap comes
10
+ back temporally fresh and only quantity-partial -- never silently
11
+ padded/fabricated and never marked stale for that reason alone.
12
+
13
+ Mocked HTTP only -- no live network calls.
14
+ """
15
+ import asyncio
16
+ import os
17
+ import sys
18
+ import time
19
+ import pytest
20
+
21
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # hermes_overlay/
22
+
23
+ import trading.dual_datasource_client as ddc
24
+ import tools.futures_dashboard_api as fda
25
+
26
+
27
+ @pytest.fixture(autouse=True)
28
+ def _disable_live_binance(monkeypatch):
29
+ async def _unavailable(symbol, needed):
30
+ return {}, []
31
+ monkeypatch.setattr(ddc.binance_public, "get_binance_public_snapshot", _unavailable)
32
+
33
+
34
+ class _FakeResponse:
35
+ def __init__(self, payload, status_code=200):
36
+ self._payload = payload
37
+ self.status_code = status_code
38
+
39
+ def raise_for_status(self):
40
+ pass
41
+
42
+ def json(self):
43
+ return self._payload
44
+
45
+
46
+ def _make_fake_client(ds4_payload, captured_params=None):
47
+ class _FakeClient:
48
+ async def __aenter__(self):
49
+ return self
50
+
51
+ async def __aexit__(self, *a):
52
+ return False
53
+
54
+ async def get(self, url, timeout=None, params=None, **_kwargs):
55
+ if "short-hunter/snapshot" in url:
56
+ if captured_params is not None:
57
+ captured_params.update(params or {})
58
+ return _FakeResponse(ds4_payload)
59
+ return _FakeResponse({})
60
+
61
+ return _FakeClient()
62
+
63
+
64
+ def _make_candles(n, interval_seconds=300, end_ms=None):
65
+ """n candles at `interval_seconds` spacing, the last one dated `end_ms` (default: now)."""
66
+ end_ms = end_ms if end_ms is not None else int(time.time() * 1000)
67
+ return [
68
+ {
69
+ "timestamp": end_ms - (n - 1 - i) * interval_seconds * 1000,
70
+ "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.5, "volume": 10.0,
71
+ }
72
+ for i in range(n)
73
+ ]
74
+
75
+
76
+ def _ds4_payload(ohlcv):
77
+ return {
78
+ "success": True,
79
+ "symbol": "BTCUSDT",
80
+ "dataState": "live",
81
+ "noTradeGuard": False,
82
+ "timestamp": None,
83
+ "warnings": [],
84
+ "errors": [],
85
+ "data": {
86
+ "contract": {"symbol": "XBTUSDTM", "contractType": "PERPETUAL", "status": "TRADING"},
87
+ "ticker": {"lastPrice": 64364.4, "change24h": 1.2},
88
+ "ohlcv": ohlcv,
89
+ "orderbook": {"bids": [[64364.4, 1]], "asks": [[64364.5, 1]]},
90
+ "funding": {"currentFundingRate": -8.2e-05},
91
+ "openInterest": {"openInterest": 27000928.0, "history": []},
92
+ "indicators": {"rsi14": 49.3},
93
+ "sentiment": None,
94
+ },
95
+ }
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # 1 & 2: within-provider-cap limits (60, 120) -- fresh and complete
100
+ # ---------------------------------------------------------------------------
101
+
102
+ @pytest.mark.parametrize("requested", [60, 120])
103
+ def test_within_cap_limit_returns_fresh_complete(monkeypatch, requested):
104
+ payload = _ds4_payload(_make_candles(requested))
105
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
106
+ result = asyncio.run(ddc.get_market_context("BTCUSDT", limit=requested))
107
+
108
+ assert result["requestedLimit"] == requested
109
+ assert result["providerLimit"] == requested
110
+ assert result["returnedCandleCount"] == requested
111
+ assert result["candleCompleteness"] == "complete"
112
+ assert result["freshness"]["datasource4"] == "fresh"
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # 3: requested 240, provider cap 200 -> request window built for newest 200
117
+ # ---------------------------------------------------------------------------
118
+
119
+ def test_over_cap_request_window_uses_provider_cap(monkeypatch):
120
+ payload = _ds4_payload(_make_candles(200))
121
+ captured_params = {}
122
+ monkeypatch.setattr(
123
+ ddc.httpx, "AsyncClient",
124
+ lambda *a, **kw: _make_fake_client(payload, captured_params),
125
+ )
126
+ result = asyncio.run(ddc.get_market_context("BTCUSDT", limit=240))
127
+
128
+ assert captured_params.get("limit") == 200
129
+ assert result["providerLimit"] == 200
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # 4: requested 240, returned 200 recent candles -> fresh and partial
134
+ # ---------------------------------------------------------------------------
135
+
136
+ def test_over_cap_returns_fresh_partial_not_stale(monkeypatch):
137
+ payload = _ds4_payload(_make_candles(200))
138
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
139
+ result = asyncio.run(ddc.get_market_context("BTCUSDT", limit=240))
140
+
141
+ assert result["requestedLimit"] == 240
142
+ assert result["providerLimit"] == 200
143
+ assert result["returnedCandleCount"] == 200
144
+ assert result["candleCompleteness"] == "partial"
145
+ assert result["freshness"]["datasource4"] == "fresh"
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # 5: genuinely old final candle -> stale
150
+ # ---------------------------------------------------------------------------
151
+
152
+ def test_genuinely_old_final_candle_is_stale(monkeypatch):
153
+ old_end = int(time.time() * 1000) - 3600 * 1000 # 1 hour old, well past the 15-minute window
154
+ payload = _ds4_payload(_make_candles(60, end_ms=old_end))
155
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
156
+ result = asyncio.run(ddc.get_market_context("BTCUSDT", limit=60))
157
+
158
+ assert result["freshness"]["datasource4"] == "stale"
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # 6: malformed candles -> invalid/unavailable
163
+ # ---------------------------------------------------------------------------
164
+
165
+ def test_malformed_candles_are_invalid_unavailable(monkeypatch):
166
+ malformed = [{"timestamp": None, "open": 1, "high": 1, "low": 1, "close": 1, "volume": 1}] * 5
167
+ payload = _ds4_payload(malformed)
168
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
169
+ result = asyncio.run(ddc.get_market_context("BTCUSDT", limit=60))
170
+
171
+ assert result["merged"]["ohlcv"] is None
172
+ assert result["sources"]["ohlcv"] == "unavailable"
173
+ assert result["returnedCandleCount"] == 0
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # 7: candle quantity mismatch does not invalidate other fresh fields
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def test_candle_shortfall_does_not_invalidate_other_fresh_fields(monkeypatch):
181
+ payload = _ds4_payload(_make_candles(200))
182
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
183
+ result = asyncio.run(ddc.get_market_context("BTCUSDT", limit=240))
184
+
185
+ assert result["candleCompleteness"] == "partial"
186
+ assert result["merged"]["ticker"]["lastPrice"] == 64364.4
187
+ assert result["merged"]["ticker"]["change24h"] == 1.2
188
+ assert result["merged"]["funding"] == {"currentFundingRate": -8.2e-05}
189
+ assert result["merged"]["orderbook"] == {"bids": [[64364.4, 1]], "asks": [[64364.5, 1]]}
190
+ assert result["merged"]["openInterest"] == {"openInterest": 27000928.0, "history": []}
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # 8: API response exposes requestedLimit/providerLimit/returnedCount/completeness
195
+ # ---------------------------------------------------------------------------
196
+
197
+ def test_futures_market_endpoint_exposes_completeness_fields(monkeypatch):
198
+ payload = _ds4_payload(_make_candles(200))
199
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
200
+ monkeypatch.setattr(fda, "_dash_state", fda._dash_state) # keep real state module (no-op sanity)
201
+ monkeypatch.setattr(fda._dash_state, "record_market_context", lambda *a, **kw: None)
202
+
203
+ response = asyncio.run(fda.futures_market(symbol="BTCUSDT", interval="5m", limit=240))
204
+ body = response.body
205
+ import json as _json
206
+ data = _json.loads(body)
207
+
208
+ assert data["requestedLimit"] == 240
209
+ assert data["providerLimit"] == 200
210
+ assert data["returnedCount"] == 200
211
+ assert data["candleCompleteness"] == "partial"
212
+ assert data["freshness"] == "fresh"
213
+ assert data["state"] in {"available", "partial"}
214
+ assert len(data["candles"]) == 200
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # 9: no candle padding or fabrication
219
+ # ---------------------------------------------------------------------------
220
+
221
+ def test_normalize_candles_does_not_pad_or_fabricate():
222
+ raw = _make_candles(200)
223
+ normalized = fda._normalize_candles(raw, 240)
224
+ assert len(normalized) == 200
225
+ # timestamps preserved verbatim, newest candle kept, nothing synthesized
226
+ assert normalized[-1]["timestamp"] == raw[-1]["timestamp"]
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # 10: warning is emitted once
231
+ # ---------------------------------------------------------------------------
232
+
233
+ def test_over_cap_warning_emitted_once(monkeypatch):
234
+ payload = _ds4_payload(_make_candles(200))
235
+ monkeypatch.setattr(ddc.httpx, "AsyncClient", lambda *a, **kw: _make_fake_client(payload))
236
+ result = asyncio.run(ddc.get_market_context("BTCUSDT", limit=240))
237
+
238
+ matches = [w for w in result["warnings"] if "Datasource 4's maximum is 200" in w]
239
+ assert len(matches) == 1
hermes_overlay/tools/futures_dashboard_api.py CHANGED
@@ -743,6 +743,10 @@ async def futures_market(
743
  "symbol": norm.ds4,
744
  "interval": interval,
745
  "limit": limit,
 
 
 
 
746
  "candles": [],
747
  "currentPrice": None,
748
  "markPrice": None,
@@ -865,6 +869,10 @@ async def futures_market(
865
  "symbol": norm.ds4,
866
  "interval": interval,
867
  "limit": limit,
 
 
 
 
868
  "candles": candles,
869
  "currentPrice": price,
870
  "markPrice": mark_price,
 
743
  "symbol": norm.ds4,
744
  "interval": interval,
745
  "limit": limit,
746
+ "requestedLimit": limit,
747
+ "providerLimit": None,
748
+ "returnedCount": 0,
749
+ "candleCompleteness": "unavailable",
750
  "candles": [],
751
  "currentPrice": None,
752
  "markPrice": None,
 
869
  "symbol": norm.ds4,
870
  "interval": interval,
871
  "limit": limit,
872
+ "requestedLimit": context.get("requestedLimit", limit),
873
+ "providerLimit": context.get("providerLimit"),
874
+ "returnedCount": context.get("returnedCandleCount", len(candles)),
875
+ "candleCompleteness": context.get("candleCompleteness"),
876
  "candles": candles,
877
  "currentPrice": price,
878
  "markPrice": mark_price,
hermes_overlay/trading/dual_datasource_client.py CHANGED
@@ -41,6 +41,14 @@ FUTURES_CRITICAL_FIELDS = ("contract", "ticker", "orderbook", "funding", "openIn
41
  DS4_FRESH_STATES = {"live", "fresh", "ok", "real"}
42
  SUPPORTED_INTERVALS = {"1m": 60, "5m": 300, "15m": 900, "1h": 3600}
43
  MAX_MARKET_LIMIT = 500
 
 
 
 
 
 
 
 
44
 
45
 
46
  def normalize_epoch_milliseconds(value: Any) -> int:
@@ -118,19 +126,26 @@ async def _get_json(
118
 
119
  async def _fetch_ds4_snapshot(
120
  client: httpx.AsyncClient, ds4_symbol: str, timeframe: str = "5m", limit: int = 120
121
- ) -> tuple[Optional[dict], list[str], dict]:
122
  warnings: list[str] = []
123
  bounded_limit = max(1, min(int(limit), MAX_MARKET_LIMIT))
124
- start_ms, end_ms = build_kucoin_time_range(timeframe, bounded_limit)
 
 
 
 
 
125
  endpoint = f"{DS4_BASE}/api/short-hunter/snapshot/{ds4_symbol}"
126
  data, meta = await _get_json_with_meta(
127
  client, endpoint, DS4_TIMEOUT_S,
128
- params={"interval": timeframe, "limit": bounded_limit,
129
  "from": start_ms, "to": end_ms},
130
  )
131
  if data is None:
132
  warnings.append("Datasource 4 (Short Hunter) unreachable or errored")
133
- return data, warnings, meta
 
 
134
 
135
 
136
  async def _fetch_ds2_context(
@@ -708,12 +723,13 @@ async def get_market_context(symbol: str, timeframe: str = "5m", limit: int = 12
708
  no_trade_reasons: list[str] = []
709
 
710
  async with httpx.AsyncClient() as client:
711
- (ds4, ds4_warnings, ds4_transport), (ds2_context, ds2_warnings, ds2_transport) = await asyncio.gather(
712
  _fetch_ds4_snapshot(client, norm.ds4, timeframe, limit),
713
  _fetch_ds2_context(client, norm.ds2),
714
  )
715
  warnings.extend(ds4_warnings)
716
  warnings.extend(ds2_warnings)
 
717
 
718
  ds4_warnings_payload = _find_section(ds4, "warnings") if isinstance(ds4, dict) else None
719
  ds4_errors_payload = _find_section(ds4, "errors") if isinstance(ds4, dict) else None
@@ -1027,8 +1043,27 @@ async def get_market_context(symbol: str, timeframe: str = "5m", limit: int = 12
1027
  trading_readiness = "ready" if not no_trade_guard else "blocked"
1028
  merge_status = "complete" if not missing_critical and not stale_critical else "partial"
1029
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1030
  return {
1031
  "symbol": norm.ds4,
 
 
 
 
1032
  "verifiedFutures": bool(futures_verification.get("verified")),
1033
  "futuresVerification": futures_verification,
1034
  "primary": ds4 or {},
 
41
  DS4_FRESH_STATES = {"live", "fresh", "ok", "real"}
42
  SUPPORTED_INTERVALS = {"1m": 60, "5m": 300, "15m": 900, "1h": 3600}
43
  MAX_MARKET_LIMIT = 500
44
+ # Datasource 4's Short Hunter snapshot endpoint caps OHLCV history at 200
45
+ # candles per request regardless of the `limit` requested. Requesting a wider
46
+ # time window than DS4 can actually fill causes it to return the OLDEST
47
+ # candles within that window rather than the most recent ones -- the request
48
+ # window must therefore be built from this provider cap, never from the raw
49
+ # caller-requested limit, or the "freshest" candle in the response can be
50
+ # hours old. See dual-datasource candle-count completeness fix.
51
+ DS4_MAX_OHLCV_CANDLES = 200
52
 
53
 
54
  def normalize_epoch_milliseconds(value: Any) -> int:
 
126
 
127
  async def _fetch_ds4_snapshot(
128
  client: httpx.AsyncClient, ds4_symbol: str, timeframe: str = "5m", limit: int = 120
129
+ ) -> tuple[Optional[dict], list[str], dict, int]:
130
  warnings: list[str] = []
131
  bounded_limit = max(1, min(int(limit), MAX_MARKET_LIMIT))
132
+ # Build the request window from what DS4 can actually deliver, not from
133
+ # the raw requested limit. Asking for a window wider than DS4's own cap
134
+ # makes it return the oldest candles in that window (stale), not the
135
+ # most recent ones -- see DS4_MAX_OHLCV_CANDLES above.
136
+ provider_limit = min(bounded_limit, DS4_MAX_OHLCV_CANDLES)
137
+ start_ms, end_ms = build_kucoin_time_range(timeframe, provider_limit)
138
  endpoint = f"{DS4_BASE}/api/short-hunter/snapshot/{ds4_symbol}"
139
  data, meta = await _get_json_with_meta(
140
  client, endpoint, DS4_TIMEOUT_S,
141
+ params={"interval": timeframe, "limit": provider_limit,
142
  "from": start_ms, "to": end_ms},
143
  )
144
  if data is None:
145
  warnings.append("Datasource 4 (Short Hunter) unreachable or errored")
146
+ # A more precise candle-count-mismatch warning (aware of how many candles
147
+ # were actually returned) is added by the caller, get_market_context.
148
+ return data, warnings, meta, provider_limit
149
 
150
 
151
  async def _fetch_ds2_context(
 
723
  no_trade_reasons: list[str] = []
724
 
725
  async with httpx.AsyncClient() as client:
726
+ (ds4, ds4_warnings, ds4_transport, ds4_provider_limit), (ds2_context, ds2_warnings, ds2_transport) = await asyncio.gather(
727
  _fetch_ds4_snapshot(client, norm.ds4, timeframe, limit),
728
  _fetch_ds2_context(client, norm.ds2),
729
  )
730
  warnings.extend(ds4_warnings)
731
  warnings.extend(ds2_warnings)
732
+ requested_limit = max(1, min(int(limit), MAX_MARKET_LIMIT))
733
 
734
  ds4_warnings_payload = _find_section(ds4, "warnings") if isinstance(ds4, dict) else None
735
  ds4_errors_payload = _find_section(ds4, "errors") if isinstance(ds4, dict) else None
 
1043
  trading_readiness = "ready" if not no_trade_guard else "blocked"
1044
  merge_status = "complete" if not missing_critical and not stale_critical else "partial"
1045
 
1046
+ ohlcv_value = merged.get("ohlcv")
1047
+ returned_candle_count = len(ohlcv_value) if isinstance(ohlcv_value, list) else 0
1048
+ # Candle-count completeness is independent from temporal freshness: a
1049
+ # response can hold fewer candles than requested (because the provider
1050
+ # caps history length) while every candle in it is fully current. This
1051
+ # compares against what the caller actually asked for, not the provider
1052
+ # cap, so a 240-request answered with 200 fresh candles is reported as
1053
+ # quantity-partial rather than silently rounded up to "complete".
1054
+ candle_completeness = "complete" if returned_candle_count >= requested_limit else "partial"
1055
+ if candle_completeness == "partial" and ds4_provider_limit < requested_limit and returned_candle_count >= ds4_provider_limit:
1056
+ warnings.append(
1057
+ f"Requested {requested_limit} candles; Datasource 4's maximum is "
1058
+ f"{ds4_provider_limit} and {returned_candle_count} fresh candles were returned"
1059
+ )
1060
+
1061
  return {
1062
  "symbol": norm.ds4,
1063
+ "requestedLimit": requested_limit,
1064
+ "providerLimit": ds4_provider_limit,
1065
+ "returnedCandleCount": returned_candle_count,
1066
+ "candleCompleteness": candle_completeness,
1067
  "verifiedFutures": bool(futures_verification.get("verified")),
1068
  "futuresVerification": futures_verification,
1069
  "primary": ds4 or {},