Overall Statistics
Total Orders
309
Average Win
1.44%
Average Loss
-0.62%
Compounding Annual Return
32.628%
Drawdown
24.100%
Expectancy
1.463
Start Equity
100000.0
End Equity
407769.70
Net Profit
307.770%
Sharpe Ratio
1.347
Sortino Ratio
1.976
Probabilistic Sharpe Ratio
75.448%
Loss Rate
26%
Win Rate
74%
Profit-Loss Ratio
2.32
Alpha
0.246
Beta
0.192
Annual Standard Deviation
0.244
Annual Variance
0.06
Information Ratio
-0.201
Tracking Error
0.511
Treynor Ratio
1.714
Total Fees
$2031.43
Estimated Strategy Capacity
$7900000000.00
Lowest Capacity Asset
TUSDUSDT 18N
Portfolio Turnover
0.36%
Drawdown Recovery
846
from AlgorithmImports import *


class BinanceTopCapMomentum(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2021, 1, 1)
        self.SetCash(100000)

        self.SetBrokerageModel(BrokerageName.Binance, AccountType.Margin)

        self.UniverseSettings.Resolution = Resolution.Daily
        self.UniverseSettings.Leverage = 1.0  # exposure controlled by weights

        # --- Strategy parameters
        self.N_HOLD = 5
        self.WEIGHT = 0.05          # 5% each => 25% total exposure
        self.MOM_LOOKBACK = 252     # ~12 months (trading-days convention)

        # --- "Top 10 market cap USDT pairs"
        # NOTE: QC Binance universe doesn't provide market cap ranking,
        # so we use a static list that approximates "top market cap".
        # You can modify this list anytime.
        self.top10_usdt_tickers = [
            "BTCUSDT", "ETHUSDT", "BNBUSDT", "XRPUSDT", "SOLUSDT",
            "ADAUSDT", "DOGEUSDT", "TRXUSDT", "DOTUSDT", "MATICUSDT"
        ]

        # --- Add stablecoin pairs on top of the universe
        self.extra_pairs = ["USDCUSDT", "TUSDUSDT"]

        self.rebalance = False
        self.current_universe = []

        # Add a Binance universe, but we will *only* select our chosen symbols from it
        self.AddUniverse(CryptoUniverse.Binance(self.SelectUniverse))

        # Also explicitly add the stablecoin pairs so they exist even if universe data is sparse
        for ticker in self.extra_pairs:
            self.AddCrypto(ticker, Resolution.Daily, Market.Binance)

        # Monthly rebalance: first trading day of each month at 00:00
        self.Schedule.On(
            self.DateRules.MonthStart(),
            self.TimeRules.At(0, 0),
            self.TriggerRebalance
        )

        self.SetWarmUp(self.MOM_LOOKBACK + 5, Resolution.Daily)

    def TriggerRebalance(self):
        self.rebalance = True

    def SelectUniverse(self, universe):
        """
        Universe contains Binance crypto pair snapshots.
        We select:
          - the static top-10 "market cap" USDT pairs, plus
          - USDCUSDT and DAIUSDT
        And we also avoid non-USDT quotes to prevent FX conversion issues (e.g., NGN).
        """
        # Build a set of available symbols from the incoming universe slice
        available = set()
        for c in universe:
            sym = getattr(c, "Symbol", None)
            if sym is None:
                continue

            # Avoid NGN and other unsupported quote currencies; keep USDT pairs only
            if not sym.Value.endswith("USDT"):
                continue
            available.add(sym.Value)

        desired = list(self.top10_usdt_tickers) + list(self.extra_pairs)

        # Keep only what is actually available in this runtime/feed
        selected_values = [v for v in desired if v in available]

        # Convert to Symbol objects that QC expects from universe selection
        selected_symbols = [Symbol.Create(v, SecurityType.Crypto, Market.Binance) for v in selected_values]

        self.current_universe = selected_symbols
        return selected_symbols

    def OnSecuritiesChanged(self, changes: SecurityChanges):
        for sec in changes.AddedSecurities:
            sec.SetLeverage(1.0)

    def OnData(self, data: Slice):
        if self.IsWarmingUp or not self.rebalance:
            return
        self.rebalance = False

        symbols = list(self.current_universe)
        # Ensure stablecoin pairs are included even if not selected via universe
        for ticker in self.extra_pairs:
            sym = self.Symbol(ticker)
            if sym not in symbols:
                symbols.append(sym)

        # Pull history for momentum ranking
        hist = self.History(symbols, self.MOM_LOOKBACK + 1, Resolution.Daily)
        if hist.empty:
            return

        mom = []
        for sym in symbols:
            try:
                df = hist.loc[sym]
            except Exception:
                continue

            if df is None or len(df.index) < (self.MOM_LOOKBACK + 1):
                continue
            if "close" not in df.columns:
                continue

            closes = df["close"].dropna()
            if len(closes) < (self.MOM_LOOKBACK + 1):
                continue

            close_now = float(closes.iloc[-1])
            close_then = float(closes.iloc[-(self.MOM_LOOKBACK + 1)])
            if close_then <= 0:
                continue

            momentum_12m = close_now / close_then - 1.0
            mom.append((sym, momentum_12m))

        if len(mom) == 0:
            return

        # Top 5 by 12-month momentum
        mom.sort(key=lambda x: x[1], reverse=True)
        selected = [x[0] for x in mom[:self.N_HOLD]]

        # Liquidate anything not selected
        for sym in self.Securities.Keys:
            if self.Portfolio[sym].Invested and sym not in selected:
                self.Liquidate(sym)

        # Allocate 5% each (25% total exposure)
        for sym in selected:
            if sym in self.Securities and self.Securities[sym].HasData:
                self.SetHoldings(sym, self.WEIGHT)

        self.Debug(f"{self.Time.date()} Selected: {', '.join([s.Value for s in selected])}")