config.json.example: data_providers.active = "oanda" (credentials stay in env/.env or the top-level oanda block; TD and Tiingo remain registered fallbacks). Backtest parquet cache is now keyed per provider — Oanda and TD bars diverge (2026-07-24 study), so a feed switch must never serve another feed's cached bars; twelve_data keeps the legacy un-prefixed filename so pinned-book caches stay valid. 254 tests green; live smoke-tested against the practice API (66 contract checks: all scanned TFs, boundary/freshness guarantees, real spread $0.71, fetch_range pagination, typed error paths, CLI + backtest end-to-end). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mc7XKYyPySVXiUpcN82QEK
52 KiB
ForexBot2 — Codebase Reference
Signal-intelligence platform for XAU/USD (gold — live, validated). It fetches OHLCV from the active data provider (Oanda v20 since 2026-07-28; Twelve Data and Tiingo stay registered as fallbacks — config.json data_providers.active switches), scores 9 technical indicators per timeframe with regime-adaptive weights, checks multi-timeframe agreement and news/calendar risk, grades the signal (A+…F), and generates a full trade setup (entry, SL, TP1/TP2, trailing plan). Delivery: Telegram bot (auto-scanner + subscriber channel) and a CLI. A realism-focused backtester replays history through the exact same signal code.
This file is the authoritative developer/agent reference. The human-facing docs live in docs/ (user_guide.html + beginner_guide.html, and their PDF builds) — keep them in sync when signal behavior changes. backtest/instructions_for_backtest.txt lists what every backtest report must record.
Read this whole file before changing signal logic. Every numeric threshold below was either backtested or is load-bearing for a backtested result. Rules for changes are at the bottom ("Rules for making changes").
Repository map
| Path | Lines | Purpose |
|---|---|---|
config.py |
~374 | All config dataclasses, PAIR_INFO registry, per-pair strategy configs. PlatformConfig defaults ARE the tuned XAU config. |
app_config.py |
~290 | config.json loader/validation, env-precedence resolvers, repo-root .env loader (setdefault at import), apply_pair_overrides deployment overlay |
config.json.example |
24 | committed schema template; real gitignored config.json holds secrets + deployment settings |
providers/base.py |
~400 | BaseDataProvider: shared cache, validation/cleaning, freshness + boundary guarantees, fetch_range pagination, streaming stubs. Subclasses implement only _fetch_raw |
providers/twelve_data.py |
~240 | TwelveDataProvider (alias ForexDataProvider): TD SDK fetch, rate-limit (55 credits/min), WS quote streaming |
providers/oanda.py |
~190 | OandaDataProvider: v20 REST MBA candles, real bid/ask spread, date-window fetch |
providers/tiingo.py |
~200 | TiingoDataProvider: FX endpoint mid-price candles (no volume, no spread — see Tiingo note), named errors for every failure shape |
providers/__init__.py |
~55 | PROVIDER_REGISTRY + make_data_provider (config.json data_providers.active selects) |
data_provider.py / oanda_provider.py |
~25 | back-compat shims re-exporting from providers/ |
engine.py |
~1173 | TechnicalEngine: computes all indicators on one DataFrame → TechnicalAnalysis (composite score, regime, key levels) |
chart_patterns.py |
~280 | Chart-pattern detectors for the XAU_CP archetype (pure functions, ATR-unit geometry, first-close-beyond triggers) |
signal_aggregator.py |
~1447 | SignalAggregator: MTF analysis, news integration, gates/penalties, quality grade, trade-setup generation → SignalReport |
news_engine.py |
~680 | Economic calendar (Forex Factory JSON + MyFXBook RSS fallback) + keyword RSS sentiment |
telegram_bot.py |
~1259 | Async Telegram bot: candle-boundary auto-scanner, WebSocket level-breach monitor, owner lock, subscriber channel |
pair_signals/__init__.py |
52 | PAIR_PRODUCERS registry + produce_signal() |
pair_signals/xauusd.py |
71 | XAU config factory + per-timeframe overrides (docstring = validation record) |
main.py |
~348 | CLI: python main.py --pair XAUUSD --timeframe H1 --detail/--json |
test_all.py |
~4100 | QA suite: 253 tests in 16 modules, no live API needed. Must stay green. |
backtest/gpu_backtest.py |
~2231 | Bar-replay backtester (CuPy→NumPy fallback) using the real SignalAggregator |
backtest/diagnose_filters.py |
609 | Filter-diagnosis tooling |
backtest/research/ |
— | Calendar research infra (ff_calendar_scraper.py, calendar_store.py) + result CSVs |
backtest/*.csv |
— | Verification trade books from past tuning campaigns (xau_*.csv, …). Gitignored, local-only — used for manual before/after comparison, not by tests |
backtest/cache/ |
— | Parquet OHLCV cache (raw bars) |
logs/telegram.log |
— | Bot log (3-hour rotation × 240 backups = 30-day retention) |
Pair status
- XAU/USD: the tuned, validated product — and the ONLY supported pair. All defaults in
PlatformConfig/XauStrategyConfigare its tuned values. - EUR/USD: REMOVED from the codebase (2026-07-16, branch
xau-only-chart-patterns). It never had a validated edge: the 2026-07-11/12 verdict found no configuration passing validation on M5, M15, or H1 at a 1-pip round-trip cost; root cause was cost-to-volatility (EUR M15 ATR ≈ 10 pips → 1-pip spread taxes ~10% of ATR; gold's ATR/spread ratio is several times larger). Calendar-surprise and H1-hybrid avenues were also tested and closed. The full EUR implementation and verdict history live in git history (last commit with EUR support:7d3fd95). Do not re-add EUR without new data (tick fills or H4/D1 horizons).
End-to-end pipeline (what happens on every scan)
Twelve Data REST (+ WebSocket ticks in the bot)
→ data_provider.fetch_ohlcv() per timeframe: throttle, fetch, drop forming candle, validate/clean, cache
→ engine.TechnicalEngine.analyze() per timeframe: 9 scored indicators + ATR context → regime → composite score
→ signal_aggregator.analyze_pair() live entry point (weekend/day filters, fetch, staleness)
└─ analyze_from_dataframes() replayable core: MTF agreement → news → bonuses/penalties →
final signal + quality grade → gates (session, SELL, M5, regime) →
trade setup (3 generators) → reasons
→ SignalReport consumed by telegram_bot.py, main.py, backtest/gpu_backtest.py
Signal currency everywhere is the SignalStrength enum: STRONG_BUY=2, BUY=1, NEUTRAL=0, SELL=-1, STRONG_SELL=-2.
config.py — every knob
PAIR_INFO (market constants; key is the canonical symbol "XAU/USD")
| Field | XAU/USD | Meaning |
|---|---|---|
pip_value |
0.10 | price units per pip |
price_precision |
2 | decimals for SL/TP rounding & display |
default_spread |
0.30 | round-trip cost charged by the backtester when --spread not given |
TechnicalConfig (indicator parameters)
EMA 9/21/50/200 (ema_mode: "4ema" default or "2ema" = 50/200 only); RSI 14 (OB 70 / OS 30 / neutral zone 40–60; rsi_mode: "regime_aware" default or "classic"); MACD 12/26/9 (macd_as_pillar=True — MACD is a scored indicator, not just divergence input); BB 20/2.0σ; ATR 14; ADX 14 (strong ≥25, very strong ≥50); divergence lookback 40; swing-S/R lookback 50; ROC period 10, threshold 1.0%; Keltner: EMA 20 mid ± 1.5 × ATR(10); ADX slope lookback 5, "emerging" threshold 3.0; disabled_indicators list (removes the pillar from scoring; for MACD the histogram is still computed — divergence needs it); regime_weights_override dict (replaces the gold regime-multiplier table for experiments).
SignalConfig (scoring & trade geometry — XAU-calibrated)
| Field | Default | Meaning |
|---|---|---|
mtf_agreement_threshold |
2 | timeframes (of primary + confirmations) that must agree |
min_rr_ratio |
0.5 | reject setups below this RR |
tp_tightening_factor |
1.0 | TP scale factor. Leave at 1.0: 0.8 was re-tested 2026-07-08 with spread+gap fills and nearly halved total R |
round_trip_cost |
0.30 | XAU $ round trip |
min_tp_cost_multiple |
30.0 | spread gate: TP1 distance must be ≥ cost × this (= $9 on XAU). Validated: sub-$10 targets lose |
min_sl_pips |
80.0 | SL floor in pips ($8 on XAU) |
tp_floor_rr |
0.5 | TP1 floor as fraction of SL distance (M15 factory overrides to 1.0) |
trail_arm_r / trail_dist_r |
0.6 / 0.75 | trailing exit: once favorable excursion ≥ arm×risk, stop trails dist×risk behind best price (bar-close updates). TP1 becomes advisory. Validated on 12mo M5: +105R vs +48R static. 0 disables. M15 factory overrides arm to 0.70 (2026-07-21 grid: +210.7R vs +175.7R/5yr, OOS +57.4 vs +45.4; DD clause waived by owner); M5 factory overrides dist to 0.85 (same-day grid: +231.2R vs +218.0R/5yr, OOS +14.5 vs +11.1, DD flat; dist 1.0 rejected at the OOS gate — see factory docstring) |
pb_trail_arm_r / pb_trail_dist_r |
0.8 / 0.75 | trailing params for XAU_PB pullback trades (tighter stops → arm later) |
NewsConfig
Tracked currencies ["USD","XAU"]; pre-event buffer 30 min, post-event 15 min (45 min after a surprise print); event_risk_caps_signals=False — XAU default: event risk warns but does NOT cap confidence (2026-07-11 audit: the caps suppressed trades worth +4.6..+7.2R/yr; XAU trades near USD releases outperform); sentiment_affects_signals=False — headline sentiment is informational only (warnings still emitted; the +5 alignment bonus and 10-pt news_conflict penalty are gated off, so live decisions are pure technical, matching backtests). Context cache TTL 600 s (must stay < pre-event buffer). 4 RSS feeds (investinglive, FXStreet, Google-News gold query, CNBC economy).
XauStrategyConfig (all XAU gates; enabled=True)
| Field | Default | Notes |
|---|---|---|
blocked_hours_utc |
[0,1,2,10,11,13,16,21] | bar-close hours never traded. M5 factory appends 9 (post-London-open whipsaw, −28.9R/143 over 6 yr) |
allowed_weekdays |
[0,1,2,3,4,6] | Mon–Fri + Sun (Sydney open). weekend_filter=True: Fri 22:00 → Sun 22:00 UTC closed |
min_atr_threshold |
1.0 | $1 ATR floor for swing-based setups |
v2_sl_atr_multiplier |
2.0 | SL ATR floor in the generic setup generator |
sl_atr_multiplier / tp_atr_multiplier |
2.0 / 1.0 | used by the XAU ATR setup generator (A+/A only) |
sell_min_quality |
["A+","A","B"] | SELLs below B are blocked |
sell_vol_regimes |
["high","extreme"] | SELLs only in these volatility regimes (quiet shorts fade vs gold's macro uptrend) |
blocked_regimes / sell_blocked_regimes |
[] / [] | per-TF factory opt-ins. M15 factory sets blocked_regimes=["trending"] (mixed regime: strong ADX, EMAs unaligned — lost −6.8R/24) |
sell_blocked_hours_utc |
[] | bar-CLOSE UTC hours where SELLs are blocked (BUYs unaffected). Per-TF factory opt-in: M15 sets [15, 17] (NY-session shorts, −33.5R/165 over 5 yr, negative 6/6 years both halves; BUYs there +34.3R) |
| M5 gates | m5_require_mtf=True; m5_min_abs_score=30; m5_min_confirmation_score=10; m5_sell_max_quality=["B","C"] (A-grade M5 SELLs enter at dip extremes and lose); m5_blocked_vol_regimes=["high"] (chop transition — 61% WR, negative total); m5_sell_min_rsi=25 |
|
| M5 pullback (XAU_PB) | m5_pullback_enabled=True; min score 15 (lower than gate 1 BY DESIGN — mid-pullback scores weaker); ADX ≥ 20; RSI zones BUY (35,62) / SELL (38,65); retrace depth 0.8–6.0 ATR from the 40-bar swing extreme; band touch within 3 bars. Validated +38.3R/254 trades on 12mo |
|
| Chart patterns (XAU_CP) | cp_enabled=False |
cp_* fields: pattern list, lookback 72, swing window 3, recency 20, double tolerance 0.5 ATR, height 1.5–8 ATR, H&S head ≥0.5 ATR / shoulder tol 1.0 ATR, flag pole ≥3 ATR in ≤12 bars, flag 3–12 bars retracing 10–50%. Textbook defaults, NOT tuned. Default OFF — M5 factory enables BUY-only (inv_hs, double_bottom, bull_flag); M15/H1/H4 remain disabled (all failed OOS validation 2026-07-16). Bearish patterns (hs_top, double_top, bear_flag) dropped: SELL CP lost −3.21R on OOS, −4.52R on M15 5yr |
FilterConfig (config.backtest — despite the name it applies LIVE too)
session_filter=True, window 03:00–23:00 UTC, blocked_hours_utc mirrors XAU's list (non-XAU pairs read this copy), allowed_weekdays Mon–Fri+Sun. regime_filter=False (optional H4-ADX≥25 gate, off).
PlatformConfig (top level)
symbol="XAU/USD", primary TF H1, confirmations [M15, H4], data_lookback_days=60, scan_pairs=[XAUUSD] (mirrors config.json scanning.pairs — see below), scan_timeframes=[M5,M15,H1,H4], ws_cooldown_seconds=300.
Per-pair base blocked hours plus scan_pairs/scan_timeframes overlay from config.json via app_config.apply_pair_overrides, applied in the pair factory BEFORE per-TF deltas. Credentials, alert quality, and log level never touch PlatformConfig — telegram_bot.py and data_provider.py read them directly from app_config.get_app_config() and its resolvers. scanning.pairs in config.json is the deployment switch for what the bot scans — PlatformConfig.scan_pairs just carries the resolved value.
engine.py — TechnicalEngine
Input contract: pandas DataFrame, UTC tz-aware DatetimeIndex, oldest-first, columns Open High Low Close Volume (capitalized). < 30 bars → empty NEUTRAL result. EMA200 needs ≥ 200 bars (otherwise EMA-trend indicator degrades gracefully).
Compute order matters: ATR first (EMA-ribbon and candlestick proximity use it) → EMA → RSI → BB → ADX → MACD (always computed, even if not a pillar — divergence needs the histogram) → ROC → Keltner (needs BB levels + MACD histogram) → divergence → structure S/R → candlesticks → regime classification → regime-aware RSI reinterpretation → composite.
Results land in TechnicalAnalysis.indicators: Dict[str, IndicatorSignal] (fields: name, value, signal, confidence 0-100, description, weight). Private series ride on the result object as underscore attrs (_rsi_series, _macd_histogram, _adx_slope, _kc_squeeze, _kc_squeeze_released) and are skipped by the composite scorer and all formatters (skip rule: name starts with _ or weight == 0).
The 9 scored indicators (+ ATR as context)
| Key | Base weight | Signal logic (exact) |
|---|---|---|
ema_trend |
1.0 | 4-EMA mode: alignment score over checks (price>EMA9, 9>21, 21>50, 50>200), normalized to [-1,1]. >0.6 STRONG_BUY (conf ≤90), >0.2 BUY, mirrored sells, else NEUTRAL (40). Fast/medium cross ±10 conf. Ribbon (9/21/50 spread < 1 ATR) = "COMPRESSED"; compressed→expanded = +10 conf. Emits ema_fast/medium/slow/trend key levels |
rsi |
1.5 | Wilder RSI (ewm α=1/14). Classic pass first: ≥70 SELL, ≤30 BUY, ≥60 BUY-momentum (45), ≤40 SELL-momentum (45), else NEUTRAL (30). Then overwritten by regime reinterpretation (below) when rsi_mode="regime_aware" |
macd |
1.5 | Histogram = MACD line − signal line. Cross above 0 → BUY (conf 55+); accelerating (hist>prev>0) → STRONG_BUY; mirrored down; positive-but-fading / negative-but-fading → NEUTRAL (35); flat → NEUTRAL (30). Confidence scaled by |
bollinger |
1.5 | Position = (close−lower)/(upper−lower). >1.0 SELL (65), <0.0 BUY (65), >0.8 SELL (45), <0.2 BUY (45), else NEUTRAL (30). Squeeze = width < 0.75 × 20-bar avg width → +10 conf. Emits bb_upper/lower/mid |
adx |
0.5 | Wilder ADX/DI (ties zero BOTH DMs — fixes a systematic bearish nudge). ADX ≥ 25 → BUY/SELL by DI dominance, else NEUTRAL. Conf 40/65/80 by ADX band. ADX slope (5-bar diff): <25 with slope > 3.0 → "TREND EMERGING" +15 conf; also strengthening/weakening adjustments. Sets _adx_slope |
roc |
1.5 | 10-period %change. Positive & rising → BUY (STRONG if >2× threshold=2%); negative & falling → SELL (zero-crosses land in these branches by definition — a separate cross branch was provably dead and removed 2026-07-23); else NEUTRAL. Directionally redundant with MACD/Keltner/EMA (0.8–0.94 corr) but its weight is load-bearing: zeroing it backtested 13R WORSE — composite thresholds are calibrated with it. Do not remove without retuning thresholds |
keltner |
1.5 | EMA20 mid ± 1.5×ATR(10). TTM squeeze = BB fully inside KC. Squeeze released (was→not) + MACD hist sign → STRONG_BUY/STRONG_SELL (80); squeeze active → NEUTRAL (60); close above/below channel → BUY/SELL continuation (65); inside → NEUTRAL (30). Emits kc_upper/lower/mid |
divergence |
1.5 | RSI + MACD-hist vs price over 40 bars. Swing points (window 3) must align within ±2 bars to pair up; last two pairs: price LL + indicator HL = bullish, price HH + indicator LH = bearish. Any found → BUY/SELL/NEUTRAL-if-both, conf 70, value = count |
candlestick |
1.0 | Last bar: Doji (body<10% range), Hammer/Hanging Man (lower wick>2×body), Shooting Star/Inverted Hammer, Engulfing (vs prev bar), Marubozu (body>85%). Majority vote → signal, conf 50; +20 if pattern sits within 0.5 ATR of a swing S/R level |
atr |
0.0 (context) | Wilder ATR. Volatility regime from percentile vs last 50 ATR values: >80 extreme, >60 high, >30 normal, else low. Emits atr, atr_pct |
Structure S/R (not scored): swing highs/lows (window 3) over swing_sr_lookback (min 120 bars on 1min/5min TFs); up to 3 above and 3 below stored as swing_high_1..3 / swing_low_1..3 key levels.
Market regime classification (exact rules)
- ADX ≥ 25 and EMA-trend signal bullish →
trending_up; bearish →trending_down; EMA mixed →trending(the "mixed" regime — momentum without directional structure; treated as direction-UNKNOWN everywhere; a past bullish-bias bug here cost −6.8R and is fixed) - else ADX slope > 3.0 →
transitioning(trend birth, ~5–10 bars before ADX crosses 25) - else →
ranging
Regime-aware RSI reinterpretation (rsi_mode="regime_aware", the default — classic mode backtested worse for gold)
| Regime | RSI zones |
|---|---|
| trending_up | 38–55 STRONG_BUY 75 ("pullback zone, ideal entry"); 30–38 BUY 60; 55–65 BUY 50; >65 NEUTRAL ("extended, wait"); <30 NEUTRAL ("possible reversal") |
| trending_down | mirrored: 45–62 STRONG_SELL 75; 62–70 SELL 60; 35–45 SELL 50; <35 / >70 NEUTRAL |
| transitioning / trending (mixed) | ONLY extremes: ≥70 SELL 50, ≤30 BUY 50, else NEUTRAL 30 |
| ranging | ≥70 SELL 70, ≤30 BUY 70, ≥60 SELL 45, ≤40 BUY 45, else NEUTRAL |
Composite score
For each scored indicator: effective_weight = base_weight × regime_multiplier; contribution = signal.value × effective_weight × confidence/100. Composite = (Σ contributions / Σ effective_weights) × 50, clamped to ±100. Confidence = weight-averaged indicator confidence. Composite signal: ≥40 STRONG_BUY, ≥15 BUY, ≤−40 STRONG_SELL, ≤−15 SELL, else NEUTRAL.
Gold regime multipliers (defaults in _compute_composite; technical.regime_weights_override replaces the whole dict):
| Indicator | trending_up/down/trending | ranging | transitioning |
|---|---|---|---|
| ema_trend | 2.0 | 0.3 | 1.5 |
| rsi | 1.0 | 1.5 | 1.0 |
| macd | 2.0 | 1.5 | 2.5 |
| bollinger | 0.5 | 1.5 | 1.0 |
| keltner | 1.5 | 2.0 | 2.5 |
| roc | 1.5 | 0.5 | 2.0 |
| candlestick | 0.8 | 1.0 | 1.0 |
| divergence | 1.0 | 2.5 | 2.0 |
| adx | 0.8 | 0.5 | 1.0 |
signal_aggregator.py — SignalAggregator
Two entry points:
analyze_pair(symbol)— LIVE: applies XAU weekend + allowed-weekday filters on wall clock, fetches all TFs (primary + confirmations), flags stale data (primary stale → signal suppressed to NEUTRAL/F), delegates toanalyze_from_dataframes, then applies the live session check on the bar-CLOSE time (bar timestamp + candle duration): outside 03:00–23:00 or in blocked hours → NO TRADE. This is the exact predicate the backtester replays, so live == backtest.analyze_from_dataframes(symbol, dfs, context, use_news)— replayable core; treats each df as the visible history at decision time (caller must slice; no weekend filter here — the backtester applies it).
Pipeline inside analyze_from_dataframes (numbered as in the source)
- Primary TF analysis → seeds report (score, confidence, regime, vol regime, price, timestamp = last bar's index label).
- Confirmation TF analyses →
mtf_signals/mtf_scoreskeyed by TF string ("5min", "1h", …). - MTF agreement: count BUY-ish vs SELL-ish across ALL TFs (primary included); agreement = count ≥ 2 in the primary's direction (two bullish confirms cannot "confirm" a bearish primary).
- News (if
use_news): event risk (clear/caution/danger) + pair sentiment (−1..+1). Backtests passuse_news=False. - Spread estimate = median(High−Low) of primary df.
_compute_final_signal(bonuses → event cap → penalties → thresholds → grade):- Bonuses: MTF agreement → +10 conf and
score = 0.6×primary + 0.4×mean(all TF scores); news sentiment aligned (|sent|>0.2) → +5 (gated bynews.sentiment_affects_signals, False by default); in a directional trend, RSI in pullback zone → +5, BB position favorable → +5 (mixedtrendingregime gets NO directional bonus). - Event risk
danger: warning always; confidence hard-capped at 30 ONLY ifnews.event_risk_caps_signals(False by default — XAU-tuned). - Penalty system (diminishing, replaces old additive stacking): collect, sort by size desc, apply with decay ×1.0, ×0.5, ×0.25…, total capped at 30. Penalties:
mtf_disagreement15;news_conflict10 (gated bysentiment_affects_signals, off by default — warning still emitted);event_caution15 (config-gated);counter_trend15;extreme_vol10;ema200_near5 (price within 0.5% of EMA200 — envFB2_EMA_LOoverrides);ema200_far5 (>2.5%);exhaustion8 (price >3.5 ATR beyond the FARTHEST of the 3 tracked swing levels — fires earlier than a nearest-swing rule would; calibrated as-is in every validated book);no_pullback8 (>2.5 ATR from EMA21 in trend) /weak_pullback5 (>2.0);rsi_extended5 (RSI>65 with BUY / <35 with SELL in any strong-ADX regime, both directions in mixedtrending);bb_overextended8 (BB pos >0.85 BUY / <0.15 SELL). - Exhaustion hard block: ≥2 exhaustion-tagged penalties ({exhaustion, no_pullback, rsi_extended, bb_overextended} →
report.exhaustion_count) AND momentum fading (ROC sign against score, or MACD signal against score) → confidence = 0. - Confidence clamped [0, 95]. Final signal: score ≥35 & conf ≥55 STRONG_BUY; ≥25 & ≥50 BUY; mirrored sells; else NEUTRAL.
- Quality grade: |score|≥40 & conf≥65 & MTF → A+; ≥33 & ≥55 & MTF → A; ≥28 & ≥50 → B; ≥25 & ≥45 → C; ≥15 → D; else F.
- Bonuses: MTF agreement → +10 conf and
- Post-grade gates, in order:
- 6b session filter (bar-label hour + weekday; XAU reads
xau_strategy.blocked_hours_utc/allowed_weekdays, others readconfig.backtest.*) → NEUTRAL/F. - 6c transitioning boost: regime
transitioningand grade B/C/D → A if |score|≥30 & conf≥50 & MTF; else B if |score|≥25 & conf≥45 (early trend entries). - 6d exhausted-trend cap: trending regime + exhaustion_count ≥2 + grade C/D → D, setup killed.
- 6e XAU SELL quality gate: sell-direction & grade actionable (A+..C) & not in
sell_min_quality→ D. The actionable guard is load-bearing: without it a session-blocked F report was upgraded to D, re-opening the M5 pullback path (its entry check is!= "F"). - 6f MTF opposition: ≥2 TFs pointing against the trade direction → D (never trade against consensus).
- 6g XAU SELL volatility gate: sell-direction & vol regime not in {high, extreme} → D.
- 6g2 XAU regime gates: regime in
blocked_regimes(M15: mixedtrending) → D; sell-direction & regime insell_blocked_regimes→ D. - 6g3 XAU SELL hour gate: sell-direction & bar-CLOSE hour (label + candle duration — the entry hour under next-open fills) in
sell_blocked_hours_utc→ D. Per-TF opt-in; M15 factory blocks 15/17 UTC. - 6i XAU M5 noise gates (
_apply_m5_filters, primary TF = M5 only): (1) |score| ≥ 30; (2) MTF agreement required; (3) ≥1 confirmation TF with score ≥ ±10 in trade direction (NEUTRAL confirms don't count); (4) SELL grade must be B/C (A-grade M5 shorts lose −0.017R/trade); (5) vol regimehighblocked; (6) SELL blocked when RSI < 25. Each failure → D, setup killed.
- 6b session filter (bar-label hour + weekday; XAU reads
- Trade setup: XAU + grade A+/A →
_generate_xau_atr_trade_setupfirst; any pair + grade A+/A/B/C and no setup yet →_generate_trade_setup(swing-based). - Reasons: every non-neutral indicator description →
bullish_reasons/bearish_reasons(the backtester tags strategy from reasons[0] markers). - XAU_PB pullback (M5 XAU only,
m5_pullback_enabled, no setup yet, grade ≠ F):_try_m5_pullback— see below. - XAU_CP chart pattern (any TF, XAU only,
cp_enabled, no setup yet, NOT session/day-blocked — weak-composite grade F is allowed BY DESIGN, reversals complete on weak composites):_try_chart_pattern— see below.
Trade setup generators (exact geometry)
A. _generate_xau_atr_trade_setup (XAU, grade A+/A, ATR>0): TP quality scale A+ 2.0 / A 1.5 (A with exhaustion≥2 → 1.0). TP1 = entry ± tp_atr_multiplier(1.0)×scale×ATR; TP2 = 1.5×that; SL = entry ∓ 2.0×ATR. Gates: RR ≥ 0.5; TP1 distance ≥ $9 (spread gate). Prices rounded to 2 dp; pip 0.10 hardcoded — this generator is XAU-only by construction.
B. _generate_trade_setup (swing-based, any pair, grade A+..C): XAU requires ATR ≥ $1 (LOW ATR FILTER). Per-side:
- SL anchor = nearest swing low below price (BUY) / swing high above (SELL) ∓/±
sl_swing_buffer×ATR (1.0); fallback (no swing) = (1+buffer)×ATR. SL distance floored at max(ATR × sl_atr_floor, min_sl_pips×pip) — XAU: max(2.0×ATR, $8). - TP1 = nearest swing beyond entry ± 0.3 ATR if it's within
tp_swing_capATR (2.5) AND ≥ SL×RR-floor; else entry ± max(tp_mult×ATR, SL×RR-floor). tp_mult 1.5, RR-floor = max(min_rr 0.5, tp_floor_rr — 1.0 on M15). - TP2 = second swing if within 5 ATR, else entry ± max((tp_mult+1.5)×ATR, SL×floor×1.5); forced beyond TP1 by ≥1 ATR.
- Gates: RR ≥ min_rr; spread gate TP1 distance ≥ cost×multiple ($9 on XAU).
C. _try_m5_pullback (XAU_PB): detection = EMA alignment ≥3 of 4 checks (9>21, 21>50, 50>200, close>200 — mirrored for SELL), ADX ≥ 20, price touched max(EMA21, KC-mid) within last 3 bars, resumption bar closed back in trend direction beyond EMA9, retrace depth 0.8–6.0 ATR from the 40-bar extreme, RSI in pullback zone (BUY 35–62 / SELL 38–65). Then gates: directional score ≥ 15; ≥1 confirmation TF ≥ ±10; not against MTF consensus; vol regime gates (same as breakout path). Geometry: SL = pullback 8-bar extreme ∓ 0.5 ATR (min $8); TP1 = 1.5×risk, TP2 = 2.5×risk; spread gate $9. Sets strategy="XAU_PB", grade min B, marker "XAU PULLBACK STRATEGY: …" at reasons[0]. Rejection funnel counters in class attr SignalAggregator.pb_funnel.
D. _try_chart_pattern (XAU_CP): detection in chart_patterns.py — 6 classical patterns (double_bottom/double_top, inv_hs/hs_top, bull_flag/bear_flag), swing-point based (strict local extrema, window cp_swing_window=3), all thresholds in ATR units, scanned over the last cp_lookback=72 closed bars. Trigger = FIRST close beyond the neckline/boundary since the pattern formed (one breakout → one signal, no repaint); reversal patterns are invalidated by a new extreme beyond the pattern. No composite-score gate BY DESIGN. Gates: MTF non-opposition (6f mirror); SELL patterns respect sell_vol_regimes, sell_blocked_hours_utc (bar-close hour), M5 RSI floor; M5 blocks m5_blocked_vol_regimes both ways; ATR ≥ $1. Geometry: SL = pattern anchor ∓ 0.5 ATR (min $8); TP1 = measured move (pattern height), TP2 = 1.5×height; RR ≥ 0.5; spread gate $9. Sets strategy="XAU_CP" (global trailing params), grade min B, marker "XAU CHART PATTERN: …" at reasons[0]. Funnel in SignalAggregator.cp_funnel.
TradeSetup fields: pair, direction, entry, SL, TP1, TP2, RR, risk/reward pips, pip_value, strategy ("GENERIC", "XAU_PB", or "XAU_CP" — selects trailing params; XAU_CP uses the global pair), trail_arm_r/trail_dist_r (stamped from the PRODUCING config at generation so per-TF factory overrides reach formatters; None = legacy unstamped → display falls back to platform defaults).
news_engine.py — NewsEngine
- Calendar: Forex Factory free JSON (
nfs.faireconomy.media/ff_calendar_thisweek.json), 3 retries, honors 429 Retry-After with class-level backoff (≤600 s); fallback MyFXBook RSS. Never fabricates events — unavailable calendar = risk checks skipped that cycle. - Caches are class-level (a fresh NewsEngine is built per scan): calendar + headlines, TTL = 600 s on success, 60 s on empty result; lock held across fetch so concurrent scans share one request.
- Event risk (
get_event_risk_level): high-impact event for base/quote within 30 min →danger; within 60 min →caution; already-released high-impact →cautionfor 15 min after (45 min if actual ≠ forecast — "surprise").EconomicEvent.surprisecompares parsed numeric actual vs forecast (handles "%", "K/M/B" suffixes); direction is numeric-only (doesn't know if higher is bullish). - Sentiment: keyword sets over headlines from 4 RSS feeds, deduped by title (Google News " - Publisher" suffix stripped), last 24 h only. Per-currency scores;
GOLD_INVERSE_KEYWORDS(rate hike/hawkish/tightening/rate cut/dovish/easing/stimulus) flip sign for XAU (hawkish = dollar-bullish = gold-bearish). Currency detection via compiled word-boundary-ish regexes (bare "us"/"eu"/"uk" deliberately excluded). Pair sentiment = base − quote.
providers/ — data-provider abstraction (refactored 2026-07-25)
One class per provider, all subclassing providers/base.py BaseDataProvider, which owns every behavior the signal pipeline depends on — defined ONCE (a test enforces that neither subclass overrides shared methods):
- In-memory cache: key
{symbol}_{tf}, TTL = candle duration (M5 300 s … H4 14400 s); a cached frame is evicted and refetched if it fails the freshness check or the boundary check (_has_latest_closed_bar: the frame must contain the most recent fully closed bar, weekend-exempt — a wall-clock TTL alone would serve mid-candle snapshots at the next boundary and scans would run one bar behind). Fresh fetches enforce the same boundary guarantee: a response missing the just-closed bar (feed latency at the boundary) gets one grace retry afterFRESH_FETCH_RETRY_SECONDS(3 s), then proceeds with a loud warning. Cache is lock-protected (one provider is shared across the bot's thread pool). _drop_forming_candle: some feeds include the current partial candle; the scanner fires seconds after boundary, so the last bar is dropped if its close time is in the future. Without this every indicator would read a seconds-old stub and signals would repaint. (No-op on feeds that only return complete candles, e.g. Oanda.)- Freshness (
is_data_fresh, public): last bar older than 3× candle duration → stale (warns; live path suppresses signal if the PRIMARY TF is stale). Weekends exempt (Fri 22:00→Sun 22:00 UTC). _validate_and_clean(public wrappervalidate_and_clean): drop all-NaN rows → ffill (limit 3) → drop NaN closes → sort + dedupe (keep last) → drop future timestamps (>now+5 min) → repair OHLC inconsistencies (High=max, Low=min of O/C/existing) → remove >10σ return outliers (spike bar only — the opposite-direction snap-back bar right after it is kept, else every spike punched a two-bar hole) → warn if < ~210 bars.fetch_range(symbol, tf, start, end): paginated RAW date-range fetch underMAX_BARS_PER_REQUEST(5000) — the backtester'sfetch_historycalls this, so ANY registered provider can feed the backtester.outputsize = max(300, min(days×bars_per_day+20, 5000));CANDLE_SECONDS/BARS_PER_DAYtables live on the base class (the aggregator's bar-close clock readsBaseDataProvider.CANDLE_SECONDS).- Streaming capability (optional):
supports_streamingflag + async generatoriter_ticks(symbols, on_connect)yielding(canonical_symbol, price)for one connection's lifetime; the caller owns reconnect/backoff. Errors: each provider raises its ownDATA_ERROR, always a subclass ofDataIntegrityError— catch the base type provider-agnostically.
A subclass implements only _fetch_raw(symbol, tf, outputsize, start=None, end=None) (normalized frame: capitalized OHLCV, UTC tz-aware index named "Datetime", oldest-first) plus symbol/interval maps and optional capabilities. To add a provider: one module in providers/, one PROVIDER_REGISTRY entry in providers/__init__.py, one config.json data_providers block. The live feed is selected by config.json data_providers.active (registration alone changes nothing).
providers/twelve_data.pyTwelveDataProvider(aliasForexDataProvider): TD SDK (TDClient.time_series(...).as_pandas()), symbols normalized to"XAU/USD"form; lowercase→capitalized columns, newest-first→oldest-first; rate limit 55 credits/min sliding window (_throttle, lock-protected, blocks with sleep). Implements streaming (ws.twelvedata.comquotes WS, 10 s heartbeat,_parse_ws_price).providers/oanda.pyOandaDataProvider: v20 REST MBA candles (complete-only), real bid/ask spread viaget_spreadoverride; credentials default to the config.jsonoandablock so the registry can build it config-only (explicitapi_token/base_urlargs still accepted — the daemon passes them). Date-window fetches sendfrom/toand omitcount(v20 rejects the combination).providers/tiingo.pyTiingoDataProvider:/tiingo/fx/{ticker}/pricesresampled mid-price candles; no volume (filled 0.0), W1 unmapped (raises). Tiingo takes date windows, not bar counts — live fetches derivestartDatefrom the requested count × candle duration × 1.6 weekend pad. Auth in the header (key never in URLs/logs). Every failure shape raisesTiingoDataErrorwith a named cause (429 quota, 401/403 auth, empty response, malformed candle, missing key). Candles only —get_spreadstays on the base default; the free quote endpoint's indicative spread must not be re-added (see Tiingo spread-monitor removal note).data_provider.py/oanda_provider.pyare back-compat shims re-exporting fromproviders/;ForexDataProvideris the historical name of the TD provider.- No silent failures: provider errors all subclass
DataIntegrityErrorand carry a named cause; the aggregator logs per-TF fetch failures and degrades the report (missing primary → NEUTRAL/F "data unavailable");make_data_providerraises on an unknownactivename; keyless providers warn at construction and raise at fetch.
pair_signals/ — per-pair factories (ALWAYS use these, never bare PlatformConfig for a pair)
PAIR_PRODUCERS: Dict[symbol, factory]; factory signature make_config(primary_tf, confirmation_tfs=None) -> PlatformConfig. produce_signal(symbol, tf, data_provider) builds config → aggregator → analyze_pair. Registered: XAU/USD.
Inside make_config, call order is: PlatformConfig() defaults → app_config.apply_pair_overrides(config) (stamps config.json's non-credential deployment settings onto the config: base xau_strategy/backtest blocked_hours_utc, scan_pairs, scan_timeframes) → the validated per-TF deltas below. The overlay runs first so e.g. the M5 hour-9 append lands on top of the JSON base, not the other way around.
A bare PlatformConfig() silently runs any pair with XAU defaults — always go through the factory (the bot, CLI, and backtester all do).
Default confirmation map (same in the factory, the bot, and backtest usage): M5→[M15,H1], M15→[M5,H1], H1→[M15,H4], H4→[H1,D1]. (Exception: main.py's CLI map uses M5→[M1,M15] — a CLI-only quirk.)
xauusd.py: PlatformConfig() defaults + per-TF overrides (each validated; full evidence in the module docstring):
- M5: append hour 9 UTC to blocked hours (6-yr replay: +207.1R vs +178.3R; M15-and-up keep hour 9 — it's positive there). Enable BUY-only chart patterns (
cp_enabled=True,cp_patterns=["inv_hs","double_bottom","bull_flag"]): 12mo +41.8R PF 1.60 (train/val both positive), 5yr +65.2R PF 1.48; bearish CP dropped (OOS −3.21R). M15/H1 CP remain disabled (all failed OOS). - M5:
signals.trail_dist_r = 0.85(wider trail leash, arm stays 0.60; validated 2026-07-21 5-yr grid: +231.2R PF 1.30 vs +218.0R PF 1.29, halves and OOS better, DD flat; dist 1.0 had the best in-sample total but FAILED the OOS gate. XAU_PB keeps pb_trail 0.8/0.75; opposite finding vs M15's later-arm — per-TF divergence again). - M15:
signals.tp_floor_rr = 1.0(1R minimum targets: +116.6R vs +105.4R, better in train/val and 2024 OOS; hurts M5 — per-TF only) andxau_strategy.blocked_regimes = ["trending"](mixed regime block: +65.7R vs +59.0R). A transitioning-SELL block was tested and REJECTED (bull-market artifact — failed 2024 OOS). - M15:
xau_strategy.sell_blocked_hours_utc = [15, 17](SELL-only hour block, validated 2026-07-16 on the 5-yr replay: +176.7R PF 1.31 vs +144.9R PF 1.22, better in all 6 years, both 12-mo halves, and 2024-06→2025-03 OOS; M5 cross-TF pocket confirms; BUYs at those hours are +34.3R soblocked_hours_utcwould be wrong). - M15:
signals.trail_arm_r = 0.70(trail arms later; validated 2026-07-21, 5-yr grid arm 0.3-0.9 × dist 0.5-1.0: +210.7R PF 1.35 WR 57.8% vs +175.7R PF 1.30 WR 53.9% at the 0.6 default; both halves and OOS better; 5yr max-DD clause 33.9R vs 29.1R waived by owner. Earlier-arm cells uniformly worse. M5 keeps 0.6). - The factory must stay equivalent to inline
PlatformConfig(symbol=XAUUSD, …)+ overrides — enforced by a test in test_all.py.
telegram_bot.py — the bot
Run: python telegram_bot.py (credentials resolve env TELEGRAM_TOKEN/TELEGRAM_CHAT_ID/TELEGRAM_CHANNEL_ID → config.json telegram.* → refuses to boot; see Environment variables).
- Owner lock: every command/button is wrapped in
_owner_only— onlyTELEGRAM_CHAT_IDgets responses; others get a refusal. The bot is the owner's control panel; the product is the channel. - Subscriber channel (
TELEGRAM_CHANNEL_ID,-100…;""disables): auto-scan signal alerts + breach alerts also post there (bot needs channel-admin). Monetization = Telegram Stars subscription invite links; Telegram handles membership, the bot has zero payment code. The channel feed keeps running even when the owner toggles DM auto-scan off (_feed_active()= DM chats OR channel). - Auto-scan: boot-starts if
TELEGRAM_CHAT_IDset (initial M15 scan populates the level cache first). Candle-boundary loops for M5/M15/H1/H4, firing at boundary + 10 s. Each scan runs allscan_pairsconcurrently, updates the level cache, then broadcasts. - Alert gating: quality threshold (
_alert_min_quality, default "C", menu cycles C→B→A) + fingerprint dedup per (symbol, TF) on (signal, quality, direction) with 1-hour re-arm — a persisting identical signal doesn't re-send every candle, but a setup whose entry moved ≥ half the last-ALERTED risk distance (new pullback/swing anchor; compared against the stored last-alert entry/risk, a stable reference immune to per-candle ATR drift) re-alerts. The fingerprint is committed only after a message reaches ≥1 target (_commit_alert), so a failed Telegram send doesn't consume the alert. - WebSocket monitor: the provider owns the connection/protocol (
BaseDataProvider.iter_ticksstreaming capability — TD implements it; heartbeat + message parsing live inproviders/twelve_data.py); the bot owns exponential reconnect (≤60 s) + breach dispatch, and disables the monitor with a log line if the active provider hassupports_streaming=False. On tick crossing any cached key level (swing_high/low_1..3,ema_trend,bb_upper/lower,kc_upper/lower; cache TTL 1 h) → immediate M15 rescan → "LEVEL BREAK" alert if actionable+changed (same gates as auto-scan), 5-min per-pair cooldown. - Commands:
/start(menu),/scan(buttons: per pair — M5/M15/H1/H4 + Scan All TFs),/autoscan(toggle DM copy),/status(loops, WS, live prices, level cache, cooldowns),/cache(age per pair/TF),/clearcache,/help. - Tiingo spread monitor: REMOVED 2026-07-23 (shipped 2026-07-22, removed next day). Tiingo's free
/tiingo/fx/toppublishes a two-tier indicative spread for gold — exactly $0.10 in liquid hours and exactly $0.70 in the 21:00–00:00 UTC rollover window / news moments (verified: 273-sample production log + live probes; only those two values ever occur). Quotes are mid ± a posted increment, not executable depth, so the feature could never validate the $0.30 backtest cost assumption. Do not re-add a spread display from Tiingo's free FX feed. - Signal messages (redesigned 2026-07-24,
format_signal_message): verdict header first, then trade setup, then a condensed one-line indicator summary (EMA↑ RSI→ …,_indicator_summary) + warnings. The setup block includes an informational 100-pip TP line (_INFO_TP_PIPS, display-only — entry ± 100 pips, ≈R vs stop; NOT part of the backtested exit model) and the trailing-stop management plan (arm price + trail distance, read from thetrail_arm_r/trail_dist_rstamped on the setup by the producing per-TF config). Risk disclaimer on every outbound message. - Blocking scan work runs in
ThreadPoolExecutor(max_workers=4); one sharedForexDataProvideracross all scans (cache reuse). - Logging (hardened 2026-07-22): root-logger handlers (
force=True) → console +logs/telegram.log(3-hour rotation, 240 backups = 30 days), full-date timestamps; every module logger (data_provider, news_engine, websockets, telegram.ext) lands in the same file. Failure surfaces all covered: candle loops catch-and-continue per cycle (one bad scan can't kill a loop), breach tasks log exceptions via done-callback, PTBadd_error_handlerregistered, andsys.excepthook/threading.excepthook(installed inmain()) route uncaught crashes to the log. WS monitor keeps its exponential-backoff reconnect +logger.exception.
main.py — CLI
python main.py [--pair XAUUSD] [--timeframe M5|M15|M30|H1|H4|D1] [--detail] [--json] [--verbose]. Uses the pair factory, renders a compact terminal report (decision, setup, context, top-3 reasons per side, warnings) or full JSON via report_to_dict.
backtest/gpu_backtest.py — the backtester
Replays history bar-by-bar through the real SignalAggregator.analyze_from_dataframes — same gates, same setup math. News is disabled (use_news=False): backtests are pure technical.
Realism model (all validated fixes — do not regress):
- Entry at the next bar's open after the signal bar; SL/TP distances preserved relative to actual entry. Entries are re-anchored to the raw price path (
_reanchor_entries_to_raw): if cleaning dropped a >10σ spike bar right after the signal bar, the trade enters at the spike bar's open, not the bar after it — skipping it was a favorable bias. - Round-trip
--spreadsubtracted from every trade's PnL before computing R (default = pair'sdefault_spread: XAU $0.30). Results before 2026-07-08 (+59.5R baseline era) charged no spread — not comparable. - Trade resolution runs against the RAW price path (
fetch_history(clean=False)): the parquet cache stores raw bars; cleaning (which drops >10σ news spikes that hit stops in reality) is applied only to the signal-side data. Caches written before this fix contain cleaned data — refetch to restore spike bars. - Same-bar SL+TP tie → SL first (conservative). Gap through SL/TP → filled at the bar open.
- Trailing exits (default on, from
signals.trail_arm_r/trail_dist_r; XAU_PB trades carry per-trade 0.8/0.75 overrides): stop ratchets at bar close only, so a new stop can only fill from the next bar (conservative). Exit reasons: SL / TRAIL / TP1 (static mode) / TIMEOUT / NO_DATA. - Session + weekend + weekday filters evaluate the decision time = bar close (
ts + primary_duration) — exactly matching the live bot's wall-clock check. - The optional H4-ADX regime filter (
config.backtest.regime_filter, default off) is applied in the replay by calling the live_apply_regime_filter_livemethod itself under the same guard, and the replay loads/slices the regime-filter TF whenever the gate is on (like liveanalyze_pair) — previously M5/M15 primaries silently no-opped the filter because H4 wasn't in their confirm maps. Live and backtest cannot diverge if it's enabled (2026-07-21 parity fix; the unregistered-pair config fallback also applies the config.json overlay now). - Confirmation TFs are sliced to bars fully closed by decision time (no look-ahead).
Performance: precompute_indicators builds every indicator series once per TF (CuPy on GPU or NumPy — check the log line Backend: CuPy GPU vs NumPy CPU fallback; CuPy needs the nvidia-*-cu12 pip wheels or it silently falls back). GPUTechnicalEngine subclasses the live engine and overrides only the _compute_* methods with cache lookups — outputs are byte-identical to the live engine (regression-tested). Bar replay: pure per-bar evaluation fans out over forked worker processes (--workers, 0 = auto: cores−2 capped 12; <5000 bars forces 1); the entry cooldown (5 bars) is the only sequential rule, applied in an ordered pass afterwards — parallel and sequential books are identical. ~300 bars/s/core, ~1700 bars/s at 10 workers.
Strategy tags (from reasons[0] markers): XAU_PB, XAU_CP, GENERIC (plus legacy XAU recognized). --strategies filters the book by tag. --cp force-enables the chart-pattern entry for candidate validation; --cp-patterns narrows the detector set.
CLI:
python backtest/gpu_backtest.py --pair XAUUSD --days 365
python backtest/gpu_backtest.py --pair XAUUSD --start 2025-01-01 --end 2025-06-01 --primary-tf 15min --confirm-tfs 1h,4h
# key flags: --spread --quality A+,A,B,C --strategies --workers --max-hold-bars 300
# --warmup-bars 250 --cooldown-bars 5 --no-cache --export-csv out.csv --detail
Report: summary metrics (total R, PF, Sharpe on R, max DD, MFE/MAE), breakdowns by quality/strategy/regime, trade log, and per-trade reasoning dumps (indicator-by-indicator agreement at entry — required by instructions_for_backtest.txt). --export-csv flattens every indicator's signal/value/agreement into columns.
Comparability note (2026-07-16): the parity/realism fixes of that date (EMA-ribbon port to the GPU engine, exact-pandas Bollinger precompute, raw-path entry re-anchoring, per-TF D1 warmup, live confirm-map CLI default, outlier snap-back retention) all shift books slightly vs earlier runs. Books produced before these fixes are not directly comparable — re-run the baseline before any before/after tuning comparison.
Data: paginated fetches via provider.fetch_range (5000-bar chunks; works with any registered provider) cached as parquet in backtest/cache/ keyed by provider/symbol/TF/window (bars differ between feeds — Oanda/TD divergence study; twelve_data keeps the legacy un-prefixed filename so pinned-book caches stay valid); warmup pad ≥60 days before the window for EMA200, extended per-TF so slow confirmation TFs get ≥210 bars at window start (D1 gets ~299 days — 62 days gave only ~61 bars: no EMA200, unlike live's 300-bar outputsize floor). --confirm-tfs defaults to the pair factory's live confirmation map (pass it explicitly to override); a hardcoded default here previously made the standard invocation backtest a different MTF setup than the bot scans.
test_all.py — QA suite
python test_all.py — 253 tests, 16 modules (config, data_provider, engine, news_engine, signal_aggregator, main, telegram_bot, integration, pair_signals registry + engine-bug regressions, 2026-07-16 audit-fix regressions, chart patterns, app_config, .env loader + bot resilience, Oanda provider/executor/daemon, provider abstraction). No network needed (mocked). Uses a custom @test("name") decorator that runs immediately (NOT pytest — don't grep for def test_). Exit code non-zero on failure. Includes: the xauusd-factory ≡ inline-config equivalence test, engine bug-fix regression tests (Module 9b), a live-vs-GPU engine parity sweep incl. the EMA-ribbon path (Module 11), config.json loading/precedence/validation coverage (Module 13), and .env loader + bot-resilience coverage (Module 14). Any signal-logic change must keep this green and should add tests.
Environment variables
Precedence for every credential/deployment setting: env var → config.json → none. A repo-root .env file (gitignored) is loaded at app_config import via os.environ.setdefault — it behaves like real env vars except a real shell export always wins. Hardcoded fallbacks (previously in config.py / telegram_bot.py) were REMOVED 2026-07-21; the bot now refuses to boot (telegram_bot.main) and data fetches fail with a named error (data_provider) if a secret resolves to neither source. See app_config.py and config.json.example.
| Variable | Purpose | Default |
|---|---|---|
TWELVE_DATA_API_KEY |
market data key (twelve_data only) | none — falls through to config.json data_providers.twelve_data.api_key, then fails fast |
TIINGO_API_KEY |
market data key (tiingo only) | none — falls through to config.json data_providers.tiingo.api_key, then fails fast |
TELEGRAM_TOKEN |
bot token | none — falls through to config.json telegram.token, then refuses to boot |
TELEGRAM_CHAT_ID |
owner chat id (boot auto-scan + owner lock) | none — falls through to config.json telegram.chat_id, then refuses to boot |
TELEGRAM_CHANNEL_ID |
subscriber channel (-100…); "" disables the feed |
none — falls through to config.json telegram.channel_id |
FB2_EMA_LO |
EMA200 "near" penalty threshold in % | 0.5 |
Dependencies (requirements.txt): numpy, pandas, requests, twelvedata, python-telegram-bot ≥20, websockets. Python 3.12. A venv/ exists in-repo.
Code conventions
- Type hints on public methods; dataclasses for all config and outputs.
SignalStrength(−2..+2) is the universal signal currency.- Key levels dict keys:
swing_high_*,swing_low_*,ema_*,bb_*,kc_*,atr,atr_pct. - Private per-indicator series ride on
TechnicalAnalysisas_underscoreattributes; anything_-prefixed or weight-0 is skipped by scorers/formatters. - UTC everywhere; DataFrames indexed by tz-aware
Datetime, oldest-first, capitalized OHLCV columns. - Logging via module loggers; the bot adds a rotating file handler.
Rules for making changes (READ BEFORE EDITING SIGNAL LOGIC)
- Never change a tuned value without re-validation. The acceptance rule used throughout: positive total R in BOTH the train and validation halves of a ≥12-month window, PF ≥ 1.3, at realistic cost (XAU $0.30 round trip), with trailing exits and next-open fills. For any marginal edge additionally re-test on the 2024-06→2025-03 out-of-sample window — within-year splits alone have approved configs that failed OOS.
- Per-timeframe overrides live in the pair factory (
pair_signals/*.py), never in global defaults — the same knob has opposite effects on different TFs (proven twice:tp_floor_rr, hour-9 block). Document the validation evidence in the factory docstring. - Pair isolation is a hard invariant. XAU gates in the aggregator are display-gated (
report.display_name == "XAU/USD") so a future pair port cannot inherit gold-calibrated logic by accident. - Live == backtest parity: session/weekend/hour filters must evaluate bar-CLOSE time in both paths;
xauusd.make_configmust stay equivalent to the inline config the backtester/bot would build (test-enforced). If you add a live gate, add it to the replay path (oranalyze_from_dataframes) in the same commit. - ROC stays weighted (see engine table) unless you retune composite thresholds and re-validate — removing it lost 13R.
- Backtester realism knobs are results-critical: raw-path resolution, spread charging, next-open entry, SL-first ties, close-only trail ratchet. Any "optimization" that touches these invalidates comparability with the pinned books (
backtest/*.csv, memory notes). - Don't zero/disable indicators via config in production paths —
disabled_indicatorsexists for experiments; composite thresholds assume the full set. - The forming candle must stay dropped and analysis must only ever see closed bars, or live signals repaint.
- Keep
test_all.pygreen (python test_all.py); extend it for every new gate/knob. - Dead/legacy config fields were removed 2026-07-21 (
MarketSession,is_jpy_pair,candle_buffer_seconds, the unusedSignalConfig/XauStrategyConfiglegacy knobs). Do not add config fields that nothing reads; delete dead fields only with test updates.