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_FEATURES explicitly excludes close_total_points, open_total_points, total_points_move
  • total_ridge_core feature list (in backtest.py) contains only opening-time features
  • update_movement_clv reads from a collected close snapshot, never from backtest history
  • total_ridge_market is documented as diagnostic-only and excluded from pre-game predictions
  • Leakage guard is enforced by test_no_closing_line_in_live_total_features in the test suite