cd /news/machine-learning/data-freshness-is-a-schema-property-… · home › topics › machine-learning › article
[ARTICLE · art-168151] src=dev.to ↗ pub= topic=machine-learning verified=true sentiment=· neutral

Data freshness is a schema property: freezing the market price before kickoff

A football analytics platform operator audited 209 models and 46,233 settled predictions for time leakage and traced it to three schema problems rather than model bugs. The audit found 81 predictions written after kickoff (settling at 69.14% versus 56.9% for the rest), 62,922 post-kickoff odds ticks across 15,259 fixtures in a 15.4-million-row price store, and only 29.9% of stored "opening" prices matching a declared opening quote. The operator argues data freshness should be enforced as a schema property, since nothing currently prevents rows from being written after the event they describe.

by read8 min views1 publishedNov 12, 2026

Every prediction is a claim about a world state that does not exist yet, which makes its input side auditable in a way most ML pipelines never face: the feature vector must be reconstructable as of a timestamp strictly earlier than the event.

We run a football analytics platform with 209 models and 46,233 settled predictions. Hunting for time leakage, I found it in three places, none a model bug — all three schema problems: tables physically able to store a value that could not have existed at decision time. The audit came down to one question per input table: what stops a row from being written after the event it describes? Mostly, nothing.

The gap between row creation and the event timestamp is the first thing to plot. Join to fixtures, bucket the delta.

WITH lead AS (
  SELECT p.id, p.hit,
         EXTRACT(EPOCH FROM (n.match_time - p.created_at)) / 60.0 AS lead_min
  FROM ai_predictions p
  JOIN nami_matches n ON n.id = p.match_id
  WHERE p.settled
)
SELECT CASE WHEN lead_min < 0    THEN 'after kickoff'
            WHEN lead_min < 10   THEN '0-10 min'
            WHEN lead_min < 60   THEN '10-60 min'
            WHEN lead_min < 360  THEN '1-6 h'
            WHEN lead_min < 1440 THEN '6-24 h'
            ELSE 'over 24 h' END AS bucket,
       COUNT(*) AS n,
       ROUND(AVG(CASE WHEN hit THEN 1 ELSE 0 END) * 100, 2) AS hit_pct
FROM lead GROUP BY 1 ORDER BY 2 DESC;
bucket rows share settled-hit %
over 24 h 14,908 32.25% 56.45
6-24 h 20,292 43.89% 57.28
1-6 h 9,491 20.53% 56.34
10-60 min 1,171 2.53% 58.84
0-10 min 290 0.63% 55.52
after kickoff 81 0.18% 69.14

Eighty-one rows were written after the fixture had started, settling at 69.14% against 56.9% for the rest. At n=81 that is a symptom, not a result: those rows come from a different code path, one close enough to the outcome to matter. The reading is not "late rows are better" but "we cannot tell what they saw, and should not have been able to write them".

Percentiles: p5 100.4 | p25 413.1 | p50 1,074.1 | p75 1,840.5 | p95 4,183 minutes. A 17.9-hour median is healthy; a p5 of 100 puts one row in twenty inside the window where the market is most informative — and most likely quoted from the wrong tick.

The price store is a change log: 15,378,162 rows over 44,933 fixtures, 22.1 sources per fixture (median 19, max 87), median 140 ticks per fixture (p90 983). No is_prematch flag, no write guard — so the worker keeps appending after kickoff:

SELECT COUNT(*) FILTER (WHERE e.changed_at > n.match_time) AS post_kickoff_ticks,
       COUNT(*)                                            AS total_ticks,
       COUNT(DISTINCT e.match_id)
         FILTER (WHERE e.changed_at > n.match_time)        AS affected_fixtures
FROM nami_europe_odds e JOIN nami_matches n ON n.id = e.match_id;
post_kickoff_ticks | total_ticks | affected_fixtures
             62,922 |  15,378,162 |            15,259

0.41% of rows across 15,259 fixtures — a third of the store, small per fixture (mean 4.1 ticks, max 443) and short (median worst-case lag 2.4 minutes, max 1,439.6). The issue is that ORDER BY changed_at DESC LIMIT 1 — the pattern in every dashboard, prompt and backfill — silently returns an in-play value and cannot know it.

The consequence: of 44,625 fixtures with a pre-kickoff tick, the last lands a median 3.0 minutes before kickoff but the mean is 444.9; 13,540 (30.3%) have nothing newer than six hours, 2,201 (4.9%) nothing newer than 24. Bimodal — the feed either tracks to the whistle or stopped last evening, and no column records which.

ai_predictions carries opening_eu_home/draw/away; the name promises the fixture's first quote. Two reference sets, tolerance 0.005, same 45,819 settled rows carrying a stored price:

reference rows matching share
declared opening, any source 13,706 29.9%
pre-kickoff price within 2 h, any source 20,365 44.5%

A column called opening reconciles with a pre-kickoff price more often than with the opening. Backwards. So I restricted to the 15,487 rows whose fixture has a declared opening, giving both tests one denominator:

WITH declared AS (
  SELECT match_id, MAX(initial_eu_home) AS ini
  FROM nami_europe_odds WHERE initial_eu_home IS NOT NULL GROUP BY 1
), prekick AS (
  SELECT DISTINCT e.match_id, e.eu_home
  FROM nami_europe_odds e JOIN nami_matches n ON n.id = e.match_id
  WHERE e.changed_at <= n.match_time
    AND e.changed_at >  n.match_time - INTERVAL '3 hours'
)
SELECT COUNT(*) AS n,
       COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM declared d
           WHERE d.match_id = p.match_id
             AND abs(d.ini - p.opening_eu_home) < 0.005)) AS hit_opening,
       COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM prekick k
           WHERE k.match_id = p.match_id
             AND abs(k.eu_home - p.opening_eu_home) < 0.005)) AS hit_prekick
FROM ai_predictions p
WHERE p.settled AND p.opening_eu_home IS NOT NULL
  AND EXISTS (SELECT 1 FROM declared d WHERE d.match_id = p.match_id);
n = 15,487 | hit_opening 849 (5.5%) | hit_prekick 7,942 (51.3%)

The innocent explanation is that prices rarely move. Control: of the 13,882 fixtures holding both a declared opening and a pre-kickoff price, only 1,273 (9.2%) are unchanged. With 90.8% moving, 51.3% against the pre-kickoff price and 5.5% against the opening cannot both come from stability.

A placebo run on the 3,000 most recent rows — matching each stored price against the next fixture's pre-kickoff quote — returns 8.5%, the base rate of a 19-source grid; the same-fixture rate is 49.7%.

So opening_eu_home is filled at write time from whatever the feed currently says — usually the freshest quote. A timestamp-of-write price wearing a timestamp-of-event name:

Across 13,876 measured fixtures, the drift between those two ticks is a median 3.08 pp of implied probability, p90 10.93 pp, with 4,649 fixtures (33.5%) moving more than five points.

Worse than a wrong name is a disappearing reference. Coverage of initial_eu_home by month:

month fixtures in store with declared opening coverage
2026-06 1,007 0 0.0%
2026-07 3,018 0 0.0%
2026-08 9,320 0 0.0%
2026-09 7,443 5,108 68.6%
2026-10 17,392 8,848 50.9%
2026-11 6,689 41 0.6%

Backfilled for two months, then abandoned — no migration, no alert. The store's semantics moved under its consumers: the most expensive kind of data debt, invisible to NOT NULL, and it turns every historical comparison into a comparison of different things.

match_time_audit exists because kickoff times are not stable: 42,378 fixtures, 141,111 changes, 13,500 of them carrying settled predictions. 2,056 predictions were created inside the window between the old and new kickoff — already running by the original schedule, not yet started by the revised one. Each has an unanswerable "was this pre-kickoff?" until the row records which copy of the schedule it used.

1. A watermark, not a clock. Every price row carries the event time it describes (as_of) plus its fixture. Queries use as_of <= :decision_time, never changed_at. "Latest price" becomes "latest price as of a timestamp someone chose deliberately".

2. Freeze once, at kickoff, with a hash. One row per fixture, rejected at or after kickoff by the writer and by a constraint:

import hashlib, json
from datetime import datetime, timezone

def freeze(cur, match_id: int, as_of: datetime, prices: dict, source: str) -> str:
    """Insert the one and only frozen price for a fixture. Returns the hash."""
    cur.execute("SELECT match_time FROM nami_matches WHERE id = %s", (match_id,))
    kickoff = cur.fetchone()[0]
    if as_of >= kickoff:
        raise ValueError(f"{match_id}: as_of {as_of} is not before kickoff {kickoff}")

    body = {"match_id": match_id,
            "as_of": as_of.astimezone(timezone.utc).isoformat(),
            "source": source, "prices": prices}
    digest = hashlib.sha256(
        json.dumps(body, sort_keys=True, separators=(",", ":")).encode()).hexdigest()

    cur.execute("""INSERT INTO frozen_prices (match_id, as_of, source, payload, sha256)
                   VALUES (%s, %s, %s, %s, %s)
                   ON CONFLICT (match_id) DO NOTHING""",
                (match_id, as_of, source, json.dumps(body, sort_keys=True), digest))

    cur.execute("SELECT sha256 FROM frozen_prices WHERE match_id = %s", (match_id,))
    if cur.fetchone()[0] != digest:
        raise RuntimeError(f"{match_id}: frozen row exists with a different payload")
    return digest

3. Reference the freeze from the prediction. Store frozen_price_id and kickoff_at instead of three copied numbers; "which price did the model see" becomes a foreign key that survives every backfill.

One table already gets this right: odds_snapshot_ledger, 15.4M rows over 20,065 fixtures, write lag median 6 s and p95 34 s against the bucket it records, and zero rows in the last seven days whose live-tick timestamp postdates kickoff (2,083,478 checked). Same vendor, same worker — the difference is the schema, not the ingest code.

created_at >= kickoff_at = 0, and frozen prices with as_of >= kickoff_at = 0. Alert, not warn.frozen_price_id to exactly one row, and the hash verifies.as_of — 100%, not 30%. None of these are modelling decisions. They are the arithmetic that makes a backtest mean anything — and all of them were failing on a system that produced 46,233 settled predictions without an alarm. Our 56.9% settled rate was computed against a price column that, in half the rows, holds a later price than the model saw. The model was not cheating; the schema left the door open and nobody checked who walked through it.

If you take one thing from this: data freshness is not a cron schedule, it is a column. A pipeline that refreshes every 60 seconds is still a time machine if the destination is not recorded on the row.

Analytics and research content. Not betting advice. 18+

本内容仅供数据分析与研究参考,不构成任何决策建议。

── more in #machine-learning 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/data-freshness-is-a-…] indexed:0 read:8min 2026-11-12 · —