NRL Operator Runbook
Scope
This runbook assumes the repo root is the canonical working directory on the Ubuntu VM.
Current operating context for the current-season flow: June 2026, Round 14.
It covers:
- one-time historical backfill for completed seasons
2016through2025 - one-time
2026recent-round catch-up - weekly regular-season updates for
2026 - separate monthly
playerssnapshots for2026 - a local metadata catalog for quick bucket coverage checks
- weekly finals updates for
2026 - separate scrape and stage phases using the existing bucket-first architecture
- optional dbt and Elementary data-quality build for draw, ladder, players, and stats gold models after staging
- root-level commands as the supported operating pattern for this repo
Preflight
From repo root:
source .venv/bin/activate
python -c "import duckdb,boto3,scrapy; print('duckdb', duckdb.__version__); print('boto3 ok'); print('scrapy ok')"
python test_spaces.py --check-vars
python test_spaces.py
Install dbt and Elementary dependencies when you want to build the analytics models and local data-quality report:
pip install -r analytics/dbt_nrl/requirements-dbt.txt
Before any scrape batch that should write to Spaces:
export NRL_UPLOAD_ENABLED=true
Canonical working directory for every command below:
source .venv/bin/activate
Important Rules
- Keep
competition=111. - Keep historical
2016-2025backfill separate from the in-progress2026workflow. - Do not use
--stage-after-runfor operator batches. Scrape first, then stage explicitly. - For seasonal
statsscraping, omit--statso the spider collects all stat categories for that season. - Current-season
2026 statsruns are YTD snapshots. They do not reconstruct prior weekly2026states unless the scraper logic is extended. - Weekly
2026 statsruns are point-in-time YTD checkpoints because the spider is called without--stat. - Current-season
2026 drawandladdershould use a one-time last-five-round catch-up, then continue with one weekly round input at a time rather than a full season reconstruction. - Current-season
2026 playersis a season snapshot dataset, not a round-based dataset. Keep it in catch-up, then refresh it with a separate monthly operator run. - Close DBeaver before any staging step that writes
duckdb/nrl_bucket.duckdb. - Preserve raw and staged history, but use
stg_nrl_*_latestviews orops_nrl_run_inventory.is_latest_run = truewhen you want deduped analytics. - Run dbt only after staged Parquet exists. dbt reads
artifacts/stage_localand writes silver/gold draw, ladder, players, and stats tables toduckdb/nrl_bucket.duckdb. - The dashboard Events tab now reads one unified event flow for upcoming, live, and completed matches; completed recommendation badges are derived from the recommended side versus the final score.
- Stats gold dedupe grain is
season + stat_category + player/team key + metric key, with the latest canonical row chosen byscraped_at_utc desc, thenrun_id desc, thenrank_position asc.
Historical Backfill: 2016-2025
Recommended operator sequence
From the repo root:
export NRL_UPLOAD_ENABLED=true
./scripts/backfill_nrl_2016_2025.sh
What this does:
players: one run per seasonstats: one run per season with--competition 111 --year <YEAR>and no--statdraw: one run per round per seasonladder: one run per round per season
Round stop rule
The backfill script loops rounds 1..40 by default.
Primary stop condition:
drawwrites0meaningful data rowsladderwrites0meaningful data rows
Fallback stop condition for site clamping:
drawrepeats the same meaningful data rows as the prior roundladderrepeats the same meaningful data rows as the prior round
This keeps the operator flow simple and avoids needing perfect season-length metadata upfront.
The wider upper bound is intentional because finals can still appear as late round values, and out-of-range rounds can clamp to the terminal finals state instead of returning empty output.
Equivalent manual command shapes
For one season YEAR:
python run_spiders.py --spider players --competition 111 --year "$YEAR" --load-type historical
python run_spiders.py --spider stats --competition 111 --year "$YEAR" --load-type historical
python run_spiders.py --spider draw --competition 111 --year "$YEAR" --round "$ROUND" --load-type historical
python run_spiders.py --spider ladder --competition 111 --year "$YEAR" --round "$ROUND" --load-type historical
Historical staging
After the raw backfill scrape is complete and DBeaver is closed:
./scripts/stage_nrl_datasets.sh historical
Then build the dbt gold models and local docs:
bash scripts/dbt_build_nrl.sh --docs
To also refresh the Elementary local data-quality report:
bash scripts/dbt_dq_report_nrl.sh
Direct per-dataset alternatives:
python scripts/stage_bucket_runs.py --dataset draw
python scripts/stage_bucket_runs.py --dataset ladder
python scripts/stage_bucket_runs.py --dataset players
python scripts/stage_bucket_runs.py --dataset stats
Use the direct per-dataset commands when you want tighter control or to rerun a single dataset.
Current-Season Workflow: 2026
Treat 2026 as a separate current-season operator flow.
Do not reuse the 2016-2025 historical round sweep for 2026 draw.
Choosing the round value
Use the round currently published on nrl.com for the refresh you want to capture:
- regular season: use the currently published regular-season round page
- finals: use the currently published finals round page
The draw and ladder scrapers are being used here as a recent-round catch-up plus ongoing weekly refresh flow, not as a full 2026 round reconstruction.
The players scraper remains current-season snapshot style and is intentionally kept outside the weekly round wrapper.
One-time 2026 catch-up
Run this once when establishing or repairing the current-season starting point on the VM.
As of May 8, 2026, the target is to catch up the latest five published rounds for the round-based datasets and then take fresh current-season snapshots for the season-based datasets.
Recommended wrapper:
export NRL_UPLOAD_ENABLED=true
bash scripts/catch_up_2026_recent_rounds.sh <CURRENT_ROUND>
Equivalent manual sequence:
CURRENT_ROUND=<CURRENT_ROUND>
START_ROUND=$((CURRENT_ROUND - 4))
if [ "$START_ROUND" -lt 1 ]; then START_ROUND=1; fi
for ROUND in $(seq "$START_ROUND" "$CURRENT_ROUND"); do
python run_spiders.py --spider draw --competition 111 --year 2026 --round "$ROUND" --load-type weekly
python run_spiders.py --spider ladder --competition 111 --year 2026 --round "$ROUND" --load-type weekly
done
python run_spiders.py --spider players --competition 111 --year 2026 --load-type weekly
python run_spiders.py --spider stats --competition 111 --year 2026 --load-type weekly
What this catch-up means:
draw: one raw run per round across the latest five published roundsladder: one raw run per round across the latest five published roundsplayers: one2026current-season snapshotstats: one2026all-category YTD snapshot because--statis omitted- the catch-up script stages with
historicalmode at the end so all newly caught-up round grains land in DuckDB and Spaces, not just the latest round
Repairing a specific 2026 round gap
Use this when the metadata catalog shows a hole inside the current-season round sequence, for example rounds 2-5 missing while later rounds are already present.
Recommended wrapper:
export NRL_UPLOAD_ENABLED=true
bash scripts/backfill_2026_round_range.sh <START_ROUND> <END_ROUND>
export NRL_UPLOAD_ENABLED=true
bash scripts/backfill_2026_round_range.sh 2 6
Round-gap example for rounds 2-5:
export NRL_UPLOAD_ENABLED=true
bash scripts/backfill_2026_round_range.sh 2 5
What this repair means:
- it reruns only
drawandladder, because those are the round-grain datasets - it does not rerun
playersorstats, because those are season snapshots and do not need one run per round - it stages with
historicalmode at the end so each repaired round stays queryable in DuckDB and Spaces
Weekly regular-season run
Once the catch-up exists, use the weekly wrapper for in-season refreshes:
export NRL_UPLOAD_ENABLED=true
bash scripts/weekly_2026_round.sh <PUBLISHED_REGULAR_SEASON_ROUND>
What this weekly run is for:
draw: refresh the current-season round page so the prior week's completed results are reflected in the latest snapshotladder: refresh standingsstats: refresh the2026all-category YTD snapshot with no--statstage: run weekly staging automatically at the end after reminding the operator to close DBeaver
This is a refresh flow, not a repeated full reconstruction of every 2026 round.
Round 10 example while the season is partway through:
export NRL_UPLOAD_ENABLED=true
bash scripts/weekly_2026_round.sh 10
This captures the current published state of round 10 for draw and ladder, plus the current 2026 stats YTD snapshot.
After weekly staging completes, refresh the dbt gold tables:
bash scripts/dbt_build_nrl.sh
Run the visual data-quality report when you want a local HTML view of dbt test health:
bash scripts/dbt_dq_report_nrl.sh
Cluster task wrapper
Use scripts/run_cluster_task.sh as the stable command surface for scheduled
pipeline tasks. Future Kubernetes CronJobs should call this wrapper rather than
embedding long scrape, dbt, odds, or prediction command sequences in YAML.
Validate the runtime first:
bash scripts/run_cluster_task.sh validate-env
Weekly ingest for a published round:
export NRL_UPLOAD_ENABLED=true
bash scripts/run_cluster_task.sh weekly-ingest --season 2026 --round 18
Six-hour odds refresh:
bash scripts/run_cluster_task.sh odds-refresh --season 2026 --round 18
Close odds for the explicit current round:
bash scripts/run_cluster_task.sh odds-close --season 2026 --round 18
If odds-close is run without --round, the wrapper prints that kickoff-aware
close scheduling is not implemented yet and does not fake round detection.
Prediction generation:
bash scripts/run_cluster_task.sh predict --season 2026 --round 18
predict is diagnostic-only. It does not seed paper trades or publish
recommendations. The authoritative publication command is:
bash scripts/run_cluster_task.sh recommend --season 2026 --round 18 --market all
recommend reconciles Kubernetes manifests, stages them into the VM-owned
DuckDB, runs dbt and the recommendation DQ gate, and only then predicts,
seeds paper trades, and publishes artifacts. A failed, pending, zero-row, or
missing reconciliation blocks the command.
Odds refresh and close collection are bucket-first and upload immutable tick
objects plus _run.json manifests to DigitalOcean Spaces. Kubernetes pods are
stateless collectors; the VM is the single DuckDB writer. Use
--local-only only for an explicitly disposable diagnostic run.
Canonical cluster runtime contract
The wrapper is the operational entry point for Kubernetes CronJobs and VM
workers. Pods collect immutable objects; the VM stages and publishes the
DuckDB snapshot.
| Task | Required timing/ownership | Command |
|---|---|---|
| odds-open | Opening collection, idempotent per match/market | run_cluster_task.sh odds-open --season 2026 --round N |
| odds-refresh | Scheduled refresh collection and movement detection | run_cluster_task.sh odds-refresh --season 2026 --round N |
| odds-close | T-45 to T-75 close collection and CLV update | run_cluster_task.sh odds-close --season 2026 --round N |
| teamlist-window | Team-list timing observation | run_cluster_task.sh teamlist-window --season 2026 --round N |
| recommend | Reconcile, stage, dbt, pregame gate, then publish | run_cluster_task.sh recommend --season 2026 --round N |
| reconcile | Reconcile manifests and stage pending runs | run_cluster_task.sh reconcile --season 2026 [--round N] |
| publish-ui-snapshot | Publish verified versioned DB and stable pointer | run_cluster_task.sh publish-ui-snapshot --season 2026 [--round N] |
Use dq-gate --gate-mode pregame for recommendation eligibility,
--gate-mode close-audit for the per-match close window audit, and
--gate-mode settlement only after close data exists. A pregame gate does not
require close snapshots. --event-id scopes close-audit/settlement to one
match grain.
Bucket configuration uses NRL_BUCKET_NAME, NRL_BUCKET_ENDPOINT,
NRL_BUCKET_REGION, NRL_BUCKET_ACCESS_KEY, and NRL_BUCKET_SECRET_KEY.
The older DO_SPACES_* and SPACES_* names remain read-only compatibility
aliases. Never print credential values.
Close runs with no eligible rows are successful zero-row manifests with
record_count: 0 and object_keys: []; they must not create a ticks object.
Non-empty runs publish a ticks object and manifest containing record count,
checksum, season, round, snapshot label/context, collector, Kubernetes/VM
context, source commit, image reference/digest, market, and task type.
UI snapshot publication and recovery
publish-ui-snapshot uploads the versioned object first:
sync/duckdb/nrl_bucket.duckdb/run_id=<run_id>/nrl_bucket.duckdb. Only after
that succeeds does it update the stable pointer
sync/duckdb/nrl_bucket.duckdb._run.json. The pointer includes the source
commit, image reference/digest, timestamp, byte size, and SHA-256 checksum.
Readers validate the pointer, download the versioned object, and reject a
checksum or required-table mismatch. If publication fails, rerun the publisher;
the previous stable pointer remains authoritative.
Promotion assurance
Promotion requires an explicit market-only baseline. If the baseline has no
usable profit/stake evidence, the evaluator reports insufficient_evidence
and promotion is not treated as proven. The strategy UI displays this status.
Data-quality report:
bash scripts/run_cluster_task.sh dq-report
For command previews that do not call APIs, DuckDB, or dbt:
DRY_RUN=true bash scripts/run_cluster_task.sh weekly-ingest --season 2026 --round 18
DQ dashboard (Elementary OSS)
The web app exposes a Data Quality link in the top navigation that opens the Elementary OSS report.
| Item | Detail |
|---|---|
| Dashboard | Elementary OSS |
| Generate report | bash scripts/dbt_dq_report_nrl.sh |
| Open in app | /dq |
How it works:
- dbt tests own the DQ rules (schema tests and singular tests in
analytics/dbt_nrl/). - Elementary visualises the dbt run and test results as a self-contained HTML report.
- The web app is read-only — navigating to
/dqnever invokes dbt,edr, or any scripts.
Routing logic at /dq:
- If
NRL_DQ_DASHBOARD_URLis set → redirects to that URL (for a hosted Elementary instance behind Nginx, Cloudflare Access, or VPN). - Else if
analytics/dbt_nrl/target/elementary_report.htmlexists → serves it via/dq/elementary. - Else → shows a status page with the generate command and expected file path.
Environment variables:
# Redirect to a hosted Elementary report (leave empty for local-file mode)
NRL_DQ_DASHBOARD_URL=
# Override local report path (default shown)
NRL_ELEMENTARY_REPORT_PATH=analytics/dbt_nrl/target/elementary_report.html
Monthly players run
Run players separately during the first weekly refresh you perform each new calendar month, or whenever you explicitly want a fresh current-season player snapshot.
Recommended sequence:
export NRL_UPLOAD_ENABLED=true
bash scripts/monthly_2026_players.sh
Round 10 example if you also want the monthly players refresh:
export NRL_UPLOAD_ENABLED=true
bash scripts/weekly_2026_round.sh 10
bash scripts/monthly_2026_players.sh
Weekly finals run
When finals begin, keep the same current-season refresh model and keep historical backfill separate.
Recommended sequence:
export NRL_UPLOAD_ENABLED=true
bash scripts/weekly_2026_round.sh <PUBLISHED_FINALS_ROUND>
What this finals run is for:
draw: refresh the published finals round page so the snapshot captures both the teams scheduled for the upcoming finals round and the results from the just-finished finals roundladder: refresh only if the source still publishes meaningful finals-era ladder contentstats: refresh the2026all-category YTD snapshot
If ladder stops being relevant during finals, skip it with:
SKIP_LADDER=true bash scripts/weekly_2026_round.sh <PUBLISHED_FINALS_ROUND>
Equivalent manual current-season command shapes
python run_spiders.py --spider draw --competition 111 --year 2026 --round <PUBLISHED_ROUND> --load-type weekly
python run_spiders.py --spider ladder --competition 111 --year 2026 --round <PUBLISHED_ROUND> --load-type weekly
python run_spiders.py --spider stats --competition 111 --year 2026 --load-type weekly
Use these manual command shapes when you want to rerun or skip a specific weekly dataset without changing the wrapper script. The compatibility wrapper bash scripts/weekly_nrl_2026.sh <ROUND> still works, but bash scripts/weekly_2026_round.sh <ROUND> is the primary entry point.
Manual monthly players shape:
python run_spiders.py --spider players --competition 111 --year 2026 --load-type weekly
Staging Sequence
For stage-only reruns after any scrape batch and with DBeaver closed:
./scripts/stage_nrl_datasets.sh weekly
What the wrapper does:
weekly: stages only the latest raw run per datasethistorical: stages all raw runs for the selected datasets- use
historicalafter the one-time 2026 catch-up or the 2016-2025 backfill because those batches produce multiple new round grains - use
weeklyafter the single-round weekly 2026 wrapper because that batch produces one new run fordraw,ladder, andstats - use
weeklyafter the separate monthlyplayerswrapper because that batch produces one newplayersrun
Direct per-dataset alternatives:
python scripts/stage_bucket_runs.py --dataset draw --max-runs 1
python scripts/stage_bucket_runs.py --dataset ladder --max-runs 1
python scripts/stage_bucket_runs.py --dataset players --max-runs 1
python scripts/stage_bucket_runs.py --dataset stats --max-runs 1
DBeaver Lock Warning
If DBeaver has duckdb/nrl_bucket.duckdb open, staging can fail because DuckDB uses a file lock.
Recommended practice:
- Finish the scrape batch first.
- Close DBeaver.
- Run staging.
- Reopen DBeaver after staging completes.
The helper script ./scripts/stage_nrl_datasets.sh checks that the local DuckDB catalog can be opened before it starts.
Validation Checks
Local CSV checks (debug only)
These files should exist after a run:
Output_Sheets/draw.csvOutput_Sheets/ladder.csvOutput_Sheets/players.csvOutput_Sheets/stats.csv
Format-aware row counts:
python scripts/count_csv_rows.py Output_Sheets/draw.csv --dataset draw
python scripts/count_csv_rows.py Output_Sheets/ladder.csv --dataset ladder
python scripts/count_csv_rows.py Output_Sheets/players.csv --dataset players
python scripts/count_csv_rows.py Output_Sheets/stats.csv --dataset stats
Bucket metadata catalog
Refresh the local metadata catalog from raw bucket manifest keys:
source .venv/bin/activate
bash scripts/check_nrl_bucket_status.sh
What this produces:
- local DuckDB catalog:
duckdb/nrl_metadata_catalog.duckdbat the repository root - manifest inventory file:
artifacts/catalog/nrl_manifest_inventory.ndjsonat the repository root - DuckDB views for analysis:
cat_nrl_manifest_inventorycat_nrl_round_coveragecat_nrl_season_coveragecat_nrl_status_check
Use cat_nrl_status_check for a compact year-by-year status view and cat_nrl_round_coverage when you want to inspect the individual round grains captured for draw or ladder.
DuckDB checks
python - <<'PY'
import duckdb
con = duckdb.connect("duckdb/nrl_bucket.duckdb")
for table in ["stg_nrl_draw", "stg_nrl_ladder", "stg_nrl_players", "stg_nrl_stats"]:
print(table, con.execute(f"select count(*) from {table}").fetchone()[0])
for table in ["stg_nrl_draw_latest", "stg_nrl_ladder_latest", "stg_nrl_players_latest", "stg_nrl_stats_latest"]:
print(table, con.execute(f"select count(*) from {table}").fetchone()[0])
for table in ["gold_nrl_draw_latest", "gold_nrl_ladder_latest", "gold_nrl_players_latest", "gold_nrl_stats_latest"]:
print(table, con.execute(f"select count(*) from {table}").fetchone()[0])
print(con.execute("select dataset, run_id, record_count, status from ops_nrl_run_inventory order by started_at_utc desc").fetchall())
con.close()
PY
Gold stats duplicate check. Expect 0 rows:
select
season,
stat_category_key,
player_key,
team_key,
metric_key,
count(*) as duplicate_rows
from gold_nrl_stats_latest
group by 1, 2, 3, 4, 5
having count(*) > 1
order by duplicate_rows desc, season desc, stat_category_key, player_key, team_key;
Historical coverage check
After the one-time backfill stage:
stg_nrl_*data should include seasons2016through20252026should only appear after you intentionally run the one-time catch-up or weekly current-season flow
The Odds API — Live Odds
Overview
Live NRL odds are fetched via nrl-bet-advisor/data/odds_api.py using The Odds API.
A single call retrieves h2h, spreads (line), and totals (over/under) for all upcoming fixtures.
- Bookmakers (default): Betfair Exchange AU, PointsBet AU, SportsBet
- Quota: 500 requests/month on the free tier. Each
fetch_nrl_odds()call = 1 request. - Markets returned: h2h (win/loss), spreads (handicap line), totals (over/under)
- Key env var:
ODDS_API_LIMIT_500MONTHLY(orODDS_API_KEY) in the root.env
Setup
Add to your .env:
ODDS_API_LIMIT_500MONTHLY=your_key_here
Usage
From within the prediction / paper trading workflow:
from data.odds_api import fetch_nrl_odds
df = fetch_nrl_odds()
# Returns DataFrame with columns:
# event_id, kickoff_utc, home_team, away_team,
# home_odds, away_odds, bookmaker_count,
# home_line, home_line_odds, away_line_odds, ← spreads
# total_line, over_odds, under_odds ← totals
Paper trades are seeded only by the gated recommend command above. Direct
calls to the setup script are unsupported for production operation.
python scripts/setup_paper_trading.py --season 2026 --round <ROUND>
Quota management
- 500 requests/month on the free tier.
fetch_nrl_odds()uses exactly 1 request per call regardless of how many markets are requested (h2h+spreads+totals are bundled). - Check remaining quota in the response headers — logged automatically.
- Avoid calling more than once per hour intraday; once per day is sufficient.
Current quota status (as of 2026-07-20)
392/500 used · 108 remaining. No plan upgrade planned — work within budget.
R21 estimated spend (week of 2026-07-21):
| Calls | Count | Notes |
|---|---|---|
| tlwindow CronJobs (Tuesday) | 4 | 06:05 / 09:05 / 12:05 / 18:00 UTC |
| Open snapshot | 1 | Tuesday/Wednesday when market publishes |
| Mid-week refreshes | ≤3 | k8s CronJob at 6h intervals — do NOT increase frequency |
| Close snapshots | 8 | One per kickoff, runs ~T−40min |
| R21 total | ≤16 |
Remaining budget after R21: ~92 calls. Monthly counter resets with the billing
cycle (~1 Aug) restoring the full 500 for R22 onward.
Schedule discipline for R21: do not run manual test calls against the API
this week. If verifying the tlwindow fix, use --dry-run (no API call made).
If the k8s refresh CronJob (0 */6 * * *) is running through the week, that
adds ~44 calls (11 days × 4/day) — still within the 108 budget combined with
R21's 16 operational calls, but leaves no headroom for unplanned reruns.