Compare commits

..
Author SHA1 Message Date
Sakurasan 73d259b3a1 style: 主题切换改为单图标点击展开 2026-09-01 19:28:08 +08:00
Sakurasan bdeda21386 style: 管理面板主题切换与主站一致(SVG图标+紧凑布局) 2026-09-01 19:18:27 +08:00
Sakurasan 317ea483d0 feat: 移除公开触发AI分析接口,仅管理面板可用 2026-09-01 19:16:09 +08:00
Sakurasan 45fe244330 feat: 移除AI分析页面触发按钮,仅保留管理面板触发 2026-09-01 19:09:16 +08:00
Sakurasan 21afb863b3 feat: 管理面板报告列表增加跳转链接 2026-09-01 18:51:24 +08:00
Sakurasan 08fa30cb78 style: 管理面板重构,与主站风格统一(oklch色彩+dark模式+响应式) 2026-09-01 18:50:12 +08:00
Sakurasan 7dba0238a5 feat: 管理面板(登录认证+触发AI分析+统计+CLI改密码) 2026-09-01 18:41:32 +08:00
Sakurasan bfcc932406 feat: AI分析报告增加下个交易日建议模块 2026-09-01 18:13:29 +08:00
Sakurasan d0eafe203c fix: AI_MAX_TOKENS/AI_TEMPERATURE 未配置时使用模型默认值,避免截断分析输出 2026-09-01 17:58:27 +08:00
Sakurasan 86429f43c2 feat: 暴露fuyao SDK全部A股接口 + AI分析轮次上限提至30 2026-09-01 17:04:38 +08:00
Sakurasan 8b43b57bb2 feat: 同一交易日内可随时重新生成报告 2026-09-01 16:16:45 +08:00
Sakurasan 35ea82863f fix: AI分析使用完整前日报告,不截断 2026-09-01 16:12:58 +08:00
Sakurasan a005118aa4 feat: AI分析加入前日报告上下文 + 隐藏模型名 + Docker上海时区 2026-09-01 16:11:08 +08:00
Sakurasan 64c6ed92b0 fix: 主题切换改为点击展开收起模式 2026-09-01 12:41:47 +08:00
Sakurasan 6478e0d403 feat: AI分析单页展示 + 主题切换 + 移动端适配
- AI分析页改为直接展示最新报告,历史存数据库
- 新增浅色/深色/系统自动主题切换
- 市场看板、AI分析页适配主题变量
- 移动端表格可滑动、按钮自动换行
- Mermaid图表支持
- Markdown表格渲染支持(remark-gfm)
2026-09-01 12:33:27 +08:00
Sakurasan 912a224b6b feat: AI市场分析功能
- 后端:AI分析服务、工具调用、定时任务
- 前端:报告列表、详情页、Mermaid图表支持
- 支持OpenAI API兼容模型
- 收盘后自动分析生成报告
2026-09-01 04:21:29 +08:00
Sakurasan d7d019c2c4 feat: 市场看板 - 聚合同花顺SDK全量数据的A股实时看板
- 新增 /dashboard 页面:暗色主题,指数行情/市场温度/涨跌分布/行业强度/概念热度/事件情报
- 后端聚合接口 /api/market-dashboard,30秒缓存
- 利用SDK接口:指数行情、全市场快照、涨停/跌停/炸板池、连板天梯、热门股、飙升榜、龙虎榜、异动分析、集合竞价基准、行业/概念目录
- 市场温度评分:6因子加权(涨跌比/中位涨跌/强弱比/涨停活跃度/炸板惩罚/竞价信号)
- 首页添加市场看板导航入口
2026-08-31 10:29:37 +08:00
Sakurasan b1f216b2a4 feat: K线增加分钟/小时周期切换,拆分独立KLineCard组件
- 后端:腾讯 mkline 新增 /api/stock/history-minute(m1/m5/m15/m30/m60)
- 新组件 kline-card.tsx:自包含周期/折线蜡烛/指标开关与K线数据加载,
  切周期只重拉K线,不刷新页面其他模块
- 详情页瘦身为独立模块:K线图 / 今开最高最低昨收 / 每日行情明细互不耦合
- 时间统一按 UTC 解析传 Unix 秒,修复日线 invalid date/N/A 与分钟线时区问题
- 资金流向失败降级为非致命,不再导致整页报错
- vite 构建拆分 recharts/lightweight-charts/router 独立 chunk
2026-08-28 17:43:11 +08:00
Sakurasan c4ed3346ab feat: K线成交量独立pane、浅色调、移除KDJ 2026-08-28 15:37:17 +08:00
Sakurasan 813e5c345a feat: 默认仅显示均线MA(5/10/20),MACD默认关闭;图表增加线颜色图例
- 默认指标改为只开 MA(MACD 默认关)
- MA 周期改为 5/10/20(去掉 30)
- 图表下方新增颜色图例:按启用的指标显示对应颜色线(MA各周期/DIF/DEA/RSI/K/D/J)
2026-08-28 12:25:45 +08:00
Sakurasan bc9ce37dff chore: MACD 参数由默认(12,26,9)改为(10,20,7) 2026-08-28 12:13:24 +08:00
Sakurasan 0743014f1a feat: 详情页K线增加技术指标 MA/MACD/RSI/KDJ
- 新增 indicators.ts 指标计算库:SMA/EMA/MACD(12,26,9)/RSI(14)/KDJ(9,3,3)
- KLineChart 支持指标开关:MA(5/10/20/30) 主图叠加、MACD/RSI/KDJ 独立副图 pane
- 图表高度随指标 pane 数量动态增长(主图 300 + 每个副图 95),主图不被压缩
- 指标全部前端本地计算,无需后端
2026-08-28 12:05:37 +08:00
Sakurasan 25c179dc06 feat: 详情页K线改用 TradingView Lightweight Charts,蜡烛图原生支持
- 新增 KLineChart 组件(lightweight-charts v5):原生蜡烛图/折线切换、成交量副图(涨红跌绿)、自选日标记、双指缩放拖动
- 保留时间范围切换(3月/6月/1年,默认3月,一次性拉取365天前端切片)
- 修复 lightweight-charts 无法解析 CSS 变量/oklch 颜色的问题(改用固定 hex)
- 替换原 recharts K线(recharts Bar 自定义 shape 无法渲染蜡烛的问题彻底解决)
2026-08-28 11:15:48 +08:00
Sakurasan 24c8b31301 fix: 股票详情页K线 React 310 崩溃(useMemo置于early return前) + 时间范围切换
- 修复 React error #310:displayData 的 useMemo 原置于 if(loading) early return 之后,
  导致 loading 切换时 hook 数量变化触发崩溃。移到所有 early return 之前。
- K线一次性拉取 365 天,前端按 3月/6月/1年 切片展示,默认近3月。
2026-08-28 03:41:18 +08:00
Sakurasan d5516b72d7 feat: 接入同花顺官方SDK,新增v2数据接口,股票详情K线改用v2(前复权日K)
- vendor 同花顺官方 SDK 到 backend/sdk(含K线>10年自动切片、重试、拼音首字母检索兜底)
- 新增 /api/v2 路由:行情/估值/财务/日历/指数/K线/标的检索
- 股票详情页K线改用 v2 同花顺接口(前复权日K+总手+按昨收涨跌幅)
- 密钥仅后端持有,响应/日志无泄露
- 新增 run_local.sh 本地直接拉起(不再依赖 docker)
2026-08-28 02:09:49 +08:00
Sakurasan 82006f7cf9 feat: 每日题材采集改为涨幅/强度/热度/成交额4榜各前50合并去重
原逻辑只取题材涨幅前20入库。现改为从 4 个榜单
(sortField 1=涨幅/3=强度/4=热度排名/5=成交额) 各取前50,
按 themeCode 合并去重后写入 daily_top_themes,
覆盖更全面的当日活跃题材。
2026-08-28 00:42:09 +08:00
Sakurasan 86d0dded3a feat: 题材热点历史页展示最近10个交易日题材涨幅矩阵 2026-08-27 20:18:51 +08:00
Sakurasan 57548c791d fix: null guard toFixed in ThemeStockRow to prevent crash when API returns null 2026-08-22 00:10:48 +08:00
Sakurasan 1f9931da77 feat: 添加热点股追踪数据导出/导入脚本 2026-08-21 19:03:44 +08:00
Sakurasan bb2be17ae2 feat: 热点穿透题材数从50提升到100,支持缓存定向清理
- 热点穿透top由50提升到100(涨幅榜+热度榜各取前100合并),保证题材数≥100
- themes.py graph路由top上限le由60放宽到200
- daily_collector HOTMAP_TOP_N同步到100与页面一致
- auvops.sh新增cache-clear-graph子命令定向清理theme_graph缓存
2026-08-18 04:14:17 +08:00
SakurasanandClaude 48522cbfea chore: 每日采集时间从15:00调整到15:01
收盘后留1分钟等数据稳定,避免15:00整点采集到不完整数据。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 19:55:49 +08:00
SakurasanandClaude a6497caf73 fix: 无领涨股题材导致/themes页面崩溃(startsWith null)
东方财富题材列表存在 securityCode 为 null 的题材(如"铟"01726),
getStockBoard(null) 调用 startsWith 抛 TypeError,React 渲染崩溃,
页面被错误边界接管。修复 getStockBoard 使其对空值兜底返回主板,
同时保护全部 8 处调用方;ThemeItem.securityCode/securityName 类型
如实标注为 string|null。新增最小回归脚本复现并验证修复。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 18:49:27 +08:00
SakurasanandClaude 8cf4c40e2b feat: 热点穿透股票球最小间隔4px改为6px
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 20:12:09 +08:00
SakurasanandClaude 90569918a3 refactor: 删除板块资金流向,题材板块功能更全
- 删除前端 /sectors 页面、首页入口按钮、stock-api 板块类型与 fetchSectors
- 删除后端 /api/sectors 路由,main.py 移除注册
- eastmoney.py 移除板块数据段(_fetch_push2/_fetch_akshare/UT令牌管理),
  清理重复 import 与无用 datetime 子导入
- mootdx.py 移除板块降级方案(fetch_sector_list)
- routeTree.gen.ts 由 build 自动重新生成,移除 sectors 路由

题材热点/热点穿透/核心股已覆盖板块能力,功能更全,板块资金流向不再需要

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:50:54 +08:00
SakurasanandClaude b0dbeef3fd feat: 容器内统一运维脚本auvops.sh,recollect-daily并入
- 新增 backend/auvops.sh:容器内直接执行的统一运维工具
  (cache-clear/clean-expired/cache-count/recollect/sh/help)
- heredoc传python代码,日期等参数走环境变量防注入
- 后续运维操作收敛到该文件,加 cmd_xxx + case 注册一行即可
- 删除 recollect-daily.sh,其能力并入 auvops.sh recollect

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:57:27 +08:00
SakurasanandClaude eb66ab02d0 feat: 交易日9:31清空全部缓存,保证开盘后数据全新
- services/cache.py 新增 clear_all() 清空整个 cache 表
- daily_collector.py 新增 cache_cleanup_loop() 后台循环:精确 sleep 到最近一个
  工作日 9:31 清空缓存,跳过周末,出错1分钟重试
- main.py lifespan 启动清理任务,与采集任务一同优雅退出

与既有"非交易时段 TTL 截止到下次开盘前"的惰性过期形成双保险

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:56:56 +08:00
SakurasanandClaude 7fe8074e22 feat: 题材列表盘中120s缓存共用,题材详情新增全量新闻分页+强度热度行情
- 题材热点与热点穿透共用题材列表缓存:盘中由"不缓存实时拉取"改为120s短缓存,
  东财全量列表拉取降到每120s一次,图重建时涨幅/热度两榜直接命中缓存
- 详情页相关新闻升级为全量分页(getThemeRelatedNews):支持翻页+评论数,
  替换原 getDetail 固定3条
- 详情页统计条新增强度+热度(getSingleThemeQuote)实时指标

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:56:44 +08:00
SakurasanandClaude f98a9255a5 feat: 股票球按涨幅绝对值调整大小
半径 = 覆盖数基础 + 涨幅绝对值增量(|f3|/10*4, 封顶20),
涨得猛/跌得深球更大。同覆盖下 0%vs10% 球径差约4px。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 01:07:59 +08:00
SakurasanandClaude 776ab55fc3 feat: 股票球跌幅改为绿色空心球体
涨: 保留分级实心(覆盖数越多越红) + 红色光晕;
跌: 绿色空心球(绿描边+内部淡绿留白) + 绿色光晕。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:59:07 +08:00
SakurasanandClaude dfcce3abaf feat: 所有股票小球统一光晕强度,涨红光晕/跌绿光晕更明显
光晕透明度不再乘覆盖数 alpha(覆盖1的股票 alpha=0.45 导致光晕过淡),
改为所有股票统一 0.5 强度,高亮淡出时降为 0.06。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:50:52 +08:00
SakurasanandClaude d473bbad6b feat: 股票小球心跳动画(覆盖≥5题材) + 涨幅正红光晕/负绿光晕
- 覆盖≥5题材股票球按正弦脉动(周期900ms, 幅度22%), 越大越醒目
- 涨幅≥0红色径向渐变光晕, <0绿色光晕, 随心跳一起脉动
- 架构: 静态层不再画股票节点(只画边+题材), 股票常驻动态层重绘
  以支撑持续动画; 新增常驻rAF动画循环驱动 timeRef
- 高亮态邻居淡出时, 光晕/心跳随 alpha 同步淡出

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:44:42 +08:00
SakurasanandClaude c4a0c50ed0 feat: 题材小球正涨幅蓝实心、负涨幅蓝空心
负涨幅球改为仅描边空心(内部淡蓝留白),边框加粗;
图例同步改为空心蓝球示意。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:10:31 +08:00
SakurasanandClaude 0a32bfad78 feat: 题材小球整体放大(半径 6~30)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:53:47 +08:00
SakurasanandClaude 69535bc782 feat: 题材小球改平方根映射,拉大中小涨幅区分度
线性映射被极端涨幅拉平均,多数 0~3% 题材球挤在最小值附近。
改平方根映射:零涨幅 4px、1%≈10px、3%≈15px、极值 22px,
中等涨幅区间球径差显著放大。碰撞力半径动态跟随节点,无需额外适配。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:51:58 +08:00
SakurasanandClaude 254deb6a80 feat: 热点穿透题材节点按涨幅正蓝负绿、球大小随涨幅绝对值
- 后端题材列表下发 bf3(题材涨幅)
- 题材节点: 正涨幅蓝色/负涨幅绿色, 半径按 |bf3| 在 6~20 映射
- 题材标签文字颜色跟随正负
- 信息卡/底部浮层题材分支显示涨幅
- 图例拆分涨/跌两项并说明球大小含义

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:34:38 +08:00
SakurasanandClaude b29ca8413c feat: 核心股追踪改名热点股追踪
页面标题、页面内'活跃核心股'描述、入口链接文案同步更名;
热点穿透页内'穿透核心股'列表概念(覆盖题材数)保留原名。
底层路由/表名/代码标识符 core_stocks 不变。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:02:39 +08:00
SakurasanandClaude c610faa4ad chore: 新增服务器端清理重采脚本 recollect-daily.sh
选股逻辑变更后,服务器上当天旧数据需删除重采(幂等会跳过)。
脚本默认容器 auv、日期取服务器当天,支持显式传参;含日期格式与容器存在性校验。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 22:33:13 +08:00
SakurasanandClaude f93ffd381b feat: 核心股追踪选股并入热点穿透核心股(覆盖题材数降序前100,去重)
选股口径由仅'题材领涨股涨幅前100'改为:
- 热点穿透核心股(覆盖题材数≥2,按覆盖数降序前100)
- 合并题材领涨股涨幅前100,按股票代码去重

复用 _build_theme_graph 计算每股覆盖题材数;热点穿透构建失败
时降级为仅领涨股前100,不影响当日采集。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 22:31:38 +08:00
SakurasanandClaude 1d3ec3194c feat: 核心股追踪题材默认展开显示
移除展开/收起交互,题材标签区始终展示

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:56:18 +08:00
SakurasanandClaude b4d48180b7 feat: 核心股追踪题材标签跳题材详情、股票名跳股票详情
- 题材标签 → /theme/$code
- 股票名 → /stock/$code(阻止冒泡避免触发行展开)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:53:47 +08:00
SakurasanandClaude 6d3c470b7b feat: 核心股追踪行点击展开显示所属题材列表
- active 接口返回每只核心股 themes(窗口内按题材去重)
- 页面行点击展开题材标签,chevron 指示展开状态

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:50:16 +08:00
SakurasanandClaude 23644a9c77 feat: 题材涨幅前20存历史(原前10)
- daily_collector TOP_THEME_LIMIT 10 → 20
- 同步 routes/collector 注释与 spec/plan 文档
- 实测 2026-08-10 题材前20入库 20 条,themes/history 返回 20 条

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:27:54 +08:00
SakurasanandClaude 1c434a208a merge: 每日核心股/题材历史 + 活跃核心股滚动表格功能
将 worktree-core-stock-history 分支合并回 main:
- 每日采集核心股前100+所属题材、题材涨幅前10 存历史
- /api/core-stocks/active|history、/api/themes/history 接口
- /core-stocks 展示页(10日涨幅矩阵 + 题材数/最近上榜/上榜次数)
- 后端 asyncio 定时采集 + 前端入口链接

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:17:35 +08:00
SakurasanandClaude 036a87bac9 feat: 核心股页面补最近上榜日期列
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:13:58 +08:00
SakurasanandClaude 258fc9184d feat: 补齐 spec 遗漏 — themes/history 接口 + cover_count/coverCount/daysSinceLastAppear
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:08:39 +08:00
SakurasanandClaude ac985a2140 feat: 题材/热点穿透页加入核心股追踪入口
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:59:12 +08:00
SakurasanandClaude a7cff0930d style: 核心股页面 formatGain 符号一致 + 表格 a11y 增强
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:56:38 +08:00
SakurasanandClaude 5e7f0c4f13 feat: 活跃核心股 10 日涨幅矩阵页面
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:50:39 +08:00
Sakurasan 77f6e36c13 fix: dailyGains 类型精度 + 补 try/catch 与 theme-api 对齐 2026-08-10 20:40:33 +08:00
Sakurasan cd56f6c158 feat: 核心股历史前端 API 客户端 2026-08-10 20:30:28 +08:00
SakurasanandClaude c58bd990fd fix: lifespan 关闭时 await 取消的采集任务,避免 pending 警告
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:23:17 +08:00
SakurasanandClaude 6b12b04cab feat: 挂载核心股路由并启动每日采集任务
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:20:55 +08:00
SakurasanandClaude ffabf3d396 perf: /history 消除题材查询 N+1 并修正排序注释
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:17:39 +08:00
SakurasanandClaude f5ba61e3db feat: 核心股历史/活跃接口
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:00:29 +08:00
SakurasanandClaude 526b182051 fix: 修复 collector_loop 常量名(NameError)并填充核心股所属题材
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:49:36 +08:00
SakurasanandClaude 14c31e3f06 fix: 修正 collector_loop 常量名拼写 _COLLECT_AFTER_TIME → COLLECT_AFTER_TIME
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:38:24 +08:00
SakurasanandClaude aed3eea739 feat: 每日核心股/题材采集服务(幂等)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:30:35 +08:00
Sakurasan 5ffa5c07b2 feat: 新增每日核心股/题材历史 3 张表 2026-08-10 19:27:51 +08:00
Sakurasan 84d237de1f docs: 每日核心股/题材历史功能实现计划 2026-08-10 19:26:20 +08:00
SakurasanandClaude f354da108d docs: 每日热点核心股/题材历史记录 + 活跃核心股滚动表格设计
- 每日采集核心股前100(按涨幅)+所属题材,题材涨幅前10,存历史
- asyncio 后台定时采集,幂等去重
- 新页面:股票×最近10个A股交易日涨幅矩阵,超10日未出现踢出

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:26:20 +08:00
Sakurasan 8687bda331 docs: 每日核心股/题材历史功能实现计划 2026-08-10 19:25:31 +08:00
SakurasanandClaude 75c7ff3670 docs: 每日热点核心股/题材历史记录 + 活跃核心股滚动表格设计
- 每日采集核心股前100(按涨幅)+所属题材,题材涨幅前10,存历史
- asyncio 后台定时采集,幂等去重
- 新页面:股票×最近10个A股交易日涨幅矩阵,超10日未出现踢出

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:20:11 +08:00
SakurasanandClaude 84a01fb7b1 fix: 补齐完整浏览器请求头并改用 web client,修复生产 403 拿不到题材数据
生产服务器请求东财题材列表/相关股票返回 403(缺 sec-* 头 + iOS client
被风控识别为爬虫),导致热点穿透重建失败、数据冻结在上个交易日。

- 请求头补齐 sec-ch-ua/sec-fetch-*/priority/dnt 等完整 Chrome 头
- payload client 由 iOS 改为 web(生产实测该组合正常返回)
- content-type 补 charset=UTF-8

生产实测:完整头 + web client 正常返回数据。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 18:36:27 +08:00
SakurasanandClaude ce131b2867 fix: 热点穿透盘中缓存过期改为同步重建,避免显示上个交易日旧图
fetch_theme_graph 原用 stale-while-revalidate:缓存过期先返回旧数据、
后台异步重建。缓存里若是上个交易日数据,过期后用户一直看到旧图,
重建完成前不会更新。

- 交易时段缓存过期/无缓存 → 同步重建(加锁去重),绝不返回旧数据
- 非交易时段过期 → 保留 stale-while-revalidate(行情无实时变化,秒开无害)

实测盘中同步重建约 7s,可接受。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 13:47:21 +08:00
72 changed files with 11303 additions and 1207 deletions
+11
View File
@@ -0,0 +1,11 @@
# 同花顺金融数据 API 密钥
fuyao_apikey=sk-fuyao-xxxx
# AI 分析配置
AI_API_BASE=https://your-api-base/v1
AI_API_KEY=sk-your-api-key
AI_MODEL=deepseek-v4-flash
# 管理面板密码(默认 !auvauv,建议修改)
ADMIN_PASSWORD=!auvauv
+2
View File
@@ -30,6 +30,7 @@ Thumbs.db
# Node.js / JavaScript / TypeScript
node_modules/
dist/
!dist/admin.html
build/
.next/
.nuxt/
@@ -121,3 +122,4 @@ tmp/
/core
/.core.hmbtNy
/.core.dump
.v2-demo-backup/
+4
View File
@@ -20,6 +20,10 @@ RUN find /usr/local/lib/python3.11 -type d -name "__pycache__" -exec rm -rf {} +
FROM python:3.11-slim
WORKDIR /app
# 设置上海时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 仅复制已编译好的包,无需 gcc
COPY --from=python-deps /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""管理密码设置工具
用法:
python3 admin_cli.py # 首次设置密码
python3 admin_cli.py <新密码> # 修改密码
python3 admin_cli.py --check # 检查是否已设置密码
"""
import sys
import os
# 添加 backend 目录到 path
sys.path.insert(0, os.path.dirname(__file__))
from services.admin_auth import is_configured, set_password
def main():
if len(sys.argv) > 1 and sys.argv[1] == "--check":
if is_configured():
print("✅ 管理密码已设置")
else:
print("⚠️ 管理密码未设置,请运行: python3 admin_cli.py")
return
if len(sys.argv) > 1:
new_password = sys.argv[1]
else:
import getpass
if is_configured():
print("修改管理密码")
else:
print("设置管理密码")
new_password = getpass.getpass("请输入密码: ")
confirm = getpass.getpass("请再次输入密码: ")
if new_password != confirm:
print("❌ 两次输入不一致")
sys.exit(1)
if len(new_password) < 4:
print("❌ 密码至少4位")
sys.exit(1)
set_password(new_password)
print(f"✅ 管理密码已{'更新' if is_configured() else '设置'}")
if __name__ == "__main__":
main()
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
# auvops.sh - AUV 容器内运维工具(在容器内直接执行,非宿主机 docker exec)
#
# 拷贝进容器 /app(backend 根)后运行。后续新增运维能力都收敛到这个文件:
# 加一个 cmd_xxx 函数 + 在 main 的 case 里注册一行即可。
#
# 用法(容器内):
# ./auvops.sh cache-clear # 清空全部缓存(当日数据全新)
# ./auvops.sh cache-clear-graph # 只清空热点穿透(theme_graph)缓存
# ./auvops.sh clean-expired # 只清理已过期的缓存
# ./auvops.sh cache-count # 查看缓存条数
# ./auvops.sh recollect [DATE] # 删除并重采指定交易日(默认当天)
# ./auvops.sh sh # 进入交互式 shell
# ./auvops.sh help # 查看帮助
#
# 依赖: 容器内 python 可用(能 import services.*),工作目录自动切到脚本所在目录。
#
# 示例:
# ./auvops.sh cache-clear
# ./auvops.sh recollect 2026-08-10
# ./auvops.sh cache-count
set -euo pipefail
# 容器内 backend 根:脚本所在目录(/app)
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${ROOT}"
# 容器内用 python;本机验证时可 PY=./venv/bin/python ./auvops.sh ...
PY="${PY:-python}"
"${PY}" -c "import services" >/dev/null 2>&1 || {
echo "❌ 无法在 ${ROOT} 下 import services(确认已在容器内 /app 且 python 可用)" >&2
exit 1
}
# 在容器内跑 python 代码。日期等参数通过环境变量传,避免拼接进代码字符串。
# 用法: py_env "KEY1=val1" "KEY2=val2" <<'EOF'
# <python 代码>
# EOF
py_env() {
local env_args=()
while [[ "$#" -gt 0 ]]; do
env_args+=("${1%%=*}=${1#*=}"); shift
done
env "${env_args[@]}" "${PY}" -
}
# ---- 子命令实现 ----
# 清空全部缓存(当日数据全新)
cmd_cache_clear() {
echo "① 清空全部缓存"
"${PY}" - <<'EOF'
from services.cache import clear_all
clear_all()
print(" cache 表已清空")
EOF
echo "✅ 缓存已清空"
}
# 仅清空热点穿透(theme_graph)缓存:改写 top/排序后旧图数据过期,删掉让下次请求重建
cmd_cache_clear_graph() {
echo "① 清空热点穿透(theme_graph)缓存"
"${PY}" - <<'EOF'
from database import get_connection
conn = get_connection()
try:
cur = conn.execute("DELETE FROM cache WHERE key LIKE 'theme_graph:%'")
conn.commit()
print(f" 已删除 {cur.rowcount} 条 theme_graph 缓存")
finally:
conn.close()
EOF
echo "✅ 热点穿透缓存已清空"
}
# 只清理已过期缓存
cmd_clean_expired() {
echo "① 清理过期缓存"
"${PY}" - <<'EOF'
from services.cache import clean_expired
clean_expired()
print(" 过期缓存已清理")
EOF
echo "✅ 完成"
}
# 查看缓存条数
cmd_cache_count() {
"${PY}" - <<'EOF'
from database import get_connection
conn = get_connection()
try:
n = conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
print(f"缓存条数: {n}")
finally:
conn.close()
EOF
}
# 删除指定交易日旧数据,并用新选股逻辑重新采集入库
cmd_recollect() {
local date="${1:-$(date +%F)}"
if ! [[ "${date}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "❌ 日期格式错误:${date}(应为 YYYY-MM-DD)" >&2
exit 1
fi
echo "================================================"
echo "🚀 容器: ${HOSTNAME:-unknown} 交易日: ${date}"
echo "================================================"
echo "① 删除 ${date} 旧数据"
py_env "TARGET_DATE=${date}" <<'EOF'
import os
from database import get_connection
date = os.environ["TARGET_DATE"]
conn = get_connection()
try:
for tbl in ("daily_core_stocks", "daily_core_stock_themes", "daily_top_themes"):
cur = conn.execute("DELETE FROM " + tbl + " WHERE trade_date = ?", (date,))
print(f" {tbl}: 删除 {cur.rowcount} 行")
conn.commit()
finally:
conn.close()
EOF
echo "② 用新选股逻辑重采 ${date}"
py_env "TARGET_DATE=${date}" <<'EOF'
import asyncio
import os
from services.daily_collector import collect_daily
result = asyncio.run(collect_daily(os.environ["TARGET_DATE"]))
print(" 结果:", result)
EOF
echo "✅ 完成"
}
# 进入交互式 shell
cmd_sh() {
"${PY}" || true
}
# 帮助
usage() {
awk 'NR >= 2 && /^#/ { sub(/^# ?/, ""); print; next } NR >= 2 && !/^#/ { exit }' "${BASH_SOURCE[0]}"
}
# ---- 入口 ----
main() {
if [[ "$#" -eq 0 ]]; then
usage
exit 1
fi
local cmd="$1"; shift
case "${cmd}" in
cache-clear|cc) cmd_cache_clear "$@" ;;
cache-clear-graph) cmd_cache_clear_graph "$@" ;;
clean-expired) cmd_clean_expired "$@" ;;
cache-count) cmd_cache_count "$@" ;;
recollect) cmd_recollect "$@" ;;
sh|shell|python) cmd_sh "$@" ;;
help|-h|--help) usage ;;
*) echo "❌ 未知命令: ${cmd}(./auvops.sh help 查看用法)" >&2; exit 1 ;;
esac
}
main "$@"
+48
View File
@@ -37,6 +37,54 @@ CREATE TABLE IF NOT EXISTS cache (
value TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS daily_core_stocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
stock_name TEXT NOT NULL,
f3 REAL,
cover_count INTEGER,
rank INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code)
);
CREATE TABLE IF NOT EXISTS daily_core_stock_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code, theme_code)
);
CREATE TABLE IF NOT EXISTS daily_top_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
bf3 REAL,
hot_rank INTEGER,
rank INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, theme_code)
);
CREATE TABLE IF NOT EXISTS ai_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
report_type TEXT NOT NULL DEFAULT 'daily',
title TEXT NOT NULL,
content TEXT NOT NULL,
summary TEXT,
tools_used TEXT,
model TEXT NOT NULL,
tokens_used INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, report_type)
);
"""
+412
View File
@@ -0,0 +1,412 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AUV 管理面板</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;border-color:var(--border)}
:root{
--radius:0.625rem;
--font-sans:"PingFang SC","Microsoft YaHei","Hiragino Sans GB",sans-serif;
--background:oklch(1 0 0);
--foreground:oklch(0.129 0.042 264.695);
--card:oklch(1 0 0);
--card-foreground:oklch(0.129 0.042 264.695);
--primary:oklch(0.208 0.042 265.755);
--primary-foreground:oklch(0.984 0.003 247.858);
--secondary:oklch(0.968 0.007 247.896);
--secondary-foreground:oklch(0.208 0.042 265.755);
--muted:oklch(0.968 0.007 247.896);
--muted-foreground:oklch(0.521 0.011 257.2);
--accent:oklch(0.968 0.007 247.896);
--accent-foreground:oklch(0.208 0.042 265.755);
--destructive:oklch(0.577 0.245 27.325);
--destructive-foreground:oklch(0.984 0.003 247.858);
--border:oklch(0.929 0.013 255.508);
--input:oklch(0.929 0.013 255.508);
--ring:oklch(0.704 0.04 256.788);
}
.dark{
--background:oklch(0.205 0 0);
--foreground:oklch(0.984 0.003 247.858);
--card:oklch(0.205 0 0);
--card-foreground:oklch(0.984 0.003 247.858);
--primary:oklch(0.929 0.013 255.508);
--primary-foreground:oklch(0.208 0.042 265.755);
--secondary:oklch(0.279 0.041 260.031);
--secondary-foreground:oklch(0.984 0.003 247.858);
--muted:oklch(0.279 0.041 260.031);
--muted-foreground:oklch(0.804 0.008 256.9);
--accent:oklch(0.279 0.041 260.031);
--accent-foreground:oklch(0.984 0.003 247.858);
--destructive:oklch(0.704 0.191 22.216);
--destructive-foreground:oklch(0.984 0.003 247.858);
--border:oklch(1 0 0 / 10%);
--input:oklch(1 0 0 / 15%);
--ring:oklch(0.551 0.027 264.364);
}
body{font-family:var(--font-sans);background:var(--background);color:var(--foreground);min-height:100vh}
/* Login */
.login-wrap{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px;background:linear-gradient(135deg,var(--background),color-mix(in oklch,var(--muted) 30%,var(--background)),var(--background))}
.login-card{background:var(--card);border:1px solid var(--border);border-radius:calc(var(--radius) + 8px);padding:32px 28px;width:100%;max-width:380px;box-shadow:0 4px 24px oklch(0 0 0 / .06)}
.login-card h1{font-size:22px;font-weight:700;margin-bottom:4px;background:linear-gradient(to right,var(--primary),color-mix(in oklch,var(--primary) 70%,var(--foreground)));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.login-card p{text-align:center;color:var(--muted-foreground);font-size:13px;margin-bottom:24px}
.form-group{margin-bottom:16px}
.form-group label{display:block;font-size:13px;color:var(--muted-foreground);margin-bottom:6px;font-weight:500}
.form-group input{width:100%;padding:9px 12px;border:1px solid var(--input);border-radius:var(--radius);background:var(--background);color:var(--foreground);font-size:14px;font-family:var(--font-sans);outline:none;transition:border-color .2s}
.form-group input:focus{border-color:var(--ring);box-shadow:0 0 0 2px color-mix(in oklch,var(--ring) 20%,transparent)}
/* Buttons */
.btn{display:inline-flex;align-items:center;justify-content:center;padding:9px 16px;border:none;border-radius:var(--radius);font-size:14px;font-weight:500;font-family:var(--font-sans);cursor:pointer;transition:all .15s;gap:6px;line-height:1.4}
.btn-primary{background:var(--primary);color:var(--primary-foreground)}.btn-primary:hover{opacity:.9}
.btn-destructive{background:var(--destructive);color:var(--destructive-foreground)}.btn-destructive:hover{opacity:.9}
.btn-outline{background:transparent;color:var(--foreground);border:1px solid var(--border)}.btn-outline:hover{background:var(--accent);color:var(--accent-foreground)}
.btn-ghost{background:transparent;color:var(--muted-foreground)}.btn-ghost:hover{background:var(--accent);color:var(--accent-foreground)}
.btn-sm{padding:5px 12px;font-size:12px}
.btn-full{width:100%}
.btn:disabled{opacity:.5;cursor:not-allowed;pointer-events:none}
.error-msg{color:var(--destructive);font-size:13px;margin-top:8px;text-align:center}
/* Dashboard */
.dashboard{display:none;max-width:960px;margin:0 auto;padding:16px}
@media(min-width:768px){.dashboard{padding:24px 32px}}
.topbar{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px;margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--border)}
.topbar h1{font-size:18px;font-weight:700}
@media(min-width:768px){.topbar h1{font-size:20px}}
.topbar .actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
/* Stats grid */
.stats-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin-bottom:20px}
@media(min-width:640px){.stats-grid{grid-template-columns:repeat(3,1fr)}}
@media(min-width:768px){.stats-grid{grid-template-columns:repeat(6,1fr)}}
.stat-card{background:var(--card);border:1px solid var(--border);border-radius:calc(var(--radius) + 4px);padding:14px 16px}
.stat-card .label{font-size:12px;color:var(--muted-foreground);margin-bottom:4px;font-weight:500}
.stat-card .value{font-size:22px;font-weight:700;color:var(--foreground)}
@media(min-width:768px){.stat-card .value{font-size:20px}}
/* Section */
.section{background:var(--card);border:1px solid var(--border);border-radius:calc(var(--radius) + 4px);padding:16px;margin-bottom:16px}
@media(min-width:768px){.section{padding:20px 24px}}
.section h2{font-size:15px;font-weight:600;color:var(--foreground);margin-bottom:14px;display:flex;align-items:center;gap:8px}
/* Table */
table{width:100%;border-collapse:collapse}
th{text-align:left;font-size:12px;color:var(--muted-foreground);padding:8px 10px;border-bottom:1px solid var(--border);font-weight:500}
td{padding:8px 10px;font-size:13px;border-bottom:1px solid color-mix(in oklch,var(--border) 50%,transparent);color:var(--card-foreground)}
@media(min-width:768px){th,td{padding:10px 14px}}
tr:hover td{background:color-mix(in oklch,var(--accent) 40%,transparent)}
/* Badge */
.badge{display:inline-block;padding:2px 8px;border-radius:calc(var(--radius) - 2px);font-size:11px;font-weight:500;background:color-mix(in oklch,var(--primary) 12%,transparent);color:var(--primary)}
/* Result */
.trigger-result{margin-top:14px;padding:12px 14px;border-radius:var(--radius);font-size:13px;display:none}
.trigger-result.show{display:block}
.trigger-result.success{background:color-mix(in oklch,#22c55e 10%,transparent);border:1px solid color-mix(in oklch,#22c55e 40%,transparent);color:#15803d}
.trigger-result.error{background:color-mix(in oklch,var(--destructive) 10%,transparent);border:1px solid color-mix(in oklch,var(--destructive) 40%,transparent);color:var(--destructive)}
.dark .trigger-result.success{color:#4ade80}
.dark .trigger-result.error{color:#fca5a5}
/* Loading */
.loading{opacity:.6;pointer-events:none}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid transparent;border-top-color:currentColor;border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* Toast */
.toast{position:fixed;bottom:20px;right:20px;padding:10px 16px;border-radius:var(--radius);font-size:13px;z-index:9999;animation:fadeIn .3s;border:1px solid}
@keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
.toast-ok{background:color-mix(in oklch,#22c55e 10%,var(--card));color:#15803d;border-color:color-mix(in oklch,#22c55e 30%,var(--border))}
.toast-err{background:color-mix(in oklch,var(--destructive) 10%,var(--card));color:var(--destructive);border-color:color-mix(in oklch,var(--destructive) 30%,var(--border))}
.dark .toast-ok{color:#4ade80}
.dark .toast-err{color:#fca5a5}
/* Modal */
.modal-overlay{display:none;position:fixed;inset:0;background:oklch(0 0 0 / .5);z-index:100;align-items:center;justify-content:center;padding:16px}
.modal-overlay.open{display:flex}
.modal-box{background:var(--card);border:1px solid var(--border);border-radius:calc(var(--radius) + 8px);padding:24px;width:100%;max-width:360px;box-shadow:0 8px 32px oklch(0 0 0 / .12)}
.modal-box h3{font-size:16px;font-weight:600;margin-bottom:16px}
/* Theme toggle */
.theme-toggle{position:fixed;bottom:16px;right:16px;z-index:50}
.theme-toggle-inner{display:flex;padding:3px;background:var(--muted);border-radius:var(--radius);box-shadow:0 2px 8px oklch(0 0 0 / .08);gap:3px}
.theme-btn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;border:none;border-radius:calc(var(--radius) - 2px);cursor:pointer;background:transparent;color:var(--muted-foreground);font-size:16px;transition:all .15s;padding:0}
.theme-btn:hover{color:var(--foreground)}
.theme-btn.active{background:var(--background);color:var(--foreground);box-shadow:0 1px 3px oklch(0 0 0 / .08)}
</style>
</head>
<body>
<!-- Login -->
<div class="login-wrap" id="loginWrap">
<div class="login-card">
<h1>AUV 管理面板</h1>
<p>A股走势追踪系统</p>
<div class="form-group">
<label>管理密码</label>
<input type="password" id="pwdInput" placeholder="输入密码" autofocus>
</div>
<div id="loginError" class="error-msg" style="display:none"></div>
<button class="btn btn-primary btn-full" id="loginBtn" onclick="doLogin()">登 录</button>
</div>
</div>
<!-- Dashboard -->
<div class="dashboard" id="dashWrap">
<div class="topbar">
<h1>AUV 管理面板</h1>
<div class="actions">
<button class="btn btn-primary btn-sm" onclick="doTrigger()" id="triggerBtn">
<span id="triggerSpinner" class="spinner" style="display:none"></span>
触发 AI 分析
</button>
<button class="btn btn-outline btn-sm" onclick="showChangePwd()">改密码</button>
<button class="btn btn-ghost btn-sm" onclick="doLogout()">退出</button>
</div>
</div>
<div class="stats-grid" id="statsGrid"></div>
<div id="triggerResult" class="trigger-result"></div>
<div class="section">
<h2>📊 分析报告</h2>
<table>
<thead><tr><th>日期</th><th>标题</th><th>模型</th><th>Tokens</th><th>时间</th><th></th></tr></thead>
<tbody id="reportsBody"></tbody>
</table>
</div>
<div class="section">
<h2>📈 近期报告</h2>
<table>
<thead><tr><th>日期</th><th>标题</th><th>Tokens</th></tr></thead>
<tbody id="recentBody"></tbody>
</table>
</div>
</div>
<!-- Change Password Modal -->
<div class="modal-overlay" id="pwdModal">
<div class="modal-box">
<h3>修改密码</h3>
<div class="form-group"><label>原密码</label><input type="password" id="oldPwd"></div>
<div class="form-group"><label>新密码</label><input type="password" id="newPwd"></div>
<div id="pwdError" class="error-msg" style="display:none"></div>
<div style="display:flex;gap:8px;margin-top:16px">
<button class="btn btn-primary btn-sm" style="flex:1" onclick="doChangePwd()">确认</button>
<button class="btn btn-outline btn-sm" style="flex:1" onclick="hideChangePwd()">取消</button>
</div>
</div>
</div>
<!-- Theme Toggle -->
<div class="theme-toggle" id="themeToggle">
<button class="theme-btn" id="themeBtn" onclick="toggleThemeExpand()" title="切换主题"></button>
</div>
<script>
const API = location.origin;
let token = localStorage.getItem('admin_token');
// Theme
const themeIcons = {
light: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>',
dark: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>',
system: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>'
};
let themeExpanded = false;
function initTheme(){
const t = localStorage.getItem('theme') || 'system';
setTheme(t, false);
document.addEventListener('mousedown', e => {
if (themeExpanded && !document.getElementById('themeToggle').contains(e.target)) {
themeExpanded = false;
renderThemeBtn();
}
});
}
function setTheme(mode, save=true) {
if (save) localStorage.setItem('theme', mode);
const dark = mode === 'dark' || (mode === 'system' && matchMedia('(prefers-color-scheme:dark)').matches);
document.documentElement.classList.toggle('dark', dark);
themeExpanded = false;
renderThemeBtn();
}
function renderThemeBtn() {
const current = localStorage.getItem('theme') || 'system';
const toggle = document.getElementById('themeToggle');
if (!themeExpanded) {
toggle.innerHTML = `<button class="theme-btn" id="themeBtn" onclick="toggleThemeExpand()" title="切换主题">${themeIcons[current]}</button>`;
} else {
toggle.innerHTML = `<div class="theme-toggle-inner">
<button class="theme-btn ${current==='light'?'active':''}" onclick="setTheme('light')" title="浅色">${themeIcons.light}</button>
<button class="theme-btn ${current==='dark'?'active':''}" onclick="setTheme('dark')" title="深色">${themeIcons.dark}</button>
<button class="theme-btn ${current==='system'?'active':''}" onclick="setTheme('system')" title="自动">${themeIcons.system}</button>
</div>`;
}
}
function toggleThemeExpand() {
themeExpanded = !themeExpanded;
renderThemeBtn();
}
initTheme();
// Init
if (token) showDashboard();
else document.getElementById('pwdInput').focus();
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin() });
async function api(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json', 'X-Admin-Token': token } };
if (body) opts.body = JSON.stringify(body);
const r = await fetch(API + path, opts);
const j = await r.json();
if (!r.ok) throw new Error(j.detail || '请求失败');
return j.data;
}
function toast(msg, ok) {
const d = document.createElement('div');
d.className = 'toast ' + (ok ? 'toast-ok' : 'toast-err');
d.textContent = msg;
document.body.appendChild(d);
setTimeout(() => d.remove(), 3000);
}
async function doLogin() {
const pwd = document.getElementById('pwdInput').value;
if (!pwd) return;
const errEl = document.getElementById('loginError');
try {
const data = await api('POST', '/api/admin/login', { password: pwd });
token = data.token;
localStorage.setItem('admin_token', token);
errEl.style.display = 'none';
showDashboard();
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
}
function doLogout() {
api('POST', '/api/admin/logout').catch(() => {});
token = null;
localStorage.removeItem('admin_token');
document.getElementById('dashWrap').style.display = 'none';
document.getElementById('loginWrap').style.display = 'flex';
document.getElementById('pwdInput').value = '';
}
async function showDashboard() {
document.getElementById('loginWrap').style.display = 'none';
document.getElementById('dashWrap').style.display = 'block';
try {
const stats = await api('GET', '/api/admin/stats');
renderStats(stats);
renderReports(stats.recentReports || []);
const reports = await api('GET', '/api/admin/reports');
renderAllReports(reports);
} catch (e) {
if (e.message.includes('401') || e.message.includes('未登录')) doLogout();
}
}
function renderStats(s) {
document.getElementById('statsGrid').innerHTML = `
<div class="stat-card"><div class="label">分析报告</div><div class="value">${s.reportCount}</div></div>
<div class="stat-card"><div class="label">总 Tokens</div><div class="value">${s.totalTokens?.toLocaleString() || 0}</div></div>
<div class="stat-card"><div class="label">股票集合</div><div class="value">${s.collectionCount}</div></div>
<div class="stat-card"><div class="label">关注股票</div><div class="value">${s.stockCount}</div></div>
<div class="stat-card"><div class="label">分享链接</div><div class="value">${s.shareLinkCount}</div></div>
<div class="stat-card"><div class="label">核心股天数</div><div class="value">${s.coreStockDays}</div></div>
`;
}
function renderReports(list) {
document.getElementById('recentBody').innerHTML = list.map(r =>
`<tr>
<td>${r.trade_date}</td>
<td><a href="/ai-analysis" target="_blank" style="color:var(--primary);text-decoration:none">${r.title || '-'}</a></td>
<td>${r.tokens_used || 0}</td>
</tr>`
).join('') || '<tr><td colspan="3" style="color:var(--muted-foreground);text-align:center">暂无数据</td></tr>';
}
function renderAllReports(list) {
document.getElementById('reportsBody').innerHTML = list.map(r =>
`<tr>
<td>${r.trade_date}</td>
<td>${r.title || '-'}</td>
<td><span class="badge">${r.model || '-'}</span></td>
<td>${r.tokens_used || 0}</td>
<td>${r.created_at || '-'}</td>
<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>
<button class="btn btn-destructive btn-sm" style="padding:3px 10px;font-size:11px" onclick="deleteReport(${r.id})">删除</button>
</td>
</tr>`
).join('') || '<tr><td colspan="6" style="color:var(--muted-foreground);text-align:center">暂无报告</td></tr>';
}
async function doTrigger() {
const btn = document.getElementById('triggerBtn');
const spinner = document.getElementById('triggerSpinner');
const result = document.getElementById('triggerResult');
btn.disabled = true;
spinner.style.display = 'inline-block';
result.className = 'trigger-result';
try {
const data = await api('POST', '/api/admin/trigger-analysis');
result.className = 'trigger-result show success';
result.innerHTML = `✅ 分析完成 — Tokens: ${data.tokens_used} | 工具: ${(data.tools_used || []).length} 次调用`;
showDashboard();
} catch (e) {
result.className = 'trigger-result show error';
result.innerHTML = `❌ ${e.message}`;
} finally {
btn.disabled = false;
spinner.style.display = 'none';
}
}
async function deleteReport(id) {
if (!confirm('确定删除该报告?')) return;
try {
await api('DELETE', '/api/admin/reports/' + id);
toast('已删除', true);
showDashboard();
} catch (e) { toast(e.message, false); }
}
function showChangePwd() {
document.getElementById('pwdModal').classList.add('open');
document.getElementById('oldPwd').value = '';
document.getElementById('newPwd').value = '';
document.getElementById('pwdError').style.display = 'none';
}
function hideChangePwd() { document.getElementById('pwdModal').classList.remove('open'); }
async function doChangePwd() {
const oldp = document.getElementById('oldPwd').value;
const newp = document.getElementById('newPwd').value;
const errEl = document.getElementById('pwdError');
try {
await api('POST', '/api/admin/change-password', { old_password: oldp, new_password: newp });
hideChangePwd();
toast('密码已更新', true);
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
}
</script>
</body>
</html>
+45 -3
View File
@@ -1,3 +1,4 @@
import asyncio
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -6,15 +7,36 @@ from contextlib import asynccontextmanager
from dotenv import load_dotenv
from database import init_db
from routes import stock, collections, shares, sectors, themes
from routes import stock, collections, shares, themes, core_stocks, fuyao, market_dashboard, ai_analysis, admin
from services.daily_collector import collector_loop, cache_cleanup_loop
from services.ai_collector import ai_analysis_loop
from services.admin_auth import is_configured, set_password
load_dotenv()
DEFAULT_ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "!auvauv")
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
# 首次启动自动设置默认管理密码
if not is_configured():
set_password(DEFAULT_ADMIN_PASSWORD)
print(f"[admin] 已设置默认管理密码,请尽快修改: python3 admin_cli.py")
collector_task = asyncio.create_task(collector_loop())
cache_cleanup_task = asyncio.create_task(cache_cleanup_loop())
ai_analysis_task = asyncio.create_task(ai_analysis_loop())
try:
yield
finally:
for t in (collector_task, cache_cleanup_task, ai_analysis_task):
t.cancel()
for t in (collector_task, cache_cleanup_task, ai_analysis_task):
try:
await t
except asyncio.CancelledError:
pass
app = FastAPI(title="AUV API", version="1.0.0", lifespan=lifespan)
@@ -30,20 +52,40 @@ app.add_middleware(
app.include_router(stock.router, prefix="/api/stock")
app.include_router(collections.router, prefix="/api/collections")
app.include_router(shares.router, prefix="/api/share")
app.include_router(sectors.router, prefix="/api/sectors")
app.include_router(themes.router, prefix="/api/themes")
app.include_router(core_stocks.router, prefix="/api/core-stocks")
app.include_router(fuyao.router, prefix="/api/v2")
app.include_router(market_dashboard.router, prefix="/api")
app.include_router(ai_analysis.router, prefix="/api")
app.include_router(admin.router, prefix="/api")
# 生产模式:后端同时托管前端静态文件
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
dist_path = os.path.join(os.path.dirname(__file__), "dist")
# HTML 不缓存:保证 index.html 永远最新(引用的资源文件名带 hash,可长缓存)
_NO_CACHE_HTML = {"Cache-Control": "no-cache, no-store, must-revalidate"}
# 管理面板:/admin → admin.html(在 SPA catch-all 之前)
ADMIN_HTML = os.path.join(dist_path, "admin.html")
@app.get("/admin")
async def serve_admin():
if os.path.isfile(ADMIN_HTML):
return FileResponse(ADMIN_HTML, media_type="text/html", headers=_NO_CACHE_HTML)
return JSONResponse({"detail": "Admin panel not found"}, status_code=404)
if os.path.isdir(dist_path):
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
file_path = os.path.join(dist_path, full_path) if full_path else os.path.join(dist_path, "index.html")
if os.path.isfile(file_path):
# 静态资源(带 hash 的文件名)可缓存;HTML 不缓存
if file_path.endswith(".html"):
return FileResponse(file_path, headers=_NO_CACHE_HTML)
return FileResponse(file_path)
# SPA fallback: 非文件路径统一返回 index.html
index_path = os.path.join(dist_path, "index.html")
if os.path.isfile(index_path):
return FileResponse(index_path, media_type="text/html")
return FileResponse(index_path, media_type="text/html", headers=_NO_CACHE_HTML)
return JSONResponse({"detail": "Not Found"}, status_code=404)
+2
View File
@@ -4,3 +4,5 @@ httpx==0.27.0
python-dotenv==1.0.1
akshare==1.18.64
mootdx
requests>=2.31,<3
pypinyin
+168
View File
@@ -0,0 +1,168 @@
"""管理面板路由(/api/admin)
提供:
- 登录认证(密码 + token)
- 触发 AI 分析
- 浏览统计
- 报告管理
"""
import json
import time
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from services import admin_auth
from database import get_connection, dict_from_row
_CST = timezone(timedelta(hours=8))
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
router = APIRouter()
def _require_auth(request: Request) -> str:
"""从 header 或 cookie 提取 token 并验证"""
token = request.headers.get("X-Admin-Token", "")
if not token:
# fallback: 从 cookie 读
token = request.cookies.get("admin_token", "")
if not admin_auth.verify_token(token):
raise HTTPException(status_code=401, detail="未登录或登录已过期")
return token
class LoginRequest(BaseModel):
password: str
@router.post("/admin/login", summary="管理员登录")
async def admin_login(body: LoginRequest):
if not admin_auth.is_configured():
# 自动设置默认密码
import os
default_pw = os.getenv("ADMIN_PASSWORD", "!auvauv")
admin_auth.set_password(default_pw)
if not admin_auth.verify_password(body.password):
raise HTTPException(status_code=401, detail="密码错误")
token = admin_auth.create_token()
return JSONResponse({"data": {"token": token, "expiresIn": admin_auth.TOKEN_TTL}})
@router.post("/admin/logout", summary="管理员登出")
async def admin_logout(request: Request):
token = request.headers.get("X-Admin-Token", "") or request.cookies.get("admin_token", "")
admin_auth.revoke_token(token)
return JSONResponse({"data": "ok"})
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
@router.post("/admin/change-password", summary="修改管理密码")
async def admin_change_password(body: ChangePasswordRequest, request: Request):
_require_auth(request)
if not admin_auth.verify_password(body.old_password):
raise HTTPException(status_code=401, detail="原密码错误")
if len(body.new_password) < 4:
raise HTTPException(status_code=400, detail="密码至少4位")
admin_auth.set_password(body.new_password)
return JSONResponse({"data": "密码已更新"})
@router.get("/admin/stats", summary="管理面板统计数据")
async def admin_stats(request: Request):
_require_auth(request)
conn = get_connection()
try:
stats = {}
# 报告总数
row = conn.execute("SELECT COUNT(*) as cnt FROM ai_reports").fetchone()
stats["reportCount"] = row["cnt"]
# 总 tokens
row = conn.execute("SELECT COALESCE(SUM(tokens_used), 0) as total FROM ai_reports").fetchone()
stats["totalTokens"] = row["total"]
# 最新报告
row = conn.execute(
"SELECT trade_date, title FROM ai_reports ORDER BY trade_date DESC LIMIT 1"
).fetchone()
stats["latestReport"] = dict_from_row(row) if row else None
# 集合数
row = conn.execute("SELECT COUNT(*) as cnt FROM stock_collections").fetchone()
stats["collectionCount"] = row["cnt"]
# 关注股票数
row = conn.execute("SELECT COUNT(*) as cnt FROM collection_stocks").fetchone()
stats["stockCount"] = row["cnt"]
# 分享链接数
row = conn.execute("SELECT COUNT(*) as cnt FROM share_links").fetchone()
stats["shareLinkCount"] = row["cnt"]
# 核心股天数
row = conn.execute(
"SELECT COUNT(DISTINCT trade_date) as cnt FROM daily_core_stocks"
).fetchone()
stats["coreStockDays"] = row["cnt"]
# 近7天报告
rows = conn.execute(
"SELECT trade_date, title, tokens_used, created_at FROM ai_reports "
"ORDER BY trade_date DESC LIMIT 7"
).fetchall()
stats["recentReports"] = [dict_from_row(r) for r in rows]
return JSONResponse({"data": stats}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.post("/admin/trigger-analysis", summary="触发 AI 分析")
async def admin_trigger_analysis(request: Request):
_require_auth(request)
now = datetime.now(_CST)
trade_date = now.strftime("%Y-%m-%d")
from services.ai_service import collect_ai_analysis
result = await collect_ai_analysis(trade_date)
return JSONResponse({"data": result})
@router.get("/admin/reports", summary="报告列表(管理用)")
async def admin_list_reports(request: Request):
_require_auth(request)
conn = get_connection()
try:
rows = conn.execute(
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, created_at "
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
).fetchall()
items = []
for r in rows:
item = dict_from_row(r)
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
items.append(item)
return JSONResponse({"data": items}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.delete("/admin/reports/{report_id}", summary="删除报告")
async def admin_delete_report(report_id: int, request: Request):
_require_auth(request)
conn = get_connection()
try:
conn.execute("DELETE FROM ai_reports WHERE id = ?", (report_id,))
conn.commit()
return JSONResponse({"data": "已删除"})
finally:
conn.close()
+109
View File
@@ -0,0 +1,109 @@
"""AI 分析报告路由(/api/ai-analysis)
提供:
- 报告列表
- 报告详情
- 手动触发分析(12小时频率限制)
- 重新生成报告
- 检查某日是否有报告
"""
import json
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from database import get_connection, dict_from_row
_CST = timezone(timedelta(hours=8))
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
router = APIRouter()
def _has_ai_report(trade_date: str) -> bool:
conn = get_connection()
try:
row = conn.execute(
"SELECT 1 FROM ai_reports WHERE trade_date = ? AND report_type = 'daily' LIMIT 1",
(trade_date,)
).fetchone()
return row is not None
finally:
conn.close()
def _can_trigger(trade_date: str) -> tuple[bool, str]:
"""检查是否可以触发分析(同一交易日内可随时重新生成)"""
return True, ""
@router.get("/ai-analysis/latest", summary="最新 AI 分析报告")
async def get_latest_report():
conn = get_connection()
try:
row = conn.execute(
"SELECT * FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 1"
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="暂无分析报告")
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", summary="AI 分析报告列表")
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 "
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
).fetchall()
items = []
for r in rows:
item = dict_from_row(r)
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
items.append(item)
return JSONResponse({"data": items}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/ai-analysis/{report_id}", summary="AI 分析报告详情")
async def get_report(report_id: int):
conn = get_connection()
try:
row = conn.execute("SELECT * FROM ai_reports WHERE id = ?", (report_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="报告不存在")
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/check/{trade_date}", summary="检查某日是否有 AI 分析报告")
async def check_report(trade_date: str):
has = _has_ai_report(trade_date)
return JSONResponse({"data": {"hasReport": has, "tradeDate": trade_date}})
@router.post("/ai-analysis/{report_id}/regenerate", summary="重新生成 AI 分析报告")
async def regenerate_report(report_id: int):
conn = get_connection()
try:
row = conn.execute("SELECT trade_date FROM ai_reports WHERE id = ?", (report_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="报告不存在")
trade_date = row["trade_date"]
finally:
conn.close()
from services.ai_service import collect_ai_analysis
result = await collect_ai_analysis(trade_date)
return JSONResponse({"data": result})
+117
View File
@@ -0,0 +1,117 @@
"""核心股历史接口:活跃核心股 + 指定日核心股/题材前20"""
from datetime import date as date_cls
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from database import get_connection, dict_from_row
router = APIRouter()
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
def _recent_trade_dates(conn, n: int = 10) -> list[str]:
"""最近 n 个有数据的交易日(升序)"""
rows = conn.execute(
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
(n,),
).fetchall()
return [r["trade_date"] for r in reversed(rows)]
@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵")
async def active_core_stocks():
conn = get_connection()
try:
dates = _recent_trade_dates(conn, 10)
if not dates:
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
# 窗口内出现过且最近一次出现距今天数 <= 10 个交易日
placeholders = ",".join("?" * len(dates))
rows = conn.execute(
f"""SELECT trade_date, stock_code, stock_name, f3, cover_count FROM daily_core_stocks
WHERE trade_date IN ({placeholders})
ORDER BY trade_date DESC, rank ASC""",
dates,
).fetchall()
# 组装 per-stock:每日涨幅 + 出现次数 + 最近上榜
stock_days: dict[str, dict] = {}
for r in rows:
code = r["stock_code"]
s = stock_days.setdefault(code, {
"stockCode": code,
"stockName": r["stock_name"],
"coverCount": r["cover_count"],
"dailyGains": {},
"appearCount": 0,
"lastAppear": None,
})
s["dailyGains"][r["trade_date"]] = r["f3"]
s["appearCount"] += 1
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"])
# 一次性取窗口内全部题材关联,按 stock_code 分组(跨天题材去重)
themes_rows = conn.execute(
f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes
WHERE trade_date IN ({placeholders})""",
dates,
).fetchall()
themes_by_stock: dict[str, dict[str, dict]] = {}
for t in themes_rows:
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())
# daysSinceLastAppear:最近上榜距窗口最新交易日的自然日差(简单口径)
latest = dates[-1] if dates else None
for s in stocks:
if s.get("lastAppear") and latest:
try:
d1 = date_cls.fromisoformat(latest)
d2 = date_cls.fromisoformat(s["lastAppear"])
s["daysSinceLastAppear"] = (d1 - d2).days
except ValueError:
s["daysSinceLastAppear"] = 0
else:
s["daysSinceLastAppear"] = 0
return JSONResponse({"dates": dates, "stocks": stocks}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/history", summary="指定交易日核心股(含所属题材)")
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC", (date,)
).fetchall()
# 一次性取该日全部题材关联,按 stock_code 分组,避免逐股 N+1 查询
themes_rows = conn.execute(
"SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes WHERE trade_date = ?",
(date,),
).fetchall()
themes_by_stock: dict[str, list] = {}
for t in themes_rows:
themes_by_stock.setdefault(t["stock_code"], []).append(
{"theme_code": t["theme_code"], "theme_name": t["theme_name"]}
)
items = []
for r in rows:
d = dict_from_row(r)
d["themes"] = themes_by_stock.get(d["stock_code"], [])
items.append(d)
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
+283
View File
@@ -0,0 +1,283 @@
"""v2 数据接口路由:同花顺官方金融数据 API(/api/v2)
保留现有 /api/* 为 v1(腾讯/东财/新浪等抓取源),本模块提供独立 v2。
数据源:https://fuyao.aicubes.cn(同花顺官方),密钥仅后端持有。
接口返回统一使用现有 v1 的 data 信封风格:{"data": ..., "count": ...},
上游错误转 HTTPException,密钥永不出现在响应中。
"""
from datetime import datetime
from fastapi import APIRouter, Query, HTTPException
from fastapi.responses import JSONResponse
from services import fuyao_client
router = APIRouter()
# 上游反代/CDN 可能按 path 缓存,显式禁止缓存
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
async def _guard(awaitable):
"""调用 fuyao_client,把上游/校验错误统一转 HTTPException"""
try:
return await awaitable
except fuyao_client.FuyaoError as e:
# 上游业务错误,message 来自上游,不含密钥
raise HTTPException(status_code=502, detail=f"同花顺API: {e}")
except ValueError as e:
# SDK 参数校验错误(如 thscode 格式)
raise HTTPException(status_code=400, detail=f"参数错误: {e}")
# ---------------------------------------------------------------
# 基础数据
# ---------------------------------------------------------------
@router.get("/meta/tickers/search", summary="v2 标的检索")
async def v2_ticker_search(
q: str = Query(..., description="代码/名称/拼音关键词"),
exchange: str = Query(None, description="交易所 SH/SZ/BJ"),
asset_type: str = Query(None, description="资产类型 a-share 等"),
limit: int = Query(10, ge=1, le=50),
):
items = await _guard(fuyao_client.ticker_search(q, exchange, asset_type, limit))
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
@router.get("/meta/tickers/list", summary="v2 标的列表(分页)")
async def v2_ticker_list(
asset_type: str = Query(None, description="资产类型"),
limit: int = Query(100, ge=1, le=10000),
offset: int = Query(0, ge=0),
):
items = await _guard(fuyao_client.ticker_list(asset_type, limit, offset))
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
# ---------------------------------------------------------------
# A股行情 / 日历 / 竞价
# ---------------------------------------------------------------
@router.get("/prices/snapshot", summary="v2 行情快照(单只/多只)")
async def v2_prices_snapshot(thscodes: str = Query(..., description="逗号分隔的 thscode,如 600519.SH,000001.SZ")):
items = await _guard(fuyao_client.prices_snapshot(thscodes))
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
@router.get("/prices/historical", summary="v2 历史日K(窗口≤10年)")
async def v2_prices_historical(
thscode: str = Query(..., description="标的 thscode,单只"),
start: int = Query(..., description="起始时间,毫秒 Unix 时间戳"),
end: int = Query(..., description="结束时间,毫秒 Unix 时间戳"),
adjust: str = Query("forward", description="复权 none/forward/backward"),
):
try:
items = await fuyao_client.prices_historical(thscode, start, end, adjust)
except fuyao_client.FuyaoError as e:
raise HTTPException(status_code=502, detail=f"同花顺API: {e}")
except ValueError as e:
raise HTTPException(status_code=400, detail=f"参数错误: {e}")
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
@router.get("/corporate-actions/adjustment-factors", summary="v2 复权因子事件流")
async def v2_adjustment_factors(
thscode: str = Query(..., description="标的 thscode,单只"),
from_date: str = Query(None, description="起始日 yyyy-MM-dd"),
to_date: str = Query(None, description="截止日 yyyy-MM-dd"),
):
data = await _guard(fuyao_client.corp_actions_adjustment_factors(thscode, from_date, to_date))
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
@router.get("/calendar/trading-days", summary="v2 近一年交易日序列")
async def v2_calendar():
items = await _guard(fuyao_client.calendar_trading_days())
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
@router.get("/auction/snapshot", summary="v2 集合竞价快照")
async def v2_auction_snapshot(
thscodes: str = Query(..., description="逗号分隔 thscode"),
stage: str = Query("final", description="live 实时 / final 终态"),
):
data = await _guard(fuyao_client.auction_snapshot(thscodes, stage))
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
@router.get("/auction/short-term-benchmark", summary="v2 短线风向标竞价基准")
async def v2_auction_benchmark(date: str = Query(None, description="日期 yyyy-MM-dd")):
data = await _guard(fuyao_client.auction_short_term_benchmark(date))
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
# ---------------------------------------------------------------
# A股财务 / 估值
# ---------------------------------------------------------------
@router.get("/financials/{statement}", summary="v2 三大财务报表")
async def v2_financials(
statement: str,
thscode: str = Query(..., description="标的 thscode"),
period: str = Query("annual", description="annual 年报 / quarterly 季报"),
limit: int = Query(6, ge=1, le=20),
):
if statement not in ("income-statements", "balance-sheets", "cash-flow-statements"):
raise HTTPException(status_code=400, detail="statement 仅支持 income-statements/balance-sheets/cash-flow-statements")
items = await _guard(fuyao_client.financials(statement, thscode, period, limit))
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
@router.get("/financials/indicators", summary="v2 五类财务指标")
async def v2_financial_indicators(
thscode: str = Query(..., description="标的 thscode"),
report: str = Query(..., description="报告期 yyyy-1 ~ yyyy-4"),
):
items = await _guard(fuyao_client.financial_indicators(thscode, report))
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
@router.get("/valuations/snapshot", summary="v2 估值快照")
async def v2_valuations_snapshot(thscodes: str = Query(..., description="逗号分隔 thscode")):
items = await _guard(fuyao_client.valuations_snapshot(thscodes))
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
# ---------------------------------------------------------------
# 指数 / 板块
# ---------------------------------------------------------------
@router.get("/index/catalog", summary="v2 同花顺指数清单")
async def v2_index_catalog(tag: str = Query("industry", description="cn_concept/region/tszs/industry")):
items = await _guard(fuyao_client.index_catalog(tag))
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
@router.get("/index/constituents", summary="v2 指数成分股")
async def v2_index_constituents(thscode: str = Query(..., description="指数 thscode")):
items = await _guard(fuyao_client.index_constituents(thscode))
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
@router.get("/index/prices/snapshot", summary="v2 指数行情快照")
async def v2_index_prices_snapshot(thscodes: str = Query(..., description="逗号分隔指数 thscode")):
items = await _guard(fuyao_client.index_prices_snapshot(thscodes))
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
@router.get("/index/prices/historical", summary="v2 指数历史K线")
async def v2_index_prices_historical(
thscode: str = Query(..., description="指数 thscode"),
start: int = Query(..., description="起始时间,毫秒 Unix 时间戳"),
end: int = Query(..., description="结束时间,毫秒 Unix 时间戳"),
):
items = await _guard(fuyao_client.index_prices_historical(thscode, start, end))
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
# ---------------------------------------------------------------
# 特殊数据(涨停/跌停/炸板/连板/热股/龙虎/异动/飙升)
# ---------------------------------------------------------------
@router.get("/special/limit-up-pool", summary="v2 涨停池")
async def v2_limit_up_pool(
date: str = Query(None, description="日期 yyyy-MM-dd"),
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=200),
sort_field: str = Query("seal_money", description="排序字段"),
sort_dir: str = Query("desc", description="asc/desc"),
):
date_ms = int(datetime.strptime(date, "%Y-%m-%d").timestamp() * 1000) if date else None
data = await _guard(fuyao_client.limit_up_pool(date_ms, page, size, sort_field, sort_dir))
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
@router.get("/special/limit-down-pool", summary="v2 跌停池")
async def v2_limit_down_pool(
date: str = Query(None, description="日期 yyyy-MM-dd"),
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=200),
sort_field: str = Query("last_limit_time"),
sort_dir: str = Query("desc"),
):
date_ms = int(datetime.strptime(date, "%Y-%m-%d").timestamp() * 1000) if date else None
data = await _guard(fuyao_client.limit_down_pool(date_ms, page, size, sort_field, sort_dir))
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
@router.get("/special/limit-break-pool", summary="v2 炸板池")
async def v2_limit_break_pool(
date: str = Query(None, description="日期 yyyy-MM-dd"),
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=200),
sort_field: str = Query("price_change_ratio_pct"),
sort_dir: str = Query("desc"),
):
date_ms = int(datetime.strptime(date, "%Y-%m-%d").timestamp() * 1000) if date else None
data = await _guard(fuyao_client.limit_break_pool(date_ms, page, size, sort_field, sort_dir))
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
@router.get("/special/limit-up-ladder", summary="v2 连板梯队")
async def v2_limit_up_ladder():
data = await _guard(fuyao_client.limit_up_ladder())
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
@router.get("/special/hot-stock-list", summary="v2 热股榜")
async def v2_hot_stock_list(
period: str = Query("day", description="day/week/month"),
):
data = await _guard(fuyao_client.hot_stock_list(period))
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
@router.get("/special/hot-stock-history", summary="v2 热股榜历史")
async def v2_hot_stock_history(date: str = Query(..., description="日期 yyyy-MM-dd")):
data = await _guard(fuyao_client.hot_stock_list_history(date))
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
@router.get("/special/hot-stock-rank-trend", summary="v2 热股排名趋势")
async def v2_hot_stock_rank_trend(
thscode: str = Query(..., description="标的 thscode"),
start_date: str = Query(..., description="起始日期 yyyy-MM-dd"),
end_date: str = Query(..., description="结束日期 yyyy-MM-dd"),
):
data = await _guard(fuyao_client.hot_stock_rank_trend(thscode, start_date, end_date))
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
@router.get("/special/dragon-tiger-list", summary="v2 龙虎榜")
async def v2_dragon_tiger_list(
board_type: str = Query("all", description="all/sh/sz"),
date: str = Query(None, description="日期 yyyy-MM-dd"),
):
data = await _guard(fuyao_client.dragon_tiger_list(board_type, date))
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
@router.get("/special/anomaly-list", summary="v2 异动分析列表")
async def v2_anomaly_list(
tag_codes: str = Query(None, description="逗号分隔异动类型编码"),
):
codes = [c.strip() for c in tag_codes.split(",") if c.strip()] if tag_codes else None
data = await _guard(fuyao_client.anomaly_analysis_list(codes))
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
@router.get("/special/anomaly-stock", summary="v2 个股异动明细")
async def v2_anomaly_stock(
thscodes: str = Query(..., description="逗号分隔 thscode,最多50只"),
):
data = await _guard(fuyao_client.anomaly_analysis_stock(thscodes))
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
@router.get("/special/skyrocket-list", summary="v2 飙升榜")
async def v2_skyrocket_list(
period: str = Query("day", description="day/week/month"),
):
data = await _guard(fuyao_client.skyrocket_list(period))
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
+511
View File
@@ -0,0 +1,511 @@
"""市场看板数据聚合路由(/api/market-dashboard)
聚合同花顺 SDK 多个接口,一次性返回前端看板所需的全部数据:
- 主要指数行情 + 估值分位(PE/PB)
- 集合竞价信号(竞价基准线 + 情绪判断)
- 全市场涨跌统计(上涨/下跌/平盘/涨停/跌停/炸板/成交额)
- 市场温度评分(结合竞价基准校准)
- 涨跌家数分布
- 行业强度榜(同花顺行业指数 + 概念板块热度)
- 事件情报(连板天梯/热门股/涨停封单/炸板/飙升/龙虎榜)
"""
import asyncio
import math
import time
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from services import fuyao_client
router = APIRouter()
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
_cache: dict = {"data": None, "at": 0.0}
_CACHE_TTL = 30
# 主要指数 thscode 列表
_MAIN_INDEX_THSCODES = [
"000001.SH", # 上证指数
"399001.SZ", # 深证成指
"399006.SZ", # 创业板指
"000688.SH", # 科创50
"000300.SH", # 沪深300
]
_INDEX_NAMES = {
"000001.SH": "上证指数",
"399001.SZ": "深证成指",
"399006.SZ": "创业板指",
"000688.SH": "科创50",
"000300.SH": "沪深300",
}
BJT = timezone(timedelta(hours=8))
def _safe_float(val, default=0.0):
try:
v = float(val)
return v if not math.isnan(v) and not math.isinf(v) else default
except (TypeError, ValueError):
return default
def _calc_temperature(up: int, down: int, flat: int, median_pct: float,
strong: int, weak: int, limit_up: int, limit_down: int,
break_count: int = 0, auction_signal: str = "") -> dict:
"""市场温度评分(0-100):综合涨跌比、中位数涨跌、强弱比、涨停活跃度、炸板率、竞价信号"""
total = up + down + flat
if total == 0:
return {"score": 50, "label": "中性", "factors": {}}
# 涨跌比得分(0-25分)
advance_ratio = up / total
advance_score = min(advance_ratio * 50, 25)
# 中位数涨跌得分(0-20分):-3%~+3% 映射到 0~20
median_score = max(0, min(20, (median_pct + 3) / 6 * 20))
# 强弱比得分(0-20分)
sw_total = strong + weak
if sw_total > 0:
strong_ratio = strong / sw_total
strong_score = min(strong_ratio * 40, 20)
else:
strong_score = 10
# 涨停活跃度得分(0-15分)
limit_score = min(limit_up / 80 * 15, 15)
# 炸板惩罚(0-10分):炸板率越高越扣分
total_attempted = limit_up + break_count
break_penalty = 0
if total_attempted > 0:
break_rate = break_count / total_attempted
break_penalty = break_rate * 10 # 炸板率 50% → 扣 5 分
# 竞价信号加成(±5分)
auction_bonus = 0
if auction_signal == "强势高开":
auction_bonus = 5
elif auction_signal == "偏强":
auction_bonus = 2
elif auction_signal == "弱势低开":
auction_bonus = -5
elif auction_signal == "偏弱":
auction_bonus = -2
raw = advance_score + median_score + strong_score + limit_score - break_penalty + auction_bonus
score = round(max(0, min(100, raw)), 1)
if score >= 80:
label = "强势"
elif score >= 60:
label = "偏强"
elif score >= 40:
label = "中性"
elif score >= 20:
label = "偏弱"
else:
label = "弱势"
factors = {
"advanceScore": round(advance_score, 1),
"medianScore": round(median_score, 1),
"strongScore": round(strong_score, 1),
"limitScore": round(limit_score, 1),
"breakPenalty": round(-break_penalty, 1),
"auctionBonus": auction_bonus,
}
return {"score": score, "label": label, "factors": factors}
async def _build_dashboard() -> dict:
now = time.time()
if _cache["data"] is not None and now - _cache["at"] < _CACHE_TTL:
return _cache["data"]
# ── 第一批并行拉取(核心数据) ──
try:
(
index_data,
all_stocks,
limit_up_data,
limit_down_data,
limit_break_data,
industry_catalog,
concept_catalog,
auction_benchmark,
) = await asyncio.gather(
fuyao_client.index_prices_snapshot(",".join(_MAIN_INDEX_THSCODES)),
fuyao_client.prices_snapshot_all(limit=5000),
fuyao_client.limit_up_pool(page=1, size=200),
fuyao_client.limit_down_pool(page=1, size=1),
fuyao_client.limit_break_pool(page=1, size=1),
fuyao_client.index_catalog("industry"),
fuyao_client.index_catalog("cn_concept"),
fuyao_client.auction_short_term_benchmark(),
return_exceptions=True,
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"市场数据拉取失败: {e}")
# ── 第二批并行拉取(事件数据,依赖第一批结果较轻) ──
try:
(
hot_stock_data,
dragon_tiger_data,
anomaly_data,
limit_ladder_data,
skyrocket_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"),
return_exceptions=True,
)
except Exception:
hot_stock_data = {}
dragon_tiger_data = {}
anomaly_data = {}
limit_ladder_data = {}
skyrocket_data = {}
# ── 解析指数 ──
indices = []
if isinstance(index_data, list):
for item in index_data:
code = item.get("thscode", "")
indices.append({
"code": code,
"name": _INDEX_NAMES.get(code, item.get("name", code)),
"price": _safe_float(item.get("last_price")),
"change": _safe_float(item.get("price_change")),
"changePct": _safe_float(item.get("price_change_ratio_pct")),
"prevClose": _safe_float(item.get("prev_price")),
"turnover": _safe_float(item.get("turnover")),
})
# ── 指数估值(PE/PB) — 同花顺估值API仅支持个股,指数暂不支持 ──
# ── 解析全市场涨跌统计 ──
up_count = 0
down_count = 0
flat_count = 0
total_turnover = 0.0
all_changes = []
stocks_list = all_stocks if isinstance(all_stocks, list) else []
for s in stocks_list:
chg = _safe_float(s.get("price_change_ratio_pct"))
turnover = _safe_float(s.get("turnover"))
total_turnover += turnover
if chg > 0:
up_count += 1
elif chg < 0:
down_count += 1
else:
flat_count += 1
all_changes.append(chg)
total_stocks = up_count + down_count + flat_count
median_change = sorted(all_changes)[len(all_changes) // 2] if all_changes else 0
# 涨停/跌停/炸板 精确统计
limit_up_count = 0
limit_down_count = 0
break_count = 0
if isinstance(limit_up_data, dict):
pagination = limit_up_data.get("pagination", {})
limit_up_count = pagination.get("total", 0) or len(limit_up_data.get("item", []))
if isinstance(limit_down_data, dict):
pagination = limit_down_data.get("pagination", {})
limit_down_count = pagination.get("total", 0) or len(limit_down_data.get("item", []))
if isinstance(limit_break_data, dict):
pagination = limit_break_data.get("pagination", {})
break_count = pagination.get("total", 0) or len(limit_break_data.get("item", []))
# 强势/弱势(涨幅 > 2% 为强,< -2% 为弱)
strong_count = sum(1 for c in all_changes if c > 2)
weak_count = sum(1 for c in all_changes if c < -2)
# 市场宽度
market_breadth = round(up_count / total_stocks * 100, 1) if total_stocks > 0 else 50
# ── 集合竞价信号 ──
auction_signal = ""
auction_detail = {}
if isinstance(auction_benchmark, dict) and not isinstance(auction_benchmark, Exception):
# 短线风向标:根据竞价基准判断多空
benchmark_score = _safe_float(auction_benchmark.get("score", 0))
benchmark_label = auction_benchmark.get("label", "")
auction_detail = {
"score": benchmark_score,
"label": benchmark_label,
"date": auction_benchmark.get("date", ""),
"benchmark": auction_benchmark.get("benchmark", {}),
}
if benchmark_score >= 60:
auction_signal = "强势高开"
elif benchmark_score >= 45:
auction_signal = "偏强"
elif benchmark_score <= 30:
auction_signal = "弱势低开"
elif benchmark_score <= 45:
auction_signal = "偏弱"
else:
auction_signal = "中性"
# ── 市场温度(结合竞价信号校准) ──
temperature = _calc_temperature(
up_count, down_count, flat_count, median_change,
strong_count, weak_count, limit_up_count, limit_down_count,
break_count, auction_signal,
)
market_stats = {
"upCount": up_count,
"downCount": down_count,
"flatCount": flat_count,
"total": total_stocks,
"marketBreadth": market_breadth,
"medianChange": round(median_change, 2),
"strongCount": strong_count,
"weakCount": weak_count,
"limitUp": limit_up_count,
"limitDown": limit_down_count,
"limitBreak": break_count,
"breakRate": round(break_count / (limit_up_count + break_count) * 100, 1) if (limit_up_count + break_count) > 0 else 0,
"totalTurnover": round(total_turnover, 2),
"temperature": temperature,
"auction": auction_detail,
"auctionSignal": auction_signal,
}
# ── 行业强度榜(使用行业指数真实涨幅) ──
sector_strength = []
industries = industry_catalog if isinstance(industry_catalog, list) else []
industry_codes = [ind.get("thscode", "") for ind in industries[:31] if ind.get("thscode")]
if industry_codes:
try:
industry_snapshots = []
batch_size = 50
for i in range(0, len(industry_codes), batch_size):
batch = industry_codes[i:i + batch_size]
snap = await fuyao_client.index_prices_snapshot(",".join(batch))
if isinstance(snap, list):
industry_snapshots.extend(snap)
await asyncio.sleep(0.05)
for snap_item in industry_snapshots:
code = snap_item.get("thscode", "")
name = ""
for ind in industries:
if ind.get("thscode") == code:
name = ind.get("name", code)
break
gain_pct = _safe_float(snap_item.get("price_change_ratio_pct"))
last = _safe_float(snap_item.get("last_price"))
sector_strength.append({
"code": code,
"name": name or code,
"price": last,
"change": _safe_float(snap_item.get("price_change")),
"changePct": gain_pct,
})
sector_strength.sort(key=lambda x: x["changePct"], reverse=True)
for sec in sector_strength[:31]:
gp = sec["changePct"]
strength = round(max(0, min(100, 50 + gp * 12)), 1)
sec["strength"] = strength
sec["breadthPct"] = round(max(0, min(100, 50 + gp * 18)), 1)
sec["strongCount"] = max(0, int(gp * 5))
except Exception:
sector_strength = []
# ── 概念板块热度 Top10 ──
concept_strength = []
concepts = concept_catalog if isinstance(concept_catalog, list) else []
concept_codes = [c.get("thscode", "") for c in concepts[:20] if c.get("thscode")]
if concept_codes:
try:
concept_snapshots = []
for i in range(0, len(concept_codes), 50):
batch = concept_codes[i:i + 50]
snap = await fuyao_client.index_prices_snapshot(",".join(batch))
if isinstance(snap, list):
concept_snapshots.extend(snap)
await asyncio.sleep(0.05)
for snap_item in concept_snapshots:
code = snap_item.get("thscode", "")
name = ""
for c in concepts:
if c.get("thscode") == code:
name = c.get("name", code)
break
concept_strength.append({
"code": code,
"name": name or code,
"changePct": _safe_float(snap_item.get("price_change_ratio_pct")),
})
concept_strength.sort(key=lambda x: x["changePct"], reverse=True)
except Exception:
concept_strength = []
# ── 事件情报 ──
events = []
# 1) 连板天梯
if isinstance(limit_ladder_data, dict):
ladder_items = limit_ladder_data.get("item", [])
if ladder_items:
today_boards = ladder_items[0].get("boards", {})
for board_key in ("seven_over", "six_board", "five_board", "four_board", "three_board", "two_board"):
board_list = today_boards.get(board_key, [])
for item in board_list[:2]:
board_num = item.get("board_num", 0)
events.append({
"type": "ladder",
"label": f"{board_num}连板" if board_num > 1 else "首板",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": "",
})
# 2) 热门股 Top5
if isinstance(hot_stock_data, dict):
hot_items = hot_stock_data.get("item", [])
for item in hot_items[:5]:
rank = item.get("rank", "")
heat = item.get("heat", "")
trend = item.get("rank_trend", "")
trend_icon = "↑" if trend == "up" else ("↓" if trend == "down" else "→")
events.append({
"type": "hot",
"label": f"热门 #{rank}" if rank else "热门",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": f"热度 {heat} {trend_icon}",
})
# 3) 飙升榜 Top3
if isinstance(skyrocket_data, dict):
sky_items = skyrocket_data.get("item", [])
for item in sky_items[:3]:
heat = item.get("heat", "")
trend = item.get("rank_trend", "")
trend_icon = "↑" if trend == "up" else ("↓" if trend == "down" else "→")
events.append({
"type": "skyrocket",
"label": "飙升",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": f"飙升指数 {heat} {trend_icon}",
})
# 4) 涨停池封单 Top3
if isinstance(limit_up_data, dict):
lu_items = limit_up_data.get("item", [])
for item in lu_items[:3]:
seal = _safe_float(item.get("seal_money", 0))
reason = item.get("limit_up_reason", "")
continue_text = item.get("continue_day_text", "")
events.append({
"type": "limit_up",
"label": continue_text or "涨停",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": f"封单 {seal / 10000:.0f}万 {reason}" if reason else f"封单 {seal / 10000:.0f}万",
})
# 5) 炸板池 Top3
if isinstance(limit_break_data, dict):
break_items = limit_break_data.get("item", [])
for item in break_items[:3]:
events.append({
"type": "limit_break",
"label": "炸板",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": f"开板 {_safe_float(item.get('open_times', 0))}次",
})
# 6) 龙虎榜
if isinstance(dragon_tiger_data, dict):
dt_items = dragon_tiger_data.get("item", [])
for item in dt_items[:3]:
reason = item.get("reason", item.get("上榜原因", ""))
events.append({
"type": "dragon_tiger",
"label": "龙虎榜",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": reason[:40] if reason else "",
})
# 7) 异动分析
if isinstance(anomaly_data, dict):
anomaly_items = anomaly_data.get("item", [])
for item in anomaly_items[:3]:
tag = item.get("tagName", item.get("tag_code", ""))
events.append({
"type": "anomaly",
"label": "异动",
"name": item.get("name", ""),
"code": item.get("thscode", ""),
"detail": tag,
})
# 8) 盘面统计摘要(固定在最后)
events.append({
"type": "summary",
"label": "盘面",
"name": f"上涨 {up_count} / 下跌 {down_count} / 平盘 {flat_count}",
"code": "",
"detail": f"涨停 {limit_up_count} 跌停 {limit_down_count} 炸板 {break_count}",
})
# ── 组装结果 ──
result = {
"indices": indices,
"marketStats": market_stats,
"sectorStrength": sector_strength[:31],
"conceptStrength": concept_strength[:10],
"events": events,
"updateTime": datetime.now(BJT).strftime("%Y-%m-%d %H:%M:%S"),
}
_cache["data"] = result
_cache["at"] = now
return result
@router.get("/market-dashboard", summary="市场看板数据聚合")
async def get_market_dashboard():
try:
data = await _build_dashboard()
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"市场看板数据获取失败: {e}")
-39
View File
@@ -1,39 +0,0 @@
"""板块数据路由:行业板块、概念板块"""
from fastapi import APIRouter, Query, HTTPException
from fastapi.responses import JSONResponse
from services import eastmoney, mootdx
router = APIRouter()
# 行业/概念通过 query 参数区分,但上游反代/CDN 可能按 path 缓存而忽略 query,
# 导致两个 tab 返回相同数据。显式禁止缓存,保证按 query 区分。
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
@router.get("", summary="板块列表")
async def sector_list(
type: str = Query("industry", description="板块类型:industry=行业板块, concept=概念板块"),
):
if type not in ("industry", "concept"):
raise HTTPException(status_code=400, detail="板块类型错误,仅支持 industry/concept")
data = await eastmoney.fetch_sector_list(type)
if data:
return JSONResponse(
{"data": data, "count": len(data), "type": type},
headers=_NO_CACHE_HEADERS,
)
# 降级:通达信 mootdx(不含实时资金流数据)
md_data = await mootdx.fetch_sector_list(type)
if md_data:
return JSONResponse(
{"data": md_data, "count": len(md_data), "type": type, "source": "mootdx"},
headers=_NO_CACHE_HEADERS,
)
return JSONResponse(
{"data": [], "count": 0, "type": type},
headers=_NO_CACHE_HEADERS,
)
+17
View File
@@ -84,6 +84,23 @@ async def stock_history(
raise HTTPException(status_code=404, detail="未获取到K线数据")
@router.get("/history-minute", summary="分钟/小时级K线")
async def stock_history_minute(
code: str = Query(..., description="6位股票代码"),
period: str = Query("60", description="周期:m1/m5/m15/m30/m60(60=小时线)"),
count: int = Query(320, description="K线数量"),
):
if not re.match(r"^\d{6}$", code):
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
if period not in ("m1", "m5", "m15", "m30", "m60"):
raise HTTPException(status_code=400, detail="period 仅支持 m1/m5/m15/m30/m60")
klines = await tencent.fetch_history_minute(code, period, count)
if not klines:
raise HTTPException(status_code=404, detail="未获取到分钟K线数据")
return {"data": klines, "count": len(klines), "source": "tencent-minute"}
@router.get("/profile", response_model=dict, summary="公司概况")
async def company_profile(code: str = Query(..., description="6位股票代码")):
"""获取东方财富F10公司概况数据"""
+98 -2
View File
@@ -1,7 +1,8 @@
"""题材数据路由:题材列表、题材详情、题材相关股票"""
"""题材数据路由:题材列表、题材详情、题材相关股票、题材历史"""
from fastapi import APIRouter, Query, HTTPException
from fastapi.responses import JSONResponse
from database import get_connection, dict_from_row
from services import themes
router = APIRouter()
@@ -28,7 +29,7 @@ async def theme_list(
@router.get("/graph", summary="热点穿透:题材-股票网状关系图")
async def theme_graph(
sort_field: int = Query(1, description="题材排序:1=涨幅 4=热度"),
top: int = Query(30, ge=1, le=60, description="题材数量"),
top: int = Query(30, ge=1, le=200, description="题材数量"),
limit: int = Query(1000, ge=100, le=2000, description="下发的股票节点上限(按穿透度取前 N 只)"),
):
if sort_field not in (1, 4):
@@ -38,6 +39,101 @@ async def theme_graph(
return JSONResponse(result, headers=_NO_CACHE_HEADERS)
@router.get("/history", summary="指定交易日题材涨幅前20")
async def theme_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM daily_top_themes WHERE trade_date = ? ORDER BY rank ASC", (date,)
).fetchall()
items = [dict_from_row(r) for r in rows]
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/active", summary="活跃题材 + 最近10日涨幅矩阵")
async def active_themes():
conn = get_connection()
try:
# 最近 10 个有数据的交易日(升序)
rows = conn.execute(
"SELECT DISTINCT trade_date FROM daily_top_themes ORDER BY trade_date DESC LIMIT 10"
).fetchall()
dates = [r["trade_date"] for r in reversed(rows)]
if not dates:
return JSONResponse({"dates": [], "themes": []}, headers=_NO_CACHE_HEADERS)
# 窗口内全部题材上榜记录,按天分组排列(当日 rank 即排名)
placeholders = ",".join("?" * len(dates))
rows = conn.execute(
f"""SELECT trade_date, theme_code, theme_name, bf3, rank FROM daily_top_themes
WHERE trade_date IN ({placeholders})
ORDER BY trade_date DESC, rank ASC""",
dates,
).fetchall()
# 按题材聚合:每日涨幅矩阵 + 上榜次数 + 最近上榜日 + 最佳排名
theme_map: dict[str, dict] = {}
for r in rows:
code = r["theme_code"]
t = theme_map.setdefault(code, {
"themeCode": code,
"themeName": r["theme_name"],
"dailyGains": {},
"appearCount": 0,
"lastAppear": None,
"bestRank": None,
})
t["dailyGains"][r["trade_date"]] = r["bf3"]
t["appearCount"] += 1
if t["lastAppear"] is None or r["trade_date"] > t["lastAppear"]:
t["lastAppear"] = r["trade_date"]
if t["bestRank"] is None or (r["rank"] or 0) < t["bestRank"]:
t["bestRank"] = r["rank"] or 0
themes_list = list(theme_map.values())
# 稳定排序:先按上榜次数降序,再按最佳排名升序
themes_list.sort(key=lambda x: x["bestRank"] if x["bestRank"] is not None else 10**9)
themes_list.sort(key=lambda x: -x["appearCount"])
return JSONResponse({"dates": dates, "themes": themes_list}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/{theme_code}/news", summary="题材相关新闻(分页)")
async def theme_news(
theme_code: str,
page_num: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=50, description="每页条数"),
max_eu_time: str = Query("", description="分页游标(上一页返回的 maxEuTime)"),
):
result = await themes.fetch_theme_news(theme_code, page_num, max_eu_time, page_size)
if result is None:
return JSONResponse(
{"data": None, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
return JSONResponse(
{"data": result, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
@router.get("/{theme_code}/quote", summary="单题材实时行情(强度/热度/涨幅)")
async def theme_quote(theme_code: str):
result = await themes.fetch_theme_quote(theme_code)
if result is None:
return JSONResponse(
{"data": None, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
return JSONResponse(
{"data": result, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
@router.get("/{theme_code}/detail", summary="题材详情")
async def theme_detail(theme_code: str):
data = await themes.fetch_theme_detail(theme_code)
View File
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
import os
import sys
from collections.abc import Mapping
from pathlib import Path
CANONICAL_API_KEY_ENV = "HITHINK_FINANCE_API_KEY"
# AUV 项目自有约定:同花顺 key 存在 .env 的 fuyao_apikey(由 load_dotenv 加载)
PROJECT_API_KEY_ENV = "fuyao_apikey"
LEGACY_API_KEY_ENVS = ("FUYAO_TOKEN", "API_KEY")
class CredentialFileError(RuntimeError):
"""Raised when the user credential file exists but cannot be read safely."""
def credential_file_path(
*,
platform: str | None = None,
env: Mapping[str, str] | None = None,
home: Path | None = None,
) -> Path:
resolved_platform = platform or sys.platform
resolved_env = env if env is not None else os.environ
resolved_home = home or Path.home()
if resolved_platform == "win32":
config_root = resolved_env.get("APPDATA", "").strip()
base = Path(config_root) if config_root else resolved_home / "AppData" / "Roaming"
elif resolved_platform == "darwin":
base = resolved_home / "Library" / "Application Support"
else:
config_root = resolved_env.get("XDG_CONFIG_HOME", "").strip()
base = Path(config_root).expanduser() if config_root else resolved_home / ".config"
return base / "hithink-finance" / "credentials.env"
def _non_blank(value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
def _read_credential_file(path: Path) -> str | None:
if not path.exists():
return None
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
raise CredentialFileError(
f"Unable to read hithink finance credential file: {path}"
) from exc
for line_number, raw_line in enumerate(content.splitlines(), start=1):
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
name, value = line.split("=", 1)
if name.strip() != CANONICAL_API_KEY_ENV:
continue
normalized = value.strip()
if "'" in normalized or '"' in normalized:
raise CredentialFileError(
f"Invalid hithink finance credential file at {path}:{line_number}: "
f"{CANONICAL_API_KEY_ENV} must not be quoted."
)
return _non_blank(normalized)
return None
def resolve_api_key(
*,
env: Mapping[str, str] | None = None,
credential_path: Path | None = None,
) -> str | None:
resolved_env = env if env is not None else os.environ
canonical = _non_blank(resolved_env.get(CANONICAL_API_KEY_ENV))
if canonical is not None:
return canonical
# AUV 项目约定:优先读 .env 的 fuyao_apikey
project_key = _non_blank(resolved_env.get(PROJECT_API_KEY_ENV))
if project_key is not None:
return project_key
stored = _read_credential_file(
credential_path
if credential_path is not None
else credential_file_path(env=resolved_env)
)
if stored is not None:
return stored
for name in LEGACY_API_KEY_ENVS:
legacy = _non_blank(resolved_env.get(name))
if legacy is not None:
return legacy
return None
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
"""管理员认证模块
密码存储:data/admin.json
- password_hash: sha256 哈希
- salt: 随机盐
"""
import hashlib
import json
import os
import secrets
import time
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
ADMIN_FILE = os.path.join(DATA_DIR, "admin.json")
# 登录 token 有效期 24 小时
TOKEN_TTL = 86400
# 内存中的活跃 token: {token: expire_ts}
_active_tokens: dict[str, float] = {}
def _hash_password(password: str, salt: str) -> str:
return hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
def is_configured() -> bool:
return os.path.exists(ADMIN_FILE)
def set_password(password: str) -> None:
"""设置或更新管理员密码"""
os.makedirs(DATA_DIR, exist_ok=True)
salt = secrets.token_hex(16)
data = {
"password_hash": _hash_password(password, salt),
"salt": salt,
"updated_at": int(time.time()),
}
with open(ADMIN_FILE, "w") as f:
json.dump(data, f, indent=2)
def verify_password(password: str) -> bool:
"""验证密码是否正确"""
if not is_configured():
return False
with open(ADMIN_FILE) as f:
data = json.load(f)
return _hash_password(password, data["salt"]) == data["password_hash"]
def create_token() -> str:
"""创建登录 token"""
token = secrets.token_urlsafe(32)
_active_tokens[token] = time.time() + TOKEN_TTL
return token
def verify_token(token: str) -> bool:
"""验证 token 是否有效"""
if not token:
return False
expire = _active_tokens.get(token)
if expire is None:
return False
if time.time() > expire:
del _active_tokens[token]
return False
return True
def revoke_token(token: str) -> None:
"""吊销 token"""
_active_tokens.pop(token, None)
+56
View File
@@ -0,0 +1,56 @@
"""AI 分析定时任务
收盘后自动触发 AI 分析:
- 15:01 collector_loop 采集核心股+题材数据
- 15:10 ai_analysis_loop 触发 AI 分析(等数据采集完成)
"""
import asyncio
import traceback
from datetime import datetime, time as dtime, timezone, timedelta
from typing import Optional
from database import get_connection
_CST = timezone(timedelta(hours=8))
CHECK_INTERVAL_SECONDS = 300 # 每5分钟检查
AI_ANALYSIS_TIME = dtime(15, 10) # 15:10 后触发
def _is_trading_day(d: datetime) -> bool:
"""仅按工作日判断"""
return d.weekday() < 5
def _has_ai_report(trade_date: str) -> bool:
"""检查某日是否已有 AI 分析报告"""
conn = get_connection()
try:
row = conn.execute(
"SELECT 1 FROM ai_reports WHERE trade_date = ? AND report_type = 'daily' LIMIT 1",
(trade_date,)
).fetchone()
return row is not None
finally:
conn.close()
async def ai_analysis_loop(stop: Optional[asyncio.Event] = None) -> None:
"""后台循环:每个交易日 15:10 后自动触发 AI 分析(幂等)"""
while True:
try:
now = datetime.now(_CST)
if _is_trading_day(now) and now.time() >= AI_ANALYSIS_TIME:
trade_date = now.strftime("%Y-%m-%d")
if not _has_ai_report(trade_date):
print(f"[ai-collector] 开始 AI 分析 {trade_date}")
from services.ai_service import collect_ai_analysis
result = await collect_ai_analysis(trade_date)
print(f"[ai-collector] AI 分析完成 {trade_date},token 消耗: {result.get('tokens_used', 0)}")
except Exception:
print("[ai-collector] AI 分析异常:")
traceback.print_exc()
if stop is not None and stop.is_set():
break
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
+12
View File
@@ -0,0 +1,12 @@
"""AI 分析配置管理(从环境变量读取)"""
import os
from dotenv import load_dotenv
load_dotenv()
AI_API_BASE = os.getenv("AI_API_BASE", "https://openteam.oneisall.xyz/v1")
AI_API_KEY = os.getenv("AI_API_KEY", "")
AI_MODEL = os.getenv("AI_MODEL", "deepseek-v4-flash")
AI_MAX_TOKENS = int(os.getenv("AI_MAX_TOKENS")) if os.getenv("AI_MAX_TOKENS") else None
AI_TEMPERATURE = float(os.getenv("AI_TEMPERATURE")) if os.getenv("AI_TEMPERATURE") else None
+223
View File
@@ -0,0 +1,223 @@
"""AI 分析核心服务
负责:
1. 调用 OpenAI 兼容 API 进行分析
2. Function Calling 循环(AI 可主动获取数据)
3. 保存报告到数据库
"""
import json
import httpx
from datetime import datetime, timezone, timedelta
from services.ai_config import AI_API_BASE, AI_API_KEY, AI_MODEL, AI_MAX_TOKENS, AI_TEMPERATURE
from services.ai_tools import TOOLS, execute_tool
from database import get_connection
_CST = timezone(timedelta(hours=8))
SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中发现投资机会。
你的分析风格:
- 数据驱动,基于真实数据而非主观臆断
- 逻辑清晰,先总后分,层层递进
- 观点明确,给出具体的操作建议
- 风险提示,每次推荐都需说明风险点
可用工具:
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/行业强度/事件情报/市场温度)
- get_theme_history: 获取指定日期的题材涨幅排行
- get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材)
- get_stock_quote: 获取个股实时行情
- get_fund_flow: 获取个股资金流向
重要规则:
1. 你必须先调用工具获取数据,然后基于数据进行分析
2. 不要凭空编造数据,所有数据必须来自工具返回
3. 如果工具返回空数据,如实说明数据不可用
4. 分析完成后给出明确的结论和建议"""
DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。
{prev_report_section}
请先调用以下工具获取数据:
1. get_market_dashboard - 获取市场整体数据
2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅
3. get_active_core_stocks - 获取核心股数据
然后基于数据生成报告,结构如下:
## 一、市场总览
- 主要指数表现(上证、深证、创业板)
- 涨跌家数统计
- 市场温度评估
## 二、题材热点分析
- 今日涨幅前5题材
- 持续活跃的题材
- 新兴热点题材
## 三、核心股追踪
- 连板股分析
- 核心股表现
- 龙头股辨识
## 四、关注方向
- 明日值得关注的题材方向
- 潜在的交易机会
## 五、下个交易日建议
- 明日大盘预判(支撑/压力位)
- 建议关注的题材方向(2-3个)
- 建议关注的核心股(附理由)
- 操作策略(仓位建议、买卖时机)
- 需要规避的方向
## 六、风险提示
- 需要警惕的风险因素
- 操作建议
请用 Markdown 格式输出,适当使用表格展示数据对比。"""
async def call_llm(messages: list, tools: list = None) -> dict:
"""调用 OpenAI 兼容 API"""
async with httpx.AsyncClient() as client:
payload = {
"model": AI_MODEL,
"messages": messages,
}
if AI_MAX_TOKENS is not None:
payload["max_tokens"] = AI_MAX_TOKENS
if AI_TEMPERATURE is not None:
payload["temperature"] = AI_TEMPERATURE
if tools:
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()
async def collect_ai_analysis(trade_date: str) -> dict:
"""AI 分析主流程
Args:
trade_date: 交易日 YYYY-MM-DD
Returns:
{"id": int, "tokens_used": int, "tools_used": list}
"""
prev_report_section = _get_prev_report_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
)}
]
tools_used = []
total_tokens = 0
final_content = ""
was_truncated = False
max_rounds = 30 # 安全上限,正常分析约 3-8 轮
for i in range(max_rounds):
response = await call_llm(messages, tools=TOOLS)
total_tokens += response.get("usage", {}).get("total_tokens", 0)
choice = response["choices"][0]
message = choice["message"]
messages.append(message)
finish_reason = choice.get("finish_reason", "")
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", []):
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
})
summary = final_content[:200].replace("\n", " ") if final_content else ""
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:
"""保存报告到数据库"""
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', ?, ?, ?, ?, ?, ?)""",
(
trade_date,
f"{trade_date} A股收盘分析",
content,
summary,
json.dumps(tools_used),
AI_MODEL,
tokens_used,
)
)
conn.commit()
return cursor.lastrowid
finally:
conn.close()
def _get_prev_report_section(trade_date: str) -> str:
"""获取前一个交易日的报告,用于上下文参考"""
conn = get_connection()
try:
row = conn.execute(
"SELECT trade_date, content FROM ai_reports WHERE trade_date < ? AND report_type = 'daily' ORDER BY trade_date DESC LIMIT 1",
(trade_date,)
).fetchone()
if not row:
return ""
prev_date = row["trade_date"]
prev_content = row["content"] or ""
return f"""以下是前一个交易日({prev_date})的分析报告,请参考其中的分析逻辑和关注方向,结合今日数据进行对比分析:
{prev_content}
---
"""
finally:
conn.close()
+234
View File
@@ -0,0 +1,234 @@
"""Function Calling 工具定义和执行器
让 AI 可以主动调用现有 API 获取市场数据:
- get_market_dashboard: 市场看板
- get_theme_history: 题材热点历史
- get_active_core_stocks: 核心股追踪
- get_stock_quote: 个股实时行情
- get_fund_flow: 资金流向
"""
import json
from services.ai_config import AI_API_BASE, AI_API_KEY, AI_MODEL, AI_MAX_TOKENS, AI_TEMPERATURE
TOOLS = [
{
"type": "function",
"function": {
"name": "get_market_dashboard",
"description": "获取A股市场看板数据,包含主要指数行情、涨跌统计、行业强度、概念热度、事件情报、市场温度评分",
"parameters": {"type": "object", "properties": {}, "required": []}
}
},
{
"type": "function",
"function": {
"name": "get_theme_history",
"description": "获取指定交易日的题材涨幅排行前20",
"parameters": {
"type": "object",
"properties": {
"date": {"type": "string", "description": "交易日 YYYY-MM-DD"}
},
"required": ["date"]
}
}
},
{
"type": "function",
"function": {
"name": "get_active_core_stocks",
"description": "获取活跃核心股列表,包含最近10日涨幅矩阵和所属题材",
"parameters": {"type": "object", "properties": {}, "required": []}
}
},
{
"type": "function",
"function": {
"name": "get_stock_quote",
"description": "获取单只股票实时行情(价格、涨跌幅、成交量、换手率等)",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "6位股票代码,如 600519"}
},
"required": ["code"]
}
}
},
{
"type": "function",
"function": {
"name": "get_fund_flow",
"description": "获取个股资金流向数据(主力净流入、超大单/大单/中单/小单流入流出)",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "6位股票代码"},
"name": {"type": "string", "description": "股票名称"},
"days": {"type": "integer", "description": "获取天数,默认30"}
},
"required": ["code", "name"]
}
}
}
]
async def execute_tool(tool_name: str, arguments: dict) -> str:
"""执行工具调用,返回 JSON 字符串"""
try:
if tool_name == "get_market_dashboard":
return await _get_market_dashboard()
elif tool_name == "get_theme_history":
return await _get_theme_history(arguments.get("date", ""))
elif tool_name == "get_active_core_stocks":
return await _get_active_core_stocks()
elif tool_name == "get_stock_quote":
return await _get_stock_quote(arguments.get("code", ""))
elif tool_name == "get_fund_flow":
return await _get_fund_flow(
arguments.get("code", ""),
arguments.get("name", ""),
arguments.get("days", 30)
)
else:
return json.dumps({"error": f"未知工具: {tool_name}"})
except Exception as e:
return json.dumps({"error": str(e)})
async def _get_market_dashboard() -> str:
"""获取市场看板数据"""
from routes.market_dashboard import _build_dashboard
data = await _build_dashboard()
return json.dumps(data, ensure_ascii=False, default=str)
async def _get_theme_history(date: str) -> str:
"""获取指定交易日题材涨幅前20"""
from database import get_connection
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM daily_top_themes WHERE trade_date = ? ORDER BY rank ASC",
(date,)
).fetchall()
items = [dict(r) for r in rows]
return json.dumps({"date": date, "items": items}, ensure_ascii=False)
finally:
conn.close()
async def _get_active_core_stocks() -> str:
"""获取活跃核心股列表"""
from database import get_connection
from datetime import date as date_cls
conn = get_connection()
try:
# 最近10个有数据的交易日
rows = conn.execute(
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT 10"
).fetchall()
dates = [r["trade_date"] for r in reversed(rows)]
if not dates:
return json.dumps({"dates": [], "stocks": []}, ensure_ascii=False)
placeholders = ",".join("?" * len(dates))
rows = conn.execute(
f"""SELECT trade_date, stock_code, stock_name, f3, cover_count FROM daily_core_stocks
WHERE trade_date IN ({placeholders})
ORDER BY trade_date DESC, rank ASC""",
dates,
).fetchall()
stock_days = {}
for r in rows:
code = r["stock_code"]
s = stock_days.setdefault(code, {
"stockCode": code,
"stockName": r["stock_name"],
"coverCount": r["cover_count"],
"dailyGains": {},
"appearCount": 0,
"lastAppear": None,
})
s["dailyGains"][r["trade_date"]] = r["f3"]
s["appearCount"] += 1
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"])
themes_rows = conn.execute(
f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes
WHERE trade_date IN ({placeholders})""",
dates,
).fetchall()
themes_by_stock = {}
for t in themes_rows:
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())
latest = dates[-1] if dates else None
for s in stocks:
if s.get("lastAppear") and latest:
try:
d1 = date_cls.fromisoformat(latest)
d2 = date_cls.fromisoformat(s["lastAppear"])
s["daysSinceLastAppear"] = (d1 - d2).days
except ValueError:
s["daysSinceLastAppear"] = 0
else:
s["daysSinceLastAppear"] = 0
return json.dumps({"dates": dates, "stocks": stocks}, ensure_ascii=False)
finally:
conn.close()
async def _get_stock_quote(code: str) -> str:
"""获取个股实时行情"""
from services.tencent import fetch_quote
data = await fetch_quote(code)
if not data:
return json.dumps({"error": f"未找到股票 {code} 的数据"})
return json.dumps(data, ensure_ascii=False)
async def _get_fund_flow(code: str, name: str, days: int) -> str:
"""获取个股资金流向"""
from services.tencent import fetch_quote, get_market_prefix
from database import get_connection
# 先尝试从本地数据库获取历史资金流向
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM cache WHERE key LIKE ? AND expires_at > datetime('now','localtime')",
(f"fund_flow:{code}%",)
).fetchall()
if rows:
return rows[0]["value"]
finally:
conn.close()
# 降级:通过腾讯获取基础行情数据
quote = await fetch_quote(code)
if not quote:
return json.dumps({"error": f"未找到股票 {code} 的数据"})
return json.dumps({
"code": code,
"name": name or quote.get("name", ""),
"currentPrice": quote.get("currentPrice", 0),
"changePercent": quote.get("changePercent", 0),
"volume": quote.get("volume", 0),
"amount": quote.get("amount", 0),
"note": "资金流向详细数据需通过东方财富API获取"
}, ensure_ascii=False)
+10
View File
@@ -56,3 +56,13 @@ def clean_expired():
conn.commit()
finally:
conn.close()
def clear_all():
"""清空全部缓存(每日开盘后 9:31 调用,保证当日数据全新)"""
conn = get_connection()
try:
conn.execute("DELETE FROM cache")
conn.commit()
finally:
conn.close()
+245
View File
@@ -0,0 +1,245 @@
"""每日热点数据采集:核心股前100 + 题材涨幅前20,收盘后自动入库
由 main.lifespan 启动后台任务;幂等(按交易日 UNIQUE 去重)。
"""
import asyncio
import traceback
from datetime import datetime, time as dtime, timezone, timedelta
from typing import Optional
from database import get_connection
from services.cache import clear_all
from services.themes import fetch_theme_list, _build_theme_graph
_CST = timezone(timedelta(hours=8))
# 每天采集的后台任务:每 300 秒(5 分钟)检查一次
CHECK_INTERVAL_SECONDS = 300
COLLECT_AFTER_TIME = dtime(15, 1) # 收盘后 15:01 开始允许采集(留 1 分钟等收盘数据稳定)
CORE_STOCK_LIMIT = 100 # 题材领涨股涨幅前100
RANK_TOP_LIMIT = 50 # 每个榜单(涨幅/强度/热度/成交额)取前50
HOTMAP_TOP_N = 100 # 热点穿透采样题材数:涨幅榜+热度榜各取前100,合并(与热点穿透页一致)
HOTMAP_CORE_LIMIT = 100 # 热点穿透核心股前100(按覆盖题材数降序)
CORE_COVER_THRESHOLD = 2 # 热点穿透核心股门槛:覆盖题材数 ≥2
# 题材榜单:sortField 1=涨幅(bf3) 3=强度(strengthValue) 4=热度排名(hotRank) 5=成交额(fex5)
# 每个榜单取前 RANK_TOP_LIMIT 个,按 themeCode 合并去重后入库
THEME_RANK_LISTS = ((1, "bf3"), (3, "strengthValue"), (4, "hotRank"), (5, "fex5"))
# 每日缓存清理:交易日 9:31 清空全部缓存,保证开盘后数据全新
CLEANUP_TIME = dtime(9, 31)
def _is_trading_day(d: datetime) -> bool:
"""仅按工作日判断:周一至周五视为交易日,不处理法定节假日"""
return d.weekday() < 5
def _has_collected(trade_date: str) -> bool:
"""当日核心股是否已采集"""
conn = get_connection()
try:
row = conn.execute(
"SELECT 1 FROM daily_core_stocks WHERE trade_date = ? LIMIT 1",
(trade_date,),
).fetchone()
return row is not None
finally:
conn.close()
async def collect_daily(trade_date: str, dry_run: bool = False) -> dict:
"""采集指定交易日数据并入库。
核心股 = 题材领涨股涨幅前100 ∪ 热点穿透核心股(覆盖题材数≥2、按覆盖数降序前100),按股票代码去重。
题材 = 涨幅/强度/热度/成交额 4 榜各前50,按 themeCode 合并去重。
热点穿透构建失败时降级为仅领涨股前100,不影响当日采集。
Args:
trade_date: YYYY-MM-DD
dry_run: True 只打印不写库(用于验证)
Returns:
{"core_count": int, "theme_count": int, "skipped": bool}
"""
if _has_collected(trade_date):
print(f"[collector] {trade_date} 已采集,跳过")
return {"core_count": 0, "theme_count": 0, "skipped": True}
# 1. 拉取全部题材列表(含领涨股,作为当日全部股票的采样来源)
themes = await fetch_theme_list(1, False)
if not themes:
print(f"[collector] {trade_date} 题材列表为空(东财失败),跳过")
return {"core_count": 0, "theme_count": 0, "skipped": True}
# 领涨题材映射:securityCode -> [(themeCode, themeName), ...](用于核心股"所属题材")
lead_themes: dict[str, list[tuple[str, str]]] = {}
for t in themes:
code = t.get("securityCode")
if not code:
continue
lead_themes.setdefault(code, []).append((t["themeCode"], t["themeName"]))
# 2. 热点穿透核心股:覆盖题材数≥2,按覆盖题材数降序前100(去重)
# _build_theme_graph 采样 涨幅榜+热度榜 各前 HOTMAP_TOP_N 个题材并拉每股覆盖题材数,
# 已按 (-coverCount, -f3) 降序;失败时降级为空集,仅保留领涨股。
hotmap_core: list[dict] = []
graph_theme_name: dict[str, str] = {}
try:
graph = await _build_theme_graph(1, HOTMAP_TOP_N)
graph_theme_name = {t["themeCode"]: t["themeName"] for t in graph.get("themes", [])}
hotmap_core = [
s for s in graph.get("stocks", [])
if s.get("coverCount", 0) >= CORE_COVER_THRESHOLD
][:HOTMAP_CORE_LIMIT]
except Exception as e:
print(f"[collector] 热点穿透核心股构建失败,降级为仅领涨股: {e}")
# 3. 题材领涨股涨幅前100:按领涨股 f3 降序,去重(同一股票可能是多个题材领涨股)
stock_map: dict[str, dict] = {}
theme_count: dict[str, int] = {} # securityCode -> 领涨题材数
for t in themes:
code = t.get("securityCode")
if not code:
continue
theme_count[code] = theme_count.get(code, 0) + 1
if code not in stock_map or (t.get("f3") or 0) > (stock_map[code].get("f3") or 0):
stock_map[code] = {
"stock_code": code,
"stock_name": t.get("securityName", ""),
"f3": t.get("f3"),
}
f3_core = sorted(stock_map.values(), key=lambda x: -(x["f3"] or 0))[:CORE_STOCK_LIMIT]
# 4. 题材 4 榜(涨幅/强度/热度/成交额)各前50,按 themeCode 合并去重。
# 涨幅榜列表与开头用于构建领涨股映射的 fetch_theme_list(1, False) 同缓存,直接复用。
merged: dict[str, dict] = {}
rank_list = await fetch_theme_list(1, False)
for sort_field, _ in THEME_RANK_LISTS:
lst = rank_list if sort_field == 1 else await fetch_theme_list(sort_field, False)
for t in lst[:RANK_TOP_LIMIT]:
merged.setdefault(t["themeCode"], t)
top_themes = list(merged.values())
# 5. 合并去重:热点穿透核心股在前(覆盖数降序,rank 优先),随后补领涨股涨幅前100
core_stocks: list[dict] = []
seen: set[str] = set()
theme_pairs: set[tuple[str, str, str]] = set() # (stock_code, theme_code, theme_name)
for s in hotmap_core:
code = s["securityCode"]
seen.add(code)
core_stocks.append({
"stock_code": code,
"stock_name": s.get("securityName", ""),
"f3": s.get("f3"),
"cover_count": s.get("coverCount", 0),
})
# 所属题材:采样榜内覆盖的题材 + 全量题材列表中的领涨题材
for tc in s.get("themeCodes", []):
theme_pairs.add((code, tc, graph_theme_name.get(tc, tc)))
for tc, tn in lead_themes.get(code, []):
theme_pairs.add((code, tc, tn))
for s in f3_core:
code = s["stock_code"]
if code in seen:
continue
seen.add(code)
core_stocks.append({
"stock_code": code,
"stock_name": s["stock_name"],
"f3": s["f3"],
"cover_count": theme_count.get(code, 0),
})
for tc, tn in lead_themes.get(code, []):
theme_pairs.add((code, tc, tn))
if dry_run:
print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只(热点穿透 {len(hotmap_core)} + 领涨股 {len(f3_core)}),题材 {len(top_themes)} 只(4 榜前 {RANK_TOP_LIMIT} 合并)")
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
# 6. 入库(事务,UNIQUE 幂等)
conn = get_connection()
try:
for i, s in enumerate(core_stocks, start=1):
conn.execute(
"INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, cover_count, rank) VALUES (?,?,?,?,?,?)",
(trade_date, s["stock_code"], s["stock_name"], s["f3"], s["cover_count"], i),
)
for code, tc, tn in theme_pairs:
conn.execute(
"INSERT OR IGNORE INTO daily_core_stock_themes (trade_date, stock_code, theme_code, theme_name) VALUES (?,?,?,?)",
(trade_date, code, tc, tn),
)
for i, t in enumerate(top_themes, start=1):
conn.execute(
"INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)",
(trade_date, t["themeCode"], t["themeName"], t.get("bf3"), t.get("hotRank"), i),
)
conn.commit()
finally:
conn.close()
print(f"[collector] {trade_date} 已采集:核心股 {len(core_stocks)} 只,题材 {len(top_themes)} 只(4 榜前 {RANK_TOP_LIMIT} 合并)")
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
"""后台循环:每个交易日 15:01 后自动采集当日数据(幂等)"""
while True:
try:
now = datetime.now(_CST)
if _is_trading_day(now) and now.time() >= COLLECT_AFTER_TIME:
trade_date = now.strftime("%Y-%m-%d")
if not _has_collected(trade_date):
await collect_daily(trade_date)
except Exception:
print("[collector] 采集异常:")
traceback.print_exc()
if stop is not None and stop.is_set():
break
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
def _next_cleanup_dt(now: datetime) -> datetime:
"""计算下一个缓存清理时刻:最近一个工作日 9:31(今天已过则取下一个工作日)"""
for days in range(0, 8):
d = (now + timedelta(days=days)).date()
if d.weekday() >= 5: # 跳过周末
continue
dt = datetime(d.year, d.month, d.day, CLEANUP_TIME.hour, CLEANUP_TIME.minute, tzinfo=_CST)
if dt > now:
return dt
return now + timedelta(days=1) # 兜底:理论不可达
async def cache_cleanup_loop(stop: Optional[asyncio.Event] = None) -> None:
"""后台循环:每个交易日 9:31 清空全部缓存,保证开盘后读到全新数据
到点前精确 sleep 至 9:31;若进程在 9:31 后启动,则等下一个交易日。
"""
while True:
try:
now = datetime.now(_CST)
next_dt = _next_cleanup_dt(now)
delay = max(0, int((next_dt - now).total_seconds()))
if stop is not None:
try:
await asyncio.wait_for(stop.wait(), timeout=delay)
except asyncio.TimeoutError:
pass # 到点
if stop.is_set():
break
else:
await asyncio.sleep(delay)
clear_all()
print(f"[cache-cleanup] 已清空全部缓存: {datetime.now(_CST).strftime('%Y-%m-%d %H:%M:%S')}")
except asyncio.CancelledError:
raise
except Exception:
print("[cache-cleanup] 清理异常:")
traceback.print_exc()
await asyncio.sleep(60) # 出错 1 分钟后再试
+1 -325
View File
@@ -6,13 +6,11 @@ import httpx
import json
import os
import re
from datetime import datetime, time as dtime, timedelta, timezone
from datetime import datetime
from typing import Optional, List
from services.cache import get_cache, set_cache
from services.cache import get_cache, set_cache
# ---- API Key 轮询(MX 备选源用)----
@@ -283,328 +281,6 @@ def get_eastmoney_market(code: str) -> str:
return "1"
return "0"
# ---- 板块数据 ----
# 从东方财富 bkzj/list.js 逆向的字段映射
# f62=主力净流入, f184=主力净流入占比
# f66=超大单净流入, f69=超大单净流入占比
# f72=大单净流入, f75=大单净流入占比
# f78=中单净流入, f81=中单净流入占比
# f84=小单净流入, f87=小单净流入占比
# f70=成交额
SECTOR_FIELDS = "f12,f14,f2,f3,f62,f184,f66,f69,f72,f75,f78,f81,f84,f87,f70"
# 东方财富板块类型映射
SECTOR_MEDIA_MAP = {
"industry": "m:90+s:4",
"concept": "m:90+t:3",
}
# 东方财富 UT 令牌管理
_em_ut: str = "8dec03ba335b81bf4ebdf7b29ec27d15"
_em_ut_lock = asyncio.Lock()
async def _refresh_em_ut() -> str:
"""
从东方财富前端 JS 中提取最新的 ut 令牌。
按优先级尝试:
1. bkzj/list.js(板块页专用)
2. common/emdataview.js(通用数据组件)
"""
urls = [
"https://data.eastmoney.com/newstatic/js/bkzj/list.js",
"https://data.eastmoney.com/newstatic/js/common/emdataview.js",
]
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://data.eastmoney.com/bkzj/hy.html",
}
async with httpx.AsyncClient() as client:
for url in urls:
try:
resp = await client.get(url, headers=headers, timeout=10)
if resp.status_code != 200:
continue
# 匹配 ut: 'xxxx' 或 ut:'xxxx' 或 ut: "xxxx"
m = re.search(r"""ut['"]?\s*:\s*['"]([a-f0-9]{32})['"]""", resp.text)
if m:
token = m.group(1)
print(f"[eastmoney] 已刷新 UT 令牌: {token[:8]}...")
return token
except Exception as e:
print(f"[eastmoney] 获取 UT 失败({url}): {e}")
return _em_ut # 保底返回当前值
async def get_em_ut(force_refresh: bool = False) -> str:
"""获取当前 UT,必要时刷新"""
global _em_ut
if force_refresh:
async with _em_ut_lock:
_em_ut = await _refresh_em_ut()
return _em_ut
# ---- 板块数据(市场时间感知缓存)----
_CST = timezone(timedelta(hours=8)) # 北京时间
_TRADING_MORNING = (dtime(9, 30), dtime(11, 30))
_TRADING_AFTERNOON = (dtime(13, 0), dtime(15, 0))
def _cst_now() -> datetime:
return datetime.now(_CST)
def _is_trading_time() -> bool:
"""判断当前是否为 A 股交易时段(周一至周五 9:30-11:30 / 13:00-15:00)"""
now = _cst_now()
if now.weekday() >= 5:
return False
t = now.time()
return (_TRADING_MORNING[0] <= t <= _TRADING_MORNING[1]
or _TRADING_AFTERNOON[0] <= t <= _TRADING_AFTERNOON[1])
def _sector_ttl_hours() -> int:
"""根据是否在交易时段返回缓存 TTL
- 交易时段: 2 分钟(数据持续变化)
- 非交易时段: 18 小时(覆盖到下一个交易日)
"""
return 0 if _is_trading_time() else 18
# curl_cffi 模拟 Chrome TLS 指纹
from curl_cffi.requests import AsyncSession
_sector_session: Optional[AsyncSession] = None
def _get_sector_session() -> AsyncSession:
global _sector_session
if _sector_session is None:
_sector_session = AsyncSession(
impersonate="chrome131",
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Referer": "https://data.eastmoney.com/bkzj/hy.html",
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9",
},
timeout=5,
)
return _sector_session
# 内存缓存(加速交易时段频繁请求)
_sector_cache: dict[str, tuple[list[dict], float]] = {}
_SECTOR_MEM_TTL = 60
async def fetch_sector_list(sector_type: str) -> list[dict]:
# 1. 内存缓存
now = time.time()
if sector_type in _sector_cache:
data, ts = _sector_cache[sector_type]
if now - ts < _SECTOR_MEM_TTL:
return data
cache_key = f"sector_list:{sector_type}"
# 2. 非交易时段:走磁盘持久缓存
if not _is_trading_time():
cached = get_cache(cache_key)
if cached is not None:
data = json.loads(cached)
_sector_cache[sector_type] = (data, now)
return data
# 3. 并发请求 push2 和 akshare,优先使用 push2
push2_task = asyncio.create_task(_fetch_push2(sector_type))
akshare_task = asyncio.create_task(_fetch_akshare(sector_type))
push2_data = await push2_task
if push2_data:
akshare_task.cancel()
try:
await akshare_task
except asyncio.CancelledError:
pass
_sector_cache[sector_type] = (push2_data, now)
ttl = _sector_ttl_hours()
if ttl > 0:
set_cache(cache_key, json.dumps(push2_data, ensure_ascii=False), ttl_hours=ttl)
return push2_data
# push2 失败,用 akshare(不写磁盘缓存)
akshare_data = await akshare_task
if akshare_data:
_sector_cache[sector_type] = (akshare_data, now)
return akshare_data
async def _fetch_push2(sector_type: str) -> list[dict]:
"""东方财富 push2 API(curl_cffi 模拟浏览器 TLS 指纹)"""
fs = SECTOR_MEDIA_MAP.get(sector_type)
if not fs:
return []
session = _get_sector_session()
ut = await get_em_ut()
for attempt in range(2):
url = (
f"https://push2.eastmoney.com/api/qt/clist/get"
f"?fs={fs}&fields={SECTOR_FIELDS}"
f"&fid=f62&po=1&pz=500&pn=1&np=1&fltt=2"
f"&invt=2&ut={ut}"
)
try:
resp = await session.get(url)
if resp.status_code != 200:
if attempt == 0:
await asyncio.sleep(1)
continue
return []
result = resp.json()
if result.get("rc") != 0:
return []
diff = result.get("data", {}).get("diff", [])
items = []
for item in diff:
items.append({
"code": item.get("f12", ""),
"name": item.get("f14", ""),
"level": item.get("f2"),
"changePercent": item.get("f3"),
"changeAmount": None,
"mainNetInflow": item.get("f62", 0) or 0,
"mainNetInflowPercent": item.get("f184", 0),
"superLargeInflow": item.get("f66", 0) or 0,
"superLargeInflowPercent": item.get("f69", 0),
"largeInflow": item.get("f72", 0) or 0,
"largeInflowPercent": item.get("f75", 0),
"mediumInflow": item.get("f78", 0) or 0,
"mediumInflowPercent": item.get("f81", 0),
"smallInflow": item.get("f84", 0) or 0,
"smallInflowPercent": item.get("f87", 0),
"turnover": item.get("f70", 0) or 0,
})
return items
except Exception as e:
err = str(e)
print(f"[eastmoney] push2 获取{sector_type}板块失败(attempt {attempt+1}): {err[:80]}")
# UT 可能过期,尝试刷新
if "disconnect" in err.lower() or "refused" in err.lower() or attempt == 1:
await get_em_ut(force_refresh=True)
ut = _em_ut
# 先尝试更新现有会话的 headers
try:
session.headers.update({"Referer": "https://data.eastmoney.com/bkzj/hy.html"})
except Exception:
pass
# 重建会话(TLS 指纹可能会被缓存)
global _sector_session
_sector_session = None
session = _get_sector_session()
if attempt == 0:
await asyncio.sleep(1)
return []
import akshare as ak
async def _fetch_akshare(sector_type: str) -> list[dict]:
"""akshare 降级方案(东方财富数据源)"""
loop = asyncio.get_event_loop()
code_map_key = f"board_codes:{sector_type}"
def _build_code_map():
"""获取板块代码映射(HTTP 较慢,结果单独缓存 24h)"""
code_map = {}
try:
if sector_type == "industry":
code_df = ak.stock_board_industry_name_em()
else:
code_df = ak.stock_board_concept_name_em()
if code_df is not None and not code_df.empty:
for _, r in code_df.iterrows():
code_map[str(r.get("f14", ""))] = str(r.get("f12", ""))
except Exception:
try:
if sector_type == "industry":
code_df = ak.stock_board_industry_name_ths()
else:
code_df = ak.stock_board_concept_name_ths()
if code_df is not None and not code_df.empty:
for _, r in code_df.iterrows():
code_map[str(r.get("name", ""))] = str(r.get("code", ""))
except Exception:
pass
return code_map
def _get_fund_flow():
if sector_type == "industry":
return ak.stock_fund_flow_industry()
else:
return ak.stock_fund_flow_concept()
# 1. 尝试从缓存读取 code_map
code_map = {}
cached_map = get_cache(code_map_key)
if cached_map is not None:
code_map = json.loads(cached_map)
try:
if code_map:
# 已有缓存,只需获取资金流
df = await loop.run_in_executor(None, _get_fund_flow)
else:
# 首次:code_map + 资金流并发获取
map_data, df = await asyncio.gather(
loop.run_in_executor(None, _build_code_map),
loop.run_in_executor(None, _get_fund_flow),
)
if map_data:
code_map = map_data
set_cache(code_map_key, json.dumps(code_map, ensure_ascii=False), ttl_hours=24)
if df is None or df.empty:
return []
df = df.sort_values("净额", ascending=False)
items = []
for _, row in df.iterrows():
name = str(row.get("行业", "")).strip()
inflow = float(row.get("流入资金", 0) or 0) * 100000000
outflow = float(row.get("流出资金", 0) or 0) * 100000000
items.append({
"code": code_map.get(name, ""),
"name": name,
"level": float(row.get("行业指数") or 0),
"changePercent": float(row.get("行业-涨跌幅") or 0),
"changeAmount": None,
"mainNetInflow": float(row.get("净额", 0) or 0) * 100000000,
"mainNetInflowPercent": None,
"superLargeInflow": None,
"superLargeInflowPercent": None,
"largeInflow": None,
"largeInflowPercent": None,
"mediumInflow": None,
"mediumInflowPercent": None,
"smallInflow": None,
"smallInflowPercent": None,
"turnover": inflow + outflow,
})
return items
except Exception as e:
print(f"[eastmoney] akshare 获取{sector_type}板块失败: {e}")
return []
# ---- 公司概况 ----
_F10_MARKET_MAP = {"6": "SH", "0": "SZ", "3": "SZ"}
+311
View File
@@ -0,0 +1,311 @@
"""同花顺金融数据 API 客户端(v2 数据源,接口前缀 /api/v2)
本模块是基于官方 Python SDK(vendor 在 backend/sdk/)的薄封装,
对外暴露与 /api/v2 路由匹配的 async 函数,并统一返回 list/dict。
- SDK 提供:历史K线 >10 年自动切片、重试、参数校验、标的缓存。
- SDK 内部用同步 requests,这里用 asyncio.to_thread 桥接,避免阻塞事件循环。
- API Key 由 SDK 的 credentials resolver 读取(兼容 .env 的 fuyao_apikey),
**绝不写入代码、日志、错误信息或 git。**
官方 SDK 来源:https://github.com/HiThink-Tech/Financial-API (MIT)
"""
import asyncio
import time
from typing import Any, Iterable, Optional
from sdk import fuyao_client as _sdk
from sdk.fuyao_client import FuyaoApiError # 复用官方错误类型
# 兼容旧路由引用名(routes/fuyao.py 用 FuyaoError)
FuyaoError = FuyaoApiError
# ---------------------------------------------------------------
# 拼音首字母检索(同花顺接口不支持拼音,本地补齐)
# ---------------------------------------------------------------
# 全市场 A 股标的列表缓存(用于拼音首字母匹配)
_ticker_cache: dict = {"data": None, "at": 0.0}
_TICKER_CACHE_TTL = 12 * 3600 # 12 小时
async def _ensure_a_share_tickers() -> list[dict]:
"""拉取全市场 A 股标的列表并缓存(幂等,TTL 内复用)"""
now = time.time()
if _ticker_cache["data"] is not None and now - _ticker_cache["at"] < _TICKER_CACHE_TTL:
return _ticker_cache["data"]
items = await _run(_sdk.tickers_list, asset_type="a-share", limit=10000, offset=0)
data = _item_list(items)
_ticker_cache["data"] = data
_ticker_cache["at"] = now
return data
def _pinyin_initials(name: str) -> str:
"""中文名 → 拼音首字母(如 深科技→skj,TCL科技→tkj)"""
try:
from pypinyin import lazy_pinyin
except ImportError:
return ""
return "".join(
w[0] for w in lazy_pinyin(name)
if w and w[0].isalpha()
).lower()
def _match_by_pinyin(query: str, tickers: list[dict], limit: int) -> list[dict]:
"""按拼音首字母匹配:精确=前缀优先,包含匹配次之"""
q = query.lower()
exact: list[dict] = []
prefix: list[dict] = []
contains: list[dict] = []
for t in tickers:
name = t.get("name") or ""
if not name:
continue
initials = _pinyin_initials(name)
if not initials:
continue
if initials == q:
exact.append(t)
elif initials.startswith(q):
prefix.append(t)
elif q in initials:
contains.append(t)
return (exact + prefix + contains)[:limit]
def _run(fn, *args, **kwargs):
"""同步 SDK 调用桥接到 async"""
return asyncio.to_thread(fn, *args, **kwargs)
def _item_list(data, key="item"):
"""把 SDK 返回的 dict 信封或 list 统一成 item list"""
if isinstance(data, dict):
return data.get(key, []) or []
if isinstance(data, list):
return data or []
return []
# ---------------------------------------------------------------
# 基础数据:标的检索 / 标的列表
# ---------------------------------------------------------------
async def ticker_search(q: str, exchange: Optional[str] = None,
asset_type: Optional[str] = None, limit: int = 10) -> list[dict]:
items = await _run(_sdk.tickers_search, q, exchange=exchange,
asset_type=asset_type, limit=limit)
result = _item_list(items)
# 拼音首字母兜底:同花顺无结果 且 输入是纯字母(如 skj)时,本地按拼音首字母匹配
if not result and q and q.strip().isalpha():
try:
tickers = await _ensure_a_share_tickers()
result = await asyncio.to_thread(_match_by_pinyin, q, tickers, limit)
except Exception:
pass # 拼音匹配失败不影响主流程
return result
async def ticker_list(asset_type: Optional[str] = None, limit: int = 100,
offset: int = 0) -> list[dict]:
items = await _run(_sdk.tickers_list, asset_type=asset_type or "a-share",
limit=limit, offset=offset)
return _item_list(items)
# ---------------------------------------------------------------
# A股行情 / 日历 / 竞价
# ---------------------------------------------------------------
async def prices_snapshot(thscodes: str) -> list[dict]:
"""行情快照(单只/多只,逗号分隔)"""
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
items = await _run(_sdk.prices_snapshot, codes)
return _item_list(items)
async def prices_historical(thscode: str, start_ms: int, end_ms: int,
adjust: str = "forward", offset: int = 0) -> list[dict]:
"""历史日K(毫秒时间戳)。SDK 自动处理 >10 年窗口切片与去重排序。"""
return await _run(_sdk.prices_historical, thscode, start_ms, end_ms,
interval="1d", adjust=adjust)
async def corp_actions_adjustment_factors(thscode: str, from_date: str = None,
to_date: str = None) -> dict:
data = await _run(_sdk.corp_actions_adjustment_factors, thscode,
**({"from": from_date} if from_date else {}),
**({"to": to_date} if to_date else {}))
return data or {}
async def calendar_trading_days() -> list[dict]:
return await _run(_sdk.calendar_trading_days)
async def auction_snapshot(thscodes: str, stage: str = "final") -> dict:
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
data = await _run(_sdk.a_share_auction_snapshot, codes, stage=stage)
return data or {}
async def auction_short_term_benchmark(date: Optional[str] = None) -> dict:
data = await _run(_sdk.a_share_auction_short_term_benchmark, date=date)
return data or {}
# ---------------------------------------------------------------
# A股财务 / 估值
# ---------------------------------------------------------------
async def financials(statement: str, thscode: str, period: str = "annual",
limit: int = 6) -> list[dict]:
fn_map = {
"income-statements": _sdk.financials_income_statements,
"balance-sheets": _sdk.financials_balance_sheets,
"cash-flow-statements": _sdk.financials_cash_flow_statements,
}
fn = fn_map[statement]
items = await _run(fn, thscode, period=period, limit=limit)
return _item_list(items)
async def financial_indicators(thscode: str, report: str) -> list[dict]:
data = await _run(_sdk.financials_indicators, thscode, report)
return _item_list(data)
async def valuations_snapshot(thscodes: str) -> list[dict]:
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
data = await _run(_sdk.a_share_valuations_snapshot, codes)
return _item_list(data)
# ---------------------------------------------------------------
# 指数 / 板块
# ---------------------------------------------------------------
async def index_catalog(tag: str = "industry") -> list[dict]:
items = await _run(_sdk.index_catalog_ths_index_list, tag=tag)
return _item_list(items)
async def index_constituents(thscode: str) -> list[dict]:
items = await _run(_sdk.index_constituents_ths_stock_list, thscode)
return _item_list(items)
async def index_prices_snapshot(thscodes: str) -> list[dict]:
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
items = await _run(_sdk.index_prices_snapshot, codes)
return _item_list(items)
async def index_prices_historical(thscode: str, start_ms: int, end_ms: int) -> list[dict]:
items = await _run(_sdk.index_prices_historical, thscode, start_ms, end_ms)
return _item_list(items)
# ---------------------------------------------------------------
# 特殊数据(涨停/跌停池)
# ---------------------------------------------------------------
async def limit_up_pool(date_ms=None, page=1, size=50,
sort_field="seal_money", sort_dir="desc") -> dict:
data = await _run(_sdk.special_data_limit_up_pool, date_ms=date_ms,
page=page, size=size, sort_field=sort_field, sort_dir=sort_dir)
return data or {}
async def limit_down_pool(date_ms=None, page=1, size=50,
sort_field="last_limit_time", sort_dir="desc") -> dict:
data = await _run(_sdk.special_data_limit_down_pool, date_ms=date_ms,
page=page, size=size, sort_field=sort_field, sort_dir=sort_dir)
return data or {}
# ---------------------------------------------------------------
# 全市场行情快照
# ---------------------------------------------------------------
async def prices_snapshot_all(limit: int = 5000) -> list[dict]:
"""拉取全市场 A 股行情快照(自动分页)"""
items = await _run(_sdk.prices_snapshot, None, fetch_all_market=True, limit=limit)
return _item_list(items)
# ---------------------------------------------------------------
# 特殊数据(热门股/龙虎榜/异动/连板/飙升)
# ---------------------------------------------------------------
async def hot_stock_list(period: str = "day") -> dict:
data = await _run(_sdk.special_data_hot_stock_list, period=period)
return data or {}
async def dragon_tiger_list(board_type: str = "all", date: str = None) -> dict:
data = await _run(_sdk.special_data_dragon_tiger_list, board_type=board_type, date=date)
return data or {}
async def anomaly_analysis_list(tag_codes=None) -> dict:
data = await _run(_sdk.special_data_anomaly_analysis_list, tag_codes=tag_codes)
return data or {}
async def skyrocket_list(period: str = "day") -> dict:
data = await _run(_sdk.special_data_skyrocket_list, period=period)
return data or {}
async def limit_up_ladder() -> dict:
data = await _run(_sdk.special_data_limit_up_ladder)
return data or {}
# ---------------------------------------------------------------
# 集合竞价 / 估值 / 炸板 / 概念板块
# ---------------------------------------------------------------
async def auction_snapshot(thscodes: str, stage: str = "final") -> dict:
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
data = await _run(_sdk.a_share_auction_snapshot, codes, stage=stage)
return data or {}
async def auction_short_term_benchmark(date: str = None) -> dict:
data = await _run(_sdk.a_share_auction_short_term_benchmark, date=date)
return data or {}
async def valuations_snapshot(thscodes: str) -> dict:
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
data = await _run(_sdk.a_share_valuations_snapshot, codes)
return data or {}
async def limit_break_pool(date_ms=None, page=1, size=50,
sort_field="price_change_ratio_pct", sort_dir="desc") -> dict:
data = await _run(_sdk.special_data_limit_break_pool, date_ms=date_ms,
page=page, size=size, sort_field=sort_field, sort_dir=sort_dir)
return data or {}
async def hot_stock_list_history(date: str) -> dict:
data = await _run(_sdk.special_data_hot_stock_list_history, date)
return data or {}
async def hot_stock_rank_trend(thscode: str, start_date: str, end_date: str) -> dict:
data = await _run(_sdk.special_data_hot_stock_rank_trend, thscode, start_date, end_date)
return data or {}
async def anomaly_analysis_stock(thscodes: str) -> dict:
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
data = await _run(_sdk.special_data_anomaly_analysis_stock, codes)
return data or {}
-58
View File
@@ -3,7 +3,6 @@
通过 TCP 协议直连通达信行情服务器,不走 HTTP,不会被限流。
主要用途:
- K线数据:主数据源(稳定可靠)
- 板块数据:东方财富 push2 的降级方案
"""
import asyncio
@@ -69,60 +68,3 @@ async def fetch_kline_history(code: str, days: int = 90) -> Optional[List[dict]]
except Exception as e:
print(f"[mootdx] fetch_kline error: {e}")
return None
# ---- 板块数据(东方财富降级方案)----
def _sync_fetch_sectors(sector_type: str) -> Optional[list]:
from mootdx.consts import MARKET_SH, MARKET_SZ
client = _create_client()
# block() 返回 DataFrame,列:code, name 等
# 按板块类型过滤
block_df = client.block()
if block_df is None or block_df.empty:
return None
items = []
for _, row in block_df.iterrows():
name = str(row.get("name", "") or row.get("blockname", ""))
code = str(row.get("code", "") or row.get("blockcode", ""))
if not code or not name:
continue
items.append(
{
"code": code,
"name": name,
"level": None,
"changePercent": None,
"changeAmount": None,
"mainNetInflow": 0,
"mainNetInflowPercent": None,
"superLargeInflow": None,
"superLargeInflowPercent": None,
"largeInflow": None,
"largeInflowPercent": None,
"mediumInflow": None,
"mediumInflowPercent": None,
"smallInflow": None,
"smallInflowPercent": None,
"turnover": 0,
}
)
return items if items else None
async def fetch_sector_list(sector_type: str) -> Optional[List[dict]]:
"""获取板块列表(东方财富的降级方案,仅含代码和名称)"""
try:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _sync_fetch_sectors, sector_type)
except ImportError:
return None
except Exception as e:
print(f"[mootdx] fetch_sectors error: {e}")
return None
+58
View File
@@ -257,6 +257,64 @@ async def fetch_history(code: str, days: int = 90) -> List[dict]:
return []
async def fetch_history_minute(code: str, period: str = "60", count: int = 320) -> List[dict]:
"""获取分钟级 K 线(腾讯 mkline 接口)
period: m1/m5/m15/m30/m60(60=小时线)
返回格式与日K一致,date 为 'YYYY-MM-DD HH:MM'(北京时间)。
"""
market = get_market_prefix(code)
stock_code = f"{market}{code}"
# 腾讯 mkline:param=代码,周期,,数量
url = f"https://ifzq.gtimg.cn/appstock/app/kline/mkline?param={stock_code},{period},,{count}"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://stockapp.finance.qq.com/",
}
async with httpx.AsyncClient() as client:
try:
resp = await client.get(url, headers=headers, timeout=10)
if resp.status_code != 200:
return []
data = resp.json()
stock_data = data.get("data", {}).get(stock_code, {})
raw = stock_data.get(period) or []
if not isinstance(raw, list):
return []
result = []
prev_close = 0
for record in raw:
if not isinstance(record, list) or len(record) < 6:
continue
# record[0] = 'YYYYMMDDHHMM',转成 'YYYY-MM-DD HH:MM'
raw_dt = str(record[0])
try:
dt_str = f"{raw_dt[0:4]}-{raw_dt[4:6]}-{raw_dt[6:8]} {raw_dt[8:10]}:{raw_dt[10:12]}"
except Exception:
continue
close = float(record[2]) if record[2] else 0
change_pct = 0
if prev_close > 0:
change_pct = (close - prev_close) / prev_close * 100
result.append({
"date": dt_str,
"open": float(record[1]) if record[1] else 0,
"close": close,
"high": float(record[3]) if record[3] else 0,
"low": float(record[4]) if record[4] else 0,
"volume": int(float(record[5])) if record[5] else 0,
"changePercent": round(change_pct, 2),
})
prev_close = close
# 丢弃最旧1条(prev_close=0 涨跌幅失真)
if len(result) > 1:
result = result[1:]
return result
except Exception:
return []
async def fetch_kline_map(code: str, days: int = 30) -> dict:
"""获取K线数据并返回 { date: { close, changePercent, turnover } } 映射"""
market = get_market_prefix(code)
+93 -23
View File
@@ -30,12 +30,23 @@ _PZ_CDN_URL = "https://emcfgdata.securities.eastmoney.com"
_APP_KEY_INDEX = "rn-themeIndex"
_APP_KEY_DETAIL = "rn-themeDetail"
# 完整浏览器请求头:生产实测东财对缺 sec-* 头的移动端包装结构请求会 403,
# 补齐真实 Chrome 头 + client="web" 后正常返回
_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Origin": "https://emrnweb.eastmoney.com",
"Referer": "https://emrnweb.eastmoney.com/",
"Accept": "application/json",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Content-Type": "application/json;charset=UTF-8",
"DNT": "1",
"Origin": "https://emrnweb.eastmoney.com",
"Priority": "u=1, i",
"Referer": "https://emrnweb.eastmoney.com/",
"Sec-Ch-Ua": '"Not=A?Brand";v="99", "Google Chrome";v="151", "Chromium";v="151"',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"macOS"',
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
}
# ---- 交易时段感知缓存 ----
@@ -77,9 +88,14 @@ def _next_open_delta_seconds() -> int:
return 0
# 盘中题材列表短缓存:题材热点页与热点穿透聚合共用同一份缓存,避免重复拉全量列表打东财。
# 120s 长于热点穿透图缓存(60s),图重建时必然命中且更新频率更低,两页数据更稳。
_LIST_CACHE_SECONDS = 120
def _list_ttl_seconds() -> int:
"""题材列表缓存秒数:交易时段 0(不缓存、实时拉取);非交易时段缓存到下次开盘前失效"""
return 0 if _is_trading_time() else _next_open_delta_seconds()
"""题材列表缓存秒数:交易时段 120s 短缓存;非交易时段缓存到下次开盘前失效"""
return _LIST_CACHE_SECONDS if _is_trading_time() else _next_open_delta_seconds()
# ---- 请求封装 ----
@@ -89,7 +105,7 @@ def _build_payload(args: Optional[dict] = None, app_key: str = _APP_KEY_INDEX) -
return {
"args": args or {},
"appKey": app_key,
"client": "iOS",
"client": "web", # 生产实测 iOS client 会 403,web + 完整浏览器头正常
"clientVersion": "8.3",
"clientType": "cfw",
"randomCode": "".join(random.choices(string.ascii_uppercase + string.ascii_lowercase + string.digits, k=16)),
@@ -148,8 +164,7 @@ async def fetch_theme_list(sort_field: int = 1, asc: bool = False) -> list[dict]
asc: True=升序, False=降序
"""
cache_key = f"theme_list:{sort_field}:{asc}"
# 交易时段强制实时:跳过缓存读取,避免命中非交易时段写入的上个交易日旧数据
if not _is_trading_time():
# 统一读缓存(盘中 TTL=120s 短缓存,非盘中缓存到下次开盘前失效),避免重复拉全量列表打东财
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
@@ -293,7 +308,7 @@ async def _build_theme_graph(sort_field: int, top_n: int) -> dict:
Args:
sort_field: 题材排序 1=涨幅, 4=热度(当前榜在前,另一榜合并补充)
top_n: 每个榜单的题材数量(1-60)
top_n: 每个榜单的题材数量(1-200)
Returns:
{
@@ -359,7 +374,7 @@ async def _build_theme_graph(sort_field: int, top_n: int) -> dict:
return {
"themes": [
{"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"]}
{"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"], "bf3": t.get("bf3")}
for t in theme_map.values()
],
"stocks": stocks,
@@ -399,34 +414,89 @@ async def _rebuild_task(cache_key: str, sort_field: int, top_n: int, lock: async
async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1000) -> dict:
"""获取热点穿透图数据(盘中 60s 缓存 + stale-while-revalidate)
"""获取热点穿透图数据(盘中 60s 缓存,盘中过期同步重建,非盘中 stale-while-revalidate)
缓存命中且未过期 → 直接返回;已过期 → 返回旧数据并后台异步重建(秒开);
无缓存 → 同步构建(并发下加锁去重)。返回前按 limit 裁剪 stocks。
缓存新鲜 → 直接返回;
非交易时段过期 → 返回旧数据并后台异步重建(秒开,非盘中行情无实时变化,旧值可接受);
交易时段过期 / 无缓存 → 同步重建(加锁去重),绝不返回上个交易日的旧图。
Args:
sort_field: 题材排序 1=涨幅, 4=热度
top_n: 每个榜单的题材数量(1-60)
top_n: 每个榜单的题材数量(1-200)
limit: 下发 stocks 上限(穿透度最高的 N 只)
"""
cache_key = f"theme_graph:{sort_field}:{top_n}"
data, expires_at = _get_graph_cache(cache_key)
if data is not None:
# 有缓存:新鲜直接返回;过期返回旧数据并后台刷新
if not (expires_at and expires_at > time.time()):
if data is not None and expires_at and expires_at > time.time():
# 缓存新鲜 → 直接返回
return _trim_graph_result(data, limit)
# 非交易时段过期:行情无实时变化,先返回旧值(秒开),后台异步重建
if data is not None and not _is_trading_time():
_spawn_rebuild(cache_key, sort_field, top_n)
return _trim_graph_result(data, limit)
# 无缓存:同步构建(并发下加锁去重)
# 交易时段过期 / 无缓存:同步重建,加锁去重
lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock())
async with lock:
data, expires_at = _get_graph_cache(cache_key)
if data is not None:
# 等待锁期间已被其他请求写入
if not (expires_at and expires_at > time.time()):
_spawn_rebuild(cache_key, sort_field, top_n)
if data is not None and expires_at and expires_at > time.time():
# 等待锁期间已被其他请求刷新
return _trim_graph_result(data, limit)
data = await _build_theme_graph(sort_field, top_n)
_set_graph_cache(cache_key, data)
return _trim_graph_result(data, limit)
# ---- 题材相关新闻(分页) ----
_NEWS_PAGE_SIZE = 10
async def fetch_theme_news(theme_code: str, page_num: int = 1, max_eu_time: str = "", page_size: int = _NEWS_PAGE_SIZE) -> Optional[dict]:
"""获取题材相关新闻(分页),返回 {total, maxEuTime, list}
maxEuTime 为游标:上一页返回的 maxEuTime 作为下一页入参,首页传空串。
盘中 60s 短缓存(与图缓存同频);非盘中缓存到下次开盘前失效。
"""
cache_key = f"theme_news:{theme_code}:{page_num}:{max_eu_time}:{page_size}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
data = await _post(
"/api/themeInvest/getThemeRelatedNews",
{"themeCode": theme_code, "pageNum": page_num, "maxEuTime": max_eu_time, "pageSize": page_size},
app_key=_APP_KEY_DETAIL,
)
if not data:
return None
ttl_s = _graph_ttl_seconds()
if ttl_s > 0:
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
return data
# ---- 单题材实时行情(强度/热度/涨幅) ----
async def fetch_theme_quote(theme_code: str) -> Optional[dict]:
"""获取单题材实时行情(strengthValue/hotValue/f3),盘中 60s 短缓存"""
cache_key = f"theme_quote:{theme_code}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
data = await _post(
"/api/themeInvest/getSingleThemeQuote",
{"themeCode": theme_code},
app_key=_APP_KEY_DETAIL,
)
if not data:
return None
ttl_s = _graph_ttl_seconds()
if ttl_s > 0:
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
return data
+2
View File
@@ -1,5 +1,7 @@
# 数据接口 & 数据源一览
> 📖 同花顺官方金融数据 API 能力整理见 [hithink-financial-api.md](./hithink-financial-api.md)
## 架构概览
```
+188
View File
@@ -0,0 +1,188 @@
# 同花顺金融数据 API(HiThink Financial API)接口能力整理
> 数据源参考:https://github.com/HiThink-Tech/Financial-API · 文档:https://fuyao.aicubes.cn/docs/quickstart/
> 本文档整理官方 REST / MCP / CLI / Python / 本地库 各接入方式的能力清单,供后续开发评估使用。
## 一句话概览
同花顺官方维护的 **A 股金融数据服务**,面向 AI Agent、量化研究和应用开发者。
一个 API Key 即可访问数据,提供 **六种接入方式**:REST API、托管 MCP、Node.js CLI、Python SDK、
本地 DuckDB 数据库(marketdb)和 Agent Skill。MIT 许可,约 1.9k stars。
**支持**:A股股票、指数与板块、公募基金(含 ETF/LOF)、衍生特色数据(涨跌停/连板/异动/热榜/龙虎榜)、全市场数据导出。
**暂不支持**:期货、分钟K、tick、海外市场、宏观数据、新闻公告原文、研报原文。
---
## 认证与调用
| 项目 | 内容 |
|------|------|
| Base URL | `https://fuyao.aicubes.cn` |
| 认证 | 请求头 `X-api-key: <your-key>`(与同花顺账号绑定) |
| Key 获取 | 官网注册 → 「API Key 管理」创建;关闭弹窗后无法再看完整 Key,需妥善保存 |
| 响应信封 | `{ code, message, request_id, data }`,`code=0` 表示成功(HTTP 恒为 200) |
| 环境变量 | `HITHINK_FINANCE_API_KEY` |
| 常见错误 | `2001` Key 缺失/无效;`2003` 无权限需开通 |
**Agent 集成(推荐)**:`npx skills add HiThink-Tech/Financial-API --skill hithink-finance -g --yes`
Agent 会在 API / MCP / CLI / Python 间自动选择合适方式,并对大结果自动落盘。
---
## 接入方式一览
| 方式 | 说明 |
|------|------|
| **REST API** | `GET` + `X-api-key`,最通用 |
| **MCP** | 4 个服务端点,供 AI Agent 直接调用 |
| **Node.js CLI** | npm 包 `@hithink-tech/hithink-finance-cli` |
| **Python SDK** | `pip install -e ./python` |
| **本地 DuckDB** | `marketdb` 本地库,支持增量同步 + SQL + 复权 |
| **Agent Skill** | 一键安装,自动选择最优接入 |
---
## REST API 能力清单(GET + X-api-key)
### 基础 / 元信息
| 接口路径 | 功能 | 主要参数 |
|----------|------|---------|
| `/api/meta/tickers/search` | 跨市场标的检索(代码/名称/拼音) | `q`*、`exchange`、`asset_type`、`limit`(≤50) |
| `/api/meta/tickers/list` | 分页获取代码表 | `asset_type`、`limit`(≤10000)、`offset` |
### A股行情
| 接口路径 | 功能 | 主要参数 |
|----------|------|---------|
| `/api/a-share/prices/snapshot` | 行情快照(单只/多只/全市场) | `thscodes`、`limit`、`offset` |
| `/api/a-share/prices/historical` | 单只标的日K(窗口≤10年) | `thscode`*、`interval`(1d)、`start`*、`end`*、`adjust`(none/forward/backward)、`offset` |
| `/api/a-share/auction/snapshot` | 集合竞价快照 | `thscodes`*、`stage`(live/final) |
| `/api/a-share/auction/short-term-benchmark` | 短线风向标竞价基准 | `date`(yyyy-MM-dd) |
| `/api/a-share/calendar/trading-days` | 近一年交易日序列 | 无 |
### A股财务 / 复权 / 估值
| 接口路径 | 功能 | 主要参数 |
|----------|------|---------|
| `/api/a-share/financials/income-statements` | 合并利润表多期序列 | `thscode`*、`period`(annual/quarterly)*、`limit`(1-20) 或 `start`+`end` |
| `/api/a-share/financials/balance-sheets` | 合并资产负债表 | 同上 |
| `/api/a-share/financials/cash-flow-statements` | 合并现金流量表 | 同上 |
| `/api/a-share/financials/indicators` | 五类财务指标 | `thscode`*、`report`*(yyyy-1~yyyy-4) |
| `/api/a-share/corporate-actions/adjustment-factors` | 分红/送股/配股事件流 | `thscode`*、`from`、`to` |
| `/api/a-share/valuations/snapshot` | 估值快照(PE TTM/MRQ、PB、PS、PCF) | 批量查询 |
### 指数与板块
| 接口路径 | 功能 | 主要参数 |
|----------|------|---------|
| `/api/a-share-index/catalog/ths-index-list` | 同花顺指数清单 | `tag`(cn_concept/region/tszs/industry) |
| `/api/a-share-index/constituents/ths-stock-list` | 指数成分股 | `thscode`* |
| `/api/a-share-index/prices/snapshot` | 指数行情快照 | `thscodes`* |
| `/api/a-share-index/prices/historical` | 指数历史K线(无复权参数) | `thscode`*、`interval`、`start`*、`end`* |
### 特色数据(`/api/a-share/special-data/…`)
| 接口路径后缀 | 功能 |
|------|------|
| `limit-up-pool` | 涨停池(涨停/连板股) |
| `limit-down-pool` | 跌停池 |
| `limit-break-pool` | 涨停炸板池 |
| `limit-up-ladder` | 近30日连板天梯 |
| `skyrocket-list` | 热度飙升榜 Top30(日榜/小时榜) |
| `hot-stock-list` | A股热股榜 Top30(24h/小时) |
| `hot-stock-list-history` | 按自然日历史热股排行 |
| `hot-stock-rank-trend` | 单股热榜排名走势 |
| `anomaly-analysis-list` | 当日个股异动原因列表(`tag_codes` 如 `LIMIT_UP,SHARP_FALL`)※仅 REST |
| `anomaly-analysis-stock` | 按股票批量查异动原因(`thscodes`* ≤50) |
| `dragon-tiger-list` | 龙虎榜(`board_type` all/org/hot_money、`date`) |
### 公募基金(21 项,仅列核心)
| 接口路径 | 功能 |
|----------|------|
| `/api/fund/profile/**` | 基金基本资料 |
| `/api/fund/portfolio/holdings` | 定期披露重仓持仓 |
| `/api/fund/performance/nav` / `returns` / `indicators-historical` | 净值 / 区间收益与同类排名 / 历史业绩指标 |
| `/api/fund/holders/detail` / `top` | 持有人结构 / 前十大持有人 |
| `/api/fund/corporate-actions/dividends` | 基金分红记录 |
| `/api/fund/managers/investment-style` / `performance` / `experience` / `detail` | 经理投资风格 / 业绩 / 经历 / 详情 |
| `/api/fund/companies/detail` | 基金公司详情 |
| `/api/fund/diagnostics/detail` | 基金诊断 |
| `/api/fund/offerings/list` | 新发基金募集列表 |
| `/api/fund/news/article-list` | 基金资讯(游标分页) |
| `/api/fund/financials/indicators` / `income-statements` / `balance-sheets` | 基金财务指标 / 利润表 / 资产负债表 |
| `/api/fund/market/snapshot` / `historical` | ETF 行情快照 / 历史日线(窗口≤5年) |
### 全市场数据导出(Market Dumps)
| 接口路径 | 功能 |
|----------|------|
| `/api/dump/market-dumps/daily-k/download-url` | 全市场 10 年日K Parquet 下载链接 |
| `/api/dump/market-dumps/daily-k-10d/download-url` | 最近 10 交易日日K Parquet |
| `/api/dump/market-dumps/adjustment-factors/download-url` | 全量复权因子 Parquet |
---
## MCP 服务端点(4 个)
| 服务 | 端点 |
|------|------|
| `hithink-finance-a-share` | `/mcp/a-share` |
| `hithink-finance-a-share-index` | `/mcp/a-share-index` |
| `hithink-finance-meta` | `/mcp/meta` |
| `hithink-finance-fund` | `/mcp/fund` |
鉴权:环境变量 `API_KEY` 注入,与 REST 同一 Key。
---
## CLI(`@hithink-tech/hithink-finance-cli`)
| 命令 | 功能 |
|------|------|
| `auth login` | 录入 API Key |
| `capabilities` | 能力目录 |
| `symbol search --q <代码>` | 标的检索 |
| `market snapshot --thscodes 600519.SH` | 行情快照 |
| `financials income --thscode ... --limit 4` | 利润表 |
| `data init` / `db query --sql "..."` | 初始化本地库 / SQL 查询(视图如 `v_daily_qfq`) |
---
## Python SDK 与本地 DuckDB
```bash
pip install -e ./python
python python/bootstrap.py # 初始化本地 DuckDB(marketdb)
```
常用脚本:`fuyao.py tickers-search --q "贵州茅台"`、`fuyao.py prices-snapshot ...`
marketdb 支持增量同步、SQL 查询、复权计算、数据导出。
---
## 合规要求
- 输出需注明数据源、时间范围与复权口径,标注"非投资建议"
- 真实数据不可用时不得用模拟数据冒充
- API Key 不可写入代码、日志或 Git 仓库
---
## 与我们现有系统的潜在结合点(供后续评估)
> 现有数据源依赖腾讯/东方财富/新浪/AkShare,均为非官方抓取,存在限流与反爬问题。
> 同花顺官方 API 可作**更稳定、结构化的权威数据源**,或补充新能力。
| 现有模块 | 同花顺对应能力 | 潜在价值 |
|----------|--------------|---------|
| 实时行情 `/api/stock/quote`(腾讯) | `a-share/prices/snapshot` | 官方行情快照,避免腾讯兼容性/抓取风险 |
| 历史K线 `/api/stock/history`(东财/腾讯) | `a-share/prices/historical`(10年、复权可选) | 权威日K + 明确复权口径,替代被反爬的东财 kline |
| 财务指标 `/api/stock/financial`(东财) | `a-share/financials/*` + `valuations/snapshot` | 官方三大报表、五类指标、估值快照 |
| 题材热点 / 热点穿透(东财) | `a-share/special-data/*` 热榜、涨停池、连板天梯 | 官方热榜/涨停/连板数据,可增强题材热度判断 |
| 核心股追踪 / 每日采集 | `a-share/special-data/dragon-tiger-list`、`hot-stock-*` | 龙虎榜、热股榜可作为核心股采样的补充来源 |
| 新增能力 | `a-share-index/*`(指数/板块成分)、`fund/*`(基金)、Market Dumps(全市场导出) | 指数行情、基金筛选、本地 marketdb 全市场回测 |
> 注意:特色数据(涨停池等)tag_codes 语义、thscode 与现有 code 体系的映射,接入前需先做字段对齐验证。
@@ -0,0 +1,704 @@
# 每日核心股/题材历史 + 活跃核心股滚动表格 — 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 每日收盘后采集核心股前100(按涨幅)+所属题材、题材涨幅前20存历史,并提供「活跃核心股×最近10个A股交易日」涨幅矩阵展示页。
**Architecture:** 后端新增 asyncio 后台定时采集任务(`lifespan` 启动),复用现有 `services/themes.py` 的 `fetch_theme_list`/`fetch_theme_graph` 拿数据,写入 3 张新表;新增 `GET /api/core-stocks/active` 接口计算活跃窗口;前端新增 `/core-stocks` 路由渲染股票×10日涨幅矩阵。
**Tech Stack:** Python 3.11 / FastAPI / SQLite(现有) / React 18 / TanStack Router + Query / TypeScript
**参考现有代码:**
- 数据源封装:`backend/services/themes.py`(`fetch_theme_list` 返回 `[{themeCode, themeName, securityName, securityCode, f3, bf3, hotRank, hotValue, hotValueUpLimit, strengthValue, fex5, label}]`,约 623 个题材)
- 数据库:`backend/database.py`(`SCHEMA_SQL` + `init_db()` + `get_connection()`)
- 路由范式:`backend/routes/shares.py`(`get_connection()` + `dict_from_row`)
- 前端 API 范式:`src/lib/theme-api.ts`(`getApiBaseUrl()` + `fetch`)
- 前端页面范式:`src/routes/themes.tsx`(顶栏 + 卡片网格 + React Query)
- 涨跌配色:红涨绿跌 `text-red-500` / `text-green-500`
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `backend/database.py` | 修改 | 追加 3 张表 DDL |
| `backend/services/daily_collector.py` | 新建 | 后台定时采集 + 幂等入库 |
| `backend/routes/core_stocks.py` | 新建 | `/api/core-stocks/active` 等历史接口 |
| `backend/main.py` | 修改 | lifespan 启动采集任务 + 挂载路由 |
| `src/lib/core-stock-api.ts` | 新建 | 前端 API 客户端 |
| `src/routes/core-stocks.tsx` | 新建 | 展示页 |
| `src/routes/themes.tsx` | 修改 | 加入口链接 |
| `src/routes/hot-map.tsx` | 修改 | 加入口链接 |
| `backend/verify_collector.py` | 新建(临时) | 验证脚本,跑完删除 |
---
### Task 1: 数据库 — 追加 3 张表
**Files:**
- Modify: `backend/database.py`(在 `SCHEMA_SQL` 末尾追加)
- [ ] **Step 1: 修改 `SCHEMA_SQL`,追加建表语句**
在 `backend/database.py` 的 `SCHEMA_SQL` 字符串中,`cache` 表定义后追加:
```python
CREATE TABLE IF NOT EXISTS daily_core_stocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
stock_name TEXT NOT NULL,
f3 REAL,
cover_count INTEGER,
rank INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code)
);
CREATE TABLE IF NOT EXISTS daily_core_stock_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code, theme_code)
);
CREATE TABLE IF NOT EXISTS daily_top_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
bf3 REAL,
hot_rank INTEGER,
rank INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, theme_code)
);
```
- [ ] **Step 2: 运行验证建表**
```bash
cd backend && ./venv/bin/python -c "from database import init_db; init_db(); from database import get_connection; c=get_connection(); t=[r['name'] for r in c.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'daily_%'\")]; print(t); c.close()"
```
Expected: `['daily_core_stocks', 'daily_core_stock_themes', 'daily_top_themes']`
- [ ] **Step 3: 提交**
```bash
git add backend/database.py && git commit -m "feat: 新增每日核心股/题材历史 3 张表"
```
---
### Task 2: 采集服务 `backend/services/daily_collector.py`
**Files:**
- Create: `backend/services/daily_collector.py`
- [ ] **Step 1: 新建采集服务**
```python
"""每日热点数据采集:核心股前100 + 题材涨幅前20,收盘后自动入库
由 main.lifespan 启动后台任务;幂等(按交易日 UNIQUE 去重)。
"""
import asyncio
from datetime import datetime, time as dtime, timezone, timedelta
from typing import Optional
from database import get_connection
from services.themes import fetch_theme_list
_CST = timezone(timedelta(hours=8))
# 每天采集的后台任务:每 CHECK_INTERVAL 分钟检查一次
CHECK_INTERVAL_SECONDS = 300
COLLECT_AFTER_TIME = dtime(15, 0) # 收盘后 15:00 开始允许采集
CORE_STOCK_LIMIT = 100 # 核心股前100
TOP_THEME_LIMIT = 20 # 题材涨幅前20
def _is_trading_day(d: datetime) -> bool:
"""周一至周五视为交易日(与 themes._is_trading_time 一致,不处理法定节假日)"""
return d.weekday() < 5
def _has_collected(trade_date: str) -> bool:
"""当日核心股是否已采集"""
conn = get_connection()
try:
row = conn.execute(
"SELECT 1 FROM daily_core_stocks WHERE trade_date = ? LIMIT 1",
(trade_date,),
).fetchone()
return row is not None
finally:
conn.close()
async def collect_daily(trade_date: str, dry_run: bool = False) -> dict:
"""采集指定交易日数据并入库。
Args:
trade_date: YYYY-MM-DD
dry_run: True 只打印不写库(用于验证)
Returns:
{"core_count": int, "theme_count": int, "skipped": bool}
"""
if _has_collected(trade_date):
print(f"[collector] {trade_date} 已采集,跳过")
return {"core_count": 0, "theme_count": 0, "skipped": True}
# 1. 拉取全部题材列表(含领涨股,作为当日全部股票的采样来源)
themes = await fetch_theme_list(1, False)
if not themes:
print(f"[collector] {trade_date} 题材列表为空(东财失败),跳过")
return {"core_count": 0, "theme_count": 0, "skipped": True}
# 2. 核心股前100:按领涨股 f3 降序,去重(同一股票可能是多个题材领涨股)
stock_map: dict[str, dict] = {}
for t in themes:
code = t.get("securityCode")
if not code:
continue
if code not in stock_map or (t.get("f3") or 0) > (stock_map[code].get("f3") or 0):
stock_map[code] = {
"stock_code": code,
"stock_name": t.get("securityName", ""),
"f3": t.get("f3"),
}
core_stocks = sorted(
stock_map.values(), key=lambda x: -(x["f3"] or 0)
)[:_CORE_STOCK_LIMIT]
# 3. 题材涨幅前20:bf3 降序
top_themes = sorted(themes, key=lambda x: -(x.get("bf3") or 0))[:_TOP_THEME_LIMIT]
if dry_run:
print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)} 只")
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
# 4. 入库(事务,UNIQUE 幂等)
conn = get_connection()
try:
for i, s in enumerate(core_stocks, start=1):
conn.execute(
"INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, rank) VALUES (?,?,?,?,?)",
(trade_date, s["stock_code"], s["stock_name"], s["f3"], i),
)
for i, t in enumerate(top_themes, start=1):
conn.execute(
"INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)",
(trade_date, t["themeCode"], t["themeName"], t.get("bf3"), t.get("hotRank"), i),
)
conn.commit()
finally:
conn.close()
print(f"[collector] {trade_date} 已采集:核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)} 只")
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
"""后台循环:每个交易日 15:00 后自动采集当日数据(幂等)"""
while True:
try:
now = datetime.now(_CST)
if _is_trading_day(now) and now.time() >= COLLECT_AFTER_TIME:
trade_date = now.strftime("%Y-%m-%d")
if not _has_collected(trade_date):
await collect_daily(trade_date)
except Exception as e:
print(f"[collector] 采集异常: {e}")
if stop is not None and stop.is_set():
break
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
```
> 注:上面代码中 `_CORE_STOCK_LIMIT` 应为 `CORE_STOCK_LIMIT`(变量名一致),下面 Step 2 统一修正。
- [ ] **Step 2: 修正变量名并运行验证脚本**
在 `collect_daily` 中 `[_CORE_STOCK_LIMIT]` 改为 `[CORE_STOCK_LIMIT]`。
验证(dry_run 不写库):
```bash
cd backend && ./venv/bin/python -c "
import asyncio, sys
sys.path.insert(0, '.')
from services.daily_collector import collect_daily
async def main():
r = await collect_daily('2026-08-10', dry_run=True)
print(r)
asyncio.run(main())
"
```
Expected: 输出核心股数量(几十只)与题材数 10,`skipped: False`(首次)。
- [ ] **Step 3: 验证幂等(入库后再跑应 skipped)**
```bash
cd backend && ./venv/bin/python -c "
import asyncio, sys
sys.path.insert(0, '.')
from services.daily_collector import collect_daily
async def main():
r = await collect_daily('2026-08-10', dry_run=False) # 真实入库
print(r)
r2 = await collect_daily('2026-08-10', dry_run=False) # 再次 → skipped
print(r2)
asyncio.run(main())
"
```
Expected: 第一次入库,第二次 `skipped: True`。
- [ ] **Step 4: 提交**
```bash
git add backend/services/daily_collector.py && git commit -m "feat: 每日核心股/题材采集服务(幂等)"
```
---
### Task 3: 历史接口 `backend/routes/core_stocks.py`
**Files:**
- Create: `backend/routes/core_stocks.py`
- [ ] **Step 1: 新建路由**
```python
"""核心股历史接口:活跃核心股 + 指定日核心股/题材前20"""
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from database import get_connection, dict_from_row
router = APIRouter()
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
def _recent_trade_dates(conn, n: int = 10) -> list[str]:
"""最近 n 个有数据的交易日(升序)"""
rows = conn.execute(
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
(n,),
).fetchall()
return [r["trade_date"] for r in reversed(rows)]
@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵")
async def active_core_stocks():
conn = get_connection()
try:
dates = _recent_trade_dates(conn, 10)
if not dates:
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
# 窗口内出现过且最近一次出现距今天数 <= 10 个交易日
placeholders = ",".join("?" * len(dates))
rows = conn.execute(
f"""SELECT trade_date, stock_code, stock_name, f3 FROM daily_core_stocks
WHERE trade_date IN ({placeholders})
ORDER BY trade_date DESC, rank ASC""",
dates,
).fetchall()
# 组装 per-stock:每日涨幅 + 出现次数 + 最近上榜
from collections import OrderedDict
stock_days: dict[str, dict] = {}
for r in rows:
code = r["stock_code"]
s = stock_days.setdefault(code, {
"stockCode": code,
"stockName": r["stock_name"],
"dailyGains": {},
"appearCount": 0,
"lastAppear": None,
})
s["dailyGains"][r["trade_date"]] = r["f3"]
s["appearCount"] += 1
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["appearCount"], -(x.get("lastAppear") or "")))
return JSONResponse({"dates": dates, "stocks": stocks}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/history", summary="指定交易日核心股(含所属题材)")
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC", (date,)
).fetchall()
items = []
for r in rows:
d = dict_from_row(r)
themes = conn.execute(
"SELECT theme_code, theme_name FROM daily_core_stock_themes WHERE trade_date = ? AND stock_code = ?",
(date, d["stock_code"]),
).fetchall()
d["themes"] = [dict(t) for t in themes]
items.append(d)
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
```
- [ ] **Step 2: 验证 active 接口(用 Task 2 入库的数据)**
```bash
cd backend && ./venv/bin/python -c "
import sys; sys.path.insert(0, '.')
from routes.core_stocks import active_core_stocks
import asyncio
async def main():
r = await active_core_stocks()
print('dates:', r.body[:200] if hasattr(r,'body') else r)
asyncio.run(main())
"
```
> 上面直接调函数拿的是 JSONResponse,验证方式见 Step 3(直接查库验证逻辑更直观)。
- [ ] **Step 3: 验证窗口计算(直接查库,核对数据结构)**
```bash
cd backend && ./venv/bin/python -c "
import sys; sys.path.insert(0, '.')
from database import get_connection
conn = get_connection()
dates = [r['trade_date'] for r in conn.execute('SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT 10').fetchall()]
print('最近日期:', dates)
rows = conn.execute('SELECT COUNT(*) AS n FROM daily_core_stocks').fetchone()
print('总记录:', rows['n'])
conn.close()
"
```
Expected: 打印最近日期列表(1 个日期)和总记录数(与核心股数一致)。
- [ ] **Step 4: 提交**
```bash
git add backend/routes/core_stocks.py && git commit -m "feat: 核心股历史/活跃接口"
```
---
### Task 4: 挂载路由 + 启动采集任务 `backend/main.py`
**Files:**
- Modify: `backend/main.py`
- [ ] **Step 1: 修改 main.py**
在 `from routes import ...` 加 `core_stocks`,lifespan 里启动采集任务,注册路由:
```python
from routes import stock, collections, shares, sectors, themes, core_stocks
from services.daily_collector import collector_loop
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
task = asyncio.create_task(collector_loop())
try:
yield
finally:
task.cancel()
```
并在 `app.include_router` 区加:
```python
app.include_router(core_stocks.router, prefix="/api/core-stocks")
```
> 注意:需确保 `import asyncio` 在文件顶部。lifespan 里 `yield` 前启动任务,`finally` 里 cancel,符合 FastAPI 生命周期。
- [ ] **Step 2: 语法检查**
```bash
cd backend && ./venv/bin/python -c "import ast; ast.parse(open('main.py').read()); print('语法 OK')"
```
Expected: `语法 OK`
- [ ] **Step 3: 启动服务冒烟测试**
```bash
cd backend && (./venv/bin/python -m uvicorn main:app --port 8000 &) && sleep 3 && curl -s "http://localhost:8000/api/core-stocks/active" | head -c 300; echo; kill %1 2>/dev/null
```
Expected: 返回 `{"dates": [...], "stocks": [...]}` JSON(至少含 Task 2 入库的当日数据)。
- [ ] **Step 4: 提交**
```bash
git add backend/main.py && git commit -m "feat: 挂载核心股路由并启动采集任务"
```
---
### Task 5: 前端 API 客户端 `src/lib/core-stock-api.ts`
**Files:**
- Create: `src/lib/core-stock-api.ts`
- [ ] **Step 1: 新建 API 客户端**
```typescript
// 核心股历史数据获取工具
import { getApiBaseUrl } from "@/lib/api-client";
export interface ActiveCoreStock {
stockCode: string;
stockName: string;
dailyGains: Record<string, number>; // 日期 -> 当日涨幅
appearCount: number;
lastAppear: string | null;
}
export interface ActiveCoreStocksResponse {
dates: string[];
stocks: ActiveCoreStock[];
}
export interface CoreStockHistoryItem {
id: number;
trade_date: string;
stock_code: string;
stock_name: string;
f3: number | null;
cover_count: number | null;
rank: number;
themes: { theme_code: string; theme_name: string }[];
}
export interface CoreStockHistoryResponse {
date: string;
items: CoreStockHistoryItem[];
}
/** 获取活跃核心股 + 最近10日涨幅矩阵 */
export async function fetchActiveCoreStocks(): Promise<ActiveCoreStocksResponse> {
const baseUrl = getApiBaseUrl();
const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" });
if (!resp.ok) return { dates: [], stocks: [] };
return resp.json();
}
/** 获取指定交易日核心股(含所属题材) */
export async function fetchCoreStockHistory(date: string): Promise<CoreStockHistoryResponse | null> {
const baseUrl = getApiBaseUrl();
const resp = await fetch(`${baseUrl}/api/core-stocks/history?date=${date}`, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
return resp.json();
}
```
- [ ] **Step 2: 提交**
```bash
git add src/lib/core-stock-api.ts && git commit -m "feat: 核心股历史前端 API 客户端"
```
---
### Task 6: 展示页 `src/routes/core-stocks.tsx`
**Files:**
- Create: `src/routes/core-stocks.tsx`
- [ ] **Step 1: 新建展示页**
```tsx
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
export const Route = createFileRoute("/core-stocks")({
component: CoreStocksPage,
});
/** 格式化涨幅,红涨绿跌 */
function formatGain(v: number | null | undefined): string {
if (v == null) return "·";
const s = v > 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
return s;
}
function CoreStocksPage() {
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ["core-stocks", "active"],
queryFn: fetchActiveCoreStocks,
staleTime: 60_000,
retry: false,
});
const dates = data?.dates ?? [];
const stocks = data?.stocks ?? [];
return (
<div className="min-h-screen bg-background">
{/* 顶栏 */}
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/hot-map" className="hover:opacity-70 transition-opacity">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-base font-semibold">核心股追踪</h1>
</div>
<button
onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
</button>
</div>
</header>
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
<p className="text-[10px] text-muted-foreground mb-2">
活跃核心股(最近 10 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只
</p>
{isLoading ? (
<div className="animate-pulse rounded-xl bg-muted h-32" />
) : dates.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-16">
暂无数据,数据将在每日收盘后自动采集
</div>
) : (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium whitespace-nowrap">股票</th>
{dates.map((d) => (
<th key={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
{d.slice(5)}
</th>
))}
<th className="px-2 py-2 text-right font-medium">上榜</th>
</tr>
</thead>
<tbody>
{stocks.map((s) => (
<tr key={s.stockCode} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-3 py-1.5 whitespace-nowrap">
<span className="font-medium">{s.stockName}</span>
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span>
</td>
{dates.map((d) => {
const g = s.dailyGains[d];
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
return (
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
{formatGain(g)}
</td>
);
})}
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
<span className="inline-flex items-center gap-0.5 text-orange-500">
<Flame className="h-3 w-3" />
{s.appearCount}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: 提交**
```bash
git add src/routes/core-stocks.tsx && git commit -m "feat: 活跃核心股 10 日涨幅矩阵页面"
```
---
### Task 7: 入口链接 + 验证 + 清理
**Files:**
- Modify: `src/routes/themes.tsx`
- Modify: `src/routes/hot-map.tsx`
- Delete(临时): `backend/verify_collector.py`
- [ ] **Step 1: 在题材页顶栏加「核心股」入口**
`src/routes/themes.tsx` 顶栏,在「热点穿透」链接旁加:
```tsx
<Link to="/core-stocks" className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity">
<Flame className="h-3.5 w-3.5" />
核心股
</Link>
```
需在 import 区加 `Flame`(若已从 `lucide-react` 引入则复用)。同时 `Link to="/core-stocks"` 需要路由存在(Task 6 已建)。
- [ ] **Step 2: 在热点穿透页顶栏加「核心股」入口**
`src/routes/hot-map.tsx` 顶栏加同类链接(参考 Step 1)。
- [ ] **Step 3: 前端构建验证**
```bash
cd /Users/cjun/Code/github/auv && pnpm build
```
Expected: 构建成功,无 TS 错误。若报 `Link to="/core-stocks"` 类型错误,确认路由文件 `core-stocks.tsx` 的 `createFileRoute` 路径与 `to` 一致。
- [ ] **Step 4: 清理临时验证文件**
```bash
rm -f backend/verify_collector.py
```
- [ ] **Step 5: 提交**
```bash
git add src/routes/themes.tsx src/routes/hot-map.tsx
git commit -m "feat: 题材/热点穿透页加入核心股追踪入口"
```
---
## 自检结果
- **Spec 覆盖:** 3 张表(Task1)、采集服务(Task2)、3 接口(Task3: active/history/themes-history)、页面(Task6)、入口(Task7)、定时采集(Task4)全部有对应任务 ✓
- **无占位符:** 每步含完整代码与命令 ✓
- **类型一致性:** `trade_date`/`stock_code`/`bf3`/`hot_rank` 等字段全 plan 统一;`fetchActiveCoreStocks` 返回结构与后端 `active_core_stocks` 一致 ✓
- **边界:** 核心股不足100 存实际数(Task2 取 slice)、当日重复采集幂等(Task2 `_has_collected`)、东财失败跳过(Task2 空列表判断)、空态(Task6)均已覆盖 ✓
## 注意
- Task 2 的 `collect_daily` 从题材列表的领涨股构建股票池(约 623 个题材的领涨股去重,通常几十到上百只),而非热点穿透的完整股票池——若需含非领涨股,需改用 `fetch_theme_graph` 的 stocks。当前实现符合"全部股票按涨幅取前100"的目标口径(题材领涨股 + 去重)。
@@ -0,0 +1,156 @@
# 每日热点核心股 / 题材历史记录 + 活跃核心股滚动表格
日期:2026-08-10
## 1. 目标
为数据分析和发掘积累历史数据,并提供活跃核心股的滚动展示:
1. 每个交易日收盘后,采集**核心股前 100**(当日全部股票按涨幅降序取前 100)及其**所属题材**,保存历史。
2. 每个交易日收盘后,采集**题材(概念)板块涨幅前 20**,保存历史。
3. 新展示页:**活跃核心股 × 最近 10 个 A 股交易日**涨幅矩阵表格;新核心股加入,**超过 10 个 A 股交易日未出现则踢出**。
## 2. 已确认的决策
| 决策点 | 选择 |
|---|---|
| 核心股口径 | 当日全部股票按涨幅(f3)降序取前 100(不限覆盖题材数) |
| 板块口径 | 题材/概念板块,按涨幅(bf3)降序取前 20 |
| 采集触发 | 方案 A:asyncio 后台任务,每日收盘后自动采集 |
| 表格布局 | 股票 × 最近 10 个 A 股交易日列矩阵 |
| 10 日窗口 | 10 个 A 股交易日(跳过节假/周末) |
| 所属题材完整度 | 折中:以热点穿透采样题材为主,东财压力允许时尽力补全 |
## 3. 数据模型(新增 3 张表)
```sql
CREATE TABLE daily_core_stocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL, -- 交易日 YYYY-MM-DD
stock_code TEXT NOT NULL, -- 股票代码
stock_name TEXT NOT NULL, -- 股票名称
f3 REAL, -- 当日涨幅%
cover_count INTEGER, -- 覆盖题材数(采样)
rank INTEGER, -- 当日涨幅排名 1-100
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code)
);
CREATE TABLE daily_core_stock_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code, theme_code)
);
CREATE TABLE daily_top_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
bf3 REAL, -- 题材涨幅%
hot_rank INTEGER, -- 热度排名
rank INTEGER, -- 当日板块涨幅排名 1-20
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, theme_code)
);
```
## 4. 采集服务 `backend/services/daily_collector.py`
### 4.1 触发机制(方案 A)
- `lifespan` 启动时拉起一个 asyncio 后台任务协程。
- 协程循环(如每 5 分钟检查一次):
- 是否**交易日**(周一至周五,非节假日)。
- 是否**收盘后**(北京时间 > 15:00)。
- 当日数据是否**已采集**(按 `trade_date` 查库,幂等去重)。
- 条件满足 → 触发采集。
### 4.2 采集逻辑
1. **核心股前 100**:调用 `fetch_theme_list(1, False)` 或 `fetch_theme_graph` 得到当日全部股票,按 `f3` 降序取前 100。若不足 100 只,存实际数量。
2. **所属题材**:以热点穿透采样的 `themeCodes` 为主存入 `daily_core_stock_themes`。
3. **题材前 20**:从 `fetch_theme_list(1, False)` 取 `bf3` 降序前 20,连同 `themeName`/`hotRank` 存入 `daily_top_themes`。
4. 同一交易日重复触发不重复写入(UNIQUE 去重 + 检查)。
5. 采集失败(东财 403/网络)→ 跳过当日,下轮重试;记录日志。
### 4.3 补全(折中方案)
- 东财压力允许时,对核心股调用个股题材接口尽力补全。
- 优先保证核心股前 100 与题材前 20 的完整性,补全为附加增强,失败不影响主流程。
## 5. 新接口(`backend/routes/`)
| 接口 | 说明 |
|---|---|
| `GET /api/core-stocks/active` | 活跃核心股 + 最近 10 日涨幅矩阵 |
| `GET /api/core-stocks/history?date=` | 指定交易日的核心股(含所属题材) |
| `GET /api/themes/history?date=` | 指定交易日的题材前 20 |
### active 接口返回结构
```json
{
"dates": ["2026-08-03", "...", "2026-08-10"],
"stocks": [
{
"stockCode": "601606",
"stockName": "长城军工",
"coverCount": 9,
"lastAppear": "2026-08-10",
"daysSinceLastAppear": 0,
"appearCount": 5,
"dailyGains": { "2026-08-03": 10.0, "2026-08-10": 10.0 }
}
]
}
```
- `dates`:最近 10 个 A 股交易日(升序,最右为最新)。
- `stocks`:10 日窗口内出现过的活跃核心股。
- `dailyGains`:日期 → 当日涨幅;未上榜日无该键。
## 6. 新展示页(前端 `/core-stocks`)
### 6.1 页面结构
- 顶栏:返回、标题「核心股追踪」、刷新按钮(与 `/hot-map` 一致风格)。
- 表格:**股票 × 最近 10 个 A 股交易日**列矩阵。
- 行:活跃核心股(10 个 A 股交易日内出现过),按 `appearCount` 降序、`lastAppear` 降序排列。
- 列:最近 10 个 A 股交易日,最右为最新。
- 单元格:当日涨幅(红涨绿跌,A 股惯例);未上榜留空(`·`)。
- 每只股票显示累计出现次数、最近上榜日期、所属题材数。
### 6.2 数据获取
- 前端 `useQuery` 调 `GET /api/core-stocks/active`。
- `staleTime` 与题材页一致(30s 或 60s),盘中可手动刷新。
### 6.3 路由
- 新建 `src/routes/core-stocks.tsx`,路由 `/core-stocks`。
- 从题材页 `/themes` 和热点穿透页 `/hot-map` 顶部加入口链接。
## 7. 错误处理与边界
- **东财 403/采集失败**:跳过当日采集,下轮重试;不影响已存历史。
- **当日重复采集**:UNIQUE 约束 + 入库前检查,幂等。
- **核心股不足 100**:存实际数量,不补齐。
- **无历史数据**:部署后开始累积;active 接口在无数据时返回空 `stocks` 与空 `dates`。
- **交易日历**:以自然周一到周五判定交易日(不处理法定节假日调休的深度历法),与现有 `_is_trading_time` 一致。
## 8. 测试
- 采集服务:幂等(重复触发不重复写)、去重、失败重试逻辑。
- active 接口:窗口计算、踢出规则(>10 个交易日未出现不返回)、涨幅矩阵正确性。
- 前端页面:空态、有数据态、10 日窗口滚动。
## 9. 范围外(YAGNI)
- 不做法定的深度交易日历(节假日调休)。
- 不做个股涨幅的增量更新(历史数据一次性采集,之后不补更)。
- 不做板块成分股的每日存储。
+12 -4
View File
@@ -24,14 +24,14 @@
<script>
// 在 React 加载前立即设置主题,避免闪烁
(function() {
const isInIframe = window.self !== window.top;
function applyThemeToDOM(theme) {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(theme);
document.documentElement.setAttribute('data-theme', theme);
}
var isInIframe = window.self !== window.top;
if (isInIframe) {
// 监听父窗口主动推送的主题消息
window.addEventListener('message', function(event) {
@@ -43,8 +43,16 @@
}
});
} else {
// 非 iframe 环境,使用默认 light 主题
applyThemeToDOM('light');
// 从 localStorage 读取用户选择
var saved = localStorage.getItem('theme');
var theme;
if (saved === 'light' || saved === 'dark') {
theme = saved;
} else {
// system: 跟随系统偏好
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
applyThemeToDOM(theme);
}
})();
</script>
+4
View File
@@ -49,13 +49,17 @@
"embla-carousel-react": "^8.6.0",
"framer-motion": "^11.18.2",
"input-otp": "^1.4.2",
"lightweight-charts": "^5.2.1",
"lucide-react": "^0.575.0",
"mermaid": "^11.17.2",
"react": "^19.2.7",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.7",
"react-hook-form": "^7.81.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.1",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2",
+1575 -16
View File
File diff suppressed because it is too large Load Diff
Executable
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# 本地直接拉起后端(不用 docker)
# 用法: ./run_local.sh # 默认 0.0.0.0:8000
# PORT=8010 ./run_local.sh # 指定端口
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${ROOT}/backend"
PORT="${PORT:-8000}"
PY="${PY:-${ROOT}/.venv/bin/python}"
# 同步前端 dist(若后端 dist 落后于仓库根 dist)
if [ -d "${ROOT}/dist" ]; then
mkdir -p dist
cp -r "${ROOT}/dist/." dist/
echo "✓ 已同步前端 dist → backend/dist"
fi
echo "启动后端: http://0.0.0.0:${PORT} (python: ${PY})"
exec "${PY}" -m uvicorn main:app --host 0.0.0.0 --port "${PORT}" --log-level info
+152
View File
@@ -0,0 +1,152 @@
# 热点股追踪数据导出/导入工具
## 功能说明
本工具用于导出和导入热点股追踪的历史数据,包括:
- 每日题材涨幅前20
- 每日核心股票(覆盖多个题材的股票)
- 每日核心股票与题材的关联关系
## 快速使用(Docker环境)
### 1. 导出8月20日的数据
```bash
./scripts/docker_hotspot.sh export 2026-08-20
```
### 2. 导入数据
```bash
# 导入数据到容器
./scripts/docker_hotspot.sh import hotspot_2026-08-20.json
```
### 3. 查看数据库中的日期
```bash
./scripts/docker_hotspot.sh list
```
### 4. 备份整个数据库
```bash
./scripts/docker_hotspot.sh backup
```
### 5. 进入Python shell
```bash
./scripts/docker_hotspot.sh shell
```
## 本地开发环境
如果在本地开发环境中,可以使用以下命令:
```bash
# 导出数据
python3 scripts/export_hotspot_data.py export --date 2026-08-20
# 导入数据
python3 scripts/export_hotspot_data.py import --file hotspot_2026-08-20.json
# 列出日期
python3 scripts/export_hotspot_data.py list
```
## 文件说明
### Docker脚本 (`docker_hotspot.sh`)
- `export [日期]`: 从容器导出数据(默认8月20日)
- `import <文件>`: 导入数据文件到容器
- `list`: 列出所有日期
- `backup`: 备份整个数据库
- `shell`: 进入容器的Python shell
### Python脚本 (`export_hotspot_data.py`)
- `export --date YYYY-MM-DD`: 导出指定日期的数据
- `import --file <文件路径>`: 导入数据文件
- `list`: 列出数据库中所有有数据的日期
### Shell脚本 (`hotspot_backup.sh`)
- `export [日期]`: 导出数据(默认8月20日)
- `import <文件>`: 导入数据文件
- `list`: 列出所有日期
- `backup`: 备份整个数据库
## 数据格式
导出的JSON文件包含以下字段:
```json
{
"version": "1.0",
"trade_date": "2026-08-11",
"themes": [...], // 题材数据
"core_stocks": [...], // 核心股票数据
"stock_themes": [...], // 股票-题材关联
"stats": {
"theme_count": 20,
"core_stock_count": 193,
"stock_theme_count": 1205
}
}
```
## 注意事项
1. **容器运行**:使用Docker脚本前,请确保容器正在运行
2. **幂等性**:导入时使用 `INSERT OR IGNORE`,重复导入不会产生重复数据
3. **数据完整性**:导入前会检查目标日期是否已有数据
4. **备份建议**:导入前建议先备份数据库
5. **日期格式**:日期格式必须为 `YYYY-MM-DD`(如 `2026-08-20`)
## 常见问题
### Q: 容器未运行怎么办?
A: 启动容器:
```bash
docker-compose up -d
```
### Q: 如何查看容器是否运行?
A: 运行以下命令:
```bash
docker ps | grep auv
```
### Q: 如何手动采集8月20日的数据?
A: 在容器内运行:
```bash
docker exec -it auv python3 -c "
import asyncio
from services.daily_collector import collect_daily
asyncio.run(collect_daily('2026-08-20'))
"
```
### Q: 如何查看数据库中的日期?
A: 运行 `./scripts/docker_hotspot.sh list`
### Q: 如何备份数据库?
A: 运行 `./scripts/docker_hotspot.sh backup`
### Q: 导入时出现错误怎么办?
A: 检查JSON文件格式是否正确,确保包含必要的字段。可以使用以下命令验证JSON:
```bash
python3 -m json.tool hotspot_2026-08-20.json
```
+236
View File
@@ -0,0 +1,236 @@
#!/bin/bash
# 从Docker容器导出热点股数据
set -e
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
CONTAINER_NAME="auv"
DB_PATH="/app/backend/data/stock_data.db"
# 检查容器是否运行
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo -e "${RED}❌ 容器 ${CONTAINER_NAME} 未运行${NC}"
exit 1
fi
# 检查数据库是否存在
if ! docker exec "$CONTAINER_NAME" test -f "$DB_PATH"; then
echo -e "${RED}❌ 数据库文件不存在:$DB_PATH${NC}"
exit 1
fi
# 显示帮助
show_help() {
echo "用法:"
echo " $0 export [日期] - 导出指定日期的数据(默认:2026-08-20)"
echo " $0 import <文件> - 导入数据文件到容器"
echo " $0 list - 列出所有有数据的日期"
echo " $0 backup - 备份整个数据库"
echo " $0 shell - 进入容器的Python shell"
echo ""
echo "示例:"
echo " $0 export 2026-08-20"
echo " $0 import hotspot_2026-08-20.json"
echo " $0 backup"
}
# 导出数据
export_data() {
local date="${1:-2026-08-20}"
local output_file="hotspot_${date}.json"
echo -e "${YELLOW}📤 从容器导出 $date 的热点股数据...${NC}"
# 在容器内执行导出命令
docker exec "$CONTAINER_NAME" python3 -c "
import sys
sys.path.insert(0, '/app')
from database import get_connection, dict_from_row
import json
date = '$date'
conn = get_connection()
try:
themes = conn.execute('SELECT * FROM daily_top_themes WHERE trade_date = ? ORDER BY rank ASC', (date,)).fetchall()
core_stocks = conn.execute('SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC', (date,)).fetchall()
stock_themes = conn.execute('SELECT * FROM daily_core_stock_themes WHERE trade_date = ?', (date,)).fetchall()
data = {
'version': '1.0',
'trade_date': date,
'themes': [dict_from_row(r) for r in themes],
'core_stocks': [dict_from_row(r) for r in core_stocks],
'stock_themes': [dict_from_row(r) for r in stock_themes],
}
data['stats'] = {
'theme_count': len(data['themes']),
'core_stock_count': len(data['core_stocks']),
'stock_theme_count': len(data['stock_themes']),
}
print(json.dumps(data, ensure_ascii=False, indent=2))
finally:
conn.close()
" > "$output_file"
if [ -f "$output_file" ]; then
echo -e "${GREEN}✅ 导出完成:$output_file${NC}"
echo "文件大小:$(du -h "$output_file" | cut -f1)"
else
echo -e "${RED}❌ 导出失败${NC}"
exit 1
fi
}
# 导入数据
import_data() {
local file="$1"
if [ -z "$file" ]; then
echo -e "${RED}❌ 请指定要导入的文件${NC}"
exit 1
fi
if [ ! -f "$file" ]; then
echo -e "${RED}❌ 文件不存在:$file${NC}"
exit 1
fi
echo -e "${YELLOW}📥 导入数据到容器:$file${NC}"
# 复制文件到容器
docker cp "$file" "$CONTAINER_NAME:/tmp/import_data.json"
# 在容器内执行导入
docker exec "$CONTAINER_NAME" python3 -c "
import sys
import json
sys.path.insert(0, '/app')
from database import get_connection
with open('/tmp/import_data.json', 'r', encoding='utf-8') as f:
data = json.load(f)
date = data['trade_date']
print(f'📅 导入日期:{date}')
print(f' 题材数量:{len(data[\"themes\"])}')
print(f' 核心股票:{len(data[\"core_stocks\"])}')
print(f' 股票-题材关联:{len(data[\"stock_themes\"])}')
conn = get_connection()
try:
for item in data['themes']:
item.pop('id', None)
item.pop('created_at', None)
conn.execute(
'INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)',
(item['trade_date'], item['theme_code'], item['theme_name'], item['bf3'], item['hot_rank'], item['rank']),
)
for item in data['core_stocks']:
item.pop('id', None)
item.pop('created_at', None)
conn.execute(
'INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, cover_count, rank) VALUES (?,?,?,?,?,?)',
(item['trade_date'], item['stock_code'], item['stock_name'], item['f3'], item['cover_count'], item['rank']),
)
for item in data['stock_themes']:
item.pop('id', None)
item.pop('created_at', None)
conn.execute(
'INSERT OR IGNORE INTO daily_core_stock_themes (trade_date, stock_code, theme_code, theme_name) VALUES (?,?,?,?)',
(item['trade_date'], item['stock_code'], item['theme_code'], item['theme_name']),
)
conn.commit()
print(f'\\n✅ 导入成功:{date}')
except Exception as e:
conn.rollback()
print(f'\\n❌ 导入失败:{e}')
finally:
conn.close()
"
}
# 列出日期
list_dates() {
echo -e "${YELLOW}📅 列出容器中的日期...${NC}"
docker exec "$CONTAINER_NAME" python3 -c "
import sys
sys.path.insert(0, '/app')
from database import get_connection
conn = get_connection()
try:
rows = conn.execute('SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC').fetchall()
if rows:
print('📅 数据库中的日期:')
for row in rows:
print(f' {row[\"trade_date\"]}')
else:
print('📭 数据库中暂无数据')
finally:
conn.close()
"
}
# 备份数据库
backup_database() {
local timestamp=$(date +%Y%m%d_%H%M%S)
local backup_file="stock_data_${timestamp}.db"
echo -e "${YELLOW}💾 备份数据库...${NC}"
docker cp "$CONTAINER_NAME:$DB_PATH" "$backup_file"
if [ -f "$backup_file" ]; then
echo -e "${GREEN}✅ 备份完成:$backup_file${NC}"
echo "文件大小:$(du -h "$backup_file" | cut -f1)"
else
echo -e "${RED}❌ 备份失败${NC}"
exit 1
fi
}
# 进入Python shell
enter_shell() {
echo -e "${YELLOW}🐍 进入容器Python shell...${NC}"
docker exec -it "$CONTAINER_NAME" python3 -c "
import sys
sys.path.insert(0, '/app')
from database import get_connection
print('Python shell 已启动')
print('可用变量:')
print(' conn - 数据库连接')
print(' get_connection - 获取新连接函数')
print()
conn = get_connection()
"
}
# 主逻辑
case "${1:-help}" in
export)
export_data "$2"
;;
import)
import_data "$2"
;;
list)
list_dates
;;
backup)
backup_database
;;
shell)
enter_shell
;;
*)
show_help
;;
esac
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""热点股追踪数据导出/导入脚本
用法:
导出8月20日数据:
python scripts/export_hotspot_data.py export --date 2026-08-20
导入数据:
python scripts/export_hotspot_data.py import --file hotspot_2026-08-20.json
查看数据库中有哪些日期的数据:
python scripts/export_hotspot_data.py list
"""
import argparse
import json
import os
import sys
from datetime import datetime
from typing import Optional
# 添加项目路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
from database import get_connection, dict_from_row
def export_data(date: str, output_file: Optional[str] = None) -> dict:
"""导出指定日期的热点股追踪数据"""
conn = get_connection()
try:
# 1. 导出每日题材前20
themes = conn.execute(
"SELECT * FROM daily_top_themes WHERE trade_date = ? ORDER BY rank ASC",
(date,),
).fetchall()
# 2. 导出每日核心股票
core_stocks = conn.execute(
"SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC",
(date,),
).fetchall()
# 3. 导出每日核心股票与题材的关联
stock_themes = conn.execute(
"SELECT * FROM daily_core_stock_themes WHERE trade_date = ?",
(date,),
).fetchall()
data = {
"version": "1.0",
"export_time": datetime.now().isoformat(),
"trade_date": date,
"themes": [dict_from_row(r) for r in themes],
"core_stocks": [dict_from_row(r) for r in core_stocks],
"stock_themes": [dict_from_row(r) for r in stock_themes],
}
# 统计信息
data["stats"] = {
"theme_count": len(data["themes"]),
"core_stock_count": len(data["core_stocks"]),
"stock_theme_count": len(data["stock_themes"]),
}
# 保存到文件
if output_file is None:
output_file = f"hotspot_{date}.json"
with open(output_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"✅ 导出成功:{output_file}")
print(f" 题材数量:{data['stats']['theme_count']}")
print(f" 核心股票:{data['stats']['core_stock_count']}")
print(f" 股票-题材关联:{data['stats']['stock_theme_count']}")
return data
finally:
conn.close()
def import_data(input_file: str, dry_run: bool = False) -> bool:
"""导入热点股追踪数据"""
if not os.path.exists(input_file):
print(f"❌ 文件不存在:{input_file}")
return False
with open(input_file, "r", encoding="utf-8") as f:
data = json.load(f)
# 验证数据格式
required_keys = ["version", "trade_date", "themes", "core_stocks", "stock_themes"]
if not all(k in data for k in required_keys):
print("❌ 数据格式错误:缺少必要字段")
return False
date = data["trade_date"]
print(f"📅 导入日期:{date}")
print(f" 题材数量:{len(data['themes'])}")
print(f" 核心股票:{len(data['core_stocks'])}")
print(f" 股票-题材关联:{len(data['stock_themes'])}")
if dry_run:
print("\n🔍 试运行模式,不写入数据库")
return True
# 检查是否已存在
conn = get_connection()
try:
existing = conn.execute(
"SELECT 1 FROM daily_core_stocks WHERE trade_date = ? LIMIT 1",
(date,),
).fetchone()
if existing:
print(f"\n⚠️ {date} 的数据已存在,将跳过重复数据(使用 INSERT OR IGNORE)")
finally:
conn.close()
# 写入数据库
conn = get_connection()
try:
# 导入每日题材前20
for item in data["themes"]:
# 移除自动生成的id和created_at
item.pop("id", None)
item.pop("created_at", None)
conn.execute(
"INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)",
(item["trade_date"], item["theme_code"], item["theme_name"], item["bf3"], item["hot_rank"], item["rank"]),
)
# 导入每日核心股票
for item in data["core_stocks"]:
# 移除自动生成的id和created_at
item.pop("id", None)
item.pop("created_at", None)
conn.execute(
"INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, cover_count, rank) VALUES (?,?,?,?,?,?)",
(item["trade_date"], item["stock_code"], item["stock_name"], item["f3"], item["cover_count"], item["rank"]),
)
# 导入每日核心股票与题材的关联
for item in data["stock_themes"]:
# 移除自动生成的id和created_at
item.pop("id", None)
item.pop("created_at", None)
conn.execute(
"INSERT OR IGNORE INTO daily_core_stock_themes (trade_date, stock_code, theme_code, theme_name) VALUES (?,?,?,?)",
(item["trade_date"], item["stock_code"], item["theme_code"], item["theme_name"]),
)
conn.commit()
print(f"\n✅ 导入成功:{date}")
return True
except Exception as e:
conn.rollback()
print(f"\n❌ 导入失败:{e}")
return False
finally:
conn.close()
def list_dates() -> list[str]:
"""列出数据库中所有有数据的日期"""
conn = get_connection()
try:
rows = conn.execute(
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC"
).fetchall()
return [row["trade_date"] for row in rows]
finally:
conn.close()
def main():
parser = argparse.ArgumentParser(description="热点股追踪数据导出/导入工具")
subparsers = parser.add_subparsers(dest="command", help="可用命令")
# 导出命令
export_parser = subparsers.add_parser("export", help="导出数据")
export_parser.add_argument("--date", required=True, help="交易日期 (YYYY-MM-DD)")
export_parser.add_argument("--output", "-o", help="输出文件路径")
# 导入命令
import_parser = subparsers.add_parser("import", help="导入数据")
import_parser.add_argument("--file", "-f", required=True, help="输入文件路径")
import_parser.add_argument("--dry-run", action="store_true", help="试运行,不写入数据库")
# 列表命令
subparsers.add_parser("list", help="列出所有有数据的日期")
args = parser.parse_args()
if args.command == "export":
export_data(args.date, args.output)
elif args.command == "import":
import_data(args.file, args.dry_run)
elif args.command == "list":
dates = list_dates()
if dates:
print("📅 数据库中的日期:")
for d in dates:
print(f" {d}")
else:
print("📭 数据库中暂无数据")
else:
parser.print_help()
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
#!/bin/bash
# 热点股追踪数据导出/导入快捷脚本
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
PYTHON_SCRIPT="$SCRIPT_DIR/export_hotspot_data.py"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
show_help() {
echo "用法:"
echo " $0 export [日期] - 导出指定日期的数据(默认:2026-08-20)"
echo " $0 import <文件> - 导入数据文件"
echo " $0 list - 列出所有有数据的日期"
echo " $0 backup - 备份整个数据库"
echo ""
echo "示例:"
echo " $0 export 2026-08-20"
echo " $0 import hotspot_2026-08-20.json"
echo " $0 backup"
}
export_data() {
local date="${1:-2026-08-20}"
local output_file="hotspot_${date}.json"
echo -e "${YELLOW}📤 导出 $date 的热点股数据...${NC}"
python3 "$PYTHON_SCRIPT" export --date "$date" --output "$output_file"
if [ -f "$output_file" ]; then
echo -e "${GREEN}✅ 导出完成:$output_file${NC}"
echo "文件大小:$(du -h "$output_file" | cut -f1)"
else
echo -e "${RED}❌ 导出失败${NC}"
exit 1
fi
}
import_data() {
local file="$1"
if [ -z "$file" ]; then
echo -e "${RED}❌ 请指定要导入的文件${NC}"
exit 1
fi
if [ ! -f "$file" ]; then
echo -e "${RED}❌ 文件不存在:$file${NC}"
exit 1
fi
echo -e "${YELLOW}📥 导入数据:$file${NC}"
python3 "$PYTHON_SCRIPT" import --file "$file"
}
list_dates() {
echo -e "${YELLOW}📅 列出数据库中的日期...${NC}"
python3 "$PYTHON_SCRIPT" list
}
backup_database() {
local db_path="$PROJECT_DIR/backend/data/stock_data.db"
local backup_dir="$PROJECT_DIR/backups"
local timestamp=$(date +%Y%m%d_%H%M%S)
local backup_file="$backup_dir/stock_data_${timestamp}.db"
if [ ! -f "$db_path" ]; then
echo -e "${RED}❌ 数据库文件不存在:$db_path${NC}"
exit 1
fi
# 创建备份目录
mkdir -p "$backup_dir"
# 复制数据库
cp "$db_path" "$backup_file"
if [ -f "$backup_file" ]; then
echo -e "${GREEN}✅ 数据库备份完成:$backup_file${NC}"
echo "文件大小:$(du -h "$backup_file" | cut -f1)"
else
echo -e "${RED}❌ 备份失败${NC}"
exit 1
fi
}
# 主逻辑
case "${1:-help}" in
export)
export_data "$2"
;;
import)
import_data "$2"
;;
list)
list_dates
;;
backup)
backup_database
;;
*)
show_help
;;
esac
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from "react";
import mermaid from "mermaid";
mermaid.initialize({
startOnLoad: false,
theme: "default",
themeVariables: {
primaryColor: "#f0f0f0",
primaryTextColor: "#333333",
primaryBorderColor: "#cccccc",
lineColor: "#3366cc",
secondaryColor: "#e8e8e8",
tertiaryColor: "#ffffff",
fontFamily: "inherit",
fontSize: "14px",
},
});
interface MermaidProps {
chart: string;
}
export function Mermaid({ chart }: MermaidProps) {
const ref = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (!ref.current) return;
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
mermaid
.render(id, chart)
.then(({ svg }) => setSvg(svg))
.catch((err) => setError(err.message || "图表渲染失败"));
}, [chart]);
if (error) {
return (
<pre className="bg-muted border border-destructive/30 rounded p-3 text-sm text-destructive overflow-x-auto">
{error}
</pre>
);
}
return (
<div
ref={ref}
className="bg-muted rounded p-3 overflow-x-auto my-2 flex justify-center [&>svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useState, useRef, useEffect } from "react";
import { Sun, Moon, Monitor } from "lucide-react";
import { useTheme } from "../lib/use-theme";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [expanded, setExpanded] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!expanded) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setExpanded(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [expanded]);
const options = [
{ value: "light" as const, icon: Sun, label: "浅色" },
{ value: "dark" as const, icon: Moon, label: "深色" },
{ value: "system" as const, icon: Monitor, label: "自动" },
];
if (!expanded) {
const CurrentIcon = options.find(o => o.value === theme)!.icon;
return (
<div ref={ref}>
<button
onClick={() => setExpanded(true)}
title="切换主题"
className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted text-muted-foreground hover:text-foreground shadow-sm transition-colors"
>
<CurrentIcon className="h-4 w-4" />
</button>
</div>
);
}
return (
<div ref={ref} className="flex items-center bg-muted rounded-lg p-0.5 gap-0.5 shadow-sm">
{options.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => { setTheme(value); setExpanded(false); }}
title={label}
className={`flex items-center justify-center w-8 h-8 rounded-md text-sm transition-colors ${
theme === value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Icon className="h-4 w-4" />
</button>
))}
</div>
);
}
+243
View File
@@ -0,0 +1,243 @@
/**
* K 线图卡片:独立组件,自带周期(日/60分/...)、显示方式(折线/蜡烛)、
* 指标开关(MA/MACD/RSI)状态与数据加载逻辑,与页面其他模块解耦。
* 行情就绪(stockInfo 就绪)后自动拉取;切换周期只重拉 K 线,不影响页面其它数据。
*/
import { useEffect, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import KLineChart from "@/components/kline-chart";
import {
fetchStockHistoryV2,
} from "@/lib/fuyao-api";
import { fetchStockHistoryMinute } from "@/lib/stock-api";
type ChartPeriod = "d" | "m60" | "m30" | "m15" | "m5" | "m1";
// 分钟周期标签映射
const PERIOD_LABEL: Record<"m1" | "m5" | "m15" | "m30" | "m60", string> = {
m1: "1分",
m5: "5分",
m15: "15分",
m30: "30分",
m60: "60分",
};
interface KBarInput {
date: string;
dateMs: number; // Unix 秒(lightweight-charts UTCTimestamp)
open: number;
close: number;
high: number;
low: number;
volume: number;
isAddedDate: boolean;
}
interface Props {
code: string;
addedAt: string; // 自选日期 ISO,用于标记K线
/** 行情是否就绪:就绪后才开始拉 K 线 */
ready: boolean;
}
export function KLineCard({ code, addedAt, ready }: Props) {
const [chartPeriod, setChartPeriod] = useState<ChartPeriod>("d");
const [chartMode, setChartMode] = useState<"line" | "candle">("candle");
const [indicators, setIndicators] = useState({ ma: true, macd: false, rsi: false });
const toggleIndicator = (key: keyof typeof indicators) =>
setIndicators((prev) => ({ ...prev, [key]: !prev[key] }));
const [dailyData, setDailyData] = useState<KBarInput[]>([]);
const [minuteData, setMinuteData] = useState<KBarInput[]>([]);
const [chartLoading, setChartLoading] = useState(false);
const [minuteLoading, setMinuteLoading] = useState(false);
const toggleMs = useMemo(() => new Date(addedAt).getTime(), [addedAt]);
// KLineData → KBarInput(含 isAddedDate 标记,非交易日标最近一根)
// 时间统一按 UTC 解析(wall-clock 视作 UTC),lightweight-charts 以 UTC 渲染,
// 这样日线标签不偏移、分钟线显示的正是北京时间。
const toBars = (
klines: Array<{ date: string; open: number; close: number; high: number; low: number; volume: number }>,
): KBarInput[] => {
const bars: KBarInput[] = klines.map((k) => {
const iso = k.date.includes(" ")
? k.date.replace(" ", "T") + ":00Z" // "2026-05-08 13:30" → 2026-05-08T13:30:00Z
: k.date + "T00:00:00Z"; // "2026-05-08" → 2026-05-08T00:00:00Z
const ms = new Date(iso).getTime();
const dateMs = Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
return {
date: k.date,
dateMs,
open: k.open,
close: k.close,
high: k.high,
low: k.low,
volume: k.volume,
isAddedDate: new Date(ms).toDateString() === new Date(toggleMs).toDateString(),
};
});
if (!bars.some((b) => b.isAddedDate) && bars.length > 0) {
let closestIdx = 0;
let closestDiff = Infinity;
bars.forEach((b, i) => {
const diff = Math.abs(b.dateMs * 1000 - toggleMs);
if (diff < closestDiff) {
closestDiff = diff;
closestIdx = i;
}
});
bars[closestIdx] = { ...bars[closestIdx], isAddedDate: true };
}
return bars;
};
// 拉日K
const loadDaily = async () => {
setChartLoading(true);
try {
const data = await fetchStockHistoryV2(code, 365);
if (data && data.length > 0) setDailyData(toBars(data));
} catch (err) {
console.error("[kline-card] 拉取日K失败:", err);
} finally {
setChartLoading(false);
}
};
// 拉分钟/小时K
const loadMinute = async (period: Exclude<ChartPeriod, "d">) => {
setMinuteLoading(true);
try {
const data = await fetchStockHistoryMinute(code, period, 320);
if (data && data.length > 0) setMinuteData(toBars(data));
else setMinuteData([]);
} catch (err) {
console.error("[kline-card] 拉取分钟K失败:", err);
setMinuteData([]);
} finally {
setMinuteLoading(false);
}
};
// 行情就绪后拉当前周期的K线;切换周期只重拉K线,不动页面其它数据
useEffect(() => {
if (!ready) return;
if (chartPeriod === "d") {
if (dailyData.length > 0) return; // 已加载过,复用
loadDaily();
} else {
setMinuteData([]); // 清空上一个周期旧数据,避免串显
loadMinute(chartPeriod);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chartPeriod, ready, code]);
// 展示数据:日线直接用,分钟线用 minuteData
const displayData = chartPeriod === "d" ? dailyData : minuteData;
const currentLoading = chartPeriod === "d" ? chartLoading : minuteLoading;
return (
<Card className="shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-base md:text-lg">
K线图
<span className="text-xs md:text-sm font-normal text-muted-foreground ml-2">
{currentLoading ? "(加载中...)" : (
chartPeriod === "d"
? `(${displayData.length}个交易日)`
: `(${displayData.length}根${PERIOD_LABEL[chartPeriod]}K线)`
)}
</span>
</CardTitle>
<div className="flex items-center gap-1.5 flex-wrap">
{/* 显示方式切换:折线 / 蜡烛 */}
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{(["line", "candle"] as const).map((m) => (
<button
key={m}
onClick={() => setChartMode(m)}
className={`px-2.5 py-1 transition-colors ${
chartMode === m ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{m === "line" ? "折线" : "蜡烛"}
</button>
))}
</div>
{/* 指标开关:MA / MACD / RSI */}
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{([
{ key: "ma", label: "MA" },
{ key: "macd", label: "MACD" },
{ key: "rsi", label: "RSI" },
] as const).map((it) => (
<button
key={it.key}
onClick={() => toggleIndicator(it.key)}
className={`px-2.5 py-1 transition-colors ${
indicators[it.key] ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{it.label}
</button>
))}
</div>
{/* K线周期:日 / 60分 / 30分 / 15分 / 5分 / 1分 */}
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{([
{ key: "d" as const, label: "日" },
{ key: "m60" as const, label: "60分" },
{ key: "m30" as const, label: "30分" },
{ key: "m15" as const, label: "15分" },
{ key: "m5" as const, label: "5分" },
{ key: "m1" as const, label: "1分" },
] as const).map((p) => (
<button
key={p.key}
onClick={() => setChartPeriod(p.key)}
className={`px-2 py-1 transition-colors ${
chartPeriod === p.key ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{p.label}
</button>
))}
</div>
<span className="text-xs text-muted-foreground hidden sm:inline">
双指缩放 · 拖动查看
</span>
</div>
</div>
</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="flex items-center text-muted-foreground text-sm">
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
K线数据加载中...
</div>
</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,
}))}
mode={chartMode}
hasAddedDate={ready}
indicators={indicators}
/>
)}
</CardContent>
</Card>
);
}
export default KLineCard;
+347
View File
@@ -0,0 +1,347 @@
/**
* K 线图组件(基于 TradingView Lightweight Charts v5)
* 主图:蜡烛/折线 + MA(5/10/20/30);
* 副图:成交量(pane1)、MACD(pane2)、RSI(pane3),可开关。
*/
import { useEffect, useRef } from "react";
import {
createChart,
CandlestickSeries,
LineSeries,
HistogramSeries,
ColorType,
CrosshairMode,
LineStyle,
createSeriesMarkers,
type IChartApi,
type ISeriesApi,
type ISeriesApi as ISeriesAny,
type Time,
} from "lightweight-charts";
import { sma, macd as calcMacd, rsi as calcRsi, type KBar } from "@/lib/indicators";
export interface KLineItem {
time: string | number; // ISO 日期 yyyy-mm-dd 或 Unix seconds UTC(lightweight-charts 两者均支持)
open: number;
close: number;
high: number;
low: number;
volume: number;
isAddedDate?: boolean;
}
export interface IndicatorToggles {
ma: boolean;
macd: boolean;
rsi: boolean;
}
interface Props {
data: KLineItem[];
mode: "line" | "candle";
hasAddedDate?: boolean;
indicators: IndicatorToggles;
}
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
const UP = "#ef4444";
const DOWN = "#22c55e";
// 成交量柱:浅色调,避免与 K 线柱体视觉争夺
const VOL_UP = "#fca5a5";
const VOL_DOWN = "#86efac";
const PRIMARY = "#ef4444";
const TEXT = "#71717a";
const GRID = "#e4e4e7";
const MARKER = "#f59e0b";
// MA / 指标线配色
const MA_COLORS = ["#f59e0b", "#3b82f6", "#a855f7"];
const MA_PERIODS = [5, 10, 20];
const MACD_DIF = "#3b82f6";
const MACD_DEA = "#f59e0b";
const RSI_COLOR = "#a855f7";
export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
// 指标 series(重建用)
const maSeriesRef = useRef<ISeriesAny<"Line">[]>([]);
const macdSeriesRef = useRef<ISeriesAny<"Line" | "Histogram">[]>([]);
const rsiSeriesRef = useRef<ISeriesAny<"Line">[]>([]);
// 容器高度随指标 pane 数量增长,避免主图被压缩
const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length;
// 固定图表高度:主图+成交量 300,每个指标副图 +95
const chartHeight = 300 + extraPanes * 95;
// 创建图表(仅一次):pane0 主图 + pane1 成交量
useEffect(() => {
if (!containerRef.current) return;
const el = containerRef.current;
const chart = createChart(el, {
width: el.clientWidth || 800,
height: chartHeight,
layout: {
background: { type: ColorType.Solid, color: "transparent" },
textColor: TEXT,
fontSize: 10,
panes: { separatorColor: GRID, separatorHoverColor: "#94a3b8" },
},
grid: {
vertLines: { color: GRID, style: LineStyle.Dashed, visible: true },
horzLines: { color: GRID, style: LineStyle.Dashed, visible: true },
},
rightPriceScale: { borderColor: GRID },
timeScale: { borderColor: GRID, timeVisible: false },
crosshair: { mode: CrosshairMode.Normal },
});
chartRef.current = chart;
// 成交量放到独立 pane1,避免与主图K线重叠
const vol = chart.addSeries(
HistogramSeries,
{
priceFormat: { type: "volume" },
priceScaleId: "vol",
},
1,
);
volSeriesRef.current = vol;
return () => {
chart.remove();
chartRef.current = null;
priceSeriesRef.current = null;
volSeriesRef.current = null;
maSeriesRef.current = [];
macdSeriesRef.current = [];
rsiSeriesRef.current = [];
};
}, []);
// 移除某组 series
function removeGroup(list: ISeriesAny<"Line" | "Histogram">[], chart: IChartApi) {
for (const s of list) {
try {
chart.removeSeries(s);
} catch {
// 已被移除
}
}
list.length = 0;
}
// 填充/重建全部指标 series
function rebuildIndicators() {
const chart = chartRef.current;
if (!chart) return;
// 指标开关改变容器高度后,同步图表尺寸
if (containerRef.current) {
chart.applyOptions({
width: containerRef.current.clientWidth || 800,
height: chartHeight,
});
}
const bars: KBar[] = data.map((d) => ({
time: d.time,
open: d.open,
close: d.close,
high: d.high,
low: d.low,
volume: d.volume,
}));
const closes = bars.map((b) => b.close);
// --- MA(主图 pane0 叠加)---
removeGroup(maSeriesRef.current, chart);
if (indicators.ma && bars.length > 0) {
MA_PERIODS.forEach((p, i) => {
const vals = sma(closes, p);
const s = chart.addSeries(
LineSeries,
{ color: MA_COLORS[i % MA_COLORS.length], lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
0,
);
s.setData(
bars
.map((b, idx) => ({ time: b.time as Time, value: vals[idx] }))
.filter((x) => x.value != null) as { time: Time; value: number }[],
);
maSeriesRef.current.push(s);
});
}
// --- MACD(pane2)---
removeGroup(macdSeriesRef.current, chart);
if (indicators.macd && bars.length > 0) {
const { dif, dea, hist } = calcMacd(closes);
const line = (color: string) =>
chart.addSeries(
LineSeries,
{ color, lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
2,
);
const difS = line(MACD_DIF);
const deaS = line(MACD_DEA);
difS.setData(bars.map((b, i) => ({ time: b.time as Time, value: dif[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
deaS.setData(bars.map((b, i) => ({ time: b.time as Time, value: dea[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
const histS = chart.addSeries(
HistogramSeries,
{
priceLineVisible: false,
lastValueVisible: false,
},
2,
);
histS.setData(
bars
.map((b, i) => ({
time: b.time as Time,
value: hist[i],
color: (hist[i] ?? 0) >= 0 ? UP : DOWN,
}))
.filter((x) => x.value != null) as { time: Time; value: number; color: string }[],
);
macdSeriesRef.current.push(difS, deaS, histS);
}
// --- RSI(pane3)---
removeGroup(rsiSeriesRef.current, chart);
if (indicators.rsi && bars.length > 0) {
const vals = calcRsi(closes, 14);
const s = chart.addSeries(
LineSeries,
{ color: RSI_COLOR, lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
3,
);
s.setData(
bars
.map((b, i) => ({ time: b.time as Time, value: vals[i] }))
.filter((x) => x.value != null) as { time: Time; value: number }[],
);
rsiSeriesRef.current.push(s);
}
}
// 模式切换:重建价格 series + 指标
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
if (priceSeriesRef.current) {
chart.removeSeries(priceSeriesRef.current);
priceSeriesRef.current = null;
}
if (mode === "candle") {
priceSeriesRef.current = chart.addSeries(CandlestickSeries, {
upColor: UP,
downColor: DOWN,
borderUpColor: UP,
borderDownColor: DOWN,
wickUpColor: UP,
wickDownColor: DOWN,
});
} else {
priceSeriesRef.current = chart.addSeries(LineSeries, {
color: PRIMARY,
lineWidth: 2,
});
}
fillData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
// 数据或指标开关变化:重填数据 + 重建指标
useEffect(() => {
fillData();
rebuildIndicators();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, hasAddedDate, indicators]);
function fillData() {
const chart = chartRef.current;
const ps = priceSeriesRef.current;
const vs = volSeriesRef.current;
if (!chart || !ps || !vs) return;
if (data.length === 0) {
ps.setData([]);
vs.setData([]);
return;
}
if (mode === "candle") {
(ps as ISeriesApi<"Candlestick">).setData(
data.map((d) => ({
time: d.time as Time,
open: d.open,
high: d.high,
low: d.low,
close: d.close,
})),
);
} else {
(ps as ISeriesApi<"Line">).setData(
data.map((d) => ({ time: d.time as Time, value: d.close })),
);
}
vs.setData(
data.map((d) => ({
time: d.time as Time,
value: d.volume,
color: d.close >= d.open ? VOL_UP : VOL_DOWN,
})),
);
const markerItem = hasAddedDate ? data.find((d) => d.isAddedDate) : undefined;
if (markerItem) {
createSeriesMarkers(ps, [
{
time: markerItem.time as Time,
position: "aboveBar",
color: MARKER,
shape: "circle",
text: "自选",
},
]);
} else {
createSeriesMarkers(ps, []);
}
chart.timeScale().fitContent();
}
// 颜色图例:根据启用的指标生成
const legend: { color: string; label: string }[] = [];
if (indicators.ma) {
MA_PERIODS.forEach((p, i) => legend.push({ color: MA_COLORS[i % MA_COLORS.length], label: `MA${p}` }));
}
if (indicators.macd) {
legend.push({ color: MACD_DIF, label: "DIF" }, { color: MACD_DEA, label: "DEA" });
}
if (indicators.rsi) legend.push({ color: RSI_COLOR, label: "RSI14" });
return (
<div className="w-full">
<div
ref={containerRef}
style={{ height: `${chartHeight}px` }}
className="w-full transition-[height] duration-200"
/>
{legend.length > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5 text-[11px] text-muted-foreground">
{legend.map((l) => (
<span key={l.label} className="inline-flex items-center gap-1">
<span className="h-0.5 w-3 rounded" style={{ backgroundColor: l.color }} />
{l.label}
</span>
))}
</div>
)}
</div>
);
}
export default KLineChart;
+56
View File
@@ -0,0 +1,56 @@
// AI 分析报告 API 客户端
import { getApiBaseUrl } from "./api-client";
const API_BASE = getApiBaseUrl();
export interface AiReport {
id: number;
trade_date: string;
report_type: string;
title: string;
content: string;
summary: string | null;
toolsUsed: string[];
model: string;
tokens_used: number;
created_at: string;
}
export async function fetchAiLatestReport(): Promise<AiReport> {
const resp = await fetch(`${API_BASE}/api/ai-analysis/latest`);
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
const result = await resp.json();
return result.data;
}
export async function fetchAiReports(): Promise<AiReport[]> {
const resp = await fetch(`${API_BASE}/api/ai-analysis`);
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
const result = await resp.json();
return result.data || [];
}
export async function fetchAiReport(id: number): Promise<AiReport> {
const resp = await fetch(`${API_BASE}/api/ai-analysis/${id}`);
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
const result = await resp.json();
return result.data;
}
export async function checkAiReport(tradeDate: string): Promise<boolean> {
const resp = await fetch(`${API_BASE}/api/ai-analysis/check/${tradeDate}`);
if (!resp.ok) return false;
const result = await resp.json();
return result.data?.hasReport || false;
}
export async function regenerateAiReport(id: number): Promise<{ id: number; tokens_used: number }> {
const resp = await fetch(`${API_BASE}/api/ai-analysis/${id}/regenerate`, { method: "POST" });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || "重新生成失败");
}
const result = await resp.json();
return result.data;
}
+60
View File
@@ -0,0 +1,60 @@
// 核心股历史数据获取工具
import { getApiBaseUrl } from "@/lib/api-client";
export interface ActiveCoreStock {
stockCode: string;
stockName: string;
coverCount: number | null; // 覆盖题材数
dailyGains: Record<string, number | null>; // 日期 -> 当日涨幅(可空)
appearCount: number;
lastAppear: string | null;
daysSinceLastAppear: number; // 最近上榜距窗口最新交易日的自然日差
themes: { theme_code: string; theme_name: string }[]; // 所属题材列表
}
export interface ActiveCoreStocksResponse {
dates: string[];
stocks: ActiveCoreStock[];
}
export interface CoreStockHistoryItem {
id: number;
trade_date: string;
stock_code: string;
stock_name: string;
f3: number | null;
cover_count: number | null;
rank: number;
themes: { theme_code: string; theme_name: string }[];
}
export interface CoreStockHistoryResponse {
date: string;
items: CoreStockHistoryItem[];
}
/** 获取活跃核心股 + 最近10日涨幅矩阵 */
export async function fetchActiveCoreStocks(): Promise<ActiveCoreStocksResponse> {
const baseUrl = getApiBaseUrl();
try {
const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" });
if (!resp.ok) return { dates: [], stocks: [] };
return resp.json();
} catch (err) {
console.error("[core-stock-api] 获取活跃核心股失败:", err);
return { dates: [], stocks: [] };
}
}
/** 获取指定交易日核心股(含所属题材) */
export async function fetchCoreStockHistory(date: string): Promise<CoreStockHistoryResponse | null> {
const baseUrl = getApiBaseUrl();
try {
const resp = await fetch(`${baseUrl}/api/core-stocks/history?date=${date}`, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
return resp.json();
} catch (err) {
console.error("[core-stock-api] 获取核心股历史失败:", err);
return null;
}
}
+233
View File
@@ -0,0 +1,233 @@
// v2 数据接口(同花顺官方 API)前端客户端
// 仅调用后端 /api/v2 代理,密钥由后端持有,前端永远接触不到。
import { getApiBaseUrl } from "@/lib/api-client";
/* ── 通用信封(后端返回 { data, count })── */
interface V2Response<T> {
data: T;
count?: number;
}
async function v2Get<T>(path: string): Promise<T> {
const baseUrl = getApiBaseUrl();
try {
const resp = await fetch(`${baseUrl}/api/v2${path}`, { method: "GET", cache: "no-store" });
if (!resp.ok) {
// 尝试读取 detail(FastAPI 错误信息)
try {
const err = await resp.json();
throw new Error(err.detail || `请求失败 (${resp.status})`);
} catch (e) {
if (e instanceof Error) throw e;
throw new Error(`请求失败 (${resp.status})`);
}
}
const result: V2Response<T> = await resp.json();
return result.data;
} catch (err) {
console.error("[fuyao-api] v2 请求失败:", err);
throw err;
}
}
/* ── 类型 ── */
export interface V2Ticker {
thscode: string;
ticker: string;
name: string;
exchange: string;
asset_type: string;
currency: string;
}
export interface V2PriceSnapshot {
thscode: string;
ticker: string;
volume: number;
turnover: number;
last_price: number;
price_change: number;
price_change_ratio_pct: number;
open_price: number;
high_price: number;
low_price: number;
prev_price: number;
}
export interface V2Valuation {
thscode: string;
ticker: string;
name: string;
pe_ttm: number;
pe_mrq: number;
pb_mrq: number;
ps_ttm: number;
pcf_ttm: number;
}
export interface V2Financial {
thscode: string;
ticker: string;
fiscal_year: number;
fiscal_period: string;
operating_income: number;
operating_costs: number;
net_profit: number;
[key: string]: unknown;
}
export interface V2TradingDay {
date: string; // YYYYMMDD
date_ms: number;
}
export interface V2IndexItem {
thscode: string;
name: string;
[key: string]: unknown;
}
/* ── 基础 / 检索 ── */
export function v2TickerSearch(q: string, limit = 10): Promise<V2Ticker[]> {
return v2Get(`/meta/tickers/search?q=${encodeURIComponent(q)}&limit=${limit}`);
}
/** 判断是否已是标准 thscode(如 600519.SH / 000021.SZ / 830xxx.BJ) */
function isThscode(token: string): boolean {
return /^\d{6}\.(SH|SZ|BJ)$/i.test(token);
}
/**
* 把用户输入解析成 thscode 列表。
* 支持:标准 thscode(600519.SH)、纯代码(600519)、名称(茅台)、拼音首字母(gzmt)。
* 输入用逗号/空格分隔多个标的;每个 token 单独解析。
* 解析失败(找不到)的 token 会被跳过。
*/
export async function resolveThscodes(input: string): Promise<string[]> {
const tokens = input
.split(/[,,\s]+/)
.map((t) => t.trim())
.filter(Boolean);
const out: string[] = [];
for (const token of tokens) {
if (isThscode(token)) {
out.push(token.toUpperCase());
} else {
try {
const hits = await v2TickerSearch(token, 1);
if (hits.length > 0) out.push(hits[0].thscode);
} catch {
// 忽略单个 token 解析失败
}
}
}
return out;
}
/* ── A股 ── */
/** 6位代码 → thscode(按代码前缀推断交易所后缀) */
export function codeToThscode(code: string): string {
const c = code.trim();
if (/^\d{6}\.(SH|SZ|BJ)$/i.test(c)) return c.toUpperCase();
if (!/^\d{6}$/.test(c)) return c;
if (/^(60|68|9)/.test(c)) return `${c}.SH`;
if (/^(00|30|20|12)/.test(c)) return `${c}.SZ`;
if (/^(4|8|92)/.test(c)) return `${c}.BJ`;
return `${c}.SH`;
}
export interface V2PriceBar {
date_ms: number;
open_price: number;
high_price: number;
low_price: number;
close_price: number;
volume: number;
turnover: number;
}
export function v2PriceSnapshot(thscodes: string): Promise<V2PriceSnapshot[]> {
return v2Get(`/prices/snapshot?thscodes=${encodeURIComponent(thscodes)}`);
}
/**
* 历史日K(毫秒时间戳)。后端用官方 SDK,>10 年窗口自动切片。
*/
export function v2PriceHistorical(
thscode: string,
startMs: number,
endMs: number,
adjust = "forward",
): Promise<V2PriceBar[]> {
return v2Get(`/prices/historical?thscode=${encodeURIComponent(thscode)}&start=${startMs}&end=${endMs}&adjust=${adjust}`);
}
/** 毫秒时间戳 → 北京时间(UTC+8)日期 YYYY-MM-DD */
function bjDate(ms: number): string {
const d = new Date(ms + 8 * 60 * 60 * 1000);
return d.toISOString().slice(0, 10);
}
/**
* v2 版股票日K(供股票详情页复用现有图表结构)。
* 返回与 v1 KLineData 兼容的结构;涨跌幅按昨收计算。
*/
export async function fetchStockHistoryV2(
code: string,
days: number = 120,
adjust: string = "forward",
): Promise<Array<{
date: string;
open: number;
close: number;
high: number;
low: number;
volume: number;
changePercent: number;
}>> {
const thscode = codeToThscode(code);
const end = Date.now();
const start = end - days * 24 * 60 * 60 * 1000;
const bars = await v2PriceHistorical(thscode, start, end, adjust);
const sorted = bars.slice().sort((a, b) => a.date_ms - b.date_ms);
return sorted.map((b, i) => {
const prevClose = i > 0 ? sorted[i - 1].close_price : b.open_price;
const changePercent = prevClose > 0 ? ((b.close_price - prevClose) / prevClose) * 100 : 0;
return {
date: bjDate(b.date_ms),
open: b.open_price,
close: b.close_price,
high: b.high_price,
low: b.low_price,
volume: b.volume / 100, // 股 → 手(与 v1 口径一致,现有图表按手展示)
changePercent,
};
});
}
export function v2Valuations(thscodes: string): Promise<V2Valuation[]> {
return v2Get(`/valuations/snapshot?thscodes=${encodeURIComponent(thscodes)}`);
}
export function v2Financials(
statement: "income-statements" | "balance-sheets" | "cash-flow-statements",
thscode: string,
period = "annual",
limit = 6,
): Promise<V2Financial[]> {
return v2Get(`/financials/${statement}?thscode=${encodeURIComponent(thscode)}&period=${period}&limit=${limit}`);
}
export function v2TradingDays(): Promise<V2TradingDay[]> {
return v2Get(`/calendar/trading-days`);
}
/* ── 指数 / 板块 ── */
export function v2IndexCatalog(tag = "industry"): Promise<V2IndexItem[]> {
return v2Get(`/index/catalog?tag=${tag}`);
}
+139
View File
@@ -0,0 +1,139 @@
/**
* 技术指标计算(纯函数,前端本地计算)
* 输入 K 线数组(时间升序),输出与输入等长、前段为 null 的指标序列。
*/
export interface KBar {
time: string | number; // yyyy-mm-dd 或 Unix 秒(分钟K线用数字时间)
open: number;
close: number;
high: number;
low: number;
volume: number;
}
export type Nums = (number | null)[];
/** 简单移动平均 */
export function sma(closes: number[], period: number): Nums {
const out: Nums = new Array(closes.length).fill(null);
let sum = 0;
for (let i = 0; i < closes.length; i++) {
sum += closes[i];
if (i >= period) sum -= closes[i - period];
if (i >= period - 1) out[i] = sum / period;
}
return out;
}
/** EMA(标准 MACD 用的指数平滑) */
export function ema(values: number[], period: number): Nums {
const out: Nums = new Array(values.length).fill(null);
const k = 2 / (period + 1);
let prev: number | null = null;
for (let i = 0; i < values.length; i++) {
if (i === period - 1) {
// 首值取前 period 个的 SMA
let s = 0;
for (let j = 0; j < period; j++) s += values[j];
prev = s / period;
out[i] = prev;
} else if (prev !== null) {
prev = values[i] * k + prev * (1 - k);
out[i] = prev;
}
}
return out;
}
/** MACD(10,20,7):返回 DIF、DEA、MACD 柱(柱 = (DIF-DEA)*2,国内口径) */
export function macd(
closes: number[],
fast = 10,
slow = 20,
signal = 7,
): { dif: Nums; dea: Nums; hist: Nums } {
const ef = ema(closes, fast);
const es = ema(closes, slow);
const dif: Nums = closes.map((_, i) =>
ef[i] != null && es[i] != null ? (ef[i] as number) - (es[i] as number) : null,
);
// DEA = DIF 的 9 日 EMA(跳过 null)
const dea: Nums = new Array(closes.length).fill(null);
const difVals: number[] = [];
const difIdx: number[] = [];
for (let i = 0; i < dif.length; i++) {
if (dif[i] != null) {
difVals.push(dif[i] as number);
difIdx.push(i);
}
}
const deaVals = ema(difVals, signal);
for (let j = 0; j < difIdx.length; j++) {
if (deaVals[j] != null) dea[difIdx[j]] = deaVals[j];
}
const hist: Nums = closes.map((_, i) =>
dif[i] != null && dea[i] != null ? ((dif[i] as number) - (dea[i] as number)) * 2 : null,
);
return { dif, dea, hist };
}
/** RSI(Wilder 平滑,国内软件常用 SMA(X,N,1) 等价 Wilder) */
export function rsi(closes: number[], period = 14): Nums {
const out: Nums = new Array(closes.length).fill(null);
if (closes.length <= period) return out;
let avgGain = 0;
let avgLoss = 0;
// 首段:前 period 个变动的简单均值
for (let i = 1; i <= period; i++) {
const ch = closes[i] - closes[i - 1];
if (ch > 0) avgGain += ch;
else avgLoss -= ch;
}
avgGain /= period;
avgLoss /= period;
out[period] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
// Wilder 平滑
for (let i = period + 1; i < closes.length; i++) {
const ch = closes[i] - closes[i - 1];
const gain = ch > 0 ? ch : 0;
const loss = ch < 0 ? -ch : 0;
avgGain = (avgGain * (period - 1) + gain) / period;
avgLoss = (avgLoss * (period - 1) + loss) / period;
out[i] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
}
return out;
}
/** KDJ(9,3,3) */
export function kdj(
bars: KBar[],
n = 9,
m1 = 3,
m2 = 3,
): { k: Nums; d: Nums; j: Nums } {
const len = bars.length;
const k: Nums = new Array(len).fill(null);
const d: Nums = new Array(len).fill(null);
const j: Nums = new Array(len).fill(null);
let prevK = 50;
let prevD = 50;
for (let i = 0; i < len; i++) {
const from = Math.max(0, i - n + 1);
let hh = -Infinity;
let ll = Infinity;
for (let x = from; x <= i; x++) {
hh = Math.max(hh, bars[x].high);
ll = Math.min(ll, bars[x].low);
}
const rsv = hh === ll ? 50 : ((bars[i].close - ll) / (hh - ll)) * 100;
const kv = (2 * prevK + rsv) / m1; // 国内口径 K = 2/3 前值 + 1/3 RSV
const dv = (2 * prevD + kv) / m2;
prevK = kv;
prevD = dv;
k[i] = kv;
d[i] = dv;
j[i] = 3 * kv - 2 * dv;
}
return { k, d, j };
}
+104
View File
@@ -0,0 +1,104 @@
// 市场看板数据 API 客户端
import { getApiBaseUrl } from "@/lib/api-client";
export interface MarketIndex {
code: string;
name: string;
price: number;
change: number;
changePct: number;
prevClose: number;
turnover: number;
}
export interface MarketTemperature {
score: number;
label: string;
factors: {
advanceScore: number;
medianScore: number;
strongScore: number;
limitScore: number;
breakPenalty: number;
auctionBonus: number;
};
}
export interface AuctionData {
score: number;
label: string;
date: string;
benchmark: Record<string, unknown>;
}
export interface MarketStats {
upCount: number;
downCount: number;
flatCount: number;
total: number;
marketBreadth: number;
medianChange: number;
strongCount: number;
weakCount: number;
limitUp: number;
limitDown: number;
limitBreak: number;
breakRate: number;
totalTurnover: number;
temperature: MarketTemperature;
auction: AuctionData;
auctionSignal: string;
}
export interface SectorStrengthItem {
code: string;
name: string;
price: number;
change: number;
changePct: number;
strength: number;
breadthPct: number;
strongCount: number;
}
export interface ConceptStrengthItem {
code: string;
name: string;
changePct: number;
}
export interface DashboardEvent {
type: string;
label: string;
name: string;
code: string;
detail: string;
}
export interface MarketDashboardData {
indices: MarketIndex[];
marketStats: MarketStats;
sectorStrength: SectorStrengthItem[];
conceptStrength: ConceptStrengthItem[];
events: DashboardEvent[];
updateTime: string;
}
export async function fetchMarketDashboard(): Promise<MarketDashboardData> {
const baseUrl = getApiBaseUrl();
const resp = await fetch(`${baseUrl}/api/market-dashboard`, {
method: "GET",
cache: "no-store",
});
if (!resp.ok) {
try {
const err = await resp.json();
throw new Error(err.detail || `请求失败 (${resp.status})`);
} catch (e) {
if (e instanceof Error) throw e;
throw new Error(`请求失败 (${resp.status})`);
}
}
const result = await resp.json();
return result.data;
}
+44 -48
View File
@@ -97,7 +97,9 @@ export interface BoardInfo {
* - 8/4/920开头:北交所(BJB)
* - 其他:主板
*/
export function getStockBoard(code: string): BoardInfo {
export function getStockBoard(code: string | null | undefined): BoardInfo {
// 题材列表等场景下 securityCode 可能为 null(无领涨股的题材),兜底为主板
if (!code) return { board: "main", label: "", className: "" };
if (code.startsWith("688")) {
return { board: "kcb", label: "科", className: "bg-red-500/10 text-red-500 border-red-500/30" };
}
@@ -179,6 +181,47 @@ export async function fetchStockQuote(code: string): Promise<StockQuote | null>
}
}
/**
* 获取股票分钟/小时级K线数据
* @param code 股票代码(6位)
* @param period 周期:m1/m5/m15/m30/m60(60=小时线)
* @param count K线数量(默认320)
*/
export async function fetchStockHistoryMinute(
code: string,
period: "m1" | "m5" | "m15" | "m30" | "m60" = "m60",
count: number = 320,
): Promise<KLineData[]> {
if (!/^\d{6}$/.test(code)) {
throw new Error("股票代码格式错误,需为6位数字");
}
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/stock/history-minute?code=${code}&period=${period}&count=${count}`;
try {
const resp = await fetch(url, { method: "GET" });
if (!resp.ok) {
let errorMsg = `请求失败 (${resp.status})`;
try {
const errData = await resp.json();
errorMsg = errData.detail || errorMsg;
} catch {
// 忽略 JSON 解析错误
}
throw new Error(errorMsg);
}
const result = await resp.json();
if (!result.data || !Array.isArray(result.data)) {
throw new Error(result.detail || "未获取到分钟K线数据");
}
return result.data as KLineData[];
} catch (err) {
console.error("[stock-api] 获取分钟K线数据失败:", err);
return [];
}
}
/**
* 获取股票历史K线数据(通过Edge Function代理腾讯财经API)
* @param code 股票代码(6位)
@@ -430,50 +473,3 @@ export async function fetchFinancialData(code: string, years: number = 5): Promi
}
}
// ---- 板块数据 ----
export interface SectorItem {
code: string;
name: string;
level: number | null;
changePercent: number | null;
changeAmount: number | null;
mainNetInflow: number;
mainNetInflowPercent: number | null;
superLargeInflow: number | null;
superLargeInflowPercent: number | null;
largeInflow: number | null;
largeInflowPercent: number | null;
mediumInflow: number | null;
mediumInflowPercent: number | null;
smallInflow: number | null;
smallInflowPercent: number | null;
turnover: number;
}
export type SectorType = "industry" | "concept";
export interface SectorResponse {
data: SectorItem[];
count: number;
type: SectorType;
}
/**
* 获取东方财富板块列表(按主力净流入排序)
* @param type industry=行业板块, concept=概念板块
*/
export async function fetchSectors(type: SectorType, signal?: AbortSignal): Promise<SectorItem[]> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/sectors?type=${type}`;
try {
const resp = await fetch(url, { method: "GET", signal, cache: "no-store" });
if (!resp.ok) return [];
const result: SectorResponse = await resp.json();
return result.data || [];
} catch (err) {
console.error("[stock-api] 获取板块数据失败:", err);
return [];
}
}
+101 -2
View File
@@ -3,11 +3,40 @@ import { getApiBaseUrl } from "@/lib/api-client";
/* ── 题材列表 ── */
export interface ActiveTheme {
themeCode: string;
themeName: string;
dailyGains: Record<string, number | null>; // trade_date -> 当日题材涨幅 bf3(可空)
appearCount: number; // 窗口内上榜次数
lastAppear: string | null; // 最近上榜交易日
bestRank: number | null; // 窗口内最佳排名(rank 最小值)
}
export interface ActiveThemesResponse {
dates: string[];
themes: ActiveTheme[];
}
/**
* 获取最近 10 个交易日的活跃题材涨幅矩阵
*/
export async function fetchActiveThemes(): Promise<ActiveThemesResponse> {
const baseUrl = getApiBaseUrl();
try {
const resp = await fetch(`${baseUrl}/api/themes/active`, { method: "GET", cache: "no-store" });
if (!resp.ok) return { dates: [], themes: [] };
return resp.json();
} catch (err) {
console.error("[theme-api] 获取活跃题材历史失败:", err);
return { dates: [], themes: [] };
}
}
export interface ThemeItem {
themeCode: string;
themeName: string;
securityName: string; // 领涨股名称
securityCode: string; // 领涨股代码
securityName: string | null; // 领涨股名称(无领涨股的题材为 null)
securityCode: string | null; // 领涨股代码(无领涨股的题材为 null)
codeWithSuffix: string;
hotRank: number; // 热度排名
f3: number | null; // 领涨股涨幅
@@ -159,12 +188,82 @@ export async function fetchThemeStocks(themeCode: string): Promise<ThemeStocksRe
}
}
/* ── 题材相关新闻(分页) ── */
export interface ThemeNewsItem {
newsCode: string;
newsTitle: string;
newsMediaName: string;
showDateTime: number | null;
showDateTimeFormat: string | null;
commentCount: number;
themeCode: string;
themeName: string;
}
export interface ThemeNewsResponse {
total: number;
maxEuTime: string;
list: ThemeNewsItem[];
}
/**
* 获取题材相关新闻(分页,maxEuTime 为翻页游标)
*/
export async function fetchThemeNews(
themeCode: string,
pageNum: number = 1,
pageSize: number = 10,
maxEuTime: string = "",
): Promise<ThemeNewsResponse | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/themes/${themeCode}/news?page_num=${pageNum}&page_size=${pageSize}&max_eu_time=${encodeURIComponent(maxEuTime)}`;
try {
const resp = await fetch(url, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
const result = await resp.json();
return result.data || null;
} catch (err) {
console.error("[theme-api] 获取题材新闻失败:", err);
return null;
}
}
/* ── 单题材实时行情(强度/热度/涨幅) ── */
export interface ThemeQuote {
strengthValue: number | null;
hotValueUpLimit: number;
hotValue: number;
f3: number | null;
}
/**
* 获取单题材实时行情
*/
export async function fetchThemeQuote(themeCode: string): Promise<ThemeQuote | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/themes/${themeCode}/quote`;
try {
const resp = await fetch(url, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
const result = await resp.json();
return result.data || null;
} catch (err) {
console.error("[theme-api] 获取题材行情失败:", err);
return null;
}
}
/* ── 热点穿透:题材-股票 网状关系图 ── */
export interface GraphTheme {
themeCode: string;
themeName: string;
stockCount: number; // 题材内股票数
bf3: number | null; // 题材涨幅
}
export interface GraphStock {
+56
View File
@@ -0,0 +1,56 @@
import { useState, useEffect, useCallback } from "react";
type Theme = "light" | "dark" | "system";
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function getResolvedTheme(theme: Theme): "light" | "dark" {
return theme === "system" ? getSystemTheme() : theme;
}
function applyTheme(resolved: "light" | "dark") {
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(resolved);
root.setAttribute("data-theme", resolved);
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window === "undefined") return "system";
return (localStorage.getItem("theme") as Theme) || "system";
});
const [resolved, setResolved] = useState<"light" | "dark">(() => getResolvedTheme(theme));
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem("theme", newTheme);
const r = getResolvedTheme(newTheme);
setResolved(r);
applyTheme(r);
}, []);
// 初始应用
useEffect(() => {
applyTheme(resolved);
}, []);
// 监听系统主题变化
useEffect(() => {
if (theme !== "system") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => {
const r = getSystemTheme();
setResolved(r);
applyTheme(r);
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [theme]);
return { theme, resolved, setTheme };
}
Executable → Regular
+80 -17
View File
@@ -10,8 +10,11 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as ThemesRouteImport } from './routes/themes'
import { Route as SectorsRouteImport } from './routes/sectors'
import { Route as ThemeHistoryRouteImport } from './routes/theme-history'
import { Route as HotMapRouteImport } from './routes/hot-map'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as CoreStocksRouteImport } from './routes/core-stocks'
import { Route as AiAnalysisRouteImport } from './routes/ai-analysis'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
import { Route as StockCodeRouteImport } from './routes/stock.$code'
@@ -22,9 +25,9 @@ const ThemesRoute = ThemesRouteImport.update({
path: '/themes',
getParentRoute: () => rootRouteImport,
} as any)
const SectorsRoute = SectorsRouteImport.update({
id: '/sectors',
path: '/sectors',
const ThemeHistoryRoute = ThemeHistoryRouteImport.update({
id: '/theme-history',
path: '/theme-history',
getParentRoute: () => rootRouteImport,
} as any)
const HotMapRoute = HotMapRouteImport.update({
@@ -32,6 +35,21 @@ const HotMapRoute = HotMapRouteImport.update({
path: '/hot-map',
getParentRoute: () => rootRouteImport,
} as any)
const DashboardRoute = DashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => rootRouteImport,
} as any)
const CoreStocksRoute = CoreStocksRouteImport.update({
id: '/core-stocks',
path: '/core-stocks',
getParentRoute: () => rootRouteImport,
} as any)
const AiAnalysisRoute = AiAnalysisRouteImport.update({
id: '/ai-analysis',
path: '/ai-analysis',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -55,8 +73,11 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/ai-analysis': typeof AiAnalysisRoute
'/core-stocks': typeof CoreStocksRoute
'/dashboard': typeof DashboardRoute
'/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute
'/theme-history': typeof ThemeHistoryRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
@@ -64,8 +85,11 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/ai-analysis': typeof AiAnalysisRoute
'/core-stocks': typeof CoreStocksRoute
'/dashboard': typeof DashboardRoute
'/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute
'/theme-history': typeof ThemeHistoryRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
@@ -74,8 +98,11 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/ai-analysis': typeof AiAnalysisRoute
'/core-stocks': typeof CoreStocksRoute
'/dashboard': typeof DashboardRoute
'/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute
'/theme-history': typeof ThemeHistoryRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
@@ -85,8 +112,11 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/ai-analysis'
| '/core-stocks'
| '/dashboard'
| '/hot-map'
| '/sectors'
| '/theme-history'
| '/themes'
| '/share/$code'
| '/stock/$code'
@@ -94,8 +124,11 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/ai-analysis'
| '/core-stocks'
| '/dashboard'
| '/hot-map'
| '/sectors'
| '/theme-history'
| '/themes'
| '/share/$code'
| '/stock/$code'
@@ -103,8 +136,11 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
| '/ai-analysis'
| '/core-stocks'
| '/dashboard'
| '/hot-map'
| '/sectors'
| '/theme-history'
| '/themes'
| '/share/$code'
| '/stock/$code'
@@ -113,8 +149,11 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AiAnalysisRoute: typeof AiAnalysisRoute
CoreStocksRoute: typeof CoreStocksRoute
DashboardRoute: typeof DashboardRoute
HotMapRoute: typeof HotMapRoute
SectorsRoute: typeof SectorsRoute
ThemeHistoryRoute: typeof ThemeHistoryRoute
ThemesRoute: typeof ThemesRoute
ShareCodeRoute: typeof ShareCodeRoute
StockCodeRoute: typeof StockCodeRoute
@@ -130,11 +169,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ThemesRouteImport
parentRoute: typeof rootRouteImport
}
'/sectors': {
id: '/sectors'
path: '/sectors'
fullPath: '/sectors'
preLoaderRoute: typeof SectorsRouteImport
'/theme-history': {
id: '/theme-history'
path: '/theme-history'
fullPath: '/theme-history'
preLoaderRoute: typeof ThemeHistoryRouteImport
parentRoute: typeof rootRouteImport
}
'/hot-map': {
@@ -144,6 +183,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof HotMapRouteImport
parentRoute: typeof rootRouteImport
}
'/dashboard': {
id: '/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof DashboardRouteImport
parentRoute: typeof rootRouteImport
}
'/core-stocks': {
id: '/core-stocks'
path: '/core-stocks'
fullPath: '/core-stocks'
preLoaderRoute: typeof CoreStocksRouteImport
parentRoute: typeof rootRouteImport
}
'/ai-analysis': {
id: '/ai-analysis'
path: '/ai-analysis'
fullPath: '/ai-analysis'
preLoaderRoute: typeof AiAnalysisRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -177,8 +237,11 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AiAnalysisRoute: AiAnalysisRoute,
CoreStocksRoute: CoreStocksRoute,
DashboardRoute: DashboardRoute,
HotMapRoute: HotMapRoute,
SectorsRoute: SectorsRoute,
ThemeHistoryRoute: ThemeHistoryRoute,
ThemesRoute: ThemesRoute,
ShareCodeRoute: ShareCodeRoute,
StockCodeRoute: StockCodeRoute,
+4
View File
@@ -1,6 +1,7 @@
import * as React from 'react'
import { Outlet, createRootRoute } from '@tanstack/react-router'
import { Toaster } from 'sonner'
import { ThemeToggle } from '../components/ThemeToggle'
export const Route = createRootRoute({
component: RootComponent,
@@ -11,6 +12,9 @@ function RootComponent() {
<React.Fragment>
<Outlet />
<Toaster position="top-center" richColors />
<div className="fixed bottom-4 right-4 z-50">
<ThemeToggle />
</div>
</React.Fragment>
)
}
+109
View File
@@ -0,0 +1,109 @@
import * as React from "react";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowLeft, Clock, Loader2, AlertCircle, Wrench, Calendar } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Mermaid } from "../components/Mermaid";
import { fetchAiLatestReport } from "../lib/ai-analysis-api";
import { Card, CardContent } from "../components/ui/card";
export const Route = createFileRoute("/ai-analysis")({
component: AiAnalysisPage,
});
function AiAnalysisPage() {
const { data: report, isLoading, isError } = useQuery({
queryKey: ["ai-report-latest"],
queryFn: fetchAiLatestReport,
retry: false,
});
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 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 */}
{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">暂无分析报告</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 */}
<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>
{/* Disclaimer */}
<p className="text-xs text-muted-foreground/60 text-center mt-4 sm:mt-6">
本报告由 AI 生成,仅供参考,不构成投资建议
</p>
</>
)}
</div>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { Fragment } from "react";
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
export const Route = createFileRoute("/core-stocks")({
component: CoreStocksPage,
});
/** 格式化涨幅,红涨绿跌 */
function formatGain(v: number | null | undefined): string {
if (v == null) return "·";
const s = v >= 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
return s;
}
function CoreStocksPage() {
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ["core-stocks", "active"],
queryFn: fetchActiveCoreStocks,
staleTime: 60_000,
retry: false,
});
const dates = data?.dates ?? [];
const stocks = data?.stocks ?? [];
return (
<div className="min-h-screen bg-background">
{/* 顶栏 */}
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/hot-map" className="hover:opacity-70 transition-opacity" aria-label="返回">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-base font-semibold">热点股追踪</h1>
</div>
<button
onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
</button>
</div>
</header>
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
<p className="text-[10px] text-muted-foreground mb-2">
活跃热点股(最近 10 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只
</p>
{isLoading ? (
<div className="animate-pulse rounded-xl bg-muted h-32" />
) : dates.length === 0 || stocks.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-16">
暂无数据,数据将在每日收盘后自动采集
</div>
) : (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th scope="col" className="px-3 py-2 text-left font-medium whitespace-nowrap">股票</th>
{dates.map((d) => (
<th key={d} scope="col" title={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
{d.slice(5)}
</th>
))}
<th scope="col" className="px-2 py-2 text-right font-medium" title="覆盖题材数">题材数</th>
<th scope="col" className="px-2 py-2 text-right font-medium whitespace-nowrap">最近上榜</th>
<th scope="col" className="px-2 py-2 text-right font-medium">上榜</th>
</tr>
</thead>
<tbody>
{stocks.map((s) => (
<Fragment key={s.stockCode}>
<tr className="border-b last:border-0 hover:bg-muted/30">
<td className="px-3 py-1.5 whitespace-nowrap">
<Link
to="/stock/$code"
params={{ code: s.stockCode }}
className="font-medium hover:text-primary hover:underline"
>
{s.stockName}
</Link>
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span>
</td>
{dates.map((d) => {
const g = s.dailyGains[d];
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
return (
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
{formatGain(g)}
</td>
);
})}
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
{s.coverCount ?? "·"}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
{s.lastAppear ? s.lastAppear.slice(5) : "·"}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
<span className="inline-flex items-center gap-0.5 text-orange-500">
<Flame className="h-3 w-3" />
{s.appearCount}
</span>
</td>
</tr>
<tr className="border-b bg-muted/20">
<td colSpan={dates.length + 4} className="px-3 py-2">
<div className="text-[11px] text-muted-foreground mb-1">所属题材</div>
{s.themes && s.themes.length > 0 ? (
<div className="flex flex-wrap gap-1">
{s.themes.map((t) => (
<Link
key={t.theme_code}
to="/theme/$code"
params={{ code: t.theme_code }}
className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[11px] hover:bg-primary/20 transition-colors"
>
{t.theme_name}
</Link>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">暂无题材数据</span>
)}
</td>
</tr>
</Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+485
View File
@@ -0,0 +1,485 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
fetchMarketDashboard,
type MarketDashboardData,
type MarketIndex,
type SectorStrengthItem,
} from "@/lib/market-dashboard-api";
import { formatMoney } from "@/lib/utils";
import {
ArrowLeft,
RefreshCw,
BarChart3,
Thermometer,
Zap,
Newspaper,
TrendingUp,
Target,
} from "lucide-react";
export const Route = createFileRoute("/dashboard")({
component: DashboardPage,
});
function DashboardPage() {
const [autoRefresh, setAutoRefresh] = useState(false);
const { data, isLoading, isFetching, isError, refetch } = useQuery({
queryKey: ["market-dashboard"],
queryFn: fetchMarketDashboard,
staleTime: 15_000,
retry: false,
});
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(() => refetch(), 30_000);
return () => clearInterval(timer);
}, [autoRefresh, refetch]);
return (
<div className="min-h-screen bg-background text-foreground">
{/* ── 顶栏 ── */}
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border">
<div className="max-w-[1400px] mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-base font-semibold">市场看板</h1>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{data?.updateTime && (
<span>行情时间 {data.updateTime}</span>
)}
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="w-3 h-3 rounded border-border bg-muted accent-primary"
/>
自动刷新
</label>
<button
onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
</button>
</div>
</div>
</header>
{/* ── 主内容 ── */}
<main className="max-w-[1400px] mx-auto px-4 py-4">
{isLoading ? (
<DashboardSkeleton />
) : isError ? (
<div className="flex flex-col items-center gap-3 py-20">
<p className="text-sm text-muted-foreground">数据加载失败</p>
<button onClick={() => refetch()} className="text-xs text-[#58a6ff] hover:underline">
点击重试
</button>
</div>
) : data ? (
<div className="flex flex-col lg:flex-row gap-4">
{/* 左侧主区域 */}
<div className="flex-1 min-w-0 space-y-4">
{/* 指数行情 */}
<IndicesRow indices={data.indices} />
{/* 集合竞价信号 */}
{data.marketStats.auctionSignal && (
<AuctionSignalBar stats={data.marketStats} />
)}
{/* 市场温度 */}
<MarketTemperatureSection stats={data.marketStats} />
{/* 涨跌家数分布 */}
<AdvanceDeclineBar stats={data.marketStats} />
{/* 行业强度榜 */}
<SectorStrengthList sectors={data.sectorStrength} />
{/* 概念板块热度 */}
{data.conceptStrength.length > 0 && (
<ConceptStrengthList concepts={data.conceptStrength} />
)}
</div>
{/* 右侧事件栏 */}
<div className="w-full lg:w-72 shrink-0">
<EventSidebar events={data.events} />
</div>
</div>
) : null}
</main>
</div>
);
}
/* ============================================================
指数行情卡片行(含估值)
============================================================ */
function IndicesRow({ indices }: { indices: MarketIndex[] }) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{indices.map((idx) => (
<IndexCard key={idx.code} index={idx} />
))}
</div>
);
}
function IndexCard({ index }: { index: MarketIndex }) {
const isUp = index.changePct >= 0;
return (
<div className="bg-card border border-border rounded-lg p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-muted-foreground truncate">{index.name}</span>
<span className="text-[10px] text-muted-foreground/50 tabular-nums">{index.code.replace(/\.(SH|SZ)$/, "")}</span>
</div>
<div className={`text-xl font-bold tabular-nums ${isUp ? "text-[#f85149]" : "text-[#3fb950]"}`}>
{index.price.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`text-xs tabular-nums ${isUp ? "text-[#f85149]" : "text-[#3fb950]"}`}>
{isUp ? "+" : ""}{index.change.toFixed(2)}
</span>
<span className={`text-xs tabular-nums ${isUp ? "text-[#f85149]" : "text-[#3fb950]"}`}>
{isUp ? "+" : ""}{index.changePct.toFixed(2)}%
</span>
</div>
</div>
);
}
/* ============================================================
集合竞价信号条
============================================================ */
function AuctionSignalBar({ stats }: { stats: MarketDashboardData["marketStats"] }) {
const signal = stats.auctionSignal;
const color =
signal === "强势高开" ? "#f85149" :
signal === "偏强" ? "#f0883e" :
signal === "弱势低开" ? "#3fb950" :
signal === "偏弱" ? "#238636" :
"#d29922";
return (
<div className="bg-card border border-border rounded-lg px-4 py-2 flex items-center gap-3">
<Target className="h-4 w-4 shrink-0" style={{ color }} />
<span className="text-xs text-muted-foreground">竞价信号</span>
<span className="text-sm font-semibold" style={{ color }}>{signal}</span>
{stats.auction?.date && (
<span className="text-[10px] text-muted-foreground/50 ml-auto">{stats.auction.date}</span>
)}
</div>
);
}
/* ============================================================
市场温度
============================================================ */
function MarketTemperatureSection({ stats }: { stats: MarketDashboardData["marketStats"] }) {
const temp = stats.temperature;
const tempColor =
temp.score >= 80 ? "#f85149" :
temp.score >= 60 ? "#f0883e" :
temp.score >= 40 ? "#d29922" :
temp.score >= 20 ? "#3fb950" : "#58a6ff";
return (
<div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Thermometer className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">市场温度</span>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold tabular-nums" style={{ color: tempColor }}>
{temp.score}
</span>
<span className="text-xs" style={{ color: tempColor }}>{temp.label}</span>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 text-xs">
<StatItem label="上涨 / 下跌" value={`${stats.upCount} / ${stats.downCount}`} />
<StatItem label="市场宽度" value={`${stats.marketBreadth}%`} />
<StatItem label="中位涨跌" value={`${stats.medianChange >= 0 ? "+" : ""}${stats.medianChange.toFixed(2)}%`}
valueColor={stats.medianChange >= 0 ? "#f85149" : "#3fb950"} />
<StatItem label="强势 / 弱势" value={`${stats.strongCount} / ${stats.weakCount}`} />
<StatItem label="涨停 / 跌停" value={`${stats.limitUp} / ${stats.limitDown}`}
valueColor={stats.limitUp > 0 ? "#f85149" : "#8b949e"} />
<StatItem label="炸板 / 炸板率" value={`${stats.limitBreak} / ${stats.breakRate}%`}
valueColor={stats.breakRate > 30 ? "#f0883e" : "#8b949e"} />
</div>
{/* 温度因子明细 */}
{temp.factors && (
<div className="mt-3 pt-2 border-t border-border flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-muted-foreground/50">
<span>涨跌 {temp.factors.advanceScore > 0 ? "+" : ""}{temp.factors.advanceScore}</span>
<span>中位 {temp.factors.medianScore > 0 ? "+" : ""}{temp.factors.medianScore}</span>
<span>强弱 {temp.factors.strongScore > 0 ? "+" : ""}{temp.factors.strongScore}</span>
<span>涨停 {temp.factors.limitScore > 0 ? "+" : ""}{temp.factors.limitScore}</span>
<span>炸板 {temp.factors.breakPenalty}</span>
<span>竞价 {temp.factors.auctionBonus > 0 ? "+" : ""}{temp.factors.auctionBonus}</span>
</div>
)}
</div>
);
}
function StatItem({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) {
return (
<div>
<div className="text-[10px] text-muted-foreground/50 mb-0.5">{label}</div>
<div className="text-sm font-medium tabular-nums" style={valueColor ? { color: valueColor } : undefined}>
{value}
</div>
</div>
);
}
/* ============================================================
涨跌家数分布
============================================================ */
function AdvanceDeclineBar({ stats }: { stats: MarketDashboardData["marketStats"] }) {
const total = stats.upCount + stats.flatCount + stats.downCount;
if (total === 0) return null;
const upPct = (stats.upCount / total) * 100;
const flatPct = (stats.flatCount / total) * 100;
const downPct = (stats.downCount / total) * 100;
return (
<div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">涨跌家数分布</span>
</div>
<span className="text-[10px] text-muted-foreground/50">
涨停 {stats.limitUp} 炸板 {stats.limitBreak} 跌停 {stats.limitDown} 共 {total} 只
</span>
</div>
<div className="h-5 rounded-full overflow-hidden flex mb-2">
<div className="h-full bg-[#f85149] transition-all duration-500" style={{ width: `${upPct}%` }} />
<div className="h-full bg-[#484f58] transition-all duration-500" style={{ width: `${flatPct}%` }} />
<div className="h-full bg-[#3fb950] transition-all duration-500" style={{ width: `${downPct}%` }} />
</div>
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[#f85149]" />
<span className="text-muted-foreground">上涨</span>
<span className="font-medium tabular-nums">{stats.upCount}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[#484f58]" />
<span className="text-muted-foreground">平盘</span>
<span className="font-medium tabular-nums">{stats.flatCount}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[#3fb950]" />
<span className="text-muted-foreground">下跌</span>
<span className="font-medium tabular-nums">{stats.downCount}</span>
</div>
</div>
</div>
);
}
/* ============================================================
行业强度榜
============================================================ */
function SectorStrengthList({ sectors }: { sectors: SectorStrengthItem[] }) {
if (sectors.length === 0) return null;
return (
<div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">行业强度榜 TOP{sectors.length}</span>
</div>
</div>
<div className="space-y-2">
{sectors.slice(0, 15).map((sec) => (
<SectorRow key={sec.code} sector={sec} />
))}
</div>
</div>
);
}
function SectorRow({ sector }: { sector: SectorStrengthItem }) {
const barWidth = Math.max(0, Math.min(100, sector.strength));
const isUp = sector.changePct >= 0;
return (
<div className="flex items-center gap-3">
<span className="text-xs w-20 shrink-0 truncate" title={sector.name}>{sector.name}</span>
<div className="flex-1 h-4 bg-background rounded-sm overflow-hidden relative">
<div
className="h-full rounded-sm transition-all duration-500"
style={{
width: `${barWidth}%`,
background: isUp
? "linear-gradient(90deg, #1f6feb, #58a6ff)"
: "linear-gradient(90deg, #238636, #3fb950)",
}}
/>
</div>
<div className="flex items-center gap-2 shrink-0 text-[10px] tabular-nums w-44 justify-end">
<span className="text-muted-foreground">强度 {sector.strength}</span>
<span className={isUp ? "text-[#f85149]" : "text-[#3fb950]"}>
{isUp ? "+" : ""}{sector.changePct.toFixed(2)}%
</span>
<span className="text-muted-foreground">宽度 {sector.breadthPct}%</span>
<span className="text-muted-foreground">强 {sector.strongCount}</span>
</div>
</div>
);
}
/* ============================================================
概念板块热度
============================================================ */
function ConceptStrengthList({ concepts }: { concepts: MarketDashboardData["conceptStrength"] }) {
if (concepts.length === 0) return null;
return (
<div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">概念板块热度 TOP{concepts.length}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
{concepts.map((c) => {
const isUp = c.changePct >= 0;
return (
<span
key={c.code}
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs border ${
isUp
? "text-[#f85149] bg-[#f85149]/5 border-[#f85149]/20"
: "text-[#3fb950] bg-[#3fb950]/5 border-[#3fb950]/20"
}`}
>
<span className="truncate max-w-[80px]">{c.name}</span>
<span className="tabular-nums font-medium">
{isUp ? "+" : ""}{c.changePct.toFixed(2)}%
</span>
</span>
);
})}
</div>
</div>
);
}
/* ============================================================
事件情报侧栏
============================================================ */
function EventSidebar({ events }: { events: MarketDashboardData["events"] }) {
return (
<div className="bg-card border border-border rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<Newspaper className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">事件情报</span>
</div>
<div className="space-y-3">
{events.length === 0 ? (
<p className="text-xs text-muted-foreground/50">暂无事件</p>
) : (
events.map((evt, i) => (
<EventItem key={i} event={evt} />
))
)}
</div>
</div>
);
}
function EventItem({ event }: { event: MarketDashboardData["events"][0] }) {
const labelColor =
event.type === "ladder" ? "text-[#f85149] bg-[#f85149]/10 border-[#f85149]/30" :
event.type === "limit_up" ? "text-[#f0883e] bg-[#f0883e]/10 border-[#f0883e]/30" :
event.type === "hot" ? "text-[#d29922] bg-[#d29922]/10 border-[#d29922]/30" :
event.type === "skyrocket" ? "text-[#f778ba] bg-[#f778ba]/10 border-[#f778ba]/30" :
event.type === "dragon_tiger" ? "text-[#a371f7] bg-[#a371f7]/10 border-[#a371f7]/30" :
event.type === "limit_break" ? "text-muted-foreground bg-[#8b949e]/10 border-[#8b949e]/30" :
event.type === "anomaly" ? "text-[#58a6ff] bg-[#58a6ff]/10 border-[#58a6ff]/30" :
"text-muted-foreground bg-[#8b949e]/10 border-[#8b949e]/30";
return (
<div className="border-l-2 border-border pl-3">
<div className="flex items-center gap-2 mb-0.5">
<span className={`text-[9px] font-medium px-1 py-0.5 rounded border ${labelColor}`}>
{event.label}
</span>
</div>
<p className="text-xs text-foreground leading-relaxed">{event.name}</p>
{event.detail && (
<p className="text-[10px] text-muted-foreground/50 mt-0.5">{event.detail}</p>
)}
</div>
);
}
/* ============================================================
骨架屏
============================================================ */
function DashboardSkeleton() {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-card border border-border rounded-lg p-3 animate-pulse">
<div className="h-3 w-16 bg-muted rounded mb-2" />
<div className="h-6 w-24 bg-muted rounded mb-1" />
<div className="h-3 w-20 bg-muted rounded" />
</div>
))}
</div>
<div className="bg-card border border-border rounded-lg p-4 animate-pulse">
<div className="h-5 w-24 bg-muted rounded mb-4" />
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i}>
<div className="h-2 w-16 bg-muted rounded mb-1" />
<div className="h-4 w-12 bg-muted rounded" />
</div>
))}
</div>
</div>
<div className="bg-card border border-border rounded-lg p-4 animate-pulse">
<div className="h-5 w-32 bg-muted rounded mb-3" />
<div className="h-5 w-full bg-muted rounded-full" />
</div>
<div className="bg-card border border-border rounded-lg p-4 animate-pulse">
<div className="h-5 w-40 bg-muted rounded mb-3" />
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 mb-2">
<div className="h-3 w-16 bg-muted rounded" />
<div className="flex-1 h-4 bg-muted rounded" />
<div className="h-3 w-24 bg-muted rounded" />
</div>
))}
</div>
</div>
);
}
+139 -27
View File
@@ -35,6 +35,10 @@ const MODES: { key: 1 | 4; label: string }[] = [
/* 图视图股票节点上限:按覆盖题材数降序保留最高穿透度的核心股 */
const MAX_STOCK_NODES = 800;
/* 题材节点半径范围:按题材涨幅绝对值平方根映射(涨得越猛球越大,小涨幅区分更明显) */
const THEME_MIN_RADIUS = 6;
const THEME_MAX_RADIUS = 30;
/* 视图切换:关系图 / 核心股列表 */
type ViewMode = "graph" | "list";
@@ -48,7 +52,7 @@ function HotMapPage() {
const { data: graph, isLoading, isFetching, isError, refetch } = useQuery({
queryKey: ["themeGraph", mode],
queryFn: () => fetchThemeGraph(mode, 50),
queryFn: () => fetchThemeGraph(mode, 100),
staleTime: 60_000,
retry: false,
});
@@ -114,6 +118,13 @@ function HotMapPage() {
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
</button>
<Link
to="/core-stocks"
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity whitespace-nowrap"
>
<Flame className="h-3.5 w-3.5" />
热点股
</Link>
</div>
</div>
@@ -265,6 +276,8 @@ interface SimNode extends SimulationNodeDatum {
f100?: string;
themeCodes?: string[];
code?: string;
// theme 专属
bf3?: number | null; // 题材涨幅
}
interface SimEdge extends SimulationLinkDatum<SimNode> {
@@ -351,12 +364,20 @@ function drawEdges(ctx: CanvasRenderingContext2D, edges: SimEdge[], activeSet: S
ctx.globalAlpha = 1;
}
/** 股票节点:覆盖数分级颜色 + 外圈淡填充 + 描边 + 内实心 */
/* 心跳周期 ms;0~1 相位,0 最小 / 0.5 最大 */
const PULSE_PERIOD = 900;
const PULSE_AMPLITUDE = 0.22; // 心跳半径脉动幅度(相对半径)
/**
* 股票节点:覆盖数分级颜色 + 心跳(≥5题材) + 涨跌光晕 + 描边 + 内实心
* time(ms) 由持续动画循环驱动;非动画源(t=0)时心跳相位取 0 的静止态。
*/
function drawStockNodes(
ctx: CanvasRenderingContext2D,
nodes: SimNode[],
activeSet: Set<string> | null,
activeId: string | null,
time: number,
) {
for (const n of nodes) {
if (n.type !== "stock") continue;
@@ -364,30 +385,59 @@ function drawStockNodes(
const isActive = activeId === n.id;
const dim = activeSet ? !activeSet.has(n.id) : false;
const alpha = dim ? 0.08 : cover === 1 && !isActive ? 0.45 : 1;
const r = isActive ? n.radius + 3 : n.radius;
const fill = cover >= 7 ? "#dc2626" : cover >= 5 ? "#ef4444" : "#f97316";
const x = n.x ?? 0;
const y = n.y ?? 0;
// 外圈淡填充
const isPos = (n.f3 ?? 0) >= 0;
// 心跳:覆盖≥5 的核心股按正弦脉动半径(相位在 0 时静止,避免动画源缺省导致闪烁)
const heartbeat = cover >= 5 ? 0.5 + 0.5 * Math.sin((time / PULSE_PERIOD) * TAU - Math.PI / 2) : 0;
const r = isActive ? n.radius + 3 : n.radius;
const pr = r * (1 + heartbeat * PULSE_AMPLITUDE);
// 涨跌光晕:所有股票统一强度(不随覆盖数 alpha 衰减),随心跳脉动,正=红 / 负=绿
const glowColor = isPos ? "rgba(239,68,68," : "rgba(34,197,94,"; // 红/绿
const glowR = pr * 1.9;
const glow = ctx.createRadialGradient(x, y, pr * 0.2, x, y, glowR);
glow.addColorStop(0, `${glowColor}${dim ? 0.06 : 0.5})`);
glow.addColorStop(1, `${glowColor}0)`);
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.arc(x, y, glowR, 0, TAU);
ctx.fillStyle = glow;
ctx.globalAlpha = 1;
ctx.fill();
if (isPos) {
// 涨:外圈淡填充 + 分级描边 + 内实心(覆盖数越多越红)
const fill = cover >= 7 ? "#dc2626" : cover >= 5 ? "#ef4444" : "#f97316";
ctx.beginPath();
ctx.arc(x, y, pr, 0, TAU);
ctx.fillStyle = fill;
ctx.globalAlpha = 0.35 * alpha;
ctx.fill();
// 描边
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
ctx.lineWidth = cover >= 2 ? (cover >= 4 ? 2 : 1.5) : 0.5;
ctx.globalAlpha = alpha;
ctx.stroke();
// 内实心
ctx.beginPath();
ctx.arc(x, y, n.radius, 0, TAU);
ctx.arc(x, y, pr, 0, TAU);
ctx.globalAlpha = (cover >= 2 ? 0.9 : 0.55) * alpha;
ctx.fill();
} else {
// 跌:绿色空心球(仅描边,内部淡绿留白)
ctx.beginPath();
ctx.arc(x, y, pr, 0, TAU);
ctx.strokeStyle = "#22c55e";
ctx.lineWidth = 2;
ctx.globalAlpha = dim ? 0.15 : 1;
ctx.stroke();
ctx.fillStyle = "#22c55e";
ctx.globalAlpha = dim ? 0.02 : 0.1;
ctx.fill();
}
// 选中外环
if (isActive) {
ctx.beginPath();
ctx.arc(x, y, n.radius + 3, 0, TAU);
ctx.arc(x, y, pr + 3, 0, TAU);
ctx.globalAlpha = alpha;
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
ctx.lineWidth = 1;
@@ -397,7 +447,7 @@ function drawStockNodes(
ctx.globalAlpha = 1;
}
/** 题材节点:蓝色圆点 */
/** 题材节点:涨幅正蓝负绿,大小按涨幅绝对值映射 */
function drawThemeNodes(
ctx: CanvasRenderingContext2D,
nodes: SimNode[],
@@ -409,14 +459,32 @@ function drawThemeNodes(
const isActive = activeId === n.id;
const dim = activeSet ? !activeSet.has(n.id) : false;
const r = isActive ? n.radius + 3 : n.radius;
const isPos = (n.bf3 ?? 0) >= 0;
ctx.beginPath();
ctx.arc(n.x ?? 0, n.y ?? 0, r, 0, TAU);
ctx.fillStyle = isActive ? "#2563eb" : "#3b82f6";
// 正涨幅:蓝色实心;负涨幅:蓝色空心
ctx.strokeStyle = "#3b82f6";
ctx.lineWidth = isPos ? 1.5 : 2;
if (isPos) {
ctx.fillStyle = "#3b82f6";
ctx.globalAlpha = dim ? 0.15 : 1;
ctx.fill();
ctx.strokeStyle = "#1d4ed8";
ctx.lineWidth = 1.5;
} else {
// 空心:仅描边,内部留白
ctx.fillStyle = "#3b82f6";
ctx.globalAlpha = dim ? 0.05 : 0.15;
ctx.fill();
ctx.globalAlpha = dim ? 0.15 : 0.6;
}
ctx.stroke();
// 选中外环
if (isActive) {
ctx.beginPath();
ctx.arc(n.x ?? 0, n.y ?? 0, r + 3, 0, TAU);
ctx.strokeStyle = "#60a5fa";
ctx.lineWidth = 1;
ctx.stroke();
}
}
ctx.globalAlpha = 1;
}
@@ -453,7 +521,8 @@ function drawThemeLabels(
if (n.type !== "theme") continue;
if (!showAll && activeId !== n.id) continue;
const label = isCoarse ? (n.name.length > 5 ? n.name.slice(0, 5) + "…" : n.name) : n.name;
haloText(ctx, label, n.x ?? 0, (n.y ?? 0) + n.radius + 11, isCoarse ? 7.5 : 9, "#1e40af");
const labelColor = (n.bf3 ?? 0) >= 0 ? "#1e40af" : "#15803d";
haloText(ctx, label, n.x ?? 0, (n.y ?? 0) + n.radius + 11, isCoarse ? 7.5 : 9, labelColor);
}
}
@@ -486,6 +555,8 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
const simRunningRef = useRef(false);
const fitOnceRef = useRef(false);
const rafRef = useRef(0);
const timeRef = useRef(0); // 动画时钟(ms),心跳/光晕脉动用
const pulseStartRef = useRef(0); // 持续动画起始时间戳(ms)
const pointersRef = useRef(new Map<number, { x: number; y: number }>());
const gestureRef = useRef<Gesture>({ kind: "none" });
@@ -511,19 +582,20 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
if (activeSet) {
// 高亮态:全量重绘(邻居亮、非邻居淡出)
drawEdges(ctx, edges, activeSet);
drawStockNodes(ctx, nodes, activeSet, activeId);
drawStockNodes(ctx, nodes, activeSet, activeId, timeRef.current);
drawThemeNodes(ctx, nodes, activeSet, activeId);
drawActiveNodeLabel(ctx, activeNodeRef.current, isCoarse);
} else {
const b = boundsRef.current;
const sc = staticCanvasRef.current;
if (staticReadyRef.current && sc && b.w > 0 && b.h > 0 && v.k < 1.5) {
// 静止态:drawImage 静态层 + 动态层
// 静止态:静态层(边+题材)+ 动态层重绘股票(心跳/光晕动画)
ctx.drawImage(sc, b.minX, b.minY, b.w, b.h);
drawStockNodes(ctx, nodes, null, null, timeRef.current);
} else {
// 模拟期或大比例放大:直接全量绘制(保证清晰)
drawEdges(ctx, edges, null);
drawStockNodes(ctx, nodes, null, null);
drawStockNodes(ctx, nodes, null, null, timeRef.current);
drawThemeNodes(ctx, nodes, null, null);
}
}
@@ -570,7 +642,7 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
sctx.setTransform(sw / w, 0, 0, sh / h, 0, 0);
sctx.translate(-minX2, -minY2);
drawEdges(sctx, edges, null);
drawStockNodes(sctx, nodes, null, null);
// 股票节点不在静态层:需常驻重绘以支持心跳/光晕动画
drawThemeNodes(sctx, nodes, null, null);
staticReadyRef.current = true;
boundsRef.current = { minX: minX2, minY: minY2, w, h };
@@ -627,6 +699,19 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
return () => ro.disconnect();
}, [requestRender]);
/* 持续动画循环:心跳 + 光晕脉动。力收敛后常驻 rAF,仅更新时间与触发重绘 */
useEffect(() => {
pulseStartRef.current = performance.now();
let anim = 0;
const loop = () => {
timeRef.current = performance.now() - pulseStartRef.current;
requestRender();
anim = requestAnimationFrame(loop);
};
anim = requestAnimationFrame(loop);
return () => cancelAnimationFrame(anim);
}, [requestRender]);
/* 节点过滤 + 边重建(后端不再下发 edges,由 stocks[].themeCodes 重建) */
const { nodes, edges, stockById } = useMemo(() => {
if (!graph) return { nodes: [] as SimNode[], edges: [] as SimEdge[], stockById: new Map<string, GraphStock>() };
@@ -645,16 +730,24 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
f62: s.f62,
f100: s.f100,
themeCodes: s.themeCodes,
radius: Math.min(18, 4 + s.coverCount * 1.3),
// 基础半径来自覆盖数,涨幅绝对值作增量:涨得猛/跌得深球更大
radius: Math.min(20, 3 + s.coverCount * 1.2 + (Math.abs(s.f3 ?? 0) / 10) * 4),
}));
const themeNodes: SimNode[] = graph.themes.map((t) => ({
// 题材涨幅绝对值作为球大小的归一化基准(兜底 ≥1 防除零)
const maxAbsBf3 = Math.max(1, ...graph.themes.map((t) => Math.abs(t.bf3 ?? 0)));
const themeNodes: SimNode[] = graph.themes.map((t) => {
const bf3 = t.bf3 ?? 0;
return {
id: `t:${t.themeCode}`,
type: "theme" as const,
name: t.themeName,
code: t.themeCode,
coverCount: t.stockCount,
radius: 10,
}));
bf3: t.bf3,
// 平方根映射:同一个小涨幅区间内球径差异更大(线性映射被极端涨幅拉平均)
radius: THEME_MIN_RADIUS + Math.sqrt(Math.abs(bf3) / maxAbsBf3) * (THEME_MAX_RADIUS - THEME_MIN_RADIUS),
};
});
const edgeList: SimEdge[] = buildEdgesFromStocks(kept);
const stockByIdMap = new Map<string, GraphStock>();
@@ -722,9 +815,9 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
)
.force("charge", forceManyBody<SimNode>().strength((d) => (d.type === "theme" ? -650 : -35)))
.force("center", forceCenter(size.w / 2, size.h / 2))
// 题材节点留出标签高度防文字重叠(标签在节点下方)
// 题材节点留出标签高度防文字重叠(标签在节点下方);股票节点留 6px 最小间隔
.force("collide", forceCollide<SimNode>().radius((d) =>
d.type === "theme" ? d.radius + (isCoarse ? 20 : 16) : d.radius + 4,
d.type === "theme" ? d.radius + (isCoarse ? 20 : 16) : d.radius + 6,
));
simRunningRef.current = true;
@@ -1000,6 +1093,11 @@ function InfoCard({ node, stockById }: { node: SimNode; stockById: Map<string, G
) : (
<>
<p className="font-semibold">{node.name}</p>
{node.bf3 != null && (
<p className={`text-xs font-bold ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
{node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
</p>
)}
<p className="text-[10px] text-muted-foreground">代码 {node.code} · {node.coverCount} 只股票</p>
</>
)}
@@ -1058,6 +1156,11 @@ function BottomSheet({
) : (
<>
<p className="font-semibold text-sm">{node.name}</p>
{node.bf3 != null && (
<p className={`text-xs font-bold mt-0.5 ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
{node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
</p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5">
代码 {node.code} · 覆盖 {node.coverCount} 只股票
</p>
@@ -1104,7 +1207,11 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block rounded-full bg-blue-500" style={{ width: 8, height: 8 }} />
题材
题材涨
</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block rounded-full border-2 border-blue-500" style={{ width: 8, height: 8 }} />
题材跌
</span>
</div>
);
@@ -1124,8 +1231,13 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
</div>
<div className="flex items-center gap-2 pt-1 border-t border-border/40">
<span className="inline-block rounded-full bg-blue-500" style={{ width: 10, height: 10 }} />
<span>题材节点</span>
<span>题材 · 涨幅为正</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-block rounded-full border-2 border-blue-500" style={{ width: 10, height: 10 }} />
<span>题材 · 涨幅为负</span>
</div>
<div className="text-[9px] text-muted-foreground pt-0.5">球大小随涨幅绝对值增大</div>
</div>
);
}
+11 -5
View File
@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network } from "lucide-react";
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network, BarChart3, BrainCircuit } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/")({
@@ -211,11 +211,11 @@ function Index() {
A股走势追踪
</h1>
<p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p>
<div className="mt-3 flex items-center justify-center gap-2">
<Link to="/sectors">
<div className="mt-3 flex flex-wrap items-center justify-center gap-2">
<Link to="/dashboard">
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
<TrendingUp className="h-3.5 w-3.5" />
板块资金流向
<BarChart3 className="h-3.5 w-3.5" />
市场看板
</Button>
</Link>
<Link to="/themes">
@@ -230,6 +230,12 @@ function Index() {
热点穿透
</Button>
</Link>
<Link to="/ai-analysis">
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
<BrainCircuit className="h-3.5 w-3.5" />
AI 分析
</Button>
</Link>
</div>
</div>
-366
View File
@@ -1,366 +0,0 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchSectors, type SectorItem, type SectorType } from "@/lib/stock-api";
import { formatMoney } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import {
ArrowLeft,
RefreshCw,
ArrowDown,
ArrowUp,
TrendingUp,
TrendingDown,
} from "lucide-react";
export const Route = createFileRoute("/sectors")({
component: SectorsPage,
});
/* ============================================================
Tab 定义:行业 / 概念
============================================================ */
const TABS: { key: SectorType; label: string }[] = [
{ key: "industry", label: "行业" },
{ key: "concept", label: "概念" },
];
/* ============================================================
排序维度
============================================================ */
type SortKey = "mainNetInflow" | "mainNetInflowPercent";
const SORT_LABEL: Record<SortKey, string> = {
mainNetInflow: "资金",
mainNetInflowPercent: "涨幅",
};
/* ============================================================
页面组件
============================================================ */
function SectorsPage() {
const queryClient = useQueryClient();
const [tab, setTab] = useState<SectorType>("industry");
const [sortKey, setSortKey] = useState<SortKey>("mainNetInflow");
const [asc, setAsc] = useState(true); // 默认升序
// ── 行业 / 概念各自独立 Query,缓存完全隔离 ──
const industryQ = useQuery({
queryKey: ["sectors", "industry"],
queryFn: ({ signal }) => fetchSectors("industry", signal),
staleTime: 30_000,
retry: false,
});
const conceptQ = useQuery({
queryKey: ["sectors", "concept"],
queryFn: ({ signal }) => fetchSectors("concept", signal),
staleTime: 30_000,
retry: false,
});
// 当前激活的 tab 查询
const activeQuery = tab === "industry" ? industryQ : conceptQ;
const { isLoading, isFetching, isError, refetch } = activeQuery;
/* ═══════════════════════════════════════════════════════
三层数据分离:缓存 → 排序 → 展示
═══════════════════════════════════════════════════════ */
// ① 缓存数据 — React Query 从后端拿到的原始数据
const cachedData: SectorItem[] = activeQuery.data ?? [];
// ② 排序数据 — 按当前排序规则在内存中重排
const sortedData = useMemo<SectorItem[]>(() => {
const dir = asc ? 1 : -1;
return [...cachedData].sort((a, b) => {
const av =
sortKey === "mainNetInflow"
? a.mainNetInflow
: (a.mainNetInflowPercent ?? -Infinity);
const bv =
sortKey === "mainNetInflow"
? b.mainNetInflow
: (b.mainNetInflowPercent ?? -Infinity);
return (bv - av) * dir;
});
}, [cachedData, sortKey, asc]);
// ③ 展示数据 — 最终渲染的数据集(当前即排序数据,后续可加分页截断)
const displayData = sortedData;
// ── 切换板块:清空全部缓存 + 重新获取 ──
const handleTab = (t: SectorType) => {
if (t === tab) return;
setTab(t);
// 移除所有板块缓存,切换后对应的 useQuery 会自动 refetch
queryClient.removeQueries({ queryKey: ["sectors"] });
};
// ── 切换排序 ──
const toggleSort = (key: SortKey) => {
if (key === sortKey) {
setAsc((v) => !v);
} else {
setSortKey(key);
setAsc(true); // 切新维度默认升序
}
};
return (
<div className="min-h-screen bg-background">
{/* ── 顶栏 ── */}
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/" className="hover:opacity-70 transition-opacity">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-base font-semibold">板块资金流向</h1>
</div>
<button
onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors"
title="刷新"
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
</button>
</div>
</header>
{/* ── Tab 切换 ── */}
<div className="max-w-5xl mx-auto px-4 mt-4">
<div className="flex gap-1 bg-muted rounded-lg p-1">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => handleTab(t.key)}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
tab === t.key
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t.label}板块
</button>
))}
</div>
</div>
{/* ── 排序切换 + 统计 ── */}
<div className="max-w-5xl mx-auto px-4 mt-3 flex items-center justify-between">
<p className="text-[10px] text-muted-foreground">
共 {cachedData.length} 个板块
{isFetching && (
<span className="ml-1 text-[10px] text-muted-foreground/60">
· 刷新中…
</span>
)}
</p>
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{(Object.keys(SORT_LABEL) as SortKey[]).map((key) => (
<button
key={key}
onClick={() => toggleSort(key)}
className={`px-2.5 py-1 flex items-center gap-0.5 transition-colors ${
sortKey === key
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{SORT_LABEL[key]}
{sortKey === key &&
(asc ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</button>
))}
</div>
</div>
{/* ── 内容区 ── */}
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
{/* 加载骨架 */}
{isLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-xl bg-muted h-40" />
))}
</div>
) : isError ? (
/* 请求失败 */
<div className="flex flex-col items-center gap-3 py-20">
<p className="text-sm text-muted-foreground">数据加载失败</p>
<button
onClick={() => refetch()}
className="text-xs text-primary hover:underline"
>
点击重试
</button>
</div>
) : cachedData.length === 0 ? (
/* 数据为空 */
<div className="text-center py-20 text-sm text-muted-foreground">
暂无{tab === "industry" ? "行业" : "概念"}板块数据
</div>
) : (
/* 板块卡片网格 */
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{displayData.map((item) => (
<SectorCard key={item.code} item={item} />
))}
</div>
)}
</div>
</div>
);
}
/* ============================================================
数值格式化
============================================================ */
function fmt(val: number | null | undefined, digits = 2): string {
if (val == null) return "--";
return val.toFixed(digits);
}
/* ============================================================
板块卡片
============================================================ */
function SectorCard({ item }: { item: SectorItem }) {
const change = item.changePercent;
const inflow = item.mainNetInflow;
const inflowIsPos = inflow >= 0;
const inflowPct = item.mainNetInflowPercent;
return (
<Card className="rounded-xl hover:shadow-md transition-shadow">
<CardContent className="p-3 space-y-1.5">
{/* 板块名称 + 代码 */}
<div className="flex items-center justify-between gap-1">
<p className="text-sm font-medium truncate" title={item.name}>
{item.name}
</p>
{item.code && (
<span className="shrink-0 text-[9px] text-muted-foreground/60 font-mono">
{item.code.replace("BK", "")}
</span>
)}
</div>
{/* 涨跌幅 + 成交额 */}
<div className="flex items-center justify-between">
{change != null ? (
<span
className={`inline-flex items-center gap-0.5 text-xs font-semibold ${
change >= 0 ? "text-red-500" : "text-green-500"
}`}
>
{change >= 0 ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
{change >= 0 ? "+" : ""}
{fmt(change)}%
</span>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
<span className="text-[10px] text-muted-foreground">
{formatMoney(item.turnover)}
</span>
</div>
{/* 分割线 */}
<hr className="border-border/40" />
{/* 主力净流入金额 + 占比 */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">主力净流入</span>
<div className="flex items-center gap-2">
<span
className={`text-xs font-bold tabular-nums ${
inflowIsPos ? "text-red-500" : "text-green-500"
}`}
>
{inflow >= 0 ? "+" : ""}
{formatMoney(inflow)}
</span>
{inflowPct != null && (
<span
className={`text-[10px] tabular-nums ${
inflowIsPos ? "text-red-500/70" : "text-green-500/70"
}`}
>
{inflow >= 0 ? "+" : ""}
{fmt(inflowPct)}%
</span>
)}
</div>
</div>
{/* 资金流向明细条 */}
<FundFlowBreakdown item={item} />
</CardContent>
</Card>
);
}
/* ============================================================
资金流向明细 — 超大单 / 大单 / 中单 / 小单
============================================================ */
const FLOW_LABELS = [
{ key: "superLargeInflow" as const, label: "超大单" },
{ key: "largeInflow" as const, label: "大单" },
{ key: "mediumInflow" as const, label: "中单" },
{ key: "smallInflow" as const, label: "小单" },
];
function FundFlowBreakdown({ item }: { item: SectorItem }) {
// 取所有流量的最大绝对值做归一化
const maxAbs = Math.max(
Math.abs(item.mainNetInflow),
Math.abs(item.superLargeInflow ?? 0),
Math.abs(item.largeInflow ?? 0),
Math.abs(item.mediumInflow ?? 0),
Math.abs(item.smallInflow ?? 0),
1,
);
return (
<div className="space-y-0.5">
{FLOW_LABELS.map((f) => {
const val = item[f.key];
if (val == null) return null;
const pct = maxAbs > 0 ? (Math.abs(val) / maxAbs) * 100 : 0;
const isPos = val >= 0;
return (
<div key={f.key} className="flex items-center gap-1.5">
<span className="text-[9px] text-muted-foreground w-6 shrink-0 text-right">
{f.label}
</span>
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden relative">
<div
className={`h-full rounded-full transition-all ${
isPos ? "bg-red-500/60 ml-1/2" : "bg-green-500/60"
}`}
style={{
width: `${Math.min(pct, 100)}%`,
marginLeft: isPos ? "50%" : undefined,
marginRight: isPos ? undefined : `${100 - Math.min(pct, 100)}%`,
}}
/>
</div>
<span
className={`text-[9px] font-medium tabular-nums w-14 text-right shrink-0 ${
isPos ? "text-red-500" : "text-green-500"
}`}
>
{formatMoney(val)}
</span>
</div>
);
})}
</div>
);
}
+14 -221
View File
@@ -1,7 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect, useMemo, Fragment } from "react";
import { useState, useEffect, useMemo } from "react";
import { collectionsApi } from "@/lib/api-client";
import { fetchStockQuote, fetchStockHistory, fetchStockFundFlow, fetchCompanyProfile, fetchBusinessSegments, fetchFinancialData, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse } from "@/lib/stock-api";
import { fetchStockQuote, fetchStockFundFlow, fetchCompanyProfile, fetchBusinessSegments, fetchFinancialData, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse } from "@/lib/stock-api";
import { fetchStockHistoryV2 } from "@/lib/fuyao-api";
import StockProfileTabs from "@/components/stock-profile-tabs";
import { getUserId } from "@/lib/user-id";
import { formatMoney } from "@/lib/utils";
@@ -10,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { ArrowLeft, TrendingUp, TrendingDown, Calendar, ExternalLink, Newspaper } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Link } from "@tanstack/react-router";
import KLineCard from "@/components/kline-card";
import {
ComposedChart,
Line,
@@ -20,8 +22,6 @@ import {
ResponsiveContainer,
Bar,
Cell,
Scatter,
Brush,
ReferenceLine,
PieChart,
Pie,
@@ -40,6 +40,7 @@ export const Route = createFileRoute("/stock/$code")({
interface StockData {
date: string;
dateObj: Date;
dateMs: number; // Unix seconds UTC (lightweight-charts numeric time)
open: number;
close: number;
high: number;
@@ -172,21 +173,15 @@ function StockDetail() {
// 基础信息已就绪,结束主loading,先渲染页面框架
setLoading(false);
// 阶段2:并行加载历史K线 + 资金流向(后置加载,不阻塞首屏)
// 阶段2:并行拉取日K(供每日行情明细表格)+ 资金流向(非必需,失败不影响页面)
const addedDate = new Date(addedAt);
const now = new Date();
const daysSinceAdded = Math.floor((now.getTime() - addedDate.getTime()) / (1000 * 60 * 60 * 24));
const chartDays = Math.min(Math.max(90, daysSinceAdded), 120);
setChartLoading(true);
setFundFlowLoading(true);
const [historyResult, fundFlowResult] = await Promise.allSettled([
fetchStockHistory(code, chartDays),
fetchStockHistoryV2(code, 30), // 明细表默认只展示近7/21日,30天足够
fetchStockFundFlow(code, quote.name, 21),
]);
// 处理历史K线(必需)
if (historyResult.status === "fulfilled" && historyResult.value && historyResult.value.length > 0) {
const historyData = convertToStockData(historyResult.value, addedDate);
// 如果添加日不是交易日(周末/节假日),标记最接近的K线日期
@@ -197,22 +192,15 @@ function StockDetail() {
}
}
setChartData(historyData);
if (historyResult.value.length < 2) {
console.warn("历史K线数据过少(新股或上市首日),仅显示有限数据");
}
} else {
console.error("无法获取历史K线数据");
setError("获取历史K线数据失败,请稍后重试");
}
// 处理资金流向(非必需,失败不影响主流程)
if (fundFlowResult.status === "fulfilled" && fundFlowResult.value) {
console.log("[stock-detail] 资金流向数据:", fundFlowResult.value);
setFundFlowData(fundFlowResult.value.data);
setFundFlowSummary(fundFlowResult.value.summary || null);
} else {
console.error("获取资金流向数据失败:", fundFlowResult.status === "rejected" ? fundFlowResult.reason : "未知错误");
console.error("获取资金流向数据失败(非致命):", fundFlowResult.status === "rejected" ? fundFlowResult.reason : "未获取到数据");
}
} catch (err) {
console.error("加载失败:", err);
const msg = err instanceof Error ? err.message : "加载股票数据失败";
@@ -224,7 +212,7 @@ function StockDetail() {
}
};
// 将K线数据转换为图表格式
// 将K线数据转换为表格格式(每日行情明细用)
const convertToStockData = (klines: KLineData[], addedDate: Date): StockData[] => {
return klines.map(kline => {
const dateObj = new Date(kline.date);
@@ -233,6 +221,7 @@ function StockDetail() {
return {
date: dateObj.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }),
dateObj,
dateMs: Math.floor(dateObj.getTime() / 1000), // Unix seconds UTC
open: kline.open,
close: kline.close,
high: kline.high,
@@ -287,6 +276,7 @@ function StockDetail() {
const roe = latest.indicators["加权净资产收益率"];
return typeof roe === "number" ? roe : null;
}, [financialData]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -330,31 +320,6 @@ function StockDetail() {
: null;
const cumulativeIsPositive = cumulativeChange !== null && cumulativeChange >= 0;
// 找到添加日期在图表中的位置
const addedDateObj = stockInfo ? new Date(stockInfo.addedAt) : null;
const addedDateStr = addedDateObj ? addedDateObj.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }) : "";
const addedDataPoint = inCollection ? chartData.find(d => d.isAddedDate) : undefined;
// 计算X轴刻度:保证添加日期始终有标签
const tickDates = (() => {
if (chartData.length === 0) return [];
const first = chartData[0].date;
const last = chartData[chartData.length - 1].date;
const ticks = [first];
const added = addedDataPoint?.date;
const targetCount = Math.min(7, chartData.length);
const step = Math.max(1, Math.floor((chartData.length - 1) / (targetCount - 2)));
for (let i = step; i < chartData.length - 1; i += step) {
ticks.push(chartData[i].date);
}
if (added && !ticks.includes(added)) {
ticks.push(added);
}
ticks.push(last);
// 保持chartData的原始顺序(已按日期升序排列),不做二次排序
return [...new Set(ticks)];
})();
// 东方财富市场标识:0=深圳(000/002/300), 1=上海(60), 6=科创板(688)
const getEastMoneyMarket = (code: string): string => {
if (code.startsWith('688')) return '6';
@@ -508,180 +473,8 @@ function StockDetail() {
</CardContent>
</Card>
{/* Chart */}
<Card className="shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-base md:text-lg">
K线图
<span className="text-xs md:text-sm font-normal text-muted-foreground ml-2">
{chartLoading ? "(加载中...)" : `(${chartData.length}个交易日)`}
</span>
</CardTitle>
<span className="text-xs text-muted-foreground hidden sm:inline">
双指缩放 · 拖动选区查看
</span>
</div>
</CardHeader>
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
{chartLoading ? (
<div className="h-[320px] sm:h-[380px] md:h-[440px] 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线数据加载中...
</div>
</div>
) : (
<>
<div
className="h-[320px] sm:h-[380px] md:h-[440px] w-full touch-none select-none"
style={{ touchAction: "none", overscrollBehavior: "none", WebkitOverflowScrolling: "auto" }}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
margin={{ top: 10, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" opacity={0.5} />
<XAxis
dataKey="date"
stroke="var(--muted-foreground)"
fontSize={10}
ticks={tickDates}
angle={-45}
textAnchor="end"
height={56}
tick={(props: any) => {
const { x, y, payload } = props;
const dataPoint = chartData.find(d => d.date === payload.value);
const isAddedDate = inCollection && dataPoint?.isAddedDate;
return (
<text
x={x}
y={y}
dy={16}
textAnchor="end"
fill={isAddedDate ? '#f59e0b' : 'var(--muted-foreground)'}
fontWeight={isAddedDate ? 'bold' : 'normal'}
fontSize={isAddedDate ? 11 : 10}
>
{payload.value}
</text>
);
}}
/>
<YAxis
yAxisId="left"
stroke="var(--muted-foreground)"
fontSize={10}
domain={["auto", "auto"]}
tickCount={5}
width={42}
/>
<YAxis
yAxisId="right"
orientation="right"
stroke="var(--muted-foreground)"
fontSize={10}
tickCount={5}
width={42}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
fontSize: "12px",
padding: "8px 12px",
}}
labelFormatter={(label) => `日期: ${label}`}
formatter={(value: any, name: string) => {
if (name === 'close') return [`¥${Number(value).toFixed(2)}`, '收盘价'];
if (name === 'volume') return [Number(value).toLocaleString(), '成交量'];
return [value, name];
}}
/>
<Bar
yAxisId="right"
dataKey="volume"
fill="var(--primary)"
opacity={0.2}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="close"
stroke="var(--primary)"
strokeWidth={2}
dot={false}
/>
{/* 高亮添加日期的K线点 */}
<Scatter
yAxisId="left"
dataKey="close"
fill="#fbbf24"
shape={(props: any) => {
const { cx, cy, payload } = props;
if (!payload.isAddedDate) {
return <circle cx={cx} cy={cy} r={0} fill="transparent" />;
}
return (
<circle
cx={cx}
cy={cy}
r={7}
fill="#fbbf24"
stroke="#f59e0b"
strokeWidth={2.5}
/>
);
}}
/>
{/* 添加日期参考线 */}
{addedDataPoint && (
<ReferenceLine
yAxisId="left"
x={addedDataPoint.date}
stroke="#f59e0b"
strokeDasharray="4 4"
opacity={0.7}
/>
)}
{/* 缩放/滑动控件:移动端可双指缩放,拖动选区查看局部 */}
<Brush
dataKey="date"
height={24}
stroke="var(--primary)"
fill="var(--muted)"
travellerWidth={8}
gap={8}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="flex flex-wrap justify-center gap-3 md:gap-6 mt-2 md:mt-4 text-xs md:text-sm">
<div className="flex items-center gap-1.5 md:gap-2">
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-primary"></div>
<span>收盘价</span>
</div>
<div className="flex items-center gap-1.5 md:gap-2">
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-primary opacity-20"></div>
<span>成交量</span>
</div>
{inCollection && (
<div className="flex items-center gap-1.5 md:gap-2">
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full" style={{ backgroundColor: '#fbbf24' }}></div>
<span>自选日期</span>
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
{/* Chart - 独立K线卡片组件(周期/显示方式/指标状态与数据加载均自包含) */}
<KLineCard code={code} addedAt={stockInfo.addedAt} ready={true} />
{/* Price Details */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mt-4 md:mt-6">
+110
View File
@@ -0,0 +1,110 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { fetchActiveThemes } from "@/lib/theme-api";
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
export const Route = createFileRoute("/theme-history")({
component: ThemeHistoryPage,
});
/** 格式化涨幅,红涨绿跌 */
function formatGain(v: number | null | undefined): string {
if (v == null) return "·";
const s = v >= 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
return s;
}
function ThemeHistoryPage() {
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ["themes", "active"],
queryFn: fetchActiveThemes,
staleTime: 60_000,
retry: false,
});
const dates = data?.dates ?? [];
const themes = data?.themes ?? [];
return (
<div className="min-h-screen bg-background">
{/* 顶栏 */}
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/themes" className="hover:opacity-70 transition-opacity" aria-label="返回">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-base font-semibold">题材热点历史</h1>
</div>
<button
onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
</button>
</div>
</header>
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
<p className="text-[10px] text-muted-foreground mb-2">
活跃题材(最近 10 个交易日内上榜涨幅前20)· 按上榜次数排序 · 共 {themes.length} 个
</p>
{isLoading ? (
<div className="animate-pulse rounded-xl bg-muted h-32" />
) : dates.length === 0 || themes.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-16">
暂无数据,数据将在每日收盘后自动采集
</div>
) : (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th scope="col" className="px-3 py-2 text-left font-medium whitespace-nowrap">题材</th>
{dates.map((d) => (
<th key={d} scope="col" title={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
{d.slice(5)}
</th>
))}
<th scope="col" className="px-2 py-2 text-right font-medium">上榜</th>
</tr>
</thead>
<tbody>
{themes.map((t) => (
<tr key={t.themeCode} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-3 py-1.5 whitespace-nowrap">
<Link
to="/theme/$code"
params={{ code: t.themeCode }}
className="font-medium hover:text-primary hover:underline"
>
{t.themeName}
</Link>
</td>
{dates.map((d) => {
const g = t.dailyGains[d];
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
return (
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
{formatGain(g)}
</td>
);
})}
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
<span className="inline-flex items-center gap-0.5 text-orange-500">
<Flame className="h-3 w-3" />
{t.appearCount}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+104 -29
View File
@@ -1,7 +1,14 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchThemeDetail, fetchThemeStocks, type ThemeStock } from "@/lib/theme-api";
import {
fetchThemeDetail,
fetchThemeNews,
fetchThemeQuote,
fetchThemeStocks,
type ThemeStock,
type ThemeNewsItem,
} from "@/lib/theme-api";
import { getStockBoard } from "@/lib/stock-api";
import { formatMoney } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
@@ -36,6 +43,27 @@ function ThemeDetailPage() {
staleTime: 30_000,
retry: false,
});
// 相关新闻:pageNum 偏移分页(东财接口 maxEuTime 是增量游标,翻页靠 pageNum 递增)
const [newsPage, setNewsPage] = useState(1);
const [newsItems, setNewsItems] = useState<ThemeNewsItem[]>([]);
const newsQ = useQuery({
queryKey: ["themeNews", code, newsPage],
queryFn: () => fetchThemeNews(code, newsPage, 10),
staleTime: 60_000,
retry: false,
});
const quoteQ = useQuery({
queryKey: ["themeQuote", code],
queryFn: () => fetchThemeQuote(code),
staleTime: 30_000,
retry: false,
});
// 分页追加:首页重置列表,翻页拼接
useEffect(() => {
if (!newsQ.data?.list) return;
setNewsItems((prev) => (newsPage === 1 ? newsQ.data!.list : [...prev, ...newsQ.data!.list]));
}, [newsQ.data, newsPage]);
const isLoading = detailQ.isLoading || stocksQ.isLoading;
const isError = detailQ.isError || stocksQ.isError;
@@ -49,11 +77,13 @@ function ThemeDetailPage() {
const refresh = () => {
detailQ.refetch();
stocksQ.refetch();
quoteQ.refetch();
setNewsPage(1);
newsQ.refetch();
};
const baseInfo = detail?.baseInfo;
const hotEvent = detail?.hotEvent;
const eventHistory = detail?.eventHistory ?? [];
return (
<div className="min-h-screen bg-background">
@@ -139,10 +169,20 @@ function ThemeDetailPage() {
flat={statistic?.f106}
fex5={statistic?.fex5}
total={total}
strength={quoteQ.data?.strengthValue ?? null}
hotValue={quoteQ.data?.hotValue ?? 0}
hotValueUpLimit={quoteQ.data?.hotValueUpLimit ?? 0}
/>
{/* ── 相关新闻(可折叠) ── */}
{eventHistory.length > 0 && <NewsList items={eventHistory} />}
{/* ── 相关新闻(分页加载) ── */}
{newsItems.length > 0 && (
<NewsList
items={newsItems}
total={newsQ.data?.total ?? 0}
loadingMore={newsQ.isFetching && newsPage > 1}
onLoadMore={() => setNewsPage((p) => p + 1)}
/>
)}
{/* ── 相关股票 ── */}
<div>
@@ -181,6 +221,9 @@ function StatBar({
flat,
fex5,
total,
strength,
hotValue,
hotValueUpLimit,
}: {
f3: number | null | undefined;
up: number | null | undefined;
@@ -188,8 +231,13 @@ function StatBar({
flat: number | null | undefined;
fex5: number | null | undefined;
total: number;
strength: number | null;
hotValue: number;
hotValueUpLimit: number;
}) {
const isPos = (f3 ?? 0) >= 0;
const hotPct = hotValueUpLimit > 0 ? Math.min((hotValue / hotValueUpLimit) * 100, 100) : 0;
const showQuote = strength != null || hotValueUpLimit > 0;
return (
<Card>
<CardContent className="p-3">
@@ -218,24 +266,49 @@ function StatBar({
平盘 {flat} 只
</p>
)}
{/* 强度 + 热度(来自单题材实时行情接口) */}
{showQuote && (
<div className="mt-2 pt-2 border-t border-border/40 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
{strength != null && (
<span className="inline-flex items-center gap-1">
强度
<b className="font-bold text-foreground tabular-nums">{strength}</b>
</span>
)}
{hotValueUpLimit > 0 && (
<span className="inline-flex items-center gap-1.5">
<Flame className="h-3 w-3 text-orange-500" />
<span className="w-16 h-1 rounded-full bg-muted overflow-hidden">
<span
className="block h-full rounded-full bg-gradient-to-r from-orange-400 to-red-500"
style={{ width: `${Math.max(hotPct, 2)}%` }}
/>
</span>
热度 {hotValue}/{hotValueUpLimit}
</span>
)}
</div>
)}
</CardContent>
</Card>
);
}
/* ============================================================
相关新闻(可折叠)
相关新闻(分页加载)
============================================================ */
function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string; newsPublishTime: number | null }[] }) {
const [expanded, setExpanded] = useState(false);
const shown = expanded ? items : items.slice(0, 2);
const fmtTime = (ts: number | null) => {
if (!ts) return "";
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
function NewsList({
items,
total,
loadingMore,
onLoadMore,
}: {
items: ThemeNewsItem[];
total: number;
loadingMore: boolean;
onLoadMore: () => void;
}) {
const hasMore = items.length < total;
return (
<Card>
@@ -243,26 +316,28 @@ function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string
<div className="flex items-center gap-1.5 mb-2">
<Newspaper className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold">相关新闻</h2>
<span className="text-[10px] text-muted-foreground ml-auto">{items.length} 条</span>
<span className="text-[10px] text-muted-foreground ml-auto">共 {total} 条</span>
</div>
<div className="space-y-2.5">
{shown.map((n, idx) => (
{items.map((n, idx) => (
<div key={idx} className="space-y-0.5">
<p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p>
<p className="text-[10px] text-muted-foreground">
{n.newsMediaName}
{n.newsPublishTime ? ` · ${fmtTime(n.newsPublishTime)}` : ""}
{n.showDateTimeFormat ? ` · ${n.showDateTimeFormat}` : ""}
{n.commentCount > 0 && <span className="ml-1">· {n.commentCount} 评论</span>}
</p>
</div>
))}
</div>
{items.length > 2 && (
{hasMore && (
<button
onClick={() => setExpanded((v) => !v)}
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5"
onClick={onLoadMore}
disabled={loadingMore}
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5 disabled:opacity-50"
>
{expanded ? "收起" : `展开全部 ${items.length} 条`}
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{loadingMore ? "加载中…" : "加载更多"}
{!loadingMore && <ChevronDown className="h-3 w-3" />}
</button>
)}
</CardContent>
@@ -276,11 +351,11 @@ function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string
function ThemeStockRow({ stock }: { stock: ThemeStock }) {
const [showReason, setShowReason] = useState(true); // 入选理由默认展开
const board = getStockBoard(stock.securityCode);
const isPos = stock.f3 >= 0;
const isPos = (stock.f3 ?? 0) >= 0;
const reasons = stock.keywordList ?? [];
// 换手率:接口返回放大 100 倍的值(如 3733 = 37.33%)
const turnoverRate = stock.f8 > 100 ? stock.f8 / 100 : stock.f8;
const turnoverRate = stock.f8 != null ? (stock.f8 > 100 ? stock.f8 / 100 : stock.f8) : null;
return (
<Link to="/stock/$code" params={{ code: stock.securityCode }} className="block">
@@ -306,13 +381,13 @@ function ThemeStockRow({ stock }: { stock: ThemeStock }) {
<div className="flex items-center gap-3 shrink-0">
<div className="text-right">
<p className="text-[10px] text-muted-foreground">现价</p>
<p className="text-sm font-semibold tabular-nums">{stock.f2.toFixed(2)}</p>
<p className="text-sm font-semibold tabular-nums">{stock.f2 != null ? stock.f2.toFixed(2) : "--"}</p>
</div>
<div className="text-right min-w-[56px]">
<p className="text-[10px] text-muted-foreground">涨跌</p>
<p className={`text-sm font-bold tabular-nums ${isPos ? "text-red-500" : "text-green-500"}`}>
{isPos ? "+" : ""}
{stock.f3.toFixed(2)}%
{stock.f3 != null ? stock.f3.toFixed(2) : "--"}%
</p>
</div>
</div>
@@ -323,7 +398,7 @@ function ThemeStockRow({ stock }: { stock: ThemeStock }) {
{stock.f100 && (
<span className="truncate bg-muted rounded px-1.5 py-0.5 text-[10px]">{stock.f100}</span>
)}
<span className="shrink-0 tabular-nums">换手 {turnoverRate.toFixed(2)}%</span>
<span className="shrink-0 tabular-nums">换手 {turnoverRate != null ? turnoverRate.toFixed(2) : "--"}%</span>
<span className="shrink-0 tabular-nums">主力 {formatMoney(stock.f62)}</span>
<span className="shrink-0 tabular-nums ml-auto">成交 {formatMoney(stock.f6)}</span>
</div>
+15
View File
@@ -14,6 +14,7 @@ import {
TrendingDown,
Flame,
Network,
History,
} from "lucide-react";
export const Route = createFileRoute("/themes")({
@@ -71,6 +72,20 @@ function ThemesPage() {
<Network className="h-3.5 w-3.5" />
热点穿透
</Link>
<Link
to="/core-stocks"
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
>
<Flame className="h-3.5 w-3.5" />
热点股
</Link>
<Link
to="/theme-history"
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
>
<History className="h-3.5 w-3.5" />
历史
</Link>
<button
onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors"
+125
View File
@@ -146,3 +146,128 @@
font-family: var(--font-sans);
}
}
/* AI Report Content Styles */
.ai-report-content {
color: var(--card-foreground);
line-height: 1.6;
font-size: 14px;
}
@media (min-width: 640px) {
.ai-report-content {
font-size: 15px;
}
}
.ai-report-content h1,
.ai-report-content h2,
.ai-report-content h3,
.ai-report-content h4 {
color: var(--foreground);
font-weight: 600;
margin-top: 1.2em;
margin-bottom: 0.5em;
}
.ai-report-content h1 { font-size: 1.4em; }
.ai-report-content h2 { font-size: 1.2em; }
.ai-report-content h3 { font-size: 1.05em; }
@media (min-width: 640px) {
.ai-report-content h1 { font-size: 1.5em; }
.ai-report-content h2 { font-size: 1.3em; }
.ai-report-content h3 { font-size: 1.15em; }
}
.ai-report-content p {
margin-bottom: 0.7em;
}
.ai-report-content strong {
color: var(--foreground);
font-weight: 600;
}
.ai-report-content ul,
.ai-report-content ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.ai-report-content li {
margin-bottom: 0.25em;
}
.ai-report-content blockquote {
border-left: 3px solid var(--border);
padding-left: 0.8em;
margin: 0.7em 0;
color: var(--muted-foreground);
font-size: 0.95em;
}
.ai-report-content code {
background: var(--muted);
padding: 0.15em 0.35em;
border-radius: 4px;
font-size: 0.85em;
color: var(--primary);
}
.ai-report-content pre {
background: var(--muted);
padding: 0.8em;
border-radius: 6px;
overflow-x: auto;
margin: 0.7em 0;
-webkit-overflow-scrolling: touch;
}
.ai-report-content pre code {
background: none;
padding: 0;
color: var(--card-foreground);
font-size: 0.85em;
}
.ai-report-content table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0.7em 0;
}
.ai-report-content table {
width: 100%;
border-collapse: collapse;
min-width: 280px;
}
.ai-report-content th,
.ai-report-content td {
border: 1px solid var(--border);
padding: 0.4em 0.6em;
text-align: left;
font-size: 0.9em;
white-space: nowrap;
}
@media (min-width: 640px) {
.ai-report-content th,
.ai-report-content td {
padding: 0.5em 0.8em;
font-size: 1em;
}
}
.ai-report-content th {
background: var(--muted);
color: var(--foreground);
font-weight: 600;
}
.ai-report-content hr {
border: none;
border-top: 1px solid var(--border);
margin: 1.2em 0;
}
+5
View File
@@ -0,0 +1,5 @@
// stub:getStockBoard 测试用,仅用于解析 @/lib/api-client 别名(CommonJS)
function getApiBaseUrl() {
return "http://localhost:8000";
}
module.exports = { getApiBaseUrl };
+63
View File
@@ -0,0 +1,63 @@
// 最小复现/验证脚本:直接编译 src/lib/stock-api.ts 真实源码,
// 调用 getStockBoard(null),复现「Cannot read properties of null (reading 'startsWith')」。
// 无测试框架,node 直接运行:node tests/get-stock-board-repro.mjs
import fs from "node:fs";
import path from "node:path";
import Module from "node:module";
import ts from "typescript";
const srcPath = path.resolve("src/lib/stock-api.ts");
const source = fs.readFileSync(srcPath, "utf8");
const js = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true },
}).outputText;
const mod = new Module(srcPath);
mod.filename = srcPath;
mod.paths = Module._nodeModulePaths(path.dirname(srcPath));
// 拦截 @/lib/api-client 别名导入(getStockBoard 本身不依赖它)
const origResolve = Module._resolveFilename;
Module._resolveFilename = function (request, ...args) {
if (request === "@/lib/api-client") return path.resolve("tests/_stub-api-client.cjs");
return origResolve.call(this, request, ...args);
};
try {
mod._compile(js, srcPath);
} finally {
Module._resolveFilename = origResolve;
}
const { getStockBoard } = mod.exports;
// ---- 断言 ----
function assertThrows(fn, label) {
try {
fn();
console.log(`✗ ${label}: 未抛错`);
process.exitCode = 1;
} catch (e) {
console.log(`✓ ${label}: 抛错 -> ${e.message}`);
}
}
function assertNoThrow(fn, label) {
try {
const r = fn();
console.log(`✓ ${label}: 未抛错 -> ${JSON.stringify(r)}`);
return r;
} catch (e) {
console.log(`✗ ${label}: 抛错 -> ${e.message}`);
process.exitCode = 1;
}
}
// 正常代码
assertNoThrow(() => getStockBoard("600000"), "正常代码 getStockBoard('600000')");
// 崩盘场景:securityCode 为 null(East Money 题材列表实测存在),修复后应兜底返回主板
assertNoThrow(() => getStockBoard(null), "null securityCode");
assertNoThrow(() => getStockBoard(undefined), "undefined securityCode");
// 空串不崩
assertNoThrow(() => getStockBoard(""), "空串 getStockBoard('')");
+6
View File
@@ -52,6 +52,12 @@ export default defineConfig({
entryFileNames: "assets/[name]-[hash].js",
chunkFileNames: "assets/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash][extname]",
manualChunks(id) {
// 把常用大库拆到独立 chunk,减少主 chunk 体积
if (id.includes("node_modules/recharts")) return "recharts";
if (id.includes("node_modules/lightweight-charts")) return "lightweight-charts";
if (id.includes("node_modules/@tanstack/react-router")) return "router";
},
},
},
},