audit: fix 9 defects from the 2026-09-04 hidden-bug sweep
None of these touch the signal path. The 12-month M15 book (2024-03-31..2025-03-30, $0.65, cached Oanda bars) is byte-identical before and after (md5 c59d3e7e685c3c880b614640adfea74b, 375 trades), and signal/indicator logging to logs/signals.db is unchanged — verified end to end: per-TF indicators for all 3 TFs, 14 candles each, MTF and reasons all still persisted. agent_panel: the >=2-take quorum guards the CHAIR's synthesizer spend, but ran BEFORE the macro branch — so the shipped default (verdict_source="macro", which never calls the synthesizer) silently dropped a good macro verdict whenever the other three personas were slow. Quorum moved below the macro path. analyze_signal_log: _forward_return clamped to the last available bar when the horizon had not elapsed, reporting a near-zero "forward move" for every row logged within `hours` of the end of the data — on every run, blended into the hit-rate/mean-move aggregate. Now returns None. analyze_signal_log: persona_scorecard iterated raw rows through an index that maps EVERY member row of an event, so a re-logged scan appended the same event R twice. One vote per event, matching news_outcome_buckets. Measured inflation on the live DB: 1.03x. signal_log: dedup required a non-negative logged_at gap, but logged_at is stamped before the row wins the write lock (busy_timeout 5 s), so two processes logging one setup at a boundary can invert timestamp order against id order and seed a second event. Now abs(gap); same in backfill_seeds. Zero occurrences in the current 366 setup rows. advisory_outcome: the catch-all turned real evaluator faults into the same UNRESOLVABLE used for legitimate weekend gaps, with nothing in the log. Still fail-safe, now logged. llm_client: only the "cap" warn-once flag was ever reset, so in a daemon the second and every later network/http/parse outage logged at DEBUG only. Cleared on UTC-day roll and re-armed by a recovered call. dashboard.js: renderCards() redraws the selector for every timeframe but was gated on the SELECTED one changing, leaving the other buttons stale (up to 4 h on H4) while renderHero's rail already showed the new value. news_summary.php: atomic_write treated a short write (disk full) as success, publishing a truncated body the cache-first branch then served forever; and the daily cap was charged before the no-key check and before the stampede lock re-checked the cache, so a click burst could exhaust the 150/day budget with zero LLM calls. migrate_signal_log_news: logged_at is ISO with 'T' while SQLite's datetime() uses a space, so string-comparing them put the whole cutoff calendar day on the wrong side and it never pruned. NOTE: this now deletes one day more than before — it only runs when invoked by hand. Tests 414 -> 422 (new Module 28, one per fix). The existing "<2 valid takes" test was retargeted at the chair — the case it actually protects — rather than deleted, with new coverage for macro-alone. Not fixed, recorded for the owner: - Gate 6c's boost condition is textually identical to the C-grade definition, so every C in a "transitioning" regime is promoted to B before gate 6e reads it, making sell_min_quality's exclusion of "C" a no-op on M15/H4 (H1 and M5 are immune by other means). The backtester runs the same code, so every pinned book already includes this — changing it is a rule-1 revalidation event. - Safe-haven/geopolitical headlines score exactly 0.00 in the keyword fallback and are now dropped entirely by the ±0.1 neutral filter, contradicting the platform's own gold doctrine. Bites only when DeepSeek is capped/disabled/failing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ah5EB1goi5inQmSd37Ne6a
This commit is contained in:
@@ -444,6 +444,14 @@ def _forward_return(
|
||||
"""Close-to-close move from `price` to the last close at ts+hours."""
|
||||
target = pd.Timestamp(ts) + pd.Timedelta(hours=hours)
|
||||
closes = df["Close"]
|
||||
# The horizon must have ELAPSED. Without this the searchsorted below
|
||||
# clamps to the last available bar, so every row logged within
|
||||
# `hours` of the end of the data reported the latest close as its
|
||||
# "forward" move — a near-zero reading for a trade that has had no
|
||||
# time to develop, blended into the hit-rate/mean-move aggregate on
|
||||
# every run. Not-yet-known is None, the same as insufficient history.
|
||||
if len(df.index) == 0 or df.index[-1] < target:
|
||||
return None
|
||||
pos = int(df.index.searchsorted(target, side="right")) - 1
|
||||
if pos < 0:
|
||||
return None
|
||||
@@ -753,6 +761,7 @@ def persona_scorecard(rows: Sequence[LogRow], events: Sequence[SignalEvent],
|
||||
by_row = _row_index(events)
|
||||
con = open_readonly(db_path)
|
||||
agg: Dict[Tuple[str, str], List[float]] = {}
|
||||
seen_events: set = set()
|
||||
try:
|
||||
for rid, takes in con.execute(
|
||||
"SELECT id, panel_takes_json FROM signals"
|
||||
@@ -760,6 +769,15 @@ def persona_scorecard(rows: Sequence[LogRow], events: Sequence[SignalEvent],
|
||||
ev = by_row.get(rid)
|
||||
if ev is None or ev.r_headline is None:
|
||||
continue
|
||||
# One vote per EVENT, not per row. A scan re-logged inside the
|
||||
# cluster window carries its own panel takes, and counting
|
||||
# both rows appended the SAME event R twice — inflating n and
|
||||
# the persona means (news_outcome_buckets already iterates
|
||||
# unique events; this is the same rule).
|
||||
ekey = ev.rows[0].id if ev.rows else rid
|
||||
if ekey in seen_events:
|
||||
continue
|
||||
seen_events.add(ekey)
|
||||
try:
|
||||
for t in json.loads(takes):
|
||||
agg.setdefault((t.get("persona", "?"),
|
||||
|
||||
Reference in New Issue
Block a user