Skip to content
markpaper

src/history/group.ts

v0.3.0 · 4.2 KB

Download file
import { ZERO, add, cmp, div, mul, parseDec, requireDec, toStr, type Dec } from './decimal.js';
import { sortFills } from './fills.js';
import type { FillLike, FillSide } from './types.js';

/** Partial fills of one order (or one event, when the oid is unknown), aggregated. */
export interface OrderGroup {
  /** `coin|oid:<oid>` or the fallback `coin|time|dir`. */
  key: string;
  coin: string;
  oid: number | null;
  side: FillSide;
  /** Dir of the first fill. */
  dir: string;
  /** Distinct dirs in order of appearance (an order may flip a position). */
  dirs: string[];
  firstTime: number;
  lastTime: number;
  /** Number of partial fills. */
  fillCount: number;
  /** Sum of sizes (exact decimal string). */
  sz: string;
  /** Sum of px * sz (exact decimal string). */
  notional: string;
  /** Size-weighted average price, rounded to 12 fractional digits. */
  px: string;
  closedPnl: string;
  fee: string;
  /** Sum of builderFee over fills that carry it, or null when none do. */
  builderFee: string | null;
  /** Number of taker fills (`crossed === true`). */
  takerFills: number;
  tids: number[];
}

/**
 * Group key of a partial fill: `coin|oid:<oid>`, falling back to
 * `coin|time|dir` only when the oid is missing.
 *
 * Why oid: a large resting limit eaten in pieces produces partial fills with
 * DIFFERENT times but one oid. A `(coin, time, dir)` key does not merge them,
 * so the order count equals the fill count.
 */
export function orderGroupKey(f: FillLike): string {
  return f.oid !== undefined && f.oid !== null ? `${f.coin}|oid:${f.oid}` : `${f.coin}|${f.time}|${f.dir}`;
}

interface Acc {
  group: OrderGroup;
  sz: Dec;
  notional: Dec;
  pnl: Dec;
  fee: Dec;
  builderFee: Dec | null;
}

/**
 * Aggregates partial fills into orders: sizes, closedPnl and fees are SUMMED,
 * price is size-weighted. Groups are sorted by first fill time.
 *
 * Why summing matters: a dedup by `coin:timestamp:type` via `Map.set`
 * overwrites partial fills instead of summing them, and most of the traded size
 * and PnL silently drops out of the totals.
 *
 * Server-side `aggregateByTime: true` only merges partials within one time
 * slice; it will not merge a resting limit filled at different times.
 */
export function groupFillsByOrder(fills: readonly FillLike[]): OrderGroup[] {
  const byKey = new Map<string, Acc>();
  for (const f of sortFills(fills)) {
    const key = orderGroupKey(f);
    const sz = requireDec(f.sz, 'sz');
    const px = requireDec(f.px, 'px');
    const pnl = parseDec(f.closedPnl) ?? ZERO;
    const fee = parseDec(f.fee) ?? ZERO;
    const bf = f.builderFee !== undefined ? parseDec(f.builderFee) : null;
    let acc = byKey.get(key);
    if (!acc) {
      acc = {
        group: {
          key,
          coin: f.coin,
          oid: f.oid ?? null,
          side: f.side,
          dir: f.dir,
          dirs: [],
          firstTime: f.time,
          lastTime: f.time,
          fillCount: 0,
          sz: '0',
          notional: '0',
          px: '0',
          closedPnl: '0',
          fee: '0',
          builderFee: null,
          takerFills: 0,
          tids: [],
        },
        sz: ZERO,
        notional: ZERO,
        pnl: ZERO,
        fee: ZERO,
        builderFee: null,
      };
      byKey.set(key, acc);
    }
    const g = acc.group;
    g.fillCount += 1;
    g.lastTime = Math.max(g.lastTime, f.time);
    g.firstTime = Math.min(g.firstTime, f.time);
    if (!g.dirs.includes(f.dir)) g.dirs.push(f.dir);
    if (f.crossed === true) g.takerFills += 1;
    if (f.tid !== undefined && f.tid !== null) g.tids.push(f.tid);
    acc.sz = add(acc.sz, sz);
    acc.notional = add(acc.notional, mul(px, sz));
    acc.pnl = add(acc.pnl, pnl);
    acc.fee = add(acc.fee, fee);
    if (bf) acc.builderFee = add(acc.builderFee ?? ZERO, bf);
  }

  const out: OrderGroup[] = [];
  for (const acc of byKey.values()) {
    const g = acc.group;
    g.sz = toStr(acc.sz);
    g.notional = toStr(acc.notional);
    g.px = cmp(acc.sz, ZERO) === 0 ? '0' : toStr(div(acc.notional, acc.sz, 12));
    g.closedPnl = toStr(acc.pnl);
    g.fee = toStr(acc.fee);
    g.builderFee = acc.builderFee ? toStr(acc.builderFee) : null;
    out.push(g);
  }
  return out.sort((a, b) => a.firstTime - b.firstTime);
}
All files