From 6b8daba651108c460a3008a9ca96e6df6014aacb Mon Sep 17 00:00:00 2001 From: Samuel Tinnerholm Date: Mon, 8 Jun 2026 10:57:43 +0000 Subject: [PATCH] fix(core): avoid float drift in venue decimal math --- core/src/exchanges/gemini-titan/index.ts | 3 +- core/src/exchanges/gemini-titan/normalizer.ts | 19 +- core/src/exchanges/kalshi/normalizer.ts | 9 +- core/src/exchanges/opinion/normalizer.ts | 10 +- core/src/exchanges/polymarket_us/price.ts | 14 +- core/src/exchanges/smarkets/normalizer.ts | 4 +- core/src/exchanges/smarkets/price.ts | 6 +- core/src/utils/decimal-math.ts | 208 ++++++++++++++++++ core/test/utils/decimal-math.test.ts | 33 +++ 9 files changed, 276 insertions(+), 30 deletions(-) create mode 100644 core/src/utils/decimal-math.ts create mode 100644 core/test/utils/decimal-math.test.ts diff --git a/core/src/exchanges/gemini-titan/index.ts b/core/src/exchanges/gemini-titan/index.ts index 6d3236e2..34ae5eed 100644 --- a/core/src/exchanges/gemini-titan/index.ts +++ b/core/src/exchanges/gemini-titan/index.ts @@ -17,6 +17,7 @@ import { BuiltOrder, } from '../../types'; import { AuthenticationError } from '../../errors'; +import { toFixedDecimal } from '../../utils/decimal-math'; import { getGeminiConfig, GeminiApiConfig } from './config'; import { GeminiFetcher } from './fetcher'; import { GeminiNormalizer } from './normalizer'; @@ -230,7 +231,7 @@ export class GeminiTitanExchange extends PredictionMarketExchange { orderType: 'limit', side: params.side, quantity: String(params.amount), - price: params.price !== undefined ? params.price.toFixed(2) : '0.50', + price: params.price !== undefined ? toFixedDecimal(params.price, 2) : '0.50', outcome: side, timeInForce: params.type === 'market' ? 'immediate-or-cancel' : 'good-til-cancel', }; diff --git a/core/src/exchanges/gemini-titan/normalizer.ts b/core/src/exchanges/gemini-titan/normalizer.ts index 80cb576a..9ed7da60 100644 --- a/core/src/exchanges/gemini-titan/normalizer.ts +++ b/core/src/exchanges/gemini-titan/normalizer.ts @@ -10,6 +10,7 @@ import { import { IExchangeNormalizer } from '../interfaces'; import { addBinaryOutcomes } from '../../utils/market-utils'; import { buildSourceMetadata } from '../../utils/metadata'; +import { averageDecimals, complementDecimal, multiplyDecimals, roundDecimalPlaces, subtractDecimals } from '../../utils/decimal-math'; import { toMarketId, toOutcomeId } from './utils'; import { TICK_SIZE } from './config'; import { @@ -106,11 +107,8 @@ function extractDescription(desc: unknown): string { return ''; } -/** - * Round to 2 decimal places to avoid floating point noise. - */ function roundPrice(n: number): number { - return Math.round(n * 100) / 100; + return roundDecimalPlaces(n, 2); } // ---------------------------------------------------------------------------- @@ -211,18 +209,20 @@ export class GeminiNormalizer implements IExchangeNormalizer ({ - price: Math.round((1 - parseFloat(level[0])) * 10000) / 10000, + price: complementDecimal(level[0], 4), size: parseFloat(level[1]), })); } else { @@ -231,7 +232,7 @@ export class KalshiNormalizer implements IExchangeNormalizer ({ - price: Math.round((1 - parseFloat(level[0])) * 10000) / 10000, + price: complementDecimal(level[0], 4), size: parseFloat(level[1]), })); } diff --git a/core/src/exchanges/opinion/normalizer.ts b/core/src/exchanges/opinion/normalizer.ts index 7d9ed48f..a3942456 100644 --- a/core/src/exchanges/opinion/normalizer.ts +++ b/core/src/exchanges/opinion/normalizer.ts @@ -14,6 +14,7 @@ import { import { IExchangeNormalizer } from '../interfaces'; import { addBinaryOutcomes } from '../../utils/market-utils'; import { buildSourceMetadata } from '../../utils/metadata'; +import { divideDecimals, proportionalDecimal, subtractDecimals } from '../../utils/decimal-math'; import { parseNumStr, mapOrderStatus, toMillis, intervalToMs } from './utils'; import { OpinionRawMarket, @@ -83,9 +84,8 @@ export class OpinionNormalizer implements IExchangeNormalizer sum + parseNumStr(c.volume), 0); for (const child of children) { - const childVolume = parseNumStr(child.volume); const childVolume24h = totalChildVolume > 0 - ? (childVolume / totalChildVolume) * parentVolume24h + ? proportionalDecimal(child.volume || '0', totalChildVolume, parentVolume24h, 6) : 0; const market = this.normalizeChildMarket(child, raw, childVolume24h); if (market) results.push(market); @@ -243,7 +243,9 @@ export class OpinionNormalizer implements IExchangeNormalizer 0 ? currentValue / sharesOwned : 0; + const currentPrice = sharesOwned > 0 + ? divideDecimals(raw.currentValueInQuoteToken || '0', raw.sharesOwned || '0') + : 0; return { marketId: String(raw.marketId), @@ -272,7 +274,7 @@ export class OpinionNormalizer implements IExchangeNormalizer = new Set([ ]); /** - * Round a price to a tick size using Math.round. Defaults to the + * Round a price to a tick size using decimal string arithmetic. Defaults to the * Polymarket US tick size (0.001) but accepts a per-market override. - * Re-rounds to `POLYMARKET_US_PRICE_DECIMALS` afterwards to avoid - * floating-point drift. * * Note: this is a pure rounding helper - it does NOT validate bounds. * Out-of-range inputs (e.g. 0.9999 -> 1.000) will pass through unchanged. @@ -49,10 +48,7 @@ export function roundToTickSize( price: number, tickSize: number = POLYMARKET_US_TICK_SIZE, ): number { - const ticks = Math.round(price / tickSize); - const rounded = ticks * tickSize; - const scale = Math.pow(10, POLYMARKET_US_PRICE_DECIMALS); - return Math.round(rounded * scale) / scale; + return roundToTickDecimal(price, tickSize, POLYMARKET_US_PRICE_DECIMALS); } /** @@ -94,7 +90,7 @@ export function toLongSidePrice(intent: OrderIntent, userPrice: number): number if (LONG_INTENTS.has(intent)) { return userPrice; } - const longPrice = 1 - userPrice; + const longPrice = complementDecimal(userPrice, POLYMARKET_US_PRICE_DECIMALS); validatePriceBounds(longPrice); return longPrice; } @@ -114,7 +110,7 @@ export function fromLongSidePrice(intent: OrderIntent, longPrice: number): numbe if (LONG_INTENTS.has(intent)) { return longPrice; } - const userPrice = 1 - longPrice; + const userPrice = complementDecimal(longPrice, POLYMARKET_US_PRICE_DECIMALS); validatePriceBounds(userPrice); return userPrice; } diff --git a/core/src/exchanges/smarkets/normalizer.ts b/core/src/exchanges/smarkets/normalizer.ts index 3ea6333a..8eedce67 100644 --- a/core/src/exchanges/smarkets/normalizer.ts +++ b/core/src/exchanges/smarkets/normalizer.ts @@ -2,6 +2,7 @@ import { UnifiedMarket, UnifiedEvent, OrderBook, Trade, UserTrade, Position, Bal import { IExchangeNormalizer } from '../interfaces'; import { addBinaryOutcomes } from '../../utils/market-utils'; import { buildSourceMetadata } from '../../utils/metadata'; +import { subtractDecimals } from '../../utils/decimal-math'; import { fromBasisPoints, fromQuantityUnits } from './price'; import { SmarketsRawEventWithMarkets, @@ -304,12 +305,13 @@ export class SmarketsNormalizer implements IExchangeNormalizer= digits.length) { + return `${sign}${digits}${'0'.repeat(decimalIndex - digits.length)}`; + } + return `${sign}${digits.slice(0, decimalIndex)}.${digits.slice(decimalIndex)}`; +} + +type DecimalParts = { + numerator: bigint; + scale: bigint; +}; + +function parseDecimal(value: number | string): DecimalParts { + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new RangeError(`Expected a finite decimal value, got ${value}`); + } + + let text = expandExponential(String(value).trim()); + if (!text) return { numerator: 0n, scale: 1n }; + + let sign = 1n; + if (text.startsWith('-')) { + sign = -1n; + text = text.slice(1); + } else if (text.startsWith('+')) { + text = text.slice(1); + } + + const [integerPart = '0', fractionPart = ''] = text.split('.'); + const digits = `${integerPart || '0'}${fractionPart}`.replace(/^0+(?=\d)/, '') || '0'; + return { + numerator: sign * BigInt(digits), + scale: 10n ** BigInt(fractionPart.length), + }; +} + +function pow10(places: number): bigint { + if (!Number.isInteger(places) || places < 0) { + throw new RangeError(`Decimal places must be a non-negative integer, got ${places}`); + } + return 10n ** BigInt(places); +} + +function divRoundHalfUp(numerator: bigint, denominator: bigint): bigint { + if (denominator === 0n) { + throw new RangeError('Cannot divide by zero'); + } + + let sign = 1n; + let n = numerator; + let d = denominator; + if (n < 0n) { + sign *= -1n; + n = -n; + } + if (d < 0n) { + sign *= -1n; + d = -d; + } + + const quotient = n / d; + const remainder = n % d; + const rounded = remainder * 2n >= d ? quotient + 1n : quotient; + return sign * rounded; +} + +function formatScaled(integer: bigint, scale: bigint): string { + const negative = integer < 0n; + let digits = (negative ? -integer : integer).toString(); + const places = scale.toString().length - 1; + + if (places === 0) return `${negative ? '-' : ''}${digits}`; + if (digits.length <= places) { + digits = `${'0'.repeat(places - digits.length + 1)}${digits}`; + } + + const whole = digits.slice(0, -places) || '0'; + const fraction = digits.slice(-places).replace(/0+$/, ''); + return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`; +} + +export function toFixedDecimal(value: number | string, places: number): string { + const { numerator, scale } = parseDecimal(value); + const targetScale = pow10(places); + const rounded = divRoundHalfUp(numerator * targetScale, scale); + const negative = rounded < 0n; + let digits = (negative ? -rounded : rounded).toString(); + + if (places === 0) return `${negative ? '-' : ''}${digits}`; + if (digits.length <= places) { + digits = `${'0'.repeat(places - digits.length + 1)}${digits}`; + } + + return `${negative ? '-' : ''}${digits.slice(0, -places) || '0'}.${digits.slice(-places)}`; +} + +export function roundDecimalPlaces(value: number | string, places: number): number { + return Number(toFixedDecimal(value, places)); +} + +export function toScaledInteger(value: number | string, scale: number): number { + if (!Number.isSafeInteger(scale) || scale <= 0) { + throw new RangeError(`Scale must be a positive safe integer, got ${scale}`); + } + const { numerator, scale: decimalScale } = parseDecimal(value); + return Number(divRoundHalfUp(numerator * BigInt(scale), decimalScale)); +} + +export function subtractDecimals( + left: number | string, + right: number | string, + places?: number, +): number { + const a = parseDecimal(left); + const b = parseDecimal(right); + const numerator = a.numerator * b.scale - b.numerator * a.scale; + const denominator = a.scale * b.scale; + + if (places === undefined) { + return Number(formatScaled(numerator, denominator)); + } + return Number(toFixedDecimal(formatScaled(numerator, denominator), places)); +} + +export function averageDecimals( + left: number | string, + right: number | string, + places: number = 12, +): number { + const a = parseDecimal(left); + const b = parseDecimal(right); + const targetScale = pow10(places); + const numerator = (a.numerator * b.scale + b.numerator * a.scale) * targetScale; + const denominator = 2n * a.scale * b.scale; + return Number(formatScaled(divRoundHalfUp(numerator, denominator), targetScale)); +} + +export function multiplyDecimals( + left: number | string, + right: number | string, + places: number = 12, +): number { + const a = parseDecimal(left); + const b = parseDecimal(right); + const targetScale = pow10(places); + const numerator = a.numerator * b.numerator * targetScale; + const denominator = a.scale * b.scale; + return Number(formatScaled(divRoundHalfUp(numerator, denominator), targetScale)); +} + +export function divideDecimals( + numeratorValue: number | string, + denominatorValue: number | string, + places: number = 12, +): number { + const numerator = parseDecimal(numeratorValue); + const denominator = parseDecimal(denominatorValue); + const targetScale = pow10(places); + const scaledNumerator = numerator.numerator * denominator.scale * targetScale; + const scaledDenominator = numerator.scale * denominator.numerator; + return Number(formatScaled(divRoundHalfUp(scaledNumerator, scaledDenominator), targetScale)); +} + +export function proportionalDecimal( + part: number | string, + total: number | string, + amount: number | string, + places: number = 6, +): number { + const p = parseDecimal(part); + const t = parseDecimal(total); + const a = parseDecimal(amount); + const targetScale = pow10(places); + const numerator = p.numerator * a.numerator * t.scale * targetScale; + const denominator = p.scale * a.scale * t.numerator; + return Number(formatScaled(divRoundHalfUp(numerator, denominator), targetScale)); +} + +export function complementDecimal(value: number | string, places: number = 12): number { + return subtractDecimals('1', value, places); +} + +export function roundToTickDecimal( + value: number | string, + tickSize: number | string, + places: number, +): number { + const v = parseDecimal(value); + const tick = parseDecimal(tickSize); + const ticks = divRoundHalfUp(v.numerator * tick.scale, v.scale * tick.numerator); + const targetScale = pow10(places); + const rounded = divRoundHalfUp(ticks * tick.numerator * targetScale, tick.scale); + return Number(formatScaled(rounded, targetScale)); +} diff --git a/core/test/utils/decimal-math.test.ts b/core/test/utils/decimal-math.test.ts new file mode 100644 index 00000000..b626e659 --- /dev/null +++ b/core/test/utils/decimal-math.test.ts @@ -0,0 +1,33 @@ +import { + averageDecimals, + complementDecimal, + divideDecimals, + multiplyDecimals, + proportionalDecimal, + roundDecimalPlaces, + roundToTickDecimal, + subtractDecimals, + toFixedDecimal, + toScaledInteger, +} from '../../src/utils/decimal-math'; + +describe('decimal-math utilities', () => { + it('rounds decimal strings without binary floating point drift', () => { + expect(toFixedDecimal(0.575, 2)).toBe('0.58'); + expect(roundDecimalPlaces(0.575, 2)).toBe(0.58); + expect(toScaledInteger(0.10015, 10000)).toBe(1002); + }); + + it('computes complements, averages, differences, and products via decimal arithmetic', () => { + expect(complementDecimal('0.425', 4)).toBe(0.575); + expect(averageDecimals('0.43', '0.45')).toBe(0.44); + expect(subtractDecimals('99.99', '90.01')).toBe(9.98); + expect(multiplyDecimals(subtractDecimals('0.56', '0.55'), '100.0')).toBe(1); + }); + + it('rounds to tick sizes and ratios without double Math.round drift', () => { + expect(roundToTickDecimal(0.5501, 0.001, 3)).toBe(0.55); + expect(divideDecimals('6.18', '10.3')).toBe(0.6); + expect(proportionalDecimal('350', '1000', '1000', 6)).toBe(350); + }); +});