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
- Scope
- API Test Results — Resolved Questions
- Files to Create
- Files to Modify
- Phase 1 — Types & Client
- Phase 2 — Mapper
- Phase 3 — Cache
- Phase 4 — Adapter (Odds)
- Phase 5 — Adapter (Orders)
- Phase 6 — Settlement Job
- Phase 7 — PAL Registration & Routing
- Phase 8 — Config & Environment
- PAL Method → Pinnacle API Mapping
- Tracer Bullet
- Testing Strategy
- Unresolved Questions
1. Scope
In Scope (MVP)
| Item | Details |
|---|---|
| Sports | Soccer (pin:29), Basketball (pin:4), Tennis (pin:33) — easily extendable |
| Bet types | Straight bets only (moneyline, spread, total, team total) |
| Markets | Moneyline, Spread, Over/Under, Team Total |
| Periods | Full game (0) + sport-specific halves/sets (1, 2) |
| Live betting | Supported — handle PENDING_ACCEPTANCE |
| Settlement | Polling job — same pattern as Betfair |
| Incremental updates | since cursor polling for fixtures + odds |
| Routing | Pinnacle primary for soccer/basketball/tennis, Betfair fallback |
Out of Scope (Future)
| Item | Why Deferred |
|---|---|
| Parlay bets | Separate API flow, not in Betfair adapter either |
| Teaser bets | American sports specific, low priority |
| Special/prop bets | Different endpoint pattern, separate integration |
| Alt lines beyond primary | Start with primary lines only, add alt lines later |
| American Football (pin:15) | Account supports it (1 event), but low priority for MVP |
| Cricket | Not available on Pinnacle — no cricket sportId in /v3/sports response |
2. API Test Results — Resolved Questions
Tested 2026-02-08 against
api.ps3838.comwith accountPWF3800HK1. 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 sportId | Sport Name | Hannibal sportId | eventCount | Notes |
|---|---|---|---|---|
| 29 | Soccer | 10 | 1430 | Primary target. 446 leagues, 3913 fixtures |
| 4 | Basketball | 1 | 298 | 9 leagues, 205 fixtures |
| 33 | Tennis | 6 | 200 | 48 leagues, 1042 fixtures |
| 15 | Football (US) | 3 | 1 | Only specials (281 eventSpecials). Low priority |
| 3 | Baseball | 23 | 0 | Has offerings but 0 events currently |
| 22 | MMA | 12 | 0 | No 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)
| Finding | Value |
|---|---|
| Rate limit | ~4 req/s (Cloudflare-enforced) |
| Error code | 1015 (Cloudflare) |
| HTTP status | 429 Too Many Requests |
| Reset time | ~3-5 seconds (empirical) |
| Scope | Global 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):
| Endpoint | Interval (with since) | Interval (without since) | Notes |
|---|---|---|---|
/fixtures | 5s per sport | 60s per sport | since must NOT be 0 or 1 |
/odds | 5s per sport | 60s per sport | Same rule |
/fixtures/settled | 5s per sport | 60s per sport | |
/sports | N/A | 60s | Sport list rarely changes |
/leagues | N/A | 60s per sport | |
/inrunning | N/A | 2s | |
/bets | N/A | 2s | |
/betting-status | N/A | 1s | |
/line | On demand only | N/A | Must NOT loop — pre-bet check only |
Critical fair use rules:
- Must NOT call
/oddsor/fixturesin a loop per league/fixture — batch allleagueIds/eventIdsin one call - Must NOT call
/linein 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):
| # | description | shortDescription |
|---|---|---|
| 0 | Match | Match |
| 1 | 1st Half | 1st H |
| 2 | 2nd Half | 2nd H |
| 3 | Extra Time | ET |
| 4 | Extra Time 1st Half | ET 1H |
| 5 | Extra Time 2nd Half | ET 2H |
| 6 | Penalty Shootout | Shootout |
| 8 | To Qualify | Qualify |
Basketball (4):
| # | description | shortDescription |
|---|---|---|
| 0 | Game | Game |
| 1 | 1st Half | 1st H |
| 2 | 2nd Half | 2nd H |
| 3 | 1st Quarter | 1st Q |
| 4 | 2nd Quarter | 2nd Q |
| 5 | 3rd Quarter | 3rd Q |
| 6 | 4th Quarter | 4th 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:
| Field | Present? | Notes |
|---|---|---|
homeScore, awayScore | Yes (event level) | Live scores in odds response |
homeRedCards, awayRedCards | Yes (event level) | Red cards in odds response |
spreads[0] (no altLineId) | Primary line | Primary spread line |
spreads[1+] (with altLineId) | Alt lines | Alt lines have altLineId + max |
moneyline.draw | Present for soccer | Absent for basketball |
teamTotal | Present | { home: [...], away: [...] } |
status: 1 | Period online | status: 2 = offline (cutoff passed) |
*UpdatedAt timestamps | Per market type | spreadUpdatedAt, 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 }
| state | Meaning |
|---|---|
| 1 | 1st Half |
| 2 | 2nd Half |
| 9 | Half-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:
homeTeamType | Home team = | Away team = |
|---|---|---|
"Team1" | TEAM1 | TEAM2 |
"Team2" | TEAM2 | TEAM1 |
Examples from docs:
homeTeamType | Selected Odds | team Param |
|---|---|---|
"Team1" | Spread → away | Team2 |
| Any | Moneyline → draw | Draw |
"Team2" | Team Total → away | Team1 |
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?"
| Finding | Value | Impact |
|---|---|---|
| Live delay | ~6 seconds | First GET /v3/bets call should be 6s after placing |
uniqueRequestId expiry | 30 minutes | After 30 min, stops returning results for that ID. Cache cleanup |
| RUNNING betlist | Excludes PENDING_ACCEPTANCE and NOT_ACCEPTED | Must query by uniqueRequestIds for pending live bets |
betId assignment | Only on ACCEPTED | PENDING_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:
- Snapshot returns period 0 (moneyline + spreads) and period 1 (moneyline + spreads)
- Delta returns only period 1 (moneyline + totals)
- 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/).
| # | File | Purpose | Est. Lines |
|---|---|---|---|
| 1 | backend/src/exchanges/adapters/pinnacle/types.ts | Pinnacle API request/response TypeScript types | ~300 |
| 2 | backend/src/exchanges/adapters/pinnacle/PinnacleClient.ts | HTTP client — Basic Auth, Lines API + Bets API | ~350 |
| 3 | backend/src/exchanges/adapters/pinnacle/mappers/PinnacleMapper.ts | Pinnacle → Canonical data transformation | ~400 |
| 4 | backend/src/exchanges/adapters/pinnacle/PinnacleCache.ts | In-memory cache — since cursors, fixtures, odds, leagueIds | ~150 |
| 5 | backend/src/exchanges/adapters/pinnacle/PinnacleAdapter.ts | Main adapter — implements IExchangeAdapter | ~500 |
| 6 | backend/src/jobs/pinnacleSettlementJob.ts | Settlement 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
| # | File | Change | Risk |
|---|---|---|---|
| 1 | backend/src/exchanges/index.ts | Register PinnacleAdapter, add sport routing configs, conditional settlement jobs | Low — additive only |
| 2 | backend/src/config/index.ts | Add PINNACLE_* env vars (6 total: ENABLED, USERNAME, PASSWORD, BASE_URL, CREDIT_LIMIT, MARGIN_PERCENT) | Low — new fields only |
| 3 | backend/src/services/orderService.ts | Dynamic bookmaker from PAL routing, lay bet validation for Pinnacle, Redis distributed lock for exposure guard, transaction metadata with provider | Medium — core bet placement path |
| 4 | backend/src/services/redis.ts | acquireRedisLock() + releaseRedisLock() helpers (SET NX EX + Lua CAS) | Low — new functions |
| 5 | backend/src/services/providerExposureService.ts (new) | Exposure guard: 80% warn / 90% hard-block, Redis-cached (30s TTL) | Low — new service |
| 6 | backend/src/exchanges/core/models/canonical.ts | No changes needed — pinnacle?: string already exists in ExternalIds | None |
| 7 | backend/src/exchanges/core/models/capability.ts | No structural changes — routing config updated at runtime in index.ts | None |
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)
| Type | Source | Used 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 event | See confirmed shape in §2h |
PinnacleFixturesResponse | /v3/fixtures wrapper | { sportId: number; last: number; league: PinnacleFixtureLeague[] } |
PinnacleFixtureLeague | Nested in fixtures | { id: number; name?: string; events: PinnacleFixture[] } |
PinnacleOddsResponse | /v4/odds wrapper | { sportId: number; last: number; leagues: PinnacleOddsLeague[] } |
PinnacleOddsLeague | Nested in odds | { id: number; events: PinnacleOddsEvent[] } |
PinnacleOddsEvent | Nested in odds | { id: number; awayScore?: number; homeScore?: number; awayRedCards?: number; homeRedCards?: number; periods: PinnacleOddsPeriod[] } |
PinnacleOddsPeriod | Nested in odds | See full confirmed shape below |
PinnacleMoneyline | Nested in period | { home: number; away: number; draw?: number } |
PinnacleSpread | Nested in period | { altLineId?: number; hdp: number; home: number; away: number; max?: number } |
PinnacleTotal | Nested in period | { altLineId?: number; points: number; over: number; under: number; max?: number } |
PinnacleTeamTotals | Nested in period | { home: PinnacleTotal[]; away: PinnacleTotal[] } |
PinnacleLineResponse | /v2/line | See confirmed shape in §2f |
PinnaclePlaceBetRequest | /v2/bets/place body | See §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 item | See research doc §16 |
PinnacleSettledFixture | /v3/fixtures/settled event | { id: number; periods: PinnacleSettledPeriod[] } |
PinnacleSettledPeriod | Nested 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 }) { ... }
| Method | Signature | Pinnacle Endpoint | Returns |
|---|---|---|---|
getSports() | (): Promise<PinnacleSport[]> | GET /v3/sports | Sport list |
getLeagues(sportId) | (sportId: number): Promise<PinnacleLeague[]> | GET /v3/leagues?sportId=X | League list |
getPeriods(sportId) | (sportId: number): Promise<PinnaclePeriodDefinition[]> | GET /v1/periods?sportId=X | Period defs |
getFixtures(sportId, opts?) | (sportId: number, opts?: { since?: number; isLive?: boolean; eventIds?: number[] }): Promise<PinnacleFixturesResponse> | GET /v3/fixtures?sportId=X | Fixtures w/ cursor |
getOdds(sportId, opts?) | (sportId: number, opts?: { since?: number; isLive?: boolean; eventIds?: number[]; oddsFormat?: string }): Promise<PinnacleOddsResponse> | GET /v4/odds?sportId=X&oddsFormat=Decimal | Odds 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/line | Fresh line |
placeBet(request) | (request: PinnaclePlaceBetRequest): Promise<PinnaclePlaceBetResponse> | POST /v2/bets/place | Bet result |
getBets(params) | (params: { betlist?: string; fromDate: string; toDate: string; betIds?: number[]; uniqueRequestIds?: string[] }): Promise<PinnacleStraightBet[]> | GET /v3/bets | Bet list |
getSettledFixtures(sportId, opts?) | (sportId: number, opts?: { since?: number }): Promise<PinnacleSettledFixturesResponse> | GET /v3/fixtures/settled?sportId=X | Settled fixtures |
getInRunning() | (): Promise<PinnacleInRunningResponse> | GET /v2/inrunning | Live game states |
getBettingStatus() | (): Promise<{ status: string }> | GET /v1/bets/betting-status | System status |
Client internals:
| Concern | Implementation | Reference |
|---|---|---|
| Auth | HTTP Basic — Authorization: Basic <Base64(username:password)> on every request | Unlike Betfair which needs session login/refresh |
| Base URL | https://api.ps3838.com (configurable) | |
| HTTP lib | Axios instance with defaults | BetfairClient uses raw https — Pinnacle is simpler, no SSL cert auth |
| Rate limiting | Token bucket: 3 req/s, burst 4 | Confirmed 429 at ~4 req/s |
| Retry | 1 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 better | Betfair REST client does not use exponential backoff — single retry with 3s delay |
| Error handling | Parse 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 |
| Logging | Debug: all requests/responses. Error: failures. | Same pattern as BetfairClient |
| Request timeout | 15s 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
| Function | Signature | Input → Output | Key Logic |
|---|---|---|---|
mapFixtureToCanonical | (fixture: PinnacleFixture, leagueId: number, leagueName: string, pinnacleSportId: number): CanonicalFixture | null | Pinnacle 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 array | Iterate periods → explode each into markets |
mapPeriodToMarkets | (period: PinnacleOddsPeriod, eventId: number, pinnacleSportId: number): CanonicalMarket[] | Single period → multiple markets | Create moneyline + spread + total + teamTotal markets |
mapMoneylineToMarket | (ml: PinnacleMoneyline, period: PinnacleOddsPeriod, eventId: number, pinnacleSportId: number): CanonicalMarket | Moneyline → market | 2 outcomes (basketball/tennis) or 3 (soccer has draw) |
mapSpreadToMarket | (spread: PinnacleSpread, period: PinnacleOddsPeriod, eventId: number, pinnacleSportId: number): CanonicalMarket | Spread → market | 2 outcomes: home + away with handicap |
mapTotalToMarket | (total: PinnacleTotal, period: PinnacleOddsPeriod, eventId: number, pinnacleSportId: number): CanonicalMarket | Total → market | 2 outcomes: over + under with points |
mapTeamTotalToMarkets | (teamTotal: PinnacleTeamTotals, period: PinnacleOddsPeriod, eventId: number, pinnacleSportId: number): CanonicalMarket[] | Team totals → 2 markets | Home TT + Away TT |
mapBetToCanonicalOrder | (bet: PinnacleStraightBet): CanonicalOrder | Bet → order | Map betStatus2 → OrderStatus + SettlementResult |
mapFixtureStatus | (liveStatus: number, status: PinnacleFixtureStatus): FixtureStatus | Two fields → status | See table below |
getPinnacleSportId | (hannibalSportId: number): number | undefined | Forward map | |
getHannibalSportId | (pinnacleSportId: number): number | undefined | Reverse map | |
parseMarketId | (marketId: string): ParsedMarketId | "pin_123_p0_ml" → struct | Extract eventId, period, betType, handicap |
parseOutcomeId | (outcomeId: string): ParsedOutcomeId | "pin_123_p0_ml_home" → struct | Extract eventId, period, betType, team/side |
mapSelectionToTeam | (selection: 'home' | 'away' | 'draw', homeTeamType: string): PinnacleTeam | Maps canonical selection → API team param using homeTeamType from leagues | See homeTeamType mapping table in §2m |
calculateMaxRisk | (maxVolume: number, price: number): number | Converts /odds max volume → max risk stake | price > 2 ? maxVolume : maxVolume / (price - 1) — see §2o |
Fixture Status Mapping (confirmed from live data)
liveStatus | status | → CanonicalFixture.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 Type | ID Pattern | Example (confirmed eventId format) |
|---|---|---|
| Moneyline | pin_{eventId}_p{period}_ml | pin_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 Home | pin_{eventId}_p{period}_tt_home_{points} | pin_1623781857_p0_tt_home_0.5 |
| Team Total Away | pin_{eventId}_p{period}_tt_away_{points} | pin_1623781857_p0_tt_away_0.5 |
Outcome ID Generation
| Market Type | Outcome | ID Pattern |
|---|---|---|
| Moneyline | Home | pin_{eventId}_p{period}_ml_home |
| Moneyline | Away | pin_{eventId}_p{period}_ml_away |
| Moneyline | Draw | pin_{eventId}_p{period}_ml_draw |
| Spread | Home | pin_{eventId}_p{period}_spread_{hdp}_home |
| Spread | Away | pin_{eventId}_p{period}_spread_{hdp}_away |
| Total | Over | pin_{eventId}_p{period}_ou_{points}_over |
| Total | Under | pin_{eventId}_p{period}_ou_{points}_under |
| TT Home | Over | pin_{eventId}_p{period}_tt_home_{points}_over |
| TT Home | Under | pin_{eventId}_p{period}_tt_home_{points}_under |
| TT Away | Over | pin_{eventId}_p{period}_tt_away_{points}_over |
| TT Away | Under | pin_{eventId}_p{period}_tt_away_{points}_under |
Price Mapping
| Pinnacle Field | → Canonical Field | Notes |
|---|---|---|
| Price value (decimal) | back | All Pinnacle prices are back-only (bookmaker model) |
| N/A | lay | Always undefined — no lay prices |
| N/A | backDepth, layDepth | Always undefined — single price, no depth |
calculateMaxRisk(maxMoneyline, price) / calculateMaxRisk(maxSpread, price) / calculateMaxRisk(maxTotal, price) | limit | Converted 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) | limitMin | Only populated via getBetslip. Confirmed: 20 USD |
spreadUpdatedAt / moneylineUpdatedAt / totalUpdatedAt | changedAt | Use 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 Key | Value | TTL | Purpose |
|---|---|---|---|
fixtures:since:{sportId} | last cursor (number) | No TTL | Track incremental fixture updates |
odds:since:{sportId} | last cursor (number) | No TTL | Track incremental odds updates |
settlement:since:{sportId} | last cursor (number) | No TTL | Track settlement polling |
fixtures:{sportId} | Map<eventId, PinnacleFixture> | 5 min | Fixture cache |
odds:{sportId}:{eventId} | PinnacleOddsPeriod[] | 30 sec | Odds cache (short TTL — odds move fast) |
leagueMap:{sportId} | Map<eventId, { leagueId: number; homeTeamType: string }> | 5 min | Event → leagueId + homeTeamType mapping (both required for bet placement) |
periods:{sportId} | PinnaclePeriodDefinition[] | 24 hr | Period definitions (rarely change) |
| Method | Signature | Description |
|---|---|---|
getFixtureSinceCursor(sportId) | (sportId: number): number | undefined | Get last fixtures cursor |
setFixtureSinceCursor(sportId, cursor) | (sportId: number, cursor: number): void | Update fixtures cursor |
getOddsSinceCursor(sportId) | (sportId: number): number | undefined | Get last odds cursor |
setOddsSinceCursor(sportId, cursor) | (sportId: number, cursor: number): void | Update odds cursor |
getCachedFixtures(sportId) | (sportId: number): Map<number, PinnacleFixture> | undefined | Get cached fixtures map |
updateFixturesWithLeagueInfo(sportId, leagues) | (sportId: number, leagues: PinnacleFixtureLeague[]): void | Merge 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[] | undefined | Get cached odds periods |
updateOdds(sportId, leagues) | (sportId: number, leagues: PinnacleOddsLeague[]): void | Merge new/updated odds |
getLeagueId(sportId, eventId) | (sportId: number, eventId: number): number | undefined | Get leagueId for event |
getHomeTeamType(sportId, eventId) | (sportId: number, eventId: number): string | undefined | Get homeTeamType for event's league (from leagues response). Returns undefined if not cached — callers default to 'Team1' with logger.warn() |
getLeagueHomeTeamType(leagueId) | (leagueId: number): string | Get 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}>): void | Store league-level homeTeamType. Called during initialize() with data from GET /v3/leagues. |
getPeriodDefinitions(sportId) | (sportId: number): PinnaclePeriodDefinition[] | undefined | Get period defs |
setPeriodDefinitions(sportId, periods) | (sportId: number, periods: PinnaclePeriodDefinition[]): void | Cache period defs |
invalidate(sportId) | (sportId: number): void | Clear all caches for a sport |
clear() | (): void | Clear 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 Method | Signature | Implementation | Pinnacle 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=true4. Call client.getFixtures(sportId, { since, isLive })5. cache.updateFixtures(sportId, response.league) — stores fixtures + leagueId mapping6. 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 eventId2. 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 markets7. Return CanonicalMarket[] | GET /v4/odds?sportId=X&eventIds=Y&oddsFormat=Decimal |
getOdds(params) | (params: GetOddsParams): Promise<GetOddsResult> | Delegates to getMarkets() — same endpoint provides both | Same 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): boolean | Return getPinnacleSportId(sportId) !== undefined | None |
Adapter Lifecycle
| Method | Implementation | Pinnacle Calls |
|---|---|---|
initialize() | 1. Validate credentials exist 2. Create PinnacleClient instance3. Call client.getSports() → verify auth + connectivity4. 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 = false | None |
isReady() | Return ready && client !== null | None |
getCapabilities() | Return features (see below) | None |
healthCheck() | Call client.getBettingStatus() → measure latency → return status | GET /v1/bets/betting-status |
getHealthStatus() | Return lastHealthCheck | None |
supportsStreaming() | Return false | None |
getStreamProvider() | Return null | None |
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 Method | Signature | Implementation | Pinnacle API |
|---|---|---|---|
placeOrder(params) | (params: PlaceOrderParams): Promise<PlaceOrderResult> | See detailed flow below | GET /v2/line → POST /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 each | GET /v3/bets?betlist=X&fromDate=Y&toDate=Z |
getBetslip(params) | (params: BetslipParams): Promise<BetslipResult> | See detailed flow below | GET /v2/line |
supportsOrdersForSport(sportId) | (sportId: number): boolean | Same 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 Pattern | Extracted Fields |
|---|---|
pin_1623781857_p0_ml_home | eventId=1623781857, period=0, betType=MONEYLINE, team=TEAM1 |
pin_1623781857_p0_ml_draw | eventId=1623781857, period=0, betType=MONEYLINE, team=DRAW |
pin_1623781857_p0_spread_-0.25_home | eventId=1623781857, period=0, betType=SPREAD, handicap=-0.25, team=TEAM1 |
pin_1623781857_p0_ou_1.0_over | eventId=1623781857, period=0, betType=TOTAL_POINTS, handicap=1.0, side=OVER |
pin_1623781857_p0_tt_home_0.5_over | eventId=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.
| Job | Function Name | Interval | Purpose |
|---|---|---|---|
| Settlement | checkPinnacleSettledOrders() | 60s | Poll settled fixtures → settle matching orders |
| Pending | resolvePendingPinnacleOrders() | 15s | Poll PENDING_ACCEPTANCE resolution |
| Sync | syncPinnacleRunningOrders() | 30s | Sync 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/settledper-sport withsincecursor. Actual implementation queries per-order viagetOrder()(which uses/v3/bets?betIds=X). This is simpler and avoids the complexity of matching settled fixtures to orders by eventId. Thesincecursor for settlements is still available inPinnacleCachebut 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 bybetIdsviagetOrder()sincebetsApiOrderIdis 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 value | Meaning | Action |
|---|---|---|
| 1 | Settled | Normal settlement |
| 2 | Re-settled | Reverse previous settlement, apply new with new settlementId |
| 3 | Cancelled | Void the bet |
| 4 | Re-settled as cancelled | Reverse previous, then void |
| 5 | Deleted | Treat 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 SportId | Sport | Primary Provider | Fallback(s) |
|---|---|---|---|
| 27 | Cricket | betfair | [] — no Pinnacle fallback |
| 10 | Soccer | pinnacle | ['betfair'] |
| 1 | Basketball | pinnacle | ['betfair'] |
| 6 | Tennis | pinnacle | ['betfair'] |
| 3-25 (others) | Various | betfair | [] |
Phase 8 — Config & Environment
New Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
PINNACLE_ENABLED | No | false | Feature flag |
PINNACLE_USERNAME | Yes (if enabled) | — | PS3838 API username |
PINNACLE_PASSWORD | Yes (if enabled) | — | PS3838 API password |
PINNACLE_BASE_URL | No | https://api.ps3838.com | API base URL |
PINNACLE_CREDIT_LIMIT | No | 1000 | Platform credit limit in USD |
PINNACLE_MARGIN_PERCENT | No | 0 | Hannibal 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 Method | Pinnacle API Call(s) | Notes |
|---|---|---|
getFixtures(params) | GET /v3/fixtures?sportId=X&since=Y | Use 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=Decimal | Explode periods → multiple markets |
getOdds(params) | Same as getMarkets | Same endpoint |
getFixture(id) | GET /v3/fixtures?sportId=X&eventIds=Y | Single fixture filter |
placeOrder(params) | 1. GET /v2/line → 2. POST /v2/bets/place | Two-step. leagueId required for step 1 |
cancelOrder(params) | N/A | Return failure — unsupported |
getOrder(params) | GET /v3/bets?betIds=X&fromDate=Y&toDate=Z | fromDate/toDate required |
getOrders(params) | GET /v3/bets?betlist=X&fromDate=Y&toDate=Z | fromDate/toDate required |
getBetslip(params) | GET /v2/line | leagueId required. Returns fresh price + limits + lineId |
healthCheck() | GET /v1/bets/betting-status | Lightweight, returns ALL_BETTING_ENABLED |
initialize() | GET /v3/sports + 3× GET /v1/periods + 3× GET /v3/leagues | 7 API calls total. Leagues needed for homeTeamType cache |
| Settlement polling | GET /v3/bets?betIds=X per-order | Background 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.
| Step | What to Build | Verification |
|---|---|---|
| 1 | types.ts — PinnacleFixture, PinnacleFixturesResponse, PinnacleSport, PinnaclePeriodDefinition | Compiles |
| 2 | PinnacleClient.ts — getSports(), getPeriods(), getFixtures() + auth + rate limiter | Call API, log raw JSON. Verify 1430 soccer events returned |
| 3 | PinnacleMapper.ts — mapFixtureToCanonical(), sport maps, mapFixtureStatus() | Transform one fixture → verify CanonicalFixture fields match |
| 4 | PinnacleCache.ts — fixture cache + since cursor + leagueId map | Cache stores fixtures, second call uses since and returns fewer events |
| 5 | PinnacleAdapter.ts — getFixtures(), initialize(), supportsSport(), getCapabilities() | Adapter works standalone |
| 6 | config/index.ts — add env vars | Config loads cleanly with PINNACLE_ENABLED=true |
| 7 | exchanges/index.ts — register adapter + routing | exchangeCoordinator.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 jobsPinnacleMapper.ts:mapBetToCanonicalOrder()
Verify: Settlement job detects recently settled fixtures and processes them.
Testing Strategy
| Test Type | What | How |
|---|---|---|
| Unit — Mapper | PinnacleMapper functions (mapFixtureToCanonical, mapPeriodToMarkets, parseOutcomeId, etc.) | Vitest — use real API response snapshots from §2 as input → verify canonical output |
| Unit — Client | PinnacleClient methods + rate limiter + retry logic | Vitest — mock Axios responses including 429, retryable errors |
| Unit — Adapter | PinnacleAdapter methods | Vitest — mock client + mapper |
| Unit — Cache | PinnacleCache TTL, since cursor, leagueId map | Vitest |
| Integration — API | Real API calls against ps3838.com | Manual — verify auth, fixture fetch (1430 events), odds, line validation |
| Integration — PAL | Full routing flow | Manual — exchangeCoordinator.getFixtures({ sportId: 10 }) routes to Pinnacle |
| Integration — Settlement | Settlement job e2e | Manual — wait for settled fixtures → verify detection |
Unresolved Questions
| # | Question | Impact | Status |
|---|---|---|---|
| 1 | RESOLVED — Soccer=29, Basketball=4, Tennis=33 | ||
| 2 | RESOLVED — No. Cricket not available | ||
| 3 | RESOLVED — ~4 req/s, Cloudflare, error 1015 | ||
| 4 | RESOLVED — USD | ||
| 5 | Default fillType | Bet behavior | DECISION: Use NORMAL — reject if over limit |
| 6 | acceptBetterLine — always true? | Bet behavior | DECISION: true — always accept favorable moves |
| 7 | PENDING_ACCEPTANCE timeout | Order mgmt | DECISION: 30 min timeout → mark declined. Use full uniqueRequestId window (Pinnacle stops returning results after 30 min) |
| 8 | Alt lines — include in MVP? | Markets | DECISION: No — primary lines only |
| 9 | Team totals — include in MVP? | Markets | DECISION: Yes — confirmed data available, simple to add since mapper already handles totals |
| 10 | since | Freshness | RESOLVED — since reduces 3913→12 events. Start 10s live, 60s pre-match |
| 11 | NEW: 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 |
| 12 | NEW: How does parentId affect betting? | Live events | Live 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 |
| 13 | homeTeamType for team mapping | Bet placement | RESOLVED — homeTeamType from GET /v3/leagues determines TEAM1/TEAM2 mapping. Must cache per league. See §2m |
| 14 | Pending orders | RESOLVED — ~6s live delay. First getBets call 6s after placing. uniqueRequestId results expire after 30 min. See §2n | |
| 15 | Stake limits | RESOLVED — /odds returns max volume, not max risk. Formula: price > 2 ? maxVolume : maxVolume/(price-1). See §2o | |
| 16 | Cache merge | RESOLVED — Delta returns ALL markets for changed periods (not just changed markets). Missing market = no longer offered. Replace entire period in cache. See §2p | |
| 17 | Credit monitoring | RESOLVED — GET /v1/client/balance exists. Returns availableBalance, outstandingTransactions, givenCredit, currency. Use for real-time credit monitoring every 60s. | |
| 18 | Settlement P&L | RESOLVED — winLoss is number (IEEE 754 double). No fixed decimal places. Round to 2 decimal places when ingesting to match our Decimal(18,2). | |
| 19 | Rate limiting | RESOLVED — 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. |