From 1ddf644235e1f99165d921436c054e177c62068f Mon Sep 17 00:00:00 2001 From: dev-belly Date: Sun, 4 Oct 2026 11:00:40 +0200 Subject: [PATCH] fix: preserve exact quote and notional conversions --- docs/adr/001-integer-price-ticks.md | 7 +++++ src/tradeforge/domain/fills.py | 3 ++- src/tradeforge/domain/instrument.py | 41 ++++++++++++++++++++++------- tests/unit/test_domain.py | 36 ++++++++++++++++++++++++- 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/docs/adr/001-integer-price-ticks.md b/docs/adr/001-integer-price-ticks.md index 7bce582..81794cb 100644 --- a/docs/adr/001-integer-price-ticks.md +++ b/docs/adr/001-integer-price-ticks.md @@ -15,6 +15,13 @@ duplicate levels for values that are economically identical. * All prices inside `domain`, `orderbook`, `matching`, `replay`, `queue` are `int64` **ticks**. * `InstrumentSpec.tick_size` (a `Decimal`, not a float) converts to/from price. +* Human prices round half-up using the exact integer ratio of the declared + decimal price and tick size. Caller Decimal precision, rounding, exponent + limits and arithmetic traps cannot change that conversion. Non-finite prices + and tick sizes are rejected. +* `InstrumentSpec` price/notional conversions and `Fill.notional` construct the + decimal coefficient directly. They preserve the tick size's decimal quantum + without intermediate rounding, including in a low-precision caller context. * Conversion to float happens **only** at reporting / ML feature boundaries. * Tick count is bounded by `price_ticks * tick_size` and validated against `InstrumentSpec.price_bounds`. diff --git a/src/tradeforge/domain/fills.py b/src/tradeforge/domain/fills.py index ccd4e73..8d263ba 100644 --- a/src/tradeforge/domain/fills.py +++ b/src/tradeforge/domain/fills.py @@ -11,6 +11,7 @@ from decimal import Decimal from .enums import LiquidityFlag, OrderStatus, ReportType, Side +from .instrument import _decimal_times_int @dataclass(frozen=True, slots=True) @@ -40,7 +41,7 @@ def notional(self, tick_size: Decimal) -> Decimal: survived: dead code that fails is worse than dead code that does not, because it looks usable. """ - return Decimal(self.price_ticks) * tick_size * Decimal(self.quantity_base) + return _decimal_times_int(tick_size, self.price_ticks * self.quantity_base) @dataclass(frozen=True, slots=True) diff --git a/src/tradeforge/domain/instrument.py b/src/tradeforge/domain/instrument.py index 58f2ebe..2df8f24 100644 --- a/src/tradeforge/domain/instrument.py +++ b/src/tradeforge/domain/instrument.py @@ -8,11 +8,24 @@ from __future__ import annotations from dataclasses import dataclass -from decimal import ROUND_HALF_UP, Decimal +from decimal import Decimal, InvalidOperation from .exceptions import InstrumentError +def _decimal_times_int(value: Decimal, multiplier: int) -> Decimal: + """Multiply a finite decimal coefficient without using arithmetic context.""" + if not value.is_finite(): + raise InstrumentError("decimal amount must be finite") + parts = value.as_tuple() + coefficient = 0 + for digit in parts.digits: + coefficient = coefficient * 10 + digit + digits = Decimal(coefficient * abs(multiplier)).as_tuple().digits + sign = int(value.is_signed() != (multiplier < 0)) + return Decimal((sign, digits, int(parts.exponent))) + + @dataclass(frozen=True) class InstrumentSpec: """Static instrument definition. Never hard-code these values in logic.""" @@ -25,8 +38,8 @@ class InstrumentSpec: price_band_upper_ticks: int def __post_init__(self) -> None: - if self.tick_size <= 0: - raise InstrumentError(f"tick_size must be positive, got {self.tick_size}") + if not self.tick_size.is_finite() or self.tick_size <= 0: + raise InstrumentError(f"tick_size must be finite and positive, got {self.tick_size}") if self.lot_size <= 0: raise InstrumentError(f"lot_size must be positive, got {self.lot_size}") if self.price_band_lower_ticks >= self.price_band_upper_ticks: @@ -36,20 +49,30 @@ def __post_init__(self) -> None: def price_to_ticks(self, price: float | str | Decimal) -> int: """Convert a human price to integer ticks, rounded half-up.""" - dec = price if isinstance(price, Decimal) else Decimal(str(price)) - ticks = (dec / self.tick_size).quantize(Decimal("1"), rounding=ROUND_HALF_UP) - return int(ticks) + try: + dec = price if isinstance(price, Decimal) else Decimal(str(price)) + except InvalidOperation as error: + raise InstrumentError(f"invalid price: {price}") from error + if not dec.is_finite(): + raise InstrumentError("price must be finite") + numerator, denominator = dec.as_integer_ratio() + tick_numerator, tick_denominator = self.tick_size.as_integer_ratio() + numerator *= tick_denominator + denominator *= tick_numerator + whole, remainder = divmod(abs(numerator), denominator) + rounded = whole + int(2 * remainder >= denominator) + return -rounded if numerator < 0 else rounded def ticks_to_decimal(self, price_ticks: int) -> Decimal: """Exact price. Use for notional and for anything that is reported.""" - return Decimal(price_ticks) * self.tick_size + return _decimal_times_int(self.tick_size, price_ticks) def ticks_to_float(self, price_ticks: int) -> float: """Lossy conversion for reporting / plotting only. Never feed the result back into book or matching logic. """ - return float(Decimal(price_ticks) * self.tick_size) + return float(self.ticks_to_decimal(price_ticks)) def validate_price_ticks(self, price_ticks: int) -> None: if not self.price_band_lower_ticks <= price_ticks <= self.price_band_upper_ticks: @@ -71,7 +94,7 @@ def round_to_lot(self, quantity_base: int) -> int: # ------------------------------------------------------------- notional def notional(self, price_ticks: int, quantity_base: int) -> Decimal: - return self.ticks_to_decimal(price_ticks) * Decimal(int(quantity_base)) + return _decimal_times_int(self.tick_size, price_ticks * int(quantity_base)) @classmethod def from_dict(cls, payload: dict[str, object]) -> InstrumentSpec: diff --git a/tests/unit/test_domain.py b/tests/unit/test_domain.py index fbaa2c5..50236c5 100644 --- a/tests/unit/test_domain.py +++ b/tests/unit/test_domain.py @@ -2,7 +2,7 @@ from __future__ import annotations -from decimal import Decimal +from decimal import ROUND_DOWN, Decimal, Inexact, Rounded, localcontext import pytest @@ -27,6 +27,35 @@ class TestInstrument: + def test_price_and_notional_ignore_the_callers_decimal_context(self, instrument): + with localcontext() as context: + context.prec = 2 + context.rounding = ROUND_DOWN + context.Emax = 1 + context.Emin = -1 + context.traps[Inexact] = True + context.traps[Rounded] = True + assert instrument.price_to_ticks("100.005") == 10_001 + assert instrument.price_to_ticks("-100.005") == -10_001 + assert instrument.price_to_ticks("99.994") == 9_999 + assert instrument.ticks_to_decimal(10_005) == Decimal("100.05") + assert instrument.ticks_to_float(10_005) == pytest.approx(100.05) + assert instrument.notional(10_005, 13) == Decimal("1300.65") + assert context.prec == 2 and context.traps[Inexact] + + def test_long_price_below_half_a_tick_does_not_round_up(self, instrument): + assert instrument.price_to_ticks("100.004" + "9" * 150) == 10_000 + + @pytest.mark.parametrize("value", ["NaN", "sNaN", "Infinity", "-Infinity"]) + def test_nonfinite_tick_size_is_rejected(self, value): + with pytest.raises(InstrumentError, match="finite"): + InstrumentSpec("X", Decimal(value), 1, "USD", 1, 2) + + @pytest.mark.parametrize("value", ["NaN", "sNaN", "Infinity", "-Infinity"]) + def test_nonfinite_price_is_rejected(self, instrument, value): + with pytest.raises(InstrumentError, match="finite"): + instrument.price_to_ticks(value) + def test_price_to_ticks_rounds_half_up(self, instrument): assert instrument.price_to_ticks("100.00") == 10_000 assert instrument.price_to_ticks("100.005") == 10_001 @@ -117,6 +146,11 @@ def test_fill_notional_is_callable(self): sequence_id=1, ) assert fill.notional(Decimal("0.01")) == Decimal("20010.00") + with localcontext() as context: + context.prec = 2 + context.traps[Inexact] = True + context.traps[Rounded] = True + assert fill.notional(Decimal("0.01")) == Decimal("20010.00") class TestOrderStateMachine: