Tags: stop-loss, ema, trend-analysis, get-started

Exploring the Power of Detached Technical Indicators#

Welcome to this interactive notebook! Our primary goal here is to delve into the concept and application of detached technical indicators. These unique tools are beneficial when we’re looking to maintain a clear and uncluttered price chart, or when the indicator operates on a scale vastly different from that of the price chart. By detaching these indicators, we can analyze them separately, enhancing our ability to interpret the data they provide without compromising the readability of our main price chart.

While we’ll also be exploring a specific trading strategy within this notebook, we highly encourage you to focus on the visual output of the charts. This will enable you to better understand how detached technical indicators function in real-time market analysis. Through this visualization, you’ll appreciate the power of these indicators, and how they can improve your ability to make informed trading decisions. Remember, in trading, clarity and understanding are key, and that’s what this notebook aims to provide.

[15]:
import psutil

mem = psutil.virtual_memory()
print(f"Current memory: {mem.available / 2**20:.2f} MB / {mem.total / 2**20:.2f} MB")
Current memory: 31435.31 MB / 39899.51 MB

Single trading pair EMA crossover strategy

This is an example notebook how to create and run backtests with tradeexecutor framework.

Some highlights of this notebook:

  • The backtest has all its code within a single Jupyter notebook

    • The backtest code and charts are self-contained in a single file

    • The example code is easy to read

    • Easy to test different functionalities of tradeexecutor library

  • Runs a backtest for EMA strategy on a single trading pair

    • Uses PancakeSwap on BSC chain for trading

    • Based on fast EMA and slow EMA

    • Depending on the moving average overlap, enters in to a position

  • You need a Trading Strategy API key to run the notebook

  • This backtest is made to demostrate the features

    • The strategy may or may not generate any profits, as it is not the purpose of this example

Set up#

Set up the parameters used in in this strategy backtest study.

  • Backtested blockchain, exchange and trading pair

  • Backtesting period

  • Strategy parameters for EMA crossovers

[16]:
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
# NOTE: this setting has currently no effect
TRADING_STRATEGY_TYPE_ENGINE_VERSION = "0.1"

# What kind of strategy we are running.
# This tells we are going to use
# NOTE: this setting has currently no effect
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.pancakeswap_busd

# How often the strategy performs the decide_trades cycle.
# We do it for every 4h.
TRADING_STRATEGY_CYCLE = CycleDuration.cycle_1h

# Strategy keeps its cash in USDC
RESERVE_CURRENCY = ReserveCurrency.busd

# Time bucket for our candles
CANDLE_TIME_BUCKET = TimeBucket.h1

# Which chain we are trading
CHAIN_ID = ChainId.bsc

# Which exchange we are trading on.
EXCHANGE_SLUG = "pancakeswap-v2"

# Which trading pair we are trading
TRADING_PAIR = ("ETH", "USDC")

# How much of the cash to put on a single trade
POSITION_SIZE = 0.70

#
# Strategy thinking specific parameter
#

BATCH_SIZE = 90

SLOW_EMA_CANDLE_COUNT = 10
FAST_EMA_CANDLE_COUNT = 3


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

START_AT = datetime.datetime(2022, 1, 1)

END_AT = datetime.datetime(2022, 1, 10)

# Start with 10,000 USD
INITIAL_DEPOSIT = 10_000

# If the price drops 0.7% we trigger a stop loss
STOP_LOSS_PCT = 0.993

STOP_LOSS_TIME_BUCKET = TimeBucket.m15

Deciding Trades#

  • The decide_trades function is used to determine which trades we should make. This is the main function that drives our trading decisions.

Using Indicators#

  • In this case, we’re using two exponential moving averages (EMAs) to help us make these decisions. EMAs are a type of data smoothing that gives more weight to recent data. By comparing two EMAs (one that reacts more quickly to price changes and one that reacts more slowly), we can identify potential trading opportunities.

  • To keep our price chart uncluttered, we use “detached” and “overlayed” technical indicators. This means we present some data in separate panels rather than all on the main price chart.

Event Indicators#

  • We also use “event indicators” to highlight certain key events on the chart. Unlike continuous indicators (like EMAs), these are discrete events that only need to be shown at certain points.

  • To visualize these events, we use “markers” (which look like circles) rather than lines.

  • The functions is_crossover and is_crossover_constant help us create these event indicators. They identify points where our two EMAs cross each other, which can signal a trading opportunity.

  • However, these event indicators might not always align perfectly with the crossover points when you zoom in on the chart. This is due to the fact that our price data is “discrete” (it only updates at specific intervals, like every minute or hour) rather than “continuous” (updating constantly in real time).

  • In simple terms, we’re using some mathematical tools and visual aids to help us decide when to trade. These tools look at recent price changes and highlight key events that might suggest a good opportunity to buy or sell.

  • Note the use of recording_time = RecordingTime.market_time for these event indicators

[17]:
from typing import List, Dict

from pandas_ta.overlap import ema

from tradeexecutor.state.visualisation import PlotKind, PlotShape, RecordingTime
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
from tradingstrategy.universe import Universe
from tradeexecutor.strategy.pandas_trader.position_manager import PositionManager
from tradeexecutor.utils.crossover import contains_cross_over, contains_cross_under

import random
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.

    - Reads incoming execution state (positions, past trades)

    - Reads the current universe (candles)

    - Decides what trades to do next, if any, at current timestamp.

    - Outputs strategy thinking for visualisation and debug messages

    :param timestamp:
        The Pandas timestamp object for this cycle. Matches
        TRADING_STRATEGY_CYCLE division.
        Always truncated to the zero seconds and minutes, never a real-time clock.

    :param universe:
        Trading universe that was constructed earlier.

    :param state:
        The current trade execution state.
        Contains current open positions and all previously executed trades, plus output
        for statistics, visualisation and diangnostics of the strategy.

    :param pricing_model:
        Pricing model can tell the buy/sell price of the particular asset at a particular moment.

    :param cycle_debug_data:
        Python dictionary for various debug variables you can read or set, specific to this trade cycle.
        This data is discarded at the end of the trade cycle.

    :return:
        List of trade instructions in the form of :py:class:`TradeExecution` instances.
        The trades can be generated using `position_manager` but strategy could also hand craft its trades.
    """

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

    # 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_prices = candles["close"]

    # Calculate exponential moving averages based on slow and fast sample numbers.
    slow_ema_series = ema(close_prices, length=SLOW_EMA_CANDLE_COUNT)
    fast_ema_series = ema(close_prices, 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 []

    if len(slow_ema_series) < 2 or len(fast_ema_series) < 2:
        # We need at least two data points to determine if EMA crossover (or crossunder)
        # occurred at current timestamp.
        return []

    slow_ema_latest = slow_ema_series.iloc[-1]
    fast_ema_latest = fast_ema_series.iloc[-1]
    price_latest = close_prices.iloc[-1]

    # Compute technical indicators needed for trade decisions.
    slow_ema_crossover = (
        close_prices.iloc[-3] < slow_ema_series.iloc[-2]
        and price_latest > slow_ema_latest
    )
    slow_ema_crossunder = (
        close_prices.iloc[-2] > slow_ema_series.iloc[-2]
        and price_latest < slow_ema_latest
    )
    fast_ema_crossunder = (
        close_prices.iloc[-2] > fast_ema_series.iloc[-2]
        and price_latest < fast_ema_latest
    )

    trades = []


    position_manager = PositionManager(timestamp, universe, state, pricing_model)

    if not position_manager.is_any_open():
        # No open positions, decide if BUY in this cycle.
        # We buy if we just crossed over the slow EMA or if this is a very first
        # trading cycle and the price is already above the slow EMA.

        if (
            slow_ema_crossunder
            or price_latest < slow_ema_latest and timestamp == START_AT
            ):
            buy_amount = cash * POSITION_SIZE
            new_trades = position_manager.open_1x_long(pair, buy_amount, stop_loss_pct=STOP_LOSS_PCT)
            trades.extend(new_trades)

    else:
        # We have an open position, decide if SELL in this cycle.
        # We do that if we fall below any of the two moving averages.
        if slow_ema_crossover or (fast_ema_crossunder and  fast_ema_latest > slow_ema_latest) :
            new_trades = position_manager.close_all()
            assert len(new_trades) == 1
            trades.extend(new_trades)

    # 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, "random 1", PlotKind.technical_indicator_detached, random.randint(7100, 10000), colour="green")

    visualisation.plot_indicator(timestamp, "Fast EMA", PlotKind.technical_indicator_detached, fast_ema_latest, colour="red")

    visualisation.plot_indicator(timestamp, "Slow EMA", PlotKind.technical_indicator_overlay_on_detached, slow_ema_latest, colour="green", detached_overlay_name="Fast EMA")

    # plot stop loss line
    if position_manager.is_any_open():
        stop_loss_price = position_manager.get_current_position().stop_loss
    else:
        stop_loss_price = None
    visualisation.plot_indicator(timestamp, "stop loss", PlotKind.technical_indicator_on_price, stop_loss_price, colour="blue", recording_time = RecordingTime.market_time)

    # how to plot horizontal lines
    visualisation.plot_indicator(timestamp, "h2", PlotKind.technical_indicator_overlay_on_detached, 3300, colour="orange", detached_overlay_name="Fast EMA")

    # plot event markers (dots)
    if contains_cross_over(fast_ema_series, slow_ema_series) or contains_cross_under(fast_ema_series, slow_ema_series):
        visualisation.plot_indicator(timestamp, "cross over", PlotKind.technical_indicator_overlay_on_detached, fast_ema_latest, colour="red", detached_overlay_name="Fast EMA", plot_shape = PlotShape.markers, recording_time = RecordingTime.market_time)

    return trades

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

[18]:
from typing import Optional
from tradeexecutor.strategy.trading_strategy_universe import load_pair_data_for_single_exchange, TradingStrategyUniverse
from tradeexecutor.strategy.execution_context import ExecutionContext
from tradeexecutor.strategy.universe_model import UniverseOptions
from tradingstrategy.client import Client
import datetime

def create_single_pair_trading_universe(
        ts: datetime.datetime,
        client: Client,
        execution_context: ExecutionContext,
        universe_options: UniverseOptions,
) -> TradingStrategyUniverse:
    dataset = load_pair_data_for_single_exchange(
        client,
        execution_context,
        CANDLE_TIME_BUCKET,
        CHAIN_ID,
        EXCHANGE_SLUG,
        [TRADING_PAIR],
        universe_options,
        stop_loss_time_bucket=STOP_LOSS_TIME_BUCKET,
    )

    # Filter down to the single pair we are interested in
    universe = TradingStrategyUniverse.create_single_pair_universe(
        dataset,
        CHAIN_ID,
        EXCHANGE_SLUG,
        TRADING_PAIR[0],
        TRADING_PAIR[1],
    )

    return universe

Set up the market data client#

The Trading Strategy market data client is the Python library responsible for managing the data feeds needed to run the backtest.None

We set up the market data client with an API key.

If you do not have an API key yet, you can register one.

[19]:
from tradingstrategy.client import Client

client = Client.create_jupyter_client()
Started Trading Strategy in Jupyter notebook environment, configuration is stored in /home/alex/.tradingstrategy

Load data#

[20]:
from tradeexecutor.strategy.execution_context import ExecutionMode
from tradeexecutor.strategy.universe_model import UniverseOptions

universe = create_single_pair_trading_universe(
    END_AT,
    client,
    ExecutionContext(mode=ExecutionMode.data_preload),
    UniverseOptions()
)
print(f"We loaded {universe.universe.candles.get_candle_count():,} candles.")
We loaded 21,207 candles.

Run 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.

  • The trade execution engine will download the necessary datasets to run the backtest. The datasets may be large, several gigabytes.

[21]:
import logging

from tradeexecutor.backtest.backtest_runner import run_backtest_inline

state, universe, debug_dump = run_backtest_inline(
    name="ETH/USDC fast and slow EMA example",
    start_at=START_AT,
    end_at=END_AT,
    client=client,
    cycle_duration=TRADING_STRATEGY_CYCLE,
    decide_trades=decide_trades,
    universe=universe,
    # create_trading_universe=create_single_pair_trading_universe,
    initial_deposit=INITIAL_DEPOSIT,
    reserve_currency=RESERVE_CURRENCY,
    trade_routing=TRADE_ROUTING,
    log_level=logging.WARNING,
)

trade_count = len(list(state.portfolio.get_all_trades()))
print(f"Backtesting completed, backtested strategy made {trade_count} trades")
Backtesting completed, backtested strategy made 46 trades

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

[22]:
print(f"Positions taken: {len(list(state.portfolio.get_all_positions()))}")
print(f"Trades made: {len(list(state.portfolio.get_all_trades()))}")
Positions taken: 23
Trades made: 46

Note#

When zooming in, the event indicators will not be exactly where they should, unless the event falls exactly on a candle close. This is unfortunately something that cannot be fixed for the time being.

[23]:

from tradeexecutor.visual.single_pair import visualise_single_pair, visualise_single_pair_positions_with_duration_and_slippage
from tradingstrategy.charting.candle_chart import VolumeBarMode

figure = visualise_single_pair(
    state,
    universe.universe.candles,
    start_at=START_AT,
    end_at=END_AT,
    volume_bar_mode=VolumeBarMode.separate,
    volume_axis_name="Volume (USD)",
    height = 1000,
    relative_sizing=[1, 0.2, 0.2, 1]
)

figure.show()
/home/alex/work/tradingstrategy-ai/stuff/trade-executor/tradeexecutor/visual/utils.py:243: SettingWithCopyWarning:


A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy

Data type cannot be displayed: application/vnd.plotly.v1+json

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

[24]:
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"],
    start_at=START_AT,
    end_at=END_AT,
    height=800
)

fig.show()

Data type cannot be displayed: application/vnd.plotly.v1+json

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

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

analysis = build_trade_analysis(state.portfolio)

Strategy summary#

Overview of strategy performance

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

summary = analysis.calculate_summary_statistics(CANDLE_TIME_BUCKET, state)

with pd.option_context("display.max_row", None):
    summary.display()
Returns
Annualised return % -103.12%
Lifetime return % -2.44%
Realised PnL $-244.27
Unrealised PnL $0.00
Trade period 8 days 15 hours
Holdings
Total assets $9,755.73
Cash left $9,755.73
Open position value $0.00
Open positions 0
Winning Losing Total
Closed Positions
Number of positions 11 12 23
% of total 47.83% 52.17% 100.00%
Average PnL % 0.56% -0.81% -0.15%
Median PnL % 0.30% -0.35% -0.03%
Biggest PnL % 2.02% -2.25% -
Average duration 2 hours 27 minutes 2 hours 33 minutes 2 hours 30 minutes
Max consecutive streak 3 2 -
Max runup / drawdown 2.47% -4.46% -
Stop losses Take profits
Position Exits
Triggered exits 5 0
Percent winning 0.00% -
Percent losing 100.00% -
Percent of total 21.74% 0.00%
Risk Analysis
Biggest realized risk 0.66%
Average realized risk -0.56%
Max pullback of capital -2.84%
Sharpe Ratio -3.53
Sortino Ratio -4.80
Profit Factor 0.85

Position and trade timeline#

Display all positions and how much profit they made.

[27]:
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))

Remarks Type Opened at Duration Exchange Base asset Quote asset Position max value PnL USD PnL % Open mid price USD Close mid price USD Trade count LP fees
Long 2022-01-01 1 hours PancakeSwap v2 ETH USDC $7,000.00 $40.99 0.59% $3,694.899519 $3,716.533257 2 $35.15
Long 2022-01-01 2 hours PancakeSwap v2 ETH USDC $7,028.69 $-22.70 -0.32% $3,725.995574 $3,713.964356 2 $35.13
Long 2022-01-01 3 hours PancakeSwap v2 ETH USDC $7,012.80 $-2.28 -0.03% $3,705.466627 $3,704.259739 2 $35.10
Long 2022-01-01 2 hours PancakeSwap v2 ETH USDC $7,011.20 $32.32 0.46% $3,711.766057 $3,728.876975 2 $35.18
Long 2022-01-02 5 hours PancakeSwap v2 ETH USDC $7,033.83 $20.25 0.29% $3,738.208857 $3,748.969659 2 $35.26
Long 2022-01-02 2 hours PancakeSwap v2 ETH USDC $7,048.00 $142.30 2.02% $3,757.035178 $3,832.888772 2 $35.64
Long 2022-01-03 4 hours PancakeSwap v2 ETH USDC $7,147.61 $-17.70 -0.25% $3,811.494523 $3,802.056941 2 $35.74
Long 2022-01-03 2 hours PancakeSwap v2 ETH USDC $7,135.22 $-23.60 -0.33% $3,810.960463 $3,798.354625 2 $35.66
Long 2022-01-03 2 hours PancakeSwap v2 ETH USDC $7,118.70 $21.05 0.30% $3,815.182796 $3,826.465706 2 $35.69
SL Long 2022-01-03 1 hours 15 mins PancakeSwap v2 ETH USDC $7,133.44 $-115.72 -1.62% $3,823.753780 $3,761.725034 2 $35.42
Long 2022-01-04 7 hours PancakeSwap v2 ETH USDC $7,052.43 $-10.98 -0.16% $3,755.959652 $3,750.113858 2 $35.28
Long 2022-01-04 2 hours PancakeSwap v2 ETH USDC $7,044.75 $53.79 0.76% $3,780.859811 $3,809.726237 2 $35.40
Long 2022-01-05 4 hours PancakeSwap v2 ETH USDC $7,082.40 $0.88 0.01% $3,802.572775 $3,803.043460 2 $35.46
Long 2022-01-05 2 hours PancakeSwap v2 ETH USDC $7,083.01 $-26.42 -0.37% $3,818.680519 $3,804.434349 2 $35.39
Long 2022-01-05 3 hours PancakeSwap v2 ETH USDC $7,064.52 $10.38 0.15% $3,800.917019 $3,806.501937 2 $35.39
SL Long 2022-01-05 1 hours 15 mins PancakeSwap v2 ETH USDC $7,071.78 $-159.06 -2.25% $3,813.748416 $3,727.969457 2 $35.00
SL Long 2022-01-07 45 mins PancakeSwap v2 ETH USDC $6,960.44 $-119.94 -1.72% $3,422.450808 $3,363.476958 2 $34.55
Long 2022-01-07 2 hours PancakeSwap v2 ETH USDC $6,876.49 $94.08 1.37% $3,184.362916 $3,227.929935 2 $34.66
Long 2022-01-08 2 hours PancakeSwap v2 ETH USDC $6,942.34 $2.61 0.04% $3,209.705826 $3,210.912494 2 $34.76
SL Long 2022-01-08 2 hours PancakeSwap v2 ETH USDC $6,944.17 $-86.36 -1.24% $3,210.431452 $3,170.506418 2 $34.55
Long 2022-01-09 2 hours PancakeSwap v2 ETH USDC $6,883.72 $15.95 0.23% $3,093.671744 $3,100.840423 2 $34.50
Long 2022-01-09 3 hours PancakeSwap v2 ETH USDC $6,894.88 $-6.15 -0.09% $3,119.698623 $3,116.916152 2 $34.50
SL Long 2022-01-09 2 hours 30 mins PancakeSwap v2 ETH USDC $6,890.58 $-87.95 -1.28% $3,118.983293 $3,079.172522 2 $34.28

Finishing notes#

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

print(“All ok”)