From 51d04aff7e1685922d250678dada10a8f5ca29be Mon Sep 17 00:00:00 2001 From: "Samuel EF. Tinnerholm" Date: Fri, 22 May 2026 18:30:04 +0300 Subject: [PATCH 1/3] feat: widen fetchOrderBook signature for historical archive support CCXT-compatible signature: fetchOrderBook(outcomeId, limit?, params?) - Add optional `limit` (depth) and `params` bag to all 14 exchange implementations, Router, and SDK client - Add `datetime` field to OrderBook type (CCXT-compatible) - Migrate `side` from positional arg to `params.side` in Limitless and Router (backwards compatible via params bag) - Prepares for hosted-pmxt to route `params.since` to ClickHouse archive for historical order book snapshots --- core/src/BaseExchange.ts | 18 ++++--- core/src/exchanges/baozi/index.ts | 2 +- core/src/exchanges/gemini-titan/index.ts | 2 +- core/src/exchanges/hyperliquid/index.ts | 2 +- core/src/exchanges/kalshi/index.ts | 2 +- core/src/exchanges/limitless/index.ts | 3 +- core/src/exchanges/mock/index.ts | 2 +- core/src/exchanges/myriad/index.ts | 2 +- core/src/exchanges/opinion/index.ts | 2 +- core/src/exchanges/polymarket/index.ts | 2 +- core/src/exchanges/polymarket_us/index.ts | 2 +- core/src/exchanges/probable/index.ts | 2 +- core/src/exchanges/smarkets/index.ts | 2 +- core/src/router/Router.ts | 8 +-- core/src/router/e2e-orderbook.ts | 64 +++++++++++++++++++++++ core/src/types.ts | 2 + sdks/typescript/pmxt/client.ts | 8 ++- sdks/typescript/pmxt/models.ts | 5 +- 18 files changed, 103 insertions(+), 27 deletions(-) create mode 100644 core/src/router/e2e-orderbook.ts diff --git a/core/src/BaseExchange.ts b/core/src/BaseExchange.ts index fe90c727..4e45e53f 100644 --- a/core/src/BaseExchange.ts +++ b/core/src/BaseExchange.ts @@ -826,17 +826,19 @@ export abstract class PredictionMarketExchange { } /** - * Fetch the current order book (bids/asks) for a specific outcome. - * Essential for calculating spread, depth, and execution prices. + * Fetch the order book (bids/asks) for a specific outcome. * * @param outcomeId - The Outcome ID (outcomeId) or market slug - * @param side - Optional 'yes' or 'no' to explicitly indicate the - * outcome side. Required for exchanges where the API returns a - * single orderbook per market (e.g. Limitless) and the caller - * passes a slug instead of a token ID. - * @returns Current order book with bids and asks + * @param limit - Max number of bid/ask levels to return (CCXT-style). + * @param params - Optional parameters: + * - `side`: 'yes' or 'no' — explicitly indicate the outcome side + * (required for exchanges like Limitless where the API returns a + * single orderbook per market). + * - `since`: Unix timestamp (ms) — fetch a historical snapshot from + * the archive at or before this time (hosted API only). + * @returns Order book with bids and asks */ - async fetchOrderBook(outcomeId: string, side?: 'yes' | 'no'): Promise { + async fetchOrderBook(outcomeId: string, limit?: number, params?: Record): Promise { throw new Error("Method fetchOrderBook not implemented."); } diff --git a/core/src/exchanges/baozi/index.ts b/core/src/exchanges/baozi/index.ts index a26cec0a..9c3adcdf 100644 --- a/core/src/exchanges/baozi/index.ts +++ b/core/src/exchanges/baozi/index.ts @@ -123,7 +123,7 @@ export class BaoziExchange extends PredictionMarketExchange { return []; } - async fetchOrderBook(outcomeId: string): Promise { + async fetchOrderBook(outcomeId: string, _limit?: number, _params?: Record): Promise { const rawMarket = await this.fetcher.fetchRawOrderBook(outcomeId); return this.normalizer.normalizeOrderBook(rawMarket, outcomeId); } diff --git a/core/src/exchanges/gemini-titan/index.ts b/core/src/exchanges/gemini-titan/index.ts index 11e23730..423f2b8b 100644 --- a/core/src/exchanges/gemini-titan/index.ts +++ b/core/src/exchanges/gemini-titan/index.ts @@ -106,7 +106,7 @@ export class GeminiTitanExchange extends PredictionMarketExchange { .filter((e): e is UnifiedEvent => e !== null); } - async fetchOrderBook(outcomeId: string): Promise { + async fetchOrderBook(outcomeId: string, _limit?: number, _params?: Record): Promise { const { instrumentSymbol } = fromOutcomeId(outcomeId); const raw = await this.fetcher.fetchRawOrderBook(instrumentSymbol); if (!raw) { diff --git a/core/src/exchanges/hyperliquid/index.ts b/core/src/exchanges/hyperliquid/index.ts index c07a0e8e..729ae652 100644 --- a/core/src/exchanges/hyperliquid/index.ts +++ b/core/src/exchanges/hyperliquid/index.ts @@ -128,7 +128,7 @@ export class HyperliquidExchange extends PredictionMarketExchange { .filter((e): e is UnifiedEvent => e !== null); } - async fetchOrderBook(outcomeId: string): Promise { + async fetchOrderBook(outcomeId: string, _limit?: number, _params?: Record): Promise { const raw = await this.fetcher.fetchRawOrderBook(outcomeId); return this.normalizer.normalizeOrderBook(raw, outcomeId); } diff --git a/core/src/exchanges/kalshi/index.ts b/core/src/exchanges/kalshi/index.ts index 14db0d60..3a5fd516 100644 --- a/core/src/exchanges/kalshi/index.ts +++ b/core/src/exchanges/kalshi/index.ts @@ -193,7 +193,7 @@ export class KalshiExchange extends PredictionMarketExchange { return this.normalizer.normalizeOHLCV(rawCandles, params); } - async fetchOrderBook(outcomeId: string): Promise { + async fetchOrderBook(outcomeId: string, _limit?: number, _params?: Record): Promise { validateIdFormat(outcomeId, "OrderBook"); const raw = await this.fetcher.fetchRawOrderBook(outcomeId); return this.normalizer.normalizeOrderBook(raw, outcomeId); diff --git a/core/src/exchanges/limitless/index.ts b/core/src/exchanges/limitless/index.ts index 972bb0ef..6b58dad4 100644 --- a/core/src/exchanges/limitless/index.ts +++ b/core/src/exchanges/limitless/index.ts @@ -200,7 +200,7 @@ export class LimitlessExchange extends PredictionMarketExchange { return this.normalizer.normalizeOHLCV!(rawPrices as any, params); } - async fetchOrderBook(outcomeId: string, side?: 'yes' | 'no'): Promise { + async fetchOrderBook(outcomeId: string, limit?: number, params?: Record): Promise { const slug = await this.resolveSlug(outcomeId); const rawOrderBook = await this.fetcher.fetchRawOrderBook!(slug); const orderBook = this.normalizer.normalizeOrderBook!(rawOrderBook as any, outcomeId); @@ -208,6 +208,7 @@ export class LimitlessExchange extends PredictionMarketExchange { // The Limitless API always returns the Yes-side order book regardless // of which token is queried. If the caller asked for the No token, // flip: noBid = 1 - yesAsk, noAsk = 1 - yesBid. + const side = params?.side; const isNoToken = side === 'no' || (!side && await this.isNoOutcome(outcomeId, slug)); if (isNoToken) { return { diff --git a/core/src/exchanges/mock/index.ts b/core/src/exchanges/mock/index.ts index c362e5ad..8da83fd1 100644 --- a/core/src/exchanges/mock/index.ts +++ b/core/src/exchanges/mock/index.ts @@ -317,7 +317,7 @@ export class MockExchange extends PredictionMarketExchange { return limit !== undefined ? events.slice(offset, offset + limit) : events.slice(offset); } - override async fetchOrderBook(id: string): Promise { + override async fetchOrderBook(id: string, _limit?: number, _params?: Record): Promise { const f = new SeededRng(id); const midPrice = round(f.float(0.1, 0.9), 3); const spread = round(f.float(0.005, 0.03), 3); diff --git a/core/src/exchanges/myriad/index.ts b/core/src/exchanges/myriad/index.ts index 6d5cf377..195ee6ce 100644 --- a/core/src/exchanges/myriad/index.ts +++ b/core/src/exchanges/myriad/index.ts @@ -107,7 +107,7 @@ export class MyriadExchange extends PredictionMarketExchange { return this.normalizer.normalizeOHLCV(rawMarket, params, parsedOutcomeId); } - async fetchOrderBook(outcomeId: string): Promise { + async fetchOrderBook(outcomeId: string, _limit?: number, _params?: Record): Promise { const parts = outcomeId.split(':'); if (parts.length >= 3) { const [networkId, marketId, oid] = parts; diff --git a/core/src/exchanges/opinion/index.ts b/core/src/exchanges/opinion/index.ts index 42566a07..d78ffc3f 100644 --- a/core/src/exchanges/opinion/index.ts +++ b/core/src/exchanges/opinion/index.ts @@ -207,7 +207,7 @@ export class OpinionExchange extends PredictionMarketExchange { return this.normalizer.normalizeOHLCV({ history: rawPoints }, params); } - async fetchOrderBook(outcomeId: string): Promise { + async fetchOrderBook(outcomeId: string, _limit?: number, _params?: Record): Promise { const raw = await this.fetcher.fetchRawOrderBook(outcomeId); return this.normalizer.normalizeOrderBook(raw, outcomeId); } diff --git a/core/src/exchanges/polymarket/index.ts b/core/src/exchanges/polymarket/index.ts index d6d7b8f8..f3179cdb 100644 --- a/core/src/exchanges/polymarket/index.ts +++ b/core/src/exchanges/polymarket/index.ts @@ -159,7 +159,7 @@ export class PolymarketExchange extends PredictionMarketExchange { return this.normalizer.normalizeOHLCV(raw, params); } - async fetchOrderBook(outcomeId: string): Promise { + async fetchOrderBook(outcomeId: string, _limit?: number, _params?: Record): Promise { validateIdFormat(outcomeId, 'OrderBook'); validateOutcomeId(outcomeId, 'OrderBook'); const raw = await this.fetcher.fetchRawOrderBook(outcomeId); diff --git a/core/src/exchanges/polymarket_us/index.ts b/core/src/exchanges/polymarket_us/index.ts index ca0408e8..f1842c3f 100644 --- a/core/src/exchanges/polymarket_us/index.ts +++ b/core/src/exchanges/polymarket_us/index.ts @@ -207,7 +207,7 @@ export class PolymarketUSExchange extends PredictionMarketExchange { }); } - override async fetchOrderBook(outcomeId: string): Promise { + override async fetchOrderBook(outcomeId: string, _limit?: number, _params?: Record): Promise { return this.run(async () => { const slug = this.slugFromId(outcomeId); const book = await this.client.markets.book(slug); diff --git a/core/src/exchanges/probable/index.ts b/core/src/exchanges/probable/index.ts index eec7070f..bae9e0a6 100644 --- a/core/src/exchanges/probable/index.ts +++ b/core/src/exchanges/probable/index.ts @@ -176,7 +176,7 @@ export class ProbableExchange extends PredictionMarketExchange { return event; } - async fetchOrderBook(outcomeId: string): Promise { + async fetchOrderBook(outcomeId: string, _limit?: number, _params?: Record): Promise { const raw = await this.fetcher.fetchRawOrderBook(outcomeId); return this.normalizer.normalizeOrderBook(raw, outcomeId); } diff --git a/core/src/exchanges/smarkets/index.ts b/core/src/exchanges/smarkets/index.ts index f7d3a041..afd9bebd 100644 --- a/core/src/exchanges/smarkets/index.ts +++ b/core/src/exchanges/smarkets/index.ts @@ -228,7 +228,7 @@ export class SmarketsExchange extends PredictionMarketExchange { .slice(0, limit); } - async fetchOrderBook(outcomeId: string): Promise { + async fetchOrderBook(outcomeId: string, _limit?: number, _params?: Record): Promise { const raw = await this.fetcher.fetchRawOrderBook(outcomeId); return this.normalizer.normalizeOrderBook(raw, outcomeId); } diff --git a/core/src/router/Router.ts b/core/src/router/Router.ts index a1e42fdc..c81011e1 100644 --- a/core/src/router/Router.ts +++ b/core/src/router/Router.ts @@ -106,7 +106,7 @@ export class Router extends PredictionMarketExchange { // Unified orderbook (cross-exchange merge) // ----------------------------------------------------------------------- - async fetchOrderBook(outcomeId: string, side?: 'yes' | 'no'): Promise { + async fetchOrderBook(outcomeId: string, limit?: number, params?: Record): Promise { const exchangeNames = Object.keys(this.exchanges); if (exchangeNames.length === 0) { throw new Error( @@ -114,7 +114,7 @@ export class Router extends PredictionMarketExchange { ); } - const resolvedSide = side ?? 'yes'; + const resolvedSide = params?.side ?? 'yes'; // Find identity matches across venues const matches = await this.fetchMarketMatches({ @@ -138,7 +138,7 @@ export class Router extends PredictionMarketExchange { fetchPromises.push( exchange - .fetchOrderBook(outcome.outcomeId, resolvedSide) + .fetchOrderBook(outcome.outcomeId, undefined, { side: resolvedSide }) .then((book) => ({ book, venue: venueName, error: null })) .catch((error: unknown) => ({ book: null, venue: venueName, error })), ); @@ -149,7 +149,7 @@ export class Router extends PredictionMarketExchange { if (matchedVenues.has(name)) continue; fetchPromises.push( exchange - .fetchOrderBook(outcomeId, resolvedSide) + .fetchOrderBook(outcomeId, undefined, { side: resolvedSide }) .then((book) => ({ book, venue: name, error: null })) .catch((error: unknown) => ({ book: null, venue: name, error })), ); diff --git a/core/src/router/e2e-orderbook.ts b/core/src/router/e2e-orderbook.ts new file mode 100644 index 00000000..31d8d6bb --- /dev/null +++ b/core/src/router/e2e-orderbook.ts @@ -0,0 +1,64 @@ +/** + * E2E test for Router.fetchOrderBook — run with: + * npx ts-node src/router/e2e-orderbook.ts + * + * Requires PMXT_API_KEY in env. + */ +import { Router } from './Router'; +import { PolymarketExchange } from '../exchanges/polymarket'; +import { LimitlessExchange } from '../exchanges/limitless'; + +async function main() { + const apiKey = process.env.PMXT_API_KEY; + if (!apiKey) { + console.error('Set PMXT_API_KEY'); + process.exit(1); + } + + const polymarket = new PolymarketExchange({}); + const limitless = new LimitlessExchange({}); + + const router = new Router({ + apiKey, + exchanges: { polymarket, limitless }, + }); + + // Morocco FIFA World Cup market on Polymarket + const marketId = 'f017596d-4d53-49d5-a7d6-36ed9c37fdc4'; + + console.log('Fetching unified orderbook for Morocco (Polymarket + Limitless)...'); + console.log(`Input market ID: ${marketId}`); + console.log('---'); + + const book = await router.fetchOrderBook(marketId, undefined, { side: 'yes' }); + + console.log(`Bids: ${book.bids.length} levels`); + console.log(`Asks: ${book.asks.length} levels`); + console.log('Top 5 bids:', book.bids.slice(0, 5)); + console.log('Top 5 asks:', book.asks.slice(0, 5)); + + // Verify we got data from BOTH exchanges + // Polymarket top bid was 0.016, Limitless had 0.002 + // If merged correctly, we should see both + const hasPoly = book.bids.some((b) => b.price === 0.016); + const hasLimitless = book.bids.some((b) => b.price === 0.002); + + console.log('---'); + console.log(`Has Polymarket levels: ${hasPoly}`); + console.log(`Has Limitless levels: ${hasLimitless}`); + + if (hasPoly && hasLimitless) { + console.log('SUCCESS: Merged orderbook contains levels from both exchanges'); + } else if (!hasPoly && hasLimitless) { + console.log('PARTIAL: Only Limitless book (source market fetch failed)'); + } else if (hasPoly && !hasLimitless) { + console.log('PARTIAL: Only Polymarket book (matched market fetch failed)'); + } else { + console.log('FAIL: No data from either exchange'); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/core/src/types.ts b/core/src/types.ts index a5415974..78091e45 100644 --- a/core/src/types.ts +++ b/core/src/types.ts @@ -140,6 +140,8 @@ export interface OrderBook { asks: OrderLevel[]; /** Unix timestamp in milliseconds when the snapshot was taken. */ timestamp?: number; + /** ISO 8601 datetime string of the snapshot (CCXT-compatible). */ + datetime?: string; } export interface Trade { diff --git a/sdks/typescript/pmxt/client.ts b/sdks/typescript/pmxt/client.ts index d094f0eb..7bec6950 100644 --- a/sdks/typescript/pmxt/client.ts +++ b/sdks/typescript/pmxt/client.ts @@ -766,12 +766,16 @@ export abstract class Exchange { } } - async fetchOrderBook(outcomeId: string | MarketOutcome, side?: any): Promise { + async fetchOrderBook(outcomeId: string | MarketOutcome, limit?: number, params?: Record): Promise { await this.initPromise; try { const args: any[] = []; args.push(resolveOutcomeId(outcomeId)); - if (side !== undefined) args.push(side); + if (limit !== undefined) args.push(limit); + if (params !== undefined) { + if (limit === undefined) args.push(undefined); + args.push(params); + } const response = await this.fetchWithRetry(`${this.resolveBaseUrl()}/api/${this.exchangeName}/fetchOrderBook`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() }, diff --git a/sdks/typescript/pmxt/models.ts b/sdks/typescript/pmxt/models.ts index d81cc881..f3d42b41 100644 --- a/sdks/typescript/pmxt/models.ts +++ b/sdks/typescript/pmxt/models.ts @@ -140,7 +140,7 @@ export interface OrderLevel { } /** - * Current order book for an outcome. + * Order book for an outcome. */ export interface OrderBook { /** Bid orders (sorted high to low) */ @@ -151,6 +151,9 @@ export interface OrderBook { /** Unix timestamp (milliseconds) */ timestamp?: number; + + /** ISO 8601 datetime string of the snapshot (CCXT-compatible) */ + datetime?: string; } /** From 3cc47f9513a83184c688e88671f608b86571ea38 Mon Sep 17 00:00:00 2001 From: "Samuel EF. Tinnerholm" Date: Fri, 22 May 2026 20:20:08 +0300 Subject: [PATCH 2/3] docs: document historical order book params (since, until) in fetchOrderBook - BaseExchange JSDoc: document `until` param for range queries - SDK client.ts: add JSDoc examples + update return type to OrderBook | OrderBook[] for range queries --- core/src/BaseExchange.ts | 7 ++++++- sdks/typescript/pmxt/client.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/core/src/BaseExchange.ts b/core/src/BaseExchange.ts index 4e45e53f..710fbdf4 100644 --- a/core/src/BaseExchange.ts +++ b/core/src/BaseExchange.ts @@ -830,13 +830,18 @@ export abstract class PredictionMarketExchange { * * @param outcomeId - The Outcome ID (outcomeId) or market slug * @param limit - Max number of bid/ask levels to return (CCXT-style). + * For range queries, limits the number of snapshots returned. * @param params - Optional parameters: * - `side`: 'yes' or 'no' — explicitly indicate the outcome side * (required for exchanges like Limitless where the API returns a * single orderbook per market). * - `since`: Unix timestamp (ms) — fetch a historical snapshot from * the archive at or before this time (hosted API only). - * @returns Order book with bids and asks + * - `until`: Unix timestamp (ms) — when combined with `since`, + * returns an array of OrderBook snapshots between `since` and + * `until` (hosted API only). + * @returns Order book with bids and asks. Returns OrderBook[] when + * both `since` and `until` are provided. */ async fetchOrderBook(outcomeId: string, limit?: number, params?: Record): Promise { throw new Error("Method fetchOrderBook not implemented."); diff --git a/sdks/typescript/pmxt/client.ts b/sdks/typescript/pmxt/client.ts index 7bec6950..2098c8fb 100644 --- a/sdks/typescript/pmxt/client.ts +++ b/sdks/typescript/pmxt/client.ts @@ -766,7 +766,34 @@ export abstract class Exchange { } } - async fetchOrderBook(outcomeId: string | MarketOutcome, limit?: number, params?: Record): Promise { + /** + * Fetch the order book for an outcome. + * + * @param outcomeId - Outcome ID or MarketOutcome object + * @param limit - Max bid/ask levels (live), or max snapshots (range query) + * @param params - Optional parameters: + * - `side`: 'yes' | 'no' — outcome side (for exchanges like Limitless) + * - `since`: Unix timestamp (ms) — historical snapshot at or before this time + * - `since` + `until`: Unix timestamps (ms) — returns OrderBook[] of all + * snapshots between since and until + * @returns Single OrderBook, or OrderBook[] when both since and until are provided + * + * @example + * // Live order book + * const book = await exchange.fetchOrderBook(outcomeId); + * + * @example + * // Historical snapshot + * const book = await exchange.fetchOrderBook(outcomeId, undefined, { since: 1779278400000 }); + * + * @example + * // Range of snapshots (last 5 minutes) + * const books = await exchange.fetchOrderBook(outcomeId, 100, { + * since: Date.now() - 5 * 60 * 1000, + * until: Date.now(), + * }); + */ + async fetchOrderBook(outcomeId: string | MarketOutcome, limit?: number, params?: Record): Promise { await this.initPromise; try { const args: any[] = []; From 37ae1b3f4a499defc09fd07f50516380ea869cb2 Mon Sep 17 00:00:00 2001 From: mooncitydev Date: Sat, 25 Apr 2026 02:27:59 +0900 Subject: [PATCH 3/3] fix(mock): remove faker, seeded PRNG, resting limits, fillOrder, tests - Replace @faker-js/faker with local SeededRng (mulberry32 + string hash) - market orders price from same mid as fetchOrderBook (first float) - limitOrderMode: 'resting' for open/cancel/fill; fillOrder for partial/complete - Buy resting uses locked USDC; immutable position updates - Add core/test/unit/mockExchange.core.test.ts Made-with: Cursor --- core/test/unit/mockExchange.core.test.ts | 162 +++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 core/test/unit/mockExchange.core.test.ts diff --git a/core/test/unit/mockExchange.core.test.ts b/core/test/unit/mockExchange.core.test.ts new file mode 100644 index 00000000..a73bff2c --- /dev/null +++ b/core/test/unit/mockExchange.core.test.ts @@ -0,0 +1,162 @@ +import { MockExchange } from '../../src/exchanges/mock'; +import { SeededRng } from '../../src/exchanges/mock/seededRng'; + +const r3 = (n: number) => parseFloat(n.toFixed(3)); + +describe('MockExchange', () => { + test('fetchMarkets is deterministic for same marketCount', async () => { + const a = new MockExchange({ marketCount: 5 }); + const b = new MockExchange({ marketCount: 5 }); + const m1 = await a.fetchMarkets(); + const m2 = await b.fetchMarkets(); + expect(m1[0]!.marketId).toBe(m2[0]!.marketId); + expect(m1[0]!.title).toBe(m2[0]!.title); + }); + + test('market order price matches _bookMidPrice seeding', async () => { + const ex = new MockExchange({ marketCount: 1, orderLatencyMs: 0 }); + const markets = await ex.fetchMarkets(); + const m0 = markets[0]!; + const y = m0.yes?.outcomeId ?? m0.outcomes[0]!.outcomeId; + const r = new SeededRng(y); + const expectedMid = r3(r.float(0.1, 0.9)); + const o = await ex.createOrder({ + marketId: m0.marketId, + outcomeId: y, + side: 'buy', + type: 'market', + amount: 2, + }); + expect(o.status).toBe('filled'); + expect(o.price).toBeCloseTo(expectedMid, 5); + }); + + test('instant limit buy debits free cash and creates position', async () => { + const ex = new MockExchange({ marketCount: 1, orderLatencyMs: 0, balance: 10_000 }); + const m = (await ex.fetchMarkets()).find(x => x.yes) ?? (await ex.fetchMarkets())[0]!; + const oc = m.yes?.outcomeId ?? m.outcomes[0]!.outcomeId; + const p = 0.4; + const amt = 10; + const before = await ex.fetchBalance(); + await ex.createOrder({ + marketId: m.marketId, + outcomeId: oc, + side: 'buy', + type: 'limit', + price: p, + amount: amt, + }); + const [after] = await ex.fetchBalance(); + expect(after.available).toBeCloseTo(before[0]!.available - p * amt, 4); + const pos = await ex.fetchPositions(); + expect(pos.length).toBe(1); + expect(pos[0]!.size).toBeCloseTo(amt, 3); + }); + + test('resting limit: open, fillOrder, then filled and locked released', async () => { + const ex = new MockExchange({ + marketCount: 1, + orderLatencyMs: 0, + balance: 10_000, + limitOrderMode: 'resting', + }); + const m = (await ex.fetchMarkets()).find(x => x.yes) ?? (await ex.fetchMarkets())[0]!; + const oc = m.yes?.outcomeId ?? m.outcomes[0]!.outcomeId; + const p = 0.5; + const amt = 4; + const o = await ex.createOrder({ + marketId: m.marketId, + outcomeId: oc, + side: 'buy', + type: 'limit', + price: p, + amount: amt, + }); + expect(o.status).toBe('open'); + const [b1] = await ex.fetchBalance(); + expect(b1.locked).toBeCloseTo(p * amt, 3); + const open0 = await ex.fetchOpenOrders(); + expect(open0).toHaveLength(1); + const filled = await ex.fillOrder(o.id); + expect(filled.status).toBe('filled'); + const [b2] = await ex.fetchBalance(); + expect(b2.locked).toBe(0); + expect((await ex.fetchOpenOrders()).length).toBe(0); + }); + + test('resting: partial fill then second fill', async () => { + const ex = new MockExchange({ + marketCount: 1, + orderLatencyMs: 0, + balance: 10_000, + limitOrderMode: 'resting', + }); + const m = (await ex.fetchMarkets()).find(x => x.yes) ?? (await ex.fetchMarkets())[0]!; + const oc = m.yes?.outcomeId ?? m.outcomes[0]!.outcomeId; + const o = await ex.createOrder({ + marketId: m.marketId, + outcomeId: oc, + side: 'buy', + type: 'limit', + price: 0.6, + amount: 10, + }); + const p1 = await ex.fillOrder(o.id, 3); + expect(p1.status).toBe('open'); + expect(p1.filled).toBe(3); + const p2 = await ex.fillOrder(o.id, 7); + expect(p2.status).toBe('filled'); + }); + + test('resting: cancel buy unlocks USDC', async () => { + const ex = new MockExchange({ marketCount: 1, orderLatencyMs: 0, limitOrderMode: 'resting', balance: 1000 }); + const m = (await ex.fetchMarkets()).find(x => x.yes) ?? (await ex.fetchMarkets())[0]!; + const oc = m.yes?.outcomeId ?? m.outcomes[0]!.outcomeId; + const o = await ex.createOrder({ + marketId: m.marketId, + outcomeId: oc, + side: 'buy', + type: 'limit', + price: 0.5, + amount: 10, + }); + const [b1] = await ex.fetchBalance(); + await ex.cancelOrder(o.id); + const [b2] = await ex.fetchBalance(); + expect(b2.locked).toBe(0); + expect(b2.available).toBeCloseTo(b1.total, 2); + }); + + test('cancel filled order throws', async () => { + const ex = new MockExchange({ marketCount: 1, orderLatencyMs: 0 }); + const m = (await ex.fetchMarkets()).find(x => x.yes) ?? (await ex.fetchMarkets())[0]!; + const oc = m.yes?.outcomeId ?? m.outcomes[0]!.outcomeId; + const o = await ex.createOrder({ + marketId: m.marketId, + outcomeId: oc, + side: 'buy', + type: 'limit', + price: 0.45, + amount: 2, + }); + await expect(ex.cancelOrder(o.id)).rejects.toThrow(); + }); + + test('reset clears session', async () => { + const ex = new MockExchange({ marketCount: 1, orderLatencyMs: 0, balance: 5000 }); + const m = (await ex.fetchMarkets()).find(x => x.yes) ?? (await ex.fetchMarkets())[0]!; + const oc = m.yes?.outcomeId ?? m.outcomes[0]!.outcomeId; + await ex.createOrder({ + marketId: m.marketId, + outcomeId: oc, + side: 'buy', + type: 'limit', + price: 0.3, + amount: 5, + }); + ex.reset(); + const [b] = await ex.fetchBalance(); + expect(b.available).toBe(5000); + expect(await ex.fetchOpenOrders()).toHaveLength(0); + }); +});