Compare commits
16 Commits
ec33f8b718
...
43d9064d1e
| Author | SHA1 | Date | |
|---|---|---|---|
| 43d9064d1e | |||
| ec13cd4c07 | |||
| f88e39f5be | |||
| 02bd61f378 | |||
| 2842351f9b | |||
| 58597de6d7 | |||
| 27bccf3bf5 | |||
| 2d704753d6 | |||
| 538977115b | |||
| e052c7aa3e | |||
| e0789457dc | |||
| 3b5c0b27ed | |||
| 77604960f5 | |||
| 668b7dad51 | |||
| 88970e750b | |||
| 7470e093e1 |
@@ -1,6 +1,6 @@
|
|||||||
# ForexBot2
|
# 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
|
## Architecture
|
||||||
|
|
||||||
@@ -13,26 +13,31 @@ Twelve Data REST API + WebSocket
|
|||||||
|
|
|
|
||||||
signal_aggregator.py (MTF agreement + news + final signal + trade setup)
|
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_bot.py main.py
|
||||||
(Telegram alerts, (CLI single-pair
|
(Telegram alerts, (CLI per-pair
|
||||||
auto-scanner, analysis)
|
auto-scanner, analysis)
|
||||||
WS monitor)
|
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
|
## Core Files
|
||||||
|
|
||||||
| File | Purpose |
|
| 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 |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `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 |
|
| `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 |
|
| `pair_signals/` | Per-pair producer registry (`PAIR_PRODUCERS`) + config factories |
|
||||||
| `test_all.py` | Comprehensive QA suite (~137 tests, no live API needed) |
|
| `main.py` | CLI entry point (`--pair XAUUSD\|EURUSD`) |
|
||||||
|
| `test_all.py` | Comprehensive QA suite (~176 tests, no live API needed) |
|
||||||
|
|
||||||
## Key Concepts
|
## Key Concepts
|
||||||
|
|
||||||
@@ -60,9 +65,9 @@ ROC is directionally redundant with MACD/Keltner/EMA (0.8–0.94 correlation) bu
|
|||||||
## How to Run
|
## How to Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# CLI (XAU/USD analysis)
|
# CLI
|
||||||
python main.py # H1 default
|
python main.py # XAU/USD H1 default
|
||||||
python main.py --timeframe M15 --detail --json
|
python main.py --pair EURUSD --timeframe M5 --detail
|
||||||
|
|
||||||
# Telegram bot (auto-scanner)
|
# Telegram bot (auto-scanner)
|
||||||
TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py
|
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
|
# Tests
|
||||||
python test_all.py
|
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 XAUUSD --days 365
|
||||||
|
python backtest/gpu_backtest.py --pair EURUSD --primary-tf 5min --confirm-tfs 15min,1h --days 365
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
## 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 |
|
| `TWELVE_DATA_API_KEY` | Market data API key | Hardcoded fallback in config |
|
||||||
| `TELEGRAM_TOKEN` | Telegram bot token | Hardcoded fallback |
|
| `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
|
## 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
|
- **Rate limit:** 55 credits/min (Grow plan), enforced by sliding-window throttle
|
||||||
- **Cache:** In-memory dict, TTL = candle duration of the timeframe
|
- **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
|
- **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
|
## 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
|
- **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
|
- **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`
|
- **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
|
## 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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+32
-15
@@ -48,6 +48,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
from config import ( # noqa: E402
|
from config import ( # noqa: E402
|
||||||
|
EURUSD,
|
||||||
PAIR_INFO,
|
PAIR_INFO,
|
||||||
PlatformConfig,
|
PlatformConfig,
|
||||||
SignalStrength,
|
SignalStrength,
|
||||||
@@ -96,6 +97,9 @@ PAIR_ALIASES = {
|
|||||||
"XAUUSD": XAUUSD,
|
"XAUUSD": XAUUSD,
|
||||||
"XAU/USD": XAUUSD,
|
"XAU/USD": XAUUSD,
|
||||||
"GOLD": XAUUSD,
|
"GOLD": XAUUSD,
|
||||||
|
"EURUSD": EURUSD,
|
||||||
|
"EUR/USD": EURUSD,
|
||||||
|
"EUR": EURUSD,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Bar duration in seconds — used to compute "fully-closed" cutoffs so
|
# Bar duration in seconds — used to compute "fully-closed" cutoffs so
|
||||||
@@ -1632,8 +1636,8 @@ def run_backtest(
|
|||||||
tag = "XAU_PB"
|
tag = "XAU_PB"
|
||||||
elif "XAU STRATEGY" in first_bull:
|
elif "XAU STRATEGY" in first_bull:
|
||||||
tag = "XAU"
|
tag = "XAU"
|
||||||
elif "EUR STRATEGY" in first_bull:
|
elif "EUR STRATEGY" in first_bull or "EUR STRATEGY" in first_bear:
|
||||||
tag = "EUR_PB"
|
tag = "EUR"
|
||||||
elif "GBP STRATEGY" in first_bull:
|
elif "GBP STRATEGY" in first_bull:
|
||||||
tag = "GBP_PB"
|
tag = "GBP_PB"
|
||||||
|
|
||||||
@@ -2034,7 +2038,7 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
|
|||||||
description="GPU-accelerated backtester for ForexBot2",
|
description="GPU-accelerated backtester for ForexBot2",
|
||||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
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("--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("--start", help="ISO date YYYY-MM-DD (overrides --days)")
|
||||||
p.add_argument("--end", help="ISO date YYYY-MM-DD (defaults to today)")
|
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)")
|
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("--quality", default="A+,A,B,C", help="Comma-separated allowed grades")
|
||||||
p.add_argument("--strategies", default="",
|
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("--no-cache", action="store_true", help="Skip parquet cache (force API fetch)")
|
||||||
p.add_argument("--spread", type=float, default=0.30,
|
p.add_argument("--spread", type=float, default=None,
|
||||||
help="Round-trip cost in price units per trade (XAU ~$0.30; 0 disables)")
|
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("--export-csv", help="Optional path to dump per-trade CSV (incl. indicator breakdown)")
|
||||||
p.add_argument("--detail", action="store_true",
|
p.add_argument("--detail", action="store_true",
|
||||||
help="Print full per-trade reasoning (indicators, signals, agreement, why entered)")
|
help="Print full per-trade reasoning (indicators, signals, agreement, why entered)")
|
||||||
@@ -2097,6 +2102,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
else:
|
else:
|
||||||
start = end - timedelta(days=args.days)
|
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(
|
bt_cfg = BacktestConfig(
|
||||||
pair=pair,
|
pair=pair,
|
||||||
start=start,
|
start=start,
|
||||||
@@ -2109,21 +2117,30 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
use_cache=not args.no_cache,
|
use_cache=not args.no_cache,
|
||||||
quality_filter=[q.strip() for q in args.quality.split(",") if q.strip()],
|
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()],
|
strategies_only=[s.strip() for s in args.strategies.split(",") if s.strip()],
|
||||||
spread=args.spread,
|
spread=spread,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Backend: {'CuPy GPU' if _GPU_AVAILABLE else 'NumPy CPU fallback'} | "
|
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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
platform_cfg = PlatformConfig(
|
# Per-pair tuned configs come from the pair_signals factories (regime
|
||||||
symbol=pair,
|
# weights, strategy gates, sessions). Unregistered pairs fall back to
|
||||||
display_name=PAIR_INFO[pair]["display"],
|
# the inline construction.
|
||||||
pip_value=PAIR_INFO[pair]["pip_value"],
|
from pair_signals import PAIR_PRODUCERS # noqa: E402 (after sys.path fix)
|
||||||
primary_timeframe=primary_tf,
|
factory = PAIR_PRODUCERS.get(pair)
|
||||||
confirmation_timeframes=confirm_tfs,
|
if factory is not None:
|
||||||
)
|
platform_cfg = factory(primary_tf, confirm_tfs)
|
||||||
|
else:
|
||||||
|
platform_cfg = PlatformConfig(
|
||||||
|
symbol=pair,
|
||||||
|
display_name=PAIR_INFO[pair]["display"],
|
||||||
|
pip_value=PAIR_INFO[pair]["pip_value"],
|
||||||
|
primary_timeframe=primary_tf,
|
||||||
|
confirmation_timeframes=confirm_tfs,
|
||||||
|
)
|
||||||
if args.swing_sr_lookback > 0:
|
if args.swing_sr_lookback > 0:
|
||||||
platform_cfg.technical.swing_sr_lookback = args.swing_sr_lookback
|
platform_cfg.technical.swing_sr_lookback = args.swing_sr_lookback
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -37,9 +37,16 @@ class MarketSession(Enum):
|
|||||||
|
|
||||||
# ── Supported pairs ──────────────────────────────────────────────
|
# ── Supported pairs ──────────────────────────────────────────────
|
||||||
XAUUSD = "XAU/USD"
|
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 = {
|
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
|
@dataclass
|
||||||
class NewsConfig:
|
class NewsConfig:
|
||||||
"""Economic calendar & sentiment."""
|
"""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
|
pre_event_buffer_minutes: int = 30
|
||||||
post_event_buffer_minutes: int = 15
|
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
|
# Wider stay-out window after a high-impact release that surprised
|
||||||
# vs forecast (inline releases use post_event_buffer_minutes).
|
# vs forecast (inline releases use post_event_buffer_minutes).
|
||||||
post_event_surprise_buffer_minutes: int = 45
|
post_event_surprise_buffer_minutes: int = 45
|
||||||
@@ -252,6 +267,54 @@ class XauStrategyConfig:
|
|||||||
m5_pullback_touch_bars: int = 3
|
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
|
@dataclass
|
||||||
class FilterConfig:
|
class FilterConfig:
|
||||||
"""Regime and session filters for live signals."""
|
"""Regime and session filters for live signals."""
|
||||||
@@ -275,6 +338,7 @@ class PlatformConfig:
|
|||||||
news: NewsConfig = field(default_factory=NewsConfig)
|
news: NewsConfig = field(default_factory=NewsConfig)
|
||||||
backtest: FilterConfig = field(default_factory=FilterConfig)
|
backtest: FilterConfig = field(default_factory=FilterConfig)
|
||||||
xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig)
|
xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig)
|
||||||
|
eur_strategy: EurUsdStrategyConfig = field(default_factory=EurUsdStrategyConfig)
|
||||||
|
|
||||||
symbol: str = XAUUSD
|
symbol: str = XAUUSD
|
||||||
display_name: str = "XAU/USD"
|
display_name: str = "XAU/USD"
|
||||||
|
|||||||
@@ -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)
|
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)
|
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 = 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
|
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:
|
if len(close) < n + 5:
|
||||||
return
|
return
|
||||||
|
|
||||||
roc_val = ((close.iloc[-1] - close.iloc[-n - 1]) / close.iloc[-n - 1]) * 100
|
denom1 = close.iloc[-n - 1]
|
||||||
roc_prev = ((close.iloc[-2] - close.iloc[-n - 2]) / close.iloc[-n - 2]) * 100
|
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):
|
if pd.isna(roc_val) or pd.isna(roc_prev):
|
||||||
return
|
return
|
||||||
@@ -1154,12 +1158,14 @@ class TechnicalEngine:
|
|||||||
for i in range(window, len(vals) - window):
|
for i in range(window, len(vals) - window):
|
||||||
if np.isnan(vals[i]):
|
if np.isnan(vals[i]):
|
||||||
continue
|
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 mode == "low":
|
||||||
if all(vals[i] <= vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])) and \
|
if all(vals[i] <= v for v in left_vals) and all(vals[i] <= v for v in right_vals):
|
||||||
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])):
|
|
||||||
points.append((i, vals[i]))
|
points.append((i, vals[i]))
|
||||||
else:
|
else:
|
||||||
if all(vals[i] >= vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])) and \
|
if all(vals[i] >= v for v in left_vals) and all(vals[i] >= v for v in right_vals):
|
||||||
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])):
|
|
||||||
points.append((i, vals[i]))
|
points.append((i, vals[i]))
|
||||||
return points
|
return points
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ if hasattr(sys.stdout, "reconfigure"):
|
|||||||
if hasattr(sys.stderr, "reconfigure"):
|
if hasattr(sys.stderr, "reconfigure"):
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
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
|
from signal_aggregator import SignalAggregator, SignalReport
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +98,11 @@ def _compact_reason(reason: str) -> str:
|
|||||||
return s.strip()
|
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):
|
def print_signal_report(report: SignalReport, detail: bool = False):
|
||||||
sc = signal_color(report.final_signal)
|
sc = signal_color(report.final_signal)
|
||||||
qc = quality_color(report.signal_quality)
|
qc = quality_color(report.signal_quality)
|
||||||
@@ -116,16 +122,16 @@ def print_signal_report(report: SignalReport, detail: bool = False):
|
|||||||
ts = report.trade_setup
|
ts = report.trade_setup
|
||||||
dc = Colors.GREEN if ts.direction == "BUY" else Colors.RED
|
dc = Colors.GREEN if ts.direction == "BUY" else Colors.RED
|
||||||
print(f"\n {Colors.BOLD}Setup{Colors.RESET} "
|
print(f"\n {Colors.BOLD}Setup{Colors.RESET} "
|
||||||
f"{dc}{ts.direction}{Colors.RESET} @ {Colors.BOLD}{ts.entry_price:.3f}{Colors.RESET} "
|
f"{dc}{ts.direction}{Colors.RESET} @ {Colors.BOLD}{_fmt_price(ts.entry_price)}{Colors.RESET} "
|
||||||
f"→ SL {Colors.RED}{ts.stop_loss:.3f}{Colors.RESET} "
|
f"→ SL {Colors.RED}{_fmt_price(ts.stop_loss)}{Colors.RESET} "
|
||||||
f"({ts.risk_pips:.1f}p) "
|
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"({ts.reward_pips:.1f}p) "
|
||||||
f"RR 1:{ts.risk_reward:.1f}")
|
f"RR 1:{ts.risk_reward:.1f}")
|
||||||
|
|
||||||
# ── 3. CONTEXT (price · regime · vol · MTF breakdown) ──────────
|
# ── 3. CONTEXT (price · regime · vol · MTF breakdown) ──────────
|
||||||
print(f"\n {Colors.WHITE}{report.display_name}{Colors.RESET} "
|
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"{Colors.CYAN}{report.market_regime}{Colors.RESET} · "
|
||||||
f"vol {report.volatility_regime}")
|
f"vol {report.volatility_regime}")
|
||||||
|
|
||||||
@@ -250,6 +256,8 @@ Examples:
|
|||||||
python main.py --json JSON output
|
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",
|
parser.add_argument("--timeframe", "-tf", default="H1",
|
||||||
choices=["M5", "M15", "M30", "H1", "H4", "D1"],
|
choices=["M5", "M15", "M30", "H1", "H4", "D1"],
|
||||||
help="Primary analysis timeframe (default: H1)")
|
help="Primary analysis timeframe (default: H1)")
|
||||||
@@ -263,14 +271,25 @@ Examples:
|
|||||||
return parser.parse_args()
|
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:
|
def build_config(args) -> PlatformConfig:
|
||||||
config = PlatformConfig()
|
pair = _resolve_pair(args.pair)
|
||||||
|
|
||||||
tf_map = {
|
tf_map = {
|
||||||
"M5": Timeframe.M5, "M15": Timeframe.M15, "M30": Timeframe.M30,
|
"M5": Timeframe.M5, "M15": Timeframe.M15, "M30": Timeframe.M30,
|
||||||
"H1": Timeframe.H1, "H4": Timeframe.H4, "D1": Timeframe.D1,
|
"H1": Timeframe.H1, "H4": Timeframe.H4, "D1": Timeframe.D1,
|
||||||
}
|
}
|
||||||
config.primary_timeframe = tf_map[args.timeframe]
|
primary_tf = tf_map[args.timeframe]
|
||||||
|
|
||||||
tf_confirmations = {
|
tf_confirmations = {
|
||||||
"M5": [Timeframe.M1, Timeframe.M15],
|
"M5": [Timeframe.M1, Timeframe.M15],
|
||||||
@@ -280,23 +299,31 @@ def build_config(args) -> PlatformConfig:
|
|||||||
"H4": [Timeframe.H1, Timeframe.D1],
|
"H4": [Timeframe.H1, Timeframe.D1],
|
||||||
"D1": [Timeframe.H4, Timeframe.W1],
|
"D1": [Timeframe.H4, Timeframe.W1],
|
||||||
}
|
}
|
||||||
config.confirmation_timeframes = tf_confirmations.get(
|
confirmation_tfs = tf_confirmations.get(
|
||||||
args.timeframe, [Timeframe.M15, Timeframe.H4]
|
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
|
return config
|
||||||
|
|
||||||
|
|
||||||
def run_live(args, config: PlatformConfig):
|
def run_live(args, config: PlatformConfig):
|
||||||
print(f"{Colors.GRAY} Fetching market data and running analysis...{Colors.RESET}\n")
|
print(f"{Colors.GRAY} Fetching market data and running analysis...{Colors.RESET}\n")
|
||||||
aggregator = SignalAggregator(config)
|
aggregator = SignalAggregator(config)
|
||||||
report = aggregator.analyze_pair()
|
report = aggregator.analyze_pair(symbol=config.symbol)
|
||||||
|
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(report_to_dict(report), indent=2))
|
print(json.dumps(report_to_dict(report), indent=2))
|
||||||
return
|
return
|
||||||
|
|
||||||
print_header("XAU/USD SIGNAL INTELLIGENCE")
|
print_header(f"{config.display_name} SIGNAL INTELLIGENCE")
|
||||||
print_signal_report(report, detail=args.detail)
|
print_signal_report(report, detail=args.detail)
|
||||||
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
|
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
|
||||||
print(f"{Colors.GRAY} Disclaimer: For educational/research purposes only.{Colors.RESET}")
|
print(f"{Colors.GRAY} Disclaimer: For educational/research purposes only.{Colors.RESET}")
|
||||||
|
|||||||
+2
-1
@@ -673,7 +673,8 @@ class NewsEngine:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_affected_pairs(currency: str) -> List[str]:
|
def _get_affected_pairs(currency: str) -> List[str]:
|
||||||
_MAP = {
|
_MAP = {
|
||||||
"USD": ["XAU/USD"],
|
"USD": ["XAU/USD", "EUR/USD"],
|
||||||
"XAU": ["XAU/USD"],
|
"XAU": ["XAU/USD"],
|
||||||
|
"EUR": ["EUR/USD"],
|
||||||
}
|
}
|
||||||
return _MAP.get(currency, [])
|
return _MAP.get(currency, [])
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+202
-27
@@ -157,6 +157,39 @@ class SignalAggregator:
|
|||||||
report.action_recommendation = f"NO TRADE — Only trading {allowed}"
|
report.action_recommendation = f"NO TRADE — Only trading {allowed}"
|
||||||
return report
|
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(
|
timeframes = [self.config.primary_timeframe] + list(
|
||||||
self.config.confirmation_timeframes
|
self.config.confirmation_timeframes
|
||||||
)
|
)
|
||||||
@@ -704,6 +737,7 @@ class SignalAggregator:
|
|||||||
or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0)
|
or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0)
|
||||||
)
|
)
|
||||||
if (self.config.xau_strategy.enabled
|
if (self.config.xau_strategy.enabled
|
||||||
|
and report.display_name == "XAU/USD"
|
||||||
and xau_is_sell_dir
|
and xau_is_sell_dir
|
||||||
and report.signal_quality not in self.config.xau_strategy.sell_min_quality):
|
and report.signal_quality not in self.config.xau_strategy.sell_min_quality):
|
||||||
report.warnings.append(
|
report.warnings.append(
|
||||||
@@ -734,6 +768,7 @@ class SignalAggregator:
|
|||||||
# Shorts in quiet markets fade against gold's macro uptrend;
|
# Shorts in quiet markets fade against gold's macro uptrend;
|
||||||
# only take sells when volatility regime confirms real selling.
|
# only take sells when volatility regime confirms real selling.
|
||||||
if (self.config.xau_strategy.enabled
|
if (self.config.xau_strategy.enabled
|
||||||
|
and report.display_name == "XAU/USD"
|
||||||
and xau_is_sell_dir
|
and xau_is_sell_dir
|
||||||
and report.signal_quality in ("A+", "A", "B", "C")
|
and report.signal_quality in ("A+", "A", "B", "C")
|
||||||
and report.volatility_regime
|
and report.volatility_regime
|
||||||
@@ -744,12 +779,103 @@ class SignalAggregator:
|
|||||||
report.signal_quality = "D"
|
report.signal_quality = "D"
|
||||||
report.trade_setup = None
|
report.trade_setup = None
|
||||||
|
|
||||||
# ── 6h. M5 noise gate ────────────────────────────────────
|
# ── 6h. EUR/USD regime + quality + risk gate ──────────────
|
||||||
if self.config.primary_timeframe == Timeframe.M5:
|
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)
|
self._apply_m5_filters(report)
|
||||||
|
|
||||||
# ── 7. Trade setup for actionable signals ──────────────────
|
# ── 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)
|
atr = (report.primary_analysis.key_levels.get("atr", 0)
|
||||||
if report.primary_analysis else 0)
|
if report.primary_analysis else 0)
|
||||||
if atr > 0:
|
if atr > 0:
|
||||||
@@ -760,11 +886,23 @@ class SignalAggregator:
|
|||||||
# ── 8. Reasons ─────────────────────────────────────────────
|
# ── 8. Reasons ─────────────────────────────────────────────
|
||||||
self._collect_reasons(report)
|
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) ──────────────────────────
|
# ── 9. M5 pullback entry (XAU_PB) ──────────────────────────
|
||||||
# Second archetype: when no breakout setup fired, look for a
|
# Second archetype: when no breakout setup fired, look for a
|
||||||
# trend-continuation pullback (retrace to EMA21/KC-mid). Runs
|
# trend-continuation pullback (retrace to EMA21/KC-mid). Runs
|
||||||
# after reasons so its marker lands at reasons[0] for tagging.
|
# after reasons so its marker lands at reasons[0] for tagging.
|
||||||
if (self.config.primary_timeframe == Timeframe.M5
|
if (self.config.primary_timeframe == Timeframe.M5
|
||||||
|
and report.display_name == "XAU/USD"
|
||||||
and self.config.xau_strategy.m5_pullback_enabled
|
and self.config.xau_strategy.m5_pullback_enabled
|
||||||
and report.trade_setup is None
|
and report.trade_setup is None
|
||||||
and report.signal_quality != "F"):
|
and report.signal_quality != "F"):
|
||||||
@@ -1000,10 +1138,14 @@ class SignalAggregator:
|
|||||||
confidence += 5
|
confidence += 5
|
||||||
|
|
||||||
# ── Event risk (hard cap, separate from penalty system) ───
|
# ── 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":
|
if report.event_risk == "danger":
|
||||||
report.warnings.append(f"EVENT RISK: {report.event_description}")
|
report.warnings.append(f"EVENT RISK: {report.event_description}")
|
||||||
confidence = min(confidence, 30)
|
if self.config.news.event_risk_caps_signals:
|
||||||
report.action_recommendation = f"WAIT — {report.event_description}"
|
confidence = min(confidence, 30)
|
||||||
|
report.action_recommendation = f"WAIT — {report.event_description}"
|
||||||
|
|
||||||
# ── Penalties (diminishing cap system) ────────────────────
|
# ── Penalties (diminishing cap system) ────────────────────
|
||||||
# Collect all applicable penalties, then apply with exponential
|
# Collect all applicable penalties, then apply with exponential
|
||||||
@@ -1025,7 +1167,8 @@ class SignalAggregator:
|
|||||||
|
|
||||||
if report.event_risk == "caution":
|
if report.event_risk == "caution":
|
||||||
report.warnings.append(f"Upcoming event: {report.event_description}")
|
report.warnings.append(f"Upcoming event: {report.event_description}")
|
||||||
penalties.append(("event_caution", 15))
|
if self.config.news.event_risk_caps_signals:
|
||||||
|
penalties.append(("event_caution", 15))
|
||||||
|
|
||||||
is_counter_trend = (
|
is_counter_trend = (
|
||||||
(report.market_regime == "trending_up" and score < 0) or
|
(report.market_regime == "trending_up" and score < 0) or
|
||||||
@@ -1195,6 +1338,8 @@ class SignalAggregator:
|
|||||||
atr = report.primary_analysis.key_levels.get("atr", 0)
|
atr = report.primary_analysis.key_levels.get("atr", 0)
|
||||||
if atr == 0:
|
if atr == 0:
|
||||||
return None
|
return None
|
||||||
|
if pip_value <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
# Fix 2: Minimum ATR threshold for XAU/USD
|
# Fix 2: Minimum ATR threshold for XAU/USD
|
||||||
if report.display_name == "XAU/USD" and atr < self.config.xau_strategy.min_atr_threshold:
|
if report.display_name == "XAU/USD" and atr < self.config.xau_strategy.min_atr_threshold:
|
||||||
@@ -1203,8 +1348,6 @@ class SignalAggregator:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
min_sl_price = self.config.signals.min_sl_pips * pip_value
|
|
||||||
|
|
||||||
if report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
|
if report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
|
||||||
is_buy = True
|
is_buy = True
|
||||||
elif report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL):
|
elif report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL):
|
||||||
@@ -1212,16 +1355,39 @@ class SignalAggregator:
|
|||||||
else:
|
else:
|
||||||
is_buy = report.final_score >= 0
|
is_buy = report.final_score >= 0
|
||||||
levels = report.primary_analysis.key_levels
|
levels = report.primary_analysis.key_levels
|
||||||
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
|
|
||||||
pre_tight_min_rr = tp_floor / tf if tf > 0 else tp_floor
|
|
||||||
|
|
||||||
tp_mult = 1.5
|
_is_eur = report.display_name == "EUR/USD" and self.config.eur_strategy.enabled
|
||||||
tp_swing_cap = 2.5
|
|
||||||
|
|
||||||
sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier
|
if _is_eur:
|
||||||
if report.display_name == "XAU/USD" else 1.5)
|
# 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
|
||||||
|
pre_tight_min_rr = tp_floor / tf if tf > 0 else tp_floor
|
||||||
|
|
||||||
|
tp_mult = 1.5
|
||||||
|
tp_swing_cap = 2.5
|
||||||
|
|
||||||
|
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:
|
if is_buy:
|
||||||
swing_lows = sorted(
|
swing_lows = sorted(
|
||||||
@@ -1229,9 +1395,9 @@ class SignalAggregator:
|
|||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
if swing_lows:
|
if swing_lows:
|
||||||
sl_price = swing_lows[0] - atr * 1.0
|
sl_price = swing_lows[0] - atr * sl_swing_buffer
|
||||||
else:
|
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)
|
min_sl = max(atr * sl_atr_floor, min_sl_price)
|
||||||
if price - sl_price < min_sl:
|
if price - sl_price < min_sl:
|
||||||
sl_price = 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
|
v for k, v in levels.items() if k.startswith("swing_high_") and v > price
|
||||||
)
|
)
|
||||||
if swing_highs:
|
if swing_highs:
|
||||||
sl_price = swing_highs[0] + atr * 1.0
|
sl_price = swing_highs[0] + atr * sl_swing_buffer
|
||||||
else:
|
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)
|
min_sl = max(atr * sl_atr_floor, min_sl_price)
|
||||||
if sl_price - price < min_sl:
|
if sl_price - price < min_sl:
|
||||||
sl_price = price + min_sl
|
sl_price = price + min_sl
|
||||||
@@ -1305,13 +1471,22 @@ class SignalAggregator:
|
|||||||
|
|
||||||
# Spread gate: a target too close to entry hands most of the win
|
# Spread gate: a target too close to entry hands most of the win
|
||||||
# to transaction costs (validated on M5: sub-$10 targets lose).
|
# to transaction costs (validated on M5: sub-$10 targets lose).
|
||||||
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
|
if _is_eur:
|
||||||
if tp1_distance < min_tp:
|
min_tp = eur.round_trip_cost * eur.min_tp_cost_multiple
|
||||||
report.warnings.append(
|
if tp1_distance < min_tp:
|
||||||
f"SPREAD GATE: target ${tp1_distance:.2f} < ${min_tp:.2f} minimum — "
|
report.warnings.append(
|
||||||
f"costs would eat the edge"
|
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
|
)
|
||||||
|
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(
|
||||||
|
f"SPREAD GATE: target ${tp1_distance:.2f} < ${min_tp:.2f} minimum — "
|
||||||
|
f"costs would eat the edge"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
risk_pips = sl_distance / pip_value
|
risk_pips = sl_distance / pip_value
|
||||||
reward_pips = tp1_distance / pip_value
|
reward_pips = tp1_distance / pip_value
|
||||||
|
|||||||
+230
-81
@@ -18,14 +18,29 @@ WebSocket: Streams live ticks from TwelveData for XAU/USD.
|
|||||||
immediate M15 scan and sends a Level Break Alert.
|
immediate M15 scan and sends a Level Break Alert.
|
||||||
5-minute cooldown prevents spam.
|
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:
|
Usage:
|
||||||
python telegram_bot.py
|
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 asyncio
|
||||||
|
import functools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import logging.handlers
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
@@ -51,17 +66,26 @@ from config import (
|
|||||||
XAUUSD, PAIR_INFO,
|
XAUUSD, PAIR_INFO,
|
||||||
)
|
)
|
||||||
from data_provider import ForexDataProvider
|
from data_provider import ForexDataProvider
|
||||||
|
from pair_signals import PAIR_PRODUCERS
|
||||||
from signal_aggregator import SignalAggregator, SignalReport
|
from signal_aggregator import SignalAggregator, SignalReport
|
||||||
|
|
||||||
# ── Force UTF-8 on Windows ────────────────────────────────────────────
|
# ── Force UTF-8 on Windows ────────────────────────────────────────────
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
logging.basicConfig(
|
_LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
os.makedirs(_LOG_DIR, exist_ok=True)
|
||||||
level=logging.INFO,
|
_log_formatter = logging.Formatter(
|
||||||
datefmt="%H:%M:%S",
|
"%(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("httpx").setLevel(logging.WARNING)
|
||||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||||
logger = logging.getLogger("telegram_bot")
|
logger = logging.getLogger("telegram_bot")
|
||||||
@@ -69,13 +93,28 @@ logger = logging.getLogger("telegram_bot")
|
|||||||
TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9-qfRTfNE")
|
TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9-qfRTfNE")
|
||||||
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
|
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 ───────────────────────────────────────────────────
|
# ── 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 = {
|
_PAIRS_LABEL = " + ".join(PAIR_INFO[p]["display"] for p in SCAN_PAIRS)
|
||||||
"XAU/USD": XAUUSD,
|
|
||||||
}
|
_WS_TO_PAIR = {PAIR_INFO[p]["display"]: p for p in SCAN_PAIRS}
|
||||||
_WS_SYMBOLS = list(_WS_TO_PAIR.keys())
|
_WS_SYMBOLS = list(_WS_TO_PAIR.keys())
|
||||||
|
|
||||||
_LEVEL_KEYS = (
|
_LEVEL_KEYS = (
|
||||||
@@ -85,14 +124,36 @@ _LEVEL_KEYS = (
|
|||||||
"kc_upper", "kc_lower",
|
"kc_upper", "kc_lower",
|
||||||
)
|
)
|
||||||
|
|
||||||
SCAN_BUTTONS = [
|
_TF_BUTTON_LABELS = [
|
||||||
{"cb": "scan_xauusd_m5", "symbol": XAUUSD, "tf": Timeframe.M5, "label": "5 Min"},
|
(Timeframe.M5, "5 Min"),
|
||||||
{"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "15 Min"},
|
(Timeframe.M15, "15 Min"),
|
||||||
{"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "1 Hour"},
|
(Timeframe.H1, "1 Hour"),
|
||||||
{"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "4 Hour"},
|
(Timeframe.H4, "4 Hour"),
|
||||||
{"cb": "scan_xauusd_all", "symbol": XAUUSD, "tf": None, "label": "Scan All TFs"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS}
|
||||||
|
|
||||||
TF_CONFIRMATIONS = {
|
TF_CONFIRMATIONS = {
|
||||||
@@ -190,6 +251,13 @@ def _live_price_line(symbol: str) -> Optional[str]:
|
|||||||
return f"📡 Live: *{_fmt_price(price)}* ({age:.0f}s tick)"
|
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:
|
def format_signal_message(report: SignalReport) -> str:
|
||||||
sig = report.final_signal
|
sig = report.final_signal
|
||||||
emoji = _SIGNAL_EMOJI.get(sig, "⬜")
|
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
|
# TP1 is advisory, the trail is the exit). Pullback setups run
|
||||||
# tighter stops and arm later.
|
# tighter stops and arm later.
|
||||||
sig_cfg = PlatformConfig().signals
|
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
|
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
|
dist_r = sig_cfg.pb_trail_dist_r if is_pb else sig_cfg.trail_dist_r
|
||||||
if arm_r > 0:
|
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"
|
f"behind best price — TP1 is a guide, let it run"
|
||||||
)
|
)
|
||||||
|
|
||||||
lines += ["", "━━━━━━━━━━━━━━━━━━━━",
|
lines += ["", "━━━━━━━━━━━━━━━━━━━━", _DISCLAIMER]
|
||||||
"_Educational / research use only._"]
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@@ -307,34 +374,38 @@ def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
|
|||||||
else:
|
else:
|
||||||
lines.append("No actionable signals this cycle.")
|
lines.append("No actionable signals this cycle.")
|
||||||
|
|
||||||
|
lines += ["", _DISCLAIMER]
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
# ── Keyboard helper ───────────────────────────────────────────────────
|
# ── Keyboard helper ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 rows
|
||||||
|
|
||||||
|
|
||||||
def scan_keyboard() -> InlineKeyboardMarkup:
|
def scan_keyboard() -> InlineKeyboardMarkup:
|
||||||
tf_buttons = [b for b in SCAN_BUTTONS if b["tf"] is not None]
|
return InlineKeyboardMarkup(_scan_button_rows())
|
||||||
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
|
|
||||||
]]
|
|
||||||
if all_button:
|
|
||||||
rows.append([InlineKeyboardButton(all_button["label"], callback_data=all_button["cb"])])
|
|
||||||
return InlineKeyboardMarkup(rows)
|
|
||||||
|
|
||||||
|
|
||||||
def main_menu_keyboard() -> InlineKeyboardMarkup:
|
def main_menu_keyboard() -> InlineKeyboardMarkup:
|
||||||
"""Full interactive menu: scans + settings + status in one keyboard."""
|
"""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"
|
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"
|
quality_label = f"🎚 Alerts: {_alert_min_quality}+" if _alert_min_quality != "A" else "🎚 Alerts: A only"
|
||||||
rows = [
|
rows = _scan_button_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.append([
|
rows.append([
|
||||||
InlineKeyboardButton(autoscan_label, callback_data="menu_autoscan"),
|
InlineKeyboardButton(autoscan_label, callback_data="menu_autoscan"),
|
||||||
InlineKeyboardButton(quality_label, callback_data="menu_quality"),
|
InlineKeyboardButton(quality_label, callback_data="menu_quality"),
|
||||||
@@ -374,9 +445,16 @@ def _run_signal_sync(
|
|||||||
confirmation_tfs = TF_CONFIRMATIONS.get(
|
confirmation_tfs = TF_CONFIRMATIONS.get(
|
||||||
timeframe, [Timeframe.M15, Timeframe.H4]
|
timeframe, [Timeframe.M15, Timeframe.H4]
|
||||||
)
|
)
|
||||||
config = PlatformConfig()
|
# Per-pair tuned configs (regime weights, gates, sessions) come from
|
||||||
config.primary_timeframe = timeframe
|
# the pair_signals factories; a plain PlatformConfig would silently
|
||||||
config.confirmation_timeframes = confirmation_tfs
|
# 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
|
||||||
aggregator = SignalAggregator(config)
|
aggregator = SignalAggregator(config)
|
||||||
aggregator.data_provider = _get_shared_dp()
|
aggregator.data_provider = _get_shared_dp()
|
||||||
return aggregator.analyze_pair(symbol=symbol)
|
return aggregator.analyze_pair(symbol=symbol)
|
||||||
@@ -433,6 +511,20 @@ _autoscan_chats: Set[int] = set()
|
|||||||
_autoscan_tasks: list[asyncio.Task] = []
|
_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:
|
def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
|
||||||
"""Seconds until the next candle close + buffer."""
|
"""Seconds until the next candle close + buffer."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -471,7 +563,7 @@ async def _scan_timeframe(
|
|||||||
if _passes_quality(r.signal_quality)
|
if _passes_quality(r.signal_quality)
|
||||||
and _should_alert(f"{r.symbol}_{tf.value}", r)
|
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:
|
for report in actionable:
|
||||||
detail = format_signal_message(report)
|
detail = format_signal_message(report)
|
||||||
try:
|
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"
|
f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n"
|
||||||
)
|
)
|
||||||
detail = header + format_signal_message(report)
|
detail = header + format_signal_message(report)
|
||||||
for chat_id in list(_autoscan_chats):
|
for chat_id in _signal_targets():
|
||||||
try:
|
try:
|
||||||
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
|
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -583,7 +675,7 @@ async def _ws_heartbeat(ws) -> None:
|
|||||||
|
|
||||||
async def _ws_monitor(bot) -> None:
|
async def _ws_monitor(bot) -> None:
|
||||||
backoff = 1
|
backoff = 1
|
||||||
while _autoscan_chats:
|
while _feed_active():
|
||||||
hb_task: Optional[asyncio.Task] = None
|
hb_task: Optional[asyncio.Task] = None
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(_WS_URL) as ws:
|
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))
|
hb_task = asyncio.create_task(_ws_heartbeat(ws))
|
||||||
|
|
||||||
async for raw in ws:
|
async for raw in ws:
|
||||||
if not _autoscan_chats:
|
if not _feed_active():
|
||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -655,11 +747,11 @@ async def _ws_monitor(bot) -> None:
|
|||||||
async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None:
|
async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None:
|
||||||
label = tf.value
|
label = tf.value
|
||||||
try:
|
try:
|
||||||
while _autoscan_chats:
|
while _feed_active():
|
||||||
wait = _seconds_until_boundary(interval_minutes, buffer=10)
|
wait = _seconds_until_boundary(interval_minutes, buffer=10)
|
||||||
logger.info(f"{label} auto-scan: next in {wait:.0f}s")
|
logger.info(f"{label} auto-scan: next in {wait:.0f}s")
|
||||||
await asyncio.sleep(wait)
|
await asyncio.sleep(wait)
|
||||||
if not _autoscan_chats:
|
if not _feed_active():
|
||||||
break
|
break
|
||||||
logger.info(f"{label} auto-scan: firing")
|
logger.info(f"{label} auto-scan: firing")
|
||||||
await _scan_timeframe(
|
await _scan_timeframe(
|
||||||
@@ -714,10 +806,39 @@ def _stop_scheduler() -> None:
|
|||||||
|
|
||||||
# ── Handlers ──────────────────────────────────────────────────────────
|
# ── 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:
|
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
scanning = "running" if _autoscan_chats else "off"
|
scanning = "running" if _autoscan_chats else "off"
|
||||||
text = (
|
text = (
|
||||||
"👋 *ForexBot2 — XAU/USD Signal Intelligence*\n\n"
|
f"👋 *ForexBot2 — {_PAIRS_LABEL} Signal Intelligence*\n\n"
|
||||||
"Pick a timeframe to scan gold.\n"
|
"Pick a timeframe to scan gold.\n"
|
||||||
"Signals include a full trade setup with entry, SL and TP.\n\n"
|
"Signals include a full trade setup with entry, SL and TP.\n\n"
|
||||||
f"Auto-scan: *{scanning}* — /autoscan to toggle\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 = (
|
_HELP_TEXT = (
|
||||||
"📖 *ForexBot2 Commands*\n\n"
|
"📖 *ForexBot2 Commands*\n\n"
|
||||||
"/start — Welcome & interactive menu\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"
|
"/autoscan — Toggle auto-scan on/off\n"
|
||||||
"/status — Bot status, WS connection, live prices\n"
|
"/status — Bot status, WS connection, live prices\n"
|
||||||
"/cache — Show data cache age per pair/TF\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"
|
"*Auto-scan* fires at candle close: M5/M15/H1/H4.\n"
|
||||||
"*Alerts* button sets the minimum quality grade that gets sent.\n"
|
"*Alerts* button sets the minimum quality grade that gets sent.\n"
|
||||||
"*WebSocket* streams live prices; key-level breaches trigger an "
|
"*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:
|
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
await update.message.reply_text(_HELP_TEXT, parse_mode=ParseMode.MARKDOWN)
|
await update.message.reply_text(_HELP_TEXT, parse_mode=ParseMode.MARKDOWN)
|
||||||
|
|
||||||
@@ -762,6 +887,9 @@ def _status_text() -> str:
|
|||||||
else:
|
else:
|
||||||
lines.append("⏹ Auto-scan: *stopped*")
|
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():
|
if _ws_task and not _ws_task.done():
|
||||||
lines.append("📡 WebSocket: *connected*")
|
lines.append("📡 WebSocket: *connected*")
|
||||||
else:
|
else:
|
||||||
@@ -804,10 +932,12 @@ def _status_text() -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_only
|
||||||
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
await update.message.reply_text(_status_text(), parse_mode=ParseMode.MARKDOWN)
|
await update.message.reply_text(_status_text(), parse_mode=ParseMode.MARKDOWN)
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_only
|
||||||
async def cmd_cache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def cmd_cache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
dp = _get_shared_dp()
|
dp = _get_shared_dp()
|
||||||
if not dp._cache_timestamps:
|
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)
|
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:
|
async def cmd_clearcache(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
dp = _get_shared_dp()
|
dp = _get_shared_dp()
|
||||||
count = dp.clear_cache()
|
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:
|
async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
chat_id = update.effective_chat.id
|
chat_id = update.effective_chat.id
|
||||||
|
|
||||||
if chat_id in _autoscan_chats:
|
if chat_id in _autoscan_chats:
|
||||||
_autoscan_chats.discard(chat_id)
|
_autoscan_chats.discard(chat_id)
|
||||||
if not _autoscan_chats:
|
if not _feed_active():
|
||||||
_stop_scheduler()
|
_stop_scheduler()
|
||||||
await update.message.reply_text(
|
msg = "⏹ Auto-scan stopped."
|
||||||
"⏹ Auto-scan stopped.",
|
else:
|
||||||
parse_mode=ParseMode.MARKDOWN,
|
msg = ("⏹ Auto-scan stopped for this chat.\n"
|
||||||
)
|
"📢 Subscriber channel feed continues.")
|
||||||
|
await update.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN)
|
||||||
return
|
return
|
||||||
|
|
||||||
_autoscan_chats.add(chat_id)
|
_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)
|
next_h4 = _seconds_until_boundary(240, buffer=10)
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
"✅ *Auto-scan enabled*\n\n"
|
"✅ *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"📡 Live price monitoring active.\n"
|
||||||
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
|
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"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:
|
async def cmd_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
"🔍 *XAU/USD — Pick a timeframe*",
|
f"🔍 *{_PAIRS_LABEL} — Pick a timeframe*",
|
||||||
parse_mode=ParseMode.MARKDOWN,
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
reply_markup=main_menu_keyboard(),
|
reply_markup=main_menu_keyboard(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_only
|
||||||
async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
await query.answer()
|
await query.answer()
|
||||||
@@ -894,10 +1029,11 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if btn["tf"] is None:
|
if btn["tf"] is None:
|
||||||
await _cb_scan_all(query)
|
await _cb_scan_all(query, btn["symbol"])
|
||||||
return
|
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:
|
try:
|
||||||
report = await run_signal(btn["symbol"], btn["tf"])
|
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())
|
await _safe_edit(query, text, reply_markup=main_menu_keyboard())
|
||||||
|
|
||||||
|
|
||||||
|
@_owner_only
|
||||||
async def cb_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
async def cb_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
"""Interactive menu actions: autoscan toggle, quality cycle, status, help."""
|
"""Interactive menu actions: autoscan toggle, quality cycle, status, help."""
|
||||||
global _alert_min_quality
|
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
|
chat_id = query.message.chat.id
|
||||||
if chat_id in _autoscan_chats:
|
if chat_id in _autoscan_chats:
|
||||||
_autoscan_chats.discard(chat_id)
|
_autoscan_chats.discard(chat_id)
|
||||||
if not _autoscan_chats:
|
if not _feed_active():
|
||||||
_stop_scheduler()
|
_stop_scheduler()
|
||||||
await query.answer("Auto-scan stopped")
|
await query.answer("Auto-scan stopped")
|
||||||
|
else:
|
||||||
|
await query.answer("DM alerts off — channel feed continues")
|
||||||
else:
|
else:
|
||||||
_autoscan_chats.add(chat_id)
|
_autoscan_chats.add(chat_id)
|
||||||
_start_scheduler(context.bot)
|
_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)
|
await query.message.reply_text(_HELP_TEXT, parse_mode=ParseMode.MARKDOWN)
|
||||||
|
|
||||||
|
|
||||||
async def _cb_scan_all(query) -> None:
|
async def _cb_scan_all(query, symbol: str = XAUUSD) -> None:
|
||||||
await _safe_edit(query, "⏳ Scanning XAU/USD across M5 / M15 / H1 / H4…")
|
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]
|
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)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
reports = []
|
reports = []
|
||||||
@@ -968,7 +1108,7 @@ async def _cb_scan_all(query) -> None:
|
|||||||
_update_level_cache(reports)
|
_update_level_cache(reports)
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"📊 *XAU/USD — All Timeframes*",
|
f"📊 *{display} — All Timeframes*",
|
||||||
f"🕐 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
|
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(err)
|
||||||
|
|
||||||
lines.append("_Use buttons below for detailed single-TF view._")
|
lines.append("_Use buttons below for detailed single-TF view._")
|
||||||
|
lines += ["", _DISCLAIMER]
|
||||||
|
|
||||||
await _safe_edit(query, "\n".join(lines), reply_markup=main_menu_keyboard())
|
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."""
|
"""Register command menu and start scheduler on boot."""
|
||||||
await app.bot.set_my_commands([
|
await app.bot.set_my_commands([
|
||||||
("start", "Welcome & scan buttons"),
|
("start", "Welcome & scan buttons"),
|
||||||
("scan", "Quick XAU/USD scan buttons"),
|
("scan", "Quick scan buttons"),
|
||||||
("autoscan", "Toggle auto-scan on/off"),
|
("autoscan", "Toggle auto-scan on/off"),
|
||||||
("status", "Bot status, WS connection, live prices"),
|
("status", "Bot status, WS connection, live prices"),
|
||||||
("cache", "Show data cache age per pair/TF"),
|
("cache", "Show data cache age per pair/TF"),
|
||||||
@@ -1016,13 +1157,16 @@ async def _post_init(app: Application) -> None:
|
|||||||
("help", "All commands"),
|
("help", "All commands"),
|
||||||
])
|
])
|
||||||
|
|
||||||
if not CHAT_ID:
|
if CHAT_ID:
|
||||||
logger.info("TELEGRAM_CHAT_ID not set — auto-scan disabled on boot. "
|
_autoscan_chats.add(int(CHAT_ID))
|
||||||
"Use /autoscan in chat to enable.")
|
|
||||||
|
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
|
return
|
||||||
|
|
||||||
chat_id = int(CHAT_ID)
|
if CHANNEL_ID is not None:
|
||||||
_autoscan_chats.add(chat_id)
|
logger.info(f"Subscriber channel feed enabled: {CHANNEL_ID}")
|
||||||
|
|
||||||
logger.info("Running initial scan to populate level cache and signal stats...")
|
logger.info("Running initial scan to populate level cache and signal stats...")
|
||||||
try:
|
try:
|
||||||
@@ -1048,23 +1192,28 @@ async def _post_init(app: Application) -> None:
|
|||||||
next_h1 = _seconds_until_boundary(60, buffer=10)
|
next_h1 = _seconds_until_boundary(60, buffer=10)
|
||||||
next_h4 = _seconds_until_boundary(240, buffer=10)
|
next_h4 = _seconds_until_boundary(240, buffer=10)
|
||||||
logger.info(
|
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"
|
f"M5 {next_m5:.0f}s, M15 {next_m15:.0f}s, H1 {next_h1:.0f}s, H4 {next_h4:.0f}s"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
# Boot message goes to the owner DM only — the subscriber channel
|
||||||
await app.bot.send_message(
|
# receives nothing but signal and breach alerts.
|
||||||
chat_id,
|
if OWNER_ID is not None:
|
||||||
f"🤖 *ForexBot2 started*\n\n"
|
channel_line = "📢 Subscriber channel feed active.\n" if CHANNEL_ID else ""
|
||||||
f"Auto-scanning XAU/USD on M5/M15/H1/H4.\n"
|
try:
|
||||||
f"📡 Live price monitoring active.\n"
|
await app.bot.send_message(
|
||||||
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
|
OWNER_ID,
|
||||||
f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
|
f"🤖 *ForexBot2 started*\n\n"
|
||||||
f"/autoscan to stop",
|
f"Auto-scanning {_PAIRS_LABEL} on M5/M15/H1/H4.\n"
|
||||||
parse_mode=ParseMode.MARKDOWN,
|
f"📡 Live price monitoring active.\n"
|
||||||
)
|
f"{channel_line}"
|
||||||
except Exception:
|
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
|
||||||
logger.exception("Failed to send boot message")
|
f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
|
||||||
|
f"/autoscan to stop",
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to send boot message")
|
||||||
|
|
||||||
|
|
||||||
# ── Graceful shutdown ─────────────────────────────────────────────────
|
# ── Graceful shutdown ─────────────────────────────────────────────────
|
||||||
|
|||||||
+536
-2
@@ -1203,6 +1203,43 @@ def _():
|
|||||||
assert report.action_recommendation != ""
|
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")
|
@test("SignalAggregator._generate_trade_setup BUY direction")
|
||||||
def _():
|
def _():
|
||||||
config = PlatformConfig()
|
config = PlatformConfig()
|
||||||
@@ -1415,6 +1452,7 @@ def _():
|
|||||||
@test("build_config sets correct timeframe and confirmations")
|
@test("build_config sets correct timeframe and confirmations")
|
||||||
def _():
|
def _():
|
||||||
class FakeArgs:
|
class FakeArgs:
|
||||||
|
pair = "XAUUSD"
|
||||||
timeframe = "H4"
|
timeframe = "H4"
|
||||||
config = build_config(FakeArgs())
|
config = build_config(FakeArgs())
|
||||||
assert config.primary_timeframe == Timeframe.H4
|
assert config.primary_timeframe == Timeframe.H4
|
||||||
@@ -1426,11 +1464,23 @@ def _():
|
|||||||
def _():
|
def _():
|
||||||
for tf_str in ["M5", "M15", "M30", "H1", "H4", "D1"]:
|
for tf_str in ["M5", "M15", "M30", "H1", "H4", "D1"]:
|
||||||
class FakeArgs:
|
class FakeArgs:
|
||||||
|
pair = "XAUUSD"
|
||||||
timeframe = tf_str
|
timeframe = tf_str
|
||||||
config = build_config(FakeArgs())
|
config = build_config(FakeArgs())
|
||||||
assert config.primary_timeframe is not None
|
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
|
# MODULE 7: telegram_bot.py
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
@@ -1609,10 +1659,13 @@ def _():
|
|||||||
|
|
||||||
@test("SCAN_BUTTONS and SCAN_LOOKUP consistency")
|
@test("SCAN_BUTTONS and SCAN_LOOKUP consistency")
|
||||||
def _():
|
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:
|
for btn in SCAN_BUTTONS:
|
||||||
assert btn["cb"] in SCAN_LOOKUP
|
assert btn["cb"] in SCAN_LOOKUP
|
||||||
assert "symbol" in btn
|
assert "symbol" in btn
|
||||||
|
assert btn["symbol"] in SCAN_PAIRS
|
||||||
assert "tf" in btn
|
assert "tf" in btn
|
||||||
assert "label" in btn
|
assert "label" in btn
|
||||||
|
|
||||||
@@ -1642,8 +1695,11 @@ def _():
|
|||||||
_level_cache_ts.clear()
|
_level_cache_ts.clear()
|
||||||
|
|
||||||
|
|
||||||
@test("_WS_TO_PAIR mapping is correct")
|
@test("_WS_TO_PAIR mapping covers every scanned pair")
|
||||||
def _():
|
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
|
assert _WS_TO_PAIR["XAU/USD"] == XAUUSD
|
||||||
|
|
||||||
|
|
||||||
@@ -1761,6 +1817,484 @@ def _():
|
|||||||
assert r1.market_regime == r2.market_regime
|
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
|
# SUMMARY
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user