Quant review 2026-09-26: alert cooldown, plain-language alerts, M15 grade-A SELL block, 1R printed targets, panel/breach fixes

Live signals.db review (2026-09-04 → 09-25, 100 unique setups replayed on the
scans' stored candles): the stop-hunt claim is refuted (71% of stop-outs ran
≥ 1R past the stop, the same as the books' 70–72%); −25.1R of the −37.8R raw
live book were consecutive-bar re-entries the backtester's 5-bar cooldown never
takes; the cooldown view sits at the 3rd–5th percentile of the books' three-week
windows.

Delivery (book-neutral):
- telegram_bot: 5-bar alert cooldown per timeframe (_ALERT_COOLDOWN_BARS =
  backtest.cooldown_bars); setup block prints the trail first as the tested
  exit, TP1/TP2 as guides with their R, "Planned TP1 RR", and one validated-book
  line per setup class (agent_panel_base_rates.json); the informational 100-pip
  TP line is removed (owner request).
- AI block rewritten for a new trader: "AI check: the odds look better than /
  worse than / about usual", the chance "in 100" against the usual odds, one
  reason for and one against, no verdict word or confidence figure; rendered
  whenever the verdict carries a probability. agent_panel: persona prompts ban
  trading jargon (PANEL_PROMPT_VERSION v2.1-2026-09-26); the macro-source
  verdict now carries its persona's p_win (every live v2 row had
  panel_probability NULL). Dashboard and guides mirror the wording.
- telegram_bot: level-breach rescans skip the settlement-pause window (the
  20:45 M15 bar was re-logged at 22:01/22:06/22:11 every evening).

Signal (pre-registered rule-1 campaign, CUDA twin paired with gpu_backtest.py):
- M15 grade-A SELLs blocked (sell_min_quality ["A+","B"], M15 override 6):
  W2 813/+154.15R PF 1.53 vs 931/+146.14R PF 1.43, OOS +68.5 vs +60.67,
  W3 max DD 20.9 vs 19.3R. PASSED a–f.
- Printed target 1 floored at 1R in every generator (signals.tp_print_floor_rr),
  applied AFTER the min-RR and spread gates so no trade changes; mirrored in
  cuda_signal_kernels.py K2. Label-only re-pin of all four timeframes (only
  tp1/rr_planned columns move). A floor applied before the gate cost −24.6R and
  was rejected.
- FAILED: M15 min_rr_ratio 1.0 (trade-identical, −2.0R). Owner's call: M5
  tp_floor_rr 1.0 (+48.9R W2, max DD 10.1 → 15.9R), not shipped.

New pins: M5 W2 e599c8a8…, M15 W2 5b65ecf6…, M15 OOS 09b8a8e7…, H1 W3
a44fd19b…, H4 W3 f3587bae…; twin and reference MD5s equal on every window.
Base-rate table rebuilt from the new pins. Suite 558 passed.

Evidence: backtest/research/rr_and_sell_campaign_2026-09-26.md (plus the
2026-09-18 loss-anatomy and wrong-direction reports it builds on).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Harwinder Sekhon
2026-09-26 21:06:05 -04:00
parent 4f33480ce1
commit 1454ea1838
16 changed files with 1695 additions and 201 deletions
+175 -47
View File
@@ -275,7 +275,6 @@ def _mtf_line(report: SignalReport) -> str:
# Informational fixed-pip target shown on every trade setup (display-only,
# not part of the backtested exit model).
_INFO_TP_PIPS = 100
_IND_SHORT = {
"ema_trend": "EMA", "rsi": "RSI", "macd": "MACD", "bollinger": "BB",
@@ -324,36 +323,81 @@ _DISCLAIMER = (
)
def _panel_estimate_lines(report: SignalReport) -> list[str]:
"""v2 panel (verdict_source="mean"): a plain-language probability, the
class base rate it is anchored to, the biggest worry, and each
persona's own estimate. Advisory only — no colour, no verdict word,
never a recommendation (2026-09-17 review: the old AGREE/OPPOSE line
opposed target-reaching trades as often as it agreed)."""
def _panel_odds_word(p: float, usual: Optional[float]) -> str:
"""Plain-language headline: the estimate against the usual odds for
this kind of setup (8 points either way), or against 60 / 40 when no
base rate is known. Never a verdict word."""
if usual is not None:
d = p - usual
if d >= 8:
return "the odds look better than usual"
if d <= -8:
return "the odds look worse than usual"
return "the odds look about usual"
if p >= 60:
return "the odds look good"
if p <= 40:
return "the odds look poor"
return "the odds look about even"
def _panel_lines(report: SignalReport) -> list[str]:
"""The AI review for a new trader (owner rule 2026-09-26: simple and
clear). One headline in everyday words, the chance "in 100" against
the usual odds for this kind of setup, one reason for and one
against, and a plain warning. Used whenever the verdict carries a
probability (the "mean" source, and "macro" since 2026-09-26); no
verdict word, no percentages that are not probabilities, no persona
names unless several personas ran (research configurations)."""
p = float(report.panel_probability or 0.0)
base = (report.panel_context or {}).get("base_rate") or {}
usual = base.get("win_rate")
line = (f"🤖 AI estimate: about a *{report.panel_probability:.0f}%* "
f"chance this trade pays off")
if usual is not None:
line += f" (usual for this kind of setup: {usual:.0f}%)"
line += "."
head = f"🤖 AI check: {_panel_odds_word(p, usual)}."
if report.panel_rationale:
line += f" {_escape_md(report.panel_rationale)}"
worst = min((t for t in report.panel_takes
if t.get("p_win") is not None and t.get("reason_against")),
key=lambda t: t["p_win"], default=None)
if worst is not None:
line += f" Biggest worry: {_escape_md(worst['reason_against'])}"
line += " Still being tested."
out = [line]
if len(report.panel_takes) > 1: # one persona: the headline says it all
votes = " · ".join(
f"{t['persona']} {t['p_win']:.0f}%" if t.get("p_win") is not None
else f"{t['persona']} -" for t in report.panel_takes)
head += f" {_escape_md(report.panel_rationale)}"
chance = f" Chance it pays off: about *{p:.0f} in 100*"
if usual is not None:
chance += f" (usual for this kind of setup: {usual:.0f} in 100)"
out = [head, chance + "."]
takes = [t for t in report.panel_takes if t.get("p_win") is not None]
lead = max(takes, key=lambda t: abs(float(t["p_win"]) - 50.0), default=None)
if lead is not None:
if lead.get("reason_for"):
out.append(f" For: {_escape_md(lead['reason_for'])}")
if lead.get("reason_against"):
out.append(f" Against: {_escape_md(lead['reason_against'])}")
if len(takes) > 1: # one persona: the headline says it all
votes = " · ".join(f"{t['persona']} {float(t['p_win']):.0f}%" for t in takes)
out.append(f" _{_escape_md(votes)}_")
out.append(" This check is an experiment. Do not trade on it alone.")
return out
def _book_rate_line(report: SignalReport) -> Optional[str]:
"""One line of validated-book statistics for the setup's class
(timeframe × strategy × grade × direction, falling back to wider
classes exactly as the agent panel does). None when no table or no
primary analysis is available; never raises."""
ts = report.trade_setup
if ts is None or report.primary_analysis is None:
return None
try:
from agent_panel import _base_rate
rate = _base_rate(report.primary_analysis.timeframe,
getattr(ts, "strategy", None) or "GENERIC",
report.signal_quality, ts.direction)
except Exception:
logger.debug("book rate line unavailable", exc_info=True)
return None
if not rate or "win_rate" not in rate or "mean_r" not in rate:
return None
scope = {"class": "this setup class",
"timeframe_direction": f"{ts.direction} setups on this timeframe",
"timeframe": "this timeframe"}.get(rate.get("level"), "this class")
return (f"📚 Book ({scope}): {rate['n']} trades, "
f"{rate['win_rate']:.0f}% winners, avg {rate['mean_r']:+.2f}R per trade")
def format_signal_message(report: SignalReport) -> str:
sig = report.final_signal
emoji = _SIGNAL_EMOJI.get(sig, "⬜")
@@ -388,30 +432,21 @@ def format_signal_message(report: SignalReport) -> str:
dir_emoji = "📈" if ts.direction == "BUY" else "📉"
risk_usd = ts.risk_pips * ts.pip_value
reward_usd = ts.reward_pips * ts.pip_value
# Order and wording (2026-09-26): the trail is the tested exit
# (the backtester never exits at TP1 — `_simulate_trailing` reads
# only the stop and the trailing rule), so it is printed first
# and TP1/TP2 are labelled guides with their R. The RR line is
# the PLANNED TP1 ratio, not the expected payoff: the validated
# books earn their R from the trailed runners (M5 average win
# +1.03R vs loss −0.76R; M15 +0.87R vs −0.93R).
risk_dist = abs(ts.entry_price - ts.stop_loss)
lines += [
"",
"🚀 *TRADE SETUP*",
f"{dir_emoji} Direction: *{ts.direction}*",
f"▶ Entry `{_fmt_price(ts.entry_price)}`",
f"🛑 SL `{_fmt_price(ts.stop_loss)}` (${risk_usd:.2f} / {ts.risk_pips:.0f}p)",
f"🎯 TP1 `{_fmt_price(ts.take_profit_1)}` (${reward_usd:.2f} / {ts.reward_pips:.0f}p)",
f"🛑 SL `{_fmt_price(ts.stop_loss)}` (${risk_usd:.2f} / {ts.risk_pips:.0f}p = 1R)",
]
if ts.take_profit_2:
lines.append(f"🎯 TP2 `{_fmt_price(ts.take_profit_2)}`")
# Informational fixed-pip target: where a 100-pip TP would sit for
# this setup (display-only — not part of the backtested exit model).
info_dist = _INFO_TP_PIPS * ts.pip_value
if ts.direction == "BUY":
info_price = ts.entry_price + info_dist
else:
info_price = ts.entry_price - info_dist
risk_dist = abs(ts.entry_price - ts.stop_loss)
info_r = f" · ≈{info_dist / risk_dist:.1f}R" if risk_dist > 0 else ""
lines.append(
f"🎯 TP {_INFO_TP_PIPS}p: `{_fmt_price(info_price)}` (${info_dist:.2f}{info_r})"
)
lines.append(f"⚖️ RR 1:{ts.risk_reward:.1f}")
# Trailing-stop management plan (matches backtested exit model:
# TP1 is advisory, the trail is the exit). Params are stamped on
@@ -433,11 +468,33 @@ def format_signal_message(report: SignalReport) -> str:
trail_dist = dist_r * risk_dist
tag = " (pullback)" if is_pb else ""
lines.append(
f"🔄 Trail{tag}: arm `{_fmt_price(arm_price)}` "
f"(+{arm_r:.1f}R), trail SL ${trail_dist:.2f} "
f"behind best price — TP1 is a guide, let it run"
f"🔄 Exit rule{tag} (tested): once price reaches "
f"`{_fmt_price(arm_price)}` (+{arm_r:.1f}R), trail the SL "
f"${trail_dist:.2f} behind the best price, moving it at "
f"candle close only. Otherwise the SL is the exit."
)
tp1_r = (abs(ts.take_profit_1 - ts.entry_price) / risk_dist
if risk_dist > 0 else 0.0)
lines.append(
f"🎯 TP1 guide `{_fmt_price(ts.take_profit_1)}` "
f"(${reward_usd:.2f} / {ts.reward_pips:.0f}p = {tp1_r:.1f}R)"
)
if ts.take_profit_2:
tp2_r = (abs(ts.take_profit_2 - ts.entry_price) / risk_dist
if risk_dist > 0 else 0.0)
lines.append(
f"🎯 TP2 guide `{_fmt_price(ts.take_profit_2)}` ({tp2_r:.1f}R)")
lines.append(f"⚖️ Planned TP1 RR 1:{ts.risk_reward:.1f} (guide only — the trail is the exit)")
# What this setup class actually paid in the validated books
# (agent_panel_base_rates.json, the table the AI estimate is
# anchored to): the honest expectation for a discretionary reader.
rate_line = _book_rate_line(report)
if rate_line:
lines.append(rate_line)
extra: list[str] = []
mtf = _mtf_line(report)
if mtf:
@@ -453,8 +510,8 @@ def format_signal_message(report: SignalReport) -> str:
extra.append(_escape_md(report.watch_line))
if report.news_summary:
extra.append(f"📰 {_escape_md(report.news_summary)}")
if report.panel_verdict and report.panel_verdict_source == "mean":
extra.extend(_panel_estimate_lines(report))
if report.panel_verdict and (report.panel_probability or 0) > 0:
extra.extend(_panel_lines(report))
elif report.panel_verdict:
mark = {"AGREE": "✅", "OPPOSE": "⛔", "NEUTRAL": "➖"}.get(
report.panel_verdict, "")
@@ -670,6 +727,55 @@ def _alert_sent_line(report: SignalReport, tf_label: str, delivered_to: int,
_last_alert_quality: Dict[str, str] = {} # key → grade of last ALERTED signal
_last_alert_bar: Dict[str, pd.Timestamp] = {} # key → bar label of last ALERTED setup
# Entry cooldown, in primary bars, after an ALERTED setup (2026-09-26):
# the backtester's `_apply_cooldown` (cooldown_bars=5) never enters within
# 5 bars of an accepted entry, so every validated book counts one trade per
# move. Live had no such rule: the entry-moved re-alert below re-sent the
# same idea on consecutive bars while price ran (2026-09-15 08:00–08:45,
# 2026-09-18 06:00–06:30, 2026-09-22 18:00–19:40 …); over 2026-09-04 →
# 2026-09-25 those re-entries were 42 of 99 unique setups and −25.1R of
# the −37.8R raw advisory book (backtest/research/rr_and_sell_campaign_
# 2026-09-26.md). Must equal backtest.cooldown_bars in gpu_backtest.py.
_ALERT_COOLDOWN_BARS = 5
def _key_bar_seconds(key: str) -> Optional[int]:
"""Candle length for an alert key ("XAU/USD_15min" → 900). None when
the key carries no known timeframe (test keys), which disables the
bar cooldown for that key."""
tf_value = key.rsplit("_", 1)[-1]
for tf, secs in BaseDataProvider.CANDLE_SECONDS.items():
if tf.value == tf_value:
return secs
return None
def _report_bar_ts(report: SignalReport) -> Optional[pd.Timestamp]:
"""The report's primary bar label as a UTC timestamp; None when it
cannot be parsed (the cooldown then does not apply)."""
try:
ts = pd.Timestamp(report.timestamp)
except Exception:
return None
if pd.isna(ts):
return None
return ts.tz_localize("UTC") if ts.tzinfo is None else ts.tz_convert("UTC")
def _inside_alert_cooldown(key: str, report: SignalReport) -> bool:
"""True while this key sits within _ALERT_COOLDOWN_BARS primary bars
of its last ALERTED setup's bar (the backtester's entry cooldown)."""
last = _last_alert_bar.get(key)
if last is None or report.trade_setup is None:
return False
secs = _key_bar_seconds(key)
now_bar = _report_bar_ts(report)
if secs is None or now_bar is None:
return False
elapsed = (now_bar - last).total_seconds()
return 0 <= elapsed < _ALERT_COOLDOWN_BARS * secs
def _alert_fingerprint(report: SignalReport) -> tuple:
@@ -699,6 +805,14 @@ def _should_alert(key: str, report: SignalReport) -> bool:
Pure check — call _commit_alert only after the message actually went
out, so a failed Telegram send doesn't consume the alert for an hour.
"""
if _inside_alert_cooldown(key, report):
# Same rule as the backtester: no second entry within 5 bars of
# the last one, whatever the grade or the entry drift did.
logger.info(
f"alert skipped: {key} {report.final_signal.name} grade "
f"{report.signal_quality} bar {report.timestamp} is inside the "
f"{_ALERT_COOLDOWN_BARS}-bar entry cooldown (backtester rule)")
return False
fp = _alert_fingerprint(report)
if _last_alert_fp.get(key) != fp:
return True
@@ -728,6 +842,9 @@ def _commit_alert(key: str, report: SignalReport) -> None:
_last_alert_setup[key] = (
setup.entry_price, abs(setup.entry_price - setup.stop_loss)
)
bar = _report_bar_ts(report)
if bar is not None:
_last_alert_bar[key] = bar
else:
_last_alert_setup.pop(key, None)
@@ -1031,6 +1148,17 @@ def _cleanup_cooldowns() -> None:
async def _handle_breach(bot, pair_symbol: str, price: float, level_name: str) -> None:
# No M15 bar exists to read while the bar that "just closed" falls in
# gold's daily settlement pause (21:00–22:00 UTC in summer) — the
# first post-pause bar closes 15 min after the pause ends. A rescan
# here only re-reads the last pre-pause bar: the 2026-09-26 review of
# signals.db found the 20:45 UTC bar logged again at 22:01, 22:06 and
# 22:11 every evening from these tick-driven rescans (the candle loop
# already skips the same window, `_closed_bar_in_settlement`).
if _closed_bar_in_settlement(15):
logger.info(f"Level breach: {pair_symbol} crossed {level_name} at {price} — "
f"skipped, the M15 bar to scan is inside the settlement pause")
return
now = time.time()
if now - _scan_cooldowns.get(pair_symbol, 0) < _WS_COOLDOWN:
# Park the crossing instead of dropping it — price holding beyond