Spaces:
Configuration error
Configuration error
Download tests/test_predictor.py from Brettapps/Trifecta-Lab: direct link, hf CLI and curl.
- Browser
- Download file 11.5 kB
-
https://huggingface.co/spaces/Brettapps/Trifecta-Lab/resolve/main/tests/test_predictor.py
- Command line
-
hf download hf://spaces/Brettapps/Trifecta-Lab/tests/test_predictor.py
-
curl -L -o test_predictor.py https://huggingface.co/spaces/Brettapps/Trifecta-Lab/resolve/main/tests/test_predictor.py
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) | |