Overall Statistics
Total Orders
949
Average Win
1.08%
Average Loss
-0.78%
Compounding Annual Return
52.698%
Drawdown
22.900%
Expectancy
0.579
Start Equity
100000
End Equity
830171.61
Net Profit
730.172%
Sharpe Ratio
1.5
Sortino Ratio
2.095
Probabilistic Sharpe Ratio
89.336%
Loss Rate
34%
Win Rate
66%
Profit-Loss Ratio
1.38
Alpha
0.273
Beta
0.696
Annual Standard Deviation
0.22
Annual Variance
0.048
Information Ratio
1.329
Tracking Error
0.186
Treynor Ratio
0.475
Total Fees
$7346.66
Estimated Strategy Capacity
$63000000.00
Lowest Capacity Asset
BSV TRO5ZARLX6JP
Portfolio Turnover
17.25%
Drawdown Recovery
175
# =============================================================================
# kinfo ENSEMBLE — CLEAN canonical (Variant A), FRESH-VERIFIED 2026-06-15.
# Decision: history() in on_data already returns through the most-recent daily close (T) -> decide
#   on that close. NO securities.close append (the old "TODAYCLOSE/B" append was proven 2026-06-15
#   to DUPLICATE that same close at a phantom date and pollute rolling windows; disproven artifact).
# Fill: MarketOnOpenOrder -> T+1 OPEN (type-4). Order tags stamp decClose+qqqDecC+now for forensics.
# Debug-tag proof (proj 32615400): A=aeab57c1 vs B=1e32d1b4 had IDENTICAL qqqDecC every order =>
#   no freshness difference; fillprice = next open != decision close => 0 look-ahead, 0 invalid.
# Fresh scoring: IS 2010-2020 (effective 2013-2020, TQQQ-warmup) = 18.14/38.8/0.771 (809e305d);
#   OOS 2021-2025 = 51.65/22.9/1.498 (abdf530b). Gross<=100% (W_0379 0.70 + W_0273 0.30 = 1.0).
# GENUINE T-1 (LAG_DECISION=1) tested & REJECTED: IS 19.05/27.9/0.827 (31038cb3) looked better but
#   OOS 32.45/19.9/0.96 (58da4103) gave up ~19pp CAGR + 0.5 Sharpe vs T. Decide on T = canonical.
# =============================================================================
from AlgorithmImports import *
import numpy as np
import pandas as pd

# =============================================================================
# kinfo ENSEMBLE — 0379 (defense) + 0273 (offense), one daily algorithm
# Applies kinfo's #1 lesson (uncorrelated legs beat singles) to the two engines
# that VERIFIED on QC:
#   * 0379 symphony : DD-controlled multi-asset rotation (TLT/GLD/SHV + crash gate)
#                     -> OOS 32.6%/21.8%DD/Sh0.94, full 20.4/29.3/0.77
#   * 0273          : aggressive leveraged-Nasdaq regime rotator (QQQ/TQQQ/SQQQ,
#                     5-cond bear gate) -> kinfo full 33.6%/42.5%/Sh1.05
# Each sleeve is gross<=100%; blended at W_0379 / W_0273 (sum=1) so the combined
# book never borrows -> cash-faithful. QC-default margin (faithful proxy; the
# cash-mode MOO rotation artifact is avoided, see champ_lev_regime notes).
#
# IS = 2010-2020 | OOS = 2021-2025 | default full. Tune W_0379 / W_0273 by results.
# =============================================================================

class KinfoEnsemble(QCAlgorithm):

    # blend weights (sum to 1.0) — 70/30 chosen: best DD (25.2% full) + Sharpe 1.32/1.63, monotone in-sample
    W_0379 = 0.70
    W_0273 = 0.30

    USE_CASH = False   # False = margin proxy (gross<=100%, no borrowing). True = IBKR cash + 10bps + sells-before-buys
    REBAL_TOL = 0.08   # rebalance only when held names change or a weight moves >= this (cuts turnover/fees)

    # ---- FRESH-VERIFY 2026-06-15 run controls (set per backtest) ----
    VARIANT = "A"        # A = CLEAN no-append (decide on history's most-recent daily close = T, fill T+1 open).
                         # B = append securities.close: proven 2026-06-15 to DUPLICATE that same close (no
                         # freshness) and pollute rolling windows -> disproven artifact. Canonical = A.
    WINDOW = "OOS"     # "IS"=2010-2020 | "OOS"=2021-2025 | "DEBUG"=short window for fill proof
    DEBUG_FILLS = False   # log every fill (price vs that-bar open/close) + per-decision close date
    LAG_DECISION = 0      # 0 = decide on most-recent close (T, canonical A). 1 = GENUINE T-1: drop the
                          # last close row so the decision uses the prior session (one bar staler), still
                          # fills T+1 open. This is a REAL staleness lag (unlike the disproven B append).

    T_0379 = ["QQQ","SPY","TLT","BSV","GLD","QLD","PSQ","SHV","IEF","QID","SMH","USD"]
    T_0273 = ["TQQQ","SQQQ"]              # QQQ shared with 0379 set; ONEQ + VIX added below
    HISTORY_BARS = 1500                   # 0273 needs ~6y for bear-streak; 0379 uses last 300

    # 0273 params
    STREAK_MIN = 40
    DIST_THRESHOLD = -0.08
    BOUNCE_ENTRY = 0.045
    MAX_SQQQ = 0.90

    def initialize(self):
        if self.WINDOW == "IS":
            self.set_start_date(2010, 1, 1); self.set_end_date(2020, 12, 31)
        elif self.WINDOW == "OOS":
            self.set_start_date(2021, 6, 28); self.set_end_date(2026, 6, 30)
        else:  # DEBUG: short window, enough warmup to trade, small order log
            self.set_start_date(2014, 11, 1); self.set_end_date(2015, 2, 1)
        self.set_cash(100000)
        if self.USE_CASH:
            self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.CASH)
            self.settings.minimum_order_margin_portfolio_percentage = 0.0

        self.syms = {}
        for t in self.T_0379 + self.T_0273:
            if t in self.syms:
                continue
            eq = self.add_equity(t, Resolution.DAILY)
            if self.USE_CASH:
                eq.set_fee_model(InteractiveBrokersFeeModel())
                eq.set_slippage_model(ConstantSlippageModel(0.001))
                eq.set_settlement_model(ImmediateSettlementModel())
            self.syms[t] = eq.symbol
        self.ixic = self.add_equity("ONEQ", Resolution.DAILY).symbol   # 0273 IXIC proxy
        try:
            self.vix = self.add_data(CBOE, "VIX", Resolution.DAILY).symbol
            self._has_vix = True
        except Exception:
            self.vix = None
            self._has_vix = False

        self.set_benchmark("QQQ")
        self.set_warm_up(self.HISTORY_BARS, Resolution.DAILY)
        self._last_key = None
        self._pending = None   # FILL-FIX: queued weights, drained at next open via MOO
        self._dbg = self.DEBUG_FILLS  # per-decision close-date log when debugging
        self._max_fill_vs_open = 0.0   # fill-forensics accumulators (surfaced as runtime stats)
        self._max_fill_vs_close = 0.0
        self._n_fills = 0

    # ---------- shared helpers ----------
    @staticmethod
    def _rsi_wilder(px, window):
        delta = px.diff()
        gain = delta.clip(lower=0.0)
        loss = (-delta).clip(lower=0.0)
        ag = gain.ewm(alpha=1.0/window, adjust=False).mean()
        al = loss.ewm(alpha=1.0/window, adjust=False).mean()
        rs = ag / al.replace(0.0, np.nan)
        return (100.0 - 100.0/(1.0+rs)).fillna(50.0)

    @staticmethod
    def _rsi_sma_seed(series, period):
        delta = series.diff()
        gain = delta.clip(lower=0.0).to_numpy()
        loss = (-delta.clip(upper=0.0)).to_numpy()
        n = len(series); ag = np.full(n, np.nan); al = np.full(n, np.nan)
        if n > period:
            ag[period] = np.nanmean(gain[1:period+1]); al[period] = np.nanmean(loss[1:period+1])
            ip = 1.0/period
            for i in range(period+1, n):
                ag[i] = ag[i-1]*(1-ip) + gain[i]*ip
                al[i] = al[i-1]*(1-ip) + loss[i]*ip
        rs = np.where(al > 0, ag/np.where(al == 0, np.nan, al), np.nan)
        return pd.Series(100.0 - 100.0/(1.0+rs), index=series.index).fillna(50.0)

    @staticmethod
    def _top(tickers, rsi10):
        best = tickers[0]; bv = rsi10[best]
        for t in tickers[1:]:
            if rsi10[t] > bv: best, bv = t, rsi10[t]
        return best

    # ---------- 0379 sleeve ----------
    def _decide_0379(self, close):
        rsi_assets = ["QQQ","SPY","TLT","BSV","GLD","PSQ","IEF","QLD","USD","SMH"]
        rsi10 = {t: float(self._rsi_sma_seed(close[t], 10).iloc[-1]) for t in rsi_assets}
        rsi60_spy = float(self._rsi_sma_seed(close["SPY"], 60).iloc[-1])
        spy = close["SPY"]; qqq = close["QQQ"]
        spy_ma200 = float(spy.rolling(200).mean().iloc[-1])
        spy_ma20  = float(spy.rolling(20).mean().iloc[-1])
        qqq_ma20  = float(qqq.rolling(20).mean().iloc[-1])
        spy_l = float(spy.iloc[-1]); qqq_l = float(qqq.iloc[-1])
        cr10  = float((qqq.iloc[-1]/qqq.iloc[-11]-1.0)*100.0)
        cr60  = float((qqq.iloc[-1]/qqq.iloc[-61]-1.0)*100.0)
        cr252 = float((qqq.iloc[-1]/qqq.iloc[-253]-1.0)*100.0)
        w = {}
        def add(t, x): w[t] = w.get(t, 0.0) + x
        if spy_l > spy_ma200:
            if rsi10["QQQ"] > 80.0: add("TLT", 1.0)
            elif rsi10["SPY"] > 80.0: add("TLT", 1.0)
            elif rsi60_spy > 60.0: add(self._top(["TLT","BSV","GLD"], rsi10), 1.0)
            else: add("QQQ", 1.0)
        else:
            if rsi10["QQQ"] < 30.0:
                add("QLD", 1.0)
            else:
                tlt_gt_psq = rsi10["TLT"] > rsi10["PSQ"]
                qqq_below = qqq_l < qqq_ma20
                if qqq_l > qqq_ma20:
                    if rsi10["PSQ"] < 30.0: add("PSQ", 0.50)
                    elif cr10 > 5.5: add("SHV", 0.50)
                    else: add("QQQ", 0.50)
                else:
                    add(self._top(["IEF","PSQ"], rsi10), 0.50)
                if cr252 < -20.0:
                    if qqq_below:
                        if cr60 <= -12.0:
                            sib1 = "SPY" if spy_l > spy_ma20 else ("QQQ" if tlt_gt_psq else "PSQ")
                            sib2 = "QQQ" if tlt_gt_psq else "PSQ"
                            add(sib1, 0.25); add(sib2, 0.25)
                        else:
                            add("QLD" if tlt_gt_psq else "QID", 0.50)
                    else:
                        if rsi10["PSQ"] < 31.0: add("PSQ", 0.50)
                        elif cr10 > 5.5: add("PSQ", 0.50)
                        else: add(self._top(["QQQ","SMH"], rsi10), 0.50)
                else:
                    if qqq_below:
                        add("QLD" if tlt_gt_psq else "QID", 0.50)
                    else:
                        if rsi10["PSQ"] < 31.0: add("QID", 0.50)
                        elif cr10 > 5.5: add("QID", 0.50)
                        else: add(self._top(["QLD","USD"], rsi10), 0.50)
        return w

    # ---------- 0273 sleeve ----------
    def _decide_0273(self, df, vix_series):
        df = df.copy()
        df["qqq_sma_50"] = df["QQQ"].rolling(50).mean()
        df["qqq_sma_200"] = df["QQQ"].rolling(200).mean()
        df["qqq_dist_sma_50"] = (df["QQQ"] - df["qqq_sma_50"]) / df["qqq_sma_50"]
        df["qqq_dist_sma_200"] = (df["QQQ"] - df["qqq_sma_200"]) / df["qqq_sma_200"]
        df["qqq_sma_200_slope_20"] = (df["qqq_sma_200"] - df["qqq_sma_200"].shift(20)) / df["qqq_sma_200"].shift(20)
        df["qqq_roc_21"] = df["QQQ"].pct_change(21)
        df["ixic_sma_50"] = df["IXIC"].rolling(50).mean()
        df["ixic_sma_100"] = df["IXIC"].rolling(100).mean()
        df["ixic_sma_175"] = df["IXIC"].rolling(175).mean()
        df["ixic_sma_200"] = df["IXIC"].rolling(200).mean()
        df["ixic_sma_200_dist"] = (df["IXIC"] - df["ixic_sma_200"]) / df["ixic_sma_200"]
        df["ixic_above_sma_50"] = (df["IXIC"] > df["ixic_sma_50"]).astype(float)
        df["ixic_above_sma_100"] = (df["IXIC"] > df["ixic_sma_100"]).astype(float)
        df["ixic_above_sma_175"] = (df["IXIC"] > df["ixic_sma_175"]).astype(float)
        df["qqq_rsi_14"] = self._rsi_wilder(df["QQQ"], 14)
        df["tqqq_vol20"] = df["TQQQ"].pct_change().rolling(20).std() * np.sqrt(252)
        if vix_series is not None and not vix_series.empty:
            v = vix_series.reindex(df.index).ffill()
            df["vix_ratio_20"] = (v / v.rolling(20).mean()).fillna(1.0)
        else:
            df["vix_ratio_20"] = 1.0

        feat = df.iloc[-1]   # FIX: today (T) close; fill T+1 open
        req = ["qqq_dist_sma_50","qqq_dist_sma_200","qqq_sma_200_slope_20","qqq_roc_21",
               "tqqq_vol20","qqq_rsi_14","ixic_sma_200_dist","ixic_above_sma_50",
               "ixic_above_sma_100","ixic_above_sma_175","vix_ratio_20"]
        if feat[req].isna().any():
            return {}

        above175 = bool(feat["ixic_above_sma_175"] > 0.5)
        above100 = bool(feat["ixic_above_sma_100"] > 0.5)
        above50 = bool(feat["ixic_above_sma_50"] > 0.5)
        rsi14 = float(feat["qqq_rsi_14"]); roc21 = float(feat["qqq_roc_21"])
        dist_200_ixic = float(feat["ixic_sma_200_dist"]); vix_ratio = float(feat["vix_ratio_20"])
        strong_bull = above175 and above50 and (rsi14 > 55.0) and (roc21 > 0.0)
        full_momentum = (rsi14 > 60.0) and (roc21 > 0.0)
        regime = 4
        if above100: regime = 3
        if above175 and (not strong_bull): regime = 2
        if above175 and strong_bull and (not full_momentum): regime = 5
        if above175 and strong_bull and full_momentum: regime = 1
        vix_scale = 1.0
        if vix_ratio >= 2.2: vix_scale = 0.38
        elif vix_ratio >= 1.5: vix_scale = 0.62
        elif vix_ratio >= 1.1: vix_scale = 0.88
        w_qqq, w_tqqq, w_sqqq = 0.0, 0.0, 0.0
        very_extended = (regime == 1) and (dist_200_ixic > 0.15)
        normal_bull = (regime == 1) and (not very_extended)
        if very_extended: w_tqqq = float(np.clip(0.84*vix_scale, 0.35, 0.84))
        elif normal_bull: w_tqqq = float(np.clip(0.90*vix_scale, 0.35, 0.90))
        elif regime == 5: w_tqqq = float(np.clip(0.60*vix_scale, 0.28, 0.60))
        elif regime == 2: w_tqqq = float(np.clip(0.70*vix_scale, 0.28, 0.70))
        elif regime == 4: w_qqq = 0.45
        elif regime == 3:
            w_tqqq = 0.62 if rsi14 > 55 else (0.48 if rsi14 > 45 else 0.34)
        t_vol = float(feat["tqqq_vol20"])
        if above175 and (t_vol < 0.60): w_tqqq = min(0.95, w_tqqq*1.05)
        if above175 and (t_vol >= 0.80): w_tqqq = min(0.95, w_tqqq*0.75)
        if very_extended: w_tqqq = min(0.95, w_tqqq*0.92)

        now = df.iloc[-1]
        dist50 = float(now["qqq_dist_sma_50"]); dist200 = float(now["qqq_dist_sma_200"])
        slope200 = float(now["qqq_sma_200_slope_20"])
        if any(np.isnan([dist50, dist200, slope200])):
            return {}
        below_200 = (df["qqq_dist_sma_200"] < 0).astype(int).values
        streak = 0
        for b in below_200[:-1][::-1]:
            if b == 1: streak += 1
            else: break
        bear_gate = (streak >= self.STREAK_MIN) and (dist200 < dist50) and (dist200 < self.DIST_THRESHOLD) and (slope200 < 0.0)
        roc3 = float(df["QQQ"].pct_change(3).shift(1).iloc[-1])
        if bear_gate and (roc3 >= self.BOUNCE_ENTRY):
            w_qqq, w_tqqq, w_sqqq = 0.0, 0.0, self.MAX_SQQQ
        gross = w_qqq + w_tqqq + w_sqqq
        if gross > 1.0:
            w_qqq /= gross; w_tqqq /= gross; w_sqqq /= gross
        return {"QQQ": w_qqq, "TQQQ": w_tqqq, "SQQQ": w_sqqq}

    # ---------- combined rebalance ----------
    def rebalance(self):
        if self.is_warming_up:
            return
        all_syms = [self.syms[t] for t in self.syms] + [self.ixic]
        hist = self.history(all_syms, self.HISTORY_BARS, Resolution.DAILY)
        if hist is None or hist.empty or "close" not in hist.columns:
            return
        try:
            wide = hist["close"].unstack(level=0)
        except Exception:
            return
        col = {}
        for t in self.syms:
            s = self.syms[t]
            if s in wide.columns: col[t] = wide[s]
        if self.ixic in wide.columns: col["IXIC"] = wide[self.ixic]
        if any(t not in col for t in self.T_0379) or "IXIC" not in col:
            return
        close = pd.DataFrame(col).dropna()
        # QC: history() in on_data EXCLUDES the current bar -> `close` ends at T-1.
        # VARIANT B = decide on T close: append TODAY's (T) close from the just-delivered daily bar.
        # VARIANT A = decide on T-1 close: skip the append entirely (leaves `close` ending at T-1).
        # BOTH then place MOO -> fill at T+1 open (look-ahead-free either way).
        if self.VARIANT == "B":
            row = {}
            for t in self.syms:
                s = self.syms[t]
                if self.securities.contains_key(s) and float(self.securities[s].close) > 0:
                    row[t] = float(self.securities[s].close)
            if self.securities.contains_key(self.ixic) and float(self.securities[self.ixic].close) > 0:
                row["IXIC"] = float(self.securities[self.ixic].close)
            if all(t in row for t in self.T_0379) and "IXIC" in row:
                close.loc[self.time] = pd.Series(row)
        # GENUINE T-1 test: drop the most-recent close(s) so the decision uses the prior session.
        # A REAL staleness lag (mirrors a pre-close rebalance that can't see T yet), not the B no-op.
        if self.LAG_DECISION > 0 and len(close) > self.LAG_DECISION:
            close = close.iloc[:-self.LAG_DECISION]
        if getattr(self, "_dbg", False):
            self.debug(f"DECIDE now={self.time} last_close_date={close.index[-1]} qqq={float(close['QQQ'].iloc[-1]):.2f}")
        if len(close) < 260:
            return

        # vix series
        vix_series = None
        if self._has_vix and self.vix is not None:
            try:
                vh = self.history(self.vix, self.HISTORY_BARS, Resolution.DAILY)
                if vh is not None and not vh.empty:
                    s = vh["value"] if "value" in vh.columns else (vh["close"] if "close" in vh.columns else None)
                    if s is not None:
                        if isinstance(s.index, pd.MultiIndex):
                            s.index = s.index.get_level_values(-1)
                        s.index = pd.to_datetime(s.index).tz_localize(None)
                        vix_series = s[~pd.Index(s.index).duplicated(keep="last")].sort_index().astype(float)
            except Exception:
                vix_series = None

        w379 = self._decide_0379(close[self.T_0379])
        df0273 = close[["QQQ","TQQQ","SQQQ","IXIC"]]
        if vix_series is not None:
            df0273.index = close.index
        w273 = self._decide_0273(df0273, vix_series)

        target = {}
        for t, x in w379.items():
            target[t] = target.get(t, 0.0) + self.W_0379 * x
        for t, x in w273.items():
            if x > 0:
                target[t] = target.get(t, 0.0) + self.W_0273 * x

        target = {t: round(w, 3) for t, w in target.items() if w > 0.005}
        # rebalance-on-change with tolerance band (cuts turnover/fees on cash):
        # only trade if the held names change OR some weight moves >= REBAL_TOL.
        if self._last_key is not None:
            same_names = set(target) == set(self._last_key)
            moves = [abs(target.get(t, 0.0) - self._last_key.get(t, 0.0))
                     for t in set(target) | set(self._last_key)]
            if same_names and (max(moves) if moves else 0.0) < self.REBAL_TOL:
                return
        self._last_key = target

        # DECISION-FRESHNESS STAMP: tag every order with the DATE of the last close row the decision
        # used + that QQQ close + today. Variant B => dec_date == now (T); Variant A => dec_date == T-1.
        # Visible in read_backtest_orders, so order forensics prove decision-freshness AND fill-timing.
        dtag = (f"V={self.VARIANT} decClose={pd.Timestamp(close.index[-1]).date()} "
                f"qqqDecC={float(close['QQQ'].iloc[-1]):.2f} now={self.time.date()}")

        # FILL-CORRECT: decided on data THROUGH TODAY (T close appended above). Place MOO ->
        # fills T+1 open. Fresh + realistic + look-ahead-free (QC_PATTERNS.md).
        keep = set(self.syms[t] for t in target)
        for kvp in list(self.portfolio):
            sym, h = kvp.key, kvp.value
            if h.invested and sym not in keep and sym != self.ixic and h.quantity != 0:
                self.market_on_open_order(sym, -h.quantity, tag=dtag)
        equity = self.portfolio.total_portfolio_value
        def cur_w(t):
            h = self.portfolio[self.syms[t]]
            return (float(h.holdings_value) / equity) if equity > 0 else 0.0
        for t, w in sorted(target.items(), key=lambda kv: kv[1] - cur_w(kv[0])):
            sym = self.syms[t]
            price = float(self.securities[sym].price)
            if price <= 0:
                continue
            target_qty = int(equity * 0.98 * w / price)
            cur = self.portfolio[sym].quantity if self.portfolio.contains_key(sym) else 0
            delta = target_qty - cur
            if delta != 0:
                self.market_on_open_order(sym, delta, tag=dtag)

    def on_data(self, data):
        if self.is_warming_up:
            return
        self.rebalance()

    def on_order_event(self, oe):
        # FILL FORENSICS: prove decide-on-close -> fill at NEXT open. At fill time self.time is the
        # T+1 bar; fillpx should equal bar_open (the MOO auction price), NOT any close the signal saw.
        if not self.DEBUG_FILLS:
            return
        if oe.status != OrderStatus.FILLED or oe.fill_quantity == 0:
            return
        sec = self.securities[oe.symbol]
        side = "BUY" if oe.fill_quantity > 0 else "SELL"
        self.debug(f"FILL {self.time} {oe.symbol.value} {side} "
                   f"fillpx={oe.fill_price:.4f} bar_open={sec.open:.4f} bar_close={sec.close:.4f}")
        # MCP-READABLE PROOF: max |fill - that-bar-open| should be ~0 (fills AT the open);
        # max |fill - that-bar-close| should be LARGE (fills are NOT at the close). Surfaced as
        # runtime statistics so read_backtest can verify without needing the cloud Debug log.
        fp = float(oe.fill_price); o = float(sec.open); c = float(sec.close)
        if o > 0:
            self._max_fill_vs_open = max(self._max_fill_vs_open, abs(fp - o) / o)
        if c > 0:
            self._max_fill_vs_close = max(self._max_fill_vs_close, abs(fp - c) / c)
        self._n_fills += 1
        self.set_runtime_statistic("Fills", str(self._n_fills))
        self.set_runtime_statistic("MaxFillVsOpen_pct", f"{self._max_fill_vs_open*100:.4f}")
        self.set_runtime_statistic("MaxFillVsClose_pct", f"{self._max_fill_vs_close*100:.4f}")