# Nado — markets and numbers: `symbols`, x18, lots and ticks, `trading_status`, metadata cache

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

1. **The product list comes from query `{type:'symbols', product_type:'perp'}`.** `data.symbols` is an **object** with keys such as `'BTC-PERP'`; perpetuals have `type === 'perp'`, while spot products (`type:'spot'`, for example `wAAPLx`) are in the same map. *Verified 2026-07-24.* → §1
2. **All numbers are integer x18 strings.** `price_increment_x18`, `size_increment`, `min_size`, weights, fees, prices, sizes, and balances are all `value × 1e18`. Read them as `BigInt` and convert them to a decimal string without floating point. → §2
3. **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` / `pxToStr` over BigInt, not a “number of decimals.” Wherever sizes and prices are calculated and sent, quantize them with the **same** functions. → §3
4. **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
5. **A market has a `trading_status` mode:** `live`, `post_only` (pre-listing and **weekends for stock perpetuals**), `reduce_only`, `soft_reduce_only`, `not_tradable`. Listing progresses through `not_tradable → post_only → live`. In `post_only`, only the POST_ONLY type is accepted (code 2117); in `not_tradable`, nothing is accepted (2069). *Observed 2026-09-10/11.* → §5
6. **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
7. **Cache `symbols` with 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 to `trading_status`. → §7
8. **Market mode from the cache is not current.** Publish the “market is post-only” flag only from a fresh cache; when stale, return `undefined` and 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):

```json
"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+$/`) into `BigInt`; anything else is a read error, not `NaN`;
- convert to a decimal string and to `Number` through an exact decimal string, not `Number(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.

```ts
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, not `lots * lot` in 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.

```ts
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:**
- `floorSz` for openings and partial reductions; `ceilSz` only for a full reduceOnly close (the exchange clips to the position — but see `orders.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:

```ts
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, return `undefined`, 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_tradable` accepts 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. See `orders.md` §4 for details.
- To diagnose “orders for this coin do not rest,” grep logs for `2117` / `2069`, then inspect the market's `trading_status` in `symbols`.

---

## 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_status` only 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 to `trading_status` **is 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`; if `bid ≤ 0`, `ask ≤ 0`, or `ask < 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 = `symbol` without `-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_only`** and the `isolated` appendix bit: always `false` on 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.
- **`Number` precision for large sizes:** `x18ToNumber` was 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`.

---

<!-- license-footer -->
_© markpaper authors. Licensed under [CC BY 4.0](LICENSE.md): when publishing or adapting this work, credit “markpaper — Nado knowledge base” and link to the original and the license._
