Daily-trend veto on M15 + lot-size / already-open lines (campaign 2026-09-30), and the 2026-10-01 whole-codebase bug review
Two bodies of work that touch the same files, so one commit. Direction and setup-size campaign (2026-09-30 → 10-01, owner's RISK-FIRST rule, written before the runs): - SIGNAL: M15 daily-trend veto (factory override 7, xau_strategy.daily_trend_veto "both"). A finished setup against a clear daily trend (closed D1 bars, Sunday stubs dropped; close vs its 50-bar EMA AND vs the close 60 kept days earlier) is removed as the last step of the core. One definition (signal_aggregator.daily_trend_series) for live, the bar replay and the twin's host side; D1 is a required frame of the M15 scan; mirrored in cuda_signal_kernels.py K2. W3 824 / +205.12R / PF 1.73 / max DD 12.4R vs 1105 / +195.79R / 1.49 / 20.9R; 2019 → 2026 1473 / +223.33R / 1.42 / 41.5R vs 2091 / +192.53R / 1.24 / 64.8R; unseen holdout −4.62R vs −6.63R. - FAILED, not shipped: the veto on M5 (W3 drawdown 10.1R → 15.2R) and on H1 (−9.7R), buy-only and sell-only on M15, the wide-stop cap (xau_strategy.wide_stop_cap_atr stays 0.0, a research knob). - New M15 pins: W2 dfc647ee…, OOS e87b6de1…, W3 ad3b0dbd…. M5 / H1 / H4 pins unchanged. --set xau_strategy.daily_trend_veto=off reproduces the previous M15 pins byte for byte. Twin and reference script MD5-equal on every window; recorder and feature-export parity re-proved; base-rate table rebuilt. - DISPLAY (books byte-identical): exposure.py — the lot size for trade_size.risk_per_trade (new config.json block, off by default, private to the owner) and the count of alerted same-direction plans still open (signals.alerted_at, read-only open_alerted_setups); the daily trend in words on Telegram, the CLI and the dashboard; signals.daily_trend logged. - providers/oanda: a date window with no bars is an empty page, not an error (a D1 pad starting on a Saturday could not be fetched). - Reports only, nothing shipped: seven context screens (no state nominated) and eight outside data sources (none adds a forecast to gold's own daily trend). - Independent verification (report section 5.13): every figure reproduced on both scripts, scoring identical to the previous commit on all four timeframes, no look-ahead. Caveats recorded there: this is a risk filter, not a more accurate direction call; nine tenths of the 2019 → 2026 gain is 2022; the neighbour clause is thin (a simple 100-day average misses the R line by 1.2R); "never adds a trade" is an observation, not a guarantee. Bug review of the whole codebase (2026-10-01). Signal path clean. 17 defects elsewhere, plus 4 found by an independent review of the fixes; all fixed, each with a test; no trade book changed: - telegram_bot: one _deliver_alert for the candle scan and the level break. The row is stamped alerted_at BEFORE the send, on a one-worker alert-db pool, and un-stamped when no chat received it or the task is cancelled mid-stamp, so two alerts at one candle close count each other and no SQLite write runs on the event loop. The 5-bar cooldown counts bars of the scan's own frame (== gpu_backtest._apply_cooldown; clock time released the 22:00 UTC bar after the settlement pause) and is restored from signals.db at start-up. The lot-size line goes to the owner's own chat only (a group the owner ran /autoscan in used to receive it). /status gains a news-model line and escapes failure details. - signal_log: mark_alerted returns the stamp it wrote; unmark_alerted; last_alerted_bars (read-only). - advisory_outcome: candle continuity is judged bar by bar inside the walk; a hole after a trade's exit no longer voids it. The stored-candle replay resolves 108 of 111 logged setups instead of 99, all equal to the live resolver's R. - providers/oanda: an empty date window is accepted only when it is 5 days or shorter and no malformed candle was skipped; a longer one is retried, then refused, instead of being cached as history with a hole. - news_engine: blank RSS titles skipped; one unreadable headline no longer costs the local model its whole batch (checked on the real bundle); model health recorded; no DeepSeek audit call in a process that does not archive; event-direction cache keyed per release; peek_week_events never fetches. - llm_client: chat_json never raises and never logs the API key (requests quotes the Authorization value in InvalidHeader; error bodies are redacted). - signal_aggregator: the scan's news block is guarded; the replay path (use_news=False) assigns exactly the values it did before. - agent_panel: PANEL_PROMPT_VERSION v2.2 — same prompt text, corrected input (event timing over the cached calendar week, headlines newest first). scripts/panel_replay.py replays the logged payload per persona; scripts/label_scan_outcomes.py counts with an ISO cutoff. - test_all.py: no ALTER TABLE … DROP COLUMN (the live box has SQLite 3.34); tests that write Parquet print a notice and return without pyarrow. Verification after the last code change: suite 632 passed, 0 failed (also with both Parquet engines blocked and FB2_BACKEND=cpu, the live box's view); the five reference MD5s reproduce on both backtesters, 0 cache fallbacks; Module 37 green; static Python 3.9 / SQLite 3.34 check clean on every touched file. Evidence: backtest/research/daily_trend_campaign_2026-09-30.md, context_screens_2026-10.md, new_inputs_2026-10.md; CLAUDE.md sections 2, 4, 6, 8, 9, 10 and 11; pair_signals/xauusd.py docstring (M15 override 7).
This commit is contained in:
+275
-43
@@ -74,11 +74,13 @@ from app_config import (
|
||||
from providers import BaseDataProvider, make_data_provider
|
||||
from providers.base import _is_daily_maintenance
|
||||
from agent_panel import attach_panel, panel_health
|
||||
from exposure import attach_exposure, size_shared
|
||||
from llm_client import get_llm_client
|
||||
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 signal_log import (gate_reason, last_alerted_bars, mark_alerted,
|
||||
unmark_alerted)
|
||||
from violent_watch import attach_watch, should_send_headsup
|
||||
|
||||
if TYPE_CHECKING: # type hints only — ViolentWatch is never needed at runtime here
|
||||
@@ -199,6 +201,16 @@ TF_CONFIRMATIONS = {
|
||||
}
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=4)
|
||||
# ONE worker for an alert's database steps (refresh the "already open" line,
|
||||
# stamp the row as alerted, undo the stamp). A single worker is the mutual
|
||||
# exclusion: one alert's read and write run back to back, in arrival order,
|
||||
# so of two alerts sent at the same candle close the second counts the
|
||||
# first. It also keeps SQLite off the event loop (the logger's lock can be
|
||||
# held for seconds by the outcome labeller) and never queues behind the
|
||||
# scan pool above. Not an asyncio.Lock: on Python 3.9 one created at
|
||||
# import binds to a loop that is not the running one.
|
||||
_alert_db_executor = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="alert-db")
|
||||
|
||||
# ── Markdown escaping ─────────────────────────────────────────────────
|
||||
|
||||
@@ -409,7 +421,23 @@ def _plain_words_line(report: SignalReport) -> str:
|
||||
f"Reliability: *{reliability_words(report.final_confidence)}* · {agree}")
|
||||
|
||||
|
||||
def format_signal_message(report: SignalReport) -> str:
|
||||
def _daily_trend_line(report: SignalReport) -> str:
|
||||
""""🧭 Daily trend: rising — this buy goes with it" (main.daily_trend_words,
|
||||
the same words on every surface), or "" when the scan carries no daily
|
||||
trend. On M15 a plan against a clear daily trend never reaches an
|
||||
alert (the core removes it); on the other timeframes the line is
|
||||
information only."""
|
||||
from main import daily_trend_words # display helper, no side effects
|
||||
words = daily_trend_words(report)
|
||||
return f"🧭 {_escape_md(words)}" if words else ""
|
||||
|
||||
|
||||
def format_signal_message(report: SignalReport, channel: bool = False) -> str:
|
||||
"""The alert / scan text. `channel=True` builds the copy for every
|
||||
audience but the owner's own chat (the subscriber channel, a group
|
||||
the bot was switched on in): identical, except that the lot-size line
|
||||
(which reveals the owner's risk per trade) is left out unless
|
||||
config.json sets trade_size.telegram_channel."""
|
||||
sig = report.final_signal
|
||||
emoji = _SIGNAL_EMOJI.get(sig, "⬜")
|
||||
qe = _QUALITY_EMOJI.get(report.signal_quality, "")
|
||||
@@ -431,6 +459,9 @@ def format_signal_message(report: SignalReport) -> str:
|
||||
_plain_words_line(report),
|
||||
f"📊 Regime: {regime_str} · Vol: {vol_str}",
|
||||
]
|
||||
trend_line = _daily_trend_line(report)
|
||||
if trend_line:
|
||||
lines.append(trend_line)
|
||||
if report.action_recommendation:
|
||||
# Pattern identifiers use underscores, which legacy Telegram Markdown
|
||||
# can misparse even when escaped inside this italicized action line.
|
||||
@@ -457,6 +488,15 @@ def format_signal_message(report: SignalReport) -> str:
|
||||
f"▶ Entry `{_fmt_price(ts.entry_price)}`",
|
||||
f"🛑 SL `{_fmt_price(ts.stop_loss)}` (${risk_usd:.2f} / {ts.risk_pips:.0f}p = 1R)",
|
||||
]
|
||||
# Lot size for the owner's risk limit and the alerted plans still
|
||||
# open in this direction (exposure.attach_exposure stamps both
|
||||
# post-hoc; empty unless configured / unless something is open).
|
||||
# The size is private: the channel copy carries it only when
|
||||
# config.json says so.
|
||||
if report.size_line and (not channel or size_shared("telegram_channel")):
|
||||
lines.append(f"📏 {_escape_md(report.size_line)}")
|
||||
if report.open_line:
|
||||
lines.append(f"🧱 {_escape_md(report.open_line)}")
|
||||
|
||||
# Trailing-stop management plan (matches backtested exit model:
|
||||
# TP1 is advisory, the trail is the exit). Params are stamped on
|
||||
@@ -611,7 +651,13 @@ def _run_signal_sync(
|
||||
config = PAIR_PRODUCERS[symbol](timeframe, confirmation_tfs)
|
||||
aggregator = SignalAggregator(config)
|
||||
aggregator.data_provider = _get_shared_dp()
|
||||
return aggregator.analyze_pair(symbol=symbol, log_source="telegram")
|
||||
report = aggregator.analyze_pair(symbol=symbol, log_source="telegram")
|
||||
# Display-only lot-size and "already open" lines (never raises; one
|
||||
# read-only query on signals.db). Stamped here, on the scan executor,
|
||||
# for the manual scans. An ALERT refreshes them just before it is sent
|
||||
# (_deliver_alert): every timeframe scans at the same candle close, so
|
||||
# the line stamped here cannot know about an alert going out beside it.
|
||||
return attach_exposure(report, surface="telegram")
|
||||
|
||||
|
||||
async def run_signal(
|
||||
@@ -761,11 +807,10 @@ def _key_bar_seconds(key: str) -> Optional[int]:
|
||||
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)."""
|
||||
def _bar_label_ts(label: Any) -> Optional[pd.Timestamp]:
|
||||
"""A bar label as a UTC timestamp; None when it cannot be parsed."""
|
||||
try:
|
||||
ts = pd.Timestamp(report.timestamp)
|
||||
ts = pd.Timestamp(label)
|
||||
except Exception:
|
||||
return None
|
||||
if pd.isna(ts):
|
||||
@@ -773,9 +818,45 @@ def _report_bar_ts(report: SignalReport) -> Optional[pd.Timestamp]:
|
||||
return ts.tz_localize("UTC") if ts.tzinfo is None else ts.tz_convert("UTC")
|
||||
|
||||
|
||||
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)."""
|
||||
return _bar_label_ts(report.timestamp)
|
||||
|
||||
|
||||
def _primary_bars_since(key: str, report: SignalReport, last: pd.Timestamp,
|
||||
now_bar: pd.Timestamp) -> Optional[int]:
|
||||
"""How many primary bars lie after `last`, up to and including the
|
||||
report's own bar, in the frame the scan analysed (report._frames):
|
||||
the backtester's position difference `pos - last_entry_pos`. None when
|
||||
the report carries no frame for the key's timeframe or its bar is not
|
||||
in it; the caller then falls back to clock time."""
|
||||
try:
|
||||
tf = Timeframe(key.rsplit("_", 1)[-1])
|
||||
df = (getattr(report, "_frames", None) or {}).get(tf)
|
||||
if df is None or len(df) == 0:
|
||||
return None
|
||||
idx = df.index
|
||||
if getattr(idx, "tz", None) is None:
|
||||
idx = idx.tz_localize("UTC")
|
||||
if now_bar not in idx:
|
||||
return None
|
||||
return int(((idx > last) & (idx <= now_bar)).sum())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
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)."""
|
||||
of its last ALERTED setup's bar (the backtester's entry cooldown).
|
||||
|
||||
Bars are COUNTED in the scan's own primary frame, like the
|
||||
backtester's bar positions. Clock time is not the same thing: across
|
||||
the daily settlement pause or the weekend an hour or two days pass
|
||||
while no bar prints, and a clock rule released the 22:00 UTC bar that
|
||||
no book ever enters (six M15 and three M5 entries in three years,
|
||||
2026-10-01 review). Clock time remains the fallback for a report
|
||||
without frames."""
|
||||
last = _last_alert_bar.get(key)
|
||||
if last is None or report.trade_setup is None:
|
||||
return False
|
||||
@@ -783,8 +864,30 @@ def _inside_alert_cooldown(key: str, report: SignalReport) -> bool:
|
||||
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
|
||||
if now_bar < last:
|
||||
return False
|
||||
bars = _primary_bars_since(key, report, last, now_bar)
|
||||
if bars is not None:
|
||||
return bars < _ALERT_COOLDOWN_BARS
|
||||
return (now_bar - last).total_seconds() < _ALERT_COOLDOWN_BARS * secs
|
||||
|
||||
|
||||
def _restore_alert_cooldowns(bars: Dict[Any, str]) -> int:
|
||||
"""Seed the cooldown memory from signal_log.last_alerted_bars() after a
|
||||
start-up: {(symbol, timeframe): bar label} of the newest alerted row.
|
||||
Without it every restart forgot the last alert and the next bar could
|
||||
alert again inside the 5 bars. Only the cooldown is restored; the
|
||||
dedup fingerprints start empty, as before. A key already in memory is
|
||||
left alone. Returns how many keys were seeded."""
|
||||
restored = 0
|
||||
for (symbol, timeframe), label in (bars or {}).items():
|
||||
bar = _bar_label_ts(label)
|
||||
key = f"{symbol}_{timeframe}"
|
||||
if bar is None or key in _last_alert_bar:
|
||||
continue
|
||||
_last_alert_bar[key] = bar
|
||||
restored += 1
|
||||
return restored
|
||||
|
||||
|
||||
|
||||
@@ -844,6 +947,10 @@ def _should_alert(key: str, report: SignalReport) -> bool:
|
||||
|
||||
|
||||
def _commit_alert(key: str, report: SignalReport) -> None:
|
||||
# In-memory dedup state only. The signal-log stamp of the alert is
|
||||
# written (and, for an undelivered alert, undone) by _deliver_alert on
|
||||
# the alert-db worker: a database write does not belong on the event
|
||||
# loop this function runs on.
|
||||
_last_alert_fp[key] = _alert_fingerprint(report)
|
||||
_last_alert_ts[key] = time.time()
|
||||
_last_alert_quality[key] = report.signal_quality
|
||||
@@ -898,17 +1005,123 @@ async def _send_alert(bot, chat_id: int, text: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def _broadcast(bot, text: str) -> int:
|
||||
def _is_owner_chat(chat_id: Any) -> bool:
|
||||
"""True for the owner's own chat with the bot, the one audience of the
|
||||
private lot-size line. The owner lock is per USER, so /autoscan or a
|
||||
scan button used inside a group makes that group a target: it is not
|
||||
the owner's chat and gets the copy without the size."""
|
||||
return OWNER_ID is not None and chat_id == OWNER_ID
|
||||
|
||||
|
||||
async def _broadcast(bot, text: str, channel_text: Optional[str] = None) -> 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."""
|
||||
drift between them. `channel_text`, when given, is what every target
|
||||
except the owner's own chat receives instead (the copy without the
|
||||
owner's private lot-size line)."""
|
||||
delivered = 0
|
||||
for chat_id in _signal_targets():
|
||||
if await _send_alert(bot, chat_id, text):
|
||||
body = (text if channel_text is None or _is_owner_chat(chat_id)
|
||||
else channel_text)
|
||||
if await _send_alert(bot, chat_id, body):
|
||||
delivered += 1
|
||||
return delivered
|
||||
|
||||
|
||||
def _stamp_exposure_and_mark(report: SignalReport) -> Optional[str]:
|
||||
"""On the alert-db worker: refresh the lot-size and "already open"
|
||||
lines, then stamp the report's signal-log row as alerted. Read first,
|
||||
write second, nothing in between: the next alert on the worker sees
|
||||
this stamp. Returns the stamp to undo if no chat receives the alert
|
||||
(None when nothing was written). Never raises."""
|
||||
attach_exposure(report, surface="telegram")
|
||||
return mark_alerted(report)
|
||||
|
||||
|
||||
def _undo_cancelled_stamp(report: SignalReport, done: Any) -> None:
|
||||
"""Done-callback of the stamp job when the alert's task was cancelled
|
||||
while that job was queued or running (a shutdown, or auto-scan switched
|
||||
off, at the wrong moment). Cancelling a task cannot stop a job the
|
||||
worker has already started, so its stamp lands although nothing will be
|
||||
sent: clear it. Runs on the alert-db worker right after the job, also
|
||||
when the pool has already been told to shut down. Never raises."""
|
||||
try:
|
||||
if done.cancelled():
|
||||
return # never ran: nothing was written
|
||||
stamp = done.result()
|
||||
if stamp:
|
||||
unmark_alerted(report, stamp)
|
||||
except Exception:
|
||||
logger.warning("alert: could not clear the stamp of a cancelled "
|
||||
"alert", exc_info=True)
|
||||
|
||||
|
||||
async def _undo_alert_mark(report: SignalReport, stamp: Optional[str]) -> None:
|
||||
"""The alert reached no chat: clear the stamp this attempt wrote (and
|
||||
only that one), so the row does not count as a plan the reader holds.
|
||||
Never raises."""
|
||||
if not stamp:
|
||||
return
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
_alert_db_executor, unmark_alerted, report, stamp)
|
||||
except Exception:
|
||||
logger.warning("alert: could not clear the alerted stamp of an "
|
||||
"undelivered alert", exc_info=True)
|
||||
|
||||
|
||||
async def _deliver_alert(bot, key: str, report: SignalReport, tf_label: str,
|
||||
header: str = "", kind: str = "alert") -> int:
|
||||
"""Send one alert: panel → exposure lines + alerted stamp → both
|
||||
copies → commit. The ONE delivery path of the candle scan and the
|
||||
level break; the caller holds _alert_lock(key) and has passed
|
||||
_should_alert. Returns how many chats received it.
|
||||
|
||||
The row is stamped BEFORE the send so that an alert going out at the
|
||||
same candle close on another timeframe already counts this plan in its
|
||||
"already open" line (2026-10-01: stamped after the send, two alerts at
|
||||
one boundary never counted each other). A send that reaches no chat
|
||||
undoes the stamp and commits nothing, so the setup stays eligible for
|
||||
the next scan; so does a cancellation before the send has started.
|
||||
Residue: if this send fails completely after another alert has already
|
||||
counted it, that alert's line is one too high."""
|
||||
loop = asyncio.get_running_loop()
|
||||
# Panel AFTER the quality gate + dedup decision so no LLM spend on
|
||||
# suppressed alerts. Runs on the scan executor to keep the event loop
|
||||
# free; this key's alert lock is held for the panel's wall time
|
||||
# (≤~25 s), which only delays a same-key duplicate that _should_alert
|
||||
# would drop anyway.
|
||||
await loop.run_in_executor(_executor, attach_panel, report)
|
||||
# submit(), not run_in_executor(): the job's own future is kept so that
|
||||
# a cancellation HERE (nothing has been sent yet) can still undo the
|
||||
# stamp the job writes.
|
||||
job = _alert_db_executor.submit(_stamp_exposure_and_mark, report)
|
||||
try:
|
||||
stamp = await asyncio.wrap_future(job, loop=loop)
|
||||
except asyncio.CancelledError:
|
||||
job.add_done_callback(
|
||||
functools.partial(_undo_cancelled_stamp, report))
|
||||
raise
|
||||
delivered = 0
|
||||
try:
|
||||
detail = header + format_signal_message(report)
|
||||
delivered = await _broadcast(
|
||||
bot, detail, header + format_signal_message(report, channel=True))
|
||||
except Exception:
|
||||
# (A cancellation is not an Exception: on shutdown mid-send a chat
|
||||
# may already hold the message, so the stamp stays.)
|
||||
await _undo_alert_mark(report, stamp)
|
||||
raise
|
||||
if not delivered:
|
||||
await _undo_alert_mark(report, stamp)
|
||||
return 0
|
||||
# Commit only when the alert reached at least one target; a fully
|
||||
# failed send stays eligible for the next scan.
|
||||
_commit_alert(key, report)
|
||||
logger.info(_alert_sent_line(report, tf_label, delivered, kind=kind))
|
||||
return delivered
|
||||
|
||||
|
||||
# ── Candle-boundary scheduler ────────────────────────────────────────
|
||||
|
||||
_autoscan_chats: Set[int] = set()
|
||||
@@ -1062,20 +1275,7 @@ async def _scan_timeframe(
|
||||
async with _alert_lock(key):
|
||||
if not _should_alert(key, report):
|
||||
continue
|
||||
# Panel AFTER the quality gate + dedup decision so no LLM
|
||||
# spend on suppressed alerts. Runs on the scan executor to
|
||||
# keep the event loop free; this key's alert lock is held
|
||||
# for the panel's wall time (≤~25 s), which only delays a
|
||||
# same-key duplicate that _should_alert would drop anyway.
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
_executor, attach_panel, report)
|
||||
detail = format_signal_message(report)
|
||||
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))
|
||||
await _deliver_alert(bot, key, report, tf.value)
|
||||
|
||||
|
||||
# ── WebSocket level-breach monitor ──────────────────────────────────
|
||||
@@ -1222,20 +1422,16 @@ async def _handle_breach(bot, pair_symbol: str, price: float, level_name: str) -
|
||||
if not _should_alert(key, report):
|
||||
return
|
||||
|
||||
# Same post-gate panel attach as the auto-scan path (see
|
||||
# _scan_timeframe for the spend/lock rationale).
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
_executor, attach_panel, report)
|
||||
# The same delivery as the auto-scan path (panel, the refreshed
|
||||
# "already open" line, stamp, send, commit), under the level-break
|
||||
# header. The cached M15 report this path reuses can be up to a
|
||||
# bar old; _deliver_alert refreshes its exposure lines.
|
||||
header = (
|
||||
f"⚡ *LEVEL BREAK* — {_escape_md(display)} crossed "
|
||||
f"*{_escape_md(label)}* @ `{_fmt_price(price)}`\n\n"
|
||||
)
|
||||
detail = header + format_signal_message(report)
|
||||
sent = await _broadcast(bot, detail)
|
||||
if sent:
|
||||
_commit_alert(key, report)
|
||||
logger.info(_alert_sent_line(report, Timeframe.M15.value, sent,
|
||||
kind="breach alert"))
|
||||
await _deliver_alert(bot, key, report, Timeframe.M15.value,
|
||||
header=header, kind="breach alert")
|
||||
|
||||
|
||||
def _breach_task_done(task: asyncio.Task) -> None:
|
||||
@@ -1517,7 +1713,9 @@ def _news_health_lines() -> List[str]:
|
||||
Reads the class-level `NewsEngine._source_health` written by the
|
||||
scan-path fetches plus the LLM client's health stamps — display
|
||||
only, never triggers a fetch. ⚠️ marks a degraded dependency;
|
||||
details live in the rotating log (transition-logged WARNINGs)."""
|
||||
details live in the rotating log (transition-logged WARNINGs).
|
||||
Failure details are exception text, so they are Markdown-escaped: an
|
||||
unpaired underscore in one would make Telegram reject the reply."""
|
||||
lines = ["\n*News & AI:*"]
|
||||
# Snapshot: a scan thread may insert a source's FIRST entry while we
|
||||
# iterate (dict-changed-size RuntimeError); entry mutation is safe.
|
||||
@@ -1536,11 +1734,13 @@ def _news_health_lines() -> List[str]:
|
||||
ff = sh.get("calendar:forexfactory")
|
||||
if cal:
|
||||
if NewsEngine.source_is_failing(cal):
|
||||
lines.append(f" ⚠️ Calendar: unavailable — {cal['detail']}"
|
||||
lines.append(" ⚠️ Calendar: unavailable — "
|
||||
f"{_escape_md(str(cal['detail']))}"
|
||||
f" ({_fmt_age(cal['err'])})")
|
||||
elif NewsEngine.source_is_failing(ff):
|
||||
lines.append(f" ⚠️ Calendar: {cal['extra']} — ForexFactory"
|
||||
f" down: {ff['detail']} ({_fmt_age(ff['err'])})")
|
||||
f" down: {_escape_md(str(ff['detail']))}"
|
||||
f" ({_fmt_age(ff['err'])})")
|
||||
else:
|
||||
lines.append(f" ✅ Calendar: {cal['extra']}"
|
||||
f" ({_fmt_age(cal['ok'])} ago)")
|
||||
@@ -1548,13 +1748,27 @@ def _news_health_lines() -> List[str]:
|
||||
if heads:
|
||||
failing = NewsEngine.source_is_failing(heads)
|
||||
mark = "⚠️" if failing else "✅"
|
||||
detail = f" — {heads['detail']}" if failing else ""
|
||||
detail = (f" — {_escape_md(str(heads['detail']))}"
|
||||
if failing else "")
|
||||
ts = heads["err"] if failing else heads["ok"]
|
||||
lines.append(f" {mark} Headlines: {heads['extra']}{detail}"
|
||||
f" ({_fmt_age(ts)} ago)")
|
||||
# The local headline scorer (news_model modes fallback / primary).
|
||||
# Present only in a process that ran it; a failing batch leaves the
|
||||
# cycle's headlines on keyword scores, which nothing else showed.
|
||||
model = sh.get("news_model")
|
||||
if model:
|
||||
if NewsEngine.source_is_failing(model):
|
||||
lines.append(" ⚠️ News model: not scoring — "
|
||||
f"{_escape_md(str(model['detail']))}"
|
||||
f" ({_fmt_age(model['err'])})")
|
||||
else:
|
||||
lines.append(f" ✅ News model: {model['extra']}"
|
||||
f" ({_fmt_age(model['ok'])} ago)")
|
||||
for name, entry in sorted(sh.items()):
|
||||
if name.startswith("rss:") and NewsEngine.source_is_failing(entry):
|
||||
lines.append(f" ⚠️ {name[4:]}: {entry['detail']}"
|
||||
lines.append(f" ⚠️ {_escape_md(name[4:])}: "
|
||||
f"{_escape_md(str(entry['detail']))}"
|
||||
f" ({_fmt_age(entry['err'])})")
|
||||
client = get_llm_client()
|
||||
if not client.available:
|
||||
@@ -1786,7 +2000,12 @@ async def cb_scan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
|
||||
try:
|
||||
report = await run_signal(btn["symbol"], btn["tf"])
|
||||
text = format_signal_message(report)
|
||||
# The reply lands in the chat the button was pressed in. Only the
|
||||
# owner's own chat gets the private lot-size line; the owner
|
||||
# pressing it inside a group must not show the group their risk.
|
||||
chat = update.effective_chat
|
||||
text = format_signal_message(
|
||||
report, channel=not _is_owner_chat(chat.id if chat else None))
|
||||
except Exception as exc:
|
||||
logger.exception("Signal scan failed")
|
||||
text = f"❌ Scan failed: {_escape_md(str(exc))}"
|
||||
@@ -1914,6 +2133,18 @@ async def _post_init(app: Application) -> None:
|
||||
if CHAT_ID:
|
||||
_autoscan_chats.add(int(CHAT_ID))
|
||||
|
||||
# The 5-bar entry cooldown lives in memory; seed it from the signal log
|
||||
# so a restart (a deploy, the weekly timer) cannot re-alert inside it.
|
||||
# Before the feed check: /autoscan can start the loops later.
|
||||
try:
|
||||
bars = await asyncio.get_running_loop().run_in_executor(
|
||||
_alert_db_executor, last_alerted_bars)
|
||||
restored = _restore_alert_cooldowns(bars)
|
||||
logger.info(f"Alert cooldown restored from the signal log: "
|
||||
f"{restored} timeframe(s)")
|
||||
except Exception:
|
||||
logger.exception("Alert cooldown restore failed — starting without it")
|
||||
|
||||
if not _feed_active():
|
||||
logger.info("Neither TELEGRAM_CHAT_ID nor TELEGRAM_CHANNEL_ID set — "
|
||||
"auto-scan disabled on boot. Use /autoscan in chat to enable.")
|
||||
@@ -1980,6 +2211,7 @@ async def _post_shutdown(app: Application) -> None:
|
||||
logger.info("Shutting down ForexBot2...")
|
||||
_stop_scheduler()
|
||||
_executor.shutdown(wait=False)
|
||||
_alert_db_executor.shutdown(wait=False)
|
||||
logger.info("Shutdown complete")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user