diff --git a/ts-sdk/package-lock.json b/ts-sdk/package-lock.json index 434806d9..f48d8b37 100644 --- a/ts-sdk/package-lock.json +++ b/ts-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pod-network/trade-sdk", - "version": "0.12.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pod-network/trade-sdk", - "version": "0.12.0", + "version": "0.13.0", "license": "MIT", "dependencies": { "viem": "^2.21.0" diff --git a/ts-sdk/package.json b/ts-sdk/package.json index 7f45ed1a..717c8e1e 100644 --- a/ts-sdk/package.json +++ b/ts-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@pod-network/trade-sdk", - "version": "0.12.0", + "version": "0.13.0", "description": "Read/stream TypeScript SDK for the pod trading indexer: cacheable REST seeds + one multiplexed WebSocket, kept in memory. Framework-agnostic.", "type": "module", "sideEffects": false, diff --git a/ts-sdk/src/codec/fixed.ts b/ts-sdk/src/codec/fixed.ts index 6bef8863..c17d97a2 100644 --- a/ts-sdk/src/codec/fixed.ts +++ b/ts-sdk/src/codec/fixed.ts @@ -5,6 +5,9 @@ import { WAD } from "./units.js"; const absB = (x: bigint) => (x < 0n ? -x : x); +/** `Math.min` for bigints, which it rejects. */ +export const min = (a: bigint, b: bigint) => (a < b ? a : b); + /** Decimal `*`: (a*b)/1e18 truncated toward zero. */ export function mul(a: bigint, b: bigint): bigint { const n = a * b; diff --git a/ts-sdk/src/index.ts b/ts-sdk/src/index.ts index dc32b82d..b0bf90d3 100644 --- a/ts-sdk/src/index.ts +++ b/ts-sdk/src/index.ts @@ -30,6 +30,7 @@ export { closePreview, type ClosePreview, priceForReturn, type ReturnPriceInput, closestTriggers, + estimateFill, type FillEstimate, } from "./sync/order-preview.js"; export type { SeriesResource } from "./sync/candles.js"; export { CandleSeries } from "./sync/candles.js"; diff --git a/ts-sdk/src/sync/order-preview.test.ts b/ts-sdk/src/sync/order-preview.test.ts index 086c7a6a..51f0ac08 100644 --- a/ts-sdk/src/sync/order-preview.test.ts +++ b/ts-sdk/src/sync/order-preview.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import type { Market, PerpPosition, PositionsSnapshot } from "../types/public.js"; import { div, imRate, mul } from "../codec/fixed.js"; import { WAD } from "../codec/units.js"; -import { previewOrder } from "./order-preview.js"; +import { estimateFill, previewOrder } from "./order-preview.js"; const LOT = 10n ** 12n; // 1e-6 base units — every staging/canary perp const PRICE = 100n * WAD; @@ -176,3 +176,55 @@ describe("previewOrder liquidationPrice", () => { expect(p.liquidationPrice).toBeUndefined(); }); }); + +describe("estimateFill", () => { + const lvl = (price: number, volume: number) => ({ price: BigInt(price) * WAD, volume: BigInt(volume) * WAD, total: 0n, depth: 0 }); + const book = { + asks: [lvl(100, 1), lvl(101, 2), lvl(103, 5)], + bids: [lvl(99, 1), lvl(98, 2)], + }; + + it("clears at a part-taken best level with no slippage", () => { + const f = estimateFill(book, "long", WAD / 2n); + expect(f).toEqual({ filled: WAD / 2n, price: 100n * WAD, slippagePct: 0, exhausted: false }); + }); + + it("taking a level whole clears midway to the next one", () => { + expect(estimateFill(book, "long", WAD).price).toBe(100n * WAD + WAD / 2n); + }); + + it("charges the whole fill the last level's price, not the average across levels", () => { + const f = estimateFill(book, "long", 2n * WAD); + expect(f.price).toBe(101n * WAD); // 101 still holds 1 after this fill + expect(f.slippagePct).toBeCloseTo(1, 6); + }); + + it("stops at the limit, and the clearing price never passes it", () => { + // 100 and 101 taken whole: the midpoint to 103 would be 102.5, over the limit. + const f = estimateFill(book, "long", 5n * WAD, 102n * WAD); + expect(f.filled).toBe(3n * WAD); + expect(f.price).toBe(102n * WAD); + expect(f.exhausted).toBe(false); + }); + + it("fills nothing when the best level is already past the limit", () => { + expect(estimateFill(book, "long", WAD, 99n * WAD)).toEqual({ filled: 0n, exhausted: false }); + }); + + it("walks the bids for a short, slippage still counted as worse", () => { + const f = estimateFill(book, "short", 2n * WAD); + expect(f.price).toBe(98n * WAD); + expect(f.slippagePct).toBeCloseTo((1 / 99) * 100, 6); + }); + + it("flags running off the levels given, clearing at the last one with nothing past it", () => { + const f = estimateFill(book, "short", 10n * WAD, 90n * WAD); + expect(f.filled).toBe(3n * WAD); + expect(f.price).toBe(98n * WAD); + expect(f.exhausted).toBe(true); + }); + + it("an empty side fills nothing and is exhausted", () => { + expect(estimateFill({ asks: [], bids: [] }, "long", WAD)).toEqual({ filled: 0n, exhausted: true }); + }); +}); diff --git a/ts-sdk/src/sync/order-preview.ts b/ts-sdk/src/sync/order-preview.ts index 9eaaa851..a5e2f275 100644 --- a/ts-sdk/src/sync/order-preview.ts +++ b/ts-sdk/src/sync/order-preview.ts @@ -8,8 +8,8 @@ // implied_leverage = (Σ current perp notional + notional) / perps_equity // liquidation = mark where equity falls to Σ maintenance margin (im/2) -import type { Market, PerpPosition, PositionsSnapshot, Trigger } from "../types/public.js"; -import { div, imRate, mul } from "../codec/fixed.js"; +import type { Market, Orderbook, PerpPosition, PositionsSnapshot, Trigger } from "../types/public.js"; +import { div, imRate, min, mul } from "../codec/fixed.js"; import { alignSize, parseAmount, toNumber, WAD } from "../codec/units.js"; export interface ReturnPriceInput { @@ -220,3 +220,57 @@ export function closePreview( fee: mul(notional, input.feeRate ?? 0n), }; } + +export interface FillEstimate { + /** Base size the book fills within `limit` (≤ `size`). */ + filled: bigint; + /** The price the whole fill clears at were this the batch's only order; + * undefined when nothing fills. A batch clears every match at one price and + * a market order never sets it, so this is at least the last level reached — + * not an average across the levels. */ + price?: bigint; + /** How much worse `price` is than the best level, in percent — the cost of + * the size alone, spread excluded. */ + slippagePct?: number; + /** The walk ran off the last level given while still inside `limit`. On a + * depth-truncated book `filled` is then a floor, not an estimate. */ + exhausted: boolean; +} + +/** + * What a taker order of `size` (base, magnitude) would fill against `book`: + * walks the opposite side best-first — asks for a long, bids for a short — and + * stops at `limit`, a market order's protective price. Grouped levels make the + * prices approximate to the grouping. + */ +export function estimateFill( + book: Pick, + side: "long" | "short", + size: bigint, + limit?: bigint, +): FillEstimate { + const levels = side === "long" ? book.asks : book.bids; + const within = (p: bigint) => limit === undefined || (side === "long" ? p <= limit : p >= limit); + let filled = 0n; + let left = 0n; // volume the last level reached still holds + let i = 0; + for (; i < levels.length && filled < size; i++) { + const l = levels[i]!; + if (!within(l.price)) break; + const take = min(l.volume, size - filled); + filled += take; + left = l.volume - take; + } + const exhausted = filled < size && i === levels.length; + if (filled === 0n) return { filled, exhausted }; + // The clearing range runs from the last level matched to the first level left + // unmatched — that same level when only part of it was taken — and settles at + // its midpoint, never past the order's own limit (doc/protocol/orderbook.md, Clearing). + const last = levels[i - 1]!.price; + const unmatched = left > 0n ? last : levels[i]?.price; + let price = unmatched === undefined ? last : (last + unmatched) / 2n; + if (limit !== undefined && !within(price)) price = limit; + const best = levels[0]!.price; + const worse = side === "long" ? price - best : best - price; + return { filled, price, slippagePct: (Number(worse) / Number(best)) * 100, exhausted }; +}