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

401 lines
14 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
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 两层模型:
# - 第一层编排:thinkingMiniMax
# - 第二层翻译: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)