Skip to content

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.

  1. Resolve instrument profiles for every symbol and the configured benchmark.
  2. Download any missing OHLCV bars on the chosen interval, clamped to start_date / end_date if set.
  3. 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.
  4. Compute indicators once over the full dataset.
  5. Run strategies. Each strategy gets its own portfolio, order book, equity log, and trade log.
  6. 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 Benchmark that holds a pure passive BuyAndHold(<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_margin percentage the engine triggers a margin call. With raise_on_margin_limit=False (the default), the position is reduced automatically and a warning is logged. With raise_on_margin_limit=True, the run aborts so you can investigate.
  • Interest costs. Borrowed money is not free. The margin_interest rate 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:

  1. Credits the portfolio cash with the proceeds of the sale (price x quantity).
  2. Records a negative position in the symbol.
  3. Accrues the borrow_rate daily against the notional value of the short.
  4. 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_rate is 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_margin and max_leverage checks 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=True when 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 it False so 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.