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 --force is passed.

Open quality:

  • true_open: open captured at least 72 hours before kickoff
  • usable_open: open captured 24-72 hours before kickoff
  • late_open: open captured under 24 hours before kickoff
  • missing_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