src/risk/stops.ts
v0.3.0 · 5.8 KB
// 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)));
}