598 lines
27 KiB
Python
598 lines
27 KiB
Python
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"])
|
||
|