Files
2026-07-13 12:36:27 +08:00

598 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta, datetime
import io
import math
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import streamlit as st
import yfinance as yf
from web.pages.wheel import strategy_cache as cache
from web.pages.wheel.style_profiles import StyleProfile
from web.pages.wheel.strategy_shared import annual_to_daily, apply_dark_style, bs_price, close_series, rsi
INITIAL_CAPITAL = 10_000.0
COMMISSION = 2.99
CASH_RATE = 0.03
RISK_FREE = 0.03
MAX_CONTRACTS = 1
ENGINE_VERSION = "wheel-v3-split"
RULE_VERSION = "tqqq-wheel-manual-v1.1"
@dataclass
class Position:
kind: str
open_date: pd.Timestamp
expiry: pd.Timestamp
strike: float
premium_open: float
qty: int = 1
def series_to_records(s: pd.Series) -> list[list]:
return [[str(i.date()), float(v)] for i, v in s.items()]
def records_to_series(rows: list[list]) -> pd.Series:
if not rows:
return pd.Series(dtype=float)
idx = pd.to_datetime([r[0] for r in rows])
vals = [float(r[1]) for r in rows]
return pd.Series(vals, index=idx).sort_index()
def serialize_result(res: dict) -> dict:
return {
"equity": series_to_records(res["equity"]),
"benchmark_bh": series_to_records(res["benchmark_bh"]),
"monthly_pct": series_to_records(res["monthly_pct"]),
"ivr": series_to_records(res["ivr"]),
"rsi": series_to_records(res["rsi"]),
"underlying": series_to_records(res["underlying"]),
"signal": series_to_records(res["signal"]),
"metrics": dict(res["metrics"]),
"trade_log": list(res["trade_log"]),
"underlying_ticker": str(res.get("underlying_ticker", "TQQQ")),
"signal_ticker": str(res.get("signal_ticker", "QQQ")),
"style": str(res.get("style", "aggressive")),
}
def deserialize_result(payload: dict | None) -> dict | None:
if not payload:
return None
try:
return {
"equity": records_to_series(payload.get("equity", [])),
"benchmark_bh": records_to_series(payload.get("benchmark_bh", [])),
"monthly_pct": records_to_series(payload.get("monthly_pct", [])),
"ivr": records_to_series(payload.get("ivr", [])),
"rsi": records_to_series(payload.get("rsi", [])),
"underlying": records_to_series(payload.get("underlying", [])),
"signal": records_to_series(payload.get("signal", [])),
"metrics": dict(payload.get("metrics", {})),
"trade_log": list(payload.get("trade_log", [])),
"underlying_ticker": str(payload.get("underlying_ticker", "TQQQ")),
"signal_ticker": str(payload.get("signal_ticker", "QQQ")),
"style": str(payload.get("style", "aggressive")),
}
except Exception:
return None
def _rolling_hv(close: pd.Series, window: int = 20) -> pd.Series:
return close.pct_change().rolling(window).std() * np.sqrt(252)
def _iv_and_ivr(close: pd.Series) -> tuple[pd.Series, pd.Series]:
hv = _rolling_hv(close, 20).bfill().fillna(0.45)
iv = (hv * 1.15).clip(lower=0.10, upper=2.5)
iv_min = iv.rolling(252, min_periods=20).min()
iv_max = iv.rolling(252, min_periods=20).max()
ivr = ((iv - iv_min) / (iv_max - iv_min).replace(0, np.nan) * 100.0).fillna(50.0)
return iv, ivr.clip(lower=0, upper=100)
def _fetch_option_mid_from_chain(ticker_obj: yf.Ticker, expiry: pd.Timestamp, strike: float, kind: str) -> float | None:
try:
chain = ticker_obj.option_chain(expiry.strftime("%Y-%m-%d"))
df = chain.puts if kind == "put" else chain.calls
if df is None or df.empty:
return None
i = (df["strike"] - strike).abs().idxmin()
row = df.loc[i]
bid = float(row.get("bid", np.nan))
ask = float(row.get("ask", np.nan))
last = float(row.get("lastPrice", np.nan))
if np.isfinite(bid) and np.isfinite(ask) and bid > 0 and ask > 0:
return 0.5 * (bid + ask)
if np.isfinite(last) and last > 0:
return last
except Exception:
return None
return None
def _option_price(
ticker_obj: yf.Ticker,
spot: float,
strike: float,
now: pd.Timestamp,
expiry: pd.Timestamp,
sigma: float,
kind: str,
) -> tuple[float, str]:
prem = _fetch_option_mid_from_chain(ticker_obj, expiry, strike, kind)
if prem is not None:
return prem, "chain"
t = max((expiry - now).days / 365.0, 0.0)
return bs_price(spot, strike, t, RISK_FREE, sigma, kind), "bs"
def _option_expiry(open_date: pd.Timestamp, dte: int) -> pd.Timestamp:
return open_date + pd.Timedelta(days=int(dte))
def _max_dd(equity: pd.Series) -> float:
peak = equity.cummax()
return float(((equity - peak) / peak).min() * 100.0)
def simulate_wheel(
*,
style: StyleProfile,
start: date,
end: date,
underlying_ticker: str,
signal_ticker: str,
put_tp: float,
call_tp: float,
put_sl: float | None,
call_sl: float | None,
put_otm: float,
call_otm: float,
) -> dict:
sig = close_series(signal_ticker, start.isoformat(), (end + timedelta(days=1)).isoformat())
ul = close_series(underlying_ticker, start.isoformat(), (end + timedelta(days=1)).isoformat())
idx = sig.index.intersection(ul.index)
sig = sig.reindex(idx).ffill()
ul = ul.reindex(idx).ffill()
rs = rsi(sig, 14).fillna(50.0)
iv, ivr = _iv_and_ivr(ul)
iv = iv.reindex(idx).ffill().bfill()
ivr = ivr.reindex(idx).ffill().bfill()
ticker_obj = yf.Ticker(underlying_ticker)
cash = INITIAL_CAPITAL
shares = 0
stock_cost_basis = 0.0
pos: Position | None = None
trade_log: list[dict] = []
equity: dict = {}
daily = annual_to_daily(CASH_RATE)
chain_hits = 0
bs_hits = 0
last_put_open: pd.Timestamp | None = None
cooldown_until: pd.Timestamp | None = None
confirm_counter = 0
for d in idx:
cash *= daily
spot = float(ul.loc[d])
sigma = float(iv.loc[d])
liab = 0.0
if pos is not None:
mark, src = _option_price(ticker_obj, spot, pos.strike, d, pos.expiry, sigma, pos.kind)
liab = mark * 100 * pos.qty
chain_hits += int(src == "chain")
bs_hits += int(src == "bs")
loss_mult = (mark - pos.premium_open) / max(pos.premium_open, 1e-9)
tp_ratio = (pos.premium_open - mark) / max(pos.premium_open, 1e-9)
if pos.kind == "put" and tp_ratio >= put_tp:
cash -= mark * 100 * pos.qty + COMMISSION
pnl = (pos.premium_open - mark) * 100 * pos.qty - 2 * COMMISSION
trade_log.append({"date": d, "type": "PUT", "action": "TP_Close", "strike": pos.strike, "spot": spot, "premium": pos.premium_open, "close_cost": mark, "pnl": pnl, "hold_days": (d - pos.open_date).days, "result": "Win" if pnl > 0 else "Loss"})
pos = None
liab = 0.0
elif pos.kind == "call" and tp_ratio >= call_tp:
cash -= mark * 100 * pos.qty + COMMISSION
pnl = (pos.premium_open - mark) * 100 * pos.qty - 2 * COMMISSION
trade_log.append({"date": d, "type": "CALL", "action": "TP_Close", "strike": pos.strike, "spot": spot, "premium": pos.premium_open, "close_cost": mark, "pnl": pnl, "hold_days": (d - pos.open_date).days, "result": "Win" if pnl > 0 else "Loss"})
pos = None
liab = 0.0
elif pos.kind == "put" and style.roll_preferred and style.roll_stop_threshold is not None and loss_mult >= style.roll_stop_threshold and (pos.expiry - d).days > 14:
new_expiry = d + pd.Timedelta(days=35)
new_strike = round(min(pos.strike * 0.95, spot * 0.90), 2)
new_prem, src2 = _option_price(ticker_obj, spot, new_strike, d, new_expiry, sigma, "put")
chain_hits += int(src2 == "chain")
bs_hits += int(src2 == "bs")
net_credit = new_prem - mark
if net_credit >= 0:
cash += (new_prem - mark) * 100 - 2 * COMMISSION
trade_log.append({"date": d, "type": "PUT", "action": "ROLL_PUT", "strike": pos.strike, "spot": spot, "premium": pos.premium_open, "close_cost": mark, "pnl": (pos.premium_open - mark) * 100 - 2 * COMMISSION, "hold_days": (d - pos.open_date).days, "result": "Roll"})
pos = Position("put", d, new_expiry, new_strike, new_prem, MAX_CONTRACTS)
liab = 0.0
elif pos.kind == "put" and put_sl is not None and loss_mult >= put_sl:
cash -= mark * 100 * pos.qty + COMMISSION
pnl = (pos.premium_open - mark) * 100 * pos.qty - 2 * COMMISSION
trade_log.append({"date": d, "type": "PUT", "action": "SL_Close", "strike": pos.strike, "spot": spot, "premium": pos.premium_open, "close_cost": mark, "pnl": pnl, "hold_days": (d - pos.open_date).days, "result": "Win" if pnl > 0 else "Loss"})
pos = None
liab = 0.0
if style.cooldown_days > 0:
cooldown_until = d + pd.Timedelta(days=style.cooldown_days)
elif pos.kind == "call" and call_sl is not None and loss_mult >= call_sl:
cash -= mark * 100 * pos.qty + COMMISSION
pnl = (pos.premium_open - mark) * 100 * pos.qty - 2 * COMMISSION
trade_log.append({"date": d, "type": "CALL", "action": "SL_Close", "strike": pos.strike, "spot": spot, "premium": pos.premium_open, "close_cost": mark, "pnl": pnl, "hold_days": (d - pos.open_date).days, "result": "Win" if pnl > 0 else "Loss"})
pos = None
liab = 0.0
if pos is not None and d >= pos.expiry:
if pos.kind == "put":
intrinsic = max(pos.strike - spot, 0.0)
assigned = spot < pos.strike
if assigned:
qty = 100 * pos.qty
cash -= pos.strike * qty
prev_cost = stock_cost_basis * shares
shares += qty
stock_cost_basis = ((prev_cost + pos.strike * qty) / shares) if shares > 0 else 0.0
pnl = (pos.premium_open - intrinsic) * 100 * pos.qty - COMMISSION
trade_log.append({"date": d, "type": "PUT", "action": "Assigned" if assigned else "Expire_OTM", "strike": pos.strike, "spot": spot, "premium": pos.premium_open, "close_cost": intrinsic, "pnl": pnl, "hold_days": (d - pos.open_date).days, "result": "Win" if pnl > 0 else "Loss"})
else:
intrinsic = max(spot - pos.strike, 0.0)
called = spot > pos.strike and shares >= 100
if called:
qty = 100 * pos.qty
cash += pos.strike * qty
shares -= qty
if shares == 0:
stock_cost_basis = 0.0
pnl = (pos.premium_open - intrinsic) * 100 * pos.qty - COMMISSION
trade_log.append({"date": d, "type": "CALL", "action": "Call_Away" if called else "Expire_OTM", "strike": pos.strike, "spot": spot, "premium": pos.premium_open, "close_cost": intrinsic, "pnl": pnl, "hold_days": (d - pos.open_date).days, "result": "Win" if pnl > 0 else "Loss"})
pos = None
liab = 0.0
allow_cycle = last_put_open is None or (d - last_put_open).days >= style.entry_cycle_days
in_cooldown = cooldown_until is not None and d <= cooldown_until
if pos is None:
if shares < 100:
strike = round(spot * (1.0 - put_otm), 2)
base_cond = float(ivr.loc[d]) > style.ivr_min and float(rs.loc[d]) < style.rsi_max and cash >= strike * 100 and not in_cooldown
confirm_counter = (confirm_counter + 1) if base_cond else 0
confirmed = confirm_counter >= max(1, style.confirm_days + 1)
if allow_cycle and base_cond and confirmed:
expiry = _option_expiry(d, style.dte)
premium, src = _option_price(ticker_obj, spot, strike, d, expiry, sigma, "put")
chain_hits += int(src == "chain")
bs_hits += int(src == "bs")
cash += premium * 100 - COMMISSION
pos = Position("put", d, expiry, strike, premium, MAX_CONTRACTS)
last_put_open = d
else:
strike = spot * (1.0 + call_otm)
if style.cc_min_cost_basis:
strike = max(strike, stock_cost_basis)
if style.cc_pause_below_cost_ratio is not None and stock_cost_basis > 0 and spot < stock_cost_basis * style.cc_pause_below_cost_ratio:
strike = -1.0
if strike > 0 and shares >= 100:
strike = round(strike, 2)
expiry = _option_expiry(d, style.dte)
premium, src = _option_price(ticker_obj, spot, strike, d, expiry, sigma, "call")
chain_hits += int(src == "chain")
bs_hits += int(src == "bs")
cash += premium * 100 - COMMISSION
pos = Position("call", d, expiry, strike, premium, MAX_CONTRACTS)
if pos is not None:
mark, _ = _option_price(ticker_obj, spot, pos.strike, d, pos.expiry, sigma, pos.kind)
liab = mark * 100 * pos.qty
equity[d] = cash + shares * spot - liab
eq = pd.Series(equity).sort_index()
ret = eq.pct_change().dropna()
years = max((eq.index[-1] - eq.index[0]).days / 365.25, 0.001)
cagr = (float(eq.iloc[-1]) / INITIAL_CAPITAL) ** (1.0 / years) - 1.0
ann_vol = ret.std() * math.sqrt(252) if len(ret) > 1 else 1.0
sharpe = (cagr - RISK_FREE) / ann_vol if ann_vol > 0 else 0.0
mdd = _max_dd(eq)
wins = [x for x in trade_log if x["pnl"] > 0]
losses = [x for x in trade_log if x["pnl"] <= 0]
monthly = eq.resample("ME").last().pct_change().dropna() * 100
benchmark_bh = INITIAL_CAPITAL * sig / float(sig.iloc[0])
return {
"equity": eq,
"benchmark_bh": benchmark_bh.reindex(eq.index).ffill(),
"trade_log": trade_log,
"metrics": {
"total_return": (float(eq.iloc[-1]) / INITIAL_CAPITAL - 1.0) * 100,
"final_value": float(eq.iloc[-1]),
"cagr": cagr * 100,
"sharpe": sharpe,
"max_dd": mdd,
"n_trades": len(trade_log),
"wins": len(wins),
"losses": len(losses),
"win_rate": (len(wins) / len(trade_log) * 100) if trade_log else 0.0,
"avg_win": float(np.mean([x["pnl"] for x in wins])) if wins else 0.0,
"avg_loss": float(np.mean([x["pnl"] for x in losses])) if losses else 0.0,
"chain_quotes": chain_hits,
"bs_quotes": bs_hits,
},
"monthly_pct": monthly,
"ivr": ivr,
"rsi": rs,
"underlying": ul,
"signal": sig,
"underlying_ticker": underlying_ticker,
"signal_ticker": signal_ticker,
"style": style.key,
}
def render_metrics(m: dict) -> None:
c1, c2, c3, c4, c5, c6 = st.columns(6)
c1.metric("总收益率", f"{m.get('total_return', 0):+.1f}%")
c2.metric("CAGR", f"{m.get('cagr', 0):+.1f}%")
c3.metric("Sharpe", f"{m.get('sharpe', 0):.3f}")
c4.metric("最大回撤", f"{m.get('max_dd', 0):.1f}%")
c5.metric("胜率", f"{m.get('win_rate', 0):.0f}%")
c6.metric("交易笔数", str(m.get("n_trades", 0)))
st.caption(
f"初始 ${INITIAL_CAPITAL:,.0f} | 佣金 ${COMMISSION:.2f}/笔 | 空闲现金 {CASH_RATE:.0%} 年化 | "
f"期权定价:chain={m.get('chain_quotes',0)} 次,BS fallback={m.get('bs_quotes',0)} 次"
)
def fig_to_png_bytes(fig: plt.Figure) -> bytes:
buf = io.BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
buf.seek(0)
return buf.read()
def build_chart_figures(res: dict) -> dict[str, plt.Figure]:
eq = res["equity"]
bh = res["benchmark_bh"]
monthly = res["monthly_pct"]
dd = (eq / eq.cummax() - 1.0) * 100
fig1, ax1 = plt.subplots(figsize=(10, 3.8))
ax1.plot(eq.index, eq.values, lw=1.8, color="#34d399", label="Wheel Equity")
ax1.plot(bh.index, bh.values, lw=1.2, ls="--", color="#60a5fa", label=f"{res.get('signal_ticker', 'Benchmark')} B&H")
ax1.set_title("Portfolio Equity Curve")
ax1.set_ylabel("Portfolio ($)")
ax1.legend(loc="upper left")
apply_dark_style(fig1, ax1)
fig2, ax2 = plt.subplots(figsize=(7, 3.0))
ax2.plot(dd.index, dd.values, lw=1.4, color="#fb7185")
ax2.fill_between(dd.index, dd.values, 0, color="#fb7185", alpha=0.12)
ax2.set_title("Drawdown Curve (%)")
apply_dark_style(fig2, ax2)
fig3, ax3 = plt.subplots(figsize=(7, 3.0))
colors = ["#34d399" if x >= 0 else "#fb7185" for x in monthly.values]
ax3.bar(monthly.index.strftime("%Y-%m"), monthly.values, color=colors, alpha=0.85)
ax3.set_title("Monthly Return (%)")
ax3.tick_params(axis="x", rotation=45)
apply_dark_style(fig3, ax3)
fig4, ax4 = plt.subplots(figsize=(10, 3.2))
ivr = res["ivr"].reindex(eq.index).ffill()
rsi_s = res["rsi"].reindex(eq.index).ffill()
ul = res["underlying"].reindex(eq.index).ffill()
ax4.plot(ivr.index, ivr.values, color="#22d3ee", lw=1.2, label="IVR%")
ax4.plot(rsi_s.index, rsi_s.values, color="#fbbf24", lw=1.1, label="RSI")
ax4_t = ax4.twinx()
ul_name = res.get("underlying_ticker", "Underlying")
ax4_t.plot(ul.index, ul.values, color="#a78bfa", lw=1.0, alpha=0.75, label=ul_name)
ax4.set_title(f"Indicators: IVR / RSI / {ul_name}")
apply_dark_style(fig4, ax4, ax4_t)
return {"equity": fig1, "drawdown": fig2, "monthly": fig3, "indicators": fig4}
def render_charts(res: dict) -> None:
figs = build_chart_figures(res)
st.pyplot(figs["equity"], use_container_width=True)
c1, c2 = st.columns(2)
with c1:
st.pyplot(figs["drawdown"], use_container_width=True)
with c2:
st.pyplot(figs["monthly"], use_container_width=True)
st.pyplot(figs["indicators"], use_container_width=True)
for fig in figs.values():
plt.close(fig)
def render_trade_table(rows: list[dict]) -> None:
if not rows:
st.info("当前区间没有生成有效交易。")
return
df = pd.DataFrame(rows).rename(
columns={
"date": "日期",
"type": "类型",
"action": "操作",
"strike": "行权价",
"spot": "标的价",
"premium": "开仓权利金",
"close_cost": "平仓成本",
"pnl": "盈亏",
"hold_days": "持仓天数",
"result": "结果",
}
)
st.dataframe(df, use_container_width=True, hide_index=True)
def run_style_tab(style: StyleProfile, state_prefix: str, default_underlying: str, default_signal: str) -> None:
st.markdown(f"#### {style.label}风格")
c0, c1, c2, c3 = st.columns(4)
with c0:
underlying = st.text_input("标的Ticker", value=default_underlying, key=f"{state_prefix}_{style.key}_ul").upper().strip()
with c1:
signal = st.text_input("信号Ticker", value=default_signal, key=f"{state_prefix}_{style.key}_sig").upper().strip()
with c2:
end_d = st.date_input("结束日期", value=date.today(), key=f"{state_prefix}_{style.key}_end")
with c3:
start_d = st.date_input("开始日期", value=date.today() - timedelta(days=365), key=f"{state_prefix}_{style.key}_start")
p1, p2, p3, p4, p5, p6 = st.columns(6)
with p1:
put_otm_pct = st.number_input("Put OTM(%)", min_value=0.0, max_value=30.0, value=style.put_otm * 100, step=0.5, key=f"{state_prefix}_{style.key}_put_otm")
with p2:
call_otm_pct = st.number_input("Call OTM(%)", min_value=0.0, max_value=30.0, value=style.call_otm * 100, step=0.5, key=f"{state_prefix}_{style.key}_call_otm")
with p3:
put_tp_pct = st.number_input("Put止盈(%)", min_value=1.0, max_value=95.0, value=style.put_tp * 100, step=1.0, key=f"{state_prefix}_{style.key}_put_tp")
with p4:
call_tp_pct = st.number_input("Call止盈(%)", min_value=1.0, max_value=99.0, value=style.call_tp * 100, step=1.0, key=f"{state_prefix}_{style.key}_call_tp")
with p5:
put_sl_pct = st.number_input("Put止损倍数(%)", min_value=0.0, max_value=1000.0, value=(style.put_sl or 0.0) * 100, step=50.0, key=f"{state_prefix}_{style.key}_put_sl")
with p6:
call_sl_pct = st.number_input("Call止损倍数(%)", min_value=0.0, max_value=1000.0, value=0.0, step=50.0, key=f"{state_prefix}_{style.key}_call_sl")
if st.button(f"运行{style.label}回测", key=f"run_{state_prefix}_{style.key}", use_container_width=True):
with st.spinner("正在获取 yfinance 数据并回测..."):
try:
res = simulate_wheel(
style=style,
start=start_d,
end=end_d,
underlying_ticker=underlying,
signal_ticker=signal,
put_tp=put_tp_pct / 100.0,
call_tp=call_tp_pct / 100.0,
put_sl=(put_sl_pct / 100.0) if put_sl_pct > 0 else None,
call_sl=(call_sl_pct / 100.0) if call_sl_pct > 0 else None,
put_otm=put_otm_pct / 100.0,
call_otm=call_otm_pct / 100.0,
)
payload = serialize_result(res)
latest_key = f"{state_prefix}_{style.key}_latest"
cache.save(latest_key, {"style": style.key, "result_payload": payload, "start": str(start_d), "end": str(end_d)})
chart_pngs: dict[str, bytes] = {}
figs = build_chart_figures(res)
for name, fig in figs.items():
chart_pngs[name] = fig_to_png_bytes(fig)
plt.close(fig)
cache.save_report(
{
"style": style.key,
"ticker": underlying,
"run_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"meta": {
"start": str(start_d),
"end": str(end_d),
"style": style.key,
"underlying_ticker": underlying,
"signal_ticker": signal,
"put_otm_pct": put_otm_pct,
"call_otm_pct": call_otm_pct,
"put_tp_pct": put_tp_pct,
"call_tp_pct": call_tp_pct,
"put_sl_pct": put_sl_pct if put_sl_pct > 0 else None,
"call_sl_pct": call_sl_pct if call_sl_pct > 0 else None,
"engine_version": ENGINE_VERSION,
"rule_version": RULE_VERSION,
},
"series": {
"equity": payload["equity"],
"benchmark_bh": payload["benchmark_bh"],
"monthly_pct": payload["monthly_pct"],
"ivr": payload["ivr"],
"rsi": payload["rsi"],
"underlying": payload["underlying"],
"signal": payload["signal"],
},
"trades": payload["trade_log"],
"metrics": payload["metrics"],
},
chart_pngs=chart_pngs,
)
st.session_state[f"{state_prefix}_{style.key}_result"] = res
st.success("回测完成。")
except Exception as exc:
st.error(f"回测失败:{exc}")
res = st.session_state.get(f"{state_prefix}_{style.key}_result")
if res is None:
cached = cache.load(f"{state_prefix}_{style.key}_latest")
if cached:
res = deserialize_result(cached.get("result_payload"))
if res is not None:
st.session_state[f"{state_prefix}_{style.key}_result"] = res
st.caption(f"已加载上次{style.label}结果:{cached.get('start')}{cached.get('end')}")
if res is None:
st.info("点击上方按钮开始回测。")
return
render_metrics(res["metrics"])
render_charts(res)
st.markdown("#### 交易记录")
render_trade_table(res["trade_log"])
def render_history_tab(state_prefix: str) -> None:
st.markdown("#### 历史报告")
c1, c2 = st.columns(2)
with c1:
style_filter = st.selectbox("风格筛选", ["全部", "aggressive", "neutral", "conservative"], key=f"{state_prefix}_hist_style")
with c2:
ticker_filter = st.text_input("Ticker筛选(可空)", value="", key=f"{state_prefix}_hist_ticker").strip().upper()
reports = cache.list_reports(style=None if style_filter == "全部" else style_filter, ticker=ticker_filter or None)
if not reports:
st.info("暂无历史报告。")
return
options = [f"{r.get('run_at')} | {r.get('style')} | {r.get('ticker')} | {r.get('start')}~{r.get('end')}" for r in reports]
choice = st.radio("选择历史报告", options, index=0, key=f"{state_prefix}_hist_radio")
rid = reports[options.index(choice)]["report_id"]
data = cache.load_report(rid)
if not data:
st.warning("读取历史报告失败。")
return
series = data.get("series", {})
res = deserialize_result(
{
"equity": series.get("equity", []),
"benchmark_bh": series.get("benchmark_bh", []),
"monthly_pct": series.get("monthly_pct", []),
"ivr": series.get("ivr", []),
"rsi": series.get("rsi", []),
"underlying": series.get("underlying", []),
"signal": series.get("signal", []),
"metrics": data.get("metrics", {}),
"trade_log": data.get("trades", []),
"underlying_ticker": data.get("meta", {}).get("underlying_ticker", ""),
"signal_ticker": data.get("meta", {}).get("signal_ticker", ""),
"style": data.get("meta", {}).get("style", data.get("style", "")),
}
)
if not res:
st.warning("历史报告序列解析失败。")
return
st.caption(f"Report ID: {rid}")
chart_map = data.get("charts", {}) if isinstance(data, dict) else {}
if chart_map:
with st.expander("查看历史快照图(本地PNG", expanded=False):
c1, c2 = st.columns(2)
if chart_map.get("equity"):
c1.image(chart_map["equity"], caption="equity.png")
if chart_map.get("drawdown"):
c2.image(chart_map["drawdown"], caption="drawdown.png")
c3, c4 = st.columns(2)
if chart_map.get("monthly"):
c3.image(chart_map["monthly"], caption="monthly.png")
if chart_map.get("indicators"):
c4.image(chart_map["indicators"], caption="indicators.png")
render_metrics(res["metrics"])
render_charts(res)
st.markdown("#### 交易记录")
render_trade_table(res["trade_log"])