Delisting liquidations fill at midnight instead of the last trading day's close

オープン
#9,512 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@Divyansh151005 がすでに取り組んでいます。

2026年10月21日 から。

  • #9932 @Divyansh151005 による — マージされずにクローズ

評価

難易度
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.Warning is emitted at the start of the last trading day (eventArgs.Date >= DelistingDate.Date), while the security is still tradable.
  • DelistingType.Delisted is emitted the next tradable day (eventArgs.Date > DelistingDate), stamped DelistingDate.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 pendingDelistings is populated, so the warning is seen the same step it arrives. At daily resolution the Warning arrives at the 00:00 start-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 the 16:00 close bar — too late (GetNextMarketClose(16:00) rolls to the following day and the order is never submitted before the security is removed). Seeing the Warning at the 00:00 step 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.Quantity and set security.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 short algorithm.Debug(...) should explain the rejection.
  • The MOC fill is time-triggered, not data-gated. EquityFillModel.MarketOnCloseFill fills as soon as asset.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. HandleDelistingNotification stays only for cases the close-MOC can't cover — warm-up, always-open markets (MarketOnClose throws for IsMarketAlwaysOpen), out-of-scope security types, and DailyPreciseEndTime = 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 Market order filling at 2007-05-19 00:00 (midnight) → test fails. (The Delisted event fires at midnight regardless of resolution.)
  • After the fix (all resolutions): liquidation is a MarketOnClose order filling at 2007-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:

  1. 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.
  2. 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.
  3. DailyPreciseEndTime = false. With precise end times off, the daily bar ends at next-day midnight, so LocalTime never reaches 16:00 and 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 the 16:00 fill. This case stays on the midnight fallback.
  4. Backtest-only by construction. pendingDelistings is 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 master branch
  • 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

環境構築

Codespaces で開く

このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

QuantConnect/Lean のほかの issue

QuantConnect/Lean の issue をすべて見る

似ている issue

C# の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。