Files
vyndr/specs/odds-429-diagnosis.md
T
builtbykev 3ba3dd28f3 Scoreboard every challenger; diagnose the 429 as odds-api, not PropLine
PROMOTE-THE-EARNED. Nothing was promoted, because nothing earned it -- not
because the bar was held high. Measured on the same bar that refuted hits-v1:
own rows only, direction-aligned, paired bootstrap, promote only on a CI
excluding zero.

  arch-v1        n=1741  delta 0.0000  CI[-0.0050,+0.0054]  inconclusive
  contact-v1     n=1055  delta +0.0008 CI[-0.0052,+0.0069]  inconclusive
  proj-v1.1      n=1664  delta -0.0301 CI[-0.0543,-0.0060]  reliably WORSE
  matchup/tb-v1/hits-v1  n=0  genuinely pending (rows dated 08-02+)

arch-v1 is the interesting one: it MOVED 76% of rows by 2.5 points on average
and resolution is identical to the champion to four decimals, on the moved
rows too. That is active movement carrying no information -- a finding, not a
pending verdict.

These are true prospective holdouts: arch-v1 and contact-v1 wrote p_win at
grade time into their own columns before the game. Nothing recomputed.

THE 429, read-only. The premise was that we re-pull the full picture every
slot and blow the quota. Measured: PropLine is at 5 calls of 3,000/day --
0.17%. One snapshot is ONE PropLine call per sport, all markets comma-joined.
There is no request-pattern problem, so a change-based pull cannot fix it and
no tier upgrade is needed.

The 429 is odds-api: 478/500 MONTHLY, blocked at 95%. oddsService falls
through silently when PropLine returns empty, and the backup's quota gate
throws the error -- so an empty slate is indistinguishable from an outage and
the message names the wrong provider. Flagged for its own order.

Could NOT verify PropLine movement endpoints: docs are auth-gated and the keys
are production-only. Not asserted either way. The movement-as-data argument
stands on its own merits and should be justified that way, not as a quota fix
it isn't.

Book-breadth invariant written down: we never discard books. All are kept and
shown (DISPLAY_BOOKS = MODEL + REFERENCE + DFS); DFS pick'em is excluded from
PRICING only, because a fixed-payout shaded number is not a market price.
Verified this is already what bookRoles.js does.

Champion byte-identical; every challenger stays wired.
4,159 tests green (332 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-02 22:07:43 -04:00

6.9 KiB
Raw Blame History

THE 429 — DIAGNOSED, AND IT IS NOT WHAT WE THOUGHT

Read-only investigation, 2026-08-03. Nothing was implemented, no tier bought.

VERDICT: the change-based pull is NOT the fix, and no tier upgrade is needed. There is no request-pattern problem to solve. PropLine is running at 5 calls out of 3,000/day — 0.17% of the free allowance. The 429 comes from a different provider entirely.


1. BOOK BREADTH — the invariant, written down (and already true in code)

WE DO NOT DISCARD BOOKS. All books are KEPT and SHOWN. More breadth is strictly good for display, line-shopping and consensus. The only selectivity is that DFS pick'em (prizepicks / underdog / sleeper / dabble) does not vote on the fair-price CONSENSUS, because a fixed-payout shaded number is not a market price. That is one category exclusion from pricing, not a reduction in breadth.

Verified in src/config/bookRoles.js — this is already exactly what the code does, and the invariant is recorded so no future "cleanup" undoes it:

DISPLAY_BOOKS       = MODEL_BOOKS  REFERENCE_BOOKS  DFS_PLATFORMS   ← everything shows
EXCLUDED_FROM_PRICING = DFS_PLATFORMS  OFFSHORE_OR_INTL              ← pricing only

The historical real discarding — ALLOWED_BOOKS cutting the slate to 5 keys and making 64.8% of MLB props invisible — was fixed in Order Zero and must not return. No language in this repo should describe book handling as "discarding".

2. Post-reset status (STEP 1) — the pipeline RECOVERED

GET /api/internal/snapshot/status   →  overdue: false
  mlb   2026-08-03T01:00:24Z   gradeCount 235
  wnba  2026-08-03T01:02:58Z   gradeCount 139

The 14/19/22 UTC slots on 2026-08-02 all failed; the 01:00 UTC slot after the reset succeeded. So the failure was time-bounded and self-healing — which already argued against a structural request-pattern defect.

3. What one snapshot actually requests (STEP 2)

Measured in proplineAdapter.fetchRaw: one snapshot = ONE PropLine HTTP call per sport. All markets ride the same request, comma-joined (12 markets for MLB). There is no per-prop, per-book or per-event fan-out.

snapshot          1 call × 2 active sports × 5 slots/day        ≈ 10 calls/day
intraday refresh  every 20 min, slate hours only (12h)          ≈ 72 calls/day
public /api/odds  cache-aside, 1h TTL                            bounded, small
                                                        ── total order-of-10s/day

MEASURED, not estimatedGET /api/internal/quota, 2026-08-03:

provider used limit period allowed
propline 5 3,000 daily true
odds-api 478 500 monthly false (95.6%)
oddspapi 0 1,000 monthly true
tank01 86 1,000 monthly true

There is no waste to reclaim. We are using 0.17% of the free PropLine allowance. Re-pulling "the full picture" every slot costs one request; a change-based pull would replace one request with one request.

4. Where the 429 actually comes from

oddsService.getOdds tries PropLine first, and falls through silently when it returns null or an empty array. The 429 is thrown after that, by the odds-api backup's quota gate:

const quotaStatus = await quotaTracker.getQuotaStatus('odds-api');
if (!quotaStatus.allowed) {
  const error = new Error('Odds data temporarily unavailable. Try again later.');
  error.statusCode = 429;                    // ← THIS is the 429 we saw

So the real sequence on 2026-08-02 was:

  1. PropLine returned no props for that moment (it had 2,995 calls remaining — this was an empty/failed slate, not exhaustion),
  2. control fell through to odds-api,
  3. odds-api sits at 478/500 for the month and the tracker blocks at ≥95%,
  4. → user-facing 429 Odds data temporarily unavailable.

The 429 is the dead backup announcing itself. It is not a PropLine quota signal, and tuning PropLine request volume cannot affect it.

The real defect this exposes

An empty PropLine slate is indistinguishable from a provider outage. A legitimately empty board (off-hours, no props posted yet) produces the identical 429 as a genuine failure, because the fall-through is silent and the error text belongs to whichever provider happened to be asked last. That is a truth-in-reporting bug in the odds path — the operator cannot tell "no games" from "we are broken." Flagged for its own order; not fixed here (read-only).

5. Change-based capture (STEP 3) — NOT VERIFIED, and moot for quota

I could not verify whether PropLine supports /movement, odds-history, or a since= parameter. PropLine's docs are auth-gated (HTTP 401) and the API keys are production-only — not present in the local environment. I am not going to assert an endpoint exists or costs less when I could not test it; "documented ≠ verified" cuts both ways.

What can be said without testing anything:

  • For quota, it is moot. At 5/3,000 there is nothing to optimise. A change-based pull would save at most a handful of calls per day out of an allowance we are not close to touching.
  • The movement-data argument stands on its own, and is the better reason. A change-based capture records line movement as from → to at time T, which is genuine model input we currently lack: steam detection, true CLV attribution, and the market-movement axis. Today intradayRefreshService.trackHistory reconstructs a coarse version of this by diffing full pulls. If PropLine exposes movement natively it is worth building as a data feature — and it should be justified that way, not as a quota fix it isn't.

6. VERDICT (STEP 4), with the numbers

question answer
Did the pipeline recover post-reset? Yesoverdue: false, mlb 235 grades at 01:00Z
Requests per snapshot 1 per sport, all markets in one call
Requests/day vs allowance ~10s/day vs 3,000/day free — 0.17% used
Is the 429 a request-pattern problem? No. Measured, decisively
Is the 429 a PropLine quota problem? No. 2,995 calls remaining, allowed: true
What is the 429? odds-api backup at 478/500 monthly, blocked at 95%
Does change-based pull fix it? No — it cannot; wrong provider, and no volume problem
Is a tier upgrade needed? No. Not for PropLine. Nothing is constrained

What actually deserves an order, in priority sequence:

  1. The silent fall-through — an empty PropLine slate must report "no props available", never the backup's quota 429 (§4).
  2. odds-api at 478/500 monthly — it is a dead backup for the rest of August. Either accept it (PropLine is primary and healthy) and stop letting its quota gate speak for the whole odds path, or budget it deliberately.
  3. Movement capture as a DATA feature — worth scoping on its own merits, with PropLine's endpoint support verified first from an environment that has keys.