audit 2026-09-13: eleven delivery/measurement fixes; books byte-identical

Signal path
- A shipped setup always names a direction: grade C needs confidence
  >= 45 (gate 6c grants B at >= 45) while BUY/SELL needs >= 50, so C/B
  reports reached the alert gate as NEUTRAL with a full setup. The
  backtester traded them (the replay never reads final_signal) while
  Telegram rejected NEUTRAL first — 5/91 trades, +3.7R of +22.2R, in
  the H1 12-month book. _attach_core_setup stamps the setup's direction
  and repairs the pre-gate action text; no-setup reports stay NEUTRAL.
- analyze_pair fails the scan when ANY required frame is missing
  ("DATA UNAVAILABLE:", scan_kind='failed'); a lost confirmation frame
  used to run with an unearned -15 MTF penalty.

Outcomes and labels
- advisory_outcome.resolve_locked_floor: a gap the database cannot fill
  resolves at the locked trailing stop (GAP_LOCKED, result.floor) when
  the stop had crossed the entry; the rebuild fires on both gap reasons
  (rows 915/920: +1.14R / +0.84R floors; resolved sum -2.46R -> -0.48R).
- label_scan_outcomes: SCAN_LABEL_MAX_AGE_H = 72 h, stale rows never
  anchor the shared bar stream, labeled_at bumped on re-examination,
  _stream_since warns at its row cap (the Labor-Day pile would have
  blinded scan_outcomes around 2026-09-14).

Providers and scheduling
- The daily settlement pause (Mon-Thu 17:00 New York) is a scheduled
  closure for _has_latest_closed_bar (whole-span label walk-back) and
  _is_data_fresh (closed seconds are not age); bot and dashboard skip
  scans whose just-closed label sits in the pause (61 false stale rows
  in three nights).
- fetch_range retries a failed page in place (three failures on one
  window raise) and logs an ERROR for interior gaps of >3 unscheduled
  bars.
- pd.Timestamp.utcnow() replaced in providers/base.py and engine.py;
  requirements pin pandas<4, numpy<3.
- telegram_bot: boundary loops share one scan per bar with the breach
  handler (_remember_scan keys on the analysed bar; breach defers 20 s
  after an M15 boundary); the rotating file handler attaches in main()
  only (the test suite used to write into logs/telegram.log).
- dashboard_publisher: catch_up_timeframes re-scans boundaries missed
  while a cycle overran or the process was down.

Backtester and engine guards
- _replay_span returns the per-span GPU cache-miss count; run_backtest
  sums, logs it (must be 0) and writes cache_fallbacks to the manifest.
- regime_weight_table raises on a partial regime_weights_override.

Verification: 519 tests; M5/M15 2-yr, M15 OOS, H1/H4 3-yr books
byte-identical before and after; live signals.db untouched (checked on
a copy). CLAUDE.md carries the dated entry and the blocked-hours union
semantics that every book was validated under.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139xz1J6YccndA2be6yFnEo
This commit is contained in:
Harwinder Sekhon
2026-09-13 21:42:37 -04:00
parent 1c293f7d2e
commit 2836024e9d
12 changed files with 1602 additions and 70 deletions
+60 -4
View File
@@ -49,9 +49,10 @@ import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set
import pandas as pd
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ParseMode
from telegram.error import BadRequest
@@ -71,6 +72,7 @@ from app_config import (
telegram_channel_id, violent_watch_enabled,
)
from providers import BaseDataProvider, make_data_provider
from providers.base import _is_daily_maintenance
from agent_panel import attach_panel, panel_health
from llm_client import get_llm_client
from market_outlook import maybe_run_outlook
@@ -110,8 +112,12 @@ _file_handler = logging.handlers.TimedRotatingFileHandler(
when="midnight", backupCount=30, encoding="utf-8",
)
_file_handler.setFormatter(_log_formatter)
# Console only at IMPORT: `python test_all.py` imports this module, and an
# import-time file handler wrote fixture alerts (2027 dates, 3300.00 prices)
# into logs/telegram.log on whatever box ran the suite. main() attaches
# _file_handler for real bot runs.
logging.basicConfig(level=getattr(logging, _APP.log_level, logging.INFO),
handlers=[_console_handler, _file_handler],
handlers=[_console_handler],
force=True)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
@@ -545,7 +551,21 @@ def _bar_epoch(tf: Timeframe, now: Optional[float] = None) -> int:
def _remember_scan(symbol: str, tf: Timeframe, report: SignalReport) -> None:
_last_scan[f"{symbol}_{tf.value}"] = (_bar_epoch(tf), report)
# File the report under the bar it actually ANALYSED, not the wall
# clock at completion: a scan that straddles a boundary would land
# under the new bar, so the boundary loop's own scan of that bar
# found no cache entry and re-fetched/re-scored/re-logged the same
# closed bar (15 duplicate M15 rows in five days — 2026-09-13 audit).
epoch = _bar_epoch(tf)
if report.primary_analysis is not None:
try:
# report.timestamp is the primary bar's OPEN label; the bar
# closes (= the boundary this scan belongs to) one TF later.
epoch = (int(pd.Timestamp(report.timestamp).timestamp())
+ _TF_SECONDS_CACHE.get(tf, 900))
except (ValueError, TypeError):
pass # unparseable label → wall-clock boundary, as before
_last_scan[f"{symbol}_{tf.value}"] = (epoch, report)
async def _cached_or_scan(
@@ -767,6 +787,16 @@ def _seconds_until_boundary(interval_minutes: int, buffer: int = 10) -> float:
return remaining
def _closed_bar_in_settlement(interval_minutes: int,
now: Optional[datetime] = None) -> bool:
"""True when the bar that just closed (label = now - interval) falls in
gold's daily settlement pause — no such bar exists, so a scan would only
re-read the previous bar (61 stale rows in the 2026-09 audit DB)."""
label_time = ((now or datetime.now(timezone.utc))
- timedelta(minutes=interval_minutes))
return _is_daily_maintenance(label_time)
# ── Violent-move watch (display-only) ────────────────────────────────
# Per-process heads-up budget: {"day": date, "count": int, "sent": {...}}.
_watch_state: Dict[str, Any] = {}
@@ -800,7 +830,11 @@ async def _scan_timeframe(
confirmation_tfs: List[Timeframe],
) -> None:
"""Scan all pairs for one timeframe and broadcast results."""
tasks = [run_signal(symbol, tf, confirmation_tfs) for symbol in SCAN_PAIRS]
# _cached_or_scan, not run_signal: the WS breach handler may already
# have scanned this closed bar (and vice versa) — one fetch/score/log
# per bar per TF (2026-09-13 audit).
tasks = [_cached_or_scan(symbol, tf, confirmation_tfs)
for symbol in SCAN_PAIRS]
results = await asyncio.gather(*tasks, return_exceptions=True)
reports: List[SignalReport] = []
for i, result in enumerate(results):
@@ -930,6 +964,9 @@ _pending_breach: Dict[str, tuple] = {}
# A failed breach rescan shrinks the just-set cooldown to this retry
# window instead of silencing the symbol for the full cooldown (H33a).
_BREACH_RETRY_SECONDS = 30
# A breach landing inside this many seconds after an M15 boundary waits for
# the candle loop's scan (boundary+10s) and reuses it — 2026-09-13 audit.
_BREACH_DEFER_SECONDS = 20.0
_cfg = PlatformConfig()
_WS_COOLDOWN = _cfg.ws_cooldown_seconds
@@ -1003,6 +1040,13 @@ async def _handle_breach(bot, pair_symbol: str, price: float, level_name: str) -
logger.info(f"Level breach: {display} crossed {label} at {price} — rescanning M15")
try:
# A tick a second after an M15 boundary would scan a bar the feed
# may still be lagging on, and the candle loop (boundary+10s) would
# then scan the same bar again. Wait for the loop and reuse it.
elapsed = time.time() - _bar_epoch(Timeframe.M15)
if elapsed < _BREACH_DEFER_SECONDS:
logger.debug("deferring breach rescan to the boundary scan")
await asyncio.sleep(_BREACH_DEFER_SECONDS - elapsed)
report = await _cached_or_scan(pair_symbol, Timeframe.M15)
_update_level_cache([report])
except Exception:
@@ -1152,6 +1196,11 @@ async def _candle_loop(bot, tf: Timeframe, interval_minutes: int) -> None:
await asyncio.sleep(wait)
if not _feed_active():
break
if _closed_bar_in_settlement(interval_minutes):
logger.info(
f"{label} auto-scan: skipped — bar label inside the "
f"daily settlement pause")
continue
logger.info(f"{label} auto-scan: firing")
try:
await _scan_timeframe(
@@ -1824,6 +1873,13 @@ def main() -> None:
"and telegram.chat_id in config.json, or the TELEGRAM_TOKEN and "
"TELEGRAM_CHAT_ID environment variables.")
# Only a real bot run writes the rotating log file: tests import this
# module (an import-time handler logged their fixtures into
# logs/telegram.log) and one of them calls main() to assert the refusal
# above, so the attach sits after that guard — 2026-09-13 audit.
if _file_handler not in logging.getLogger().handlers:
logging.getLogger().addHandler(_file_handler)
_install_excepthooks()
logger.info("Starting ForexBot2 Telegram bot…")