The exchange API
The SomniaMarkets class is the SDK's primary surface: markets keyed by symbols,
fetch*/watch*/createOrder verbs, human-unit structs — the idioms every
exchange bot already speaks (if you've driven a venue through ccxt, nothing
here will surprise you, down to the field names). Under the hood it runs on
the native engine (scoped watches, local books, one-round-trip writes), so the
watch* channels are zero-round-trip and createOrder confirms in a single
round-trip.
import { SomniaMarkets } from "@somnia-chain/markets-sdk";
const exchange = new SomniaMarkets({ indexerUrl, chain, wsRpcUrl, privateKey }); // one per chain
await exchange.loadMarkets();
const symbol = "BTC-95000-31DEC26/USDC#YES";
const book = await exchange.watchOrderBook(symbol, 10); // live, zero RTT
const order = await exchange.createOrder(symbol, "limit", "buy", 10, 0.62);
// … later:
await exchange.cancelOrder(order.id, symbol);
The raw engine stays available at exchange.client — bigint-exact,
address-keyed — for anything the exchange verbs don't cover; see
the engine guide.
Symbols
A symbol names a market; a tradable symbol names something you place orders on. For spot they coincide; for outcome markets each outcome is its own tradable:
| Kind | Market symbol | Tradables |
|---|---|---|
| Spot | SOMI/USDC | SOMI/USDC |
| Binary | BTC-95000-31DEC26/USDC | …#YES, …#NO |
| Perp | BTC/USDSO:USDSO | BTC/USDSO:USDSO |
| Categorical (soon) | US-ELECTION-28/USDC | …#TRUMP, …#NEWSOM, … |
Binary symbols are synthesized as ASSET-STRIKE-DDMONYY/QUOTE (quote = the
collateral's ERC-20 symbol), with an -HHMM expiry component for intraday
series markets (ETH-171780-03JUL26-0930/TUSDC); any residual collision gets
a deterministic 4-hex market-id tiebreaker on the base side of the slash. Everything before # follows standard exchange symbol conventions; #OUTCOME
is our extension (binary markets need one). Any raw ref works wherever a symbol does — a pool address,
market id, or BinaryMarket address resolves to the same tradable (outcome
markets default to #YES).
Numbers are human units in the tradable's own terms: a #NO price of 0.38
is the NO probability — the YES-terms complement, raw integers, and escrow
math are all internal. Raw payloads ride on every struct's info.
Verbs
| Method | Backed by | Notes |
|---|---|---|
loadMarkets() / fetchMarkets() | indexer (+ one-time symbol() reads) | builds the symbol registry |
fetchOrderBook(symbol, limit?) | chain | head-fresh one-shot |
fetchTrades(symbol, since?, limit?) | indexer | public tape |
fetchOHLCV(symbol, "5m", since?, limit?) | indexer | 1m 5m 15m 1h 4h 1d |
fetchOpenOrders(symbol?, limit?) | indexer | authenticated; limit is per venue (default 200) |
fetchMyTrades(symbol?, since?, limit?) | indexer | authenticated; scoped + windowed at the query, newest-first across venues |
fetchBalance() | chain | wallet balances by currency code (incl. binary YES/NO ERC-6909 holdings, keyed by tradable symbol) |
fetchStatus() | local | watch/socket health |
watchOrderBook(symbol, limit?) | local store | zero RTT, current to last block |
watchTrades(symbol) · watchOrders(symbol) · watchMyTrades(symbol) | local store | streaming |
createOrder(symbol, type, side, amount, price?, params?) | chain write | 1-RTT confirm, fills decoded |
cancelOrder(id, symbol) | chain write | |
mintSet / burnSet / redeem (symbol, amount) | chain write | outcome markets only |
fetchFundingRate(symbol) | chain | perps: mark/index + funding rate |
fetchPositions(symbols?) | chain | perps: open MarginBank positions |
depositMargin / withdrawMargin (symbol, amount) | chain write | perps: cross-margin collateral |
watchPrice(asset) · fetchPrice(asset) | price feed | index price (EMA oracle) — see Price feeds |
fetchPriceOHLCV(asset, "1m") | price feed | index candles: 1m/1h/1d |
fetchTicker(symbol) | indexer; best-effort chain read on perps | last, 24h change and volume; perps add mark/index/funding/open interest when getPerpState succeeds and degrade to candle data when that chain read fails |
fetchOrders(symbol?, since?, limit?, { offset? }) | indexer | authenticated; order history as one offset-paged set |
getOrderHistoryPage({ ref?, status?, limit?, offset? }) | indexer | authenticated; preferred indexed-history name; partial raw-row page with immutable identity and continuation |
getOrdersPage({ ref?, status?, limit?, offset? }) | indexer | compatibility name for getOrderHistoryPage |
fetchPortfolioAnalytics(timeframe, params?) | indexer | authenticated; PnL, equity and holdings series |
createStopOrder(symbol, type, side, amount, triggerPrice, price?, params?) | chain write | spot only; see Spot markets |
fetchOpenStopOrders(symbol?) · cancelStopOrder(id, symbol) | indexer · chain write | spot stop orders |
fetchFundingRateHistory(symbol, since?, limit?) | indexer | perps: funding-rate history |
setSigner({ privateKey | account | walletClient }) | local | bind or replace the signer after construction |
priceToPrecision(ref, price) · amountToPrecision(ref, amount) | local | snap a value down onto the market's tick or lot grid |
market(ref) | local | resolve any handle to its tradable |
close() | local | release all watches, channels, and sockets; a Node script exits on its own afterwards |
Still reserved: watchPositions, setLeverage (the raw
trader.setPerpLeverage exists for the latter).
Paginated order identity
getOrderHistoryPage reads one bounded page for the configured account. Use it
when the caller must retain the chain, account, market, pool, and order id
together. getOrdersPage remains available as a compatibility name. The
default limit is 100 and the default offset is 0. A status filter runs in the
indexer. A ref also scopes the query to its pool, then verifies each raw market
id so a recycled binary pool cannot cross market lifetimes.
rowsRead counts raw indexer rows. nextOffset advances after a full raw page,
even when outcome filtering removes every display order or every identity is
unresolved. The unresolved list keeps rows whose immutable market id is absent
or inconsistent in the loaded registry. A null binary side remains on the YES
book. A NO scope excludes that row.
Every result has coverage: "partial" and source: "indexer-offset". These are
indexed mutable records. They are not an immutable lifecycle log or a current
Open Orders snapshot. The indexer orders rows by placement time without a frozen
revision or a unique tie-break. Concurrent inserts or status changes can make
pages skip or repeat rows. A short page does not prove complete account state.
The status option filters indexed history. status: "Open" cannot establish
completeness, removals, continuity, or action freshness. Use a separately
supported current-state flow when the caller needs the SDK's current open-order
view. Each flow retains its documented freshness limits.
let offset = 0;
while (true) {
const page = await exchange.getOrderHistoryPage({ limit: 100, offset });
for (const order of page.orders) console.log(order.identity, order.status);
if (page.nextOffset === null) break;
offset = page.nextOffset;
}
The watch* idiom
Each await resolves when the channel next changes; the first call resolves
immediately after the (ref-counted) market watch hydrates:
while (running) {
const book = await exchange.watchOrderBook(symbol, 5); // resolves per change
requote(book.bids[0], book.asks[0]);
}
"Did my resting order fill?" — watch your orders and look for the status flip:
const orders = await exchange.watchOrders(symbol); // resolves per change
const mine = orders.find((o) => o.id === orderId);
if (mine?.status === "closed") … // filled
createOrder params
type: "limit" | "market"— market orders compute a crossing limit from the best opposite level ±params.slippage(default 1% on this surface; the engine'squoteBinaryStake/quoteBinarySellhelpers default to 3% with a 10-tick floor) and send IOC; they throwInvalidInputErrorif the opposite side is empty. When the chain rejects an order you get aContractRevertErrorwhoseerrorNameis the contract's own error — see ENGINE.md § Errors.params.timeInForce: "GTC" | "IOC" | "FOK" | "PO"(orpostOnly: true) — maps to the on-chain order types.params.builder/params.builderFeeBpsTimes1k— attribute the order to a routing/builder frontend and pay it a per-order fee (pool bps×1000 unit). Requires a prior one-timeexchange.trader.approveBuilder({ pool, builder, maxFeeBpsTimes1k })opt-in on the pool, else a non-zero fee reverts (BuilderFeeExceedsCapon binary,BuilderNotApprovedon spot,BuilderFeeExceedsApprovalon perp). Works on binary, spot and perp — but the approval is stored per pool, so opt in on each pool you attribute orders on.- The returned order:
status: "open"(rested),"closed"(fully filled in the same round-trip —filled/remainingsay how much), or"canceled"(IOC remainder).infocarries the receipt + raw fills.
Structs
One set of shapes throughout: markets { id, symbol, type, base, quote, active, precision, limits, outcomes?, info }; books as [price, amount] pairs, best
first; orders { id, symbol, type?, side, price, amount, filled, remaining, status, timestamp, info } (type is absent on orders read back from the
indexer — the pools do not emit it); trades { id, symbol, price, amount, cost, side?, timestamp, info }; balances { [code]: { free, used, total } } (escrowed
funds live in the pools, so wallet free === total); OHLCV rows
[ms, open, high, low, close, baseVolume].
How each market kind maps
- Spot — symbol is the tradable;
sideis base-buy/base-sell; done. - Binary — two tradables per market. The engine keeps one YES-terms book;
the resolver presents each outcome's own view (prices, sides, candles all
outcome-relative).
mintSet/burnSet/redeemoperate at market level. - Perps —
type: "swap", settle = quote (linear, collateral-settled);createOrderunchanged (margin locks from your MarginBank balance —depositMarginfirst);fetchPositions/fetchFundingRateread the MarginBank/pool live. - Categorical / multi-outcome (teed up) — N tradables on one pool
(outcome index = the order's
userData, exactly as binary uses 0/1 today); same verbs, and cross-book complete-set fills will surface inwatchMyTradeswith a shared match id ininfo.
The design intent: the verbs never change again — every future market kind
is new data (a type, an outcomes[] list), not new API.