feat(replay): R2 outcomes + calibration metrics
Scores every archived estimate against reality — the sample multiplier the phase plan calls for: Brier/log-loss of estimated_prob benchmarked against the market price (prior_prob) on the same rows, over ALL evaluations with a resolved outcome, not just executed trades. - schema.sql: market_outcomes (one row per resolved market; outcome = final YES price 1.0/0.0, UMA-final only) - bot/outcomes.py: CLI (python -m bot.outcomes) with two phases — fetch resolutions for archived markets via the existing get_market_resolution() (open/disputed/ambiguous markets simply retry next invocation; no data-loss urgency, Gamma reports past resolutions at any time), then compute calibration: Brier micro (per evaluation) / macro (per market — the honest sample size given ~1 eval/min autocorrelation), log-loss with 1e-9 clipping, per-category breakdown. --run-id scores a replay run's re-estimates instead of the archive (counterfactual calibration). - db.py: 4 accessors (pending markets, outcome upsert, coverage, calibration rows for archive or run) - tests: 12 new (116 total green); compute_calibration is a pure function driven by literals No prod behavior change: the bot never imports bot.outcomes; the only shared surface is the idempotent schema migration (dry-run BEGIN/ROLLBACK clean against prod). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6c544e46e2
commit
124b6d8558
@@ -0,0 +1,174 @@
|
||||
"""Replay R2 tests — outcome fetching and calibration scoring."""
|
||||
import asyncio
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from bot.data.polymarket import MarketResolution
|
||||
from bot.outcomes import (
|
||||
LOGLOSS_EPS,
|
||||
compute_calibration,
|
||||
fetch_outcomes,
|
||||
print_report,
|
||||
)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class FakePoly:
|
||||
"""get_market_resolution stand-in driven by a dict of canned responses."""
|
||||
|
||||
def __init__(self, responses: dict):
|
||||
self.responses = responses
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def get_market_resolution(self, market_id: str):
|
||||
self.calls.append(market_id)
|
||||
return self.responses.get(market_id)
|
||||
|
||||
|
||||
RESOLVED_AT = datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _row(market_id="m1", category="politics", est=0.6, prior=0.5, outcome=1.0):
|
||||
return {
|
||||
"market_id": market_id,
|
||||
"category": category,
|
||||
"estimated_prob": est,
|
||||
"prior_prob": prior,
|
||||
"outcome": outcome,
|
||||
}
|
||||
|
||||
|
||||
# ── fetch_outcomes ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_fetch_keeps_only_definitive_resolutions():
|
||||
poly = FakePoly({
|
||||
"yes": MarketResolution(resolved=True, resolution=1.0,
|
||||
resolved_at=RESOLVED_AT),
|
||||
"no": MarketResolution(resolved=True, resolution=0.0,
|
||||
resolved_at=None),
|
||||
"open": MarketResolution(resolved=False),
|
||||
"disputed": MarketResolution(resolved=False),
|
||||
"apierror": None, # get_market_resolution returns None on HTTP errors
|
||||
})
|
||||
out = asyncio.run(
|
||||
fetch_outcomes(poly, ["yes", "no", "open", "disputed", "apierror"])
|
||||
)
|
||||
assert poly.calls == ["yes", "no", "open", "disputed", "apierror"]
|
||||
assert out == [
|
||||
{"market_id": "yes", "outcome": 1.0, "resolved_at": RESOLVED_AT},
|
||||
{"market_id": "no", "outcome": 0.0, "resolved_at": None},
|
||||
]
|
||||
|
||||
|
||||
def test_fetch_empty_list_is_noop():
|
||||
poly = FakePoly({})
|
||||
assert asyncio.run(fetch_outcomes(poly, [])) == []
|
||||
assert poly.calls == []
|
||||
|
||||
|
||||
# ── compute_calibration ──────────────────────────────────────────────────────
|
||||
|
||||
def test_no_rows_returns_none():
|
||||
assert compute_calibration([]) is None
|
||||
|
||||
|
||||
def test_single_row_known_values():
|
||||
m = compute_calibration([_row(est=0.8, prior=0.6, outcome=1.0)])
|
||||
assert m["n_evaluations"] == 1
|
||||
assert m["n_markets"] == 1
|
||||
assert m["brier_model"] == pytest.approx((0.8 - 1.0) ** 2)
|
||||
assert m["brier_prior"] == pytest.approx((0.6 - 1.0) ** 2)
|
||||
assert m["logloss_model"] == pytest.approx(-math.log(0.8))
|
||||
assert m["logloss_prior"] == pytest.approx(-math.log(0.6))
|
||||
# one market: macro == micro
|
||||
assert m["brier_model_macro"] == pytest.approx(m["brier_model"])
|
||||
assert m["brier_prior_macro"] == pytest.approx(m["brier_prior"])
|
||||
|
||||
|
||||
def test_logloss_no_outcome_branch():
|
||||
m = compute_calibration([_row(est=0.2, prior=0.7, outcome=0.0)])
|
||||
assert m["logloss_model"] == pytest.approx(-math.log(0.8))
|
||||
assert m["logloss_prior"] == pytest.approx(-math.log(0.3))
|
||||
|
||||
|
||||
def test_logloss_clipping_keeps_hard_miss_finite():
|
||||
# A hard 1.0 estimate on a NO outcome must not produce inf.
|
||||
m = compute_calibration([_row(est=1.0, prior=0.5, outcome=0.0)])
|
||||
assert math.isfinite(m["logloss_model"])
|
||||
assert m["logloss_model"] == pytest.approx(-math.log(LOGLOSS_EPS))
|
||||
|
||||
|
||||
def test_micro_weights_evaluations_macro_weights_markets():
|
||||
# Market a: 3 evaluations, model error 0.1; market b: 1 evaluation, error 0.5.
|
||||
rows = [
|
||||
_row(market_id="a", est=0.9, prior=0.8, outcome=1.0),
|
||||
_row(market_id="a", est=0.9, prior=0.8, outcome=1.0),
|
||||
_row(market_id="a", est=0.9, prior=0.8, outcome=1.0),
|
||||
_row(market_id="b", est=0.5, prior=0.6, outcome=1.0),
|
||||
]
|
||||
m = compute_calibration(rows)
|
||||
assert m["n_evaluations"] == 4
|
||||
assert m["n_markets"] == 2
|
||||
# micro: (3*0.01 + 0.25) / 4 ; macro: (0.01 + 0.25) / 2
|
||||
assert m["brier_model"] == pytest.approx((3 * 0.01 + 0.25) / 4)
|
||||
assert m["brier_model_macro"] == pytest.approx((0.01 + 0.25) / 2)
|
||||
assert m["brier_prior"] == pytest.approx((3 * 0.04 + 0.16) / 4)
|
||||
assert m["brier_prior_macro"] == pytest.approx((0.04 + 0.16) / 2)
|
||||
|
||||
|
||||
def test_model_beating_market_gives_negative_delta():
|
||||
# est closer to the outcome than the price on every row
|
||||
rows = [
|
||||
_row(market_id="a", est=0.8, prior=0.6, outcome=1.0),
|
||||
_row(market_id="b", est=0.3, prior=0.45, outcome=0.0),
|
||||
]
|
||||
m = compute_calibration(rows)
|
||||
assert m["brier_model"] < m["brier_prior"]
|
||||
assert m["logloss_model"] < m["logloss_prior"]
|
||||
|
||||
|
||||
def test_per_category_grouping_and_unknown():
|
||||
rows = [
|
||||
_row(market_id="a", category="politics", est=0.8, prior=0.6, outcome=1.0),
|
||||
_row(market_id="b", category="politics", est=0.7, prior=0.6, outcome=1.0),
|
||||
_row(market_id="c", category=None, est=0.4, prior=0.5, outcome=0.0),
|
||||
]
|
||||
m = compute_calibration(rows)
|
||||
assert set(m["per_category"]) == {"politics", "unknown"}
|
||||
pol = m["per_category"]["politics"]
|
||||
assert pol["n"] == 2 and pol["markets"] == 2
|
||||
assert pol["brier_model"] == pytest.approx((0.04 + 0.09) / 2)
|
||||
unk = m["per_category"]["unknown"]
|
||||
assert unk["n"] == 1 and unk["markets"] == 1
|
||||
assert unk["brier_model"] == pytest.approx(0.16)
|
||||
|
||||
|
||||
def test_repeated_market_counts_once_in_markets():
|
||||
rows = [
|
||||
_row(market_id="a", est=0.8, prior=0.6, outcome=1.0),
|
||||
_row(market_id="a", est=0.7, prior=0.55, outcome=1.0),
|
||||
]
|
||||
m = compute_calibration(rows)
|
||||
assert m["n_markets"] == 1
|
||||
assert m["per_category"]["politics"]["markets"] == 1
|
||||
|
||||
|
||||
# ── print_report ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_report_handles_no_metrics(capsys):
|
||||
print_report(None, "R0 archive")
|
||||
assert "no scorable rows yet" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_report_prints_all_metric_lines(capsys):
|
||||
m = compute_calibration([
|
||||
_row(market_id="a", est=0.8, prior=0.6, outcome=1.0),
|
||||
_row(market_id="b", category=None, est=0.4, prior=0.5, outcome=0.0),
|
||||
])
|
||||
print_report(m, "R0 archive")
|
||||
out = capsys.readouterr().out
|
||||
assert "2 evaluations, 2 markets" in out
|
||||
for label in ("Brier micro", "Brier macro", "logloss micro",
|
||||
"politics", "unknown"):
|
||||
assert label in out
|
||||
Reference in New Issue
Block a user