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.

Claude-Session: https://claude.ai/code/session_01Ah5EB1goi5inQmSd37Ne6a
This commit is contained in:
Harwinder Sekhon
2026-09-04 20:19:08 -04:00
parent a3d7594f0c
commit bed8b549ac
9 changed files with 353 additions and 43 deletions
+11
View File
@@ -8,11 +8,14 @@ but its evidence is the cleaned mid-candle snapshots stored in signals.db.
from __future__ import annotations from __future__ import annotations
import logging
import math import math
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict, Iterable, List, Optional, Tuple from typing import Any, Dict, Iterable, List, Optional, Tuple
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
MODEL = "stored-closed-mid-next-open-trail-v1" MODEL = "stored-closed-mid-next-open-trail-v1"
MAX_HOLD_BARS = 300 MAX_HOLD_BARS = 300
REFERENCE_COST = 0.30 REFERENCE_COST = 0.30
@@ -331,6 +334,14 @@ def advance_outcome(
return state return state
return state return state
except Exception as exc: except Exception as exc:
# Still fail-safe (never raises into a scan), but no longer
# SILENT: a real defect here used to be indistinguishable from a
# legitimate weekend/downtime gap, discoverable only by grepping
# advisory_outcome_json. Callers persist the state either way.
logger.exception(
"advisory outcome evaluator failed for the %s outcome seeded "
"at %s — recorded UNRESOLVABLE",
state.get("timeframe"), state.get("signal_bar_at"))
state["status"] = "UNRESOLVABLE" state["status"] = "UNRESOLVABLE"
state["result"] = None state["result"] = None
state["reason"] = f"outcome evaluator error: {type(exc).__name__}" state["reason"] = f"outcome evaluator error: {type(exc).__name__}"
+11 -3
View File
@@ -758,9 +758,8 @@ def run_panel(report: "SignalReport",
done, _ = _fwait(futures, timeout=client_timeout + 8.0) done, _ = _fwait(futures, timeout=client_timeout + 8.0)
takes = [f.result() for f in done if not f.exception()] takes = [f.result() for f in done if not f.exception()]
takes = [t for t in takes if t is not None] takes = [t for t in takes if t is not None]
if len(takes) < 2: if not takes:
logger.debug("agent panel: %d/4 persona takes — skipping synthesizer", logger.debug("agent panel: 0/4 persona takes — no verdict")
len(takes))
return None return None
order = {"macro": 0, "technical": 1, "risk": 2, "contrarian": 3} order = {"macro": 0, "technical": 1, "risk": 2, "contrarian": 3}
takes.sort(key=lambda t: order.get(t.persona, 9)) takes.sort(key=lambda t: order.get(t.persona, 9))
@@ -773,6 +772,15 @@ def run_panel(report: "SignalReport",
takes=takes, created_ts=time.time(), takes=takes, created_ts=time.time(),
payload_hash=payload_hash, source="macro") payload_hash=payload_hash, source="macro")
# Macro take missing (timed out / invalid) — fall back to the chair. # Macro take missing (timed out / invalid) — fall back to the chair.
# Quorum guards the CHAIR only: it weighs several voices, so one lone
# take is not worth a synthesizer request. The macro path above needs
# only its own take and must never be gated by it — that gate used to
# sit before the macro branch and silently dropped a good macro
# verdict whenever the other three personas were slow.
if len(takes) < 2:
logger.debug("agent panel: %d/4 persona takes — skipping synthesizer",
len(takes))
return None
reply = get_llm_client().chat_json(_SYNTH_SYSTEM, { reply = get_llm_client().chat_json(_SYNTH_SYSTEM, {
"direction": direction, "direction": direction,
"takes": [{"persona": t.persona, "stance": t.stance, "takes": [{"persona": t.persona, "stance": t.stance,
+18
View File
@@ -444,6 +444,14 @@ def _forward_return(
"""Close-to-close move from `price` to the last close at ts+hours.""" """Close-to-close move from `price` to the last close at ts+hours."""
target = pd.Timestamp(ts) + pd.Timedelta(hours=hours) target = pd.Timestamp(ts) + pd.Timedelta(hours=hours)
closes = df["Close"] 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 pos = int(df.index.searchsorted(target, side="right")) - 1
if pos < 0: if pos < 0:
return None return None
@@ -753,6 +761,7 @@ def persona_scorecard(rows: Sequence[LogRow], events: Sequence[SignalEvent],
by_row = _row_index(events) by_row = _row_index(events)
con = open_readonly(db_path) con = open_readonly(db_path)
agg: Dict[Tuple[str, str], List[float]] = {} agg: Dict[Tuple[str, str], List[float]] = {}
seen_events: set = set()
try: try:
for rid, takes in con.execute( for rid, takes in con.execute(
"SELECT id, panel_takes_json FROM signals" "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) ev = by_row.get(rid)
if ev is None or ev.r_headline is None: if ev is None or ev.r_headline is None:
continue 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: try:
for t in json.loads(takes): for t in json.loads(takes):
agg.setdefault((t.get("persona", "?"), agg.setdefault((t.get("persona", "?"),
+13 -1
View File
@@ -90,7 +90,11 @@ class DeepSeekClient:
if self._count_day != today: if self._count_day != today:
self._count_day = today self._count_day = today
self._count = 0 self._count = 0
self._warned.discard("cap") # Clear ALL warn-once kinds, not just "cap": the network/
# http_*/parse flags used to be set once and never reset,
# so in a daemon that runs for weeks the second and every
# later outage logged at DEBUG only.
self._warned.clear()
if self._count >= self._daily_cap: if self._count >= self._daily_cap:
self._warn_once( self._warn_once(
"cap", "DeepSeek daily request cap (%d) exhausted — " "cap", "DeepSeek daily request cap (%d) exhausted — "
@@ -149,6 +153,14 @@ class DeepSeekClient:
"expected JSON object — ignoring") "expected JSON object — ignoring")
else: else:
self.last_ok = time.time() self.last_ok = time.time()
# A recovered call re-arms the failure warnings so the NEXT
# outage is visible at WARNING again. "cap" keeps its own
# UTC-day reset in _under_cap. (Taken under the lock; safe
# here because chat_json holds no lock — _under_cap has
# already returned.)
if self._warned - {"cap"}:
with self._lock:
self._warned = {k for k in self._warned if k == "cap"}
return parsed return parsed
@staticmethod @staticmethod
+7 -1
View File
@@ -95,9 +95,15 @@ def prune_orphans(con: sqlite3.Connection) -> int:
def prune_news_older_than(con: sqlite3.Connection, days: int) -> int: def prune_news_older_than(con: sqlite3.Connection, days: int) -> int:
"""Drop the news reference (not the row) from rows older than N days, """Drop the news reference (not the row) from rows older than N days,
then delete snapshots nothing references any more.""" then delete snapshots nothing references any more."""
# logged_at is ISO8601 with a 'T' and a +00:00 offset; SQLite's
# datetime() returns 'YYYY-MM-DD HH:MM:SS'. Comparing them as raw
# strings put every row from the cutoff CALENDAR DAY on the wrong
# side ('T' > ' '), so a full day never pruned. Normalise to the
# same shape first — all logged_at values are UTC (see _iso()).
cur = con.execute( cur = con.execute(
"UPDATE signals SET headlines_hash=NULL, events_hash=NULL" "UPDATE signals SET headlines_hash=NULL, events_hash=NULL"
" WHERE logged_at < datetime('now', ?)", (f"-{int(days)} days",)) " WHERE substr(replace(logged_at, 'T', ' '), 1, 19)"
" < datetime('now', ?)", (f"-{int(days)} days",))
con.commit() con.commit()
prune_orphans(con) prune_orphans(con)
return cur.rowcount return cur.rowcount
+9 -2
View File
@@ -748,7 +748,14 @@ class SignalLogger:
- datetime.fromisoformat(prior[2])).total_seconds() - datetime.fromisoformat(prior[2])).total_seconds()
except (TypeError, ValueError): except (TypeError, ValueError):
gap = None gap = None
if gap is not None and 0 <= gap <= OUTCOME_DEDUP_GAP_SECONDS: # abs(), not 0 <= gap: logged_at is stamped in _report_row
# BEFORE this row wins the write lock (busy_timeout 5 s), so
# two processes logging the same setup at one boundary can
# land with their timestamp order inverted against their id
# order. A negative gap is that inversion, not a different
# trade — treating it as "not a duplicate" seeded a second
# event and double-counted its R.
if gap is not None and abs(gap) <= OUTCOME_DEDUP_GAP_SECONDS:
event_id = int(prior[1] or prior[0]) event_id = int(prior[1] or prior[0])
self._conn.execute( self._conn.execute(
"UPDATE signals SET advisory_event_id=?," "UPDATE signals SET advisory_event_id=?,"
@@ -967,7 +974,7 @@ class SignalLogger:
continue continue
prior = last_by_key.get((symbol, key)) prior = last_by_key.get((symbol, key))
if (prior is not None if (prior is not None
and 0 <= (ts - prior[2]).total_seconds() and abs((ts - prior[2]).total_seconds())
<= OUTCOME_DEDUP_GAP_SECONDS): <= OUTCOME_DEDUP_GAP_SECONDS):
# Same setup re-logged inside the gap: a duplicate of # Same setup re-logged inside the gap: a duplicate of
# the canonical event regardless of its legacy status # the canonical event regardless of its legacy status
+10 -1
View File
@@ -820,6 +820,7 @@ async function poll() {
const priorSnapshot = snapshot; const priorSnapshot = snapshot;
let snapChanged = false; let snapChanged = false;
let selectedChanged = false; let selectedChanged = false;
let cardsChanged = false;
let detailChanged = false; let detailChanged = false;
if (snap.text !== lastSnapText) { if (snap.text !== lastSnapText) {
const incoming = JSON.parse(snap.text); const incoming = JSON.parse(snap.text);
@@ -827,6 +828,14 @@ async function poll() {
snapChanged = true; snapChanged = true;
selectedChanged = tfFingerprint(priorSnapshot?.timeframes?.[selectedTf]) !== selectedChanged = tfFingerprint(priorSnapshot?.timeframes?.[selectedTf]) !==
tfFingerprint(incoming.timeframes?.[selectedTf]); tfFingerprint(incoming.timeframes?.[selectedTf]);
// renderCards() redraws the selector for EVERY timeframe, so it
// must react to any of them changing — not just the selected one.
// Gating it on selectedChanged left the other buttons showing a
// stale arrow/score (up to 4 h on H4) while renderHero's rail,
// which redraws unconditionally, already showed the new value.
cardsChanged = TF_ORDER.some(tf =>
tfFingerprint(priorSnapshot?.timeframes?.[tf]) !==
tfFingerprint(incoming.timeframes?.[tf]));
detailChanged = detailTf != null && tfFingerprint(priorSnapshot?.timeframes?.[detailTf]) !== detailChanged = detailTf != null && tfFingerprint(priorSnapshot?.timeframes?.[detailTf]) !==
tfFingerprint(incoming.timeframes?.[detailTf]); tfFingerprint(incoming.timeframes?.[detailTf]);
snapshot = incoming; snapshot = incoming;
@@ -841,7 +850,7 @@ async function poll() {
if (snapChanged) { if (snapChanged) {
renderHero(); renderHero();
if (selectedChanged) renderCards(); if (cardsChanged) renderCards();
if (detailChanged) renderDetail(); if (detailChanged) renderDetail();
if (selectedChanged) { if (selectedChanged) {
const signal = snapshot.timeframes[selectedTf]?.report?.signal; const signal = snapshot.timeframes[selectedTf]?.report?.signal;
+53 -33
View File
@@ -73,7 +73,12 @@ function respond(int $status, array $obj, string $cache = 'no-store'): void
function atomic_write(string $path, string $data): bool function atomic_write(string $path, string $data): bool
{ {
$tmp = $path . '.tmp.' . getmypid(); $tmp = $path . '.tmp.' . getmypid();
if (@file_put_contents($tmp, $data) === false) { $written = @file_put_contents($tmp, $data);
// A SHORT write (disk full / quota) returns a byte count, NOT false.
// Renaming that into place would publish a truncated JSON body which
// the cache-first branch above then serves forever.
if ($written === false || $written !== strlen($data)) {
@unlink($tmp);
return false; return false;
} }
if (!@rename($tmp, $path)) { if (!@rename($tmp, $path)) {
@@ -120,44 +125,14 @@ if ($hit === null) {
'message' => 'Headline is no longer in the feed.']); 'message' => 'Headline is no longer in the feed.']);
} }
// ── 4. daily cap (fail closed) ────────────────────────────────────── // ── 4. API key ──────────────────────────────────────────────────────
$dailyCap = (int)(getenv('AI_SUMMARY_DAILY_CAP') ?: 150);
$capFh = @fopen(CAP_FILE, 'c+');
if ($capFh === false || !flock($capFh, LOCK_EX)) {
respond(503, ['error' => 'cap_io']);
}
$today = gmdate('Y-m-d');
$cap = json_decode((string)stream_get_contents($capFh), true);
if (!is_array($cap) || ($cap['day'] ?? '') !== $today) {
$cap = ['day' => $today, 'count' => 0];
}
if ($cap['count'] >= $dailyCap) {
flock($capFh, LOCK_UN);
fclose($capFh);
respond(429, ['error' => 'cap_exceeded']);
}
// Increment BEFORE the LLM call — attempts count against the cap
// (same semantics as llm_client._under_cap).
$cap['count']++;
rewind($capFh);
if (ftruncate($capFh, 0) === false
|| fwrite($capFh, json_encode($cap)) === false) {
flock($capFh, LOCK_UN);
fclose($capFh);
respond(503, ['error' => 'cap_io']);
}
fflush($capFh);
flock($capFh, LOCK_UN);
fclose($capFh);
// ── 5. API key ──────────────────────────────────────────────────────
$keyFile = getenv('DEEPSEEK_KEY_FILE') ?: '/etc/gold-dashboard/deepseek.key'; $keyFile = getenv('DEEPSEEK_KEY_FILE') ?: '/etc/gold-dashboard/deepseek.key';
$apiKey = trim((string)@file_get_contents($keyFile)); $apiKey = trim((string)@file_get_contents($keyFile));
if ($apiKey === '') { if ($apiKey === '') {
respond(503, ['error' => 'no_key']); respond(503, ['error' => 'no_key']);
} }
// ── 6. per-id stampede lock, then re-check the cache ──────────────── // ── 5. per-id stampede lock, then re-check the cache ────────────────
$lockFh = @fopen(CACHE_DIR . '/.ht_lock_' . $id, 'c'); $lockFh = @fopen(CACHE_DIR . '/.ht_lock_' . $id, 'c');
if ($lockFh !== false) { if ($lockFh !== false) {
flock($lockFh, LOCK_EX); // blocks while a concurrent winner generates flock($lockFh, LOCK_EX); // blocks while a concurrent winner generates
@@ -172,6 +147,51 @@ if ($lockFh !== false) {
exit; exit;
} }
} }
$releaseLock = function () use (&$lockFh) {
if ($lockFh !== false) {
@flock($lockFh, LOCK_UN);
@fclose($lockFh);
$lockFh = false;
}
};
// ── 6. daily cap (fail closed) ──────────────────────────────────────
// Counted HERE, not before the lock: a burst of clicks on one headline
// makes exactly one DeepSeek call, and the losers are served from the
// re-checked cache above — charging them against the budget (and
// charging a request that then found no key) could exhaust the whole
// 150/day allowance with zero LLM calls made.
$dailyCap = (int)(getenv('AI_SUMMARY_DAILY_CAP') ?: 150);
$capFh = @fopen(CAP_FILE, 'c+');
if ($capFh === false || !flock($capFh, LOCK_EX)) {
$releaseLock();
respond(503, ['error' => 'cap_io']);
}
$today = gmdate('Y-m-d');
$cap = json_decode((string)stream_get_contents($capFh), true);
if (!is_array($cap) || ($cap['day'] ?? '') !== $today) {
$cap = ['day' => $today, 'count' => 0];
}
if ($cap['count'] >= $dailyCap) {
flock($capFh, LOCK_UN);
fclose($capFh);
$releaseLock();
respond(429, ['error' => 'cap_exceeded']);
}
// Increment BEFORE the LLM call — attempts count against the cap
// (same semantics as llm_client._under_cap).
$cap['count']++;
rewind($capFh);
if (ftruncate($capFh, 0) === false
|| fwrite($capFh, json_encode($cap)) === false) {
flock($capFh, LOCK_UN);
fclose($capFh);
$releaseLock();
respond(503, ['error' => 'cap_io']);
}
fflush($capFh);
flock($capFh, LOCK_UN);
fclose($capFh);
// ── 7. DeepSeek call (exact chat_json body shape) ─────────────────── // ── 7. DeepSeek call (exact chat_json body shape) ───────────────────
$userPayload = json_encode([ $userPayload = json_encode([
+221 -2
View File
@@ -6603,12 +6603,12 @@ def _():
assert v("risk", {"stance": "neutral", "confidence": -3, "note": ""}).confidence == 0.0 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 _(): def _():
try: try:
stub = _PanelStub({ap._MACRO_SYSTEM: _TAKE_OK}) # others → None stub = _PanelStub({ap._MACRO_SYSTEM: _TAKE_OK}) # others → None
lc._client = stub 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], \ assert ap._SYNTH_SYSTEM not in [s for s, _ in stub.calls], \
"synthesizer request must not be spent on a failed panel" "synthesizer request must not be spent on a failed panel"
assert len(stub.calls) == 4 assert len(stub.calls) == 4
@@ -6617,6 +6617,38 @@ def _():
ap.reset_panel_state() 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") @test("agent_panel: synthesizer failure or garbage verdict → None")
def _(): def _():
try: try:
@@ -9010,6 +9042,193 @@ def _():
pass 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 # SUMMARY
# ══════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════