Overall Statistics
Total Orders
163
Average Win
4.64%
Average Loss
-2.68%
Compounding Annual Return
21.455%
Drawdown
47.800%
Expectancy
0.465
Start Equity
100000.00
End Equity
263137.42
Net Profit
163.137%
Sharpe Ratio
0.773
Sortino Ratio
1.142
Probabilistic Sharpe Ratio
31.399%
Loss Rate
46%
Win Rate
54%
Profit-Loss Ratio
1.73
Alpha
0.139
Beta
0.22
Annual Standard Deviation
0.302
Annual Variance
0.092
Information Ratio
-0.379
Tracking Error
0.524
Treynor Ratio
1.064
Total Fees
â‚®3623.91
Estimated Strategy Capacity
â‚®21000000000000.00
Lowest Capacity Asset
BNBUSDT 18N
Portfolio Turnover
1.10%
Drawdown Recovery
1076
from AlgorithmImports import *

class BinanceTopVolumeMomentum(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2021, 1, 1)

        # (Optional but recommended for Binance USDT trading to avoid conversion quirks)
        self.SetAccountCurrency("USDT")
        self.SetCash(100000)

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

        self.UniverseSettings.Resolution = Resolution.Daily
        self.UniverseSettings.Leverage = 1.0
        self.UniverseSettings.Asynchronous = True  # QC recommends async for crypto universes :contentReference[oaicite:1]{index=1}

        # --- Strategy parameters
        self.TOP_N_UNIVERSE = 7
        self.N_HOLD = 2
        self.WEIGHT = 0.15
        self.MOM_LOOKBACK = 252

        # --- Optional: always keep these subscribed (if you still want them)
        self.extra_pairs = ["USDCUSDT", "TUSDUSDT"]
        for ticker in self.extra_pairs:
            self.AddCrypto(ticker, Resolution.Daily, Market.Binance)

        self.rebalance = False
        self.current_universe = []

        # Universe = top 10 USDT pairs by USD volume (previous day snapshot)
        self.AddUniverse(CryptoUniverse.Binance(self.SelectUniverse))

        # Monthly rebalance
        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_day):
        # Keep only USDT-quoted pairs with valid USD volume
        candidates = []
        for c in universe_day:
            sym = getattr(c, "Symbol", None)
            if sym is None:
                continue

            # USDT quote only (avoids unsupported quote currencies)
            if not sym.Value.endswith("USDT"):
                continue

            vol_usd = getattr(c, "VolumeInUsd", None)
            if vol_usd is None:
                vol_usd = getattr(c, "volume_in_usd", None)

            if not vol_usd:
                continue

            candidates.append((sym, float(vol_usd)))

        # Top N by USD volume
        candidates.sort(key=lambda x: x[1], reverse=True)
        selected = [x[0] for x in candidates[:self.TOP_N_UNIVERSE]]

        self.current_universe = selected
        return selected

    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 extra 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)

        if len(symbols) == 0:
            return

        # Always assign hist before using it
        hist = self.History(symbols, self.MOM_LOOKBACK + 1, Resolution.Daily)
        if hist is None or hist.empty:
            return

        mom = []

        # Iterate only symbols that actually came back from History
        for sym, df in hist.groupby(level=0):
            if df is None or "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 not mom:
            return

        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 list(self.Securities.Keys):
            if self.Portfolio[sym].Invested and sym not in selected:
                self.Liquidate(sym)

        # Allocate
        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])}")