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.
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 provides | Your system still needs |
|---|---|
| Current live and prematch Pinnacle odds | At least one other bookmaker or exchange source |
| Decimal prices by event, period, and market | Event and market matching across sources |
| REST snapshots, detected-drop SSE, optional raw WebSocket | Execution, account access, and bet confirmation |
| No-vig price on drop records | Your 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.
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:
A three-way soccer moneyline includes the draw:
Call that reciprocal sum S. The equal-return stake for one leg is:
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:
Then estimate the expected-value edge of the outside price:
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:
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.
export PINNWIRE_KEY="demo"
export SPORT_ID="1"
$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.
[
{
"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.
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()
Run it and understand the output
Save the script and your normalized outside prices in the same folder, then run:
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
| Variable | Default | Purpose |
|---|---|---|
PINNWIRE_KEY | demo | Your PinnWire API key |
SPORT_ID | 1 | 1 soccer, 2 tennis, 3 basketball, through 13 cricket |
OTHER_ODDS_FILE | other_book_odds.json | Normalized prices from your second source |
BANKROLL | 100 | Total theoretical stake per opportunity |
MIN_EV_PCT | 2 | Minimum outside-price edge to print |
Turn the example into a production scanner
Team-name normalization is tutorial code. Join on mapped participant IDs, league, start time, period, and market rules.
Inspect generated_at. Re-fetch with a random fresh value if an intermediary returns an old response.
Use SSE for detected price-drop alerts. Use the optional raw WebSocket for continuous live and prematch market updates.
PinnWire serves current snapshots and roughly three hours of recent drops, not a historical archive.
Subtract commission, exchange fees, transfer costs, rounding, and a safety margin before alerting.
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.