Compare commits

...

16 Commits

Author SHA1 Message Date
Harwinder Sekhon 43d9064d1e Telegram: subscriber channel feed, owner lock, full risk disclaimer
- TELEGRAM_CHANNEL_ID mirrors auto-scan signal + breach alerts to a
  subscriber channel (Stars-subscription monetization; Telegram manages
  membership). Feed keeps running even when the owner's DM auto-scan
  is toggled off.
- _owner_only decorator locks all commands/buttons to TELEGRAM_CHAT_ID;
  others get a refusal pointing at the channel.
- Every signal-bearing message (detailed signal, scan summary, all-TF
  summary, breach alerts) now ends with a not-financial-advice /
  educational-use disclaimer via a single _DISCLAIMER constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 18:37:50 -04:00
Harwinder Sekhon ec13cd4c07 EUR: document closure of calendar + H1-hybrid avenues
Surprise-conditioned entries lose 2-5p at every horizon both years;
avoidance filter marginal for EUR and profit-removing for XAU; the
H1-direction + M15-pullback prototype failed its pre-registered kill
criteria (2024-25 OOS negative). EUR stays informational-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:26:32 -04:00
Harwinder Sekhon f88e39f5be XAU M15: adopt 1R minimum targets (per-timeframe, validated)
Finishes PENDING_STEPS Step 1 under the current trailing-exit model.
M15 window 2025-03-30->2026-05-31 ($0.30 spread): +116.6R/460 PF 1.83
vs +105.4R/424 PF 1.82 baseline, better in both halves; 2024-06->
2025-03 OOS +30.3R vs +10.6R control. Same knob on the pinned M5 book
is WORSE (+131.3R/699 vs +139.9R/547) so the override is M15-only in
the xauusd factory, per the pre-registered decision rule. M5 path
unchanged (factory override gated on primary_tf == M15).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:22:33 -04:00
Harwinder Sekhon 02bd61f378 Live news gate: stop capping signal confidence near events (XAU default)
Replaying the live danger/caution gates against the validated 12mo XAU
M5 book showed they suppress 25-35 trades worth +4.6..+7.2R/yr — trades
entered near USD releases outperform the book average (+0.475R vs
+0.256R). New NewsConfig.event_risk_caps_signals (default False =
XAU-tuned) keeps EVENT RISK warnings in alerts but no longer guts
confidence; EUR factory re-enables the caps (evidence is gold-specific).
Pinned XAU M5 regression: 547 trades, +139.94R, trade-identical.
Tests: 177 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:02:27 -04:00
Harwinder Sekhon 2842351f9b Add calendar research infra: FF scraper + verified event store
Scrapes Forex Factory weekly pages (epoch datelines, actual/forecast/
impact/betterWorse) into backtest/cache/calendar.parquet (gitignored;
rebuild with the scraper). Timestamps verified: NFP/CPI/ECB times exact
across DST, and high-impact event bars average 3.5x ATR with the spike
vanishing at +/-1h shifts. calendar_store provides precise_events and
event_window_mask for screens and counterfactuals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:47:11 -04:00
Harwinder Sekhon 58597de6d7 EUR M15/H1 investigation: document no-validated-edge verdict
Five independent tests at 1-pip cost (M15 wide-open both windows,
transitioning-slice OOS, mean-reversion trade sims, forward-return
screens on M15+M5, H1 both windows) — nothing clears cost in both
2024-25 and 2025-26. Root cause: spread is ~10% of M15 ATR on EUR.
EUR stays out of scan_pairs; open avenues documented in docstring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:02:24 -04:00
Harwinder Sekhon 27bccf3bf5 EUR M5: commit best-found config after realism re-tune — no validated edge
12mo grid campaign (2025-07-08 → 2026-07-08, 1-pip round-trip cost,
train/val split at 2026-01-08). Donor config scored -6.7R/34 trades.
Findings: transitioning regime -27R/88 wide-open; A-grade entries lose
(-26R) while B-grade wins; Fridays -17R; hours 8/12/13/18/19 UTC bleed;
score 30-35 band holds the train-half losses; trailing (0.45R arm/0.5R
dist) beats static TP1 and gold's 0.6/0.75.

Best-found config: -1.06R / 53 trades / PF 0.93 / 62.3% WR — breakeven,
FAILS acceptance (positive both halves, PF>=1.3). Per plan, EUR ships
functional but stays out of default scan_pairs. Grid results and
methodology documented in the module docstring; full numbers in
backtest/cache/eurv2_*.csv.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:14:49 -04:00
Harwinder Sekhon 2d704753d6 Update CLAUDE.md for multi-pair architecture
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 21:16:34 -04:00
Harwinder Sekhon 538977115b Live path: config-driven multi-pair scanning (Telegram bot + CLI)
- telegram_bot: SCAN_PAIRS/_WS_TO_PAIR/SCAN_BUTTONS all derive from
  PlatformConfig.scan_pairs (the single pair on/off switch); buttons and
  keyboards are generated per pair; _run_signal_sync builds per-pair
  configs via pair_signals factories (was: XAU defaults for any symbol);
  scan-all works per pair; PB badge matches any *_PB tag; daily-rotating
  file logs in logs/telegram.log (30-day retention)
- main.py: --pair argument (XAUUSD | EURUSD + aliases) routed through
  pair_signals factories; price display is precision-aware (2dp gold,
  5dp FX)

scan_pairs default stays [XAUUSD] until the EUR M5 re-tune validates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 21:13:42 -04:00
Harwinder Sekhon e052c7aa3e Add EUR/USD test suite + map EUR news events to EUR/USD
Module 10 (21 tests): EUR config/registry/factory, EUR filter pipeline
(regime/quality/score/confidence/RSI-extended/weekend), EUR trade
geometry (swing SL buffers, pip-denominated spread gate, per-pair gate
contrast), and cross-pair isolation regressions (XAU report bit-equal
with eur_strategy flipped; XAU gates never fire on EUR reports).

news_engine: _get_affected_pairs now maps USD events to EUR/USD too and
adds an EUR entry (calendar warnings reach EUR scans).

175/175 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 21:06:55 -04:00
Harwinder Sekhon e0789457dc Backtester: EUR/USD support with per-pair configs and default spread
- EURUSD aliases in PAIR_ALIASES
- main() builds PlatformConfig via pair_signals.PAIR_PRODUCERS factories
  (per-pair regime weights/gates/sessions); unregistered pairs keep the
  inline construction. XAU factory is asdict-identical to inline
  (enforced by test) so XAU runs are unchanged.
- --spread default now resolves from PAIR_INFO default_spread
  (XAU $0.30, EUR 0.0001) instead of a hardcoded gold value
- EUR strategy tag: renamed EUR_PB→EUR, detected in both bullish and
  bearish reasons[0] (SELL setups mark the bearish list)

Smoke-validated: 30-day EUR M5 run produces EUR-tagged trades with
1-pip round-trip cost charged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 21:02:44 -04:00
Harwinder Sekhon 3b5c0b27ed Add EUR/USD signal gates, trade geometry, and weekend/session filters
Re-grafted from feature/eurusd-support onto the evolved aggregator:
- 6h EUR gate chain (regime, quality, exhaustion, EMA200, session,
  score/confidence, volatility, indicator agreement, RSI-extended),
  active only for EUR/USD with eur_strategy.enabled
- _generate_trade_setup: EUR branch reads EurUsdStrategyConfig only
  (min_sl_pips, min_rr, mature-move + quality-scaled TP, sl_atr_floor,
  swing-buffer SL) with a pip-denominated spread gate; adds a
  pip_value<=0 guard; non-EUR branch is arithmetically unchanged
  (sl_swing_buffer=1.0 extraction only)
- EUR weekend/weekday pre-filter in analyze_pair mirroring the XAU block
- "EUR STRATEGY" reasons[0] marker so the backtester tags EUR trades

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:57:54 -04:00
Harwinder Sekhon 77604960f5 Gate all XAU-only aggregator logic by pair
The SELL quality gate (6e), SELL volatility gate (6g), M5 noise gates
(6h), ATR trade-setup generator, and M5 pullback archetype are all
gold-tuned but previously keyed only on xau_strategy.enabled or the
timeframe, so they would have silently applied gold constants to any
other pair. Each now also requires display_name == "XAU/USD".

No behavior change for XAU (conditions are tautologically true for the
only registered pair today) — verified by test suite and pinned-window
backtest CSV diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:53:23 -04:00
Harwinder Sekhon 668b7dad51 Add pair_signals per-pair producer registry
Ported from feature/eurusd-support: PAIR_PRODUCERS registry with
make_config() factories per pair. xauusd.make_config is a pure
PlatformConfig-defaults wrapper (equality with the inline construction
is enforced by test); eurusd.make_config carries the donor branch's
regime weights and gates as a pre-realism baseline.

Adds 4 registry tests. 154/154 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:52:06 -04:00
Harwinder Sekhon 88970e750b Add EUR/USD pair constants and EurUsdStrategyConfig
- EURUSD in PAIR_INFO; new per-pair price_precision and default_spread
  fields on both pairs (backtester consumes default_spread when --spread
  is omitted)
- EurUsdStrategyConfig ported from feature/eurusd-support plus EUR-local
  cost knobs (round_trip_cost=1 pip, min_tp_cost_multiple=10,
  min_sl_pips=8) so SignalConfig's gold-dollar values never apply to EUR;
  donor-tuned values are a pre-realism baseline pending re-tune
- PlatformConfig.eur_strategy field; track EUR in news currencies

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:50:08 -04:00
Harwinder Sekhon 7470e093e1 Fix 3 engine bugs: swing-point NaN false positives, ROC div-by-zero, ADX epsilon
Ported from feature/eurusd-support (e6e09c5), pair-neutral fixes:
- _find_swing_points: all(<empty generator>) is vacuously True, so a value
  surrounded by NaNs was reported as a swing point; now requires at least
  one valid neighbor on each side
- ROC: guard against zero close at the lookback positions (inf scores)
- ADX: epsilon denominator instead of NaN-replace to avoid NaN cascade

Adds 8 regression tests (Module 9). 150/150 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:48:56 -04:00
15 changed files with 1634 additions and 157 deletions
+21 -12
View File
@@ -1,6 +1,6 @@
# ForexBot2
XAU/USD signal intelligence platform. Analyzes gold across multiple timeframes using 9 technical indicators with regime-adaptive weights, news sentiment, and economic calendar data. Delivers signals via Telegram with full trade setups (entry, SL, TP).
Multi-pair signal intelligence platform (XAU/USD + EUR/USD). Analyzes each pair across multiple timeframes using 9 technical indicators with regime-adaptive weights, news sentiment, and economic calendar data. Delivers signals via Telegram with full trade setups (entry, SL, TP).
## Architecture
@@ -13,26 +13,31 @@ Twelve Data REST API + WebSocket
|
signal_aggregator.py (MTF agreement + news + final signal + trade setup)
|
pair_signals/ (per-pair config factories: xauusd.py, eurusd.py)
|
+-----+--------+
| |
telegram_bot.py main.py
(Telegram alerts, (CLI single-pair
(Telegram alerts, (CLI per-pair
auto-scanner, analysis)
WS monitor)
```
**Per-pair modularity:** each pair registers a `make_config(primary_tf, confirmation_tfs) -> PlatformConfig` factory in `pair_signals/`. `PlatformConfig.scan_pairs` is the single switch for which pairs the bot scans (single- or multi-pair). Market constants (pip value, price precision, backtest default spread) live in `PAIR_INFO`; strategy knobs live in per-pair dataclasses (`XauStrategyConfig`, `EurUsdStrategyConfig`). All XAU-specific gates in the aggregator are display-gated — nothing gold-calibrated touches the EUR path, and vice versa (enforced by pair-isolation tests + a pinned-window XAU regression CSV).
## Core Files
| File | Purpose |
|------|---------|
| `config.py` | All configuration dataclasses, XAU/USD pair definition, strategy config |
| `config.py` | All configuration dataclasses, `PAIR_INFO` registry, per-pair strategy configs |
| `data_provider.py` | Twelve Data SDK wrapper with in-memory cache and rate limiting |
| `engine.py` | `TechnicalEngine` — computes EMA, RSI, BB, ADX, ATR, MACD, ROC, Keltner, divergences, candlestick patterns, swing S/R |
| `signal_aggregator.py` | `SignalAggregator` — MTF analysis, news integration, diminishing penalty cap, trade setup generation |
| `news_engine.py` | Economic calendar (Forex Factory JSON) + RSS sentiment (keyword-based) |
| `telegram_bot.py` | Async Telegram bot with auto-scanner (M5/M15/H1/H4), WebSocket price monitor |
| `main.py` | CLI entry point for XAU/USD analysis |
| `test_all.py` | Comprehensive QA suite (~137 tests, no live API needed) |
| `pair_signals/` | Per-pair producer registry (`PAIR_PRODUCERS`) + config factories |
| `main.py` | CLI entry point (`--pair XAUUSD\|EURUSD`) |
| `test_all.py` | Comprehensive QA suite (~176 tests, no live API needed) |
## Key Concepts
@@ -60,9 +65,9 @@ ROC is directionally redundant with MACD/Keltner/EMA (0.8–0.94 correlation) bu
## How to Run
```bash
# CLI (XAU/USD analysis)
python main.py # H1 default
python main.py --timeframe M15 --detail --json
# CLI
python main.py # XAU/USD H1 default
python main.py --pair EURUSD --timeframe M5 --detail
# Telegram bot (auto-scanner)
TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py
@@ -70,8 +75,9 @@ TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py
# Tests
python test_all.py
# Backtest
# Backtest (spread defaults per pair: XAU $0.30, EUR 0.0001 — see PAIR_INFO)
python backtest/gpu_backtest.py --pair XAUUSD --days 365
python backtest/gpu_backtest.py --pair EURUSD --primary-tf 5min --confirm-tfs 15min,1h --days 365
```
## Environment Variables
@@ -80,7 +86,8 @@ python backtest/gpu_backtest.py --pair XAUUSD --days 365
|----------|---------|---------|
| `TWELVE_DATA_API_KEY` | Market data API key | Hardcoded fallback in config |
| `TELEGRAM_TOKEN` | Telegram bot token | Hardcoded fallback |
| `TELEGRAM_CHAT_ID` | Auto-scan chat ID (enables boot auto-scan) | Hardcoded fallback |
| `TELEGRAM_CHAT_ID` | Owner chat ID (enables boot auto-scan; only this user can drive the bot) | Hardcoded fallback |
| `TELEGRAM_CHANNEL_ID` | Subscriber channel ID (`-100…`); signal + breach alerts mirror there; `""` disables | Hardcoded fallback |
## Dependencies
@@ -92,7 +99,7 @@ python backtest/gpu_backtest.py --pair XAUUSD --days 365
- **Rate limit:** 55 credits/min (Grow plan), enforced by sliding-window throttle
- **Cache:** In-memory dict, TTL = candle duration of the timeframe
- **Validation:** Drops NaN rows, forward-fills gaps (limit 3), fixes OHLC inconsistencies, removes outliers (>10σ), strips future timestamps, deduplicates
- **WebSocket:** XAU/USD only
- **WebSocket:** subscribes every pair in `scan_pairs`
## Telegram Bot
@@ -100,12 +107,14 @@ python backtest/gpu_backtest.py --pair XAUUSD --days 365
- **Manual scan:** Buttons for M15/H1/H4 individual scans + "Scan All TFs" multi-timeframe summary
- **WebSocket:** Streams live ticks for XAU/USD; triggers immediate M15 scan on key level breach (swing S/R, BB, KC, EMA200) with 5-min cooldown
- **Commands:** `/start`, `/scan`, `/autoscan`, `/status`, `/cache`, `/clearcache`, `/help`
- **Owner lock:** all commands/buttons respond only to `TELEGRAM_CHAT_ID`; others get a refusal message
- **Subscriber channel:** with `TELEGRAM_CHANNEL_ID` set, auto-scan + breach alerts also post to the channel (bot must be channel admin). Monetized via Telegram Stars subscription invite links — Telegram handles membership; the bot has no payment code. Channel feed keeps running even if the owner's DM auto-scan is off
## Backtesting
The `backtest/` directory contains `gpu_backtest.py` — a GPU-accelerated backtester (CuPy with NumPy fallback) that replays historical OHLCV through the real SignalAggregator. Pre-computes all indicator series including ROC, Keltner, ADX slope for 100x+ speedup. Uses parquet caching for data persistence.
Fill realism: charges a round-trip spread per trade (`--spread`, default $0.30 for XAU), fills gap-through SL/TP at the bar open, and resolves trades against the RAW price path (the parquet cache stores raw bars; validation/cleaning runs on load — signals see cleaned data, trade resolution sees real spikes). Entry is at the next bar's open after the signal bar. Weekend/session filters evaluate bar-close (decision) time to match the live bot's wall clock.
Fill realism: charges a round-trip spread per trade (`--spread`; defaults to the pair's `default_spread` in PAIR_INFO), fills gap-through SL/TP at the bar open, and resolves trades against the RAW price path (the parquet cache stores raw bars; validation/cleaning runs on load — signals see cleaned data, trade resolution sees real spikes). Entry is at the next bar's open after the signal bar. Weekend/session filters evaluate bar-close (decision) time to match the live bot's wall clock.
GPU note: CuPy needs the NVIDIA runtime pip wheels (`nvidia-cuda-runtime-cu12`, `nvidia-cuda-nvrtc-cu12`, `nvidia-curand-cu12`, `nvidia-nvjitlink-cu12`) or it silently falls back to NumPy — check the "Backend:" line in the run log. The bar-replay loop itself is single-threaded Python either way (~340 bars/s); GPU only accelerates indicator precompute and SL/TP scanning.
+25 -8
View File
@@ -48,6 +48,7 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from config import ( # noqa: E402
EURUSD,
PAIR_INFO,
PlatformConfig,
SignalStrength,
@@ -96,6 +97,9 @@ PAIR_ALIASES = {
"XAUUSD": XAUUSD,
"XAU/USD": XAUUSD,
"GOLD": XAUUSD,
"EURUSD": EURUSD,
"EUR/USD": EURUSD,
"EUR": EURUSD,
}
# Bar duration in seconds — used to compute "fully-closed" cutoffs so
@@ -1632,8 +1636,8 @@ def run_backtest(
tag = "XAU_PB"
elif "XAU STRATEGY" in first_bull:
tag = "XAU"
elif "EUR STRATEGY" in first_bull:
tag = "EUR_PB"
elif "EUR STRATEGY" in first_bull or "EUR STRATEGY" in first_bear:
tag = "EUR"
elif "GBP STRATEGY" in first_bull:
tag = "GBP_PB"
@@ -2034,7 +2038,7 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
description="GPU-accelerated backtester for ForexBot2",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument("--pair", required=True, help="XAUUSD | XAU/USD | GOLD")
p.add_argument("--pair", required=True, help="XAUUSD | GOLD | EURUSD")
p.add_argument("--days", type=int, default=180, help="Days of history (ignored if --start/--end set)")
p.add_argument("--start", help="ISO date YYYY-MM-DD (overrides --days)")
p.add_argument("--end", help="ISO date YYYY-MM-DD (defaults to today)")
@@ -2047,10 +2051,11 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
help="Override TechnicalConfig.swing_sr_lookback (0 = use config default)")
p.add_argument("--quality", default="A+,A,B,C", help="Comma-separated allowed grades")
p.add_argument("--strategies", default="",
help="Comma-separated strategy tags to include (XAU,EUR_PB,GBP_PB,GENERIC). Empty = all.")
help="Comma-separated strategy tags to include (XAU,XAU_PB,EUR,GENERIC). Empty = all.")
p.add_argument("--no-cache", action="store_true", help="Skip parquet cache (force API fetch)")
p.add_argument("--spread", type=float, default=0.30,
help="Round-trip cost in price units per trade (XAU ~$0.30; 0 disables)")
p.add_argument("--spread", type=float, default=None,
help="Round-trip cost in price units per trade (default: pair's "
"default_spread from PAIR_INFO — XAU $0.30, EUR 0.0001; 0 disables)")
p.add_argument("--export-csv", help="Optional path to dump per-trade CSV (incl. indicator breakdown)")
p.add_argument("--detail", action="store_true",
help="Print full per-trade reasoning (indicators, signals, agreement, why entered)")
@@ -2097,6 +2102,9 @@ def main(argv: Optional[List[str]] = None) -> int:
else:
start = end - timedelta(days=args.days)
spread = (args.spread if args.spread is not None
else PAIR_INFO[pair].get("default_spread", 0.30))
bt_cfg = BacktestConfig(
pair=pair,
start=start,
@@ -2109,14 +2117,23 @@ def main(argv: Optional[List[str]] = None) -> int:
use_cache=not args.no_cache,
quality_filter=[q.strip() for q in args.quality.split(",") if q.strip()],
strategies_only=[s.strip() for s in args.strategies.split(",") if s.strip()],
spread=args.spread,
spread=spread,
)
logger.info(
f"Backend: {'CuPy GPU' if _GPU_AVAILABLE else 'NumPy CPU fallback'} | "
f"Pair: {PAIR_INFO[pair]['display']} | Window: {start.date()} → {end.date()}"
f"Pair: {PAIR_INFO[pair]['display']} | Window: {start.date()} → {end.date()} | "
f"Spread: {spread}"
)
# Per-pair tuned configs come from the pair_signals factories (regime
# weights, strategy gates, sessions). Unregistered pairs fall back to
# the inline construction.
from pair_signals import PAIR_PRODUCERS # noqa: E402 (after sys.path fix)
factory = PAIR_PRODUCERS.get(pair)
if factory is not None:
platform_cfg = factory(primary_tf, confirm_tfs)
else:
platform_cfg = PlatformConfig(
symbol=pair,
display_name=PAIR_INFO[pair]["display"],
+5
View File
@@ -0,0 +1,5 @@
"""Research tooling: calendar sourcing/store and edge-screening utilities.
Nothing here is imported by the live bot; these modules support
backtesting and edge validation only.
"""
+94
View File
@@ -0,0 +1,94 @@
"""Load and query the historical economic calendar (backtest side).
The store is built by `ff_calendar_scraper.py`. Timestamps are unix
epoch (UTC) — verified against known release times and price-volatility
alignment (see scraper docstring).
Key API:
cal = load_calendar() # full DataFrame, `utc` column
ev = precise_events(cal, ["USD", "EUR"]) # high-impact, minute-accurate
mask = event_window_mask(bar_index, ev.utc, before, after)
"""
from pathlib import Path
from typing import Iterable, Optional
import numpy as np
import pandas as pd
PARQUET = Path(__file__).resolve().parent.parent / "cache" / "calendar.parquet"
# timeLabel values that mean "no intraday time" — these events cannot
# anchor an M5/M15 window (German Prelim CPI is the notable High one).
_UNTIMED = {"All Day", "Tentative", ""}
def _parse_release_value(raw) -> float:
"""'0.3%' / '-1.7%' / '228K' / '1.1M' -> float (nan if unparseable)."""
import re
if raw is None or (isinstance(raw, float) and np.isnan(raw)):
return np.nan
m = re.search(r"(-?\d+(?:\.\d+)?)\s*([kmbt])?",
str(raw).replace(",", ""), re.IGNORECASE)
if not m:
return np.nan
mult = {"k": 1e3, "m": 1e6, "b": 1e9, "t": 1e12}.get(
(m.group(2) or "").lower(), 1.0)
return float(m.group(1)) * mult
def load_calendar(path: Path = PARQUET) -> pd.DataFrame:
df = pd.read_parquet(path)
df["utc"] = pd.to_datetime(df.dateline, unit="s", utc=True)
for col in ("actual", "forecast", "previous"):
df[f"{col}_num"] = df[col].map(_parse_release_value)
df["surprise"] = df.actual_num - df.forecast_num
return df
def precise_events(
cal: pd.DataFrame,
currencies: Iterable[str] = ("USD", "EUR"),
impacts: Iterable[str] = ("high",),
require_values: bool = False,
) -> pd.DataFrame:
"""Events with a minute-accurate timestamp, filtered by currency/impact."""
out = cal[cal.currency.isin(list(currencies))
& cal.impactName.isin(list(impacts))
& ~cal.timeMasked.astype(bool)
& ~cal.timeLabel.fillna("").isin(_UNTIMED)]
if require_values:
out = out.dropna(subset=["actual_num", "forecast_num"])
return out.sort_values("utc").reset_index(drop=True)
def event_window_mask(
bar_index: pd.DatetimeIndex,
event_times: pd.Series,
before: pd.Timedelta = pd.Timedelta(minutes=30),
after: pd.Timedelta = pd.Timedelta(minutes=30),
) -> np.ndarray:
"""Boolean mask: bar OPEN time falls inside [event-before, event+after).
Use on a backtest's entry times to identify trades entered around
releases (avoidance counterfactuals, event-window cost models).
"""
starts = pd.DatetimeIndex(event_times) - before
ends = pd.DatetimeIndex(event_times) + after
order = np.argsort(starts.values)
starts, ends = starts.values[order], ends.values[order]
# merge overlapping windows, then interval-stab each bar time
merged_s, merged_e = [], []
for s, e in zip(starts, ends):
if merged_e and s <= merged_e[-1]:
merged_e[-1] = max(merged_e[-1], e)
else:
merged_s.append(s)
merged_e.append(e)
ms = np.array(merged_s)
me = np.array(merged_e)
ts = bar_index.values
pos = np.searchsorted(ms, ts, side="right") - 1
ok = pos >= 0
res = np.zeros(len(ts), dtype=bool)
res[ok] = ts[ok] < me[pos[ok]]
return res
+144
View File
@@ -0,0 +1,144 @@
"""Forex Factory calendar scraper -> backtest/cache/calendar.parquet.
Fetches weekly calendar pages and parses the event JSON embedded in
`window.calendarComponentStates` (the `days:` array). Event `dateline`
is a unix epoch, so timestamps carry no timezone ambiguity — verified
2026-07-11 against known release times (NFP 12:30/13:30 UTC across DST,
ECB 12:15/13:15) and against EUR/USD M15 volatility: the bar containing
a high-impact event averages 3.5x normal range, and shifting event
times by ±1h destroys that alignment.
Usage:
python backtest/research/ff_calendar_scraper.py --start 2024-01-01 --end 2026-07-13
python backtest/research/ff_calendar_scraper.py # refresh: last stored week -> today
Merges into the existing parquet on event (id, dateline), so refreshing
is idempotent and also picks up revised `actual` values for recent weeks.
Be polite: ~1.6s between requests (a full year is ~52 requests).
"""
import argparse
import json
import time
from datetime import date, timedelta
from pathlib import Path
from typing import List, Optional
import pandas as pd
import requests
CACHE_DIR = Path(__file__).resolve().parent.parent / "cache"
PARQUET = CACHE_DIR / "calendar.parquet"
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
FIELDS = ["id", "dateline", "name", "country", "currency", "impactName",
"timeLabel", "timeMasked", "actual", "forecast", "previous",
"revision", "actualBetterWorse"]
def week_url(d: date) -> str:
return (f"https://www.forexfactory.com/calendar?week="
f"{d.strftime('%b').lower()}{d.day}.{d.year}")
def extract_days(html: str) -> Optional[list]:
"""Balanced-bracket extraction of the `days:` JSON array."""
i = html.find("days:")
if i < 0:
return None
j = html.find("[", i)
depth, in_str, esc = 0, False, False
for k in range(j, len(html)):
ch = html[k]
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
else:
if ch == '"':
in_str = True
elif ch == "[":
depth += 1
elif ch == "]":
depth -= 1
if depth == 0:
return json.loads(html[j:k + 1])
return None
def fetch_week(sess: requests.Session, d: date, retries: int = 4) -> Optional[List[dict]]:
for attempt in range(retries):
try:
r = sess.get(week_url(d), timeout=30)
if r.status_code == 200:
days = extract_days(r.text)
if days is not None:
return [{k: e.get(k) for k in FIELDS}
for day in days for e in day.get("events", [])]
except requests.RequestException:
pass
time.sleep(5 * (attempt + 1))
return None
def scrape(start: date, end: date) -> pd.DataFrame:
sess = requests.Session()
sess.headers["User-Agent"] = UA
rows: List[dict] = []
failed: List[date] = []
d = start
while d <= end:
week_rows = fetch_week(sess, d)
if week_rows is None:
failed.append(d)
print(f"FAILED week {d}")
else:
rows.extend(week_rows)
d += timedelta(days=7)
time.sleep(1.6)
if failed:
print(f"WARNING: {len(failed)} weeks failed: {failed}")
return pd.DataFrame(rows)
def merge_into_store(new: pd.DataFrame, path: Path = PARQUET) -> pd.DataFrame:
if path.exists():
old = pd.read_parquet(path)
combined = pd.concat([old, new])
else:
combined = new
# keep="last": a refresh overwrites stale rows (actuals fill in
# after release, revisions land weeks later).
combined = (combined.drop_duplicates(subset=["id", "dateline"], keep="last")
.sort_values("dateline").reset_index(drop=True))
combined.to_parquet(path, index=False)
return combined
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--start", type=date.fromisoformat, default=None)
ap.add_argument("--end", type=date.fromisoformat, default=date.today())
args = ap.parse_args()
start = args.start
if start is None:
if PARQUET.exists():
last = pd.read_parquet(PARQUET).dateline.max()
start = pd.Timestamp(last, unit="s").date() - timedelta(days=7)
else:
start = date(2024, 1, 1)
print(f"Scraping {start} -> {args.end}")
new = scrape(start, args.end)
combined = merge_into_store(new)
print(f"Store: {len(combined)} events "
f"({pd.Timestamp(combined.dateline.min(), unit='s')} -> "
f"{pd.Timestamp(combined.dateline.max(), unit='s')}) -> {PARQUET}")
if __name__ == "__main__":
main()
+66 -2
View File
@@ -37,9 +37,16 @@ class MarketSession(Enum):
# ── Supported pairs ──────────────────────────────────────────────
XAUUSD = "XAU/USD"
EURUSD = "EUR/USD"
# price_precision: decimals for SL/TP rounding and display.
# default_spread: round-trip cost per trade in price units, charged by
# the backtester when --spread is not given.
PAIR_INFO = {
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False},
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False,
"price_precision": 2, "default_spread": 0.30},
EURUSD: {"display": "EUR/USD", "pip_value": 0.0001, "is_jpy_pair": False,
"price_precision": 5, "default_spread": 0.0001},
}
@@ -158,9 +165,17 @@ class SignalConfig:
@dataclass
class NewsConfig:
"""Economic calendar & sentiment."""
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU"])
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU", "EUR"])
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 via backtest/research/calendar_store.
event_risk_caps_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
@@ -252,6 +267,54 @@ class XauStrategyConfig:
m5_pullback_touch_bars: int = 3
@dataclass
class EurUsdStrategyConfig:
"""EUR/USD strategy config — separate from XAU for independent tuning.
Values ported from feature/eurusd-support are a pre-realism baseline
(tuned before spread charging and next-open fills) and are re-tuned
against the realistic backtester. Cost knobs (round_trip_cost,
min_tp_cost_multiple, min_sl_pips) are EUR-local so nothing
gold-calibrated from SignalConfig leaks into the EUR path.
"""
enabled: bool = True
blocked_hours_utc: List[int] = field(default_factory=lambda: [22, 23, 0, 1, 2, 3])
weekend_filter: bool = True
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4])
min_atr_threshold: float = 0.0003
sl_atr_multiplier: float = 1.5 # swing-buffer ATR multiple for SL placement
tp_atr_multiplier: float = 1.2
sl_atr_floor: float = 1.2
min_rr_ratio: float = 1.0
mature_move_threshold: float = 35.0
tp_quality_scale: Dict[str, float] = field(default_factory=lambda: {
"A+": 1.0, "A": 1.0, "B": 1.0, "C": 1.0,
})
blocked_regimes: List[str] = field(default_factory=lambda: ["ranging", "transitioning"])
min_quality_grades: List[str] = field(default_factory=lambda: ["A+", "A", "B"])
max_exhaustion_tags: int = 99
block_near_ema200: bool = False
session_hours: List[int] = field(default_factory=list)
min_entry_score: float = 38.0
min_entry_confidence: float = 60.0
block_rsi_extended: bool = True
blocked_vol_regimes: List[str] = field(default_factory=list)
min_indicator_agreement: int = 0
be_trigger_r: float = 0.3
be_buffer_r: float = 0.15
# ── EUR-local trade-cost knobs (replace SignalConfig's gold-dollar
# values in the EUR path) ─────────────────────────────────────────
# Round-trip cost per trade in price units (1.0 pip).
round_trip_cost: float = 0.0001
# Reject setups whose TP1 distance < round_trip_cost × this multiple
# (10 → 10-pip minimum target; EUR M5 ATR runs ~5-8 pips).
min_tp_cost_multiple: float = 10.0
# Minimum SL distance in pips.
min_sl_pips: float = 8.0
@dataclass
class FilterConfig:
"""Regime and session filters for live signals."""
@@ -275,6 +338,7 @@ class PlatformConfig:
news: NewsConfig = field(default_factory=NewsConfig)
backtest: FilterConfig = field(default_factory=FilterConfig)
xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig)
eur_strategy: EurUsdStrategyConfig = field(default_factory=EurUsdStrategyConfig)
symbol: str = XAUUSD
display_name: str = "XAU/USD"
+13 -7
View File
@@ -492,7 +492,7 @@ class TechnicalEngine:
plus_di = 100 * (plus_dm.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean() / atr)
minus_di = 100 * (minus_dm.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean() / atr)
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan)
dx = 100 * (plus_di - minus_di).abs() / np.maximum(plus_di + minus_di, 1e-10)
adx = dx.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean()
adx_val = adx.iloc[-1] if not adx.empty and not pd.isna(adx.iloc[-1]) else 0.0
@@ -602,8 +602,12 @@ class TechnicalEngine:
if len(close) < n + 5:
return
roc_val = ((close.iloc[-1] - close.iloc[-n - 1]) / close.iloc[-n - 1]) * 100
roc_prev = ((close.iloc[-2] - close.iloc[-n - 2]) / close.iloc[-n - 2]) * 100
denom1 = close.iloc[-n - 1]
denom2 = close.iloc[-n - 2]
if denom1 == 0 or denom2 == 0:
return
roc_val = ((close.iloc[-1] - denom1) / denom1) * 100
roc_prev = ((close.iloc[-2] - denom2) / denom2) * 100
if pd.isna(roc_val) or pd.isna(roc_prev):
return
@@ -1154,12 +1158,14 @@ class TechnicalEngine:
for i in range(window, len(vals) - window):
if np.isnan(vals[i]):
continue
left_vals = [vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])]
right_vals = [vals[i+j] for j in range(1, window+1) if i+j < len(vals) and not np.isnan(vals[i+j])]
if not left_vals or not right_vals:
continue
if mode == "low":
if all(vals[i] <= vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])) and \
all(vals[i] <= vals[i+j] for j in range(1, window+1) if i+j < len(vals) and not np.isnan(vals[i+j])):
if all(vals[i] <= v for v in left_vals) and all(vals[i] <= v for v in right_vals):
points.append((i, vals[i]))
else:
if all(vals[i] >= vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])) and \
all(vals[i] >= vals[i+j] for j in range(1, window+1) if i+j < len(vals) and not np.isnan(vals[i+j])):
if all(vals[i] >= v for v in left_vals) and all(vals[i] >= v for v in right_vals):
points.append((i, vals[i]))
return points
+37 -10
View File
@@ -21,7 +21,8 @@ if hasattr(sys.stdout, "reconfigure"):
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD, EURUSD
from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport
@@ -97,6 +98,11 @@ def _compact_reason(reason: str) -> str:
return s.strip()
def _fmt_price(price: float) -> str:
"""2dp for gold-scale prices, 5dp for FX-scale (EUR/USD etc.)."""
return f"{price:.2f}" if price >= 10 else f"{price:.5f}"
def print_signal_report(report: SignalReport, detail: bool = False):
sc = signal_color(report.final_signal)
qc = quality_color(report.signal_quality)
@@ -116,16 +122,16 @@ def print_signal_report(report: SignalReport, detail: bool = False):
ts = report.trade_setup
dc = Colors.GREEN if ts.direction == "BUY" else Colors.RED
print(f"\n {Colors.BOLD}Setup{Colors.RESET} "
f"{dc}{ts.direction}{Colors.RESET} @ {Colors.BOLD}{ts.entry_price:.3f}{Colors.RESET} "
f"→ SL {Colors.RED}{ts.stop_loss:.3f}{Colors.RESET} "
f"{dc}{ts.direction}{Colors.RESET} @ {Colors.BOLD}{_fmt_price(ts.entry_price)}{Colors.RESET} "
f"→ SL {Colors.RED}{_fmt_price(ts.stop_loss)}{Colors.RESET} "
f"({ts.risk_pips:.1f}p) "
f"→ TP {Colors.GREEN}{ts.take_profit_1:.3f}{Colors.RESET} "
f"→ TP {Colors.GREEN}{_fmt_price(ts.take_profit_1)}{Colors.RESET} "
f"({ts.reward_pips:.1f}p) "
f"RR 1:{ts.risk_reward:.1f}")
# ── 3. CONTEXT (price · regime · vol · MTF breakdown) ──────────
print(f"\n {Colors.WHITE}{report.display_name}{Colors.RESET} "
f"{Colors.BOLD}{report.current_price:.3f}{Colors.RESET} · "
f"{Colors.BOLD}{_fmt_price(report.current_price)}{Colors.RESET} · "
f"{Colors.CYAN}{report.market_regime}{Colors.RESET} · "
f"vol {report.volatility_regime}")
@@ -250,6 +256,8 @@ Examples:
python main.py --json JSON output
""",
)
parser.add_argument("--pair", "-p", default="XAUUSD",
help="Pair to analyze: XAUUSD | EURUSD (default: XAUUSD)")
parser.add_argument("--timeframe", "-tf", default="H1",
choices=["M5", "M15", "M30", "H1", "H4", "D1"],
help="Primary analysis timeframe (default: H1)")
@@ -263,14 +271,25 @@ Examples:
return parser.parse_args()
def _resolve_pair(s: str) -> str:
aliases = {
"XAUUSD": XAUUSD, "XAU/USD": XAUUSD, "GOLD": XAUUSD,
"EURUSD": EURUSD, "EUR/USD": EURUSD, "EUR": EURUSD,
}
key = s.upper().strip()
if key in aliases:
return aliases[key]
raise SystemExit(f"Unknown pair '{s}'. Try one of: {', '.join(sorted(aliases))}")
def build_config(args) -> PlatformConfig:
config = PlatformConfig()
pair = _resolve_pair(args.pair)
tf_map = {
"M5": Timeframe.M5, "M15": Timeframe.M15, "M30": Timeframe.M30,
"H1": Timeframe.H1, "H4": Timeframe.H4, "D1": Timeframe.D1,
}
config.primary_timeframe = tf_map[args.timeframe]
primary_tf = tf_map[args.timeframe]
tf_confirmations = {
"M5": [Timeframe.M1, Timeframe.M15],
@@ -280,23 +299,31 @@ def build_config(args) -> PlatformConfig:
"H4": [Timeframe.H1, Timeframe.D1],
"D1": [Timeframe.H4, Timeframe.W1],
}
config.confirmation_timeframes = tf_confirmations.get(
confirmation_tfs = tf_confirmations.get(
args.timeframe, [Timeframe.M15, Timeframe.H4]
)
# Per-pair tuned configs from the pair_signals factories.
factory = PAIR_PRODUCERS.get(pair)
if factory is not None:
return factory(primary_tf, confirmation_tfs)
config = PlatformConfig()
config.primary_timeframe = primary_tf
config.confirmation_timeframes = confirmation_tfs
return config
def run_live(args, config: PlatformConfig):
print(f"{Colors.GRAY} Fetching market data and running analysis...{Colors.RESET}\n")
aggregator = SignalAggregator(config)
report = aggregator.analyze_pair()
report = aggregator.analyze_pair(symbol=config.symbol)
if args.json:
print(json.dumps(report_to_dict(report), indent=2))
return
print_header("XAU/USD SIGNAL INTELLIGENCE")
print_header(f"{config.display_name} SIGNAL INTELLIGENCE")
print_signal_report(report, detail=args.detail)
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
print(f"{Colors.GRAY} Disclaimer: For educational/research purposes only.{Colors.RESET}")
+2 -1
View File
@@ -673,7 +673,8 @@ class NewsEngine:
@staticmethod
def _get_affected_pairs(currency: str) -> List[str]:
_MAP = {
"USD": ["XAU/USD"],
"USD": ["XAU/USD", "EUR/USD"],
"XAU": ["XAU/USD"],
"EUR": ["EUR/USD"],
}
return _MAP.get(currency, [])
+52
View File
@@ -0,0 +1,52 @@
"""
Per-pair signal producers.
Each pair module exposes:
make_config(primary_tf, confirmation_tfs) -> PlatformConfig
To add a new pair: create pair_signals/<pair>.py with make_config(),
then register it in this file.
"""
from typing import Optional, List, Dict, Callable
from config import PlatformConfig, Timeframe
from signal_aggregator import SignalAggregator, SignalReport
from data_provider import ForexDataProvider
PAIR_PRODUCERS: Dict[str, Callable] = {}
def register_pair(symbol: str, config_factory: Callable):
PAIR_PRODUCERS[symbol] = config_factory
def get_supported_pairs() -> List[str]:
return list(PAIR_PRODUCERS.keys())
def produce_signal(
symbol: str,
primary_tf: Timeframe,
data_provider: ForexDataProvider,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> SignalReport:
config_factory = PAIR_PRODUCERS.get(symbol)
if config_factory is None:
raise ValueError(
f"No signal producer registered for {symbol}. "
f"Available: {list(PAIR_PRODUCERS.keys())}"
)
config = config_factory(primary_tf, confirmation_tfs)
aggregator = SignalAggregator(config)
aggregator.data_provider = data_provider
return aggregator.analyze_pair(symbol=symbol)
from pair_signals import xauusd as _xau_mod
from pair_signals import eurusd as _eur_mod
from config import XAUUSD, EURUSD
register_pair(XAUUSD, _xau_mod.make_config)
register_pair(EURUSD, _eur_mod.make_config)
+158
View File
@@ -0,0 +1,158 @@
"""EUR/USD signal producer config.
Re-tuned 2026-07-10 against the realistic backtester (1-pip round-trip
cost, next-open fills, raw-path resolution) on 2025-07-08 → 2026-07-08
M5 with M15/H1 confirmations. RESULT: no configuration passed the
acceptance rule (positive in BOTH train and val halves, PF >= 1.3).
The M5 defaults below are the best found — roughly breakeven
(-1.1R / 53 trades / PF 0.93 / 62% WR on 12mo) versus the donor
branch's -6.7R. Findings that shaped them:
- transitioning regime bled -27R/88 trades wide-open → blocked
- A-grade entries LOSE (-26R) while B-grade wins — max conviction
fires at move extremes (same pathology as gold M5)
- Fridays (-17R) and hours 8, 12, 13, 18, 19 UTC negative
- score 30-35 band is where the train-half losses live → floor 35
- trailing exits beat static TP1 (arm 0.45R / dist 0.5R best)
M15 + H1 investigated 2026-07-11, verdict NO VALIDATED EDGE either
(all at 1-pip round-trip cost, real fills):
- M15 wide-open (all gates off): -2.5R/270 trades 2025-26;
-54.8R/308 trades on 2024-25 out-of-sample. PF 0.61-0.98.
- Best in-sample slice (transitioning-regime-only: +15.4R, PF 1.59,
positive both halves, month-spread) FAILED 2024-25 OOS at PF 0.55.
Within-year train/val splits are not sufficient for EUR — always
re-test on the 2024 window before believing a config.
- Raw-parquet forward-return screens (M15 and M5, ~14 entry
conditions x 4-5 horizons: momentum, mean-reversion, breakout,
streak, session families): nothing clears +1 pip in both years.
- H1 wide-open: +9.8R PF 1.32 in 2025-26 (both halves positive) but
-5.7R PF 0.82 on 2024-25. Positive from Jan 2025 onward; H2-2024
regime negative. Closest to workable, still unvalidated.
Root cause is cost-to-volatility: EUR M15 ATR ~= 10 pips, so the 1-pip
spread taxes every trade ~10% of ATR (M5 worse). The XAU signal engine
survives because gold's ATR/spread ratio is several times larger.
EUR/USD therefore ships FUNCTIONAL BUT OFF by default: it is not in
PlatformConfig.scan_pairs. Scan it on demand (main.py --pair EURUSD,
or add to scan_pairs) but treat all EUR M5/M15 signals as informational
only.
Calendar + hybrid avenues CLOSED 2026-07-12 (see backtest/research/):
- Surprise-conditioned entries (real Forex Factory archive, verified
timestamps): follow-surprise loses 2-5 pips at every horizon in
both years under every conditioning. The market prices the
surprise within the release bar.
- Event-window avoidance: EUR effect marginal/inconsistent (and on
XAU it would REMOVE profit — event-window trades outperform).
- H1-anchored direction + M15 pullback execution (pre-registered
kill criteria): 2025-26 +2.8R PF 1.11, 2024-25 OOS -1.5R PF 0.92
-> killed.
Remaining avenues need data we don't have: tick-level fills for
limit-entry cost reduction, or H4/D1 swing horizons.
"""
from typing import Optional, List
from config import PlatformConfig, Timeframe, EURUSD
_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],
}
_TRENDING = {
"ema_trend": 2.0, "rsi": 1.0, "macd": 2.0,
"bollinger": 0.5, "keltner": 1.0, "roc": 1.5,
"candlestick": 0.5, "divergence": 2.0, "adx": 1.0,
}
EUR_REGIME_WEIGHTS = {
"trending_up": _TRENDING,
"trending_down": _TRENDING,
"trending": _TRENDING,
"ranging": {
"ema_trend": 0.3, "rsi": 2.0, "macd": 1.0,
"bollinger": 2.0, "keltner": 1.5, "roc": 0.5,
"candlestick": 0.8, "divergence": 2.5, "adx": 0.5,
},
"transitioning": {
"ema_trend": 1.5, "rsi": 1.5, "macd": 2.0,
"bollinger": 1.0, "keltner": 1.5, "roc": 2.0,
"candlestick": 0.8, "divergence": 2.0, "adx": 1.0,
},
}
_M5_TRENDING = {
"ema_trend": 1.5, "rsi": 1.0, "macd": 2.5,
"bollinger": 0.5, "keltner": 2.0, "roc": 2.0,
"candlestick": 0.3, "divergence": 1.0, "adx": 1.0,
}
M5_REGIME_WEIGHTS = {
"trending_up": _M5_TRENDING,
"trending_down": _M5_TRENDING,
"trending": _M5_TRENDING,
"ranging": {
"ema_trend": 0.3, "rsi": 1.5, "macd": 1.5,
"bollinger": 1.5, "keltner": 2.0, "roc": 1.0,
"candlestick": 0.3, "divergence": 1.5, "adx": 0.5,
},
"transitioning": {
"ema_trend": 1.0, "rsi": 1.0, "macd": 2.5,
"bollinger": 0.5, "keltner": 2.5, "roc": 2.0,
"candlestick": 0.3, "divergence": 1.0, "adx": 1.0,
},
}
def make_config(
primary_tf: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> PlatformConfig:
config = PlatformConfig()
config.symbol = EURUSD
config.display_name = "EUR/USD"
config.pip_value = 0.0001
config.primary_timeframe = primary_tf
config.confirmation_timeframes = (
confirmation_tfs or _CONFIRMATIONS.get(primary_tf, [Timeframe.M15, Timeframe.H4])
)
# XAU gates are display-gated in the aggregator; disabling here is
# belt-and-braces so no gold logic can ever fire on an EUR config.
config.xau_strategy.enabled = False
# Keep event-risk signal gating ON for EUR (the XAU-tuned default
# turned it off after the 2026-07-11 audit — that evidence is
# gold-specific; EUR event-window trades were flat-to-negative).
config.news.event_risk_caps_signals = True
config.backtest.session_start_hour = 7
config.backtest.session_end_hour = 20
config.backtest.blocked_hours_utc = [22, 23, 0, 1, 2, 3]
config.backtest.allowed_weekdays = [0, 1, 2, 3, 4]
if primary_tf == Timeframe.M5:
config.technical.regime_weights_override = M5_REGIME_WEIGHTS
config.technical.swing_sr_lookback = 20
config.eur_strategy.sl_atr_floor = 0.7
config.eur_strategy.sl_atr_multiplier = 0.3
config.eur_strategy.min_quality_grades = ["A+", "A", "B"]
# Best-found 2026-07-10 (breakeven, see module docstring):
config.eur_strategy.min_entry_score = 35.0
config.eur_strategy.min_entry_confidence = 45.0
config.eur_strategy.blocked_vol_regimes = []
config.eur_strategy.min_indicator_agreement = 0
config.eur_strategy.allowed_weekdays = [0, 1, 2, 3] # Fridays bled -17R
config.backtest.allowed_weekdays = [0, 1, 2, 3]
config.backtest.blocked_hours_utc = [22, 23, 0, 1, 2, 3, 8, 12, 13, 18, 19]
# Trailing exit beat static TP1; earlier/tighter than gold's 0.6/0.75.
config.signals.trail_arm_r = 0.45
config.signals.trail_dist_r = 0.5
else:
config.technical.regime_weights_override = EUR_REGIME_WEIGHTS
return config
+42
View File
@@ -0,0 +1,42 @@
"""XAU/USD signal producer config.
PlatformConfig defaults ARE the tuned XAU/USD configuration, so this
factory sets the timeframes plus documented per-timeframe overrides.
It must stay equivalent to the inline PlatformConfig(symbol=XAUUSD,
...) construction (plus those overrides) used by the backtester and
Telegram bot (enforced by a test in test_all.py).
M15 override (validated 2026-07-11, window 2025-03-30 -> 2026-05-31,
$0.30 spread, trailing exits): tp_floor_rr 1.0 scores +116.6R/460
trades PF 1.83 vs +105.4R/424 PF 1.82 at the 0.5 default — better in
both train/val halves and on the 2024-06 -> 2025-03 out-of-sample
window (+30.3R vs +10.6R; the 1R floor lifts targets past the min-TP
spread gate, roughly doubling trades there). The same knob HURTS M5
(pinned 12mo book: +131.3R/699 vs +139.9R/547, PF 1.55 vs 1.78), so it
is per-timeframe, exactly as PENDING_STEPS' decision rule anticipated.
"""
from typing import Optional, List
from config import PlatformConfig, Timeframe
_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],
}
def make_config(
primary_tf: Timeframe,
confirmation_tfs: Optional[List[Timeframe]] = None,
) -> PlatformConfig:
config = PlatformConfig()
config.primary_timeframe = primary_tf
config.confirmation_timeframes = (
confirmation_tfs or _CONFIRMATIONS.get(primary_tf, [Timeframe.M15, Timeframe.H4])
)
if primary_tf == Timeframe.M15:
# 1R minimum target on M15 only — see module docstring.
config.signals.tp_floor_rr = 1.0
return config
+184 -9
View File
@@ -157,6 +157,39 @@ class SignalAggregator:
report.action_recommendation = f"NO TRADE — Only trading {allowed}"
return report
if pair_info["display"] == "EUR/USD" and self.config.eur_strategy.enabled:
eur_cfg = self.config.eur_strategy
if eur_cfg.weekend_filter and _is_forex_weekend(now_utc):
report = SignalReport(
symbol=symbol,
display_name=pair_info["display"],
timestamp=str(now_utc),
)
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.trade_setup = None
report.warnings.append("WEEKEND FILTER: EUR/USD market closed — no liquidity")
report.action_recommendation = "NO TRADE — Weekend (market closed)"
return report
if eur_cfg.allowed_weekdays and now_utc.weekday() not in eur_cfg.allowed_weekdays:
day_names = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 5: "Sat", 6: "Sun"}
allowed = "/".join(day_names.get(d, str(d)) for d in eur_cfg.allowed_weekdays)
report = SignalReport(
symbol=symbol,
display_name=pair_info["display"],
timestamp=str(now_utc),
)
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.trade_setup = None
report.warnings.append(
f"DAY FILTER: {day_names.get(now_utc.weekday(), '?')} not in allowed days ({allowed})"
)
report.action_recommendation = f"NO TRADE — Only trading {allowed}"
return report
timeframes = [self.config.primary_timeframe] + list(
self.config.confirmation_timeframes
)
@@ -704,6 +737,7 @@ class SignalAggregator:
or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0)
)
if (self.config.xau_strategy.enabled
and report.display_name == "XAU/USD"
and xau_is_sell_dir
and report.signal_quality not in self.config.xau_strategy.sell_min_quality):
report.warnings.append(
@@ -734,6 +768,7 @@ class SignalAggregator:
# Shorts in quiet markets fade against gold's macro uptrend;
# only take sells when volatility regime confirms real selling.
if (self.config.xau_strategy.enabled
and report.display_name == "XAU/USD"
and xau_is_sell_dir
and report.signal_quality in ("A+", "A", "B", "C")
and report.volatility_regime
@@ -744,12 +779,103 @@ class SignalAggregator:
report.signal_quality = "D"
report.trade_setup = None
# ── 6h. M5 noise gate ────────────────────────────────────
if self.config.primary_timeframe == Timeframe.M5:
# ── 6h. EUR/USD regime + quality + risk gate ──────────────
if report.display_name == "EUR/USD" and self.config.eur_strategy.enabled:
eur = self.config.eur_strategy
if eur.blocked_regimes and report.market_regime in eur.blocked_regimes:
report.warnings.append(
f"EUR REGIME FILTER: {report.market_regime} blocked"
)
report.signal_quality = "F"
if (eur.min_quality_grades
and report.signal_quality not in eur.min_quality_grades
and report.signal_quality != "F"):
report.warnings.append(
f"EUR QUALITY FILTER: {report.signal_quality} below minimum"
)
report.signal_quality = "F"
if (report.signal_quality != "F"
and getattr(report, 'exhaustion_count', 0) > eur.max_exhaustion_tags):
report.warnings.append(
f"EUR EXHAUSTION FILTER: {report.exhaustion_count} tags > max {eur.max_exhaustion_tags}"
)
report.signal_quality = "F"
if (report.signal_quality != "F"
and eur.block_near_ema200
and any(w.startswith("NEAR EMA200") for w in report.warnings)):
report.warnings.append("EUR EMA200 FILTER: too close to EMA200")
report.signal_quality = "F"
if eur.session_hours and report.signal_quality != "F":
try:
bar_hour = pd.Timestamp(report.timestamp).hour
if bar_hour not in eur.session_hours:
report.warnings.append(
f"EUR SESSION FILTER: {bar_hour:02}:00 UTC outside allowed hours"
)
report.signal_quality = "F"
except Exception:
pass
if report.signal_quality != "F":
abs_s = abs(report.final_score)
if abs_s < eur.min_entry_score:
report.warnings.append(
f"EUR SCORE FILTER: |{abs_s:.1f}| < {eur.min_entry_score}"
)
report.signal_quality = "F"
elif report.final_confidence < eur.min_entry_confidence:
report.warnings.append(
f"EUR CONF FILTER: {report.final_confidence} < {eur.min_entry_confidence}"
)
report.signal_quality = "F"
if (report.signal_quality != "F"
and eur.blocked_vol_regimes
and report.volatility_regime):
if report.volatility_regime in eur.blocked_vol_regimes:
report.warnings.append(
f"EUR VOL FILTER: {report.volatility_regime} volatility blocked"
)
report.signal_quality = "F"
if (report.signal_quality != "F"
and eur.min_indicator_agreement > 0
and report.primary_analysis):
agree_count = sum(
1 for ind in report.primary_analysis.indicators.values()
if ind.signal.value != 0
and (ind.signal.value > 0) == (report.final_score > 0)
)
if agree_count < eur.min_indicator_agreement:
report.warnings.append(
f"EUR AGREE FILTER: {agree_count} < {eur.min_indicator_agreement}"
)
report.signal_quality = "F"
if (report.signal_quality != "F"
and eur.block_rsi_extended
and report.primary_analysis):
rsi_ind = report.primary_analysis.indicators.get("rsi")
if rsi_ind:
rsi_v = rsi_ind.value
is_buy = report.final_score > 0
if is_buy and rsi_v > 70:
report.warnings.append(
f"EUR RSI FILTER: RSI {rsi_v:.0f} overbought for BUY"
)
report.signal_quality = "F"
elif not is_buy and rsi_v < 30:
report.warnings.append(
f"EUR RSI FILTER: RSI {rsi_v:.0f} oversold for SELL"
)
report.signal_quality = "F"
# ── 6i. M5 noise gate (gold-tuned thresholds — XAU only) ──
if (self.config.primary_timeframe == Timeframe.M5
and report.display_name == "XAU/USD"):
self._apply_m5_filters(report)
# ── 7. Trade setup for actionable signals ──────────────────
if report.trade_setup is None and report.signal_quality in ("A+", "A"):
# ATR generator is XAU-only: it hardcodes gold pip value and
# 2-decimal price rounding.
if (report.trade_setup is None and report.signal_quality in ("A+", "A")
and report.display_name == "XAU/USD"):
atr = (report.primary_analysis.key_levels.get("atr", 0)
if report.primary_analysis else 0)
if atr > 0:
@@ -760,11 +886,23 @@ class SignalAggregator:
# ── 8. Reasons ─────────────────────────────────────────────
self._collect_reasons(report)
# ── 8b. EUR strategy marker ────────────────────────────────
# The backtester tags trades from reasons[0]; mark EUR setups so
# they report under the EUR strategy instead of GENERIC.
if (report.display_name == "EUR/USD" and self.config.eur_strategy.enabled
and report.trade_setup is not None):
marker = (f"EUR STRATEGY: {report.market_regime} regime, "
f"score {report.final_score:+.1f}, quality {report.signal_quality}")
target = (report.bullish_reasons if report.trade_setup.direction == "BUY"
else report.bearish_reasons)
target.insert(0, marker)
# ── 9. M5 pullback entry (XAU_PB) ──────────────────────────
# Second archetype: when no breakout setup fired, look for a
# trend-continuation pullback (retrace to EMA21/KC-mid). Runs
# after reasons so its marker lands at reasons[0] for tagging.
if (self.config.primary_timeframe == Timeframe.M5
and report.display_name == "XAU/USD"
and self.config.xau_strategy.m5_pullback_enabled
and report.trade_setup is None
and report.signal_quality != "F"):
@@ -1000,8 +1138,12 @@ class SignalAggregator:
confidence += 5
# ── Event risk (hard cap, separate from penalty system) ───
# Warning always shown; the confidence cap is config-gated
# (news.event_risk_caps_signals) — see NewsConfig for the audit
# showing the cap costs R on XAU.
if report.event_risk == "danger":
report.warnings.append(f"EVENT RISK: {report.event_description}")
if self.config.news.event_risk_caps_signals:
confidence = min(confidence, 30)
report.action_recommendation = f"WAIT — {report.event_description}"
@@ -1025,6 +1167,7 @@ class SignalAggregator:
if report.event_risk == "caution":
report.warnings.append(f"Upcoming event: {report.event_description}")
if self.config.news.event_risk_caps_signals:
penalties.append(("event_caution", 15))
is_counter_trend = (
@@ -1195,6 +1338,8 @@ class SignalAggregator:
atr = report.primary_analysis.key_levels.get("atr", 0)
if atr == 0:
return None
if pip_value <= 0:
return None
# Fix 2: Minimum ATR threshold for XAU/USD
if report.display_name == "XAU/USD" and atr < self.config.xau_strategy.min_atr_threshold:
@@ -1203,8 +1348,6 @@ class SignalAggregator:
)
return None
min_sl_price = self.config.signals.min_sl_pips * pip_value
if report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
is_buy = True
elif report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL):
@@ -1212,6 +1355,28 @@ class SignalAggregator:
else:
is_buy = report.final_score >= 0
levels = report.primary_analysis.key_levels
_is_eur = report.display_name == "EUR/USD" and self.config.eur_strategy.enabled
if _is_eur:
# EUR geometry reads only EurUsdStrategyConfig — nothing
# gold-calibrated from SignalConfig applies here.
eur = self.config.eur_strategy
min_sl_price = eur.min_sl_pips * pip_value
min_rr = eur.min_rr_ratio
# No TP-tightening interplay for EUR: the RR floor used for
# target selection IS min_rr (tp_tightening_factor is 1.0;
# revisit if EUR ever tunes it).
pre_tight_min_rr = min_rr
mature_move = abs(report.final_score) > eur.mature_move_threshold
quality_scale = eur.tp_quality_scale.get(report.signal_quality, 1.0)
tp_mult = (eur.tp_atr_multiplier if mature_move
else eur.tp_atr_multiplier * 1.5) * quality_scale
tp_swing_cap = (2.0 if mature_move else 2.5) * quality_scale
sl_atr_floor = eur.sl_atr_floor
sl_swing_buffer = eur.sl_atr_multiplier
else:
min_sl_price = self.config.signals.min_sl_pips * pip_value
min_rr = self.config.signals.min_rr_ratio
tp_floor = max(min_rr, self.config.signals.tp_floor_rr)
tf = self.config.signals.tp_tightening_factor
@@ -1222,6 +1387,7 @@ class SignalAggregator:
sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier
if report.display_name == "XAU/USD" else 1.5)
sl_swing_buffer = 1.0
if is_buy:
swing_lows = sorted(
@@ -1229,9 +1395,9 @@ class SignalAggregator:
reverse=True,
)
if swing_lows:
sl_price = swing_lows[0] - atr * 1.0
sl_price = swing_lows[0] - atr * sl_swing_buffer
else:
sl_price = price - atr * 2.0
sl_price = price - atr * (1.0 + sl_swing_buffer)
min_sl = max(atr * sl_atr_floor, min_sl_price)
if price - sl_price < min_sl:
sl_price = price - min_sl
@@ -1259,9 +1425,9 @@ class SignalAggregator:
v for k, v in levels.items() if k.startswith("swing_high_") and v > price
)
if swing_highs:
sl_price = swing_highs[0] + atr * 1.0
sl_price = swing_highs[0] + atr * sl_swing_buffer
else:
sl_price = price + atr * 2.0
sl_price = price + atr * (1.0 + sl_swing_buffer)
min_sl = max(atr * sl_atr_floor, min_sl_price)
if sl_price - price < min_sl:
sl_price = price + min_sl
@@ -1305,6 +1471,15 @@ class SignalAggregator:
# Spread gate: a target too close to entry hands most of the win
# to transaction costs (validated on M5: sub-$10 targets lose).
if _is_eur:
min_tp = eur.round_trip_cost * eur.min_tp_cost_multiple
if tp1_distance < min_tp:
report.warnings.append(
f"SPREAD GATE: target {tp1_distance / pip_value:.1f} pips "
f"< {min_tp / pip_value:.1f} pip minimum — costs would eat the edge"
)
return None
else:
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
if tp1_distance < min_tp:
report.warnings.append(
+215 -66
View File
@@ -18,14 +18,29 @@ WebSocket: Streams live ticks from TwelveData for XAU/USD.
immediate M15 scan and sends a Level Break Alert.
5-minute cooldown prevents spam.
Owner lock: All commands and buttons only respond to TELEGRAM_CHAT_ID
(the owner). Everyone else gets a polite refusal — the bot
is a private control panel, not the product.
Channel: If TELEGRAM_CHANNEL_ID is set (-100xxxxxxxxxx), auto-scan
signal alerts and level-breach alerts are also posted to
that channel. Pair it with a Telegram Stars subscription
invite link to sell access — Telegram manages join/renewal/
kick; the bot just posts there (needs channel-admin +
post-messages). Manual scans, status and cache replies stay
owner-DM only. The channel feed runs even when the owner
toggles their own DM auto-scan off.
Usage:
python telegram_bot.py
TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py
TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> \
TELEGRAM_CHANNEL_ID=<-100…> python telegram_bot.py
"""
import asyncio
import functools
import json
import logging
import logging.handlers
import os
import signal
import sys
@@ -51,17 +66,26 @@ from config import (
XAUUSD, PAIR_INFO,
)
from data_provider import ForexDataProvider
from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport
# ── Force UTF-8 on Windows ────────────────────────────────────────────
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
level=logging.INFO,
datefmt="%H:%M:%S",
_LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
os.makedirs(_LOG_DIR, exist_ok=True)
_log_formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%H:%M:%S"
)
_console_handler = logging.StreamHandler()
_console_handler.setFormatter(_log_formatter)
_file_handler = logging.handlers.TimedRotatingFileHandler(
os.path.join(_LOG_DIR, "telegram.log"),
when="midnight", backupCount=30, encoding="utf-8",
)
_file_handler.setFormatter(_log_formatter)
logging.basicConfig(level=logging.INFO, handlers=[_console_handler, _file_handler])
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logger = logging.getLogger("telegram_bot")
@@ -69,13 +93,28 @@ logger = logging.getLogger("telegram_bot")
TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9-qfRTfNE")
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
# Owner lock: only this user/chat may drive the bot (commands + buttons).
# The bot is the product's signal source — without the lock, anyone who
# finds it could /autoscan and receive paid signals for free in DM.
OWNER_ID: Optional[int] = int(CHAT_ID) if CHAT_ID else None
# Subscriber channel (paid, Stars-subscription invite link managed by
# Telegram). When set, auto-scan signal alerts and level-breach alerts
# are also posted there. Manual scans, status, cache and boot messages
# stay owner-DM only. Set TELEGRAM_CHANNEL_ID="" to disable the feed.
_CHANNEL_ID_RAW = os.environ.get("TELEGRAM_CHANNEL_ID", "-1003942265371")
CHANNEL_ID: Optional[int] = int(_CHANNEL_ID_RAW) if _CHANNEL_ID_RAW else None
# ── Pairs & buttons ───────────────────────────────────────────────────
# The scanned pair list is config-driven: PlatformConfig.scan_pairs is
# the single switch for single- vs multi-pair operation. Buttons, WS
# subscriptions, and scan loops all derive from it.
SCAN_PAIRS = [XAUUSD]
SCAN_PAIRS = list(PlatformConfig().scan_pairs)
_WS_TO_PAIR = {
"XAU/USD": XAUUSD,
}
_PAIRS_LABEL = " + ".join(PAIR_INFO[p]["display"] for p in SCAN_PAIRS)
_WS_TO_PAIR = {PAIR_INFO[p]["display"]: p for p in SCAN_PAIRS}
_WS_SYMBOLS = list(_WS_TO_PAIR.keys())
_LEVEL_KEYS = (
@@ -85,14 +124,36 @@ _LEVEL_KEYS = (
"kc_upper", "kc_lower",
)
SCAN_BUTTONS = [
{"cb": "scan_xauusd_m5", "symbol": XAUUSD, "tf": Timeframe.M5, "label": "5 Min"},
{"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "15 Min"},
{"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "1 Hour"},
{"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "4 Hour"},
{"cb": "scan_xauusd_all", "symbol": XAUUSD, "tf": None, "label": "Scan All TFs"},
_TF_BUTTON_LABELS = [
(Timeframe.M5, "5 Min"),
(Timeframe.M15, "15 Min"),
(Timeframe.H1, "1 Hour"),
(Timeframe.H4, "4 Hour"),
]
def _build_scan_buttons() -> List[dict]:
"""One row of TF buttons + a scan-all button per scanned pair."""
multi = len(SCAN_PAIRS) > 1
buttons = []
for symbol in SCAN_PAIRS:
slug = symbol.replace("/", "").lower()
base = PAIR_INFO[symbol]["display"].split("/")[0] # XAU, EUR
prefix = f"{base} " if multi else ""
for tf, label in _TF_BUTTON_LABELS:
buttons.append({
"cb": f"scan_{slug}_{tf.value}", "symbol": symbol,
"tf": tf, "label": f"{prefix}{label}",
})
buttons.append({
"cb": f"scan_{slug}_all", "symbol": symbol, "tf": None,
"label": f"Scan All TFs — {base}" if multi else "Scan All TFs",
})
return buttons
SCAN_BUTTONS = _build_scan_buttons()
SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS}
TF_CONFIRMATIONS = {
@@ -190,6 +251,13 @@ def _live_price_line(symbol: str) -> Optional[str]:
return f"📡 Live: *{_fmt_price(price)}* ({age:.0f}s tick)"
_DISCLAIMER = (
"⚠️ _Not financial advice. For educational and research purposes only. "
"Trading forex/CFDs carries a high risk of loss — do your own analysis "
"and never risk money you cannot afford to lose._"
)
def format_signal_message(report: SignalReport) -> str:
sig = report.final_signal
emoji = _SIGNAL_EMOJI.get(sig, "⬜")
@@ -258,7 +326,7 @@ def format_signal_message(report: SignalReport) -> str:
# TP1 is advisory, the trail is the exit). Pullback setups run
# tighter stops and arm later.
sig_cfg = PlatformConfig().signals
is_pb = getattr(ts, "strategy", "GENERIC") == "XAU_PB"
is_pb = getattr(ts, "strategy", "GENERIC").endswith("_PB")
arm_r = sig_cfg.pb_trail_arm_r if is_pb else sig_cfg.trail_arm_r
dist_r = sig_cfg.pb_trail_dist_r if is_pb else sig_cfg.trail_dist_r
if arm_r > 0:
@@ -275,8 +343,7 @@ def format_signal_message(report: SignalReport) -> str:
f"behind best price — TP1 is a guide, let it run"
)
lines += ["", "━━━━━━━━━━━━━━━━━━━━",
"_Educational / research use only._"]
lines += ["", "━━━━━━━━━━━━━━━━━━━━", _DISCLAIMER]
return "\n".join(lines)
@@ -307,34 +374,38 @@ def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
else:
lines.append("No actionable signals this cycle.")
lines += ["", _DISCLAIMER]
return "\n".join(lines)
# ── Keyboard helper ───────────────────────────────────────────────────
def scan_keyboard() -> InlineKeyboardMarkup:
tf_buttons = [b for b in SCAN_BUTTONS if b["tf"] is not None]
all_button = next((b for b in SCAN_BUTTONS if b["tf"] is None), None)
rows = [[
InlineKeyboardButton(b["label"], callback_data=b["cb"])
for b in tf_buttons
]]
def _scan_button_rows() -> List[List[InlineKeyboardButton]]:
"""Per pair: one row of TF buttons + one scan-all row."""
rows = []
for symbol in SCAN_PAIRS:
tf_buttons = [b for b in SCAN_BUTTONS
if b["symbol"] == symbol and b["tf"] is not None]
rows.append([InlineKeyboardButton(b["label"], callback_data=b["cb"])
for b in tf_buttons])
all_button = next((b for b in SCAN_BUTTONS
if b["symbol"] == symbol and b["tf"] is None), None)
if all_button:
rows.append([InlineKeyboardButton(all_button["label"], callback_data=all_button["cb"])])
return InlineKeyboardMarkup(rows)
rows.append([InlineKeyboardButton(all_button["label"],
callback_data=all_button["cb"])])
return rows
def scan_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(_scan_button_rows())
def main_menu_keyboard() -> InlineKeyboardMarkup:
"""Full interactive menu: scans + settings + status in one keyboard."""
tf_buttons = [b for b in SCAN_BUTTONS if b["tf"] is not None]
all_button = next((b for b in SCAN_BUTTONS if b["tf"] is None), None)
autoscan_label = "🔔 Auto-scan: ON" if _autoscan_chats else "🔕 Auto-scan: OFF"
quality_label = f"🎚 Alerts: {_alert_min_quality}+" if _alert_min_quality != "A" else "🎚 Alerts: A only"
rows = [
[InlineKeyboardButton(b["label"], callback_data=b["cb"]) for b in tf_buttons],
]
if all_button:
rows.append([InlineKeyboardButton(all_button["label"], callback_data=all_button["cb"])])
rows = _scan_button_rows()
rows.append([
InlineKeyboardButton(autoscan_label, callback_data="menu_autoscan"),
InlineKeyboardButton(quality_label, callback_data="menu_quality"),
@@ -374,6 +445,13 @@ def _run_signal_sync(
confirmation_tfs = TF_CONFIRMATIONS.get(
timeframe, [Timeframe.M15, Timeframe.H4]
)
# Per-pair tuned configs (regime weights, gates, sessions) come from
# the pair_signals factories; a plain PlatformConfig would silently
# run every pair with XAU defaults.
factory = PAIR_PRODUCERS.get(symbol)
if factory is not None:
config = factory(timeframe, confirmation_tfs)
else:
config = PlatformConfig()
config.primary_timeframe = timeframe
config.confirmation_timeframes = confirmation_tfs
@@ -433,6 +511,20 @@ _autoscan_chats: Set[int] = set()
_autoscan_tasks: list[asyncio.Task] = []
def _feed_active() -> bool:
"""Scan loops keep running while anyone is listening: owner DM
auto-scan chats or the subscriber channel."""
return bool(_autoscan_chats) or CHANNEL_ID is not None
def _signal_targets() -> List[int]:
"""Where signal/breach alerts go: owner auto-scan chats + channel."""
targets: List[int] = list(_autoscan_chats)
if CHANNEL_ID is not None:
targets.append(CHANNEL_ID)
return targets
def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
"""Seconds until the next candle close + buffer."""
now = datetime.now(timezone.utc)
@@ -471,7 +563,7 @@ async def _scan_timeframe(
if _passes_quality(r.signal_quality)
and _should_alert(f"{r.symbol}_{tf.value}", r)
]
for chat_id in list(_autoscan_chats):
for chat_id in _signal_targets():
for report in actionable:
detail = format_signal_message(report)
try:
@@ -566,7 +658,7 @@ async def _handle_breach(bot, ws_symbol: str, pair_symbol: str, price: float, le
f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n"
)
detail = header + format_signal_message(report)
for chat_id in list(_autoscan_chats):
for chat_id in _signal_targets():
try:
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
except Exception as exc:
@@ -583,7 +675,7 @@ async def _ws_heartbeat(ws) -> None:
async def _ws_monitor(bot) -> None:
backoff = 1
while _autoscan_chats:
while _feed_active():
hb_task: Optional[asyncio.Task] = None
try:
async with websockets.connect(_WS_URL) as ws:
@@ -597,7 +689,7 @@ async def _ws_monitor(bot) -> None:
hb_task = asyncio.create_task(_ws_heartbeat(ws))
async for raw in ws:
if not _autoscan_chats:
if not _feed_active():
break
try:
@@ -655,11 +747,11 @@ async def _ws_monitor(bot) -> None:
async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None:
label = tf.value
try:
while _autoscan_chats:
while _feed_active():
wait = _seconds_until_boundary(interval_minutes, buffer=10)
logger.info(f"{label} auto-scan: next in {wait:.0f}s")
await asyncio.sleep(wait)
if not _autoscan_chats:
if not _feed_active():
break
logger.info(f"{label} auto-scan: firing")
await _scan_timeframe(
@@ -714,10 +806,39 @@ def _stop_scheduler() -> None:
# ── Handlers ──────────────────────────────────────────────────────────
def _owner_only(handler):
"""Reject commands/buttons from anyone but the owner.
Signals are sold via the subscriber channel; the bot itself is the
owner's control panel. Without this, any user who finds the bot
could /autoscan and receive the paid feed for free in their DM.
"""
@functools.wraps(handler)
async def wrapped(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
user = update.effective_user
chat = update.effective_chat
uid = user.id if user else None
if OWNER_ID is not None and (uid == OWNER_ID or (chat and chat.id == OWNER_ID)):
return await handler(update, context)
logger.warning(
f"Unauthorized access blocked: user={uid} "
f"chat={chat.id if chat else None} handler={handler.__name__}"
)
if update.callback_query:
await update.callback_query.answer("🔒 Not authorized.")
elif update.message:
await update.message.reply_text(
"🔒 This is a private control bot. Signals are published "
"in the subscriber channel."
)
return wrapped
@_owner_only
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
scanning = "running" if _autoscan_chats else "off"
text = (
"👋 *ForexBot2 — XAU/USD Signal Intelligence*\n\n"
f"👋 *ForexBot2 — {_PAIRS_LABEL} Signal Intelligence*\n\n"
"Pick a timeframe to scan gold.\n"
"Signals include a full trade setup with entry, SL and TP.\n\n"
f"Auto-scan: *{scanning}* — /autoscan to toggle\n"
@@ -735,7 +856,7 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
_HELP_TEXT = (
"📖 *ForexBot2 Commands*\n\n"
"/start — Welcome & interactive menu\n"
"/scan — Quick XAU/USD scan menu\n"
"/scan — Quick scan menu\n"
"/autoscan — Toggle auto-scan on/off\n"
"/status — Bot status, WS connection, live prices\n"
"/cache — Show data cache age per pair/TF\n"
@@ -744,10 +865,14 @@ _HELP_TEXT = (
"*Auto-scan* fires at candle close: M5/M15/H1/H4.\n"
"*Alerts* button sets the minimum quality grade that gets sent.\n"
"*WebSocket* streams live prices; key-level breaches trigger an "
"instant M15 rescan and alert (5-min cooldown)."
"instant M15 rescan and alert (5-min cooldown).\n"
"*Channel feed*: signal + breach alerts mirror to the subscriber "
"channel when TELEGRAM_CHANNEL_ID is set; /autoscan only toggles "
"your DM copy."
)
@_owner_only
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(_HELP_TEXT, parse_mode=ParseMode.MARKDOWN)
@@ -762,6 +887,9 @@ def _status_text() -> str:
else:
lines.append("⏹ Auto-scan: *stopped*")
if CHANNEL_ID is not None:
lines.append(f"📢 Channel feed: *on* (`{CHANNEL_ID}`)")
if _ws_task and not _ws_task.done():
lines.append("📡 WebSocket: *connected*")
else:
@@ -804,10 +932,12 @@ def _status_text() -> str:
return "\n".join(lines)
@_owner_only
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(_status_text(), parse_mode=ParseMode.MARKDOWN)
@_owner_only
async def cmd_cache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
dp = _get_shared_dp()
if not dp._cache_timestamps:
@@ -837,6 +967,7 @@ async def cmd_cache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.MARKDOWN)
@_owner_only
async def cmd_clearcache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
dp = _get_shared_dp()
count = dp.clear_cache()
@@ -846,17 +977,19 @@ async def cmd_clearcache(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
)
@_owner_only
async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = update.effective_chat.id
if chat_id in _autoscan_chats:
_autoscan_chats.discard(chat_id)
if not _autoscan_chats:
if not _feed_active():
_stop_scheduler()
await update.message.reply_text(
"⏹ Auto-scan stopped.",
parse_mode=ParseMode.MARKDOWN,
)
msg = "⏹ Auto-scan stopped."
else:
msg = ("⏹ Auto-scan stopped for this chat.\n"
"📢 Subscriber channel feed continues.")
await update.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN)
return
_autoscan_chats.add(chat_id)
@@ -868,7 +1001,7 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
next_h4 = _seconds_until_boundary(240, buffer=10)
await update.message.reply_text(
"✅ *Auto-scan enabled*\n\n"
f"Scanning XAU/USD on M5/M15/H1/H4 at candle close.\n"
f"Scanning {_PAIRS_LABEL} on M5/M15/H1/H4 at candle close.\n"
f"📡 Live price monitoring active.\n"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
@@ -877,14 +1010,16 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
)
@_owner_only
async def cmd_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(
"🔍 *XAU/USD — Pick a timeframe*",
f"🔍 *{_PAIRS_LABEL} — Pick a timeframe*",
parse_mode=ParseMode.MARKDOWN,
reply_markup=main_menu_keyboard(),
)
@_owner_only
async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
@@ -894,10 +1029,11 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
return
if btn["tf"] is None:
await _cb_scan_all(query)
await _cb_scan_all(query, btn["symbol"])
return
await _safe_edit(query, f"⏳ Scanning XAU/USD {btn['label']}…")
display = PAIR_INFO[btn["symbol"]]["display"]
await _safe_edit(query, f"⏳ Scanning {display} {btn['label']}…")
try:
report = await run_signal(btn["symbol"], btn["tf"])
@@ -909,6 +1045,7 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await _safe_edit(query, text, reply_markup=main_menu_keyboard())
@_owner_only
async def cb_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Interactive menu actions: autoscan toggle, quality cycle, status, help."""
global _alert_min_quality
@@ -919,9 +1056,11 @@ async def cb_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
chat_id = query.message.chat.id
if chat_id in _autoscan_chats:
_autoscan_chats.discard(chat_id)
if not _autoscan_chats:
if not _feed_active():
_stop_scheduler()
await query.answer("Auto-scan stopped")
else:
await query.answer("DM alerts off — channel feed continues")
else:
_autoscan_chats.add(chat_id)
_start_scheduler(context.bot)
@@ -949,11 +1088,12 @@ async def cb_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await query.message.reply_text(_HELP_TEXT, parse_mode=ParseMode.MARKDOWN)
async def _cb_scan_all(query) -> None:
await _safe_edit(query, "⏳ Scanning XAU/USD across M5 / M15 / H1 / H4…")
async def _cb_scan_all(query, symbol: str = XAUUSD) -> None:
display = PAIR_INFO[symbol]["display"]
await _safe_edit(query, f"⏳ Scanning {display} across M5 / M15 / H1 / H4…")
tfs = [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
tasks = [run_signal(XAUUSD, tf) for tf in tfs]
tasks = [run_signal(symbol, tf) for tf in tfs]
results = await asyncio.gather(*tasks, return_exceptions=True)
reports = []
@@ -968,7 +1108,7 @@ async def _cb_scan_all(query) -> None:
_update_level_cache(reports)
lines = [
"📊 *XAU/USD — All Timeframes*",
f"📊 *{display} — All Timeframes*",
f"🕐 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
"",
]
@@ -998,6 +1138,7 @@ async def _cb_scan_all(query) -> None:
lines.append(err)
lines.append("_Use buttons below for detailed single-TF view._")
lines += ["", _DISCLAIMER]
await _safe_edit(query, "\n".join(lines), reply_markup=main_menu_keyboard())
@@ -1008,7 +1149,7 @@ async def _post_init(app: Application) -> None:
"""Register command menu and start scheduler on boot."""
await app.bot.set_my_commands([
("start", "Welcome & scan buttons"),
("scan", "Quick XAU/USD scan buttons"),
("scan", "Quick scan buttons"),
("autoscan", "Toggle auto-scan on/off"),
("status", "Bot status, WS connection, live prices"),
("cache", "Show data cache age per pair/TF"),
@@ -1016,13 +1157,16 @@ async def _post_init(app: Application) -> None:
("help", "All commands"),
])
if not CHAT_ID:
logger.info("TELEGRAM_CHAT_ID not set — auto-scan disabled on boot. "
"Use /autoscan in chat to enable.")
if CHAT_ID:
_autoscan_chats.add(int(CHAT_ID))
if not _feed_active():
logger.info("Neither TELEGRAM_CHAT_ID nor TELEGRAM_CHANNEL_ID set — "
"auto-scan disabled on boot. Use /autoscan in chat to enable.")
return
chat_id = int(CHAT_ID)
_autoscan_chats.add(chat_id)
if CHANNEL_ID is not None:
logger.info(f"Subscriber channel feed enabled: {CHANNEL_ID}")
logger.info("Running initial scan to populate level cache and signal stats...")
try:
@@ -1048,16 +1192,21 @@ async def _post_init(app: Application) -> None:
next_h1 = _seconds_until_boundary(60, buffer=10)
next_h4 = _seconds_until_boundary(240, buffer=10)
logger.info(
f"Auto-scan started for chat {chat_id} — "
f"Auto-scan started (chats={sorted(_autoscan_chats)}, channel={CHANNEL_ID}) — "
f"M5 {next_m5:.0f}s, M15 {next_m15:.0f}s, H1 {next_h1:.0f}s, H4 {next_h4:.0f}s"
)
# Boot message goes to the owner DM only — the subscriber channel
# receives nothing but signal and breach alerts.
if OWNER_ID is not None:
channel_line = "📢 Subscriber channel feed active.\n" if CHANNEL_ID else ""
try:
await app.bot.send_message(
chat_id,
OWNER_ID,
f"🤖 *ForexBot2 started*\n\n"
f"Auto-scanning XAU/USD on M5/M15/H1/H4.\n"
f"Auto-scanning {_PAIRS_LABEL} on M5/M15/H1/H4.\n"
f"📡 Live price monitoring active.\n"
f"{channel_line}"
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
f"/autoscan to stop",
+536 -2
View File
@@ -1203,6 +1203,43 @@ def _():
assert report.action_recommendation != ""
@test("Event-risk signal gating obeys news.event_risk_caps_signals")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
def recompute(risk, caps):
config.news.event_risk_caps_signals = caps
report.event_risk = risk
report.event_description = "USD Test Event in 10min"
report.warnings = []
agg._compute_final_signal(report)
return report.final_confidence
baseline = recompute("clear", True)
# default XAU behavior (caps off): danger warns but does not gut
conf_off = recompute("danger", False)
assert conf_off == baseline, "flag off must not change confidence"
assert any("EVENT RISK" in w for w in report.warnings), "warning must persist"
# caps on: danger hard-caps confidence at 30
conf_on = recompute("danger", True)
assert conf_on <= 30, f"danger with caps on must cap conf at 30, got {conf_on}"
# caution with caps off is also warning-only
conf_caut_off = recompute("caution", False)
assert conf_caut_off == baseline
assert any("Upcoming event" in w for w in report.warnings)
# PlatformConfig default is off (XAU-tuned); EUR factory re-enables
assert PlatformConfig().news.event_risk_caps_signals is False
from pair_signals.eurusd import make_config as eur_make
assert eur_make(Timeframe.M15).news.event_risk_caps_signals is True
@test("SignalAggregator._generate_trade_setup BUY direction")
def _():
config = PlatformConfig()
@@ -1415,6 +1452,7 @@ def _():
@test("build_config sets correct timeframe and confirmations")
def _():
class FakeArgs:
pair = "XAUUSD"
timeframe = "H4"
config = build_config(FakeArgs())
assert config.primary_timeframe == Timeframe.H4
@@ -1426,11 +1464,23 @@ def _():
def _():
for tf_str in ["M5", "M15", "M30", "H1", "H4", "D1"]:
class FakeArgs:
pair = "XAUUSD"
timeframe = tf_str
config = build_config(FakeArgs())
assert config.primary_timeframe is not None
@test("build_config resolves EURUSD pair to EUR factory config")
def _():
class FakeArgs:
pair = "EURUSD"
timeframe = "M15"
config = build_config(FakeArgs())
assert config.symbol == "EUR/USD"
assert config.pip_value == 0.0001
assert config.primary_timeframe == Timeframe.M15
# ══════════════════════════════════════════════════════════════════════
# MODULE 7: telegram_bot.py
# ══════════════════════════════════════════════════════════════════════
@@ -1609,10 +1659,13 @@ def _():
@test("SCAN_BUTTONS and SCAN_LOOKUP consistency")
def _():
assert len(SCAN_BUTTONS) == 5 # 4 TFs + "Scan All"
from telegram_bot import SCAN_PAIRS
# 4 TF buttons + one "Scan All" per scanned pair
assert len(SCAN_BUTTONS) == 5 * len(SCAN_PAIRS)
for btn in SCAN_BUTTONS:
assert btn["cb"] in SCAN_LOOKUP
assert "symbol" in btn
assert btn["symbol"] in SCAN_PAIRS
assert "tf" in btn
assert "label" in btn
@@ -1642,8 +1695,11 @@ def _():
_level_cache_ts.clear()
@test("_WS_TO_PAIR mapping is correct")
@test("_WS_TO_PAIR mapping covers every scanned pair")
def _():
from telegram_bot import SCAN_PAIRS
for p in SCAN_PAIRS:
assert _WS_TO_PAIR[PAIR_INFO[p]["display"]] == p
assert _WS_TO_PAIR["XAU/USD"] == XAUUSD
@@ -1761,6 +1817,484 @@ def _():
assert r1.market_regime == r2.market_regime
print("\n═══ Module 9: pair_signals registry ═══")
@test("pair_signals registry includes XAU/USD and EUR/USD")
def _():
import pair_signals
pairs = pair_signals.get_supported_pairs()
assert XAUUSD in pairs, f"XAU missing from registry: {pairs}"
assert "EUR/USD" in pairs, f"EUR missing from registry: {pairs}"
@test("xauusd.make_config equals inline PlatformConfig construction")
def _():
import dataclasses
from pair_signals import PAIR_PRODUCERS
for tf, ctfs in [
(Timeframe.M5, [Timeframe.M15, Timeframe.H1]),
(Timeframe.M15, [Timeframe.M5, Timeframe.H1]),
(Timeframe.H1, [Timeframe.M15, Timeframe.H4]),
]:
factory_cfg = PAIR_PRODUCERS[XAUUSD](tf, ctfs)
inline_cfg = PlatformConfig(
symbol=XAUUSD,
display_name="XAU/USD",
pip_value=0.10,
primary_timeframe=tf,
confirmation_timeframes=ctfs,
)
if tf == Timeframe.M15:
# Documented per-TF override (see xauusd.py docstring):
# 1R minimum targets validated for M15 only.
inline_cfg.signals.tp_floor_rr = 1.0
assert dataclasses.asdict(factory_cfg) == dataclasses.asdict(inline_cfg), \
f"XAU factory config differs from inline construction for {tf}"
@test("eurusd.make_config produces EUR config with XAU strategy disabled")
def _():
from pair_signals import PAIR_PRODUCERS
cfg = PAIR_PRODUCERS["EUR/USD"](Timeframe.M5, None)
assert cfg.symbol == "EUR/USD"
assert cfg.pip_value == 0.0001
assert cfg.xau_strategy.enabled is False
assert cfg.eur_strategy.enabled is True
assert cfg.technical.regime_weights_override, "EUR regime weights not applied"
assert cfg.confirmation_timeframes == [Timeframe.M15, Timeframe.H1]
@test("produce_signal raises for unregistered pair")
def _():
from pair_signals import produce_signal
try:
produce_signal("GBP/JPY", Timeframe.H1, None)
assert False, "Expected ValueError for unregistered pair"
except ValueError as e:
assert "GBP/JPY" in str(e)
print("\n═══ Module 9b: Engine Bug Fix Regressions ═══")
# ── 9a. Swing point NaN fix ──────────────────────────────────────────
@test("Swing points: all-NaN neighbors produces no false positives")
def _():
series = pd.Series([np.nan, np.nan, np.nan, 5.0, np.nan, np.nan, np.nan])
lows = TechnicalEngine._find_swing_points(series, mode="low", window=3)
highs = TechnicalEngine._find_swing_points(series, mode="high", window=3)
assert lows == [], f"Expected no lows with all-NaN neighbors, got {lows}"
assert highs == [], f"Expected no highs with all-NaN neighbors, got {highs}"
@test("Swing points: partial NaN neighbors still detect valid swings")
def _():
series = pd.Series([10.0, 8.0, np.nan, 4.0, np.nan, 8.0, 10.0])
lows = TechnicalEngine._find_swing_points(series, mode="low", window=2)
low_vals = [v for _, v in lows]
assert 4.0 in low_vals, f"Expected 4.0 as swing low, got {low_vals}"
@test("Swing points: empty series returns empty list")
def _():
series = pd.Series([], dtype=float)
assert TechnicalEngine._find_swing_points(series, mode="low") == []
assert TechnicalEngine._find_swing_points(series, mode="high") == []
@test("Swing points: monotonic increasing has no interior lows")
def _():
series = pd.Series([float(i) for i in range(20)])
lows = TechnicalEngine._find_swing_points(series, mode="low", window=2)
assert len(lows) == 0, f"Monotonic increase should have no interior lows, got {lows}"
@test("Swing points: V-shape detects low at bottom")
def _():
series = pd.Series([10.0, 8.0, 6.0, 4.0, 6.0, 8.0, 10.0])
lows = TechnicalEngine._find_swing_points(series, mode="low", window=2)
low_vals = [v for _, v in lows]
assert 4.0 in low_vals, f"V-shape should detect 4.0 as low, got {low_vals}"
@test("Swing points: inverted V-shape detects high at top")
def _():
series = pd.Series([4.0, 6.0, 8.0, 10.0, 8.0, 6.0, 4.0])
highs = TechnicalEngine._find_swing_points(series, mode="high", window=2)
high_vals = [v for _, v in highs]
assert 10.0 in high_vals, f"Inverted V should detect 10.0 as high, got {high_vals}"
# ── 9b. ROC div-by-zero fix ──────────────────────────────────────────
@test("ROC: zero close at lookback position does not produce inf")
def _():
df = make_ohlcv(n=100, seed=42)
df.iloc[-11, df.columns.get_loc("Close")] = 0.0
engine = TechnicalEngine(PlatformConfig().technical)
result = engine.analyze(df, "EUR/USD", Timeframe.H1)
if "roc" in result.indicators:
assert np.isfinite(result.indicators["roc"].value), "ROC should not be inf"
# ── 9c. ADX epsilon fix ──────────────────────────────────────────────
@test("ADX: flat data produces finite ADX value")
def _():
n = 100
dates = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=n, freq="h")
df = pd.DataFrame({
"Open": [100.0]*n, "High": [100.01]*n, "Low": [99.99]*n,
"Close": [100.0]*n, "Volume": [1000.0]*n,
}, index=dates)
engine = TechnicalEngine(PlatformConfig().technical)
result = engine.analyze(df, "EUR/USD", Timeframe.H1)
if "adx" in result.indicators:
assert np.isfinite(result.indicators["adx"].value), "ADX should be finite on flat data"
print("\n═══ Module 10: EUR/USD Support ═══")
from config import EURUSD, EurUsdStrategyConfig
# ── 10a. Config & registry ──────────────────────────────────────────
@test("PAIR_INFO includes EUR/USD with correct pip_value")
def _():
assert EURUSD in PAIR_INFO
assert PAIR_INFO[EURUSD]["pip_value"] == 0.0001
assert PAIR_INFO[EURUSD]["is_jpy_pair"] is False
assert PAIR_INFO[EURUSD]["display"] == "EUR/USD"
assert PAIR_INFO[EURUSD]["price_precision"] == 5
assert PAIR_INFO[EURUSD]["default_spread"] == 0.0001
@test("EurUsdStrategyConfig defaults are sensible")
def _():
cfg = EurUsdStrategyConfig()
assert cfg.enabled is True
assert 0 in cfg.blocked_hours_utc
assert 5 not in cfg.allowed_weekdays # No Saturday
assert 6 not in cfg.allowed_weekdays # No Sunday
assert cfg.min_atr_threshold == 0.0003
assert cfg.min_rr_ratio == 1.0
assert cfg.mature_move_threshold == 35.0
assert cfg.blocked_regimes == ["ranging", "transitioning"]
assert "A" in cfg.min_quality_grades
# EUR-local cost knobs must be pip-scale, not gold-dollar-scale
assert cfg.round_trip_cost == 0.0001
assert cfg.round_trip_cost * cfg.min_tp_cost_multiple < 0.01
assert cfg.min_sl_pips == 8.0
@test("PlatformConfig has eur_strategy field")
def _():
config = PlatformConfig()
assert hasattr(config, "eur_strategy")
assert isinstance(config.eur_strategy, EurUsdStrategyConfig)
@test("EUR/USD config factory produces correct config")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
assert config.symbol == EURUSD
assert config.display_name == "EUR/USD"
assert config.pip_value == 0.0001
assert config.xau_strategy.enabled is False
assert config.technical.regime_weights_override
assert "trending" in config.technical.regime_weights_override
assert "ranging" in config.technical.regime_weights_override
@test("XAU/USD config factory keeps XAU strategy enabled")
def _():
from pair_signals.xauusd import make_config
config = make_config(Timeframe.H1)
assert config.symbol == XAUUSD
assert config.display_name == "XAU/USD"
assert config.pip_value == 0.10
assert config.xau_strategy.enabled is True
@test("NewsEngine._get_affected_pairs includes EUR/USD for EUR and USD events")
def _():
from news_engine import NewsEngine
assert "EUR/USD" in NewsEngine._get_affected_pairs("EUR")
assert "EUR/USD" in NewsEngine._get_affected_pairs("USD")
assert "XAU/USD" in NewsEngine._get_affected_pairs("USD")
assert "EUR/USD" not in NewsEngine._get_affected_pairs("GBP")
# ── 10b. EUR filter pipeline ────────────────────────────────────────
@test("EUR/USD signal analysis produces valid report (mocked data)")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.backtest.session_filter = False
agg = SignalAggregator(config)
df = make_ohlcv(n=300, start_price=1.0800, trend=0.00005)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
assert report.display_name == "EUR/USD"
assert report.signal_quality in ("A+", "A", "B", "C", "D", "F")
assert not any("SELL FILTER" in w for w in report.warnings)
@test("EUR filter: blocked regime sets quality F")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.eur_strategy.blocked_regimes = ["ranging"]
agg = SignalAggregator(config)
df = make_ohlcv(n=300, seed=42)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
if report.market_regime == "ranging":
assert report.signal_quality == "F", \
f"Ranging should be blocked, got {report.signal_quality}"
@test("EUR filter: quality not in min_quality_grades sets F")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.eur_strategy.min_quality_grades = ["A+"]
agg = SignalAggregator(config)
df = make_ohlcv(n=300, seed=42, trend=0.001)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
if report.signal_quality not in ("A+", "F"):
assert False, f"Non-A+ grade should be blocked, got {report.signal_quality}"
@test("EUR filter: score below min sets F")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.eur_strategy.min_entry_score = 99.0
agg = SignalAggregator(config)
df = make_ohlcv(n=300, seed=42, trend=0.001)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
assert report.signal_quality == "F", \
f"Score 99 threshold should block all, got {report.signal_quality}"
@test("EUR filter: confidence below min sets F")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.eur_strategy.min_entry_confidence = 99.0
agg = SignalAggregator(config)
df = make_ohlcv(n=300, seed=42, trend=0.001)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
assert report.signal_quality == "F", \
f"Confidence 99 threshold should block all, got {report.signal_quality}"
@test("EUR filter: RSI extended buy (RSI>70) blocked")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.eur_strategy.block_rsi_extended = True
config.eur_strategy.min_entry_score = 0.0
config.eur_strategy.min_entry_confidence = 0.0
config.eur_strategy.min_quality_grades = ["A+", "A", "B", "C", "D"]
agg = SignalAggregator(config)
df = make_ohlcv(n=300, trend=0.005, seed=42)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
if report.primary_analysis and "rsi" in report.primary_analysis.indicators:
rsi_v = report.primary_analysis.indicators["rsi"].value
if report.final_score > 0 and rsi_v > 70:
assert report.signal_quality == "F", \
f"Buy with RSI {rsi_v:.0f} should be blocked"
@test("EUR weekend filter returns F report on Saturday")
def _():
import signal_aggregator as sa_mod
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
agg = SignalAggregator(config)
saturday = datetime(2026, 7, 11, 12, 0, tzinfo=timezone.utc)
assert saturday.weekday() == 5
with patch.object(sa_mod, "datetime", wraps=datetime) as mock_dt:
mock_dt.now.return_value = saturday
report = agg.analyze_pair(symbol=EURUSD)
assert report.signal_quality == "F"
assert any("WEEKEND" in w for w in report.warnings)
assert report.trade_setup is None
# ── 10c. EUR trade geometry ─────────────────────────────────────────
def _mk_eur_report(score=45.0, quality="A", atr=0.0006, price=1.0800):
from signal_aggregator import SignalReport
r = SignalReport(symbol=EURUSD, display_name="EUR/USD", timestamp="2026-07-08 14:00")
r.final_signal = SignalStrength.BUY
r.final_score = score
r.final_confidence = 70.0
r.signal_quality = quality
class _Analysis:
key_levels = {
"atr": atr,
"swing_low_1": price - 0.0015,
"swing_low_2": price - 0.0030,
"swing_high_1": price + 0.0030,
"swing_high_2": price + 0.0050,
}
r.primary_analysis = _Analysis()
r.current_price = price
return r
@test("EUR trade setup: swing SL with EUR buffers, RR >= min_rr")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.M5)
agg = SignalAggregator(config)
r = _mk_eur_report()
ts = agg._generate_trade_setup(r, 0.0001)
assert ts is not None, f"no setup, warnings: {r.warnings}"
assert ts.pip_value == 0.0001
assert ts.stop_loss < ts.entry_price < ts.take_profit_1
assert ts.risk_pips < 60, f"EUR risk pips insane: {ts.risk_pips}"
assert ts.risk_reward >= config.eur_strategy.min_rr_ratio - 0.01
# levels must NOT be snapped to gold's 2-decimal grid
assert abs(ts.stop_loss * 100 - round(ts.stop_loss * 100)) > 1e-6 or \
abs(ts.take_profit_1 * 100 - round(ts.take_profit_1 * 100)) > 1e-6
@test("EUR spread gate rejects sub-minimum pip targets")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.M5)
config.eur_strategy.min_rr_ratio = 0.1
agg = SignalAggregator(config)
r = _mk_eur_report(atr=0.0002)
r.primary_analysis.key_levels = {"atr": 0.0002}
ts = agg._generate_trade_setup(r, 0.0001)
assert ts is None
assert any("SPREAD GATE" in w and "pip" in w for w in r.warnings), r.warnings
@test("Per-pair spread gates: 40-pip EUR target passes, $4 XAU target rejected")
def _():
from pair_signals.eurusd import make_config
# EUR: 40-pip TP distance clears the 10-pip minimum
config = make_config(Timeframe.M5)
agg = SignalAggregator(config)
r = _mk_eur_report(atr=0.0020)
ts = agg._generate_trade_setup(r, 0.0001)
assert ts is not None and abs(ts.take_profit_1 - ts.entry_price) / 0.0001 >= 10
# XAU: a $4 target must fail gold's $9 minimum (round_trip 0.30 × 30)
xau_cfg = PlatformConfig()
xau_agg = SignalAggregator(xau_cfg)
from signal_aggregator import SignalReport
xr = SignalReport(symbol=XAUUSD, display_name="XAU/USD", timestamp="2026-07-08 14:00")
xr.final_signal = SignalStrength.BUY
xr.final_score = 45.0
xr.final_confidence = 70.0
xr.signal_quality = "B"
class _XA:
key_levels = {"atr": 2.0}
xr.primary_analysis = _XA()
xr.current_price = 4000.0
xau_cfg.signals.min_rr_ratio = 0.1
xau_cfg.signals.tp_floor_rr = 0.1
ts2 = xau_agg._generate_trade_setup(xr, 0.10)
# ATR 2.0 → TP distance ~3, below the $9 gate
assert ts2 is None
assert any("SPREAD GATE" in w and "$" in w for w in xr.warnings), xr.warnings
@test("Trade setup: pip_value zero returns None")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
df = make_ohlcv(n=300, trend=0.002, seed=42)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False)
if report.primary_analysis:
setup = agg._generate_trade_setup(report, pip_value=0.0)
assert setup is None, "pip_value=0 should produce no trade setup"
# ── 10d. Cross-pair isolation ───────────────────────────────────────
@test("XAU sell gate does not apply to EUR/USD")
def _():
from pair_signals.eurusd import make_config
config = make_config(Timeframe.H1)
config.backtest.session_filter = False
agg = SignalAggregator(config)
df = make_ohlcv(n=300, start_price=1.0800, trend=-0.0005, seed=99)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
assert not any("SELL FILTER" in w for w in report.warnings)
@test("EUR report never carries XAU gate warnings across seeds")
def _():
from pair_signals.eurusd import make_config
for seed in (7, 21, 42, 77, 99):
config = make_config(Timeframe.M5)
agg = SignalAggregator(config)
df = make_ohlcv(n=400, start_price=1.0800, trend=0.0004, seed=seed)
dfs = {Timeframe.M5: df, Timeframe.M15: df, Timeframe.H1: df}
report = agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
leaked = [w for w in report.warnings
if "SELL FILTER" in w or "M5 FILTER" in w or "XAU PULLBACK" in w
or "LOW ATR FILTER" in w]
assert not leaked, f"XAU gates leaked into EUR (seed={seed}): {leaked}"
if report.trade_setup:
assert report.trade_setup.pip_value == 0.0001
@test("XAU report identical whether eur_strategy is enabled or disabled")
def _():
df = make_ohlcv(n=300, seed=42, trend=0.001)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
results = []
for eur_enabled in (True, False):
config = PlatformConfig()
config.eur_strategy.enabled = eur_enabled
agg = SignalAggregator(config)
report = agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False)
results.append((report.final_score, report.final_confidence,
report.signal_quality, str(report.trade_setup),
tuple(report.warnings)))
assert results[0] == results[1], \
f"XAU output changed with eur_strategy.enabled flip: {results[0]} vs {results[1]}"
# and no EUR warnings on an XAU report
assert not any("EUR" in w for w in results[0][4])
@test("Integration: XAU and EUR produce independent results")
def _():
from pair_signals.eurusd import make_config as eur_make_config
df = make_ohlcv(n=300, seed=42, trend=0.001)
xau_config = PlatformConfig()
xau_agg = SignalAggregator(xau_config)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
xau_report = xau_agg.analyze_from_dataframes(symbol=XAUUSD, dfs=dfs, use_news=False)
eur_config = eur_make_config(Timeframe.H1)
eur_agg = SignalAggregator(eur_config)
eur_report = eur_agg.analyze_from_dataframes(symbol=EURUSD, dfs=dfs, use_news=False)
assert xau_report.display_name == "XAU/USD"
assert eur_report.display_name == "EUR/USD"
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════