Why build custom indicators in Backtrader?
Quantitative strategies rarely derive sustainable edge from standard textbook indicators. Off-the-shelf implementations of Simple Moving Averages (SMA), Bollinger Bands, or the Relative Strength Index (RSI) are ubiquitously tracked, leaving little alpha on liquid historical series. Developing robust quantitative models typically requires engineering bespoke mathematical transformations: volatility-adjusted trend bands, multi-timeframe volume accumulators, adaptive smoothing filters (such as Kaufman AMA or fractal dimension filters), or structural regime classification channels.
When developing in Backtrader, novice quant developers frequently make the architectural error of writing custom mathematical formulas directly inside the Strategy.next() method:
class FragileStrategy(bt.Strategy):
def __init__(self):
self.rolling_window = []
def next(self):
# Anti-pattern: Manual list appending and lookback management
self.rolling_window.append(self.data.close[0])
if len(self.rolling_window) > 20:
self.rolling_window.pop(0)
custom_metric = sum(self.rolling_window) / len(self.rolling_window)
# Strategy execution logic mixed with arithmetic...Embedding ad-hoc indicator logic inside Strategy.next() creates severe systemic problems:
- Zero modularity and reusability: The mathematical formula cannot be shared across multiple strategies, parameter sweeps, or benchmark comparisons without duplicating code.
- Buffer management overhead: Manually maintaining Python lists or deques inside
next()incurs unnecessary memory allocations and computational overhead compared to Backtrader's underlying circular line buffers. - Warm-up synchronization failure: When multiple feeds or indicators require differing lookback buffers, managing synchronization manually invites off-by-one errors and indexing exceptions.
- Broken visualization: Ad-hoc variables calculated inside
Strategy.next()cannot be plotted automatically by Backtrader's charting engine (cerebro.plot()).
By subclassing bt.Indicator, you transform your mathematical formula into a first-class citizen of Backtrader's computation graph. Subclassed indicators hook directly into Backtrader's line-delay dependency tree, support automated warm-up coordination, integrate seamlessly with native plotting, and maintain clean separation of concerns between alpha calculation and order execution.
Anatomy of a Backtrader Indicator
Every custom backtrader custom indicator inherits from bt.Indicator (or a specialized sub-indicator class). Under the hood, Backtrader utilizes Python metaclasses (MetaIndicator and MetaLineSeries) to inspect class attributes before runtime, automatically instantiating line series arrays, declaring parameter bindings, and registering visualization properties.
flowchart TD
subgraph CustomIndicator ["Custom Indicator Class Structure"]
Lines["lines = ('mid', 'upper', 'lower')\nDefines output series"]
Params["params = (('period', 20), ...)\nConfigurable inputs"]
Plot["plotinfo & plotlines\nCharting placement & line styling"]
end
subgraph ExecutionHooks ["Computation Engine"]
Init["__init__()\nDeclarative line arithmetic & child indicators"]
Prenext["prenext()\nExecutes while buffer < minperiod"]
Nextstart["nextstart()\nExecutes exactly once on initial valid bar"]
Next["next()\nIterative bar-by-bar calculation"]
end
CustomIndicator --> ExecutionHooksA standard custom indicator defines three foundational class-level attributes:
1. Declaring lines: The output series
The lines attribute is a tuple of strings defining the sequential time-series buffers produced by the indicator:
class VolatilityBands(bt.Indicator):
lines = ('mid', 'upper', 'lower', 'bandwidth')When Backtrader initializes the class, its metaclass converts each string in lines into a LineSeries instance. These lines can be accessed within the indicator or from an external strategy using multiple syntax conventions:
self.lines.midorself.l.midself.lines.upperorself.l.upper- Direct attribute access:
self.mid(provided there is no name collision with existing instance methods or properties)
If an indicator only outputs a single line, it is customary to name it using the primary indicator label (e.g., lines = ('signal',)). The first declared line becomes the default line when the indicator instance is referenced in scalar comparisons.
2. Declaring params: Configurable arguments
The params attribute defines the hyperparameters that configure the indicator's behavior. Backtrader accepts params as a tuple of tuples (or a dict):
class VolatilityBands(bt.Indicator):
lines = ('mid', 'upper', 'lower')
params = (
('period', 20),
('devfactor', 2.0),
('movav', bt.indicators.SMA),
)Parameters are accessible within instance methods via self.params.<name> or the shorthand self.p.<name>. When instantiating the indicator inside a strategy, callers can override defaults:
class MyStrategy(bt.Strategy):
def __init__(self):
# Override default period and deviation factor
self.bands = VolatilityBands(
self.data.close,
period=30,
devfactor=2.5
)3. Configuring plotinfo and plotlines: Charting placement and styling
Backtrader integrates an automated Matplotlib charting pipeline via cerebro.plot(). You control how and where your custom indicator renders through plotinfo and plotlines.
plotinfo configures the indicator canvas container:
plot(bool, defaultTrue): Whether to plot this indicator.subplot(bool, defaultTrue): IfTrue, renders in a dedicated sub-panel below the price chart (standard for oscillators like RSI or MACD). IfFalse, overlays directly onto the primary price candlestick chart (essential for overlays like moving averages, Bollinger Bands, and Keltner Channels).plotname(str): Label displayed on the chart legend.plotabove(bool, defaultFalse): Ifsubplot=True, controls whether to place the sub-panel above the main price chart.
plotlines configures the visual styling of individual output lines declared in lines:
class VolatilityBands(bt.Indicator):
lines = ('mid', 'upper', 'lower', 'bandwidth')
params = (('period', 20), ('devfactor', 2.0))
plotinfo = dict(
plot=True,
subplot=False, # Overlay directly on the price chart
plotname='Volatility Bands'
)
plotlines = dict(
mid=dict(color='blue', ls='--'),
upper=dict(color='red', ls='-'),
lower=dict(color='green', ls='-'),
bandwidth=dict(_plotskip=True), # Suppress plotting this diagnostic line
)The special key _plotskip=True instructs the plotting renderer to omit that specific line, allowing you to emit secondary numerical lines for trading logic without cluttering the visualization.
Declarative __init__ vs. Iterative next()
Backtrader offers two distinct architectural paradigms for computing indicator logic: Declarative line operations inside __init__() and Iterative bar-by-bar execution inside next(). Understanding the distinction between these two modes is vital for writing performant, bug-free indicators.
┌──────────────────────────────────────────────┐
│ Backtrader Custom Indicator Design │
└──────────────────────┬───────────────────────┘
│
┌─────────────────────────────┴─────────────────────────────┐
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ Declarative __init__ │ │ Iterative next() │
├─────────────────────────────┤ ├─────────────────────────────┤
│ • Vector-like Line bindings │ │ • Imperative relative slice │
│ • Pure mathematical DAG │ │ • Dynamic state machines │
│ • Auto minperiod resolution │ │ • Requires addminperiod() │
│ • Child indicator chaining │ │ • prenext / nextstart hooks │
└─────────────────────────────┘ └─────────────────────────────┘Paradigm A: Declarative math in __init__()
In declarative mode, all mathematical relationships are established once during the strategy initialization phase (__init__). Backtrader overloads Python operators (+, -, *, /, >, <, ==, abs()) across LineSeries objects.
class DeclarativeSpread(bt.Indicator):
lines = ('spread', 'ratio')
params = (('period', 14),)
def __init__(self):
sma = bt.indicators.SMA(self.data.close, period=self.p.period)
# Declarative line bindings: Evaluated lazily as bars advance
self.lines.spread = self.data.close - sma
self.lines.ratio = self.data.close / smaWhen you write self.data.close - sma, Backtrader does not immediately evaluate numbers across an array. Instead, it creates a delayed-evaluation line operation object that is assembled into a Directed Acyclic Graph (DAG).
Key benefits of declarative mode:
- Speed: Built-in C-optimized and native line operations execute faster than Python-level loops.
- Automatic Warm-up: Backtrader automatically determines the minimum lookback period by traversing child indicators.
- Clarity: Concise mathematical syntax with minimal boilerplate.
Paradigm B: Iterative bar-by-bar execution in next()
When your algorithm involves non-linear transformations, iterative accumulation loops, conditional branching across historical bars, or recursive formulas that cannot be vectorized into line operators, you must implement iterative calculation hooks.
In iterative mode, Backtrader steps through bars sequentially using relative indexing:
self.data.close[0]: The value at the current bart.self.data.close[-1]: The value at the previous bart-1.self.lines.myline[0] = value: Assigning the computed result to the current bar's output line.
To govern the lifecycle before and during minimum buffer fulfillment, Backtrader provides three distinct execution methods:
| Lifecycle Method | Execution Timing | Purpose |
|---|---|---|
prenext(self) | Every bar where \text{current\_bars} < \text{minperiod} | Optional: Initialize state, seed buffers, or track warm-up progress. |
nextstart(self) | Exactly once on the bar where \text{current\_bars} == \text{minperiod} | Critical: Seed recursive algorithms (e.g., initial SMA seed for Wilder's smoothing). Defaults to calling self.next(). |
next(self) | Every bar where \text{current\_bars} \ge \text{minperiod} | Primary iterative calculation loop. |
class RecursiveMomentum(bt.Indicator):
lines = ('momentum',)
params = (('period', 10), ('smoothing', 0.1))
def __init__(self):
# Manually declare minimum period requirement!
self.addminperiod(self.p.period)
def prenext(self):
# Called on bars 0 through 8 (for period=10)
pass
def nextstart(self):
# Called exactly once on bar index 9
# Calculate initial seed value using simple average
initial_val = sum(self.data.close.get(ago=0, size=self.p.period)) / self.p.period
self.lines.momentum[0] = initial_val
def next(self):
# Called on bar 10 and all subsequent bars
prev = self.lines.momentum[-1]
delta = self.data.close[0] - self.data.close[-self.p.period + 1]
self.lines.momentum[0] = prev * (1.0 - self.p.smoothing) + delta * self.p.smoothingThe warm-up period trap: Managing addminperiod()
The single most common bug when writing custom Backtrader indicators is the warm-up period trap.
How Backtrader calculates minimum periods
Every LineSeries and Indicator possesses an internal attribute: _minperiod. This integer represents the number of historical bars required before the line can produce a mathematically valid result.
When you compose standard sub-indicators declaratively in __init__(), Backtrader inspects the child indicators and computes the combined lookback automatically:
\text{Combined MinPeriod} = \text{Child MinPeriod} + \text{Parent Lookback} - 1For example, computing a 10-period SMA of a 14-period RSI yields:
14 + 10 - 1 = 23 \text{ bars minimum period}Until 23 bars have been ingested, Backtrader will not invoke next() on the parent strategy, preventing invalid computations.
Bar Timeline:
0 5 10 15 20 22 23 30
├─── ... ─┼─── ... ─┼─── ... ─┼─── ... ─┼────┼────┼─── ... ─┤
[ RSI warming up (14 bars) ]
[ SMA warming up (10 bars) ]
▲
└── Strategy next() begins here (Bar 23)The silent indexing trap in next()
When you bypass declarative line operators and write custom indexing logic inside next(), Backtrader cannot inspect your Python code to infer your lookback requirements.
Consider this flawed indicator:
class BuggySpread(bt.Indicator):
lines = ('spread',)
params = (('lookback', 20),)
def next(self):
# DANGER: Backtrader assumes _minperiod == 1 by default!
# On bar 5, accessing index -19 raises an IndexError or reads corrupt buffer memory!
self.lines.spread[0] = self.data.close[0] - self.data.close[-self.p.lookback + 1]Because __init__() did not instantiate any child indicators, Backtrader sets _minperiod = 1. Consequently, Backtrader triggers next() immediately on the very first bar (t = 0). When the code attempts to read self.data.close[-19], Backtrader throws an IndexError or accesses uninitialized circular buffer entries.
The solution: self.addminperiod(N)
To communicate your lookback requirement to Cerebro, you must explicitly call self.addminperiod() inside __init__():
class SafeSpread(bt.Indicator):
lines = ('spread',)
params = (('lookback', 20),)
def __init__(self):
# Register the lookback requirement with the Cerebro engine
self.addminperiod(self.p.lookback)
def next(self):
# Guaranteed safe: next() will not fire until at least 20 bars exist
self.lines.spread[0] = self.data.close[0] - self.data.close[-self.p.lookback + 1][!WARNING] Calling
self.addminperiod(N)increases the indicator's_minperiodto at leastN. If your indicator combines both declarative child indicators and iterative lookbacks,self.addminperiod()ensures Cerebro waits for the largest required warm-up buffer across all components.
Complete runnable code example: Building a Custom Volatility Band Indicator
Below is a self-contained, fully runnable script demonstrating a professional custom indicator: the Bespoke Volatility Band (BVB).
This indicator combines:
- A declarative Exponential Moving Average (
EMA) centerline. - A declarative Average True Range (
ATR) for volatility scale. - An iterative bar-by-bar calculation that computes a normalized Bandwidth Ratio and evaluates a historical Volatility Squeeze Flag across a custom lookback buffer.
- Custom
plotinfoandplotlinesstyling. - Unit verification against synthetic OHLCV data.
import datetime
import numpy as np
import pandas as pd
import backtrader as bt
class BespokeVolatilityBand(bt.Indicator):
"""
Bespoke Volatility Band (BVB):
- Centerline: Exponential Moving Average (EMA) of Close.
- Upper/Lower Bands: Centerline +/- (ATR * devfactor).
- Bandwidth: (Upper - Lower) / Centerline.
- Squeeze: Boolean flag (1.0 or 0.0) active when Bandwidth is at
its minimum over the last 'squeeze_lookback' bars.
"""
lines = ('mid', 'upper', 'lower', 'bandwidth', 'squeeze')
params = (
('period', 20),
('devfactor', 2.0),
('squeeze_lookback', 10),
)
plotinfo = dict(
plot=True,
subplot=False, # Overlay directly on the primary price candlestick chart
plotname='Bespoke Volatility Bands'
)
plotlines = dict(
mid=dict(color='navy', ls='--'),
upper=dict(color='crimson', ls='-'),
lower=dict(color='forestgreen', ls='-'),
bandwidth=dict(_plotskip=True), # Suppressed from price chart overlay
squeeze=dict(_plotskip=True), # Suppressed from price chart overlay
)
def __init__(self):
# 1. Declarative math: Centerline and Volatility spread
self.lines.mid = bt.indicators.EMA(self.data.close, period=self.p.period)
self.atr = bt.indicators.ATR(self.data, period=self.p.period)
self.lines.upper = self.lines.mid + (self.atr * self.p.devfactor)
self.lines.lower = self.lines.mid - (self.atr * self.p.devfactor)
self.lines.bandwidth = (self.lines.upper - self.lines.lower) / self.lines.mid
# 2. Inform Backtrader about the iterative squeeze lookback requirement
# Total minperiod will be the maximum of ATR/EMA warm-up + squeeze_lookback
self.addminperiod(self.p.period + self.p.squeeze_lookback)
def next(self):
# 3. Iterative calculation: Scan historical bandwidth values
current_bw = self.lines.bandwidth[0]
# Extract past 'squeeze_lookback' bandwidth values (including current bar)
is_squeeze = True
for i in range(1, self.p.squeeze_lookback):
if self.lines.bandwidth[-i] <= current_bw:
is_squeeze = False
break
self.lines.squeeze[0] = 1.0 if is_squeeze else 0.0
class VolatilityStrategy(bt.Strategy):
"""
Consumes the custom BespokeVolatilityBand indicator to trade breakouts
following volatility squeeze conditions.
"""
params = (
('band_period', 20),
('devfactor', 2.0),
('squeeze_lookback', 10),
)
def __init__(self):
self.bvb = BespokeVolatilityBand(
self.data,
period=self.p.band_period,
devfactor=self.p.devfactor,
squeeze_lookback=self.p.squeeze_lookback
)
self.order = None
self.eval_count = 0
def next(self):
self.eval_count += 1
close = self.data.close[0]
upper = self.bvb.upper[0]
lower = self.bvb.lower[0]
squeeze = self.bvb.squeeze[0]
# Log initial bar evaluation to verify warm-up synchronization
if self.eval_count == 1:
print(f"Strategy first executed at Bar {len(self.data)} | "
f"Close={close:.2f}, Mid={self.bvb.mid[0]:.2f}, "
f"Upper={upper:.2f}, Lower={lower:.2f}")
if not self.position:
# Long entry on breakout above upper band after a squeeze
if close > upper and self.bvb.squeeze[-1] == 1.0:
self.order = self.buy()
else:
# Exit position when price reverts below the centerline
if close < self.bvb.mid[0]:
self.order = self.close()
def generate_synthetic_data(n_bars: int = 250) -> pd.DataFrame:
"""Generates reproducible geometric Brownian motion OHLCV data."""
np.random.seed(42)
start_date = datetime.datetime(2025, 1, 1)
dates = [start_date + datetime.timedelta(days=i) for i in range(n_bars)]
returns = np.random.normal(0.0005, 0.015, n_bars)
price = 100.0 * np.exp(np.cumsum(returns))
high = price * (1.0 + np.abs(np.random.normal(0.005, 0.003, n_bars)))
low = price * (1.0 - np.abs(np.random.normal(0.005, 0.003, n_bars)))
open_p = low + (high - low) * np.random.uniform(0.2, 0.8, n_bars)
volume = np.random.randint(100000, 5000000, n_bars).astype(float)
df = pd.DataFrame({
'open': open_p,
'high': high,
'low': low,
'close': price,
'volume': volume
}, index=pd.DatetimeIndex(dates))
return df
if __name__ == '__main__':
# 1. Initialize Cerebro engine
cerebro = bt.Cerebro()
cerebro.broker.setcash(100000.0)
# 2. Ingest synthetic historical bars
raw_df = generate_synthetic_data(n_bars=200)
data_feed = bt.feeds.PandasData(dataname=raw_df)
cerebro.adddata(data_feed)
# 3. Register Strategy with Custom Indicator
cerebro.addstrategy(VolatilityStrategy)
# 4. Execute backtest
print("Running Backtrader engine...")
results = cerebro.run()
strat = results[0]
# 5. Numerical validation of indicator output
print(f"\nExecution Summary:")
print(f"Total bars processed: {len(raw_df)}")
print(f"Total strategy evaluation bars: {strat.eval_count}")
print(f"Final portfolio value: ${cerebro.broker.getvalue():,.2f}")
# Assert that no NaN or uninitialized values contaminated the strategy
assert strat.eval_count > 0, "Strategy never fired!"
assert not np.isnan(strat.bvb.mid[0]), "Indicator mid line contains NaN!"
assert not np.isnan(strat.bvb.upper[0]), "Indicator upper line contains NaN!"
print("\n[VALIDATION PASSED]: Custom indicator initialized and synchronized cleanly.")Explanation of execution flow
- Declarative Composition:
self.lines.mid = bt.indicators.EMA(...)builds the centerline. The upper and lower bands are created via vectorized line arithmetic. - Buffer Registration:
self.addminperiod(self.p.period + self.p.squeeze_lookback)instructs Cerebro to delay callingnext()until both the 20-period EMA/ATR and the 10-period squeeze lookback have sufficient history (20 + 10 = 30bars). - Iterative Squeeze Scanning: Inside
next(), relative negative indexing (self.lines.bandwidth[-i]) safely compares recent bandwidth values without risking out-of-boundsIndexErrorexceptions. - Selective Plotting:
_plotskip=Truekeeps diagnostic lines (bandwidthandsqueeze) off the candlestick overlay while retaining them for strategy logic.
Moving to modern indicator paradigms
Backtrader's line-based metaclass architecture was innovative when designed in 2015. However, modern quantitative engineering workflows have largely shifted toward decoupled, vectorized indicator pipelines.
The friction of Backtrader's indicator engine
As quantitative models scale in complexity, Backtrader's custom indicator paradigm presents noticeable architectural bottlenecks:
- Metaclass Opacity: Heavy reliance on metaclasses makes debugging difficult. Stack traces originating within
LineIteratororLineSeriesobscure standard Python tracebacks. - Python Overhead: Stepping through iterative loops bar-by-bar in pure Python incurs significant execution latency when running thousands of parameter optimization combinations.
- Lookahead Vulnerabilities: Negative indexing (
[-1],[0]) mixed with declarative line bindings can lead to accidental indexing errors if lookback offsets are miscalculated.
Modern vectorization: pandas-ta and OpenBacktesting
In modern quantitative research architectures, indicator calculation is cleanly decoupled from event simulation:
- Precomputed Vectorization: Indicators are computed upfront across the entire historical DataFrame using optimized C, NumPy, or Numba kernels (via libraries like
pandas-ta-classic). - Explicit Nullity / Validity: Rather than managing hidden
_minperiodvariables, indicators produce standard IEEENaNvalues during warm-up periods. Strategies query explicit.validflags or filter ondf.dropna(). - Decoupled Bar Execution: Frameworks like OpenBacktesting (v0.2.1) accept these vectorized features and evaluate order execution logic (market, limit, stop, stop-limit, trailing stop, OCO) strictly bar-by-bar, eliminating lookahead bias while preserving raw execution speed.
| Feature / Dimension | Backtrader Custom Indicators | Modern Architecture (OpenBacktesting v0.2.1) |
|---|---|---|
| Calculation Engine | Custom LineSeries DAG + Python iterative loops | Vectorized NumPy / pandas-ta-classic upfront |
| Warm-Up Management | Implicit _minperiod and manual addminperiod() | Explicit NaN handling and boolean .valid masks |
| Execution Loop | Coupled indicator and strategy stepping | Pure single-asset bar-by-bar execution loop |
| Debugging Experience | Complex metaclass stack traces | Standard Python dataclasses & pandas DataFrames |
| Order Execution Types | Market, Limit, Stop, StopLimit | Market, Limit, Stop, Stop-Limit, Trailing Stop, OCO |
| Parameter Search | Multiprocessing grid search | SQLite-backed Trial persistence, Grid & SAMBO search |
| Visualization | Matplotlib window plots via cerebro.plot() | Interactive browser-based OpenBacktesting Studio |
Summary checklist for custom indicator authors
Before deploying a custom indicator into an event-driven backtesting environment, systematically audit your implementation against this engineering checklist:
flowchart TD
Start["Authoring Custom Indicator"] --> Step1{"1. Line & Param Declarations"}
Step1 -->|"Missing outputs or defaults"| Fix1["Define lines = ('...',)\nDefine params = ((..., ...),)"]
Step1 -->|"Complete"| Step2{"2. Plotting Placement"}
Step2 -->|"Price Overlay?"| P1["Set subplot=False in plotinfo"]
Step2 -->|"Sub-panel Oscillator?"| P2["Set subplot=True in plotinfo"]
P1 --> Step3{"3. Calculation Mode"}
P2 --> Step3
Step3 -->|"Pure Line Operators"| Declarative["Declarative __init__\nAuto minperiod propagation"]
Step3 -->|"Iterative Relative Slices"| Iterative["Iterative next()\nMUST invoke self.addminperiod(N)"]
Declarative --> Step4{"4. Lookahead Audit"}
Iterative --> Step4
Step4 -->|"Accessed index > 0?"| Fix4["VIOLATION: Remove positive index references"]
Step4 -->|"Indexed <= 0 only"| Step5["5. Seed & Zero-Division Guards"]
Step5 --> Step6["6. End-to-End Validation Run\nVerify eval_count & assert notna"]Verification checklist
- Verify output line declarations:
Ensure all calculated time-series are declared in
lines = (...). Avoid saving dynamic state in ad-hoc instance variables (self.my_var = []) if that state represents a temporal series. - Explicitly register lookback buffers:
If your code accesses relative historical bars inside
next()(e.g.,self.data.close[-k]), always callself.addminperiod(k + 1)in__init__(). - Audit lookahead boundary safety:
Never access positive index offsets (e.g.,
self.data.close[1]). Ensure orders generated from indicator values at bartfill strictly on bart+1's open or subsequent ticks. - Guard against zero-division exceptions:
When computing ratio indicators (such as Bandwidth, RSI, or Chande Momentum), always include conditional floor checks (
max(denominator, 1e-8)) to prevent simulation halts during zero-volatility sessions. - Configure plot line visibility:
Use
_plotskip=Trueinplotlinesfor intermediate helper lines or discrete flags to maintain clean, readable Matplotlib charts. - Validate against deterministic baselines: Always run your custom indicator on a known, reproducible OHLCV test dataset to assert that output values match independent calculations from pandas or TA-Lib down to machine precision.
Further reading and related resources
- Backtrader Tutorial: Build and Inspect Your First Strategy — Detailed breakdown of Cerebro, line iterators, and event-driven backtesting mechanics.
- Backtrader RSI: Wilder Smoothing, Warm-Up, and Reproducible Values — In-depth mathematical audit of indicator warm-up buffers and smoothing variations.
- Lookahead Bias in Backtesting: Detection and Prevention — Learn how improper indicator slicing corrupts simulation validity.
- OpenBacktesting Documentation — Next-generation bar-by-bar single-asset backtesting with SQLite parameter persistence and OpenBacktesting Studio.
