2cc5915b00
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).
2275 lines
104 KiB
Python
Executable File
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}")
|