Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27bccf3bf5 | |||
| 2d704753d6 | |||
| 538977115b | |||
| e052c7aa3e | |||
| e0789457dc | |||
| 3b5c0b27ed | |||
| 77604960f5 | |||
| 668b7dad51 | |||
| 88970e750b | |||
| 7470e093e1 |
@@ -1,6 +1,6 @@
|
||||
# ForexBot2
|
||||
|
||||
XAU/USD signal intelligence platform. Analyzes gold across multiple timeframes using 9 technical indicators with regime-adaptive weights, news sentiment, and economic calendar data. Delivers signals via Telegram with full trade setups (entry, SL, TP).
|
||||
Multi-pair signal intelligence platform (XAU/USD + EUR/USD). Analyzes each pair across multiple timeframes using 9 technical indicators with regime-adaptive weights, news sentiment, and economic calendar data. Delivers signals via Telegram with full trade setups (entry, SL, TP).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -13,26 +13,31 @@ Twelve Data REST API + WebSocket
|
||||
|
|
||||
signal_aggregator.py (MTF agreement + news + final signal + trade setup)
|
||||
|
|
||||
pair_signals/ (per-pair config factories: xauusd.py, eurusd.py)
|
||||
|
|
||||
+-----+--------+
|
||||
| |
|
||||
telegram_bot.py main.py
|
||||
(Telegram alerts, (CLI single-pair
|
||||
(Telegram alerts, (CLI per-pair
|
||||
auto-scanner, analysis)
|
||||
WS monitor)
|
||||
```
|
||||
|
||||
**Per-pair modularity:** each pair registers a `make_config(primary_tf, confirmation_tfs) -> PlatformConfig` factory in `pair_signals/`. `PlatformConfig.scan_pairs` is the single switch for which pairs the bot scans (single- or multi-pair). Market constants (pip value, price precision, backtest default spread) live in `PAIR_INFO`; strategy knobs live in per-pair dataclasses (`XauStrategyConfig`, `EurUsdStrategyConfig`). All XAU-specific gates in the aggregator are display-gated — nothing gold-calibrated touches the EUR path, and vice versa (enforced by pair-isolation tests + a pinned-window XAU regression CSV).
|
||||
|
||||
## Core Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `config.py` | All configuration dataclasses, XAU/USD pair definition, strategy config |
|
||||
| `config.py` | All configuration dataclasses, `PAIR_INFO` registry, per-pair strategy configs |
|
||||
| `data_provider.py` | Twelve Data SDK wrapper with in-memory cache and rate limiting |
|
||||
| `engine.py` | `TechnicalEngine` — computes EMA, RSI, BB, ADX, ATR, MACD, ROC, Keltner, divergences, candlestick patterns, swing S/R |
|
||||
| `signal_aggregator.py` | `SignalAggregator` — MTF analysis, news integration, diminishing penalty cap, trade setup generation |
|
||||
| `news_engine.py` | Economic calendar (Forex Factory JSON) + RSS sentiment (keyword-based) |
|
||||
| `telegram_bot.py` | Async Telegram bot with auto-scanner (M5/M15/H1/H4), WebSocket price monitor |
|
||||
| `main.py` | CLI entry point for XAU/USD analysis |
|
||||
| `test_all.py` | Comprehensive QA suite (~137 tests, no live API needed) |
|
||||
| `pair_signals/` | Per-pair producer registry (`PAIR_PRODUCERS`) + config factories |
|
||||
| `main.py` | CLI entry point (`--pair XAUUSD\|EURUSD`) |
|
||||
| `test_all.py` | Comprehensive QA suite (~176 tests, no live API needed) |
|
||||
|
||||
## Key Concepts
|
||||
|
||||
@@ -60,9 +65,9 @@ ROC is directionally redundant with MACD/Keltner/EMA (0.8–0.94 correlation) bu
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# CLI (XAU/USD analysis)
|
||||
python main.py # H1 default
|
||||
python main.py --timeframe M15 --detail --json
|
||||
# CLI
|
||||
python main.py # XAU/USD H1 default
|
||||
python main.py --pair EURUSD --timeframe M5 --detail
|
||||
|
||||
# Telegram bot (auto-scanner)
|
||||
TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py
|
||||
@@ -70,8 +75,9 @@ TELEGRAM_TOKEN=<token> TELEGRAM_CHAT_ID=<id> python telegram_bot.py
|
||||
# Tests
|
||||
python test_all.py
|
||||
|
||||
# Backtest
|
||||
# Backtest (spread defaults per pair: XAU $0.30, EUR 0.0001 — see PAIR_INFO)
|
||||
python backtest/gpu_backtest.py --pair XAUUSD --days 365
|
||||
python backtest/gpu_backtest.py --pair EURUSD --primary-tf 5min --confirm-tfs 15min,1h --days 365
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
@@ -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
|
||||
- **Cache:** In-memory dict, TTL = candle duration of the timeframe
|
||||
- **Validation:** Drops NaN rows, forward-fills gaps (limit 3), fixes OHLC inconsistencies, removes outliers (>10σ), strips future timestamps, deduplicates
|
||||
- **WebSocket:** XAU/USD only
|
||||
- **WebSocket:** subscribes every pair in `scan_pairs`
|
||||
|
||||
## Telegram Bot
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+32
-15
@@ -48,6 +48,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from config import ( # noqa: E402
|
||||
EURUSD,
|
||||
PAIR_INFO,
|
||||
PlatformConfig,
|
||||
SignalStrength,
|
||||
@@ -96,6 +97,9 @@ PAIR_ALIASES = {
|
||||
"XAUUSD": XAUUSD,
|
||||
"XAU/USD": XAUUSD,
|
||||
"GOLD": XAUUSD,
|
||||
"EURUSD": EURUSD,
|
||||
"EUR/USD": EURUSD,
|
||||
"EUR": EURUSD,
|
||||
}
|
||||
|
||||
# Bar duration in seconds — used to compute "fully-closed" cutoffs so
|
||||
@@ -1632,8 +1636,8 @@ def run_backtest(
|
||||
tag = "XAU_PB"
|
||||
elif "XAU STRATEGY" in first_bull:
|
||||
tag = "XAU"
|
||||
elif "EUR STRATEGY" in first_bull:
|
||||
tag = "EUR_PB"
|
||||
elif "EUR STRATEGY" in first_bull or "EUR STRATEGY" in first_bear:
|
||||
tag = "EUR"
|
||||
elif "GBP STRATEGY" in first_bull:
|
||||
tag = "GBP_PB"
|
||||
|
||||
@@ -2034,7 +2038,7 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
|
||||
description="GPU-accelerated backtester for ForexBot2",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
p.add_argument("--pair", required=True, help="XAUUSD | XAU/USD | GOLD")
|
||||
p.add_argument("--pair", required=True, help="XAUUSD | GOLD | EURUSD")
|
||||
p.add_argument("--days", type=int, default=180, help="Days of history (ignored if --start/--end set)")
|
||||
p.add_argument("--start", help="ISO date YYYY-MM-DD (overrides --days)")
|
||||
p.add_argument("--end", help="ISO date YYYY-MM-DD (defaults to today)")
|
||||
@@ -2047,10 +2051,11 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
|
||||
help="Override TechnicalConfig.swing_sr_lookback (0 = use config default)")
|
||||
p.add_argument("--quality", default="A+,A,B,C", help="Comma-separated allowed grades")
|
||||
p.add_argument("--strategies", default="",
|
||||
help="Comma-separated strategy tags to include (XAU,EUR_PB,GBP_PB,GENERIC). Empty = all.")
|
||||
help="Comma-separated strategy tags to include (XAU,XAU_PB,EUR,GENERIC). Empty = all.")
|
||||
p.add_argument("--no-cache", action="store_true", help="Skip parquet cache (force API fetch)")
|
||||
p.add_argument("--spread", type=float, default=0.30,
|
||||
help="Round-trip cost in price units per trade (XAU ~$0.30; 0 disables)")
|
||||
p.add_argument("--spread", type=float, default=None,
|
||||
help="Round-trip cost in price units per trade (default: pair's "
|
||||
"default_spread from PAIR_INFO — XAU $0.30, EUR 0.0001; 0 disables)")
|
||||
p.add_argument("--export-csv", help="Optional path to dump per-trade CSV (incl. indicator breakdown)")
|
||||
p.add_argument("--detail", action="store_true",
|
||||
help="Print full per-trade reasoning (indicators, signals, agreement, why entered)")
|
||||
@@ -2097,6 +2102,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
else:
|
||||
start = end - timedelta(days=args.days)
|
||||
|
||||
spread = (args.spread if args.spread is not None
|
||||
else PAIR_INFO[pair].get("default_spread", 0.30))
|
||||
|
||||
bt_cfg = BacktestConfig(
|
||||
pair=pair,
|
||||
start=start,
|
||||
@@ -2109,21 +2117,30 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
use_cache=not args.no_cache,
|
||||
quality_filter=[q.strip() for q in args.quality.split(",") if q.strip()],
|
||||
strategies_only=[s.strip() for s in args.strategies.split(",") if s.strip()],
|
||||
spread=args.spread,
|
||||
spread=spread,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Backend: {'CuPy GPU' if _GPU_AVAILABLE else 'NumPy CPU fallback'} | "
|
||||
f"Pair: {PAIR_INFO[pair]['display']} | Window: {start.date()} → {end.date()}"
|
||||
f"Pair: {PAIR_INFO[pair]['display']} | Window: {start.date()} → {end.date()} | "
|
||||
f"Spread: {spread}"
|
||||
)
|
||||
|
||||
platform_cfg = PlatformConfig(
|
||||
symbol=pair,
|
||||
display_name=PAIR_INFO[pair]["display"],
|
||||
pip_value=PAIR_INFO[pair]["pip_value"],
|
||||
primary_timeframe=primary_tf,
|
||||
confirmation_timeframes=confirm_tfs,
|
||||
)
|
||||
# Per-pair tuned configs come from the pair_signals factories (regime
|
||||
# weights, strategy gates, sessions). Unregistered pairs fall back to
|
||||
# the inline construction.
|
||||
from pair_signals import PAIR_PRODUCERS # noqa: E402 (after sys.path fix)
|
||||
factory = PAIR_PRODUCERS.get(pair)
|
||||
if factory is not None:
|
||||
platform_cfg = factory(primary_tf, confirm_tfs)
|
||||
else:
|
||||
platform_cfg = PlatformConfig(
|
||||
symbol=pair,
|
||||
display_name=PAIR_INFO[pair]["display"],
|
||||
pip_value=PAIR_INFO[pair]["pip_value"],
|
||||
primary_timeframe=primary_tf,
|
||||
confirmation_timeframes=confirm_tfs,
|
||||
)
|
||||
if args.swing_sr_lookback > 0:
|
||||
platform_cfg.technical.swing_sr_lookback = args.swing_sr_lookback
|
||||
|
||||
|
||||
@@ -37,9 +37,16 @@ class MarketSession(Enum):
|
||||
|
||||
# ── Supported pairs ──────────────────────────────────────────────
|
||||
XAUUSD = "XAU/USD"
|
||||
EURUSD = "EUR/USD"
|
||||
|
||||
# price_precision: decimals for SL/TP rounding and display.
|
||||
# default_spread: round-trip cost per trade in price units, charged by
|
||||
# the backtester when --spread is not given.
|
||||
PAIR_INFO = {
|
||||
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False},
|
||||
XAUUSD: {"display": "XAU/USD", "pip_value": 0.10, "is_jpy_pair": False,
|
||||
"price_precision": 2, "default_spread": 0.30},
|
||||
EURUSD: {"display": "EUR/USD", "pip_value": 0.0001, "is_jpy_pair": False,
|
||||
"price_precision": 5, "default_spread": 0.0001},
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +165,7 @@ class SignalConfig:
|
||||
@dataclass
|
||||
class NewsConfig:
|
||||
"""Economic calendar & sentiment."""
|
||||
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU"])
|
||||
tracked_currencies: List[str] = field(default_factory=lambda: ["USD", "XAU", "EUR"])
|
||||
pre_event_buffer_minutes: int = 30
|
||||
post_event_buffer_minutes: int = 15
|
||||
# Wider stay-out window after a high-impact release that surprised
|
||||
@@ -252,6 +259,54 @@ class XauStrategyConfig:
|
||||
m5_pullback_touch_bars: int = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class EurUsdStrategyConfig:
|
||||
"""EUR/USD strategy config — separate from XAU for independent tuning.
|
||||
|
||||
Values ported from feature/eurusd-support are a pre-realism baseline
|
||||
(tuned before spread charging and next-open fills) and are re-tuned
|
||||
against the realistic backtester. Cost knobs (round_trip_cost,
|
||||
min_tp_cost_multiple, min_sl_pips) are EUR-local so nothing
|
||||
gold-calibrated from SignalConfig leaks into the EUR path.
|
||||
"""
|
||||
enabled: bool = True
|
||||
blocked_hours_utc: List[int] = field(default_factory=lambda: [22, 23, 0, 1, 2, 3])
|
||||
weekend_filter: bool = True
|
||||
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4])
|
||||
min_atr_threshold: float = 0.0003
|
||||
sl_atr_multiplier: float = 1.5 # swing-buffer ATR multiple for SL placement
|
||||
tp_atr_multiplier: float = 1.2
|
||||
|
||||
sl_atr_floor: float = 1.2
|
||||
min_rr_ratio: float = 1.0
|
||||
mature_move_threshold: float = 35.0
|
||||
tp_quality_scale: Dict[str, float] = field(default_factory=lambda: {
|
||||
"A+": 1.0, "A": 1.0, "B": 1.0, "C": 1.0,
|
||||
})
|
||||
blocked_regimes: List[str] = field(default_factory=lambda: ["ranging", "transitioning"])
|
||||
min_quality_grades: List[str] = field(default_factory=lambda: ["A+", "A", "B"])
|
||||
max_exhaustion_tags: int = 99
|
||||
block_near_ema200: bool = False
|
||||
session_hours: List[int] = field(default_factory=list)
|
||||
min_entry_score: float = 38.0
|
||||
min_entry_confidence: float = 60.0
|
||||
block_rsi_extended: bool = True
|
||||
blocked_vol_regimes: List[str] = field(default_factory=list)
|
||||
min_indicator_agreement: int = 0
|
||||
be_trigger_r: float = 0.3
|
||||
be_buffer_r: float = 0.15
|
||||
|
||||
# ── EUR-local trade-cost knobs (replace SignalConfig's gold-dollar
|
||||
# values in the EUR path) ─────────────────────────────────────────
|
||||
# Round-trip cost per trade in price units (1.0 pip).
|
||||
round_trip_cost: float = 0.0001
|
||||
# Reject setups whose TP1 distance < round_trip_cost × this multiple
|
||||
# (10 → 10-pip minimum target; EUR M5 ATR runs ~5-8 pips).
|
||||
min_tp_cost_multiple: float = 10.0
|
||||
# Minimum SL distance in pips.
|
||||
min_sl_pips: float = 8.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterConfig:
|
||||
"""Regime and session filters for live signals."""
|
||||
@@ -275,6 +330,7 @@ class PlatformConfig:
|
||||
news: NewsConfig = field(default_factory=NewsConfig)
|
||||
backtest: FilterConfig = field(default_factory=FilterConfig)
|
||||
xau_strategy: XauStrategyConfig = field(default_factory=XauStrategyConfig)
|
||||
eur_strategy: EurUsdStrategyConfig = field(default_factory=EurUsdStrategyConfig)
|
||||
|
||||
symbol: str = XAUUSD
|
||||
display_name: str = "XAU/USD"
|
||||
|
||||
@@ -492,7 +492,7 @@ class TechnicalEngine:
|
||||
plus_di = 100 * (plus_dm.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean() / atr)
|
||||
minus_di = 100 * (minus_dm.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean() / atr)
|
||||
|
||||
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan)
|
||||
dx = 100 * (plus_di - minus_di).abs() / np.maximum(plus_di + minus_di, 1e-10)
|
||||
adx = dx.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean()
|
||||
|
||||
adx_val = adx.iloc[-1] if not adx.empty and not pd.isna(adx.iloc[-1]) else 0.0
|
||||
@@ -602,8 +602,12 @@ class TechnicalEngine:
|
||||
if len(close) < n + 5:
|
||||
return
|
||||
|
||||
roc_val = ((close.iloc[-1] - close.iloc[-n - 1]) / close.iloc[-n - 1]) * 100
|
||||
roc_prev = ((close.iloc[-2] - close.iloc[-n - 2]) / close.iloc[-n - 2]) * 100
|
||||
denom1 = close.iloc[-n - 1]
|
||||
denom2 = close.iloc[-n - 2]
|
||||
if denom1 == 0 or denom2 == 0:
|
||||
return
|
||||
roc_val = ((close.iloc[-1] - denom1) / denom1) * 100
|
||||
roc_prev = ((close.iloc[-2] - denom2) / denom2) * 100
|
||||
|
||||
if pd.isna(roc_val) or pd.isna(roc_prev):
|
||||
return
|
||||
@@ -1154,12 +1158,14 @@ class TechnicalEngine:
|
||||
for i in range(window, len(vals) - window):
|
||||
if np.isnan(vals[i]):
|
||||
continue
|
||||
left_vals = [vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])]
|
||||
right_vals = [vals[i+j] for j in range(1, window+1) if i+j < len(vals) and not np.isnan(vals[i+j])]
|
||||
if not left_vals or not right_vals:
|
||||
continue
|
||||
if mode == "low":
|
||||
if all(vals[i] <= vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])) and \
|
||||
all(vals[i] <= vals[i+j] for j in range(1, window+1) if i+j < len(vals) and not np.isnan(vals[i+j])):
|
||||
if all(vals[i] <= v for v in left_vals) and all(vals[i] <= v for v in right_vals):
|
||||
points.append((i, vals[i]))
|
||||
else:
|
||||
if all(vals[i] >= vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])) and \
|
||||
all(vals[i] >= vals[i+j] for j in range(1, window+1) if i+j < len(vals) and not np.isnan(vals[i+j])):
|
||||
if all(vals[i] >= v for v in left_vals) and all(vals[i] >= v for v in right_vals):
|
||||
points.append((i, vals[i]))
|
||||
return points
|
||||
|
||||
@@ -21,7 +21,8 @@ if hasattr(sys.stdout, "reconfigure"):
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD
|
||||
from config import PlatformConfig, Timeframe, SignalStrength, XAUUSD, EURUSD
|
||||
from pair_signals import PAIR_PRODUCERS
|
||||
from signal_aggregator import SignalAggregator, SignalReport
|
||||
|
||||
|
||||
@@ -97,6 +98,11 @@ def _compact_reason(reason: str) -> str:
|
||||
return s.strip()
|
||||
|
||||
|
||||
def _fmt_price(price: float) -> str:
|
||||
"""2dp for gold-scale prices, 5dp for FX-scale (EUR/USD etc.)."""
|
||||
return f"{price:.2f}" if price >= 10 else f"{price:.5f}"
|
||||
|
||||
|
||||
def print_signal_report(report: SignalReport, detail: bool = False):
|
||||
sc = signal_color(report.final_signal)
|
||||
qc = quality_color(report.signal_quality)
|
||||
@@ -116,16 +122,16 @@ def print_signal_report(report: SignalReport, detail: bool = False):
|
||||
ts = report.trade_setup
|
||||
dc = Colors.GREEN if ts.direction == "BUY" else Colors.RED
|
||||
print(f"\n {Colors.BOLD}Setup{Colors.RESET} "
|
||||
f"{dc}{ts.direction}{Colors.RESET} @ {Colors.BOLD}{ts.entry_price:.3f}{Colors.RESET} "
|
||||
f"→ SL {Colors.RED}{ts.stop_loss:.3f}{Colors.RESET} "
|
||||
f"{dc}{ts.direction}{Colors.RESET} @ {Colors.BOLD}{_fmt_price(ts.entry_price)}{Colors.RESET} "
|
||||
f"→ SL {Colors.RED}{_fmt_price(ts.stop_loss)}{Colors.RESET} "
|
||||
f"({ts.risk_pips:.1f}p) "
|
||||
f"→ TP {Colors.GREEN}{ts.take_profit_1:.3f}{Colors.RESET} "
|
||||
f"→ TP {Colors.GREEN}{_fmt_price(ts.take_profit_1)}{Colors.RESET} "
|
||||
f"({ts.reward_pips:.1f}p) "
|
||||
f"RR 1:{ts.risk_reward:.1f}")
|
||||
|
||||
# ── 3. CONTEXT (price · regime · vol · MTF breakdown) ──────────
|
||||
print(f"\n {Colors.WHITE}{report.display_name}{Colors.RESET} "
|
||||
f"{Colors.BOLD}{report.current_price:.3f}{Colors.RESET} · "
|
||||
f"{Colors.BOLD}{_fmt_price(report.current_price)}{Colors.RESET} · "
|
||||
f"{Colors.CYAN}{report.market_regime}{Colors.RESET} · "
|
||||
f"vol {report.volatility_regime}")
|
||||
|
||||
@@ -250,6 +256,8 @@ Examples:
|
||||
python main.py --json JSON output
|
||||
""",
|
||||
)
|
||||
parser.add_argument("--pair", "-p", default="XAUUSD",
|
||||
help="Pair to analyze: XAUUSD | EURUSD (default: XAUUSD)")
|
||||
parser.add_argument("--timeframe", "-tf", default="H1",
|
||||
choices=["M5", "M15", "M30", "H1", "H4", "D1"],
|
||||
help="Primary analysis timeframe (default: H1)")
|
||||
@@ -263,14 +271,25 @@ Examples:
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _resolve_pair(s: str) -> str:
|
||||
aliases = {
|
||||
"XAUUSD": XAUUSD, "XAU/USD": XAUUSD, "GOLD": XAUUSD,
|
||||
"EURUSD": EURUSD, "EUR/USD": EURUSD, "EUR": EURUSD,
|
||||
}
|
||||
key = s.upper().strip()
|
||||
if key in aliases:
|
||||
return aliases[key]
|
||||
raise SystemExit(f"Unknown pair '{s}'. Try one of: {', '.join(sorted(aliases))}")
|
||||
|
||||
|
||||
def build_config(args) -> PlatformConfig:
|
||||
config = PlatformConfig()
|
||||
pair = _resolve_pair(args.pair)
|
||||
|
||||
tf_map = {
|
||||
"M5": Timeframe.M5, "M15": Timeframe.M15, "M30": Timeframe.M30,
|
||||
"H1": Timeframe.H1, "H4": Timeframe.H4, "D1": Timeframe.D1,
|
||||
}
|
||||
config.primary_timeframe = tf_map[args.timeframe]
|
||||
primary_tf = tf_map[args.timeframe]
|
||||
|
||||
tf_confirmations = {
|
||||
"M5": [Timeframe.M1, Timeframe.M15],
|
||||
@@ -280,23 +299,31 @@ def build_config(args) -> PlatformConfig:
|
||||
"H4": [Timeframe.H1, Timeframe.D1],
|
||||
"D1": [Timeframe.H4, Timeframe.W1],
|
||||
}
|
||||
config.confirmation_timeframes = tf_confirmations.get(
|
||||
confirmation_tfs = tf_confirmations.get(
|
||||
args.timeframe, [Timeframe.M15, Timeframe.H4]
|
||||
)
|
||||
|
||||
# Per-pair tuned configs from the pair_signals factories.
|
||||
factory = PAIR_PRODUCERS.get(pair)
|
||||
if factory is not None:
|
||||
return factory(primary_tf, confirmation_tfs)
|
||||
|
||||
config = PlatformConfig()
|
||||
config.primary_timeframe = primary_tf
|
||||
config.confirmation_timeframes = confirmation_tfs
|
||||
return config
|
||||
|
||||
|
||||
def run_live(args, config: PlatformConfig):
|
||||
print(f"{Colors.GRAY} Fetching market data and running analysis...{Colors.RESET}\n")
|
||||
aggregator = SignalAggregator(config)
|
||||
report = aggregator.analyze_pair()
|
||||
report = aggregator.analyze_pair(symbol=config.symbol)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(report_to_dict(report), indent=2))
|
||||
return
|
||||
|
||||
print_header("XAU/USD SIGNAL INTELLIGENCE")
|
||||
print_header(f"{config.display_name} SIGNAL INTELLIGENCE")
|
||||
print_signal_report(report, detail=args.detail)
|
||||
print(f"\n{Colors.CYAN}{'═' * 80}{Colors.RESET}")
|
||||
print(f"{Colors.GRAY} Disclaimer: For educational/research purposes only.{Colors.RESET}")
|
||||
|
||||
+2
-1
@@ -673,7 +673,8 @@ class NewsEngine:
|
||||
@staticmethod
|
||||
def _get_affected_pairs(currency: str) -> List[str]:
|
||||
_MAP = {
|
||||
"USD": ["XAU/USD"],
|
||||
"USD": ["XAU/USD", "EUR/USD"],
|
||||
"XAU": ["XAU/USD"],
|
||||
"EUR": ["EUR/USD"],
|
||||
}
|
||||
return _MAP.get(currency, [])
|
||||
|
||||
@@ -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
|
||||
+194
-24
@@ -157,6 +157,39 @@ class SignalAggregator:
|
||||
report.action_recommendation = f"NO TRADE — Only trading {allowed}"
|
||||
return report
|
||||
|
||||
if pair_info["display"] == "EUR/USD" and self.config.eur_strategy.enabled:
|
||||
eur_cfg = self.config.eur_strategy
|
||||
|
||||
if eur_cfg.weekend_filter and _is_forex_weekend(now_utc):
|
||||
report = SignalReport(
|
||||
symbol=symbol,
|
||||
display_name=pair_info["display"],
|
||||
timestamp=str(now_utc),
|
||||
)
|
||||
report.final_signal = SignalStrength.NEUTRAL
|
||||
report.signal_quality = "F"
|
||||
report.trade_setup = None
|
||||
report.warnings.append("WEEKEND FILTER: EUR/USD market closed — no liquidity")
|
||||
report.action_recommendation = "NO TRADE — Weekend (market closed)"
|
||||
return report
|
||||
|
||||
if eur_cfg.allowed_weekdays and now_utc.weekday() not in eur_cfg.allowed_weekdays:
|
||||
day_names = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 5: "Sat", 6: "Sun"}
|
||||
allowed = "/".join(day_names.get(d, str(d)) for d in eur_cfg.allowed_weekdays)
|
||||
report = SignalReport(
|
||||
symbol=symbol,
|
||||
display_name=pair_info["display"],
|
||||
timestamp=str(now_utc),
|
||||
)
|
||||
report.final_signal = SignalStrength.NEUTRAL
|
||||
report.signal_quality = "F"
|
||||
report.trade_setup = None
|
||||
report.warnings.append(
|
||||
f"DAY FILTER: {day_names.get(now_utc.weekday(), '?')} not in allowed days ({allowed})"
|
||||
)
|
||||
report.action_recommendation = f"NO TRADE — Only trading {allowed}"
|
||||
return report
|
||||
|
||||
timeframes = [self.config.primary_timeframe] + list(
|
||||
self.config.confirmation_timeframes
|
||||
)
|
||||
@@ -704,6 +737,7 @@ class SignalAggregator:
|
||||
or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0)
|
||||
)
|
||||
if (self.config.xau_strategy.enabled
|
||||
and report.display_name == "XAU/USD"
|
||||
and xau_is_sell_dir
|
||||
and report.signal_quality not in self.config.xau_strategy.sell_min_quality):
|
||||
report.warnings.append(
|
||||
@@ -734,6 +768,7 @@ class SignalAggregator:
|
||||
# Shorts in quiet markets fade against gold's macro uptrend;
|
||||
# only take sells when volatility regime confirms real selling.
|
||||
if (self.config.xau_strategy.enabled
|
||||
and report.display_name == "XAU/USD"
|
||||
and xau_is_sell_dir
|
||||
and report.signal_quality in ("A+", "A", "B", "C")
|
||||
and report.volatility_regime
|
||||
@@ -744,12 +779,103 @@ class SignalAggregator:
|
||||
report.signal_quality = "D"
|
||||
report.trade_setup = None
|
||||
|
||||
# ── 6h. M5 noise gate ────────────────────────────────────
|
||||
if self.config.primary_timeframe == Timeframe.M5:
|
||||
# ── 6h. EUR/USD regime + quality + risk gate ──────────────
|
||||
if report.display_name == "EUR/USD" and self.config.eur_strategy.enabled:
|
||||
eur = self.config.eur_strategy
|
||||
if eur.blocked_regimes and report.market_regime in eur.blocked_regimes:
|
||||
report.warnings.append(
|
||||
f"EUR REGIME FILTER: {report.market_regime} blocked"
|
||||
)
|
||||
report.signal_quality = "F"
|
||||
if (eur.min_quality_grades
|
||||
and report.signal_quality not in eur.min_quality_grades
|
||||
and report.signal_quality != "F"):
|
||||
report.warnings.append(
|
||||
f"EUR QUALITY FILTER: {report.signal_quality} below minimum"
|
||||
)
|
||||
report.signal_quality = "F"
|
||||
if (report.signal_quality != "F"
|
||||
and getattr(report, 'exhaustion_count', 0) > eur.max_exhaustion_tags):
|
||||
report.warnings.append(
|
||||
f"EUR EXHAUSTION FILTER: {report.exhaustion_count} tags > max {eur.max_exhaustion_tags}"
|
||||
)
|
||||
report.signal_quality = "F"
|
||||
if (report.signal_quality != "F"
|
||||
and eur.block_near_ema200
|
||||
and any(w.startswith("NEAR EMA200") for w in report.warnings)):
|
||||
report.warnings.append("EUR EMA200 FILTER: too close to EMA200")
|
||||
report.signal_quality = "F"
|
||||
if eur.session_hours and report.signal_quality != "F":
|
||||
try:
|
||||
bar_hour = pd.Timestamp(report.timestamp).hour
|
||||
if bar_hour not in eur.session_hours:
|
||||
report.warnings.append(
|
||||
f"EUR SESSION FILTER: {bar_hour:02}:00 UTC outside allowed hours"
|
||||
)
|
||||
report.signal_quality = "F"
|
||||
except Exception:
|
||||
pass
|
||||
if report.signal_quality != "F":
|
||||
abs_s = abs(report.final_score)
|
||||
if abs_s < eur.min_entry_score:
|
||||
report.warnings.append(
|
||||
f"EUR SCORE FILTER: |{abs_s:.1f}| < {eur.min_entry_score}"
|
||||
)
|
||||
report.signal_quality = "F"
|
||||
elif report.final_confidence < eur.min_entry_confidence:
|
||||
report.warnings.append(
|
||||
f"EUR CONF FILTER: {report.final_confidence} < {eur.min_entry_confidence}"
|
||||
)
|
||||
report.signal_quality = "F"
|
||||
if (report.signal_quality != "F"
|
||||
and eur.blocked_vol_regimes
|
||||
and report.volatility_regime):
|
||||
if report.volatility_regime in eur.blocked_vol_regimes:
|
||||
report.warnings.append(
|
||||
f"EUR VOL FILTER: {report.volatility_regime} volatility blocked"
|
||||
)
|
||||
report.signal_quality = "F"
|
||||
if (report.signal_quality != "F"
|
||||
and eur.min_indicator_agreement > 0
|
||||
and report.primary_analysis):
|
||||
agree_count = sum(
|
||||
1 for ind in report.primary_analysis.indicators.values()
|
||||
if ind.signal.value != 0
|
||||
and (ind.signal.value > 0) == (report.final_score > 0)
|
||||
)
|
||||
if agree_count < eur.min_indicator_agreement:
|
||||
report.warnings.append(
|
||||
f"EUR AGREE FILTER: {agree_count} < {eur.min_indicator_agreement}"
|
||||
)
|
||||
report.signal_quality = "F"
|
||||
if (report.signal_quality != "F"
|
||||
and eur.block_rsi_extended
|
||||
and report.primary_analysis):
|
||||
rsi_ind = report.primary_analysis.indicators.get("rsi")
|
||||
if rsi_ind:
|
||||
rsi_v = rsi_ind.value
|
||||
is_buy = report.final_score > 0
|
||||
if is_buy and rsi_v > 70:
|
||||
report.warnings.append(
|
||||
f"EUR RSI FILTER: RSI {rsi_v:.0f} overbought for BUY"
|
||||
)
|
||||
report.signal_quality = "F"
|
||||
elif not is_buy and rsi_v < 30:
|
||||
report.warnings.append(
|
||||
f"EUR RSI FILTER: RSI {rsi_v:.0f} oversold for SELL"
|
||||
)
|
||||
report.signal_quality = "F"
|
||||
|
||||
# ── 6i. M5 noise gate (gold-tuned thresholds — XAU only) ──
|
||||
if (self.config.primary_timeframe == Timeframe.M5
|
||||
and report.display_name == "XAU/USD"):
|
||||
self._apply_m5_filters(report)
|
||||
|
||||
# ── 7. Trade setup for actionable signals ──────────────────
|
||||
if report.trade_setup is None and report.signal_quality in ("A+", "A"):
|
||||
# ATR generator is XAU-only: it hardcodes gold pip value and
|
||||
# 2-decimal price rounding.
|
||||
if (report.trade_setup is None and report.signal_quality in ("A+", "A")
|
||||
and report.display_name == "XAU/USD"):
|
||||
atr = (report.primary_analysis.key_levels.get("atr", 0)
|
||||
if report.primary_analysis else 0)
|
||||
if atr > 0:
|
||||
@@ -760,11 +886,23 @@ class SignalAggregator:
|
||||
# ── 8. Reasons ─────────────────────────────────────────────
|
||||
self._collect_reasons(report)
|
||||
|
||||
# ── 8b. EUR strategy marker ────────────────────────────────
|
||||
# The backtester tags trades from reasons[0]; mark EUR setups so
|
||||
# they report under the EUR strategy instead of GENERIC.
|
||||
if (report.display_name == "EUR/USD" and self.config.eur_strategy.enabled
|
||||
and report.trade_setup is not None):
|
||||
marker = (f"EUR STRATEGY: {report.market_regime} regime, "
|
||||
f"score {report.final_score:+.1f}, quality {report.signal_quality}")
|
||||
target = (report.bullish_reasons if report.trade_setup.direction == "BUY"
|
||||
else report.bearish_reasons)
|
||||
target.insert(0, marker)
|
||||
|
||||
# ── 9. M5 pullback entry (XAU_PB) ──────────────────────────
|
||||
# Second archetype: when no breakout setup fired, look for a
|
||||
# trend-continuation pullback (retrace to EMA21/KC-mid). Runs
|
||||
# after reasons so its marker lands at reasons[0] for tagging.
|
||||
if (self.config.primary_timeframe == Timeframe.M5
|
||||
and report.display_name == "XAU/USD"
|
||||
and self.config.xau_strategy.m5_pullback_enabled
|
||||
and report.trade_setup is None
|
||||
and report.signal_quality != "F"):
|
||||
@@ -1195,6 +1333,8 @@ class SignalAggregator:
|
||||
atr = report.primary_analysis.key_levels.get("atr", 0)
|
||||
if atr == 0:
|
||||
return None
|
||||
if pip_value <= 0:
|
||||
return None
|
||||
|
||||
# Fix 2: Minimum ATR threshold for XAU/USD
|
||||
if report.display_name == "XAU/USD" and atr < self.config.xau_strategy.min_atr_threshold:
|
||||
@@ -1203,8 +1343,6 @@ class SignalAggregator:
|
||||
)
|
||||
return None
|
||||
|
||||
min_sl_price = self.config.signals.min_sl_pips * pip_value
|
||||
|
||||
if report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
|
||||
is_buy = True
|
||||
elif report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL):
|
||||
@@ -1212,16 +1350,39 @@ class SignalAggregator:
|
||||
else:
|
||||
is_buy = report.final_score >= 0
|
||||
levels = report.primary_analysis.key_levels
|
||||
min_rr = self.config.signals.min_rr_ratio
|
||||
tp_floor = max(min_rr, self.config.signals.tp_floor_rr)
|
||||
tf = self.config.signals.tp_tightening_factor
|
||||
pre_tight_min_rr = tp_floor / tf if tf > 0 else tp_floor
|
||||
|
||||
tp_mult = 1.5
|
||||
tp_swing_cap = 2.5
|
||||
_is_eur = report.display_name == "EUR/USD" and self.config.eur_strategy.enabled
|
||||
|
||||
sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier
|
||||
if report.display_name == "XAU/USD" else 1.5)
|
||||
if _is_eur:
|
||||
# EUR geometry reads only EurUsdStrategyConfig — nothing
|
||||
# gold-calibrated from SignalConfig applies here.
|
||||
eur = self.config.eur_strategy
|
||||
min_sl_price = eur.min_sl_pips * pip_value
|
||||
min_rr = eur.min_rr_ratio
|
||||
# No TP-tightening interplay for EUR: the RR floor used for
|
||||
# target selection IS min_rr (tp_tightening_factor is 1.0;
|
||||
# revisit if EUR ever tunes it).
|
||||
pre_tight_min_rr = min_rr
|
||||
mature_move = abs(report.final_score) > eur.mature_move_threshold
|
||||
quality_scale = eur.tp_quality_scale.get(report.signal_quality, 1.0)
|
||||
tp_mult = (eur.tp_atr_multiplier if mature_move
|
||||
else eur.tp_atr_multiplier * 1.5) * quality_scale
|
||||
tp_swing_cap = (2.0 if mature_move else 2.5) * quality_scale
|
||||
sl_atr_floor = eur.sl_atr_floor
|
||||
sl_swing_buffer = eur.sl_atr_multiplier
|
||||
else:
|
||||
min_sl_price = self.config.signals.min_sl_pips * pip_value
|
||||
min_rr = self.config.signals.min_rr_ratio
|
||||
tp_floor = max(min_rr, self.config.signals.tp_floor_rr)
|
||||
tf = self.config.signals.tp_tightening_factor
|
||||
pre_tight_min_rr = tp_floor / tf if tf > 0 else tp_floor
|
||||
|
||||
tp_mult = 1.5
|
||||
tp_swing_cap = 2.5
|
||||
|
||||
sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier
|
||||
if report.display_name == "XAU/USD" else 1.5)
|
||||
sl_swing_buffer = 1.0
|
||||
|
||||
if is_buy:
|
||||
swing_lows = sorted(
|
||||
@@ -1229,9 +1390,9 @@ class SignalAggregator:
|
||||
reverse=True,
|
||||
)
|
||||
if swing_lows:
|
||||
sl_price = swing_lows[0] - atr * 1.0
|
||||
sl_price = swing_lows[0] - atr * sl_swing_buffer
|
||||
else:
|
||||
sl_price = price - atr * 2.0
|
||||
sl_price = price - atr * (1.0 + sl_swing_buffer)
|
||||
min_sl = max(atr * sl_atr_floor, min_sl_price)
|
||||
if price - sl_price < min_sl:
|
||||
sl_price = price - min_sl
|
||||
@@ -1259,9 +1420,9 @@ class SignalAggregator:
|
||||
v for k, v in levels.items() if k.startswith("swing_high_") and v > price
|
||||
)
|
||||
if swing_highs:
|
||||
sl_price = swing_highs[0] + atr * 1.0
|
||||
sl_price = swing_highs[0] + atr * sl_swing_buffer
|
||||
else:
|
||||
sl_price = price + atr * 2.0
|
||||
sl_price = price + atr * (1.0 + sl_swing_buffer)
|
||||
min_sl = max(atr * sl_atr_floor, min_sl_price)
|
||||
if sl_price - price < min_sl:
|
||||
sl_price = price + min_sl
|
||||
@@ -1305,13 +1466,22 @@ class SignalAggregator:
|
||||
|
||||
# Spread gate: a target too close to entry hands most of the win
|
||||
# to transaction costs (validated on M5: sub-$10 targets lose).
|
||||
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
|
||||
if tp1_distance < min_tp:
|
||||
report.warnings.append(
|
||||
f"SPREAD GATE: target ${tp1_distance:.2f} < ${min_tp:.2f} minimum — "
|
||||
f"costs would eat the edge"
|
||||
)
|
||||
return None
|
||||
if _is_eur:
|
||||
min_tp = eur.round_trip_cost * eur.min_tp_cost_multiple
|
||||
if tp1_distance < min_tp:
|
||||
report.warnings.append(
|
||||
f"SPREAD GATE: target {tp1_distance / pip_value:.1f} pips "
|
||||
f"< {min_tp / pip_value:.1f} pip minimum — costs would eat the edge"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
|
||||
if tp1_distance < min_tp:
|
||||
report.warnings.append(
|
||||
f"SPREAD GATE: target ${tp1_distance:.2f} < ${min_tp:.2f} minimum — "
|
||||
f"costs would eat the edge"
|
||||
)
|
||||
return None
|
||||
|
||||
risk_pips = sl_distance / pip_value
|
||||
reward_pips = tp1_distance / pip_value
|
||||
|
||||
+92
-46
@@ -26,6 +26,7 @@ Usage:
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
@@ -51,17 +52,26 @@ from config import (
|
||||
XAUUSD, PAIR_INFO,
|
||||
)
|
||||
from data_provider import ForexDataProvider
|
||||
from pair_signals import PAIR_PRODUCERS
|
||||
from signal_aggregator import SignalAggregator, SignalReport
|
||||
|
||||
# ── Force UTF-8 on Windows ────────────────────────────────────────────
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
level=logging.INFO,
|
||||
datefmt="%H:%M:%S",
|
||||
_LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
|
||||
os.makedirs(_LOG_DIR, exist_ok=True)
|
||||
_log_formatter = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%H:%M:%S"
|
||||
)
|
||||
_console_handler = logging.StreamHandler()
|
||||
_console_handler.setFormatter(_log_formatter)
|
||||
_file_handler = logging.handlers.TimedRotatingFileHandler(
|
||||
os.path.join(_LOG_DIR, "telegram.log"),
|
||||
when="midnight", backupCount=30, encoding="utf-8",
|
||||
)
|
||||
_file_handler.setFormatter(_log_formatter)
|
||||
logging.basicConfig(level=logging.INFO, handlers=[_console_handler, _file_handler])
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
logger = logging.getLogger("telegram_bot")
|
||||
@@ -70,12 +80,15 @@ TOKEN = os.environ.get("TELEGRAM_TOKEN", "8228251480:AAFW2OAyQ2idMicql2t_CIXk1r9
|
||||
CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6025899022")
|
||||
|
||||
# ── Pairs & buttons ───────────────────────────────────────────────────
|
||||
# The scanned pair list is config-driven: PlatformConfig.scan_pairs is
|
||||
# the single switch for single- vs multi-pair operation. Buttons, WS
|
||||
# subscriptions, and scan loops all derive from it.
|
||||
|
||||
SCAN_PAIRS = [XAUUSD]
|
||||
SCAN_PAIRS = list(PlatformConfig().scan_pairs)
|
||||
|
||||
_WS_TO_PAIR = {
|
||||
"XAU/USD": XAUUSD,
|
||||
}
|
||||
_PAIRS_LABEL = " + ".join(PAIR_INFO[p]["display"] for p in SCAN_PAIRS)
|
||||
|
||||
_WS_TO_PAIR = {PAIR_INFO[p]["display"]: p for p in SCAN_PAIRS}
|
||||
_WS_SYMBOLS = list(_WS_TO_PAIR.keys())
|
||||
|
||||
_LEVEL_KEYS = (
|
||||
@@ -85,14 +98,36 @@ _LEVEL_KEYS = (
|
||||
"kc_upper", "kc_lower",
|
||||
)
|
||||
|
||||
SCAN_BUTTONS = [
|
||||
{"cb": "scan_xauusd_m5", "symbol": XAUUSD, "tf": Timeframe.M5, "label": "5 Min"},
|
||||
{"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "15 Min"},
|
||||
{"cb": "scan_xauusd_h1", "symbol": XAUUSD, "tf": Timeframe.H1, "label": "1 Hour"},
|
||||
{"cb": "scan_xauusd_h4", "symbol": XAUUSD, "tf": Timeframe.H4, "label": "4 Hour"},
|
||||
{"cb": "scan_xauusd_all", "symbol": XAUUSD, "tf": None, "label": "Scan All TFs"},
|
||||
_TF_BUTTON_LABELS = [
|
||||
(Timeframe.M5, "5 Min"),
|
||||
(Timeframe.M15, "15 Min"),
|
||||
(Timeframe.H1, "1 Hour"),
|
||||
(Timeframe.H4, "4 Hour"),
|
||||
]
|
||||
|
||||
|
||||
def _build_scan_buttons() -> List[dict]:
|
||||
"""One row of TF buttons + a scan-all button per scanned pair."""
|
||||
multi = len(SCAN_PAIRS) > 1
|
||||
buttons = []
|
||||
for symbol in SCAN_PAIRS:
|
||||
slug = symbol.replace("/", "").lower()
|
||||
base = PAIR_INFO[symbol]["display"].split("/")[0] # XAU, EUR
|
||||
prefix = f"{base} " if multi else ""
|
||||
for tf, label in _TF_BUTTON_LABELS:
|
||||
buttons.append({
|
||||
"cb": f"scan_{slug}_{tf.value}", "symbol": symbol,
|
||||
"tf": tf, "label": f"{prefix}{label}",
|
||||
})
|
||||
buttons.append({
|
||||
"cb": f"scan_{slug}_all", "symbol": symbol, "tf": None,
|
||||
"label": f"Scan All TFs — {base}" if multi else "Scan All TFs",
|
||||
})
|
||||
return buttons
|
||||
|
||||
|
||||
SCAN_BUTTONS = _build_scan_buttons()
|
||||
|
||||
SCAN_LOOKUP = {b["cb"]: b for b in SCAN_BUTTONS}
|
||||
|
||||
TF_CONFIRMATIONS = {
|
||||
@@ -258,7 +293,7 @@ def format_signal_message(report: SignalReport) -> str:
|
||||
# TP1 is advisory, the trail is the exit). Pullback setups run
|
||||
# tighter stops and arm later.
|
||||
sig_cfg = PlatformConfig().signals
|
||||
is_pb = getattr(ts, "strategy", "GENERIC") == "XAU_PB"
|
||||
is_pb = getattr(ts, "strategy", "GENERIC").endswith("_PB")
|
||||
arm_r = sig_cfg.pb_trail_arm_r if is_pb else sig_cfg.trail_arm_r
|
||||
dist_r = sig_cfg.pb_trail_dist_r if is_pb else sig_cfg.trail_dist_r
|
||||
if arm_r > 0:
|
||||
@@ -312,29 +347,31 @@ def _format_scan_summary(reports: List[SignalReport], tf: Timeframe) -> str:
|
||||
|
||||
# ── Keyboard helper ───────────────────────────────────────────────────
|
||||
|
||||
def _scan_button_rows() -> List[List[InlineKeyboardButton]]:
|
||||
"""Per pair: one row of TF buttons + one scan-all row."""
|
||||
rows = []
|
||||
for symbol in SCAN_PAIRS:
|
||||
tf_buttons = [b for b in SCAN_BUTTONS
|
||||
if b["symbol"] == symbol and b["tf"] is not None]
|
||||
rows.append([InlineKeyboardButton(b["label"], callback_data=b["cb"])
|
||||
for b in tf_buttons])
|
||||
all_button = next((b for b in SCAN_BUTTONS
|
||||
if b["symbol"] == symbol and b["tf"] is None), None)
|
||||
if all_button:
|
||||
rows.append([InlineKeyboardButton(all_button["label"],
|
||||
callback_data=all_button["cb"])])
|
||||
return rows
|
||||
|
||||
|
||||
def scan_keyboard() -> InlineKeyboardMarkup:
|
||||
tf_buttons = [b for b in SCAN_BUTTONS if b["tf"] is not None]
|
||||
all_button = next((b for b in SCAN_BUTTONS if b["tf"] is None), None)
|
||||
rows = [[
|
||||
InlineKeyboardButton(b["label"], callback_data=b["cb"])
|
||||
for b in tf_buttons
|
||||
]]
|
||||
if all_button:
|
||||
rows.append([InlineKeyboardButton(all_button["label"], callback_data=all_button["cb"])])
|
||||
return InlineKeyboardMarkup(rows)
|
||||
return InlineKeyboardMarkup(_scan_button_rows())
|
||||
|
||||
|
||||
def main_menu_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Full interactive menu: scans + settings + status in one keyboard."""
|
||||
tf_buttons = [b for b in SCAN_BUTTONS if b["tf"] is not None]
|
||||
all_button = next((b for b in SCAN_BUTTONS if b["tf"] is None), None)
|
||||
autoscan_label = "🔔 Auto-scan: ON" if _autoscan_chats else "🔕 Auto-scan: OFF"
|
||||
quality_label = f"🎚 Alerts: {_alert_min_quality}+" if _alert_min_quality != "A" else "🎚 Alerts: A only"
|
||||
rows = [
|
||||
[InlineKeyboardButton(b["label"], callback_data=b["cb"]) for b in tf_buttons],
|
||||
]
|
||||
if all_button:
|
||||
rows.append([InlineKeyboardButton(all_button["label"], callback_data=all_button["cb"])])
|
||||
rows = _scan_button_rows()
|
||||
rows.append([
|
||||
InlineKeyboardButton(autoscan_label, callback_data="menu_autoscan"),
|
||||
InlineKeyboardButton(quality_label, callback_data="menu_quality"),
|
||||
@@ -374,9 +411,16 @@ def _run_signal_sync(
|
||||
confirmation_tfs = TF_CONFIRMATIONS.get(
|
||||
timeframe, [Timeframe.M15, Timeframe.H4]
|
||||
)
|
||||
config = PlatformConfig()
|
||||
config.primary_timeframe = timeframe
|
||||
config.confirmation_timeframes = confirmation_tfs
|
||||
# Per-pair tuned configs (regime weights, gates, sessions) come from
|
||||
# the pair_signals factories; a plain PlatformConfig would silently
|
||||
# run every pair with XAU defaults.
|
||||
factory = PAIR_PRODUCERS.get(symbol)
|
||||
if factory is not None:
|
||||
config = factory(timeframe, confirmation_tfs)
|
||||
else:
|
||||
config = PlatformConfig()
|
||||
config.primary_timeframe = timeframe
|
||||
config.confirmation_timeframes = confirmation_tfs
|
||||
aggregator = SignalAggregator(config)
|
||||
aggregator.data_provider = _get_shared_dp()
|
||||
return aggregator.analyze_pair(symbol=symbol)
|
||||
@@ -717,7 +761,7 @@ def _stop_scheduler() -> None:
|
||||
async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
scanning = "running" if _autoscan_chats else "off"
|
||||
text = (
|
||||
"👋 *ForexBot2 — XAU/USD Signal Intelligence*\n\n"
|
||||
f"👋 *ForexBot2 — {_PAIRS_LABEL} Signal Intelligence*\n\n"
|
||||
"Pick a timeframe to scan gold.\n"
|
||||
"Signals include a full trade setup with entry, SL and TP.\n\n"
|
||||
f"Auto-scan: *{scanning}* — /autoscan to toggle\n"
|
||||
@@ -735,7 +779,7 @@ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
_HELP_TEXT = (
|
||||
"📖 *ForexBot2 Commands*\n\n"
|
||||
"/start — Welcome & interactive menu\n"
|
||||
"/scan — Quick XAU/USD scan menu\n"
|
||||
"/scan — Quick scan menu\n"
|
||||
"/autoscan — Toggle auto-scan on/off\n"
|
||||
"/status — Bot status, WS connection, live prices\n"
|
||||
"/cache — Show data cache age per pair/TF\n"
|
||||
@@ -868,7 +912,7 @@ async def cmd_autoscan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
||||
next_h4 = _seconds_until_boundary(240, buffer=10)
|
||||
await update.message.reply_text(
|
||||
"✅ *Auto-scan enabled*\n\n"
|
||||
f"Scanning XAU/USD on M5/M15/H1/H4 at candle close.\n"
|
||||
f"Scanning {_PAIRS_LABEL} on M5/M15/H1/H4 at candle close.\n"
|
||||
f"📡 Live price monitoring active.\n"
|
||||
f"Next: M5 {next_m5 / 60:.0f}m | M15 {next_m15 / 60:.0f}m | "
|
||||
f"H1 {next_h1 / 60:.0f}m | H4 {next_h4 / 60:.0f}m\n\n"
|
||||
@@ -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:
|
||||
await update.message.reply_text(
|
||||
"🔍 *XAU/USD — Pick a timeframe*",
|
||||
f"🔍 *{_PAIRS_LABEL} — Pick a timeframe*",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
reply_markup=main_menu_keyboard(),
|
||||
)
|
||||
@@ -894,10 +938,11 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
return
|
||||
|
||||
if btn["tf"] is None:
|
||||
await _cb_scan_all(query)
|
||||
await _cb_scan_all(query, btn["symbol"])
|
||||
return
|
||||
|
||||
await _safe_edit(query, f"⏳ Scanning XAU/USD {btn['label']}…")
|
||||
display = PAIR_INFO[btn["symbol"]]["display"]
|
||||
await _safe_edit(query, f"⏳ Scanning {display} {btn['label']}…")
|
||||
|
||||
try:
|
||||
report = await run_signal(btn["symbol"], btn["tf"])
|
||||
@@ -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)
|
||||
|
||||
|
||||
async def _cb_scan_all(query) -> None:
|
||||
await _safe_edit(query, "⏳ Scanning XAU/USD across M5 / M15 / H1 / H4…")
|
||||
async def _cb_scan_all(query, symbol: str = XAUUSD) -> None:
|
||||
display = PAIR_INFO[symbol]["display"]
|
||||
await _safe_edit(query, f"⏳ Scanning {display} across M5 / M15 / H1 / H4…")
|
||||
|
||||
tfs = [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]
|
||||
tasks = [run_signal(XAUUSD, tf) for tf in tfs]
|
||||
tasks = [run_signal(symbol, tf) for tf in tfs]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
reports = []
|
||||
@@ -968,7 +1014,7 @@ async def _cb_scan_all(query) -> None:
|
||||
_update_level_cache(reports)
|
||||
|
||||
lines = [
|
||||
"📊 *XAU/USD — All Timeframes*",
|
||||
f"📊 *{display} — All Timeframes*",
|
||||
f"🕐 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
|
||||
"",
|
||||
]
|
||||
@@ -1008,7 +1054,7 @@ async def _post_init(app: Application) -> None:
|
||||
"""Register command menu and start scheduler on boot."""
|
||||
await app.bot.set_my_commands([
|
||||
("start", "Welcome & scan buttons"),
|
||||
("scan", "Quick XAU/USD scan buttons"),
|
||||
("scan", "Quick scan buttons"),
|
||||
("autoscan", "Toggle auto-scan on/off"),
|
||||
("status", "Bot status, WS connection, live prices"),
|
||||
("cache", "Show data cache age per pair/TF"),
|
||||
@@ -1056,7 +1102,7 @@ async def _post_init(app: Application) -> None:
|
||||
await app.bot.send_message(
|
||||
chat_id,
|
||||
f"🤖 *ForexBot2 started*\n\n"
|
||||
f"Auto-scanning XAU/USD on M5/M15/H1/H4.\n"
|
||||
f"Auto-scanning {_PAIRS_LABEL} on M5/M15/H1/H4.\n"
|
||||
f"📡 Live price monitoring active.\n"
|
||||
f"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"
|
||||
|
||||
+495
-2
@@ -1415,6 +1415,7 @@ def _():
|
||||
@test("build_config sets correct timeframe and confirmations")
|
||||
def _():
|
||||
class FakeArgs:
|
||||
pair = "XAUUSD"
|
||||
timeframe = "H4"
|
||||
config = build_config(FakeArgs())
|
||||
assert config.primary_timeframe == Timeframe.H4
|
||||
@@ -1426,11 +1427,23 @@ def _():
|
||||
def _():
|
||||
for tf_str in ["M5", "M15", "M30", "H1", "H4", "D1"]:
|
||||
class FakeArgs:
|
||||
pair = "XAUUSD"
|
||||
timeframe = tf_str
|
||||
config = build_config(FakeArgs())
|
||||
assert config.primary_timeframe is not None
|
||||
|
||||
|
||||
@test("build_config resolves EURUSD pair to EUR factory config")
|
||||
def _():
|
||||
class FakeArgs:
|
||||
pair = "EURUSD"
|
||||
timeframe = "M15"
|
||||
config = build_config(FakeArgs())
|
||||
assert config.symbol == "EUR/USD"
|
||||
assert config.pip_value == 0.0001
|
||||
assert config.primary_timeframe == Timeframe.M15
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# MODULE 7: telegram_bot.py
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
@@ -1609,10 +1622,13 @@ def _():
|
||||
|
||||
@test("SCAN_BUTTONS and SCAN_LOOKUP consistency")
|
||||
def _():
|
||||
assert len(SCAN_BUTTONS) == 5 # 4 TFs + "Scan All"
|
||||
from telegram_bot import SCAN_PAIRS
|
||||
# 4 TF buttons + one "Scan All" per scanned pair
|
||||
assert len(SCAN_BUTTONS) == 5 * len(SCAN_PAIRS)
|
||||
for btn in SCAN_BUTTONS:
|
||||
assert btn["cb"] in SCAN_LOOKUP
|
||||
assert "symbol" in btn
|
||||
assert btn["symbol"] in SCAN_PAIRS
|
||||
assert "tf" in btn
|
||||
assert "label" in btn
|
||||
|
||||
@@ -1642,8 +1658,11 @@ def _():
|
||||
_level_cache_ts.clear()
|
||||
|
||||
|
||||
@test("_WS_TO_PAIR mapping is correct")
|
||||
@test("_WS_TO_PAIR mapping covers every scanned pair")
|
||||
def _():
|
||||
from telegram_bot import SCAN_PAIRS
|
||||
for p in SCAN_PAIRS:
|
||||
assert _WS_TO_PAIR[PAIR_INFO[p]["display"]] == p
|
||||
assert _WS_TO_PAIR["XAU/USD"] == XAUUSD
|
||||
|
||||
|
||||
@@ -1761,6 +1780,480 @@ def _():
|
||||
assert r1.market_regime == r2.market_regime
|
||||
|
||||
|
||||
print("\n═══ Module 9: pair_signals registry ═══")
|
||||
|
||||
@test("pair_signals registry includes XAU/USD and EUR/USD")
|
||||
def _():
|
||||
import pair_signals
|
||||
pairs = pair_signals.get_supported_pairs()
|
||||
assert XAUUSD in pairs, f"XAU missing from registry: {pairs}"
|
||||
assert "EUR/USD" in pairs, f"EUR missing from registry: {pairs}"
|
||||
|
||||
|
||||
@test("xauusd.make_config equals inline PlatformConfig construction")
|
||||
def _():
|
||||
import dataclasses
|
||||
from pair_signals import PAIR_PRODUCERS
|
||||
for tf, ctfs in [
|
||||
(Timeframe.M5, [Timeframe.M15, Timeframe.H1]),
|
||||
(Timeframe.M15, [Timeframe.M5, Timeframe.H1]),
|
||||
(Timeframe.H1, [Timeframe.M15, Timeframe.H4]),
|
||||
]:
|
||||
factory_cfg = PAIR_PRODUCERS[XAUUSD](tf, ctfs)
|
||||
inline_cfg = PlatformConfig(
|
||||
symbol=XAUUSD,
|
||||
display_name="XAU/USD",
|
||||
pip_value=0.10,
|
||||
primary_timeframe=tf,
|
||||
confirmation_timeframes=ctfs,
|
||||
)
|
||||
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
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user