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:
+60
-4
@@ -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…")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user