Tune 5M signals, fix XAU regression, add signal dedup cooldown

EUR/USD 5M: raise min_entry_score 28→35, block hours 4/19/21
  → 83.4% WR, 145 trades, +9.68R, PF 1.42 (120-day)

XAU/USD 5M: fix B-grade exclusion regression in setup_grades,
  add breakeven trail (trigger 0.15R, buffer 0.05R)
  → 84.9% WR, 4810 trades, +164.54R, PF 1.23 (730-day)

EUR/USD M15: tighten defaults (SL 1.5x ATR, TP 1.0x, min_rr 0.5,
  block ranging+transitioning, BE trigger 0.2R)

Backtest: multiprocess fork parallelism, vectorized pre-filter,
  CUDA preload fix, per-pair BE trigger routing

Telegram bot: add signal deduplication — suppress repeated
  same-direction messages within 5-bar cooldown per pair+TF.
  Resend on direction flip, quality upgrade, or cooldown expiry.
  Manual scans always ungated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
harwinder
2026-05-19 20:19:11 -04:00
parent 9e5410d617
commit b7ec5341cf
7 changed files with 280 additions and 151 deletions
+206 -129
View File
@@ -31,11 +31,13 @@ from __future__ import annotations
import argparse import argparse
import logging import logging
import multiprocessing as mp
import os import os
import sys import sys
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from itertools import chain
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
@@ -65,26 +67,42 @@ from pair_signals import PAIR_PRODUCERS # noqa: E402
# An importable CuPy can still fail at first kernel launch if CUDA libs # An importable CuPy can still fail at first kernel launch if CUDA libs
# are missing (containers, headless boxes), so verify with a tiny op. # are missing (containers, headless boxes), so verify with a tiny op.
# ───────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────
def _auto_detect_cuda_path(): def _preload_nvidia_libs():
"""Set CUDA_PATH from pip-installed nvidia packages if not already set.""" """Pre-load versioned CUDA shared libraries so CuPy can find them.
if os.environ.get("CUDA_PATH"):
return
for p in sys.path:
nvrtc_lib = os.path.join(p, "nvidia", "cuda_nvrtc", "lib")
if os.path.isdir(nvrtc_lib):
os.environ["CUDA_PATH"] = os.path.join(p, "nvidia")
ld = os.environ.get("LD_LIBRARY_PATH", "")
if nvrtc_lib not in ld:
os.environ["LD_LIBRARY_PATH"] = nvrtc_lib + (":" + ld if ld else "")
return
_auto_detect_cuda_path() pip-installed nvidia packages ship libnvrtc.so.12 (versioned) but CuPy's
dlopen looks for the unversioned libnvrtc.so. Loading the versioned .so
via ctypes with RTLD_GLOBAL makes the symbols available process-wide.
"""
import ctypes
search_paths = list(sys.path)
for venv_dir in (PROJECT_ROOT / "venv", PROJECT_ROOT / ".venv"):
if venv_dir.is_dir():
for sp in venv_dir.glob("lib/python*/site-packages"):
search_paths.append(str(sp))
for p in search_paths:
nvidia_base = os.path.join(p, "nvidia")
if not os.path.isdir(nvidia_base):
continue
for pkg in sorted(os.listdir(nvidia_base)):
lib_dir = os.path.join(nvidia_base, pkg, "lib")
if not os.path.isdir(lib_dir):
continue
for f in sorted(os.listdir(lib_dir)):
if f.endswith(".so") or (".so." in f and not f.endswith(".o")):
try:
ctypes.CDLL(os.path.join(lib_dir, f), mode=ctypes.RTLD_GLOBAL)
except OSError:
pass
return
_preload_nvidia_libs()
def _detect_gpu(): def _detect_gpu():
try: try:
import cupy as cp_mod # type: ignore import cupy as cp_mod # type: ignore
# Force runtime init: a kernel launch will surface missing libs.
_ = cp_mod.asarray(np.zeros(2, dtype=np.float64)) _ = cp_mod.asarray(np.zeros(2, dtype=np.float64))
_ = cp_mod.cumsum(_).get() _ = cp_mod.cumsum(_).get()
return cp_mod, True return cp_mod, True
@@ -1707,127 +1725,81 @@ def _is_pair_blocked_by_calendar(ts: pd.Timestamp, cfg: PlatformConfig) -> Optio
return None return None
def run_backtest( def _prefilter_bars(primary_df: pd.DataFrame, bt_cfg, platform_cfg,
bt_cfg: BacktestConfig, first_pos: int, last_pos: int):
platform_cfg: Optional[PlatformConfig] = None, """Vectorized calendar/weekend pre-filter. Returns (eligible, skipped_counts)."""
) -> Tuple[List[Trade], Dict[Timeframe, pd.DataFrame]]: idx = primary_df.index[first_pos:last_pos]
"""Replay history and collect trades.""" wd = np.array(idx.weekday)
if platform_cfg is None: hour = np.array(idx.hour)
platform_cfg = PlatformConfig(
symbol=bt_cfg.pair,
display_name=PAIR_INFO[bt_cfg.pair]["display"],
pip_value=PAIR_INFO[bt_cfg.pair]["pip_value"],
primary_timeframe=bt_cfg.primary_tf,
confirmation_timeframes=list(bt_cfg.confirmation_tfs),
)
provider = ForexDataProvider(platform_cfg) weekend = ((wd == 4) & (hour >= 22)) | (wd == 5) | ((wd == 6) & (hour < 22))
# ── Load all required timeframes ── is_xau = PAIR_INFO[bt_cfg.pair]["display"] == "XAU/USD"
timeframes = [bt_cfg.primary_tf] + list(bt_cfg.confirmation_tfs) allowed = (
# Pad lookback so earliest TF has enough warmup for EMA200, ADX, etc. platform_cfg.xau_strategy.allowed_weekdays if is_xau
pad_days = max(60, bt_cfg.warmup_bars // 4) else platform_cfg.backtest.allowed_weekdays
fetch_start = bt_cfg.start - timedelta(days=pad_days)
dfs_full: Dict[Timeframe, pd.DataFrame] = {}
for tf in timeframes:
df = fetch_history(
provider, bt_cfg.pair, tf,
start=fetch_start, end=bt_cfg.end,
use_cache=bt_cfg.use_cache,
)
if df.empty:
raise RuntimeError(f"No data for {bt_cfg.pair} {tf.value}")
dfs_full[tf] = df
# ── Pre-compute indicators on each TF ──
t0 = time.perf_counter()
caches: Dict[str, IndicatorCache] = {
tf.value: precompute_indicators(dfs_full[tf], platform_cfg.technical, gpu_log=True)
for tf in timeframes
}
logger.info(f"Indicator pre-compute took {time.perf_counter() - t0:.2f}s")
# ── Inject GPU engine into a fresh aggregator ──
aggregator = SignalAggregator(platform_cfg)
aggregator.tech_engine = GPUTechnicalEngine(platform_cfg.technical, caches)
# ── Walk primary TF bar-by-bar ──
primary_df = dfs_full[bt_cfg.primary_tf]
# bt_cfg.start/end are already tz-aware UTC datetimes; specifying tz=
# again would raise. Just wrap.
bt_start_ts = pd.Timestamp(bt_cfg.start)
bt_end_ts = pd.Timestamp(bt_cfg.end)
# Start at warmup_bars OR first bar inside the bt window (whichever later).
first_eligible_pos = max(
bt_cfg.warmup_bars,
int(primary_df.index.searchsorted(bt_start_ts)),
) )
last_pos = int(primary_df.index.searchsorted(bt_end_ts, side="right")) if allowed:
last_pos = min(last_pos, len(primary_df)) day_ok = np.isin(wd, allowed)
else:
day_ok = np.ones(len(idx), dtype=bool)
trades: List[Trade] = [] eligible_mask = ~weekend & day_ok
last_entry_pos = -10_000 eligible = np.where(eligible_mask)[0] + first_pos
skipped_reasons: Dict[str, int] = {}
logger.info( skipped: Dict[str, int] = {}
f"Replaying {bt_cfg.pair} {bt_cfg.primary_tf.value}: " n_weekend = int(weekend.sum())
f"bars {first_eligible_pos}–{last_pos} of {len(primary_df)}" n_day = int((~weekend & ~day_ok).sum())
) if n_weekend:
skipped["WEEKEND_FILTER"] = n_weekend
if n_day:
skipped["DAY_FILTER"] = n_day
primary_dur = pd.Timedelta(seconds=BAR_SECONDS[bt_cfg.primary_tf]) return eligible.tolist(), skipped
t_loop = time.perf_counter()
for pos in range(first_eligible_pos, last_pos):
ts = primary_df.index[pos]
# Decision time = close of the current primary bar.
decision_time = ts + primary_dur
# Calendar filter (matches live analyze_pair behavior). # ── Parallel replay worker state (set before fork) ──────────────────
if PAIR_INFO[bt_cfg.pair]["display"] == "XAU/USD": _mp_dfs_full: Optional[Dict] = None
block = _is_xau_blocked_by_calendar(ts, platform_cfg) _mp_caches: Optional[Dict] = None
else: _mp_platform_cfg: Optional[PlatformConfig] = None
block = _is_pair_blocked_by_calendar(ts, platform_cfg) _mp_bt_cfg: Optional["BacktestConfig"] = None
if block:
skipped_reasons[block] = skipped_reasons.get(block, 0) + 1
continue
# Cooldown.
if pos - last_entry_pos < bt_cfg.cooldown_bars:
continue
# Build sliced dfs per TF — only include bars fully closed by def _replay_worker(positions: List[int]) -> List[Tuple[int, Trade]]:
# decision_time. A bar timestamped T closes at T + bar_dur, so we """Process a chunk of bar positions and return candidate trades."""
# keep bars where T + bar_dur <= decision_time → T <= decision_time - bar_dur. aggregator = SignalAggregator(_mp_platform_cfg)
# Without this, the H1 bar containing the current M15 close would be aggregator.tech_engine = GPUTechnicalEngine(_mp_platform_cfg.technical, _mp_caches)
# included with future high/low/close values: a look-ahead leak.
timeframes = [_mp_bt_cfg.primary_tf] + list(_mp_bt_cfg.confirmation_tfs)
primary_df = _mp_dfs_full[_mp_bt_cfg.primary_tf]
primary_dur = pd.Timedelta(seconds=BAR_SECONDS[_mp_bt_cfg.primary_tf])
bar_durs = {tf: pd.Timedelta(seconds=BAR_SECONDS[tf]) for tf in timeframes}
n_primary = len(primary_df)
candidates: List[Tuple[int, Trade]] = []
for pos in positions:
decision_time = primary_df.index[pos] + primary_dur
slice_dfs: Dict[Timeframe, pd.DataFrame] = {} slice_dfs: Dict[Timeframe, pd.DataFrame] = {}
for tf in timeframes: for tf in timeframes:
full = dfs_full[tf] full = _mp_dfs_full[tf]
bar_dur = pd.Timedelta(seconds=BAR_SECONDS[tf]) cutoff = decision_time - bar_durs[tf]
cutoff = decision_time - bar_dur
cut = int(full.index.searchsorted(cutoff, side="right")) cut = int(full.index.searchsorted(cutoff, side="right"))
if cut == 0: if cut > 0:
continue slice_dfs[tf] = full.iloc[:cut]
slice_dfs[tf] = full.iloc[:cut]
if bt_cfg.primary_tf not in slice_dfs: if _mp_bt_cfg.primary_tf not in slice_dfs:
continue continue
report: SignalReport = aggregator.analyze_from_dataframes( report: SignalReport = aggregator.analyze_from_dataframes(
symbol=bt_cfg.pair, symbol=_mp_bt_cfg.pair, dfs=slice_dfs, context=None, use_news=False,
dfs=slice_dfs,
context=None,
use_news=False,
) )
if report.trade_setup is None: if report.trade_setup is None:
continue continue
if report.signal_quality not in bt_cfg.quality_filter: if report.signal_quality not in _mp_bt_cfg.quality_filter:
continue continue
# Strategy tag (from bullish_reasons[0] which the aggregator pre-pends).
tag = "GENERIC" tag = "GENERIC"
if report.bullish_reasons: if report.bullish_reasons:
first = report.bullish_reasons[0] first = report.bullish_reasons[0]
@@ -1838,28 +1810,23 @@ def run_backtest(
elif "GBP STRATEGY" in first: elif "GBP STRATEGY" in first:
tag = "GBP_PB" tag = "GBP_PB"
if bt_cfg.strategies_only and tag not in bt_cfg.strategies_only: if _mp_bt_cfg.strategies_only and tag not in _mp_bt_cfg.strategies_only:
continue continue
ts_setup: TradeSetup = report.trade_setup ts_setup: TradeSetup = report.trade_setup
# Entry on the NEXT bar's open price would be ideal, but live signals fire
# at bar close and we use that close as entry (matches setup.entry_price).
# For simulation realism we step entry to next bar so bar `pos` is not
# used to resolve the trade outcome (bar `pos` is the signal bar).
entry_pos = pos + 1 entry_pos = pos + 1
if entry_pos >= len(primary_df): if entry_pos >= n_primary:
continue continue
entry_time = primary_df.index[entry_pos]
breakdown, agree, oppose, neutral, total = _build_indicator_breakdown( breakdown, agree, oppose, neutral, total = _build_indicator_breakdown(
report, ts_setup.direction report, ts_setup.direction,
) )
reasons = (report.bullish_reasons if ts_setup.direction == "BUY" reasons = (report.bullish_reasons if ts_setup.direction == "BUY"
else report.bearish_reasons) else report.bearish_reasons)
trades.append(Trade( candidates.append((pos, Trade(
pair=bt_cfg.pair, pair=_mp_bt_cfg.pair,
entry_time=entry_time, entry_time=primary_df.index[entry_pos],
entry_price=ts_setup.entry_price, entry_price=ts_setup.entry_price,
sl=ts_setup.stop_loss, sl=ts_setup.stop_loss,
tp1=ts_setup.take_profit_1, tp1=ts_setup.take_profit_1,
@@ -1882,21 +1849,131 @@ def run_backtest(
indicators_opposing=oppose, indicators_opposing=oppose,
indicators_neutral=neutral, indicators_neutral=neutral,
indicators_total=total, indicators_total=total,
)) )))
return candidates
def run_backtest(
bt_cfg: BacktestConfig,
platform_cfg: Optional[PlatformConfig] = None,
) -> Tuple[List[Trade], Dict[Timeframe, pd.DataFrame]]:
"""Replay history and collect trades."""
if platform_cfg is None:
platform_cfg = PlatformConfig(
symbol=bt_cfg.pair,
display_name=PAIR_INFO[bt_cfg.pair]["display"],
pip_value=PAIR_INFO[bt_cfg.pair]["pip_value"],
primary_timeframe=bt_cfg.primary_tf,
confirmation_timeframes=list(bt_cfg.confirmation_tfs),
)
provider = ForexDataProvider(platform_cfg)
# ── Load all required timeframes ──
timeframes = [bt_cfg.primary_tf] + list(bt_cfg.confirmation_tfs)
pad_days = max(60, bt_cfg.warmup_bars // 4)
fetch_start = bt_cfg.start - timedelta(days=pad_days)
dfs_full: Dict[Timeframe, pd.DataFrame] = {}
for tf in timeframes:
df = fetch_history(
provider, bt_cfg.pair, tf,
start=fetch_start, end=bt_cfg.end,
use_cache=bt_cfg.use_cache,
)
if df.empty:
raise RuntimeError(f"No data for {bt_cfg.pair} {tf.value}")
dfs_full[tf] = df
# ── Pre-compute indicators on each TF ──
t0 = time.perf_counter()
caches: Dict[str, IndicatorCache] = {
tf.value: precompute_indicators(dfs_full[tf], platform_cfg.technical, gpu_log=True)
for tf in timeframes
}
logger.info(f"Indicator pre-compute took {time.perf_counter() - t0:.2f}s")
# ── Determine bar range ──
primary_df = dfs_full[bt_cfg.primary_tf]
bt_start_ts = pd.Timestamp(bt_cfg.start)
bt_end_ts = pd.Timestamp(bt_cfg.end)
first_eligible_pos = max(
bt_cfg.warmup_bars,
int(primary_df.index.searchsorted(bt_start_ts)),
)
last_pos = min(
int(primary_df.index.searchsorted(bt_end_ts, side="right")),
len(primary_df),
)
# ── Vectorized pre-filter (weekend + weekday) ──
eligible, skipped_reasons = _prefilter_bars(
primary_df, bt_cfg, platform_cfg, first_eligible_pos, last_pos,
)
total_bars = last_pos - first_eligible_pos
logger.info(
f"Replaying {bt_cfg.pair} {bt_cfg.primary_tf.value}: "
f"{len(eligible)} eligible bars of {total_bars} "
f"(filtered {total_bars - len(eligible)})"
)
# ── Parallel signal replay across CPU cores ──
n_workers = min(os.cpu_count() or 4, max(1, len(eligible) // 200))
t_loop = time.perf_counter()
if n_workers > 1:
global _mp_dfs_full, _mp_caches, _mp_platform_cfg, _mp_bt_cfg
_mp_dfs_full = dfs_full
_mp_caches = caches
_mp_platform_cfg = platform_cfg
_mp_bt_cfg = bt_cfg
chunks = [eligible[i::n_workers] for i in range(n_workers)]
ctx = mp.get_context("fork")
with ctx.Pool(n_workers) as pool:
chunk_results = pool.map(_replay_worker, chunks)
_mp_dfs_full = _mp_caches = _mp_platform_cfg = _mp_bt_cfg = None
all_candidates = sorted(chain.from_iterable(chunk_results), key=lambda x: x[0])
else:
_mp_dfs_full = dfs_full
_mp_caches = caches
_mp_platform_cfg = platform_cfg
_mp_bt_cfg = bt_cfg
all_candidates = _replay_worker(eligible)
_mp_dfs_full = _mp_caches = _mp_platform_cfg = _mp_bt_cfg = None
# ── Apply cooldown (sequential — depends on previous trade) ──
trades: List[Trade] = []
last_entry_pos = -10_000
for pos, trade in all_candidates:
if pos - last_entry_pos < bt_cfg.cooldown_bars:
continue
trades.append(trade)
last_entry_pos = pos last_entry_pos = pos
elapsed = time.perf_counter() - t_loop
logger.info( logger.info(
f"Replay complete: {len(trades)} entries in " f"Replay complete: {len(trades)} entries in {elapsed:.1f}s "
f"{time.perf_counter() - t_loop:.1f}s " f"({len(eligible) / max(elapsed, 1e-6):.0f} bars/s, {n_workers} workers)"
f"({(last_pos - first_eligible_pos) / max(time.perf_counter() - t_loop, 1e-6):.0f} bars/s)"
) )
if skipped_reasons: if skipped_reasons:
logger.info(f"Skipped bars by reason: {skipped_reasons}") logger.info(f"Skipped bars by reason: {skipped_reasons}")
# ── Resolve trade outcomes ── # ── Resolve trade outcomes ──
t_sim = time.perf_counter() t_sim = time.perf_counter()
be_trigger = getattr(platform_cfg.eur_strategy, 'be_trigger_r', 0.0) if platform_cfg.eur_strategy.enabled and bt_cfg.pair != XAUUSD else 0.0 if bt_cfg.pair == XAUUSD:
be_buffer = getattr(platform_cfg.eur_strategy, 'be_buffer_r', 0.0) if be_trigger > 0 else 0.0 be_trigger = platform_cfg.xau_strategy.be_trigger_r
be_buffer = platform_cfg.xau_strategy.be_buffer_r
elif platform_cfg.eur_strategy.enabled:
be_trigger = platform_cfg.eur_strategy.be_trigger_r
be_buffer = platform_cfg.eur_strategy.be_buffer_r
else:
be_trigger, be_buffer = 0.0, 0.0
simulate_trades(trades, primary_df, max_bars=bt_cfg.max_hold_bars, simulate_trades(trades, primary_df, max_bars=bt_cfg.max_hold_bars,
be_trigger_r=be_trigger, be_buffer_r=be_buffer) be_trigger_r=be_trigger, be_buffer_r=be_buffer)
logger.info( logger.info(
+10 -8
View File
@@ -159,6 +159,8 @@ class XauStrategyConfig:
rsi_zone: tuple = (40.0, 65.0) rsi_zone: tuple = (40.0, 65.0)
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4, 6]) # Mon-Fri + Sun (Sydney open) allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4, 6]) # Mon-Fri + Sun (Sydney open)
sell_min_quality: List[str] = field(default_factory=lambda: ["A+", "A", "B"]) sell_min_quality: List[str] = field(default_factory=lambda: ["A+", "A", "B"])
be_trigger_r: float = 0.0
be_buffer_r: float = 0.0
@dataclass @dataclass
@@ -169,24 +171,24 @@ class EurUsdStrategyConfig:
weekend_filter: bool = True weekend_filter: bool = True
allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4]) allowed_weekdays: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4])
min_atr_threshold: float = 0.0003 min_atr_threshold: float = 0.0003
sl_atr_multiplier: float = 0.5 sl_atr_multiplier: float = 1.5
tp_atr_multiplier: float = 1.5 tp_atr_multiplier: float = 1.0
sl_atr_floor: float = 1.2 sl_atr_floor: float = 1.2
min_rr_ratio: float = 1.0 min_rr_ratio: float = 0.5
mature_move_threshold: float = 35.0 mature_move_threshold: float = 35.0
tp_quality_scale: Dict[str, float] = field(default_factory=lambda: { tp_quality_scale: Dict[str, float] = field(default_factory=lambda: {
"A+": 1.0, "A": 1.0, "B": 1.0, "C": 1.0, "A+": 1.0, "A": 1.0, "B": 1.0, "C": 1.0,
}) })
blocked_regimes: List[str] = field(default_factory=list) blocked_regimes: List[str] = field(default_factory=lambda: ["ranging", "transitioning"])
min_quality_grades: List[str] = field(default_factory=lambda: ["A+", "A"]) min_quality_grades: List[str] = field(default_factory=lambda: ["A+", "A", "B"])
max_exhaustion_tags: int = 99 max_exhaustion_tags: int = 99
block_near_ema200: bool = False block_near_ema200: bool = False
session_hours: List[int] = field(default_factory=list) session_hours: List[int] = field(default_factory=list)
min_entry_score: float = 30.0 min_entry_score: float = 38.0
min_entry_confidence: float = 50.0 min_entry_confidence: float = 60.0
block_rsi_extended: bool = True block_rsi_extended: bool = True
be_trigger_r: float = 0.3 be_trigger_r: float = 0.2
be_buffer_r: float = 0.05 be_buffer_r: float = 0.05
+2 -1
View File
@@ -80,8 +80,9 @@ def make_config(
config.eur_strategy.sl_atr_floor = 0.8 config.eur_strategy.sl_atr_floor = 0.8
config.eur_strategy.sl_atr_multiplier = 0.3 config.eur_strategy.sl_atr_multiplier = 0.3
config.eur_strategy.min_quality_grades = ["A+", "A", "B"] config.eur_strategy.min_quality_grades = ["A+", "A", "B"]
config.eur_strategy.min_entry_score = 28.0 config.eur_strategy.min_entry_score = 35.0
config.eur_strategy.min_entry_confidence = 50.0 config.eur_strategy.min_entry_confidence = 50.0
config.backtest.blocked_hours_utc = [22, 23, 0, 1, 2, 3, 4, 19, 21]
else: else:
config.technical.regime_weights_override = EUR_REGIME_WEIGHTS config.technical.regime_weights_override = EUR_REGIME_WEIGHTS
+5
View File
@@ -20,4 +20,9 @@ def make_config(
config.confirmation_timeframes = ( config.confirmation_timeframes = (
confirmation_tfs or _CONFIRMATIONS.get(primary_tf, [Timeframe.M15, Timeframe.H4]) confirmation_tfs or _CONFIRMATIONS.get(primary_tf, [Timeframe.M15, Timeframe.H4])
) )
if primary_tf == Timeframe.M5:
config.xau_strategy.be_trigger_r = 0.15
config.xau_strategy.be_buffer_r = 0.05
return config return config
+2 -1
View File
@@ -498,7 +498,8 @@ class SignalAggregator:
if report.primary_analysis else 0) if report.primary_analysis else 0)
if atr > 0: if atr > 0:
report.trade_setup = self._generate_xau_atr_trade_setup(report, atr) report.trade_setup = self._generate_xau_atr_trade_setup(report, atr)
if report.trade_setup is None and report.signal_quality in ("A+", "A", "B", "C"): setup_grades = ("A+", "A", "B", "C")
if report.trade_setup is None and report.signal_quality in setup_grades:
report.trade_setup = self._generate_trade_setup(report, pip_value) report.trade_setup = self._generate_trade_setup(report, pip_value)
# ── 8. Reasons ───────────────────────────────────────────── # ── 8. Reasons ─────────────────────────────────────────────
+4 -5
View File
@@ -1656,15 +1656,14 @@ def _():
assert 6 not in cfg.allowed_weekdays # No Sunday assert 6 not in cfg.allowed_weekdays # No Sunday
assert cfg.min_atr_threshold == 0.0003 assert cfg.min_atr_threshold == 0.0003
assert cfg.sl_atr_floor == 1.2 assert cfg.sl_atr_floor == 1.2
assert cfg.min_rr_ratio == 1.0 assert cfg.min_rr_ratio == 0.5
assert cfg.mature_move_threshold == 35.0 assert cfg.mature_move_threshold == 35.0
assert cfg.tp_quality_scale["A+"] == 1.0 assert cfg.tp_quality_scale["A+"] == 1.0
assert cfg.tp_quality_scale["A"] == 1.0 assert cfg.tp_quality_scale["A"] == 1.0
assert cfg.tp_quality_scale["C"] == 1.0 assert cfg.tp_quality_scale["C"] == 1.0
assert cfg.blocked_regimes == [] assert cfg.blocked_regimes == ["ranging", "transitioning"]
assert "A" in cfg.min_quality_grades assert "A" in cfg.min_quality_grades
assert "A" in cfg.min_quality_grades assert "B" in cfg.min_quality_grades
assert "B" not in cfg.min_quality_grades
@test("PlatformConfig has eur_strategy field") @test("PlatformConfig has eur_strategy field")
@@ -1756,7 +1755,7 @@ def _():
config.eur_strategy.blocked_regimes = [] config.eur_strategy.blocked_regimes = []
config.eur_strategy.min_quality_grades = [] config.eur_strategy.min_quality_grades = []
assert config.eur_strategy.sl_atr_floor == 1.2 assert config.eur_strategy.sl_atr_floor == 1.2
assert config.eur_strategy.min_rr_ratio == 1.0 assert config.eur_strategy.min_rr_ratio == 0.5
agg = SignalAggregator(config) agg = SignalAggregator(config)
df = make_ohlcv(n=300, start_price=1.0800, trend=0.0003, seed=77) df = make_ohlcv(n=300, start_price=1.0800, trend=0.0003, seed=77)
dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df} dfs = {Timeframe.H1: df, Timeframe.M15: df, Timeframe.H4: df}
+51 -7
View File
@@ -86,6 +86,8 @@ _LEVEL_KEYS = (
_signal_stats: Dict[str, Dict[str, SignalReport]] = {} _signal_stats: Dict[str, Dict[str, SignalReport]] = {}
_stats_updated: Optional[float] = None _stats_updated: Optional[float] = None
_last_sent: Dict[str, Dict] = {}
SCAN_BUTTONS = [ SCAN_BUTTONS = [
{"cb": "scan_xauusd_m5", "symbol": XAUUSD, "tf": Timeframe.M5, "label": "XAU 5m"}, {"cb": "scan_xauusd_m5", "symbol": XAUUSD, "tf": Timeframe.M5, "label": "XAU 5m"},
{"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "XAU 15m"}, {"cb": "scan_xauusd_m15", "symbol": XAUUSD, "tf": Timeframe.M15, "label": "XAU 15m"},
@@ -468,6 +470,37 @@ def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
return remaining return remaining
_QUALITY_RANK = {"A+": 4, "A": 3, "B": 2, "C": 1}
def _should_send(report: SignalReport, tf: Timeframe) -> bool:
if not report.trade_setup:
return False
key = f"{report.symbol}:{tf.value}"
prev = _last_sent.get(key)
if prev is None:
return True
direction = report.trade_setup.direction
quality = report.signal_quality
if direction != prev["direction"]:
return True
if _QUALITY_RANK.get(quality, 0) > _QUALITY_RANK.get(prev["quality"], 0):
return True
cooldown = 5 * _TF_SECONDS.get(tf, 300)
if time.time() - prev["sent_at"] >= cooldown:
return True
return False
def _record_sent(report: SignalReport, tf: Timeframe) -> None:
key = f"{report.symbol}:{tf.value}"
_last_sent[key] = {
"direction": report.trade_setup.direction,
"quality": report.signal_quality,
"sent_at": time.time(),
}
async def _scan_timeframe( async def _scan_timeframe(
bot, bot,
tf: Timeframe, tf: Timeframe,
@@ -492,13 +525,19 @@ async def _scan_timeframe(
await _refresh_dashboard(bot) await _refresh_dashboard(bot)
actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B", "C")] actionable = [r for r in reports if r.signal_quality in ("A+", "A", "B", "C")]
for chat_id in list(_autoscan_chats): sendable = [r for r in actionable if _should_send(r, tf)]
for report in actionable: for report in sendable:
detail = format_signal_message(report) _record_sent(report, tf)
try: if sendable:
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN) for chat_id in list(_autoscan_chats):
except Exception as exc: for report in sendable:
logger.warning(f"Failed to send signal for chat {chat_id}: {exc}") detail = format_signal_message(report)
try:
await bot.send_message(chat_id, detail, parse_mode=ParseMode.MARKDOWN)
except Exception as exc:
logger.warning(f"Failed to send signal for chat {chat_id}: {exc}")
elif actionable:
logger.info(f"Auto-scan {tf.value}: {len(actionable)} signal(s) suppressed by cooldown")
# ── WebSocket level-breach monitor ────────────────────────────────── # ── WebSocket level-breach monitor ──────────────────────────────────
@@ -563,6 +602,11 @@ _AUTOSCAN_LOOPS = [
(Timeframe.H4, 240), (Timeframe.H4, 240),
] ]
_TF_SECONDS = {
Timeframe.M5: 300, Timeframe.M15: 900,
Timeframe.H1: 3600, Timeframe.H4: 14400,
}
def _start_scheduler(bot) -> None: def _start_scheduler(bot) -> None:
global _dashboard_task global _dashboard_task