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:
- the Security objects and their cached prices,
- your consolidators,
- the automatic indicators you create with the helper methods, and
- on_data.
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?
Alexandre Catarino
The material on this website is provided for informational purposes only and does not constitute an offer to sell, a solicitation to buy, or a recommendation or endorsement for any security or strategy, nor does it constitute an offer to provide investment advisory services by QuantConnect. In addition, the material offers no opinion with respect to the suitability of any security or specific investment. QuantConnect makes no guarantees as to the accuracy or completeness of the views expressed in the website. The views are subject to change, and may have become unreliable for various reasons, including changes in market conditions or economic circumstances. All investments involve risk, including loss of principal. You should consult with an investment professional before making any investment decisions.
To unlock posting to the community forums please complete at least 30% of Boot Camp.
You can continue your Boot Camp training progress from the terminal. We hope to see you in the community soon!