Skip to content

Daily MarketOnOpen fills with ConstantSlippageModel use same-day Close for slippage, causing look-ahead bias #9753

Description

@3050760440

Expected Behavior

For an equity MarketOnOpen order using Daily TradeBar data:

  • The base fill price may be derived from the fill-day Open.
  • Any slippage applied to that simulated opening fill should depend only on information available at or before the market open.
  • If the Open and all prior inputs remain unchanged, changing only the fill-day Close should not change the MOO fill price.

Actual Behavior

With ConstantSlippageModel(0.001), the observed fill is:

BUY fill  = fill-day Open + fill-day Close × 0.001
SELL fill = fill-day Open - fill-day Close × 0.001

Therefore, an economically opening-auction fill depends on the same day's closing price, which is not available at the market open. This introduces look-ahead bias into the simulated execution price.

Although Daily resolution emits the order event when the completed daily bar becomes available, MarketOnOpenFill prices the order from that bar's Open. Using the same bar's Close as the slippage reference makes the simulated opening execution non-causal.

Controlled Reproduction

The experiment used:

  • Explicit MarketOnOpen orders
  • Equity Daily TradeBar data
  • ConstantSlippageModel(0.001)
  • Raw normalization
  • fill_forward=False
  • extended_market_hours=False
  • daily_precise_end_time=True
  • Fixed synthetic data
  • Fixed LEAN image:
docker.io/quantconnect/lean:18024
sha256:8d35b77bf74bf551f5bbfbaa9d2904c762ea5d1cc8147c18ea322631d5b578e8
linux/amd64

Three runs were performed. Each mutation changed exactly one fill-day Close; the corresponding Open, strategy, configuration, image, order metadata, quantities, fees, and all other data remained unchanged.

Buy control

Baseline fill-day bar:

Date:  2024-01-04
Open:  104
Close: 105
BUY fill: 104.105

Mutation:

Date:  2024-01-04
Open:  104       (unchanged)
Close: 103.5     (only changed field)
BUY fill: 104.1035

Changing only the fill-day Close from 105 to 103.5 changed the BUY fill from 104.105 to 104.1035.

The SELL fill, order type, submission time, fill time, quantity, and fees were unchanged.

Sell control

Baseline fill-day bar:

Date:  2024-01-09
Open:  98
Close: 97
SELL fill: 97.903

Mutation:

Date:  2024-01-09
Open:  98        (unchanged)
Close: 98.5      (only changed field)
SELL fill: 97.9015

Changing only the fill-day Close from 97 to 98.5 changed the SELL fill from 97.903 to 97.9015.

The BUY fill, order type, submission time, fill time, quantity, and fees were unchanged.

Relevant Source Path

The behavior appears to result from the interaction of three components:

  1. EquityFillModel.MarketOnOpenFill uses the TradeBar.Open as the base MOO fill price and then applies the configured slippage model.
  2. ConstantSlippageModel calculates slippage from asset.GetLastData().Value.
  3. For a Daily TradeBar, Value represents the bar's Close.

This produces the empirically observed Open ± Close × slippagePercent result.

Minimal Algorithm

from AlgorithmImports import *
from datetime import date


class DailyMooSlippageCausalityAlgorithm(QCAlgorithm):

    def initialize(self):
        self.set_start_date(2024, 1, 2)
        self.set_end_date(2024, 1, 11)
        self.set_cash(10000)
        self.settings.daily_precise_end_time = True

        self.symbol = self.add_equity(
            "SYN",
            Resolution.DAILY,
            fill_forward=False,
            leverage=1,
            extended_market_hours=False,
            data_normalization_mode=DataNormalizationMode.RAW,
        ).symbol

        security = self.securities[self.symbol]
        security.set_slippage_model(ConstantSlippageModel(0.001))
        security.set_fee_model(ConstantFeeModel(1))

    def on_data(self, data: Slice):
        if self.symbol not in data.bars:
            return

        if self.time.date() == date(2024, 1, 3):
            self.market_on_open_order(self.symbol, 10)

        elif self.time.date() == date(2024, 1, 8):
            self.market_on_open_order(self.symbol, -10)

Run this algorithm against three otherwise identical synthetic datasets:

  1. Baseline.
  2. Change only the 2024-01-04 Close from 105 to 103.5.
  3. Change only the 2024-01-09 Close from 97 to 98.5.

Potential Solution

The important semantic requirement is that slippage for an opening fill must not depend on information unavailable at the open.

Possible approaches include:

  • Letting the fill model provide the pre-slippage reference price to the slippage model.
  • Adding execution context to the slippage-model interface.
  • Handling MOO slippage using a causal opening-price reference.

I do not want to prescribe a specific implementation, but documenting the current behavior alone would not remove the look-ahead bias.

Evidence

The machine-readable comparison file is attached.

comparison.json
SHA-256:
052D8B21DD40638BDDB046B1C6FEC3D416D1924249BE4B9BF927CF145D8AD6C1

Related Issues

This appears related to, but not duplicated by:

Those issues concern MOO accuracy or stale/non-causal fills, but they do not describe the specific fill-day Close dependency caused by Daily TradeBar + MarketOnOpen + ConstantSlippageModel.

Checklist

  • I reproduced the behavior using a fixed LEAN image digest.
  • I isolated the fill-day Close as the only changed input.
  • I reproduced the dependency independently for BUY and SELL fills.
  • I searched existing issues and did not find this exact case.
  • I included a minimal algorithm and machine-readable evidence.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions