diff --git a/changelog.md b/changelog.md index bc7e67a..611cabe 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [2.17.10] - 2026-02-25 + +### Added + +- **Raw mode for price data**: Added an optional `{ mode: "raw" }` API argument to return raw exchange price values without normalization. + ## [2.17.9] - 2026-02-25 ### Fixed diff --git a/core/src/BaseExchange.ts b/core/src/BaseExchange.ts index f382d3a..6772fcb 100644 --- a/core/src/BaseExchange.ts +++ b/core/src/BaseExchange.ts @@ -20,6 +20,10 @@ export interface ApiDescriptor { endpoints: Record; } +export interface RequestOptions { + mode?: 'raw'; +} + export interface ImplicitApiMethodInfo { name: string; method: string; @@ -222,7 +226,7 @@ export abstract class PredictionMarketExchange { // Snapshot state for cursor-based pagination private _snapshotTTL: number; - private _snapshot?: { markets: UnifiedMarket[]; takenAt: number; id: string }; + private _snapshot?: { markets: UnifiedMarket[]; takenAt: number; id: string; mode?: RequestOptions['mode'] }; get rateLimit(): number { return this._rateLimit; @@ -405,8 +409,11 @@ export abstract class PredictionMarketExchange { * @example-python Get market by slug * markets = exchange.fetch_markets(slug='will-trump-win') */ - async fetchMarkets(params?: MarketFetchParams): Promise { - return this.fetchMarketsImpl(params); + async fetchMarkets( + params?: MarketFetchParams, + options?: RequestOptions, + ): Promise { + return this.fetchMarketsImpl(params, options); } /** @@ -424,9 +431,13 @@ export abstract class PredictionMarketExchange { * @param params.cursor - Opaque cursor returned by a previous call * @returns PaginatedMarketsResult with data, total, and optional nextCursor */ - async fetchMarketsPaginated(params?: { limit?: number; cursor?: string }): Promise { + async fetchMarketsPaginated( + params?: { limit?: number; cursor?: string }, + options?: RequestOptions, + ): Promise { const limit = params?.limit; const cursor = params?.cursor; + const mode = options?.mode; if (cursor) { // Cursor encodes: snapshotId:offset @@ -437,6 +448,7 @@ export abstract class PredictionMarketExchange { if ( !this._snapshot || this._snapshot.id !== snapshotId || + this._snapshot.mode !== mode || (this._snapshotTTL > 0 && Date.now() - this._snapshot.takenAt > this._snapshotTTL) ) { throw new Error('Cursor has expired'); @@ -454,13 +466,15 @@ export abstract class PredictionMarketExchange { if ( !this._snapshot || this._snapshotTTL === 0 || + this._snapshot.mode !== mode || Date.now() - this._snapshot.takenAt > this._snapshotTTL ) { - const markets = await this.fetchMarketsImpl(); + const markets = await this.fetchMarketsImpl(undefined, options); this._snapshot = { markets, takenAt: Date.now(), id: Math.random().toString(36).slice(2), + mode, }; } @@ -497,8 +511,11 @@ export abstract class PredictionMarketExchange { * fed_event = events[0] * print(fed_event.title, len(fed_event.markets), 'markets') */ - async fetchEvents(params?: EventFetchParams): Promise { - return this.fetchEventsImpl(params ?? {}); + async fetchEvents( + params?: EventFetchParams, + options?: RequestOptions, + ): Promise { + return this.fetchEventsImpl(params ?? {}, options); } /** @@ -518,7 +535,10 @@ export abstract class PredictionMarketExchange { * @example-python Fetch by market ID * market = exchange.fetch_market(market_id='663583') */ - async fetchMarket(params?: MarketFetchParams): Promise { + async fetchMarket( + params?: MarketFetchParams, + options?: RequestOptions, + ): Promise { // Try to fetch from cache first if we have loaded markets and have an ID/slug if (this.loadedMarkets) { if (params?.marketId && this.markets[params.marketId]) { @@ -529,7 +549,7 @@ export abstract class PredictionMarketExchange { } } - const markets = await this.fetchMarkets(params); + const markets = await this.fetchMarkets(params, options); if (markets.length === 0) { const identifier = params?.marketId || params?.outcomeId || params?.slug || params?.eventId || params?.query || 'unknown'; throw new MarketNotFound(identifier, this.name); @@ -551,8 +571,11 @@ export abstract class PredictionMarketExchange { * @example-python Fetch by event ID * event = exchange.fetch_event(event_id='TRUMP25DEC') */ - async fetchEvent(params?: EventFetchParams): Promise { - const events = await this.fetchEvents(params); + async fetchEvent( + params?: EventFetchParams, + options?: RequestOptions, + ): Promise { + const events = await this.fetchEvents(params, options); if (events.length === 0) { const identifier = params?.eventId || params?.slug || params?.query || 'unknown'; throw new EventNotFound(identifier, this.name); @@ -569,7 +592,10 @@ export abstract class PredictionMarketExchange { * Implementation for fetching/searching markets. * Exchanges should handle query, slug, and plain fetch cases based on params. */ - protected async fetchMarketsImpl(params?: MarketFetchParams): Promise { + protected async fetchMarketsImpl( + params?: MarketFetchParams, + options?: RequestOptions, + ): Promise { throw new Error("Method fetchMarketsImpl not implemented."); } @@ -577,7 +603,10 @@ export abstract class PredictionMarketExchange { * @internal * Implementation for searching events by keyword. */ - protected async fetchEventsImpl(params: EventFetchParams): Promise { + protected async fetchEventsImpl( + params: EventFetchParams, + options?: RequestOptions, + ): Promise { throw new Error("Method fetchEventsImpl not implemented."); } @@ -607,7 +636,11 @@ export abstract class PredictionMarketExchange { * @notes Polymarket: outcomeId is the CLOB Token ID. Kalshi: outcomeId is the Market Ticker. * @notes Resolution options: '1m' | '5m' | '15m' | '1h' | '6h' | '1d' */ - async fetchOHLCV(id: string, params: OHLCVParams): Promise { + async fetchOHLCV( + id: string, + params: OHLCVParams, + options?: RequestOptions, + ): Promise { throw new Error("Method fetchOHLCV not implemented."); } @@ -630,7 +663,7 @@ export abstract class PredictionMarketExchange { * print(f"Best ask: {book.asks[0].price}") * print(f"Spread: {(book.asks[0].price - book.bids[0].price) * 100:.2f}%") */ - async fetchOrderBook(id: string): Promise { + async fetchOrderBook(id: string, options?: RequestOptions): Promise { throw new Error("Method fetchOrderBook not implemented."); } @@ -654,7 +687,11 @@ export abstract class PredictionMarketExchange { * * @notes Polymarket requires an API key for trade history. Use fetchOHLCV for public historical data. */ - async fetchTrades(id: string, params: TradesParams | HistoryFilterParams): Promise { + async fetchTrades( + id: string, + params: TradesParams | HistoryFilterParams, + options?: RequestOptions, + ): Promise { // Deprecation warning for resolution parameter if ('resolution' in params && params.resolution !== undefined) { console.warn( @@ -751,7 +788,7 @@ export abstract class PredictionMarketExchange { * order = exchange.fetch_order('order-456') * print(f"Filled: {order.filled}/{order.amount}") */ - async fetchOrder(orderId: string): Promise { + async fetchOrder(orderId: string, options?: RequestOptions): Promise { throw new Error("Method fetchOrder not implemented."); } @@ -778,19 +815,31 @@ export abstract class PredictionMarketExchange { * @example-python Fetch orders for a specific market * orders = exchange.fetch_open_orders('FED-25JAN') */ - async fetchOpenOrders(marketId?: string): Promise { + async fetchOpenOrders( + marketId?: string, + options?: RequestOptions, + ): Promise { throw new Error("Method fetchOpenOrders not implemented."); } - async fetchMyTrades(params?: MyTradesParams): Promise { + async fetchMyTrades( + params?: MyTradesParams, + options?: RequestOptions, + ): Promise { throw new Error("Method fetchMyTrades not implemented."); } - async fetchClosedOrders(params?: OrderHistoryParams): Promise { + async fetchClosedOrders( + params?: OrderHistoryParams, + options?: RequestOptions, + ): Promise { throw new Error("Method fetchClosedOrders not implemented."); } - async fetchAllOrders(params?: OrderHistoryParams): Promise { + async fetchAllOrders( + params?: OrderHistoryParams, + options?: RequestOptions, + ): Promise { throw new Error("Method fetchAllOrders not implemented."); } @@ -812,7 +861,7 @@ export abstract class PredictionMarketExchange { * print(f"{pos.outcome_label}: {pos.size} @ ${pos.entry_price}") * print(f"Unrealized P&L: ${pos.unrealized_pnl:.2f}") */ - async fetchPositions(): Promise { + async fetchPositions(options?: RequestOptions): Promise { throw new Error("Method fetchPositions not implemented."); } @@ -829,7 +878,7 @@ export abstract class PredictionMarketExchange { * balances = exchange.fetch_balance() * print(f"Available: ${balances[0].available}") */ - async fetchBalance(): Promise { + async fetchBalance(options?: RequestOptions): Promise { throw new Error("Method fetchBalance not implemented."); } @@ -1206,7 +1255,11 @@ export abstract class PredictionMarketExchange { * book = exchange.watch_order_book(outcome.outcome_id) * print(f"Bid: {book.bids[0].price} Ask: {book.asks[0].price}") */ - async watchOrderBook(id: string, limit?: number): Promise { + async watchOrderBook( + id: string, + limit?: number, + options?: RequestOptions, + ): Promise { throw new Error(`watchOrderBook() is not supported by ${this.name}`); } @@ -1233,7 +1286,12 @@ export abstract class PredictionMarketExchange { * for trade in trades: * print(f"{trade.side} {trade.amount} @ {trade.price}") */ - async watchTrades(id: string, since?: number, limit?: number): Promise { + async watchTrades( + id: string, + since?: number, + limit?: number, + options?: RequestOptions, + ): Promise { throw new Error(`watchTrades() is not supported by ${this.name}`); } diff --git a/core/src/exchanges/baozi/fetchEvents.ts b/core/src/exchanges/baozi/fetchEvents.ts index b353cc1..d9457d8 100644 --- a/core/src/exchanges/baozi/fetchEvents.ts +++ b/core/src/exchanges/baozi/fetchEvents.ts @@ -1,5 +1,5 @@ import { Connection } from '@solana/web3.js'; -import { EventFetchParams } from '../../BaseExchange'; +import { EventFetchParams, RequestOptions } from '../../BaseExchange'; import { UnifiedEvent } from '../../types'; import { fetchMarkets } from './fetchMarkets'; import { baoziErrorMapper } from './errors'; @@ -11,6 +11,7 @@ import { baoziErrorMapper } from './errors'; export async function fetchEvents( connection: Connection, params: EventFetchParams, + options?: RequestOptions, ): Promise { try { const markets = await fetchMarkets(connection, { @@ -19,7 +20,7 @@ export async function fetchEvents( offset: params.offset, status: params.status, searchIn: params.searchIn, - }); + }, options); return markets.map(m => { const unifiedEvent = { diff --git a/core/src/exchanges/baozi/fetchMarkets.ts b/core/src/exchanges/baozi/fetchMarkets.ts index 7a9284b..681d1b2 100644 --- a/core/src/exchanges/baozi/fetchMarkets.ts +++ b/core/src/exchanges/baozi/fetchMarkets.ts @@ -1,5 +1,5 @@ import { Connection } from '@solana/web3.js'; -import { MarketFetchParams } from '../../BaseExchange'; +import { MarketFetchParams, RequestOptions } from '../../BaseExchange'; import { UnifiedMarket } from '../../types'; import { PROGRAM_ID, @@ -20,10 +20,11 @@ const marketsCache = new Cache(30_000); // 30s TTL export async function fetchMarkets( connection: Connection, params?: MarketFetchParams, + options?: RequestOptions, ): Promise { try { // Use cache for default (no-filter) fetches - if (!params?.query && !params?.slug) { + if (!params?.query && !params?.slug && options?.mode !== 'raw') { const cached = marketsCache.get(); if (cached) { return applyFilters(cached, params); @@ -46,7 +47,7 @@ export async function fetchMarkets( for (const account of booleanAccounts) { try { const parsed = parseMarket(account.account.data); - markets.push(mapBooleanToUnified(parsed, account.pubkey.toString())); + markets.push(mapBooleanToUnified(parsed, account.pubkey.toString(), options)); } catch { // Skip malformed accounts } @@ -56,14 +57,16 @@ export async function fetchMarkets( for (const account of raceAccounts) { try { const parsed = parseRaceMarket(account.account.data); - markets.push(mapRaceToUnified(parsed, account.pubkey.toString())); + markets.push(mapRaceToUnified(parsed, account.pubkey.toString(), options)); } catch { // Skip malformed accounts } } // Cache results - marketsCache.set(markets); + if (options?.mode !== 'raw') { + marketsCache.set(markets); + } return applyFilters(markets, params); } catch (error: any) { @@ -74,6 +77,7 @@ export async function fetchMarkets( export async function fetchSingleMarket( connection: Connection, pubkey: string, + options?: RequestOptions, ): Promise { try { const { PublicKey } = await import('@solana/web3.js'); @@ -87,13 +91,13 @@ export async function fetchSingleMarket( // Check if it's a boolean market if (Buffer.from(discriminator).equals(Buffer.from([219, 190, 213, 55, 0, 227, 198, 154]))) { const parsed = parseMarket(data); - return mapBooleanToUnified(parsed, pubkey); + return mapBooleanToUnified(parsed, pubkey, options); } // Check if it's a race market if (Buffer.from(discriminator).equals(Buffer.from([235, 196, 111, 75, 230, 113, 118, 238]))) { const parsed = parseRaceMarket(data); - return mapRaceToUnified(parsed, pubkey); + return mapRaceToUnified(parsed, pubkey, options); } return null; diff --git a/core/src/exchanges/baozi/fetchOrderBook.ts b/core/src/exchanges/baozi/fetchOrderBook.ts index a28a7d9..e3cdd4d 100644 --- a/core/src/exchanges/baozi/fetchOrderBook.ts +++ b/core/src/exchanges/baozi/fetchOrderBook.ts @@ -2,6 +2,7 @@ import { Connection } from '@solana/web3.js'; import { OrderBook } from '../../types'; import { fetchSingleMarket } from './fetchMarkets'; import { baoziErrorMapper } from './errors'; +import { RequestOptions } from '../../BaseExchange'; /** * Pari-mutuel markets don't have a real order book. @@ -15,10 +16,11 @@ import { baoziErrorMapper } from './errors'; export async function fetchOrderBook( connection: Connection, outcomeId: string, + options?: RequestOptions, ): Promise { try { const marketPubkey = outcomeId.replace(/-YES$|-NO$|-\d+$/, ''); - const market = await fetchSingleMarket(connection, marketPubkey); + const market = await fetchSingleMarket(connection, marketPubkey, options); if (!market) { throw new Error(`Market not found: ${marketPubkey}`); diff --git a/core/src/exchanges/baozi/index.ts b/core/src/exchanges/baozi/index.ts index 79a2f82..e8963b1 100644 --- a/core/src/exchanges/baozi/index.ts +++ b/core/src/exchanges/baozi/index.ts @@ -13,6 +13,7 @@ import { HistoryFilterParams, TradesParams, ExchangeCredentials, + RequestOptions, } from '../../BaseExchange'; import { UnifiedMarket, @@ -117,20 +118,26 @@ export class BaoziExchange extends PredictionMarketExchange { // Market Data // ----------------------------------------------------------------------- - protected async fetchMarketsImpl(params?: MarketFetchParams): Promise { - return fetchMarkets(this.connection, params); + protected async fetchMarketsImpl( + params?: MarketFetchParams, + options?: RequestOptions, + ): Promise { + return fetchMarkets(this.connection, params, options); } - protected async fetchEventsImpl(params: EventFetchParams): Promise { - return fetchEvents(this.connection, params); + protected async fetchEventsImpl( + params: EventFetchParams, + options?: RequestOptions, + ): Promise { + return fetchEvents(this.connection, params, options); } async fetchOHLCV(): Promise { return fetchOHLCV(); } - async fetchOrderBook(id: string): Promise { - return fetchOrderBook(this.connection, id); + async fetchOrderBook(id: string, options?: RequestOptions): Promise { + return fetchOrderBook(this.connection, id, options); } async fetchTrades(): Promise { @@ -141,7 +148,7 @@ export class BaoziExchange extends PredictionMarketExchange { // User Data // ----------------------------------------------------------------------- - async fetchBalance(): Promise { + async fetchBalance(options?: RequestOptions): Promise { try { const auth = this.ensureAuth(); const lamports = await this.connection.getBalance(auth.getPublicKey()); @@ -158,7 +165,7 @@ export class BaoziExchange extends PredictionMarketExchange { } } - async fetchPositions(): Promise { + async fetchPositions(options?: RequestOptions): Promise { try { const auth = this.ensureAuth(); const userPubkey = auth.getPublicKey(); @@ -197,7 +204,7 @@ export class BaoziExchange extends PredictionMarketExchange { const marketInfo = await this.connection.getAccountInfo(marketPda); if (marketInfo) { const market = parseMarket(marketInfo.data); - const unified = mapBooleanToUnified(market, marketPda.toString()); + const unified = mapBooleanToUnified(market, marketPda.toString(), options); currentYesPrice = unified.yes?.price ?? 0; currentNoPrice = unified.no?.price ?? 0; marketTitle = market.question; @@ -253,7 +260,7 @@ export class BaoziExchange extends PredictionMarketExchange { const marketInfo = await this.connection.getAccountInfo(racePda); if (marketInfo) { const raceMarket = parseRaceMarket(marketInfo.data); - const unified = mapRaceToUnified(raceMarket, racePdaStr); + const unified = mapRaceToUnified(raceMarket, racePdaStr, options); outcomePrices = unified.outcomes.map(o => o.price); outcomeLabels = unified.outcomes.map(o => o.label); } @@ -497,11 +504,11 @@ export class BaoziExchange extends PredictionMarketExchange { // WebSocket // ----------------------------------------------------------------------- - async watchOrderBook(id: string): Promise { + async watchOrderBook(id: string, limit?: number, options?: RequestOptions): Promise { if (!this.ws) { this.ws = new BaoziWebSocket(); } - return this.ws.watchOrderBook(this.connection, id); + return this.ws.watchOrderBook(this.connection, id, options); } async watchTrades(): Promise { diff --git a/core/src/exchanges/baozi/price.test.ts b/core/src/exchanges/baozi/price.test.ts new file mode 100644 index 0000000..106c648 --- /dev/null +++ b/core/src/exchanges/baozi/price.test.ts @@ -0,0 +1,42 @@ +import { + clampBaoziPrice, + normalizeBaoziOutcomes, +} from "./price"; +import { MarketOutcome } from "../../types"; + +describe("baozi price helpers", () => { + it("clamps values in normalized mode", () => { + expect(clampBaoziPrice(1.2)).toBe(1); + expect(clampBaoziPrice(-0.1)).toBe(0); + expect(clampBaoziPrice(0.3)).toBe(0.3); + }); + + it("does not clamp in raw mode", () => { + expect(clampBaoziPrice(1.2, { mode: "raw" })).toBe(1.2); + expect(clampBaoziPrice(-0.1, { mode: "raw" })).toBe(-0.1); + }); + + it("normalizes outcomes in normalized mode", () => { + const outcomes: MarketOutcome[] = [ + { outcomeId: "a", marketId: "m", label: "A", price: 45 }, + { outcomeId: "b", marketId: "m", label: "B", price: 55 }, + ]; + + normalizeBaoziOutcomes(outcomes); + + expect(outcomes[0].price).toBeCloseTo(0.45); + expect(outcomes[1].price).toBeCloseTo(0.55); + }); + + it("skips normalization in raw mode", () => { + const outcomes: MarketOutcome[] = [ + { outcomeId: "a", marketId: "m", label: "A", price: 45 }, + { outcomeId: "b", marketId: "m", label: "B", price: 55 }, + ]; + + normalizeBaoziOutcomes(outcomes, { mode: "raw" }); + + expect(outcomes[0].price).toBe(45); + expect(outcomes[1].price).toBe(55); + }); +}); diff --git a/core/src/exchanges/baozi/price.ts b/core/src/exchanges/baozi/price.ts new file mode 100644 index 0000000..6885b83 --- /dev/null +++ b/core/src/exchanges/baozi/price.ts @@ -0,0 +1,30 @@ +import { RequestOptions } from "../../BaseExchange"; +import { MarketOutcome } from "../../types"; + +export function clampBaoziPrice( + value: number, + options?: RequestOptions, +): number { + if (options?.mode === "raw") { + return value; + } + return Math.min(Math.max(value, 0), 1); +} + +export function normalizeBaoziOutcomes( + outcomes: MarketOutcome[], + options?: RequestOptions, +): void { + if (options?.mode === "raw") { + return; + } + + const sum = outcomes.reduce((acc, item) => acc + item.price, 0); + if (sum <= 0) { + return; + } + + for (const outcome of outcomes) { + outcome.price = outcome.price / sum; + } +} diff --git a/core/src/exchanges/baozi/utils.ts b/core/src/exchanges/baozi/utils.ts index a55acaf..f476562 100644 --- a/core/src/exchanges/baozi/utils.ts +++ b/core/src/exchanges/baozi/utils.ts @@ -2,7 +2,12 @@ import { PublicKey } from '@solana/web3.js'; import bs58 from 'bs58'; import { createHash } from 'crypto'; import { UnifiedMarket, MarketOutcome } from '../../types'; +import { RequestOptions } from '../../BaseExchange'; import { addBinaryOutcomes } from '../../utils/market-utils'; +import { + clampBaoziPrice, + normalizeBaoziOutcomes, +} from './price'; // --------------------------------------------------------------------------- // Constants @@ -367,7 +372,11 @@ export function parseRacePosition(data: Buffer | Uint8Array): BaoziRacePosition // Mapping to Unified Types // --------------------------------------------------------------------------- -export function mapBooleanToUnified(market: BaoziMarket, pubkey: string): UnifiedMarket { +export function mapBooleanToUnified( + market: BaoziMarket, + pubkey: string, + options?: RequestOptions, +): UnifiedMarket { const totalPool = market.yesPool + market.noPool; const totalPoolSol = Number(totalPool) / LAMPORTS_PER_SOL; @@ -387,13 +396,13 @@ export function mapBooleanToUnified(market: BaoziMarket, pubkey: string): Unifie outcomeId: `${pubkey}-YES`, marketId: pubkey, label: 'Yes', - price: yesPrice, + price: clampBaoziPrice(yesPrice, options), }, { outcomeId: `${pubkey}-NO`, marketId: pubkey, label: 'No', - price: noPrice, + price: clampBaoziPrice(noPrice, options), }, ]; @@ -415,7 +424,11 @@ export function mapBooleanToUnified(market: BaoziMarket, pubkey: string): Unifie return um; } -export function mapRaceToUnified(market: BaoziRaceMarket, pubkey: string): UnifiedMarket { +export function mapRaceToUnified( + market: BaoziRaceMarket, + pubkey: string, + options?: RequestOptions, +): UnifiedMarket { const totalPoolSol = Number(market.totalPool) / LAMPORTS_PER_SOL; const outcomes: MarketOutcome[] = []; @@ -431,17 +444,12 @@ export function mapRaceToUnified(market: BaoziRaceMarket, pubkey: string): Unifi outcomeId: `${pubkey}-${i}`, marketId: pubkey, label: market.outcomeLabels[i] || `Outcome ${i + 1}`, - price: Math.min(Math.max(price, 0), 1), + price: clampBaoziPrice(price, options), }); } - // Normalize prices to sum to 1 - const priceSum = outcomes.reduce((s, o) => s + o.price, 0); - if (priceSum > 0) { - for (const o of outcomes) { - o.price = o.price / priceSum; - } - } + // Normalize prices to sum to 1 in non-raw mode. + normalizeBaoziOutcomes(outcomes, options); const um: UnifiedMarket = { marketId: pubkey, diff --git a/core/src/exchanges/baozi/websocket.ts b/core/src/exchanges/baozi/websocket.ts index 3b15ee3..4114ece 100644 --- a/core/src/exchanges/baozi/websocket.ts +++ b/core/src/exchanges/baozi/websocket.ts @@ -1,5 +1,6 @@ import { Connection, PublicKey } from '@solana/web3.js'; import { OrderBook } from '../../types'; +import { RequestOptions } from '../../BaseExchange'; import { MARKET_DISCRIMINATOR, RACE_MARKET_DISCRIMINATOR, @@ -23,7 +24,11 @@ export class BaoziWebSocket { private orderBookResolvers = new Map[]>(); private subscriptions = new Map(); - async watchOrderBook(connection: Connection, outcomeId: string): Promise { + async watchOrderBook( + connection: Connection, + outcomeId: string, + options?: RequestOptions, + ): Promise { const marketPubkey = outcomeId.replace(/-YES$|-NO$|-\d+$/, ''); const marketKey = new PublicKey(marketPubkey); @@ -38,10 +43,10 @@ export class BaoziWebSocket { if (Buffer.from(discriminator).equals(MARKET_DISCRIMINATOR)) { const parsed = parseMarket(data); - market = mapBooleanToUnified(parsed, marketPubkey); + market = mapBooleanToUnified(parsed, marketPubkey, options); } else if (Buffer.from(discriminator).equals(RACE_MARKET_DISCRIMINATOR)) { const parsed = parseRaceMarket(data); - market = mapRaceToUnified(parsed, marketPubkey); + market = mapRaceToUnified(parsed, marketPubkey, options); } if (!market) return; diff --git a/core/src/exchanges/kalshi/fetchEvents.ts b/core/src/exchanges/kalshi/fetchEvents.ts index ccd9d3a..8578392 100644 --- a/core/src/exchanges/kalshi/fetchEvents.ts +++ b/core/src/exchanges/kalshi/fetchEvents.ts @@ -1,4 +1,4 @@ -import { EventFetchParams } from "../../BaseExchange"; +import { EventFetchParams, RequestOptions } from "../../BaseExchange"; import { UnifiedEvent, UnifiedMarket } from "../../types"; import { mapMarketToUnified } from "./utils"; import { kalshiErrorMapper } from "./errors"; @@ -11,6 +11,7 @@ type CallApi = ( async function fetchEventByTicker( eventTicker: string, callApi: CallApi, + options?: RequestOptions, ): Promise { const normalizedTicker = eventTicker.toUpperCase(); const data = await callApi("GetEvent", { @@ -24,7 +25,7 @@ async function fetchEventByTicker( const markets: UnifiedMarket[] = []; if (event.markets) { for (const market of event.markets) { - const unifiedMarket = mapMarketToUnified(event, market); + const unifiedMarket = mapMarketToUnified(event, market, options); if (unifiedMarket) { markets.push(unifiedMarket); } @@ -46,11 +47,14 @@ async function fetchEventByTicker( return [unifiedEvent]; } -function rawEventToUnified(event: any): UnifiedEvent { +function rawEventToUnified( + event: any, + options?: RequestOptions, +): UnifiedEvent { const markets: UnifiedMarket[] = []; if (event.markets) { for (const market of event.markets) { - const unifiedMarket = mapMarketToUnified(event, market); + const unifiedMarket = mapMarketToUnified(event, market, options); if (unifiedMarket) { markets.push(unifiedMarket); } @@ -106,16 +110,17 @@ async function fetchAllWithStatus( export async function fetchEvents( params: EventFetchParams, callApi: CallApi, + options?: RequestOptions, ): Promise { try { // Handle eventId lookup (direct API call) if (params.eventId) { - return await fetchEventByTicker(params.eventId, callApi); + return await fetchEventByTicker(params.eventId, callApi, options); } // Handle slug lookup (slug IS the event ticker on Kalshi) if (params.slug) { - return await fetchEventByTicker(params.slug, callApi); + return await fetchEventByTicker(params.slug, callApi, options); } const status = params?.status || "active"; @@ -152,7 +157,9 @@ export async function fetchEvents( const sort = params?.sort || "volume"; const sorted = sortRawEvents(filtered, sort); - const unifiedEvents: UnifiedEvent[] = sorted.map(rawEventToUnified); + const unifiedEvents: UnifiedEvent[] = sorted.map((event) => + rawEventToUnified(event, options), + ); return unifiedEvents.slice(0, limit); } catch (error: any) { throw kalshiErrorMapper.mapError(error); diff --git a/core/src/exchanges/kalshi/fetchMarkets.ts b/core/src/exchanges/kalshi/fetchMarkets.ts index eb4c434..86d5713 100644 --- a/core/src/exchanges/kalshi/fetchMarkets.ts +++ b/core/src/exchanges/kalshi/fetchMarkets.ts @@ -1,4 +1,4 @@ -import { MarketFetchParams } from "../../BaseExchange"; +import { MarketFetchParams, RequestOptions } from "../../BaseExchange"; import { UnifiedMarket } from "../../types"; import { mapMarketToUnified } from "./utils"; import { kalshiErrorMapper } from "./errors"; @@ -104,36 +104,37 @@ export function resetCache(): void { export async function fetchMarkets( params: MarketFetchParams | undefined, callApi: CallApi, + options?: RequestOptions, ): Promise { try { // Handle marketId lookup (Kalshi marketId is the ticker) if (params?.marketId) { - return await fetchMarketsBySlug(params.marketId, callApi); + return await fetchMarketsBySlug(params.marketId, callApi, options); } // Handle slug-based lookup (event ticker) if (params?.slug) { - return await fetchMarketsBySlug(params.slug, callApi); + return await fetchMarketsBySlug(params.slug, callApi, options); } // Handle outcomeId lookup (strip -NO suffix, use as ticker) if (params?.outcomeId) { const ticker = params.outcomeId.replace(/-NO$/, ""); - return await fetchMarketsBySlug(ticker, callApi); + return await fetchMarketsBySlug(ticker, callApi, options); } // Handle eventId lookup (event ticker works the same way) if (params?.eventId) { - return await fetchMarketsBySlug(params.eventId, callApi); + return await fetchMarketsBySlug(params.eventId, callApi, options); } // Handle query-based search if (params?.query) { - return await searchMarkets(params.query, params, callApi); + return await searchMarkets(params.query, params, callApi, options); } // Default: fetch markets - return await fetchMarketsDefault(params, callApi); + return await fetchMarketsDefault(params, callApi, options); } catch (error: any) { throw kalshiErrorMapper.mapError(error); } @@ -142,6 +143,7 @@ export async function fetchMarkets( async function fetchMarketsBySlug( eventTicker: string, callApi: CallApi, + options?: RequestOptions, ): Promise { // Kalshi API expects uppercase tickers, but URLs use lowercase const normalizedTicker = eventTicker.toUpperCase(); @@ -174,7 +176,7 @@ async function fetchMarketsBySlug( const markets = event.markets || []; for (const market of markets) { - const unifiedMarket = mapMarketToUnified(event, market); + const unifiedMarket = mapMarketToUnified(event, market, options); if (unifiedMarket) { unifiedMarkets.push(unifiedMarket); } @@ -187,12 +189,14 @@ async function searchMarkets( query: string, params: MarketFetchParams | undefined, callApi: CallApi, + options?: RequestOptions, ): Promise { // We must fetch ALL markets to search them locally since we don't have server-side search const searchLimit = 250000; const markets = await fetchMarketsDefault( { ...params, limit: searchLimit }, callApi, + options, ); const lowerQuery = query.toLowerCase(); const searchIn = params?.searchIn || "title"; // Default to title-only search @@ -215,6 +219,7 @@ async function searchMarkets( async function fetchMarketsDefault( params: MarketFetchParams | undefined, callApi: CallApi, + options?: RequestOptions, ): Promise { const limit = params?.limit || 250000; const offset = params?.offset || 0; @@ -285,7 +290,7 @@ async function fetchMarketsDefault( const markets = event.markets || []; for (const market of markets) { - const unifiedMarket = mapMarketToUnified(event, market); + const unifiedMarket = mapMarketToUnified(event, market, options); if (unifiedMarket) { allMarkets.push(unifiedMarket); } diff --git a/core/src/exchanges/kalshi/fetchOHLCV.ts b/core/src/exchanges/kalshi/fetchOHLCV.ts index 4e8cdd3..d750245 100644 --- a/core/src/exchanges/kalshi/fetchOHLCV.ts +++ b/core/src/exchanges/kalshi/fetchOHLCV.ts @@ -1,13 +1,15 @@ -import { OHLCVParams } from "../../BaseExchange"; +import { OHLCVParams, RequestOptions } from "../../BaseExchange"; import { PriceCandle } from "../../types"; import { mapIntervalToKalshi } from "./utils"; import { validateIdFormat } from "../../utils/validation"; import { kalshiErrorMapper } from "./errors"; +import { getKalshiPriceContext, fromKalshiCents } from "./price"; export async function fetchOHLCV( id: string, params: OHLCVParams, callApi: (operationId: string, params?: Record) => Promise, + options?: RequestOptions, ): Promise { validateIdFormat(id, "OHLCV"); @@ -72,6 +74,7 @@ export async function fetchOHLCV( end_ts: endTs, }); const candles = data.candlesticks || []; + const priceContext = getKalshiPriceContext(options); const mappedCandles: PriceCandle[] = candles.map((c: any) => { // Priority: @@ -98,10 +101,10 @@ export async function fetchOHLCV( return { timestamp: c.end_period_ts * 1000, - open: getVal("open") / 100, - high: getVal("high") / 100, - low: getVal("low") / 100, - close: getVal("close") / 100, + open: fromKalshiCents(getVal("open"), priceContext), + high: fromKalshiCents(getVal("high"), priceContext), + low: fromKalshiCents(getVal("low"), priceContext), + close: fromKalshiCents(getVal("close"), priceContext), volume: c.volume || 0, }; }); diff --git a/core/src/exchanges/kalshi/fetchOrderBook.ts b/core/src/exchanges/kalshi/fetchOrderBook.ts index 2fd5881..a73e069 100644 --- a/core/src/exchanges/kalshi/fetchOrderBook.ts +++ b/core/src/exchanges/kalshi/fetchOrderBook.ts @@ -3,14 +3,22 @@ import { OrderBook } from "../../types"; import { validateIdFormat } from "../../utils/validation"; import { kalshiErrorMapper } from "./errors"; import { getMarketsUrl } from "./config"; +import { RequestOptions } from "../../BaseExchange"; +import { + getKalshiPriceContext, + fromKalshiCents, + invertKalshiCents, +} from "./price"; export async function fetchOrderBook( baseUrl: string, id: string, + options?: RequestOptions, ): Promise { validateIdFormat(id, "OrderBook"); try { + const priceContext = getKalshiPriceContext(options); // Check if this is a NO outcome request const isNoOutcome = id.endsWith("-NO"); const ticker = id.replace(/-NO$/, ""); @@ -31,12 +39,12 @@ export async function fetchOrderBook( // - Bids: people buying NO (use data.no directly) // - Asks: people selling NO = people buying YES (invert data.yes) bids = (data.no || []).map((level: number[]) => ({ - price: level[0] / 100, + price: fromKalshiCents(level[0], priceContext), size: level[1], })); asks = (data.yes || []).map((level: number[]) => ({ - price: 1 - level[0] / 100, // Invert YES price to get NO ask price + price: invertKalshiCents(level[0], priceContext), // Invert YES price to get NO ask price size: level[1], })); } else { @@ -44,12 +52,12 @@ export async function fetchOrderBook( // - Bids: people buying YES (use data.yes directly) // - Asks: people selling YES = people buying NO (invert data.no) bids = (data.yes || []).map((level: number[]) => ({ - price: level[0] / 100, + price: fromKalshiCents(level[0], priceContext), size: level[1], })); asks = (data.no || []).map((level: number[]) => ({ - price: 1 - level[0] / 100, // Invert NO price to get YES ask price + price: invertKalshiCents(level[0], priceContext), // Invert NO price to get YES ask price size: level[1], })); } diff --git a/core/src/exchanges/kalshi/fetchTrades.ts b/core/src/exchanges/kalshi/fetchTrades.ts index ef1fd44..9b17e5f 100644 --- a/core/src/exchanges/kalshi/fetchTrades.ts +++ b/core/src/exchanges/kalshi/fetchTrades.ts @@ -1,15 +1,18 @@ import axios from "axios"; -import { HistoryFilterParams, TradesParams } from "../../BaseExchange"; +import { HistoryFilterParams, TradesParams, RequestOptions } from "../../BaseExchange"; import { Trade } from "../../types"; import { kalshiErrorMapper } from "./errors"; import { getMarketsUrl } from "./config"; +import { getKalshiPriceContext, fromKalshiCents } from "./price"; export async function fetchTrades( baseUrl: string, id: string, params: TradesParams | HistoryFilterParams, + options?: RequestOptions, ): Promise { try { + const priceContext = getKalshiPriceContext(options); const ticker = id.replace(/-NO$/, ""); const url = getMarketsUrl(baseUrl, undefined, ["trades"]); const response = await axios.get(url, { @@ -23,7 +26,7 @@ export async function fetchTrades( return trades.map((t: any) => ({ id: t.trade_id, timestamp: new Date(t.created_time).getTime(), - price: t.yes_price / 100, + price: fromKalshiCents(t.yes_price, priceContext), amount: t.count, side: t.taker_side === "yes" ? "buy" : "sell", })); diff --git a/core/src/exchanges/kalshi/index.ts b/core/src/exchanges/kalshi/index.ts index f6ae1a4..152c45b 100644 --- a/core/src/exchanges/kalshi/index.ts +++ b/core/src/exchanges/kalshi/index.ts @@ -5,6 +5,7 @@ import { OHLCVParams, TradesParams, ExchangeCredentials, + RequestOptions, EventFetchParams, MyTradesParams, OrderHistoryParams, @@ -32,6 +33,11 @@ import { AuthenticationError } from "../../errors"; import { parseOpenApiSpec } from "../../utils/openapi"; import { kalshiApiSpec } from "./api"; import { getKalshiConfig, KalshiApiConfig, KALSHI_PATHS } from "./config"; +import { + getKalshiPriceContext, + fromKalshiCents, + invertKalshiCents, +} from "./price"; // Re-export for external use export type { KalshiWebSocketConfig }; @@ -146,26 +152,30 @@ export class KalshiExchange extends PredictionMarketExchange { protected async fetchMarketsImpl( params?: MarketFilterParams, + options?: RequestOptions, ): Promise { - return fetchMarkets(params, this.callApi.bind(this)); + return fetchMarkets(params, this.callApi.bind(this), options); } protected async fetchEventsImpl( params: EventFetchParams, + options?: RequestOptions, ): Promise { - return fetchEvents(params, this.callApi.bind(this)); + return fetchEvents(params, this.callApi.bind(this), options); } async fetchOHLCV( id: string, params: OHLCVParams, + options?: RequestOptions, ): Promise { - return fetchOHLCV(id, params, this.callApi.bind(this)); + return fetchOHLCV(id, params, this.callApi.bind(this), options); } - async fetchOrderBook(id: string): Promise { + async fetchOrderBook(id: string, options?: RequestOptions): Promise { validateIdFormat(id, "OrderBook"); + const priceContext = getKalshiPriceContext(options); const isNoOutcome = id.endsWith("-NO"); const ticker = id.replace(/-NO$/, ""); const data = (await this.callApi("GetMarketOrderbook", { ticker })) @@ -176,20 +186,20 @@ export class KalshiExchange extends PredictionMarketExchange { if (isNoOutcome) { bids = (data.no || []).map((level: number[]) => ({ - price: level[0] / 100, + price: fromKalshiCents(level[0], priceContext), size: level[1], })); asks = (data.yes || []).map((level: number[]) => ({ - price: 1 - level[0] / 100, + price: invertKalshiCents(level[0], priceContext), size: level[1], })); } else { bids = (data.yes || []).map((level: number[]) => ({ - price: level[0] / 100, + price: fromKalshiCents(level[0], priceContext), size: level[1], })); asks = (data.no || []).map((level: number[]) => ({ - price: 1 - level[0] / 100, + price: invertKalshiCents(level[0], priceContext), size: level[1], })); } @@ -203,6 +213,7 @@ export class KalshiExchange extends PredictionMarketExchange { async fetchTrades( id: string, params: TradesParams | HistoryFilterParams, + options?: RequestOptions, ): Promise { if ("resolution" in params && params.resolution !== undefined) { console.warn( @@ -210,6 +221,7 @@ export class KalshiExchange extends PredictionMarketExchange { "It will be removed in v3.0.0. Please remove it from your code.", ); } + const priceContext = getKalshiPriceContext(options); const ticker = id.replace(/-NO$/, ""); const data = await this.callApi("GetTrades", { ticker, @@ -219,7 +231,7 @@ export class KalshiExchange extends PredictionMarketExchange { return trades.map((t: any) => ({ id: t.trade_id, timestamp: new Date(t.created_time).getTime(), - price: t.yes_price / 100, + price: fromKalshiCents(t.yes_price, priceContext), amount: t.count, side: t.taker_side === "yes" ? "buy" : "sell", })); @@ -229,10 +241,11 @@ export class KalshiExchange extends PredictionMarketExchange { // User Data Methods // ---------------------------------------------------------------------------- - async fetchBalance(): Promise { + async fetchBalance(options?: RequestOptions): Promise { const data = await this.callApi("GetBalance"); - const available = data.balance / 100; - const total = data.portfolio_value / 100; + const priceContext = getKalshiPriceContext(options); + const available = fromKalshiCents(data.balance, priceContext); + const total = fromKalshiCents(data.portfolio_value, priceContext); return [ { currency: "USD", @@ -291,12 +304,12 @@ export class KalshiExchange extends PredictionMarketExchange { }; } - async fetchOrder(orderId: string): Promise { + async fetchOrder(orderId: string, options?: RequestOptions): Promise { const data = await this.callApi("GetOrder", { order_id: orderId }); - return this.mapKalshiOrder(data.order); + return this.mapKalshiOrder(data.order, options); } - async fetchOpenOrders(marketId?: string): Promise { + async fetchOpenOrders(marketId?: string, options?: RequestOptions): Promise { const queryParams: Record = { status: "resting" }; if (marketId) { queryParams.ticker = marketId; @@ -304,10 +317,13 @@ export class KalshiExchange extends PredictionMarketExchange { const data = await this.callApi("GetOrders", queryParams); const orders = data.orders || []; - return orders.map((order: any) => this.mapKalshiOrder(order)); + return orders.map((order: any) => this.mapKalshiOrder(order, options)); } - async fetchMyTrades(params?: MyTradesParams): Promise { + async fetchMyTrades( + params?: MyTradesParams, + options?: RequestOptions, + ): Promise { const queryParams: Record = {}; if (params?.outcomeId || params?.marketId) { queryParams.ticker = (params.outcomeId || params.marketId)!.replace( @@ -323,17 +339,21 @@ export class KalshiExchange extends PredictionMarketExchange { if (params?.cursor) queryParams.cursor = params.cursor; const data = await this.callApi("GetFills", queryParams); + const priceContext = getKalshiPriceContext(options); return (data.fills || []).map((f: any) => ({ id: f.fill_id, timestamp: new Date(f.created_time).getTime(), - price: f.yes_price / 100, + price: fromKalshiCents(f.yes_price, priceContext), amount: f.count, side: f.side === "yes" ? ("buy" as const) : ("sell" as const), orderId: f.order_id, })); } - async fetchClosedOrders(params?: OrderHistoryParams): Promise { + async fetchClosedOrders( + params?: OrderHistoryParams, + options?: RequestOptions, + ): Promise { const queryParams: Record = {}; if (params?.marketId) queryParams.ticker = params.marketId; if (params?.until) @@ -342,10 +362,13 @@ export class KalshiExchange extends PredictionMarketExchange { if (params?.cursor) queryParams.cursor = params.cursor; const data = await this.callApi("GetHistoricalOrders", queryParams); - return (data.orders || []).map((o: any) => this.mapKalshiOrder(o)); + return (data.orders || []).map((o: any) => this.mapKalshiOrder(o, options)); } - async fetchAllOrders(params?: OrderHistoryParams): Promise { + async fetchAllOrders( + params?: OrderHistoryParams, + options?: RequestOptions, + ): Promise { const queryParams: Record = {}; if (params?.marketId) queryParams.ticker = params.marketId; if (params?.since) @@ -370,20 +393,21 @@ export class KalshiExchange extends PredictionMarketExchange { ]) { if (!seen.has(o.order_id)) { seen.add(o.order_id); - all.push(this.mapKalshiOrder(o)); + all.push(this.mapKalshiOrder(o, options)); } } return all.sort((a, b) => b.timestamp - a.timestamp); } - async fetchPositions(): Promise { + async fetchPositions(options?: RequestOptions): Promise { const data = await this.callApi("GetPositions"); const positions = data.market_positions || []; + const priceContext = getKalshiPriceContext(options); return positions.map((pos: any) => { const absPosition = Math.abs(pos.position); const entryPrice = - absPosition > 0 ? pos.total_cost / absPosition / 100 : 0; + absPosition > 0 ? fromKalshiCents(pos.total_cost, priceContext) / absPosition : 0; return { marketId: pos.ticker, @@ -391,22 +415,31 @@ export class KalshiExchange extends PredictionMarketExchange { outcomeLabel: pos.ticker, size: pos.position, entryPrice, - currentPrice: pos.market_price ? pos.market_price / 100 : entryPrice, - unrealizedPnL: pos.market_exposure ? pos.market_exposure / 100 : 0, - realizedPnL: pos.realized_pnl ? pos.realized_pnl / 100 : 0, + currentPrice: pos.market_price + ? fromKalshiCents(pos.market_price, priceContext) + : entryPrice, + unrealizedPnL: pos.market_exposure + ? fromKalshiCents(pos.market_exposure, priceContext) + : 0, + realizedPnL: pos.realized_pnl + ? fromKalshiCents(pos.realized_pnl, priceContext) + : 0, }; }); } // Helper to map a raw Kalshi order object to a unified Order - private mapKalshiOrder(order: any): Order { + private mapKalshiOrder(order: any, options?: RequestOptions): Order { + const priceContext = getKalshiPriceContext(options); return { id: order.order_id, marketId: order.ticker, outcomeId: order.ticker, side: order.side === "yes" ? "buy" : "sell", type: order.type === "limit" ? "limit" : "market", - price: order.yes_price ? order.yes_price / 100 : undefined, + price: order.yes_price + ? fromKalshiCents(order.yes_price, priceContext) + : undefined, amount: order.count, status: this.mapKalshiOrderStatus(order.status), filled: order.count - (order.remaining_count || 0), @@ -439,7 +472,11 @@ export class KalshiExchange extends PredictionMarketExchange { private ws?: KalshiWebSocket; - async watchOrderBook(id: string, limit?: number): Promise { + async watchOrderBook( + id: string, + limit?: number, + options?: RequestOptions, + ): Promise { const auth = this.ensureAuth(); if (!this.ws) { @@ -452,13 +489,14 @@ export class KalshiExchange extends PredictionMarketExchange { } // Normalize ticker (strip -NO suffix if present) const marketTicker = id.replace(/-NO$/, ""); - return this.ws.watchOrderBook(marketTicker); + return this.ws.watchOrderBook(marketTicker, options); } async watchTrades( id: string, since?: number, limit?: number, + options?: RequestOptions, ): Promise { const auth = this.ensureAuth(); @@ -472,7 +510,7 @@ export class KalshiExchange extends PredictionMarketExchange { } // Normalize ticker (strip -NO suffix if present) const marketTicker = id.replace(/-NO$/, ""); - return this.ws.watchTrades(marketTicker); + return this.ws.watchTrades(marketTicker, options); } async close(): Promise { diff --git a/core/src/exchanges/kalshi/kalshi.test.ts b/core/src/exchanges/kalshi/kalshi.test.ts index 97014cb..c2cefe5 100644 --- a/core/src/exchanges/kalshi/kalshi.test.ts +++ b/core/src/exchanges/kalshi/kalshi.test.ts @@ -105,6 +105,56 @@ describe("KalshiExchange", () => { const markets = await exchange.fetchMarkets(); expect(markets).toBeDefined(); }); + + it("should return raw prices for fetchTrades when mode is raw", async () => { + const mockResponse = { + data: { + trades: [ + { + trade_id: "trade-1", + created_time: "2026-01-13T12:00:00Z", + yes_price: 55, + count: 10, + taker_side: "yes", + }, + ], + }, + }; + (mockAxiosInstance.request as jest.Mock).mockResolvedValue( + mockResponse, + ); + + const trades = await exchange.fetchTrades( + "TEST-MARKET", + { limit: 1 }, + { mode: "raw" }, + ); + + expect(trades).toHaveLength(1); + expect(trades[0].price).toBe(55); + }); + + it("should return raw prices for fetchOrderBook when mode is raw", async () => { + exchange = new KalshiExchange(mockCredentials); + const mockResponse = { + data: { + orderbook: { + yes: [[55, 10]], + no: [[45, 5]], + }, + }, + }; + (mockAxiosInstance.request as jest.Mock).mockResolvedValue( + mockResponse, + ); + + const book = await exchange.fetchOrderBook("TEST-MARKET", { + mode: "raw", + }); + + expect(book.bids[0].price).toBe(55); + expect(book.asks[0].price).toBe(55); + }); }); describe("Trading Methods", () => { diff --git a/core/src/exchanges/kalshi/price.test.ts b/core/src/exchanges/kalshi/price.test.ts new file mode 100644 index 0000000..7932e0a --- /dev/null +++ b/core/src/exchanges/kalshi/price.test.ts @@ -0,0 +1,42 @@ +import { + getKalshiPriceContext, + fromKalshiCents, + invertKalshiCents, + invertKalshiUnified, +} from "./price"; + +describe("kalshi price helpers", () => { + it("returns normalized context by default", () => { + const context = getKalshiPriceContext(); + + expect(context.isRaw).toBe(false); + expect(context.scale).toBe(100); + expect(context.unit).toBe(1); + expect(context.defaultPrice).toBe(0.5); + }); + + it("returns raw context when mode is raw", () => { + const context = getKalshiPriceContext({ mode: "raw" }); + + expect(context.isRaw).toBe(true); + expect(context.scale).toBe(1); + expect(context.unit).toBe(100); + expect(context.defaultPrice).toBe(50); + }); + + it("converts cents to unified normalized prices", () => { + const context = getKalshiPriceContext(); + + expect(fromKalshiCents(55, context)).toBe(0.55); + expect(invertKalshiCents(45, context)).toBe(0.55); + expect(invertKalshiUnified(0.45, context)).toBe(0.55); + }); + + it("converts cents to raw prices without scaling", () => { + const context = getKalshiPriceContext({ mode: "raw" }); + + expect(fromKalshiCents(55, context)).toBe(55); + expect(invertKalshiCents(45, context)).toBe(55); + expect(invertKalshiUnified(45, context)).toBe(55); + }); +}); diff --git a/core/src/exchanges/kalshi/price.ts b/core/src/exchanges/kalshi/price.ts new file mode 100644 index 0000000..ab60795 --- /dev/null +++ b/core/src/exchanges/kalshi/price.ts @@ -0,0 +1,41 @@ +import { RequestOptions } from "../../BaseExchange"; + +export interface KalshiPriceContext { + isRaw: boolean; + scale: number; + unit: number; + defaultPrice: number; +} + +export function getKalshiPriceContext( + options?: RequestOptions, +): KalshiPriceContext { + const isRaw = options?.mode === "raw"; + return { + isRaw, + scale: isRaw ? 1 : 100, + unit: isRaw ? 100 : 1, + defaultPrice: isRaw ? 50 : 0.5, + }; +} + +export function fromKalshiCents( + priceInCents: number, + context: KalshiPriceContext, +): number { + return priceInCents / context.scale; +} + +export function invertKalshiCents( + priceInCents: number, + context: KalshiPriceContext, +): number { + return context.unit - fromKalshiCents(priceInCents, context); +} + +export function invertKalshiUnified( + price: number, + context: KalshiPriceContext, +): number { + return context.unit - price; +} diff --git a/core/src/exchanges/kalshi/utils.ts b/core/src/exchanges/kalshi/utils.ts index e0e589c..43450fa 100644 --- a/core/src/exchanges/kalshi/utils.ts +++ b/core/src/exchanges/kalshi/utils.ts @@ -1,20 +1,32 @@ +import { RequestOptions } from "../../BaseExchange"; import { UnifiedMarket, MarketOutcome, CandleInterval } from "../../types"; import { addBinaryOutcomes } from "../../utils/market-utils"; +import { + getKalshiPriceContext, + fromKalshiCents, + invertKalshiUnified, +} from "./price"; export function mapMarketToUnified( event: any, market: any, + options?: RequestOptions, ): UnifiedMarket | null { if (!market) return null; + const priceContext = getKalshiPriceContext(options); + // Calculate price - let price = 0.5; + let price = priceContext.defaultPrice; if (market.last_price) { - price = market.last_price / 100; + price = fromKalshiCents(market.last_price, priceContext); } else if (market.yes_ask && market.yes_bid) { - price = (market.yes_ask + market.yes_bid) / 200; + price = + (fromKalshiCents(market.yes_ask, priceContext) + + fromKalshiCents(market.yes_bid, priceContext)) / + 2; } else if (market.yes_ask) { - price = market.yes_ask / 100; + price = fromKalshiCents(market.yes_ask, priceContext); } // Extract candidate name @@ -25,7 +37,19 @@ export function mapMarketToUnified( // Calculate 24h change let priceChange = 0; - if ( + if (priceContext.isRaw) { + if ( + market.previous_price !== undefined && + market.last_price !== undefined + ) { + priceChange = market.last_price - market.previous_price; + } else if ( + market.previous_price_dollars !== undefined && + market.last_price_dollars !== undefined + ) { + priceChange = market.last_price_dollars - market.previous_price_dollars; + } + } else if ( market.previous_price_dollars !== undefined && market.last_price_dollars !== undefined ) { @@ -44,7 +68,7 @@ export function mapMarketToUnified( outcomeId: `${market.ticker}-NO`, marketId: market.ticker, label: candidateName ? `Not ${candidateName}` : "No", - price: 1 - price, + price: invertKalshiUnified(price, priceContext), priceChange24h: -priceChange, // Inverse change for No? simplified assumption }, ]; diff --git a/core/src/exchanges/kalshi/websocket.ts b/core/src/exchanges/kalshi/websocket.ts index 85d6f63..5d6cba5 100644 --- a/core/src/exchanges/kalshi/websocket.ts +++ b/core/src/exchanges/kalshi/websocket.ts @@ -1,6 +1,12 @@ import WebSocket from "ws"; import { OrderBook, Trade, OrderLevel } from "../../types"; +import { RequestOptions } from "../../BaseExchange"; import { KalshiAuth } from "./auth"; +import { + getKalshiPriceContext, + fromKalshiCents, + invertKalshiCents, +} from "./price"; interface QueuedPromise { resolve: (value: T | PromiseLike) => void; @@ -26,6 +32,8 @@ export class KalshiWebSocket { private orderBookResolvers = new Map[]>(); private tradeResolvers = new Map[]>(); private orderBooks = new Map(); + private orderBookOptions = new Map(); + private tradeOptions = new Map(); private subscribedOrderBookTickers = new Set(); private subscribedTradeTickers = new Set(); private messageIdCounter = 1; @@ -222,6 +230,8 @@ export class KalshiWebSocket { private handleOrderbookSnapshot(data: any) { const ticker = data.market_ticker; + const options = this.orderBookOptions.get(ticker); + const priceContext = getKalshiPriceContext(options); // Kalshi orderbook structure: // yes: [{ price: number (cents), quantity: number }, ...] @@ -229,7 +239,7 @@ export class KalshiWebSocket { const bids: OrderLevel[] = (data.yes || []) .map((level: any) => { - const price = (level.price || level[0]) / 100; + const price = fromKalshiCents(level.price || level[0], priceContext); const size = (level.quantity !== undefined ? level.quantity @@ -242,7 +252,7 @@ export class KalshiWebSocket { const asks: OrderLevel[] = (data.no || []) .map((level: any) => { - const price = (100 - (level.price || level[0])) / 100; + const price = invertKalshiCents(level.price || level[0], priceContext); const size = (level.quantity !== undefined ? level.quantity @@ -266,6 +276,8 @@ export class KalshiWebSocket { private handleOrderbookDelta(data: any) { const ticker = data.market_ticker; const existing = this.orderBooks.get(ticker); + const options = this.orderBookOptions.get(ticker); + const priceContext = getKalshiPriceContext(options); if (!existing) { // No snapshot yet, skip delta @@ -274,7 +286,7 @@ export class KalshiWebSocket { // Apply delta updates // Kalshi sends: { price: number, delta: number, side: 'yes' | 'no' } - const price = data.price / 100; + const price = fromKalshiCents(data.price, priceContext); const delta = data.delta !== undefined ? data.delta @@ -286,8 +298,8 @@ export class KalshiWebSocket { if (side === "yes") { this.applyDelta(existing.bids, price, delta, "desc"); } else { - const yesPrice = (100 - data.price) / 100; - this.applyDelta(existing.asks, yesPrice, delta, "asc"); + const invertedPrice = invertKalshiCents(data.price, priceContext); + this.applyDelta(existing.asks, invertedPrice, delta, "asc"); } existing.timestamp = Date.now(); @@ -330,6 +342,8 @@ export class KalshiWebSocket { private handleTrade(data: any) { const ticker = data.market_ticker; + const options = this.tradeOptions.get(ticker); + const priceContext = getKalshiPriceContext(options); // Kalshi trade structure: // { trade_id, market_ticker, yes_price, no_price, count, created_time, taker_side } @@ -364,8 +378,8 @@ export class KalshiWebSocket { timestamp, price: data.yes_price || data.price - ? (data.yes_price || data.price) / 100 - : 0.5, + ? fromKalshiCents(data.yes_price || data.price, priceContext) + : priceContext.defaultPrice, amount: data.count || data.size || 0, side: data.taker_side === "yes" || data.side === "buy" @@ -390,7 +404,10 @@ export class KalshiWebSocket { } } - async watchOrderBook(ticker: string): Promise { + async watchOrderBook( + ticker: string, + options?: RequestOptions, + ): Promise { // Ensure connection if (!this.isConnected) { await this.connect(); @@ -401,6 +418,7 @@ export class KalshiWebSocket { this.subscribedOrderBookTickers.add(ticker); this.subscribeToOrderbook(Array.from(this.subscribedOrderBookTickers)); } + this.orderBookOptions.set(ticker, options); // Return a promise that resolves on the next orderbook update return new Promise((resolve, reject) => { @@ -411,7 +429,7 @@ export class KalshiWebSocket { }); } - async watchTrades(ticker: string): Promise { + async watchTrades(ticker: string, options?: RequestOptions): Promise { // Ensure connection if (!this.isConnected) { await this.connect(); @@ -422,6 +440,7 @@ export class KalshiWebSocket { this.subscribedTradeTickers.add(ticker); this.subscribeToTrades(Array.from(this.subscribedTradeTickers)); } + this.tradeOptions.set(ticker, options); // Return a promise that resolves on the next trade return new Promise((resolve, reject) => { diff --git a/core/src/exchanges/myriad/index.ts b/core/src/exchanges/myriad/index.ts index b16f912..93f2fe6 100644 --- a/core/src/exchanges/myriad/index.ts +++ b/core/src/exchanges/myriad/index.ts @@ -1,4 +1,4 @@ -import { PredictionMarketExchange, MarketFilterParams, HistoryFilterParams, OHLCVParams, TradesParams, ExchangeCredentials, EventFetchParams, MyTradesParams } from '../../BaseExchange'; +import { PredictionMarketExchange, MarketFilterParams, HistoryFilterParams, OHLCVParams, TradesParams, ExchangeCredentials, EventFetchParams, MyTradesParams, RequestOptions } from '../../BaseExchange'; import { UnifiedMarket, UnifiedEvent, PriceCandle, OrderBook, Trade, UserTrade, Balance, Order, Position, CreateOrderParams } from '../../types'; import { fetchMarkets } from './fetchMarkets'; import { fetchEvents } from './fetchEvents'; @@ -11,6 +11,7 @@ import { AuthenticationError } from '../../errors'; import { BASE_URL } from './utils'; import { parseOpenApiSpec } from '../../utils/openapi'; import { myriadApiSpec } from './api'; +import { resolveMyriadPrice } from './price'; export class MyriadExchange extends PredictionMarketExchange { override readonly has = { @@ -95,7 +96,11 @@ export class MyriadExchange extends PredictionMarketExchange { return fetchOrderBook(id, this.callApi.bind(this)); } - async fetchTrades(id: string, params: TradesParams | HistoryFilterParams): Promise { + async fetchTrades( + id: string, + params: TradesParams | HistoryFilterParams, + options?: RequestOptions, + ): Promise { if ('resolution' in params && params.resolution !== undefined) { console.warn( '[pmxt] Warning: The "resolution" parameter is deprecated for fetchTrades() and will be ignored. ' + @@ -140,13 +145,13 @@ export class MyriadExchange extends PredictionMarketExchange { return filtered.map((t: any, index: number) => ({ id: `${t.blockNumber || t.timestamp}-${index}`, timestamp: (t.timestamp || 0) * 1000, - price: t.shares > 0 ? Number(t.value) / Number(t.shares) : 0, + price: resolveMyriadPrice(t, options), amount: Number(t.shares || 0), side: t.action === 'buy' ? 'buy' as const : 'sell' as const, })); } - async fetchMyTrades(params?: MyTradesParams): Promise { + async fetchMyTrades(params?: MyTradesParams, options?: RequestOptions): Promise { const walletAddress = this.ensureAuth().walletAddress; if (!walletAddress) { throw new AuthenticationError( @@ -169,7 +174,7 @@ export class MyriadExchange extends PredictionMarketExchange { return tradeEvents.map((t: any, i: number) => ({ id: `${t.blockNumber || t.timestamp}-${i}`, timestamp: (t.timestamp || 0) * 1000, - price: t.shares > 0 ? Number(t.value) / Number(t.shares) : 0, + price: resolveMyriadPrice(t, options), amount: Number(t.shares || 0), side: t.action === 'buy' ? 'buy' as const : 'sell' as const, })); @@ -238,7 +243,7 @@ export class MyriadExchange extends PredictionMarketExchange { return []; // AMM: no open orders } - async fetchPositions(): Promise { + async fetchPositions(options?: RequestOptions): Promise { const walletAddress = this.ensureAuth().walletAddress; if (!walletAddress) { throw new AuthenticationError( @@ -256,7 +261,7 @@ export class MyriadExchange extends PredictionMarketExchange { outcomeLabel: pos.outcomeTitle || `Outcome ${pos.outcomeId}`, size: Number(pos.shares || 0), entryPrice: Number(pos.price || 0), - currentPrice: Number(pos.value || 0) / Math.max(Number(pos.shares || 1), 1), + currentPrice: resolveMyriadPrice(pos, options), unrealizedPnL: Number(pos.profit || 0), })); } @@ -290,20 +295,29 @@ export class MyriadExchange extends PredictionMarketExchange { // WebSocket (poll-based) // ------------------------------------------------------------------------ - async watchOrderBook(id: string, _limit?: number): Promise { + async watchOrderBook( + id: string, + _limit?: number, + options?: RequestOptions, + ): Promise { this.ensureAuth(); if (!this.ws) { this.ws = new MyriadWebSocket(this.callApi.bind(this)); } - return this.ws.watchOrderBook(id); + return this.ws.watchOrderBook(id, options); } - async watchTrades(id: string, _since?: number, _limit?: number): Promise { + async watchTrades( + id: string, + _since?: number, + _limit?: number, + options?: RequestOptions, + ): Promise { this.ensureAuth(); if (!this.ws) { this.ws = new MyriadWebSocket(this.callApi.bind(this)); } - return this.ws.watchTrades(id); + return this.ws.watchTrades(id, options); } async close(): Promise { @@ -312,4 +326,5 @@ export class MyriadExchange extends PredictionMarketExchange { this.ws = undefined; } } + } diff --git a/core/src/exchanges/myriad/price.test.ts b/core/src/exchanges/myriad/price.test.ts new file mode 100644 index 0000000..9e4e5bb --- /dev/null +++ b/core/src/exchanges/myriad/price.test.ts @@ -0,0 +1,25 @@ +import { resolveMyriadPrice } from "./price"; + +describe("myriad price helpers", () => { + it("uses raw event.price when raw mode is requested and present", () => { + const value = resolveMyriadPrice( + { price: "42.5", value: 100, shares: 2 }, + { mode: "raw" }, + ); + expect(value).toBe(42.5); + }); + + it("falls back to value/shares when raw price is missing", () => { + const value = resolveMyriadPrice( + { value: 100, shares: 4 }, + { mode: "raw" }, + ); + expect(value).toBe(25); + }); + + it("keeps previous fallback behavior when shares are missing or zero", () => { + expect(resolveMyriadPrice({ value: 12, shares: 0 })).toBe(12); + expect(resolveMyriadPrice({ value: 7 })).toBe(7); + }); +}); + diff --git a/core/src/exchanges/myriad/price.ts b/core/src/exchanges/myriad/price.ts new file mode 100644 index 0000000..5cb43d0 --- /dev/null +++ b/core/src/exchanges/myriad/price.ts @@ -0,0 +1,15 @@ +import { RequestOptions } from "../../BaseExchange"; + +export function resolveMyriadPrice(event: any, options?: RequestOptions): number { + if ( + options?.mode === "raw" && + event.price !== undefined && + event.price !== null + ) { + return Number(event.price); + } + + const shares = Math.max(Number(event.shares || 1), 1); + return Number(event.value || 0) / shares; +} + diff --git a/core/src/exchanges/myriad/websocket.ts b/core/src/exchanges/myriad/websocket.ts index 8073ded..af11bc9 100644 --- a/core/src/exchanges/myriad/websocket.ts +++ b/core/src/exchanges/myriad/websocket.ts @@ -1,5 +1,7 @@ import { OrderBook, Trade } from '../../types'; +import { RequestOptions } from '../../BaseExchange'; import { fetchOrderBook } from './fetchOrderBook'; +import { resolveMyriadPrice } from './price'; // Myriad API v2 does not expose a WebSocket endpoint. // We implement a poll-based fallback that resolves promises @@ -15,6 +17,7 @@ export class MyriadWebSocket { private orderBookResolvers: Map void)[]> = new Map(); private tradeResolvers: Map void)[]> = new Map(); private lastTradeTimestamp: Map = new Map(); + private tradeOptions: Map = new Map(); private closed = false; constructor(callApi: (operationId: string, params?: Record) => Promise, pollInterval?: number) { @@ -22,7 +25,7 @@ export class MyriadWebSocket { this.pollInterval = pollInterval || DEFAULT_POLL_INTERVAL; } - async watchOrderBook(id: string): Promise { + async watchOrderBook(id: string, _options?: RequestOptions): Promise { if (this.closed) throw new Error('WebSocket connection is closed'); return new Promise((resolve) => { @@ -37,7 +40,7 @@ export class MyriadWebSocket { }); } - async watchTrades(id: string): Promise { + async watchTrades(id: string, options?: RequestOptions): Promise { if (this.closed) throw new Error('WebSocket connection is closed'); return new Promise((resolve) => { @@ -46,6 +49,7 @@ export class MyriadWebSocket { } this.tradeResolvers.get(id)!.push(resolve); + this.tradeOptions.set(id, options); if (!this.tradeTimers.has(id)) { this.startTradePolling(id); } @@ -92,6 +96,7 @@ export class MyriadWebSocket { private startTradePolling(id: string): void { const poll = async () => { try { + const options = this.tradeOptions.get(id); const parts = id.split(':'); const [networkId, marketId] = parts; const outcomeId = parts.length >= 3 ? parts[2] : undefined; @@ -116,7 +121,7 @@ export class MyriadWebSocket { const trades: Trade[] = filtered.map((t: any, index: number) => ({ id: `${t.blockNumber || t.timestamp}-${index}`, timestamp: (t.timestamp || 0) * 1000, - price: t.shares > 0 ? Number(t.value) / Number(t.shares) : 0, + price: resolveMyriadPrice(t, options), amount: Number(t.shares || 0), side: t.action === 'buy' ? 'buy' as const : 'sell' as const, })); diff --git a/core/src/server/openapi.yaml b/core/src/server/openapi.yaml index 8295a32..5e1a5ae 100644 --- a/core/src/server/openapi.yaml +++ b/core/src/server/openapi.yaml @@ -82,9 +82,12 @@ paths: properties: args: type: array - maxItems: 1 + minItems: 0 + maxItems: 2 items: - $ref: '#/components/schemas/MarketFilterParams' + oneOf: + - $ref: '#/components/schemas/MarketFilterParams' + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' responses: @@ -119,14 +122,17 @@ paths: properties: args: type: array - maxItems: 1 + minItems: 0 + maxItems: 2 items: - type: object - properties: - limit: - type: number - cursor: - type: string + oneOf: + - type: object + properties: + limit: + type: number + cursor: + type: string + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' responses: @@ -162,9 +168,12 @@ paths: properties: args: type: array - maxItems: 1 + minItems: 0 + maxItems: 2 items: - $ref: '#/components/schemas/EventFetchParams' + oneOf: + - $ref: '#/components/schemas/EventFetchParams' + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' responses: @@ -199,9 +208,12 @@ paths: properties: args: type: array - maxItems: 1 + minItems: 0 + maxItems: 2 items: - $ref: '#/components/schemas/MarketFilterParams' + oneOf: + - $ref: '#/components/schemas/MarketFilterParams' + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' responses: @@ -234,9 +246,12 @@ paths: properties: args: type: array - maxItems: 1 + minItems: 0 + maxItems: 2 items: - $ref: '#/components/schemas/EventFetchParams' + oneOf: + - $ref: '#/components/schemas/EventFetchParams' + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' responses: @@ -270,11 +285,12 @@ paths: args: type: array minItems: 2 - maxItems: 2 + maxItems: 3 items: oneOf: - type: string - $ref: '#/components/schemas/OHLCVParams' + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' required: @@ -309,10 +325,12 @@ paths: properties: args: type: array - maxItems: 1 - items: - type: string minItems: 1 + maxItems: 2 + items: + oneOf: + - type: string + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' required: @@ -348,13 +366,14 @@ paths: args: type: array minItems: 2 - maxItems: 2 + maxItems: 3 items: oneOf: - type: string - oneOf: - $ref: '#/components/schemas/TradesParams' - $ref: '#/components/schemas/HistoryFilterParams' + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' required: @@ -461,10 +480,12 @@ paths: properties: args: type: array - maxItems: 1 - items: - type: string minItems: 1 + maxItems: 2 + items: + oneOf: + - type: string + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' required: @@ -497,9 +518,12 @@ paths: properties: args: type: array - maxItems: 1 + minItems: 0 + maxItems: 2 items: - type: string + oneOf: + - type: string + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' responses: @@ -532,9 +556,12 @@ paths: properties: args: type: array - maxItems: 1 + minItems: 0 + maxItems: 2 items: - $ref: '#/components/schemas/MyTradesParams' + oneOf: + - $ref: '#/components/schemas/MyTradesParams' + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' responses: @@ -566,9 +593,12 @@ paths: properties: args: type: array - maxItems: 1 + minItems: 0 + maxItems: 2 items: - $ref: '#/components/schemas/OrderHistoryParams' + oneOf: + - $ref: '#/components/schemas/OrderHistoryParams' + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' responses: @@ -600,9 +630,12 @@ paths: properties: args: type: array - maxItems: 1 + minItems: 0 + maxItems: 2 items: - $ref: '#/components/schemas/OrderHistoryParams' + oneOf: + - $ref: '#/components/schemas/OrderHistoryParams' + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' responses: @@ -634,8 +667,9 @@ paths: properties: args: type: array - maxItems: 0 - items: {} + maxItems: 1 + items: + type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' responses: @@ -668,8 +702,9 @@ paths: properties: args: type: array - maxItems: 0 - items: {} + maxItems: 1 + items: + type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' responses: @@ -883,11 +918,12 @@ paths: args: type: array minItems: 1 - maxItems: 2 + maxItems: 3 items: oneOf: - type: string - type: number + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' required: @@ -923,12 +959,13 @@ paths: args: type: array minItems: 1 - maxItems: 3 + maxItems: 4 items: oneOf: - type: string - type: number - type: number + - type: object credentials: $ref: '#/components/schemas/ExchangeCredentials' required: @@ -950,6 +987,39 @@ paths: description: >- Watch trade executions in real-time via WebSocket. Returns a promise that resolves with the next trade(s). Call repeatedly in a loop to stream updates (CCXT Pro pattern). + '/api/{exchange}/testDummyMethod': + post: + summary: Test Dummy Method + operationId: testDummyMethod + parameters: + - $ref: '#/components/parameters/ExchangeParam' + requestBody: + content: + application/json: + schema: + title: TestDummyMethodRequest + type: object + properties: + args: + type: array + maxItems: 1 + items: + type: string + credentials: + $ref: '#/components/schemas/ExchangeCredentials' + responses: + '200': + description: Test Dummy Method response + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + type: string + description: Test method for auto-generation verification. '/api/{exchange}/close': post: summary: Close @@ -976,9 +1046,6 @@ paths: application/json: schema: $ref: '#/components/schemas/BaseResponse' - description: >- - Close all WebSocket connections and clean up resources. Call this when you're done streaming to properly release - connections. components: parameters: ExchangeParam: diff --git a/docs/MIGRATE_FROM_DOMEAPI.md b/docs/MIGRATE_FROM_DOMEAPI.md index 89e3477..9460dc8 100644 --- a/docs/MIGRATE_FROM_DOMEAPI.md +++ b/docs/MIGRATE_FROM_DOMEAPI.md @@ -94,7 +94,7 @@ markets = poly.fetch_markets(query='Trump') price = markets[0].yes.price # 0.0 to 1.0 ``` -> Note: DomeAPI returns price as a raw value. pmxt prices are always 0.0-1.0 (probability). Multiply by 100 for percentage. +> Note: DomeAPI returns price as a raw value. pmxt prices default to 0.0-1.0 (probability). To return raw exchange values, pass `{ mode: "raw" }` as the final argument (e.g., `fetchMarkets(params, { mode: "raw" })`). --- diff --git a/readme.md b/readme.md index cc82397..8fe16aa 100644 --- a/readme.md +++ b/readme.md @@ -148,6 +148,8 @@ const warsh = fedEvent.markets.match('Kevin Warsh'); console.log(`Price: ${warsh.yes?.price}`); ``` +Prices are normalized to `0.0-1.0` by default. To return raw exchange values, pass `{ mode: "raw" }` as the final argument to fetch methods (e.g., `fetchMarkets(params, { mode: "raw" })`). + ## Trading pmxt supports unified trading across exchanges. diff --git a/sdks/python/README.md b/sdks/python/README.md index f48ab9f..ff611f9 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -158,6 +158,20 @@ print(f"Available: ${balances[0].available}") - `get_execution_price(order_book, side, amount)` - Get execution price - `get_execution_price_detailed(order_book, side, amount)` - Get detailed execution info +### Raw Price Mode + +Use `mode="raw"` to get source exchange prices without normalization. + +```python +# Kalshi raw cents +book = kalshi.fetch_order_book("KXBTC-100K-YES", mode="raw") +print(book.bids[0].price) # e.g. 55 instead of 0.55 + +# Polymarket normalized default +book = poly.fetch_order_book(outcome_id) +print(book.bids[0].price) # e.g. 0.55 +``` + ### Trading Methods (require authentication) - `create_order(params)` - Place a new order diff --git a/sdks/python/pmxt/__init__.py b/sdks/python/pmxt/__init__.py index 72a6c74..f4f7f19 100644 --- a/sdks/python/pmxt/__init__.py +++ b/sdks/python/pmxt/__init__.py @@ -33,6 +33,7 @@ Order, Position, Balance, + RequestOptions, ) @@ -80,4 +81,5 @@ def restart_server(): "Order", "Position", "Balance", + "RequestOptions", ] diff --git a/sdks/python/pmxt/client.py b/sdks/python/pmxt/client.py index de3244e..56d1efc 100644 --- a/sdks/python/pmxt/client.py +++ b/sdks/python/pmxt/client.py @@ -7,7 +7,7 @@ import os import sys -from typing import List, Optional, Dict, Any, Literal, Union +from typing import List, Optional, Dict, Any, Literal, Union, Sequence from datetime import datetime from abc import ABC, abstractmethod import json @@ -226,7 +226,7 @@ def __init__( base_url: str = "http://localhost:3847", auto_start_server: bool = True, proxy_address: Optional[str] = None, - signature_type: Optional[Any] = None, + signature_type: Optional[Union[int, str]] = None, ): """ Initialize an exchange client. @@ -330,6 +330,45 @@ def _get_credentials_dict(self) -> Optional[Dict[str, Any]]: creds["signatureType"] = self.signature_type return creds if creds else None + def _build_request_options( + self, + mode: Optional[Literal["normalized", "raw"]], + ) -> Optional[Dict[str, str]]: + """Build validated request options.""" + if mode is None: + return None + if mode not in ("normalized", "raw"): + raise ValueError("mode must be either 'normalized' or 'raw'") + return {"mode": mode} + + def _build_args_with_optional_options( + self, + required_args: Sequence[object], + optional_args: Optional[Sequence[object]] = None, + mode: Optional[Literal["normalized", "raw"]] = None, + ) -> List[object]: + """ + Build args while preserving optional argument positions when options are present. + """ + args: List[object] = list(required_args) + optional_values = list(optional_args or []) + options = self._build_request_options(mode) + + if options is not None: + args.extend(optional_values) + args.append(options) + return args + + last_non_none = -1 + for i, value in enumerate(optional_values): + if value is not None: + last_non_none = i + + if last_non_none >= 0: + args.extend(optional_values[: last_non_none + 1]) + + return args + @property def has(self) -> Dict[str, Any]: """ @@ -363,11 +402,20 @@ def has(self) -> Dict[str, Any]: # Low-Level API Access - def _call_method(self, method_name: str, params: Optional[Dict[str, Any]] = None) -> Any: + def _call_method( + self, + method_name: str, + params: Optional[Dict[str, object]] = None, + mode: Optional[Literal["normalized", "raw"]] = None, + ) -> Any: """Call any exchange method on the server by name.""" try: url = f"{self._api_client.configuration.host}/api/{self.exchange_name}/{method_name}" - body: Dict[str, Any] = {"args": [params] if params is not None else []} + optional_params = params + if mode is not None and optional_params is None: + optional_params = {} + args = self._build_args_with_optional_options([], [optional_params], mode) + body: Dict[str, object] = {"args": args} creds = self._get_credentials_dict() if creds: body["credentials"] = creds @@ -462,7 +510,12 @@ def load_markets(self, reload: bool = False) -> Dict[str, UnifiedMarket]: self._loaded_markets = True return self.markets - def fetch_markets(self, query: Optional[str] = None, **kwargs) -> List[UnifiedMarket]: + def fetch_markets( + self, + query: Optional[str] = None, + mode: Optional[Literal["normalized", "raw"]] = None, + **kwargs, + ) -> List[UnifiedMarket]: """ Get active markets from the exchange. @@ -477,10 +530,8 @@ def fetch_markets(self, query: Optional[str] = None, **kwargs) -> List[UnifiedMa >>> markets = exchange.fetch_markets("Trump", limit=20, sort="volume") """ try: - body_dict = {"args": []} - # Prepare arguments - search_params = {} + search_params: Dict[str, object] = {} if query: search_params["query"] = query @@ -488,8 +539,13 @@ def fetch_markets(self, query: Optional[str] = None, **kwargs) -> List[UnifiedMa for key, value in kwargs.items(): search_params[key] = value - if search_params: - body_dict["args"] = [search_params] + params_arg: Optional[Dict[str, object]] = ( + search_params if search_params else None + ) + if mode is not None and params_arg is None: + params_arg = {} + args = self._build_args_with_optional_options([], [params_arg], mode) + body_dict = {"args": args} # Add credentials if available creds = self._get_credentials_dict() @@ -508,7 +564,12 @@ def fetch_markets(self, query: Optional[str] = None, **kwargs) -> List[UnifiedMa except ApiException as e: raise Exception(f"Failed to fetch markets: {self._extract_api_error(e)}") from None - def fetch_events(self, query: Optional[str] = None, **kwargs) -> List[UnifiedEvent]: + def fetch_events( + self, + query: Optional[str] = None, + mode: Optional[Literal["normalized", "raw"]] = None, + **kwargs, + ) -> List[UnifiedEvent]: """ Fetch events with optional keyword search. Events group related markets together. @@ -524,10 +585,8 @@ def fetch_events(self, query: Optional[str] = None, **kwargs) -> List[UnifiedEve >>> events = exchange.fetch_events("Election", limit=10) """ try: - body_dict = {"args": []} - # Prepare arguments - search_params = {} + search_params: Dict[str, object] = {} if query: search_params["query"] = query @@ -535,8 +594,13 @@ def fetch_events(self, query: Optional[str] = None, **kwargs) -> List[UnifiedEve for key, value in kwargs.items(): search_params[key] = value - if search_params: - body_dict["args"] = [search_params] + params_arg: Optional[Dict[str, object]] = ( + search_params if search_params else None + ) + if mode is not None and params_arg is None: + params_arg = {} + args = self._build_args_with_optional_options([], [params_arg], mode) + body_dict = {"args": args} # Add credentials if available creds = self._get_credentials_dict() @@ -562,7 +626,8 @@ def fetch_market( event_id: Optional[str] = None, slug: Optional[str] = None, query: Optional[str] = None, - **kwargs + mode: Optional[Literal["normalized", "raw"]] = None, + **kwargs, ) -> UnifiedMarket: """ Fetch a single market by lookup parameters. @@ -587,7 +652,7 @@ def fetch_market( >>> market = exchange.fetch_market(slug='will-trump-win') """ try: - search_params = {} + search_params: Dict[str, object] = {} if market_id: search_params["marketId"] = market_id if outcome_id: @@ -608,7 +673,13 @@ def fetch_market( camel_key = key_map.get(key, key) search_params[camel_key] = value - body_dict = {"args": [search_params] if search_params else []} + params_arg: Optional[Dict[str, object]] = ( + search_params if search_params else None + ) + if mode is not None and params_arg is None: + params_arg = {} + args = self._build_args_with_optional_options([], [params_arg], mode) + body_dict = {"args": args} creds = self._get_credentials_dict() if creds: @@ -639,7 +710,8 @@ def fetch_event( event_id: Optional[str] = None, slug: Optional[str] = None, query: Optional[str] = None, - **kwargs + mode: Optional[Literal["normalized", "raw"]] = None, + **kwargs, ) -> UnifiedEvent: """ Fetch a single event by lookup parameters. @@ -662,7 +734,7 @@ def fetch_event( >>> event = exchange.fetch_event(slug='us-election') """ try: - search_params = {} + search_params: Dict[str, object] = {} if event_id: search_params["eventId"] = event_id if slug: @@ -678,7 +750,13 @@ def fetch_event( camel_key = key_map.get(key, key) search_params[camel_key] = value - body_dict = {"args": [search_params] if search_params else []} + params_arg: Optional[Dict[str, object]] = ( + search_params if search_params else None + ) + if mode is not None and params_arg is None: + params_arg = {} + args = self._build_args_with_optional_options([], [params_arg], mode) + body_dict = {"args": args} creds = self._get_credentials_dict() if creds: @@ -927,7 +1005,8 @@ def fetch_ohlcv( limit: Optional[int] = None, start: Optional[datetime] = None, end: Optional[datetime] = None, - **kwargs + mode: Optional[Literal["normalized", "raw"]] = None, + **kwargs, ) -> List[PriceCandle]: """ Get historical price candles. @@ -957,7 +1036,7 @@ def fetch_ohlcv( ... ) """ try: - params_dict = {} + params_dict: Dict[str, object] = {} if resolution: params_dict["resolution"] = resolution if start: @@ -972,7 +1051,12 @@ def fetch_ohlcv( if key not in params_dict: params_dict[key] = value - request_body_dict = {"args": [outcome_id, params_dict]} + args = self._build_args_with_optional_options( + [outcome_id], + [params_dict], + mode, + ) + request_body_dict = {"args": args} request_body = internal_models.FetchOHLCVRequest.from_dict(request_body_dict) response = self._api.fetch_ohlcv( @@ -985,7 +1069,11 @@ def fetch_ohlcv( except ApiException as e: raise Exception(f"Failed to fetch OHLCV: {self._extract_api_error(e)}") from None - def fetch_order_book(self, outcome_id: str) -> OrderBook: + def fetch_order_book( + self, + outcome_id: str, + mode: Optional[Literal["normalized", "raw"]] = None, + ) -> OrderBook: """ Get current order book for an outcome. @@ -1001,7 +1089,8 @@ def fetch_order_book(self, outcome_id: str) -> OrderBook: >>> print(f"Best ask: {order_book.asks[0].price}") """ try: - body_dict = {"args": [outcome_id]} + args = self._build_args_with_optional_options([outcome_id], [], mode) + body_dict = {"args": args} request_body = internal_models.FetchOrderBookRequest.from_dict(body_dict) response = self._api.fetch_order_book( @@ -1019,7 +1108,8 @@ def fetch_trades( outcome_id: str, limit: Optional[int] = None, since: Optional[int] = None, - **kwargs + mode: Optional[Literal["normalized", "raw"]] = None, + **kwargs, ) -> List[Trade]: """ Get trade history for an outcome. @@ -1039,7 +1129,7 @@ def fetch_trades( >>> trades = exchange.fetch_trades(outcome_id, limit=50) """ try: - params_dict = {} + params_dict: Dict[str, object] = {} if limit: params_dict["limit"] = limit if since: @@ -1050,7 +1140,12 @@ def fetch_trades( if key not in params_dict: params_dict[key] = value - request_body_dict = {"args": [outcome_id, params_dict]} + args = self._build_args_with_optional_options( + [outcome_id], + [params_dict], + mode, + ) + request_body_dict = {"args": args} request_body = internal_models.FetchTradesRequest.from_dict(request_body_dict) response = self._api.fetch_trades( @@ -1065,7 +1160,12 @@ def fetch_trades( # WebSocket Streaming Methods - def watch_order_book(self, outcome_id: str, limit: Optional[int] = None) -> OrderBook: + def watch_order_book( + self, + outcome_id: str, + limit: Optional[int] = None, + mode: Optional[Literal["normalized", "raw"]] = None, + ) -> OrderBook: """ Watch real-time order book updates via WebSocket. @@ -1087,9 +1187,11 @@ def watch_order_book(self, outcome_id: str, limit: Optional[int] = None) -> Orde ... print(f"Best ask: {order_book.asks[0].price}") """ try: - args = [outcome_id] - if limit is not None: - args.append(limit) + args = self._build_args_with_optional_options( + [outcome_id], + [limit], + mode, + ) body_dict = {"args": args} @@ -1114,7 +1216,8 @@ def watch_trades( self, outcome_id: str, since: Optional[int] = None, - limit: Optional[int] = None + limit: Optional[int] = None, + mode: Optional[Literal["normalized", "raw"]] = None, ) -> List[Trade]: """ Watch real-time trade updates via WebSocket. @@ -1138,11 +1241,11 @@ def watch_trades( ... print(f"Trade: {trade.price} @ {trade.amount}") """ try: - args = [outcome_id] - if since is not None: - args.append(since) - if limit is not None: - args.append(limit) + args = self._build_args_with_optional_options( + [outcome_id], + [since, limit], + mode, + ) body_dict = {"args": args} @@ -1384,7 +1487,11 @@ def cancel_order(self, order_id: str) -> Order: except ApiException as e: raise Exception(f"Failed to cancel order: {self._extract_api_error(e)}") from None - def fetch_order(self, order_id: str) -> Order: + def fetch_order( + self, + order_id: str, + mode: Optional[Literal["normalized", "raw"]] = None, + ) -> Order: """ Get details of a specific order. @@ -1395,7 +1502,8 @@ def fetch_order(self, order_id: str) -> Order: Order details """ try: - body_dict = {"args": [order_id]} + args = self._build_args_with_optional_options([order_id], [], mode) + body_dict = {"args": args} # Add credentials if available creds = self._get_credentials_dict() @@ -1414,7 +1522,11 @@ def fetch_order(self, order_id: str) -> Order: except ApiException as e: raise Exception(f"Failed to fetch order: {self._extract_api_error(e)}") from None - def fetch_open_orders(self, market_id: Optional[str] = None) -> List[Order]: + def fetch_open_orders( + self, + market_id: Optional[str] = None, + mode: Optional[Literal["normalized", "raw"]] = None, + ) -> List[Order]: """ Get all open orders, optionally filtered by market. @@ -1425,9 +1537,7 @@ def fetch_open_orders(self, market_id: Optional[str] = None) -> List[Order]: List of open orders """ try: - args = [] - if market_id: - args.append(market_id) + args = self._build_args_with_optional_options([], [market_id], mode) body_dict = {"args": args} @@ -1452,9 +1562,10 @@ def fetch_my_trades( self, outcome_id: Optional[str] = None, market_id: Optional[str] = None, - since: Optional[Any] = None, + since: Optional[Union[datetime, int, float, str]] = None, limit: Optional[int] = None, cursor: Optional[str] = None, + mode: Optional[Literal["normalized", "raw"]] = None, ) -> List[UserTrade]: """ Get trades made by the authenticated user. @@ -1472,26 +1583,30 @@ def fetch_my_trades( Example: trades = exchange.fetch_my_trades(limit=50) """ - params: Dict[str, Any] = {} + params: Dict[str, object] = {} if outcome_id is not None: params["outcomeId"] = outcome_id if market_id is not None: params["marketId"] = market_id if since is not None: - params["since"] = since.isoformat() if hasattr(since, "isoformat") else since + if isinstance(since, datetime): + params["since"] = since.isoformat() + else: + params["since"] = since if limit is not None: params["limit"] = limit if cursor is not None: params["cursor"] = cursor - data = self._call_method("fetchMyTrades", params or None) + data = self._call_method("fetchMyTrades", params or None, mode) return [_convert_user_trade(t) for t in (data or [])] def fetch_closed_orders( self, market_id: Optional[str] = None, - since: Optional[Any] = None, - until: Optional[Any] = None, + since: Optional[Union[datetime, int, float, str]] = None, + until: Optional[Union[datetime, int, float, str]] = None, limit: Optional[int] = None, + mode: Optional[Literal["normalized", "raw"]] = None, ) -> List[Order]: """ Get filled and cancelled orders. @@ -1508,24 +1623,31 @@ def fetch_closed_orders( Example: orders = exchange.fetch_closed_orders(market_id="some-market") """ - params: Dict[str, Any] = {} + params: Dict[str, object] = {} if market_id is not None: params["marketId"] = market_id if since is not None: - params["since"] = since.isoformat() if hasattr(since, "isoformat") else since + if isinstance(since, datetime): + params["since"] = since.isoformat() + else: + params["since"] = since if until is not None: - params["until"] = until.isoformat() if hasattr(until, "isoformat") else until + if isinstance(until, datetime): + params["until"] = until.isoformat() + else: + params["until"] = until if limit is not None: params["limit"] = limit - data = self._call_method("fetchClosedOrders", params or None) + data = self._call_method("fetchClosedOrders", params or None, mode) return [_convert_order(o) for o in (data or [])] def fetch_all_orders( self, market_id: Optional[str] = None, - since: Optional[Any] = None, - until: Optional[Any] = None, + since: Optional[Union[datetime, int, float, str]] = None, + until: Optional[Union[datetime, int, float, str]] = None, limit: Optional[int] = None, + mode: Optional[Literal["normalized", "raw"]] = None, ) -> List[Order]: """ Get all orders (open + closed), sorted newest-first. @@ -1542,22 +1664,29 @@ def fetch_all_orders( Example: orders = exchange.fetch_all_orders() """ - params: Dict[str, Any] = {} + params: Dict[str, object] = {} if market_id is not None: params["marketId"] = market_id if since is not None: - params["since"] = since.isoformat() if hasattr(since, "isoformat") else since + if isinstance(since, datetime): + params["since"] = since.isoformat() + else: + params["since"] = since if until is not None: - params["until"] = until.isoformat() if hasattr(until, "isoformat") else until + if isinstance(until, datetime): + params["until"] = until.isoformat() + else: + params["until"] = until if limit is not None: params["limit"] = limit - data = self._call_method("fetchAllOrders", params or None) + data = self._call_method("fetchAllOrders", params or None, mode) return [_convert_order(o) for o in (data or [])] def fetch_markets_paginated( self, limit: Optional[int] = None, cursor: Optional[str] = None, + mode: Optional[Literal["normalized", "raw"]] = None, ) -> PaginatedMarketsResult: """ Fetch markets with cursor-based pagination. @@ -1577,12 +1706,12 @@ def fetch_markets_paginated( while page.next_cursor: page = exchange.fetch_markets_paginated(limit=100, cursor=page.next_cursor) """ - params: Dict[str, Any] = {} + params: Dict[str, object] = {} if limit is not None: params["limit"] = limit if cursor is not None: params["cursor"] = cursor - raw = self._call_method("fetchMarketsPaginated", params or None) + raw = self._call_method("fetchMarketsPaginated", params or None, mode) return PaginatedMarketsResult( data=[_convert_market(m) for m in raw.get("data", [])], total=raw.get("total", 0), @@ -1591,7 +1720,10 @@ def fetch_markets_paginated( # Account Methods - def fetch_positions(self) -> List[Position]: + def fetch_positions( + self, + mode: Optional[Literal["normalized", "raw"]] = None, + ) -> List[Position]: """ Get current positions across all markets. @@ -1599,7 +1731,8 @@ def fetch_positions(self) -> List[Position]: List of positions """ try: - body_dict = {"args": []} + args = self._build_args_with_optional_options([], [], mode) + body_dict = {"args": args} # Add credentials if available creds = self._get_credentials_dict() @@ -1618,7 +1751,10 @@ def fetch_positions(self) -> List[Position]: except ApiException as e: raise Exception(f"Failed to fetch positions: {self._extract_api_error(e)}") from None - def fetch_balance(self) -> List[Balance]: + def fetch_balance( + self, + mode: Optional[Literal["normalized", "raw"]] = None, + ) -> List[Balance]: """ Get account balance. @@ -1626,7 +1762,8 @@ def fetch_balance(self) -> List[Balance]: List of balances (by currency) """ try: - body_dict = {"args": []} + args = self._build_args_with_optional_options([], [], mode) + body_dict = {"args": args} # Add credentials if available creds = self._get_credentials_dict() @@ -1717,4 +1854,3 @@ def get_execution_price_detailed( return _convert_execution_result(data) except Exception as e: raise Exception(f"Failed to get execution price: {self._extract_api_error(e)}") from None - diff --git a/sdks/python/pmxt/models.py b/sdks/python/pmxt/models.py index ffbd68c..1262be5 100644 --- a/sdks/python/pmxt/models.py +++ b/sdks/python/pmxt/models.py @@ -16,6 +16,15 @@ OrderSide = Literal["buy", "sell"] OrderType = Literal["market", "limit"] OutcomeType = Literal["yes", "no", "up", "down"] +PriceMode = Literal["normalized", "raw"] + + +@dataclass +class RequestOptions: + """Optional request options for exchange methods.""" + + mode: Optional[PriceMode] = None + """Price mode. Use 'raw' to skip normalization when supported.""" @dataclass @@ -447,4 +456,3 @@ class EventFilterCriteria(TypedDict, total=False): total_volume: MinMax EventFilterFunction = Callable[[UnifiedEvent], bool] - diff --git a/sdks/python/tests/test_raw_mode_args.py b/sdks/python/tests/test_raw_mode_args.py new file mode 100644 index 0000000..c59d7f1 --- /dev/null +++ b/sdks/python/tests/test_raw_mode_args.py @@ -0,0 +1,98 @@ +import json +import sys +import types +import unittest + +if "pmxt_internal" not in sys.modules: + fake_pmxt_internal = types.ModuleType("pmxt_internal") + + class _FakeApiClient: + def __init__(self, configuration=None): + self.configuration = configuration + self.default_headers = {} + + def call_api(self, **kwargs): + return _FakeResponse({"success": True, "data": {}}) + + class _FakeConfiguration: + def __init__(self, host="http://localhost:3847"): + self.host = host + + class _FakeDefaultApi: + def __init__(self, api_client=None): + self.api_client = api_client + + class _FakeApiException(Exception): + pass + + fake_pmxt_internal.ApiClient = _FakeApiClient + fake_pmxt_internal.Configuration = _FakeConfiguration + fake_pmxt_internal.models = types.SimpleNamespace() + sys.modules["pmxt_internal"] = fake_pmxt_internal + + fake_api_pkg = types.ModuleType("pmxt_internal.api") + sys.modules["pmxt_internal.api"] = fake_api_pkg + + fake_default_api = types.ModuleType("pmxt_internal.api.default_api") + fake_default_api.DefaultApi = _FakeDefaultApi + sys.modules["pmxt_internal.api.default_api"] = fake_default_api + + fake_exceptions = types.ModuleType("pmxt_internal.exceptions") + fake_exceptions.ApiException = _FakeApiException + sys.modules["pmxt_internal.exceptions"] = fake_exceptions + +from pmxt.client import Exchange + + +class _FakeResponse: + def __init__(self, payload): + self.data = json.dumps(payload).encode("utf-8") + + def read(self): + return None + + +class TestRawModeArgs(unittest.TestCase): + def setUp(self): + self.exchange = Exchange("polymarket", auto_start_server=False) + + def test_build_request_options_rejects_invalid_mode(self): + with self.assertRaises(ValueError): + self.exchange._build_request_options("invalid") + + def test_build_args_preserves_optional_positions_in_raw_mode(self): + args = self.exchange._build_args_with_optional_options( + ["outcome-id"], + [None, 50], + "raw", + ) + self.assertEqual(args, ["outcome-id", None, 50, {"mode": "raw"}]) + + def test_build_args_trims_trailing_none_without_mode(self): + args = self.exchange._build_args_with_optional_options( + ["outcome-id"], + [10, None], + None, + ) + self.assertEqual(args, ["outcome-id", 10]) + + def test_call_method_sends_empty_params_when_only_mode_is_set(self): + captured = {} + + def fake_call_api(**kwargs): + captured.update(kwargs) + return _FakeResponse({"success": True, "data": {"ok": True}}) + + self.exchange._api_client.call_api = fake_call_api + result = self.exchange._call_method( + "fetchMarketsPaginated", + params=None, + mode="raw", + ) + + self.assertEqual(result, {"ok": True}) + self.assertEqual(captured["body"]["args"], [{}, {"mode": "raw"}]) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdks/python/tests/test_status.py b/sdks/python/tests/test_status.py index affb751..81aa938 100644 --- a/sdks/python/tests/test_status.py +++ b/sdks/python/tests/test_status.py @@ -1,5 +1,6 @@ import unittest +import inspect from pmxt import Polymarket, Kalshi class TestStatusParams(unittest.TestCase): @@ -17,5 +18,20 @@ def test_fetch_events_status_signature(self): params = {"query": "Politics", "status": "active"} self.assertEqual(params["status"], "active") + def test_raw_mode_signature(self): + """Verify raw mode is exposed on key market-data methods.""" + methods = [ + Polymarket.fetch_markets, + Polymarket.fetch_events, + Polymarket.fetch_ohlcv, + Polymarket.fetch_order_book, + Polymarket.fetch_trades, + Kalshi.watch_order_book, + Kalshi.watch_trades, + ] + for method in methods: + signature = inspect.signature(method) + self.assertIn("mode", signature.parameters) + if __name__ == '__main__': unittest.main() diff --git a/sdks/typescript/API_REFERENCE.md b/sdks/typescript/API_REFERENCE.md index 511c590..088dc56 100644 --- a/sdks/typescript/API_REFERENCE.md +++ b/sdks/typescript/API_REFERENCE.md @@ -28,6 +28,17 @@ console.log(markets[0].title); --- +## Raw Mode + +Prices are normalized to `0.0-1.0` by default. To return raw exchange values, pass `{ mode: "raw" }` as the final argument to fetch methods. + +```typescript +const markets = await poly.fetchMarkets({ query: "Trump" }, { mode: "raw" }); +const orderBook = await kalshi.fetchOrderBook("FED-25JAN", { mode: "raw" }); +``` + +--- + ## Server Management The SDK provides global functions to manage the background sidecar server. This is useful for clearing state or diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index bebcabd..79262b0 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -136,7 +136,7 @@ For complete API documentation and examples, see: ## Important Notes - **Use `outcome.outcomeId`, not `market.marketId`** for deep-dive methods (fetchOHLCV, fetchOrderBook, fetchTrades) -- **Prices are 0.0 to 1.0** (multiply by 100 for percentages) +- **Prices are 0.0 to 1.0 by default** (multiply by 100 for percentages). Use `{ mode: 'raw' }` as the final argument to return raw exchange values. - **Timestamps are Unix milliseconds** - **Volumes are in USD** diff --git a/sdks/typescript/pmxt/args.ts b/sdks/typescript/pmxt/args.ts new file mode 100644 index 0000000..837d64d --- /dev/null +++ b/sdks/typescript/pmxt/args.ts @@ -0,0 +1,29 @@ +import { RequestOptions } from "./models.js"; + +export function buildArgsWithOptionalOptions( + primary?: any, + options?: RequestOptions, +): any[] { + if (options !== undefined) { + return [primary ?? null, options]; + } + return primary !== undefined ? [primary] : []; +} + +export function withTrailingOptions( + args: any[], + options: RequestOptions | undefined, + optionalArgCount: number, +): any[] { + if (options === undefined) { + return args; + } + + while (args.length < optionalArgCount) { + args.push(null); + } + + args.push(options); + return args; +} + diff --git a/sdks/typescript/pmxt/client.ts b/sdks/typescript/pmxt/client.ts index df892f6..ae553f0 100644 --- a/sdks/typescript/pmxt/client.ts +++ b/sdks/typescript/pmxt/client.ts @@ -34,9 +34,11 @@ import { MarketFilterFunction, EventFilterCriteria, EventFilterFunction, + RequestOptions, } from "./models.js"; import { ServerManager } from "./server-manager.js"; +import { buildArgsWithOptionalOptions, withTrailingOptions } from "./args.js"; // Converter functions function convertMarket(raw: any): UnifiedMarket { @@ -379,11 +381,10 @@ export abstract class Exchange { } } - async fetchMarkets(params?: any): Promise { + async fetchMarkets(params?: any, options?: RequestOptions): Promise { await this.initPromise; try { - const args: any[] = []; - if (params !== undefined) args.push(params); + const args = buildArgsWithOptionalOptions(params, options); const response = await fetch(`${this.config.basePath}/api/${this.exchangeName}/fetchMarkets`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers }, @@ -401,11 +402,10 @@ export abstract class Exchange { } } - async fetchMarketsPaginated(params?: any): Promise { + async fetchMarketsPaginated(params?: any, options?: RequestOptions): Promise { await this.initPromise; try { - const args: any[] = []; - if (params !== undefined) args.push(params); + const args = buildArgsWithOptionalOptions(params, options); const response = await fetch(`${this.config.basePath}/api/${this.exchangeName}/fetchMarketsPaginated`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers }, @@ -427,11 +427,10 @@ export abstract class Exchange { } } - async fetchEvents(params?: any): Promise { + async fetchEvents(params?: any, options?: RequestOptions): Promise { await this.initPromise; try { - const args: any[] = []; - if (params !== undefined) args.push(params); + const args = buildArgsWithOptionalOptions(params, options); const response = await fetch(`${this.config.basePath}/api/${this.exchangeName}/fetchEvents`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers }, @@ -449,11 +448,10 @@ export abstract class Exchange { } } - async fetchMarket(params?: any): Promise { + async fetchMarket(params?: any, options?: RequestOptions): Promise { await this.initPromise; try { - const args: any[] = []; - if (params !== undefined) args.push(params); + const args = buildArgsWithOptionalOptions(params, options); const response = await fetch(`${this.config.basePath}/api/${this.exchangeName}/fetchMarket`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers }, @@ -471,11 +469,10 @@ export abstract class Exchange { } } - async fetchEvent(params?: any): Promise { + async fetchEvent(params?: any, options?: RequestOptions): Promise { await this.initPromise; try { - const args: any[] = []; - if (params !== undefined) args.push(params); + const args = buildArgsWithOptionalOptions(params, options); const response = await fetch(`${this.config.basePath}/api/${this.exchangeName}/fetchEvent`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers }, @@ -493,11 +490,10 @@ export abstract class Exchange { } } - async fetchOrderBook(id: string): Promise { + async fetchOrderBook(id: string, options?: RequestOptions): Promise { await this.initPromise; try { - const args: any[] = []; - args.push(id); + const args = withTrailingOptions([id], options, 2); const response = await fetch(`${this.config.basePath}/api/${this.exchangeName}/fetchOrderBook`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers }, @@ -537,11 +533,10 @@ export abstract class Exchange { } } - async fetchOrder(orderId: string): Promise { + async fetchOrder(orderId: string, options?: RequestOptions): Promise { await this.initPromise; try { - const args: any[] = []; - args.push(orderId); + const args = withTrailingOptions([orderId], options, 2); const response = await fetch(`${this.config.basePath}/api/${this.exchangeName}/fetchOrder`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers }, @@ -559,11 +554,10 @@ export abstract class Exchange { } } - async fetchOpenOrders(marketId?: string): Promise { + async fetchOpenOrders(marketId?: string, options?: RequestOptions): Promise { await this.initPromise; try { - const args: any[] = []; - if (marketId !== undefined) args.push(marketId); + const args = buildArgsWithOptionalOptions(marketId, options); const response = await fetch(`${this.config.basePath}/api/${this.exchangeName}/fetchOpenOrders`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers }, @@ -581,11 +575,10 @@ export abstract class Exchange { } } - async fetchMyTrades(params?: any): Promise { + async fetchMyTrades(params?: any, options?: RequestOptions): Promise { await this.initPromise; try { - const args: any[] = []; - if (params !== undefined) args.push(params); + const args = buildArgsWithOptionalOptions(params, options); const response = await fetch(`${this.config.basePath}/api/${this.exchangeName}/fetchMyTrades`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers }, @@ -603,11 +596,10 @@ export abstract class Exchange { } } - async fetchClosedOrders(params?: any): Promise { + async fetchClosedOrders(params?: any, options?: RequestOptions): Promise { await this.initPromise; try { - const args: any[] = []; - if (params !== undefined) args.push(params); + const args = buildArgsWithOptionalOptions(params, options); const response = await fetch(`${this.config.basePath}/api/${this.exchangeName}/fetchClosedOrders`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers }, @@ -625,11 +617,10 @@ export abstract class Exchange { } } - async fetchAllOrders(params?: any): Promise { + async fetchAllOrders(params?: any, options?: RequestOptions): Promise { await this.initPromise; try { - const args: any[] = []; - if (params !== undefined) args.push(params); + const args = buildArgsWithOptionalOptions(params, options); const response = await fetch(`${this.config.basePath}/api/${this.exchangeName}/fetchAllOrders`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers }, @@ -647,10 +638,10 @@ export abstract class Exchange { } } - async fetchPositions(): Promise { + async fetchPositions(options?: RequestOptions): Promise { await this.initPromise; try { - const args: any[] = []; + const args = options !== undefined ? [options] : []; const response = await fetch(`${this.config.basePath}/api/${this.exchangeName}/fetchPositions`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers }, @@ -668,10 +659,10 @@ export abstract class Exchange { } } - async fetchBalance(): Promise { + async fetchBalance(options?: RequestOptions): Promise { await this.initPromise; try { - const args: any[] = []; + const args = options !== undefined ? [options] : []; const response = await fetch(`${this.config.basePath}/api/${this.exchangeName}/fetchBalance`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.config.headers }, @@ -730,7 +721,8 @@ export abstract class Exchange { */ async fetchOHLCV( outcomeId: string, - params: any + params: any, + options?: RequestOptions ): Promise { await this.initPromise; try { @@ -746,7 +738,7 @@ export abstract class Exchange { } const requestBody: FetchOHLCVRequest = { - args: [outcomeId, paramsDict], + args: withTrailingOptions([outcomeId, paramsDict], options, 3), credentials: this.getCredentials() }; @@ -773,17 +765,21 @@ export abstract class Exchange { */ async fetchTrades( outcomeId: string, - params: any + params: any, + options?: RequestOptions ): Promise { await this.initPromise; try { - const paramsDict: any = { resolution: params.resolution }; - if (params.limit) { + const paramsDict: any = {}; + if (params?.resolution !== undefined) { + paramsDict.resolution = params.resolution; + } + if (params?.limit !== undefined) { paramsDict.limit = params.limit; } const requestBody: FetchTradesRequest = { - args: [outcomeId, paramsDict], + args: withTrailingOptions([outcomeId, paramsDict], options, 3), credentials: this.getCredentials() }; @@ -821,13 +817,18 @@ export abstract class Exchange { * } * ``` */ - async watchOrderBook(outcomeId: string, limit?: number): Promise { + async watchOrderBook( + outcomeId: string, + limit?: number, + options?: RequestOptions + ): Promise { await this.initPromise; try { const args: any[] = [outcomeId]; if (limit !== undefined) { args.push(limit); } + withTrailingOptions(args, options, 2); const requestBody: any = { args, @@ -871,7 +872,8 @@ export abstract class Exchange { async watchTrades( outcomeId: string, since?: number, - limit?: number + limit?: number, + options?: RequestOptions ): Promise { await this.initPromise; try { @@ -882,6 +884,7 @@ export abstract class Exchange { if (limit !== undefined) { args.push(limit); } + withTrailingOptions(args, options, 3); const requestBody: any = { args, diff --git a/sdks/typescript/pmxt/models.ts b/sdks/typescript/pmxt/models.ts index 95db590..4dce320 100644 --- a/sdks/typescript/pmxt/models.ts +++ b/sdks/typescript/pmxt/models.ts @@ -1,9 +1,16 @@ /** * Data models for PMXT TypeScript SDK. - * + * * These are clean TypeScript interfaces that provide a user-friendly API. */ + /** + * Request options for API calls. + */ +export interface RequestOptions { + mode?: 'raw'; +} + /** * A single tradeable outcome within a market. */ diff --git a/sdks/typescript/tests/client-args.test.ts b/sdks/typescript/tests/client-args.test.ts new file mode 100644 index 0000000..8b8dff0 --- /dev/null +++ b/sdks/typescript/tests/client-args.test.ts @@ -0,0 +1,40 @@ +import { describe, test, expect } from "@jest/globals"; +import { + buildArgsWithOptionalOptions, + withTrailingOptions, +} from "../pmxt/args"; + +describe("client args helpers", () => { + test("buildArgsWithOptionalOptions handles optional options and primary", () => { + expect(buildArgsWithOptionalOptions(undefined, undefined)).toEqual([]); + expect(buildArgsWithOptionalOptions({ q: 1 }, undefined)).toEqual([{ q: 1 }]); + expect(buildArgsWithOptionalOptions(undefined, { mode: "raw" })).toEqual([ + null, + { mode: "raw" }, + ]); + expect(buildArgsWithOptionalOptions({ q: 1 }, { mode: "raw" })).toEqual([ + { q: 1 }, + { mode: "raw" }, + ]); + }); + + test("withTrailingOptions pads missing optional args before options", () => { + expect(withTrailingOptions(["id"], undefined, 2)).toEqual(["id"]); + expect(withTrailingOptions(["id"], { mode: "raw" }, 2)).toEqual([ + "id", + null, + { mode: "raw" }, + ]); + expect(withTrailingOptions(["id", 10], { mode: "raw" }, 2)).toEqual([ + "id", + 10, + { mode: "raw" }, + ]); + expect(withTrailingOptions(["id", 100], { mode: "raw" }, 3)).toEqual([ + "id", + 100, + null, + { mode: "raw" }, + ]); + }); +});