# Consolidated Moneylines (Live Kalshi + User Context)

## Purpose & UX intent

The **Consolidated Moneylines** panel on the NBA Value Dashboard is intended to be a _game-level comparison surface_:

- Compare **Kalshi** prices/liquidity side-by-side (and later **Polymarket**).
- Quickly spot **deltas** (price dislocations, liquidity imbalance).
- Enable click-through to execution (open market detail / order ticket).
- Overlay **user context** (open orders + open positions + fills) so the table reflects _your actual exposure_.

This repository follows a **client-authoritative, transport-only** model:

- The browser holds Kalshi credentials and performs request signing.
- Galactus Relay (`apps/relay`) only forwards bytes and multiplexes WebSockets.
- All state modeling (positions, grouping, valuation) is done in the frontend (`apps/dashboard`).

## Current state (what exists today)

### The dashboard table is still mocked

`NbaValueDashboardView` renders `ConsolidatedMoneylines`, but feeds it `MOCK_GAMES` rather than live `NBAMarketRow[]`:

```22:220:apps/dashboard/src/components/pages/NbaValueDashboardView.tsx
// Mock data for development - will be replaced with real data from NBAMarketRow[]
const MOCK_GAMES: ConsolidatedGame[] = [
  // ...
];
// ...
const [games] = useState<ConsolidatedGame[]>(MOCK_GAMES);
// ...
<ConsolidatedMoneylines games={games} />
```

### Live Kalshi market streaming exists (per-ticker)

The app already has a live market stream (`createNbaStream`) which:

1. Fetches initial NBA markets via REST (discovery + transform).
2. Subscribes to WS for the discovered tickers.
3. Updates `NBAMarketRow.currentPrice` on each `PriceUpdate`.

```35:116:apps/dashboard/src/lib/stream.ts
export function createNbaStream(options: StreamOptions): NbaStream {
  // ...
  const handlePriceUpdate = (update: PriceUpdate) => {
    marketState.updatePrice(update);
    const row = state.getRow(update.ticker);
    if (row) {
      const previousPrice = row.currentPrice;
      row.currentPrice = update.price;
      row.priceChange = previousPrice !== undefined ? update.price - previousPrice : undefined;
      emit({ type: 'price_update', priceUpdate: update, changedTickers: [update.ticker] });
    }
  };
  // ...
  await marketStream.connect(accessKeyId, privateKey, environment);
  marketStream.subscribe(subscribedTickers);
}
```

### User context exists (positions/orders/fills)

User portfolio data is loaded and kept up to date via:

- REST polling for positions/orders (`/trade-api/v2/portfolio/positions`, `/trade-api/v2/portfolio/orders`)
- A user WebSocket stream (`fill`, `order`) via relay (`UserStream`)
- A fallback poller (`OrderMonitor`) when WS is unavailable
- A client-side ledger (`PositionTracker`) that computes P&L

## Data model gap (why Consolidated Moneylines isn't live yet)

Today, the live market pipeline is **per-ticker**:

- `NBAMarketRow` is keyed by `market_ticker`.
- `MarketStream` produces updates keyed by `ticker`.

But a "consolidated game row" needs **two Kalshi tickers** (away leg + home leg), plus a stable **grouping key**.

The older `MarketsTable` component shows the intended "game-level" display but still uses `NBAMarketRow[]` directly:

```25:113:apps/dashboard/src/components/MarketsTable.tsx
<h2>CONSOLIDATED MONEYLINES</h2>
// shows away/home prob + bid/ask + liquidity
```

In `nba.ts`, `transformToNBARows()` attempts to "merge away/home" into one row, but it keeps only one `market_ticker` (whichever row came first). That makes it impossible to subscribe/route updates for _both_ legs using only `NBAMarketRow.market_ticker`.

## End-to-end dataflows

### 1) Market (public) dataflow

```text
Kalshi REST (via relay HTTP)
  └─ discover NBA markets: getSeries/getMarkets + transformToNBARows (nba.ts)
      └─ initial NBAMarketRow[] snapshot (StateManager)
          └─ React state: markets[] in App.tsx

Kalshi WS (via relay WS)
  └─ MarketStream.connect() (marketStream.ts)
      └─ relay ws connect: op=connect, signed headers for GET /trade-api/ws/v2
  └─ MarketStream.subscribe() / subscribeOrderbook()
      └─ Kalshi subscribe cmd(s)
          └─ incoming frames (ticker / trades / orderbook_snapshot / orderbook_delta)
              └─ parsed into PriceUpdate (YES price in 0..1)
                  └─ createNbaStream.handlePriceUpdate()
                      └─ updates NBAMarketRow.currentPrice and emits StreamUpdate('price_update')
                          └─ App.tsx handleStreamUpdate updates markets[] and marketsDataRef
```

**Notes on message formats:**

- `MarketStream` attempts to interpret both `ticker/trades` and `orderbook_delta` to produce `PriceUpdate`.
- Orderbook semantics (Kalshi docs): YES/NO arrays are bids; asks are derived:
  - best YES ask = \(1 -\) best NO bid
  - best NO ask = \(1 -\) best YES bid

### 2) User (private) dataflow

```text
Kalshi REST (via relay HTTP)
  ├─ getPositions() -> normalizeKalshiPositions() -> KalshiPosition[]
  ├─ getOpenOrders() -> KalshiOrder[]
  └─ getFilledOrders() -> KalshiFill[]

UserStream (via relay WS)
  └─ connect signed GET /trade-api/ws/v2
  └─ subscribe channels: fill, order
      ├─ fill events -> FillNotification -> App.handleFill -> PositionTracker.addFill()
      └─ order events -> App refreshOrders()

Fallback polling
  └─ OrderMonitor polls /portfolio/orders and /portfolio/orders?status=filled
      └─ emits FillNotification + status changes
```

## Unit conventions & normalization (critical)

Kalshi payloads can represent "price" in several ways:

- **Probability**: 0..1
- **Cents**: 0..100
- **String dollars**: `"0.730"` (WS orderbook docs include `price_dollars`)

This codebase standardizes most "current prices" as:

- `PriceUpdate.price`: **YES price in 0..1**
- `Position.averagePrice` + `Position.currentPrice`: **contract price in 0..1**, where:
  - YES position uses YES price
  - NO position uses \(1 -\) YES price

Normalization helper:

```ts
normalizeKalshiPriceToProbability(raw):
  if raw > 1.01 → raw/100
  else raw
```

**Avoid double-scaling bugs**:

- Don't multiply by 100 again if you already normalized cents → probability.
- UI formatting should treat values as dollars/probability consistently.

## How to implement live Consolidated Moneylines (frontend-only)

You have two viable approaches; **Approach A is recommended** because it's minimally invasive and preserves the per-ticker streaming model.

### Approach A (recommended): derive `ConsolidatedGame[]` by grouping per-ticker rows

Goal: keep `NBAMarketRow` as the live, per-ticker stream state, and build a derived list of "game rows".

**Checklist**:

- Replace `MOCK_GAMES` in `NbaValueDashboardView.tsx` with a derived array built from `markets?: NBAMarketRow[]`.
- Define a stable `gameKey` for grouping:
  - Use what you already have: `row.gameTime + row.awayTeam + row.homeTeam`
  - Be careful: `row.gameDate` vs `row.gameTime` naming is inconsistent; pick one.
- Build `ConsolidatedGame` as:
  - `awayKalshi` from the away leg's current YES price (prefer `row.currentPrice`, else `row.awayProb`)
  - `homeKalshi` from the home leg's current YES price (prefer `row.currentPrice`, else `row.homeProb`)
  - `awayKalshiLiquidity/homeKalshiLiquidity` from `awayLiq/homeLiq`
- For liquidity, decide whether "liq" means:
  - current REST-derived depth band (what `nba.ts` computes using `getOrderbook`), or
  - live WS-derived depth (recommended long-term; see below)

**Important caveat**: the current `transformToNBARows()` merges away/home into a single `NBAMarketRow` while retaining only one `market_ticker`. For true live legs, you need either:

- stop merging in `transformToNBARows` (keep one `NBAMarketRow` per ticker), or
- augment the model (Approach B).

### Approach B: evolve the data model to include both tickers on one row

Add a new model (example):

```ts
type ConsolidatedGameLive = {
  gameKey: string;
  gameTime: string;
  awayTeam: string;
  homeTeam: string;
  away: { ticker: string; yesPrice: number | null; liq: number };
  home: { ticker: string; yesPrice: number | null; liq: number };
};
```

Then route `PriceUpdate` by `ticker` into either `away` or `home`.

## Overlaying user context onto consolidated games

Once you have a real `ConsolidatedGame[]`, overlay the user's portfolio without changing the relay:

- **Open orders**:
  - Use `orders` already loaded in `App.tsx` and passed into the dashboard.
  - Group orders by market ticker and side.
  - In the consolidated row, display per-leg open order count and queued exposure.

- **Open positions**:
  - Use `positions` already computed by `PositionTracker`.
  - Group by ticker/side.
  - In the consolidated row, annotate:
    - position qty per leg
    - avg price / current mark
    - P&L contribution

## Live liquidity (WS) recommendation

Today, `nba.ts` computes liquidity from a REST orderbook snapshot (`getOrderbook`) inside `transformToNBARows`. That's **not live**.

If you want liquidity bars to be live (and not "stale at load"), subscribe to:

- `orderbook_delta` for each ticker (via `MarketStream.subscribeOrderbook()`), and maintain a local book to compute:
  - best bid/ask
  - depth within a band
  - implied "liquidity" metric

This is already partially implemented in `marketStream.ts` (it maintains orderbooks and emits derived prices).

## Example payloads

### Relay HTTP envelope (`POST /relay/http`)

The frontend sends _byte-faithful_ requests to the relay, which forwards them to the Kalshi API:

```json
{
  "method": "GET",
  "path": "/trade-api/v2/portfolio/positions",
  "headers": {
    "KALSHI-ACCESS-KEY": "<accessKeyId>",
    "KALSHI-TIMESTAMP": "<unix_ms_or_iso>",
    "KALSHI-SIGNATURE": "<rsa_pss_signature>",
    "Content-Type": "application/json"
  },
  "body": ""
}
```

### Relay WS operations (client → relay)

Connect upstream WS:

```json
{
  "op": "connect",
  "id": "market-stream-<timestamp>",
  "url": "wss://<kalshi-host>/trade-api/ws/v2",
  "headers": {
    "KALSHI-ACCESS-KEY": "<accessKeyId>",
    "KALSHI-TIMESTAMP": "<timestamp>",
    "KALSHI-SIGNATURE": "<signature>"
  }
}
```

Send/subscribe:

```json
{
  "op": "subscribe",
  "id": "market-stream-...",
  "payload": {
    "id": 1,
    "cmd": "subscribe",
    "params": { "channels": ["ticker"], "market_ticker": "KX..." }
  }
}
```

Close:

```json
{ "op": "close", "id": "market-stream-..." }
```

### Relay WS frames (relay → client)

```json
{ "id": "market-stream-...", "type": "message", "data": "{\"type\":\"ticker\",\"msg\":{...}}" }
```

Errors:

```json
{ "id": "market-stream-...", "type": "error", "error": "Upstream WebSocket closed" }
```

### Kalshi WS subscribe (market)

Per docs (2026):

```json
{
  "id": 1,
  "cmd": "subscribe",
  "params": {
    "channels": ["orderbook_delta"],
    "market_ticker": "FED-23DEC-T3.00"
  }
}
```

Subscribed response:

```json
{
  "id": 1,
  "type": "subscribed",
  "msg": { "channel": "orderbook_delta", "sid": 2 }
}
```

### Kalshi WS orderbook updates (examples)

Snapshot:

```json
{
  "type": "orderbook_snapshot",
  "sid": 2,
  "seq": 2,
  "msg": {
    "market_ticker": "FED-23DEC-T3.00",
    "yes": [
      [8, 300],
      [22, 333]
    ],
    "no": [
      [54, 20],
      [56, 146]
    ]
  }
}
```

Delta:

```json
{
  "type": "orderbook_delta",
  "sid": 2,
  "seq": 3,
  "msg": {
    "market_ticker": "FED-23DEC-T3.00",
    "price": 96,
    "delta": -54,
    "side": "yes"
  }
}
```

### PriceUpdate (internal)

What the UI pipeline consumes:

```json
{
  "ticker": "KXNBA-...",
  "price": 0.73,
  "timestamp": 1769125074610,
  "yes_bid": 0.72,
  "yes_ask": 0.74
}
```

### User stream subscribe (fills/orders)

```json
{
  "id": 1,
  "cmd": "subscribe",
  "params": { "channels": ["fill", "order"] }
}
```

Fill event (shape varies; client parser is defensive):

```json
{
  "type": "fill",
  "msg": {
    "market_ticker": "KX...",
    "side": "yes",
    "action": "buy",
    "count": 10,
    "price": 73,
    "order_id": "..."
  }
}
```

## Implementation checklist (file-by-file)

- `src/components/pages/NbaValueDashboardView.tsx`
  - Remove `MOCK_GAMES`
  - Build `games` from `markets` via grouping logic (Approach A) or new model (Approach B)

- `src/components/nba-value-dashboard/ConsolidatedMoneylines.tsx`
  - Keep as presentational component
  - Optionally enhance to show per-leg bid/ask and user overlay badges

- `src/lib/nba.ts`
  - If using Approach A, avoid merging away/home into a single `NBAMarketRow` **unless** you also preserve both tickers somewhere

- `src/lib/marketStream.ts`
  - If you want live liquidity, compute depth metrics from the maintained orderbook maps and expose them

- `src/App.tsx`
  - Pass live `markets`, `orders`, `positions` into the dashboard (already happening for markets/positions)
  - Add derived selectors to compute per-game overlays (orders/positions grouped by ticker)
