Repository Public
1/**2 * Universal derivatives router — SIMULATION ONLY (docs/v4/ROUTER.md). Pure: no network, no clock, no state, exact decimals (`D`).3 *4 * For one order intent on a base asset and one snapshot per venue instrument, it answers, per instrument:5 * - may this order be placed there at all (eligibility, with the reasons when not);6 * - what the fill would look like (order book: the book is walked level by level; GMX: oracle price, price impact from the7 * contracts' formula with the market's current parameters, capped);8 * - every cost, itemised, each with its inputs and where each input comes from;9 * - margin, maintenance rule, estimated liquidation price (margin.ts);10 * - the contract terms side by side, and the list of material differences.11 * Then a ranking by all-in cost. The ranking is INFORMATION: nothing is selected, nothing is sent, and two contracts are never12 * called the same (principle 6 of DERIVATIVES-ARCHITECTURE.md).13 *14 * Sources (principle 5). `venue`: read from the venue in the snapshot. `constant`: a documented fact pinned here with its date15 * and its page (fee schedule, deposit rule). `estimate`: computed here. Every COST is an estimate — a simulated fill is not a16 * fill; what is tagged `venue` or `constant` is its inputs.17 *18 * Rounding: products and sums are exact; divisions state their scale. Outputs are rounded half-even to USD_DP decimals (USD),19 * BPS_DP (basis points), PRICE_DP (prices). The non-integer power of the GMX impact formula is computed to 30 decimals (the20 * contracts use an 18-decimal fixed-point power; the difference is far below a cent on any size the pools accept).21 */22import { z } from 'zod';23import { D, type Dec, type DecStr } from './decimal.ts';24import { initialMargin, liquidationPriceIsolated } from './margin.ts';25import { DecStrSchema, SideSchema, type Book, type Instrument, type Side, type Ticker, type VenueId } from './models.ts';26import type { GmxLiquidity } from './venues/gmx/adapter.ts';2728export const USD_DP = 6, BPS_DP = 4, PRICE_DP = 10, CALC_DP = 30;29const BPS = D.from(10000n), TWO = D.from(2n), HUNDRED = D.from(100n), HOUR = D.from(3600n);30const q = (x: Dec, dp: number): Dec => D.div(x, D.one, dp, 'half_even');31const usdS = (x: Dec): DecStr => D.str(q(x, USD_DP));32const bpsS = (x: Dec): DecStr => D.str(q(x, BPS_DP));33const pxS = (x: Dec): DecStr => D.str(q(x, PRICE_DP));3435// ------------------------------------------------------------------------------------------------------------------ intent3637const NonNeg = DecStrSchema.refine((x) => !x.startsWith('-'), 'must not be negative');38const Pos = NonNeg.refine((x) => /[1-9]/.test(x), 'must be positive');39export const MAX_HORIZON_HOURS = 8760;40export const OrderIntentSchema = z.strictObject({41 base: z.string().regex(/^[A-Za-z0-9]{1,15}$/),42 /** side of the EXPOSURE: `long` opens a long (a buy); with `reduceOnly` it reduces an existing long (a sell) */43 side: SideSchema,44 /** exactly one of the two: a quantity of the base asset, or a USD notional */45 sizeBase: Pos.optional(), notionalUsd: Pos.optional(),46 leverage: Pos,47 type: z.enum(['market', 'limit']),48 /** limit price; must be null for a market order */49 price: Pos.nullable().default(null),50 reduceOnly: z.boolean().default(false),51 /** expected holding period, for funding and borrowing; 0 = entry costs only */52 horizonHours: NonNeg.refine((x) => D.isValid(x) && D.lte(D.from(x), D.from(BigInt(MAX_HORIZON_HOURS))), `at most ${MAX_HORIZON_HOURS} hours`).default('24'),53}).refine((i) => (i.sizeBase === undefined) !== (i.notionalUsd === undefined), { message: 'give exactly one of sizeBase and notionalUsd', path: ['sizeBase'] })54 .refine((i) => (i.type === 'limit') === (i.price !== null), { message: 'a limit order needs a price; a market order takes none', path: ['price'] });55export type OrderIntent = z.infer<typeof OrderIntentSchema>;5657// ------------------------------------------------------------------------------------------------------------------ facts5859export type RouterSource = 'venue' | 'constant' | 'estimate';60export const ROUTER_FACTS_CHECKED = '2026-11-10';6162/** documented facts the router needs and no adapter carries; each with its page. Checked on ROUTER_FACTS_CHECKED (docs/v4/venues/). */63export interface VenueRouterFacts {64 name: string; orderBook: boolean;65 /** base fee tier, fractions of notional; null when the venue has no maker/taker schedule (GMX: read from the market) */66 takerRate: DecStr | null; makerRate: DecStr | null; feeSource: string;67 /** maintenance margin as a fraction of notional; 'half_initial_at_max' = 1 / (2 · max leverage) */68 maintenance: 'half_initial_at_max' | 'gmx_min_collateral' | DecStr | null; maintenanceText: string;69 integerLeverage: boolean;70 terms: Record<'structure' | 'execution' | 'quote' | 'fundingRule' | 'otherCarry' | 'sizeUnit' | 'liquidation' | 'fees', string>;71 /** what moving collateral there takes (facts, not a quote) */72 collateral: { asset: string; route: string; fixedCostUsd: DecStr | null; needsArbitrumTransfer: boolean; needsArbitrumRequest: boolean; source: string };73}74export const ROUTER_FACTS: Record<VenueId, VenueRouterFacts> = {75 hyperliquid: {76 name: 'Hyperliquid', orderBook: true, takerRate: '0.00045', makerRate: '0.00015',77 feeSource: 'base tier of the perps fee schedule (taker 0.045 %, maker 0.015 %); 14-day volume tiers and staking discounts lower it — https://hyperliquid.gitbook.io/hyperliquid-docs/trading/fees',78 maintenance: 'half_initial_at_max', maintenanceText: 'half of the initial margin at the maximum leverage (1 / (2 × max leverage) of notional); tiered by position size — https://hyperliquid.gitbook.io/hyperliquid-docs/trading/margining',79 integerLeverage: true,80 terms: {81 structure: 'on-chain central limit order book',82 execution: 'immediate match against the book',83 quote: 'USDT-denominated oracle, USDC collateral, no conversion (quanto: USDT PnL paid in USDC)',84 fundingRule: 'paid every hour, peer to peer, on size × oracle price × rate; the hourly rate is 1/8 of an 8 h rate; cap 0.5 % per hour',85 otherCarry: 'none',86 sizeUnit: 'base asset, lot 10^-szDecimals',87 liquidation: 'on the mark price, through the book, then the liquidator vault (maintenance margin not returned in a backstop); no separate fee',88 fees: 'taker / maker on notional, 14-day volume tiers',89 },90 collateral: { asset: 'USDC', route: 'native USDC sent from the account address on Arbitrum One to the venue bridge (legacy route; Circle CCTP is now the venue\'s preferred one). Minimum 5 USDC — a smaller deposit is lost. No deposit fee documented; a later withdrawal costs 1 USDC.',91 fixedCostUsd: '0', needsArbitrumTransfer: true, needsArbitrumRequest: false, source: 'docs/v4/venues/hyperliquid.md §8, §14 — https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/bridge2' },92 },93 dydx: {94 name: 'dYdX v4', orderBook: true, takerRate: '0.0005', makerRate: '0.0001',95 feeSource: 'base tier (taker 0.05 %, maker 0.01 %) — https://docs.dydx.xyz/concepts/trading/rewards',96 maintenance: null, maintenanceText: 'maintenance margin fraction per market (1.2 % for BTC and ETH), on the oracle price — https://docs.dydx.xyz/concepts/trading/margin',97 integerLeverage: false,98 terms: {99 structure: 'on-chain order book; short-term orders live in validator memory',100 execution: 'immediate match (block time about 1 s)',101 quote: 'USD oracle; PnL and funding in USDC',102 fundingRule: 'paid every hour on size × oracle price × rate; rate = premium / 8 + interest',103 otherCarry: 'none',104 sizeUnit: 'base asset, lot per market',105 liquidation: 'on the oracle price; penalty up to 1.5 % to the insurance fund',106 fees: 'taker / maker on notional, 30-day volume tiers',107 },108 collateral: { asset: 'USDC (Noble USDC over IBC)', route: 'not offered: the venue is restricted for the owner', fixedCostUsd: null, needsArbitrumTransfer: false, needsArbitrumRequest: false, source: 'docs/v4/venues/dydx.md' },109 },110 gmx: {111 name: 'GMX v2 (Arbitrum)', orderBook: false, takerRate: null, makerRate: null,112 feeSource: 'position fee factors of the market, read from the venue (GET /markets/config) — https://docs.gmx.io/docs/trading/fees/',113 maintenance: 'gmx_min_collateral', maintenanceText: 'remaining collateral (after PnL, fees and capped negative impact) must stay above max($1, minCollateralFactorForLiquidation × size) — https://docs.gmx.io/docs/trading/liquidations/',114 integerLeverage: false,115 terms: {116 structure: 'no order book: oracle-priced trades against a GM liquidity pool; one instrument per pool',117 execution: 'two transactions: a request, then a keeper executes at a later signed oracle price; the order can be cancelled or frozen instead',118 quote: 'PnL computed in USD, paid in the pool token of the side',119 fundingRule: 'accrues per second (quoted per hour), adaptive to the long/short imbalance; settled at each position change; positive funding must be claimed',120 otherCarry: 'borrowing fee, paid by the larger open-interest side only',121 sizeUnit: 'USD (30 decimals); the token size results from the execution price',122 liquidation: 'on the signed oracle min/max; liquidation fee 0.20–0.45 % of size; remaining collateral returned',123 fees: 'position fee on size: lower factor if the trade improves the long/short balance; price impact settled at close; execution fee in ETH; Arbitrum gas',124 },125 collateral: { asset: 'the pool\'s long or short token', route: 'no venue account: the collateral must be in the wallet on Arbitrum One (USDC or the pool token) and approved once to the GMX Router; getting it to Arbitrum from elsewhere depends on where the funds are',126 fixedCostUsd: null, needsArbitrumTransfer: false, needsArbitrumRequest: true, source: 'docs/v4/venues/gmx.md Part B §3–§4 — https://docs.gmx.io/docs/trading/overview/' },127 },128};129130/**131 * Gas units assumed for the two Arbitrum transactions the router prices — MEASURED samples, not venue figures132 * (scripts/sim-deriv-router.mts prints fresh receipts; docs/v4/ROUTER.md records the measurement).133 */134export const ARBITRUM_GAS_UNITS = {135 /** GMX market-increase request sent to ExchangeRouter (Classic): 10 receipts of 2026-11-11, 833 133 – 1 740 858 gas, median 937 922 */136 gmxCreateOrder: '1000000',137 /** plain native-USDC transfer: 13 receipts of 2026-11-11 over three runs, 40 343 – 62 289 gas, median about 40 400 (the upper end is used) */138 erc20Transfer: '62000',139} as const;140/** half-widths used for "near tie" — conventions, stated as such (docs/v4/ROUTER.md, "Uncertainty") */141export const UNCERTAINTY = { priceImpactFraction: '1', executionFeeFraction: '0.5', gasFraction: '0.5', carryFractionWithoutHistory: '1' } as const;142/** a snapshot older than this (ms) is refused: the router does not estimate on stale data */143export const ROUTER_MAX_AGE_MS = { ticker: 15_000, book: 15_000, liquidity: 30_000 } as const;144145// ------------------------------------------------------------------------------------------------------------------ snapshots146147export interface RouterAccount { id: string; label: string; kind: 'paper' | 'venue'; mode: string; equity: DecStr; freeCollateral: DecStr }148export interface ChainCosts {149 /** Arbitrum gas price in ETH per unit of gas (wei / 1e18), and the ETH price used for the conversion, both from the snapshot */150 gasPriceEth: DecStr | null; ethUsd: DecStr | null; ethUsdSource: string;151 /** GMX: minimum execution fee the contracts require for a market increase at this gas price, in ETH (GasUtils formula on DataStore values), or null */152 gmxExecutionFeeEth: DecStr | null; gmxExecutionFeeNote: string;153 ts: number;154}155export interface VenueSnapshot {156 venue: VenueId;157 /** non-null: the venue is restricted for the owner — never a candidate */158 restriction: string | null;159 /** what this wallet can do on the venue today */160 wallet: { trading: 'paper' | 'live' | 'none'; note: string };161 instrument: Instrument; ticker: Ticker;162 /** order-book venues; `bookLevels` = levels per side the snapshot was cut at (to say "the snapshot ends") */163 book: Book | null;164 /** GMX */165 gmx: GmxLiquidity | null;166 /** last hours of the hourly funding rate (what longs pay), when the caller has them */167 fundingRange: { minPerHour: DecStr; maxPerHour: DecStr; hours: number } | null;168 chain: ChainCosts | null;169 account: RouterAccount | null;170 /** the caller could not read the book / liquidity of this instrument: the reason (the instrument is then not estimated) */171 unavailable?: string | null;172}173174// ------------------------------------------------------------------------------------------------------------------ results175176export interface Input { name: string; value: DecStr | string | null; source: RouterSource }177export type CostId = 'spread_slippage' | 'trading_fee' | 'price_impact' | 'execution_fee' | 'network_gas' | 'funding' | 'borrow' | 'collateral_transfer';178export interface CostComponent {179 id: CostId;180 /** positive = a cost, negative = an income; null = unknown (then it is NOT in the total, and the total says so) */181 usd: DecStr | null; bps: DecStr | null;182 source: 'estimate';183 inputs: Input[];184 basis: string;185 /** half-width used for ties, USD; and what could move it */186 uncertaintyUsd: DecStr; uncertainty: string;187 /** when it is paid */188 timing: 'at_fill' | 'over_horizon' | 'at_close' | 'before_trading';189}190export interface FillSimulation {191 method: 'order_book_walk' | 'gmx_oracle_impact';192 /** reference price the slippage is measured from: book mid, or oracle mid */193 referencePrice: DecStr; referenceSource: RouterSource;194 filledBase: DecStr; filledUsd: DecStr;195 avgPrice: DecStr | null; worstPrice: DecStr | null; slippageBps: DecStr | null;196 /** order book */197 levelsConsumed: number | null; depthConsumedPct: DecStr | null; snapshotLevels: number | null;198 /** part of the size the snapshot could not fill, and why */199 unfilledBase: DecStr; unfilledReason: 'none' | 'book_snapshot_ends' | 'limit_price' | 'not_marketable';200 /** part of a limit order that would rest on the book (not a fill) */201 restingBase: DecStr;202 /** GMX */203 gmx: null | {204 oracleMin: DecStr; oracleMax: DecStr; executionBasePrice: DecStr;205 impactUsd: DecStr; impactCappedUsd: DecStr; capPositiveUsd: DecStr; capNegativeUsd: DecStr; capped: boolean;206 balanceWasImproved: boolean; crossover: boolean; imbalanceBeforeUsd: DecStr; imbalanceAfterUsd: DecStr; openInterestBasis: 'tokens × oracle mid' | 'USD cost basis';207 /** 'none': the market has no virtual inventory; 'known': it was read and applied; 'unknown': it exists and could not be read */208 virtualInventory: 'none' | 'known' | 'unknown'; fromVirtualInventory: boolean;209 verification: string;210 };211 source: 'estimate'; notes: string[];212}213export interface MarginEstimate {214 notionalUsd: DecStr; leverage: DecStr; initialMarginUsd: DecStr;215 maintenanceFraction: DecStr | null; maintenanceRule: string; maintenanceSource: RouterSource;216 liquidationPrice: { value: DecStr | null; source: 'estimate'; method: string }; liquidationDistanceBps: DecStr | null;217}218export type ReasonKind = 'blocker' | 'setup' | 'note';219export interface Reason { code: string; kind: ReasonKind; message: string }220export type TermKey = 'structure' | 'execution' | 'collateral' | 'settlement' | 'quote' | 'markPrice' | 'fundingInterval' | 'fundingRule' | 'otherCarry' | 'maxLeverage' | 'marginModes'221 | 'tick' | 'lot' | 'sizeUnit' | 'minOrder' | 'maintenance' | 'liquidation' | 'fees';222export const TERM_KEYS: TermKey[] = ['structure', 'execution', 'collateral', 'settlement', 'quote', 'markPrice', 'fundingInterval', 'fundingRule', 'otherCarry', 'maxLeverage', 'marginModes',223 'tick', 'lot', 'sizeUnit', 'minOrder', 'maintenance', 'liquidation', 'fees'];224export type ContractTerms = Record<TermKey, { value: string; source: RouterSource }>;225226export interface ExecutionEstimate {227 venue: VenueId; venueName: string; instrument: string; symbol: string;228 eligibility: {229 /** false: a blocker applies — the instrument is not ranked */230 eligible: boolean;231 /** true only when the wallet could place this order there today, in the mode stated (`paper` or `live`) */232 executableNow: boolean; executableAs: 'paper' | 'live' | null;233 reasons: Reason[];234 };235 order: null | { side: 'buy' | 'sell'; positionSide: Side; reduceOnly: boolean; type: 'market' | 'limit'; limitPrice: DecStr | null;236 sizeUnit: 'base' | 'usd'; sizeBase: DecStr; sizeUsd: DecStr; requested: string; rounding: string | null };237 fill: FillSimulation | null;238 costs: CostComponent[];239 margin: MarginEstimate | null;240 terms: ContractTerms;241 /** all-in cost of THIS order over the horizon: entry costs + carry. Unknown components are listed, not guessed. */242 allIn: null | { usd: DecStr; bps: DecStr; uncertaintyUsd: DecStr; included: CostId[]; unknown: CostId[]; horizonHours: DecStr; complete: boolean; note: string };243 /** closing the same size right away under the same snapshot — information, not part of the all-in cost */244 exit: null | { usd: DecStr; bps: DecStr; /** all-in + exit */ roundTripUsd: DecStr; roundTripBps: DecStr; parts: { id: CostId; usd: DecStr; basis: string }[]; note: string };245 data: { tickerAgeMs: number; depthAgeMs: number | null };246 source: 'estimate'; ts: number;247}248export interface RankingRow { rank: number; instrument: string; venue: VenueId; allInUsd: DecStr; allInBps: DecStr; uncertaintyUsd: DecStr; complete: boolean; executableNow: boolean;249 tiedWith: string[]; nearTieWith: string[];250 /** all-in + an immediate close under the same snapshot, and the place this instrument would take on that figure (null: no exit estimate) */251 roundTripUsd: DecStr | null; roundTripRank: number | null }252export interface Ranking {253 rows: RankingRow[];254 /** always null: the router never selects a venue */255 selected: null;256 basis: string; note: string;257 /** true when ordering by the round trip (entry + carry + immediate close) gives another order than the all-in cost */258 orderChangesWithExit: boolean;259 excluded: { instrument: string; venue: VenueId; reasons: string[] }[];260}261export interface Difference { field: TermKey; values: { instrument: string; venue: VenueId; value: string; source: RouterSource }[] }262export interface Differences {263 /** always false: two perpetuals on the same asset are never the same contract */264 interchangeable: false;265 statement: string; fields: Difference[]; never: string[];266}267export interface RouteResult { intent: OrderIntent; candidates: ExecutionEstimate[]; ranking: Ranking; differences: Differences; warnings: string[]; ts: number; source: 'estimate' }268269// ------------------------------------------------------------------------------------------------------------------ exact power270271const PW = 50, ONE_W = 10n ** BigInt(PW);272const mulW = (a: bigint, b: bigint): bigint => (a * b) / ONE_W;273/** ln of a positive fixed-point number (scale 10^PW): range reduction by powers of two, then 2·atanh((x−1)/(x+1)) */274function lnW(x: bigint): bigint {275 if (x <= 0n) throw new RangeError('ln of a non-positive number');276 let k = 0n;277 while (x > (ONE_W * 3n) / 2n) { x /= 2n; k += 1n; }278 while (x < (ONE_W * 3n) / 4n) { x *= 2n; k -= 1n; }279 return atanh2((x - ONE_W) * ONE_W / (x + ONE_W)) + k * LN2();280}281function atanh2(z: bigint): bigint {282 const z2 = mulW(z, z);283 let term = z, sum = z;284 for (let n = 3n; n < 400n; n += 2n) { term = mulW(term, z2); const t = term / n; if (t === 0n) break; sum += t; }285 return 2n * sum;286}287let ln2: bigint | null = null;288const LN2 = (): bigint => (ln2 ??= atanh2(ONE_W / 3n));289function expW(y: bigint): bigint {290 const l2 = LN2();291 let k = y / l2; let r = y - k * l2;292 if (r > l2 / 2n) { r -= l2; k += 1n; } else if (r < -l2 / 2n) { r += l2; k -= 1n; }293 let term = ONE_W, sum = ONE_W;294 for (let n = 1n; n < 200n; n += 1n) { term = mulW(term, r) / n; if (term === 0n) break; sum += term; }295 return k >= 0n ? sum << k : sum >> -k;296}297/**298 * x^e for x > 0, rounded half-even to CALC_DP decimals. An integer exponent up to 8 is multiplied out exactly; otherwise299 * exp(e · ln x) on 50-digit fixed point (error far below the 30th decimal for the magnitudes used here: x ≤ 10^15, e ≤ 4).300 */301export function powDec(x: Dec, e: Dec): Dec {302 if (!D.isPos(x)) throw new RangeError('powDec needs a positive base');303 if (D.isNeg(e)) throw new RangeError('powDec needs a non-negative exponent');304 if (e.s === 0 && e.m <= 8n) { let r = D.one; for (let i = 0n; i < e.m; i += 1n) r = D.mul(r, x); return r; }305 // `D.div` normalises its result (trailing zeros stripped): bring the mantissa back to the working scale306 const toW = (v: Dec): bigint => { const r = D.div(v, D.one, PW, 'half_even'); return r.m * 10n ** BigInt(PW - r.s); };307 const fx = toW(x), fe = toW(e);308 return q({ m: expW(mulW(fe, lnW(fx))), s: PW }, CALC_DP);309}310311// ------------------------------------------------------------------------------------------------------------------ GMX price impact312313export interface GmxImpactInput {314 /** open interest per side as the contracts measure the balance (tokens × price when the market says so), USD */315 longOi: Dec; shortOi: Dec;316 /** USD moved on the side's open interest. For a DECREASE on a tokens-based market this is the position's tokens × the current price, not its cost basis. */317 sizeDeltaUsd: Dec; isLong: boolean; increase: boolean;318 factorPositive: Dec; factorNegative: Dec; exponentPositive: Dec; exponentNegative: Dec;319 /** virtual inventory of the index token in USD, contract sign (negative = traders net long); null/undefined = the market has none, or it is unknown */320 virtualInventoryUsd?: Dec | null;321}322export interface GmxImpact { impactUsd: Dec; balanceWasImproved: boolean; crossover: boolean; initialDiffUsd: Dec; nextDiffUsd: Dec;323 /** the figure comes from the virtual inventory (it was worse than the market's own) */ fromVirtualInventory: boolean; marketImpactUsd: Dec }324/** `Precision.applyExponentFactor` then `applyFactor`: a difference below 1 USD contributes nothing */325function applyImpactFactor(diffUsd: Dec, factor: Dec, exponent: Dec): Dec {326 if (D.lt(diffUsd, D.one)) return D.zero;327 return D.mul(powDec(diffUsd, exponent), factor);328}329/**330 * Position price impact of GMX v2, in USD (positive = in favour of the trader), before any cap.331 * Source: gmx-synthetics `PositionPricingUtils.getPriceImpactUsd` / `_getPriceImpactUsd` and `PricingUtils`332 * (https://github.com/gmx-io/gmx-synthetics/blob/updates/contracts/pricing/PositionPricingUtils.sol , PricingUtils.sol),333 * documented as "(initial USD difference)^exponent × factor − (next USD difference)^exponent × factor"334 * (https://docs.gmx.io/docs/trading/fees/). Same-side move: one factor (positive when the imbalance shrinks). Crossing the335 * balance point: the initial difference is credited at the positive factor, the next one charged at the negative factor.336 * The positive factor is never above the negative one (`getAdjustedPositionImpactFactor`).337 * Virtual inventory (`getNextOpenInterestForVirtualInventory`): when the market's own impact is NEGATIVE and the index token has338 * a virtual inventory, the impact is computed a second time on that inventory (one side only: long when it is negative, short339 * when positive; both sides shifted by the size for a decrease) and the WORSE of the two applies.340 * NOT modelled: the impact pool balance (it can only cut a positive impact).341 */342export function gmxPriceImpact(i: GmxImpactInput): GmxImpact {343 const own = impactOn(i, i.longOi, i.shortOi);344 const base = { ...own, fromVirtualInventory: false, marketImpactUsd: own.impactUsd };345 if (!D.isNeg(own.impactUsd) || i.virtualInventoryUsd === null || i.virtualInventoryUsd === undefined) return base;346 let vLong = D.isPos(i.virtualInventoryUsd) ? D.zero : D.neg(i.virtualInventoryUsd), vShort = D.isPos(i.virtualInventoryUsd) ? i.virtualInventoryUsd : D.zero;347 if (!i.increase) { vLong = D.add(vLong, i.sizeDeltaUsd); vShort = D.add(vShort, i.sizeDeltaUsd); }348 const virt = impactOn(i, vLong, vShort);349 // the market's own imbalance decides the fee tier (balanceWasImproved); only the amount comes from the virtual inventory350 return D.lt(virt.impactUsd, own.impactUsd) ? { ...base, impactUsd: virt.impactUsd, fromVirtualInventory: true } : base;351}352function impactOn(i: GmxImpactInput, longOi: Dec, shortOi: Dec): Omit<GmxImpact, 'fromVirtualInventory' | 'marketImpactUsd'> {353 const delta = i.increase ? i.sizeDeltaUsd : D.neg(i.sizeDeltaUsd);354 const zero = (x: Dec): Dec => (D.isNeg(x) ? D.zero : x);355 const nextLong = i.isLong ? zero(D.add(longOi, delta)) : longOi, nextShort = i.isLong ? shortOi : zero(D.add(shortOi, delta));356 const d0 = D.abs(D.sub(longOi, shortOi)), d1 = D.abs(D.sub(nextLong, nextShort));357 const sameSide = D.lte(longOi, shortOi) === D.lte(nextLong, nextShort);358 const improved = D.lt(d1, d0), fPos = D.min(i.factorPositive, i.factorNegative);359 if (sameSide) {360 const f = improved ? fPos : i.factorNegative, e = improved ? i.exponentPositive : i.exponentNegative;361 const diff = D.abs(D.sub(applyImpactFactor(d0, f, e), applyImpactFactor(d1, f, e)));362 return { impactUsd: improved ? diff : D.neg(diff), balanceWasImproved: improved, crossover: false, initialDiffUsd: d0, nextDiffUsd: d1 };363 }364 const pos = applyImpactFactor(d0, fPos, i.exponentPositive), neg = applyImpactFactor(d1, i.factorNegative, i.exponentNegative);365 return { impactUsd: D.sub(pos, neg), balanceWasImproved: improved, crossover: true, initialDiffUsd: d0, nextDiffUsd: d1 };366}367/**368 * Open interest per side as the market measures its balance, and the virtual inventory in the same unit.369 * `virtual`: 'none' = the market has no virtual inventory; 'known' = `virtualUsd` is its value; 'unknown' = it has one (or does370 * not say) and the value could not be read — a negative impact may then be WORSE than the formula says.371 */372export function gmxBalanceOi(l: GmxLiquidity): { long: Dec; short: Dec; basis: 'tokens × oracle mid' | 'USD cost basis'; virtual: 'none' | 'known' | 'unknown'; virtualUsd: Dec | null } {373 const tokens = l.useOpenInterestInTokensForBalance === true, v = l.virtualInventory;374 const mid = D.div(D.add(D.from(l.oracleMin), D.from(l.oracleMax)), TWO, CALC_DP, 'half_even');375 const raw = tokens ? v.tokens : v.usd;376 const virtual = v.applies === false ? 'none' as const : raw === null || v.applies === null ? 'unknown' as const : 'known' as const;377 const virtualUsd = virtual !== 'known' ? null : tokens ? D.mul(D.from(raw!), mid) : D.from(raw!);378 return tokens ? { long: D.mul(D.from(l.openInterestLongTokens), mid), short: D.mul(D.from(l.openInterestShortTokens), mid), basis: 'tokens × oracle mid', virtual, virtualUsd }379 : { long: D.from(l.openInterestLongUsd), short: D.from(l.openInterestShortUsd), basis: 'USD cost basis', virtual, virtualUsd };380}381export const GMX_IMPACT_VERIFICATION = 'formula reproduced to six digits against the venue\'s own closePriceImpactDeltaUsd of real open positions, virtual inventory included (recorded fixtures and live run of scripts/sim-deriv-router.mts); the venue publishes no figure for an order that is not placed, so the OPENING leg — the same function with the size added instead of removed — is not itself verified';382383// ------------------------------------------------------------------------------------------------------------------ book walk384385export interface BookWalk { filled: Dec; cost: Dec; avg: Dec | null; worst: Dec | null; levels: number; sideTotal: Dec; remainder: Dec; stoppedBy: 'filled' | 'book_snapshot_ends' | 'limit_price' }386/** walks one side of the book for `size`, never beyond `limit`; exact */387export function walkBook(levels: readonly [DecStr, DecStr][], size: Dec, side: 'buy' | 'sell', limit: Dec | null): BookWalk {388 let left = size, cost = D.zero, worst: Dec | null = null, used = 0, total = D.zero, stopped: BookWalk['stoppedBy'] = 'book_snapshot_ends';389 for (const lv of levels) total = D.add(total, D.from(lv[1]));390 for (const [p, s] of levels) {391 if (!D.isPos(left)) break;392 const px = D.from(p), sz = D.from(s);393 if (limit !== null && (side === 'buy' ? D.gt(px, limit) : D.lt(px, limit))) { stopped = 'limit_price'; break; }394 if (!D.isPos(sz)) continue;395 const take = D.min(left, sz);396 cost = D.add(cost, D.mul(take, px)); left = D.sub(left, take); worst = px; used += 1;397 }398 const filled = D.sub(size, left);399 return { filled, cost, avg: D.isPos(filled) ? D.div(cost, filled, CALC_DP, 'half_even') : null, worst, levels: used, sideTotal: total, remainder: left, stoppedBy: D.isPos(left) ? stopped : 'filled' };400}401402// ------------------------------------------------------------------------------------------------------------------ one venue403404const perHour = (rate: DecStr, intervalSec: number): Dec => D.div(D.mul(D.from(rate), HOUR), D.from(BigInt(intervalSec)), CALC_DP, 'half_even');405const intervalText = (sec: number): string => (sec % 3600 === 0 ? `${sec / 3600} h` : `${sec} s`);406const bpsOf = (x: Dec, notional: Dec): Dec => (D.isPos(notional) ? D.div(D.mul(x, BPS), notional, CALC_DP, 'half_even') : D.zero);407408function termsOf(s: VenueSnapshot): ContractTerms {409 const i = s.instrument, f = ROUTER_FACTS[s.venue];410 const v = (value: string): { value: string; source: RouterSource } => ({ value, source: 'venue' }), c = (value: string): { value: string; source: RouterSource } => ({ value, source: 'constant' });411 return {412 structure: c(f.terms.structure), execution: c(f.terms.execution), collateral: v(i.collateral), settlement: v(i.settlement), quote: c(f.terms.quote), markPrice: v(i.markPriceMethod),413 fundingInterval: v(s.venue === 'gmx' ? `continuous (per second), quoted per ${intervalText(i.fundingIntervalSec)}` : `every ${intervalText(i.fundingIntervalSec)}`),414 fundingRule: c(f.terms.fundingRule), otherCarry: c(f.terms.otherCarry),415 maxLeverage: v(s.venue === 'gmx' ? `${i.maxLeverage}× (contract bound; lower in practice and falling with open interest)` : `${i.maxLeverage}×`),416 marginModes: v(i.marginModes.join(', ')), tick: v(i.tickSize), lot: v(i.lotSize), sizeUnit: c(f.terms.sizeUnit),417 minOrder: v(i.minNotional !== null ? `${i.minNotional} USD notional` : i.minOrderSize !== null ? `${i.minOrderSize} ${i.base}` : 'none published'),418 maintenance: c(f.maintenanceText), liquidation: c(f.terms.liquidation), fees: c(f.terms.fees),419 };420}421422interface Ctx { intent: OrderIntent; now: number; maxAge: { ticker: number; book: number; liquidity: number } }423424function carryUncertainty(s: VenueSnapshot, notional: Dec, hours: Dec, amount: Dec): { usd: Dec; text: string } {425 if (s.fundingRange && s.fundingRange.hours > 0) {426 const half = D.div(D.sub(D.from(s.fundingRange.maxPerHour), D.from(s.fundingRange.minPerHour)), TWO, CALC_DP, 'up');427 return { usd: D.mul(D.mul(notional, hours), half), text: `rates change: half the range of the hourly rate over the last ${s.fundingRange.hours} h (${s.fundingRange.minPerHour} … ${s.fundingRange.maxPerHour}) applied to the whole horizon` };428 }429 return { usd: D.mul(D.abs(amount), D.from(UNCERTAINTY.carryFractionWithoutHistory)), text: 'rates change and no history was supplied: ±100 % of the amount (convention)' };430}431432/** estimate for one instrument. Never throws on market conditions: what cannot be estimated is a reason. */433export function estimateVenue(s: VenueSnapshot, c: Ctx): ExecutionEstimate {434 const { intent, now } = c, inst = s.instrument, facts = ROUTER_FACTS[s.venue];435 const reasons: Reason[] = [], add = (code: string, kind: ReasonKind, message: string): void => { reasons.push({ code, kind, message }); };436 const tickerAge = now - s.ticker.ts, depthTs = s.book?.ts ?? s.gmx?.ts ?? null, depthAge = depthTs === null ? null : now - depthTs;437 const base: ExecutionEstimate = {438 venue: s.venue, venueName: facts.name, instrument: inst.id, symbol: inst.symbol,439 eligibility: { eligible: false, executableNow: false, executableAs: null, reasons }, order: null, fill: null, costs: [], margin: null, terms: termsOf(s), allIn: null, exit: null,440 data: { tickerAgeMs: tickerAge, depthAgeMs: depthAge }, source: 'estimate', ts: now,441 };442 const done = (): ExecutionEstimate => {443 const blocked = reasons.some((r) => r.kind === 'blocker'), setup = reasons.some((r) => r.kind === 'setup');444 base.eligibility.eligible = !blocked;445 base.eligibility.executableNow = !blocked && !setup && s.wallet.trading !== 'none';446 base.eligibility.executableAs = base.eligibility.executableNow ? (s.wallet.trading as 'paper' | 'live') : null;447 return base;448 };449450 // ---- venue-level refusals: nothing is estimated behind them except, for a restricted venue, the public figures451 if (s.restriction !== null) add('venue_restricted', 'blocker', s.restriction);452 if (inst.status === 'halted') { add('instrument_halted', 'blocker', `${inst.id} is halted on the venue`); return done(); }453 if (inst.status === 'reduce_only' && !intent.reduceOnly) add('instrument_reduce_only', 'blocker', `${inst.id} only accepts orders that reduce a position`);454 if (!Number.isFinite(tickerAge) || Math.abs(tickerAge) > c.maxAge.ticker) { add('stale_data', 'blocker', `the ticker is ${Math.round(tickerAge / 1000)} s old (limit ${c.maxAge.ticker / 1000} s): no estimate on stale data`); return done(); }455 if (s.unavailable) { add('data_unavailable', 'blocker', `the ${facts.orderBook ? 'order book' : 'liquidity figures'} of ${inst.id} could not be read: ${s.unavailable}`); return done(); }456 const depthLimit = s.venue === 'gmx' ? c.maxAge.liquidity : c.maxAge.book;457 if (facts.orderBook ? s.book === null : s.gmx === null) { add('no_depth_data', 'blocker', facts.orderBook ? 'no order book snapshot: nothing to walk' : 'no liquidity snapshot: no parameters to price from'); return done(); }458 if (depthAge === null || Math.abs(depthAge) > depthLimit) { add('stale_data', 'blocker', `the ${facts.orderBook ? 'book' : 'liquidity'} snapshot is ${Math.round((depthAge ?? 0) / 1000)} s old (limit ${depthLimit / 1000} s): no estimate on stale data`); return done(); }459460 const lev = D.from(intent.leverage), hours = D.from(intent.horizonHours), long = intent.side === 'long';461 const buy = long !== intent.reduceOnly, side: 'buy' | 'sell' = buy ? 'buy' : 'sell';462 const limit = intent.price === null ? null : D.from(intent.price);463 if (D.gt(lev, D.from(inst.maxLeverage)) && !intent.reduceOnly) add('leverage_above_venue_max', 'blocker', `leverage ${intent.leverage}× is above the venue maximum ${inst.maxLeverage}× for ${inst.id}`);464 if (facts.integerLeverage && !intent.reduceOnly && (!D.isMultiple(lev, D.one) || D.lt(lev, D.one))) add('leverage_not_integer', 'blocker', `leverage on ${facts.name} is an integer of at least 1`);465 if (s.wallet.trading === 'none') add('wallet_cannot_trade_here', 'setup', s.wallet.note);466 else add(s.wallet.trading === 'paper' ? 'paper_only' : 'live_available', 'note', s.wallet.note);467468 const costs: CostComponent[] = [];469 const cost = (id: CostId, usd: Dec | null, notional: Dec, x: Omit<CostComponent, 'id' | 'usd' | 'bps' | 'source' | 'uncertaintyUsd'> & { unc: Dec }): void => {470 const { unc, ...rest } = x;471 costs.push({ id, usd: usd === null ? null : usdS(usd), bps: usd === null ? null : bpsS(bpsOf(usd, notional)), source: 'estimate', uncertaintyUsd: usdS(unc), ...rest });472 };473 let notional: Dec, sizeBase: Dec, entry: Dec | null = null, feeRateClose: Dec = D.zero, gmxMaint: Dec | null = null, openFee = D.zero;474 const exitParts: { id: CostId; usd: Dec; basis: string }[] = [];475476 if (facts.orderBook) {477 // ================================================================ order book478 const book = s.book!, bb = book.bids[0], ba = book.asks[0];479 if (!bb || !ba) { add('empty_book', 'blocker', 'one side of the book is empty: no mid price, nothing to walk'); return done(); }480 const bid = D.from(bb[0]), ask = D.from(ba[0]), mid = D.div(D.add(bid, ask), TWO, CALC_DP, 'half_even'), lot = D.from(inst.lotSize);481 const want = intent.sizeBase !== undefined ? D.from(intent.sizeBase) : D.div(D.from(intent.notionalUsd!), mid, CALC_DP, 'down');482 sizeBase = D.quantize(want, lot, 'down');483 const rounding = D.eq(sizeBase, want) ? null : `rounded down to this venue's lot ${inst.lotSize} ${inst.base}`;484 notional = D.mul(sizeBase, mid);485 base.order = { side, positionSide: intent.side, reduceOnly: intent.reduceOnly, type: intent.type, limitPrice: intent.price, sizeUnit: 'base', sizeBase: D.str(sizeBase), sizeUsd: usdS(notional),486 requested: intent.sizeBase !== undefined ? `${intent.sizeBase} ${inst.base}` : `${intent.notionalUsd} USD at the book mid ${pxS(mid)}`, rounding };487 if (!D.isPos(sizeBase)) { add('size_rounds_to_zero', 'blocker', `the size is below one lot (${inst.lotSize} ${inst.base}) on ${facts.name}`); return done(); }488 if (inst.minOrderSize !== null && D.lt(sizeBase, D.from(inst.minOrderSize))) add('below_min_size', 'blocker', `size ${D.str(sizeBase)} ${inst.base} is below the venue minimum ${inst.minOrderSize}`);489 if (inst.minNotional !== null && D.lt(notional, D.from(inst.minNotional))) add('below_min_notional', 'blocker', `notional ${usdS(notional)} USD is below the venue minimum ${inst.minNotional} USD`);490 if (limit !== null && !D.isMultiple(limit, D.from(inst.tickSize))) add('price_not_on_tick', 'blocker', `the limit price ${intent.price} is not a multiple of the finest tick ${inst.tickSize}`);491492 const levels = buy ? book.asks : book.bids, w = walkBook(levels, sizeBase, side, limit);493 const marketable = D.isPos(w.filled);494 const resting = intent.type === 'limit' ? w.remainder : D.zero;495 const unfilled = intent.type === 'market' ? w.remainder : D.zero;496 const notes: string[] = [];497 let unfilledReason: FillSimulation['unfilledReason'] = 'none';498 if (intent.type === 'market' && D.isPos(w.remainder)) {499 unfilledReason = 'book_snapshot_ends';500 add('book_snapshot_ends', 'blocker', `the book snapshot ends after ${levels.length} levels (${D.str(w.sideTotal)} ${inst.base}); ${D.str(w.remainder)} ${inst.base} of the order cannot be priced from it. The venue may hold more depth beyond the snapshot — it is not extrapolated.`);501 notes.push('book snapshot ends: the remainder is NOT priced');502 }503 if (intent.type === 'limit' && D.isPos(resting)) {504 unfilledReason = marketable ? 'limit_price' : 'not_marketable';505 const why = w.stoppedBy === 'book_snapshot_ends' ? 'the book snapshot ends before the limit price' : 'the next level is beyond the limit price';506 notes.push(`${D.str(resting)} ${inst.base} would REST on the book at ${intent.price} (${why}): it is not a fill. Its costs below assume it fills later at the limit price with the maker fee — not guaranteed.`);507 add('limit_rests', 'note', `${D.str(resting)} ${inst.base} of the limit order would rest on the book: a resting order may never fill`);508 }509 // the whole order valued: immediate fills at their prices, the resting part at the limit price510 const valued = D.add(w.cost, D.mul(resting, limit ?? D.zero)), sized = D.add(w.filled, resting);511 entry = D.isPos(sized) ? D.div(valued, sized, CALC_DP, 'half_even') : null;512 const slip = entry === null ? D.zero : D.mul(sized, buy ? D.sub(entry, mid) : D.sub(mid, entry));513 const half = D.div(D.sub(ask, bid), TWO, CALC_DP, 'half_even');514 base.fill = {515 method: 'order_book_walk', referencePrice: pxS(mid), referenceSource: 'venue', filledBase: D.str(w.filled), filledUsd: usdS(w.cost),516 avgPrice: w.avg === null ? null : pxS(w.avg), worstPrice: w.worst === null ? null : pxS(w.worst),517 slippageBps: w.avg === null ? null : bpsS(D.div(D.mul(buy ? D.sub(w.avg, mid) : D.sub(mid, w.avg), BPS), mid, CALC_DP, 'half_even')),518 levelsConsumed: w.levels, depthConsumedPct: D.isPos(w.sideTotal) ? D.str(q(D.div(D.mul(w.filled, HUNDRED), w.sideTotal, CALC_DP, 'half_even'), 4)) : null, snapshotLevels: levels.length,519 unfilledBase: D.str(unfilled), unfilledReason, restingBase: D.str(resting), gmx: null, source: 'estimate', notes,520 };521 cost('spread_slippage', slip, notional, {522 inputs: [{ name: 'best bid', value: bb[0], source: 'venue' }, { name: 'best ask', value: ba[0], source: 'venue' }, { name: 'book levels walked', value: String(w.levels), source: 'venue' },523 { name: 'average price', value: entry === null ? null : pxS(entry), source: 'estimate' }],524 basis: `size × (average price − book mid), walking the ${buy ? 'asks' : 'bids'} of the snapshot level by level${D.isPos(resting) ? '; the resting part is valued at the limit price' : ''}`,525 unc: D.mul(sized, half), uncertainty: 'the book moves between snapshot and order: half the bid-ask spread × size', timing: 'at_fill',526 });527 const taker = facts.takerRate === null ? null : D.from(facts.takerRate), maker = facts.makerRate === null ? null : D.from(facts.makerRate);528 const fee = taker === null || maker === null ? null : D.add(D.mul(w.cost, taker), D.mul(D.mul(resting, limit ?? D.zero), maker));529 openFee = fee ?? D.zero; feeRateClose = taker ?? D.zero;530 cost('trading_fee', fee, notional, {531 inputs: [{ name: 'taker rate', value: facts.takerRate, source: 'constant' }, { name: 'maker rate', value: facts.makerRate, source: 'constant' }, { name: 'taker notional', value: usdS(w.cost), source: 'estimate' }],532 basis: `taker rate × immediately filled notional${D.isPos(resting) ? ' + maker rate × resting notional' : ''}; ${facts.feeSource}`,533 unc: D.zero, uncertainty: 'the account\'s real tier may be lower than the base tier; the venue\'s fills are the truth', timing: 'at_fill',534 });535 // carry536 if (!intent.reduceOnly) {537 const oracle = D.from(s.ticker.index ?? s.ticker.mark);538 if (s.ticker.funding === null) cost('funding', null, notional, { inputs: [], basis: 'the venue publishes no funding rate for this instrument', unc: D.zero, uncertainty: 'unknown', timing: 'over_horizon' });539 else {540 const rate = perHour(s.ticker.funding, inst.fundingIntervalSec), fn = D.mul(sizeBase, oracle);541 const amount = D.mul(D.mul(fn, rate), long ? hours : D.neg(hours)), u = carryUncertainty(s, fn, hours, amount);542 cost('funding', amount, notional, {543 inputs: [{ name: 'rate per hour (what longs pay)', value: D.str(rate), source: 'venue' }, { name: 'venue interval', value: intervalText(inst.fundingIntervalSec), source: 'venue' },544 { name: s.ticker.index === null ? 'mark price' : 'oracle price', value: s.ticker.index ?? s.ticker.mark, source: 'venue' }, { name: 'horizon (h)', value: intent.horizonHours, source: 'estimate' }],545 basis: `size × oracle price × CURRENT hourly rate × ${intent.horizonHours} h, ${long ? 'paid by' : 'received by'} the ${intent.side} when the rate is positive. THE RATE CHANGES every ${intervalText(inst.fundingIntervalSec)}: this is today's rate held constant, not a forecast.`,546 unc: u.usd, uncertainty: u.text, timing: 'over_horizon',547 });548 }549 }550 // exit, for information: the opposite side of the same snapshot551 const back = walkBook(buy ? book.bids : book.asks, sizeBase, buy ? 'sell' : 'buy', null);552 if (!D.isPos(back.remainder) && back.avg !== null && taker !== null) {553 exitParts.push({ id: 'spread_slippage', usd: D.mul(sizeBase, buy ? D.sub(mid, back.avg) : D.sub(back.avg, mid)), basis: `walking the ${buy ? 'bids' : 'asks'} of the same snapshot` });554 exitParts.push({ id: 'trading_fee', usd: D.mul(back.cost, taker), basis: 'taker rate × notional' });555 }556 } else {557 // ================================================================ GMX: oracle price + price impact558 const l = s.gmx!, oMin = D.from(l.oracleMin), oMax = D.from(l.oracleMax), mid = D.div(D.add(oMin, oMax), TWO, CALC_DP, 'half_even');559 if (!D.isPos(oMin) || D.lt(oMax, oMin)) { add('bad_oracle', 'blocker', 'the oracle price range of the snapshot is not usable'); return done(); }560 // longs open at max and close at min; shorts the reverse561 const execBase = buy ? oMax : oMin, px = limit ?? execBase;562 const sizeUsd = intent.notionalUsd !== undefined ? D.from(intent.notionalUsd) : D.mul(D.from(intent.sizeBase!), px);563 notional = sizeUsd; entry = px;564 sizeBase = D.quantize(D.div(sizeUsd, px, CALC_DP, 'down'), D.from(inst.lotSize), 'down');565 base.order = { side, positionSide: intent.side, reduceOnly: intent.reduceOnly, type: intent.type, limitPrice: intent.price, sizeUnit: 'usd', sizeBase: D.str(sizeBase), sizeUsd: usdS(sizeUsd),566 requested: intent.notionalUsd !== undefined ? `${intent.notionalUsd} USD` : `${intent.sizeBase} ${inst.base} at ${pxS(px)}`, rounding: 'GMX orders are sized in USD; the token size results from the execution price' };567 if (inst.minNotional !== null && D.lt(sizeUsd, D.from(inst.minNotional))) add('below_min_notional', 'blocker', `size ${usdS(sizeUsd)} USD is below the venue minimum position ${inst.minNotional} USD`);568 const sideOi = D.from(long ? l.openInterestLongUsd : l.openInterestShortUsd), maxOi = D.from(long ? l.maxOpenInterestLongUsd : l.maxOpenInterestShortUsd), capacity = D.from(long ? l.capacityLongUsd : l.capacityShortUsd);569 if (!intent.reduceOnly) {570 if (D.gt(sizeUsd, capacity)) add('above_capacity', 'blocker', `size ${usdS(sizeUsd)} USD is above the capacity still open on the ${intent.side} side, ${usdS(capacity)} USD (limited by ${long ? l.capacityLimitedBy.long : l.capacityLimitedBy.short})`);571 if (D.gt(D.add(sideOi, sizeUsd), maxOi)) add('above_open_interest_cap', 'blocker', `${intent.side} open interest ${usdS(sideOi)} USD + ${usdS(sizeUsd)} USD would pass the market cap ${usdS(maxOi)} USD`);572 // the minimum collateral factor rises with the side's open interest: leverage ≤ 1 / max(minCollateralFactor, OI after × factor per USD)573 const perUsd = D.from(long ? l.minCollateralFactorPerUsdOfOpenInterest.long : l.minCollateralFactorPerUsdOfOpenInterest.short);574 const minFactor = D.max(D.from(l.minCollateralFactor), D.mul(D.add(sideOi, sizeUsd), perUsd));575 if (D.isPos(minFactor) && D.gt(D.mul(lev, minFactor), D.one) && !reasons.some((r) => r.code === 'leverage_above_venue_max')) add('leverage_above_venue_max', 'blocker', `leverage ${intent.leverage}× is above 1 / ${D.str(q(minFactor, 8))} = ${D.str(D.div(D.one, minFactor, 2, 'down'))}× allowed by the market's minimum collateral factor at this open interest`);576 add('gmx_interface_leverage', 'note', 'the bound above is the contracts\'; the venue\'s own interface allows less (not verified), and fees and price impact reduce the collateral that counts');577 } else add('position_unknown', 'note', 'reduce-only: the router does not know the position being reduced on GMX (no venue account); costs are for a decrease of this size');578 if (intent.type === 'limit') add('limit_waits_for_oracle', 'note', `a GMX limit order waits until the oracle price reaches ${intent.price}; a keeper then executes it, or it is frozen if it cannot be executed`);579 add('two_step_execution', 'note', 'two-step execution: the price is the signed oracle price at keeper execution, after the request — not the snapshot\'s');580581 const oi = gmxBalanceOi(l), pi = l.priceImpact;582 const imp = gmxPriceImpact({ longOi: oi.long, shortOi: oi.short, sizeDeltaUsd: sizeUsd, isLong: long, increase: !intent.reduceOnly, virtualInventoryUsd: oi.virtualUsd,583 factorPositive: D.from(pi.factorPositive), factorNegative: D.from(pi.factorNegative), exponentPositive: D.from(pi.exponentPositive), exponentNegative: D.from(pi.exponentNegative) });584 const capPos = D.mul(sizeUsd, D.from(pi.maxFactorPositive)), capNeg = D.mul(sizeUsd, D.from(pi.maxFactorNegative));585 const capped = D.isPos(imp.impactUsd) ? D.min(imp.impactUsd, capPos) : D.max(imp.impactUsd, D.neg(capNeg));586 const isCapped = !D.eq(capped, imp.impactUsd);587 const spread = D.mul(sizeBase, buy ? D.sub(px, mid) : D.sub(mid, px));588 base.fill = {589 method: 'gmx_oracle_impact', referencePrice: pxS(mid), referenceSource: 'venue', filledBase: D.str(sizeBase), filledUsd: usdS(sizeUsd), avgPrice: pxS(px), worstPrice: pxS(px),590 slippageBps: bpsS(D.div(D.mul(buy ? D.sub(px, mid) : D.sub(mid, px), BPS), mid, CALC_DP, 'half_even')),591 levelsConsumed: null, depthConsumedPct: D.isPos(capacity) ? D.str(q(D.div(D.mul(sizeUsd, HUNDRED), capacity, CALC_DP, 'half_even'), 4)) : null, snapshotLevels: null,592 unfilledBase: '0', unfilledReason: 'none', restingBase: '0',593 gmx: { oracleMin: l.oracleMin, oracleMax: l.oracleMax, executionBasePrice: pxS(execBase), impactUsd: usdS(imp.impactUsd), impactCappedUsd: usdS(capped), capPositiveUsd: usdS(capPos), capNegativeUsd: usdS(capNeg), capped: isCapped,594 balanceWasImproved: imp.balanceWasImproved, crossover: imp.crossover, imbalanceBeforeUsd: usdS(imp.initialDiffUsd), imbalanceAfterUsd: usdS(imp.nextDiffUsd), openInterestBasis: oi.basis,595 virtualInventory: oi.virtual, fromVirtualInventory: imp.fromVirtualInventory, verification: GMX_IMPACT_VERIFICATION },596 source: 'estimate',597 notes: [`no order book: ${buy === long ? 'opens' : 'closes'} at the oracle ${buy ? 'max' : 'min'} price; "depth consumed" is the share of the venue's remaining ${intent.side} capacity`,598 'price impact is not in the entry price: it is recorded when the position opens and settled when it is decreased, netted with the impact of that decrease, within the caps'],599 };600 cost('spread_slippage', spread, notional, {601 inputs: [{ name: 'oracle min', value: l.oracleMin, source: 'venue' }, { name: 'oracle max', value: l.oracleMax, source: 'venue' }],602 basis: `size × (execution base price − oracle mid): the order executes at the oracle ${buy ? 'max' : 'min'}${limit ? ', here valued at the limit price' : ''}${D.eq(oMin, oMax) ? '. The venue\'s oracle range has min = max for this index in this snapshot: no spread to count' : ''}`,603 unc: D.mul(sizeBase, D.sub(oMax, oMin)), uncertainty: 'the oracle price at keeper execution is not the snapshot\'s: one oracle spread × size (the market move in between is not a cost of the venue and is not counted)', timing: 'at_fill',604 });605 cost('price_impact', D.neg(capped), notional, {606 inputs: [{ name: 'imbalance before (USD)', value: usdS(imp.initialDiffUsd), source: 'venue' }, { name: 'imbalance after (USD)', value: usdS(imp.nextDiffUsd), source: 'estimate' },607 { name: 'factor +', value: pi.factorPositive, source: 'venue' }, { name: 'factor −', value: pi.factorNegative, source: 'venue' }, { name: 'exponent', value: imp.balanceWasImproved ? pi.exponentPositive : pi.exponentNegative, source: 'venue' },608 { name: 'cap against the trade (USD)', value: usdS(capNeg), source: 'venue' }, { name: 'cap in favour (USD)', value: usdS(capPos), source: 'venue' }],609 basis: `GMX v2 formula on the long/short imbalance (${oi.basis}): imbalance^exponent × factor before − after${imp.crossover ? ', split at the balance point' : ''}; ${isCapped ? 'CAPPED at the market\'s maximum factor × size' : 'inside the caps'}. `610 + `${oi.virtual === 'none' ? 'This market has no virtual inventory.' : oi.virtual === 'known' ? (imp.fromVirtualInventory ? 'The figure comes from the index token\'s VIRTUAL INVENTORY, worse than the market\'s own.' : 'The index token\'s virtual inventory was applied; the market\'s own imbalance gives the figure.') : 'VIRTUAL INVENTORY UNKNOWN: a negative impact can be worse than this figure, up to the cap.'} `611 + `A ${D.isNeg(capped) ? 'negative impact is a cost' : 'positive impact is a credit'} RECORDED at the open and SETTLED at the close, netted with the impact of the close: ${D.isNeg(capped) ? 'part of it may come back' : 'it is not money received at entry, and under an unchanged imbalance the close takes it back (see the exit figure)'}. ${GMX_IMPACT_VERIFICATION}. The impact pool balance is not modelled.`,612 unc: oi.virtual === 'unknown' && !D.isPos(capped) ? D.max(D.sub(capNeg, D.abs(capped)), D.abs(capped)) : D.mul(D.abs(capped), D.from(UNCERTAINTY.priceImpactFraction)),613 uncertainty: `settled only at the close and netted with the close impact on the imbalance of that moment: ±${D.str(D.mul(D.from(UNCERTAINTY.priceImpactFraction), HUNDRED))} % of the figure (convention)${oi.virtual === 'unknown' ? ', widened to the cap because the virtual inventory is unknown' : ''}; the hard bound is the cap, ${usdS(capNeg)} USD against the trade`,614 timing: 'at_close',615 });616 const feeFactor = D.from(imp.balanceWasImproved ? l.positionFeeFactor.balanceImproved : l.positionFeeFactor.balanceNotImproved);617 openFee = D.mul(sizeUsd, feeFactor); feeRateClose = D.from(l.positionFeeFactor.balanceNotImproved); gmxMaint = D.from(l.minCollateralFactorForLiquidation);618 cost('trading_fee', openFee, notional, {619 inputs: [{ name: 'position fee factor', value: D.str(feeFactor), source: 'venue' }, { name: 'balance improved by the trade', value: String(imp.balanceWasImproved), source: 'estimate' }],620 basis: `position fee = size × ${imp.balanceWasImproved ? 'the lower factor (the trade reduces the long/short imbalance)' : 'the higher factor (the trade does not reduce the imbalance)'}; ${facts.feeSource}`,621 unc: D.zero, uncertainty: 'which factor applies is decided on the open interest at execution', timing: 'at_fill',622 });623 const ch = s.chain, ethUsd = ch?.ethUsd ? D.from(ch.ethUsd) : null;624 const exec = ch?.gmxExecutionFeeEth && ethUsd ? D.mul(D.from(ch.gmxExecutionFeeEth), ethUsd) : null;625 cost('execution_fee', exec, notional, {626 inputs: [{ name: 'execution fee (ETH)', value: ch?.gmxExecutionFeeEth ?? null, source: 'estimate' }, { name: 'ETH price', value: ch?.ethUsd ?? null, source: 'venue' }],627 basis: exec === null ? `unknown: ${ch?.gmxExecutionFeeNote ?? 'no chain snapshot was supplied'}` : `keeper execution fee in ETH × ETH price (${ch!.ethUsdSource}); ${ch!.gmxExecutionFeeNote}`,628 unc: exec === null ? D.zero : D.mul(exec, D.from(UNCERTAINTY.executionFeeFraction)), uncertainty: 'this is the amount to send with the request; the keeper keeps only the gas it used and the rest is refunded, so the real cost is lower', timing: 'at_fill',629 });630 const gas = ch?.gasPriceEth && ethUsd ? D.mul(D.mul(D.from(ARBITRUM_GAS_UNITS.gmxCreateOrder), D.from(ch.gasPriceEth)), ethUsd) : null;631 cost('network_gas', gas, notional, {632 inputs: [{ name: 'gas units (measured sample)', value: ARBITRUM_GAS_UNITS.gmxCreateOrder, source: 'constant' }, { name: 'Arbitrum gas price (ETH)', value: ch?.gasPriceEth ?? null, source: 'venue' }, { name: 'ETH price', value: ch?.ethUsd ?? null, source: 'venue' }],633 basis: gas === null ? 'unknown: no gas price in the snapshot' : 'gas of the request transaction (ExchangeRouter.multicall) = assumed gas units × Arbitrum gas price × ETH price',634 unc: gas === null ? D.zero : D.mul(gas, D.from(UNCERTAINTY.gasFraction)), uncertainty: 'gas used and gas price vary: ±50 % (convention)', timing: 'at_fill',635 });636 if (!intent.reduceOnly) {637 // venue sign: positive = the side RECEIVES638 const fr = D.from(long ? l.fundingRateLongPerHour : l.fundingRateShortPerHour), funding = D.neg(D.mul(D.mul(sizeUsd, fr), hours)), fu = carryUncertainty(s, sizeUsd, hours, funding);639 cost('funding', funding, notional, {640 inputs: [{ name: `funding rate per hour, ${intent.side} side (venue sign: positive = receives)`, value: D.str(fr), source: 'venue' }, { name: 'horizon (h)', value: intent.horizonHours, source: 'estimate' }],641 basis: `size × CURRENT hourly rate of the ${intent.side} side × ${intent.horizonHours} h. Funding accrues PER SECOND and is adaptive: THE RATE CHANGES continuously, and this order itself moves the imbalance that drives it. Today's rate held constant, not a forecast.`,642 unc: fu.usd, uncertainty: fu.text, timing: 'over_horizon',643 });644 const br = D.from(long ? l.borrowingRateLongPerHour : l.borrowingRateShortPerHour), borrow = D.mul(D.mul(sizeUsd, D.abs(br)), hours);645 cost('borrow', borrow, notional, {646 inputs: [{ name: `borrowing rate per hour, ${intent.side} side`, value: D.str(br), source: 'venue' }, { name: 'horizon (h)', value: intent.horizonHours, source: 'estimate' }],647 basis: `size × CURRENT hourly borrowing rate of the ${intent.side} side × ${intent.horizonHours} h; only the larger open-interest side pays, so the rate can switch on or off as the imbalance moves`,648 unc: D.isZero(borrow) ? D.mul(D.mul(sizeUsd, D.abs(D.from(long ? l.borrowingRateShortPerHour : l.borrowingRateLongPerHour))), hours) : borrow,649 uncertainty: D.isZero(borrow) ? 'zero today because this side is the smaller one; if the sides switch, the other side\'s current rate would apply' : 'the rate follows pool utilisation: ±100 % (convention)', timing: 'over_horizon',650 });651 // exit, for information: decrease of the same size on the open interest AFTER this order, netted with the pending impact652 const nl = long ? D.add(oi.long, sizeUsd) : oi.long, ns = long ? oi.short : D.add(oi.short, sizeUsd);653 // traders net longer → the virtual inventory (negative = net long) falls by the size; net shorter → it rises654 const nv = oi.virtualUsd === null ? null : long ? D.sub(oi.virtualUsd, sizeUsd) : D.add(oi.virtualUsd, sizeUsd);655 const out = gmxPriceImpact({ longOi: nl, shortOi: ns, sizeDeltaUsd: sizeUsd, isLong: long, increase: false, virtualInventoryUsd: nv,656 factorPositive: D.from(pi.factorPositive), factorNegative: D.from(pi.factorNegative), exponentPositive: D.from(pi.exponentPositive), exponentNegative: D.from(pi.exponentNegative) });657 const net = D.add(imp.impactUsd, out.impactUsd), netCapped = D.isPos(net) ? D.min(net, capPos) : D.max(net, D.neg(capNeg));658 exitParts.push({ id: 'price_impact', usd: D.sub(D.neg(netCapped), D.neg(capped)), basis: 'impact of the decrease on the open interest after this order, netted with the opening impact and capped; shown as the change against the opening impact already counted' });659 exitParts.push({ id: 'spread_slippage', usd: spread, basis: 'the close executes at the other end of the oracle range' });660 exitParts.push({ id: 'trading_fee', usd: D.mul(sizeUsd, D.from(out.balanceWasImproved ? l.positionFeeFactor.balanceImproved : l.positionFeeFactor.balanceNotImproved)), basis: 'position fee of the decrease' });661 if (exec !== null) exitParts.push({ id: 'execution_fee', usd: exec, basis: 'a second keeper execution' });662 if (gas !== null) exitParts.push({ id: 'network_gas', usd: gas, basis: 'a second request transaction' });663 }664 }665666 // ---- margin (opening orders)667 let im = D.zero;668 const entryPx = entry;669 if (!intent.reduceOnly && entryPx !== null && D.isPos(sizeBase)) {670 const positionNotional = D.mul(sizeBase, entryPx);671 im = initialMargin(positionNotional, lev);672 const mmf = facts.maintenance === 'half_initial_at_max' ? D.div(D.one, D.mul(TWO, D.from(inst.maxLeverage)), 18, 'up') : facts.maintenance === 'gmx_min_collateral' ? gmxMaint : facts.maintenance === null ? null : D.from(facts.maintenance);673 let liq: MarginEstimate['liquidationPrice'] = { value: null, source: 'estimate', method: 'no maintenance fraction is known for this instrument: no liquidation estimate' }, dist: DecStr | null = null;674 if (mmf !== null) {675 // GMX takes the opening fee out of the collateral and counts the closing fee against it; an order-book venue is estimated without fees676 const gmx = s.venue === 'gmx', margin = gmx ? D.sub(im, openFee) : im;677 if (D.isPos(margin)) {678 const r = liquidationPriceIsolated({ side: intent.side, size: sizeBase, entryPrice: entryPx, margin, maintenanceFraction: mmf, ...(gmx ? { feeRate: feeRateClose } : {}) });679 const tail = gmx ? 'margin = initial margin − opening fee; funding, borrowing, price impact and the liquidation fee are ignored; liquidation is on the oracle min/max, not the mid'680 : 'position funded with exactly its initial margin (isolated); on cross margin the whole account backs it and the price is further away; size tiers of the maintenance margin ignored';681 liq = { value: r.value === null ? null : pxS(r.value), source: 'estimate', method: `${r.method}; ${tail}` };682 if (r.value !== null) dist = bpsS(D.div(D.mul(D.abs(D.sub(entryPx, r.value)), BPS), entryPx, CALC_DP, 'half_even'));683 } else liq = { value: null, source: 'estimate', method: 'the opening fee is larger than the initial margin at this leverage: the position could not be opened' };684 }685 base.margin = { notionalUsd: usdS(positionNotional), leverage: intent.leverage, initialMarginUsd: usdS(im), maintenanceFraction: mmf === null ? null : D.str(q(mmf, 10)), maintenanceRule: facts.maintenanceText,686 maintenanceSource: facts.maintenance === 'gmx_min_collateral' ? 'venue' : 'constant', liquidationPrice: liq, liquidationDistanceBps: dist };687 }688689 // ---- collateral: is it there?690 const need = D.add(im, openFee), cf = facts.collateral, ch = s.chain;691 const transferGas = cf.needsArbitrumTransfer && ch?.gasPriceEth && ch.ethUsd ? D.mul(D.mul(D.from(ARBITRUM_GAS_UNITS.erc20Transfer), D.from(ch.gasPriceEth)), D.from(ch.ethUsd)) : null;692 const facts3: Input[] = [{ name: 'route', value: cf.route, source: 'constant' }, { name: 'source', value: cf.source, source: 'constant' }];693 if (intent.reduceOnly) { /* reducing needs no new collateral */ }694 else if (s.restriction !== null) cost('collateral_transfer', null, notional, { inputs: facts3, basis: 'not applicable: the venue is not available to the owner', unc: D.zero, uncertainty: 'unknown', timing: 'before_trading' });695 else if (s.account && D.gte(D.from(s.account.freeCollateral), need)) {696 cost('collateral_transfer', D.zero, notional, { inputs: [{ name: `free collateral of « ${s.account.label} » (${s.account.kind})`, value: s.account.freeCollateral, source: s.account.kind === 'venue' ? 'venue' : 'estimate' }],697 basis: s.account.kind === 'paper' ? 'the paper account holds enough SIMULATED collateral: nothing to move (a real account would have to be funded first)' : 'the venue account holds enough free collateral: nothing to move',698 unc: D.zero, uncertainty: 'none', timing: 'before_trading' });699 } else {700 if (s.account) add('insufficient_collateral_there', 'setup', `« ${s.account.label} » has ${s.account.freeCollateral} USD of free collateral; this order needs about ${usdS(need)} USD (initial margin + fee)`);701 else add('no_collateral_account', 'setup', s.venue === 'gmx' ? 'GMX has no venue account: the collateral must already be in the wallet on Arbitrum One, and this wallet does not hold a GMX position account' : `no account with collateral on ${facts.name} is known to the wallet: it would have to be funded first`);702 const known = cf.fixedCostUsd !== null && (!cf.needsArbitrumTransfer || transferGas !== null);703 const amount = known ? D.add(D.from(cf.fixedCostUsd!), transferGas ?? D.zero) : null;704 cost('collateral_transfer', amount, notional, {705 inputs: [...facts3, ...(cf.needsArbitrumTransfer ? [{ name: 'gas units of a USDC transfer (measured sample)', value: ARBITRUM_GAS_UNITS.erc20Transfer, source: 'constant' as const }, { name: 'Arbitrum gas price (ETH)', value: ch?.gasPriceEth ?? null, source: 'venue' as const }] : [])],706 basis: amount === null ? `unknown: ${cf.needsArbitrumTransfer ? 'no gas price in the snapshot' : 'it depends on where the funds are today'}. ${cf.route}` : `gas of one USDC transfer on Arbitrum One, assuming the USDC is already there. ${cf.route}`,707 unc: amount === null ? D.zero : D.mul(amount, D.from(UNCERTAINTY.gasFraction)), uncertainty: 'bringing funds to Arbitrum from another network is NOT included (unknown)', timing: 'before_trading',708 });709 }710711 base.costs = costs;712 // ---- all-in: what is known is summed, what is not is named713 if (base.fill) {714 let total = D.zero, unc = D.zero; const included: CostId[] = [], unknown: CostId[] = [];715 for (const k of costs) { if (k.usd === null) { unknown.push(k.id); continue; } total = D.add(total, D.from(k.usd)); unc = D.add(unc, D.from(k.uncertaintyUsd)); included.push(k.id); }716 const priced = !D.isPos(D.from(base.fill.unfilledBase));717 base.allIn = { usd: usdS(total), bps: bpsS(bpsOf(total, notional)), uncertaintyUsd: usdS(unc), included, unknown, horizonHours: intent.horizonHours, complete: priced && unknown.length === 0,718 note: `${priced ? '' : 'PARTIAL — the book snapshot ends: only the priced part is in this figure. '}entry costs + carry over ${intent.horizonHours} h at today's rates, in USD and in basis points of the order's notional; closing costs are not included${unknown.length ? `; NOT included because unknown: ${unknown.join(', ')}` : ''}` };719 if (exitParts.length) {720 const x = exitParts.reduce((a, p) => D.add(a, p.usd), D.zero);721 const rt = D.add(total, x);722 base.exit = { usd: usdS(x), bps: bpsS(bpsOf(x, notional)), roundTripUsd: usdS(rt), roundTripBps: bpsS(bpsOf(rt, notional)), parts: exitParts.map((p) => ({ id: p.id, usd: usdS(p.usd), basis: p.basis })),723 note: 'closing the same size immediately under the SAME snapshot — information only: at the real close the book, the oracle price, the imbalance and the rates will be different' };724 }725 }726 return done();727}728729// ------------------------------------------------------------------------------------------------------------------ ranking, differences730731export const RANKING_NOTE = 'Information only. The router selects nothing and sends nothing: it orders eligible instruments by estimated all-in cost, lowest first. "Lowest estimated cost" is not "best": the contracts differ (collateral, price used for liquidation, funding rule, execution model) and every figure is an estimate that moves with the market.';732733export function rankCandidates(c: ExecutionEstimate[]): Ranking {734 const ok = c.filter((e) => e.eligibility.eligible && e.allIn !== null);735 const sorted = [...ok].sort((a, b) => D.cmp(D.from(a.allIn!.usd), D.from(b.allIn!.usd)) || a.instrument.localeCompare(b.instrument));736 const rows: RankingRow[] = [];737 sorted.forEach((e, k) => {738 const mine = D.from(e.allIn!.usd), prev = rows[k - 1];739 const tied = sorted.filter((o) => o !== e && D.eq(D.from(o.allIn!.usd), mine)).map((o) => o.instrument);740 // within the sum of the two uncertainties: the order of the two is not established by these estimates741 const near = sorted.filter((o) => o !== e && !D.eq(D.from(o.allIn!.usd), mine) && D.lte(D.abs(D.sub(D.from(o.allIn!.usd), mine)), D.add(D.from(o.allIn!.uncertaintyUsd), D.from(e.allIn!.uncertaintyUsd)))).map((o) => o.instrument);742 rows.push({ rank: prev && D.eq(D.from(prev.allInUsd), mine) ? prev.rank : k + 1, instrument: e.instrument, venue: e.venue, allInUsd: e.allIn!.usd, allInBps: e.allIn!.bps, uncertaintyUsd: e.allIn!.uncertaintyUsd,743 complete: e.allIn!.complete, executableNow: e.eligibility.executableNow, tiedWith: tied, nearTieWith: near, roundTripUsd: e.exit?.roundTripUsd ?? null, roundTripRank: null });744 });745 const byTrip = rows.filter((r) => r.roundTripUsd !== null).sort((a, b) => D.cmp(D.from(a.roundTripUsd!), D.from(b.roundTripUsd!)) || a.instrument.localeCompare(b.instrument));746 byTrip.forEach((r, k) => { r.roundTripRank = k + 1; });747 const entryOrder = rows.filter((r) => r.roundTripUsd !== null).map((r) => r.instrument);748 const orderChangesWithExit = byTrip.some((r, k) => r.instrument !== entryOrder[k] && !D.eq(D.from(r.roundTripUsd!), D.from(byTrip.find((x) => x.instrument === entryOrder[k])!.roundTripUsd!)));749 return { rows, selected: null, orderChangesWithExit, basis: 'all-in cost over the horizon (entry costs + carry at today\'s rates), USD, lowest first; equal figures share a rank; "near tie" = the difference is within the sum of the two uncertainties', note: RANKING_NOTE,750 excluded: c.filter((e) => !ok.includes(e)).map((e) => ({ instrument: e.instrument, venue: e.venue, reasons: e.eligibility.reasons.filter((r) => r.kind === 'blocker').map((r) => r.code) })) };751}752753export const NEVER = [754 'A long on one venue and a short on another are not "flat": the contracts follow different prices (Hyperliquid: a mark blending its book with a USDT-denominated oracle; GMX: a signed oracle min/max; dYdX: a USD oracle).',755 'A size valid on one venue is not valid on another: each has its own lot, and GMX orders are in USD.',756 'A book price and an oracle price are not the same kind of price: on GMX the cost is fee + price impact + execution fee + borrowing, on a book it is spread, depth and fee.',757 'An accepted order is not a fill: a GMX order is a request a keeper may execute later, cancel or freeze.',758 'A leverage figure does not carry across venues: the maintenance rule, the price used for liquidation and the penalty all differ.',759];760export function contractDifferences(base: string, c: ExecutionEstimate[]): Differences {761 const fields: Difference[] = [];762 for (const k of TERM_KEYS) {763 const values = c.map((e) => ({ instrument: e.instrument, venue: e.venue, value: e.terms[k].value, source: e.terms[k].source }));764 if (new Set(values.map((v) => v.value)).size > 1) fields.push({ field: k, values });765 }766 const n = c.length;767 return { interchangeable: false, fields, never: NEVER,768 statement: n === 0 ? `No instrument on ${base} was found.` : n === 1 ? `One instrument on ${base}: there is nothing to compare it with, and it is not the same contract as a ${base} perpetual anywhere else.`769 : `${n} different contracts on ${base}. None of them is the same contract as another — not even two pools of one venue: ${fields.length} of ${TERM_KEYS.length} terms differ between them, and a position on one cannot be closed, netted or margined on another.` };770}771772export const ROUTER_WARNINGS = [773 'Simulation only: nothing was sent to any venue, and no order can be placed from this result.',774 'Funding and borrowing rates change (hourly on order-book venues, every second on GMX): the carry shown is today\'s rate held constant over the horizon, not a forecast.',775 'Every cost is an estimate on a snapshot: the book, the oracle price and the pools move before any real order would execute.',776];777778/**779 * The whole comparison. `snapshots`: one per instrument on the intent's base (several per venue on GMX: one per pool).780 * A restricted venue is estimated from its public data and shown, never ranked.781 */782export function routeOrder(intentIn: z.input<typeof OrderIntentSchema>, snapshots: readonly VenueSnapshot[], o: { now: number; maxAgeMs?: Partial<typeof ROUTER_MAX_AGE_MS> }): RouteResult {783 const intent = OrderIntentSchema.parse(intentIn), want = intent.base.toUpperCase();784 const maxAge = { ...ROUTER_MAX_AGE_MS, ...(o.maxAgeMs ?? {}) };785 const mine = snapshots.filter((s) => s.instrument.base.toUpperCase() === want);786 const candidates = mine.map((s) => estimateVenue(s, { intent, now: o.now, maxAge }));787 const warnings = [...ROUTER_WARNINGS];788 if (mine.length === 0) warnings.push(`No venue lists a perpetual on ${want}.`);789 const ranking = rankCandidates(candidates);790 if (ranking.orderChangesWithExit) warnings.push('The order changes when an immediate close is added to the all-in cost (see the round-trip column): the ranking is on entry costs and carry only.');791 const partial = candidates.filter((e) => e.allIn && e.allIn.unknown.length > 0);792 if (partial.length) warnings.push(`Some costs are unknown and are NOT in the totals (${[...new Set(partial.flatMap((e) => e.allIn!.unknown))].join(', ')}): the totals of those instruments are lower bounds.`);793 if (candidates.some((e) => e.eligibility.reasons.some((r) => r.code === 'venue_restricted'))) warnings.push('A venue restricted for the owner is shown for information and is never ranked.');794 return { intent, candidates, ranking, differences: contractDifferences(want, candidates), warnings, ts: o.now, source: 'estimate' };795}796797/** the order a paper or venue account would be asked to check for this estimate (size on the venue's lot; market orders bounded by `slippageBps`) */798export function orderBodyFor(e: ExecutionEstimate, slippageBps = 50): null | { instrument: string; side: 'buy' | 'sell'; type: 'market' | 'limit'; size: DecStr; price: DecStr | null; reduceOnly: boolean; leverage?: DecStr; slippageBps?: number } {799 if (!e.order || e.order.sizeUnit !== 'base' || !D.isPos(D.from(e.order.sizeBase))) return null;800 return { instrument: e.instrument, side: e.order.side, type: e.order.type, size: e.order.sizeBase, price: e.order.limitPrice, reduceOnly: e.order.reduceOnly,801 ...(e.margin ? { leverage: e.margin.leverage } : {}), ...(e.order.type === 'market' ? { slippageBps } : {}) };802}803