Approved 2026-07-24: 6-phase plan for fully switching the signal feed from Twelve Data to Oanda v20 (inert plumbing -> backtester agnosticism -> pricing-stream monitor -> shadow parity -> book re-validation with owner cost gates -> reversible cutover). TD remains a permanent fallback. Grounded in the 2026-07-24 divergence study (10% bar-direction flips, 11%-of-ATR noise, real Oanda spread ~$0.72 vs assumed $0.30). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YNvxXtgz62F7kTKZZsAAAE
12 KiB
Full Migration to Oanda v20 as Market-Data Source — Roadmap
Status: reference roadmap, not scheduled for implementation. Owner decision 2026-07-24: document only; when executed, Twelve Data stays as a permanent fallback (no removal phase).
Context
The oanda_demo branch already executes trades on an Oanda practice account, but the signal feed (Telegram bot, CLI, backtester) still runs on Twelve Data. 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.