Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/adr/001-integer-price-ticks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 2 additions & 1 deletion src/tradeforge/domain/fills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 32 additions & 9 deletions src/tradeforge/domain/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
36 changes: 35 additions & 1 deletion tests/unit/test_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from decimal import Decimal
from decimal import ROUND_DOWN, Decimal, Inexact, Rounded, localcontext

import pytest

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Loading