Split application drops a whole share when the entitlement is an exact multiple of the ratio
Maintainer thường phản hồi trong vòng 1 ngày
@amirani8137 đang làm issue này rồi.
Từ ngày 8/10/2026.
- #9883 của @amirani8137 — đã đóng, không merge
Đánh giá
- Độ khó
- 3/5
- Thời gian dự kiến
- 1-2 ngày
- Mức phù hợp với người mới
- 45/100
Hướng nghiên cứu
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.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
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:
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
masterbranch - I have confirmed that this is not a duplicate issue by searching issues
- I have provided detailed steps to reproduce the issue
- Ngôn ngữ chính
- C#
- Star
- 22.5k
- Fork
- 5.4k
- Merge trung bình
- 22 giờ 6 phút
- Pull request đã merge (30 ngày)
- 81
Chuẩn bị môi trường
Khởi chạy dev container của dự án ngay trên trình duyệt, bằng tài khoản GitHub của bạn.
- Có Dockerfile hoặc tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của QuantConnect/Lean
-
PDF report generation can overwrite HTML when the destination extension is not lowercase .htmlĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
QuantConnect/Lean#10082 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
QuantConnect/Lean#10059 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Library Request: highspyĐang mởlibrary-request
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
QuantConnect/Lean#10034 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
QuantConnect/Lean#9965 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
QuantConnect/Lean#9955 ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của QuantConnect/Lean
Issue tương tự
-
bug emitter:client:csharp
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 84/100
microsoft/typespec#12572 · 1 bình luận · 1 reaction ·
Maintainer thường phản hồi trong vòng 1 ngày
-
needs-area-label
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 2 ngày
-
AWS Lambda: KeyNotFoundException drops HTTP-triggered transactions when allowAllHeaders is disabledĐang mởbug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
newrelic/newrelic-dotnet-agent#3949 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
area-networking
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
dotnet/aspnetcore#70545 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 87/100
Maintainer thường phản hồi trong vòng 1 ngày