Tags: alpha-model, advanced, multipair, momentum

Portfolio construction model trading strategy example#

This is an example notebook how to construct a momentum based portfolio construction strategy using Trading Strategy framework and backtest it for DeFi tokens.

This backtest uses alpha model approach where each trading pair has a signal and basede on the signal strenghts we construct new portfolio weightings for the upcoming week.

Some highlights of this notebook:

  • Not a realistic trading strategy, but more of an code example - this may generate profits or loss but this is outside the scode of this example

  • Make sure you have studied some simpler backtesting examples first

  • The backtest has all its code within a single Jupyter notebook

    • The backtest code and charts are self-contained in a single file

    • The example code is easy to read

  • Runs a backtest for a momentum strategy

    • Uses handpicked “DeFi bluechip tokens” - see Trading universe definition for the list

    • Long only

    • Pick top 3 tokens for each strategy cycle

    • Trade across multiple blockchains

    • Trade across multiple DEXes

    • Based on momentum (previous week price change %)

    • Weekly rebalances - weekly strategy cycle

    • Due to volatile cryptocurrency markets, uses take profit/stop loss triggers to manage risks and exit outside the normal rebalance cycle

    • Ignore price impact, and thus may cause unrealistic results

    • Ignore available liquidity to trade, and thus may cause unrealistic results

    • Ignore any complications of trading assets cross chain - we assume our reserve currency USDC is good on any chain and DEX and trading pair, which is not a realistic assumption

  • Demostrates statistics and performance analyses

    • Equity curve with comparison to buy and hold ETH

    • Alpha model

    • Aggregated monthly performance

  • Order routing is ignored; the strategy cannot be moved to live trading as is

    • We use the oldest available DEX price data we have: Uniswap v2, others

    • For live trade execution, one would want to choose execution on a newer DEX with less fees

  • You need a Trading Strategy API key to run the notebook

  • You will need a powerful computer to run this notebook (> 16GB RAM)

There is also a unit test version availble in the repository in tests/strategy_tests.

Strategy parameter set up#

Set up the parameters used in in this strategy backtest study.

  • Backtested blockchain, exchange and trading pair

  • Backtesting period

  • Strategy parameters for EMA crossovers

[1]:
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 StrategyType, TradeRouting, ReserveCurrency

# Tell what trade execution engine version this strategy needs to use
trading_strategy_engine_version = "0.1"

# What kind of strategy we are running.
# This tells we are going to use
trading_strategy_type = StrategyType.managed_positions

# How our trades are routed.
# PancakeSwap basic routing supports two way trades with BUSD
# and three way trades with BUSD-BNB hop.
trade_routing = TradeRouting.ignore

# Set cycle to 7 days and look back the momentum of the previous candle
trading_strategy_cycle = CycleDuration.cycle_7d
momentum_lookback_period = datetime.timedelta(days=7)

# Hold top 3 coins for every cycle
max_assets_in_portfolio = 4

# Leave 20% cash buffer
value_allocated_to_positions = 0.80

# Set 33% stop loss over mid price
stop_loss = 0.66

# Set 5% take profit over mid price
take_profit = 1.05

# The weekly price must be up 2.5% for us to take a long position
minimum_mometum_threshold = 0.025

# Don't bother with trades that would move position
# less than 300 USD
minimum_rebalance_trade_threshold = 300

# Use hourly candles to trigger the stop loss
stop_loss_data_granularity = TimeBucket.h1

# Strategy keeps its cash in USDC
reserve_currency = ReserveCurrency.usdc

# The duration of the backtesting period
start_at = datetime.datetime(2020, 11, 1)
end_at = datetime.datetime(2023, 1, 31)

# Start with 10,000 USD
initial_deposit = 10_000


# List of trading pairs that we consider "DeFi blueschips" for this strategy
# For token ordering, wrappign see https://tradingstrategy.ai/docs/programming/market-data/trading-pairs.html
pairs = (
    (ChainId.ethereum, "uniswap-v2", "WETH", "USDC"),  # ETH
    (ChainId.ethereum, "sushi", "AAVE", "WETH"),  # AAVE
    (ChainId.ethereum, "uniswap-v2", "UNI", "WETH"),  # UNI
    (ChainId.ethereum, "uniswap-v2", "CRV", "WETH"),  # Curve
    (ChainId.ethereum, "sushi", "SUSHI", "WETH"),  # Sushi
    (ChainId.bsc, "pancakeswap-v2", "WBNB", "BUSD"),  # BNB
    (ChainId.bsc, "pancakeswap-v2", "Cake", "BUSD"),  # Cake
    (ChainId.polygon, "quickswap", "WMATIC", "USDC"),  # Matic
    (ChainId.avalanche, "trader-joe", "WAVAX", "USDC"),  # Avax
    (ChainId.avalanche, "trader-joe", "JOE", "WAVAX"),  # TraderJoe
)


Strategy logic and trade decisions#

decide_trades function decide what trades to take.

In this example

  • We set up AlphaModel for our strategy

  • We do weekly rebalances

  • We calculate momentum for each trading pair as the price change % of the last weekly candle

  • Outside weekly rebalances, we set take profit/stop loss triggers that are evaluated hourly in backtesting. In live trading these triggers would be real time.

  • Portfolio assets are distributed to the three strongest pairs as 1/N division

  • We maintain a cash buffer and never go 100% in

[2]:
from tradeexecutor.strategy.trading_strategy_universe import translate_trading_pair
from typing import List, Dict, Counter

from tradingstrategy.universe import Universe
from tradeexecutor.strategy.weighting import weight_by_1_slash_n
from tradeexecutor.strategy.alpha_model import AlphaModel
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]:

    # 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)

    alpha_model = AlphaModel(timestamp)

    # Watch out for the inclusive range and include and avoid peeking in the future
    adjusted_timestamp = timestamp - pd.Timedelta(seconds=1)
    start = adjusted_timestamp - momentum_lookback_period - datetime.timedelta(seconds=1)
    end = adjusted_timestamp

    candle_universe = universe.candles
    pair_universe = universe.pairs

    # Get candle data for all candles, inclusive time range
    candle_data = candle_universe.iterate_samples_by_pair_range(start, end)

    # Iterate over all candles for all pairs in this timestamp (ts)
    for pair_id, pair_df in candle_data:

        # We should have candles for range start - end,
        # where end is the current strategy cycle timestamp
        # and start is one week before end.
        # Because of sparse data we may have 0, 1 or 2 candles
        first_candle = pair_df.iloc[0]
        last_candle = pair_df.iloc[-1]

        # How many candles we are going to evaluate
        candle_count = len(pair_df)

        assert last_candle["timestamp"] < timestamp, "Something wrong with the data - we should not be able to peek the candle of the current timestamp, but always use the previous candle"

        open = last_candle["open"]
        close = last_candle["close"]

        # DEXPair instance contains more data than internal TradingPairIdentifier
        # we use to store this pair across the strategy
        dex_pair = pair_universe.get_pair_by_id(pair_id)
        pair = translate_trading_pair(dex_pair)

        # We define momentum as how many % the trading pair price gained during
        # the momentum window
        momentum = (close - open) / open

        # This pair has not positive momentum,
        # we only buy when stuff goes up
        if momentum <= minimum_mometum_threshold:
            continue

        alpha_model.set_signal(
            pair,
            momentum,
            stop_loss=stop_loss,
            take_profit=take_profit,
        )

    # Select max_assets_in_portfolio assets in which we are going to invest
    # Calculate a weight for ecah asset in the portfolio using 1/N method based on the raw signal
    alpha_model.select_top_signals(max_assets_in_portfolio)
    alpha_model.assign_weights(method=weight_by_1_slash_n)
    alpha_model.normalise_weights()

    # Load in old weight for each trading pair signal,
    # so we can calculate the adjustment trade size
    alpha_model.update_old_weights(state.portfolio)

    # Calculate how much dollar value we want each individual position to be on this strategy cycle,
    # based on our total available equity
    portfolio = position_manager.get_current_portfolio()
    portfolio_target_value = portfolio.get_total_equity() * value_allocated_to_positions
    alpha_model.calculate_target_positions(position_manager, portfolio_target_value)

    # Shift portfolio from current positions to target positions
    # determined by the alpha signals (momentum)
    trades = alpha_model.generate_rebalance_trades_and_triggers(
        position_manager,
        min_trade_threshold=minimum_rebalance_trade_threshold,  # Don't bother with trades under 300 USD
    )

    # Record alpha model state so we can later visualise our alpha model thinking better
    state.visualisation.add_calculations(timestamp, alpha_model.to_dict())

    return trades

Set up the market data client#

The Trading Strategy market data client is the Python library responsible for managing the data feeds needed to run the backtest.None

We set up the market data client with an API key.

If you do not have an API key yet, you can register one.

[3]:
from tradingstrategy.client import Client

client = Client.create_jupyter_client()
Started Trading Strategy in Jupyter notebook environment, configuration is stored in /home/alex/.tradingstrategy

Setup trading universe#

We setup the trading universe for the backtesting.

  • Read in a handwritten allowed trading pair universe list

  • Download candle data

  • Print out trading pair addresses and volumes as the sanity check the pair defintions look correct

[4]:
from tradingstrategy.client import Client

from tradeexecutor.strategy.trading_strategy_universe import TradingStrategyUniverse
from tradeexecutor.strategy.trading_strategy_universe import load_partial_data
from tradeexecutor.strategy.execution_context import ExecutionContext
from tradeexecutor.strategy.execution_context import ExecutionMode
from tradeexecutor.strategy.universe_model import UniverseOptions


def create_trading_universe(
        ts: datetime.datetime,
        client: Client,
        execution_context: ExecutionContext,
        universe_options: UniverseOptions,
) -> TradingStrategyUniverse:

    assert not execution_context.mode.is_live_trading(), \
        f"Only strategy backtesting supported, got {execution_context.mode}"

    # Load data for our trading pair whitelist
    dataset = load_partial_data(
        client=client,
        time_bucket=trading_strategy_cycle.to_timebucket(),
        pairs=pairs,
        execution_context=execution_context,
        universe_options=universe_options,
        liquidity=False,
        stop_loss_time_bucket=stop_loss_data_granularity,
        start_at=start_at,
        end_at=end_at,
    )

    # Filter down the dataset to the pairs we specified
    universe = TradingStrategyUniverse.create_multichain_universe_by_pair_descriptions(
        dataset,
        pairs,
        reserve_token_symbol="USDC"  # Pick any USDC - does not matter as we do not route
    )

    return universe

universe = create_trading_universe(
    datetime.datetime.utcnow(),
    client,
    ExecutionContext(mode=ExecutionMode.backtesting),
    UniverseOptions(),
)

for pair in universe.universe.pairs.iterate_pairs():
    all_time_volume = pair.buy_volume_all_time
    print(f"Trading pair {pair.base_token_symbol} ({pair.base_token_address}) - {pair.quote_token_symbol} ({pair.quote_token_address}) - all time volume {all_time_volume:,.0f} USD")
Trading pair WETH (0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2) - USDC (0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48) - all time volume 21,768,685,568 USD
Trading pair CRV (0xd533a949740bb3306d119cc777fa900ba034cd52) - WETH (0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2) - all time volume 586,796,480 USD
Trading pair SUSHI (0x6b3595068778dd592e39a122f4f5a5cf09c90fe2) - WETH (0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2) - all time volume 7,782,359,040 USD
Trading pair UNI (0x1f9840a85d5af5bf1d1762f925bdaddc4201f984) - WETH (0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2) - all time volume 4,002,584,320 USD
Trading pair AAVE (0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9) - WETH (0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2) - all time volume 1,644,330,752 USD
Trading pair WMATIC (0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270) - USDC (0x2791bca1f2de4661ed88a30c99a7a9449aa84174) - all time volume 3,020,135,424 USD
Trading pair Cake (0x0e09fabb73bd3ade0a17ecc321fd13a19e81ce82) - BUSD (0xe9e7cea3dedca5984780bafc599bd69add087d56) - all time volume 2,495,561,472 USD
Trading pair WBNB (0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c) - BUSD (0xe9e7cea3dedca5984780bafc599bd69add087d56) - all time volume 31,212,638,208 USD
Trading pair JOE (0x6e84a6216ea6dacc71ee8e6b0a5b7322eebc0fdd) - WAVAX (0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7) - all time volume 1,227,691,648 USD
Trading pair WAVAX (0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7) - USDC (0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e) - all time volume 1,668,127,744 USD

Run 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.

  • The trade execution engine will download the necessary datasets to run the backtest. The datasets may be large, several gigabytes.

[5]:
import logging

from tradeexecutor.backtest.backtest_runner import run_backtest_inline

state, universe, debug_dump = run_backtest_inline(
    name="Portfolio construction example",
    start_at=start_at,
    end_at=end_at,
    client=client,
    cycle_duration=trading_strategy_cycle,
    decide_trades=decide_trades,
    create_trading_universe=create_trading_universe,
    initial_deposit=initial_deposit,
    reserve_currency=reserve_currency,
    trade_routing=trade_routing,
    log_level=logging.WARNING,
    universe=universe,
)

trade_count = len(list(state.portfolio.get_all_trades()))
print(f"Backtesting completed, backtested strategy made {trade_count} trades")
Backtesting completed, backtested strategy made 596 trades

Examine backtest results#

Examine state that contains - All actions the trade executor took - Visualisation and diagnostics data associated with the actity

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

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

Benchmarking the strategy performance#

Here we benchmark the strategy performance against some baseline scenarios.

  • Buy and hold ETH

  • Buy and hold US Dollar (do nothing)

[7]:
from tradeexecutor.visual.benchmark import visualise_benchmark

eth_usd = universe.get_pair_by_human_description((ChainId.ethereum, "uniswap-v2", "WETH", "USDC"))
eth_candles = universe.universe.candles.get_candles_by_pair(eth_usd.internal_id)
eth_price = eth_candles["close"]

fig = visualise_benchmark(
    state.name,
    portfolio_statistics=state.stats.portfolio,
    all_cash=state.portfolio.get_initial_deposit(),
    buy_and_hold_asset_name="ETH",
    buy_and_hold_price_series=eth_price,
    start_at=start_at,
    end_at=end_at,
)

fig.update_layout(
    autosize=False,
    width=1200,
    height=800,)
fig.show("svg")

../../_images/programming_more-old-strategy-examples_portfolio-construction-model_16_0.svg

Equity curve and drawdown#

Visualise equity curve and related performnace over time.

  • Returns

  • Drawdown

  • Daily returns

[8]:
# Set Jupyter Notebook output mode parameters
# Used to avoid warnings
from tradeexecutor.backtest.notebook import setup_charting_and_output
setup_charting_and_output()

# Needed to improve the resolution of matplotlib chart used here
%config InlineBackend.figure_format = 'svg'

from tradeexecutor.visual.equity_curve import calculate_equity_curve, calculate_returns
from tradeexecutor.visual.equity_curve import visualise_equity_curve

curve = calculate_equity_curve(state)
returns = calculate_returns(curve)
visualise_equity_curve(returns)
[8]:
../../_images/programming_more-old-strategy-examples_portfolio-construction-model_18_0.svg

Returns monthly breakdown#

  • Monthly returns

  • Best day/week/month/year

[9]:
from tradeexecutor.visual.equity_curve import visualise_returns_over_time

visualise_returns_over_time(returns)
[9]:
../../_images/programming_more-old-strategy-examples_portfolio-construction-model_20_0.svg

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

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

analysis = build_trade_analysis(state.portfolio)

Strategy summary#

Overview of strategy performance

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

summary = analysis.calculate_summary_statistics(trading_strategy_cycle.to_timebucket(), state)

with pd.option_context("display.max_row", None):
    summary.display()
Returns
Annualised return % 324.51%
Lifetime return % 721.93%
Realised PnL $72,569.95
Trade period 812 days 0 hours
Holdings
Total assets $82,193.19
Cash left $16,513.99
Open position value $65,679.20
Open positions 4
Winning Losing Total
Closed Positions
Number of positions 218 78 296
% of total 73.65% 26.35% 100.00%
Average PnL % 7.47% -13.08% 2.06%
Median PnL % 5.24% -9.87% 4.81%
Biggest PnL % 138.33% -36.57% -
Average duration 0 bars 0 bars 0 bars
Max consecutive streak 25 7 -
Max runup / drawdown 725.88% -33.39% -
Stop losses Take profits
Position Exits
Triggered exits 6 212
Percent winning 0.00% 100.00%
Percent losing 100.00% 0.00%
Percent of total 2.03% 71.62%
Risk Analysis
Biggest realized risk 27.36%
Average realized risk -2.69%
Max pullback of capital -33.21%
Sharpe Ratio 187.47%
Sortino Ratio 385.25%
Profit Factor 271.96%

Alpha model timeline analysis#

We display a table that shows how alpha model performermed over time.

  • Events are in the timeline order

  • Display portfolio weightings at each strategy cycle

  • Show performance of individidual positions taken during this cycle

  • Stop loss events are shown as their own rows

The visualisation will have the a column for each asset being traded. The cell of a column contains the following elements

  • Asset price change (up/down) since the last strategy cycle

  • Price of the asset at the start of the strategy cycle

  • Weight % as the part of the portfolio that was chosen for the next cycle

  • Open value of the position as USD

  • Adjust value of the position as USD - to get to the target weight - how much asset needs to be bought or sold

  • Close the remaining value of the position as USD

  • Profit: The life time profit of the position so far

Position opening and closing rules

  • A position may contain multiple trades

  • Unlike on some exchanges, we adjust existing positions by increasing or decreasing them - a trade can count against existing position

  • Posions are opened if there are no existing holdings of an asset

  • The position is adjusted over the time

  • The position is closed when the remaining assets of the position are sold

[12]:
from tradeexecutor.analysis.alpha_model_analyser import render_alpha_model_plotly_table, create_alpha_model_timeline_all_assets

timeline = create_alpha_model_timeline_all_assets(state, universe, new_line="<br>")
figure, table = render_alpha_model_plotly_table(timeline)

figure.update_layout(
    margin={"l": 0, "r": 0, "t": 0, "b": 0},
    width=2000,
    height=800,)

display(figure)

Single pair price action, trade, entry and exit analyses#

Plot a single pair from our trading universe and show our entries and exits for this particular pair.

  • Analyse ETH/USDC entries and exits

  • We use 1h candles, as weekly candles used for strategy decisions do not have enough time resolution - we downloaded 1h candles earlier to be used as a trigger signal for take profit/stop loss

[13]:
from tradeexecutor.visual.single_pair import visualise_single_pair
from tradingstrategy.charting.candle_chart import VolumeBarMode

eth_usd = universe.get_pair_by_human_description((ChainId.ethereum, "uniswap-v2", "WETH", "USDC"))
eth_usd_candles = universe.backtest_stop_loss_candles.get_candles_by_pair(eth_usd.internal_id)

fig = visualise_single_pair(
    state,
    pair_id=eth_usd.internal_id,
    title="ETH/USDC individual trades",
    candle_universe=eth_usd_candles,
    start_at=start_at,
    end_at=end_at,
    volume_bar_mode=VolumeBarMode.hidden,
)

fig.show()
[14]:
from tradeexecutor.visual.single_pair import visualise_single_pair_positions_with_duration_and_slippage

fig = visualise_single_pair_positions_with_duration_and_slippage(
    state,
    pair_id=eth_usd.internal_id,
    title="ETH/USDC entries and exits",
    candles=eth_usd_candles,
    start_at=start_at,
    end_at=end_at,
)

fig.update_layout(
    autosize=False,
    width=1200,
    height=800,)
fig.show("svg")
../../_images/programming_more-old-strategy-examples_portfolio-construction-model_29_0.svg

Finishing notes#

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

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