SPB Git

Repository Public

router.ts 71.4 KB 803 lines typescript

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
Files main
Menu

Theme