Trifecta-Lab / tests /test_predictor.py
Brettapps's picture
Upload folder using huggingface_hub (part 21)
e23172f verified
Raw History Blame Contribute Delete
11.5 kB
"""Unit tests for the trifecta predictor. All tests run without live API access.
Run: python -m unittest discover -s tests
"""
from __future__ import annotations
import os
import sys
import unittest
from pathlib import Path
# Make package importable
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from trifecta_bro.config import settings # noqa: E402
from trifecta_bro.data.normalizer import normalise_race, normalise_meeting # noqa: E402
from trifecta_bro.model.feature_engine import ( # noqa: E402
parse_form_positions, form_quality_score, spell_count, is_fresh_after_spell)
from trifecta_bro.model.pace_analysis import classify_pace, pace_adjustment # noqa: E402
from trifecta_bro.model.scoring import score_runner, load_weights # noqa: E402
from trifecta_bro.model.probability import ( # noqa: E402
generate_trifecta, compute_confidence, softmax_probabilities)
from trifecta_bro.model.analyse_race import analyse_race # noqa: E402
from trifecta_bro.data.storage import Storage # noqa: E402
from trifecta_bro.evaluation.backtester import Backtester, _parse_actual # noqa: E402
from tests.mock_formfav import ( # noqa: E402
meetings_payload, race_form_full, race_form_with_scratch_and_missing,
race_form_abandoned)
DB_PATH = ROOT / "data" / "test_trincta_bro.db"
def fresh_storage() -> Storage:
if DB_PATH.exists():
DB_PATH.unlink()
return Storage(db_path=DB_PATH)
class TestDateLogic(unittest.TestCase):
def test_tomorrow_australia(self):
from trifecta_bro.jobs.daily_prediction import tomorrow_australia
# deterministic check: a known now -> next day
result = tomorrow_australia()
self.assertEqual(len(result), 10)
self.assertRegex(result, r"^\d{4}-\d{2}-\d{2}$")
class TestFormParsing(unittest.TestCase):
def test_positions_basic(self):
self.assertEqual(parse_form_positions("12345"), [1, 2, 3, 4, 5])
def test_x_is_spell_not_position(self):
pos = parse_form_positions("1x231")
self.assertEqual(pos, [1, None, 2, 3, 1])
def test_zero_is_tenth_or_worse(self):
pos = parse_form_positions("10")
self.assertEqual(pos, [1, 0])
def test_spell_count_and_fresh(self):
self.assertEqual(spell_count("1x23x"), 2)
self.assertTrue(is_fresh_after_spell("123x"))
self.assertFalse(is_fresh_after_spell("x123"))
class TestFormQuality(unittest.TestCase):
def test_strong_recent_form_scores_high(self):
score, diag = form_quality_score("11122")
self.assertGreater(score, 70)
self.assertGreater(diag["consistency"], 0.8)
def test_poor_form_scores_low(self):
score, _ = form_quality_score("88880")
self.assertLess(score, 40)
def test_spell_does_not_count_as_position(self):
score_with_spell, _ = form_quality_score("1x231")
score_no_spell, _ = form_quality_score("12231")
# spell slightly lowers availability but shouldn't be treated as a 10th
self.assertLess(score_with_spell, score_no_spell + 1)
def test_empty_form(self):
score, diag = form_quality_score("")
self.assertEqual(score, 0.0)
self.assertIn("no form data", diag["reason"])
class TestPace(unittest.TestCase):
def test_classify_and_roles(self):
race = normalise_race(race_form_full())
pace = classify_pace(race, race.active_runners)
self.assertIn(pace["label"], {"SLOW", "MODERATE", "FAST", "VERY FAST"})
self.assertIn(1, pace["roles"])
def test_leader_advantaged_in_slow(self):
from trifecta_bro.data.models import RaceModel, RunnerModel
r = RunnerModel(number=1, barrier=1, win_percent=20.0)
slow = {"label": "SLOW", "roles": {1: "leader/on-speed"}}
self.assertGreater(pace_adjustment(r, slow), 0)
def test_leader_penalised_in_fast(self):
from trifecta_bro.data.models import RunnerModel
r = RunnerModel(number=1, barrier=1, win_percent=20.0)
fast = {"label": "FAST", "roles": {1: "leader/on-speed"}}
self.assertLess(pace_adjustment(r, fast), 0)
class TestScoring(unittest.TestCase):
def test_weights_load_and_sums(self):
w = load_weights()
self.assertAlmostEqual(sum(w.values()), 1.0, places=2)
def test_score_runner_runs(self):
race = normalise_race(race_form_full())
pace = classify_pace(race, race.active_runners)
w = load_weights()
res = score_runner(race.active_runners[0], race, pace, w)
self.assertIn("score", res)
self.assertGreaterEqual(res["score"], 0)
self.assertLessEqual(res["score"], 100)
def test_missing_fields_graceful(self):
race = normalise_race(race_form_with_scratch_and_missing())
pace = classify_pace(race, race.active_runners)
w = load_weights()
# Ghost runner has almost no data; should not crash and returns a score
ghost = [r for r in race.active_runners if r.name == "Ghost"][0]
res = score_runner(ghost, race, pace, w)
self.assertIsInstance(res["score"], float)
class TestScratchedAbandoned(unittest.TestCase):
def test_scratched_excluded(self):
race = normalise_race(race_form_with_scratch_and_missing())
numbers = [r.number for r in race.active_runners]
self.assertNotIn(4, numbers) # scratched
self.assertIn(8, numbers)
def test_abandoned_flag(self):
race = normalise_race(race_form_abandoned())
self.assertTrue(race.abandoned)
def test_missing_form_falls_back(self):
race = normalise_race(race_form_with_scratch_and_missing())
only = [r for r in race.active_runners if r.name == "OnlyOne"][0]
self.assertTrue(only.form) # derived from last20Starts
class TestTrifectaGen(unittest.TestCase):
def test_generate_trifecta(self):
race = normalise_race(race_form_full())
a = analyse_race(race)
self.assertEqual(len(a["trifecta"]), 3)
self.assertCountEqual(a["top3"], a["trifecta"])
self.assertIsInstance(a["alternatives"], list)
def test_probabilities_sum_to_one(self):
probs = softmax_probabilities([50, 60, 70], temperature=12.0)
self.assertAlmostEqual(sum(probs), 1.0, places=3)
def test_insufficient_runners_skips(self):
# build a 2-runner race
payload = race_form_full()
payload["runners"] = payload["runners"][:2]
race = normalise_race(payload)
a = analyse_race(race)
self.assertTrue(a.get("skippable"))
class TestConfidence(unittest.TestCase):
def test_confidence_categories(self):
race = normalise_race(race_form_full())
a = analyse_race(race)
self.assertIn(a["confidence"]["category"], {"HIGH", "MEDIUM", "LOW", "AVOID"})
def test_avoid_on_weak_leader(self):
from trifecta_bro.model.probability import compute_confidence
# fake ranking with weak leader
ranked = [{"number": 1, "score": 12.0}, {"number": 2, "score": 11.0}]
conf = compute_confidence(ranked, 0.9, {"label": "SLOW"}, {})
self.assertEqual(conf["category"], "AVOID")
class TestValidation(unittest.TestCase):
def test_meetings_validation_flags_error(self):
from trifecta_bro.api.validator import validate_meetings
issues = validate_meetings({"foo": 1})
self.assertTrue(any(i.level == "error" for i in issues))
def test_race_form_validation(self):
from trifecta_bro.api.validator import validate_race_form
issues = validate_race_form(race_form_full())
self.assertFalse(any(i.level == "error" for i in issues))
class TestStorage(unittest.TestCase):
def setUp(self):
self.store = fresh_storage()
def tearDown(self):
self.store.close()
if DB_PATH.exists():
DB_PATH.unlink()
def test_upsert_and_read(self):
race = normalise_race(race_form_full())
rid = self.store.upsert_race(race)
self.assertGreater(rid, 0)
# save a prediction
self.store.save_prediction({
"date": race.date, "track": race.track, "track_slug": race.track_slug,
"race_number": race.race_number, "model_version": "1.0.0",
"generated_at": "2026-08-11T00:00:00Z", "top3": [3, 1, 2],
"trifecta": [3, 1, 2], "alternatives": [[1, 3, 2]],
"confidence": "MEDIUM", "confidence_score": 55,
"ranked_runners": [{"no": 3, "name": "x", "score": 70}],
"data_completeness": 0.9,
})
preds = self.store.get_predictions(race.date)
self.assertEqual(len(preds), 1)
self.assertEqual(preds[0]["trifecta"], [3, 1, 2])
def test_save_result(self):
race = normalise_race(race_form_full())
self.store.upsert_race(race)
self.store.save_result(race.date, race.track, race.track_slug, race.race_number, [3, 1, 2])
# re-saving is idempotent
self.store.save_result(race.date, race.track, race.track_slug, race.race_number, [3, 1, 2])
class TestBacktester(unittest.TestCase):
def test_parse_actual_variants(self):
self.assertEqual(_parse_actual("1,7,9"), [1, 7, 9])
self.assertEqual(_parse_actual([4, 5, 6]), [4, 5, 6])
self.assertIsNone(_parse_actual(""))
self.assertIsNone(_parse_actual(None))
def test_backtest_no_leakage(self):
race = normalise_race(race_form_full())
# analyse WITHOUT actuals first
a = analyse_race(race)
pred = a["trifecta"]
# now compute metrics against actual — actual never fed to model
bt = Backtester()
result = bt.evaluate([race], {("dubbo", 1): [3, 1, 2]})
rec = result["records"][0]
self.assertTrue(rec["scored"])
# exact hit only if prediction matched the provided actual
self.assertEqual(rec["exact_hit"], (pred == [3, 1, 2]))
# metrics present
self.assertIn("exact_trifecta_hit_rate", result)
self.assertIn("top1_accuracy", result)
def test_backtest_partial_coverage(self):
race = normalise_race(race_form_full())
bt = Backtester()
result = bt.evaluate([race], {("dubbo", 1): [9, 9, 9]}) # none match
rec = result["records"][0]
self.assertEqual(rec["partial3"], 0)
class TestEndToEndMocked(unittest.TestCase):
def test_full_pipeline_with_fake_client(self):
from trifecta_bro.api.formfav_client import FormFavClient
class FakeClient(FormFavClient):
def get_meetings(self, date, country=None, race_code=None):
return meetings_payload(date)["meetings"]
def get_race_form(self, date, track, race, country=None, race_code=None, timezone=None):
if track == "dubbo" and race == 1:
return race_form_full(date, track, race)
if track == "dubbo" and race == 2:
return race_form_abandoned(date, track, race)
if track == "randwick" and race == 7:
return race_form_with_scratch_and_missing(date, track, race)
raise FileNotFoundError("no mock")
# exercise analyse_race for each race form directly
for fn in (race_form_full, race_form_with_scratch_and_missing, race_form_abandoned):
race = normalise_race(fn())
a = analyse_race(race)
self.assertIn("confidence", a)
if __name__ == "__main__":
unittest.main(verbosity=2)