Files
ForexBot2/signal_aggregator.py
Harwinder Sekhon 2cc5915b00 Daily-trend veto on M15 + lot-size / already-open lines (campaign 2026-09-30), and the 2026-10-01 whole-codebase bug review
Two bodies of work that touch the same files, so one commit.

Direction and setup-size campaign (2026-09-30 → 10-01, owner's RISK-FIRST rule,
written before the runs):
- SIGNAL: M15 daily-trend veto (factory override 7, xau_strategy.daily_trend_veto
  "both"). A finished setup against a clear daily trend (closed D1 bars, Sunday
  stubs dropped; close vs its 50-bar EMA AND vs the close 60 kept days earlier)
  is removed as the last step of the core. One definition
  (signal_aggregator.daily_trend_series) for live, the bar replay and the twin's
  host side; D1 is a required frame of the M15 scan; mirrored in
  cuda_signal_kernels.py K2. W3 824 / +205.12R / PF 1.73 / max DD 12.4R vs
  1105 / +195.79R / 1.49 / 20.9R; 2019 → 2026 1473 / +223.33R / 1.42 / 41.5R vs
  2091 / +192.53R / 1.24 / 64.8R; unseen holdout −4.62R vs −6.63R.
- FAILED, not shipped: the veto on M5 (W3 drawdown 10.1R → 15.2R) and on H1
  (−9.7R), buy-only and sell-only on M15, the wide-stop cap
  (xau_strategy.wide_stop_cap_atr stays 0.0, a research knob).
- New M15 pins: W2 dfc647ee…, OOS e87b6de1…, W3 ad3b0dbd…. M5 / H1 / H4 pins
  unchanged. --set xau_strategy.daily_trend_veto=off reproduces the previous M15
  pins byte for byte. Twin and reference script MD5-equal on every window;
  recorder and feature-export parity re-proved; base-rate table rebuilt.
- DISPLAY (books byte-identical): exposure.py — the lot size for
  trade_size.risk_per_trade (new config.json block, off by default, private to
  the owner) and the count of alerted same-direction plans still open
  (signals.alerted_at, read-only open_alerted_setups); the daily trend in words
  on Telegram, the CLI and the dashboard; signals.daily_trend logged.
- providers/oanda: a date window with no bars is an empty page, not an error
  (a D1 pad starting on a Saturday could not be fetched).
- Reports only, nothing shipped: seven context screens (no state nominated) and
  eight outside data sources (none adds a forecast to gold's own daily trend).
- Independent verification (report section 5.13): every figure reproduced on
  both scripts, scoring identical to the previous commit on all four
  timeframes, no look-ahead. Caveats recorded there: this is a risk filter, not
  a more accurate direction call; nine tenths of the 2019 → 2026 gain is 2022;
  the neighbour clause is thin (a simple 100-day average misses the R line by
  1.2R); "never adds a trade" is an observation, not a guarantee.

Bug review of the whole codebase (2026-10-01). Signal path clean. 17 defects
elsewhere, plus 4 found by an independent review of the fixes; all fixed, each
with a test; no trade book changed:
- telegram_bot: one _deliver_alert for the candle scan and the level break. The
  row is stamped alerted_at BEFORE the send, on a one-worker alert-db pool, and
  un-stamped when no chat received it or the task is cancelled mid-stamp, so
  two alerts at one candle close count each other and no SQLite write runs on
  the event loop. The 5-bar cooldown counts bars of the scan's own frame
  (== gpu_backtest._apply_cooldown; clock time released the 22:00 UTC bar after
  the settlement pause) and is restored from signals.db at start-up. The
  lot-size line goes to the owner's own chat only (a group the owner ran
  /autoscan in used to receive it). /status gains a news-model line and escapes
  failure details.
- signal_log: mark_alerted returns the stamp it wrote; unmark_alerted;
  last_alerted_bars (read-only).
- advisory_outcome: candle continuity is judged bar by bar inside the walk; a
  hole after a trade's exit no longer voids it. The stored-candle replay
  resolves 108 of 111 logged setups instead of 99, all equal to the live
  resolver's R.
- providers/oanda: an empty date window is accepted only when it is 5 days or
  shorter and no malformed candle was skipped; a longer one is retried, then
  refused, instead of being cached as history with a hole.
- news_engine: blank RSS titles skipped; one unreadable headline no longer
  costs the local model its whole batch (checked on the real bundle); model
  health recorded; no DeepSeek audit call in a process that does not archive;
  event-direction cache keyed per release; peek_week_events never fetches.
- llm_client: chat_json never raises and never logs the API key (requests
  quotes the Authorization value in InvalidHeader; error bodies are redacted).
- signal_aggregator: the scan's news block is guarded; the replay path
  (use_news=False) assigns exactly the values it did before.
- agent_panel: PANEL_PROMPT_VERSION v2.2 — same prompt text, corrected input
  (event timing over the cached calendar week, headlines newest first).
  scripts/panel_replay.py replays the logged payload per persona;
  scripts/label_scan_outcomes.py counts with an ISO cutoff.
- test_all.py: no ALTER TABLE … DROP COLUMN (the live box has SQLite 3.34);
  tests that write Parquet print a notice and return without pyarrow.

Verification after the last code change: suite 632 passed, 0 failed (also with
both Parquet engines blocked and FB2_BACKEND=cpu, the live box's view); the
five reference MD5s reproduce on both backtesters, 0 cache fallbacks; Module 37
green; static Python 3.9 / SQLite 3.34 check clean on every touched file.

Evidence: backtest/research/daily_trend_campaign_2026-09-30.md,
context_screens_2026-10.md, new_inputs_2026-10.md; CLAUDE.md sections 2, 4, 6,
8, 9, 10 and 11; pair_signals/xauusd.py docstring (M15 override 7).
2026-10-01 10:28:28 -04:00

2275 lines
104 KiB
Python
Executable File

"""
ForexBot2 — Signal Aggregator
==============================
Combines multi-timeframe technical analysis with optional news context
into a graded actionable signal.
"""
import hashlib
import json
import logging
import os
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Dict, List, Optional
from zoneinfo import ZoneInfo
import numpy as np
import pandas as pd
_NY_TZ = ZoneInfo("America/New_York")
# Prefix of the cost-floor veto both core setup generators emit; the XAU_CP
# cleanup and the post-archetype stale-veto pass match on it, so it is
# spelled once.
_SPREAD_GATE_PREFIX = "SPREAD GATE:"
from chart_patterns import detect_chart_pattern
from config import PlatformConfig, SignalStrength, Timeframe
from providers import BaseDataProvider, make_data_provider
from engine import TechnicalEngine, TechnicalAnalysis, regime_weight_table
from news_engine import NewsEngine, FundamentalContext
import signal_log
logger = logging.getLogger(__name__)
# ── Grading rule (score + confidence -> final signal and grade) ─────────
# "pair" is the shipped rule; "min" / "product" are the 2026-09-27 research
# candidates (backtest/research/conviction_campaign_2026-09-27.md). The CUDA
# twin (backtest/cuda_signal_kernels.py, K2) mirrors these helpers and packs
# CONVICTION_THRESHOLDS from here, so this module is the single source.
GRADING_MODES = ("pair", "min", "product")
# (aplus, a, b, c, strong, buy, boost_a, boost_b) on the conviction scale.
# "min": the control's corners on the |score| axis (0.6 = the control's
# score / confidence ratio). "product": the control's corner products
# 40x65, 33x55, 28x50, 25x45, 35x55, 25x50, 30x50, 25x45, each / 100.
CONVICTION_THRESHOLDS = {
"min": (40.0, 33.0, 28.0, 25.0, 35.0, 25.0, 30.0, 25.0),
"product": (26.0, 18.15, 14.0, 11.25, 19.25, 12.5, 15.0, 11.25),
}
def grading_mode_of(signals_cfg) -> str:
"""The validated `signals.grading_mode`; an unknown value is a hard error."""
mode = signals_cfg.grading_mode
if mode not in GRADING_MODES:
raise ValueError(
f"signals.grading_mode={mode!r}; expected one of {GRADING_MODES}"
)
return mode
def conviction(abs_score: float, confidence: float, mode: str) -> float:
"""The single decision number of the research modes. Evaluation order is
load-bearing for the CUDA mirror: min(a, b) with b = 0.6 * confidence in
that argument order (PYMIN), product as (abs_score * confidence) / 100."""
if mode == "min":
return min(abs_score, 0.6 * confidence)
if mode == "product":
return abs_score * confidence / 100.0
raise ValueError(f"conviction() has no formula for grading_mode={mode!r}")
def _signal_pair(score: float, confidence: float) -> SignalStrength:
"""The shipped final-signal rule (validated literals; do not edit)."""
if score >= 35 and confidence >= 55:
return SignalStrength.STRONG_BUY
elif score >= 25 and confidence >= 50:
return SignalStrength.BUY
elif score <= -35 and confidence >= 55:
return SignalStrength.STRONG_SELL
elif score <= -25 and confidence >= 50:
return SignalStrength.SELL
return SignalStrength.NEUTRAL
def _quality_pair(abs_score: float, confidence: float, mtf: bool) -> str:
"""The shipped grade table (validated literals; do not edit)."""
if abs_score >= 40 and confidence >= 65 and mtf:
return "A+"
elif abs_score >= 33 and confidence >= 55 and mtf:
return "A"
elif abs_score >= 28 and confidence >= 50:
return "B"
elif abs_score >= 25 and confidence >= 45:
return "C"
elif abs_score >= 15:
return "D"
return "F"
def _signal_conv(score: float, conv: float, mode: str) -> SignalStrength:
t_strong, t_buy = CONVICTION_THRESHOLDS[mode][4:6]
if score > 0 and conv >= t_strong:
return SignalStrength.STRONG_BUY
elif score > 0 and conv >= t_buy:
return SignalStrength.BUY
elif score < 0 and conv >= t_strong:
return SignalStrength.STRONG_SELL
elif score < 0 and conv >= t_buy:
return SignalStrength.SELL
return SignalStrength.NEUTRAL
def _quality_conv(abs_score: float, conv: float, mtf: bool, mode: str) -> str:
t_aplus, t_a, t_b, t_c = CONVICTION_THRESHOLDS[mode][0:4]
if conv >= t_aplus and mtf:
return "A+"
elif conv >= t_a and mtf:
return "A"
elif conv >= t_b:
return "B"
elif conv >= t_c:
return "C"
elif abs_score >= 15:
return "D"
return "F"
def _final_signal_and_grade(score: float, confidence: float, mtf: bool, mode: str):
"""(final_signal, signal_quality) for the post-penalty score / confidence.
"pair" is the shipped rule; the research modes grade on one conviction
number. The single dispatch point the CUDA K2 twin mirrors."""
if mode == "pair":
return _signal_pair(score, confidence), _quality_pair(abs(score), confidence, mtf)
conv = conviction(abs(score), confidence, mode)
return _signal_conv(score, conv, mode), _quality_conv(abs(score), conv, mtf, mode)
def _transitioning_boost(abs_score: float, confidence: float, mtf: bool, mode: str) -> Optional[str]:
"""Gate 6c's grade lift in a transitioning regime: "A", "B" or None.
The "pair" branch is the shipped rule (validated literals; do not edit)."""
if mode == "pair":
if abs_score >= 30 and confidence >= 50 and mtf:
return "A"
elif abs_score >= 25 and confidence >= 45:
return "B"
return None
conv = conviction(abs_score, confidence, mode)
t_boost_a, t_boost_b = CONVICTION_THRESHOLDS[mode][6:8]
if conv >= t_boost_a and mtf:
return "A"
elif conv >= t_boost_b:
return "B"
return None
# ── Daily trend (campaign 2026-09-30) ────────────────────────────────
# One definition for the live scan, the bar replay and the CUDA twin's host
# side (backtest/cudaBasedBacktesting.trend_flags calls daily_trend_series on
# the whole D1 frame and indexes it). Every step is causal (the Sunday
# filter, the EMA recursion, the shift), so the value on a prefix slice is
# bit-identical to the value at the same bar of the full series.
DAILY_TREND_MODES = ("off", "both", "buy", "sell")
DAILY_TREND_EMA_SPAN = 50 # validated literals (do not edit): the
DAILY_TREND_LOOKBACK = 60 # campaign tested exactly this definition
_DAILY_TREND_PREFIX = "DAILY TREND FILTER:"
def daily_trend_mode_of(xau_cfg) -> str:
"""The validated `xau_strategy.daily_trend_veto`; an unknown value is a hard error."""
mode = xau_cfg.daily_trend_veto
if mode not in DAILY_TREND_MODES:
raise ValueError(
f"xau_strategy.daily_trend_veto={mode!r}; expected one of {DAILY_TREND_MODES}"
)
return mode
def daily_trend_needed(config: PlatformConfig) -> bool:
"""True when the scan needs the D1 frame: the veto is on for a gold config."""
return (daily_trend_mode_of(config.xau_strategy) != "off"
and config.xau_strategy.enabled
and config.display_name == "XAU/USD")
def daily_trend_series(d1: Optional[pd.DataFrame]) -> pd.Series:
"""Daily trend per CLOSED D1 bar: +1 when the close is above its 50-bar
EMA and above the close 60 bars earlier, -1 when below both, else 0.
`d1` must hold closed bars only (the provider drops the forming candle;
the replay slices as-of). Bars labelled Sunday are dropped first: the
provider's UTC-aligned daily candles include a 1-2 hour stub for the
Sunday reopening, which is not a trading day. The first 60 kept bars
read 0 (no 60-bar change yet)."""
if d1 is None or len(d1) == 0:
return pd.Series([], dtype="int8")
kept = d1[d1.index.weekday != 6]
close = kept["Close"].astype("float64")
ema = close.ewm(span=DAILY_TREND_EMA_SPAN, adjust=False).mean()
change = close / close.shift(DAILY_TREND_LOOKBACK) - 1.0
up = (close > ema) & (change > 0)
down = (close < ema) & (change < 0)
state = np.where(up, 1, np.where(down, -1, 0)).astype("int8")
return pd.Series(state, index=kept.index)
def daily_trend(d1: Optional[pd.DataFrame]) -> Optional[int]:
"""The daily trend at the last closed D1 bar of `d1` (+1 / 0 / -1), or
None when the frame holds no usable bar."""
series = daily_trend_series(d1)
if len(series) == 0:
return None
return int(series.iloc[-1])
def _is_forex_weekend(now_utc: datetime) -> bool:
"""Gold/FX market closed for the weekend: Friday 17:00 → Sunday 18:00
NEW YORK time (= Fri 21:00 UTC in US summer, 22:00 in winter —
matches measured Oanda feed behavior; 2026-08-04 audit I43).
Deliberate copy of providers.base._is_forex_weekend — a Module 21
test asserts the copies agree across DST transitions."""
ny = now_utc.astimezone(_NY_TZ)
wd = ny.weekday()
if wd == 4: # Friday
return ny.hour >= 17
if wd == 5: # Saturday
return True
if wd == 6: # Sunday
return ny.hour < 18
return False
@dataclass
class TradeSetup:
pair: str
display_name: str
direction: str # "BUY" / "SELL"
entry_price: float
stop_loss: float
take_profit_1: float
take_profit_2: float
risk_reward: float
risk_pips: float
reward_pips: float
pip_value: float
# Entry archetype — selects trail params and explains the setup to
# advisory consumers. setup_pattern is populated only by XAU_CP.
strategy: str = "GENERIC"
setup_pattern: Optional[str] = None
# Strategy cost assumption, not a broker/data-provider quote.
round_trip_cost: Optional[float] = None
# Trailing exit params stamped from the PRODUCING config, so per-TF
# factory overrides (e.g. M15 arm 0.70, M5 dist 0.85) reach every
# consumer. None = legacy/unstamped setup — formatters fall back to
# platform defaults; 0.0 means trailing is genuinely disabled.
trail_arm_r: Optional[float] = None
trail_dist_r: Optional[float] = None
# Post-decision stake multiplier from position_sizing (advisory
# overlay). None = unsized — every gate/exit path ignores it, and
# the backtester's R accounting is unaffected unless it opts in.
stake_multiplier: Optional[float] = None
@dataclass
class SignalReport:
symbol: str
display_name: str
timestamp: str
primary_signal: SignalStrength = SignalStrength.NEUTRAL
primary_score: float = 0.0
primary_confidence: float = 0.0
mtf_agreement: bool = False
mtf_signals: Dict[str, SignalStrength] = field(default_factory=dict)
mtf_scores: Dict[str, float] = field(default_factory=dict)
primary_analysis: Optional[TechnicalAnalysis] = None
confirmation_analyses: List[TechnicalAnalysis] = field(default_factory=list)
event_risk: str = "clear"
event_description: str = ""
news_sentiment: float = 0.0
news_description: str = ""
news_summary: str = "" # LLM market-context blurb (display-only)
# LLM agent-panel consensus (advisory/DISPLAY-ONLY). Populated
# post-hoc by consumers via agent_panel.attach_panel — NEVER inside
# analyze_from_dataframes (backtest parity) and never read by any
# gate or scoring path.
panel_verdict: str = "" # "AGREE" / "NEUTRAL" / "OPPOSE" / ""
panel_confidence: float = 0.0
panel_rationale: str = ""
panel_takes: List[Dict] = field(default_factory=list)
panel_created_at: str = ""
panel_cache_status: str = ""
panel_payload_hash: str = ""
panel_verdict_source: str = "" # "mean" / "macro" / "chair" / ""
# v2 panel (2026-09-17): the mean persona probability (0-100) that
# the trade closes profitably, the prompt version that produced it,
# the exact per-persona payloads and the shared code-computed
# context — all advisory/DISPLAY-ONLY, logged for the offline replay
# harness (scripts/panel_replay.py) and the calibration scorecard.
panel_probability: float = 0.0
panel_prompt_version: str = ""
panel_payloads: Dict = field(default_factory=dict)
panel_context: Dict = field(default_factory=dict)
# Violent-move watch line (advisory/DISPLAY-ONLY). Stamped post-hoc
# by surfaces via violent_watch.attach_watch — never populated
# inside analyze_from_dataframes (backtest parity) and never read by
# any gate, grade or setup generator.
watch_line: str = ""
final_signal: SignalStrength = SignalStrength.NEUTRAL
final_score: float = 0.0
final_confidence: float = 0.0
action_recommendation: str = ""
signal_quality: str = "" # A+, A, B, C, D, F
trade_setup: Optional[TradeSetup] = None
bullish_reasons: List[str] = field(default_factory=list)
bearish_reasons: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
# Warnings a shipped setup superseded, removed from `warnings` but
# logged so they stay auditable (metadata only): the vetoes the
# XAU_CP acceptance stripped and, since 2026-09-08, a core
# generator's stale spread-gate veto when a later generator produced
# the setup (`_drop_stale_spread_gate_vetoes`).
cleaned_warnings: List[str] = field(default_factory=list)
current_price: float = 0.0
spread_estimate: float = 0.0
market_regime: str = ""
volatility_regime: str = ""
exhaustion_count: int = 0
# Record-keeping only (no gate reads these): the penalty tags that
# fired in _compute_final_signal (tag -> RAW pre-decay amount), the
# decayed total actually subtracted from confidence, and whether the
# momentum-fading half of the exhaustion block was true.
penalty_tags: Dict[str, float] = field(default_factory=dict)
penalty_total: float = 0.0
momentum_fading: bool = False
# Daily trend at the last closed D1 bar (+1 up / 0 unclear / -1 down);
# None = not computed. Set by the core when the daily-trend veto is on
# (_apply_daily_trend_veto); where the veto is off the LIVE wrapper
# stamps it for the alert text and the signal log only
# (_stamp_daily_trend_for_display) — no gate reads it there.
daily_trend: Optional[int] = None
# Lot-size and open-exposure lines (advisory/DISPLAY-ONLY). Stamped
# post-hoc by surfaces via exposure.attach_exposure — never populated
# inside analyze_from_dataframes and never read by any gate.
size_line: str = ""
open_line: str = ""
class SignalAggregator:
"""Master aggregator for live signal analysis."""
def __init__(self, config: PlatformConfig):
self.config = config
grading_mode_of(config.signals) # fail at start, not at the first bar
daily_trend_mode_of(config.xau_strategy)
if not config.xau_strategy.wide_stop_cap_atr >= 0:
raise ValueError(
f"xau_strategy.wide_stop_cap_atr={config.xau_strategy.wide_stop_cap_atr!r}; "
f"expected a number >= 0 (0 = off)")
self.data_provider = make_data_provider(config)
self.tech_engine = TechnicalEngine(config.technical)
self._news_engine: Optional[NewsEngine] = None # lazy
def _get_news_engine(self) -> NewsEngine:
if self._news_engine is None:
self._news_engine = NewsEngine(self.config.news)
return self._news_engine
# ── Public: live mode entry point ──────────────────────────────
def analyze_pair(
self,
symbol: Optional[str] = None,
log_source: Optional[str] = None,
) -> SignalReport:
"""Live-mode entry: fetch all needed timeframes then delegate.
Every report produced here (including weekend/day-filter early
returns) is persisted to the signal-history DB via _log_signal —
fail-safe, and never reached by the backtester, which calls
analyze_from_dataframes directly (live == backtest parity)."""
if symbol is None:
symbol = self.config.symbol
pair_info = self.data_provider.get_pair_info(symbol)
now_utc = datetime.now(timezone.utc)
if pair_info["display"] == "XAU/USD" and self.config.xau_strategy.enabled:
xau_cfg = self.config.xau_strategy
if xau_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: XAU/USD market closed — no liquidity")
report.action_recommendation = "NO TRADE — Weekend (market closed)"
self._log_signal(report, log_source, blocked=True)
return report
if xau_cfg.allowed_weekdays and now_utc.weekday() not in xau_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 xau_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}"
self._log_signal(report, log_source, blocked=True)
return report
timeframes = [self.config.primary_timeframe] + list(
self.config.confirmation_timeframes
)
bt = self.config.backtest
if bt.regime_filter and bt.regime_filter_timeframe not in timeframes:
timeframes.append(bt.regime_filter_timeframe)
# The daily-trend veto reads the D1 frame inside the core, so it is
# a REQUIRED frame when the veto is on (a failed fetch fails the
# scan below, as for any other frame). The backtesters append it
# under the same guard (rule 4).
if daily_trend_needed(self.config) and Timeframe.D1 not in timeframes:
timeframes.append(Timeframe.D1)
dfs: Dict[Timeframe, pd.DataFrame] = {}
stale_tfs = []
for tf in timeframes:
try:
df = self.data_provider.fetch_ohlcv(symbol, tf)
dfs[tf] = df
if not self.data_provider.is_data_fresh(df, symbol, tf):
stale_tfs.append(tf.value)
except Exception as e:
logger.warning(f"Fetch failed for {symbol} {tf.value}: {e}")
# A missing frame is a FAILED scan, never a degraded one. Losing a
# CONFIRMATION frame used to let the scan continue with fewer
# frames: _check_mtf_agreement needs >= 2 signals, so it returned
# False and the composite took a 15-point mtf_disagreement penalty
# the market never earned (A/A+ became impossible), while on
# M15/H1/H4 a B/C setup could still ship on one confirmation with
# no warning at all. The backtester never sees that state
# (run_backtest raises on an empty frame), so it was a live-only
# divergence. 2026-09-13 audit.
missing = [tf for tf in timeframes if tf not in dfs]
if missing:
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
if self.config.primary_timeframe in missing:
# Legacy text signal_log.gate_reason already recognises.
report.warnings.append("Primary timeframe data unavailable")
report.warnings.append(
f"DATA UNAVAILABLE: {', '.join(tf.value for tf in missing)}"
" — scan failed"
)
report.action_recommendation = "NO TRADE — market data unavailable"
self._log_signal(report, log_source, dfs, failed=True)
return report
report = self.analyze_from_dataframes(
symbol=symbol,
dfs=dfs,
use_news=True,
)
# Live-only hand-off for the post-hoc advisory add-ons (the agent
# panel's price context reads the frames this scan analysed, so
# it sees exactly the bars the engine saw). A private attribute
# like _signal_log_rowid: not a dataclass field, never read by
# the core, absent on backtest replays by construction.
report._frames = dfs
if stale_tfs:
report.warnings.append(f"STALE DATA on {', '.join(stale_tfs)} — signal may be unreliable")
if self.config.primary_timeframe.value in stale_tfs:
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.trade_setup = None
report.warnings.append("Primary timeframe stale — signal suppressed")
if bt.regime_filter:
self._apply_regime_filter_live(report, dfs)
# Session window + blocked hours on the signal bar's CLOSE time —
# the exact predicate the backtester's replay loop applies before
# each bar, so live and backtest cannot diverge. Bar-close time
# (not wall clock) makes manual mid-candle scans behave like
# boundary auto-scans. This replaces an older wall-clock filter
# that was guarded by a marker string no code wrote.
if bt.session_filter and report.primary_analysis is not None:
try:
bar_close = (
pd.Timestamp(report.timestamp)
+ pd.Timedelta(
seconds=BaseDataProvider.CANDLE_SECONDS[
self.config.primary_timeframe
]
)
)
close_h = bar_close.hour
_is_xau = pair_info["display"] == "XAU/USD"
_blocked = (self.config.xau_strategy.blocked_hours_utc
if _is_xau else bt.blocked_hours_utc)
outside = not (bt.session_start_hour <= close_h < bt.session_end_hour)
if outside or close_h in _blocked:
report.warnings.append(
f"SESSION FILTER: bar closes {close_h:02}:00 UTC — "
+ ("outside window" if outside else "blocked hour")
)
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.action_recommendation = "NO TRADE — Outside tradeable session"
report.trade_setup = None
except Exception:
# Fail-open by design (an unparseable timestamp shouldn't
# kill the scan) but never silently: this filter guards
# real money hours.
logger.exception(
f"{symbol}: live session filter errored — filter SKIPPED this scan"
)
self._stamp_daily_trend_for_display(report, dfs)
self._log_signal(report, log_source, dfs)
return report
def _stamp_daily_trend_for_display(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> None:
"""Daily trend for the alert text and the signal log on the
timeframes where the daily-trend veto is OFF (the core stamps it
where the veto is on). Display and record-keeping only: the frame
is never added to `dfs`, nothing reads the value back, and a failed
fetch leaves it None instead of failing the scan. The scan's own D1
frame is used when it has one (H4 confirms on D1); otherwise the
provider's D1 frame, cached until the next daily close and
refreshed by the M15 scan, which needs it as a required frame.
Live wrapper only — the backtesters never come through here."""
if report.daily_trend is not None or report.display_name != "XAU/USD":
return
if report.primary_analysis is None:
return
try:
d1 = dfs.get(Timeframe.D1)
if d1 is None:
d1 = self.data_provider.fetch_ohlcv(report.symbol, Timeframe.D1)
report.daily_trend = daily_trend(d1)
except Exception:
logger.debug("daily trend (display) unavailable", exc_info=True)
def _log_signal(
self, report: SignalReport, source: Optional[str],
dataframes: Optional[Dict[Timeframe, pd.DataFrame]] = None,
blocked: bool = False, failed: bool = False,
) -> None:
"""Persist the scan to the signal-history DB (signal_log.py).
Fail-safe by contract — a logging failure must never break a
live scan. The news context re-fetch rides the NewsEngine's
class-level caches, so it costs nothing within a scan and
matches the context the signal just used; if it fails, the row
is still written, just without the news JSON blobs. ``blocked``
marks weekend/day early returns: no news fetch, no snapshots —
a ~1 KB liveness row (or nothing, per signal_log.log_blocked_scans).
A scan missing ANY required frame (primary or confirmation, or
the regime-filter frame when that filter is on) is logged as
``scan_kind`` ``failed`` (price 0, no signal): liveness evidence
that readers skip like ``blocked``, so it never pollutes NEUTRAL
statistics."""
context = None
if not blocked:
try:
context = self._get_news_engine().get_fundamental_context()
except Exception:
logger.debug("signal log: news context unavailable",
exc_info=True)
kind = "blocked" if blocked else "scan"
primary = (dataframes or {}).get(self.config.primary_timeframe)
if failed or (not blocked and dataframes is not None
and (primary is None or primary.empty)):
kind = "failed"
extra = {"scan_kind": kind}
for key, fingerprint in (
("config_fingerprint", self.gate_config_fingerprint),
("engine_fingerprint", self.engine_config_fingerprint)):
try:
extra[key] = fingerprint()
except Exception:
logger.debug("signal log: %s unavailable", key, exc_info=True)
signal_log.log_signal(
report,
context=context,
source=source or "live",
timeframe=self.config.primary_timeframe.value,
dataframes=dataframes,
extra=extra,
)
def gate_config_fingerprint(self) -> str:
"""Short hash of the gate configuration this process runs with.
Logged on every row so two surfaces disagreeing on the same bar
(2026-08-19: the dashboard process applied a different blocked-
hours list than the bot for a day) is diagnosable from the DB."""
xs = self.config.xau_strategy
bt = self.config.backtest
payload = {
"primary": self.config.primary_timeframe.value,
"confirm": [tf.value for tf in self.config.confirmation_timeframes],
"blocked_hours": list(xs.blocked_hours_utc),
"sell_blocked_hours": list(xs.sell_blocked_hours_utc),
"allowed_weekdays": list(xs.allowed_weekdays),
"blocked_regimes": list(xs.blocked_regimes),
"sell_blocked_regimes": list(xs.sell_blocked_regimes),
"sell_min_quality": list(xs.sell_min_quality),
"sell_vol_regimes": list(xs.sell_vol_regimes),
"big_candle": xs.big_candle_atr_multiple,
"cp": bool(xs.cp_enabled),
"pb": bool(xs.m5_pullback_enabled),
"session": [bt.session_filter, bt.session_start_hour,
bt.session_end_hour],
"regime_filter": bool(bt.regime_filter),
}
# 2026-09-30 knobs enter the hash only when they are in force (the
# grading_mode precedent in engine_config_fingerprint): a config
# that leaves them off keeps the fingerprint its live rows carry.
if xs.daily_trend_veto != "off":
payload["daily_trend_veto"] = xs.daily_trend_veto
if xs.wide_stop_cap_atr > 0:
payload["wide_stop_cap_atr"] = xs.wide_stop_cap_atr
return hashlib.sha1(
json.dumps(payload, sort_keys=True, default=str).encode("utf-8")
).hexdigest()[:12]
def engine_config_fingerprint(self) -> str:
"""Short hash of the SCORING configuration this process runs with.
gate_config_fingerprint covers the post-grade gates; this covers
what produced the composite in the first place — indicator
parameters, signal thresholds/geometry, the regime weight
multipliers actually in force (override included) and the
timeframe set. Logged on every row so indicator-weight research
can tell which rows share a scoring configuration."""
signals = asdict(self.config.signals)
# The research grading switch enters the hash only when it is in
# force: under the shipped "pair" rule the scoring is byte-identical
# to the pre-2026-09-27 code, so live rows keep the same fingerprint.
if signals.get("grading_mode") == "pair":
signals.pop("grading_mode")
payload = {
"technical": asdict(self.config.technical),
"signals": signals,
"regime_weights": regime_weight_table(self.config.technical),
"primary": self.config.primary_timeframe.value,
"confirm": [tf.value for tf in self.config.confirmation_timeframes],
"display_name": self.config.display_name,
}
return hashlib.sha1(
json.dumps(payload, sort_keys=True, default=str).encode("utf-8")
).hexdigest()[:12]
def _apply_regime_filter_live(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> None:
"""If higher-TF ADX is below threshold, downgrade signal to NEUTRAL."""
bt = self.config.backtest
regime_tf = bt.regime_filter_timeframe
df = dfs.get(regime_tf)
if df is None or df.empty:
report.warnings.append(
f"Regime filter requested but {regime_tf.value} data unavailable"
)
return
adx_value = self._latest_adx(df, self.config.technical.adx_period)
if adx_value is None:
report.warnings.append(
f"Regime filter: ADX could not be computed on {regime_tf.value}"
)
return
if adx_value < bt.regime_filter_min_adx:
report.warnings.append(
f"REGIME FILTER: {regime_tf.value} ADX={adx_value:.1f} < "
f"{bt.regime_filter_min_adx:.0f} — market choppy, skipping signal"
)
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.action_recommendation = (
f"NO TRADE — Regime filter (ADX {adx_value:.1f} on {regime_tf.value})"
)
report.trade_setup = None
else:
report.bullish_reasons.append(
f"Regime: {regime_tf.value} ADX={adx_value:.1f} confirms trending market"
) if report.final_score > 0 else report.bearish_reasons.append(
f"Regime: {regime_tf.value} ADX={adx_value:.1f} confirms trending market"
)
@staticmethod
def _latest_adx(df: pd.DataFrame, period: int = 14) -> Optional[float]:
"""Wilder ADX on a fresh df; returns the last finite value or None."""
if df is None or len(df) < period * 3:
return None
high, low, close = df["High"], df["Low"], df["Close"]
tr = pd.concat([
high - low,
(high - close.shift()).abs(),
(low - close.shift()).abs(),
], axis=1).max(axis=1)
plus_dm_raw = high.diff()
minus_dm_raw = -low.diff()
plus_dm = plus_dm_raw.where((plus_dm_raw > minus_dm_raw) & (plus_dm_raw > 0), 0.0)
minus_dm = minus_dm_raw.where((minus_dm_raw > plus_dm_raw) & (minus_dm_raw > 0), 0.0)
atr_s = tr.ewm(alpha=1/period, min_periods=period, adjust=False).mean()
plus_di = 100 * (plus_dm.ewm(alpha=1/period, min_periods=period, adjust=False).mean() / atr_s)
minus_di = 100 * (minus_dm.ewm(alpha=1/period, min_periods=period, adjust=False).mean() / atr_s)
denom = (plus_di + minus_di).replace(0, float("nan"))
dx = 100 * (plus_di - minus_di).abs() / denom
adx = dx.ewm(alpha=1/period, min_periods=period, adjust=False).mean().dropna()
if adx.empty:
return None
return float(adx.iloc[-1])
def _detect_big_candle(self, primary_df: pd.DataFrame) -> Optional[tuple]:
"""Largest abnormal candle in the recent primary-TF window.
Returns (timestamp, ratio) of the biggest bar whose range is
>= big_candle_atr_multiple x the PRIOR bar's ATR within the last
big_candle_lookback_hours of primary-TF bars, else None. The
prior-bar ATR (shift 1) matters: a huge candle inflates the
Wilder ATR immediately, so comparing against the concurrent ATR
under-detects the very bars this gate exists to catch.
Pure pandas on the caller-sliced df, so the live path and the
backtest replay (which calls analyze_from_dataframes on prefix
slices) compute bit-identical answers. The tail slice keeps the
per-call cost bounded (~n + 42 rows); min_periods NaNs and short
histories degrade to "no trip".
"""
cfg = self.config.xau_strategy
bar_secs = BaseDataProvider.CANDLE_SECONDS[self.config.primary_timeframe]
n = max(1, int(cfg.big_candle_lookback_hours * 3600 // bar_secs))
period = self.config.technical.atr_period
tail = primary_df.iloc[-(n + 3 * period):]
high, low, close = tail["High"], tail["Low"], tail["Close"]
tr = pd.concat(
[high - low,
(high - close.shift()).abs(),
(low - close.shift()).abs()],
axis=1,
).max(axis=1)
atr = tr.ewm(alpha=1 / period, min_periods=period, adjust=False).mean()
ratio = (high - low) / atr.shift(1)
window = ratio.iloc[-n:]
if window.empty:
return None
max_ratio = window.max()
if pd.isna(max_ratio) or max_ratio < cfg.big_candle_atr_multiple:
return None
return window.idxmax(), float(max_ratio)
def _apply_m5_filters(self, report: SignalReport) -> None:
"""Extra selectivity for M5 — noisy TF needs stronger confirmation."""
if report.signal_quality not in ("A+", "A", "B", "C"):
return
xau_cfg = self.config.xau_strategy
abs_score = abs(report.final_score)
# Gate 1: minimum absolute score (M5 composite scores near the
# threshold are noise — require a wider margin).
if abs_score < xau_cfg.m5_min_abs_score:
report.warnings.append(
f"M5 FILTER: |score| {abs_score:.1f} < {xau_cfg.m5_min_abs_score:.0f} minimum"
)
report.signal_quality = "D"
report.trade_setup = None
return
# Gate 2: require MTF agreement (M5 alone is too noisy).
if xau_cfg.m5_require_mtf and not report.mtf_agreement:
report.warnings.append(
"M5 FILTER: no MTF agreement — M5 signals require confirmation"
)
report.signal_quality = "D"
report.trade_setup = None
return
# Gate 3: at least one confirmation TF must have a meaningful
# score in the same direction — NEUTRAL confirmations don't
# count for M5 since they indicate the move isn't visible on
# higher timeframes yet.
min_conf_score = xau_cfg.m5_min_confirmation_score
is_buy = report.final_score > 0
confirmed = False
primary_tf = self.config.primary_timeframe.value
for tf_val, tf_score in report.mtf_scores.items():
if tf_val == primary_tf:
continue
if is_buy and tf_score >= min_conf_score:
confirmed = True
break
if not is_buy and tf_score <= -min_conf_score:
confirmed = True
break
if not confirmed:
report.warnings.append(
"M5 FILTER: no confirmation TF shows directional conviction"
)
report.signal_quality = "D"
report.trade_setup = None
return
# Gate 4: cap SELL quality on M5 — A-grade SELLs enter at the
# extreme of temporary dips in gold's macro uptrend and lose
# money. B-grade SELLs (weaker conviction, shallower entries)
# are marginally profitable.
is_sell = report.final_score < 0
if is_sell and report.signal_quality not in xau_cfg.m5_sell_max_quality:
report.warnings.append(
f"M5 FILTER: SELL quality {report.signal_quality} capped — "
f"high-conviction M5 shorts enter at extremes"
)
report.signal_quality = "D"
report.trade_setup = None
return
# Gate 5: block chop-transition volatility. 'high' vol on M5 is
# elevated noise without the directional expansion of 'extreme'
# — stops get swept before targets (61% WR, negative total in
# every backtest window; low/normal/extreme all positive).
if report.volatility_regime in xau_cfg.m5_blocked_vol_regimes:
report.warnings.append(
f"M5 FILTER: '{report.volatility_regime}' volatility — "
f"chop-transition regime, stops get swept"
)
report.signal_quality = "D"
report.trade_setup = None
return
# Gate 6: SELL RSI floor — shorting with RSI already < 25 means
# entering at the extreme of the dip (same pathology as Gate 4,
# visible through RSI instead of quality grade).
if is_sell:
rsi_ind = report.primary_analysis.indicators.get("rsi")
if rsi_ind is not None and rsi_ind.value < xau_cfg.m5_sell_min_rsi:
report.warnings.append(
f"M5 FILTER: SELL blocked — RSI {rsi_ind.value:.0f} < "
f"{xau_cfg.m5_sell_min_rsi:.0f}, dip already extended"
)
report.signal_quality = "D"
report.trade_setup = None
return
# ── M5 pullback entry (XAU_PB) ──────────────────────────────────
def _detect_m5_pullback(
self, report: SignalReport, primary_df: pd.DataFrame
) -> Optional[Dict]:
"""Detect a pullback-continuation candidate on the primary TF.
Trend intact (EMA alignment >= 3/4, ADX floor) + price retraced
to EMA21/KC-mid within the last few bars + resumption bar closed
back in trend direction + RSI in the pullback zone (not the
extended zone breakout entries fire in).
"""
cfg = self.config.xau_strategy
pa = report.primary_analysis
if pa is None or len(primary_df) < 60:
return None
levels = pa.key_levels
atr = levels.get("atr", 0.0)
ema_f = levels.get("ema_fast")
ema_m = levels.get("ema_medium")
ema_s = levels.get("ema_slow")
ema_t = levels.get("ema_trend")
if not atr or None in (ema_f, ema_m, ema_s, ema_t):
return None
adx_ind = pa.indicators.get("adx")
rsi_ind = pa.indicators.get("rsi")
if adx_ind is None or rsi_ind is None:
return None
if adx_ind.value < cfg.m5_pullback_adx_min:
return None
close = float(primary_df["Close"].iloc[-1])
open_ = float(primary_df["Open"].iloc[-1])
n_touch = cfg.m5_pullback_touch_bars
recent_low = float(primary_df["Low"].iloc[-n_touch:].min())
recent_high = float(primary_df["High"].iloc[-n_touch:].max())
# Pullback swing anchor for SL: extreme of the last 8 bars.
pb_low = float(primary_df["Low"].iloc[-8:].min())
pb_high = float(primary_df["High"].iloc[-8:].max())
kc_mid = levels.get("kc_mid", ema_m)
lo_atr, hi_atr = cfg.m5_pullback_retrace_atr
rsi = rsi_ind.value
# BUY: uptrend structure, dip to the band, bullish resumption.
up_align = sum([ema_f > ema_m, ema_m > ema_s, ema_s > ema_t,
close > ema_t])
if up_align >= 3:
touched = recent_low <= max(ema_m, kc_mid)
swing_high = float(primary_df["High"].iloc[-40:].max())
depth = (swing_high - pb_low) / atr
resumed = close > open_ and close > ema_f
lo_z, hi_z = cfg.m5_pullback_rsi_buy
if (touched and resumed and lo_atr <= depth <= hi_atr
and lo_z <= rsi <= hi_z):
return {"direction": "BUY", "anchor": pb_low,
"retrace_atr": depth}
# SELL: mirrored.
dn_align = sum([ema_f < ema_m, ema_m < ema_s, ema_s < ema_t,
close < ema_t])
if dn_align >= 3:
touched = recent_high >= min(ema_m, kc_mid)
swing_low = float(primary_df["Low"].iloc[-40:].min())
depth = (pb_high - swing_low) / atr
resumed = close < open_ and close < ema_f
lo_z, hi_z = cfg.m5_pullback_rsi_sell
if (touched and resumed and lo_atr <= depth <= hi_atr
and lo_z <= rsi <= hi_z):
return {"direction": "SELL", "anchor": pb_high,
"retrace_atr": depth}
return None
def _try_m5_pullback(
self, report: SignalReport, primary_df: pd.DataFrame, pip_value: float
) -> None:
"""Build an XAU_PB trade setup if a pullback candidate passes gates."""
cfg = self.config.xau_strategy
pb = self._detect_m5_pullback(report, primary_df)
if pb is None:
return
direction = pb["direction"]
is_buy = direction == "BUY"
# Directional composite floor (lower than Gate 1 by design —
# price mid-pullback scores weaker on the momentum indicators).
signed = report.final_score if is_buy else -report.final_score
if signed < cfg.m5_pullback_min_score:
return
# Confirmation TF must agree (reuse Gate-3 threshold).
min_conf = cfg.m5_min_confirmation_score
confirmed = False
primary_tf = self.config.primary_timeframe.value
for tf_val, tf_score in report.mtf_scores.items():
if tf_val == primary_tf:
continue
if (is_buy and tf_score >= min_conf) or \
(not is_buy and tf_score <= -min_conf):
confirmed = True
break
if not confirmed:
return
# Never against multi-TF consensus (mirror of gate 6f).
sigs = list(report.mtf_signals.values())
bull_n = sum(1 for s in sigs if s in (SignalStrength.BUY, SignalStrength.STRONG_BUY))
bear_n = sum(1 for s in sigs if s in (SignalStrength.SELL, SignalStrength.STRONG_SELL))
thr = self.config.signals.mtf_agreement_threshold
if (is_buy and bear_n >= thr) or (not is_buy and bull_n >= thr):
return
# Volatility gates match the breakout path.
if report.volatility_regime in cfg.m5_blocked_vol_regimes:
return
if (not is_buy and report.volatility_regime
and report.volatility_regime not in cfg.sell_vol_regimes):
return
atr = report.primary_analysis.key_levels.get("atr", 0.0)
entry = report.current_price
min_sl_price = self.config.signals.min_sl_pips * pip_value
if is_buy:
sl_dist = max(entry - (pb["anchor"] - 0.5 * atr), min_sl_price)
sl = entry - sl_dist
tp1 = entry + 1.5 * sl_dist
tp2 = entry + 2.5 * sl_dist
else:
sl_dist = max((pb["anchor"] + 0.5 * atr) - entry, min_sl_price)
sl = entry + sl_dist
tp1 = entry - 1.5 * sl_dist
tp2 = entry - 2.5 * sl_dist
# Spread gate — same economics as the breakout generators.
min_tp = (self.config.signals.round_trip_cost
* self.config.signals.min_tp_cost_multiple)
if 1.5 * sl_dist < min_tp:
return
report.trade_setup = TradeSetup(
pair=report.symbol,
display_name=report.display_name,
direction=direction,
entry_price=entry,
stop_loss=sl,
take_profit_1=tp1,
take_profit_2=tp2,
risk_reward=1.5,
risk_pips=sl_dist / pip_value,
reward_pips=1.5 * sl_dist / pip_value,
pip_value=pip_value,
strategy="XAU_PB",
round_trip_cost=self.config.signals.round_trip_cost,
trail_arm_r=self.config.signals.pb_trail_arm_r,
trail_dist_r=self.config.signals.pb_trail_dist_r,
)
if report.signal_quality not in ("A+", "A", "B"):
report.signal_quality = "B"
report.final_signal = (SignalStrength.BUY if is_buy
else SignalStrength.SELL)
report.action_recommendation = (
f"{direction} — pullback continuation entry"
)
marker = (f"XAU PULLBACK STRATEGY: retrace {pb['retrace_atr']:.1f} "
f"ATR to EMA21/KC-mid, trend resumed")
target = report.bullish_reasons if is_buy else report.bearish_reasons
target.insert(0, marker)
# ── Chart-pattern entry (XAU_CP) ────────────────────────────────
def _try_chart_pattern(
self, report: SignalReport, primary_df: pd.DataFrame, pip_value: float
) -> None:
"""Build an XAU_CP trade setup if a completed chart pattern
passes the pair-level protections.
Unlike the composite paths there is no minimum-score gate: a
reversal pattern completes exactly where the composite is still
pointing the old way. Protection comes from the pattern's own
structural gates plus the validated directional filters below.
"""
cfg = self.config.xau_strategy
pa = report.primary_analysis
if pa is None:
return
atr = pa.key_levels.get("atr", 0.0)
# Same $-ATR floor the swing generator applies (dead tape).
if atr < cfg.min_atr_threshold:
return
pat = detect_chart_pattern(primary_df, atr, cfg)
if pat is None:
return
direction = pat["direction"]
is_buy = direction == "BUY"
# Never against multi-TF consensus (mirror of gate 6f).
sigs = list(report.mtf_signals.values())
bull_n = sum(1 for s in sigs if s in (SignalStrength.BUY, SignalStrength.STRONG_BUY))
bear_n = sum(1 for s in sigs if s in (SignalStrength.SELL, SignalStrength.STRONG_SELL))
thr = self.config.signals.mtf_agreement_threshold
if (is_buy and bear_n >= thr) or (not is_buy and bull_n >= thr):
return
# Directional protections — gold's macro-uptrend asymmetry is
# structural, so the validated SELL gates apply to pattern
# shorts too (quiet-vol shorts fade, blocked SELL hours lose).
if not is_buy:
if (report.volatility_regime
and report.volatility_regime not in cfg.sell_vol_regimes):
return
if cfg.sell_blocked_hours_utc:
try:
close_h = (
pd.Timestamp(report.timestamp)
+ pd.Timedelta(
seconds=BaseDataProvider.CANDLE_SECONDS[
self.config.primary_timeframe
]
)
).hour
except Exception:
close_h = None
logger.warning(
f"{report.symbol}: bar-close hour underivable — "
f"CP SELL hour gate FAILS CLOSED (short rejected)"
)
if close_h is None or close_h in cfg.sell_blocked_hours_utc:
return
if self.config.primary_timeframe == Timeframe.M5:
rsi_ind = pa.indicators.get("rsi")
if rsi_ind is not None and rsi_ind.value < cfg.m5_sell_min_rsi:
return
# M5 chop-transition volatility block (validated, both ways).
if (self.config.primary_timeframe == Timeframe.M5
and report.volatility_regime in cfg.m5_blocked_vol_regimes):
return
entry = report.current_price
min_sl_price = self.config.signals.min_sl_pips * pip_value
height = pat["height"]
if is_buy:
sl_dist = max(entry - (pat["anchor"] - 0.5 * atr), min_sl_price)
sl = entry - sl_dist
tp1 = entry + height
tp2 = entry + 1.5 * height
else:
sl_dist = max((pat["anchor"] + 0.5 * atr) - entry, min_sl_price)
sl = entry + sl_dist
tp1 = entry - height
tp2 = entry - 1.5 * height
# The measured move IS the pattern target — if it can't clear
# the RR floor or the spread gate, the pattern is untradeable.
rr = height / sl_dist if sl_dist > 0 else 0.0
if rr < self.config.signals.min_rr_ratio - 1e-9:
return
min_tp = (self.config.signals.round_trip_cost
* self.config.signals.min_tp_cost_multiple)
if height < min_tp:
return
# Wide-stop cap AFTER the gates (xau_strategy.wide_stop_cap_atr,
# 0 = off), same contract as the swing generator's.
cap_dist = self._wide_stop_cap(report, atr, min_sl_price)
if cap_dist is not None and sl_dist > cap_dist:
sl_dist = cap_dist
sl = entry - cap_dist if is_buy else entry + cap_dist
rr = height / sl_dist
# Printed-target floor AFTER the gates (2026-09-26, see
# config.SignalConfig.tp_print_floor_rr): the measured move stays
# the gated target; the printed guide is at least the floor.
# Mirrored in cuda_signal_kernels.py K2.
tp1_dist = height
floor_dist = sl_dist * self._printed_target_floor()
if tp1_dist < floor_dist:
tp1_dist = floor_dist
if is_buy:
tp1 = entry + floor_dist
tp2 = entry + 1.5 * floor_dist
else:
tp1 = entry - floor_dist
tp2 = entry - 1.5 * floor_dist
rr = tp1_dist / sl_dist
report.trade_setup = TradeSetup(
pair=report.symbol,
display_name=report.display_name,
direction=direction,
entry_price=entry,
stop_loss=sl,
take_profit_1=tp1,
take_profit_2=tp2,
risk_reward=round(rr, 2),
risk_pips=sl_dist / pip_value,
reward_pips=tp1_dist / pip_value,
pip_value=pip_value,
strategy="XAU_CP",
setup_pattern=pat["pattern"],
round_trip_cost=self.config.signals.round_trip_cost,
trail_arm_r=self.config.signals.trail_arm_r,
trail_dist_r=self.config.signals.trail_dist_r,
)
if report.signal_quality not in ("A+", "A", "B"):
report.signal_quality = "B"
report.final_signal = (SignalStrength.BUY if is_buy
else SignalStrength.SELL)
report.action_recommendation = (
f"{direction} — {pat['pattern']} pattern completion"
)
marker = (f"XAU CHART PATTERN: {pat['pattern']} "
f"({direction}) break of {pat['trigger']:.2f}, "
f"height {height / atr:.1f} ATR")
target = report.bullish_reasons if is_buy else report.bearish_reasons
target.insert(0, marker)
self._clean_accepted_cp_warnings(report)
@staticmethod
def _clean_accepted_cp_warnings(report: SignalReport) -> None:
"""Drop superseded composite-path warnings after XAU_CP accepts.
This is metadata-only. Pattern detection, gates, confidence,
exhaustion state, setup geometry, and signal selection are already
final when this runs.
"""
stale_prefixes = (
_SPREAD_GATE_PREFIX,
"EXHAUSTION BLOCK",
"EXHAUSTED TREND FILTER:",
"MTF OPPOSITION:",
"SELL FILTER: ",
"M5 FILTER: |score|",
"M5 FILTER: no MTF agreement",
"M5 FILTER: no confirmation TF",
"M5 FILTER: SELL quality",
"M5 FILTER: SELL blocked",
)
extension_prefixes = (
"TREND EXHAUSTION",
"NO PULLBACK",
"FAR FROM EMA200",
"Extended — RSI",
"Weak pullback",
)
filtered: List[str] = []
dropped: List[str] = []
for warning in report.warnings:
if warning.startswith(stale_prefixes):
dropped.append(warning)
continue
if (warning.startswith("REGIME FILTER: '")
and warning.endswith("blocked for XAU")):
dropped.append(warning)
continue
if (warning.startswith("M5 FILTER: '")
and "volatility" in warning):
dropped.append(warning)
continue
filtered.append(warning)
extension_items = [
(index, warning)
for index, warning in enumerate(filtered)
if warning.startswith(extension_prefixes)
]
if not extension_items:
report.warnings[:] = filtered
report.cleaned_warnings.extend(dropped)
return
first_index = extension_items[0][0]
priority = {prefix: rank for rank, prefix in enumerate(extension_prefixes)}
strongest = min(
(warning for _, warning in extension_items),
key=lambda warning: next(
rank for prefix, rank in priority.items()
if warning.startswith(prefix)),
)
kept = [
warning for warning in filtered
if not warning.startswith(extension_prefixes)
]
kept.insert(first_index, strongest)
dropped.extend(
warning for _, warning in extension_items if warning != strongest)
report.warnings[:] = kept
report.cleaned_warnings.extend(dropped)
# ── Public: bar-replayable entry point ─────────────────────────
def analyze_from_dataframes(
self,
symbol: str,
dfs: Dict[Timeframe, pd.DataFrame],
context: Optional[FundamentalContext] = None,
use_news: bool = True,
) -> SignalReport:
"""
Run the full analysis pipeline on already-fetched dataframes.
`dfs` must include the primary timeframe and ideally the
confirmation timeframes. Each df is treated as the visible history
at decision time — the backtester is responsible for slicing so
no future bars leak in.
"""
pair_info = self.data_provider.get_pair_info(symbol)
display = pair_info["display"]
pip_value = pair_info["pip_value"]
report = SignalReport(
symbol=symbol,
display_name=display,
timestamp=str(datetime.now(timezone.utc)),
)
# ── 1. Primary timeframe ───────────────────────────────────
primary_df = dfs.get(self.config.primary_timeframe)
if primary_df is None or primary_df.empty:
report.warnings.append("Primary timeframe data unavailable")
report.signal_quality = "F"
return report
primary_analysis = self.tech_engine.analyze(
primary_df, symbol, self.config.primary_timeframe.value
)
report.primary_analysis = primary_analysis
report.primary_signal = primary_analysis.composite_signal
report.primary_score = primary_analysis.composite_score
report.primary_confidence = primary_analysis.confidence
report.market_regime = primary_analysis.market_regime
report.volatility_regime = primary_analysis.volatility_regime
report.current_price = float(primary_df["Close"].iloc[-1])
report.timestamp = str(primary_df.index[-1])
report.mtf_signals[self.config.primary_timeframe.value] = primary_analysis.composite_signal
report.mtf_scores[self.config.primary_timeframe.value] = primary_analysis.composite_score
# ── 2. Confirmation timeframes ─────────────────────────────
for tf in self.config.confirmation_timeframes:
cf_df = dfs.get(tf)
if cf_df is None or cf_df.empty:
continue
try:
cf_analysis = self.tech_engine.analyze(cf_df, symbol, tf.value)
report.confirmation_analyses.append(cf_analysis)
report.mtf_signals[tf.value] = cf_analysis.composite_signal
report.mtf_scores[tf.value] = cf_analysis.composite_score
except Exception as e:
logger.warning(f"Confirmation TF {tf.value} failed for {symbol}: {e}")
# ── 3. MTF agreement ───────────────────────────────────────
report.mtf_agreement = self._check_mtf_agreement(report)
# ── 4. Fundamental context (optional) ──────────────────────
# News is display and warning only (it never gates), so a failure
# anywhere in it must not cost the scan: the report then carries
# no news at all, the values the replay path (use_news=False)
# always has. All five fields are set together or not at all.
news_fields = ("clear", "", 0.0, "", "")
if use_news:
try:
news = self._get_news_engine()
if context is None:
context = news.get_fundamental_context()
event_risk, event_desc = news.get_event_risk_level(symbol, context)
sentiment, sent_desc = news.get_pair_sentiment(symbol, context)
news_fields = (event_risk, event_desc, sentiment, sent_desc,
context.llm_summary)
except Exception:
logger.warning(
f"News context failed for {symbol} — the scan continues "
f"without news", exc_info=True)
(report.event_risk, report.event_description, report.news_sentiment,
report.news_description, report.news_summary) = news_fields
# ── 5. Spread estimate from primary data (avoids extra API call) ─
try:
report.spread_estimate = float(
(primary_df["High"] - primary_df["Low"]).median()
)
except Exception:
report.spread_estimate = 0.0
# ── 6. Final signal ────────────────────────────────────────
self._compute_final_signal(report)
# ── 6b. Session filter (works for both live and backtest) ─
if self.config.backtest.session_filter:
try:
bar_ts = pd.Timestamp(report.timestamp)
bar_hour = bar_ts.hour
bar_wd = bar_ts.weekday()
_is_xau_pair = report.display_name == "XAU/USD"
_blocked_hours = (self.config.xau_strategy.blocked_hours_utc
if _is_xau_pair else self.config.backtest.blocked_hours_utc)
if bar_hour in _blocked_hours:
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.warnings.append(f"SESSION FILTER: {bar_hour:02}:00 UTC blocked")
_allowed_wd = (self.config.xau_strategy.allowed_weekdays
if _is_xau_pair else self.config.backtest.allowed_weekdays)
if _allowed_wd and bar_wd not in _allowed_wd:
day_names = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu",
4: "Fri", 5: "Sat", 6: "Sun"}
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.warnings.append(
f"DAY FILTER: {day_names.get(bar_wd, '?')} not in allowed days"
)
except Exception:
# Fail-open by design, but never silently (see live wrapper).
logger.exception(
f"{symbol}: session/day filter errored — filter SKIPPED"
)
# ── 6c. Transitioning regime quality boost ──────────────────
if report.market_regime == "transitioning" and report.signal_quality in ("B", "C", "D"):
abs_score = abs(report.final_score)
boost = _transitioning_boost(
abs_score, report.final_confidence, report.mtf_agreement,
grading_mode_of(self.config.signals),
)
if boost == "A":
report.signal_quality = "A"
report.action_recommendation = f"{report.final_signal.name.replace('_', ' ')} — Trend emerging, early entry"
elif boost == "B":
report.signal_quality = "B"
report.action_recommendation = f"{report.final_signal.name.replace('_', ' ')} — Trend forming, watch closely"
# ── 6d. Exhausted trend quality cap ───────────────────────
if (report.market_regime.startswith("trending")
and report.exhaustion_count >= 2
and report.signal_quality in ("C", "D")):
report.warnings.append(
"EXHAUSTED TREND FILTER: C-grade blocked in exhausted trend"
)
report.signal_quality = "D"
report.trade_setup = None
# ── 6e. SELL quality gate ─────────────────────────────────
xau_is_sell_dir = (
report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL)
or (report.final_signal == SignalStrength.NEUTRAL and report.final_score < 0)
)
# Actionable grades only: without this guard the gate would "upgrade"
# a session-blocked F report to D, which re-opens the M5 pullback
# path (its entry check is quality != "F").
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.signal_quality not in self.config.xau_strategy.sell_min_quality):
report.warnings.append(
f"SELL FILTER: quality {report.signal_quality} below minimum for SELL"
)
report.signal_quality = "D"
report.trade_setup = None
# ── 6f. MTF opposition gate ───────────────────────────────
# Never trade against a multi-timeframe consensus: if the
# threshold number of TFs point opposite to the trade direction,
# block the setup (counter-trend entries in gold bleed).
if report.signal_quality in ("A+", "A", "B", "C") and report.mtf_signals:
sigs = list(report.mtf_signals.values())
bull_n = sum(1 for s in sigs if s in (SignalStrength.BUY, SignalStrength.STRONG_BUY))
bear_n = sum(1 for s in sigs if s in (SignalStrength.SELL, SignalStrength.STRONG_SELL))
thr = self.config.signals.mtf_agreement_threshold
opposed = (bear_n >= thr and report.final_score > 0) or \
(bull_n >= thr and report.final_score < 0)
if opposed:
report.warnings.append(
"MTF OPPOSITION: majority of timeframes point against signal — no trade"
)
report.signal_quality = "D"
report.trade_setup = None
# ── 6g. SELL volatility gate ──────────────────────────────
# 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
and report.volatility_regime not in self.config.xau_strategy.sell_vol_regimes):
report.warnings.append(
f"SELL FILTER: volatility '{report.volatility_regime}' too quiet for shorts"
)
report.signal_quality = "D"
report.trade_setup = None
# ── 6g2. XAU regime gates (opt-in via per-TF factory overrides) ──
if (self.config.xau_strategy.enabled
and report.display_name == "XAU/USD"
and report.signal_quality in ("A+", "A", "B", "C")):
if report.market_regime in self.config.xau_strategy.blocked_regimes:
report.warnings.append(
f"REGIME FILTER: '{report.market_regime}' blocked for XAU"
)
report.signal_quality = "D"
report.trade_setup = None
elif (xau_is_sell_dir
and report.market_regime
in self.config.xau_strategy.sell_blocked_regimes):
report.warnings.append(
f"SELL FILTER: regime '{report.market_regime}' blocked for shorts"
)
report.signal_quality = "D"
report.trade_setup = None
elif xau_is_sell_dir and self.config.xau_strategy.sell_blocked_hours_utc:
# ── 6g3. XAU SELL hour gate (opt-in via per-TF factories) ──
# Evaluated on the bar's CLOSE time (= entry time under
# next-open fills), the same clock the session filter and
# backtest replay use — NOT the bar label hour.
try:
close_h = (
pd.Timestamp(report.timestamp)
+ pd.Timedelta(
seconds=BaseDataProvider.CANDLE_SECONDS[
self.config.primary_timeframe
]
)
).hour
except Exception:
close_h = None
logger.warning(
f"{report.symbol}: bar-close hour underivable — "
f"SELL hour gate FAILS CLOSED (short blocked)"
)
# Fail CLOSED: an underivable close hour blocks the short
# (`None in [15, 17]` used to pass silently). Never fires
# on parseable timestamps, so pinned books are unchanged.
if (close_h is None
or close_h in self.config.xau_strategy.sell_blocked_hours_utc):
hour_txt = "??" if close_h is None else f"{close_h:02}"
report.warnings.append(
f"SELL FILTER: {hour_txt}:00 UTC entry blocked for shorts"
)
report.signal_quality = "D"
report.trade_setup = None
# ── 6h. Big-candle entry gate (opt-in via per-TF factories) ──
# An abnormally large candle in the recent window = institutional
# burst; price may reverse violently, so NO entry of any kind may
# fire. Detection runs for ANY grade: the warning is the
# suppression channel for the XAU_PB path (which accepts D) and
# the XAU_CP path (which accepts even F). The D-downgrade itself
# touches actionable grades only — F stays F (same convention as
# 6e).
if (self.config.xau_strategy.enabled
and report.display_name == "XAU/USD"
and self.config.xau_strategy.big_candle_atr_multiple > 0):
big_candle = self._detect_big_candle(primary_df)
if big_candle is not None:
bc_ts, bc_ratio = big_candle
report.warnings.append(
f"BIG CANDLE FILTER: {bc_ratio:.1f}x ATR bar at {bc_ts} "
f"within "
f"{self.config.xau_strategy.big_candle_lookback_hours:g}h "
f"— no entries"
)
if report.signal_quality in ("A+", "A", "B", "C"):
report.signal_quality = "D"
report.trade_setup = None
# ── 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 ──────────────────
self._attach_core_setup(report, pip_value)
# ── 8. Reasons ─────────────────────────────────────────────
self._collect_reasons(report)
# ── 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"
and not any(w.startswith("BIG CANDLE FILTER")
for w in report.warnings)):
self._try_m5_pullback(report, primary_df, pip_value)
# ── 10. Chart-pattern entry (XAU_CP) ────────────────────────
# Third archetype: completion of a classical chart pattern
# (double top/bottom, H&S, flag — see chart_patterns.py). Only
# fills empty slots (core + pullback setups take precedence).
# Unlike XAU_PB this must NOT require quality != "F": a weak
# composite (grade F on |score| < 15) is the normal state at a
# reversal-pattern completion. Session/day blocks still bind —
# they are detected by their warnings (and the live wrapper +
# backtest replay apply the bar-close session predicate again
# on top, so a pattern setup can never trade a blocked hour).
if (report.display_name == "XAU/USD"
and self.config.xau_strategy.cp_enabled
and report.trade_setup is None
and not any(w.startswith(("SESSION FILTER", "DAY FILTER",
"WEEKEND FILTER",
"BIG CANDLE FILTER"))
for w in report.warnings)):
self._try_chart_pattern(report, primary_df, pip_value)
# ── 11. Stale spread-gate vetoes ─────────────────────────────
# Whichever archetype shipped the setup, a core generator's
# cost-floor veto no longer describes it.
self._drop_stale_spread_gate_vetoes(report)
# ── 12. Daily-trend veto (opt-in via per-TF factories) ───────
# Last on purpose: a FINAL veto on whatever archetype shipped the
# setup (core, XAU_PB or XAU_CP), which is exactly what the
# campaign replayed. Mirrored in cuda_signal_kernels.py K2.
self._apply_daily_trend_veto(report, dfs)
return report
# ── Internals ──────────────────────────────────────────────────
# Only A+/A reach the XAU ATR generator (gate at the call site).
_TP_QUALITY_SCALE = {"A+": 2.0, "A": 1.5}
def _attach_core_setup(self, report: SignalReport, pip_value: float) -> None:
"""Step 7: the XAU ATR generator (A+/A) first, the swing generator
(GENERIC) for anything it declined. A veto either one leaves
behind is reconciled after every archetype has run
(`_drop_stale_spread_gate_vetoes`)."""
# 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:
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"):
report.trade_setup = self._generate_trade_setup(report, pip_value)
# A shipped setup always names a direction. The signal thresholds
# (BUY/SELL need confidence >= 50) sit above the C grade floor
# (>= 45), and gate 6c grants B at >= 45 in "transitioning", so a
# C/B report could leave here as NEUTRAL with a full setup: the
# backtester traded it (the replay never reads final_signal) while
# the Telegram alert gate rejects NEUTRAL first (2026-09-13 audit:
# 5/91 H1 12-mo book trades, +3.7R of +22.2R, were never alerted).
# The generators derived the direction from the score sign — the
# same sign every direction gate above used — so this changes
# display and alert eligibility only; the trade books are unchanged.
# A report no generator served stays NEUTRAL, as before.
if (report.trade_setup is not None
and report.final_signal == SignalStrength.NEUTRAL):
report.final_signal = (SignalStrength.BUY
if report.trade_setup.direction == "BUY"
else SignalStrength.SELL)
# The action text was written before the gate chain with the
# old signal name; keep it consistent with the header.
if report.action_recommendation.startswith("NEUTRAL"):
report.action_recommendation = (
report.final_signal.name.replace("_", " ")
+ report.action_recommendation[len("NEUTRAL"):])
@staticmethod
def _drop_stale_spread_gate_vetoes(report: SignalReport) -> None:
"""Step 11: runs once after every setup archetype (core ATR/swing,
XAU_PB, XAU_CP) has had its turn.
Both core generators veto a target under the cost floor with a
`_SPREAD_GATE_PREFIX` warning. When a later generator then ships
a setup with a bigger target, that veto is stale — the 2026-09-08
07:45 UTC A-grade alert said "costs would eat the edge" beside a
$26.49 TP1. A stale veto moves to `cleaned_warnings` (metadata
only, the XAU_CP cleanup's contract; that path has already
stripped its own copy by the time this runs, and the PB path is
covered here). Nothing downstream reads warnings while a setup
exists, so trade books are unchanged — verified 2026-09-08 on the
3-yr H1/M15 books; only the exported `warnings` text column
differs.
"""
if report.trade_setup is None:
return
kept: List[str] = []
stale: List[str] = []
for warning in report.warnings:
(stale if warning.startswith(_SPREAD_GATE_PREFIX) else kept).append(warning)
if stale:
report.warnings[:] = kept
report.cleaned_warnings.extend(stale)
def _apply_daily_trend_veto(
self, report: SignalReport, dfs: Dict[Timeframe, pd.DataFrame]
) -> None:
"""Step 12: remove a finished setup that goes against a clear daily
trend (xau_strategy.daily_trend_veto, off by default).
Reads the D1 frame from `dfs` (closed bars only: the live wrapper
fetches it as a required frame, the replay slices it as-of), stamps
report.daily_trend, and follows the gate-chain convention when it
fires: grade D, no setup, a warning that names the gate. The final
signal and the action text are left as they are."""
xcfg = self.config.xau_strategy
mode = daily_trend_mode_of(xcfg)
if mode == "off" or not xcfg.enabled or report.display_name != "XAU/USD":
return
state = daily_trend(dfs.get(Timeframe.D1))
report.daily_trend = state
if state is None:
# Mixed case like the regime filter's twin: never a gate prefix.
report.warnings.append(
"Daily trend filter requested but 1d data unavailable")
return
setup = report.trade_setup
if setup is None or state == 0:
return
is_buy = setup.direction == "BUY"
against = ((is_buy and state < 0 and mode in ("both", "buy"))
or (not is_buy and state > 0 and mode in ("both", "sell")))
if not against:
return
report.warnings.append(
f"{_DAILY_TREND_PREFIX} {setup.direction} against a "
f"{'falling' if is_buy else 'rising'} daily trend — no trade"
)
report.signal_quality = "D"
report.trade_setup = None
def _wide_stop_cap(self, report: SignalReport, atr: float,
stop_floor: float) -> Optional[float]:
"""The largest stop distance xau_strategy.wide_stop_cap_atr allows
for a swing or chart-pattern setup: max(K x ATR, the setup's own
stop floor); None when the cap is off (K = 0) or the pair is not
gold. Applied by the generators AFTER their min-RR and spread
gates, so the gates keep judging the uncapped geometry and the
set of trades cannot change (the printed-target floor precedent)."""
xcfg = self.config.xau_strategy
k = xcfg.wide_stop_cap_atr
if not k > 0 or not xcfg.enabled or report.display_name != "XAU/USD":
return None
return max(k * atr, stop_floor)
def _printed_target_floor(self) -> float:
"""The floor every generator applies to the PRINTED target 1, as a
fraction of the stop: max(tp_floor_rr, tp_print_floor_rr). Applied
after the gates (see config.SignalConfig.tp_print_floor_rr)."""
sig = self.config.signals
return max(sig.tp_floor_rr, getattr(sig, "tp_print_floor_rr", 0.0))
def _generate_xau_atr_trade_setup(
self, report: SignalReport, atr: float
) -> Optional[TradeSetup]:
"""XAU ATR-based trade setup with quality-tiered TP scaling."""
cfg = self.config.xau_strategy
price = report.current_price
pip_value = 0.10
if report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
is_buy = True
elif report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL):
is_buy = False
else:
is_buy = report.final_score >= 0
quality_scale = self._TP_QUALITY_SCALE[report.signal_quality]
if report.signal_quality == "A" and report.exhaustion_count >= 2:
quality_scale = 1.0
tp_mult = cfg.tp_atr_multiplier * quality_scale
if is_buy:
sl = price - cfg.sl_atr_multiplier * atr
tp1 = price + tp_mult * atr
tp2 = price + tp_mult * 1.5 * atr
direction = "BUY"
else:
sl = price + cfg.sl_atr_multiplier * atr
tp1 = price - tp_mult * atr
tp2 = price - tp_mult * 1.5 * atr
direction = "SELL"
tf = self.config.signals.tp_tightening_factor
tp1 = price + (tp1 - price) * tf
tp2 = price + (tp2 - price) * tf
sl_distance = abs(price - sl)
tp_distance = abs(tp1 - price)
rr = tp_distance / sl_distance if sl_distance > 0 else 0
min_rr = self.config.signals.min_rr_ratio
if rr < min_rr - 1e-9:
return None
# Spread gate (same rule as _generate_trade_setup).
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
if tp_distance < min_tp:
report.warnings.append(
f"{_SPREAD_GATE_PREFIX} target ${tp_distance:.2f} < ${min_tp:.2f} minimum — "
f"costs would eat the edge"
)
return None
# Printed-target floor (2026-09-26): the guide target is at least
# max(tp_floor_rr, tp_print_floor_rr) x the stop (1R by default).
# With a 2 x ATR stop the grade-A target of 1.5 x ATR printed
# "RR 1:0.8" (1 x ATR with two exhaustion tags, "1:0.5"); on M15
# the factory floor is 1R, on M5 (0.5) nothing binds. Applied
# AFTER the gates on purpose: the min-RR and spread checks above
# keep judging the generator's own 1.5 x ATR target, so which
# generator serves the signal (and therefore the stop) is
# unchanged and the books are trade-identical (a floor applied
# before the spread gate moved 94 grade-A M15 setups onto the
# 2 x ATR stop and cost -24.6R on W2: rr_and_sell_campaign_
# 2026-09-26.md). TP1 is advisory in the backtester (the trail is
# the exit), so only the exported tp1/tp2/rr_planned columns
# move. Mirrored in cuda_signal_kernels.py K2.
floor_dist = sl_distance * self._printed_target_floor()
if tp_distance < floor_dist:
tp_distance = floor_dist
if is_buy:
tp1 = price + floor_dist
tp2 = price + floor_dist * 1.5
else:
tp1 = price - floor_dist
tp2 = price - floor_dist * 1.5
rr = tp_distance / sl_distance
return TradeSetup(
pair=report.symbol,
display_name=report.display_name,
direction=direction,
entry_price=price,
stop_loss=round(sl, 2),
take_profit_1=round(tp1, 2),
take_profit_2=round(tp2, 2),
risk_reward=round(rr, 2),
risk_pips=round(sl_distance / pip_value, 1),
reward_pips=round(tp_distance / pip_value, 1),
pip_value=pip_value,
strategy="XAU_ATR",
round_trip_cost=self.config.signals.round_trip_cost,
trail_arm_r=self.config.signals.trail_arm_r,
trail_dist_r=self.config.signals.trail_dist_r,
)
def _check_mtf_agreement(self, report: SignalReport) -> bool:
signals = list(report.mtf_signals.values())
if len(signals) < 2:
return False
bullish = sum(1 for s in signals if s in (SignalStrength.BUY, SignalStrength.STRONG_BUY))
bearish = sum(1 for s in signals if s in (SignalStrength.SELL, SignalStrength.STRONG_SELL))
threshold = self.config.signals.mtf_agreement_threshold
# Agreement must point the same way as the primary timeframe —
# two bullish confirmation TFs must not "confirm" a bearish primary.
if report.primary_score > 0:
return bullish >= threshold
if report.primary_score < 0:
return bearish >= threshold
return bullish >= threshold or bearish >= threshold
def _compute_final_signal(self, report: SignalReport):
score = report.primary_score
confidence = report.primary_confidence
# ── Bonuses (additive, applied first) ─────────────────────
if report.mtf_agreement:
confidence += 10
all_scores = list(report.mtf_scores.values())
if all_scores:
mtf_avg = sum(all_scores) / len(all_scores)
score = score * 0.6 + mtf_avg * 0.4
if self.config.news.sentiment_affects_signals and abs(report.news_sentiment) > 0.2:
if (report.news_sentiment > 0 and score > 0) or \
(report.news_sentiment < 0 and score < 0):
confidence += 5
primary = report.primary_analysis
if primary and report.market_regime.startswith("trending"):
rsi_ind = primary.indicators.get("rsi")
bb_ind = primary.indicators.get("bollinger")
# Pullback bonuses require a KNOWN trend direction — the mixed
# "trending" regime (EMAs unaligned) gets no directional bonus.
if rsi_ind:
rsi_val = rsi_ind.value
if report.market_regime == "trending_up" and score > 0 and 38 <= rsi_val <= 55:
confidence += 5
elif report.market_regime == "trending_down" and score < 0 and 45 <= rsi_val <= 62:
confidence += 5
if bb_ind:
bb_pos = bb_ind.value
if report.market_regime == "trending_up" and score > 0 and bb_pos < 0.4:
confidence += 5
elif report.market_regime == "trending_down" and score < 0 and bb_pos > 0.6:
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}"
# ── Penalties (diminishing cap system) ────────────────────
# Collect all applicable penalties, then apply with exponential
# decay so stacking never guts a signal beyond 30%.
penalties = []
if not report.mtf_agreement:
penalties.append(("mtf_disagreement", 15))
if len(report.mtf_scores) >= 2:
scores_list = list(report.mtf_scores.values())
if any(s > 10 for s in scores_list) and any(s < -10 for s in scores_list):
report.warnings.append("Timeframes CONFLICT — mixed signals across timeframes")
if abs(report.news_sentiment) > 0.2:
if (report.news_sentiment > 0 and score < -10) or \
(report.news_sentiment < 0 and score > 10):
report.warnings.append("News sentiment CONFLICTS with technical signal")
if self.config.news.sentiment_affects_signals:
penalties.append(("news_conflict", 10))
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 = (
(report.market_regime == "trending_up" and score < 0) or
(report.market_regime == "trending_down" and score > 0)
)
if is_counter_trend:
report.warnings.append("Counter-trend signal — reduced confidence")
penalties.append(("counter_trend", 15))
if report.volatility_regime == "extreme":
report.warnings.append("EXTREME volatility — wider stops needed")
penalties.append(("extreme_vol", 10))
elif report.volatility_regime == "low":
report.warnings.append("Low volatility — potential for breakout")
if primary is not None:
ema200 = primary.key_levels.get("ema_trend")
if ema200 and ema200 > 0 and report.current_price > 0:
ema_dist_pct = abs(report.current_price - ema200) / ema200 * 100.0
# FB2_EMA_LO: research override for the EMA200-proximity
# penalty band; the tuned default is 0.5. An unparseable
# value must degrade to the default, never crash the scan
# (2026-08-04 audit; hardened per owner delegation).
try:
_lo = float(os.environ.get("FB2_EMA_LO", "0.5"))
except (TypeError, ValueError):
logger.warning(
"FB2_EMA_LO=%r is not a number — using tuned "
"default 0.5", os.environ.get("FB2_EMA_LO"))
_lo = 0.5
if ema_dist_pct < _lo:
report.warnings.append(f"NEAR EMA200 ({ema_dist_pct:.2f}%) — chop risk")
penalties.append(("ema200_near", 5))
elif ema_dist_pct > 2.5:
report.warnings.append(f"FAR FROM EMA200 ({ema_dist_pct:.2f}%) — over-extended")
penalties.append(("ema200_far", 5))
atr = primary.key_levels.get("atr", 0)
if atr > 0 and score != 0:
# Distance is measured from the FARTHEST of the 3 tracked
# swings (sorted()[0] = deepest low / highest high), so the
# penalty fires earlier than a nearest-swing rule would.
# Calibrated as-is in every validated book — changing the
# anchor is a signal-logic change requiring re-validation.
if score > 0:
swing_lows = sorted(
v for k, v in primary.key_levels.items() if k.startswith("swing_low_")
)
if swing_lows and report.current_price - swing_lows[0] > 3.5 * atr:
report.warnings.append("TREND EXHAUSTION — price extended >3.5 ATR past swing low")
penalties.append(("exhaustion", 8))
else:
swing_highs = sorted(
(v for k, v in primary.key_levels.items() if k.startswith("swing_high_")),
reverse=True,
)
if swing_highs and swing_highs[0] - report.current_price > 3.5 * atr:
report.warnings.append("TREND EXHAUSTION — price extended >3.5 ATR past swing high")
penalties.append(("exhaustion", 8))
ema_medium = primary.key_levels.get("ema_medium")
if atr > 0 and ema_medium and report.market_regime.startswith("trending"):
pullback_dist = abs(report.current_price - ema_medium) / atr
if pullback_dist > 2.5:
report.warnings.append(f"NO PULLBACK — price {pullback_dist:.1f} ATR from EMA21")
penalties.append(("no_pullback", 8))
elif pullback_dist > 2.0:
report.warnings.append(f"Weak pullback — price {pullback_dist:.1f} ATR from EMA21")
penalties.append(("weak_pullback", 5))
if report.market_regime.startswith("trending"):
rsi_ind = primary.indicators.get("rsi")
bb_ind = primary.indicators.get("bollinger")
# Extension penalties apply in ANY strong-ADX regime; the
# mixed "trending" regime penalizes BOTH directions (it
# previously penalized only BUYs — the SELL side of the
# same bullish-bias bug fixed in the RSI reinterpretation).
if rsi_ind:
rsi_val = rsi_ind.value
if report.market_regime in ("trending_up", "trending") and score > 0 and rsi_val > 65:
report.warnings.append(f"Extended — RSI {rsi_val:.0f} in uptrend")
penalties.append(("rsi_extended", 5))
elif report.market_regime in ("trending_down", "trending") and score < 0 and rsi_val < 35:
report.warnings.append(f"Extended — RSI {rsi_val:.0f} in downtrend")
penalties.append(("rsi_extended", 5))
if bb_ind:
bb_pos = bb_ind.value
if report.market_regime in ("trending_up", "trending") and score > 0 and bb_pos > 0.85:
penalties.append(("bb_overextended", 8))
elif report.market_regime in ("trending_down", "trending") and score < 0 and bb_pos < 0.15:
penalties.append(("bb_overextended", 8))
# Hard block: static exhaustion + fading momentum → kill the trade
# Static signals alone are normal in gold trends. Only block when
# price is extended AND momentum indicators confirm deceleration.
_EXHAUSTION_TAGS = {"exhaustion", "no_pullback", "rsi_extended", "bb_overextended"}
exhaustion_count = sum(1 for name, _ in penalties if name in _EXHAUSTION_TAGS)
report.exhaustion_count = exhaustion_count
momentum_fading = False
if primary is not None and exhaustion_count >= 2:
roc_ind = primary.indicators.get("roc")
macd_ind = primary.indicators.get("macd")
roc_fading = False
macd_fading = False
if roc_ind:
if score > 0 and roc_ind.value < 0:
roc_fading = True
elif score < 0 and roc_ind.value > 0:
roc_fading = True
if macd_ind:
if score > 0 and macd_ind.signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL):
macd_fading = True
elif score < 0 and macd_ind.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
macd_fading = True
momentum_fading = roc_fading or macd_fading
exhaustion_blocked = exhaustion_count >= 2 and momentum_fading
MAX_PENALTY = 30.0
penalties.sort(key=lambda x: x[1], reverse=True)
total_penalty = 0.0
decay = 1.0
for _name, amount in penalties:
applied = amount * decay
if total_penalty + applied > MAX_PENALTY:
applied = MAX_PENALTY - total_penalty
total_penalty += applied
decay *= 0.5
if total_penalty >= MAX_PENALTY:
break
# Record-keeping for position_sizing / signal-log forensics.
# Purely additive — nothing below reads these fields.
report.penalty_tags = {name: float(amount) for name, amount in penalties}
report.penalty_total = float(total_penalty)
report.momentum_fading = bool(momentum_fading)
confidence -= total_penalty
confidence = max(0.0, min(95.0, confidence))
if exhaustion_blocked:
report.warnings.append(
f"EXHAUSTION BLOCK — {exhaustion_count} exhaustion signals fired, trade rejected"
)
confidence = 0.0
# Final signal (grading_mode: "pair" = the shipped rule; the research
# modes grade on one conviction number — helpers at module top)
report.final_signal, report.signal_quality = _final_signal_and_grade(
score, confidence, report.mtf_agreement, grading_mode_of(self.config.signals)
)
report.final_score = score
report.final_confidence = confidence
if not report.action_recommendation:
sig_name = report.final_signal.name.replace("_", " ")
if report.signal_quality in ("A+", "A"):
report.action_recommendation = f"{sig_name} — High-quality setup, consider entry"
elif report.signal_quality == "B":
report.action_recommendation = f"{sig_name} — Decent setup, wait for confirmation"
elif report.signal_quality in ("C", "D"):
report.action_recommendation = "WATCH — Signal present but low confidence"
else:
report.action_recommendation = "NO TRADE — No clear signal"
def _generate_trade_setup(self, report: SignalReport, pip_value: float) -> Optional[TradeSetup]:
if not report.primary_analysis:
return None
price = report.current_price
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:
report.warnings.append(
f"LOW ATR FILTER: ATR ${atr:.2f} < ${self.config.xau_strategy.min_atr_threshold:.2f} minimum"
)
return None
if report.final_signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
is_buy = True
elif report.final_signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL):
is_buy = False
else:
is_buy = report.final_score >= 0
levels = report.primary_analysis.key_levels
min_sl_price = self.config.signals.min_sl_pips * pip_value
min_rr = self.config.signals.min_rr_ratio
tp_floor = max(min_rr, self.config.signals.tp_floor_rr)
tf = self.config.signals.tp_tightening_factor
pre_tight_min_rr = tp_floor / tf if tf > 0 else tp_floor
tp_mult = 1.5
tp_swing_cap = 2.5
sl_atr_floor = (self.config.xau_strategy.v2_sl_atr_multiplier
if report.display_name == "XAU/USD" else 1.5)
sl_swing_buffer = 1.0
if is_buy:
swing_lows = sorted(
[v for k, v in levels.items() if k.startswith("swing_low_") and v < price],
reverse=True,
)
if swing_lows:
sl_price = swing_lows[0] - atr * sl_swing_buffer
else:
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
sl_distance = price - sl_price
swing_highs = sorted(
v for k, v in levels.items() if k.startswith("swing_high_") and v > price + atr * 0.3
)
if (swing_highs
and swing_highs[0] - price <= atr * tp_swing_cap
and swing_highs[0] - price >= sl_distance * pre_tight_min_rr):
tp1 = swing_highs[0]
else:
tp1 = price + max(atr * tp_mult, sl_distance * pre_tight_min_rr)
if len(swing_highs) >= 2 and swing_highs[1] - price <= atr * 5.0:
tp2 = swing_highs[1]
else:
tp2 = price + max(atr * (tp_mult + 1.5), sl_distance * pre_tight_min_rr * 1.5)
direction = "BUY"
entry, sl = price, sl_price
else:
swing_highs = sorted(
v for k, v in levels.items() if k.startswith("swing_high_") and v > price
)
if swing_highs:
sl_price = swing_highs[0] + atr * sl_swing_buffer
else:
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
sl_distance = sl_price - price
swing_lows = sorted(
[v for k, v in levels.items() if k.startswith("swing_low_") and v < price - atr * 0.3],
reverse=True,
)
if (swing_lows
and price - swing_lows[0] <= atr * tp_swing_cap
and price - swing_lows[0] >= sl_distance * pre_tight_min_rr):
tp1 = swing_lows[0]
else:
tp1 = price - max(atr * tp_mult, sl_distance * pre_tight_min_rr)
if len(swing_lows) >= 2 and price - swing_lows[1] <= atr * 5.0:
tp2 = swing_lows[1]
else:
tp2 = price - max(atr * (tp_mult + 1.5), sl_distance * pre_tight_min_rr * 1.5)
direction = "SELL"
entry, sl = price, sl_price
tf = self.config.signals.tp_tightening_factor
tp1 = entry + (tp1 - entry) * tf
tp2 = entry + (tp2 - entry) * tf
# TP2 must sit beyond TP1 — swing picks can invert when TP1 falls
# back to the ATR target while TP2 takes a nearer second swing.
if is_buy:
tp2 = max(tp2, tp1 + atr)
else:
tp2 = min(tp2, tp1 - atr)
tp1_distance = abs(tp1 - entry)
rr = tp1_distance / sl_distance if sl_distance > 0 else 0
if rr < min_rr - 1e-9:
return None
# Spread gate: a target too close to entry hands most of the win
# to transaction costs (validated on M5: sub-$10 targets lose).
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
if tp1_distance < min_tp:
report.warnings.append(
f"{_SPREAD_GATE_PREFIX} target ${tp1_distance:.2f} < ${min_tp:.2f} minimum — "
f"costs would eat the edge"
)
return None
# Wide-stop cap AFTER the gates (xau_strategy.wide_stop_cap_atr,
# 0 = off): the gates above judged the swing-anchored stop, so the
# same trades ship; only the stop (the risk unit) is pulled in,
# never inside the generator's own floor. Mirrored in
# cuda_signal_kernels.py K2.
cap_dist = self._wide_stop_cap(
report, atr, max(atr * sl_atr_floor, min_sl_price))
if cap_dist is not None and sl_distance > cap_dist:
sl_distance = cap_dist
sl = entry - cap_dist if is_buy else entry + cap_dist
rr = tp1_distance / sl_distance
# Printed-target floor AFTER the gates (2026-09-26, see
# config.SignalConfig.tp_print_floor_rr): the gates above judged
# the swing/ATR target, so the trade is unchanged; only the guide
# moves. TP2 keeps its "at least 1 ATR beyond TP1" rule.
# Mirrored in cuda_signal_kernels.py K2.
floor_dist = sl_distance * self._printed_target_floor()
if tp1_distance < floor_dist:
tp1_distance = floor_dist
if is_buy:
tp1 = entry + floor_dist
tp2 = max(tp2, tp1 + atr)
else:
tp1 = entry - floor_dist
tp2 = min(tp2, tp1 - atr)
rr = tp1_distance / sl_distance
risk_pips = sl_distance / pip_value
reward_pips = tp1_distance / pip_value
return TradeSetup(
pair=report.symbol,
display_name=report.display_name,
direction=direction,
entry_price=entry,
stop_loss=sl,
take_profit_1=tp1,
take_profit_2=tp2,
risk_reward=round(rr, 2),
risk_pips=risk_pips,
reward_pips=reward_pips,
pip_value=pip_value,
round_trip_cost=self.config.signals.round_trip_cost,
trail_arm_r=self.config.signals.trail_arm_r,
trail_dist_r=self.config.signals.trail_dist_r,
)
def _collect_reasons(self, report: SignalReport):
if not report.primary_analysis:
return
for name, ind in report.primary_analysis.indicators.items():
if name.startswith("_") or ind.weight == 0:
continue
if ind.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
prefix = "STRONG: " if ind.signal == SignalStrength.STRONG_BUY else ""
report.bullish_reasons.append(f"{prefix}{ind.name}: {ind.description}")
elif ind.signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL):
prefix = "STRONG: " if ind.signal == SignalStrength.STRONG_SELL else ""
report.bearish_reasons.append(f"{prefix}{ind.name}: {ind.description}")
if report.news_sentiment > 0.1:
report.bullish_reasons.append(f"News: {report.news_description}")
elif report.news_sentiment < -0.1:
report.bearish_reasons.append(f"News: {report.news_description}")