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 fieldMeaningWhy it matters
event_keyStable fixture identity after team and kickoff matchingPrevents a valid line from landing on the wrong match
market_typeah for Asian handicapKeeps Asian, European handicap and totals data separate
periodnum_0, num_1, or your explicit period aliasFull match and first half are different wagers
home_lineSigned line from the canonical home perspectiveThe only line number needed for matching
prices.home / prices.awayDecimal prices for both selections at that lineLine and price together identify the market state
legsSettlement components for quarter linesLets 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 sidehome or away—before comparing a single quoted price. This keeps the line, period and outcome attached as one unambiguous wager.

Store

One home line

Persist home_line and derive away_line = -home_line on read.

Store

Exact period

Keep num_0 and num_1 in different keys, even when the line number matches.

Store

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:

  1. Match the sport and competition context.
  2. Resolve each source's team names to stable IDs, including aliases and accents.
  3. Match kickoff timestamps inside a documented tolerance.
  4. Choose the canonical home and away IDs from the resolved fixture.
  5. 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 resolutionCanonical resultReason
Arsenal (canonical home), -1.25home_line = -1.25The quoted team is home
Chelsea (canonical away), +1.25home_line = -1.25The quoted team is away, so negate once
Provider labels Chelsea as “home”, but team ID is Chelsea with +1.25home_line = -1.25Team identity wins over a provider's display order
Unknown team or unresolved fixtureReject and logGuessing 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 lineSettlement legsStake
-0.250.0 and -0.550% each
-0.75-0.5 and -1.050% each
-1.25-1.0 and -1.550% each
+0.250.0 and +0.550% 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 needPinnWire surfaceWhat to retain
Current live referenceGET /kit/v1/markets?sport_id=1event identity, period, every spread, hdp, prices, generated_at
Current prematch referenceGET /kit/v1/prematch/fixtures?sport_id=1fixture identity and full published handicap depth
One prematch eventGET /kit/v1/prematch/lines?event_id=…&market_type=spreadscompact full-game spread rows for the comparison key
Continuous line transitionsOptional raw WebSocketbaseline, updates, deletes and close signals; update the local book by version
Detected price dropsREST or SSE drop feedevent, 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.