Delisting liquidations fill at midnight instead of the last trading day's close
メンテナーはふだん 1 日以内に返信
評価
- 難易度
- 5/5
- 見積もり時間
- 1週間以上
- 初心者へのやさしさ
- 42/100
- issue の種類
- バグ
- 明瞭さ
- 明確に書かれている
- 活発さ
- 静か
- 技術スタック
- csharp
調査の方向性
AlgorithmManager の delisting フロー、BrokerageTransactionHandler.HandleDelistingNotification、DelistingEventProvider.GetEvents、EquityFillModel.MarketOnCloseFill から始めます。提案された DelistingLiquidationFillTimeRegressionAlgorithm テストを、合成 intraday データを含めて、Daily、Hour、Minute の各解像度で実行します。完了の条件は、delisting 対象の保有ポジションが最後の通常セッションのクローズ時に 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時間
- マージ済み PR(30日)
- 55
環境構築
このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。
- Dockerfile または Docker Compose ファイルあり
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- 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 日以内に返信