Delisting liquidations fill at midnight instead of the last trading day's close
维护者通常 1 天内回复
评估
- 难度
- 5/5
- 预计耗时
- 一周以上
- 新手友好度
- 42/100
- Issue 类型
- 缺陷
- 描述清晰度
- 描述清楚
- 活跃度
- 冷清
- 技术栈
- csharp
调研方向
从 AlgorithmManager 的退市流程、BrokerageTransactionHandler.HandleDelistingNotification、DelistingEventProvider.GetEvents 和 EquityFillModel.MarketOnCloseFill 开始。针对 Daily、Hour 和 Minute 分辨率运行拟议的 DelistingLiquidationFillTimeRegressionAlgorithm 测试,包括合成的日内数据。完成的标准是,退市持仓通过 MarketOnClose 订单在最后一个常规交易时段收盘时成交,同时仍覆盖 fallback 情况。
由索引模型根据 Issue 内容生成。
描述
Expected Behavior
When the algorithm holds a position in a security that gets delisted (e.g. acquired), the engine should liquidate that holding at the close of the last trading day — a MarketOnClose fill that occurs during regular market hours, at the day's closing price.
Actual Behavior
The delisting liquidation is triggered off the DelistingType.Delisted event, which is emitted on the next tradable day and delivered at midnight (00:00) exchange-time. At that point BrokerageTransactionHandler.HandleDelistingNotification synthesizes an immediate MarketOrder fill at Algorithm.UtcTime (midnight), tagged "Liquidate from delisting".
So a holding in a delisted security is liquidated with a fill stamped at midnight, outside regular trading hours, rather than at the last trading day's close. Besides being unrealistic (you can sell at the last close), the midnight timestamp trips downstream logic that keys on fill time being inside regular hours. In particular, these midnight liquidation fills are a false positive for OrderFillsDuringExtendedMarketHoursAnalysis: it flags the algorithm as filling orders during extended market hours even though the user never placed an extended-hours order — the only offending fill is the engine's own delisting liquidation.
Potential Solution
Why it fills at midnight. Per DelistingEventProvider.GetEvents:
DelistingType.Warningis emitted at the start of the last trading day (eventArgs.Date >= DelistingDate.Date), while the security is still tradable.DelistingType.Delistedis emitted the next tradable day (eventArgs.Date > DelistingDate), stampedDelistingDate.AddDays(1), delivered at midnight. This is the event that currently triggers the liquidation, which is why the fill lands at midnight.
Confirmed empirically with the repro below: the RXDX Warning arrives at 2023-06-16 00:00 (start of the last trading day, Friday) and the Delisted at 2023-06-17 00:00 (Saturday).
Proposed fix — liquidate with a MarketOnClose order at the last trading day's close. The DelistingType.Warning registers the pending delisting. A pass then runs on every time step and, once the day reaches a single resolution-rounded cutoff (and the algorithm still holds the security), submits a MarketOnClose order sized to current holdings, tagged "Liquidate from delisting", so the position is liquidated at that day's close. The cutoff:
latest = (nextMarketClose − MarketOnCloseOrder.SubmissionTimeBuffer)
.RoundDownInTimeZone(highestResolution, exchangeTz, dataTz)
That one rule produces the correct submission time at every resolution (default buffer = 15.5 min):
| Resolution | (16:00 − 15.5min) = 15:44:30 rounded down to… |
Submit at |
|---|---|---|
| Daily | the day → 00:00 |
start of the last trading day (i.e. on the Warning) |
| Hourly | the hour → 15:00 |
1 h before close |
| Minute | the minute → 15:44 |
~16 min before close |
So you do not special-case per resolution — RoundDownInTimeZone(highestResolution) collapses to "submit on the warning" for daily and to a near-close cutoff for intraday data. In all cases the order clears the MarketOnClose submission buffer and fills at that day's close, in regular hours.
Key design points:
- Run the submission pass right after
pendingDelistingsis populated, so the warning is seen the same step it arrives. At daily resolution theWarningarrives at the00:00start-of-day step, and that is also where the order must be submitted. If the pass instead saw the pending delisting a step later, the next step is the16:00close bar — too late (GetNextMarketClose(16:00)rolls to the following day and the order is never submitted before the security is removed). Seeing theWarningat the00:00step is what makes the daily case work. - Size to current holdings and mark closing-only at the cutoff. At the submission step, size the MOC to
-Holdings.Quantityand setsecurity.IsTradable = false(order submission already rejects non-tradable securities). For intraday resolutions this only blocks the final buffer window; for daily it blocks the whole last day (harmless — there is no intraday trading to capture). This guarantees the MOC quantity is final. A shortalgorithm.Debug(...)should explain the rejection. - The MOC fill is time-triggered, not data-gated.
EquityFillModel.MarketOnCloseFillfills as soon asasset.LocalTime >= nextMarketClose, using the last cached close — there is no stale-data guard for MOC (unlike MarketOnOpen). So a delisted security that prints no bar on its last day still fills at the close. - Keep the existing midnight handler as a narrow fallback.
HandleDelistingNotificationstays only for cases the close-MOC can't cover — warm-up, always-open markets (MarketOnClosethrows forIsMarketAlwaysOpen), out-of-scope security types, andDailyPreciseEndTime = false(see below). In the common case it now finds zero holdings and does nothing.
Concrete implementation. A ProcessPendingDelistings method on AlgorithmManager, invoked right after pendingDelistings is updated in the main loop (so the daily start-of-day step is seen the same step the Warning arrives):
private static void ProcessPendingDelistings(IAlgorithm algorithm, List<Delisting> pendingDelistings)
{
for (var i = pendingDelistings.Count - 1; i >= 0; i--)
{
var delisting = pendingDelistings[i];
// Scope to equities; futures/options delistings are handled by their own notification flows.
if (delisting.Symbol.SecurityType != SecurityType.Equity
|| !algorithm.Securities.TryGetValue(delisting.Symbol, out var security))
{
continue;
}
// Nothing to liquidate, or we already submitted the closing order (IsTradable flipped off below).
if (security.Holdings.Quantity == 0 || !security.IsTradable)
{
continue;
}
// MarketOnClose is invalid for always-open markets; warm-up positions already reflect delistings.
if (security.Exchange.Hours.IsMarketAlwaysOpen || algorithm.IsWarmingUp)
{
continue;
}
var configs = algorithm.SubscriptionManager.SubscriptionDataConfigService
.GetSubscriptionDataConfigs(security.Symbol);
if (configs.Count == 0)
{
continue;
}
var nextMarketClose = security.Exchange.Hours.GetNextMarketClose(security.LocalTime, false);
var latest = nextMarketClose.Subtract(MarketOnCloseOrder.SubmissionTimeBuffer)
.RoundDownInTimeZone(configs.GetHighestResolution().ToTimeSpan(), security.Exchange.TimeZone, configs.First().DataTimeZone);
if (security.LocalTime < latest)
{
continue;
}
algorithm.Transactions.CancelOpenOrders(security.Symbol,
"Canceled due to impending delisting. MarketOnClose order submitted to liquidate position.");
var request = new SubmitOrderRequest(OrderType.MarketOnClose, security.Type, security.Symbol,
-security.Holdings.Quantity, 0, 0, algorithm.UtcTime, BrokerageTransactionHandler.LiquidateFromDelistingTag);
algorithm.Transactions.AddOrder(request);
security.IsTradable = false; // closing-only; also prevents re-submission
}
}
"Liquidate from delisting" is shared as a public const string LiquidateFromDelistingTag on BrokerageTransactionHandler and reused by both the existing handler and this MOC so the tag has a single source of truth.
Verification (failing test → fix → passing test). Using the synthetic AAA.1 equity that ships with LEAN (delists 2007-05-18), a regression algorithm buys and holds it through the delisting so the engine liquidates it:
public class DelistingLiquidationFillTimeRegressionAlgorithm : QCAlgorithm
{
public override void Initialize()
{
SetStartDate(2007, 5, 15);
SetEndDate(2007, 5, 25);
SetCash(100000);
Settings.DailyPreciseEndTime = true;
// Resolution comes from config so one algorithm exercises Daily / Hour / Minute.
var resolution = (Resolution)Enum.Parse(typeof(Resolution), Config.Get("delisting-test-resolution", "Daily"), ignoreCase: true);
AddSecurity(SecurityType.Equity, "AAA.1", resolution);
AddSecurity(SecurityType.Equity, "SPY", Resolution.Daily);
}
public override void OnData(Slice slice)
{
// Hold through the delisting; do NOT liquidate on the warning so the engine has to.
if (!Portfolio.Invested && Securities["AAA.1"].IsTradable && slice.Bars.ContainsKey("AAA.1"))
{
SetHoldings("AAA.1", 1);
}
}
}
A unit test runs it at each resolution and asserts the delisting liquidation filled during regular hours.
The Hour and Minute cases use synthetic AAA.1 minute/hour bars added under Data/equity/usa/ (the daily
data already ships with LEAN); no delisted equity in the sample set has intraday data spanning its delisting:
[TestCase("Daily")]
[TestCase("Hour")]
[TestCase("Minute")]
public void DelistingLiquidationFillsAtMarketClose(string resolution)
{
AlgorithmRunner.RunLocalBacktest(nameof(DelistingLiquidationFillTimeRegressionAlgorithm),
new Dictionary<string, string>(), Language.CSharp, AlgorithmStatus.Completed,
customConfigurations: new Dictionary<string, string> { { "delisting-test-resolution", resolution } });
var algorithm = AlgorithmRunner.RegressionSetupHandlerWrapper.Algorithm;
var liquidation = algorithm.Transactions
.GetOrders(o => o.Tag == BrokerageTransactionHandler.LiquidateFromDelistingTag).Single();
var security = algorithm.Securities[liquidation.Symbol];
var localFill = liquidation.LastFillTime.Value.ConvertFromUtc(security.Exchange.TimeZone);
// A midnight delisting fill is outside regular hours and fails this; a close fill passes.
Assert.IsTrue(security.Exchange.Hours.IsOpen(localFill.AddTicks(-1), extendedMarketHours: false),
$"Delisting liquidation filled outside regular hours at {localFill}. Order type: {liquidation.Type}.");
}
Result:
- Before the fix (all resolutions): liquidation is a
Marketorder filling at2007-05-19 00:00(midnight) → test fails. (TheDelistedevent fires at midnight regardless of resolution.) - After the fix (all resolutions): liquidation is a
MarketOnCloseorder filling at2007-05-18 16:00(last trading day's close) → test passes. The resolution-rounded cutoff submits on the warning (Daily), 1 h before close (Hour), and ~16 min before close (Minute); all three fill at the 16:00 close.
Trade-offs to weigh:
- Behavior / results change. Liquidation moves from next-day midnight to the last trading day's close; the fill price becomes the close (± slippage) instead of the last-known price at midnight, and P&L is realized a day earlier. Every regression algorithm holding a delisted name will need its expected statistics regenerated — this is the bulk of the work.
- No trading in the final cutoff window of the delisting security's last day (the whole day at daily resolution; the final ~15.5 min at minute resolution) — a user-facing behavior change.
DailyPreciseEndTime = false. With precise end times off, the daily bar ends at next-day midnight, soLocalTimenever reaches16:00and the MOC fill still lands at midnight. This is inherent to that mode (every fill is at midnight) and is already exempted downstream; the modern default (DailyPreciseEndTime = true) gives the16:00fill. This case stays on the midnight fallback.- Backtest-only by construction.
pendingDelistingsis only tracked in non-live mode (if (!algorithm.LiveMode)), so this stays a backtesting-fidelity fix; live liquidation remains the broker's responsibility. Extending to live would be a larger, separate change.
Reproducing the Problem
Minimal algorithm — holds RXDX (Prometheus Biosciences, acquired by Merck) across its June 2023 delisting:
from AlgorithmImports import *
class LiquidUniverseAlgorithm(QCAlgorithm):
def initialize(self):
self.set_start_date(2023, 5, 25)
self.set_end_date(2023, 6, 25)
self.set_cash(100000)
self.settings.seed_initial_prices = True
self._rxdx = self.add_equity('RXDX', Resolution.DAILY)
self._spy = self.add_equity('SPY', Resolution.DAILY)
self.schedule.on(
self.date_rules.month_start(self._spy),
self.time_rules.at(8, 0),
self._rebalance
)
def _rebalance(self):
targets = [PortfolioTarget(symbol, 0.5) for symbol in [self._spy, self._rxdx]]
self.set_holdings(targets)
In the results, the RXDX liquidation order is tagged "Liquidate from delisting" with a fill time of 2023-06-17T04:00:00Z (00:00 ET / midnight), instead of filling at the close of the last trading day (2023-06-16 16:00 ET).
System Information
The LiquidUniverseAlgorithm can be run in QC Cloud.
The test algorithms were run on my local machine:
- OS: Windows 11 (10.0.26200)
- Runtime: .NET 10
- LEAN:
master
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
- 主要语言
- C#
- 星标
- 22.4k
- 派生
- 5.4k
- 平均合并
- 1 天 22 小时
- 30 天内合并 PR
- 55
环境准备
在浏览器里用你自己的 GitHub 账号启动这个项目的开发容器。
- 提供 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
QuantConnect/Lean 的其他 Issue
-
难度 2/5 1-3 小时 新手友好度 78/100
QuantConnect/Lean#10047 ·
维护者通常 1 天内回复
-
library-request
难度 2/5 1-3 小时 新手友好度 76/100
QuantConnect/Lean#10034 ·
维护者通常 1 天内回复
-
`LeanData.TryParsePath` fails on an absolute universe file path可能已有人在做 @FeiyangP 于 7 天前认领。 未关闭
难度 2/5 1-3 小时 新手友好度 78/100
QuantConnect/Lean#10005 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 72/100
QuantConnect/Lean#9965 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 82/100
QuantConnect/Lean#9955 ·
维护者通常 1 天内回复
查看 QuantConnect/Lean 的全部 Issue
相似的 Issue
-
Skills未关闭wayfinder:grilling
难度 2/5 1-3 小时 新手友好度 64/100
DigiBanks99/lopen#37 ·
-
难度 2/5 1-3 小时 新手友好度 66/100
sebastienros/fluid#1273 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 82/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 65/100
维护者通常 1 天内回复
-
enhancement
难度 2/5 1-3 小时 新手友好度 73/100
KnifeLemon/Filee#1344 ·
维护者通常 1 天内回复