Skip to main content

Strikes and Ticks

Every strike in DeepBook Predict is an absolute integer tick counted from zero. One expiry market fixes one tick_size, and the raw strike is the product:

raw_strike = tick * tick_size

A position is a pair of those ticks, (lower_tick, higher_tick), and it pays its full quantity when settlement lands inside the half-open region (lower, higher]. There is no second strike representation anywhere in the protocol: the same tick means the same price at the mint call, in the events, in the payout index, and at settlement. The protocol expresses nothing relative to a per-market origin, and market creation reads no live spot price.

Positions are not objects. A mint returns a packed u256 order ID, and that ID together with its market identifies the position.

The tick grid

Each expiry market snapshots its tick_size from the cadence configuration the registry holds for its underlying, and never changes it. Raw strikes use the protocol's 1e9 fixed-point price scale, so a tick_size of 10_000_000 is 0.01 USD per tick. Every configured tick_size must be a multiple of constants::market_tick_size_unit!(), which is 10_000.

Read the grid off an ExpiryMarket with these public accessors:

public fun tick_size(market: &ExpiryMarket): u64
public fun admission_tick_size(market: &ExpiryMarket): u64
public fun reference_tick(market: &ExpiryMarket): Option<u64>
public fun reference_tick_source_timestamp_ms(market: &ExpiryMarket): u64
Click to open
Source for the tick accessors

The only conversion in the other direction is range_codec::strike_from_tick, which applies the sentinel mapping first and multiplies otherwise. It is the single public entry into the raw-price domain:

Both the Mainnet and the Testnet deployment run one underlying, BTC, with 2 enabled cadences, and both cadences use the same grids:

Cadencetick_size rawFine gridadmission_tick_size rawAdmission grid
1m10_000_0000.01 USD1_000_000_0001 USD
5m10_000_0000.01 USD1_000_000_0001 USD

The registry also holds the 1h, 1d, 1w, and 1mo cadences in a disabled state, and a disabled cadence reads back as an all-zero configuration. Verification on 2026-09-10 confirmed those values on both networks. Read the live values with registry::cadence_config or registry::cadence_configs, or as tickSize and admissionTickSize from read.markets(), rather than hardcoding them, because an admin can change a cadence and each market keeps the terms it received at creation.

A position is a tick pair

Both mint functions take lower_tick and higher_tick as separate u64 arguments, and both order events carry them the same way. Only the durable order ID packs the pair into one integer.

The payout region is left-open and right-closed. A settlement exactly at the lower strike loses, and a settlement exactly at the higher strike wins. The chain decides that in range_codec::settlement_in_range, which first rounds the settlement price up onto the tick grid:

prefix_limit_tick = ceil(settlement / tick_size)
in_range = lower_tick < prefix_limit_tick
&& (higher_tick == pos_inf_tick || prefix_limit_tick <= higher_tick)

Rounding up is what makes the boundary asymmetric: a finite boundary tick is active in the settlement walk only when its own strike sits strictly below the settlement price. The higher_tick == pos_inf_tick branch short-circuits because a settlement above the encodable range produces a prefix_limit_tick that legitimately exceeds pos_inf_tick, so the comparison is a plain u64 bound and the chain never validates it as a domain tick.

Worked against a 0.01 USD grid and a settlement of exactly 105,000 USD, prefix_limit_tick is 10_500_000:

PositionTicksTestResult
104,000 to 105,000 USD(10_400_000, 10_500_000]10_400_000 < 10_500_000 and 10_500_000 <= 10_500_000Pays in full
105,000 to 106,000 USD(10_500_000, 10_600_000]10_500_000 < 10_500_000 is falsePays zero

A winning position pays its full quantity, and the contract measures quantity in base units of the quote coin, USDC, where 1_000_000 pays 1 USD at settlement. On Mainnet that coin is native USDC; on Testnet it is a mintable test coin at the same usdc::USDC module path that displays as DUSDC. Take the exact type from getConfig(network).quoteCoinType.

Sentinel ticks

The protocol reserves 2 ticks so that an open-ended range needs no artificial outer strike:

EndTickRaw strikeConstantVisibility
Negative infinity00constants::neg_inf!()public
Positive infinity1073741823std::u64::max_value!()constants::pos_inf!()public

1073741823 is (1 << 30) - 1, the maximum value of the 30-bit tick field. Onchain it is constants::pos_inf_tick!(), which is public(package) and therefore not callable from an integrator package. Hardcode the literal 1073741823, or take it from the SDK's POS_INF_TICK export. The raw-strike sentinels neg_inf!() and pos_inf!() are public, but they live on the price axis rather than the tick axis, so they are not what a mint call takes.

Finite ticks occupy 1 through 1073741822. A tick above pos_inf_tick aborts with order::EInvalidTick.

Choose a range shape

A directional view is a one-sided range, and the protocol prices it through exactly the same path as a two-sided one. Pick the shape that matches your view on settlement:

Your view on settlementShapeTicksWins when
Ends above one levelDirectional up(K, 1073741823]settlement > K
Ends at or below one levelDirectional down(0, K]settlement <= K
Lands between 2 levelsTwo-sided range(L, H]L < settlement <= H

A one-sided range wins on an unbounded outcome, so it costs more per contract and wins more often. A two-sided range wins on a bounded outcome, so it costs less per contract and wins less often. Ranges suit a view on where a price settles rather than on which direction it moves.

The order module rejects 2 shapes outright:

  • lower_tick >= higher_tick aborts with EInvalidRange, so a zero-width or inverted band cannot exist.
  • The fully open range (0, 1073741823] aborts with EInvalidRange, because it always pays and is not a tradable contract.

The tick grid and the admission grid

Confusing the 2 grids is the most common integration failure on this surface. A new mint's strike must satisfy both conditions:

  • Fine grid: The raw strike must be an exact multiple of the market's tick_size, otherwise it is not a tick at all.
  • Admission grid: Each finite boundary must additionally land on the coarser admission_tick_size, which the cadence configuration sets per cadence.

The chain tests the second condition in tick units. With admission_multiple = admission_tick_size / tick_size, the chain admits a boundary when it is a sentinel, when tick % admission_multiple == 0, or when it equals the market's recorded reference_tick. Anything else aborts with strike_exposure::EInvalidAdmissionTick.

On both enabled cadences, admission_multiple is 100: a 0.01 USD fine grid under a 1 USD admission grid.

The reference_tick is the one finite strike a market admits off the admission grid. It is the market's window anchor, floored onto the fine grid from an exact Propbook Pyth observation at reference_tick_source_timestamp_ms, and a permissionless call records it:

reference_tick reads back as none until that call succeeds, and recording a different tick afterwards aborts with strike_exposure::EReferenceTickAlreadySet. Calling it before the source observation lands in the feed aborts with expiry_market::EReferenceTickObservationMissing.

Grid state lives in the market's StrikeExposure, which has no public functions of its own. An ExpiryMarket getter mirrors every value an integrator needs:

Click to open
Source for the StrikeExposure struct

Build a valid range

Follow these steps before you submit a mint:

  1. Resolve the market for your underlying and expiry, then read tick_size, admission_tick_size, and reference_tick from that ExpiryMarket.
  2. Convert each finite boundary price to a tick with tick = raw_strike / tick_size, and reject any price that leaves a remainder.
  3. Snap each finite tick to the admission grid, or set it to the market's reference_tick.
  4. Replace an open lower bound with 0 and an open higher bound with 1073741823.
  5. Confirm lower_tick < higher_tick and that the pair is not (0, 1073741823].
  6. Round the quantity to a whole lot of 10_000 base units, which is 0.01 USD of payout, then check the premium against the minimum below.

The lot is the granularity, not a tradable minimum. Every mint must also produce a premium of at least 1_000_000 base units, which is 1 USDC, or it aborts with strike_exposure_config::EPremiumBelowMinimum. Premium is quantity * entry_probability and the contract caps entry probability at 0.99, so the smallest admissible quantity never falls below about 1.02 USDC of payout, and it rises steeply as the strike moves away from the money. A far out-of-the-money strike fails earlier still, on the entry-probability band. See Mint admission errors for both codes and the measured floors.

A valid range can still fail on timing. Live mints and live redeems abort with protocol_config::ETradeWindowClosed inside the last no_trade_window_ms before expiry, deployed at 2,000 ms on both networks, so check the market's remaining time before you submit; see Predict for the window.

Using a 0.01 USD fine grid under a 1 USD admission grid, the same numeric strike passes or fails on where it lands:

StrikeTickResult
105,000 USD10_500_000Admitted
105,000.50 USD10_500_050Aborts with EInvalidAdmissionTick, unless it equals the market's reference_tick
105,000.005 USDNot a tickOff the fine grid

The packed order ID

mint_exact_quantity and mint_exact_amount both return a u256 order ID. That integer carries the durable contract terms and nothing else, so the ID deliberately omits mint-only inputs such as entry probability, premium, and fee policy. Tightening admission policy in a later version therefore cannot retroactively invalidate an existing ID.

The layout is dense in the low bits:

FieldOffsetWidthMeaning
quantity_lotsBit 10032 bitsQuantity in lots. Payout quantity is quantity_lots * 10_000 base units.
lower_tickBit 7030 bitsLower boundary tick, where 0 is negative infinity.
higher_tickBit 4030 bitsHigher boundary tick, where 1073741823 is positive infinity.
sequenceBits 0 to 3940 bitsExpiry-local monotonic counter.

Total width is 132 bits, and any bit set at or above 132 aborts with order::EInvalidOrderId. Quantity must be nonzero, a whole multiple of 10_000, and at most 4_294_967_295 lots. Those are encoding bounds only. Mint admission holds a higher effective floor, because the premium has to clear 1 USDC; see Build a valid range.

Treat the ID as an opaque handle. Pass it back unchanged to redeem, claim, or query a position, and read range facts from the order events rather than by unpacking bits yourself.

The sequence counter is expiry-local, so an order ID is unique only within one expiry market. An ID alone carries no expiry or market identity. A position is the pair (expiry_market_id, order_id), which is why every trade event carries expiry_market_id alongside order_id.

Click to open
Source for the Order view

order declares no public functions. The module validates and decodes packed IDs for the rest of the package, and integrators observe the same terms through the OrderMinted, LiveOrderRedeemed, and SettledOrderRedeemed events.

Where Predict tracks positions

A holder's positions live in Predict's app-data slot on the account package's Account, not in the expiry market. predict_account::PredictData keeps a Table<PositionKey, Position> where PositionKey is exactly the (expiry_market_id, order_id) pair, and the stored Position records the root order ID plus the millisecond timestamp at which the position opened.

The root order ID is the original mint's ID, carried forward unchanged across partial-close replacements. One economic position keeps one stable handle even though a partial close retires the current ID and issues a new one. Check membership with the public read:

public fun has_position(account: &Account, expiry_market_id: ID, order_id: u256): bool

For the account model, the shared AccountWrapper, and the Auth hot potato that opens it, see Accounts and Custody.

Find a market

Resolve an ExpiryMarket object ID from the shared Registry:

public fun expiry_market_id(
registry: &Registry,
propbook_underlying_id: u32,
expiry: u64,
): Option<ID>
Click to open
Source for expiry_market_id

The registry stores those IDs in a Table keyed by market_manager::MarketKey, a u32 underlying ID paired with a u64 expiry. That struct is an internal uniqueness key: it has no accessors, no exported constructor, and no role in any integrator call. Use expiry_market_id instead, and take propbook_underlying_id from getConfig(network).underlyings or the deployment manifest, where BTC is 1 on both networks.

Click to open
Source for the MarketKey uniqueness key

Ticks in the TypeScript SDK

@mysten/deepbook-v3 version 2.3.0 or later ships a /predict subpath that does the tick arithmetic for you on either network. Describe a position with a MarketDescriptor in USD, and the SDK resolves the market, converts each strike, and admission-checks each finite boundary before it builds the transaction.

The descriptor has 2 arms:

  • Binary arm: { side: 'up' | 'down', strike: number | 'reference' }, where 'reference' resolves to the market's referencePrice.
  • Range arm: { side: 'range', lower: number, upper: number }, where both bounds are finite USD prices and lower must be below upper.

Each arm maps onto the same tick pair the chain takes:

DescriptorlowerTickhigherTick
{ side: 'up', strike: K }K / tickSizePOS_INF_TICK
{ side: 'down', strike: K }0K / tickSize
{ side: 'range', lower: L, upper: H }L / tickSizeH / tickSize

The SDK exports POS_INF_TICK as a bigint equal to 1073741823n. strike: 'reference' is available on the binary arm only, and it throws while the market has no reference tick recorded. Recording one is permissionless, so a caller blocked this way can unblock themselves by calling set_reference_tick rather than waiting for someone else.

read.markets() returns tickSize, admissionTickSize, and referencePrice as USD decimals, so snap a target price with plain arithmetic:

const markets = await client.predict.read.markets();
const m = markets[0];
const strike = Math.round(target / m.admissionTickSize) * m.admissionTickSize;

The SDK raises a typed PredictInputError before it builds a transaction when a strike is off the fine grid, off the admission grid and not the reference price, outside the finite tick domain, or when lower is not below upper.

The following example mints a directional position, quoting first and capping the all-in cost; NETWORK in the examples package selects Testnet or Mainnet:

import type { MarketDescriptor, MintQuote } from '@mysten/deepbook-v3/predict';
import type { Transaction } from '@mysten/sui/transactions';
import { client } from './client.js';
import { UNDERLYING } from './config.js';
import { admissibleStrike, tradeableMarket } from './markets.js';

// Quote, then mint with a cap derived from the quote.
//
// Both caps are optional, and omitting them is not a safe default: the SDK sends
// U64_MAX for a missing `maxCost` or `maxProbability`, which leaves the mint
// genuinely uncapped against any price move between the quote and execution.
// This builder mints an exact payout quantity, so the premium alone cannot exceed
// `quantity`; fees are charged on top, and `maxCost` is what bounds the total
// debit. `tx.mintAmount` is the one that can reach the whole balance, because
// there fees are charged on top of `spend` and `maxCost` is the only bound on the
// full withdrawal. Always pass at least `maxCost`.
export async function mintDirectional(params: {
owner: string;
side: 'up' | 'down';
// Maximum payout in USD, at $1 per contract. Must be a whole $0.01 lot.
quantity: number;
// Omit to trade at the market's on-chain reference price, the window anchor.
targetStrikeUsd?: number;
}): Promise<{ tx: Transaction; quote: MintQuote; descriptor: MarketDescriptor }> {
const { owner, side, quantity, targetStrikeUsd } = params;
const market = await tradeableMarket();

const descriptor: MarketDescriptor = {
underlying: UNDERLYING,
expiryMs: market.expiryMs,
// Pin the exact market object that was read, rather than whatever the
// registry resolves to at submit time.
marketId: market.id,
side,
strike:
targetStrikeUsd === undefined
? 'reference'
: admissibleStrike(market, targetStrikeUsd),
};

// The quote dry-runs the identical transaction the mint builds, against the
// real account and the real fee path, so it doubles as preflight: it throws
// the same typed errors the mint would.
const quote = await client.predict.read.quoteMint(owner, descriptor, { quantity });

// `quote.cost` is the all-in account debit, not the premium. Raw amounts are
// integers at six decimals, so round the cap to six decimals: a finer value
// throws `PredictInputError`.
const maxCost = Math.ceil(quote.cost * 1.01 * 1e6) / 1e6;

// A second, independent ceiling on the fill price, 0..1 per $1 of payout.
const maxProbability = Math.min(1, Number((quote.entryProbability * 1.02).toFixed(6)));

const tx = await client.predict.tx.mint(owner, descriptor, {
quantity,
maxCost,
maxProbability,
});

// The transaction is ready to sign. Nothing here signs it, and the SDK never
// holds keys.
return { tx, quote, descriptor };
}

This one mints a two-sided range through the same builder, with both bounds snapped to the admission grid:

import type { MarketDescriptor, MintQuote } from '@mysten/deepbook-v3/predict';
import type { Transaction } from '@mysten/sui/transactions';
import { client } from './client.js';
import { UNDERLYING } from './config.js';
import { admissibleStrike, tradeableMarket } from './markets.js';

// A range position pays out when settlement lands inside `(lower, upper]`, which
// is left-open and right-closed. Both bounds are finite numeric strikes, so both
// must sit on the market's admission grid. There is no `strike` field on the
// range arm of the descriptor, so `'reference'` has no meaning here: center the
// band on the market's reference price instead.
export async function mintRange(params: {
owner: string;
// Maximum payout in USD, at $1 per contract. Must be a whole $0.01 lot.
quantity: number;
// Half-width of the band around the window anchor, in USD.
halfWidthUsd: number;
}): Promise<{ tx: Transaction; quote: MintQuote; lower: number; upper: number }> {
const { owner, quantity, halfWidthUsd } = params;
const market = await tradeableMarket();

const anchor = market.referencePrice;
if (anchor === null) {
throw new Error('The market has no reference price yet. Retry once the keeper seeds it.');
}

const lower = admissibleStrike(market, anchor - halfWidthUsd);
// Each bound rounds independently, so a band narrower than one admission tick
// can collapse onto a single tick. The chain requires `lower` strictly below
// `upper`.
const upper = Math.max(
admissibleStrike(market, anchor + halfWidthUsd),
lower + market.admissionTickSize,
);

const descriptor: MarketDescriptor = {
underlying: UNDERLYING,
expiryMs: market.expiryMs,
marketId: market.id,
side: 'range',
lower,
upper,
};

// Range positions quote and mint through the same builders as binary ones.
const quote = await client.predict.read.quoteMint(owner, descriptor, { quantity });

// Cap both dimensions. Omitting either sends U64_MAX for it, and a cost cap
// alone still lets the fill price move: pass `maxProbability` as well.
const maxCost = Math.ceil(quote.cost * 1.01 * 1e6) / 1e6;
const maxProbability = Math.min(1, Number((quote.entryProbability * 1.02).toFixed(6)));

const tx = await client.predict.tx.mint(owner, descriptor, {
quantity,
maxCost,
maxProbability,
});

return { tx, quote, lower, upper };
}

Error codes

order validates range shape and quantity:

CodeConstantCause
0EInvalidOrderIdThe packed ID sets a bit at or above 132.
1EInvalidTickA tick exceeds the 30-bit field or pos_inf_tick.
2EInvalidRangelower_tick >= higher_tick, or the range is the fully open (0, pos_inf_tick].
3EInvalidQuantityQuantity is zero, not a whole 10_000 lot, above 4_294_967_295 lots, or a replacement is not strictly smaller.
4EInvalidSequenceThe sequence exceeds 40 bits.

strike_exposure validates grid admission and exposure bookkeeping:

CodeConstantCause
0EInvalidCloseQuantityA live close asks for more than the order's quantity.
1EInvalidAdmissionTickA finite boundary is neither on the admission grid nor equal to the market's reference_tick.
2EInvalidReferenceTickA reference tick resolves to 0 or to at least pos_inf_tick.
3EReferenceTickAlreadySetA call records a different reference tick after one is already set.
4ETermsExposureMismatchTerms priced against another market's exposure book.
5EMintQuantityBelowMinThe sized or budgeted quantity is below min_quantity.
6EInvalidInventoryImpactScaleThe market's inventory-impact scale is zero at construction.

The payout index behind strike_exposure is strike_payout_tree, and one of its codes is a mint abort:

CodeConstantCause
0EInsufficientPayoutQuantityA close removes more payout than the tree holds at that boundary.
1EMaxPayoutTreeNodesThe mint would add a new finite boundary tick when the market's index already holds 960 nodes.
2ENonMonotonePriceThe pricer returned a probability that does not fall as the strike rises.
3EStaleValuationSnapshotA flush read a payout snapshot from an earlier flush.
4ESnapshotSeqNotIncreasingA flush activated a snapshot whose sequence did not advance.

Each distinct finite boundary tick in a market costs one node, and a range with 2 new finite boundaries costs 2. The 960 cap is the valuation budget for a single market, so on a busy market a mint at a fresh strike can abort with EMaxPayoutTreeNodes while a mint at a strike some other position already uses succeeds. Codes 3 and 4 belong to the pool flush, and a mint or redeem cannot reach them.

A third module gates a mint on strike and size policy rather than on shape. strike_exposure_config holds the entry-probability band and the min_premium floor, and its 2 trader-facing codes, EEntryProbabilityOutOfBounds and EPremiumBelowMinimum, are the ordinary failure modes of a first mint. That table lives with the mint call, in Mint admission errors. The list of ways a well-formed mint aborts ends with 2 expiry_market codes: EMintCostAboveMaxPayout, code 11, when the all-in cost of the quote exceeds the quantity it would pay, which a deep in-the-money strike plus fees can reach; and protocol_config::ETradeWindowClosed, code 7, inside the no-trade window before expiry.

Error numbers repeat across modules, so the number alone identifies nothing: resolve an abort against the module named in the abort location. Code 3 is EInvalidQuantity in order, EReferenceTickAlreadySet in strike_exposure, EPremiumBelowMinimum in strike_exposure_config, EStaleValuationSnapshot in strike_payout_tree, and EMintProbabilityAboveMax in expiry_market. A reader who looks up 3 in the wrong table gets a plausible and wrong answer every time.

For the mint and redeem calls these ticks feed, see Predict. For cadence configuration and market creation, see Registry.