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
- The robinhoodchain instance has 40 base perpetuals plus 26
*/USDGduplicates (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/orderBooksororderBookDetails; do not claim from memory that something is or is not listed. Verified 2026-08-23. - 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. - Two independent minimums: dollar-based
min_quote_amount($10 on RH) and lot-basedmin_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. - Use one quantization function for sizing and sending. Compute the lot as
Number((10 ** -n).toFixed(n)), not10 ** -n(which yields0.00009999999999999999); use an epsilon of1e-9for floor and ceil. A one-lot discrepancy between two implementations causes endless order replacement. - Mid =
mark_pricefromorderBookDetails, notlast_trade_price: the mark is what the exchange uses for margin, liquidation, and rejection 21734 (“too far from the mark”). Verified 2026-08-20. - 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
SPYETF. - 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 ismarket_index. They are the same number. - All writes (
create_order,cancel_order,update_leverage) address a market by this number, not by symbol. Keep thesymbol ↔ market_idmapping from fresh metadata; an order with an unknownmarket_indexin book reads is a warning, not a silent skip, because the bot cannot cancel it. - The
market_idfor the same symbol does not have to match between RH and mainnet (they are different instances). Do not hard-code it. market_id=255inaccountActiveOrdersmeans 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 |
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"). KeeppxStralongsidepxand 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_amount0.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.
- SNDK: lot 0.0001,
- 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)
/** 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_pricejumps 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).
*/USDGmarkets: minimums, liquidity, and whether the same key can trade them were not tried.- What market statuses other than
activemean 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_decimalsis 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.
© markpaper authors. Licensed under CC BY 4.0: when publishing or adapting this material, credit “markpaper — Lighter knowledge base” and link to the original and the license.