Overall Statistics
Total Orders
158
Average Win
3.89%
Average Loss
-0.97%
Compounding Annual Return
8.137%
Drawdown
20.200%
Expectancy
2.627
Start Equity
100000
End Equity
727356.51
Net Profit
627.357%
Sharpe Ratio
0.429
Sortino Ratio
0.4
Probabilistic Sharpe Ratio
2.541%
Loss Rate
28%
Win Rate
72%
Profit-Loss Ratio
4.01
Alpha
0.025
Beta
0.29
Annual Standard Deviation
0.085
Annual Variance
0.007
Information Ratio
-0.031
Tracking Error
0.134
Treynor Ratio
0.126
Total Fees
$976.92
Estimated Strategy Capacity
$820000000.00
Lowest Capacity Asset
SPY R735QTJ8XC9X
Portfolio Turnover
0.72%
# region imports
from AlgorithmImports import *
from dateutil.relativedelta import relativedelta
# endregion

# Source: https://www.finra.org/rules-guidance/key-topics/margin-accounts/margin-statistics
class MarginDebt(PythonData):
    _last_update_date:datetime.date = datetime(1,1,1).date()

    @staticmethod
    def get_last_update_date() -> datetime.date:
       return MarginDebt._last_update_date

    def GetSource(self, config: SubscriptionDataConfig, date: datetime, isLiveMode: bool) -> SubscriptionDataSource:
        return SubscriptionDataSource(f'data.quantpedia.com/backtesting_data/economic/MARGIN_DEBT.csv', SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)

    def Reader(self, config: SubscriptionDataConfig, line: str, date: datetime, isLiveMode: bool) -> BaseData:
        data = MarginDebt()
        data.Symbol = config.Symbol

        if not line[0].isdigit(): return None
        split = line.split(';')
        
        # Parse the CSV file's columns into the custom data class
        data.Time = datetime.strptime(split[0], "%Y-%m-%d") + relativedelta(months=1)
        if split[-1] != '.':
            data.Value = float(split[-1])

        if data.Time.date() > MarginDebt._last_update_date:
            MarginDebt._last_update_date = data.Time.date()
        
        return data
# https://quantpedia.com/strategies/margin-debt-market-timing-strategy
# 
# The investment universe for this strategy consists of two primary instruments: SPY ETF, which serves as a proxy for the stock market performance, and SHY ETF, 
# representing a low-risk, cash-like investment. SPY is selected based on its role as a widely recognized benchmark for the U.S. stock market, while SHY is chosen 
# for its stability and low risk, making it suitable for holding cash positions. The strategy also incorporates margin debt data as a key indicator, sourced from 
# FINRA, which provides insights into investor leverage and sentiment.
# The strategy employs moving averages as the primary tool for generating trading signals. Specifically, it uses 6 to 12-month moving averages of both SPY prices 
# and margin debt. The methodology involves calculating these moving averages monthly and comparing the latest SPY price and margin debt value to their respective 
# moving averages. A "Buy" signal is generated when both the SPY price and margin debt exceed their moving averages, indicating a favorable market condition. 
# Conversely, a "Hold Cash" signal is triggered when either the SPY price or margin debt falls below their moving averages, suggesting a more cautious approach.
# The strategy involves monthly rebalancing, where capital is allocated based on the latest signals. Positions are equally distributed across the different moving 
# average periods (6 to 12 months) to diversify risk and reduce the impact of any single period's underperformance. Risk management is achieved through this 
# diversification and the use of SHY ETF to hold cash positions during unfavorable market conditions. This approach helps mitigate potential losses and stabilizes 
# returns by ensuring that not all capital is exposed to market risk at any given time.

# region imports
from AlgorithmImports import *
import data_tools
from dateutil.relativedelta import relativedelta
# endregion

class MarginDebtMarketTimingStrategy(QCAlgorithm):

    def initialize(self) -> None:
        self.set_start_date(2000, 1, 1)
        self.set_cash(100_000)

        self._SMA_range: range = range(6, 13)

        self._market: Symbol = self.add_equity('SPY', Resolution.DAILY).symbol
        self._cash: Symbol = self.add_equity('SHY', Resolution.DAILY).symbol
        self._margin_debt: Symbol = self.add_data(data_tools.MarginDebt, 'MARGIN_DEBT', Resolution.DAILY).symbol

        self._rebalance_flag: bool = False
        self.schedule.on(
            self.date_rules.month_start(self._market),
            self.time_rules.after_market_open(self._market),
            self._rebalance
        )

        self.settings.daily_precise_end_time = False

    def on_data(self, slice: Slice) -> None:
        # Monthly rebalance.
        if not self._rebalance_flag:
            return
        self._rebalance_flag = False

        # Chech if data is still coming.
        if self.securities[self._margin_debt].get_last_data() and self.time.date() > data_tools.MarginDebt.get_last_update_date():
            self.log('Margin Debt data stopped coming.')
            return

        # SPY and margin debt history.
        spy_history: DataFrame = self.history(self._market, start=self.time - relativedelta(months=max(self._SMA_range)), end=self.time).unstack(level=0)
        margin_debt_history: DataFrame = self.history(self._margin_debt, start=self.time - relativedelta(months=max(self._SMA_range) + 1), end=self.time).unstack(level=0)

        if any(df.empty for df in [spy_history, margin_debt_history]):
            self.log('Insufficient data for further calculation.')
            return

        spy_history_prices: DataFrame = spy_history.close.groupby(pd.Grouper(freq='MS')).first()
        margin_debt_history = margin_debt_history.value

        spy_allocation, shy_allocation = .0, .0

        # Evaluate signals using the SMA.
        for SMA_period in self._SMA_range:
            signals: List[bool] = []
            for df in [spy_history_prices, margin_debt_history]:
                cutoff_date: datetime = (self.time - relativedelta(months=SMA_period)).replace(day=1)
                observed_df: DataFrame = df.loc[cutoff_date:]
                signals.append(True if self.securities[df.columns[0]].get_last_data().price > observed_df.mean()[0] else False)
            if all(signals):
                spy_allocation += 1 / len(self._SMA_range)
            else:
                shy_allocation += 1 / len(self._SMA_range)

        # Trade execution.
        targets: List[PortfolioTarget] = []
        for symbol, allocation in [(self._market, spy_allocation), (self._cash, shy_allocation)]:
            if slice.contains_key(symbol) and slice[symbol]:
                targets.append(PortfolioTarget(symbol, allocation))
        
        self.set_holdings(targets, True)

    def _rebalance(self) -> None:
        if not all(self.securities[symbol].get_last_data() for symbol in [self._market, self._cash]):
            return
        self._rebalance_flag = True