Skip to main content

Pinnacle Integration Plan

Step-by-step plan for integrating Pinnacle (PS3838) into the PAL architecture. Betfair remains untouched. Pinnacle becomes primary for soccer, basketball, tennis. Betfair remains primary for cricket.


Table of Contents

  1. Scope
  2. API Test Results — Resolved Questions
  3. Files to Create
  4. Files to Modify
  5. Phase 1 — Types & Client
  6. Phase 2 — Mapper
  7. Phase 3 — Cache
  8. Phase 4 — Adapter (Odds)
  9. Phase 5 — Adapter (Orders)
  10. Phase 6 — Settlement Job
  11. Phase 7 — PAL Registration & Routing
  12. Phase 8 — Config & Environment
  13. PAL Method → Pinnacle API Mapping
  14. Tracer Bullet
  15. Testing Strategy
  16. Unresolved Questions

1. Scope

In Scope (MVP)

ItemDetails
SportsSoccer (pin:29), Basketball (pin:4), Tennis (pin:33) — easily extendable
Bet typesStraight bets only (moneyline, spread, total, team total)
MarketsMoneyline, Spread, Over/Under, Team Total
PeriodsFull game (0) + sport-specific halves/sets (1, 2)
Live bettingSupported — handle PENDING_ACCEPTANCE
SettlementPolling job — same pattern as Betfair
Incremental updatessince cursor polling for fixtures + odds
RoutingPinnacle primary for soccer/basketball/tennis, Betfair fallback

Out of Scope (Future)

ItemWhy Deferred
Parlay betsSeparate API flow, not in Betfair adapter either
Teaser betsAmerican sports specific, low priority
Special/prop betsDifferent endpoint pattern, separate integration
Alt lines beyond primaryStart with primary lines only, add alt lines later
American Football (pin:15)Account supports it (1 event), but low priority for MVP
CricketNot available on Pinnacle — no cricket sportId in /v3/sports response

2. API Test Results — Resolved Questions

Tested 2026-02-08 against api.ps3838.com with account PWF3800HK1. All questions from previous "Unresolved" section now answered with real data.

2a. Sport IDs — Confirmed

GET /v3/sports → 6 sports available on this account:
Pinnacle sportIdSport NameHannibal sportIdeventCountNotes
29Soccer101430Primary target. 446 leagues, 3913 fixtures
4Basketball12989 leagues, 205 fixtures
33Tennis620048 leagues, 1042 fixtures
15Football (US)31Only specials (281 eventSpecials). Low priority
3Baseball230Has offerings but 0 events currently
22MMA120No offerings

Cricket: NOT AVAILABLE. Not in the /v3/sports response at all. Pinnacle cannot be a cricket fallback. Routing for cricket (27) stays Betfair-only.

2b. Rate Limits — Confirmed

Test: 10 rapid sequential GET /v3/sports
Result: 4 × HTTP 200, then 6 × HTTP 429

429 response body: "error code: 1015" (Cloudflare rate limit, not application-level)
FindingValue
Rate limit~4 req/s (Cloudflare-enforced)
Error code1015 (Cloudflare)
HTTP status429 Too Many Requests
Reset time~3-5 seconds (empirical)
ScopeGlobal across all endpoints for the account
Per-endpoint?No — global bucket. Mixed-endpoint test at ~2 req/s passed 10/10

Recommendation: Token bucket at 3 req/s (conservative) with burst of 4. Queue requests internally. This leaves headroom for settlement + odds polling to coexist.

Official per-endpoint polling limits (from PS3838 Fair Use Policy — must be enforced application-side, separate from Cloudflare rate limit):

EndpointInterval (with since)Interval (without since)Notes
/fixtures5s per sport60s per sportsince must NOT be 0 or 1
/odds5s per sport60s per sportSame rule
/fixtures/settled5s per sport60s per sport
/sportsN/A60sSport list rarely changes
/leaguesN/A60s per sport
/inrunningN/A2s
/betsN/A2s
/betting-statusN/A1s
/lineOn demand onlyN/AMust NOT loop — pre-bet check only

Critical fair use rules:

  • Must NOT call /odds or /fixtures in a loop per league/fixture — batch all leagueIds/eventIds in one call
  • Must NOT call /line in a loop — purpose is pre-bet price check only
  • Always start with snapshot (no since), then continue with delta calls
  • Snapshot calls are cached server-side for 60s. Delta calls are not cached
  • API usage must be proportionate to wagering — no data scraping

2c. Account Currency — Confirmed

GET /v2/currencies → USD rate: 1.0

Account is USD-denominated. All stakes, limits, and P&L in USD. No currency conversion needed.

2d. Betting Status — Confirmed

GET /v1/bets/betting-status → { "status": "ALL_BETTING_ENABLED" }

2e. Period Definitions — Confirmed (from /v1/periods)

Soccer (29):

#descriptionshortDescription
0MatchMatch
11st Half1st H
22nd Half2nd H
3Extra TimeET
4Extra Time 1st HalfET 1H
5Extra Time 2nd HalfET 2H
6Penalty ShootoutShootout
8To QualifyQualify

Basketball (4):

#descriptionshortDescription
0GameGame
11st Half1st H
22nd Half2nd H
31st Quarter1st Q
42nd Quarter2nd Q
53rd Quarter3rd Q
64th Quarter4th Q

Tennis (33): 5 sets + per-game periods (Set 1 Game 1 through Set 5 Game 13). ~80 periods total. MVP only uses periods 0-5 (Match + Sets 1-5).

2f. Line Response — Confirmed (from /v2/line)

// GET /v2/line?sportId=29&eventId=1623781857&periodNumber=0&betType=MONEYLINE&leagueId=284677&team=TEAM1&oddsFormat=Decimal
{
"status": "SUCCESS",
"price": 2.47,
"lineId": 3449934794,
"altLineId": null,
"team1Score": 0, "team2Score": 0,
"team1RedCards": 0, "team2RedCards": 0,
"maxRiskStake": 763.75,
"minRiskStake": 20.0,
"maxWinStake": 1122.71,
"minWinStake": 29.4,
"effectiveAsOf": "2026-02-08T05:01:00Z",
"periodTeam1Score": 0, "periodTeam2Score": 0,
"periodTeam1RedCards": 0, "periodTeam2RedCards": 0
}

Critical finding: leagueId is a REQUIRED parameter for /v2/line. Must store leagueId per event in cache/fixture mapping.

minRiskStake = 20 USD (confirmed for soccer moneyline live).

2g. Odds Response — Confirmed (from /v4/odds)

Key structural findings from real response:

FieldPresent?Notes
homeScore, awayScoreYes (event level)Live scores in odds response
homeRedCards, awayRedCardsYes (event level)Red cards in odds response
spreads[0] (no altLineId)Primary linePrimary spread line
spreads[1+] (with altLineId)Alt linesAlt lines have altLineId + max
moneyline.drawPresent for soccerAbsent for basketball
teamTotalPresent{ home: [...], away: [...] }
status: 1Period onlinestatus: 2 = offline (cutoff passed)
*UpdatedAt timestampsPer market typespreadUpdatedAt, moneylineUpdatedAt, etc.

lineId is per-period, NOT per-market-type. Same lineId: 3449934794 used for moneyline, spread, and total within period 0. Confirmed via /v2/line calls.

2h. Fixture Response — Confirmed (from /v3/fixtures)

{
"id": 1623781857,
"starts": "2026-02-08T04:00:00Z",
"home": "Roasso Kumamoto",
"away": "Renofa Yamaguchi",
"rotNum": "35458",
"liveStatus": 1, // 0=no live, 1=currently live, 2=will offer live
"status": "O", // O=Open, H=Halted
"parlayRestriction": 2, // 0=unrestricted, 1=no parlay, 2=one leg/event
"parentId": 1623546549, // pre-match event ID (live events have parents)
"altTeaser": false,
"resultingUnit": "Regular",
"betAcceptanceType": 1, // 0=None, 1=Danger Zone, 2=Delay, 3=Both
"version": 648258875
}

parentId exists on live events — the live event is a child of the pre-match event. Important for linking.

2i. Settlement Response — Confirmed (from /v3/fixtures/settled)

{
"id": 1623396154,
"periods": [
{ "number": 0, "status": 1, "settlementId": 23829540, "settledAt": "2026-02-07T20:58:01Z", "team1Score": 1, "team2Score": 0 },
{ "number": 1, "status": 1, "settlementId": 23828281, "settledAt": "2026-02-07T19:52:29Z", "team1Score": 0, "team2Score": 0 }
]
}

Settlement is per-period. Each period has its own settlementId, settledAt, and scores.

2j. Incremental Updates — Confirmed

Full fetch:    GET /v3/fixtures?sportId=29       → 446 leagues, 3913 events, last=1770526822682
Incremental: GET /v3/fixtures?sportId=29&since=1770526822682 → 10 leagues, 12 events

since reduces response from 3913 to 12 events. Highly effective.

2k. In-Running Data — Confirmed (from /v2/inrunning)

{ "id": 1623781857, "state": 2, "elapsed": 7 }
stateMeaning
11st Half
22nd Half
9Half-time

2l. GET /v3/bets — Requires fromDate/toDate

GET /v3/bets?betlist=RUNNING → ERROR: "Missing fromDate/toDate"
GET /v3/bets?betlist=RUNNING&fromDate=2026-02-01T00:00:00Z&toDate=2026-02-09T00:00:00Z → OK

fromDate and toDate are required even for RUNNING betlist. Must always include date range.

2m. homeTeamType — Critical for Bet Placement (from API docs)

Source: api/01_getting_started.md — Parameter Mapping section

The team parameter in POST /v2/bets/place and GET /v2/line depends on homeTeamType from the leagues response (GET /v3/leagues).

homeTeamType tells you whether the home team is Team1 or Team2. This determines the correct team value:

homeTeamTypeHome team =Away team =
"Team1"TEAM1TEAM2
"Team2"TEAM2TEAM1

Examples from docs:

homeTeamTypeSelected Oddsteam Param
"Team1"Spread → awayTeam2
AnyMoneyline → drawDraw
"Team2"Team Total → awayTeam1

Impact on implementation: Must cache homeTeamType per league (alongside leagueId). Used in PinnacleMapper to translate "home"/"away" outcome IDs → correct TEAM1/TEAM2 API params.

2n. Live Betting Behavior (from API docs)

Source: api/03_faqs.md — "How to place a bet on live events?"

FindingValueImpact
Live delay~6 secondsFirst GET /v3/bets call should be 6s after placing
uniqueRequestId expiry30 minutesAfter 30 min, stops returning results for that ID. Cache cleanup
RUNNING betlistExcludes PENDING_ACCEPTANCE and NOT_ACCEPTEDMust query by uniqueRequestIds for pending live bets
betId assignmentOnly on ACCEPTEDPENDING_ACCEPTANCE bets have no betId yet — use uniqueRequestId to track

2o. Max Risk Calculation from Max Volume (from API docs)

Source: api/03_faqs.md — "How to calculate max risk from the max volume limits in /odds?"

/odds returns maxMoneyline, maxSpread, maxTotal as max volume (not max risk). To get max risk stake:

If price > 2:  maxRisk = maxVolume
If price < 2: maxRisk = maxVolume / (price - 1)

Example: maxMoneyline=250, home price=1.819, away price=2.03

  • Home max risk = 250 / (1.819 - 1) = 305
  • Away max risk = 250 (price > 2)

Impact: The limit field in CanonicalPrice should use calculated maxRisk, not raw maxVolume.

2p. Delta Response Behavior (from API docs)

Source: api/03_faqs.md — "How to get odds changes?"

Delta response contains only changed periods, but includes ALL markets currently offered for that period (not just changed markets).

Example:

  1. Snapshot returns period 0 (moneyline + spreads) and period 1 (moneyline + spreads)
  2. Delta returns only period 1 (moneyline + totals)
  3. Interpretation: Period 0 unchanged. Period 1: spreads no longer offered, totals now offered, moneyline may have new prices

Impact on cache: When merging delta odds, replace the entire period in cache (not just individual markets). A missing market in the delta means it's no longer offered.

2q. Server-Side Caching (from API docs)

Source: api/03_faqs.md — "Do you cache responses?"

  • Snapshot calls (same params, same account): cached 60 seconds server-side
  • Delta calls: NOT cached

This means rapid snapshot calls within 60s return stale data. Delta calls always return fresh changes. Reinforces the "snapshot once → delta forever" pattern.


3. Files to Create

All paths relative to worktree root (pinnacle-pal/).

#FilePurposeEst. Lines
1backend/src/exchanges/adapters/pinnacle/types.tsPinnacle API request/response TypeScript types~300
2backend/src/exchanges/adapters/pinnacle/PinnacleClient.tsHTTP client — Basic Auth, Lines API + Bets API~350
3backend/src/exchanges/adapters/pinnacle/mappers/PinnacleMapper.tsPinnacle → Canonical data transformation~400
4backend/src/exchanges/adapters/pinnacle/PinnacleCache.tsIn-memory cache — since cursors, fixtures, odds, leagueIds~150
5backend/src/exchanges/adapters/pinnacle/PinnacleAdapter.tsMain adapter — implements IExchangeAdapter~500
6backend/src/jobs/pinnacleSettlementJob.tsSettlement polling job~150

Directory Structure

backend/src/exchanges/adapters/pinnacle/
├── types.ts — API types (request/response shapes)
├── PinnacleClient.ts — HTTP client (auth, retry, rate limiting)
├── PinnacleCache.ts — In-memory cache with `since` cursor tracking
├── PinnacleAdapter.ts — Main adapter (IOddsProvider + IOrderProvider)
├── index.ts — Re-exports
└── mappers/
└── PinnacleMapper.ts — Pinnacle ↔ Canonical transformations

4. Files to Modify

#FileChangeRisk
1backend/src/exchanges/index.tsRegister PinnacleAdapter, add sport routing configs, conditional settlement jobsLow — additive only
2backend/src/config/index.tsAdd PINNACLE_* env vars (6 total: ENABLED, USERNAME, PASSWORD, BASE_URL, CREDIT_LIMIT, MARGIN_PERCENT)Low — new fields only
3backend/src/services/orderService.tsDynamic bookmaker from PAL routing, lay bet validation for Pinnacle, Redis distributed lock for exposure guard, transaction metadata with providerMedium — core bet placement path
4backend/src/services/redis.tsacquireRedisLock() + releaseRedisLock() helpers (SET NX EX + Lua CAS)Low — new functions
5backend/src/services/providerExposureService.ts (new)Exposure guard: 80% warn / 90% hard-block, Redis-cached (30s TTL)Low — new service
6backend/src/exchanges/core/models/canonical.tsNo changes needed — pinnacle?: string already exists in ExternalIdsNone
7backend/src/exchanges/core/models/capability.tsNo structural changes — routing config updated at runtime in index.tsNone

Total files touched: 7 new + 4 modified = 11 files. Zero changes to Betfair adapter.


Phase 1 — Types & Client

1a. types.ts — Pinnacle API Types

Define TypeScript types for all Pinnacle API request/response shapes we'll use.

// ============================================================
// ENUMS (confirmed from API responses)
// ============================================================

type PinnacleBetType = 'MONEYLINE' | 'SPREAD' | 'TOTAL_POINTS' | 'TEAM_TOTAL_POINTS';
type PinnacleOddsFormat = 'American' | 'Decimal' | 'HongKong' | 'Indonesian' | 'Malay';
type PinnacleWinRiskStake = 'WIN' | 'RISK';
type PinnacleFillType = 'NORMAL' | 'FILLANDKILL' | 'FILLMAXLIMIT';
type PinnacleBetStatus = 'ACCEPTED' | 'CANCELLED' | 'LOSE' | 'PENDING_ACCEPTANCE'
| 'REFUNDED' | 'NOT_ACCEPTED' | 'WON' | 'REJECTED';
type PinnacleBetStatus2 = PinnacleBetStatus | 'LOST' | 'HALF_WON_HALF_PUSHED' | 'HALF_LOST_HALF_PUSHED';
type PinnaclePlaceBetStatus = 'ACCEPTED' | 'PENDING_ACCEPTANCE' | 'PROCESSED_WITH_ERROR';
type PinnacleTeam = 'TEAM1' | 'TEAM2' | 'DRAW';
type PinnacleSide = 'OVER' | 'UNDER';
type PinnacleFixtureStatus = 'O' | 'H' | 'I'; // Open, Halted, Red-circle(deprecated)
TypeSourceUsed By
PinnacleSport/v3/sports response item{ id: number; name: string; hasOfferings: boolean; eventCount: number; leagueSpecialsCount: number; eventSpecialsCount: number }
PinnacleSportsResponse/v3/sports wrapper{ sports: PinnacleSport[] }
PinnacleFixture/v3/fixtures eventSee confirmed shape in §2h
PinnacleFixturesResponse/v3/fixtures wrapper{ sportId: number; last: number; league: PinnacleFixtureLeague[] }
PinnacleFixtureLeagueNested in fixtures{ id: number; name?: string; events: PinnacleFixture[] }
PinnacleOddsResponse/v4/odds wrapper{ sportId: number; last: number; leagues: PinnacleOddsLeague[] }
PinnacleOddsLeagueNested in odds{ id: number; events: PinnacleOddsEvent[] }
PinnacleOddsEventNested in odds{ id: number; awayScore?: number; homeScore?: number; awayRedCards?: number; homeRedCards?: number; periods: PinnacleOddsPeriod[] }
PinnacleOddsPeriodNested in oddsSee full confirmed shape below
PinnacleMoneylineNested in period{ home: number; away: number; draw?: number }
PinnacleSpreadNested in period{ altLineId?: number; hdp: number; home: number; away: number; max?: number }
PinnacleTotalNested in period{ altLineId?: number; points: number; over: number; under: number; max?: number }
PinnacleTeamTotalsNested in period{ home: PinnacleTotal[]; away: PinnacleTotal[] }
PinnacleLineResponse/v2/lineSee confirmed shape in §2f
PinnaclePlaceBetRequest/v2/bets/place bodySee §12
PinnaclePlaceBetResponse/v2/bets/place response{ status: PinnaclePlaceBetStatus; errorCode?: string; betId?: number; uniqueRequestId: string; betterLineWasAccepted?: boolean; price?: number; win?: number; risk?: number }
PinnacleStraightBet/v3/bets itemSee research doc §16
PinnacleSettledFixture/v3/fixtures/settled event{ id: number; periods: PinnacleSettledPeriod[] }
PinnacleSettledPeriodNested in settled{ number: number; status: number; settlementId: number; settledAt: string; team1Score: number; team2Score: number }
PinnaclePeriodDefinition/v1/periods item{ number: number; description: string; shortDescription: string }
PinnacleLeague/v3/leagues item{ id: number; name: string; homeTeamType: string; hasOfferings: boolean; container: string; allowRoundRobins: boolean; eventCount: number }homeTeamType is critical for bet placement team mapping
PinnacleInRunningEvent/v2/inrunning item{ id: number; state: number; elapsed: number }

PinnacleOddsPeriod (confirmed from real response):

interface PinnacleOddsPeriod {
lineId: number; // e.g., 3449934794 — shared across market types within period
number: number; // 0=full, 1=1H, 2=2H, ...
cutoff: string; // ISO datetime — when wagering closes
status: number; // 1=online, 2=offline
maxSpread: number; // e.g., 2350
maxMoneyline: number; // e.g., 1175
maxTotal: number; // e.g., 2350
maxTeamTotal?: number; // e.g., 950
spreadUpdatedAt?: string; // ISO — last spread change
moneylineUpdatedAt?: string; // ISO — last moneyline change
totalUpdatedAt?: string; // ISO — last total change
teamTotalUpdatedAt?: string; // ISO — last team total change
homeScore?: number; // Period-level score (live)
awayScore?: number;
homeRedCards?: number;
awayRedCards?: number;
moneyline?: PinnacleMoneyline;
spreads?: PinnacleSpread[]; // [0]=primary, [1+]=alt lines
totals?: PinnacleTotal[]; // [0]=primary, [1+]=alt lines
teamTotal?: PinnacleTeamTotals;
}

1b. PinnacleClient.ts — HTTP Client

Reference pattern: BetfairClient.ts (lines 79-393) — uses raw https module. Pinnacle uses Axios for simplicity (no session management needed, just Basic Auth on every request).

class PinnacleClient {
private readonly baseUrl = 'https://api.ps3838.com';
private readonly authHeader: string; // Basic <Base64(user:pass)>
private readonly rateLimiter: TokenBucketRateLimiter; // 3 req/s, burst 4

constructor(config: { username: string; password: string; baseUrl?: string }) { ... }
MethodSignaturePinnacle EndpointReturns
getSports()(): Promise<PinnacleSport[]>GET /v3/sportsSport list
getLeagues(sportId)(sportId: number): Promise<PinnacleLeague[]>GET /v3/leagues?sportId=XLeague list
getPeriods(sportId)(sportId: number): Promise<PinnaclePeriodDefinition[]>GET /v1/periods?sportId=XPeriod defs
getFixtures(sportId, opts?)(sportId: number, opts?: { since?: number; isLive?: boolean; eventIds?: number[] }): Promise<PinnacleFixturesResponse>GET /v3/fixtures?sportId=XFixtures w/ cursor
getOdds(sportId, opts?)(sportId: number, opts?: { since?: number; isLive?: boolean; eventIds?: number[]; oddsFormat?: string }): Promise<PinnacleOddsResponse>GET /v4/odds?sportId=X&oddsFormat=DecimalOdds w/ cursor
getLine(params)(params: { sportId: number; eventId: number; periodNumber: number; betType: PinnacleBetType; oddsFormat: string; leagueId: number; team?: PinnacleTeam; side?: PinnacleSide; handicap?: number }): Promise<PinnacleLineResponse>GET /v2/lineFresh line
placeBet(request)(request: PinnaclePlaceBetRequest): Promise<PinnaclePlaceBetResponse>POST /v2/bets/placeBet result
getBets(params)(params: { betlist?: string; fromDate: string; toDate: string; betIds?: number[]; uniqueRequestIds?: string[] }): Promise<PinnacleStraightBet[]>GET /v3/betsBet list
getSettledFixtures(sportId, opts?)(sportId: number, opts?: { since?: number }): Promise<PinnacleSettledFixturesResponse>GET /v3/fixtures/settled?sportId=XSettled fixtures
getInRunning()(): Promise<PinnacleInRunningResponse>GET /v2/inrunningLive game states
getBettingStatus()(): Promise<{ status: string }>GET /v1/bets/betting-statusSystem status

Client internals:

ConcernImplementationReference
AuthHTTP Basic — Authorization: Basic <Base64(username:password)> on every requestUnlike Betfair which needs session login/refresh
Base URLhttps://api.ps3838.com (configurable)
HTTP libAxios instance with defaultsBetfairClient uses raw https — Pinnacle is simpler, no SSL cert auth
Rate limitingToken bucket: 3 req/s, burst 4Confirmed 429 at ~4 req/s
Retry1 auto-retry on RESUBMIT_REQUEST, SYSTEM_ERROR_3 (3s delay). LINE_CHANGED is NOT retried here — caller (PinnacleAdapter.placeOrder) re-fetches line, compares price, and retries only if equal or betterBetfair REST client does not use exponential backoff — single retry with 3s delay
Error handlingParse JSON { code, message } on 4xx/5xx → throw PinnacleApiError. 429 body = "error code: 1015" (string, not JSON)429 is NOT retried — thrown as PinnacleApiError(RATE_LIMITED). Betfair does not retry 429s either. Token bucket + fair-use tracker should prevent 429s entirely
LoggingDebug: all requests/responses. Error: failures.Same pattern as BetfairClient
Request timeout15s default (Pinnacle responses are fast, ~500ms typical)BetfairClient uses 30s

Rate Limiter implementation (TokenBucketRateLimiter class):

// Redis-backed token bucket with in-memory fallback:
// - Redis: Lua script for atomic HMGET/HMSET (cross-instance safe)
// - In-memory fallback: if Redis unavailable
// Token bucket: 3 tokens/sec, max 4 burst tokens
// Before each request: await rateLimiter.acquire()
// 429 is NOT retried — Betfair REST client doesn't retry 429s either

Fair-Use Tracker (FairUseTracker class — separate from rate limiter): Enforces per-endpoint polling intervals from §2b to prevent Pinnacle fair-use violations.

Per-Event Bet Serialization: Map<eventId, {promise, timestamp}> lock prevents concurrent bets on the same event (which would trigger RESUBMIT_REQUEST). Features:

  • 5s lock timeout (prevents deadlock if lock holder hangs)
  • 60s cleanup interval removes leaked entries
  • Reference equality check in .finally() ensures only lock holder clears

Phase 2 — Mapper

PinnacleMapper.ts

Reference pattern: BetfairMapper.ts (mappers/BetfairMapper.ts) — exports pure functions, no class.

Sport ID Maps (confirmed from /v3/sports)

const HANNIBAL_TO_PINNACLE_SPORT: Record<number, number> = {
10: 29, // Soccer → confirmed 1430 events
1: 4, // Basketball → confirmed 298 events
6: 33, // Tennis → confirmed 200 events
// Future: 3: 15 (American Football → confirmed 1 event)
};

const PINNACLE_TO_HANNIBAL_SPORT: Record<number, number> = {
29: 10, // Soccer
4: 1, // Basketball
33: 6, // Tennis
};

// NO cricket mapping — Pinnacle doesn't offer cricket

Period Name Maps (confirmed from /v1/periods)

function mapPeriodName(periodNumber: number, pinnacleSportId: number): string {
if (periodNumber === 0) return 'full_time';

switch (pinnacleSportId) {
case 29: // Soccer
return { 1: 'first_half', 2: 'second_half', 3: 'extra_time' }[periodNumber] ?? `period_${periodNumber}`;
case 4: // Basketball
return { 1: 'first_half', 2: 'second_half', 3: '1st_quarter', 4: '2nd_quarter', 5: '3rd_quarter', 6: '4th_quarter' }[periodNumber] ?? `period_${periodNumber}`;
case 33: // Tennis
return { 1: 'first_set', 2: 'second_set', 3: 'third_set', 4: 'fourth_set', 5: 'fifth_set' }[periodNumber] ?? `period_${periodNumber}`;
default:
return `period_${periodNumber}`;
}
}

Mapping Functions

FunctionSignatureInput → OutputKey Logic
mapFixtureToCanonical(fixture: PinnacleFixture, leagueId: number, leagueName: string, pinnacleSportId: number): CanonicalFixture | nullPinnacle fixture → Canonical (null if unmapped sport)Returns null with logger.error() for unmapped sport IDs. Caller skips null fixtures.
mapOddsEventToMarkets(event: PinnacleOddsEvent, pinnacleSportId: number): CanonicalMarket[]Odds event → markets arrayIterate periods → explode each into markets
mapPeriodToMarkets(period: PinnacleOddsPeriod, eventId: number, pinnacleSportId: number): CanonicalMarket[]Single period → multiple marketsCreate moneyline + spread + total + teamTotal markets
mapMoneylineToMarket(ml: PinnacleMoneyline, period: PinnacleOddsPeriod, eventId: number, pinnacleSportId: number): CanonicalMarketMoneyline → market2 outcomes (basketball/tennis) or 3 (soccer has draw)
mapSpreadToMarket(spread: PinnacleSpread, period: PinnacleOddsPeriod, eventId: number, pinnacleSportId: number): CanonicalMarketSpread → market2 outcomes: home + away with handicap
mapTotalToMarket(total: PinnacleTotal, period: PinnacleOddsPeriod, eventId: number, pinnacleSportId: number): CanonicalMarketTotal → market2 outcomes: over + under with points
mapTeamTotalToMarkets(teamTotal: PinnacleTeamTotals, period: PinnacleOddsPeriod, eventId: number, pinnacleSportId: number): CanonicalMarket[]Team totals → 2 marketsHome TT + Away TT
mapBetToCanonicalOrder(bet: PinnacleStraightBet): CanonicalOrderBet → orderMap betStatus2 → OrderStatus + SettlementResult
mapFixtureStatus(liveStatus: number, status: PinnacleFixtureStatus): FixtureStatusTwo fields → statusSee table below
getPinnacleSportId(hannibalSportId: number): number | undefinedForward map
getHannibalSportId(pinnacleSportId: number): number | undefinedReverse map
parseMarketId(marketId: string): ParsedMarketId"pin_123_p0_ml" → structExtract eventId, period, betType, handicap
parseOutcomeId(outcomeId: string): ParsedOutcomeId"pin_123_p0_ml_home" → structExtract eventId, period, betType, team/side
mapSelectionToTeam(selection: 'home' | 'away' | 'draw', homeTeamType: string): PinnacleTeamMaps canonical selection → API team param using homeTeamType from leaguesSee homeTeamType mapping table in §2m
calculateMaxRisk(maxVolume: number, price: number): numberConverts /odds max volume → max risk stakeprice > 2 ? maxVolume : maxVolume / (price - 1) — see §2o

Fixture Status Mapping (confirmed from live data)

liveStatusstatusCanonicalFixture.status
1"O"'live'
0 or 2"O"'not_started'
any"H"'suspended'
any"I"'suspended' (deprecated but handle)

Market ID Generation

Composite IDs encode all info needed to reconstruct Pinnacle API params for /v2/line:

Market TypeID PatternExample (confirmed eventId format)
Moneylinepin_{eventId}_p{period}_mlpin_1623781857_p0_ml
Spread (primary)pin_{eventId}_p{period}_spread_{hdp}pin_1623781857_p0_spread_-0.25
Total (primary)pin_{eventId}_p{period}_ou_{points}pin_1623781857_p0_ou_1.0
Team Total Homepin_{eventId}_p{period}_tt_home_{points}pin_1623781857_p0_tt_home_0.5
Team Total Awaypin_{eventId}_p{period}_tt_away_{points}pin_1623781857_p0_tt_away_0.5

Outcome ID Generation

Market TypeOutcomeID Pattern
MoneylineHomepin_{eventId}_p{period}_ml_home
MoneylineAwaypin_{eventId}_p{period}_ml_away
MoneylineDrawpin_{eventId}_p{period}_ml_draw
SpreadHomepin_{eventId}_p{period}_spread_{hdp}_home
SpreadAwaypin_{eventId}_p{period}_spread_{hdp}_away
TotalOverpin_{eventId}_p{period}_ou_{points}_over
TotalUnderpin_{eventId}_p{period}_ou_{points}_under
TT HomeOverpin_{eventId}_p{period}_tt_home_{points}_over
TT HomeUnderpin_{eventId}_p{period}_tt_home_{points}_under
TT AwayOverpin_{eventId}_p{period}_tt_away_{points}_over
TT AwayUnderpin_{eventId}_p{period}_tt_away_{points}_under

Price Mapping

Pinnacle Field→ Canonical FieldNotes
Price value (decimal)backAll Pinnacle prices are back-only (bookmaker model)
N/AlayAlways undefined — no lay prices
N/AbackDepth, layDepthAlways undefined — single price, no depth
calculateMaxRisk(maxMoneyline, price) / calculateMaxRisk(maxSpread, price) / calculateMaxRisk(maxTotal, price)limitConverted from max volume → max risk using formula: price > 2 ? maxVolume : maxVolume / (price - 1). Raw values from period-level maxMoneyline/maxSpread/maxTotal are max volume, not max risk
minRiskStake (from /v2/line)limitMinOnly populated via getBetslip. Confirmed: 20 USD
spreadUpdatedAt / moneylineUpdatedAt / totalUpdatedAtchangedAtUse the market-type-specific timestamp

homeTeamType → Team Mapping (from API docs §2m)

// homeTeamType comes from GET /v3/leagues response — cached per league
// It tells us whether "home" in the fixture = TEAM1 or TEAM2 in the API

function mapSelectionToTeam(
selection: 'home' | 'away' | 'draw',
homeTeamType: string
): PinnacleTeam {
if (selection === 'draw') return 'DRAW';

if (homeTeamType === 'Team1') {
return selection === 'home' ? 'TEAM1' : 'TEAM2';
} else {
// homeTeamType === 'Team2'
return selection === 'home' ? 'TEAM2' : 'TEAM1';
}
}

Moneyline Draw Logic (confirmed)

// Soccer (29): moneyline has draw → 3 outcomes, type='match_odds'
// Basketball (4): no draw → 2 outcomes, type='moneyline'
// Tennis (33): no draw → 2 outcomes, type='moneyline'

// pinnacleSportId param removed — draw presence is sufficient
function getMoneylineMarketType(ml: PinnacleMoneyline): MarketType {
return ml.draw !== undefined ? 'match_odds' : 'moneyline';
}

Phase 3 — Cache

PinnacleCache.ts

In-memory cache. since cursors can optionally back up to Redis for persistence across restarts.

Additional cache: leagueId per event — required for /v2/line calls.

Cache KeyValueTTLPurpose
fixtures:since:{sportId}last cursor (number)No TTLTrack incremental fixture updates
odds:since:{sportId}last cursor (number)No TTLTrack incremental odds updates
settlement:since:{sportId}last cursor (number)No TTLTrack settlement polling
fixtures:{sportId}Map<eventId, PinnacleFixture>5 minFixture cache
odds:{sportId}:{eventId}PinnacleOddsPeriod[]30 secOdds cache (short TTL — odds move fast)
leagueMap:{sportId}Map<eventId, { leagueId: number; homeTeamType: string }>5 minEvent → leagueId + homeTeamType mapping (both required for bet placement)
periods:{sportId}PinnaclePeriodDefinition[]24 hrPeriod definitions (rarely change)
MethodSignatureDescription
getFixtureSinceCursor(sportId)(sportId: number): number | undefinedGet last fixtures cursor
setFixtureSinceCursor(sportId, cursor)(sportId: number, cursor: number): voidUpdate fixtures cursor
getOddsSinceCursor(sportId)(sportId: number): number | undefinedGet last odds cursor
setOddsSinceCursor(sportId, cursor)(sportId: number, cursor: number): voidUpdate odds cursor
getCachedFixtures(sportId)(sportId: number): Map<number, PinnacleFixture> | undefinedGet cached fixtures map
updateFixturesWithLeagueInfo(sportId, leagues)(sportId: number, leagues: PinnacleFixtureLeague[]): voidMerge fixtures + set homeTeamType from leagues cache per event. Renamed from updateFixtures to clarify it also populates leagueId/homeTeamType mapping.
getCachedOdds(sportId, eventId)(sportId: number, eventId: number): PinnacleOddsPeriod[] | undefinedGet cached odds periods
updateOdds(sportId, leagues)(sportId: number, leagues: PinnacleOddsLeague[]): voidMerge new/updated odds
getLeagueId(sportId, eventId)(sportId: number, eventId: number): number | undefinedGet leagueId for event
getHomeTeamType(sportId, eventId)(sportId: number, eventId: number): string | undefinedGet homeTeamType for event's league (from leagues response). Returns undefined if not cached — callers default to 'Team1' with logger.warn()
getLeagueHomeTeamType(leagueId)(leagueId: number): stringGet homeTeamType for a league. Defaults to 'Team1' with logger.warn() if not found. Used internally by updateFixturesWithLeagueInfo.
setLeagueHomeTeamTypes(sportId, data)(sportId: number, data: Array<{leagueId, homeTeamType}>): voidStore league-level homeTeamType. Called during initialize() with data from GET /v3/leagues.
getPeriodDefinitions(sportId)(sportId: number): PinnaclePeriodDefinition[] | undefinedGet period defs
setPeriodDefinitions(sportId, periods)(sportId: number, periods: PinnaclePeriodDefinition[]): voidCache period defs
invalidate(sportId)(sportId: number): voidClear all caches for a sport
clear()(): voidClear all caches

Phase 4 — Adapter (Odds)

PinnacleAdapter.ts — IOddsProvider Implementation

Reference: BetfairAdapter.ts (lines 116-1249). Same class structure: readonly providerId, readonly displayName, lifecycle methods, then interface methods.

export class PinnacleAdapter implements IExchangeAdapter {
readonly providerId = 'pinnacle';
readonly displayName = 'Pinnacle (PS3838)';

private client: PinnacleClient | null = null;
private cache: PinnacleCache;
private ready = false;
private lastHealthCheck: HealthCheck = { status: 'healthy', lastCheck: new Date() };

IOddsProvider Methods

PAL MethodSignatureImplementationPinnacle API
getFixtures(params)(params: GetFixturesParams): Promise<GetFixturesResult>1. Map params.sportId → Pinnacle sportId via getPinnacleSportId()
2. Get since cursor from cache.getFixtureSinceCursor()
3. If params.status === 'live': add isLive=true
4. Call client.getFixtures(sportId, { since, isLive })
5. cache.updateFixtures(sportId, response.league) — stores fixtures + leagueId mapping
6. cache.setFixtureSinceCursor(sportId, response.last)
7. Map each fixture via mapFixtureToCanonical(fixture, leagueId, leagueName, sportId)
8. If params.includeMarkets: call getOdds() for each fixture (batch via eventIds param)
GET /v3/fixtures?sportId=X&since=Y
getMarkets(params)(params: GetMarketsParams): Promise<GetMarketsResult>1. Parse fixtureId to get Pinnacle eventId
2. Determine sportId from fixture cache or params
3. Check odds cache first
4. If miss: call client.getOdds(sportId, { eventIds: [eventId] })
5. Cache the odds
6. mapOddsEventToMarkets(event, sportId) — explodes periods into markets
7. Return CanonicalMarket[]
GET /v4/odds?sportId=X&eventIds=Y&oddsFormat=Decimal
getOdds(params)(params: GetOddsParams): Promise<GetOddsResult>Delegates to getMarkets() — same endpoint provides bothSame as getMarkets
getFixture(fixtureId)(fixtureId: string): Promise<CanonicalFixture | null>1. Check fixture cache
2. If miss: parse eventId, determine sportId, call client.getFixtures(sportId, { eventIds: [eventId] })
3. Map and return
GET /v3/fixtures?sportId=X&eventIds=Y
supportsSport(sportId)(sportId: number): booleanReturn getPinnacleSportId(sportId) !== undefinedNone

Adapter Lifecycle

MethodImplementationPinnacle Calls
initialize()1. Validate credentials exist
2. Create PinnacleClient instance
3. Call client.getSports() → verify auth + connectivity
4. For each supported sport: call client.getPeriods(sportId)cache.setPeriodDefinitions()
5. For each supported sport: call client.getLeagues(sportId) → cache homeTeamType per league (required for bet placement team mapping, see §2m)
6. Set ready = true
GET /v3/sports + 3× GET /v1/periods + 3× GET /v3/leagues
shutdown()Clear cache, set ready = falseNone
isReady()Return ready && client !== nullNone
getCapabilities()Return features (see below)None
healthCheck()Call client.getBettingStatus() → measure latency → return statusGET /v1/bets/betting-status
getHealthStatus()Return lastHealthCheckNone
supportsStreaming()Return falseNone
getStreamProvider()Return nullNone

Capabilities Declaration

getCapabilities(): ProviderCapability {
return {
providerId: 'pinnacle',
displayName: 'Pinnacle (PS3838)',
supportedSports: [10, 1, 6], // Soccer, Basketball, Tennis
supportedMarkets: ['match_odds', 'moneyline', 'spread', 'over_under'],
features: {
layBetting: false, // Bookmaker — no lay
inPlay: true, // With PENDING_ACCEPTANCE
streaming: false, // Poll-based
cashOut: false,
partialMatching: false,
betEditing: false,
cancellation: false, // No cancellation
accountBalance: true,
orderStatus: true,
},
priority: 50, // Lower than Betfair (100)
rateLimits: { requestsPerSecond: 3, maxConcurrent: 3 },
health: this.lastHealthCheck,
};
}

Phase 5 — Adapter (Orders)

PinnacleAdapter.ts — IOrderProvider Implementation

PAL MethodSignatureImplementationPinnacle API
placeOrder(params)(params: PlaceOrderParams): Promise<PlaceOrderResult>See detailed flow belowGET /v2/linePOST /v2/bets/place
cancelOrder(params)(params: CancelOrderParams): Promise<CancelOrderResult>Return { success: false, orderId, message: 'Pinnacle does not support bet cancellation' }None
getOrder(params)(params: GetOrderParams): Promise<GetOrderResult>Call client.getBets({ betIds: [orderId], fromDate, toDate })mapBetToCanonicalOrder()GET /v3/bets?betIds=X&fromDate=Y&toDate=Z
getOrders(params)(params: GetOrdersParams): Promise<GetOrdersResult>Call client.getBets({ betlist: 'RUNNING' or 'SETTLED', fromDate, toDate }) → map eachGET /v3/bets?betlist=X&fromDate=Y&toDate=Z
getBetslip(params)(params: BetslipParams): Promise<BetslipResult>See detailed flow belowGET /v2/line
supportsOrdersForSport(sportId)(sportId: number): booleanSame as supportsSport()None

placeOrder() Detailed Flow

1. Parse outcomeId: "pin_1623781857_p0_ml_home"
→ eventId=1623781857, periodNumber=0, betType=MONEYLINE, selection=home

2. Get leagueId + homeTeamType from cache:
cache.getLeagueId(sportId, eventId) → leagueId=284677 (REQUIRED for /v2/line)
cache.getHomeTeamType(sportId, eventId) → homeTeamType="Team1"

2b. Map selection → API team param using homeTeamType:
homeTeamType="Team1" + selection="home" → team=TEAM1
homeTeamType="Team2" + selection="home" → team=TEAM2
selection="draw" → team=DRAW (always)

3. GET /v2/line?sportId=29&eventId=1623781857&periodNumber=0&betType=MONEYLINE
&oddsFormat=Decimal&leagueId=284677&team=TEAM1
→ { status: "SUCCESS", lineId: 3449934794, price: 2.47,
maxRiskStake: 763.75, minRiskStake: 20.0 }

4. Validate: params.order.stake >= minRiskStake (20) && stake <= maxRiskStake (763.75)
→ If out of range, return declined order with message

5. POST /v2/bets/place
{
oddsFormat: "DECIMAL",
uniqueRequestId: "<uuid-v4>",
acceptBetterLine: true,
stake: params.order.stake,
winRiskStake: "RISK",
lineId: 3449934794,
sportId: 29,
eventId: 1623781857,
periodNumber: 0,
betType: "MONEYLINE",
team: "TEAM1"
}

6. Handle LINE_CHANGED error (if POST /v2/bets/place throws errorCode=LINE_CHANGED):
a. Re-fetch line: GET /v2/line (same params)
b. Store originalLinePrice from step 3's response
c. Compare: if freshLine.price > originalLinePrice → decline (price worsened for back bet)
d. If freshLine.price <= originalLinePrice → retry POST with fresh lineId (max 2 total attempts)
e. Note: LINE_CHANGED is NOT retried in PinnacleClient.postWithRetry — caller handles it

7. Handle response:
ACCEPTED → PlaceOrderResult { order: { status: 'accepted', providerOrderId: betId }, submitted: true }
PENDING_ACCEPTANCE → PlaceOrderResult { order: { status: 'submitted', providerOrderId: uniqueRequestId }, submitted: true }
PROCESSED_WITH_ERROR → PlaceOrderResult { order: { status: 'declined', error: errorCode }, submitted: false }

getBetslip() Detailed Flow

1. Parse marketId + outcomeId to extract: eventId, periodNumber, betType, team/side, handicap
2. Get leagueId from cache
3. GET /v2/line with all params
4. Return:
- available: status === 'SUCCESS'
- currentOdds: response.price
- limit: response.maxRiskStake
- limitMin: response.minRiskStake
- betslipToken: String(response.lineId) // Store for placeOrder

Parsing Composite IDs

interface ParsedMarketId {
eventId: number;
periodNumber: number;
betType: PinnacleBetType;
handicap?: number;
teamTotalSide?: 'home' | 'away';
}

interface ParsedOutcomeId extends ParsedMarketId {
team?: PinnacleTeam; // TEAM1, TEAM2, DRAW
side?: PinnacleSide; // OVER, UNDER
}

// Regex-based parser for outcomeId strings
function parseOutcomeId(outcomeId: string): ParsedOutcomeId {
// pin_{eventId}_p{period}_{betType}[_{handicap}]_{team/side}
const match = outcomeId.match(/^pin_(\d+)_p(\d+)_(ml|spread|ou|tt_home|tt_away)(?:_([-\d.]+))?_(home|away|draw|over|under)$/);
// ... extract fields
}
ID PatternExtracted Fields
pin_1623781857_p0_ml_homeeventId=1623781857, period=0, betType=MONEYLINE, team=TEAM1
pin_1623781857_p0_ml_draweventId=1623781857, period=0, betType=MONEYLINE, team=DRAW
pin_1623781857_p0_spread_-0.25_homeeventId=1623781857, period=0, betType=SPREAD, handicap=-0.25, team=TEAM1
pin_1623781857_p0_ou_1.0_overeventId=1623781857, period=0, betType=TOTAL_POINTS, handicap=1.0, side=OVER
pin_1623781857_p0_tt_home_0.5_overeventId=1623781857, period=0, betType=TEAM_TOTAL_POINTS, team=TEAM1, handicap=0.5, side=OVER

Phase 6 — Settlement Job

pinnacleSettlementJob.ts

Reference: backend/src/jobs/orderSyncJob.ts — same three-job pattern.

JobFunction NameIntervalPurpose
SettlementcheckPinnacleSettledOrders()60sPoll settled fixtures → settle matching orders
PendingresolvePendingPinnacleOrders()15sPoll PENDING_ACCEPTANCE resolution
SyncsyncPinnacleRunningOrders()30sSync running orders with Pinnacle

checkPinnacleSettledOrders() Flow

1. Find Pinnacle orders that could be settled:
WHERE bookmaker='pinnacle' AND betsApiOrderId IS NOT NULL
AND status IN ('accepted', 'partially_accepted')
LIMIT 100

2. For each order:
a. Call pinnacleAdapter.getOrder({ providerOrderId: betsApiOrderId })
→ internally calls GET /v3/bets?betIds=X&fromDate=7d-ago&toDate=now
b. If palOrder.status === 'settled' && settlementResult exists:
- Map 'push' → 'void' (settlementService doesn't accept 'push')
- Call settlementService.settleOrderFromBetsApi(orderId, result, profitLoss)

3. After all settlements: updateProviderState('pinnacle') + invalidate exposure cache

Deviation from plan: Originally planned to use /v3/fixtures/settled per-sport with since cursor. Actual implementation queries per-order via getOrder() (which uses /v3/bets?betIds=X). This is simpler and avoids the complexity of matching settled fixtures to orders by eventId. The since cursor for settlements is still available in PinnacleCache but not currently used by the settlement job.

resolvePendingPinnacleOrders() Flow

1. Find orders in DB:
WHERE bookmaker='pinnacle' AND betsApiOrderId IS NOT NULL
AND status IN ('submitted', 'pending')
LIMIT 50

2. For each order:
a. Check age: ageMs = Date.now() - order.createdAt
b. If ageMs > 30 minutes (PENDING_ACCEPTANCE_TIMEOUT_MS):
→ Refund user (increment balancePoints, create bet_refund transaction)
→ UPDATE order SET status='declined'
→ Continue to next order
c. Otherwise: call pinnacleAdapter.getOrder({ providerOrderId: betsApiOrderId })
→ Internally calls GET /v3/bets?betIds=X
d. If status changed:
- 'declined' → Refund user + UPDATE order status='declined'
- Other (e.g. 'accepted') → UPDATE order status + placedStake + placedOdds

3. Polling notes:
- Uses getOrder (by betId), not uniqueRequestIds
- RUNNING betlist does NOT return PENDING_ACCEPTANCE bets (would need uniqueRequestIds)
- But since we store betsApiOrderId, we query by betIds which returns all statuses

Deviation from plan: Originally planned to poll by uniqueRequestIds. Actual implementation queries by betIds via getOrder() since betsApiOrderId is stored at submission time. The 6-second initial delay is not explicitly enforced — the 15s polling interval naturally covers it.

Settlement Status Handling (confirmed from real data)

period.status valueMeaningAction
1SettledNormal settlement
2Re-settledReverse previous settlement, apply new with new settlementId
3CancelledVoid the bet
4Re-settled as cancelledReverse previous, then void
5DeletedTreat as void

Phase 7 — PAL Registration & Routing

Changes to exchanges/index.ts

Reference: Current file (lines 60-131) — register Betfair, configure routing, initialize coordinator.

// In initializePAL() — ADDITIVE changes only:

import { PinnacleAdapter } from './adapters/pinnacle/PinnacleAdapter.js';

// After Betfair registration (line ~84):
if (config.pinnacle?.enabled) {
const pinnacleAdapter = new PinnacleAdapter({
username: config.pinnacle.username,
password: config.pinnacle.password,
baseUrl: config.pinnacle.baseUrl,
});
providerRegistry.register(pinnacleAdapter);
logger.info('Registered Pinnacle adapter');
}

// Update sportConfigs array (lines 89-115):
const sportConfigs = [
// Cricket — Betfair ONLY (Pinnacle has no cricket)
{ sportId: 27, primary: 'betfair', fallbacks: [] },

// Soccer — Pinnacle primary, Betfair fallback
{ sportId: 10, primary: config.pinnacle?.enabled ? 'pinnacle' : 'betfair',
fallbacks: config.pinnacle?.enabled ? ['betfair'] : [] },

// Basketball — Pinnacle primary, Betfair fallback
{ sportId: 1, primary: config.pinnacle?.enabled ? 'pinnacle' : 'betfair',
fallbacks: config.pinnacle?.enabled ? ['betfair'] : [] },

// Tennis — Pinnacle primary, Betfair fallback
{ sportId: 6, primary: config.pinnacle?.enabled ? 'pinnacle' : 'betfair',
fallbacks: config.pinnacle?.enabled ? ['betfair'] : [] },

// All other sports — Betfair only (unchanged)
...existingNonTargetSportConfigs,
];

// After coordinator.initialize():
if (config.pinnacle?.enabled) {
startPinnacleSettlementJobs();
}

Routing Table (after integration)

Hannibal SportIdSportPrimary ProviderFallback(s)
27Cricketbetfair[] — no Pinnacle fallback
10Soccerpinnacle['betfair']
1Basketballpinnacle['betfair']
6Tennispinnacle['betfair']
3-25 (others)Variousbetfair[]

Phase 8 — Config & Environment

New Environment Variables

VariableRequiredDefaultDescription
PINNACLE_ENABLEDNofalseFeature flag
PINNACLE_USERNAMEYes (if enabled)PS3838 API username
PINNACLE_PASSWORDYes (if enabled)PS3838 API password
PINNACLE_BASE_URLNohttps://api.ps3838.comAPI base URL
PINNACLE_CREDIT_LIMITNo1000Platform credit limit in USD
PINNACLE_MARGIN_PERCENTNo0Hannibal margin on top of Pinnacle odds (0 = use Pinnacle's built-in vig only)

Changes to config/index.ts

Add to Zod schema:

PINNACLE_ENABLED: z.string().optional().transform(v => v === 'true'),
PINNACLE_USERNAME: z.string().optional(),
PINNACLE_PASSWORD: z.string().optional(),
PINNACLE_BASE_URL: z.string().optional().default('https://api.ps3838.com'),
PINNACLE_CREDIT_LIMIT: z.string().optional().transform(v => Number(v) || 1000),
PINNACLE_MARGIN_PERCENT: z.string().optional().transform(v => Number(v) || 0),

Export as config.pinnacle:

pinnacle: {
enabled: env.PINNACLE_ENABLED ?? false,
username: env.PINNACLE_USERNAME ?? '',
password: env.PINNACLE_PASSWORD ?? '',
baseUrl: env.PINNACLE_BASE_URL ?? 'https://api.ps3838.com',
creditLimit: env.PINNACLE_CREDIT_LIMIT ?? 1000,
marginPercent: env.PINNACLE_MARGIN_PERCENT ?? 0,
}

Validation: if enabled is true, username and password must be non-empty.


PAL Method → Pinnacle API Mapping

Quick reference table:

PAL Interface MethodPinnacle API Call(s)Notes
getFixtures(params)GET /v3/fixtures?sportId=X&since=YUse since cursor. since must NOT be 0 or 1. Response includes last for next cursor. Fair use: delta every 5s, snapshot every 60s per sport
getMarkets(params)GET /v4/odds?sportId=X&eventIds=Y&oddsFormat=DecimalExplode periods → multiple markets
getOdds(params)Same as getMarketsSame endpoint
getFixture(id)GET /v3/fixtures?sportId=X&eventIds=YSingle fixture filter
placeOrder(params)1. GET /v2/line → 2. POST /v2/bets/placeTwo-step. leagueId required for step 1
cancelOrder(params)N/AReturn failure — unsupported
getOrder(params)GET /v3/bets?betIds=X&fromDate=Y&toDate=ZfromDate/toDate required
getOrders(params)GET /v3/bets?betlist=X&fromDate=Y&toDate=ZfromDate/toDate required
getBetslip(params)GET /v2/lineleagueId required. Returns fresh price + limits + lineId
healthCheck()GET /v1/bets/betting-statusLightweight, returns ALL_BETTING_ENABLED
initialize()GET /v3/sports + 3× GET /v1/periods + 3× GET /v3/leagues7 API calls total. Leagues needed for homeTeamType cache
Settlement pollingGET /v3/bets?betIds=X per-orderBackground job, polls per-order (simpler than planned /v3/fixtures/settled approach)

Tracer Bullet

First Slice — End-to-End Fixture Fetch

Goal: exchangeCoordinator.getFixtures({ sportId: 10 }) returns Pinnacle soccer fixtures.

StepWhat to BuildVerification
1types.tsPinnacleFixture, PinnacleFixturesResponse, PinnacleSport, PinnaclePeriodDefinitionCompiles
2PinnacleClient.tsgetSports(), getPeriods(), getFixtures() + auth + rate limiterCall API, log raw JSON. Verify 1430 soccer events returned
3PinnacleMapper.tsmapFixtureToCanonical(), sport maps, mapFixtureStatus()Transform one fixture → verify CanonicalFixture fields match
4PinnacleCache.ts — fixture cache + since cursor + leagueId mapCache stores fixtures, second call uses since and returns fewer events
5PinnacleAdapter.tsgetFixtures(), initialize(), supportsSport(), getCapabilities()Adapter works standalone
6config/index.ts — add env varsConfig loads cleanly with PINNACLE_ENABLED=true
7exchanges/index.ts — register adapter + routingexchangeCoordinator.getFixtures({ sportId: 10 }) returns Pinnacle fixtures

Second Slice — Odds

Add to existing files:

  • types.ts: PinnacleOddsResponse, PinnacleOddsPeriod, PinnacleMoneyline, etc.
  • PinnacleClient.ts: getOdds()
  • PinnacleMapper.ts: mapOddsEventToMarkets(), mapPeriodToMarkets(), mapMoneylineToMarket(), etc.
  • PinnacleAdapter.ts: getMarkets(), getOdds()

Verify: exchangeCoordinator.getOdds({ fixtureId: 'pin_1623781857' }) returns canonical markets with prices.

Third Slice — Bet Placement

Add:

  • PinnacleClient.ts: getLine(), placeBet()
  • PinnacleMapper.ts: parseOutcomeId(), parseMarketId()
  • PinnacleAdapter.ts: getBetslip(), placeOrder()

Verify: getBetslip() returns fresh price + limits. placeOrder() with $0.10 stake succeeds or returns proper error (minRiskStake = $20, so we'll get BELOW_MIN_BET_AMOUNT — that's a valid test).

Fourth Slice — Settlement

Add:

  • PinnacleClient.ts: getBets(), getSettledFixtures()
  • pinnacleSettlementJob.ts: all three jobs
  • PinnacleMapper.ts: mapBetToCanonicalOrder()

Verify: Settlement job detects recently settled fixtures and processes them.


Testing Strategy

Test TypeWhatHow
Unit — MapperPinnacleMapper functions (mapFixtureToCanonical, mapPeriodToMarkets, parseOutcomeId, etc.)Vitest — use real API response snapshots from §2 as input → verify canonical output
Unit — ClientPinnacleClient methods + rate limiter + retry logicVitest — mock Axios responses including 429, retryable errors
Unit — AdapterPinnacleAdapter methodsVitest — mock client + mapper
Unit — CachePinnacleCache TTL, since cursor, leagueId mapVitest
Integration — APIReal API calls against ps3838.comManual — verify auth, fixture fetch (1430 events), odds, line validation
Integration — PALFull routing flowManual — exchangeCoordinator.getFixtures({ sportId: 10 }) routes to Pinnacle
Integration — SettlementSettlement job e2eManual — wait for settled fixtures → verify detection

Unresolved Questions

#QuestionImpactStatus
1Exact Pinnacle sport IDsSport mappingRESOLVED — Soccer=29, Basketball=4, Tennis=33
2Does PS3838 account have cricket?FallbackRESOLVED — No. Cricket not available
3Rate limitsThrottlingRESOLVED — ~4 req/s, Cloudflare, error 1015
4Account currencyStakesRESOLVED — USD
5Default fillTypeBet behaviorDECISION: Use NORMAL — reject if over limit
6acceptBetterLine — always true?Bet behaviorDECISION: true — always accept favorable moves
7PENDING_ACCEPTANCE timeoutOrder mgmtDECISION: 30 min timeout → mark declined. Use full uniqueRequestId window (Pinnacle stops returning results after 30 min)
8Alt lines — include in MVP?MarketsDECISION: No — primary lines only
9Team totals — include in MVP?MarketsDECISION: Yes — confirmed data available, simple to add since mapper already handles totals
10Polling intervals for sinceFreshnessRESOLVEDsince reduces 3913→12 events. Start 10s live, 60s pre-match
11NEW: Can we place bets < $20?Testing$20 min risk stake. Our $0.10 test will get BELOW_MIN_BET_AMOUNT. Need to test with $20+ or accept error-path testing
12NEW: How does parentId affect betting?Live eventsLive events are children of pre-match events. Need to decide: use live eventId or parent eventId for our canonical fixture ID? Recommend: Use the eventId from /v3/fixtures as-is (live or pre-match), let the parentId link them
13homeTeamType for team mappingBet placementRESOLVEDhomeTeamType from GET /v3/leagues determines TEAM1/TEAM2 mapping. Must cache per league. See §2m
14Live delay timingPending ordersRESOLVED — ~6s live delay. First getBets call 6s after placing. uniqueRequestId results expire after 30 min. See §2n
15Max volume vs max riskStake limitsRESOLVED/odds returns max volume, not max risk. Formula: price > 2 ? maxVolume : maxVolume/(price-1). See §2o
16Delta response behaviorCache mergeRESOLVED — Delta returns ALL markets for changed periods (not just changed markets). Missing market = no longer offered. Replace entire period in cache. See §2p
17Pinnacle account balance APICredit monitoringRESOLVEDGET /v1/client/balance exists. Returns availableBalance, outstandingTransactions, givenCredit, currency. Use for real-time credit monitoring every 60s.
18winLoss field precisionSettlement P&LRESOLVEDwinLoss is number (IEEE 754 double). No fixed decimal places. Round to 2 decimal places when ingesting to match our Decimal(18,2).
19Concurrent bet placementRate limitingRESOLVED — Two POST /v2/bets/place calls CAN be in-flight simultaneously on different lines/events. Same-line concurrent bets may get RESUBMIT_REQUEST. DUPLICATE_UNIQUE_REQUEST_ID prevents replays. Serialize per-event in PinnacleClient.