A backtest gives you one number per statistic, computed on one price path: the one that actually happened. That makes it hard to tell a robust edge from a strategy that is tuned to the exact sequence of ticks in your test window. A cheap way to probe that is to re-run the same algorithm over many slightly different versions of history and look at the spread of the results.

We just added a documentation example that does exactly this with a data filter: Perturb Prices for Robustness Tests.

Why a data filter is the right hook

A SecurityDataFilter is usually described as an accept/reject gate, but the filter receives the actual data object and is free to edit it before returning True. The important part is where LEAN runs it: inside the subscription enumerator, in SubscriptionFilterEnumerator.MoveNext(), before the Slice is assembled. Everything downstream therefore sees the edited bar:

So you can inject noise in one place instead of threading a perturbation through every indicator and signal in your algorithm.

The example

import random


class PriceNoiseFilterAlgorithm(QCAlgorithm):

    def initialize(self) -> None:
        self.set_start_date(2024, 9, 3)
        self.set_end_date(2024, 9, 5)
        equity = self.add_equity("AAPL", Resolution.MINUTE)
        # Set the data filter. Change the seed to get a different price path.
        seed = self.get_parameter("seed", 1)
        sigma = self.get_parameter("sigma", 0.0005)
        equity.set_data_filter(PriceNoiseFilter(seed, sigma))
        # The filter perturbs the data before the consolidators update it, so this
        # indicator is built from the perturbed prices.
        self._ema = self.ema(equity.symbol, 20, Resolution.MINUTE)


class PriceNoiseFilter(SecurityDataFilter):

    def __init__(self, seed: int, sigma: float) -> None:
        super().__init__()
        self._seed = seed
        self._sigma = sigma

    def filter(self, vehicle: Security, data: BaseData) -> bool:
        # Seed on the bar time so that the trade and quote subscriptions
        # apply the same shock to the same bar.
        rng = random.Random(f"{self._seed}:{data.end_time}")
        shock = 1 + rng.gauss(0, self._sigma)
        if isinstance(data, TradeBar):
            # The close setter also updates the value property.
            data.open *= shock
            data.high *= shock
            data.low *= shock
            data.close *= shock
        elif isinstance(data, QuoteBar):
            for side in [data.bid, data.ask]:
                if side:
                    side.open *= shock
                    side.high *= shock
                    side.low *= shock
                    side.close *= shock
            # The value property of a QuoteBar isn't derived from the bid and ask
            # properties, so scale it explicitly.
            data.value *= shock
        # Return True (keep) or False (discard).
        return True

The C# version is on the same documentation page.

Design choices worth understanding before you copy it

  • One multiplier per bar, applied to every price field. Scaling the open, high, low, and close by the same positive number keeps the bar internally consistent (the high stays the highest, the low stays the lowest) and keeps the bid/ask spread proportional. Perturbing each field independently produces bars that cannot happen and fill models that behave strangely.
  • The shock is seeded on the bar end time, not drawn from a running generator. A security can have both a trade and a quote subscription, and each one is filtered separately. Deriving the shock deterministically from the seed and the bar end time makes both subscriptions agree on the same bar, and makes the whole run reproducible.
  • Change the seed to get a different path. Because it is a parameter, you can sweep it with the optimizer and read the distribution of Sharpe ratio, drawdown, or whatever statistic you care about, instead of a single point estimate. Sigma is the per-bar relative shock size; keep it small (the default is 5 bps) so that you are testing sensitivity rather than simulating a different asset.

Limitations

  • History requests do not pass through the data filter, so history calls and indicator warm-up from history return the original prices. If your logic mixes history-based and streamed values, the two will be inconsistent.
  • Auxiliary data (splits, dividends, delistings) is skipped by the filter, so corporate actions stay intact.
  • If the filter throws, LEAN reports a runtime error and drops that data point, so keep it cheap and total. It runs on every data point of every subscription.
  • This perturbs prices only. It does not resample the order of returns, so it does not test path-order dependence; it tests sensitivity to small, measurement-level differences.

If you already do robustness testing on LEAN, I would be interested in what you shock and how: a proportional Gaussian on each bar like this one, bootstrapped returns, or a bump to the spread and fill model instead of the data. Does anyone calibrate sigma to the realized volatility of the asset rather than fixing it?