@@ -0,0 +1,80 @@
|
||||
"""Unit tests for V38 live-conditioned projection (pure, no DB/model deps)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from models.live_matrix import (
|
||||
build_live_projection,
|
||||
estimate_minute,
|
||||
state_multiplier,
|
||||
)
|
||||
|
||||
|
||||
def _approx(a, b, tol=1e-6):
|
||||
return abs(a - b) <= tol
|
||||
|
||||
|
||||
def test_probs_form_distribution():
|
||||
proj = build_live_projection(0.50, 0.27, 0.23, 0.55, 1, 0, 60)
|
||||
p = proj["probs"]
|
||||
assert _approx(p["1"] + p["X"] + p["2"], 1.0, 1e-3)
|
||||
assert 0.0 <= proj["p_away_scores_again"] <= 1.0
|
||||
|
||||
|
||||
def test_minute_one_roughly_matches_prematch():
|
||||
# at 0-0 minute 1 the projection must stay close to the anchored numbers
|
||||
proj = build_live_projection(0.50, 0.27, 0.23, 0.55, 0, 0, 1)
|
||||
assert abs(proj["probs"]["1"] - 0.50) < 0.06
|
||||
assert abs(proj["probs"]["2"] - 0.23) < 0.06
|
||||
|
||||
|
||||
def test_one_goal_lead_at_80():
|
||||
# the user's exact case: 1-0 at 80' (OOS-validated: said 21.7 / actual 23.0)
|
||||
proj = build_live_projection(0.50, 0.27, 0.23, 0.55, 1, 0, 80)
|
||||
assert proj["probs"]["1"] > 0.72 # leader is now strong fav
|
||||
assert 0.08 <= proj["p_away_scores_again"] <= 0.30
|
||||
assert _approx(
|
||||
proj["p_comeback"], proj["probs"]["X"] + proj["probs"]["2"], 1e-9
|
||||
)
|
||||
|
||||
|
||||
def test_less_time_means_fewer_chances():
|
||||
early = build_live_projection(0.50, 0.27, 0.23, 0.55, 1, 0, 60)
|
||||
late = build_live_projection(0.50, 0.27, 0.23, 0.55, 1, 0, 85)
|
||||
assert late["p_away_scores_again"] < early["p_away_scores_again"]
|
||||
assert late["probs"]["1"] > early["probs"]["1"]
|
||||
|
||||
|
||||
def test_trailing_team_pushes_late():
|
||||
assert state_multiplier(-1, 80) > 1.05 # trailing by one, late: pushes
|
||||
assert state_multiplier(1, 80) < 1.0 # leading by one, late: parks bus
|
||||
assert state_multiplier(-1, 80) > state_multiplier(-1, 30)
|
||||
|
||||
|
||||
def test_score_consistency_with_current_score():
|
||||
proj = build_live_projection(0.50, 0.27, 0.23, 0.55, 2, 1, 75)
|
||||
# every scenario must be reachable from the current score
|
||||
for s in proj["scenario_top5"]:
|
||||
fh, fa = map(int, str(s["score"]).split("-"))
|
||||
assert fh >= 2 and fa >= 1
|
||||
assert proj["current_score"] == "2-1"
|
||||
|
||||
|
||||
def test_estimate_minute_approximation():
|
||||
now = 1_700_000_000_000
|
||||
assert estimate_minute(None, now) is None
|
||||
assert estimate_minute(now + 60_000, now) is None # not kicked off
|
||||
assert estimate_minute(now - 30 * 60_000, now) == 30 # mid 1H
|
||||
assert estimate_minute(now - 55 * 60_000, now) == 46 # HT break
|
||||
assert estimate_minute(now - 80 * 60_000, now) == 65 # 2H, break folded
|
||||
assert estimate_minute(now - 200 * 60_000, now) == 94 # capped
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||
for fn in fns:
|
||||
fn()
|
||||
print(f"PASS {fn.__name__}")
|
||||
print(f"\nAll {len(fns)} tests passed.")
|
||||
@@ -5,6 +5,9 @@ import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
# tests must be deterministic: never consult the DB source for corrections
|
||||
os.environ["MARKET_ANCHOR_DB"] = "0"
|
||||
|
||||
from models.market_anchor import devig, home_favorite_delta, apply_home_correction
|
||||
|
||||
|
||||
@@ -51,6 +54,83 @@ def test_apply_home_correction_keeps_distribution_valid():
|
||||
assert _approx(q[0], 0.30)
|
||||
|
||||
|
||||
def test_corrections_artifact_loaded_and_fallback():
|
||||
import json
|
||||
import tempfile
|
||||
from models import market_anchor as ma
|
||||
|
||||
# 1) valid artifact -> values come from the file
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", suffix=".json", delete=False, encoding="utf-8"
|
||||
) as fh:
|
||||
json.dump(
|
||||
{"version": "test", "corrections": {"ms_home": [
|
||||
{"lo": 0.60, "hi": 0.70, "delta": 0.042},
|
||||
]}},
|
||||
fh,
|
||||
)
|
||||
path = fh.name
|
||||
try:
|
||||
os.environ["MARKET_ANCHOR_CORRECTIONS_PATH"] = path
|
||||
ma.reload_corrections()
|
||||
assert _approx(ma.home_favorite_delta(0.65), 0.042)
|
||||
# band not in the artifact -> the STATIC PRIOR applies (silence must
|
||||
# not erase proven knowledge); 0.45-0.55 static prior is 0.010
|
||||
assert _approx(ma.home_favorite_delta(0.50), 0.010)
|
||||
|
||||
# 2) malformed artifact -> static fallback, never crashes
|
||||
with open(path, "w", encoding="utf-8") as fh2:
|
||||
fh2.write("{not json")
|
||||
ma.reload_corrections()
|
||||
assert ma.home_favorite_delta(0.65) > 0.0 # fallback band value
|
||||
assert _approx(ma.home_favorite_delta(0.65), 0.028)
|
||||
finally:
|
||||
os.environ.pop("MARKET_ANCHOR_CORRECTIONS_PATH", None)
|
||||
ma.reload_corrections()
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_away_corrections_only_from_artifact():
|
||||
import json
|
||||
import tempfile
|
||||
from models import market_anchor as ma
|
||||
|
||||
# without an artifact: away correction must be ZERO (earned, not assumed).
|
||||
# (Point the env path at a nonexistent file: the repo now SHIPS a fitted
|
||||
# artifact, so "no artifact" must be simulated explicitly.)
|
||||
os.environ["MARKET_ANCHOR_CORRECTIONS_PATH"] = os.path.join(
|
||||
os.path.dirname(__file__), "does_not_exist.json"
|
||||
)
|
||||
ma.reload_corrections()
|
||||
assert ma.away_favorite_delta(0.65) == 0.0
|
||||
base = ma.apply_corrections(0.20, 0.20, 0.60)
|
||||
assert _approx(base[2], 0.60) # away untouched without artifact
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", suffix=".json", delete=False, encoding="utf-8"
|
||||
) as fh:
|
||||
json.dump(
|
||||
{"version": "t2", "corrections": {
|
||||
"ms_home": [{"lo": 0.45, "hi": 0.55, "delta": 0.010}],
|
||||
"ms_away": [{"lo": 0.55, "hi": 0.65, "delta": 0.020}],
|
||||
}},
|
||||
fh,
|
||||
)
|
||||
path = fh.name
|
||||
try:
|
||||
os.environ["MARKET_ANCHOR_CORRECTIONS_PATH"] = path
|
||||
ma.reload_corrections()
|
||||
assert _approx(ma.away_favorite_delta(0.60), 0.020)
|
||||
p1, px, p2 = ma.apply_corrections(0.20, 0.20, 0.60)
|
||||
assert p2 > 0.60 # away favourite lifted
|
||||
assert _approx(p1 + px + p2, 1.0) # still a valid distribution
|
||||
assert p1 < 0.20 and px < 0.20 # others renormalised down
|
||||
finally:
|
||||
os.environ.pop("MARKET_ANCHOR_CORRECTIONS_PATH", None)
|
||||
ma.reload_corrections()
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||
for fn in fns:
|
||||
|
||||
Reference in New Issue
Block a user