alerts: require a trade setup, log every send, drop stale spread-gate vetoes, daily log rotation
Delivery-side fixes from the 2026-09-08 three-day signal-log + telegram-log review. Nothing in the signal core changes: the 3-year H1 and M15 books at $0.65 were regenerated before/after and are identical on every trade column (H1 253 / +70.02R / PF 1.98, M15 1092 / +151.90R / PF 1.38); only the exported `warnings` text differs, on 50/253 H1 and 358/1092 M15 trades. - signal_aggregator: step 7 moves into `_attach_core_setup`; a final pass after the PB/CP archetypes (`_drop_stale_spread_gate_vetoes`) moves a core generator's "SPREAD GATE: ... costs would eat the edge" veto to `cleaned_warnings` whenever a later generator shipped a setup. The 2026-09-08 07:45 UTC A-grade alert carried that line beside a $26.49 TP1; a fifth of H1 and a third of M15 A-grade alerts had been doing the same. The prefix is now the `_SPREAD_GATE_PREFIX` constant shared by both emitters and both cleanups. - telegram_bot: alerts require a trade setup (`_is_alert_candidate`, shared by the scan and breach paths; no-setup candidates log `alert skipped: ... (gate reason)`). The 07:50 UTC B-grade SELL with no setup had gone out under the same warning. - telegram_bot: every delivered alert / breach alert / violent-move heads-up logs an `... sent:` INFO line with the signal-log row id (only failures were logged before, so the review had to reconstruct sends from panel rows). Send fan-out is one `_broadcast` helper for both paths. - telegram_bot: `telegram.log` rotates daily x 30 instead of 3-hourly x 240 (old `_HH` files on the live box need one manual delete). - signal_log / CLAUDE.md: `cleaned_warnings_json` is no longer a CP-only marker; docs say so. - test_all: 468 tests (+2): stale-veto cleanup incl. the PB-shaped case and the both-vetoed case, and the alert-candidate rule with its skip line; the rotation test asserts MIDNIGHT / interval 86400 / 30 backups. Claude-Session: https://claude.ai/code/session_018xY4NPPrx6NJoG6QWHzwpd
This commit is contained in:
+113
-4
@@ -2579,6 +2579,55 @@ def _():
|
||||
d.pop(key, None)
|
||||
|
||||
|
||||
@test("Alert candidates: a quality-passing signal without a trade setup is skipped and logged")
|
||||
def _():
|
||||
import logging
|
||||
import telegram_bot as tb
|
||||
from signal_aggregator import SignalReport, TradeSetup
|
||||
|
||||
def rpt(setup: bool, signal=SignalStrength.SELL, q="B"):
|
||||
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
|
||||
r.final_signal = signal
|
||||
r.signal_quality = q
|
||||
r.warnings = ["SPREAD GATE: target $8.31 < $9.00 minimum — "
|
||||
"costs would eat the edge"]
|
||||
if setup:
|
||||
r.trade_setup = TradeSetup(
|
||||
pair="XAU/USD", display_name="XAU/USD", direction="SELL",
|
||||
entry_price=4388.0, stop_loss=4410.0, take_profit_1=4377.0,
|
||||
take_profit_2=4372.0, risk_reward=0.5, risk_pips=220.0,
|
||||
reward_pips=110.0, pip_value=0.10)
|
||||
return r
|
||||
|
||||
records = []
|
||||
handler = logging.Handler()
|
||||
handler.emit = records.append
|
||||
tb.logger.addHandler(handler)
|
||||
saved_threshold = tb._alert_min_quality
|
||||
tb._alert_min_quality = "C"
|
||||
try:
|
||||
assert tb._is_alert_candidate(rpt(True), "5min")
|
||||
assert not tb._is_alert_candidate(rpt(False), "5min"), \
|
||||
"no trade setup → no alert (the 2026-09-08 07:50 UTC no-setup SELL)"
|
||||
assert not tb._is_alert_candidate(rpt(True, SignalStrength.NEUTRAL), "5min")
|
||||
assert not tb._is_alert_candidate(rpt(True, q="F"), "5min")
|
||||
finally:
|
||||
tb._alert_min_quality = saved_threshold
|
||||
tb.logger.removeHandler(handler)
|
||||
skips = [r.getMessage() for r in records
|
||||
if r.getMessage().startswith("alert skipped:")]
|
||||
assert len(skips) == 1, skips
|
||||
assert "5min SELL grade B" in skips[0] and "SPREAD GATE" in skips[0], skips[0]
|
||||
|
||||
line = tb._alert_sent_line(rpt(True), "5min", 2)
|
||||
assert line.startswith("alert sent: XAU/USD 5min SELL grade B"), line
|
||||
assert "SELL entry 4388.00 sl 4410.00 tp1 4377.00" in line and "2 chat(s)" in line, line
|
||||
r = rpt(True)
|
||||
r._signal_log_rowid = 917
|
||||
breach = tb._alert_sent_line(r, "15min", 1, kind="breach alert")
|
||||
assert breach.startswith("breach alert sent:") and "row #917" in breach, breach
|
||||
|
||||
|
||||
@test("Backtester --confirm-tfs defaults to None (factory's live map)")
|
||||
def _():
|
||||
from gpu_backtest import _parse_args
|
||||
@@ -2861,6 +2910,65 @@ def _():
|
||||
], rep.warnings
|
||||
|
||||
|
||||
@test("Core setup: a stale ATR spread-gate veto is cleaned when the swing generator ships a setup")
|
||||
def _():
|
||||
from types import SimpleNamespace
|
||||
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup
|
||||
|
||||
agg = SignalAggregator(PlatformConfig())
|
||||
price = 4400.0
|
||||
|
||||
def rpt(levels):
|
||||
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
|
||||
r.final_signal = SignalStrength.STRONG_BUY
|
||||
r.final_score = 40.0
|
||||
r.signal_quality = "A"
|
||||
r.current_price = price
|
||||
r.primary_analysis = SimpleNamespace(key_levels=dict(levels))
|
||||
return r
|
||||
|
||||
# ATR $2 → the A-grade ATR target is 1.5 × $2 = $3, under the $9 cost
|
||||
# floor (veto + warning). The swing generator's stop sits under the
|
||||
# $20-deep swing low, so its RR-floor target is $11 and a GENERIC
|
||||
# setup ships — the ATR veto is then stale (alert #915, 2026-09-08).
|
||||
r = rpt({"atr": 2.0, "swing_low_1": price - 20.0})
|
||||
agg._attach_core_setup(r, 0.10)
|
||||
assert r.trade_setup is not None and r.trade_setup.strategy == "GENERIC", r.trade_setup
|
||||
assert sum(w.startswith("SPREAD GATE:") for w in r.warnings) == 1, \
|
||||
"the generators themselves leave the veto in place"
|
||||
agg._drop_stale_spread_gate_vetoes(r)
|
||||
assert not any(w.startswith("SPREAD GATE:") for w in r.warnings), r.warnings
|
||||
assert sum(w.startswith("SPREAD GATE:") for w in r.cleaned_warnings) == 1, \
|
||||
"the veto must stay auditable in cleaned_warnings"
|
||||
|
||||
# No swing structure: the swing generator's fallback target is
|
||||
# max(1.5 × ATR, RR floor × the $8 minimum stop) = $4 — also gated —
|
||||
# so no setup ships and BOTH vetoes stay visible.
|
||||
r2 = rpt({"atr": 2.0})
|
||||
agg._attach_core_setup(r2, 0.10)
|
||||
agg._drop_stale_spread_gate_vetoes(r2)
|
||||
assert r2.trade_setup is None
|
||||
assert sum(w.startswith("SPREAD GATE:") for w in r2.warnings) == 2, r2.warnings
|
||||
assert not r2.cleaned_warnings
|
||||
|
||||
# The pass runs after the PB/CP archetypes too: a setup shipped by a
|
||||
# later generator (XAU_PB shape here) supersedes BOTH core vetoes,
|
||||
# and unrelated warnings keep their order.
|
||||
r3 = rpt({"atr": 2.0})
|
||||
agg._attach_core_setup(r3, 0.10)
|
||||
r3.warnings.insert(0, "NEAR EMA200 (0.2%) — chop risk")
|
||||
r3.warnings.append("TREND EXHAUSTION — price extended >3.5 ATR past swing low")
|
||||
r3.trade_setup = TradeSetup(
|
||||
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
||||
entry_price=price, stop_loss=price - 8.0, take_profit_1=price + 12.0,
|
||||
take_profit_2=price + 18.0, risk_reward=1.5, risk_pips=80.0,
|
||||
reward_pips=120.0, pip_value=0.10, strategy="XAU_PB")
|
||||
agg._drop_stale_spread_gate_vetoes(r3)
|
||||
assert r3.warnings == ["NEAR EMA200 (0.2%) — chop risk",
|
||||
"TREND EXHAUSTION — price extended >3.5 ATR past swing low"], r3.warnings
|
||||
assert sum(w.startswith("SPREAD GATE:") for w in r3.cleaned_warnings) == 2
|
||||
|
||||
|
||||
@test("CP gate: MTF opposition blocks the pattern trade")
|
||||
def _():
|
||||
cfg = _cp_config()
|
||||
@@ -3392,15 +3500,16 @@ def _():
|
||||
load_dotenv("/nonexistent/.env") # missing file is a silent no-op
|
||||
|
||||
|
||||
@test("telegram_bot: log rotation is 3-hourly with 30-day retention")
|
||||
@test("telegram_bot: log rotation is daily with 30-day retention")
|
||||
def _():
|
||||
import logging.handlers
|
||||
import telegram_bot as tb
|
||||
h = tb._file_handler
|
||||
assert isinstance(h, logging.handlers.TimedRotatingFileHandler)
|
||||
assert h.when.upper() == "H" and h.interval == 3 * 3600, \
|
||||
"rotation must fire every 3 hours"
|
||||
assert h.backupCount == 240, "240 × 3h = 30-day retention"
|
||||
assert h.when.upper() == "MIDNIGHT", \
|
||||
"one file per day (2026-09-08: was 3-hourly × 240)"
|
||||
assert h.interval == 86400, "plain daily — an interval=N would stretch retention"
|
||||
assert h.backupCount == 30, "30 daily files = 30-day retention"
|
||||
|
||||
|
||||
@test("telegram_bot: candle loop survives a failing scan cycle")
|
||||
|
||||
Reference in New Issue
Block a user