Files
ForexBot2/config.py
T
Harwinder Sekhon 1793d9167a Daily-trend veto on M15 + lot-size / already-open lines (campaign 2026-09-30), and the 2026-10-01 whole-codebase bug review
Two bodies of work that touch the same files, so one commit.

Direction and setup-size campaign (2026-09-30 → 10-01, owner's RISK-FIRST rule,
written before the runs):
- SIGNAL: M15 daily-trend veto (factory override 7, xau_strategy.daily_trend_veto
  "both"). A finished setup against a clear daily trend (closed D1 bars, Sunday
  stubs dropped; close vs its 50-bar EMA AND vs the close 60 kept days earlier)
  is removed as the last step of the core. One definition
  (signal_aggregator.daily_trend_series) for live, the bar replay and the twin's
  host side; D1 is a required frame of the M15 scan; mirrored in
  cuda_signal_kernels.py K2. W3 824 / +205.12R / PF 1.73 / max DD 12.4R vs
  1105 / +195.79R / 1.49 / 20.9R; 2019 → 2026 1473 / +223.33R / 1.42 / 41.5R vs
  2091 / +192.53R / 1.24 / 64.8R; unseen holdout −4.62R vs −6.63R.
- FAILED, not shipped: the veto on M5 (W3 drawdown 10.1R → 15.2R) and on H1
  (−9.7R), buy-only and sell-only on M15, the wide-stop cap
  (xau_strategy.wide_stop_cap_atr stays 0.0, a research knob).
- New M15 pins: W2 dfc647ee…, OOS e87b6de1…, W3 ad3b0dbd…. M5 / H1 / H4 pins
  unchanged. --set xau_strategy.daily_trend_veto=off reproduces the previous M15
  pins byte for byte. Twin and reference script MD5-equal on every window;
  recorder and feature-export parity re-proved; base-rate table rebuilt.
- DISPLAY (books byte-identical): exposure.py — the lot size for
  trade_size.risk_per_trade (new config.json block, off by default, private to
  the owner) and the count of alerted same-direction plans still open
  (signals.alerted_at, read-only open_alerted_setups); the daily trend in words
  on Telegram, the CLI and the dashboard; signals.daily_trend logged.
- providers/oanda: a date window with no bars is an empty page, not an error
  (a D1 pad starting on a Saturday could not be fetched).
- Reports only, nothing shipped: seven context screens (no state nominated) and
  eight outside data sources (none adds a forecast to gold's own daily trend).
- Independent verification (report section 5.13): every figure reproduced on
  both scripts, scoring identical to the previous commit on all four
  timeframes, no look-ahead. Caveats recorded there: this is a risk filter, not
  a more accurate direction call; nine tenths of the 2019 → 2026 gain is 2022;
  the neighbour clause is thin (a simple 100-day average misses the R line by
  1.2R); "never adds a trade" is an observation, not a guarantee.

Bug review of the whole codebase (2026-10-01). Signal path clean. 17 defects
elsewhere, plus 4 found by an independent review of the fixes; all fixed, each
with a test; no trade book changed:
- telegram_bot: one _deliver_alert for the candle scan and the level break. The
  row is stamped alerted_at BEFORE the send, on a one-worker alert-db pool, and
  un-stamped when no chat received it or the task is cancelled mid-stamp, so
  two alerts at one candle close count each other and no SQLite write runs on
  the event loop. The 5-bar cooldown counts bars of the scan's own frame
  (== gpu_backtest._apply_cooldown; clock time released the 22:00 UTC bar after
  the settlement pause) and is restored from signals.db at start-up. The
  lot-size line goes to the owner's own chat only (a group the owner ran
  /autoscan in used to receive it). /status gains a news-model line and escapes
  failure details.
- signal_log: mark_alerted returns the stamp it wrote; unmark_alerted;
  last_alerted_bars (read-only).
- advisory_outcome: candle continuity is judged bar by bar inside the walk; a
  hole after a trade's exit no longer voids it. The stored-candle replay
  resolves 108 of 111 logged setups instead of 99, all equal to the live
  resolver's R.
- providers/oanda: an empty date window is accepted only when it is 5 days or
  shorter and no malformed candle was skipped; a longer one is retried, then
  refused, instead of being cached as history with a hole.
- news_engine: blank RSS titles skipped; one unreadable headline no longer
  costs the local model its whole batch (checked on the real bundle); model
  health recorded; no DeepSeek audit call in a process that does not archive;
  event-direction cache keyed per release; peek_week_events never fetches.
- llm_client: chat_json never raises and never logs the API key (requests
  quotes the Authorization value in InvalidHeader; error bodies are redacted).
- signal_aggregator: the scan's news block is guarded; the replay path
  (use_news=False) assigns exactly the values it did before.
- agent_panel: PANEL_PROMPT_VERSION v2.2 — same prompt text, corrected input
  (event timing over the cached calendar week, headlines newest first).
  scripts/panel_replay.py replays the logged payload per persona;
  scripts/label_scan_outcomes.py counts with an ISO cutoff.
- test_all.py: no ALTER TABLE … DROP COLUMN (the live box has SQLite 3.34);
  tests that write Parquet print a notice and return without pyarrow.

Verification after the last code change: suite 632 passed, 0 failed (also with
both Parquet engines blocked and FB2_BACKEND=cpu, the live box's view); the
five reference MD5s reproduce on both backtesters, 0 cache fallbacks; Module 37
green; static Python 3.9 / SQLite 3.34 check clean on every touched file.

Evidence: backtest/research/daily_trend_campaign_2026-09-30.md,
context_screens_2026-10.md, new_inputs_2026-10.md; CLAUDE.md sections 2, 4, 6,
8, 9, 10 and 11; pair_signals/xauusd.py docstring (M15 override 7).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 10:28:28 -04:00

397 lines
18 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
ForexBot2 — Configuration (XAU/USD focused)
==========================================
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List
class Timeframe(Enum):
M1 = "1min"
M5 = "5min"
M15 = "15min"
M30 = "30min"
H1 = "1h"
H4 = "4h"
D1 = "1d"
W1 = "1wk"
class SignalStrength(Enum):
STRONG_BUY = 2
BUY = 1
NEUTRAL = 0
SELL = -1
STRONG_SELL = -2
# ── Supported pairs ──────────────────────────────────────────────
XAUUSD = "XAU/USD"
# price_precision: decimals for SL/TP rounding and display.
# default_spread: $0.30 round-trip cost retained for TD-era cross-book
# comparability and used when the backtester receives no --spread override.
# Oanda's measured round trip is $0.65-$0.72; research reports headline it
# separately with --spread 0.65.
PAIR_INFO = {
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10,
"price_precision": 2, "default_spread": 0.30},
}
@dataclass
class TechnicalConfig:
"""Technical indicator parameters."""
ema_fast: int = 9
ema_medium: int = 21
ema_slow: int = 50
ema_trend: int = 200
# 4-EMA mode only: score partial ribbon alignment (2-3 of 4 checks)
# as NEUTRAL instead of a weak BUY/SELL. Research knob (2026-08-04
# indicator audit: partial-alignment trades averaged -0.008R vs
# +0.230R fully aligned on the M5 12mo Oanda book); default False =
# legacy behavior until a replay campaign validates flipping it.
ema_partial_neutral: bool = False
rsi_period: int = 14
rsi_overbought: float = 70.0
rsi_oversold: float = 30.0
rsi_neutral_upper: float = 60.0
rsi_neutral_lower: float = 40.0
macd_fast: int = 12
macd_slow: int = 26
macd_signal: int = 9
bb_period: int = 20
bb_std: float = 2.0
atr_period: int = 14
adx_period: int = 14
adx_strong_trend: float = 25.0
adx_very_strong: float = 50.0
divergence_lookback: int = 40
swing_sr_lookback: int = 50
disabled_indicators: List[str] = field(default_factory=list)
ema_mode: str = "4ema" # "4ema" (9/21/50/200) or "2ema" (50/200)
rsi_mode: str = "regime_aware" # "classic" or "regime_aware"
macd_as_pillar: bool = True # MACD histogram is a scored signal
# ROC (Rate of Change)
roc_period: int = 10
roc_threshold: float = 1.0 # % threshold for strong signal
# Keltner Channels
kc_period: int = 20
kc_atr_period: int = 10
kc_multiplier: float = 1.5
# ADX slope (trend acceleration detection)
adx_slope_lookback: int = 5
adx_slope_emerging_threshold: float = 3.0
regime_weights_override: Dict[str, Dict[str, float]] = field(default_factory=dict)
@dataclass
class SignalConfig:
"""Signal scoring & filtering."""
mtf_agreement_threshold: int = 2 # of 3 timeframes
# RESEARCH-ONLY grading rule switch (campaign 2026-09-27,
# backtest/research/conviction_campaign_2026-09-27.md). "pair" is the
# shipped two-number rule (a score floor AND a confidence floor, the
# literal table in signal_aggregator._quality_pair); "min" and "product"
# grade on ONE conviction number (min(|score|, 0.6 x confidence) or
# |score| x confidence / 100, thresholds in
# signal_aggregator.CONVICTION_THRESHOLDS). Read by
# SignalAggregator._compute_final_signal and gate 6c; packed for the CUDA
# twin. Any value other than "pair" is a rule-1 event.
grading_mode: str = "pair"
min_rr_ratio: float = 0.5
# TP tightening (May 2026) was tuned on the pre-spread backtester.
# Re-tested 2026-07-08 with $0.30 spread + gap fills: 0.8 nearly
# halves total R (+36.1R vs +66.8R, worse in both train/val halves)
# — smaller wins make the fixed spread a larger share of every TP.
tp_tightening_factor: float = 1.0
# ── Trade geometry ────────────────────────────────────────────
# Deliberately $0.30 for TD-era cross-book comparability. Oanda's measured
# round trip is $0.65-$0.72, reported separately in re-pin campaigns.
# This is also a LIVE gate input: increasing it to $0.65 raises the TP1
# floor from about $9 to $19.50, so it requires rule-1 revalidation.
round_trip_cost: float = 0.30
# Spread gate: reject setups whose TP1 distance is below
# round_trip_cost × this multiple (caps cost at ~3% of target).
# Validated 2026-07: small-target trades lose in both data halves
# on M5 while TP≥$10 trades earn ~+0.08R/trade.
min_tp_cost_multiple: float = 30.0
# Fixed SL floor in pips (80 pips = $8 on XAU). 0 disables — SL then
# floors at ATR × v2_sl_atr_multiplier only.
min_sl_pips: float = 80.0
# TP1 floor as a fraction of SL distance (0.5 = historical RR 0.5).
# Part of the setup geometry the gates judge (min-RR, spread gate).
tp_floor_rr: float = 0.5
# Printed-target floor (2026-09-26, owner rule "TP1 at least 1:1"):
# every generator raises the PRINTED TP1 to at least this fraction
# of the stop AFTER the min-RR and spread gates have judged the
# generator's own target, so which generator serves a signal (and
# the stop) never changes and the books stay trade-identical; only
# the exported tp1 / rr_planned columns move. TP1 is advisory in the
# tested exit (the trail), so this is a guide, not a book change.
# The effective floor is max(tp_floor_rr, tp_print_floor_rr).
tp_print_floor_rr: float = 1.0
# ── Trailing-stop exit (2026-07) ──────────────────────────────
# Once favorable excursion reaches trail_arm_r × risk, the stop
# trails trail_dist_r × risk behind the best price (updated at bar
# close). TP1 becomes advisory — the trail is the exit. Validated
# on 12mo M5: +105R vs +48R with the fixed RR-0.5 target, balanced
# train/val, positive every quarter, PF 2.27. The RR-0.5 target was
# the binding constraint — winners kept running after TP.
# Set trail_arm_r = 0 to disable and exit at TP1 as before.
trail_arm_r: float = 0.6
trail_dist_r: float = 0.75
# Pullback (XAU_PB) trades run tighter stops (~$8 vs ~$25), so the
# global 0.6R arm fires on noise. Arm later; whole 0.8-arm row of
# the grid is +35-38R vs +32R at global params (balanced halves).
pb_trail_arm_r: float = 0.8
pb_trail_dist_r: float = 0.75
@dataclass
class NewsConfig:
"""Economic calendar & sentiment."""
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU"])
pre_event_buffer_minutes: int = 30
post_event_buffer_minutes: int = 15
# Whether event risk gates the SIGNAL (danger: confidence hard-capped
# at 30; caution: 15-point penalty). Warnings are always emitted
# regardless. Default False = XAU-tuned: replaying the live gates
# against the validated 12mo XAU M5 book (547 trades, +139.9R)
# showed they suppress 25-35 trades worth +4.6..+7.2R/yr — trades
# entered near USD releases OUTPERFORM (+0.475R vs +0.256R avg).
# Audit 2026-07-11 (calendar-replay study; tooling since removed).
event_risk_caps_signals: bool = False
# Whether headline sentiment moves the SIGNAL (aligned: +5 confidence;
# conflicting: 10-point penalty). Warnings are always emitted
# regardless. Default False = news is informational only; decisions
# are pure technical, matching the backtester (use_news=False).
sentiment_affects_signals: bool = False
# Wider stay-out window after a high-impact release that surprised
# vs forecast (inline releases use post_event_buffer_minutes).
post_event_surprise_buffer_minutes: int = 45
# How long fetched calendar/headline data is reused across scans.
# Must stay below pre_event_buffer_minutes so an event can't cross
# into the danger window while a stale cache says it's clear.
context_cache_ttl_seconds: int = 600
# Maximum age of RSS headlines accepted for scoring. Older headlines
# (and headlines with no parsable publish time) are discarded before
# keyword analysis and LLM enrichment. 12 h → 4 h on 2026-09-04: the
# 3-week signal log showed the 12 h mean was a trailing echo of the
# previous day's price move (see backtest/research/
# news_sentiment_value_2026-09-04.md). Display/warning-only either way.
headline_max_age_seconds: int = 14400 # 4 hours
rss_feeds: List[str] = field(default_factory=lambda: [
"https://investinglive.com/feed/",
"https://www.fxstreet.com/rss",
# Google News query targeted at gold drivers — also carries Kitco,
# Reuters etc. whose direct feeds are unavailable.
"https://news.google.com/rss/search?q=gold+price+OR+fed+OR+inflation&hl=en-US&gl=US&ceid=US:en",
# CNBC "Economy" section
"https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=20910258",
])
@dataclass
class XauStrategyConfig:
"""XAU/USD strategy config."""
enabled: bool = True
tp_atr_multiplier: float = 1.0
sl_atr_multiplier: float = 2.0
blocked_hours_utc: List[int] = field(default_factory=lambda: [0, 1, 2, 10, 11, 13, 16, 21])
weekend_filter: bool = True
min_atr_threshold: float = 1.0
v2_sl_atr_multiplier: float = 2.0
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4, 6]) # Mon-Fri + Sun (Sydney open)
sell_min_quality: List[str] = field(default_factory=lambda: ["A+", "A", "B"])
# Volatility regimes in which SELL setups are allowed (low/normal-vol
# shorts lose consistently against gold's macro uptrend).
sell_vol_regimes: List[str] = field(default_factory=lambda: ["high", "extreme"])
# Market regimes in which no XAU setup may fire (any direction).
# Empty by default; per-timeframe factories opt in — e.g. the mixed
# "trending" regime (strong ADX, EMAs disagree) on M15.
blocked_regimes: List[str] = field(default_factory=list)
# Market regimes in which XAU SELL setups may not fire. Empty by
# default; per-timeframe factories opt in.
sell_blocked_regimes: List[str] = field(default_factory=list)
# Bar-CLOSE (decision-time) UTC hours in which XAU SELL setups may
# not fire. Empty by default; per-timeframe factories opt in.
# BUYs are unaffected (unlike blocked_hours_utc, which is both-way).
sell_blocked_hours_utc: List[int] = field(default_factory=list)
# ── M5-specific filters ──────────────────────────────────────
# M5 signals are noisy; require stronger confirmation before
# generating trade setups. Without these, M5 is breakeven
# (+0.01R/trade) with 21% drawdown.
# Relaxed 2026-07-30 (30→25, 10→5) in the relaxation campaign:
# the combined config (with the M5 hour-21 unblock) beat control
# in ALL 6 calendar years of the 5yr Oanda replay (+230.9R vs
# +168.0R, PF 1.26 vs 1.21, max DD 21.3 vs 27.0), 12mo +107.4R
# vs +84.6R PF 1.36 both halves positive, OOS 2024-06→2025-03
# +26.1R vs +17.2R, and at $0.70 spread +86.9R vs +66.4R.
# Score floors below 25 are vacuous (grade C requires |score|≥25).
m5_require_mtf: bool = True
m5_min_abs_score: float = 25.0
m5_min_confirmation_score: float = 5.0
# A-grade M5 SELLs enter at the extreme of temporary dips and
# lose (-0.017R/trade on 163 trades) — maximum conviction at
# the worst entry. Cap SELL quality so A-grade can't trigger
# the ATR-based setup generator; B-grade swing setups survive
# because they catch moderate drops, not extremes.
m5_sell_max_quality: List[str] = field(default_factory=lambda: ["B", "C"])
# 'high' vol regime on M5 is chop transition — elevated noise
# without directional expansion. 61% WR / negative total across
# 12mo while low/normal/extreme are all positive (validated 2026-07).
m5_blocked_vol_regimes: List[str] = field(default_factory=lambda: ["high"])
# Block M5 SELLs when RSI is already < this floor: the dip is
# extended and mean-reverts before the target (negative in both
# halves of 6mo and 12mo backtests).
m5_sell_min_rsi: float = 25.0
# ── M5 pullback entry (XAU_PB) ──
# Second entry archetype: enter on the retrace to EMA21/KC-mid in
# an established trend instead of at Keltner-breakout extension.
# Validated 2026-07: +38.3R/254 trades on 12mo, positive both
# halves and all four full quarters, core trades undisturbed.
m5_pullback_enabled: bool = True
# Composite score is weaker mid-pullback BY DESIGN (price sits
# below the momentum bands) — separate, lower floor than Gate 1.
m5_pullback_min_score: float = 15.0
m5_pullback_adx_min: float = 20.0
# RSI must be in the pullback zone, NOT the extended zone the
# breakout entries fire in (those run 65-80).
m5_pullback_rsi_buy: tuple = (35.0, 62.0)
m5_pullback_rsi_sell: tuple = (38.0, 65.0)
# Retrace depth from the pre-pullback swing extreme, in ATRs.
# Shallower = still extended; deeper = trend likely broken.
# M5 gold trend legs measure 3-4 ATR swing-to-trough (median 3.9),
# so the ceiling sits well above that of higher TFs.
m5_pullback_retrace_atr: tuple = (0.8, 6.0)
# Price must have touched EMA21/KC-mid within this many bars.
m5_pullback_touch_bars: int = 3
# ── Chart-pattern entry (XAU_CP) ──────────────────────────────
# Third entry archetype: completion of a classical chart pattern
# (see chart_patterns.py for the pattern set and design contract).
# Defaults are textbook geometry, NOT tuned values. Disabled by
# default — per-TF adoption happens in pair_signals/xauusd.py only
# after the pattern book passes the standard validation rule
# (positive R both halves of ≥12mo, PF ≥ 1.3, $0.30 spread,
# trailing exits, next-open fills).
cp_enabled: bool = False
cp_patterns: List[str] = field(default_factory=lambda: [
"inv_hs", "hs_top", "double_bottom", "double_top",
"bull_flag", "bear_flag",
])
cp_lookback: int = 72 # bars scanned for pattern structure
cp_swing_window: int = 3 # bars each side to confirm a swing
cp_recency_bars: int = 20 # last extreme must be this fresh
cp_min_sep_bars: int = 8 # min bars between double extremes
cp_tolerance_atr: float = 0.5 # double top/bottom level match tol
cp_min_height_atr: float = 1.5 # pattern height floor (noise gate)
cp_max_height_atr: float = 8.0 # pattern height ceiling
cp_hs_head_min_atr: float = 0.5 # head must exceed shoulders by this
cp_hs_shoulder_tol_atr: float = 1.0 # shoulder level match tol
cp_flag_pole_atr: float = 3.0 # min pole size
cp_flag_pole_bars: int = 12 # pole measured over ≤ this many bars
cp_flag_min_bars: int = 3 # flag consolidation length bounds
cp_flag_max_bars: int = 12
cp_flag_retrace: tuple = (0.1, 0.5) # flag depth as fraction of pole
# ── Big-candle entry gate ──────────────────────────────────────
# When the recent window on the scan's own primary TF contains an
# abnormally large candle (bar range >= K x the PRIOR bar's ATR —
# institutional burst; price may reverse violently), no XAU entry
# of ANY kind may fire (core, XAU_PB, XAU_CP). 0.0 = disabled.
# Per-TF factories opt in only after the standard validation rule.
big_candle_atr_multiple: float = 0.0
big_candle_lookback_hours: float = 24.0
# ── Daily-trend veto (2026-09-30) ──────────────────────────────
# The engine sees at most the H1 chart, and on M15 trades against a
# CLEAR daily trend earn nothing after costs. The daily trend is +1 /
# -1 when the last closed D1 close sits above / below its 50-bar EMA
# AND above / below the close 60 daily bars earlier, else 0
# (signal_aggregator.daily_trend_series; Sunday stub bars dropped).
# "both" removes any finished setup that goes against a non-zero
# daily trend, "buy" only BUYs against a falling one, "sell" only
# SELLs against a rising one, "off" nothing. Off by default; the M15
# factory sets "both" (override 7). It FAILED its rule on M5 and H1
# and as "buy" / "sell" on M15
# (backtest/research/daily_trend_campaign_2026-09-30.md).
daily_trend_veto: str = "off"
# ── Wide-stop cap (2026-09-30, research knob) ──────────────────
# Caps the stop distance of the swing (GENERIC) and chart-pattern
# setups at max(K x ATR, the setup's own stop floor). Applied AFTER
# the min-RR and spread gates, like the printed-target floor, so
# the set of trades cannot change. 0.0 = off, and it ships off
# everywhere: K = 6 FAILED its rule on M15 (a narrow peak; K = 5
# loses 18.6R over 2019-2026). Same campaign report.
wide_stop_cap_atr: float = 0.0
@dataclass
class FilterConfig:
"""Regime and session filters for live signals."""
regime_filter: bool = False
regime_filter_timeframe: Timeframe = Timeframe.H4
regime_filter_min_adx: float = 25.0
session_filter: bool = True
session_start_hour: int = 3
session_end_hour: int = 23
blocked_hours_utc: List[int] = field(default_factory=lambda: [0, 1, 2, 10, 11, 13, 16, 21])
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4, 6]) # Mon-Fri + Sun (Sydney open)
@dataclass
class PlatformConfig:
"""Top-level configuration."""
technical: TechnicalConfig = field(default_factory=TechnicalConfig)
signals: SignalConfig = field(default_factory=SignalConfig)
news: NewsConfig = field(default_factory=NewsConfig)
backtest: FilterConfig = field(default_factory=FilterConfig)
xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig)
symbol: str = XAUUSD
display_name: str = "XAU/USD"
primary_timeframe: Timeframe = Timeframe.H1
confirmation_timeframes: List[Timeframe] = field(
default_factory=lambda: [Timeframe.M15, Timeframe.H4]
)
data_lookback_days: int = 60
refresh_interval: int = 300
scan_pairs: List[str] = field(
default_factory=lambda: [XAUUSD]
)
scan_timeframes: List[Timeframe] = field(
default_factory=lambda: [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
)
ws_cooldown_seconds: int = 300
log_level: str = "INFO"