On this page
Every serious odds project ends up wanting history: what the price was when you looked, what it opened at, and what it closed at. This recipe is a Node.js poller that keeps a SQLite copy of the Pinnacle prematch board via the pinnodds REST API, records every price change, and answers the closing-line-value question with plain SQL. It uses the built-in node:sqlite module, so there are no native dependencies to compile. The API calls come from the Node quickstart; if you have not read the section on since deltas there, do that first.
Design: three tables, one cursor
The poller keeps events (one row per fixture), prices (the current price for each selection) and price_history (an append-only row whenever a price differs from the stored one). A fourth table holds the since cursor per sport so a restart resumes instead of re-downloading. Separating current from history keeps the hot query fast and the history table honest: it only grows when something moved.
The board is prematch only. Live prices change many times a second and SQLite on a single writer will keep up, but the volume and the poll cadence make the raw WebSocket the better source for live history. Prematch through REST is exactly the workload since was designed for.
Schema
Field names follow the API. starts is stored as the ISO string the API returns, and time comparisons convert it with strftime. Spread points are from the home side, as the API quotes them, so a row with side = 'away' and points = -0.5 means the away price against a home −0.5 line.
-- schema.sql
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS events (
event_id INTEGER PRIMARY KEY,
sport_id INTEGER NOT NULL,
league_id INTEGER,
league_name TEXT,
home TEXT NOT NULL,
away TEXT NOT NULL,
starts TEXT NOT NULL, -- ISO 8601 UTC from the API
event_type TEXT NOT NULL, -- 'prematch' | 'live'
last INTEGER NOT NULL, -- per-event cursor from the API
updated_at INTEGER NOT NULL -- unix ms, our clock
);
CREATE INDEX IF NOT EXISTS events_starts ON events (starts);
-- current price per selection; one row per (event, period, market, side, points)
CREATE TABLE IF NOT EXISTS prices (
event_id INTEGER NOT NULL REFERENCES events (event_id) ON DELETE CASCADE,
period INTEGER NOT NULL, -- 0 = full match
market TEXT NOT NULL, -- money_line | spreads | totals | team_total
side TEXT NOT NULL, -- home | away | draw | over | under
points REAL, -- spread (home side) or total line, NULL for money_line
price REAL NOT NULL, -- decimal
seen_at INTEGER NOT NULL,
PRIMARY KEY (event_id, period, market, side, points)
);
-- append-only: a row every time a price differs from the stored one
CREATE TABLE IF NOT EXISTS price_history (
id INTEGER PRIMARY KEY,
event_id INTEGER NOT NULL,
period INTEGER NOT NULL,
market TEXT NOT NULL,
side TEXT NOT NULL,
points REAL,
price REAL NOT NULL,
seen_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS hist_sel ON price_history (event_id, period, market, side, points, seen_at);
CREATE TABLE IF NOT EXISTS cursors (
sport_id INTEGER PRIMARY KEY,
last INTEGER NOT NULL,
full_at INTEGER NOT NULL -- when we last took a full snapshot
); The primary key on prices uses points, which is NULL for money lines. SQLite treats NULLs as distinct in a primary key, so the lookup uses IS rather than =. The ON DELETE CASCADE is only active if you enable foreign keys with PRAGMA foreign_keys = ON; the poller never deletes events, so leaving it off is fine.
The poller
The whole program is one file. It takes a full snapshot when it has no cursor or the cursor is old, otherwise a delta, then walks each event's periods, flattens money line, spreads and totals into selections, and writes a history row when the price differs.
// poller.mjs - Node >= 22.13 (node:sqlite), pinnodds SDK
import { DatabaseSync } from "node:sqlite";
import { readFileSync } from "node:fs";
import { setTimeout as sleep } from "node:timers/promises";
import { Client, SPORTS, AuthError, RateLimitError, PinnoddsError } from "pinnodds";
const api = new Client(process.env.PINNODDS_KEY);
const db = new DatabaseSync(process.env.DB_PATH ?? "./odds.db");
db.exec(readFileSync(new URL("./schema.sql", import.meta.url), "utf8"));
const SPORT_IDS = (process.env.SPORT_IDS ?? String(SPORTS.soccer)).split(",").map(Number);
const PLAN = process.env.PLAN ?? "trial"; // trial | pro | scale
const RESYNC_AFTER_MS = 30 * 60 * 1000; // full snapshot at least every 30 min
// Budget: stay well under the plan's published limit.
// Trial 20/min & 100/day, Pro 10 req/s, Scale 30 req/s.
const INTERVAL_MS = { trial: 15 * 60 * 1000, pro: 5_000, scale: 2_000 }[PLAN];
const upEvent = db.prepare(`
INSERT INTO events (event_id, sport_id, league_id, league_name, home, away, starts, event_type, last, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO UPDATE SET league_name=excluded.league_name, starts=excluded.starts,
event_type=excluded.event_type, last=excluded.last, updated_at=excluded.updated_at`);
const getPrice = db.prepare(`SELECT price FROM prices WHERE event_id=? AND period=? AND market=? AND side=? AND points IS ?`);
const upPrice = db.prepare(`
INSERT INTO prices (event_id, period, market, side, points, price, seen_at) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(event_id, period, market, side, points) DO UPDATE SET price=excluded.price, seen_at=excluded.seen_at`);
const addHist = db.prepare(`INSERT INTO price_history (event_id, period, market, side, points, price, seen_at) VALUES (?, ?, ?, ?, ?, ?, ?)`);
const getCursor = db.prepare(`SELECT last, full_at FROM cursors WHERE sport_id=?`);
const setCursor = db.prepare(`INSERT INTO cursors (sport_id, last, full_at) VALUES (?, ?, ?)
ON CONFLICT(sport_id) DO UPDATE SET last=excluded.last, full_at=excluded.full_at`);
// Flatten one period object into (market, side, points, price) rows.
// money_line: { home, away, draw? }. spreads: { "<hdp>": { hdp, home, away, max } } (home side).
// totals: { "<points>": { points, over, under, max } }.
function* selections(period) {
const ml = period.money_line ?? {};
for (const side of ["home", "draw", "away"]) if (ml[side] != null) yield ["money_line", side, null, ml[side]];
for (const s of Object.values(period.spreads ?? {})) {
if (s.home != null) yield ["spreads", "home", s.hdp, s.home];
if (s.away != null) yield ["spreads", "away", s.hdp, s.away];
}
for (const t of Object.values(period.totals ?? {})) {
if (t.over != null) yield ["totals", "over", t.points, t.over];
if (t.under != null) yield ["totals", "under", t.points, t.under];
}
}
// node:sqlite has no transaction helper, so BEGIN/COMMIT are explicit.
function apply(board, sportId, now) {
db.exec("BEGIN");
try {
let changed = 0;
for (const ev of board.events) {
upEvent.run(ev.event_id, ev.sport_id, ev.league_id ?? null, ev.league_name ?? null, ev.home, ev.away,
ev.starts, ev.event_type, ev.last, now);
for (const [key, period] of Object.entries(ev.periods ?? {})) {
const pnum = period.number ?? Number(key.replace("num_", ""));
for (const [market, side, points, price] of selections(period)) {
const cur = getPrice.get(ev.event_id, pnum, market, side, points);
if (cur && cur.price === price) continue;
upPrice.run(ev.event_id, pnum, market, side, points, price, now);
addHist.run(ev.event_id, pnum, market, side, points, price, now);
changed++;
}
}
}
db.exec("COMMIT");
return changed;
} catch (e) { db.exec("ROLLBACK"); throw e; }
}
async function pollSport(sportId) {
const now = Date.now();
const c = getCursor.get(sportId);
const full = !c || now - c.full_at > RESYNC_AFTER_MS; // cursors age fast: resync periodically
const board = await api.markets({ sportId, eventType: "prematch", ...(full ? {} : { since: c.last }) });
const changed = apply(board, sportId, now);
setCursor.run(sportId, board.last, full ? now : c.full_at);
console.log(new Date(now).toISOString(), `sport ${sportId} ${full ? "FULL" : "delta"}: ${board.events.length} events, ${changed} price changes, cursor ${board.last}`);
}
for (;;) {
for (const sportId of SPORT_IDS) {
try {
await pollSport(sportId);
} catch (err) {
if (err instanceof AuthError) { console.error("bad key"); process.exit(1); }
if (err instanceof RateLimitError) { console.warn("429, sleeping", err.retryAfter, "s"); await sleep(err.retryAfter * 1000); continue; }
if (err instanceof PinnoddsError) { console.warn("api error:", err.message); continue; }
throw err;
}
}
await sleep(INTERVAL_MS);
} 2026-09-28T09:00:03.412Z sport 1 FULL: 612 events, 9114 price changes, cursor 297592 2026-09-28T09:15:03.877Z sport 1 delta: 41 events, 133 price changes, cursor 297911 2026-09-28T09:30:04.102Z sport 1 delta: 38 events, 97 price changes, cursor 298230
The flattening in selections is the one place you should check against a real response before trusting it. A money line is an object keyed by side (home, away, and draw where the sport has one). Spreads are an object keyed by handicap, each entry holding hdp, home, away and max; totals are keyed by line with points, over, under and max. The pinnodds docs print the full market-type table and an example event. Team totals (team_total for the primary line, team_totals for every alternate line per side) are omitted here; add a branch the same way. node:sqlite exposes no transaction helper yet, so the code uses explicit BEGIN and COMMIT; one transaction per poll keeps a full snapshot of several thousand rows to a fraction of a second.
Scheduling against the rate limit
Rate limits are per key and set by plan: Trial allows 20 requests a minute and 100 a day, Pro 10 requests a second, Scale 30. One poll per sport is one request, so the interval times the number of sports must fit the daily budget on Trial and the per-second budget on Pro and Scale. The INTERVAL_MS table in the script encodes this.
Scroll for more
| Plan | Limit | Sports | Interval | Requests/day |
|---|---|---|---|---|
| Trial | 20/min, 100/day | 1 | 15 min | 96 |
| Trial | 20/min, 100/day | 2 | 30 min | 96 |
| Pro | 10 req/s | 3 | 5 s | 51,840 |
| Scale | 30 req/s | 13 | 2 s | 561,600 |
On Trial, 96 requests a day leaves four for debugging; do not run the poller and your own curl experiments on the same key. Trial is fine for validating the schema over a weekend. For anything you want to analyse later, Pro at a 5-second interval captures nearly every prematch move, and Scale exists for people polling all thirteen sports every two seconds. The pricing breakdown compares the tiers; note that they differ only in rate limit and push access, not in which markets you get.
Cursor aging and resync
Each response carries a top-level last; pass it back as since to receive only events whose own last is newer. The catch is that cursors age fast: a since value more than a few minutes old matches most of the board anyway, so a Trial poller on a 15-minute interval is effectively taking snapshots regardless. That is fine, the code path is the same, but do not expect the small deltas you see at 5 seconds.
Two further reasons to resync on a timer, which the script does every 30 minutes via RESYNC_AFTER_MS. First, an event that Pinnacle removes will not appear in a delta, so the events table only learns it is gone when a full snapshot omits it; extend the script to mark missing events if that matters to you. Second, every REST response carries an X-Data-Status header (fresh, stale, unknown); when upstream is behind, the API returns an empty events array rather than old prices. The SDK does not expose that header, so an empty delta on a Saturday afternoon should trigger a check of api.health(), or switch the fetch to raw fetch() as the Node quickstart shows and read the header directly.
Querying closing-line value
Closing-line value compares the price you took with the price just before kickoff. With price_history in place, it is one query: the first price we saw, the last price before your bet time, and the last price before starts.
-- Opening price, price at a chosen time, and closing price for one selection.
-- "Closing" = last price we saw before kickoff. Requires the poller to run up to kickoff.
WITH sel AS (
SELECT h.*, e.starts
FROM price_history h JOIN events e USING (event_id)
WHERE h.event_id = :event_id AND h.period = 0 AND h.market = 'money_line' AND h.side = 'home'
AND h.seen_at <= strftime('%s', e.starts) * 1000
)
SELECT
(SELECT price FROM sel ORDER BY seen_at ASC LIMIT 1) AS opening,
(SELECT price FROM sel WHERE seen_at <= :bet_time ORDER BY seen_at DESC LIMIT 1) AS at_bet,
(SELECT price FROM sel ORDER BY seen_at DESC LIMIT 1) AS closing;
-- CLV in implied-probability points for a bet taken at price p_bet:
-- clv_pp = (1/closing - 1/p_bet) * 100 -> positive means you beat the close opening at_bet closing 2.10 2.05 1.92 -- bet at 2.05, closed 1.92: (1/1.92 - 1/2.05) * 100 = +3.30 pp
Measure CLV in implied-probability points rather than as a percentage of the price, or short favourites will look like they never move. The same table answers other questions cheaply: how often a market moves more than 5% in the last hour, which leagues drift most, whether your drop alerts preceded the close or chased it. pinnodds has a plain-language CLV guide and a note on why Pinnacle's close is the usual benchmark.
Running it and next steps
Node 22.13 or newer is required for node:sqlite without a flag. Set the plan and sports in the environment and start it; the database file appears next to the script.
npm init -y && npm pkg set type=module && npm install pinnodds
export PINNODDS_KEY=... PLAN=trial SPORT_IDS=1
node poller.mjs
# inspect
sqlite3 odds.db "SELECT count(*) FROM events; SELECT count(*) FROM price_history;" For production, run it under systemd exactly as the Telegram recipe does, with Restart=always and the key in a root-owned environment file. Because this poller uses REST only, it does not compete for the single SSE connection, so it can share a key with an alert bot as long as the combined request rate fits the plan.
- Add drops. Call
api.drops({ sportId, minDropPct: 2 })on the same schedule and store the rows;market_keygives you a stable join toprices. See the SSE guide for the two drop shapes. - Add specials. Pass
includeSpecialstoprematchFixturesand store player and team props with theirparent_id. - Go live. For in-play history, feed the WebSocket frames into the same
price_historytable; the merge rules there tell you when a market closes.