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).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Harwinder Sekhon
2026-10-01 10:28:28 -04:00
parent 0c56b4e155
commit 1793d9167a
31 changed files with 6021 additions and 286 deletions
+275 -43
View File
@@ -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")