Created with Highcharts 12.1.2EquityJul 2020Jan 2021Jul 2021Jan 2022Jul 2022Jan 2023Jul 2023Jan 2024Jul 2024Jan 2025Jul 202501M2M3M-20-10000.20120100M200M01M2M050
Overall Statistics
Total Orders
3041
Average Win
0.10%
Average Loss
-0.05%
Compounding Annual Return
17.888%
Drawdown
17.400%
Expectancy
1.074
Start Equity
1000000
End Equity
2277578.34
Net Profit
127.758%
Sharpe Ratio
0.803
Sortino Ratio
0.966
Probabilistic Sharpe Ratio
46.744%
Loss Rate
35%
Win Rate
65%
Profit-Loss Ratio
2.18
Alpha
0.012
Beta
0.803
Annual Standard Deviation
0.125
Annual Variance
0.016
Information Ratio
-0.174
Tracking Error
0.054
Treynor Ratio
0.125
Total Fees
$3411.99
Estimated Strategy Capacity
$30000000.00
Lowest Capacity Asset
ROP R735QTJ8XC9X
Portfolio Turnover
0.94%
# region imports
from AlgorithmImports import *
from universe import TopologicalGraphUniverseSelectionModel
from portfolio import EqualClustersWeightingPortfolioConstructionModel
# endregion
np.random.seed(0)

class TopologicalPortfolio(QCAlgorithm):
    def initialize(self):
        self.set_end_date(datetime.now())
        self.set_start_date(self.end_date - timedelta(5*365))
        self.set_cash(1000000)

        # Lookback window to construct and analyze the topological structure.
        history_lookback = self.get_parameter("history_lookback", 250)
        # Set the period to reconstruct the topological complex.
        recalibrate_periods = [
            self.date_rules.every_day(),
            self.date_rules.week_start(),
            self.date_rules.month_start(),
            self.date_rules.year_start()
        ]
        recalibrate_period = recalibrate_periods[self.get_parameter("recalibrate_period", 0)]
        # Construct a portfolio with SPY constituents.
        universe_model = TopologicalGraphUniverseSelectionModel(
            "SPY",
            history_lookback,
            recalibrate_period,
            lambda u: [x.symbol for x in sorted(
                [x for x in u if x.weight], 
                key=lambda x: x.weight, 
                reverse=True
            )[:200]]
        )
        self.add_universe_selection(universe_model)
        self.add_alpha(ConstantAlphaModel(InsightType.PRICE, InsightDirection.UP, timedelta(1)))
        self.set_portfolio_construction(EqualClustersWeightingPortfolioConstructionModel(timedelta(1), universe_model))

        # We would like to compare with SPY for the correlation and risk-adjusted return.
        self.set_benchmark("SPY")
# region imports
from AlgorithmImports import *
from Portfolio.EqualWeightingPortfolioConstructionModel import EqualWeightingPortfolioConstructionModel
# endregion

class EqualClustersWeightingPortfolioConstructionModel(EqualWeightingPortfolioConstructionModel):
    def __init__(self, rebalance, universe_model):
        self.universe_model = universe_model
        super().__init__(rebalance)

    def determine_target_percent(self, active_insights):
        results = {}
        if self.universe_model.clustered_symbols is not None:
            # Equal weight distribution between and within each cluster.
            series = self.weight_distribution(self.universe_model.clustered_symbols)
            # Connect insight with the assigned weight.
            return {insight: series[insight.symbol] if insight.symbol in series.index else 0 for insight in active_insights}
        return {}

    def weight_distribution(self, clustered_symbols):
        # Assign weight between and within giant and small clusters. Note that we do not invest in outliers.
        weights = {}
        def assign_weights(nested_list, level=1):
            num_elements = len(nested_list)
            if num_elements == 0:
                return
            weight_per_element = 1 / num_elements
            for item in nested_list:
                if isinstance(item, list):
                    assign_weights(item, level + 1)
                else:
                    weights[item] = weights.get(item, 0) + weight_per_element / (2 ** (level - 1))
        # Calculate the overall weights.
        assign_weights(clustered_symbols)
        return pd.Series(weights) / sum(weights.values())
# region imports
from AlgorithmImports import *
from Selection.ETFConstituentsUniverseSelectionModel import ETFConstituentsUniverseSelectionModel
import kmapper as km
from sklearn.cluster import DBSCAN
from sklearn.decomposition import PCA
from umap import UMAP
# endregion
np.random.seed(0)

class TopologicalGraphUniverseSelectionModel(ETFConstituentsUniverseSelectionModel):
    def __init__(self, etf_symbol, lookback_window = 250, recalibration_period = None, universe_filter_func = None):
        self.lookback_window = lookback_window
        self.recalibration_period = recalibration_period
        self.clustered_symbols = None
        super().__init__(etf_symbol, None, universe_filter_func)

    def create_universes(self, algorithm: QCAlgorithm) -> list[Universe]:
        universe_list = super().create_universes(algorithm)
        # Set schedule event to reconstruct the topological structure.
        # Initial warm up of the universe (the maximum days until the next open is 4 days).
        algorithm.schedule.on(
            algorithm.date_rules.on([algorithm.time + timedelta(5)]),
            algorithm.time_rules.at(23, 59),
            lambda: self.get_graph_symbols(algorithm)
        )
        algorithm.schedule.on(
            self.recalibration_period if self.recalibration_period is not None else algorithm.date_rules.every_day(),
            algorithm.time_rules.at(0, 1),
            lambda: self.get_graph_symbols(algorithm)
        )
        return universe_list

    def get_graph_symbols(self, algorithm):
        # Construct simplicial complex.
        graph, symbol_list = self.construct_simplicial_complex(algorithm, self.lookback_window)
        if len(symbol_list) > 0:
            self.clustered_symbols = self.clustering_symbols(graph, symbol_list)

    def construct_simplicial_complex(self, algorithm: QCAlgorithm, lookback_window: int) -> Dict[str, object]:
        if not self.universe.selected:
            return {}, []
        # Obtain historical data to construct a graph of stock relationship.
        prices = algorithm.history(self.universe.selected, lookback_window, Resolution.DAILY).unstack(0).close
        # Calculate daily log return. Then, transpose the data since we're relating stocks.
        log_returns = np.log(prices / prices.shift(1)).dropna().T
        if log_returns.empty:
            return {}, []

        # Initialize the mapper algorithm.
        mapper = km.KeplerMapper()
        # Project the data into a 2d subspace via 2 transformation, PCA and UMAP.
        # PCA: since it can retain the most variance while denoising, as well as fast.
        # UMAP: handles non-linear relationships well and preserves both local and global structures.
        # MDS and Isomap are not included due to their potential sensitivity to noise and outliers in financial data.
        projected_data = mapper.fit_transform(log_returns, projection=[PCA(n_components=0.8, random_state=1), UMAP(n_components=1, random_state=1, n_jobs=-1)])
        # Cluster the data with DBSCAN since it is better in handling noise.
        # We are interested in the correlation distance to cluster and form a portfolio.
        graph = mapper.map(projected_data, log_returns, clusterer=DBSCAN(metric='correlation', n_jobs=-1))
        return graph, prices.columns

    def clustering_symbols(self, graph, symbol_list):
        # Each connected structure as a giant cluster.
        linked_clusters = []
        for x, y in graph['links'].items():
            isin = False
            for i in range(len(linked_clusters)):
                if x in linked_clusters[i] or y in linked_clusters[i]:
                    linked_clusters[i] = list(set(linked_clusters[i] + [x] + y))
                    isin = True
            if isin:
                continue
            linked_clusters.append([x] + y)
        linked_clusters += [[x] for x in graph['nodes'] if x not in [z for y in linked_clusters for z in y]]
        # Convert the node into symbol.
        return [[list([symbol_list[graph['nodes'][x]]][0]) for x in linked_cluster] for linked_cluster in linked_clusters]