# Order Slip Channel


Source: https://docs.stxapp.io/websockets/channels/order-slip/

Topic: `order_slip:{user_id}`

Costs an order before you place it. Register the order you are considering, and
this channel replies with what it would cost you (risk, fee, how much of it
would fill immediately and at which prices), then re-pushes those numbers every
time the book moves underneath it.

Registering an order here places nothing. Nothing is sent to the matching
engine, no funds are committed, and the numbers are a projection of the book as
it stands. Place the order through `POST /api/v1/orders` when you want it live.

:::caution[`betslip:{user_id}` is superseded, and it sends different numbers]
This channel replaces `betslip:{user_id}`. That topic still works (same events,
same fields, same limits), so nothing breaks if you are already on it.

**It does not send the same number formats.** `betslip:` predates the dollar
format and still sends money rounded to two decimal places and quantities as
JSON numbers:

| Field | `order_slip:` | `betslip:` (deprecated) |
| --- | --- | --- |
| `risk`, `fee`, `risk_with_fee`, level `price`, level `fee` | `"144.9999999"` | `"145.00"` |
| `fill_qty`, `unfilled_qty`, level `qty` | `"3.00"` | `3.0` |

The two-decimal form is lossy: an order price carries up to seven decimals, so
`betslip:` can round a risk of 144.9999999 to `"145.00"`. `order_slip:` keeps
the value.

Move to `order_slip:{user_id}`. It is a one-line topic change plus parsing money
and quantity as decimal strings, the same way you already parse `orders` and
`fills`. `betslip:` will be removed.

If an environment refuses a join to `order_slip:{user_id}`, it is running a
build from before the rename; `betslip:{user_id}` works there.
:::

Money and quantities on `order_slip:` follow the standard
[wire format](/websockets/channels/wire-format/): money as a decimal string with
a minimum of four decimal places and any further precision preserved, quantities
as decimal strings with a minimum of two. Parse both with a variable-scale
decimal type.

:::tip[These are projections, not balances]
The authoritative amounts are the ones on `fills:{user_id}` after the order
actually executes. Use these to display and to decide, not to reconcile.
:::

## Registering an order

### add_order

| Field | Required | Type | Meaning |
| --- | --- | --- | --- |
| `market_id` | yes | string | The market's UUID. |
| `qty` | yes | number | Contracts you are considering. Must be positive. |
| `side` | yes | string | `"buy"` or `"sell"`. |
| `max_price` | yes | number | That market's `max_price`, **in dollars**: `1` for a market whose REST `max_price` is `"1.0000"`. Sets the price ceiling the risk and fee are computed against. |
| `limit_price` | no | number | Your limit, in dollars (`0.55`). Must be positive if given. **Omit it for a market order**; the projection then sweeps the book at any price. |

:::caution[Send dollars, not the `markets` channel's cents]
`max_price` and `limit_price` here are dollars, the unit of the REST API. The
[`markets`](/websockets/channels/markets/) and
[`market_updates`](/websockets/channels/market-updates/) channels send `max_price` in
cents (`100` for a $1 market). Passing that value here is accepted without error and
costs the order as if each contract paid $100: a sell of one contract at `0.42` then
projects a risk of `99.5800` instead of `0.5800`. Take `max_price` from
`GET /api/v1/markets`, or divide the channel value by 100.
:::

Reply carries a `ref` identifying this entry. Keep it: it is echoed on every
push, and it is what `remove_order` takes.

```json
["3","4","order_slip:<user_id>","add_order",{"market_id":"687e9cdb-a391-4118-aa80-1122bb14779f","qty":100,"side":"buy","limit_price":0.55,"max_price":1}]
```

```json
{"status":"ok","response":{"ref":"8f3a1c22-7b40-4e19-9d51-2c7e6a8b0f33","self_match":false}}
```

You may hold **10 registered entries** at once. An eleventh is refused with
`max_orders_reached`. Remove one first.

Entries live only as long as the channel does. They are not restored after a
disconnect: rejoin, then re-add everything you were tracking.

:::caution[Send only the events listed here]
This channel accepts `add_order`, `remove_order` and `ping`. Any other event
drops the channel, and your registered entries go with it; you will have to
rejoin and re-add every one. `select_market_ids`, which the other account
channels take, is not among them: there is no filter here.
:::

### remove_order

```json
["3","5","order_slip:<user_id>","remove_order",{"ref":"8f3a1c22-7b40-4e19-9d51-2c7e6a8b0f33"}]
```

```json
{"status":"ok","response":{}}
```

### Rejected registrations

`add_order` and `remove_order` reply `{"status":"error","response":{"reason":"..."}}`.

| `reason` | Cause |
| --- | --- |
| `missing required field` | One of `market_id`, `qty`, `side` or `max_price` is absent. |
| `market_id must be a string` | `market_id` was sent as something other than a string. |
| `invalid market_id` | `market_id` is a string but not a UUID. |
| `side must be 'buy' or 'sell'` | Any other value. |
| `qty must be a number`, `max_price must be a number`, `limit_price must be a number` | That field was not a number or a numeric string. `5` and `"5"` are both accepted; `true`, `null`, a list or an object are not. |
| `invalid decimal` | The value is a string or float but could not be read as a decimal: `"abc"`, `""`. |
| `qty must be positive`, `max_price must be positive`, `limit_price must be positive` | Zero or negative. |
| `max_orders_reached` | You already hold 10 entries. |
| `limit_exceeded` | The market's order book refused the registration because this connection already holds its per-market maximum. Both limits are 10 by default, so `max_orders_reached` is normally hit first and this is not seen; it becomes reachable when the two are configured apart. Remove an entry on that market. |
| `market_unavailable` | That market has no order book right now. **Transient** on `pre_open`, `open`, `closed` and `cancelled`, where a book exists and may be restarting; retry. **Permanent** on `scheduled`, `resulted` and `voided`, where no book is ever started, so retrying cannot succeed. |
| `fee_unavailable` | Fees could not be determined for your account on that market. |
| `not_found` | `remove_order` was given a `ref` that is not registered. |
| `ref required` | `remove_order` was sent without a `ref`. |
| `invalid ref` | `ref` is not a UUID. |

## Pushed as the book moves

### order_numbers_batch

Every registered entry whose numbers changed, in one frame.

```json
[null, null, "order_slip:<user_id>", "order_numbers_batch", {"updates": [OrderNumbers]}]
```

- `ref` : The entry these numbers are for.
- `market_id` : The market the entry is on.
- `self_match` : See [Self-match](#self-match) below.
- `risk` : What the order would cost you if it filled as projected: the
  filling portion plus, for a limit order, the remainder left resting.
- `fee` : Fee on the same basis. The filling portion is charged the taker rate,
  any resting remainder the maker rate.
- `risk_with_fee` : `risk` + `fee`. What to show as the total.
- `fill_qty` : Contracts that would fill immediately.
- `unfilled_qty` : Contracts that would not. For a limit order this is what
  rests on the book; for a market order it is what the book cannot cover.
- `est_fill_at_price` : The immediate fill, broken down by price level, best
  price first. Each entry is `price`, `qty` and `fee`. A resting remainder is
  **not** a level here; this lists only what fills now.

```json
[
  null,
  null,
  "order_slip:a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "order_numbers_batch",
  {
    "updates": [
      {
        "ref": "8f3a1c22-7b40-4e19-9d51-2c7e6a8b0f33",
        "market_id": "687e9cdb-a391-4118-aa80-1122bb14779f",
        "self_match": false,
        "risk": "54.2000",
        "fee": "0.5000",
        "risk_with_fee": "54.7000",
        "fill_qty": "60.00",
        "unfilled_qty": "40.00",
        "est_fill_at_price": [
          {"price": "0.5300", "qty": "40.00", "fee": "0.2000"},
          {"price": "0.5500", "qty": "20.00", "fee": "0.1000"}
        ]
      }
    ]
  }
]
```

Working that example through, for a buy of 100 at a limit of `0.5500` on a
market whose `max_price` is `1`:

| | Quantity | Price | Risk | Fee |
| --- | --- | --- | --- | --- |
| Fills now | 40 | `0.5300` | `21.2000` | `0.2000` taker |
| Fills now | 20 | `0.5500` | `11.0000` | `0.1000` taker |
| Rests | 40 | `0.5500` | `22.0000` | `0.2000` maker |
| **Total** | | | **`54.2000`** | **`0.5000`** |

`risk` counts the resting remainder, so it does not equal the sum of
`est_fill_at_price`; that list is only what fills now. `risk_with_fee` is
`54.2000 + 0.5000`.

Risk per contract is the price for a **buy** and `max_price - price` for a
**sell**: what you stand to lose either way, not what you pay.

A newly registered entry is costed immediately; its first
`order_numbers_batch` arrives on registration, not on the next tick.

After that, nothing is pushed while the book is still, so silence normally
means the projection has not changed.

:::caution[Silence does not prove the entry is still registered]
If a market's order book becomes unavailable and has not come back after about
five seconds, every entry you hold on that market is discarded. **No event is
sent.** The numbers you last received simply stop updating, and they will look
no different from a quiet market.

Usually `remove_order` on that `ref` then returns `not_found`, which is your
signal. **It is not guaranteed.** If the book comes back but refuses one of your
re-registrations, the entry stays on your slip and still counts against your 10:
it receives nothing further, and `remove_order` on it replies `{}` as though it
had been live.

So neither silence nor a clean `remove_order` proves an entry is still being
costed. If stale numbers would be costly to show, re-add entries you have not
heard from rather than trusting the last push.
:::

### self_match_batch

```json
[null, null, "order_slip:<user_id>", "self_match_batch", {"updates": [{"ref": "...", "self_match": true}]}]
```

Only entries whose flag actually flipped are included.

## Self-match

`self_match` is `true` when you already hold an open order on the opposite side
within crossing range of the entry (for a market order, any opposite-side
order at all).

The exchange rejects a self-crossing order outright rather than filling around
it. So when this flag is `true`, the numbers alongside it describe a fill that
cannot currently happen, and placing the order would be refused.

The entry stays registered anyway, because the condition is yours to clear:
cancel the order that is in the way and the flag clears by itself. That is what
`self_match_batch` tells you: the book need not have moved for an entry to
become placeable, so this is the only signal that it did.

Treat the flag as advisory. It is read from a cache that trails the exchange by
a moment, and it fails open: if it cannot be determined at registration, the
entry is returned unflagged rather than blocked. The engine remains the
authority at placement.

## Use Cases

| Use case | Message to send |
| --- | --- |
| Join | `["3","3","order_slip:<user_id>","phx_join",{}]` |
| Cost a limit order | `["3","4","order_slip:<user_id>","add_order",{"market_id":"<uuid>","qty":100,"side":"buy","limit_price":0.55,"max_price":1}]` |
| Cost a market order | `["3","5","order_slip:<user_id>","add_order",{"market_id":"<uuid>","qty":100,"side":"buy","max_price":1}]` |
| Stop tracking one entry | `["3","6","order_slip:<user_id>","remove_order",{"ref":"<ref>"}]` |
| Check the connection is alive | `["3","7","order_slip:<user_id>","ping",{}]` |

### Joining

```json
["3","3","order_slip:<user_id>","phx_join",{}]
```

```json
{"status":"ok","response":{}}
```

No snapshot and no filter. The join reply is empty and nothing follows until
you register an entry. Joining a topic whose user id is not yours fails with
`{"reason":"unauthorized"}`.
