Analyze the Backtest of a Trading Portfolio in Python Using Pandas

We have reached a crucial stage by mastering the backtesting of our trading strategies.

However, creating a strategy is just the first step. The thorough analysis of the results is where the distinction between a good and an excellent strategy lies.

Today, we delve into this critical phase with scientific precision and rigor. We will explore analytical tools and evaluation techniques that transform raw data into insightful information, enabling thoughtful and informed decision-making.

Every detail, every piece of data, every signal will be scrutinized to refine and perfect our market interventions. This step is decisive, marking the transition from theory to refined practice, where each decision is backed by methodical and precise analysis. Welcome to this essential lesson, where the quality of our analysis will define the robustness and effectiveness of our trading strategies.

Preparing the analysis of our trading strategy

Let's begin by revisiting the code from our previous session.

class BacktestCrossMA:
    def __init__(self) -> None:
        self.df = pd.DataFrame()

    def load_data(self, path):
        self.df = pd.read_csv(path)
        self.df["date"] = pd.to_datetime(self.df["date"], unit="ms")
        self.df = self.df.set_index(self.df["date"])
        del self.df["date"]

    def populate_indicators(self):
        self.df['ma50'] = self.df['close'].rolling(50).mean()
        self.df['ma200'] = self.df['close'].rolling(200).mean()

    def populate_signals(self):
        self.df['buy_signal'] = False
        self.df['sell_signal'] = False
        self.df.loc[(self.df['ma50'] > self.df['ma200']), 'buy_signal'] = True
        self.df.loc[(self.df['ma50'] < self.df['ma200']), 'sell_signal'] = True

    def run_backtest(self):
        balance = 1000
        position = None
        asset = "BTC"

        for index, row in self.df.iterrows():

            if position is None and row['buy_signal']:
                open_price = row['close']
                position = {
                    'open_price': open_price,
                    'open_size': balance,
                }
                print(f"{index} - Buy for {balance}$ of {asset} at {open_price}$")

            elif position and row['sell_signal']:
                close_price = row['close']
                trade_result = (close_price - position['open_price']) / position['open_price']
                balance = balance + trade_result * position['open_size']
                position = None
                print(f"{index} - Sell for {balance}$ of {asset} at {close_price}$")      
            
        
        print(f"Final balance: {balance}$")

Accounting for trading fees

It's crucial to acknowledge that no trading strategy is complete without considering the fees associated with transactions. We've developed a solid model with our BacktestCrossMA class, but today, we'll refine it by integrating transaction fees. This seemingly minor detail can have a significant impact, and its inclusion will make our backtesting results more accurate and our strategies more robust in a real trading environment.

To do this, we'll start by initializing a fees variable at 0.001 to signify that for each transaction, we will pay a fee of 0.1%. This is an estimate that can vary significantly depending on the brokers you interact with.

    def run_backtest(self):
        balance = 1000
        position = None
        asset = "BTC"
        fee = 0.001

        for index, row in self.df.iterrows():

            if position is None and row['buy_signal']:
                open_price = row['close']
                open_size = balance
                fees = open_size * fee
                open_size = open_size - fees
                balance = balance - fees
                position = {
                    'open_price': open_price,
                    'open_size': open_size,
                }
                print(f"{index} - Buy for {balance}$ of {asset} at {open_price}$")

            elif position and row['sell_signal']:
                close_price = row['close']
                trade_result = (close_price - position['open_price']) / position['open_price']
                close_size = (position["open_size"] + position["open_size"] * trade_result)
                fees = close_size * fee
                close_size = close_size - fees
                balance = balance + close_size - position["open_size"]
                position = None
                print(f"{index} - Sell for {balance}$ of {asset} at {close_price}$")

To account for fees at the time of buying and selling, we defined a fee variable representing the transaction fees. The fees paid are equal to the transaction amount multiplied by our fees variable. We also added the open_size and close_size variables so that we could apply our fees to them, and then see their impact on our final balance.

If we run the code now, we notice that our final balance is now $ 10,458 instead of the initial $ 12,756, which is still very good, but should not be overlooked.

Daily portfolio tracking

To perform a comprehensive tracking of our strategy, we will record our balance in a list each day. At the beginning of our run_backtest() function, we will initialize our days list and a trades list to record each of our trades.

self.trades = []
self.days = []

We use self to make these lists accessible throughout our class.

Remember, our DataFrame df represents the OHLCV data of Bitcoin on an hourly basis. However, we want to capture the state of our portfolio once per day.

To do this, we can check the current day at each hour and compare it to the previous day. If the current day is different from the previous day, it's a new day, and we can record data in our self.days list. Here's the code to do this:

	def run_backtest(self):
        balance = 1000
        position = None
        fee = 0.001
        asset = "BTC"
        self.trades = []
        self.days = []
        previous_day = -1

        for index, row in self.df.iterrows():
            current_day = index.day
            if previous_day != current_day:
                temp_balance = balance
                if position:
                    close_price = row["close"]
                    trade_result = (close_price - position["open_price"]) / position[
                        "open_price"
                    ]
                    close_size = (
                        position["open_size"] + position["open_size"] * trade_result
                    )
                    fees = close_size * fee
                    close_size = close_size - fees
                    temp_balance = temp_balance + close_size - position["open_size"]
                self.days.append({
                    "day":index.date(),
                    "balance":temp_balance,
                    "price":row['close']
                })
            previous_day = current_day

We initialize the previous_day variable to -1. During our iteration, we check the current day using index.day (as a reminder, we have set the dates as the index column of our DataFrame).

If we have an open position, we simulate closing it to see its impact on our balance. Finally, we store the date, our temporary balance, and the asset's price in a dictionary that we add to our self.days list.

Storing each trade of the strategy

To perform a complete analysis later, we need to store all our trades with the necessary information in our self.trades list. We add this information when opening a position:

            if position is None and row["buy_signal"]:
                open_price = row["close"]
                open_size = balance
                fees = open_size * fee
                open_size = open_size - fees
                balance = balance - fees
                position = {
                    "open_price": open_price,
                    "open_size": balance,
                    "open_date": index,
                    "open_fee": fees,
                    "open_reason": "Market Buy",
                    "open_balance": balance,
                }

Compared to the original code, we added the position's opening date, the associated fees, the reason for opening, and the balance at the time of opening the position.

Now, when closing the position, just as we did when adding information related to the current day, let's create a dictionary with various details related to our trade:

            elif position and row["sell_signal"]:
                close_price = row["close"]
                trade_result = (close_price - position["open_price"]) / position[
                    "open_price"
                ]
                close_size = (
                    position["open_size"] + position["open_size"] * trade_result
                )
                fees = close_size * fee
                close_size = close_size - fees
                balance = balance + close_size - position["open_size"]
                self.trades.append(
                    {
                        "open_date": position["open_date"],
                        "close_date": index,
                        "open_price": position["open_price"],
                        "close_price": close_price,
                        "open_size": position["open_size"],
                        "close_size": close_size,
                        "open_fee": position["open_fee"],
                        "close_fee": fees,
                        "open_reason": position["open_reason"],
                        "close_reason": "Market Sell",
                        "open_balance": position["open_balance"],
                        "close_balance": balance,
                    }
                )
                position = None

Thanks to this code, when we close a position, we add the following to our self.trades list:

  • open_date
  • close_date
  • open_price
  • close_price
  • open_size
  • close_size
  • open_fee
  • close_fee
  • open_reason
  • close_reason
  • open_balance
  • close_balance

Below, you will find the code for the run_backtest() function with all the new modifications :

    def run_backtest(self):
        balance = 1000
        position = None
        fee = 0.001
        self.trades = []
        self.days = []
        previous_day = -1

        for index, row in self.df.iterrows():
            current_day = index.day
            if previous_day != current_day:
                temp_balance = balance
                if position:
                    close_price = row["close"]
                    trade_result = (close_price - position["open_price"]) / position[
                        "open_price"
                    ]
                    close_size = (
                        position["open_size"] + position["open_size"] * trade_result
                    )
                    fees = close_size * fee
                    close_size = close_size - fees
                    temp_balance = temp_balance + close_size - position["open_size"]
                self.days.append({
                    "day":index.date(),
                    "balance":temp_balance,
                    "price":row['close']
                })
            previous_day = current_day
            
            if position is None and row["buy_signal"]:
                open_price = row["close"]
                open_size = balance
                fees = open_size * fee
                open_size = open_size - fees
                balance = balance - fees
                position = {
                    "open_price": open_price,
                    "open_size": balance,
                    "open_date": index,
                    "open_fee": fees,
                    "open_reason": "Market Buy",
                    "open_balance": balance,
                }
                # print(f"{index} - Buy for {balance}$ of {asset} at {open_price}$")

            elif position and row["sell_signal"]:
                close_price = row["close"]
                trade_result = (close_price - position["open_price"]) / position[
                    "open_price"
                ]
                close_size = (
                    position["open_size"] + position["open_size"] * trade_result
                )
                fees = close_size * fee
                close_size = close_size - fees
                balance = balance + close_size - position["open_size"]
                self.trades.append(
                    {
                        "open_date": position["open_date"],
                        "close_date": index,
                        "open_price": position["open_price"],
                        "close_price": close_price,
                        "open_size": position["open_size"],
                        "close_size": close_size,
                        "open_fee": position["open_fee"],
                        "close_fee": fees,
                        "open_reason": position["open_reason"],
                        "close_reason": "Market Sell",
                        "open_balance": position["open_balance"],
                        "close_balance": balance,
                    }
                )
                position = None
                # print(f"{index} - Sell for {balance}$ of {asset} at {close_price}$")

Analyzing the results of our Strategy with Pandas

With our days and trades data meticulously recorded in self.days and self.trades, we are now ready to move on to a crucial part of our evaluation process: analyzing performance metrics.

This phase is key to deciphering and assessing the validity and effectiveness of our trading strategy in an operational context.

In the complex and dynamic world of trading, a strategy can only be considered viable if it has been rigorously tested and validated using precise quantitative criteria.

These metrics, ranging from risk-adjusted returns to maximum drawdowns, provide invaluable insights into the robustness, profitability, and resilience of our approach.

Each transaction and daily variation will be scrutinized to ensure that our strategy not only generates positive returns but does so consistently and reliably, all while minimizing risk.

This will ensure that the strategy is well-equipped to navigate various market conditions, ensuring optimal performance.

To achieve this, we will create a new function within our class called backtest_analysis().

Initialization and data verification

df_trades = pd.DataFrame(self.trades)
df_days = pd.DataFrame(self.days)

if df_trades.empty:
    raise Exception("No trades found")
if df_days.empty:
    raise Exception("No days found")

In this initial section of the backtest_analysis function, we initialize two DataFrames, df_trades and df_days, from the trades and days data stored during the backtest. We also perform a crucial check to ensure that these DataFrames are not empty. If one is empty, an exception is raised, indicating the absence of trades or days, which is crucial to avoid further execution errors in the analysis.

Calculating returns and trade results

df_days['evolution'] = df_days['balance'].diff()
df_days['daily_return'] = df_days['evolution']/df_days['balance'].shift(1)

df_trades["trade_result"] = df_trades["close_size"] - df_trades["open_size"]
df_trades["trade_result_pct"] = (df_trades["trade_result"] / df_trades["open_size"])
df_trades["trades_duration"] = df_trades["close_date"] - df_trades["open_date"]

We then proceed to calculate the daily returns and the outcomes of the trades. We determine the day-to-day balance evolution and calculate the daily return.

For the trades, we calculate the result of each trade as well as the percentage of this result in relation to the opening size of the trade. We also calculate the duration of each trade to have a detailed view of how quickly the trades were concluded.

Analyzing Drawdowns and Performance

df_days["balance_ath"] = df_days["balance"].cummax()
df_days["drawdown"] = df_days["balance_ath"] - df_days["balance"]
df_days["drawdown_pct"] = df_days["drawdown"] / df_days["balance_ath"]

In this step, we analyze drawdowns by calculating the maximum balance to date and the subsequent drawdown in both absolute and percentage terms. This analysis gives us an understanding of the maximum potential loss during the trading period.

Trade Details

total_trades = len(df_trades)
total_days = len(df_days)

good_trades = df_trades.loc[df_trades["trade_result"] > 0]
total_good_trades = len(good_trades)
avg_profit_good_trades = good_trades["trade_result_pct"].mean()
mean_good_trades_duration = good_trades["trades_duration"].mean()
global_win_rate = total_good_trades / total_trades

bad_trades = df_trades.loc[df_trades["trade_result"] < 0]
total_bad_trades = len(bad_trades)
avg_profit_bad_trades = bad_trades["trade_result_pct"].mean()
mean_bad_trades_duration = bad_trades["trades_duration"].mean()

max_days_drawdown = df_days["drawdown_pct"].max()
initial_balance = df_days.iloc[0]["balance"]
final_balance = df_days.iloc[-1]["balance"]
balance_evolution = (final_balance - initial_balance) / initial_balance
mean_trades_duration = df_trades["trades_duration"].mean()
avg_profit = df_trades["trade_result_pct"].mean()
mean_trades_duration = df_trades['trades_duration'].mean()
mean_trades_per_days = total_trades/total_days

best_trade = df_trades.loc[df_trades["trade_result_pct"].idxmax()]
worst_trade = df_trades.loc[df_trades["trade_result_pct"].idxmin()]

This section delves into detailed analysis, including the total number of trades, the number of winning trades, the winning trade ratio, the average trade result in percentage, and the average trade duration. These metrics provide a clear picture of how often our trades are profitable, how much we typically gain or lose, and how long our trades typically last.

Summary and Display

print(f"Period: [{df_days.iloc[0]['day']}] -> [{df_days.iloc[-1]['day']}]")
print(f"Initial balance: {round(initial_balance,2)} $")

print("\n--- General Information ---")
print(f"Final balance: {round(final_balance,2)} $")
print(f"Performance: {round(balance_evolution*100,2)} %")
print(f"Worst Drawdown: -{round(max_days_drawdown*100, 2)}%")
print(f"Total trades on the period: {total_trades}")
print(f"Average Profit: {round(avg_profit*100, 2)} %")
print(f"Global Win rate: {round(global_win_rate*100, 2)} %")

print("\n--- Trades Information ---")
print(f"Mean Trades per day: {round(mean_trades_per_days, 2)}")
print(f"Mean Trades Duration: {mean_trades_duration}")
print(f"Best trades: +{round(best_trade['trade_result_pct']*100, 2)} % the {best_trade['open_date']} -> {best_trade['close_date']}")
print(f"Worst trades: {round(worst_trade['trade_result_pct']*100, 2)} % the {worst_trade['open_date']} -> {worst_trade['close_date']}")
print(f"Total Good trades on the period: {total_good_trades}")
print(f"Total Bad trades on the period: {total_bad_trades}")
print(f"Average Good Trades result: {round(avg_profit_good_trades*100, 2)} %")
print(f"Average Bad Trades result: {round(avg_profit_bad_trades*100, 2)} %")
print(f"Mean Good Trades Duration: {mean_good_trades_duration}")
print(f"Mean Bad Trades Duration: {mean_bad_trades_duration}")

print("\n--- Trades reasons ---")
print(df_trades["open_reason"].value_counts().to_string())
print(df_trades["close_reason"].value_counts().to_string())

Finally, the function displays a summary of our backtest analysis, providing a quick and comprehensive overview of our trading strategy's performance, including the number of trades, the ratio of successful trades, and the average profitability and duration of trades.

A little extra: the Sharpe Ratio

The Sharpe Ratio measures the risk-adjusted performance of an investment or trading strategy, comparing its return to its volatility. A higher Sharpe Ratio indicates a more favorable risk-adjusted performance, offering higher returns for a given level of risk. This ratio is one of the most relevant for comparing two strategies with each other.

backtest-result

Here's how to calculate it using the daily information of our strategy :

sharpe_ratio = (365**(0.5) * df_days['daily_return'].mean())/df_days['daily_return'].std()
print(f"Sharpe Ratio: {round(sharpe_ratio,2)}")

We annualize our Sharpe Ratio by multiplying our daily return by the square root of 365 and then dividing everything by the standard deviation.

Bonus: Adding a Stop Loss to the backtest

A stop loss is an automatic sell order used by traders to limit their losses in case of an unfavorable market movement. It is triggered when the price of an asset reaches a predetermined level, forcing the sale of the asset to avoid additional losses. In other words, it's a protective mechanism that helps manage and minimize risks associated with market volatility.

To add a stop loss to our strategy, we will need to define its threshold at the time of purchase. For example, let's set our stop loss at -10%.

stop_loss = open_price - (open_price * 0.1)
position = {
    "open_price": open_price,
    "open_size": balance,
    "open_date": index,
    "open_fee": fee,
    "open_reason": "Market Buy",
    "open_balance": balance,
    "stop_loss": stop_loss,
}

In this case, we add to our position dictionary the price at which we wish to close the position if it falls below.

Next, we will add a new condition during our iteration:

elif position and row["low"] < position["stop_loss"]:
    close_price = position["stop_loss"]
    trade_result = (close_price - position["open_price"]) / position"open_price"]
    close_size = (position["open_size"] + position["open_size"] * trade_result)
    fee = close_size * fees
    close_size = close_size - fee
    balance = balance + close_size - position["open_size"]
    self.trades.append(
        {
            "open_date": position["open_date"],
            "close_date": index,
            "open_price": position["open_price"],
            "close_price": close_price,
            "open_size": position["open_size"],
            "close_size": close_size,
            "open_fee": position["open_fee"],
            "close_fee": fee,
            "open_reason": position["open_reason"],
            "close_reason": "Stop Loss",
            "open_balance": position["open_balance"],
            "close_balance": balance,
        }
    )
    position = None

We enter this condition if the lowest point of a candle (the low) falls below the value of our stop loss. In this case, we sell our Bitcoin at the stop loss price on the same principle as a "classic" sale.

Charts to judge our strategy

There are hundreds of charts that can be created from the information collected on the strategy. Here we will focus on the most important information, which are the P&L (Profit and Loss) representing simply the evolution of our strategy's balance and the Drawdown (Loss compared to the last high).

def plot_equity_vs_asset(self):
	  df_days = self.df_days.copy()
		df_days = df_days.set_index('day')
	  fig, ax_left = plt.subplots(figsize=(15, 20), nrows=4, ncols=1)
	
	  ax_left[0].title.set_text("Profit and Loss curve")
	  ax_left[0].plot(df_days['balance'], color='royalblue', lw=1)
	  ax_left[0].fill_between(df_days['balance'].index, df_days['balance'], alpha=0.2, color='royalblue')
	  ax_left[0].axhline(y=df_days.iloc[0]['balance'], color='black', alpha=0.3)
	  ax_left[0].legend(['Wallet evolution (equity)'], loc ="upper left")
	
	  ax_left[1].title.set_text("Asset evolution")
	  ax_left[1].plot(df_days['price'], color='sandybrown', lw=1)
	  ax_left[1].fill_between(df_days['price'].index, df_days['price'], alpha=0.2, color='sandybrown')
	  ax_left[1].axhline(y=df_days.iloc[0]['price'], color='black', alpha=0.3)
	  ax_left[1].legend(['Asset evolution'], loc ="upper left")
	
	  ax_left[2].title.set_text("Drawdown curve")
	  ax_left[2].plot(-df_days['drawdown_pct']*100, color='indianred', lw=1)
	  ax_left[2].fill_between(df_days['drawdown_pct'].index, -df_days['drawdown_pct']*100, alpha=0.2, color='indianred')
	  ax_left[2].axhline(y=0, color='black', alpha=0.3)
	  ax_left[2].legend(['Drawdown in %'], loc ="lower left")
	
	  ax_right = ax_left[3].twinx()
	
	  ax_left[3].title.set_text("P&L VS Asset (not on the same scale)")
	  ax_left[3].plot(df_days['balance'], color='royalblue', lw=1)
	  ax_right.plot(df_days['price'], color='sandybrown', lw=1)
	  ax_left[3].legend(['Wallet evolution (equity)'], loc ="lower right")
	  ax_right.legend(['Asset evolution'], loc ="upper left")
	
	  plt.show()

We won't go into detail on this code in this session, but here are some keys to understanding it :

  • We start by defining our graph as being divided into 4 subgraphs thanks to the line fig, ax_left = plt.subplots(figsize=(15, 20), nrows=4, ncols=1).
  • We then display the first chart with ax_left[0], then the second with ax_left[1], and so on.
  • For the last chart, we create a second axis on the right which is a copy of the left axis of the last subgraph thanks to ax_right = ax_left[3].twinx(). This axis will allow us to display on the same graph the course of our asset (Bitcoin here) and the evolution of our balance. This particularly allows us to see if the two curves have variations at the same time and therefore if they are correlated.

equity-chart

Our strategy with Stop Loss which started at 1000 dollars at the end of 2017 ends up at nearly 8000 dollars in 2023. We notice that the strategy at its highest reached a balance of 12,000 dollars at the beginning of 2021. Since then, we have never returned to this level, and we have had a maximum drawdown of more than -50%.

We also find that there is a very strong correlation between the performance of our strategy and the performance of Bitcoin, which is ultimately quite logical because by only buying (betting on the rise) we can only make money if Bitcoin goes up.

We have addressed and deepened key elements for the design, execution, and analysis of effective trading strategies. With the ability to create sophisticated strategies, the integration of stop loss to manage and minimize risks, and in-depth analytical tools, we are now equipped to make precise evaluations.

Whether through textual data or graphic visualizations, we can dissect each element of our strategy, allowing us to refine and optimize every aspect to maximize performance. Thus, we are not only prepared to understand the underlying mechanisms of our operations but also to choose and apply the best possible trading strategy in varied market conditions.

Complete class code and how to use it:

import pandas as pd
import matplotlib.pyplot as plt

class BacktestCrossMA:
    def __init__(self) -> None:
        self.df = pd.DataFrame()
        self.trades = []
        self.days = []
        self.df_days = pd.DataFrame()
        self.df_trades = pd.DataFrame()

    def load_data(self, path):
        self.df = pd.read_csv(path)
        self.df["date"] = pd.to_datetime(self.df["date"], unit="ms")
        self.df = self.df.set_index(self.df["date"])
        del self.df["date"]

    def populate_indicators(self):
        self.df["ma100"] = self.df["close"].rolling(100).mean()
        self.df["ma200"] = self.df["close"].rolling(200).mean()

    def populate_signals(self):
        self.df["buy_signal"] = False
        self.df["sell_signal"] = False
        self.df.loc[(self.df["ma100"] > self.df["ma200"]), "buy_signal"] = True
        self.df.loc[(self.df["ma100"] < self.df["ma200"]), "sell_signal"] = True

    def run_backtest(self):
        balance = 1000
        position = None
        fees = 0.0007
        self.trades = []
        self.days = []
        previous_day = -1

        for index, row in self.df.iterrows():
            current_day = index.day
            if previous_day != current_day:
                temp_balance = balance
                if position:
                    close_price = row["close"]
                    trade_result = (close_price - position["open_price"]) / position[
                        "open_price"
                    ]
                    close_size = (
                        position["open_size"] + position["open_size"] * trade_result
                    )
                    fee = close_size * fees
                    close_size = close_size - fee
                    temp_balance = temp_balance + close_size - position["open_size"]
                self.days.append({
                    "day":index.date(),
                    "balance":temp_balance,
                    "price":row['close']
                })
            previous_day = current_day
            
            if position is None and row["buy_signal"]:
                open_price = row["close"]
                open_size = balance
                fee = open_size * fees
                open_size = open_size - fee
                balance = balance - fee
                stop_loss = open_price - (open_price * 0.1)
                position = {
                    "open_price": open_price,
                    "open_size": balance,
                    "open_date": index,
                    "open_fee": fee,
                    "open_reason": "Market Buy",
                    "open_balance": balance,
                    "stop_loss": stop_loss,
                }
                # print(f"{index} - Buy for {balance}$ of {asset} at {open_price}$")

            elif position and row["low"] < position["stop_loss"]:
                close_price = position["stop_loss"]
                trade_result = (close_price - position["open_price"]) / position[
                    "open_price"
                ]
                close_size = (
                    position["open_size"] + position["open_size"] * trade_result
                )
                fee = close_size * fees
                close_size = close_size - fee
                balance = balance + close_size - position["open_size"]
                self.trades.append(
                    {
                        "open_date": position["open_date"],
                        "close_date": index,
                        "open_price": position["open_price"],
                        "close_price": close_price,
                        "open_size": position["open_size"],
                        "close_size": close_size,
                        "open_fee": position["open_fee"],
                        "close_fee": fee,
                        "open_reason": position["open_reason"],
                        "close_reason": "Stop Loss",
                        "open_balance": position["open_balance"],
                        "close_balance": balance,
                    }
                )
                position = None
                # print(f"{index} - Sell for {balance}$ of {asset} at {close_price}$")

            elif position and row["sell_signal"]:
                close_price = row["close"]
                trade_result = (close_price - position["open_price"]) / position[
                    "open_price"
                ]
                close_size = (
                    position["open_size"] + position["open_size"] * trade_result
                )
                fee = close_size * fees
                close_size = close_size - fee
                balance = balance + close_size - position["open_size"]
                self.trades.append(
                    {
                        "open_date": position["open_date"],
                        "close_date": index,
                        "open_price": position["open_price"],
                        "close_price": close_price,
                        "open_size": position["open_size"],
                        "close_size": close_size,
                        "open_fee": position["open_fee"],
                        "close_fee": fee,
                        "open_reason": position["open_reason"],
                        "close_reason": "Market Sell",
                        "open_balance": position["open_balance"],
                        "close_balance": balance,
                    }
                )
                position = None
                # print(f"{index} - Sell for {balance}$ of {asset} at {close_price}$")

    def backtest_analysis(self):
        df_trades = pd.DataFrame(self.trades)
        df_days = pd.DataFrame(self.days)

        if df_trades.empty:
            raise Exception("No trades found")
        if df_days.empty:
            raise Exception("No days found")

        df_days['evolution'] = df_days['balance'].diff()
        df_days['daily_return'] = df_days['evolution']/df_days['balance'].shift(1)

        df_trades["trade_result"] = df_trades["close_size"] - df_trades["open_size"]
        df_trades["trade_result_pct"] = (
            df_trades["trade_result"] / df_trades["open_size"]
        )
        df_trades["trades_duration"] = df_trades["close_date"] - df_trades["open_date"]

        df_days["balance_ath"] = df_days["balance"].cummax()
        df_days["drawdown"] = df_days["balance_ath"] - df_days["balance"]
        df_days["drawdown_pct"] = df_days["drawdown"] / df_days["balance_ath"]

        total_trades = len(df_trades)
        total_days = len(df_days)

        good_trades = df_trades.loc[df_trades["trade_result"] > 0]
        total_good_trades = len(good_trades)
        avg_profit_good_trades = good_trades["trade_result_pct"].mean()
        mean_good_trades_duration = good_trades["trades_duration"].mean()
        global_win_rate = total_good_trades / total_trades

        bad_trades = df_trades.loc[df_trades["trade_result"] < 0]
        total_bad_trades = len(bad_trades)
        avg_profit_bad_trades = bad_trades["trade_result_pct"].mean()
        mean_bad_trades_duration = bad_trades["trades_duration"].mean()

        max_days_drawdown = df_days["drawdown_pct"].max()
        initial_balance = df_days.iloc[0]["balance"]
        final_balance = df_days.iloc[-1]["balance"]
        balance_evolution = (final_balance - initial_balance) / initial_balance
        mean_trades_duration = df_trades["trades_duration"].mean()
        avg_profit = df_trades["trade_result_pct"].mean()
        mean_trades_duration = df_trades['trades_duration'].mean()
        mean_trades_per_days = total_trades/total_days

        best_trade = df_trades.loc[df_trades["trade_result_pct"].idxmax()]
        worst_trade = df_trades.loc[df_trades["trade_result_pct"].idxmin()]

        sharpe_ratio = (365**(0.5) * df_days['daily_return'].mean())/df_days['daily_return'].std()

        print(f"Period: [{df_days.iloc[0]['day']}] -> [{df_days.iloc[-1]['day']}]")
        print(f"Initial balance: {round(initial_balance,2)} $")

        print("\n--- General Information ---")
        print(f"Final balance: {round(final_balance,2)} $")
        print(f"Sharpe Ratio: {round(sharpe_ratio,2)}")
        print(f"Performance: {round(balance_evolution*100,2)} %")
        print(f"Worst Drawdown: -{round(max_days_drawdown*100, 2)}%")
        print(f"Total trades on the period: {total_trades}")
        print(f"Average Profit: {round(avg_profit*100, 2)} %")
        print(f"Global Win rate: {round(global_win_rate*100, 2)} %")

        print("\n--- Trades Information ---")
        print(f"Mean Trades per day: {round(mean_trades_per_days, 2)}")
        print(f"Mean Trades Duration: {mean_trades_duration}")
        print(f"Best trades: +{round(best_trade['trade_result_pct']*100, 2)} % the {best_trade['open_date']} -> {best_trade['close_date']}")
        print(f"Worst trades: {round(worst_trade['trade_result_pct']*100, 2)} % the {worst_trade['open_date']} -> {worst_trade['close_date']}")
        print(f"Total Good trades on the period: {total_good_trades}")
        print(f"Total Bad trades on the period: {total_bad_trades}")
        print(f"Average Good Trades result: {round(avg_profit_good_trades*100, 2)} %")
        print(f"Average Bad Trades result: {round(avg_profit_bad_trades*100, 2)} %")
        print(f"Mean Good Trades Duration: {mean_good_trades_duration}")
        print(f"Mean Bad Trades Duration: {mean_bad_trades_duration}")

        print("\n--- Trades reasons ---")
        print(df_trades["open_reason"].value_counts().to_string())
        print(df_trades["close_reason"].value_counts().to_string())

        self.df_days = df_days
        self.df_trades = df_trades

    def plot_equity_vs_asset(self):
        df_days = self.df_days.copy()
        df_days = df_days.set_index('day')
        fig, ax_left = plt.subplots(figsize=(15, 20), nrows=4, ncols=1)

        ax_left[0].title.set_text("Profit and Loss curve")
        ax_left[0].plot(df_days['balance'], color='royalblue', lw=1)
        ax_left[0].fill_between(df_days['balance'].index, df_days['balance'], alpha=0.2, color='royalblue')
        ax_left[0].axhline(y=df_days.iloc[0]['balance'], color='black', alpha=0.3)
        ax_left[0].legend(['Balance evolution'], loc ="upper left")

        ax_left[1].title.set_text("Asset evolution")
        ax_left[1].plot(df_days['price'], color='sandybrown', lw=1)
        ax_left[1].fill_between(df_days['price'].index, df_days['price'], alpha=0.2, color='sandybrown')
        ax_left[1].axhline(y=df_days.iloc[0]['price'], color='black', alpha=0.3)
        ax_left[1].legend(['Asset evolution'], loc ="upper left")

        ax_left[2].title.set_text("Drawdown curve")
        ax_left[2].plot(-df_days['drawdown_pct']*100, color='indianred', lw=1)
        ax_left[2].fill_between(df_days['drawdown_pct'].index, -df_days['drawdown_pct']*100, alpha=0.2, color='indianred')
        ax_left[2].axhline(y=0, color='black', alpha=0.3)
        ax_left[2].legend(['Drawdown in %'], loc ="lower left")

        ax_right = ax_left[3].twinx()

        ax_left[3].title.set_text("P&L VS Asset (not on the same scale)")
        ax_left[3].plot(df_days['balance'], color='royalblue', lw=1)
        ax_right.plot(df_days['price'], color='sandybrown', lw=1)
        ax_left[3].legend(['Wallet evolution (equity)'], loc ="lower right")
        ax_right.legend(['Asset evolution'], loc ="upper left")

        plt.show()

bt = BacktestCrossMA()
bt.load_data('BTC-USDT.csv')
bt.populate_indicators()
bt.populate_signals()
bt.run_backtest()
bt.backtest_analysis()
bt.plot_equity_vs_asset()

Practical exercise

This marks the final episode of our "Python for Finance" free course. Therefore, we will not provide a structured practical application as before. It is now up to you to apply your acquired skills to meet your specific needs and push your limits to explore new horizons.

However, to continue in the same theme, we suggest experimenting with new trading strategies, integrating mechanisms such as Take Profit - which essentially work as the opposite of Stop Loss - to secure your gains when markets move in your favor.

Moreover, enrich your analysis by integrating additional measures such as the number of days in gain, in loss, the longest winning or losing streak, among others. Explore the incorporation of new charts and visualizations to offer more nuanced and detailed perspectives on your trading data and results. You have a correction suggestion here if you need https://github.com/RobotTraders/Python_For_Finance/blob/main/exercise_correction_chapter_10.ipynb.

Remember, the possibilities are endless; every addition and adjustment is a step towards refining your approach to navigate accurately in the dynamic and complex environment of finance. Happy exploring!