Skip to content
markpaper

src/history/roundtrips.ts

v0.3.0 · 10.7 KB

Download file
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,
  };
}
All files