> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kairos.trade/llms.txt
> Use this file to discover all available pages before exploring further.

# Enriched trade stream

> Live trades with trader identity and the taker's position and PnL, over the market data WebSocket

The enriched trade stream is the live trade tape with two things added to every
print: who traded, and where that trader stands in the market right after the
fill. It runs on the same [market data WebSocket](https://docs.kairos.trade/websocket/market-data-websocket.md)
as the raw tape, as two opt-in topics. Read this page before you build a trade
feed that shows trader names or per-trade PnL.

## What you get

| Topic | Tag | Payload | Adds |
| - | - | - | - |
| `trades.enriched` | `0x20` | `kairos.enriched.v1.EnrichedTradeBatch` | The print, plus the taker's and maker's public identity |
| `trades.enriched.pnl` | `0x21` | `kairos.enriched.v1.TradePnLBatch` | The taker's position and PnL across the whole market, right after the fill |

Both topics carry the same prints, keyed by the same `seq`. Subscribe to one or
both.

### When to use it instead of `trades`

Use the raw [`trades` topic](https://docs.kairos.trade/websocket/market-data-websocket#trade-batch-tag-0x04)
when you need every print as fast as possible and do not need identity. It has
no added latency, it covers every venue, and it is available to anonymous
connections.

Use the enriched topics when you want to show a name, avatar, or PnL next to
each print without calling another API per row. You pay for that with a short
delay, a smaller set of venues, and a credential.

### Venue coverage

| `provider` | Identity | PnL |
| - | - | - |
| `polymarket` | Public profile name | Yes, including fast provisional PnL for pre-confirmation prints |
| `predictfun` | Public account name | Yes |
| `hyperliquid` | `UNAVAILABLE` | `UNAVAILABLE` |

A print with no taker wallet is never published. That rules out venues whose
public tape is anonymous, such as Kalshi. Backfilled history is not published
either: the stream carries live prints only.

## Connecting

Use the market data gateway:

```text theme={null}
wss://stream.kairos.trade
```

Both topics require a credential. Authenticate with API-key headers or a JWT, as
described in [Authenticated connections](https://docs.kairos.trade/websocket/market-data-websocket.md#authenticated-connections).
An anonymous connection (`kairos.marketdata.public.v1`) that asks for either
topic gets an `ErrorResponse` with `action: "subscribe"` and
`message: "Topic requires authentication: enriched-trade"` (or `enriched-pnl`).
Nothing else in that request is subscribed.

## Subscribing

Send a normal `SubscribeRequest` (tag `0x10`) and name the topics explicitly:

```text theme={null}
SubscribeRequest {
  contract_id: "570362"
  provider: "polymarket"
  topics: ["trades.enriched", "trades.enriched.pnl"]
}
```

* **Use the trade-tape id.** `contract_id` is the same id you use for the raw
  `trades` topic. For Polymarket that is the numeric market id, not the
  condition id. See [Venues and products](https://docs.kairos.trade/websocket/market-data-websocket.md#venues-and-products).
* **Opt in by name.** Neither topic is in the default set that an empty `topics`
  list selects, and `trades.enriched.pnl` does not imply `trades.enriched`.
* **Add to an existing subscription.** Subscriptions are additive, so you can
  send these topics in a second `SubscribeRequest` for a market you already
  follow.
* **Nothing is replayed.** The gateway forwards frames as they are published. It
  sends no snapshot or history on subscribe. Load earlier prints from
  [Trade history](https://docs.kairos.trade/market-data/trades.md#trade-history).

To drop the enriched topics and keep the rest of the market, send an
`UnsubscribeRequest` (tag `0x11`) with the topic names in `repeated string`
field number `4`. Every name in that field must be `trades.enriched` or
`trades.enriched.pnl`. An `UnsubscribeRequest` without field `4` drops every
topic for the market, enriched ones included.

## Messages

Every frame is `[1-byte tag][protobuf payload]`. Each batch covers one market.

### `EnrichedTradeBatch` (tag `0x20`)

| Field | # | Type | Description |
| - | - | - | - |
| `provider` | 1 | `kairos.v1.Provider` | Venue |
| `contract_id` | 2 | string | Trade-tape market id |
| `epoch` | 3 | uint64 | Sequence space for this market. See [Sequencing](#sequencing-and-joining-the-topics). |
| `trades` | 4 | `EnrichedTrade[]` | Prints, in ascending `seq` within the batch |

`EnrichedTrade`:

| Field | # | Type | Description |
| - | - | - | - |
| `seq` | 1 | uint64 | Per-market sequence number, stamped when the print reached the enrichment step |
| `trade` | 2 | `kairos.v1.Trade` | The print exactly as the raw tape carried it. See [Trade Batch](https://docs.kairos.trade/websocket/market-data-websocket.md#trade-batch-tag-0x04). `wallet_metadata` is always unset. |
| `taker` | 3 | `TraderIdentity` | Identity of `trade.taker_address` |
| `maker` | 4 | `TraderIdentity` | Identity of `trade.maker_address` |
| `ingested_at_ms` | 5 | int64 | When the raw print arrived for enrichment, Unix ms |
| `enriched_at_ms` | 6 | int64 | When this record was published, Unix ms |

`TraderIdentity`:

| Field | # | Type | Description |
| - | - | - | - |
| `wallet` | 1 | string | Wallet address, lowercase |
| `status` | 2 | `IdentityStatus` | See [Identity status](#identity-status) |
| `display_name` | 3 | string | Public name. Set only when `status` is `READY`. |
| `pseudonym` | 4 | string | Venue pseudonym, if the venue has one |
| `image_url` | 5 | string | Avatar URL, if any |
| `verified` | 6 | bool | The venue marks this profile as verified |

### `TradePnLBatch` (tag `0x21`)

| Field | # | Type | Description |
| - | - | - | - |
| `provider` | 1 | `kairos.v1.Provider` | Venue |
| `contract_id` | 2 | string | Trade-tape market id |
| `epoch` | 3 | uint64 | Same sequence space as `EnrichedTradeBatch.epoch` |
| `pnl` | 4 | `TradePnL[]` | One entry per print, in ascending `seq` within the batch |

`TradePnL`:

| Field | # | Type | Description |
| - | - | - | - |
| `seq` | 1 | uint64 | The print's `seq`; joins to `EnrichedTrade.seq` |
| `trade_id` | 2 | string | The print's `trade.trade_id` |
| `pnl` | 3 | `MarketPnL` | The subject wallet's position in the market |

`MarketPnL` is the position of the print's taker (`trade.taker_address`) across
every outcome of the market, right after this fill. On-chain venues publish one
print per wallet, so the other side's PnL arrives on its own print.

| Field | # | Type | Description |
| - | - | - | - |
| `wallet` | 1 | string | Subject wallet, lowercase |
| `status` | 2 | `PnLStatus` | See [PnL status](#pnl-status). Ignore the numbers unless this is `READY`. |
| `realized_pnl_usd` | 3 | double | Realized PnL, summed over `tokens` |
| `unrealized_pnl_usd` | 4 | double | `position_value_usd - cost_basis_usd` |
| `position_value_usd` | 5 | double | Sum of `net_qty * mark_price` per token. A token with no mark counts at its cost basis. |
| `cost_basis_usd` | 6 | double | Cost basis, summed over `tokens` |
| `tokens` | 7 | `TokenPosition[]` | One entry per outcome token the wallet holds in this market |

`TokenPosition`:

| Field | # | Type | Description |
| - | - | - | - |
| `token_id` | 1 | string | Outcome token id |
| `outcome` | 2 | string | Outcome label, such as `Yes`. Can be empty. |
| `net_qty` | 3 | double | Shares held after the fill |
| `cost_basis_usd` | 4 | double | Cost basis of the shares held |
| `realized_pnl_usd` | 5 | double | Realized PnL on this token |
| `mark_price` | 6 | double | Latest print price seen for this token when the PnL was computed, 0 to 1 dollars. `0` when none was seen. |

> [!NOTE]
>
> The PnL is a snapshot at the fill. `mark_price` and `unrealized_pnl_usd` are not
> updated as the market moves. For a live valuation, re-mark `tokens` with your
> own prices. For how cost basis and realized PnL work, see
> [Positions and PnL](https://docs.kairos.trade/learn/positions-and-pnl.md).

> [accordion] enriched\_trade.proto
>
> Save this as `proto/kairos/enriched/v1/enriched_trade.proto`, next to the
> `kairos/v1` files from the [Protobuf schema](https://docs.kairos.trade/websocket/protobuf-reference.md) page.
> It imports `common.proto` and `trade.proto`.
>
> ```protobuf theme={null}
> syntax = "proto3";
>
> package kairos.enriched.v1;
>
> import "kairos/v1/common.proto";
> import "kairos/v1/trade.proto";
>
> enum IdentityStatus {
>   IDENTITY_STATUS_UNSPECIFIED = 0;
>   IDENTITY_STATUS_READY = 1;
>   IDENTITY_STATUS_ANONYMOUS = 2;
>   IDENTITY_STATUS_PENDING = 3;
>   IDENTITY_STATUS_UNAVAILABLE = 4;
> }
>
> message TraderIdentity {
>   string wallet = 1;
>   IdentityStatus status = 2;
>   string display_name = 3;
>   string pseudonym = 4;
>   string image_url = 5;
>   bool verified = 6;
> }
>
> message EnrichedTrade {
>   uint64 seq = 1;
>   kairos.v1.Trade trade = 2;
>   TraderIdentity taker = 3;
>   TraderIdentity maker = 4;
>   int64 ingested_at_ms = 5;
>   int64 enriched_at_ms = 6;
> }
>
> message EnrichedTradeBatch {
>   kairos.v1.Provider provider = 1;
>   string contract_id = 2;
>   uint64 epoch = 3;
>   repeated EnrichedTrade trades = 4;
> }
>
> enum PnLStatus {
>   PNL_STATUS_UNSPECIFIED = 0;
>   PNL_STATUS_READY = 1;
>   PNL_STATUS_PENDING = 2;
>   PNL_STATUS_UNAVAILABLE = 3;
> }
>
> message TokenPosition {
>   string token_id = 1;
>   string outcome = 2;
>   double net_qty = 3;
>   double cost_basis_usd = 4;
>   double realized_pnl_usd = 5;
>   double mark_price = 6;
> }
>
> message MarketPnL {
>   string wallet = 1;
>   PnLStatus status = 2;
>   double realized_pnl_usd = 3;
>   double unrealized_pnl_usd = 4;
>   double position_value_usd = 5;
>   double cost_basis_usd = 6;
>   repeated TokenPosition tokens = 7;
> }
>
> message TradePnL {
>   uint64 seq = 1;
>   string trade_id = 2;
>   MarketPnL pnl = 3;
> }
>
> message TradePnLBatch {
>   kairos.v1.Provider provider = 1;
>   string contract_id = 2;
>   uint64 epoch = 3;
>   repeated TradePnL pnl = 4;
> }
> ```

## Status values

### Identity status

| `IdentityStatus` | Meaning | What to show |
| - | - | - |
| `READY` | The wallet has a public name | `display_name`, falling back to `pseudonym` |
| `ANONYMOUS` | The venue confirmed the wallet has no public name. Venue placeholder names count as no name. | The address |
| `PENDING` | The lookup did not finish within about 1.5 seconds | The address for now |
| `UNAVAILABLE` | The venue has no profile source, the lookup failed, or the address is a venue contract | The address |

A `PENDING` identity is not republished for that print. Later prints by the same
wallet carry the result once the lookup finishes. Cache identities by
`(provider, wallet)` and apply the newest `READY` or `ANONYMOUS` value to every
row from that wallet.

### PnL status

| `PnLStatus` | Meaning |
| - | - |
| `READY` | The numbers are the wallet's position after this fill |
| `PENDING` | The position ledger did not record this fill within 20 seconds. The print is published without PnL, and a ledger update that arrives later is ignored for this print. |
| `UNAVAILABLE` | The venue has no position ledger (for example Hyperliquid). Also sent for a [retraction](#update-in-place). |

## Sequencing and joining the topics

Each market has its own sequence. `seq` increments by one per print, in the
order prints reached the enrichment step. `epoch` identifies the sequence space:
it changes when the enrichment service restarts, and seqs from different epochs
are not comparable. Every key you build should include `epoch`.

**Join the two topics on `(provider, contract_id, epoch, seq)`.** `trade_id`
identifies the same print and works as a join key too.

A print is published on both topics together, once its identity and its PnL
have both resolved or timed out. This holds even if you subscribe only to
`trades.enriched`, so a print never shows up there before its PnL exists. The
two frames usually arrive back to back, but either can arrive first. Create the
row from whichever arrives first and fill in the other half when it lands.

### Update in place

A `TradePnL` can arrive more than once for the same `seq`. Treat every
`TradePnL` as an upsert: replace the PnL you hold for that `seq`. Do not append
a second row.

This happens for Polymarket prints seen before on-chain confirmation:

1. The print is published with **provisional** PnL, usually within milliseconds.
2. When the fill confirms, the confirmed PnL replaces it. If the values are the
   same, nothing is sent. If they differ, `trades.enriched.pnl` is sent again
   with the same `seq` and the confirmed values.
3. If the fill never lands, the same `seq` is sent again with status
   `UNAVAILABLE`. Remove that PnL. If the wallet's previous print on the market
   is still in view, fall back to its PnL. A late confirmation can restore the
   print with a `READY` update for the same `seq`.

`trades.enriched` is not republished.

### Ordering and gaps

Records can arrive out of `seq` order. A print whose lookups finish quickly is
published before an earlier print that is still waiting. Within one batch,
records are in ascending `seq`. Across batches, they are not.

* **Key by seq, don't assume order.** Store rows by key and sort them for
  display. Don't drop a record because its `seq` is below one you have seen.
* **Tolerate gaps.** A `seq` can arrive up to 20 seconds after its neighbours,
  the PnL deadline. One still missing after that was lost and will not arrive.
  If you hold rows back to fill gaps, cap the wait. A few seconds covers almost
  every print.
* **Tolerate duplicates.** An upsert by key makes a repeated record harmless.
* **Handle a new epoch.** Records from an old epoch are still real prints. Keep
  them, and start a new sequence space for the new epoch.
* **Reconnect cleanly.** After a reconnect, re-send your `SubscribeRequest`s and
  load anything you missed from [Trade history](https://docs.kairos.trade/market-data/trades.md#trade-history).
  The gateway does not replay enriched frames.

## Latency

The stream trades a little latency for completeness. A print is released when
its identity and PnL are ready, and never later than the 20-second PnL deadline.
Identity lookups give up after about 1.5 seconds.

In testing, the median delay from the raw print to the published row with PnL
is around 8 ms, p90 around 100 ms, and p99 around 2 seconds. These are typical
figures, not a guarantee. The slow tail is mostly a sell that waits for the
ledger to record its opening buy, and the first identity lookup for a wallet not
seen before.

## TypeScript example

This Node.js client subscribes to both topics for one Polymarket market, decodes
the frames, and merges them into one row per print. It uses
[`@bufbuild/protobuf`](https://github.com/bufbuild/protobuf-es) and
[`ws`](https://github.com/websockets/ws).

Generate the bindings from the `kairos/v1` protos and `enriched_trade.proto`:

```bash theme={null}
npm install @bufbuild/protobuf ws
npm install -D @bufbuild/buf @bufbuild/protoc-gen-es @types/ws

cat > buf.gen.yaml <<'EOF'
version: v2
inputs:
  - directory: proto
plugins:
  - local: protoc-gen-es
    out: gen
    opt: target=ts
EOF
npx buf generate
```

```typescript theme={null}
import WebSocket from "ws";
import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
import { ErrorResponseSchema, SubscribeRequestSchema } from "./gen/kairos/v1/websocket_control_pb";
import {
  EnrichedTradeBatchSchema, IdentityStatus, PnLStatus, TradePnLBatchSchema,
  type EnrichedTrade, type MarketPnL, type TraderIdentity,
} from "./gen/kairos/enriched/v1/enriched_trade_pb";

const TAG_SUBSCRIBE = 0x10;
const TAG_ERROR = 0x17;
const TAG_ENRICHED_TRADES = 0x20;
const TAG_TRADE_PNL = 0x21;
const MAX_ROWS = 2_000;

interface Row { trade?: EnrichedTrade; pnl?: MarketPnL }

// One row per print, keyed by (provider, contract, epoch, seq).
const rows = new Map<string, Row>();
const names = new Map<string, string>(); // latest public name per wallet

function rowFor(provider: number, contract: string, epoch: bigint, seq: bigint): Row {
  const key = `${provider}:${contract}:${epoch}:${seq}`;
  let row = rows.get(key);
  if (!row) {
    row = {};
    rows.set(key, row);
    if (rows.size > MAX_ROWS) rows.delete(rows.keys().next().value!);
  }
  return row;
}

function remember(provider: number, identity?: TraderIdentity) {
  if (!identity?.wallet) return;
  const key = `${provider}:${identity.wallet}`;
  if (identity.status === IdentityStatus.READY) {
    names.set(key, identity.displayName || identity.pseudonym || identity.wallet);
  } else if (identity.status === IdentityStatus.ANONYMOUS) {
    names.delete(key);
  }
}

function render(provider: number, row: Row) {
  const trade = row.trade?.trade;
  if (!trade) return; // PnL arrived first; wait for the print
  const who = names.get(`${provider}:${trade.takerAddress.toLowerCase()}`) ?? trade.takerAddress;
  // protobuf-es keeps the PNL_STATUS_ prefix on this enum's values.
  const pnl = row.pnl?.status === PnLStatus.PNL_STATUS_READY
    ? `realized ${row.pnl.realizedPnlUsd.toFixed(2)} unrealized ${row.pnl.unrealizedPnlUsd.toFixed(2)}`
    : "no PnL";
  console.log(trade.tradeId, who, trade.price / 10_000, pnl);
}

const ws = new WebSocket("wss://stream.kairos.trade", {
  headers: {
    "X-Client-Id": process.env.KAIROS_CLIENT_ID!,
    "X-Api-Key": process.env.KAIROS_API_KEY!,
    "X-Api-Secret": process.env.KAIROS_API_SECRET!,
  },
});

ws.on("open", () => {
  const request = create(SubscribeRequestSchema, {
    contractId: "570362", // numeric market id: the trade-tape id
    provider: "polymarket",
    topics: ["trades.enriched", "trades.enriched.pnl"],
  });
  ws.send(Uint8Array.of(TAG_SUBSCRIBE, ...toBinary(SubscribeRequestSchema, request)));
});

ws.on("message", (raw: WebSocket.RawData) => {
  const data = raw as Buffer; // binary frames arrive as a Buffer by default
  const payload = data.subarray(1);
  switch (data[0]) {
    case TAG_ENRICHED_TRADES: {
      const batch = fromBinary(EnrichedTradeBatchSchema, payload);
      for (const record of batch.trades) {
        remember(batch.provider, record.taker);
        remember(batch.provider, record.maker);
        const row = rowFor(batch.provider, batch.contractId, batch.epoch, record.seq);
        row.trade = record;
        render(batch.provider, row);
      }
      break;
    }
    case TAG_TRADE_PNL: {
      const batch = fromBinary(TradePnLBatchSchema, payload);
      for (const record of batch.pnl) {
        const row = rowFor(batch.provider, batch.contractId, batch.epoch, record.seq);
        row.pnl = record.pnl; // upsert: a later frame for the same seq replaces this
        render(batch.provider, row);
      }
      break;
    }
    case TAG_ERROR: {
      const error = fromBinary(ErrorResponseSchema, payload);
      console.error(error.action, error.message);
      break;
    }
    default:
      break; // ignore other tags for forward compatibility
  }
});
```

In a browser, pass the JWT through `Sec-WebSocket-Protocol` instead of headers,
as shown in [Authenticated connections](https://docs.kairos.trade/websocket/market-data-websocket.md#authenticated-connections),
and set `ws.binaryType = "arraybuffer"`.

## Limits and caveats

* **Credential required.** Anonymous connections cannot subscribe.
* **Limited venues.** Identity covers Polymarket and predict.fun. PnL covers
  Polymarket and predict.fun. Prints without a taker wallet are not published.
* **Live only.** No history, snapshot, or replay on subscribe. Backfilled
  prints are not published.
* **PnL is per fill.** It is the taker's position right after the print, marked
  at the latest price seen then. It is not a live portfolio value.
* **Gaps are possible.** A record lost in transit is not resent. Use
  [Trade history](https://docs.kairos.trade/market-data/trades.md#trade-history) as the source of truth for
  the tape.
* **Connection limits apply.** Each subscription counts toward the
  [connection limits](https://docs.kairos.trade/websocket/market-data-websocket.md#connection-limits) like
  any other topic.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.