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:
+221
-2
@@ -6603,12 +6603,12 @@ def _():
|
||||
assert v("risk", {"stance": "neutral", "confidence": -3, "note": ""}).confidence == 0.0
|
||||
|
||||
|
||||
@test("agent_panel: <2 valid takes → None without spending synthesizer")
|
||||
@test("agent_panel: chair with <2 valid takes → None without spending synthesizer")
|
||||
def _():
|
||||
try:
|
||||
stub = _PanelStub({ap._MACRO_SYSTEM: _TAKE_OK}) # others → None
|
||||
lc._client = stub
|
||||
assert ap.run_panel(_panel_report()) is None
|
||||
assert ap.run_panel(_panel_report(), verdict_source="chair") is None
|
||||
assert ap._SYNTH_SYSTEM not in [s for s, _ in stub.calls], \
|
||||
"synthesizer request must not be spent on a failed panel"
|
||||
assert len(stub.calls) == 4
|
||||
@@ -6617,6 +6617,38 @@ def _():
|
||||
ap.reset_panel_state()
|
||||
|
||||
|
||||
@test("agent_panel: macro mode yields a verdict from the macro take ALONE")
|
||||
def _():
|
||||
# The >=2-take quorum exists to protect the SYNTHESIZER spend. Macro
|
||||
# mode never calls the synthesizer and needs only its own take, so a
|
||||
# slow/failed technical+risk+contrarian must NOT discard a good macro
|
||||
# verdict (the quorum used to run before the macro branch).
|
||||
try:
|
||||
stub = _PanelStub({ap._MACRO_SYSTEM: _TAKE_OK}) # others → None
|
||||
lc._client = stub
|
||||
v = ap.run_panel(_panel_report(), verdict_source="macro")
|
||||
assert v is not None, "macro verdict dropped when alone"
|
||||
assert v.source == "macro" and v.verdict == "AGREE"
|
||||
assert len(v.takes) == 1 and v.takes[0].persona == "macro"
|
||||
assert ap._SYNTH_SYSTEM not in [s for s, _ in stub.calls], \
|
||||
"macro mode must never spend a synthesizer request"
|
||||
finally:
|
||||
lc._client = None
|
||||
ap.reset_panel_state()
|
||||
|
||||
|
||||
@test("agent_panel: zero valid takes → None in both verdict modes")
|
||||
def _():
|
||||
try:
|
||||
for source in ("macro", "chair"):
|
||||
lc._client = _PanelStub({}) # every persona → None
|
||||
assert ap.run_panel(_panel_report(), verdict_source=source) is None, source
|
||||
ap.reset_panel_state()
|
||||
finally:
|
||||
lc._client = None
|
||||
ap.reset_panel_state()
|
||||
|
||||
|
||||
@test("agent_panel: synthesizer failure or garbage verdict → None")
|
||||
def _():
|
||||
try:
|
||||
@@ -9010,6 +9042,193 @@ def _():
|
||||
pass
|
||||
|
||||
|
||||
print("\n═══ Module 28: 2026-09-04 audit fixes ═══")
|
||||
|
||||
|
||||
@test("signal_log: inverted logged_at (write-lock race) still marks DUPLICATE")
|
||||
def _():
|
||||
# logged_at is stamped BEFORE the row wins the SQLite write lock
|
||||
# (busy_timeout 5 s), so two processes logging the same setup at one
|
||||
# candle boundary can land with timestamp order inverted against id
|
||||
# order. That must not be read as a second, independent trade.
|
||||
import signal_log as slog
|
||||
real_dt = slog.datetime
|
||||
stamp = {"v": "2026-08-19T12:00:30+00:00"}
|
||||
|
||||
class _FakeDT:
|
||||
@staticmethod
|
||||
def now(tz=None):
|
||||
return real_dt.fromisoformat(stamp["v"])
|
||||
|
||||
@staticmethod
|
||||
def fromisoformat(s):
|
||||
return real_dt.fromisoformat(s)
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
try:
|
||||
lg = slog.SignalLogger(path)
|
||||
slog.datetime = _FakeDT
|
||||
r1 = lg.log(_m27_report(), source="telegram", timeframe="5min")
|
||||
stamp["v"] = "2026-08-19T12:00:00+00:00" # EARLIER, but higher id
|
||||
r2 = lg.log(_m27_report(), source="dashboard", timeframe="5min")
|
||||
slog.datetime = real_dt
|
||||
rows = {r[0]: r[1:] for r in lg._conn.execute(
|
||||
"SELECT id, advisory_event_id, advisory_outcome_status FROM signals")}
|
||||
assert rows[r2][1] == "DUPLICATE", (
|
||||
f"negative gap is a lock-order inversion, not a new trade: {rows}")
|
||||
assert rows[r2][0] == r1
|
||||
lg.close()
|
||||
finally:
|
||||
slog.datetime = real_dt
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
@test("analyze_signal_log: forward return is None until the horizon elapsed")
|
||||
def _():
|
||||
import analyze_signal_log as asl
|
||||
idx = pd.date_range("2026-08-19T00:00:00Z", periods=24, freq="1h")
|
||||
df = pd.DataFrame({"Close": [2400.0 + i for i in range(24)]}, index=idx)
|
||||
ts = idx[-3].to_pydatetime()
|
||||
assert asl._forward_return(df, ts, 2400.0, 1) is not None, \
|
||||
"an elapsed horizon must still measure"
|
||||
assert asl._forward_return(df, ts, 2400.0, 24) is None, \
|
||||
"an unreached horizon must be None, not the latest close"
|
||||
assert asl._forward_return(df, idx[-1].to_pydatetime(), 2400.0, 1) is None
|
||||
|
||||
|
||||
@test("analyze_signal_log: persona scorecard counts each event once, not per row")
|
||||
def _():
|
||||
import sqlite3
|
||||
import types as _types
|
||||
import analyze_signal_log as asl
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
try:
|
||||
con = sqlite3.connect(path)
|
||||
con.execute("CREATE TABLE signals (id INTEGER PRIMARY KEY,"
|
||||
" panel_takes_json TEXT)")
|
||||
takes = json.dumps([{"persona": "macro", "stance": "support",
|
||||
"confidence": 70, "note": "x"}])
|
||||
con.executemany(
|
||||
"INSERT INTO signals (id, panel_takes_json) VALUES (?,?)",
|
||||
[(1, takes), (2, takes)])
|
||||
con.commit()
|
||||
con.close()
|
||||
# ONE event whose two rows both carry panel takes (same scan
|
||||
# re-logged inside the cluster window).
|
||||
ev = _types.SimpleNamespace(
|
||||
rows=[_types.SimpleNamespace(id=1), _types.SimpleNamespace(id=2)],
|
||||
r_headline=2.0)
|
||||
out = asl.persona_scorecard([], [ev], path)
|
||||
assert len(out) == 1, out
|
||||
assert out[0]["n"] == 1, f"two rows of ONE event must count once: {out}"
|
||||
assert out[0]["mean_r"] == 2.0
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
@test("llm_client: failure warnings re-arm after a recovered call")
|
||||
def _():
|
||||
import requests as _req
|
||||
import llm_client as _lc
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
|
||||
def __init__(self, payload):
|
||||
self._p = payload
|
||||
|
||||
def json(self):
|
||||
return self._p
|
||||
|
||||
class _Sess:
|
||||
def __init__(self, seq):
|
||||
self.seq = list(seq)
|
||||
|
||||
def post(self, *a, **k):
|
||||
item = self.seq.pop(0)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return item
|
||||
|
||||
c = _lc.DeepSeekClient("key")
|
||||
c._session = _Sess([
|
||||
_req.RequestException("boom"),
|
||||
_Resp({"choices": [{"message": {"content": '{"ok": 1}'}}]}),
|
||||
])
|
||||
assert c.chat_json("sys", {}) is None
|
||||
assert "network" in c._warned, "first failure must arm the warn-once flag"
|
||||
assert c.chat_json("sys", {}) == {"ok": 1}
|
||||
assert "network" not in c._warned, \
|
||||
"a recovered call must re-arm so the NEXT outage logs at WARNING"
|
||||
|
||||
|
||||
@test("advisory_outcome: evaluator errors are logged, not silently UNRESOLVABLE")
|
||||
def _():
|
||||
import advisory_outcome as ao
|
||||
records = []
|
||||
|
||||
class _H(logging.Handler):
|
||||
def emit(self, record):
|
||||
records.append(record)
|
||||
|
||||
handler = _H()
|
||||
ao.logger.addHandler(handler)
|
||||
try:
|
||||
# progress={} makes the evaluator raise KeyError internally.
|
||||
out = ao.advance_outcome(
|
||||
{"status": "PENDING", "progress": {}, "setup": {}, "policy": {},
|
||||
"signal_bar_at": "2026-08-19T12:00:00+00:00", "timeframe": "1h"},
|
||||
[])
|
||||
finally:
|
||||
ao.logger.removeHandler(handler)
|
||||
assert out["status"] == "UNRESOLVABLE", "must stay fail-safe"
|
||||
assert out["result"] is None
|
||||
assert records and max(r.levelno for r in records) >= logging.ERROR, \
|
||||
"a real evaluator fault must be visible in the log"
|
||||
|
||||
|
||||
@test("migrate: prune_news_older_than prunes the cutoff calendar day")
|
||||
def _():
|
||||
import sqlite3
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_mig_test", os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"scripts", "migrate_signal_log_news.py"))
|
||||
mig = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mig)
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
try:
|
||||
con = sqlite3.connect(path)
|
||||
con.execute("CREATE TABLE signals (id INTEGER PRIMARY KEY,"
|
||||
" logged_at TEXT, headlines_hash TEXT, events_hash TEXT)")
|
||||
con.execute("CREATE TABLE news_snapshots (hash TEXT PRIMARY KEY,"
|
||||
" body TEXT)")
|
||||
now = datetime.now(timezone.utc)
|
||||
# Genuinely older than the 30-day cutoff, but on the SAME calendar
|
||||
# day as it — the case the raw string compare put on the wrong side
|
||||
# ('T' sorts after ' ').
|
||||
boundary = (now - timedelta(days=30)).replace(
|
||||
hour=0, minute=0, second=1, microsecond=0).isoformat()
|
||||
con.executemany(
|
||||
"INSERT INTO signals VALUES (?,?,?,?)",
|
||||
[(1, boundary, "h1", "e1"), (2, now.isoformat(), "h2", "e2")])
|
||||
con.executemany("INSERT INTO news_snapshots VALUES (?,?)",
|
||||
[("h1", "{}"), ("e1", "{}"), ("h2", "{}"), ("e2", "{}")])
|
||||
con.commit()
|
||||
n = mig.prune_news_older_than(con, 30)
|
||||
assert n == 1, f"the cutoff-day row must prune, got {n}"
|
||||
kept = con.execute(
|
||||
"SELECT headlines_hash FROM signals ORDER BY id").fetchall()
|
||||
assert kept == [(None,), ("h2",)], kept
|
||||
con.close()
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# SUMMARY
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user