Overall Statistics
Total Orders
11
Average Win
7.21%
Average Loss
-7.08%
Compounding Annual Return
11.070%
Drawdown
18.900%
Expectancy
0.413
Start Equity
100000
End Equity
162069.60
Net Profit
62.070%
Sharpe Ratio
0.45
Sortino Ratio
0.499
Probabilistic Sharpe Ratio
15.421%
Loss Rate
30%
Win Rate
70%
Profit-Loss Ratio
1.02
Alpha
0.038
Beta
0.29
Annual Standard Deviation
0.135
Annual Variance
0.018
Information Ratio
-0.1
Tracking Error
0.178
Treynor Ratio
0.209
Total Fees
$26.97
Estimated Strategy Capacity
$110000000.00
Lowest Capacity Asset
SPY R735QTJ8XC9X
Portfolio Turnover
0.99%
# region imports
from AlgorithmImports import *
import numpy as np
from ripser import Rips
import persim
# endregion

class MuscularMagentaLion(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2020, 1, 1)
        self.SetCash(100000)
        
        self.eq = self.AddEquity("SPY", Resolution.HOUR).Symbol

        # Rolling window
        self.lookback = 20
        self.threshold = 1
        self.rips = Rips(maxdim=2)

        self.close_window = RollingWindow[float](self.lookback*5)

        self.SetWarmup(self.lookback*5)


        #self.AddRiskManagement(MaximumUnrealizedProfitPercentPerSecurity(0.01))
        #self.AddRiskManagement(MaximumDrawdownPercentPerSecurity(0.01))

    def OnData(self, data: Slice):
        
        if self.IsWarmingUp:
            return

        if not (data.ContainsKey(self.eq) and data[self.eq] is not None):
            return

        self.close_window.Add(data[self.eq].close)

        if not self.close_window.IsReady:
            return

        closes_list = list(self.close_window)
        self.prices = np.array(closes_list)

        lgr = np.log(self.prices[1:] / self.prices[:-1])

        wasserstein_dists = self.compute_wasserstein_distances(lgr, self.lookback, self.rips)
        wd = sum(wasserstein_dists)

        self.Plot("wd", "wd", wd)

        
        if self.Portfolio[self.eq].is_short:

            if wd <= self.threshold:
                self.set_holdings(self.eq, 0.80, True)

            else: return

        elif self.Portfolio[self.eq].is_long:
            if wd >= self.threshold:
                self.set_holdings(self.eq, -0.80, True)

            else: return

        else: self.set_holdings(self.eq, 0.80)

    def compute_wasserstein_distances(self, log_returns, window_size, rips):
        """Compute the Wasserstein distances."""

        # https://medium.com/@crisvelasquez/predicting-stock-market-crashes-with-topological-data-analysis-in-python-1dc4f18ca7ca

        n = len(log_returns) - (2 * window_size) + 1
        distances = np.full((n, 1), np.nan)  # Using np.full with NaN values

        for i in range(n):
            segment1 = log_returns[i:i+window_size].reshape(-1, 1)
            segment2 = log_returns[i+window_size:i+(2*window_size)].reshape(-1, 1)

            if segment1.shape[0] != window_size or segment2.shape[0] != window_size:
                continue

            dgm1 = rips.fit_transform(segment1)
            dgm2 = rips.fit_transform(segment2)
            distance = persim.wasserstein(dgm1[0], dgm2[0], matching=False)
            distances[i] = distance

        return distances