Compare commits

..
6 Commits
13 changed files with 928 additions and 134 deletions
+3
View File
@@ -123,3 +123,6 @@ tmp/
/.core.hmbtNy
/.core.dump
.v2-demo-backup/
# 后端运行时数据(本地启动生成:SQLite 库、管理密码等)
backend/data/
+23 -3
View File
@@ -55,8 +55,9 @@
- 生成分享链接时复用已有短链,避免每次生成新链接
- 全页面适配移动端:响应式字体、间距、布局断点(sm/md/lg)
- K线图历史数据获取失败时直接报错,禁止使用模拟数据降级(避免"刷新数据变化"问题)
- K线天数逻辑:默认3个月(90天),自选日期到现在超过3个月则从自选日期开始
- K线图使用 recharts Brush 组件实现移动端缩放和滑动查看
- K线天数逻辑:日K一次拉取近365天(供拖动回看),默认视口只展示最近90个自然日(3个月),向左拖动查看更早数据;分钟K一次拉取320根,铺满展示
- K线图基于 TradingView Lightweight Charts v5 实现(蜡烛/折线 + MA/MACD/RSI 副图);拖动/缩放查看数据,切换指标或显示方式保留当前视口(仅在数据集变化时重置)
- K线图时间格式自定义:时间轴刻度 年→`YYYY`、月→`YYYY-MM`、日→`MM-DD`,十字光标→`YYYY-MM-DD`(`tickMarkFormatter` + `localization.timeFormatter`);格式化必须用 UTC 取值(`getUTCFullYear` 等),因为时间戳按"北京时间墙钟视作 UTC"存储,用本地时区方法会错位一天
- 板块标记:688开头=科创(红)、300/301开头=创业(紫)、920/8/4开头=北交(橙),主板不显示标签;标记位置:搜索候选、详情页标题、集合卡片股票列表、分享页股票卡片标题
- 详情页右上角外部跳转按钮:①"东方财富" `https://wap.eastmoney.com/quote/stock/{market}.{code}.html?appfenxiang=1`,market映射 688→6/60→1/其他→0;②"金十数据" `https://search.jin10.com/?keyword={股票名称URL编码}`(按名称搜索金十资讯)
- 详情页资金流向模块:展示近30日资金流向分析,包含:
@@ -69,4 +70,23 @@
- 成交额备用计算:当资金流向API获取失败时,使用 `chartData` 的 `volume × close` 近似计算成交额(单位:元)
- 资金流向数据获取失败时,前端通过 `fundFlowError` 状态显示错误信息,便于排查问题
- ❌ 东方财富 API(push2his.eastmoney.com)在 Edge Function 环境被拒绝访问(peer closed connection),主力净流入数据无法获取,显示为 "-"
- ✅ 替代方案:使用腾讯实时行情API的外盘(索引7)和内盘(索引8)数据计算净主动买入额 = (外盘 - 内盘) × 当前价 × 100(单位:元)
- ✅ 替代方案:使用腾讯实时行情API的外盘(索引7)和内盘(索引8)数据计算净主动买入额 = (外盘 - 内盘) × 当前价 × 100(单位:元)
## 每日 AI 分析(backend FastAPI,15:10 自动触发)
- 报告结构约定:正文标题(#)后第一行必须是引用块 `> 今日定调:<80字内核心结论+关键数字>`;保存时由 `_extract_summary()` 正则提取进 `summary` 字段,前端顶部高亮展示,缺失时退回正文截断
- 环比数据:`collect_ai_analysis` 每次运行先采集当日盘面快照(指数+涨跌统计 JSON)写入 `daily_market_stats` 表(UNIQUE trade_date,upsert),再读上一交易日快照格式化为 prompt 中的"环比数据段";首跑无昨日数据时 AI 须如实标注"暂无昨日基准"
- 连板梯队:`get_market_dashboard` 返回 `limitLadder` 字段(完整版,2连板以上全量+首板前8),涨停原因/封单金额来自涨停池按 thscode 匹配;看板 events 里的天梯仍只取每层前2(保持 UI 精简),两处用途不同不要合并
- tokens 口径:`ai_reports.tokens_used` 只记"当次生成"消耗(约7万/份);同日重新生成走 UPSERT,`generation_count` 自增、`updated_at` 刷新、`created_at` 保留首次生成时间;表有 UNIQUE(trade_date, report_type),禁止改回 INSERT OR REPLACE 之外还要注意别用 lastrowid(UPSERT 更新时不可靠,须回查 id)
- 旧库补列用 init_db 里的 try/except ALTER TABLE 轻量迁移(CREATE TABLE IF NOT EXISTS 不会更新旧表结构)
- 补充数据源(services/market_extra.py):①财经快讯 get_news=新浪7x24 zhibo.sina.cn(feed.list.rich_text);②板块主力资金流=东财 push2 的 clist 接口(f62 主力净额),**必须用 push2delay.eastmoney.com 镜像**——push2 对部分客户端 TLS 指纹拦截(peer closed),查询串保持字面量 `+` 号;③两融=datacenter-web 的 RPTA_RZRQ_LSHJ(T+1 披露),两融余额=RZYE+RQYE
- AI 报告重要消息面规则:必须基于 get_news 快讯,5-8条,格式【宏观/政策/行业/公司/海外】新闻——影响解读,禁止编造;板块资金面规则:必须引用 sectorFundFlow 的行业净流入/流出TOP3+概念TOP3
- 截断续写:报告长导致 finish_reason=length 时,拼接已有内容并向 messages 追加"继续"指令让模型续写(最多3次),truncated 仅在续写后仍截断时为 true;call_llm 读超时 300s(续写携带全部上下文)
- 快照入库防护:_capture_market_snapshot 校验 indices 非空且涨跌统计不全 0,fuyao 失败时跳过入库,防止空快照污染环比
- 调试注意:独立脚本跑 backend 代码必须显式 `load_dotenv("/path/to/repo/.env")`——fuyao_apikey 在仓库根目录 .env(uvicorn 靠 --env-file 参数加载),backend/.env 只有 MX keys;且 stdin 脚本里 load_dotenv() 无参调用会因 frame 断言报错,须显式传路径
- 第三档呈现:报告页按 `## ` 二级标题拆分为多张卡片渲染(`splitReport`),首卡含 # 标题+定调引用块;表格单元格数字按 A股惯例红涨绿跌(`colorizeChildren` 只给带 +/- 号的数字着色);标题栏显示"总第 N 期"(`issue_number`,latest 接口用 COUNT(trade_date<=) 子查询计算)
- 海外指数/国内期货:`fetch_global_markets` 走 push2delay 的 ulist.np(海外 secid `100.NDX` 等)+ clist(期货 fs `m:8/113/142/114/115`,只取名称含"主连/主力合约"且排除"次主连",按成交额降序取前12);接入 dashboard `globalMarkets` 字段
- ⚠️ openteam 网关对 LLM 单请求有约 120s 硬超时(超时返回 502 或空 SSE),GLM reasoning 长报告一次生成必死。解法:`call_llm` 全流式(SSE)+ `collect_ai_analysis` 分三段生成(REPORT_PARTS,每段约1200-1600字,各重试3次带退避,失败占位不阻塞);工具轮拿到数据后立即 break 进分段(再问一轮只会空转120s);`thinking:{type:disabled}` 参数网关返回400不可用
- 工具结果必须瘦身:`get_active_core_stocks` 只保留出现次数前25只+题材前5(全量145KB会撑爆上下文);get_market_dashboard 全量约13KB可接受
- glm-5.3-flash 空返回特征:SSE 200 但只有 reasoning_content 无 content/tool_calls/finish_reason(可能流满120s被掐),按空轮次处理重试即可
+20
View File
@@ -82,9 +82,19 @@ CREATE TABLE IF NOT EXISTS ai_reports (
tools_used TEXT,
model TEXT NOT NULL,
tokens_used INTEGER,
generation_count INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
updated_at TEXT,
UNIQUE(trade_date, report_type)
);
CREATE TABLE IF NOT EXISTS daily_market_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date)
);
"""
@@ -107,6 +117,16 @@ def init_db():
conn = get_connection()
conn.executescript(SCHEMA_SQL)
# 轻量迁移:为已存在的旧表补列(CREATE TABLE IF NOT EXISTS 不会更新旧表结构)
for alter_sql in (
"ALTER TABLE ai_reports ADD COLUMN generation_count INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE ai_reports ADD COLUMN updated_at TEXT",
):
try:
conn.execute(alter_sql)
except sqlite3.OperationalError:
pass # 列已存在
# 清理过期缓存
from services.cache import clean_expired
clean_expired()
+2 -1
View File
@@ -143,7 +143,8 @@ async def admin_list_reports(request: Request):
conn = get_connection()
try:
rows = conn.execute(
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, created_at "
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, "
"generation_count, created_at, updated_at "
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
).fetchall()
items = []
+6 -2
View File
@@ -43,8 +43,11 @@ def _can_trigger(trade_date: str) -> tuple[bool, str]:
async def get_latest_report():
conn = get_connection()
try:
# issue_number:按报告日期序数作为总期号
row = conn.execute(
"SELECT * FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 1"
"""SELECT r.*,
(SELECT COUNT(*) FROM ai_reports WHERE trade_date <= r.trade_date) AS issue_number
FROM ai_reports r ORDER BY r.trade_date DESC, r.id DESC LIMIT 1"""
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="暂无分析报告")
@@ -60,7 +63,8 @@ async def list_reports():
conn = get_connection()
try:
rows = conn.execute(
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, created_at "
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, "
"generation_count, created_at, updated_at "
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
).fetchall()
items = []
+80 -1
View File
@@ -17,7 +17,7 @@ from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from services import fuyao_client
from services import fuyao_client, market_extra
router = APIRouter()
@@ -163,12 +163,18 @@ async def _build_dashboard() -> dict:
anomaly_data,
limit_ladder_data,
skyrocket_data,
sector_flow_data,
margin_data,
global_markets_data,
) = await asyncio.gather(
fuyao_client.hot_stock_list("day"),
fuyao_client.dragon_tiger_list("all"),
fuyao_client.anomaly_analysis_list(["SHARP_RISE", "RAPID_RALLY", "LIMIT_UP"]),
fuyao_client.limit_up_ladder(),
fuyao_client.skyrocket_list("day"),
market_extra.fetch_sector_fund_flow(),
market_extra.fetch_margin_summary(),
market_extra.fetch_global_markets(),
return_exceptions=True,
)
except Exception:
@@ -177,6 +183,9 @@ async def _build_dashboard() -> dict:
anomaly_data = {}
limit_ladder_data = {}
skyrocket_data = {}
sector_flow_data = None
margin_data = None
global_markets_data = None
# ── 解析指数 ──
indices = []
@@ -291,6 +300,12 @@ async def _build_dashboard() -> dict:
"auctionSignal": auction_signal,
}
# 两融余额(交易所 T+1 披露),供看板展示、AI 分析与次日环比快照使用
if isinstance(margin_data, dict):
market_stats["marginBalanceYi"] = margin_data.get("balanceYi")
market_stats["marginChangeYi"] = margin_data.get("changeYi")
market_stats["marginDate"] = margin_data.get("date")
# ── 行业强度榜(使用行业指数真实涨幅) ──
sector_strength = []
industries = industry_catalog if isinstance(industry_catalog, list) else []
@@ -485,13 +500,77 @@ async def _build_dashboard() -> dict:
"detail": f"涨停 {limit_up_count} 跌停 {limit_down_count} 炸板 {break_count}",
})
# ── 连板梯队(完整版,供 AI 分析用;上方 events 天梯只取前2保持看板精简) ──
# 涨停池里带 limit_up_reason / seal_money,按代码匹配给梯队个股
reason_by_code = {}
if isinstance(limit_up_data, dict):
for lu in limit_up_data.get("item", []) or []:
code = lu.get("thscode", "")
if code:
reason_by_code[code] = {
"reason": (lu.get("limit_up_reason") or "").strip(),
"sealWan": round(_safe_float(lu.get("seal_money")) / 10000),
}
_LADDER_LEVELS = [
("seven_over", 7, "7连板+"),
("six_board", 6, "6连板"),
("five_board", 5, "5连板"),
("four_board", 4, "4连板"),
("three_board", 3, "3连板"),
("two_board", 2, "2连板"),
("first_board", 1, "首板"),
]
limit_ladder = []
if isinstance(limit_ladder_data, dict):
ladder_items = limit_ladder_data.get("item", [])
if ladder_items:
today_boards = ladder_items[0].get("boards", {}) or {}
seen_keys = set()
for key, board_num, label in _LADDER_LEVELS:
seen_keys.add(key)
# 首板数量多(几十家)只取前8家;2连板以上全量保留
cap = 8 if board_num == 1 else None
entries = today_boards.get(key, []) or []
if cap is not None:
entries = entries[:cap]
for item in entries:
code = item.get("thscode", "")
extra = reason_by_code.get(code, {})
limit_ladder.append({
"board": board_num,
"label": label,
"name": item.get("name", ""),
"code": code,
"reason": extra.get("reason", ""),
"sealWan": extra.get("sealWan"),
})
# 兜底:天梯返回了未知层级 key 时也带上(跳过已处理的已知 key)
for key, entries in today_boards.items():
if key in seen_keys:
continue
for item in entries or []:
code = item.get("thscode", "")
extra = reason_by_code.get(code, {})
limit_ladder.append({
"board": _safe_float(item.get("board_num")) or 1,
"label": f"{int(_safe_float(item.get('board_num')) or 1)}连板",
"name": item.get("name", ""),
"code": code,
"reason": extra.get("reason", ""),
"sealWan": extra.get("sealWan"),
})
# ── 组装结果 ──
result = {
"indices": indices,
"marketStats": market_stats,
"sectorStrength": sector_strength[:31],
"conceptStrength": concept_strength[:10],
"sectorFundFlow": sector_flow_data if isinstance(sector_flow_data, dict) else None,
"globalMarkets": global_markets_data if isinstance(global_markets_data, dict) else None,
"events": events,
"limitLadder": limit_ladder,
"updateTime": datetime.now(BJT).strftime("%Y-%m-%d %H:%M:%S"),
}
+333 -72
View File
@@ -3,10 +3,15 @@
负责:
1. 调用 OpenAI 兼容 API 进行分析
2. Function Calling 循环(AI 可主动获取数据)
3. 保存报告到数据库
3. 采集当日盘面快照(供次日环比)
4. 保存报告到数据库
"""
import asyncio
import json
import re
import traceback
import httpx
from datetime import datetime, timezone, timedelta
@@ -25,11 +30,12 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
- 风险提示,每次推荐都需说明风险点
可用工具:
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/行业强度/事件情报/市场温度)
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/市场温度/连板梯队/行业强度/板块资金流/两融/海外指数与国内期货/事件情报)
- get_theme_history: 获取指定日期的题材涨幅排行
- get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材)
- get_stock_quote: 获取个股实时行情
- get_fund_flow: 获取个股资金流向
- get_news: 获取财经快讯(新浪7x24,用于重要消息面)
重要规则:
1. 你必须先调用工具获取数据,然后基于数据进行分析
@@ -40,53 +46,120 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。
{prev_report_section}
{prev_snapshot_section}
请先调用以下工具获取数据:
1. get_market_dashboard - 获取市场整体数据
1. get_market_dashboard - 获取市场整体数据(含涨跌统计、市场温度、连板梯队 limitLadder、板块资金流 sectorFundFlow、两融、海外与期货 globalMarkets)
2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅
3. get_active_core_stocks - 获取核心股数据
4. get_news(limit=30) - 获取今日财经快讯
然后基于数据生成报告,结构如下:
生成报告时必须遵守以下格式规则:
## 一、市场总览
- 主要指数表现(上证、深证、创业板)
- 涨跌家数统计
- 市场温度评估
1. 报告标题(# 一级标题)之后的第一行,必须是一个引用块"定调摘要",格式严格为:
> 今日定调:<一句话核心结论,不超过80字,必须包含1-2个关键数字(如成交额、涨停家数、市场温度)>
## 二、题材热点分析
- 今日涨幅前5题材
- 持续活跃的题材
- 新兴热点题材
2. 量能与情绪类数字必须给环比:若上方提供了"前一交易日盘面数据快照",成交额、涨跌家数、涨停数、两融余额、市场温度等在与昨日对比后表述(如"成交额2.05万亿,较昨日缩量约700亿");没有昨日快照则如实说明"暂无昨日数据"。
## 三、核心股追踪
- 连板股分析
- 核心股表现
- 龙头股辨识
3. 连板梯队必须完整呈现 get_market_dashboard 返回的 limitLadder:从最高连板到2连板逐级列表格,每只标注涨停原因(reason字段)与封单金额(sealWan,单位万,为空则不写);首板只挑3-5只人气最高的点评。
## 四、关注方向
- 明日值得关注的题材方向
- 潜在的交易机会
4. 板块资金面必须引用 get_market_dashboard 返回的 sectorFundFlow:行业主力净流入TOP3、净流出TOP3、概念净流入TOP3(单位亿元),结合题材分析说明资金动向。
## 五、下个交易日建议
- 明日大盘预判(支撑/压力位)
- 建议关注的题材方向(2-3个)
- 建议关注的核心股(附理由)
- 操作策略(仓位建议、买卖时机)
- 需要规避的方向
5. 海外市场与国内期货必须引用 get_market_dashboard 返回的 globalMarkets:overseas 为海外主要指数(纳斯达克/道琼斯/标普500/恒生/日经/富时),futures 为国内期货主力合约(按成交额降序,已含价格与涨跌幅);点评与A股关联度高的品种(股指期货、原油、贵金属、黑色系),数据缺失则如实说明。
## 六、风险提示
- 需要警惕的风险因素
- 操作建议
6. 重要消息面必须基于 get_news 返回的快讯整理:挑5-8条对次日盘面影响最大的消息,每条格式为"【分类】一句话新闻 —— 一句影响解读"(分类用:宏观/政策/行业/公司/海外);快讯中若没有某方面的重要消息,如实说明,严禁编造工具中不存在的新闻。
7. 适当使用表格展示数据对比。
然后基于数据生成报告。报告共八章,将由系统分三次调用完成,每次调用只负责其中一部分,具体写作指令由后续消息给出。
请用 Markdown 格式输出,适当使用表格展示数据对比。"""
# 分段生成指令:网关对单次 LLM 请求有约120s硬超时,整篇报告一次生成必被掐断,
# 故拆为三段(每段约1200-1600字),各自独立调用后拼接
REPORT_PARTS = [
"""现在写报告的【第1部分】,只输出这一部分,直接输出 Markdown,不要任何开场白或说明:
1. 以 `# {title} A股收盘分析报告` 一级标题开头
2. 标题后第一行输出定调引用块,格式严格为:`> 今日定调:<80字内核心结论,含1-2个关键数字>`
3. 写 `## 一、市场总览`(指数表格、涨跌统计须环比、市场温度)与 `## 二、题材热点分析`(涨幅前5、持续活跃、新兴热点、退潮警示、板块主力资金流TOP3)
全文控制在1600字以内。""",
"""现在写报告的【第2部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容:
- `## 三、核心股追踪`(连板梯队完整表格:层级/股票/涨停原因/封单,首板挑3-5只人气股点评;核心股表现;龙头辨识)
- `## 四、关注方向`(明日题材方向、潜在交易机会)
全文控制在1300字以内。""",
"""现在写报告的【第3部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容:
- `## 五、下个交易日建议`(大盘预判、题材方向、核心股、仓位策略、规避方向)
- `## 六、重要消息面`(5-8条,格式【分类】新闻——影响解读)
- `## 七、海外市场与国内期货`(点评对次日A股的影响)
- `## 八、风险提示`
全文控制在1900字以内。""",
]
# 报告头部的"今日定调"引用行,保存时提取为 summary
_TONE_LINE_RE = re.compile(r"^>\s*今日定调[::]\s*(.+)$", re.MULTILINE)
async def _consume_sse(resp: httpx.Response) -> dict:
"""消费 OpenAI 兼容 SSE 流,拼装为与非流式响应相同的结构"""
content_parts: list[str] = []
finish_reason = ""
usage: dict = {}
# tool_calls 按 index 拼装(流式下 arguments 分片到达)
tool_acc: dict[int, dict] = {}
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue
data = line[5:].strip()
if not data or data == "[DONE]":
continue
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
if chunk.get("usage"):
usage = chunk["usage"]
choices = chunk.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta") or {}
if delta.get("content"):
content_parts.append(delta["content"])
for tc in delta.get("tool_calls") or []:
idx = tc.get("index", 0)
slot = tool_acc.setdefault(idx, {"id": "", "type": "function",
"function": {"name": "", "arguments": ""}})
if tc.get("id"):
slot["id"] = tc["id"]
fn = tc.get("function") or {}
if fn.get("name"):
slot["function"]["name"] += fn["name"]
if fn.get("arguments"):
slot["function"]["arguments"] += fn["arguments"]
if choices[0].get("finish_reason"):
finish_reason = choices[0]["finish_reason"]
message: dict = {"role": "assistant", "content": "".join(content_parts) or None}
if tool_acc:
message["tool_calls"] = [
{"id": tool_acc[i]["id"], "type": "function",
"function": tool_acc[i]["function"]}
for i in sorted(tool_acc)
]
return {"choices": [{"message": message, "finish_reason": finish_reason}], "usage": usage}
async def call_llm(messages: list, tools: list = None) -> dict:
"""调用 OpenAI 兼容 API"""
"""调用 OpenAI 兼容 API(流式)。
必须用 stream:网关对非流式请求有约120s的代理超时,长生成会被 502 掐断;
流式下字节持续到达不会被判定超时。返回结构与非流式一致。
"""
async with httpx.AsyncClient() as client:
payload = {
"model": AI_MODEL,
"messages": messages,
"stream": True,
}
if AI_MAX_TOKENS is not None:
payload["max_tokens"] = AI_MAX_TOKENS
@@ -96,17 +169,40 @@ async def call_llm(messages: list, tools: list = None) -> dict:
payload["tools"] = tools
payload["tool_choice"] = "auto"
resp = await client.post(
f"{AI_API_BASE}/chat/completions",
headers={
"Authorization": f"Bearer {AI_API_KEY}",
"Content-Type": "application/json",
},
json=payload,
timeout=120,
)
resp.raise_for_status()
return resp.json()
max_attempts = 4
for attempt in range(1, max_attempts + 1):
try:
async with client.stream(
"POST",
f"{AI_API_BASE}/chat/completions",
headers={
"Authorization": f"Bearer {AI_API_KEY}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
json=payload,
timeout=600,
) as resp:
if resp.status_code == 429 and attempt < max_attempts:
wait = min(30 * attempt, 90)
print(f"[ai-service] LLM 429 限流,{wait}s 后重试(第 {attempt}/{max_attempts - 1} 次)")
await asyncio.sleep(wait)
continue
if resp.status_code >= 500 and attempt < max_attempts:
print(f"[ai-service] LLM {resp.status_code},{min(15 * attempt, 60)}s 后重试")
await asyncio.sleep(min(15 * attempt, 60))
continue
resp.raise_for_status()
return await _consume_sse(resp)
except httpx.TransportError as e:
# 网络层错误(超时/断连)也值得重试
if attempt < max_attempts:
wait = min(15 * attempt, 60)
print(f"[ai-service] LLM 网络错误({type(e).__name__}),{wait}s 后重试")
await asyncio.sleep(wait)
continue
raise
raise RuntimeError("LLM 调用重试次数耗尽")
async def collect_ai_analysis(trade_date: str) -> dict:
@@ -119,19 +215,24 @@ async def collect_ai_analysis(trade_date: str) -> dict:
{"id": int, "tokens_used": int, "tools_used": list}
"""
prev_report_section = _get_prev_report_section(trade_date)
# 采集当日盘面快照(供次日环比),并读取上一交易日快照注入 prompt
await _capture_market_snapshot(trade_date)
prev_snapshot_section = _get_prev_snapshot_section(trade_date)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": DAILY_ANALYSIS_PROMPT.format(
trade_date=trade_date,
prev_report_section=prev_report_section
prev_report_section=prev_report_section,
prev_snapshot_section=prev_snapshot_section,
)}
]
# ── 阶段一:工具轮(只获取数据,模型若直接开写报告则丢弃,由阶段二重写) ──
tools_used = []
total_tokens = 0
final_content = ""
was_truncated = False
max_rounds = 30 # 安全上限,正常分析约 3-8 轮
max_rounds = 8
for i in range(max_rounds):
response = await call_llm(messages, tools=TOOLS)
@@ -139,52 +240,208 @@ async def collect_ai_analysis(trade_date: str) -> dict:
choice = response["choices"][0]
message = choice["message"]
messages.append(message)
finish_reason = choice.get("finish_reason", "")
print(f"[ai-service] tool round {i}: finish={finish_reason}, "
f"content_len={len(message.get('content') or '')}, "
f"tool_calls={len(message.get('tool_calls') or [])}")
if finish_reason == "stop":
final_content = message.get("content") or ""
break
if finish_reason == "length":
was_truncated = True
final_content = message.get("content") or ""
if not final_content:
for msg in reversed(messages):
if msg.get("role") == "assistant" and msg.get("content"):
final_content = msg["content"]
break
print(f"[ai-service] 警告:响应被截断 (finish_reason=length)")
break
if finish_reason == "tool_calls":
for tool_call in message.get("tool_calls", []):
if finish_reason == "tool_calls" and message.get("tool_calls"):
messages.append(message)
for tool_call in message["tool_calls"]:
func_name = tool_call["function"]["name"]
func_args = json.loads(tool_call["function"]["arguments"])
tools_used.append(func_name)
result = await execute_tool(func_name, func_args)
messages.append({
"role": "tool",
"tool_call_id": tool_call["id"],
"content": result
})
break # 工具已齐,立即进入分段写作(再问一轮模型只会空转120s)
summary = final_content[:200].replace("\n", " ") if final_content else ""
# 非工具轮(模型直接开写/空返回):只要有工具结果就直接进入分段写作;
# 一轮工具都没拿到则重试
if tools_used:
break
print(f"[ai-service] 未获取到工具数据,重试({i + 1}/{max_rounds})")
await asyncio.sleep(5)
if not tools_used:
raise RuntimeError("工具数据获取失败,无法生成报告")
# ── 阶段二:分段生成报告(绕开网关单请求约120s硬超时) ──
final_content = ""
for part_idx, part_prompt in enumerate(REPORT_PARTS):
part_content = ""
for attempt, backoff in ((1, 0), (2, 10), (3, 30)):
if backoff:
await asyncio.sleep(backoff)
part_messages = messages + [{
"role": "user",
"content": part_prompt.format(title=trade_date) if part_idx == 0 else part_prompt,
}]
response = await call_llm(part_messages)
total_tokens += response.get("usage", {}).get("total_tokens", 0)
choice = response["choices"][0]
part_content = choice["message"].get("content") or ""
finish = choice.get("finish_reason", "")
print(f"[ai-service] part {part_idx + 1} attempt {attempt}: finish={finish}, len={len(part_content)}")
if part_content and finish in ("stop", "length"):
break
print(f"[ai-service] part {part_idx + 1} 生成异常,重试")
if not part_content:
was_truncated = True
print(f"[ai-service] 警告:part {part_idx + 1} 三次尝试均失败")
part_content = f"\n\n> (第{part_idx + 1}部分生成失败,请稍后重新生成)\n"
final_content += (final_content and "\n\n" or "") + part_content
summary = _extract_summary(final_content)
report_id = _save_report(trade_date, final_content, summary, tools_used, total_tokens)
return {"id": report_id, "tokens_used": total_tokens, "tools_used": tools_used, "truncated": was_truncated}
def _save_report(trade_date: str, content: str, summary: str, tools_used: list, tokens_used: int) -> int:
"""保存报告到数据库"""
def _extract_summary(content: str) -> str:
"""提取报告头部的"今日定调"引用行作为摘要;缺失时退回正文截断"""
match = _TONE_LINE_RE.search(content or "")
if match:
text = match.group(1).strip()
if text:
return text[:200]
return (content or "")[:200].replace("\n", " ")
def _num(v) -> str:
"""快照数值安全转字符串"""
if isinstance(v, float):
return f"{v:g}"
return str(v if v is not None else "-")
def _fmt_amount(v) -> str:
"""成交额(元)→ 万亿/亿 可读格式"""
try:
v = float(v)
except (TypeError, ValueError):
return "-"
if v >= 1e12:
return f"{v / 1e12:.2f}万亿"
if v >= 1e8:
return f"{v / 1e8:.0f}亿"
return f"{v:.0f}元"
def _fmt_index(idx: dict) -> str:
if not idx:
return "-"
try:
pct = round(float(idx.get("changePct", 0)), 2)
except (TypeError, ValueError):
pct = 0
sign = "+" if pct >= 0 else ""
return f"{idx.get('price', '-')}({sign}{pct}%)"
def _fmt_temperature(v) -> str:
"""温度可能是 dict(score/label/factors),取分数与标签"""
if isinstance(v, dict):
score = v.get("score")
label = v.get("label") or ""
return f"{score}分{('(' + label + ')') if label else ''}"
return _num(v)
async def _capture_market_snapshot(trade_date: str) -> bool:
"""采集当日盘面快照入库(指数+涨跌统计),供次日分析做环比"""
try:
from routes.market_dashboard import _build_dashboard
data = await _build_dashboard()
stats = data.get("marketStats") or {}
# 数据有效性校验:fuyao 拉取失败时涨跌统计全 0,空快照会污染次日环比
if not data.get("indices") or (stats.get("upCount", 0) + stats.get("downCount", 0) == 0):
print(f"[ai-service] 盘面数据无效,跳过快照入库 {trade_date}")
return False
payload = json.dumps(
{"indices": data.get("indices", []), "marketStats": stats},
ensure_ascii=False,
default=str,
)
conn = get_connection()
try:
conn.execute(
"""INSERT INTO daily_market_stats (trade_date, payload) VALUES (?, ?)
ON CONFLICT(trade_date) DO UPDATE SET payload = excluded.payload""",
(trade_date, payload),
)
conn.commit()
finally:
conn.close()
return True
except Exception:
print(f"[ai-service] 市场快照采集失败 {trade_date}:")
traceback.print_exc()
return False
def _get_prev_snapshot_section(trade_date: str) -> str:
"""读取上一交易日盘面快照,格式化为 prompt 中的环比数据段"""
conn = get_connection()
try:
cursor = conn.execute(
"""INSERT OR REPLACE INTO ai_reports
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used)
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?)""",
row = conn.execute(
"SELECT trade_date, payload FROM daily_market_stats WHERE trade_date < ? ORDER BY trade_date DESC LIMIT 1",
(trade_date,),
).fetchone()
if not row:
return ""
try:
snap = json.loads(row["payload"])
except (TypeError, ValueError):
return ""
stats = snap.get("marketStats") or {}
indices = {i.get("name"): i for i in (snap.get("indices") or []) if isinstance(i, dict)}
idx_line = "、".join(
f"{name} {_fmt_index(indices.get(name))}"
for name in ("上证指数", "深证成指", "创业板指", "科创50")
)
margin_line = ""
if stats.get("marginBalanceYi"):
change = stats.get("marginChangeYi")
change_txt = ""
if change is not None:
sign = "+" if float(change) >= 0 else ""
change_txt = f"(较前一日 {sign}{_num(change)}亿)"
margin_line = f"\n- 两融余额:{_num(stats.get('marginBalanceYi'))}亿{change_txt},数据日期 {stats.get('marginDate') or '-'}(T+1)"
return f"""以下是前一交易日({row["trade_date"]})的盘面数据快照,报告中的量能与情绪数字必须给出与它的环比对比:
- 两市成交额:{_fmt_amount(stats.get("totalTurnover"))}
- 上涨/下跌/平盘:{_num(stats.get("upCount"))}/{_num(stats.get("downCount"))}/{_num(stats.get("flatCount"))},涨停 {_num(stats.get("limitUp"))} 家、跌停 {_num(stats.get("limitDown"))} 家、炸板 {_num(stats.get("limitBreak"))} 家(炸板率 {_num(stats.get("breakRate"))}%)
- 强势/弱势股:{_num(stats.get("strongCount"))}/{_num(stats.get("weakCount"))},市场宽度 {_num(stats.get("marketBreadth"))}%
- 市场温度:{_fmt_temperature(stats.get("temperature"))},竞价信号:{stats.get("auctionSignal") or "-"}{margin_line}
- 指数收盘:{idx_line}
---
"""
finally:
conn.close()
def _save_report(trade_date: str, content: str, summary: str, tools_used: list, tokens_used: int) -> int:
"""保存报告到数据库(同日重生成:覆盖内容、generation_count+1、tokens 记当次消耗)"""
conn = get_connection()
try:
conn.execute(
"""INSERT INTO ai_reports
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used, updated_at)
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?, datetime('now','localtime'))
ON CONFLICT(trade_date, report_type) DO UPDATE SET
title = excluded.title,
content = excluded.content,
summary = excluded.summary,
tools_used = excluded.tools_used,
model = excluded.model,
tokens_used = excluded.tokens_used,
updated_at = excluded.updated_at,
generation_count = ai_reports.generation_count + 1""",
(
trade_date,
f"{trade_date} A股收盘分析",
@@ -193,10 +450,14 @@ def _save_report(trade_date: str, content: str, summary: str, tools_used: list,
json.dumps(tools_used),
AI_MODEL,
tokens_used,
)
),
)
conn.commit()
return cursor.lastrowid
row = conn.execute(
"SELECT id FROM ai_reports WHERE trade_date = ? AND report_type = 'daily'",
(trade_date,),
).fetchone()
return row["id"]
finally:
conn.close()
+32 -5
View File
@@ -16,7 +16,7 @@ TOOLS = [
"type": "function",
"function": {
"name": "get_market_dashboard",
"description": "获取A股市场看板数据,包含主要指数行情、涨跌统计、行业强度、概念热度、事件情报、市场温度评分",
"description": "获取A股市场看板数据,包含主要指数行情、全市场涨跌统计(涨跌家数/涨停/跌停/炸板率/成交额)、市场温度评分与竞价信号、行业强度榜、概念热度、板块主力资金流、两融余额、海外主要指数与国内期货主力合约(globalMarkets字段)、完整连板梯队(limitLadder字段,含涨停原因与封单金额)、事件情报(热门股/龙虎榜/飙升/异动)",
"parameters": {"type": "object", "properties": {}, "required": []}
}
},
@@ -71,6 +71,20 @@ TOOLS = [
"required": ["code", "name"]
}
}
},
{
"type": "function",
"function": {
"name": "get_news",
"description": "获取最近的财经快讯(新浪7x24,含宏观/行业/公司/海外动态),用于重要消息面梳理",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "获取条数,默认30,最大50"}
},
"required": []
}
}
}
]
@@ -92,12 +106,23 @@ async def execute_tool(tool_name: str, arguments: dict) -> str:
arguments.get("name", ""),
arguments.get("days", 30)
)
elif tool_name == "get_news":
return await _get_news(arguments.get("limit", 30))
else:
return json.dumps({"error": f"未知工具: {tool_name}"})
except Exception as e:
return json.dumps({"error": str(e)})
async def _get_news(limit: int = 30) -> str:
"""获取财经快讯"""
from services.market_extra import fetch_news
news = await fetch_news(limit)
if not news:
return json.dumps({"error": "快讯获取失败或暂无数据"}, ensure_ascii=False)
return json.dumps({"count": len(news), "items": news}, ensure_ascii=False)
async def _get_market_dashboard() -> str:
"""获取市场看板数据"""
from routes.market_dashboard import _build_dashboard
@@ -159,9 +184,9 @@ async def _get_active_core_stocks() -> str:
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
s["lastAppear"] = r["trade_date"]
stocks = list(stock_days.values())
stocks.sort(key=lambda x: x.get("lastAppear") or "", reverse=True)
stocks.sort(key=lambda x: -x["appearCount"])
# 只保留最近10日中出现次数最多的前25只(全量可达145KB,会把上下文撑爆)
stocks = sorted(stock_days.values(), key=lambda x: -x["appearCount"])[:25]
keep_codes = {s["stockCode"] for s in stocks}
themes_rows = conn.execute(
f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes
@@ -170,10 +195,12 @@ async def _get_active_core_stocks() -> str:
).fetchall()
themes_by_stock = {}
for t in themes_rows:
if t["stock_code"] not in keep_codes:
continue
per = themes_by_stock.setdefault(t["stock_code"], {})
per.setdefault(t["theme_code"], {"theme_code": t["theme_code"], "theme_name": t["theme_name"]})
for s in stocks:
s["themes"] = list(themes_by_stock.get(s["stockCode"], {}).values())
s["themes"] = list(themes_by_stock.get(s["stockCode"], {}).values())[:5]
latest = dates[-1] if dates else None
for s in stocks:
+210
View File
@@ -0,0 +1,210 @@
"""市场补充数据源(供 AI 分析与看板扩展)
- fetch_news: 新浪财经 7x24 快讯
- fetch_sector_fund_flow: 东方财富板块主力资金流排行(行业/概念)
- fetch_margin_summary: 东方财富两融余额汇总(T+1 数据)
均为公开接口,失败时返回 []/None,不阻塞主流程。
"""
import asyncio
import httpx
_TIMEOUT = 10.0
_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
async def fetch_news(limit: int = 30) -> list[dict]:
"""新浪财经 7x24 快讯,返回 [{time: 'MM-DD HH:MM', content}];失败返回 []"""
try:
limit = max(1, min(int(limit or 30), 50))
except (TypeError, ValueError):
limit = 30
try:
async with httpx.AsyncClient(timeout=_TIMEOUT, headers={"User-Agent": _UA}) as client:
resp = await client.get(
"https://zhibo.sina.com.cn/api/zhibo/feed",
params={"page": 1, "page_size": limit, "zhibo_id": 152, "tag_id": 0},
)
resp.raise_for_status()
items = resp.json()["result"]["data"]["feed"]["list"]
news = []
for it in items or []:
text = (it.get("rich_text") or "").strip()
if not text:
continue
news.append({
"time": (it.get("create_time") or "")[5:16], # 'MM-DD HH:MM'
"content": text[:300],
})
return news
except Exception:
return []
_FLOW_HOSTS = (
# push2 对部分客户端有 TLS 指纹拦截(peer closed),delay 镜像同接口且稳定
"https://push2delay.eastmoney.com",
"https://push2.eastmoney.com",
)
async def _fetch_flow_boards(client: httpx.AsyncClient, fs: str, po: int, pz: int) -> list[dict]:
"""拉取一类板块的主力净流入排行。po=1 降序(净流入最多),po=0 升序(净流出最多)
查询串保持字面量 + 号(与东财网页请求一致),逐 host 尝试。
"""
qs = (f"/api/qt/clist/get?fid=f62&po={po}&pz={pz}&pn=1&np=1"
f"&fltt=2&invt=2&fs={fs}&fields=f12,f14,f62,f184")
last_err: Exception | None = None
for host in _FLOW_HOSTS:
try:
resp = await client.get(host + qs)
resp.raise_for_status()
diff = (resp.json().get("data") or {}).get("diff") or []
break
except Exception as e:
last_err = e
diff = []
else:
raise ConnectionError(f"板块资金流全部数据源失败: {last_err}")
if isinstance(diff, dict): # 兼容旧版 {index: item} 结构
diff = list(diff.values())
boards = []
for d in diff:
amt = d.get("f62")
if not isinstance(amt, (int, float)):
continue
boards.append({
"code": d.get("f12", ""),
"name": d.get("f14", ""),
"mainNet": round(amt / 1e8, 1), # 亿元
"mainNetPct": d.get("f184"), # 主力净占比 %
})
return boards
_GLOBAL_INDICES = "100.NDX,100.DJIA,100.SPX,100.HSI,100.N225,100.FTSE"
_FUT_MARKETS = ("m:8", "m:113", "m:142", "m:114", "m:115") # 中金所/上期所/上期能源/大商所/郑商所
async def fetch_global_markets() -> dict | None:
"""海外主要指数 + 国内期货主力合约;失败返回 None"""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT, headers={"User-Agent": _UA}) as client:
overseas, futures = await asyncio.gather(
_fetch_overseas_indices(client),
_fetch_futures_main(client),
)
if not overseas and not futures:
return None
return {"overseas": overseas, "futures": futures}
except Exception:
return None
async def _fetch_overseas_indices(client: httpx.AsyncClient) -> list[dict]:
resp = await client.get(
"https://push2delay.eastmoney.com/api/qt/ulist.np/get",
params={"secids": _GLOBAL_INDICES, "fields": "f12,f14,f2,f3", "fltt": 2, "invt": 2},
)
resp.raise_for_status()
diff = (resp.json().get("data") or {}).get("diff") or []
return [
{"name": d.get("f14", ""), "price": d.get("f2"), "changePct": d.get("f3")}
for d in diff
]
async def _fetch_futures_main(client: httpx.AsyncClient) -> list[dict]:
"""国内期货主力合约(名称含"主连/主力合约"),按成交额降序取前12"""
import re
results: list[dict] = []
for fs in _FUT_MARKETS:
try:
resp = await client.get(
"https://push2delay.eastmoney.com/api/qt/clist/get",
params={"fid": "f6", "po": 1, "pz": 200, "pn": 1, "np": 1,
"fltt": 2, "invt": 2, "fs": fs, "fields": "f12,f14,f2,f3,f6"},
)
resp.raise_for_status()
diff = (resp.json().get("data") or {}).get("diff") or []
except Exception:
continue
for d in diff:
name = d.get("f14") or ""
if "主连" not in name and "主力合约" not in name:
continue
if "次主连" in name: # 次主力合约,排除
continue
amount = d.get("f6")
if not isinstance(amount, (int, float)):
continue
results.append({
"name": re.sub(r"(主连|主力合约)$", "", name),
"price": d.get("f2"),
"changePct": d.get("f3"),
"amountYi": round(amount / 1e8, 1),
})
results.sort(key=lambda x: -x["amountYi"])
return results[:12]
async def fetch_sector_fund_flow() -> dict | None:
"""板块主力资金流排行:行业净流入/净流出 TOP6 + 概念净流入 TOP6;失败返回 None"""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT, headers={"User-Agent": _UA}) as client:
industry_in, industry_out, concept_in = await asyncio.gather(
_fetch_flow_boards(client, "m:90+t:2", 1, 6),
_fetch_flow_boards(client, "m:90+t:2", 0, 6),
_fetch_flow_boards(client, "m:90+t:3", 1, 6),
)
return {
"industryInflow": industry_in, # 主力净流入降序
"industryOutflow": industry_out, # 升序(净流出最多在前)
"conceptInflow": concept_in,
"unit": "亿元",
}
except Exception:
return None
async def fetch_margin_summary() -> dict | None:
"""沪深北两融余额汇总(交易所 T+1 披露);失败返回 None"""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT, headers={"User-Agent": _UA}) as client:
resp = await client.get(
"https://datacenter-web.eastmoney.com/api/data/v1/get",
params={
"reportName": "RPTA_RZRQ_LSHJ",
"columns": "ALL",
"source": "WEB",
"sortColumns": "dim_date",
"sortTypes": "-1",
"pageSize": 2,
"pageNumber": 1,
},
)
resp.raise_for_status()
rows = ((resp.json().get("result") or {}).get("data")) or []
if not rows:
return None
def _balance(row: dict) -> float:
return float(row.get("RZYE") or 0) + float(row.get("RQYE") or 0)
latest = rows[0]
prev = rows[1] if len(rows) > 1 else None
balance = _balance(latest)
change = (balance - _balance(prev)) if prev else None
return {
"date": (latest.get("DIM_DATE") or "")[:10],
"balanceYi": round(balance / 1e8), # 亿元
"changeYi": round(change / 1e8) if change is not None else None,
"rzjmeYi": round(float(latest.get("RZJME") or 0) / 1e8, 1), # 融资净买入
}
except Exception:
return None
+19 -10
View File
@@ -137,6 +137,22 @@ export function KLineCard({ code, addedAt, ready }: Props) {
const displayData = chartPeriod === "d" ? dailyData : minuteData;
const currentLoading = chartPeriod === "d" ? chartLoading : minuteLoading;
// 传给图表的数据引用需稳定(仅随 displayData 变化),
// 否则切换指标/显示方式时 KLineChart 会把新数组当成新数据集重置视口
const chartData = useMemo(
() =>
displayData.map((d) => ({
time: d.dateMs,
open: d.open,
close: d.close,
high: d.high,
low: d.low,
volume: d.volume,
isAddedDate: d.isAddedDate,
})),
[displayData],
);
return (
<Card className="shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
@@ -213,7 +229,7 @@ export function KLineCard({ code, addedAt, ready }: Props) {
</CardHeader>
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
{currentLoading ? (
<div className="h-[320px] sm:h-[380px] md:h-[440px] w-full flex items-center justify-center">
<div className="h-[390px] w-full flex items-center justify-center">
<div className="flex items-center text-muted-foreground text-sm">
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
K线数据加载中...
@@ -221,18 +237,11 @@ export function KLineCard({ code, addedAt, ready }: Props) {
</div>
) : (
<KLineChart
data={displayData.map((d) => ({
time: d.dateMs,
open: d.open,
close: d.close,
high: d.high,
low: d.low,
volume: d.volume,
isAddedDate: d.isAddedDate,
}))}
data={chartData}
mode={chartMode}
hasAddedDate={ready}
indicators={indicators}
defaultVisibleDays={chartPeriod === "d" ? 90 : undefined}
/>
)}
</CardContent>
+73 -5
View File
@@ -12,6 +12,7 @@ import {
ColorType,
CrosshairMode,
LineStyle,
TickMarkType,
createSeriesMarkers,
type IChartApi,
type ISeriesApi,
@@ -41,6 +42,8 @@ interface Props {
mode: "line" | "candle";
hasAddedDate?: boolean;
indicators: IndicatorToggles;
/** 默认只展示最近 N 个自然日(日线用),其余数据靠拖动查看;不传则铺满全部数据 */
defaultVisibleDays?: number;
}
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
@@ -61,9 +64,41 @@ const MACD_DIF = "#3b82f6";
const MACD_DEA = "#f59e0b";
const RSI_COLOR = "#a855f7";
export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
// 时间统一按 UTC 取值格式化(kline-card 把北京时间墙钟视作 UTC 存储),
// 用本地时区方法会导致日期错位一天
function timeToDate(t: Time): Date {
if (typeof t === "number") return new Date(t * 1000);
if (typeof t === "string") return new Date(t.length === 10 ? `${t}T00:00:00Z` : t);
return new Date(Date.UTC(t.year, t.month - 1, t.day));
}
const pad2 = (n: number) => String(n).padStart(2, "0");
/** 十字光标处的完整日期:2026-09-01 */
function formatCrosshairTime(t: Time): string {
const d = timeToDate(t);
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
}
/** 底部时间轴刻度:年→2026、月→2026-09、日→09-01(替代默认的 "01 9月 '26") */
function formatTickMark(t: Time, tickMarkType: TickMarkType): string {
const d = timeToDate(t);
if (tickMarkType === TickMarkType.Year) return `${d.getUTCFullYear()}`;
if (tickMarkType === TickMarkType.Month) return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}`;
return `${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
}
// 各 pane 固定高度:主图加高、成交量压低,指标副图居中
const MAIN_PANE_H = 320;
const VOL_PANE_H = 70;
const IND_PANE_H = 95;
export function KLineChart({ data, mode, hasAddedDate, indicators, defaultVisibleDays }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
// 记录上次填充的数据集:仅数据集变化(首次加载/切换周期)时调整视口,
// 指标开关/显示方式切换时保留用户拖动缩放后的位置
const lastDataRef = useRef<KLineItem[] | null>(null);
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
// 指标 series(重建用)
@@ -73,8 +108,15 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
// 容器高度随指标 pane 数量增长,避免主图被压缩
const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length;
// 固定图表高度:主图+成交量 300,每个指标副图 +95
const chartHeight = 300 + extraPanes * 95;
// 图表总高 = 各 pane 高度之和(主图 + 成交量 + 指标副图×N)
const chartHeight = MAIN_PANE_H + VOL_PANE_H + extraPanes * IND_PANE_H;
// pane 顺序固定:0=主图 1=成交量 2+=指标副图;指标开关增删 pane 后重新应用高度
function applyPaneHeights(chart: IChartApi) {
chart.panes().forEach((pane, i) => {
pane.setHeight(i === 0 ? MAIN_PANE_H : i === 1 ? VOL_PANE_H : IND_PANE_H);
});
}
// 创建图表(仅一次):pane0 主图 + pane1 成交量
useEffect(() => {
@@ -94,7 +136,12 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
horzLines: { color: GRID, style: LineStyle.Dashed, visible: true },
},
rightPriceScale: { borderColor: GRID },
timeScale: { borderColor: GRID, timeVisible: false },
localization: { timeFormatter: formatCrosshairTime },
timeScale: {
borderColor: GRID,
timeVisible: false,
tickMarkFormatter: formatTickMark,
},
crosshair: { mode: CrosshairMode.Normal },
});
chartRef.current = chart;
@@ -109,6 +156,7 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
1,
);
volSeriesRef.current = vol;
applyPaneHeights(chart);
return () => {
chart.remove();
@@ -118,6 +166,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
maSeriesRef.current = [];
macdSeriesRef.current = [];
rsiSeriesRef.current = [];
// 图表实例销毁后重置,重建时重新应用默认视口(StrictMode 重挂载同样生效)
lastDataRef.current = null;
};
}, []);
@@ -223,6 +273,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
);
rsiSeriesRef.current.push(s);
}
applyPaneHeights(chart);
}
// 模式切换:重建价格 series + 指标
@@ -310,7 +362,23 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
createSeriesMarkers(ps, []);
}
chart.timeScale().fitContent();
if (lastDataRef.current === data) return;
lastDataRef.current = data;
if (defaultVisibleDays) {
// 默认视口只展示最近 N 个自然日,更早的数据靠向左拖动查看
const toMs = (t: string | number) =>
typeof t === "number" ? t * 1000 : new Date(t).getTime();
const lastMs = toMs(data[data.length - 1].time);
let fromIdx = data.findIndex(
(d) => toMs(d.time) >= lastMs - defaultVisibleDays * 86400_000,
);
if (fromIdx < 0) fromIdx = 0;
chart
.timeScale()
.setVisibleLogicalRange({ from: fromIdx, to: data.length - 1 + 2 });
} else {
chart.timeScale().fitContent();
}
}
// 颜色图例:根据启用的指标生成
+3
View File
@@ -14,7 +14,10 @@ export interface AiReport {
toolsUsed: string[];
model: string;
tokens_used: number;
generation_count?: number;
issue_number?: number;
created_at: string;
updated_at?: string | null;
}
export async function fetchAiLatestReport(): Promise<AiReport> {
+124 -35
View File
@@ -4,6 +4,7 @@ import { ArrowLeft, Clock, Loader2, AlertCircle, Wrench, Calendar } from "lucide
import { useQuery } from "@tanstack/react-query";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { Mermaid } from "../components/Mermaid";
import { fetchAiLatestReport } from "../lib/ai-analysis-api";
import { Card, CardContent } from "../components/ui/card";
@@ -12,6 +13,51 @@ export const Route = createFileRoute("/ai-analysis")({
component: AiAnalysisPage,
});
/** A股惯例红涨绿跌:只给带显式 +/- 号的数字着色(+2.3% / -1.2亿 / +56万...),无符号数字语义不明保持默认 */
const SIGNED_NUM_RE = /([+-]\d+(?:\.\d+)?(?:%|亿|万亿|万)?)/g;
function colorizeText(text: string): React.ReactNode[] {
return text.split(SIGNED_NUM_RE).map((part, i) => {
if (part.startsWith("+") || part.startsWith("-")) {
const val = parseFloat(part);
if (!Number.isNaN(val) && val !== 0) {
if (val > 0) return <span key={i} className="text-red-500">{part}</span>;
return <span key={i} className="text-green-600">{part}</span>;
}
}
return <React.Fragment key={i}>{part}</React.Fragment>;
});
}
function colorizeChildren(children: React.ReactNode): React.ReactNode {
return React.Children.map(children, (child) => {
if (typeof child === "string") return colorizeText(child);
if (React.isValidElement(child)) {
const kids = (child.props as { children?: React.ReactNode }).children;
if (kids != null) {
return React.cloneElement(
child as React.ReactElement<{ children?: React.ReactNode }>,
{},
colorizeChildren(kids),
);
}
}
return child;
});
}
/** 按 "## " 二级标题拆分报告为多张卡片;首段(# 标题 + 定调引用块)单独一张 */
function splitReport(content: string): { intro: string; sections: { title: string; body: string }[] } {
const parts = content.split(/\n(?=## )/);
const sections = parts.slice(1).map((p) => {
const nl = p.indexOf("\n");
const title = (nl === -1 ? p : p.slice(0, nl)).replace(/^##\s*/, "").trim();
const body = nl === -1 ? "" : p.slice(nl + 1).trim();
return { title, body };
});
return { intro: parts[0].trim(), sections };
}
function AiAnalysisPage() {
const { data: report, isLoading, isError } = useQuery({
queryKey: ["ai-report-latest"],
@@ -19,6 +65,30 @@ function AiAnalysisPage() {
retry: false,
});
const mdComponents: Components = {
table({ children, ...props }) {
return (
<div className="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0">
<table {...props}>{children}</table>
</div>
);
},
td({ children, ...props }) {
return <td {...props}>{colorizeChildren(children)}</td>;
},
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
if (match && match[1] === "mermaid") {
return <Mermaid chart={String(children).replace(/\n$/, "")} />;
}
return (
<code className={className} {...props}>
{children}
</code>
);
},
};
return (
<div className="min-h-screen bg-background text-foreground">
<div className="max-w-4xl mx-auto px-3 sm:px-4 py-4 sm:py-6">
@@ -30,6 +100,9 @@ function AiAnalysisPage() {
<h1 className="text-lg sm:text-xl md:text-2xl font-bold flex items-center gap-2">
<Calendar className="h-5 w-5 text-primary" />
AI 收盘分析
{report?.issue_number ? (
<span className="text-xs font-normal text-muted-foreground">总第 {report.issue_number} 期</span>
) : null}
</h1>
</div>
@@ -53,9 +126,12 @@ function AiAnalysisPage() {
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 sm:gap-4 text-xs sm:text-sm text-muted-foreground">
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{report.created_at}</span>
<span className="truncate">{report.updated_at || report.created_at}</span>
</span>
<span>{report.tokens_used?.toLocaleString()} tokens</span>
{(report.generation_count ?? 1) > 1 && (
<span>第 {report.generation_count} 次生成</span>
)}
{report.toolsUsed && report.toolsUsed.length > 0 && (
<span className="flex items-center gap-1.5">
<Wrench className="h-3.5 w-3.5 shrink-0" />
@@ -66,44 +142,57 @@ function AiAnalysisPage() {
</CardContent>
</Card>
{/* Report Content */}
<Card className="bg-card border-border">
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
<Markdown
remarkPlugins={[remarkGfm]}
components={{
table({ children, ...props }) {
return (
<div className="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0">
<table {...props}>{children}</table>
</div>
);
},
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
if (match && match[1] === "mermaid") {
return <Mermaid chart={String(children).replace(/\n$/, "")} />;
}
return (
<code className={className} {...props}>
{children}
</code>
);
},
}}
>
{report.content}
</Markdown>
</CardContent>
</Card>
{/* 今日定调摘要 */}
{report.summary && (
<div className="mb-3 sm:mb-4 rounded-lg border border-primary/30 bg-primary/5 px-3 sm:px-4 py-3">
<div className="flex items-start gap-2.5">
<span className="shrink-0 mt-0.5 rounded bg-primary/10 px-1.5 py-0.5 text-[11px] font-semibold text-primary">
今日定调
</span>
<p className="text-sm leading-relaxed">{report.summary}</p>
</div>
</div>
)}
{/* Disclaimer */}
<p className="text-xs text-muted-foreground/60 text-center mt-4 sm:mt-6">
本报告由 AI 生成,仅供参考,不构成投资建议
</p>
{/* 报告正文:按 ## 章节分卡片渲染 */}
{(() => {
const { intro, sections } = splitReport(report.content);
return (
<div className="space-y-3 sm:space-y-4">
{intro && (
<Card className="bg-card border-border">
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
<Markdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{intro}
</Markdown>
</CardContent>
</Card>
)}
{sections.map((sec) => (
<Card key={sec.title} className="bg-card border-border overflow-hidden">
<div className="px-3 sm:px-4 md:px-6 pt-3 sm:pt-4 pb-2 sm:pb-3 border-b border-border/60">
<h2 className="text-base sm:text-lg font-semibold">{sec.title}</h2>
</div>
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
<Markdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{sec.body}
</Markdown>
</CardContent>
</Card>
))}
</div>
);
})()}
</>
)}
{/* Disclaimer */}
<p className="text-xs text-muted-foreground/60 text-center mt-4 sm:mt-6">
本报告由 AI 生成,仅供参考,不构成投资建议
</p>
</div>
</div>
);
}
export default AiAnalysisPage;