Files
tick-stock-panel/backend/app/api/stock_analysis.py
T
shy3130 2015ef0c78 feat: 个股分析(Beta) — 专用日K + 9类关键价位 + AI四维分析
新增个股分析页,以行情与关键价位为视觉主体,与财务分析(财务质量评级)定位互补:

后端:
- indicators/levels.py: 9 类关键价位纯函数计算
  · 压力支撑(布林带)/ 成交密集区(POC)/ 枢轴点(可配档位)/ 前高前低
  · Keltner通道(MA±n×ATR 短/中/长)/ ATR止损(close±nATR)
  · 缺口位(未回补跳空)/ 斐波那契回撤 / 整数关口(自适应步长)
- services/stock_analyzer.py: AI 四维(技术/基本面/财务/消息面)流式分析,NDJSON
- services/stock_reports.py: 报告持久化(最多50条),独立 JSON 存储
- api/stock_analysis.py: levels/analyze/reports 端点

前端:
- AnalysisKChart: 专用 ECharts 日K(主图+成交量+滑块),9 类价位开关
- 蓝色胶囊(Bubble/Host/Dialog),与财务分析紫色主题并存
- stockAnalysisStore: useSyncExternalStore 全局存储,后台流式
- useLastStock + LastStockChip: 记忆最近查看个股(财务/个股分析页)

其他优化:
- AI 设置页:一键清空配置(保留UA) + 保存后乐观更新连接状态/菜单
- MarkdownRenderer: 去除 space-y-0 遮蔽,各块间距与分隔线可见性优化
- 个股分析页 Beta 标识(标题 + 菜单)
- 版本号 0.1.45 → 0.1.50
2026-06-26 15:36:52 +08:00

134 lines
4.2 KiB
Python

"""个股分析 API — 关键价位 + AI 四维分析 + 报告持久化。
路由前缀: /api/stock-analysis
端点:
GET /levels?symbol= 4 类关键价位(图表 markLine 数据源)
POST /analyze AI 流式四维分析(NDJSON)
GET /reports 历史报告列表
POST /reports 保存一条报告
DELETE /reports/{report_id} 删除一条报告
"""
from __future__ import annotations
import logging
from datetime import date, timedelta
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"])
@router.get("/levels")
def get_levels(
request: Request,
symbol: str = Query(..., description="标的代码,如 000001.SZ"),
days: int = Query(120, ge=30, le=500, description="计算样本天数"),
):
"""计算 4 类关键价位(压力支撑 / 成交密集区 / 枢轴点 / 前高前低)。
返回 {levels: {sr, profile, pivot, extreme}, close, summary}。
前端按 levels 的 key 渲染开关按钮,逐组显隐 markLine。
"""
if not symbol:
raise HTTPException(400, "symbol 不能为空")
repo = request.app.state.repo
end = date.today()
start = end - timedelta(days=days * 2)
df = repo.get_daily(symbol, start, end)
if df.is_empty():
return {"levels": {"sr": [], "profile": [], "pivot": [], "extreme": [],
"keltner": [], "atr_stop": [], "gap": [], "fib": [], "round": []},
"close": None, "summary": "无数据", "symbol": symbol}
levels = compute_levels(df)
close = float(df.tail(1)["close"][0]) if "close" in df.columns else None
return {
"levels": levels,
"close": close,
"summary": summarize_levels(levels, close),
"symbol": symbol,
}
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}