Overall Statistics
Total Orders
6651
Average Win
0.11%
Average Loss
-0.26%
Compounding Annual Return
16.984%
Drawdown
44.400%
Expectancy
0.087
Start Equity
100000.00
End Equity
219189.09
Net Profit
119.189%
Sharpe Ratio
0.593
Sortino Ratio
0.704
Probabilistic Sharpe Ratio
13.794%
Loss Rate
24%
Win Rate
76%
Profit-Loss Ratio
0.44
Alpha
0.138
Beta
0.36
Annual Standard Deviation
0.368
Annual Variance
0.135
Information Ratio
-0.01
Tracking Error
0.453
Treynor Ratio
0.605
Total Fees
â‚®11772.68
Estimated Strategy Capacity
â‚®280000.00
Lowest Capacity Asset
LINKUSDT 18N
Portfolio Turnover
5.47%
Drawdown Recovery
759
# region imports
from AlgorithmImports import *
from collections import deque
import numpy as np
import pandas as pd
import strategies
# endregion


class Q25CryptoTop10Strategy(QCAlgorithm):
    """
    Quantiacs Q25 Crypto Top-10 Long entry on QuantConnect (Strategies submission).

    Long-only over the point-in-time top-10 crypto assets by CoinGecko market cap that trade
    against USDT on Binance (stablecoins and wrapped coins excluded). One of the three Q25 weight
    functions in strategies.py (STRATEGY below) sets the daily target book: per-name cap 0.35,
    3-day weight smoothing, cash residual when the signal is off.

    Daily cycle (UTC): the daily bar closes 00:00 -> 00:05 compute targets and sell -> 01:05 buy
    (cash account: proceeds must settle before buys; orders fill on the next hourly bar).
    Default Binance brokerage, fee and fill models; hourly bars; 5-year backtest window.
    """

    STABLE = {"USDT", "USDC", "BUSD", "DAI", "TUSD", "USDE", "UST", "USTC", "USDS", "FDUSD",
              "PYUSD", "USDD", "USDP", "GUSD", "FRAX", "LUSD", "PAX", "SUSD", "EURC", "USD1"}
    WRAPPED = {"WBTC", "STETH", "WSTETH", "WETH", "WBETH", "WEETH", "CBBTC", "RETH", "BTCB",
               "HBTC", "BSC-USD", "CBETH", "LEO"}
    TOP_N = 10
    WARMUP_DAYS = 400   # EWMA states (E2 book vol, E3 market EMA) converge before trading
    WMAX = 0.35
    SMOOTH = 3
    MIN_NOTIONAL = 10.0   # USDT; Binance rejects orders under 5 USDT notional

    STRATEGY = "e3"      # e1 | e2 | e3 (strategies.py) — e3 dual momentum: best 1y/3y Sharpe on the research grid
    PRESUB = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]   # see _setup_universe

    def initialize(self):
        self._strategy = self.STRATEGY
        self._universe_mode = "coingecko"
        self._market_name = "binance"
        self._band = 0.0
        self._res = Resolution.HOUR
        self._presub = list(self.PRESUB)
        self.set_time_zone(TimeZones.UTC)
        self.set_start_date(self.end_date - timedelta(5 * 365))
        self._setup_market()                 # account currency must be set BEFORE set_cash
        self.set_cash(100_000)
        self.universe_settings.resolution = self._res
        self.settings.free_portfolio_value_percentage = 0.03

        self._label, self._weight_fn, self._min_hist = strategies.STRATEGIES[self._strategy]
        self._lookback = self._min_hist + 40
        self._state: dict = {}
        self._raw_hist: deque = deque(maxlen=self.SMOOTH)
        self._liquid: set = set()
        self._tracked: set = set()
        self._pending_buys: list = []

        self._setup_universe()

        self.set_warm_up(timedelta(days=self.WARMUP_DAYS))
        # Sells first; buys one bar later so the cash account holds the proceeds (a market
        # order fills on the next hourly bar).
        self.schedule.on(self.date_rules.every_day(), self.time_rules.at(0, 5), self._rebalance)
        self.schedule.on(self.date_rules.every_day(), self.time_rules.at(1, 5), self._execute_buys)
        self.debug(f"{self._label} | universe=coingecko market=binance hourly default models")

    # ------------------------------------------------------------------ setup
    def _setup_market(self):
        self._market, self._quote = Market.BINANCE, "USDT"
        self.set_account_currency("USDT")     # before set_cash: no stray USD cash entry
        self.set_brokerage_model(BrokerageName.BINANCE, AccountType.CASH)
        pairs = self.symbol_properties_database.get_symbol_properties_list(self._market)
        self._pairs = {x.key.symbol for x in pairs
                       if x.key.security_type == SecurityType.CRYPTO
                       and x.value.quote_currency == self._quote}
        self.set_security_initializer(self._init_security)

    def _init_security(self, security):
        BrokerageModelSecurityInitializer(
            self.brokerage_model, FuncSecuritySeeder(self.get_last_known_prices)
        ).initialize(security)

    def on_warmup_finished(self):
        self._rebalance()
        self._execute_buys()

    def _setup_universe(self):
        # Majors subscribed from day one: a pair added mid-run resolves its base-currency cash
        # conversion through the symbol-properties database, where Binance lists dead USD
        # pairs for these coins (conversion rate never available).
        for pair in self._presub:
            self._tracked.add(self.add_crypto(pair, self._res, self._market).symbol)
        self.add_universe(CoinGeckoUniverse, "CoinGeckoUniverse", Resolution.DAILY,
                          self._select_coingecko)

    def _select_coingecko(self, data):
        tradable = [d for d in data
                    if d.coin.upper() not in self.STABLE and d.coin.upper() not in self.WRAPPED
                    and d.coin.upper() + self._quote in self._pairs
                    and d.market_cap is not None and d.market_cap > 0]
        top = sorted(tradable, key=lambda x: x.market_cap, reverse=True)[:self.TOP_N]
        if len(top) < self.TOP_N // 2:
            return Universe.UNCHANGED          # garbled day: keep yesterday's membership
        symbols = [c.create_symbol(self._market, self._quote) for c in top]
        # Subscribe manually and never let the universe remove a pair: a removed
        # subscription loses its cash-book conversion rate (LEAN error "conversion rate
        # for BTC is not available") while dust of that coin can still sit in the book.
        for sym in symbols:
            if sym not in self._tracked:
                self.add_crypto(sym.value, self._res, self._market)
                self._tracked.add(sym)
        self._liquid = set(symbols)
        return Universe.UNCHANGED

    # -------------------------------------------------------------- daily cycle
    def _liquid_today(self) -> list:
        return [s for s in self._liquid if s in self._tracked]

    def _close_panel(self, symbols: list) -> pd.DataFrame:
        if not symbols:
            return pd.DataFrame()
        hist = self.history(symbols, self._lookback, Resolution.DAILY)
        if hist.empty or "close" not in hist.columns:
            return pd.DataFrame()
        close = hist["close"].unstack(level=0)
        close = close.where(close > 0)
        return close.dropna(axis=1, thresh=self._min_hist + 1)

    def _rebalance(self):
        liquid = [s for s in self._liquid_today() if self.securities[s].price > 0]
        close = self._close_panel(liquid)
        raw = pd.Series(dtype=float)
        if not close.empty:
            raw = self._weight_fn(close, self._state)
            raw = raw.replace([np.inf, -np.inf], 0.0).fillna(0.0).clip(lower=0.0, upper=self.WMAX)
        # Quantiacs breadth/vol scalars are computed over the FULL liquid set; names that
        # dropped out of the panel (too little history) carry zero weight, as in qnt.
        raw_d = {s: float(raw.get(s, 0.0)) for s in liquid}
        self._raw_hist.append(raw_d)
        smoothed = {}
        for s in self._tracked:
            vals = [d.get(s, 0.0) for d in self._raw_hist]
            smoothed[s] = max(0.0, float(np.mean(vals))) if s in liquid else 0.0
        if self.is_warming_up:
            return
        total = self.portfolio.total_portfolio_value
        sells, buys = [], []
        for s, wt in smoothed.items():
            sec = self.securities[s]
            if sec.price <= 0:
                continue
            held = self.portfolio[s].holdings_value / total if total > 0 else 0.0
            if abs(wt - held) <= self._band and not (wt == 0.0 and held > 0):
                continue
            (sells if wt < held else buys).append(PortfolioTarget(s, wt))
        for target in sells:
            self._place(target)
        self._pending_buys = buys

    def _place(self, target) -> None:
        """Market order to the target weight, skipped when the order would be below the
        exchange minimum notional (unsellable dust, cash-buffer rounding)."""
        qty = self.calculate_order_quantity(target.symbol, target.quantity)
        if qty == 0:
            return
        if abs(qty) * self.securities[target.symbol].price < self.MIN_NOTIONAL:
            return
        self.market_order(target.symbol, qty)

    def _execute_buys(self):
        if self.is_warming_up or not self._pending_buys:
            return
        for target in self._pending_buys:
            if self.securities[target.symbol].price > 0:
                self._place(target)
        self._pending_buys = []

    def on_data(self, data: Slice):
        pass
"""
Run the 5-year submission backtest of this project on QuantConnect and publish it to the
Strategies platform through the API (POST /strategies/create).

  python3 publish.py backtest  [--name NAME]       # compile + run + wait, prints stats/errors
  python3 publish.py publish <backtestId> --name NAME --description TEXT [--notes TEXT]
"""
from __future__ import annotations

import argparse
import json
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path.home() / "quantmar" / "qccloud"))
import qc_api  # noqa: E402

PID = int(json.load(open(Path(__file__).resolve().parent / "config.json"))["cloud-id"])
KEYS = ["Sharpe Ratio", "Compounding Annual Return", "Drawdown", "Total Orders", "Net Profit",
        "Probabilistic Sharpe Ratio", "Annual Standard Deviation"]


def backtest(name: str) -> str:
    cid = qc_api.compile_project(PID)
    for _ in range(120):
        b = qc_api.call("/backtests/create", {"projectId": PID, "compileId": cid, "backtestName": name})
        if b.get("success"):
            break
        if "no spare nodes" not in " ".join(b.get("errors") or []):
            raise RuntimeError(b.get("errors"))
        time.sleep(30)
    btid = b["backtest"]["backtestId"]
    print("backtest", btid, "running", flush=True)
    for _ in range(360):
        d = qc_api.read_backtest(PID, btid).get("backtest") or {}
        if d.get("completed") or d.get("error"):
            break
        time.sleep(20)
    st = d.get("statistics") or {}
    print(json.dumps({k: st.get(k) for k in KEYS}, indent=1))
    print("runtime:", d.get("runtimeStatistics"))
    print("window:", d.get("backtestStart"), "->", d.get("backtestEnd"))
    print("error:", d.get("error"))
    return btid


def publish(btid: str, name: str, description: str, notes: str) -> None:
    body = {"projectId": PID, "backtestId": btid, "name": name, "description": description,
            "versionNotes": notes}
    r = qc_api.call("/strategies/create", body)
    print(json.dumps(r, indent=1)[:2000])


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("cmd", choices=["backtest", "publish"])
    ap.add_argument("backtest_id", nargs="?", default="")
    ap.add_argument("--name", default="Q25 crypto top-10 submission")
    ap.add_argument("--description", default="")
    ap.add_argument("--notes", default="Initial version.")
    a = ap.parse_args()
    if a.cmd == "backtest":
        backtest(a.name)
    else:
        publish(a.backtest_id, a.name, a.description, a.notes)
"""
Q25 Crypto Top-10 — the three Quantiacs entries as pure weight functions.

Each function maps (close panel over the liquid universe, strategy state) to the
RAW target weights for the current day, mirroring the xarray Quantiacs code
line by line.  All indicators are causal; parameters are the a-priori horizons
of the submitted entries (no re-tuning on QuantConnect).

  E1  risk-adjusted momentum   — strategy_dev.py            (live entry 1)
  E2  vol-targeted low-vol RP  — strategy_entry2_final.py   (live entry 2)
  E3  dual momentum            — strategy_candidates.C4     (entry 3 candidate)

Common tail (applied by the algorithm, not here): WMAX cap, 3-day smoothing,
long-only, liquid-only.
"""
from __future__ import annotations

import numpy as np
import pandas as pd

EPS = 1e-6
A = 365


def _ema_last(panel: pd.DataFrame, span: int) -> pd.Series:
    return panel.ewm(span=span, adjust=False).mean().iloc[-1]


# ---------------------------------------------------------------------------
# E1 — risk-adjusted momentum, EMA-100 gate, breadth-to-cash
# ---------------------------------------------------------------------------
E1_PARAMS = {"MOM_LB": 30, "VOL_LB": 30, "TREND_LB": 100}


def e1_risk_adjusted_momentum(close: pd.DataFrame, state: dict) -> pd.Series:
    p = E1_PARAMS
    ret = close.pct_change(fill_method=None)
    vol = ret.rolling(p["VOL_LB"]).std().iloc[-1]
    mom = close.pct_change(p["MOM_LB"], fill_method=None).iloc[-1] * 100.0          # qta.roc (%)
    up = close.iloc[-1] > _ema_last(close, p["TREND_LB"])
    score = mom.clip(lower=0.0) / (vol + EPS)
    score = (score * up.astype(float)).replace([np.inf, -np.inf], 0.0).fillna(0.0)
    n_liquid = close.shape[1]
    n_sel = int((score > 0).sum())
    breadth = n_sel / n_liquid if n_liquid else 0.0
    s_sum = float(score.sum())
    return (score / s_sum) * breadth if s_sum > 0 else score * 0.0


# ---------------------------------------------------------------------------
# E2 — inverse-vol risk parity, EMA-20 screen, book vol-targeted to 40 %
#      state["book_var"] = EWMA(20) of the squared causal basket return
#      state["w_rel_prev"] = yesterday's basket weights (for today's basket ret)
# ---------------------------------------------------------------------------
E2_PARAMS = {"VOL_LB": 30, "FAST_LB": 20, "BOOKVOL_LB": 20, "TARGET_ANN": 0.40}


def e2_vol_targeted_low_vol(close: pd.DataFrame, state: dict) -> pd.Series:
    p = E2_PARAMS
    ret = close.pct_change(fill_method=None)
    vol = ret.rolling(p["VOL_LB"]).std().iloc[-1]
    sel = (close.iloc[-1] > _ema_last(close, p["FAST_LB"])).astype(float)
    inv = (1.0 / (vol + EPS)) * sel
    inv = inv.replace([np.inf, -np.inf], 0.0).fillna(0.0)
    s = float(inv.sum())
    w_rel = inv / s if s > 0 else inv * 0.0

    # causal basket return: yesterday's basket weights x today's returns
    w_prev = state.get("w_rel_prev", pd.Series(dtype=float))
    today = ret.iloc[-1].fillna(0.0)
    r_b = float((w_prev.reindex(today.index).fillna(0.0) * today).sum())
    alpha = 2.0 / (p["BOOKVOL_LB"] + 1.0)                          # qta.ema
    book_var = state.get("book_var")
    book_var = r_b * r_b if book_var is None else (1 - alpha) * book_var + alpha * r_b * r_b
    state["book_var"], state["w_rel_prev"] = book_var, w_rel

    book_vol = np.sqrt(max(book_var, 0.0))
    gross = float(np.clip((p["TARGET_ANN"] / np.sqrt(A)) / (book_vol + EPS), 0.0, 1.0))
    breadth = float(sel.sum()) / close.shape[1] if close.shape[1] else 0.0
    return w_rel * gross * (0.5 + 0.5 * breadth)


# ---------------------------------------------------------------------------
# E3 — dual momentum: absolute 120d momentum, equal weight, market EMA-100 timer
#      state["mkt_ema"] = EMA(100) of the equal-price market index (qnt: mean close)
# ---------------------------------------------------------------------------
E3_PARAMS = {"ABS_LB": 120, "MKT_LB": 100}


def e3_dual_momentum(close: pd.DataFrame, state: dict) -> pd.Series:
    p = E3_PARAMS
    abs_up = (close.pct_change(p["ABS_LB"], fill_method=None).iloc[-1] > 0).astype(float).fillna(0.0)
    n_sel = float(abs_up.sum())
    w_rel = abs_up / n_sel if n_sel > 0 else abs_up * 0.0

    mkt = float(close.iloc[-1].mean())                              # qnt: mean close over liquid
    alpha = 2.0 / (p["MKT_LB"] + 1.0)
    ema = state.get("mkt_ema")
    ema = mkt if ema is None else (1 - alpha) * ema + alpha * mkt
    state["mkt_ema"] = ema
    return w_rel * (1.0 if mkt > ema else 0.0)


STRATEGIES = {
    "e1": ("E1 risk-adjusted momentum", e1_risk_adjusted_momentum, 100 + 30),
    "e2": ("E2 vol-targeted low-vol risk parity", e2_vol_targeted_low_vol, 30 + 20),
    "e3": ("E3 dual momentum", e3_dual_momentum, 120),
}