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:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user