"""LRS TQQQ Strategy — Historical Backtest Engine. Compares LRS strategy vs QQQ buy-and-hold across six time windows: 近 2 年 / 近 3 年 / 近 5 年 / 近 10 年 / 2015—2020 / 2010—2015 Key design choices ────────────────── • Each window downloads 1 extra year for MA200 warmup before trading starts. • "Initial entry": if signal is already True on day 1 of the trading window, enter immediately (no need to wait for a fresh MA200 crossover). • Transaction cost: $1.99 / side (Futu US stock rates). • Initial capital: $10,000 USD. • Idle cash earns 4% annualized, compounded each trading day (≈ (1.04)^(1/252) per day). """ from __future__ import annotations import math from dataclasses import dataclass from datetime import datetime, timedelta import matplotlib.pyplot as plt import numpy as np import pandas as pd import streamlit as st import yfinance as yf from web.pages.lrs.strategy_shared import apply_dark_style _INITIAL_CAPITAL = 10_000.0 _COST_PER_SIDE = 1.99 # $0.99 commission + $1.00 platform fee (Futu) _DISTANCE_THRESH = 6.0 # macro_score=3 → 6% (Step 3) _WHIPSAW_DAYS = 10 # Holding ≤ N days AND loss _CASH_YIELD_ANNUAL = 0.04 # idle cash MMF / T-bill style; compounded on trading days _CASH_YIELD_DAILY = (1.0 + _CASH_YIELD_ANNUAL) ** (1.0 / 252.0) def _accrue_cash_yield(cash: float) -> float: """Apply one trading day of compound interest on idle cash (4% annualized).""" return cash * _CASH_YIELD_DAILY # ───────────────────────────────────────────────────────────────────────────── # Period configuration # ───────────────────────────────────────────────────────────────────────────── # # Each entry: # label – displayed in the sub-tab # fetch_type – "period" (yfinance period string) or "range" (start/end dates) # dl_period – yfinance period to download (includes warmup) [fetch_type=period] # dl_start – warmup start date string [fetch_type=range] # dl_end – end date string [fetch_type=range] # target_days – approx trading days to keep after warmup [used when eff_start=None] # eff_start – fixed effective start date (None = compute from target_days / TQQQ) _PERIOD_SPECS: dict[str, dict] = { "2y": { "label": "近 2 年", "fetch_type": "period", "dl_period": "3y", "target_days": 504, }, "3y": { "label": "近 3 年", "fetch_type": "period", "dl_period": "4y", "target_days": 756, }, "5y": { "label": "近 5 年", "fetch_type": "period", "dl_period": "6y", "target_days": 1260, }, "10y": { "label": "近 10 年", "fetch_type": "range", "dl_start": None, "dl_end": None, # computed at runtime (today − 11 yr) "target_days": 2520, }, "2015_2020": { "label": "2015—2020", "fetch_type": "range", "dl_start": "2014-01-01", "dl_end": "2020-12-31", "eff_start": "2015-01-02", }, "2010_2015": { "label": "2010—2015", "fetch_type": "range", "dl_start": "2009-01-01", "dl_end": "2015-12-31", "eff_start": None, # will use first available TQQQ date (~2010-02-09) }, } _PERIOD_ORDER = ["2y", "3y", "5y", "10y", "2015_2020", "2010_2015"] # ───────────────────────────────────────────────────────────────────────────── # Data fetching # ───────────────────────────────────────────────────────────────────────────── @st.cache_data(ttl=3600, show_spinner=False) def _fetch_by_period(dl_period: str) -> tuple[pd.DataFrame, pd.DataFrame]: """Download by yfinance period string (includes warmup year).""" qqq = yf.download("QQQ", period=dl_period, auto_adjust=True, progress=False) tqqq = yf.download("TQQQ", period=dl_period, auto_adjust=True, progress=False) for df in (qqq, tqqq): if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.get_level_values(0) return qqq, tqqq @st.cache_data(ttl=3600, show_spinner=False) def _fetch_by_range(start: str, end: str) -> tuple[pd.DataFrame, pd.DataFrame]: """Download by explicit date range (includes warmup year).""" qqq = yf.download("QQQ", start=start, end=end, auto_adjust=True, progress=False) tqqq = yf.download("TQQQ", start=start, end=end, auto_adjust=True, progress=False) for df in (qqq, tqqq): if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.get_level_values(0) return qqq, tqqq def _fetch(period_key: str) -> tuple[pd.DataFrame, pd.DataFrame]: """Dispatch to the right fetch function.""" spec = _PERIOD_SPECS[period_key] if spec["fetch_type"] == "period": return _fetch_by_period(spec["dl_period"]) # date-range based start = spec.get("dl_start") end = spec.get("dl_end") if start is None: # "10y": compute dynamically end_dt = datetime.now() start_dt = end_dt - timedelta(days=365 * 11) start = start_dt.strftime("%Y-%m-%d") end = end_dt.strftime("%Y-%m-%d") return _fetch_by_range(start, end) def _get_effective_start( qqq_full: pd.Series, tqqq_full: pd.Series, period_key: str, ) -> pd.Timestamp: """Return the first tradeable date (after warmup, after TQQQ inception).""" spec = _PERIOD_SPECS[period_key] # Fixed start defined in config if spec.get("eff_start"): ts = pd.Timestamp(spec["eff_start"]) # Clamp to actual data range return max(ts, qqq_full.index[0]) # "2010_2015": TQQQ only starts ~Feb 2010 — use first TQQQ date if period_key == "2010_2015": return tqqq_full.index[0] # Period-based / "10y": keep last target_days trading days n = spec.get("target_days", 504) offset = max(0, len(qqq_full) - n) return qqq_full.index[offset] # ───────────────────────────────────────────────────────────────────────────── # Signals # ───────────────────────────────────────────────────────────────────────────── def _streak(s: pd.Series) -> pd.Series: """Vectorized consecutive-True count (resets to 0 on False).""" groups = (s != s.shift()).cumsum() return (s.groupby(groups).cumcount() + 1).where(s, 0) def _build_signals(qqq_close: pd.Series) -> pd.DataFrame: ma200 = qqq_close.rolling(200).mean() a1 = qqq_close > ma200 slope = (ma200 - ma200.shift(20)) / ma200.shift(20) * 100 a2 = slope > -0.5 # MA200 flat or rising a3 = _streak(a1) >= 3 # 3+ consecutive closes above signal = a1 & a2 & a3 entry_signal = signal & ~signal.shift(1).fillna(False) # False→True transition below = qqq_close < ma200 exit_signal = below & below.shift(1).fillna(False) # 2 consecutive days below cross = a1 & ~a1.shift(1).fillna(False) return pd.DataFrame({ "qqq": qqq_close, "ma200": ma200, "a1": a1, "a2": a2, "a3": a3, "signal": signal, "entry_signal": entry_signal, "exit_signal": exit_signal, "cross": cross, }) def _allocation(dist: float) -> tuple[str, float]: """(mode_label, cash_fraction) per Step 3 with macro_score=3.""" if dist <= _DISTANCE_THRESH: return "Direct Buy", 2 / 3 elif dist <= 15.0: return "Split", 1 / 3 else: return "Split(>15%)", 1 / 3 # ───────────────────────────────────────────────────────────────────────────── # Trade # ───────────────────────────────────────────────────────────────────────────── @dataclass class Trade: entry_date: pd.Timestamp entry_price: float entry_mode: str shares: float cost_buy: float exit_date: pd.Timestamp | None = None exit_price: float | None = None cost_sell: float = 0.0 forced_exit: bool = False @property def holding_days(self) -> int: if self.exit_date is None: return 0 return (self.exit_date - self.entry_date).days @property def gross_pnl(self) -> float: if self.exit_price is None: return 0.0 return (self.exit_price - self.entry_price) * self.shares @property def net_pnl(self) -> float: return self.gross_pnl - self.cost_buy - self.cost_sell @property def return_pct(self) -> float: invested = self.entry_price * self.shares + self.cost_buy return (self.net_pnl / invested * 100) if invested > 0 else 0.0 @property def is_whipsaw(self) -> bool: return self.holding_days <= _WHIPSAW_DAYS and self.net_pnl < 0 # ───────────────────────────────────────────────────────────────────────────── # Simulation # ───────────────────────────────────────────────────────────────────────────── def _simulate( sig: pd.DataFrame, tqqq_close: pd.Series, trade_start: pd.Timestamp | None = None, ) -> tuple[list[Trade], pd.Series]: """ Simulate LRS trades day-by-day. Returns (trades, equity_series). trade_start: first tradeable day (warmup period is skipped). Initial-entry: if signal is already True on trade_start, enter immediately. """ tqqq = tqqq_close.reindex(sig.index, method="ffill") cross_dates = sig.index[sig["cross"]].tolist() cash = _INITIAL_CAPITAL shares = 0.0 in_pos = False current : Trade | None = None trades : list[Trade] = [] equity : dict = {} first_day = True for date in sig.index: if trade_start is not None and date < trade_start: continue cash = _accrue_cash_yield(cash) raw = tqqq.get(date) if raw is None or (isinstance(raw, float) and math.isnan(raw)): equity[date] = cash + shares * ( float(tqqq.dropna().iloc[-1]) if shares > 0 else 0.0 ) first_day = False continue price = float(raw) # ── Exit ────────────────────────────────────────────────────────── if in_pos and current is not None and bool(sig.at[date, "exit_signal"]): cash += shares * price - _COST_PER_SIDE current.exit_date = date current.exit_price = price current.cost_sell = _COST_PER_SIDE trades.append(current) shares = 0.0 in_pos = False current = None # ── Entry ───────────────────────────────────────────────────────── # Fire on normal False→True transition, or on day 1 if already bullish is_entry = bool(sig.at[date, "entry_signal"]) if first_day and not in_pos and bool(sig.at[date, "signal"]): is_entry = True first_day = False if not in_pos and is_entry: prior = [d for d in cross_dates if d <= date] cross_date = prior[-1] if prior else date cross_raw = tqqq.get(cross_date) cross_price = ( float(cross_raw) if cross_raw is not None and not math.isnan(float(cross_raw)) else price ) dist = (price - cross_price) / cross_price * 100 if cross_price > 0 else 0.0 mode, frac = _allocation(dist) invest = cash * frac - _COST_PER_SIDE if invest > 0: n = math.floor(invest / price) if n >= 1: cash -= n * price + _COST_PER_SIDE shares = float(n) in_pos = True current = Trade( entry_date=date, entry_price=price, entry_mode=mode, shares=shares, cost_buy=_COST_PER_SIDE, ) equity[date] = cash + shares * price # ── Force-close at period end ───────────────────────────────────────── if in_pos and current is not None and shares > 0: last_date = sig.index[-1] last_price = float(tqqq.iloc[-1]) cash += shares * last_price - _COST_PER_SIDE current.exit_date = last_date current.exit_price = last_price current.cost_sell = _COST_PER_SIDE current.forced_exit = True trades.append(current) equity[last_date] = cash return trades, pd.Series(equity) # ───────────────────────────────────────────────────────────────────────────── # Metrics # ───────────────────────────────────────────────────────────────────────────── def _max_dd(equity: pd.Series) -> float: peak = equity.cummax() return float(((equity - peak) / peak).min() * 100) def _metrics(trades: list[Trade], equity: pd.Series, qqq_close: pd.Series) -> dict: n = len(trades) wins = [t for t in trades if t.net_pnl > 0] loss = [t for t in trades if t.net_pnl <= 0] wsaw = [t for t in trades if t.is_whipsaw] avg = float(np.mean([t.holding_days for t in trades])) if trades else 0.0 return { "strategy_return": (equity.iloc[-1] / _INITIAL_CAPITAL - 1) * 100, "qqq_return": (qqq_close.iloc[-1] / qqq_close.iloc[0] - 1) * 100, "final_value": float(equity.iloc[-1]), "n_trades": n, "n_wins": len(wins), "n_losses": len(loss), "win_rate": (len(wins) / n * 100) if n > 0 else 0.0, "max_dd": _max_dd(equity), "avg_days": avg, "n_whipsaws": len(wsaw), "total_cost": sum(t.cost_buy + t.cost_sell for t in trades), } # ───────────────────────────────────────────────────────────────────────────── # Charts (all-English labels to avoid mojibake) # ───────────────────────────────────────────────────────────────────────────── def _fig_equity( equity: pd.Series, qqq_close: pd.Series, trades: list[Trade], period_label: str, ) -> plt.Figure: qqq_bh = qqq_close / qqq_close.iloc[0] * _INITIAL_CAPITAL idx = qqq_close.index eq = equity.reindex(idx, method="ffill").bfill() bh = qqq_bh.reindex(idx, method="ffill") fig, ax = plt.subplots(figsize=(12, 5)) ax.plot(idx, eq.values, color="#38bdf8", linewidth=1.8, label="LRS Strategy") ax.plot(idx, bh.values, color="#a78bfa", linewidth=1.4, linestyle="--", label="QQQ Buy & Hold") ax.fill_between(idx, eq.values, bh.values, where=(eq.values >= bh.values), alpha=0.10, color="#22c55e", interpolate=True) ax.fill_between(idx, eq.values, bh.values, where=(eq.values < bh.values), alpha=0.10, color="#ef4444", interpolate=True) for t in trades: ax.axvline(t.entry_date, color="#22c55e", alpha=0.20, linewidth=0.6, linestyle=":") if t.exit_date: c = "#ef4444" if t.net_pnl < 0 else "#22c55e" ax.axvline(t.exit_date, color=c, alpha=0.15, linewidth=0.6, linestyle=":") ax.set_title( f"Equity Curve — LRS vs QQQ Buy & Hold | {period_label}" f" (Initial ${_INITIAL_CAPITAL:,.0f})" ) ax.set_ylabel("Portfolio Value (USD)") ax.set_xlabel("Date") ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"${x:,.0f}")) ax.legend(fontsize=9) apply_dark_style(fig, ax) plt.tight_layout() return fig def _fig_per_trade(trades: list[Trade], period_label: str) -> plt.Figure: if not trades: fig, ax = plt.subplots(figsize=(8, 3)) ax.text(0.5, 0.5, f"No trades in this period ({period_label})", ha="center", va="center", transform=ax.transAxes, color="#94a3b8", fontsize=12) apply_dark_style(fig, ax) return fig labels = [t.entry_date.strftime("%y-%m-%d") for t in trades] returns = [t.return_pct for t in trades] colors = ["#f59e0b" if t.is_whipsaw else ("#22c55e" if r >= 0 else "#ef4444") for t, r in zip(trades, returns)] fig, ax = plt.subplots(figsize=(max(10, len(trades) * 1.3), 4)) ax.bar(range(len(trades)), returns, color=colors, width=0.7, edgecolor="none") ax.axhline(0, color="#64748b", linewidth=0.8) ax.set_xticks(range(len(trades))) ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=8) ax.set_ylabel("Return (%)") ax.set_title( f"Per-Trade Return (%) | {period_label}" " (green=win / red=loss / orange=whipsaw)" ) for i, (r, t) in enumerate(zip(returns, trades)): va = "bottom" if r >= 0 else "top" off = 0.3 if r >= 0 else -0.3 ax.text(i, r + off, f"{r:.1f}%", ha="center", va=va, fontsize=7, color="#e2e8f0") apply_dark_style(fig, ax) plt.tight_layout() return fig def _fig_drawdown(equity: pd.Series, period_label: str) -> plt.Figure: peak = equity.cummax() dd = (equity - peak) / peak * 100 fig, ax = plt.subplots(figsize=(12, 3)) ax.fill_between(dd.index, dd.values, 0, color="#ef4444", alpha=0.4) ax.plot(dd.index, dd.values, color="#f87171", linewidth=1.0) ax.axhline(0, color="#64748b", linewidth=0.8) ax.set_title(f"Strategy Drawdown (%) | {period_label}") ax.set_ylabel("Drawdown (%)") ax.set_xlabel("Date") apply_dark_style(fig, ax) plt.tight_layout() return fig # ───────────────────────────────────────────────────────────────────────────── # Trade log # ───────────────────────────────────────────────────────────────────────────── def _trade_df(trades: list[Trade]) -> pd.DataFrame: rows = [] for t in trades: tag = "Whipsaw" if t.is_whipsaw else ("Force-Close" if t.forced_exit else "-") rows.append({ "Entry Date": t.entry_date.date(), "Exit Date": t.exit_date.date() if t.exit_date else "-", "Entry ($)": round(t.entry_price, 2), "Exit ($)": round(t.exit_price, 2) if t.exit_price else "-", "Mode": t.entry_mode, "Hold (days)": t.holding_days, "Return (%)": round(t.return_pct, 2), "Cost ($)": round(t.cost_buy + t.cost_sell, 2), "Net P&L ($)": round(t.net_pnl, 2), "Flag": tag, }) return pd.DataFrame(rows) # ───────────────────────────────────────────────────────────────────────────── # Assumptions text # ───────────────────────────────────────────────────────────────────────────── _ASSUMPTIONS_MD = """ **信号规则(与策略仪表盘 Step 2 一致)** - **A1** QQQ 日收盘 > MA200 - **A2** MA200 过去 20 日斜率 > −0.5%(平稳或向上) - **A3** 连续 ≥ 3 日收盘站上 MA200 **仓位比例(Step 3,宏观评分 = 3)** - 距突破价 ≤ 6% → Direct Buy → 当前现金 **2/3** - 距突破价 > 6% → Split → 当前现金 **1/3** **初始入场** 若回测开始时 QQQ 已在 MA200 之上(signal=True),立即入场 **离场** QQQ 连续 2 日收盘低于 MA200 **成本** 每笔买/卖各 $1.99(富途美股:$0.99 佣金 + $1.00 平台费) **基准** QQQ 买入持有(同期,等额资金) **Whipsaw** 持仓 ≤ 10 天且亏损 """ # ───────────────────────────────────────────────────────────────────────────── # Per-period sub-tab renderer # ───────────────────────────────────────────────────────────────────────────── def _render_period(period_key: str) -> None: """Render one backtest period sub-tab (run button + cached results).""" spec = _PERIOD_SPECS[period_key] label = spec["label"] cache_key = f"bt_{period_key}" run = st.button("▶ 运行回测", type="primary", key=f"bt_run_{period_key}") if not run and cache_key not in st.session_state: st.info(f"点击「运行回测」开始计算 **{label}** 的回测。") with st.expander("回测假设说明"): st.markdown(_ASSUMPTIONS_MD) return # ── Run ─────────────────────────────────────────────────────────────── if run: with st.spinner(f"下载 {label} 数据(含预热期)…"): try: qqq_df, tqqq_df = _fetch(period_key) except Exception as exc: st.error(f"数据下载失败:{exc}") return if qqq_df.empty or tqqq_df.empty: st.error("QQQ 或 TQQQ 数据为空,请稍后重试。") return qqq_full = qqq_df["Close"].squeeze().dropna() tqqq_full = tqqq_df["Close"].squeeze().dropna() if len(qqq_full) < 220: st.error(f"数据量不足({len(qqq_full)} 天),无法计算 MA200。") return t_start = _get_effective_start(qqq_full, tqqq_full, period_key) qqq_eff = qqq_full[t_start:] with st.spinner("计算信号并模拟交易…"): sig = _build_signals(qqq_full) trades, eq = _simulate(sig, tqqq_full, trade_start=t_start) m = _metrics(trades, eq, qqq_eff) st.session_state[cache_key] = { "trades": trades, "equity": eq, "qqq_eff": qqq_eff, "metrics": m, } st.success("回测完成!") # ── Render ──────────────────────────────────────────────────────────── res = st.session_state.get(cache_key) if res is None: return trades = res["trades"] equity = res["equity"] qqq_eff = res["qqq_eff"] m = res["metrics"] qqq_r = m["qqq_return"] bh_final = _INITIAL_CAPITAL * (1 + qqq_r / 100) strat_r = m["strategy_return"] delta = strat_r - qqq_r st.caption( f"数据区间:{qqq_eff.index[0].date()} → {qqq_eff.index[-1].date()}" ) # ── Metric block 1: performance ─────────────────────────────────────── with st.container(border=True): # Row 1: Total return c1, c2, c3, c4 = st.columns(4) c1.metric("LRS 总收益率", f"{strat_r:+.1f}%", f"vs QQQ {delta:+.1f}%", delta_color="normal") c2.metric("QQQ B&H 收益率", f"{qqq_r:+.1f}%") c3.metric("LRS 最终资产", f"${m['final_value']:,.0f}") c4.metric("QQQ B&H 最终", f"${bh_final:,.0f}") st.divider() # Row 2: Risk stats c1, c2, c3, c4 = st.columns(4) c1.metric("最大回撤", f"{m['max_dd']:.1f}%") c2.metric("交易次数", str(m["n_trades"]), f"盈 {m['n_wins']} / 亏 {m['n_losses']}") c3.metric("胜率", f"{m['win_rate']:.0f}%") c4.metric("Whipsaw", f"{m['n_whipsaws']} 次", f"均持仓 {m['avg_days']:.0f} 天") # ── Equity curve ────────────────────────────────────────────────────── with st.container(border=True): st.markdown("##### Equity Curve") st.pyplot(_fig_equity(equity, qqq_eff, trades, label), clear_figure=True) # ── Per-trade returns ───────────────────────────────────────────────── with st.container(border=True): st.markdown("##### Per-Trade Return") st.pyplot(_fig_per_trade(trades, label), clear_figure=True) # ── Drawdown ────────────────────────────────────────────────────────── with st.container(border=True): st.markdown("##### Drawdown") st.pyplot(_fig_drawdown(equity, label), clear_figure=True) # ── Trade log ───────────────────────────────────────────────────────── with st.container(border=True): st.markdown("##### 交易明细") if trades: st.dataframe( _trade_df(trades), use_container_width=True, hide_index=True, column_config={ "Return (%)": st.column_config.NumberColumn(format="%.2f %%"), "Net P&L ($)": st.column_config.NumberColumn(format="$ %.2f"), }, ) st.caption( f"Total cost: ${m['total_cost']:.2f} | " f"Period: {qqq_eff.index[0].date()} → {qqq_eff.index[-1].date()}" ) else: st.info("该区间内未产生任何交易信号。") with st.expander("回测假设说明"): st.markdown(_ASSUMPTIONS_MD) # ───────────────────────────────────────────────────────────────────────────── # Entry point # ───────────────────────────────────────────────────────────────────────────── def render_backtest_tab() -> None: st.subheader("LRS TQQQ 策略回测") st.caption( "LRS 策略 vs QQQ 买入持有 | 六个时间窗口独立缓存,切换 tab 无需重新运行。" ) tabs = st.tabs([_PERIOD_SPECS[k]["label"] for k in _PERIOD_ORDER]) for tab, period_key in zip(tabs, _PERIOD_ORDER): with tab: _render_period(period_key)