项目文件夹

文件
2026-07-13 12:36:27 +08:00

253 行
11 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""Step 3 — Entry Mode Decision: Price Distance Assessment.
UX pattern:
- Page load: show last cached results
- "更新判断" button: fetch fresh data, compute, save, display
- Chart: only after "更新判断" in current session
"""
from __future__ import annotations
import matplotlib.pyplot as plt
import pandas as pd
import streamlit as st
import yfinance as yf
from web.pages.lrs import strategy_cache as sc
from web.pages.lrs.strategy_shared import apply_dark_style, render_score_card
_CACHE_KEY = "validation"
_SKEY = "lrs_step3"
@st.cache_data(ttl=300, show_spinner=False)
def _fetch_data() -> tuple[pd.DataFrame, pd.DataFrame]:
tqqq = yf.download("TQQQ", period="6mo", auto_adjust=True, progress=False)
qqq = yf.download("QQQ", period="2y", auto_adjust=True, progress=False)
for df in (tqqq, qqq):
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
return tqqq, qqq
def _find_crossover(qqq_df: pd.DataFrame):
close = qqq_df["Close"].dropna()
ma200 = close.rolling(200).mean()
above = (close > ma200).dropna()
for i in range(len(above) - 1, 0, -1):
if above.iloc[i] and not above.iloc[i - 1]:
return above.index[i]
return None
def _entry_mode(distance_pct: float, macro_score: float) -> tuple[str, str, str]:
if macro_score >= 5: threshold = 8.0
elif macro_score >= 3: threshold = 6.0
elif macro_score >= 2: threshold = 4.0
else:
return ("HOLD", "不入场",
f"宏观评分 {macro_score:.0f}/5 过低,请返回 Step 1。")
if distance_pct < 0:
return ("Reassess", "返回 Step 2",
"TQQQ 低于突破日价格 — 请重新确认 QQQ 是否仍在 MA200 之上。")
if distance_pct <= 4.0:
return ("Direct Buy", "2/3 直接买入 + 1/3 Wheel 保证金",
f"距离 {distance_pct:.1f}% — 最佳入场,安全边际最大。")
if distance_pct <= threshold:
return ("Direct Buy", "2/3 直接买入 + 1/3 Wheel 保证金",
f"距离 {distance_pct:.1f}% — 突破较新,直接买入合适。")
if distance_pct <= 15.0:
return ("Split", "1/3 直接买入 + 1/3 Sell Put + 1/3 预留",
f"距离 {distance_pct:.1f}% — TQQQ 已有一定涨幅,分批入场降低追高风险。")
return ("Split(强烈建议)", "1/3 直接买入 + 1/3 Sell Put + 1/3 预留",
f"距离 {distance_pct:.1f}% — 已大幅上涨,历史上回调频率较高。")
def _to_cache(cross_idx, cross_price, current_price, distance_pct,
mode, mode_detail, rationale, macro_score) -> dict:
return {
"cross_idx": str(cross_idx.date()) if cross_idx is not None else None,
"cross_price": float(cross_price) if cross_price is not None else None,
"current_price": float(current_price),
"distance_pct": float(distance_pct),
"entry_mode": mode,
"mode_detail": mode_detail,
"rationale": rationale,
"macro_score": float(macro_score),
}
def _render_distance_chart(tqqq_close, cross_idx, cross_price, current_price, save: bool = False) -> None:
with st.container(border=True):
st.markdown("**图表 — TQQQ 价格与突破基准距离**")
fig, ax = plt.subplots(figsize=(12, 5))
t90 = tqqq_close.tail(90)
ax.plot(t90.index, t90.values, color="#38bdf8", linewidth=1.8, label="TQQQ Close")
if cross_idx is not None and cross_price is not None:
ax.axvline(x=cross_idx, color="#f59e0b", linestyle="--", linewidth=1.3,
label=f"Crossover ({cross_idx.date()})")
ax.axhline(y=cross_price, color="#a78bfa", linestyle="--", linewidth=1.2,
label=f"Cross price ${cross_price:.2f}")
ax.axhline(y=current_price, color="#22c55e", linestyle=":", linewidth=1.2,
label=f"Current ${current_price:.2f}")
fc = "#22c55e" if current_price >= cross_price else "#ef4444"
ax.fill_between(t90.index, cross_price, current_price, alpha=0.12, color=fc)
ax.set_title("TQQQ — Distance from MA200 Crossover Day"); ax.set_ylabel("Price (USD)")
ax.legend(loc="upper left", fontsize=9)
apply_dark_style(fig, ax)
plt.tight_layout()
if save:
sc.save_figure("validation_chart", fig)
st.pyplot(fig, clear_figure=True)
def _render_all(data: dict, is_fresh: bool = False) -> None:
cross_price = data.get("cross_price")
current_price = data.get("current_price", 0.0)
distance_pct = data.get("distance_pct", 0.0)
cross_idx = data.get("cross_idx")
macro_score = float(data.get("macro_score", 3))
mode = data.get("entry_mode", "N/A")
mode_detail = data.get("mode_detail", "")
rationale = data.get("rationale", "")
with st.container(border=True):
st.markdown("#### 3.1 — 价格距离计算")
st.caption("公式:Distance % = (TQQQ 当前价 - TQQQ 突破当日收盘价) / 突破当日收盘价 x 100")
if cross_price is not None:
col1, col2, col3 = st.columns(3)
col1.metric("TQQQ 突破当日收盘价", f"${cross_price:.2f}",
help=f"QQQ 突破日期:{cross_idx}")
col2.metric("TQQQ 当前价格", f"${current_price:.2f}")
col3.metric("距突破涨幅", f"{distance_pct:+.1f}%",
delta=f"{distance_pct:+.1f}%")
d_score = 1 if distance_pct <= 8 else (-1 if distance_pct <= 15 else 0)
render_score_card(
label="距离评估",
score=d_score,
metric=f"涨幅 = {distance_pct:+.1f}% | 突破日:{cross_idx} | 突破价:${cross_price:.2f} | 当前价:${current_price:.2f}",
verdict=("突破时间较近 — 直接买入条件有利。"
if distance_pct <= 8 else
"已大幅上涨 — 建议使用分批入场模式。"),
)
st.session_state["lrs_step3_distance_pct"] = distance_pct
st.session_state["lrs_step3_cross_price"] = cross_price
st.session_state["lrs_step3_current_price"] = current_price
else:
st.warning("未检测到 MA200 看涨突破,请先完成 Step 2 分析。")
distance_pct = 0.0
if is_fresh:
live = st.session_state.get(f"{_SKEY}_live_data")
if live is not None:
_render_distance_chart(live["tqqq_close"], live["cross_idx"],
cross_price, current_price, save=True)
else:
img = sc.figure_path("validation_chart")
if img:
st.image(img, use_container_width=True)
else:
st.info("点击「更新判断」生成图表。")
with st.container(border=True):
st.markdown("#### 3.2 — 入场模式判断")
st.caption(f"宏观评分(来自宏观经济分析 tab):**{macro_score:.0f} / 5**")
st.markdown("""
| Distance | Macro >=5 | Macro 3-4 | Macro 2 | Entry Mode |
|---|---|---|---|---|
| 0% – 4% | Direct Buy | Direct Buy | Direct Buy | Optimal |
| 4% – 8% | Direct Buy | Direct Buy | **Split** | Still good |
| 8% – 15% | **Split** | **Split** | **Split** | Partial buy + Sell Put |
| 15%+ | **Split** | **Split** | **Split** | Strongly recommended |
""")
_MODE_SCORE = {"Direct Buy": 1, "Split": -1,
"Split (strongly recommended)": -1, "Reassess": 0, "HOLD": 0}
render_score_card(label=f"入场模式:{mode}", score=_MODE_SCORE.get(mode, -1),
metric=mode_detail, verdict=rationale)
st.session_state["lrs_step3_entry_mode"] = mode
with st.container(border=True):
st.markdown("#### Step 3 输出记录")
cp_str = f"${cross_price:.2f}" if cross_price else "N/A"
st.markdown(f"""
| Field | Value |
|---|---|
| TQQQ 突破当日收盘价 | **{cp_str}** |
| TQQQ 当前价格 | **${current_price:.2f}** |
| 距突破涨幅 | **{distance_pct:+.1f}%** |
| 宏观评分(来自 Step 1 | **{macro_score:.0f} / 5** |
| 入场模式 | **{mode}** |
| 资金分配 | **{mode_detail}** |
""")
st.caption("以上结果已存入 session_state,Step 4 将自动读取。")
def render_tab_validation() -> None:
st.markdown("### Step 3 — 入场模式判断(价格距离评估)")
cached = sc.load(_CACHE_KEY)
col1, col2 = st.columns([4, 1])
with col1:
if cached:
st.caption(f"上次更新:{cached.get('_updated_at', '—')} | 点击「更新判断」获取最新数据")
else:
st.caption("暂无历史数据,请点击「更新判断」开始分析")
with col2:
update = st.button("🔄 更新判断", key=f"{_SKEY}_update",
type="primary", use_container_width=True)
if update:
st.session_state.pop(f"{_SKEY}_chart_ran", None)
try:
with st.spinner("正在获取 TQQQ / QQQ 数据…"):
_fetch_data.clear()
tqqq_df, qqq_df = _fetch_data()
except Exception as exc:
st.error(f"数据获取失败:{exc}")
return
tqqq_close = tqqq_df["Close"].dropna() if not tqqq_df.empty else pd.Series(dtype=float)
if tqqq_close.empty:
st.error("TQQQ 数据下载失败(yfinance 返回空数据),请稍后重试。")
return
current_price = float(tqqq_close.iloc[-1])
qqq_close_chk = qqq_df["Close"].dropna() if not qqq_df.empty else pd.Series(dtype=float)
if qqq_close_chk.empty:
st.error("QQQ 数据下载失败(yfinance 返回空数据),请稍后重试。")
return
cross_idx = _find_crossover(qqq_df)
cross_price = None
if cross_idx is not None:
nearest = tqqq_close.index[tqqq_close.index >= cross_idx]
if len(nearest) > 0:
cross_price = float(tqqq_close.loc[nearest[0]])
distance_pct = ((current_price - cross_price) / cross_price * 100
if cross_price is not None else 0.0)
macro_score = float(st.session_state.get("macro_score_total", 3))
mode, mode_detail, rationale = _entry_mode(distance_pct, macro_score)
sc.save(_CACHE_KEY, _to_cache(cross_idx, cross_price, current_price,
distance_pct, mode, mode_detail, rationale, macro_score))
st.session_state[f"{_SKEY}_live_data"] = {
"tqqq_close": tqqq_close, "cross_idx": cross_idx,
}
st.success(f"数据已更新 — Entry Mode: **{mode}**")
live = st.session_state.get(f"{_SKEY}_live_data")
if live is not None:
display_data = sc.load(_CACHE_KEY) or {}
is_fresh = True
elif cached:
display_data = cached
is_fresh = False
st.session_state["lrs_step3_entry_mode"] = cached.get("entry_mode", "")
st.session_state["lrs_step3_distance_pct"] = cached.get("distance_pct")
st.session_state["lrs_step3_current_price"] = cached.get("current_price")
else:
st.info("点击「更新判断」开始分析。")
return
_render_all(display_data, is_fresh=is_fresh)