Key Concepts
Algorithm Performance
Introduction
The time a backtest takes is mostly decided by how you write the algorithm, not by the machine that runs it. LEAN's runtime scales with the number of data points your algorithm consumes and with the amount of work you do on each one, so the largest speed gains come from subscribing to less data and keeping your event handlers thin.
The following list shows the order to work through when a backtest runs too slowly:
- Measure the algorithm and name the dominant cost before you change any code.
- Reduce the volume of data the algorithm consumes.
- Remove history requests from the code paths that run repeatedly.
- Move expensive work out of the data event handlers.
- Choose the language that suits the event rate of the strategy.
- Size the node, once the preceding steps are done.
The order matters. A strategy that subscribes to minute data for 50 US Equities processes tens of millions of data points over a decade, so halving the resolution or the universe size saves more time than any node upgrade. Node size is last on the list because a single backtest runs on a single node and cannot be split across several of them.
This section covers the performance of the algorithm you write. For the throughput of the engine itself, see the Engine Performance page, which reports the results of the benchmark algorithms QuantConnect runs against LEAN.
Measure First
Profile the algorithm before you optimize it. Guessing at the bottleneck usually leads to changes that alter the strategy without making it faster.
Reproduce the Slowdown on a Short Backtest
Record the original start date, end date, and universe, then shrink both to the smallest backtest that still shows the slowdown. One to three months and a handful of securities is usually enough. A short backtest lets you re-run after each change and compare the same region of the chart.
Enable the Performance Chart
The Performance chart reports CPU usage, RAM usage, and the execution time LEAN spends in each of its subsystems.
The chart is disabled by default.
To enable it, set the Settings.PerformanceSamplePeriod propertyself.settings.performance_sample_period attribute to the sampling period you want:
// Sample the algorithm's resource usage and subsystem timings once a week. Settings.PerformanceSamplePeriod = TimeSpan.FromDays(7);
# Sample the algorithm's resource usage and subsystem timings once a week. self.settings.performance_sample_period = timedelta(7)
Because the chart is off by default, a backtest you already ran has no Performance chart to inspect. Enable the setting across your projects so the data is there when you need it.
Read the Dominant Series
Find the series with the tallest spike, or the highest sustained plateau if there is no single spike, and fix the code path it points to. The following table maps each series to the changes that reduce it:
| Series | What To Change |
|---|---|
| Subscriptions | Request a coarser resolution, subscribe to fewer securities, and drop resolutions the algorithm never reads. |
| Selection | Cap the size of the universe, move expensive queries out of the selection function, and cache results that hold across calls. |
| Slice | Reduce the subscription count, the resolution, and the number of custom data fields. |
| Consolidators | Consolidate fewer securities, consolidate to a coarser period, and share one consolidator across the subscribers that need the same bars. |
| OnData | Move heavy logic to a Scheduled Event and replace hand-written series calculations with indicators. |
| Schedule | Lower the event frequency and cache the intermediate results the handler computes. |
| HistoryDataPoints | Remove history requests from repeated code paths, or request fewer securities, fields, and bars. |
| Securities | Reduce the number of active securities, which is the securities the universe selected plus the ones you hold or have open orders for. |
| ActiveSecurities | Liquidate stale positions and cancel stale orders so securities leave the universe when selection drops them. |
| Transactions | Rebalance less often and batch orders through portfolio targets. |
| SplitsDividendsDelisting | Reduce the universe size or move the corporate action logic out of the handler. |
| WallTime | Read this series to see the real time each sampling period cost, which tells you whether a change actually helped. |
When several series spike together, start with the earliest one in the pipeline.
Subscriptions comes before Consolidators, Consolidators comes before OnData, and Selection comes before Securities and Transactions.
Treat the CPU, ManagedRAM, and TotalRAM series as symptoms until one of the timing series names the code path.
Profile the Python Code
When CPU or RAM is high and no timing series isolates the cost, add a Python profiler to find the expensive function. See Debugging Tools for the full recipe. Remove the profiler once you have the measurement.
Read the Resource Usage Outside the IDE
The following list shows the ways to collect resource usage across many backtests:
- On the Overview tab of the backtest results, click Download Results to get the charts as JSON with UTC timestamps.
- Call the Read Backtest Chart endpoint with the chart name
Performanceto read one backtest's chart programmatically. - Read the
OSclass members from inside the algorithm for spot checks during a run.
Reduce Data Volume
LEAN reads, decompresses, and dispatches every data point your algorithm subscribes to, so the runtime is roughly proportional to the number of data points processed. Cutting the data volume is the change with the largest effect on backtest speed, and it is usually the cheapest one to make.
Request the Coarsest Resolution That Answers the Question
A minute subscription delivers about 390 bars per US Equity per trading day and a daily subscription delivers one. Tick subscriptions deliver several orders of magnitude more than minute. Use daily or hourly data for strategies that make decisions once a day, and reserve tick data for strategies whose logic depends on individual trades and quotes.
// Subscribe to daily data when the strategy only makes one decision a day. UniverseSettings.Resolution = Resolution.Daily;
# Subscribe to daily data when the strategy only makes one decision a day. self.universe_settings.resolution = Resolution.DAILY
Warm Up at the Resolution You Need
The warm-up period streams data through the algorithm like any other period, so it counts toward the data volume. When you set it with a time span alone, LEAN warms up at the resolution of your subscriptions. A year of warm-up on a minute subscription is a year of minute data before the algorithm places its first trade.
Pass the resolution you actually need for the warm-up. Daily indicators and a year of universe selection history, such as a trailing year of PE ratios, only need daily bars.
// Warm up with a year of daily bars instead of a year of the subscription's resolution. SetWarmUp(TimeSpan.FromDays(365), Resolution.Daily);
# Warm up with a year of daily bars instead of a year of the subscription's resolution. self.set_warm_up(timedelta(365), Resolution.DAILY)
If you registered indicators or consolidators for automatic updates, the warm-up resolution must be at or below the lowest resolution they use.
Universe selection runs during the warm-up period too.
When you only want to accumulate selection data and not trade yet, return Universe.UnchangedUniverse.UNCHANGED while the algorithm warms up.
The selection function still receives the data, so you collect what you need without adding and removing securities.
// Record the selection data during warm-up, but leave the universe alone.
private IEnumerable<Symbol> SelectSymbols(IEnumerable<Fundamental> fundamental)
{
RecordPeRatios(fundamental);
if (IsWarmingUp) return Universe.Unchanged;
return fundamental.OrderBy(f => f.ValuationRatios.PERatio).Take(50).Select(f => f.Symbol);
} # Record the selection data during warm-up, but leave the universe alone.
def _select_symbols(self, fundamental):
self._record_pe_ratios(fundamental)
if self.is_warming_up:
return Universe.UNCHANGED
return [f.symbol for f in sorted(fundamental, key=lambda f: f.valuation_ratios.pe_ratio)[:50]]
Cap the Universe Size
Give every universe selection function an explicit limit on the number of securities it returns.
An unbounded selection function can return thousands of securities on a single day, which multiplies the subscription count, the Slice construction cost, and the memory footprint at once.
// Return a fixed number of securities so the subscription count stays bounded. return fundamentals.OrderByDescending(f => f.DollarVolume).Take(50).Select(f => f.Symbol);
# Return a fixed number of securities so the subscription count stays bounded. return [f.symbol for f in sorted(fundamentals, key=lambda f: f.dollar_volume, reverse=True)[:50]]
Filter Option Chains Tightly
An unfiltered Option chain contains every strike and expiry that traded, so the contract count dwarfs the underlying subscription. Narrow the Option universe to the contracts the strategy trades.
// Select the contracts within 3 strikes of the underlying price that expire within 30 days. option.SetFilter(u => u.Strikes(-3, 3).Expiration(0, 30));
# Select the contracts within 3 strikes of the underlying price that expire within 30 days. option.set_filter(lambda u: u.strikes(-3, 3).expiration(0, 30))
The arguments of the Strikesstrikes method are a count of strikes from the at-the-money strike, not a percentage of the underlying price.
Strikes(-3, 3)strikes(-3, 3) selects three strikes below and three strikes above the underlying price.
The default filter selects standard and weekly contracts, so weeklys are in the chain unless you exclude them.
When the strategy only trades standard expiries, call the StandardsOnlystandards_only method to drop the rest.
// Drop the weekly contracts when the strategy only trades standard expiries. option.SetFilter(u => u.Strikes(-3, 3).Expiration(0, 30).StandardsOnly());
# Drop the weekly contracts when the strategy only trades standard expiries. option.set_filter(lambda u: u.strikes(-3, 3).expiration(0, 30).standards_only())
When you need end-of-day contract statistics rather than a live chain, read the Option Universe data instead of subscribing to the contracts.
Chain Universes Instead of Accumulating Subscriptions
To trade Options on a dynamic Equity universe, chain the universes rather than adding contracts by hand. A chained universe adds and removes the Option contracts as the underlying universe changes, so the subscription count tracks the current selection instead of growing with every security the algorithm has ever selected.
Remove Subscriptions You Finished With
Each subscription costs memory and dispatch time for as long as it lives.
Call the RemoveSecurityremove_security method to remove one when the strategy no longer needs it.
The method cancels your open orders for the security and liquidates your holdings, so only call it when you intend to exit the position.
Turn Off Data the Strategy Never Reads
Extended market hours data roughly doubles the bar count for a US Equity subscription. Leave extended market hours off unless the strategy trades in the pre-market or post-market session, and drop any secondary resolution the algorithm subscribes to but never reads.
History Requests
A history request reads and decompresses data outside the streaming data feed.
The work is not shared with the subscriptions the algorithm already has, so every call pays the full cost of loading the data again.
A bulk history request in the Initializeinitialize method can allocate more memory than the node has, which stops the algorithm with a memory error or a timeout that no larger node fixes.
Let LEAN Warm Up Your Indicators
Most history requests exist to fill a rolling calculation before the strategy starts trading. LEAN does that for you. The following list shows the options in order of preference:
- An indicator with a warm-up period, which LEAN feeds from the data it already loads.
- An indicator extension, when the value you need is a function of other indicators.
- The
Security.SessionSecurity.sessionproperty, for open, high, low, close, and volume values LEAN already caches. - A
RollingWindow, only when LEAN has no built-in equivalent.
// Warm up the indicators from the data feed instead of requesting history. Settings.AutomaticIndicatorWarmUp = true; _sma = SMA(_symbol, 200, Resolution.Daily);
# Warm up the indicators from the data feed instead of requesting history. self.settings.automatic_indicator_warm_up = True self._sma = self.sma(self._symbol, 200, Resolution.DAILY)
Keep History Requests Out of Repeated Code Paths
Never call the Historyhistory method inside the OnDataon_data method, inside a loop over the securities in your universe, or inside a universe selection function.
Each of those runs on every time step, so one history request becomes millions over a backtest.
A large HistoryDataPoints series on the Performance chart is the signature of this pattern.
Batch the Requests You Cannot Remove
When the strategy genuinely needs history, make one request for all the securities instead of one request per security. Request only the bars and fields you use.
// Request the history of every symbol in one call. var history = History<TradeBar>(_symbols, 30, Resolution.Daily);
# Request the history of every symbol in one call. history = self.history[TradeBar](self._symbols, 30, Resolution.DAILY)
Iterating the typed enumerable, as in the preceding example, avoids building a DataFrame.
The DataFrame conversion is a large part of the cost of a Python history request, so skip it when you only need to loop over the bars.
Cache the result in a member variable so later time steps reuse it. For data you derive once and reuse across backtests, save it to the Object Store and load it at the start of the algorithm.
Thin Event Handlers
The OnDataon_data method runs on every time step, so any cost inside it is multiplied by the number of time steps in the backtest.
Work that does not need to run that often belongs somewhere else.
Move Periodic Work to Scheduled Events
A strategy that rebalances monthly does not need to evaluate its rebalancing logic on every minute bar. Put that logic in a Scheduled Event and leave the data event handler to the work that genuinely reacts to each bar.
// Run the rebalancing logic once a month instead of on every data event. Schedule.On(DateRules.MonthStart(_symbol), TimeRules.AfterMarketOpen(_symbol, 30), Rebalance);
# Run the rebalancing logic once a month instead of on every data event. self.schedule.on(self.date_rules.month_start(self._symbol), self.time_rules.after_market_open(self._symbol, 30), self._rebalance)
Schedule One Event Per Market, Not One Per Security
The SymbolSymbol you pass to a date rule or time rule only selects the trading calendar the rule follows.
Securities that share a market share that calendar, so every US Equity fires at the same moment.
Schedule one event against any symbol in the market and loop over your securities inside the handler.
Scheduling one event per security multiplies the Schedule series by the size of your universe and gains you nothing.
// One event serves every US Equity because they all follow the same market hours. Schedule.On(DateRules.EveryDay(_spy), TimeRules.BeforeMarketClose(_spy, 10), Rebalance);
# One event serves every US Equity because they all follow the same market hours. self.schedule.on(self.date_rules.every_day(self._spy), self.time_rules.before_market_close(self._spy, 10), self._rebalance)
Add a second event only for securities on a different calendar, such as Futures, Crypto, or a foreign exchange.
Consolidate Only to a Coarser Period
A consolidator that produces bars at the resolution you already subscribed to rebuilds a bar LEAN just gave you.
It costs an update for every bar of every security and returns data you already have.
Read the bar from the data event handler instead.
Consolidators earn their cost when the period you want is coarser than the subscription, such as minute data consolidated into hourly or daily bars.
A large Consolidators series on the Performance chart often points at this mistake.
A consolidator builds the open, high, low, close, and volume of the aggregated bar, so it only pays for itself when you use more than the close. When your logic reads the closing value alone, schedule an event at the end of the period and read the price there. The Scheduled Event costs one call per period, while the consolidator costs an update for every bar in the period.
// Read the closing price once a day instead of consolidating minute bars into daily bars.
Schedule.On(DateRules.EveryDay(_spy), TimeRules.BeforeMarketClose(_spy, 1), () => { var close = Securities[_spy].Price; }); # Read the closing price once a day instead of consolidating minute bars into daily bars. self.schedule.on(self.date_rules.every_day(self._spy), self.time_rules.before_market_close(self._spy, 1), lambda: self._record(self.securities[self._spy].price))
Read the Greeks Instead of Recomputing Them
LEAN evaluates the Option price model lazily and caches the result on the contract.
Read the Greeks from the Option chain in the current slice.
Calling the EvaluatePriceModelevaluate_price_model method yourself repeats work LEAN already did and can dominate the runtime of an Options strategy.
// Read the cached Greeks from the chain instead of re-evaluating the price model.
foreach (var contract in chain) { var delta = contract.Greeks.Delta; } # Read the cached Greeks from the chain instead of re-evaluating the price model.
for contract in chain:
delta = contract.greeks.delta
Batch Your Orders
Every order produces order events that LEAN processes and that your handlers see.
Rebalance less often and express the target portfolio through portfolio targets so LEAN issues the smallest set of orders that reaches the target.
This shows up as a smaller Transactions series.
Don't Submit Orders That Get Rejected
An invalid order is not free. LEAN still creates the order, runs it through the pre-trade checks, raises the order event, and keeps the record for the rest of the backtest, so every rejection costs both time and memory that you never get back. Algorithms that react to a rejection by resizing and resubmitting pay for it several times over.
Check the conditions before you place the order rather than after LEAN refuses it. The Basic Validation page lists what LEAN checks: the security is tradable, the market is open, the last known price is not zero, and the quantity is neither zero nor smaller than the lot size. Size the position from the buying power you have and skip the order when the result rounds to nothing.
// Size the order from the available buying power and skip it when there is nothing to trade. var quantity = CalculateOrderQuantity(symbol, weight); if (quantity != 0) MarketOrder(symbol, quantity);
# Size the order from the available buying power and skip it when there is nothing to trade.
quantity = self.calculate_order_quantity(symbol, weight)
if quantity != 0:
self.market_order(symbol, quantity)
Orders placed while the algorithm is warming up are rejected as well, so guard that code path with the IsWarmingUpis_warming_up property.
Filter Tick Data at the Source
Tick subscriptions deliver both trades and quotes. When the strategy only reads trades, add a security data filter that drops the rest before the data reaches your algorithm. Filtering out quote ticks removes the bid and ask information that market order fill models use, so check that the fills still model your strategy correctly.
Train Machine Learning Models Outside the Time Step
An algorithm must normally process each time step within 10 minutes.
Fit models in the Research Environment, save them to the Object Store, and load them at runtime.
You can go further and precompute the predictions themselves so the algorithm streams them in as a custom universe dataset.
When you have to fit inside the algorithm, use the Traintrain method, which raises that limit for the training session.
Log Sparingly
Every log statement costs execution time and counts against your log quota, so a statement in a handler that runs on every time step can slow an algorithm down and exhaust the quota in a single run.
Never log without a bound inside the OnDataon_data event handler or inside a Scheduled Event that fires often.
Log the condition you are investigating, not every pass through the handler.
Use the Loglog method rather than the Debugdebug method for routine records.
Debug messages go through the messaging system, which rate limits them to protect your browser and slows the algorithm.
Reserve debug statements for the few messages you want to stand out in the terminal.
Gate Diagnostics Behind a Verbosity Level
Rather than adding and removing diagnostic statements each time you investigate something, route them through one method that checks a verbosity level. You keep the statements in place and turn them off for the runs where you do not need them.
// Log the message only when the algorithm runs at or above the given verbosity level.
private void Trace(int level, string message)
{
if (level <= _logLevel) Log(message);
} # Log the message only when the algorithm runs at or above the given verbosity level.
def _trace(self, level, message):
if level <= self._log_level:
self.log(message)
The caller builds the message before the method runs, so string formatting still costs you even when the method discards the result. In the handlers that run on every time step, check the level at the call site so you skip the formatting too.
// Skip building the message when the level is off.
if (_logLevel >= 2) Trace(2, $"{Time}: {symbol} at {price}"); # Skip building the message when the level is off.
if self._log_level >= 2:
self._trace(2, f"{self.time}: {symbol} at {price}")
Don't Log Prices
Logging dataset information is not permitted, and a price log writes one line per bar per security, which is the largest and least useful log a backtest can produce. To see a series, plot it instead.
If you suspect the data itself is wrong, don't hunt for it with log statements across a full backtest. Confirm the values in the Research Environment with a history request for the symbol and period in question, then report the data issue. Attach the notebook, or a short algorithm that runs over the affected days and shows the problem.
Record Trade Information on the Order
To record why a trade happened, pass a tagtag argument to the order method instead of writing a log statement.
The tag travels with the order and appears in the results, so you keep the context of each trade without the cost of logging it.
// Record the reason for the trade on the order instead of in the log. StopMarketOrder(symbol, -quantity, stopPrice, tag: "stop loss");
# Record the reason for the trade on the order instead of in the log. self.stop_market_order(symbol, -quantity, stop_price, tag="stop loss")
Language Choice
LEAN runs on .NET. A C# algorithm compiles into the engine and calls it directly. A Python algorithm runs through Python.NET, which marshals every call that crosses between the two runtimes.
The cost of that boundary is paid per call, not per backtest, so it scales with the event rate of the strategy. A daily-resolution strategy on a few securities crosses the boundary a few thousand times and the overhead is not measurable next to the data loading. A tick-resolution or large-universe strategy crosses it millions of times and the overhead becomes the dominant cost. Write tick-resolution and other high-event-rate strategies in C#. For everything else, choose the language you are most productive in and apply the practices below.
Cache the Objects You Read Repeatedly
Every read of a LEAN object from Python crosses the boundary, so fetching the same object again on every bar is pure overhead.
Resolve it once and read your own copy afterwards.
The on_securities_changed event handler is the natural place to do it.
# Resolve the Security objects once, when the universe changes.
def on_securities_changed(self, changes):
for security in changes.added_securities:
self._securities[security.symbol] = security
for security in changes.removed_securities:
self._securities.pop(security.symbol, None)
# Read from the plain Python dictionary in the event handler.
def on_data(self, data):
for symbol, security in self._securities.items():
price = security.price
Track Order State Instead of Querying It
The self.transactions.get_open_orders() method rebuilds the list of open orders on every call, and it is usually called from the hottest loop in the algorithm.
A strategy that maintains several stop-loss and take-profit orders per security across a large universe pays this cost on every bar.
Maintain your own dictionary of open tickets and update it from the order event handler.
# Update your own view of the open orders when their state changes.
def on_order_event(self, order_event):
if order_event.status.is_closed():
self._open_tickets.pop(order_event.order_id, None)
The same rule applies to any property you read more than once in a time step, including portfolio holdings, security prices, and symbol properties. Read the value into a local variable at the top of the handler, then use the local variable.
In C#, reading a Security object or querying the open orders is an ordinary in-process call with no marshalling cost, so you do not need to cache these values to avoid a boundary crossing.
Push the Arithmetic into Compiled Code
The following list shows how to keep numerical work out of the Python interpreter:
- Use the built-in indicators, which execute as C#, instead of computing the same values in Python.
- Vectorize array work with NumPy rather than looping in Python.
- Apply just-in-time compilation and caching to the pure-Python functions that remain hot.
Node Sizing
Change the node last. A node upgrade is the only step in this list that costs money on every backtest, and it is the step with the smallest effect on a well-written algorithm.
Extra Cores Do Not Speed Up Your Strategy Logic
LEAN is multi-threaded and loads data in parallel, but your algorithm's events fire synchronously in backtesting. As the Threads in LEAN page states, the primary bottleneck to LEAN execution is executing client code.
The cores beyond the first serve LEAN's data loading, not your strategy. A backtest that leaves most of its cores idle is behaving normally, and it tells you the algorithm spends its time in your code rather than waiting on data. You cannot split one backtest across several cores or several nodes by writing the algorithm differently.
Throughput Comes From More Nodes, Not Bigger Ones
One backtest runs on one node. To run more backtests at the same time, add nodes. When your backtests use a fraction of the RAM and cores of the node they run on, several smaller nodes give your organization more total throughput than the same spend on fewer large ones.
Backtesting Node Specifications
The following table shows the specifications of the backtesting node models:
| Name | Number of Cores | Processing Speed (GHz) | RAM (GB) | GPU |
|---|---|---|---|---|
| B-MICRO | 2 | 3.3 | 8 | 0 |
| B2-8 | 2 | 4.9 | 8 | 0 |
| B4-12 | 4 | 4.9 | 12 | 0 |
| B4-16-GPU | 4 | 3 | 16 | 1/3 |
| B8-16 | 8 | 4.9 | 16 | 0 |
The B2-8, B4-12, and B8-16 nodes run at the same clock speed, so single-threaded algorithm code runs at the same speed on all three. What the larger nodes add is RAM headroom and cores for data loading. The B-MICRO and B4-16-GPU nodes run at a lower clock speed, so they execute algorithm code more slowly.
Size the node on the peak memory your algorithm reaches, not the average. LEAN stops an algorithm when its smoothed memory reading crosses the node's limit, as the Memory Metrics page describes. Universe selection and Option chains are the usual sources of memory spikes, so avoid the smallest nodes for those strategies.
Choose a GPU node only when the algorithm offloads computation to the device, such as training a deep learning model with a framework that uses the GPU. It takes time to transfer data to the GPU, and the GPU node runs at a lower clock speed than the standard nodes, so an algorithm that does not use the GPU runs more slowly on it.
Test a Node Before You Commit to It
You don't have to reason about which node your algorithm needs. You can add and remove nodes at any time, so add the model you want to evaluate, run your heaviest strategy on it, and remove it if it doesn't earn its cost.
Adding or removing a node renews your entire subscription period. When you add one, QuantConnect charges you the difference in price between your current subscription and the new one, which covers both the node and the extension of the subscription. When you remove one, the period renews in the same way and your organization receives a pro-rated credit that is applied to your next invoice.