# unifair-betfair

A Node 18+ proxy that fronts the **uni247** quote / order / streaming APIs and
re-exposes them in the shape of the **Betfair Exchange Sports/Scores/Streaming
APIs**, plus a settlement engine that auto-resolves cricket markets from live
score data.

The goal is "Betfair drop-in" — request bodies, response shapes, JSON-RPC
envelope, and the streaming protocol all match Betfair's published spec
closely enough that existing Betfair-stream client libraries can talk to this
proxy without code changes.

---

## Quick start

```bash
cp .env.example .env       # paste one or more uni247 JWTs (comma-separated)
npm install
npm start                  # default port 8787
```

```bash
curl localhost:8787/healthz
# { "ok":true, "tokens":1, "time":"..." }
```

Everything else is described below.

---

## Authentication

uni247's JWT is held server-side; clients of *this proxy* don't need to send
auth in normal use. The Betfair-style `authentication` op on the streaming
socket is accepted as a no-op for compatibility with off-the-shelf Betfair
clients.

```
.env
  UNI247_TOKENS=<jwt1>,<jwt2>,...     # round-robined per outbound HTTP call
  UNI247_HTTP_BASE=https://api.uni247.xyz
  UNI247_QUOTE_BASE=https://quote-cdn.uni247.xyz
  UNI247_WS_BASE=wss://cdnws.uni247.xyz
  PORT=8787
  LOG_LEVEL=info                       # debug for verbose engine traces
  DEFAULT_PLAYER_ID=hb32be7khel58demo
  DEFAULT_LANGUAGE=en
  DEFAULT_TIMEZONE=5
```

---

## Betting API (Sports API-NG)

REST endpoints under `/api/betting/`. Equivalent JSON-RPC envelope at
`POST /exchange/betting/json-rpc/v1` accepts `SportsAPING/v1.0/<method>`
method names.

### `listEventTypes`
```http
POST /api/betting/listEventTypes
Body: { "filter": { "eventTypeIds"?: ["..."], "textQuery"?: ["..."] } }
```
Returns: `[{ "eventType": { "id":"4","name":"Cricket" }, "marketCount":34 }, …]`

### `listCompetitions`
```http
POST /api/betting/listCompetitions
Body: { "filter": { "eventTypeIds"?:[], "competitionIds"?:[] } }
```
Returns: `[{ "competition":{"id":"sr:tournament:2472","name":"Indian Premier League"}, "marketCount":3, "competitionRegion":"EVT_4" }, …]`

### `listEvents`
```http
POST /api/betting/listEvents
Body: { "filter": { "eventTypeIds"?:[], "competitionIds"?:[], "eventIds"?:[], "maxResults"?:30 } }
```
Returns:
```json
[{
  "event":{"id":"70976000","name":"Wellington v Central","openDate":"...","timezone":"UTC","countryCode":""},
  "marketCount":3
}]
```

### `listMarketCatalogue`
```http
POST /api/betting/listMarketCatalogue
Body: { "filter": { "eventIds": ["70976000"], "marketIds"?: ["..."] } }
```
Returns full Betfair-style catalogue per market — `marketId, marketName, marketStartTime, totalMatched, runners[], eventType, competition, event, description{ marketType, bettingType, turnInPlayEnabled, marketTime, persistenceEnabled, bspMarket, ... }`.

### `listMarketBook`
```http
POST /api/betting/listMarketBook
Body: { "eventId": "70976000", "marketIds": ["340_186737862"] }
```
Returns Betfair MarketBook with full ladder per runner:
```json
[{
  "marketId":"340_186737862",
  "isMarketDataDelayed":false,"status":"OPEN","betDelay":0,
  "complete":true,"inplay":true,
  "numberOfWinners":1,"numberOfRunners":2,"numberOfActiveRunners":2,
  "totalMatched":0,"totalAvailable":0,"crossMatching":true,
  "runners":[{
    "selectionId":4,"handicap":0.0,"status":"ACTIVE|WINNER|LOSER",
    "lastPriceTraded":2.74,"totalMatched":0,
    "ex":{
      "availableToBack":[{"price":2.72,"size":1158045}],
      "availableToLay":[{"price":3.30,"size":10197000}],
      "tradedVolume":[]
    }
  }],
  "settlement":{ "source":"score|manual", "settledAt":"...", "winnerSelectionId":4 }   // present iff resolved
}]
```

### `listRunnerBook`
```http
POST /api/betting/listRunnerBook
Body: { "eventId": "70976000", "marketId": "340_186737862", "selectionId": 4 }
```
Same shape as `listMarketBook` filtered to one runner.

### `listMatchedOrders`
```http
POST /api/betting/listMatchedOrders
Body: { "eventId": "70423590", "marketId": "11_185866407" }
```
Returns full traded-volume ladder per runner — equivalent to a Betfair `MarketBook` with `EX_TRADED ALL_LADDER` price projection.

### `listMarketResult` *(custom — settlement extension)*
```http
POST /api/betting/listMarketResult
Body: { "eventId": "70976000" }
```
Returns:
```json
{
  "eventId":"70976000",
  "finalScore":{ "home":{...}, "away":{...}, "status":"ended", "matchStatus":"100" },
  "marketCount":10,
  "markets":[{ "marketId":"...","marketName":"...","marketType":"FANCY|MATCH_ODDS|...",
               "marketStatus":"CLOSED","winnerSelectionId":4,
               "runners":[{"selectionId":4,"status":"WINNER"}, {"selectionId":5,"status":"LOSER"}],
               "source":"score|manual|price-heuristic","settledAt":"..." }]
}
```

### `listClearedOrders` *(read-only projection from settlement store)*
```http
POST /api/betting/listClearedOrders
Body: { "eventIds": ["70423590","70976000"] }
```
Returns Betfair-style:
```json
{
  "clearedOrders":[
    {"eventId":"70423590","marketId":"11_185866407","selectionId":5,"betOutcome":"WON","settledDate":"...","source":"manual","marketType":"MATCH_ODDS"}
  ],
  "moreAvailable":false
}
```

### `findResults` *(custom — discovery walker)*
```http
POST /api/betting/findResults
Body: { "eventTypeIds"?:["4"], "statusFilter"?:["ended"], "maxConcurrency"?:8 }
```
Walks the burger menu, queries each event's score, returns those whose status matches the filter (default `["ended"]`). Use this to enumerate finished matches because uni247 has no native results-list endpoint.

---

## Scores API

```http
POST /api/scores/listScores
Body: { "eventIds": ["70976000"], "mstatus"?: "inplay"|"ended" }
```
Returns Betfair Scores-API style:
```json
[{
  "eventId":"70976000","eventTypeId":"21",
  "eventStatus":"IN_PLAY|PRE_EVENT|FINISHED",
  "responseCode":"OK","updateContext":"SCORE",
  "values":{
    "tournament":"Super Smash SRL",
    "home":{"name":"...","abbr":"WEL","score":"60/4"},
    "away":{"name":"...","abbr":"CEN","score":"161/5"},
    "currentInnings":"2","over":"11","delivery":"0",
    "matchStatus":"First innings, home team",
    "matchStatusCode":"501","scheduled":"...","updateTime":"..."
  }
}]
```

---

## Streaming API — `ws://localhost:8787/api/stream`

Protocol mirrors **Betfair Exchange Stream API** with `op` typed messages,
`clk` change tokens, `ct` change types, and `img` flags.

### Client → Server messages

```json
{ "op":"authentication", "appKey":"<any>", "session":"<any>" }

{ "op":"marketSubscription", "id":1,
   "marketFilter":   { "eventIds":["70976000"], "marketIds"?:["..."] },
   "marketDataFilter": { "fields":["EX_BEST_OFFERS","EX_LTP","EX_TRADED_VOL"], "ladderLevels":3 },
   "channels": ["ex_c","bookmaker","fancy","c_v2","sportsbook"] }

{ "op":"globalSubscription", "id":2, "channels":["sportsbook"] }

{ "op":"orderSubscription", "id":3 }     // accepted; emits nothing — read-only proxy

{ "op":"heartbeat" }
{ "op":"unsubscribe", "id":1 }
```

### Server → Client messages

| op | When | Shape |
|---|---|---|
| `connection` | once on connect | `{op,connectionId}` |
| `status` | response to client op | `{op,id,statusCode:"SUCCESS"\|"FAILURE",connectionClosed,connectionsAvailable,errorCode?,errorMessage?}` |
| `heartbeat` | every 5s | `{op,pt,ct:"HEARTBEAT",clk}` |
| `mcm` | market change (ladder/odds) | `{op,id,clk,pt,ct?,mc:[…]}` |
| `fcm` | fancy change (bl/ll line) | `{op,id,clk,pt,fmc:[…]}` |

`mcm.mc[]` (Market Change) entry:
```json
{
  "id":"340_186737862",
  "img": true,                // true on first frame for this market within this subscription
  "con": false,               // conflate flag (always false here)
  "rc":[                       // RunnerChange[]
    { "id":4, "ltp":2.74,
      "batb":[[0,2.74,375847],[1,2.72,1158045],[2,2.70,2696200]],
      "batl":[[0,3.25,3412500],[1,3.30,10197000]] }
  ],
  "marketDefinition": {       // present on first (img=true) frame for that market
    "status":"OPEN", "eventId":"70976000",
    "betDelay":0,"bspMarket":false,"persistenceEnabled":false,
    "turnInPlayEnabled":true, "marketBaseRate":0,
    "bp":"105.47%","lp":"94.46%","mark":""
  }
}
```

`fcm.fmc[]` (Fancy Change) entry:
```json
{
  "id":"356_186910950_f","eventId":"70976000",
  "status":"OPEN", "name":"1st inn 7 over WEL run(WEL vs CEN)",
  "runRange":{"lay":7,"back":8},     // ll, bl thresholds
  "odds":{"lay":120,"back":135},     // lo, bo (×100, divide by 100 for decimal odds)
  "ts":"2026-04-28T21:13:01.385486375Z",
  "label":["Totals","Total","All Markets","Over By Over"]
}
```

`ct` (change type) on `mcm`/`heartbeat`:
- `SUB_IMAGE` — first batch in a new subscription
- `RESUBSCRIBE_DELTA` — resume after disconnect (reserved; not currently emitted)
- `HEARTBEAT` — keepalive

`clk` is a per-connection monotonic counter (zero-padded 8-digit string), useful for client-side resume bookkeeping.

### Channel taxonomy

| client `channel` | upstream uni247 socket                        | output `mcm.source` / `fcm` |
|------------------|-----------------------------------------------|-----------------------------|
| `ex_c`           | `wss://…/api/quote/vsb/vbt/ex-c?event_id=…`   | `mcm` / `exchange`          |
| `bookmaker`      | `wss://…/api/quote/vsb/vbt/bookmaker?event_id=…` | `mcm` / `bookmaker`      |
| `fancy`          | `wss://…/api/quote/vsb/fancy?event_id=…`      | `fcm`                       |
| `c_v2`           | `wss://…/api/quote/c/v2?event_id=…`           | `mcm` / `catalogue`         |
| `sportsbook`     | `wss://…/api/quote/do-not-look-back?type=sp`  | `mcm` / `sportsbook` (filtered by eventIds) |

---

## Settlement engine

uni247 has no settlement endpoint, so this proxy includes a polling +
streaming engine that auto-resolves what the data supports. Track an event,
the engine consumes upstream live-score + `c/v2` push + `vsb/fancy` push,
populates `data/settlements.json`, and overlays the result onto every
subsequent `listMarketBook` response.

### Endpoints

```
POST /api/settlement/track       { eventId }      start polling
POST /api/settlement/untrack     { eventId }      stop polling
GET  /api/settlement/tracked                      list tracked events
POST /api/admin/markWinner       { marketId, eventId, selectionId, runnerStatuses?, note? }
POST /api/admin/voidMarket       { marketId, eventId, runnerSelectionIds:[…], note? }
```

### What auto-resolves

| Market type / fancy subject       | Auto? | Trigger event       |
|------------------------------------|-------|---------------------|
| `MATCH_ODDS` (340_, 11_)           | ✅    | `matchEnded`        |
| `TIED_MATCH` (342_)                | ✅    | `matchEnded`        |
| `OVER_UNDER` per-over total (356_) | ✅    | `overClosed`        |
| `OVER_UNDER` range total (1229_)   | ✅    | `overClosed`/`inningsClosed` |
| `ODD_EVEN` per-over (359_)         | ✅    | `overClosed`        |
| `OVER_RUN_RANGE` (1223_)           | ✅    | `overClosed`        |
| `FALL_OF_WICKET` (875_, 876_)      | ✅    | `wicketFell`        |
| FANCY: tied / overTotal / oddEven / fallOfWicket / inningsTotal / overRangeTotal | ✅ | mapped to above triggers |
| Closed market (any)                | ✅    | price-heuristic (ltp < 1.5 / > 5) |
| `BALL_BY_BALL` (362_)              | ❌    | per-delivery not exposed |
| `PLAYER_TOTAL` (638_)              | ❌    | per-player not exposed |
| `PLAYER_MILESTONE` (662_)          | ❌    | per-player not exposed |
| `DISMISSAL_METHOD` (816_)          | ❌    | dismissal type not exposed |
| `INNINGS_FOURS` (659_)             | ❌    | boundary count not exposed |

For the ❌ rows, use `markWinner` for manual override.

### Robustness notes

- The engine ignores backward `innings` flicker (uni247 transient state).
- For events tracked mid-match, the first observed wicket / over close is used as a baseline — no spurious `wicketFell` for already-fallen wickets, no garbage `runsInOver` from missing prior-over totals.
- When `currentInnings ≥ 2` the engine probes `mstatus=ended` as a tiebreak — uni247's `inplay` endpoint can lazy-flip status long after the chase completes.
- Settlement records persist to `data/settlements.json` and reload on restart.

---

## ID mapping

- **`eventId`**: uni247's `70976000-e` → exposed as `70976000`. Re-added on the way back.
- **`marketId`**: passed through as-is (e.g. `340_186737862`); Betfair's spec says marketId is just a string.
- **`selectionId`**: numeric uni247 `product_id`s pass through (`"4"` → `4`); non-numeric (`sr:run_range:3-12+:1853`) are FNV-1a hashed into `1_000_000_000+`. A reverse registry in [src/idmap.js](src/idmap.js) supports lookup.

---

## Examples

### Read a market book
```bash
curl -s -X POST -H 'content-type: application/json' \
     localhost:8787/api/betting/listMarketBook \
     -d '{"eventId":"70976000","marketIds":["340_186737862"]}' | jq
```

### Subscribe to a streaming feed
```bash
wscat -c ws://localhost:8787/api/stream
> {"op":"authentication","appKey":"x","session":"x"}
> {"op":"marketSubscription","id":1,"marketFilter":{"eventIds":["70976000"]},"channels":["ex_c","fancy","c_v2"]}
```

### Settle a finished match by hand
```bash
curl -s -X POST -H 'content-type: application/json' \
     localhost:8787/api/admin/markWinner \
     -d '{"marketId":"11_185866407","eventId":"70423590","selectionId":5,
          "runnerStatuses":{"4":"LOSER","5":"WINNER"},
          "note":"final score 439>428"}'
```

### Find finished cricket events
```bash
curl -s -X POST -H 'content-type: application/json' \
     localhost:8787/api/betting/findResults \
     -d '{"eventTypeIds":["4"],"statusFilter":["ended"]}' | jq '.results[] | {eventId,winner}'
```

---

## Layout

```
unifair-betfair/
  server.js                       Express + WebSocketServer entrypoint
  package.json                    Express, undici, ws
  .env / .env.example
  data/settlements.json           Persistent settlement store
  src/
    config.js                     env + JWT decode + token pool
    log.js                        Level-aware logger
    upstream.js                   undici HTTP client + token round-robin
    idmap.js                      eventId / marketId / selectionId mapping
    streamHub.js                  Multiplexes upstream WS sockets
    jsonrpc.js                    Betfair JSON-RPC envelope dispatcher
    transform/
      sports.js                   listEventTypes/Competitions/Events/MarketCatalogue
      prices.js                   listMarketBook / RunnerBook / MatchedOrders / fancy book
      streaming.js                ws frame → mcm/fcm with marketDefinition
      score.js                    listScores
    settlement/
      store.js                    JSON-file persistent store
      scoreState.js               Per-event match state machine
      resolvers.js                Per-market-type resolution + fancy parser
      engine.js                   Polls + push subscriptions + cache + sweep
```
