Experiments
An experiment is a single end-to-end backtest run. It binds together a set of strategies, a universe of symbols, a portfolio definition, a benchmark, an exchange model and an engine configuration into one reproducible unit. Every experiment is persisted to storage so it can be reopened, compared and re-analysed long after the original run finished.
You configure an experiment in the Experiment tab of the application,
or programmatically via ExperimentConfig and Experiment. Pass the
configuration first and runtime strategy and indicator objects separately, just as Session accepts a
SessionConfig followed by its runtime strategy and indicators:
from backtide import DataExpConfig, Experiment, ExperimentConfig
from backtide.indicators import SimpleMovingAverage
from backtide.strategies import BuyAndHold
config = ExperimentConfig(
data=DataExpConfig(
symbols=["AAPL"],
interval="1d",
start_date="2020-01-01",
end_date="2024-12-31",
full_history=False,
)
)
result = Experiment(
config,
strategies=[BuyAndHold()],
indicators=[SimpleMovingAverage(20)],
).run()
Strategies and indicators can be passed as a stored name, an instance (the
class name is used as display name), a dict[name, instance], or any list
mixing those forms. Instances are used directly and not persisted to disk. They stay outside
ExperimentConfig because
arbitrary Python objects cannot be represented faithfully in TOML or JSON. The
corresponding strategy and indicator fields inside the configuration contain serializable library
names so saved experiments remain portable and reproducible. Metrics are different by design:
built-in strings and custom metric objects share the single ExperimentConfig.metrics list. See
Metrics for every exact built-in key.
Lifecycle
When Experiment.run is invoked, the engine runs the following phases in
order. Failures in any phase emit warnings (visible on the results page) but
do not stop the run unless they leave it without a single tradeable bar.
- Resolve instrument profiles for every symbol and the configured benchmark.
- Download any missing OHLCV bars on the chosen interval, clamped to
start_date/end_dateif set. - Load bars from storage and align them onto a master timeline (the union
of all symbol timestamps, sorted). Empty bars are filled according to
EmptyBarPolicy. - Compute indicators once over the full dataset.
- Run strategies. Each strategy gets its own portfolio, order book, equity log, and trade log.
- Persist the aggregate result and per-strategy artifacts to the database and return them to the caller.
Configuration sections
ExperimentConfig groups seven typed sections plus the ordered metrics list. They map to the
tabs in the application's experiment page.
| Section | What it controls |
|---|---|
GeneralExpConfig |
Name, tags, free-text description. |
DataExpConfig |
Instrument type, symbols, date range, interval. |
PortfolioExpConfig |
Initial cash, base currency, starting positions. |
StrategyExpConfig |
Selected strategies and the benchmark symbol. |
IndicatorExpConfig |
Extra indicators to compute on top of the auto-injected ones. |
metrics |
Built-in metric keys and custom metric objects, in display and ranking order. |
ExchangeExpConfig |
Commission, slippage, allowed order types, margin, short selling, currency conversion. |
EngineExpConfig |
Warmup period, trade-on-close, risk-free rate, exclusive orders, RNG seed, empty-bar policy. |
The full TOML representation is what's stored next to every experiment under
<storage_path>/experiments/<id>/config.toml. You can re-create a config from
disk with ExperimentConfig.from_toml(...). Selected metrics have their own section immediately
after indicators:
[indicators]
indicators = []
[metrics]
selected = ["sharpe", "total_return", "max_dd"]
Older files with a root-level metrics = [...][] list remain readable.
Benchmark
If StrategyExpConfig.benchmark is non-empty, the engine:
- Folds the benchmark symbol into the data download list so its bars are available for every strategy.
- Auto-injects an extra strategy run named
Benchmarkthat holds a pure passiveBuyAndHold(<SYMBOL>)over the same window. This is the series used to compute alpha. The benchmark run ignores starting positions. - Does not let the benchmark symbol leak into other strategies. Only the symbols you explicitly added in the data tab are visible to user strategies; if you want a strategy to also trade the benchmark, add it to the symbol list.
Margin trading
Margin trading lets you borrow funds from the broker to open positions larger than your cash balance. In other words, you can control more shares (or contracts) than you could afford outright, amplifying both gains and losses.
How it works in Backtide
Margin is controlled through ExchangeExpConfig. By default, it is disabled
(allow_margin=False), meaning you can only buy what your available cash covers.
| Parameter | Default | Description |
|---|---|---|
allow_margin |
False |
Master switch. Set to True to enable margin. |
max_leverage |
2.0 |
Maximum ratio of total exposure to equity. A value of 2.0 means you can control up to twice your equity. |
initial_margin |
50.0 |
Percentage of the order's notional value that must be covered by equity at the time the order is placed. At 50 % and a $10 000 purchase, you need at least $5 000 in equity. |
maintenance_margin |
25.0 |
Minimum equity percentage that must be maintained at all times. If equity drops below this threshold, the engine issues a margin call. |
margin_interest |
0.0 |
Annualised interest rate on borrowed funds. Accrued daily and deducted from the portfolio's cash balance. |
raise_on_margin_limit |
False |
When True, the engine raises an error if an order would breach max_leverage or if equity falls below maintenance_margin. When False, orders are auto-shrunk or rejected with a warning instead. |
from backtide import (
DataExpConfig,
ExchangeExpConfig,
Experiment,
ExperimentConfig,
GeneralExpConfig,
)
from backtide.strategies import SmaCrossover
config = ExperimentConfig(
general=GeneralExpConfig(name="SMA crossover with 2x margin"),
data=DataExpConfig(symbols=["AAPL"], interval="1d"),
exchange=ExchangeExpConfig(
allow_margin=True,
max_leverage=3.0,
initial_margin=50.0,
maintenance_margin=25.0,
margin_interest=8.0,
),
)
result = Experiment(config, strategies=[SmaCrossover()]).run()
What to consider
Amplified losses
Margin amplifies losses just as much as gains. A 2x leveraged position that drops 25% wipes out 50% of your equity — and at higher leverage the numbers escalate fast.
- Margin calls. When your equity falls below the
maintenance_marginpercentage the engine triggers a margin call. Withraise_on_margin_limit=False(the default), the position is reduced automatically and a warning is logged. Withraise_on_margin_limit=True, the run aborts so you can investigate. - Interest costs. Borrowed money is not free. The
margin_interestrate is charged annually but accrued daily, eroding your returns even on flat days. Make sure your strategy's expected return exceeds the borrowing cost. - Max leverage. Start with a low
max_leverage(e.g., 1.5–2.0) and increase only after verifying that drawdowns remain tolerable. In live trading, most retail brokers enforce similar limits. - Backtesting bias. Margin strategies that look great in a backtest can blow up in practice because backtests don't capture extreme events like flash crashes, exchange halts, or liquidity gaps that prevent timely liquidation.
Short selling
Short selling (or shorting) means selling a security you do not own, with the intention of buying it back later at a lower price. You profit when the price falls and lose when it rises. Short selling is essential for strategies that need to express bearish views or hedge long exposure.
How it works in Backtide
Short selling is controlled by two fields in ExchangeExpConfig:
| Parameter | Default | Description |
|---|---|---|
allow_short_selling |
False |
Master switch. When False, any sell order for a symbol you do not hold is rejected. |
borrow_rate |
0.0 |
Annualised cost of borrowing shares for a short position. Accrued daily and deducted from cash, simulating the stock-loan fee a real broker would charge. |
raise_on_short_violation |
False |
When True, the engine raises an error and aborts the run if a sell order would create or increase a short position while allow_short_selling is False. When False, such orders are silently rejected with a warning instead. |
When enabled, a strategy can place a sell order with a negative quantity for a symbol it does not currently hold. The engine:
- Credits the portfolio cash with the proceeds of the sale (price x quantity).
- Records a negative position in the symbol.
- Accrues the
borrow_ratedaily against the notional value of the short. - Closes the short when the strategy places an equal-and-opposite buy order, or when the engine auto-liquidates all positions at the end of the simulation.
from backtide import DataExpConfig, ExchangeExpConfig, Experiment, ExperimentConfig, Order
from backtide.indicators import RelativeStrengthIndex
from backtide.strategies import BaseStrategy
class ShortOnRsiExtreme(BaseStrategy):
"""Short when RSI > 80, cover when RSI < 50."""
def required_indicators(self):
return [RelativeStrengthIndex(14)]
def evaluate(self, data, portfolio, state, indicators):
orders = []
for symbol, df in data.items():
rsi = indicators["RSI_14"][symbol]
if rsi is None or len(rsi) < 1:
continue
rsi = rsi.iloc[-1]
qty = portfolio.positions.get(symbol, 0)
if rsi > 80 and qty >= 0:
orders.append(Order(symbol=symbol, order_type="market", quantity=-100))
elif rsi < 50 and qty < 0:
orders.append(Order(symbol=symbol, order_type="market", quantity=-qty))
return orders
config = ExperimentConfig(
data=DataExpConfig(symbols=["AAPL"], interval="1d"),
exchange=ExchangeExpConfig(allow_short_selling=True, borrow_rate=3.5),
)
result = Experiment(config, strategies=[ShortOnRsiExtreme()]).run()
What to consider
Unlimited downside
When you buy a stock, the most you can lose is your investment (the price drops to zero). When you short a stock, your potential loss is theoretically unlimited since the price can rise forever.
- Borrow costs. The
borrow_rateis a steady drag on returns. Hard-to-borrow stocks can have annualized rates well above 10%, which can eat into or erase a modest short profit. - Short squeezes. A rapid price spike forces short sellers to cover at sharply higher prices. Backtesting cannot fully capture the liquidity dynamics of a squeeze, so treat squeeze-prone environments with extra caution.
- Dividends. In real markets, short sellers are responsible for paying dividends to the share lender. Keep this in mind when backtesting short strategies on high-dividend stocks.
- Margin interaction. Short selling and margin trading are often used
together. When both are enabled, the engine applies
initial_margin,maintenance_marginandmax_leveragechecks to the combined long and short exposure. Make sure the margin parameters are realistic for your broker. - Catching accidental shorts. Set
raise_on_short_violation=Truewhen developing a long-only strategy. The engine will abort on the first order that would accidentally go short, making bugs easy to spot. In production backtests you can leave itFalseso rejected orders are simply logged.
Results
Each experiment produces an ExperimentResult containing one RunResult
per evaluated strategy. Every fill, cancellation and rejection produces an
OrderRecord. The stored trades is a closed round-trip: an opening
fill paired with the matching closing fill (FIFO). One sell that closes a
100-share long entered in two separate buys becomes two rows — one per
cost-basis lot consumed.
Note
Only the closing leg's commission is subtracted from Trade.pnl. The opening
leg's commission is paid out of cash but does not appear in the per-trade PnL,
it shows up only in the equity curve and the headline pnl metric.
The Results guide shows how to extract these records from the returned objects. See Metrics for the definitions and formulas behind the summary values.