Skip to content
markpaper

src/format/slippage.ts

v0.3.0 · 6.3 KB

Download file
import { HlFormatError, add, cmp, mul, parseDec, sub, toNumber, toPlain, type Dec } from './decimal.js';
import { isBuySide, roundPriceDec, tickDown, tickUp } from './price.js';
import { parsePositive } from './size.js';
import type { Numeric, OrderSide, PriceGridOptions } from './types.js';

/**
 * Slippage floor for reduceOnly exits (fraction). Why: a narrow cap on a
 * reduceOnly exit did not cross the book during lag, under a 429 storm or on an
 * illiquid HIP-3 market, and the position stayed open. An aggressive reduceOnly
 * exit has no downside: it cannot flip the position.
 */
export const DEFAULT_EXIT_SLIPPAGE_FLOOR = 0.1;

/** Hard cap of {@link escalatingSlippage} (fraction). */
export const DEFAULT_FLATTEN_SLIPPAGE_CAP = 0.05;

function assertFraction(value: number, field: string, allowZero: boolean): void {
  if (typeof value !== 'number' || !Number.isFinite(value) || value >= 1 || (allowZero ? value < 0 : value <= 0)) {
    throw new HlFormatError(`Invalid ${field}: ${String(value)} (expected ${allowZero ? '0 <=' : '0 <'} ${field} < 1)`);
  }
}

function parseMid(mid: Numeric): Dec {
  const d = parsePositive(mid, 'mid');
  // allMids validation from the knowledge base: a mid above MAX_SAFE_INTEGER is
  // treated as unavailable (a passed-through Infinity can leave a position unprotected).
  if (toNumber(d) > Number.MAX_SAFE_INTEGER) throw new HlFormatError(`Invalid mid: ${toPlain(d)} is not a usable price`);
  return d;
}

export interface MarketLimitPriceInput extends PriceGridOptions {
  /** Reference price, usually `allMids` of the market's dex. */
  mid: Numeric;
  side: OrderSide;
  /** Fraction, `0.01` = 1%. Must be > 0 and < 1. */
  slippage: number;
  /**
   * `'aggressive'` (default) rounds toward execution (buy ceil, sell floor) so the
   * cap never shrinks below the requested slippage. `'passive'` never exceeds it.
   */
  rounding?: 'aggressive' | 'passive';
}

/**
 * Limit price for a "market" order. HL has no market order type; it is an IoC
 * limit at `mid * (1 + slippage)` for buys and `mid * (1 - slippage)` for sells,
 * computed exactly and rounded onto the price grid (orders.md §2.2).
 *
 * - The limit is only a cap: IoC fills at the best book prices within it.
 * - An IoC exactly at mid does not cross the spread and does not fill; a shift of
 *   at least ~0.5% is usually needed, and the spread must be narrower than
 *   the slippage or the IoC silently cancels. Hence `slippage` must be > 0.
 * - Entries: narrow (0.5-1%). Exits: wider, see {@link exitSlippage}.
 * - Default rounding is aggressive so rounding never narrows the crossing.
 *
 * Throws `HlFormatError` for a missing/invalid mid; the caller should skip an
 * entry and queue a close for retry.
 */
export function marketLimitPrice(input: MarketLimitPriceInput): string {
  assertFraction(input.slippage, 'slippage', false);
  if (input.rounding !== undefined && input.rounding !== 'aggressive' && input.rounding !== 'passive') {
    throw new HlFormatError(`Invalid rounding: ${String(input.rounding)}`);
  }
  const buy = isBuySide(input.side);
  const mid = parseMid(input.mid);
  const one: Dec = { n: 1n, s: 0 };
  const slip = parseDec(input.slippage, 'slippage');
  const raw = mul(mid, buy ? add(one, slip) : sub(one, slip));
  const aggressive = (input.rounding ?? 'aggressive') === 'aggressive';
  const direction = buy === aggressive ? 'ceil' : 'floor';
  return toPlain(roundPriceDec(raw, input, direction));
}

/**
 * Slippage for a reduceOnly exit: `max(slippage, floor)` with a 10% default floor
 * (orders.md §2.2, market-data.md §4). The entry/exit asymmetry is deliberate.
 */
export function exitSlippage(slippage: number, floor: number = DEFAULT_EXIT_SLIPPAGE_FLOOR): number {
  assertFraction(slippage, 'slippage', true);
  assertFraction(floor, 'floor', true);
  return Math.max(slippage, floor);
}

export interface EscalatingSlippageOptions {
  /** Default {@link DEFAULT_FLATTEN_SLIPPAGE_CAP} (5%). */
  cap?: number;
}

/**
 * Slippage for the N-th attempt of an emergency flatten loop (orders.md §8.2):
 * `min(cap, base * (1 + 0.5 * min(attempt, 8)))`. Why: an IoC at a stale price
 * has nothing to match against, so repeating the same order is pointless; the cap
 * widens with each attempt. `attempt` starts at 0.
 */
export function escalatingSlippage(base: number, attempt: number, opts: EscalatingSlippageOptions = {}): number {
  assertFraction(base, 'base', true);
  const cap = opts.cap ?? DEFAULT_FLATTEN_SLIPPAGE_CAP;
  assertFraction(cap, 'cap', true);
  if (!Number.isSafeInteger(attempt) || attempt < 0) throw new HlFormatError(`Invalid attempt: ${String(attempt)}`);
  return Math.min(cap, base * (1 + 0.5 * Math.min(attempt, 8)));
}

export interface PostOnlyPriceInput extends PriceGridOptions {
  /** Desired quote price. */
  px: Numeric;
  side: OrderSide;
  /** Current best bid (needed to clamp asks). */
  bestBid?: Numeric;
  /** Current best ask (needed to clamp bids). */
  bestAsk?: Numeric;
}

/**
 * Price for a post-only (`Alo`) quote that cannot cross the book (orders.md §2.1, §5.3).
 *
 * The price is rounded passively (bid floor, ask ceil), then clamped: a bid is at
 * most one tick below `bestAsk`, an ask at least one tick above `bestBid`, using the
 * finer tick below a power of ten. Why: an Alo that would cross is rejected
 * (`badAloPxRejected`), not filled as taker; nearest rounding (`toPrecision`) can
 * move a quote across by a fraction of a tick. Throws on a crossed book
 * (`bestBid >= bestAsk`), which must not be quoted.
 */
export function postOnlyPrice(input: PostOnlyPriceInput): string {
  const buy = isBuySide(input.side);
  const bid = input.bestBid === undefined ? null : parsePositive(input.bestBid, 'bestBid');
  const ask = input.bestAsk === undefined ? null : parsePositive(input.bestAsk, 'bestAsk');
  if (bid && ask && cmp(bid, ask) >= 0) {
    throw new HlFormatError(`Crossed book: bestBid ${toPlain(bid)} >= bestAsk ${toPlain(ask)}`);
  }
  let p = roundPriceDec(parsePositive(input.px, 'price'), input, buy ? 'floor' : 'ceil');
  if (buy && ask && cmp(p, ask) >= 0) {
    // ceil(ask) - one tick is always strictly below ask.
    p = tickDown(roundPriceDec(ask, input, 'ceil'), input);
  } else if (!buy && bid && cmp(p, bid) <= 0) {
    // floor(bid) + one tick is always strictly above bid.
    p = tickUp(roundPriceDec(bid, input, 'floor'), input);
  }
  return toPlain(p);
}
All files