Skip to content

Commit 6b8daba

Browse files
committed
fix(core): avoid float drift in venue decimal math
1 parent 27b93c5 commit 6b8daba

9 files changed

Lines changed: 276 additions & 30 deletions

File tree

core/src/exchanges/gemini-titan/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
BuiltOrder,
1818
} from '../../types';
1919
import { AuthenticationError } from '../../errors';
20+
import { toFixedDecimal } from '../../utils/decimal-math';
2021
import { getGeminiConfig, GeminiApiConfig } from './config';
2122
import { GeminiFetcher } from './fetcher';
2223
import { GeminiNormalizer } from './normalizer';
@@ -230,7 +231,7 @@ export class GeminiTitanExchange extends PredictionMarketExchange {
230231
orderType: 'limit',
231232
side: params.side,
232233
quantity: String(params.amount),
233-
price: params.price !== undefined ? params.price.toFixed(2) : '0.50',
234+
price: params.price !== undefined ? toFixedDecimal(params.price, 2) : '0.50',
234235
outcome: side,
235236
timeInForce: params.type === 'market' ? 'immediate-or-cancel' : 'good-til-cancel',
236237
};

core/src/exchanges/gemini-titan/normalizer.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { IExchangeNormalizer } from '../interfaces';
1111
import { addBinaryOutcomes } from '../../utils/market-utils';
1212
import { buildSourceMetadata } from '../../utils/metadata';
13+
import { averageDecimals, complementDecimal, multiplyDecimals, roundDecimalPlaces, subtractDecimals } from '../../utils/decimal-math';
1314
import { toMarketId, toOutcomeId } from './utils';
1415
import { TICK_SIZE } from './config';
1516
import {
@@ -106,11 +107,8 @@ function extractDescription(desc: unknown): string {
106107
return '';
107108
}
108109

109-
/**
110-
* Round to 2 decimal places to avoid floating point noise.
111-
*/
112110
function roundPrice(n: number): number {
113-
return Math.round(n * 100) / 100;
111+
return roundDecimalPlaces(n, 2);
114112
}
115113

116114
// ----------------------------------------------------------------------------
@@ -211,18 +209,20 @@ export class GeminiNormalizer implements IExchangeNormalizer<GeminiRawEvent, Gem
211209
const marketId = toMarketId(instrumentSymbol);
212210

213211
// Extract prices
214-
const bestBid = contract.prices?.bestBid ? parseFloat(contract.prices.bestBid) : 0.5;
215-
const bestAsk = contract.prices?.bestAsk ? parseFloat(contract.prices.bestAsk) : 0.5;
212+
const bestBidRaw = contract.prices?.bestBid ?? '0.5';
213+
const bestAskRaw = contract.prices?.bestAsk ?? '0.5';
214+
const bestBid = parseFloat(bestBidRaw);
215+
const bestAsk = parseFloat(bestAskRaw);
216216
const buyYes = contract.prices?.buy?.yes ? parseFloat(contract.prices.buy.yes) : undefined;
217217
const sellYes = contract.prices?.sell?.yes ? parseFloat(contract.prices.sell.yes) : undefined;
218218
const buyNo = contract.prices?.buy?.no ? parseFloat(contract.prices.buy.no) : undefined;
219219
const sellNo = contract.prices?.sell?.no ? parseFloat(contract.prices.sell.no) : undefined;
220220
const lastPrice = contract.prices?.lastTradePrice
221221
? parseFloat(contract.prices.lastTradePrice)
222-
: (bestBid + bestAsk) / 2;
222+
: averageDecimals(bestBidRaw, bestAskRaw);
223223

224224
const yesPriceSource = buyYes ?? sellYes ?? lastPrice;
225-
const noPriceSource = buyNo ?? sellNo ?? (1 - yesPriceSource);
225+
const noPriceSource = buyNo ?? sellNo ?? complementDecimal(yesPriceSource);
226226

227227
const yesPrice = roundPrice(Math.max(0, Math.min(1, yesPriceSource)));
228228
const noPrice = roundPrice(Math.max(0, Math.min(1, noPriceSource)));
@@ -325,6 +325,7 @@ export class GeminiNormalizer implements IExchangeNormalizer<GeminiRawEvent, Gem
325325
: 0;
326326
const entryPrice = parseFloat(raw.avgPrice);
327327
const size = parseFloat(raw.totalQuantity);
328+
const priceDelta = subtractDecimals(raw.prices?.bestBid ?? '0', raw.avgPrice);
328329

329330
return {
330331
marketId: toMarketId(raw.symbol),
@@ -333,7 +334,7 @@ export class GeminiNormalizer implements IExchangeNormalizer<GeminiRawEvent, Gem
333334
size,
334335
entryPrice,
335336
currentPrice,
336-
unrealizedPnL: (currentPrice - entryPrice) * size,
337+
unrealizedPnL: multiplyDecimals(priceDelta, raw.totalQuantity),
337338
};
338339
}
339340
}

core/src/exchanges/kalshi/normalizer.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { UnifiedMarket, UnifiedEvent, UnifiedSeries, PriceCandle, OrderBook, Tra
33
import { IExchangeNormalizer } from '../interfaces';
44
import { addBinaryOutcomes } from '../../utils/market-utils';
55
import { buildSourceMetadata } from '../../utils/metadata';
6+
import { averageDecimals, complementDecimal, subtractDecimals } from '../../utils/decimal-math';
67
import { fromKalshiCents, invertKalshiUnified } from './price';
78
import { KalshiRawEvent, KalshiRawMarket, KalshiRawCandlestick, KalshiRawTrade, KalshiRawFill, KalshiRawOrder, KalshiRawPosition, KalshiRawOrderBookFp, KalshiRawSeries } from './fetcher';
89

@@ -51,7 +52,7 @@ export class KalshiNormalizer implements IExchangeNormalizer<KalshiRawEvent, Kal
5152
if (market.last_price_dollars != null) {
5253
price = parseFloat(market.last_price_dollars);
5354
} else if (market.yes_ask_dollars != null && market.yes_bid_dollars != null) {
54-
price = (parseFloat(market.yes_ask_dollars) + parseFloat(market.yes_bid_dollars)) / 2;
55+
price = averageDecimals(market.yes_ask_dollars, market.yes_bid_dollars);
5556
} else if (market.yes_ask_dollars != null) {
5657
price = parseFloat(market.yes_ask_dollars);
5758
} else if (market.last_price) {
@@ -66,7 +67,7 @@ export class KalshiNormalizer implements IExchangeNormalizer<KalshiRawEvent, Kal
6667

6768
let priceChange = 0;
6869
if (market.previous_price_dollars != null && market.last_price_dollars != null) {
69-
priceChange = parseFloat(market.last_price_dollars) - parseFloat(market.previous_price_dollars);
70+
priceChange = subtractDecimals(market.last_price_dollars, market.previous_price_dollars);
7071
}
7172

7273
const outcomes: MarketOutcome[] = [
@@ -222,7 +223,7 @@ export class KalshiNormalizer implements IExchangeNormalizer<KalshiRawEvent, Kal
222223
size: parseFloat(level[1]),
223224
}));
224225
asks = (data.yes_dollars || []).map((level) => ({
225-
price: Math.round((1 - parseFloat(level[0])) * 10000) / 10000,
226+
price: complementDecimal(level[0], 4),
226227
size: parseFloat(level[1]),
227228
}));
228229
} else {
@@ -231,7 +232,7 @@ export class KalshiNormalizer implements IExchangeNormalizer<KalshiRawEvent, Kal
231232
size: parseFloat(level[1]),
232233
}));
233234
asks = (data.no_dollars || []).map((level) => ({
234-
price: Math.round((1 - parseFloat(level[0])) * 10000) / 10000,
235+
price: complementDecimal(level[0], 4),
235236
size: parseFloat(level[1]),
236237
}));
237238
}

core/src/exchanges/opinion/normalizer.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { IExchangeNormalizer } from '../interfaces';
1515
import { addBinaryOutcomes } from '../../utils/market-utils';
1616
import { buildSourceMetadata } from '../../utils/metadata';
17+
import { divideDecimals, proportionalDecimal, subtractDecimals } from '../../utils/decimal-math';
1718
import { parseNumStr, mapOrderStatus, toMillis, intervalToMs } from './utils';
1819
import {
1920
OpinionRawMarket,
@@ -83,9 +84,8 @@ export class OpinionNormalizer implements IExchangeNormalizer<OpinionRawMarket,
8384
const totalChildVolume = children.reduce((sum, c) => sum + parseNumStr(c.volume), 0);
8485

8586
for (const child of children) {
86-
const childVolume = parseNumStr(child.volume);
8787
const childVolume24h = totalChildVolume > 0
88-
? (childVolume / totalChildVolume) * parentVolume24h
88+
? proportionalDecimal(child.volume || '0', totalChildVolume, parentVolume24h, 6)
8989
: 0;
9090
const market = this.normalizeChildMarket(child, raw, childVolume24h);
9191
if (market) results.push(market);
@@ -243,7 +243,9 @@ export class OpinionNormalizer implements IExchangeNormalizer<OpinionRawMarket,
243243
normalizePosition(raw: OpinionRawPosition): Position {
244244
const sharesOwned = parseNumStr(raw.sharesOwned);
245245
const currentValue = parseNumStr(raw.currentValueInQuoteToken);
246-
const currentPrice = sharesOwned > 0 ? currentValue / sharesOwned : 0;
246+
const currentPrice = sharesOwned > 0
247+
? divideDecimals(raw.currentValueInQuoteToken || '0', raw.sharesOwned || '0')
248+
: 0;
247249

248250
return {
249251
marketId: String(raw.marketId),
@@ -272,7 +274,7 @@ export class OpinionNormalizer implements IExchangeNormalizer<OpinionRawMarket,
272274
amount: orderShares,
273275
status: mapOrderStatus(raw.status),
274276
filled: filledShares,
275-
remaining: orderShares - filledShares,
277+
remaining: subtractDecimals(raw.orderShares || '0', raw.filledShares || '0'),
276278
timestamp: toMillis(raw.createdAt) ?? 0,
277279
};
278280
}

core/src/exchanges/polymarket_us/price.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { OrderIntent, Amount } from 'polymarket-us';
2+
import { complementDecimal, roundToTickDecimal } from '../../utils/decimal-math';
23

34
/**
45
* Polymarket US price/quantity conversion utilities.
@@ -37,10 +38,8 @@ const LONG_INTENTS: ReadonlySet<OrderIntent> = new Set<OrderIntent>([
3738
]);
3839

3940
/**
40-
* Round a price to a tick size using Math.round. Defaults to the
41+
* Round a price to a tick size using decimal string arithmetic. Defaults to the
4142
* Polymarket US tick size (0.001) but accepts a per-market override.
42-
* Re-rounds to `POLYMARKET_US_PRICE_DECIMALS` afterwards to avoid
43-
* floating-point drift.
4443
*
4544
* Note: this is a pure rounding helper - it does NOT validate bounds.
4645
* Out-of-range inputs (e.g. 0.9999 -> 1.000) will pass through unchanged.
@@ -49,10 +48,7 @@ export function roundToTickSize(
4948
price: number,
5049
tickSize: number = POLYMARKET_US_TICK_SIZE,
5150
): number {
52-
const ticks = Math.round(price / tickSize);
53-
const rounded = ticks * tickSize;
54-
const scale = Math.pow(10, POLYMARKET_US_PRICE_DECIMALS);
55-
return Math.round(rounded * scale) / scale;
51+
return roundToTickDecimal(price, tickSize, POLYMARKET_US_PRICE_DECIMALS);
5652
}
5753

5854
/**
@@ -94,7 +90,7 @@ export function toLongSidePrice(intent: OrderIntent, userPrice: number): number
9490
if (LONG_INTENTS.has(intent)) {
9591
return userPrice;
9692
}
97-
const longPrice = 1 - userPrice;
93+
const longPrice = complementDecimal(userPrice, POLYMARKET_US_PRICE_DECIMALS);
9894
validatePriceBounds(longPrice);
9995
return longPrice;
10096
}
@@ -114,7 +110,7 @@ export function fromLongSidePrice(intent: OrderIntent, longPrice: number): numbe
114110
if (LONG_INTENTS.has(intent)) {
115111
return longPrice;
116112
}
117-
const userPrice = 1 - longPrice;
113+
const userPrice = complementDecimal(longPrice, POLYMARKET_US_PRICE_DECIMALS);
118114
validatePriceBounds(userPrice);
119115
return userPrice;
120116
}

core/src/exchanges/smarkets/normalizer.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { UnifiedMarket, UnifiedEvent, OrderBook, Trade, UserTrade, Position, Bal
22
import { IExchangeNormalizer } from '../interfaces';
33
import { addBinaryOutcomes } from '../../utils/market-utils';
44
import { buildSourceMetadata } from '../../utils/metadata';
5+
import { subtractDecimals } from '../../utils/decimal-math';
56
import { fromBasisPoints, fromQuantityUnits } from './price';
67
import {
78
SmarketsRawEventWithMarkets,
@@ -304,12 +305,13 @@ export class SmarketsNormalizer implements IExchangeNormalizer<SmarketsRawEventW
304305
normalizeBalance(raw: SmarketsRawBalance): Balance[] {
305306
const balance = parseFloat(raw.balance || '0');
306307
const available = parseFloat(raw.available_balance || '0');
308+
const locked = subtractDecimals(raw.balance || '0', raw.available_balance || '0');
307309

308310
return [{
309311
currency: raw.currency || 'GBP',
310312
total: balance,
311313
available,
312-
locked: balance - available,
314+
locked,
313315
}];
314316
}
315317

core/src/exchanges/smarkets/price.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* and 1/10000 GBP units for quantities.
66
*/
77

8+
import { toScaledInteger } from '../../utils/decimal-math';
9+
810
const BASIS_POINTS_SCALE = 10000;
911

1012
/**
@@ -18,7 +20,7 @@ export function fromBasisPoints(basisPoints: number): number {
1820
* Convert probability (0.0-1.0) to Smarkets basis points (0-10000).
1921
*/
2022
export function toBasisPoints(probability: number): number {
21-
return Math.round(probability * BASIS_POINTS_SCALE);
23+
return toScaledInteger(probability, BASIS_POINTS_SCALE);
2224
}
2325

2426
/**
@@ -32,7 +34,7 @@ export function fromQuantityUnits(units: number): number {
3234
* Convert GBP to Smarkets quantity units (1/10000 GBP).
3335
*/
3436
export function toQuantityUnits(gbp: number): number {
35-
return Math.round(gbp * BASIS_POINTS_SCALE);
37+
return toScaledInteger(gbp, BASIS_POINTS_SCALE);
3638
}
3739

3840
/**

0 commit comments

Comments
 (0)