Overall Statistics
Total Orders
7931
Average Win
0.51%
Average Loss
-0.29%
Compounding Annual Return
234.879%
Drawdown
16.500%
Expectancy
0.561
Start Equity
10000
End Equity
4882834.44
Net Profit
48728.344%
Sharpe Ratio
4.116
Sortino Ratio
6.282
Probabilistic Sharpe Ratio
100.000%
Loss Rate
43%
Win Rate
57%
Profit-Loss Ratio
1.73
Alpha
1.321
Beta
1.12
Annual Standard Deviation
0.338
Annual Variance
0.114
Information Ratio
4.447
Tracking Error
0.299
Treynor Ratio
1.241
Total Fees
$56903.47
Estimated Strategy Capacity
$28000000.00
Lowest Capacity Asset
TYP U8JOSZGR4OKL
Portfolio Turnover
37.51%
Drawdown Recovery
106
from AlgorithmImports import *
from tree_data import TREE_JSON
import json
import numpy as np


class BullishGuardEnsemble(QCAlgorithm):

    PRICE_WINDOW_SIZE = 400  # covers the largest lookback likely used (360) + buffer

    def initialize(self) -> None:
        self.set_start_date(2021, 7, 1)
        self.set_cash(10_000)
        self.set_brokerage_model(
            BrokerageName.INTERACTIVE_BROKERS_BROKERAGE,
            AccountType.MARGIN,
        )
        self.set_benchmark("SPY")
        self.settings.minimum_order_margin_portfolio_percentage = 0.0

        self.tree = json.loads(TREE_JSON)

        # -- Discover every ticker referenced (as a tradable asset or as an
        #    indicator input) and every (ticker, RSI window) pair needed.
        all_tickers = set()
        self._collect_tickers(self.tree, all_tickers)

        rsi_pairs = set()
        self._collect_rsi_pairs(self.tree, rsi_pairs)

        # -- Subscribe & build per-ticker daily price history + RSI indicators
        self._syms = {}
        self._price_window = {}
        for t in sorted(all_tickers):
            try:
                sym = self.add_equity(t, Resolution.DAILY).symbol
            except Exception as e:
                self.log(f"WARNING: could not subscribe {t}: {e}")
                continue
            self._syms[t] = sym
            self._price_window[t] = RollingWindow[float](self.PRICE_WINDOW_SIZE)

        self._rsi = {}
        for ticker, window in rsi_pairs:
            if ticker not in self._syms:
                continue
            self._rsi[(ticker, window)] = self.rsi(
                self._syms[ticker], window, MovingAverageType.WILDERS, Resolution.DAILY
            )

        self.set_warm_up(self.PRICE_WINDOW_SIZE + 5, Resolution.DAILY)

        # -- Seed price windows from history so day-0 evaluation has data
        for t, sym in self._syms.items():
            hist = self.history(sym, self.PRICE_WINDOW_SIZE + 5, Resolution.DAILY)
            if not hist.empty:
                closes = hist["close"].values
                for c in closes[::-1]:  # oldest first push, so window[0] ends most recent
                    if c > 0:
                        self._price_window[t].add(float(c))

        self._trade_count = 0
        self._ticker_of_sym = {sym: t for t, sym in self._syms.items()}

        # -- Feed daily closes into the rolling windows right after each
        #    session close, then compute the rebalance target and place
        #    MarketOnOpenOrders for the next session's open.
        self.schedule.on(
            self.date_rules.every_day(),
            self.time_rules.after_market_close("SPY", 0),
            self._update_price_windows,
        )
        self.schedule.on(
            self.date_rules.every_day(),
            self.time_rules.after_market_close("SPY", 1),
            self._rebalance,
        )

    # ── Tree-requirement discovery ──────────────────────────────────

    def _collect_tickers(self, node, out: set) -> None:
        if node.get("t") == "node_asset":
            out.add(node["tk"])
        for expr_key in ("cond", "sf"):
            if expr_key in node:
                self._scan_expr_tickers(node[expr_key], out)
        for key in ("c", "th", "el"):
            for ch in node.get(key, []):
                self._collect_tickers(ch, out)

    def _scan_expr_tickers(self, expr, out: set) -> None:
        if isinstance(expr, list):
            for item in expr:
                self._scan_expr_tickers(item, out)
        elif isinstance(expr, str) and expr.startswith("EQUITIES::"):
            out.add(expr.split("::")[1].split("//")[0])

    def _collect_rsi_pairs(self, node, out: set) -> None:
        for expr_key in ("cond",):
            if expr_key in node:
                self._scan_expr_rsi(node[expr_key], None, out)
        if node.get("t") == "node_filter":
            rsi_window = self._rsi_window_in_expr(node["sf"])
            if rsi_window is not None:
                for ch in node["c"]:
                    rep = self._representative_ticker(ch)
                    if rep:
                        out.add((rep, rsi_window))
        for key in ("c", "th", "el"):
            for ch in node.get(key, []):
                self._collect_rsi_pairs(ch, out)

    def _scan_expr_rsi(self, expr, ref_ticker, out: set) -> None:
        if not isinstance(expr, list):
            return
        if expr[0] == "fn_relative_strength_index":
            tk = self._ticker_of(expr[1], ref_ticker)
            if tk:
                out.add((tk, expr[2]))
        for item in expr:
            self._scan_expr_rsi(item, ref_ticker, out)

    def _rsi_window_in_expr(self, expr):
        if not isinstance(expr, list):
            return None
        if expr[0] == "fn_relative_strength_index":
            return expr[2]
        for item in expr:
            r = self._rsi_window_in_expr(item)
            if r is not None:
                return r
        return None

    def _ticker_of(self, metric_close_expr, ref_ticker):
        return self._extract_ticker(metric_close_expr, ref_ticker)

    def _extract_ticker(self, arg, ref_ticker):
        """Extract a ticker from an arg that may be a raw "EQUITIES::X//USD"
        string, a ["reference","%"] filter/weight placeholder, or a
        ["metric_close", <arg>] wrapper (recursed into) -- different source
        trees use these forms inconsistently, so handle all of them."""
        if isinstance(arg, str) and arg.startswith("EQUITIES::"):
            return arg.split("::")[1].split("//")[0]
        if isinstance(arg, list) and arg:
            if arg[0] == "reference":
                return ref_ticker
            if arg[0] == "metric_close":
                return self._extract_ticker(arg[1], ref_ticker)
        return None

    def _representative_ticker(self, node):
        t = node.get("t")
        if t == "node_asset":
            return node["tk"]
        if t == "node_if" and "cond" in node:
            tk = self._first_ticker_in_expr(node["cond"])
            if tk:
                return tk
        for key in ("c", "th", "el"):
            for ch in node.get(key, []):
                tk = self._representative_ticker(ch)
                if tk:
                    return tk
        return None

    def _first_ticker_in_expr(self, expr):
        if isinstance(expr, list):
            for item in expr:
                if isinstance(item, str) and item.startswith("EQUITIES::"):
                    return item.split("::")[1].split("//")[0]
                tk = self._first_ticker_in_expr(item)
                if tk:
                    return tk
        return None

    # ── Metric / condition evaluation ───────────────────────────────

    def _eval_metric(self, expr, ref_ticker=None) -> float:
        head = expr[0]
        if head == "fn_constant":
            return expr[1]
        if head == "weight_every_fn":
            return self._eval_metric(expr[1], ref_ticker)
        if head == "metric_close":
            tk = self._ticker_of(expr, ref_ticker)
            w = self._price_window.get(tk)
            return w[0] if w and w.is_ready else 0.0
        if head == "fn_relative_strength_index":
            # QC's RSI indicator is 0-100 scale; the source tree's thresholds
            # (fn_constant values like 0.79, 0.3) are on a 0-1 scale, so divide.
            tk = self._ticker_of(expr[1], ref_ticker)
            window = expr[2]
            ind = self._rsi.get((tk, window))
            return (ind.current.value / 100.0) if ind and ind.is_ready else 0.5
        if head == "fn_simple_moving_average":
            inner, window = expr[1], expr[2]
            if inner[0] == "metric_close":
                tk = self._ticker_of(inner, ref_ticker)
                w = self._price_window.get(tk)
                if not w or not w.is_ready or w.count < window:
                    return 0.0
                return float(np.mean([w[i] for i in range(window)]))
            elif inner[0] == "fn_rate_of_return":
                tk = self._ticker_of(inner[1], ref_ticker)
                w = self._price_window.get(tk)
                if not w or not w.is_ready or w.count < window + 1:
                    return 0.0
                rets = [w[i] / w[i + 1] - 1.0 for i in range(window)]
                return float(np.mean(rets))
        if head == "fn_exponential_moving_average":
            inner, window = expr[1], expr[2]
            tk = self._ticker_of(inner, ref_ticker)
            w = self._price_window.get(tk)
            if not w or not w.is_ready or w.count < window:
                return 0.0
            vals = [w[i] for i in range(window)]
            vals.reverse()  # oldest -> newest
            alpha = 2.0 / (window + 1)
            ema = vals[0]
            for v in vals[1:]:
                ema = alpha * v + (1 - alpha) * ema
            return ema
        if head == "fn_max_drawdown":
            inner, window = expr[1], expr[2]
            tk = self._ticker_of(inner, ref_ticker)
            w = self._price_window.get(tk)
            if not w or not w.is_ready or w.count < window:
                return 0.0
            vals = [w[i] for i in range(window)]
            vals.reverse()  # oldest -> newest
            peak = vals[0]
            max_dd = 0.0
            for v in vals:
                peak = max(peak, v)
                if peak > 0:
                    max_dd = max(max_dd, (peak - v) / peak)
            return max_dd
        if head == "fn_standard_deviation":
            inner, window = expr[1], expr[2]
            tk = self._ticker_of(inner[1], ref_ticker)
            w = self._price_window.get(tk)
            if not w or not w.is_ready or w.count < window + 1:
                return 0.0
            rets = [w[i] / w[i + 1] - 1.0 for i in range(window)]
            return float(np.std(rets))
        if head == "fn_cumulative_return":
            inner, window = expr[1], expr[2]
            tk = self._ticker_of(inner, ref_ticker)
            w = self._price_window.get(tk)
            if not w or not w.is_ready or w.count < window + 1:
                return 0.0
            return w[0] / w[window] - 1.0
        if head == "fn_rate_of_return":
            tk = self._ticker_of(expr[1], ref_ticker)
            w = self._price_window.get(tk)
            if not w or not w.is_ready or w.count < 2:
                return 0.0
            return w[0] / w[1] - 1.0
        if head == "fn_inverse_volatility":
            inner, window = expr[1], expr[2]
            # inner is typically fn_rate_of_return(<ref or metric_close>)
            arg = inner[1] if isinstance(inner, list) and inner[0] == "fn_rate_of_return" else inner
            tk = self._ticker_of(arg, ref_ticker)
            w = self._price_window.get(tk)
            if not w or not w.is_ready or w.count < window + 1:
                return 0.0
            rets = [w[i] / w[i + 1] - 1.0 for i in range(window)]
            stdev = float(np.std(rets))
            return (1.0 / stdev) if stdev > 0 else 0.0
        raise ValueError(f"unhandled metric expr head: {head}")

    def _eval_cond(self, expr, ref_ticker=None) -> bool:
        head = expr[0]
        if head == "fn_gt":
            return self._eval_metric(expr[1], ref_ticker) > self._eval_metric(expr[2], ref_ticker)
        if head == "fn_lt":
            return self._eval_metric(expr[1], ref_ticker) < self._eval_metric(expr[2], ref_ticker)
        if head == "fn_gte":
            return self._eval_metric(expr[1], ref_ticker) >= self._eval_metric(expr[2], ref_ticker)
        if head == "fn_lte":
            return self._eval_metric(expr[1], ref_ticker) <= self._eval_metric(expr[2], ref_ticker)
        if head == "fn_eq":
            return self._eval_metric(expr[1], ref_ticker) == self._eval_metric(expr[2], ref_ticker)
        if head == "fn_or":
            return self._eval_cond(expr[1], ref_ticker) or self._eval_cond(expr[2], ref_ticker)
        if head == "fn_and":
            return self._eval_cond(expr[1], ref_ticker) and self._eval_cond(expr[2], ref_ticker)
        if head == "fn_not":
            return not self._eval_cond(expr[1], ref_ticker)
        raise ValueError(f"unhandled condition head: {head}")

    # ── Tree resolution -> target weights ───────────────────────────

    def _resolve(self, node) -> dict:
        t = node["t"]
        if t == "node_root":
            return self._resolve_children(node["c"])
        if t == "node_weight":
            return self._resolve_weighted(node["c"], node["w"])
        if t == "node_if":
            branch = node["th"] if self._eval_cond(node["cond"]) else node.get("el", [])
            return self._resolve_children(branch)
        if t == "node_asset":
            sym = self._syms.get(node["tk"])
            return {sym: 1.0} if sym is not None else {}
        if t == "node_filter":
            return self._resolve_filter(node)
        raise ValueError(f"unknown node type: {t}")

    def _resolve_children(self, children) -> dict:
        if not children:
            return {}
        combined = {}
        share = 1.0 / len(children)
        for ch in children:
            for sym, w in self._resolve(ch).items():
                combined[sym] = combined.get(sym, 0.0) + w * share
        return combined

    def _resolve_weighted(self, children, weight_spec) -> dict:
        kind = weight_spec[0]
        if kind == "weight_equal":
            return self._resolve_children(children)
        if kind == "weight_constants":
            weights = weight_spec[1]
            combined = {}
            for ch, wt in zip(children, weights):
                for sym, w in self._resolve(ch).items():
                    combined[sym] = combined.get(sym, 0.0) + w * wt
            return combined
        if kind == "weight_every_fn":
            # Weight each child proportionally to a per-child metric (e.g.
            # inverse volatility), normalized to sum to 1. Resolve each
            # child FIRST and, if it collapses to a single held ticker,
            # score against THAT ticker rather than a structural guess at
            # "the" ticker a complex nested candidate represents -- for a
            # candidate that's itself a multi-level sub-strategy, the
            # structurally-first-referenced ticker (e.g. one used only in an
            # inner regime-gate condition) can be a completely different,
            # unrelated instrument to whatever the candidate actually ends
            # up holding that day.
            fn_expr = weight_spec[1]
            resolved_list = []
            scores = []
            for ch in children:
                resolved = self._resolve(ch)
                ref_ticker = self._single_resolved_ticker(resolved) or self._representative_ticker(ch)
                score = self._eval_metric(fn_expr, ref_ticker)
                resolved_list.append(resolved)
                scores.append(max(score, 0.0))
            total = sum(scores)
            combined = {}
            if total <= 0:
                share = 1.0 / len(resolved_list) if resolved_list else 0.0
                for resolved in resolved_list:
                    for sym, w in resolved.items():
                        combined[sym] = combined.get(sym, 0.0) + w * share
                return combined
            for resolved, score in zip(resolved_list, scores):
                wt = score / total
                for sym, w in resolved.items():
                    combined[sym] = combined.get(sym, 0.0) + w * wt
            return combined
        raise ValueError(f"unknown weight kind: {kind}")

    def _single_resolved_ticker(self, resolved: dict):
        """If a resolved weight dict collapses to exactly one held symbol,
        return its ticker string -- used so filter/weight_every_fn scoring
        can key off what a candidate ACTUALLY holds today, not a guess."""
        if len(resolved) == 1:
            sym = next(iter(resolved))
            return self._ticker_of_sym.get(sym)
        return None

    def _resolve_filter(self, node) -> dict:
        sort_fn = node["sf"]
        n = node["n"]
        direction = node["dir"]
        scored = []
        for ch in node["c"]:
            resolved = self._resolve(ch)
            ref_ticker = self._single_resolved_ticker(resolved) or self._representative_ticker(ch)
            score = self._eval_metric(sort_fn, ref_ticker)
            scored.append((score, resolved))
        scored.sort(key=lambda x: x[0], reverse=(direction == "desc"))
        selected = [resolved for _, resolved in scored[:n]]
        if not selected:
            return {}
        combined = {}
        share = 1.0 / len(selected)
        for resolved in selected:
            for sym, w in resolved.items():
                combined[sym] = combined.get(sym, 0.0) + w * share
        return combined

    # ── Scheduled events ─────────────────────────────────────────────

    def _update_price_windows(self) -> None:
        for t, sym in self._syms.items():
            close = self.securities[sym].close
            if close > 0:
                self._price_window[t].add(float(close))

    def _rebalance(self) -> None:
        if self.is_warming_up:
            return

        combined = self._resolve(self.tree)
        total_w = sum(combined.values())
        if total_w <= 0:
            return

        targets = set(combined)
        for h in list(self.portfolio.values()):
            if h.invested and h.symbol not in targets:
                self.market_on_open_order(h.symbol, -h.quantity)

        pv = self.portfolio.total_portfolio_value
        for sym, wt in combined.items():
            price = self.securities[sym].price
            if price <= 0:
                continue
            target_qty = int(pv * wt / price)
            delta = target_qty - int(self.portfolio[sym].quantity)
            if delta != 0:
                self.market_on_open_order(sym, delta)

        self._trade_count += 1
        net = "+".join(f"{round(w*100):.0f}%{s.value}"
                        for s, w in sorted(combined.items(), key=lambda x: -x[1]) if w > 0.005)
        self.log(f"[{self._trade_count:04d}] {self.time.date()} | net={net}")

    def on_end_of_algorithm(self) -> None:
        self.log(f"\n  Final NAV: ${self.portfolio.total_portfolio_value:>15,.2f}  |  Rebalances: {self._trade_count}")
# region imports
from AlgorithmImports import *
# endregion
import gzip
import base64

_TREE_B64 = "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"

TREE_JSON = gzip.decompress(base64.b64decode(_TREE_B64)).decode('utf-8')