src/history/group.ts
v0.3.0 · 4.2 KB
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);
}