You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Numbers for the Decimal question, as promised. Short version: the headroom is 1.11–1.33× end-to-end (clustering ~1.24×), and I don't think it justifies the trade as a straight float retype. I also prototyped a narrower @njit variant that keeps results reproducible; it reaches ~1.13× at best, and I'm lukewarm on it too. Detail and the measurement method below.
Method
Measured against current dev (943e30d) on an idle 4-core box, Python 3.14 / numba 0.66 / numpy 2.4.6. Three arms, each a mechanical patch to a detached worktree so the algorithm and call graph are identical and only the numeric type differs:
A — Decimal: dev as-is.
B — float: every Decimal in the package aliased to float, to_decimal → float(), and the ~8 genuinely Decimal-specific call sites (is_finite/is_nan/quantize/ROUND_DOWN/Decimal.max) rewritten. 21 insertions / 23 deletions across 9 files.
C — Decimal, minus redundant conversions: keeps Decimal, hoists the loop-invariant to_decimal() calls out of the per-entry loop in _calculate_pnl_mae_mfe and de-duplicates the per-symbol close conversion in capture_bar. Verified bit-identical to A.
9 paired reps per scenario, arms interleaved and alternated per rep — running all of A then all of B produced a phantom result in the earlier parallel_models work, so I won't trust an unpaired comparison. Reported figure is the median paired ratio.
Headroom
scenario
Decimal
float
speedup
range
float wins
arm C
walkforward (reference fixture)
0.1027s
0.0828s
1.235×
1.058–1.282
9/9
1.014×
held_stops
0.1170s
0.0894s
1.326×
1.283–1.352
9/9
1.010×
large (10 sym × 5y × 5 win)
0.4315s
0.3479s
1.238×
1.166–1.262
9/9
0.999×
wide (50 sym × 2y)
0.6697s
0.6095s
1.108×
1.060–1.134
9/9
1.014×
Consistently, the swap removes ~40% of Portfolio bar-loop time. Portfolio is 30–56% of walkforward runtime, so the Amdahl ceiling — if the bar loop cost nothing — is only 1.43–2.27×. float captures roughly half of that; the rest is Python object overhead (attribute access, deque iteration, NamedTuple construction) that only the @njit + np.record rewrite would reach.
Where the time actually is
Per-method cumulative timing, instrumented identically in both arms (large scenario):
method
calls
Decimal
float
speedup
% of total saved
capture_bar
1045
0.0984s
0.0328s
3.00×
14.7%
_calculate_pnl_mae_mfe
5059
0.0424s
0.0097s
4.38×
7.3%
check_stops
1045
0.0999s
0.0944s
1.06×
1.2%
buy
153
0.0065s
0.0048s
1.34×
0.4%
sell
138
0.0050s
0.0037s
1.33×
0.3%
check_stops barely moves — _StopData.value is already float and triggering goes through _get_stop_amount_f. That part of the migration is effectively already done, which is why the remaining headroom is smaller than it looks from the 186 Decimal references.
The whole win is the marking path. Primitive costs on the same box, for reference:
op
Decimal
float
int64 cents
multiply
94.2n
28.3n
58.8n
divide
204.6n
33.3n
47.9n
to_decimal(float) (Decimal(str(x)))
514.7n
61.5n
131.4n
cast out via float(x)
183.0n
28.2n
38.7n
There is no free-lunch version
Arm C is the interesting negative result. capture_bar converts the same close up to 3× per symbol and _calculate_pnl_mae_mfe called to_decimal(close/low/high)inside its per-entry loop, so my hypothesis was that a good chunk of the win was redundant work recoverable without touching precision.
It isn't: arm C is bit-identical to dev and measures 1.01×. to_decimal is only ~13% of capture_bar time (23.4 calls/bar) despite being the priciest single op. The cost is spread across Decimal arithmetic and the float() boundary casts, with no single hot call site to fix. So the speedup genuinely requires the type change.
Peak memory is also a wash (226.5 vs 225.5 MiB) now that bar recording defaults off.
The precision cost, measured
Not "floats are inexact" — what actually changes. I swept 15 backtests (3 fee/share configurations × 5 seeds, 12 symbols × 1000 bars, 13,172 orders total) diffing the full order and trade streams, not just summary metrics.
The invariants hold. I re-checked the _clamp_shares buying-power invariant directly under float inputs: it holds in every fee-mode × leverage combination, with slack. Internal arithmetic drift is ~1e-16 relative, as expected.
Cent-level output differences are real but small: in the 14 runs that stayed on the same trajectory, roughly 1 fill in 650 rounds to a different cent. The cause is the ROUND_HALF_UP at the output boundary, not accumulation — of the x.xx5 ties I scanned, 1,147 round the other way because the binary float isn't the tie (0.145 is stored as 0.14499999999999999, so it rounds down where Decimal rounds up). Worth being explicit: a wider float does not fix this.np.float64is Python float, and np.longdouble only moves the error from ~1e-16 to ~1e-19 while forfeiting @njit entirely.
The finding that actually decides it: 1 of the 15 runs diverged into a different backtest. On fractional seed 3, a one-ulp difference in a fractional share count on the second order —
The mechanism is calc_target_shares sizing off equity: a last-ulp share difference changes equity, which changes the next position size, which changes the next. The error is not bounded at a cent — it is chaotically amplified, and fractional shares are where it bites because integer share counts quantize the perturbation away.
To be fair to float: neither result is "correct" in an absolute sense; both are approximations of a path-dependent system, and the same amplification would follow any small semantic change. But it does mean the honest characterisation of the cost is not "results shift by a cent" — it is "a minority of runs, concentrated in fractional-share strategies, produce materially different headline metrics." That is a much harder thing to put in a release note than a rounding change.
One scoping finding
I ran the full suite against arm B: 244 failed / 4912 passed. That number is misleading and I want to be precise about it — ~205 are TypeError from tests hand-feeding Decimal into a float Portfolio, and ~27 are assert 110.11 == Decimal('110.11'), where the value is right and only the assertion form is Decimal-specific. Those are migration cost (rewriting assertions), not incorrectness.
The 6 that matter are in optimize.py: (0.3 - 0.1) % 0.1 != 0 in binary float, so hyperparameter grids that are valid today get rejected outright. optimize.py uses Decimal(str(x)) specifically to make that validation exact — Decimal is load-bearing there for logic, not money. My patch aliased the whole package, which a real migration wouldn't; the lesson is that the change has to be scoped to Portfolio state, not applied package-wide.
Follow-up: can the speedup be had without the divergence?
The result above says the blanket retype is a bad trade. But it conflates two
things, and they looked separable:
the hot path is marking — its inputs are already float64
(close_f = float(close_arr[idx])), fill prices are already cent-rounded in float
before reaching the Portfolio (PriceScope._round_float), and the aggregate is already
a float sum (to_decimal(math.fsum(...))). The Decimal there is a veneer.
the exactness-critical path is the ledger — cash, fees, share counts. It lives in
the fill path, which is ~0.4% of runtime and cannot be compiled at all
(_calculate_fees may be a user callable and is reached from _clamp_shares, i.e.
from sizing; plus SlippageModel.adjust_fill and Stop.fill_price callables).
So I prototyped exactly that split: _calculate_pnl_mae_mfe as an @njit(cache=True)
kernel over float64 shadow arrays of the entries, capture_bar's per-symbol arithmetic
in float, math.fsum kept in Python (numba has no fsum, and Neumaier only approximates
it — a last-bit equity difference is precisely what feeds calc_target_shares), and share counts, cash, fees and sizing left on Decimal.
The correctness result is excellent. Across the same 15-run sweep, 13,172 orders:
blanket float
njit marking path
cent-level fill diffs
501 (3.80%)
0
trajectory divergences
1 of 15
0 of 15
headline metrics differing
3/39
0/39
test failures
244
4
The 4 failures are all internal pre-quantization assertions on MAE/MFE and pos.pnl
(assert pos.pnl == (fill_price - close_price) * shares); mae/mfe are quantized to
cents at the TestResult boundary, so nothing user-visible moves.
The speed result is the problem. Keeping the public Decimal surface means converting
back every bar, and that costs what the Decimal arithmetic cost:
method
blanket float
njit, Decimal write-back kept
capture_bar
3.00×
1.19×
_calculate_pnl_mae_mfe
4.38×
1.77×
buy / sell
1.34× / 1.33×
0.93× / 0.80×
total
1.229×
1.042×
(buy/sell regress because maintaining the shadow arrays costs two to_decimal per
entry per fill.) Dropping the per-bar Decimal writes entirely — holding the marking
state as float64 on the Position — recovers a good part of it:
scenario
blanket float
njit marking path
reproducible?
walkforward
1.235×
1.045×
yes
held_stops
1.326×
1.084×
yes
large
1.238×
1.132×
yes
wide
1.108×
1.079×
yes
What is left in the gap is the remaining Decimal↔float boundary on the per-bar path: float(pos.shares) and float(entry_notional) per position per bar, and the Decimal
accumulation of pos_long_shares. Those can only be removed by making share counts
float — which is exactly the change that produced the divergence in the first place.
So the two halves are less separable than they look: roughly 60% of the speedup is
available with zero reproducibility cost, and the last 40% is gated behind the precision
trade, not beside it.
Where I land
1.24× in exchange for "1 in 15 runs returns a materially different number" is, in my
view, not a good trade — especially since the cheap half of it (check_stops) is already
banked, and the remaining Python-object overhead is the bigger prize that this change
doesn't touch. If it had been a bounded cent-level shift I'd have argued the other way;
the chaotic amplification in fractional-share strategies is what decides it, because it
can't be characterised in a release note as "expect small differences."
The marking-path variant is the more interesting option, and I'd frame the choice as:
Do nothing. Defensible. The remaining headroom is ~1.1–1.3× against an Amdahl
ceiling of 1.43–2.27×, and v2 already banked the stop path.
Take the reproducible ~1.13× (marking path only). Costs an njit kernel, float64
shadow arrays of the entries, Position.equity/close/margin/pnl becoming properties
over float state, and a ~1e-16 change to internal MAE/MFE that the cent-quantized
output hides. Zero divergences in 13,172 orders. Whether that complexity is worth
1.13× on the bar-loop-heavy scenario is a maintainer call, not mine.
Take the full ~1.24× and accept non-reproducible fractional-share results.
I'd not recommend the third. Between the first two I lean mildly toward the first,
because the marking prototype's complexity is concentrated in exactly the code that is
hardest to keep correct (FIFO entry bookkeeping, the non-FIFO removal in check_stops),
and 1.13× is not much reward for that.
Worth stating plainly since I raised it earlier: int64 scaled cents does not help
here. The hot path is marking, whose inputs are float64 to begin with, so exact
integer money buys nothing there; and the ledger, where exactness would matter, is ~0.4%
of runtime and uncompilable. It also does not fit numba cleanly — at the current _SHARES_EPSILON = 1E-9, price_cents × shares_scaled reaches ~1e19–1e20 against an
int64 ceiling of 9.22e18, and nopython mode has no int128.
The one thing that would change my mind: if V4 is really about @njit end to end, then
float isn't the goal but a precondition, and it should be judged as step 1 of a change
targeting the full 1.43–2.27× ceiling rather than on its own 1.24×. The prototype above
only compiled the marking path; the rest of the bar loop — the FIFO entry bookkeeping and
the per-symbol dict work — is where the remaining ceiling lives, and I haven't measured
what it would take to get that onto flat arrays.
Caveats on the above, so you can weight it properly: all timings are one 4-core box, and
the divergence rate (1 in 15) comes from a sweep large enough to prove the failure mode
exists but not to pin its frequency. The optimize.py breakage is an artifact of my
patch aliasing the whole package rather than something a scoped change would hit.
Happy to hand over the measurement scripts — they're mechanical patches against a
detached worktree, so any of these arms rebuilds from a given revision in one command.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Numbers for the Decimal question, as promised. Short version: the headroom is 1.11–1.33× end-to-end (clustering ~1.24×), and I don't think it justifies the trade as a straight
floatretype. I also prototyped a narrower@njitvariant that keeps results reproducible; it reaches ~1.13× at best, and I'm lukewarm on it too. Detail and the measurement method below.Method
Measured against current
dev(943e30d) on an idle 4-core box, Python 3.14 / numba 0.66 / numpy 2.4.6. Three arms, each a mechanical patch to a detached worktree so the algorithm and call graph are identical and only the numeric type differs:devas-is.Decimalin the package aliased tofloat,to_decimal→float(), and the ~8 genuinely Decimal-specific call sites (is_finite/is_nan/quantize/ROUND_DOWN/Decimal.max) rewritten. 21 insertions / 23 deletions across 9 files.to_decimal()calls out of the per-entry loop in_calculate_pnl_mae_mfeand de-duplicates the per-symbol close conversion incapture_bar. Verified bit-identical to A.9 paired reps per scenario, arms interleaved and alternated per rep — running all of A then all of B produced a phantom result in the earlier
parallel_modelswork, so I won't trust an unpaired comparison. Reported figure is the median paired ratio.Headroom
Consistently, the swap removes ~40% of Portfolio bar-loop time. Portfolio is 30–56% of walkforward runtime, so the Amdahl ceiling — if the bar loop cost nothing — is only 1.43–2.27×. float captures roughly half of that; the rest is Python object overhead (attribute access, deque iteration, NamedTuple construction) that only the
@njit+np.recordrewrite would reach.Where the time actually is
Per-method cumulative timing, instrumented identically in both arms (
largescenario):capture_bar_calculate_pnl_mae_mfecheck_stopsbuysellcheck_stopsbarely moves —_StopData.valueis alreadyfloatand triggering goes through_get_stop_amount_f. That part of the migration is effectively already done, which is why the remaining headroom is smaller than it looks from the 186Decimalreferences.The whole win is the marking path. Primitive costs on the same box, for reference:
to_decimal(float)(Decimal(str(x)))float(x)There is no free-lunch version
Arm C is the interesting negative result.
capture_barconverts the same close up to 3× per symbol and_calculate_pnl_mae_mfecalledto_decimal(close/low/high)inside its per-entry loop, so my hypothesis was that a good chunk of the win was redundant work recoverable without touching precision.It isn't: arm C is bit-identical to
devand measures 1.01×.to_decimalis only ~13% ofcapture_bartime (23.4 calls/bar) despite being the priciest single op. The cost is spread across Decimal arithmetic and thefloat()boundary casts, with no single hot call site to fix. So the speedup genuinely requires the type change.Peak memory is also a wash (226.5 vs 225.5 MiB) now that bar recording defaults off.
The precision cost, measured
Not "floats are inexact" — what actually changes. I swept 15 backtests (3 fee/share configurations × 5 seeds, 12 symbols × 1000 bars, 13,172 orders total) diffing the full order and trade streams, not just summary metrics.
The invariants hold. I re-checked the
_clamp_sharesbuying-power invariant directly under float inputs: it holds in every fee-mode × leverage combination, with slack. Internal arithmetic drift is ~1e-16 relative, as expected.Cent-level output differences are real but small: in the 14 runs that stayed on the same trajectory, roughly 1 fill in 650 rounds to a different cent. The cause is the
ROUND_HALF_UPat the output boundary, not accumulation — of thex.xx5ties I scanned, 1,147 round the other way because the binary float isn't the tie (0.145is stored as0.14499999999999999, so it rounds down where Decimal rounds up). Worth being explicit: a wider float does not fix this.np.float64is Pythonfloat, andnp.longdoubleonly moves the error from ~1e-16 to ~1e-19 while forfeiting@njitentirely.The finding that actually decides it: 1 of the 15 runs diverged into a different backtest. On
fractionalseed 3, a one-ulp difference in a fractional share count on the second order —— compounded over 1000 bars into:
The mechanism is
calc_target_sharessizing off equity: a last-ulp share difference changes equity, which changes the next position size, which changes the next. The error is not bounded at a cent — it is chaotically amplified, and fractional shares are where it bites because integer share counts quantize the perturbation away.To be fair to float: neither result is "correct" in an absolute sense; both are approximations of a path-dependent system, and the same amplification would follow any small semantic change. But it does mean the honest characterisation of the cost is not "results shift by a cent" — it is "a minority of runs, concentrated in fractional-share strategies, produce materially different headline metrics." That is a much harder thing to put in a release note than a rounding change.
One scoping finding
I ran the full suite against arm B: 244 failed / 4912 passed. That number is misleading and I want to be precise about it — ~205 are
TypeErrorfrom tests hand-feedingDecimalinto a float Portfolio, and ~27 areassert 110.11 == Decimal('110.11'), where the value is right and only the assertion form is Decimal-specific. Those are migration cost (rewriting assertions), not incorrectness.The 6 that matter are in
optimize.py:(0.3 - 0.1) % 0.1 != 0in binary float, so hyperparameter grids that are valid today get rejected outright.optimize.pyusesDecimal(str(x))specifically to make that validation exact — Decimal is load-bearing there for logic, not money. My patch aliased the whole package, which a real migration wouldn't; the lesson is that the change has to be scoped to Portfolio state, not applied package-wide.Follow-up: can the speedup be had without the divergence?
The result above says the blanket retype is a bad trade. But it conflates two
things, and they looked separable:
float64(
close_f = float(close_arr[idx])), fill prices are already cent-rounded in floatbefore reaching the Portfolio (
PriceScope._round_float), and the aggregate is alreadya float sum (
to_decimal(math.fsum(...))). TheDecimalthere is a veneer.the fill path, which is ~0.4% of runtime and cannot be compiled at all
(
_calculate_feesmay be a user callable and is reached from_clamp_shares, i.e.from sizing; plus
SlippageModel.adjust_fillandStop.fill_pricecallables).So I prototyped exactly that split:
_calculate_pnl_mae_mfeas an@njit(cache=True)kernel over float64 shadow arrays of the entries,
capture_bar's per-symbol arithmeticin float,
math.fsumkept in Python (numba has no fsum, and Neumaier only approximatesit — a last-bit equity difference is precisely what feeds
calc_target_shares), andshare counts, cash, fees and sizing left on Decimal.
The correctness result is excellent. Across the same 15-run sweep, 13,172 orders:
The 4 failures are all internal pre-quantization assertions on MAE/MFE and
pos.pnl(
assert pos.pnl == (fill_price - close_price) * shares);mae/mfeare quantized tocents at the
TestResultboundary, so nothing user-visible moves.The speed result is the problem. Keeping the public Decimal surface means converting
back every bar, and that costs what the Decimal arithmetic cost:
capture_bar_calculate_pnl_mae_mfebuy/sell(
buy/sellregress because maintaining the shadow arrays costs twoto_decimalperentry per fill.) Dropping the per-bar Decimal writes entirely — holding the marking
state as float64 on the
Position— recovers a good part of it:What is left in the gap is the remaining
Decimal↔floatboundary on the per-bar path:float(pos.shares)andfloat(entry_notional)per position per bar, and the Decimalaccumulation of
pos_long_shares. Those can only be removed by making share countsfloat — which is exactly the change that produced the divergence in the first place.
So the two halves are less separable than they look: roughly 60% of the speedup is
available with zero reproducibility cost, and the last 40% is gated behind the precision
trade, not beside it.
Where I land
1.24× in exchange for "1 in 15 runs returns a materially different number" is, in my
view, not a good trade — especially since the cheap half of it (
check_stops) is alreadybanked, and the remaining Python-object overhead is the bigger prize that this change
doesn't touch. If it had been a bounded cent-level shift I'd have argued the other way;
the chaotic amplification in fractional-share strategies is what decides it, because it
can't be characterised in a release note as "expect small differences."
The marking-path variant is the more interesting option, and I'd frame the choice as:
ceiling of 1.43–2.27×, and v2 already banked the stop path.
shadow arrays of the entries,
Position.equity/close/margin/pnlbecoming propertiesover float state, and a ~1e-16 change to internal MAE/MFE that the cent-quantized
output hides. Zero divergences in 13,172 orders. Whether that complexity is worth
1.13× on the bar-loop-heavy scenario is a maintainer call, not mine.
I'd not recommend the third. Between the first two I lean mildly toward the first,
because the marking prototype's complexity is concentrated in exactly the code that is
hardest to keep correct (FIFO entry bookkeeping, the non-FIFO removal in
check_stops),and 1.13× is not much reward for that.
Worth stating plainly since I raised it earlier: int64 scaled cents does not help
here. The hot path is marking, whose inputs are float64 to begin with, so exact
integer money buys nothing there; and the ledger, where exactness would matter, is ~0.4%
of runtime and uncompilable. It also does not fit numba cleanly — at the current
_SHARES_EPSILON = 1E-9,price_cents × shares_scaledreaches ~1e19–1e20 against anint64 ceiling of 9.22e18, and nopython mode has no int128.
The one thing that would change my mind: if V4 is really about
@njitend to end, thenfloat isn't the goal but a precondition, and it should be judged as step 1 of a change
targeting the full 1.43–2.27× ceiling rather than on its own 1.24×. The prototype above
only compiled the marking path; the rest of the bar loop — the FIFO entry bookkeeping and
the per-symbol dict work — is where the remaining ceiling lives, and I haven't measured
what it would take to get that onto flat arrays.
Caveats on the above, so you can weight it properly: all timings are one 4-core box, and
the divergence rate (1 in 15) comes from a sweep large enough to prove the failure mode
exists but not to pin its frequency. The
optimize.pybreakage is an artifact of mypatch aliasing the whole package rather than something a scoped change would hit.
Happy to hand over the measurement scripts — they're mechanical patches against a
detached worktree, so any of these arms rebuilds from a given revision in one command.
All reactions