Synthetic data w/stop loss backtesting example#

This is an example notebook how to create and run backtests where stop loss is being used. It is based on synthetic EMA example.

  • Synthetic trading data is used, as the purpose of this notebook is show how to stop loss functions

  • Stop loss is set to 95% when a position is opened with open_1x_long

Set up#

Set up strategy paramets that will decide its behavior

[13]:
import datetime

import pandas as pd

from tradingstrategy.chain import ChainId
from tradingstrategy.timebucket import TimeBucket
from tradeexecutor.strategy.cycle import CycleDuration
from tradeexecutor.strategy.strategy_module import TradeRouting, ReserveCurrency

trading_strategy_cycle = CycleDuration.cycle_24h

# Strategy keeps its cash in BUSD
reserve_currency = ReserveCurrency.busd

# How much of the cash to put on a single trade
position_size = 0.10

#
# Strategy thinking specific parameter
#

slow_ema_candle_count = 20

fast_ema_candle_count = 5

# How many candles to extract from the dataset once
batch_size = 90

# Set stop loss to 5% of opening price
stop_loss_pct = 0.95

# Range of backtesting and synthetic data generation.
# Because we are using synthetic data actual dates do not really matter -
# only the duration

start_at = datetime.datetime(2021, 6, 1)
end_at = datetime.datetime(2022, 1, 1)

Strategy logic and trade decisions#

decide_trades function decide what trades to take. In this example, we calculate two exponential moving averages (EMAs) and make decisions based on those.

[14]:
from typing import List, Dict

from pandas_ta.overlap import ema

from tradingstrategy.universe import Universe

from tradeexecutor.state.visualisation import PlotKind
from tradeexecutor.state.trade import TradeExecution
from tradeexecutor.strategy.pricing_model import PricingModel
from tradeexecutor.strategy.pandas_trader.position_manager import PositionManager
from tradeexecutor.state.state import State



def decide_trades(
        timestamp: pd.Timestamp,
        universe: Universe,
        state: State,
        pricing_model: PricingModel,
        cycle_debug_data: Dict) -> List[TradeExecution]:
    """The brain function to decide the trades on each trading strategy cycle."""

    # The pair we are trading
    pair = universe.pairs.get_single()

    # How much cash we have in the hand
    cash = state.portfolio.get_current_cash()

    # Get OHLCV candles for our trading pair as Pandas Dataframe.
    # We could have candles for multiple trading pairs in a different strategy,
    # but this strategy only operates on single pair candle.
    # We also limit our sample size to N latest candles to speed up calculations.
    candles: pd.DataFrame = universe.candles.get_single_pair_data(timestamp, sample_count=batch_size)

    # We have data for open, high, close, etc.
    # We only operate using candle close values in this strategy.
    close = candles["close"]

    # Calculate exponential moving averages based on slow and fast sample numbers.
    # https://github.com/twopirllc/pandas-ta
    # https://github.com/twopirllc/pandas-ta/blob/bc3b292bf1cc1d5f2aba50bb750a75209d655b37/pandas_ta/overlap/ema.py#L7
    slow_ema_series = ema(close, length=slow_ema_candle_count)
    fast_ema_series = ema(close, length=fast_ema_candle_count)

    if slow_ema_series is None or fast_ema_series is None:
        # Cannot calculate EMA, because
        # not enough samples in backtesting
        return []

    slow_ema = slow_ema_series.iloc[-1]
    fast_ema = fast_ema_series.iloc[-1]

    # Get the last close price from close time series
    # that's Pandas's Series object
    # https://pandas.pydata.org/docs/reference/api/pandas.Series.iat.html
    current_price = close.iloc[-1]

    # List of any trades we decide on this cycle.
    # Because the strategy is simple, there can be
    # only zero (do nothing) or 1 (open or close) trades
    # decides
    trades = []

    # Create a position manager helper class that allows us easily to create
    # opening/closing trades for different positions
    position_manager = PositionManager(timestamp, universe, state, pricing_model)

    if current_price >= slow_ema:
        # Entry condition:
        # Close price is higher than the slow EMA
        if not position_manager.is_any_open():
            buy_amount = cash * position_size
            trades += position_manager.open_1x_long(pair, buy_amount, stop_loss_pct=stop_loss_pct)
    elif fast_ema >= slow_ema:
        # Exit condition:
        # Fast EMA crosses slow EMA
        if position_manager.is_any_open():
            trades += position_manager.close_all()

    # Visualize strategy
    # See available Plotly colours here
    # https://community.plotly.com/t/plotly-colours-list/11730/3?u=miohtama
    visualisation = state.visualisation
    visualisation.plot_indicator(timestamp, "Slow EMA", PlotKind.technical_indicator_on_price, slow_ema, colour="darkblue")
    visualisation.plot_indicator(timestamp, "Fast EMA", PlotKind.technical_indicator_on_price, fast_ema, colour="#003300")

    return trades

Defining trading universe#

We create a trading universe with a single blockchain, exchange and trading pair. For the sake of easier understanding the code, we name this “Uniswap v2” like exchange with a single ETH-USDC trading pair.

The trading pair contains generated noise-like OHLCV trading data.

[15]:

import random from tradeexecutor.state.identifier import AssetIdentifier, TradingPairIdentifier from tradingstrategy.candle import GroupedCandleUniverse from tradeexecutor.testing.synthetic_ethereum_data import generate_random_ethereum_address from tradeexecutor.testing.synthetic_exchange_data import generate_exchange from tradeexecutor.testing.synthetic_price_data import generate_ohlcv_candles from tradeexecutor.strategy.trading_strategy_universe import TradingStrategyUniverse, \ create_pair_universe_from_code def create_trading_universe() -> TradingStrategyUniverse: # Set up fake assets mock_chain_id = ChainId.ethereum mock_exchange = generate_exchange( exchange_id=random.randint(1, 1000), chain_id=mock_chain_id, address=generate_random_ethereum_address()) usdc = AssetIdentifier(ChainId.ethereum.value, generate_random_ethereum_address(), "USDC", 6, 1) weth = AssetIdentifier(ChainId.ethereum.value, generate_random_ethereum_address(), "WETH", 18, 2) weth_usdc = TradingPairIdentifier(weth, usdc, generate_random_ethereum_address(), mock_exchange.address, internal_id=random.randint(1, 1000), internal_exchange_id=mock_exchange.exchange_id) time_bucket = TimeBucket.d1 pair_universe = create_pair_universe_from_code(mock_chain_id, [weth_usdc]) # Generate candle data with 15% daily movement candles = generate_ohlcv_candles( time_bucket, start_at, end_at, pair_id=weth_usdc.internal_id, daily_drift=(0.85, 1.15) ) candle_universe = GroupedCandleUniverse.create_from_single_pair_dataframe(candles) universe = Universe( time_bucket=time_bucket, chains={mock_chain_id}, exchanges={mock_exchange}, pairs=pair_universe, candles=candle_universe, liquidity=None ) # As we are using synthetic data, # we need to slip in stop loss data feed. # In this case, it is the same as normal price feed, # but usually should be finer granularity than our strategy candles. # E.g. if strategy candles are 1h you can use 15m candles for stop loss. return TradingStrategyUniverse( universe=universe, reserve_assets=[usdc], backtest_stop_loss_time_bucket=universe.candles.time_bucket, backtest_stop_loss_candles=universe.candles, )

Running the backtest#

Run backtest using giving trading universe and strategy function.

Running the backtest outputs state object that contains all the information on the backtesting position and trades.

[16]:
from tradeexecutor.testing.synthetic_exchange_data import generate_simple_routing_model
from tradeexecutor.backtest.backtest_runner import run_backtest_inline

universe = create_trading_universe()

# Check that synthetic trading data has price feed
# to check stop losses
assert universe.has_stop_loss_data()

start_candle, end_candle = universe.universe.candles.get_timestamp_range()
print(f"Our universe has synthetic candle data for the period {start_candle} - {end_candle}")

state, universe,  debug_dump = run_backtest_inline(
    name="Stop loss example with synthetic data",
    start_at=start_at,
    end_at=end_at,
    client=None,  # None of downloads needed, because we are using synthetic data
    cycle_duration=CycleDuration.cycle_24h,  # Override to use 24h cycles despite what strategy file says
    decide_trades=decide_trades,
    universe=universe,
    initial_deposit=10_000,
    reserve_currency=ReserveCurrency.busd,
    trade_routing=TradeRouting.user_supplied_routing_model,
    routing_model=generate_simple_routing_model(universe),
)

Our universe has synthetic candle data for the period 2021-06-01 00:00:00 - 2021-12-31 00:00:00
Backtesting Stop loss example with synthetic data, 2021-06-01-2022-01-01 at 2021-12-31, total 32 trades: 100%|██████████| 18489600/18489600 [00:00<00:00, 42979903.20it/s]

Examine backtest results#

Examine state that contains all actions the trade executor took.

We plot out a chart that shows - The price action - When the strategy made buys or sells - When sell was a stop loss sell

[17]:
print(f"Positions taken: {len(list(state.portfolio.get_all_positions()))}")
print(f"Trades made: {len(list(state.portfolio.get_all_trades()))}")

stop_loss_trades = [t for t in state.portfolio.get_all_trades() if t.is_stop_loss()]
print(f"Trades w/stop loss triggered: {len(stop_loss_trades)}")
Positions taken: 16
Trades made: 32
Trades w/stop loss triggered: 10
[18]:
from tradeexecutor.visual.single_pair import visualise_single_pair

figure = visualise_single_pair(state, universe.universe.candles)

figure.show()

Benchmarking the strategy performance#

Here we benchmark the strategy performance against some baseline scenarios.

  • Buy and hold US dollar

  • Buy and hold the underlying trading pair base asset

[19]:
close = universe.universe.candles.get_single_pair_data()["close"]
[20]:
from tradeexecutor.visual.benchmark import visualise_benchmark

traded_pair = universe.universe.pairs.get_single()

fig = visualise_benchmark(
    state.name,
    portfolio_statistics=state.stats.portfolio,
    all_cash=state.portfolio.get_initial_deposit(),
    buy_and_hold_asset_name=traded_pair.base_token_symbol,
    buy_and_hold_price_series=universe.universe.candles.get_single_pair_data()["close"],
)

fig.show()

Analysing the strategy success#

Here we calculate statistics on how well the strategy performed.

  • Won/lost trades

  • Timeline of taken positions with color coding of trade performance

[21]:
from tradeexecutor.analysis.trade_analyser import build_trade_analysis

analysis = build_trade_analysis(state.portfolio)

Strategy summary#

Overview of strategy performance

[22]:
from IPython.core.display_functions import display

summary = analysis.calculate_summary_statistics()

with pd.option_context("display.max_row", None):
    display(summary.to_dataframe())
0
Trading period length 180 days
Return % -1.36%
Annualised return % -2.77%
Cash at start $10,000.00
Value at end $9,863.51
Trade win percent 25.00%
Total trades done 16
Won trades 4
Lost trades 12
Stop losses triggered 10
Stop loss % of all 62.50%
Stop loss % of lost 83.33%
Zero profit trades 0
Positions open at the end 0
Realised profit and loss $-136.49
Portfolio unrealised value $0.00
Extra returns on lending pool interest $0.00
Cash left at the end $9,863.51

Position and trade timeline#

Display all positions and how much profit they made.

[23]:
from tradeexecutor.analysis.trade_analyser import expand_timeline

timeline = analysis.create_timeline()

expanded_timeline, apply_styles = expand_timeline(
        universe.universe.exchanges,
        universe.universe.pairs,
        timeline)

# Do not truncate the row output
with pd.option_context("display.max_row", None):
    display(apply_styles(expanded_timeline))

Remarks Opened at Duration Exchange Base asset Quote asset Position max size PnL USD PnL % Open price USD Close price USD Trade count
2021-07-02 7 days WETH USDC $1,979.71 $-20.29 -2.03% $1,332.520800 $1,305.480482 2
SL 2021-07-10 1 days WETH USDC $1,922.67 $-73.27 -7.34% $1,422.375147 $1,317.944182 2
SL 2021-08-01 4 days WETH USDC $1,903.89 $-77.40 -7.81% $920.391923 $848.479443 2
SL 2021-08-10 2 days WETH USDC $1,915.40 $-50.41 -5.13% $920.035204 $872.850159 2
2021-08-13 5 days WETH USDC $1,967.17 $11.45 1.17% $931.897743 $942.808639 2
SL 2021-08-22 1 days WETH USDC $1,889.49 $-68.52 -7.00% $932.583002 $867.310327 2
2021-08-24 21 days WETH USDC $2,293.88 $349.57 35.96% $977.918680 $1,329.558369 2
SL 2021-09-21 3 days WETH USDC $1,961.62 $-52.60 -5.22% $1,394.883681 $1,322.030961 2
2021-09-25 8 days WETH USDC $1,962.05 $-41.65 -4.16% $1,484.845169 $1,423.109061 2
SL 2021-10-29 1 days WETH USDC $1,943.13 $-52.25 -5.24% $1,041.338797 $986.805607 2
SL 2021-10-30 4 days WETH USDC $1,913.20 $-71.72 -7.23% $986.814488 $915.498112 2
2021-11-08 11 days WETH USDC $2,112.44 $141.86 14.40% $973.483079 $1,113.640637 2
2021-11-20 20 days WETH USDC $2,189.62 $190.67 19.08% $1,146.446848 $1,365.157856 2
SL 2021-12-14 2 days WETH USDC $1,967.18 $-69.91 -6.86% $1,458.038819 $1,357.968717 2
SL 2021-12-19 2 days WETH USDC $1,910.21 $-112.89 -11.16% $1,532.448214 $1,361.421323 2
SL 2021-12-22 7 days WETH USDC $1,861.41 $-139.12 -13.91% $1,554.641353 $1,338.419203 2

Finishing notes#

Print out a line to signal the notebook finished the execution successfully.

[24]:
print("All ok")
All ok