diff --git a/README.md b/README.md index 9deee2a..cb658dd 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ | 配置项 | 说明 | 是否必填 | | :--- | :--- | :--- | | **TickFlow API Key** | 数据源凭证,留空启用 None 模式,获取免费key后开启free模式可定制策略+回测 | 可选 | -| **AI 大模型 API Key** | 用于 AI 生成策略、个股分析(开发中)、行情分析(开发中)等,任意 OpenAI 兼容接口,留空关闭 | 可选 | +| **AI 大模型 API Key** | 用于 AI 生成策略、个股分析、财务分析等,任意 OpenAI 兼容接口,留空关闭 | 可选 | @@ -143,6 +143,29 @@ - **沙箱约束**:生成代码经 `ast` 校验、限定 `import polars as pl`,避免逐行循环,优先向量化表达 - **可插拔**:留空 AI 配置即跳过整个模块,不影响核心功能 +### 📈 个股分析(Beta) + +**以「行情 + 关键价位」为视觉主体的单标的决策页**,与「财务分析」(财务质量评级)定位互补: + +- **专用日 K 图表**:不复用行情浏览图表,主图 + 成交量 + 滑块三段布局,默认展示近 6 个月,9 类关键价位可逐组开关 +- **9 类关键价位**(均纯函数实时计算,毫秒级): + +| 类型 | 算法 | 说明 | +| :--- | :--- | :--- | +| 压力支撑 | 布林带上下轨 | 近期波动边界 | +| 成交密集区 | 成交量分布 POC + 高成交带 | 筹码密集价位 | +| 枢轴点 | 经典 Pivot P/R1~R3/S1~S3 | 可配档位(1~3 档) | +| 前高前低 | 60/250 日极值 + swing 高低点 | 历史转折参照 | +| Keltner 通道 | MA20/60/120 ± n×ATR(短/中/长) | 波动自适应趋势边界 | +| ATR 止损 | close ± 1.5/2×ATR | 动态止盈止损位 | +| 缺口位 | 近 120 日未回补跳空缺口 | 天然支撑/阻力 | +| 斐波那契 | 近期波段 0.236~0.786 回撤 | 经典回撤位 | +| 整数关口 | 当前价附近心理整数位 | 自适应步长 | + +- **AI 四维分析**:技术面 / 基本面 / 财务面 / 消息面流式生成,NDJSON 推送,「实战派交易员」视角输出买卖区间与操作建议 +- **蓝色胶囊**:与财务分析(紫色)并存的全局气泡,支持最小化后台生成、复制全文、历史报告(最多 50 条) +- **记忆最近查看**:进入页面自动回显上次查看的个股 + ### 🧰 数据与扩展 - **多源数据**:TickFlow 日 K / 分钟 K / 指数 / 财务(利润 / 资产负债 / 现金流)/ 自选行情 @@ -317,6 +340,7 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录 | **3** | vectorbt 回测 + T+1 + 手续费 + 止损 + max-hold | ✅ | | **4** | 监控引擎 + 告警规则 + Webhook + APScheduler 盘后定时 | ✅ | | **5** | 统一监控中心 + 四类监控规则 + 实时推送 + 持久化触发记录 + 声效通知 | ✅ | +| **6** | 个股分析(专用日 K + 9 类关键价位 + AI 四维分析 + 报告持久化) | ✅ | | **v2** | Webhook 推送(QMT/掘金下单) · 板块异动 · 早晚报 · 更多扩展 | 🚧 | --- diff --git a/backend/app/__init__.py b/backend/app/__init__.py index b2c6a87..f8df33d 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -2,7 +2,7 @@ import sys -__version__ = "0.1.45" +__version__ = "0.1.50" # Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的 # 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。 diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 1e0700a..4157fbe 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -246,6 +246,24 @@ def save_ai_settings(req: AiSettingsIn) -> dict: return {"ok": True} +@router.delete("/ai") +def clear_ai_settings() -> dict: + """一键清空 AI 配置(provider / base_url / api_key / model)。 + + 保留 ai_user_agent —— 自定义请求头与凭证解耦,清空凭证不影响绕过 CDN 拦截的设置。 + """ + from app.config import settings + + secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model") + # 同步重置运行时内存(provider 回默认值,其余置空) + settings.ai_provider = "openai_compat" + settings.ai_base_url = "" + settings.ai_api_key = "" + settings.ai_model = "" + + return {"ok": True} + + # ===== 偏好设置 ===== def _realtime_allowed() -> bool: diff --git a/backend/app/api/stock_analysis.py b/backend/app/api/stock_analysis.py new file mode 100644 index 0000000..d6791a5 --- /dev/null +++ b/backend/app/api/stock_analysis.py @@ -0,0 +1,133 @@ +"""个股分析 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} diff --git a/backend/app/indicators/levels.py b/backend/app/indicators/levels.py new file mode 100644 index 0000000..4af1bdd --- /dev/null +++ b/backend/app/indicators/levels.py @@ -0,0 +1,580 @@ +"""关键价位计算 —— 独立模块,纯函数,无 IO / 无存储。 + +输入: 已经包含 OHLCV 的 polars 日 K DataFrame(内存中,通常来自 KlineRepository 缓存)。 +输出: 4 类结构化价位点,供: + - 图表 markLine 渲染(压力位 / 支撑位 / 成交密集区 / 枢轴点 / 前高前低) + - AI 个股分析提示词(价位上下文) + +设计: + - 纯函数 + polars 向量化,毫秒级,无需落盘。 + - 每个点位带 {value, label, type, side, strength?},前端直接画水平价格线。 + - NaN/Inf 全部过滤,空数据返回空列表,不抛异常。 +""" +from __future__ import annotations + +import logging +from typing import Any + +import polars as pl + +logger = logging.getLogger(__name__) + + +# ================================================================ +# 输出结构 +# ================================================================ + +class PriceLevel: + """单个价位点的数据结构(用 dict 表达,这里只作文档说明)。 + + { + "value": 12.34, # 价格 + "label": "压力位 R1", # 显示标签 + "type": "pivot", # 类型分组(同类型用一个开关按钮控制显隐) + "side": "resistance", # 方向:resistance(压力) / support(支撑) / neutral + "strength": "medium", # 强度:strong / medium / weak(可选,影响线型) + "rank": 1, # 档位(仅 pivot 有):0=P,1=R1/S1,2=R2/S2,3=R3/S3 + # 前端按"显示到第几档"过滤,非 pivot 点位无此字段 + } + """ + + +# 价位分组 → 开关 key。前端按这个 type 显隐。 +LEVEL_TYPES = { + "sr": "压力支撑", # 布林带 + swing 高低点 + "profile": "成交密集区", # 成交量分布 POC + 密集区 + "pivot": "枢轴点", # 经典 Pivot P/R/S + "extreme": "前高前低", # 60/120/250 日极值 + "keltner": "Keltner通道", # 短/中/长三档 MA±n×ATR + "atr_stop": "ATR止损", # close±nATR 动态止盈止损 + "gap": "缺口位", # 未回补跳空缺口 + "fib": "斐波那契", # 回撤位 0.236~0.786 + "round": "整数关口", # 心理整数位 +} + + +# ================================================================ +# 1. 压力位 / 支撑位 —— 布林带上下轨 + 局部 swing 高低点 +# ================================================================ + +def _support_resistance(df: pl.DataFrame) -> list[dict]: + """布林带上下轨(压力/支撑带)。 + + 本组只放"通道型"压力支撑 —— 即布林带的上下轨,代表近期波动边界。 + 局部 swing 高低点 / 前高前低 等点位归到 extreme 组,避免与本组重叠。 + """ + if df.is_empty() or df.height < 20: + return [] + + out: list[dict] = [] + last = df.tail(1) + + if "boll_upper" in df.columns: + bu = last["boll_upper"][0] + if _ok(bu): + out.append({"value": round(float(bu), 2), "label": "压力位(布林上轨)", + "type": "sr", "side": "resistance", "strength": "medium"}) + if "boll_lower" in df.columns: + bl = last["boll_lower"][0] + if _ok(bl): + out.append({"value": round(float(bl), 2), "label": "支撑位(布林下轨)", + "type": "sr", "side": "support", "strength": "medium"}) + + return out + + +# ================================================================ +# 2. 成交密集区 —— 成交量分布 (Volume Profile) +# ================================================================ + +def _volume_profile(df: pl.DataFrame, bins: int = 40) -> list[dict]: + """按价格分桶统计成交量,找 POC(控制点)+ 高成交密集区。 + + 密集区 = 成交量高于均值的桶,按成交量降序取前 3 个作为关键价位带。 + """ + if df.is_empty() or "volume" not in df.columns or df.height < 20: + return [] + + hi = float(df["high"].max()) + lo = float(df["low"].min()) + if not (hi > lo > 0): + return [] + + # 每根 K 的价格区间中点 × 成交量 ≈ 该价位层贡献的成交量(简化模型) + df2 = df.select([ + ((pl.col("high") + pl.col("low")) / 2).alias("mid"), + pl.col("volume").alias("vol"), + ]).drop_nulls() + + # 桶边界:bins 个桶需要 bins-1 个内部 break,cut 据此切成 bins 段 + step = (hi - lo) / bins + edges = [lo + i * step for i in range(bins + 1)] # 含首尾,共 bins+1 个边界值 + breaks = edges[1:-1] # 内部 break,bins-1 个 + bin_labels = [f"{i}" for i in range(bins)] # 桶序号 0..bins-1 + # 至少要有 1 个不同的内部 break + if len(set(f"{b:.6f}" for b in breaks)) < 1: + return [] + + df2 = df2.with_columns( + pl.col("mid").cut(breaks, labels=bin_labels).alias("bin") + ) + prof = df2.group_by("bin").agg(pl.col("vol").sum()) + if prof.is_empty(): + return [] + + # 把桶序号字符串还原为 int,以便回查 edges;并按序号排序保证可索引 + prof = prof.with_columns(pl.col("bin").cast(pl.Int64).alias("bi")).sort("bi") + bin_ids = prof["bi"].to_list() + vols = prof["vol"].to_list() + mean_vol = sum(vols) / len(vols) if vols else 0 + + def bin_mid(bin_id: int) -> float: + return (edges[bin_id] + edges[bin_id + 1]) / 2 + + close = float(df.tail(1)["close"][0]) + + out: list[dict] = [] + # POC:成交量最大的桶 + poc_pos = max(range(len(vols)), key=lambda i: vols[i]) + poc_mid = bin_mid(bin_ids[poc_pos]) + out.append({"value": round(poc_mid, 2), "label": "成交密集区(POC)", + "type": "profile", "side": _side(poc_mid, close), "strength": "strong"}) + + # 其他高成交区(高于均值,排除 POC),按成交量降序取 2 个 + candidates = [(i, v) for i, v in enumerate(vols) if v > mean_vol and i != poc_pos] + candidates.sort(key=lambda x: x[1], reverse=True) + for i, _v in candidates[:2]: + mid = bin_mid(bin_ids[i]) + out.append({"value": round(mid, 2), "label": "成交密集区", + "type": "profile", "side": _side(mid, close), "strength": "medium"}) + return out + + +# ================================================================ +# 3. 枢轴点 (Pivot Point) —— 经典公式,基于最近完整交易日 +# ================================================================ + +def _pivot_points(df: pl.DataFrame) -> list[dict]: + """经典 Pivot:P = (H+L+C)/3, R1/R2/R3, S1/S2/S3。 + + 基准:最后 1 根 K(代表"上一交易日")。实务中常用前一日,这里取最后一根。 + """ + if df.is_empty(): + return [] + last = df.tail(1) + h = last["high"][0] + l = last["low"][0] + c = last["close"][0] + if not _ok(h) or not _ok(l) or not _ok(c): + return [] + + h, l, c = float(h), float(l), float(c) + p = (h + l + c) / 3 + r1 = 2 * p - l + s1 = 2 * p - h + r2 = p + (h - l) + s2 = p - (h - l) + r3 = h + 2 * (p - l) + s3 = l - 2 * (h - p) + + def lv(v: float, label: str, side: str, strength: str, rank: int) -> dict: + # rank:档位标记,前端据此按"显示到第几档"过滤 + # 0 = 枢轴位 P(始终显示) + # 1 = R1/S1(第一档压力/支撑) + # 2 = R2/S2(第二档) + # 3 = R3/S3(第三档,极端,实际很少触及) + return {"value": round(v, 2), "label": label, "type": "pivot", + "side": side, "strength": strength, "rank": rank} + + return [ + lv(p, "枢轴位 P", "neutral", "strong", 0), + lv(r1, "压力位 R1", "resistance", "medium", 1), + lv(r2, "压力位 R2", "resistance", "medium", 2), + lv(r3, "压力位 R3", "resistance", "weak", 3), + lv(s1, "支撑位 S1", "support", "medium", 1), + lv(s2, "支撑位 S2", "support", "medium", 2), + lv(s3, "支撑位 S3", "support", "weak", 3), + ] + + +# ================================================================ +# 4. 前高 / 前低 —— 60 / 120 / 250 日极值 +# ================================================================ + +def _extreme_levels(df: pl.DataFrame) -> list[dict]: + """关键前高 / 前低 —— 历史极值 + 近期 swing 高低点(收敛后)。 + + 设计:把所有"前高前低"类点位集中在本组,与 sr(通道)区分: + - 60 日极值:近一季度高低点(短期参照) + - 250 日极值:年度高低点(牛熊分界参照);跳过 120 日(被 250 日包含,信息冗余) + - swing 高低点:近期局部转折点,每侧只取距当前价最近的 2 个 + """ + if df.is_empty(): + return [] + close = float(df.tail(1)["close"][0]) if "close" in df.columns else None + out: list[dict] = [] + + # —— 历史极值(只取 60 / 250,避免中间档冗余)—— + for n in (60, 250): + if df.height < n: + continue + sub = df.tail(n) + hi = float(sub["high"].max()) + lo = float(sub["low"].min()) + if _ok(hi): + out.append({"value": round(hi, 2), "label": f"{n}日新高", + "type": "extreme", "side": "resistance", "strength": "strong"}) + if _ok(lo): + out.append({"value": round(lo, 2), "label": f"{n}日新低", + "type": "extreme", "side": "support", "strength": "strong"}) + + # —— 近期 swing 高低点(每侧只取距当前价最近的 2 个,避免点位爆炸)—— + win = 5 + if df.height > win * 2 and close: + highs = df["high"].to_list() + lows = df["low"].to_list() + swing_highs: list[float] = [] + swing_lows: list[float] = [] + for i in range(win, len(highs) - win): + if highs[i] == max(highs[i - win:i + win + 1]): + swing_highs.append(float(highs[i])) + if lows[i] == min(lows[i - win:i + win + 1]): + swing_lows.append(float(lows[i])) + + # 聚合 ±1% 相近价位,再按距当前价排序取最近 2 个 + agg_h = _aggregate_levels(swing_highs, 0.01) + agg_h = [v for v in agg_h if v > close * 1.001] + agg_h.sort(key=lambda v: abs(v - close)) + for v in agg_h[:2]: + out.append({"value": round(v, 2), "label": "前高", + "type": "extreme", "side": "resistance", "strength": "medium"}) + + agg_l = _aggregate_levels(swing_lows, 0.01) + agg_l = [v for v in agg_l if v < close * 0.999] + agg_l.sort(key=lambda v: abs(v - close)) + for v in agg_l[:2]: + out.append({"value": round(v, 2), "label": "前低", + "type": "extreme", "side": "support", "strength": "medium"}) + + return out + + +# ================================================================ +# 5. Keltner 通道 —— MA ± n × ATR,短/中/长三档 +# ================================================================ + +def _keltner_channels(df: pl.DataFrame) -> list[dict]: + """三档 Keltner 通道(波动自适应边界)。 + + 基于 ATR 的通道:均线 ± n×ATR。ATR 自适应波动,通道宽度随行情自动收缩/扩张, + 比布林带(基于标准差)更稳定,实战常用作趋势边界与突破参照。 + + 三档: + - 短期:MA20 ± 2×ATR (近期波动带,约一个月) + - 中期:MA60 ± 2.5×ATR (季度波动带) + - 长期:MA120 ± 3×ATR (半年波动带,牛熊趋势边界) + """ + if df.is_empty() or df.height < 20: + return [] + out: list[dict] = [] + last = df.tail(1) + close = float(last["close"][0]) if "close" in df.columns else 0 + if not close: + return [] + + # ATR 列由 compute_indicators 生成(atr_14);缺失则跳过 + if "atr_14" not in df.columns: + return [] + atr = float(last["atr_14"][0]) + if not _ok(atr): + return [] + + def _band(ma_col: str | None, n: int, label_short: str, window: int) -> None: + """单档通道:优先取预计算列,缺失则现场 rolling_mean。""" + ma_val: float | None = None + if ma_col and ma_col in df.columns: + v = last[ma_col][0] + ma_val = float(v) if _ok(v) else None + elif df.height >= window: + # ma120 等未预计算列:现场算 + v = df.select(pl.col("close").rolling_mean(window)).tail(1)["close"][0] + ma_val = float(v) if _ok(v) else None + if ma_val is None: + return + upper = ma_val + n * atr + lower = ma_val - n * atr + out.append({"value": round(upper, 2), "label": f"{label_short}通道上轨", + "type": "keltner", "side": _side(upper, close), "strength": "medium"}) + out.append({"value": round(lower, 2), "label": f"{label_short}通道下轨", + "type": "keltner", "side": _side(lower, close), "strength": "medium"}) + + _band("ma20", 2, "短期", 20) + _band("ma60", 2.5, "中期", 60) + _band(None, 3, "长期", 120) # ma120 未预计算,现场 rolling_mean + return out + + +# ================================================================ +# 6. ATR 止损位 —— close ± n × ATR,动态止盈止损 +# ================================================================ + +def _atr_stops(df: pl.DataFrame) -> list[dict]: + """基于 ATR 的动态止损/止盈位。 + + ATR 衡量平均真实波幅,close ± n×ATR 是交易者最常用的止损位算法: + - 止损位:close - 2×ATR (跌破即趋势破坏) + - 止盈位:close + 2×ATR (突破即顺势扩展) + - 近端波动带:close ± 1.5×ATR (中短期风控参考) + """ + if df.is_empty() or "atr_14" not in df.columns: + return [] + last = df.tail(1) + close = float(last["close"][0]) + atr = float(last["atr_14"][0]) + if not _ok(close) or not _ok(atr): + return [] + + def lv(v: float, label: str, side: str, strength: str) -> dict: + return {"value": round(v, 2), "label": label, "type": "atr_stop", + "side": side, "strength": strength} + + return [ + lv(close + 2 * atr, "ATR 止盈(+2)", "resistance", "medium"), + lv(close + 1.5 * atr, "ATR 上轨(+1.5)", "resistance", "weak"), + lv(close - 1.5 * atr, "ATR 下轨(-1.5)", "support", "weak"), + lv(close - 2 * atr, "ATR 止损(-2)", "support", "medium"), + ] + + +# ================================================================ +# 7. 缺口位 (Gap) —— 未回补的跳空缺口 +# ================================================================ + +def _gap_levels(df: pl.DataFrame, lookback: int = 120) -> list[dict]: + """近期未回补的向上/向下跳空缺口。 + + 向上缺口:当日 low > 前日 high(开盘跳空高开,全天未回补) + 向下缺口:当日 high < 前日 low(开盘跳空低开,全天未回补) + + 缺口是天然的支撑/阻力位。只保留"未回补"的(后续价格未回到缺口区间内), + 并按价格聚合相近缺口(±0.5%),每方向只取距当前价最近的 2~3 个。 + """ + if df.is_empty() or df.height < 5: + return [] + sub = df.tail(lookback) if df.height > lookback else df + close = float(df.tail(1)["close"][0]) + highs = sub["high"].to_list() + lows = sub["low"].to_list() + + up_gaps: list[tuple[float, float]] = [] # (缺口低点, 缺口高点) + dn_gaps: list[tuple[float, float]] = [] + for i in range(1, len(highs)): + if _ok(highs[i]) and _ok(lows[i]) and _ok(highs[i - 1]) and _ok(lows[i - 1]): + if lows[i] > highs[i - 1]: # 向上缺口 + up_gaps.append((highs[i - 1], lows[i])) + elif highs[i] < lows[i - 1]: # 向下缺口 + dn_gaps.append((highs[i], lows[i - 1])) + + def _filter_unfilled(gaps: list[tuple[float, float]], is_up: bool) -> list[float]: + """过滤掉已被后续价格回补的缺口,取缺口价位中点。""" + mids: list[float] = [] + for g_lo, g_hi in gaps: + # 未回补判定:当前价不在缺口区间内 + if is_up and close >= g_hi: # 向上缺口:价格已超过缺口上沿 = 未回补(站在缺口上方) + mids.append((g_lo + g_hi) / 2) + elif not is_up and close <= g_lo: # 向下缺口:价格已低于缺口下沿 = 未回补 + mids.append((g_lo + g_hi) / 2) + # 聚合相近缺口 + 按距当前价排序取最近 3 个 + agg = _aggregate_levels(mids, 0.005) + agg.sort(key=lambda v: abs(v - close)) + return agg[:3] + + out: list[dict] = [] + for mid in _filter_unfilled(up_gaps, True): + out.append({"value": round(mid, 2), "label": "向上缺口", + "type": "gap", "side": _side(mid, close), "strength": "medium"}) + for mid in _filter_unfilled(dn_gaps, False): + out.append({"value": round(mid, 2), "label": "向下缺口", + "type": "gap", "side": _side(mid, close), "strength": "medium"}) + return out + + +# ================================================================ +# 8. 斐波那契回撤 —— 基于近期波段的回撤位 +# ================================================================ + +def _fibonacci_levels(df: pl.DataFrame, window: int = 120) -> list[dict]: + """基于近期一段明确趋势的斐波那契回撤位。 + + 取近 window 个交易日的最高/最低点: + - 若高点出现在低点之后(上涨波段):从低到高,回撤 = high - range × ratio + - 若低点出现在高点之后(下跌波段):从高到低,回撤 = low + range × ratio + 比率:0.236 / 0.382 / 0.5 / 0.618 / 0.786 + """ + if df.is_empty() or df.height < 10: + return [] + sub = df.tail(window) if df.height > window else df + close = float(df.tail(1)["close"][0]) + + highs = sub["high"].to_list() + lows = sub["low"].to_list() + hi_pos = highs.index(max(highs)) + lo_pos = lows.index(min(lows)) + hi_val = float(highs[hi_pos]) + lo_val = float(lows[lo_pos]) + if not _ok(hi_val) or not _ok(lo_val) or hi_val <= lo_val: + return [] + + ratios = [0.236, 0.382, 0.5, 0.618, 0.786] + rng = hi_val - lo_val + + out: list[dict] = [] + # 判断波段方向:高点在低点之后 = 上涨波段(从低回撤) + up_trend = hi_pos > lo_pos + for r in ratios: + if up_trend: + val = hi_val - rng * r # 从高点向下回撤 + else: + val = lo_val + rng * r # 从低点向上回撤 + out.append({"value": round(val, 2), "label": f"Fib {int(r * 1000) / 10:.1f}%", + "type": "fib", "side": _side(val, close), "strength": "medium"}) + return out + + +# ================================================================ +# 9. 整数关口 —— 心理支撑/阻力位 +# ================================================================ + +def _round_numbers(df: pl.DataFrame, pct: float = 0.10, max_count: int = 8) -> list[dict]: + """当前价附近的心理整数关口。 + + 整数位(如 10/11/12元,或 60/65/70元)是天然的心理支撑/阻力, + 低价股尤其明显。按价格量级自适应步长: + - 价格 < 10: 步长 0.5 (如 6.5, 7.0, 7.5) + - 价格 < 20: 步长 1 (如 11, 12, 13) + - 价格 < 100: 步长 5 (如 60, 65, 70) + - 价格 < 500: 步长 10 (如 110, 120, 130) + - 价格 >= 500: 步长 50 (如 1100, 1150, 1200) + 过滤掉距当前价 <1% 的(太近,无分析价值),最多 max_count 个。 + """ + if df.is_empty(): + return [] + close = float(df.tail(1)["close"][0]) + if not _ok(close): + return [] + + if close < 10: + step = 0.5 + elif close < 20: + step = 1.0 + elif close < 100: + step = 5.0 + elif close < 500: + step = 10.0 + else: + step = 50.0 + + lo = close * (1 - pct) + hi = close * (1 + pct) + # 找区间 [lo, hi] 内所有 step 的整数倍(严格限定在区间内) + start = (int(lo / step) + (1 if lo % step > 0 else 0)) * step + candidates: list[float] = [] + v = start + while v <= hi: + if v > 0: + candidates.append(round(v, 2)) + v += step + + # 按距当前价从近到远排序,取前 max_count 个 + candidates.sort(key=lambda x: abs(x - close)) + out: list[dict] = [] + for v in candidates[:max_count]: + # 过滤距当前价 <1% 的(太近,无分析价值) + if abs(v - close) / close < 0.01: + continue + out.append({"value": round(v, 2), "label": f"整数关口 {v:g}", + "type": "round", "side": _side(v, close), "strength": "weak"}) + return out + +def compute_levels(df: pl.DataFrame) -> dict[str, list[dict]]: + """计算 9 类价位点,返回 {分组key: [点位...]}。 + + 分组 key 与 LEVEL_TYPES 一致(sr / profile / pivot / extreme / + keltner / atr_stop / gap / fib / round),前端按 key 渲染开关按钮,逐组显隐。 + """ + if df.is_empty(): + return {k: [] for k in LEVEL_TYPES} + + try: + return { + "sr": _support_resistance(df), + "profile": _volume_profile(df), + "pivot": _pivot_points(df), + "extreme": _extreme_levels(df), + "keltner": _keltner_channels(df), + "atr_stop": _atr_stops(df), + "gap": _gap_levels(df), + "fib": _fibonacci_levels(df), + "round": _round_numbers(df), + } + except Exception as e: # noqa: BLE001 + logger.warning("compute_levels failed: %s", e) + return {k: [] for k in LEVEL_TYPES} + + +def summarize_levels(levels: dict[str, list[dict]], close: float | None) -> str: + """生成给 AI 提示词的价位摘要文本(紧凑,供上下文)。""" + if not close: + return "无价位数据" + parts: list[str] = [] + # 当前价 + parts.append(f"当前价 {close:.2f}") + # 每组取前 2 个最相关的(距当前价近的优先) + for key, label in LEVEL_TYPES.items(): + pts = levels.get(key, []) + if not pts: + continue + # 按距当前价排序,取前 2 + ranked = sorted(pts, key=lambda p: abs(p["value"] - close))[:2] + desc = "、".join( + f"{p['label']}={p['value']}" for p in ranked + ) + parts.append(f"{label}: {desc}") + return " · ".join(parts) + + +# ================================================================ +# 内部工具 +# ================================================================ + +def _ok(v: Any) -> bool: + """数值有效(非空/非 NaN/非 Inf/正数)。""" + try: + f = float(v) + except (TypeError, ValueError): + return False + import math + return math.isfinite(f) and f > 0 + + +def _side(level: float, close: float) -> str: + """价位相对当前价的方向。""" + if level > close * 1.001: + return "resistance" + if level < close * 0.999: + return "support" + return "neutral" + + +def _aggregate_levels(values: list[float], tol: float) -> list[float]: + """把相近的价位聚合(±tol),返回去重后的代表值(保留最新)。""" + if not values: + return [] + values = sorted(values) + out: list[float] = [values[0]] + for v in values[1:]: + if abs(v - out[-1]) / out[-1] <= tol: + out[-1] = v # 聚合到最新(更近期) + else: + out.append(v) + return out diff --git a/backend/app/main.py b/backend/app/main.py index e9dc5c0..f8fc073 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,7 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from app import __version__ -from app.api import analysis, backtest, data, ext_data, financials, indices, intraday, kline, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, strategy, watchlist +from app.api import analysis, backtest, data, ext_data, financials, indices, intraday, kline, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, stock_analysis, strategy, watchlist from app.api.routes import router as core_router from app.config import settings from app.jobs import daily_pipeline @@ -192,6 +192,7 @@ app.include_router(pipeline.router) app.include_router(data.router) app.include_router(ext_data.router) app.include_router(financials.router) +app.include_router(stock_analysis.router) app.include_router(settings_api.router) app.include_router(strategy.router) app.include_router(signals.router) diff --git a/backend/app/services/stock_analyzer.py b/backend/app/services/stock_analyzer.py new file mode 100644 index 0000000..7538303 --- /dev/null +++ b/backend/app/services/stock_analyzer.py @@ -0,0 +1,337 @@ +"""AI 个股分析服务 — 技术面 / 基本面 / 财务面 / 消息面 四维综合分析。 + +职责: + 组合一只股票的 K 线(含已算好的技术指标)+ 财务表 + 关键价位 → + 拼装"实战派交易员"级系统提示词 → 流式调用 LLM → 逐 chunk 吐给前端。 + +与 financial_analyzer.py 的区别(刻意区分,非复用): + - 角色:A 股实战派交易员 / 技术分析师(非 CFA 财务分析师) + - 数据源:K 线 + 技术指标为主,财务表为辅(财务分析以财务表为主) + - 输出框架:技术面→基本面→财务面→消息面(四维),落点是买卖区间与操作建议 + (财务分析的落点是财务质量评级) + +不知道: HTTP、前端、配置持久化。 +""" +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import AsyncIterator + +import polars as pl + +from app.indicators.levels import compute_levels, summarize_levels +from app.services.financial_sync import get_financial_df + +logger = logging.getLogger(__name__) + +# 注入最近多少根日 K(技术面分析样本) +_KLINE_WINDOW = 90 +# 注入财务表的最近期数 +_MAX_PERIODS = 4 + + +# ================================================================ +# 数据加载 +# ================================================================ + +def _load_kline(repo, symbol: str) -> pl.DataFrame: + """读取该标的最近 N 根日 K(已含技术指标 / 信号)。 + + repo: KlineRepository;走内存缓存,性能可控。 + """ + from datetime import date, timedelta + + end = date.today() + start = end - timedelta(days=_KLINE_WINDOW * 2) # 多取一些保证交易日够 + df = repo.get_daily(symbol, start, end) + if df.is_empty(): + return df + return df.tail(_KLINE_WINDOW) + + +def _clean_rows(df: pl.DataFrame, keep_cols: list[str]) -> list[dict]: + """把 DataFrame 转成 JSON 安全的 dict 列表(只保留关键列 + 清洗 NaN/Inf + date→字符串)。 + + polars 的 date 列会变成 Python datetime.date,json.dumps 无法直接序列化, + 必须转成 ISO 字符串,否则 json.dumps 抛 TypeError 让整个流静默失败。 + """ + import datetime + import math + cols = [c for c in keep_cols if c in df.columns] + sub = df.select(cols) + rows = [] + for rec in sub.to_dicts(): + clean = {} + for k, v in rec.items(): + if isinstance(v, float): + clean[k] = None if not math.isfinite(v) else round(v, 4) + elif isinstance(v, (datetime.date, datetime.datetime)): + clean[k] = v.isoformat() + else: + clean[k] = v + rows.append(clean) + return rows + + +def _load_financials(data_dir: Path, symbol: str) -> dict[str, list[dict]]: + """读取该标的核心财务指标 + 利润表(只取最有信息量的两张表)。 + + 财务面只需要关键指标(ROE / 增速 / 毛利率 等),不需要把 4 张表全塞进上下文 + (那是 financial_analyzer 的职责)。这里取轻量,留给技术面更多 token。 + """ + out: dict[str, list[dict]] = {} + for table in ("metrics", "income"): + df = get_financial_df(data_dir, table) + if df.is_empty(): + out[table] = [] + continue + df = df.filter(pl.col("symbol") == symbol) + if df.is_empty(): + out[table] = [] + continue + if "period_end" in df.columns: + df = df.sort("period_end", descending=True).head(2) # 只取最近 2 期 + import math + rows = [] + for rec in df.to_dicts(): + clean = {} + for k, v in rec.items(): + if k == "symbol": + continue + if isinstance(v, float): + clean[k] = None if not math.isfinite(v) else v + else: + clean[k] = v + rows.append(clean) + out[table] = rows + return out + + +# ================================================================ +# 系统提示词 —— 实战派交易员四维框架(与财务分析明确区分) +# ================================================================ + +_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深交易员兼技术分析师,擅长从 K 线、量价、关键价位与基本面交叉验证中把握买卖时机。你的任务是:基于提供的个股数据,产出一份**实战、可直接指导交易决策**的综合分析报告。 + +## 输出规范 + +用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。 + +### 1. 🎯 一句话定调(1-2 句) +用一句话概括该股当前的**技术状态与交易属性**(如"高位放量滞涨,需警惕回调"/"底部筹码集中,放量突破在即")。结尾用【操作建议:观望 / 轻仓试探 / 逢低吸纳 / 持有 / 减仓 / 规避】给出明确倾向。 + +### 2. 📈 技术面分析(核心维度) +这是你的主战场,务必深入: +- **趋势判断**:均线多头/空头排列、20/60 日均线方向、价格在均线之上/下 +- **形态结构**:近期是否有突破/破位/双底/双顶/旗形等关键形态 +- **指标信号**:MACD 金叉/死叉/背离、KDJ 超买超卖、RSI 强弱、布林通道位置 +- **量价配合**:放量上涨/缩量回调/量价背离/换手率异动 +每条结论必须引用具体数值(如"MACD 在 6/12 出现金叉,DIF 0.32 上穿 DEA 0.18")。 + +### 3. 💰 关键价位(买卖区间) +基于提供的关键价位数据,明确指出: +- **上方压力位**(逐档列出,标注强度):第一压力、第二压力 +- **下方支撑位**(逐档列出,标注强度):第一支撑、第二支撑 +- 给出**建议买入区间**与**止损位**(基于支撑位) +用数据说话,引用提供的压力/支撑/密集区/枢轴点数值。 + +### 4. 🏭 基本面与财务面(辅助验证) +简要点评(2-4 句,不展开长篇): +- 盈利质量(ROE / 毛利率水平)、成长性(营收/利润增速) +- 与技术面的**交叉验证**:好公司 + 技术面走坏 → 仍需谨慎;差公司 + 技术面强势 → 警惕炒作风险 + +**当用户消息中标注了"该标的暂无财务数据"时**,本节请输出: +> 📌 财务面分析能力正在接入中。当前版本(Free)未同步该标的的财务报表,基本面维度暂无法评估。 +> 技术面分析不依赖财务数据,以下结论依然有效;升级套餐或等待财务数据同步后可补充本维度。 + +**绝对不要**在无数据时编造 ROE / 增速等数字。 + +### 5. 📰 消息面(价量异动推断) +**注意:本期无直接新闻数据输入。** 请基于 K 线的**异动信号**进行推断(如: +- 涨停/连板/炸板 → 可能有利好或资金炒作 +- 放量暴跌 → 可能有未公开利空 +- 突破放量 → 可能有催化剂 +明确标注"[推断]",告诉用户这是基于价量的推测,真实消息面数据待接入。若无明显异动,直说"近期价量平稳,无明显消息面信号"。 + +### 6. ⚖️ 综合研判与操作建议 +2-3 段: +- 该股当前处于(底部启动 / 上升途中 / 高位震荡 / 下跌趋势 / 底部企稳)哪个阶段 +- 风险收益比评估(距支撑位的空间 vs 距压力位的空间) +- **明确操作建议**:激进型 / 稳健型 / 保守型 分别怎么应对 +- **需要重点盯的信号**(如跌破 X 支撑止损、站上 Y 压力加仓) + +## 分析准则(务必遵守) + +1. **技术面优先**:作为交易员,技术面和量价是主要依据,基本面是验证手段,主次分明 +2. **数据说话**:每个判断引用具体数值,严禁空泛套话("走势良好"必须改成"连续 3 日站稳 20 日均线且放量") +3. **诚实中立**:看多就写多,看空就写空,不要模棱两可骑墙;数据不支持时直言无法判断 +4. **价位精确**:买卖区间必须落到具体价格,基于提供的关键价位数据推演 +5. **风险前置**:任何买入建议都要配止损位;提示潜在风险不回避 +6. **简明实战**:用交易员能扫读的密度输出,总字数 1000-1800 字,重在可执行 + +## 重要免责 +报告末尾附一行:"> ⚠️ 本报告由 AI 基于公开行情与财务数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。" + +现在请基于下方数据进行分析。""" + + +# ================================================================ +# 用户消息构建 +# ================================================================ + +def _build_user_prompt( + kline_tail: list[dict], + fins: dict[str, list[dict]], + levels: dict[str, list[dict]], + close: float | None, + symbol: str, + focus: str, +) -> str: + """构建用户消息:标的 + 价位摘要 + 技术指标 JSON + 财务摘要 + 关注点。""" + parts: list[str] = [ + f"标的标准代码: {symbol}", + f"关键价位概览: {summarize_levels(levels, close)}", + "", + "以下是该标的最近日 K 数据(JSON,含 OHLCV 与已计算的技术指标。" + f"最近 {_KLINE_WINDOW} 个交易日,升序):", + "```json", + json.dumps(kline_tail, ensure_ascii=False), + "```", + ] + + has_fin = any(fins.values()) + if has_fin: + parts.extend([ + "", + "以下是该标的最新财务数据(JSON,核心指标 + 利润表,金额单位为元):", + "```json", + json.dumps(fins, ensure_ascii=False), + "```", + ]) + else: + parts.extend([ + "", + "(该标的暂无财务数据:当前为 Free 模式或尚未同步财务报表。" + "请按系统提示词第 4 节的说明,在基本面/财务面维度给出\"接入中\"的友好提示,不要编造数据。)", + ]) + + if focus.strip(): + parts.extend(["", f"本次分析请特别关注: {focus.strip()}"]) + return "\n".join(parts) + + +# ================================================================ +# 关键列筛选(控制上下文体积) +# ================================================================ + +_KLINE_KEEP_COLS = [ + "date", "open", "high", "low", "close", "volume", "change_pct", + "ma5", "ma10", "ma20", "ma60", + "macd_dif", "macd_dea", "macd_hist", + "kdj_k", "kdj_d", "kdj_j", + "rsi_6", "rsi_14", "rsi_24", + "boll_upper", "boll_mid", "boll_lower", + "atr_14", "vol_ratio_5d", "turnover_rate", + "consecutive_limit_ups", + # 信号类(布尔)——只挑对消息面推断有用的几个 + "signal_limit_up", "signal_broken_limit_up", "signal_macd_golden", + "signal_macd_death", "signal_ma_golden_5_20", "signal_volume_surge", + "signal_boll_breakout_upper", "signal_boll_breakout_lower", +] + + +# ================================================================ +# 流式分析入口 +# ================================================================ + +async def analyze_stock_stream( + repo, + data_dir: Path, + symbol: str, + focus: str = "", +) -> AsyncIterator[str]: + """流式个股分析:yield 出每个 NDJSON 事件。 + + 协议(与 financial_analyzer 一致,前端解析无差异): + {"type":"meta","symbol","summary","levels"} 数据 + 价位摘要 + {"type":"delta","content":"..."} 逐 chunk 文本 + {"type":"error","message":"..."} + {"type":"done"} + """ + # 1. 加载 K 线 + df = _load_kline(repo, symbol) + if df.is_empty(): + yield json.dumps({ + "type": "error", + "message": f"标的 {symbol} 暂无日 K 数据,请先同步", + }, ensure_ascii=False) + return + + # 2. 价位计算(基于 K 线) + levels = compute_levels(df) + close = float(df.tail(1)["close"][0]) if "close" in df.columns else None + + # 3. 财务(辅助) + fins = _load_financials(data_dir, symbol) + + # 4. meta + yield json.dumps({ + "type": "meta", + "symbol": symbol, + "summary": summarize_levels(levels, close), + "levels": levels, + "close": close, + }, ensure_ascii=False) + + # 5+6. 构建提示词 + 流式调用 LLM(整体 try-except,任何异常都 yield error,避免前端卡死) + try: + from openai import AsyncOpenAI + from app import secrets_store + from app.config import settings + + # 5. 构建提示词 + kline_tail = _clean_rows(df, _KLINE_KEEP_COLS) + user_prompt = _build_user_prompt(kline_tail, fins, levels, close, symbol, focus) + + # 6. 流式调用 LLM + ai_key = secrets_store.get_ai_key() + if not ai_key: + yield json.dumps({ + "type": "error", + "message": "AI API Key 未配置,请在「设置 → AI」中配置", + }, ensure_ascii=False) + return + + user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent + client = AsyncOpenAI( + api_key=ai_key, + base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"), + timeout=180.0, + max_retries=2, + default_headers={"User-Agent": user_agent}, + ) + + stream = await client.chat.completions.create( + model=secrets_store.get_ai_config("ai_model", "gpt-5.5"), + messages=[ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + temperature=0.5, + max_tokens=4500, + stream=True, + ) + + async for chunk in stream: + delta = chunk.choices[0].delta if chunk.choices else None + if delta and delta.content: + yield json.dumps({"type": "delta", "content": delta.content}, ensure_ascii=False) + + except Exception as e: # noqa: BLE001 + logger.exception("AI stock analysis failed for %s: %s", symbol, e) + yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False) + return + + yield json.dumps({"type": "done"}, ensure_ascii=False) diff --git a/backend/app/services/stock_reports.py b/backend/app/services/stock_reports.py new file mode 100644 index 0000000..d522d23 --- /dev/null +++ b/backend/app/services/stock_reports.py @@ -0,0 +1,91 @@ +"""AI 个股分析报告持久化存储。 + +与 ai_reports.py(财务分析报告)完全独立 —— 单独的文件、字段、上限, +互不影响。刻意不复用,避免引入 kind 判别字段与分支(解耦 > 抽象)。 + +存储位置: data/user_data/ai_stock_reports.json (数组,按 created_at 降序) +保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。 + +每条报告结构: +{ + "id": "sar_xxx", # 唯一 id(stock-analysis-report) + "symbol": "600519.SH", + "name": "贵州茅台", + "focus": "", # 用户追加的关心点(可为空) + "content": "# ...markdown", # 报告正文 + "summary": "当前价 12.3 · 压力位...", # 价位/数据摘要 + "levels": {...}, # 报告生成时的关键价位(供图表回放) + "close": 12.3, # 报告生成时的收盘价 + "created_at": "2026-06-26T10:00:00" +} +""" +from __future__ import annotations + +import json +import logging +import time +from pathlib import Path + +logger = logging.getLogger(__name__) + +MAX_REPORTS = 50 + + +def _path() -> Path: + from app.config import settings + p = settings.data_dir / "user_data" / "ai_stock_reports.json" + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def list_reports() -> list[dict]: + """返回全部报告(按 created_at 降序)。""" + p = _path() + if not p.exists(): + return [] + try: + data = json.loads(p.read_text(encoding="utf-8")) + if isinstance(data, list): + return sorted(data, key=lambda r: r.get("created_at", ""), reverse=True) + except Exception as e: # noqa: BLE001 + logger.warning("ai_stock_reports.json malformed: %s", e) + return [] + + +def _save_all(reports: list[dict]) -> None: + """全量写入(裁剪到 MAX_REPORTS)。""" + reports.sort(key=lambda r: r.get("created_at", ""), reverse=True) + if len(reports) > MAX_REPORTS: + reports = reports[:MAX_REPORTS] + _path().write_text( + json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8", + ) + + +def save_report(report: dict) -> dict: + """新增一条报告并持久化。返回保存后的报告(含 id / created_at)。""" + reports = list_reports() + if not report.get("id"): + report["id"] = f"sar_{int(time.time() * 1000)}_{report.get('symbol', 'x')}" + if not report.get("created_at"): + report["created_at"] = _now_iso() + reports.append(report) + _save_all(reports) + logger.info("Stock report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports)) + return report + + +def delete_report(report_id: str) -> bool: + """删除指定报告。返回是否删除成功。""" + reports = list_reports() + before = len(reports) + reports = [r for r in reports if r.get("id") != report_id] + if len(reports) < before: + _save_all(reports) + return True + return False + + +def _now_iso() -> str: + from datetime import datetime + return datetime.now().isoformat(timespec="seconds") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0c7fa66..24f0c88 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tickflow-stock-panel-backend" -version = "0.1.45" +version = "0.1.50" description = "A 股选股 + 监控 + 回测面板 — TickFlow 适配" readme = "../README.md" requires-python = ">=3.11" diff --git a/frontend/package.json b/frontend/package.json index 9dbef2b..1b76dd8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "tickflow-stock-panel-frontend", "private": true, - "version": "0.1.45", + "version": "0.1.50", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/LastStockChip.tsx b/frontend/src/components/LastStockChip.tsx new file mode 100644 index 0000000..dd29f58 --- /dev/null +++ b/frontend/src/components/LastStockChip.tsx @@ -0,0 +1,31 @@ +import { Clock } from 'lucide-react' +import type { StockRef } from '@/lib/useLastStock' + +/** + * "上次查看"个股胶囊 —— 显示在 PageHeader 右侧。 + * 上方名称、下方代码,小字体二排;点击恢复该个股的查看。 + */ +export function LastStockChip({ + stock, + onSelect, +}: { + stock: StockRef | null + onSelect?: (symbol: string, name: string) => void +}) { + if (!stock) return null + return ( + + ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index e12b714..cd87948 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -7,6 +7,8 @@ import { ToastContainer } from '@/components/Toast' import { AlertToastContainer } from '@/components/AlertToast' import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost' import { AiReportBubble } from '@/components/financials/AiReportBubble' +import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost' +import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble' import { useCapabilities, useSettings, @@ -420,6 +422,12 @@ export function Layout() { <> {label} + {/* 个股分析 Beta 标识 */} + {to === '/stock-analysis' && ( + + Beta + + )} {/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */} {to === '/data' && isDataSyncing && ( @@ -533,6 +541,8 @@ export function Layout() { + + ) } diff --git a/frontend/src/components/financials/MarkdownRenderer.tsx b/frontend/src/components/financials/MarkdownRenderer.tsx index 8f85df3..8fdfc93 100644 --- a/frontend/src/components/financials/MarkdownRenderer.tsx +++ b/frontend/src/components/financials/MarkdownRenderer.tsx @@ -88,7 +88,7 @@ export function MarkdownRenderer({ content }: { content: string }) { // 分隔线 if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { - blocks.push(
) + blocks.push(
) i++ continue } @@ -99,9 +99,9 @@ export function MarkdownRenderer({ content }: { content: string }) { const level = hMatch[1].length const text = hMatch[2] const sizeCls = level === 1 ? 'text-base' : level === 2 ? 'text-sm' : 'text-xs' - const mtCls = level <= 2 ? 'mt-4' : 'mt-3' + const mtCls = level <= 2 ? 'mt-6' : 'mt-5' blocks.push( -
+
{renderInline(text, `h-${key}`)}
, ) @@ -117,7 +117,7 @@ export function MarkdownRenderer({ content }: { content: string }) { i++ } blocks.push( -
+
{renderInline(quoteLines.join(' '), `q-${key}`)}
, ) @@ -131,7 +131,7 @@ export function MarkdownRenderer({ content }: { content: string }) { const [header, ...body] = table.rows const ncol = header.length blocks.push( -
+
{/* 首列(维度)较窄;末列(判断/说明)最宽并允许折行 */} @@ -176,10 +176,10 @@ export function MarkdownRenderer({ content }: { content: string }) { i++ } blocks.push( -