Files
trade-frontend/apps/visualizer/src/features/market/dlobDerivedMetrics.ts

245 lines
7.2 KiB
TypeScript

import type { DlobL2, OrderbookRow } from './useDlobL2';
export const DEFAULT_DLOB_DEPTH_BANDS_BPS = [5, 10, 20, 50, 100, 200] as const;
export const DEFAULT_DLOB_SLIPPAGE_SIZES_USD = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000, 50000] as const;
export type ComputedDlobDepthBandRow = {
marketName: string;
bandBps: number;
midPrice: number | null;
bestBid: number | null;
bestAsk: number | null;
bidUsd: number | null;
askUsd: number | null;
bidBase: number | null;
askBase: number | null;
imbalance: number | null;
updatedAt: string | null;
};
export type ComputedDlobSlippageRow = {
marketName: string;
side: 'buy' | 'sell';
sizeUsd: number;
midPrice: number | null;
vwapPrice: number | null;
worstPrice: number | null;
filledUsd: number | null;
filledBase: number | null;
impactBps: number | null;
levelsConsumed: number | null;
fillPct: number | null;
updatedAt: string | null;
};
type OrderbookSide = 'buy' | 'sell';
function levelUsd(level: OrderbookRow): number {
if (Number.isFinite(level.sizeUsd)) return level.sizeUsd;
if (Number.isFinite(level.sizeBase) && Number.isFinite(level.price)) return level.sizeBase * level.price;
return 0;
}
function canonicalBids(l2: DlobL2): OrderbookRow[] {
return (Array.isArray(l2.bids) ? l2.bids : [])
.slice()
.sort((a, b) => b.price - a.price);
}
function canonicalAsks(l2: DlobL2): OrderbookRow[] {
return (Array.isArray(l2.asks) ? l2.asks.slice().reverse() : [])
.slice()
.sort((a, b) => a.price - b.price);
}
function resolveBestBid(l2: DlobL2, bids: OrderbookRow[]): number | null {
if (l2.bestBid != null && Number.isFinite(l2.bestBid)) return l2.bestBid;
const price = bids[0]?.price;
return Number.isFinite(price) ? price : null;
}
function resolveBestAsk(l2: DlobL2, asks: OrderbookRow[]): number | null {
if (l2.bestAsk != null && Number.isFinite(l2.bestAsk)) return l2.bestAsk;
const price = asks[0]?.price;
return Number.isFinite(price) ? price : null;
}
function resolveMidPrice(l2: DlobL2, bestBid: number | null, bestAsk: number | null): number | null {
if (l2.mid != null && Number.isFinite(l2.mid)) return l2.mid;
if (bestBid != null && bestAsk != null) return (bestBid + bestAsk) / 2;
return bestBid ?? bestAsk ?? null;
}
function computeBandDepth(
bids: OrderbookRow[],
asks: OrderbookRow[],
midPrice: number | null,
bandBps: number
): Pick<ComputedDlobDepthBandRow, 'bidUsd' | 'askUsd' | 'bidBase' | 'askBase' | 'imbalance'> {
if (midPrice == null || !Number.isFinite(midPrice) || !(midPrice > 0)) {
return {
bidUsd: null,
askUsd: null,
bidBase: null,
askBase: null,
imbalance: null,
};
}
const minBidPrice = midPrice * (1 - bandBps / 10_000);
const maxAskPrice = midPrice * (1 + bandBps / 10_000);
let bidBase = 0;
let askBase = 0;
let bidUsd = 0;
let askUsd = 0;
for (const level of bids) {
if (!Number.isFinite(level.price) || !Number.isFinite(level.sizeBase)) continue;
if (level.price < minBidPrice) break;
bidBase += level.sizeBase;
bidUsd += levelUsd(level);
}
for (const level of asks) {
if (!Number.isFinite(level.price) || !Number.isFinite(level.sizeBase)) continue;
if (level.price > maxAskPrice) break;
askBase += level.sizeBase;
askUsd += levelUsd(level);
}
const denom = bidUsd + askUsd;
return {
bidUsd,
askUsd,
bidBase,
askBase,
imbalance: denom > 0 ? (bidUsd - askUsd) / denom : null,
};
}
function simulateFill(levels: OrderbookRow[], sizeUsd: number) {
let remainingUsd = sizeUsd;
let filledUsd = 0;
let filledBase = 0;
let worstPrice: number | null = null;
let levelsConsumed = 0;
for (const level of levels) {
const availableUsd = levelUsd(level);
if (!(availableUsd > 0) || !Number.isFinite(level.price) || !(level.price > 0)) continue;
if (remainingUsd <= 0) break;
levelsConsumed += 1;
worstPrice = level.price;
const takeUsd = Math.min(remainingUsd, availableUsd);
filledUsd += takeUsd;
filledBase += takeUsd / level.price;
remainingUsd -= takeUsd;
}
return {
filledUsd,
filledBase,
worstPrice,
levelsConsumed,
vwapPrice: filledBase > 0 ? filledUsd / filledBase : null,
fillPct: sizeUsd > 0 ? (filledUsd / sizeUsd) * 100 : null,
};
}
function impactBps(side: OrderbookSide, midPrice: number | null, vwapPrice: number | null): number | null {
if (midPrice == null || vwapPrice == null) return null;
if (!(midPrice > 0) || !Number.isFinite(vwapPrice)) return null;
if (side === 'buy') return ((vwapPrice / midPrice) - 1) * 10_000;
return (1 - vwapPrice / midPrice) * 10_000;
}
export function computeDepthBandsFromDlobL2(
l2: DlobL2 | null | undefined,
bandList: readonly number[] = DEFAULT_DLOB_DEPTH_BANDS_BPS
): ComputedDlobDepthBandRow[] {
if (!l2?.marketName) return [];
const bids = canonicalBids(l2);
const asks = canonicalAsks(l2);
const bestBid = resolveBestBid(l2, bids);
const bestAsk = resolveBestAsk(l2, asks);
const midPrice = resolveMidPrice(l2, bestBid, bestAsk);
const updatedAt = l2.updatedAt ?? null;
return bandList
.map((bandBps) => Number(bandBps))
.filter((bandBps, idx, arr) => Number.isFinite(bandBps) && bandBps > 0 && arr.indexOf(bandBps) === idx)
.sort((a, b) => a - b)
.map((bandBps) => ({
marketName: l2.marketName,
bandBps,
midPrice,
bestBid,
bestAsk,
updatedAt,
...computeBandDepth(bids, asks, midPrice, bandBps),
}));
}
export function computeSlippageFromDlobL2(
l2: DlobL2 | null | undefined,
sizeList: readonly number[] = DEFAULT_DLOB_SLIPPAGE_SIZES_USD
): ComputedDlobSlippageRow[] {
if (!l2?.marketName) return [];
const bids = canonicalBids(l2);
const asks = canonicalAsks(l2);
const bestBid = resolveBestBid(l2, bids);
const bestAsk = resolveBestAsk(l2, asks);
const midPrice = resolveMidPrice(l2, bestBid, bestAsk);
const updatedAt = l2.updatedAt ?? null;
if (midPrice == null || !Number.isFinite(midPrice) || !(midPrice > 0)) return [];
const sizes = sizeList
.map((sizeUsd) => Number(sizeUsd))
.filter((sizeUsd, idx, arr) => Number.isFinite(sizeUsd) && sizeUsd > 0 && arr.indexOf(sizeUsd) === idx)
.sort((a, b) => a - b);
const rows: ComputedDlobSlippageRow[] = [];
for (const sizeUsd of sizes) {
const buyFill = simulateFill(asks, sizeUsd);
rows.push({
marketName: l2.marketName,
side: 'buy',
sizeUsd,
midPrice,
vwapPrice: buyFill.vwapPrice,
worstPrice: buyFill.worstPrice,
filledUsd: buyFill.filledUsd,
filledBase: buyFill.filledBase,
impactBps: impactBps('buy', midPrice, buyFill.vwapPrice),
levelsConsumed: buyFill.levelsConsumed,
fillPct: buyFill.fillPct,
updatedAt,
});
const sellFill = simulateFill(bids, sizeUsd);
rows.push({
marketName: l2.marketName,
side: 'sell',
sizeUsd,
midPrice,
vwapPrice: sellFill.vwapPrice,
worstPrice: sellFill.worstPrice,
filledUsd: sellFill.filledUsd,
filledBase: sellFill.filledBase,
impactBps: impactBps('sell', midPrice, sellFill.vwapPrice),
levelsConsumed: sellFill.levelsConsumed,
fillPct: sellFill.fillPct,
updatedAt,
});
}
return rows;
}