Total Points Over/Under Edge
What the total market is
The total points (over/under) market lets you bet on whether the combined score of both teams exceeds or falls short of a bookmaker-set line. A line of 43.5 means you bet Over (total > 43.5) or Under (total < 43.5) at roughly equal prices.
NRL totals average ~42–48 points per game with a standard deviation of ~13 points, meaning the outcome is highly variable. The ridge model MAE is ~10 pts — roughly 1.5 converted tries of error — so the model alone has limited signal. The current edge hypothesis is not the model recommendation in isolation, but the odds-movement signal.
Why total_ridge_core alone is not enough
Backtest result (edge_capped policy, ~1,812 bets):
| Model | Bets | Profit | ROI |
|---|---|---|---|
| total_ridge_core | 1,812 | −$17.17 | −0.6% |
| total_ridge_market | 723 | +$0.88 | +0.1% |
total_ridge_core is slightly negative long-run. Do not promote it as a standalone edge. It is used only as an agreement filter — if the model and the movement signal both point the same direction, that is a stronger candidate than movement alone.
total_ridge_market looks better on paper but uses close_total_points and close_over_implied_prob — closing-line features. It is a closing-time diagnostic only, not actionable at bet time. It is excluded from all pre-game recommendations.
Why movement_total_2pt_v1 is the current candidate
Historical context from docs/model-edge-status.md:
Key coefficient on
total_ridge_market:close_total_points(+2.23),total_points_move(+1.23). The model is finding value relative to the opening line when the closing line has moved. This signal is only visible after market close.
This suggests total line movement carries information. The hypothesis: when the total line moves ≥ 2 pts, sharp money has pushed it. Betting the direction of movement (steam) should capture CLV.
Strategy definition:
total_move = latest_total_line − open_total_line
if total_move ≥ 2.0 → candidate: OVER
if total_move ≤ −2.0 → candidate: UNDER
otherwise → no trade
This is a paper-trade candidate only. No real money until promotion gates are met.
CLV interpretation for total market
For total markets, positive line CLV means we got a better number than the market closed at:
| Side | Positive CLV condition |
|---|---|
| Over | closing_line > taken_line (market moved further up after we bet — we had the lower/easier over line) |
| Under | closing_line < taken_line (market moved further down after we bet — we had the higher/easier under line) |
This is opposite in sign from spread/h2h CLV. The _total_side_clv() function handles this correctly. Positive average CLV-line is the primary validation metric.
Live paper-trading workflow
Each round:
# 1. Collect opening snapshot (Monday/Tuesday)
python scripts/collect_nrl_odds_snapshot.py --season 2026 --round <ROUND> --snapshot-label open
# 2. Collect mid-week refresh
python scripts/collect_nrl_odds_snapshot.py --season 2026 --round <ROUND> --snapshot-label refresh
# 3. Detect movement edges and write paper trades
python scripts/detect_nrl_movement_edges.py --season 2026 --round <ROUND>
# Optional: annotate model agreement
python scripts/detect_nrl_movement_edges.py --season 2026 --round <ROUND> --with-model-agreement
# 4. Collect closing snapshot (within ~1 hour of kickoff)
python scripts/collect_nrl_odds_snapshot.py --season 2026 --round <ROUND> --snapshot-label close
# 5. Update CLV from close snapshot
python scripts/update_nrl_movement_clv.py --season 2026 --round <ROUND>
# 6. Settle results after the round
python scripts/update_nrl_movement_clv.py --season 2026 --round <ROUND> --settle
# 7. Report
python scripts/report_nrl_movement_edges.py --market total
python scripts/report_nrl_movement_edges.py --market total --all-trades
The 6-hourly cron (run_odds_snapshot.sh) handles steps 1–2 automatically. Steps 3–7 are manual.
Model agreement filter
When running with --with-model-agreement, detect_nrl_movement_edges.py loads the live total ridge model and annotates each paper trade with:
| Field | Description |
|---|---|
model_predicted_total |
Predicted total from total_ridge_core (leakage-safe) |
model_side |
Model's preferred side (over/under) based on predicted vs taken_line |
model_agrees_with_movement |
True when model direction matches movement direction |
model_edge_points |
abs(predicted_total − taken_line) |
This filter is not required to trigger a paper trade — all ≥2pt moves are recorded regardless. It is used in reporting to split performance:
python scripts/report_nrl_movement_edges.py --market total
Which shows: by movement bucket / by side / by model agreement / by season / by round.
Promotion gates
These gates must ALL be met before any real betting is considered:
| Gate | Threshold | Why |
|---|---|---|
| Sample size | ≥ 100 live/paper trades | Minimum for statistical reliability |
| ROI | ≥ +5% | Covers bookmaker margin (~2–3%) with buffer |
| Average CLV-line | > 0 | Positive CLV means we're beating closing line consistently |
| Positive CLV rate | > 50% | Majority of trades should be ahead of close |
| Season split | Positive in ≥ 2 of last 3 seasons | Guards against single-season variance |
| Forward split | Positive in live-forward period (post-discovery) | Guards against in-sample overfitting |
| No closing-line leakage | Confirmed by test suite | LIVE_TOTAL_FEATURES must not contain close fields |
Do not promote based on any single metric. Do not claim a real edge until all gates pass.
Report as: "movement_total_2pt_v1 is a hypothesis / paper-trade candidate."
Do NOT use words like "proven edge", "guaranteed profit", or "bet now."
Leakage status
Confirmed clean as of 2026-06-25:
LIVE_TOTAL_FEATURESexplicitly excludesclose_total_points,open_total_points,total_points_movetotal_ridge_corefeature list (inbacktest.py) contains only opening-time featuresupdate_movement_clvreads from a collectedclosesnapshot, never from backtest historytotal_ridge_marketis documented as diagnostic-only and excluded from pre-game predictions- Leakage guard is enforced by
test_no_closing_line_in_live_total_featuresin the test suite