Map Asian handicap lines between bookmakers without sign errors
Use PinnWire as the canonical real-time Pinnacle reference, then map every external quote to the same home-team line. The durable contract is simple: resolve the fixture, keep the period, identify the team carrying the handicap, and store one signed home_line. Derive the away line only when you display or settle it.
Start with a live PinnWire reference row
PinnWire exposes current Pinnacle markets in a stable, Pinnacle-compatible shape. Test the reference leg with the public demo key, then use a trial key for repeated mapping:
curl "https://pinnwire.com/kit/v1/markets?sport_id=1&key=demo&fresh=ah-map"
For a prematch event, use /kit/v1/prematch/lines?event_id=EVENT_ID&market_type=spreads&key=demo. In either response, inspect events[].home, events[].away, periods and the full spreads object. Every response includes generated_at; use it to confirm that your comparison is based on a current snapshot. The public demo is a shared allowance, so a 429 is a cue to wait or use a free trial key.
The one invariant: handicap belongs to a named team
An Asian handicap is not just a number. -0.75 means different things depending on whether it is applied to the home or away team. A mapper should make that ownership explicit before it stores or compares a line.
Choose the fixture's canonical home team once. For a normalized row:
home_line = the handicap applied to the canonical home team
away_line = -home_line # derive, do not persist independently
PinnWire's hdp value in a spreads object is already home-anchored. If hdp is -1.25, the row is home −1.25 and the paired away selection is away +1.25. The home and away prices stay attached to that exact line.
| Canonical field | Meaning | Why it matters |
|---|---|---|
event_key | Stable fixture identity after team and kickoff matching | Prevents a valid line from landing on the wrong match |
market_type | ah for Asian handicap | Keeps Asian, European handicap and totals data separate |
period | num_0, num_1, or your explicit period alias | Full match and first half are different wagers |
home_line | Signed line from the canonical home perspective | The only line number needed for matching |
prices.home / prices.away | Decimal prices for both selections at that line | Line and price together identify the market state |
legs | Settlement components for quarter lines | Lets a settlement or EV engine handle split stakes correctly |
A normalized row that cannot silently disagree
Keep one authoritative line and a side-to-price map. Do not write a separately sourced away_line; a partial update can otherwise leave the two sides describing different markets.
{
"source": "pinnwire",
"event_key": "arsenal|chelsea|2026-08-26T18:00Z",
"market_type": "ah",
"period": "num_0",
"home_team_id": "arsenal",
"away_team_id": "chelsea",
"home_line": -1.25,
"prices": {"home": 1.95, "away": 1.97},
"legs": [-1.5, -1.0],
"generated_at": "2026-08-26T18:00:03.241Z"
}
The published line remains -1.25; legs is settlement metadata, not a second market. A market-level key should include at least (event_key, market_type, period, home_line). For outcome-level identity, append side—home or away—before comparing a single quoted price. This keeps the line, period and outcome attached as one unambiguous wager.
One home line
Persist home_line and derive away_line = -home_line on read.
Exact period
Keep num_0 and num_1 in different keys, even when the line number matches.
Market type
Require ah explicitly. A shared handicap field is not enough to prove the settlement model.
Resolve the fixture before you flip a sign
Line arithmetic is cheap. Fixture identity is where false value signals begin. Normalize team identifiers and kickoff time before asking whether two quotes are the same market:
- Match the sport and competition context.
- Resolve each source's team names to stable IDs, including aliases and accents.
- Match kickoff timestamps inside a documented tolerance.
- Choose the canonical home and away IDs from the resolved fixture.
- Only then map the quoted team's handicap to
home_line.
A provider can list the same fixture in reverse order. That does not automatically mean the sign must flip. The sign flips when the team carrying the handicap changes relative to your canonical home team.
| Quote after team resolution | Canonical result | Reason |
|---|---|---|
Arsenal (canonical home), -1.25 | home_line = -1.25 | The quoted team is home |
Chelsea (canonical away), +1.25 | home_line = -1.25 | The quoted team is away, so negate once |
Provider labels Chelsea as “home”, but team ID is Chelsea with +1.25 | home_line = -1.25 | Team identity wins over a provider's display order |
| Unknown team or unresolved fixture | Reject and log | Guessing creates a confident, unmatched signal |
Treat a sudden rise in rejected or unmatched rows as an integration alert. It often means a source changed team aliases, fixture ordering or sign convention.
Keep periods and market families in the key
Full match, first half and second half lines can all be -0.25. Their identical number does not make them comparable. PinnWire groups published periods under keys such as num_0; preserve that key through your mapper.
(event_key, market_type, period, home_line)
("arsenal|chelsea|...", "ah", "num_0", -0.25) # full match
("arsenal|chelsea|...", "ah", "num_1", -0.25) # first half: separate row
Also keep Asian handicap separate from European handicap and totals. Asian handicap is a two-way settlement family with quarter-line splits. European handicap can include a draw outcome; totals share one line between Over and Under. A field named handicap does not define the market by itself.
Quarter lines are two real settlement legs
A quarter line is published market data, not a value to interpolate. Split the stake into adjacent whole and half lines:
| Published home line | Settlement legs | Stake |
|---|---|---|
-0.25 | 0.0 and -0.5 | 50% each |
-0.75 | -0.5 and -1.0 | 50% each |
-1.25 | -1.0 and -1.5 | 50% each |
+0.25 | 0.0 and +0.5 | 50% each |
Keep the published home_line as the comparison identity and keep the two legs as settlement metadata. Do not create a synthetic -0.85 or average two nearby prices. PinnWire returns the actual lines it publishes, including quarter lines, so your mapper can compare real market states.
Runnable Python mapper: PinnWire plus external quotes
This standard-library example fetches PinnWire's current soccer reference, preserves its hdp, home and away fields, and normalizes user-supplied external quotes. Replace the sample external rows with your own source after resolving its team IDs and periods.
import json
import os
import time
from decimal import Decimal
from urllib.parse import urlencode
from urllib.request import urlopen
def D(value):
return Decimal(str(value))
def is_quarter(line):
"""True for .25/.75-family lines, including negative lines."""
scaled = abs(D(line) * 4)
return scaled == scaled.to_integral_value() and int(scaled) % 2 == 1
def settlement_legs(line):
"""Return the two equal-stake legs for a quarter line."""
line = D(line)
if not is_quarter(line):
return (line,)
return tuple(sorted((line - D("0.25"), line + D("0.25"))))
def home_anchored_line(team_id, line, canonical_home_id, canonical_away_id):
"""Map a quote owned by a named team to the canonical home perspective."""
line = D(line)
if team_id == canonical_home_id:
return line
if team_id == canonical_away_id:
return -line
raise ValueError(f"team {team_id!r} is not in the resolved fixture")
def fetch_pinnwire(key=None):
key = key or os.environ.get("PINNWIRE_KEY", "demo")
query = urlencode({"sport_id": 1, "key": key, "fresh": str(time.time_ns())})
with urlopen("https://pinnwire.com/kit/v1/markets?" + query, timeout=15) as response:
return json.load(response)
def pinnwire_rows(payload):
"""Yield one home-anchored row per PinnWire spread and period."""
for event in payload.get("events", []):
home_id = event.get("home_id") or event.get("home")
away_id = event.get("away_id") or event.get("away")
event_key = str(event.get("event_id"))
for period, period_data in event.get("periods", {}).items():
for object_key, spread in period_data.get("spreads", {}).items():
line = D(spread.get("hdp", object_key))
yield {
"source": "pinnwire",
"event_key": event_key,
"market_type": "ah",
"period": period,
"home_team_id": home_id,
"away_team_id": away_id,
"home_line": line,
"prices": {
"home": spread.get("home"),
"away": spread.get("away"),
},
"legs": settlement_legs(line),
"generated_at": payload.get("generated_at"),
}
def external_row(event_key, period, quote, canonical_home_id, canonical_away_id):
"""Normalize one user-supplied quote; quote['team_id'] owns quote['line']."""
line = home_anchored_line(
quote["team_id"], quote["line"], canonical_home_id, canonical_away_id
)
side = "home" if quote["team_id"] == canonical_home_id else "away"
return {
"source": quote["source"],
"event_key": event_key,
"market_type": "ah",
"period": period,
"home_line": line,
"side": side,
"price": D(quote["price"]),
"legs": settlement_legs(line),
}
def match_key(row):
return (row["event_key"], row["market_type"], row["period"], row["home_line"])
# User-supplied data: team IDs, not a provider's display order, drive the sign.
external_quotes = [
{"source": "my-external-feed", "team_id": "chelsea", "line": "1.25", "price": "2.10"},
{"source": "my-external-feed", "team_id": "arsenal", "line": "-1.25", "price": "1.88"},
]
# Example resolved fixture. In production, obtain these IDs from your fixture matcher.
for quote in external_quotes:
mapped = external_row("1001", "num_0", quote, "arsenal", "chelsea")
print(match_key(mapped), mapped["side"], mapped["price"])
# To use the live PinnWire leg, call:
# payload = fetch_pinnwire()
# reference = {match_key(row): row for row in pinnwire_rows(payload)}
The mapper is intentionally independent of transport after fetch_pinnwire. You can pass a captured PinnWire JSON response instead when building repeatable tests, while the external quote rows remain under your control.
Tests that catch the expensive bugs
Run these checks before allowing a price difference to become an alert. They test the transformation, not whether an external source is trustworthy.
def test_home_and_away_are_one_market():
assert home_anchored_line("arsenal", "-1.25", "arsenal", "chelsea") == D("-1.25")
assert home_anchored_line("chelsea", "+1.25", "arsenal", "chelsea") == D("-1.25")
def test_reversed_display_order_uses_team_identity():
# Another source displays Chelsea first, but its quote names Arsenal.
assert home_anchored_line("arsenal", "-1.25", "arsenal", "chelsea") == D("-1.25")
# A quote owned by Chelsea stays the same market after one sign inversion.
assert home_anchored_line("chelsea", "+1.25", "arsenal", "chelsea") == D("-1.25")
def test_quarter_lines_split_without_interpolation():
assert settlement_legs("-0.75") == (D("-1.0"), D("-0.5"))
assert settlement_legs("-1.25") == (D("-1.5"), D("-1.0"))
assert settlement_legs("-1.0") == (D("-1.0"),)
def test_period_is_part_of_identity():
ft = {"event_key": "1001", "market_type": "ah", "period": "num_0", "home_line": D("-0.25")}
first_half = dict(ft, period="num_1")
assert match_key(ft) != match_key(first_half)
def test_unknown_team_fails_loudly():
try:
home_anchored_line("not-in-fixture", "-0.75", "arsenal", "chelsea")
except ValueError:
pass
else:
raise AssertionError("unresolved teams must not be mapped")
Notice what the tests refuse to do: they never default an unknown side to home, never compare num_0 with num_1, and never treat a quarter line as one whole-line outcome. A loud rejected row is safer than a silent false edge.
Use the right PinnWire surface for the job
| Mapping need | PinnWire surface | What to retain |
|---|---|---|
| Current live reference | GET /kit/v1/markets?sport_id=1 | event identity, period, every spread, hdp, prices, generated_at |
| Current prematch reference | GET /kit/v1/prematch/fixtures?sport_id=1 | fixture identity and full published handicap depth |
| One prematch event | GET /kit/v1/prematch/lines?event_id=…&market_type=spreads | compact full-game spread rows for the comparison key |
| Continuous line transitions | Optional raw WebSocket | baseline, updates, deletes and close signals; update the local book by version |
| Detected price drops | REST or SSE drop feed | event, market, side and before/after prices; re-check the full line before acting |
REST gives you a current comparison snapshot. The optional raw WebSocket is the better fit when your mapper must see a line and price transition as it happens. In either mode, remove closed or deleted markets and re-fetch when a freshness check says your reference is stale.
Common mapping failures
Flipping because labels flipped
Resolve team IDs first. Invert based on the quoted team, not on which label a source prints first.
Comparing the same number in different periods
Make period part of every key. Full match and first half are never interchangeable.
Dropping the market type
Require ah. Asian and European handicaps can share a display field while settling differently.
Storing two independent signs
Persist one home_line. Derive the away line to prevent partial updates from disagreeing.
Treating quarters as interpolation
Keep the real published quarter line and two settlement legs. Never invent a price between markets.
Using stale reference odds
Read generated_at and last_odds_update_seconds_ago; use a unique fresh value when checking a cache.
Why PinnWire is the reference leg
PinnWire is purpose-built for developers who need a real-time Pinnacle benchmark: current live and prematch markets, full Asian handicap line depth, signed home-perspective hdp, decimal prices, and a clean REST shape that is easy to preserve in a normalization pipeline. That makes PinnWire the natural anchor for a line-mapping service, model input, trading dashboard or comparison grid.
Fit limits: PinnWire is a focused Pinnacle odds API, not a multi-book archive or bet-placement system. Bring your own external quotes when you need a second source, and keep that source behind the same fixture, period, market-type and home-line contract. PinnWire supplies the current reference leg; your application owns the external ingestion and historical recording.
Asian handicap mapping questions
What is the safest Asian handicap sign convention for an API?
Anchor every line to the canonical home team. Store one home_line and derive the away line as its negative on read. PinnWire's hdp field already uses the home-team perspective in its spreads objects.
How do I map a line when another feed lists the fixture in reverse order?
Resolve the actual team identities first, then apply the sign based on the team the quote belongs to. If the quoted team is the canonical home team, its line is home_line; if it is the canonical away team, negate it. Never invert merely because a provider's display order is reversed.
How should quarter Asian handicap lines be represented?
Keep the published quarter line as the market identity and store its two settlement legs separately. A home −0.75 line splits into home −0.5 and home −1.0 at half stake each; do not invent an interpolated price.
Can PinnWire be the reference leg in a multi-source handicap mapper?
Yes. PinnWire is a focused real-time Pinnacle odds API. Use the live or prematch endpoints for the current reference snapshot, then normalize user-supplied external quotes to the same fixture, period, side and hdp before comparing prices.
Which PinnWire endpoint should I use for Asian handicap mapping?
Use GET /kit/v1/markets?sport_id=1 for current live soccer markets, GET /kit/v1/prematch/fixtures?sport_id=1 for prematch fixtures, and /kit/v1/prematch/lines?event_id=...&market_type=spreads for a compact prematch event view. The optional raw WebSocket is for continuous live and prematch updates.
Build your mapper on a current Pinnacle line
Verify the reference with key=demo, then take a free PinnWire trial key for repeatable comparisons and line capture.