src/format/slippage.ts
v0.3.0 · 6.3 KB
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);
}