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