Developing portfolio construction trading strategies#

In this post, we will cover the basics of portfolio constructions and alpha models. 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 are 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 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 - to identify 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 kind of trading strategies such as factor investing, statistical arbitrage, and trend-following, to identify assets and ways to trade them that are 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 that have a stronger alpha signal are bought. The indivual percent weight of an asset is decided by some optimisation and risk management criteria.

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

What is 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 that are likely to outperform or underperform their peers, based on a variety of factors and variables.

Alpha models can be constructed using a variety of techniques, such as statistical analysis, machine learning algorithms, or financial modeling. 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 Trading Strategy framework support creating portfolio constructions 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 that represents 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 an 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 includes hourly, daily, and weekly trade decisions. In the portfolio construction, this strategy cycle is called rebalance.

Overview of portfolio construction strategy architecture#

The Trading Strategy framework offers Python “lego blocks” that allows you to easily put together a strategy without the need to develop the software plumbing yourself.

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

  • decide_trades() and create_trading_universe() are interface functions that the strategy developer fills in

  • timestamp is the current strategy cycle tick of the trading strategy.

  • Trading pairs are identified with a copy-by-value class tradeexecutor.state.identifier.TradingPairIdentifier that encapsulates blockchain id, exchange id, ERC-20 tokens and their smart contract addresses which are all needed to uniquely refer to trading pairs in decentralised markets environments. This is one of the core challenges in decentralised markets, as assets cannot be simply referred to by their three or four letter stock tickers.

  • tradeexecutor.strategy.trading_strategy_universe.TradingStrategyUniverse contains all data that can be an input to the trade.

  • tradeexecutor.state.state.State contains all past and current data about the previous actions the strategy took, like opened and closed positions, trades, blockchain transaction execution details, technical indicators, uptime, deposited capital.

  • tradeexecutor.strategy.alpha_model.AlphaModel offers a way to set weighted trading signals based on the data analysis. It has helper methods of tracking signals, choosing top signals, and generating rebalance trades automatically.

  • tradeexecutor.strategy.pandas_trader.position_manager.PositionManager is a high level utility class that is used to generate trades. For example, you can call PositionManager.close_all and it will return a list of trade orders that need to be executed in order to sell all assets and go back to fully cash.

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

../../../_images/portfolio-construction-strategy.svg

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

The overview of state management:

../../../_images/state.svg

An example portfolio constructions strategy in Python#

Below is a Python strategy code for an example portfolio construction strategy. It is based on fictional strategy that would trade a fixed set of decentralised finance assets across 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, uses take profit/stop loss triggers to manage 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 less fees

You can read the full backtesting Jupyter Notebook in Trading Strategy documentation.

First we define our strategy backtest paramaters:

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

Note

Past performance is not indicative of future results.

The Trading Strategy Framework offers few analysis tools you can use in Jupyter Notebooks out of the box. Below are some examples.

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

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

Summary statistics:

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

Individual position timeline:

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

Further discussion#

If you have questions or feedback, please join the Trading Strategy community Discord for discussion.