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:
+175
-47
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user