A reference for Nado market metadata: how to read the product list, how wire numbers are represented, how to quantize price and size when lots are not powers of 10, what trading_status means, and how to avoid freezing your own cache.
TL;DR
- The product list comes from query
{type:'symbols', product_type:'perp'}.data.symbolsis an object with keys such as'BTC-PERP'; perpetuals havetype === 'perp', while spot products (type:'spot', for examplewAAPLx) are in the same map. Verified 2026-07-24. → §1 - All numbers are integer x18 strings.
price_increment_x18,size_increment,min_size, weights, fees, prices, sizes, and balances are allvalue × 1e18. Read them asBigIntand convert them to a decimal string without floating point. → §2 - Lots are not powers of 10: BTC 0.00005, XRP 5, PONS 2, SOL/HYPE 0.1. Ticks are fixed: BTC $1, ETH $0.1, XRP $0.0001. Quantize with
floorSz/ceilSz/pxToStrover BigInt, not a “number of decimals.” Wherever sizes and prices are calculated and sent, quantize them with the same functions. → §3 - Leverage is not configurable; it follows from the weight:
maxLeverage ≈ 1 / (1 − long_weight_initial): 0.98 → 50x, 0.9 → 10x, 0.8 → 5x. → §4 - A market has a
trading_statusmode:live,post_only(pre-listing and weekends for stock perpetuals),reduce_only,soft_reduce_only,not_tradable. Listing progresses throughnot_tradable → post_only → live. Inpost_only, only the POST_ONLY type is accepted (code 2117); innot_tradable, nothing is accepted (2069). Observed 2026-09-10/11. → §5 - Nado is not crypto-only: 72 perpetuals as of 2026-07-24 (75 by 2026-08-19), including stocks, ETFs, FX, and commodities. Request the list from the exchange; never claim something “is not listed” from memory. → §6
- Cache
symbolswith a 5-minute TTL, serve stale on failure, and define freshness as two TTLs. A stability check (product ID, lot, and tick do not change for a known coin) protects against a truncated response, but a market rename freezes the cache until restart. Do not apply this check totrading_status. → §7 - Market mode from the cache is not current. Publish the “market is post-only” flag only from a fresh cache; when stale, return
undefinedand use default behavior. For decisions involving money, rely on the exchange rejection from the same tick. → §5.3, §7
1. symbols query
Request: {type:'symbols', product_type:'perp'} (weight 2). Without product_type, spot products are returned as well.
Live shape of one product (mainnet, 2026-07-24; PONS on 2026-09-11):
"BTC-PERP": {
"type": "perp", "product_id": 2, "symbol": "BTC-PERP",
"price_increment_x18": "1000000000000000000",
"size_increment": "50000000000000",
"min_size": "100000000000000000000",
"maker_fee_rate_x18": "100000000000000",
"taker_fee_rate_x18": "350000000000000",
"long_weight_initial_x18": "980000000000000000",
"long_weight_maintenance_x18": "990000000000000000",
"max_open_interest_x18": "165000000000000000000000000",
"trading_status": "live", "isolated_only": false
}
"PONS-PERP": {
"type": "perp", "product_id": 188, "symbol": "PONS-PERP",
"price_increment_x18": "10000000000000", "size_increment": "2000000000000000000",
"min_size": "100000000000000000000", "maker_fee_rate_x18": "0", "taker_fee_rate_x18": "0",
"long_weight_initial_x18": "800000000000000000", "long_weight_maintenance_x18": "900000000000000000",
"trading_status": "post_only", "isolated_only": false
}
| Field | Meaning | How to read it |
|---|---|---|
product_id | market ID; also the verifyingContract for place_order (api-and-signing.md §5) | integer > 0 |
symbol | 'XXX-PERP'; equals the key | coin for your code = symbol without -PERP |
price_increment_x18 | price tick | x18 → BTC 1e18 = $1; ETH 1e17 = $0.1; XRP 1e14 = $0.0001; PONS 1e13 = $0.00001 |
size_increment | lot (no suffix, but still x18) | BTC 5e13 = 0.00005; XRP 5e18 = 5; PONS 2e18 = 2 |
min_size | minimum notional in USDT0 | 1e20 = $100 for every market (see orders.md §5 — in practice, book orders only) |
maker_fee_rate_x18 / taker_fee_rate_x18 | product rates | 1e14 = 0.0001 = 1 bps; 3.5e14 = 3.5 bps; 0 / 0 during pre-listing |
long_weight_initial_x18 / long_weight_maintenance_x18 | risk weights | 0.98 / 0.99 for BTC; 0.9 / 0.95 for ZEC; 0.8 / 0.9 for PONS |
max_open_interest_x18 | OI cap | may be null |
trading_status | market mode | §5 |
isolated_only | (bool) | always false on the markets checked; semantics not verified |
Fail-closed decoder. One malformed product (missing trading_status, non-integer product_id, duplicate product_id or coin, key ≠ symbol, tick or lot ≤ 0) must invalidate the entire response: a shifted or partial payload must never reassign a coin to another ID because orders are signed against that ID. An empty perpetual map is also an error, not “there are no markets.”
2. x18 numbers
The wire format is decimal integer strings representing value × 10^18; prices have the _x18 suffix, while size_increment / min_size / amount do not, but use the same scale. Number('…') loses precision on such strings and silently produces NaN from garbage. Rules:
- parse strictly (
/^-?\d+$/) intoBigInt; anything else is a read error, notNaN; - convert to a decimal string and to
Numberthrough an exact decimal string, notNumber(bigint)(which rounds differently in edge cases); - construct wire values from a decimal string (
decimalToX18), rejecting exponent notation and more than 18 digits after the decimal point.
export const X18 = 10n ** 18n;
export function x18ToBigInt(value: unknown, label: string): bigint {
if (typeof value !== 'string' && typeof value !== 'number') throw new Error(`${label} is not numeric`);
const s = String(value).trim();
if (!/^-?\d+$/.test(s)) throw new Error(`${label}="${s}" is not an integer x18 string`);
return BigInt(s);
}
/** Exact decimal string (no floating point), with trailing zeros removed. */
export function x18ToDecimalString(v: bigint): string {
const neg = v < 0n; const a = neg ? -v : v;
const whole = a / X18, frac = a % X18;
if (frac === 0n) return `${neg ? '-' : ''}${whole}`;
return `${neg ? '-' : ''}${whole}.${frac.toString().padStart(18, '0').replace(/0+$/, '')}`;
}
export function x18ToNumber(v: bigint): number {
const n = Number(x18ToDecimalString(v));
if (!Number.isFinite(n)) throw new Error(`x18 value ${v} does not fit a double`);
return n;
}
/** '0.00005' -> 50000000000000n. Exponent notation and >18 digits are errors. */
export function decimalToX18(s: string): bigint {
const m = /^(-?)(\d+)(?:\.(\d{1,18}))?$/.exec(s.trim());
if (!m) throw new Error(`decimalToX18: "${s}" is not a plain decimal`);
const sign = m[1] === '-' ? -1n : 1n;
return sign * (BigInt(m[2]) * X18 + BigInt((m[3] ?? '').padEnd(18, '0') || '0'));
}
/** Digits after the decimal point for a step: 1e18 -> 0, 5e13 (0.00005) -> 5, 5e18 (5) -> 0. */
export function stepDecimals(stepX18: bigint): number {
if (stepX18 <= 0n) throw new Error(`invalid step ${stepX18}`);
let v = stepX18, zeros = 0;
while (v % 10n === 0n && zeros < 18) { v /= 10n; zeros++; }
return Math.max(0, 18 - zeros);
}
Verified identities: x18ToDecimalString(-50000000000000n) === '-0.00005'; decimalToX18('66119') === 66119n * 10n ** 18n; x18ToNumber(50000000000000n) === 0.00005.
Nonces in responses are also large integers (around 1.87e18 for live orders), larger than 2^53. Parse string → BigInt; if the field arrives as a number, JSON.parse has already corrupted it. Use a separate strict unsigned-integer parser for nonces, IDs, and timestamps that accepts only /^\d+$/.
3. Lots and ticks: quantize with functions
Nado lots are arbitrary, not powers of 10 (0.00005, 5, 2, 0.1), and division by an inexact lot produces the classic 0.29 / 0.01 === 28.999999999999996 errors. Therefore:
- apply epsilon to the quotient (
sz / lot + ε), not to the size, and keep it far below one lot for realistic sizes (quotients < 1e7); - reconstruct the result through BigInt (
lots × lotX18) and an exact decimal string, notlots * lotin floating point; - price:
round(px / tick)ticks ×tickX18→ exact string on the wire. Parse this same string back when reconciling resting orders, so it cannot carry floating-point noise.
const QUOT_EPSILON = 1e-9;
export function nadoQuant(tickX18: bigint, lotX18: bigint) {
const tick = x18ToNumber(tickX18), lot = x18ToNumber(lotX18);
return {
lot,
decimals: stepDecimals(lotX18), // for final toFixed
pxToStr(px: number): string {
const ticks = Math.max(0, Math.round(px / tick));
return x18ToDecimalString(BigInt(ticks) * tickX18);
},
floorSz(sz: number): number { // everything except a full reduceOnly close
const lots = Math.floor(sz / lot + QUOT_EPSILON);
return lots <= 0 ? 0 : Number(x18ToDecimalString(BigInt(lots) * lotX18));
},
ceilSz(sz: number): number { // ONLY a full reduceOnly close
const lots = Math.ceil(sz / lot - QUOT_EPSILON);
return lots <= 0 ? 0 : Number(x18ToDecimalString(BigInt(lots) * lotX18));
},
};
}
Verified cases (tests using live ticks/lots):
| Market | tick / lot | Input | Result |
|---|---|---|---|
| BTC | $1 / 0.00005 | pxToStr(66119.7) | '66120' |
| BTC | floorSz(0.00012) | 0.0001 | |
| BTC | ceilSz(0.0001) | 0.0001 (no phantom extra lot) | |
| XRP | $0.0001 / 5 | floorSz(1392.7) / ceilSz(1390.1) | 1390 / 1395 |
| XRP | pxToStr(1.128064) | '1.1281' | |
| ZEC | $0.01 / 0.01 | floorSz(0.29) | 0.29 (not 0.28); idempotent; floorSz(0.2937) = 0.29 |
| ETH | $0.1 / 0.001 | pxToStr(1902.37) | '1902.4' |
Rules:
floorSzfor openings and partial reductions;ceilSzonly for a full reduceOnly close (the exchange clips to the position — but seeorders.md§6 regarding 2064).- A size quantized to 0 is
SKIPPED, not an order for 0. - The number of digits after the decimal point (
stepDecimals) is only for formatting and logs; the lot is the real size grid. - Use one quantization function for pre-send validation, size calculations, and writes: two independent implementations that differ by one lot cause endless order replacement, because expected size and resting-order size never match.
4. Leverage from weights
Nado uses unified cross-margin: margin is determined by product weights, with no separate account-level leverage setting. Effective maximum leverage:
const maxLeverage = Math.max(1, Math.round(1 / (1 - longWeightInitial)));
// 0.98 -> 50x (BTC), 0.9 -> 10x (ZEC), 0.8 -> 5x (PONS)
To estimate position margin: initial margin fraction = 1 − long_weight_initial. A position's weight (in subaccount_info.perp_products[].risk.long_weight_initial_x18) can differ from the weight in symbols; use the one from subaccount_info when it is in (0, 1), otherwise use symbols. Short weights (short_weight_*) were not verified.
5. trading_status and listing phases
5.1. Values
trading_status | What the exchange accepts | Rejection code | Source |
|---|---|---|---|
live | everything | — | verified live |
post_only | only the POST_ONLY type; DEFAULT is rejected at any price (including bids far below the book), as are IOC/FOK | 2117 Market is in post-only mode … Only post-only orders are accepted | observed 2026-09-11 |
reduce_only / soft_reduce_only | (semantics not measured; by name, reductions only) | not observed | documentation |
not_tradable | nothing | 2069 Trading is blocked for this market | observed 2026-09-10 |
5.2. Listing phases and weekends
A new market progresses through not_tradable → post_only → live (observed for PONS on 2026-09-10/11; product fees were 0 / 0 during pre-listing). Stock perpetuals (stocks and ETFs) switch to post_only on weekends: during that time, orders may only rest in the book.
5.3. What code should do
- The mode comes from
symbols(cached for up to 5 minutes), so it is not a current fact. Return the “market is post-only” flag only from a fresh cache (§7); when stale, returnundefined, and use default behavior (DEFAULT + fallback on code 2117). A frozen cache must not silently postpone reductions in a market that has long been live. - The exchange itself is the second witness: resend a DEFAULT order rejected with 2117 as POST_ONLY (new nonce; the first attempt was rejected by an envelope, so no duplicate is possible).
- A market in
not_tradableaccepts no orders (2069): begin trading only after its status changes. Do not remove an already traded market from the market list because its status changed. Seeorders.md§4 for details. - To diagnose “orders for this coin do not rest,” grep logs for
2117/2069, then inspect the market'strading_statusinsymbols.
6. What is listed
There were 72 perpetuals as of 2026-07-24 and 75 by 2026-08-19. Besides crypto:
- stocks: AAPL, NVDA, TSLA, MSFT, META, GOOGL, AMZN, AMD, AVGO, MU, INTC, DELL, MRVL, MSTR, SNDK, SPCX, CHIP;
- ETFs: SPY, QQQ;
- FX: EURUSD, GBPUSD, USDJPY;
- commodities: WTI, XAG, XAUT.
The list changes (renames: CIRCLE → CRCL on 2026-08-10; new listings CRCL, MEGA, PENG, SKR, BBX, XPL, MON, and LIT by 2026-08-19; PONS in 2026-09). Rule: always request symbols; never claim “not listed” from memory.
Spot products (type:'spot', for example wAAPLx) are separate records in the same map; filter perpetuals by type.
Liquidity varies: XRP-PERP was thin in July 2026 (around $0.4M per day), so expect slippage on IOC.
7. symbols cache
Cache discipline:
- TTL 5 minutes; when refresh fails, serve stale (product IDs and lots “almost never” change, while throwing a metadata error would disrupt every order, including closes) and do not retry refresh before 30 seconds.
- Freshness means the last successful refresh is less than two TTLs old (one failure is forgiven). Publish
trading_statusonly from a fresh cache (§5.3). - Stability check across refreshes: a known coin cannot disappear or change its
product_id, lot, or tick. Treat such a response as unusable (truncated or foreign payload), not as the new truth. A change totrading_statusis allowed: opening a market (post_only → live) must pass through refresh. - Single-flight while building: one request shared by all waiters.
Cache freeze. On 2026-08-10, Nado renamed CIRCLE → CRCL. After such a rename, the strict stability check rejects every subsequent refresh (a known coin has “disappeared”), and the cache remains frozen until the process restarts. Trading does not stop (lots and ticks are present), but new listings are invisible, including the renamed coin. The cause is self-sustaining: the baseline for comparison is the frozen in-memory cache itself. Restart fixes it; before restarting, request symbols 3 times in a row and compare the size and presence of your coins (the protection exists precisely because a response can be truncated). The long-term solution is not to freeze the entire cache when a coin absent from positions, orders, and the traded-market list disappears.
8. Prices: market_price / market_prices
{type:'market_price', product_id}(weight 1) →{bid_x18, ask_x18};{type:'market_prices', product_ids:[…]}(weight ≈ number of IDs) →market_prices[{product_id, bid_x18, ask_x18}].- Mid =
(bid + ask) / 2; ifbid ≤ 0,ask ≤ 0, orask < bid, there is no book, so do not return a mid (the coin “holds” for one tick). - Request not all ~72 markets, but the working set: active coins, nonzero balances, and your resting orders. For an empty working set (a fresh process with no traded markets), value the entire bounded universe; otherwise
{}is indistinguishable from a failed read. - Candles, trade history, and order-book depth beyond bid/ask were not verified (see Open questions).
9. Coin names
- Coin =
symbolwithout-PERP. There are no prefixes or separate namespaces: all perpetuals share one list.
10. Pitfalls
| What breaks | Why | Correct approach |
|---|---|---|
Number(min_size) = 1e20, “minimum is $100 quintillion” | x18 was read as an ordinary number | BigInt → decimal string → number |
| Size 0.28 instead of 0.29 with lot 0.01 | floor(0.29/0.01) = 28 in floating point | apply epsilon to the quotient and reconstruct through BigInt |
Extra lot from ceilSz on an aligned size | ceil(x/lot) when the representation is slightly above an integer | ceil(sz/lot − ε) |
| Market cache remains stuck until restart, and new coins “are not listed” | the stability check reacted to a rename | do not freeze the whole cache over a coin outside positions/orders; restart as a remedy; 3 requests beforehand |
Rejection 2117 on every resting order while the market is in post_only | the post_only mode was parsed but never consumed | metadata field → order type; fresh cache only; fallback on code |
| “The coin is not listed on Nado” is false | asserted from memory | request symbols |
| A product “moved” to another ID after a truncated response | decoder was not fail-closed | any invalid record invalidates the entire response; enforce ID/lot/tick stability |
market_prices for the whole universe every tick | no working set | interest set + periodic complete survey |
Empty {} mids means “exchange unavailable” | empty working set | value everything when the set is empty |
11. Open questions / not verified
- Semantics of
reduce_only/soft_reduce_only: accepted types and rejection codes were not measured. isolated_onlyand theisolatedappendix bit: alwaysfalseon the markets checked; behavior in an isolated market was not verified.- Short weights (
short_weight_*), the liquidation formula, and ADL were not studied. - Candles, trade history, order-book depth beyond bid/ask, funding, and OI through the API were not verified; endpoint availability and shapes were not recorded.
- Trading hours for stock perpetuals: only “weekends →
post_only” is known; the exact schedule (overnight, holidays) was not captured. Numberprecision for large sizes:x18ToNumberwas checked for lots and prices; double precision is sufficient for aggregates (sum of notionals), but no test was written.- Product rename and the fate of its resting orders and exchange position: cache freezing was investigated, but exchange behavior was not.
Facts verified through 2026-09-16. Nado changes: verify market modes, minimums, and listings against live symbols.
© markpaper authors. Licensed under CC BY 4.0: when publishing or adapting this work, credit “markpaper — Nado knowledge base” and link to the original and the license.