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). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoZKi67Ct6QTeiFJmybXSw
This commit is contained in:
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user