mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
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
This commit is contained in:
@@ -35,7 +35,7 @@
|
||||
| 配置项 | 说明 | 是否必填 |
|
||||
| :--- | :--- | :--- |
|
||||
| **TickFlow API Key** | 数据源凭证,留空启用 None 模式,获取免费key后开启free模式可定制策略+回测 | 可选 |
|
||||
| **AI 大模型 API Key** | 用于 AI 生成策略、个股分析(开发中)、行情分析(开发中)等,任意 OpenAI 兼容接口,留空关闭 | 可选 |
|
||||
| **AI 大模型 API Key** | 用于 AI 生成策略、个股分析、财务分析等,任意 OpenAI 兼容接口,留空关闭 | 可选 |
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -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/掘金下单) · 板块异动 · 早晚报 · 更多扩展 | 🚧 |
|
||||
|
||||
---
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import sys
|
||||
|
||||
__version__ = "0.1.45"
|
||||
__version__ = "0.1.50"
|
||||
|
||||
# Windows 默认 stdout/stderr 编码为 GBK(cp936),TickFlow SDK 内部输出含 emoji 的
|
||||
# 指数/标的名称(如 \U0001f193)时会抛 UnicodeEncodeError,导致请求失败。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
+2
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tickflow-stock-panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.45",
|
||||
"version": "0.1.50",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => onSelect?.(stock.symbol, stock.name)}
|
||||
title={`继续查看 ${stock.name}`}
|
||||
className="group inline-flex items-center gap-1.5 rounded-lg border border-border/40 bg-elevated/40 px-2 py-1 hover:border-border hover:bg-elevated transition-colors"
|
||||
>
|
||||
<Clock className="h-3 w-3 text-muted shrink-0" />
|
||||
<span className="flex flex-col items-start leading-tight">
|
||||
<span className="text-[11px] font-medium text-secondary group-hover:text-foreground transition-colors max-w-[7em] truncate">
|
||||
{stock.name}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-muted">{stock.symbol}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
<>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{/* 个股分析 Beta 标识 */}
|
||||
{to === '/stock-analysis' && (
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400 shrink-0">
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
|
||||
{to === '/data' && isDataSyncing && (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
|
||||
@@ -533,6 +541,8 @@ export function Layout() {
|
||||
<AlertToastContainer />
|
||||
<AiAnalysisHost />
|
||||
<AiReportBubble />
|
||||
<StockAnalysisHost />
|
||||
<StockAnalysisBubble />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
|
||||
|
||||
// 分隔线
|
||||
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
||||
blocks.push(<hr key={key++} className="my-3 border-border/40" />)
|
||||
blocks.push(<hr key={key++} className="my-6 border-border" />)
|
||||
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(
|
||||
<div key={key++} className={`${sizeCls} ${mtCls} mb-2 font-semibold text-foreground flex items-center gap-1.5`}>
|
||||
<div key={key++} className={`${sizeCls} ${mtCls} mb-3 font-semibold text-foreground flex items-center gap-1.5`}>
|
||||
{renderInline(text, `h-${key}`)}
|
||||
</div>,
|
||||
)
|
||||
@@ -117,7 +117,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<blockquote key={key++} className="my-2 pl-3 border-l-2 border-amber-400/40 bg-amber-400/[0.04] py-1.5 pr-2 rounded-r text-xs text-secondary">
|
||||
<blockquote key={key++} className="my-4 pl-3 border-l-2 border-amber-400/40 bg-amber-400/[0.04] py-1.5 pr-2 rounded-r text-xs text-secondary">
|
||||
{renderInline(quoteLines.join(' '), `q-${key}`)}
|
||||
</blockquote>,
|
||||
)
|
||||
@@ -131,7 +131,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
|
||||
const [header, ...body] = table.rows
|
||||
const ncol = header.length
|
||||
blocks.push(
|
||||
<div key={key++} className="my-3 overflow-hidden rounded-btn border border-border/30">
|
||||
<div key={key++} className="my-5 overflow-hidden rounded-btn border border-border/30">
|
||||
<table className="w-full text-xs border-collapse table-fixed">
|
||||
<colgroup>
|
||||
{/* 首列(维度)较窄;末列(判断/说明)最宽并允许折行 */}
|
||||
@@ -176,10 +176,10 @@ export function MarkdownRenderer({ content }: { content: string }) {
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<ul key={key++} className="my-1.5 space-y-1">
|
||||
<ul key={key++} className="my-4 space-y-2">
|
||||
{items.map((item, ii) => (
|
||||
<li key={ii} className="flex items-start gap-2 text-xs text-foreground/90 leading-relaxed">
|
||||
<span className="mt-1.5 h-1 w-1 rounded-full bg-accent/60 shrink-0" />
|
||||
<span className="mt-[7px] h-1 w-1 rounded-full bg-accent/60 shrink-0" />
|
||||
<span>{renderInline(item, `li-${key}-${ii}`)}</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -196,7 +196,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
<ol key={key++} className="my-1.5 space-y-1">
|
||||
<ol key={key++} className="my-4 space-y-2">
|
||||
{items.map((item, ii) => (
|
||||
<li key={ii} className="flex items-start gap-2 text-xs text-foreground/90 leading-relaxed">
|
||||
<span className="mt-0.5 h-4 w-4 rounded-full bg-accent/10 text-accent text-[10px] font-mono flex items-center justify-center shrink-0">
|
||||
@@ -212,12 +212,14 @@ export function MarkdownRenderer({ content }: { content: string }) {
|
||||
|
||||
// 普通段落
|
||||
blocks.push(
|
||||
<p key={key++} className="my-1.5 text-xs text-foreground/90 leading-relaxed">
|
||||
<p key={key++} className="my-3 text-xs text-foreground/90 leading-relaxed">
|
||||
{renderInline(trimmed, `p-${key}`)}
|
||||
</p>,
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
return <div className="space-y-0">{blocks}</div>
|
||||
// 注意:外层不加 space-y-*,否则会覆盖各块自己的 margin-top 导致间距失效。
|
||||
// 让各块的 my-* 自然叠加(margin collapse),间距更可控。
|
||||
return <div>{blocks}</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useEffect, useRef, useMemo, useState } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts, EChartsOption } from 'echarts'
|
||||
import type { KlineRow } from '@/lib/api'
|
||||
|
||||
/**
|
||||
* 个股分析专用日 K 图表。
|
||||
*
|
||||
* 与 StockDailyKChart/EChartsCandlestick 刻意不复用:
|
||||
* - 那套图表面向「行情浏览」,强调全套指标副图(MA/MACD/KDJ/BOLL)、涨停标记等;
|
||||
* - 本图表面向「分析决策」,核心是【关键价位】(压力/支撑/密集区/枢轴/前高前低),
|
||||
* 通过开关按钮控制各价位组的显隐,布局更简洁(主图 + 成交量即可)。
|
||||
*
|
||||
* 预留接口(类型已定义,渲染逻辑留 hook,后续实现):
|
||||
* - markers: 日期标记点(新闻/暴雷/利好 → markPoint)
|
||||
* - ranges: 区间高亮(事件区间 → markArea)
|
||||
* - onDateClick: 点击日期回调(后续接消息面时间轴)
|
||||
* - 指标副图: 后续如需 MACD/KDJ,按 SUB_CHARTS 模式扩展
|
||||
*/
|
||||
|
||||
// ===== 配色(与主图一致的红涨绿跌,深色背景) =====
|
||||
const THEME = {
|
||||
bull: '#C74040',
|
||||
bear: '#2D9B65',
|
||||
text: '#A1A1AA',
|
||||
grid: 'rgba(255,255,255,0.04)',
|
||||
volUp: 'rgba(240,68,56,0.5)',
|
||||
volDown: 'rgba(18,183,106,0.5)',
|
||||
}
|
||||
|
||||
// ===== 价位类型(与后端 levels.py 的 LEVEL_TYPES 对齐) =====
|
||||
export type LevelType = 'sr' | 'profile' | 'pivot' | 'extreme' | 'keltner' | 'atr_stop' | 'gap' | 'fib' | 'round'
|
||||
|
||||
export interface PriceLevel {
|
||||
value: number
|
||||
label: string
|
||||
type: LevelType
|
||||
side: 'resistance' | 'support' | 'neutral'
|
||||
strength?: 'strong' | 'medium' | 'weak'
|
||||
/** 档位(仅 pivot 有):0=P, 1=R1/S1, 2=R2/S2, 3=R3/S3 */
|
||||
rank?: number
|
||||
}
|
||||
|
||||
/** 价位组开关配置:label = 按钮文案,color = markLine 颜色 */
|
||||
export const LEVEL_GROUPS: { key: LevelType; label: string; color: string }[] = [
|
||||
{ key: 'sr', label: '压力支撑', color: '#F97316' }, // 橙
|
||||
{ key: 'profile', label: '成交密集', color: '#3B82F6' }, // 蓝
|
||||
{ key: 'pivot', label: '枢轴点', color: '#8B5CF6' }, // 紫
|
||||
{ key: 'extreme', label: '前高前低', color: '#EAB308' }, // 黄
|
||||
{ key: 'keltner', label: 'Keltner', color: '#06B6D4' }, // 青
|
||||
{ key: 'atr_stop', label: 'ATR止损', color: '#EF4444' }, // 红(警示)
|
||||
{ key: 'gap', label: '缺口位', color: '#EC4899' }, // 粉
|
||||
{ key: 'fib', label: '斐波那契', color: '#F59E0B' }, // 金
|
||||
{ key: 'round', label: '整数关口', color: '#71717A' }, // 灰(心理位,弱视觉)
|
||||
]
|
||||
|
||||
// ===== 预留:标记 / 区间(后续新闻面、事件区间用) =====
|
||||
export interface ChartMarker {
|
||||
date: string
|
||||
label?: string
|
||||
color?: string
|
||||
above?: boolean
|
||||
}
|
||||
export interface ChartRange {
|
||||
start: string
|
||||
end: string
|
||||
label?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: KlineRow[]
|
||||
levels?: Record<LevelType, PriceLevel[]>
|
||||
/** 默认开启的价位组 */
|
||||
defaultLevelTypes?: LevelType[]
|
||||
/** 预留:新闻/暴雷/利好日期标记 */
|
||||
markers?: ChartMarker[]
|
||||
/** 预留:事件区间高亮 */
|
||||
ranges?: ChartRange[]
|
||||
/** 预留:点击某根 K 线 */
|
||||
onDateClick?: (date: string) => void
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const VOL_PANE_H = 90
|
||||
|
||||
export function AnalysisKChart({
|
||||
rows,
|
||||
levels,
|
||||
defaultLevelTypes = ['sr', 'pivot', 'keltner'],
|
||||
markers,
|
||||
ranges,
|
||||
onDateClick,
|
||||
height = 460,
|
||||
className,
|
||||
}: Props) {
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const chartInstRef = useRef<ECharts | null>(null)
|
||||
const [activeTypes, setActiveTypes] = useState<Set<LevelType>>(new Set(defaultLevelTypes))
|
||||
/** 枢轴点显示到第几档:1=只P+R1/S1, 2=到R2/S2, 3=全档(R3/S3) */
|
||||
const [pivotRank, setPivotRank] = useState<1 | 2 | 3>(1)
|
||||
|
||||
// 数据预处理
|
||||
const { dates, candle, vols, dateIndex, zoomStart } = useMemo(() => {
|
||||
const dates = rows.map(r => (typeof r.date === 'string' ? r.date.slice(0, 10) : String(r.date)))
|
||||
const candle = rows.map(r => [r.open, r.close, r.low, r.high])
|
||||
const vols = rows.map(r => ({
|
||||
value: r.volume ?? 0,
|
||||
itemStyle: { color: r.close >= r.open ? THEME.volUp : THEME.volDown },
|
||||
}))
|
||||
const dateIndex = new Map(dates.map((d, i) => [d, i]))
|
||||
// 默认显示最近 6 个月 ≈ 120 个交易日;数据不足则全部显示
|
||||
const showBars = 120
|
||||
const zoomStart = dates.length > showBars ? Math.round((1 - showBars / dates.length) * 100) : 0
|
||||
return { dates, candle, vols, dateIndex, zoomStart }
|
||||
}, [rows])
|
||||
|
||||
// 构建 option
|
||||
const buildOption = (): EChartsOption => {
|
||||
const priceLines = collectPriceLines(levels, activeTypes, pivotRank)
|
||||
|
||||
// 三段布局:主图 / 成交量 / 缩放条,从上到下累加,各段之间留间距,互不遮挡
|
||||
// [16 顶部] [mainH 主图] [8 间距] [volH 成交量] [12 间距] [SLIDER_H 缩放条] [8 底部]
|
||||
const SLIDER_H = 22
|
||||
const PAD_TOP = 16
|
||||
const GAP_MAIN_VOL = 8 // 主图 ↔ 成交量
|
||||
const GAP_VOL_SLIDER = 12 // 成交量 ↔ 缩放条(留足,避免遮挡)
|
||||
const PAD_BOTTOM = 8
|
||||
const volH = VOL_PANE_H
|
||||
const mainH = height - PAD_TOP - GAP_MAIN_VOL - volH - GAP_VOL_SLIDER - SLIDER_H - PAD_BOTTOM
|
||||
const volTop = PAD_TOP + mainH + GAP_MAIN_VOL
|
||||
const sliderBottom = PAD_BOTTOM
|
||||
|
||||
// 主图 markLine(关键价位)
|
||||
const markLineData: any[] = priceLines.map(p => ({
|
||||
yAxis: p.value,
|
||||
lineStyle: { color: p.color, type: 'dashed', width: 1, opacity: 0.85 },
|
||||
label: {
|
||||
show: true,
|
||||
formatter: `${p.label} ${p.value.toFixed(2)}`,
|
||||
position: 'insideEndTop',
|
||||
color: p.color,
|
||||
fontSize: 10,
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
backgroundColor: 'rgba(15,23,42,0.72)',
|
||||
padding: [1, 5],
|
||||
borderRadius: 3,
|
||||
},
|
||||
}))
|
||||
|
||||
// 预留:markPoint(新闻标记)
|
||||
const markPointData: any[] = (markers ?? [])
|
||||
.filter(m => dateIndex.has(m.date))
|
||||
.map(m => ({
|
||||
coord: [m.date, rows[dateIndex.get(m.date)!].high],
|
||||
symbol: 'pin', symbolSize: 32,
|
||||
itemStyle: { color: m.color ?? '#EAB308' },
|
||||
label: { show: !!m.label, formatter: m.label ?? '', fontSize: 9, color: '#fff' },
|
||||
}))
|
||||
|
||||
// 预留:markArea(事件区间)
|
||||
const markAreaData: any[] = (ranges ?? [])
|
||||
.filter(r => dateIndex.has(r.start) && dateIndex.has(r.end))
|
||||
.map(r => [{
|
||||
xAxis: r.start, name: r.label ?? '',
|
||||
itemStyle: { color: r.color ?? 'rgba(234,179,8,0.08)' },
|
||||
label: r.label ? { show: true, position: 'insideTop', distance: 6, color: '#EAB308', fontSize: 10 } : undefined,
|
||||
}, { xAxis: r.end }])
|
||||
|
||||
const series: any[] = [
|
||||
{
|
||||
name: 'K', type: 'candlestick', data: candle, animation: false,
|
||||
itemStyle: {
|
||||
color: THEME.bull, color0: THEME.bear,
|
||||
borderColor: THEME.bull, borderColor0: THEME.bear,
|
||||
},
|
||||
markLine: markLineData.length ? { silent: true, symbol: 'none', animation: false, data: markLineData } : undefined,
|
||||
markPoint: markPointData.length ? { data: markPointData, animation: false } : undefined,
|
||||
markArea: markAreaData.length ? { silent: true, data: markAreaData } : undefined,
|
||||
},
|
||||
{
|
||||
name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1,
|
||||
data: vols, animation: false,
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
animation: false,
|
||||
backgroundColor: 'transparent',
|
||||
grid: [
|
||||
{ left: 56, right: 64, top: 16, height: mainH },
|
||||
{ left: 56, right: 64, top: volTop, height: volH },
|
||||
],
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category', data: dates, boundaryGap: true,
|
||||
axisLine: { lineStyle: { color: THEME.grid } },
|
||||
axisLabel: { color: THEME.text, fontSize: 10 },
|
||||
splitLine: { show: false },
|
||||
axisPointer: { show: true, label: { show: false } },
|
||||
},
|
||||
{
|
||||
type: 'category', gridIndex: 1, data: dates, boundaryGap: true,
|
||||
axisLabel: { show: false }, axisLine: { show: false }, axisTick: { show: false },
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{ scale: true, splitLine: { lineStyle: { color: THEME.grid } },
|
||||
axisLabel: { color: THEME.text, fontSize: 10, fontFamily: 'JetBrains Mono, monospace' } },
|
||||
{ scale: true, gridIndex: 1, splitNumber: 2,
|
||||
// 成交量区不画背景横线
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: THEME.text, fontSize: 9, fontFamily: 'JetBrains Mono, monospace',
|
||||
formatter: (v: number) => fmtVol(v) } },
|
||||
],
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: [0, 1], start: zoomStart, end: 100 },
|
||||
{ type: 'slider', xAxisIndex: [0, 1], bottom: sliderBottom, height: SLIDER_H, start: zoomStart, end: 100,
|
||||
borderColor: 'transparent', fillerColor: 'rgba(255,255,255,0.06)',
|
||||
handleStyle: { color: '#52525B' }, textStyle: { color: THEME.text, fontSize: 10 } },
|
||||
],
|
||||
// 不弹 hover tooltip(用户要求);但保留十字线 axisPointer 作为缩放/定位参照
|
||||
tooltip: { show: false },
|
||||
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
||||
series,
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 + 数据更新
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return
|
||||
if (!chartInstRef.current) {
|
||||
chartInstRef.current = echarts.init(chartRef.current, undefined, { renderer: 'canvas' })
|
||||
chartInstRef.current.on('click', (params: any) => {
|
||||
// 预留:点击 K 线(非 markPoint/markLine)回调
|
||||
if (params.componentType === 'series' && params.seriesType === 'candlestick' && onDateClick) {
|
||||
onDateClick(dates[params.dataIndex])
|
||||
}
|
||||
})
|
||||
}
|
||||
chartInstRef.current.setOption(buildOption(), true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows, levels, activeTypes, pivotRank, markers, ranges, height])
|
||||
|
||||
// resize
|
||||
useEffect(() => {
|
||||
const inst = chartInstRef.current
|
||||
if (!inst) return
|
||||
const onResize = () => inst.resize()
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => { window.removeEventListener('resize', onResize); inst.dispose(); chartInstRef.current = null }
|
||||
}, [])
|
||||
|
||||
const toggleType = (t: LevelType) => {
|
||||
setActiveTypes(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(t)) next.delete(t)
|
||||
else next.add(t)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* 价位开关按钮组 */}
|
||||
{levels && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-2">
|
||||
<span className="text-[10px] text-muted mr-1">关键价位</span>
|
||||
{LEVEL_GROUPS.map(g => {
|
||||
const active = activeTypes.has(g.key)
|
||||
// 枢轴点数量按当前档位过滤显示;其他组显示原始数量
|
||||
const raw = levels[g.key] ?? []
|
||||
const count = g.key === 'pivot'
|
||||
? raw.filter(p => p.rank === undefined || p.rank <= pivotRank).length
|
||||
: raw.length
|
||||
return (
|
||||
<button
|
||||
key={g.key}
|
||||
onClick={() => toggleType(g.key)}
|
||||
disabled={raw.length === 0}
|
||||
title={`${g.label} (${count} 个)`}
|
||||
className={`inline-flex items-center gap-1 h-6 px-2 rounded-md text-[10px] font-medium border transition-all disabled:opacity-30 disabled:cursor-not-allowed ${
|
||||
active
|
||||
? 'text-foreground'
|
||||
: 'text-muted bg-base/40 border-border/30 hover:border-border/60'
|
||||
}`}
|
||||
style={active ? { borderColor: g.color + '66', backgroundColor: g.color + '1a' } : undefined}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: active ? g.color : '#52525B' }} />
|
||||
{g.label}
|
||||
<span className="opacity-50">{count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 枢轴点档位选择器 —— 仅当枢轴点开启时显示 */}
|
||||
{activeTypes.has('pivot') && (levels.pivot?.length ?? 0) > 0 && (
|
||||
<div className="inline-flex items-center gap-0.5 ml-1 pl-2 border-l border-border/40">
|
||||
<span className="text-[10px] text-muted mr-1">档位</span>
|
||||
{([1, 2, 3] as const).map(r => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setPivotRank(r)}
|
||||
title={r === 1 ? 'P + R1/S1(3 个)' : r === 2 ? '到 R2/S2(5 个)' : '全档 R3/S3(7 个)'}
|
||||
className={`h-6 px-2 rounded-md text-[10px] font-mono border transition-all ${
|
||||
pivotRank === r
|
||||
? 'bg-[#8B5CF6]/15 border-[#8B5CF6]/40 text-[#c4b5fd]'
|
||||
: 'text-muted bg-base/40 border-border/30 hover:border-border/60'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={chartRef} style={{ width: '100%', height }} />
|
||||
|
||||
{/* 价位概览:把当前开启的点位按"压力 / 支撑"结构化列出 */}
|
||||
{levels && (
|
||||
<LevelOverview
|
||||
levels={levels}
|
||||
activeTypes={activeTypes}
|
||||
pivotRank={pivotRank}
|
||||
close={rows.length ? rows[rows.length - 1].close : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 价位概览面板(结构化文本展示) =====
|
||||
function LevelOverview({
|
||||
levels, activeTypes, pivotRank, close,
|
||||
}: {
|
||||
levels: Record<LevelType, PriceLevel[]>
|
||||
activeTypes: Set<LevelType>
|
||||
pivotRank: 1 | 2 | 3
|
||||
close?: number
|
||||
}) {
|
||||
// 收集当前显示的点位(同 collectPriceLines 的过滤逻辑)
|
||||
const visible: PriceLevel[] = []
|
||||
for (const g of LEVEL_GROUPS) {
|
||||
if (!activeTypes.has(g.key)) continue
|
||||
for (const p of levels[g.key] ?? []) {
|
||||
if (p.type === 'pivot' && p.rank !== undefined && p.rank > pivotRank) continue
|
||||
visible.push(p)
|
||||
}
|
||||
}
|
||||
if (visible.length === 0) return null
|
||||
|
||||
// 按方向分两组:压力位(在当前价之上) / 支撑位(之下),各自按距当前价远近排序
|
||||
const cur = close ?? visible[0].value
|
||||
const resistances = visible
|
||||
.filter(p => p.side === 'resistance')
|
||||
.sort((a, b) => a.value - b.value) // 由近及远(低→高)
|
||||
const supports = visible
|
||||
.filter(p => p.side === 'support')
|
||||
.sort((a, b) => b.value - a.value) // 由近及远(高→低)
|
||||
const neutrals = visible.filter(p => p.side === 'neutral')
|
||||
|
||||
const fmtPct = (v: number) => {
|
||||
if (!cur) return ''
|
||||
const pct = ((v - cur) / cur) * 100
|
||||
const sign = pct >= 0 ? '+' : ''
|
||||
return `${sign}${pct.toFixed(1)}%`
|
||||
}
|
||||
|
||||
const Row = ({ p }: { p: PriceLevel }) => {
|
||||
const color = LEVEL_GROUPS.find(g => g.key === p.type)?.color ?? THEME.text
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||
<span className="text-[11px] text-secondary w-24 shrink-0 truncate">{p.label}</span>
|
||||
<span className="text-[11px] font-mono text-foreground">{p.value.toFixed(2)}</span>
|
||||
<span className="text-[9px] font-mono text-muted">{fmtPct(p.value)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 rounded-lg border border-border/40 bg-base/20 px-3 py-2">
|
||||
{/* 当前价 */}
|
||||
<div className="sm:col-span-2 flex items-center gap-2 pb-1 border-b border-border/30 mb-0.5">
|
||||
<span className="text-[10px] text-muted">当前价</span>
|
||||
<span className="text-xs font-mono font-medium text-foreground">{cur.toFixed(2)}</span>
|
||||
</div>
|
||||
{/* 压力位(从近到远,即从低到高)倒序展示:最高的在最上 */}
|
||||
{resistances.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-bear mb-0.5">压力位 ↑</div>
|
||||
{[...resistances].reverse().map((p, i) => <Row key={`r-${i}`} p={p} />)}
|
||||
</div>
|
||||
)}
|
||||
{/* 支撑位 + 中性(枢轴位 P) */}
|
||||
<div>
|
||||
{supports.length > 0 && (
|
||||
<>
|
||||
<div className="text-[10px] font-medium text-bull mb-0.5">支撑位 ↓</div>
|
||||
{supports.map((p, i) => <Row key={`s-${i}`} p={p} />)}
|
||||
</>
|
||||
)}
|
||||
{neutrals.length > 0 && (
|
||||
<div className={supports.length > 0 ? 'mt-2' : ''}>
|
||||
{supports.length === 0 && <div className="text-[10px] font-medium text-muted mb-0.5">枢轴位</div>}
|
||||
{neutrals.map((p, i) => <Row key={`n-${i}`} p={p} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 工具:收集要画的价位线(按开启的组 + 档位 + 强度配色) =====
|
||||
function collectPriceLines(
|
||||
levels: Record<LevelType, PriceLevel[]> | undefined,
|
||||
active: Set<LevelType>,
|
||||
pivotRank: 1 | 2 | 3,
|
||||
): { value: number; label: string; color: string }[] {
|
||||
if (!levels) return []
|
||||
const out: { value: number; label: string; color: string }[] = []
|
||||
for (const g of LEVEL_GROUPS) {
|
||||
if (!active.has(g.key)) continue
|
||||
for (const p of levels[g.key] ?? []) {
|
||||
// 枢轴点:按档位过滤(rank>P 的,只显示到选定的档位)
|
||||
if (p.type === 'pivot' && p.rank !== undefined && p.rank > pivotRank) continue
|
||||
out.push({ value: p.value, label: p.label, color: strengthColor(p.strength, g.color) })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function strengthColor(strength: string | undefined, base: string): string {
|
||||
// strong 用实色,medium 用 0.85,weak 用 0.55 透明
|
||||
if (strength === 'weak') return base + '8C'
|
||||
if (strength === 'medium') return base + 'D9'
|
||||
return base
|
||||
}
|
||||
|
||||
function fmtVol(v: number): string {
|
||||
if (!v) return '0'
|
||||
if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿'
|
||||
if (v >= 1e4) return (v / 1e4).toFixed(0) + '万'
|
||||
return v.toFixed(0)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Loader2, Check, AlertCircle } from 'lucide-react'
|
||||
import { useBubbleTasks, restoreDialog } from '@/lib/stockAnalysisStore'
|
||||
import type { ActiveTask } from '@/lib/stockAnalysisStore'
|
||||
|
||||
/**
|
||||
* AI 个股分析任务全局气泡 —— 与财务分析胶囊并列,蓝色主题区分。
|
||||
* 挂在右侧,拖拽丝滑(逻辑同 AiReportBubble,独立状态池)。
|
||||
*
|
||||
* 与财务胶囊的差异:蓝色系 + "个股分析中"文案,避免与财务分析混淆。
|
||||
*/
|
||||
|
||||
const BUBBLE_W = 148
|
||||
const EDGE_MARGIN = 12
|
||||
|
||||
export function StockAnalysisBubble() {
|
||||
const activeTasks = useBubbleTasks()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ x: number; y: number }>(() => loadPos())
|
||||
|
||||
const draggingRef = useRef(false)
|
||||
const dragData = useRef({ mx: 0, my: 0, ox: 0, oy: 0 })
|
||||
const movedRef = useRef(false)
|
||||
const clickTargetRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const applyTransform = useCallback((x: number, y: number) => {
|
||||
const el = containerRef.current
|
||||
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0)`
|
||||
}, [])
|
||||
|
||||
const clamp = useCallback((x: number, y: number) => {
|
||||
const maxX = window.innerWidth - BUBBLE_W - EDGE_MARGIN
|
||||
const maxY = window.innerHeight - 80
|
||||
return {
|
||||
x: Math.max(EDGE_MARGIN, Math.min(maxX, x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(maxY, y)),
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
draggingRef.current = true
|
||||
movedRef.current = false
|
||||
dragData.current = { mx: e.clientX, my: e.clientY, ox: pos.x, oy: pos.y }
|
||||
const el = containerRef.current
|
||||
if (el) el.classList.add('dragging')
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}, [pos.x, pos.y])
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!draggingRef.current) return
|
||||
const dx = e.clientX - dragData.current.mx
|
||||
const dy = e.clientY - dragData.current.my
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) movedRef.current = true
|
||||
const c = clamp(dragData.current.ox + dx, dragData.current.oy + dy)
|
||||
applyTransform(c.x, c.y)
|
||||
}, [clamp, applyTransform])
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
if (!draggingRef.current) return
|
||||
draggingRef.current = false
|
||||
const el = containerRef.current
|
||||
if (el) el.classList.remove('dragging')
|
||||
if (movedRef.current) {
|
||||
setPos(prev => {
|
||||
const transform = el?.style.transform ?? ''
|
||||
const m = transform.match(/translate3d\(([-\d.]+)px,\s*([-\d.]+)px/)
|
||||
const finalPos = m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : prev
|
||||
savePos(finalPos)
|
||||
return finalPos
|
||||
})
|
||||
} else {
|
||||
const fn = clickTargetRef.current
|
||||
clickTargetRef.current = null
|
||||
fn?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
setPos(prev => {
|
||||
const c = clamp(prev.x, prev.y)
|
||||
if (c.x !== prev.x || c.y !== prev.y) {
|
||||
applyTransform(c.x, c.y)
|
||||
return c
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [clamp, applyTransform])
|
||||
|
||||
useEffect(() => { applyTransform(pos.x, pos.y) }, [pos.x, pos.y, applyTransform])
|
||||
|
||||
if (activeTasks.length === 0) return null
|
||||
|
||||
// 默认位置偏下,避免与财务胶囊(右下)重叠
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="sa-bubble-root fixed z-[60] select-none cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
width: `${BUBBLE_W}px`,
|
||||
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
|
||||
touchAction: 'none',
|
||||
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{activeTasks.map((task, i) => (
|
||||
<BubbleItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isLast={i === activeTasks.length - 1}
|
||||
onPointerDown={() => { clickTargetRef.current = () => restoreDialog(task.id) }}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<style>{`.sa-bubble-root.dragging { transition: none !important; }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BubbleItem({ task, isLast, onPointerDown }: {
|
||||
task: ActiveTask
|
||||
isLast: boolean
|
||||
onPointerDown: () => void
|
||||
}) {
|
||||
const isWorking = task.phase === 'loading' || task.phase === 'streaming'
|
||||
const isError = task.phase === 'error'
|
||||
|
||||
// 蓝色系(区别于财务分析的紫色)
|
||||
const accent = isWorking
|
||||
? 'from-sky-500/25 to-blue-500/20 text-sky-300 border-sky-300/40 shadow-[0_6px_24px_-10px_rgba(14,165,233,0.5)]'
|
||||
: isError
|
||||
? 'from-red-500/20 to-red-500/10 text-red-300 border-red-300/40 shadow-[0_6px_20px_-10px_rgba(239,68,68,0.4)]'
|
||||
: 'from-emerald-500/20 to-emerald-500/10 text-emerald-300 border-emerald-300/40 shadow-[0_6px_20px_-10px_rgba(16,185,129,0.35)]'
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -8 }}
|
||||
transition={{ type: 'spring', damping: 22, stiffness: 300 }}
|
||||
className={isLast ? '' : 'mb-1.5'}
|
||||
>
|
||||
<div
|
||||
onPointerDown={onPointerDown}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={isWorking ? '个股分析中,点击恢复' : isError ? '分析失败,点击重试' : '点击查看个股分析报告'}
|
||||
className={`group relative flex w-full cursor-pointer items-center gap-1.5 overflow-hidden rounded-lg border bg-gradient-to-br px-2 py-1.5 backdrop-blur-xl transition-all duration-200 hover:scale-[1.02] active:scale-[0.99] ${accent}`}
|
||||
>
|
||||
{isWorking && (
|
||||
<div className="absolute inset-x-0 top-0 h-px overflow-hidden">
|
||||
<div className="h-full w-1/2 bg-gradient-to-r from-transparent via-sky-200 to-transparent animate-sa-bubble-progress" />
|
||||
</div>
|
||||
)}
|
||||
<span className="flex h-4 w-4 items-center justify-center shrink-0">
|
||||
{isWorking ? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: isError ? <AlertCircle className="h-3 w-3" />
|
||||
: <Check className="h-3 w-3" />}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 text-[11px] font-medium text-foreground leading-none truncate">
|
||||
{task.name || task.symbol}
|
||||
</span>
|
||||
<span className="shrink-0 text-[9px] leading-none">
|
||||
{isWorking ? <span className="text-sky-300/80">个股分析</span>
|
||||
: isError ? <span className="text-red-300/80">失败</span>
|
||||
: <span className="text-emerald-300/80">点击查看</span>}
|
||||
</span>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes sa-bubble-progress { 0% { transform: translateX(-100%); } 100% { transform: translateX(300%); } }
|
||||
.animate-sa-bubble-progress { animation: sa-bubble-progress 1.6s ease-in-out infinite; }
|
||||
`}</style>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
const POS_KEY = 'sa_bubble_pos'
|
||||
function loadPos(): { x: number; y: number } {
|
||||
// 默认右下,偏上一点(避开财务胶囊的右下位置)
|
||||
const defaultX = Math.max(EDGE_MARGIN, window.innerWidth - BUBBLE_W - EDGE_MARGIN)
|
||||
const defaultY = Math.max(EDGE_MARGIN, window.innerHeight - 320)
|
||||
try {
|
||||
const v = localStorage.getItem(POS_KEY)
|
||||
if (v) {
|
||||
const p = JSON.parse(v)
|
||||
if (typeof p.x === 'number' && typeof p.y === 'number') {
|
||||
return {
|
||||
x: Math.max(EDGE_MARGIN, Math.min(window.innerWidth - BUBBLE_W - EDGE_MARGIN, p.x)),
|
||||
y: Math.max(EDGE_MARGIN, Math.min(window.innerHeight - 80, p.y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { x: defaultX, y: defaultY }
|
||||
}
|
||||
function savePos(p: { x: number; y: number }) {
|
||||
try { localStorage.setItem(POS_KEY, JSON.stringify(p)) } catch { /* ignore */ }
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
X, Sparkles, Loader2, AlertTriangle, Copy, Check, RefreshCw,
|
||||
Settings2, Send, Wand2, Minimize2, History, LineChart,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
|
||||
import {
|
||||
type ActiveTask, type HistoryReport,
|
||||
minimizeDialog, closeDialog, startAnalysis,
|
||||
} from '@/lib/stockAnalysisStore'
|
||||
|
||||
/**
|
||||
* AI 个股分析对话框 —— 蓝色主题,与财务分析对话框区分。
|
||||
* 复用 MarkdownRenderer(通用 markdown 渲染);标题/配色/文案独立。
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
task: ActiveTask | HistoryReport | null
|
||||
mode: 'active' | 'history' | null
|
||||
minimized: boolean
|
||||
}
|
||||
|
||||
type Phase = 'loading' | 'streaming' | 'done' | 'error'
|
||||
|
||||
function getPhase(task: ActiveTask | HistoryReport | null): Phase {
|
||||
if (!task) return 'loading'
|
||||
if ('phase' in task) return task.phase
|
||||
return 'done'
|
||||
}
|
||||
function getContent(task: ActiveTask | HistoryReport | null): string {
|
||||
return task?.content ?? ''
|
||||
}
|
||||
function getMeta(task: ActiveTask | HistoryReport | null) {
|
||||
if (!task) return null
|
||||
if ('meta' in task) return task.meta
|
||||
return { summary: task.summary, close: task.close, levels: task.levels }
|
||||
}
|
||||
|
||||
export function StockAnalysisDialog({ task, mode, minimized }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [focus, setFocus] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const phase = getPhase(task)
|
||||
const content = getContent(task)
|
||||
const meta = getMeta(task)
|
||||
const isHistory = mode === 'history'
|
||||
const isWorking = phase === 'loading' || phase === 'streaming'
|
||||
const open = !!task && !minimized
|
||||
|
||||
useEffect(() => {
|
||||
if (open && phase === 'streaming' && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [content, phase, open])
|
||||
|
||||
useEffect(() => {
|
||||
setFocus(task && 'focus' in task ? task.focus : '')
|
||||
}, [task])
|
||||
|
||||
const handleStartNew = useCallback(async () => {
|
||||
if (!task) return
|
||||
const name = 'name' in task ? task.name : ''
|
||||
await startAnalysis(task.symbol, name, focus.trim())
|
||||
}, [task, focus])
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!content) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const error = task && 'error' in task ? task.error : ''
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget && !isWorking) closeDialog() }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 12 }}
|
||||
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
|
||||
className="w-full max-w-3xl max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* 头部 —— 蓝色主题 */}
|
||||
<div className="relative px-5 py-3.5 border-b border-border/50 bg-gradient-to-r from-sky-500/[0.06] via-blue-500/[0.04] to-transparent">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500/20 to-blue-500/15 border border-sky-400/30 shrink-0">
|
||||
{isHistory
|
||||
? <History className="h-4.5 w-4.5 text-sky-300" />
|
||||
: <LineChart className="h-4.5 w-4.5 text-sky-300" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-foreground truncate">
|
||||
{isHistory ? '历史分析报告' : 'AI 个股分析'}
|
||||
</span>
|
||||
{task && <span className="text-xs text-secondary truncate">{task.name}</span>}
|
||||
{task && <span className="text-[10px] font-mono text-muted shrink-0">{task.symbol}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-[10px] text-muted">
|
||||
{meta?.summary ? (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Sparkles className="h-2.5 w-2.5 shrink-0" />
|
||||
<span className="truncate">{meta.summary}</span>
|
||||
</span>
|
||||
) : isWorking ? <span>正在读取行情与价位数据…</span> : null}
|
||||
{phase === 'streaming' && (
|
||||
<span className="flex items-center gap-1 text-sky-300 shrink-0">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-sky-400 animate-pulse" />生成中
|
||||
</span>
|
||||
)}
|
||||
{isHistory && task && 'created_at' in task && (
|
||||
<span className="shrink-0">{fmtRelative(task.created_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{content && !isWorking && (
|
||||
<button onClick={handleCopy} title="复制全文"
|
||||
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
|
||||
{copied ? <Check className="h-4 w-4 text-emerald-400" /> : <Copy className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
{!isHistory && isWorking && (
|
||||
<button onClick={minimizeDialog} title="最小化为气泡,后台继续生成"
|
||||
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{(!isWorking || isHistory) && (
|
||||
<button onClick={closeDialog} title="关闭"
|
||||
className="p-1.5 rounded-lg hover:bg-elevated text-muted hover:text-foreground transition-colors">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-5 py-4 min-h-[280px]">
|
||||
{phase === 'loading' && !content && (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||
<div className="relative">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-sky-500/20 to-blue-500/15 border border-sky-400/30 flex items-center justify-center">
|
||||
<LineChart className="h-4.5 w-4.5 text-sky-300 animate-pulse" />
|
||||
</div>
|
||||
<Loader2 className="absolute -inset-1 h-12 w-12 text-sky-400/40 animate-spin" style={{ animationDuration: '3s' }} />
|
||||
</div>
|
||||
<div className="text-xs text-secondary">AI 正在分析行情与关键价位…</div>
|
||||
<div className="text-[10px] text-muted">读取日 K / 技术指标 / 压力支撑 / 财务,生成四维分析</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'error' && (
|
||||
<div className="flex flex-col items-center justify-center py-14 gap-3">
|
||||
<div className="h-11 w-11 rounded-full bg-danger/10 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">分析失败</div>
|
||||
<div className="text-xs text-secondary text-center max-w-md px-4">{error}</div>
|
||||
{error.includes('AI') && (
|
||||
<button onClick={() => { window.location.href = '/settings?tab=ai' }}
|
||||
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
|
||||
<Settings2 className="h-3.5 w-3.5" /> 去配置 AI
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleStartNew}
|
||||
className="mt-1 inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-sky-500/15 border border-sky-400/30 text-xs text-sky-300 hover:bg-sky-500/20 transition-colors">
|
||||
<RefreshCw className="h-3.5 w-3.5" /> 重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(content || phase === 'streaming') && (
|
||||
<div className="relative">
|
||||
<MarkdownRenderer content={content} />
|
||||
{phase === 'streaming' && (
|
||||
<span className="inline-block w-1.5 h-3.5 bg-sky-400 ml-0.5 align-middle animate-pulse rounded-sm" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部:关注点输入 */}
|
||||
<div className="border-t border-border/50 bg-surface/60 px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted shrink-0">
|
||||
<Wand2 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">关注重点</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={focus}
|
||||
onChange={e => setFocus(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && (phase === 'done' || phase === 'error' || isHistory)) handleStartNew() }}
|
||||
disabled={isWorking}
|
||||
placeholder={isHistory ? '修改关注重点,回车重新生成' : (phase === 'done' ? '如:重点看能否突破压力位…回车重新分析' : '可留空,留空则全面分析')}
|
||||
className={cn(
|
||||
'flex-1 h-8 px-3 rounded-lg bg-base ring-1 ring-border/30 text-xs text-foreground placeholder:text-muted/40',
|
||||
'focus:outline-none focus:ring-2 focus:ring-sky-400/30 transition-shadow disabled:opacity-50',
|
||||
)}
|
||||
/>
|
||||
{isHistory ? (
|
||||
<button
|
||||
onClick={handleStartNew}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs font-medium text-sky-300 hover:from-sky-500/30 hover:to-blue-500/20 transition-all shrink-0"
|
||||
title="以此关注点重新生成新报告"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />重新生成
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStartNew}
|
||||
disabled={isWorking}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs font-medium text-sky-300 hover:from-sky-500/30 hover:to-blue-500/20 disabled:opacity-40 disabled:cursor-not-allowed transition-all shrink-0"
|
||||
title={focus.trim() ? '按关注重点重新分析' : '重新分析'}
|
||||
>
|
||||
{isWorking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : phase === 'done' ? <RefreshCw className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
|
||||
{phase === 'done' ? '重新分析' : '分析'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] text-muted/50 leading-relaxed">
|
||||
{isHistory
|
||||
? '历史报告为静态记录;修改关注重点后将作为新任务重新生成。报告仅供参考,不构成投资建议。'
|
||||
: '报告由项目已配置的 AI 模型基于本地行情与财务数据生成;消息面维度暂依据价量异动推断。报告仅供参考,不构成投资建议。'}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtRelative(iso: string): string {
|
||||
try {
|
||||
const t = new Date(iso).getTime()
|
||||
const diff = Date.now() - t
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
|
||||
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
|
||||
return new Date(iso).toLocaleDateString('zh-CN')
|
||||
} catch { return '' }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useDialogTask, useDialogState } from '@/lib/stockAnalysisStore'
|
||||
import { StockAnalysisDialog } from './StockAnalysisDialog'
|
||||
|
||||
/**
|
||||
* AI 个股分析对话框宿主 —— 单点挂载在 Layout。
|
||||
* 与财务分析的 AiAnalysisHost 并列,独立 store,蓝色主题。
|
||||
*/
|
||||
export function StockAnalysisHost() {
|
||||
const { task, mode } = useDialogTask()
|
||||
const { minimized } = useDialogState()
|
||||
return <StockAnalysisDialog task={task} mode={mode} minimized={minimized} />
|
||||
}
|
||||
@@ -116,6 +116,38 @@ export interface AiFinancialReport {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// ===== 个股分析 =====
|
||||
export type LevelType = 'sr' | 'profile' | 'pivot' | 'extreme' | 'keltner' | 'atr_stop' | 'gap' | 'fib' | 'round'
|
||||
|
||||
export interface PriceLevel {
|
||||
value: number
|
||||
label: string
|
||||
type: LevelType
|
||||
side: 'resistance' | 'support' | 'neutral'
|
||||
strength?: 'strong' | 'medium' | 'weak'
|
||||
/** 档位(仅 pivot 有):0=P, 1=R1/S1, 2=R2/S2, 3=R3/S3。前端按"显示到第几档"过滤。 */
|
||||
rank?: number
|
||||
}
|
||||
|
||||
export interface StockLevels {
|
||||
levels: Record<LevelType, PriceLevel[]>
|
||||
close: number | null
|
||||
summary: string
|
||||
symbol: string
|
||||
}
|
||||
|
||||
export interface AiStockReport {
|
||||
id: string
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
content: string
|
||||
summary?: string
|
||||
close?: number | null
|
||||
levels?: Record<LevelType, PriceLevel[]>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// ===== Kline =====
|
||||
export interface MinuteKlineRow {
|
||||
datetime: string
|
||||
@@ -654,6 +686,10 @@ export const api = {
|
||||
body: JSON.stringify(ai),
|
||||
}),
|
||||
|
||||
/** 一键清空 AI 配置(保留自定义 UA) */
|
||||
clearAiSettings: () =>
|
||||
request<{ ok: boolean }>('/api/settings/ai', { method: 'DELETE' }),
|
||||
|
||||
preferences: () => request<Preferences>('/api/settings/preferences'),
|
||||
updateMinuteSync: (enabled: boolean, days: number) =>
|
||||
request<Preferences>('/api/settings/preferences/minute-sync', {
|
||||
@@ -1258,6 +1294,72 @@ export const api = {
|
||||
}
|
||||
},
|
||||
|
||||
// ===== 个股分析 =====
|
||||
stockAnalysisLevels: (symbol: string, days = 120) =>
|
||||
request<StockLevels>(`/api/stock-analysis/levels?symbol=${encodeURIComponent(symbol)}&days=${days}`),
|
||||
|
||||
stockAnalysisReportsList: () =>
|
||||
request<{ reports: AiStockReport[] }>('/api/stock-analysis/reports'),
|
||||
|
||||
stockAnalysisReportSave: (r: {
|
||||
symbol: string; name?: string; focus?: string; content: string
|
||||
summary?: string; close?: number | null
|
||||
levels?: Record<LevelType, PriceLevel[]>
|
||||
}) =>
|
||||
request<{ ok: boolean; report: AiStockReport }>('/api/stock-analysis/reports', {
|
||||
method: 'POST', body: JSON.stringify(r),
|
||||
}),
|
||||
|
||||
stockAnalysisReportDelete: (reportId: string) =>
|
||||
request<{ ok: boolean }>(`/api/stock-analysis/reports/${encodeURIComponent(reportId)}`, { method: 'DELETE' }),
|
||||
|
||||
/**
|
||||
* AI 个股四维分析 — 流式调用(NDJSON,与财务分析同协议)。
|
||||
* meta 里额外带 levels(关键价位)供图表回放。
|
||||
*/
|
||||
async *stockAnalyzeStream(symbol: string, focus?: string): AsyncGenerator<{
|
||||
type: 'meta' | 'delta' | 'error' | 'done'
|
||||
symbol?: string
|
||||
summary?: string
|
||||
levels?: Record<LevelType, PriceLevel[]>
|
||||
close?: number | null
|
||||
content?: string
|
||||
message?: string
|
||||
}> {
|
||||
const res = await fetch('/api/stock-analysis/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ symbol, focus: focus ?? '' }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
let detail = ''
|
||||
try { const j = JSON.parse(await res.text()); detail = j.detail ?? j.message ?? '' } catch { /* ignore */ }
|
||||
const msg = detail || `${res.status} ${res.statusText}`
|
||||
toast(msg, 'error')
|
||||
throw new Error(msg)
|
||||
}
|
||||
if (!res.body) throw new Error('响应无 body')
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
const s = line.trim()
|
||||
if (!s) continue
|
||||
try { yield JSON.parse(s) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
if (buf.trim()) {
|
||||
try { yield JSON.parse(buf.trim()) } catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
|
||||
// ===== Strategy Engine =====
|
||||
strategyList: () =>
|
||||
request<{ strategies: StrategyDetail[] }>('/api/strategies'),
|
||||
|
||||
@@ -50,6 +50,7 @@ export const QK = {
|
||||
// Kline
|
||||
kline: (symbol: string, start: string, end: string, extColumns?: string) =>
|
||||
['kline', symbol, start, end, extColumns ?? ''] as const,
|
||||
stockLevels: (symbol: string, days?: number) => ['stock-levels', symbol, days ?? 120] as const,
|
||||
klineMinute: (symbol: string, date: string) =>
|
||||
['kline-minute', symbol, date] as const,
|
||||
indexDaily: (symbol: string, start: string, end: string) =>
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api, type PriceLevel, type LevelType } from './api'
|
||||
|
||||
/**
|
||||
* AI 个股分析 —— 全局任务/报告 store(与 aiReportStore 解耦、并行存在)。
|
||||
*
|
||||
* 与财务分析 store 的区别:
|
||||
* - 独立的 activeTasks / history 状态池(不共享 3 并发上限)
|
||||
* - meta 额外带 levels(关键价位),供图表回放
|
||||
* - 状态文案在胶囊组件里用「蓝」色系区分(财务用紫)
|
||||
*
|
||||
* 设计同 aiReportStore:流式接收逻辑在此,与弹窗解耦 → 关闭弹窗后台照常累积。
|
||||
*/
|
||||
|
||||
export type Phase = 'loading' | 'streaming' | 'done' | 'error'
|
||||
|
||||
export interface ActiveTask {
|
||||
id: string
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
phase: Phase
|
||||
content: string
|
||||
error: string
|
||||
meta: {
|
||||
summary?: string
|
||||
levels?: Record<LevelType, PriceLevel[]>
|
||||
close?: number | null
|
||||
} | null
|
||||
createdAt: number
|
||||
savedReportId?: string
|
||||
doneAt?: number
|
||||
dismissed?: boolean
|
||||
}
|
||||
|
||||
export interface HistoryReport {
|
||||
id: string
|
||||
symbol: string
|
||||
name: string
|
||||
focus: string
|
||||
content: string
|
||||
summary?: string
|
||||
close?: number | null
|
||||
levels?: Record<LevelType, PriceLevel[]>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const MAX_ACTIVE = 3
|
||||
|
||||
let activeTasks: ActiveTask[] = []
|
||||
let history: HistoryReport[] = []
|
||||
let historyLoaded = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
let activeDialogTaskId: string | null = null
|
||||
let dialogMinimized = false
|
||||
|
||||
function emit() { listeners.forEach(fn => fn()) }
|
||||
function subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn) } }
|
||||
|
||||
let _activeSnap: ActiveTask[] = []
|
||||
let _historySnap: HistoryReport[] = []
|
||||
interface DialogSnap { taskId: string | null; minimized: boolean }
|
||||
let _dialogSnap: DialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
||||
|
||||
function rebuildSnap() {
|
||||
_activeSnap = activeTasks
|
||||
_historySnap = history
|
||||
_dialogSnap = { taskId: activeDialogTaskId, minimized: dialogMinimized }
|
||||
}
|
||||
|
||||
function getActiveSnapshot() { return _activeSnap }
|
||||
function getHistorySnapshot() { return _historySnap }
|
||||
function getDialogSnapshot() { return _dialogSnap }
|
||||
|
||||
function patchTask(id: string, patch: Partial<ActiveTask>) {
|
||||
activeTasks = activeTasks.map(t => {
|
||||
if (t.id !== id) return t
|
||||
const next = { ...t, ...patch }
|
||||
if ((patch.phase === 'done' || patch.phase === 'error') && t.phase !== patch.phase && !next.doneAt) {
|
||||
next.doneAt = Date.now()
|
||||
}
|
||||
return next
|
||||
})
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
|
||||
// ===== 查询 hooks =====
|
||||
|
||||
export function useBubbleTasks(): ActiveTask[] {
|
||||
const all = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
||||
useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
||||
const ds = _dialogSnap
|
||||
return all.filter(t => {
|
||||
if (t.phase === 'loading' || t.phase === 'streaming') {
|
||||
return !(ds.taskId === t.id && !ds.minimized)
|
||||
}
|
||||
if (t.dismissed) return false
|
||||
if (!ds.minimized && ds.taskId === t.id) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function useHistoryReports(): { reports: HistoryReport[]; loaded: boolean } {
|
||||
const reports = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
||||
return { reports, loaded: historyLoaded }
|
||||
}
|
||||
|
||||
export function useDialogState() {
|
||||
return useSyncExternalStore(subscribe, getDialogSnapshot, () => ({ taskId: null, minimized: false }))
|
||||
}
|
||||
|
||||
export function useDialogTask(): { task: ActiveTask | HistoryReport | null; mode: 'active' | 'history' | null } {
|
||||
const ds = useDialogState()
|
||||
const active = useSyncExternalStore(subscribe, getActiveSnapshot, () => [])
|
||||
const hist = useSyncExternalStore(subscribe, getHistorySnapshot, () => [])
|
||||
if (!ds.taskId) return { task: null, mode: null }
|
||||
if (ds.taskId.startsWith('history:')) {
|
||||
const rid = ds.taskId.slice('history:'.length)
|
||||
return { task: hist.find(r => r.id === rid) ?? null, mode: 'history' }
|
||||
}
|
||||
return { task: active.find(t => t.id === ds.taskId) ?? null, mode: 'active' }
|
||||
}
|
||||
|
||||
// ===== 动作 =====
|
||||
|
||||
export async function loadHistory(): Promise<void> {
|
||||
try {
|
||||
const res = await api.stockAnalysisReportsList()
|
||||
history = res.reports ?? []
|
||||
historyLoaded = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
|
||||
export async function findLatestHistoryReport(symbol: string): Promise<HistoryReport | null> {
|
||||
if (!historyLoaded) await loadHistory()
|
||||
return history.find(r => r.symbol === symbol) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某只股票【当日】是否已生成过分析报告(用于二次确认)。
|
||||
* 判断依据:created_at 的日期部分 == 本地今天。
|
||||
* @returns 当天最近一条报告,或 null
|
||||
*/
|
||||
export async function findTodayReport(symbol: string): Promise<HistoryReport | null> {
|
||||
if (!historyLoaded) await loadHistory()
|
||||
const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
|
||||
return history.find(r => r.symbol === symbol && (r.created_at ?? '').slice(0, 10) === today) ?? null
|
||||
}
|
||||
|
||||
export async function startAnalysis(symbol: string, name: string, focus = ''): Promise<{ id?: string; error?: string }> {
|
||||
const existing = activeTasks.find(t => t.symbol === symbol && (t.phase === 'loading' || t.phase === 'streaming'))
|
||||
if (existing) {
|
||||
activeDialogTaskId = existing.id
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
return { id: existing.id }
|
||||
}
|
||||
const ongoing = activeTasks.filter(t => t.phase === 'loading' || t.phase === 'streaming')
|
||||
if (ongoing.length >= MAX_ACTIVE) {
|
||||
return { error: `同时进行的个股分析任务不能超过 ${MAX_ACTIVE} 个,请等待现有任务完成` }
|
||||
}
|
||||
|
||||
const id = `stask_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const task: ActiveTask = {
|
||||
id, symbol, name, focus,
|
||||
phase: 'loading', content: '', error: '',
|
||||
meta: null, createdAt: Date.now(),
|
||||
}
|
||||
activeTasks = [...activeTasks, task]
|
||||
activeDialogTaskId = id
|
||||
dialogMinimized = false
|
||||
rebuildSnap()
|
||||
emit()
|
||||
|
||||
runStream(id, symbol, name, focus)
|
||||
return { id }
|
||||
}
|
||||
|
||||
async function runStream(id: string, symbol: string, _name: string, focus: string) {
|
||||
try {
|
||||
let firstDelta = true
|
||||
for await (const chunk of api.stockAnalyzeStream(symbol, focus)) {
|
||||
const cur = activeTasks.find(t => t.id === id)
|
||||
if (!cur) return
|
||||
switch (chunk.type) {
|
||||
case 'meta':
|
||||
patchTask(id, { meta: { summary: chunk.summary, levels: chunk.levels, close: chunk.close } })
|
||||
break
|
||||
case 'delta':
|
||||
if (firstDelta) { patchTask(id, { phase: 'streaming' }); firstDelta = false }
|
||||
patchTask(id, { content: cur.content + (chunk.content ?? '') })
|
||||
break
|
||||
case 'error':
|
||||
patchTask(id, { phase: 'error', error: chunk.message ?? '分析失败' })
|
||||
return
|
||||
case 'done':
|
||||
patchTask(id, { phase: 'done' })
|
||||
break
|
||||
}
|
||||
}
|
||||
const final = activeTasks.find(t => t.id === id)
|
||||
if (final && final.phase !== 'error') {
|
||||
// 兜底:流正常结束但从未收到 delta(后端在生成内容前异常断流)→ 标记失败,避免卡死
|
||||
if (!final.content) {
|
||||
patchTask(id, { phase: 'error', error: '分析未返回内容(后端可能异常中断),请重试' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.stockAnalysisReportSave({
|
||||
symbol: final.symbol, name: final.name, focus: final.focus,
|
||||
content: final.content, summary: final.meta?.summary ?? '',
|
||||
close: final.meta?.close ?? null, levels: final.meta?.levels,
|
||||
})
|
||||
if (res.report) {
|
||||
patchTask(id, { savedReportId: res.report.id })
|
||||
history = [res.report, ...history.filter(r => r.id !== res.report.id)]
|
||||
historyLoaded = true
|
||||
rebuildSnap()
|
||||
emit()
|
||||
}
|
||||
} catch { /* 持久化失败不影响展示 */ }
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.message ?? '分析失败')
|
||||
patchTask(id, {
|
||||
phase: 'error',
|
||||
error: msg.includes('API Key') || msg.includes('api_key')
|
||||
? 'AI API Key 未配置或无效,请在「设置 → AI」中配置'
|
||||
: msg,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function openDialog(taskId: string) {
|
||||
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
|
||||
}
|
||||
export function minimizeDialog() {
|
||||
dialogMinimized = true; rebuildSnap(); emit()
|
||||
}
|
||||
export function closeDialog() {
|
||||
activeDialogTaskId = null; dialogMinimized = false; rebuildSnap(); emit()
|
||||
}
|
||||
export function restoreDialog(taskId: string) {
|
||||
const t = activeTasks.find(x => x.id === taskId)
|
||||
if (t && (t.phase === 'done' || t.phase === 'error')) {
|
||||
patchTask(taskId, { dismissed: true })
|
||||
}
|
||||
activeDialogTaskId = taskId; dialogMinimized = false; rebuildSnap(); emit()
|
||||
}
|
||||
export async function retryAnalysis(task: { symbol: string; name: string; focus: string }): Promise<{ error?: string }> {
|
||||
return startAnalysis(task.symbol, task.name, task.focus)
|
||||
}
|
||||
export async function deleteReport(reportId: string): Promise<void> {
|
||||
try {
|
||||
await api.stockAnalysisReportDelete(reportId)
|
||||
history = history.filter(r => r.id !== reportId)
|
||||
rebuildSnap()
|
||||
emit()
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
export function openHistoryReport(reportId: string) {
|
||||
activeDialogTaskId = `history:${reportId}`; dialogMinimized = false; rebuildSnap(); emit()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
/**
|
||||
* 记忆"上次查看的个股"(按页面维度,localStorage 持久化)。
|
||||
*
|
||||
* 两个分析页(财务 / 个股)各自独立记忆,key 区分:
|
||||
* - financials: 最后查看的财务分析个股
|
||||
* - stock-analysis: 最后查看的个股分析个股
|
||||
*
|
||||
* 用法:
|
||||
* const { last, remember } = useLastStock('stock-analysis')
|
||||
* remember('000001.SZ', '平安银行') // 选中股票时调用
|
||||
* <LastStockChip stock={last} ... /> // 渲染在 PageHeader 右侧
|
||||
*/
|
||||
|
||||
export interface StockRef { symbol: string; name: string }
|
||||
|
||||
const PREFIX = 'last_stock:'
|
||||
|
||||
export function useLastStock(scope: string) {
|
||||
const [last, setLast] = useState<StockRef | null>(() => load(scope))
|
||||
|
||||
const remember = useCallback((symbol: string, name: string) => {
|
||||
const ref = { symbol, name }
|
||||
setLast(ref)
|
||||
save(scope, ref)
|
||||
}, [scope])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setLast(null)
|
||||
save(scope, null)
|
||||
}, [scope])
|
||||
|
||||
return { last, remember, clear }
|
||||
}
|
||||
|
||||
function load(scope: string): StockRef | null {
|
||||
try {
|
||||
const v = localStorage.getItem(PREFIX + scope)
|
||||
if (!v) return null
|
||||
const p = JSON.parse(v)
|
||||
if (p && typeof p.symbol === 'string' && typeof p.name === 'string') return p
|
||||
} catch { /* ignore */ }
|
||||
return null
|
||||
}
|
||||
|
||||
function save(scope: string, ref: StockRef | null) {
|
||||
try {
|
||||
if (ref) localStorage.setItem(PREFIX + scope, JSON.stringify(ref))
|
||||
else localStorage.removeItem(PREFIX + scope)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials'
|
||||
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
|
||||
import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail'
|
||||
import { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel'
|
||||
import { LastStockChip } from '@/components/LastStockChip'
|
||||
import { useLastStock } from '@/lib/useLastStock'
|
||||
import { fmtBigNum } from '@/lib/format'
|
||||
import { toast } from '@/components/Toast'
|
||||
|
||||
@@ -49,6 +51,11 @@ export function Financials() {
|
||||
}, [syncing, syncStartedAt])
|
||||
// 选中的个股(模糊搜索结果);null 时显示搜索引导
|
||||
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
|
||||
const { last: lastStock, remember: rememberStock } = useLastStock('financials')
|
||||
const pick = (symbol: string, name: string) => {
|
||||
setSelected({ symbol, name })
|
||||
rememberStock(symbol, name)
|
||||
}
|
||||
|
||||
if (!hasFinancial) {
|
||||
return (
|
||||
@@ -129,6 +136,7 @@ export function Financials() {
|
||||
subtitle="利润表 / 资负表 / 现金流 / 关键指标 / AI分析 · Expert"
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<LastStockChip stock={lastStock} onSelect={pick} />
|
||||
{syncing && (
|
||||
<span className="text-xs text-accent/80 flex items-center gap-1.5">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
@@ -250,7 +258,7 @@ export function Financials() {
|
||||
// 已选股:紧凑搜索条 + 清除按钮(便于换股)
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 max-w-xl">
|
||||
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
|
||||
<StockFinancialSearch onSelect={pick} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
@@ -269,7 +277,7 @@ export function Financials() {
|
||||
<span>搜索个股查看详细财务数据</span>
|
||||
</div>
|
||||
<div className="w-full max-w-xl">
|
||||
<StockFinancialSearch onSelect={(symbol, name) => setSelected({ symbol, name })} />
|
||||
<StockFinancialSearch onSelect={pick} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">支持股票代码或名称模糊匹配,如 600000 / 浦发</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,292 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Sparkles, LineChart, History as HistoryIcon, Loader2, ExternalLink } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { LastStockChip } from '@/components/LastStockChip'
|
||||
import { AnalysisKChart, type PriceLevel, type LevelType } from '@/components/stock-analysis/AnalysisKChart'
|
||||
import { api } from '@/lib/api'
|
||||
import { useLastStock } from '@/lib/useLastStock'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { toast } from '@/components/Toast'
|
||||
import {
|
||||
startAnalysis, findTodayReport, useHistoryReports,
|
||||
deleteReport, openHistoryReport,
|
||||
} from '@/lib/stockAnalysisStore'
|
||||
|
||||
/**
|
||||
* 个股分析页 —— 日 K + 关键价位(压力/支撑/密集区/枢轴/前高前低)+ AI 四维分析。
|
||||
*
|
||||
* 与财务分析页的区别:
|
||||
* - 以【行情 + 关键价位】为视觉主体(专用日 K 图表,不复用个股对话框图表)
|
||||
* - AI 分析输出买卖区间 / 操作建议(非财务质量评级)
|
||||
* - 报告胶囊用蓝色系,与财务分析(紫色)并存
|
||||
*/
|
||||
export function StockAnalysis() {
|
||||
const [symbol, setSymbol] = useState<string>('')
|
||||
const [name, setName] = useState<string>('')
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
|
||||
const { last: lastStock, remember: rememberStock } = useLastStock('stock-analysis')
|
||||
|
||||
const onSelect = (sym: string, nm: string) => {
|
||||
setSymbol(sym)
|
||||
setName(nm)
|
||||
setShowHistory(false)
|
||||
setConfirmReport(null)
|
||||
rememberStock(sym, nm)
|
||||
}
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!symbol || checking) return
|
||||
setChecking(true)
|
||||
try {
|
||||
// 当日已分析过 → 二次确认(查看今日报告 / 重新分析)
|
||||
const today = await findTodayReport(symbol)
|
||||
if (today) {
|
||||
setConfirmReport({ id: today.id, created_at: today.created_at, focus: today.focus })
|
||||
} else {
|
||||
await doAnalysis()
|
||||
}
|
||||
} catch {
|
||||
await doAnalysis()
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const doAnalysis = async () => {
|
||||
const r = await startAnalysis(symbol, name)
|
||||
if (r.error) toast(r.error, 'error')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<h2 className="text-lg text-secondary">个股分析</h2>
|
||||
<p className="mt-2 text-sm text-muted">开发中...</p>
|
||||
<>
|
||||
<PageHeader
|
||||
title="个股分析"
|
||||
titleExtra={
|
||||
<span className="inline-flex items-center rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400">
|
||||
Beta
|
||||
</span>
|
||||
}
|
||||
subtitle="日 K · 关键价位 · AI 四维分析(技术 / 基本面 / 财务 / 消息面)"
|
||||
right={
|
||||
<div className="flex items-center gap-2">
|
||||
<LastStockChip stock={lastStock} onSelect={onSelect} />
|
||||
{symbol && (
|
||||
<button
|
||||
onClick={() => setShowHistory(v => !v)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn border border-border text-secondary text-xs hover:text-foreground hover:bg-elevated transition-colors"
|
||||
>
|
||||
<HistoryIcon className="h-3.5 w-3.5" />
|
||||
历史报告
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="px-8 py-6 space-y-6 max-w-7xl">
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-72">
|
||||
<StockFinancialSearch onSelect={onSelect} />
|
||||
</div>
|
||||
{symbol && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setPreviewSymbol(symbol)}
|
||||
title="查看个股日 K 详情"
|
||||
className="group flex items-center gap-2 text-sm rounded-md px-1.5 py-0.5 -mx-1.5 hover:bg-elevated transition-colors"
|
||||
>
|
||||
<span className="text-foreground font-medium group-hover:text-sky-300 transition-colors">{name || symbol}</span>
|
||||
<span className="text-[10px] font-mono text-muted">{symbol}</span>
|
||||
<ExternalLink className="h-3 w-3 text-muted opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={checking}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-sky-500/25 to-blue-500/15 border border-sky-400/30 text-sky-300 text-xs font-medium hover:from-sky-500/35 hover:to-blue-500/25 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
AI 个股分析
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 主体 */}
|
||||
{!symbol ? (
|
||||
<EmptyState
|
||||
icon={LineChart}
|
||||
title="选择一只股票开始分析"
|
||||
hint="搜索代码或名称,查看日 K 与关键价位,并可让 AI 进行技术面 / 基本面 / 财务面 / 消息面四维综合分析。"
|
||||
/>
|
||||
) : showHistory ? (
|
||||
<HistoryList symbol={symbol} />
|
||||
) : (
|
||||
<StockAnalysisBoard symbol={symbol} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 二次确认:已有历史报告 */}
|
||||
{confirmReport && (
|
||||
<ConfirmModal
|
||||
report={confirmReport}
|
||||
onView={() => { openHistoryReport(confirmReport.id); setConfirmReport(null) }}
|
||||
onRedo={async () => { setConfirmReport(null); await doAnalysis() }}
|
||||
onClose={() => setConfirmReport(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 个股日 K 详情对话框(点击名称/代码打开) */}
|
||||
<StockPreviewDialog
|
||||
symbol={previewSymbol}
|
||||
name={previewSymbol === symbol ? name : undefined}
|
||||
triggerInfo={null}
|
||||
onClose={() => setPreviewSymbol(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 分析看板:日 K + 关键价位 =====
|
||||
function StockAnalysisBoard({ symbol }: { symbol: string }) {
|
||||
const kline = useQuery({
|
||||
queryKey: ['kline', symbol, ''],
|
||||
queryFn: () => api.klineDaily(symbol, 250),
|
||||
enabled: !!symbol,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const levelsQ = useQuery({
|
||||
queryKey: QK.stockLevels(symbol),
|
||||
queryFn: () => api.stockAnalysisLevels(symbol, 250),
|
||||
enabled: !!symbol,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
if (kline.isLoading) {
|
||||
return <div className="flex items-center justify-center py-20"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
|
||||
}
|
||||
|
||||
const rows = kline.data?.rows ?? []
|
||||
if (rows.length === 0) {
|
||||
return <EmptyState icon={LineChart} title="暂无日 K 数据" hint="该标的尚未同步日 K,请先在数据页或自选页同步。" />
|
||||
}
|
||||
|
||||
const levels = (levelsQ.data?.levels ?? {}) as Record<LevelType, PriceLevel[]>
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-border/60 bg-surface/40 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-border/40">
|
||||
<div className="flex items-center gap-2">
|
||||
<LineChart className="h-4 w-4 text-sky-400" />
|
||||
<span className="text-sm font-medium text-foreground">关键价位分析</span>
|
||||
<span className="text-[10px] text-muted">
|
||||
{rows.length} 个交易日 · 当前价 {levelsQ.data?.close?.toFixed(2) ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<AnalysisKChart
|
||||
rows={rows}
|
||||
levels={levels}
|
||||
defaultLevelTypes={['sr', 'pivot']}
|
||||
height={480}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 历史报告列表 =====
|
||||
function HistoryList({ symbol }: { symbol: string }) {
|
||||
const { reports, loaded } = useHistoryReports()
|
||||
const mine = reports.filter(r => r.symbol === symbol)
|
||||
|
||||
if (!loaded) {
|
||||
return <div className="flex items-center justify-center py-20"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
|
||||
}
|
||||
if (mine.length === 0) {
|
||||
return <EmptyState icon={HistoryIcon} title="暂无历史报告" hint={`还没有 ${symbol} 的个股分析报告,点击「AI 个股分析」生成第一份。`} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{mine.map(r => (
|
||||
<div key={r.id} className="rounded-card border border-border/60 bg-surface/40 p-3 hover:border-border transition-colors">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<button onClick={() => openHistoryReport(r.id)} className="flex-1 text-left min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-secondary">{fmtRelative(r.created_at)}</span>
|
||||
{r.close && <span className="text-[10px] font-mono text-muted">价 {r.close.toFixed(2)}</span>}
|
||||
{r.focus && <span className="text-[10px] text-sky-300/70 truncate">关注: {r.focus}</span>}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted truncate">{r.summary || '点击查看完整报告'}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { deleteReport(r.id); toast('已删除', 'success') }}
|
||||
className="shrink-0 text-[10px] text-muted hover:text-danger transition-colors px-2 py-1"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 二次确认弹窗 =====
|
||||
function ConfirmModal({ report, onView, onRedo, onClose }: {
|
||||
report: { id: string; created_at: string; focus: string }
|
||||
onView: () => void
|
||||
onRedo: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-sm bg-surface border border-border rounded-2xl p-5 shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<HistoryIcon className="h-4 w-4 text-sky-400" />
|
||||
<span className="text-sm font-medium text-foreground">该个股已有分析报告</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary leading-relaxed mb-1">
|
||||
最近一次报告生成于 <span className="text-foreground">{fmtRelative(report.created_at)}</span>。
|
||||
</p>
|
||||
{report.focus && <p className="text-xs text-muted mb-1">关注点: {report.focus}</p>}
|
||||
<p className="text-xs text-muted mb-4">可直接查看历史,或重新生成一份新报告。</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onView}
|
||||
className="flex-1 h-8 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
|
||||
查看历史
|
||||
</button>
|
||||
<button onClick={onRedo}
|
||||
className="flex-1 h-8 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs text-sky-300 hover:from-sky-500/30 transition-all">
|
||||
重新分析
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtRelative(iso: string): string {
|
||||
try {
|
||||
const t = new Date(iso).getTime()
|
||||
const diff = Date.now() - t
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
|
||||
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
|
||||
return new Date(iso).toLocaleDateString('zh-CN')
|
||||
} catch { return iso }
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield,
|
||||
Shuffle, Plug, Zap, Settings2, ExternalLink,
|
||||
Shuffle, Plug, Zap, Settings2, ExternalLink, Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useSettings } from '@/lib/useSharedQueries'
|
||||
import { api } from '@/lib/api'
|
||||
import { api, type SettingsState } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
|
||||
// 统一的输入框样式(与项目其他设置页一致)
|
||||
@@ -35,6 +35,7 @@ export function SettingsAIPanel() {
|
||||
const [userAgent, setUserAgent] = useState('')
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
|
||||
// 测试
|
||||
const [testing, setTesting] = useState(false)
|
||||
@@ -70,11 +71,44 @@ export function SettingsAIPanel() {
|
||||
user_agent: customUa ? userAgent : '',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setSaved(true); setApiKey(''); qc.invalidateQueries({ queryKey: QK.settings })
|
||||
setSaved(true); setApiKey('')
|
||||
// 乐观更新:保存后立刻刷新连接状态 & 左侧菜单,不等异步 refetch
|
||||
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
|
||||
...prev,
|
||||
ai_provider: provider,
|
||||
ai_base_url: baseUrl,
|
||||
ai_model: model,
|
||||
// 只在本次确实提交了新 Key 时才更新连接态(留空不修改)
|
||||
...(apiKey ? {
|
||||
has_ai_key: true,
|
||||
ai_api_key_masked: `${apiKey.slice(0, 4)}••••••${apiKey.slice(-4)}`,
|
||||
} : {}),
|
||||
} : prev)
|
||||
qc.invalidateQueries({ queryKey: QK.settings })
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
},
|
||||
})
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: () => api.clearAiSettings(),
|
||||
onSuccess: () => {
|
||||
setConfirmClear(false)
|
||||
// 同步清空本地表单(保留自定义 UA)
|
||||
setProvider('openai_compat'); setBaseUrl(''); setApiKey(''); setModel('')
|
||||
setTestResult(null)
|
||||
// 乐观更新:立刻把连接状态/左侧菜单置为未配置
|
||||
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
|
||||
...prev,
|
||||
ai_provider: 'openai_compat',
|
||||
ai_base_url: '',
|
||||
ai_model: '',
|
||||
has_ai_key: false,
|
||||
ai_api_key_masked: '',
|
||||
} : prev)
|
||||
qc.invalidateQueries({ queryKey: QK.settings })
|
||||
},
|
||||
})
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true); setTestResult(null)
|
||||
try {
|
||||
@@ -246,12 +280,45 @@ export function SettingsAIPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 保存 ===== */}
|
||||
<button onClick={() => save.mutate()} disabled={save.isPending || !canSave}
|
||||
className="w-full h-10 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all">
|
||||
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : saved ? <Check className="h-4 w-4" /> : <Save className="h-4 w-4" />}
|
||||
{save.isPending ? '保存中...' : saved ? '已保存' : '保存配置'}
|
||||
</button>
|
||||
{/* ===== 保存 / 清空 ===== */}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => save.mutate()} disabled={save.isPending || !canSave}
|
||||
className="flex-1 h-10 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all">
|
||||
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : saved ? <Check className="h-4 w-4" /> : <Save className="h-4 w-4" />}
|
||||
{save.isPending ? '保存中...' : saved ? '已保存' : '保存配置'}
|
||||
</button>
|
||||
{configured && (
|
||||
<button onClick={() => setConfirmClear(true)} disabled={clear.isPending}
|
||||
className="h-10 px-4 rounded-xl bg-elevated text-secondary hover:text-danger text-sm flex items-center justify-center gap-1.5 hover:bg-elevated/80 disabled:opacity-50 transition-all shrink-0"
|
||||
title="清除 API Key、地址、模型等所有 AI 配置(保留自定义 UA)">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
清空配置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 二次确认:清空 AI 配置 */}
|
||||
{confirmClear && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmClear(false)} />
|
||||
<div className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">清空 AI 配置</h3>
|
||||
<p className="text-xs text-secondary mb-5 leading-relaxed">
|
||||
将清除 API Key、API 地址、模型等所有 AI 配置。自定义请求头(User-Agent)会保留。清空后相关 AI 功能将不可用,需重新配置。
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button onClick={() => setConfirmClear(false)}
|
||||
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<button onClick={() => clear.mutate()} disabled={clear.isPending}
|
||||
className="px-3 py-1.5 rounded-btn bg-danger/15 text-danger hover:bg-danger/25 text-sm font-medium transition-colors disabled:opacity-50">
|
||||
{clear.isPending ? '清空中...' : '确认清空'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user