Compare commits

15 Commits
Author SHA1 Message Date
Sakurasan ca0d908129 fix: 连续上榜改为严格N日每天上榜,缺一日都不行 2026-09-17 23:27:15 +08:00
Sakurasan 9d1e1c9af5 feat: AI日报核心股追踪章节增加连续上榜股票(近3日+) 2026-09-17 23:07:25 +08:00
Sakurasan 1df1f77b13 feat: 热点股追踪增加近3/5/10交易日筛选 2026-09-17 20:34:42 +08:00
Sakurasan 229362533b 更新prompt模板 2026-09-13 00:51:56 +08:00
Sakurasan 6c7d8fe4bc feat: AI分析页支持按日期查看历史报告(?date=,兼容?data=) 2026-09-12 20:21:44 +08:00
Sakurasan e24b1a360b feat: 优化每日分析提示词模板并修复分段生成链路
提示词:
- SYSTEM_PROMPT 重写为可执行原则(事实优先/时间锚定/判断挂数字/禁用套话)+输出格式契约
- DAILY_ANALYSIS_PROMPT 重排注意力:取数指令置顶(要求同轮并行调用 4 个核心工具)、
  写作规约 8 条、环比快照居中、前日报告全文置底并改为「今日数据优先+核对昨日展望」
- 新增单位口径、符号规范(+/- 是前端红涨绿跌着色依据)、不荐股等硬规约
- 移除已废弃的「今日定调」摘要规则(与分段指令、前端渲染互相冲突)
- 八章重构:板块资金流独立为「四、资金与筹码」,重复的「关注方向+下个交易日建议」
  合并为「七、明日展望」

生成链路:
- 修复分段生成未回灌前文,后段看不到前段导致重复铺数据、数字口径打架
- 新增 REQUIRED_TOOLS 校验:核心工具不全时补一轮并在 user 消息点名缺失项
- 修复 _extract_summary 被 # 标题行占掉,改为剥离标题与 Markdown 标记后截断

AGENTS.md 同步八章结构与 prompt 工程约定
2026-09-12 15:57:14 +08:00
Sakurasan fefb1b4736 feat: 记录并展示每次生成消耗的LLM调用次数(llm_calls) 2026-09-02 14:10:49 +08:00
Sakurasan 734a449037 docs: AGENTS.md 同步移除今日定调约定 2026-09-02 13:51:13 +08:00
Sakurasan 854427092d remove: 去掉今日定调摘要(prompt不再生成、前端移除高亮框、空标题卡不渲染) 2026-09-02 13:49:52 +08:00
Sakurasan d8d36f32cf merge: 合并 origin 主题图标改动(backend/dist/admin.html)与本地K线/AI分析五提交 2026-09-02 13:26:18 +08:00
Sakurasan 1d46ad7a08 feat: AI分析第三档——海外期货章节、报告分卡片渲染、红绿着色、期号;LLM改流式+分段生成绕开网关120s超时 2026-09-02 13:17:49 +08:00
Sakurasan 311b9f0d7e feat: AI分析第二档——get_news消息面、板块主力资金流TOPS、两融余额、截断续写机制 2026-09-02 03:50:43 +08:00
Sakurasan 0fb2f8d3a5 feat: 每日AI分析改造——定调摘要、环比快照、完整连板梯队带涨停原因、生成次数记账 2026-09-02 02:59:14 +08:00
Sakurasan 375d196fef feat: K线图默认展示近3个月,拖动回看更早数据;自定义日期格式;主图加高成交量压低 2026-09-02 01:13:26 +08:00
Sakurasan 9a72f0bbf6 chore: 忽略后端运行时数据目录 backend/data 2026-09-02 01:13:26 +08:00
17 changed files with 1241 additions and 173 deletions
+3
View File
@@ -123,3 +123,6 @@ tmp/
/.core.hmbtNy /.core.hmbtNy
/.core.dump /.core.dump
.v2-demo-backup/ .v2-demo-backup/
# 后端运行时数据(本地启动生成:SQLite 库、管理密码等)
backend/data/
+25 -2
View File
@@ -55,8 +55,9 @@
- 生成分享链接时复用已有短链,避免每次生成新链接 - 生成分享链接时复用已有短链,避免每次生成新链接
- 全页面适配移动端:响应式字体、间距、布局断点(sm/md/lg) - 全页面适配移动端:响应式字体、间距、布局断点(sm/md/lg)
- K线图历史数据获取失败时直接报错,禁止使用模拟数据降级(避免"刷新数据变化"问题) - K线图历史数据获取失败时直接报错,禁止使用模拟数据降级(避免"刷新数据变化"问题)
- K线天数逻辑:默认3个月(90天),自选日期到现在超过3个月则从自选日期开始 - K线天数逻辑:日K一次拉取近365天(供拖动回看),默认视口只展示最近90个自然日(3个月),向左拖动查看更早数据;分钟K一次拉取320根,铺满展示
- K线图使用 recharts Brush 组件实现移动端缩放和滑动查看 - 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开头=北交(橙),主板不显示标签;标记位置:搜索候选、详情页标题、集合卡片股票列表、分享页股票卡片标题 - 板块标记: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编码}`(按名称搜索金十资讯) - 详情页右上角外部跳转按钮:①"东方财富" `https://wap.eastmoney.com/quote/stock/{market}.{code}.html?appfenxiang=1`,market映射 688→6/60→1/其他→0;②"金十数据" `https://search.jin10.com/?keyword={股票名称URL编码}`(按名称搜索金十资讯)
- 详情页资金流向模块:展示近30日资金流向分析,包含: - 详情页资金流向模块:展示近30日资金流向分析,包含:
@@ -70,3 +71,25 @@
- 资金流向数据获取失败时,前端通过 `fundFlowError` 状态显示错误信息,便于排查问题 - 资金流向数据获取失败时,前端通过 `fundFlowError` 状态显示错误信息,便于排查问题
- ❌ 东方财富 API(push2his.eastmoney.com)在 Edge Function 环境被拒绝访问(peer closed connection),主力净流入数据无法获取,显示为 "-" - ❌ 东方财富 API(push2his.eastmoney.com)在 Edge Function 环境被拒绝访问(peer closed connection),主力净流入数据无法获取,显示为 "-"
- ✅ 替代方案:使用腾讯实时行情API的外盘(索引7)和内盘(索引8)数据计算净主动买入额 = (外盘 - 内盘) × 当前价 × 100(单位:元) - ✅ 替代方案:使用腾讯实时行情API的外盘(索引7)和内盘(索引8)数据计算净主动买入额 = (外盘 - 内盘) × 当前价 × 100(单位:元)
## 每日 AI 分析(backend FastAPI,15:10 自动触发)
- 报告结构约定:正文以 `# {trade_date} A股收盘分析报告` 开头后直接进入第一章,无"今日定调"摘要(已移除:prompt 不生成、前端无高亮框);`summary` 为去掉标题行与 Markdown 标记后的正文前200字,供管理列表展示
- 八章固定顺序:一、市场总览|二、题材热点分析|三、核心股追踪|四、资金与筹码|五、重要消息面|六、海外市场与国内期货|七、明日展望|八、风险提示;分段归属:part1=一+二、part2=三+四、part3=五~八(原"四、关注方向/五、下个交易日建议"内容重复,已合并为"七、明日展望",板块资金流从第二章挪出独立成第四章)
- prompt 工程约定(改模板必读):①取数指令放 prompt 最前(要求同一轮并行调用 4 个核心工具,代码用 `REQUIRED_TOOLS` 校验,缺则补一轮并在 user 消息里点名缺哪些);②环比快照紧随其后、前日报告全文放最后(避免长文本把取数指令挤出注意力区);③凡写数字的规则必须同时写"找不到就写 —(今日无数据)",否则模型会用常识补全;④涨跌幅/环比/净额一律带 + 或 -,这是前端 `colorizeText` 红涨绿跌的着色依据;⑤模板用 `.format()` 渲染,正文中出现裸 `{}` 会直接报错
- 分段生成必须回灌前文(`messages.append({"role":"assistant","content":part_content})`):否则后段看不到前段,会重复铺同一段数据、数字口径打架;生成失败的占位段不回灌
- 环比数据:`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被掐),按空轮次处理重试即可
+22
View File
@@ -82,9 +82,20 @@ CREATE TABLE IF NOT EXISTS ai_reports (
tools_used TEXT, tools_used TEXT,
model TEXT NOT NULL, model TEXT NOT NULL,
tokens_used INTEGER, tokens_used INTEGER,
generation_count INTEGER NOT NULL DEFAULT 1,
llm_calls INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
updated_at TEXT,
UNIQUE(trade_date, report_type) 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 +118,17 @@ def init_db():
conn = get_connection() conn = get_connection()
conn.executescript(SCHEMA_SQL) 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",
"ALTER TABLE ai_reports ADD COLUMN llm_calls INTEGER",
):
try:
conn.execute(alter_sql)
except sqlite3.OperationalError:
pass # 列已存在
# 清理过期缓存 # 清理过期缓存
from services.cache import clean_expired from services.cache import clean_expired
clean_expired() clean_expired()
+2 -2
View File
@@ -334,7 +334,7 @@ function renderReports(list) {
document.getElementById('recentBody').innerHTML = list.map(r => document.getElementById('recentBody').innerHTML = list.map(r =>
`<tr> `<tr>
<td>${r.trade_date}</td> <td>${r.trade_date}</td>
<td><a href="/ai-analysis" target="_blank" style="color:var(--primary);text-decoration:none">${r.title || '-'}</a></td> <td><a href="/ai-analysis?date=${r.trade_date}" target="_blank" style="color:var(--primary);text-decoration:none">${r.title || '-'}</a></td>
<td>${r.tokens_used || 0}</td> <td>${r.tokens_used || 0}</td>
</tr>` </tr>`
).join('') || '<tr><td colspan="3" style="color:var(--muted-foreground);text-align:center">暂无数据</td></tr>'; ).join('') || '<tr><td colspan="3" style="color:var(--muted-foreground);text-align:center">暂无数据</td></tr>';
@@ -349,7 +349,7 @@ function renderAllReports(list) {
<td>${r.tokens_used || 0}</td> <td>${r.tokens_used || 0}</td>
<td>${r.created_at || '-'}</td> <td>${r.created_at || '-'}</td>
<td style="white-space:nowrap"> <td style="white-space:nowrap">
<a href="/ai-analysis" target="_blank" class="btn btn-ghost btn-sm" style="padding:3px 10px;font-size:11px;text-decoration:none">查看</a> <a href="/ai-analysis?date=${r.trade_date}" target="_blank" class="btn btn-ghost btn-sm" style="padding:3px 10px;font-size:11px;text-decoration:none">查看</a>
<button class="btn btn-destructive btn-sm" style="padding:3px 10px;font-size:11px" onclick="deleteReport(${r.id})">删除</button> <button class="btn btn-destructive btn-sm" style="padding:3px 10px;font-size:11px" onclick="deleteReport(${r.id})">删除</button>
</td> </td>
</tr>` </tr>`
+2 -1
View File
@@ -143,7 +143,8 @@ async def admin_list_reports(request: Request):
conn = get_connection() conn = get_connection()
try: try:
rows = conn.execute( 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, llm_calls, created_at, updated_at "
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50" "FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
).fetchall() ).fetchall()
items = [] items = []
+29 -2
View File
@@ -43,8 +43,11 @@ def _can_trigger(trade_date: str) -> tuple[bool, str]:
async def get_latest_report(): async def get_latest_report():
conn = get_connection() conn = get_connection()
try: try:
# issue_number:按报告日期序数作为总期号
row = conn.execute( 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() ).fetchone()
if not row: if not row:
raise HTTPException(status_code=404, detail="暂无分析报告") raise HTTPException(status_code=404, detail="暂无分析报告")
@@ -60,7 +63,8 @@ async def list_reports():
conn = get_connection() conn = get_connection()
try: try:
rows = conn.execute( 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, llm_calls, created_at, updated_at "
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50" "FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
).fetchall() ).fetchall()
items = [] items = []
@@ -73,6 +77,29 @@ async def list_reports():
conn.close() conn.close()
@router.get("/ai-analysis/by-date/{trade_date}", summary="按交易日获取 AI 分析报告")
async def get_report_by_date(trade_date: str):
try:
datetime.strptime(trade_date, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="日期格式错误,应为 YYYY-MM-DD")
conn = get_connection()
try:
row = conn.execute(
"""SELECT r.*,
(SELECT COUNT(*) FROM ai_reports WHERE trade_date <= r.trade_date) AS issue_number
FROM ai_reports r WHERE r.trade_date = ? AND r.report_type = 'daily' LIMIT 1""",
(trade_date,)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail=f"{trade_date} 暂无分析报告")
item = dict_from_row(row)
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
return JSONResponse({"data": item}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/ai-analysis/{report_id}", summary="AI 分析报告详情") @router.get("/ai-analysis/{report_id}", summary="AI 分析报告详情")
async def get_report(report_id: int): async def get_report(report_id: int):
conn = get_connection() conn = get_connection()
+53 -3
View File
@@ -20,11 +20,11 @@ def _recent_trade_dates(conn, n: int = 10) -> list[str]:
return [r["trade_date"] for r in reversed(rows)] return [r["trade_date"] for r in reversed(rows)]
@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵") @router.get("/active", summary="活跃核心股 + 最近N日涨幅矩阵")
async def active_core_stocks(): async def active_core_stocks(days: int = Query(10, ge=1, le=30, description="交易日天数")):
conn = get_connection() conn = get_connection()
try: try:
dates = _recent_trade_dates(conn, 10) dates = _recent_trade_dates(conn, days)
if not dates: if not dates:
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS) return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
@@ -90,6 +90,56 @@ async def active_core_stocks():
conn.close() conn.close()
@router.get("/consecutive", summary="最近N日每天上榜的核心股")
async def consecutive_core_stocks(days: int = Query(3, ge=2, le=10, description="交易日天数")):
conn = get_connection()
try:
rows = conn.execute(
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
(days,)
).fetchall()
recent_dates = [r["trade_date"] for r in reversed(rows)]
if len(recent_dates) < days:
return JSONResponse({"days": days, "stocks": []}, headers=_NO_CACHE_HEADERS)
placeholders = ",".join("?" * len(recent_dates))
rows = conn.execute(
f"""SELECT trade_date, stock_code, stock_name, f3, cover_count
FROM daily_core_stocks
WHERE trade_date IN ({placeholders})""",
recent_dates,
).fetchall()
stock_dates: dict[str, dict] = {}
for r in rows:
code = r["stock_code"]
s = stock_dates.setdefault(code, {
"stockCode": code,
"stockName": r["stock_name"],
"dates": set(),
"dailyGains": {},
})
s["dates"].add(r["trade_date"])
s["dailyGains"][r["trade_date"]] = r["f3"]
results = []
for code, s in stock_dates.items():
if len(s["dates"]) >= days:
results.append({
"stockCode": code,
"stockName": s["stockName"],
"consecutiveDays": len(s["dates"]),
"recentDates": sorted(s["dates"]),
"dailyGains": s["dailyGains"],
})
results.sort(key=lambda x: -x["consecutiveDays"])
return JSONResponse({"days": days, "recentDates": recent_dates, "stocks": results}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/history", summary="指定交易日核心股(含所属题材)") @router.get("/history", summary="指定交易日核心股(含所属题材)")
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")): async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
conn = get_connection() conn = get_connection()
+80 -1
View File
@@ -17,7 +17,7 @@ from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from services import fuyao_client from services import fuyao_client, market_extra
router = APIRouter() router = APIRouter()
@@ -163,12 +163,18 @@ async def _build_dashboard() -> dict:
anomaly_data, anomaly_data,
limit_ladder_data, limit_ladder_data,
skyrocket_data, skyrocket_data,
sector_flow_data,
margin_data,
global_markets_data,
) = await asyncio.gather( ) = await asyncio.gather(
fuyao_client.hot_stock_list("day"), fuyao_client.hot_stock_list("day"),
fuyao_client.dragon_tiger_list("all"), fuyao_client.dragon_tiger_list("all"),
fuyao_client.anomaly_analysis_list(["SHARP_RISE", "RAPID_RALLY", "LIMIT_UP"]), fuyao_client.anomaly_analysis_list(["SHARP_RISE", "RAPID_RALLY", "LIMIT_UP"]),
fuyao_client.limit_up_ladder(), fuyao_client.limit_up_ladder(),
fuyao_client.skyrocket_list("day"), fuyao_client.skyrocket_list("day"),
market_extra.fetch_sector_fund_flow(),
market_extra.fetch_margin_summary(),
market_extra.fetch_global_markets(),
return_exceptions=True, return_exceptions=True,
) )
except Exception: except Exception:
@@ -177,6 +183,9 @@ async def _build_dashboard() -> dict:
anomaly_data = {} anomaly_data = {}
limit_ladder_data = {} limit_ladder_data = {}
skyrocket_data = {} skyrocket_data = {}
sector_flow_data = None
margin_data = None
global_markets_data = None
# ── 解析指数 ── # ── 解析指数 ──
indices = [] indices = []
@@ -291,6 +300,12 @@ async def _build_dashboard() -> dict:
"auctionSignal": auction_signal, "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 = [] sector_strength = []
industries = industry_catalog if isinstance(industry_catalog, list) else [] 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}", "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 = { result = {
"indices": indices, "indices": indices,
"marketStats": market_stats, "marketStats": market_stats,
"sectorStrength": sector_strength[:31], "sectorStrength": sector_strength[:31],
"conceptStrength": concept_strength[:10], "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, "events": events,
"limitLadder": limit_ladder,
"updateTime": datetime.now(BJT).strftime("%Y-%m-%d %H:%M:%S"), "updateTime": datetime.now(BJT).strftime("%Y-%m-%d %H:%M:%S"),
} }
+392 -75
View File
@@ -3,10 +3,15 @@
负责: 负责:
1. 调用 OpenAI 兼容 API 进行分析 1. 调用 OpenAI 兼容 API 进行分析
2. Function Calling 循环(AI 可主动获取数据) 2. Function Calling 循环(AI 可主动获取数据)
3. 保存报告到数据库 3. 采集当日盘面快照(供次日环比)
4. 保存报告到数据库
""" """
import asyncio
import json import json
import re
import traceback
import httpx import httpx
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@@ -16,7 +21,7 @@ from database import get_connection
_CST = timezone(timedelta(hours=8)) _CST = timezone(timedelta(hours=8))
SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中发现投资机会。 SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中发现投资机会。为收盘后的每日复盘报告供稿。读者是有一定经验、但没时间盯盘的短线与波段投资者。
你的分析风格: 你的分析风格:
- 数据驱动,基于真实数据而非主观臆断 - 数据驱动,基于真实数据而非主观臆断
@@ -25,68 +30,163 @@ SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中
- 风险提示,每次推荐都需说明风险点 - 风险提示,每次推荐都需说明风险点
可用工具: 可用工具:
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/行业强度/事件情报/市场温度) - get_market_dashboard: 获取市场整体数据(指数/涨跌统计/市场温度/连板梯队/行业强度/板块资金流/两融/海外指数与国内期货/事件情报)
- get_theme_history: 获取指定日期的题材涨幅排行 - get_theme_history: 获取指定日期的题材涨幅排行
- get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材) - get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材)
- get_consecutive_core_stocks: 获取最近N个交易日每天都上榜的核心股(缺一日都不行),用于识别持续活跃的热点股
- get_stock_quote: 获取个股实时行情 - get_stock_quote: 获取个股实时行情
- get_fund_flow: 获取个股资金流向 - get_fund_flow: 获取个股资金流向
- get_news: 获取财经快讯(新浪7x24,用于重要消息面)
重要规则: 重要规则:
1. 你必须先调用工具获取数据,然后基于数据进行分析 1. 你必须先调用工具获取数据,然后基于数据进行分析
2. 不要凭空编造数据,所有数据必须来自工具返回 2. 不要凭空编造数据,所有数据必须来自工具返回
3. 如果工具返回空数据,如实说明数据不可用 3. 如果工具返回空数据,如实说明数据不可用
4. 分析完成后给出明确的结论和建议""" 4. 分析完成后给出明确的结论和建议
DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。 工作原则:
1. 事实优先:所有数字、股票名称与代码、涨停原因、封单金额必须能在工具返回中找到出处;工具没有的写「—(今日无数据)」,严禁凭记忆、常识或推理补全。
2. 时间锚定:只分析给定交易日当天及之前的真实数据,禁止引用训练记忆中的行情、政策或题材。
3. 判断必须挂数字:每个结论后面要有价格/涨跌幅/金额/家数支撑,不做无数据的定性判断。
4. 说人话:结论先行、短句、少形容词;禁用「整体来看」「值得注意的是」「情绪有所回暖」这类无信息量的套话。
输出格式(前端按 ## 二级标题切卡片渲染,红涨绿跌依赖数字符号):
- 章节标题严格用 `## 一、xxx` 形式,标题不超过 12 字
- 涨跌幅、环比变化、资金净额等有方向的数字必须带 + 或 - 号(如 +2.31%、-15.6亿);不带符号的数字不会被着色
- 多维度对比一律用 Markdown 表格,表头标注单位"""
# 一次工具轮就必须拿全的核心工具:模型常只调 1-2 个就开写,缺失会让对应章节数据空洞
REQUIRED_TOOLS = ("get_market_dashboard", "get_theme_history",
"get_active_core_stocks", "get_consecutive_core_stocks", "get_news")
DAILY_ANALYSIS_PROMPT = """请对 {trade_date}(A股交易日) 的A股市场进行收盘分析,生成一份完整的分析报告。
{prev_report_section} {prev_report_section}
{prev_snapshot_section}
请先调用以下工具获取数据: 你的分析风格:
1. get_market_dashboard - 获取市场整体数据 - 数据驱动,基于真实数据而非主观臆断
2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅 - 逻辑清晰,先总后分,层层递进
3. get_active_core_stocks - 获取核心股数据 - 观点明确,给出具体的操作建议
- 风险提示,每次推荐都需说明风险点
然后基于数据生成报告,结构如下: 重要规则:
1. 你必须先调用工具获取数据,然后基于数据进行分析
2. 不要凭空编造数据,所有数据必须来自工具返回
3. 如果工具返回空数据,如实说明数据不可用
4. 分析完成后给出明确的结论和建议
## 一、市场总览 ■ 第一步 · 取数(必须一次并行完成)
- 主要指数表现(上证、深证、创业板) 在同一次回复中并行发起以下 5 个工具调用,不要拆成多轮、不要只调其中一部分:
- 涨跌家数统计 1. get_market_dashboard() —— 指数、涨跌统计、市场温度、连板梯队 limitLadder、板块资金流 sectorFundFlow、两融、海外指数与国内期货 globalMarkets
- 市场温度评估 2. get_theme_history(date="{trade_date}") —— 今日题材涨幅榜
3. get_active_core_stocks() —— 核心股 10 日涨幅矩阵与所属题材
4. get_consecutive_core_stocks(days=3) —— 最近3个交易日每天都上榜的核心股(缺一日都不行)
5. get_news(limit=30) —— 今日财经快讯
只有在需要核实某只具体个股时,才额外调用 get_stock_quote / get_fund_flow,合计不超过 2 次。
## 二、题材热点分析 ■ 第二步 · 写作(以下规约对每一部分都生效)
- 今日涨幅前5题材 0. 报告标题(# 一级标题)之后的第一行,必须是一个引用块"定调摘要",格式严格为:
- 持续活跃的题材 > 今日定调:<一句话核心结论,不超过80字,必须包含1-2个关键数字(如成交额、涨停家数、市场温度)>
- 新兴热点题材 1. 事实约束:所有数字、个股代码与名称、涨停原因、封单金额必须出自工具返回,找不到就写「—(今日无数据)」,禁止推测或用常识填补;禁止用「预计/有望/大概率」代替数据。
2. 环比约束:量能与情绪类数字必须给环比,若上方提供了"前一交易日盘面数据快照"(成交额、涨跌家数、涨停/跌停/炸板、市场温度、两融余额、主要指数),首次提到时必须写成「今日值(较昨日 ±变化)」;快照缺失时写「暂无昨日基准」,不得编造环比。
3. 单位口径(全文统一,表头标注单位):成交额→亿元(≥1万亿时写 x.xx 万亿);资金净额→亿元;封单金额→万元(limitLadder 的 sealWan 字段);指数→点。
4. 符号规范:涨跌幅、环比变化、净流入/流出一律带 + 或 -,这是前端红涨绿跌的着色依据。
5. 连板梯队:连板梯队必须完整呈现 get_market_dashboard 返回的 limitLadder:从最高连板到2连板逐级列表格,每只标注涨停原因(reason字段)与封单金额(sealWan,单位万,为空则不写);首板只挑3-5只人气最高的点评。
6. 板块资金:板块资金面必须引用 get_market_dashboard 返回的 sectorFundFlow:行业主力净流入TOP5、净流出TOP5、概念净流入TOP5(亿元,带符号),结合题材分析说明资金动向。
7. 海外市场与国内期货:必须引用 get_market_dashboard 返回的 globalMarkets:overseas 为海外主要指数(纳斯达克/道琼斯/标普500/恒生/日经/富时),futures 为国内期货主力合约(按成交额降序,已含价格与涨跌幅);点评与A股关联度高的品种(股指期货、原油、贵金属、黑色系),数据缺失则如实说明。
8. 消息面:重要消息面必须基于 get_news 返回的快讯整理:挑5-8条对次日盘面影响最大的消息,每条格式为"【分类】一句话新闻 —— 一句影响解读"(分类用:宏观/政策/行业/公司/海外);快讯中若没有某方面的重要消息,如实说明「今日无重要消息」,严禁编造工具中不存在的新闻。
9. 适当使用表格展示数据对比。
基于数据生成报告。报告共八章,固定顺序:一、市场总览|二、题材热点分析|三、核心股追踪|四、资金与筹码|五、重要消息面|六、海外市场与国内期货|七、下个交易日建议|八、风险提示。
同一段数据只允许在它归属的那一章出现:指数与量能在第一章、题材涨幅在第二章、连板与核心股在第三章、资金流与两融在第四章,其他章节只做结论引用,不重复铺数据。
系统会把八章拆成三次生成,每次只写被指定的部分。
"""
## 三、核心股追踪 # 分段生成指令:网关对单次 LLM 请求有约120s硬超时,整篇报告一次生成必被掐断,
- 连板股分析 # 故拆为三段(每段约1200-1600字),各自独立调用后拼接
- 核心股表现 REPORT_PARTS = [
- 龙头股辨识 """现在写报告的【第1部分】,只输出这一部分,直接输出 Markdown,不要任何开场白或说明:
1. 以 `# {title} A股收盘分析报告` 一级标题开头
2. `## 一、市场总览`(指数表格、涨跌统计须环比、市场温度)
3. `## 二、题材热点分析`(涨幅前5、持续活跃、新兴热点、退潮警示、板块主力资金流TOP3)
全文控制在1600字以内。""",
"""现在写报告的【第2部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容:
- `## 三、核心股追踪`(连板梯队完整表格:层级/股票/涨停原因/封单,首板挑3-5只人气股点评;连续上榜股票表格:股票/连续天数/近3日涨幅,点评持续活跃的核心标的;核心股表现;龙头辨识)
- `## 四、资金与筹码`:sectorFundFlow 的行业净流入 TOP3 / 净流出 TOP3 / 概念净流入 TOP3(亿元,带符号)、两融余额与变化、事件情报中值得注意的筹码信号
全文控制在 1500 字以内。""",
"""前两部分已在上下文中,现在续写【第3部分】,直接输出 Markdown,不要复述前文、不要写过渡句:
- `## 五、重要消息面`:5-8条,格式【分类】新闻——影响解读
- `## 六、海外市场与国内期货`(点评对次日A股的影响)
全文控制在1300字以内。""",
"""现在写报告的【第3部分】,只输出这一部分,直接输出 Markdown,不要重复之前内容:
- `## 七、下个交易日建议`(大盘预判、题材方向、核心股、仓位策略、潜在交易机会、规避方向,结合市场情绪、资金流向、热点题材、核心股表现、消息面、海外市场与国内期货、风险提示,给出明确的结论和建议。)
- `## 八、风险提示`
全文控制在1900字以内。""",
]
## 五、下个交易日建议`(大盘预判、题材方向、核心股、仓位策略、规避方向)
## 四、关注方向
- 明日值得关注的题材方向
- 潜在的交易机会
## 五、下个交易日建议 async def _consume_sse(resp: httpx.Response) -> dict:
- 明日大盘预判(支撑/压力位) """消费 OpenAI 兼容 SSE 流,拼装为与非流式响应相同的结构"""
- 建议关注的题材方向(2-3个) 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"]
请用 Markdown 格式输出,适当使用表格展示数据对比。""" 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: async def call_llm(messages: list, tools: list = None) -> dict:
"""调用 OpenAI 兼容 API""" """调用 OpenAI 兼容 API(流式)。
必须用 stream:网关对非流式请求有约120s的代理超时,长生成会被 502 掐断;
流式下字节持续到达不会被判定超时。返回结构与非流式一致。
"""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
payload = { payload = {
"model": AI_MODEL, "model": AI_MODEL,
"messages": messages, "messages": messages,
"stream": True,
} }
if AI_MAX_TOKENS is not None: if AI_MAX_TOKENS is not None:
payload["max_tokens"] = AI_MAX_TOKENS payload["max_tokens"] = AI_MAX_TOKENS
@@ -96,17 +196,40 @@ async def call_llm(messages: list, tools: list = None) -> dict:
payload["tools"] = tools payload["tools"] = tools
payload["tool_choice"] = "auto" payload["tool_choice"] = "auto"
resp = await client.post( max_attempts = 4
for attempt in range(1, max_attempts + 1):
try:
async with client.stream(
"POST",
f"{AI_API_BASE}/chat/completions", f"{AI_API_BASE}/chat/completions",
headers={ headers={
"Authorization": f"Bearer {AI_API_KEY}", "Authorization": f"Bearer {AI_API_KEY}",
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "text/event-stream",
}, },
json=payload, json=payload,
timeout=120, 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() resp.raise_for_status()
return resp.json() 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: async def collect_ai_analysis(trade_date: str) -> dict:
@@ -119,72 +242,257 @@ async def collect_ai_analysis(trade_date: str) -> dict:
{"id": int, "tokens_used": int, "tools_used": list} {"id": int, "tokens_used": int, "tools_used": list}
""" """
prev_report_section = _get_prev_report_section(trade_date) 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 = [ messages = [
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": DAILY_ANALYSIS_PROMPT.format( {"role": "user", "content": DAILY_ANALYSIS_PROMPT.format(
trade_date=trade_date, trade_date=trade_date,
prev_report_section=prev_report_section prev_report_section=prev_report_section,
prev_snapshot_section=prev_snapshot_section,
)} )}
] ]
# ── 阶段一:工具轮(只获取数据,模型若直接开写报告则丢弃,由阶段二重写) ──
tools_used = [] tools_used = []
total_tokens = 0 total_tokens = 0
final_content = "" llm_calls = 0
was_truncated = False was_truncated = False
max_rounds = 30 # 安全上限,正常分析约 3-8 轮 max_rounds = 8
tool_rounds = 0
for i in range(max_rounds): for i in range(max_rounds):
response = await call_llm(messages, tools=TOOLS) response = await call_llm(messages, tools=TOOLS)
llm_calls += 1
total_tokens += response.get("usage", {}).get("total_tokens", 0) total_tokens += response.get("usage", {}).get("total_tokens", 0)
choice = response["choices"][0] choice = response["choices"][0]
message = choice["message"] message = choice["message"]
messages.append(message)
finish_reason = choice.get("finish_reason", "") 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": if finish_reason == "tool_calls" and message.get("tool_calls"):
final_content = message.get("content") or "" tool_rounds += 1
break messages.append(message)
for tool_call in message["tool_calls"]:
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", []):
func_name = tool_call["function"]["name"] func_name = tool_call["function"]["name"]
func_args = json.loads(tool_call["function"]["arguments"]) func_args = json.loads(tool_call["function"]["arguments"])
tools_used.append(func_name) tools_used.append(func_name)
result = await execute_tool(func_name, func_args) result = await execute_tool(func_name, func_args)
messages.append({ messages.append({
"role": "tool", "role": "tool",
"tool_call_id": tool_call["id"], "tool_call_id": tool_call["id"],
"content": result "content": result
}) })
# 核心工具齐了就立即进分段写作(再问一轮只会空转120s);
# 不齐则最多再补一轮,否则对应章节会无数据可写
if all(t in tools_used for t in REQUIRED_TOOLS):
break
if tool_rounds >= 2:
print(f"[ai-service] 核心工具仍不齐(已取:{sorted(set(tools_used))}),直接进入写作")
break
print(f"[ai-service] 核心工具不全,补一轮取数({tool_rounds}/2)")
missing = [t for t in REQUIRED_TOOLS if t not in tools_used]
messages.append({
"role": "user",
"content": f"还缺以下数据,请立即调用后停止取数:{', '.join(missing)}"
f"(get_theme_history 的 date 参数为 {trade_date})"
})
continue
summary = final_content[:200].replace("\n", " ") if final_content else "" # 非工具轮(模型直接开写/空返回):只要有工具结果就直接进入分段写作;
report_id = _save_report(trade_date, final_content, summary, tools_used, total_tokens) # 一轮工具都没拿到则重试
if tools_used:
break
print(f"[ai-service] 未获取到工具数据,重试({i + 1}/{max_rounds})")
await asyncio.sleep(5)
return {"id": report_id, "tokens_used": total_tokens, "tools_used": tools_used, "truncated": was_truncated} 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)
llm_calls += 1
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"
else:
# 回灌前文:后段看不到前段内容会导致重复铺数据、数字口径打架
messages.append({"role": "assistant", "content": part_content})
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, llm_calls)
print(f"[ai-service] 生成完成:LLM调用 {llm_calls} 次,tokens {total_tokens}")
return {"id": report_id, "tokens_used": total_tokens, "llm_calls": llm_calls, "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:
"""保存报告到数据库""" """摘要:去掉标题行与 Markdown 标记后截断 200 字(供管理列表展示)
正文首行是 `# XXXX A股收盘分析报告`,直接截断会让它占掉摘要大半。
"""
text = re.sub(r"^#.*$", "", content or "", flags=re.MULTILINE)
text = re.sub(r"[>#*`|]", "", text)
return re.sub(r"\s+", " ", text).strip()[:200]
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() conn = get_connection()
try: try:
cursor = conn.execute( conn.execute(
"""INSERT OR REPLACE INTO ai_reports """INSERT INTO daily_market_stats (trade_date, payload) VALUES (?, ?)
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used) ON CONFLICT(trade_date) DO UPDATE SET payload = excluded.payload""",
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?)""", (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:
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, llm_calls: int = 0) -> 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, llm_calls, 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,
llm_calls = excluded.llm_calls,
updated_at = excluded.updated_at,
generation_count = ai_reports.generation_count + 1""",
( (
trade_date, trade_date,
f"{trade_date} A股收盘分析", f"{trade_date} A股收盘分析",
@@ -193,10 +501,15 @@ def _save_report(trade_date: str, content: str, summary: str, tools_used: list,
json.dumps(tools_used), json.dumps(tools_used),
AI_MODEL, AI_MODEL,
tokens_used, tokens_used,
) llm_calls,
),
) )
conn.commit() 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: finally:
conn.close() conn.close()
@@ -213,7 +526,11 @@ def _get_prev_report_section(trade_date: str) -> str:
return "" return ""
prev_date = row["trade_date"] prev_date = row["trade_date"]
prev_content = row["content"] or "" prev_content = row["content"] or ""
return f"""以下是前一个交易日({prev_date})的分析报告,请参考其中的分析逻辑和关注方向,结合今日数据进行对比分析: return f"""以下是前一个交易日({prev_date})的分析报告,仅用于保持分析连续性:
- 今日数据优先:两者冲突时以今日工具数据为准,并在正文点出变化(如"昨日提示的××今日××")
- 禁止照搬昨日结论、禁止复制昨日段落
- 重点核对昨日"明日展望"与"风险提示"里提到的方向是否兑现;未兑现的要在正文中说明
{prev_content} {prev_content}
+108 -5
View File
@@ -16,7 +16,7 @@ TOOLS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "get_market_dashboard", "name": "get_market_dashboard",
"description": "获取A股市场看板数据,包含主要指数行情、涨跌统计、行业强度、概念热度、事件情报、市场温度评分", "description": "获取A股市场看板数据,包含主要指数行情、全市场涨跌统计(涨跌家数/涨停/跌停/炸板率/成交额)、市场温度评分与竞价信号、行业强度榜、概念热度、板块主力资金流、两融余额、海外主要指数与国内期货主力合约(globalMarkets字段)、完整连板梯队(limitLadder字段,含涨停原因与封单金额)、事件情报(热门股/龙虎榜/飙升/异动)",
"parameters": {"type": "object", "properties": {}, "required": []} "parameters": {"type": "object", "properties": {}, "required": []}
} }
}, },
@@ -42,6 +42,20 @@ TOOLS = [
"parameters": {"type": "object", "properties": {}, "required": []} "parameters": {"type": "object", "properties": {}, "required": []}
} }
}, },
{
"type": "function",
"function": {
"name": "get_consecutive_core_stocks",
"description": "获取最近N个交易日每天都上榜的核心股(缺一日都不行),用于识别持续活跃的热点股",
"parameters": {
"type": "object",
"properties": {
"days": {"type": "integer", "description": "交易日天数,默认3"}
},
"required": []
}
}
},
{ {
"type": "function", "type": "function",
"function": { "function": {
@@ -71,6 +85,20 @@ TOOLS = [
"required": ["code", "name"] "required": ["code", "name"]
} }
} }
},
{
"type": "function",
"function": {
"name": "get_news",
"description": "获取最近的财经快讯(新浪7x24,含宏观/行业/公司/海外动态),用于重要消息面梳理",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "获取条数,默认30,最大50"}
},
"required": []
}
}
} }
] ]
@@ -84,6 +112,8 @@ async def execute_tool(tool_name: str, arguments: dict) -> str:
return await _get_theme_history(arguments.get("date", "")) return await _get_theme_history(arguments.get("date", ""))
elif tool_name == "get_active_core_stocks": elif tool_name == "get_active_core_stocks":
return await _get_active_core_stocks() return await _get_active_core_stocks()
elif tool_name == "get_consecutive_core_stocks":
return await _get_consecutive_core_stocks(arguments.get("days", 3))
elif tool_name == "get_stock_quote": elif tool_name == "get_stock_quote":
return await _get_stock_quote(arguments.get("code", "")) return await _get_stock_quote(arguments.get("code", ""))
elif tool_name == "get_fund_flow": elif tool_name == "get_fund_flow":
@@ -92,12 +122,23 @@ async def execute_tool(tool_name: str, arguments: dict) -> str:
arguments.get("name", ""), arguments.get("name", ""),
arguments.get("days", 30) arguments.get("days", 30)
) )
elif tool_name == "get_news":
return await _get_news(arguments.get("limit", 30))
else: else:
return json.dumps({"error": f"未知工具: {tool_name}"}) return json.dumps({"error": f"未知工具: {tool_name}"})
except Exception as e: except Exception as e:
return json.dumps({"error": str(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: async def _get_market_dashboard() -> str:
"""获取市场看板数据""" """获取市场看板数据"""
from routes.market_dashboard import _build_dashboard from routes.market_dashboard import _build_dashboard
@@ -159,9 +200,9 @@ async def _get_active_core_stocks() -> str:
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]: if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
s["lastAppear"] = r["trade_date"] s["lastAppear"] = r["trade_date"]
stocks = list(stock_days.values()) # 只保留最近10日中出现次数最多的前25只(全量可达145KB,会把上下文撑爆)
stocks.sort(key=lambda x: x.get("lastAppear") or "", reverse=True) stocks = sorted(stock_days.values(), key=lambda x: -x["appearCount"])[:25]
stocks.sort(key=lambda x: -x["appearCount"]) keep_codes = {s["stockCode"] for s in stocks}
themes_rows = conn.execute( themes_rows = conn.execute(
f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes
@@ -170,10 +211,12 @@ async def _get_active_core_stocks() -> str:
).fetchall() ).fetchall()
themes_by_stock = {} themes_by_stock = {}
for t in themes_rows: for t in themes_rows:
if t["stock_code"] not in keep_codes:
continue
per = themes_by_stock.setdefault(t["stock_code"], {}) per = themes_by_stock.setdefault(t["stock_code"], {})
per.setdefault(t["theme_code"], {"theme_code": t["theme_code"], "theme_name": t["theme_name"]}) per.setdefault(t["theme_code"], {"theme_code": t["theme_code"], "theme_name": t["theme_name"]})
for s in stocks: 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 latest = dates[-1] if dates else None
for s in stocks: for s in stocks:
@@ -192,6 +235,66 @@ async def _get_active_core_stocks() -> str:
conn.close() conn.close()
async def _get_consecutive_core_stocks(days: int = 3) -> str:
"""获取最近N个交易日**每个交易日都上榜**的核心股,缺一日都不行"""
from database import get_connection
conn = get_connection()
try:
# 最近N个有数据的交易日
rows = conn.execute(
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
(days,)
).fetchall()
recent_dates = [r["trade_date"] for r in reversed(rows)]
if len(recent_dates) < days:
return json.dumps({"days": days, "stocks": []}, ensure_ascii=False)
# 查询这些日期的上榜记录
placeholders = ",".join("?" * len(recent_dates))
rows = conn.execute(
f"""SELECT trade_date, stock_code, stock_name, f3, cover_count
FROM daily_core_stocks
WHERE trade_date IN ({placeholders})""",
recent_dates,
).fetchall()
# 按股票分组,记录上榜日期
stock_dates: dict[str, dict] = {}
for r in rows:
code = r["stock_code"]
s = stock_dates.setdefault(code, {
"stockCode": code,
"stockName": r["stock_name"],
"dates": set(),
"dailyGains": {},
})
s["dates"].add(r["trade_date"])
s["dailyGains"][r["trade_date"]] = r["f3"]
# 只保留N个交易日全部上榜的股票
results = []
for code, s in stock_dates.items():
if len(s["dates"]) >= days:
results.append({
"stockCode": code,
"stockName": s["stockName"],
"consecutiveDays": len(s["dates"]),
"recentDates": sorted(s["dates"]),
"dailyGains": s["dailyGains"],
})
results.sort(key=lambda x: -x["consecutiveDays"])
return json.dumps({
"days": days,
"recentDates": recent_dates,
"stocks": results,
}, ensure_ascii=False)
finally:
conn.close()
async def _get_stock_quote(code: str) -> str: async def _get_stock_quote(code: str) -> str:
"""获取个股实时行情""" """获取个股实时行情"""
from services.tencent import fetch_quote from services.tencent import fetch_quote
+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 displayData = chartPeriod === "d" ? dailyData : minuteData;
const currentLoading = chartPeriod === "d" ? chartLoading : minuteLoading; 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 ( return (
<Card className="shadow-lg"> <Card className="shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6"> <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> </CardHeader>
<CardContent className="px-2 md:px-6 pb-2 md:pb-6"> <CardContent className="px-2 md:px-6 pb-2 md:pb-6">
{currentLoading ? ( {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="flex items-center text-muted-foreground text-sm">
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div> <div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
K线数据加载中... K线数据加载中...
@@ -221,18 +237,11 @@ export function KLineCard({ code, addedAt, ready }: Props) {
</div> </div>
) : ( ) : (
<KLineChart <KLineChart
data={displayData.map((d) => ({ data={chartData}
time: d.dateMs,
open: d.open,
close: d.close,
high: d.high,
low: d.low,
volume: d.volume,
isAddedDate: d.isAddedDate,
}))}
mode={chartMode} mode={chartMode}
hasAddedDate={ready} hasAddedDate={ready}
indicators={indicators} indicators={indicators}
defaultVisibleDays={chartPeriod === "d" ? 90 : undefined}
/> />
)} )}
</CardContent> </CardContent>
+72 -4
View File
@@ -12,6 +12,7 @@ import {
ColorType, ColorType,
CrosshairMode, CrosshairMode,
LineStyle, LineStyle,
TickMarkType,
createSeriesMarkers, createSeriesMarkers,
type IChartApi, type IChartApi,
type ISeriesApi, type ISeriesApi,
@@ -41,6 +42,8 @@ interface Props {
mode: "line" | "candle"; mode: "line" | "candle";
hasAddedDate?: boolean; hasAddedDate?: boolean;
indicators: IndicatorToggles; indicators: IndicatorToggles;
/** 默认只展示最近 N 个自然日(日线用),其余数据靠拖动查看;不传则铺满全部数据 */
defaultVisibleDays?: number;
} }
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。 // lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
@@ -61,9 +64,41 @@ const MACD_DIF = "#3b82f6";
const MACD_DEA = "#f59e0b"; const MACD_DEA = "#f59e0b";
const RSI_COLOR = "#a855f7"; 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 containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null); const chartRef = useRef<IChartApi | null>(null);
// 记录上次填充的数据集:仅数据集变化(首次加载/切换周期)时调整视口,
// 指标开关/显示方式切换时保留用户拖动缩放后的位置
const lastDataRef = useRef<KLineItem[] | null>(null);
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null); const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null); const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
// 指标 series(重建用) // 指标 series(重建用)
@@ -73,8 +108,15 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
// 容器高度随指标 pane 数量增长,避免主图被压缩 // 容器高度随指标 pane 数量增长,避免主图被压缩
const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length; const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length;
// 固定图表高度:主图+成交量 300,每个指标副图 +95 // 图表总高 = 各 pane 高度之和(主图 + 成交量 + 指标副图×N)
const chartHeight = 300 + extraPanes * 95; 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 成交量 // 创建图表(仅一次):pane0 主图 + pane1 成交量
useEffect(() => { useEffect(() => {
@@ -94,7 +136,12 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
horzLines: { color: GRID, style: LineStyle.Dashed, visible: true }, horzLines: { color: GRID, style: LineStyle.Dashed, visible: true },
}, },
rightPriceScale: { borderColor: GRID }, rightPriceScale: { borderColor: GRID },
timeScale: { borderColor: GRID, timeVisible: false }, localization: { timeFormatter: formatCrosshairTime },
timeScale: {
borderColor: GRID,
timeVisible: false,
tickMarkFormatter: formatTickMark,
},
crosshair: { mode: CrosshairMode.Normal }, crosshair: { mode: CrosshairMode.Normal },
}); });
chartRef.current = chart; chartRef.current = chart;
@@ -109,6 +156,7 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
1, 1,
); );
volSeriesRef.current = vol; volSeriesRef.current = vol;
applyPaneHeights(chart);
return () => { return () => {
chart.remove(); chart.remove();
@@ -118,6 +166,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
maSeriesRef.current = []; maSeriesRef.current = [];
macdSeriesRef.current = []; macdSeriesRef.current = [];
rsiSeriesRef.current = []; rsiSeriesRef.current = [];
// 图表实例销毁后重置,重建时重新应用默认视口(StrictMode 重挂载同样生效)
lastDataRef.current = null;
}; };
}, []); }, []);
@@ -223,6 +273,8 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
); );
rsiSeriesRef.current.push(s); rsiSeriesRef.current.push(s);
} }
applyPaneHeights(chart);
} }
// 模式切换:重建价格 series + 指标 // 模式切换:重建价格 series + 指标
@@ -310,8 +362,24 @@ export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
createSeriesMarkers(ps, []); createSeriesMarkers(ps, []);
} }
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(); chart.timeScale().fitContent();
} }
}
// 颜色图例:根据启用的指标生成 // 颜色图例:根据启用的指标生成
const legend: { color: string; label: string }[] = []; const legend: { color: string; label: string }[] = [];
+11
View File
@@ -14,7 +14,11 @@ export interface AiReport {
toolsUsed: string[]; toolsUsed: string[];
model: string; model: string;
tokens_used: number; tokens_used: number;
generation_count?: number;
llm_calls?: number | null;
issue_number?: number;
created_at: string; created_at: string;
updated_at?: string | null;
} }
export async function fetchAiLatestReport(): Promise<AiReport> { export async function fetchAiLatestReport(): Promise<AiReport> {
@@ -31,6 +35,13 @@ export async function fetchAiReports(): Promise<AiReport[]> {
return result.data || []; return result.data || [];
} }
export async function fetchAiReportByDate(tradeDate: string): Promise<AiReport> {
const resp = await fetch(`${API_BASE}/api/ai-analysis/by-date/${tradeDate}`);
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
const result = await resp.json();
return result.data;
}
export async function fetchAiReport(id: number): Promise<AiReport> { export async function fetchAiReport(id: number): Promise<AiReport> {
const resp = await fetch(`${API_BASE}/api/ai-analysis/${id}`); const resp = await fetch(`${API_BASE}/api/ai-analysis/${id}`);
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`); if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
+3 -3
View File
@@ -33,11 +33,11 @@ export interface CoreStockHistoryResponse {
items: CoreStockHistoryItem[]; items: CoreStockHistoryItem[];
} }
/** 获取活跃核心股 + 最近10日涨幅矩阵 */ /** 获取活跃核心股 + 最近N日涨幅矩阵 */
export async function fetchActiveCoreStocks(): Promise<ActiveCoreStocksResponse> { export async function fetchActiveCoreStocks(days: number = 10): Promise<ActiveCoreStocksResponse> {
const baseUrl = getApiBaseUrl(); const baseUrl = getApiBaseUrl();
try { try {
const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" }); const resp = await fetch(`${baseUrl}/api/core-stocks/active?days=${days}`, { method: "GET", cache: "no-store" });
if (!resp.ok) return { dates: [], stocks: [] }; if (!resp.ok) return { dates: [], stocks: [] };
return resp.json(); return resp.json();
} catch (err) { } catch (err) {
+193 -59
View File
@@ -1,77 +1,98 @@
import * as React from "react"; import * as React from "react";
import { Link, createFileRoute } from "@tanstack/react-router"; import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Clock, Loader2, AlertCircle, Wrench, Calendar } from "lucide-react"; import { ArrowLeft, Clock, Loader2, AlertCircle, Wrench, Calendar } from "lucide-react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { Mermaid } from "../components/Mermaid"; import { Mermaid } from "../components/Mermaid";
import { fetchAiLatestReport } from "../lib/ai-analysis-api"; import { fetchAiLatestReport, fetchAiReportByDate, fetchAiReports } from "../lib/ai-analysis-api";
import { Card, CardContent } from "../components/ui/card"; import { Card, CardContent } from "../components/ui/card";
export const Route = createFileRoute("/ai-analysis")({ export const Route = createFileRoute("/ai-analysis")({
validateSearch: (search: Record<string, unknown>) => {
return {
date: typeof search.date === "string" ? search.date : undefined,
// 兼容 ?data=YYYY-MM-DD 写法
data: typeof search.data === "string" ? search.data : undefined,
};
},
component: AiAnalysisPage, 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 };
});
const intro = parts[0].trim();
// intro 只剩标题(无其他内容)时置空,避免渲染一张只有大标题的空卡片
const introBody = intro.replace(/^#[^\n]*$/m, "").trim();
return { intro: introBody ? intro : "", sections };
}
function AiAnalysisPage() { function AiAnalysisPage() {
const navigate = useNavigate();
const { date, data } = Route.useSearch();
const tradeDate = date ?? data ?? undefined;
const isHistorical = !!tradeDate;
const { data: report, isLoading, isError } = useQuery({ const { data: report, isLoading, isError } = useQuery({
queryKey: ["ai-report-latest"], queryKey: isHistorical ? ["ai-report", tradeDate] : ["ai-report-latest"],
queryFn: fetchAiLatestReport, queryFn: () => (isHistorical ? fetchAiReportByDate(tradeDate!) : fetchAiLatestReport()),
retry: false, retry: false,
}); });
return ( const { data: history = [] } = useQuery({
<div className="min-h-screen bg-background text-foreground"> queryKey: ["ai-reports"],
<div className="max-w-4xl mx-auto px-3 sm:px-4 py-4 sm:py-6"> queryFn: fetchAiReports,
{/* Header */} retry: false,
<div className="flex items-center gap-2 sm:gap-3 mb-4 sm:mb-6"> });
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<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 收盘分析
</h1>
</div>
{/* Content */} const goDate = (d?: string) => {
{isLoading ? ( navigate({ to: "/ai-analysis", search: d ? { date: d } : {} });
<div className="flex flex-col items-center gap-3 py-20"> };
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">加载中...</p>
</div>
) : isError || !report ? (
<div className="flex flex-col items-center gap-3 py-20">
<AlertCircle className="h-6 w-6 text-destructive" />
<p className="text-sm text-muted-foreground">暂无分析报告</p>
<p className="text-xs text-muted-foreground/60">请通过管理面板触发 AI 分析</p>
</div>
) : (
<>
{/* Meta Info */}
<Card className="bg-card border-border mb-3 sm:mb-4">
<CardContent className="p-3 sm:p-4">
<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>
<span>{report.tokens_used?.toLocaleString()} tokens</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" />
{report.toolsUsed.length} 个工具
</span>
)}
</div>
</CardContent>
</Card>
{/* Report Content */} const selectedDate = tradeDate ?? report?.trade_date ?? "";
<Card className="bg-card border-border">
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content"> const mdComponents: Components = {
<Markdown
remarkPlugins={[remarkGfm]}
components={{
table({ children, ...props }) { table({ children, ...props }) {
return ( return (
<div className="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0"> <div className="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0">
@@ -79,6 +100,9 @@ function AiAnalysisPage() {
</div> </div>
); );
}, },
td({ children, ...props }) {
return <td {...props}>{colorizeChildren(children)}</td>;
},
code({ className, children, ...props }) { code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || ""); const match = /language-(\w+)/.exec(className || "");
if (match && match[1] === "mermaid") { if (match && match[1] === "mermaid") {
@@ -90,20 +114,130 @@ function AiAnalysisPage() {
</code> </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">
{/* Header */}
<div className="flex items-center justify-between gap-2 sm:gap-3 mb-4 sm:mb-6">
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-lg sm:text-xl md:text-2xl font-bold flex items-center gap-2 truncate">
<Calendar className="h-5 w-5 text-primary shrink-0" />
AI 收盘分析
{report?.issue_number ? (
<span className="text-xs font-normal text-muted-foreground whitespace-nowrap">总第 {report.issue_number} 期</span>
) : null}
</h1>
</div>
{history.length > 0 && (
<select
value={history.some((h) => h.trade_date === selectedDate) ? selectedDate : ""}
onChange={(e) => goDate(e.target.value || undefined)}
className="text-xs sm:text-sm border border-border rounded-md bg-background px-2 py-1.5 text-foreground shrink-0"
aria-label="选择历史报告日期"
> >
{report.content} <option value="">最新</option>
{history.map((h) => (
<option key={h.id} value={h.trade_date}>
{h.trade_date}
</option>
))}
</select>
)}
</div>
{/* Content */}
{isLoading ? (
<div className="flex flex-col items-center gap-3 py-20">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">加载中...</p>
</div>
) : isError || !report ? (
<div className="flex flex-col items-center gap-3 py-20">
<AlertCircle className="h-6 w-6 text-destructive" />
<p className="text-sm text-muted-foreground">
{isHistorical ? `${tradeDate} 暂无分析报告` : "暂无分析报告"}
</p>
{isHistorical ? (
<Link
to="/ai-analysis"
search={{}}
className="text-xs text-primary hover:underline"
>
查看最新报告
</Link>
) : (
<p className="text-xs text-muted-foreground/60">请通过管理面板触发 AI 分析</p>
)}
</div>
) : (
<>
{/* Meta Info */}
<Card className="bg-card border-border mb-3 sm:mb-4">
<CardContent className="p-3 sm:p-4">
<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.updated_at || report.created_at}</span>
</span>
<span>{report.tokens_used?.toLocaleString()} tokens</span>
{report.llm_calls ? <span>{report.llm_calls} 次 LLM 调用</span> : null}
{(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" />
{report.toolsUsed.length} 个工具
</span>
)}
</div>
</CardContent>
</Card>
{/* 报告正文:按 ## 章节分卡片渲染 */}
{(() => {
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> </Markdown>
</CardContent> </CardContent>
</Card> </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 */} {/* Disclaimer */}
<p className="text-xs text-muted-foreground/60 text-center mt-4 sm:mt-6"> <p className="text-xs text-muted-foreground/60 text-center mt-4 sm:mt-6">
本报告由 AI 生成,仅供参考,不构成投资建议 本报告由 AI 生成,仅供参考,不构成投资建议
</p> </p>
</>
)}
</div> </div>
</div> </div>
); );
} }
export default AiAnalysisPage;
+16 -5
View File
@@ -1,8 +1,9 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Fragment } from "react"; import { Fragment, useState } from "react";
import { fetchActiveCoreStocks } from "@/lib/core-stock-api"; import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
import { ArrowLeft, RefreshCw, Flame } from "lucide-react"; import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export const Route = createFileRoute("/core-stocks")({ export const Route = createFileRoute("/core-stocks")({
component: CoreStocksPage, component: CoreStocksPage,
@@ -16,9 +17,10 @@ function formatGain(v: number | null | undefined): string {
} }
function CoreStocksPage() { function CoreStocksPage() {
const [days, setDays] = useState(10);
const { data, isLoading, isFetching, refetch } = useQuery({ const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ["core-stocks", "active"], queryKey: ["core-stocks", "active", days],
queryFn: fetchActiveCoreStocks, queryFn: () => fetchActiveCoreStocks(days),
staleTime: 60_000, staleTime: 60_000,
retry: false, retry: false,
}); });
@@ -48,9 +50,18 @@ function CoreStocksPage() {
</header> </header>
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8"> <div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
<p className="text-[10px] text-muted-foreground mb-2"> <div className="flex items-center justify-between mb-3">
活跃热点股(最近 10 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只 <p className="text-[10px] text-muted-foreground">
活跃热点股(最近 {days} 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只
</p> </p>
<Tabs value={String(days)} onValueChange={(v) => setDays(Number(v))}>
<TabsList>
<TabsTrigger value="3">近3日</TabsTrigger>
<TabsTrigger value="5">近5日</TabsTrigger>
<TabsTrigger value="10">近10日</TabsTrigger>
</TabsList>
</Tabs>
</div>
{isLoading ? ( {isLoading ? (
<div className="animate-pulse rounded-xl bg-muted h-32" /> <div className="animate-pulse rounded-xl bg-muted h-32" />