Skip to content

Absolute 1e-12 quantity-zero epsilon is scale-blind: positions of 8192+ units cannot be closed, inflating n_positions #66

Description

@brentianpalmer

Summary

FillExecutor._update_position decides a position is closed by comparing the post-fill quantity against an absolute epsilon:

# src/ml4t/backtest/execution/fill_executor.py:85, :261-267  (v0.1.0b21)
self._qty_zero_epsilon = 1e-12
...
old_qty = pos.quantity
new_qty = old_qty + ctx.signed_qty
if abs(new_qty) < self._qty_zero_epsilon:
    new_qty = 0.0

float64 spacing grows with magnitude, so a single absolute threshold is only correct over one binade:

position size one ULP below 1e-12?
4096 9.094947e-13 yes
8191 9.094947e-13 yes
8192 (2^13) 1.818989e-12 no
10927 1.818989e-12 no
874442 1.164153e-10 no

At and above 2^13 = 8192 units, an economically exact close whose two operands differ in their last bit leaves a residual of one ULP, which is larger than 1e-12. The guard does not fire, _close_position is never reached, and the key survives in broker.positions.

Because Engine._record_portfolio_state reports n_positions as len(self.broker.positions) (src/ml4t/backtest/engine.py:275-277) with no threshold of its own, the reported position count is one too high until the name is traded again — while the position it counts holds ~1e-12 units.

Reproducer

import math
from datetime import datetime
from ml4t.backtest.broker import Broker
from ml4t.backtest.config import ShareType
from ml4t.backtest.models import NoCommission, NoSlippage
from ml4t.backtest.types import OrderSide, Position

QTY = 10927.322882            # any magnitude >= 8192 reproduces
TS = datetime(2024, 1, 2, 16, 0)

broker = Broker(1e8, NoCommission(), NoSlippage(),
                allow_short_selling=True, allow_leverage=True,
                share_type=ShareType.FRACTIONAL)
broker.positions["AAA"] = Position(asset="AAA", quantity=-QTY, entry_price=10.0,
                                   current_price=10.0, entry_time=TS)
broker._update_time(timestamp=TS, prices={"AAA": 10.0}, opens={"AAA": 10.0},
                    highs={"AAA": 10.0}, lows={"AAA": 10.0},
                    volumes={"AAA": 1e12}, signals={})

cover = math.nextafter(QTY, math.inf)      # one ULP above the held quantity
assert -QTY + cover == 2.0**-39            # exact: Sterbenz's lemma
broker.submit_order("AAA", cover, OrderSide.BUY)
broker._process_orders()

print(broker.positions)     # {'AAA': Position(quantity=1.8189894035458565e-12, ...)}

Expected: {}. Actual on v0.1.0b21: a retained key holding 2**-39 units.

With QTY = 4096.5 (below 2^13) the same script closes correctly, which isolates the binade crossing as the cause.

Proposed numerical contract

The residual is not created by the closing addition. When the close is exact the two operands are within a factor of two, so by Sterbenz's lemma old_qty + signed_qty is computed exactly; a non-zero result means the two floats were not exact negatives, having been produced by different routes. The gap between them is bounded by float64 spacing at the magnitude of the quantities being cancelled.

So the tolerance should scale with the operands:

scale = max(|old_qty|, |signed_qty|)          # the operands, never the result
tol   = max(1e-12, 16 * ulp(scale))
  • The scale must be the operands. The result is ~0 by construction, so a tolerance taken against it can never fire.
  • The floor preserves the current absolute behaviour everywhere spacing is finer than 1e-12, i.e. every magnitude below 8192. A call passing a single operand is therefore exactly equivalent to today's rule, which keeps the "is this book quantity zero?" sites unchanged.
  • 16 ULP is ~3.6e-15 of the operation scale, the fifteenth significant digit — below what float64 arithmetic at that scale can reliably preserve. At the largest scale we have observed in practice (874,443 units) it is 1.86e-9, still well under OrderBook._MIN_ORDER_SIZE (1e-8).

Why a larger fixed epsilon is not the repair

Raising 1e-12 to, say, 1e-9 moves the failure point from 8192 units to roughly 8.4e6 units — it does not remove it, because every absolute threshold is scale-blind somewhere. It also erases genuine small positions below the new value. For the same reason a scale-relative rule must carry no upper cap: a ceiling is itself a fixed absolute value and would reintroduce the same blindness.

Related observations

  • Create path has no floor at all. _update_position opens a position for any non-zero signed_qty (fill_executor.py:256-258), so a fill of 1e-15 units creates a counted position that only the 1e-12 path could ever close.
  • The sites disagree at the threshold. fill_executor.py:263, order_book._simulate_position_update and gatekeeper use <; the shadow-book and shadow-queue paths in order_book.py and core/execution_engine.py use <=. The two conventions differ exactly at |q| == eps.

Fix

A branch implementing the above, with 66 focused tests, is at
https://github.com/brentianpalmer/backtest/tree/fix/scale-relative-quantity-zeroing-20260804
(based on v0.1.0b21, commit 877332b). Happy to open a PR against main.

Full test suite on that branch: 1555 passed, 13 skipped — the same 1489 passing tests as the base plus the 66 new ones, no regressions. ruff check and ty check report the same counts as the base.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions