Skip to content
markpaper

src/risk/stops.ts

v0.3.0 · 5.8 KB

Download file
// ROE levels -> trigger prices, native TP/SL leg prices and the average entry after an increase.
//
// Convention: ROE values are FRACTIONS (−0.3 = −30%). Prices returned here are
// UNROUNDED; round them to the asset's tick / significant figures before
// sending an order.

import { decAdd, decDiv, decMul, decToNumber } from './decimal.js';
import { sideSign, type PositionSide } from './margin.js';

export type RoeBasis = 'entry' | 'mark';

export interface RoeToPriceInput {
  side: PositionSide;
  entryPx: number;
  leverage: number;
  /** Target ROE as a fraction: negative for a stop, positive for a take-profit. */
  roe: number;
}

function stepOf(input: RoeToPriceInput): number | null {
  if (!(input.entryPx > 0) || !Number.isFinite(input.entryPx)) return null;
  // Leverage below 1 (e.g. 0.5) is not a valid HL leverage.
  if (!(input.leverage >= 1) || !Number.isFinite(input.leverage) || !Number.isFinite(input.roe)) return null;
  return (sideSign(input.side) * input.roe) / input.leverage;
}

/**
 * Price at which a position reaches a ROE level under an explicit margin basis.
 * With `s = dir × roe / leverage`:
 * - `entry` basis (isolated, margin ≈ size × entry / L): `px = entry × (1 + s)`;
 * - `mark` basis (cross, margin = size × mark / L): `px = entry / (1 − s)`.
 *
 * @returns `null` for invalid input, an unreachable mark-based level (`s >= 1`)
 * or a non-positive price.
 */
export function roeToPrice(input: RoeToPriceInput & { basis: RoeBasis }): number | null {
  const s = stepOf(input);
  if (s === null) return null;
  let px: number;
  if (input.basis === 'entry') {
    px = input.entryPx * (1 + s);
  } else {
    if (s >= 1) return null;
    px = input.entryPx / (1 - s);
  }
  return px > 0 && Number.isFinite(px) ? px : null;
}

/**
 * Conservative ROE level -> trigger price when the margin mode is unknown.
 *
 * For each leg the formula FARTHER from entry is used, so a native (exchange)
 * trigger fires no earlier than the client-side stop under either mode:
 * above entry -> mark-based `entry / (1 − s)`; below entry -> entry-based
 * `entry × (1 + s)`; `s = dir × roe / leverage`. A non-conservative conversion
 * lets the native stop fire before the client stop.
 *
 * Examples (entry 100, 10x): long −25% -> 97.5; long +50% -> ≈105.26
 * (entry-based would give 105); short −25% -> ≈102.56; short +50% -> 95.
 *
 * @returns `null` when entry <= 0, leverage < 1, `s >= 1` (e.g. long 1x +110%:
 * the mark-based level is unreachable) or the price would be <= 0 (long 1x −120%).
 */
export function roeToTriggerPrice(input: RoeToPriceInput): number | null {
  const s = stepOf(input);
  if (s === null) return null;
  if (s >= 1) return null;
  const px = s > 0 ? input.entryPx / (1 - s) : input.entryPx * (1 + s);
  return px > 0 && Number.isFinite(px) ? px : null;
}

export interface TpslLegInput extends RoeToPriceInput {
  /**
   * Worst acceptable execution after triggering, as a fraction (0.1 = 10%).
   * Exits get a deliberately wide bound so a thin book cannot stall the close.
   */
  closeSlippage: number;
}

export interface TpslLegPrices {
  /** Exit side: `true` = buy (closing a short). */
  isBuy: boolean;
  triggerPx: number;
  /** Limit price `p` of the trigger order (slippage bound after triggering). */
  limitPx: number;
}

/**
 * Prices of one `positionTpsl` leg (market trigger, `s: '0'`, reduce-only).
 * Trigger from {@link roeToTriggerPrice}; limit shifted toward execution:
 * `trigger × (1 + slip)` for a buy exit, `trigger × (1 − slip)` for a sell exit.
 *
 * Native TP/SL trigger on MARK price. Place the native level a few ROE points
 * beyond the programmatic stop: it is the backstop for a dead or 429-throttled
 * backend, not the primary stop. Do not attach a builder fee to these orders.
 *
 * @returns `null` when the leg is degenerate (see {@link roeToTriggerPrice});
 * the other leg of the pair can still be placed.
 * @throws RangeError when `closeSlippage` is outside [0, 1).
 */
export function tpslLegPrices(input: TpslLegInput): TpslLegPrices | null {
  if (!Number.isFinite(input.closeSlippage) || input.closeSlippage < 0 || input.closeSlippage >= 1) {
    throw new RangeError(`closeSlippage must be within [0, 1), got ${input.closeSlippage}`);
  }
  const triggerPx = roeToTriggerPrice(input);
  if (triggerPx === null) return null;
  const isBuy = input.side === 'short';
  const limitPx = triggerPx * (isBuy ? 1 + input.closeSlippage : 1 - input.closeSlippage);
  return { isBuy, triggerPx, limitPx };
}

export interface AverageEntryInput {
  /** Position size before the fill (magnitude). */
  prevSize: number;
  /** Average entry before the fill; `null`/<=0 when unknown. */
  prevEntryPx: number | null | undefined;
  /** Fill size (magnitude). */
  fillSz: number;
  fillPx: number;
}

/**
 * New average entry after an increase, without a REST read:
 * `(prevSize × prevEntry + fillSz × fillPx) / (prevSize + fillSz)`.
 * Falls back to `fillPx` when the previous entry is unknown — worse than the
 * exact average but better than stale triggers. Re-place native triggers from
 * the new average.
 */
export function averageEntryPx(input: AverageEntryInput): number {
  const prevEntry = input.prevEntryPx;
  const prevSize = Math.abs(input.prevSize);
  const fillSz = Math.abs(input.fillSz);
  if (!(input.fillPx > 0) || !Number.isFinite(input.fillPx) || !Number.isFinite(fillSz)) {
    throw new RangeError(`fillPx and fillSz must be finite (fillPx > 0), got ${input.fillPx} / ${input.fillSz}`);
  }
  if (typeof prevEntry !== 'number' || !(prevEntry > 0) || !Number.isFinite(prevEntry)) return input.fillPx;
  if (!(prevSize > 0) || !Number.isFinite(prevSize)) return input.fillPx;
  // Exact numerator and denominator, one division at the end.
  const numerator = decAdd(decMul(prevSize, prevEntry), decMul(fillSz, input.fillPx));
  return decToNumber(decDiv(numerator, decAdd(prevSize, fillSz)));
}
All files