Your dashboard puts a long shot above the favorite. You check the sorting function. It's fine. The damage happened before the rows reached it.
A field named homeMoneyline looks reassuringly numeric. But the Actor preserves published prices, including fractional strings. Applying parseFloat before identifying the notation can reverse your implied-probability ranking.
Example: this assertion shows a fractional long shot incorrectly outranking an American-priced favorite after a permissive conversion.
import assert from 'node:assert/strict'; const americanProbability = (price: number) => price > 0 ? 100 / (price + 100) : -price / (-price + 100); const fractionalLongShot = '20/1'; const americanFavorite = -110; assert.ok( americanProbability(parseFloat(fractionalLongShot)) > americanProbability(americanFavorite), ); Nothing throws. That's the annoying part.
The reusable pattern is to preserve the source value, normalize only recognized representations, and give ingestion its own replay identity. Your dashboard should receive an explicit review state whenever those steps can't produce a trustworthy comparison.
Fetch the prices without interpreting them
Use odds mode because it returns a record per sportsbook line. Keep the request narrow while you establish the contract.
Example: this input selects a soccer competition so the workflow also handles a draw price.
import { ApifyClient } from 'apify-client'; import { z } from 'zod'; import pg from 'pg'; const input = { mode: 'odds', league: 'epl', date: '2024-01-15', maxItems: 100, }; const client = new ApifyClient({ token: process.env.APIFY_TOKEN, }); const started = process.env.REPLAY_RUN_ID ? { id: process.env.REPLAY_RUN_ID } : await client .actor('nimbryxo-studio/espn-live-scores-schedules-and-game-odds-scraper') .start(input); const run = await client.run(started.id).waitForFinish(); if ( !run || !['SUCCEEDED', 'FAILED', 'TIMED-OUT', 'ABORTED'].includes(run.status) ) { throw new Error('A terminal run is required before dataset ingestion'); } Set REPLAY_RUN_ID when reprocessing an existing dataset. Leave it unset when you want a fresh capture.
Those actions have different meanings. A fresh capture reads the live source again. It can't reconstruct an earlier price that has disappeared.
Waiting for a terminal state also gives pagination a fixed ingestion boundary. A failed run may still have useful records; the code below preserves available items and raises a separate run-status signal.
How do you normalize fractional moneylines?
Fractional odds express profit relative to stake. Their implied probability is the denominator divided by the sum of numerator and denominator.
Decimal odds express total return. American odds use a signed price. These representations need separate branches, even when their values all fit inside a JavaScript string.
Example: the parser below checks the published fractional forms and leaves bare numbers behind an explicit provider policy.
type Notation = 'american' | 'decimal'; type Price = string | number | undefined; type NormalizedPrice = { probability: number | null; state: 'ok' | 'missing' | 'unrecognized'; }; const providerNotation = new Map<string, Notation>(); function normalizePrice( value: Price, notation?: Notation, ): NormalizedPrice { if (value === undefined) { return { probability: null, state: 'missing' }; } const text = String(value).trim(); const fraction = /^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/.exec(text); let probability: number | undefined; if (fraction) { const numerator = Number(fraction[1]); const denominator = Number(fraction[2]); const total = numerator + denominator; if ( numerator >= 0 && denominator > 0 && Number.isFinite(total) ) { probability = denominator / total; } } else if (/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) { const numeric = Number(text); if (Number.isFinite(numeric)) { if (notation === 'american' && Math.abs(numeric) >= 100) { probability = americanProbability(numeric); } else if (notation === 'decimal' && numeric > 1) { probability = 1 / numeric; } } } if ( probability === undefined || !Number.isFinite(probability) || probability <= 0 || probability > 1 ) { return { probability: null, state: 'unrecognized' }; } return { probability, state: 'ok' }; } assert.equal(normalizePrice(fractionalLongShot).probability, 1 / 21); assert.equal(normalizePrice('1/28').probability, 28 / 29); assert.ok( normalizePrice(fractionalLongShot).probability! < americanProbability(americanFavorite), ); The provider map starts empty deliberately. Configure it only after establishing the representation used by that feed. A field name, a familiar magnitude, or a successful cast is insufficient evidence.
A provider-wide policy also has limits. If representations vary by market, your policy needs that context before you enable numeric normalization.
These probabilities provide a comparison scale. Bookmaker margin means they aren't calibrated forecasts. Compare matching outcomes and market definitions before interpreting a difference as an opportunity.
Preserve additions, quarantine broken contracts
Use passthrough validation for fields you consume. Extra fields remain available without breaking ingestion.
Strict validation would reject every addition, including harmless enrichment. Passthrough accepts that evolution, while still rejecting changed types in the identifiers and prices your calculations depend on.
| Condition | Storage decision | Dashboard decision |
|---|---|---|
| Recognized prices and valid identifiers | Keep raw and normalized values | Eligible for comparison |
| Unrecognized or missing market prices | Keep row with review reasons | Exclude from ranking |
| Invalid consumed fields | Keep raw row with validation issues | Exclude from ranking |
For this soccer request, eligibility requires the draw price as well as the team prices. Otherwise, your apparent full-market comparison silently omits an outcome.
This schema checks only consumed fields, and the mapper retains every original record.
const identifier = z.union([ z.string().min(1), z.number().finite(), ]).transform(String); const sourcePrice = z.union([ z.string().min(1), z.number().finite(), ]); const oddsSchema = z.object({ gameId: identifier, providerId: identifier, recordType: z.string().min(1), scrapedAt: z.string().datetime({ offset: true }), homeMoneyline: sourcePrice.optional(), awayMoneyline: sourcePrice.optional(), drawMoneyline: sourcePrice.optional(), }).passthrough(); function makeObservation(raw: unknown, itemIndex: number) { const base = { run_id: run.id, item_index: itemIndex, run_status: run.status, game_id: null as string | null, provider_id: null as string | null, scraped_at: null as string | null, home_probability: null as number | null, away_probability: null as number | null, draw_probability: null as number | null, normalizer: 'fractional-v1', raw, }; const checked = oddsSchema.safeParse(raw); if (!checked.success) { return { ...base, ingest_state: 'schema_review', issues: checked.error.issues, }; } const row = checked.data; const notation = providerNotation.get(row.providerId); const prices = [ normalizePrice(row.homeMoneyline, notation), normalizePrice(row.awayMoneyline, notation), normalizePrice(row.drawMoneyline, notation), ]; const outcomes = ['home', 'away', 'draw']; const issues = prices.flatMap((price, index) => price.state === 'ok' ? [] : [{ outcome: outcomes[index], reason: price.state }], ); return { ...base, game_id: row.gameId, provider_id: row.providerId, scraped_at: row.scrapedAt, home_probability: prices[0].probability, away_probability: prices[1].probability, draw_probability: prices[2].probability, ingest_state: issues.length ? 'price_review' : 'ready', issues, }; } Missing values and unfamiliar representations receive different reasons. Absence can be normal source behavior. An unfamiliar notation needs investigation before normalization resumes.
The schema intentionally leaves nested side-detail objects untouched. Guessing their internal structure would merely move the permissive parser problem elsewhere.
Give replay an ingestion identity
gameId identifies the event. providerId identifies the sportsbook provider. Their combination is useful for grouping observations, but it can't identify a historical price snapshot.
Upserting on that pair would overwrite movement you intended to retain.
Use the run ID and absolute dataset position for replay deduplication instead. This identifies an ingestion occurrence, not a source-level odds line.
The distinction looks like this:
same terminal dataset + same position -> replace derived observation fresh capture + same game/provider -> retain another observation invalid price or shape -> retain raw row for review That key depends on replaying the same terminal dataset in the same order. Don't change filtering, sorting, or dataset contents underneath it. Exported or reordered data needs a persisted ingestion identity of its own.
Keep typed columns for joins, filtering, and ranking. Keep raw JSONB for audit and reprocessing. JSONB alone makes every dashboard query responsible for normalization; typed columns alone discard the evidence needed to fix it.
This table stores both, with a normalizer version attached to derived values.
const pool = new pg.Pool(); const createTable = ` CREATE TABLE IF NOT EXISTS odds_observations ( run_id text NOT NULL, item_index bigint NOT NULL, run_status text NOT NULL, game_id text, provider_id text, scraped_at timestamptz, home_probability double precision, away_probability double precision, draw_probability double precision, normalizer text NOT NULL, raw jsonb NOT NULL, ingest_state text NOT NULL, issues jsonb NOT NULL, PRIMARY KEY (run_id, item_index) ) `; const upsertBatch = ` INSERT INTO odds_observations SELECT * FROM jsonb_populate_recordset( NULL::odds_observations, $1::jsonb ) ON CONFLICT (run_id, item_index) DO UPDATE SET run_status = EXCLUDED.run_status, game_id = EXCLUDED.game_id, provider_id = EXCLUDED.provider_id, scraped_at = EXCLUDED.scraped_at, home_probability = EXCLUDED.home_probability, away_probability = EXCLUDED.away_probability, draw_probability = EXCLUDED.draw_probability, normalizer = EXCLUDED.normalizer, raw = EXCLUDED.raw, ingest_state = EXCLUDED.ingest_state, issues = EXCLUDED.issues `; Reprocessing replaces the current derived interpretation. If you also need an audit of normalization changes, retain those versions separately.
Commit every dataset page as a batch
Read until pagination is exhausted. The input cap limits returned records; it doesn't replace dataset pagination.
Each page becomes a single database statement. A database failure rolls back that statement, while previously committed pages remain available. Replay starts from the beginning and safely replaces them.
This loop retains rejected records, commits valid observations, and emits a structured attention event after ingestion.
let offset = 0; let reviewCount = 0; const pageSize = 100; try { await pool.query(createTable); while (true) { const { items } = await client .dataset(run.defaultDatasetId) .listItems({ offset, limit: pageSize }); if (items.length === 0) break; const batch = items.map((item, index) => makeObservation(item, offset + index), ); await pool.query(upsertBatch, [JSON.stringify(batch)]); reviewCount += batch.filter( row => row.ingest_state !== 'ready', ).length; offset += items.length; } const capReached = offset >= input.maxItems; if ( reviewCount > 0 || run.status !== 'SUCCEEDED' || capReached || offset === 0 ) { console.error(JSON.stringify({ event: 'odds_ingest_attention', runId: run.id, runStatus: run.status, reviewCount, capReached, emptyDataset: offset === 0, })); } } finally { await pool.end(); } Route that event to your job runner's alert rule. Let database and dataset-read exceptions fail the job normally.
Hitting the cap signals possible truncation, not proof that more records exist. An empty dataset needs context rather than an automatic outage page. A completed ingestion with quarantined prices deserves attention because the dashboard now has a known coverage gap.
Your ranking query must require ingest_state = 'ready'. For published snapshots, also require an accepted run status and an explicit freshness policy.
Missing prices aren't necessarily schema drift
The worked source is ESPN Live Scores, Schedules & Game Odds Scraper.
It returns published values and omits unavailable fields. Provider coverage varies by event, and lines can disappear after a game ends. Soccer lines may supply moneyline and draw prices instead of spread and total.
Those behaviors explain several failures that can look like parser bugs. Re-running a historical request can't restore a removed line, and a missing spread doesn't make a soccer market unusable.
League-response failures produce diagnostic records rather than replacement game records. Those diagnostics fail this odds-specific schema and remain in raw storage with review issues. They don't become blank sportsbook observations.
Keep availability alerts separate from contract alerts. A changed price type can require a code fix; a removed source price requires a coverage decision.
Before you trust the ranking
- Confirm notation before enabling a provider's numeric prices.
- Inspect review reasons before admitting quarantined rows.
- Include the draw when comparing the full soccer market.
- Replay an existing dataset to repair normalization.
- Start a fresh capture when you want a new observation.
- Check run status, freshness, and cap signals before publishing.
A number parser accepting a price isn't evidence that it understood it.
