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. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xY4NPPrx6NJoG6QWHzwpd
This commit is contained in:
@@ -72,7 +72,8 @@ tail -f logs/dashboard.log
|
|||||||
|
|
||||||
The daemon scans all four timeframes at boot, then wakes at every M5
|
The daemon scans all four timeframes at boot, then wakes at every M5
|
||||||
boundary (+10 s) and rescans whichever timeframes just closed. Logs rotate
|
boundary (+10 s) and rescans whichever timeframes just closed. Logs rotate
|
||||||
every 3 h (240 backups), same scheme as the bot.
|
every 3 h (240 backups); the bot's own `telegram.log` rotates daily (30
|
||||||
|
backups) since 2026-09-08.
|
||||||
|
|
||||||
## 5. Optional Apache polish
|
## 5. Optional Apache polish
|
||||||
|
|
||||||
|
|||||||
+62
-15
@@ -18,6 +18,11 @@ import pandas as pd
|
|||||||
|
|
||||||
_NY_TZ = ZoneInfo("America/New_York")
|
_NY_TZ = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
# Prefix of the cost-floor veto both core setup generators emit; the XAU_CP
|
||||||
|
# cleanup and the post-archetype stale-veto pass match on it, so it is
|
||||||
|
# spelled once.
|
||||||
|
_SPREAD_GATE_PREFIX = "SPREAD GATE:"
|
||||||
|
|
||||||
from chart_patterns import detect_chart_pattern
|
from chart_patterns import detect_chart_pattern
|
||||||
from config import PlatformConfig, SignalStrength, Timeframe
|
from config import PlatformConfig, SignalStrength, Timeframe
|
||||||
from providers import BaseDataProvider, make_data_provider
|
from providers import BaseDataProvider, make_data_provider
|
||||||
@@ -129,8 +134,11 @@ class SignalReport:
|
|||||||
bullish_reasons: List[str] = field(default_factory=list)
|
bullish_reasons: List[str] = field(default_factory=list)
|
||||||
bearish_reasons: List[str] = field(default_factory=list)
|
bearish_reasons: List[str] = field(default_factory=list)
|
||||||
warnings: List[str] = field(default_factory=list)
|
warnings: List[str] = field(default_factory=list)
|
||||||
# Warnings the XAU_CP acceptance removed from `warnings` (metadata
|
# Warnings a shipped setup superseded, removed from `warnings` but
|
||||||
# only — logged so the gates a CP trade bypassed stay auditable).
|
# logged so they stay auditable (metadata only): the vetoes the
|
||||||
|
# XAU_CP acceptance stripped and, since 2026-09-08, a core
|
||||||
|
# generator's stale spread-gate veto when a later generator produced
|
||||||
|
# the setup (`_drop_stale_spread_gate_vetoes`).
|
||||||
cleaned_warnings: List[str] = field(default_factory=list)
|
cleaned_warnings: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
current_price: float = 0.0
|
current_price: float = 0.0
|
||||||
@@ -889,7 +897,7 @@ class SignalAggregator:
|
|||||||
final when this runs.
|
final when this runs.
|
||||||
"""
|
"""
|
||||||
stale_prefixes = (
|
stale_prefixes = (
|
||||||
"SPREAD GATE:",
|
_SPREAD_GATE_PREFIX,
|
||||||
"EXHAUSTION BLOCK",
|
"EXHAUSTION BLOCK",
|
||||||
"EXHAUSTED TREND FILTER:",
|
"EXHAUSTED TREND FILTER:",
|
||||||
"MTF OPPOSITION:",
|
"MTF OPPOSITION:",
|
||||||
@@ -1225,16 +1233,7 @@ class SignalAggregator:
|
|||||||
self._apply_m5_filters(report)
|
self._apply_m5_filters(report)
|
||||||
|
|
||||||
# ── 7. Trade setup for actionable signals ──────────────────
|
# ── 7. Trade setup for actionable signals ──────────────────
|
||||||
# ATR generator is XAU-only: it hardcodes gold pip value and
|
self._attach_core_setup(report, pip_value)
|
||||||
# 2-decimal price rounding.
|
|
||||||
if (report.trade_setup is None and report.signal_quality in ("A+", "A")
|
|
||||||
and report.display_name == "XAU/USD"):
|
|
||||||
atr = (report.primary_analysis.key_levels.get("atr", 0)
|
|
||||||
if report.primary_analysis else 0)
|
|
||||||
if atr > 0:
|
|
||||||
report.trade_setup = self._generate_xau_atr_trade_setup(report, atr)
|
|
||||||
if report.trade_setup is None and report.signal_quality in ("A+", "A", "B", "C"):
|
|
||||||
report.trade_setup = self._generate_trade_setup(report, pip_value)
|
|
||||||
|
|
||||||
# ── 8. Reasons ─────────────────────────────────────────────
|
# ── 8. Reasons ─────────────────────────────────────────────
|
||||||
self._collect_reasons(report)
|
self._collect_reasons(report)
|
||||||
@@ -1271,6 +1270,11 @@ class SignalAggregator:
|
|||||||
for w in report.warnings)):
|
for w in report.warnings)):
|
||||||
self._try_chart_pattern(report, primary_df, pip_value)
|
self._try_chart_pattern(report, primary_df, pip_value)
|
||||||
|
|
||||||
|
# ── 11. Stale spread-gate vetoes ─────────────────────────────
|
||||||
|
# Whichever archetype shipped the setup, a core generator's
|
||||||
|
# cost-floor veto no longer describes it.
|
||||||
|
self._drop_stale_spread_gate_vetoes(report)
|
||||||
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
# ── Internals ──────────────────────────────────────────────────
|
# ── Internals ──────────────────────────────────────────────────
|
||||||
@@ -1278,6 +1282,49 @@ class SignalAggregator:
|
|||||||
# Only A+/A reach the XAU ATR generator (gate at the call site).
|
# Only A+/A reach the XAU ATR generator (gate at the call site).
|
||||||
_TP_QUALITY_SCALE = {"A+": 2.0, "A": 1.5}
|
_TP_QUALITY_SCALE = {"A+": 2.0, "A": 1.5}
|
||||||
|
|
||||||
|
def _attach_core_setup(self, report: SignalReport, pip_value: float) -> None:
|
||||||
|
"""Step 7: the XAU ATR generator (A+/A) first, the swing generator
|
||||||
|
(GENERIC) for anything it declined. A veto either one leaves
|
||||||
|
behind is reconciled after every archetype has run
|
||||||
|
(`_drop_stale_spread_gate_vetoes`)."""
|
||||||
|
# ATR generator is XAU-only: it hardcodes gold pip value and
|
||||||
|
# 2-decimal price rounding.
|
||||||
|
if (report.trade_setup is None and report.signal_quality in ("A+", "A")
|
||||||
|
and report.display_name == "XAU/USD"):
|
||||||
|
atr = (report.primary_analysis.key_levels.get("atr", 0)
|
||||||
|
if report.primary_analysis else 0)
|
||||||
|
if atr > 0:
|
||||||
|
report.trade_setup = self._generate_xau_atr_trade_setup(report, atr)
|
||||||
|
if report.trade_setup is None and report.signal_quality in ("A+", "A", "B", "C"):
|
||||||
|
report.trade_setup = self._generate_trade_setup(report, pip_value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _drop_stale_spread_gate_vetoes(report: SignalReport) -> None:
|
||||||
|
"""Step 11: runs once after every setup archetype (core ATR/swing,
|
||||||
|
XAU_PB, XAU_CP) has had its turn.
|
||||||
|
|
||||||
|
Both core generators veto a target under the cost floor with a
|
||||||
|
`_SPREAD_GATE_PREFIX` warning. When a later generator then ships
|
||||||
|
a setup with a bigger target, that veto is stale — the 2026-09-08
|
||||||
|
07:45 UTC A-grade alert said "costs would eat the edge" beside a
|
||||||
|
$26.49 TP1. A stale veto moves to `cleaned_warnings` (metadata
|
||||||
|
only, the XAU_CP cleanup's contract; that path has already
|
||||||
|
stripped its own copy by the time this runs, and the PB path is
|
||||||
|
covered here). Nothing downstream reads warnings while a setup
|
||||||
|
exists, so trade books are unchanged — verified 2026-09-08 on the
|
||||||
|
3-yr H1/M15 books; only the exported `warnings` text column
|
||||||
|
differs.
|
||||||
|
"""
|
||||||
|
if report.trade_setup is None:
|
||||||
|
return
|
||||||
|
kept: List[str] = []
|
||||||
|
stale: List[str] = []
|
||||||
|
for warning in report.warnings:
|
||||||
|
(stale if warning.startswith(_SPREAD_GATE_PREFIX) else kept).append(warning)
|
||||||
|
if stale:
|
||||||
|
report.warnings[:] = kept
|
||||||
|
report.cleaned_warnings.extend(stale)
|
||||||
|
|
||||||
def _generate_xau_atr_trade_setup(
|
def _generate_xau_atr_trade_setup(
|
||||||
self, report: SignalReport, atr: float
|
self, report: SignalReport, atr: float
|
||||||
) -> Optional[TradeSetup]:
|
) -> Optional[TradeSetup]:
|
||||||
@@ -1325,7 +1372,7 @@ class SignalAggregator:
|
|||||||
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
|
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
|
||||||
if tp_distance < min_tp:
|
if tp_distance < min_tp:
|
||||||
report.warnings.append(
|
report.warnings.append(
|
||||||
f"SPREAD GATE: target ${tp_distance:.2f} < ${min_tp:.2f} minimum — "
|
f"{_SPREAD_GATE_PREFIX} target ${tp_distance:.2f} < ${min_tp:.2f} minimum — "
|
||||||
f"costs would eat the edge"
|
f"costs would eat the edge"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -1744,7 +1791,7 @@ class SignalAggregator:
|
|||||||
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
|
min_tp = self.config.signals.round_trip_cost * self.config.signals.min_tp_cost_multiple
|
||||||
if tp1_distance < min_tp:
|
if tp1_distance < min_tp:
|
||||||
report.warnings.append(
|
report.warnings.append(
|
||||||
f"SPREAD GATE: target ${tp1_distance:.2f} < ${min_tp:.2f} minimum — "
|
f"{_SPREAD_GATE_PREFIX} target ${tp1_distance:.2f} < ${min_tp:.2f} minimum — "
|
||||||
f"costs would eat the edge"
|
f"costs would eat the edge"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|||||||
+5
-2
@@ -49,8 +49,11 @@ or news_engine) so this module stays at the bottom of the import graph.
|
|||||||
* Post-gate truth columns: ``gate_reason`` (the warning that suppressed
|
* Post-gate truth columns: ``gate_reason`` (the warning that suppressed
|
||||||
the setup), ``actionable`` (grade >= C AND setup present — what an
|
the setup), ``actionable`` (grade >= C AND setup present — what an
|
||||||
alert surface would act on; the ``action`` text is written BEFORE the
|
alert surface would act on; the ``action`` text is written BEFORE the
|
||||||
gate chain and can disagree), ``cleaned_warnings_json`` (warnings the
|
gate chain and can disagree), ``cleaned_warnings_json`` (warnings a
|
||||||
XAU_CP acceptance removed from ``warnings_json``), ``bar_range_median``
|
shipped setup superseded, removed from ``warnings_json``: the vetoes
|
||||||
|
the XAU_CP acceptance stripped and, since 2026-09-08, a core
|
||||||
|
generator's stale spread-gate veto when a later generator produced
|
||||||
|
the setup — so non-empty does NOT mean "XAU_CP row"), ``bar_range_median``
|
||||||
(the value historically stored as ``spread`` — it is the median
|
(the value historically stored as ``spread`` — it is the median
|
||||||
High-Low of the primary frame, NOT a broker spread), ``scan_kind``
|
High-Low of the primary frame, NOT a broker spread), ``scan_kind``
|
||||||
(``scan``; ``blocked`` for weekend/day early returns, which carry no
|
(``scan``; ``blocked`` for weekend/day early returns, which carry no
|
||||||
|
|||||||
+78
-22
@@ -77,6 +77,7 @@ from market_outlook import maybe_run_outlook
|
|||||||
from news_engine import FundamentalContext, NewsEngine
|
from news_engine import FundamentalContext, NewsEngine
|
||||||
from pair_signals import PAIR_PRODUCERS
|
from pair_signals import PAIR_PRODUCERS
|
||||||
from signal_aggregator import SignalAggregator, SignalReport
|
from signal_aggregator import SignalAggregator, SignalReport
|
||||||
|
from signal_log import gate_reason
|
||||||
from violent_watch import attach_watch, should_send_headsup
|
from violent_watch import attach_watch, should_send_headsup
|
||||||
|
|
||||||
if TYPE_CHECKING: # type hints only — ViolentWatch is never needed at runtime here
|
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 = logging.StreamHandler()
|
||||||
_console_handler.setFormatter(_log_formatter)
|
_console_handler.setFormatter(_log_formatter)
|
||||||
# 3-hour rotation × 240 backups = 30 days retention. Handlers sit on the
|
# One file per (server-local) day × 30 backups = 30 days retention.
|
||||||
# ROOT logger (force=True guarantees ownership even if something logged
|
# 2026-09-08: was 3-hourly × 240 — a 4-day review meant 18 files. The old
|
||||||
# first), so every module logger — data_provider, news_engine,
|
# `telegram.log.YYYY-MM-DD_HH` files do not match the daily handler's
|
||||||
# signal_aggregator, websockets, telegram.ext — lands in the same file.
|
# 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(
|
_file_handler = logging.handlers.TimedRotatingFileHandler(
|
||||||
os.path.join(_LOG_DIR, "telegram.log"),
|
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)
|
_file_handler.setFormatter(_log_formatter)
|
||||||
logging.basicConfig(level=getattr(logging, _APP.log_level, logging.INFO),
|
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)
|
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
|
_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
|
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 ────────────────────────────────────────
|
# ── Candle-boundary scheduler ────────────────────────────────────────
|
||||||
|
|
||||||
_autoscan_chats: Set[int] = set()
|
_autoscan_chats: Set[int] = set()
|
||||||
@@ -786,8 +840,16 @@ async def _scan_timeframe(
|
|||||||
_watch_state, now, w.kinds,
|
_watch_state, now, w.kinds,
|
||||||
int(vw_settings.get("max_headsup_per_day", 12)))
|
int(vw_settings.get("max_headsup_per_day", 12)))
|
||||||
if ok:
|
if ok:
|
||||||
await _send_alert(bot, OWNER_ID,
|
if await _send_alert(bot, OWNER_ID,
|
||||||
_format_headsup(report, w))
|
_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:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"violent watch: heads-up skipped ({why})")
|
f"violent watch: heads-up skipped ({why})")
|
||||||
@@ -798,11 +860,9 @@ async def _scan_timeframe(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("violent watch failed")
|
logger.exception("violent watch failed")
|
||||||
|
|
||||||
candidates = [
|
# Same gate as the breach path: non-neutral, grade ≥ threshold, and a
|
||||||
r for r in reports
|
# trade setup (_is_alert_candidate logs the no-setup skips).
|
||||||
if r.final_signal != SignalStrength.NEUTRAL # same gate as breach path
|
candidates = [r for r in reports if _is_alert_candidate(r, tf.value)]
|
||||||
and _passes_quality(r.signal_quality)
|
|
||||||
]
|
|
||||||
for report in candidates:
|
for report in candidates:
|
||||||
key = f"{report.symbol}_{tf.value}"
|
key = f"{report.symbol}_{tf.value}"
|
||||||
# The lock spans check → send → commit so a concurrent breach
|
# 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(
|
await asyncio.get_running_loop().run_in_executor(
|
||||||
_executor, attach_panel, report)
|
_executor, attach_panel, report)
|
||||||
detail = format_signal_message(report)
|
detail = format_signal_message(report)
|
||||||
delivered = False
|
delivered = await _broadcast(bot, detail)
|
||||||
for chat_id in _signal_targets():
|
|
||||||
if await _send_alert(bot, chat_id, detail):
|
|
||||||
delivered = True
|
|
||||||
# Commit only when the alert reached at least one target;
|
# Commit only when the alert reached at least one target;
|
||||||
# a fully failed send stays eligible for the next scan.
|
# a fully failed send stays eligible for the next scan.
|
||||||
if delivered:
|
if delivered:
|
||||||
_commit_alert(key, report)
|
_commit_alert(key, report)
|
||||||
|
logger.info(_alert_sent_line(report, tf.value, delivered))
|
||||||
|
|
||||||
# This independent advisory call runs only after the H1 scan completes.
|
# This independent advisory call runs only after the H1 scan completes.
|
||||||
# Offloading the LLM request keeps the event loop responsive.
|
# 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 —
|
# Alert only when the rescan produced an actionable, changed signal —
|
||||||
# same gates as the auto-scanner, so a breach can't spam the channel.
|
# same gates as the auto-scanner, so a breach can't spam the channel.
|
||||||
if (report.final_signal == SignalStrength.NEUTRAL
|
if not _is_alert_candidate(report, Timeframe.M15.value):
|
||||||
or not _passes_quality(report.signal_quality)):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
key = f"{pair_symbol}_{Timeframe.M15.value}"
|
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"
|
f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n"
|
||||||
)
|
)
|
||||||
detail = header + format_signal_message(report)
|
detail = header + format_signal_message(report)
|
||||||
sent = False
|
sent = await _broadcast(bot, detail)
|
||||||
for chat_id in _signal_targets():
|
|
||||||
if await _send_alert(bot, chat_id, detail):
|
|
||||||
sent = True
|
|
||||||
if sent:
|
if sent:
|
||||||
_commit_alert(key, report)
|
_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:
|
def _breach_task_done(task: asyncio.Task) -> None:
|
||||||
|
|||||||
+113
-4
@@ -2579,6 +2579,55 @@ def _():
|
|||||||
d.pop(key, None)
|
d.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
@test("Alert candidates: a quality-passing signal without a trade setup is skipped and logged")
|
||||||
|
def _():
|
||||||
|
import logging
|
||||||
|
import telegram_bot as tb
|
||||||
|
from signal_aggregator import SignalReport, TradeSetup
|
||||||
|
|
||||||
|
def rpt(setup: bool, signal=SignalStrength.SELL, q="B"):
|
||||||
|
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
|
||||||
|
r.final_signal = signal
|
||||||
|
r.signal_quality = q
|
||||||
|
r.warnings = ["SPREAD GATE: target $8.31 < $9.00 minimum — "
|
||||||
|
"costs would eat the edge"]
|
||||||
|
if setup:
|
||||||
|
r.trade_setup = TradeSetup(
|
||||||
|
pair="XAU/USD", display_name="XAU/USD", direction="SELL",
|
||||||
|
entry_price=4388.0, stop_loss=4410.0, take_profit_1=4377.0,
|
||||||
|
take_profit_2=4372.0, risk_reward=0.5, risk_pips=220.0,
|
||||||
|
reward_pips=110.0, pip_value=0.10)
|
||||||
|
return r
|
||||||
|
|
||||||
|
records = []
|
||||||
|
handler = logging.Handler()
|
||||||
|
handler.emit = records.append
|
||||||
|
tb.logger.addHandler(handler)
|
||||||
|
saved_threshold = tb._alert_min_quality
|
||||||
|
tb._alert_min_quality = "C"
|
||||||
|
try:
|
||||||
|
assert tb._is_alert_candidate(rpt(True), "5min")
|
||||||
|
assert not tb._is_alert_candidate(rpt(False), "5min"), \
|
||||||
|
"no trade setup → no alert (the 2026-09-08 07:50 UTC no-setup SELL)"
|
||||||
|
assert not tb._is_alert_candidate(rpt(True, SignalStrength.NEUTRAL), "5min")
|
||||||
|
assert not tb._is_alert_candidate(rpt(True, q="F"), "5min")
|
||||||
|
finally:
|
||||||
|
tb._alert_min_quality = saved_threshold
|
||||||
|
tb.logger.removeHandler(handler)
|
||||||
|
skips = [r.getMessage() for r in records
|
||||||
|
if r.getMessage().startswith("alert skipped:")]
|
||||||
|
assert len(skips) == 1, skips
|
||||||
|
assert "5min SELL grade B" in skips[0] and "SPREAD GATE" in skips[0], skips[0]
|
||||||
|
|
||||||
|
line = tb._alert_sent_line(rpt(True), "5min", 2)
|
||||||
|
assert line.startswith("alert sent: XAU/USD 5min SELL grade B"), line
|
||||||
|
assert "SELL entry 4388.00 sl 4410.00 tp1 4377.00" in line and "2 chat(s)" in line, line
|
||||||
|
r = rpt(True)
|
||||||
|
r._signal_log_rowid = 917
|
||||||
|
breach = tb._alert_sent_line(r, "15min", 1, kind="breach alert")
|
||||||
|
assert breach.startswith("breach alert sent:") and "row #917" in breach, breach
|
||||||
|
|
||||||
|
|
||||||
@test("Backtester --confirm-tfs defaults to None (factory's live map)")
|
@test("Backtester --confirm-tfs defaults to None (factory's live map)")
|
||||||
def _():
|
def _():
|
||||||
from gpu_backtest import _parse_args
|
from gpu_backtest import _parse_args
|
||||||
@@ -2861,6 +2910,65 @@ def _():
|
|||||||
], rep.warnings
|
], rep.warnings
|
||||||
|
|
||||||
|
|
||||||
|
@test("Core setup: a stale ATR spread-gate veto is cleaned when the swing generator ships a setup")
|
||||||
|
def _():
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from signal_aggregator import SignalAggregator, SignalReport, TradeSetup
|
||||||
|
|
||||||
|
agg = SignalAggregator(PlatformConfig())
|
||||||
|
price = 4400.0
|
||||||
|
|
||||||
|
def rpt(levels):
|
||||||
|
r = SignalReport(symbol="XAU/USD", display_name="XAU/USD", timestamp="t")
|
||||||
|
r.final_signal = SignalStrength.STRONG_BUY
|
||||||
|
r.final_score = 40.0
|
||||||
|
r.signal_quality = "A"
|
||||||
|
r.current_price = price
|
||||||
|
r.primary_analysis = SimpleNamespace(key_levels=dict(levels))
|
||||||
|
return r
|
||||||
|
|
||||||
|
# ATR $2 → the A-grade ATR target is 1.5 × $2 = $3, under the $9 cost
|
||||||
|
# floor (veto + warning). The swing generator's stop sits under the
|
||||||
|
# $20-deep swing low, so its RR-floor target is $11 and a GENERIC
|
||||||
|
# setup ships — the ATR veto is then stale (alert #915, 2026-09-08).
|
||||||
|
r = rpt({"atr": 2.0, "swing_low_1": price - 20.0})
|
||||||
|
agg._attach_core_setup(r, 0.10)
|
||||||
|
assert r.trade_setup is not None and r.trade_setup.strategy == "GENERIC", r.trade_setup
|
||||||
|
assert sum(w.startswith("SPREAD GATE:") for w in r.warnings) == 1, \
|
||||||
|
"the generators themselves leave the veto in place"
|
||||||
|
agg._drop_stale_spread_gate_vetoes(r)
|
||||||
|
assert not any(w.startswith("SPREAD GATE:") for w in r.warnings), r.warnings
|
||||||
|
assert sum(w.startswith("SPREAD GATE:") for w in r.cleaned_warnings) == 1, \
|
||||||
|
"the veto must stay auditable in cleaned_warnings"
|
||||||
|
|
||||||
|
# No swing structure: the swing generator's fallback target is
|
||||||
|
# max(1.5 × ATR, RR floor × the $8 minimum stop) = $4 — also gated —
|
||||||
|
# so no setup ships and BOTH vetoes stay visible.
|
||||||
|
r2 = rpt({"atr": 2.0})
|
||||||
|
agg._attach_core_setup(r2, 0.10)
|
||||||
|
agg._drop_stale_spread_gate_vetoes(r2)
|
||||||
|
assert r2.trade_setup is None
|
||||||
|
assert sum(w.startswith("SPREAD GATE:") for w in r2.warnings) == 2, r2.warnings
|
||||||
|
assert not r2.cleaned_warnings
|
||||||
|
|
||||||
|
# The pass runs after the PB/CP archetypes too: a setup shipped by a
|
||||||
|
# later generator (XAU_PB shape here) supersedes BOTH core vetoes,
|
||||||
|
# and unrelated warnings keep their order.
|
||||||
|
r3 = rpt({"atr": 2.0})
|
||||||
|
agg._attach_core_setup(r3, 0.10)
|
||||||
|
r3.warnings.insert(0, "NEAR EMA200 (0.2%) — chop risk")
|
||||||
|
r3.warnings.append("TREND EXHAUSTION — price extended >3.5 ATR past swing low")
|
||||||
|
r3.trade_setup = TradeSetup(
|
||||||
|
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
|
||||||
|
entry_price=price, stop_loss=price - 8.0, take_profit_1=price + 12.0,
|
||||||
|
take_profit_2=price + 18.0, risk_reward=1.5, risk_pips=80.0,
|
||||||
|
reward_pips=120.0, pip_value=0.10, strategy="XAU_PB")
|
||||||
|
agg._drop_stale_spread_gate_vetoes(r3)
|
||||||
|
assert r3.warnings == ["NEAR EMA200 (0.2%) — chop risk",
|
||||||
|
"TREND EXHAUSTION — price extended >3.5 ATR past swing low"], r3.warnings
|
||||||
|
assert sum(w.startswith("SPREAD GATE:") for w in r3.cleaned_warnings) == 2
|
||||||
|
|
||||||
|
|
||||||
@test("CP gate: MTF opposition blocks the pattern trade")
|
@test("CP gate: MTF opposition blocks the pattern trade")
|
||||||
def _():
|
def _():
|
||||||
cfg = _cp_config()
|
cfg = _cp_config()
|
||||||
@@ -3392,15 +3500,16 @@ def _():
|
|||||||
load_dotenv("/nonexistent/.env") # missing file is a silent no-op
|
load_dotenv("/nonexistent/.env") # missing file is a silent no-op
|
||||||
|
|
||||||
|
|
||||||
@test("telegram_bot: log rotation is 3-hourly with 30-day retention")
|
@test("telegram_bot: log rotation is daily with 30-day retention")
|
||||||
def _():
|
def _():
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
import telegram_bot as tb
|
import telegram_bot as tb
|
||||||
h = tb._file_handler
|
h = tb._file_handler
|
||||||
assert isinstance(h, logging.handlers.TimedRotatingFileHandler)
|
assert isinstance(h, logging.handlers.TimedRotatingFileHandler)
|
||||||
assert h.when.upper() == "H" and h.interval == 3 * 3600, \
|
assert h.when.upper() == "MIDNIGHT", \
|
||||||
"rotation must fire every 3 hours"
|
"one file per day (2026-09-08: was 3-hourly × 240)"
|
||||||
assert h.backupCount == 240, "240 × 3h = 30-day retention"
|
assert h.interval == 86400, "plain daily — an interval=N would stretch retention"
|
||||||
|
assert h.backupCount == 30, "30 daily files = 30-day retention"
|
||||||
|
|
||||||
|
|
||||||
@test("telegram_bot: candle loop survives a failing scan cycle")
|
@test("telegram_bot: candle loop survives a failing scan cycle")
|
||||||
|
|||||||
Reference in New Issue
Block a user