agent_panel: harden evidence and track outcomes
This commit is contained in:
+131
-13
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user