Skip to content

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

@DerekMelchin

Description

@DerekMelchin

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions