src/history/roundtrips.ts
v0.3.0 · 10.7 KB
import {
ZERO,
abs,
add,
cmp,
max,
mul,
parseDec,
pow10Neg,
requireDec,
sign,
sub,
toStr,
type Dec,
} from './decimal.js';
import { isSpotCoin, sortFills } from './fills.js';
import type { FillLike, PositionSide } from './types.js';
/** A completed position cycle: flat -> non-flat -> flat. */
export interface RoundTrip {
coin: string;
side: PositionSide;
openTime: number;
closeTime: number;
durationMs: number;
/** Fills that belong to the trip (opening, adding, reducing, closing). */
fillCount: number;
/** Sum of closedPnl over the reducing fills of the trip (exact decimal string). */
closedPnl: string;
/** Sum of fees of the trip fills. A flipping fill's fee is attributed to the trip it closes. */
fee: string;
/** Sum of |size reduced| * px. */
closeNotional: string;
/** Largest absolute position during the trip. */
maxSize: string;
/**
* `flat` - a fill brought the position to zero; `flip` - a fill crossed zero
* and opened the opposite side; `inferred` - a later fill reported a flat
* `startPosition` although no seen fill closed the trip (a fill is missing
* from the input, e.g. a TWAP slice).
*/
closeKind: 'flat' | 'flip' | 'inferred';
}
/** A cycle still open at the end of the input. */
export interface OpenRoundTrip {
coin: string;
side: PositionSide;
openTime: number;
lastTime: number;
fillCount: number;
closedPnl: string;
fee: string;
/** Signed position after the last fill. */
position: string;
}
export interface RoundTripsResult {
/** Closed trips sorted by close time. */
trips: RoundTrip[];
/** Trips still open at the end of the input, one per coin at most. */
open: OpenRoundTrip[];
/**
* Fills of positions that were already open when the input begins (window
* boundary). They are not turned into a phantom cycle.
*/
skippedPreWindow: number;
/** Fills that are not position trades: `Settlement`, spot, unknown dirs. */
skippedNonTrade: number;
}
/**
* true for dirs that move a perp position: `Open ...`, `Close ...`, liquidations
* (contain `Liquidat`), and any other dir naming Long/Short. `Settlement` and
* spot `Buy`/`Sell` are not trades of a cycle.
*/
export function isPositionTradeDir(dir: string): boolean {
if (/settlement/i.test(dir)) return false;
return /^(open|close)\b/i.test(dir) || /liquidat/i.test(dir) || /long|short/i.test(dir);
}
/** true for closing fills: dir starts with `Close` or contains `Liquidat` (case-insensitive). */
export function isClosingDir(dir: string): boolean {
return /^close/i.test(dir) || /liquidat/i.test(dir);
}
const EPS_REL = pow10Neg(6);
const EPS_ABS = pow10Neg(9);
/**
* Relative flat threshold `eps = max(sz * 1e-6, 1e-9)`: coins have very
* different size scales, a fixed epsilon is wrong for some of them.
*/
function isFlat(pos: Dec, fillSz: Dec): boolean {
const eps = max(mul(fillSz, EPS_REL), EPS_ABS);
return cmp(abs(pos), eps) <= 0;
}
interface TripState {
side: PositionSide;
openTime: number;
lastTime: number;
fillCount: number;
pnl: Dec;
fee: Dec;
closeNotional: Dec;
maxSize: Dec;
position: Dec;
}
const sideOf = (pos: Dec): PositionSide => (sign(pos) > 0 ? 'LONG' : 'SHORT');
function finish(coin: string, t: TripState, closeTime: number, closeKind: RoundTrip['closeKind']): RoundTrip {
return {
coin,
side: t.side,
openTime: t.openTime,
closeTime,
durationMs: closeTime - t.openTime,
fillCount: t.fillCount,
closedPnl: toStr(t.pnl),
fee: toStr(t.fee),
closeNotional: toStr(t.closeNotional),
maxSize: toStr(t.maxSize),
closeKind,
};
}
function openTrip(time: number, position: Dec): TripState {
return {
side: sideOf(position),
openTime: time,
lastTime: time,
fillCount: 1,
pnl: ZERO,
fee: ZERO,
closeNotional: ZERO,
maxSize: abs(position),
position,
};
}
/**
* Reconstructs flat -> flat position cycles per coin from fills.
*
* Rules:
* - The trajectory comes from `startPosition` (the position BEFORE each fill),
* never from "sum opens, closes eat them". Rebuilding from zero over a
* 2000-fill window glues the tail of an earlier position into a phantom
* cycle and cannot tell a real flat from a partial unload of a large position.
* - A cycle opens on a fill whose `startPosition` is flat and ends when the
* position returns to flat. Fills of a position already open at the start of
* the input are skipped (`skippedPreWindow`); a position open at the end is
* reported in `open`.
* - Flat uses a relative epsilon `max(sz * 1e-6, 1e-9)`; arithmetic is exact decimal.
* - PnL of a cycle = sum of closedPnl of ALL reducing fills (partial closes and
* the final one); one fill never carries the PnL of a position.
* - Delta sign comes from `side` (`B` = +sz, `A` = -sz), which also covers
* liquidation and flip dirs.
*
* Input: fills of any coins in any order (sorted internally). Include TWAP
* slices (`mergeFillsWithTwap`), otherwise TWAP-driven changes show up as
* `inferred` closes or skipped fills.
*/
export function reconstructRoundTrips(fills: readonly FillLike[]): RoundTripsResult {
const states = new Map<string, TripState | null>();
const trips: RoundTrip[] = [];
let skippedPreWindow = 0;
let skippedNonTrade = 0;
for (const f of sortFills(fills)) {
if (isSpotCoin(f.coin) || !isPositionTradeDir(f.dir) || f.startPosition === undefined) {
skippedNonTrade += 1;
continue;
}
const before = requireDec(f.startPosition, 'startPosition');
const sz = abs(requireDec(f.sz, 'sz'));
const px = requireDec(f.px, 'px');
const fee = parseDec(f.fee) ?? ZERO;
const pnl = parseDec(f.closedPnl) ?? ZERO;
const after = f.side === 'B' ? add(before, sz) : sub(before, sz);
const wasFlat = isFlat(before, sz);
const nowFlat = isFlat(after, sz);
let trip = states.get(f.coin) ?? null;
if (trip && wasFlat) {
// The exchange says we were flat, yet our trip is open: a closing fill is
// missing from the input. Close at the last seen fill.
trips.push(finish(f.coin, trip, trip.lastTime, 'inferred'));
trip = null;
} else if (trip && sideOf(before) !== trip.side) {
// Position changed side without a seen flip: the opening of the current
// position is missing, so close ours and treat the rest as unknown.
trips.push(finish(f.coin, trip, trip.lastTime, 'inferred'));
trip = null;
}
if (!trip) {
if (wasFlat) {
if (!nowFlat) {
const t = openTrip(f.time, after);
t.fee = fee;
states.set(f.coin, t);
} else {
states.set(f.coin, null);
}
continue;
}
// Position predates the input (or an unseen fill opened it).
skippedPreWindow += 1;
if (!nowFlat && sign(after) !== sign(before)) {
// A flip out of a pre-window position opens a real cycle here.
states.set(f.coin, openTrip(f.time, after));
} else {
states.set(f.coin, null);
}
continue;
}
// Trip open and position non-flat before the fill.
trip.fillCount += 1;
trip.lastTime = f.time;
trip.fee = add(trip.fee, fee);
const flipped = !nowFlat && sign(after) !== sign(before);
if (flipped) {
trip.pnl = add(trip.pnl, pnl);
trip.closeNotional = add(trip.closeNotional, mul(abs(before), px));
trips.push(finish(f.coin, trip, f.time, 'flip'));
states.set(f.coin, openTrip(f.time, after));
continue;
}
const reduced = cmp(abs(after), abs(before)) < 0;
if (reduced) {
trip.pnl = add(trip.pnl, pnl);
trip.closeNotional = add(trip.closeNotional, mul(sub(abs(before), abs(after)), px));
}
trip.position = after;
trip.maxSize = max(trip.maxSize, abs(after));
if (nowFlat) {
trips.push(finish(f.coin, trip, f.time, 'flat'));
states.set(f.coin, null);
}
}
const open: OpenRoundTrip[] = [];
for (const [coin, t] of states) {
if (!t) continue;
open.push({
coin,
side: t.side,
openTime: t.openTime,
lastTime: t.lastTime,
fillCount: t.fillCount,
closedPnl: toStr(t.pnl),
fee: toStr(t.fee),
position: toStr(t.position),
});
}
trips.sort((a, b) => a.closeTime - b.closeTime);
open.sort((a, b) => a.openTime - b.openTime);
return { trips, open, skippedPreWindow, skippedNonTrade };
}
export interface RealizedPnlQuery {
coin: string;
side: PositionSide;
/** Open time of the CURRENT position; older closes of the same coin are ignored. */
openedAt: number;
}
export interface RealizedPnlResult<F extends FillLike> {
/** Sum of closedPnl over all matching close fills, or undefined when none has a finite value. */
realizedPnl: string | undefined;
/** Price of the latest close fill (if > 0). */
exitPx: string | undefined;
exitTime: number | undefined;
/** Matching close fills, newest first. */
closeFills: F[];
}
/**
* Realized PnL of a (closed or partially closed) position: the sum of closedPnl
* over EVERY close fill of that coin and side with `time >= openedAt`. Close
* fills: dir contains `Close` or `Liquidat` plus the side, or a flip
* `Long > Short` / `Short > Long` whose left side is the position side.
*
* Why: partial closes are separate fills, so "PnL of the last fill" understates
* the result; without the `openedAt` filter, earlier trades of the same pair are
* picked up. HL's realized PnL is more reliable than the last snapshot's unrealizedPnl.
* Note: index lag - a just-closed position's fill can appear in `userFills`
* seconds after the WS position update; retry, it is not an error.
*/
export function realizedPnlSince<F extends FillLike>(fills: readonly F[], q: RealizedPnlQuery): RealizedPnlResult<F> {
const sideWord = q.side === 'LONG' ? 'long' : 'short';
const closeFills = fills
.filter((f) => {
if (f.coin !== q.coin || f.time < q.openedAt) return false;
const d = f.dir.toLowerCase();
// A fill that crosses zero (HL dir `Long > Short`) closes the left side and
// carries its closedPnl; skipping it understates the closed position's PnL.
const flip = /^\s*(long|short)\s*>\s*(long|short)\s*$/.exec(d);
if (flip) return flip[1] === sideWord;
return (d.includes('close') || d.includes('liquidat')) && d.includes(sideWord);
})
.sort((a, b) => b.time - a.time || (b.tid ?? 0) - (a.tid ?? 0));
let sum: Dec | null = null;
for (const f of closeFills) {
const v = parseDec(f.closedPnl);
if (v) sum = add(sum ?? ZERO, v);
}
const last = closeFills[0];
const lastPx = last ? parseDec(last.px) : null;
return {
realizedPnl: sum ? toStr(sum) : undefined,
exitPx: last && lastPx && sign(lastPx) > 0 ? last.px : undefined,
exitTime: last?.time,
closeFills,
};
}