news corpus for a local scorer + agent panel v2 + dashboard read-only (2026-09-17); books byte-identical

Agent panel v2: personas return p_win anchored to the book base rates with a
code-computed decision context; verdict = equal-weight mean (verdict_source
"mean"); only the macro persona runs live (agent_panel.personas ["macro"]);
prompt version and exact payloads logged; offline replay harness
scripts/panel_replay.py; base-rate table agent_panel_base_rates.json
(scripts/build_panel_base_rates.py). Backtester `strategy` column now carries
the generator's own stamp (label-only; five reference books re-pinned, all
62 other columns identical).

News corpus (after evaluating the owner's ML-NEWS scikit model against
DeepSeek: golden set 39/78 vs 78/78, out-of-sample agreement 64% against a
90% self-agreement ceiling; verdict: fallback only, not a replacement):
- NewsSentiment.xau_score + archive column xau_score keep DeepSeek's gold
  score for every headline (930 of the first 1,546 DeepSeek rows had none);
  an existing newsdata.db gains the column in place on first open
- headlines naming no tracked currency are scored and archived
  (currencies_json []) but never returned to the platform
- _apply_llm_sentiment sends every new headline in its cycle, 25 per call;
  a failed call ends the cycle's sending

Dashboard publisher never writes a database: main() calls
app_config.set_db_writes_for_process(False), honoured by signal_log_enabled()
and news_archive_enabled() (owner decision: the bot is the single writer).

Test suite: the hermetic app config now points both writers at a
suite-private temp dir — the RSS fixture tests were writing fixture
headlines into the real logs/newsdata.db — with a Module 32 test pinning it.
529 tests.
This commit is contained in:
Harwinder Sekhon
2026-09-17 19:49:29 -04:00
parent 183df7832b
commit ad14ea4fed
21 changed files with 3803 additions and 259 deletions
+140
View File
@@ -794,6 +794,145 @@ def persona_scorecard(events: Sequence[SignalEvent], db_path: str) -> List[dict]
for (p, s), v in sorted(agg.items())]
# ── Probability scorecard (agent panel v2, 2026-09-17) ───────────────
#
# Scores the panel as a PREDICTOR of the trade closing profitably, per
# prompt version: Brier score and log-loss per persona and for the mean,
# calibration by probability band, discrimination (win rate / mean R by
# band), the constant-voter rate, rank correlation of p with R, and the
# counterfactual books. v1 rows (stance + confidence) are mapped onto the
# same 0-100 probability scale with agent_panel.pseudo_probability so the
# two eras are comparable; nothing here is a decision (owner rule: no
# persona-weight tuning before ~300 graded outcomes).
def _p_bands(pairs: Sequence[Tuple[float, float]]) -> Dict[str, dict]:
from agent_panel import P_OPPOSE, P_SUPPORT
out = {}
for label, pick in (("<=40", lambda p: p <= P_OPPOSE),
("40-60", lambda p: P_OPPOSE < p < P_SUPPORT),
(">=60", lambda p: p >= P_SUPPORT)):
sel = [(p, r) for p, r in pairs if pick(p)]
out[label] = ({"n": len(sel),
"mean_p": sum(p for p, _ in sel) / len(sel),
"win_rate": sum(1 for _, r in sel if r > 0) / len(sel),
"mean_r": sum(r for _, r in sel) / len(sel),
"sum_r": sum(r for _, r in sel)}
if sel else {"n": 0})
return out
def _score_pairs(pairs: Sequence[Tuple[float, float]]) -> Optional[dict]:
from agent_panel import P_OPPOSE, P_SUPPORT, derive_stance
if not pairs:
return None
ys = [1.0 if r > 0 else 0.0 for _, r in pairs]
ps = [p / 100.0 for p, _ in pairs]
eps = 1e-3
brier = sum((p - y) ** 2 for p, y in zip(ps, ys)) / len(ps)
ll = -sum(y * math.log(min(1 - eps, max(eps, p)))
+ (1 - y) * math.log(min(1 - eps, max(eps, 1 - p)))
for p, y in zip(ps, ys)) / len(ps)
stances = [derive_stance(p) for p, _ in pairs]
modal = max(set(stances), key=stances.count)
rho = None
if len(pairs) >= 5:
a = pd.Series([p for p, _ in pairs]).rank()
b = pd.Series([r for _, r in pairs]).rank()
c = a.corr(b)
rho = None if c != c else float(c)
return {"n": len(pairs), "brier": brier, "logloss": ll,
"mean_p": sum(p for p, _ in pairs) / len(pairs),
"win_rate": sum(ys) / len(ys),
"modal": modal, "constant_rate": stances.count(modal) / len(stances),
"rho_p_r": rho, "bands": _p_bands(pairs),
"sum_all": sum(r for _, r in pairs),
"sum_skip_oppose": sum(r for p, r in pairs if p > P_OPPOSE),
"sum_support_only": sum(r for p, r in pairs if p >= P_SUPPORT)}
def probability_scorecard(events: Sequence[SignalEvent], db_path: str
) -> Dict[str, Dict[str, Optional[dict]]]:
"""{prompt_version: {"mean": stats, "<persona>": stats, "base_rate": stats}}
over resolved events (one vote per event, first row with a panel)."""
from agent_panel import pseudo_probability
by_row = _row_index(events)
con = open_readonly(db_path)
have = {r[1] for r in con.execute("PRAGMA table_info(signals)")}
cols = ["id", "panel_takes_json"]
for c in ("panel_probs_json", "panel_prompt_version", "panel_probability",
"panel_context_json"):
cols.append(c if c in have else f"NULL AS {c}")
per_version: Dict[str, Dict[str, List[Tuple[float, float]]]] = {}
seen: set = set()
try:
for rid, takes_json, probs_json, version, p_mean, ctx_json in con.execute(
f"SELECT {', '.join(cols)} FROM signals"
" WHERE panel_takes_json IS NOT NULL ORDER BY id"):
ev = by_row.get(rid)
if ev is None or ev.r_headline is None:
continue
ekey = ev.rows[0].id if ev.rows else rid
if ekey in seen:
continue
seen.add(ekey)
r = ev.r_headline
version = version or "v1"
bucket = per_version.setdefault(version, {})
probs: Dict[str, float] = {}
try:
source = json.loads(probs_json) if probs_json else json.loads(takes_json)
except (TypeError, ValueError):
continue
for t in source or []:
p = t.get("p_win")
if p is None and t.get("stance") in ("support", "oppose", "neutral"):
p = pseudo_probability(t["stance"], float(t.get("confidence") or 0.0))
if p is not None:
probs[str(t.get("persona"))] = float(p)
bucket.setdefault(str(t.get("persona")), []).append((float(p), r))
if p_mean is not None:
bucket.setdefault("mean", []).append((float(p_mean), r))
elif len(probs) >= 3:
bucket.setdefault("mean", []).append(
(sum(probs.values()) / len(probs), r))
try:
base = (json.loads(ctx_json) if ctx_json else {}).get("base_rate") or {}
if base.get("win_rate") is not None:
bucket.setdefault("base_rate", []).append((float(base["win_rate"]), r))
except (TypeError, ValueError, AttributeError):
pass
finally:
con.close()
return {v: {k: _score_pairs(pairs) for k, pairs in sorted(b.items())}
for v, b in sorted(per_version.items())}
def print_probability_scorecard(card: Dict[str, Dict[str, Optional[dict]]]) -> None:
print("\n── Probability scorecard (Brier: lower is better; 0.25 = coin flip) " + "─" * 6)
if not card:
print(" no resolved events with a panel yet")
return
for version, preds in card.items():
print(f" prompt version {version}")
print(f" {'predictor':12s} {'n':>3s} {'Brier':>6s} {'logloss':>7s} {'mean p':>6s} "
f"{'win%':>5s} {'modal':>8s} {'const%':>6s} {'rho(p,R)':>8s} "
f"{'R all':>7s} {'skip<=40':>8s} {'>=60only':>8s}")
for name, st in preds.items():
if not st:
continue
rho = f"{st['rho_p_r']:+8.2f}" if st["rho_p_r"] is not None else " -"
print(f" {name:12s} {st['n']:3d} {st['brier']:6.3f} {st['logloss']:7.3f} "
f"{st['mean_p']:6.1f} {100 * st['win_rate']:5.0f} {st['modal']:>8s} "
f"{100 * st['constant_rate']:6.0f} {rho} {st['sum_all']:+7.2f} "
f"{st['sum_skip_oppose']:+8.2f} {st['sum_support_only']:+8.2f}")
mean = preds.get("mean")
if mean:
cells = " ".join(
f"{k}: n={v['n']} p={v['mean_p']:.0f} win={100 * v['win_rate']:.0f}% R={v['mean_r']:+.2f}"
if v["n"] else f"{k}: n=0" for k, v in mean["bands"].items())
print(f" calibration of the mean: {cells}")
# ── News sentiment lead/lag ───────────────────────────────────────────
@@ -1309,6 +1448,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
for p in persona_scorecard(events, args.db):
print(f" {p['persona']:11} {p['stance']:8} n={p['n']:3d} "
f"mean {p['mean_r']:+.3f}R win {100 * p['win_rate']:.0f}%")
print_probability_scorecard(probability_scorecard(events, args.db))
if args.news:
print_news(news_lead_lag(rows, streams),
news_extremes(rows, streams),