401 lines
14 KiB
Python
401 lines
14 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
import threading
|
||
import uuid
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
from typing import Any
|
||
|
||
import numpy as np
|
||
import yfinance as yf
|
||
|
||
from web.services import backtesting_core as bt
|
||
from web.services import market_data as md
|
||
from web.services.x_brief_data import collect_x_rss_last_week, normalize_to_x_url, save_raw_payload
|
||
from web.services.x_brief_llm import build_fallback_modules, build_x_brief_modules, translate_display_content
|
||
from web.services.x_brief_renderer import (
|
||
build_monthly_analysis_markdown,
|
||
build_tweet_summary_markdown,
|
||
load_latest_view,
|
||
persist_outputs,
|
||
)
|
||
from web.pages.wheel.style_profiles import STYLE_PROFILES
|
||
from web.pages.wheel.strategy_shared import rsi
|
||
from web.services.dashboard_llm import build_llm_config
|
||
from web.services.strategy_api_service import answer_lrs_question, get_lrs_doc_markdown
|
||
|
||
|
||
DEFAULT_SESSION_STATE: dict[str, Any] = {
|
||
"current_page": "仪表盘",
|
||
"ui_mode": "idle",
|
||
"show_config": False,
|
||
"event_log": [],
|
||
"runtime_stage": "",
|
||
"latest_run_dir": "",
|
||
"last_params": None,
|
||
"selected_params_summary": "",
|
||
"active_output_dir": "",
|
||
"runtime_stats": {},
|
||
"selected_ticker": "QQQ",
|
||
"error": "",
|
||
"strategy_sub_menu": "LRS TQQQ策略",
|
||
}
|
||
|
||
|
||
def strategy_metadata() -> dict[str, Any]:
|
||
return {
|
||
"sub_strategies": ["LRS TQQQ策略", "Wheel策略"],
|
||
"lrs_doc_md": get_lrs_doc_markdown(),
|
||
}
|
||
|
||
|
||
def answer_lrs_chat(question: str, last_params: dict[str, Any] | None) -> str:
|
||
cfg = build_llm_config(last_params)
|
||
prompt = (
|
||
"你是量化策略研究助手。请围绕 LRS TQQQ 策略回答问题,"
|
||
"要求中文、结构化、可执行。\n\n用户问题:"
|
||
f"{question}"
|
||
)
|
||
return answer_lrs_question(prompt, cfg)
|
||
|
||
|
||
@dataclass
|
||
class BacktestJob:
|
||
job_id: str
|
||
period_key: str
|
||
status: str
|
||
created_at: str
|
||
result: dict[str, Any] | None = None
|
||
error: str | None = None
|
||
updated_at: str = field(default_factory=lambda: _now_iso())
|
||
|
||
|
||
def _now_iso() -> str:
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
_JOBS: dict[str, BacktestJob] = {}
|
||
_LOCK = threading.Lock()
|
||
|
||
|
||
def _run_backtest(period_key: str, job_id: str) -> None:
|
||
try:
|
||
qqq_df, tqqq_df = bt._fetch(period_key)
|
||
if qqq_df.empty or tqqq_df.empty:
|
||
raise RuntimeError("QQQ 或 TQQQ 数据为空")
|
||
qqq_full = qqq_df["Close"].squeeze().dropna()
|
||
tqqq_full = tqqq_df["Close"].squeeze().dropna()
|
||
if len(qqq_full) < 220:
|
||
raise RuntimeError(f"数据量不足({len(qqq_full)} 天),无法计算 MA200")
|
||
trade_start = bt._get_effective_start(qqq_full, tqqq_full, period_key)
|
||
qqq_eff = qqq_full[trade_start:]
|
||
sig = bt._build_signals(qqq_full)
|
||
trades, equity = bt._simulate(sig, tqqq_full, trade_start=trade_start)
|
||
metrics = bt._metrics(trades, equity, qqq_eff)
|
||
with _LOCK:
|
||
_JOBS[job_id].status = "completed"
|
||
_JOBS[job_id].updated_at = _now_iso()
|
||
_JOBS[job_id].result = {
|
||
"period_key": period_key,
|
||
"period_label": bt._PERIOD_SPECS[period_key]["label"],
|
||
"date_range": {
|
||
"start": str(qqq_eff.index[0].date()),
|
||
"end": str(qqq_eff.index[-1].date()),
|
||
},
|
||
"metrics": metrics,
|
||
"trades": [t.__dict__ for t in trades],
|
||
}
|
||
except Exception as exc: # pragma: no cover - best effort background task
|
||
with _LOCK:
|
||
_JOBS[job_id].status = "failed"
|
||
_JOBS[job_id].updated_at = _now_iso()
|
||
_JOBS[job_id].error = str(exc)
|
||
|
||
|
||
def create_backtest_job(period_key: str) -> BacktestJob:
|
||
job_id = uuid.uuid4().hex
|
||
job = BacktestJob(
|
||
job_id=job_id,
|
||
period_key=period_key,
|
||
status="running",
|
||
created_at=_now_iso(),
|
||
)
|
||
with _LOCK:
|
||
_JOBS[job_id] = job
|
||
th = threading.Thread(target=_run_backtest, args=(period_key, job_id), daemon=True)
|
||
th.start()
|
||
return job
|
||
|
||
|
||
def get_backtest_job(job_id: str) -> BacktestJob | None:
|
||
with _LOCK:
|
||
return _JOBS.get(job_id)
|
||
|
||
|
||
def _serialize_quote_row(row: Any) -> dict[str, Any]:
|
||
return {
|
||
"ticker": getattr(row, "ticker", ""),
|
||
"label": getattr(row, "label", ""),
|
||
"price": getattr(row, "price", None),
|
||
"prev_close": getattr(row, "prev_close", None),
|
||
"change": getattr(row, "change", None),
|
||
"change_pct": getattr(row, "change_pct", None),
|
||
}
|
||
|
||
|
||
def _serialize_snapshot(snapshot: dict[str, Any], news: list[dict[str, Any]] | None) -> dict[str, Any]:
|
||
top6_raw = snapshot.get("top6_sectors")
|
||
if top6_raw is None:
|
||
top6_raw = snapshot.get("top3_sectors", [])
|
||
return {
|
||
"market_status": snapshot.get("market_status", "N/A"),
|
||
"last_updated_et": snapshot.get("last_updated_et", "N/A"),
|
||
"fetched_at_utc": snapshot.get("fetched_at_utc", ""),
|
||
"llm_model": snapshot.get("llm_model", ""),
|
||
"llm_provider": snapshot.get("llm_provider", ""),
|
||
"llm_news_error": snapshot.get("llm_news_error", ""),
|
||
"llm_digest_error": snapshot.get("llm_digest_error", ""),
|
||
"market_digest_md": snapshot.get("market_digest_md", ""),
|
||
"indexes": [_serialize_quote_row(x) for x in snapshot.get("indexes", [])],
|
||
"top6_sectors": [_serialize_quote_row(x) for x in top6_raw],
|
||
"macro_strip": snapshot.get("macro_strip") or [],
|
||
"news": news or [],
|
||
}
|
||
|
||
|
||
def get_dashboard_snapshot() -> dict[str, Any]:
|
||
snapshot, news = md.load_dashboard_display()
|
||
if snapshot is None:
|
||
snapshot = md.get_dashboard_market_snapshot()
|
||
return _serialize_snapshot(snapshot, news)
|
||
|
||
|
||
def refresh_dashboard_snapshot(limit: int = 10) -> dict[str, Any]:
|
||
snapshot, news = md.refresh_dashboard_cache(limit=limit)
|
||
return _serialize_snapshot(snapshot, news)
|
||
|
||
|
||
def wheel_profiles() -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
for key, p in STYLE_PROFILES.items():
|
||
out.append(
|
||
{
|
||
"key": key,
|
||
"label": p.label,
|
||
"dte": p.dte,
|
||
"ivr_min": p.ivr_min,
|
||
"rsi_max": p.rsi_max,
|
||
"put_otm": p.put_otm,
|
||
"call_otm": p.call_otm,
|
||
"put_tp": p.put_tp,
|
||
"call_tp": p.call_tp,
|
||
"put_sl": p.put_sl,
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def _fetch_close(ticker: str) -> Any:
|
||
df = yf.download(ticker, period="6mo", auto_adjust=True, progress=False)
|
||
if df.empty:
|
||
raise RuntimeError(f"{ticker} 无可用行情")
|
||
close = df["Close"]
|
||
if hasattr(close, "columns"):
|
||
close = close.iloc[:, 0]
|
||
return close.dropna()
|
||
|
||
|
||
def _estimate_ivr(underlying_close: Any) -> float:
|
||
ret = underlying_close.pct_change().dropna()
|
||
if len(ret) < 20:
|
||
return 50.0
|
||
hv = ret.rolling(20).std() * np.sqrt(252)
|
||
iv = (hv * 1.15).clip(lower=0.1, upper=2.5)
|
||
iv_min = iv.rolling(126, min_periods=20).min()
|
||
iv_max = iv.rolling(126, min_periods=20).max()
|
||
ivr = ((iv - iv_min) / (iv_max - iv_min).replace(0, np.nan) * 100.0).fillna(50.0)
|
||
return float(ivr.iloc[-1])
|
||
|
||
|
||
def wheel_evaluate(style_key: str, underlying_ticker: str, signal_ticker: str) -> dict[str, Any]:
|
||
if style_key not in STYLE_PROFILES:
|
||
raise RuntimeError(f"未知风格:{style_key}")
|
||
style = STYLE_PROFILES[style_key]
|
||
ul = _fetch_close(underlying_ticker.upper())
|
||
sig = _fetch_close(signal_ticker.upper())
|
||
rs = rsi(sig, 14).fillna(50.0)
|
||
latest_ul = float(ul.iloc[-1])
|
||
latest_sig = float(sig.iloc[-1])
|
||
latest_rsi = float(rs.iloc[-1])
|
||
latest_ivr = _estimate_ivr(ul)
|
||
|
||
put_strike = round(latest_ul * (1.0 - style.put_otm), 2)
|
||
call_strike = round(latest_ul * (1.0 + style.call_otm), 2)
|
||
put_entry_ok = latest_ivr >= style.ivr_min and latest_rsi <= style.rsi_max
|
||
regime = "bullish" if latest_sig >= float(sig.iloc[-20:].mean()) else "neutral_to_weak"
|
||
if put_entry_ok:
|
||
action = f"可考虑卖出现金担保Put(行权价约 {put_strike})"
|
||
elif regime == "bullish":
|
||
action = "等待更优波动率窗口(IVR 偏低),暂不卖Put"
|
||
else:
|
||
action = "信号偏弱,优先观望或缩小仓位"
|
||
|
||
return {
|
||
"style": {
|
||
"key": style.key,
|
||
"label": style.label,
|
||
"dte": style.dte,
|
||
"ivr_min": style.ivr_min,
|
||
"rsi_max": style.rsi_max,
|
||
"put_otm": style.put_otm,
|
||
"call_otm": style.call_otm,
|
||
"put_tp": style.put_tp,
|
||
"call_tp": style.call_tp,
|
||
"put_sl": style.put_sl,
|
||
},
|
||
"signal": {
|
||
"underlying_ticker": underlying_ticker.upper(),
|
||
"signal_ticker": signal_ticker.upper(),
|
||
"underlying_price": latest_ul,
|
||
"signal_price": latest_sig,
|
||
"rsi14": latest_rsi,
|
||
"ivr_est": latest_ivr,
|
||
"put_entry_ok": put_entry_ok,
|
||
"regime": regime,
|
||
},
|
||
"execution_hint": {
|
||
"suggested_action": action,
|
||
"put_strike_hint": put_strike,
|
||
"call_strike_hint": call_strike,
|
||
"dte_hint": style.dte,
|
||
"take_profit": {
|
||
"put": style.put_tp,
|
||
"call": style.call_tp,
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
def _normalize_quote_code(code: str) -> str:
|
||
c = (code or "").strip().upper()
|
||
if not c:
|
||
return ""
|
||
if c.startswith("US.."):
|
||
return f"^{c[4:]}"
|
||
if c.startswith("US."):
|
||
return c[3:]
|
||
return c
|
||
|
||
|
||
def batch_quotes(codes: list[str]) -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
for raw in codes:
|
||
normalized = _normalize_quote_code(raw)
|
||
if not normalized:
|
||
continue
|
||
q = md._quote(normalized, normalized)
|
||
out.append(
|
||
{
|
||
"request_code": raw,
|
||
"normalized_code": normalized,
|
||
"ticker": q.ticker,
|
||
"label": q.label,
|
||
"price": q.price,
|
||
"prev_close": q.prev_close,
|
||
"change": q.change,
|
||
"change_pct": q.change_pct,
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def get_x_brief_latest() -> dict[str, Any] | None:
|
||
return load_latest_view()
|
||
|
||
|
||
def refresh_x_brief(days: int = 7, per_account_limit: int = 80) -> dict[str, Any]:
|
||
payload = collect_x_rss_last_week(days=days, per_account_limit=per_account_limit)
|
||
cfg = build_llm_config(None)
|
||
# X Brief 两层模型:
|
||
# - 第一层编排:thinking(MiniMax)
|
||
# - 第二层翻译:quick(沿用 quick_think_llm)
|
||
cfg["xbrief_orchestrator_provider"] = os.environ.get("XBRIEF_ORCHESTRATOR_PROVIDER", "siliconflow")
|
||
cfg["xbrief_orchestrator_model"] = os.environ.get("XBRIEF_ORCHESTRATOR_MODEL", "Pro/MiniMaxAI/MiniMax-M2.5")
|
||
cfg["xbrief_orchestrator_base_url"] = os.environ.get("XBRIEF_ORCHESTRATOR_BASE_URL", "https://api.siliconflow.cn/v1")
|
||
cfg["xbrief_translate_provider"] = cfg.get("llm_provider")
|
||
cfg["xbrief_translate_model"] = cfg.get("quick_think_llm")
|
||
cfg["xbrief_translate_base_url"] = cfg.get("backend_url")
|
||
save_raw_payload(payload)
|
||
tweet_md = build_tweet_summary_markdown(payload)
|
||
modules: dict[str, Any]
|
||
try:
|
||
modules = build_x_brief_modules(payload, tweet_md, cfg)
|
||
except Exception as exc:
|
||
modules = build_fallback_modules(payload)
|
||
modules["risk_signals"] = modules.get("risk_signals", []) + [
|
||
{
|
||
"id": "llm-fallback-error",
|
||
"level": "MID",
|
||
"title": "LLM 编排暂不可用",
|
||
"detail": f"本次使用规则兜底:{exc}",
|
||
}
|
||
]
|
||
if not any(modules.get(k) for k in ("themes", "p0_events", "top_quotes", "category_updates", "risk_signals")):
|
||
modules = build_fallback_modules(payload)
|
||
|
||
# 最终落盘前统一做一遍 URL 规范化,避免历史链路残留 nitter 链接。
|
||
tweets = payload.get("tweets", []) or []
|
||
norm_tweets: list[dict[str, Any]] = []
|
||
for t in tweets:
|
||
d = dict(t)
|
||
d["url"] = normalize_to_x_url(str(d.get("url", "")), str(d.get("handle", "")))
|
||
norm_tweets.append(d)
|
||
payload["tweets"] = norm_tweets
|
||
|
||
for theme in modules.get("themes", []) or []:
|
||
for sample in theme.get("samples", []) or []:
|
||
sample["url"] = normalize_to_x_url(str(sample.get("url", "")), str(sample.get("handle", "")))
|
||
for e in modules.get("p0_events", []) or []:
|
||
e["url"] = normalize_to_x_url(str(e.get("url", "")))
|
||
for q in modules.get("top_quotes", []) or []:
|
||
speaker = str(q.get("speaker", "")).lstrip("@")
|
||
q["url"] = normalize_to_x_url(str(q.get("url", "")), speaker)
|
||
for c in modules.get("category_updates", []) or []:
|
||
for item in c.get("items", []) or []:
|
||
item["url"] = normalize_to_x_url(str(item.get("url", "")))
|
||
|
||
try:
|
||
payload = translate_display_content(payload, modules, cfg)
|
||
except Exception as exc:
|
||
modules["risk_signals"] = (modules.get("risk_signals") or []) + [
|
||
{
|
||
"id": "llm-translate-fallback-error",
|
||
"level": "MID",
|
||
"title": "前端内容翻译层异常",
|
||
"detail": f"本次展示内容使用原文兜底:{exc}",
|
||
}
|
||
]
|
||
analysis_md = build_monthly_analysis_markdown(payload)
|
||
|
||
# 统计 modules 实际引用的唯一推文数,写入 overview
|
||
_kept_urls: set[str] = set()
|
||
for _theme in modules.get("themes", []) or []:
|
||
for _s in _theme.get("samples", []) or []:
|
||
if _s.get("url"):
|
||
_kept_urls.add(_s["url"])
|
||
for _e in modules.get("p0_events", []) or []:
|
||
if _e.get("url"):
|
||
_kept_urls.add(_e["url"])
|
||
for _q in modules.get("top_quotes", []) or []:
|
||
if _q.get("url"):
|
||
_kept_urls.add(_q["url"])
|
||
overview = dict(payload.get("overview") or {})
|
||
overview["tweets_kept"] = len(_kept_urls)
|
||
payload = dict(payload)
|
||
payload["overview"] = overview
|
||
|
||
return persist_outputs(payload, tweet_md, analysis_md, modules=modules)
|
||
|