Repository navigation
Description
Version: ml4t-backtest 0.2.0 (PyPI); the code path is unchanged on main (execution/rebalancer.py, accounting/gatekeeper.py).
What happens
With ShareType.FRACTIONAL in a cash account, closing a position by passing a target weight of 0.0 to TargetWeightExecutor.execute() is sometimes rejected with Position reversal not allowed in cash account. _process_asset sizes the sell as equity * (0 - current_wt) / price, which equals the held quantity only up to floating-point error. When the result is a few ULP larger than the position (we saw +1.1e-13 shares), Gatekeeper._is_reversal sees new_qty < 0 and rejects the whole order. The buys that rely on the freed cash then fail with Insufficient cash.
The effect is silent: the position stays in the portfolio until the next rebalance, turnover and costs are understated, and the backtest drifts from the intended strategy. In a weekly 25-name equal-weight rotation we got 75–150 rejected orders per run, and net CAGR (20 bps per side) dropped from about 44 % to about 40 % once exits were made exact; gross results barely moved.
Minimal reproduction
import numpy as np, polars as pl
from datetime import datetime, timedelta
from ml4t.backtest import BacktestConfig, RebalanceConfig, Strategy, TargetWeightExecutor, run_backtest
from ml4t.backtest.config import ShareType
rng = np.random.default_rng(0)
dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(60)]
assets = [f"A{i}" for i in range(20)]
rows = []
for a in assets:
p = 50 * np.cumprod(1 + rng.normal(0, 0.02, len(dates)))
for d, c in zip(dates, p):
rows.append({"timestamp": d, "asset": a, "open": c * (1 + rng.normal(0, 0.005)),
"high": c * 1.01, "low": c * 0.99, "close": c, "volume": 1e6})
prices = pl.DataFrame(rows)
class Rotate(Strategy):
def __init__(self):
self.ex = TargetWeightExecutor(RebalanceConfig())
self.bar, self.r = 0, np.random.default_rng(1)
def on_data(self, ts, data, context, broker):
self.bar += 1
if (self.bar - 1) % 5:
return
w = {a: 0.98 / 10 for a in self.r.choice(assets, 10, replace=False)}
for a in broker.get_positions():
w.setdefault(a, 0.0) # exit via target weight 0
self.ex.execute(w, data, broker, timestamp=ts)
cfg = BacktestConfig()
cfg.share_type = ShareType.FRACTIONAL
cfg.initial_cash = 100_000.0
res = run_backtest(prices, Rotate(), config=cfg)
print(res.to_rejected_orders_dataframe().group_by("rejection_reason").len())
# -> 8 x "Position reversal not allowed in cash account", 8 x "Insufficient cash"Expected
A target weight of 0 closes the position exactly, as broker.close_position() does (with it the same run has 0 rejected orders).
Possible fixes (either would do)
- In
TargetWeightExecutor._process_asset: iftarget_wt == 0(or the computed sell would cross zero), sell exactlypos.quantity, or clamp the sell to the held quantity. - In
Gatekeeper._is_reversal: treatabs(new_qty) <= quantity_zero_tolerance(current_qty, order_qty_delta)as flat (the helper already exists incore/shared.py).
Workaround used for now: close exits with broker.close_position(asset) and pass only the remaining targets to the executor.
Activity