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:
+127
-16
@@ -104,6 +104,7 @@ from advisory_outcome import (
|
||||
canonical_r,
|
||||
initial_outcome,
|
||||
normalize_timeframe,
|
||||
resolve_locked_floor,
|
||||
timeframe_value,
|
||||
)
|
||||
# Private on purpose — the scan-outcome labeller must apply EXACTLY the
|
||||
@@ -258,6 +259,7 @@ _GATE_PREFIXES = (
|
||||
"SESSION FILTER",
|
||||
"Primary timeframe stale",
|
||||
"Primary timeframe data unavailable",
|
||||
"DATA UNAVAILABLE:",
|
||||
"REGIME FILTER",
|
||||
"EXHAUSTION BLOCK",
|
||||
"EXHAUSTED TREND FILTER",
|
||||
@@ -278,6 +280,10 @@ SCAN_OUTCOME_WINDOW_H = 24
|
||||
# A 24 h window can only be judged once it has elapsed; the extra hour
|
||||
# leaves room for the last bar of the window to be stored.
|
||||
SCAN_LABEL_MIN_AGE_H = 25
|
||||
# An incomplete row older than this is FINAL: its window had a real hole
|
||||
# (a feed outage), which no later bar can fill. It is neither re-examined
|
||||
# nor allowed to anchor the shared bar stream for the rest of the batch.
|
||||
SCAN_LABEL_MAX_AGE_H = 72
|
||||
# Missing bars tolerated inside a window ON TOP of the scheduled closures
|
||||
# advisory_outcome._has_continuity already allows.
|
||||
SCAN_LABEL_MAX_GAP_BARS = 3
|
||||
@@ -399,6 +405,20 @@ def setup_key(setup: Optional[Dict[str, Any]], timeframe: Any) -> Optional[str]:
|
||||
))
|
||||
|
||||
|
||||
def _is_gap_reason(reason: Any) -> bool:
|
||||
"""True when an outcome was left UNRESOLVABLE by a candle gap.
|
||||
|
||||
advance_outcome writes TWO such reasons — "stored candle snapshots
|
||||
lost continuity" (no overlap with the last processed bar) and "stored
|
||||
candle snapshots contain an internal gap" — and each call site used to
|
||||
match only one of them, so the stream rebuild and the locked-floor
|
||||
rescue skipped half the gap rows. The substring clauses keep states
|
||||
written by older code inside the same net."""
|
||||
text = str(reason or "")
|
||||
return (text.startswith("stored candle snapshots")
|
||||
or "continuity" in text or "gap" in text)
|
||||
|
||||
|
||||
def gate_reason(
|
||||
warnings: Iterable[str],
|
||||
cleaned_warnings: Iterable[str],
|
||||
@@ -1150,12 +1170,19 @@ class SignalLogger:
|
||||
before = _dumps(json.loads(hit[0]))
|
||||
advanced = advance_outcome(json.loads(before), bars)
|
||||
if (advanced.get("status") == "UNRESOLVABLE"
|
||||
and "continuity" in str(advanced.get("reason", ""))):
|
||||
and _is_gap_reason(advanced.get("reason"))):
|
||||
union = self._stream_since(
|
||||
symbol, timeframe,
|
||||
json.loads(before)["progress"]["last_processed_bar"])
|
||||
if union:
|
||||
advanced = advance_outcome(json.loads(before), union)
|
||||
# The database could not fill the hole. A trade
|
||||
# whose trail had already locked profit still has a
|
||||
# real answer — its floor — so record that instead
|
||||
# of dropping the row out of the scorecard.
|
||||
if (advanced.get("status") == "UNRESOLVABLE"
|
||||
and _is_gap_reason(advanced.get("reason"))):
|
||||
advanced = resolve_locked_floor(advanced)
|
||||
after = _dumps(advanced)
|
||||
if after == before:
|
||||
self._conn.rollback()
|
||||
@@ -1194,6 +1221,11 @@ class SignalLogger:
|
||||
" WHERE symbol=? AND candles_json IS NOT NULL"
|
||||
" AND logged_at >= ? ORDER BY id LIMIT ?",
|
||||
(f'$."{key}"', symbol, since, int(max_rows))).fetchall()
|
||||
if len(rows) == int(max_rows):
|
||||
logger.warning(
|
||||
"signal_log: bar stream truncated for %s %s (key %s)"
|
||||
" since %s — hit the %s-row cap, later bars are missing",
|
||||
symbol, tf, key, since, int(max_rows))
|
||||
for (blob,) in rows:
|
||||
if not blob:
|
||||
continue
|
||||
@@ -1212,42 +1244,82 @@ class SignalLogger:
|
||||
|
||||
Candidates are rows that carry indicator snapshots (a real scan)
|
||||
and a price, whose signal bar closed more than 25 h ago and which
|
||||
have no COMPLETE label yet; the least recently labelled go first,
|
||||
so a permanently short stream can never starve the rest. Returns
|
||||
the number of rows actually WRITTEN — a row whose numbers did not
|
||||
change is skipped, which makes repeated passes idempotent and lets
|
||||
a batch driver stop at 0.
|
||||
have no COMPLETE label yet. An incomplete row is re-examined only
|
||||
while it is younger than SCAN_LABEL_MAX_AGE_H (counted from the
|
||||
newest logged row when the database is behind the clock) — beyond
|
||||
that its window had a real hole that no later bar can fill, so the
|
||||
label it already carries is final — and a stale row never anchors
|
||||
the shared bar stream either (a key that has nothing else due
|
||||
starts at the age bound plus one window). Both bounds are
|
||||
load-bearing: a handful of permanently incomplete rows (a feed
|
||||
outage) sort to the front of every pass, and anchoring the stream
|
||||
at their bar pushes its row cap past today, which silently labels
|
||||
every genuinely due row NULL and incomplete — the scan_outcomes
|
||||
table then goes blind. Returns the number of rows actually WRITTEN
|
||||
— a row whose numbers did not change is not rewritten (only its
|
||||
labeled_at is bumped, so it rotates to the back of the queue),
|
||||
which makes repeated passes idempotent and lets a batch driver
|
||||
stop at 0.
|
||||
|
||||
Bounded on purpose: at most ``budget`` candidates are examined and
|
||||
the pass stops after ``time_budget`` seconds (default 2 s) so the
|
||||
opportunistic call inside log() stays cheap. Per-row failures are
|
||||
logged and skipped."""
|
||||
moment = now or datetime.now(timezone.utc)
|
||||
cutoff = _utc(moment) - timedelta(hours=SCAN_LABEL_MIN_AGE_H)
|
||||
moment_utc = _utc(moment)
|
||||
cutoff = moment_utc - timedelta(hours=SCAN_LABEL_MIN_AGE_H)
|
||||
deadline = time.monotonic() + float(
|
||||
SCAN_LABEL_TIME_BUDGET if time_budget is None else time_budget)
|
||||
written = 0
|
||||
with self._lock:
|
||||
# Age is measured from the newest row when the database itself
|
||||
# is behind the clock (an imported snapshot on the dev box),
|
||||
# otherwise every row of a 3-day-old import would be declared
|
||||
# final on the first pass. On a live logger the two coincide.
|
||||
horizon = moment_utc
|
||||
newest = self._conn.execute(
|
||||
"SELECT logged_at FROM signals ORDER BY id DESC"
|
||||
" LIMIT 1").fetchone()
|
||||
try:
|
||||
horizon = min(horizon, _utc(datetime.fromisoformat(
|
||||
str(newest[0]).replace(" ", "T"))))
|
||||
except (TypeError, ValueError, IndexError):
|
||||
pass
|
||||
stale_before = horizon - timedelta(hours=SCAN_LABEL_MAX_AGE_H)
|
||||
# Deepest a stream may start when every row due for that key is
|
||||
# stale: one window before the age bound, so such a row still
|
||||
# gets its single final pass without dragging the stream back.
|
||||
anchor_floor = stale_before - timedelta(hours=SCAN_LABEL_MIN_AGE_H)
|
||||
# Row ids are monotonic in logged_at (a uniform ISO string), so
|
||||
# an id bound is the cheap, index-friendly way to leave stale
|
||||
# incomplete rows out of the re-examination set. bar_ts mixes
|
||||
# separators and must never be compared as a string.
|
||||
min_id = self._conn.execute(
|
||||
"SELECT MIN(id) FROM signals WHERE logged_at >= ?",
|
||||
(stale_before.isoformat(),)).fetchone()[0]
|
||||
if min_id is None:
|
||||
min_id = 2 ** 62 # nothing recent: re-examine none
|
||||
rows = self._conn.execute(
|
||||
"SELECT s.id, s.symbol, s.timeframe, s.price,"
|
||||
" COALESCE(s.bar_ts, json_extract(s.indicators_json,"
|
||||
" '$.\"' || s.timeframe || '\".timestamp')) AS bar,"
|
||||
" json_extract(s.indicators_json,"
|
||||
" '$.\"' || s.timeframe || '\".key_levels.atr') AS atr"
|
||||
" '$.\"' || s.timeframe || '\".key_levels.atr') AS atr,"
|
||||
" (o.signal_id IS NULL) AS unlabelled"
|
||||
" FROM signals s"
|
||||
" LEFT JOIN scan_outcomes o ON o.signal_id = s.id"
|
||||
" WHERE s.indicators_json IS NOT NULL AND s.price > 0"
|
||||
" AND (o.signal_id IS NULL OR o.complete = 0)"
|
||||
" AND (o.signal_id IS NULL OR (o.complete = 0 AND s.id >= ?))"
|
||||
" AND bar IS NOT NULL"
|
||||
" ORDER BY COALESCE(o.labeled_at, '') ASC, s.id ASC"
|
||||
" LIMIT ?", (int(budget),)).fetchall()
|
||||
" LIMIT ?", (int(min_id), int(budget))).fetchall()
|
||||
# One bar stream per (symbol, timeframe) for the whole batch,
|
||||
# built from the OLDEST bar in it — _stream_since is a scan
|
||||
# over every row logged since that bar, far too expensive to
|
||||
# repeat per candidate.
|
||||
due: List[Tuple[Any, ...]] = []
|
||||
oldest: Dict[Tuple[str, str], Tuple[datetime, str]] = {}
|
||||
for rowid, symbol, tf, price, bar, atr in rows:
|
||||
for rowid, symbol, tf, price, bar, atr, unlabelled in rows:
|
||||
try:
|
||||
stamp = _utc(datetime.fromisoformat(
|
||||
str(bar).replace(" ", "T")))
|
||||
@@ -1255,8 +1327,16 @@ class SignalLogger:
|
||||
continue
|
||||
if stamp > cutoff:
|
||||
continue # window has not elapsed yet
|
||||
# Stale = old by the bar it closed on AND by when it was
|
||||
# written (on a live database the two agree within minutes;
|
||||
# requiring both keeps a freshly imported or backfilled DB,
|
||||
# whose rows all carry today's logged_at, out of it).
|
||||
stale = stamp < stale_before and rowid < min_id
|
||||
if stale and not unlabelled:
|
||||
continue # final: a hole no later bar can ever fill
|
||||
key = (str(symbol), str(tf))
|
||||
if key not in oldest or stamp < oldest[key][0]:
|
||||
if not stale and (key not in oldest
|
||||
or stamp < oldest[key][0]):
|
||||
oldest[key] = (stamp, str(bar))
|
||||
due.append((rowid, symbol, tf, price, bar, atr, key))
|
||||
streams: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
|
||||
@@ -1265,8 +1345,13 @@ class SignalLogger:
|
||||
break
|
||||
try:
|
||||
if key not in streams:
|
||||
# 3 days of anchor is ~2,100 rows on the live DB;
|
||||
# the cap is a safety net, not the working limit.
|
||||
anchor = oldest.get(key)
|
||||
streams[key] = self._stream_since(
|
||||
symbol, tf, oldest[key][1])
|
||||
symbol, tf,
|
||||
anchor[1] if anchor else anchor_floor.isoformat(),
|
||||
max_rows=10000)
|
||||
label = scan_outcome(bar, tf, streams[key],
|
||||
_finite(atr), price)
|
||||
if label is None:
|
||||
@@ -1277,6 +1362,14 @@ class SignalLogger:
|
||||
+ " FROM scan_outcomes WHERE signal_id=?",
|
||||
(rowid,)).fetchone()
|
||||
if prior is not None and tuple(prior) == values:
|
||||
# Unchanged, so NOT counted as written — but its
|
||||
# queue position moves, or the same short-stream
|
||||
# rows would head every pass for ever.
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
self._conn.execute(
|
||||
"UPDATE scan_outcomes SET labeled_at=?"
|
||||
" WHERE signal_id=?", (now_iso, rowid))
|
||||
self._conn.commit()
|
||||
continue
|
||||
self._conn.execute(
|
||||
_SCAN_OUTCOME_INSERT,
|
||||
@@ -1376,6 +1469,9 @@ class SignalLogger:
|
||||
continue
|
||||
before = _dumps(state)
|
||||
advanced = advance_outcome(json.loads(before), union)
|
||||
if (advanced.get("status") == "UNRESOLVABLE"
|
||||
and _is_gap_reason(advanced.get("reason"))):
|
||||
advanced = resolve_locked_floor(advanced)
|
||||
after = _dumps(advanced)
|
||||
if after == before:
|
||||
continue
|
||||
@@ -1393,7 +1489,9 @@ class SignalLogger:
|
||||
"""Bounded retry for continuity-broken outcomes: re-freeze the
|
||||
seed from the row's own setup/indicators (at most ``max_reseeds``
|
||||
times per row) so the next resolver pass can walk the now-filled
|
||||
stream. Invalid-seed rows stay terminal."""
|
||||
stream. Invalid-seed rows stay terminal. A gap row that had already
|
||||
trailed its stop past the entry is finished at that locked floor
|
||||
instead of re-seeded; the returned list holds re-seeded ids only."""
|
||||
reseeded: List[int] = []
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
@@ -1403,8 +1501,21 @@ class SignalLogger:
|
||||
" AND advisory_event_id = id").fetchall()
|
||||
for rowid, tf, setup_json, ind_json, state_json in rows:
|
||||
old = json.loads(state_json) if state_json else {}
|
||||
if "continuity" not in str(old.get("reason", "")) and \
|
||||
"gap" not in str(old.get("reason", "")):
|
||||
if not _is_gap_reason(old.get("reason")):
|
||||
continue
|
||||
# The locked trailing stop lives ONLY in this progress
|
||||
# block, and a re-seed rebuilds the walk from scratch over
|
||||
# a stream whose hole is still there (the continuity check
|
||||
# is all-or-nothing), so the floor would be lost for good.
|
||||
# Finish such a row here; re-seed only what has nothing.
|
||||
floored = resolve_locked_floor(json.loads(_dumps(old)))
|
||||
if floored.get("status") == "RESOLVED":
|
||||
self._conn.execute(
|
||||
"UPDATE signals SET advisory_outcome_status=?,"
|
||||
" advisory_r_multiple=?, advisory_outcome_json=?"
|
||||
" WHERE id=?",
|
||||
("RESOLVED", canonical_r(floored),
|
||||
_dumps(floored), rowid))
|
||||
continue
|
||||
count = int((old.get("reseed") or {}).get("count", 0))
|
||||
if count >= max_reseeds or not setup_json:
|
||||
|
||||
Reference in New Issue
Block a user