Odds Movement Monitor
Tracks NRL market movement across the week by taking repeated odds snapshots, detecting line/price shifts that suggest sharp-money activity, and recording paper trade candidates with close-line value attribution for paper/admin testing.
Purpose
Hypothesis: when the NRL market moves significantly (≥2 pts on a total or spread, ≥3% probability shift on H2H) before kickoff, the move is often sharp-money driven and provides a directional signal. Paper-trading the moved side with CLV measurement allows us to validate or refute this before risking real money.
Use words like candidate, hypothesis, paper trade, and signal. Do not claim a proven edge until a strategy has ≥100 settled bets with positive CLV and positive ROI.
Tables
Both tables live in duckdb/nrl_bucket.duckdb alongside existing staging tables.
movement_odds_snapshots
Append-only raw per-bookmaker snapshot rows.
| Column | Type | Notes |
|---|---|---|
season |
INTEGER | NRL season year |
round |
INTEGER | Round number |
snapshot_label |
VARCHAR | open / refresh / close |
source |
VARCHAR | Data provider, e.g. odds_api |
event_id |
VARCHAR | The Odds API event ID |
home_team |
VARCHAR | Normalised team name |
away_team |
VARCHAR | Normalised team name |
kickoff_utc |
TIMESTAMPTZ | Game start time |
bookmaker |
VARCHAR | Bookmaker key |
market_type |
VARCHAR | h2h / line / total |
side |
VARCHAR | home / away / over / under |
line |
DOUBLE | Handicap/total threshold (NULL for h2h) |
price |
DOUBLE | Decimal odds |
captured_at_utc |
TIMESTAMPTZ | When snapshot was taken |
Raw storage is append-only. Exact duplicate rows are skipped by (event_id, bookmaker, market_type, side, captured_at_utc, season, round).
Snapshot-label rules:
open: first capture only per(season, round, event_id, bookmaker, market_type, side). Existing open keys are skipped; newly available markets/sides can still be added later.refresh: always appends the current market, apart from exact duplicate safety.close: rolling pre-kickoff capture for games currently 45-75 minutes from kickoff by default. Duplicate close keys are skipped unless--forceis passed.
Open quality:
true_open: open captured at least 72 hours before kickoffusable_open: open captured 24-72 hours before kickofflate_open: open captured under 24 hours before kickoffmissing_open: no explicit open snapshot for the event
movement_paper_trades
One row per (strategy_id, season, round, event_id, market_type, side). Primary key is a 16-char SHA-256 hash of those six fields.
Notable columns: taken_line, taken_price, open_line, open_price, line_move_points, abs_line_move_points, movement_strength_bucket, hours_since_open, bookmaker_count, closing_price, closing_line, clv_price, clv_line, status, profit.
Scripts
collect_nrl_odds_snapshot.py
Fetches current NRL odds from The Odds API and appends to movement_odds_snapshots.
python scripts/collect_nrl_odds_snapshot.py --season 2026 --round 16 --snapshot-label open
python scripts/collect_nrl_odds_snapshot.py --season 2026 --round 16 --snapshot-label open --only-missing-open
python scripts/collect_nrl_odds_snapshot.py --season 2026 --round 16 --snapshot-label refresh
python scripts/collect_nrl_odds_snapshot.py --season 2026 --round 16 --snapshot-label close
Flags: --bookmakers, --only-missing-open, --kickoff-window-minutes-min, --kickoff-window-minutes-max, --force, --dry-run, --duckdb-path.
detect_nrl_movement_edges.py
Compares the explicit open snapshot against the latest non-close snapshot per event/market/side and writes paper trade candidates. late_open rows are excluded by default so an arbitrary first refresh cannot become the opening point.
python scripts/detect_nrl_movement_edges.py --season 2026 --round 16
python scripts/detect_nrl_movement_edges.py --season 2026 --round 16 --dry-run
python scripts/detect_nrl_movement_edges.py --season 2026 --round 16 --include-late-open
Idempotent — re-running never creates duplicates.
update_nrl_movement_clv.py
Populates closing_price, closing_line, clv_price, clv_line from the close snapshot. Optionally settles trades where game scores are available.
python scripts/update_nrl_movement_clv.py --season 2026 --round 16
python scripts/update_nrl_movement_clv.py --season 2026 --round 16 --settle
Run this after close snapshots have been collected for the relevant games.
report_nrl_movement_edges.py
Prints a per-strategy P&L and CLV summary.
python scripts/report_nrl_movement_edges.py
python scripts/report_nrl_movement_edges.py --season 2026 --round 16
python scripts/report_nrl_movement_edges.py --season 2026 --all-trades
report_nrl_odds_snapshot_health.py
Reports missing open/close snapshots, open quality, snapshot counts, and bookmaker coverage.
python scripts/report_nrl_odds_snapshot_health.py --season 2026 --round 16
Strategies
movement_total_2pt_v1
- Market: totals
- Trigger:
abs(latest_total_line - open_total_line) ≥ 2.0 pts, min 2 bookmakers - Trade: follow steam — bet OVER if total moved UP, UNDER if moved DOWN
- Hypothesis: steam on totals predicts the final direction more often than not
movement_spread_2pt_v1
- Market: spreads (line)
- Trigger:
abs(latest_home_line - open_home_line) ≥ 2.0 pts, min 2 bookmakers - Trade: home line DOWN (more negative) → bet HOME; home line UP → bet AWAY
- Hypothesis: spread steam follows sharp knowledge of lineup/travel/weather
movement_h2h_3pct_v1
- Market: H2H
- Trigger: no-vig probability shift ≥ 3% for either team, min 2 bookmakers
- Trade: bet the team whose no-vig probability increased
- Hypothesis: large H2H probability shifts precede genuine favouritism changes
Movement strength buckets
| Bucket | abs_line_move_points |
|---|---|
no_move |
0.0 |
0-1 |
> 0.0 and ≤ 1.0 |
1-2 |
> 1.0 and ≤ 2.0 |
2-4 |
> 2.0 and ≤ 4.0 |
4+ |
> 4.0 |
CLV definitions
clv_line= side-specific taken line versus event close (positive = we got a better number)clv_price=taken_price / closing_price − 1(positive = we got longer odds than close)
The closing_price/closing_line are populated from event-specific close snapshots. Never use closing values as model inputs pre-kickoff — they are post-hoc measurement only.
VM cron shape
Current VM working directory:
cd "/home/nitro/nitro-repos/scrapers/NRL Data Scraping Pipeline/NRL Data Scraping Pipeline"
. .venv/bin/activate
Example cron entries while proving the VM workflow:
# Hourly — fill missing open keys only. Safe to run often; existing open keys are skipped.
0 * * * * cd "/home/nitro/nitro-repos/scrapers/NRL Data Scraping Pipeline/NRL Data Scraping Pipeline" && . .venv/bin/activate && python scripts/collect_nrl_odds_snapshot.py --season 2026 --round 16 --snapshot-label open --only-missing-open >> logs/odds_monitor.log 2>&1
# Every 6 hours — refresh snapshots for movement testing.
5 */6 * * * cd "/home/nitro/nitro-repos/scrapers/NRL Data Scraping Pipeline/NRL Data Scraping Pipeline" && . .venv/bin/activate && python scripts/collect_nrl_odds_snapshot.py --season 2026 --round 16 --snapshot-label refresh >> logs/odds_monitor.log 2>&1
# After refresh — detect paper movement candidates. Idempotent.
15 */6 * * * cd "/home/nitro/nitro-repos/scrapers/NRL Data Scraping Pipeline/NRL Data Scraping Pipeline" && . .venv/bin/activate && python scripts/detect_nrl_movement_edges.py --season 2026 --round 16 >> logs/odds_monitor.log 2>&1
# Every 10 minutes — close scanner captures games 45-75 minutes from kickoff.
*/10 * * * * cd "/home/nitro/nitro-repos/scrapers/NRL Data Scraping Pipeline/NRL Data Scraping Pipeline" && . .venv/bin/activate && python scripts/collect_nrl_odds_snapshot.py --season 2026 --round 16 --snapshot-label close >> logs/odds_monitor.log 2>&1
# Hourly — update CLV and settle completed paper trades where scores are available.
30 * * * * cd "/home/nitro/nitro-repos/scrapers/NRL Data Scraping Pipeline/NRL Data Scraping Pipeline" && . .venv/bin/activate && python scripts/update_nrl_movement_clv.py --season 2026 --round 16 --settle >> logs/odds_monitor.log 2>&1
# Daily — snapshot health check.
45 7 * * * cd "/home/nitro/nitro-repos/scrapers/NRL Data Scraping Pipeline/NRL Data Scraping Pipeline" && . .venv/bin/activate && python scripts/report_nrl_odds_snapshot_health.py --season 2026 --round 16 >> logs/odds_monitor.log 2>&1
API quota note: The Odds API free tier is 500 requests/month (~17/day). Three markets (h2h+spreads+totals) count as 1 request per fetch. Running every 6 hours = 4 fetches/day = ~120/month, well within quota.
After this VM workflow is proven, the same command sequence can move to go-cicd-ops CronJobs. Do not introduce Kubernetes automation until the VM capture cadence and health reports look reliable.
Kubernetes CronJob path
For production / cloud deployment, the odds monitor runs as a Kubernetes CronJob.
scripts/run_odds_monitor.py is the entrypoint — it runs one collect+detect cycle and exits.
Image
# Build (from repo root)
docker build -t nrl-odds-monitor:latest .
# Local smoke-test with bind-mount (run twice to prove persistence)
export ODDS_API_LIMIT_500MONTHLY=<key>
docker run --rm \
-e ODDS_API_LIMIT_500MONTHLY="$ODDS_API_LIMIT_500MONTHLY" \
-e DUCKDB_PATH=/data/test.duckdb \
-e ODDS_MONITOR_SEASON=2026 \
-e ODDS_MONITOR_ROUND=18 \
-e ODDS_MONITOR_SNAPSHOT_LABEL=refresh \
-v /tmp/odds-docker-test:/data \
nrl-odds-monitor:latest
# Run again — row count should double (140 rows, 2 distinct captured_at_utc values)
Environment variables
| Variable | Default | Notes |
|---|---|---|
ODDS_API_KEY |
— | Required. The Odds API key (from k8s secret). Also accepts ODDS_API_LIMIT_500MONTHLY. |
DUCKDB_PATH |
/data/nrl_bucket.duckdb |
Path inside the mounted PVC. |
ODDS_MONITOR_SEASON |
current year | NRL season. |
ODDS_MONITOR_ROUND |
auto-detect | Round number. If unset, queries gold_nrl_draw_latest for the next upcoming round. |
ODDS_MONITOR_SNAPSHOT_LABEL |
refresh |
open / refresh / close. |
ODDS_API_BOOKMAKERS |
betfair_ex_au,pointsbetau,sportsbet |
Comma-separated bookmaker keys. |
CronJob manifest (example)
apiVersion: batch/v1
kind: CronJob
metadata:
name: nrl-odds-monitor
spec:
schedule: "0 */6 * * 1-5" # every 6 hours Mon-Fri
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: odds-monitor
image: nrl-odds-monitor:latest
env:
- name: DUCKDB_PATH
value: /data/nrl_bucket.duckdb
- name: ODDS_MONITOR_SNAPSHOT_LABEL
value: refresh
- name: ODDS_API_KEY
valueFrom:
secretKeyRef:
name: odds-api
key: key
volumeMounts:
- name: odds-data
mountPath: /data
volumes:
- name: odds-data
persistentVolumeClaim:
claimName: nrl-odds-data
Exit codes
| Code | Meaning |
|---|---|
| 0 | Both collect and detect succeeded |
| 1 | Collect step failed (check ODDS_API_KEY and quota) |
| 2 | Detect step failed (check DuckDB path or round auto-detect) |
Verification checklist (VM)
# 1. Confirm snapshot stored
python -c "
import duckdb
con = duckdb.connect('duckdb/nrl_bucket.duckdb', read_only=True)
print(con.execute(\"SELECT season, round, snapshot_label, COUNT(*) FROM movement_odds_snapshots GROUP BY ALL ORDER BY 1,2,3\").fetchdf())
"
# 2. Check paper trades
python scripts/report_nrl_movement_edges.py --season 2026 --all-trades
# 3. Check snapshot health
python scripts/report_nrl_odds_snapshot_health.py --season 2026 --round 16
# 4. Run tests
cd "/home/nitro/nitro-repos/scrapers/NRL Data Scraping Pipeline/NRL Data Scraping Pipeline"
. .venv/bin/activate
python -m unittest tests.test_odds_movement_monitor
Module structure
nrl-bet-advisor/data/odds_movement.py # Core: DDL, storage, features, detection, CLV, report
scripts/collect_nrl_odds_snapshot.py # Fetch + store snapshot
scripts/detect_nrl_movement_edges.py # Detect + write paper trades
scripts/update_nrl_movement_clv.py # Populate CLV + optional settle
scripts/report_nrl_movement_edges.py # Print P&L / CLV summary
scripts/report_nrl_odds_snapshot_health.py # Print snapshot coverage / quality
tests/test_odds_movement_monitor.py # Unit tests (in-memory DuckDB)
docs/odds-movement-monitor.md # This file
docs/edge-5-movement-refinement-v1.md # Strategy hypothesis and promotion gates