Files
tick-stock-panel/backend/app/api/stock_analysis.py
T
Jinfeng SunandClaude Fable 5 b0d1f2f742 feat: 自选/个股分析/K线查询接入 ETF (#58)
* feat: 自选/个股分析/K线查询接入 ETF

数据层此前已完成 ETF 同步与存储 (instruments_etf / kline_etf_* /
adj_factor_etf), 但查询侧仍只走股票路径。本次把已有的 ETF 读取能力
接到面向用户的接口上:

- repository: 新增 get_etf_symbol_set / resolve_asset_type 按 symbol
  判定资产类型; get_minute / get_minute_batch / latest_minute_date
  增加 asset_type 参数切换 ETF 分钟K存储
- kline API: instruments/search 增加 asset_types 参数 (默认 stock,
  既有调用方行为不变), 结果附 asset_type; instruments/names 合并
  ETF 名称; /daily /minute /minute-batch 按资产类型分流
- watchlist API: /enriched 合并 ETF enriched 缓存行, 名称补齐支持 ETF
- 个股分析: /levels 与 AI 分析按资产类型分流, ETF 无财务数据走
  已有兜底提示
- 前端: 自选搜索框传 asset_types=stock,etf 并显示 ETF 标记

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: 修复 ETF 接入的审查发现问题

多智能体代码审查确认的 9 处问题修复:

- watchlist /enriched: 仅自选实际含 ETF 时才加载 ETF enriched 缓存,
  避免无 ETF 用户在缓存冷启动时触发全量懒加载阻塞请求; 恢复股票
  enriched 预热期间 all-or-nothing 旧契约 (不再返回只有 ETF 的部分结果);
  as_of 取股票/ETF 两类缓存中较旧者, 不再把旧 ETF 行标成股票缓存日期
- repository: refresh_cache 失效 _etf_enriched_cache, 盘后管道跑完
  ETF 行不再停留在旧日期; get_etf_symbol_set / get_index_symbol_set
  增加 memo (随 instruments 缓存失效), 热路径不再每请求重建全量集合;
  新增 get_name_map 统一股票+ETF 名称解析, 收敛三处重复合并逻辑
- kline /daily: 实时蜡烛注入改为资产感知, ETF 开启实时拉取时同样
  注入今日 bar (未开启时由"非今日不注入"守卫自然跳过)
- kline search: 空关键词早退提前到数据处理之前; symbol 列 dtype
  归一到 Utf8, 防两份缓存来源 dtype 不一致导致 concat SchemaError

已知限制 (不在本次范围): kline_etf_minute 目前无盘后同步写入方,
ETF 分时依赖"本地缺失 → TickFlow 实时补拉"路径, 与其他未同步分钟K
标的行为一致。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:26:40 +08:00

219 lines
7.4 KiB
Python

"""个股分析 API — 关键价位 + AI 四维分析 + 报告持久化。
路由前缀: /api/stock-analysis
端点:
GET /levels?symbol= 11 类关键价位(图表 markLine 数据源)
POST /analyze AI 流式四维分析(NDJSON)
GET /reports 历史报告列表
POST /reports 保存一条报告
DELETE /reports/{report_id} 删除一条报告
"""
from __future__ import annotations
import logging
import math
from datetime import date, timedelta
import polars as pl
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.indicators.levels import compute_levels, summarize_levels
from app.services import stock_reports
from app.services.stock_analyzer import analyze_stock_stream
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/stock-analysis", tags=["stock-analysis"])
def _to_float_list(series: pl.Series) -> list:
"""polars Series → JSON 安全的 float 列表(null/NaN → None)。"""
out: list = []
for v in series.to_list():
if v is None:
out.append(None)
continue
try:
f = float(v)
out.append(round(f, 2) if math.isfinite(f) else None)
except (TypeError, ValueError):
out.append(None)
return out
def _build_series(df: pl.DataFrame) -> dict:
"""提取带状指标(布林带 / Keltner通道 / ATR止损)的每日时间序列。
这些指标的本质是"每日一条线",随 MA/ATR/σ 漂移,画成曲线才能体现通道形态。
其余固定价位(枢轴/前高前低等)不在此,仍用水平 markLine。
返回结构(每个 value 都是按日期对齐的数组):
{
"boll": {"upper": [...], "lower": [...]},
"keltner_s": {"upper": [...], "lower": [...]}, # 短期 MA20±2ATR
"keltner_m": {"upper": [...], "lower": [...]}, # 中期 MA60±2.5ATR
"keltner_l": {"upper": [...], "lower": [...]}, # 长期 MA120±3ATR
"atr": {"stop_loss": [...], "take_profit": [...]}, # close∓2ATR
}
"""
if df.is_empty() or "close" not in df.columns:
return {}
out: dict[str, dict] = {}
close = df["close"]
has_atr = "atr_14" in df.columns
# 布林带(上/下/中轨;中轨 = MA20,数据层已预计算)
if "boll_upper" in df.columns and "boll_lower" in df.columns:
out["boll"] = {
"upper": _to_float_list(df["boll_upper"]),
"lower": _to_float_list(df["boll_lower"]),
"mid": _to_float_list(df["ma20"]) if "ma20" in df.columns else None,
}
# Keltner 通道三档(需要 ATR)
if has_atr:
atr = df["atr_14"]
# MA120 现场算(不在预计算列中)
ma120 = df.select(pl.col("close").rolling_mean(120))["close"] if df.height >= 120 else None
def _channel(ma: pl.Series, n: float) -> dict:
return {
"upper": _to_float_list(ma + n * atr),
"lower": _to_float_list(ma - n * atr),
}
if "ma20" in df.columns:
out["keltner_s"] = _channel(df["ma20"], 2.0)
if "ma60" in df.columns:
out["keltner_m"] = _channel(df["ma60"], 2.5)
if ma120 is not None:
out["keltner_l"] = _channel(ma120, 3.0)
# ATR 止损/止盈: close ± 2×ATR(跟随行情漂移的动态止损线)
out["atr"] = {
"stop_loss": _to_float_list(close - 2 * atr),
"take_profit": _to_float_list(close + 2 * atr),
}
return out
@router.get("/levels")
def get_levels(
request: Request,
symbol: str = Query(..., description="标的代码,如 000001.SZ"),
days: int = Query(120, ge=30, le=500, description="计算样本天数"),
):
"""计算 11 类关键价位(成交密集区压力支撑 / 枢轴点 / 前高前低 /
布林带 / Keltner短中长 / ATR止损 / 缺口 / 斐波那契 / 整数关口)。
返回 {levels: {sr, pivot, extreme, boll, keltner_s, keltner_m, keltner_l,
atr_stop, gap, fib, round}, close, summary, dates, series}。
前端按 levels 的 key 渲染开关按钮,逐组显隐 markLine / 曲线。
"""
if not symbol:
raise HTTPException(400, "symbol 不能为空")
repo = request.app.state.repo
end = date.today()
start = end - timedelta(days=days * 2)
# 按资产类型分流: ETF/指数走独立 enriched 存储, 股票保持原路径
df = repo.get_daily_asset(repo.resolve_asset_type(symbol), symbol, start, end)
if df.is_empty():
return {"levels": {"sr": [], "pivot": [], "extreme": [],
"boll": [], "keltner_s": [], "keltner_m": [], "keltner_l": [],
"atr_stop": [], "gap": [], "fib": [], "round": []},
"close": None, "summary": "无数据", "symbol": symbol,
"dates": [], "series": {}}
levels = compute_levels(df)
close = float(df.tail(1)["close"][0]) if "close" in df.columns else None
# 日期 + 带状曲线序列(供前端画 Keltner/ATR/布林带曲线)
dates = df["date"].to_list()
series = _build_series(df)
return {
"levels": levels,
"close": close,
"summary": summarize_levels(levels, close),
"symbol": symbol,
"dates": [str(d) for d in dates],
"series": series,
}
class AnalyzeRequest(BaseModel):
"""AI 个股分析请求。"""
symbol: str
focus: str = "" # 可选:用户追加的分析关注点
@router.post("/analyze")
async def analyze_stock(request: Request, req: AnalyzeRequest):
"""AI 个股四维分析 — NDJSON 流式返回。
组合 K 线(技术指标)+ 财务表 + 关键价位 → 实战派提示词 →
流式调用 LLM → 逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
"""
if not req.symbol:
raise HTTPException(400, "symbol 不能为空")
repo = request.app.state.repo
data_dir = repo.store.data_dir
async def stream_gen():
async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus):
yield chunk + "\n"
return StreamingResponse(
stream_gen(),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ================================================================
# 报告 CRUD(历史报告持久化)
# ================================================================
class SaveReportRequest(BaseModel):
"""保存一条 AI 个股分析报告。"""
symbol: str
name: str = ""
focus: str = ""
content: str
summary: str = ""
close: float | None = None
levels: dict | None = None
@router.get("/reports")
def list_reports(request: Request):
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。"""
return {"reports": stock_reports.list_reports()}
@router.post("/reports")
def save_report(request: Request, req: SaveReportRequest):
"""保存一条报告。"""
report = stock_reports.save_report({
"symbol": req.symbol,
"name": req.name,
"focus": req.focus,
"content": req.content,
"summary": req.summary,
"close": req.close,
"levels": req.levels,
})
return {"ok": True, "report": report}
@router.delete("/reports/{report_id}")
def delete_report(request: Request, report_id: str):
"""删除一条报告。"""
ok = stock_reports.delete_report(report_id)
return {"ok": ok}