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:
Harwinder Sekhon
2026-09-08 11:13:52 -04:00
parent d2e679cd20
commit 2e332545a9
6 changed files with 265 additions and 48 deletions
+78 -22
View File
@@ -77,6 +77,7 @@ from market_outlook import maybe_run_outlook
from news_engine import FundamentalContext, NewsEngine
from pair_signals import PAIR_PRODUCERS
from signal_aggregator import SignalAggregator, SignalReport
from signal_log import gate_reason
from violent_watch import attach_watch, should_send_headsup
if TYPE_CHECKING: # type hints only — ViolentWatch is never needed at runtime here
@@ -96,13 +97,17 @@ _log_formatter = logging.Formatter(
)
_console_handler = logging.StreamHandler()
_console_handler.setFormatter(_log_formatter)
# 3-hour rotation × 240 backups = 30 days retention. Handlers sit on the
# ROOT logger (force=True guarantees ownership even if something logged
# first), so every module logger — data_provider, news_engine,
# signal_aggregator, websockets, telegram.ext — lands in the same file.
# One file per (server-local) day × 30 backups = 30 days retention.
# 2026-09-08: was 3-hourly × 240 — a 4-day review meant 18 files. The old
# `telegram.log.YYYY-MM-DD_HH` files do not match the daily handler's
# cleanup pattern and need one manual delete on the live box.
# Handlers sit on the ROOT logger (force=True guarantees ownership even
# if something logged first), so every module logger — data_provider,
# news_engine, signal_aggregator, websockets, telegram.ext — lands in the
# same file.
_file_handler = logging.handlers.TimedRotatingFileHandler(
os.path.join(_LOG_DIR, "telegram.log"),
when="h", interval=3, backupCount=240, encoding="utf-8",
when="midnight", backupCount=30, encoding="utf-8",
)
_file_handler.setFormatter(_log_formatter)
logging.basicConfig(level=getattr(logging, _APP.log_level, logging.INFO),
@@ -575,6 +580,44 @@ def _passes_quality(quality: str) -> bool:
return _QUALITY_RANK.get(quality, 0) >= _QUALITY_RANK.get(_alert_min_quality, 1)
def _is_alert_candidate(report: SignalReport, tf_label: str) -> bool:
"""An alert is a trade call: non-neutral, grade at/above the
threshold, AND a trade setup.
Until 2026-09-08 the setup was not required, so a signal every
generator had declined (spread gate, RR floor, swing geometry) still
went out — 07:50 UTC that day sent a B-grade SELL with no setup under
a "costs would eat the edge" warning. Skips are logged at INFO so the
decision stays visible in telegram.log."""
if report.final_signal == SignalStrength.NEUTRAL:
return False
if not _passes_quality(report.signal_quality):
return False
if report.trade_setup is None:
why = gate_reason(report.warnings, report.cleaned_warnings,
report.signal_quality, False)
logger.info(
f"alert skipped: {report.display_name} {tf_label} "
f"{report.final_signal.name} grade {report.signal_quality} "
f"has no trade setup ({why})")
return False
return True
def _alert_sent_line(report: SignalReport, tf_label: str, delivered_to: int,
kind: str = "alert") -> str:
"""One INFO line per alert that reached a chat. Only failures were
logged before 2026-09-08, so that day's log review had to reconstruct
what was sent from panel rows and dedup skips."""
s = report.trade_setup
setup = (f"{s.direction} entry {s.entry_price:.2f} sl {s.stop_loss:.2f} "
f"tp1 {s.take_profit_1:.2f} [{s.strategy}]" if s else "no setup")
rowid = getattr(report, "_signal_log_rowid", None)
return (f"{kind} sent: {report.display_name} {tf_label} "
f"{report.final_signal.name} grade {report.signal_quality} — "
f"{setup} — signal-log row #{rowid} → {delivered_to} chat(s)")
_last_alert_quality: Dict[str, str] = {} # key → grade of last ALERTED signal
@@ -677,6 +720,17 @@ async def _send_alert(bot, chat_id: int, text: str) -> bool:
return False
async def _broadcast(bot, text: str) -> int:
"""Send `text` to every signal target; returns how many chats got it.
Shared by the scan and breach paths so the delivery rule cannot
drift between them."""
delivered = 0
for chat_id in _signal_targets():
if await _send_alert(bot, chat_id, text):
delivered += 1
return delivered
# ── Candle-boundary scheduler ────────────────────────────────────────
_autoscan_chats: Set[int] = set()
@@ -786,8 +840,16 @@ async def _scan_timeframe(
_watch_state, now, w.kinds,
int(vw_settings.get("max_headsup_per_day", 12)))
if ok:
await _send_alert(bot, OWNER_ID,
_format_headsup(report, w))
if await _send_alert(bot, OWNER_ID,
_format_headsup(report, w)):
logger.info(
f"violent watch: heads-up sent ({w.level}: "
f"{', '.join(w.kinds)}) signal-log row "
f"#{getattr(report, '_signal_log_rowid', None)}")
else:
logger.warning(
"violent watch: heads-up send failed "
"(this hour's slot is consumed)")
else:
logger.info(
f"violent watch: heads-up skipped ({why})")
@@ -798,11 +860,9 @@ async def _scan_timeframe(
except Exception:
logger.exception("violent watch failed")
candidates = [
r for r in reports
if r.final_signal != SignalStrength.NEUTRAL # same gate as breach path
and _passes_quality(r.signal_quality)
]
# Same gate as the breach path: non-neutral, grade ≥ threshold, and a
# trade setup (_is_alert_candidate logs the no-setup skips).
candidates = [r for r in reports if _is_alert_candidate(r, tf.value)]
for report in candidates:
key = f"{report.symbol}_{tf.value}"
# The lock spans check → send → commit so a concurrent breach
@@ -818,14 +878,12 @@ async def _scan_timeframe(
await asyncio.get_running_loop().run_in_executor(
_executor, attach_panel, report)
detail = format_signal_message(report)
delivered = False
for chat_id in _signal_targets():
if await _send_alert(bot, chat_id, detail):
delivered = True
delivered = await _broadcast(bot, detail)
# Commit only when the alert reached at least one target;
# a fully failed send stays eligible for the next scan.
if delivered:
_commit_alert(key, report)
logger.info(_alert_sent_line(report, tf.value, delivered))
# This independent advisory call runs only after the H1 scan completes.
# Offloading the LLM request keeps the event loop responsive.
@@ -962,8 +1020,7 @@ async def _handle_breach(bot, pair_symbol: str, price: float, level_name: str) -
# Alert only when the rescan produced an actionable, changed signal —
# same gates as the auto-scanner, so a breach can't spam the channel.
if (report.final_signal == SignalStrength.NEUTRAL
or not _passes_quality(report.signal_quality)):
if not _is_alert_candidate(report, Timeframe.M15.value):
return
key = f"{pair_symbol}_{Timeframe.M15.value}"
@@ -983,12 +1040,11 @@ async def _handle_breach(bot, pair_symbol: str, price: float, level_name: str) -
f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n"
)
detail = header + format_signal_message(report)
sent = False
for chat_id in _signal_targets():
if await _send_alert(bot, chat_id, detail):
sent = True
sent = await _broadcast(bot, detail)
if sent:
_commit_alert(key, report)
logger.info(_alert_sent_line(report, Timeframe.M15.value, sent,
kind="breach alert"))
def _breach_task_done(task: asyncio.Task) -> None: