Skip to content

TargetWeightExecutor: exit via target weight 0 rejected as "Position reversal" (float residue, fractional shares) #182

Open

Description

@carstenf

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)

  1. In TargetWeightExecutor._process_asset: if target_wt == 0 (or the computed sell would cross zero), sell exactly pos.quantity, or clamp the sell to the held quantity.
  2. In Gatekeeper._is_reversal: treat abs(new_qty) <= quantity_zero_tolerance(current_qty, order_qty_delta) as flat (the helper already exists in core/shared.py).

Workaround used for now: close exits with broker.close_position(asset) and pass only the remaining targets to the executor.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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