providers: keep real news bars (snap-back bad-print rule), Oanda retry ladder, scan_kind=failed
2026-09-07 signal-log inspection follow-up — three fixes that do not depend on sample size.
- providers/base.py: the signal-side 10σ outlier filter is now a bad-print filter. A >10σ
close-to-close bar is dropped only when the NEXT close snaps back ≥80% of the way
(OUTLIER_SIGMA / OUTLIER_REVERT_FRACTION); persisting moves and the newest bar are kept.
The unconditional rule deleted ~36 real M5 news bars/yr in the backtester (whole-frame σ)
and a different ~24/yr live (fetch-window σ) — e.g. the 2026-09-04 12:30 UTC NFP bar
vanished from M5+M15 and the 12:35 scan was a byte-identical repeat of 12:30. 3-yr Oanda
drops are now M5 2 / M15 1 / H1 0.
- providers/oanda.py: transient 401/5xx/network faults retry along RETRY_DELAYS_S=(2,5,10)s
under RETRY_BUDGET_S=45s. 11 boundary scans in two days had lost their primary frame at
xx:00:22–:26 UTC; one 2 s retry could not outlast the top-of-hour blip.
- signal_aggregator/_log_signal: a scan whose primary fetch failed logs scan_kind='failed'
(price 0, no signal); analyze_signal_log.parity_report skips it like 'blocked'.
- test_all.py: 466 tests (+6): retry ladder walks all delays, 5xx blip recovers on the third
attempt, retry budget stops the ladder, persisting/newest/partially-retraced 10σ bars kept,
failed-scan row kind, parity skip.
- CLAUDE.md: cleaning rule, retry ladder, failed scan kind, dated current-state note.
Book impact at $0.65 (rule 1 still passes everywhere, both halves positive):
12mo M5 557/+112.21/PF1.56 → 560/+108.81/1.54 M15 397/+73.93/1.53 → 398/+74.61/1.53 H1 identical
3yr M5 857/+161.3/1.53 → 870/+153.6/1.50 M15 1086/+148.7/1.37 → 1092/+151.9/1.38
H1 255/+70.5/1.98 → 253/+70.0/1.98 H4 113/+22.7/1.63 → 114/+21.7/1.59
Only trades on the previously-deleted event bars move; the pinned M5/M15 CSVs are no longer
byte-reproducible and should be regenerated at the next re-pin.
Claude-Session: https://claude.ai/code/session_01NDnBxJ2y8GRUM6ZNFJs1hZ
This commit is contained in:
+37
-14
@@ -32,7 +32,11 @@ class OandaDataProvider(BaseDataProvider):
|
||||
|
||||
name = "oanda"
|
||||
DATA_ERROR = OandaDataError
|
||||
RETRY_DELAY_S = 2.0
|
||||
# Transient-fault retry ladder (see _fetch_oanda_candles): sleeps
|
||||
# between attempts, plus a wall-clock cap on the whole sequence.
|
||||
RETRY_DELAYS_S = (2.0, 5.0, 10.0)
|
||||
RETRY_BUDGET_S = 45.0
|
||||
RETRY_DELAY_S = RETRY_DELAYS_S[0] # first delay; kept for callers/tests
|
||||
|
||||
OANDA_GRANULARITY = {
|
||||
Timeframe.M1: "M1",
|
||||
@@ -128,34 +132,53 @@ class OandaDataProvider(BaseDataProvider):
|
||||
else:
|
||||
params["count"] = min(count, self.MAX_BARS_PER_REQUEST)
|
||||
|
||||
# One retry absorbs Oanda's sporadic spurious 401s (~15-20/day on
|
||||
# the practice API — the same token succeeds on adjacent requests)
|
||||
# plus transient 5xx/network faults. A real credential problem
|
||||
# fails both attempts and raises exactly as before. Other 4xx
|
||||
# (bad request, 429) are never retried.
|
||||
# Retry ladder for transient faults: Oanda's sporadic spurious
|
||||
# 401s (~15-20/day on the practice API — the same token succeeds
|
||||
# on adjacent requests), 5xx and network errors. Other 4xx (bad
|
||||
# request, 429) are never retried; a real credential problem
|
||||
# fails every attempt and raises as before. The 2026-09-07
|
||||
# signal-log audit found 11 boundary scans in two days that lost
|
||||
# their primary frame at xx:00:22-:26 UTC: a single 2 s retry
|
||||
# could not outlast a 10-15 s provider blip at the top of the
|
||||
# hour, and a scan without its primary frame logs a price-0
|
||||
# NEUTRAL row. RETRY_BUDGET_S bounds the whole sequence so a scan
|
||||
# (three fetches) still finishes well inside an M5 bar.
|
||||
last_err: Optional[OandaDataError] = None
|
||||
for attempt in range(2):
|
||||
resp = None
|
||||
attempts = 1 + len(self.RETRY_DELAYS_S)
|
||||
started = time.monotonic()
|
||||
for attempt in range(attempts):
|
||||
if attempt:
|
||||
time.sleep(self.RETRY_DELAY_S)
|
||||
delay = self.RETRY_DELAYS_S[attempt - 1]
|
||||
if time.monotonic() - started + delay > self.RETRY_BUDGET_S:
|
||||
logger.warning(
|
||||
f"Oanda fetch for {instrument}: retry budget "
|
||||
f"({self.RETRY_BUDGET_S:.0f}s) exhausted after "
|
||||
f"{attempt} attempt(s) — giving up")
|
||||
break
|
||||
time.sleep(delay)
|
||||
try:
|
||||
resp = self._session.get(url, params=params, timeout=30)
|
||||
except requests.RequestException as e:
|
||||
last_err = OandaDataError(
|
||||
f"Oanda candle fetch failed for {instrument}: {e}")
|
||||
last_err.__cause__ = e
|
||||
resp = None
|
||||
logger.warning(
|
||||
f"Oanda fetch for {instrument} attempt "
|
||||
f"{attempt + 1}/{attempts} failed: {e}")
|
||||
continue
|
||||
if resp.status_code == 401 or resp.status_code >= 500:
|
||||
last_err = OandaDataError(
|
||||
f"Oanda candle fetch failed for {instrument}: "
|
||||
f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
if attempt == 0:
|
||||
logger.warning(
|
||||
f"Oanda fetch for {instrument} got HTTP "
|
||||
f"{resp.status_code}; retrying in "
|
||||
f"{self.RETRY_DELAY_S}s")
|
||||
logger.warning(
|
||||
f"Oanda fetch for {instrument} attempt "
|
||||
f"{attempt + 1}/{attempts} got HTTP {resp.status_code}")
|
||||
resp = None
|
||||
continue
|
||||
break
|
||||
else:
|
||||
if resp is None:
|
||||
raise last_err
|
||||
|
||||
if resp.status_code != 200:
|
||||
|
||||
Reference in New Issue
Block a user