- Delete back-compat shims data_provider.py / oanda_provider.py; test_all.py now imports providers.* directly (ForexDataProvider name retired; the two shim-only tests removed, 268 -> 266 tests) - Delete backtest/research/calendar_store.py (no importer, no entry point); config.py audit comment reworded - Delete executed plan doc (2026-07-21 centralized-config) and the two unbuildable guide PDFs; HTML guides remain the source of truth - OANDA_MIGRATION_ROADMAP.md status header corrected: phases 1-2 shipped 2026-07-28, feed is live on Oanda; phases 3-6 still unbuilt - Branches deleted locally + on both remotes: oanda_demo (merged), web-dashboard, eurusd-modular, reversal-exit-alert (owner-approved permanent deletion); July stash dropped Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019zR58p8ACW1oJ5QXVUZ5es
13 KiB
Full Migration to Oanda v20 as Market-Data Source — Roadmap
Status: partially executed (updated 2026-08-03). Phases 1–2 shipped 2026-07-28 via the providers/ package refactor and the live feed switched to Oanda (data_providers.active: "oanda"). Phases 3–6 remain unbuilt: the level-breach stream monitor is simply disabled for Oanda, and the pinned backtest books are still Twelve-Data-pinned at the $0.30 cost assumption. Owner decision 2026-07-24 stands: Twelve Data is a permanent fallback (no removal phase).
Context
The daemon already executes trades on an Oanda practice account, and since 2026-07-28 the signal feed (Telegram bot, CLI, backtester) also runs on Oanda. Oanda's API is free with no metering (120 req/s vs TD's 55 credits/min), serves real bid/ask spreads, and matches the actual trading calendar. The 2026-07-24 divergence study (backtest/research/oanda_td_divergence.py) proved the switch is not plumbing-only: steady-state bars differ by median $0.69 (11% of M15 ATR), bar direction agrees only 89.9%, TD prints 4,000 phantom Saturday bars/yr + 21:00 UTC rollover bars that contaminate Monday indicator windows, and Oanda's real spread ($0.72 liquid / $0.84 rollover) is ~2.4× the $0.30 the validated books assume. So the migration = plumbing (Phases 1–3, inert) + shadow evidence (4) + book re-validation with owner gates (5) + reversible cutover (6). CLAUDE.md rules 1 (no tuned-value change without re-validation) and 4 (live == backtest parity) govern throughout.
Master sequencing rule: Phases 1–3 land inert behind data_providers.active == "twelve_data". Nothing changes live behavior until the Phase 6 config flip, which stays reversible forever (TD is a permanent fallback).
Phase 1 — Provider plumbing (inert)
Files: oanda_provider.py, data_provider.py, config.py, config.json.example.
- Constructor self-resolution:
OandaDataProvider.__init__(config, api_token=None, base_url=None)— defaults resolve via existingapp_config.oanda_api_token()/oanda_base_url(); explicit args keepoanda_daemon.py:180working. Fail lazily at first fetch (mirror ForexDataProvider's_td is Nonepattern) so credential-less construction doesn't break the hermetic test header. - Registry: add
"oanda": OandaDataProvidertoPROVIDER_REGISTRY(data_provider.py:392); import at bottom ofdata_provider.py(one-direction edge, no cycle —oanda_providermust never import fromdata_provider). AddPROVIDER_NAMEclass attr to both providers. Cutover config needs an"oanda": {}block underdata_providers(creds stay in the existing top-leveloandasection / env). - Dedupe shared constants: move
CANDLE_SECONDS/BARS_PER_DAYtoconfig.py, alias as class attrs in both providers — all existing call sites (signal_aggregator.py:18,217,640,942,telegram_bot.py:1062,gpu_backtest.py:975,1706, tests) keep working unchanged. Also move_is_forex_weekendtoconfig.pyeventually (currently duplicated + sync-tested). _fetch_window(symbol, tf, start, end)on both providers — the backtester's common interface. Contract: raw closed bars labeled in [start, end], oldest-first, capitalized OHLCV, UTC index; empty DataFrame (not exception) for empty windows; caller keeps windows ≤ ~4500 bars. TD: thin wrapper over existing_fetch_twelve_data(start_date=, end_date=)(keep its exception behavior byte-identical). Oanda: newfrom/toRFC3339 request (price=MBA, dailyAlignment=0, alignmentTimezone=Etc/UTC); refactor_fetch_oanda_candles's parsing body (complete-filter, mid extraction, spread cache) into a shared_parse_candles(). The from+count quirk (Oanda'scountincludes the boundary bar; seebacktest/research/fetch_oanda_m15.py) does not apply to from/to windows. Add a no-op-by-construction_drop_forming_candleto OandaDataProvider (gpu_backtest.py:1025calls it unconditionally). No throttle needed for Oanda.- Tests (extend Module 15): registry selection; constructor self-resolution + explicit-args-win;
_fetch_windowboth providers (mock_session.get/_fetch_twelve_data); CANDLE_SECONDS/BARS_PER_DAY cross-provider sync; provider-contract hasattr test covering everything callers reach for (fetch_ohlcv, get_pair_info, clear_cache, _is_data_fresh, _has_latest_closed_bar, _validate_and_clean, _drop_forming_candle, _fetch_window, CANDLE_SECONDS, BARS_PER_DAY, _cache, _cache_timestamps, PROVIDER_NAME); port Module 11's two cache/boundary tests (test_all.py:2278,2313) to Oanda variants patching_fetch_oanda_candles. Tests that swapset_app_configmust restore_TEST_APPinfinally.
Phase 2 — Backtester provider-agnosticism
File: backtest/gpu_backtest.py (fetch_history, lines 943–1036).
- Line 984:
provider._fetch_twelve_data(...)→provider._fetch_window(...). Keep the backward-stepping chunk loop,empty_streakbail, cursor stepping byte-identical (rule 6 — this loop produced every pinned cache). - Lines 975/1706:
ForexDataProvider.BARS_PER_DAY→provider.BARS_PER_DAY. - Provider-namespaced cache keys in
_cache_path(line 938): TD keeps legacy names (pinned parquets stay loadable, needed for A/B), Oanda getsoanda_prefix (oanda_XAU_USD_15min_....parquet) — no collisions. Comment the asymmetry. clean=True/Falseraw-path semantics untouched.- Verification before merge: one M15 12-month TD replay pre/post commit — trade books must be identical. Alignment audit: fetch one month of Oanda H4 + D1 via
_fetch_window, assert label sets match TD's cached labels (H4 at 00/04/08 UTC, D1 at 00:00 UTC) — a mismatch would silently shift every MTF confirmation. - Tests:
fetch_historydispatch with a stub provider (concat/dedupe/trim, clean=False skips cleaning);_cache_pathnamespacing regression.
Phase 3 — Telegram bot: TD WebSocket → Oanda pricing stream
File: telegram_bot.py; new resolver in app_config.py.
- New
oanda_stream_url()resolver: envOANDA_STREAM_URL→ config.jsonoanda.stream_url(add to_KNOWN_OANDA) → derivedoanda_base_url().replace("api-", "stream-", 1). - Pure parser
_parse_stream_line(line) -> Optional[(pair_symbol, mid_price)]: type==PRICE → mid from closeoutBid/Ask (fallback bids[0]/asks[0]); HEARTBEAT/garbage → None._STREAM_TO_PAIRfromOandaDataProvider.OANDA_INSTRUMENT(replaces TD-display-keyed_WS_TO_PAIR). - Reader thread (no new deps):
requests.get(.../v3/accounts/{id}/pricing/stream?instruments=..., stream=True, timeout=(10,30)), iterateiter_lines(chunk_size=1)— chunk_size=1 is load-bearing (urllib3 buffering vs 5s server heartbeats); push lines to asyncio queue vialoop.call_soon_threadsafe; interrupt a blocked read withresp.close()from the async side. - Async monitor
_oanda_stream_monitor: dedicated thread (NOT the 4-worker scan executor),await asyncio.wait_for(queue.get(), timeout=15)as staleness watchdog (3× server heartbeat) → reconnect with existing exponential-backoff pattern. PRICE lines feed the identical existing body:_check_level_breach→_handle_breach→ M15 rescan, 5-min cooldown. No client heartbeat. - Provider-gated selection in
_start_scheduler(line 889):active_provider == "oanda"→ stream monitor, else existing_ws_monitor. This is the rollback hinge and stays permanently (TD fallback)._stop_schedulermust stop the reader thread. Generalize/statuslabels (_status_textiteratesSCAN_PAIRS, not_WS_TO_PAIR). - Tests:
_parse_stream_linevariants (PRICE/closeout, PRICE/arrays, HEARTBEAT, malformed, unknown instrument);_STREAM_TO_PAIRround-trip; monitor selection under bothactivevalues; watchdog-reconnect (structure one connection lifecycle as a testable inner coroutine).
Phase 4 — Shadow / parity evidence (3–4 weeks, no hot-path code)
The daemon already runs the full signal stack on Oanda bars. Formalize evidence before any cutover:
- Code parity (exact): replay the daemon's shadow window through the backtester on Oanda data (Phase 2 enables this); diff against daemon-logged decisions per (tf, bar_close_ts) — signal, quality, direction, entry/SL/TP. Acceptance: 100% modulo documented daemon divergences (single-position rule, quality gate). Any mismatch is a bug, not feed noise.
- Feed divergence (measured, not zero-gated):
backtest/research/shadow_compare.pyparseslogs/telegram.log+ daemon log into per-scan rows: signal agreement rate, direction flips, grade drift, clustering by hour/weekday. Expect high-single-digit % divergence concentrated near grade thresholds and Monday sessions (TD Saturday/rollover bars). Deliverable: written note attributing every divergence class. - Operational telemetry: boundary grace-retry frequency (is Oanda's just-closed bar present at boundary+10s?),
get_spread()time series (live input to the Phase 5 cost decision), stream disconnect counts, Monday-open indicator snapshots TD-vs-Oanda. - Owner note: at cutover the data feed host family should match the execution host (practice vs live pricing can micro-differ).
Phase 5 — Re-validation campaign (rule 1, owner gates)
Fetch Oanda history (M5/M15/H1/H4/D1, 2020-07 → present; Oanda XAU reaches ~2005). Run matrix per primary TF (M5/M15/H1, current factory configs): 12-mo train/val halves, 5-yr, OOS 2024-06→2025-03 — each at --spread 0.30 (pure feed-effect A/B vs pinned TD books), 0.70 (honest Oanda cost), 0.85 (rollover stress). Caution: Jul–Nov 2025 is provider-sensitive (weekly offsets −$6..+$18.5) — lean on 5-yr + OOS for decisions. Acceptance unchanged: positive both halves, PF ≥ 1.3, trailing exits, next-open fills, OOS positive.
Owner decision gates (never silent):
- Canonical cost — (a)
round_trip_cost=0.70everywhere (moves the live TP1 spread gate $9→$21 — a strategy re-tune); (b)default_spread=0.70for backtest PnL but retunemin_tp_cost_multiple30→~13 to preserve the validated $9 gate; (c) keep $0.30 with documented optimism (only if $0.70 books pass anyway). Full acceptance matrix at chosen values. - Degradation handling per failing TF: re-run tuning grids on Oanda data / drop TF from
scanning.timeframes/ keep Oanda execution-only (status quo). - Re-pin: passing books become the new pinned CSVs + factory-docstring evidence; CLAUDE.md tuned-value annotations update in the cutover commit, not before.
Files (per decisions): pair_signals/xauusd.py, config.py (PAIR_INFO.default_spread, SignalConfig.round_trip_cost/min_tp_cost_multiple), test_all.py tuned-constant assertions, CLAUDE.md.
Phase 6 — Cutover (reversible forever; no TD removal)
- config.json:
data_providers.active: "oanda"+"oanda": {}block; restart bot. Everything flips through two seams:make_data_provider(bot shared dp, CLI, backtester) and the Phase 3 monitor selector. Any Phase 5 tuned-value changes land in this commit with their validation records. - Bake ≥1 week (full weekly cycle incl. Monday open): grace-retry rate, stream stability, alert plausibility vs daemon,
/cachefreshness. - Rollback = flip
activeback — TD code, key, caches, and WS monitor all stay maintained indefinitely (owner decision). Dual-provider posture: cross-provider sync tests (Phase 1) keep both honest; TD subscription/key must stay valid to remain a real fallback. - Docs: CLAUDE.md (pipeline header, data_provider section, env-var table +
OANDA_STREAM_URL, backtester cache note, comparability note: "pre-Oanda books are TD-feed books"),docs/guides if they name the data source.
Key risks
- Cost-gate coupling: naively setting
round_trip_cost=0.70silently moves the live $9 TP1 gate to $21 — owner gate 5.1 exists precisely for this. - Cutover before re-pinning — feed divergence is material (11% of ATR, 10% direction flips); sequencing rule prevents it.
- Hermetic test header —
_TEST_APPpins twelve_data; provider-swapping tests must restore it or later modules fail mysteriously. - Stream liveness triad —
chunk_size=1,resp.close()interruption, 15s watchdog. - H4/D1 alignment — audit before trusting any Oanda backtest.
- Monday/rollover divergence class — attribute in Phase 4 evidence; do not "fix".
Verification (per phase)
- Phases 1–3:
python test_all.pygreen (235 + new); live behavior provably unchanged (TD active). - Phase 2: pre/post TD book identity diff; Oanda H4/D1 alignment audit.
- Phase 3: soak a second bot instance with
active=oandascratch config; observe stream reconnects in log. - Phase 5: full acceptance matrix, owner sign-offs recorded in factory docstrings.
- Phase 6: 1-week bake checklist above; rollback drill (flip back, confirm TD scan cycle) once during bake.