knowledge/lighter/markets-and-numbers.md
vregistry-c914171 · 12.6 KB
# Lighter — markets, listings, precision, and quantization
The market list on the robinhoodchain instance, `*/USDG` duplicates, `market_id`, price and size steps, integer `base_amount`/`price` values on the wire, two minimums (dollars and lots), and quantization through one function. Facts were verified on the robinhoodchain instance.
## TL;DR
1. **The robinhoodchain instance has 40 base perpetuals plus 26 `*/USDG` duplicates** (the same instrument in a second quote currency), as of 2026-08-23. Do not count duplicates when counting markets. Obtain the list from `/api/v1/orderBooks` or `orderBookDetails`; **do not claim from memory that something is or is not listed**. *Verified 2026-08-23.*
2. **Steps are exact powers of ten:** lot = `10^-supported_size_decimals`, tick = `10^-supported_price_decimals`. Values on the wire are **integers**: `base_amount = round(sz × 10^sizeDecimals)`, `price = round(px × 10^priceDecimals)`. *Verified 2026-08-20.*
3. **Two independent minimums:** dollar-based `min_quote_amount` ($10 on RH) and lot-based `min_base_amount` (code **21706**). The lot minimum can be stricter than the dollar minimum (SNDK: 0.01 ≈ $16 at a mark around 1605) or weaker (CRWV: 0.04 ≈ $3.6). An order that passes $10 can still be rejected by the lot minimum. *Verified 2026-08-20.*
4. **Use one quantization function** for sizing and sending. Compute the lot as `Number((10 ** -n).toFixed(n))`, not `10 ** -n` (which yields `0.00009999999999999999`); use an epsilon of `1e-9` for floor and ceil. A one-lot discrepancy between two implementations causes endless order replacement.
5. **Mid = `mark_price`** from `orderBookDetails`, not `last_trade_price`: the mark is what the exchange uses for margin, liquidation, and rejection 21734 (“too far from the mark”). *Verified 2026-08-20.*
6. **Spreads on thin instance markets can exceed 1%** (measurement 2026-08-20: one market at 1.18% while the others were 0.02–0.15%). An IoC that crossed by 0.5% did not reach it and silently did not fill. Check the spread, not just liquidity. *Verified 2026-08-20.*
---
## 1. Listings
### 1.1 What is and is not available (2026-08…09 snapshot, RH instance)
- **Available** (examples): major crypto assets (ETH, BTC, SOL, XRP, HYPE), equity perpetuals (META, NVDA, INTC, SNDK, CRWV, VVV, LIT), and the `SPY` ETF.
- **Not available** (examples from the checked samples): some popular altcoins (UNI, DOGE, XMR, LDO), PAXG, and CL.
- The snapshot becomes stale: **query the instance** before every conclusion about what is listed.
### 1.2 `*/USDG` duplicates
Alongside `ETH` there is `ETH/USDG`—the same instrument in a second quote currency. Count only the base market; otherwise, “66 markets” appears almost twice as broad as it really is. The duplicates have not been traded (their liquidity, minimums, and `market_id` values are separate).
### 1.3 Market status
Trade only when `status === 'active'`. Other values have not been observed; treat a market with any other status as “no metadata” (do not trade), but if there is already an open position, do not silently discard its metadata—warn loudly.
---
## 2. Identifiers: `market_id` / `market_index`
- In metadata, the field is called `market_id`; in orders and positions, it is `market_index`. They are the same number.
- All writes (`create_order`, `cancel_order`, `update_leverage`) address a market by this number, not by symbol. Keep the `symbol ↔ market_id` mapping from fresh metadata; an order with an unknown `market_index` in book reads is a **warning**, not a silent skip, because the bot cannot cancel it.
- The `market_id` for the same symbol does **not have to match** between RH and mainnet (they are different instances). Do not hard-code it.
- `market_id=255` in `accountActiveOrders` means all markets (as does omitting the parameter). *Verified 2026-08-20.*
---
## 3. Precision and values on the wire
| Value | Source | Formula |
|---|---|---|
| size lot | `supported_size_decimals` = n | `lot = Number((10 ** -n).toFixed(n))` |
| price tick | `supported_price_decimals` = m | `tick = 10^-m`; price string `px.toFixed(m)` |
| `base_amount` (int) | size | `Math.round(sz × 10^n)` |
| `price` (int) | price | `Math.round(Number(pxStr) × 10^m)`, where `m` is the number of decimal places in `pxStr` |
```ts
interface MarketQuant {
lot: number; // 10^-sizeDecimals
decimals: number; // sizeDecimals—for the final toFixed
minSz?: number; // min_base_amount; undefined = no lot-based minimum
pxToStr(px: number): string; // price on the instance grid, as an exact string
floorSz(sz: number): number; // everything except a full reduceOnly close
ceilSz(sz: number): number; // ONLY a full reduceOnly close (the exchange caps it)
}
function quantForMarket(m: { sizeDecimals: number; priceDecimals: number; minBase: number }): MarketQuant {
// Use Number(toFixed), not 10 ** -n: the latter yields 0.00009999999999999999 instead of 0.0001.
const lot = Number((10 ** -m.sizeDecimals).toFixed(m.sizeDecimals));
const round = (v: number, dec: number) => Number(v.toFixed(dec));
return {
lot,
decimals: m.sizeDecimals,
minSz: m.minBase > 0 ? m.minBase : undefined,
pxToStr: (px) => px.toFixed(m.priceDecimals),
floorSz: (sz) => round(Math.max(0, Math.floor(sz / lot + 1e-9) * lot), m.sizeDecimals),
ceilSz: (sz) => round(Math.max(0, Math.ceil(sz / lot - 1e-9) * lot), m.sizeDecimals),
};
}
/** Integers for the wire. pxStr is already on the grid (pxToStr), so take the decimal-place count from the string. */
function toWire(sz: number, pxStr: string, sizeDecimals: number): { base_amount: number; price: number } {
const priceDecimals = pxStr.split('.')[1]?.length ?? 0;
return {
base_amount: Math.round(sz * 10 ** sizeDecimals),
price: Math.round(Number(pxStr) * 10 ** priceDecimals),
};
}
```
- **The price grid is an invariant across all code, not only writes.** A price calculated off the instance grid is placed on the instance grid. Compare the target price with the order in the book **after quantizing to the instance grid**; otherwise, target 41.237 is sent as 41.24, read back as 41.24, and never equals itself, so the order is replaced on every cycle.
- Prices in responses are strings (`"41.24"`). Keep `pxStr` alongside `px` and compare strings, not floats.
---
## 4. Two minimums
### 4.1 Dollar-based `min_quote_amount`
$10 on the RH instance (the `"10.000000"` value from `orderBookDetails`). It is evaluated at the order price after size quantization. How the minimum was measured → `orders.md` §4.
### 4.2 Lot-based `min_base_amount` (code 21706)
- The minimum size in base units, **independent** of the dollar minimum.
- It can dominate the dollar minimum in either direction (measurements 2026-08-20…23):
- SNDK: lot 0.0001, `min_base_amount` 0.01 ≈ $16 at a mark around 1605—**stricter** than $10. An order for 0.007 × 1605 ≈ $11.24 passes the dollar test and is rejected by the lot minimum.
- CRWV: 0.04 ≈ $3.6—less strict than $10.
- VVV: 0.5 ≈ $7.30 at a price around 14.6—if size is raised only to the lot minimum, the order remains below $10.
- LIT: lot **5 units**—every order size is a multiple of 5 units; a smaller size cannot be expressed.
- If the pre-send size check considers only the dollar minimum, a large share of orders receives 21706.
### 4.3 Sizing policy (shared by entries, partial closes, and full closes)
```ts
/** Size decision: skip | { sz, bumped }. One function for sizing and sending. */
function decideSize(
p: { sz: number; px: number; reduceOnly: boolean; fullClose?: boolean },
q: MarketQuant, minNotionalUsd: number,
): { sz: number; bumped: boolean } | { skip: string } {
if (!(p.sz > 0) || !(p.px > 0)) return { skip: 'invalid dimensions' };
const isFullClose = p.reduceOnly && p.fullClose === true;
const rounded = isFullClose ? q.ceilSz(p.sz) : q.floorSz(p.sz);
if (rounded <= 0) return isFullClose ? { sz: q.lot, bumped: true } : { skip: 'rounds to 0' };
const minSz = q.minSz ?? 0;
if (minSz > 0 && rounded < minSz - 1e-12) {
if (!isFullClose) return { skip: `${rounded} < min_base_amount ${minSz}` };
// Full close: raise above BOTH minimums at once (raising only to minSz leaves VVV below $10).
const lotsForMin = minNotionalUsd > 0 ? Math.ceil(minNotionalUsd / (p.px * q.lot)) : 0;
let placeable = q.ceilSz(Math.max(rounded, minSz, lotsForMin * q.lot));
if (placeable * p.px < minNotionalUsd) placeable += q.lot;
return { sz: Number(placeable.toFixed(q.decimals)), bumped: true };
}
if (rounded * p.px < minNotionalUsd) {
if (!isFullClose) return { skip: `$${(rounded * p.px).toFixed(2)} < $${minNotionalUsd}` };
let placeable = Math.max(rounded, Math.ceil(minNotionalUsd / (p.px * q.lot)) * q.lot);
if (placeable * p.px < minNotionalUsd) placeable += q.lot; // 9.999999999999998
return { sz: Number(placeable.toFixed(q.decimals)), bumped: true };
}
return { sz: rounded, bumped: false };
}
```
Rules enforced by this function:
- **Entries** and **partial reduceOnly** orders below either minimum are **skipped**, not bumped: raising to a lot or minimum changes the intended size (by multiples for LIT).
- A **full reduceOnly close** uses ceil and is raised above **both** minimums at once: the exchange caps reduceOnly at the live position, so it cannot overshoot. → `orders.md` §3
- Check both minimums locally before sending. An order sent without the check receives 21706 and **consumes one write in the 40/60 s window**; a local check rejects it without a write.
---
## 5. Mid, spread, and “too far from the mark”
- The planning mid is `mark_price`. `last_trade_price` jumps on thin markets.
- **Code 21734, “too far from the mark”:** the exchange rejects a limit order too far from the mark (the threshold was not measured; far orders were rejected). This is a structural rejection: the order cannot pass until the market approaches it. Retrying every cycle produces rejection after rejection and wastes writes from the 40/60 s window, while a stream of identical rejections hides real failures. The remedy is temporary rejected-price memory (5 minutes), followed by another attempt. *Verified 2026-08-22.* → `orders.md` §7
- **Spread.** An IoC crossing 0.5% from the mark did not reach the ask on a market with a 1.18% spread (mark→ask 1.08%): the IoC limit did not cross the book, the order was canceled, and no position was acquired. This looked like a minimum-size rejection even though there was ample volume at the best ask. Other measured markets were at 0.02–0.15%. A 1.5% cross is almost safe—the fill occurs at the best available price; the cross only widens the worst case. *Verified 2026-08-20.*
---
## Pitfalls
| What breaks | Why | Correct approach |
|---|---|---|
| A large share of orders is rejected with 21706 | the dollar minimum passes but the lot minimum does not; the sizing check did not know `min_base_amount` | map `min_base_amount` to `minSz`; use one sizing function for calculation and sending |
| Full close is still rejected after bumping to the lot minimum | raised only to `minSz` and remained below $10 (VVV) | raise above both minimums at once |
| Order is replaced forever | target price was compared with the book off the instance grid | quantize price to the instance grid before comparison |
| Lot is `0.00009999999999999999` | `10 ** -n` | `Number((10 ** -n).toFixed(n))` |
| IoC entry silently does not fill | spread is wider than the cross | measure the spread; cross ≥ spread (1.5% on RH) |
| Order is rejected on every cycle (21734) | limit order too far from the mark | remember the rejection for 5 minutes; do not hammer it |
| Order with unknown `market_index` is silently skipped | stale metadata or a new market | warn: the bot cannot cancel that order |
---
## Open questions / not verified
- The 21734 “too far from the mark” threshold has not been measured as a percentage (far limit orders were rejected, close ones passed).
- `*/USDG` markets: minimums, liquidity, and whether the same key can trade them were not tried.
- What market statuses other than `active` mean and how markets behave in those statuses.
- The zkLighter-mainnet listing was not checked; the assumption that it is “broader than RH” is not verified.
- Whether `supported_price_decimals` is fixed for expensive assets (BTC) is an observation that was not tested separately.
---
Facts verified through 2026-09-16. Listings and minimums change—query `orderBookDetails` on the correct instance before drawing conclusions about listings.
---
<!-- license-footer -->
_© markpaper authors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting this material, credit “markpaper — Lighter knowledge base” and link to the original and the license._