Split application drops a whole share when the entitlement is an exact multiple of the ratio

Abierto
#9,882 2 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 1 día

@amirani8137 ya está trabajando en esto.

Desde el 8/10/2026.

  • #9883 de @amirani8137 — cerrado sin fusionar

Evaluación

Dificultad
3/5
Tiempo estimado
1-2 días
Aptitud para principiantes
45/100
Tipo de issue
Error
Claridad
Bien especificado
Estado de actividad
Activo
Stack tecnológico
csharp, python
Área
backend

Línea de trabajo

Start in SecurityPortfolioManager.ApplySplit in Common/Securities/SecurityPortfolioManager.cs, where the post-split quantity is truncated with (int), and check how CorporateFactorRow.GetSplit derives the factor. Add a unit test in SecurityPortfolioManagerTests that applies the reported splits to 16 and 100 shares. Done means 16 shares at factor 8.00000003210186 yields 2 shares with no cash change, and 100 shares at 0.1428572 yields 700.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

Expected Behavior

In a raw-data backtest (and in live trading, where ApplySplit runs regardless of normalization mode), a position that is an exact whole-share multiple of the announced split ratio should hold exactly that many shares after the split, with no cash in lieu:

  • 16 GE shares across the 2021-08-02 one-for-eight reverse split should become 2 shares.
  • 100 AAPL shares across the 2014-06-09 seven-for-one split should become 700 shares.
  • 100 AAPL shares across the 2000-06-21 two-for-one split should become 200 shares.
Actual Behavior

The engine keeps one share fewer in each case and credits the missing share to cash:

Position before Event Delivered SplitFactor Expected shares Actual shares Cash in lieu credited
8 GE 2021-08-02, 1-for-8 8.00000003210186 1 0 103.60 (the whole position)
16 GE 2021-08-02, 1-for-8 8.00000003210186 2 1 103.59999958
24 GE 2021-08-02, 1-for-8 8.00000003210186 3 2 103.59999917
100 AAPL 2000-06-21, 2-for-1 0.500001120002688 200 199 50.48
100 AAPL 2014-06-09, 7-for-1 0.1428572 700 699 92.20
100 GE 2021-08-02, 1-for-8 8.00000003210186 12 (+ 0.5 share in cash) 12 51.80 (correct)
1,003 GE 2021-08-02, 1-for-8 8.00000003210186 125 (+ 0.375 share in cash) 125 38.85 (correct)
100 AAPL 2005-02-28, 2-for-1 0.49999860000056007 200 200 0.02 (benign)

Portfolio value is preserved at the split (the missing share is paid out at the post-split price), so the error is invisible in equity curves and NAV checks and only shows up as reduced stock exposure afterwards. The 8-share case is the extreme: the whole position is converted to cash.

Cause

SecurityPortfolioManager.ApplySplit truncates the post-split quantity with a plain cast:

https://github.com/QuantConnect/Lean/blob/80e7843f645673bcbeaab963049f76f20f6785e1/Common/Securities/SecurityPortfolioManager.cs#L834-L842

var quantity = security.Holdings.Quantity / split.SplitFactor;
...
var leftOver = quantity - (int)quantity;
security.Holdings.SetHoldings(avgPrice, (int)quantity);

The SplitFactor the algorithm receives is not the announced ratio. CorporateFactorRow.GetSplit and CorporateFactorProvider.HasSplitEventOnNextTradingDay derive it as the quotient of two cumulative factor-file values, each stored rounded to 8 decimal places (CorporateFactorRow.GetFileFormat). The quotient is exact only for a security's most recent split (it divides by 1.0); every earlier split carries the rounding error of both rows. When that error puts the factor a hair above the true ratio, an exact whole-share entitlement lands a hair below the whole number (16 / 8.00000003210186 = 1.999999992; 100 / 0.1428572 = 699.9997) and (int) drops a whole share.

The relative error is small for GE (4e-9) but much larger for securities with a long split history, because the cumulative factors become small and 8 decimal places leave few significant digits: the AAPL rows are 0.00892857 and 0.01785710, so the 2000 split is delivered with a relative error of 2.2e-6.

Related

#5765 reported the AAPL 100 -> 699 case in July 2021. #5780 closed it by fixing the price the cash in lieu is valued at (the comment on the issue notes ~$644 was credited instead of ~$94), but the truncation itself is unchanged and the 699 result still reproduces on master today. #9782 / #9785 later converted the cash in lieu into the account currency; same code path, same truncation.

Potential Solution

Before truncating, snap the post-split quantity to the next whole share when the shortfall is a negligible fraction of one pre-split share, which no genuine split ratio can produce (a genuine fractional entitlement is at least one pre-split share divided by the ratio's denominator: one share for a reverse split, a twenty-first of a share for a 5% stock dividend). Expressing the tolerance in pre-split shares keeps it independent of the direction and size of the split:

var quantity = security.Holdings.Quantity / split.SplitFactor;
var wholeShares = Math.Sign(quantity) * Math.Ceiling(Math.Abs(quantity));
if (Math.Abs(wholeShares - quantity) * split.SplitFactor < SplitWholeShareTolerance)   // e.g. 0.01m
{
    quantity = wholeShares;
}
var leftOver = quantity - (int)quantity;

Only the "just below a whole share" direction needs snapping; a quantity a hair above a whole number already truncates to the right share count, with a negligible cash credit.

This mitigates the common cases (every GE position up to a few million shares, AAPL positions up to a few thousand shares across the 2000 split). The complete fix is on the data side: writing cumulative factors with more significant digits, or delivering the per-event factor from the splits record rather than as a quotient of rounded cumulative factors.

Reproducing the Problem

Raw minute data, one market order on the first bar, log the split events and the holdings; the qty, ticker, start and end parameters select the case. Backtests in our organization, LEAN 2.5.0.0.18176 (the GE case also reproduces on 2.5.0.0.18171): e4c046c8472eac868482c0873a7c2579 (16 GE -> 1), 86812f35f24698dface7bb0201d6cfac (8 GE -> 0), 8da07d16a7c0a1f9dbfc06667e846d29 (24 GE -> 2), add07e746f967d26ebb8723e9a95e542 (100 AAPL 2014 -> 699), f05313729e7e838c9ff42f175bb33d01 (100 AAPL 2000 -> 199).

from AlgorithmImports import *


class SplitExactWholeShareMultipleAudit(QCAlgorithm):

    def initialize(self):
        start = str(self.get_parameter("start", "2021-07-28"))
        end = str(self.get_parameter("end", "2021-08-06"))
        self.set_start_date(datetime.strptime(start, "%Y-%m-%d"))
        self.set_end_date(datetime.strptime(end, "%Y-%m-%d"))
        self.set_cash(1000000)
        self.qty = int(self.get_parameter("qty", 16))
        ticker = str(self.get_parameter("ticker", "GE"))
        eq = self.add_equity(ticker, Resolution.MINUTE,
                             data_normalization_mode=DataNormalizationMode.RAW)
        eq.set_fee_model(ConstantFeeModel(0))
        self._sym = eq.symbol
        self._bought = False
        self._lines = []

    def on_data(self, slice):
        px = self.securities[self._sym].price
        if not self._bought and px > 0:
            self.market_order(self._sym, self.qty)
            self._bought = True

        split = slice.splits.get(self._sym)
        if split:
            h = self.portfolio[self._sym]
            self._lines.append(
                f"SPLIT {self.time} type={split.type} factor={split.split_factor!r} "
                f"ref={split.reference_price} qty_now={h.quantity} avg_now={h.average_price} "
                f"cash_now={self.portfolio.cash} price_now={px}")

    def on_order_event(self, e):
        if e.status == OrderStatus.FILLED:
            self._lines.append(f"FILL {self.time} qty={e.fill_quantity} price={e.fill_price} cash_after={self.portfolio.cash}")

    def on_end_of_algorithm(self):
        for line in self._lines:
            self.log(line)
        h = self.portfolio[self._sym]
        self.log(f"FINAL qty_param={self.qty} qty={h.quantity} avg={h.average_price} "
                 f"cash={self.portfolio.cash} value={self.portfolio.total_portfolio_value}")

Output of the 16-share GE run:

FILL 2021-07-28 09:31:00 qty=16.0 price=13.19 cash_after=999788.96
SPLIT 2021-07-30 00:00:00 type=WARNING factor=8.00000003210186 ref=13.21 qty_now=16.0 avg_now=13.19 cash_now=999788.96 price_now=13.29
SPLIT 2021-08-02 00:00:00 type=SPLIT_OCCURRED factor=8.00000003210186 ref=12.95 qty_now=1.0 avg_now=105.52000042342354 cash_now=999892.5599995842 price_now=103.6000004157191
FINAL qty_param=16 qty=1.0 avg=105.52000042342354 cash=999892.5599995842 value=999997.0799995844

Output of the 100-share AAPL 2014 run:

FILL 2014-06-02 09:31:00 qty=100.0 price=633.82 cash_after=936618.0
SPLIT 2014-06-09 00:00:00 type=SPLIT_OCCURRED factor=0.1428572 ref=645.57 qty_now=699.0 avg_now=90.545750504 cash_now=936710.198499804 price_now=92.224322604
FINAL qty_param=100 qty=699.0 avg=90.545750504 cash=936710.198499804 value=1000514.918499804

A unit test in SecurityPortfolioManagerTests with new Split(Symbols.SPY, date, 12.95m, 8.00000003210186m, SplitType.SplitOccurred) applied to a 16-share holding (expect 2 shares, no cash change) and 0.1428572m applied to 100 shares (expect 700) fails on master.

System Information

QuantConnect Cloud, LEAN 2.5.0.0.18176 and 2.5.0.0.18171; the code is unchanged at master 80e7843. Intercom conversation 215476300828848.

Checklist
  • I have completely filled out this template
  • I have confirmed that this issue exists on the current master branch
  • I have confirmed that this is not a duplicate issue by searching issues
  • I have provided detailed steps to reproduce the issue
Lenguaje dominante
C#
Estrellas
22.5k
Forks
5.4k
Merge medio
22 h 6 min
PR fusionados (30 d)
81

Preparar el entorno

Abrir en Codespaces

Inicia el contenedor de desarrollo del proyecto en tu navegador, con tu propia cuenta de GitHub.

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de QuantConnect/Lean

Todos los issues de QuantConnect/Lean

Issues similares

Más issues de C#

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.