On the wire · 05:08:22 UTC Live 585 · Prematch 14,345 · Markets 57,146 Vol. I · No. 5 · Friday 13 November 2026 wss://pinnfeed.io/v1/stream
PinnFeed
Tutorial · Data · DuckDB

Pinnacle closing odds into DuckDB and Parquet

Updated · PinnFeed · 2 min read

Answer: on Builder or Pro, run a small daily job that pages GET /v1/closing?date=YYYY-MM-DD (up to 2,000 rows per call), flattens each row's market and outcomes, and writes one Parquet file per day. DuckDB then queries the whole folder with plain SQL, with no database server to run. Records exist from when PinnFeed started storing closes, so your dataset grows from there.

1. Daily export (Python)#

import os, sys, requests, pandas as pd
H = {"x-api-key": os.environ["PINNFEED_KEY"]}  # ps_live_...

def closing_day(day, sport):
    rows, offset = [], 0
    while True:
        r = requests.get("https://pinnfeed.io/v1/closing", headers=H, timeout=30,
                         params={"date": day, "sport": sport, "limit": 2000, "offset": offset})
        r.raise_for_status()                     # 403 plan_lacks_history on Free/Hobby
        batch = r.json()["closing"]
        rows += batch
        if len(batch) < 2000:
            return rows
        offset += 2000

day = sys.argv[1]                                # e.g. 2026-11-08
out = []
for row in closing_day(day, "soccer"):
    m = row["market"]
    for o in m["outcomes"] or []:
        out.append({
            "event_id": row["eventId"], "league": row["league"]["name"], "home": row["home"], "away": row["away"],
            "start_time": row["startTime"], "captured_at": row["capturedAt"],
            "market": m["type"], "period": m["period"], "line": m["line"], "main": m["main"],
            "margin": m["margin"], "max_stake": m["maxStake"],
            "outcome": o.get("name"), "decimal": o.get("decimal"), "fair": o.get("fair"),
        })
os.makedirs("closing", exist_ok=True)
pd.DataFrame(out).to_parquet(f"closing/soccer-{day}.parquet", index=False)   # needs pyarrow
print(len(out), "outcome rows")

Run it once a day for the previous date (cron, GitHub Actions or a scheduled task). Check one response first: if an outcome field you need is missing, adjust the mapping rather than guessing.

2. Query with DuckDB#

-- duckdb shell, from the folder that contains closing/
SELECT league, market, round(avg(margin) * 100, 2) AS avg_margin_pct, count(DISTINCT event_id) AS events
FROM 'closing/*.parquet'
WHERE main AND period = 0
GROUP BY ALL
ORDER BY events DESC
LIMIT 20;

3. Join your own bets for CLV#

Keep a bets.csv with event_id, market, line, outcome, price_taken, then:

SELECT b.*, c.decimal AS close, round((b.price_taken / c.decimal - 1) * 100, 2) AS clv_pct
FROM 'bets.csv' b
JOIN 'closing/*.parquet' c USING (event_id, market, line, outcome)
WHERE c.period = 0;

USING does not match NULL lines, so join moneyline bets on event_id, market, outcome only. CLV as price taken over closing price is one common definition; use the one your tracker already uses. Spreadsheet version: bet tracker with CLV.

Plans#

Builder (€99/mo): closing records, all 13 sports, WebSocket. Enterprise adds bulk CSV/Parquet exports. 3-day trial; a card is required and it is charged after day 3 unless you cancel. Data-science overview: use case.

Educational content for adults (18+). Research use; no tips, no guaranteed profit.

Related: Historical closing odds.

Questions people ask#

Why one file per day?#

Re-running a day overwrites only that file, and DuckDB reads the glob as one table.

Can I backfill earlier dates?#

Yes, for any date PinnFeed already holds closes for. Dates before that return no rows.

pandas instead of DuckDB?#

Both work on the same Parquet files: pandas DataFrame.

The WebSocket plan: Builder, €99/mo.

Real-time Pinnacle odds pushed over one WebSocket, all 13 sports, every market, no-vig fair odds and closing lines. 3 days free, cancel in one click.

Form K-1 · Free API key

Get your free API key.

One field. No password, no card: enter your email, click the link we send, and your key is on screen.

By continuing you agree to the Terms and Privacy Policy.

One free key per email. Link valid for 20 minutes.