Python tutorial · Pinnacle odds

Build a Pinnacle odds arbitrage scanner in Python

Fetch current Pinnacle moneylines from PinnWire, combine them with a second odds source, test every outcome for a cross-book arb, size the stakes, and rank outside prices against a sharp no-vig baseline.

Short answer PinnWire can power the Pinnacle side of an arbitrage scanner, but it is not a multi-book arb finder. You must add prices from at least one other bookmaker or exchange. The complete script below makes that boundary explicit: PinnWire supplies current Pinnacle odds; your second source supplies the comparison prices.

What PinnWire contributes to an arb scanner

PinnWire is an independent, data-only API for live and prematch Pinnacle-attributed odds. For this project, its job is to provide the sharp reference side: current decimal moneylines, spreads, totals, and other markets in a stable JSON shape.

PinnWire providesYour system still needs
Current live and prematch Pinnacle oddsAt least one other bookmaker or exchange source
Decimal prices by event, period, and marketEvent and market matching across sources
REST snapshots, detected-drop SSE, optional raw WebSocketExecution, account access, and bet confirmation
No-vig price on drop recordsYour own storage for long-term history

The scanner in this article fetches prematch fixtures from PinnWire and reads full-match moneylines at periods.num_0.money_line. It then joins those events to a normalized file from your other source.

When PinnWire is the right data source: use it when you already have another book or exchange feed and need a dedicated Pinnacle reference, need to calculate sharp no-vig probabilities, or want real-time Pinnacle movement signals. If you need one API that returns ready-made opportunities across many bookmakers, PinnWire alone is not that product.

The two calculations: arbitrage and sharp value

1. Cross-book arbitrage

For every outcome, keep the highest executable decimal price across Pinnacle and your other source. A two-way market is a theoretical arb when:

1 / best_home + 1 / best_away < 1

A three-way soccer moneyline includes the draw:

1 / best_home + 1 / best_draw + 1 / best_away < 1

Call that reciprocal sum S. The equal-return stake for one leg is:

leg_stake = bankroll × (1 / leg_odds) / S

The return on the total bankroll is bankroll / S, so the theoretical return on investment is (1 / S - 1) × 100.

2. Value against the Pinnacle no-vig baseline

An arb requires prices for every outcome to cross at the same time. A value screen asks a different question: “Is this outside price better than the fair probability implied by the Pinnacle market?”

For a full market, first normalize Pinnacle's implied probabilities so they sum to one:

fair_probability_i = (1 / pinnacle_odds_i) / Σ(1 / pinnacle_odds)

Then estimate the expected-value edge of the outside price:

estimated_EV = other_book_odds × fair_probability - 1

This is an estimate from a market reference, not a promise. For detected price moves, PinnWire also publishes nvp directly; 1 / nvp is the no-vig implied probability. See the no-vig fair odds guide.

Setup: Python, a PinnWire key, and one outside source

You need Python 3.9 or newer and the requests package:

terminalone dependency
python -m pip install requests

For a quick one-shot test, use the public demo key. It is a single shared allowance for everyone, so use your own free trial key for development.

macOS / Linuxenvironment variables
export PINNWIRE_KEY="demo"
export SPORT_ID="1"
PowerShellenvironment variables
$env:PINNWIRE_KEY = "demo"
$env:SPORT_ID = "1"

Normalize your second source

Save current prices from your other licensed source as other_book_odds.json. Replace the placeholder teams and prices with its real output. Decimal odds must refer to the same event, full-match period, rules, and home/draw/away outcomes as the PinnWire market.

other_book_odds.jsonadapter boundary
[
  {
    "home": "Home Team",
    "away": "Away Team",
    "bookmaker": "Your other source",
    "money_line": {
      "home": 2.18,
      "draw": 3.55,
      "away": 3.40
    }
  }
]

The file is deliberately simple. In production, replace load_other_rows() with the API client or database query for your second source while preserving the normalized record shape.

Complete Python Pinnacle arbitrage scanner

This is a one-shot scanner, which is safer for the shared demo quota. It fetches a fresh prematch snapshot, calculates Pinnacle no-vig probabilities, reports outside prices above your EV threshold, and prints any complete cross-book arbitrage with equal-return stakes.

pinnacle_arb_scanner.pycomplete script
import json
import os
import re
import time
from pathlib import Path

import requests


PINNWIRE_URL = "https://pinnwire.com/kit/v1/prematch/fixtures"
PINNWIRE_KEY = os.getenv("PINNWIRE_KEY", "demo")
SPORT_ID = int(os.getenv("SPORT_ID", "1"))
OTHER_FILE = Path(os.getenv("OTHER_ODDS_FILE", "other_book_odds.json"))
BANKROLL = float(os.getenv("BANKROLL", "100"))
MIN_EV_PCT = float(os.getenv("MIN_EV_PCT", "2"))


def canonical_name(value):
    """Simple team-name key. Use IDs and start times in production."""
    return re.sub(r"[^a-z0-9]", "", str(value).lower())


def event_key(home, away):
    return canonical_name(home), canonical_name(away)


def fetch_pinnwire_events():
    response = requests.get(
        PINNWIRE_URL,
        params={
            "sport_id": SPORT_ID,
            "key": PINNWIRE_KEY,
            "fresh": int(time.time()),
        },
        timeout=20,
    )

    if response.status_code == 429:
        retry = response.headers.get("Retry-After", "the advertised delay")
        raise SystemExit(f"PinnWire rate limit reached; retry after {retry}.")

    response.raise_for_status()
    payload = response.json()
    print(f"PinnWire snapshot: {payload.get('generated_at', 'timestamp unavailable')}")
    return payload.get("events", [])


def load_other_rows():
    if not OTHER_FILE.exists():
        raise SystemExit(
            f"Create {OTHER_FILE} with the normalized JSON shown in the guide."
        )

    rows = json.loads(OTHER_FILE.read_text(encoding="utf-8"))
    if not isinstance(rows, list):
        raise SystemExit("The outside-source JSON must contain a list of events.")
    return rows


def decimal_moneyline(event):
    moneyline = (
        event.get("periods", {})
        .get("num_0", {})
        .get("money_line")
    )
    if not isinstance(moneyline, dict):
        return None

    clean = {}
    for side in ("home", "draw", "away"):
        value = moneyline.get(side)
        if value is not None and float(value) > 1.0:
            clean[side] = float(value)
    return clean if "home" in clean and "away" in clean else None


def clean_outside_moneyline(row, required_sides):
    source = row.get("money_line", {})
    clean = {}
    for side in required_sides:
        value = source.get(side)
        if value is None or float(value) <= 1.0:
            return None
        clean[side] = float(value)
    return clean


def fair_probabilities(pinnacle_odds):
    """Remove the Pinnacle market margin by proportional normalization."""
    raw = {side: 1.0 / price for side, price in pinnacle_odds.items()}
    overround = sum(raw.values())
    return {side: probability / overround for side, probability in raw.items()}


def best_legs(pinnacle_odds, outside_odds, outside_name):
    legs = {}
    for side in pinnacle_odds:
        choices = [
            (pinnacle_odds[side], "Pinnacle via PinnWire"),
            (outside_odds[side], outside_name),
        ]
        price, source = max(choices, key=lambda item: item[0])
        legs[side] = {"odds": price, "source": source}
    return legs


def report_value(event, outside_odds, outside_name, fair):
    candidates = []
    for side, price in outside_odds.items():
        ev_pct = (price * fair[side] - 1.0) * 100.0
        if ev_pct >= MIN_EV_PCT:
            fair_price = 1.0 / fair[side]
            candidates.append((side, price, fair_price, ev_pct))

    if candidates:
        print(f"\nVALUE  {event['home']} vs {event['away']}  [{outside_name}]")
        for side, price, fair_price, ev_pct in candidates:
            print(
                f"  {side:5} outside={price:.3f} "
                f"Pinnacle_no_vig={fair_price:.3f} estimated_EV={ev_pct:.2f}%"
            )
    return len(candidates)


def report_arb(event, legs):
    reciprocal_sum = sum(1.0 / leg["odds"] for leg in legs.values())
    if reciprocal_sum >= 1.0:
        return False

    roi_pct = (1.0 / reciprocal_sum - 1.0) * 100.0
    equal_return = BANKROLL / reciprocal_sum
    print(f"\nARB    {event['home']} vs {event['away']}  ROI={roi_pct:.2f}%")

    for side, leg in legs.items():
        stake = BANKROLL * (1.0 / leg["odds"]) / reciprocal_sum
        print(
            f"  {side:5} {leg['odds']:.3f} at {leg['source']}; "
            f"stake={stake:.2f}"
        )

    print(
        f"  total_stake={BANKROLL:.2f} equal_return={equal_return:.2f} "
        f"theoretical_profit={equal_return - BANKROLL:.2f}"
    )
    return True


def main():
    pinnwire_events = fetch_pinnwire_events()
    outside_rows = load_other_rows()
    outside_by_event = {
        event_key(row.get("home", ""), row.get("away", "")): row
        for row in outside_rows
    }

    matched = 0
    arbs = 0
    value_prices = 0

    for event in pinnwire_events:
        pinnacle_odds = decimal_moneyline(event)
        if not pinnacle_odds:
            continue

        outside = outside_by_event.get(event_key(event["home"], event["away"]))
        if not outside:
            continue

        required_sides = list(pinnacle_odds)
        outside_odds = clean_outside_moneyline(outside, required_sides)
        if not outside_odds:
            continue

        matched += 1
        outside_name = outside.get("bookmaker", "Other source")
        fair = fair_probabilities(pinnacle_odds)
        value_prices += report_value(
            event, outside_odds, outside_name, fair
        )

        legs = best_legs(pinnacle_odds, outside_odds, outside_name)
        arbs += int(report_arb(event, legs))

    print(
        f"\nDone: {matched} matched events, {arbs} theoretical arbs, "
        f"{value_prices} outside prices at or above {MIN_EV_PCT:.2f}% estimated EV."
    )
    if matched == 0:
        print("Check team aliases, start times, market period, and event coverage.")


if __name__ == "__main__":
    main()
Important: the code identifies mathematical crosses in quoted data. It does not place bets. Before treating any result as actionable, confirm the event identity, market rules, current price, available stake, fees, currency, and that every leg was accepted.

Run it and understand the output

Save the script and your normalized outside prices in the same folder, then run:

terminalone snapshot
python pinnacle_arb_scanner.py

Illustrative output looks like this:

PinnWire snapshot: 2026-08-26T09:42:18.231Z

VALUE  Home Team vs Away Team  [Your other source]
  away  outside=3.400 Pinnacle_no_vig=3.180 estimated_EV=6.92%

ARB    Home Team vs Away Team  ROI=1.22%
  home  2.180 at Your other source; stake=46.43
  draw  3.550 at Your other source; stake=28.52
  away  4.040 at Pinnacle via PinnWire; stake=25.05
  total_stake=100.00 equal_return=101.22 theoretical_profit=1.22

VALUE means an outside price cleared the configured EV threshold against the normalized Pinnacle baseline. ARB means the best available price for every required outcome produced a reciprocal sum below one. Neither line proves that the quoted prices remain executable.

Useful environment settings

VariableDefaultPurpose
PINNWIRE_KEYdemoYour PinnWire API key
SPORT_ID11 soccer, 2 tennis, 3 basketball, through 13 cricket
OTHER_ODDS_FILEother_book_odds.jsonNormalized prices from your second source
BANKROLL100Total theoretical stake per opportunity
MIN_EV_PCT2Minimum outside-price edge to print

Turn the example into a production scanner

Match with stable identifiers

Team-name normalization is tutorial code. Join on mapped participant IDs, league, start time, period, and market rules.

Reject stale snapshots

Inspect generated_at. Re-fetch with a random fresh value if an intermediary returns an old response.

Stream the right signal

Use SSE for detected price-drop alerts. Use the optional raw WebSocket for continuous live and prematch market updates.

Store your own history

PinnWire serves current snapshots and roughly three hours of recent drops, not a historical archive.

Model execution costs

Subtract commission, exchange fees, transfer costs, rounding, and a safety margin before alerting.

Recheck before staking

Refresh both sides, verify limits, and require every leg to be accepted. Partial execution creates risk.

REST is appropriate for a scheduled prematch scan. For faster workflows, use PinnWire's dropping-odds SSE as a trigger, then refresh the event and your other source before recalculating. The optional WebSocket carries raw live and prematch market updates; it is not a ready-made arbitrage feed.

Honest limits

  • Pinnacle only: PinnWire cannot discover multi-book arbitrage without your second price source.
  • No betting or execution: PinnWire is a data service, not a bookmaker, exchange, bet broker, or automated wagering system.
  • Quoted is not accepted: a theoretical arb can disappear between calculation and placement.
  • Market alignment matters: regulation time versus overtime, listed pitchers, pushes, void rules, handicaps, and participant orientation must match.
  • Simple de-vig method: proportional normalization is transparent and useful, but it is one fair-price model rather than ground truth.
  • No long-term archive: persist snapshots and alerts yourself for backtests or closing-line analysis.
  • Demo capacity is shared: the public demo may return 429. A free personal trial key is better for building.

Try the Pinnacle side now

Test one live REST request with key=demo, or get a personal key with 100 requests/day. No card.

Frequently asked questions

Can PinnWire find arbitrage across bookmakers by itself?

No. PinnWire provides Pinnacle-attributed odds only. A cross-book arbitrage scanner must combine PinnWire with at least one other bookmaker, exchange, or licensed odds source, then align the same event, period, market, line, and outcomes.

Why use Pinnacle odds in an arbitrage or value-bet scanner?

Pinnacle prices are commonly used as a sharp market reference. Removing their market margin creates a useful fair-probability baseline for judging an outside price. A Pinnacle price can also form one leg of a cross-book arb when you can actually place that leg.

What is the arbitrage formula for decimal odds?

Take the highest decimal price for every mutually exclusive outcome and add the reciprocals. If the total is below one, the prices form a theoretical arbitrage before execution risk and costs. The script calculates both the reciprocal sum and equal-return stakes.

Can I test the Python scanner with the PinnWire demo key?

Yes. Set PINNWIRE_KEY=demo for a one-shot test. The demo quota is shared across everyone and may be exhausted. Use the free emailed trial key for repeated requests.

Does PinnWire return fair odds?

PinnWire drop records include nvp, the no-vig decimal fair price for the moved outcome. Current market snapshots return the complete decimal market, so the example calculates normalized no-vig probabilities locally.

Does an arbitrage alert guarantee profit?

No. It proves only that the captured prices crossed mathematically. Odds can move, markets can suspend, limits can bind, bets can be rejected, and bad event matching or fees can erase the edge. Confirm every leg before staking.