Bug #5: metrics.record_trade() only delegated to save_trade(), which
executor.execute() already calls — every trade was written twice (deduped
only by ON CONFLICT DO NOTHING). Remove the redundant call and the now-dead
method. RealExecutor.execute() raises NotImplementedError, so real mode is
unaffected.
Bug #6 (CYCLE SUMMARY): manifold accepted/rejected counters only increment
on the active-signal path, so with MANIFOLD_SIGNAL_ENABLED=false they always
printed 0/0 — print 'manifold_signal: disabled' instead.
family_conflicts_prevented duplicated blocked_by_family (same counter
printed twice); removed. gnews_cap was a dead variable with a misleading
comment; removed.
Bug #7 (/api/summary): total_trades was len() over a LIMIT-500 query —
capped once history grows; counts now come from COUNT(*) via
compute_metrics_from_db. cash_available was reimplemented in the API;
extract cash_available() in paper.py (same formula, unchanged) and feed it
from get_open_position_data() — the exact source/helper
PaperExecutor.initialize() uses. Test asserts API and executor report
identical cash for the same DB state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Measure real Manifold coverage per semantic market category counted by UNIQUE
market (not audit rows, which are inflated by repeated re-evaluation). Base table
is manifold_match_audit filtered to v3_outcome_guard; each poly_market_id is
collapsed to one row, LEFT JOIN trades for family_key, category inferred from
family_key when present else from poly_question (gubernatorial / mayoral / senate
/ primary-republican / primary-democrat / big-tech / geopolitics / other).
Per category: unique_evaluated / unique_accepted / unique_rejected /
unique_no_results / coverage_rate, ordered by unique_evaluated DESC. Summary adds
total_unique_evaluated, total_unique_accepted, overall_coverage_rate,
categories_with_coverage. Read-only: new db method + endpoint only; matcher,
scheduler, cooldowns, thresholds and exposure untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The trading loop re-evaluated the same ~22 politics/tech markets every ~60s,
flooding manifold_match_audit with ~76k rows (~3,500 attempts/market) of which
none carried new information, making the metrics uninterpretable.
Add per-market persistent cooldowns:
- New table manifold_eval_cooldown (poly_market_id PK, last_evaluated_at,
last_status, retry_after, cooldown_reason) created via run_migrations.
- bayesian.evaluate() consults the cooldown BEFORE calling the matcher and skips
the call entirely while now() < retry_after — no matcher call, no audit row,
no signal (equivalent to no_results). After a real evaluation it upserts the
cooldown with a verdict-dependent backoff: no_results/low_score/outcome_mismatch/
ambiguous_inversion -> 24h, conditional_market -> 7d, accepted -> 1h.
- manifold.py stays a pure client/matcher with no DB access.
- New db methods get_manifold_cooldown / upsert_manifold_cooldown.
- /api/metrics/manifold-matches summary gains unique_markets
{evaluated, accepted, coverage_rate} for per-market (not per-attempt) coverage.
Matching logic, MANIFOLD_MATCHER_VERSION, MANIFOLD_LOGODDS_WEIGHT, edge/exposure
thresholds and existing audit rows are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The trades_dominated_by_mfld counter omitted the excluded_from_metrics
filter, so the admin-closed Maine governor trade inflated it to 1 while
attribution/features (which exclude such trades) were empty.
Add excluded_from_metrics IS NOT TRUE and mfld_match_status = 'accepted'
to the query so the counter is consistent with the attribution and
feature-metrics endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add MANIFOLD_MATCHER_VERSION="v3_outcome_guard" tag persisted to
manifold_match_audit.matcher_version so metrics can isolate current-matcher
stats from pre-versioning records, whose accepted matches the outcome
guard would now reject.
- schema: add matcher_version column + index; idempotent startup backfill
tagging NULL rows as legacy_pre_outcome_guard (no outcome types) or
v2_outcome_guard_no_version (has outcome type, version not persisted)
- save_manifold_audit: write matcher_version on every new record
- get_manifold_matches: split summary into current_version / all_time /
legacy; recent_matches now carry matcher_version
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds alpha attribution by dominant signal feature — which feat_*_lo had
the largest absolute log-odds value on each trade.
Changes:
- _dominant_feature() helper in api/main.py: picks the winning feature
from signal_components (threshold 0.0001, same as "triggered" in
/api/metrics/features)
- _enrich_trade() refactored to single exit point; adds dominant_feature
field to every open trade in /api/trades
- compute_attribution_from_db() in db.py: VALUES subquery finds dominant
feature per trade in SQL, then aggregates trade_count/avg_edge_net/
unrealized_pnl_est/realized_pnl/resolved_count/win_rate per group
- /api/metrics/attribution endpoint: returns attribution dict + total_attributed_trades
No schema changes, no strategy changes. Pure observability.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds feat_fg_lo / feat_mom_lo / feat_news_lo / feat_mfld_lo / feat_btc_dom_lo
to every trade, all normalized to log-odds contribution for direct comparability.
- fg / mom / btc_dom: raw probability-delta × 2 → log-odds
- news / mfld: already log-odds (LOGODDS_WEIGHT already applied), no scaling
- btc_dom tracked separately in bayesian.py instead of bundled in total_adj
- reasoning string updated to fg_lo= / mom_lo= notation for self-documentation
Schema: 5 new DOUBLE PRECISION columns + 2 partial indexes
Stack: TradingSignal → Order → Trade → save_trade all carry feat fields
Startup: backfill_feature_columns() recovers fg/mom/news/mfld from old
reasoning strings (×2 applied to fg/mom); btc_dom_lo stays NULL for legacy
API: /api/metrics/features — triggered/material split per feature with
two-level thresholds (0.05 for fg/mom/btc_dom, 0.10 for news/mfld)
API: /api/trades/legacy — exposes pre-Phase-1 trades (edge_net IS NULL)
API: _enrich_trade backward-compat: reads DB columns first, falls back to
reasoning regex with unit conversion for pre-Phase-6 trades
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
schema.sql
trades: + close_pnl, resolution (market outcome storage)
metrics_daily: + unrealized_pnl_est, realized_pnl, open/closed/resolved_count
db.py
close_paper_position(): accepts resolution; computes close_pnl in SQL
BUY_YES: (resolution − entry_price) × shares
BUY_NO: ((1 − resolution) − entry_price) × shares
save_daily_metrics(): persists new columns
compute_metrics_from_db(): single DB query for all metrics; no in-memory state
tracker.py — complete rewrite (stateless)
Removed self._trades, self._daily_returns, compute_metrics(), _compute_sharpe(),
check_promotion_thresholds(), _empty_metrics()
update_daily_summary() now reads compute_metrics_from_db() every cycle
Safe across pod restarts: always reflects full DB history
paper.py
close_position(): passes resolution to close_paper_position()
api/main.py /api/summary
Added unrealized_pnl_est (estimated, open trades) and realized_pnl (exact,
closed+resolved) as separate fields alongside total_pnl
win_rate: null if < 5 resolved trades (was proxy on entry_price < 0.5)
calibration_score: Brier-based, null if < 10 resolved trades
resolved_count exposed as field
Each field annotated with: exact/estimated, source, null conditions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
total_pnl now uses edge_net × net_cost instead of (0.5 - entry_price) × shares.
The old formula overestimated BUY_NO trades at low entry prices by 3–10× because
buying at price 0.158 yields 3164 shares — any exit-at-0.5 assumption produced
$1072 PnL on $500 deployed. edge_net × net_cost is bounded by net_cost per trade
and uses the model's own signal, giving $122 for the same position.
calibration_score is now None (null in API) instead of 1 - 2×|avg_edge|. That
formula was not a real calibration: it requires knowing market resolutions
(YES=1/NO=0) which we do not store yet. Returning null is more honest than
returning 0.0 or a meaningless proxy. Fix 3 will compute it from closed trades.
check_promotion_thresholds updated to handle None calibration (null → not ready).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- db: update_family_key() persists corrected family slugs for open trades
- db: get_recently_closed_inverted() returns markets closed for inversion
within N hours; used as reentry guard in the trading loop
- db: get_recent_trades() accepts status=open|closed|None and adds a
computed "status" field to every row
- bot/main.py: legacy scan now computes family_key from stored question
alone (dummy Market) when a position's market is no longer active —
fixes NULL family_key on legacy trades like Ken Paxton (562186)
- bot/main.py: legacy scan (Step 2.5) persists corrected family_keys in
DB so family conflict guards work correctly on next restart
- bot/main.py: positions with NULL edge_net and no live market are tagged
legacy_incomplete instead of OK; counted separately in scan summary
- bot/main.py: reentry_guard blocks re-entering any market closed for
inversion bug within 24h; logs reentry_guard_triggered per skip
- api/main.py: /api/trades now accepts ?status=open|closed|all (default
open) and includes status_filter in response
DB fix (applied directly): 629558 family_key politics-2026 →
ohio-gubernatorial-2026; 562186 family_key NULL → texas-republican-2026
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>