agent_panel: harden evidence and track outcomes

This commit is contained in:
root
2026-08-23 12:57:05 -04:00
parent 6c5e58f1c3
commit 4dd0ca0b46
7 changed files with 1313 additions and 159 deletions
+131 -13
View File
@@ -13,9 +13,10 @@ never writes here (live == backtest parity — CLAUDE.md rule 4).
Panel fields arrive POST-HOC: the row is inserted during analyze_pair
(before consumers attach the advisory panel), so log_signal stashes the
rowid on the report and agent_panel.attach_panel calls
log_panel_update() to fill panel_verdict / panel_confidence /
panel_rationale / panel_takes_json on that same row. A report without a
rowid (backtest replay, logging disabled) is a silent no-op.
log_panel_update() to fill the verdict, takes, creation/cache provenance,
payload hash, and advisory-outcome seed on that same row. Later closed-candle
snapshots advance the hypothetical next-open/trailing outcome. A report
without a rowid (backtest replay, logging disabled) is a silent no-op.
Deployment settings come from the config.json "signal_log" block via
app_config (env vars SIGNAL_LOG_ENABLED / SIGNAL_LOG_PATH win):
@@ -38,6 +39,12 @@ from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
import app_config
from advisory_outcome import (
advance_outcome,
canonical_r,
initial_outcome,
timeframe_value,
)
logger = logging.getLogger(__name__)
@@ -73,7 +80,13 @@ CREATE TABLE IF NOT EXISTS signals (
panel_verdict TEXT,
panel_confidence REAL,
panel_rationale TEXT,
panel_takes_json TEXT
panel_takes_json TEXT,
panel_created_at TEXT,
panel_cache_status TEXT,
panel_payload_hash TEXT,
advisory_outcome_status TEXT,
advisory_r_multiple REAL,
advisory_outcome_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_signals_sym_tf_ts
ON signals (symbol, timeframe, logged_at);
@@ -101,6 +114,12 @@ _ADDITIVE_SIGNAL_COLS = (
("panel_takes_json", "TEXT"),
("indicators_json", "TEXT"),
("candles_json", "TEXT"),
("panel_created_at", "TEXT"),
("panel_cache_status", "TEXT"),
("panel_payload_hash", "TEXT"),
("advisory_outcome_status", "TEXT"),
("advisory_r_multiple", "REAL"),
("advisory_outcome_json", "TEXT"),
)
_ADDITIVE_OUTLOOK_COLS = (
@@ -327,6 +346,9 @@ def _report_row(
"risk_pips": _finite(getattr(setup, "risk_pips", None)),
"reward_pips": _finite(getattr(setup, "reward_pips", None)),
"strategy": getattr(setup, "strategy", None),
"setup_pattern": getattr(setup, "setup_pattern", None),
"round_trip_cost": _finite(
getattr(setup, "round_trip_cost", None)),
"trail_arm_r": _finite(getattr(setup, "trail_arm_r", None)),
"trail_dist_r": _finite(getattr(setup, "trail_dist_r", None)),
})
@@ -403,8 +425,17 @@ class SignalLogger:
existing = {r[1] for r in self._conn.execute("PRAGMA table_info(signals)")}
for name, col_type in _ADDITIVE_SIGNAL_COLS:
if name not in existing:
self._conn.execute(
f"ALTER TABLE signals ADD COLUMN {name} {col_type}")
try:
self._conn.execute(
f"ALTER TABLE signals ADD COLUMN {name} {col_type}")
except sqlite3.OperationalError as exc:
# Another dashboard/bot process may have migrated the
# shared database after our PRAGMA snapshot.
if "duplicate column name" not in str(exc).lower():
raise
self._conn.execute(
"CREATE INDEX IF NOT EXISTS idx_signals_advisory_pending"
" ON signals (symbol, advisory_outcome_status, id)")
self._conn.commit()
def _migrate_outlook_columns(self) -> None:
@@ -414,8 +445,12 @@ class SignalLogger:
}
for name, col_type in _ADDITIVE_OUTLOOK_COLS:
if name not in existing:
self._conn.execute(
f"ALTER TABLE outlooks ADD COLUMN {name} {col_type}")
try:
self._conn.execute(
f"ALTER TABLE outlooks ADD COLUMN {name} {col_type}")
except sqlite3.OperationalError as exc:
if "duplicate column name" not in str(exc).lower():
raise
self._conn.commit()
def log(
@@ -431,7 +466,13 @@ class SignalLogger:
with self._lock:
cur = self._conn.execute(_INSERT, row)
self._conn.commit()
return int(cur.lastrowid)
rowid = int(cur.lastrowid)
try:
self.resolve_pending(str(row[2]), row[25])
except Exception:
logger.warning("signal_log: advisory outcome resolver failed",
exc_info=True)
return rowid
def update_panel(
self,
@@ -440,15 +481,89 @@ class SignalLogger:
confidence: Optional[float],
rationale: str,
takes_json: str,
*,
created_at: Optional[str] = None,
cache_status: Optional[str] = None,
payload_hash: Optional[str] = None,
) -> None:
"""Fill the panel columns on an already-inserted scan row."""
"""Fill panel provenance and initialize its advisory outcome."""
with self._lock:
row = self._conn.execute(
"SELECT setup_json, indicators_json, timeframe,"
" advisory_outcome_status FROM signals WHERE id=?",
(rowid,),
).fetchone()
if row is None:
return
advisory_status = row[3]
advisory_json = None
if advisory_status is None:
setup = json.loads(row[0]) if row[0] else None
indicators = json.loads(row[1]) if row[1] else None
advisory_status, state = initial_outcome(
setup, indicators, row[2])
advisory_json = _dumps(state)
self._conn.execute(
"UPDATE signals SET panel_verdict=?, panel_confidence=?,"
" panel_rationale=?, panel_takes_json=? WHERE id=?",
(verdict, confidence, rationale, takes_json, rowid))
" panel_rationale=?, panel_takes_json=?,"
" panel_created_at=COALESCE(?, panel_created_at),"
" panel_cache_status=COALESCE(?, panel_cache_status),"
" panel_payload_hash=COALESCE(?, panel_payload_hash),"
" advisory_outcome_status=COALESCE(advisory_outcome_status, ?),"
" advisory_outcome_json=COALESCE(advisory_outcome_json, ?)"
" WHERE id=?",
(verdict, confidence, rationale, takes_json,
created_at, cache_status, payload_hash,
advisory_status, advisory_json, rowid))
self._conn.commit()
def resolve_pending(
self, symbol: str, candles_json: Optional[str], limit: int = 100
) -> int:
"""Advance pending advisory outcomes from a new candle snapshot."""
if not candles_json:
return 0
snapshots = json.loads(candles_json)
if not isinstance(snapshots, dict):
return 0
changed = 0
with self._lock:
self._conn.execute("BEGIN IMMEDIATE")
try:
rows = self._conn.execute(
"SELECT id, timeframe, advisory_outcome_json FROM signals"
" WHERE symbol=? AND advisory_outcome_status='PENDING'"
" ORDER BY id",
(symbol,),
).fetchall()
eligible = 0
for rowid, timeframe, state_json in rows:
bars = timeframe_value(snapshots, timeframe)
if not bars or not state_json:
continue
if eligible >= max(1, int(limit)):
break
eligible += 1
state = json.loads(state_json)
before = _dumps(state)
state = advance_outcome(state, bars)
after = _dumps(state)
if after == before:
continue
status = state.get("status", "UNRESOLVABLE")
self._conn.execute(
"UPDATE signals SET advisory_outcome_status=?,"
" advisory_r_multiple=?, advisory_outcome_json=?"
" WHERE id=? AND advisory_outcome_status='PENDING'",
(status, canonical_r(state), after, rowid),
)
changed += 1
self._conn.commit()
except Exception:
self._conn.rollback()
raise
return changed
def log_outlook(self, outlook: Any, surface: str,
payload: Any = None) -> int:
"""Insert one advisory market outlook; returns its rowid."""
@@ -540,7 +655,10 @@ def log_panel_update(report: Any) -> None:
int(rowid), verdict,
_finite(getattr(report, "panel_confidence", None)),
getattr(report, "panel_rationale", "") or "",
_dumps(getattr(report, "panel_takes", None) or []))
_dumps(getattr(report, "panel_takes", None) or []),
created_at=getattr(report, "panel_created_at", "") or None,
cache_status=getattr(report, "panel_cache_status", "") or None,
payload_hash=getattr(report, "panel_payload_hash", "") or None)
except Exception:
logger.warning("signal_log: failed to persist panel verdict",
exc_info=True)