Date: 2026-04-02 Scope: How team total data flows from collection through edge detection to execution Status: DEPLOYED (2026-04-02)
Pinnacle Page Load
│
├── HTML Parse ──────────────> PinnacleMatchup[] (ML, spread, total)
│ │
│ ▼
│ storePinnacleRows()
│ │
│ ▼
│ sports_odds_snapshots
│ (source='pinnacle')
│
└── Arcadia API Intercept ──> PinnacleTeamTotal[] (per-team line + odds)
│
▼
storePinnacleTeamTotalRows()
│
▼
sports_odds_snapshots
(source='pinnacle-teamtotal')
Kalshi API Pull
│
▼
KalshiAltLine[] (marketType='teamTotal', teamCode='BOS')
│
▼
storeKalshiRows()
│
▼
sports_odds_snapshots (source='kalshi')
Edge Scanner (scanForEdges)
│
├── Pinnacle spread + total ──> impliedScores() ──> per-team expected score
│
├── Normal curve (σ ≈ 9.9) ──> P(team > threshold)
│
├── Compare model prob vs Kalshi execution price
│
├── Apply 7% Kalshi fee
│
└── Store edge in sports_edges (marketType='teamTotal')
| Condition | Interval |
|---|---|
| No games today | Every 2 hours (baseline) |
| T-4h before first game | Every 15 minutes |
| T-1h before first game | Every 5 minutes |
| Closing snapshot | At each game's start time |
Team totals are collected in the same browser session as regular Pinnacle odds — no extra Chromium launches.
| Condition | Interval |
|---|---|
| Baseline | Every 30 minutes |
| T-1h before first game | Every 5 minutes |
| Event-driven | Immediate snap on Pinnacle movement |
| Market close | Snapshot at game start |
Pinnacle's React SPA fetches odds from guest.api.arcadia.pinnacle.com. During page load:
x-api-key from request headers (reusable)In the straight markets response:
| Field | Game Total | Team Total |
|---|---|---|
type |
"total" |
"total" |
participantId |
absent | present |
period |
0 (full game) | 0 (full game) |
prices[].designation |
"over" / "under" |
"over" / "under" |
prices[].points |
game total line | team total line |
The participantId field is what distinguishes team totals from game totals.
If the API intercept fails (no data captured during page load), the scraper:
x-api-key + leagueId to call the API directly via page.evaluate()scrapePinnacleOdds() (regular scraper, no team totals)| Column | Value | Example |
|---|---|---|
| sport | 'nba' |
'nba' |
| game_id | 'Away@Home::TT:TeamName' |
'BOS@MIL::TT:Boston Celtics' |
| home_team | Home team full name | 'Milwaukee Bucks' |
| away_team | Away team full name | 'Boston Celtics' |
| source | 'pinnacle-teamtotal' |
'pinnacle-teamtotal' |
| snapshot_type | 'scheduled' / 'closing' |
'scheduled' |
| total | Team total line | 112.5 |
| total_over_odds | American odds for over | -110 |
| total_under_odds | American odds for under | -110 |
| captured_at | ISO timestamp | '2026-04-02T15:30:00.000Z' |
The game_id format Away@Home::TT:TeamName encodes the team identity so the edge scanner can match it to the correct Kalshi alt lines.
| Column | Value |
|---|---|
| market_type | 'teamTotal' |
| team_code | Kalshi team code (e.g., 'BOS') |
| pinnacle_anchor | Team implied score (e.g., 115.5) |
| de_vig_method | 'implied' |
| All other columns | Same as spread/total edges |
The pipeline maps between three naming systems:
Pinnacle: "Boston Celtics"
Kalshi: "BOS"
ESPN: "Boston Celtics"
Mapping uses standardize() from team-codes.ts + findKalshiCode():
standardize()findKalshiCode()teamCode matched against Kalshi codes for home/awayscanForEdges() receives:
pinnacleSnapshot: PinnacleMarket[] — spread + total per gamekalshiSnapshot: KalshiAltLine[] — includes marketType='teamTotal' lines with teamCodemarketType === 'teamTotal' && teamCodegameIdimpliedScores() → home/away expected scoresteamCode to home/away via Kalshi code lookupmean = teamImplied, σ = gameSigma / sqrt(2)P(score > threshold) via Normal CDF exceedanceEach ticker is deduped per scan type per day:
dedupKey = `${alt.ticker}::shin`
| Key | Source | Updated by |
|---|---|---|
pinnacle-nba-teamtotal |
Pinnacle Arcadia API | collectPinnacle() |
edge-scanner-nba-teamtotal |
Edge scanner output | scanForEdges() (when wired) |
kalshi-nba |
Kalshi API | collectKalshi() (includes KXNBATEAMTOTAL) |
Team total settlement follows the same pattern as game totals:
Settlement is sport-agnostic — the existing settleGameEdges() function handles marketType='teamTotal' alongside other types.
| Risk | Mitigation |
|---|---|
| Arcadia API key rotation | Key is captured fresh each page load — no stale keys |
| Arcadia API changes format | Fallback to derived team totals from spread + game total |
| Pinnacle blocks stealth Chromium | Odds API fallback for game lines; team totals derived |
| Team code mismatch | standardize() covers all 30 NBA teams; unresolved codes logged |
| Low Kalshi team total liquidity | Max spread filter (20c) prevents trading illiquid contracts |
| Normal distribution tail error | Tail confidence flag degrades far-from-anchor lines |
| Sport | Series Ticker | Status |
|---|---|---|
| NBA | KXNBATEAMTOTAL |
DEPLOYED |
| MLB | KXMLBTEAMTOTAL |
Config exists, scraper ready |
| NHL | TBD | Not yet on Kalshi |
| NCAAB | TBD | Not yet on Kalshi |
| Soccer | N/A | Goals-based, different model |
MLB team totals can be activated by adding 'mlb' to the combined scraper path in collectPinnacle().