Compare commits
10 Commits
ec33f8b718
...
27bccf3bf5
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
@@ -92,7 +98,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
|
||||||
|
|
||||||
@@ -105,7 +111,7 @@ python backtest/gpu_backtest.py --pair XAUUSD --days 365
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,14 +2117,23 @@ 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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Per-pair tuned configs come from the pair_signals factories (regime
|
||||||
|
# weights, strategy gates, sessions). Unregistered pairs fall back to
|
||||||
|
# the inline construction.
|
||||||
|
from pair_signals import PAIR_PRODUCERS # noqa: E402 (after sys.path fix)
|
||||||
|
factory = PAIR_PRODUCERS.get(pair)
|
||||||
|
if factory is not None:
|
||||||
|
platform_cfg = factory(primary_tf, confirm_tfs)
|
||||||
|
else:
|
||||||
platform_cfg = PlatformConfig(
|
platform_cfg = PlatformConfig(
|
||||||
symbol=pair,
|
symbol=pair,
|
||||||
display_name=PAIR_INFO[pair]["display"],
|
display_name=PAIR_INFO[pair]["display"],
|
||||||
|
|||||||
@@ -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,7 +165,7 @@ 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
|
||||||
# Wider stay-out window after a high-impact release that surprised
|
# Wider stay-out window after a high-impact release that surprised
|
||||||
@@ -252,6 +259,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 +330,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,121 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
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 do not treat M5 signals as having a
|
||||||
|
validated edge. M15 tuning is a future session.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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,29 @@
|
|||||||
|
"""XAU/USD signal producer config.
|
||||||
|
|
||||||
|
PlatformConfig defaults ARE the tuned XAU/USD configuration, so this
|
||||||
|
factory only sets the timeframes. It must stay equivalent to the inline
|
||||||
|
PlatformConfig(symbol=XAUUSD, ...) construction used by the backtester
|
||||||
|
and Telegram bot (enforced by a test in test_all.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
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])
|
||||||
|
)
|
||||||
|
return config
|
||||||
+179
-9
@@ -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"):
|
||||||
@@ -1195,6 +1333,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 +1343,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,6 +1350,28 @@ 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
|
||||||
|
|
||||||
|
_is_eur = report.display_name == "EUR/USD" and self.config.eur_strategy.enabled
|
||||||
|
|
||||||
|
if _is_eur:
|
||||||
|
# EUR geometry reads only EurUsdStrategyConfig — nothing
|
||||||
|
# gold-calibrated from SignalConfig applies here.
|
||||||
|
eur = self.config.eur_strategy
|
||||||
|
min_sl_price = eur.min_sl_pips * pip_value
|
||||||
|
min_rr = eur.min_rr_ratio
|
||||||
|
# No TP-tightening interplay for EUR: the RR floor used for
|
||||||
|
# target selection IS min_rr (tp_tightening_factor is 1.0;
|
||||||
|
# revisit if EUR ever tunes it).
|
||||||
|
pre_tight_min_rr = min_rr
|
||||||
|
mature_move = abs(report.final_score) > eur.mature_move_threshold
|
||||||
|
quality_scale = eur.tp_quality_scale.get(report.signal_quality, 1.0)
|
||||||
|
tp_mult = (eur.tp_atr_multiplier if mature_move
|
||||||
|
else eur.tp_atr_multiplier * 1.5) * quality_scale
|
||||||
|
tp_swing_cap = (2.0 if mature_move else 2.5) * quality_scale
|
||||||
|
sl_atr_floor = eur.sl_atr_floor
|
||||||
|
sl_swing_buffer = eur.sl_atr_multiplier
|
||||||
|
else:
|
||||||
|
min_sl_price = self.config.signals.min_sl_pips * pip_value
|
||||||
min_rr = self.config.signals.min_rr_ratio
|
min_rr = self.config.signals.min_rr_ratio
|
||||||
tp_floor = max(min_rr, self.config.signals.tp_floor_rr)
|
tp_floor = max(min_rr, self.config.signals.tp_floor_rr)
|
||||||
tf = self.config.signals.tp_tightening_factor
|
tf = self.config.signals.tp_tightening_factor
|
||||||
@@ -1222,6 +1382,7 @@ class SignalAggregator:
|
|||||||
|
|
||||||
sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier
|
sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier
|
||||||
if report.display_name == "XAU/USD" else 1.5)
|
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 +1390,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 +1420,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,6 +1466,15 @@ 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).
|
||||||
|
if _is_eur:
|
||||||
|
min_tp = eur.round_trip_cost * eur.min_tp_cost_multiple
|
||||||
|
if tp1_distance < min_tp:
|
||||||
|
report.warnings.append(
|
||||||
|
f"SPREAD GATE: target {tp1_distance / pip_value:.1f} pips "
|
||||||
|
f"< {min_tp / pip_value:.1f} pip minimum — costs would eat the edge"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
|
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
|
||||||
if tp1_distance < min_tp:
|
if tp1_distance < min_tp:
|
||||||
report.warnings.append(
|
report.warnings.append(
|
||||||
|
|||||||
+89
-43
@@ -26,6 +26,7 @@ Usage:
|
|||||||
import asyncio
|
import asyncio
|
||||||
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 +52,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")
|
||||||
@@ -70,12 +80,15 @@ TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9
|
|||||||
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
|
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
|
||||||
|
|
||||||
# ── 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 +98,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 = {
|
||||||
@@ -258,7 +293,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:
|
||||||
@@ -312,29 +347,31 @@ def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
|
|||||||
|
|
||||||
# ── Keyboard helper ───────────────────────────────────────────────────
|
# ── Keyboard helper ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def scan_keyboard() -> InlineKeyboardMarkup:
|
def _scan_button_rows() -> List[List[InlineKeyboardButton]]:
|
||||||
tf_buttons = [b for b in SCAN_BUTTONS if b["tf"] is not None]
|
"""Per pair: one row of TF buttons + one scan-all row."""
|
||||||
all_button = next((b for b in SCAN_BUTTONS if b["tf"] is None), None)
|
rows = []
|
||||||
rows = [[
|
for symbol in SCAN_PAIRS:
|
||||||
InlineKeyboardButton(b["label"], callback_data=b["cb"])
|
tf_buttons = [b for b in SCAN_BUTTONS
|
||||||
for b in tf_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:
|
if all_button:
|
||||||
rows.append([InlineKeyboardButton(all_button["label"], callback_data=all_button["cb"])])
|
rows.append([InlineKeyboardButton(all_button["label"],
|
||||||
return InlineKeyboardMarkup(rows)
|
callback_data=all_button["cb"])])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def scan_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardMarkup(_scan_button_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,6 +411,13 @@ def _run_signal_sync(
|
|||||||
confirmation_tfs = TF_CONFIRMATIONS.get(
|
confirmation_tfs = TF_CONFIRMATIONS.get(
|
||||||
timeframe, [Timeframe.M15, Timeframe.H4]
|
timeframe, [Timeframe.M15, Timeframe.H4]
|
||||||
)
|
)
|
||||||
|
# Per-pair tuned configs (regime weights, gates, sessions) come from
|
||||||
|
# the pair_signals factories; a plain PlatformConfig would silently
|
||||||
|
# run every pair with XAU defaults.
|
||||||
|
factory = PAIR_PRODUCERS.get(symbol)
|
||||||
|
if factory is not None:
|
||||||
|
config = factory(timeframe, confirmation_tfs)
|
||||||
|
else:
|
||||||
config = PlatformConfig()
|
config = PlatformConfig()
|
||||||
config.primary_timeframe = timeframe
|
config.primary_timeframe = timeframe
|
||||||
config.confirmation_timeframes = confirmation_tfs
|
config.confirmation_timeframes = confirmation_tfs
|
||||||
@@ -717,7 +761,7 @@ def _stop_scheduler() -> None:
|
|||||||
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 +779,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"
|
||||||
@@ -868,7 +912,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"
|
||||||
@@ -879,7 +923,7 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
|||||||
|
|
||||||
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(),
|
||||||
)
|
)
|
||||||
@@ -894,10 +938,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"])
|
||||||
@@ -949,11 +994,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 +1014,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')}",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
@@ -1008,7 +1054,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"),
|
||||||
@@ -1056,7 +1102,7 @@ async def _post_init(app: Application) -> None:
|
|||||||
await app.bot.send_message(
|
await app.bot.send_message(
|
||||||
chat_id,
|
chat_id,
|
||||||
f"🤖 *ForexBot2 started*\n\n"
|
f"🤖 *ForexBot2 started*\n\n"
|
||||||
f"Auto-scanning XAU/USD on M5/M15/H1/H4.\n"
|
f"Auto-scanning {_PAIRS_LABEL} on M5/M15/H1/H4.\n"
|
||||||
f"📡 Live price monitoring active.\n"
|
f"📡 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"
|
||||||
|
|||||||
+495
-2
@@ -1415,6 +1415,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 +1427,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 +1622,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 +1658,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 +1780,480 @@ 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,
|
||||||
|
)
|
||||||
|
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