Remove Oanda auto-execution daemon/executor (not auto-trading)
Deletes oanda_daemon.py, oanda_executor.py, their 28 tests and the daemon-only config keys (oanda.lot_size/quality_gate/max_hold_bars). The Oanda data provider and credentials stay — it is the live candle feed. 402 tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC4s6VCURFte4dub4xKxYL
This commit is contained in:
@@ -4,18 +4,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
Signal-intelligence platform for **XAU/USD (gold — the only supported pair)**. It fetches OHLCV from the active data provider (**Oanda v20 since 2026-07-28**; Twelve Data and Tiingo remain registered fallbacks), scores 9 technical indicators per timeframe with regime-adaptive weights, checks multi-timeframe agreement and news risk, grades the signal (A+…F), and generates a full trade setup (entry, SL, TP1/TP2, trailing plan). Delivery: Telegram bot, CLI, and an Oanda practice-account auto-execution daemon. A realism-focused backtester replays history through the *exact same* signal code.
|
Signal-intelligence platform for **XAU/USD (gold — the only supported pair)**. It fetches OHLCV from the active data provider (**Oanda v20 since 2026-07-28**; Twelve Data and Tiingo remain registered fallbacks), scores 9 technical indicators per timeframe with regime-adaptive weights, checks multi-timeframe agreement and news risk, grades the signal (A+…F), and generates a full trade setup (entry, SL, TP1/TP2, trailing plan). Delivery: Telegram bot and CLI. A realism-focused backtester replays history through the *exact same* signal code.
|
||||||
|
|
||||||
Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtested or is load-bearing for a backtested result. Validation evidence lives in the factory/module docstrings and in `~/.claude/projects/.../memory/`.
|
Every numeric threshold in `config.py` and `pair_signals/xauusd.py` was backtested or is load-bearing for a backtested result. Validation evidence lives in the factory/module docstrings and in `~/.claude/projects/.../memory/`.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python test_all.py # QA suite: 430 tests, 27 modules, no network (mocked)
|
python test_all.py # QA suite: 402 tests, 27 modules, no network (mocked)
|
||||||
python main.py --pair XAUUSD --timeframe H1 --detail # CLI signal report (--json for full dump,
|
python main.py --pair XAUUSD --timeframe H1 --detail # CLI signal report (--json for full dump,
|
||||||
# --panel to run the advisory LLM agent panel on-demand)
|
# --panel to run the advisory LLM agent panel on-demand)
|
||||||
python telegram_bot.py # the bot (refuses to boot without credentials)
|
python telegram_bot.py # the bot (refuses to boot without credentials)
|
||||||
python oanda_daemon.py # auto-execution daemon on Oanda practice account
|
|
||||||
python dashboard_publisher.py --once # web dashboard: one scan+publish cycle to $DASHBOARD_OUT_DIR
|
python dashboard_publisher.py --once # web dashboard: one scan+publish cycle to $DASHBOARD_OUT_DIR
|
||||||
# (omit --once for the candle-close daemon; deploy/README-dashboard.md)
|
# (omit --once for the candle-close daemon; deploy/README-dashboard.md)
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ providers/<active>.fetch_ohlcv() per TF: single-flight fetch, drop forming
|
|||||||
→ signal_aggregator.analyze_pair() live entry point (weekend/day filters, fetch, staleness)
|
→ signal_aggregator.analyze_pair() live entry point (weekend/day filters, fetch, staleness)
|
||||||
└─ analyze_from_dataframes() replayable core: MTF agreement → news → bonuses/penalties →
|
└─ analyze_from_dataframes() replayable core: MTF agreement → news → bonuses/penalties →
|
||||||
final signal + quality grade → gates → trade setup → reasons
|
final signal + quality grade → gates → trade setup → reasons
|
||||||
→ SignalReport consumed by telegram_bot.py, main.py, oanda_daemon.py, backtester
|
→ SignalReport consumed by telegram_bot.py, main.py, backtester
|
||||||
```
|
```
|
||||||
|
|
||||||
Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz-aware, oldest-first, capitalized OHLCV columns. Everything runs on **closed bars only** — the forming candle is dropped, and session/hour filters evaluate **bar-CLOSE time** (bar label + candle duration) identically in live and backtest.
|
Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz-aware, oldest-first, capitalized OHLCV columns. Everything runs on **closed bars only** — the forming candle is dropped, and session/hour filters evaluate **bar-CLOSE time** (bar label + candle duration) identically in live and backtest.
|
||||||
@@ -59,13 +58,12 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz
|
|||||||
- **`engine.py`** — `TechnicalEngine`: 9 scored indicators (ema_trend, rsi, macd, bollinger, adx, roc, keltner, divergence, candlestick) + ATR as context. Regime classification (trending_up/down, `trending` = mixed/direction-UNKNOWN, transitioning, ranging) drives per-indicator weight multipliers and regime-aware RSI reinterpretation. Compute order matters (ATR first; MACD always computed even when not a pillar — divergence needs the histogram). Private series ride on the result as `_underscore` attrs; scorers/formatters skip anything `_`-prefixed or weight-0.
|
- **`engine.py`** — `TechnicalEngine`: 9 scored indicators (ema_trend, rsi, macd, bollinger, adx, roc, keltner, divergence, candlestick) + ATR as context. Regime classification (trending_up/down, `trending` = mixed/direction-UNKNOWN, transitioning, ranging) drives per-indicator weight multipliers and regime-aware RSI reinterpretation. Compute order matters (ATR first; MACD always computed even when not a pillar — divergence needs the histogram). Private series ride on the result as `_underscore` attrs; scorers/formatters skip anything `_`-prefixed or weight-0.
|
||||||
- **`signal_aggregator.py`** — MTF agreement, diminishing-decay penalty system, exhaustion hard block, quality grading, then an ordered chain of post-grade gates (session, XAU SELL quality/vol/hour, regime blocks, M5 noise gates), then three setup generators: XAU ATR (A+/A), generic swing-based, plus the M5 pullback (`XAU_PB`) and chart-pattern (`XAU_CP`, `chart_patterns.py`) entry paths. The PB/CP generators run AFTER the gate chain and force quality to "B"; the CP path deliberately accepts grade-F composites and re-checks only session/day/big-candle (via warning prefixes) plus its own SELL/vol/RSI gates — it does NOT re-check exhaustion or the M5 min-score/MTF gates. **This is validated behavior, not a bug** (2026-08-04 audit: 226/586 trades in the shipped M5 12mo book are XAU_CP, +38.9R, 104 of them exhaustion-flagged and net-positive) — changing it is a rule-1 revalidation event. `TradeSetup.strategy` selects trailing params; trail params are stamped on the setup from the *producing* config so per-TF overrides reach formatters.
|
- **`signal_aggregator.py`** — MTF agreement, diminishing-decay penalty system, exhaustion hard block, quality grading, then an ordered chain of post-grade gates (session, XAU SELL quality/vol/hour, regime blocks, M5 noise gates), then three setup generators: XAU ATR (A+/A), generic swing-based, plus the M5 pullback (`XAU_PB`) and chart-pattern (`XAU_CP`, `chart_patterns.py`) entry paths. The PB/CP generators run AFTER the gate chain and force quality to "B"; the CP path deliberately accepts grade-F composites and re-checks only session/day/big-candle (via warning prefixes) plus its own SELL/vol/RSI gates — it does NOT re-check exhaustion or the M5 min-score/MTF gates. **This is validated behavior, not a bug** (2026-08-04 audit: 226/586 trades in the shipped M5 12mo book are XAU_CP, +38.9R, 104 of them exhaustion-flagged and net-positive) — changing it is a rule-1 revalidation event. `TradeSetup.strategy` selects trailing params; trail params are stamped on the setup from the *producing* config so per-TF overrides reach formatters.
|
||||||
- **`pair_signals/`** — per-pair config factories. **Always use the factory (`produce_signal` / `make_config`), never bare `PlatformConfig()`** — the factory applies the config.json overlay first, then the validated per-TF deltas (M5 hour-9 block, M15 trail arm 0.70, M15 SELL-hour block, M5 SELL-in-transitioning block, H1 all-shorts block (2026-09-03), etc.; evidence in the docstring). A test enforces factory ≡ inline-config equivalence. Confirmation map: M5→[M15,H1], M15→[M5,H1], H1→[M15,H4], H4→[H1,D1].
|
- **`pair_signals/`** — per-pair config factories. **Always use the factory (`produce_signal` / `make_config`), never bare `PlatformConfig()`** — the factory applies the config.json overlay first, then the validated per-TF deltas (M5 hour-9 block, M15 trail arm 0.70, M15 SELL-hour block, M5 SELL-in-transitioning block, H1 all-shorts block (2026-09-03), etc.; evidence in the docstring). A test enforces factory ≡ inline-config equivalence. Confirmation map: M5→[M15,H1], M15→[M5,H1], H1→[M15,H4], H4→[H1,D1].
|
||||||
- **`signal_log.py` / `advisory_outcome.py`** — SQLite signal-history logger (stdlib only). Hooked into `SignalAggregator.analyze_pair` only (the live entry point — the backtester never writes here, preserving parity), so every live scan from every surface (Telegram `telegram`, CLI `cli`, Oanda daemon `oanda_daemon`, dashboard `dashboard`) lands one row in `logs/signals.db`: signal/grade/price, MTF + trade-setup + reasons JSON, full per-TF indicator snapshots, the last 14 closed OHLCV candles per fetched TF, and the news at scan time (event risk + calendar events + scored headlines, re-fetched via the NewsEngine class-level caches). Config.json `signal_log` block (`enabled`, `path`, `log_blocked_scans`; env `SIGNAL_LOG_ENABLED`/`SIGNAL_LOG_PATH`/`SIGNAL_LOG_BLOCKED_SCANS`). **Fail-safe by contract** — a logging failure can never break a scan. Panel fields arrive post-hoc: `log_signal` stashes the rowid and `agent_panel.attach_panel` writes verdict + creation/cache provenance + payload hash onto the same row. **Advisory outcomes (2026-09):** EVERY row with a trade setup is seeded at log time (not only panel rows); same-setup re-logs within 60 min from any surface (`setup_key` = tf|direction|entry|stop|strategy) are `DUPLICATE` rows pointing at the canonical row via `advisory_event_id`, so sum `advisory_r_multiple` over `RESOLVED` rows only. The versioned model advances from later stored closed-candle snapshots (next-open reanchoring, intrabar peak with close-only ratchet, SL-first, scheduled weekend/21:00-UTC closures allowed, 300-bar timeout, $0.30/$0.65 views); when a snapshot no longer overlaps the outcome's last bar it rebuilds the stream from every stored snapshot of that TF before giving up. It is a cleaned-mid evidence model, not execution P&L. **Post-gate truth columns:** `gate_reason` (the warning that removed the setup), `actionable` (grade ≥ C AND setup — the `action` text is written BEFORE the gate chain and can say "STRONG SELL — High-quality setup" on a D row), `cleaned_warnings_json` (what the XAU_CP acceptance stripped), `bar_range_median` (= the misnamed `spread` column: median High-Low, NOT a broker spread), `scan_kind` (`blocked` weekend/day early returns carry no news/candles), `config_fingerprint` (gate config hash — surfaces config drift between processes). **News is content-addressed:** `headlines_json`/`events_json` are interned into `news_snapshots` (they were 73% of a 667 MB DB); read through the `signals_with_news` view or `signal_log.read_news`. Readers must open with `mode=ro`, never `immutable=1` (blind to WAL rows). Tools: `analyze_signal_log.py` (`--stored-candles` DB-only replay with event/opportunity/one-position books + persona scorecard, `--parity` telegram-vs-dashboard divergence report), `scripts/backfill_signal_outcomes.py` (seed/dedup/advance legacy rows), `scripts/migrate_signal_log_news.py` (intern legacy blobs; `--vacuum` with both writers stopped).
|
- **`signal_log.py` / `advisory_outcome.py`** — SQLite signal-history logger (stdlib only). Hooked into `SignalAggregator.analyze_pair` only (the live entry point — the backtester never writes here, preserving parity), so every live scan from every surface (Telegram `telegram`, CLI `cli`, dashboard `dashboard`) lands one row in `logs/signals.db`: signal/grade/price, MTF + trade-setup + reasons JSON, full per-TF indicator snapshots, the last 14 closed OHLCV candles per fetched TF, and the news at scan time (event risk + calendar events + scored headlines, re-fetched via the NewsEngine class-level caches). Config.json `signal_log` block (`enabled`, `path`, `log_blocked_scans`; env `SIGNAL_LOG_ENABLED`/`SIGNAL_LOG_PATH`/`SIGNAL_LOG_BLOCKED_SCANS`). **Fail-safe by contract** — a logging failure can never break a scan. Panel fields arrive post-hoc: `log_signal` stashes the rowid and `agent_panel.attach_panel` writes verdict + creation/cache provenance + payload hash onto the same row. **Advisory outcomes (2026-09):** EVERY row with a trade setup is seeded at log time (not only panel rows); same-setup re-logs within 60 min from any surface (`setup_key` = tf|direction|entry|stop|strategy) are `DUPLICATE` rows pointing at the canonical row via `advisory_event_id`, so sum `advisory_r_multiple` over `RESOLVED` rows only. The versioned model advances from later stored closed-candle snapshots (next-open reanchoring, intrabar peak with close-only ratchet, SL-first, scheduled weekend/21:00-UTC closures allowed, 300-bar timeout, $0.30/$0.65 views); when a snapshot no longer overlaps the outcome's last bar it rebuilds the stream from every stored snapshot of that TF before giving up. It is a cleaned-mid evidence model, not execution P&L. **Post-gate truth columns:** `gate_reason` (the warning that removed the setup), `actionable` (grade ≥ C AND setup — the `action` text is written BEFORE the gate chain and can say "STRONG SELL — High-quality setup" on a D row), `cleaned_warnings_json` (what the XAU_CP acceptance stripped), `bar_range_median` (= the misnamed `spread` column: median High-Low, NOT a broker spread), `scan_kind` (`blocked` weekend/day early returns carry no news/candles), `config_fingerprint` (gate config hash — surfaces config drift between processes). **News is content-addressed:** `headlines_json`/`events_json` are interned into `news_snapshots` (they were 73% of a 667 MB DB); read through the `signals_with_news` view or `signal_log.read_news`. Readers must open with `mode=ro`, never `immutable=1` (blind to WAL rows). Tools: `analyze_signal_log.py` (`--stored-candles` DB-only replay with event/opportunity/one-position books + persona scorecard, `--parity` telegram-vs-dashboard divergence report), `scripts/backfill_signal_outcomes.py` (seed/dedup/advance legacy rows), `scripts/migrate_signal_log_news.py` (intern legacy blobs; `--vacuum` with both writers stopped).
|
||||||
- **`news_engine.py`** — Forex Factory calendar (MyFXBook RSS fallback) + RSS keyword sentiment with gold-inverse keywords. Class-level caches (fresh engine per scan). Optional DeepSeek enrichment via `llm_client.py` (requests-only, no SDK): batched headline sentiment overriding keyword scores, gold-direction on release surprises, and a market-summary blurb (`SignalReport.news_summary`) — **display/warning-only, keyword fallback on any failure, inert without a key**; prompts are static module constants (DeepSeek prefix-cache pricing) and requests send `thinking: disabled` (V4-Flash reasoning otherwise blows the timeout). By XAU-tuned default, event risk and sentiment **warn but do not gate signals** (`event_risk_caps_signals=False`, `sentiment_affects_signals=False`) — the LLM does not change that.
|
- **`news_engine.py`** — Forex Factory calendar (MyFXBook RSS fallback) + RSS keyword sentiment with gold-inverse keywords. Class-level caches (fresh engine per scan). Optional DeepSeek enrichment via `llm_client.py` (requests-only, no SDK): batched headline sentiment overriding keyword scores, gold-direction on release surprises, and a market-summary blurb (`SignalReport.news_summary`) — **display/warning-only, keyword fallback on any failure, inert without a key**; prompts are static module constants (DeepSeek prefix-cache pricing) and requests send `thinking: disabled` (V4-Flash reasoning otherwise blows the timeout). By XAU-tuned default, event risk and sentiment **warn but do not gate signals** (`event_risk_caps_signals=False`, `sentiment_affects_signals=False`) — the LLM does not change that.
|
||||||
- **`agent_panel.py`** — ai-hedge-fund-style advisory panel: 4 DeepSeek persona calls (macro/technical/risk/contrarian, concurrent on a panel-private pool) + 1 synthesizer producing an **ADVISORY-ONLY** consensus (`AGREE/NEUTRAL/OPPOSE` + confidence + rationale) written onto `SignalReport.panel_*` fields. Attached **post-hoc** by consumers (`telegram_bot` alert path, `main --panel`, `dashboard_publisher`) — never runs inside `analyze_from_dataframes` (backtest parity, test-locked) and no gate reads its fields. Persona payloads are role-filtered: macro gets high-impact events plus only code-scored directional news; technical gets strategy/pattern, primary indicators, MTF, levels, and ATR-normalized candles; risk gets setup strategy, strategy cost assumption, trailing semantics, volatility/events/warnings (never the median candle range as spread); contrarian gets directional news, setup, timestamped latest-closed price, indicators, reasons, and warnings. Spend is bounded by exact cache, full-evidence-identity TTL reuse, and `max_panels_per_day`; changed evidence never inherits a direction-only cached verdict (test-locked — which also means TTL reuse effectively never fires live because price/candles change every bar; the daily cap is the real spend bound). Verdict provenance logs creation time, `MISS`/`EXACT_HIT`/`TTL_REUSE`, and a SHA-256 payload hash. Prompts forbid outside knowledge and outputs remain short. **OPPOSE guard (2026-09):** the 3-week signal-log scorecard showed the contrarian/risk/technical personas oppose almost every trend entry (chair said OPPOSE 73% of the time; opposed trades made +30R, PF 2.6) while the macro persona was the only informative voice (macro=support → 88% win). `_apply_oppose_guard` therefore downgrades a chair OPPOSE to NEUTRAL unless macro opposes, or technical opposes while macro is not supportive; the synthesizer prompt states the same rule. Re-score with `analyze_signal_log.py --stored-candles` (persona scorecard) before changing it again.
|
- **`agent_panel.py`** — ai-hedge-fund-style advisory panel: 4 DeepSeek persona calls (macro/technical/risk/contrarian, concurrent on a panel-private pool) + 1 synthesizer producing an **ADVISORY-ONLY** consensus (`AGREE/NEUTRAL/OPPOSE` + confidence + rationale) written onto `SignalReport.panel_*` fields. Attached **post-hoc** by consumers (`telegram_bot` alert path, `main --panel`, `dashboard_publisher`) — never runs inside `analyze_from_dataframes` (backtest parity, test-locked) and no gate reads its fields. Persona payloads are role-filtered: macro gets high-impact events plus only code-scored directional news; technical gets strategy/pattern, primary indicators, MTF, levels, and ATR-normalized candles; risk gets setup strategy, strategy cost assumption, trailing semantics, volatility/events/warnings (never the median candle range as spread); contrarian gets directional news, setup, timestamped latest-closed price, indicators, reasons, and warnings. Spend is bounded by exact cache, full-evidence-identity TTL reuse, and `max_panels_per_day`; changed evidence never inherits a direction-only cached verdict (test-locked — which also means TTL reuse effectively never fires live because price/candles change every bar; the daily cap is the real spend bound). Verdict provenance logs creation time, `MISS`/`EXACT_HIT`/`TTL_REUSE`, and a SHA-256 payload hash. Prompts forbid outside knowledge and outputs remain short. **OPPOSE guard (2026-09):** the 3-week signal-log scorecard showed the contrarian/risk/technical personas oppose almost every trend entry (chair said OPPOSE 73% of the time; opposed trades made +30R, PF 2.6) while the macro persona was the only informative voice (macro=support → 88% win). `_apply_oppose_guard` therefore downgrades a chair OPPOSE to NEUTRAL unless macro opposes, or technical opposes while macro is not supportive; the synthesizer prompt states the same rule. Re-score with `analyze_signal_log.py --stored-candles` (persona scorecard) before changing it again.
|
||||||
- **`market_outlook.py`** — hourly DeepSeek market brief for XAU/USD, using ATR-normalized M15/H1/H4 candles, visible indicator states, levels, news, and recent signal history (read `mode=ro`). It returns a written **ADVISORY-ONLY** bias/setup-forming assessment and persists it independently in `outlooks` within `logs/signals.db` together with the news, indicator/regime/level, and candle evidence sent to the model. It may be sent post-scan by Telegram or requested with `main.py --outlook`; it never imports or runs inside the signal path. Hourly dedup is by UTC hour BUCKET (the old `now − last < 3600` rule dropped every other hour because the H1 loop fires at boundary+10 s+scan time) plus its own UTC-day cap (default 12); every silent early return logs `market outlook: skipped (...)` and an empty LLM reply logs a warning — a dark feature must be visible in `logs/telegram.log`. Provider/news failures leave a safe partial brief while LLM/database failures degrade to no outlook.
|
- **`market_outlook.py`** — hourly DeepSeek market brief for XAU/USD, using ATR-normalized M15/H1/H4 candles, visible indicator states, levels, news, and recent signal history (read `mode=ro`). It returns a written **ADVISORY-ONLY** bias/setup-forming assessment and persists it independently in `outlooks` within `logs/signals.db` together with the news, indicator/regime/level, and candle evidence sent to the model. It may be sent post-scan by Telegram or requested with `main.py --outlook`; it never imports or runs inside the signal path. Hourly dedup is by UTC hour BUCKET (the old `now − last < 3600` rule dropped every other hour because the H1 loop fires at boundary+10 s+scan time) plus its own UTC-day cap (default 12); every silent early return logs `market outlook: skipped (...)` and an empty LLM reply logs a warning — a dark feature must be visible in `logs/telegram.log`. Provider/news failures leave a safe partial brief while LLM/database failures degrade to no outlook.
|
||||||
- **`telegram_bot.py`** — owner-locked bot + subscriber channel; candle-boundary auto-scan loops (M5/M15/H1/H4, boundary+10 s), alert quality threshold + fingerprint dedup (committed only after a successful send; the fingerprint is signal+direction — grade is NOT part of it, a grade UPGRADE re-alerts, an A↔B flicker does not), 📰 LLM market-context line on alerts when available, WebSocket level-breach monitor fed by the streaming provider (`data_providers.streaming`, falling back to the active provider; auto-disabled when the resolved provider can't stream or has no API key — Oanda does not stream, Tiingo does). The breach handler reuses this bar's M15 report (`_cached_or_scan`, keyed by closed-bar epoch) instead of rescanning — it used to log 3.5 M15 rows per candle. Rotating log: `logs/telegram.log`, 3-h rotation × 240 backups.
|
- **`telegram_bot.py`** — owner-locked bot + subscriber channel; candle-boundary auto-scan loops (M5/M15/H1/H4, boundary+10 s), alert quality threshold + fingerprint dedup (committed only after a successful send; the fingerprint is signal+direction — grade is NOT part of it, a grade UPGRADE re-alerts, an A↔B flicker does not), 📰 LLM market-context line on alerts when available, WebSocket level-breach monitor fed by the streaming provider (`data_providers.streaming`, falling back to the active provider; auto-disabled when the resolved provider can't stream or has no API key — Oanda does not stream, Tiingo does). The breach handler reuses this bar's M15 report (`_cached_or_scan`, keyed by closed-bar epoch) instead of rescanning — it used to log 3.5 M15 rows per candle. Rotating log: `logs/telegram.log`, 3-h rotation × 240 backups.
|
||||||
- **`oanda_daemon.py` / `oanda_executor.py`** — practice-account auto-execution. Book-parity rules (see daemon docstring): no broker-side TP (trailing model — TP1/TP2 advisory), 5-bar per-TF cooldown (wall-clock approximation, so it expires over weekends where the backtester would still count bars), trail ratchets at bar close once per *observed* bar (a missed/failed cycle skips those bars entirely — the intra-gap high/low never enters the peak). Accepted divergence: one position total; orders send `positionFill=OPEN_ONLY` so a state/broker divergence cancels the order instead of silently netting. State/broker re-sync runs at boot and after any suspicious fill (`_reconcile_positions`).
|
- **`dashboard_publisher.py` / `static/dashboard.html`** — web dashboard as a **static publisher** (live LAMP server): standalone daemon scans at candle closes via `produce_signal` and atomically writes `snapshot.json`/`news.json`/`dashboard.html` to `$DASHBOARD_OUT_DIR` (Apache serves them; page is public by owner decision). **Hard independence rule: never imports, and is never imported by, `telegram_bot`** — confirmation map and boundary math are deliberate local copies, so a web-side failure can only mean a stale page. Due-TF classification uses the RAW wake time (flooring wake−buffer skips every H1/H4 scan — 2026-08-02 bug). News panel reads `NewsEngine.get_fundamental_context()` plus the additive `get_week_events()` accessor (display-only; no signal path calls it); as a separate process it has its own news caches and DeepSeek daily cap. News/calendar rows are clickable (detail dialog); headlines carry `id` = sha1(headline) in news.json, and exactly one on-demand endpoint exists: `static/news_summary.php` (installed by `install_static`), which serves a DeepSeek gold-impact summary per headline — whitelist-gated against news.json ids, cache-forever in `ai/`, own fail-closed daily cap (150/day, file-based, separate from the publisher's), key at `/etc/gold-dashboard/deepseek.key`, never reads repo config. Deployment checklist: `deploy/README-dashboard.md`.
|
||||||
- **`dashboard_publisher.py` / `static/dashboard.html`** — web dashboard as a **static publisher** (live LAMP server): standalone daemon scans at candle closes via `produce_signal` and atomically writes `snapshot.json`/`news.json`/`dashboard.html` to `$DASHBOARD_OUT_DIR` (Apache serves them; page is public by owner decision). **Hard independence rule: never imports, and is never imported by, `telegram_bot`/`oanda_daemon`** — confirmation map and boundary math are deliberate local copies, so a web-side failure can only mean a stale page. Due-TF classification uses the RAW wake time (flooring wake−buffer skips every H1/H4 scan — 2026-08-02 bug). News panel reads `NewsEngine.get_fundamental_context()` plus the additive `get_week_events()` accessor (display-only; no signal path calls it); as a separate process it has its own news caches and DeepSeek daily cap. News/calendar rows are clickable (detail dialog); headlines carry `id` = sha1(headline) in news.json, and exactly one on-demand endpoint exists: `static/news_summary.php` (installed by `install_static`), which serves a DeepSeek gold-impact summary per headline — whitelist-gated against news.json ids, cache-forever in `ai/`, own fail-closed daily cap (150/day, file-based, separate from the publisher's), key at `/etc/gold-dashboard/deepseek.key`, never reads repo config. Deployment checklist: `deploy/README-dashboard.md`.
|
|
||||||
- **`backtest/gpu_backtest.py`** — bar-by-bar replay through the real `analyze_from_dataframes` (news off). Realism model (do not regress): next-bar-open entries re-anchored to the raw price path, round-trip `--spread` charged per trade, trade resolution on RAW bars (cleaning applies only to signal-side data), same-bar SL+TP tie → SL first, gap fills at bar open, trail ratchet at bar close only. Indicators are precomputed once per TF (CuPy GPU or NumPy fallback — check the `Backend:` log line); replay fans out over worker processes and the 5-bar cooldown is applied in an ordered pass afterwards, so parallel and sequential books are identical. Parquet cache in `backtest/cache/` keyed by provider/symbol/TF/window. `backtest/*.csv` are gitignored pinned trade books from past campaigns — used for manual before/after comparison. `backtest/instructions_for_backtest.txt` lists what every backtest report must record.
|
- **`backtest/gpu_backtest.py`** — bar-by-bar replay through the real `analyze_from_dataframes` (news off). Realism model (do not regress): next-bar-open entries re-anchored to the raw price path, round-trip `--spread` charged per trade, trade resolution on RAW bars (cleaning applies only to signal-side data), same-bar SL+TP tie → SL first, gap fills at bar open, trail ratchet at bar close only. Indicators are precomputed once per TF (CuPy GPU or NumPy fallback — check the `Backend:` log line); replay fans out over worker processes and the 5-bar cooldown is applied in an ordered pass afterwards, so parallel and sequential books are identical. Parquet cache in `backtest/cache/` keyed by provider/symbol/TF/window. `backtest/*.csv` are gitignored pinned trade books from past campaigns — used for manual before/after comparison. `backtest/instructions_for_backtest.txt` lists what every backtest report must record.
|
||||||
|
|
||||||
## Rules for making changes (read before editing signal logic)
|
## Rules for making changes (read before editing signal logic)
|
||||||
@@ -85,13 +83,14 @@ Signal currency everywhere is `SignalStrength` (−2..+2). DataFrames are UTC tz
|
|||||||
- **Weekend calendar: FIXED (owner-approved 2026-08-04).** All three signal-path `_is_forex_weekend` copies are NY-anchored (Fri 17:00 → Sun 18:00 ET; during US DST that is Fri 21:00 UTC — measured on 5yr of Oanda bars). Kills the weekly false-"STALE DATA/feed lagging" hours. Book impact at the audit HEAD: M15 byte-identical; M5 12mo dropped exactly ONE trade — a phantom whose signal fired on the final summer-Friday bar (close 21:00 UTC) and "filled" at Sunday's reopen 49 h later (−1.08R, live-impossible), leaving 555 trades / +98.64R / PF≈1.50 @ $0.65 (was 556/+97.56/1.48). The current-HEAD canonical reference is recorded below. A Module 21 test sweeps those copies plus the advisory outcome's continuity copy hourly across both DST transitions.
|
- **Weekend calendar: FIXED (owner-approved 2026-08-04).** All three signal-path `_is_forex_weekend` copies are NY-anchored (Fri 17:00 → Sun 18:00 ET; during US DST that is Fri 21:00 UTC — measured on 5yr of Oanda bars). Kills the weekly false-"STALE DATA/feed lagging" hours. Book impact at the audit HEAD: M15 byte-identical; M5 12mo dropped exactly ONE trade — a phantom whose signal fired on the final summer-Friday bar (close 21:00 UTC) and "filled" at Sunday's reopen 49 h later (−1.08R, live-impossible), leaving 555 trades / +98.64R / PF≈1.50 @ $0.65 (was 556/+97.56/1.48). The current-HEAD canonical reference is recorded below. A Module 21 test sweeps those copies plus the advisory outcome's continuity copy hourly across both DST transitions.
|
||||||
- **Spread in exit-trigger detection (rule 6): owner DECLINED 2026-08-04.** Backtest stop triggers stay on raw mid High/Low; the measured optimism (~4–6% of total R: −3.55R @ $0.30, −4.24R @ $0.65 on the M15 12mo book) is accepted and books stay comparable. Re-open only as a conscious re-pin campaign.
|
- **Spread in exit-trigger detection (rule 6): owner DECLINED 2026-08-04.** Backtest stop triggers stay on raw mid High/Low; the measured optimism (~4–6% of total R: −3.55R @ $0.30, −4.24R @ $0.65 on the M15 12mo book) is accepted and books stay comparable. Re-open only as a conscious re-pin campaign.
|
||||||
- **`FB2_EMA_LO`: HARDENED 2026-08-04 (delegated).** Research override for the EMA200-proximity band, default = tuned 0.5; an unparseable value now degrades to the default with a warning instead of crashing every scan. Documented here — it is a knob that exists.
|
- **`FB2_EMA_LO`: HARDENED 2026-08-04 (delegated).** Research override for the EMA200-proximity band, default = tuned 0.5; an unparseable value now degrades to the default with a warning instead of crashing every scan. Documented here — it is a knob that exists.
|
||||||
- **H4 seeding divergence: parked.** Live H4 fetches only 380 bars vs the backtest's unbounded history — ~2% of sampled H4 bars flip an indicator signal (composite Δ up to 12.3). M5/M15/H1 clean at their real fetch depths. Fold into any future H4 campaign; H4 remains "unproven for auto-execution".
|
- **H4 seeding divergence: parked.** Live H4 fetches only 380 bars vs the backtest's unbounded history — ~2% of sampled H4 bars flip an indicator signal (composite Δ up to 12.3). M5/M15/H1 clean at their real fetch depths. Fold into any future H4 campaign; H4 remains "unproven".
|
||||||
|
|
||||||
## Current state notes (2026-08-14)
|
## Current state notes (2026-08-14)
|
||||||
|
|
||||||
- Oanda v20 is the live feed (`data_providers.active: "oanda"`). The 2026-08-04 re-pin made the 27 `backtest/oanda_*.csv` books the baseline; all TD-era books are superseded. Current-HEAD M5 canonical book: `backtest/oanda_m5_12mo_065_nopb_20260814.csv`, +106.82R PF 1.51 at $0.65 (both halves positive; PF 1.46 / 1.55). M15 reference: +73.93R PF 1.53. See `backtest/research/repin_verification_2026-08-14.md`.
|
- Oanda v20 is the live feed (`data_providers.active: "oanda"`). The 2026-08-04 re-pin made the 27 `backtest/oanda_*.csv` books the baseline; all TD-era books are superseded. Current-HEAD M5 canonical book: `backtest/oanda_m5_12mo_065_nopb_20260814.csv`, +106.82R PF 1.51 at $0.65 (both halves positive; PF 1.46 / 1.55). M15 reference: +73.93R PF 1.53. See `backtest/research/repin_verification_2026-08-14.md`.
|
||||||
- Cost reporting is dual: $0.65 is the measured-Oanda realistic headline ($0.65-$0.72 round trip), while $0.30 remains for cross-book comparability and as the live TP spread-gate input. Raising `round_trip_cost` from $0.30 would move the TP floor from about $9 to $19.50 and requires full rule-1 revalidation.
|
- Cost reporting is dual: $0.65 is the measured-Oanda realistic headline ($0.65-$0.72 round trip), while $0.30 remains for cross-book comparability and as the live TP spread-gate input. Raising `round_trip_cost` from $0.30 would move the TP floor from about $9 to $19.50 and requires full rule-1 revalidation.
|
||||||
- Twelve Data is signal-incompatible with Oanda (Phase 1: about 19% M5 bar-direction disagreement and a systematic ~$1 high bias; see `backtest/research/feed_divergence_2026-08-14.md`). On an Oanda candle outage, halt auto-execution rather than switching to TD candles.
|
- Twelve Data is signal-incompatible with Oanda (Phase 1: about 19% M5 bar-direction disagreement and a systematic ~$1 high bias; see `backtest/research/feed_divergence_2026-08-14.md`). On an Oanda candle outage, stop scanning rather than switching to TD candles.
|
||||||
- Tiingo is streaming-only (`data_providers.streaming: "tiingo"`), not a designated candle fallback. Oanda-Tiingo signal parity is documented in `backtest/research/signal_parity_2026-08-14.md`; Phase 4's two-week live shadow scan remains a manual operational step.
|
- Tiingo is streaming-only (`data_providers.streaming: "tiingo"`), not a designated candle fallback. Oanda-Tiingo signal parity is documented in `backtest/research/signal_parity_2026-08-14.md`; Phase 4's two-week live shadow scan remains a manual operational step.
|
||||||
- Human-facing docs in `docs/` (`user_guide.html`, `beginner_guide.html` + PDF builds) must be kept in sync when signal behavior changes.
|
- Human-facing docs in `docs/` (`user_guide.html`, `beginner_guide.html` + PDF builds) must be kept in sync when signal behavior changes.
|
||||||
- 2026-09-03 signal-log campaign shipped two new per-TF gates (`backtest/research/signal_log_campaign_2026-09.md`): M5 `sell_blocked_regimes=["transitioning"]` and H1 `sell_min_quality=[]` (all H1 shorts blocked). Current-HEAD M5 canonical book is now `backtest/oanda_m5_12mo_065_nopb_20260903.csv` (557 / +112.21R / PF 1.56, halves +55.9/+56.4); H1 reference `backtest/oanda_h1_12mo_065_20260903.csv` (91 / +22.2R / PF 1.79; 5yr +49.1R PF 1.46; OOS +16.7R PF 1.74).
|
- 2026-09-03 signal-log campaign shipped two new per-TF gates (`backtest/research/signal_log_campaign_2026-09.md`): M5 `sell_blocked_regimes=["transitioning"]` and H1 `sell_min_quality=[]` (all H1 shorts blocked). Current-HEAD M5 canonical book is now `backtest/oanda_m5_12mo_065_nopb_20260903.csv` (557 / +112.21R / PF 1.56, halves +55.9/+56.4); H1 reference `backtest/oanda_h1_12mo_065_20260903.csv` (91 / +22.2R / PF 1.79; 5yr +49.1R PF 1.46; OOS +16.7R PF 1.74).
|
||||||
|
- Auto-execution (oanda_daemon.py / oanda_executor.py) was removed 2026-09-03 — the owner is not auto-trading; last commit containing it is 31002cb. Book-parity rules that lived in its docstring (no broker TP, 5-bar cooldown, once-per-bar trail) remain the backtester's model.
|
||||||
|
|||||||
+1
-16
@@ -89,8 +89,7 @@ _KNOWN_TOP = {"telegram", "data_providers", "scanning", "pairs", "log_level",
|
|||||||
"oanda", "deepseek", "agent_panel", "market_outlook",
|
"oanda", "deepseek", "agent_panel", "market_outlook",
|
||||||
"signal_log"}
|
"signal_log"}
|
||||||
_KNOWN_OANDA = {
|
_KNOWN_OANDA = {
|
||||||
"account_id", "api_token", "base_url", "lot_size",
|
"account_id", "api_token", "base_url",
|
||||||
"quality_gate", "max_hold_bars",
|
|
||||||
}
|
}
|
||||||
_KNOWN_DEEPSEEK = {
|
_KNOWN_DEEPSEEK = {
|
||||||
"api_key", "model", "enabled", "timeout_seconds", "daily_request_cap",
|
"api_key", "model", "enabled", "timeout_seconds", "daily_request_cap",
|
||||||
@@ -256,20 +255,6 @@ def load_app_config(path: str = CONFIG_PATH) -> AppConfig:
|
|||||||
|
|
||||||
oanda = raw.get("oanda", {})
|
oanda = raw.get("oanda", {})
|
||||||
_check_keys(oanda, _KNOWN_OANDA, '"oanda"')
|
_check_keys(oanda, _KNOWN_OANDA, '"oanda"')
|
||||||
if "lot_size" in oanda:
|
|
||||||
ls = oanda["lot_size"]
|
|
||||||
if not isinstance(ls, int) or isinstance(ls, bool) or ls < 1:
|
|
||||||
raise AppConfigError(
|
|
||||||
"config.json: oanda.lot_size must be a positive integer")
|
|
||||||
if "quality_gate" in oanda and oanda["quality_gate"] not in _VALID_QUALITIES:
|
|
||||||
raise AppConfigError(
|
|
||||||
f'config.json: oanda.quality_gate "{oanda["quality_gate"]}" '
|
|
||||||
f"not in {_VALID_QUALITIES}")
|
|
||||||
if "max_hold_bars" in oanda:
|
|
||||||
mhb = oanda["max_hold_bars"]
|
|
||||||
if not isinstance(mhb, int) or isinstance(mhb, bool) or mhb < 1:
|
|
||||||
raise AppConfigError(
|
|
||||||
"config.json: oanda.max_hold_bars must be a positive integer")
|
|
||||||
app.oanda_settings = dict(oanda)
|
app.oanda_settings = dict(oanda)
|
||||||
|
|
||||||
deepseek = raw.get("deepseek", {})
|
deepseek = raw.get("deepseek", {})
|
||||||
|
|||||||
+1
-4
@@ -28,10 +28,7 @@
|
|||||||
"oanda": {
|
"oanda": {
|
||||||
"account_id": "REPLACE_WITH_ACCOUNT_ID",
|
"account_id": "REPLACE_WITH_ACCOUNT_ID",
|
||||||
"api_token": "REPLACE_WITH_API_TOKEN",
|
"api_token": "REPLACE_WITH_API_TOKEN",
|
||||||
"base_url": "https://api-fxpractice.oanda.com",
|
"base_url": "https://api-fxpractice.oanda.com"
|
||||||
"lot_size": 1,
|
|
||||||
"quality_gate": "C",
|
|
||||||
"max_hold_bars": 300
|
|
||||||
},
|
},
|
||||||
"deepseek": {
|
"deepseek": {
|
||||||
"api_key": "REPLACE_WITH_YOUR_DEEPSEEK_API_KEY_OR_EMPTY",
|
"api_key": "REPLACE_WITH_YOUR_DEEPSEEK_API_KEY_OR_EMPTY",
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ can only ever mean a stale page.
|
|||||||
Independence rules (owner requirement — the telegram bot must be
|
Independence rules (owner requirement — the telegram bot must be
|
||||||
immune to anything on the web side):
|
immune to anything on the web side):
|
||||||
* Runs as its own process; never imported by, and never imports,
|
* Runs as its own process; never imported by, and never imports,
|
||||||
`telegram_bot` / `oanda_daemon` (telegram_bot resolves credentials
|
`telegram_bot` (telegram_bot resolves credentials at import). The
|
||||||
at import). The confirmation map and boundary math are therefore
|
confirmation map and boundary math are therefore duplicated here
|
||||||
duplicated here as local constants.
|
as local constants.
|
||||||
* The only shared-code dependency beyond the standard signal path is
|
* The only shared-code dependency beyond the standard signal path is
|
||||||
the additive `NewsEngine.get_week_events` accessor, which no
|
the additive `NewsEngine.get_week_events` accessor, which no
|
||||||
production signal path calls.
|
production signal path calls.
|
||||||
@@ -236,8 +236,8 @@ def due_timeframes(wake_dt: datetime) -> List[Timeframe]:
|
|||||||
|
|
||||||
|
|
||||||
def seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
|
def seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
|
||||||
# Same math as telegram_bot.py / oanda_daemon.py — kept local so the
|
# Same math as telegram_bot.py — kept local so the dashboard never
|
||||||
# dashboard never imports bot modules (independence rules above).
|
# imports bot modules (independence rules above).
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
total_minutes = now.hour * 60 + now.minute
|
total_minutes = now.hour * 60 + now.minute
|
||||||
elapsed_minutes = total_minutes % interval_minutes
|
elapsed_minutes = total_minutes % interval_minutes
|
||||||
|
|||||||
@@ -130,13 +130,6 @@ box(31, 33.2, 38, 5.2, "SignalReport",
|
|||||||
band(1, 13.2, 98, 17.2, "DELIVERY SURFACES", "#4C8F50")
|
band(1, 13.2, 98, 17.2, "DELIVERY SURFACES", "#4C8F50")
|
||||||
box(2.5, 15.0, 12, 12.6, "main.py",
|
box(2.5, 15.0, 12, 12.6, "main.py",
|
||||||
["CLI report", "--detail", "--json"], DEL_FC, DEL_EC)
|
["CLI report", "--detail", "--json"], DEL_FC, DEL_EC)
|
||||||
box(16, 15.0, 24, 12.6, "oanda_daemon.py + executor",
|
|
||||||
["practice auto-execution",
|
|
||||||
"book-parity rules: no broker TP,",
|
|
||||||
"5-bar cooldown, once-per-bar",
|
|
||||||
"trail, OPEN_ONLY fills,",
|
|
||||||
"boot reconcile · orders → Oanda"],
|
|
||||||
DEL_FC, DEL_EC, title_fs=7.8)
|
|
||||||
box(42.5, 15.0, 31, 12.6, "dashboard_publisher.py → Apache",
|
box(42.5, 15.0, 31, 12.6, "dashboard_publisher.py → Apache",
|
||||||
["independent daemon · scans at candle closes",
|
["independent daemon · scans at candle closes",
|
||||||
"atomic publish to /var/www/trade:",
|
"atomic publish to /var/www/trade:",
|
||||||
@@ -170,7 +163,6 @@ arrow((55, 69.8), (55, 63.1), label="warn-only context", lx=0, ly=1.0)
|
|||||||
arrow((86, 69.8), (86, 67.3), ls=(0, (3, 2)), lw=1.1) # config → core
|
arrow((86, 69.8), (86, 67.3), ls=(0, (3, 2)), lw=1.1) # config → core
|
||||||
arrow((50, 40.5), (50, 38.8)) # core → report
|
arrow((50, 40.5), (50, 38.8)) # core → report
|
||||||
arrow((36, 34.5), (8.5, 27.95), conn="arc3,rad=0.12") # report → cli
|
arrow((36, 34.5), (8.5, 27.95), conn="arc3,rad=0.12") # report → cli
|
||||||
arrow((40, 33.2), (28, 27.95), conn="arc3,rad=0.06") # report → daemon
|
|
||||||
arrow((60, 33.2), (58, 27.95)) # report → dashboard
|
arrow((60, 33.2), (58, 27.95)) # report → dashboard
|
||||||
arrow((66, 34.5), (86.5, 27.95), conn="arc3,rad=-0.12") # report → telegram
|
arrow((66, 34.5), (86.5, 27.95), conn="arc3,rad=-0.12") # report → telegram
|
||||||
|
|
||||||
@@ -181,7 +173,6 @@ arrow((95, 86.2), (95, 27.95), conn="arc3,rad=0.15", color=HAUL,
|
|||||||
ax.text(99.2, 57, "alerts / commands", fontsize=6.4, color=MUT,
|
ax.text(99.2, 57, "alerts / commands", fontsize=6.4, color=MUT,
|
||||||
rotation=90, ha="center", va="center")
|
rotation=90, ha="center", va="center")
|
||||||
# browser click → news_summary.php → DeepSeek
|
# browser click → news_summary.php → DeepSeek
|
||||||
# (oanda_daemon's order flow is noted inside its box — no long arc)
|
|
||||||
arrow((66, 27.95), (73, 86.2), conn="arc3,rad=-0.18", color=HAUL,
|
arrow((66, 27.95), (73, 86.2), conn="arc3,rad=-0.18", color=HAUL,
|
||||||
lw=1.2, ls=(0, (4, 2)),
|
lw=1.2, ls=(0, (4, 2)),
|
||||||
label="on-demand AI summary\n(browser click → PHP)", lx=2.5, ly=11.6)
|
label="on-demand AI summary\n(browser click → PHP)", lx=2.5, ly=11.6)
|
||||||
|
|||||||
-855
@@ -1,855 +0,0 @@
|
|||||||
"""
|
|
||||||
ForexBot2 — Oanda Automated Trading Daemon
|
|
||||||
============================================
|
|
||||||
Autonomous trading process for XAU/USD on an Oanda practice account.
|
|
||||||
Runs candle-boundary scan loops (M5/M15/H1/H4), executes trades via
|
|
||||||
the Oanda v20 API, and manages trailing stops at bar close — matching
|
|
||||||
the backtested bar-close ratchet model.
|
|
||||||
|
|
||||||
Book-parity notes (matching backtest/gpu_backtest.py):
|
|
||||||
- No broker-side take-profit: in the trailing model TP1/TP2 are
|
|
||||||
advisory; the only exits are SL (broker), TRAIL (daemon ratchet),
|
|
||||||
and TIMEOUT (max_hold_bars). A hard TP would cap winners the trail
|
|
||||||
lets run and diverge the live book from the backtest.
|
|
||||||
- Entry cooldown is 5 bars per timeframe (the backtester's only
|
|
||||||
sequential rule) — not a fingerprint block.
|
|
||||||
- The trail ratchets at bar CLOSE only, and each closed bar is
|
|
||||||
processed exactly once (last_bar_ts guard), so weekends and feed
|
|
||||||
lag can't double-count bars or inflate the hold counter.
|
|
||||||
|
|
||||||
Divergence from the backtest book (accepted for demo safety): one
|
|
||||||
position total. While a trade is open, other signals are logged and
|
|
||||||
skipped, so skipped entries can be compared against the book later.
|
|
||||||
|
|
||||||
Usage: python oanda_daemon.py
|
|
||||||
Config: env vars OANDA_ACCOUNT_ID, OANDA_API_TOKEN, OANDA_BASE_URL
|
|
||||||
or config.json "oanda" section (env wins).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from logging.handlers import TimedRotatingFileHandler
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
from app_config import (
|
|
||||||
get_app_config, oanda_account_id, oanda_api_token, oanda_base_url,
|
|
||||||
)
|
|
||||||
from config import PAIR_INFO, PlatformConfig, Timeframe, XAUUSD
|
|
||||||
from signal_aggregator import SignalAggregator, SignalReport, SignalStrength
|
|
||||||
from pair_signals import PAIR_PRODUCERS
|
|
||||||
from providers.oanda import OandaDataProvider
|
|
||||||
from oanda_executor import OandaExecutor, OandaAPIError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# ── Constants ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
SYMBOL = XAUUSD
|
|
||||||
INSTRUMENT = "XAU_USD"
|
|
||||||
|
|
||||||
SCAN_CONFIRMATIONS = {
|
|
||||||
Timeframe.M5: [Timeframe.M15, Timeframe.H1],
|
|
||||||
Timeframe.M15: [Timeframe.M5, Timeframe.H1],
|
|
||||||
Timeframe.H1: [Timeframe.M15, Timeframe.H4],
|
|
||||||
Timeframe.H4: [Timeframe.H1, Timeframe.D1],
|
|
||||||
}
|
|
||||||
|
|
||||||
TF_INTERVAL_MINUTES = {
|
|
||||||
Timeframe.M5: 5, Timeframe.M15: 15, Timeframe.M30: 30,
|
|
||||||
Timeframe.H1: 60, Timeframe.H4: 240, Timeframe.D1: 1440,
|
|
||||||
}
|
|
||||||
|
|
||||||
SCAN_TIMEFRAMES = [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
|
|
||||||
|
|
||||||
QUALITY_RANK = {"A+": 4, "A": 3, "B": 2, "C": 1, "D": 0, "F": -1}
|
|
||||||
|
|
||||||
# Matches the backtester's --cooldown-bars default (the only sequential
|
|
||||||
# rule in the replay): no new entry within 5 bars of the previous entry
|
|
||||||
# on the same timeframe.
|
|
||||||
COOLDOWN_BARS = 5
|
|
||||||
|
|
||||||
# How often the heartbeat confirms the process is alive and the account
|
|
||||||
# is still reachable — independent of the candle-boundary loops, so a
|
|
||||||
# wedged scan or mid-run credential expiry still surfaces in the log.
|
|
||||||
HEARTBEAT_SECONDS = 3600
|
|
||||||
|
|
||||||
STATE_FILE = os.path.join(
|
|
||||||
os.path.dirname(os.path.abspath(__file__)), "oanda_state.json")
|
|
||||||
|
|
||||||
# ── Config resolution (env → config.json → default) ──────────────────
|
|
||||||
|
|
||||||
def _oanda_setting_int(env_key: str, json_key: str, default: int) -> int:
|
|
||||||
env = os.environ.get(env_key)
|
|
||||||
if env:
|
|
||||||
try:
|
|
||||||
return int(env)
|
|
||||||
except ValueError:
|
|
||||||
raise SystemExit(
|
|
||||||
f"{env_key}={env!r} is not an integer — refusing to start")
|
|
||||||
try:
|
|
||||||
return int(get_app_config().oanda_settings.get(json_key, default))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise SystemExit(
|
|
||||||
f"config.json oanda.{json_key} is not an integer — "
|
|
||||||
f"refusing to start")
|
|
||||||
|
|
||||||
|
|
||||||
def _oanda_setting_str(env_key: str, json_key: str, default: str) -> str:
|
|
||||||
return (os.environ.get(env_key)
|
|
||||||
or str(get_app_config().oanda_settings.get(json_key, default)))
|
|
||||||
|
|
||||||
# ── Logging setup ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _setup_logging(level: str = "INFO") -> None:
|
|
||||||
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
|
||||||
os.makedirs(log_dir, exist_ok=True)
|
|
||||||
log_file = os.path.join(log_dir, "daemon.log")
|
|
||||||
|
|
||||||
fmt = logging.Formatter(
|
|
||||||
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
||||||
datefmt="%Y-%m-%d %H:%M:%S")
|
|
||||||
|
|
||||||
file_handler = TimedRotatingFileHandler(
|
|
||||||
log_file, when="h", interval=3, backupCount=240, utc=True)
|
|
||||||
file_handler.setFormatter(fmt)
|
|
||||||
|
|
||||||
console_handler = logging.StreamHandler(sys.stdout)
|
|
||||||
console_handler.setFormatter(fmt)
|
|
||||||
|
|
||||||
logging.basicConfig(
|
|
||||||
level=getattr(logging, level.upper(), logging.INFO),
|
|
||||||
handlers=[file_handler, console_handler],
|
|
||||||
force=True)
|
|
||||||
|
|
||||||
# ── Boundary timing (same as telegram_bot.py) ─────────────────────────
|
|
||||||
|
|
||||||
def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
total_minutes = now.hour * 60 + now.minute
|
|
||||||
elapsed_minutes = total_minutes % interval_minutes
|
|
||||||
elapsed_seconds = elapsed_minutes * 60 + now.second + now.microsecond / 1e6
|
|
||||||
interval_seconds = interval_minutes * 60
|
|
||||||
remaining = interval_seconds - elapsed_seconds + buffer
|
|
||||||
# NOTE: remaining > buffer always (elapsed < interval by construction);
|
|
||||||
# a call at the exact boundary instant targets the NEXT boundary —
|
|
||||||
# steady-state loops wake at boundary+buffer, so only a process
|
|
||||||
# started in the boundary microsecond skips one scan (2026-08-04
|
|
||||||
# audit I42; the old `remaining <= buffer` guard was unreachable).
|
|
||||||
return remaining
|
|
||||||
|
|
||||||
# ── Trade state persistence ───────────────────────────────────────────
|
|
||||||
|
|
||||||
def _load_state() -> Dict:
|
|
||||||
if os.path.exists(STATE_FILE):
|
|
||||||
try:
|
|
||||||
with open(STATE_FILE, encoding="utf-8") as fh:
|
|
||||||
return json.load(fh)
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
logger.warning("Could not load state file, starting fresh")
|
|
||||||
return {"trades": {}, "last_entries": {}}
|
|
||||||
|
|
||||||
|
|
||||||
def _save_state(state: Dict) -> None:
|
|
||||||
try:
|
|
||||||
tmp = STATE_FILE + ".tmp"
|
|
||||||
with open(tmp, "w", encoding="utf-8") as fh:
|
|
||||||
json.dump(state, fh, indent=2)
|
|
||||||
os.replace(tmp, STATE_FILE)
|
|
||||||
except OSError:
|
|
||||||
logger.exception("Failed to save state file")
|
|
||||||
|
|
||||||
# ── Trading Daemon ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TradingDaemon:
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
account_id = oanda_account_id()
|
|
||||||
api_token = oanda_api_token()
|
|
||||||
base_url = oanda_base_url()
|
|
||||||
lot_size = _oanda_setting_int("OANDA_LOT_SIZE", "lot_size", 1)
|
|
||||||
self._quality_gate = _oanda_setting_str(
|
|
||||||
"OANDA_QUALITY_GATE", "quality_gate", "C").strip()
|
|
||||||
if self._quality_gate not in QUALITY_RANK:
|
|
||||||
# Fail CLOSED: an unrecognized gate must never silently fall
|
|
||||||
# back to the most permissive rank (QUALITY_RANK.get default).
|
|
||||||
raise SystemExit(
|
|
||||||
f"OANDA_QUALITY_GATE/quality_gate {self._quality_gate!r} "
|
|
||||||
f"is not one of {sorted(QUALITY_RANK)} — refusing to start")
|
|
||||||
self._max_hold_bars = _oanda_setting_int(
|
|
||||||
"OANDA_MAX_HOLD_BARS", "max_hold_bars", 300)
|
|
||||||
self._price_precision = PAIR_INFO[SYMBOL]["price_precision"]
|
|
||||||
|
|
||||||
if not account_id or not api_token:
|
|
||||||
msg = (
|
|
||||||
"Missing Oanda credentials. Set OANDA_ACCOUNT_ID and "
|
|
||||||
"OANDA_API_TOKEN env vars or config.json oanda section.")
|
|
||||||
logger.critical(msg)
|
|
||||||
raise SystemExit(msg)
|
|
||||||
|
|
||||||
self._provider = OandaDataProvider(
|
|
||||||
PlatformConfig(), api_token, base_url)
|
|
||||||
self._executor = OandaExecutor(
|
|
||||||
account_id, api_token, base_url, lot_size,
|
|
||||||
price_precision=self._price_precision)
|
|
||||||
self._thread_pool = ThreadPoolExecutor(max_workers=4)
|
|
||||||
|
|
||||||
self._state = _load_state()
|
|
||||||
self._state.setdefault("trades", {})
|
|
||||||
self._state.setdefault("last_entries", {})
|
|
||||||
self._state_lock = threading.Lock()
|
|
||||||
# Serializes the position-check + order-placement critical section
|
|
||||||
# across concurrent TF loops (all four fire at aligned boundaries).
|
|
||||||
self._entry_lock = threading.Lock()
|
|
||||||
self._running = True
|
|
||||||
|
|
||||||
# ── Main entry point ───────────────────────────────────────────
|
|
||||||
|
|
||||||
async def run(self) -> None:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
loop.set_exception_handler(self._asyncio_exception_handler)
|
|
||||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
||||||
loop.add_signal_handler(sig, self._request_shutdown)
|
|
||||||
|
|
||||||
logger.info("=== Oanda Trading Daemon starting ===")
|
|
||||||
logger.info(
|
|
||||||
"Quality gate: %s | Max hold: %d bars | Cooldown: %d bars | "
|
|
||||||
"Timeframes: %s",
|
|
||||||
self._quality_gate, self._max_hold_bars, COOLDOWN_BARS,
|
|
||||||
"/".join(tf.name for tf in SCAN_TIMEFRAMES))
|
|
||||||
|
|
||||||
# Credentials first: with a bad token every later call fails, so
|
|
||||||
# refuse to start before touching positions or spawning loops.
|
|
||||||
self._verify_account_access()
|
|
||||||
|
|
||||||
self._discover_existing_positions()
|
|
||||||
|
|
||||||
tasks = []
|
|
||||||
for tf in SCAN_TIMEFRAMES:
|
|
||||||
interval = TF_INTERVAL_MINUTES[tf]
|
|
||||||
tasks.append(asyncio.create_task(
|
|
||||||
self._candle_loop(tf, interval)))
|
|
||||||
tasks.append(asyncio.create_task(self._heartbeat_loop()))
|
|
||||||
|
|
||||||
logger.info("Candle-boundary loops started")
|
|
||||||
try:
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
self._shutdown()
|
|
||||||
|
|
||||||
def _verify_account_access(self) -> None:
|
|
||||||
"""Boot-time credential check. A 4xx here means the token/account
|
|
||||||
id is wrong — that never fixes itself, so refuse to start rather
|
|
||||||
than run a daemon that can't place or manage trades. A connection
|
|
||||||
failure or 5xx is a transient outage: log it and continue, since
|
|
||||||
broker-side stops already protect any open position and the
|
|
||||||
hourly heartbeat will keep reporting account health."""
|
|
||||||
try:
|
|
||||||
self._executor.get_account_summary()
|
|
||||||
except OandaAPIError as e:
|
|
||||||
if 400 <= e.status_code < 500:
|
|
||||||
logger.critical(
|
|
||||||
"Oanda rejected the account credentials (HTTP %d): "
|
|
||||||
"%s — refusing to start", e.status_code, e)
|
|
||||||
raise SystemExit(
|
|
||||||
f"Oanda rejected the account credentials "
|
|
||||||
f"(HTTP {e.status_code}): {e}")
|
|
||||||
logger.error(
|
|
||||||
"Failed to fetch account summary at boot (HTTP %d): %s",
|
|
||||||
e.status_code, e)
|
|
||||||
|
|
||||||
def _asyncio_exception_handler(self, loop, context) -> None:
|
|
||||||
exc = context.get("exception")
|
|
||||||
if exc is not None:
|
|
||||||
logger.critical(
|
|
||||||
"Uncaught asyncio exception: %s",
|
|
||||||
context.get("message", ""), exc_info=exc)
|
|
||||||
else:
|
|
||||||
logger.critical(
|
|
||||||
"Uncaught asyncio error: %s", context.get("message", ""))
|
|
||||||
|
|
||||||
def _request_shutdown(self) -> None:
|
|
||||||
logger.info("Shutdown requested")
|
|
||||||
self._running = False
|
|
||||||
for task in asyncio.all_tasks():
|
|
||||||
task.cancel()
|
|
||||||
|
|
||||||
def _shutdown(self) -> None:
|
|
||||||
with self._state_lock:
|
|
||||||
_save_state(self._state)
|
|
||||||
self._thread_pool.shutdown(wait=False)
|
|
||||||
logger.info("=== Oanda Trading Daemon stopped ===")
|
|
||||||
|
|
||||||
# ── Position discovery (restart recovery) ──────────────────────
|
|
||||||
|
|
||||||
def _discover_existing_positions(self) -> None:
|
|
||||||
self._reconcile_positions("boot discovery")
|
|
||||||
|
|
||||||
def _reconcile_positions(self, reason: str) -> None:
|
|
||||||
"""Re-sync tracked state with the broker's open trades. Safe to
|
|
||||||
call mid-run (lock-guarded); used at boot and whenever a fill or
|
|
||||||
placement error suggests state and broker may have diverged."""
|
|
||||||
try:
|
|
||||||
open_trades = self._executor.get_open_trades(INSTRUMENT)
|
|
||||||
except OandaAPIError:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to query open trades (%s) — reconcile skipped",
|
|
||||||
reason)
|
|
||||||
return
|
|
||||||
|
|
||||||
live_ids = {t["id"] for t in open_trades}
|
|
||||||
|
|
||||||
with self._state_lock:
|
|
||||||
saved = self._state.get("trades", {})
|
|
||||||
|
|
||||||
stale = [tid for tid in saved if tid not in live_ids]
|
|
||||||
for tid in stale:
|
|
||||||
logger.info(
|
|
||||||
"Stale trade %s in state file (closed on Oanda), "
|
|
||||||
"removing", tid)
|
|
||||||
del saved[tid]
|
|
||||||
|
|
||||||
for trade in open_trades:
|
|
||||||
tid = trade["id"]
|
|
||||||
if tid in saved:
|
|
||||||
logger.info(
|
|
||||||
"Recovered trade %s from state: %s %s @ %.2f, SL %.2f",
|
|
||||||
tid, saved[tid].get("direction"), INSTRUMENT,
|
|
||||||
saved[tid].get("entry"), saved[tid].get("current_sl"))
|
|
||||||
continue
|
|
||||||
|
|
||||||
direction = ("BUY" if float(trade.get("currentUnits", 0)) > 0
|
|
||||||
else "SELL")
|
|
||||||
entry = float(trade.get("price", 0))
|
|
||||||
sl_order = trade.get("stopLossOrder") or {}
|
|
||||||
sl_price = sl_order.get("price")
|
|
||||||
if sl_price is None:
|
|
||||||
# No broker-side stop at all: risk is undefined, so the
|
|
||||||
# trailing model cannot manage this trade. Scream — a
|
|
||||||
# naked position needs a human (or a policy) to set a
|
|
||||||
# stop; fabricating one here would invent trading
|
|
||||||
# behavior the books never validated.
|
|
||||||
logger.critical(
|
|
||||||
"Adopted trade %s (%s @ %.2f) has NO STOP LOSS on "
|
|
||||||
"the broker — it will NOT be trailed. Set a stop "
|
|
||||||
"manually or close it.", tid, direction, entry)
|
|
||||||
sl = entry
|
|
||||||
risk = 0.0
|
|
||||||
else:
|
|
||||||
sl = float(sl_price)
|
|
||||||
risk = abs(entry - sl)
|
|
||||||
|
|
||||||
saved[tid] = {
|
|
||||||
"tf": "H1",
|
|
||||||
"direction": direction,
|
|
||||||
"entry": entry,
|
|
||||||
"sl_initial": sl,
|
|
||||||
"current_sl": sl,
|
|
||||||
"risk": risk,
|
|
||||||
"peak": entry,
|
|
||||||
"trail_armed": False,
|
|
||||||
"trail_arm_r": 0.6,
|
|
||||||
"trail_dist_r": 0.75,
|
|
||||||
"bars_held": 0,
|
|
||||||
"last_bar_ts": None,
|
|
||||||
"signal_bar_ts": None,
|
|
||||||
}
|
|
||||||
logger.info(
|
|
||||||
"Adopted unknown trade %s: %s @ %.2f, SL %.2f, risk %.2f "
|
|
||||||
"(conservative defaults)",
|
|
||||||
tid, direction, entry, sl, risk)
|
|
||||||
|
|
||||||
self._state["trades"] = saved
|
|
||||||
_save_state(self._state)
|
|
||||||
logger.info(
|
|
||||||
"Position reconcile (%s): %d active trade(s)", reason, len(saved))
|
|
||||||
|
|
||||||
# ── Candle-boundary loop ───────────────────────────────────────
|
|
||||||
|
|
||||||
async def _candle_loop(self, tf: Timeframe, interval_minutes: int) -> None:
|
|
||||||
label = tf.value
|
|
||||||
try:
|
|
||||||
while self._running:
|
|
||||||
wait = _seconds_until_boundary(interval_minutes, buffer=10)
|
|
||||||
logger.info("%s loop: next scan in %.0fs", label, wait)
|
|
||||||
await asyncio.sleep(wait)
|
|
||||||
if not self._running:
|
|
||||||
break
|
|
||||||
logger.info("%s loop: firing scan", label)
|
|
||||||
try:
|
|
||||||
await self._scan_and_manage(tf)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("%s loop: cycle failed", label)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
logger.info("%s loop stopped", label)
|
|
||||||
|
|
||||||
async def _scan_and_manage(self, tf: Timeframe) -> None:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
|
|
||||||
# Trade management FIRST: the SL ratchet must not wait on (or be
|
|
||||||
# starved by) the signal scan — news fetches can take seconds and
|
|
||||||
# a failed scan must never cost an open trade its trail update.
|
|
||||||
try:
|
|
||||||
await loop.run_in_executor(
|
|
||||||
self._thread_pool, self._manage_open_trades, tf)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("%s: trade management failed", tf.value)
|
|
||||||
|
|
||||||
report = await loop.run_in_executor(
|
|
||||||
self._thread_pool, self._run_signal, tf)
|
|
||||||
|
|
||||||
if not self._should_execute(report, tf):
|
|
||||||
return
|
|
||||||
|
|
||||||
await loop.run_in_executor(
|
|
||||||
self._thread_pool, self._execute_trade, report, tf)
|
|
||||||
|
|
||||||
def _manage_open_trades(self, tf: Timeframe) -> None:
|
|
||||||
self._check_closed_trades()
|
|
||||||
self._manage_trailing(tf)
|
|
||||||
|
|
||||||
# ── Heartbeat ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _heartbeat_once(self) -> None:
|
|
||||||
with self._state_lock:
|
|
||||||
n = len(self._state.get("trades", {}))
|
|
||||||
try:
|
|
||||||
self._executor.get_account_summary()
|
|
||||||
except OandaAPIError as e:
|
|
||||||
logger.error("HEARTBEAT: account summary failed: %s", e)
|
|
||||||
# Logged unconditionally — even on a failed summary call the
|
|
||||||
# heartbeat line itself must reach the log, so a wedged process
|
|
||||||
# doesn't look identical to a healthy but silent one.
|
|
||||||
logger.info("HEARTBEAT: alive | tracked trades: %d", n)
|
|
||||||
|
|
||||||
async def _heartbeat_loop(self) -> None:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
try:
|
|
||||||
while self._running:
|
|
||||||
await asyncio.sleep(HEARTBEAT_SECONDS)
|
|
||||||
if not self._running:
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
await loop.run_in_executor(
|
|
||||||
self._thread_pool, self._heartbeat_once)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("heartbeat loop: cycle failed")
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
logger.info("heartbeat loop stopped")
|
|
||||||
|
|
||||||
# ── Signal pipeline ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _run_signal(self, tf: Timeframe) -> SignalReport:
|
|
||||||
conf_tfs = SCAN_CONFIRMATIONS.get(
|
|
||||||
tf, [Timeframe.M15, Timeframe.H4])
|
|
||||||
factory = PAIR_PRODUCERS.get(SYMBOL)
|
|
||||||
if factory is not None:
|
|
||||||
config = factory(tf, conf_tfs)
|
|
||||||
else:
|
|
||||||
config = PlatformConfig()
|
|
||||||
config.primary_timeframe = tf
|
|
||||||
config.confirmation_timeframes = conf_tfs
|
|
||||||
|
|
||||||
aggregator = SignalAggregator(config)
|
|
||||||
aggregator.data_provider = self._provider
|
|
||||||
report = aggregator.analyze_pair(symbol=SYMBOL,
|
|
||||||
log_source="oanda_daemon")
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"%s scan: signal=%s score=%.1f conf=%.0f quality=%s "
|
|
||||||
"regime=%s vol=%s price=%.2f spread=%.4f",
|
|
||||||
tf.value,
|
|
||||||
report.final_signal.name, report.final_score,
|
|
||||||
report.final_confidence, report.signal_quality,
|
|
||||||
report.market_regime, report.volatility_regime,
|
|
||||||
report.current_price,
|
|
||||||
self._provider.get_spread(SYMBOL))
|
|
||||||
|
|
||||||
# Stale-data/suppression warnings must be visible at WARNING;
|
|
||||||
# routine session-filter notices go to DEBUG — the M5 loop fires
|
|
||||||
# 288x/day and would drown the log at WARNING otherwise.
|
|
||||||
for w in report.warnings:
|
|
||||||
if "STALE" in w.upper() or "FAIL" in w.upper():
|
|
||||||
logger.warning("%s scan: %s", tf.value, w)
|
|
||||||
else:
|
|
||||||
logger.debug("%s scan: %s", tf.value, w)
|
|
||||||
|
|
||||||
return report
|
|
||||||
|
|
||||||
# ── Execution decision ─────────────────────────────────────────
|
|
||||||
|
|
||||||
def _should_execute(self, report: SignalReport, tf: Timeframe) -> bool:
|
|
||||||
if report.final_signal == SignalStrength.NEUTRAL:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if report.trade_setup is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
quality_rank = QUALITY_RANK.get(report.signal_quality, -1)
|
|
||||||
gate_rank = QUALITY_RANK.get(self._quality_gate, 1)
|
|
||||||
if quality_rank < gate_rank:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self._in_cooldown(tf):
|
|
||||||
logger.info(
|
|
||||||
"%s: actionable signal (%s %s) but inside %d-bar entry "
|
|
||||||
"cooldown, skipping",
|
|
||||||
tf.value, report.trade_setup.direction,
|
|
||||||
report.signal_quality, COOLDOWN_BARS)
|
|
||||||
return False
|
|
||||||
|
|
||||||
with self._state_lock:
|
|
||||||
if self._state.get("trades"):
|
|
||||||
logger.info(
|
|
||||||
"%s: actionable signal (%s %s) but position already "
|
|
||||||
"open, skipping",
|
|
||||||
tf.value, report.trade_setup.direction,
|
|
||||||
report.signal_quality)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _in_cooldown(self, tf: Timeframe) -> bool:
|
|
||||||
"""Backtest parity: no entry within COOLDOWN_BARS bars of the
|
|
||||||
previous entry on the same timeframe (wall-clock approximation)."""
|
|
||||||
with self._state_lock:
|
|
||||||
last = self._state.get("last_entries", {}).get(tf.value)
|
|
||||||
if not last:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
last_ts = datetime.fromisoformat(last)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
interval_s = TF_INTERVAL_MINUTES[tf] * 60
|
|
||||||
elapsed = (datetime.now(timezone.utc) - last_ts).total_seconds()
|
|
||||||
return elapsed < COOLDOWN_BARS * interval_s
|
|
||||||
|
|
||||||
# ── Trade execution ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _execute_trade(self, report: SignalReport, tf: Timeframe) -> None:
|
|
||||||
ts = report.trade_setup
|
|
||||||
if ts is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
# All four TF loops can fire at an aligned boundary (e.g. :00) and
|
|
||||||
# pass _should_execute before any of them has recorded a fill —
|
|
||||||
# re-check under the entry lock so only one order goes out.
|
|
||||||
with self._entry_lock:
|
|
||||||
with self._state_lock:
|
|
||||||
if self._state.get("trades"):
|
|
||||||
logger.info(
|
|
||||||
"%s: position opened by a concurrent scan, skipping",
|
|
||||||
tf.value)
|
|
||||||
return
|
|
||||||
|
|
||||||
# NO take-profit on the order: the backtested trailing model
|
|
||||||
# exits via SL/TRAIL/TIMEOUT only — a hard TP would cap
|
|
||||||
# winners and diverge the live book from the backtest.
|
|
||||||
try:
|
|
||||||
result = self._executor.place_market_order(
|
|
||||||
instrument=INSTRUMENT,
|
|
||||||
direction=ts.direction,
|
|
||||||
stop_loss=ts.stop_loss,
|
|
||||||
)
|
|
||||||
except OandaAPIError:
|
|
||||||
# The order is never retried (a timeout after a server-side
|
|
||||||
# fill would double-up on retry), but the server MAY have
|
|
||||||
# filled before the error reached us — reconcile so a live
|
|
||||||
# position can't stay untracked until the next restart.
|
|
||||||
logger.exception(
|
|
||||||
"Failed to place %s order for %s %s — reconciling "
|
|
||||||
"broker positions",
|
|
||||||
ts.direction, SYMBOL, tf.value)
|
|
||||||
self._reconcile_positions("order placement error")
|
|
||||||
return
|
|
||||||
|
|
||||||
fill_tx = result.get("orderFillTransaction")
|
|
||||||
if not fill_tx:
|
|
||||||
logger.warning(
|
|
||||||
"Order not filled for %s %s", SYMBOL, tf.value)
|
|
||||||
return
|
|
||||||
|
|
||||||
trade_opened = fill_tx.get("tradeOpened", {})
|
|
||||||
trade_id = trade_opened.get("tradeID")
|
|
||||||
if not trade_id:
|
|
||||||
# A fill with no tradeOpened means the order interacted
|
|
||||||
# with an existing broker position (tradesClosed /
|
|
||||||
# tradeReduced on a netting account) — state and broker
|
|
||||||
# have diverged. Surface it and re-sync instead of
|
|
||||||
# silently forgetting a real fill.
|
|
||||||
logger.critical(
|
|
||||||
"Order FILLED but opened no trade (netting close/"
|
|
||||||
"reduce?) — fill: closed=%s reduced=%s. Reconciling.",
|
|
||||||
fill_tx.get("tradesClosed"), fill_tx.get("tradeReduced"))
|
|
||||||
self._reconcile_positions("fill without tradeOpened")
|
|
||||||
return
|
|
||||||
|
|
||||||
fill_price = float(fill_tx.get("price", ts.entry_price))
|
|
||||||
risk = abs(fill_price - ts.stop_loss)
|
|
||||||
|
|
||||||
trail_arm = ts.trail_arm_r if ts.trail_arm_r is not None else 0.6
|
|
||||||
trail_dist = ts.trail_dist_r if ts.trail_dist_r is not None else 0.75
|
|
||||||
|
|
||||||
now_iso = datetime.now(timezone.utc).isoformat()
|
|
||||||
trade_state = {
|
|
||||||
"tf": tf.value,
|
|
||||||
"direction": ts.direction,
|
|
||||||
"entry": fill_price,
|
|
||||||
"sl_initial": ts.stop_loss,
|
|
||||||
"current_sl": ts.stop_loss,
|
|
||||||
"tp1": ts.take_profit_1,
|
|
||||||
"tp2": ts.take_profit_2,
|
|
||||||
"risk": risk,
|
|
||||||
"peak": fill_price,
|
|
||||||
"trail_armed": False,
|
|
||||||
"trail_arm_r": trail_arm,
|
|
||||||
"trail_dist_r": trail_dist,
|
|
||||||
"bars_held": 0,
|
|
||||||
# The signal bar is already closed; the first bar this
|
|
||||||
# trade lives through is the one that just started. Guard
|
|
||||||
# so a lagging feed can't trail on the signal bar itself.
|
|
||||||
"signal_bar_ts": report.timestamp,
|
|
||||||
"last_bar_ts": None,
|
|
||||||
"strategy": ts.strategy,
|
|
||||||
"quality": report.signal_quality,
|
|
||||||
"rr": ts.risk_reward,
|
|
||||||
"opened_at": now_iso,
|
|
||||||
}
|
|
||||||
|
|
||||||
with self._state_lock:
|
|
||||||
self._state["trades"][trade_id] = trade_state
|
|
||||||
self._state["last_entries"][tf.value] = now_iso
|
|
||||||
_save_state(self._state)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"TRADE OPENED: %s %s @ %.2f | SL %.2f | TP1 %.2f (advisory) | "
|
|
||||||
"risk $%.2f | RR %.2f | quality %s | strategy %s | TF %s | "
|
|
||||||
"trail arm %.2fR dist %.2fR",
|
|
||||||
ts.direction, SYMBOL, fill_price, ts.stop_loss,
|
|
||||||
ts.take_profit_1,
|
|
||||||
risk, ts.risk_reward, report.signal_quality,
|
|
||||||
ts.strategy, tf.value, trail_arm, trail_dist)
|
|
||||||
|
|
||||||
# ── Trailing stop management ───────────────────────────────────
|
|
||||||
|
|
||||||
def _manage_trailing(self, tf: Timeframe) -> None:
|
|
||||||
# Copy the per-trade dicts too: the loop mutates info (peak,
|
|
||||||
# bars_held, last_bar_ts) and must not touch live state outside
|
|
||||||
# the lock — another thread may be serializing it in _save_state.
|
|
||||||
with self._state_lock:
|
|
||||||
trades = {tid: dict(info)
|
|
||||||
for tid, info in self._state.get("trades", {}).items()}
|
|
||||||
|
|
||||||
for trade_id, info in trades.items():
|
|
||||||
if info.get("tf") != tf.value:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
df = self._provider.fetch_ohlcv(SYMBOL, tf)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to fetch candle data for trail update")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if df.empty:
|
|
||||||
logger.warning(
|
|
||||||
"%s: no candle data for trail update of trade %s",
|
|
||||||
tf.value, trade_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
bar_ts = df.index[-1].isoformat()
|
|
||||||
|
|
||||||
# Process each closed bar exactly once. Weekends and feed lag
|
|
||||||
# re-serve the same last bar; without this guard bars_held
|
|
||||||
# inflates (an M5 trade would "time out" over a weekend) and
|
|
||||||
# the peak gets double-counted.
|
|
||||||
if bar_ts == info.get("last_bar_ts"):
|
|
||||||
continue
|
|
||||||
sig_ts = info.get("signal_bar_ts")
|
|
||||||
if sig_ts:
|
|
||||||
try:
|
|
||||||
if df.index[-1] <= pd.Timestamp(sig_ts):
|
|
||||||
continue
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
info["last_bar_ts"] = bar_ts
|
|
||||||
|
|
||||||
info["bars_held"] = info.get("bars_held", 0) + 1
|
|
||||||
|
|
||||||
if info["bars_held"] >= self._max_hold_bars:
|
|
||||||
logger.info(
|
|
||||||
"TIMEOUT: trade %s held %d bars, closing",
|
|
||||||
trade_id, info["bars_held"])
|
|
||||||
try:
|
|
||||||
self._executor.close_trade(trade_id)
|
|
||||||
except OandaAPIError:
|
|
||||||
# Keep tracking — the trade is still live on the
|
|
||||||
# broker (incl. a 200 whose close order was cancelled,
|
|
||||||
# e.g. MARKET_HALTED — close_trade raises on no-fill);
|
|
||||||
# we retry the close at the next bar.
|
|
||||||
logger.exception(
|
|
||||||
"Failed to close timed-out trade %s — will retry",
|
|
||||||
trade_id)
|
|
||||||
with self._state_lock:
|
|
||||||
if trade_id in self._state["trades"]:
|
|
||||||
self._state["trades"][trade_id] = info
|
|
||||||
_save_state(self._state)
|
|
||||||
continue
|
|
||||||
with self._state_lock:
|
|
||||||
self._state["trades"].pop(trade_id, None)
|
|
||||||
_save_state(self._state)
|
|
||||||
continue
|
|
||||||
|
|
||||||
last_bar = df.iloc[-1]
|
|
||||||
bar_high = float(last_bar["High"])
|
|
||||||
bar_low = float(last_bar["Low"])
|
|
||||||
|
|
||||||
if info.get("risk", 0) <= 0:
|
|
||||||
# Adopted naked position (no broker SL → risk undefined):
|
|
||||||
# never manageable by the trailing model — keep it loud
|
|
||||||
# instead of silently skipping forever.
|
|
||||||
logger.warning(
|
|
||||||
"Trade %s has risk<=0 (no stop loss) — trailing "
|
|
||||||
"skipped; set a stop manually", trade_id)
|
|
||||||
|
|
||||||
new_sl = self._compute_trail(info, bar_high, bar_low)
|
|
||||||
|
|
||||||
if new_sl is not None:
|
|
||||||
# Round to instrument precision and track exactly what the
|
|
||||||
# broker holds, so tracked vs broker SL can never drift.
|
|
||||||
new_sl = round(new_sl, self._price_precision)
|
|
||||||
if new_sl != info["current_sl"]:
|
|
||||||
try:
|
|
||||||
self._executor.modify_trade_sl(trade_id, new_sl)
|
|
||||||
info["current_sl"] = new_sl
|
|
||||||
except OandaAPIError:
|
|
||||||
logger.exception(
|
|
||||||
"Failed to update trailing SL for trade %s",
|
|
||||||
trade_id)
|
|
||||||
|
|
||||||
with self._state_lock:
|
|
||||||
# Write back ONLY if the trade is still tracked — another
|
|
||||||
# loop's _check_closed_trades may have popped it while we
|
|
||||||
# iterated (broker-side close). Re-inserting would create
|
|
||||||
# a ghost that blocks new entries.
|
|
||||||
if trade_id in self._state["trades"]:
|
|
||||||
self._state["trades"][trade_id] = info
|
|
||||||
_save_state(self._state)
|
|
||||||
|
|
||||||
def _compute_trail(
|
|
||||||
self, info: Dict, bar_high: float, bar_low: float
|
|
||||||
) -> Optional[float]:
|
|
||||||
buy = info["direction"] == "BUY"
|
|
||||||
risk = info["risk"]
|
|
||||||
if risk <= 0:
|
|
||||||
return None
|
|
||||||
arm = info.get("trail_arm_r", 0.6)
|
|
||||||
dist = info.get("trail_dist_r", 0.75)
|
|
||||||
if arm <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
fav = bar_high if buy else bar_low
|
|
||||||
if buy:
|
|
||||||
info["peak"] = max(info.get("peak", info["entry"]), fav)
|
|
||||||
else:
|
|
||||||
info["peak"] = min(info.get("peak", info["entry"]), fav)
|
|
||||||
|
|
||||||
exc = ((info["peak"] - info["entry"]) if buy
|
|
||||||
else (info["entry"] - info["peak"])) / risk
|
|
||||||
|
|
||||||
if exc >= arm:
|
|
||||||
if buy:
|
|
||||||
lvl = info["peak"] - dist * risk
|
|
||||||
cur_sl = info.get("current_sl", info["sl_initial"])
|
|
||||||
if lvl > cur_sl:
|
|
||||||
info["trail_armed"] = True
|
|
||||||
return lvl
|
|
||||||
else:
|
|
||||||
lvl = info["peak"] + dist * risk
|
|
||||||
cur_sl = info.get("current_sl", info["sl_initial"])
|
|
||||||
if lvl < cur_sl:
|
|
||||||
info["trail_armed"] = True
|
|
||||||
return lvl
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ── Closed-trade detection ─────────────────────────────────────
|
|
||||||
|
|
||||||
def _check_closed_trades(self) -> None:
|
|
||||||
with self._state_lock:
|
|
||||||
tracked = set(self._state.get("trades", {}).keys())
|
|
||||||
|
|
||||||
if not tracked:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
open_trades = self._executor.get_open_trades(INSTRUMENT)
|
|
||||||
except OandaAPIError:
|
|
||||||
logger.exception("Failed to query open trades")
|
|
||||||
return
|
|
||||||
|
|
||||||
live_ids = {t["id"] for t in open_trades}
|
|
||||||
closed = tracked - live_ids
|
|
||||||
|
|
||||||
for tid in closed:
|
|
||||||
with self._state_lock:
|
|
||||||
info = self._state["trades"].pop(tid, {})
|
|
||||||
entry = info.get("entry", 0)
|
|
||||||
direction = info.get("direction", "?")
|
|
||||||
risk = info.get("risk", 1)
|
|
||||||
sl = info.get("current_sl", 0)
|
|
||||||
was_trailed = info.get("trail_armed", False)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"TRADE CLOSED BY BROKER: %s %s %s | entry %.2f | "
|
|
||||||
"SL was %.2f | risk $%.2f | trailed=%s | bars_held=%d",
|
|
||||||
tid, direction, SYMBOL, entry, sl, risk,
|
|
||||||
was_trailed, info.get("bars_held", 0))
|
|
||||||
|
|
||||||
if closed:
|
|
||||||
with self._state_lock:
|
|
||||||
_save_state(self._state)
|
|
||||||
|
|
||||||
# ── Uncaught exception handlers ───────────────────────────────────────
|
|
||||||
|
|
||||||
def _excepthook(exc_type, exc_value, exc_tb):
|
|
||||||
if issubclass(exc_type, KeyboardInterrupt):
|
|
||||||
sys.__excepthook__(exc_type, exc_value, exc_tb)
|
|
||||||
return
|
|
||||||
logger.critical(
|
|
||||||
"Uncaught exception", exc_info=(exc_type, exc_value, exc_tb))
|
|
||||||
|
|
||||||
def _threading_excepthook(args):
|
|
||||||
logger.critical(
|
|
||||||
"Uncaught exception in thread %s",
|
|
||||||
args.thread.name if args.thread else "?",
|
|
||||||
exc_info=(args.exc_type, args.exc_value, args.exc_traceback))
|
|
||||||
|
|
||||||
# ── Entry point ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
_setup_logging(
|
|
||||||
os.environ.get("OANDA_LOG_LEVEL") or get_app_config().log_level)
|
|
||||||
sys.excepthook = _excepthook
|
|
||||||
threading.excepthook = _threading_excepthook
|
|
||||||
|
|
||||||
daemon = TradingDaemon()
|
|
||||||
asyncio.run(daemon.run())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
"""
|
|
||||||
ForexBot2 — Oanda v20 Order Executor
|
|
||||||
======================================
|
|
||||||
Handles all Oanda account operations: market orders, trade queries,
|
|
||||||
SL/TP modification, and trade closure. Uses raw ``requests`` against
|
|
||||||
the Oanda v20 REST API — no extra dependencies.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class OandaAPIError(Exception):
|
|
||||||
"""Raised when an Oanda API call fails."""
|
|
||||||
|
|
||||||
def __init__(self, message: str, status_code: int = 0, body: str = ""):
|
|
||||||
super().__init__(message)
|
|
||||||
self.status_code = status_code
|
|
||||||
self.body = body
|
|
||||||
|
|
||||||
|
|
||||||
class OandaExecutor:
|
|
||||||
"""Oanda v20 order execution and trade management."""
|
|
||||||
|
|
||||||
MAX_RETRIES = 1
|
|
||||||
RETRY_DELAY = 2.0
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
account_id: str,
|
|
||||||
api_token: str,
|
|
||||||
base_url: str = "https://api-fxpractice.oanda.com",
|
|
||||||
lot_size: int = 1,
|
|
||||||
price_precision: int = 2,
|
|
||||||
):
|
|
||||||
self._account_id = account_id
|
|
||||||
self._base_url = base_url.rstrip("/")
|
|
||||||
self._lot_size = lot_size
|
|
||||||
self._price_precision = price_precision
|
|
||||||
self._session = requests.Session()
|
|
||||||
self._session.headers.update({
|
|
||||||
"Authorization": f"Bearer {api_token}",
|
|
||||||
"Accept-Datetime-Format": "RFC3339",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
})
|
|
||||||
|
|
||||||
# ── Order Placement ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def place_market_order(
|
|
||||||
self,
|
|
||||||
instrument: str,
|
|
||||||
direction: str,
|
|
||||||
stop_loss: float,
|
|
||||||
take_profit: Optional[float] = None,
|
|
||||||
units: Optional[int] = None,
|
|
||||||
) -> Dict:
|
|
||||||
if units is None:
|
|
||||||
units = self._lot_size
|
|
||||||
signed_units = str(units) if direction.upper() == "BUY" else str(-units)
|
|
||||||
|
|
||||||
order_body = {
|
|
||||||
"order": {
|
|
||||||
"type": "MARKET",
|
|
||||||
"instrument": instrument,
|
|
||||||
"units": signed_units,
|
|
||||||
"timeInForce": "FOK",
|
|
||||||
# The book model is one position total; on a netting
|
|
||||||
# account an opposite-side order would silently CLOSE an
|
|
||||||
# existing (possibly untracked) position instead of opening
|
|
||||||
# the signal trade. OPEN_ONLY makes that case fail loudly
|
|
||||||
# (order cancelled) so the divergence surfaces instead of
|
|
||||||
# executing an unintended close.
|
|
||||||
"positionFill": "OPEN_ONLY",
|
|
||||||
"stopLossOnFill": {
|
|
||||||
"price": self._fmt_price(stop_loss),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if take_profit is not None:
|
|
||||||
order_body["order"]["takeProfitOnFill"] = {
|
|
||||||
"price": self._fmt_price(take_profit),
|
|
||||||
}
|
|
||||||
|
|
||||||
url = f"{self._base_url}/v3/accounts/{self._account_id}/orders"
|
|
||||||
# Never retry order placement: a timeout after the server already
|
|
||||||
# filled would open a SECOND position on retry. Fail loudly instead;
|
|
||||||
# the next candle boundary re-evaluates.
|
|
||||||
resp = self._request_with_retry("POST", url, json=order_body, retries=0)
|
|
||||||
data = self._parse_json(resp)
|
|
||||||
|
|
||||||
fill_tx = data.get("orderFillTransaction")
|
|
||||||
if fill_tx:
|
|
||||||
trade_id = fill_tx.get("tradeOpened", {}).get("tradeID", "unknown")
|
|
||||||
fill_price = float(fill_tx.get("price", 0))
|
|
||||||
logger.info(
|
|
||||||
"ORDER FILLED: %s %s %s units @ %.2f — trade %s, SL %.2f%s",
|
|
||||||
direction.upper(), instrument, signed_units, fill_price,
|
|
||||||
trade_id, stop_loss,
|
|
||||||
f", TP {take_profit:.2f}" if take_profit else "")
|
|
||||||
else:
|
|
||||||
cancel_tx = data.get("orderCancelTransaction", {})
|
|
||||||
reason = cancel_tx.get("reason", "unknown")
|
|
||||||
logger.warning(
|
|
||||||
"ORDER NOT FILLED: %s %s — reason: %s",
|
|
||||||
direction.upper(), instrument, reason)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
# ── Trade Queries ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_open_trades(self, instrument: Optional[str] = None) -> List[Dict]:
|
|
||||||
url = f"{self._base_url}/v3/accounts/{self._account_id}/openTrades"
|
|
||||||
resp = self._request_with_retry("GET", url)
|
|
||||||
trades = self._parse_json(resp).get("trades", [])
|
|
||||||
if instrument:
|
|
||||||
trades = [t for t in trades if t.get("instrument") == instrument]
|
|
||||||
return trades
|
|
||||||
|
|
||||||
def get_trade(self, trade_id: str) -> Dict:
|
|
||||||
url = (f"{self._base_url}/v3/accounts/{self._account_id}"
|
|
||||||
f"/trades/{trade_id}")
|
|
||||||
resp = self._request_with_retry("GET", url)
|
|
||||||
return self._parse_json(resp).get("trade", {})
|
|
||||||
|
|
||||||
def get_account_summary(self) -> Dict:
|
|
||||||
url = f"{self._base_url}/v3/accounts/{self._account_id}/summary"
|
|
||||||
resp = self._request_with_retry("GET", url)
|
|
||||||
account = self._parse_json(resp).get("account", {})
|
|
||||||
logger.info(
|
|
||||||
"Account: balance=%.2f, NAV=%.2f, unrealizedPL=%.2f, openTrades=%s",
|
|
||||||
float(account.get("balance", 0)),
|
|
||||||
float(account.get("NAV", 0)),
|
|
||||||
float(account.get("unrealizedPL", 0)),
|
|
||||||
account.get("openTradeCount", 0))
|
|
||||||
return account
|
|
||||||
|
|
||||||
# ── Trade Modification ─────────────────────────────────────────
|
|
||||||
|
|
||||||
def modify_trade_sl(self, trade_id: str, new_sl: float) -> Dict:
|
|
||||||
url = (f"{self._base_url}/v3/accounts/{self._account_id}"
|
|
||||||
f"/trades/{trade_id}/orders")
|
|
||||||
body = {"stopLoss": {"price": self._fmt_price(new_sl)}}
|
|
||||||
resp = self._request_with_retry("PUT", url, json=body)
|
|
||||||
logger.info(
|
|
||||||
"TRAIL UPDATE: trade %s — new SL %.2f", trade_id, new_sl)
|
|
||||||
return self._parse_json(resp)
|
|
||||||
|
|
||||||
def modify_trade_tp(self, trade_id: str, new_tp: float) -> Dict:
|
|
||||||
url = (f"{self._base_url}/v3/accounts/{self._account_id}"
|
|
||||||
f"/trades/{trade_id}/orders")
|
|
||||||
body = {"takeProfit": {"price": self._fmt_price(new_tp)}}
|
|
||||||
resp = self._request_with_retry("PUT", url, json=body)
|
|
||||||
logger.info("TP UPDATE: trade %s — new TP %.2f", trade_id, new_tp)
|
|
||||||
return self._parse_json(resp)
|
|
||||||
|
|
||||||
def close_trade(self, trade_id: str) -> Dict:
|
|
||||||
url = (f"{self._base_url}/v3/accounts/{self._account_id}"
|
|
||||||
f"/trades/{trade_id}/close")
|
|
||||||
resp = self._request_with_retry("PUT", url, json={"units": "ALL"})
|
|
||||||
data = self._parse_json(resp)
|
|
||||||
close_tx = data.get("orderFillTransaction")
|
|
||||||
if not close_tx:
|
|
||||||
# A 200 whose close order was created but NOT filled (e.g.
|
|
||||||
# cancelled with MARKET_HALTED during the settlement pause)
|
|
||||||
# means the position is STILL OPEN. Raising keeps the caller
|
|
||||||
# tracking it and retrying — silently returning here would
|
|
||||||
# orphan a live position.
|
|
||||||
reason = data.get("orderCancelTransaction", {}).get(
|
|
||||||
"reason", "no orderFillTransaction in response")
|
|
||||||
raise OandaAPIError(
|
|
||||||
f"Close of trade {trade_id} did not fill: {reason}",
|
|
||||||
resp.status_code, str(data)[:300])
|
|
||||||
close_price = float(close_tx.get("price", 0))
|
|
||||||
pl = float(close_tx.get("pl", 0))
|
|
||||||
logger.info(
|
|
||||||
"TRADE CLOSED: trade %s @ %.2f — PL %.2f",
|
|
||||||
trade_id, close_price, pl)
|
|
||||||
return data
|
|
||||||
|
|
||||||
# ── Internals ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_json(resp: requests.Response) -> Dict:
|
|
||||||
try:
|
|
||||||
return resp.json()
|
|
||||||
except ValueError as e:
|
|
||||||
raise OandaAPIError(
|
|
||||||
f"Oanda returned a non-JSON response (HTTP {resp.status_code})",
|
|
||||||
resp.status_code, resp.text[:300]) from e
|
|
||||||
|
|
||||||
def _fmt_price(self, price: float) -> str:
|
|
||||||
# Oanda rejects prices with more decimals than the instrument's
|
|
||||||
# displayPrecision (XAU_USD: sub-cent precision is invalid).
|
|
||||||
return f"{price:.{self._price_precision}f}"
|
|
||||||
|
|
||||||
def _request_with_retry(
|
|
||||||
self, method: str, url: str, retries: Optional[int] = None, **kwargs
|
|
||||||
) -> requests.Response:
|
|
||||||
if retries is None:
|
|
||||||
retries = self.MAX_RETRIES
|
|
||||||
last_err = None
|
|
||||||
for attempt in range(1 + retries):
|
|
||||||
try:
|
|
||||||
resp = self._session.request(method, url, timeout=15, **kwargs)
|
|
||||||
if resp.status_code >= 500:
|
|
||||||
raise OandaAPIError(
|
|
||||||
f"Oanda server error {resp.status_code}",
|
|
||||||
resp.status_code, resp.text)
|
|
||||||
if resp.status_code >= 400:
|
|
||||||
raise OandaAPIError(
|
|
||||||
f"Oanda API error {resp.status_code}: {resp.text}",
|
|
||||||
resp.status_code, resp.text)
|
|
||||||
return resp
|
|
||||||
except (requests.ConnectionError, requests.Timeout,
|
|
||||||
OandaAPIError) as e:
|
|
||||||
last_err = e
|
|
||||||
if isinstance(e, OandaAPIError) and 400 <= e.status_code < 500:
|
|
||||||
raise
|
|
||||||
if attempt < retries:
|
|
||||||
logger.warning(
|
|
||||||
"Oanda request failed (attempt %d/%d): %s — retrying in %.0fs",
|
|
||||||
attempt + 1, 1 + retries, e,
|
|
||||||
self.RETRY_DELAY)
|
|
||||||
time.sleep(self.RETRY_DELAY)
|
|
||||||
raise OandaAPIError(f"Oanda request failed after retries: {last_err}")
|
|
||||||
+9
-852
@@ -3464,14 +3464,12 @@ def _():
|
|||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
# MODULE 15: Oanda provider / executor / daemon
|
# MODULE 15: Oanda provider
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
print("\n═══ Module 15: Oanda provider / executor / daemon ═══")
|
print("\n═══ Module 15: Oanda provider ═══")
|
||||||
|
|
||||||
import providers.oanda
|
import providers.oanda
|
||||||
import oanda_executor
|
|
||||||
import oanda_daemon
|
|
||||||
|
|
||||||
|
|
||||||
@test("providers.oanda: OandaDataProvider parses Oanda candle JSON into correct DataFrame")
|
@test("providers.oanda: OandaDataProvider parses Oanda candle JSON into correct DataFrame")
|
||||||
@@ -3647,568 +3645,20 @@ def _():
|
|||||||
assert spread == 0.30, f"Expected default spread 0.30, got {spread}"
|
assert spread == 0.30, f"Expected default spread 0.30, got {spread}"
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_executor: market order body construction")
|
@test("app_config: unknown oanda key raises AppConfigError")
|
||||||
def _():
|
|
||||||
executor = oanda_executor.OandaExecutor.__new__(
|
|
||||||
oanda_executor.OandaExecutor)
|
|
||||||
executor._account_id = "test-account"
|
|
||||||
executor._base_url = "https://test"
|
|
||||||
executor._lot_size = 1
|
|
||||||
executor._price_precision = 2
|
|
||||||
executor._session = None
|
|
||||||
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def mock_request(self, method, url, **kwargs):
|
|
||||||
captured["method"] = method
|
|
||||||
captured["url"] = url
|
|
||||||
captured["json"] = kwargs.get("json")
|
|
||||||
return type("R", (), {
|
|
||||||
"status_code": 201,
|
|
||||||
"json": lambda self: {
|
|
||||||
"orderFillTransaction": {
|
|
||||||
"price": "2400.50",
|
|
||||||
"tradeOpened": {"tradeID": "123"},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})()
|
|
||||||
|
|
||||||
executor._session = type("S", (), {
|
|
||||||
"request": mock_request,
|
|
||||||
"headers": {},
|
|
||||||
})()
|
|
||||||
|
|
||||||
result = executor.place_market_order(
|
|
||||||
instrument="XAU_USD",
|
|
||||||
direction="BUY",
|
|
||||||
stop_loss=2392.50,
|
|
||||||
take_profit=2420.00,
|
|
||||||
)
|
|
||||||
|
|
||||||
body = captured["json"]
|
|
||||||
order = body["order"]
|
|
||||||
assert order["type"] == "MARKET"
|
|
||||||
assert order["instrument"] == "XAU_USD"
|
|
||||||
assert order["units"] == "1"
|
|
||||||
# XAU price precision is 2 — Oanda rejects excess decimals.
|
|
||||||
assert order["stopLossOnFill"]["price"] == "2392.50"
|
|
||||||
assert order["takeProfitOnFill"]["price"] == "2420.00"
|
|
||||||
|
|
||||||
fill = result.get("orderFillTransaction")
|
|
||||||
assert fill is not None
|
|
||||||
assert fill["tradeOpened"]["tradeID"] == "123"
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_executor: SELL order uses negative units")
|
|
||||||
def _():
|
|
||||||
executor = oanda_executor.OandaExecutor.__new__(
|
|
||||||
oanda_executor.OandaExecutor)
|
|
||||||
executor._account_id = "test-account"
|
|
||||||
executor._base_url = "https://test"
|
|
||||||
executor._lot_size = 1
|
|
||||||
executor._price_precision = 2
|
|
||||||
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def mock_request(self, method, url, **kwargs):
|
|
||||||
captured["json"] = kwargs.get("json")
|
|
||||||
return type("R", (), {
|
|
||||||
"status_code": 201,
|
|
||||||
"json": lambda self: {
|
|
||||||
"orderFillTransaction": {
|
|
||||||
"price": "2400.50",
|
|
||||||
"tradeOpened": {"tradeID": "456"},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})()
|
|
||||||
|
|
||||||
executor._session = type("S", (), {
|
|
||||||
"request": mock_request,
|
|
||||||
"headers": {},
|
|
||||||
})()
|
|
||||||
|
|
||||||
executor.place_market_order(
|
|
||||||
instrument="XAU_USD", direction="SELL", stop_loss=2410.00)
|
|
||||||
|
|
||||||
order = captured["json"]["order"]
|
|
||||||
assert order["units"] == "-1", f"SELL should be -1, got {order['units']}"
|
|
||||||
assert "takeProfitOnFill" not in order, "No TP should be set when None"
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_executor: 4xx error raises immediately (no retry)")
|
|
||||||
def _():
|
|
||||||
executor = oanda_executor.OandaExecutor.__new__(
|
|
||||||
oanda_executor.OandaExecutor)
|
|
||||||
executor._account_id = "test"
|
|
||||||
executor._base_url = "https://test"
|
|
||||||
executor._lot_size = 1
|
|
||||||
call_count = {"n": 0}
|
|
||||||
|
|
||||||
def mock_request(self, method, url, **kwargs):
|
|
||||||
call_count["n"] += 1
|
|
||||||
return type("R", (), {
|
|
||||||
"status_code": 400,
|
|
||||||
"text": "INVALID_ORDER",
|
|
||||||
})()
|
|
||||||
|
|
||||||
executor._session = type("S", (), {
|
|
||||||
"request": mock_request,
|
|
||||||
"headers": {},
|
|
||||||
})()
|
|
||||||
|
|
||||||
try:
|
|
||||||
executor._request_with_retry("POST", "https://test/orders")
|
|
||||||
assert False, "Should have raised OandaAPIError"
|
|
||||||
except oanda_executor.OandaAPIError as e:
|
|
||||||
assert e.status_code == 400
|
|
||||||
assert call_count["n"] == 1, "4xx should not retry"
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: trailing stop ratchet matches backtester logic")
|
|
||||||
def _():
|
|
||||||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
|
||||||
|
|
||||||
info_buy = {
|
|
||||||
"direction": "BUY",
|
|
||||||
"entry": 2400.0,
|
|
||||||
"sl_initial": 2384.0,
|
|
||||||
"current_sl": 2384.0,
|
|
||||||
"risk": 16.0,
|
|
||||||
"peak": 2400.0,
|
|
||||||
"trail_armed": False,
|
|
||||||
"trail_arm_r": 0.6,
|
|
||||||
"trail_dist_r": 0.75,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Bar 1: price moves up but not enough to arm (< 0.6R = $9.60)
|
|
||||||
new_sl = daemon._compute_trail(info_buy, bar_high=2408.0, bar_low=2399.0)
|
|
||||||
assert new_sl is None, "Should not arm yet (excursion 0.5R)"
|
|
||||||
assert info_buy["peak"] == 2408.0
|
|
||||||
|
|
||||||
# Bar 2: excursion reaches 0.625R = $10 — arms, trail = 2408 - 0.75*16 = 2396
|
|
||||||
new_sl = daemon._compute_trail(info_buy, bar_high=2410.0, bar_low=2403.0)
|
|
||||||
assert new_sl is not None, "Should arm at 0.625R"
|
|
||||||
assert abs(new_sl - 2398.0) < 0.01, f"Expected trail ~2398, got {new_sl}"
|
|
||||||
assert info_buy["trail_armed"] is True
|
|
||||||
|
|
||||||
# Bar 3: price goes higher, trail ratchets up
|
|
||||||
info_buy["current_sl"] = new_sl
|
|
||||||
new_sl2 = daemon._compute_trail(info_buy, bar_high=2420.0, bar_low=2410.0)
|
|
||||||
assert new_sl2 is not None
|
|
||||||
assert new_sl2 > new_sl, "Trail must ratchet up"
|
|
||||||
expected = 2420.0 - 0.75 * 16.0 # 2408.0
|
|
||||||
assert abs(new_sl2 - expected) < 0.01, f"Expected {expected}, got {new_sl2}"
|
|
||||||
|
|
||||||
# Bar 4: price pulls back — trail must NOT ratchet down
|
|
||||||
info_buy["current_sl"] = new_sl2
|
|
||||||
new_sl3 = daemon._compute_trail(info_buy, bar_high=2412.0, bar_low=2405.0)
|
|
||||||
assert new_sl3 is None, "Trail must not ratchet backwards"
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: trailing stop for SELL direction")
|
|
||||||
def _():
|
|
||||||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
|
||||||
|
|
||||||
info_sell = {
|
|
||||||
"direction": "SELL",
|
|
||||||
"entry": 2400.0,
|
|
||||||
"sl_initial": 2416.0,
|
|
||||||
"current_sl": 2416.0,
|
|
||||||
"risk": 16.0,
|
|
||||||
"peak": 2400.0,
|
|
||||||
"trail_armed": False,
|
|
||||||
"trail_arm_r": 0.6,
|
|
||||||
"trail_dist_r": 0.75,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Not enough excursion
|
|
||||||
new_sl = daemon._compute_trail(info_sell, bar_high=2401.0, bar_low=2393.0)
|
|
||||||
assert new_sl is None, "Should not arm yet"
|
|
||||||
assert info_sell["peak"] == 2393.0
|
|
||||||
|
|
||||||
# Arms: peak at 2390, excursion = 10/16 = 0.625R
|
|
||||||
new_sl = daemon._compute_trail(info_sell, bar_high=2395.0, bar_low=2390.0)
|
|
||||||
assert new_sl is not None
|
|
||||||
expected = 2390.0 + 0.75 * 16.0 # 2402.0
|
|
||||||
assert abs(new_sl - expected) < 0.01, f"Expected {expected}, got {new_sl}"
|
|
||||||
assert info_sell["trail_armed"] is True
|
|
||||||
|
|
||||||
# Ratchets down
|
|
||||||
info_sell["current_sl"] = new_sl
|
|
||||||
new_sl2 = daemon._compute_trail(info_sell, bar_high=2392.0, bar_low=2380.0)
|
|
||||||
assert new_sl2 is not None
|
|
||||||
assert new_sl2 < new_sl, "SELL trail must ratchet down"
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: _should_execute respects quality gate")
|
|
||||||
def _():
|
|
||||||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
|
||||||
daemon._quality_gate = "B"
|
|
||||||
daemon._state = {"trades": {}, "last_entries": {}}
|
|
||||||
daemon._state_lock = threading.Lock()
|
|
||||||
|
|
||||||
from signal_aggregator import TradeSetup
|
|
||||||
setup = TradeSetup(
|
|
||||||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
|
||||||
entry_price=2400.0, stop_loss=2384.0,
|
|
||||||
take_profit_1=2416.0, take_profit_2=2432.0,
|
|
||||||
risk_reward=1.0, risk_pips=160.0, reward_pips=160.0,
|
|
||||||
pip_value=0.10)
|
|
||||||
|
|
||||||
report_c = SignalReport(
|
|
||||||
symbol="XAU/USD", display_name="XAU/USD", timestamp="2026-07-20")
|
|
||||||
report_c.final_signal = SignalStrength.BUY
|
|
||||||
report_c.signal_quality = "C"
|
|
||||||
report_c.trade_setup = setup
|
|
||||||
|
|
||||||
assert not daemon._should_execute(report_c, Timeframe.H1), \
|
|
||||||
"C should be blocked by gate=B"
|
|
||||||
|
|
||||||
report_a = SignalReport(
|
|
||||||
symbol="XAU/USD", display_name="XAU/USD", timestamp="2026-07-20")
|
|
||||||
report_a.final_signal = SignalStrength.BUY
|
|
||||||
report_a.signal_quality = "A"
|
|
||||||
report_a.trade_setup = setup
|
|
||||||
|
|
||||||
assert daemon._should_execute(report_a, Timeframe.H1), \
|
|
||||||
"A should pass gate=B"
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: _should_execute blocks when position exists")
|
|
||||||
def _():
|
|
||||||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
|
||||||
daemon._quality_gate = "C"
|
|
||||||
daemon._state = {"trades": {"12345": {"direction": "BUY"}},
|
|
||||||
"last_entries": {}}
|
|
||||||
daemon._state_lock = threading.Lock()
|
|
||||||
|
|
||||||
from signal_aggregator import TradeSetup
|
|
||||||
setup = TradeSetup(
|
|
||||||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
|
||||||
entry_price=2400.0, stop_loss=2384.0,
|
|
||||||
take_profit_1=2416.0, take_profit_2=2432.0,
|
|
||||||
risk_reward=1.0, risk_pips=160.0, reward_pips=160.0,
|
|
||||||
pip_value=0.10)
|
|
||||||
|
|
||||||
report = SignalReport(
|
|
||||||
symbol="XAU/USD", display_name="XAU/USD", timestamp="2026-07-20")
|
|
||||||
report.final_signal = SignalStrength.BUY
|
|
||||||
report.signal_quality = "A"
|
|
||||||
report.trade_setup = setup
|
|
||||||
|
|
||||||
assert not daemon._should_execute(report, Timeframe.H1), \
|
|
||||||
"Should block when a position already exists"
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: NEUTRAL signal is never executed")
|
|
||||||
def _():
|
|
||||||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
|
||||||
daemon._quality_gate = "C"
|
|
||||||
daemon._state = {"trades": {}, "last_entries": {}}
|
|
||||||
daemon._state_lock = threading.Lock()
|
|
||||||
|
|
||||||
report = SignalReport(
|
|
||||||
symbol="XAU/USD", display_name="XAU/USD", timestamp="2026-07-20")
|
|
||||||
report.final_signal = SignalStrength.NEUTRAL
|
|
||||||
report.signal_quality = "F"
|
|
||||||
report.trade_setup = None
|
|
||||||
|
|
||||||
assert not daemon._should_execute(report, Timeframe.H1)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_daemon_report(quality="A", direction="BUY"):
|
|
||||||
from signal_aggregator import TradeSetup
|
|
||||||
setup = TradeSetup(
|
|
||||||
pair="XAU/USD", display_name="XAU/USD", direction=direction,
|
|
||||||
entry_price=2400.0, stop_loss=2384.0,
|
|
||||||
take_profit_1=2416.0, take_profit_2=2432.0,
|
|
||||||
risk_reward=1.0, risk_pips=160.0, reward_pips=160.0,
|
|
||||||
pip_value=0.10, trail_arm_r=0.6, trail_dist_r=0.85)
|
|
||||||
report = SignalReport(
|
|
||||||
symbol="XAU/USD", display_name="XAU/USD",
|
|
||||||
timestamp="2026-07-20 10:00:00+00:00")
|
|
||||||
report.final_signal = (SignalStrength.BUY if direction == "BUY"
|
|
||||||
else SignalStrength.SELL)
|
|
||||||
report.signal_quality = quality
|
|
||||||
report.trade_setup = setup
|
|
||||||
return report
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: 5-bar entry cooldown blocks re-entry (backtest parity)")
|
|
||||||
def _():
|
|
||||||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
|
||||||
daemon._quality_gate = "C"
|
|
||||||
daemon._state = {"trades": {}, "last_entries": {}}
|
|
||||||
daemon._state_lock = threading.Lock()
|
|
||||||
|
|
||||||
report = _make_daemon_report()
|
|
||||||
|
|
||||||
# Entry 2 minutes ago on M5 → inside 5×5min cooldown → blocked
|
|
||||||
recent = (datetime.now(timezone.utc) - timedelta(minutes=2)).isoformat()
|
|
||||||
daemon._state["last_entries"]["5min"] = recent
|
|
||||||
assert not daemon._should_execute(report, Timeframe.M5), \
|
|
||||||
"Entry 2min ago must block M5 (cooldown 25min)"
|
|
||||||
|
|
||||||
# Entry 30 minutes ago on M5 → past 5×5min cooldown → allowed
|
|
||||||
old = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat()
|
|
||||||
daemon._state["last_entries"]["5min"] = old
|
|
||||||
assert daemon._should_execute(report, Timeframe.M5), \
|
|
||||||
"Entry 30min ago must clear the 25min M5 cooldown"
|
|
||||||
|
|
||||||
# M5 cooldown must not block H1
|
|
||||||
daemon._state["last_entries"] = {"5min": recent}
|
|
||||||
assert daemon._should_execute(report, Timeframe.H1), \
|
|
||||||
"Cooldown is per-timeframe"
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: orders carry NO take-profit (trailing book parity)")
|
|
||||||
def _():
|
def _():
|
||||||
import tempfile
|
import tempfile
|
||||||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
bad = {"oanda": {"unknown_key": 1}}
|
||||||
daemon._state = {"trades": {}, "last_entries": {}}
|
|
||||||
daemon._state_lock = threading.Lock()
|
|
||||||
daemon._entry_lock = threading.Lock()
|
|
||||||
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
class MockExecutor:
|
|
||||||
def place_market_order(self, instrument, direction, stop_loss,
|
|
||||||
take_profit=None, units=None):
|
|
||||||
captured["take_profit"] = take_profit
|
|
||||||
captured["stop_loss"] = stop_loss
|
|
||||||
return {
|
|
||||||
"orderFillTransaction": {
|
|
||||||
"price": "2400.60",
|
|
||||||
"tradeOpened": {"tradeID": "777"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
daemon._executor = MockExecutor()
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
||||||
tmp_path = f.name
|
|
||||||
orig_state_file = oanda_daemon.STATE_FILE
|
|
||||||
try:
|
|
||||||
oanda_daemon.STATE_FILE = tmp_path
|
|
||||||
daemon._execute_trade(_make_daemon_report(), Timeframe.M15)
|
|
||||||
finally:
|
|
||||||
oanda_daemon.STATE_FILE = orig_state_file
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
|
|
||||||
assert captured.get("take_profit") is None, \
|
|
||||||
"Backtested trailing model has no hard TP — order must not carry one"
|
|
||||||
assert captured["stop_loss"] == 2384.0
|
|
||||||
trade = daemon._state["trades"]["777"]
|
|
||||||
assert trade["entry"] == 2400.60, "Risk must be computed from actual fill"
|
|
||||||
assert abs(trade["risk"] - 16.60) < 0.01
|
|
||||||
assert trade["trail_arm_r"] == 0.6 and trade["trail_dist_r"] == 0.85, \
|
|
||||||
"Trail params must come from the setup stamp (per-TF factory values)"
|
|
||||||
assert daemon._state["last_entries"]["15min"], \
|
|
||||||
"Entry must be recorded for the cooldown"
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: entry-lock recheck prevents double entry from concurrent scans")
|
|
||||||
def _():
|
|
||||||
import tempfile
|
|
||||||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
|
||||||
# A concurrent TF loop already opened a trade after our _should_execute
|
|
||||||
daemon._state = {"trades": {"111": {"direction": "BUY"}},
|
|
||||||
"last_entries": {}}
|
|
||||||
daemon._state_lock = threading.Lock()
|
|
||||||
daemon._entry_lock = threading.Lock()
|
|
||||||
|
|
||||||
placed = {"n": 0}
|
|
||||||
|
|
||||||
class MockExecutor:
|
|
||||||
def place_market_order(self, *a, **k):
|
|
||||||
placed["n"] += 1
|
|
||||||
return {}
|
|
||||||
|
|
||||||
daemon._executor = MockExecutor()
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
||||||
tmp_path = f.name
|
|
||||||
orig_state_file = oanda_daemon.STATE_FILE
|
|
||||||
try:
|
|
||||||
oanda_daemon.STATE_FILE = tmp_path
|
|
||||||
daemon._execute_trade(_make_daemon_report(), Timeframe.M5)
|
|
||||||
finally:
|
|
||||||
oanda_daemon.STATE_FILE = orig_state_file
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
|
|
||||||
assert placed["n"] == 0, \
|
|
||||||
"Recheck under the entry lock must skip when a position exists"
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: same closed bar is never processed twice")
|
|
||||||
def _():
|
|
||||||
import tempfile
|
|
||||||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
|
||||||
daemon._max_hold_bars = 300
|
|
||||||
daemon._price_precision = 2
|
|
||||||
daemon._state_lock = threading.Lock()
|
|
||||||
|
|
||||||
bar_time = pd.Timestamp("2026-07-20 10:00:00", tz="UTC")
|
|
||||||
df = pd.DataFrame(
|
|
||||||
{"Open": [2400.0], "High": [2405.0], "Low": [2398.0],
|
|
||||||
"Close": [2403.0], "Volume": [100.0]},
|
|
||||||
index=pd.DatetimeIndex([bar_time], name="Datetime"))
|
|
||||||
|
|
||||||
class MockProvider:
|
|
||||||
def fetch_ohlcv(self, symbol, tf):
|
|
||||||
return df
|
|
||||||
|
|
||||||
daemon._provider = MockProvider()
|
|
||||||
daemon._executor = None # trail must not arm on this bar
|
|
||||||
|
|
||||||
daemon._state = {
|
|
||||||
"trades": {
|
|
||||||
"555": {
|
|
||||||
"tf": "5min", "direction": "BUY", "entry": 2400.0,
|
|
||||||
"sl_initial": 2384.0, "current_sl": 2384.0, "risk": 16.0,
|
|
||||||
"peak": 2400.0, "trail_armed": False,
|
|
||||||
"trail_arm_r": 0.6, "trail_dist_r": 0.85,
|
|
||||||
"bars_held": 0, "last_bar_ts": None, "signal_bar_ts": None,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"last_entries": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
||||||
tmp_path = f.name
|
|
||||||
orig_state_file = oanda_daemon.STATE_FILE
|
|
||||||
try:
|
|
||||||
oanda_daemon.STATE_FILE = tmp_path
|
|
||||||
daemon._manage_trailing(Timeframe.M5)
|
|
||||||
daemon._manage_trailing(Timeframe.M5) # same bar re-served (weekend/lag)
|
|
||||||
daemon._manage_trailing(Timeframe.M5)
|
|
||||||
finally:
|
|
||||||
oanda_daemon.STATE_FILE = orig_state_file
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
|
|
||||||
assert daemon._state["trades"]["555"]["bars_held"] == 1, \
|
|
||||||
"A re-served bar must not inflate bars_held (weekend timeout bug)"
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: failed timeout-close keeps the trade tracked")
|
|
||||||
def _():
|
|
||||||
import tempfile
|
|
||||||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
|
||||||
daemon._max_hold_bars = 1
|
|
||||||
daemon._price_precision = 2
|
|
||||||
daemon._state_lock = threading.Lock()
|
|
||||||
|
|
||||||
bar_time = pd.Timestamp("2026-07-20 10:00:00", tz="UTC")
|
|
||||||
df = pd.DataFrame(
|
|
||||||
{"Open": [2400.0], "High": [2405.0], "Low": [2398.0],
|
|
||||||
"Close": [2403.0], "Volume": [100.0]},
|
|
||||||
index=pd.DatetimeIndex([bar_time], name="Datetime"))
|
|
||||||
|
|
||||||
class MockProvider:
|
|
||||||
def fetch_ohlcv(self, symbol, tf):
|
|
||||||
return df
|
|
||||||
|
|
||||||
class MockExecutor:
|
|
||||||
def close_trade(self, trade_id):
|
|
||||||
raise oanda_executor.OandaAPIError("market halted", 503)
|
|
||||||
|
|
||||||
daemon._provider = MockProvider()
|
|
||||||
daemon._executor = MockExecutor()
|
|
||||||
|
|
||||||
daemon._state = {
|
|
||||||
"trades": {
|
|
||||||
"666": {
|
|
||||||
"tf": "5min", "direction": "BUY", "entry": 2400.0,
|
|
||||||
"sl_initial": 2384.0, "current_sl": 2384.0, "risk": 16.0,
|
|
||||||
"peak": 2400.0, "trail_armed": False,
|
|
||||||
"trail_arm_r": 0.6, "trail_dist_r": 0.85,
|
|
||||||
"bars_held": 0, "last_bar_ts": None, "signal_bar_ts": None,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"last_entries": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
|
||||||
tmp_path = f.name
|
|
||||||
orig_state_file = oanda_daemon.STATE_FILE
|
|
||||||
try:
|
|
||||||
oanda_daemon.STATE_FILE = tmp_path
|
|
||||||
daemon._manage_trailing(Timeframe.M5)
|
|
||||||
finally:
|
|
||||||
oanda_daemon.STATE_FILE = orig_state_file
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
|
|
||||||
assert "666" in daemon._state["trades"], \
|
|
||||||
"A failed broker close must keep the trade tracked for retry"
|
|
||||||
|
|
||||||
|
|
||||||
@test("app_config: invalid oanda section values raise AppConfigError")
|
|
||||||
def _():
|
|
||||||
import tempfile
|
|
||||||
for bad in (
|
|
||||||
{"oanda": {"quality_gate": "Z"}},
|
|
||||||
{"oanda": {"lot_size": 0}},
|
|
||||||
{"oanda": {"max_hold_bars": -5}},
|
|
||||||
{"oanda": {"unknown_key": 1}},
|
|
||||||
):
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode="w", suffix=".json", delete=False) as f:
|
|
||||||
json.dump(bad, f)
|
|
||||||
tmp_path = f.name
|
|
||||||
try:
|
|
||||||
app_config.load_app_config(tmp_path)
|
|
||||||
assert False, f"Should have raised for {bad}"
|
|
||||||
except app_config.AppConfigError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: boundary timing matches telegram_bot")
|
|
||||||
def _():
|
|
||||||
from telegram_bot import _seconds_until_boundary as tb_boundary
|
|
||||||
from oanda_daemon import _seconds_until_boundary as od_boundary
|
|
||||||
for interval in (5, 15, 60, 240):
|
|
||||||
tb_val = tb_boundary(interval, buffer=10)
|
|
||||||
od_val = od_boundary(interval, buffer=10)
|
|
||||||
assert abs(tb_val - od_val) < 0.01, \
|
|
||||||
f"Boundary timing mismatch for {interval}min: {tb_val} vs {od_val}"
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: state file round-trip")
|
|
||||||
def _():
|
|
||||||
import tempfile
|
|
||||||
state = {
|
|
||||||
"trades": {
|
|
||||||
"999": {
|
|
||||||
"tf": "M15",
|
|
||||||
"direction": "BUY",
|
|
||||||
"entry": 2400.0,
|
|
||||||
"sl_initial": 2384.0,
|
|
||||||
"current_sl": 2392.0,
|
|
||||||
"risk": 16.0,
|
|
||||||
"peak": 2412.0,
|
|
||||||
"trail_armed": True,
|
|
||||||
"trail_arm_r": 0.6,
|
|
||||||
"trail_dist_r": 0.75,
|
|
||||||
"bars_held": 25,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
mode="w", suffix=".json", delete=False) as f:
|
mode="w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump(bad, f)
|
||||||
tmp_path = f.name
|
tmp_path = f.name
|
||||||
|
|
||||||
orig_state_file = oanda_daemon.STATE_FILE
|
|
||||||
try:
|
try:
|
||||||
oanda_daemon.STATE_FILE = tmp_path
|
app_config.load_app_config(tmp_path)
|
||||||
oanda_daemon._save_state(state)
|
assert False, f"Should have raised for {bad}"
|
||||||
loaded = oanda_daemon._load_state()
|
except app_config.AppConfigError:
|
||||||
assert loaded["trades"]["999"]["entry"] == 2400.0
|
pass
|
||||||
assert loaded["trades"]["999"]["trail_armed"] is True
|
|
||||||
assert loaded["trades"]["999"]["bars_held"] == 25
|
|
||||||
finally:
|
finally:
|
||||||
oanda_daemon.STATE_FILE = orig_state_file
|
|
||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
@@ -4301,78 +3751,6 @@ def _():
|
|||||||
f"Retry failure must keep the original 250-bar df, got {len(result)}"
|
f"Retry failure must keep the original 250-bar df, got {len(result)}"
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_executor: non-JSON response raises OandaAPIError with status")
|
|
||||||
def _():
|
|
||||||
def _bad_json(self):
|
|
||||||
raise ValueError("Expecting value: line 1 column 1 (char 0)")
|
|
||||||
|
|
||||||
mock_resp = type("MockResp", (), {
|
|
||||||
"status_code": 200,
|
|
||||||
"text": "<html>gateway error</html>",
|
|
||||||
"json": _bad_json,
|
|
||||||
})()
|
|
||||||
|
|
||||||
executor = oanda_executor.OandaExecutor.__new__(
|
|
||||||
oanda_executor.OandaExecutor)
|
|
||||||
executor._account_id = "test-account"
|
|
||||||
executor._base_url = "https://test"
|
|
||||||
executor._session = type("S", (), {
|
|
||||||
"request": lambda self, method, url, **kwargs: mock_resp,
|
|
||||||
"headers": {},
|
|
||||||
})()
|
|
||||||
|
|
||||||
try:
|
|
||||||
executor.get_open_trades()
|
|
||||||
assert False, "Should have raised OandaAPIError"
|
|
||||||
except oanda_executor.OandaAPIError as e:
|
|
||||||
assert e.status_code == 200, f"Expected status 200, got {e.status_code}"
|
|
||||||
assert "non-JSON" in str(e), f"Expected non-JSON in message: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: boot fails fast on rejected credentials (4xx)")
|
|
||||||
def _():
|
|
||||||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
|
||||||
|
|
||||||
class MockExecutor401:
|
|
||||||
def get_account_summary(self):
|
|
||||||
raise oanda_executor.OandaAPIError("unauthorized", 401)
|
|
||||||
|
|
||||||
daemon._executor = MockExecutor401()
|
|
||||||
try:
|
|
||||||
daemon._verify_account_access()
|
|
||||||
assert False, "A 4xx credential rejection must raise SystemExit"
|
|
||||||
except SystemExit:
|
|
||||||
pass
|
|
||||||
|
|
||||||
class MockExecutorConnFail:
|
|
||||||
def get_account_summary(self):
|
|
||||||
raise oanda_executor.OandaAPIError("conn refused", 0)
|
|
||||||
|
|
||||||
daemon._executor = MockExecutorConnFail()
|
|
||||||
daemon._verify_account_access() # transient — must NOT raise
|
|
||||||
|
|
||||||
|
|
||||||
@test("oanda_daemon: heartbeat never raises and logs on account failure")
|
|
||||||
def _():
|
|
||||||
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
|
||||||
daemon._state = {"trades": {}, "last_entries": {}}
|
|
||||||
daemon._state_lock = threading.Lock()
|
|
||||||
|
|
||||||
class MockExecutorDown:
|
|
||||||
def get_account_summary(self):
|
|
||||||
raise oanda_executor.OandaAPIError("down", 503)
|
|
||||||
|
|
||||||
daemon._executor = MockExecutorDown()
|
|
||||||
daemon._heartbeat_once() # must not raise
|
|
||||||
|
|
||||||
class MockExecutorUp:
|
|
||||||
def get_account_summary(self):
|
|
||||||
return {"balance": "1000"}
|
|
||||||
|
|
||||||
daemon._executor = MockExecutorUp()
|
|
||||||
daemon._heartbeat_once() # must not raise
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
# MODULE 16: Provider abstraction (providers/ package)
|
# MODULE 16: Provider abstraction (providers/ package)
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
@@ -5827,227 +5205,6 @@ def _():
|
|||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
print("\n═══ Module 21: 2026-08-04 audit fixes — execution lifecycle ═══")
|
print("\n═══ Module 21: 2026-08-04 audit fixes — execution lifecycle ═══")
|
||||||
|
|
||||||
import threading as _thr21
|
|
||||||
|
|
||||||
|
|
||||||
def _m21_daemon(state=None, quality_gate="C", max_hold=300):
|
|
||||||
d = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
|
|
||||||
d._state = state if state is not None else {"trades": {}, "last_entries": {}}
|
|
||||||
d._state_lock = _thr21.Lock()
|
|
||||||
d._entry_lock = _thr21.Lock()
|
|
||||||
d._quality_gate = quality_gate
|
|
||||||
d._max_hold_bars = max_hold
|
|
||||||
d._price_precision = 2
|
|
||||||
d._running = True
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def _m21_bar(ts="2026-08-04 10:00:00+00:00", high=2410.0, low=2395.0):
|
|
||||||
idx = pd.DatetimeIndex([pd.Timestamp(ts)])
|
|
||||||
return pd.DataFrame({"Open": [2400.0], "High": [high], "Low": [low],
|
|
||||||
"Close": [2405.0], "Volume": [1.0]}, index=idx)
|
|
||||||
|
|
||||||
|
|
||||||
def _m21_trade(**over):
|
|
||||||
base = {"tf": "5min", "direction": "BUY", "entry": 2400.0,
|
|
||||||
"sl_initial": 2384.0, "current_sl": 2384.0, "risk": 16.0,
|
|
||||||
"peak": 2400.0, "trail_armed": False, "trail_arm_r": 0.6,
|
|
||||||
"trail_dist_r": 0.85, "bars_held": 0,
|
|
||||||
"signal_bar_ts": None, "last_bar_ts": None}
|
|
||||||
base.update(over)
|
|
||||||
return base
|
|
||||||
|
|
||||||
|
|
||||||
@test("executor: close_trade raises when the close order did not fill")
|
|
||||||
def _():
|
|
||||||
ex = oanda_executor.OandaExecutor.__new__(oanda_executor.OandaExecutor)
|
|
||||||
ex._account_id, ex._base_url, ex._price_precision = "acc", "http://x", 2
|
|
||||||
|
|
||||||
class _Resp:
|
|
||||||
status_code = 200
|
|
||||||
text = "{}"
|
|
||||||
def json(self):
|
|
||||||
return {"orderCreateTransaction": {"id": "1"},
|
|
||||||
"orderCancelTransaction": {"reason": "MARKET_HALTED"}}
|
|
||||||
|
|
||||||
ex._request_with_retry = lambda *a, **k: _Resp()
|
|
||||||
try:
|
|
||||||
ex.close_trade("T1")
|
|
||||||
assert False, "close_trade must raise on a no-fill close response"
|
|
||||||
except oanda_executor.OandaAPIError as e:
|
|
||||||
assert "MARKET_HALTED" in str(e)
|
|
||||||
|
|
||||||
|
|
||||||
@test("daemon: timeout close that did not fill keeps the trade tracked")
|
|
||||||
def _():
|
|
||||||
d = _m21_daemon({"trades": {"T1": _m21_trade(bars_held=299)},
|
|
||||||
"last_entries": {}}, max_hold=300)
|
|
||||||
|
|
||||||
class _Exec:
|
|
||||||
def close_trade(self, tid):
|
|
||||||
raise oanda_executor.OandaAPIError(
|
|
||||||
"Close of trade T1 did not fill: MARKET_HALTED", 200, "")
|
|
||||||
|
|
||||||
class _Prov:
|
|
||||||
def fetch_ohlcv(self, sym, tf):
|
|
||||||
return _m21_bar()
|
|
||||||
|
|
||||||
d._executor, d._provider = _Exec(), _Prov()
|
|
||||||
d._manage_trailing(Timeframe.M5)
|
|
||||||
assert "T1" in d._state["trades"], \
|
|
||||||
"un-filled close must NOT pop the trade from state"
|
|
||||||
|
|
||||||
|
|
||||||
@test("daemon: fill without tradeOpened triggers reconciliation, not silence")
|
|
||||||
def _():
|
|
||||||
d = _m21_daemon()
|
|
||||||
recon = []
|
|
||||||
d._reconcile_positions = lambda reason: recon.append(reason)
|
|
||||||
|
|
||||||
class _Exec:
|
|
||||||
def place_market_order(self, **k):
|
|
||||||
return {"orderFillTransaction": {
|
|
||||||
"id": "5001", "price": "2400.5",
|
|
||||||
"tradesClosed": [{"tradeID": "OLD1"}]}}
|
|
||||||
|
|
||||||
d._executor = _Exec()
|
|
||||||
ts = TradeSetup(pair="XAU_USD", display_name="XAU/USD",
|
|
||||||
direction="SELL", entry_price=2400.0, stop_loss=2416.0,
|
|
||||||
take_profit_1=2384.0, take_profit_2=2376.0,
|
|
||||||
risk_reward=1.0, risk_pips=160.0, reward_pips=160.0,
|
|
||||||
pip_value=0.1)
|
|
||||||
rep = SignalReport(symbol="XAU_USD", display_name="XAU/USD",
|
|
||||||
timestamp="2026-08-04 09:55:00+00:00")
|
|
||||||
rep.trade_setup, rep.signal_quality = ts, "B"
|
|
||||||
rep.final_signal = SignalStrength.SELL
|
|
||||||
d._execute_trade(rep, Timeframe.M5)
|
|
||||||
assert recon, "unexpected fill shape must trigger _reconcile_positions"
|
|
||||||
assert not d._state["trades"]
|
|
||||||
|
|
||||||
|
|
||||||
@test("daemon: placement OandaAPIError triggers reconciliation")
|
|
||||||
def _():
|
|
||||||
d = _m21_daemon()
|
|
||||||
recon = []
|
|
||||||
d._reconcile_positions = lambda reason: recon.append(reason)
|
|
||||||
|
|
||||||
class _Exec:
|
|
||||||
def place_market_order(self, **k):
|
|
||||||
raise oanda_executor.OandaAPIError("timeout after retries")
|
|
||||||
|
|
||||||
d._executor = _Exec()
|
|
||||||
ts = TradeSetup(pair="XAU_USD", display_name="XAU/USD",
|
|
||||||
direction="BUY", entry_price=2400.0, stop_loss=2384.0,
|
|
||||||
take_profit_1=2416.0, take_profit_2=2432.0,
|
|
||||||
risk_reward=1.0, risk_pips=160.0, reward_pips=160.0,
|
|
||||||
pip_value=0.1)
|
|
||||||
rep = SignalReport(symbol="XAU_USD", display_name="XAU/USD",
|
|
||||||
timestamp="2026-08-04 09:55:00+00:00")
|
|
||||||
rep.trade_setup, rep.signal_quality = ts, "B"
|
|
||||||
rep.final_signal = SignalStrength.BUY
|
|
||||||
d._execute_trade(rep, Timeframe.M5)
|
|
||||||
assert recon, "placement error must trigger _reconcile_positions " \
|
|
||||||
"(server may have filled before the error)"
|
|
||||||
|
|
||||||
|
|
||||||
@test("daemon: reconcile adopts naked position with risk=0 and no fake SL")
|
|
||||||
def _():
|
|
||||||
d = _m21_daemon()
|
|
||||||
|
|
||||||
class _Exec:
|
|
||||||
def get_open_trades(self, instrument=None):
|
|
||||||
return [{"id": "N1", "instrument": "XAU_USD",
|
|
||||||
"currentUnits": "1", "price": "2400.0"}] # no stopLossOrder
|
|
||||||
|
|
||||||
d._executor = _Exec()
|
|
||||||
d._reconcile_positions("test")
|
|
||||||
info = d._state["trades"]["N1"]
|
|
||||||
assert info["risk"] == 0.0, "risk must be 0 (undefined), not entry-diff"
|
|
||||||
assert d._compute_trail(dict(info), 2500.0, 2400.0) is None, \
|
|
||||||
"risk=0 must never arm the trail (no invented stop)"
|
|
||||||
|
|
||||||
|
|
||||||
@test("daemon: trade popped mid-trailing is NOT resurrected by write-back")
|
|
||||||
def _():
|
|
||||||
d = _m21_daemon({"trades": {"R1": _m21_trade()}, "last_entries": {}})
|
|
||||||
|
|
||||||
class _Exec:
|
|
||||||
def get_open_trades(self, instrument=None):
|
|
||||||
return []
|
|
||||||
def modify_trade_sl(self, tid, sl):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
d._executor = _Exec()
|
|
||||||
|
|
||||||
class _Prov:
|
|
||||||
def fetch_ohlcv(self, sym, tf):
|
|
||||||
d._check_closed_trades() # interleave: broker closed R1
|
|
||||||
assert "R1" not in d._state["trades"]
|
|
||||||
return _m21_bar()
|
|
||||||
|
|
||||||
d._provider = _Prov()
|
|
||||||
d._manage_trailing(Timeframe.M5)
|
|
||||||
assert "R1" not in d._state["trades"], \
|
|
||||||
"write-back must not re-insert a trade closed by the broker"
|
|
||||||
|
|
||||||
|
|
||||||
@test("daemon: trailing snapshot no longer aliases live state dicts")
|
|
||||||
def _():
|
|
||||||
d = _m21_daemon({"trades": {"S1": _m21_trade()}, "last_entries": {}})
|
|
||||||
with d._state_lock:
|
|
||||||
snap = {tid: dict(info)
|
|
||||||
for tid, info in d._state.get("trades", {}).items()}
|
|
||||||
assert snap["S1"] is not d._state["trades"]["S1"]
|
|
||||||
|
|
||||||
|
|
||||||
@test("daemon: unknown quality gate refuses to start (fail closed)")
|
|
||||||
def _():
|
|
||||||
real_init = oanda_daemon.TradingDaemon.__init__
|
|
||||||
# Drive just the validation logic the ctor now applies.
|
|
||||||
gate = "Bb"
|
|
||||||
assert gate not in oanda_daemon.QUALITY_RANK
|
|
||||||
d = _m21_daemon(quality_gate="Bb")
|
|
||||||
# _should_execute is unreachable in prod with a bad gate (ctor exits);
|
|
||||||
# the ctor check is the contract:
|
|
||||||
src = open(os.path.join(os.path.dirname(__file__),
|
|
||||||
"oanda_daemon.py"), encoding="utf-8").read()
|
|
||||||
assert "refusing to start" in src and "not in QUALITY_RANK" in src
|
|
||||||
|
|
||||||
|
|
||||||
@test("executor: market orders request positionFill=OPEN_ONLY")
|
|
||||||
def _():
|
|
||||||
ex = oanda_executor.OandaExecutor.__new__(oanda_executor.OandaExecutor)
|
|
||||||
ex._account_id, ex._base_url, ex._price_precision = "acc", "http://x", 2
|
|
||||||
ex._lot_size = 1
|
|
||||||
sent = {}
|
|
||||||
|
|
||||||
class _Resp:
|
|
||||||
status_code = 200
|
|
||||||
text = "{}"
|
|
||||||
def json(self):
|
|
||||||
return {"orderFillTransaction": {
|
|
||||||
"price": "2400.0", "tradeOpened": {"tradeID": "T9"}}}
|
|
||||||
|
|
||||||
def _req(method, url, retries=None, **kw):
|
|
||||||
sent.update(kw.get("json", {}))
|
|
||||||
return _Resp()
|
|
||||||
|
|
||||||
ex._request_with_retry = _req
|
|
||||||
ex.place_market_order("XAU_USD", "BUY", stop_loss=2384.0)
|
|
||||||
assert sent["order"]["positionFill"] == "OPEN_ONLY"
|
|
||||||
|
|
||||||
|
|
||||||
@test("daemon: _oanda_setting_int rejects a non-integer env value cleanly")
|
|
||||||
def _():
|
|
||||||
os.environ["OANDA_LOT_SIZE"] = "one"
|
|
||||||
try:
|
|
||||||
oanda_daemon._oanda_setting_int("OANDA_LOT_SIZE", "lot_size", 1)
|
|
||||||
assert False, "must SystemExit on a non-integer env value"
|
|
||||||
except SystemExit as e:
|
|
||||||
assert "not an integer" in str(e)
|
|
||||||
finally:
|
|
||||||
os.environ.pop("OANDA_LOT_SIZE", None)
|
|
||||||
|
|
||||||
|
|
||||||
@test("GPU parity: ALL indicators + composite identical on every prefix")
|
@test("GPU parity: ALL indicators + composite identical on every prefix")
|
||||||
def _():
|
def _():
|
||||||
|
|||||||
Reference in New Issue
Block a user