Writing a portfolio construction strategy in Python

In this post, we will cover the basics of portfolio construction and alpha model trading strategies. We will show how to build a simple portfolio construction strategy using Python and Trading Strategy’s backtesting framework.

The target audience of this post is quantitative finance developers and people who want to learn algorithmic trading.

What is portfolio construction?

Portfolio construction is a trading strategy method method of selecting securities optimally to achieve maximum returns while taking a minimum risk.

It involves understanding how different asset classes, funds, and weightings impact each other and an investor’s objectives.

Portfolio construction consists of several phases:

  • Asset allocation models - to determine the optimal mix of asset classes (stocks, bonds, and commodities) in a portfolio based on historical returns, volatility, and correlations.
  • Optimization techniques - identifying the best combination of individual securities within each asset class based on factors such as expected return, risk, and liquidity.
  • Risk management tools - such as stop-loss orders, hedging strategies, and diversification techniques, to manage portfolio risk and reduce exposure to individual assets or market risks
  • Alpha generation strategies - so-called alpha models may include different trading strategies, such as factor investing, statistical arbitrage, and trend-following, to identify assets and ways to trade them to outperform or underperform the broader market.

How does portfolio construction work?

Portfolio construction works by rebalancing the portfolio assets based on the new allocation. Rebalancing usually occurs at fixed intervals, like weekly. The total equity (assets + cash) is redeployed - assets receiving less weight are sold, while those with a stronger alpha signal are bought. The individual per cent weight of an asset is decided by some optimisation and risk management criteria.

../../../_images/rebalance.svg

What is an alpha model?

An alpha model is a mathematical or quantitative framework used to generate trading signals that can be used in portfolio construction.

The alpha model seeks to identify assets likely to outperform or underperform their peers based on various factors and variables.

Alpha models can be constructed using various techniques, such as statistical analysis, machine learning algorithms, or financial modelling. The inputs to an alpha model may include company financial statements, price and volume data, macroeconomic indicators, and other market data.

Once an alpha model generates trading signals, the portfolio manager can use those signals to construct a portfolio that aims to generate alpha (i.e., excess returns) relative to a benchmark index.

What is Trading Strategy Framework?

The Trading Strategy Framework is a Python-based software development framework to develop automated trading strategies for decentralised finance markets.

The framework consists of

The core audience of the library are quants.

How does the Trading Strategy framework support creating portfolio construction strategies?

The Trading Strategy Framework provides functionality for:

The workflow for the framework is as follows:

  • Develop and backtest your strategies using Jupyter Notebook
  • Visualise and analyse the performance of your trading strategy using various tools and methods, like performance summary statistics, equity curve, tracking the performance of individual positions
  • Take your trading strategy backtested code unmodified to the live trading execution environment.

The strategy core logic

The Trading Strategy Framework offers two functions the developer must implement for the strategies:

  • create_trading_universe() that returns an object representing all assets the strategy can trade. This data is used to set up and update backtesting and live market data feeds. This includes blockchains, exchanges, trading pairs, OHLCV data feeds, liquidity data feeds and some special data feeds e.g. used for stop loss triggers.
  • decide_trades() takes in the current strategy cycle, timestamped trading universe and the current strategy state (open positions) as input. Based on this data, the function will return a list of new trades that will either open new or close existing positions

The strategy advances in ticks. Each tick length is the duration of a strategy cycle. Common strategy cycles include hourly, daily, and weekly trade decisions. In portfolio construction, this strategy cycle is called rebalance.

Overview of portfolio construction strategy architecture

The Trading Strategy framework offers Python “lego blocks” that allow you to easily assemble a strategy without developing the software plumbing yourself.

For a developer, this is seen as high-level Python classes and objects.

Here is how decide_trades() interacts in the case of a portfolio construction strategy:

../../../_images/portfolio-construction-strategy.svg
decide_trade() and creating_trading_universe() interface functions

The state is a JSON-serialisable hierarchy of all persistent strategy objects.

The overview of state management:

../../../_images/state.svg
How the strategy execution state is managed and serialised

An example portfolio constructions strategy in Python

Below is a Python strategy code for an example portfolio construction strategy. It is based on an example strategy that would trade a fixed set of decentralised finance assets across a given set of exchanges and trading pairs.

This particular example:

  • Runs a portfolio construction strategy backtest for a momentum strategy
  • Uses handpicked “DeFi bluechip tokens” - see Trading universe definition for the list
  • Long only
  • Picks top assets for each strategy cycle
  • Trades across multiple blockchains
  • Trades across multiple DEXes
  • Is based on momentum (previous week price change %)
  • Weekly rebalances
  • Due to volatile cryptocurrency markets, the strategy uses take profit/stop loss triggers from managing risks and exit outside the normal rebalance cycle
  • Ignores price impact and thus may cause unrealistic results
  • Ignores available liquidity to trade and thus may cause unrealistic results
  • Ignores 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
  • 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 fewer fees

Full code examples

First, we define our strategy parameters:

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

Then we define our trading universe:

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


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


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

And then, we define the actual strategy in decide_trades() function:

from typing import List, Dict, Counter

from tradingstrategy.universe import Universe
from tradeexecutor.strategy.trading_strategy_universe import translate_trading_pair
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(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

Analysing the backtest results

The backtesting results can be analysed in various ways. Some of the analysis goals are:

  • To see if the strategy is profitable
  • See if the strategy has performance issues in different market conditions
  • See if the strategy has performance issues in different market conditions

Past performance is not indicative of future results.

The Trading Strategy Framework offers a few analysis tools you can use in Jupyter Notebooks. Below are some examples.

The equity curve of the backtest results that compare the example strategy to the “buy and hold ETH” index benchmark:

../../../_images/equity-curve.png

Summary statistics:

../../../_images/summary-stats-example.png

Individual position timeline:

../../../_images/position-table.png

Further discussion

If you want to study further, have questions or feedback: