Files
vyndr/specs/order-zero-book-breadth-test.md
builtbykev 293367917c Order Zero: book-breadth test + accrual clock correction (measure-only)
STEP 0 disproved the premise before any request was fired. PropLine's
OpenAPI contract states verbatim that `bookmakers` omitted = ALL books,
so proplineAdapter omitting it is correct and always was. Firing a
guessed param would have RESTRICTED the response and produced exactly
the false negative the order warned about.

The real cause is ours: PropLine sends 18 books; oddsNormalizer
ALLOWED_BOOKS intersects them at exactly 5 -- which is precisely the
"5 MLB books" the 2.18 audit measured. Measured on real public data
(no key, no quota): 4.41 books/prop from the feed, 1.50 after our
filter, and 12 of 34 props go invisible entirely.

Also corrected: "73% single-book" is the long tail of deep props
sole-posted by DraftKings or Bovada. On the core props we grade, the
market is 10-12 books wide. pinnacle appears on 0 of 40 MLB props --
the independent low-vig references present on 100% of core props are
exchanges (novig/smarkets/kalshi). DFS pick'em also covers 100% but is
not a market price and must never enter a consensus.

Verdict is outcome (d) ALREADY OPEN, not (a)/(b)/(c) -- all three
assumed the feed was the constraint. Ruler change scoped (not built):
split one allow-list into takeable/reference/excluded, fair_prob_lock
becomes a median consensus with n>=2 or a labelled fallback. Gated on
exchange price validation + the WNBA measurement, which needs the
PropLine key (prod-only, absent locally). MLB isotonic p_win declared
PROVISIONAL until re-run on the real ruler.

Side finding: we use 1 of 29 endpoints. /odds/closing, /movement,
/odds/history, /best-line, /ev, /results, /exports/resolved-props,
/context (free) map directly onto documented gaps -- and resolution
across 33 sports suggests "no free settled feed for NBA/soccer" may be
a $19/mo problem, not a data problem. Documented, not verified.

Plan edits: §10.1 rewritten, §10.2/§10.5 corrected, and §11 adds the
sequential post-completion accrual clock -- pre-completion data does
not count, no pooling across the completion boundary, two clocks
stated separately, per-sport clocks, verification gate before any
accrual, users onboarded to a complete product only. §9.1's "6-10
weeks out" corrected: that is accrual duration, not distance to the
answer. The ruler change independently forces the same no-pooling
boundary by arithmetic.

No API key was used, printed, or committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-07-31 23:19:23 -04:00

292 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Order Zero — BOOK-BREADTH TEST
**Date:** 2026-07-31 · **Type:** measure-only · **Live adapter: UNTOUCHED** ·
**Our API key: NOT USED** (measurement ran entirely on PropLine's *public,
unauthenticated* endpoints — zero quota spent, zero risk to the live feed).
---
## VERDICT UP FRONT
**The order's premise is disproven, and the finding is better than any of the
three anticipated outcomes.**
The `bookmakers` param exists, but **omitting it already returns every book.**
We are not being starved by the feed. **PropLine sends 18 books; we discard 13
of them ourselves**, in `src/utils/oddsNormalizer.js` `ALLOWED_BOOKS`.
> **Outcome (d) — ALREADY OPEN.** The constraint is one allow-list in our own
> code. No param, no cost, no tier upgrade, no second data source.
This is why Step 0 exists. Firing a guessed `bookmakers=` request would have
*restricted* the response and produced exactly the false negative the order
warned about — "doesn't open" — while the real cause sat in our repo.
---
## STEP 0 — THE PARAM, CONFIRMED FROM THE AUTHORITATIVE SPEC
Source: `https://api.prop-line.com/openapi.json` (200, 78KB, 29 paths) — the
OpenAPI contract itself, not a marketing page.
`GET /v1/sports/{sport_key}/odds` parameters:
| param | required | default | documented behaviour |
|---|---|---|---|
| `markets` | no | `h2h` | comma-separated market keys |
| `bookmakers` | no | *(none)* | **"Comma-separated bookmaker keys (e.g. 'draftkings,fanduel'). Omitted = all books. Matches the-odds-api's `bookmakers` param."** |
| `period` | no | *(none)* | full-game only unless set |
**`Omitted = all books.`** Verbatim from the spec. The param is a *filter*, not
an expander. `proplineAdapter.js:152` omitting it is **correct** and always was.
§10.1 of MASTER-PLAN said the missing param was the root cause. **That was
wrong.** Corrected in place.
---
## STEP 1 — THE MEASUREMENT
Two public endpoints, no auth, no key, no quota:
1. `GET /v1/freshness` — per-bookmaker ingestion health = **the real book universe**
2. `GET /v1/exports/sample` — "last 7 days of MLB `pitcher_strikeouts` as CSV…
no auth required" — **real resolved props with book, line, price, resolution,
actual value, and closing price**
### 1a. The book universe — 18 keys, all live
`/v1/freshness`, sampled `2026-08-01T03:13Z`, stale threshold 1800s:
| book | markets | staleness | in our `ALLOWED_BOOKS`? |
|---|---:|---:|---|
| draftkings | 487,182 | 16s | ✅ |
| **bovada** | **385,689** | 2s | ❌ dropped |
| betmgm | 166,171 | 119s | ✅ |
| betrivers | 151,845 | 12s | ✅ |
| pinnacle | 144,615 | 6s | ✅ |
| **novig** | 132,604 | 0s | ❌ dropped |
| prizepicks | 111,475 | 14s | ❌ dropped |
| unibet | 64,696 | 15s | ❌ dropped |
| **smarkets** | 39,113 | 8s | ❌ dropped |
| underdog | 33,632 | 66s | ❌ dropped |
| fanduel | 32,189 | 0s | ✅ |
| **kalshi** | 27,649 | 5s | ❌ dropped |
| **matchbook** | 24,682 | 5s | ❌ dropped |
| onexbet | 22,896 | 382s | ❌ dropped |
| **polymarket** | 17,347 | 1s | ❌ dropped |
| sleeper | 8,857 | 58s | ❌ dropped |
| tab_au | 6,183 | 45s | ❌ dropped |
| dabble | 4,409 | 51s | ❌ dropped |
**Not one book is stale.** The feed is healthy; the loss is entirely ours.
### 1b. The arithmetic that explains "5 MLB books" exactly
`ALLOWED_BOOKS` (11) ∩ PropLine (18) = **5**:
`draftkings, fanduel, betmgm, betrivers, pinnacle`.
**That is precisely the "5 MLB books" the 2.18 audit measured.** Cause
identified — it is a set intersection, not a coverage fact.
Two separate defects fall out:
- **13 of PropLine's 18 discarded by us.**
- **6 of our 11 allow-list entries do not exist at PropLine at all** —
`caesars, fanatics, bet365, hardrockbet, pointsbet, thescore`. They are inert
on the PropLine path. *(They are NOT dead code overall — the odds-api backup
path may still carry them. Do not delete them on this evidence.)*
### 1c. Per-prop breadth — MLB `pitcher_strikeouts`, 6 events, 2026-07-31
1,000 rows. 5 complete events / 34 props used for breadth (the 6th event is
truncated by the export's 1,000-row cap and is excluded — reporting it would
manufacture single-book props out of a row limit).
| | mean books/prop | single-book | props visible |
|---|---:|---:|---:|
| **PropLine sends (param omitted)** | **4.41** | 67.6% | 34 / 34 |
| **After `ALLOWED_BOOKS`** | **1.50** | 67.6% | **22 / 34** |
**Our filter costs 2.9 books per prop and makes 12 of 34 props invisible
entirely** (zero allowed books → the prop does not exist to VYNDR).
The distribution is **bimodal, and the shape is the real story**:
- **13 "core" props** (probable starters) — **1012 books each**
- **21 tail props** — sole-posted: 13 by `draftkings` alone, 13 by `bovada`
alone, 1 by `onexbet`. These are deep/reliever props no one else prices.
The headline "67.5% single-book" is therefore **not** a statement about the
market for the props we actually grade. It is the long tail of one-book deep
props dragging the mean. **On the props that matter, the market is 1012 books
wide.**
Book presence across the 13 core props:
| book | coverage | role |
|---|---|---|
| underdog, novig, sleeper, prizepicks, dabble, smarkets, kalshi | **13/13** | 3 exchanges + 4 DFS |
| draftkings, betmgm | **13/13** | ✅ ours |
| onexbet | 12/13 | offshore |
| fanduel | 12/13 | ✅ ours |
| betrivers | 10/13 | ✅ ours |
### 1d. The sharp anchor — a real negative
**`pinnacle` appears on 0 of 40 props.** It carries 144,615 markets globally but
**no MLB player props in this sample.** The one sharp book already in our
allow-list contributes **nothing** to MLB props.
What *is* present on 100% of core props: **`novig`, `smarkets`, `kalshi`** — and
`matchbook`/`polymarket` elsewhere in the feed. These are **exchanges**:
order-book priced, so near-zero vig. Arguably a *better* fair-probability
reference than Pinnacle.
**Stated as a hypothesis, not a fact:** exchange prop books are thinner than
their game-line books, and `kalshi`/`polymarket` are prediction markets whose
prop coverage is recent. Their prices must be **validated on real data before
being trusted as a ruler**, not assumed good because the theory is good.
### 1e. Three buckets, not one list — DFS is not a market price
`prizepicks, underdog, sleeper, dabble` cover 13/13 core props, but they are
**fixed-payout pick'em, not two-way markets.** Their lines are deliberately
shaded and their effective juice is flat. **They must never enter a consensus
fair price.** Admitting them "for breadth" would bend the ruler in a new
direction — the exact error this order exists to fix.
### 1f. Cost and tier
`/odds` returns **all books on the FREE tier**. Nothing about book breadth costs
money. Our quota (3 free keys × 1,000/day = 3,000/day) is unchanged by this.
Tier gates apply only to the *analytical* endpoints (§ below):
Free $0/1k-day · Hobby $9/5k · Pro $19/25k · Streaming Lite $39/100k ·
Streaming $79/1M · Enterprise custom.
### 1g. ⚠️ WNBA — NOT MEASURED
The public sample is **MLB `pitcher_strikeouts` only**. Every per-sport
alternative needs the API key, and **`PROPLINE_API_KEY_1/2/3` are absent from
the local `.env`** (prod/Coolify only).
**Do not assume WNBA mirrors MLB.** WNBA's book set is likely thinner and its
exchange coverage thinner still — which would tie directly to its
anti-predictive result. **This measurement is outstanding and needs one thing:
a PropLine key.**
---
## THE BIGGER FINDING — WE USE 1 OF 29 ENDPOINTS
While confirming the param I read the full spec. Several §10 "gaps" are not gaps
in the data — they are endpoints we never called.
| §10 gap | PropLine endpoint | tier |
|---|---|---|
| CLV instrument | **`/events/{id}/odds/closing`** — *"the canonical CLV helper"*, last snapshot at/before commence_time per (book, market, outcome) | Paid |
| Historical odds archive | `/events/{id}/odds/history` (`from`/`to`/`relative_to`/`interval`/`changes_only`) | Hobby+ |
| Bulk archive / backtest | `/exports/odds-history` — full tick firehose | Enterprise / one-time backfill |
| Steam detection | **`/events/{id}/movement`** — *"steam … computed across all 16 books we poll"* | Hobby+ |
| Line shopping | `/events/{id}/best-line` — best price per (market, player, line) + `all_prices` best-first | appears free |
| Cross-book +EV | `/events/{id}/ev` — no-vig via Pinnacle preferred, Bovada fallback | appears free |
| **Settlement for non-MLB sports** | `/events/{id}/results`, `/exports/resolved-props` (line, price, resolution, actual value, **closing price**) | Hobby+ / Pro+ |
| Park / weather / umpire / lineup | **`/events/{id}/context`** — probable pitchers, confirmed-lineup flag, home-plate umpire, first-pitch weather | **FREE** |
| Base rates | `/markets/hit-rates` | — |
| Player trend history | `/players/{name}/trends`, `/players/{name}/history` | Hobby+ |
Two of these overturn standing beliefs in `CLAUDE.md`:
1. **"NBA/WNBA/soccer have no free settled-result feed"** — PropLine *resolves
props across 33 sports* and exports them with actual values. Our
settle-nothing-but-MLB-and-WNBA constraint may be a **$19/mo** problem, not a
data-availability problem.
2. **We hand-built** probable pitchers, depth charts and lineup confirmation.
`/context` serves them, free, joined to the graded outcome.
**Caveat, stated plainly:** these are *documented* capabilities. I have verified
the **`bookmakers` contract** (a spec-level guarantee) and the **book universe +
per-prop breadth** (real data). I have **not** verified what these endpoints
actually return for our tier — that needs the key. Do not plan on them as facts
yet.
---
## STEP 3 — SCOPE OF THE RULER CHANGE *(scope only — NOT built)*
**1. Split one allow-list into three roles.** Today `ALLOWED_BOOKS` answers
"can we show this?" and "can we price against this?" with the same list. Those
are different questions and conflating them is what bent the ruler.
- `TAKEABLE_BOOKS` — a US user can actually bet here. Drives best-price,
line-shopping, `takeable`. *(today's list, minus nothing)*
- `REFERENCE_BOOKS` — the fair-probability ruler **only**; never surfaced as a
place to bet: `pinnacle, novig, smarkets, matchbook, kalshi, polymarket, bovada`
- `EXCLUDED_FROM_PRICING` — DFS pick'em (`prizepicks, underdog, sleeper,
dabble`) and offshore/intl (`onexbet, unibet, tab_au`). May be surfaced as a
separate DFS view; **never** in a consensus price.
**2. `fair_prob_lock` becomes a consensus.** Per-book two-way de-vig
(`devigTwoWay`, unchanged), then the **median** across reference books — median,
not mean, so one stale exchange cannot drag the ruler.
**3. Require n≥2 reference books, and label which ruler was used.** Below 2,
fall back to today's single-book de-vig and stamp
`fair_prob_source: 'consensus_n' | 'single_book'`. **Never silently mix the
two** — that pools two different rulers inside one column, the same error class
as pooling across a model-version boundary.
**4. Stamp a `ruler_version` at the boundary.** Changing the denominator means
**every edge and CLV number measured to date was measured on the old ruler and
cannot be pooled with post-change rows.**
**5. Gate the build on two things that do not exist yet:**
(i) exchange prop-price sanity validated on real data (§1d), and
(ii) the WNBA measurement (§1g).
---
## STEP 4 — MLB ISOTONIC `p_win` IS PROVISIONAL
**Declared explicitly:** the MLB isotonic `p_win` recalibration was measured
against the **bent single-book ruler**. It is **PROVISIONAL and GATED behind the
ruler fix.** It does **not** finalize or promote until re-run on the real
consensus ruler.
**Plan sequencing edit:** MLB promotion is **downstream** of the ruler fix, not
before it.
---
## WHAT CHANGED IN THE PLAN
- §10.1 rewritten — the param premise was wrong; the allow-list is the cause.
- §10.2 — book breadth reclassified from "needs a new source" to "one file".
- §10.5 — item 1 is now a **build we can do**, not a test to run.
- Accrual clock corrected to **sequential / post-completion** with a
verification gate (separate ride-in, §11).
**And the two interact:** the ruler change resets the denominator, so
pre-change settled rows cannot be pooled with post-change rows. That is an
*independent* derivation of Kev's sequential-clock rule — the no-pooling
boundary is forced by the arithmetic, not just by discipline.
---
## REPRODUCE
```bash
curl -s https://api.prop-line.com/openapi.json # the param contract
curl -s https://api.prop-line.com/v1/freshness # the 18-book universe
curl -s https://api.prop-line.com/v1/exports/sample # real resolved MLB props
```
All three are public. No key was used, printed, or committed.
## OPEN — needs one thing from Kev
**A PropLine API key** (it is in prod/Coolify, not in the local `.env`). With
it: the WNBA per-sport measurement, and live verification of the ten unused
endpoints above — including whether **$19/mo settles NBA and soccer.**