Expected Behavior
When an equity pays a cash distribution and is delisted without trading again afterwards (a dissolution's liquidating distribution, a cash-out at delisting), the backtest portfolio should recognise the position's value once: the distribution arrives as cash, and the engine's delisting liquidation releases only whatever residual value is left.
Actual Behavior
The position's value is counted twice. In Raw (and SplitAdjusted) mode the dividend is credited as cash while the holding's quantity and mark are left unchanged; because the security never prints another bar, the mark stays at the last pre-distribution trade. The Liquidate from delisting market order then fills at that stale security.Price on the delisting date, so the whole position value is realised a second time.
Reproduction (cloud LEAN v2.5.0.0.18122, master 7d534a1), one equity, no leverage, minute resolution, DataNormalizationMode.Raw, MACK (Merrimack Pharmaceuticals; last trading session 2024-05-17, dividend and map-file delisting date 2024-05-20):
2024-05-17 16:05 EOD qty=56468 secprice=15.13 holdvalue=854360.84 cash=151003.62 tpv=1005364.46
2024-05-20 00:00 DIVIDEND dist=15.1 ref=15.13 qty=56468 secprice=15.13 holdvalue=854360.84 cash=1003670.42 tpv=1858031.26
2024-05-20 00:00 DELISTING type=WARNING
2024-05-21 00:00 FILL qty=-56468 px=15.13 tag='Liquidate from delisting' cash=1858031.26 tpv=1858031.26
2024-05-21 00:00 DELISTING type=DELISTED
The distribution (56,468 x 15.10 = 852,666.80) is added to cash on 05-20 while holdvalue stays 854,360.84; the liquidation on 05-21 then converts that unchanged holding value into cash at 15.13. Net effect: tpv goes from 1,005,364 to 1,858,031 on a position that was worth 854,361 and paid out 852,667. The economically correct end state is about 1,007,000 (cash 151,004 + distribution 852,667 + a residual of ~0.03/share).
Reproducing algorithm
Python, cloud backtest; the log lines above are its output.
from AlgorithmImports import *
class MackDelistingDistributionRepro(QCAlgorithm):
def initialize(self):
self.set_start_date(2024, 5, 1)
self.set_end_date(2024, 5, 31)
self.set_cash(1_000_000)
self._sym = self.add_equity("MACK", Resolution.MINUTE,
data_normalization_mode=DataNormalizationMode.RAW).symbol
self._entered = False
self.schedule.on(self.date_rules.every_day(), self.time_rules.at(16, 5), self._eod)
def _state(self):
h = self.portfolio[self._sym]
return (f"qty={h.quantity} avg={h.average_price} secprice={self.securities[self._sym].price} "
f"holdvalue={h.holdings_value:.2f} cash={self.portfolio.cash:.2f} "
f"tpv={self.portfolio.total_portfolio_value:.2f}")
def on_data(self, slice):
if not self._entered and self.time.date() >= date(2024, 5, 6) and self._sym in slice.bars:
self.set_holdings(self._sym, 0.85)
self._entered = True
if slice.dividends.contains_key(self._sym):
d = slice.dividends[self._sym]
self.log(f"DIVIDEND {self.time} dist={d.distribution} ref={d.reference_price} {self._state()}")
if slice.delistings.contains_key(self._sym):
dl = slice.delistings[self._sym]
self.log(f"DELISTING {self.time} type={dl.type} evprice={dl.price} {self._state()}")
def on_order_event(self, e):
if e.status == OrderStatus.FILLED:
o = self.transactions.get_order_by_id(e.order_id)
self.log(f"FILL {self.time} qty={e.fill_quantity} px={e.fill_price} tag='{o.tag}' {self._state()}")
def _eod(self):
self.log(f"EOD {self.time.date()} {self._state()}")
def on_end_of_algorithm(self):
for d in self.history[Dividend](self._sym, datetime(2024, 4, 1), datetime(2024, 6, 30)):
self.log(f"HIST DIVIDEND end={d.end_time} dist={d.distribution} ref={d.reference_price}")
for d in self.history[Delisting](self._sym, datetime(2024, 4, 1), datetime(2024, 6, 30)):
self.log(f"HIST DELISTING end={d.end_time} type={d.type} price={d.price}")
for b in self.history[TradeBar](self._sym, datetime(2024, 5, 13), datetime(2024, 5, 24), Resolution.DAILY):
self.log(f"HIST DAILY end={b.end_time} o={b.open} h={b.high} l={b.low} c={b.close} v={b.volume}")
self.log(f"FINAL {self._state()}")
Where it happens
Common/Securities/SecurityPortfolioManager.cs#L789-L809 (ApplyDividend): credits Quantity * Distribution to cash and records the dividend on the holding, but does not touch the security's cached price. In Raw mode the price drop is expected to arrive with the next bar, which never comes for a security that stops trading at the same time.
Engine/TransactionHandlers/BrokerageTransactionHandler.cs#L1566-L1605 (HandleDelistingNotification): fills the liquidation at security.Price (L1598), the last cached trade, with no awareness that a distribution has been paid on the same holding since that price was cached.
Engine/DataFeeds/Enumerators/DelistingEventProvider.cs#L96-L104: the Delisted event is emitted on the tradable day after the delisting date, so the dividend (ex-date = delisting date) is always applied first.
Compare with splits, where the engine does keep the cached mark consistent: SecurityPortfolioManager.ApplySplit (L856) calls Security.ApplySplit -> SecurityCache.ApplySplit (Common/Securities/SecurityCache.cs#L623), which rescales Price/OHLC/bid/ask and the cached BaseData. There is no dividend counterpart.
Proposed change
Mirror the split handling for cash dividends in Raw/SplitAdjusted backtests: after crediting the cash in ApplyDividend, adjust the security's cached last data (Price, Open, High, Low, Close, BidPrice, AskPrice, and the cached BaseData instances) down by dividend.Distribution, floored at zero, e.g. a Security.ApplyDividend(Dividend) / SecurityCache.ApplyDividend(Dividend) pair. The next real bar overwrites the adjustment, so ordinary dividends are unaffected beyond the midnight-to-open window (where the current behaviour also briefly overstates TotalPortfolioValue by the dividend amount); for a security that never prints again, the delisting liquidation fills at the ex-distribution price and the double count disappears.
A narrower alternative is to have HandleDelistingNotification subtract the distributions applied since the last price update from the fill price, but that leaves the same stale mark visible in Portfolio.TotalPortfolioValue between the two events.
Open question: whether SplitAdjusted should receive the same cache adjustment (it receives the cash today).
Checklist
Support conversation: 215476063836125
Expected Behavior
When an equity pays a cash distribution and is delisted without trading again afterwards (a dissolution's liquidating distribution, a cash-out at delisting), the backtest portfolio should recognise the position's value once: the distribution arrives as cash, and the engine's delisting liquidation releases only whatever residual value is left.
Actual Behavior
The position's value is counted twice. In
Raw(andSplitAdjusted) mode the dividend is credited as cash while the holding's quantity and mark are left unchanged; because the security never prints another bar, the mark stays at the last pre-distribution trade. TheLiquidate from delistingmarket order then fills at that stalesecurity.Priceon the delisting date, so the whole position value is realised a second time.Reproduction (cloud LEAN
v2.5.0.0.18122, master7d534a1), one equity, no leverage, minute resolution,DataNormalizationMode.Raw, MACK (Merrimack Pharmaceuticals; last trading session 2024-05-17, dividend and map-file delisting date 2024-05-20):The distribution (56,468 x 15.10 = 852,666.80) is added to cash on 05-20 while
holdvaluestays 854,360.84; the liquidation on 05-21 then converts that unchanged holding value into cash at 15.13. Net effect:tpvgoes from 1,005,364 to 1,858,031 on a position that was worth 854,361 and paid out 852,667. The economically correct end state is about 1,007,000 (cash 151,004 + distribution 852,667 + a residual of ~0.03/share).Reproducing algorithm
Python, cloud backtest; the log lines above are its output.
Where it happens
Common/Securities/SecurityPortfolioManager.cs#L789-L809(ApplyDividend): creditsQuantity * Distributionto cash and records the dividend on the holding, but does not touch the security's cached price. InRawmode the price drop is expected to arrive with the next bar, which never comes for a security that stops trading at the same time.Engine/TransactionHandlers/BrokerageTransactionHandler.cs#L1566-L1605(HandleDelistingNotification): fills the liquidation atsecurity.Price(L1598), the last cached trade, with no awareness that a distribution has been paid on the same holding since that price was cached.Engine/DataFeeds/Enumerators/DelistingEventProvider.cs#L96-L104: theDelistedevent is emitted on the tradable day after the delisting date, so the dividend (ex-date = delisting date) is always applied first.Compare with splits, where the engine does keep the cached mark consistent:
SecurityPortfolioManager.ApplySplit(L856) callsSecurity.ApplySplit->SecurityCache.ApplySplit(Common/Securities/SecurityCache.cs#L623), which rescalesPrice/OHLC/bid/ask and the cachedBaseData. There is no dividend counterpart.Proposed change
Mirror the split handling for cash dividends in
Raw/SplitAdjustedbacktests: after crediting the cash inApplyDividend, adjust the security's cached last data (Price,Open,High,Low,Close,BidPrice,AskPrice, and the cachedBaseDatainstances) down bydividend.Distribution, floored at zero, e.g. aSecurity.ApplyDividend(Dividend)/SecurityCache.ApplyDividend(Dividend)pair. The next real bar overwrites the adjustment, so ordinary dividends are unaffected beyond the midnight-to-open window (where the current behaviour also briefly overstatesTotalPortfolioValueby the dividend amount); for a security that never prints again, the delisting liquidation fills at the ex-distribution price and the double count disappears.A narrower alternative is to have
HandleDelistingNotificationsubtract the distributions applied since the last price update from the fill price, but that leaves the same stale mark visible inPortfolio.TotalPortfolioValuebetween the two events.Open question: whether
SplitAdjustedshould receive the same cache adjustment (it receives the cash today).Checklist
masterbranchSupport conversation: 215476063836125