245 lines
7.2 KiB
TypeScript
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;
|
|
}
|