Edge, risk, and live monitoring plan

Current thesis

Opening markets are the soft-price instrument; the closing market is the
measuring stick. A model may be a candidate only while it produces positive,
repeatable closing-line agreement without using closing data as a feature.

Active candidates are open_fav_v1 and rapm_timing_v1. line-v2 remains
roadmap work. Generic H2H feature churn, the divergence signal, broad Elo
remixes, and a large H2H model zoo are frozen or killed unless a new data
source produces promotion-gate evidence. Deep learning and uncalibrated
voting are deprioritised; ensembles require better promotion evidence than
their components.

Promotion requires at least 100 settled paper trades, ROI above 5%, positive
mean and median CLV, no single round contributing more than 30% of profit,
and robustness across home and away subsets. Repo outputs never place
real-money bets.

Risk policy

The paper bankroll defaults to a $1,000 simulation, quarter-Kelly or lower,
$25 maximum per bet, $100 maximum per round, $25 maximum per match, and $50
maximum per team. EV below 2% receives no stake. These are simulation limits,
not betting instructions; constants live in data/risk.py.

Live evidence quota

For each monitored round: Monday open snapshot, six-hour refreshes, team lists
within 30 minutes of Tuesday release, strategy paper seeding, per-kickoff
close snapshots, settlement, and CLV population within 24 hours. Missing open
or required close data blocks recommendations. Stale weather, ladder, player
stats, or documentation is a warning unless the selected strategy needs it.

Player markets

Player markets are not currently collected or modelled. The raw observation
scaffold in data/player_markets.py covers anytime/first try scorer, player
points, run metres, tackles, assists, and line breaks. It records event,
player, team, bookmaker, market, price, line, and capture time, but cannot feed
recommendations until separately validated.

Compact feature roadmap

Future player-combination indexes are limited to spine continuity (1/6/7/9),
spine+lock cohesion (1/6/7/9/13) — the modern ball-playing lock
(Yeo/Murray/Radley class) is a fifth playmaker and belongs in the spine
unit for cohesion purposes — a lock playmaker index (trailing
involvement stats for the named 13: passes/offloads/try assists,
placement-time safe from Tuesday team lists), halves pairing, hooker-half
pairing, centre-wing pairing, top-middle rotation, starter continuity, and
minutes together where available. Do not create every possible squad pair
without CLV or calibration evidence.

Note the distinction owner questions keep raising: spine_continuity_rate
measures week-to-week disruption (same names as last round); cumulative
time-together
(time-decayed count of games this exact spine/spine+lock
unit has started together) measures accumulated cohesion. They are
different signals; the existing feature does NOT cover the second. Both
the cumulative version and the lock extensions are queued for line-v2
(the line model is not frozen), pre-registered before build.

Next model work: market-aware CLV prediction, state-space-variance line
models, calibrated lineup/player deltas, weather/ground-aware totals, and
compact player-combination indexes.

line-v2 spine+lock pre-registration (2026-07-16 — no build this round)

Owner direction accepted 2026-07-15: the modern ball-playing lock
(Yeo/Murray/Radley class) is a fifth playmaker. These three features are
pre-registered for line-v2; no code may be written until all items in
the "Before building" checklist are satisfied, and gates must not be adjusted
after results exist.

Feature 1 — spine+lock cohesion delta (delta_spine_lock_cohesion)

What it measures: Whether the named 1/6/7/9/13 unit for each team is more
or less cohesive than the opponent's. Cohesion is measured as the time-decayed
count of games this exact five-player combination has started together,
normalised to [0, 1] within the season.

Distinction from delta_spine_continuity_rate: The existing
delta_spine_continuity_rate measures disruption relative to the prior round
(same names last week vs this week). delta_spine_lock_cohesion measures
accumulated familiarity — how many times this precise unit has played together
across the whole season. A team that keeps the same five-man unit for 10 rounds
scores high; a team with one forced change per round stays near zero regardless
of continuity rate.

Positions: jersey numbers 1 (fullback), 6 (five-eighth), 7 (halfback),
9 (hooker), 13 (lock). For team list data, use starter_flag = True rows only;
positions that can't be resolved from team-list order are excluded (the unit
is only scored when all five positions are named).

Data source: stg_team_lists_latest (already staged). Unit identity is
the frozenset of canonical player IDs at positions 1/6/7/9/13.

Decay: exponential with half-life 8 weeks (≈ half an NRL regular season);
the same scheme used in RAPM walk-forward training (RAPMConfig.decay_halflife).

Implementation: new column in dbt_nrl/models/ml/ — compute in
ml_nrl_lineup_features.sql or a companion ml_nrl_spine_lock_features.sql,
then join into ml_nrl_match_training_examples. Add the delta column
(delta_spine_lock_cohesion = home − away) to LINEUP_FEATURES in
data/backtest.py only after the dbt model produces non-null values for
≥ 80% of the evaluable window (2016–2026 with lineups).

Feature 2 — lock playmaker index (lock_playmaker_index_delta)

What it measures: Whether the named lock (position 13) is a ball-playing
forward vs a predominantly defensive/running forward. Higher index = more
playmaking involvement. Computed from trailing YTD stats for the named player:

lock_playmaker_index = (
    0.4 × try_assists_per_80
  + 0.3 × (passes_per_80 / league_avg_lock_passes)
  + 0.3 × (offloads_per_80 / league_avg_lock_offloads)
)

Stats sourced from gold_nrl_stats_latest (already scraped weekly). The index
is placement-time safe: uses only stats accumulated in rounds before the
target round.

Delta: lock_playmaker_index_delta = home_lock_index − away_lock_index.
A positive delta means the home team's lock is more playmaking, which the
model may associate with lower-scoring, tighter-margin outcomes (the hypothesis
to test, not an assumption).

Why a composite: individual stats have high game-to-game variance; the
weighted composite has lower noise. Weights are provisional — if validation
shows one component dominates, simplify to that component before finalising.

Data source: gold_nrl_stats_latest. Requires the player_position or
equivalent field to identify the starting lock (position 13 from team lists).
Join on canonical player name via gold_nrl_player_name_map.

Feature 3 — cumulative spine+lock time-together (spine_lock_games_together)

What it measures: The raw (non-decayed) count of games the named
1/6/7/9/13 unit has started together in the current season, prior to the
target round. Companion to Feature 1 (decayed cohesion); kept separate because
early-season and late-season units may respond differently to accumulated reps.

Delta: spine_lock_games_together_delta = home − away.

Note on collinearity: Features 1 and 3 will be correlated (both measure
accumulated unit experience). During model evaluation, check VIF; if > 5,
drop Feature 3 and keep Feature 1 (which is more scale-stable).

Before building (all must be true)

  • [ ] Steps 1–3 of the 2026-07-15 brief are complete and pushed.
  • [ ] stg_team_lists_latest covers ≥ 90% of 2024–2026 rounds (current
    coverage: 2026 R4–R20, 2016–2025 historical; check with
    SELECT season, COUNT(DISTINCT round) FROM stg_team_lists_latest GROUP BY season).
  • [ ] Position mapping is validated: the team-list jersey-to-position resolver
    correctly assigns positions 1/6/7/9/13 for ≥ 95% of named squads
    (write a pytest covering the 10 most common team-list formats).
  • [ ] A pre-registration test is committed to tests/ that asserts the new
    delta columns have no look-ahead leakage (all training rows use only
    rounds < target round).

Pre-registered gates for line-v2 promotion

Line model gate: MAE on out-of-sample test set must beat line_ridge_core
(currently 13.46 pts). Walk-forward CLA on the LINE market must be ≥ 0.53
with z ≥ 2. These gates apply jointly — MAE improvement without CLA evidence
is not promotion.

Paper trading gate (after code builds pass): ≥ 50 LINE paper trades settled,
mean CLV > 0, no single round > 30% of profit. ROI is report-only at this
volume.

Weather data — audit (2026-07-16)

Weather features flow through the system as follows:

Open-Meteo API
  → scripts/ingest_nrl_weather.py  (hourly fetch per venue per round)
  → stg_nrl_weather_hourly_raw     (DuckDB raw table)
  → stg_nrl_weather_hourly         (dbt staging, deduped)
  → gold_nrl_match_weather         (dbt gold, kickoff-point snapshot)
  → ml_nrl_match_weather_features  (dbt ML, 5 selected features + flags)
  → ml_nrl_match_training_examples (joined into feature matrix)
  → data/backtest.py               (walk-forward training via _bulk_weather_features())
  → data/prediction.py             (live prediction via _load_weather_features_for_round())

Five features are used after correlation analysis (data/model_bench.py):
temperature_2m, weather_condition_bin, precipitation_mm_pre_match_3h,
wind_speed_bin, is_crosswind_flag. Dropped: is_rain_match, cloud_cover,
precipitation (redundant with weather_condition_bin).

All H2H models (logit_core, logit_rapm_v1, etc.) and the line/total ridge
models include the 5 weather features. When data is unavailable the code falls
back to WEATHER_DEFAULTS (temp=20°C, all zeros — intentionally neutral to
avoid biasing toward cold-weather conditions for unmatched rounds).

Coverage audit (post-backfill 2026-07-16)

Full historical backfill run 2026-07-16 filled the 2016–2023 archive gaps
(155,880 rows, 90 API calls). R15, R16, R20 2026 gaps were also resolved.

Season Matches With weather Coverage
2016 178 177 99.4%
2017 178 176 98.9%
2018 178 176 98.9%
2019 178 176 98.9%
2020 146 146 100.0%
2021 178 177 99.4%
2022 178 177 99.4%
2023 190 189 99.5%
2024 190 189 99.5%
2025 191 190 99.5%
2026 R1–R20 107 106 99.1%
2026 R21–R27 56 0 0% — future rounds, no forecast yet

Remaining single-match gaps (any season): One match per season at 98–99%
typically reflects a neutral-venue game or relocated fixture whose venue key
didn't match the stadium seed. These fall back to WEATHER_DEFAULTS; no leakage.

2026 R21–R27: Forecast data must be fetched within 16 days of kickoff
(Open-Meteo free-tier limit). weekly_2026_round.sh calls
ingest_nrl_weather.py weekly automatically each round.

rain_flag / is_rain_match column naming

The underdog segmentation diagnostic (2026-07-15 Step 2) referenced rain_flag
which was UNAVAILABLE. The correct column in the predictions frame is
weather_impact_flag (composite: precipitation ≥ 2.5 mm OR wind_speed_bin ≥ 2
OR weather_condition_bin ≥ 2). is_rain_match exists in ml_nrl_upcoming_matches
but is excluded from WEATHER_FEATURE_COLUMNS (post-correlation analysis).
If a future diagnostic segment needs a rain indicator, use weather_impact_flag
from the predictions frame.

Ongoing action items

Priority Item Command
Each round Fetch forecast (auto via weekly script) bash scripts/weekly_2026_round.sh <round>
If gap found Targeted backfill for a specific round python scripts/ingest_nrl_weather.py weekly --season 2026 --round <N>
If broader gap Full archive backfill python scripts/ingest_nrl_weather.py backfill
After any backfill Rebuild weather ML models dbt build --select +ml_nrl_match_weather_features+ ml_nrl_match_training_examples