# Live stock prices stream

Requires no token and no scope. Prices are public, like the market data they
come from.

[`GET …/stocks/list`](/stocks#the-catalogue) gives you a price that is up to a
minute old. The stream pushes a stock's price each time the venue updates it,
which is about once a second while its market is open, over one long-lived
WebSocket connection:

```
wss://stg-s2s.restake.gg/api/v1/s2s/stream
```

It is a plain [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455) WebSocket with
JSON text messages. You don't need an SDK. Any WebSocket client in any language
works.

## Quick start

```bash
npx wscat -c wss://stg-s2s.restake.gg/api/v1/s2s/stream
> {"op":"subscribe","channel":"stock_price","symbols":["AAPLon","MSFTon"],"reqId":1}
< {"type":"subscribed","channel":"stock_price","reqId":1,"symbols":["AAPLon","MSFTon"],"unknownSymbols":[]}
< {"type":"update","channel":"stock_price","data":[["AAPLon",231.45123,1789495851566],["MSFTon",514.004,1789495851567]]}
< {"type":"update","channel":"stock_price","data":[["AAPLon",231.4602,1789495852571]]}
```

## Messages you send

Every message is a JSON object with an `op`. `reqId` is optional. It can be a
string of up to 64 characters or an integer, and it is echoed on the reply so
you can match the two.

| `op` | Fields | Reply |
| --- | --- | --- |
| `subscribe` | `channel`, `symbols` | `subscribed` |
| `unsubscribe` | `channel`, `symbols` | `unsubscribed` |
| `ping` | none | `pong` |

Requests are answered **in the order you sent them**. An `unsubscribe` sent
right after a `subscribe` always takes effect second.

## Messages you receive

Every message is a JSON object with a `type`.

| `type` | When |
| --- | --- |
| `subscribed` | A `subscribe` was applied |
| `unsubscribed` | An `unsubscribe` was applied |
| `pong` | Reply to a `ping` |
| `update` | Channel data, see below |
| `error` | A request was refused. The connection stays open |

## The `stock_price` channel

### Subscribing

```json
{ "op": "subscribe", "channel": "stock_price", "symbols": ["AAPLon", "aaplon", "TSLAon"], "reqId": 1 }
```

```json title="Reply"
{
  "type": "subscribed",
  "channel": "stock_price",
  "reqId": 1,
  "symbols": ["AAPLon"],
  "unknownSymbols": ["TSLAon"]
}
```

`symbols` are the `symbol` field of the [catalogue](/stocks#the-catalogue)
(`AAPLon`), **not** the `slug` (`apple`). They are matched case-insensitively.
`symbols` in the reply lists everything you are now subscribed to, spelled the
way the catalogue spells it, and that is the spelling updates arrive under.

A symbol that is not in the catalogue is listed in `unknownSymbols` and
skipped. It does **not** fail the rest of the request, so a list that has gone
stale still subscribes to everything that exists. Log `unknownSymbols` rather
than ignoring it.

One message can name up to 1,000 symbols, which covers the whole catalogue.
Subscribing again to a symbol you already have is harmless.

### Updates

```json
{
  "type": "update",
  "channel": "stock_price",
  "data": [
    ["AAPLon", 231.45123, 1789495851566],
    ["MSFTon", 514.004, 1789495851567]
  ]
}
```

Each item in `data` is a `[symbol, price, timestamp]` array:

| Position | Meaning |
| --- | --- |
| `0` | The symbol, as the catalogue spells it |
| `1` | The token's price in USD, to five decimal places. This is the same figure `…/stocks/list` reports as `price`, which rounds it to two |
| `2` | When the venue produced this price, in unix **milliseconds**. It is not when we sent it |

Three things about delivery to design around:

- **Updates are batched.** Prices are gathered for up to 100 ms, then sent
  together, so one message can carry any number of symbols.
- **Only the latest price per symbol is sent.** If a symbol updates twice
  within one batch, you get the second price only. This stream reports the
  current price, not every trade.
- **You get a price straight after subscribing** if we already hold one. It
  arrives as a normal `update` right after the `subscribed` reply, and its
  timestamp tells you how old it is.

<Callout type="info" title="A price that stops moving is usually a shut market">
  Outside a stock's trading sessions the venue sends nothing for it, so its last
  update can be hours old. Read `marketOpen` and `nextOpenAt` from
  [`…/stocks/list`](/stocks#market-hours-are-the-thing-to-design-around) before
  concluding the stream is broken.
</Callout>

<Callout type="caution" title="Not every commodity streams">
  Tether Gold (`XAUt`) is in the catalogue and subscribes without an error, but
  this stream never sends an update for it, although its market is always open.
  Poll [its price](/stocks#commodities) instead.
</Callout>

### Unsubscribing

```json
{ "op": "unsubscribe", "channel": "stock_price", "symbols": ["AAPLon"], "reqId": 2 }
```

```json title="Reply"
{ "type": "unsubscribed", "channel": "stock_price", "reqId": 2, "symbols": ["AAPLon"] }
```

The reply echoes the symbols you sent. Unsubscribing from a symbol you never
subscribed to does nothing and returns no error. An update already on its way
can still arrive just after the reply.

## Staying connected

**Subscriptions belong to the connection.** When the connection closes they are
gone, and a new connection starts with none. Your client needs to:

1. reconnect when the socket closes, with a backoff;
2. send its `subscribe` again on every new connection.

A reconnect costs you nothing. You get the current prices straight after the
new `subscribed` reply.

Connections are closed on purpose, not only when something fails. Every deploy
closes them with code `1001`. Treat a close as routine and reconnect.

**Heartbeats.** We send a WebSocket ping every 30 seconds. A connection that has
not answered one by the next ping is dropped. Most WebSocket libraries answer
pings automatically. If yours doesn't expose them (browsers never do), send
`{"op":"ping"}` instead. Any message you send counts as a sign of life.

**Keep reading.** If you stop reading from the socket, we stop writing updates
to it and keep only the latest price per symbol. Once you catch up you get those
latest prices, not a backlog. A client that falls far enough behind to stop
answering pings is disconnected.

### Example: Node.js

```javascript
import WebSocket from 'ws';

const URL = 'wss://stg-s2s.restake.gg/api/v1/s2s/stream';
const SYMBOLS = ['AAPLon', 'MSFTon'];
let attempt = 0;

function connect() {
  const socket = new WebSocket(URL);

  socket.on('open', () => {
    socket.send(
      JSON.stringify({ op: 'subscribe', channel: 'stock_price', symbols: SYMBOLS }),
    );
  });

  socket.on('message', (raw) => {
    const message = JSON.parse(raw.toString());

    switch (message.type) {
      case 'subscribed':
        attempt = 0;
        if (message.unknownSymbols.length) {
          console.warn('Not in the catalogue:', message.unknownSymbols);
        }
        break;
      case 'update':
        for (const [symbol, price, timestamp] of message.data) {
          onPrice(symbol, price, new Date(timestamp));
        }
        break;
      case 'error':
        console.error(message.code, message.message);
        break;
    }
  });

  // `ws` answers our pings on its own. Always reconnect, with a capped backoff.
  socket.on('close', () => {
    const delay = Math.min(30_000, 1_000 * 2 ** attempt++);
    setTimeout(connect, delay);
  });
  socket.on('error', () => {}); // `close` follows
}

connect();
```

### Example: Python

```python
import asyncio, json, websockets

URL = "wss://stg-s2s.restake.gg/api/v1/s2s/stream"
SYMBOLS = ["AAPLon", "MSFTon"]

async def run():
    # `websockets` answers pings on its own, and its iterator reconnects with backoff.
    async for socket in websockets.connect(URL):
        try:
            await socket.send(json.dumps(
                {"op": "subscribe", "channel": "stock_price", "symbols": SYMBOLS}
            ))
            async for raw in socket:
                message = json.loads(raw)
                if message["type"] == "update":
                    for symbol, price, timestamp in message["data"]:
                        on_price(symbol, price, timestamp)
        except websockets.ConnectionClosed:
            continue

asyncio.run(run())
```

## Errors

A refused request is answered with an `error` message, and the connection stays
open:

```json
{ "type": "error", "code": "BAD_REQUEST", "message": "symbols should not be empty", "reqId": 1 }
```

`reqId` is included whenever the request carried a usable one.

| `code` | Cause |
| --- | --- |
| `BAD_REQUEST` | The message was not a JSON object, was sent as a binary frame, or had a missing or invalid field |
| `UNKNOWN_CHANNEL` | No channel has that name |
| `UNAVAILABLE` | The channel cannot serve right now. Retry later |
| `INTERNAL_ERROR` | Something failed on our side. Retry later |

Some problems end the connection instead:

| What you see | Cause |
| --- | --- |
| HTTP `503` instead of the WebSocket handshake | The server you reached is at capacity. Wait for the `Retry-After` header and reconnect |
| Close code `1001` | A deploy or restart. Reconnect straight away |
| Close code `1008` | You sent more than 100 requests without waiting for replies |
| Close code `1009` | A message was larger than 64 KB |
| Close with no code (`1006`) | The connection dropped, or stopped answering pings. Reconnect |
