Strategy icon

ETH-BTC-USDC momentum beta

ETH and BTC momentum strategy to maximize gains in bull market and avoid losses in bear market, on Polygon

Source code

The source code of the ETH-BTC-USDC momentum strategy

"""ETH-BTC-USDC 1h rebalance strategy, high fee variant.

- See https://tradingstrategy.ai/blog/outperfoming-eth for the strategy development information

To backtest this strategy module locally:

.. code-block:: console

    trade-executor \
        backtest \
        --strategy-file=strategies/eth-btc-usdc.py \
        --trading-strategy-api-key=$TRADING_STRATEGY_API_KEY

To see the backtest for longer history, refer to the notebook doing backtest with Binance data.
"""
import datetime

import pandas_ta
import pandas as pd

from tradeexecutor.state.trade import TradeExecution
from tradeexecutor.state.visualisation import PlotKind, PlotLabel, PlotShape
from tradeexecutor.strategy.alpha_model import AlphaModel
from tradeexecutor.strategy.cycle import CycleDuration
from tradeexecutor.strategy.default_routing_options import TradeRouting
from tradeexecutor.strategy.execution_context import ExecutionContext, ExecutionMode
from tradeexecutor.strategy.pandas_trader.indicator import IndicatorSet, IndicatorSource
from tradeexecutor.strategy.pandas_trader.strategy_input import StrategyInput
from tradeexecutor.strategy.parameters import StrategyParameters
from tradeexecutor.strategy.tag import StrategyTag
from tradeexecutor.strategy.trading_strategy_universe import TradingStrategyUniverse, load_partial_data
from tradeexecutor.strategy.universe_model import UniverseOptions
from tradeexecutor.strategy.weighting import weight_passthrouh
from tradeexecutor.utils.binance import create_binance_universe
from tradingstrategy.chain import ChainId
from tradingstrategy.client import Client
from tradingstrategy.pair import HumanReadableTradingPairDescription
from tradingstrategy.timebucket import TimeBucket
from tradingstrategy.utils.groupeduniverse import resample_price_series

trading_strategy_engine_version = "0.5"


def get_strategy_trading_pairs(execution_mode: ExecutionMode) -> list[HumanReadableTradingPairDescription]:
    """Switch between backtest and live trading pairs.

    Because the live trading DEX venues do not have enough history (< 2 years)
    for meaningful backtesting, we test with Binance CEX data.
    """
    # if execution_mode.is_live_trading():
    # Live trade
    return [
        (ChainId.polygon, "quickswap", "WBTC", "WETH", 0.0030), 
        (ChainId.polygon, "uniswap-v3", "WETH", "USDC", 0.0005),
        (ChainId.polygon, "quickswap", "WETH", "USDC", 0.0030), # keep this temporarily here for pricing
    ]
    # else:
    #     # Backtest - Binance fee matched to DEXes with Parameters.backtest_trading_fee
    #     return [
    #         (ChainId.centralised_exchange, "binance", "BTC", "USDT"),
    #         (ChainId.centralised_exchange, "binance", "ETH", "USDT"),
    #     ]


class Parameters:
    """Parameteres for this strategy.

    - Collect parameters used for this strategy here

    - Both live trading and backtesting parameters
    """

    cycle_duration = CycleDuration.cycle_1d  # Run decide_trades() every 8h
    source_time_bucket = TimeBucket.d1  # Use 1h candles as the raw data
    target_time_bucket = TimeBucket.d1  # Create synthetic 8h candles
    clock_shift_bars = 0  # Do not do shifted candles

    rsi_bars = 8  # Number of bars to calculate RSI for each tradingbar
    eth_btc_rsi_bars = 5  # Number of bars for the momentum factor

    # RSI parameters for bull and bear market
    bearish_rsi_entry = 65
    bearish_rsi_exit = 70
    bullish_rsi_entry = 80
    bullish_rsi_exit = 65

    regime_filter_ma_length = 200  # Bull/bear MA begime filter in days
    regime_filter_only_btc = 1   # Use BTC or per-pair regime filter

    allocation = 0.98  # How much cash allocate for volatile positions
    rebalance_threshold = 0.275  # How much position mix % must change when we rebalance between two open positions
    initial_cash = 10_000  # Backtesting start cash
    trailing_stop_loss = None  # Trailing stop loss as 1 - x
    trailing_stop_loss_activation_level = None  # How much above opening price we must be before starting to use trailing stop loss
    stop_loss = None  # 0.80  # Hard stop loss when opening a new position
    momentum_exponent = 3.5  # How much momentum we capture when rebalancing between open positions

    #
    # Live trading only
    #
    chain_id = ChainId.polygon
    routing = TradeRouting.default  # Pick default routes for trade execution
    required_history_period = datetime.timedelta(days=regime_filter_ma_length) * 2  # Ask some extra history just in case

    #
    # Backtesting only
    #

    backtest_start = datetime.datetime(2023, 8, 1)
    backtest_end = datetime.datetime(2024, 3, 15)
    stop_loss_time_bucket = TimeBucket.h1  # use 1h close as the stop loss signal
    backtest_trading_fee = 0.0030  # Switch to QuickSwap 30 BPS free from the default Binance 5 BPS fee


def calculate_eth_btc(strategy_universe: TradingStrategyUniverse, mode: ExecutionMode):
    """Calculate ETH/BTC price used as a rebalance factor."""
    pair_ids = get_strategy_trading_pairs(mode)
    eth = strategy_universe.get_pair_by_human_description(pair_ids[1])
    btc = strategy_universe.get_pair_by_human_description(pair_ids[0])
    btc_price = strategy_universe.data_universe.candles.get_candles_by_pair(btc.internal_id)
    eth_price = strategy_universe.data_universe.candles.get_candles_by_pair(eth.internal_id)
    series = eth_price["close"] / btc_price["close"]  # Divide two series
    return series


def calculate_eth_btc_rsi(strategy_universe: TradingStrategyUniverse, mode: ExecutionMode, length: int):
    """Calculate x hours RSI for MATIC/ETH price used as the rebalancing factor."""
    eth_btc_series = calculate_eth_btc(strategy_universe, mode)
    return pandas_ta.rsi(eth_btc_series, length=length)


def calculate_resampled_rsi(pair_close_price_series: pd.Series, length: int, upsample: TimeBucket, shift: int):
    """Calculate x hours RSI for a particular trading pair"""
    resampled_close = resample_price_series(pair_close_price_series, upsample.to_pandas_timedelta(), shift=shift)
    return pandas_ta.rsi(resampled_close, length=length)


def calculate_resampled_eth_btc(strategy_universe: TradingStrategyUniverse, mode: ExecutionMode, upsample: TimeBucket, shift: int):
    """Calculate BTC/ETH price series for x hours."""
    pair_ids = get_strategy_trading_pairs(mode)
    eth = strategy_universe.get_pair_by_human_description(pair_ids[1])
    btc = strategy_universe.get_pair_by_human_description(pair_ids[0])
    eth_price = strategy_universe.data_universe.candles.get_candles_by_pair(eth.internal_id)
    btc_price = strategy_universe.data_universe.candles.get_candles_by_pair(btc.internal_id)
    resampled_eth = resample_price_series(eth_price["close"], upsample.to_pandas_timedelta(), shift=shift)
    resampled_btc = resample_price_series(btc_price["close"], upsample.to_pandas_timedelta(), shift=shift)
    series = resampled_eth / resampled_btc
    return series


def calculate_resampled_eth_btc_rsi(strategy_universe: TradingStrategyUniverse, mode: ExecutionMode, length: int, upsample: TimeBucket, shift: int):
     """Caclulate RSI for MATIC/ETH price series for x hours."""
     etc_btc = calculate_resampled_eth_btc(strategy_universe, mode, upsample, shift)
     return pandas_ta.rsi(etc_btc, length=length)


def calculate_shifted_sma(pair_close_price_series: pd.Series, length: int, upsample: TimeBucket, shift: int):
    resampled_close = resample_price_series(pair_close_price_series, upsample.to_pandas_timedelta(), shift=shift)
    return pandas_ta.sma(resampled_close, length=length)


def create_indicators(
    timestamp: datetime.datetime,
    parameters: StrategyParameters,
    strategy_universe: TradingStrategyUniverse,
    execution_context: ExecutionContext
):
    """Create indicators for this trading strategy.

    - Because we use non-standard candle time bucket, we do upsamplign from 1h candles
    """
    indicators = IndicatorSet()
    mode = execution_context.mode  # Switch between live trading and backtesting pairs
    upsample = parameters.target_time_bucket
    shift = parameters.clock_shift_bars
    indicators.add(
        "rsi", calculate_resampled_rsi,
        {"length": parameters.rsi_bars, "upsample": upsample, "shift": shift}
    )
    indicators.add(
        "eth_btc",
        calculate_resampled_eth_btc,
        {"upsample": parameters.target_time_bucket, "mode": mode, "shift": shift},
        source=IndicatorSource.strategy_universe
    )
    indicators.add(
        "eth_btc_rsi",
        calculate_resampled_eth_btc_rsi,
        {"length": parameters.eth_btc_rsi_bars, "mode": mode, "upsample": upsample, "shift": shift},
        source=IndicatorSource.strategy_universe
    )
    indicators.add(
        "sma",
        calculate_shifted_sma,
        {"length": parameters.regime_filter_ma_length, "upsample": upsample, "shift": shift}
    )
    return indicators


def decide_trades(
    input: StrategyInput,
) -> list[TradeExecution]:
    """Trade logic."""

    # Resolve some variables we are going to use to here
    parameters = input.parameters
    position_manager = input.get_position_manager()
    state = input.state
    timestamp = input.timestamp
    indicators = input.indicators
    shift = parameters.clock_shift_bars
    clock_shift = pd.Timedelta(hours=1) * shift
    upsample = parameters.target_time_bucket
    mode = input.execution_context.mode
    our_pairs = get_strategy_trading_pairs(mode)

    # Execute the daily trade cycle when the clock hour 0..24 is correct for our hourly shift
    assert upsample.to_timedelta() >= parameters.cycle_duration.to_timedelta(), "Upsample period must be longer than cycle period"
    assert shift <= 0  # Shift -1 = do action 1 hour later

    # Override the trading fee to simulate worse DEX fees and price impact vs. Binance
    if mode.is_backtesting():
        if parameters.backtest_trading_fee:
            input.pricing_model.set_trading_fee_override(parameters.backtest_trading_fee)

    # Do the clock shift trick
    if parameters.cycle_duration.to_timedelta() != upsample.to_timedelta():
        if (input.cycle - 1 + shift) % int(upsample.to_hours()) != 0:
            return []

    alpha_model = AlphaModel(input.timestamp)
    btc_pair = position_manager.get_trading_pair(our_pairs[0])
    eth_pair = position_manager.get_trading_pair(our_pairs[1])
    position_manager.log("decide_trades() start")

    #
    # Indicators
    #
    # Calculate indicators for each pair.
    #

    # Per-trading pair calcualted data
    current_rsi_values = {}  # RSI yesterday
    previous_rsi_values = {}  # RSI day before yesterday
    current_price = {}  # Close price yesterday
    momentum = {btc_pair: 0, eth_pair: 0}

    for pair in [btc_pair, eth_pair]:
        current_price[pair] = indicators.get_price(pair)
        current_rsi_values[pair] = indicators.get_indicator_value("rsi", index=-1, pair=pair, clock_shift=clock_shift)
        previous_rsi_values[pair] = indicators.get_indicator_value("rsi", index=-2, pair=pair, clock_shift=clock_shift)

    eth_btc_yesterday = indicators.get_indicator_value("eth_btc", clock_shift=clock_shift)
    eth_btc_rsi_yesterday = indicators.get_indicator_value("eth_btc_rsi", clock_shift=clock_shift)
    if eth_btc_rsi_yesterday is not None:
        eth_momentum = (eth_btc_rsi_yesterday / 100) + 0.5
        btc_momentum = (1 - (eth_btc_rsi_yesterday / 100)) + 0.5
        momentum[eth_pair] = eth_momentum ** parameters.momentum_exponent
        momentum[btc_pair] = btc_momentum ** parameters.momentum_exponent

    #
    # Trading logic
    #

    for pair in [btc_pair, eth_pair]:

        #
        # Regime filter
        #
        # If no indicator data yet, or regime filter disabled,
        # be always bullish
        bullish = True
        if parameters.regime_filter_ma_length:  # Regime filter is not disabled
            regime_filter_pair = btc_pair if parameters.regime_filter_only_btc else pair  # Each pair has its own bullish/bearish regime?
            regime_filter_price = current_price[regime_filter_pair]
            sma = indicators.get_indicator_value("sma", index=-1, pair=regime_filter_pair, clock_shift=clock_shift)
            if sma:
                # We are bearish if close price is beloe SMA
                bullish = regime_filter_price > sma

        if bullish:
            rsi_entry = parameters.bullish_rsi_entry
            rsi_exit = parameters.bullish_rsi_exit
        else:
            rsi_entry = parameters.bearish_rsi_entry
            rsi_exit = parameters.bearish_rsi_exit

        existing_position = position_manager.get_current_position_for_pair(pair)
        pair_open = existing_position is not None
        closed_positions = position_manager.get_closed_positions_for_pair(pair)
        has_no_position = not pair_open and len(closed_positions) == 0 and mode.is_live_trading()
        pair_momentum = momentum.get(pair, 0)
        signal_strength = max(pair_momentum, 0.1)  # Singal strength must be positive, as we do long-only
        if pd.isna(signal_strength):
            signal_strength = 0
        alpha_model.set_signal(pair, 0)

        if pair_open:
            # We have existing open position for this pair,
            # keep it open by default unless we get a trigger condition below
            position_manager.log(f"Pair {pair} already open")
            alpha_model.set_signal(pair, signal_strength, stop_loss=parameters.stop_loss)

        if current_rsi_values[pair] and previous_rsi_values[pair]:

            # Check for RSI crossing our threshold values in this cycle, compared to the previous cycle
            if rsi_entry:
                rsi_cross_above = current_rsi_values[pair] >= rsi_entry and previous_rsi_values[pair] < rsi_entry
            else:
                # bearish_rsi_entry = None -> don't trade in bear market
                rsi_cross_above = False

            rsi_cross_below = current_rsi_values[pair] < rsi_exit and previous_rsi_values[pair] > rsi_exit

            if not pair_open:
                # Check for opening a position if no position is open
                if rsi_cross_above or has_no_position:
                    position_manager.log(f"Pair {pair} crossed above")
                    alpha_model.set_signal(pair, signal_strength, stop_loss=parameters.stop_loss)
            else:
                # We have open position, check for the close condition
                if rsi_cross_below:
                    position_manager.log(f"Pair {pair} crossed below")
                    alpha_model.set_signal(pair, 0)

    # Enable trailing stop loss if we have reached the activation level
    if parameters.trailing_stop_loss_activation_level is not None and parameters.trailing_stop_loss is not None:
       for p in state.portfolio.open_positions.values():
           if p.trailing_stop_loss_pct is None:
               if current_price[p.pair] >= p.get_opening_price() * parameters.trailing_stop_loss_activation_level:
                   p.trailing_stop_loss_pct = parameters.trailing_stop_loss

    # Use alpha model and construct a portfolio of two assets
    alpha_model.select_top_signals(2)
    alpha_model.assign_weights(weight_passthrouh)
    alpha_model.normalise_weights()
    alpha_model.update_old_weights(state.portfolio)
    portfolio = position_manager.get_current_portfolio()
    portfolio_target_value = portfolio.get_total_equity() * parameters.allocation
    alpha_model.calculate_target_positions(position_manager, portfolio_target_value)
    trades = alpha_model.generate_rebalance_trades_and_triggers(
        position_manager,
        min_trade_threshold=parameters.rebalance_threshold * portfolio.get_total_equity(),
    )

    #
    # Visualisations
    #

    if input.is_visualisation_enabled():

        visualisation = state.visualisation  # Helper class to visualise strategy output

        # BTC RSI daily
        if current_rsi_values[btc_pair]:
            visualisation.plot_indicator(
                timestamp,
                f"RSI BTC",
                PlotKind.technical_indicator_detached,
                current_rsi_values[btc_pair],
                colour="orange",
                pair=btc_pair,
            )

            # RSI exit value
            visualisation.plot_indicator(
                timestamp,
                f"RSI exit trigger",
                PlotKind.technical_indicator_overlay_on_detached,
                rsi_exit,
                detached_overlay_name=f"RSI BTC",
                colour="red",
                label=PlotLabel.hidden,
                pair=btc_pair,
            )

            # RSI entry value
            visualisation.plot_indicator(
                timestamp,
                f"RSI entry trigger",
                PlotKind.technical_indicator_overlay_on_detached,
                rsi_entry,
                detached_overlay_name=f"RSI BTC",
                colour="red",
                label=PlotLabel.hidden,
                pair=btc_pair,
            )

        # ETH RSI daily
        if current_rsi_values[eth_pair]:
            visualisation.plot_indicator(
                timestamp,
                f"RSI ETH",
                PlotKind.technical_indicator_overlay_on_detached,
                current_rsi_values[eth_pair],
                colour="blue",
                label=PlotLabel.hidden,
                detached_overlay_name=f"RSI BTC",
                pair=btc_pair,
            )
        
        # only useful for development
        # if eth_btc_yesterday is not None:
        #     visualisation.plot_indicator(
        #         timestamp,
        #         f"ETH/BTC",
        #         PlotKind.technical_indicator_detached,
        #         eth_btc_yesterday,
        #         colour="grey",
        #         pair=eth_pair,
        #     )

        # if eth_btc_rsi_yesterday is not None:
        #     visualisation.plot_indicator(
        #         timestamp,
        #         f"ETH/BTC RSI",
        #         PlotKind.technical_indicator_detached,
        #         eth_btc_rsi_yesterday,
        #         colour="grey",
        #         pair=eth_pair,
        #     )

        state.visualisation.add_calculations(timestamp, alpha_model.to_dict())  # Record alpha model thinking

    position_manager.log(
        f"BTC RSI: {current_rsi_values[btc_pair]}, BTC RSI yesterday: {previous_rsi_values[btc_pair]}",
    )

    return trades


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

    - For live trading, we load DEX data

    - We backtest with Binance data, as it has more history
    """

    pair_ids = get_strategy_trading_pairs(execution_context.mode)

    if execution_context.mode.is_backtesting():
        # Backtesting - load Binance data
        start_at = universe_options.start_at
        end_at = universe_options.end_at
        strategy_universe = create_binance_universe(
            [f"{p[2]}{p[3]}" for p in pair_ids],
            candle_time_bucket=Parameters.source_time_bucket,
            stop_loss_time_bucket=Parameters.stop_loss_time_bucket,
            start_at=start_at,
            end_at=end_at,
            trading_fee_override=Parameters.backtest_trading_fee,
        )
    else:
        # Live trading - load DEX data
        universe_options = UniverseOptions(
            history_period=Parameters.required_history_period,
            start_at=None,
            end_at=None,
        )

        dataset = load_partial_data(
            client=client,
            time_bucket=Parameters.source_time_bucket,
            pairs=pair_ids,
            execution_context=execution_context,
            universe_options=universe_options,
            liquidity=False,
            stop_loss_time_bucket=Parameters.stop_loss_time_bucket,
        )
        # Construct a trading universe from the loaded data,
        # and apply any data preprocessing needed before giving it
        # to the strategy and indicators
        strategy_universe = TradingStrategyUniverse.create_from_dataset(
            dataset,
            reserve_asset="USDC",
            forward_fill=True,
        )

    return strategy_universe

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

tags = {StrategyTag.beta, StrategyTag.live}

name = "ETH-BTC-USDC momentum"

short_description = "ETH and BTC momentum strategy to maximize gains in bull market and avoid losses in bear market, on Polygon"

icon = "https://tradingstrategy.ai/avatars/polygon-eth-spot-short.webp"

long_description = """
# Strategy description

This strategy is a momentum and breakout strategy.

- The strategy trades ETH and BTC over long term time horizon, doing only few trades per a year.
- The strategy delivers similar profits as buying and holding ETH and BTC, but with much less severe drawdowns.
- The strategy performs well in long-term Bitcoin [bull market](https://tradingstrategy.ai/glossary/bull-market).
- In [bear](https://tradingstrategy.ai/glossary/bear-market) and sideways markets the strategy does not perform well.
- It is based on [RSI technical indicator](https://tradingstrategy.ai/glossary/relative-strength-index-rsi), the strategy is buying when others are buying, and the strategy is selling when others are selling.

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

## Assets and trading venues

- The strategy trades only spot market
- We trade two trading asset: ETH and BTC
- The strategy keeps reserves in USDC stablecoin
- The trading happens on QuickSwap and Uniswap on Polygon blockchain
- The strategy decision cycle is daily rebalances

## Backtesting

The backtesting was performed with Binance ETH-USDT and BTC-USDT data of 2019-2024.

- [See backtesting results](./backtest)
- [Read more about what is backtesting](https://tradingstrategy.ai/glossary/backtest).

The backtesting trading venue (Binance) is different from the live trading venue (Quickswap, Uniswap), because DEX markets
do not have long enough history to result to a meaningful backtest.

The backtesting period saw one bull market rally that is unlikely to repeat in the same magnitude 
for the assets we trade.

Past peformance is no guarantee of future results. Like with manual trading, automated trading is unlikely to be perfect.
There will be variance in the range of 30% - 50% in the results.

## Profit

The backtested results indicate **80%** estimated yearly profit ([CAGR](https://tradingstrategy.ai/glossary/compound-annual-growth-rate-cagr)). 

This is similar profit as you would get by buying and holding BTC or ETH.

## Risk

This strategy has **-30%** backtested [maximum drawdown](https://tradingstrategy.ai/glossary/maximum-drawdown).
This is much less severe compared to buy and hold, making the strategy less risky than buy and hold.

For further understanding the key aspescts of risks
- The strategy does not use any leverage
- The strategy trades only established, highly liquid, trading pairs which are unlikely to go zero 

## Benchmark

For the same backtesting period, here are some benchmark of performance of different assets and indices:

|                              | CAGR | Maximum drawdown | Sharpe |
|------------------------------|------|------------------|--------|
| This strategy                | 84%  | -34%             | 1.78   |
| SP500 (20 years)             | 11%  | -33%             | 0.72   |
| Bitcoin (backtesting period) | 76%  | -76%             | 1.17   |
| Ether (backtesting period)   | 85%  | -79%             | 1.18   |


Sources:

- [Our strategy](./backtest)
- [Buy and hold BTC](./backtest)
- [Buy and hold ETH](./backtest)
- [SP500 stock index](https://curvo.eu/backtest/en/portfolio/s-p-500--NoIgygZACgBArABgSANMUBJAokgQnXAWQCUEAOAdlQEYBdeoA?config=%7B%22periodStart%22%3A%222004-02%22%7D)

## Trading frequency

The strategy is very slow moving macro-like strategy.

This strategy is estimated to to rebalance every **20 days** and enter/exit positions even less frequently. 

## Robustness

This strategy does not have good robustness tests available.

## Updates

This is one of the early, simple, strategies deployed on Trading Strategy protocol.

It is likely this strategy will be replaced with a newer, more robust, more optimised, version in some point of the future.
[Follow Trading Strategy for updates](https://tradingstrategy.ai/community) as you need to move your balance to a new strategy.

## Further information

- Any questions are welcome in [the Discord community chat](https://tradingstrategy.ai/community)
- See the blog post [on how this strategy is constructed](https://tradingstrategy.ai/blog/outperfoming-eth) 

"""
TradingStrategy.ai operated by Trading Strategy Operations Ltd., Victoria, Mahe, Seychelles.