Strategy icon

ETH/BTC rolling ratio beta

A pair trading strategy for ETH/BTC

Source code

The source code of the ETH/BTC rolling ratio strategy

"""Polygon strategy based on rolling ratio indicator. Long only.

To backtest this strategy module locally:

.. code-block:: console

    source scripts/set-latest-tag-gcp.sh
    docker-compose run enzyme-polygon-eth-rolling-ratio backtest

Or:

.. code-block:: console

    trade-executor \
        backtest \
        --strategy-file=strategy/enzyme-polygon-eth-rolling-ratio.py \
        --trading-strategy-api-key=$TRADING_STRATEGY_API_KEY

"""

import datetime
import logging

from tradingstrategy.chain import ChainId
from tradeexecutor.strategy.default_routing_options import TradeRouting
from tradingstrategy.pair import HumanReadableTradingPairDescription
from tradingstrategy.timebucket import TimeBucket
from tradeexecutor.strategy.cycle import CycleDuration
from tradeexecutor.strategy.trading_strategy_universe import load_partial_data
from tradingstrategy.client import Client
from tradeexecutor.utils.binance import create_binance_universe
from tradeexecutor.strategy.universe_model import UniverseOptions
from tradingstrategy.lending import LendingProtocolType, LendingReserveDescription
from tradeexecutor.strategy.execution_context import ExecutionContext, ExecutionMode
from tradeexecutor.strategy.pandas_trader.indicator import IndicatorSet, IndicatorSource
from tradeexecutor.strategy.parameters import StrategyParameters
from tradeexecutor.strategy.trading_strategy_universe import TradingStrategyUniverse

from tradeexecutor.state.visualisation import PlotKind
from tradeexecutor.state.trade import TradeExecution
from tradeexecutor.strategy.pandas_trader.strategy_input import StrategyInput
from tradeexecutor.utils.crossover import contains_cross_over, contains_cross_under
from tradeexecutor.strategy.tag import StrategyTag


trading_strategy_engine_version = "0.5"


class Parameters:

    id = "enzyme-polygon-eth-rolling-ratio" # Used in cache paths

    cycle_duration = CycleDuration.cycle_1d
    candle_time_bucket = TimeBucket.d1
    allocation = 0.98
    credit_allocation = 0.99
    
    # rolling mean and standard deviation lengths, used to calculate z-score
    rolling_short_mean = 7
    rolling_long_mean = 65
    rolling_std = 18
    upper_threshold = 1.32 # number standard deviations where z-score is higher than zero to enter a position
    max_upper_threshold = 1.92 # number maximum standard deviations allowed to enter a position

    stop_loss_pct = 0.88
    take_profit_pct = 1.14

    #
    # Live trading only
    #
    chain_id = ChainId.polygon
    routing = TradeRouting.default  
    required_history_period = datetime.timedelta(days=90)
    
    #
    # Backtesting only
    #

    # Use Binance data in backtesting,
    # We get a longer, more meaningful, history but no credit simulation.
    binance_data = True

    if binance_data:
        backtest_start = datetime.datetime(2020, 1, 1)
        backtest_end = datetime.datetime(2024, 7, 15)

    else:
        # dex dates
        backtest_start = datetime.datetime(2022, 10, 1)
        backtest_end = datetime.datetime(2024, 7, 15)
    
    stop_loss_time_bucket = TimeBucket.d1
    backtest_trading_fee = 0.0005
    initial_cash = 10_000



def get_strategy_trading_pairs(mode: ExecutionMode) -> list[HumanReadableTradingPairDescription]:
    """Get trading pairs the strategy uses
    
    - Different options for backtesting
    """
    use_binance = mode.is_backtesting() and Parameters.binance_data 

    if use_binance:
        trading_pairs = [
            (ChainId.centralised_exchange, "binance", "BTC", "USDT"),
            (ChainId.centralised_exchange, "binance", "ETH", "USDT"),
        ]
    else:
        trading_pairs = [
            (ChainId.polygon, "quickswap", "WBTC", "USDC", 0.0030),  # Deep liquidity
            (ChainId.polygon, "uniswap-v3", "WETH", "USDC", 0.0005),  # Deep liquidity
        ]

    return trading_pairs


def get_lending_reserves(mode: ExecutionMode) -> list[LendingReserveDescription]:
    """Get lending reserves the strategy needs."""
    
    use_binance = mode.is_backtesting() and Parameters.binance_data 

    if use_binance:
        # Credit interest is not available on Binance
       return []
    else:
        # and Aave v3 in live execution (more liquid market)
        lending_reserves = [
            (ChainId.polygon, LendingProtocolType.aave_v3, "USDC.e"),
        ]

    return lending_reserves


def create_trading_universe(
    timestamp: datetime.datetime,
    client: Client,
    execution_context: ExecutionContext,
    universe_options: UniverseOptions,
) -> TradingStrategyUniverse:
    """Create the trading universe.

    - In this example, we load all Binance spot data based on our Binance trading pair list.
    """
    trading_pairs = get_strategy_trading_pairs(execution_context.mode)
    lending_reserves = get_lending_reserves(execution_context.mode)

    use_binance = trading_pairs[0][0] == ChainId.centralised_exchange

    if use_binance:
        # Backtesting - load Binance data
        strategy_universe = create_binance_universe(
            [f"{p[2]}{p[3]}" for p in trading_pairs],
            candle_time_bucket=Parameters.candle_time_bucket,
            stop_loss_time_bucket=Parameters.stop_loss_time_bucket,
            start_at=universe_options.start_at,
            end_at=universe_options.end_at,
            trading_fee_override=Parameters.backtest_trading_fee,
            include_lending=False,
            forward_fill=True,
        )
    else:

        if execution_context.live_trading or execution_context.mode == ExecutionMode.preflight_check:
            start_at, end_at = None, None
            required_history_period=Parameters.required_history_period
        else:
            required_history_period = None
            start_at=universe_options.start_at
            end_at=universe_options.end_at

        dataset = load_partial_data(
            client,
            execution_context=execution_context,
            time_bucket=Parameters.candle_time_bucket,
            pairs=trading_pairs,
            universe_options=universe_options,
            start_at=start_at,
            end_at=end_at,
            stop_loss_time_bucket=Parameters.stop_loss_time_bucket,
            lending_reserves=lending_reserves,
            required_history_period=required_history_period
        )

        # Filter down to the single pair we are interested in
        strategy_universe = TradingStrategyUniverse.create_from_dataset(
            dataset,
            forward_fill=True,
        )
    return strategy_universe


# calculating the z-score of target token and other token ratio
def calculate_rolling_ratio(
    strategy_universe: TradingStrategyUniverse,
    short_ma: int,
    long_ma: int,
    std: int,
    execution_mode: ExecutionMode,
):
    trading_pairs = get_strategy_trading_pairs(execution_mode)
    pairs = strategy_universe.data_universe.pairs
    candles = strategy_universe.data_universe.candles

    target_pair = pairs.get_pair_by_human_description(trading_pairs[0])
    target_data = candles.get_candles_by_pair(target_pair)["close"]
    other_pair = pairs.get_pair_by_human_description(trading_pairs[1])
    other_data = candles.get_candles_by_pair(other_pair)["close"]

    ratios = target_data/other_data

    ratios_mavg_short = ratios.rolling(window=short_ma, center=False).mean()
    ratios_mavg_long = ratios.rolling(window=long_ma, center=False).mean()
    ratios_std = ratios.rolling(window=std, center=False).std()
    zscore = (ratios_mavg_short - ratios_mavg_long)/ratios_std
    
    return zscore

def create_indicators(
    timestamp: datetime.datetime | None,
    parameters: StrategyParameters,
    strategy_universe: TradingStrategyUniverse,
    execution_context: ExecutionContext
):
    indicators = IndicatorSet()

    indicators.add(
        "rolling_ratio",
        calculate_rolling_ratio,
        {
            "short_ma": parameters.rolling_short_mean,
            "long_ma": parameters.rolling_long_mean,
            "std": parameters.rolling_std,
            "execution_mode": execution_context.mode,
        },
        source=IndicatorSource.strategy_universe,
    )
    return indicators


def decide_trades(
    input: StrategyInput,
) -> list[TradeExecution]:
    # 
    # Decidion cycle setup.
    # Read all variables we are going to use for the decisions.
    #
    parameters: Parameters = input.parameters
    position_manager = input.get_position_manager()
    state = input.state
    timestamp = input.timestamp
    indicators = input.indicators
    strategy_universe = input.strategy_universe
    execution_mode = input.execution_context.mode
    cash = position_manager.get_current_cash()
    trading_pairs = get_strategy_trading_pairs(execution_mode)
    lending_reserves = get_lending_reserves(execution_mode)

    pair = strategy_universe.get_pair_by_human_description(trading_pairs[1])

    target_price = indicators.get_price(trading_pairs[0])
    close_price = indicators.get_price(trading_pairs[1])

    trades = []

    # Setup asset allocation parameters
    use_credit = len(lending_reserves) > 0

    # If any of trading pairs enters to long position,
    # close our credit position
    credit_closed = False
    traded_this_cycle = False
    available_cash = cash
    ready = False

    z_score = indicators.get_indicator_value("rolling_ratio")

    if None in (z_score, target_price, close_price):
        # Not enough historic data, cannot make decisions yet.
        # Should never happen in live trading,
        # so try to print out some useful diagnostics what might be wrong.
        if execution_mode.is_live_trading():
            z_score_df = indicators.get_indicator_series("rolling_ratio")
            position_manager.log(
                f"""Strategy does not have enough data to make any decisions
                close_price: {close_price}
                target_price: {target_price}
                z-score: {z_score}
                z_score_df: {z_score_df}
                """,
                level=logging.WARNING,
            )
        return []

    # We have accumulatd enough data to make the first real (non credit) trading decision.
    # This allows us to have fair buy-and-hold vs backtest period comparison
    state.mark_ready(timestamp)

    # Check for open condition
    if state.portfolio.get_open_position_for_pair(pair) is None:        
        if parameters.upper_threshold < z_score < parameters.max_upper_threshold:
            # close credit supply position before opening a new long position
            if position_manager.is_any_credit_supply_position_open():
                current_pos = position_manager.get_current_credit_supply_position()
                new_trades = position_manager.close_credit_supply_position(current_pos)
                trades.extend(new_trades)
                # Est. available cash after all credit positions are closed
                available_cash += float(current_pos.get_quantity()) 

            trades += position_manager.open_spot(
                pair,
                value=available_cash * parameters.allocation,
                stop_loss_pct=parameters.stop_loss_pct,
                take_profit_pct=parameters.take_profit_pct,
            )

            traded_this_cycle = True


    # If we have any access cash or new deposit, move them to Aave
    if use_credit and not traded_this_cycle:
        cash_to_deposit = available_cash * parameters.credit_allocation
        new_trades = position_manager.add_cash_to_credit_supply(cash_to_deposit)
        trades += new_trades

    # Visualisations
    if input.is_visualisation_enabled():
        visualisation = state.visualisation
        visualisation.plot_indicator(
            timestamp,
            "rolling_ratio", 
            PlotKind.technical_indicator_on_price,
            z_score,
            pair=pair,
        )
        
    return trades


#
# Strategy metadata.
#
# Displayed in the user interface.
#

tags = {StrategyTag.live, StrategyTag.beta}

name = "ETH/BTC rolling ratio"

short_description = "A pair trading strategy for ETH/BTC"

icon = ""

long_description = """
# Strategy description

**Past performance is not indicative of future results**.

This is a statistical arbitrage strategy.

The strategy uses the normalized ratio of BTC price divided by ETH price. The fundamental assumption is that ETH price follows BTC price (high correlation), and thus when the normalized ratio value is far from equilibrium (zero), the ratio will revert to equilibrium.

- The strategy only takes long positions in ETH, as ETH has been observed to have more volatility and thus will be the asset to have higher price action in times where ratio is far from equilibrium.
- Short positions have been excluded in this version of the strategy as negative price actions happen quicker. This makes prediction of negative trends more difficult with the rolling ratio based model.
- The strategy calculates rolling Z-score with long term and short term moving averages, as well as rolling standard deviation of BTC/ETH price ratio
- Enters a long ETH position when rolling Z-score is in acceptable boundaries
- Exits positions when the profit threshold or a stop loss limit is reached

The strategy enables

- Capturing gains in bull markets (like July 2021 - December 2022, and October 2023 - February 2024)
- Capturing gains in neutral markets (like January 2023 - July 2023)
- Reducing max drawdowns in bear markets compared to pure buy&hold strategies with ETH or BTC

Furthermore

- The strategy deposits excess cash to Aave V3 USDC pool to gain interest on cash

## Assets and trading venues

- The strategy trades only spot market
- Trade only single asset: ETH
- The strategy keeps reserves in USDC stablecoin
- Trading takes place on Uniswap on Polygon blockchain
- The strategy decision cycle is one day

## Backtesting

The strategy parameters (length for calculating rolling Z-score, and take profit and stop loss limits) have been optimized using Binance data from 1 January 2021 to 31 March 2024. Backtests have been carried out for subsets of this timeframe.

## Profit

The backtested historical results indicate 104.5% estimated yearly profit (CAGR).

This is above the historical profit you would have gotten by buying and holding BTC or ETH.

## Risk

This strategy has produced a maximum -22.8% backtested drawdown. This is much less severe compared to buy and hold, making the strategy less risky than buy and hold historically.

For further understanding the key aspects of risks

- The strategy does not use any leverage
- The strategy trades only the established, highly liquid, trading pair ETH-USDC which is unlikely to go zero based on historical data

"""

# Fees
management_fee = 0
trading_strategy_protocol_fee = 0.02
strategy_developer_fee = 0.05