Tags: ema, trend-analysis, synthetic-data, dark-theme

Osmosis ATOM-OSMO mean reversion strategy#

This is an example notebook how to backtest trading strategies on Osmosis Cosmos DEX. It is based on work done in HackAtom Seoul 2022 hackathon.

Some highlights of this notebook:

Set up#

Set up strategy paramets that will decide its behavior

[1]:
import sys

import datetime
import logging

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 TradeRouting, ReserveCurrency

# Rebalance every 8h
trading_strategy_cycle = CycleDuration.cycle_8h

# How much of the cash to put on a single trade
position_size = 0.90

#
# Strategy thinking specific parameter
#

# 14 days
slow_ema_candle_count = 14*24

# 5 days
fast_ema_candle_count = 5*24

# How many candles to extract from the dataset once
batch_size = slow_ema_candle_count * 2

# Range of backtesting and synthetic data generation.
# Because we are using synthetic data actual dates do not really matter -
# only the duration

# Osmosis launched at 2021-12-25
start_at = datetime.datetime(2022, 1, 25)

# When our data ends
end_at = datetime.datetime(2023, 1, 25)

Strategy logic and trade decisions#

decide_trades function decide what trades to take. In this example, we calculate two exponential moving averages (EMAs) and make decisions based on those.

[2]:
from typing import List, Dict

from pandas_ta.overlap import ema

from tradingstrategy.universe import Universe

from tradeexecutor.state.visualisation import PlotKind
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]:
    """The brain function to decide the trades on each trading strategy cycle."""

    # The pair we are trading
    pair = universe.pairs.get_single()

    assert pair.token0_symbol == "ATOM", f"Got pair {pair}"
    assert pair.token1_symbol == "OSMO", f"Got pair {pair}"

    # How much cash we have in the hand
    cash = state.portfolio.get_current_cash()

    # Get OHLCV candles for our trading pair as Pandas Dataframe.
    # We could have candles for multiple trading pairs in a different strategy,
    # but this strategy only operates on single pair candle.
    # We also limit our sample size to N latest candles to speed up calculations.
    candles: pd.DataFrame = universe.candles.get_single_pair_data(timestamp, sample_count=batch_size)

    # We have data for open, high, close, etc.
    # We only operate using candle close values in this strategy.
    close = candles["close"]

    # Calculate exponential moving averages based on slow and fast sample numbers.
    # https://github.com/twopirllc/pandas-ta
    # https://github.com/twopirllc/pandas-ta/blob/bc3b292bf1cc1d5f2aba50bb750a75209d655b37/pandas_ta/overlap/ema.py#L7
    slow_ema_series = ema(close, length=slow_ema_candle_count)
    fast_ema_series = ema(close, length=fast_ema_candle_count)

    if slow_ema_series is None or fast_ema_series is None:
        # Cannot calculate EMA, because
        # not enough samples in backtesting
        return []

    slow_ema = slow_ema_series.iloc[-1]
    fast_ema = fast_ema_series.iloc[-1]

    # Get the last close price from close time series
    # that's Pandas's Series object
    # https://pandas.pydata.org/docs/reference/api/pandas.Series.iat.html
    current_price = close.iloc[-1]

    # List of any trades we decide on this cycle.
    # Because the strategy is simple, there can be
    # only zero (do nothing) or 1 (open or close) trades
    # decides
    trades = []

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


    if not position_manager.is_any_open():
        if current_price >= slow_ema:
            # Entry condition:
            # Close price is higher than the slow EMA
            buy_amount = cash * position_size
            trades += position_manager.open_1x_long(pair, buy_amount)
    else:
        if slow_ema >= fast_ema:
        # Exit condition:
        # Fast EMA crosses slow EMA
            trades += position_manager.close_all()

    # Visualize strategy
    # See available Plotly colours here
    # https://community.plotly.com/t/plotly-colours-list/11730/3?u=miohtama
    visualisation = state.visualisation
    visualisation.plot_indicator(timestamp, "Slow EMA", PlotKind.technical_indicator_on_price, slow_ema, colour="darkblue")
    visualisation.plot_indicator(timestamp, "Fast EMA", PlotKind.technical_indicator_on_price, fast_ema, colour="#003300")

    return trades

Defining trading universe#

We create a trading universe with a single blockchain, exchange and trading pair. For the sake of easier understanding the code, we name this “Uniswap v2” like exchange with a single ETH-USDC trading pair.

The trading pair contains generated noise-like OHLCV trading data.

[3]:

from pathlib import Path
import os
import random
from tradeexecutor.state.identifier import AssetIdentifier, TradingPairIdentifier
from tradingstrategy.candle import GroupedCandleUniverse
from tradeexecutor.testing.synthetic_ethereum_data import generate_random_ethereum_address
from tradeexecutor.testing.synthetic_exchange_data import generate_exchange
from tradeexecutor.testing.synthetic_price_data import generate_ohlcv_candles, load_ohlcv_parquet_file
from tradeexecutor.strategy.trading_strategy_universe import TradingStrategyUniverse, \
    create_pair_universe_from_code

def create_trading_universe() -> TradingStrategyUniverse:

    # Set up fake assets
    mock_chain_id = ChainId.osmosis
    mock_exchange = generate_exchange(
        exchange_id=random.randint(1, 1000),
        chain_id=mock_chain_id,
        address=generate_random_ethereum_address())
    # Cosmos tokens use micro token (u-token) as the smallest unit
    osmo = AssetIdentifier(ChainId.osmosis.value, generate_random_ethereum_address(), "OSMO", 6, 1)
    atom = AssetIdentifier(ChainId.osmosis.value, generate_random_ethereum_address(), "ATOM", 6, 2)
    atom_osmo = TradingPairIdentifier(
        atom,
        osmo,
        generate_random_ethereum_address(),
        mock_exchange.address,
        internal_id=random.randint(1, 1000),
        internal_exchange_id=mock_exchange.exchange_id,
        fee=0.0005
    )

    time_bucket = TimeBucket.d1

    pair_universe = create_pair_universe_from_code(mock_chain_id, [atom_osmo])

    # Hack
    import tradeexecutor
    ohlcv_sample_path = os.path.join(os.path.dirname(tradeexecutor.__file__), "..", "notebooks", "pool_1_hourly_candles.parquet")

    ohlcv_sample_path = Path(ohlcv_sample_path)

    print(f"Loading data from {ohlcv_sample_path.as_posix()}")

    # Load candles for backtesting
    candles = load_ohlcv_parquet_file(
        ohlcv_sample_path,
        mock_chain_id,
        mock_exchange.exchange_id,
        atom_osmo.internal_id)

    # Does not work properly for Osmosis data
    del candles["volume"]

    candle_universe = GroupedCandleUniverse.create_from_single_pair_dataframe(candles)

    universe = Universe(
        time_bucket=time_bucket,
        chains={mock_chain_id},
        exchanges={mock_exchange},
        pairs=pair_universe,
        candles=candle_universe,
        liquidity=None
    )

    return TradingStrategyUniverse(universe=universe, reserve_assets=[osmo])


Running the 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.

[4]:
from tradeexecutor.testing.synthetic_exchange_data import generate_simple_routing_model
from tradeexecutor.backtest.backtest_runner import run_backtest_inline

universe = create_trading_universe()

start_candle, end_candle = universe.universe.candles.get_timestamp_range()
print(f"Our universe has synthetic candle data for the period {start_candle} - {end_candle}")

# This function set ups trade routing for our synthetic trading universe.
# Because we have only one trading pair, there is no complicated
# routing needed
routing_model = generate_simple_routing_model(universe)

state, universe,    debug_dump = run_backtest_inline(
    name="ATOM/OSMO backtest",
    start_at=start_at,
    end_at=end_at,
    client=None,  # None of downloads needed, because we are using synthetic data
    cycle_duration=CycleDuration.cycle_1d,  # Override to use 24h cycles despite what strategy file says
    decide_trades=decide_trades,
    universe=universe,
    initial_deposit=10_000,
    reserve_currency=ReserveCurrency.busd,
    trade_routing=TradeRouting.user_supplied_routing_model,
    routing_model=routing_model,
    log_level=logging.WARNING,
)

Loading data from /home/alex/work/tradingstrategy-ai/stuff/docs/deps/trade-executor/tradeexecutor/../notebooks/pool_1_hourly_candles.parquet
Our universe has synthetic candle data for the period 2021-12-25 00:00:00 - 2022-04-28 12:00:00

Examine backtest results#

Examine state that contains all actions the trade executor took.

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

[5]:
print(f"Positions taken: {len(list(state.portfolio.get_all_positions()))}")
print(f"Trades made: {len(list(state.portfolio.get_all_trades()))}")
print(f"Visualisation entries: {len(list(state.visualisation.plots))}")
Positions taken: 1
Trades made: 2
Visualisation entries: 2
[6]:
from tradeexecutor.visual.single_pair import visualise_single_pair

figure = visualise_single_pair(state, universe.universe.candles)
figure.update_layout(template="plotly_dark")  # Dark color theme https://plotly.com/python/templates/
figure.show()

Equity curve and drawdown#

Visualise equity curve and related performnace over time.

  • Returns

  • Drawdown

  • Daily returns

[7]:
# 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)
[7]:
../../_images/programming_strategy-examples_osmosis_14_0.svg

Returns monthly breakdown#

  • Monthly returns

  • Best day/week/month/year

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

visualise_returns_over_time(returns)
[8]:
../../_images/programming_strategy-examples_osmosis_16_0.svg

Benchmarking the strategy performance#

Here we benchmark the strategy performance against some baseline scenarios.

  • Buy and hold US dollar

  • Buy and hold the underlying trading pair base asset

[9]:
close = universe.universe.candles.get_single_pair_data()["close"]
[10]:
from tradeexecutor.visual.benchmark import visualise_benchmark

traded_pair = universe.universe.pairs.get_single()

fig = visualise_benchmark(
    state.name,
    portfolio_statistics=state.stats.portfolio,
    all_cash=state.portfolio.get_initial_deposit(),
    buy_and_hold_asset_name=traded_pair.base_token_symbol,
    buy_and_hold_price_series=universe.universe.candles.get_single_pair_data()["close"],
)

fig.update_layout(template="plotly_dark")  # Dark color theme https://plotly.com/python/templates/
fig.show()

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

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

analysis = build_trade_analysis(state.portfolio)

Strategy summary#

Overview of strategy performance

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

summary = analysis.calculate_summary_statistics(TimeBucket.d1, state)

with pd.option_context("display.max_row", None):
    summary.display()
Returns
Annualised return % 197.08%
Lifetime return % 27.54%
Realised PnL $2,753.77
Trade period 51 days 0 hours
Holdings
Total assets $12,753.77
Cash left $12,753.77
Open position value $0.00
Open positions 0
Winning Losing Total
Closed Positions
Number of positions 1 0 1
% of total 100.00% 0.00% 100.00%
Average PnL % 30.60% 0.00% 30.60%
Median PnL % 30.60% - 30.60%
Biggest PnL % 30.60% - -
Average duration 51 bars 0 bars 51 bars
Max consecutive streak 1 0 -
Max runup / drawdown 35.24% -7.20% -
Stop losses Take profits
Position Exits
Triggered exits 0 0
Percent winning - -
Percent losing - -
Percent of total 0.00% 0.00%
Risk Analysis
Biggest realized risk 90.00%
Average realized risk -
Max pullback of capital 0.00%
Sharpe Ratio 175.74%
Sortino Ratio 306.86%
Profit Factor 188.61%

Performance metrics#

Here is an example how to use Quantstats library to calculate the tearsheet metrics for the strategy with advanced metrics. The metrics include popular risk-adjusted return comparison metrics.

This includes metrics like:

  • Sharpe

  • Sortino

  • Max drawdown

Note: These metrics are based on equity curve and returns. Analysis here does not go down to the level of an individual trade or a position. Any consecutive wins and losses are measured in days, not in trade or candle counts.

[13]:
from tradeexecutor.visual.equity_curve import calculate_equity_curve, calculate_returns
from tradeexecutor.analysis.advanced_metrics import visualise_advanced_metrics, AdvancedMetricsMode

equity = calculate_equity_curve(state)
returns = calculate_returns(equity)
metrics = visualise_advanced_metrics(returns, mode=AdvancedMetricsMode.full)

with pd.option_context("display.max_row", None):
    display(metrics)
Strategy
Start Period 2022-01-25
End Period 2023-01-24
Risk-Free Rate 0.0%
Time in Market 14.0%
Cumulative Return 27.65%
CAGR﹪ 27.74%
Sharpe 1.76
Prob. Sharpe Ratio 96.64%
Smart Sharpe 1.55
Sortino 3.07
Smart Sortino 2.7
Sortino/√2 2.17
Smart Sortino/√2 1.91
Omega 1.89
Max Drawdown -7.2%
Longest DD Days 332
Volatility (ann.) 14.49%
Calmar 3.85
Skew 1.27
Kurtosis 17.65
Expected Daily 0.07%
Expected Monthly 1.9%
Expected Yearly 12.98%
Kelly Criterion 26.71%
Risk of Ruin 0.0%
Daily Value-at-Risk -1.18%
Expected Shortfall (cVaR) -1.18%
Max Consecutive Wins 4
Max Consecutive Losses 3
Gain/Pain Ratio 0.89
Gain/Pain (1M) 9.12
Payoff Ratio 1.43
Profit Factor 1.89
Common Sense Ratio 8.95
CPC Index 1.53
Tail Ratio 4.75
Outlier Win Ratio 20.07
Outlier Loss Ratio 1.69
MTD 0.0%
3M 0.0%
6M 0.0%
YTD 0.0%
1Y 27.65%
3Y (ann.) 27.74%
5Y (ann.) 27.74%
10Y (ann.) 27.74%
All-time (ann.) 27.74%
Best Day 4.76%
Worst Day -4.66%
Best Month 25.92%
Worst Month -3.03%
Best Year 27.65%
Worst Year 0.0%
Avg. Drawdown -2.09%
Avg. Drawdown Days 43
Recovery Factor 3.84
Ulcer Index 0.05
Serenity Index 0.52
Avg. Up Month 15.23%
Avg. Down Month -3.03%
Win Days 56.86%
Win Month 66.67%
Win Quarter 100.0%
Win Year 100.0%

Position and trade timeline#

Display all positions and how much profit they made.

[14]:
from tradeexecutor.analysis.trade_analyser import expand_timeline

timeline = analysis.create_timeline()

expanded_timeline, apply_styles = expand_timeline(
        universe.universe.exchanges,
        universe.universe.pairs,
        timeline)

# Do not truncate the row output
with pd.option_context("display.max_row", None):
    display(apply_styles(expanded_timeline))

Base asset Close mid price USD Duration Exchange LP fees Open mid price USD Opened at PnL % PnL USD Position max value Quote asset Remarks Trade count Type
ATOM $0.327217 51 days $10.38 $0.250554 2022-01-25 30.60% $2,753.77 $9,000.00 OSMO 2 Long

Finishing notes#

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

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