big-proppa / sql /nfl_props_schema.sql
Claude
Claude Sonnet 4.6
Initial commit — HEY BIG PROPPA! full app
367f592 unverified
Raw History Blame Contribute Delete
10.9 kB
-- =============================================================================
-- frontal-lobe2 : NFL player props -- RotoWire (all 22 markets, 8 books)
-- =============================================================================
-- Source: https://www.rotowire.com/betting/nfl/player-props.php
-- Confirmed 2026-09-25:
-- - robots.txt allows /betting/ (only /users/login.php and /account/ disallowed)
-- - the real prop lines are RotoWire's OWN first-party betting tables, embedded
-- directly as JSON inside the page's static HTML (no JS execution needed to
-- read them -- a plain HTTP GET returns the data already in the response body)
-- - `isPaywalled = true` in the page's JS only gates RotoWire's own CSV *export*
-- button ("must be a paid subscriber" to click Export). It does NOT hide or
-- restrict the underlying data, which is visible to any visitor and is what
-- this schema loads.
-- - The page ALSO embeds a separate third-party "Props.Cash" demo widget with
-- its own weather-context feed (an unrelated Val Town endpoint). That widget
-- is NOT used as a source here -- only RotoWire's first-party tables are.
--
-- 22 markets confirmed populated (field counts from the raw page, 2026-09-25):
-- TD scorer: firsttd, anytd, lasttd, twotd, threetd
-- Passing: comp, passatt, passyds, passtd, intsthrown
-- Rushing: rushyds, longrush
-- Receiving: recs, recyds, rushrec (combined rush+rec yards)
-- Defense: tackle, solo, assist, sack
-- Kicking: kickpts, fgm, xpm
--
-- 8 sportsbooks tracked (not every book posts every market -- columns are
-- nullable and a book's absence on a given market/game is normal, not a bug):
-- betr, betrivers, caesars, draftkings, fanduel, fanatics, mgm, hardrock,
-- thescore, circasports
-- (note: some prop blocks show "fanduel", others show "fanatics" in the same
-- book-name slot -- RotoWire appears to have transitioned sportsbook partners
-- mid-market-rollout; both are stored as distinct book codes, not merged.)
--
-- GRAIN: one row per (player, game, book, market, pull). Lines move during the
-- week, so this is append-only like odds_snapshots in the CFB schema -- never
-- update or delete a row, always insert a new snapshot. Closing-line value (CLV)
-- analysis depends on keeping every snapshot, not just the latest.
--
-- Engine: DuckDB.
-- =============================================================================
CREATE SCHEMA IF NOT EXISTS gold;
CREATE TABLE IF NOT EXISTS gold.nfl_prop_market_catalog (
market_slug VARCHAR PRIMARY KEY, -- e.g. 'passyds'
market_name VARCHAR NOT NULL, -- e.g. 'Pass Yards'
category VARCHAR NOT NULL, -- td_scorer | passing | rushing | receiving | defense | kicking
line_type VARCHAR NOT NULL -- 'over_under' (has a numeric line) | 'moneyline_only' (TD scorer props)
);
INSERT OR REPLACE INTO gold.nfl_prop_market_catalog VALUES
('firsttd', 'First TD', 'td_scorer', 'moneyline_only'),
('anytd', 'Anytime TD', 'td_scorer', 'moneyline_only'),
('lasttd', 'Last TD', 'td_scorer', 'moneyline_only'),
('twotd', 'Score 2+ TDs', 'td_scorer', 'moneyline_only'),
('threetd', 'Score 3+ TDs', 'td_scorer', 'moneyline_only'),
('comp', 'Completions', 'passing', 'over_under'),
('passatt', 'Pass Attempts', 'passing', 'over_under'),
('passyds', 'Pass Yards', 'passing', 'over_under'),
('passtd', 'Pass TDs', 'passing', 'over_under'),
('intsthrown', 'Ints Thrown', 'passing', 'over_under'),
('rushyds', 'Rush Yards', 'rushing', 'over_under'),
('longrush', 'Longest Rush', 'rushing', 'over_under'),
('recs', 'Receptions', 'receiving', 'over_under'),
('recyds', 'Rec Yards', 'receiving', 'over_under'),
('rushrec', 'Rush + Rec Yards', 'receiving', 'over_under'),
('tackle', 'Total Tackles', 'defense', 'over_under'),
('solo', 'Solo Tackles', 'defense', 'over_under'),
('assist', 'Assisted Tackles', 'defense', 'over_under'),
('sack', 'Sacks', 'defense', 'over_under'),
('kickpts', 'Kicking Points', 'kicking', 'over_under'),
('fgm', 'Field Goals Made', 'kicking', 'over_under'),
('xpm', 'Extra Points Made', 'kicking', 'over_under');
-- Scoped to the books the user actually wants tracked (draftkings, fanduel,
-- caesars). RotoWire's source page also carries betr, betrivers, fanatics, mgm,
-- hardrock, thescore, circasports -- omitted here by request, not by limitation.
-- bet365 was requested too but is NOT present on this source at all (confirmed
-- 2026-09-25); it would need a different data source if required later.
--
-- KNOWN TEAM-CODE VARIANCE: RotoWire uses 'LAR' for the LA Rams; our schedule
-- table (nflverse-sourced) uses 'LA'. Confirmed this causes is_pregame to be
-- NULL for that one team (no match found, not a wrong guess) rather than
-- silently mapping it incorrectly. Fix properly (a small team-code alias
-- table) if this team's line movement is needed; not blocking otherwise.
CREATE TABLE IF NOT EXISTS gold.nfl_prop_book_catalog (
book_slug VARCHAR PRIMARY KEY, -- e.g. 'draftkings'
book_name VARCHAR NOT NULL -- e.g. 'DraftKings'
);
INSERT OR REPLACE INTO gold.nfl_prop_book_catalog VALUES
('draftkings', 'DraftKings'),
('fanduel', 'FanDuel'),
('caesars', 'Caesars');
-- One row per player/game/book/market/pull. Append-only.
CREATE TABLE IF NOT EXISTS gold.nfl_prop_line_rotowire (
pull_id VARCHAR NOT NULL, -- one id per script run (this snapshot)
fetched_at_utc TIMESTAMPTZ NOT NULL, -- TIMESTAMPTZ, not TIMESTAMP: a naive TIMESTAMP
-- silently stored LOCAL time under a column
-- literally named _utc (confirmed real bug,
-- fixed here -- old rows before this fix are
-- off by the host's UTC offset)
game_id VARCHAR, -- rotowire gameID
player_id VARCHAR, -- rotowire playerID
player_name VARCHAR,
team VARCHAR,
opponent VARCHAR, -- as shown, e.g. '@MIA' or 'SEA' (home/away encoded)
market_slug VARCHAR NOT NULL, -- FK -> nfl_prop_market_catalog
book_slug VARCHAR NOT NULL, -- FK -> nfl_prop_book_catalog
line DOUBLE, -- numeric line (NULL for moneyline-only TD props)
over_price_american INTEGER, -- NULL for TD scorer props (no over/under side)
under_price_american INTEGER,
moneyline_american INTEGER, -- for TD scorer props: the single moneyline (e.g. firsttd odds)
is_pregame BOOLEAN, -- true if fetched before that game's kickoff;
-- NULL if kickoff time unknown at load time.
-- Required so movement analysis never mixes
-- pre-game and live in-game lines (confirmed
-- real contamination: a fake "recyds 60.5 ->
-- 174.5" move was actually two in-game snapshots
-- of a game already underway)
source_name VARCHAR NOT NULL DEFAULT 'rotowire',
PRIMARY KEY (pull_id, player_id, game_id, market_slug, book_slug)
);
CREATE INDEX IF NOT EXISTS ix_propline_rw_lookup
ON gold.nfl_prop_line_rotowire (player_id, market_slug, fetched_at_utc);
CREATE INDEX IF NOT EXISTS ix_propline_rw_game
ON gold.nfl_prop_line_rotowire (game_id, market_slug);
-- Latest snapshot per player/market/book -- what you'd actually compare against
-- your own model right now, without re-deriving "most recent" every query.
CREATE OR REPLACE VIEW gold.v_nfl_prop_line_latest AS
SELECT p.*
FROM gold.nfl_prop_line_rotowire p
WHERE p.fetched_at_utc = (
SELECT max(p2.fetched_at_utc)
FROM gold.nfl_prop_line_rotowire p2
WHERE p2.player_id = p.player_id
AND p2.market_slug = p.market_slug
AND p2.book_slug = p.book_slug
AND p2.game_id = p.game_id
);
-- Best (highest) over price and best (highest) under price across books, per
-- player/market/game -- the actual best-price shopping view for placing a bet.
CREATE OR REPLACE VIEW gold.v_nfl_prop_best_price AS
SELECT game_id, player_id, ANY_VALUE(player_name) AS player_name, market_slug,
max(line) AS line, -- lines mostly agree across books; max as a representative value
max(over_price_american) AS best_over_price,
arg_max(book_slug, over_price_american) AS best_over_book,
max(under_price_american) AS best_under_price,
arg_max(book_slug, under_price_american) AS best_under_book,
max(moneyline_american) AS best_moneyline_price,
arg_max(book_slug, moneyline_american) AS best_moneyline_book,
count(DISTINCT book_slug) AS num_books
FROM gold.v_nfl_prop_line_latest
GROUP BY game_id, player_id, market_slug;
-- Line movement: first-seen vs latest, per player/market/book. This is the CLV
-- input -- compare where your model fired to the closing number, not just today's.
--
-- PRE-GAME ONLY (is_pregame = true), on purpose. Confirmed real contamination
-- otherwise: comparing two in-game snapshots of a game already underway
-- produced a fake "Drake London recyds 60.5 -> 174.5" move -- that's live
-- re-pricing during the game, not pre-game market movement. Never compare
-- across that boundary.
CREATE OR REPLACE VIEW gold.v_nfl_prop_line_movement AS
SELECT player_id, ANY_VALUE(player_name) AS player_name, market_slug, book_slug,
min(fetched_at_utc) AS first_seen_at,
max(fetched_at_utc) AS last_seen_at,
arg_min(line, fetched_at_utc) AS opening_line,
arg_max(line, fetched_at_utc) AS current_line,
arg_max(line, fetched_at_utc) - arg_min(line, fetched_at_utc) AS line_delta,
count(DISTINCT fetched_at_utc) AS num_snapshots
FROM gold.nfl_prop_line_rotowire
WHERE line IS NOT NULL AND is_pregame = true
GROUP BY player_id, market_slug, book_slug
HAVING count(DISTINCT fetched_at_utc) > 1;