Published July 21, 2026. PropsBot Player-Prop Line-Shopping Study, version 1.0.0 data and source package.
By David Reilich. The package publishes aggregate and de-identified evidence, not the licensed raw sportsbook feed. Read the editorial policy and corrections policy.
Source Package
This page exposes the evidence behind the 2,763-prop line-shopping study. Use the README for scope and limits, the aggregate files for results, the audit sample for spot checks and the export script for the exact transformation logic.
- README And Method: Scope, matching rules, limitations and reproduction notes.
- Aggregate CSV: Aggregate results by overall sample, sport and market.
- Aggregate JSON: Machine-readable method, counts and distributions.
- De-identified Audit Sample: Fifty rows for checking the aggregation without publishing licensed raw feed data.
- Export Script: The versioned source used to build the study artifacts.
- Chart Script: The source used to render the public chart from aggregate.json.
- SHA-256 Manifest: Checksums for the original version 1.0.0 data package.
README And Method
Scope, matching rules, limitations and reproduction notes.
# PropsBot Line-Shopping Study: July 18, 2026
Version: `1.0.0`
This package reproduces PropsBot's July 18, 2026 MLB and WNBA player-prop
price-dispersion study. It publishes only aggregate statistics and a
deterministic, de-identified audit sample. It does not include raw sportsbook
prices, prop points, player identities, event identities, credentials, or a
redistributable odds feed.
## Study question
For the same player, market, event, and exact prop point, how far apart were
the best and worst sportsbook prices near 90 minutes before the event started?
Dispersion is measured in implied-probability percentage points. Raw American
odds are not subtracted because equal American-odds differences do not have a
consistent probabilistic meaning.
## Source
- MongoDB collection: `Prosbotai.clvbooksnapshots`
- Collection owner: PropsBot production backend
- Upstream category: licensed sportsbook market data
- Study window: `[2026-07-18T04:00:00Z, 2026-07-19T04:00:00Z)`
- Exporter access: read-only MongoDB aggregation with `secondaryPreferred`
- Production writes: none
The source collection has a 45-day TTL. The checked-in aggregates and hashes
are the durable study record; they are not a substitute for the licensed raw
source.
## Exact selection method
1. Select pre-commencement rows captured during the fixed UTC study window.
2. Keep candidates captured 60 through 120 minutes before commencement.
3. Define an observation as sport + event + market + player + exact prop point.
4. For each sportsbook, select the row nearest 90 minutes before commencement.
5. If two rows are equally close to 90 minutes, select the earlier capture.
6. Require at least two sportsbooks for the exact same prop point.
7. Require the selected cross-book captures to fall within 300 seconds.
8. Treat each side independently and require at least two valid prices on that side.
9. Convert American odds `a` to implied probability:
- Negative: `-a / (-a + 100)`
- Positive: `100 / (a + 100)`
10. Dispersion is `max(implied probability) - min(implied probability)`, reported
in percentage points.
11. Percentiles use linear interpolation at index `(n - 1)q`.
The analysis is a one-slate cross-sectional study. It must not be presented as
a season-long estimate or a guarantee of bettor savings.
## Artifacts
- `export-line-shopping-study-v1.js`: explicit read-only query and materializer
- `aggregate.json`: complete derived study result
- `aggregate.csv`: overall, sport, and market distributions
- `audit-sample-50.csv`: deterministic 50-observation audit sample
- `SHA256SUMS.txt`: SHA-256 hashes for the package inputs and outputs
The audit sample is ordered by a SHA-256 hash of the internal observation
identity and takes the first 50 rows. It includes sport, market, book coverage,
timing checks, quote counts, and derived dispersion only. The hash prevents the
artifact from exposing the underlying player, event, or prop point.
## Reproduction
Query mode is intentionally explicit and refuses to run without both the
database connection and a read-only acknowledgement:
```powershell
$env:DATABASE = '<read-only MongoDB connection>'
$env:PROPSBOT_READ_ONLY_ACK = '1'
node export-line-shopping-study-v1.js --query-stdout |
node export-line-shopping-study-v1.js --materialize-stdin --output-dir .
```
In production operations, run query mode inside the existing worker container
or another approved read-only environment. Do not copy credentials into this
directory. Do not add raw query output or sportsbook prices to the repository.
## Validation checklist
- `aggregate.json` reports `raw_data_included: false`.
- The audit CSV contains exactly 50 data rows.
- No output contains raw prices, prop points, player IDs, player names, or event IDs.
- Every audit observation has at least two books.
- Every audit cross-book capture span is at most 300 seconds.
- `SHA256SUMS.txt` verifies against the checked-in files.
- Re-running with the same frozen source rows produces the same observation
hashes and derived statistics. The `generated_at` and collection-size fields
may change if the query is run later.
Aggregate CSV
Aggregate results by overall sample, sport and market.
scope_type,scope_value,side,observation_count,mean_pp,min_pp,median_pp,p75_pp,p90_pp,max_pp
overall,all,over,2755,2.666,0,2.269,3.628,5.197,15.447
overall,all,under,1958,1.865,0,1.589,2.678,3.737,8.674
sport,baseball_mlb,over,2601,2.699,0,2.301,3.649,5.246,15.447
sport,baseball_mlb,under,1833,1.865,0,1.587,2.682,3.755,8.674
sport,basketball_wnba,over,154,2.115,0,1.914,3.008,4.328,7.513
sport,basketball_wnba,under,125,1.873,0,1.639,2.622,3.418,7.052
market,batter_doubles,over,240,2.681,0.056,2.514,3.61,4.387,6.832
market,batter_doubles,under,222,1.644,0,1.411,2.576,3.298,6.584
market,batter_hits,over,239,2.939,0,2.676,3.682,5.235,11.538
market,batter_hits,under,213,1.786,0,1.506,2.676,3.612,6.227
market,batter_hits_runs_rbis,over,237,3.415,0,2.935,4.433,6.185,15.447
market,batter_hits_runs_rbis,under,230,1.926,0,1.631,2.573,3.63,8.674
market,batter_home_runs,over,236,2.638,0.055,2.234,3.546,4.976,10
market,batter_home_runs,under,0,,,,,,
market,batter_rbis,over,239,3.344,0.069,3.147,4.325,5.084,9.545
market,batter_rbis,under,226,2.509,0,2.432,3.422,4.624,6.017
market,batter_runs_scored,over,223,3.028,0,2.747,3.795,5.505,10.536
market,batter_runs_scored,under,190,1.773,0,1.542,2.75,3.355,6.586
market,batter_singles,over,239,3.721,0,3.326,4.986,6.531,11.565
market,batter_singles,under,229,2.193,0,1.903,3.114,4.412,7.966
market,batter_stolen_bases,over,233,2.451,0,1.917,3.214,5.484,12.381
market,batter_stolen_bases,under,77,2.518,0,2.451,3.617,4.749,7.885
market,batter_strikeouts,over,48,1.416,0.094,1.171,2.341,2.777,3.614
market,batter_strikeouts,under,0,,,,,,
market,batter_total_bases,over,222,2.698,0,2.269,4.027,5.447,13.318
market,batter_total_bases,under,210,1.466,0,1.245,2.084,3.034,5.83
market,batter_triples,over,205,1.427,0,1.391,1.902,2.395,4.792
market,batter_triples,under,0,,,,,,
market,batter_walks,over,159,1.017,0,0.842,1.525,1.96,5.52
market,batter_walks,under,156,0.907,0,0.75,1.267,1.735,7.778
market,pitcher_earned_runs,over,17,2.04,0,2.231,3.361,3.719,4.128
market,pitcher_earned_runs,under,17,2.088,0.55,2.132,2.687,3.277,3.958
market,pitcher_hits_allowed,over,16,1.696,0,1.803,2.296,3.001,3.488
market,pitcher_hits_allowed,under,16,1.555,0,1.718,2.306,2.639,3.408
market,pitcher_outs,over,19,3.129,0,3.205,4.741,5.363,6.294
market,pitcher_outs,under,19,3.135,0.206,3.175,4.18,5.683,6.408
market,pitcher_strikeouts,over,28,2.267,0.646,1.931,3.159,4.083,5.267
market,pitcher_strikeouts,under,27,2.224,0.381,2.141,2.611,3.145,5.541
market,pitcher_walks,over,1,1.886,1.886,1.886,1.886,1.886,1.886
market,pitcher_walks,under,1,0.966,0.966,0.966,0.966,0.966,0.966
market,player_assists,over,22,1.981,0,1.752,2.298,4.28,7.488
market,player_assists,under,10,2.253,0,1.672,2.789,3.858,7.052
market,player_points,over,32,2.102,0,1.823,2.68,3.694,5.99
market,player_points,under,28,1.705,0.235,1.613,2.173,3.375,3.843
market,player_points_assists,over,9,1.684,0.375,1.374,1.994,3.476,4.141
market,player_points_assists,under,9,1.597,0.244,1.428,2.672,2.806,3.326
market,player_points_rebounds,over,17,1.571,0,1.786,2.203,2.637,3.295
market,player_points_rebounds,under,17,1.373,0.185,1.298,1.639,2.064,2.725
market,player_points_rebounds_assists,over,13,2.354,0,2.481,3.089,3.339,5.16
market,player_points_rebounds_assists,under,13,2.465,0.239,2.335,2.869,3.81,5.73
market,player_rebounds,over,28,2.299,0.09,2.097,3.133,4.137,7.139
market,player_rebounds,under,22,1.817,0.381,1.456,2.676,3.34,5.869
market,player_rebounds_assists,over,10,2.106,0,2.387,3.008,3.696,3.735
market,player_rebounds_assists,under,10,2.261,0.732,2.409,3.168,3.302,4.141
market,player_threes,over,23,2.474,0.089,2.052,4.432,4.618,7.513
market,player_threes,under,16,1.972,0.414,1.783,2.545,4.023,4.179
Aggregate JSON
Machine-readable method, counts and distributions.
{
"schema_version": 1,
"study_id": "propsbot-line-shopping-2026-07-18",
"study_version": "1.0.0",
"generated_at": "2026-07-19T04:13:16.836Z",
"title": "July 18, 2026 MLB and WNBA Player-Prop Line-Shopping Study",
"source": {
"collection": "clvbooksnapshots",
"collection_document_count_at_run": 13227805,
"provider_category": "licensed sportsbook market data",
"raw_data_included": false
},
"methodology": {
"study_window_start_utc": "2026-07-18T04:00:00.000Z",
"study_window_end_utc_exclusive": "2026-07-19T04:00:00.000Z",
"candidate_minutes_before_start": [
60,
120
],
"target_minutes_before_start": 90,
"maximum_cross_book_capture_span_seconds": 300,
"identity": "sport + event + market + player + exact prop point",
"book_selection": "nearest observation per book to the 90-minute target; earlier capture wins exact ties",
"eligibility": "at least two books at the exact same prop point and selected captures within five minutes",
"price_conversion": "negative: -a/(-a+100); positive: 100/(a+100)",
"dispersion": "maximum implied probability minus minimum implied probability, in percentage points",
"percentile_method": "linear interpolation at (n-1)q"
},
"counts": {
"candidate_multi_book_prop_points": 3513,
"eligible_same_cycle_prop_points": 2763,
"audit_sample_rows": 50,
"book_count_distribution": {
"2": 732,
"3": 648,
"4": 1185,
"5": 198
}
},
"capture_range_selected_rows": {
"minimum_utc": "2026-07-18T15:34:20.117Z",
"maximum_utc": "2026-07-19T00:39:46.921Z"
},
"book_coverage_prop_points": {
"betmgm": 2106,
"draftkings": 2462,
"espnbet": 419,
"fanduel": 2387,
"williamhill_us": 1764
},
"distributions": {
"overall": {
"over": {
"observation_count": 2755,
"mean_pp": 2.666,
"min_pp": 0,
"median_pp": 2.269,
"p75_pp": 3.628,
"p90_pp": 5.197,
"max_pp": 15.447
},
"under": {
"observation_count": 1958,
"mean_pp": 1.865,
"min_pp": 0,
"median_pp": 1.589,
"p75_pp": 2.678,
"p90_pp": 3.737,
"max_pp": 8.674
}
},
"by_sport": {
"baseball_mlb": {
"over": {
"observation_count": 2601,
"mean_pp": 2.699,
"min_pp": 0,
"median_pp": 2.301,
"p75_pp": 3.649,
"p90_pp": 5.246,
"max_pp": 15.447
},
"under": {
"observation_count": 1833,
"mean_pp": 1.865,
"min_pp": 0,
"median_pp": 1.587,
"p75_pp": 2.682,
"p90_pp": 3.755,
"max_pp": 8.674
}
},
"basketball_wnba": {
"over": {
"observation_count": 154,
"mean_pp": 2.115,
"min_pp": 0,
"median_pp": 1.914,
"p75_pp": 3.008,
"p90_pp": 4.328,
"max_pp": 7.513
},
"under": {
"observation_count": 125,
"mean_pp": 1.873,
"min_pp": 0,
"median_pp": 1.639,
"p75_pp": 2.622,
"p90_pp": 3.418,
"max_pp": 7.052
}
}
},
"by_market": {
"batter_doubles": {
"over": {
"observation_count": 240,
"mean_pp": 2.681,
"min_pp": 0.056,
"median_pp": 2.514,
"p75_pp": 3.61,
"p90_pp": 4.387,
"max_pp": 6.832
},
"under": {
"observation_count": 222,
"mean_pp": 1.644,
"min_pp": 0,
"median_pp": 1.411,
"p75_pp": 2.576,
"p90_pp": 3.298,
"max_pp": 6.584
}
},
"batter_hits": {
"over": {
"observation_count": 239,
"mean_pp": 2.939,
"min_pp": 0,
"median_pp": 2.676,
"p75_pp": 3.682,
"p90_pp": 5.235,
"max_pp": 11.538
},
"under": {
"observation_count": 213,
"mean_pp": 1.786,
"min_pp": 0,
"median_pp": 1.506,
"p75_pp": 2.676,
"p90_pp": 3.612,
"max_pp": 6.227
}
},
"batter_hits_runs_rbis": {
"over": {
"observation_count": 237,
"mean_pp": 3.415,
"min_pp": 0,
"median_pp": 2.935,
"p75_pp": 4.433,
"p90_pp": 6.185,
"max_pp": 15.447
},
"under": {
"observation_count": 230,
"mean_pp": 1.926,
"min_pp": 0,
"median_pp": 1.631,
"p75_pp": 2.573,
"p90_pp": 3.63,
"max_pp": 8.674
}
},
"batter_home_runs": {
"over": {
"observation_count": 236,
"mean_pp": 2.638,
"min_pp": 0.055,
"median_pp": 2.234,
"p75_pp": 3.546,
"p90_pp": 4.976,
"max_pp": 10
},
"under": {
"observation_count": 0,
"mean_pp": null,
"min_pp": null,
"median_pp": null,
"p75_pp": null,
"p90_pp": null,
"max_pp": null
}
},
"batter_rbis": {
"over": {
"observation_count": 239,
"mean_pp": 3.344,
"min_pp": 0.069,
"median_pp": 3.147,
"p75_pp": 4.325,
"p90_pp": 5.084,
"max_pp": 9.545
},
"under": {
"observation_count": 226,
"mean_pp": 2.509,
"min_pp": 0,
"median_pp": 2.432,
"p75_pp": 3.422,
"p90_pp": 4.624,
"max_pp": 6.017
}
},
"batter_runs_scored": {
"over": {
"observation_count": 223,
"mean_pp": 3.028,
"min_pp": 0,
"median_pp": 2.747,
"p75_pp": 3.795,
"p90_pp": 5.505,
"max_pp": 10.536
},
"under": {
"observation_count": 190,
"mean_pp": 1.773,
"min_pp": 0,
"median_pp": 1.542,
"p75_pp": 2.75,
"p90_pp": 3.355,
"max_pp": 6.586
}
},
"batter_singles": {
"over": {
"observation_count": 239,
"mean_pp": 3.721,
"min_pp": 0,
"median_pp": 3.326,
"p75_pp": 4.986,
"p90_pp": 6.531,
"max_pp": 11.565
},
"under": {
"observation_count": 229,
"mean_pp": 2.193,
"min_pp": 0,
"median_pp": 1.903,
"p75_pp": 3.114,
"p90_pp": 4.412,
"max_pp": 7.966
}
},
"batter_stolen_bases": {
"over": {
"observation_count": 233,
"mean_pp": 2.451,
"min_pp": 0,
"median_pp": 1.917,
"p75_pp": 3.214,
"p90_pp": 5.484,
"max_pp": 12.381
},
"under": {
"observation_count": 77,
"mean_pp": 2.518,
"min_pp": 0,
"median_pp": 2.451,
"p75_pp": 3.617,
"p90_pp": 4.749,
"max_pp": 7.885
}
},
"batter_strikeouts": {
"over": {
"observation_count": 48,
"mean_pp": 1.416,
"min_pp": 0.094,
"median_pp": 1.171,
"p75_pp": 2.341,
"p90_pp": 2.777,
"max_pp": 3.614
},
"under": {
"observation_count": 0,
"mean_pp": null,
"min_pp": null,
"median_pp": null,
"p75_pp": null,
"p90_pp": null,
"max_pp": null
}
},
"batter_total_bases": {
"over": {
"observation_count": 222,
"mean_pp": 2.698,
"min_pp": 0,
"median_pp": 2.269,
"p75_pp": 4.027,
"p90_pp": 5.447,
"max_pp": 13.318
},
"under": {
"observation_count": 210,
"mean_pp": 1.466,
"min_pp": 0,
"median_pp": 1.245,
"p75_pp": 2.084,
"p90_pp": 3.034,
"max_pp": 5.83
}
},
"batter_triples": {
"over": {
"observation_count": 205,
"mean_pp": 1.427,
"min_pp": 0,
"median_pp": 1.391,
"p75_pp": 1.902,
"p90_pp": 2.395,
"max_pp": 4.792
},
"under": {
"observation_count": 0,
"mean_pp": null,
"min_pp": null,
"median_pp": null,
"p75_pp": null,
"p90_pp": null,
"max_pp": null
}
},
"batter_walks": {
"over": {
"observation_count": 159,
"mean_pp": 1.017,
"min_pp": 0,
"median_pp": 0.842,
"p75_pp": 1.525,
"p90_pp": 1.96,
"max_pp": 5.52
},
"under": {
"observation_count": 156,
"mean_pp": 0.907,
"min_pp": 0,
"median_pp": 0.75,
"p75_pp": 1.267,
"p90_pp": 1.735,
"max_pp": 7.778
}
},
"pitcher_earned_runs": {
"over": {
"observation_count": 17,
"mean_pp": 2.04,
"min_pp": 0,
"median_pp": 2.231,
"p75_pp": 3.361,
"p90_pp": 3.719,
"max_pp": 4.128
},
"under": {
"observation_count": 17,
"mean_pp": 2.088,
"min_pp": 0.55,
"median_pp": 2.132,
"p75_pp": 2.687,
"p90_pp": 3.277,
"max_pp": 3.958
}
},
"pitcher_hits_allowed": {
"over": {
"observation_count": 16,
"mean_pp": 1.696,
"min_pp": 0,
"median_pp": 1.803,
"p75_pp": 2.296,
"p90_pp": 3.001,
"max_pp": 3.488
},
"under": {
"observation_count": 16,
"mean_pp": 1.555,
"min_pp": 0,
"median_pp": 1.718,
"p75_pp": 2.306,
"p90_pp": 2.639,
"max_pp": 3.408
}
},
"pitcher_outs": {
"over": {
"observation_count": 19,
"mean_pp": 3.129,
"min_pp": 0,
"median_pp": 3.205,
"p75_pp": 4.741,
"p90_pp": 5.363,
"max_pp": 6.294
},
"under": {
"observation_count": 19,
"mean_pp": 3.135,
"min_pp": 0.206,
"median_pp": 3.175,
"p75_pp": 4.18,
"p90_pp": 5.683,
"max_pp": 6.408
}
},
"pitcher_strikeouts": {
"over": {
"observation_count": 28,
"mean_pp": 2.267,
"min_pp": 0.646,
"median_pp": 1.931,
"p75_pp": 3.159,
"p90_pp": 4.083,
"max_pp": 5.267
},
"under": {
"observation_count": 27,
"mean_pp": 2.224,
"min_pp": 0.381,
"median_pp": 2.141,
"p75_pp": 2.611,
"p90_pp": 3.145,
"max_pp": 5.541
}
},
"pitcher_walks": {
"over": {
"observation_count": 1,
"mean_pp": 1.886,
"min_pp": 1.886,
"median_pp": 1.886,
"p75_pp": 1.886,
"p90_pp": 1.886,
"max_pp": 1.886
},
"under": {
"observation_count": 1,
"mean_pp": 0.966,
"min_pp": 0.966,
"median_pp": 0.966,
"p75_pp": 0.966,
"p90_pp": 0.966,
"max_pp": 0.966
}
},
"player_assists": {
"over": {
"observation_count": 22,
"mean_pp": 1.981,
"min_pp": 0,
"median_pp": 1.752,
"p75_pp": 2.298,
"p90_pp": 4.28,
"max_pp": 7.488
},
"under": {
"observation_count": 10,
"mean_pp": 2.253,
"min_pp": 0,
"median_pp": 1.672,
"p75_pp": 2.789,
"p90_pp": 3.858,
"max_pp": 7.052
}
},
"player_points": {
"over": {
"observation_count": 32,
"mean_pp": 2.102,
"min_pp": 0,
"median_pp": 1.823,
"p75_pp": 2.68,
"p90_pp": 3.694,
"max_pp": 5.99
},
"under": {
"observation_count": 28,
"mean_pp": 1.705,
"min_pp": 0.235,
"median_pp": 1.613,
"p75_pp": 2.173,
"p90_pp": 3.375,
"max_pp": 3.843
}
},
"player_points_assists": {
"over": {
"observation_count": 9,
"mean_pp": 1.684,
"min_pp": 0.375,
"median_pp": 1.374,
"p75_pp": 1.994,
"p90_pp": 3.476,
"max_pp": 4.141
},
"under": {
"observation_count": 9,
"mean_pp": 1.597,
"min_pp": 0.244,
"median_pp": 1.428,
"p75_pp": 2.672,
"p90_pp": 2.806,
"max_pp": 3.326
}
},
"player_points_rebounds": {
"over": {
"observation_count": 17,
"mean_pp": 1.571,
"min_pp": 0,
"median_pp": 1.786,
"p75_pp": 2.203,
"p90_pp": 2.637,
"max_pp": 3.295
},
"under": {
"observation_count": 17,
"mean_pp": 1.373,
"min_pp": 0.185,
"median_pp": 1.298,
"p75_pp": 1.639,
"p90_pp": 2.064,
"max_pp": 2.725
}
},
"player_points_rebounds_assists": {
"over": {
"observation_count": 13,
"mean_pp": 2.354,
"min_pp": 0,
"median_pp": 2.481,
"p75_pp": 3.089,
"p90_pp": 3.339,
"max_pp": 5.16
},
"under": {
"observation_count": 13,
"mean_pp": 2.465,
"min_pp": 0.239,
"median_pp": 2.335,
"p75_pp": 2.869,
"p90_pp": 3.81,
"max_pp": 5.73
}
},
"player_rebounds": {
"over": {
"observation_count": 28,
"mean_pp": 2.299,
"min_pp": 0.09,
"median_pp": 2.097,
"p75_pp": 3.133,
"p90_pp": 4.137,
"max_pp": 7.139
},
"under": {
"observation_count": 22,
"mean_pp": 1.817,
"min_pp": 0.381,
"median_pp": 1.456,
"p75_pp": 2.676,
"p90_pp": 3.34,
"max_pp": 5.869
}
},
"player_rebounds_assists": {
"over": {
"observation_count": 10,
"mean_pp": 2.106,
"min_pp": 0,
"median_pp": 2.387,
"p75_pp": 3.008,
"p90_pp": 3.696,
"max_pp": 3.735
},
"under": {
"observation_count": 10,
"mean_pp": 2.261,
"min_pp": 0.732,
"median_pp": 2.409,
"p75_pp": 3.168,
"p90_pp": 3.302,
"max_pp": 4.141
}
},
"player_threes": {
"over": {
"observation_count": 23,
"mean_pp": 2.474,
"min_pp": 0.089,
"median_pp": 2.052,
"p75_pp": 4.432,
"p90_pp": 4.618,
"max_pp": 7.513
},
"under": {
"observation_count": 16,
"mean_pp": 1.972,
"min_pp": 0.414,
"median_pp": 1.783,
"p75_pp": 2.545,
"p90_pp": 4.023,
"max_pp": 4.179
}
}
}
},
"disclosure": {
"raw_prices_published": false,
"raw_prop_points_published": false,
"player_or_event_identifiers_published": false,
"audit_identifiers": "one-way SHA-256 hashes of internal observation identities"
}
}
De-identified Audit Sample
Fifty rows for checking the aggregation without publishing licensed raw feed data.
observation_id_sha256,sport,market,book_count,books,cross_book_capture_span_seconds,minutes_before_start_min,minutes_before_start_max,over_quote_count,over_dispersion_pp,under_quote_count,under_dispersion_pp
0020547c78bbc67b671b1ca70782faf858633f262fbf58f0c4fa77b558836b52,baseball_mlb,batter_singles,4,betmgm|draftkings|espnbet|fanduel,0,87,87,4,3.947,3,2.225
002a99a184696c56534679c760014821dff35605911a18d94041bc773f79ba68,baseball_mlb,batter_hits_runs_rbis,4,betmgm|draftkings|fanduel|williamhill_us,0,89,89,4,8.252,3,1.22
006ae6594c8a6a9f7ea5ce646a066fa3a3e7c959e736dc56a8968b1c8f0ad478,baseball_mlb,batter_total_bases,4,betmgm|draftkings|fanduel|williamhill_us,0,91,91,4,5.952,3,1.057
007f07930b15942007d20ab181ab18a4cdfbc37164d63f053d9e63cede9cc9a7,baseball_mlb,batter_home_runs,5,betmgm|draftkings|espnbet|fanduel|williamhill_us,0,87,87,5,0.587,1,
00b3034ee4ee1dbd08ef93aaae18ac55cf5935e83261e3c1bea0a5735f499362,baseball_mlb,batter_stolen_bases,2,betmgm|fanduel,0,89,89,2,1.003,0,
00c215e7ef87b67e89db9c44b2bbaee324eac5a7b7f910fe4eb67403a2b588ec,baseball_mlb,batter_stolen_bases,3,betmgm|draftkings|fanduel,0,92,92,3,2.471,2,0.175
00d23b1130ab6e210c87b0825550b3dbe02b54ae319607085c6061d054ef8f59,baseball_mlb,batter_hits,4,betmgm|draftkings|fanduel|williamhill_us,0,91,91,4,5.071,2,3.499
00df25695bb8b5d6ca686a48092c910f9c3500e5220652ed8dfce9a71976be68,baseball_mlb,batter_home_runs,4,betmgm|draftkings|fanduel|williamhill_us,0,88,88,4,1.435,0,
0115005d82e428f6efdbf6cf2a0a0775aa3090c581801a96dd69ae00a1964610,baseball_mlb,batter_singles,3,betmgm|draftkings|fanduel,0,91,91,3,3.175,2,1.427
01465008ebcfa71931aef9f1248deffe81fa1afd2ea6394b29b2cd911af8eb55,baseball_mlb,batter_home_runs,3,betmgm|draftkings|fanduel,0,107,107,3,1.818,0,
0151b15489c2eac0bf146b3447778b26f1f587bdc76f8b6221243c1697d1ff64,baseball_mlb,batter_runs_scored,4,betmgm|draftkings|fanduel|williamhill_us,0,88,88,4,2.269,2,1.341
0163da08f93c2311b7756098258d8f6fbbc7c8d47838b8f43dc91d49753fbd53,baseball_mlb,batter_total_bases,4,betmgm|draftkings|fanduel|williamhill_us,0,88,88,4,5.971,3,3.352
01b5ac33b6821600bd62563d136e84d0c9076235f5efe573f529d06e40796d30,baseball_mlb,batter_doubles,4,betmgm|draftkings|fanduel|williamhill_us,0,91,91,4,4.762,2,2.629
01d5143e84f1b9efd845dbb55fa33c3c822b7a10c02665aba5a2984516439b50,baseball_mlb,batter_walks,2,draftkings|williamhill_us,0,90,90,2,0.79,2,0
01f36d584f198e0f6529ff1d3461b4aaf41b350daec57d1a544d30ac65b94f0d,baseball_mlb,batter_triples,2,draftkings|fanduel,0,91,91,2,0.507,0,
01fd96361cc3905d372ec8424885715d7023ca739605268f42546b03968e78c1,baseball_mlb,batter_hits,4,betmgm|draftkings|fanduel|williamhill_us,0,88,88,4,1.841,2,0
02061bf51e99cfd950a8296132bfd679dc974776fe44eef050af8e05563e9d01,baseball_mlb,batter_home_runs,4,betmgm|draftkings|fanduel|williamhill_us,0,89,89,4,2.241,0,
02158174e94e7dd75b11c9a7f5efb287aae57e2673c35bce8313b144f13e37e9,baseball_mlb,batter_rbis,4,betmgm|draftkings|fanduel|williamhill_us,0,88,88,4,1.812,2,0.605
0217c05f2cd201bf472c9775a151cf9908270a6953bfb6ad0bc4e1219c9e2df3,baseball_mlb,batter_doubles,4,betmgm|draftkings|fanduel|williamhill_us,0,88,88,4,1.45,2,1.111
0273f6f346fc9e18e1623a359a26047c7ae89851aa2a80118496ec39de61de54,baseball_mlb,batter_home_runs,3,betmgm|draftkings|fanduel,0,88,88,3,4.227,0,
0280f30ed862646f85f47e220171af8657b872ddd93eca9f8f8628f365e8fae9,baseball_mlb,batter_rbis,4,betmgm|draftkings|fanduel|williamhill_us,0,88,88,4,1.538,2,0.481
028a020cc01881649f074e51f4681dd794625136ebcaed0e35a4711e60fcb364,baseball_mlb,batter_hits,4,betmgm|draftkings|fanduel|williamhill_us,0,90,90,4,1.412,2,0.882
0294662a1e65928d7cf0fbbd8c3c11dc1ea45a35ff1f65666f3db537ea419397,baseball_mlb,batter_singles,3,betmgm|draftkings|fanduel,0,88,88,3,3.12,2,1.231
02961ee9752cff52be5b0008455bc0571a4c1df02794dd57607e9e38c363f84c,baseball_mlb,batter_triples,2,draftkings|fanduel,0,88,88,2,1.885,0,
02b10e9214ad142c710d3114e4b68f32b68570f14b2377dde0e17a141e32bde4,baseball_mlb,batter_hits_runs_rbis,5,betmgm|draftkings|espnbet|fanduel|williamhill_us,0,87,87,5,2.381,4,2.828
02bdd45e891a2c75d1d08300c59893e362032b5d461c418154df884626443a9d,basketball_wnba,player_rebounds,3,draftkings|fanduel|williamhill_us,0,90,90,3,3.052,3,2.901
02c67e5028a5b16e7a0dd91afe1b8a5a05ce95a6c7263782318911853227557a,baseball_mlb,batter_singles,4,betmgm|draftkings|espnbet|fanduel,0,87,87,4,3.143,3,2.193
02c846bd427e4115829fe54f8c00d7077c579677eb40b4d88688edf35013d4ec,baseball_mlb,batter_home_runs,4,betmgm|draftkings|espnbet|fanduel,0,87,87,4,2.564,1,
02ce85598c1ccb755f6251a61fa70a0c4da1804c4722d8684e0777132f3be713,baseball_mlb,batter_walks,2,draftkings|williamhill_us,0,88,88,2,1.52,2,1.232
02ceba4c0dd2231365f4748294ed2c1943e5727683109185d2801e70a7b1034d,baseball_mlb,batter_hits_runs_rbis,4,betmgm|draftkings|fanduel|williamhill_us,0,88,88,4,2.052,3,1.984
02ee1e2fa627c88379fce0fedbb2cfe42683fb7606c28659f350cf6f4201c212,baseball_mlb,batter_triples,3,draftkings|espnbet|fanduel,0,87,87,3,1.885,0,
031eec3026c7cf3a07d2703a8a8f9674752751d1febfe45ff6a0339535f24618,baseball_mlb,batter_doubles,4,betmgm|draftkings|fanduel|williamhill_us,0,89,89,4,3.62,2,1.948
035e36fb32b86f99c09c276715e339270d3d36843b43a6f9a295871b447a8aed,baseball_mlb,batter_home_runs,3,betmgm|draftkings|fanduel,0,88,88,3,4.917,0,
0361f6edbc5faf33a743c1087baaf00be94db1dc3a67fddcb15effc9c7b80c4b,baseball_mlb,batter_home_runs,4,betmgm|draftkings|espnbet|fanduel,0,87,87,4,0.791,1,
03a29f6b520ac01474275fe6b074a30e4cbd80afc7e73a9d992015e6285498f4,baseball_mlb,batter_home_runs,4,betmgm|draftkings|fanduel|williamhill_us,0,89,89,4,0.916,0,
03a2d0b2b93874802d70f8dfc50d2b1ba02ca07b3e1dd49336073fda2aec3198,baseball_mlb,batter_runs_scored,4,betmgm|draftkings|fanduel|williamhill_us,0,88,88,4,2.794,2,2.938
03b293863c2791fd63ae81ce902682de4d6678feec8d2b55feb6bb414877c3d1,baseball_mlb,batter_stolen_bases,3,betmgm|draftkings|fanduel,0,92,92,3,0.949,2,3.297
03d0869cc62a982d4375ab880ed774c7513395641d87fcd7a649e1717dffe1e4,baseball_mlb,batter_runs_scored,5,betmgm|draftkings|espnbet|fanduel|williamhill_us,0,87,87,5,1.161,3,1.344
03e4989dea24df827aeffd898e1f70b349b5e2727efae2980a4f50584448aa17,baseball_mlb,batter_walks,3,draftkings|espnbet|williamhill_us,0,87,87,3,2.226,3,0.833
040f1f50bc7ac2ae8f8e2d7c30c3354ba45149137ab083ffe672f5ee5c59811b,baseball_mlb,batter_hits,4,betmgm|draftkings|fanduel|williamhill_us,0,89,89,4,2.745,2,0.36
041794bbf3b484f1a2f3021f91e47d73c4f5311e8454714f61fef44563ecc67f,baseball_mlb,batter_walks,2,draftkings|williamhill_us,0,88,88,2,0.866,2,0.515
043e49e61043d13cb7bdbfd76b1d088670fb98b1be6a66b6ef2ae45b0f6dc6aa,baseball_mlb,batter_walks,2,draftkings|williamhill_us,0,88,88,2,0.853,2,0.381
0444a43c804d3c39dca9a739d87bb14a47fb304f8f9fac21fe3b4b0c41719702,baseball_mlb,pitcher_strikeouts,4,betmgm|draftkings|fanduel|williamhill_us,0,88,88,4,1.125,3,1.659
0448f81fba2dd28b88edf8cc1e7cd07555c85a45afaee71703c0c96ad0074551,baseball_mlb,batter_doubles,4,betmgm|draftkings|fanduel|williamhill_us,0,88,88,4,4.218,2,3.175
046769f38e438a3799599cfe88eadaafa4a5a0f9feb947153c653d2ce28cfcac,baseball_mlb,batter_home_runs,4,betmgm|draftkings|fanduel|williamhill_us,0,89,89,4,4.385,0,
0478e29230ccfc4eef672b810485718fb6498e90b9b7e190a2fe2e7e6342276e,baseball_mlb,batter_home_runs,4,betmgm|draftkings|fanduel|williamhill_us,0,88,88,4,3.05,0,
04b813902d005eb0d35506486ef2c6f963ab66588bf457b67f2e902abf68fccd,baseball_mlb,batter_hits_runs_rbis,4,betmgm|draftkings|fanduel|williamhill_us,0,91,91,4,4.205,3,0.228
04c085247f07910966a733a2d045c16b27cf606a8960dfa75800905f414de99a,baseball_mlb,batter_strikeouts,2,draftkings|espnbet,0,87,87,2,2.737,0,
04d66650ce4a93ed38a7703a01927923af9345b96794a454627e6969c1744db0,baseball_mlb,batter_hits_runs_rbis,4,betmgm|draftkings|fanduel|williamhill_us,0,88,88,4,1.886,3,2.393
04f0bb1be70b4b30fc8f6e0151a38675508c41c40638d583663f677323fef1ac,baseball_mlb,batter_total_bases,4,betmgm|draftkings|fanduel|williamhill_us,0,89,89,4,2.536,3,2.49
Export Script
The versioned source used to build the study artifacts.
#!/usr/bin/env node
"use strict";
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const STUDY_ID = "propsbot-line-shopping-2026-07-18";
const STUDY_VERSION = "1.0.0";
const SCHEMA_VERSION = 1;
const WINDOW_START = new Date("2026-07-18T04:00:00.000Z");
const WINDOW_END = new Date("2026-07-19T04:00:00.000Z");
const DECISION_MINUTES = 90;
const CANDIDATE_MINUTES_MIN = 60;
const CANDIDATE_MINUTES_MAX = 120;
const MAX_CROSS_BOOK_SPAN_SECONDS = 300;
const AUDIT_SAMPLE_SIZE = 50;
const SOURCE_COLLECTION = "clvbooksnapshots";
function americanToImplied(american) {
if (!Number.isFinite(american) || american === 0 || Math.abs(american) > 10000) {
return null;
}
return american < 0
? (-american) / ((-american) + 100)
: 100 / (american + 100);
}
function percentile(values, quantile) {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
const index = (sorted.length - 1) * quantile;
const lower = Math.floor(index);
const upper = Math.ceil(index);
return sorted[lower] + (sorted[upper] - sorted[lower]) * (index - lower);
}
function round(value, places = 3) {
if (value === null || !Number.isFinite(value)) return null;
const scale = 10 ** places;
return Math.round((value + Number.EPSILON) * scale) / scale;
}
function distribution(values) {
if (!values.length) {
return {
observation_count: 0,
mean_pp: null,
min_pp: null,
median_pp: null,
p75_pp: null,
p90_pp: null,
max_pp: null,
};
}
return {
observation_count: values.length,
mean_pp: round(values.reduce((sum, value) => sum + value, 0) / values.length),
min_pp: round(Math.min(...values)),
median_pp: round(percentile(values, 0.5)),
p75_pp: round(percentile(values, 0.75)),
p90_pp: round(percentile(values, 0.9)),
max_pp: round(Math.max(...values)),
};
}
function spreadPp(prices) {
const implied = prices
.map((price) => americanToImplied(Number(price)))
.filter((value) => value !== null);
if (implied.length < 2) return { quoteCount: implied.length, spread: null };
return {
quoteCount: implied.length,
spread: (Math.max(...implied) - Math.min(...implied)) * 100,
};
}
function observationHash(group) {
const identity = [
STUDY_ID,
STUDY_VERSION,
group._id.event,
group._id.market,
group._id.player,
group._id.line,
].join("|");
return crypto.createHash("sha256").update(identity, "utf8").digest("hex");
}
function addValue(map, key, side, value) {
if (value === null) return;
if (!map[key]) map[key] = { over: [], under: [] };
map[key][side].push(value);
}
function finalizeGroupedDistributions(grouped) {
return Object.fromEntries(
Object.entries(grouped)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, sides]) => [
key,
{ over: distribution(sides.over), under: distribution(sides.under) },
]),
);
}
async function queryStudy() {
if (!process.env.DATABASE) {
throw new Error("DATABASE is required for query mode.");
}
if (process.env.PROPSBOT_READ_ONLY_ACK !== "1") {
throw new Error("Set PROPSBOT_READ_ONLY_ACK=1 to acknowledge read-only query mode.");
}
// Loaded only in query mode. The production worker already provides mongoose.
const mongoose = require("mongoose");
await mongoose.connect(process.env.DATABASE, {
readPreference: "secondaryPreferred",
serverSelectionTimeoutMS: 30000,
});
try {
const db = mongoose.connection.db;
const collection = db.collection(SOURCE_COLLECTION);
const collectionStats = await db.command({ collStats: SOURCE_COLLECTION, scale: 1 });
const pipeline = [
{
$match: {
captured_at: { $gte: WINDOW_START, $lt: WINDOW_END },
is_pre_commence: true,
minutes_to_commence: {
$gte: CANDIDATE_MINUTES_MIN,
$lte: CANDIDATE_MINUTES_MAX,
},
line: { $ne: null },
},
},
{
$addFields: {
distance_to_decision: {
$abs: { $subtract: ["$minutes_to_commence", DECISION_MINUTES] },
},
},
},
{
$sort: {
event_id: 1,
market_key: 1,
player_id: 1,
line: 1,
bookmaker_key: 1,
distance_to_decision: 1,
captured_at: 1,
},
},
{
$group: {
_id: {
sport: "$sport_key",
event: "$event_id",
market: "$market_key",
player: "$player_id",
line: "$line",
book: "$bookmaker_key",
},
book: { $first: "$bookmaker_key" },
over: { $first: "$over_price" },
under: { $first: "$under_price" },
capturedAt: { $first: "$captured_at" },
minutesToCommence: { $first: "$minutes_to_commence" },
},
},
{
$group: {
_id: {
sport: "$_id.sport",
event: "$_id.event",
market: "$_id.market",
player: "$_id.player",
line: "$_id.line",
},
quotes: {
$push: {
book: "$book",
over: "$over",
under: "$under",
capturedAt: "$capturedAt",
minutesToCommence: "$minutesToCommence",
},
},
bookCount: { $sum: 1 },
captureMin: { $min: "$capturedAt" },
captureMax: { $max: "$capturedAt" },
},
},
{ $match: { bookCount: { $gte: 2 } } },
];
const candidateGroups = await collection.aggregate(pipeline, {
allowDiskUse: true,
maxTimeMS: 60000,
hint: "captured_at_ttl",
readPreference: "secondaryPreferred",
}).toArray();
const eligible = candidateGroups.filter((group) => (
new Date(group.captureMax).getTime() - new Date(group.captureMin).getTime()
) <= MAX_CROSS_BOOK_SPAN_SECONDS * 1000);
const overall = { over: [], under: [] };
const bySport = {};
const byMarket = {};
const bookCoverage = {};
const bookCountDistribution = {};
const auditRows = [];
let actualCaptureMin = null;
let actualCaptureMax = null;
for (const group of eligible) {
const over = spreadPp(group.quotes.map((quote) => quote.over));
const under = spreadPp(group.quotes.map((quote) => quote.under));
if (over.spread !== null) overall.over.push(over.spread);
if (under.spread !== null) overall.under.push(under.spread);
addValue(bySport, group._id.sport, "over", over.spread);
addValue(bySport, group._id.sport, "under", under.spread);
addValue(byMarket, group._id.market, "over", over.spread);
addValue(byMarket, group._id.market, "under", under.spread);
bookCountDistribution[group.bookCount] = (bookCountDistribution[group.bookCount] || 0) + 1;
const books = group.quotes.map((quote) => quote.book).filter(Boolean).sort();
const minutes = group.quotes
.map((quote) => Number(quote.minutesToCommence))
.filter(Number.isFinite);
for (const book of books) bookCoverage[book] = (bookCoverage[book] || 0) + 1;
const groupMin = new Date(group.captureMin);
const groupMax = new Date(group.captureMax);
if (!actualCaptureMin || groupMin < actualCaptureMin) actualCaptureMin = groupMin;
if (!actualCaptureMax || groupMax > actualCaptureMax) actualCaptureMax = groupMax;
if (over.spread !== null || under.spread !== null) {
auditRows.push({
observation_id_sha256: observationHash(group),
sport: group._id.sport,
market: group._id.market,
book_count: group.bookCount,
books: books.join("|"),
cross_book_capture_span_seconds: round((groupMax - groupMin) / 1000),
minutes_before_start_min: minutes.length ? Math.min(...minutes) : null,
minutes_before_start_max: minutes.length ? Math.max(...minutes) : null,
over_quote_count: over.quoteCount,
over_dispersion_pp: round(over.spread),
under_quote_count: under.quoteCount,
under_dispersion_pp: round(under.spread),
});
}
}
auditRows.sort((a, b) => a.observation_id_sha256.localeCompare(b.observation_id_sha256));
const auditSample = auditRows.slice(0, AUDIT_SAMPLE_SIZE);
if (auditSample.length !== AUDIT_SAMPLE_SIZE) {
throw new Error(`Expected ${AUDIT_SAMPLE_SIZE} audit rows, found ${auditSample.length}.`);
}
const aggregate = {
schema_version: SCHEMA_VERSION,
study_id: STUDY_ID,
study_version: STUDY_VERSION,
generated_at: new Date().toISOString(),
title: "July 18, 2026 MLB and WNBA Player-Prop Line-Shopping Study",
source: {
collection: SOURCE_COLLECTION,
collection_document_count_at_run: collectionStats.count,
provider_category: "licensed sportsbook market data",
raw_data_included: false,
},
methodology: {
study_window_start_utc: WINDOW_START.toISOString(),
study_window_end_utc_exclusive: WINDOW_END.toISOString(),
candidate_minutes_before_start: [CANDIDATE_MINUTES_MIN, CANDIDATE_MINUTES_MAX],
target_minutes_before_start: DECISION_MINUTES,
maximum_cross_book_capture_span_seconds: MAX_CROSS_BOOK_SPAN_SECONDS,
identity: "sport + event + market + player + exact prop point",
book_selection: "nearest observation per book to the 90-minute target; earlier capture wins exact ties",
eligibility: "at least two books at the exact same prop point and selected captures within five minutes",
price_conversion: "negative: -a/(-a+100); positive: 100/(a+100)",
dispersion: "maximum implied probability minus minimum implied probability, in percentage points",
percentile_method: "linear interpolation at (n-1)q",
},
counts: {
candidate_multi_book_prop_points: candidateGroups.length,
eligible_same_cycle_prop_points: eligible.length,
audit_sample_rows: auditSample.length,
book_count_distribution: Object.fromEntries(
Object.entries(bookCountDistribution).sort(([a], [b]) => Number(a) - Number(b)),
),
},
capture_range_selected_rows: {
minimum_utc: actualCaptureMin ? actualCaptureMin.toISOString() : null,
maximum_utc: actualCaptureMax ? actualCaptureMax.toISOString() : null,
},
book_coverage_prop_points: Object.fromEntries(
Object.entries(bookCoverage).sort(([a], [b]) => a.localeCompare(b)),
),
distributions: {
overall: {
over: distribution(overall.over),
under: distribution(overall.under),
},
by_sport: finalizeGroupedDistributions(bySport),
by_market: finalizeGroupedDistributions(byMarket),
},
disclosure: {
raw_prices_published: false,
raw_prop_points_published: false,
player_or_event_identifiers_published: false,
audit_identifiers: "one-way SHA-256 hashes of internal observation identities",
},
};
return { schema_version: SCHEMA_VERSION, aggregate, audit_sample: auditSample };
} finally {
await mongoose.disconnect();
}
}
function csvCell(value) {
if (value === null || value === undefined) return "";
const text = String(value);
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function toCsv(rows, columns) {
const lines = [columns.join(",")];
for (const row of rows) {
lines.push(columns.map((column) => csvCell(row[column])).join(","));
}
return `${lines.join("\n")}\n`;
}
function aggregateCsvRows(aggregate) {
const rows = [];
const append = (scopeType, scopeValue, sides) => {
for (const side of ["over", "under"]) {
rows.push({ scope_type: scopeType, scope_value: scopeValue, side, ...sides[side] });
}
};
append("overall", "all", aggregate.distributions.overall);
for (const [sport, sides] of Object.entries(aggregate.distributions.by_sport)) {
append("sport", sport, sides);
}
for (const [market, sides] of Object.entries(aggregate.distributions.by_market)) {
append("market", market, sides);
}
return rows;
}
function assertNoRestrictedFields(envelope) {
const forbidden = new Set([
"event_id",
"player_id",
"player_name",
"line",
"over_price",
"under_price",
"quotes",
]);
const visit = (value) => {
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
if (forbidden.has(key)) throw new Error(`Restricted output field detected: ${key}`);
visit(child);
}
};
visit(envelope);
}
function sha256File(filePath) {
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
}
function materialize(envelope, outputDir) {
if (envelope.schema_version !== SCHEMA_VERSION) {
throw new Error(`Unsupported envelope schema: ${envelope.schema_version}`);
}
if (!envelope.aggregate || !Array.isArray(envelope.audit_sample)) {
throw new Error("Envelope is missing aggregate or audit_sample.");
}
assertNoRestrictedFields(envelope);
fs.mkdirSync(outputDir, { recursive: true });
const aggregateJsonPath = path.join(outputDir, "aggregate.json");
const aggregateCsvPath = path.join(outputDir, "aggregate.csv");
const auditCsvPath = path.join(outputDir, "audit-sample-50.csv");
const manifestPath = path.join(outputDir, "SHA256SUMS.txt");
const aggregateColumns = [
"scope_type",
"scope_value",
"side",
"observation_count",
"mean_pp",
"min_pp",
"median_pp",
"p75_pp",
"p90_pp",
"max_pp",
];
const auditColumns = [
"observation_id_sha256",
"sport",
"market",
"book_count",
"books",
"cross_book_capture_span_seconds",
"minutes_before_start_min",
"minutes_before_start_max",
"over_quote_count",
"over_dispersion_pp",
"under_quote_count",
"under_dispersion_pp",
];
fs.writeFileSync(aggregateJsonPath, `${JSON.stringify(envelope.aggregate, null, 2)}\n`, "utf8");
fs.writeFileSync(
aggregateCsvPath,
toCsv(aggregateCsvRows(envelope.aggregate), aggregateColumns),
"utf8",
);
fs.writeFileSync(auditCsvPath, toCsv(envelope.audit_sample, auditColumns), "utf8");
const manifestFiles = [
"README.md",
"export-line-shopping-study-v1.js",
"aggregate.json",
"aggregate.csv",
"audit-sample-50.csv",
];
for (const file of manifestFiles) {
const fullPath = path.join(outputDir, file);
if (!fs.existsSync(fullPath)) throw new Error(`Manifest input is missing: ${file}`);
}
const manifest = manifestFiles
.map((file) => `${sha256File(path.join(outputDir, file))} ${file}`)
.join("\n");
fs.writeFileSync(manifestPath, `${manifest}\n`, "utf8");
}
async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString("utf8");
}
function argumentValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : null;
}
async function main() {
const queryMode = process.argv.includes("--query-stdout")
|| process.env.PROPSBOT_STUDY_MODE === "query-stdout";
const materializeMode = process.argv.includes("--materialize-stdin");
if (queryMode === materializeMode) {
throw new Error(
"Choose exactly one mode: --query-stdout or --materialize-stdin --output-dir <path>.",
);
}
if (queryMode) {
const envelope = await queryStudy();
assertNoRestrictedFields(envelope);
process.stdout.write(`${JSON.stringify(envelope)}\n`);
return;
}
const outputDir = argumentValue("--output-dir") || __dirname;
const input = await readStdin();
if (!input.trim()) throw new Error("Materialize mode requires a JSON envelope on stdin.");
materialize(JSON.parse(input), path.resolve(outputDir));
}
main().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exitCode = 1;
});
Chart Script
The source used to render the public chart from aggregate.json.
from __future__ import annotations
import json
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path("studies/line-shopping-2026-07-18/v1")
INPUT_PATH = ROOT / "aggregate.json"
OUTPUT_PATH = ROOT / "line-shopping-median-p90-by-sport.png"
WIDTH = 1600
HEIGHT = 900
def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
names = (
("C:/Windows/Fonts/segoeuib.ttf", "C:/Windows/Fonts/segoeui.ttf")
if bold
else ("C:/Windows/Fonts/segoeui.ttf", "C:/Windows/Fonts/arial.ttf")
)
for name in names:
try:
return ImageFont.truetype(name, size)
except OSError:
continue
return ImageFont.load_default()
def main() -> int:
data = json.loads(INPUT_PATH.read_text(encoding="utf-8"))
distributions = data["distributions"]
rows = [
("All eligible props", distributions["overall"]),
("MLB", distributions["by_sport"]["baseball_mlb"]),
("WNBA", distributions["by_sport"]["basketball_wnba"]),
]
image = Image.new("RGB", (WIDTH, HEIGHT), "#07101f")
draw = ImageDraw.Draw(image)
title_font = font(48, bold=True)
subtitle_font = font(25)
label_font = font(28, bold=True)
value_font = font(22, bold=True)
note_font = font(19)
draw.text((90, 70), "Same-line player prop price dispersion", fill="#f7fafc", font=title_font)
draw.text(
(90, 140),
"Median and 90th-percentile gap in break-even implied probability",
fill="#b9c7d8",
font=subtitle_font,
)
chart_left = 390
chart_right = 1160
chart_width = chart_right - chart_left
max_value = 6.0
start_y = 250
row_gap = 170
bar_height = 34
colors = {"over": "#2dd4bf", "under": "#f6c85f"}
for tick in range(7):
x = chart_left + int(chart_width * tick / max_value)
draw.line((x, 220, x, 700), fill="#25344a", width=2)
draw.text((x - 10, 710), str(tick), fill="#93a4b8", font=note_font)
draw.text((chart_left, 752), "Percentage-point difference", fill="#b9c7d8", font=note_font)
for index, (label, distribution) in enumerate(rows):
y = start_y + index * row_gap
draw.text((90, y + 35), label, fill="#f7fafc", font=label_font)
for side_index, side in enumerate(("over", "under")):
stats = distribution[side]
bar_y = y + side_index * 64
median = float(stats["median_pp"])
p90 = float(stats["p90_pp"])
median_x = chart_left + int(chart_width * median / max_value)
p90_x = chart_left + int(chart_width * p90 / max_value)
draw.rounded_rectangle(
(chart_left, bar_y, p90_x, bar_y + bar_height),
radius=8,
fill="#26384d",
)
draw.rounded_rectangle(
(chart_left, bar_y, median_x, bar_y + bar_height),
radius=8,
fill=colors[side],
)
draw.ellipse(
(p90_x - 8, bar_y + 9, p90_x + 8, bar_y + 25),
fill="#f7fafc",
)
draw.text((chart_left - 82, bar_y + 2), side.title(), fill=colors[side], font=note_font)
draw.text(
(1190, bar_y + 2),
f"{median:.3f} median / {p90:.3f} p90",
fill="#dbe5f0",
font=value_font,
)
draw.rounded_rectangle((88, 808, 1512, 852), radius=10, fill="#111f31")
note = (
"2,763 eligible exact-line MLB and WNBA prop points, five books, July 18, 2026. "
"One-slate price study; not an outcome, ROI, or guaranteed-savings study."
)
draw.text((110, 819), note, fill="#c8d4e3", font=note_font)
image.save(OUTPUT_PATH, format="PNG", optimize=True)
if OUTPUT_PATH.stat().st_size < 45_000:
raise RuntimeError(f"Generated chart is unexpectedly small: {OUTPUT_PATH.stat().st_size} bytes")
print(f"Wrote {OUTPUT_PATH} ({OUTPUT_PATH.stat().st_size} bytes)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256 Manifest
Checksums for the original version 1.0.0 data package.
ceb21aa1ec47e5d54aea0618d2a54353bc37f79a9c12685478119638c0192eb0 README.md
faa3826107daf8bc53c56c359b06bb4de14c5dccbb2f7ba192512ef5fa2272a4 export-line-shopping-study-v1.js
49dcb9c35869194e1fa91669d48c73604b6255ea2cc60ad1c8b6cf09c0ec4fde aggregate.json
ad086b389eb6cf91fb7cbfdaecc712d5f42a5c2c1ec49821de66d3a8e06c77f6 aggregate.csv
86b6302db4fa27a0793df7092b75cf80c2f9532ccee990fa8c39fcb7c517ecbd audit-sample-50.csv
Study Chart
Open the full-size 1,600 x 900 chart. It visualizes the published median and 90th-percentile values from aggregate.json.
Citation
PropsBot.AI, July 18, 2026 MLB and WNBA Player-Prop Line-Shopping Study, version 1.0.0. Cite the main study and retain its one-slate, exact-line and T-90 limitations.