Telegram alert redesign: verdict-first layout + informational 100-pip TP

format_signal_message restyled (display-only, zero signal-logic impact):
verdict header first (signal - pair - TF - quality), trade setup moved
above the detail, per-indicator breakdown condensed to one arrow line
(_indicator_summary), heavy dividers dropped. New TP 100p line in every
setup block: entry +/- 100 pips ($10.00 on XAU) with its R-multiple vs
the stop (_INFO_TP_PIPS, formatter-only - not stamped on TradeSetup,
not in CLI/JSON/backtester). Trailing-plan logic unchanged (stamped
per-TF params, pullback fallback). Removed dead _indicator_line and
_SIGNAL_TAG. Tests 210 -> 212 (100p TP math BUY/SELL/no-setup, one-line
indicator summary).

Claude-Session: https://claude.ai/code/session_01BoZKi67Ct6QTeiFJmybXSw
This commit is contained in:
Harwinder Sekhon
2026-07-24 10:58:22 -04:00
parent 7a810c4a4c
commit c3329d6cd5
3 changed files with 127 additions and 58 deletions
+1 -1
View File
@@ -273,7 +273,7 @@ Run: `python telegram_bot.py` (credentials resolve env `TELEGRAM_TOKEN`/`TELEGRA
- **WebSocket monitor**: subscribes every `scan_pairs` symbol to TD quotes WS; 10 s heartbeat; exponential reconnect (≤60 s). On tick crossing any cached key level (`swing_high/low_1..3`, `ema_trend`, `bb_upper/lower`, `kc_upper/lower`; cache TTL 1 h) → immediate M15 rescan → "LEVEL BREAK" alert if actionable+changed (same gates as auto-scan), 5-min per-pair cooldown. - **WebSocket monitor**: subscribes every `scan_pairs` symbol to TD quotes WS; 10 s heartbeat; exponential reconnect (≤60 s). On tick crossing any cached key level (`swing_high/low_1..3`, `ema_trend`, `bb_upper/lower`, `kc_upper/lower`; cache TTL 1 h) → immediate M15 rescan → "LEVEL BREAK" alert if actionable+changed (same gates as auto-scan), 5-min per-pair cooldown.
- **Commands**: `/start` (menu), `/scan` (buttons: per pair — M5/M15/H1/H4 + Scan All TFs), `/autoscan` (toggle DM copy), `/status` (loops, WS, live prices, level cache, cooldowns), `/cache` (age per pair/TF), `/clearcache`, `/help`. - **Commands**: `/start` (menu), `/scan` (buttons: per pair — M5/M15/H1/H4 + Scan All TFs), `/autoscan` (toggle DM copy), `/status` (loops, WS, live prices, level cache, cooldowns), `/cache` (age per pair/TF), `/clearcache`, `/help`.
- **Tiingo spread monitor: REMOVED 2026-07-23** (shipped 2026-07-22, removed next day). Tiingo's free `/tiingo/fx/top` publishes a two-tier *indicative* spread for gold — exactly $0.10 in liquid hours and exactly $0.70 in the 21:00–00:00 UTC rollover window / news moments (verified: 273-sample production log + live probes; only those two values ever occur). Quotes are mid ± a posted increment, not executable depth, so the feature could never validate the $0.30 backtest cost assumption. Do not re-add a spread display from Tiingo's free FX feed. - **Tiingo spread monitor: REMOVED 2026-07-23** (shipped 2026-07-22, removed next day). Tiingo's free `/tiingo/fx/top` publishes a two-tier *indicative* spread for gold — exactly $0.10 in liquid hours and exactly $0.70 in the 21:00–00:00 UTC rollover window / news moments (verified: 273-sample production log + live probes; only those two values ever occur). Quotes are mid ± a posted increment, not executable depth, so the feature could never validate the $0.30 backtest cost assumption. Do not re-add a spread display from Tiingo's free FX feed.
- Signal messages include the full indicator breakdown, warnings, trade setup, **trailing-stop management plan** (arm price + trail distance, read from the `trail_arm_r`/`trail_dist_r` stamped on the setup by the producing per-TF config), and a risk disclaimer on every outbound message. - Signal messages (redesigned 2026-07-24, `format_signal_message`): verdict header first, then trade setup, then a condensed one-line indicator summary (`EMA↑ RSI→ …`, `_indicator_summary`) + warnings. The setup block includes an **informational 100-pip TP line** (`_INFO_TP_PIPS`, display-only — entry ± 100 pips, ≈R vs stop; NOT part of the backtested exit model) and the **trailing-stop management plan** (arm price + trail distance, read from the `trail_arm_r`/`trail_dist_r` stamped on the setup by the producing per-TF config). Risk disclaimer on every outbound message.
- Blocking scan work runs in `ThreadPoolExecutor(max_workers=4)`; one shared `ForexDataProvider` across all scans (cache reuse). - Blocking scan work runs in `ThreadPoolExecutor(max_workers=4)`; one shared `ForexDataProvider` across all scans (cache reuse).
- **Logging (hardened 2026-07-22)**: root-logger handlers (`force=True`) → console + `logs/telegram.log` (3-hour rotation, 240 backups = 30 days), full-date timestamps; every module logger (data_provider, news_engine, websockets, telegram.ext) lands in the same file. Failure surfaces all covered: candle loops catch-and-continue per cycle (one bad scan can't kill a loop), breach tasks log exceptions via done-callback, PTB `add_error_handler` registered, and `sys.excepthook`/`threading.excepthook` (installed in `main()`) route uncaught crashes to the log. WS monitor keeps its exponential-backoff reconnect + `logger.exception`. - **Logging (hardened 2026-07-22)**: root-logger handlers (`force=True`) → console + `logs/telegram.log` (3-hour rotation, 240 backups = 30 days), full-date timestamps; every module logger (data_provider, news_engine, websockets, telegram.ext) lands in the same file. Failure surfaces all covered: candle loops catch-and-continue per cycle (one bad scan can't kill a loop), breach tasks log exceptions via done-callback, PTB `add_error_handler` registered, and `sys.excepthook`/`threading.excepthook` (installed in `main()`) route uncaught crashes to the log. WS monitor keeps its exponential-backoff reconnect + `logger.exception`.
+73 -53
View File
@@ -223,15 +223,6 @@ _QUALITY_EMOJI = {
"A+": "🏆", "A": "🥇", "B": "🥈", "C": "🥉", "D": "⚠️", "F": "❌", "A+": "🏆", "A": "🥇", "B": "🥈", "C": "🥉", "D": "⚠️", "F": "❌",
} }
_SIGNAL_TAG = {
SignalStrength.STRONG_BUY: "Strong Buy",
SignalStrength.BUY: "Bullish",
SignalStrength.NEUTRAL: "Neutral",
SignalStrength.SELL: "Bearish",
SignalStrength.STRONG_SELL: "Strong Sell",
}
def _signal_label(sig: SignalStrength) -> str: def _signal_label(sig: SignalStrength) -> str:
return sig.name.replace("_", " ") return sig.name.replace("_", " ")
@@ -246,10 +237,36 @@ def _mtf_line(report: SignalReport) -> str:
return " | ".join(parts) return " | ".join(parts)
def _indicator_line(name: str, ind) -> str: # Informational fixed-pip target shown on every trade setup (display-only,
tag = _SIGNAL_TAG.get(ind.signal, "Neutral") # not part of the backtested exit model).
short = ind.description.split(" | ")[0] if " | " in ind.description else ind.description _INFO_TP_PIPS = 100
return f"[{tag}] *{_escape_md(ind.name)}*: {_escape_md(short)}"
_IND_SHORT = {
"ema_trend": "EMA", "rsi": "RSI", "macd": "MACD", "bollinger": "BB",
"adx": "ADX", "roc": "ROC", "keltner": "KC", "divergence": "Div",
"candlestick": "Cndl",
}
_IND_ARROW = {
SignalStrength.STRONG_BUY: "⇈",
SignalStrength.BUY: "↑",
SignalStrength.NEUTRAL: "→",
SignalStrength.SELL: "↓",
SignalStrength.STRONG_SELL: "⇊",
}
def _indicator_summary(report: SignalReport) -> str:
"""One-line condensed indicator readout: 'EMA↑ RSI↑ MACD→ …'."""
if not report.primary_analysis:
return ""
parts = []
for name, ind in report.primary_analysis.indicators.items():
if name.startswith("_") or ind.weight == 0:
continue
label = _IND_SHORT.get(name, name)
parts.append(f"{label}{_IND_ARROW.get(ind.signal, '→')}")
return " ".join(parts)
def _live_price_line(symbol: str) -> Optional[str]: def _live_price_line(symbol: str) -> Optional[str]:
@@ -276,46 +293,26 @@ def format_signal_message(report: SignalReport) -> str:
emoji = _SIGNAL_EMOJI.get(sig, "⬜") emoji = _SIGNAL_EMOJI.get(sig, "⬜")
qe = _QUALITY_EMOJI.get(report.signal_quality, "") qe = _QUALITY_EMOJI.get(report.signal_quality, "")
label = _signal_label(sig) label = _signal_label(sig)
mtf_tag = "✅ MTF aligned" if report.mtf_agreement else "❌ MTF diverged" mtf_tag = "MTF ✅" if report.mtf_agreement else "MTF ❌"
tf = report.primary_analysis.timeframe if report.primary_analysis else ""
lines: list[str] = []
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
regime_str = _escape_md(report.market_regime.replace("_", " ").title()) regime_str = _escape_md(report.market_regime.replace("_", " ").title())
vol_str = _escape_md(str(report.volatility_regime)) vol_str = _escape_md(str(report.volatility_regime))
lines += [
f"🔍 *{report.display_name} Signal Analysis* ({report.primary_analysis.timeframe if report.primary_analysis else ''})", lines: list[str] = [
f"🕐 {now}", f"{emoji} *{label} · {report.display_name} · {tf}* {qe}{report.signal_quality}",
"", f"🕐 {now} · 💰 {_fmt_price(report.current_price)}",
f"💰 Close: *{_fmt_price(report.current_price)}*",
] ]
live = _live_price_line(report.symbol) live = _live_price_line(report.symbol)
if live: if live:
lines.append(live) lines.append(live)
lines += [ lines += [
f"📊 Regime: {regime_str} | Vol: {vol_str}", f"Score *{report.final_score:+.1f}* · Conf *{report.final_confidence:.0f}%* · {mtf_tag}",
"", f"📊 Regime: {regime_str} · Vol: {vol_str}",
"━━━━━━━━━━━━━━━━━━━━",
f"{emoji} *{label}* {qe}{report.signal_quality} | Score: {report.final_score:+.1f} | {report.final_confidence:.0f}% conf",
f"_{_escape_md(report.action_recommendation)}_",
f"{mtf_tag}",
] ]
if report.action_recommendation:
mtf = _mtf_line(report) lines.append(f"_{_escape_md(report.action_recommendation)}_")
if mtf:
lines += ["", f"📈 *MTF*: {mtf}"]
if report.primary_analysis:
lines += ["", "━━━━━━━━━━━━━━━━━━━━", "📋 *Indicators*"]
for name, ind in report.primary_analysis.indicators.items():
if name.startswith("_") or ind.weight == 0:
continue
lines.append(_indicator_line(name, ind))
if report.warnings:
lines.append("")
for w in report.warnings:
lines.append(f"⚠️ {_escape_md(w)}")
if report.trade_setup and sig not in (SignalStrength.NEUTRAL,): if report.trade_setup and sig not in (SignalStrength.NEUTRAL,):
ts = report.trade_setup ts = report.trade_setup
@@ -324,16 +321,28 @@ def format_signal_message(report: SignalReport) -> str:
reward_usd = ts.reward_pips * ts.pip_value reward_usd = ts.reward_pips * ts.pip_value
lines += [ lines += [
"", "",
"━━━━━━━━━━━━━━━━━━━━", "🚀 *TRADE SETUP*",
f"🚀 *TRADE SETUP*",
f"{dir_emoji} Direction: *{ts.direction}*", f"{dir_emoji} Direction: *{ts.direction}*",
f"▶ Entry: `{_fmt_price(ts.entry_price)}`", f"▶ Entry `{_fmt_price(ts.entry_price)}`",
f"🛑 SL: `{_fmt_price(ts.stop_loss)}` (${risk_usd:.2f} / {ts.risk_pips:.0f} pips)", 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} pips)", f"🎯 TP1 `{_fmt_price(ts.take_profit_1)}` (${reward_usd:.2f} / {ts.reward_pips:.0f}p)",
] ]
if ts.take_profit_2: if ts.take_profit_2:
lines.append(f"🎯 TP2: `{_fmt_price(ts.take_profit_2)}`") lines.append(f"🎯 TP2 `{_fmt_price(ts.take_profit_2)}`")
lines.append(f"⚖️ RR: 1:{ts.risk_reward:.1f}")
# 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: # Trailing-stop management plan (matches backtested exit model:
# TP1 is advisory, the trail is the exit). Params are stamped on # TP1 is advisory, the trail is the exit). Params are stamped on
@@ -348,7 +357,6 @@ def format_signal_message(report: SignalReport) -> str:
arm_r = sig_cfg.pb_trail_arm_r if is_pb else sig_cfg.trail_arm_r arm_r = sig_cfg.pb_trail_arm_r if is_pb else sig_cfg.trail_arm_r
dist_r = sig_cfg.pb_trail_dist_r if is_pb else sig_cfg.trail_dist_r dist_r = sig_cfg.pb_trail_dist_r if is_pb else sig_cfg.trail_dist_r
if arm_r > 0: if arm_r > 0:
risk_dist = abs(ts.entry_price - ts.stop_loss)
if ts.direction == "BUY": if ts.direction == "BUY":
arm_price = ts.entry_price + arm_r * risk_dist arm_price = ts.entry_price + arm_r * risk_dist
else: else:
@@ -356,12 +364,24 @@ def format_signal_message(report: SignalReport) -> str:
trail_dist = dist_r * risk_dist trail_dist = dist_r * risk_dist
tag = " (pullback)" if is_pb else "" tag = " (pullback)" if is_pb else ""
lines.append( lines.append(
f"🔄 Trail{tag}: once price hits `{_fmt_price(arm_price)}` " f"🔄 Trail{tag}: arm `{_fmt_price(arm_price)}` "
f"(+{arm_r:.1f}R), trail SL ${trail_dist:.2f} " f"(+{arm_r:.1f}R), trail SL ${trail_dist:.2f} "
f"behind best price — TP1 is a guide, let it run" f"behind best price — TP1 is a guide, let it run"
) )
lines += ["", "━━━━━━━━━━━━━━━━━━━━", _DISCLAIMER] extra: list[str] = []
mtf = _mtf_line(report)
if mtf:
extra.append(f"📈 MTF: {mtf}")
ind_summary = _indicator_summary(report)
if ind_summary:
extra.append(f"📋 {ind_summary}")
for w in report.warnings:
extra.append(f"⚠️ {_escape_md(w)}")
if extra:
lines += [""] + extra
lines += ["", _DISCLAIMER]
return "\n".join(lines) return "\n".join(lines)
+53 -4
View File
@@ -1555,7 +1555,7 @@ def _():
print("\n═══ Module 7: telegram_bot.py ═══") print("\n═══ Module 7: telegram_bot.py ═══")
from telegram_bot import ( from telegram_bot import (
_escape_md, _signal_label, _mtf_line, _escape_md, _signal_label, _mtf_line, _indicator_summary,
format_signal_message, _format_scan_summary, format_signal_message, _format_scan_summary,
_seconds_until_boundary, _check_level_breach, _seconds_until_boundary, _check_level_breach,
scan_keyboard, SCAN_BUTTONS, SCAN_LOOKUP, scan_keyboard, SCAN_BUTTONS, SCAN_LOOKUP,
@@ -1615,9 +1615,9 @@ def _():
assert isinstance(msg, str) assert isinstance(msg, str)
assert len(msg) > 0 assert len(msg) > 0
assert "XAU/USD" in msg assert "XAU/USD" in msg
assert "Signal Analysis" in msg assert "Score" in msg
assert "Price" in msg assert "Regime" in msg
assert "Indicators" in msg assert "Not financial advice" in msg
@test("format_signal_message renders stamped per-TF trail plan") @test("format_signal_message renders stamped per-TF trail plan")
@@ -1644,6 +1644,55 @@ def _():
assert "$7.50" in msg assert "$7.50" in msg
@test("format_signal_message renders 100-pip informational TP")
def _():
def _setup(direction="BUY", **extra):
return TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction=direction,
entry_price=3300.0, stop_loss=3290.0, take_profit_1=3315.0,
take_profit_2=3325.0, risk_reward=1.5, risk_pips=100,
reward_pips=150, pip_value=0.10, **extra,
)
report = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="now")
report.final_signal = SignalStrength.BUY
report.signal_quality = "A"
# BUY: 100 pips × $0.10 = $10 above entry; risk $10 → exactly 1.0R.
report.trade_setup = _setup()
msg = format_signal_message(report)
assert "TP 100p" in msg
assert "3310.00" in msg, "BUY 100p TP must be entry + $10.00"
assert "≈1.0R" in msg
# SELL mirror: $10 below entry.
report.final_signal = SignalStrength.SELL
report.trade_setup = _setup(direction="SELL")
msg = format_signal_message(report)
assert "TP 100p" in msg
assert "3290.00" in msg, "SELL 100p TP must be entry - $10.00"
# No setup → no informational TP line.
report.trade_setup = None
msg = format_signal_message(report)
assert "TP 100p" not in msg
@test("_indicator_summary condenses indicators to one arrow line")
def _():
config = PlatformConfig()
agg = SignalAggregator(config)
dfs = {
Timeframe.H1: make_ohlcv(300, seed=42),
Timeframe.M15: make_ohlcv(300, seed=43),
Timeframe.H4: make_ohlcv(300, seed=44),
}
report = agg.analyze_from_dataframes("XAU/USD", dfs, use_news=False)
summary = _indicator_summary(report)
assert "EMA" in summary
assert "RSI" in summary
assert summary.count("\n") == 0, "summary must be a single line"
msg = format_signal_message(report)
assert summary in msg
assert "[Bullish]" not in msg and "[Bearish]" not in msg and "[Neutral]" not in msg
@test("_format_scan_summary produces valid text") @test("_format_scan_summary produces valid text")
def _(): def _():
config = PlatformConfig() config = PlatformConfig()