Add Brettapps/trifecta-bro/v1 v1.0.0 trifecta predictor (rule-based)
Browse files- README.md +85 -0
- config.json +15 -0
- model_artifacts/model_artifact.json +16 -0
- push.py +64 -0
- pyproject.toml +16 -0
- trifecta_bro_v1/__init__.py +30 -0
- trifecta_bro_v1/__pycache__/__init__.cpython-313.pyc +0 -0
- trifecta_bro_v1/__pycache__/artifact.cpython-313.pyc +0 -0
- trifecta_bro_v1/__pycache__/main.cpython-313.pyc +0 -0
- trifecta_bro_v1/__pycache__/predictor.cpython-313.pyc +0 -0
- trifecta_bro_v1/artifact.py +102 -0
- trifecta_bro_v1/main.py +69 -0
- trifecta_bro_v1/predictor.py +157 -0
README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Trifecta-Bro v1 — Australian Gallops Trifecta Predictor
|
| 3 |
+
emoji: 🐎
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: "false"
|
| 7 |
+
tags:
|
| 8 |
+
- racing
|
| 9 |
+
- horse-racing
|
| 10 |
+
- australian-gallops
|
| 11 |
+
- trifecta
|
| 12 |
+
- prediction
|
| 13 |
+
- sports-betting
|
| 14 |
+
license: mit
|
| 15 |
+
library_name: other
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
# Brettapps/trifecta-bro-v1
|
| 19 |
+
|
| 20 |
+
**Model id:** `Brettapps/trifecta-bro/v1`
|
| 21 |
+
**Version:** 1.0.0
|
| 22 |
+
**Task:** Australian Gallops **trifecta prediction** (pick the top-3 finishers, in order).
|
| 23 |
+
|
| 24 |
+
Trifecta-Bro v1 is an open-source, multi-factor trifecta scorer for Australian
|
| 25 |
+
gallops. Given a race's field + form, it assigns each runner a 0–100 score and
|
| 26 |
+
emits a **primary**, **secondary**, and **value** trifecta combination, plus the
|
| 27 |
+
top-3 ranked runners with win/place probabilities.
|
| 28 |
+
|
| 29 |
+
> **Status:** v1 is a deterministic rule-based scorer. No supervised training
|
| 30 |
+
> was performed because the project has no historical race **results** (labels)
|
| 31 |
+
> yet. v2 will train a gradient-boosted / logistic model on observed outcomes
|
| 32 |
+
> once `data/results/` is populated.
|
| 33 |
+
|
| 34 |
+
## Method
|
| 35 |
+
|
| 36 |
+
For each runner, a weighted 0–100 score is computed from:
|
| 37 |
+
|
| 38 |
+
| Factor | Max weight |
|
| 39 |
+
|--------|-----------|
|
| 40 |
+
| Recent form (last 5 starts: 1/2/3 finishes) | 25 |
|
| 41 |
+
| Career overall win % | 20 |
|
| 42 |
+
| Career overall place % | 10 |
|
| 43 |
+
| Track strike rate (places/starts) | 10 |
|
| 44 |
+
| Distance strike rate | 8 |
|
| 45 |
+
| Condition strike rate (per going) | 8 |
|
| 46 |
+
| Barrier draw | 5 |
|
| 47 |
+
| Career prize money | 5 |
|
| 48 |
+
|
| 49 |
+
The three highest-scoring runners form the **primary** trifecta. A **secondary**
|
| 50 |
+
and **value** combination are derived from the next-best runners (with an
|
| 51 |
+
outsider angle when a score > 30 exists further down the field).
|
| 52 |
+
|
| 53 |
+
## Usage
|
| 54 |
+
|
| 55 |
+
```python
|
| 56 |
+
# Install from the Hub
|
| 57 |
+
# pip install huggingface_hub
|
| 58 |
+
from huggingface_hub import snapshot_download
|
| 59 |
+
path = snapshot_download("Brettapps/trifecta-bro-v1")
|
| 60 |
+
import sys; sys.path.insert(0, path)
|
| 61 |
+
|
| 62 |
+
from trifecta_bro_v1 import TrifectaPredictor, race_from_payload
|
| 63 |
+
|
| 64 |
+
payload = {...} # Trifecta-Bro race payload
|
| 65 |
+
race = race_from_payload(payload)
|
| 66 |
+
prediction = TrifectaPredictor().predict(race)
|
| 67 |
+
print(prediction["primary"], prediction["secondary"], prediction["value"])
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
Or run the bundled CLI:
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
python -m trifecta_bro_v1.main --data predictions-2026-08-10.json
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
## Artifact
|
| 77 |
+
|
| 78 |
+
`model_artifacts/model_artifact.json` documents the model method, feature
|
| 79 |
+
weights, and version — making the published model interpretable and reproducible.
|
| 80 |
+
|
| 81 |
+
## Backend note
|
| 82 |
+
|
| 83 |
+
This model is also wired as the `Brettapps/trifecta-bro/v1` identity in the
|
| 84 |
+
Trifecta-Bro LM Studio / Obsidian-vault backend. The HF-published code is the
|
| 85 |
+
canonical, dependency-light inference implementation.
|
config.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_id": "Brettapps/trifecta-bro/v1",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"task": "trifecta-prediction",
|
| 5 |
+
"domain": "australian-gallops",
|
| 6 |
+
"method": "multi-factor-rule-based-scoring",
|
| 7 |
+
"library_name": "other",
|
| 8 |
+
"license": "mit",
|
| 9 |
+
"dependencies": [],
|
| 10 |
+
"inference_entrypoints": [
|
| 11 |
+
"trifecta_bro_v1.predictor.TrifectaPredictor",
|
| 12 |
+
"trifecta_bro_v1.artifact.predict_race"
|
| 13 |
+
],
|
| 14 |
+
"artifact": "model_artifacts/model_artifact.json"
|
| 15 |
+
}
|
model_artifacts/model_artifact.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_id": "Brettapps/trifecta-bro/v1",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"method": "multi-factor rule-based scoring (0-100)",
|
| 5 |
+
"feature_weights": {
|
| 6 |
+
"recent_form": 25.0,
|
| 7 |
+
"overall_win_pct": 20.0,
|
| 8 |
+
"overall_place_pct": 10.0,
|
| 9 |
+
"track_strike": 10.0,
|
| 10 |
+
"distance_strike": 8.0,
|
| 11 |
+
"condition_strike": 8.0,
|
| 12 |
+
"barrier_draw": 5.0,
|
| 13 |
+
"career_prize": 5.0
|
| 14 |
+
},
|
| 15 |
+
"notes": "Open-source multi-factor trifecta scorer. No supervised training (no historical race results available in data/results/). Scores are deterministic given runner form/stat inputs. Future v2 will train a gradient-boosted model on observed outcomes."
|
| 16 |
+
}
|
push.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Publish Brettapps/trifecta-bro-v1 to the HuggingFace Hub.
|
| 3 |
+
|
| 4 |
+
Requires a VALID HuggingFace token, supplied via:
|
| 5 |
+
- HF_ACCESS_TOKEN / HF_TOKEN env var, or
|
| 6 |
+
- the cached `huggingface_hub` credentials (hf auth login)
|
| 7 |
+
|
| 8 |
+
This script ONLY uploads; it does not build the package (build is done via
|
| 9 |
+
save_artifact + the committed source files). Run after `hf auth login` succeeds.
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
python push.py # uses cached/ENV token
|
| 13 |
+
HF_ACCESS_TOKEN=hf_xxx python push.py
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
from huggingface_hub import HfApi, create_repo
|
| 23 |
+
|
| 24 |
+
REPO_ID = "Brettapps/trifecta-bro-v1"
|
| 25 |
+
REPO_TYPE = "model"
|
| 26 |
+
LOCAL_DIR = Path(__file__).resolve().parents[0]
|
| 27 |
+
ARTIFACT_DIR = LOCAL_DIR / "model_artifacts"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def main() -> int:
|
| 31 |
+
token = os.environ.get("HF_ACCESS_TOKEN") or os.environ.get("HF_TOKEN")
|
| 32 |
+
api = HfApi(token=token)
|
| 33 |
+
|
| 34 |
+
# Fail fast with a clear message if the token is invalid/expired.
|
| 35 |
+
try:
|
| 36 |
+
me = api.whoami()
|
| 37 |
+
print(f"Authenticated as: {me.get('name') or me.get('id')}")
|
| 38 |
+
except Exception as exc: # noqa: BLE001
|
| 39 |
+
print(f"ERROR: HuggingFace authentication failed: {exc}")
|
| 40 |
+
print("Fix: run `hf auth login` with a valid token, or set HF_ACCESS_TOKEN.")
|
| 41 |
+
return 1
|
| 42 |
+
|
| 43 |
+
# Ensure the repo exists under the authenticated namespace.
|
| 44 |
+
create_repo(
|
| 45 |
+
repo_id=REPO_ID,
|
| 46 |
+
repo_type=REPO_TYPE,
|
| 47 |
+
token=token,
|
| 48 |
+
exist_ok=True,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
print(f"Uploading {LOCAL_DIR} -> {REPO_ID}")
|
| 52 |
+
api.upload_folder(
|
| 53 |
+
folder_path=str(LOCAL_DIR),
|
| 54 |
+
repo_id=REPO_ID,
|
| 55 |
+
repo_type=REPO_TYPE,
|
| 56 |
+
commit_message="Add Brettapps/trifecta-bro/v1 v1.0.0 predictor",
|
| 57 |
+
token=token,
|
| 58 |
+
)
|
| 59 |
+
print(f"DONE: https://huggingface.co/{REPO_ID}")
|
| 60 |
+
return 0
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
if __name__ == "__main__":
|
| 64 |
+
raise SystemExit(main())
|
pyproject.toml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=61.0"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "trifecta-bro-v1"
|
| 7 |
+
version = "1.0.0"
|
| 8 |
+
description = "Australian Gallops trifecta predictor (Brettapps/trifecta-bro/v1)."
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
license = { text = "MIT" }
|
| 11 |
+
requires-python = ">=3.9"
|
| 12 |
+
keywords = ["horse-racing", "trifecta", "australian-gallops", "prediction"]
|
| 13 |
+
|
| 14 |
+
[tool.setuptools.packages.find]
|
| 15 |
+
where = ["."]
|
| 16 |
+
include = ["trifecta_bro_v1*"]
|
trifecta_bro_v1/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Brettapps/trifecta-bro-v1 — Australian Gallops trifecta predictor (v1)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from .artifact import (
|
| 6 |
+
ARTIFACT,
|
| 7 |
+
MODEL_ID,
|
| 8 |
+
ModelArtifact,
|
| 9 |
+
load_artifact,
|
| 10 |
+
predict_race,
|
| 11 |
+
race_from_payload,
|
| 12 |
+
save_artifact,
|
| 13 |
+
)
|
| 14 |
+
from .predictor import Race, Runner, TrifectaPredictor
|
| 15 |
+
|
| 16 |
+
__version__ = "1.0.0"
|
| 17 |
+
|
| 18 |
+
__all__ = [
|
| 19 |
+
"TrifectaPredictor",
|
| 20 |
+
"Race",
|
| 21 |
+
"Runner",
|
| 22 |
+
"MODEL_ID",
|
| 23 |
+
"ARTIFACT",
|
| 24 |
+
"ModelArtifact",
|
| 25 |
+
"save_artifact",
|
| 26 |
+
"load_artifact",
|
| 27 |
+
"predict_race",
|
| 28 |
+
"race_from_payload",
|
| 29 |
+
"__version__",
|
| 30 |
+
]
|
trifecta_bro_v1/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (732 Bytes). View file
|
|
|
trifecta_bro_v1/__pycache__/artifact.cpython-313.pyc
ADDED
|
Binary file (4.82 kB). View file
|
|
|
trifecta_bro_v1/__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (3.12 kB). View file
|
|
|
trifecta_bro_v1/__pycache__/predictor.cpython-313.pyc
ADDED
|
Binary file (8.94 kB). View file
|
|
|
trifecta_bro_v1/artifact.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Artifact save/load and model metadata for Brettapps/trifecta-bro-v1."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from dataclasses import asdict, dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from .predictor import Race, Runner, TrifectaPredictor
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
MODEL_ID = "Brettapps/trifecta-bro/v1"
|
| 14 |
+
MODEL_CARD_VERSION = "1.0.0"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class ModelArtifact:
|
| 19 |
+
model_id: str
|
| 20 |
+
version: str
|
| 21 |
+
method: str
|
| 22 |
+
feature_weights: dict[str, float]
|
| 23 |
+
notes: str
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# The "learned" configuration of the rule-based model. Documented explicitly so
|
| 27 |
+
# the published artifact is interpretable and reproducible.
|
| 28 |
+
ARTIFACT = ModelArtifact(
|
| 29 |
+
model_id=MODEL_ID,
|
| 30 |
+
version=MODEL_CARD_VERSION,
|
| 31 |
+
method="multi-factor rule-based scoring (0-100)",
|
| 32 |
+
feature_weights={
|
| 33 |
+
"recent_form": 25.0,
|
| 34 |
+
"overall_win_pct": 20.0,
|
| 35 |
+
"overall_place_pct": 10.0,
|
| 36 |
+
"track_strike": 10.0,
|
| 37 |
+
"distance_strike": 8.0,
|
| 38 |
+
"condition_strike": 8.0,
|
| 39 |
+
"barrier_draw": 5.0,
|
| 40 |
+
"career_prize": 5.0,
|
| 41 |
+
},
|
| 42 |
+
notes=(
|
| 43 |
+
"Open-source multi-factor trifecta scorer. No supervised training "
|
| 44 |
+
"(no historical race results available in data/results/). Scores are "
|
| 45 |
+
"deterministic given runner form/stat inputs. Future v2 will train a "
|
| 46 |
+
"gradient-boosted model on observed outcomes."
|
| 47 |
+
),
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def save_artifact(out_dir: str | Path) -> Path:
|
| 52 |
+
out_dir = Path(out_dir)
|
| 53 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
path = out_dir / "model_artifact.json"
|
| 55 |
+
path.write_text(json.dumps(asdict(ARTIFACT), indent=2))
|
| 56 |
+
return path
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def load_artifact(path: str | Path) -> ModelArtifact:
|
| 60 |
+
data = json.loads(Path(path).read_text())
|
| 61 |
+
return ModelArtifact(**data)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def predict_race(race: Race) -> dict[str, Any]:
|
| 65 |
+
"""Convenience: run the packaged predictor on a Race."""
|
| 66 |
+
return TrifectaPredictor().predict(race)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def race_from_payload(payload: dict[str, Any]) -> Race:
|
| 70 |
+
"""Build a Race from the Trifecta-Bro predictions JSON shape."""
|
| 71 |
+
form = payload.get("form", {})
|
| 72 |
+
runners = [
|
| 73 |
+
Runner(
|
| 74 |
+
number=r.get("number", 0),
|
| 75 |
+
name=r.get("name", ""),
|
| 76 |
+
jockey=r.get("jockey", ""),
|
| 77 |
+
trainer=r.get("trainer", ""),
|
| 78 |
+
weight=r.get("weight"),
|
| 79 |
+
barrier=r.get("barrier"),
|
| 80 |
+
form=r.get("form", ""),
|
| 81 |
+
last20Starts=r.get("last20Starts", ""),
|
| 82 |
+
careerPrizeMoney=r.get("careerPrizeMoney", "$0"),
|
| 83 |
+
scratched=bool(r.get("scratched", False)),
|
| 84 |
+
stats=r.get("stats", {}),
|
| 85 |
+
)
|
| 86 |
+
for r in form.get("runners", [])
|
| 87 |
+
]
|
| 88 |
+
return Race(
|
| 89 |
+
date=payload.get("date", ""),
|
| 90 |
+
track=payload.get("track", ""),
|
| 91 |
+
track_slug=payload.get("track_slug", ""),
|
| 92 |
+
race_number=str(payload.get("race_number", "")),
|
| 93 |
+
race_name=payload.get("race_name", ""),
|
| 94 |
+
distance=payload.get("distance", ""),
|
| 95 |
+
condition=payload.get("condition", ""),
|
| 96 |
+
weather=payload.get("weather", ""),
|
| 97 |
+
race_class=payload.get("race_class", ""),
|
| 98 |
+
start_time=payload.get("start_time", ""),
|
| 99 |
+
prize_money=payload.get("prize_money", ""),
|
| 100 |
+
number_of_runners=int(payload.get("number_of_runners", 0) or 0),
|
| 101 |
+
runners=runners,
|
| 102 |
+
)
|
trifecta_bro_v1/main.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""CLI for Brettapps/trifecta-bro-v1.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
# Run on the bundled sample race
|
| 6 |
+
python -m trifecta_bro_v1.main
|
| 7 |
+
|
| 8 |
+
# Run on a Trifecta-Bro predictions JSON file
|
| 9 |
+
python -m trifecta_bro_v1.main --data /path/to/predictions-2026-08-10.json
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
from .artifact import predict_race, race_from_payload
|
| 20 |
+
from .predictor import TrifectaPredictor
|
| 21 |
+
|
| 22 |
+
# A tiny self-contained demo race so the package is runnable with no inputs.
|
| 23 |
+
SAMPLE = {
|
| 24 |
+
"date": "2026-08-10",
|
| 25 |
+
"track": "Dubbo",
|
| 26 |
+
"track_slug": "dubbo",
|
| 27 |
+
"race_number": "3",
|
| 28 |
+
"race_name": "Aqua West Country Boosted BM58 Handicap",
|
| 29 |
+
"distance": "1620m",
|
| 30 |
+
"condition": "Soft 5",
|
| 31 |
+
"weather": "Showers",
|
| 32 |
+
"race_class": "BM58",
|
| 33 |
+
"start_time": "2026-08-10T04:40:00Z",
|
| 34 |
+
"prize_money": "30000",
|
| 35 |
+
"number_of_runners": 3,
|
| 36 |
+
"form": {
|
| 37 |
+
"runners": [
|
| 38 |
+
{"number": 1, "name": "Boncapo", "form": "X1241", "stats": {"overall": {"winPercent": 0.25, "placePercent": 0.5}}},
|
| 39 |
+
{"number": 14, "name": "Bill Peyto", "form": "36412", "stats": {"overall": {"winPercent": 0.07, "placePercent": 0.35}}},
|
| 40 |
+
{"number": 7, "name": "Casterly Rock", "form": "23939", "stats": {"overall": {"winPercent": 0.06, "placePercent": 0.48}}},
|
| 41 |
+
]
|
| 42 |
+
},
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def main(argv: list[str] | None = None) -> int:
|
| 47 |
+
p = argparse.ArgumentParser(description="Brettapps/trifecta-bro/v1 predictor")
|
| 48 |
+
p.add_argument("--data", help="Path to a Trifecta-Bro predictions JSON file")
|
| 49 |
+
p.add_argument("--race", type=int, default=0, help="1-based race index when --data is given")
|
| 50 |
+
args = p.parse_args(argv)
|
| 51 |
+
|
| 52 |
+
if args.data:
|
| 53 |
+
payload = json.loads(Path(args.data).read_text())
|
| 54 |
+
races = payload.get("races", [payload])
|
| 55 |
+
idx = max(0, args.race - 1)
|
| 56 |
+
if idx >= len(races):
|
| 57 |
+
print(f"Race index {args.race} out of range (have {len(races)})", file=sys.stderr)
|
| 58 |
+
return 2
|
| 59 |
+
race = race_from_payload(races[idx])
|
| 60 |
+
else:
|
| 61 |
+
race = race_from_payload(SAMPLE)
|
| 62 |
+
|
| 63 |
+
pred = TrifectaPredictor().predict(race)
|
| 64 |
+
print(json.dumps(pred, indent=2))
|
| 65 |
+
return 0
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
if __name__ == "__main__":
|
| 69 |
+
raise SystemExit(main())
|
trifecta_bro_v1/predictor.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Trifecta-Bro v1 — open-source multi-factor trifecta predictor.
|
| 2 |
+
|
| 3 |
+
Model id: Brettapps/trifecta-bro/v1 (HF repo: Brettapps/trifecta-bro-v1)
|
| 4 |
+
|
| 5 |
+
This is the standalone inference module for the HuggingFace model repo. It is a
|
| 6 |
+
self-contained, dependency-light implementation of the trifecta prediction logic
|
| 7 |
+
(rule-based multi-factor scoring). It mirrors `src/prediction_model.py` in the
|
| 8 |
+
Trifecta-Bro Space so the published model is directly usable without cloning the
|
| 9 |
+
whole Space.
|
| 10 |
+
|
| 11 |
+
Load it:
|
| 12 |
+
from trifecta_bro_v1.predictor import TrifectaPredictor, Race, Runner
|
| 13 |
+
pred = TrifectaPredictor().predict(race)
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class Runner:
|
| 24 |
+
number: int
|
| 25 |
+
name: str
|
| 26 |
+
jockey: str = ""
|
| 27 |
+
trainer: str = ""
|
| 28 |
+
weight: float | None = None
|
| 29 |
+
barrier: int | None = None
|
| 30 |
+
form: str = ""
|
| 31 |
+
last20Starts: str = ""
|
| 32 |
+
careerPrizeMoney: str = "$0"
|
| 33 |
+
scratched: bool = False
|
| 34 |
+
stats: dict[str, Any] = field(default_factory=dict)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class Race:
|
| 39 |
+
date: str
|
| 40 |
+
track: str
|
| 41 |
+
track_slug: str
|
| 42 |
+
race_number: str
|
| 43 |
+
race_name: str
|
| 44 |
+
distance: str
|
| 45 |
+
condition: str
|
| 46 |
+
weather: str
|
| 47 |
+
race_class: str
|
| 48 |
+
start_time: str
|
| 49 |
+
prize_money: str
|
| 50 |
+
number_of_runners: int
|
| 51 |
+
runners: list[Runner] = field(default_factory=list)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class TrifectaPredictor:
|
| 55 |
+
"""Open-source trifecta prediction model.
|
| 56 |
+
|
| 57 |
+
Scores each runner on a 0-100 scale using recent form, career/overall
|
| 58 |
+
win & place percentages, track/distance/condition strike rates, barrier
|
| 59 |
+
draw, and career prize money. The top three by score form the primary
|
| 60 |
+
trifecta; secondary and value bets are derived from the next-best runners.
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
MODEL_ID = "Brettapps/trifecta-bro/v1"
|
| 64 |
+
|
| 65 |
+
def predict(self, race: Race) -> dict[str, Any]:
|
| 66 |
+
runners = [r for r in race.runners if not r.scratched]
|
| 67 |
+
if len(runners) < 3:
|
| 68 |
+
return {"error": "Insufficient runners"}
|
| 69 |
+
|
| 70 |
+
scored: list[dict[str, Any]] = []
|
| 71 |
+
for runner in runners:
|
| 72 |
+
scored.append({
|
| 73 |
+
"number": runner.number,
|
| 74 |
+
"name": runner.name,
|
| 75 |
+
"score": self._score_runner(runner),
|
| 76 |
+
"win_prob": self._win_probability(runner),
|
| 77 |
+
"place_prob": self._place_probability(runner),
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
scored.sort(key=lambda x: x["score"], reverse=True)
|
| 81 |
+
top = scored[:3]
|
| 82 |
+
primary = f"{top[0]['number']}-{top[1]['number']}-{top[2]['number']}"
|
| 83 |
+
|
| 84 |
+
secondary = None
|
| 85 |
+
value = None
|
| 86 |
+
if len(scored) > 3:
|
| 87 |
+
secondary = f"{top[0]['number']}-{top[2]['number']}-{scored[3]['number']}"
|
| 88 |
+
outsiders = [s for s in scored[3:] if s["score"] > 30]
|
| 89 |
+
if outsiders:
|
| 90 |
+
value = f"{scored[1]['number']}-{top[0]['number']}-{outsiders[0]['number']}"
|
| 91 |
+
else:
|
| 92 |
+
value = f"{scored[1]['number']}-{top[0]['number']}-{top[2]['number']}"
|
| 93 |
+
|
| 94 |
+
return {
|
| 95 |
+
"model_id": self.MODEL_ID,
|
| 96 |
+
"date": race.date,
|
| 97 |
+
"track": race.track,
|
| 98 |
+
"race_number": race.race_number,
|
| 99 |
+
"race_name": race.race_name,
|
| 100 |
+
"primary": primary,
|
| 101 |
+
"secondary": secondary,
|
| 102 |
+
"value": value,
|
| 103 |
+
"top3": top,
|
| 104 |
+
"confidence": "MEDIUM",
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
def _score_runner(self, runner: Runner) -> float:
|
| 108 |
+
score = 0.0
|
| 109 |
+
form = str(runner.form or runner.last20Starts or "")
|
| 110 |
+
recent = form[-5:] if len(form) > 5 else form
|
| 111 |
+
score += min((recent.count("1") * 8 + recent.count("2") * 4 + recent.count("3") * 4), 25)
|
| 112 |
+
|
| 113 |
+
overall = runner.stats.get("overall", {})
|
| 114 |
+
win_pct = overall.get("winPercent", 0) or 0
|
| 115 |
+
place_pct = overall.get("placePercent", 0) or 0
|
| 116 |
+
score += win_pct * 20
|
| 117 |
+
score += place_pct * 10
|
| 118 |
+
|
| 119 |
+
track_stats = runner.stats.get("track", {})
|
| 120 |
+
track_starts = track_stats.get("starts", 0) or 0
|
| 121 |
+
track_places = track_stats.get("places", 0) or 0
|
| 122 |
+
score += min((track_places / max(track_starts, 1)) * 10, 10)
|
| 123 |
+
|
| 124 |
+
dist_stats = runner.stats.get("distance", {})
|
| 125 |
+
dist_starts = dist_stats.get("starts", 0) or 0
|
| 126 |
+
dist_places = dist_stats.get("places", 0) or 0
|
| 127 |
+
score += min((dist_places / max(dist_starts, 1)) * 8, 8)
|
| 128 |
+
|
| 129 |
+
cond_stats = runner.stats.get("conditions", {})
|
| 130 |
+
for _key, data in cond_stats.items():
|
| 131 |
+
c_starts = data.get("starts", 0) or 0
|
| 132 |
+
c_places = data.get("places", 0) or 0
|
| 133 |
+
score += min((c_places / max(c_starts, 1)) * 8, 8)
|
| 134 |
+
|
| 135 |
+
try:
|
| 136 |
+
barrier = int(runner.barrier) if runner.barrier else 5
|
| 137 |
+
score += max(0, 5 - abs(barrier - 5))
|
| 138 |
+
except Exception:
|
| 139 |
+
score += 3
|
| 140 |
+
|
| 141 |
+
try:
|
| 142 |
+
prize = float(str(runner.careerPrizeMoney).replace("$", "").replace(",", ""))
|
| 143 |
+
score += min(prize / 20000, 5)
|
| 144 |
+
except Exception:
|
| 145 |
+
pass
|
| 146 |
+
|
| 147 |
+
return min(round(score, 1), 100)
|
| 148 |
+
|
| 149 |
+
def _win_probability(self, runner: Runner) -> float:
|
| 150 |
+
overall = runner.stats.get("overall", {})
|
| 151 |
+
win_pct = overall.get("winPercent", 0) or 0
|
| 152 |
+
return min(max(round(win_pct * 100, 1), 0), 100)
|
| 153 |
+
|
| 154 |
+
def _place_probability(self, runner: Runner) -> float:
|
| 155 |
+
overall = runner.stats.get("overall", {})
|
| 156 |
+
place_pct = overall.get("placePercent", 0) or 0
|
| 157 |
+
return min(max(round(place_pct * 100, 1), 0), 100)
|