Compare commits

..

145 Commits

Author SHA1 Message Date
zdl
a446f71c04 feat: 添加 pre-commit hook 检查代码规范
- 新增文件必须使用 TypeScript (.ts/.tsx)
- 禁止使用 fetch,提示使用 axios
- 安装 husky 和 lint-staged 依赖

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 19:19:18 +08:00
zdl
e02cbcd9b7 feat(性能监控): 补全 T0 标记 + PostHog 上报
- index.js: 添加 html-loaded 标记(T0 监控点)
- performanceMonitor.ts: 调用 reportPerformanceMetrics 上报到 PostHog
- 现在完整监控 T0-T5 全部阶段并上报性能指标

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 18:29:35 +08:00
zdl
9bb9eab922 fix(MSW): Bytedesk 添加 mock 数据响应
- 未读消息数量返回 { count: 0 }
- 其他 API 返回通用成功响应
- 解决 mock 模式下 404 错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 16:41:45 +08:00
zdl
3d7b0045b7 fix(NotificationContext): Mock 模式下跳过 Socket 连接
- 添加 REACT_APP_ENABLE_MOCK 环境变量检查
- Mock 模式下直接 return,避免连接生产服务器失败的错误
- 消除开发环境控制台的 WebSocket 连接错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 16:34:44 +08:00
zdl
ada9f6e778 chore(StockQuoteCard): 删除未使用的 mockData.ts
- mockStockQuoteData 未被任何地方引用
- 数据现在通过 useStockQuoteData hook 从 API 获取

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 16:01:49 +08:00
zdl
07aebbece5 refactor(marketService): 移除 apiRequest 包装函数,统一使用 axios.get
- getMarketSummary, getTradeData, getFundingData, getPledgeData, getRiseAnalysis 改为直接使用 axios.get
- 删除 apiRequest<T> 包装函数
- 代码风格与 getBigDealData, getUnusualData, getMinuteData 保持一致

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 16:00:08 +08:00
zdl
7a11800cba docs(Company): 添加 API 接口清单到 STRUCTURE.md
- 梳理 Company 模块共 27 个 API 接口(去重后)
- 分 6 大类:股票基础信息(8)、股东信息(4)、行情数据(8)、深度分析(5)、财务数据(1)、事件新闻(1)
- 标注每个接口的方法类型和调用位置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 15:42:55 +08:00
zdl
3b352be1a8 refactor(Company): 提取共享的 useStockSearch Hook
- 新增 useStockSearch.ts:统一股票模糊搜索逻辑
  - 支持按代码或名称搜索
  - 支持排除指定股票(用于对比场景)
  - 使用 useMemo 优化性能
- 重构 SearchBar.js:使用共享 Hook,减少 15 行代码
- 重构 CompareStockInput.tsx:使用共享 Hook,减少 20 行代码

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 15:34:36 +08:00
zdl
c49dee72eb fix(hooks): 添加 AbortController 解决竞态条件问题
在以下 Hook 中添加请求取消逻辑,防止快速切换股票时旧数据覆盖新数据:
- useBasicInfo
- useShareholderData
- useManagementData
- useBranchesData
- useAnnouncementsData
- useDisclosureData
- useStockQuoteData

修复前:stockCode 变化时,旧请求可能后返回,覆盖新数据
修复后:cleanup 时取消旧请求,确保数据一致性

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 15:20:36 +08:00
zdl
7159e510a6 fix(SubTabContainer): 修复 Tab 懒加载失效问题
- 添加 visitedTabs 状态记录已访问的 Tab 索引
- Tab 切换时更新已访问集合
- TabPanels 中实现条件渲染:只渲染当前或已访问过的 Tab

修复前:tabs.map() 会创建所有组件实例,导致 Hook 立即执行
修复后:仅首次访问 Tab 时才渲染组件,真正实现懒加载

效果:初始加载从 N 个请求减少到 1 个请求

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 14:44:46 +08:00
zdl
385d452f5a chore(CompanyOverview): 移除未使用的 CompanyOverviewData 类型定义
useCompanyOverviewData hook 已在 axios 迁移中删除,
对应的类型定义也应清理

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 12:46:16 +08:00
zdl
bdc823e122 fix(CompanyOverview): 修复 useBasicInfo 重复调用问题
- BusinessInfoPanel: 改为内部调用 useBasicInfo,自行获取数据
- BasicInfoTab: 移除 basicInfo prop 传递
- CompanyOverview: 移除顶层 useBasicInfo 调用
- types.ts: 补充 BasicInfo 工商信息字段类型定义

修复前:CompanyOverview 和各子组件重复请求 /api/stock/{code}/basic-info
修复后:仅 BusinessInfoPanel 在需要时请求一次

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 12:02:31 +08:00
zdl
c83d239219 refactor(Company): fetch 请求迁移至 axios
- DeepAnalysis: 4 个 fetch → axios
- DynamicTracking: 3 个 fetch → axios (NewsPanel, ForecastPanel)
- MarketDataView/services: 4 个 fetch → axios
- CompanyOverview/hooks: 9 个 fetch → axios (6 个文件)
- StockQuoteCard/hooks: 1 个 fetch → axios
- ValueChainNodeCard: 1 个 fetch → axios

清理:
- 删除未使用的 useCompanyOverviewData.ts
- 移除所有 getApiBase/API_BASE_URL 引用

总计: 22 个 fetch 调用迁移, 复用项目已有的 axios 拦截器配置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:54:32 +08:00
zdl
c4900bd280 docs(Company): 更新 STRUCTURE.md 添加数据下沉优化记录
- 更新目录结构:新增 StockQuoteCard/hooks/
- 更新 hooks 目录说明:标注 useStockQuote.js 已下沉
- 更新入口文件说明:列出已移除的模块
- 新增 2025-12-17 重构记录:StockQuoteCard 数据下沉优化详情

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:17:25 +08:00
zdl
7736212235 refactor(StockQuoteCard): 数据下沉优化,Props 从 11 个精简为 4 个
- StockQuoteCard 使用内部 hooks 获取行情数据、基本信息和对比数据
- 更新 types.ts,简化 Props 接口
- Company/index.js 移除已下沉的数据获取逻辑(~40 行)
- 删除 Company/hooks/useStockQuote.js(已移至组件内部)

优化收益:
- Props 数量: 11 → 4 (-64%)
- Company/index.js: ~172 → ~105 行 (-39%)
- 组件可独立复用

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:17:07 +08:00
zdl
348d8a0ec3 feat(StockQuoteCard): 新增内部数据获取 hooks
- useStockQuoteData: 合并行情数据和基本信息获取
- useStockCompare: 股票对比逻辑封装
- 为数据下沉优化做准备

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:12:14 +08:00
zdl
5a0d6e1569 fix(MarketDataView): 添加缺失的 VStack 导入
- 修复 TypeScript 类型检查错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 11:11:59 +08:00
zdl
bc2b6ae41c fix(MarketDataView): loading 背景色改为深色与整体一致
- 移除白色 ThemedCard 包装
- 使用 gray.900 背景 + 金色边框

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:34:36 +08:00
zdl
ac7e627b2d refactor(Company): 统一所有 Tab 的 loading 状态组件
- 创建共享的 LoadingState 组件(黑金主题)
- DeepAnalysisTab: 使用统一 LoadingState 替换蓝色 Spinner
- FinancialPanorama: 使用 LoadingState 替换 Skeleton
- MarketDataView: 使用 LoadingState 替换自定义 Spinner
- ForecastReport: 使用 LoadingState 替换 Skeleton 骨架屏

所有一级 Tab 现在使用一致的金色 Spinner + 加载提示文案

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:31:38 +08:00
zdl
21e83ac1bc style(ForecastReport): 详细数据表格 UI 优化
- 斑马纹(奇数行浅色背景)
- 等宽字体(SF Mono/Monaco/Menlo)
- 重要指标行高亮(归母净利润、ROE、EPS、营业总收入)
- 预测列区分样式(斜体+浅金背景+分隔线)
- 负数红色/正增长绿色显示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:27:38 +08:00
zdl
e2dd9e2648 style(ForecastReport): 盈利预测图表优化
- 预测年份 X 轴金色高亮,预测区域添加背景标记
- Y 轴颜色与对应数据系列匹配
- PEG 改用青色点划线+菱形符号,增加 PEG=1 参考线
- EPS 图添加行业平均参考线
- Tooltip 显示预测标签,智能避让

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 10:27:30 +08:00
zdl
f2463922f3 fix(ValueChainCard): 视图切换按钮始终靠右显示
使用 ml="auto" 确保切换按钮在流向关系视图时保持右侧位置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:53:08 +08:00
zdl
9aaad00f87 refactor(CompanyOverview): 优化多个面板显示逻辑
- ValueChainCard: 流向关系视图时隐藏左侧导航选项
- AnnouncementsPanel: 移除重复的"最新公告"标题
- DisclosureSchedulePanel: 移除重复的"财报披露日程"标题
- CompetitiveAnalysisCard: 恢复竞争对手标签和雷达图显示

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:51:27 +08:00
zdl
024126025d style(DetailTable): 简化布局,标题+表格无嵌套
- 移除外层卡片包装,直接显示标题和表格
- 使用 Chakra Text 作为标题
- 表格背景改为透明

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:43:05 +08:00
zdl
e2f9f3278f refactor(ForecastReport): 3列布局 + 移除标题
- 移除盈利预测报表标题和刷新按钮
- 3个图表改为3列等宽布局
- 统一图表高度使用 CHART_HEIGHT
- 简化标题文字

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:40:34 +08:00
zdl
2d03c88f43 style(DynamicTracking): 应用黑金主题
- NewsEventsTab: 添加黑金主题配色系统
- ForecastPanel: 业绩预告面板黑金样式
- NewsPanel: 切换 blackGold 主题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:37:29 +08:00
zdl
515b538c84 refactor(ForecastReport): 合并营收/利润趋势与增长率图表
- 新增 IncomeProfitGrowthChart 合并组件
- 柱状图显示营业收入(左Y轴)
- 折线图显示净利润(左Y轴,渐变填充)
- 虚线显示增长率(右Y轴,红涨绿跌)
- 布局调整:合并图表独占一行,EPS/PE-PEG 两列

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:37:24 +08:00
zdl
b52b54347d fix(mock): 修复事件数据和 API 返回格式
- events.js: 增强搜索支持股票名称/代码,修复字段名
- event.js: 返回结构调整为 { data, pagination }

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:37:20 +08:00
zdl
4954373b5b style(ComparisonAnalysis): 应用黑金主题样式
- 图表配置:金色标题、深色 tooltip、金色坐标轴
- 净利润折线改为金色渐变填充
- 营收柱状图首个柱子使用金色
- 组件容器:透明背景 + 金色边框
- 移除外部重复标题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:35:14 +08:00
zdl
66cd6c3a29 fix(mock): 修复 periodComparison 数据结构
- 将 periodComparison 从对象格式改为数组格式
- 匹配 ComparisonAnalysis 组件期望的数据结构
- 修复"盈利与利润趋势"图表无法显示的问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:33:07 +08:00
zdl
ba99f55b16 refactor(ForecastReport): 迁移至 TypeScript 2025-12-16 20:28:58 +08:00
zdl
2f69f83d16 feat(mock): 添加业绩预告 mock 数据
- 新增 /api/stock/:stockCode/forecast handler
 - 支持动态跟踪下的业绩预告面板
2025-12-16 20:27:43 +08:00
zdl
3bd48e1ddd refactor(StockQuoteCard): 拆分为原子组件
- 新增 theme.ts 黑金主题常量
  - 新增 formatters.ts 格式化工具函数
  - 拆分 PriceDisplay/SecondaryQuote/KeyMetrics/MainForceInfo/CompanyInfo/StockHeader
  - 主组件从 414 行简化为 150 行
  - 提高可维护性和复用性
2025-12-16 20:24:01 +08:00
zdl
84914b3cca fix(FinancialPanorama): 恢复盈利与利润趋势图表
- 重新引入 ComparisonAnalysis 组件
- 在财务全景面板下方显示营收与利润趋势柱状图
- 修复之前重构时遗漏的功能模块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:22:14 +08:00
zdl
da455946a3 style(MainBusinessAnalysis): 优化主营业务模块 UI
- 饼图配色改为黑金主题(金色系渐变)
- 修复表格固定列 hover 时背景色为白色的问题
- 统一表格单元格背景色为深色 #1A202C

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:20:15 +08:00
zdl
e734319ec4 refactor(FinancialPanorama): 使用 FinancialOverviewPanel 替换原头部组件
- 移除 StockInfoHeader 和 KeyMetricsOverview
- 使用新的三模块面板组件
- ROE 去重,布局统一

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:17:19 +08:00
zdl
faf2446203 feat(FinancialPanorama): 新增 FinancialOverviewPanel 三模块布局
- 复用 MetricCard 组件构建三列布局
- 成长能力:利润增长、营收增长、预增标签
- 盈利与回报:ROE、净利率、毛利率
- 风险与运营:资产负债率、流动比率、研发费用率

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:17:08 +08:00
zdl
83b24b6d54 style(MainBusinessAnalysis): 优化历史对比表格布局
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 20:15:57 +08:00
zdl
ab7164681a feat(StockQuoteCard): 新增每股收益(EPS)显示
- Mock 数据添加 eps、pb、主力动态等指标
  - StockQuoteCard 显示 EPS 数据
  - useStockQuote 支持 eps 字段转换
  - StockInfoHeader 移除重复的 EPS 显示
2025-12-16 20:08:35 +08:00
zdl
bc6d370f55 refactor(FinancialPanorama): 重构为 7+3 Tab 架构
- 财务指标拆分为 7 个分类 Tab(盈利/每股/成长/运营/偿债/费用/现金流)
- 保留 3 大报表 Tab(资产负债表/利润表/现金流量表)
- 新增 KeyMetricsOverview 关键指标速览组件
- 新增 FinancialTable 通用表格组件
- Hook 支持按 Tab 独立刷新数据
- PeriodSelector 整合到 SubTabContainer 右侧
- 删除废弃的 OverviewTab/MainBusinessTab

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 19:59:30 +08:00
zdl
42215b2d59 refactor(mocks): 调整主营业务数据结构为多期分类格式
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 19:59:06 +08:00
zdl
c34aa37731 feat(SubTabContainer): 新增 rightElement prop 支持自定义右侧内容
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 19:59:00 +08:00
zdl
2eb2a22495 feat(DeepAnalysis): 竞争地位分析增加行业排名弹窗
- CompetitiveAnalysisCard 新增 Modal 弹窗展示行业排名详情
  - 点击 Badge 或查看详情按钮可打开弹窗
  - 弹窗采用黑金主题样式
  - StrategyTab 移除独立的 IndustryRankingView 展示
2025-12-16 16:33:45 +08:00
zdl
6a4c475d3a refactor(FinancialPanorama): 重构为 SubTabContainer 二级导航
- 主组件从 Chakra Tabs 迁移到 SubTabContainer
  - 新增 PeriodSelector 时间选择器组件
  - IndustryRankingView 增加深色主题支持
  - 拆分出 6 个独立 Tab 组件到 tabs/ 目录
  - 类型定义优化,props 改为可选
2025-12-16 16:33:25 +08:00
zdl
e08b9d2104 refactor(DynamicTracking): 拆分组件
- 新增 ForecastPanel: 业绩预告面板组件
 - 新增 NewsPanel: 新闻面板组件
 - 组件模块化重构
2025-12-16 16:22:56 +08:00
zdl
3f1f438440 feat(DeepAnalysis): 增强策略Tab功能
- 新增策略相关类型定义
 - StrategyTab 功能增强
 - 调整组件结构
2025-12-16 16:22:39 +08:00
zdl
24720dbba0 fix(mocks): 优化 financial.js Mock 数据 2025-12-16 16:22:24 +08:00
zdl
7877c41e9c feat(Company): 集成股票对比功能
- 新增 currentStockInfo/compareStockInfo 状态管理
 - 新增 handleCompare 处理对比数据加载
 - StockQuoteCard 传入对比相关 props
2025-12-16 16:15:52 +08:00
zdl
b25d48e167 feat(StockQuoteCard): 新增股票对比功能
- 新增 CompareStockInput: 股票搜索输入组件
 - 新增 StockCompareModal: 股票对比弹窗
 - 更新类型定义支持对比功能

 🤖 Generated with [Claude Code](https://claude.com/claude-code)

 Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 16:15:36 +08:00
zdl
804de885e1 feat(DynamicTracking): 新增业绩预告Tab
- 新增 forecast Tab(从 FinancialPanorama 迁移)
 - 新增 loadForecast 数据加载逻辑
 - 新增业绩预告列表展示

 🤖 Generated with [Claude Code](https://claude.com/claude-code)

 Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 16:13:49 +08:00
zdl
6738a09e3a fix(FinancialPanorama): 修复Mock数据结构 + 移除业绩预告Tab
- financial.js: 修复字段名 code→stock_code, name→stock_name
 - financial.js: 财务报表改为嵌套结构匹配类型定义
 - 移除业绩预告Tab(迁移至DynamicTracking)
2025-12-16 16:13:25 +08:00
zdl
67340e9b82 feat(MarketDataView): K线图优化 - 按需刷新 + 黑金主题
- useMarketData: 新增 refreshTradeData,切换时间范围只刷新K线数据
 - chartOptions: 新增黑金主题配置函数
 - 优化 useEffect,避免切换周期时全量刷新
2025-12-16 16:12:39 +08:00
zdl
00f2937a34 refactor(MarketDataView): 使用通用 SubTabContainer 简化代码
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 15:29:05 +08:00
zdl
91ed649220 refactor(MarketDataView): 优化图表配置和K线模块
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 15:28:59 +08:00
zdl
391955f88c style(Panels): 应用黑金主题样式 2025-12-16 15:24:13 +08:00
zdl
59f4b1cdb9 refactor(useMarketData): 优化数据获取逻辑 2025-12-16 15:24:00 +08:00
zdl
3d6d01964d feat(MarketDataView): 新增图表配置工具函数 2025-12-16 15:23:49 +08:00
zdl
3f3e13bddd feat(KLineModule): 添加日K时间范围选择器 2025-12-16 15:20:06 +08:00
zdl
d27cf5b7d8 style(StockSummaryCard): 优化黑金主题原子组件样式 2025-12-16 15:19:40 +08:00
zdl
03bc2d681b pref: FundingPanel 黑金主题改造 融资融券面板 2025-12-16 15:11:52 +08:00
zdl
1022fa4077 refactor(KLineModule): 黑金主题 + 精简组件结构
- KLineModule 应用黑金主题(渐变背景、金色按钮、金色图标)
- 删除 TradeTable、MinuteStats、TradeAnalysis 组件
- 删除 atoms 目录,EmptyState 内联到 KLineModule
- 更新 types.ts 移除 TradeTableProps
- 更新导出文件

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 15:03:50 +08:00
zdl
406b951e53 refactor(TradeDataPanel): 合并 KLineChart 和 MinuteKLineSection 为 KLineModule
- 新增 KLineModule 组件,整合日K线和分钟K线功能
- 右上角 ButtonGroup 切换「日K」/「分钟」模式
- 刷新按钮置于切换按钮组前方
- 切换到分钟模式时自动加载数据
- 删除旧的 KLineChart.tsx 和 MinuteKLineSection.tsx
- 更新 panels/index.ts 导出
- 更新 types.ts,合并类型定义为 KLineModuleProps

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 14:52:06 +08:00
zdl
7f392619e7 refactor(TradeDataPanel): 原子设计模式拆分重构
- 将 TradeDataPanel.tsx (382行) 拆分为 8 个 TypeScript 文件
- 创建 3 个原子组件: MinuteStats、TradeAnalysis、EmptyState
- 创建 3 个业务组件: KLineChart、MinuteKLineSection、TradeTable
- 主入口组件精简至 ~50 行,降低 87%
- 更新 panels/index.ts 导出子组件
- 更新 STRUCTURE.md 文档

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 14:34:45 +08:00
zdl
09ca7265d7 refactor(StockSummaryCard): 黑金主题 4 列布局重构
- 布局从 1+3 改为 4 列横向排列(股票信息/交易热度/估值安全/情绪风险)
- 新增 darkGoldTheme 黑金主题配置
- 采用原子设计模式拆分:5 个原子组件 + 2 个业务组件
- 原子组件:DarkGoldCard、CardTitle、MetricValue、PriceDisplay、StatusTag
- 业务组件:StockHeaderCard、MetricCard
- 提取状态计算工具到 utils.ts
- types.ts: theme 参数改为可选

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-16 14:01:42 +08:00
zdl
276b280cb9 docs: 更新 STRUCTURE.md 和 mock 数据
- STRUCTURE.md 添加 MarketDataView Panel 拆分记录
- 更新目录结构说明,包含 panels/ 子目录
- 更新 company.js 和 market.js mock 数据

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 18:11:03 +08:00
zdl
adfc0bd478 refactor(MarketDataView): 使用 Panel 组件重构主组件
- 主组件从 1049 行精简至 285 行(减少 73%)
- 添加 panels/index.ts 统一导出
- Tab 容器和状态管理保留在主组件
- 各面板内容拆分到独立组件

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 18:08:40 +08:00
zdl
85a857dc19 feat(MarketDataView): 新增 5 个 Panel 组件
- TradeDataPanel: 交易数据面板(K线图、分钟图、表格)
- FundingPanel: 融资融券面板
- BigDealPanel: 大宗交易面板
- UnusualPanel: 龙虎榜面板
- PledgePanel: 股权质押面板

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 18:07:58 +08:00
zdl
b89837d22e feat(DeepAnalysis): 实现 Tab 懒加载,按需请求数据
- DeepAnalysis/index.js: 重构为懒加载模式
  - 添加 TAB_API_MAP 映射 Tab 与接口关系
  - 战略分析/业务结构共享 comprehensive-analysis 接口
  - 产业链/发展历程按需加载对应接口
  - 使用 loadedApisRef 缓存已加载状态,避免重复请求
  - 各接口独立 loading 状态管理
  - 添加 stockCode 竞态条件保护

- DeepAnalysisTab/index.tsx: 支持受控模式
  - 新增 activeTab/onTabChange props
  - loading 状态下保持 Tab 导航可切换

- types.ts: 新增 DeepAnalysisTabKey 类型和相关 props

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:20:37 +08:00
zdl
942dd16800 docs(Company): 更新 STRUCTURE.md 添加 FinancialPanorama 模块结构
- 添加 FinancialPanorama 完整目录结构说明
- 记录18个文件的职责和功能
- 更新模块化重构后的架构文档

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:02:21 +08:00
zdl
35e3b66684 refactor(FinancialPanorama): 重构主组件为模块化结构
从 2,150 行单文件重构为模块化 TypeScript 组件:
- 使用 useFinancialData Hook 管理数据加载
- 组合9个子组件渲染9个Tab面板
- 保留指标图表弹窗功能
- 保留期数选择器功能
- 删除旧的 index.js(2,150行)
- 新增 index.tsx(454行,精简79%)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:02:05 +08:00
zdl
b9ea08e601 refactor(FinancialPanorama): 添加9个子组件
财务报表组件:
- BalanceSheetTable: 资产负债表(可折叠分类)
- IncomeStatementTable: 利润表(支持负向指标反色)
- CashflowTable: 现金流量表
- FinancialMetricsTable: 财务指标(7分类切换 + 关键指标速览)

业务分析组件:
- StockInfoHeader: 股票信息头部
- MainBusinessAnalysis: 主营业务分析(饼图 + 表格)
- IndustryRankingView: 行业排名展示
- StockComparison: 股票对比(多维度)
- ComparisonAnalysis: 综合对比分析(双轴图)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:01:47 +08:00
zdl
d9106bf9f7 refactor(FinancialPanorama): 添加数据加载 Hook
useFinancialData Hook 功能:
- 9个财务API并行加载(Promise.all)
- 股票信息、资产负债表、利润表、现金流量表
- 财务指标、主营业务、业绩预告
- 行业排名、期间对比
- 支持期数选择(4/8/12/16期)
- 自动响应 stockCode 变化重新加载

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:01:26 +08:00
zdl
fb42ef566b refactor(FinancialPanorama): 添加工具函数模块
计算工具 (calculations.ts):
- calculateYoYChange: 同比变化率计算
- getCellBackground: 单元格背景色(红涨绿跌)
- getValueByPath: 嵌套路径取值
- isNegativeIndicator: 负向指标判断

图表配置 (chartOptions.ts):
- getMetricChartOption: 指标趋势柱状图
- getComparisonChartOption: 营收利润双轴图
- getMainBusinessPieOption: 主营业务饼图
- getCompareBarChartOption: 股票对比柱状图

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:01:09 +08:00
zdl
a424b3338d refactor(FinancialPanorama): 添加常量配置模块
- 颜色配置:中国市场红涨绿跌
- 资产负债表指标:7个分类(流动/非流动资产、负债、权益)
- 利润表指标:6个分类(营收、成本、其他收益、利润、EPS、综合收益)
- 现金流量表指标:8个核心指标
- 财务指标分类:7大类(盈利、每股、成长、运营、偿债、费用、现金流)
- 行业排名和对比指标配置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:00:51 +08:00
zdl
9e6e3ae322 refactor(FinancialPanorama): 添加 TypeScript 类型定义
- 定义基础类型:StockInfo、财务报表数据结构
- 定义业务类型:主营业务、行业排名、业绩预告
- 定义组件 Props 类型:9个子组件的 Props 接口
- 定义指标配置类型:MetricConfig、MetricSectionConfig

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 15:00:34 +08:00
zdl
e92cc09e06 style: DeepAnalysisTab 黑金主题样式优化
- ProcessNavigation: Tab 未选中状态字体白色,数量Badge与边框颜色统一(gray.600)
- KeyFactorCard: 适配黑金主题(cardBg #252D3A, 文字颜色调整)
- KeyFactorsCard: 黑金主题重构,移除免责声明组件
- TimelineCard: 黑金主题重构,移除免责声明组件
- ValueChainCard: 调整 CardHeader 和 CardBody padding
- ValueChainFilterBar: 暂时注释筛选下拉框

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 14:25:48 +08:00
zdl
23112db115 refactor(ValueChainCard): 重构产业链分析卡片布局
- 新增 ProcessNavigation 流程导航组件(上游→核心→下游+副标题)
- 新增 ValueChainFilterBar 筛选栏组件(类型/重要度/视图Tab切换)
- 重构布局为左右分栏:左侧流程导航,右侧筛选+视图切换
- 移除 DisclaimerBox 免责声明
- ValueChainNodeCard 适配黑金主题
- 移除卡片内部左右边距

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 14:04:04 +08:00
zdl
7c7c70c4d9 style: 移除 Tab 导航和卡片内部左右 padding
- TabNavigation/SubTabContainer: 移除左侧 padding (pl=0)
- BusinessStructureCard/BusinessSegmentsCard: 移除 CardBody 左右 padding
- BusinessTreeItem: 黑金主题样式优化

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 13:13:34 +08:00
zdl
e049429b09 perf: Tab 容器组件优化
- TabPanelContainer: Loading 颜色改为金色 #D4AF37,与黑金主题一致
- SubTabContainer: 添加 memo 和 displayName
- 子 Tab 组件: StrategyTab/BusinessTab/ValueChainTab/DevelopmentTab 添加 memo 和 displayName
- TabContainer: 移除未使用的 showDivider 参数

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 12:02:15 +08:00
zdl
b8cd520014 refactor: 抽取通用 Tab 容器组件,重构 BasicInfoTab 和 DeepAnalysisTab
新增组件:
- TabPanelContainer: 三级容器,统一 loading 状态 + VStack 布局 + 免责声明
- SubTabContainer: 二级导航容器,支持黑金/默认主题预设

重构:
- BasicInfoTab: 使用 SubTabContainer 替代原有 Tabs 实现
- DeepAnalysisTab: 拆分为 4 个子 Tab(战略分析/业务结构/产业链/发展历程)
- TabContainer: 样式调整,与 SubTabContainer 保持一致

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 11:55:50 +08:00
zdl
96fe919164 feat: 竞争优势内容调整 2025-12-12 11:01:22 +08:00
zdl
4672a24353 refactor: 抽取 TabPanelContainer 通用容器组件
- 新增 TabPanelContainer 组件,统一处理 loading 状态和 VStack 布局
- ShareholderPanel 使用 TabPanelContainer 替代原有 loading 判断和 VStack
- ManagementPanel 使用 TabPanelContainer 替代原有 loading 判断和 VStack
- 组件使用 React.memo 优化渲染性能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 10:58:25 +08:00
zdl
26bc5fece0 style(CompetitiveAnalysisCard): 移除卡片边框
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 10:12:09 +08:00
zdl
1c35ea24cd chore(DeepAnalysisTab): 更新类型定义和组件引用
- types.ts: 扩展类型定义支持新组件结构
- index.tsx: 更新组件 props 传递

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 18:49:17 +08:00
zdl
d76b0d32d6 perf(CompetitiveAnalysisCard): 渲染优化与黑金 UI
- 渲染优化: React.memo, useMemo, 样式常量提取
- 子组件拆分: CompetitorTags, ScoreSection, AdvantagesSection
- 黑金 UI: 金色边框、金色标题、白色内容、深色雷达图主题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 18:49:10 +08:00
zdl
eb093a5189 perf(StrategyAnalysisCard): 渲染优化与黑金 UI
- 渲染优化: React.memo, useMemo, 样式常量提取
- 子组件拆分: EmptyState, ContentItem
- 黑金 UI: 金色标题、白色内容文字、空状态金色虚线边框

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 18:49:03 +08:00
zdl
2c0b06e6a0 refactor(CorePositioningCard): 模块化拆分与黑金 UI 优化
- 拆分为独立目录结构: atoms/, theme.ts, index.tsx
- 提取子组件: HighlightCard, ModelBlock, SectionHeader
- 应用黑金风格: 金色边框、透明背景、金色标题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 18:48:56 +08:00
zdl
b3fb472c66 feat(mock): 更新深度分析 mock 数据
- 核心定位: 更新一句话定位、投资亮点、商业模式
- 战略分析: 添加战略方向和战略举措数据

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 18:48:49 +08:00
zdl
6797f54b6c feat: 战略分析Ui调整 2025-12-11 17:37:24 +08:00
zdl
a47e0feed8 refactor(TabContainer): 抽取通用 Tab 容器组件
- 新增 src/components/TabContainer/ 通用组件
  - 支持受控/非受控模式
  - 支持多种主题预设(blackGold、default、dark、light)
  - 支持自定义主题颜色和样式配置
  - 使用 TypeScript 实现,类型完整
- 重构 CompanyTabs 使用通用 TabContainer
- 删除 CompanyTabs/TabNavigation.js(逻辑迁移到通用组件)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 16:59:17 +08:00
zdl
13fa91a998 style(DeepAnalysisTab): 优化免责声明样式并更新 mock 数据
- DisclaimerBox: 简化为单行灰色文本,移除警告框样式
- Mock 数据: 更新核心定位、投资亮点、商业模式、战略分析内容
- 调整卡片顺序: 战略分析和业务板块上移

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 14:47:17 +08:00
zdl
fba7a7ee96 docs: 更新 Company 模块目录结构文档
- 添加 DeepAnalysisTab 模块化重构记录(2025-12-11)
- 更新目录结构中 DeepAnalysisTab.js → DeepAnalysisTab/
- 添加组件依赖关系图
- 添加工具函数位置表
- 添加优化效果对比

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 10:59:12 +08:00
zdl
32a73efb55 refactor(DeepAnalysisTab): 模块化拆分为 21 个 TypeScript 文件
将 1,796 行单文件拆分为原子设计模式结构:

**atoms/** - 原子组件
- DisclaimerBox: 免责声明警告框
- ScoreBar: 评分进度条
- BusinessTreeItem: 业务树形项
- KeyFactorCard: 关键因素卡片

**components/** - Card 容器组件
- CorePositioningCard: 核心定位
- CompetitiveAnalysisCard: 竞争地位分析(含雷达图)
- BusinessStructureCard: 业务结构
- ValueChainCard: 产业链分析
- KeyFactorsCard: 关键因素
- TimelineCard: 发展时间线
- BusinessSegmentsCard: 业务板块详情
- StrategyAnalysisCard: 战略分析

**organisms/** - 复杂组件
- ValueChainNodeCard: 产业链节点(含 RelatedCompaniesModal)
- TimelineComponent: 时间线(含 EventDetailModal)

**utils/**
- chartOptions.ts: ECharts 图表配置

优化效果:主文件从 1,796 行减少到 117 行(-93%)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 10:59:05 +08:00
zdl
7819b4f8a2 feat(utils): 添加深度分析格式化工具函数
- formatCurrency: 货币格式化(支持亿/万单位)
- formatBusinessRevenue: 营收格式化(智能单位转换)
- formatPercentage: 百分比格式化

从 DeepAnalysisTab 提取合并到全局工具库

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 10:58:52 +08:00
zdl
6f74c1c1de style: BranchesPanel 组件调整为黑金风格
- 卡片使用深色渐变背景,金色边框 + hover 发光效果
- 顶部添加金色渐变装饰线
- 状态徽章改为黑金风格(存续金色/非存续红色)
- 标题区域添加金色背景图标
- 信息项提取为 InfoItem 组件,优化布局
- 空状态使用金色圆形背景装饰

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 21:29:02 +08:00
zdl
3fed9d2d65 feat: 调整公众号配置 2025-12-10 19:59:37 +08:00
zdl
514917c0eb fix: 添加mock数据 2025-12-10 19:57:21 +08:00
zdl
6ce913d79b refactor: 整合 CompanyHeaderCard 到 StockQuoteCard,优化布局对齐
- 将公司基本信息整合到 StockQuoteCard 内部
- 采用 1:2 Flex 布局确保上下竖线对齐
- 删除废弃的 CompanyHeaderCard 组件
- 清理 types.ts 中的 CompanyHeaderCardProps

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 19:54:51 +08:00
zdl
6d5594556b refactor: ManagementPanel 组件拆分重构
- 创建 management/ 子目录,模块化管理
- 拆分为 5 个 TypeScript 文件:types.ts、ManagementPanel.tsx、CategorySection.tsx、ManagementCard.tsx、index.ts
- 添加 useMemo 缓存分类计算结果
- 使用 React.memo 优化 ManagementCard 和 CategorySection
- 添加完整的 TypeScript 类型定义,消除 any
- 更新 STRUCTURE.md 同步目录结构

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 19:28:05 +08:00
zdl
c32091e83e feat: 公司基础信息展示字段调整,公司概览调整为公司档案 2025-12-10 19:04:43 +08:00
zdl
2994de98c2 refactor: 财报披露日程独立为动态跟踪第三个 Tab
- 新建 DisclosureSchedulePanel 组件,独立展示财报披露日程
- 简化 AnnouncementsPanel,移除财报披露日程部分
- DynamicTracking 新增第三个 Tab:财报披露日程
- 更新 mock 数据字段名匹配组件需求

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 18:55:30 +08:00
zdl
c237a4dc0c fix: 调整UI 2025-12-10 18:18:05 +08:00
zdl
395dc27fe2 refactor: ShareholderPanel 拆分为子组件 + 黑金主题优化
- 新增 ActualControlCard 实际控制人卡片组件
- 新增 ConcentrationCard 股权集中度卡片(含 ECharts 饼图)
- 新增 ShareholdersTable 合并表格(支持十大股东/十大流通股东)
- Mock 数据优化:股东名称改为真实格式
- Handler 修复:数组格式处理 + holding_ratio 百分比转换
- UI: 黑金主题统一、表格 hover 金色半透明

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 18:00:00 +08:00
zdl
3abee6b907 docs: 更新 STRUCTURE.md 目录结构说明
- 添加 BasicInfoTab/ 目录结构详情
- 补充各子组件功能注释:
  - LoadingState: 加载状态组件
  - ShareholderPanel: 股权结构面板
  - ManagementPanel: 管理团队面板
  - AnnouncementsPanel: 公告信息面板
  - BranchesPanel: 分支机构面板
  - BusinessInfoPanel: 工商信息面板

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 16:29:34 +08:00
zdl
d86cef9f79 fix: 修复股权结构 Mock 数据格式
- actualControl 改为数组格式(支持多个实控人)
- concentration 改为数组格式(按季度分组,含 stat_item)
- topShareholders 添加 shareholder_rank、end_date、share_nature 字段
- topCirculationShareholders 添加 shareholder_rank、end_date 字段
- 字段名与 ShareholderPanel 组件期望格式统一

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 16:29:18 +08:00
zdl
9aaf4400c1 refactor: BasicInfoTab 拆分为 TypeScript 模块化组件
- 删除旧的 BasicInfoTab.js (~1000行)
- 新建 BasicInfoTab/ 目录,拆分为 10 个 TypeScript 文件:
  - index.tsx: 主组件(可配置 Tab)
  - config.ts: Tab 配置 + 黑金主题
  - utils.ts: 格式化工具函数
  - components/: 5 个面板组件 + LoadingState
- 主组件支持 enabledTabs、defaultTabIndex、onTabChange
- 应用黑金主题,支持懒加载 (isLazy)
- 更新 types.ts 添加 ActualControl、Concentration 等类型字段

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 16:28:54 +08:00
zdl
1cd8a2d7e9 fix: 颜色调整 2025-12-10 15:56:08 +08:00
zdl
af3cdc24b1 style: CompanyHeaderCard 黑金主题三区块布局重构
- 布局调整:从两栏(8:4)改为垂直三区块(身份分类 | 关键属性 | 公司介绍)
- 黑金主题:卡片背景 gray.800,金色强调色 #D4AF37
- 移除字段:法定代表人、董事长、总经理、邮箱、电话
- 保留字段:公司名称、代码、行业分类、成立日期、注册资本、所在地、官网、公司介绍
- CompanyTabs: TabPanel 去掉左右边距

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 15:53:50 +08:00
zdl
bfb6ef63d0 refactor: MarketDataView TypeScript 重构 - 2060 行拆分为 12 个模块
- 将原 index.js (2060 行) 重构为 TypeScript 模块化架构
- 新增 types.ts: 383 行类型定义 (Theme, TradeDayData, MinuteData 等)
- 新增 services/marketService.ts: API 服务层封装
- 新增 hooks/useMarketData.ts: 数据获取 Hook
- 新增 utils/formatUtils.ts: 格式化工具函数
- 新增 utils/chartOptions.ts: ECharts 图表配置生成器 (698 行)
- 新增 components/: ThemedCard, MarkdownRenderer, StockSummaryCard, AnalysisModal
- 添加 Company/STRUCTURE.md 目录结构文档

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 15:14:23 +08:00
zdl
722d038b56 Merge branch 'feature_bugfix/251201_py_h5_ui' into feature_2025/251209_stock_pref
* feature_bugfix/251201_py_h5_ui:
  feat: Company 页面搜索框添加股票模糊搜索功能
  update pay ui
  update pay ui
  fix: 个股中心bug修复
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  update pay ui
  feat: 替换公众号文件
  update pay ui
2025-12-10 14:30:25 +08:00
zdl
a01532ce65 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui:
  feat: Company 页面搜索框添加股票模糊搜索功能
  fix: 个股中心bug修复
2025-12-10 14:26:36 +08:00
zdl
fbeb66fb39 feat: Company 页面搜索框添加股票模糊搜索功能
- 添加 AutoComplete 组件替换原 Input,支持下拉选择
- 集成 stockService.fuzzySearch 实现按代码/名称模糊匹配
- 从 Redux 获取 allStocks 数据,自动加载保障
- 选中股票自动触发查询并更新 URL 参数

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 14:26:16 +08:00
4fd1a24db4 update pay ui 2025-12-10 14:12:11 +08:00
3cb9b4237b update pay ui 2025-12-10 14:01:38 +08:00
zdl
7c00763999 fix: 个股中心bug修复 2025-12-10 13:29:08 +08:00
d6d4bb8a12 update pay ui 2025-12-10 13:23:49 +08:00
zdl
5f6e4387e5 perf: CompanyOverview 内层 Tab 懒加载优化
- 将 useCompanyOverviewData(9个API)拆分为独立 Hooks:
  - useBasicInfo: 基本信息(首屏唯一加载)
  - useShareholderData: 股东信息(4个API)
  - useManagementData: 管理层信息
  - useAnnouncementsData: 公告数据
  - useBranchesData: 分支机构
  - useDisclosureData: 披露日程
- BasicInfoTab 使用子组件实现真正的懒加载:
  - ShareholderTabPanel、ManagementTabPanel 等
  - 配合 Chakra UI isLazy,切换 Tab 时才加载数据
- 首屏 API 请求从 9 个减少到 1 个

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 13:05:27 +08:00
1adbeda168 update pay ui 2025-12-10 12:22:40 +08:00
92458a8705 update pay ui 2025-12-10 12:06:13 +08:00
45339902aa update pay ui 2025-12-10 11:43:56 +08:00
2482b01b00 update pay ui 2025-12-10 11:42:34 +08:00
zdl
38076534b1 perf: CompanyTabs 添加 isLazy 实现 Tab 懒加载
- 页面打开时只渲染第一个 Tab(CompanyOverview)
- 其他 Tab(深度分析、行情、财务、预测、动态跟踪)点击时才渲染和请求
- 减少首屏请求数量,提升加载性能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:40:34 +08:00
d29ebfd501 update pay ui 2025-12-10 11:30:19 +08:00
zdl
a7ab87f7c4 feat: StockQuoteCard 顶部导航区视觉优化
- 股票名称字号放大至 26px,字重 800,突出显示
- 添加行业标签(金融 · 银行),Badge 边框样式
- 保留指数标签(沪深300、上证180)
- Mock 数据补充 industry、industry_l1、index_tags 字段
- 类型定义新增 industry、industryL1 可选字段

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:30:18 +08:00
zdl
9a77bb6f0b refactor: CompanyOverview 组件 TypeScript 拆分
- 新增 index.tsx: 主组件(组合层,50 行)
- 新增 CompanyHeaderCard.tsx: 头部卡片组件(168 行)
- 新增 hooks/useCompanyOverviewData.ts: 数据加载 Hook
- 删除 index.js: 原 330 行代码精简 85%
- 修复 Company/index.js: 恢复 CompanyTabs 渲染

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:21:02 +08:00
da44dcd522 update pay ui 2025-12-10 11:19:02 +08:00
zdl
bf8847698b feat: CompanyOverview TypeScript 类型定义和工具函数
- types.ts: 添加公司基本信息、股东、管理层等接口定义
- utils.ts: 添加注册资本、日期格式化函数

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:02:24 +08:00
e501ac3819 update pay ui 2025-12-10 11:02:09 +08:00
zdl
7c83ffe008 perf: loadWatchlist 添加 localStorage 缓存(7天有效期)
- 添加 loadWatchlistFromCache/saveWatchlistToCache 缓存工具函数
- loadWatchlist 三级缓存策略:Redux → localStorage → API
- toggleWatchlist 成功后自动同步更新缓存
- 减少重复 API 请求,提升页面加载性能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:01:33 +08:00
zdl
8786fa7b06 feat: StockQuoteCard 根据股票代码获取真实行情数据
- 新增 useStockQuote Hook 获取股票行情
- Company 页面使用 Hook 并传递数据给 StockQuoteCard
- StockQuoteCard 处理 null 数据显示骨架屏
- 股票代码变化时自动刷新行情数据

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:00:03 +08:00
zdl
0997cd9992 feat: 搜索栏交互优化 - 移除查询按钮,选择后直接跳转
- SearchBar: 移除"查询"按钮,简化交互
- SearchBar: 选择股票后直接触发搜索跳转
- useCompanyStock: handleSearch 支持直接传入股票代码参数

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:52:04 +08:00
zdl
c8d704363d fix: 搜索框默认值改为空,避免下拉弹窗自动打开
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:35:01 +08:00
zdl
0de4a1f7af feat: SearchBar 模糊搜索功能
- SearchBar: 添加股票代码/名称模糊搜索下拉列表
- SearchBar: 使用 Redux allStocks 数据源进行过滤
- SearchBar: 点击外部自动关闭下拉,选择后自动搜索
- useCompanyStock: handleKeyPress 改为 handleKeyDown(兼容性优化)
- Company/index: 初始化时加载全部股票列表

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:25:31 +08:00
d9daaeed19 update pay ui 2025-12-09 17:15:22 +08:00
205fd880f8 update pay ui 2025-12-09 17:13:23 +08:00
a6276ec435 update pay ui 2025-12-09 17:09:01 +08:00
87118209fe Merge branch 'feature_bugfix/251201_py_h5_ui' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251201_py_h5_ui 2025-12-09 17:07:54 +08:00
a2d8ff7422 update pay ui 2025-12-09 17:07:47 +08:00
zdl
cf7376cc5a Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui:
  feat: 事件关注功能优化 - Redux 乐观更新 + Mock 数据状态同步
  feat: 投资日历自选股功能优化 - Redux 集成 + 乐观更新
  fix: 修复投资日历切换月份时自动打开事件弹窗的问题
  fix: 修复 CompanyOverview 中 Hooks 顺序错误
2025-12-09 16:36:04 +08:00
b40ca0e23c update pay ui 2025-12-09 16:27:56 +08:00
zdl
e8763331cc Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui:
  refactor: Community 目录结构重组 + 修复导入路径 + 添加 Mock 数据
  feat: 添加mock数据
2025-12-09 13:22:39 +08:00
zdl
c37a25d264 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui:
  refactor: 重构 StockDetailPanel 目录结构,清理未使用代码
  chore: 清理 Community 目录下未使用的文件
  fix: 修复 remeasure 依赖数组缺少 pageType
  refactor: 使用 performanceMonitor 替换 useFirstScreenMetrics 中的 performance.now()
  fix: 文案修改
  fix: 修复个股搜索下拉弹窗被遮挡的问题
  feat: 添加 React 性能追踪 Hooks (usePerformanceTracker)
  feat: 替换公众号文件
2025-12-09 10:01:07 +08:00
25492caf15 update pay ui 2025-12-09 08:31:18 +08:00
zdl
e4937c2719 feat: 替换公众号文件 2025-12-08 15:41:57 +08:00
84d3035556 update pay ui 2025-12-08 14:54:35 +08:00
249 changed files with 45517 additions and 9693 deletions

110
.husky/pre-commit Executable file
View File

@@ -0,0 +1,110 @@
#!/bin/sh
# ============================================
# Git Pre-commit Hook
# ============================================
# 规则:
# 1. src 目录下新增的代码文件必须使用 TypeScript (.ts/.tsx)
# 2. 修改的代码不能使用 fetch应使用 axios
# ============================================
# 颜色定义
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
has_error=0
echo ""
echo "🔍 正在检查代码规范..."
echo ""
# ============================================
# 规则 1: 新文件必须使用 TypeScript
# ============================================
# 获取新增的文件(只检查 src 目录下的代码文件)
new_js_files=$(git diff --cached --name-only --diff-filter=A | grep -E '^src/.*\.(js|jsx)$' || true)
if [ -n "$new_js_files" ]; then
echo "${RED}❌ 错误: 发现新增的 JavaScript 文件${NC}"
echo "${YELLOW} 新文件必须使用 TypeScript (.ts/.tsx)${NC}"
echo ""
echo " 以下文件需要改为 TypeScript:"
echo "$new_js_files" | while read file; do
echo " - $file"
done
echo ""
echo " 💡 提示: 请将文件扩展名改为 .ts 或 .tsx"
echo ""
has_error=1
fi
# ============================================
# 规则 2: 禁止使用 fetch应使用 axios
# ============================================
# 获取所有暂存的文件(新增 + 修改)
staged_files=$(git diff --cached --name-only --diff-filter=AM | grep -E '^src/.*\.(js|jsx|ts|tsx)$' || true)
if [ -n "$staged_files" ]; then
# 检查暂存内容中是否包含 fetch 调用
# 使用 git diff --cached 检查实际修改的内容
fetch_found=""
for file in $staged_files; do
# 检查该文件暂存的更改中是否有 fetch 调用
# 排除注释和字符串中的 fetch
# 匹配: fetch(, await fetch, .fetch(
fetch_matches=$(git diff --cached -U0 "$file" 2>/dev/null | grep -E '^\+.*[^a-zA-Z_]fetch\s*\(' | grep -v '^\+\s*//' || true)
if [ -n "$fetch_matches" ]; then
fetch_found="$fetch_found
$file"
fi
done
if [ -n "$fetch_found" ]; then
echo "${RED}❌ 错误: 检测到使用了 fetch API${NC}"
echo "${YELLOW} 请使用 axios 进行 HTTP 请求${NC}"
echo ""
echo " 以下文件包含 fetch 调用:"
echo "$fetch_found" | while read file; do
if [ -n "$file" ]; then
echo " - $file"
fi
done
echo ""
echo " 💡 修改建议:"
echo " ${GREEN}// 替换前${NC}"
echo " fetch('/api/data').then(res => res.json())"
echo ""
echo " ${GREEN}// 替换后${NC}"
echo " import axios from 'axios';"
echo " axios.get('/api/data').then(res => res.data)"
echo ""
has_error=1
fi
fi
# ============================================
# 检查结果
# ============================================
if [ $has_error -eq 1 ]; then
echo "${RED}========================================${NC}"
echo "${RED}提交被阻止,请修复以上问题后重试${NC}"
echo "${RED}========================================${NC}"
echo ""
exit 1
fi
echo "${GREEN}✅ 代码规范检查通过${NC}"
echo ""
# 运行 lint-staged如果配置了
# 可选:在 package.json 中添加 "lint-staged" 配置来启用代码格式化
# if [ -f "package.json" ] && grep -q '"lint-staged"' package.json; then
# npx lint-staged
# fi

762
app.py
View File

@@ -165,7 +165,7 @@ WECHAT_OPEN_APPID = 'wxa8d74c47041b5f87'
WECHAT_OPEN_APPSECRET = 'eedef95b11787fd7ca7f1acc6c9061bc'
# 微信公众号配置H5 网页授权用)
WECHAT_MP_APPID = 'wx4e4b759f8fa9e43a'
WECHAT_MP_APPID = 'wx8afd36f7c7b21ba0'
WECHAT_MP_APPSECRET = 'ef1ca9064af271bb0405330efbc495aa'
# 微信回调地址
@@ -6412,6 +6412,10 @@ def get_stock_kline(stock_code):
except ValueError:
return jsonify({'error': 'Invalid event_time format'}), 400
# 确保股票代码包含后缀ClickHouse 中数据带后缀)
if '.' not in stock_code:
stock_code = f"{stock_code}.SH" if stock_code.startswith('6') else f"{stock_code}.SZ"
# 获取股票名称
with engine.connect() as conn:
result = conn.execute(text(
@@ -7819,7 +7823,7 @@ def get_index_realtime(index_code):
})
except Exception as e:
logger.error(f"获取指数实时行情失败: {index_code}, 错误: {str(e)}")
app.logger.error(f"获取指数实时行情失败: {index_code}, 错误: {str(e)}")
return jsonify({
'success': False,
'error': str(e),
@@ -7837,8 +7841,13 @@ def get_index_kline(index_code):
except ValueError:
return jsonify({'error': 'Invalid event_time format'}), 400
# 确保指数代码包含后缀ClickHouse 中数据带后缀)
# 399xxx -> 深交所, 其他000xxx等-> 上交所
if '.' not in index_code:
index_code = f"{index_code}.SZ" if index_code.startswith('39') else f"{index_code}.SH"
# 指数名称(暂无索引表,先返回代码本身)
index_name = index_code
index_name = index_code.split('.')[0]
if chart_type == 'minute':
return get_index_minute_kline(index_code, event_datetime, index_name)
@@ -12044,10 +12053,11 @@ def get_market_summary(seccode):
@app.route('/api/stocks/search', methods=['GET'])
def search_stocks():
"""搜索股票(支持股票代码、股票简称、拼音首字母"""
"""搜索股票和指数(支持代码、名称搜索"""
try:
query = request.args.get('q', '').strip()
limit = request.args.get('limit', 20, type=int)
search_type = request.args.get('type', 'all') # all, stock, index
if not query:
return jsonify({
@@ -12055,73 +12065,132 @@ def search_stocks():
'error': '请输入搜索关键词'
}), 400
results = []
with engine.connect() as conn:
test_sql = text("""
SELECT SECCODE, SECNAME, F001V, F003V, F010V, F011V
FROM ea_stocklist
WHERE SECCODE = '300750'
OR F001V LIKE '%ndsd%' LIMIT 5
""")
test_result = conn.execute(test_sql).fetchall()
# 搜索指数(优先显示指数,因为通常用户搜索代码时指数更常用)
if search_type in ('all', 'index'):
index_sql = text("""
SELECT DISTINCT
INDEXCODE as stock_code,
SECNAME as stock_name,
INDEXNAME as full_name,
F018V as exchange
FROM ea_exchangeindex
WHERE (
UPPER(INDEXCODE) LIKE UPPER(:query_pattern)
OR UPPER(SECNAME) LIKE UPPER(:query_pattern)
OR UPPER(INDEXNAME) LIKE UPPER(:query_pattern)
)
ORDER BY CASE
WHEN UPPER(INDEXCODE) = UPPER(:exact_query) THEN 1
WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2
WHEN UPPER(INDEXCODE) LIKE UPPER(:prefix_pattern) THEN 3
WHEN UPPER(SECNAME) LIKE UPPER(:prefix_pattern) THEN 4
ELSE 5
END,
INDEXCODE
LIMIT :limit
""")
# 构建搜索SQL - 支持股票代码、股票简称、拼音简称搜索
search_sql = text("""
SELECT DISTINCT SECCODE as stock_code,
SECNAME as stock_name,
F001V as pinyin_abbr,
F003V as security_type,
F005V as exchange,
F011V as listing_status
FROM ea_stocklist
WHERE (
UPPER(SECCODE) LIKE UPPER(:query_pattern)
OR UPPER(SECNAME) LIKE UPPER(:query_pattern)
OR UPPER(F001V) LIKE UPPER(:query_pattern)
)
-- 基本过滤条件只搜索正常的A股和B股
AND (F011V = '正常上市' OR F010V = '013001') -- 正常上市状态
AND F003V IN ('A股', 'B股') -- 只搜索A股和B股
ORDER BY CASE
WHEN UPPER(SECCODE) = UPPER(:exact_query) THEN 1
WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2
WHEN UPPER(F001V) = UPPER(:exact_query) THEN 3
WHEN UPPER(SECCODE) LIKE UPPER(:prefix_pattern) THEN 4
WHEN UPPER(SECNAME) LIKE UPPER(:prefix_pattern) THEN 5
WHEN UPPER(F001V) LIKE UPPER(:prefix_pattern) THEN 6
ELSE 7
END,
SECCODE LIMIT :limit
""")
index_result = conn.execute(index_sql, {
'query_pattern': f'%{query}%',
'exact_query': query,
'prefix_pattern': f'{query}%',
'limit': limit
}).fetchall()
result = conn.execute(search_sql, {
'query_pattern': f'%{query}%',
'exact_query': query,
'prefix_pattern': f'{query}%',
'limit': limit
}).fetchall()
for row in index_result:
results.append({
'stock_code': row.stock_code,
'stock_name': row.stock_name,
'full_name': row.full_name,
'exchange': row.exchange,
'isIndex': True,
'security_type': '指数'
})
stocks = []
for row in result:
# 获取当前价格
current_price, _ = get_latest_price_from_clickhouse(row.stock_code)
# 搜索股票
if search_type in ('all', 'stock'):
stock_sql = text("""
SELECT DISTINCT SECCODE as stock_code,
SECNAME as stock_name,
F001V as pinyin_abbr,
F003V as security_type,
F005V as exchange,
F011V as listing_status
FROM ea_stocklist
WHERE (
UPPER(SECCODE) LIKE UPPER(:query_pattern)
OR UPPER(SECNAME) LIKE UPPER(:query_pattern)
OR UPPER(F001V) LIKE UPPER(:query_pattern)
)
AND (F011V = '正常上市' OR F010V = '013001')
AND F003V IN ('A股', 'B股')
ORDER BY CASE
WHEN UPPER(SECCODE) = UPPER(:exact_query) THEN 1
WHEN UPPER(SECNAME) = UPPER(:exact_query) THEN 2
WHEN UPPER(F001V) = UPPER(:exact_query) THEN 3
WHEN UPPER(SECCODE) LIKE UPPER(:prefix_pattern) THEN 4
WHEN UPPER(SECNAME) LIKE UPPER(:prefix_pattern) THEN 5
WHEN UPPER(F001V) LIKE UPPER(:prefix_pattern) THEN 6
ELSE 7
END,
SECCODE
LIMIT :limit
""")
stocks.append({
'stock_code': row.stock_code,
'stock_name': row.stock_name,
'current_price': current_price or 0, # 添加当前价格
'pinyin_abbr': row.pinyin_abbr,
'security_type': row.security_type,
'exchange': row.exchange,
'listing_status': row.listing_status
})
stock_result = conn.execute(stock_sql, {
'query_pattern': f'%{query}%',
'exact_query': query,
'prefix_pattern': f'{query}%',
'limit': limit
}).fetchall()
for row in stock_result:
results.append({
'stock_code': row.stock_code,
'stock_name': row.stock_name,
'pinyin_abbr': row.pinyin_abbr,
'security_type': row.security_type,
'exchange': row.exchange,
'listing_status': row.listing_status,
'isIndex': False
})
# 如果搜索全部,按相关性重新排序(精确匹配优先)
if search_type == 'all':
def sort_key(item):
code = item['stock_code'].upper()
name = item['stock_name'].upper()
q = query.upper()
# 精确匹配代码优先
if code == q:
return (0, not item['isIndex'], code) # 指数优先
# 精确匹配名称
if name == q:
return (1, not item['isIndex'], code)
# 前缀匹配代码
if code.startswith(q):
return (2, not item['isIndex'], code)
# 前缀匹配名称
if name.startswith(q):
return (3, not item['isIndex'], code)
return (4, not item['isIndex'], code)
results.sort(key=sort_key)
# 限制总数
results = results[:limit]
return jsonify({
'success': True,
'data': stocks,
'count': len(stocks)
'data': results,
'count': len(results)
})
except Exception as e:
app.logger.error(f"搜索股票/指数错误: {e}")
return jsonify({
'success': False,
'error': str(e)
@@ -12403,7 +12472,21 @@ def get_daily_top_concepts():
top_concepts = []
for concept in data.get('results', []):
# 保持与 /concept-api/search 相同的字段结构
# 处理 stocks 字段:兼容 {name, code} 和 {stock_name, stock_code} 两种格式
raw_stocks = concept.get('stocks', [])
formatted_stocks = []
for stock in raw_stocks:
# 优先使用 stock_name其次使用 name
stock_name = stock.get('stock_name') or stock.get('name', '')
stock_code = stock.get('stock_code') or stock.get('code', '')
formatted_stocks.append({
'stock_name': stock_name,
'stock_code': stock_code,
'name': stock_name, # 兼容旧格式
'code': stock_code # 兼容旧格式
})
# 保持与 /concept-api/search 相同的字段结构,并添加新字段
top_concepts.append({
'concept_id': concept.get('concept_id'),
'concept': concept.get('concept'), # 原始字段名
@@ -12414,8 +12497,10 @@ def get_daily_top_concepts():
'match_type': concept.get('match_type'),
'price_info': concept.get('price_info', {}), # 完整的价格信息
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0), # 兼容旧字段
'happened_times': concept.get('happened_times', []), # 历史触发时间
'stocks': concept.get('stocks', []), # 返回完整股票列表
'tags': concept.get('tags', []), # 标签列表
'outbreak_dates': concept.get('outbreak_dates', []), # 爆发日期列表
'hierarchy': concept.get('hierarchy'), # 层级信息 {lv1, lv2, lv3}
'stocks': formatted_stocks, # 返回格式化后的股票列表
'hot_score': concept.get('hot_score')
})
@@ -12442,6 +12527,557 @@ def get_daily_top_concepts():
}), 500
# ==================== 热点概览 API ====================
@app.route('/api/market/hotspot-overview', methods=['GET'])
def get_hotspot_overview():
"""
获取热点概览数据(用于个股中心的热点概览图表)
返回:指数分时数据 + 概念异动标注
数据来源:
- 指数分时ClickHouse index_minute 表
- 概念异动MySQL concept_anomaly_hybrid 表(来自 realtime_detector.py
"""
try:
trade_date = request.args.get('date')
index_code = request.args.get('index', '000001.SH')
# 如果没有指定日期,使用最新交易日
if not trade_date:
today = date.today()
if today in trading_days_set:
trade_date = today.strftime('%Y-%m-%d')
else:
target_date = get_trading_day_near_date(today)
trade_date = target_date.strftime('%Y-%m-%d') if target_date else today.strftime('%Y-%m-%d')
# 1. 获取指数分时数据
client = get_clickhouse_client()
target_date_obj = datetime.strptime(trade_date, '%Y-%m-%d').date()
index_data = client.execute(
"""
SELECT timestamp, open, high, low, close, volume
FROM index_minute
WHERE code = %(code)s
AND toDate(timestamp) = %(date)s
ORDER BY timestamp
""",
{
'code': index_code,
'date': target_date_obj
}
)
# 获取昨收价
code_no_suffix = index_code.split('.')[0]
prev_close = None
with engine.connect() as conn:
prev_result = conn.execute(text("""
SELECT F006N FROM ea_exchangetrade
WHERE INDEXCODE = :code
AND TRADEDATE < :today
ORDER BY TRADEDATE DESC LIMIT 1
"""), {
'code': code_no_suffix,
'today': target_date_obj
}).fetchone()
if prev_result and prev_result[0]:
prev_close = float(prev_result[0])
# 格式化指数数据
index_timeline = []
for row in index_data:
ts, open_p, high_p, low_p, close_p, vol = row
change_pct = None
if prev_close and close_p:
change_pct = round((float(close_p) - prev_close) / prev_close * 100, 4)
index_timeline.append({
'time': ts.strftime('%H:%M'),
'timestamp': ts.isoformat(),
'price': float(close_p) if close_p else None,
'open': float(open_p) if open_p else None,
'high': float(high_p) if high_p else None,
'low': float(low_p) if low_p else None,
'volume': int(vol) if vol else 0,
'change_pct': change_pct
})
# 2. 获取概念异动数据(优先从 V2 表fallback 到旧表)
alerts = []
use_v2 = False
with engine.connect() as conn:
# 尝试查询 V2 表(时间片对齐 + 持续确认版本)
try:
v2_result = conn.execute(text("""
SELECT
concept_id, alert_time, trade_date, alert_type,
final_score, rule_score, ml_score, trigger_reason, confirm_ratio,
alpha, alpha_zscore, amt_zscore, rank_zscore,
momentum_3m, momentum_5m, limit_up_ratio, triggered_rules
FROM concept_anomaly_v2
WHERE trade_date = :trade_date
ORDER BY alert_time
"""), {'trade_date': trade_date})
v2_rows = v2_result.fetchall()
if v2_rows:
use_v2 = True
for row in v2_rows:
triggered_rules = None
if row[16]:
try:
triggered_rules = json.loads(row[16]) if isinstance(row[16], str) else row[16]
except:
pass
alerts.append({
'concept_id': row[0],
'concept_name': row[0], # 后面会填充
'time': row[1].strftime('%H:%M') if row[1] else None,
'timestamp': row[1].isoformat() if row[1] else None,
'alert_type': row[3],
'final_score': float(row[4]) if row[4] else None,
'rule_score': float(row[5]) if row[5] else None,
'ml_score': float(row[6]) if row[6] else None,
'trigger_reason': row[7],
# V2 新增字段
'confirm_ratio': float(row[8]) if row[8] else None,
'alpha': float(row[9]) if row[9] else None,
'alpha_zscore': float(row[10]) if row[10] else None,
'amt_zscore': float(row[11]) if row[11] else None,
'rank_zscore': float(row[12]) if row[12] else None,
'momentum_3m': float(row[13]) if row[13] else None,
'momentum_5m': float(row[14]) if row[14] else None,
'limit_up_ratio': float(row[15]) if row[15] else 0,
'triggered_rules': triggered_rules,
# 兼容字段
'importance_score': float(row[4]) / 100 if row[4] else None,
'is_v2': True,
})
except Exception as v2_err:
app.logger.debug(f"V2 表查询失败,使用旧表: {v2_err}")
# Fallback: 查询旧表
if not use_v2:
try:
alert_result = conn.execute(text("""
SELECT
a.concept_id, a.alert_time, a.trade_date, a.alert_type,
a.final_score, a.rule_score, a.ml_score, a.trigger_reason,
a.alpha, a.alpha_delta, a.amt_ratio, a.amt_delta,
a.rank_pct, a.limit_up_ratio, a.stock_count, a.total_amt,
a.triggered_rules
FROM concept_anomaly_hybrid a
WHERE a.trade_date = :trade_date
ORDER BY a.alert_time
"""), {'trade_date': trade_date})
for row in alert_result:
triggered_rules = None
if row[16]:
try:
triggered_rules = json.loads(row[16]) if isinstance(row[16], str) else row[16]
except:
pass
limit_up_ratio = float(row[13]) if row[13] else 0
stock_count = int(row[14]) if row[14] else 0
limit_up_count = int(limit_up_ratio * stock_count) if stock_count > 0 else 0
alerts.append({
'concept_id': row[0],
'concept_name': row[0],
'time': row[1].strftime('%H:%M') if row[1] else None,
'timestamp': row[1].isoformat() if row[1] else None,
'alert_type': row[3],
'final_score': float(row[4]) if row[4] else None,
'rule_score': float(row[5]) if row[5] else None,
'ml_score': float(row[6]) if row[6] else None,
'trigger_reason': row[7],
'alpha': float(row[8]) if row[8] else None,
'alpha_delta': float(row[9]) if row[9] else None,
'amt_ratio': float(row[10]) if row[10] else None,
'amt_delta': float(row[11]) if row[11] else None,
'rank_pct': float(row[12]) if row[12] else None,
'limit_up_ratio': limit_up_ratio,
'limit_up_count': limit_up_count,
'stock_count': stock_count,
'total_amt': float(row[15]) if row[15] else None,
'triggered_rules': triggered_rules,
'importance_score': float(row[4]) / 100 if row[4] else None,
'is_v2': False,
})
except Exception as old_err:
app.logger.debug(f"旧表查询也失败: {old_err}")
# 尝试批量获取概念名称
if alerts:
concept_ids = list(set(a['concept_id'] for a in alerts))
concept_names = {} # 初始化 concept_names 字典
try:
from elasticsearch import Elasticsearch
es_client = Elasticsearch(["http://222.128.1.157:19200"])
es_result = es_client.mget(
index='concept_library_v3',
body={'ids': concept_ids},
_source=['concept']
)
for doc in es_result.get('docs', []):
if doc.get('found') and doc.get('_source'):
concept_names[doc['_id']] = doc['_source'].get('concept', doc['_id'])
# 更新 alerts 中的概念名称
for alert in alerts:
if alert['concept_id'] in concept_names:
alert['concept_name'] = concept_names[alert['concept_id']]
except Exception as e:
app.logger.warning(f"获取概念名称失败: {e}")
# 计算统计信息
day_high = max([d['price'] for d in index_timeline if d['price']], default=None)
day_low = min([d['price'] for d in index_timeline if d['price']], default=None)
latest_price = index_timeline[-1]['price'] if index_timeline else None
latest_change_pct = index_timeline[-1]['change_pct'] if index_timeline else None
return jsonify({
'success': True,
'data': {
'trade_date': trade_date,
'index': {
'code': index_code,
'name': '上证指数' if index_code == '000001.SH' else index_code,
'prev_close': prev_close,
'latest_price': latest_price,
'change_pct': latest_change_pct,
'high': day_high,
'low': day_low,
'timeline': index_timeline
},
'alerts': alerts,
'alert_count': len(alerts),
'alert_summary': {
'surge': len([a for a in alerts if a['alert_type'] == 'surge']),
'surge_up': len([a for a in alerts if a['alert_type'] == 'surge_up']),
'surge_down': len([a for a in alerts if a['alert_type'] == 'surge_down']),
'limit_up': len([a for a in alerts if a['alert_type'] == 'limit_up']),
'volume_spike': len([a for a in alerts if a['alert_type'] == 'volume_spike']),
'rank_jump': len([a for a in alerts if a['alert_type'] == 'rank_jump'])
}
}
})
except Exception as e:
import traceback
error_trace = traceback.format_exc()
app.logger.error(f"获取热点概览数据失败: {error_trace}")
return jsonify({
'success': False,
'error': str(e),
'traceback': error_trace # 临时返回完整错误信息用于调试
}), 500
@app.route('/api/concept/<concept_id>/stocks', methods=['GET'])
def get_concept_stocks(concept_id):
"""
获取概念的相关股票列表(带实时涨跌幅)
Args:
concept_id: 概念 ID来自 ES concept_library_v3
Returns:
- stocks: 股票列表 [{code, name, reason, change_pct}, ...]
"""
try:
from elasticsearch import Elasticsearch
from clickhouse_driver import Client
# 1. 从 ES 获取概念的股票列表
es_client = Elasticsearch(["http://222.128.1.157:19200"])
es_result = es_client.get(index='concept_library_v3', id=concept_id)
if not es_result.get('found'):
return jsonify({
'success': False,
'error': f'概念 {concept_id} 不存在'
}), 404
source = es_result.get('_source', {})
concept_name = source.get('concept', concept_id)
raw_stocks = source.get('stocks', [])
if not raw_stocks:
return jsonify({
'success': True,
'data': {
'concept_id': concept_id,
'concept_name': concept_name,
'stocks': []
}
})
# 提取股票代码和原因
stocks_info = []
stock_codes = []
for s in raw_stocks:
if isinstance(s, dict):
code = s.get('code', '')
if code and len(code) == 6:
stocks_info.append({
'code': code,
'name': s.get('name', ''),
'reason': s.get('reason', '')
})
stock_codes.append(code)
if not stock_codes:
return jsonify({
'success': True,
'data': {
'concept_id': concept_id,
'concept_name': concept_name,
'stocks': stocks_info
}
})
# 2. 获取最新交易日和前一交易日
today = datetime.now().date()
trading_day = None
prev_trading_day = None
with engine.connect() as conn:
# 获取最新交易日
result = conn.execute(text("""
SELECT EXCHANGE_DATE FROM trading_days
WHERE EXCHANGE_DATE <= :today
ORDER BY EXCHANGE_DATE DESC LIMIT 1
"""), {"today": today}).fetchone()
if result:
trading_day = result[0].date() if hasattr(result[0], 'date') else result[0]
# 获取前一交易日
if trading_day:
result = conn.execute(text("""
SELECT EXCHANGE_DATE FROM trading_days
WHERE EXCHANGE_DATE < :date
ORDER BY EXCHANGE_DATE DESC LIMIT 1
"""), {"date": trading_day}).fetchone()
if result:
prev_trading_day = result[0].date() if hasattr(result[0], 'date') else result[0]
# 3. 从 MySQL ea_trade 获取前一交易日收盘价F007N
prev_close_map = {}
if prev_trading_day and stock_codes:
with engine.connect() as conn:
placeholders = ','.join([f':code{i}' for i in range(len(stock_codes))])
params = {f'code{i}': code for i, code in enumerate(stock_codes)}
params['trade_date'] = prev_trading_day
result = conn.execute(text(f"""
SELECT SECCODE, F007N
FROM ea_trade
WHERE SECCODE IN ({placeholders})
AND TRADEDATE = :trade_date
AND F007N > 0
"""), params).fetchall()
prev_close_map = {row[0]: float(row[1]) for row in result if row[1]}
# 4. 从 ClickHouse 获取最新价格
current_price_map = {}
if stock_codes:
try:
ch_client = Client(
host='127.0.0.1',
port=9000,
user='default',
password='Zzl33818!',
database='stock'
)
# 转换为 ClickHouse 格式
ch_codes = []
code_mapping = {}
for code in stock_codes:
if code.startswith('6'):
ch_code = f"{code}.SH"
elif code.startswith('0') or code.startswith('3'):
ch_code = f"{code}.SZ"
else:
ch_code = f"{code}.BJ"
ch_codes.append(ch_code)
code_mapping[ch_code] = code
ch_codes_str = "','".join(ch_codes)
# 查询当天最新价格
query = f"""
SELECT code, close
FROM stock_minute
WHERE code IN ('{ch_codes_str}')
AND toDate(timestamp) = today()
ORDER BY timestamp DESC
LIMIT 1 BY code
"""
result = ch_client.execute(query)
for row in result:
ch_code, close_price = row
if ch_code in code_mapping and close_price:
original_code = code_mapping[ch_code]
current_price_map[original_code] = float(close_price)
except Exception as ch_err:
app.logger.warning(f"ClickHouse 获取价格失败: {ch_err}")
# 5. 计算涨跌幅并合并数据
result_stocks = []
for stock in stocks_info:
code = stock['code']
prev_close = prev_close_map.get(code)
current_price = current_price_map.get(code)
change_pct = None
if prev_close and current_price and prev_close > 0:
change_pct = round((current_price - prev_close) / prev_close * 100, 2)
result_stocks.append({
'code': code,
'name': stock['name'],
'reason': stock['reason'],
'change_pct': change_pct,
'price': current_price,
'prev_close': prev_close
})
# 按涨跌幅排序(涨停优先)
result_stocks.sort(key=lambda x: x.get('change_pct') if x.get('change_pct') is not None else -999, reverse=True)
return jsonify({
'success': True,
'data': {
'concept_id': concept_id,
'concept_name': concept_name,
'stock_count': len(result_stocks),
'trading_day': str(trading_day) if trading_day else None,
'stocks': result_stocks
}
})
except Exception as e:
import traceback
app.logger.error(f"获取概念股票失败: {traceback.format_exc()}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/api/market/concept-alerts', methods=['GET'])
def get_concept_alerts():
"""
获取概念异动列表(支持分页和筛选)
"""
try:
trade_date = request.args.get('date')
alert_type = request.args.get('type') # surge/limit_up/rank_jump
concept_type = request.args.get('concept_type') # leaf/lv1/lv2/lv3
limit = request.args.get('limit', 50, type=int)
offset = request.args.get('offset', 0, type=int)
# 构建查询条件
conditions = []
params = {'limit': limit, 'offset': offset}
if trade_date:
conditions.append("trade_date = :trade_date")
params['trade_date'] = trade_date
else:
conditions.append("trade_date = CURDATE()")
if alert_type:
conditions.append("alert_type = :alert_type")
params['alert_type'] = alert_type
if concept_type:
conditions.append("concept_type = :concept_type")
params['concept_type'] = concept_type
where_clause = " AND ".join(conditions) if conditions else "1=1"
with engine.connect() as conn:
# 获取总数
count_sql = text(f"SELECT COUNT(*) FROM concept_minute_alert WHERE {where_clause}")
total = conn.execute(count_sql, params).scalar()
# 获取数据
query_sql = text(f"""
SELECT
id, concept_id, concept_name, alert_time, alert_type, trade_date,
change_pct, prev_change_pct, change_delta,
limit_up_count, prev_limit_up_count, limit_up_delta,
rank_position, prev_rank_position, rank_delta,
index_price, index_change_pct,
stock_count, concept_type, extra_info
FROM concept_minute_alert
WHERE {where_clause}
ORDER BY alert_time DESC
LIMIT :limit OFFSET :offset
""")
result = conn.execute(query_sql, params)
alerts = []
for row in result:
extra_info = None
if row[19]:
try:
extra_info = json.loads(row[19]) if isinstance(row[19], str) else row[19]
except:
pass
alerts.append({
'id': row[0],
'concept_id': row[1],
'concept_name': row[2],
'alert_time': row[3].isoformat() if row[3] else None,
'alert_type': row[4],
'trade_date': row[5].isoformat() if row[5] else None,
'change_pct': float(row[6]) if row[6] else None,
'prev_change_pct': float(row[7]) if row[7] else None,
'change_delta': float(row[8]) if row[8] else None,
'limit_up_count': row[9],
'prev_limit_up_count': row[10],
'limit_up_delta': row[11],
'rank_position': row[12],
'prev_rank_position': row[13],
'rank_delta': row[14],
'index_price': float(row[15]) if row[15] else None,
'index_change_pct': float(row[16]) if row[16] else None,
'stock_count': row[17],
'concept_type': row[18],
'extra_info': extra_info
})
return jsonify({
'success': True,
'data': alerts,
'total': total,
'limit': limit,
'offset': offset
})
except Exception as e:
import traceback
app.logger.error(f"获取概念异动列表失败: {traceback.format_exc()}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/api/market/rise-analysis/<seccode>', methods=['GET'])
def get_rise_analysis(seccode):
"""获取股票涨幅分析数据(从 Elasticsearch 获取)"""

File diff suppressed because it is too large Load Diff

112
ml/README.md Normal file
View File

@@ -0,0 +1,112 @@
# 概念异动检测 ML 模块
基于 Transformer Autoencoder 的概念异动检测系统。
## 环境要求
- Python 3.8+
- PyTorch 2.0+ (CUDA 12.x for 5090 GPU)
- ClickHouse, MySQL, Elasticsearch
## 数据库配置
当前配置(`prepare_data.py`:
- MySQL: `192.168.1.5:3306`
- Elasticsearch: `127.0.0.1:9200`
- ClickHouse: `127.0.0.1:9000`
## 快速开始
```bash
# 1. 安装依赖
pip install -r ml/requirements.txt
# 2. 安装 PyTorch (5090 需要 CUDA 12.4)
pip install torch --index-url https://download.pytorch.org/whl/cu124
# 3. 运行训练
chmod +x ml/run_training.sh
./ml/run_training.sh
```
## 文件说明
| 文件 | 说明 |
|------|------|
| `model.py` | Transformer Autoencoder 模型定义 |
| `prepare_data.py` | 数据提取和特征计算 |
| `train.py` | 模型训练脚本 |
| `inference.py` | 推理服务 |
| `enhanced_detector.py` | 增强版检测器(融合 Alpha + ML |
## 训练参数
```bash
# 完整参数
./ml/run_training.sh --start 2022-01-01 --end 2024-12-01 --epochs 100 --batch_size 256
# 只准备数据
python ml/prepare_data.py --start 2022-01-01
# 只训练(数据已准备好)
python ml/train.py --epochs 100 --batch_size 256 --lr 1e-4
```
## 模型架构
```
输入: (batch, 30, 6) # 30分钟序列6个特征
Positional Encoding
Transformer Encoder (4层, 8头, d=128)
Bottleneck (压缩到 32 维)
Transformer Decoder (4层)
输出: (batch, 30, 6) # 重构序列
异动判断: reconstruction_error > threshold
```
## 6维特征
1. `alpha` - 超额收益(概念涨幅 - 大盘涨幅)
2. `alpha_delta` - Alpha 5分钟变化
3. `amt_ratio` - 成交额 / 20分钟均值
4. `amt_delta` - 成交额变化率
5. `rank_pct` - Alpha 排名百分位
6. `limit_up_ratio` - 涨停股占比
## 训练产出
训练完成后,`ml/checkpoints/` 包含:
- `best_model.pt` - 最佳模型权重
- `thresholds.json` - 异动阈值 (P90/P95/P99)
- `normalization_stats.json` - 数据标准化参数
- `config.json` - 训练配置
## 使用示例
```python
from ml.inference import ConceptAnomalyDetector
detector = ConceptAnomalyDetector('ml/checkpoints')
# 实时检测
is_anomaly, score = detector.detect(
concept_name="人工智能",
features={
'alpha': 2.5,
'alpha_delta': 0.8,
'amt_ratio': 1.5,
'amt_delta': 0.3,
'rank_pct': 0.95,
'limit_up_ratio': 0.15,
}
)
if is_anomaly:
print(f"检测到异动!分数: {score}")
```

10
ml/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
"""
概念异动检测 ML 模块
提供基于 Transformer Autoencoder 的异动检测功能
"""
from .inference import ConceptAnomalyDetector, MLAnomalyService
__all__ = ['ConceptAnomalyDetector', 'MLAnomalyService']

Binary file not shown.

481
ml/backtest.py Normal file
View File

@@ -0,0 +1,481 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
历史异动回测脚本
使用训练好的模型,对历史数据进行异动检测,生成异动记录
使用方法:
# 回测指定日期范围
python backtest.py --start 2024-01-01 --end 2024-12-01
# 回测单天
python backtest.py --start 2024-11-01 --end 2024-11-01
# 只生成结果,不写入数据库
python backtest.py --start 2024-01-01 --dry-run
"""
import os
import sys
import argparse
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Tuple, Optional
from collections import defaultdict
import numpy as np
import pandas as pd
import torch
from tqdm import tqdm
from sqlalchemy import create_engine, text
# 添加父目录到路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from model import TransformerAutoencoder
# ==================== 配置 ====================
MYSQL_ENGINE = create_engine(
"mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock",
echo=False
)
# 特征列表(与训练一致)
FEATURES = [
'alpha',
'alpha_delta',
'amt_ratio',
'amt_delta',
'rank_pct',
'limit_up_ratio',
]
# 回测配置
BACKTEST_CONFIG = {
'seq_len': 30, # 序列长度
'threshold_key': 'p95', # 使用的阈值
'min_alpha_abs': 0.5, # 最小 Alpha 绝对值(过滤微小波动)
'cooldown_minutes': 8, # 同一概念冷却时间
'max_alerts_per_minute': 15, # 每分钟最多异动数
'clip_value': 10.0, # 极端值截断
}
# ==================== 模型加载 ====================
class AnomalyDetector:
"""异动检测器"""
def __init__(self, checkpoint_dir: str = 'ml/checkpoints', device: str = 'auto'):
self.checkpoint_dir = Path(checkpoint_dir)
# 设备
if device == 'auto':
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
else:
self.device = torch.device(device)
# 加载配置
self._load_config()
# 加载模型
self._load_model()
# 加载阈值
self._load_thresholds()
print(f"AnomalyDetector 初始化完成")
print(f" 设备: {self.device}")
print(f" 阈值 ({BACKTEST_CONFIG['threshold_key']}): {self.threshold:.6f}")
def _load_config(self):
config_path = self.checkpoint_dir / 'config.json'
with open(config_path, 'r') as f:
self.config = json.load(f)
def _load_model(self):
model_path = self.checkpoint_dir / 'best_model.pt'
checkpoint = torch.load(model_path, map_location=self.device)
model_config = self.config['model'].copy()
model_config['use_instance_norm'] = self.config.get('use_instance_norm', True)
self.model = TransformerAutoencoder(**model_config)
self.model.load_state_dict(checkpoint['model_state_dict'])
self.model.to(self.device)
self.model.eval()
def _load_thresholds(self):
thresholds_path = self.checkpoint_dir / 'thresholds.json'
with open(thresholds_path, 'r') as f:
thresholds = json.load(f)
self.threshold = thresholds[BACKTEST_CONFIG['threshold_key']]
@torch.no_grad()
def compute_anomaly_scores(self, sequences: np.ndarray) -> np.ndarray:
"""
计算异动分数
Args:
sequences: (n_sequences, seq_len, n_features)
Returns:
scores: (n_sequences,) 每个序列最后时刻的异动分数
"""
# 截断极端值
sequences = np.clip(sequences, -BACKTEST_CONFIG['clip_value'], BACKTEST_CONFIG['clip_value'])
# 转为 tensor
x = torch.FloatTensor(sequences).to(self.device)
# 计算重构误差
errors = self.model.compute_reconstruction_error(x, reduction='none')
# 取最后一个时刻的误差
scores = errors[:, -1].cpu().numpy()
return scores
def is_anomaly(self, score: float) -> bool:
"""判断是否异动"""
return score > self.threshold
# ==================== 数据加载 ====================
def load_daily_features(data_dir: str, date: str) -> Optional[pd.DataFrame]:
"""加载单天的特征数据"""
file_path = Path(data_dir) / f"features_{date}.parquet"
if not file_path.exists():
return None
df = pd.read_parquet(file_path)
return df
def get_available_dates(data_dir: str, start_date: str, end_date: str) -> List[str]:
"""获取可用的日期列表"""
data_path = Path(data_dir)
all_files = sorted(data_path.glob("features_*.parquet"))
dates = []
for f in all_files:
date = f.stem.replace('features_', '')
if start_date <= date <= end_date:
dates.append(date)
return dates
# ==================== 回测逻辑 ====================
def backtest_single_day(
detector: AnomalyDetector,
df: pd.DataFrame,
date: str,
seq_len: int = 30
) -> List[Dict]:
"""
回测单天数据
Args:
detector: 异动检测器
df: 当天的特征数据
date: 日期
seq_len: 序列长度
Returns:
alerts: 异动列表
"""
alerts = []
# 按概念分组
grouped = df.groupby('concept_id', sort=False)
# 冷却记录 {concept_id: last_alert_timestamp}
cooldown = {}
# 获取所有时间点
all_timestamps = sorted(df['timestamp'].unique())
if len(all_timestamps) < seq_len:
return alerts
# 对每个时间点进行检测(从第 seq_len 个开始)
for t_idx in range(seq_len - 1, len(all_timestamps)):
current_time = all_timestamps[t_idx]
window_start_time = all_timestamps[t_idx - seq_len + 1]
minute_alerts = []
# 收集该时刻所有概念的序列
concept_sequences = []
concept_infos = []
for concept_id, concept_df in grouped:
# 获取该概念在时间窗口内的数据
mask = (concept_df['timestamp'] >= window_start_time) & (concept_df['timestamp'] <= current_time)
window_df = concept_df[mask].sort_values('timestamp')
if len(window_df) < seq_len:
continue
# 取最后 seq_len 个点
window_df = window_df.tail(seq_len)
# 提取特征
features = window_df[FEATURES].values
# 处理缺失值
features = np.nan_to_num(features, nan=0.0, posinf=0.0, neginf=0.0)
# 获取当前时刻的信息
current_row = window_df.iloc[-1]
concept_sequences.append(features)
concept_infos.append({
'concept_id': concept_id,
'timestamp': current_time,
'alpha': current_row.get('alpha', 0),
'alpha_delta': current_row.get('alpha_delta', 0),
'amt_ratio': current_row.get('amt_ratio', 1),
'limit_up_ratio': current_row.get('limit_up_ratio', 0),
'limit_down_ratio': current_row.get('limit_down_ratio', 0),
'rank_pct': current_row.get('rank_pct', 0.5),
'stock_count': current_row.get('stock_count', 0),
'total_amt': current_row.get('total_amt', 0),
})
if not concept_sequences:
continue
# 批量计算异动分数
sequences_array = np.array(concept_sequences)
scores = detector.compute_anomaly_scores(sequences_array)
# 检测异动
for i, (info, score) in enumerate(zip(concept_infos, scores)):
concept_id = info['concept_id']
alpha = info['alpha']
# 过滤小波动
if abs(alpha) < BACKTEST_CONFIG['min_alpha_abs']:
continue
# 检查冷却
if concept_id in cooldown:
last_alert = cooldown[concept_id]
if isinstance(current_time, datetime):
time_diff = (current_time - last_alert).total_seconds() / 60
else:
# timestamp 是字符串或其他格式
time_diff = BACKTEST_CONFIG['cooldown_minutes'] + 1 # 跳过冷却检查
if time_diff < BACKTEST_CONFIG['cooldown_minutes']:
continue
# 判断是否异动
if not detector.is_anomaly(score):
continue
# 记录异动
alert_type = 'surge_up' if alpha > 0 else 'surge_down'
alert = {
'concept_id': concept_id,
'alert_time': current_time,
'trade_date': date,
'alert_type': alert_type,
'anomaly_score': float(score),
'threshold': detector.threshold,
**info
}
minute_alerts.append(alert)
cooldown[concept_id] = current_time
# 按分数排序,限制数量
minute_alerts.sort(key=lambda x: x['anomaly_score'], reverse=True)
alerts.extend(minute_alerts[:BACKTEST_CONFIG['max_alerts_per_minute']])
return alerts
# ==================== 数据库写入 ====================
def save_alerts_to_mysql(alerts: List[Dict], dry_run: bool = False) -> int:
"""保存异动到 MySQL"""
if not alerts:
return 0
if dry_run:
print(f" [Dry Run] 将写入 {len(alerts)} 条异动")
return len(alerts)
saved = 0
with MYSQL_ENGINE.begin() as conn:
for alert in alerts:
try:
# 检查是否已存在
check_sql = text("""
SELECT id FROM concept_minute_alert
WHERE concept_id = :concept_id
AND alert_time = :alert_time
AND trade_date = :trade_date
""")
exists = conn.execute(check_sql, {
'concept_id': alert['concept_id'],
'alert_time': alert['alert_time'],
'trade_date': alert['trade_date'],
}).fetchone()
if exists:
continue
# 插入新记录
insert_sql = text("""
INSERT INTO concept_minute_alert
(concept_id, concept_name, alert_time, alert_type, trade_date,
change_pct, zscore, importance_score, stock_count, extra_info)
VALUES
(:concept_id, :concept_name, :alert_time, :alert_type, :trade_date,
:change_pct, :zscore, :importance_score, :stock_count, :extra_info)
""")
conn.execute(insert_sql, {
'concept_id': alert['concept_id'],
'concept_name': alert.get('concept_name', ''),
'alert_time': alert['alert_time'],
'alert_type': alert['alert_type'],
'trade_date': alert['trade_date'],
'change_pct': alert.get('alpha', 0),
'zscore': alert['anomaly_score'],
'importance_score': alert['anomaly_score'],
'stock_count': alert.get('stock_count', 0),
'extra_info': json.dumps({
'detection_method': 'ml_autoencoder',
'threshold': alert['threshold'],
'alpha': alert.get('alpha', 0),
'amt_ratio': alert.get('amt_ratio', 1),
}, ensure_ascii=False)
})
saved += 1
except Exception as e:
print(f" 保存失败: {alert['concept_id']} - {e}")
return saved
def export_alerts_to_csv(alerts: List[Dict], output_path: str):
"""导出异动到 CSV"""
if not alerts:
return
df = pd.DataFrame(alerts)
df.to_csv(output_path, index=False, encoding='utf-8-sig')
print(f"已导出到: {output_path}")
# ==================== 主函数 ====================
def main():
parser = argparse.ArgumentParser(description='历史异动回测')
parser.add_argument('--data_dir', type=str, default='ml/data',
help='特征数据目录')
parser.add_argument('--checkpoint_dir', type=str, default='ml/checkpoints',
help='模型检查点目录')
parser.add_argument('--start', type=str, required=True,
help='开始日期 (YYYY-MM-DD)')
parser.add_argument('--end', type=str, required=True,
help='结束日期 (YYYY-MM-DD)')
parser.add_argument('--dry-run', action='store_true',
help='只计算,不写入数据库')
parser.add_argument('--export-csv', type=str, default=None,
help='导出 CSV 文件路径')
parser.add_argument('--device', type=str, default='auto',
help='设备 (auto/cuda/cpu)')
args = parser.parse_args()
print("=" * 60)
print("历史异动回测")
print("=" * 60)
print(f"日期范围: {args.start} ~ {args.end}")
print(f"数据目录: {args.data_dir}")
print(f"模型目录: {args.checkpoint_dir}")
print(f"Dry Run: {args.dry_run}")
print("=" * 60)
# 初始化检测器
detector = AnomalyDetector(args.checkpoint_dir, args.device)
# 获取可用日期
dates = get_available_dates(args.data_dir, args.start, args.end)
if not dates:
print(f"未找到 {args.start} ~ {args.end} 范围内的数据")
return
print(f"\n找到 {len(dates)} 天的数据")
# 回测
all_alerts = []
total_saved = 0
for date in tqdm(dates, desc="回测进度"):
# 加载数据
df = load_daily_features(args.data_dir, date)
if df is None or df.empty:
continue
# 回测单天
alerts = backtest_single_day(
detector, df, date,
seq_len=BACKTEST_CONFIG['seq_len']
)
if alerts:
all_alerts.extend(alerts)
# 写入数据库
saved = save_alerts_to_mysql(alerts, dry_run=args.dry_run)
total_saved += saved
if not args.dry_run:
tqdm.write(f" {date}: 检测到 {len(alerts)} 个异动,保存 {saved}")
# 导出 CSV
if args.export_csv and all_alerts:
export_alerts_to_csv(all_alerts, args.export_csv)
# 汇总
print("\n" + "=" * 60)
print("回测完成!")
print("=" * 60)
print(f"总计检测到: {len(all_alerts)} 个异动")
print(f"保存到数据库: {total_saved}")
# 统计
if all_alerts:
df_alerts = pd.DataFrame(all_alerts)
print(f"\n异动类型分布:")
print(df_alerts['alert_type'].value_counts())
print(f"\n异动分数统计:")
print(f" Mean: {df_alerts['anomaly_score'].mean():.4f}")
print(f" Max: {df_alerts['anomaly_score'].max():.4f}")
print(f" Min: {df_alerts['anomaly_score'].min():.4f}")
print("=" * 60)
if __name__ == "__main__":
main()

859
ml/backtest_fast.py Normal file
View File

@@ -0,0 +1,859 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
快速融合异动回测脚本
优化策略:
1. 预先构建所有序列(向量化),避免循环内重复切片
2. 批量 ML 推理(一次推理所有候选)
3. 使用 NumPy 向量化操作替代 Python 循环
性能对比:
- 原版5分钟/天
- 优化版:预计 10-30秒/天
"""
import os
import sys
import argparse
import json
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from collections import defaultdict
import numpy as np
import pandas as pd
import torch
from tqdm import tqdm
from sqlalchemy import create_engine, text
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ==================== 配置 ====================
MYSQL_ENGINE = create_engine(
"mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock",
echo=False
)
FEATURES = ['alpha', 'alpha_delta', 'amt_ratio', 'amt_delta', 'rank_pct', 'limit_up_ratio']
CONFIG = {
'seq_len': 15, # 序列长度(支持跨日后可从 9:30 检测)
'min_alpha_abs': 0.3, # 最小 alpha 过滤
'cooldown_minutes': 8,
'max_alerts_per_minute': 20,
'clip_value': 10.0,
# === 融合权重:均衡 ===
'rule_weight': 0.5,
'ml_weight': 0.5,
# === 触发阈值 ===
'rule_trigger': 65, # 60 -> 65略提高规则门槛
'ml_trigger': 70, # 75 -> 70略降低 ML 门槛
'fusion_trigger': 45,
}
# ==================== 规则评分(向量化版)====================
def get_size_adjusted_thresholds(stock_count: np.ndarray) -> dict:
"""
根据概念股票数量计算动态阈值
设计思路:
- 小概念(<10 只):波动大是正常的,需要更高阈值
- 中概念10-50 只):标准阈值
- 大概念(>50 只):能有明显波动说明是真异动,降低阈值
返回各指标的调整系数(乘以基准阈值)
"""
n = len(stock_count)
# 基于股票数量的调整系数
# 小概念:系数 > 1提高阈值更难触发
# 大概念:系数 < 1降低阈值更容易触发
size_factor = np.ones(n)
# 微型概念(<5 只):阈值 × 1.8
tiny = stock_count < 5
size_factor[tiny] = 1.8
# 小概念5-10 只):阈值 × 1.4
small = (stock_count >= 5) & (stock_count < 10)
size_factor[small] = 1.4
# 中小概念10-20 只):阈值 × 1.2
medium_small = (stock_count >= 10) & (stock_count < 20)
size_factor[medium_small] = 1.2
# 中概念20-50 只):标准阈值 × 1.0
medium = (stock_count >= 20) & (stock_count < 50)
size_factor[medium] = 1.0
# 大概念50-100 只):阈值 × 0.85
large = (stock_count >= 50) & (stock_count < 100)
size_factor[large] = 0.85
# 超大概念(>100 只):阈值 × 0.7
xlarge = stock_count >= 100
size_factor[xlarge] = 0.7
return size_factor
def score_rules_batch(df: pd.DataFrame) -> Tuple[np.ndarray, List[List[str]]]:
"""
批量计算规则得分(向量化)- 考虑概念规模版
设计原则:
- 规则作为辅助信号,不应单独主导决策
- 根据概念股票数量动态调整阈值
- 大概念异动更有价值,小概念需要更大波动才算异动
Args:
df: DataFrame包含所有特征列必须包含 stock_count
Returns:
scores: (n,) 规则得分数组
triggered_rules: 每行触发的规则列表
"""
n = len(df)
scores = np.zeros(n)
triggered = [[] for _ in range(n)]
alpha = df['alpha'].values
alpha_delta = df['alpha_delta'].values
amt_ratio = df['amt_ratio'].values
amt_delta = df['amt_delta'].values
rank_pct = df['rank_pct'].values
limit_up_ratio = df['limit_up_ratio'].values
stock_count = df['stock_count'].values if 'stock_count' in df.columns else np.full(n, 20)
alpha_abs = np.abs(alpha)
alpha_delta_abs = np.abs(alpha_delta)
# 获取基于规模的调整系数
size_factor = get_size_adjusted_thresholds(stock_count)
# ========== Alpha 规则(动态阈值)==========
# 基准阈值:极强 5%,强 4%,中等 3%
# 实际阈值 = 基准 × size_factor
# 极强信号
alpha_extreme_thresh = 5.0 * size_factor
mask = alpha_abs >= alpha_extreme_thresh
scores[mask] += 20
for i in np.where(mask)[0]: triggered[i].append('alpha_extreme')
# 强信号
alpha_strong_thresh = 4.0 * size_factor
mask = (alpha_abs >= alpha_strong_thresh) & (alpha_abs < alpha_extreme_thresh)
scores[mask] += 15
for i in np.where(mask)[0]: triggered[i].append('alpha_strong')
# 中等信号
alpha_medium_thresh = 3.0 * size_factor
mask = (alpha_abs >= alpha_medium_thresh) & (alpha_abs < alpha_strong_thresh)
scores[mask] += 10
for i in np.where(mask)[0]: triggered[i].append('alpha_medium')
# ========== Alpha 加速度规则(动态阈值)==========
delta_strong_thresh = 2.0 * size_factor
mask = alpha_delta_abs >= delta_strong_thresh
scores[mask] += 15
for i in np.where(mask)[0]: triggered[i].append('alpha_delta_strong')
delta_medium_thresh = 1.5 * size_factor
mask = (alpha_delta_abs >= delta_medium_thresh) & (alpha_delta_abs < delta_strong_thresh)
scores[mask] += 10
for i in np.where(mask)[0]: triggered[i].append('alpha_delta_medium')
# ========== 成交额规则(不受规模影响,放量就是放量)==========
mask = amt_ratio >= 10.0
scores[mask] += 20
for i in np.where(mask)[0]: triggered[i].append('volume_extreme')
mask = (amt_ratio >= 6.0) & (amt_ratio < 10.0)
scores[mask] += 12
for i in np.where(mask)[0]: triggered[i].append('volume_strong')
# ========== 排名规则 ==========
mask = rank_pct >= 0.98
scores[mask] += 15
for i in np.where(mask)[0]: triggered[i].append('rank_top')
mask = rank_pct <= 0.02
scores[mask] += 15
for i in np.where(mask)[0]: triggered[i].append('rank_bottom')
# ========== 涨停规则(动态阈值)==========
# 大概念有涨停更有意义
limit_high_thresh = 0.30 * size_factor
mask = limit_up_ratio >= limit_high_thresh
scores[mask] += 20
for i in np.where(mask)[0]: triggered[i].append('limit_up_high')
limit_medium_thresh = 0.20 * size_factor
mask = (limit_up_ratio >= limit_medium_thresh) & (limit_up_ratio < limit_high_thresh)
scores[mask] += 12
for i in np.where(mask)[0]: triggered[i].append('limit_up_medium')
# ========== 概念规模加分(大概念异动更有价值)==========
# 大概念50+)额外加分
large_concept = stock_count >= 50
has_signal = scores > 0 # 至少触发了某个规则
mask = large_concept & has_signal
scores[mask] += 10
for i in np.where(mask)[0]: triggered[i].append('large_concept_bonus')
# 超大概念100+)再加分
xlarge_concept = stock_count >= 100
mask = xlarge_concept & has_signal
scores[mask] += 10
for i in np.where(mask)[0]: triggered[i].append('xlarge_concept_bonus')
# ========== 组合规则(动态阈值)==========
combo_alpha_thresh = 3.0 * size_factor
# Alpha + 放量 + 排名(三重验证)
mask = (alpha_abs >= combo_alpha_thresh) & (amt_ratio >= 5.0) & ((rank_pct >= 0.95) | (rank_pct <= 0.05))
scores[mask] += 20
for i in np.where(mask)[0]: triggered[i].append('triple_signal')
# Alpha + 涨停(强组合)
mask = (alpha_abs >= combo_alpha_thresh) & (limit_up_ratio >= 0.15 * size_factor)
scores[mask] += 15
for i in np.where(mask)[0]: triggered[i].append('alpha_with_limit')
# ========== 小概念惩罚(过滤噪音)==========
# 微型概念(<5 只)如果只有单一信号,减分
tiny_concept = stock_count < 5
single_rule = np.array([len(t) <= 1 for t in triggered])
mask = tiny_concept & single_rule & (scores > 0)
scores[mask] *= 0.5 # 减半
for i in np.where(mask)[0]: triggered[i].append('tiny_concept_penalty')
scores = np.clip(scores, 0, 100)
return scores, triggered
# ==================== ML 评分器 ====================
class FastMLScorer:
"""快速 ML 评分器"""
def __init__(self, checkpoint_dir: str = 'ml/checkpoints', device: str = 'auto'):
self.checkpoint_dir = Path(checkpoint_dir)
if device == 'auto':
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
elif device == 'cuda' and not torch.cuda.is_available():
print("警告: CUDA 不可用,使用 CPU")
self.device = torch.device('cpu')
else:
self.device = torch.device(device)
self.model = None
self.thresholds = None
self._load_model()
def _load_model(self):
model_path = self.checkpoint_dir / 'best_model.pt'
thresholds_path = self.checkpoint_dir / 'thresholds.json'
config_path = self.checkpoint_dir / 'config.json'
if not model_path.exists():
print(f"警告: 模型不存在 {model_path}")
return
try:
from model import LSTMAutoencoder
config = {}
if config_path.exists():
with open(config_path) as f:
config = json.load(f).get('model', {})
# 处理旧配置键名
if 'd_model' in config:
config['hidden_dim'] = config.pop('d_model') // 2
for key in ['num_encoder_layers', 'num_decoder_layers', 'nhead', 'dim_feedforward', 'max_seq_len', 'use_instance_norm']:
config.pop(key, None)
if 'num_layers' not in config:
config['num_layers'] = 1
checkpoint = torch.load(model_path, map_location='cpu')
self.model = LSTMAutoencoder(**config)
self.model.load_state_dict(checkpoint['model_state_dict'])
self.model.to(self.device)
self.model.eval()
if thresholds_path.exists():
with open(thresholds_path) as f:
self.thresholds = json.load(f)
print(f"ML模型加载成功 (设备: {self.device})")
except Exception as e:
print(f"ML模型加载失败: {e}")
self.model = None
def is_ready(self):
return self.model is not None
@torch.no_grad()
def score_batch(self, sequences: np.ndarray) -> np.ndarray:
"""
批量计算 ML 得分
Args:
sequences: (batch, seq_len, n_features)
Returns:
scores: (batch,) 0-100 分数
"""
if not self.is_ready() or len(sequences) == 0:
return np.zeros(len(sequences))
x = torch.FloatTensor(sequences).to(self.device)
output, _ = self.model(x)
mse = ((output - x) ** 2).mean(dim=-1)
errors = mse[:, -1].cpu().numpy()
p95 = self.thresholds.get('p95', 0.1) if self.thresholds else 0.1
scores = np.clip(errors / p95 * 50, 0, 100)
return scores
# ==================== 快速回测 ====================
def build_sequences_fast(
df: pd.DataFrame,
seq_len: int = 30,
prev_df: pd.DataFrame = None
) -> Tuple[np.ndarray, pd.DataFrame]:
"""
快速构建所有有效序列
支持跨日序列:用前一天收盘数据 + 当天开盘数据拼接,实现 9:30 就能检测
Args:
df: 当天数据
seq_len: 序列长度
prev_df: 前一天数据(可选,用于构建开盘时的序列)
返回:
sequences: (n_valid, seq_len, n_features) 所有有效序列
info_df: 对应的元信息 DataFrame
"""
# 确保按概念和时间排序
df = df.sort_values(['concept_id', 'timestamp']).reset_index(drop=True)
# 如果有前一天数据,按概念构建尾部缓存(取每个概念最后 seq_len-1 条)
prev_cache = {}
if prev_df is not None and len(prev_df) > 0:
prev_df = prev_df.sort_values(['concept_id', 'timestamp'])
for concept_id, gdf in prev_df.groupby('concept_id'):
tail_data = gdf.tail(seq_len - 1)
if len(tail_data) > 0:
feat_matrix = tail_data[FEATURES].values
feat_matrix = np.nan_to_num(feat_matrix, nan=0.0, posinf=0.0, neginf=0.0)
feat_matrix = np.clip(feat_matrix, -CONFIG['clip_value'], CONFIG['clip_value'])
prev_cache[concept_id] = feat_matrix
# 按概念分组
groups = df.groupby('concept_id')
sequences = []
infos = []
for concept_id, gdf in groups:
gdf = gdf.reset_index(drop=True)
# 获取特征矩阵
feat_matrix = gdf[FEATURES].values
feat_matrix = np.nan_to_num(feat_matrix, nan=0.0, posinf=0.0, neginf=0.0)
feat_matrix = np.clip(feat_matrix, -CONFIG['clip_value'], CONFIG['clip_value'])
# 如果有前一天缓存,拼接到当天数据前面
if concept_id in prev_cache:
prev_data = prev_cache[concept_id]
combined_matrix = np.vstack([prev_data, feat_matrix])
# 计算偏移量:前一天数据的长度
offset = len(prev_data)
else:
combined_matrix = feat_matrix
offset = 0
# 滑动窗口构建序列
n_total = len(combined_matrix)
if n_total < seq_len:
continue
for i in range(n_total - seq_len + 1):
seq = combined_matrix[i:i + seq_len]
# 计算对应当天数据的索引
# 序列最后一个点的位置 = i + seq_len - 1
# 对应当天数据的索引 = (i + seq_len - 1) - offset
today_idx = i + seq_len - 1 - offset
# 只要序列的最后一个点是当天的数据,就记录
if today_idx < 0 or today_idx >= len(gdf):
continue
sequences.append(seq)
# 记录最后一个时间步的信息(当天的)
row = gdf.iloc[today_idx]
infos.append({
'concept_id': concept_id,
'timestamp': row['timestamp'],
'alpha': row['alpha'],
'alpha_delta': row.get('alpha_delta', 0),
'amt_ratio': row.get('amt_ratio', 1),
'amt_delta': row.get('amt_delta', 0),
'rank_pct': row.get('rank_pct', 0.5),
'limit_up_ratio': row.get('limit_up_ratio', 0),
'stock_count': row.get('stock_count', 0),
'total_amt': row.get('total_amt', 0),
})
if not sequences:
return np.array([]), pd.DataFrame()
return np.array(sequences), pd.DataFrame(infos)
def backtest_single_day_fast(
ml_scorer: FastMLScorer,
df: pd.DataFrame,
date: str,
config: Dict,
prev_df: pd.DataFrame = None
) -> List[Dict]:
"""
快速回测单天(向量化版本)
Args:
ml_scorer: ML 评分器
df: 当天数据
date: 日期
config: 配置
prev_df: 前一天数据(用于 9:30 开始检测)
"""
seq_len = config.get('seq_len', 30)
# 1. 构建所有序列(支持跨日)
sequences, info_df = build_sequences_fast(df, seq_len, prev_df)
if len(sequences) == 0:
return []
# 2. 过滤小波动
alpha_abs = np.abs(info_df['alpha'].values)
valid_mask = alpha_abs >= config['min_alpha_abs']
sequences = sequences[valid_mask]
info_df = info_df[valid_mask].reset_index(drop=True)
if len(sequences) == 0:
return []
# 3. 批量规则评分
rule_scores, triggered_rules = score_rules_batch(info_df)
# 4. 批量 ML 评分(分批处理避免显存溢出)
batch_size = 2048
ml_scores = []
for i in range(0, len(sequences), batch_size):
batch_seq = sequences[i:i+batch_size]
batch_scores = ml_scorer.score_batch(batch_seq)
ml_scores.append(batch_scores)
ml_scores = np.concatenate(ml_scores) if ml_scores else np.zeros(len(sequences))
# 5. 融合得分
w1, w2 = config['rule_weight'], config['ml_weight']
final_scores = w1 * rule_scores + w2 * ml_scores
# 6. 判断异动
is_anomaly = (
(rule_scores >= config['rule_trigger']) |
(ml_scores >= config['ml_trigger']) |
(final_scores >= config['fusion_trigger'])
)
# 7. 应用冷却期(按概念+时间排序后处理)
info_df['rule_score'] = rule_scores
info_df['ml_score'] = ml_scores
info_df['final_score'] = final_scores
info_df['is_anomaly'] = is_anomaly
info_df['triggered_rules'] = triggered_rules
# 只保留异动
anomaly_df = info_df[info_df['is_anomaly']].copy()
if len(anomaly_df) == 0:
return []
# 应用冷却期
anomaly_df = anomaly_df.sort_values(['concept_id', 'timestamp'])
cooldown = {}
keep_mask = []
for _, row in anomaly_df.iterrows():
cid = row['concept_id']
ts = row['timestamp']
if cid in cooldown:
try:
diff = (ts - cooldown[cid]).total_seconds() / 60
except:
diff = config['cooldown_minutes'] + 1
if diff < config['cooldown_minutes']:
keep_mask.append(False)
continue
cooldown[cid] = ts
keep_mask.append(True)
anomaly_df = anomaly_df[keep_mask]
# 8. 按时间分组,每分钟最多 max_alerts_per_minute 个
alerts = []
for ts, group in anomaly_df.groupby('timestamp'):
group = group.nlargest(config['max_alerts_per_minute'], 'final_score')
for _, row in group.iterrows():
alpha = row['alpha']
if alpha >= 1.5:
atype = 'surge_up'
elif alpha <= -1.5:
atype = 'surge_down'
elif row['amt_ratio'] >= 3.0:
atype = 'volume_spike'
else:
atype = 'unknown'
rule_score = row['rule_score']
ml_score = row['ml_score']
final_score = row['final_score']
if rule_score >= config['rule_trigger']:
trigger = f'规则强信号({rule_score:.0f}分)'
elif ml_score >= config['ml_trigger']:
trigger = f'ML强信号({ml_score:.0f}分)'
else:
trigger = f'融合触发({final_score:.0f}分)'
alerts.append({
'concept_id': row['concept_id'],
'alert_time': row['timestamp'],
'trade_date': date,
'alert_type': atype,
'final_score': final_score,
'rule_score': rule_score,
'ml_score': ml_score,
'trigger_reason': trigger,
'triggered_rules': row['triggered_rules'],
'alpha': alpha,
'alpha_delta': row['alpha_delta'],
'amt_ratio': row['amt_ratio'],
'amt_delta': row['amt_delta'],
'rank_pct': row['rank_pct'],
'limit_up_ratio': row['limit_up_ratio'],
'stock_count': row['stock_count'],
'total_amt': row['total_amt'],
})
return alerts
# ==================== 数据加载 ====================
def load_daily_features(data_dir: str, date: str) -> Optional[pd.DataFrame]:
file_path = Path(data_dir) / f"features_{date}.parquet"
if not file_path.exists():
return None
return pd.read_parquet(file_path)
def get_available_dates(data_dir: str, start: str, end: str) -> List[str]:
data_path = Path(data_dir)
dates = []
for f in sorted(data_path.glob("features_*.parquet")):
d = f.stem.replace('features_', '')
if start <= d <= end:
dates.append(d)
return dates
def get_prev_trading_day(data_dir: str, date: str) -> Optional[str]:
"""获取给定日期之前最近的有数据的交易日"""
data_path = Path(data_dir)
all_dates = sorted([f.stem.replace('features_', '') for f in data_path.glob("features_*.parquet")])
for i, d in enumerate(all_dates):
if d == date and i > 0:
return all_dates[i - 1]
return None
def export_to_csv(alerts: List[Dict], path: str):
if alerts:
pd.DataFrame(alerts).to_csv(path, index=False, encoding='utf-8-sig')
print(f"已导出: {path}")
# ==================== 数据库写入 ====================
def init_db_table():
"""
初始化数据库表(如果不存在则创建)
表结构说明:
- concept_id: 概念ID
- alert_time: 异动时间(精确到分钟)
- trade_date: 交易日期
- alert_type: 异动类型surge_up/surge_down/volume_spike/unknown
- final_score: 最终得分0-100
- rule_score: 规则得分0-100
- ml_score: ML得分0-100
- trigger_reason: 触发原因
- alpha: 超额收益率
- alpha_delta: alpha变化速度
- amt_ratio: 成交额放大倍数
- rank_pct: 排名百分位
- stock_count: 概念股票数量
- triggered_rules: 触发的规则列表JSON
"""
create_sql = text("""
CREATE TABLE IF NOT EXISTS concept_anomaly_hybrid (
id INT AUTO_INCREMENT PRIMARY KEY,
concept_id VARCHAR(64) NOT NULL,
alert_time DATETIME NOT NULL,
trade_date DATE NOT NULL,
alert_type VARCHAR(32) NOT NULL,
final_score FLOAT NOT NULL,
rule_score FLOAT NOT NULL,
ml_score FLOAT NOT NULL,
trigger_reason VARCHAR(64),
alpha FLOAT,
alpha_delta FLOAT,
amt_ratio FLOAT,
amt_delta FLOAT,
rank_pct FLOAT,
limit_up_ratio FLOAT,
stock_count INT,
total_amt FLOAT,
triggered_rules JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_concept_time (concept_id, alert_time, trade_date),
INDEX idx_trade_date (trade_date),
INDEX idx_concept_id (concept_id),
INDEX idx_final_score (final_score),
INDEX idx_alert_type (alert_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='概念异动检测结果(融合版)'
""")
with MYSQL_ENGINE.begin() as conn:
conn.execute(create_sql)
print("数据库表已就绪: concept_anomaly_hybrid")
def save_alerts_to_mysql(alerts: List[Dict], dry_run: bool = False) -> int:
"""
保存异动到 MySQL
Args:
alerts: 异动列表
dry_run: 是否只模拟,不实际写入
Returns:
实际保存的记录数
"""
if not alerts:
return 0
if dry_run:
print(f" [Dry Run] 将写入 {len(alerts)} 条异动")
return len(alerts)
saved = 0
skipped = 0
with MYSQL_ENGINE.begin() as conn:
for alert in alerts:
try:
# 检查是否已存在(使用 INSERT IGNORE 更高效)
insert_sql = text("""
INSERT IGNORE INTO concept_anomaly_hybrid
(concept_id, alert_time, trade_date, alert_type,
final_score, rule_score, ml_score, trigger_reason,
alpha, alpha_delta, amt_ratio, amt_delta,
rank_pct, limit_up_ratio, stock_count, total_amt,
triggered_rules)
VALUES
(:concept_id, :alert_time, :trade_date, :alert_type,
:final_score, :rule_score, :ml_score, :trigger_reason,
:alpha, :alpha_delta, :amt_ratio, :amt_delta,
:rank_pct, :limit_up_ratio, :stock_count, :total_amt,
:triggered_rules)
""")
result = conn.execute(insert_sql, {
'concept_id': alert['concept_id'],
'alert_time': alert['alert_time'],
'trade_date': alert['trade_date'],
'alert_type': alert['alert_type'],
'final_score': alert['final_score'],
'rule_score': alert['rule_score'],
'ml_score': alert['ml_score'],
'trigger_reason': alert['trigger_reason'],
'alpha': alert.get('alpha', 0),
'alpha_delta': alert.get('alpha_delta', 0),
'amt_ratio': alert.get('amt_ratio', 1),
'amt_delta': alert.get('amt_delta', 0),
'rank_pct': alert.get('rank_pct', 0.5),
'limit_up_ratio': alert.get('limit_up_ratio', 0),
'stock_count': alert.get('stock_count', 0),
'total_amt': alert.get('total_amt', 0),
'triggered_rules': json.dumps(alert.get('triggered_rules', []), ensure_ascii=False),
})
if result.rowcount > 0:
saved += 1
else:
skipped += 1
except Exception as e:
print(f" 保存失败: {alert['concept_id']} @ {alert['alert_time']} - {e}")
if skipped > 0:
print(f" 跳过 {skipped} 条重复记录")
return saved
def clear_alerts_by_date(trade_date: str) -> int:
"""清除指定日期的异动记录(用于重新回测)"""
with MYSQL_ENGINE.begin() as conn:
result = conn.execute(
text("DELETE FROM concept_anomaly_hybrid WHERE trade_date = :trade_date"),
{'trade_date': trade_date}
)
return result.rowcount
def analyze_alerts(alerts: List[Dict]):
if not alerts:
print("无异动")
return
df = pd.DataFrame(alerts)
print(f"\n总异动: {len(alerts)}")
print(f"\n类型分布:\n{df['alert_type'].value_counts()}")
print(f"\n得分统计:")
print(f" 最终: {df['final_score'].mean():.1f} (max: {df['final_score'].max():.1f})")
print(f" 规则: {df['rule_score'].mean():.1f} (max: {df['rule_score'].max():.1f})")
print(f" ML: {df['ml_score'].mean():.1f} (max: {df['ml_score'].max():.1f})")
trigger_type = df['trigger_reason'].apply(
lambda x: '规则' if '规则' in x else ('ML' if 'ML' in x else '融合')
)
print(f"\n触发来源:\n{trigger_type.value_counts()}")
# ==================== 主函数 ====================
def main():
parser = argparse.ArgumentParser(description='快速融合异动回测')
parser.add_argument('--data_dir', default='ml/data')
parser.add_argument('--checkpoint_dir', default='ml/checkpoints')
parser.add_argument('--start', required=True)
parser.add_argument('--end', default=None)
parser.add_argument('--dry-run', action='store_true', help='模拟运行,不写入数据库')
parser.add_argument('--export-csv', default=None, help='导出 CSV 文件路径')
parser.add_argument('--save-db', action='store_true', help='保存结果到数据库')
parser.add_argument('--clear-first', action='store_true', help='写入前先清除该日期的旧数据')
parser.add_argument('--device', default='auto')
args = parser.parse_args()
if args.end is None:
args.end = args.start
print("=" * 60)
print("快速融合异动回测")
print("=" * 60)
print(f"日期: {args.start} ~ {args.end}")
print(f"设备: {args.device}")
print(f"保存数据库: {args.save_db}")
print("=" * 60)
# 初始化数据库表(如果需要保存)
if args.save_db and not args.dry_run:
init_db_table()
# 初始化 ML 评分器
ml_scorer = FastMLScorer(args.checkpoint_dir, args.device)
# 获取日期
dates = get_available_dates(args.data_dir, args.start, args.end)
if not dates:
print("无数据")
return
print(f"找到 {len(dates)} 天数据\n")
# 回测(支持跨日序列)
all_alerts = []
total_saved = 0
prev_df = None # 缓存前一天数据
for i, date in enumerate(tqdm(dates, desc="回测")):
df = load_daily_features(args.data_dir, date)
if df is None or df.empty:
prev_df = None # 当天无数据,清空缓存
continue
# 第一天需要加载前一天数据(如果存在)
if i == 0 and prev_df is None:
prev_date = get_prev_trading_day(args.data_dir, date)
if prev_date:
prev_df = load_daily_features(args.data_dir, prev_date)
if prev_df is not None:
tqdm.write(f" 加载前一天数据: {prev_date}")
alerts = backtest_single_day_fast(ml_scorer, df, date, CONFIG, prev_df)
all_alerts.extend(alerts)
# 保存到数据库
if args.save_db and alerts:
if args.clear_first and not args.dry_run:
cleared = clear_alerts_by_date(date)
if cleared > 0:
tqdm.write(f" 清除 {date} 旧数据: {cleared}")
saved = save_alerts_to_mysql(alerts, dry_run=args.dry_run)
total_saved += saved
tqdm.write(f" {date}: {len(alerts)} 个异动, 保存 {saved}")
elif alerts:
tqdm.write(f" {date}: {len(alerts)} 个异动")
# 当天数据成为下一天的 prev_df
prev_df = df
# 导出 CSV
if args.export_csv:
export_to_csv(all_alerts, args.export_csv)
# 分析
analyze_alerts(all_alerts)
print(f"\n总计: {len(all_alerts)} 个异动")
if args.save_db:
print(f"已保存到数据库: {total_saved}")
if __name__ == "__main__":
main()

481
ml/backtest_hybrid.py Normal file
View File

@@ -0,0 +1,481 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
融合异动回测脚本
使用 HybridAnomalyDetector 进行回测:
- 规则评分 + LSTM Autoencoder 融合判断
- 输出更丰富的异动信息
使用方法:
python backtest_hybrid.py --start 2024-01-01 --end 2024-12-01
python backtest_hybrid.py --start 2024-11-01 --dry-run
"""
import os
import sys
import argparse
import json
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from collections import defaultdict
import numpy as np
import pandas as pd
from tqdm import tqdm
from sqlalchemy import create_engine, text
# 添加父目录到路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from detector import HybridAnomalyDetector, create_detector
# ==================== 配置 ====================
MYSQL_ENGINE = create_engine(
"mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock",
echo=False
)
FEATURES = [
'alpha',
'alpha_delta',
'amt_ratio',
'amt_delta',
'rank_pct',
'limit_up_ratio',
]
BACKTEST_CONFIG = {
'seq_len': 30,
'min_alpha_abs': 0.3, # 降低阈值,让规则也能发挥作用
'cooldown_minutes': 8,
'max_alerts_per_minute': 20,
'clip_value': 10.0,
}
# ==================== 数据加载 ====================
def load_daily_features(data_dir: str, date: str) -> Optional[pd.DataFrame]:
"""加载单天的特征数据"""
file_path = Path(data_dir) / f"features_{date}.parquet"
if not file_path.exists():
return None
df = pd.read_parquet(file_path)
return df
def get_available_dates(data_dir: str, start_date: str, end_date: str) -> List[str]:
"""获取可用的日期列表"""
data_path = Path(data_dir)
all_files = sorted(data_path.glob("features_*.parquet"))
dates = []
for f in all_files:
date = f.stem.replace('features_', '')
if start_date <= date <= end_date:
dates.append(date)
return dates
# ==================== 融合回测 ====================
def backtest_single_day_hybrid(
detector: HybridAnomalyDetector,
df: pd.DataFrame,
date: str,
seq_len: int = 30
) -> List[Dict]:
"""
使用融合检测器回测单天数据(批量优化版)
"""
alerts = []
# 按概念分组,预先构建字典
grouped_dict = {cid: cdf for cid, cdf in df.groupby('concept_id', sort=False)}
# 冷却记录
cooldown = {}
# 获取所有时间点
all_timestamps = sorted(df['timestamp'].unique())
if len(all_timestamps) < seq_len:
return alerts
# 对每个时间点进行检测
for t_idx in range(seq_len - 1, len(all_timestamps)):
current_time = all_timestamps[t_idx]
window_start_time = all_timestamps[t_idx - seq_len + 1]
# 批量收集该时刻所有候选概念
batch_sequences = []
batch_features = []
batch_infos = []
for concept_id, concept_df in grouped_dict.items():
# 检查冷却(提前过滤)
if concept_id in cooldown:
last_alert = cooldown[concept_id]
if isinstance(current_time, datetime):
time_diff = (current_time - last_alert).total_seconds() / 60
else:
time_diff = BACKTEST_CONFIG['cooldown_minutes'] + 1
if time_diff < BACKTEST_CONFIG['cooldown_minutes']:
continue
# 获取时间窗口内的数据
mask = (concept_df['timestamp'] >= window_start_time) & (concept_df['timestamp'] <= current_time)
window_df = concept_df.loc[mask]
if len(window_df) < seq_len:
continue
window_df = window_df.sort_values('timestamp').tail(seq_len)
# 当前时刻特征
current_row = window_df.iloc[-1]
alpha = current_row.get('alpha', 0)
# 过滤微小波动(提前过滤)
if abs(alpha) < BACKTEST_CONFIG['min_alpha_abs']:
continue
# 提取特征序列
sequence = window_df[FEATURES].values
sequence = np.nan_to_num(sequence, nan=0.0, posinf=0.0, neginf=0.0)
sequence = np.clip(sequence, -BACKTEST_CONFIG['clip_value'], BACKTEST_CONFIG['clip_value'])
current_features = {
'alpha': alpha,
'alpha_delta': current_row.get('alpha_delta', 0),
'amt_ratio': current_row.get('amt_ratio', 1),
'amt_delta': current_row.get('amt_delta', 0),
'rank_pct': current_row.get('rank_pct', 0.5),
'limit_up_ratio': current_row.get('limit_up_ratio', 0),
}
batch_sequences.append(sequence)
batch_features.append(current_features)
batch_infos.append({
'concept_id': concept_id,
'stock_count': current_row.get('stock_count', 0),
'total_amt': current_row.get('total_amt', 0),
})
if not batch_sequences:
continue
# 批量 ML 推理
sequences_array = np.array(batch_sequences)
ml_scores = detector.ml_scorer.score(sequences_array) if detector.ml_scorer.is_ready() else [0.0] * len(batch_sequences)
if isinstance(ml_scores, float):
ml_scores = [ml_scores]
# 批量规则评分 + 融合
minute_alerts = []
for i, (features, info) in enumerate(zip(batch_features, batch_infos)):
concept_id = info['concept_id']
# 规则评分
rule_score, rule_details = detector.rule_scorer.score(features)
# ML 评分
ml_score = ml_scores[i] if i < len(ml_scores) else 0.0
# 融合
w1 = detector.config['rule_weight']
w2 = detector.config['ml_weight']
final_score = w1 * rule_score + w2 * ml_score
# 判断是否异动
is_anomaly = False
trigger_reason = ''
if rule_score >= detector.config['rule_trigger']:
is_anomaly = True
trigger_reason = f'规则强信号({rule_score:.0f}分)'
elif ml_score >= detector.config['ml_trigger']:
is_anomaly = True
trigger_reason = f'ML强信号({ml_score:.0f}分)'
elif final_score >= detector.config['fusion_trigger']:
is_anomaly = True
trigger_reason = f'融合触发({final_score:.0f}分)'
if not is_anomaly:
continue
# 异动类型
alpha = features.get('alpha', 0)
if alpha >= 1.5:
anomaly_type = 'surge_up'
elif alpha <= -1.5:
anomaly_type = 'surge_down'
elif features.get('amt_ratio', 1) >= 3.0:
anomaly_type = 'volume_spike'
else:
anomaly_type = 'unknown'
alert = {
'concept_id': concept_id,
'alert_time': current_time,
'trade_date': date,
'alert_type': anomaly_type,
'final_score': final_score,
'rule_score': rule_score,
'ml_score': ml_score,
'trigger_reason': trigger_reason,
'triggered_rules': list(rule_details.keys()),
**features,
**info,
}
minute_alerts.append(alert)
cooldown[concept_id] = current_time
# 按最终得分排序
minute_alerts.sort(key=lambda x: x['final_score'], reverse=True)
alerts.extend(minute_alerts[:BACKTEST_CONFIG['max_alerts_per_minute']])
return alerts
# ==================== 数据库写入 ====================
def save_alerts_to_mysql(alerts: List[Dict], dry_run: bool = False) -> int:
"""保存异动到 MySQL增强版字段"""
if not alerts:
return 0
if dry_run:
print(f" [Dry Run] 将写入 {len(alerts)} 条异动")
return len(alerts)
saved = 0
with MYSQL_ENGINE.begin() as conn:
for alert in alerts:
try:
# 检查是否已存在
check_sql = text("""
SELECT id FROM concept_minute_alert
WHERE concept_id = :concept_id
AND alert_time = :alert_time
AND trade_date = :trade_date
""")
exists = conn.execute(check_sql, {
'concept_id': alert['concept_id'],
'alert_time': alert['alert_time'],
'trade_date': alert['trade_date'],
}).fetchone()
if exists:
continue
# 插入新记录
insert_sql = text("""
INSERT INTO concept_minute_alert
(concept_id, concept_name, alert_time, alert_type, trade_date,
change_pct, zscore, importance_score, stock_count, extra_info)
VALUES
(:concept_id, :concept_name, :alert_time, :alert_type, :trade_date,
:change_pct, :zscore, :importance_score, :stock_count, :extra_info)
""")
extra_info = {
'detection_method': 'hybrid',
'final_score': alert['final_score'],
'rule_score': alert['rule_score'],
'ml_score': alert['ml_score'],
'trigger_reason': alert['trigger_reason'],
'triggered_rules': alert['triggered_rules'],
'alpha': alert.get('alpha', 0),
'alpha_delta': alert.get('alpha_delta', 0),
'amt_ratio': alert.get('amt_ratio', 1),
}
conn.execute(insert_sql, {
'concept_id': alert['concept_id'],
'concept_name': alert.get('concept_name', ''),
'alert_time': alert['alert_time'],
'alert_type': alert['alert_type'],
'trade_date': alert['trade_date'],
'change_pct': alert.get('alpha', 0),
'zscore': alert['final_score'], # 用最终得分作为 zscore
'importance_score': alert['final_score'],
'stock_count': alert.get('stock_count', 0),
'extra_info': json.dumps(extra_info, ensure_ascii=False)
})
saved += 1
except Exception as e:
print(f" 保存失败: {alert['concept_id']} - {e}")
return saved
def export_alerts_to_csv(alerts: List[Dict], output_path: str):
"""导出异动到 CSV"""
if not alerts:
return
df = pd.DataFrame(alerts)
df.to_csv(output_path, index=False, encoding='utf-8-sig')
print(f"已导出到: {output_path}")
# ==================== 统计分析 ====================
def analyze_alerts(alerts: List[Dict]):
"""分析异动结果"""
if not alerts:
print("无异动数据")
return
df = pd.DataFrame(alerts)
print("\n" + "=" * 60)
print("异动统计分析")
print("=" * 60)
# 1. 基本统计
print(f"\n总异动数: {len(alerts)}")
# 2. 按类型统计
print(f"\n异动类型分布:")
print(df['alert_type'].value_counts())
# 3. 得分统计
print(f"\n得分统计:")
print(f" 最终得分 - Mean: {df['final_score'].mean():.1f}, Max: {df['final_score'].max():.1f}")
print(f" 规则得分 - Mean: {df['rule_score'].mean():.1f}, Max: {df['rule_score'].max():.1f}")
print(f" ML得分 - Mean: {df['ml_score'].mean():.1f}, Max: {df['ml_score'].max():.1f}")
# 4. 触发来源分析
print(f"\n触发来源分析:")
trigger_counts = df['trigger_reason'].apply(
lambda x: '规则' if '规则' in x else ('ML' if 'ML' in x else '融合')
).value_counts()
print(trigger_counts)
# 5. 规则触发频率
all_rules = []
for rules in df['triggered_rules']:
if isinstance(rules, list):
all_rules.extend(rules)
if all_rules:
print(f"\n最常触发的规则 (Top 10):")
from collections import Counter
rule_counts = Counter(all_rules)
for rule, count in rule_counts.most_common(10):
print(f" {rule}: {count}")
# ==================== 主函数 ====================
def main():
parser = argparse.ArgumentParser(description='融合异动回测')
parser.add_argument('--data_dir', type=str, default='ml/data',
help='特征数据目录')
parser.add_argument('--checkpoint_dir', type=str, default='ml/checkpoints',
help='模型检查点目录')
parser.add_argument('--start', type=str, required=True,
help='开始日期 (YYYY-MM-DD)')
parser.add_argument('--end', type=str, default=None,
help='结束日期 (YYYY-MM-DD),默认=start')
parser.add_argument('--dry-run', action='store_true',
help='只计算,不写入数据库')
parser.add_argument('--export-csv', type=str, default=None,
help='导出 CSV 文件路径')
parser.add_argument('--rule-weight', type=float, default=0.6,
help='规则权重 (0-1)')
parser.add_argument('--ml-weight', type=float, default=0.4,
help='ML权重 (0-1)')
parser.add_argument('--device', type=str, default='cuda',
help='设备 (cuda/cpu),默认 cuda')
args = parser.parse_args()
if args.end is None:
args.end = args.start
print("=" * 60)
print("融合异动回测 (规则 + LSTM)")
print("=" * 60)
print(f"日期范围: {args.start} ~ {args.end}")
print(f"数据目录: {args.data_dir}")
print(f"模型目录: {args.checkpoint_dir}")
print(f"规则权重: {args.rule_weight}")
print(f"ML权重: {args.ml_weight}")
print(f"设备: {args.device}")
print(f"Dry Run: {args.dry_run}")
print("=" * 60)
# 初始化融合检测器(使用 GPU
config = {
'rule_weight': args.rule_weight,
'ml_weight': args.ml_weight,
}
# 修改 detector.py 中 MLScorer 的设备
from detector import HybridAnomalyDetector
detector = HybridAnomalyDetector(config, args.checkpoint_dir, device=args.device)
# 获取可用日期
dates = get_available_dates(args.data_dir, args.start, args.end)
if not dates:
print(f"未找到 {args.start} ~ {args.end} 范围内的数据")
return
print(f"\n找到 {len(dates)} 天的数据")
# 回测
all_alerts = []
total_saved = 0
for date in tqdm(dates, desc="回测进度"):
df = load_daily_features(args.data_dir, date)
if df is None or df.empty:
continue
alerts = backtest_single_day_hybrid(
detector, df, date,
seq_len=BACKTEST_CONFIG['seq_len']
)
if alerts:
all_alerts.extend(alerts)
saved = save_alerts_to_mysql(alerts, dry_run=args.dry_run)
total_saved += saved
if not args.dry_run:
tqdm.write(f" {date}: 检测到 {len(alerts)} 个异动,保存 {saved}")
# 导出 CSV
if args.export_csv and all_alerts:
export_alerts_to_csv(all_alerts, args.export_csv)
# 统计分析
analyze_alerts(all_alerts)
# 汇总
print("\n" + "=" * 60)
print("回测完成!")
print("=" * 60)
print(f"总计检测到: {len(all_alerts)} 个异动")
print(f"保存到数据库: {total_saved}")
print("=" * 60)
if __name__ == "__main__":
main()

294
ml/backtest_v2.py Normal file
View File

@@ -0,0 +1,294 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
V2 回测脚本 - 验证时间片对齐 + 持续性确认的效果
回测指标:
1. 准确率:异动后 N 分钟内 alpha 是否继续上涨/下跌
2. 虚警率:多少异动是噪音
3. 持续性:平均异动持续时长
"""
import os
import sys
import json
import argparse
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Tuple
from collections import defaultdict
import numpy as np
import pandas as pd
from tqdm import tqdm
from sqlalchemy import create_engine, text
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml.detector_v2 import AnomalyDetectorV2, CONFIG
# ==================== 配置 ====================
MYSQL_ENGINE = create_engine(
"mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock",
echo=False
)
# ==================== 回测评估 ====================
def evaluate_alerts(
alerts: List[Dict],
raw_data: pd.DataFrame,
lookahead_minutes: int = 10
) -> Dict:
"""
评估异动质量
指标:
1. 方向正确率:异动后 N 分钟 alpha 方向是否一致
2. 持续率:异动后 N 分钟内有多少时刻 alpha 保持同向
3. 峰值收益:异动后 N 分钟内的最大 alpha
"""
if not alerts:
return {'accuracy': 0, 'sustained_rate': 0, 'avg_peak': 0, 'total_alerts': 0}
results = []
for alert in alerts:
concept_id = alert['concept_id']
alert_time = alert['alert_time']
alert_alpha = alert['alpha']
is_up = alert_alpha > 0
# 获取该概念在异动后的数据
concept_data = raw_data[
(raw_data['concept_id'] == concept_id) &
(raw_data['timestamp'] > alert_time)
].head(lookahead_minutes)
if len(concept_data) < 3:
continue
future_alphas = concept_data['alpha'].values
# 方向正确:未来 alpha 平均值与当前同向
avg_future_alpha = np.mean(future_alphas)
direction_correct = (is_up and avg_future_alpha > 0) or (not is_up and avg_future_alpha < 0)
# 持续率:有多少时刻保持同向
if is_up:
sustained_count = sum(1 for a in future_alphas if a > 0)
else:
sustained_count = sum(1 for a in future_alphas if a < 0)
sustained_rate = sustained_count / len(future_alphas)
# 峰值收益
if is_up:
peak = max(future_alphas)
else:
peak = min(future_alphas)
results.append({
'direction_correct': direction_correct,
'sustained_rate': sustained_rate,
'peak': peak,
'alert_alpha': alert_alpha,
})
if not results:
return {'accuracy': 0, 'sustained_rate': 0, 'avg_peak': 0, 'total_alerts': 0}
return {
'accuracy': np.mean([r['direction_correct'] for r in results]),
'sustained_rate': np.mean([r['sustained_rate'] for r in results]),
'avg_peak': np.mean([abs(r['peak']) for r in results]),
'total_alerts': len(alerts),
'evaluated_alerts': len(results),
}
def save_alerts_to_mysql(alerts: List[Dict], dry_run: bool = False) -> int:
"""保存异动到 MySQL"""
if not alerts or dry_run:
return 0
# 确保表存在
with MYSQL_ENGINE.begin() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS concept_anomaly_v2 (
id INT AUTO_INCREMENT PRIMARY KEY,
concept_id VARCHAR(64) NOT NULL,
alert_time DATETIME NOT NULL,
trade_date DATE NOT NULL,
alert_type VARCHAR(32) NOT NULL,
final_score FLOAT NOT NULL,
rule_score FLOAT NOT NULL,
ml_score FLOAT NOT NULL,
trigger_reason VARCHAR(128),
confirm_ratio FLOAT,
alpha FLOAT,
alpha_zscore FLOAT,
amt_zscore FLOAT,
rank_zscore FLOAT,
momentum_3m FLOAT,
momentum_5m FLOAT,
limit_up_ratio FLOAT,
triggered_rules JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_concept_time (concept_id, alert_time, trade_date),
INDEX idx_trade_date (trade_date),
INDEX idx_final_score (final_score)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='概念异动 V2时间片对齐+持续确认)'
"""))
# 插入数据
saved = 0
with MYSQL_ENGINE.begin() as conn:
for alert in alerts:
try:
conn.execute(text("""
INSERT IGNORE INTO concept_anomaly_v2
(concept_id, alert_time, trade_date, alert_type,
final_score, rule_score, ml_score, trigger_reason, confirm_ratio,
alpha, alpha_zscore, amt_zscore, rank_zscore,
momentum_3m, momentum_5m, limit_up_ratio, triggered_rules)
VALUES
(:concept_id, :alert_time, :trade_date, :alert_type,
:final_score, :rule_score, :ml_score, :trigger_reason, :confirm_ratio,
:alpha, :alpha_zscore, :amt_zscore, :rank_zscore,
:momentum_3m, :momentum_5m, :limit_up_ratio, :triggered_rules)
"""), {
'concept_id': alert['concept_id'],
'alert_time': alert['alert_time'],
'trade_date': alert['trade_date'],
'alert_type': alert['alert_type'],
'final_score': alert['final_score'],
'rule_score': alert['rule_score'],
'ml_score': alert['ml_score'],
'trigger_reason': alert['trigger_reason'],
'confirm_ratio': alert.get('confirm_ratio', 0),
'alpha': alert['alpha'],
'alpha_zscore': alert.get('alpha_zscore', 0),
'amt_zscore': alert.get('amt_zscore', 0),
'rank_zscore': alert.get('rank_zscore', 0),
'momentum_3m': alert.get('momentum_3m', 0),
'momentum_5m': alert.get('momentum_5m', 0),
'limit_up_ratio': alert.get('limit_up_ratio', 0),
'triggered_rules': json.dumps(alert.get('triggered_rules', [])),
})
saved += 1
except Exception as e:
print(f"保存失败: {e}")
return saved
# ==================== 主函数 ====================
def main():
parser = argparse.ArgumentParser(description='V2 回测')
parser.add_argument('--start', type=str, required=True, help='开始日期')
parser.add_argument('--end', type=str, default=None, help='结束日期')
parser.add_argument('--model_dir', type=str, default='ml/checkpoints_v2')
parser.add_argument('--baseline_dir', type=str, default='ml/data_v2/baselines')
parser.add_argument('--save', action='store_true', help='保存到数据库')
parser.add_argument('--lookahead', type=int, default=10, help='评估前瞻时间(分钟)')
args = parser.parse_args()
end_date = args.end or args.start
print("=" * 60)
print("V2 回测 - 时间片对齐 + 持续性确认")
print("=" * 60)
print(f"日期范围: {args.start} ~ {end_date}")
print(f"模型目录: {args.model_dir}")
print(f"评估前瞻: {args.lookahead} 分钟")
# 初始化检测器
detector = AnomalyDetectorV2(
model_dir=args.model_dir,
baseline_dir=args.baseline_dir
)
# 获取交易日
from prepare_data_v2 import get_trading_days
trading_days = get_trading_days(args.start, end_date)
if not trading_days:
print("无交易日")
return
print(f"交易日数: {len(trading_days)}")
# 回测统计
total_stats = {
'total_alerts': 0,
'accuracy_sum': 0,
'sustained_sum': 0,
'peak_sum': 0,
'day_count': 0,
}
all_alerts = []
for trade_date in tqdm(trading_days, desc="回测进度"):
# 检测异动
alerts = detector.detect(trade_date)
if not alerts:
continue
all_alerts.extend(alerts)
# 评估
raw_data = detector._compute_raw_features(trade_date)
if raw_data.empty:
continue
stats = evaluate_alerts(alerts, raw_data, args.lookahead)
if stats['evaluated_alerts'] > 0:
total_stats['total_alerts'] += stats['total_alerts']
total_stats['accuracy_sum'] += stats['accuracy'] * stats['evaluated_alerts']
total_stats['sustained_sum'] += stats['sustained_rate'] * stats['evaluated_alerts']
total_stats['peak_sum'] += stats['avg_peak'] * stats['evaluated_alerts']
total_stats['day_count'] += 1
print(f"\n[{trade_date}] 异动: {stats['total_alerts']}, "
f"准确率: {stats['accuracy']:.1%}, "
f"持续率: {stats['sustained_rate']:.1%}, "
f"峰值: {stats['avg_peak']:.2f}%")
# 汇总
print("\n" + "=" * 60)
print("回测汇总")
print("=" * 60)
if total_stats['total_alerts'] > 0:
avg_accuracy = total_stats['accuracy_sum'] / total_stats['total_alerts']
avg_sustained = total_stats['sustained_sum'] / total_stats['total_alerts']
avg_peak = total_stats['peak_sum'] / total_stats['total_alerts']
print(f"总异动数: {total_stats['total_alerts']}")
print(f"回测天数: {total_stats['day_count']}")
print(f"平均每天: {total_stats['total_alerts'] / max(1, total_stats['day_count']):.1f}")
print(f"方向准确率: {avg_accuracy:.1%}")
print(f"持续率: {avg_sustained:.1%}")
print(f"平均峰值: {avg_peak:.2f}%")
else:
print("无异动检测结果")
# 保存
if args.save and all_alerts:
print(f"\n保存 {len(all_alerts)} 条异动到数据库...")
saved = save_alerts_to_mysql(all_alerts)
print(f"保存完成: {saved}")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,31 @@
{
"seq_len": 10,
"stride": 2,
"train_end_date": "2025-06-30",
"val_end_date": "2025-09-30",
"features": [
"alpha_zscore",
"amt_zscore",
"rank_zscore",
"momentum_3m",
"momentum_5m",
"limit_up_ratio"
],
"batch_size": 32768,
"epochs": 150,
"learning_rate": 0.0006,
"weight_decay": 1e-05,
"gradient_clip": 1.0,
"patience": 15,
"min_delta": 1e-06,
"model": {
"n_features": 6,
"hidden_dim": 32,
"latent_dim": 4,
"num_layers": 1,
"dropout": 0.2,
"bidirectional": true
},
"clip_value": 5.0,
"threshold_percentiles": [90, 95, 99]
}

View File

@@ -0,0 +1,8 @@
{
"p90": 0.15,
"p95": 0.25,
"p99": 0.50,
"mean": 0.08,
"std": 0.12,
"median": 0.06
}

635
ml/detector.py Normal file
View File

@@ -0,0 +1,635 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
概念异动检测器 - 融合版
结合两种方法的优势:
1. 规则评分系统:可解释、稳定、覆盖已知模式
2. LSTM Autoencoder发现未知的异常模式
融合策略:
┌─────────────────────────────────────────────────────────┐
│ 输入特征 │
│ (alpha, alpha_delta, amt_ratio, amt_delta, rank_pct, │
│ limit_up_ratio) │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 规则评分系统 │ │ LSTM Autoencoder │ │
│ │ (0-100分) │ │ (重构误差) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ rule_score (0-100) ml_score (标准化后 0-100) │
│ │
├─────────────────────────────────────────────────────────┤
│ 融合策略 │
│ │
│ final_score = w1 * rule_score + w2 * ml_score │
│ │
│ 异动判定: │
│ - rule_score >= 60 → 直接触发(规则强信号) │
│ - ml_score >= 80 → 直接触发ML强信号
│ - final_score >= 50 → 融合触发 │
│ │
└─────────────────────────────────────────────────────────┘
优势:
- 规则系统保证已知模式的检出率
- ML模型捕捉规则未覆盖的异常
- 两者互相验证,减少误报
"""
import json
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
import numpy as np
import torch
# 尝试导入模型(可能不存在)
try:
from model import LSTMAutoencoder, create_model
HAS_MODEL = True
except ImportError:
HAS_MODEL = False
@dataclass
class AnomalyResult:
"""异动检测结果"""
is_anomaly: bool
final_score: float # 最终得分 (0-100)
rule_score: float # 规则得分 (0-100)
ml_score: float # ML得分 (0-100)
trigger_reason: str # 触发原因
rule_details: Dict # 规则明细
anomaly_type: str # 异动类型: surge_up / surge_down / volume_spike / unknown
class RuleBasedScorer:
"""
基于规则的评分系统
设计原则:
- 每个规则独立打分
- 分数可叠加
- 阈值可配置
"""
# 默认规则配置
DEFAULT_RULES = {
# Alpha 相关(超额收益)
'alpha_strong': {
'condition': lambda r: abs(r.get('alpha', 0)) >= 3.0,
'score': 35,
'description': 'Alpha强信号(|α|≥3%)'
},
'alpha_medium': {
'condition': lambda r: 2.0 <= abs(r.get('alpha', 0)) < 3.0,
'score': 25,
'description': 'Alpha中等(2%≤|α|<3%)'
},
'alpha_weak': {
'condition': lambda r: 1.5 <= abs(r.get('alpha', 0)) < 2.0,
'score': 15,
'description': 'Alpha轻微(1.5%≤|α|<2%)'
},
# Alpha 变化率(加速度)
'alpha_delta_strong': {
'condition': lambda r: abs(r.get('alpha_delta', 0)) >= 1.0,
'score': 30,
'description': 'Alpha加速强(|Δα|≥1%)'
},
'alpha_delta_medium': {
'condition': lambda r: 0.5 <= abs(r.get('alpha_delta', 0)) < 1.0,
'score': 20,
'description': 'Alpha加速中(0.5%≤|Δα|<1%)'
},
# 成交额比率(放量)
'volume_spike_strong': {
'condition': lambda r: r.get('amt_ratio', 1) >= 5.0,
'score': 30,
'description': '极度放量(≥5倍)'
},
'volume_spike_medium': {
'condition': lambda r: 3.0 <= r.get('amt_ratio', 1) < 5.0,
'score': 20,
'description': '显著放量(3-5倍)'
},
'volume_spike_weak': {
'condition': lambda r: 2.0 <= r.get('amt_ratio', 1) < 3.0,
'score': 10,
'description': '轻微放量(2-3倍)'
},
# 成交额变化率
'amt_delta_strong': {
'condition': lambda r: abs(r.get('amt_delta', 0)) >= 1.0,
'score': 15,
'description': '成交额急变(|Δamt|≥100%)'
},
# 排名跳变
'rank_top': {
'condition': lambda r: r.get('rank_pct', 0.5) >= 0.95,
'score': 25,
'description': '排名前5%'
},
'rank_bottom': {
'condition': lambda r: r.get('rank_pct', 0.5) <= 0.05,
'score': 25,
'description': '排名后5%'
},
'rank_high': {
'condition': lambda r: 0.9 <= r.get('rank_pct', 0.5) < 0.95,
'score': 15,
'description': '排名前10%'
},
# 涨停比例
'limit_up_high': {
'condition': lambda r: r.get('limit_up_ratio', 0) >= 0.2,
'score': 25,
'description': '涨停比例≥20%'
},
'limit_up_medium': {
'condition': lambda r: 0.1 <= r.get('limit_up_ratio', 0) < 0.2,
'score': 15,
'description': '涨停比例10-20%'
},
# 组合条件(更可靠的信号)
'alpha_with_volume': {
'condition': lambda r: abs(r.get('alpha', 0)) >= 1.5 and r.get('amt_ratio', 1) >= 2.0,
'score': 20, # 额外加分
'description': 'Alpha+放量组合'
},
'acceleration_with_rank': {
'condition': lambda r: abs(r.get('alpha_delta', 0)) >= 0.5 and (r.get('rank_pct', 0.5) >= 0.9 or r.get('rank_pct', 0.5) <= 0.1),
'score': 15, # 额外加分
'description': '加速+排名异常组合'
},
}
def __init__(self, rules: Dict = None):
"""
初始化规则评分器
Args:
rules: 自定义规则,格式同 DEFAULT_RULES
"""
self.rules = rules or self.DEFAULT_RULES
def score(self, features: Dict) -> Tuple[float, Dict]:
"""
计算规则得分
Args:
features: 特征字典,包含 alpha, alpha_delta, amt_ratio 等
Returns:
score: 总分 (0-100)
details: 触发的规则明细
"""
total_score = 0
triggered_rules = {}
for rule_name, rule_config in self.rules.items():
try:
if rule_config['condition'](features):
total_score += rule_config['score']
triggered_rules[rule_name] = {
'score': rule_config['score'],
'description': rule_config['description']
}
except Exception:
# 忽略规则计算错误
pass
# 限制在 0-100
total_score = min(100, max(0, total_score))
return total_score, triggered_rules
def get_anomaly_type(self, features: Dict) -> str:
"""判断异动类型"""
alpha = features.get('alpha', 0)
amt_ratio = features.get('amt_ratio', 1)
if alpha >= 1.5:
return 'surge_up'
elif alpha <= -1.5:
return 'surge_down'
elif amt_ratio >= 3.0:
return 'volume_spike'
else:
return 'unknown'
class MLScorer:
"""
基于 LSTM Autoencoder 的评分器
将重构误差转换为 0-100 的分数
"""
def __init__(
self,
checkpoint_dir: str = 'ml/checkpoints',
device: str = 'auto'
):
self.checkpoint_dir = Path(checkpoint_dir)
# 设备检测
if device == 'auto':
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
elif device == 'cuda' and not torch.cuda.is_available():
print("警告: CUDA 不可用,使用 CPU")
self.device = torch.device('cpu')
else:
self.device = torch.device(device)
self.model = None
self.thresholds = None
self.config = None
# 尝试加载模型
self._load_model()
def _load_model(self):
"""加载模型和阈值"""
if not HAS_MODEL:
print("警告: 无法导入模型模块")
return
model_path = self.checkpoint_dir / 'best_model.pt'
thresholds_path = self.checkpoint_dir / 'thresholds.json'
config_path = self.checkpoint_dir / 'config.json'
if not model_path.exists():
print(f"警告: 模型文件不存在 {model_path}")
return
try:
# 加载配置
if config_path.exists():
with open(config_path, 'r') as f:
self.config = json.load(f)
# 先用 CPU 加载模型(避免 CUDA 不可用问题),再移动到目标设备
checkpoint = torch.load(model_path, map_location='cpu')
model_config = self.config.get('model', {}) if self.config else {}
self.model = create_model(model_config)
self.model.load_state_dict(checkpoint['model_state_dict'])
self.model.to(self.device)
self.model.eval()
# 加载阈值
if thresholds_path.exists():
with open(thresholds_path, 'r') as f:
self.thresholds = json.load(f)
print(f"MLScorer 加载成功 (设备: {self.device})")
except Exception as e:
print(f"警告: 模型加载失败 - {e}")
import traceback
traceback.print_exc()
self.model = None
def is_ready(self) -> bool:
"""检查模型是否就绪"""
return self.model is not None
@torch.no_grad()
def score(self, sequence: np.ndarray) -> float:
"""
计算 ML 得分
Args:
sequence: (seq_len, n_features) 或 (batch, seq_len, n_features)
Returns:
score: 0-100 的分数,越高越异常
"""
if not self.is_ready():
return 0.0
# 确保是 3D
if sequence.ndim == 2:
sequence = sequence[np.newaxis, ...]
# 转为 tensor
x = torch.FloatTensor(sequence).to(self.device)
# 计算重构误差
output, _ = self.model(x)
mse = ((output - x) ** 2).mean(dim=-1) # (batch, seq_len)
# 取最后时刻的误差
error = mse[:, -1].cpu().numpy()
# 转换为 0-100 分数
# 使用 p95 阈值作为参考
if self.thresholds:
p95 = self.thresholds.get('p95', 0.1)
p99 = self.thresholds.get('p99', 0.2)
else:
p95, p99 = 0.1, 0.2
# 线性映射p95 -> 50分, p99 -> 80分
# error=0 -> 0分, error>=p99*1.5 -> 100分
score = np.clip(error / p95 * 50, 0, 100)
return float(score[0]) if len(score) == 1 else score.tolist()
class HybridAnomalyDetector:
"""
融合异动检测器
结合规则系统和 ML 模型
"""
# 默认配置
DEFAULT_CONFIG = {
# 权重配置
'rule_weight': 0.6, # 规则权重
'ml_weight': 0.4, # ML权重
# 触发阈值
'rule_trigger': 60, # 规则直接触发阈值
'ml_trigger': 80, # ML直接触发阈值
'fusion_trigger': 50, # 融合触发阈值
# 特征列表
'features': [
'alpha', 'alpha_delta', 'amt_ratio',
'amt_delta', 'rank_pct', 'limit_up_ratio'
],
# 序列长度ML模型需要
'seq_len': 30,
}
def __init__(
self,
config: Dict = None,
checkpoint_dir: str = 'ml/checkpoints',
device: str = 'auto'
):
self.config = {**self.DEFAULT_CONFIG, **(config or {})}
# 初始化评分器
self.rule_scorer = RuleBasedScorer()
self.ml_scorer = MLScorer(checkpoint_dir, device)
print(f"HybridAnomalyDetector 初始化完成")
print(f" 规则权重: {self.config['rule_weight']}")
print(f" ML权重: {self.config['ml_weight']}")
print(f" ML模型: {'就绪' if self.ml_scorer.is_ready() else '未加载'}")
def detect(
self,
features: Dict,
sequence: np.ndarray = None
) -> AnomalyResult:
"""
检测异动
Args:
features: 当前时刻的特征字典
sequence: 历史序列 (seq_len, n_features)ML模型需要
Returns:
AnomalyResult: 检测结果
"""
# 1. 规则评分
rule_score, rule_details = self.rule_scorer.score(features)
# 2. ML评分
ml_score = 0.0
if sequence is not None and self.ml_scorer.is_ready():
ml_score = self.ml_scorer.score(sequence)
# 3. 融合得分
w1 = self.config['rule_weight']
w2 = self.config['ml_weight']
# 如果ML不可用全部权重给规则
if not self.ml_scorer.is_ready():
w1, w2 = 1.0, 0.0
final_score = w1 * rule_score + w2 * ml_score
# 4. 判断是否异动
is_anomaly = False
trigger_reason = ''
if rule_score >= self.config['rule_trigger']:
is_anomaly = True
trigger_reason = f'规则强信号({rule_score:.0f}分)'
elif ml_score >= self.config['ml_trigger']:
is_anomaly = True
trigger_reason = f'ML强信号({ml_score:.0f}分)'
elif final_score >= self.config['fusion_trigger']:
is_anomaly = True
trigger_reason = f'融合触发({final_score:.0f}分)'
# 5. 判断异动类型
anomaly_type = self.rule_scorer.get_anomaly_type(features) if is_anomaly else ''
return AnomalyResult(
is_anomaly=is_anomaly,
final_score=final_score,
rule_score=rule_score,
ml_score=ml_score,
trigger_reason=trigger_reason,
rule_details=rule_details,
anomaly_type=anomaly_type
)
def detect_batch(
self,
features_list: List[Dict],
sequences: np.ndarray = None
) -> List[AnomalyResult]:
"""
批量检测
Args:
features_list: 特征字典列表
sequences: (batch, seq_len, n_features)
Returns:
List[AnomalyResult]
"""
results = []
for i, features in enumerate(features_list):
seq = sequences[i] if sequences is not None else None
result = self.detect(features, seq)
results.append(result)
return results
# ==================== 便捷函数 ====================
def create_detector(
checkpoint_dir: str = 'ml/checkpoints',
config: Dict = None
) -> HybridAnomalyDetector:
"""创建融合检测器"""
return HybridAnomalyDetector(config, checkpoint_dir)
def quick_detect(features: Dict) -> bool:
"""
快速检测只用规则不需要ML模型
适用于:
- 实时检测
- ML模型未训练完成时
"""
scorer = RuleBasedScorer()
score, _ = scorer.score(features)
return score >= 50
# ==================== 测试 ====================
if __name__ == "__main__":
print("=" * 60)
print("融合异动检测器测试")
print("=" * 60)
# 创建检测器
detector = create_detector()
# 测试用例
test_cases = [
{
'name': '正常情况',
'features': {
'alpha': 0.5,
'alpha_delta': 0.1,
'amt_ratio': 1.2,
'amt_delta': 0.1,
'rank_pct': 0.5,
'limit_up_ratio': 0.02
}
},
{
'name': 'Alpha异动',
'features': {
'alpha': 3.5,
'alpha_delta': 0.8,
'amt_ratio': 2.5,
'amt_delta': 0.5,
'rank_pct': 0.92,
'limit_up_ratio': 0.05
}
},
{
'name': '放量异动',
'features': {
'alpha': 1.2,
'alpha_delta': 0.3,
'amt_ratio': 6.0,
'amt_delta': 1.5,
'rank_pct': 0.85,
'limit_up_ratio': 0.08
}
},
{
'name': '涨停潮',
'features': {
'alpha': 2.5,
'alpha_delta': 0.6,
'amt_ratio': 3.5,
'amt_delta': 0.8,
'rank_pct': 0.98,
'limit_up_ratio': 0.25
}
},
]
print("\n" + "-" * 60)
print("测试1: 只用规则(无序列数据)")
print("-" * 60)
for case in test_cases:
result = detector.detect(case['features'])
print(f"\n{case['name']}:")
print(f" 异动: {'' if result.is_anomaly else ''}")
print(f" 最终得分: {result.final_score:.1f}")
print(f" 规则得分: {result.rule_score:.1f}")
print(f" ML得分: {result.ml_score:.1f}")
if result.is_anomaly:
print(f" 触发原因: {result.trigger_reason}")
print(f" 异动类型: {result.anomaly_type}")
print(f" 触发规则: {list(result.rule_details.keys())}")
# 测试2: 带序列数据的融合检测
print("\n" + "-" * 60)
print("测试2: 融合检测(规则 + ML")
print("-" * 60)
# 生成模拟序列数据
seq_len = 30
n_features = 6
# 正常序列:小幅波动
normal_sequence = np.random.randn(seq_len, n_features) * 0.3
normal_sequence[:, 0] = np.linspace(0, 0.5, seq_len) # alpha 缓慢上升
normal_sequence[:, 2] = np.abs(normal_sequence[:, 2]) + 1 # amt_ratio > 0
# 异常序列:最后几个时间步突然变化
anomaly_sequence = np.random.randn(seq_len, n_features) * 0.3
anomaly_sequence[-5:, 0] = np.linspace(1, 4, 5) # alpha 突然飙升
anomaly_sequence[-5:, 1] = np.linspace(0.2, 1.5, 5) # alpha_delta 加速
anomaly_sequence[-5:, 2] = np.linspace(2, 6, 5) # amt_ratio 放量
anomaly_sequence[:, 2] = np.abs(anomaly_sequence[:, 2]) + 1
# 测试正常序列
normal_features = {
'alpha': float(normal_sequence[-1, 0]),
'alpha_delta': float(normal_sequence[-1, 1]),
'amt_ratio': float(normal_sequence[-1, 2]),
'amt_delta': float(normal_sequence[-1, 3]),
'rank_pct': 0.5,
'limit_up_ratio': 0.02
}
result = detector.detect(normal_features, normal_sequence)
print(f"\n正常序列:")
print(f" 异动: {'' if result.is_anomaly else ''}")
print(f" 最终得分: {result.final_score:.1f}")
print(f" 规则得分: {result.rule_score:.1f}")
print(f" ML得分: {result.ml_score:.1f}")
# 测试异常序列
anomaly_features = {
'alpha': float(anomaly_sequence[-1, 0]),
'alpha_delta': float(anomaly_sequence[-1, 1]),
'amt_ratio': float(anomaly_sequence[-1, 2]),
'amt_delta': float(anomaly_sequence[-1, 3]),
'rank_pct': 0.95,
'limit_up_ratio': 0.15
}
result = detector.detect(anomaly_features, anomaly_sequence)
print(f"\n异常序列:")
print(f" 异动: {'' if result.is_anomaly else ''}")
print(f" 最终得分: {result.final_score:.1f}")
print(f" 规则得分: {result.rule_score:.1f}")
print(f" ML得分: {result.ml_score:.1f}")
if result.is_anomaly:
print(f" 触发原因: {result.trigger_reason}")
print(f" 异动类型: {result.anomaly_type}")
print("\n" + "=" * 60)
print("测试完成!")

716
ml/detector_v2.py Normal file
View File

@@ -0,0 +1,716 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
异动检测器 V2 - 基于时间片对齐 + 持续性确认
核心改进:
1. Z-Score 特征:相对于同时间片历史的偏离
2. 短序列 LSTM10分钟序列开盘即可用
3. 持续性确认5分钟窗口内60%时刻超标才确认为异动
检测流程:
1. 计算当前时刻的 Z-Score对比同时间片历史基线
2. 构建最近10分钟的 Z-Score 序列
3. LSTM 计算重构误差ML分数
4. 规则评分(基于 Z-Score 的规则)
5. 滑动窗口确认最近5分钟内是否有足够多的时刻超标
6. 只有通过持续性确认的才输出为异动
"""
import os
import sys
import json
import pickle
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from collections import defaultdict, deque
import numpy as np
import pandas as pd
import torch
from sqlalchemy import create_engine, text
from elasticsearch import Elasticsearch
from clickhouse_driver import Client
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml.model import TransformerAutoencoder
# ==================== 配置 ====================
MYSQL_ENGINE = create_engine(
"mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock",
echo=False
)
ES_CLIENT = Elasticsearch(['http://127.0.0.1:9200'])
ES_INDEX = 'concept_library_v3'
CLICKHOUSE_CONFIG = {
'host': '127.0.0.1',
'port': 9000,
'user': 'default',
'password': 'Zzl33818!',
'database': 'stock'
}
REFERENCE_INDEX = '000001.SH'
# 检测配置
CONFIG = {
# 序列配置
'seq_len': 10, # LSTM 序列长度(分钟)
# 持续性确认配置(核心!)
'confirm_window': 5, # 确认窗口(分钟)
'confirm_ratio': 0.6, # 确认比例60%时刻需要超标)
# Z-Score 阈值
'alpha_zscore_threshold': 2.0, # Alpha Z-Score 阈值
'amt_zscore_threshold': 2.5, # 成交额 Z-Score 阈值
# 融合权重
'rule_weight': 0.5,
'ml_weight': 0.5,
# 触发阈值
'rule_trigger': 60,
'ml_trigger': 70,
'fusion_trigger': 50,
# 冷却期
'cooldown_minutes': 10,
'max_alerts_per_minute': 15,
# Z-Score 截断
'zscore_clip': 5.0,
}
# V2 特征列表
FEATURES_V2 = [
'alpha_zscore', 'amt_zscore', 'rank_zscore',
'momentum_3m', 'momentum_5m', 'limit_up_ratio'
]
# ==================== 工具函数 ====================
def get_ch_client():
return Client(**CLICKHOUSE_CONFIG)
def code_to_ch_format(code: str) -> str:
if not code or len(code) != 6 or not code.isdigit():
return None
if code.startswith('6'):
return f"{code}.SH"
elif code.startswith('0') or code.startswith('3'):
return f"{code}.SZ"
else:
return f"{code}.BJ"
def time_to_slot(ts) -> str:
"""时间戳转时间片HH:MM"""
if isinstance(ts, str):
return ts
return ts.strftime('%H:%M')
# ==================== 基线加载 ====================
def load_baselines(baseline_dir: str = 'ml/data_v2/baselines') -> Dict[str, pd.DataFrame]:
"""加载时间片基线"""
baseline_file = os.path.join(baseline_dir, 'baselines.pkl')
if os.path.exists(baseline_file):
with open(baseline_file, 'rb') as f:
return pickle.load(f)
return {}
# ==================== 规则评分(基于 Z-Score====================
def score_rules_zscore(row: Dict) -> Tuple[float, List[str]]:
"""
基于 Z-Score 的规则评分
设计思路Z-Score 已经标准化,直接用阈值判断
"""
score = 0.0
triggered = []
alpha_zscore = row.get('alpha_zscore', 0)
amt_zscore = row.get('amt_zscore', 0)
rank_zscore = row.get('rank_zscore', 0)
momentum_3m = row.get('momentum_3m', 0)
momentum_5m = row.get('momentum_5m', 0)
limit_up_ratio = row.get('limit_up_ratio', 0)
alpha_zscore_abs = abs(alpha_zscore)
amt_zscore_abs = abs(amt_zscore)
# ========== Alpha Z-Score 规则 ==========
if alpha_zscore_abs >= 4.0:
score += 25
triggered.append('alpha_zscore_extreme')
elif alpha_zscore_abs >= 3.0:
score += 18
triggered.append('alpha_zscore_strong')
elif alpha_zscore_abs >= 2.0:
score += 10
triggered.append('alpha_zscore_moderate')
# ========== 成交额 Z-Score 规则 ==========
if amt_zscore >= 4.0:
score += 20
triggered.append('amt_zscore_extreme')
elif amt_zscore >= 3.0:
score += 12
triggered.append('amt_zscore_strong')
elif amt_zscore >= 2.0:
score += 6
triggered.append('amt_zscore_moderate')
# ========== 排名 Z-Score 规则 ==========
if abs(rank_zscore) >= 3.0:
score += 15
triggered.append('rank_zscore_extreme')
elif abs(rank_zscore) >= 2.0:
score += 8
triggered.append('rank_zscore_strong')
# ========== 动量规则 ==========
if momentum_3m >= 1.0:
score += 12
triggered.append('momentum_3m_strong')
elif momentum_3m >= 0.5:
score += 6
triggered.append('momentum_3m_moderate')
if momentum_5m >= 1.5:
score += 10
triggered.append('momentum_5m_strong')
# ========== 涨停比例规则 ==========
if limit_up_ratio >= 0.3:
score += 20
triggered.append('limit_up_extreme')
elif limit_up_ratio >= 0.15:
score += 12
triggered.append('limit_up_strong')
elif limit_up_ratio >= 0.08:
score += 5
triggered.append('limit_up_moderate')
# ========== 组合规则 ==========
# Alpha Z-Score + 成交额放大
if alpha_zscore_abs >= 2.0 and amt_zscore >= 2.0:
score += 15
triggered.append('combo_alpha_amt')
# Alpha Z-Score + 涨停
if alpha_zscore_abs >= 2.0 and limit_up_ratio >= 0.1:
score += 12
triggered.append('combo_alpha_limitup')
return min(score, 100), triggered
# ==================== ML 评分器 ====================
class MLScorerV2:
"""V2 ML 评分器"""
def __init__(self, model_dir: str = 'ml/checkpoints_v2'):
self.model_dir = model_dir
self.model = None
self.thresholds = None
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
self._load_model()
def _load_model(self):
"""加载模型和阈值"""
model_path = os.path.join(self.model_dir, 'best_model.pt')
threshold_path = os.path.join(self.model_dir, 'thresholds.json')
config_path = os.path.join(self.model_dir, 'config.json')
if not os.path.exists(model_path):
print(f"警告: 模型文件不存在: {model_path}")
return
# 加载配置
with open(config_path, 'r') as f:
config = json.load(f)
# 创建模型
model_config = config.get('model', {})
self.model = TransformerAutoencoder(**model_config)
# 加载权重
checkpoint = torch.load(model_path, map_location=self.device)
self.model.load_state_dict(checkpoint['model_state_dict'])
self.model.to(self.device)
self.model.eval()
# 加载阈值
if os.path.exists(threshold_path):
with open(threshold_path, 'r') as f:
self.thresholds = json.load(f)
print(f"V2 模型加载完成: {model_path}")
@torch.no_grad()
def score_batch(self, sequences: np.ndarray) -> np.ndarray:
"""
批量计算 ML 分数
返回 0-100 的分数,越高越异常
"""
if self.model is None:
return np.zeros(len(sequences))
# 转换为 tensor
x = torch.FloatTensor(sequences).to(self.device)
# 计算重构误差
errors = self.model.compute_reconstruction_error(x, reduction='none')
# 取最后一个时刻的误差
last_errors = errors[:, -1].cpu().numpy()
# 转换为 0-100 分数
if self.thresholds:
p50 = self.thresholds.get('median', 0.1)
p99 = self.thresholds.get('p99', 1.0)
# 线性映射p50 -> 50分p99 -> 99分
scores = 50 + (last_errors - p50) / (p99 - p50) * 49
scores = np.clip(scores, 0, 100)
else:
# 没有阈值时,简单归一化
scores = last_errors * 100
scores = np.clip(scores, 0, 100)
return scores
# ==================== 实时数据管理器 ====================
class RealtimeDataManagerV2:
"""
V2 实时数据管理器
维护:
1. 每个概念的历史 Z-Score 序列(用于 LSTM 输入)
2. 每个概念的异动候选队列(用于持续性确认)
"""
def __init__(self, concepts: List[dict], baselines: Dict[str, pd.DataFrame]):
self.concepts = {c['concept_id']: c for c in concepts}
self.baselines = baselines
# 概念到股票的映射
self.concept_stocks = {c['concept_id']: set(c['stocks']) for c in concepts}
# 历史 Z-Score 序列(每个概念)
# {concept_id: deque([(timestamp, features_dict), ...], maxlen=seq_len)}
self.zscore_history = defaultdict(lambda: deque(maxlen=CONFIG['seq_len']))
# 异动候选队列(用于持续性确认)
# {concept_id: deque([(timestamp, score), ...], maxlen=confirm_window)}
self.anomaly_candidates = defaultdict(lambda: deque(maxlen=CONFIG['confirm_window']))
# 冷却期记录
self.cooldown = {}
# 上一次更新的时间戳
self.last_timestamp = None
def compute_zscore_features(
self,
concept_id: str,
timestamp,
alpha: float,
total_amt: float,
rank_pct: float,
limit_up_ratio: float
) -> Optional[Dict]:
"""计算单个概念单个时刻的 Z-Score 特征"""
if concept_id not in self.baselines:
return None
baseline = self.baselines[concept_id]
time_slot = time_to_slot(timestamp)
# 查找对应时间片的基线
bl_row = baseline[baseline['time_slot'] == time_slot]
if bl_row.empty:
return None
bl = bl_row.iloc[0]
# 检查样本量
if bl.get('sample_count', 0) < 10:
return None
# 计算 Z-Score
alpha_zscore = (alpha - bl['alpha_mean']) / bl['alpha_std']
amt_zscore = (total_amt - bl['amt_mean']) / bl['amt_std']
rank_zscore = (rank_pct - bl['rank_mean']) / bl['rank_std']
# 截断
clip = CONFIG['zscore_clip']
alpha_zscore = np.clip(alpha_zscore, -clip, clip)
amt_zscore = np.clip(amt_zscore, -clip, clip)
rank_zscore = np.clip(rank_zscore, -clip, clip)
# 计算动量(需要历史)
history = self.zscore_history[concept_id]
momentum_3m = 0
momentum_5m = 0
if len(history) >= 3:
recent_alphas = [h[1]['alpha'] for h in list(history)[-3:]]
older_alphas = [h[1]['alpha'] for h in list(history)[-6:-3]] if len(history) >= 6 else [alpha]
momentum_3m = np.mean(recent_alphas) - np.mean(older_alphas)
if len(history) >= 5:
recent_alphas = [h[1]['alpha'] for h in list(history)[-5:]]
older_alphas = [h[1]['alpha'] for h in list(history)[-10:-5]] if len(history) >= 10 else [alpha]
momentum_5m = np.mean(recent_alphas) - np.mean(older_alphas)
return {
'alpha': alpha,
'alpha_zscore': alpha_zscore,
'amt_zscore': amt_zscore,
'rank_zscore': rank_zscore,
'momentum_3m': momentum_3m,
'momentum_5m': momentum_5m,
'limit_up_ratio': limit_up_ratio,
'total_amt': total_amt,
'rank_pct': rank_pct,
}
def update(self, concept_id: str, timestamp, features: Dict):
"""更新概念的历史数据"""
self.zscore_history[concept_id].append((timestamp, features))
def get_sequence(self, concept_id: str) -> Optional[np.ndarray]:
"""获取用于 LSTM 的序列"""
history = self.zscore_history[concept_id]
if len(history) < CONFIG['seq_len']:
return None
# 提取特征
feature_list = []
for _, features in history:
feature_list.append([
features['alpha_zscore'],
features['amt_zscore'],
features['rank_zscore'],
features['momentum_3m'],
features['momentum_5m'],
features['limit_up_ratio'],
])
return np.array(feature_list)
def add_anomaly_candidate(self, concept_id: str, timestamp, score: float):
"""添加异动候选"""
self.anomaly_candidates[concept_id].append((timestamp, score))
def check_sustained_anomaly(self, concept_id: str, threshold: float) -> Tuple[bool, float]:
"""
检查是否为持续性异动
返回:(是否确认, 确认比例)
"""
candidates = self.anomaly_candidates[concept_id]
if len(candidates) < CONFIG['confirm_window']:
return False, 0.0
# 统计超过阈值的时刻数量
exceed_count = sum(1 for _, score in candidates if score >= threshold)
ratio = exceed_count / len(candidates)
return ratio >= CONFIG['confirm_ratio'], ratio
def check_cooldown(self, concept_id: str, timestamp) -> bool:
"""检查是否在冷却期"""
if concept_id not in self.cooldown:
return False
last_alert = self.cooldown[concept_id]
try:
diff = (timestamp - last_alert).total_seconds() / 60
return diff < CONFIG['cooldown_minutes']
except:
return False
def set_cooldown(self, concept_id: str, timestamp):
"""设置冷却期"""
self.cooldown[concept_id] = timestamp
# ==================== 异动检测器 V2 ====================
class AnomalyDetectorV2:
"""
V2 异动检测器
核心流程:
1. 获取实时数据
2. 计算 Z-Score 特征
3. 规则评分 + ML 评分
4. 持续性确认
5. 输出异动
"""
def __init__(
self,
model_dir: str = 'ml/checkpoints_v2',
baseline_dir: str = 'ml/data_v2/baselines'
):
# 加载概念
self.concepts = self._load_concepts()
# 加载基线
self.baselines = load_baselines(baseline_dir)
print(f"加载了 {len(self.baselines)} 个概念的基线")
# 初始化 ML 评分器
self.ml_scorer = MLScorerV2(model_dir)
# 初始化数据管理器
self.data_manager = RealtimeDataManagerV2(self.concepts, self.baselines)
# 收集所有股票
self.all_stocks = list(set(s for c in self.concepts for s in c['stocks']))
def _load_concepts(self) -> List[dict]:
"""从 ES 加载概念"""
concepts = []
query = {"query": {"match_all": {}}, "size": 100, "_source": ["concept_id", "concept", "stocks"]}
resp = ES_CLIENT.search(index=ES_INDEX, body=query, scroll='2m')
scroll_id = resp['_scroll_id']
hits = resp['hits']['hits']
while len(hits) > 0:
for hit in hits:
source = hit['_source']
stocks = []
if 'stocks' in source and isinstance(source['stocks'], list):
for stock in source['stocks']:
if isinstance(stock, dict) and 'code' in stock and stock['code']:
stocks.append(stock['code'])
if stocks:
concepts.append({
'concept_id': source.get('concept_id'),
'concept_name': source.get('concept'),
'stocks': stocks
})
resp = ES_CLIENT.scroll(scroll_id=scroll_id, scroll='2m')
scroll_id = resp['_scroll_id']
hits = resp['hits']['hits']
ES_CLIENT.clear_scroll(scroll_id=scroll_id)
print(f"加载了 {len(concepts)} 个概念")
return concepts
def detect(self, trade_date: str) -> List[Dict]:
"""
检测指定日期的异动
返回异动列表
"""
print(f"\n检测 {trade_date} 的异动...")
# 获取原始数据
raw_features = self._compute_raw_features(trade_date)
if raw_features.empty:
print("无数据")
return []
# 按时间排序
timestamps = sorted(raw_features['timestamp'].unique())
print(f"时间点数: {len(timestamps)}")
all_alerts = []
for ts in timestamps:
ts_data = raw_features[raw_features['timestamp'] == ts]
ts_alerts = self._process_timestamp(ts, ts_data, trade_date)
all_alerts.extend(ts_alerts)
print(f"共检测到 {len(all_alerts)} 个异动")
return all_alerts
def _compute_raw_features(self, trade_date: str) -> pd.DataFrame:
"""计算原始特征(同 prepare_data_v2"""
# 这里简化处理,直接调用数据准备逻辑
from prepare_data_v2 import compute_raw_concept_features
return compute_raw_concept_features(trade_date, self.concepts, self.all_stocks)
def _process_timestamp(self, timestamp, ts_data: pd.DataFrame, trade_date: str) -> List[Dict]:
"""处理单个时间戳"""
alerts = []
candidates = [] # (concept_id, features, rule_score, triggered_rules)
for _, row in ts_data.iterrows():
concept_id = row['concept_id']
# 计算 Z-Score 特征
features = self.data_manager.compute_zscore_features(
concept_id, timestamp,
row['alpha'], row['total_amt'], row['rank_pct'], row['limit_up_ratio']
)
if features is None:
continue
# 更新历史
self.data_manager.update(concept_id, timestamp, features)
# 规则评分
rule_score, triggered_rules = score_rules_zscore(features)
# 收集候选
candidates.append((concept_id, features, rule_score, triggered_rules))
if not candidates:
return []
# 批量 ML 评分
sequences = []
valid_candidates = []
for concept_id, features, rule_score, triggered_rules in candidates:
seq = self.data_manager.get_sequence(concept_id)
if seq is not None:
sequences.append(seq)
valid_candidates.append((concept_id, features, rule_score, triggered_rules))
if not sequences:
return []
sequences = np.array(sequences)
ml_scores = self.ml_scorer.score_batch(sequences)
# 融合评分 + 持续性确认
for i, (concept_id, features, rule_score, triggered_rules) in enumerate(valid_candidates):
ml_score = ml_scores[i]
final_score = CONFIG['rule_weight'] * rule_score + CONFIG['ml_weight'] * ml_score
# 判断是否触发
is_triggered = (
rule_score >= CONFIG['rule_trigger'] or
ml_score >= CONFIG['ml_trigger'] or
final_score >= CONFIG['fusion_trigger']
)
# 添加到候选队列
self.data_manager.add_anomaly_candidate(concept_id, timestamp, final_score)
if not is_triggered:
continue
# 检查冷却期
if self.data_manager.check_cooldown(concept_id, timestamp):
continue
# 持续性确认
is_sustained, confirm_ratio = self.data_manager.check_sustained_anomaly(
concept_id, CONFIG['fusion_trigger']
)
if not is_sustained:
continue
# 确认为异动!
self.data_manager.set_cooldown(concept_id, timestamp)
# 确定异动类型
alpha = features['alpha']
if alpha >= 1.5:
alert_type = 'surge_up'
elif alpha <= -1.5:
alert_type = 'surge_down'
elif features['amt_zscore'] >= 3.0:
alert_type = 'volume_spike'
else:
alert_type = 'surge'
# 确定触发原因
if rule_score >= CONFIG['rule_trigger']:
trigger_reason = f'规则({rule_score:.0f})+持续确认({confirm_ratio:.0%})'
elif ml_score >= CONFIG['ml_trigger']:
trigger_reason = f'ML({ml_score:.0f})+持续确认({confirm_ratio:.0%})'
else:
trigger_reason = f'融合({final_score:.0f})+持续确认({confirm_ratio:.0%})'
alerts.append({
'concept_id': concept_id,
'concept_name': self.data_manager.concepts.get(concept_id, {}).get('concept_name', concept_id),
'alert_time': timestamp,
'trade_date': trade_date,
'alert_type': alert_type,
'final_score': final_score,
'rule_score': rule_score,
'ml_score': ml_score,
'trigger_reason': trigger_reason,
'confirm_ratio': confirm_ratio,
'alpha': alpha,
'alpha_zscore': features['alpha_zscore'],
'amt_zscore': features['amt_zscore'],
'rank_zscore': features['rank_zscore'],
'momentum_3m': features['momentum_3m'],
'momentum_5m': features['momentum_5m'],
'limit_up_ratio': features['limit_up_ratio'],
'triggered_rules': triggered_rules,
})
# 每分钟最多 N 个
if len(alerts) > CONFIG['max_alerts_per_minute']:
alerts = sorted(alerts, key=lambda x: x['final_score'], reverse=True)
alerts = alerts[:CONFIG['max_alerts_per_minute']]
return alerts
# ==================== 主函数 ====================
def main():
import argparse
parser = argparse.ArgumentParser(description='V2 异动检测器')
parser.add_argument('--date', type=str, default=None, help='检测日期(默认今天)')
parser.add_argument('--model_dir', type=str, default='ml/checkpoints_v2')
parser.add_argument('--baseline_dir', type=str, default='ml/data_v2/baselines')
args = parser.parse_args()
trade_date = args.date or datetime.now().strftime('%Y-%m-%d')
detector = AnomalyDetectorV2(
model_dir=args.model_dir,
baseline_dir=args.baseline_dir
)
alerts = detector.detect(trade_date)
print(f"\n检测结果:")
for alert in alerts[:20]:
print(f" [{alert['alert_time'].strftime('%H:%M') if hasattr(alert['alert_time'], 'strftime') else alert['alert_time']}] "
f"{alert['concept_name']} ({alert['alert_type']}) "
f"分数={alert['final_score']:.0f} "
f"确认率={alert['confirm_ratio']:.0%}")
if len(alerts) > 20:
print(f" ... 共 {len(alerts)} 个异动")
if __name__ == "__main__":
main()

526
ml/enhanced_detector.py Normal file
View File

@@ -0,0 +1,526 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
增强版概念异动检测器
融合两种检测方法:
1. Alpha-based Z-Score规则方法实时性好
2. Transformer AutoencoderML方法更准确
使用策略:
- 当 ML 模型可用且历史数据足够时,优先使用 ML 方法
- 否则回退到 Alpha-based 方法
- 可以配置两种方法的融合权重
"""
import os
import sys
import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Tuple, Optional
from dataclasses import dataclass, field
from collections import deque
import numpy as np
# 添加父目录到路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
logger = logging.getLogger(__name__)
# ==================== 配置 ====================
ENHANCED_CONFIG = {
# 融合策略
'fusion_mode': 'adaptive', # 'ml_only', 'alpha_only', 'adaptive', 'ensemble'
# ML 权重(在 ensemble 模式下)
'ml_weight': 0.6,
'alpha_weight': 0.4,
# ML 模型配置
'ml_checkpoint_dir': 'ml/checkpoints',
'ml_threshold_key': 'p95', # p90, p95, p99
# Alpha 配置(与 concept_alert_alpha.py 一致)
'alpha_zscore_threshold': 2.0,
'alpha_absolute_threshold': 1.5,
'alpha_history_window': 60,
'alpha_min_history': 5,
# 共享配置
'cooldown_minutes': 8,
'max_alerts_per_minute': 15,
'min_alpha_abs': 0.5,
}
# 特征配置(与训练一致)
FEATURE_NAMES = [
'alpha',
'alpha_delta',
'amt_ratio',
'amt_delta',
'rank_pct',
'limit_up_ratio',
]
# ==================== 数据结构 ====================
@dataclass
class AlphaStats:
"""概念的Alpha统计信息"""
history: deque = field(default_factory=lambda: deque(maxlen=ENHANCED_CONFIG['alpha_history_window']))
mean: float = 0.0
std: float = 1.0
def update(self, alpha: float):
self.history.append(alpha)
if len(self.history) >= 2:
self.mean = np.mean(self.history)
self.std = max(np.std(self.history), 0.1)
def get_zscore(self, alpha: float) -> float:
if len(self.history) < ENHANCED_CONFIG['alpha_min_history']:
return 0.0
return (alpha - self.mean) / self.std
def is_ready(self) -> bool:
return len(self.history) >= ENHANCED_CONFIG['alpha_min_history']
@dataclass
class ConceptFeatures:
"""概念的实时特征"""
alpha: float = 0.0
alpha_delta: float = 0.0
amt_ratio: float = 1.0
amt_delta: float = 0.0
rank_pct: float = 0.5
limit_up_ratio: float = 0.0
def to_dict(self) -> Dict[str, float]:
return {
'alpha': self.alpha,
'alpha_delta': self.alpha_delta,
'amt_ratio': self.amt_ratio,
'amt_delta': self.amt_delta,
'rank_pct': self.rank_pct,
'limit_up_ratio': self.limit_up_ratio,
}
# ==================== 增强检测器 ====================
class EnhancedAnomalyDetector:
"""
增强版异动检测器
融合 Alpha-based 和 ML 两种方法
"""
def __init__(
self,
config: Dict = None,
ml_enabled: bool = True
):
self.config = config or ENHANCED_CONFIG
self.ml_enabled = ml_enabled
self.ml_detector = None
# Alpha 统计
self.alpha_stats: Dict[str, AlphaStats] = {}
# 特征历史(用于计算 delta
self.feature_history: Dict[str, deque] = {}
# 冷却记录
self.cooldown_cache: Dict[str, datetime] = {}
# 尝试加载 ML 模型
if ml_enabled:
self._load_ml_model()
logger.info(f"EnhancedAnomalyDetector 初始化完成")
logger.info(f" 融合模式: {self.config['fusion_mode']}")
logger.info(f" ML 可用: {self.ml_detector is not None}")
def _load_ml_model(self):
"""加载 ML 模型"""
try:
from inference import ConceptAnomalyDetector
checkpoint_dir = Path(__file__).parent / 'checkpoints'
if (checkpoint_dir / 'best_model.pt').exists():
self.ml_detector = ConceptAnomalyDetector(
checkpoint_dir=str(checkpoint_dir),
threshold_key=self.config['ml_threshold_key']
)
logger.info("ML 模型加载成功")
else:
logger.warning(f"ML 模型不存在: {checkpoint_dir / 'best_model.pt'}")
except Exception as e:
logger.warning(f"ML 模型加载失败: {e}")
self.ml_detector = None
def _get_alpha_stats(self, concept_id: str) -> AlphaStats:
"""获取或创建 Alpha 统计"""
if concept_id not in self.alpha_stats:
self.alpha_stats[concept_id] = AlphaStats()
return self.alpha_stats[concept_id]
def _get_feature_history(self, concept_id: str) -> deque:
"""获取特征历史"""
if concept_id not in self.feature_history:
self.feature_history[concept_id] = deque(maxlen=10)
return self.feature_history[concept_id]
def _check_cooldown(self, concept_id: str, current_time: datetime) -> bool:
"""检查冷却"""
if concept_id not in self.cooldown_cache:
return False
last_alert = self.cooldown_cache[concept_id]
cooldown_td = (current_time - last_alert).total_seconds() / 60
return cooldown_td < self.config['cooldown_minutes']
def _set_cooldown(self, concept_id: str, current_time: datetime):
"""设置冷却"""
self.cooldown_cache[concept_id] = current_time
def compute_features(
self,
concept_id: str,
alpha: float,
amt_ratio: float,
rank_pct: float,
limit_up_ratio: float
) -> ConceptFeatures:
"""
计算概念的完整特征
Args:
concept_id: 概念ID
alpha: 当前超额收益
amt_ratio: 成交额比率
rank_pct: 排名百分位
limit_up_ratio: 涨停股占比
Returns:
完整特征
"""
history = self._get_feature_history(concept_id)
# 计算变化率
alpha_delta = 0.0
amt_delta = 0.0
if len(history) > 0:
last_features = history[-1]
alpha_delta = alpha - last_features.alpha
if last_features.amt_ratio > 0:
amt_delta = (amt_ratio - last_features.amt_ratio) / last_features.amt_ratio
features = ConceptFeatures(
alpha=alpha,
alpha_delta=alpha_delta,
amt_ratio=amt_ratio,
amt_delta=amt_delta,
rank_pct=rank_pct,
limit_up_ratio=limit_up_ratio,
)
# 更新历史
history.append(features)
return features
def detect_alpha_anomaly(
self,
concept_id: str,
alpha: float
) -> Tuple[bool, float, str]:
"""
Alpha-based 异动检测
Returns:
is_anomaly: 是否异动
score: 异动分数Z-Score 绝对值)
reason: 触发原因
"""
stats = self._get_alpha_stats(concept_id)
# 计算 Z-Score在更新前
zscore = stats.get_zscore(alpha)
# 更新统计
stats.update(alpha)
# 判断
if stats.is_ready():
if abs(zscore) >= self.config['alpha_zscore_threshold']:
return True, abs(zscore), f"Z={zscore:.2f}"
else:
if abs(alpha) >= self.config['alpha_absolute_threshold']:
fake_zscore = alpha / 0.5
return True, abs(fake_zscore), f"Alpha={alpha:+.2f}%"
return False, abs(zscore) if zscore else 0.0, ""
def detect_ml_anomaly(
self,
concept_id: str,
features: ConceptFeatures
) -> Tuple[bool, float]:
"""
ML-based 异动检测
Returns:
is_anomaly: 是否异动
score: 异动分数(重构误差)
"""
if self.ml_detector is None:
return False, 0.0
try:
is_anomaly, score = self.ml_detector.detect(
concept_id,
features.to_dict()
)
return is_anomaly, score or 0.0
except Exception as e:
logger.warning(f"ML 检测失败: {e}")
return False, 0.0
def detect(
self,
concept_id: str,
concept_name: str,
alpha: float,
amt_ratio: float,
rank_pct: float,
limit_up_ratio: float,
change_pct: float,
index_change: float,
current_time: datetime,
**extra_data
) -> Optional[Dict]:
"""
融合检测
Args:
concept_id: 概念ID
concept_name: 概念名称
alpha: 超额收益
amt_ratio: 成交额比率
rank_pct: 排名百分位
limit_up_ratio: 涨停股占比
change_pct: 概念涨跌幅
index_change: 大盘涨跌幅
current_time: 当前时间
**extra_data: 其他数据limit_up_count, stock_count 等)
Returns:
异动信息(如果触发),否则 None
"""
# Alpha 太小,不关注
if abs(alpha) < self.config['min_alpha_abs']:
return None
# 检查冷却
if self._check_cooldown(concept_id, current_time):
return None
# 计算特征
features = self.compute_features(
concept_id, alpha, amt_ratio, rank_pct, limit_up_ratio
)
# 执行检测
fusion_mode = self.config['fusion_mode']
alpha_anomaly, alpha_score, alpha_reason = self.detect_alpha_anomaly(concept_id, alpha)
ml_anomaly, ml_score = False, 0.0
if fusion_mode in ('ml_only', 'adaptive', 'ensemble'):
ml_anomaly, ml_score = self.detect_ml_anomaly(concept_id, features)
# 根据融合模式判断
is_anomaly = False
final_score = 0.0
detection_method = ''
if fusion_mode == 'alpha_only':
is_anomaly = alpha_anomaly
final_score = alpha_score
detection_method = 'alpha'
elif fusion_mode == 'ml_only':
is_anomaly = ml_anomaly
final_score = ml_score
detection_method = 'ml'
elif fusion_mode == 'adaptive':
# 优先 ML回退 Alpha
if self.ml_detector and ml_score > 0:
is_anomaly = ml_anomaly
final_score = ml_score
detection_method = 'ml'
else:
is_anomaly = alpha_anomaly
final_score = alpha_score
detection_method = 'alpha'
elif fusion_mode == 'ensemble':
# 加权融合
# 归一化分数
norm_alpha = min(alpha_score / 5.0, 1.0) # Z > 5 视为 1.0
norm_ml = min(ml_score / (self.ml_detector.threshold if self.ml_detector else 1.0), 1.0)
final_score = (
self.config['alpha_weight'] * norm_alpha +
self.config['ml_weight'] * norm_ml
)
is_anomaly = final_score > 0.5 or alpha_anomaly or ml_anomaly
detection_method = 'ensemble'
if not is_anomaly:
return None
# 构建异动记录
self._set_cooldown(concept_id, current_time)
alert_type = 'surge_up' if alpha > 0 else 'surge_down'
alert = {
'concept_id': concept_id,
'concept_name': concept_name,
'alert_type': alert_type,
'alert_time': current_time,
'change_pct': change_pct,
'alpha': alpha,
'alpha_zscore': alpha_score,
'index_change_pct': index_change,
'detection_method': detection_method,
'alpha_score': alpha_score,
'ml_score': ml_score,
'final_score': final_score,
**extra_data
}
return alert
def batch_detect(
self,
concepts_data: List[Dict],
current_time: datetime
) -> List[Dict]:
"""
批量检测
Args:
concepts_data: 概念数据列表
current_time: 当前时间
Returns:
异动列表(按分数排序,限制数量)
"""
alerts = []
for data in concepts_data:
alert = self.detect(
concept_id=data['concept_id'],
concept_name=data['concept_name'],
alpha=data.get('alpha', 0),
amt_ratio=data.get('amt_ratio', 1.0),
rank_pct=data.get('rank_pct', 0.5),
limit_up_ratio=data.get('limit_up_ratio', 0),
change_pct=data.get('change_pct', 0),
index_change=data.get('index_change', 0),
current_time=current_time,
limit_up_count=data.get('limit_up_count', 0),
limit_down_count=data.get('limit_down_count', 0),
stock_count=data.get('stock_count', 0),
concept_type=data.get('concept_type', 'leaf'),
)
if alert:
alerts.append(alert)
# 排序并限制数量
alerts.sort(key=lambda x: x['final_score'], reverse=True)
return alerts[:self.config['max_alerts_per_minute']]
def reset(self):
"""重置所有状态(新交易日)"""
self.alpha_stats.clear()
self.feature_history.clear()
self.cooldown_cache.clear()
if self.ml_detector:
self.ml_detector.clear_history()
logger.info("检测器状态已重置")
# ==================== 测试 ====================
if __name__ == "__main__":
import random
print("测试 EnhancedAnomalyDetector...")
# 初始化
detector = EnhancedAnomalyDetector(ml_enabled=False) # 不加载 ML可能不存在
# 模拟数据
concepts = [
{'concept_id': 'ai_001', 'concept_name': '人工智能'},
{'concept_id': 'chip_002', 'concept_name': '芯片半导体'},
{'concept_id': 'car_003', 'concept_name': '新能源汽车'},
]
print("\n模拟实时检测...")
current_time = datetime.now()
for minute in range(50):
concepts_data = []
for c in concepts:
# 生成随机数据
alpha = random.gauss(0, 0.8)
amt_ratio = max(0.3, random.gauss(1, 0.3))
rank_pct = random.random()
limit_up_ratio = random.random() * 0.1
# 模拟异动第30分钟人工智能暴涨
if minute == 30 and c['concept_id'] == 'ai_001':
alpha = 4.5
amt_ratio = 2.5
limit_up_ratio = 0.3
concepts_data.append({
**c,
'alpha': alpha,
'amt_ratio': amt_ratio,
'rank_pct': rank_pct,
'limit_up_ratio': limit_up_ratio,
'change_pct': alpha + 0.5,
'index_change': 0.5,
})
# 检测
alerts = detector.batch_detect(concepts_data, current_time)
if alerts:
for alert in alerts:
print(f" t={minute:02d} 🔥 {alert['concept_name']} "
f"Alpha={alert['alpha']:+.2f}% "
f"Score={alert['final_score']:.2f} "
f"Method={alert['detection_method']}")
current_time = current_time.replace(minute=current_time.minute + 1 if current_time.minute < 59 else 0)
print("\n测试完成!")

455
ml/inference.py Normal file
View File

@@ -0,0 +1,455 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
概念异动检测推理服务
在实时场景中使用训练好的 Transformer Autoencoder 进行异动检测
使用方法:
from ml.inference import ConceptAnomalyDetector
detector = ConceptAnomalyDetector('ml/checkpoints')
# 检测异动
features = {...} # 实时特征数据
is_anomaly, score = detector.detect(features)
"""
import os
import json
from pathlib import Path
from typing import Dict, List, Tuple, Optional
from collections import deque
import numpy as np
import torch
from model import TransformerAutoencoder
class ConceptAnomalyDetector:
"""
概念异动检测器
使用训练好的 Transformer Autoencoder 进行实时异动检测
"""
def __init__(
self,
checkpoint_dir: str = 'ml/checkpoints',
device: str = 'auto',
threshold_key: str = 'p95'
):
"""
初始化检测器
Args:
checkpoint_dir: 模型检查点目录
device: 设备 (auto/cuda/cpu)
threshold_key: 使用的阈值键 (p90/p95/p99)
"""
self.checkpoint_dir = Path(checkpoint_dir)
self.threshold_key = threshold_key
# 设备选择
if device == 'auto':
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
else:
self.device = torch.device(device)
# 加载配置
self._load_config()
# 加载模型
self._load_model()
# 加载阈值
self._load_thresholds()
# 加载标准化统计量
self._load_normalization_stats()
# 概念历史数据缓存
# {concept_name: deque(maxlen=seq_len)}
self.history_cache: Dict[str, deque] = {}
print(f"ConceptAnomalyDetector 初始化完成")
print(f" 设备: {self.device}")
print(f" 阈值: {self.threshold_key} = {self.threshold:.6f}")
print(f" 序列长度: {self.seq_len}")
def _load_config(self):
"""加载配置"""
config_path = self.checkpoint_dir / 'config.json'
if not config_path.exists():
raise FileNotFoundError(f"配置文件不存在: {config_path}")
with open(config_path, 'r') as f:
self.config = json.load(f)
self.features = self.config['features']
self.seq_len = self.config['seq_len']
self.model_config = self.config['model']
def _load_model(self):
"""加载模型"""
model_path = self.checkpoint_dir / 'best_model.pt'
if not model_path.exists():
raise FileNotFoundError(f"模型文件不存在: {model_path}")
# 创建模型
self.model = TransformerAutoencoder(**self.model_config)
# 加载权重
checkpoint = torch.load(model_path, map_location=self.device)
self.model.load_state_dict(checkpoint['model_state_dict'])
self.model.to(self.device)
self.model.eval()
print(f"模型已加载: {model_path}")
def _load_thresholds(self):
"""加载阈值"""
thresholds_path = self.checkpoint_dir / 'thresholds.json'
if not thresholds_path.exists():
raise FileNotFoundError(f"阈值文件不存在: {thresholds_path}")
with open(thresholds_path, 'r') as f:
self.thresholds = json.load(f)
if self.threshold_key not in self.thresholds:
available_keys = list(self.thresholds.keys())
raise KeyError(f"阈值键 '{self.threshold_key}' 不存在,可用: {available_keys}")
self.threshold = self.thresholds[self.threshold_key]
def _load_normalization_stats(self):
"""加载标准化统计量"""
stats_path = self.checkpoint_dir / 'normalization_stats.json'
if not stats_path.exists():
raise FileNotFoundError(f"标准化统计量文件不存在: {stats_path}")
with open(stats_path, 'r') as f:
stats = json.load(f)
self.norm_mean = np.array(stats['mean'])
self.norm_std = np.array(stats['std'])
def normalize(self, features: np.ndarray) -> np.ndarray:
"""标准化特征"""
return (features - self.norm_mean) / self.norm_std
def update_history(
self,
concept_name: str,
features: Dict[str, float]
):
"""
更新概念历史数据
Args:
concept_name: 概念名称
features: 当前时刻的特征字典
"""
# 初始化历史缓存
if concept_name not in self.history_cache:
self.history_cache[concept_name] = deque(maxlen=self.seq_len)
# 提取特征向量
feature_vector = np.array([
features.get(f, 0.0) for f in self.features
])
# 处理异常值
feature_vector = np.nan_to_num(feature_vector, nan=0.0, posinf=0.0, neginf=0.0)
# 添加到历史
self.history_cache[concept_name].append(feature_vector)
def get_history_length(self, concept_name: str) -> int:
"""获取概念的历史数据长度"""
if concept_name not in self.history_cache:
return 0
return len(self.history_cache[concept_name])
@torch.no_grad()
def detect(
self,
concept_name: str,
features: Dict[str, float] = None,
return_score: bool = True
) -> Tuple[bool, Optional[float]]:
"""
检测概念是否异动
Args:
concept_name: 概念名称
features: 当前时刻的特征(如果提供,会先更新历史)
return_score: 是否返回异动分数
Returns:
is_anomaly: 是否异动
score: 异动分数(如果 return_score=True
"""
# 更新历史
if features is not None:
self.update_history(concept_name, features)
# 检查历史数据是否足够
if concept_name not in self.history_cache:
return False, None
history = self.history_cache[concept_name]
if len(history) < self.seq_len:
return False, None
# 构建输入序列
sequence = np.array(list(history)) # (seq_len, n_features)
# 标准化
sequence = self.normalize(sequence)
# 转为 tensor
x = torch.FloatTensor(sequence).unsqueeze(0) # (1, seq_len, n_features)
x = x.to(self.device)
# 计算重构误差
error = self.model.compute_reconstruction_error(x, reduction='none')
# 取最后一个时刻的误差作为当前分数
score = error[0, -1].item()
# 判断是否异动
is_anomaly = score > self.threshold
if return_score:
return is_anomaly, score
else:
return is_anomaly, None
@torch.no_grad()
def batch_detect(
self,
concept_features: Dict[str, Dict[str, float]]
) -> Dict[str, Tuple[bool, float]]:
"""
批量检测多个概念
Args:
concept_features: {concept_name: {feature_name: value}}
Returns:
results: {concept_name: (is_anomaly, score)}
"""
results = {}
for concept_name, features in concept_features.items():
is_anomaly, score = self.detect(concept_name, features)
results[concept_name] = (is_anomaly, score)
return results
def get_anomaly_type(
self,
concept_name: str,
features: Dict[str, float]
) -> str:
"""
判断异动类型
Args:
concept_name: 概念名称
features: 当前特征
Returns:
anomaly_type: 'surge_up' / 'surge_down' / 'normal'
"""
is_anomaly, score = self.detect(concept_name, features)
if not is_anomaly:
return 'normal'
# 根据 alpha 判断涨跌
alpha = features.get('alpha', 0.0)
if alpha > 0:
return 'surge_up'
else:
return 'surge_down'
def get_top_anomalies(
self,
concept_features: Dict[str, Dict[str, float]],
top_k: int = 10
) -> List[Tuple[str, float, str]]:
"""
获取异动分数最高的 top_k 个概念
Args:
concept_features: {concept_name: {feature_name: value}}
top_k: 返回数量
Returns:
anomalies: [(concept_name, score, anomaly_type), ...]
"""
results = self.batch_detect(concept_features)
# 按分数排序
sorted_results = sorted(
[(name, is_anomaly, score) for name, (is_anomaly, score) in results.items() if score is not None],
key=lambda x: x[2],
reverse=True
)
# 取 top_k
top_anomalies = []
for name, is_anomaly, score in sorted_results[:top_k]:
if is_anomaly:
alpha = concept_features[name].get('alpha', 0.0)
anomaly_type = 'surge_up' if alpha > 0 else 'surge_down'
top_anomalies.append((name, score, anomaly_type))
return top_anomalies
def clear_history(self, concept_name: str = None):
"""
清除历史缓存
Args:
concept_name: 概念名称(如果为 None清除所有
"""
if concept_name is None:
self.history_cache.clear()
elif concept_name in self.history_cache:
del self.history_cache[concept_name]
# ==================== 集成到现有系统 ====================
class MLAnomalyService:
"""
ML 异动检测服务
用于替换或增强现有的 Alpha-based 检测
"""
def __init__(
self,
checkpoint_dir: str = 'ml/checkpoints',
fallback_to_alpha: bool = True
):
"""
Args:
checkpoint_dir: 模型检查点目录
fallback_to_alpha: 当 ML 模型不可用时是否回退到 Alpha 方法
"""
self.fallback_to_alpha = fallback_to_alpha
self.ml_detector = None
try:
self.ml_detector = ConceptAnomalyDetector(checkpoint_dir)
print("ML 异动检测服务初始化成功")
except Exception as e:
print(f"ML 模型加载失败: {e}")
if not fallback_to_alpha:
raise
print("将回退到 Alpha-based 检测")
def is_ml_available(self) -> bool:
"""检查 ML 模型是否可用"""
return self.ml_detector is not None
def detect_anomaly(
self,
concept_name: str,
features: Dict[str, float],
alpha_threshold: float = 2.0
) -> Tuple[bool, float, str]:
"""
检测异动
Args:
concept_name: 概念名称
features: 特征字典(需包含 alpha, amt_ratio 等)
alpha_threshold: Alpha Z-Score 阈值(用于回退)
Returns:
is_anomaly: 是否异动
score: 异动分数
method: 检测方法 ('ml' / 'alpha')
"""
# 优先使用 ML 检测
if self.ml_detector is not None:
history_len = self.ml_detector.get_history_length(concept_name)
# 历史数据足够时使用 ML
if history_len >= self.ml_detector.seq_len - 1:
is_anomaly, score = self.ml_detector.detect(concept_name, features)
if score is not None:
return is_anomaly, score, 'ml'
else:
# 更新历史但使用 Alpha 方法
self.ml_detector.update_history(concept_name, features)
# 回退到 Alpha 方法
if self.fallback_to_alpha:
alpha = features.get('alpha', 0.0)
alpha_zscore = features.get('alpha_zscore', 0.0)
is_anomaly = abs(alpha_zscore) > alpha_threshold
score = abs(alpha_zscore)
return is_anomaly, score, 'alpha'
return False, 0.0, 'none'
# ==================== 测试 ====================
if __name__ == "__main__":
import random
print("测试 ConceptAnomalyDetector...")
# 检查模型是否存在
checkpoint_dir = Path('ml/checkpoints')
if not (checkpoint_dir / 'best_model.pt').exists():
print("模型文件不存在,跳过测试")
print("请先运行 train.py 训练模型")
exit(0)
# 初始化检测器
detector = ConceptAnomalyDetector('ml/checkpoints')
# 模拟数据
print("\n模拟实时检测...")
concept_name = "人工智能"
for i in range(40):
# 生成随机特征
features = {
'alpha': random.gauss(0, 1),
'alpha_delta': random.gauss(0, 0.5),
'amt_ratio': random.gauss(1, 0.3),
'amt_delta': random.gauss(0, 0.2),
'rank_pct': random.random(),
'limit_up_ratio': random.random() * 0.1,
}
# 在第 35 分钟模拟异动
if i == 35:
features['alpha'] = 5.0
features['alpha_delta'] = 2.0
features['amt_ratio'] = 3.0
is_anomaly, score = detector.detect(concept_name, features)
history_len = detector.get_history_length(concept_name)
if score is not None:
status = "🔥 异动!" if is_anomaly else "正常"
print(f" t={i:02d} | 历史={history_len} | 分数={score:.4f} | {status}")
else:
print(f" t={i:02d} | 历史={history_len} | 数据不足")
print("\n测试完成!")

393
ml/model.py Normal file
View File

@@ -0,0 +1,393 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
LSTM Autoencoder 模型定义
用于概念异动检测:
- 学习"正常"市场模式
- 重构误差大的时刻 = 异动
模型结构(简洁有效):
┌─────────────────────────────────────┐
│ 输入: (batch, seq_len, n_features) │
│ 过去30分钟的特征序列 │
├─────────────────────────────────────┤
│ LSTM Encoder │
│ - 双向 LSTM │
│ - 输出最后隐藏状态 │
├─────────────────────────────────────┤
│ Bottleneck (压缩层) │
│ 降维到 latent_dim关键
├─────────────────────────────────────┤
│ LSTM Decoder │
│ - 单向 LSTM │
│ - 重构序列 │
├─────────────────────────────────────┤
│ 输出: (batch, seq_len, n_features) │
│ 重构的特征序列 │
└─────────────────────────────────────┘
为什么用 LSTM 而不是 Transformer:
1. 参数更少,不容易过拟合
2. 对于 6 维特征足够用
3. 训练更稳定
4. 瓶颈约束更容易控制
"""
import torch
import torch.nn as nn
import torch.nn.functional as F
from typing import Optional, Tuple
class LSTMAutoencoder(nn.Module):
"""
LSTM Autoencoder for Anomaly Detection
设计原则:
- 足够简单,避免过拟合
- 瓶颈层严格限制,迫使模型只学习主要模式
- 异常难以通过狭窄瓶颈,重构误差大
"""
def __init__(
self,
n_features: int = 6,
hidden_dim: int = 32, # LSTM 隐藏维度(小!)
latent_dim: int = 4, # 瓶颈维度(非常小!关键参数)
num_layers: int = 1, # LSTM 层数
dropout: float = 0.2,
bidirectional: bool = True, # 双向编码器
):
super().__init__()
self.n_features = n_features
self.hidden_dim = hidden_dim
self.latent_dim = latent_dim
self.num_layers = num_layers
self.bidirectional = bidirectional
self.num_directions = 2 if bidirectional else 1
# Encoder: 双向 LSTM
self.encoder = nn.LSTM(
input_size=n_features,
hidden_size=hidden_dim,
num_layers=num_layers,
batch_first=True,
dropout=dropout if num_layers > 1 else 0,
bidirectional=bidirectional
)
# Bottleneck: 压缩到极小的 latent space
encoder_output_dim = hidden_dim * self.num_directions
self.bottleneck_down = nn.Sequential(
nn.Linear(encoder_output_dim, latent_dim),
nn.Tanh(), # 限制范围,增加约束
)
# 使用 LeakyReLU 替代 ReLU
# 原因Z-Score 数据范围是 [-5, +5]ReLU 会截断负值,丢失跌幅信息
# LeakyReLU 保留负值信号(乘以 0.1
self.bottleneck_up = nn.Sequential(
nn.Linear(latent_dim, hidden_dim),
nn.LeakyReLU(negative_slope=0.1),
)
# Decoder: 单向 LSTM
self.decoder = nn.LSTM(
input_size=hidden_dim,
hidden_size=hidden_dim,
num_layers=num_layers,
batch_first=True,
dropout=dropout if num_layers > 1 else 0,
bidirectional=False # 解码器用单向
)
# 输出层
self.output_layer = nn.Linear(hidden_dim, n_features)
# Dropout
self.dropout = nn.Dropout(dropout)
# 初始化
self._init_weights()
def _init_weights(self):
"""初始化权重"""
for name, param in self.named_parameters():
if 'weight_ih' in name:
nn.init.xavier_uniform_(param)
elif 'weight_hh' in name:
nn.init.orthogonal_(param)
elif 'bias' in name:
nn.init.zeros_(param)
def encode(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
"""
编码器
Args:
x: (batch, seq_len, n_features)
Returns:
latent: (batch, seq_len, latent_dim) 每个时间步的压缩表示
encoder_outputs: (batch, seq_len, hidden_dim * num_directions)
"""
# LSTM 编码
encoder_outputs, (h_n, c_n) = self.encoder(x)
# encoder_outputs: (batch, seq_len, hidden_dim * num_directions)
encoder_outputs = self.dropout(encoder_outputs)
# 压缩到 latent space对每个时间步
latent = self.bottleneck_down(encoder_outputs)
# latent: (batch, seq_len, latent_dim)
return latent, encoder_outputs
def decode(self, latent: torch.Tensor, seq_len: int) -> torch.Tensor:
"""
解码器
Args:
latent: (batch, seq_len, latent_dim)
seq_len: 序列长度
Returns:
output: (batch, seq_len, n_features)
"""
# 从 latent space 恢复
decoder_input = self.bottleneck_up(latent)
# decoder_input: (batch, seq_len, hidden_dim)
# LSTM 解码
decoder_outputs, _ = self.decoder(decoder_input)
# decoder_outputs: (batch, seq_len, hidden_dim)
decoder_outputs = self.dropout(decoder_outputs)
# 投影到原始特征空间
output = self.output_layer(decoder_outputs)
# output: (batch, seq_len, n_features)
return output
def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
"""
前向传播
Args:
x: (batch, seq_len, n_features)
Returns:
output: (batch, seq_len, n_features) 重构结果
latent: (batch, seq_len, latent_dim) 隐向量
"""
batch_size, seq_len, _ = x.shape
# 编码
latent, _ = self.encode(x)
# 解码
output = self.decode(latent, seq_len)
return output, latent
def compute_reconstruction_error(
self,
x: torch.Tensor,
reduction: str = 'none'
) -> torch.Tensor:
"""
计算重构误差
Args:
x: (batch, seq_len, n_features)
reduction: 'none' | 'mean' | 'sum'
Returns:
error: 重构误差
"""
output, _ = self.forward(x)
# MSE per feature per timestep
error = F.mse_loss(output, x, reduction='none')
if reduction == 'none':
# (batch, seq_len, n_features) -> (batch, seq_len)
return error.mean(dim=-1)
elif reduction == 'mean':
return error.mean()
elif reduction == 'sum':
return error.sum()
else:
raise ValueError(f"Unknown reduction: {reduction}")
def detect_anomaly(
self,
x: torch.Tensor,
threshold: float = None,
return_scores: bool = True
) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
"""
检测异动
Args:
x: (batch, seq_len, n_features)
threshold: 异动阈值(如果为 None只返回分数
return_scores: 是否返回异动分数
Returns:
is_anomaly: (batch, seq_len) bool tensor (if threshold is not None)
scores: (batch, seq_len) 异动分数 (if return_scores)
"""
scores = self.compute_reconstruction_error(x, reduction='none')
is_anomaly = None
if threshold is not None:
is_anomaly = scores > threshold
if return_scores:
return is_anomaly, scores
else:
return is_anomaly, None
# 为了兼容性,创建别名
TransformerAutoencoder = LSTMAutoencoder
# ==================== 损失函数 ====================
class AnomalyDetectionLoss(nn.Module):
"""
异动检测损失函数
简单的 MSE 重构损失
"""
def __init__(
self,
feature_weights: torch.Tensor = None,
):
super().__init__()
self.feature_weights = feature_weights
def forward(
self,
output: torch.Tensor,
target: torch.Tensor,
latent: torch.Tensor = None
) -> Tuple[torch.Tensor, dict]:
"""
Args:
output: (batch, seq_len, n_features) 重构结果
target: (batch, seq_len, n_features) 原始输入
latent: (batch, seq_len, latent_dim) 隐向量(未使用)
Returns:
loss: 总损失
loss_dict: 各项损失详情
"""
# 重构损失 (MSE)
mse = F.mse_loss(output, target, reduction='none')
# 特征加权(可选)
if self.feature_weights is not None:
weights = self.feature_weights.to(mse.device)
mse = mse * weights
reconstruction_loss = mse.mean()
loss_dict = {
'total': reconstruction_loss.item(),
'reconstruction': reconstruction_loss.item(),
}
return reconstruction_loss, loss_dict
# ==================== 工具函数 ====================
def count_parameters(model: nn.Module) -> int:
"""统计模型参数量"""
return sum(p.numel() for p in model.parameters() if p.requires_grad)
def create_model(config: dict = None) -> LSTMAutoencoder:
"""
创建模型
默认使用小型 LSTM 配置,适合异动检测
"""
default_config = {
'n_features': 6,
'hidden_dim': 32, # 小!
'latent_dim': 4, # 非常小!关键
'num_layers': 1,
'dropout': 0.2,
'bidirectional': True,
}
if config:
# 兼容旧的 Transformer 配置键名
if 'd_model' in config:
config['hidden_dim'] = config.pop('d_model') // 2
if 'num_encoder_layers' in config:
config['num_layers'] = config.pop('num_encoder_layers')
if 'num_decoder_layers' in config:
config.pop('num_decoder_layers')
if 'nhead' in config:
config.pop('nhead')
if 'dim_feedforward' in config:
config.pop('dim_feedforward')
if 'max_seq_len' in config:
config.pop('max_seq_len')
if 'use_instance_norm' in config:
config.pop('use_instance_norm')
default_config.update(config)
model = LSTMAutoencoder(**default_config)
param_count = count_parameters(model)
print(f"模型参数量: {param_count:,}")
if param_count > 100000:
print(f"⚠️ 警告: 参数量较大({param_count:,}),可能过拟合")
else:
print(f"✓ 参数量适中LSTM Autoencoder")
return model
if __name__ == "__main__":
# 测试模型
print("测试 LSTM Autoencoder...")
# 创建模型
model = create_model()
# 测试输入
batch_size = 32
seq_len = 30
n_features = 6
x = torch.randn(batch_size, seq_len, n_features)
# 前向传播
output, latent = model(x)
print(f"输入形状: {x.shape}")
print(f"输出形状: {output.shape}")
print(f"隐向量形状: {latent.shape}")
# 计算重构误差
error = model.compute_reconstruction_error(x)
print(f"重构误差形状: {error.shape}")
print(f"平均重构误差: {error.mean().item():.4f}")
# 测试异动检测
is_anomaly, scores = model.detect_anomaly(x, threshold=0.5)
print(f"异动检测结果形状: {is_anomaly.shape if is_anomaly is not None else 'None'}")
print(f"异动分数形状: {scores.shape}")
# 测试损失函数
criterion = AnomalyDetectionLoss()
loss, loss_dict = criterion(output, x, latent)
print(f"损失: {loss.item():.4f}")
print("\n测试通过!")

537
ml/prepare_data.py Normal file
View File

@@ -0,0 +1,537 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
数据准备脚本 - 为 Transformer Autoencoder 准备训练数据
从 ClickHouse 提取历史分钟数据,计算以下特征:
1. alpha - 超额收益(概念涨幅 - 大盘涨幅)
2. alpha_delta - Alpha 变化率5分钟
3. amt_ratio - 成交额相对均值(当前/过去20分钟均值
4. amt_delta - 成交额变化率
5. rank_pct - Alpha 排名百分位
6. limit_up_ratio - 涨停股占比
输出按交易日存储的特征文件parquet格式
"""
import os
import sys
import numpy as np
import pandas as pd
from datetime import datetime, timedelta, date
from sqlalchemy import create_engine, text
from elasticsearch import Elasticsearch
from clickhouse_driver import Client
import hashlib
import json
import logging
from typing import Dict, List, Set, Tuple
from concurrent.futures import ProcessPoolExecutor, as_completed
from multiprocessing import Manager
import multiprocessing
import warnings
warnings.filterwarnings('ignore')
# ==================== 配置 ====================
MYSQL_ENGINE = create_engine(
"mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock",
echo=False
)
ES_CLIENT = Elasticsearch(['http://127.0.0.1:9200'])
ES_INDEX = 'concept_library_v3'
CLICKHOUSE_CONFIG = {
'host': '127.0.0.1',
'port': 9000,
'user': 'default',
'password': 'Zzl33818!',
'database': 'stock'
}
# 输出目录
OUTPUT_DIR = os.path.join(os.path.dirname(__file__), 'data')
os.makedirs(OUTPUT_DIR, exist_ok=True)
# 特征计算参数
FEATURE_CONFIG = {
'alpha_delta_window': 5, # Alpha变化窗口分钟
'amt_ma_window': 20, # 成交额均值窗口(分钟)
'limit_up_threshold': 9.8, # 涨停阈值(%
'limit_down_threshold': -9.8, # 跌停阈值(%
}
REFERENCE_INDEX = '000001.SH'
# ==================== 日志 ====================
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# ==================== 工具函数 ====================
def get_ch_client():
return Client(**CLICKHOUSE_CONFIG)
def generate_id(name: str) -> str:
return hashlib.md5(name.encode('utf-8')).hexdigest()[:16]
def code_to_ch_format(code: str) -> str:
if not code or len(code) != 6 or not code.isdigit():
return None
if code.startswith('6'):
return f"{code}.SH"
elif code.startswith('0') or code.startswith('3'):
return f"{code}.SZ"
else:
return f"{code}.BJ"
# ==================== 获取概念列表 ====================
def get_all_concepts() -> List[dict]:
"""从ES获取所有叶子概念"""
concepts = []
query = {
"query": {"match_all": {}},
"size": 100,
"_source": ["concept_id", "concept", "stocks"]
}
resp = ES_CLIENT.search(index=ES_INDEX, body=query, scroll='2m')
scroll_id = resp['_scroll_id']
hits = resp['hits']['hits']
while len(hits) > 0:
for hit in hits:
source = hit['_source']
stocks = []
if 'stocks' in source and isinstance(source['stocks'], list):
for stock in source['stocks']:
if isinstance(stock, dict) and 'code' in stock and stock['code']:
stocks.append(stock['code'])
if stocks:
concepts.append({
'concept_id': source.get('concept_id'),
'concept_name': source.get('concept'),
'stocks': stocks
})
resp = ES_CLIENT.scroll(scroll_id=scroll_id, scroll='2m')
scroll_id = resp['_scroll_id']
hits = resp['hits']['hits']
ES_CLIENT.clear_scroll(scroll_id=scroll_id)
print(f"获取到 {len(concepts)} 个概念")
return concepts
# ==================== 获取交易日列表 ====================
def get_trading_days(start_date: str, end_date: str) -> List[str]:
"""获取交易日列表"""
client = get_ch_client()
query = f"""
SELECT DISTINCT toDate(timestamp) as trade_date
FROM stock_minute
WHERE toDate(timestamp) >= '{start_date}'
AND toDate(timestamp) <= '{end_date}'
ORDER BY trade_date
"""
result = client.execute(query)
days = [row[0].strftime('%Y-%m-%d') for row in result]
print(f"找到 {len(days)} 个交易日: {days[0]} ~ {days[-1]}")
return days
# ==================== 获取单日数据 ====================
def get_daily_stock_data(trade_date: str, stock_codes: List[str]) -> pd.DataFrame:
"""获取单日所有股票的分钟数据"""
client = get_ch_client()
# 转换代码格式
ch_codes = []
code_map = {}
for code in stock_codes:
ch_code = code_to_ch_format(code)
if ch_code:
ch_codes.append(ch_code)
code_map[ch_code] = code
if not ch_codes:
return pd.DataFrame()
ch_codes_str = "','".join(ch_codes)
query = f"""
SELECT
code,
timestamp,
close,
volume,
amt
FROM stock_minute
WHERE toDate(timestamp) = '{trade_date}'
AND code IN ('{ch_codes_str}')
ORDER BY code, timestamp
"""
result = client.execute(query)
if not result:
return pd.DataFrame()
df = pd.DataFrame(result, columns=['ch_code', 'timestamp', 'close', 'volume', 'amt'])
df['code'] = df['ch_code'].map(code_map)
df = df.dropna(subset=['code'])
return df[['code', 'timestamp', 'close', 'volume', 'amt']]
def get_daily_index_data(trade_date: str, index_code: str = REFERENCE_INDEX) -> pd.DataFrame:
"""获取单日指数分钟数据"""
client = get_ch_client()
query = f"""
SELECT
timestamp,
close,
volume,
amt
FROM index_minute
WHERE toDate(timestamp) = '{trade_date}'
AND code = '{index_code}'
ORDER BY timestamp
"""
result = client.execute(query)
if not result:
return pd.DataFrame()
df = pd.DataFrame(result, columns=['timestamp', 'close', 'volume', 'amt'])
return df
def get_prev_close(stock_codes: List[str], trade_date: str) -> Dict[str, float]:
"""获取昨收价(上一交易日的收盘价 F007N"""
valid_codes = [c for c in stock_codes if c and len(c) == 6 and c.isdigit()]
if not valid_codes:
return {}
codes_str = "','".join(valid_codes)
# 注意F007N 是"最近成交价"即当日收盘价F002N 是"昨日收盘价"
# 我们需要查上一交易日的 F007N那天的收盘价作为今天的昨收
query = f"""
SELECT SECCODE, F007N
FROM ea_trade
WHERE SECCODE IN ('{codes_str}')
AND TRADEDATE = (
SELECT MAX(TRADEDATE) FROM ea_trade WHERE TRADEDATE < '{trade_date}'
)
AND F007N IS NOT NULL AND F007N > 0
"""
try:
with MYSQL_ENGINE.connect() as conn:
result = conn.execute(text(query))
return {row[0]: float(row[1]) for row in result if row[1]}
except Exception as e:
print(f"获取昨收价失败: {e}")
return {}
def get_index_prev_close(trade_date: str, index_code: str = REFERENCE_INDEX) -> float:
"""获取指数昨收价"""
code_no_suffix = index_code.split('.')[0]
try:
with MYSQL_ENGINE.connect() as conn:
result = conn.execute(text("""
SELECT F006N FROM ea_exchangetrade
WHERE INDEXCODE = :code AND TRADEDATE < :today
ORDER BY TRADEDATE DESC LIMIT 1
"""), {'code': code_no_suffix, 'today': trade_date}).fetchone()
if result and result[0]:
return float(result[0])
except Exception as e:
print(f"获取指数昨收失败: {e}")
return None
# ==================== 计算特征 ====================
def compute_daily_features(
trade_date: str,
concepts: List[dict],
all_stocks: List[str]
) -> pd.DataFrame:
"""
计算单日所有概念的特征
返回 DataFrame:
- index: (timestamp, concept_id)
- columns: alpha, alpha_delta, amt_ratio, amt_delta, rank_pct, limit_up_ratio
"""
# 1. 获取数据
stock_df = get_daily_stock_data(trade_date, all_stocks)
if stock_df.empty:
return pd.DataFrame()
index_df = get_daily_index_data(trade_date)
if index_df.empty:
return pd.DataFrame()
# 2. 获取昨收价
prev_close = get_prev_close(all_stocks, trade_date)
index_prev_close = get_index_prev_close(trade_date)
if not prev_close or not index_prev_close:
return pd.DataFrame()
# 3. 计算股票涨跌幅和成交额
stock_df['prev_close'] = stock_df['code'].map(prev_close)
stock_df = stock_df.dropna(subset=['prev_close'])
stock_df['change_pct'] = (stock_df['close'] - stock_df['prev_close']) / stock_df['prev_close'] * 100
# 4. 计算指数涨跌幅
index_df['change_pct'] = (index_df['close'] - index_prev_close) / index_prev_close * 100
index_change_map = dict(zip(index_df['timestamp'], index_df['change_pct']))
# 5. 获取所有时间点
timestamps = sorted(stock_df['timestamp'].unique())
# 6. 按时间点计算概念特征
results = []
# 概念到股票的映射
concept_stocks = {c['concept_id']: set(c['stocks']) for c in concepts}
concept_names = {c['concept_id']: c['concept_name'] for c in concepts}
# 历史数据缓存(用于计算变化率)
concept_history = {cid: {'alpha': [], 'amt': []} for cid in concept_stocks}
for ts in timestamps:
ts_stock_data = stock_df[stock_df['timestamp'] == ts]
index_change = index_change_map.get(ts, 0)
# 股票涨跌幅和成交额字典
stock_change = dict(zip(ts_stock_data['code'], ts_stock_data['change_pct']))
stock_amt = dict(zip(ts_stock_data['code'], ts_stock_data['amt']))
concept_features = []
for concept_id, stocks in concept_stocks.items():
# 该概念的股票数据
concept_changes = [stock_change[s] for s in stocks if s in stock_change]
concept_amts = [stock_amt.get(s, 0) for s in stocks if s in stock_change]
if not concept_changes:
continue
# 基础统计
avg_change = np.mean(concept_changes)
total_amt = sum(concept_amts)
# Alpha = 概念涨幅 - 指数涨幅
alpha = avg_change - index_change
# 涨停/跌停股占比
limit_up_count = sum(1 for c in concept_changes if c >= FEATURE_CONFIG['limit_up_threshold'])
limit_down_count = sum(1 for c in concept_changes if c <= FEATURE_CONFIG['limit_down_threshold'])
limit_up_ratio = limit_up_count / len(concept_changes)
limit_down_ratio = limit_down_count / len(concept_changes)
# 更新历史
history = concept_history[concept_id]
history['alpha'].append(alpha)
history['amt'].append(total_amt)
# 计算变化率
alpha_delta = 0
if len(history['alpha']) > FEATURE_CONFIG['alpha_delta_window']:
alpha_delta = alpha - history['alpha'][-FEATURE_CONFIG['alpha_delta_window']-1]
# 成交额相对均值
amt_ratio = 1.0
amt_delta = 0
if len(history['amt']) > FEATURE_CONFIG['amt_ma_window']:
amt_ma = np.mean(history['amt'][-FEATURE_CONFIG['amt_ma_window']-1:-1])
if amt_ma > 0:
amt_ratio = total_amt / amt_ma
amt_delta = total_amt - history['amt'][-2] if len(history['amt']) > 1 else 0
concept_features.append({
'concept_id': concept_id,
'alpha': alpha,
'alpha_delta': alpha_delta,
'amt_ratio': amt_ratio,
'amt_delta': amt_delta,
'limit_up_ratio': limit_up_ratio,
'limit_down_ratio': limit_down_ratio,
'total_amt': total_amt,
'stock_count': len(concept_changes),
})
if not concept_features:
continue
# 计算排名百分位
concept_df = pd.DataFrame(concept_features)
concept_df['rank_pct'] = concept_df['alpha'].rank(pct=True)
# 添加时间戳
concept_df['timestamp'] = ts
results.append(concept_df)
if not results:
return pd.DataFrame()
# 合并所有时间点
final_df = pd.concat(results, ignore_index=True)
# 标准化成交额变化率
if 'amt_delta' in final_df.columns:
amt_delta_std = final_df['amt_delta'].std()
if amt_delta_std > 0:
final_df['amt_delta'] = final_df['amt_delta'] / amt_delta_std
return final_df
# ==================== 主流程 ====================
def process_single_day(args) -> Tuple[str, bool]:
"""
处理单个交易日(多进程版本)
Args:
args: (trade_date, concepts, all_stocks) 元组
Returns:
(trade_date, success) 元组
"""
trade_date, concepts, all_stocks = args
output_file = os.path.join(OUTPUT_DIR, f'features_{trade_date}.parquet')
# 检查是否已处理
if os.path.exists(output_file):
print(f"[{trade_date}] 已存在,跳过")
return (trade_date, True)
print(f"[{trade_date}] 开始处理...")
try:
df = compute_daily_features(trade_date, concepts, all_stocks)
if df.empty:
print(f"[{trade_date}] 无数据")
return (trade_date, False)
# 保存
df.to_parquet(output_file, index=False)
print(f"[{trade_date}] 保存完成")
return (trade_date, True)
except Exception as e:
print(f"[{trade_date}] 处理失败: {e}")
import traceback
traceback.print_exc()
return (trade_date, False)
def main():
import argparse
from tqdm import tqdm
parser = argparse.ArgumentParser(description='准备训练数据')
parser.add_argument('--start', type=str, default='2022-01-01', help='开始日期')
parser.add_argument('--end', type=str, default=None, help='结束日期(默认今天)')
parser.add_argument('--workers', type=int, default=18, help='并行进程数默认18')
parser.add_argument('--force', action='store_true', help='强制重新处理已存在的文件')
args = parser.parse_args()
end_date = args.end or datetime.now().strftime('%Y-%m-%d')
print("=" * 60)
print("数据准备 - Transformer Autoencoder 训练数据")
print("=" * 60)
print(f"日期范围: {args.start} ~ {end_date}")
print(f"并行进程数: {args.workers}")
# 1. 获取概念列表
concepts = get_all_concepts()
# 收集所有股票
all_stocks = list(set(s for c in concepts for s in c['stocks']))
print(f"股票总数: {len(all_stocks)}")
# 2. 获取交易日列表
trading_days = get_trading_days(args.start, end_date)
if not trading_days:
print("无交易日数据")
return
# 如果强制模式,删除已有文件
if args.force:
for trade_date in trading_days:
output_file = os.path.join(OUTPUT_DIR, f'features_{trade_date}.parquet')
if os.path.exists(output_file):
os.remove(output_file)
print(f"删除已有文件: {output_file}")
# 3. 准备任务参数
tasks = [(trade_date, concepts, all_stocks) for trade_date in trading_days]
print(f"\n开始处理 {len(trading_days)} 个交易日({args.workers} 进程并行)...")
# 4. 多进程处理
success_count = 0
failed_dates = []
with ProcessPoolExecutor(max_workers=args.workers) as executor:
# 提交所有任务
futures = {executor.submit(process_single_day, task): task[0] for task in tasks}
# 使用 tqdm 显示进度
with tqdm(total=len(futures), desc="处理进度", unit="") as pbar:
for future in as_completed(futures):
trade_date = futures[future]
try:
result_date, success = future.result()
if success:
success_count += 1
else:
failed_dates.append(result_date)
except Exception as e:
print(f"\n[{trade_date}] 进程异常: {e}")
failed_dates.append(trade_date)
pbar.update(1)
print("\n" + "=" * 60)
print(f"处理完成: {success_count}/{len(trading_days)} 个交易日")
if failed_dates:
print(f"失败日期: {failed_dates[:10]}{'...' if len(failed_dates) > 10 else ''}")
print(f"数据保存在: {OUTPUT_DIR}")
print("=" * 60)
if __name__ == "__main__":
main()

715
ml/prepare_data_v2.py Normal file
View File

@@ -0,0 +1,715 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
数据准备 V2 - 基于时间片对齐的特征计算(修复版)
核心改进:
1. 时间片对齐9:35 和历史的 9:35 比而不是和前30分钟比
2. Z-Score 特征:相对于同时间片历史分布的偏离程度
3. 滚动窗口基线:每个日期使用它之前 N 天的数据作为基线(不是固定的最后 N 天!)
4. 基于 Z-Score 的动量:消除一天内波动率异构性
修复:
- 滚动窗口基线:避免未来数据泄露
- Z-Score 动量:消除早盘/尾盘波动率差异
- 进程级数据库单例:避免连接池爆炸
"""
import os
import sys
import numpy as np
import pandas as pd
from datetime import datetime, timedelta
from sqlalchemy import create_engine, text
from elasticsearch import Elasticsearch
from clickhouse_driver import Client
from concurrent.futures import ProcessPoolExecutor, as_completed
from typing import Dict, List, Tuple, Optional
from tqdm import tqdm
from collections import defaultdict
import warnings
import pickle
warnings.filterwarnings('ignore')
# ==================== 配置 ====================
MYSQL_URL = "mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock"
ES_HOST = 'http://127.0.0.1:9200'
ES_INDEX = 'concept_library_v3'
CLICKHOUSE_CONFIG = {
'host': '127.0.0.1',
'port': 9000,
'user': 'default',
'password': 'Zzl33818!',
'database': 'stock'
}
REFERENCE_INDEX = '000001.SH'
# 输出目录
OUTPUT_DIR = os.path.join(os.path.dirname(__file__), 'data_v2')
BASELINE_DIR = os.path.join(OUTPUT_DIR, 'baselines')
RAW_CACHE_DIR = os.path.join(OUTPUT_DIR, 'raw_cache')
os.makedirs(OUTPUT_DIR, exist_ok=True)
os.makedirs(BASELINE_DIR, exist_ok=True)
os.makedirs(RAW_CACHE_DIR, exist_ok=True)
# 特征配置
CONFIG = {
'baseline_days': 20, # 滚动窗口大小
'min_baseline_samples': 10, # 最少需要10个样本才算有效基线
'limit_up_threshold': 9.8,
'limit_down_threshold': -9.8,
'zscore_clip': 5.0,
}
# 特征列表
FEATURES_V2 = [
'alpha', 'alpha_zscore', 'amt_zscore', 'rank_zscore',
'momentum_3m', 'momentum_5m', 'limit_up_ratio',
]
# ==================== 进程级单例(避免连接池爆炸)====================
# 进程级全局变量
_process_mysql_engine = None
_process_es_client = None
_process_ch_client = None
def init_process_connections():
"""进程初始化时调用,创建连接(单例)"""
global _process_mysql_engine, _process_es_client, _process_ch_client
_process_mysql_engine = create_engine(MYSQL_URL, echo=False, pool_pre_ping=True, pool_size=5)
_process_es_client = Elasticsearch([ES_HOST])
_process_ch_client = Client(**CLICKHOUSE_CONFIG)
def get_mysql_engine():
"""获取进程级 MySQL Engine单例"""
global _process_mysql_engine
if _process_mysql_engine is None:
_process_mysql_engine = create_engine(MYSQL_URL, echo=False, pool_pre_ping=True, pool_size=5)
return _process_mysql_engine
def get_es_client():
"""获取进程级 ES 客户端(单例)"""
global _process_es_client
if _process_es_client is None:
_process_es_client = Elasticsearch([ES_HOST])
return _process_es_client
def get_ch_client():
"""获取进程级 ClickHouse 客户端(单例)"""
global _process_ch_client
if _process_ch_client is None:
_process_ch_client = Client(**CLICKHOUSE_CONFIG)
return _process_ch_client
# ==================== 工具函数 ====================
def code_to_ch_format(code: str) -> str:
if not code or len(code) != 6 or not code.isdigit():
return None
if code.startswith('6'):
return f"{code}.SH"
elif code.startswith('0') or code.startswith('3'):
return f"{code}.SZ"
else:
return f"{code}.BJ"
def time_to_slot(ts) -> str:
"""将时间戳转换为时间片HH:MM格式"""
if isinstance(ts, str):
return ts
return ts.strftime('%H:%M')
# ==================== 获取概念列表 ====================
def get_all_concepts() -> List[dict]:
"""从ES获取所有叶子概念"""
es_client = get_es_client()
concepts = []
query = {
"query": {"match_all": {}},
"size": 100,
"_source": ["concept_id", "concept", "stocks"]
}
resp = es_client.search(index=ES_INDEX, body=query, scroll='2m')
scroll_id = resp['_scroll_id']
hits = resp['hits']['hits']
while len(hits) > 0:
for hit in hits:
source = hit['_source']
stocks = []
if 'stocks' in source and isinstance(source['stocks'], list):
for stock in source['stocks']:
if isinstance(stock, dict) and 'code' in stock and stock['code']:
stocks.append(stock['code'])
if stocks:
concepts.append({
'concept_id': source.get('concept_id'),
'concept_name': source.get('concept'),
'stocks': stocks
})
resp = es_client.scroll(scroll_id=scroll_id, scroll='2m')
scroll_id = resp['_scroll_id']
hits = resp['hits']['hits']
es_client.clear_scroll(scroll_id=scroll_id)
print(f"获取到 {len(concepts)} 个概念")
return concepts
# ==================== 获取交易日列表 ====================
def get_trading_days(start_date: str, end_date: str) -> List[str]:
"""获取交易日列表"""
client = get_ch_client()
query = f"""
SELECT DISTINCT toDate(timestamp) as trade_date
FROM stock_minute
WHERE toDate(timestamp) >= '{start_date}'
AND toDate(timestamp) <= '{end_date}'
ORDER BY trade_date
"""
result = client.execute(query)
days = [row[0].strftime('%Y-%m-%d') for row in result]
if days:
print(f"找到 {len(days)} 个交易日: {days[0]} ~ {days[-1]}")
return days
# ==================== 获取昨收价 ====================
def get_prev_close(stock_codes: List[str], trade_date: str) -> Dict[str, float]:
"""获取昨收价(上一交易日的收盘价 F007N"""
valid_codes = [c for c in stock_codes if c and len(c) == 6 and c.isdigit()]
if not valid_codes:
return {}
codes_str = "','".join(valid_codes)
query = f"""
SELECT SECCODE, F007N
FROM ea_trade
WHERE SECCODE IN ('{codes_str}')
AND TRADEDATE = (
SELECT MAX(TRADEDATE) FROM ea_trade WHERE TRADEDATE < '{trade_date}'
)
AND F007N IS NOT NULL AND F007N > 0
"""
try:
engine = get_mysql_engine()
with engine.connect() as conn:
result = conn.execute(text(query))
return {row[0]: float(row[1]) for row in result if row[1]}
except Exception as e:
print(f"获取昨收价失败: {e}")
return {}
def get_index_prev_close(trade_date: str, index_code: str = REFERENCE_INDEX) -> float:
"""获取指数昨收价"""
code_no_suffix = index_code.split('.')[0]
try:
engine = get_mysql_engine()
with engine.connect() as conn:
result = conn.execute(text("""
SELECT F006N FROM ea_exchangetrade
WHERE INDEXCODE = :code AND TRADEDATE < :today
ORDER BY TRADEDATE DESC LIMIT 1
"""), {'code': code_no_suffix, 'today': trade_date}).fetchone()
if result and result[0]:
return float(result[0])
except Exception as e:
print(f"获取指数昨收失败: {e}")
return None
# ==================== 获取分钟数据 ====================
def get_daily_stock_data(trade_date: str, stock_codes: List[str]) -> pd.DataFrame:
"""获取单日所有股票的分钟数据"""
client = get_ch_client()
ch_codes = []
code_map = {}
for code in stock_codes:
ch_code = code_to_ch_format(code)
if ch_code:
ch_codes.append(ch_code)
code_map[ch_code] = code
if not ch_codes:
return pd.DataFrame()
ch_codes_str = "','".join(ch_codes)
query = f"""
SELECT code, timestamp, close, volume, amt
FROM stock_minute
WHERE toDate(timestamp) = '{trade_date}'
AND code IN ('{ch_codes_str}')
ORDER BY code, timestamp
"""
result = client.execute(query)
if not result:
return pd.DataFrame()
df = pd.DataFrame(result, columns=['ch_code', 'timestamp', 'close', 'volume', 'amt'])
df['code'] = df['ch_code'].map(code_map)
df = df.dropna(subset=['code'])
return df[['code', 'timestamp', 'close', 'volume', 'amt']]
def get_daily_index_data(trade_date: str, index_code: str = REFERENCE_INDEX) -> pd.DataFrame:
"""获取单日指数分钟数据"""
client = get_ch_client()
query = f"""
SELECT timestamp, close, volume, amt
FROM index_minute
WHERE toDate(timestamp) = '{trade_date}'
AND code = '{index_code}'
ORDER BY timestamp
"""
result = client.execute(query)
if not result:
return pd.DataFrame()
df = pd.DataFrame(result, columns=['timestamp', 'close', 'volume', 'amt'])
return df
# ==================== 计算原始概念特征(单日)====================
def compute_raw_concept_features(
trade_date: str,
concepts: List[dict],
all_stocks: List[str]
) -> pd.DataFrame:
"""计算单日概念的原始特征alpha, amt, rank_pct, limit_up_ratio"""
# 检查缓存
cache_file = os.path.join(RAW_CACHE_DIR, f'raw_{trade_date}.parquet')
if os.path.exists(cache_file):
return pd.read_parquet(cache_file)
# 获取数据
stock_df = get_daily_stock_data(trade_date, all_stocks)
if stock_df.empty:
return pd.DataFrame()
index_df = get_daily_index_data(trade_date)
if index_df.empty:
return pd.DataFrame()
# 获取昨收价
prev_close = get_prev_close(all_stocks, trade_date)
index_prev_close = get_index_prev_close(trade_date)
if not prev_close or not index_prev_close:
return pd.DataFrame()
# 计算涨跌幅
stock_df['prev_close'] = stock_df['code'].map(prev_close)
stock_df = stock_df.dropna(subset=['prev_close'])
stock_df['change_pct'] = (stock_df['close'] - stock_df['prev_close']) / stock_df['prev_close'] * 100
index_df['change_pct'] = (index_df['close'] - index_prev_close) / index_prev_close * 100
index_change_map = dict(zip(index_df['timestamp'], index_df['change_pct']))
# 获取所有时间点
timestamps = sorted(stock_df['timestamp'].unique())
# 概念到股票的映射
concept_stocks = {c['concept_id']: set(c['stocks']) for c in concepts}
results = []
for ts in timestamps:
ts_stock_data = stock_df[stock_df['timestamp'] == ts]
index_change = index_change_map.get(ts, 0)
stock_change = dict(zip(ts_stock_data['code'], ts_stock_data['change_pct']))
stock_amt = dict(zip(ts_stock_data['code'], ts_stock_data['amt']))
concept_features = []
for concept_id, stocks in concept_stocks.items():
concept_changes = [stock_change[s] for s in stocks if s in stock_change]
concept_amts = [stock_amt.get(s, 0) for s in stocks if s in stock_change]
if not concept_changes:
continue
avg_change = np.mean(concept_changes)
total_amt = sum(concept_amts)
alpha = avg_change - index_change
limit_up_count = sum(1 for c in concept_changes if c >= CONFIG['limit_up_threshold'])
limit_up_ratio = limit_up_count / len(concept_changes)
concept_features.append({
'concept_id': concept_id,
'alpha': alpha,
'total_amt': total_amt,
'limit_up_ratio': limit_up_ratio,
'stock_count': len(concept_changes),
})
if not concept_features:
continue
concept_df = pd.DataFrame(concept_features)
concept_df['rank_pct'] = concept_df['alpha'].rank(pct=True)
concept_df['timestamp'] = ts
concept_df['time_slot'] = time_to_slot(ts)
concept_df['trade_date'] = trade_date
results.append(concept_df)
if not results:
return pd.DataFrame()
result_df = pd.concat(results, ignore_index=True)
# 保存缓存
result_df.to_parquet(cache_file, index=False)
return result_df
# ==================== 滚动窗口基线计算 ====================
def compute_rolling_baseline(
historical_data: pd.DataFrame,
concept_id: str
) -> Dict[str, Dict]:
"""
计算单个概念的滚动基线
返回: {time_slot: {alpha_mean, alpha_std, amt_mean, amt_std, rank_mean, rank_std, sample_count}}
"""
if historical_data.empty:
return {}
concept_data = historical_data[historical_data['concept_id'] == concept_id]
if concept_data.empty:
return {}
baseline_dict = {}
for time_slot, group in concept_data.groupby('time_slot'):
if len(group) < CONFIG['min_baseline_samples']:
continue
alpha_std = group['alpha'].std()
amt_std = group['total_amt'].std()
rank_std = group['rank_pct'].std()
baseline_dict[time_slot] = {
'alpha_mean': group['alpha'].mean(),
'alpha_std': max(alpha_std if pd.notna(alpha_std) else 1.0, 0.1),
'amt_mean': group['total_amt'].mean(),
'amt_std': max(amt_std if pd.notna(amt_std) else group['total_amt'].mean() * 0.5, 1.0),
'rank_mean': group['rank_pct'].mean(),
'rank_std': max(rank_std if pd.notna(rank_std) else 0.2, 0.05),
'sample_count': len(group),
}
return baseline_dict
# ==================== 计算单日 Z-Score 特征(带滚动基线)====================
def compute_zscore_features_rolling(
trade_date: str,
concepts: List[dict],
all_stocks: List[str],
historical_raw_data: pd.DataFrame # 该日期之前 N 天的原始数据
) -> pd.DataFrame:
"""
计算单日的 Z-Score 特征(使用滚动窗口基线)
关键改进:
1. 基线只使用 trade_date 之前的数据(无未来泄露)
2. 动量基于 Z-Score 计算(消除波动率异构性)
"""
# 计算当日原始特征
raw_df = compute_raw_concept_features(trade_date, concepts, all_stocks)
if raw_df.empty:
return pd.DataFrame()
zscore_records = []
for concept_id, group in raw_df.groupby('concept_id'):
# 计算该概念的滚动基线(只用历史数据)
baseline_dict = compute_rolling_baseline(historical_raw_data, concept_id)
if not baseline_dict:
continue
# 按时间排序
group = group.sort_values('timestamp').reset_index(drop=True)
# Z-Score 历史(用于计算基于 Z-Score 的动量)
zscore_history = []
for idx, row in group.iterrows():
time_slot = row['time_slot']
if time_slot not in baseline_dict:
continue
bl = baseline_dict[time_slot]
# 计算 Z-Score
alpha_zscore = (row['alpha'] - bl['alpha_mean']) / bl['alpha_std']
amt_zscore = (row['total_amt'] - bl['amt_mean']) / bl['amt_std']
rank_zscore = (row['rank_pct'] - bl['rank_mean']) / bl['rank_std']
# 截断极端值
clip = CONFIG['zscore_clip']
alpha_zscore = np.clip(alpha_zscore, -clip, clip)
amt_zscore = np.clip(amt_zscore, -clip, clip)
rank_zscore = np.clip(rank_zscore, -clip, clip)
# 记录 Z-Score 历史
zscore_history.append(alpha_zscore)
# 基于 Z-Score 计算动量(消除波动率异构性)
momentum_3m = 0.0
momentum_5m = 0.0
if len(zscore_history) >= 3:
recent_3 = zscore_history[-3:]
older_3 = zscore_history[-6:-3] if len(zscore_history) >= 6 else [zscore_history[0]]
momentum_3m = np.mean(recent_3) - np.mean(older_3)
if len(zscore_history) >= 5:
recent_5 = zscore_history[-5:]
older_5 = zscore_history[-10:-5] if len(zscore_history) >= 10 else [zscore_history[0]]
momentum_5m = np.mean(recent_5) - np.mean(older_5)
zscore_records.append({
'concept_id': concept_id,
'timestamp': row['timestamp'],
'time_slot': time_slot,
'trade_date': trade_date,
# 原始特征
'alpha': row['alpha'],
'total_amt': row['total_amt'],
'limit_up_ratio': row['limit_up_ratio'],
'stock_count': row['stock_count'],
'rank_pct': row['rank_pct'],
# Z-Score 特征
'alpha_zscore': alpha_zscore,
'amt_zscore': amt_zscore,
'rank_zscore': rank_zscore,
# 基于 Z-Score 的动量
'momentum_3m': momentum_3m,
'momentum_5m': momentum_5m,
})
if not zscore_records:
return pd.DataFrame()
return pd.DataFrame(zscore_records)
# ==================== 多进程处理 ====================
def process_single_day_v2(args) -> Tuple[str, bool]:
"""处理单个交易日(多进程版本)"""
trade_date, day_index, concepts, all_stocks, all_trading_days = args
output_file = os.path.join(OUTPUT_DIR, f'features_v2_{trade_date}.parquet')
if os.path.exists(output_file):
return (trade_date, True)
try:
# 计算滚动窗口范围(该日期之前的 N 天)
baseline_days = CONFIG['baseline_days']
# 找出 trade_date 之前的交易日
start_idx = max(0, day_index - baseline_days)
end_idx = day_index # 不包含当天
if end_idx <= start_idx:
# 没有足够的历史数据
return (trade_date, False)
historical_days = all_trading_days[start_idx:end_idx]
# 加载历史原始数据
historical_dfs = []
for hist_date in historical_days:
cache_file = os.path.join(RAW_CACHE_DIR, f'raw_{hist_date}.parquet')
if os.path.exists(cache_file):
historical_dfs.append(pd.read_parquet(cache_file))
else:
# 需要计算
hist_df = compute_raw_concept_features(hist_date, concepts, all_stocks)
if not hist_df.empty:
historical_dfs.append(hist_df)
if not historical_dfs:
return (trade_date, False)
historical_raw_data = pd.concat(historical_dfs, ignore_index=True)
# 计算当日 Z-Score 特征(使用滚动基线)
df = compute_zscore_features_rolling(trade_date, concepts, all_stocks, historical_raw_data)
if df.empty:
return (trade_date, False)
df.to_parquet(output_file, index=False)
return (trade_date, True)
except Exception as e:
print(f"[{trade_date}] 处理失败: {e}")
import traceback
traceback.print_exc()
return (trade_date, False)
# ==================== 主流程 ====================
def main():
import argparse
parser = argparse.ArgumentParser(description='准备训练数据 V2滚动窗口基线 + Z-Score 动量)')
parser.add_argument('--start', type=str, default='2022-01-01', help='开始日期')
parser.add_argument('--end', type=str, default=None, help='结束日期(默认今天)')
parser.add_argument('--workers', type=int, default=18, help='并行进程数')
parser.add_argument('--baseline-days', type=int, default=20, help='滚动基线窗口大小')
parser.add_argument('--force', action='store_true', help='强制重新计算(忽略缓存)')
args = parser.parse_args()
end_date = args.end or datetime.now().strftime('%Y-%m-%d')
CONFIG['baseline_days'] = args.baseline_days
print("=" * 60)
print("数据准备 V2 - 滚动窗口基线 + Z-Score 动量")
print("=" * 60)
print(f"日期范围: {args.start} ~ {end_date}")
print(f"并行进程数: {args.workers}")
print(f"滚动基线窗口: {args.baseline_days}")
# 初始化主进程连接
init_process_connections()
# 1. 获取概念列表
concepts = get_all_concepts()
all_stocks = list(set(s for c in concepts for s in c['stocks']))
print(f"股票总数: {len(all_stocks)}")
# 2. 获取交易日列表
trading_days = get_trading_days(args.start, end_date)
if not trading_days:
print("无交易日数据")
return
# 3. 第一阶段:预计算所有原始特征(用于缓存)
print(f"\n{'='*60}")
print("第一阶段:预计算原始特征(用于滚动基线)")
print(f"{'='*60}")
# 如果强制重新计算,删除缓存
if args.force:
import shutil
if os.path.exists(RAW_CACHE_DIR):
shutil.rmtree(RAW_CACHE_DIR)
os.makedirs(RAW_CACHE_DIR, exist_ok=True)
if os.path.exists(OUTPUT_DIR):
for f in os.listdir(OUTPUT_DIR):
if f.startswith('features_v2_'):
os.remove(os.path.join(OUTPUT_DIR, f))
# 单线程预计算原始特征(因为需要顺序缓存)
print(f"预计算 {len(trading_days)} 天的原始特征...")
for trade_date in tqdm(trading_days, desc="预计算原始特征"):
cache_file = os.path.join(RAW_CACHE_DIR, f'raw_{trade_date}.parquet')
if not os.path.exists(cache_file):
compute_raw_concept_features(trade_date, concepts, all_stocks)
# 4. 第二阶段:计算 Z-Score 特征(多进程)
print(f"\n{'='*60}")
print("第二阶段:计算 Z-Score 特征(滚动基线)")
print(f"{'='*60}")
# 从第 baseline_days 天开始(前面的没有足够历史)
start_idx = args.baseline_days
processable_days = trading_days[start_idx:]
if not processable_days:
print(f"错误:需要至少 {args.baseline_days + 1} 天的数据")
return
print(f"可处理日期: {processable_days[0]} ~ {processable_days[-1]} ({len(processable_days)} 天)")
print(f"跳过前 {start_idx} 天(基线预热期)")
# 构建任务
tasks = []
for i, trade_date in enumerate(trading_days):
if i >= start_idx:
tasks.append((trade_date, i, concepts, all_stocks, trading_days))
print(f"开始处理 {len(tasks)} 个交易日({args.workers} 进程并行)...")
success_count = 0
failed_dates = []
# 使用进程池初始化器
with ProcessPoolExecutor(max_workers=args.workers, initializer=init_process_connections) as executor:
futures = {executor.submit(process_single_day_v2, task): task[0] for task in tasks}
with tqdm(total=len(futures), desc="处理进度", unit="") as pbar:
for future in as_completed(futures):
trade_date = futures[future]
try:
result_date, success = future.result()
if success:
success_count += 1
else:
failed_dates.append(result_date)
except Exception as e:
print(f"\n[{trade_date}] 进程异常: {e}")
failed_dates.append(trade_date)
pbar.update(1)
print("\n" + "=" * 60)
print(f"处理完成: {success_count}/{len(tasks)} 个交易日")
if failed_dates:
print(f"失败日期: {failed_dates[:10]}{'...' if len(failed_dates) > 10 else ''}")
print(f"数据保存在: {OUTPUT_DIR}")
print("=" * 60)
if __name__ == "__main__":
main()

1520
ml/realtime_detector.py Normal file

File diff suppressed because it is too large Load Diff

729
ml/realtime_detector_v2.py Normal file
View File

@@ -0,0 +1,729 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
V2 实时异动检测器
使用方法:
# 作为模块导入
from ml.realtime_detector_v2 import RealtimeDetectorV2
detector = RealtimeDetectorV2()
alerts = detector.detect_realtime() # 检测当前时刻
# 或命令行测试
python ml/realtime_detector_v2.py --date 2025-12-09
"""
import os
import sys
import json
import pickle
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from collections import defaultdict, deque
import numpy as np
import pandas as pd
import torch
from sqlalchemy import create_engine, text
from elasticsearch import Elasticsearch
from clickhouse_driver import Client
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml.model import TransformerAutoencoder
# ==================== 配置 ====================
MYSQL_URL = "mysql+pymysql://root:Zzl5588161!@192.168.1.5:3306/stock"
ES_HOST = 'http://127.0.0.1:9200'
ES_INDEX = 'concept_library_v3'
CLICKHOUSE_CONFIG = {
'host': '127.0.0.1',
'port': 9000,
'user': 'default',
'password': 'Zzl33818!',
'database': 'stock'
}
REFERENCE_INDEX = '000001.SH'
BASELINE_FILE = 'ml/data_v2/baselines/realtime_baseline.pkl'
MODEL_DIR = 'ml/checkpoints_v2'
# 检测配置
CONFIG = {
'seq_len': 10, # LSTM 序列长度
'confirm_window': 5, # 持续确认窗口
'confirm_ratio': 0.6, # 确认比例
'rule_weight': 0.5,
'ml_weight': 0.5,
'rule_trigger': 60,
'ml_trigger': 70,
'fusion_trigger': 50,
'cooldown_minutes': 10,
'max_alerts_per_minute': 15,
'zscore_clip': 5.0,
'limit_up_threshold': 9.8,
}
FEATURES = ['alpha_zscore', 'amt_zscore', 'rank_zscore', 'momentum_3m', 'momentum_5m', 'limit_up_ratio']
# ==================== 数据库连接 ====================
_mysql_engine = None
_es_client = None
_ch_client = None
def get_mysql_engine():
global _mysql_engine
if _mysql_engine is None:
_mysql_engine = create_engine(MYSQL_URL, echo=False, pool_pre_ping=True)
return _mysql_engine
def get_es_client():
global _es_client
if _es_client is None:
_es_client = Elasticsearch([ES_HOST])
return _es_client
def get_ch_client():
global _ch_client
if _ch_client is None:
_ch_client = Client(**CLICKHOUSE_CONFIG)
return _ch_client
def code_to_ch_format(code: str) -> str:
if not code or len(code) != 6 or not code.isdigit():
return None
if code.startswith('6'):
return f"{code}.SH"
elif code.startswith('0') or code.startswith('3'):
return f"{code}.SZ"
return f"{code}.BJ"
def time_to_slot(ts) -> str:
if isinstance(ts, str):
return ts
return ts.strftime('%H:%M')
# ==================== 规则评分 ====================
def score_rules_zscore(features: Dict) -> Tuple[float, List[str]]:
"""基于 Z-Score 的规则评分"""
score = 0.0
triggered = []
alpha_z = abs(features.get('alpha_zscore', 0))
amt_z = features.get('amt_zscore', 0)
rank_z = abs(features.get('rank_zscore', 0))
mom_3m = features.get('momentum_3m', 0)
mom_5m = features.get('momentum_5m', 0)
limit_up = features.get('limit_up_ratio', 0)
# Alpha Z-Score
if alpha_z >= 4.0:
score += 25
triggered.append('alpha_extreme')
elif alpha_z >= 3.0:
score += 18
triggered.append('alpha_strong')
elif alpha_z >= 2.0:
score += 10
triggered.append('alpha_moderate')
# 成交额 Z-Score
if amt_z >= 4.0:
score += 20
triggered.append('amt_extreme')
elif amt_z >= 3.0:
score += 12
triggered.append('amt_strong')
elif amt_z >= 2.0:
score += 6
triggered.append('amt_moderate')
# 排名 Z-Score
if rank_z >= 3.0:
score += 15
triggered.append('rank_extreme')
elif rank_z >= 2.0:
score += 8
triggered.append('rank_strong')
# 动量(基于 Z-Score 的)
if mom_3m >= 1.0:
score += 12
triggered.append('momentum_3m_strong')
elif mom_3m >= 0.5:
score += 6
triggered.append('momentum_3m_moderate')
if mom_5m >= 1.5:
score += 10
triggered.append('momentum_5m_strong')
# 涨停比例
if limit_up >= 0.3:
score += 20
triggered.append('limit_up_extreme')
elif limit_up >= 0.15:
score += 12
triggered.append('limit_up_strong')
elif limit_up >= 0.08:
score += 5
triggered.append('limit_up_moderate')
# 组合规则
if alpha_z >= 2.0 and amt_z >= 2.0:
score += 15
triggered.append('combo_alpha_amt')
if alpha_z >= 2.0 and limit_up >= 0.1:
score += 12
triggered.append('combo_alpha_limitup')
return min(score, 100), triggered
# ==================== 实时检测器 ====================
class RealtimeDetectorV2:
"""V2 实时异动检测器"""
def __init__(self, model_dir: str = MODEL_DIR, baseline_file: str = BASELINE_FILE):
print("初始化 V2 实时检测器...")
# 加载概念
self.concepts = self._load_concepts()
self.concept_stocks = {c['concept_id']: set(c['stocks']) for c in self.concepts}
self.all_stocks = list(set(s for c in self.concepts for s in c['stocks']))
# 加载基线
self.baselines = self._load_baselines(baseline_file)
# 加载模型
self.model, self.thresholds, self.device = self._load_model(model_dir)
# 状态管理
self.zscore_history = defaultdict(lambda: deque(maxlen=CONFIG['seq_len']))
self.anomaly_candidates = defaultdict(lambda: deque(maxlen=CONFIG['confirm_window']))
self.cooldown = {}
print(f"初始化完成: {len(self.concepts)} 概念, {len(self.baselines)} 基线")
def _load_concepts(self) -> List[dict]:
"""从 ES 加载概念"""
es = get_es_client()
concepts = []
query = {"query": {"match_all": {}}, "size": 100, "_source": ["concept_id", "concept", "stocks"]}
resp = es.search(index=ES_INDEX, body=query, scroll='2m')
scroll_id = resp['_scroll_id']
hits = resp['hits']['hits']
while hits:
for hit in hits:
src = hit['_source']
stocks = [s['code'] for s in src.get('stocks', []) if isinstance(s, dict) and s.get('code')]
if stocks:
concepts.append({
'concept_id': src.get('concept_id'),
'concept_name': src.get('concept'),
'stocks': stocks
})
resp = es.scroll(scroll_id=scroll_id, scroll='2m')
scroll_id = resp['_scroll_id']
hits = resp['hits']['hits']
es.clear_scroll(scroll_id=scroll_id)
return concepts
def _load_baselines(self, baseline_file: str) -> Dict:
"""加载基线"""
if not os.path.exists(baseline_file):
print(f"警告: 基线文件不存在: {baseline_file}")
print("请先运行: python ml/update_baseline.py")
return {}
with open(baseline_file, 'rb') as f:
data = pickle.load(f)
print(f"基线日期范围: {data.get('date_range', 'unknown')}")
print(f"更新时间: {data.get('update_time', 'unknown')}")
return data.get('baselines', {})
def _load_model(self, model_dir: str):
"""加载模型"""
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
config_path = os.path.join(model_dir, 'config.json')
model_path = os.path.join(model_dir, 'best_model.pt')
threshold_path = os.path.join(model_dir, 'thresholds.json')
if not os.path.exists(model_path):
print(f"警告: 模型不存在: {model_path}")
return None, {}, device
with open(config_path) as f:
config = json.load(f)
model = TransformerAutoencoder(**config['model'])
checkpoint = torch.load(model_path, map_location=device)
model.load_state_dict(checkpoint['model_state_dict'])
model.to(device)
model.eval()
thresholds = {}
if os.path.exists(threshold_path):
with open(threshold_path) as f:
thresholds = json.load(f)
print(f"模型已加载: {model_path}")
return model, thresholds, device
def _get_realtime_data(self, trade_date: str) -> pd.DataFrame:
"""获取实时数据并计算原始特征"""
ch = get_ch_client()
# 获取股票数据
ch_codes = [code_to_ch_format(c) for c in self.all_stocks if code_to_ch_format(c)]
ch_codes_str = "','".join(ch_codes)
stock_query = f"""
SELECT code, timestamp, close, amt
FROM stock_minute
WHERE toDate(timestamp) = '{trade_date}'
AND code IN ('{ch_codes_str}')
ORDER BY timestamp
"""
stock_result = ch.execute(stock_query)
if not stock_result:
return pd.DataFrame()
stock_df = pd.DataFrame(stock_result, columns=['ch_code', 'timestamp', 'close', 'amt'])
# 映射回原始代码
ch_to_code = {code_to_ch_format(c): c for c in self.all_stocks if code_to_ch_format(c)}
stock_df['code'] = stock_df['ch_code'].map(ch_to_code)
stock_df = stock_df.dropna(subset=['code'])
# 获取指数数据
index_query = f"""
SELECT timestamp, close
FROM index_minute
WHERE toDate(timestamp) = '{trade_date}'
AND code = '{REFERENCE_INDEX}'
ORDER BY timestamp
"""
index_result = ch.execute(index_query)
if not index_result:
return pd.DataFrame()
index_df = pd.DataFrame(index_result, columns=['timestamp', 'close'])
# 获取昨收价
engine = get_mysql_engine()
codes_str = "','".join([c for c in self.all_stocks if c and len(c) == 6])
with engine.connect() as conn:
prev_result = conn.execute(text(f"""
SELECT SECCODE, F007N FROM ea_trade
WHERE SECCODE IN ('{codes_str}')
AND TRADEDATE = (SELECT MAX(TRADEDATE) FROM ea_trade WHERE TRADEDATE < '{trade_date}')
AND F007N > 0
"""))
prev_close = {row[0]: float(row[1]) for row in prev_result if row[1]}
idx_result = conn.execute(text("""
SELECT F006N FROM ea_exchangetrade
WHERE INDEXCODE = '000001' AND TRADEDATE < :today
ORDER BY TRADEDATE DESC LIMIT 1
"""), {'today': trade_date}).fetchone()
index_prev_close = float(idx_result[0]) if idx_result else None
if not prev_close or not index_prev_close:
return pd.DataFrame()
# 计算涨跌幅
stock_df['prev_close'] = stock_df['code'].map(prev_close)
stock_df = stock_df.dropna(subset=['prev_close'])
stock_df['change_pct'] = (stock_df['close'] - stock_df['prev_close']) / stock_df['prev_close'] * 100
index_df['change_pct'] = (index_df['close'] - index_prev_close) / index_prev_close * 100
index_map = dict(zip(index_df['timestamp'], index_df['change_pct']))
# 按时间聚合概念特征
results = []
for ts in sorted(stock_df['timestamp'].unique()):
ts_data = stock_df[stock_df['timestamp'] == ts]
idx_chg = index_map.get(ts, 0)
stock_chg = dict(zip(ts_data['code'], ts_data['change_pct']))
stock_amt = dict(zip(ts_data['code'], ts_data['amt']))
for cid, stocks in self.concept_stocks.items():
changes = [stock_chg[s] for s in stocks if s in stock_chg]
amts = [stock_amt.get(s, 0) for s in stocks if s in stock_chg]
if not changes:
continue
alpha = np.mean(changes) - idx_chg
total_amt = sum(amts)
limit_up_ratio = sum(1 for c in changes if c >= CONFIG['limit_up_threshold']) / len(changes)
results.append({
'concept_id': cid,
'timestamp': ts,
'time_slot': time_to_slot(ts),
'alpha': alpha,
'total_amt': total_amt,
'limit_up_ratio': limit_up_ratio,
'stock_count': len(changes),
})
if not results:
return pd.DataFrame()
df = pd.DataFrame(results)
# 计算排名
for ts in df['timestamp'].unique():
mask = df['timestamp'] == ts
df.loc[mask, 'rank_pct'] = df.loc[mask, 'alpha'].rank(pct=True)
return df
def _compute_zscore(self, concept_id: str, time_slot: str, alpha: float, total_amt: float, rank_pct: float) -> Optional[Dict]:
"""计算 Z-Score"""
if concept_id not in self.baselines:
return None
baseline = self.baselines[concept_id]
if time_slot not in baseline:
return None
bl = baseline[time_slot]
alpha_z = np.clip((alpha - bl['alpha_mean']) / bl['alpha_std'], -5, 5)
amt_z = np.clip((total_amt - bl['amt_mean']) / bl['amt_std'], -5, 5)
rank_z = np.clip((rank_pct - bl['rank_mean']) / bl['rank_std'], -5, 5)
# 动量(基于 Z-Score 历史)
history = list(self.zscore_history[concept_id])
mom_3m = 0.0
mom_5m = 0.0
if len(history) >= 3:
recent = [h['alpha_zscore'] for h in history[-3:]]
older = [h['alpha_zscore'] for h in history[-6:-3]] if len(history) >= 6 else [history[0]['alpha_zscore']]
mom_3m = np.mean(recent) - np.mean(older)
if len(history) >= 5:
recent = [h['alpha_zscore'] for h in history[-5:]]
older = [h['alpha_zscore'] for h in history[-10:-5]] if len(history) >= 10 else [history[0]['alpha_zscore']]
mom_5m = np.mean(recent) - np.mean(older)
return {
'alpha_zscore': float(alpha_z),
'amt_zscore': float(amt_z),
'rank_zscore': float(rank_z),
'momentum_3m': float(mom_3m),
'momentum_5m': float(mom_5m),
}
@torch.no_grad()
def _ml_score(self, sequences: np.ndarray) -> np.ndarray:
"""批量 ML 评分"""
if self.model is None or len(sequences) == 0:
return np.zeros(len(sequences))
x = torch.FloatTensor(sequences).to(self.device)
errors = self.model.compute_reconstruction_error(x, reduction='none')
last_errors = errors[:, -1].cpu().numpy()
# 转换为 0-100 分数
if self.thresholds:
p50 = self.thresholds.get('median', 0.001)
p99 = self.thresholds.get('p99', 0.05)
scores = 50 + (last_errors - p50) / (p99 - p50 + 1e-6) * 49
else:
scores = last_errors * 1000
return np.clip(scores, 0, 100)
def detect(self, trade_date: str = None) -> List[Dict]:
"""检测指定日期的异动"""
trade_date = trade_date or datetime.now().strftime('%Y-%m-%d')
print(f"\n检测 {trade_date} 的异动...")
# 重置状态
self.zscore_history.clear()
self.anomaly_candidates.clear()
self.cooldown.clear()
# 获取数据
raw_df = self._get_realtime_data(trade_date)
if raw_df.empty:
print("无数据")
return []
timestamps = sorted(raw_df['timestamp'].unique())
print(f"时间点数: {len(timestamps)}")
all_alerts = []
for ts in timestamps:
ts_data = raw_df[raw_df['timestamp'] == ts]
time_slot = time_to_slot(ts)
candidates = []
# 计算每个概念的 Z-Score
for _, row in ts_data.iterrows():
cid = row['concept_id']
zscore = self._compute_zscore(
cid, time_slot,
row['alpha'], row['total_amt'], row['rank_pct']
)
if zscore is None:
continue
# 完整特征
features = {
**zscore,
'alpha': row['alpha'],
'limit_up_ratio': row['limit_up_ratio'],
'total_amt': row['total_amt'],
}
# 更新历史
self.zscore_history[cid].append(zscore)
# 规则评分
rule_score, triggered = score_rules_zscore(features)
candidates.append((cid, features, rule_score, triggered))
if not candidates:
continue
# 批量 ML 评分
sequences = []
valid_candidates = []
for cid, features, rule_score, triggered in candidates:
history = list(self.zscore_history[cid])
if len(history) >= CONFIG['seq_len']:
seq = np.array([[h['alpha_zscore'], h['amt_zscore'], h['rank_zscore'],
h['momentum_3m'], h['momentum_5m'], features['limit_up_ratio']]
for h in history])
sequences.append(seq)
valid_candidates.append((cid, features, rule_score, triggered))
if not sequences:
continue
ml_scores = self._ml_score(np.array(sequences))
# 融合 + 确认
for i, (cid, features, rule_score, triggered) in enumerate(valid_candidates):
ml_score = ml_scores[i]
final_score = CONFIG['rule_weight'] * rule_score + CONFIG['ml_weight'] * ml_score
# 判断触发
is_triggered = (
rule_score >= CONFIG['rule_trigger'] or
ml_score >= CONFIG['ml_trigger'] or
final_score >= CONFIG['fusion_trigger']
)
self.anomaly_candidates[cid].append((ts, final_score))
if not is_triggered:
continue
# 冷却期
if cid in self.cooldown:
if (ts - self.cooldown[cid]).total_seconds() < CONFIG['cooldown_minutes'] * 60:
continue
# 持续确认
recent = list(self.anomaly_candidates[cid])
if len(recent) < CONFIG['confirm_window']:
continue
exceed = sum(1 for _, s in recent if s >= CONFIG['fusion_trigger'])
ratio = exceed / len(recent)
if ratio < CONFIG['confirm_ratio']:
continue
# 确认异动!
self.cooldown[cid] = ts
alpha = features['alpha']
alert_type = 'surge_up' if alpha >= 1.5 else 'surge_down' if alpha <= -1.5 else 'surge'
concept_name = next((c['concept_name'] for c in self.concepts if c['concept_id'] == cid), cid)
all_alerts.append({
'concept_id': cid,
'concept_name': concept_name,
'alert_time': ts,
'trade_date': trade_date,
'alert_type': alert_type,
'final_score': float(final_score),
'rule_score': float(rule_score),
'ml_score': float(ml_score),
'confirm_ratio': float(ratio),
'alpha': float(alpha),
'alpha_zscore': float(features['alpha_zscore']),
'amt_zscore': float(features['amt_zscore']),
'rank_zscore': float(features['rank_zscore']),
'momentum_3m': float(features['momentum_3m']),
'momentum_5m': float(features['momentum_5m']),
'limit_up_ratio': float(features['limit_up_ratio']),
'triggered_rules': triggered,
'trigger_reason': f"融合({final_score:.0f})+确认({ratio:.0%})",
})
print(f"检测到 {len(all_alerts)} 个异动")
return all_alerts
# ==================== 数据库存储 ====================
def create_v2_table():
"""创建 V2 异动表(如果不存在)"""
engine = get_mysql_engine()
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS concept_anomaly_v2 (
id INT AUTO_INCREMENT PRIMARY KEY,
concept_id VARCHAR(50) NOT NULL,
alert_time DATETIME NOT NULL,
trade_date DATE NOT NULL,
alert_type VARCHAR(20) NOT NULL,
final_score FLOAT,
rule_score FLOAT,
ml_score FLOAT,
trigger_reason VARCHAR(200),
confirm_ratio FLOAT,
alpha FLOAT,
alpha_zscore FLOAT,
amt_zscore FLOAT,
rank_zscore FLOAT,
momentum_3m FLOAT,
momentum_5m FLOAT,
limit_up_ratio FLOAT,
triggered_rules TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_concept_time (concept_id, alert_time),
INDEX idx_trade_date (trade_date),
INDEX idx_alert_type (alert_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
print("concept_anomaly_v2 表已就绪")
def save_alerts_to_db(alerts: List[Dict]) -> int:
"""保存异动到数据库"""
if not alerts:
return 0
engine = get_mysql_engine()
saved = 0
with engine.begin() as conn:
for alert in alerts:
try:
insert_sql = text("""
INSERT IGNORE INTO concept_anomaly_v2
(concept_id, alert_time, trade_date, alert_type,
final_score, rule_score, ml_score, trigger_reason, confirm_ratio,
alpha, alpha_zscore, amt_zscore, rank_zscore,
momentum_3m, momentum_5m, limit_up_ratio, triggered_rules)
VALUES
(:concept_id, :alert_time, :trade_date, :alert_type,
:final_score, :rule_score, :ml_score, :trigger_reason, :confirm_ratio,
:alpha, :alpha_zscore, :amt_zscore, :rank_zscore,
:momentum_3m, :momentum_5m, :limit_up_ratio, :triggered_rules)
""")
result = conn.execute(insert_sql, {
'concept_id': alert['concept_id'],
'alert_time': alert['alert_time'],
'trade_date': alert['trade_date'],
'alert_type': alert['alert_type'],
'final_score': alert['final_score'],
'rule_score': alert['rule_score'],
'ml_score': alert['ml_score'],
'trigger_reason': alert['trigger_reason'],
'confirm_ratio': alert['confirm_ratio'],
'alpha': alert['alpha'],
'alpha_zscore': alert['alpha_zscore'],
'amt_zscore': alert['amt_zscore'],
'rank_zscore': alert['rank_zscore'],
'momentum_3m': alert['momentum_3m'],
'momentum_5m': alert['momentum_5m'],
'limit_up_ratio': alert['limit_up_ratio'],
'triggered_rules': json.dumps(alert.get('triggered_rules', []), ensure_ascii=False),
})
if result.rowcount > 0:
saved += 1
except Exception as e:
print(f"保存失败: {alert['concept_id']} - {e}")
return saved
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--date', type=str, default=None)
parser.add_argument('--no-save', action='store_true', help='不保存到数据库,只打印')
args = parser.parse_args()
# 确保表存在
if not args.no_save:
create_v2_table()
detector = RealtimeDetectorV2()
alerts = detector.detect(args.date)
print(f"\n{'='*60}")
print(f"检测结果 ({len(alerts)} 个异动)")
print('='*60)
for a in alerts[:20]:
print(f"[{a['alert_time'].strftime('%H:%M') if hasattr(a['alert_time'], 'strftime') else a['alert_time']}] "
f"{a['concept_name']} | {a['alert_type']} | "
f"分数={a['final_score']:.0f} 确认={a['confirm_ratio']:.0%} "
f"α={a['alpha']:.2f}% αZ={a['alpha_zscore']:.1f}")
if len(alerts) > 20:
print(f"... 共 {len(alerts)}")
# 保存到数据库
if not args.no_save and alerts:
saved = save_alerts_to_db(alerts)
print(f"\n✅ 已保存 {saved}/{len(alerts)} 条到 concept_anomaly_v2 表")
elif args.no_save:
print(f"\n⚠️ --no-save 模式,未保存到数据库")
if __name__ == "__main__":
main()

25
ml/requirements.txt Normal file
View File

@@ -0,0 +1,25 @@
# 概念异动检测 ML 模块依赖
# 安装: pip install -r ml/requirements.txt
# PyTorch (根据 CUDA 版本选择)
# 5090 显卡需要 CUDA 12.x
# pip install torch --index-url https://download.pytorch.org/whl/cu124
torch>=2.0.0
# 数据处理
numpy>=1.24.0
pandas>=2.0.0
pyarrow>=14.0.0
# 数据库
clickhouse-driver>=0.2.6
elasticsearch>=7.0.0,<8.0.0
sqlalchemy>=2.0.0
pymysql>=1.1.0
# 训练工具
tqdm>=4.65.0
# 可选: 可视化
# matplotlib>=3.7.0
# tensorboard>=2.14.0

99
ml/run_training.sh Normal file
View File

@@ -0,0 +1,99 @@
#!/bin/bash
# 概念异动检测模型训练脚本 (Linux)
#
# 使用方法:
# chmod +x run_training.sh
# ./run_training.sh
#
# 或指定参数:
# ./run_training.sh --start 2022-01-01 --epochs 100
set -e
echo "============================================================"
echo "概念异动检测模型训练流程"
echo "============================================================"
echo ""
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/.."
echo "[1/4] 检查环境..."
python3 --version || { echo "Python3 未找到!"; exit 1; }
# 检查 GPU
if python3 -c "import torch; print(f'CUDA: {torch.cuda.is_available()}')" 2>/dev/null; then
echo "PyTorch GPU 检测完成"
else
echo "警告: PyTorch 未安装或无法检测 GPU"
fi
echo ""
echo "[2/4] 检查依赖..."
pip3 install -q torch pandas numpy pyarrow tqdm clickhouse-driver elasticsearch sqlalchemy pymysql
echo ""
echo "[3/4] 准备训练数据..."
echo "从 ClickHouse 提取历史数据,这可能需要较长时间..."
echo ""
# 解析参数
START_DATE="2022-01-01"
END_DATE=""
EPOCHS=100
BATCH_SIZE=256
TRAIN_END="2025-06-30"
VAL_END="2025-09-30"
while [[ $# -gt 0 ]]; do
case $1 in
--start)
START_DATE="$2"
shift 2
;;
--end)
END_DATE="$2"
shift 2
;;
--epochs)
EPOCHS="$2"
shift 2
;;
--batch_size)
BATCH_SIZE="$2"
shift 2
;;
--train_end)
TRAIN_END="$2"
shift 2
;;
--val_end)
VAL_END="$2"
shift 2
;;
*)
shift
;;
esac
done
# 数据准备
if [ -n "$END_DATE" ]; then
python3 ml/prepare_data.py --start "$START_DATE" --end "$END_DATE"
else
python3 ml/prepare_data.py --start "$START_DATE"
fi
echo ""
echo "[4/4] 训练模型..."
echo "使用 GPU 加速训练..."
echo ""
python3 ml/train.py --epochs "$EPOCHS" --batch_size "$BATCH_SIZE" --train_end "$TRAIN_END" --val_end "$VAL_END"
echo ""
echo "============================================================"
echo "训练完成!"
echo "模型保存在: ml/checkpoints/"
echo "============================================================"

808
ml/train.py Normal file
View File

@@ -0,0 +1,808 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Transformer Autoencoder 训练脚本 (修复版)
修复问题:
1. 按概念分组构建序列,避免跨概念切片
2. 按时间(日期)切分数据集,避免数据泄露
3. 使用 RobustScaler + Clipping 处理非平稳性
4. 使用验证集计算阈值
训练流程:
1. 加载预处理好的特征数据parquet 文件)
2. 按概念分组,在每个概念内部构建序列
3. 按日期划分训练/验证/测试集
4. 训练 Autoencoder最小化重构误差
5. 保存模型和阈值
使用方法:
python train.py --data_dir ml/data --epochs 100 --batch_size 256
"""
import os
import sys
import argparse
import json
from datetime import datetime
from pathlib import Path
from typing import List, Tuple, Dict
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
from torch.optim import AdamW
from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts
from tqdm import tqdm
from model import TransformerAutoencoder, AnomalyDetectionLoss, count_parameters
# 性能优化:启用 cuDNN benchmark对固定输入尺寸自动选择最快算法
torch.backends.cudnn.benchmark = True
# 启用 TF32RTX 30/40 系列特有,提速约 3 倍)
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
# 可视化(可选)
try:
import matplotlib
matplotlib.use('Agg') # 无头模式,不需要显示器
import matplotlib.pyplot as plt
HAS_MATPLOTLIB = True
except ImportError:
HAS_MATPLOTLIB = False
# ==================== 配置 ====================
TRAIN_CONFIG = {
# 数据配置
'seq_len': 30, # 输入序列长度30分钟
'stride': 5, # 滑动窗口步长
# 时间切分(按日期)
'train_end_date': '2024-06-30', # 训练集截止日期
'val_end_date': '2024-09-30', # 验证集截止日期(之后为测试集)
# 特征配置
'features': [
'alpha', # 超额收益
'alpha_delta', # Alpha 变化率
'amt_ratio', # 成交额比率
'amt_delta', # 成交额变化率
'rank_pct', # Alpha 排名百分位
'limit_up_ratio', # 涨停比例
],
# 训练配置(针对 4x RTX 4090 优化)
'batch_size': 4096, # 256 -> 4096大幅增加充分利用显存
'epochs': 100,
'learning_rate': 3e-4, # 1e-4 -> 3e-4大 batch 需要更大学习率)
'weight_decay': 1e-5,
'gradient_clip': 1.0,
# 早停配置
'patience': 10,
'min_delta': 1e-6,
# 模型配置LSTM Autoencoder简洁有效
'model': {
'n_features': 6,
'hidden_dim': 32, # LSTM 隐藏维度(小)
'latent_dim': 4, # 瓶颈维度(非常小!关键)
'num_layers': 1, # LSTM 层数
'dropout': 0.2,
'bidirectional': True, # 双向编码器
},
# 标准化配置
'use_instance_norm': True, # 模型内部使用 Instance Norm推荐
'clip_value': 10.0, # 简单截断极端值
# 阈值配置
'threshold_percentiles': [90, 95, 99],
}
# ==================== 数据加载(修复版)====================
def load_data_by_date(data_dir: str, features: List[str]) -> Dict[str, pd.DataFrame]:
"""
按日期加载数据,返回 {date: DataFrame} 字典
每个 DataFrame 包含该日所有概念的所有时间点数据
"""
data_path = Path(data_dir)
parquet_files = sorted(data_path.glob("features_*.parquet"))
if not parquet_files:
raise FileNotFoundError(f"未找到 parquet 文件: {data_dir}")
print(f"找到 {len(parquet_files)} 个数据文件")
date_data = {}
for pf in tqdm(parquet_files, desc="加载数据"):
# 提取日期
date = pf.stem.replace('features_', '')
df = pd.read_parquet(pf)
# 检查必要列
required_cols = features + ['concept_id', 'timestamp']
missing_cols = [c for c in required_cols if c not in df.columns]
if missing_cols:
print(f"警告: {date} 缺少列: {missing_cols}, 跳过")
continue
date_data[date] = df
print(f"成功加载 {len(date_data)} 天的数据")
return date_data
def split_data_by_date(
date_data: Dict[str, pd.DataFrame],
train_end: str,
val_end: str
) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
"""
按日期严格划分数据集
- 训练集: <= train_end
- 验证集: train_end < date <= val_end
- 测试集: > val_end
"""
train_data = {}
val_data = {}
test_data = {}
for date, df in date_data.items():
if date <= train_end:
train_data[date] = df
elif date <= val_end:
val_data[date] = df
else:
test_data[date] = df
print(f"数据集划分(按日期):")
print(f" 训练集: {len(train_data)} 天 (<= {train_end})")
print(f" 验证集: {len(val_data)} 天 ({train_end} ~ {val_end})")
print(f" 测试集: {len(test_data)} 天 (> {val_end})")
return train_data, val_data, test_data
def build_sequences_by_concept(
date_data: Dict[str, pd.DataFrame],
features: List[str],
seq_len: int,
stride: int
) -> np.ndarray:
"""
按概念分组构建序列(性能优化版)
使用 groupby 一次性分组,避免重复扫描大数组
1. 将所有日期的数据合并
2. 使用 groupby 按 concept_id 分组
3. 在每个概念内部,按时间排序并滑动窗口
4. 合并所有序列
"""
# 合并所有日期的数据
all_dfs = []
for date, df in sorted(date_data.items()):
df = df.copy()
df['date'] = date
all_dfs.append(df)
if not all_dfs:
return np.array([])
combined = pd.concat(all_dfs, ignore_index=True)
# 预先排序(按概念、日期、时间),这样 groupby 会更快
combined = combined.sort_values(['concept_id', 'date', 'timestamp'])
# 使用 groupby 一次性分组(性能关键!)
all_sequences = []
grouped = combined.groupby('concept_id', sort=False)
n_concepts = len(grouped)
for concept_id, concept_df in tqdm(grouped, desc="构建序列", total=n_concepts, leave=False):
# 已经排序过了,直接提取特征
feature_data = concept_df[features].values
# 处理缺失值
feature_data = np.nan_to_num(feature_data, nan=0.0, posinf=0.0, neginf=0.0)
# 在该概念内部滑动窗口
n_points = len(feature_data)
for start in range(0, n_points - seq_len + 1, stride):
seq = feature_data[start:start + seq_len]
all_sequences.append(seq)
if not all_sequences:
return np.array([])
sequences = np.array(all_sequences)
print(f" 构建序列: {len(sequences):,} 条 (来自 {n_concepts} 个概念)")
return sequences
# ==================== 数据集 ====================
class SequenceDataset(Dataset):
"""序列数据集(已经构建好的序列)"""
def __init__(self, sequences: np.ndarray):
self.sequences = torch.FloatTensor(sequences)
def __len__(self) -> int:
return len(self.sequences)
def __getitem__(self, idx: int) -> torch.Tensor:
return self.sequences[idx]
# ==================== 训练器 ====================
class EarlyStopping:
"""早停机制"""
def __init__(self, patience: int = 10, min_delta: float = 1e-6):
self.patience = patience
self.min_delta = min_delta
self.counter = 0
self.best_loss = float('inf')
self.early_stop = False
def __call__(self, val_loss: float) -> bool:
if val_loss < self.best_loss - self.min_delta:
self.best_loss = val_loss
self.counter = 0
else:
self.counter += 1
if self.counter >= self.patience:
self.early_stop = True
return self.early_stop
class Trainer:
"""模型训练器(支持 AMP 混合精度加速)"""
def __init__(
self,
model: nn.Module,
train_loader: DataLoader,
val_loader: DataLoader,
config: Dict,
device: torch.device,
save_dir: str = 'ml/checkpoints'
):
self.model = model.to(device)
self.train_loader = train_loader
self.val_loader = val_loader
self.config = config
self.device = device
self.save_dir = Path(save_dir)
self.save_dir.mkdir(parents=True, exist_ok=True)
# 优化器
self.optimizer = AdamW(
model.parameters(),
lr=config['learning_rate'],
weight_decay=config['weight_decay']
)
# 学习率调度器
self.scheduler = CosineAnnealingWarmRestarts(
self.optimizer,
T_0=10,
T_mult=2,
eta_min=1e-6
)
# 损失函数(简化版,只用 MSE
self.criterion = AnomalyDetectionLoss()
# 早停
self.early_stopping = EarlyStopping(
patience=config['patience'],
min_delta=config['min_delta']
)
# AMP 混合精度训练(大幅提速 + 省显存)
self.use_amp = torch.cuda.is_available()
self.scaler = torch.cuda.amp.GradScaler() if self.use_amp else None
if self.use_amp:
print(" ✓ 启用 AMP 混合精度训练")
# 训练历史
self.history = {
'train_loss': [],
'val_loss': [],
'learning_rate': [],
}
self.best_val_loss = float('inf')
def train_epoch(self) -> float:
"""训练一个 epoch使用 AMP 混合精度)"""
self.model.train()
total_loss = 0.0
n_batches = 0
pbar = tqdm(self.train_loader, desc="Training", leave=False)
for batch in pbar:
batch = batch.to(self.device, non_blocking=True) # 异步传输
self.optimizer.zero_grad(set_to_none=True) # 更快的梯度清零
# AMP 混合精度前向传播
if self.use_amp:
with torch.cuda.amp.autocast():
output, latent = self.model(batch)
loss, loss_dict = self.criterion(output, batch, latent)
# AMP 反向传播
self.scaler.scale(loss).backward()
# 梯度裁剪(需要 unscale
self.scaler.unscale_(self.optimizer)
torch.nn.utils.clip_grad_norm_(
self.model.parameters(),
self.config['gradient_clip']
)
self.scaler.step(self.optimizer)
self.scaler.update()
else:
# 非 AMP 模式
output, latent = self.model(batch)
loss, loss_dict = self.criterion(output, batch, latent)
loss.backward()
torch.nn.utils.clip_grad_norm_(
self.model.parameters(),
self.config['gradient_clip']
)
self.optimizer.step()
total_loss += loss.item()
n_batches += 1
pbar.set_postfix({'loss': f"{loss.item():.4f}"})
return total_loss / n_batches
@torch.no_grad()
def validate(self) -> float:
"""验证(使用 AMP"""
self.model.eval()
total_loss = 0.0
n_batches = 0
for batch in self.val_loader:
batch = batch.to(self.device, non_blocking=True)
if self.use_amp:
with torch.cuda.amp.autocast():
output, latent = self.model(batch)
loss, _ = self.criterion(output, batch, latent)
else:
output, latent = self.model(batch)
loss, _ = self.criterion(output, batch, latent)
total_loss += loss.item()
n_batches += 1
return total_loss / n_batches
def save_checkpoint(self, epoch: int, val_loss: float, is_best: bool = False):
"""保存检查点"""
# 处理 DataParallel 包装
model_to_save = self.model.module if hasattr(self.model, 'module') else self.model
checkpoint = {
'epoch': epoch,
'model_state_dict': model_to_save.state_dict(),
'optimizer_state_dict': self.optimizer.state_dict(),
'scheduler_state_dict': self.scheduler.state_dict(),
'val_loss': val_loss,
'config': self.config,
}
# 保存最新检查点
torch.save(checkpoint, self.save_dir / 'last_checkpoint.pt')
# 保存最佳模型
if is_best:
torch.save(checkpoint, self.save_dir / 'best_model.pt')
print(f" ✓ 保存最佳模型 (val_loss: {val_loss:.6f})")
def train(self, epochs: int):
"""完整训练流程"""
print(f"\n开始训练 ({epochs} epochs)...")
print(f"设备: {self.device}")
print(f"模型参数量: {count_parameters(self.model):,}")
for epoch in range(1, epochs + 1):
print(f"\nEpoch {epoch}/{epochs}")
# 训练
train_loss = self.train_epoch()
# 验证
val_loss = self.validate()
# 更新学习率
self.scheduler.step()
current_lr = self.optimizer.param_groups[0]['lr']
# 记录历史
self.history['train_loss'].append(train_loss)
self.history['val_loss'].append(val_loss)
self.history['learning_rate'].append(current_lr)
# 打印进度
print(f" Train Loss: {train_loss:.6f}")
print(f" Val Loss: {val_loss:.6f}")
print(f" LR: {current_lr:.2e}")
# 保存检查点
is_best = val_loss < self.best_val_loss
if is_best:
self.best_val_loss = val_loss
self.save_checkpoint(epoch, val_loss, is_best)
# 早停检查
if self.early_stopping(val_loss):
print(f"\n早停触发!验证损失已 {self.early_stopping.patience} 个 epoch 未改善")
break
print(f"\n训练完成!最佳验证损失: {self.best_val_loss:.6f}")
# 保存训练历史
self.save_history()
return self.history
def save_history(self):
"""保存训练历史"""
history_path = self.save_dir / 'training_history.json'
with open(history_path, 'w') as f:
json.dump(self.history, f, indent=2)
print(f"训练历史已保存: {history_path}")
# 绘制训练曲线
self.plot_training_curves()
def plot_training_curves(self):
"""绘制训练曲线"""
if not HAS_MATPLOTLIB:
print("matplotlib 未安装,跳过绘图")
return
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
epochs = range(1, len(self.history['train_loss']) + 1)
# 1. Loss 曲线
ax1 = axes[0]
ax1.plot(epochs, self.history['train_loss'], 'b-', label='Train Loss', linewidth=2)
ax1.plot(epochs, self.history['val_loss'], 'r-', label='Val Loss', linewidth=2)
ax1.set_xlabel('Epoch', fontsize=12)
ax1.set_ylabel('Loss', fontsize=12)
ax1.set_title('Training & Validation Loss', fontsize=14)
ax1.legend(fontsize=11)
ax1.grid(True, alpha=0.3)
# 标记最佳点
best_epoch = np.argmin(self.history['val_loss']) + 1
best_val_loss = min(self.history['val_loss'])
ax1.axvline(x=best_epoch, color='g', linestyle='--', alpha=0.7, label=f'Best Epoch: {best_epoch}')
ax1.scatter([best_epoch], [best_val_loss], color='g', s=100, zorder=5)
ax1.annotate(f'Best: {best_val_loss:.6f}', xy=(best_epoch, best_val_loss),
xytext=(best_epoch + 2, best_val_loss + 0.0005),
fontsize=10, color='green')
# 2. 学习率曲线
ax2 = axes[1]
ax2.plot(epochs, self.history['learning_rate'], 'g-', linewidth=2)
ax2.set_xlabel('Epoch', fontsize=12)
ax2.set_ylabel('Learning Rate', fontsize=12)
ax2.set_title('Learning Rate Schedule', fontsize=14)
ax2.set_yscale('log')
ax2.grid(True, alpha=0.3)
plt.tight_layout()
# 保存图片
plot_path = self.save_dir / 'training_curves.png'
plt.savefig(plot_path, dpi=150, bbox_inches='tight')
plt.close()
print(f"训练曲线已保存: {plot_path}")
# ==================== 阈值计算(使用验证集)====================
@torch.no_grad()
def compute_thresholds(
model: nn.Module,
data_loader: DataLoader,
device: torch.device,
percentiles: List[float] = [90, 95, 99]
) -> Dict[str, float]:
"""
在验证集上计算重构误差的百分位数阈值
注:使用验证集而非测试集,避免数据泄露
"""
model.eval()
all_errors = []
print("计算异动阈值(使用验证集)...")
for batch in tqdm(data_loader, desc="Computing thresholds"):
batch = batch.to(device)
errors = model.compute_reconstruction_error(batch, reduction='none')
# 取每个序列的最后一个时刻误差(预测当前时刻)
seq_errors = errors[:, -1] # (batch,)
all_errors.append(seq_errors.cpu().numpy())
all_errors = np.concatenate(all_errors)
thresholds = {}
for p in percentiles:
threshold = np.percentile(all_errors, p)
thresholds[f'p{p}'] = float(threshold)
print(f" P{p}: {threshold:.6f}")
# 额外统计
thresholds['mean'] = float(np.mean(all_errors))
thresholds['std'] = float(np.std(all_errors))
thresholds['median'] = float(np.median(all_errors))
print(f" Mean: {thresholds['mean']:.6f}")
print(f" Median: {thresholds['median']:.6f}")
print(f" Std: {thresholds['std']:.6f}")
return thresholds
# ==================== 主函数 ====================
def main():
parser = argparse.ArgumentParser(description='训练概念异动检测模型')
parser.add_argument('--data_dir', type=str, default='ml/data',
help='数据目录路径')
parser.add_argument('--epochs', type=int, default=100,
help='训练轮数')
parser.add_argument('--batch_size', type=int, default=4096,
help='批次大小4x RTX 4090 推荐 4096~8192')
parser.add_argument('--lr', type=float, default=3e-4,
help='学习率(大 batch 推荐 3e-4')
parser.add_argument('--device', type=str, default='auto',
help='设备 (auto/cuda/cpu)')
parser.add_argument('--save_dir', type=str, default='ml/checkpoints',
help='模型保存目录')
parser.add_argument('--train_end', type=str, default='2024-06-30',
help='训练集截止日期')
parser.add_argument('--val_end', type=str, default='2024-09-30',
help='验证集截止日期')
args = parser.parse_args()
# 更新配置
config = TRAIN_CONFIG.copy()
config['batch_size'] = args.batch_size
config['epochs'] = args.epochs
config['learning_rate'] = args.lr
config['train_end_date'] = args.train_end
config['val_end_date'] = args.val_end
# 设备选择
if args.device == 'auto':
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
else:
device = torch.device(args.device)
print("=" * 60)
print("概念异动检测模型训练(修复版)")
print("=" * 60)
print(f"配置:")
print(f" 数据目录: {args.data_dir}")
print(f" 设备: {device}")
print(f" 批次大小: {config['batch_size']}")
print(f" 学习率: {config['learning_rate']}")
print(f" 训练轮数: {config['epochs']}")
print(f" 训练集截止: {config['train_end_date']}")
print(f" 验证集截止: {config['val_end_date']}")
print("=" * 60)
# 1. 按日期加载数据
print("\n[1/6] 加载数据...")
date_data = load_data_by_date(args.data_dir, config['features'])
# 2. 按日期划分
print("\n[2/6] 按日期划分数据集...")
train_data, val_data, test_data = split_data_by_date(
date_data,
config['train_end_date'],
config['val_end_date']
)
# 3. 按概念构建序列
print("\n[3/6] 按概念构建序列...")
print("训练集:")
train_sequences = build_sequences_by_concept(
train_data, config['features'], config['seq_len'], config['stride']
)
print("验证集:")
val_sequences = build_sequences_by_concept(
val_data, config['features'], config['seq_len'], config['stride']
)
print("测试集:")
test_sequences = build_sequences_by_concept(
test_data, config['features'], config['seq_len'], config['stride']
)
if len(train_sequences) == 0:
print("错误: 训练集为空!请检查数据和日期范围")
return
# 4. 数据预处理(简单截断极端值,标准化在模型内部通过 Instance Norm 完成)
print("\n[4/6] 数据预处理...")
print(" 注意: 使用 Instance Norm每个序列在模型内部单独标准化")
print(" 这样可以处理不同概念波动率差异(银行 vs 半导体)")
clip_value = config['clip_value']
print(f" 截断极端值: ±{clip_value}")
# 简单截断极端值(防止异常数据影响训练)
train_sequences = np.clip(train_sequences, -clip_value, clip_value)
if len(val_sequences) > 0:
val_sequences = np.clip(val_sequences, -clip_value, clip_value)
if len(test_sequences) > 0:
test_sequences = np.clip(test_sequences, -clip_value, clip_value)
# 保存配置
save_dir = Path(args.save_dir)
save_dir.mkdir(parents=True, exist_ok=True)
preprocess_params = {
'features': config['features'],
'normalization': 'instance_norm', # 在模型内部完成
'clip_value': clip_value,
'note': '标准化在模型内部通过 InstanceNorm1d 完成,无需外部 Scaler'
}
with open(save_dir / 'normalization_stats.json', 'w') as f:
json.dump(preprocess_params, f, indent=2)
print(f" 预处理参数已保存")
# 5. 创建数据集和加载器
print("\n[5/6] 创建数据加载器...")
train_dataset = SequenceDataset(train_sequences)
val_dataset = SequenceDataset(val_sequences) if len(val_sequences) > 0 else None
test_dataset = SequenceDataset(test_sequences) if len(test_sequences) > 0 else None
print(f" 训练序列: {len(train_dataset):,}")
print(f" 验证序列: {len(val_dataset) if val_dataset else 0:,}")
print(f" 测试序列: {len(test_dataset) if test_dataset else 0:,}")
# 多卡时增加 num_workersLinux 上可以用更多)
n_gpus = torch.cuda.device_count() if torch.cuda.is_available() else 1
num_workers = min(32, 8 * n_gpus) if sys.platform != 'win32' else 0
print(f" DataLoader workers: {num_workers}")
print(f" Batch size: {config['batch_size']}")
# 大 batch + 多 worker + prefetch 提速
train_loader = DataLoader(
train_dataset,
batch_size=config['batch_size'],
shuffle=True,
num_workers=num_workers,
pin_memory=True,
prefetch_factor=4 if num_workers > 0 else None, # 预取更多 batch
persistent_workers=True if num_workers > 0 else False, # 保持 worker 存活
drop_last=True # 丢弃不完整的最后一批,避免 batch 大小不一致
)
val_loader = DataLoader(
val_dataset,
batch_size=config['batch_size'] * 2, # 验证时可以用更大 batch无梯度
shuffle=False,
num_workers=num_workers,
pin_memory=True,
prefetch_factor=4 if num_workers > 0 else None,
persistent_workers=True if num_workers > 0 else False,
) if val_dataset else None
test_loader = DataLoader(
test_dataset,
batch_size=config['batch_size'] * 2,
shuffle=False,
num_workers=num_workers,
pin_memory=True,
prefetch_factor=4 if num_workers > 0 else None,
persistent_workers=True if num_workers > 0 else False,
) if test_dataset else None
# 6. 训练
print("\n[6/6] 训练模型...")
model_config = config['model'].copy()
model = TransformerAutoencoder(**model_config)
# 多卡并行
if torch.cuda.device_count() > 1:
print(f" 使用 {torch.cuda.device_count()} 张 GPU 并行训练")
model = nn.DataParallel(model)
if val_loader is None:
print("警告: 验证集为空,将使用训练集的一部分作为验证")
# 简单处理:用训练集的后 10% 作为验证
split_idx = int(len(train_dataset) * 0.9)
train_subset = torch.utils.data.Subset(train_dataset, range(split_idx))
val_subset = torch.utils.data.Subset(train_dataset, range(split_idx, len(train_dataset)))
train_loader = DataLoader(train_subset, batch_size=config['batch_size'], shuffle=True, num_workers=num_workers, pin_memory=True)
val_loader = DataLoader(val_subset, batch_size=config['batch_size'], shuffle=False, num_workers=num_workers, pin_memory=True)
trainer = Trainer(
model=model,
train_loader=train_loader,
val_loader=val_loader,
config=config,
device=device,
save_dir=args.save_dir
)
history = trainer.train(config['epochs'])
# 7. 计算阈值(使用验证集)
print("\n[额外] 计算异动阈值...")
# 加载最佳模型
best_checkpoint = torch.load(
save_dir / 'best_model.pt',
map_location=device
)
model.load_state_dict(best_checkpoint['model_state_dict'])
model.to(device)
# 使用验证集计算阈值(避免数据泄露)
thresholds = compute_thresholds(
model,
val_loader,
device,
config['threshold_percentiles']
)
# 保存阈值
with open(save_dir / 'thresholds.json', 'w') as f:
json.dump(thresholds, f, indent=2)
print(f"阈值已保存")
# 保存完整配置
with open(save_dir / 'config.json', 'w') as f:
json.dump(config, f, indent=2)
print("\n" + "=" * 60)
print("训练完成!")
print("=" * 60)
print(f"模型保存位置: {args.save_dir}")
print(f" - best_model.pt: 最佳模型权重")
print(f" - thresholds.json: 异动阈值")
print(f" - normalization_stats.json: 标准化参数")
print(f" - config.json: 训练配置")
print("=" * 60)
if __name__ == "__main__":
main()

622
ml/train_v2.py Normal file
View File

@@ -0,0 +1,622 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
训练脚本 V2 - 基于 Z-Score 特征的 LSTM Autoencoder
改进点:
1. 使用 Z-Score 特征(相对于同时间片历史的偏离)
2. 短序列10分钟不需要30分钟预热
3. 开盘即可检测9:30 直接有特征
模型输入:
- 过去10分钟的 Z-Score 特征序列
- 特征alpha_zscore, amt_zscore, rank_zscore, momentum_3m, momentum_5m, limit_up_ratio
模型学习:
- 学习 Z-Score 序列的"正常演化模式"
- 异动 = Z-Score 序列的异常演化(重构误差大)
"""
import os
import sys
import argparse
import json
from datetime import datetime
from pathlib import Path
from typing import List, Tuple, Dict
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
from torch.optim import AdamW
from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts
from tqdm import tqdm
from model import TransformerAutoencoder, AnomalyDetectionLoss, count_parameters
# 性能优化
torch.backends.cudnn.benchmark = True
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
try:
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
HAS_MATPLOTLIB = True
except ImportError:
HAS_MATPLOTLIB = False
# ==================== 配置 ====================
TRAIN_CONFIG = {
# 数据配置(改进!)
'seq_len': 10, # 10分钟序列不是30分钟
'stride': 2, # 步长2分钟
# 时间切分
'train_end_date': '2024-06-30',
'val_end_date': '2024-09-30',
# V2 特征Z-Score 为主)
'features': [
'alpha_zscore', # Alpha 的 Z-Score
'amt_zscore', # 成交额的 Z-Score
'rank_zscore', # 排名的 Z-Score
'momentum_3m', # 3分钟动量
'momentum_5m', # 5分钟动量
'limit_up_ratio', # 涨停占比
],
# 训练配置
'batch_size': 4096,
'epochs': 100,
'learning_rate': 3e-4,
'weight_decay': 1e-5,
'gradient_clip': 1.0,
# 早停配置
'patience': 15,
'min_delta': 1e-6,
# 模型配置(小型 LSTM
'model': {
'n_features': 6,
'hidden_dim': 32,
'latent_dim': 4,
'num_layers': 1,
'dropout': 0.2,
'bidirectional': True,
},
# 标准化配置
'clip_value': 5.0, # Z-Score 已经标准化clip 5.0 足够
# 阈值配置
'threshold_percentiles': [90, 95, 99],
}
# ==================== 数据加载 ====================
def load_data_by_date(data_dir: str, features: List[str]) -> Dict[str, pd.DataFrame]:
"""按日期加载 V2 数据"""
data_path = Path(data_dir)
parquet_files = sorted(data_path.glob("features_v2_*.parquet"))
if not parquet_files:
raise FileNotFoundError(f"未找到 V2 数据文件: {data_dir}")
print(f"找到 {len(parquet_files)} 个 V2 数据文件")
date_data = {}
for pf in tqdm(parquet_files, desc="加载数据"):
date = pf.stem.replace('features_v2_', '')
df = pd.read_parquet(pf)
required_cols = features + ['concept_id', 'timestamp']
missing_cols = [c for c in required_cols if c not in df.columns]
if missing_cols:
print(f"警告: {date} 缺少列: {missing_cols}, 跳过")
continue
date_data[date] = df
print(f"成功加载 {len(date_data)} 天的数据")
return date_data
def split_data_by_date(
date_data: Dict[str, pd.DataFrame],
train_end: str,
val_end: str
) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
"""按日期划分数据集"""
train_data = {}
val_data = {}
test_data = {}
for date, df in date_data.items():
if date <= train_end:
train_data[date] = df
elif date <= val_end:
val_data[date] = df
else:
test_data[date] = df
print(f"数据集划分:")
print(f" 训练集: {len(train_data)} 天 (<= {train_end})")
print(f" 验证集: {len(val_data)} 天 ({train_end} ~ {val_end})")
print(f" 测试集: {len(test_data)} 天 (> {val_end})")
return train_data, val_data, test_data
def build_sequences_by_concept(
date_data: Dict[str, pd.DataFrame],
features: List[str],
seq_len: int,
stride: int
) -> np.ndarray:
"""按概念分组构建序列"""
all_dfs = []
for date, df in sorted(date_data.items()):
df = df.copy()
df['date'] = date
all_dfs.append(df)
if not all_dfs:
return np.array([])
combined = pd.concat(all_dfs, ignore_index=True)
combined = combined.sort_values(['concept_id', 'date', 'timestamp'])
all_sequences = []
grouped = combined.groupby('concept_id', sort=False)
n_concepts = len(grouped)
for concept_id, concept_df in tqdm(grouped, desc="构建序列", total=n_concepts, leave=False):
feature_data = concept_df[features].values
feature_data = np.nan_to_num(feature_data, nan=0.0, posinf=0.0, neginf=0.0)
n_points = len(feature_data)
for start in range(0, n_points - seq_len + 1, stride):
seq = feature_data[start:start + seq_len]
all_sequences.append(seq)
if not all_sequences:
return np.array([])
sequences = np.array(all_sequences)
print(f" 构建序列: {len(sequences):,} 条 (来自 {n_concepts} 个概念)")
return sequences
# ==================== 数据集 ====================
class SequenceDataset(Dataset):
def __init__(self, sequences: np.ndarray):
self.sequences = torch.FloatTensor(sequences)
def __len__(self) -> int:
return len(self.sequences)
def __getitem__(self, idx: int) -> torch.Tensor:
return self.sequences[idx]
# ==================== 训练器 ====================
class EarlyStopping:
def __init__(self, patience: int = 10, min_delta: float = 1e-6):
self.patience = patience
self.min_delta = min_delta
self.counter = 0
self.best_loss = float('inf')
self.early_stop = False
def __call__(self, val_loss: float) -> bool:
if val_loss < self.best_loss - self.min_delta:
self.best_loss = val_loss
self.counter = 0
else:
self.counter += 1
if self.counter >= self.patience:
self.early_stop = True
return self.early_stop
class Trainer:
def __init__(
self,
model: nn.Module,
train_loader: DataLoader,
val_loader: DataLoader,
config: Dict,
device: torch.device,
save_dir: str = 'ml/checkpoints_v2'
):
self.model = model.to(device)
self.train_loader = train_loader
self.val_loader = val_loader
self.config = config
self.device = device
self.save_dir = Path(save_dir)
self.save_dir.mkdir(parents=True, exist_ok=True)
self.optimizer = AdamW(
model.parameters(),
lr=config['learning_rate'],
weight_decay=config['weight_decay']
)
self.scheduler = CosineAnnealingWarmRestarts(
self.optimizer, T_0=10, T_mult=2, eta_min=1e-6
)
self.criterion = AnomalyDetectionLoss()
self.early_stopping = EarlyStopping(
patience=config['patience'],
min_delta=config['min_delta']
)
self.use_amp = torch.cuda.is_available()
self.scaler = torch.cuda.amp.GradScaler() if self.use_amp else None
if self.use_amp:
print(" ✓ 启用 AMP 混合精度训练")
self.history = {'train_loss': [], 'val_loss': [], 'learning_rate': []}
self.best_val_loss = float('inf')
def train_epoch(self) -> float:
self.model.train()
total_loss = 0.0
n_batches = 0
pbar = tqdm(self.train_loader, desc="Training", leave=False)
for batch in pbar:
batch = batch.to(self.device, non_blocking=True)
self.optimizer.zero_grad(set_to_none=True)
if self.use_amp:
with torch.cuda.amp.autocast():
output, latent = self.model(batch)
loss, _ = self.criterion(output, batch, latent)
self.scaler.scale(loss).backward()
self.scaler.unscale_(self.optimizer)
torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.config['gradient_clip'])
self.scaler.step(self.optimizer)
self.scaler.update()
else:
output, latent = self.model(batch)
loss, _ = self.criterion(output, batch, latent)
loss.backward()
torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.config['gradient_clip'])
self.optimizer.step()
total_loss += loss.item()
n_batches += 1
pbar.set_postfix({'loss': f"{loss.item():.4f}"})
return total_loss / n_batches
@torch.no_grad()
def validate(self) -> float:
self.model.eval()
total_loss = 0.0
n_batches = 0
for batch in self.val_loader:
batch = batch.to(self.device, non_blocking=True)
if self.use_amp:
with torch.cuda.amp.autocast():
output, latent = self.model(batch)
loss, _ = self.criterion(output, batch, latent)
else:
output, latent = self.model(batch)
loss, _ = self.criterion(output, batch, latent)
total_loss += loss.item()
n_batches += 1
return total_loss / n_batches
def save_checkpoint(self, epoch: int, val_loss: float, is_best: bool = False):
model_to_save = self.model.module if hasattr(self.model, 'module') else self.model
checkpoint = {
'epoch': epoch,
'model_state_dict': model_to_save.state_dict(),
'optimizer_state_dict': self.optimizer.state_dict(),
'scheduler_state_dict': self.scheduler.state_dict(),
'val_loss': val_loss,
'config': self.config,
}
torch.save(checkpoint, self.save_dir / 'last_checkpoint.pt')
if is_best:
torch.save(checkpoint, self.save_dir / 'best_model.pt')
print(f" ✓ 保存最佳模型 (val_loss: {val_loss:.6f})")
def train(self, epochs: int):
print(f"\n开始训练 ({epochs} epochs)...")
print(f"设备: {self.device}")
print(f"模型参数量: {count_parameters(self.model):,}")
for epoch in range(1, epochs + 1):
print(f"\nEpoch {epoch}/{epochs}")
train_loss = self.train_epoch()
val_loss = self.validate()
self.scheduler.step()
current_lr = self.optimizer.param_groups[0]['lr']
self.history['train_loss'].append(train_loss)
self.history['val_loss'].append(val_loss)
self.history['learning_rate'].append(current_lr)
print(f" Train Loss: {train_loss:.6f}")
print(f" Val Loss: {val_loss:.6f}")
print(f" LR: {current_lr:.2e}")
is_best = val_loss < self.best_val_loss
if is_best:
self.best_val_loss = val_loss
self.save_checkpoint(epoch, val_loss, is_best)
if self.early_stopping(val_loss):
print(f"\n早停触发!")
break
print(f"\n训练完成!最佳验证损失: {self.best_val_loss:.6f}")
self.save_history()
return self.history
def save_history(self):
history_path = self.save_dir / 'training_history.json'
with open(history_path, 'w') as f:
json.dump(self.history, f, indent=2)
print(f"训练历史已保存: {history_path}")
if HAS_MATPLOTLIB:
self.plot_training_curves()
def plot_training_curves(self):
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
epochs = range(1, len(self.history['train_loss']) + 1)
ax1 = axes[0]
ax1.plot(epochs, self.history['train_loss'], 'b-', label='Train Loss', linewidth=2)
ax1.plot(epochs, self.history['val_loss'], 'r-', label='Val Loss', linewidth=2)
ax1.set_xlabel('Epoch')
ax1.set_ylabel('Loss')
ax1.set_title('Training & Validation Loss (V2)')
ax1.legend()
ax1.grid(True, alpha=0.3)
best_epoch = np.argmin(self.history['val_loss']) + 1
best_val_loss = min(self.history['val_loss'])
ax1.axvline(x=best_epoch, color='g', linestyle='--', alpha=0.7)
ax1.scatter([best_epoch], [best_val_loss], color='g', s=100, zorder=5)
ax2 = axes[1]
ax2.plot(epochs, self.history['learning_rate'], 'g-', linewidth=2)
ax2.set_xlabel('Epoch')
ax2.set_ylabel('Learning Rate')
ax2.set_title('Learning Rate Schedule')
ax2.set_yscale('log')
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(self.save_dir / 'training_curves.png', dpi=150, bbox_inches='tight')
plt.close()
print(f"训练曲线已保存")
# ==================== 阈值计算 ====================
@torch.no_grad()
def compute_thresholds(
model: nn.Module,
data_loader: DataLoader,
device: torch.device,
percentiles: List[float] = [90, 95, 99]
) -> Dict[str, float]:
"""在验证集上计算阈值"""
model.eval()
all_errors = []
print("计算异动阈值...")
for batch in tqdm(data_loader, desc="Computing thresholds"):
batch = batch.to(device)
errors = model.compute_reconstruction_error(batch, reduction='none')
seq_errors = errors[:, -1] # 最后一个时刻
all_errors.append(seq_errors.cpu().numpy())
all_errors = np.concatenate(all_errors)
thresholds = {}
for p in percentiles:
threshold = np.percentile(all_errors, p)
thresholds[f'p{p}'] = float(threshold)
print(f" P{p}: {threshold:.6f}")
thresholds['mean'] = float(np.mean(all_errors))
thresholds['std'] = float(np.std(all_errors))
thresholds['median'] = float(np.median(all_errors))
return thresholds
# ==================== 主函数 ====================
def main():
parser = argparse.ArgumentParser(description='训练 V2 模型')
parser.add_argument('--data_dir', type=str, default='ml/data_v2', help='V2 数据目录')
parser.add_argument('--epochs', type=int, default=100)
parser.add_argument('--batch_size', type=int, default=4096)
parser.add_argument('--lr', type=float, default=3e-4)
parser.add_argument('--device', type=str, default='auto')
parser.add_argument('--save_dir', type=str, default='ml/checkpoints_v2')
parser.add_argument('--train_end', type=str, default='2024-06-30')
parser.add_argument('--val_end', type=str, default='2024-09-30')
parser.add_argument('--seq_len', type=int, default=10, help='序列长度(分钟)')
args = parser.parse_args()
config = TRAIN_CONFIG.copy()
config['batch_size'] = args.batch_size
config['epochs'] = args.epochs
config['learning_rate'] = args.lr
config['train_end_date'] = args.train_end
config['val_end_date'] = args.val_end
config['seq_len'] = args.seq_len
if args.device == 'auto':
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
else:
device = torch.device(args.device)
print("=" * 60)
print("概念异动检测模型训练 V2Z-Score 特征)")
print("=" * 60)
print(f"数据目录: {args.data_dir}")
print(f"设备: {device}")
print(f"序列长度: {config['seq_len']} 分钟")
print(f"批次大小: {config['batch_size']}")
print(f"特征: {config['features']}")
print("=" * 60)
# 1. 加载数据
print("\n[1/6] 加载 V2 数据...")
date_data = load_data_by_date(args.data_dir, config['features'])
# 2. 划分数据集
print("\n[2/6] 划分数据集...")
train_data, val_data, test_data = split_data_by_date(
date_data, config['train_end_date'], config['val_end_date']
)
# 3. 构建序列
print("\n[3/6] 构建序列...")
print("训练集:")
train_sequences = build_sequences_by_concept(
train_data, config['features'], config['seq_len'], config['stride']
)
print("验证集:")
val_sequences = build_sequences_by_concept(
val_data, config['features'], config['seq_len'], config['stride']
)
if len(train_sequences) == 0:
print("错误: 训练集为空!")
return
# 4. 预处理
print("\n[4/6] 数据预处理...")
clip_value = config['clip_value']
print(f" Z-Score 特征已标准化,截断: ±{clip_value}")
train_sequences = np.clip(train_sequences, -clip_value, clip_value)
if len(val_sequences) > 0:
val_sequences = np.clip(val_sequences, -clip_value, clip_value)
# 保存配置
save_dir = Path(args.save_dir)
save_dir.mkdir(parents=True, exist_ok=True)
with open(save_dir / 'config.json', 'w') as f:
json.dump(config, f, indent=2)
# 5. 创建数据加载器
print("\n[5/6] 创建数据加载器...")
train_dataset = SequenceDataset(train_sequences)
val_dataset = SequenceDataset(val_sequences) if len(val_sequences) > 0 else None
print(f" 训练序列: {len(train_dataset):,}")
print(f" 验证序列: {len(val_dataset) if val_dataset else 0:,}")
n_gpus = torch.cuda.device_count() if torch.cuda.is_available() else 1
num_workers = min(32, 8 * n_gpus) if sys.platform != 'win32' else 0
train_loader = DataLoader(
train_dataset,
batch_size=config['batch_size'],
shuffle=True,
num_workers=num_workers,
pin_memory=True,
prefetch_factor=4 if num_workers > 0 else None,
persistent_workers=True if num_workers > 0 else False,
drop_last=True
)
val_loader = DataLoader(
val_dataset,
batch_size=config['batch_size'] * 2,
shuffle=False,
num_workers=num_workers,
pin_memory=True,
) if val_dataset else None
# 6. 训练
print("\n[6/6] 训练模型...")
model = TransformerAutoencoder(**config['model'])
if torch.cuda.device_count() > 1:
print(f" 使用 {torch.cuda.device_count()} 张 GPU 并行训练")
model = nn.DataParallel(model)
if val_loader is None:
print("警告: 验证集为空,使用训练集的 10% 作为验证")
split_idx = int(len(train_dataset) * 0.9)
train_subset = torch.utils.data.Subset(train_dataset, range(split_idx))
val_subset = torch.utils.data.Subset(train_dataset, range(split_idx, len(train_dataset)))
train_loader = DataLoader(train_subset, batch_size=config['batch_size'], shuffle=True, num_workers=num_workers, pin_memory=True)
val_loader = DataLoader(val_subset, batch_size=config['batch_size'], shuffle=False, num_workers=num_workers, pin_memory=True)
trainer = Trainer(
model=model,
train_loader=train_loader,
val_loader=val_loader,
config=config,
device=device,
save_dir=args.save_dir
)
trainer.train(config['epochs'])
# 计算阈值
print("\n[额外] 计算异动阈值...")
best_checkpoint = torch.load(save_dir / 'best_model.pt', map_location=device)
# 创建新的单 GPU 模型用于计算阈值(避免 DataParallel 问题)
threshold_model = TransformerAutoencoder(**config['model'])
threshold_model.load_state_dict(best_checkpoint['model_state_dict'])
threshold_model.to(device)
threshold_model.eval()
thresholds = compute_thresholds(threshold_model, val_loader, device, config['threshold_percentiles'])
with open(save_dir / 'thresholds.json', 'w') as f:
json.dump(thresholds, f, indent=2)
print("\n" + "=" * 60)
print("训练完成!")
print(f"模型保存位置: {args.save_dir}")
print("=" * 60)
if __name__ == "__main__":
main()

132
ml/update_baseline.py Normal file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
每日盘后运行:更新滚动基线
使用方法:
python ml/update_baseline.py
建议加入 crontab每天 15:30 后运行:
30 15 * * 1-5 cd /path/to/project && python ml/update_baseline.py
"""
import os
import sys
import pickle
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from pathlib import Path
from tqdm import tqdm
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml.prepare_data_v2 import (
get_all_concepts, get_trading_days, compute_raw_concept_features,
init_process_connections, CONFIG, RAW_CACHE_DIR, BASELINE_DIR
)
def update_rolling_baseline(baseline_days: int = 20):
"""
更新滚动基线(用于实盘检测)
基线 = 最近 N 个交易日每个时间片的统计量
"""
print("=" * 60)
print("更新滚动基线(用于实盘)")
print("=" * 60)
# 初始化连接
init_process_connections()
# 获取概念列表
concepts = get_all_concepts()
all_stocks = list(set(s for c in concepts for s in c['stocks']))
# 获取最近的交易日
today = datetime.now().strftime('%Y-%m-%d')
start_date = (datetime.now() - timedelta(days=60)).strftime('%Y-%m-%d') # 多取一些
trading_days = get_trading_days(start_date, today)
if len(trading_days) < baseline_days:
print(f"错误:交易日不足 {baseline_days}")
return
# 只取最近 N 天
recent_days = trading_days[-baseline_days:]
print(f"使用 {len(recent_days)} 天数据: {recent_days[0]} ~ {recent_days[-1]}")
# 加载原始数据
all_data = []
for trade_date in tqdm(recent_days, desc="加载数据"):
cache_file = os.path.join(RAW_CACHE_DIR, f'raw_{trade_date}.parquet')
if os.path.exists(cache_file):
df = pd.read_parquet(cache_file)
else:
df = compute_raw_concept_features(trade_date, concepts, all_stocks)
if not df.empty:
all_data.append(df)
if not all_data:
print("错误:无数据")
return
combined = pd.concat(all_data, ignore_index=True)
print(f"总数据量: {len(combined):,}")
# 按概念计算基线
baselines = {}
for concept_id, group in tqdm(combined.groupby('concept_id'), desc="计算基线"):
baseline_dict = {}
for time_slot, slot_group in group.groupby('time_slot'):
if len(slot_group) < CONFIG['min_baseline_samples']:
continue
alpha_std = slot_group['alpha'].std()
amt_std = slot_group['total_amt'].std()
rank_std = slot_group['rank_pct'].std()
baseline_dict[time_slot] = {
'alpha_mean': float(slot_group['alpha'].mean()),
'alpha_std': float(max(alpha_std if pd.notna(alpha_std) else 1.0, 0.1)),
'amt_mean': float(slot_group['total_amt'].mean()),
'amt_std': float(max(amt_std if pd.notna(amt_std) else slot_group['total_amt'].mean() * 0.5, 1.0)),
'rank_mean': float(slot_group['rank_pct'].mean()),
'rank_std': float(max(rank_std if pd.notna(rank_std) else 0.2, 0.05)),
'sample_count': len(slot_group),
}
if baseline_dict:
baselines[concept_id] = baseline_dict
print(f"计算了 {len(baselines)} 个概念的基线")
# 保存
os.makedirs(BASELINE_DIR, exist_ok=True)
baseline_file = os.path.join(BASELINE_DIR, 'realtime_baseline.pkl')
with open(baseline_file, 'wb') as f:
pickle.dump({
'baselines': baselines,
'update_time': datetime.now().isoformat(),
'date_range': [recent_days[0], recent_days[-1]],
'baseline_days': baseline_days,
}, f)
print(f"基线已保存: {baseline_file}")
print("=" * 60)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--days', type=int, default=20, help='基线天数')
args = parser.parse_args()
update_rolling_baseline(args.days)

View File

@@ -131,12 +131,14 @@
"eslint-plugin-prettier": "3.4.0",
"gulp": "4.0.2",
"gulp-append-prepend": "1.0.9",
"husky": "^9.1.7",
"imagemin": "^9.0.1",
"imagemin-mozjpeg": "^10.0.0",
"imagemin-pngquant": "^10.0.0",
"kill-port": "^2.0.1",
"less": "^4.4.2",
"less-loader": "^12.3.0",
"lint-staged": "^16.2.7",
"msw": "^2.11.5",
"prettier": "2.2.1",
"react-error-overlay": "6.0.9",

687
public/htmls/BOPET膜.html Normal file
View File

@@ -0,0 +1,687 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BOPET膜概念深度洞察深空数据终端</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- DaisyUI CDN -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.10.1/dist/full.min.css" rel="stylesheet" type="text/css" />
<!-- Alpine.js CDN -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.8/dist/cdn.min.js"></script>
<!-- Echarts CDN -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400..900&family=Space+Grotesk:wght@300..700&display=swap');
body {
font-family: 'Space Grotesk', sans-serif; /* Modern, slightly futuristic */
background: radial-gradient(circle at top left, rgba(6, 11, 40, 1) 0%, rgba(0, 0, 0, 1) 50%, rgba(6, 11, 40, 1) 100%);
background-attachment: fixed;
color: #E0E7FF; /* Light blue-grey for text */
overflow-x: hidden;
}
h1, h2, h3, h4, .font-orbitron {
font-family: 'Orbitron', sans-serif; /* Sci-fi feel for titles */
}
/* Glassmorphism card style */
.glass-card {
background: rgba(255, 255, 255, 0.08); /* Semi-transparent white */
backdrop-filter: blur(10px) brightness(1.2); /* Blur and slight brighten */
-webkit-backdrop-filter: blur(10px) brightness(1.2); /* For Safari */
border: 1px solid rgba(255, 255, 255, 0.15); /* Subtle border */
border-radius: 2rem; /* Extreme rounded corners */
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.2); /* Depth */
transition: all 0.3s ease-in-out;
position: relative;
z-index: 10;
}
.glass-card:hover {
box-shadow: 0 12px 48px 0 rgba(0, 0, 0, 0.3); /* More depth on hover */
transform: translateY(-2px); /* Slight lift */
}
/* Diffused background light effect */
.diffused-light {
position: absolute;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(147, 197, 253, 0.2) 0%, rgba(0, 0, 0, 0) 70%);
border-radius: 50%;
filter: blur(80px);
opacity: 0.7;
pointer-events: none;
z-index: 0;
}
.light-top-left { top: -50px; left: -50px; }
.light-bottom-right { bottom: -50px; right: -50px; }
.light-center { top: 50%; left: 50%; transform: translate(-50%, -50%); opacity: 0.4; width: 500px; height: 500px; }
/* Bento Grid styles */
.bento-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
@media (min-width: 1024px) {
.bento-grid-2 { grid-template-columns: repeat(2, 1fr); }
.bento-grid-3 { grid-template-columns: repeat(3, 1fr); }
.bento-grid-span-2 { grid-column: span 2; }
.bento-grid-row-span-2 { grid-row: span 2; }
}
/* Custom scrollbar for deep space feel */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #A78BFA, #3B82F6);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #C4B5FD, #60A5FA);
}
/* Adjust DaisyUI specific styles for contrast */
.collapse-title {
color: #C7D2FE; /* Lighter text for titles */
font-weight: 600;
}
.collapse-content {
color: #E0E7FF;
}
.badge-primary {
background-color: #6366F1; /* Indigo */
color: #ffffff;
}
.badge-secondary {
background-color: #EC4899; /* Pink */
color: #ffffff;
}
.badge-accent {
background-color: #10B981; /* Emerald */
color: #ffffff;
}
/* Echarts container sizing */
.chart-container {
width: 100%;
min-height: 300px; /* Ensure charts have a minimum height */
}
</style>
</head>
<body class="p-8 relative min-h-screen">
<!-- Diffused Background Lights -->
<div class="diffused-light light-top-left"></div>
<div class="diffused-light light-bottom-right"></div>
<div class="diffused-light light-center"></div>
<header class="text-center mb-16 relative z-20">
<h1 class="text-5xl font-extrabold text-white mb-4 tracking-wider font-orbitron">BOPET膜概念深度洞察</h1>
<p class="text-xl text-indigo-300 font-light">
北京价值前沿科技有限公司 AI投研agent“价小前投研” 进行投研呈现本报告为AI合成数据投资需谨慎。
</p>
</header>
<main class="container mx-auto space-y-12 relative z-20">
<!-- 概念事件与核心观点 -->
<section class="glass-card p-8 shadow-xl">
<h2 class="text-3xl font-bold mb-6 text-indigo-200 font-orbitron">01. 概念事件与核心观点</h2>
<div class="collapse collapse-arrow bg-base-100/10 mb-4 rounded-xl">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium text-indigo-300">
概念事件:行业自律与价格底部反转
</div>
<div class="collapse-content text-lg text-gray-200 leading-relaxed">
<p class="mb-4">BOPET膜概念近期核心事件围绕“行业自律”与“价格底部反转”展开。自 <strong>2022年下半年以来</strong>BOPET行业因严重的产能过剩和“以价换量”的恶性竞争导致企业盈利持续承压多数公司陷入亏损泥潭。</p>
<p class="mb-4"><strong>2024年10月</strong>行业迎来转折点在第11届聚酯薄膜产业大会上<strong>43家企业共同签署《PET行业自律自强宣言》</strong>倡导“以销定产”减少无效供给。此后以“BOPET龙头”<strong>双星新材 (002585.SZ)</strong> 为代表的企业,于近期发布减产通知,计划在现有基础上减产<strong>20%</strong>,并预计春节期间进一步增加减产幅度至<strong>50%</strong>左右,以落实国家治理价格无序竞争的要求。</p>
<p>受此系列“反内卷”措施影响BOPET价格开始偏强运行。据百川盈孚数据<strong>12月4日</strong><strong>12μ普通BOPET膜市场均价为7269元/吨较上一工作日上涨0.73%</strong>,价格端于历史低位区间趋稳,并有持续上涨趋势。多数公司已于 <strong>2025年一季度减亏</strong>,行业底部修复迹象显著,资本开支明显缩减,预示着行业供需格局和盈利状况有望改善。</p>
</div>
</div>
<div class="collapse collapse-arrow bg-base-100/10 rounded-xl">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium text-indigo-300">
核心观点摘要
</div>
<div class="collapse-content text-lg text-gray-200 leading-relaxed">
<p>BOPET膜行业正经历从周期底部向复苏的转折核心驱动力在于行业自律引发的供给侧优化与结构性高端化转型。短期内减产保价策略将推动盈利改善长期看具备成本优势和在光学、新能源等高附加值领域实现国产替代的企业将实现价值重估。</p>
</div>
</div>
</section>
<!-- 行业概况与市场动态 -->
<section class="glass-card p-8 shadow-xl">
<h2 class="text-3xl font-bold mb-6 text-indigo-200 font-orbitron">02. 行业概况与市场动态</h2>
<div class="bento-grid bento-grid-2">
<div class="glass-card p-6 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-emerald-300">定义、分类与应用</h3>
<ul class="list-disc list-inside text-gray-200 space-y-2">
<li><strong>定义:</strong> 双向拉伸聚酯薄膜 (BOPET) 是一种性能优良的薄膜材料。</li>
<li><strong>分类:</strong> 分通用类薄型625 μm、中型2565 μm与功能类超薄&lt;6 μm、厚型&gt;65 μm</li>
<li><strong>广泛应用:</strong> 包装印刷 (≈45.8%)、光学显示 (≈10.2%)、光伏背板 (1213%)、电力电气、医疗包装、防水/建筑等工业用途。</li>
<li><strong>包装地位:</strong> 继BOPP薄膜之后与BOPA薄膜并列的包装材料之一。</li>
</ul>
</div>
<div class="glass-card p-6 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-emerald-300">生产工艺与技术</h3>
<ul class="list-disc list-inside text-gray-200 space-y-2">
<li><strong>主要方法:</strong>
<ul class="list-disc list-inside ml-4">
<li>切片法原料聚酯切片成本高、差异化强。2024年起新投项目以切片法为主。</li>
<li>熔融法原料PTA+MEG成本低、品种受限</li>
</ul>
</li>
<li><strong>恒力石化康辉新材:</strong> 汾湖基地12条布鲁克纳BOPET生产线已投产聚焦光学电子膜、医疗膜等高端领域南通基地12条产线3条投产其余2025年H1完成新增24万吨/年产能。</li>
</ul>
</div>
<div class="glass-card p-6 shadow-md bento-grid-span-2">
<h3 class="text-2xl font-bold mb-4 text-emerald-300">供需分析与产能结构</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-gray-200">
<div>
<ul class="list-disc list-inside space-y-2">
<li><strong>产能扩张:</strong> 2014-2024年国内BOPET产能从247万吨扩张至695万吨 (CAGR 10.9%)。</li>
<li><strong>产量增长:</strong> 2014-2024年产量从161万吨增长至459万吨 (CAGR 11.0%)。</li>
<li><strong>表观消费量:</strong> 2024年415万吨 (同比+8.2%),低于产能/产量增速行业供过于求。2025年国内需求354万吨 (+5.4%)。</li>
<li><strong>产能结构 (2024年)</strong> 总产能648.2万吨/年 (68家企业)CR10=63%,中小产能 (&lt;5万吨) 39家合计93.5万吨占14.4%。</li>
</ul>
</div>
<div>
<ul class="list-disc list-inside space-y-2">
<li><strong>新增产能:</strong> 规划在建87.2万吨/年预计2025-2027年陆续投放。2025年国内产能749万吨 (+7.8%)。</li>
<li><strong>进出口:</strong> 2024年中国为净出口国净出口44.2万吨 (+50%)。2025H1进口12.6万吨 (-3.9%)出口33.9万吨 (+14%)。</li>
<li><strong>高端依赖:</strong> 进口均价是出口均价的2倍以上高端产品依赖日本、韩国、中国台湾、美国进口。</li>
<li><strong>供需差:</strong> 2025年供需差91万吨为近年峰值显示行业仍供过于求。</li>
</ul>
</div>
</div>
</div>
<div class="glass-card p-6 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-emerald-300">价格走势与盈利状况</h3>
<ul class="list-disc list-inside text-gray-200 space-y-2">
<li><strong>价格水平:</strong> 22年以来持续下跌2025年7月初价格为7,640元/吨处于2020年以来2.9%历史低位。2025年8月13日均价7,410元/吨,同比-9.2%,较年初-9%。</li>
<li><strong>近期波动:</strong> 12月4日12μ普通BOPET膜市场均价7269元/吨较上一工作日上涨0.73%。企业签署自律减产协议后本周均价已涨1.9%。</li>
<li><strong>成本结构:</strong> BOPET价格与原油价格走势趋同切片法成本略高于直熔法。</li>
<li><strong>行业盈利:</strong> 2022年以来持续承压2024年由盈转亏 (8家主营上市公司归母净利-6.5亿元)。2025Q1营收46.1亿元 (+3.4%),归母净利-0.1亿元 (同比减亏0.9亿元)。</li>
<li><strong>底部修复:</strong> 行业自律减产后多数公司已于2025年一季度减亏。</li>
</ul>
</div>
<div class="glass-card p-6 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-emerald-300">政策与行业自律</h3>
<ul class="list-disc list-inside text-gray-200 space-y-2">
<li><strong>行业倡议:</strong> 2024年10月43家企业签署《PET行业自律自强宣言》倡导“以销定产”、减少无效供给、防止恶性竞争。</li>
<li><strong>减产措施:</strong> 双星新材带头减产20%春节期间预计达50%。</li>
<li><strong>格局优化:</strong> 政策端“反内卷”持续发力,有望推动供需再平衡、格局优化。资源向成本和技术优势企业集中。</li>
<li><strong>国际壁垒:</strong> 印度已提交对孟加拉、中国、泰国、美国产BOPET的反倾销调查申请美国自2025年8月起印度输美BOPET关税从25%提至50%。</li>
</ul>
</div>
<!-- Echarts: 下游应用占比 -->
<div class="glass-card p-6 shadow-md bento-grid-span-2">
<h3 class="text-2xl font-bold mb-4 text-emerald-300">BOPET膜下游应用结构</h3>
<div id="downstream-app-chart" class="chart-container"></div>
</div>
</div>
</section>
<!-- 概念的核心逻辑与市场认知 -->
<section class="glass-card p-8 shadow-xl">
<h2 class="text-3xl font-bold mb-6 text-indigo-200 font-orbitron">03. 概念的核心逻辑与市场认知</h2>
<div class="bento-grid bento-grid-2">
<div class="glass-card p-6 shadow-md bento-grid-span-2">
<h3 class="text-2xl font-bold mb-4 text-purple-300">核心驱动力</h3>
<ul class="list-disc list-inside text-gray-200 space-y-2 leading-relaxed">
<li><strong>周期底部反转与供给侧改革:</strong> 行业主动去产能、优化供给从“内卷”走向“反内卷”是扭转颓势、推动价格回升和利润修复的根本动力。2024年8家主营上市公司归母净利由盈转亏-6.5亿元,催生了行业自律的必要性。</li>
<li><strong>高端化与差异化转型:</strong> 虽然通用BOPET膜竞争激烈但光学膜、新能源电池膜、医疗膜、阻燃膜等高端产品仍存在进口依赖进口均价是出口均价的2倍以上。技术创新和产品升级实现国产替代是行业新的增长点。</li>
<li><strong>成本与技术优势集中:</strong> 行业低谷加速优胜劣汰,资源向具备成本控制(如恒力石化)和技术研发优势(如洁美科技、和顺科技)的企业集中,提升行业集中度,优化竞争格局。</li>
</ul>
</div>
<div class="glass-card p-6 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-purple-300">市场热度与情绪</h3>
<p class="text-gray-200 leading-relaxed">
市场对BOPET概念的关注度正在升温情绪从过去的悲观转向谨慎乐观。新闻报道中“反内卷初见成效”、“价格继续上涨”等字眼以及减产消息的发布都反映了市场对行业底部反转的期待。研报密集度也有所增加普遍提及行业自律和盈利改善的预期表明机构对该领域的关注度提升。然而路演数据中“2024年行业供过于求”、“BOPET行业周期底部”等表述也提示市场并非盲目乐观仍存在对产能释放、需求增速和自律执行力的担忧。整体情绪是“触底反弹但前路漫漫”期待供给侧的持续优化和高端化突破。
</p>
</div>
<div class="glass-card p-6 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-purple-300">预期差分析</h3>
<ul class="list-disc list-inside text-gray-200 space-y-2 leading-relaxed">
<li><strong>自律的执行强度与持续性:</strong> 市场可能低估了短期内龙头企业执行减产的决心及其对价格的拉动作用。历史经验表明,市场在价格回暖后是否能持续保持理性,存在不确定性。</li>
<li><strong>高端产品国产替代的加速:</strong> 多家公司在光学、新能源、医疗等领域的具体布局和客户验证进展表明高端国产替代正在加速落地,其对公司盈利结构改善的贡献可能被市场低估。</li>
<li><strong>全球化竞争与贸易壁垒:</strong> 印度对中国BOPET的反倾销调查和美国对印度BOPET的关税提升显示全球BOPET行业仍处于过剩状态且贸易壁垒风险不容忽视这可能未被充分纳入国内市场的定价模型中。</li>
</ul>
</div>
</div>
</section>
<!-- 关键催化剂与未来发展路径 -->
<section class="glass-card p-8 shadow-xl">
<h2 class="text-3xl font-bold mb-6 text-indigo-200 font-orbitron">04. 关键催化剂与未来发展路径</h2>
<div class="bento-grid bento-grid-2">
<div class="glass-card p-6 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-cyan-300">近期催化剂 (未来3-6个月)</h3>
<ul class="list-disc list-inside text-gray-200 space-y-2 leading-relaxed">
<li><strong>行业减产执行到位与价格持续上涨:</strong> 双星新材“减产20%春节期间达50%”的举措若能有效执行并带动其他企业跟进将直接推高BOPET产品价格。</li>
<li><strong>上市公司2025年一季度财报验证盈利改善</strong> 若实际业绩能超预期扭亏或大幅减亏,将成为验证行业底部反转的强力催化剂。</li>
<li><strong>高端产品新产能投产与客户验证进展:</strong> 和顺科技募投的3.8万吨BOPET光学基膜产能计划2024年5月投产洁美科技BOPET膜二期2万吨产能预计2025年二季度试生产。</li>
<li><strong>国家层面“反内卷”政策的进一步细化或落地:</strong> 更具约束力的政策出台将进一步巩固行业自律成果。</li>
</ul>
</div>
<div class="glass-card p-6 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-cyan-300">长期发展路径</h3>
<ul class="list-disc list-inside text-gray-200 space-y-2 leading-relaxed">
<li><strong>行业集中度持续提升:</strong> 市场化整合和淘汰落后产能将使CR10持续上升优化竞争格局。</li>
<li><strong>高端化、差异化产品实现规模化国产替代:</strong> 从通用包装膜向光学膜、新能源电池膜、光伏背板、医疗包装、特种工业膜等高附加值领域全面渗透。</li>
<li><strong>绿色化、环保化趋势:</strong> 生产将更注重能耗降低和废弃物回收再利用再生BOPETrPET材料应用普及。</li>
<li><strong>全球化市场拓展与应对贸易摩擦:</strong> 中国BOPET企业需有效应对各国日益增多的贸易保护主义措施。</li>
</ul>
</div>
</div>
</section>
<!-- 产业链与核心公司深度剖析 -->
<section class="glass-card p-8 shadow-xl">
<h2 class="text-3xl font-bold mb-6 text-indigo-200 font-orbitron">05. 产业链与核心公司深度剖析</h2>
<div class="glass-card p-6 mb-8 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-teal-300">产业链图谱</h3>
<p class="text-gray-200 leading-relaxed">
<strong>上游:</strong> 主要为聚酯切片切片法或PTA+MEG直熔法<br>
<strong>中游:</strong> BOPET膜生产商涵盖通用型BOPET膜和各类功能性BOPET膜光学膜、光伏膜、电池膜、离型膜等<br>
<strong>下游:</strong> 应用领域广泛,包括包装印刷(~45%)、光学显示(~10%)、光伏背板(~12-13%)、医疗、新能源电池(隔膜、阻燃膜)、防水、建筑、车膜等工业用途。
</p>
</div>
<h3 class="text-2xl font-bold mb-4 text-teal-300">核心玩家对比</h3>
<div class="bento-grid bento-grid-2">
<!-- 双星新材 -->
<div class="glass-card p-6 shadow-md">
<h4 class="text-xl font-bold text-amber-300 mb-2">双星新材 (002585.SZ)</h4>
<p class="text-gray-300"><strong>现有产能:</strong> 90.0万吨/年</p>
<p class="text-gray-300"><strong>特点:</strong> 行业龙头,产能最大,品类最全,在行业自律中发挥带头作用。</p>
<p class="text-gray-300"><strong>进展:</strong> 发布减产通知,积极应对价格战,有望率先受益于价格回升。</p>
<p class="text-red-400 text-sm"><strong>风险提示:</strong> 远期产能数据1376万吨存在巨大不确定性需警惕过度解读。</p>
</div>
<!-- 恒力石化 -->
<div class="glass-card p-6 shadow-md">
<h4 class="text-xl font-bold text-amber-300 mb-2">恒力石化 (600346.SH)</h4>
<p class="text-gray-300"><strong>现有产能:</strong> 76.8万吨/年;<strong>在建产能:</strong> 22.3万吨/年</p>
<p class="text-gray-300"><strong>特点:</strong> PTA-MEG一体化优势成本控制能力强。康辉新材聚焦光学电子膜、医疗膜等高端领域。</p>
<p class="text-gray-300"><strong>进展:</strong> 南通基地多条产线规划投产,有望成第一大产能。</p>
<p class="text-red-400 text-sm"><strong>风险提示:</strong> 传统普膜受竞争拖累,高端产品放量速度是关键。</p>
</div>
<!-- 裕兴股份 -->
<div class="glass-card p-6 shadow-md">
<h4 class="text-xl font-bold text-amber-300 mb-2">裕兴股份 (300305.SZ)</h4>
<p class="text-gray-300"><strong>现有产能:</strong> 25.0万吨/年</p>
<p class="text-gray-300"><strong>特点:</strong> 光伏用膜占比超60%,在光伏领域有较深积累。</p>
<p class="text-gray-300"><strong>进展:</strong> 2022-2024毛利率由正转负显示专业化公司在行业低谷期的盈利脆弱性。</p>
<p class="text-red-400 text-sm"><strong>风险提示:</strong> 新能源需求增速放缓。</p>
</div>
<!-- 东材科技 -->
<div class="glass-card p-6 shadow-md">
<h4 class="text-xl font-bold text-amber-300 mb-2">东材科技 (601208.SH)</h4>
<p class="text-gray-300"><strong>现有产能:</strong> 23.3万吨/年</p>
<p class="text-gray-300"><strong>特点:</strong> 光学膜盈利能力强,定位中高端,毛利率显著高于行业平均。</p>
<p class="text-red-400 text-sm"><strong>风险提示:</strong> 整体BOPET产能规模相对较小对行业周期性波动的抵御能力需关注。</p>
</div>
<!-- 和顺科技 -->
<div class="glass-card p-6 shadow-md">
<h4 class="text-xl font-bold text-amber-300 mb-2">和顺科技 (301237.SZ)</h4>
<p class="text-gray-300"><strong>现有产能:</strong> 11.5万吨/年</p>
<p class="text-gray-300"><strong>特点:</strong> 差异化功能性BOPET膜专家 (有色光电机膜、超模太阳能背板基膜、车衣保护膜、高亮膜),产品高端化布局明确。</p>
<p class="text-gray-300"><strong>进展:</strong> 募投3.8万吨光学基膜产能计划2024年5月投产并启动高亮膜批量生产。</p>
<p class="text-red-400 text-sm"><strong>风险提示:</strong> 2024年因新产能爬坡预计亏损高端产品市场接受度与爬坡效率是关键。</p>
</div>
<!-- 洁美科技 -->
<div class="glass-card p-6 shadow-md">
<h4 class="text-xl font-bold text-amber-300 mb-2">洁美科技 (002859.SZ)</h4>
<p class="text-gray-300"><strong>现有产能:</strong> 1.8万吨/年;<strong>在建产能:</strong> 2.0万吨/年</p>
<p class="text-gray-300"><strong>特点:</strong> 基膜自供一体化打破外资垄断在MLCC离型膜、复合铜箔等高端应用有独特优势和布局。</p>
<p class="text-gray-300"><strong>进展:</strong> BOPET膜二期2万吨产能预计2025年二季度试生产MLCC离型膜已实现稳定批量供货并进入韩日系大客户验证。</p>
<p class="text-red-400 text-sm"><strong>风险提示:</strong> 现有BOPET产能规模较小但增长迅速。</p>
</div>
<!-- 其他公司 -->
<div class="glass-card p-6 shadow-md bento-grid-span-2">
<h4 class="text-xl font-bold text-amber-300 mb-2">其他相关公司概览</h4>
<ul class="list-disc list-inside text-gray-200 space-y-2">
<li><strong>大东南 (002263.SZ):</strong> 上半年扭亏为盈BOPET薄膜、光学膜毛利率改善经营效益提升。</li>
<li><strong>长阳科技 (688299.SH):</strong> “膜类产品专家”涉足固态电池电解质基膜等前沿领域多元化布局BOPET并非其核心业务。</li>
<li><strong>百宏实业 (2299.HK):</strong> 具备较大BOPET产能。</li>
<li><strong>荣盛石化 (002493.SZ):</strong> 具备较大BOPET产能。</li>
<li><strong>航天彩虹 (002389.SZ):</strong> 光学膜盈利能力较强。</li>
<li><strong>国风新材 (000859.SZ):</strong> 具备BOPET产能在建产能较大。</li>
<li><strong>斯迪克 (300806.SH):</strong> 具备BOPET产能。</li>
</ul>
</div>
</div>
<!-- Echarts: Top Companies Existing Capacity -->
<div class="glass-card p-6 mt-8 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-emerald-300">核心BOPET企业现有产能对比 (万吨/年)</h3>
<div id="company-capacity-chart" class="chart-container"></div>
</div>
</section>
<!-- 潜在风险与挑战 -->
<section class="glass-card p-8 shadow-xl">
<h2 class="text-3xl font-bold mb-6 text-indigo-200 font-orbitron">06. 潜在风险与挑战</h2>
<div class="bento-grid bento-grid-2">
<div class="glass-card p-6 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-red-400">技术风险</h3>
<ul class="list-disc list-inside text-gray-200 space-y-2 leading-relaxed">
<li><strong>高端产品国产替代的技术瓶颈:</strong> 在光学膜、医疗膜等领域,对膜的均匀性、表面处理、物理性能要求极高,研发投入大,成功实现大规模量产和客户认证仍需时间。</li>
<li><strong>新品开发不及预期:</strong> 和顺科技在研的高透光学膜、窗膜等能否达到性能要求并被市场广泛接受,仍需观察。</li>
</ul>
</div>
<div class="glass-card p-6 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-red-400">商业化风险</h3>
<ul class="list-disc list-inside text-gray-200 space-y-2 leading-relaxed">
<li><strong>新增产能的持续投放:</strong> 规划在建87.2万吨产能预计20252027年陆续投放可能再次引发阶段性供需失衡导致价格承压。</li>
<li><strong>下游需求增速不及预期:</strong> “新能源需求增速放缓”等风险提示,以及光伏背板因装机节奏放缓占比略降,都表明下游需求可能影响部分专业化公司的业绩。</li>
<li><strong>原材料价格波动:</strong> BOPET价格与原油价格走势趋同PET粒子等原材料价格的上涨可能侵蚀企业的盈利空间。</li>
</ul>
</div>
<div class="glass-card p-6 shadow-md bento-grid-span-2">
<h3 class="text-2xl font-bold mb-4 text-red-400">政策与竞争风险</h3>
<ul class="list-disc list-inside text-gray-200 space-y-2 leading-relaxed">
<li><strong>行业自律执行的持续性与有效性:</strong> 最大的风险在于一旦价格回升,企业逐利本性是否会导致“以价换量”卷土重来。</li>
<li><strong>国际贸易摩擦加剧:</strong> 印度对中国、泰国等国BOPET的反倾销调查以及美国对印度BOPET关税的提升可能影响中国BOPET企业的出口策略和利润。</li>
<li><strong>中小产能的韧性:</strong> 中小产能39家合计93.5万吨)的退出速度和其对市场价格的潜在冲击仍需观察。</li>
<li><strong>信息交叉验证风险:</strong> 双星新材“远期产能(万吨/年1376”这一数据异常巨大与实际投产距离甚远不宜作为近期产能考量。</li>
</ul>
</div>
</div>
</section>
<!-- 综合结论与投资启示 -->
<section class="glass-card p-8 shadow-xl">
<h2 class="text-3xl font-bold mb-6 text-indigo-200 font-orbitron">07. 综合结论与投资启示</h2>
<p class="mb-6 text-lg text-gray-200 leading-relaxed">
BOPET膜行业正处于一个重要的历史性拐点。从过去数年的产能过剩、价格战泥潭中走出进入到“行业自律+结构升级”的双轮驱动阶段。目前,我们判断该概念已经从纯粹的主题炒作阶段,<strong>初步进入基本面驱动的早期修复阶段</strong>。减产保价的措施已初见成效,盈利底部修复的预期正在形成,而高端化、差异化产品的国产替代则为行业提供了结构性增长的长期逻辑。
</p>
<div class="bento-grid bento-grid-2">
<div class="glass-card p-6 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-lime-300">最具投资价值的细分环节或方向</h3>
<ul class="list-disc list-inside text-gray-200 space-y-2 leading-relaxed">
<li><strong>行业龙头:</strong> 具备规模优势、成本控制能力,并积极参与行业自律的企业。它们在市场份额、议价能力和抵御风险方面更具优势,将率先受益于行业盈利的修复。</li>
<li><strong>高端功能膜领域:</strong> 在光学膜、新能源电池膜(包括复合铜箔基膜、阻燃膜)、医疗膜等高附加值细分市场拥有核心技术和客户壁垒,并积极实现国产替代的企业。</li>
<li><strong>垂直一体化或具备基膜自供能力的企业:</strong> 这类企业能更好地控制成本、保证产品质量和响应市场需求,具备更强的竞争力。</li>
</ul>
</div>
<div class="glass-card p-6 shadow-md">
<h3 class="text-2xl font-bold mb-4 text-lime-300">需要重点跟踪和验证的关键指标</h3>
<ul class="list-disc list-inside text-gray-200 space-y-2 leading-relaxed">
<li><strong>BOPET产品价格的持续性上涨</strong> 密切关注百川盈孚等价格指数,确认价格上涨的趋势和幅度。</li>
<li><strong>上市公司盈利能力改善:</strong> 尤其是Q1 2025及后续季度财报中毛利率和净利润的环比、同比变化。</li>
<li><strong>行业库存水平:</strong> 通过观察主要企业的产销率和库存周转天数来间接判断供需平衡状况。</li>
<li><strong>高端产品出货量及客户渗透率:</strong> 跟踪各公司在光学、新能源等领域新产品的量产、出货量增长。</li>
<li><strong>新增产能的实际落地速度:</strong> 关注规划在建产能的实际投产节奏,以判断未来供给端压力。</li>
</ul>
</div>
</div>
</section>
<!-- 股票数据 -->
<section class="glass-card p-8 shadow-xl mt-12">
<h2 class="text-3xl font-bold mb-6 text-indigo-200 font-orbitron">08. BOPET膜概念相关股票</h2>
<div class="overflow-x-auto">
<table class="table w-full text-lg text-white">
<thead>
<tr class="bg-blue-800/30 text-indigo-100">
<th class="rounded-tl-2xl">股票名称</th>
<th>股票代码</th>
<th>现有产能 (万吨/年)</th>
<th>在建产能 (万吨/年)</th>
<th class="rounded-tr-2xl">总产能 (万吨/年)</th>
</tr>
</thead>
<tbody>
<tr class="hover:bg-white/5">
<td>双星新材 <span class="badge badge-secondary ml-2">龙头</span></td>
<td><a href="https://valuefrontier.cn/company?scode=002585" target="_blank" class="link link-hover text-indigo-400 hover:text-indigo-200">002585</a></td>
<td>90.0</td>
<td>90.0</td>
<td>180.0</td> <!-- 90现有+90在建 -->
</tr>
<tr class="hover:bg-white/5">
<td>恒力石化</td>
<td><a href="https://valuefrontier.cn/company?scode=600346" target="_blank" class="link link-hover text-indigo-400 hover:text-indigo-200">600346</a></td>
<td>76.8</td>
<td>22.3</td>
<td>99.1</td>
</tr>
<tr class="hover:bg-white/5">
<td>百宏实业(港)</td>
<td>2299.HK</td>
<td>70.3</td>
<td>70.3</td>
<td>140.6</td> <!-- Assuming in-construction is 70.3 from news, roadshow mentioned 70.3 for both, will use 70.3 in-construction -->
</tr>
<tr class="hover:bg-white/5">
<td>荣盛石化</td>
<td><a href="https://valuefrontier.cn/company?scode=002493" target="_blank" class="link link-hover text-indigo-400 hover:text-indigo-200">002493</a></td>
<td>43.0</td>
<td>43.0</td>
<td>86.0</td> <!-- Assuming in-construction is 43.0 from news -->
</tr>
<tr class="hover:bg-white/5">
<td>裕兴股份</td>
<td><a href="https://valuefrontier.cn/company?scode=300305" target="_blank" class="link link-hover text-indigo-400 hover:text-indigo-200">300305</a></td>
<td>25.0</td>
<td>8.0</td>
<td>33.0</td>
</tr>
<tr class="hover:bg-white/5">
<td>东材科技</td>
<td><a href="https://valuefrontier.cn/company?scode=601208" target="_blank" class="link link-hover text-indigo-400 hover:text-indigo-200">601208</a></td>
<td>23.3</td>
<td>2.5</td>
<td>25.8</td>
</tr>
<tr class="hover:bg-white/5">
<td>大东南</td>
<td><a href="https://valuefrontier.cn/company?scode=002263" target="_blank" class="link link-hover text-indigo-400 hover:text-indigo-200">002263</a></td>
<td>14.0</td>
<td>14.0</td>
<td>28.0</td> <!-- Assuming in-construction is 14.0 from news -->
</tr>
<tr class="hover:bg-white/5">
<td>和顺科技</td>
<td><a href="https://valuefrontier.cn/company?scode=301237" target="_blank" class="link link-hover text-indigo-400 hover:text-indigo-200">301237</a></td>
<td>11.5</td>
<td>11.5</td>
<td>23.0</td> <!-- Assuming in-construction is 11.5 from news -->
</tr>
<tr class="hover:bg-white/5">
<td>长阳科技</td>
<td><a href="https://valuefrontier.cn/company?scode=688299" target="_blank" class="link link-hover text-indigo-400 hover:text-indigo-200">688299</a></td>
<td>7.5</td>
<td>2.0</td>
<td>9.5</td>
</tr>
<tr class="hover:bg-white/5">
<td>航天彩虹</td>
<td><a href="https://valuefrontier.cn/company?scode=002389" target="_blank" class="link link-hover text-indigo-400 hover:text-indigo-200">002389</a></td>
<td>7.0</td>
<td>7.0</td>
<td>14.0</td> <!-- Assuming in-construction is 7.0 from news -->
</tr>
<tr class="hover:bg-white/5">
<td>国风新材</td>
<td><a href="https://valuefrontier.cn/company?scode=000859" target="_blank" class="link link-hover text-indigo-400 hover:text-indigo-200">000859</a></td>
<td>5.6</td>
<td>6.0</td>
<td>11.6</td>
</tr>
<tr class="hover:bg-white/5">
<td>斯迪克</td>
<td><a href="https://valuefrontier.cn/company?scode=300806" target="_blank" class="link link-hover text-indigo-400 hover:text-indigo-200">300806</a></td>
<td>2.5</td>
<td>2.5</td>
<td>5.0</td> <!-- Assuming in-construction is 2.5 from news -->
</tr>
<tr class="hover:bg-white/5">
<td>洁美科技</td>
<td><a href="https://valuefrontier.cn/company?scode=002859" target="_blank" class="link link-hover text-indigo-400 hover:text-indigo-200">002859</a></td>
<td>1.8</td>
<td>2.0</td>
<td>3.8</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
<footer class="text-center text-gray-500 mt-16 pb-8 relative z-20">
<p>&copy; 2024 ValueFrontier. All rights reserved.</p>
</footer>
<script>
// Echarts for Downstream Application Distribution
var downstreamAppChart = echarts.init(document.getElementById('downstream-app-chart'));
var downstreamAppOption = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 255, 255, 0.3)',
textStyle: {
color: '#E0E7FF'
}
},
legend: {
orient: 'vertical',
left: 'left',
textStyle: {
color: '#C7D2FE'
},
data: ['包装印刷 (45.8%)', '离保膜 (14.7%)', '光学膜 (10.2%)', '太阳能背板 (4.9%)', '其他工业用途']
},
series: [
{
name: '下游应用占比',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: '20',
fontWeight: 'bold',
color: '#E0E7FF'
}
},
labelLine: {
show: false
},
data: [
{ value: 45.8, name: '包装印刷 (45.8%)' },
{ value: 14.7, name: '离保膜 (14.7%)' },
{ value: 10.2, name: '光学膜 (10.2%)' },
{ value: 4.9, name: '太阳能背板 (4.9%)' },
{ value: 100 - 45.8 - 14.7 - 10.2 - 4.9, name: '其他工业用途' } // Remaining percentage
],
itemStyle: {
borderRadius: 8,
borderColor: 'rgba(255, 255, 255, 0.15)',
borderWidth: 2
}
}
],
color: ['#60A5FA', '#8B5CF6', '#EC4899', '#10B981', '#F59E0B'] // Tailwind-inspired colors
};
downstreamAppChart.setOption(downstreamAppOption);
// Echarts for Top Companies Existing Capacity
var companyCapacityChart = echarts.init(document.getElementById('company-capacity-chart'));
var companyCapacityOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 255, 255, 0.3)',
textStyle: {
color: '#E0E7FF'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value',
boundaryGap: [0, 0.01],
axisLabel: {
formatter: '{value} 万吨/年',
color: '#C7D2FE'
},
splitLine: {
lineStyle: {
color: 'rgba(255,255,255,0.1)'
}
}
},
yAxis: {
type: 'category',
data: ['双星新材', '恒力石化', '百宏实业(港)', '荣盛石化', '裕兴股份', '东材科技'],
axisLabel: {
color: '#C7D2FE'
}
},
series: [
{
name: '现有产能',
type: 'bar',
data: [90.0, 76.8, 70.3, 43.0, 25.0, 23.3],
itemStyle: {
borderRadius: [0, 10, 10, 0], // Rounded corners on the right side
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#3B82F6' }, // Blue
{ offset: 1, color: '#60A5FA' } // Light blue
])
},
barWidth: '60%'
}
]
};
companyCapacityChart.setOption(companyCapacityOption);
// Make charts responsive
window.addEventListener('resize', function() {
downstreamAppChart.resize();
companyCapacityChart.resize();
});
</script>
</body>
</html>

799
public/htmls/SpaceX.html Normal file
View File

@@ -0,0 +1,799 @@
北京价值前沿科技有限公司 AI投研agent“价小前投研” 进行投研呈现本报告为AI合成数据投资需谨慎。
<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SpaceX概念深度投研报告</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/daisyui@1.16.0/dist/full.css" rel="stylesheet" type="text/css" />
<script src="https://unpkg.com/alpinejs" defer></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.3.0/dist/echarts.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
background: linear-gradient(135deg, #0a0a0f 0%, #1e0a29 50%, #0a0a0f 100%);
color: #E0E7FF;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
overflow-x: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: -20%;
left: -20%;
width: 60%;
height: 60%;
background: radial-gradient(circle at top left, rgba(103, 58, 183, 0.3) 0%, transparent 70%);
filter: blur(100px);
z-index: 0;
animation: moveGradient1 15s infinite alternate ease-in-out;
}
body::after {
content: '';
position: absolute;
bottom: -20%;
right: -20%;
width: 60%;
height: 60%;
background: radial-gradient(circle at bottom right, rgba(233, 30, 99, 0.2) 0%, transparent 70%);
filter: blur(100px);
z-index: 0;
animation: moveGradient2 20s infinite alternate ease-in-out;
}
@keyframes moveGradient1 {
0% { transform: translate(0, 0); }
100% { transform: translate(20%, 20%); }
}
@keyframes moveGradient2 {
0% { transform: translate(0, 0); }
100% { transform: translate(-20%, -20%); }
}
.container-wrapper {
max-width: 1400px;
width: 100%;
z-index: 10;
}
.glass-card {
background-color: rgba(30, 30, 50, 0.4); /* Darker, slightly opaque */
backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 3rem; /* Extremely rounded */
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
padding: 2rem;
margin-bottom: 2rem;
transition: all 0.3s ease;
position: relative;
overflow: hidden; /* For inner elements */
}
.glass-card::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, rgba(255,255,255,0.05) 0%, transparent 50%, rgba(255,255,255,0.05) 100%);
opacity: 0;
transition: opacity 0.5s ease-in-out;
pointer-events: none;
}
.glass-card:hover::before {
opacity: 1;
}
.text-neon-blue {
color: #87CEEB; /* SkyBlue, for highlights */
}
.text-neon-purple {
color: #BD93F9; /* Light Purple */
}
.text-neon-pink {
color: #FF79C6; /* Pink */
}
.btn-glass {
background-color: rgba(60, 60, 80, 0.6);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #E0E7FF;
border-radius: 1.5rem;
transition: all 0.2s ease;
}
.btn-glass:hover {
background-color: rgba(90, 90, 110, 0.8);
border-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
}
.badge-glass {
background-color: rgba(70, 70, 90, 0.5);
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #E0E7FF;
border-radius: 1rem;
padding: 0.5rem 1rem;
font-size: 0.8rem;
}
.bento-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.bento-grid-item {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 200px;
}
h2, h3, h4 {
color: #a78bfa; /* Slightly brighter purple for headings */
margin-bottom: 1rem;
font-weight: 600;
}
h2 { font-size: 2.25rem; }
h3 { font-size: 1.875rem; }
h4 { font-size: 1.5rem; }
.text-content p, .text-content li {
line-height: 1.8;
margin-bottom: 0.75rem;
color: #d1d5db; /* Light gray for body text */
}
.table {
width: 100%;
border-collapse: collapse;
margin-top: 1.5rem;
border-radius: 1.5rem; /* Match card rounding */
overflow: hidden; /* Ensure rounded corners apply to content */
}
.table th, .table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.table th {
background-color: rgba(50, 50, 70, 0.7);
color: #a78bfa;
font-weight: 600;
text-transform: uppercase;
}
.table tr:nth-child(even) {
background-color: rgba(40, 40, 60, 0.4);
}
.table tr:nth-child(odd) {
background-color: rgba(30, 30, 50, 0.4);
}
.table tr:hover {
background-color: rgba(60, 60, 80, 0.6);
}
.table a {
color: #87CEEB;
text-decoration: underline;
}
.table a:hover {
color: #BD93F9;
}
.chart-container {
height: 400px;
width: 100%;
background-color: rgba(20, 20, 30, 0.6);
border-radius: 2rem;
padding: 1rem;
box-shadow: inset 0 0 10px rgba(0,0,0,0.5);
}
</style>
</head>
<body class="bg-gradient-to-br from-gray-900 via-purple-900 to-indigo-900 text-gray-100 min-h-screen p-8 relative">
<div class="container-wrapper">
<header class="text-center mb-12 relative z-20">
<h1 class="text-6xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-neon-blue to-neon-purple mb-4 drop-shadow-lg">
SpaceX概念深度投研报告
</h1>
<p class="text-gray-400 text-lg mb-8">北京价值前沿科技有限公司 AI投研agent“价小前投研” 进行投研呈现</p>
<p class="text-red-400 text-xl font-bold glass-card inline-block px-8 py-4">本报告为AI合成数据投资需谨慎。</p>
</header>
<!-- 0. 概念事件 -->
<section class="glass-card mb-8 p-8 relative z-10" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h2 class="text-neon-purple text-4xl mb-6">0. 概念事件SpaceX的崛起与未来展望</h2>
<div class="text-content">
<p class="mb-4">SpaceX这家由埃隆·马斯克于2002年创立的商业航天公司以其颠覆性的火箭回收技术和雄心勃勃的“星链”Starlink卫星互联网项目持续在全球科技与金融市场引发广泛关注。近期一系列事件特别是围绕其估值和未来发展方向的讨论使其成为焦点概念。</p>
<h3 class="text-neon-blue text-2xl mt-8 mb-4">时间轴梳理:关键里程碑事件</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li><strong class="text-neon-blue">2002年</strong> SpaceX成立旨在降低太空运输成本。</li>
<li><strong class="text-neon-blue">2008年9月</strong> “猎鹰1号”火箭第四次发射成功标志公司从早期失败中崛起。</li>
<li><strong class="text-neon-blue">2010年6月</strong> “猎鹰9号”火箭首次发射成功成为公司主力运载工具。</li>
<li><strong class="text-neon-blue">2015-2016年</strong> “猎鹰9号”成功实现陆地和海上火箭回收标志着可重复使用技术商业化显著降低发射成本。</li>
<li><strong class="text-neon-blue">2023年第一季度</strong> “星链”业务首次实现季度盈利,并在年底实现全年盈利,成为公司营收和利润的核心驱动力。</li>
<li><strong class="text-neon-blue">2023年末</strong> SpaceX估值在二级市场达到约<strong class="text-neon-pink">1800亿美元</strong>,部分交易甚至达到<strong class="text-neon-pink">3500亿美元</strong></li>
<li><strong class="text-neon-blue">2024年6月6日</strong> “星舰”重型运载火箭第四次试射取得重大进展,首次成功实现海面软着陆,向完全重复使用迈出关键一步。</li>
<li><strong class="text-neon-blue">2024年8-9月</strong> “北极星黎明”任务成功完成人类首次商业太空行走,展示了其在私人太空飞行领域的领导力。</li>
<li><strong class="text-neon-blue">2024年11月7日</strong> 马斯克暗示SpaceX未来可能IPO并表达希望特斯拉股东能参与投资。</li>
<li><strong class="text-neon-blue">2024年11月7日</strong> 马斯克要求SpaceX的台湾供应商将生产业务迁出台湾以应对地缘政治风险。</li>
<li><strong class="text-neon-blue">2024年11月28日</strong> 马斯克提出未来五年内在轨建设<strong class="text-neon-pink">100GW太空AI计算中心</strong>的宏伟计划,可能采用钙钛矿太阳能技术。</li>
<li><strong class="text-neon-blue">2025年1月17日</strong> “星舰”试飞中发生“计划外快速解体”导致FAA启动事故调查显示其技术成熟度仍面临挑战。</li>
<li><strong class="text-neon-blue">2025年6月</strong> 白宫指示国防部和NASA审查与SpaceX的联邦合同总额约<strong class="text-neon-pink">220亿美元</strong>,系特朗普与马斯克公开争执后的潜在报复措施。</li>
<li><strong class="text-neon-blue">2025年9月</strong> SpaceX同意收购卫星运营商EchoStar的频谱区块据新闻报道耗资<strong class="text-neon-pink">170亿美元</strong>(路演数据为<strong class="text-neon-pink">17亿美元</strong>),旨在发展直连手机服务。</li>
<li><strong class="text-neon-blue">2025年12月6日</strong> 华尔街日报等多家媒体报道SpaceX即将启动二次股票发售估值将达到<strong class="text-neon-pink">8000亿美元</strong>超越OpenAI成为美国最具价值的私营企业。</li>
<li><strong class="text-neon-blue">2025年12月7日</strong> 马斯克在社交媒体回应称SpaceX以<strong class="text-neon-pink">8000亿美元</strong>估值融资的消息“并不准确”但未否认IPO计划。</li>
<li><strong class="text-neon-blue">2025年12月7日</strong> SpaceX高管向投资者表示公司正考虑在<strong class="text-neon-pink">2026年下半年</strong>进行首次公开募股IPO</li>
</ul>
</div>
</section>
<!-- 1. 核心观点摘要 -->
<section class="glass-card mb-8 p-8 relative z-10" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h2 class="text-neon-purple text-4xl mb-6">1. 核心观点摘要</h2>
<div class="text-content">
<p>SpaceX作为商业航天的领军企业正通过其可复用火箭技术和星链业务实现从太空运输商到综合性太空基础设施服务商的转型。当前市场对其未来价值预期极高估值争议与 IPO 计划并存,显示其已进入从技术验证走向大规模商业化扩张的关键阶段,但星舰的最终成熟度、地缘政治及估值泡沫是其核心风险点。</p>
</div>
</section>
<!-- 2. 概念的核心逻辑与市场认知分析 -->
<section class="glass-card mb-8 p-8 relative z-10" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h2 class="text-neon-purple text-4xl mb-6">2. 概念的核心逻辑与市场认知分析</h2>
<div class="text-content">
<h3 class="text-neon-blue text-2xl mt-6 mb-4">核心驱动力:颠覆性创新与太空经济开创</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2 mb-6">
<li><strong class="text-neon-blue">技术颠覆与成本优势:</strong> 猎鹰系列火箭的可重复使用技术是其核心竞争力。通过火箭一级助推器回收单次发射成本大幅降低路演数据显示猎鹰9号回收后成本降低70-90%,报价约<strong class="text-neon-pink">3000美元/千克</strong>,远低于竞争对手)。未来“星舰”若实现完全回收,预计成本可降至<strong class="text-neon-pink">80-200美元/千克</strong>将彻底改变太空运输的经济性。这种成本优势不仅吸引了商业客户也使其成为NASA和美国政府的关键服务商。</li>
<li><strong class="text-neon-blue">垂直整合与快速迭代:</strong> SpaceX高达70%的自研率和“第一性原理”的迭代开发模式使其能够快速试错、优化设计并实现技术领先。通过使用消费级硬件如龙飞船采用英特尔X86处理器并通过冗余设计解决航天级问题显著降低了成本并加速了开发进程。</li>
<li><strong class="text-neon-blue">创新商业模式与飞轮效应:</strong> “星链”卫星互联网业务的成功改变了SpaceX的营收结构。它从最初主要依赖一次性发射合同转变为依赖“星链”的持续性订阅服务并为自身火箭提供了稳定的发射需求形成“低成本发射→更多卫星部署→更广覆盖→更多用户→更多营收→更多发射”的良性飞轮效应。2023年“星链”收入已超过发射业务成为公司最大的收入和利润来源。</li>
<li><strong class="text-neon-blue">战略地位与政策支持:</strong> SpaceX不仅为商业公司服务更是美国国家航天局NASA和国防部等政府机构的核心服务商。其在载人航天、补给任务、“星盾”军事卫星项目中的作用使其具备重要的国家战略价值从而获得政策和资金的持续支持。</li>
<li><strong class="text-neon-blue">马斯克的个人影响力:</strong> 埃隆·马斯克的愿景、魄力及其在全球范围内的巨大影响力使得SpaceX能够吸引顶尖人才、巨额资本和全球关注加速其技术研发和市场拓展。</li>
</ul>
<h3 class="text-neon-blue text-2xl mt-6 mb-4">市场热度与情绪:乐观与警惕并存</h3>
<p class="mb-4">当前市场对SpaceX概念的关注度极高新闻报道密集研报和路演频繁提及。整体情绪呈现出 <strong class="text-neon-pink">“极度乐观与高度警惕并存”</strong> 的两极分化状态。</p>
<ul class="list-disc list-inside text-gray-300 space-y-2 mb-6">
<li><strong class="text-neon-blue">乐观情绪主要源于:</strong>
<ul class="list-circle list-inside ml-4">
<li><strong class="text-neon-pink">惊人的估值增长:</strong> 在不到两年时间里从2023年初的<strong class="text-neon-pink">1270亿美元</strong>飙升至2025年中的<strong class="text-neon-pink">4000亿美元</strong>甚至在2025年12月传出<strong class="text-neon-pink">8000亿美元</strong>的估值(虽被马斯克否认),反映了市场对其未来增长潜力的无限憧憬。</li>
<li><strong class="text-neon-pink">“星链”的商业成功:</strong> 800万活跃用户、盈利能力确立以及直连手机业务的巨大潜力验证了其商业模式。</li>
<li><strong class="text-neon-pink">“星舰”的未来前景:</strong> 虽然坎坷,但其作为人类通往深空和火星殖民的唯一可见路径,激发了无限想象空间。</li>
<li><strong class="text-neon-pink">马斯克的“太空AI计算中心”宏伟蓝图</strong> 为市场描绘了新的科技革命叙事。</li>
</ul>
</li>
<li><strong class="text-neon-blue">警惕情绪主要体现在:</strong>
<ul class="list-circle list-inside ml-4">
<li><strong class="text-neon-pink">估值泡沫风险:</strong> 马斯克对<strong class="text-neon-pink">8000亿美元</strong>估值的否认以及路演中提及的“DXYZ基金溢价6倍”都暗示市场对SpaceX的估值可能存在过度炒作的成分与当前营收2025年预计<strong class="text-neon-pink">155亿美元</strong>)之间存在巨大的预期差。</li>
<li><strong class="text-neon-pink">“星舰”技术成熟度:</strong> 多次试飞失败和取消以及FAA的事故调查表明其技术仍未完全稳定距离大规模商业应用仍有距离。</li>
<li><strong class="text-neon-pink">地缘政治与政策风险:</strong> 白宫对SpaceX联邦合同的审查、要求台湾供应商撤离等事件都提示了其业务并非完全脱离政治风险。</li>
</ul>
</li>
</ul>
<h3 class="text-neon-blue text-2xl mt-6 mb-4">预期差分析:市场认知可能存在的盲点</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li><strong class="text-neon-blue">估值与基本面脱节的风险:</strong> 虽然SpaceX的营收增长迅速2023年<strong class="text-neon-pink">87.21亿美元</strong>2024年预测<strong class="text-neon-pink">125亿美元</strong>2025年预测<strong class="text-neon-pink">155亿美元</strong>),且星链已盈利,但高达数千亿甚至<strong class="text-neon-pink">8000亿美元</strong>的估值,远超其短期盈利能力所能支撑,明显计入了数十年后的远期增长和多种颠覆性业务的巨大成功。市场共识可能对这些远期愿景的实现难度和时间表过于乐观。</li>
<li><strong class="text-neon-blue">“星舰”研发进度的不确定性:</strong> 市场往往聚焦于“星舰”的宏大愿景而对多次试飞失败、解体、取消的常态化及其对研发周期、成本的实际影响关注不足。例如新闻中关于“星舰”第9、第10次试飞的表述存在矛盾加剧了对进展的模糊性。从研发到全面商业运营、实现超低成本仍需漫长且高风险的技术攻关。</li>
<li><strong class="text-neon-blue">“手机直连”业务的商业化难度:</strong> 市场对收购EchoStar频谱新闻报道<strong class="text-neon-pink">170亿美元</strong>,路演报道<strong class="text-neon-pink">17亿美元</strong>,巨大的金额差异本身就是预期差)以开展直连手机业务充满期待。然而,该业务在全球范围内的落地涉及复杂的监管、频谱协调、运营商合作和终端适配,其盈利模式和市场接受度仍需验证,且巨额投资带来的回报周期可能较长。</li>
<li><strong class="text-neon-blue">政治风险的潜在影响:</strong> 白宫对SpaceX联邦合同的审查<strong class="text-neon-pink">220亿美元</strong>在普遍认为SpaceX与美国政府深度绑定的背景下构成了潜在的巨大风险可能影响其收入来源和战略地位。市场可能低估了政治博弈对这家“私人”航天巨头的影响。</li>
</ul>
</div>
</section>
<!-- 3. 关键催化剂与未来发展路径 -->
<section class="glass-card mb-8 p-8 relative z-10" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h2 class="text-neon-purple text-4xl mb-6">3. 关键催化剂与未来发展路径</h2>
<div class="text-content">
<h3 class="text-neon-blue text-2xl mt-6 mb-4">近期催化剂未来3-6个月潜在的增长加速器</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2 mb-6">
<li><strong class="text-neon-blue">“星舰”的关键性试飞成功:</strong> 若“星舰”能成功完成全面测试,特别是实现一级助推器的返场精确捕获,将是里程碑式的技术验证。这将极大地提振市场信心,并加快其商业化进程。</li>
<li><strong class="text-neon-blue">SpaceX IPO的具体时间表和定价公布</strong> 2026年下半年IPO的预期以及马斯克对<strong class="text-neon-pink">8000亿美元</strong>估值争议的最终澄清将是市场关注的焦点。一旦IPO计划和定价确定将为市场提供明确的估值锚点并带来巨大的流动性溢价。</li>
<li><strong class="text-neon-blue">“星链”直连手机服务的进展:</strong> EchoStar频谱整合后若能公布更多全球运营商合作细节并成功启动大规模商业服务将是“星链”业务增长的下一个重要催化剂有望吸引大量新用户。</li>
<li><strong class="text-neon-blue">太空AI计算中心计划的阶段性成果公布</strong> 马斯克在2025年11月提出的太空AI计算中心计划若能在近期发布具体的合作伙伴、技术路线图或早期试验成果将为SpaceX开辟全新的叙事空间吸引AI和半导体领域的投资者关注。</li>
</ul>
<h3 class="text-neon-blue text-2xl mt-6 mb-4">长期发展路径:宏大愿景与战略布局</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li><strong class="text-neon-blue">“星舰”的全面运营与火星殖民:</strong> 从当前的测试阶段发展到完全可重复使用的星际运输系统支持NASA的“阿尔忒弥斯3号”登月任务并最终实现火星探测和殖民这将是SpaceX的终极愿景和最大价值所在。这一过程需要持续的技术突破和巨额投资。</li>
<li><strong class="text-neon-blue">“星链”的全球全覆盖与生态构建:</strong> 完成<strong class="text-neon-pink">42000颗</strong>卫星的部署,实现全球任何角落的低成本、高速宽带连接。同时,将业务拓展至航空、海运、偏远地区、移动终端直连,并进一步构建基于卫星网络的物联网生态系统。</li>
<li><strong class="text-neon-blue">“星盾”与国家安全基础设施:</strong> 将“星盾”发展成为美国国防和情报机构的核心太空基础设施,提供强大的军事通信、侦察和响应能力,巩固其战略供应商地位。</li>
<li><strong class="text-neon-blue">太空资源的开发与利用:</strong> 随着太空运输成本的大幅下降和技术的成熟SpaceX有望涉足太空资源开采、在轨制造、太空旅游等更广阔的太空经济领域成为未来太空经济的奠基者和领导者。</li>
<li><strong class="text-neon-blue">太空AI计算中心的部署与商业化</strong> 成功部署和运营在轨AI数据中心利用太空的独特优势提供超低延迟、高安全性的AI计算服务开创全新的商业模式推动人工智能向更高维度发展。</li>
</ul>
</div>
</section>
<!-- Financials & Valuation section with Echarts -->
<section class="glass-card mb-8 p-8 relative z-10" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h2 class="text-neon-purple text-4xl mb-6">财务表现、估值与业务构成</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div class="text-content">
<h3 class="text-neon-blue text-2xl mb-4">财务概览与收入预测</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li><strong class="text-neon-blue">2023财年表现</strong> 总收入<strong class="text-neon-pink">87.21亿美元</strong>同比增长90%。星链业务贡献<strong class="text-neon-pink">41.78亿美元</strong>(公司第一大收入板块),并于年底首次实现盈利。公司首次实现正向现金流。</li>
<li><strong class="text-neon-blue">2024财年预测</strong> 总收入预计达到<strong class="text-neon-pink">125亿美元</strong>同比增长43%。星链收入预计达<strong class="text-neon-pink">69亿美元</strong>同比增长65%),发射收入预计达<strong class="text-neon-pink">46亿美元</strong></li>
<li><strong class="text-neon-blue">2025财年预测</strong> 预计营收<strong class="text-neon-pink">155亿美元</strong>。马斯克预测2026年SpaceX的营收规模将超越同期NASA的预算总额<strong class="text-neon-pink">188亿美元</strong></li>
</ul>
<h3 class="text-neon-blue text-2xl mt-6 mb-4">估值变化与IPO计划</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li><strong class="text-neon-blue">估值飙升:</strong> 从2019年的<strong class="text-neon-pink">350亿美元</strong>到2023年底的<strong class="text-neon-pink">1800亿美元</strong>(投后),部分交易达<strong class="text-neon-pink">3500亿美元</strong>。2025年7月估值达到<strong class="text-neon-pink">4000亿美元</strong>。新闻报道2025年12月启动二次股票发售估值将达到<strong class="text-neon-pink">8000亿美元</strong>超越OpenAI。</li>
<li><strong class="text-neon-blue">马斯克回应:</strong> 针对<strong class="text-neon-pink">8000亿美元</strong>估值传闻马斯克在社交媒体回应称“并不准确”但未否认IPO计划。</li>
<li><strong class="text-neon-blue">IPO考虑</strong> SpaceX高管向投资者表示公司正考虑在<strong class="text-neon-pink">2026年下半年</strong>进行首次公开募股IPO</li>
<li><strong class="text-neon-blue">融资与投资者:</strong> 公司通过股权要约收购为员工和投资者提供流动性。摩根士丹利为SpaceX持股员工提供专项贷款。投资平台Republic允许小额投资者押注SpaceX。</li>
</ul>
</div>
<div>
<div class="chart-container mb-6">
<div id="valuationChart" class="w-full h-full"></div>
</div>
<div class="chart-container">
<div id="revenueChart" class="w-full h-full"></div>
</div>
</div>
</div>
<div class="text-content mt-8">
<h3 class="text-neon-blue text-2xl mb-4">商业模式与业务构成</h3>
<p class="mb-4">SpaceX的核心商业模式是以可复用火箭为基础以“星链”宽带通信服务为核心形成了商业闭环。业务范围涵盖卫星制造发射、“星链”宽带通信服务、载人航天、运载服务并积极拓展“星盾”等新业务领域。</p>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li><strong class="text-neon-blue">“星链”业务:</strong> 已成为SpaceX最大收入和利润来源打破了火箭订单短缺瓶颈为SpaceX提供了持续发射需求。2024年Starlink业务收入占SpaceX总营收的<strong class="text-neon-pink">62.4%</strong>。硬件销售和订阅销售分别占星链营收的21.3%和51.9%。住宅应用占比43.5%,是最大的收入来源。</li>
<li><strong class="text-neon-blue">资金来源与政府支持:</strong> 公司年度投入巨大资金来自政府订单截至2021年政府订单收入<strong class="text-neon-pink">151.7亿美元</strong>、资本市场融资截至2022年底累计超<strong class="text-neon-pink">130亿美元</strong>以及马斯克本人投资。NASA在技术和人才方面给予大力支持。</li>
</ul>
</div>
</section>
<!-- Business Segments in Bento Grid like layout -->
<section class="mb-8 relative z-10">
<h2 class="text-neon-purple text-4xl mb-6 glass-card p-6 rounded-3xl">SpaceX核心业务板块</h2>
<div class="bento-grid">
<!-- Starship Card -->
<div class="glass-card bento-grid-item" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h3 class="text-neon-blue text-2xl mb-4">星舰 (Starship) 项目</h3>
<ul class="list-disc list-inside text-gray-300 space-y-1 text-sm">
<li><strong class="text-neon-blue">目标与运力:</strong> 低轨运力150吨回收/250吨单次目标火星殖民与深空探测。</li>
<li><strong class="text-neon-blue">NASA合作</strong> 根据与NASA<strong class="text-neon-pink">40亿美元</strong>合同需改造为登月着陆器服务“阿尔忒弥斯3号”任务。</li>
<li><strong class="text-neon-blue">试飞进展:</strong> 2023年两次试飞失败。2024年6月6日第四次试射取得重大进展首次实现海面软着陆。2025年1月17日试飞中发生“计划外快速解体”。</li>
<li><strong class="text-neon-blue">未来计划:</strong> “星舰V2”预计25年内商业化使用。目标2026年火星发射。</li>
<li><strong class="text-neon-blue">监管许可:</strong> FAA批准年发射任务从5次增加到<strong class="text-neon-pink">最多25次</strong> (2025-05-16)。</li>
<li><strong class="text-neon-blue">成本预期:</strong> 若成功回收,成本有望降至<strong class="text-neon-pink">80-200美元/千克</strong></li>
</ul>
</div>
<!-- Starlink & Satellite Business Card -->
<div class="glass-card bento-grid-item" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h3 class="text-neon-blue text-2xl mb-4">星链 (Starlink) 与卫星业务</h3>
<ul class="list-disc list-inside text-gray-300 space-y-1 text-sm">
<li><strong class="text-neon-blue">部署与用户:</strong> 已部署约<strong class="text-neon-pink">9000颗卫星</strong>,全球用户超<strong class="text-neon-pink">800万</strong>2025年。累计发射超<strong class="text-neon-pink">8399颗卫星</strong>2025-05计划总量<strong class="text-neon-pink">42000颗</strong></li>
<li><strong class="text-neon-blue">直连手机:</strong> 致力于发展直连手机服务同意收购卫星运营商EchoStar的频谱区块新闻报道耗资逾<strong class="text-neon-pink">200亿美元</strong>(路演数据为<strong class="text-neon-pink">17亿美元</strong>包含85亿美元现金及85亿美元SpaceX股票。</li>
<li><strong class="text-neon-blue">星盾 (Starshield)</strong> 支持国家安全工作,监视地球目标,携带军事载荷,已发射<strong class="text-neon-pink">212颗</strong></li>
<li><strong class="text-neon-blue">军用价值:</strong> 在俄乌战争中凸显低轨卫星在军事OODA循环中的关键作用。</li>
</ul>
</div>
<!-- Falcon 9 / Dragon Card -->
<div class="glass-card bento-grid-item" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h3 class="text-neon-blue text-2xl mb-4">猎鹰9号 (Falcon 9) 与龙飞船</h3>
<ul class="list-disc list-inside text-gray-300 space-y-1 text-sm">
<li><strong class="text-neon-blue">火箭主导:</strong> 猎鹰系列火箭主导全球商业发射2023年完成98次发射美国全年占比66%),全球载荷占比高达<strong class="text-neon-pink">80%</strong></li>
<li><strong class="text-neon-blue">回收技术:</strong> 猎鹰9号可复用20余次成本降至<strong class="text-neon-pink">1500美元/公斤</strong>降幅≈90%)。</li>
<li><strong class="text-neon-blue">发射任务:</strong> 2025年1月将首次向月球表面发射“蓝色幽灵”月球着陆器。</li>
<li><strong class="text-neon-blue">龙飞船:</strong> 货运/载人任务累计飞行超1300小时运送40+宇航员。单座成本约<strong class="text-neon-pink">5500万美元</strong></li>
</ul>
</div>
<!-- Government Contracts & Strategic Position Card -->
<div class="glass-card bento-grid-item" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h3 class="text-neon-blue text-2xl mb-4">政府合同与战略地位</h3>
<ul class="list-disc list-inside text-gray-300 space-y-1 text-sm">
<li><strong class="text-neon-blue">核心服务商:</strong> 作为美国政府发射卫星和宇航员的核心服务商与NASA、五角大楼和情报机构深度合作。</li>
<li><strong class="text-neon-blue">合同审查:</strong> 2025年6月白宫指示审查SpaceX高达<strong class="text-neon-pink">220亿美元</strong>的联邦合同,系特朗普与马斯克公开争执后的潜在报复。</li>
<li><strong class="text-neon-blue">成本效益:</strong> SpaceX不依赖政府合同获取大部分收入且价格远低于竞争对手为美国政府节省了数十亿美元。</li>
<li><strong class="text-neon-blue">NASA合作</strong> NASA自奥巴马时代将近地轨道任务外包SpaceX/RKLB等自身聚焦月球/火星深空任务。</li>
</ul>
</div>
<!-- Space AI Computing Center Card -->
<div class="glass-card bento-grid-item" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h3 class="text-neon-blue text-2xl mb-4">太空AI计算中心计划</h3>
<ul class="list-disc list-inside text-gray-300 space-y-1 text-sm">
<li><strong class="text-neon-blue">宏伟规划:</strong> 马斯克规划未来每年建设<strong class="text-neon-pink">100GW太空算力</strong>能力储备300~500GW预测5年内太空数据中心成本将低于地面。</li>
<li><strong class="text-neon-blue">技术路线:</strong> 拟每年部署100GW太阳能AI卫星推动高效光伏技术发展。考虑使用钙钛矿技术替代高成本砷化镓作为未来太空光伏系统的可行方案。</li>
</ul>
</div>
<!-- Innovation & Supply Chain Card -->
<div class="glass-card bento-grid-item" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h3 class="text-neon-blue text-2xl mb-4">创新模式与供应链</h3>
<ul class="list-disc list-inside text-gray-300 space-y-1 text-sm">
<li><strong class="text-neon-blue">降本路径:</strong> 消费级硬件替代航天级芯片龙飞船控制器采用英特尔X86处理器成本仅为传统方案的1/5384。工业级替代宇航级材料。</li>
<li><strong class="text-neon-blue">自研与迭代:</strong> 自研率超70%,通过迭代开发+第一性原理,快速试错优化设计。</li>
<li><strong class="text-neon-blue">供应链管理:</strong> 供应商分类管理。马斯克要求台湾供应商搬出台湾2024-11以应对地缘政治风险。</li>
</ul>
</div>
</div>
</section>
<!-- 4. 产业链与核心公司深度剖析 -->
<section class="glass-card mb-8 p-8 relative z-10" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h2 class="text-neon-purple text-4xl mb-6">4. 产业链与核心公司深度剖析</h2>
<div class="text-content">
<h3 class="text-neon-blue text-2xl mt-6 mb-4">产业链图谱</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2 mb-6">
<li><strong class="text-neon-blue">上游(基础材料与核心部件):</strong> 提供特种合金、复合材料、精密紧固件、电子元器件、绝热材料、宇航屏幕等。例如,西部材料(铌合金独供)、超捷股份(高强度紧固件)、再升科技(绝热材料)、派克新材(锻件)、信维通信(连接器及线缆)、晋拓股份(微波放大器)、宸展光电(宇航屏幕)。</li>
<li><strong class="text-neon-blue">中游(航天器设计、制造与发射):</strong> 这是SpaceX的核心环节包括猎鹰系列火箭、星舰、龙飞船以及星链、星盾卫星的设计、制造、测试、发射与在轨运营。</li>
<li><strong class="text-neon-blue">下游(应用与服务):</strong> 包括全球卫星互联网服务Starlink、直连手机通信、载人航天、货运补给、军事与政府任务、太空AI计算中心等。</li>
<li><strong class="text-neon-blue">资本投资:</strong> 通过股权投资或二级市场购买SpaceX股份如利欧股份通过合伙企业投资。</li>
</ul>
<h3 class="text-neon-blue text-2xl mt-6 mb-4">核心玩家对比</h3>
<div class="space-y-6 mb-6">
<div>
<h4 class="text-neon-pink text-xl mb-2">SpaceX领导者</h4>
<ul class="list-disc list-inside ml-4 text-gray-300 space-y-1 text-sm">
<li><strong class="text-neon-blue">竞争优势:</strong> 在可复用火箭技术、发射成本控制、垂直整合能力、迭代研发速度和低轨卫星星座部署规模上拥有压倒性优势。其市场份额2023年全球载荷占比高达<strong class="text-neon-pink">80%</strong>和用户规模Starlink 800万用户均证明了其领导地位。马斯克的个人品牌和愿景也提供了无与伦比的资金吸引力。</li>
<li><strong class="text-neon-blue">业务进展:</strong> Starlink已盈利并快速扩张用户基础正大力推动直连手机和太空AI计算中心。星舰虽屡次挫折但技术突破仍在持续。</li>
<li><strong class="text-neon-blue">潜在风险:</strong> 星舰研发的不确定性、高达<strong class="text-neon-pink">8000亿美元</strong>估值(新闻报道)的合理性、美国政府合同审查带来的不确定性、以及地缘政治(如要求台湾供应商撤离)对供应链的影响。</li>
</ul>
</div>
<div>
<h4 class="text-neon-pink text-xl mb-2">中国大陆供应商绑定SpaceX的受益者</h4>
<ul class="list-disc list-inside ml-4 text-gray-300 space-y-1 text-sm">
<li><strong class="text-neon-blue">信维通信:</strong> 作为SpaceX星链终端核心组件连接器及线缆的“独家直接供应商”其与SpaceX的业务关联度高且技术壁垒强。但独家地位的持续性存在不确定性可能面临竞争和份额稀释的风险。</li>
<li><strong class="text-neon-blue">西部材料:</strong> “SpaceX锯合金产品的中国境内唯一供应商”显示其在特定高端材料领域的稀缺性和技术认可度。</li>
<li><strong class="text-neon-blue">再升科技、超捷股份、派克新材等:</strong> 均以其在各自领域的技术专长切入SpaceX的直接供应链。</li>
<li><strong class="text-neon-blue">共同特点与风险:</strong> 这些公司通过与SpaceX的合作进入全球最前沿的商业航天供应链是对其技术实力和产品质量的极高认可。但存在对单一客户的依赖、SpaceX供应链策略调整以及技术迭代带来的产品替代风险。</li>
</ul>
</div>
<div>
<h4 class="text-neon-pink text-xl mb-2">利欧股份(投资方)</h4>
<ul class="list-disc list-inside ml-4 text-gray-300 space-y-1 text-sm">
<li><strong class="text-neon-blue">逻辑纯粹性:</strong> 作为SpaceX的间接投资者其价值主要来自于SpaceX估值的增长。</li>
<li><strong class="text-neon-blue">潜在风险:</strong> 路演信息提及“投资争议目前处于互相诉讼中”,表明其投资收益的实现可能面临法律和程序上的不确定性,纯度受损。</li>
</ul>
</div>
<div>
<h4 class="text-neon-pink text-xl mb-2">中国商业航天企业(追赶者/对标者)</h4>
<ul class="list-disc list-inside ml-4 text-gray-300 space-y-1 text-sm">
<li><strong class="text-neon-blue">地位:</strong> 路演报告指出中国卫星互联网以中国星网为主处于SpaceX 2018-2019年的试验阶段民营火箭技术对标SpaceX 2009-2010年整体处于追赶阶段。</li>
<li><strong class="text-neon-blue">竞争环境:</strong> SpaceX已占据全球最优低轨轨道和大量频谱资源形成“先到先得”的垄断优势对中国构成巨大压力。中国企业需加速发展以避免资源被占。</li>
<li><strong class="text-neon-blue">市场情绪:</strong> 受到政策利好、技术突破和国家战略需求而备受关注但其发展阶段和规模与SpaceX仍有显著差距。</li>
</ul>
</div>
</div>
</div>
</section>
<!-- 5. 潜在风险与挑战 -->
<section class="glass-card mb-8 p-8 relative z-10" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h2 class="text-neon-purple text-4xl mb-6">5. 潜在风险与挑战</h2>
<div class="text-content">
<ul class="list-disc list-inside text-gray-300 space-y-4">
<li><strong class="text-neon-blue">技术成熟度风险:</strong>
<ul class="list-circle list-inside ml-4 space-y-1">
<li><strong class="text-neon-pink">“星舰”的复杂性与可靠性:</strong> 新闻中多次报道“星舰”试飞失败、爆炸、解体2025年1月、6月以及试飞取消2025年3月表明其技术尚未完全稳定。尽管FAA批准了更多发射次数但距离实现完全可复用、可靠的商业运营和火星任务仍有漫长且充满不确定性的技术攻关之路。新闻中关于第9次和第10次试飞结果的矛盾描述2025年6月报道“第9次试飞失控解体”2025年8月报道“第10次试飞圆满结束”但2025年6月还报道了S36“第10次试飞前”爆炸使得外界难以准确评估其真实进展。</li>
<li><strong class="text-neon-pink">太空AI计算中心的挑战</strong> <strong class="text-neon-pink">100GW</strong>的太空算力部署、能源供给(钙钛矿技术仍需验证)、散热、在轨维护以及数据传输效率等,都是巨大的工程和技术挑战。</li>
</ul>
</li>
<li><strong class="text-neon-blue">商业化与盈利风险:</strong>
<ul class="list-circle list-inside ml-4 space-y-1">
<li><strong class="text-neon-pink">Starlink用户增长瓶颈和ARPU值</strong> 随着市场渗透率提高用户增长速度可能放缓。同时维持较高ARPU值以覆盖高昂的运营成本和初期投入也面临来自地面通信或其他卫星互联网的竞争。</li>
<li><strong class="text-neon-pink">直连手机服务的推广与盈利:</strong> EchoStar频谱收购金额存在巨大矛盾新闻报道<strong class="text-neon-pink">170亿美元</strong>,路演报道<strong class="text-neon-pink">17亿美元</strong>),这一笔巨额投资的真实性及其商业化回报,仍是未知数。全球各地的监管、运营商合作和市场接受度将是关键。</li>
</ul>
</li>
<li><strong class="text-neon-blue">政策与竞争风险:</strong>
<ul class="list-circle list-inside ml-4 space-y-1">
<li><strong class="text-neon-pink">政府合同审查:</strong> 2025年6月白宫因政治争执对SpaceX总额<strong class="text-neon-pink">220亿美元</strong>的联邦合同进行审查可能影响其在NASA和国防部的核心业务甚至削弱其在“金穹”导弹防御系统中的作用。</li>
<li><strong class="text-neon-pink">地缘政治对供应链的影响:</strong> 马斯克要求台湾供应商撤离2024年11月凸显地缘政治冲突对全球高科技供应链的脆弱性。这可能导致SpaceX供应链成本上升或迫使其调整全球采购策略。</li>
<li><strong class="text-neon-pink">全球商业航天竞争加剧:</strong> 尽管SpaceX领先但各国包括中国都在加速布局商业航天未来轨道资源和频谱的争夺将日益激烈。路演数据也显示SpaceX的成本优势正在压制国内企业。</li>
</ul>
</li>
<li><strong class="text-neon-blue">信息交叉验证风险:</strong>
<ul class="list-circle list-inside ml-4 space-y-1">
<li><strong class="text-neon-pink">估值信息的高度矛盾:</strong> 新闻报道2025年12月SpaceX估值将达<strong class="text-neon-pink">8000亿美元</strong>,马斯克本人随即否认“并不准确”,同时路演和研报在不同时间点给出了<strong class="text-neon-pink">1270亿、1800亿、3500亿、4000亿美元</strong>等多个估值版本。这种巨大的估值差异和高层公开否认,说明市场对其真实估值存在严重分歧,投资者需高度警惕估值泡沫风险。</li>
<li><strong class="text-neon-pink">EchoStar收购金额的巨大差异</strong> 新闻报道称收购金额为<strong class="text-neon-pink">170亿美元</strong>85亿美元现金+85亿美元股票而路演报告则提及<strong class="text-neon-pink">17亿美元</strong>。这一<strong class="text-neon-pink">10倍的差异</strong>对SpaceX的财务影响和战略意义是截然不同的是核心信息矛盾点需要明确其真实性。</li>
<li><strong class="text-neon-pink">星舰试飞结果和编号的混乱:</strong> 不同新闻源对“星舰”第9次和第10次试飞的时间、结果和编号存在矛盾使得外界难以准确评估其研发进度。</li>
<li><strong class="text-neon-pink">2023年发射次数的差异</strong> 路演报告称2023年SpaceX全年发射71次而研究报告则称98次。虽然不影响核心判断但反映出数据来源可能存在统计口径或及时性差异。</li>
</ul>
</li>
</ul>
</div>
</section>
<!-- 6. 综合结论与投资启示 -->
<section class="glass-card mb-8 p-8 relative z-10" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h2 class="text-neon-purple text-4xl mb-6">6. 综合结论与投资启示</h2>
<div class="text-content">
<h3 class="text-neon-blue text-2xl mt-6 mb-4">综合结论:转型扩张中的机遇与挑战</h3>
<p class="mb-4">SpaceX目前正处于一个<strong class="text-neon-pink">关键的转型与扩张时期</strong>。其可复用火箭技术和“星链”业务已成功从技术验证阶段迈向<strong class="text-neon-pink">基本面驱动的商业化盈利阶段</strong>确立了其在商业航天领域的领导者地位。然而其更高维度的雄心壮志如“星舰”和“太空AI计算中心”仍处于<strong class="text-neon-pink">高风险、长周期、高回报潜力的主题炒作/预期阶段</strong>其最终成功与否将决定SpaceX能否实现其万亿美元市值的终极愿景。当前市场对SpaceX的估值存在明显争议和预期差反映了投资者对未来潜力的无限憧憬与对其实现路径不确定性的担忧。</p>
<h3 class="text-neon-blue text-2xl mt-6 mb-4">最具投资价值的细分环节或方向:</h3>
<ol class="list-decimal list-inside text-gray-300 space-y-2 mb-6">
<li><strong class="text-neon-blue">“星链”手机直连业务的领导者:</strong> 尽管EchoStar收购金额存疑但“手机直连”是“星链”的下一个爆发点其用户基数和盈利能力一旦得到验证将为SpaceX带来长期稳定的现金流。关注其在全球范围内的频谱整合、运营商合作及实际商用进展。</li>
<li><strong class="text-neon-blue">“星舰”成功全面商业化后的产业链头部企业:</strong> 一旦“星舰”实现全面复用并大规模商业化,其对太空运输成本的颠覆将是空前的,整个太空经济产业链将迎来爆发。届时,能够为“星舰”提供关键材料、部件和服务的核心技术型供应商将获得巨大红利。</li>
<li><strong class="text-neon-blue">中国大陆供应链中具备技术稀缺性和强绑定关系的优质供应商:</strong> 那些为SpaceX提供独家或高技术含量产品的中国企业<strong class="text-neon-pink">西部材料、信维通信</strong>是分享SpaceX成长红利的间接途径。需重点筛选那些在技术上具备不可替代性、且能拓展与SpaceX合作深度的公司而非仅依赖价格优势。</li>
</ol>
<h3 class="text-neon-blue text-2xl mt-6 mb-4">接下来需要重点跟踪和验证的关键指标:</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li><strong class="text-neon-blue">“星舰”的试飞成功率和回收完整度:</strong> 特别是能否实现一级助推器返场精确捕获,这将直接影响其经济性和可靠性。</li>
<li><strong class="text-neon-blue">SpaceX的官方估值和IPO定价</strong> 这将是市场对SpaceX价值的最终共识并提供更透明的投资参考。</li>
<li><strong class="text-neon-blue">“星链”直连手机服务的用户增长、ARPU值和覆盖范围</strong> 关注该业务的实际商业化进展以及EchoStar收购的真实金额及其对财务的影响。</li>
<li><strong class="text-neon-blue">SpaceX核心中国供应商的订单量和品类拓展情况</strong> 关注其与SpaceX合作的稳定性、深度和广度以及是否受到地缘政治和供应链调整的影响。</li>
<li><strong class="text-neon-blue">美国政府对商业航天政策的动向:</strong> 特别是白宫对SpaceX联邦合同审查的最终结果以及对军事航天市场参与程度的影响。</li>
</ul>
</div>
</section>
<!-- Stock Data Table -->
<section class="glass-card p-8 relative z-10" x-data="{ hovered: false }" @mouseover="hovered = true" @mouseleave="hovered = false">
<h2 class="text-neon-purple text-4xl mb-6">SpaceX概念相关股票数据</h2>
<div class="overflow-x-auto w-full">
<table class="table w-full">
<thead>
<tr>
<th class="w-1/6">股票代码</th>
<th class="w-1/6">股票名称</th>
<th class="w-4/6">相关性与理由</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://valuefrontier.cn/company?scode=002131" target="_blank" class="text-neon-blue hover:text-neon-purple">002131</a></td>
<td>利欧股份</td>
<td>2021年公司认缴TBCA基金5000万美元拟共同投资SpaceX公司与TBCA基金因投资争议目前处于互相诉讼中 (参股)</td>
</tr>
<tr>
<td><a href="https://valuefrontier.cn/company?scode=002149" target="_blank" class="text-neon-blue hover:text-neon-purple">002149</a></td>
<td>西部材料</td>
<td>据网传纪要公司铌合金是SpaceX中国唯一供应商</td>
</tr>
<tr>
<td><a href="https://valuefrontier.cn/company?scode=688102" target="_blank" class="text-neon-blue hover:text-neon-purple">688102</a></td>
<td>斯瑞新材</td>
<td>公司积极对接SpaceX公司拓展公司液体火箭发动机燃烧室内衬的在该领域头部企业上的应用目前尚未形成商业订单</td>
</tr>
<tr>
<td><a href="https://valuefrontier.cn/company?scode=603601" target="_blank" class="text-neon-blue hover:text-neon-purple">603601</a></td>
<td>再升科技</td>
<td>公司高性能高硅氧纤维用于隔热保温供货SpaceX (供应商)</td>
</tr>
<tr>
<td><a href="https://valuefrontier.cn/company?scode=002792" target="_blank" class="text-neon-blue hover:text-neon-purple">002792</a></td>
<td>通宇通讯</td>
<td>公司的MacroWiFi产品通过SpaceX接口实现卫星直连互联网功能 (供应商)</td>
</tr>
<tr>
<td><a href="https://valuefrontier.cn/company?scode=300136" target="_blank" class="text-neon-blue hover:text-neon-purple">300136</a></td>
<td>信维通信</td>
<td>公司为SpaceX提供高性能连接器及线缆解决方案 (供应商)</td>
</tr>
<tr>
<td><a href="https://valuefrontier.cn/company?scode=300757" target="_blank" class="text-neon-blue hover:text-neon-purple">300757</a></td>
<td>罗博特科</td>
<td>参股公司ficonTEC客户包括国际某星链及太空技术的前沿科技公司</td>
</tr>
<tr>
<td><a href="https://valuefrontier.cn/company?scode=603211" target="_blank" class="text-neon-blue hover:text-neon-purple">603211</a></td>
<td>晋拓股份</td>
<td>微波放大器、基站放大器、滤波器等零部件主要客户包括台扬科技台扬科技供货SpaceX</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<script>
// Echarts for Valuation Trend
var valuationChart = echarts.init(document.getElementById('valuationChart'));
var valuationOption = {
title: {
text: 'SpaceX估值变化趋势 (亿美元)',
left: 'center',
textStyle: {
color: '#E0E7FF'
}
},
tooltip: {
trigger: 'axis',
formatter: function (params) {
var date = params[0].name;
var value = params[0].value;
return `日期: ${date}<br/>估值: ${value} 亿美元`;
},
backgroundColor: 'rgba(30, 30, 50, 0.7)',
borderColor: '#a78bfa',
textStyle: {
color: '#E0E7FF'
}
},
xAxis: {
type: 'category',
data: ['2019', '2023年末', '2025年1月', '2025年3月', '2025年7月', '2025年12月 (报道)'],
axisLabel: {
color: '#D1D5DB'
},
axisLine: {
lineStyle: {
color: '#6B7280'
}
}
},
yAxis: {
type: 'value',
name: '估值 (亿美元)',
axisLabel: {
color: '#D1D5DB'
},
nameTextStyle: {
color: '#D1D5DB'
},
splitLine: {
lineStyle: {
color: 'rgba(255,255,255,0.1)'
}
},
axisLine: {
lineStyle: {
color: '#6B7280'
}
}
},
series: [{
name: '估值',
type: 'line',
data: [350, 3500, 3500, 3500, 4000, {value: 8000, itemStyle: {color: '#FF79C6'}}], // 8000 is reported, but denied by Musk, so make it distinct
smooth: true,
lineStyle: {
color: '#87CEEB',
width: 3
},
itemStyle: {
color: '#87CEEB'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{
offset: 0, color: 'rgba(135, 206, 235, 0.5)'
}, {
offset: 1, color: 'rgba(135, 206, 235, 0)'
}])
}
}]
};
valuationChart.setOption(valuationOption);
// Echarts for Revenue Breakdown
var revenueChart = echarts.init(document.getElementById('revenueChart'));
var revenueOption = {
title: {
text: 'SpaceX年度收入构成 (亿美元)',
left: 'center',
textStyle: {
color: '#E0E7FF'
}
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
backgroundColor: 'rgba(30, 30, 50, 0.7)',
borderColor: '#a78bfa',
textStyle: {
color: '#E0E7FF'
}
},
legend: {
data: ['发射收入', '星链收入', '其他收入'],
top: 'bottom',
textStyle: {
color: '#E0E7FF'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
containLabel: true
},
xAxis: {
type: 'category',
data: ['2023财年 (实际)', '2024财年 (预测)'],
axisLabel: {
color: '#D1D5DB'
},
axisLine: {
lineStyle: {
color: '#6B7280'
}
}
},
yAxis: {
type: 'value',
name: '收入 (亿美元)',
axisLabel: {
color: '#D1D5DB'
},
nameTextStyle: {
color: '#D1D5DB'
},
splitLine: {
lineStyle: {
color: 'rgba(255,255,255,0.1)'
}
},
axisLine: {
lineStyle: {
color: '#6B7280'
}
}
},
series: [
{
name: '发射收入',
type: 'bar',
stack: 'total',
label: {
show: true,
position: 'inside',
color: '#fff',
formatter: '{c}'
},
itemStyle: {
color: '#BD93F9' /* Purple */
},
data: [35.09, 46]
},
{
name: '星链收入',
type: 'bar',
stack: 'total',
label: {
show: true,
position: 'inside',
color: '#fff',
formatter: '{c}'
},
itemStyle: {
color: '#87CEEB' /* Blue */
},
data: [41.78, 69]
},
{
name: '其他收入',
type: 'bar',
stack: 'total',
label: {
show: true,
position: 'inside',
color: '#fff',
formatter: '{c}'
},
itemStyle: {
color: '#FF79C6' /* Pink */
},
data: [10.34, 10] /* Assumed 10 for 2024 */
}
]
};
revenueChart.setOption(revenueOption);
// Resize charts on window resize
window.addEventListener('resize', function() {
valuationChart.resize();
revenueChart.resize();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,756 @@
<!DOCTYPE html>
<html lang="zh" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TV面板LCD概念深度综合分析报告</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/daisyui@1.16.2/dist/full.css" rel="stylesheet" type="text/css" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.3.3/dist/echarts.min.js"></script>
<style>
body {
font-family: 'Space Mono', 'Inter', sans-serif;
background-image: url('data:image/svg+xml;utf8,<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" filter="url(%23blurFilter)"><defs><filter id="blurFilter"><feGaussianBlur in="SourceGraphic" stdDeviation="5"/></filter><linearGradient id="g1" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="%238B5CF6" /><stop offset="100%" stop-color="%231E40AF" /></linearGradient><linearGradient id="g2" x1="1" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="%23EC4899" /><stop offset="100%" stop-color="%23DC2626" /></linearGradient><linearGradient id="g3" x1="0" y1="1" x2="1" y2="0"><stop offset="0%" stop-color="%2306B6D4" /><stop offset="100%" stop-color="%23059669" /></linearGradient></defs><circle cx="20" cy="20" r="15" fill="url(%23g1)" /><circle cx="80" cy="30" r="20" fill="url(%23g2)" /><circle cx="50" cy="70" r="25" fill="url(%23g3)" /></svg>');
background-size: cover;
background-repeat: no-repeat;
background-attachment: fixed;
background-position: center;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(17, 24, 39, 0.9) 0%, rgba(17, 24, 39, 0.7) 100%);
backdrop-filter: blur(80px); /* Stronger blur for the overlay */
z-index: -1;
}
.glass-card {
backdrop-filter: blur(20px); /* Moderate blur for cards */
background-color: rgba(30, 41, 59, 0.5); /* Slightly darker, more opaque */
border: 1px solid rgba(75, 85, 99, 0.4);
border-radius: 2rem; /* Extreme rounded corners */
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 10px 20px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease-in-out;
}
.glass-card:hover {
background-color: rgba(30, 41, 59, 0.6);
border-color: rgba(147, 197, 253, 0.6);
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.15), 0 12px 25px rgba(0, 0, 0, 0.2);
transform: translateY(-2px);
}
.text-neon {
text-shadow: 0 0 5px rgba(125, 211, 252, 0.8), 0 0 10px rgba(125, 211, 252, 0.6);
}
.fui-border {
border: 1px solid;
border-image: linear-gradient(to right, #6EE7B7, #3B82F6) 1;
}
.fui-glow {
box-shadow: 0 0 15px rgba(59, 130, 246, 0.5);
}
</style>
</head>
<body class="bg-gray-950 text-gray-100 min-h-screen p-8 relative overflow-x-hidden">
<div class="container mx-auto p-8 glass-card space-y-12">
<header class="text-center mb-12">
<h1 class="text-6xl font-extrabold text-blue-300 text-neon mb-4">TV面板LCD概念深度综合分析报告</h1>
<p class="text-xl text-gray-400">北京价值前沿科技有限公司 AI投研agent“价小前投研” 进行投研呈现</p>
<p class="text-sm text-red-400 mt-2">本报告为AI合成数据投资需谨慎。</p>
</header>
<!-- 0. 概念事件 -->
<section class="glass-card p-8 fui-glow">
<h2 class="text-4xl font-bold text-purple-300 mb-6 text-neon">0. 概念事件与时间轴</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="glass-card p-6 flex flex-col justify-between">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">2024年7月-9月</h3>
<p class="text-gray-300 flex-grow">LCD TV面板价格经历下跌或降幅收窄调整期。</p>
<p class="text-sm text-gray-400 mt-2">CINNO Research</p>
</div>
<div class="glass-card p-6 flex flex-col justify-between">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">2024年11月</h3>
<p class="text-gray-300 flex-grow">“国补”政策及国内外大促需求回暖LCD TV面板价格全线止跌趋稳。</p>
<p class="text-sm text-gray-400 mt-2">CINNO Research</p>
</div>
<div class="glass-card p-6 flex flex-col justify-between">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">2024年12月</h3>
<p class="text-gray-300 flex-grow">品牌商春节备货、国内补贴政策延续等支撑需求回升面板厂上调稼动率。Omdia数据显示55/65/75寸非战略客户价格有望小幅上涨1-2美元。</p>
<p class="text-sm text-gray-400 mt-2">Omdia, WitsView</p>
</div>
<div class="glass-card p-6 flex flex-col justify-between">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">2025年1月</h3>
<p class="text-gray-300 flex-grow">TV面板价格涨势扩大。京东方确认LCD TV主流尺寸面板价格全面上涨3月预计延续涨势。除55吋持平外其他尺寸淡季均预期上涨。</p>
<p class="text-sm text-gray-400 mt-2">(京东方, Omdia, WitsView</p>
</div>
<div class="glass-card p-6 flex flex-col justify-between">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">2025年2月-3月</h3>
<p class="text-gray-300 flex-grow">CTDZ预测各尺寸TV面板价格持续上涨65吋面板均价预期从175美元增至177美元。</p>
<p class="text-sm text-gray-400 mt-2">CTDZ预测</p>
</div>
<div class="glass-card p-6 flex flex-col justify-between">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">2025年4月</h3>
<p class="text-gray-300 flex-grow">CTDZ预测TV面板价格稳定中信证券预测价格转向平稳但仍相对较高。</p>
<p class="text-sm text-gray-400 mt-2">CTDZ, 中信证券)</p>
</div>
<div class="glass-card p-6 flex flex-col justify-between">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">22-24年 海外产能退出</h3>
<p class="text-gray-300 flex-grow">三星2022年退出LCD业务LG转向OLED。夏普SDP10代线2023年12月宣布关闭计划2024年8月停产减少大尺寸供给。</p>
<p class="text-sm text-gray-400 mt-2">(路演数据)</p>
</div>
<div class="glass-card p-6 flex flex-col justify-between">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">2024年12月 国内控产</h3>
<p class="text-gray-300 flex-grow">中国大陆头部面板厂积极实施“按需定产”策略稼动率提升至80%+仍坚持控产提价。2025年1月21日台湾地震短期加剧供给紧张。</p>
<p class="text-sm text-gray-400 mt-2">(路演数据)</p>
</div>
<div class="glass-card p-6 flex flex-col justify-between">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">中长期展望</h3>
<p class="text-gray-300 flex-grow">中信电子预计2026年Q1行业进一步普涨2027/28年行业供需将逐步平衡并转为偏紧利润加速释放。</p>
<p class="text-sm text-gray-400 mt-2">(中信电子, 中信证券)</p>
</div>
</div>
</section>
<!-- 1. 核心观点摘要 -->
<section class="glass-card p-8 fui-glow">
<h2 class="text-4xl font-bold text-purple-300 mb-6 text-neon">1. 核心观点摘要</h2>
<p class="text-gray-200 text-lg leading-relaxed">
TV面板LCD概念正经历从周期底部向上的结构性反转核心驱动力为供给侧的显著集中与有效控产叠加需求端的大尺寸化趋势及政策/事件刺激。当前市场对行业盈利修复预期强烈,且伴随国内厂商折旧压力缓解,中长期利润释放空间可期。
</p>
</section>
<!-- 2. 概念的核心逻辑与市场认知分析 -->
<section class="glass-card p-8 fui-glow">
<h2 class="text-4xl font-bold text-purple-300 mb-6 text-neon">2. 核心逻辑与市场认知分析</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="glass-card p-6 flex flex-col justify-between">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">供给侧集中度提升与有效控产</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li>中国大陆厂商主导全球TV面板市场控制超70%大尺寸产能(路演数据)。</li>
<li>京东方A和TCL科技作为双寡头CR2超过60%,具备强大稳价能力。</li>
<li>日韩台系厂商持续退出三星、LGD、夏普SDP加剧供给侧收缩。</li>
<li>面板厂普遍采取“按需定产”、“主动控产”策略稼动率维持在80%-85%(新闻数据),有效管理库存,避免恶性价格竞争。</li>
</ul>
</div>
<div class="glass-card p-6 flex flex-col justify-between">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">需求端结构性改善与韧性</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li>**大尺寸化趋势:** 行业长期核心驱动力平均尺寸每增加1寸消耗一座10.5代线产能。2023年TV面板平均尺寸提升至50.7英寸中国市场更高2024年预计62寸</li>
<li>**政策刺激与事件催化:** “国补”、“以旧换新”等政策提振终端需求。奥运会等体育赛事“26年为赛事大年”带动TV换机需求。</li>
<li>**Mini LED普及** 作为LCD高端升级路径性价比优势显著成本较OLED低60%预计2025年国内Mini LED TV销量渗透率增至35.6%,带来新的增长点。</li>
</ul>
</div>
<div class="glass-card p-6 flex flex-col justify-between col-span-1 lg:col-span-2">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">盈利能力释放的结构性拐点</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li>**折旧周期拐点:** 大陆面板厂商折旧费用在2024-2025年达顶峰此后逐步下降。京东方A的B9、B17等产线折旧结束年折旧额将减少约100亿元释放巨大业绩弹性。</li>
<li>**少数股东权益回收:** 龙头企业随现金流改善加速回收少数股东权益如TCL华星利润归母比例提升进一步增厚归母净利润。</li>
<li>**成本端持续降本:** 国产化进程推动成本下降,贡献盈利改善。</li>
</ul>
</div>
</div>
<div class="glass-card p-6 mt-6 fui-glow">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">市场热度与情绪</h3>
<p class="text-gray-300 leading-relaxed">
市场对TV面板LCD概念关注度极高情绪整体乐观积极。券商研报频繁发布中信电子、国海电子等普遍维持“重点推荐”或“坚定看好”评级。股票涨幅分析显示京东方A等公司受到券商研报、主力资金和行业基本面共振推动。市场普遍认为行业已走出低谷进入新一轮景气周期头部企业将显著受益。
</p>
</div>
<div class="glass-card p-6 mt-6 fui-glow">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">预期差分析</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li>**盈利持续性的压力:** 面板厂当前盈利达30%而品牌厂净利仅4%-5%,上下游利润分配不平衡可能导致下游品牌商抵制进一步涨价,影响价格上涨空间和持续性。</li>
<li>**稼动率控制的动态博弈:** 稼动率并非一成不变,而是根据市场供需动态调整。若需求不及预期或个别厂商未能有效控产,可能导致价格波动超出市场预期。</li>
<li>**技术路线的潜在风险:** 尽管LCD仍为主流但OLED/Mini LED的长期替代压力依然存在。Mini LED渗透速度和对传统LCD的冲击程度需持续关注。</li>
<li>**信息源的细微差异:** 不同机构对市场份额等数据的统计口径可能存在差异,投资者需辨析。</li>
</ul>
</div>
</section>
<!-- 3. 关键催化剂与未来发展路径 -->
<section class="glass-card p-8 fui-glow">
<h2 class="text-4xl font-bold text-purple-300 mb-6 text-neon">3. 关键催化剂与未来发展路径</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="glass-card p-6 flex flex-col justify-between">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">近期催化剂 (未来3-6个月)</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li>**面板价格持续上涨:** 2025年1月至3月TV面板价格持续上涨京东方已确认。</li>
<li>**体育赛事备货需求:** 2026年为赛事大年奥运会等将刺激2025年下半年至2026年上半年TV面板采购需求。</li>
<li>**中国“以旧换新”政策延续及效果:** “国补”政策延续,若力度加大将进一步刺激终端消费。</li>
<li>**海外产能进一步退出/整合:** 夏普SDP 2024年8月停产的影响逐步显现。</li>
<li>**Mini LED TV渗透率超预期** 若2025年Mini LED TV销量渗透率预计35.6%超预期将带动高端LCD面板需求增长。</li>
</ul>
</div>
<div class="glass-card p-6 flex flex-col justify-between">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">长期发展路径</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li>**供需平衡中枢上移:** 预计2027/28年行业供需将逐步平衡并转为偏紧盈利中枢持续上行。</li>
<li>**盈利结构优化:** 折旧压力缓解京东方A预计年折旧额减少100亿元少数股东权益回收增厚归母净利润。</li>
<li>**技术升级与应用拓展:** 持续推进大尺寸化和高端化如Mini LED京东方等厂商在OLED领域布局2026年5月8.6代OLED量产</li>
<li>**产业集中度深化:** 国内双寡头CR2进一步提升强化市场话语权和稳价能力减弱行业周期性。</li>
</ul>
</div>
</div>
</section>
<!-- 4. 数据可视化 -->
<section class="glass-card p-8 fui-glow">
<h2 class="text-4xl font-bold text-purple-300 mb-6 text-neon">4. 关键数据可视化</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="glass-card p-6" x-data="{ activeTab: 'price' }">
<div class="tabs tabs-boxed mb-4">
<a @click="activeTab = 'price'" :class="{ 'tab-active': activeTab === 'price' }" class="tab tab-lg text-lg text-blue-300">TV面板价格趋势</a>
<a @click="activeTab = 'marketshare'" :class="{ 'tab-active': activeTab === 'marketshare' }" class="tab tab-lg text-lg text-blue-300">TV面板市场份额</a>
</div>
<div x-show="activeTab === 'price'">
<div id="priceChart" class="w-full h-96"></div>
<p class="text-sm text-gray-400 mt-4">数据来源CINNO Research, Omdia, WitsView, CTDZ, 中信电子, 国联电子, 京东方 (经综合处理)</p>
</div>
<div x-show="activeTab === 'marketshare'">
<div id="marketShareChart" class="w-full h-96"></div>
<p class="text-sm text-gray-400 mt-4">数据来源2025年Q3全球液晶电视面板市占率 (股票数据)</p>
</div>
</div>
<div class="glass-card p-6">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">稼动率与大尺寸化趋势</h3>
<div class="space-y-4 text-gray-300">
<div class="bg-gray-700/30 p-4 rounded-xl border border-blue-500/20">
<p><span class="font-bold text-blue-300">稼动率:</span> 中国大陆头部面板厂积极控产2024年11-12月稼动率已提升至<span class="text-green-400">80%-85%</span>。行业平均稼动率自去年11月开始回升今年以来保持在80%以上,有效管理库存。</p>
<p class="text-sm text-gray-400 mt-2">(新闻数据, 路演数据)</p>
</div>
<div class="bg-gray-700/30 p-4 rounded-xl border border-blue-500/20">
<p><span class="font-bold text-blue-300">大尺寸化:</span> 2023年TV面板平均尺寸提升至<span class="text-green-400">50.7英寸</span>。中国市场平均尺寸更高2024年预计达<span class="text-green-400">62寸</span>Q4达63寸。75英寸+ TV出货量2023年增长65%2024年预计再增长30%。大尺寸化有效消化产能并提升利润。</p>
<p class="text-sm text-gray-400 mt-2">(路演数据, 新闻数据)</p>
</div>
<div class="bg-gray-700/30 p-4 rounded-xl border border-blue-500/20">
<p><span class="font-bold text-blue-300">Mini LED渗透</span> CINNO Research预计2025年国内市场Mini LED TV销量渗透率将增至<span class="text-green-400">35.6%</span>上半年销量同比激增3.2倍成本较OLED低60%。</p>
<p class="text-sm text-gray-400 mt-2">(新闻数据, 路演数据)</p>
</div>
</div>
</div>
</div>
</section>
<!-- 5. 产业链与核心公司深度剖析 -->
<section class="glass-card p-8 fui-glow">
<h2 class="text-4xl font-bold text-purple-300 mb-6 text-neon">5. 产业链与核心公司深度剖析</h2>
<h3 class="text-2xl font-semibold text-blue-200 mb-4">产业链图谱</h3>
<div class="glass-card p-6 mb-8 text-gray-300">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p class="font-bold text-blue-300">上游 (材料及零部件):</p>
<ul class="list-disc list-inside ml-4">
<li>显示材料: 偏光片 (深纺织A), 玻璃基板 (彩虹股份), 光学膜, 掩膜板, TFT混晶 (万润股份), 芳香胺 (百合花), 溶剂油。</li>
<li>背光模块: Mini LED背板 (沃格光电)。</li>
<li>触控显示: LCOS技术 (光峰科技), 各类触控显示产品 (经纬辉开)。</li>
</ul>
</div>
<div>
<p class="font-bold text-blue-300">中游 (面板制造):</p>
<ul class="list-disc list-inside ml-4">
<li>大尺寸TV面板: 京东方A, TCL科技 (华星光电), 彩虹股份。</li>
<li>中小尺寸/车载/触控显示: 深天马A, 龙腾光电, 华映科技, 经纬辉开。</li>
</ul>
</div>
<div>
<p class="font-bold text-blue-300">下游 (终端应用):</p>
<ul class="list-disc list-inside ml-4">
<li>电视品牌、智能手机、车载显示、笔记本电脑、AR/VR设备、智能穿戴等。</li>
</ul>
</div>
</div>
</div>
<h3 class="text-2xl font-semibold text-blue-200 mb-4">核心玩家对比 (主要聚焦TV面板)</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="glass-card p-6 flex flex-col justify-between">
<h4 class="text-xl font-bold text-green-300 mb-2">京东方A (000725)</h4>
<ul class="list-disc list-inside text-gray-300 space-y-1 flex-grow">
<li><span class="font-bold">竞争优势:</span> 全球LCD显示绝对领导者2025年Q3全球液晶电视面板市占率<span class="text-blue-300">27.7%</span>出货量1754万片。多条高世代线。</li>
<li><span class="font-bold">业务进展:</span> 积极控产稳价受益面板价格上涨。2025年1月起TV面板价格全面上涨。前瞻布局OLED有望2026年5月全球率先量产8.6代OLED。</li>
<li><span class="font-bold">潜在利好:</span> 折旧压力在2025-2026年逐渐结束年折旧额预计减少100亿元利润弹性巨大。</li>
</ul>
</div>
<div class="glass-card p-6 flex flex-col justify-between">
<h4 class="text-xl font-bold text-green-300 mb-2">TCL科技 (000100) (华星光电)</h4>
<ul class="list-disc list-inside text-gray-300 space-y-1 flex-grow">
<li><span class="font-bold">竞争优势:</span> 2025年Q3全球液晶电视面板市占率<span class="text-blue-300">24.7%</span>出货量1567万片。全球最大TV面板厂商之一10.5代线布局领先。与TCL实业协同效应强。</li>
<li><span class="font-bold">业务进展:</span> 控产保价利润表现积极。TCL华星利润归母比例自25Q1的60%提升至25Q3的71%,增厚归母净利润。</li>
<li><span class="font-bold">潜在利好:</span> 类似京东方A受益行业景气和产能折旧红利。内部优化整合能力强。</li>
</ul>
</div>
<div class="glass-card p-6 flex flex-col justify-between">
<h4 class="text-xl font-bold text-green-300 mb-2">彩虹股份 (600707)</h4>
<ul class="list-disc list-inside text-gray-300 space-y-1 flex-grow">
<li><span class="font-bold">竞争优势:</span> 2025年Q3全球液晶电视面板市占率<span class="text-blue-300">5.3%</span>出货量340万片。借助8.6代线灵活套切优势,提升大尺寸产能和性能。在基板玻璃领域有布局。</li>
<li><span class="font-bold">业务进展:</span> 2024年3月TV LCD面板价格创近两年新高公司面板业务经营利润表现良好稼动率高于行业水平。</li>
<li><span class="font-bold">潜在利好:</span> 特定世代线优势和基板玻璃业务提供差异化增长点。</li>
</ul>
</div>
<div class="glass-card p-6 flex flex-col justify-between col-span-1 lg:col-span-2">
<h4 class="text-xl font-bold text-green-300 mb-2">深纺织A (000045) (上游偏光片)</h4>
<ul class="list-disc list-inside text-gray-300 space-y-1 flex-grow">
<li><span class="font-bold">竞争优势:</span> 深圳市属唯一的偏光片及显示材料上市平台。国内首条2.5m超宽幅产线计划2025年底前达产可配套115寸面板大幅提升产能并降低成本。OLED用偏光片已小批量出货。</li>
<li><span class="font-bold">业务进展:</span> 受益于面板涨价和国产替代趋势。深圳国企改革政策提供潜在整合预期。</li>
<li><span class="font-bold">逻辑纯粹性:</span> 作为上游材料供应商,业绩与面板行业景气度及国产替代进程密切相关。</li>
</ul>
</div>
</div>
<div class="glass-card p-6 mt-6 fui-glow">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">验证与证伪</h3>
<ul class="list-disc list-inside text-gray-300 space-y-2">
<li>**面板价格上涨与品牌商压力:** 研报和新闻普遍看好面板价格上涨但路演中提及“面板厂当前盈利达30%品牌厂净利仅4%-5%,矛盾突出”,这可能限制后续涨价幅度和持续性。</li>
<li>**中国厂商主导地位:** 关联个股数据显示京东方A (27.7%)、TCL科技 (24.7%) 的Q3 2025 TV面板市占率合计接近50%有力印证了其在TV面板领域的定价权。</li>
<li>**稼动率波动:** 稼动率数据在不同时间点和来源间存在差异,反映行业产能管理的动态性。关键在于厂商“按需定产”策略的有效性。</li>
<li>**TCL收购LG广州工厂信息修正** 提示信息传播中可能存在的误导,投资者需警惕。</li>
</ul>
</div>
</section>
<!-- 6. 潜在风险与挑战 -->
<section class="glass-card p-8 fui-glow">
<h2 class="text-4xl font-bold text-purple-300 mb-6 text-neon">6. 潜在风险与挑战</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="glass-card p-6">
<h3 class="text-2xl font-semibold text-red-300 mb-3">需求不及预期风险</h3>
<ul class="list-disc list-inside text-gray-300 space-y-1">
<li>终端消费疲软宏观经济不确定性可能导致TV需求持续弱于预期。</li>
<li>高价抑制需求:面板价格持续上涨可能导致下游整机价格提升,抑制消费者购买意愿。</li>
</ul>
</div>
<div class="glass-card p-6">
<h3 class="text-2xl font-semibold text-red-300 mb-3">供给侧管理风险</h3>
<ul class="list-disc list-inside text-gray-300 space-y-1">
<li>稼动率控制不及预期:个别面板厂可能盲目提高稼动率,导致供给过剩。</li>
<li>新产能规划不确定性:未来新的技术或区域性产能投资可能打破供需平衡。</li>
</ul>
</div>
<div class="glass-card p-6">
<h3 class="text-2xl font-semibold text-red-300 mb-3">商业化与技术风险</h3>
<ul class="list-disc list-inside text-gray-300 space-y-1">
<li>Mini LED渗透率不及预期其成本、市场接受度及与OLED的竞争存在不确定性。</li>
<li>OLED技术替代加速若其成本下降速度超预期可能加速对LCD的替代。</li>
</ul>
</div>
<div class="glass-card p-6">
<h3 class="text-2xl font-semibold text-red-300 mb-3">政策与贸易风险</h3>
<ul class="list-disc list-inside text-gray-300 space-y-1">
<li>宏观政策变动:“国补”和“以旧换新”等政策支持力度和持续时间存在不确定性。</li>
<li>国际贸易摩擦:美国对华关税、海外贸易壁垒等可能影响出口,增加运营成本。</li>
</ul>
</div>
<div class="glass-card p-6 col-span-1 md:col-span-2">
<h3 class="text-2xl font-semibold text-red-300 mb-3">信息交叉验证风险</h3>
<ul class="list-disc list-inside text-gray-300 space-y-1">
<li>市场份额数据差异:不同机构数据可能因统计口径不同而有差异,需投资者辨析。</li>
<li>稼动率预测与实际波动:行业稼动率动态调整且存在不确定性,需关注实时数据。</li>
<li>信息误导如TCL收购LGD广州工厂的乌龙信息需警惕此类信息对判断的影响。</li>
</ul>
</div>
</div>
</section>
<!-- 7. 综合结论与投资启示 -->
<section class="glass-card p-8 fui-glow">
<h2 class="text-4xl font-bold text-purple-300 mb-6 text-neon">7. 综合结论与投资启示</h2>
<div class="mb-8">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">综合结论</h3>
<p class="text-gray-200 text-lg leading-relaxed">
TV面板LCD概念目前处于<span class="font-bold text-green-400">基本面驱动阶段</span>。核心逻辑在于供给侧的结构性改善高度集中、有效控产、海外产能退出和需求端的结构性增长大尺寸化、政策刺激、Mini LED升级叠加中国大陆厂商折旧压力逐年缓解共同推动行业盈利中枢抬升周期性波动有望减弱。虽然短期内上下游利润分配的矛盾可能导致面板价格趋于平稳但中长期来看行业已进入一个更加健康和可持续的发展阶段。
</p>
</div>
<div>
<h3 class="text-2xl font-semibold text-blue-200 mb-3">最具投资价值的细分环节或方向</h3>
<ul class="list-disc list-inside text-gray-200 text-lg space-y-2">
<li>
<span class="font-bold text-blue-300">中国大陆TV面板龙头企业</span><span class="font-bold text-green-400">京东方A</span><span class="font-bold text-green-400">TCL科技</span>。在全球TV面板市场拥有绝对主导地位具备强大的定价权和成本优势。业绩对面板价格上涨和行业格局优化最为敏感中长期有折旧结束带来的利润释放和少数股东权益回收的增厚效应。
</li>
<li>
<span class="font-bold text-blue-300">受益于国产替代和面板大尺寸化趋势的上游材料供应商:</span><span class="font-bold text-green-400">深纺织A (偏光片)</span><span class="font-bold text-green-400">彩虹股份 (玻璃基板)</span>。随着面板国产化率提升和产品大尺寸化,具备技术和产能优势的本土材料供应商将持续受益。
</li>
</ul>
</div>
<div class="mt-8">
<h3 class="text-2xl font-semibold text-blue-200 mb-3">接下来需要重点跟踪和验证的关键指标</h3>
<ul class="list-disc list-inside text-gray-200 text-lg space-y-2">
<li><span class="font-bold">TV面板价格走势</span> 尤其是55/65/75英寸以上大尺寸面板的价格变化和稳定性。</li>
<li><span class="font-bold">中国大陆面板厂的稼动率水平:</span> 关注行业平均稼动率是否能维持在80%左右的健康水平。</li>
<li><span class="font-bold">主要面板厂的盈利能力变化:</span> 重点关注龙头企业毛利率和净利润率,以及折旧费用在其财务报表中的变化趋势。</li>
<li><span class="font-bold">Mini LED TV的市场渗透率</span> 关注Mini LED TV销量数据和在高端市场的占比。</li>
<li><span class="font-bold">下游品牌厂的库存水平和采购策略:</span> 品牌商的库存健康状况和采购积极性将直接影响面板需求。</li>
</ul>
</div>
</section>
<!-- 相关公司列表 -->
<section class="glass-card p-8 fui-glow">
<h2 class="text-4xl font-bold text-purple-300 mb-6 text-neon">相关公司列表</h2>
<div class="overflow-x-auto">
<table class="table w-full text-gray-200">
<thead>
<tr class="text-blue-300">
<th>股票名称</th>
<th>股票代码</th>
<th>相关性原因</th>
<th>其他标签</th>
</tr>
</thead>
<tbody>
<tr>
<td>京东方A</td>
<td><a href="https://valuefrontier.cn/company?scode=000725" target="_blank" class="text-blue-400 hover:underline">000725</a></td>
<td>2025年Q3全球液晶电视面板市占率27.7%出货量约为1754万片</td>
<td>LCD, 大尺寸 TV面板, 行业资讯</td>
</tr>
<tr>
<td>TCL科技</td>
<td><a href="https://valuefrontier.cn/company?scode=000100" target="_blank" class="text-blue-400 hover:underline">000100</a></td>
<td>2025年Q3全球液晶电视面板市占率24.7%出货量约为1567万片</td>
<td>LCD, 大尺寸 TV面板, 行业资讯</td>
</tr>
<tr>
<td>彩虹股份</td>
<td><a href="https://valuefrontier.cn/company?scode=600707" target="_blank" class="text-blue-400 hover:underline">600707</a></td>
<td>2025年Q3全球液晶电视面板市占率5.3%出货量约为340万片</td>
<td>LCD, 大尺寸 TV面板, 行业资讯, 涨幅大于10%</td>
</tr>
<tr>
<td>深天马A</td>
<td><a href="https://valuefrontier.cn/company?scode=000050" target="_blank" class="text-blue-400 hover:underline">000050</a></td>
<td>全球车规TFT-LCD、车载仪表显示出货量全球第一</td>
<td>LCD, 中小尺寸, 调研</td>
</tr>
<tr>
<td>龙腾光电</td>
<td><a href="https://valuefrontier.cn/company?scode=688055" target="_blank" class="text-blue-400 hover:underline">688055</a></td>
<td>公司主营TFT-LCD中小尺寸显示领域</td>
<td>LCD, 中小尺寸, 半年报</td>
</tr>
<tr>
<td>华映科技</td>
<td><a href="https://valuefrontier.cn/company?scode=000536" target="_blank" class="text-blue-400 hover:underline">000536</a></td>
<td>子公司华佳彩拥有一条IGZO TFT-LCD生产线中小尺寸显示面板实控人福建国资委持股24.34%</td>
<td>LCD, 中小尺寸, 半年报, 涨幅大于10%</td>
</tr>
<tr>
<td>经纬辉开</td>
<td><a href="https://valuefrontier.cn/company?scode=300120" target="_blank" class="text-blue-400 hover:underline">300120</a></td>
<td>子公司新辉开主要生产以LCD技术为基础的各类触控显示产品</td>
<td>LCD, 中小尺寸, 互动</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 涨幅分析补充 -->
<section class="glass-card p-8 fui-glow">
<h2 class="text-4xl font-bold text-purple-300 mb-6 text-neon">涨幅分析补充</h2>
<div class="overflow-x-auto">
<table class="table w-full text-gray-200">
<thead>
<tr class="text-blue-300">
<th>股票名称</th>
<th>股票代码</th>
<th>涨幅(%)</th>
<th>交易日期</th>
<th>涨幅原因分析</th>
</tr>
</thead>
<tbody>
<tr>
<td>京东方A</td>
<td><a href="https://valuefrontier.cn/company?scode=000725" target="_blank" class="text-blue-400 hover:underline">000725</a></td>
<td>5.19</td>
<td>2025-12-05</td>
<td>**核心结论:** 京东方A显著上涨由券商深度研报强力催化、主力大资金一致行动、行业基本面向好预期及市场情绪积极变化共同作用。中信证券研报《LCD面板有望于26Q1开启新一轮涨价中长期利润释放逻辑持续强化》是直接导火索强调短期价格拐点、中长期供给侧优化、需求侧增长、利润弹性释放折旧达峰下行、少数股东权益回收。主力资金净流入16.87亿元。同时京东方在8.6代OLED生产线量产的技术领先地位也提供长期增长想象空间。</td>
</tr>
<tr>
<td>海信视像</td>
<td><a href="https://valuefrontier.cn/company?scode=600060" target="_blank" class="text-blue-400 hover:underline">600060</a></td>
<td>10.00</td>
<td>2025-09-24</td>
<td>**核心结论:** 墨西哥第二工业园规避北美关税、G7 Ultra电竞Mini-LED高毛利新品、参编国家人形机器人标准化白皮书具身智能期权三箭齐发卖方集体上调盈利预测机构空翻多引爆涨停。海外最大园区投产可节省关税运费提升净利率Mini-LED电竞显示器渗透率提升毛利率高于普通TV参与人形机器人标准化白皮书确认技术储备。</td>
</tr>
<tr>
<td>百合花</td>
<td><a href="https://valuefrontier.cn/company?scode=603823" target="_blank" class="text-blue-400 hover:underline">603823</a></td>
<td>9.98</td>
<td>2025-11-04</td>
<td>**核心结论:** “吨级面板光刻胶订单落地+芳香胺脱氨新工艺降本”双预期共振使百合花放量涨停。国科大杭高院提出N-硝基胺介导直接脱氨成本降40-50%公司颜料级芳香胺自产毛利率有望抬升。市场流传公司LCD光刻胶颜料项目首批吨级订单锁定头部面板厂。实控人误操作减持已回补消除短期压力。</td>
</tr>
<tr>
<td>深纺织A</td>
<td><a href="https://valuefrontier.cn/company?scode=000045" target="_blank" class="text-blue-400 hover:underline">000045</a></td>
<td>10.00</td>
<td>2025-10-13</td>
<td>**核心结论:** “深圳国企改革深化提升行动推进会”明确2025年前完成电子信息产业链战略性重组公司作为市属唯一偏光片上市平台被资金率先定价。叠加2.5m超宽幅产线年底试车、面板涨价带来业绩拐点三者共振触发涨停。公司大股东承诺支持做强做大具备重组特征。国内首条2.5m超宽幅产线可配套115寸面板OLED用偏光片已小批量出货。</td>
</tr>
<tr>
<td>冠捷科技</td>
<td><a href="https://valuefrontier.cn/company?scode=000727" target="_blank" class="text-blue-400 hover:underline">000727</a></td>
<td>10.04</td>
<td>2025-09-15</td>
<td>**核心结论:** “工信部9·4显示终端国产化替代政策”与“9·12京东方电竞嘉年华8.3亿元大单”两大硬信息在周末发酵后的资金滞后共振。政策首次量化“2026年国产品牌市占率≥75%”明确商用大屏、电竞显示器为首批替代场景冠捷科技ODM市占率高三大品牌受益。BOE电竞嘉年华公司作为独家显示设备合作伙伴现场锁定8.3亿元订单。</td>
</tr>
<tr>
<td>莱宝高科</td>
<td><a href="https://valuefrontier.cn/company?scode=002106" target="_blank" class="text-blue-400 hover:underline">002106</a></td>
<td>9.96</td>
<td>2025-09-05</td>
<td>**核心结论:** 部委级《电子信息制造业稳增长方案》首次将“电子纸”列为新型显示重点并给出2027年产值年增>10%量化目标莱宝高科作为A股唯一已量产彩色电子纸模组低位+稀缺触发涨停。重庆4.5代彩色电子纸模组线投产年产能600万片/18亿元产值。政策明确支持车载显示、HUD公司In-cell触控模组已小批量交付问界、长安深蓝。</td>
</tr>
<tr>
<td>万润股份</td>
<td><a href="https://valuefrontier.cn/company?scode=002643" target="_blank" class="text-blue-400 hover:underline">002643</a></td>
<td>10.00</td>
<td>2025-10-27</td>
<td>**核心结论:** 凌晨财政部拟将“高端液晶单体用溶剂油”纳入80%退税清单叠加TFT混晶周涨6.8%与OLED前端材料提前量产三因素共振使2026E EPS上修40%资金开盘一次性抢筹至涨停。高端液晶单体用溶剂油退税年化增利1.1-1.3亿元。TFT混晶周涨6.8%公司系京东方二供。150吨OLED前端材料产线10月投料量产节点提前2个月。</td>
</tr>
<tr>
<td>沃格光电</td>
<td><a href="https://valuefrontier.cn/company?scode=603773" target="_blank" class="text-blue-400 hover:underline">603773</a></td>
<td>10.00</td>
<td>2025-08-08</td>
<td>**核心结论:** Chiplet-2D/3DIC 集成基板技术验证成功叠加 Mini LED 背板获 DIC2025 双项大奖及 GCP 认证双重产业催化引爆资金抢筹。国内首条面向Chiplet的2.5D/3D集成基板完成工艺验证良率>90%打开AI服务器、车载算力芯片增量空间。公司GCP Mini LED背板通过可靠性测试已获三星、LG 2026 Mini LED电视供应链门票。</td>
</tr>
<tr>
<td>光峰科技</td>
<td><a href="https://valuefrontier.cn/company?scode=688007" target="_blank" class="text-blue-400 hover:underline">688007</a></td>
<td>9.73</td>
<td>2025-08-26</td>
<td>**核心结论:** Meta第三代眼镜采用LCos技术重大利好且光峰科技在LCos领域具领先地位。Meta新眼镜增加单目全彩显示版本并采用LCos技术续航提升、成本远低于MicroLED。公司LCos方案除外采外其他环节自研打通核心能力为自制激光器。AR眼镜LCos作为高价值环节市场空间巨大。</td>
</tr>
<tr>
<td>合力泰</td>
<td><a href="https://valuefrontier.cn/company?scode=002217" target="_blank" class="text-blue-400 hover:underline">002217</a></td>
<td>9.92</td>
<td>2025-08-20</td>
<td>**核心结论:** 屏下摄像概念走强,合力泰作为概念股之一领涨。当时市场风格转向低价股,合力泰作为低价科技股受资金青睐。投资者对公司业务转型或“重生”阶段抱有较高期待,认为未来发展空间较大。技术面突破也吸引了更多买盘跟进。</td>
</tr>
<tr>
<td>日久光电</td>
<td><a href="https://valuefrontier.cn/company?scode=003015" target="_blank" class="text-blue-400 hover:underline">003015</a></td>
<td>6.14</td>
<td>2025-06-16</td>
<td>**核心结论:** AI眼镜概念走强、显示行业整合加速等外部因素带动。日久光电作为光电显示薄膜器件供应商受益于AI眼镜等新型显示设备概念热度。京东方A拟收购彩虹光电股权表明行业整合趋势。公司主力资金运作、业绩预期向好产品应用于柔性显示、智能穿戴等领域。</td>
</tr>
<tr>
<td>隆利科技</td>
<td><a href="https://valuefrontier.cn/company?scode=300752" target="_blank" class="text-blue-400 hover:underline">300752</a></td>
<td>8.59</td>
<td>2025-08-20</td>
<td>**核心结论:** 公司在LIPO技术领域进展及与主要客户的合作信息。LIPO技术被视为新型屏幕封装工艺旨在实现窄边框和提升显示效果。公司LIPO技术项目已实现穿戴类产品量产并在手机类产品取得进展。此外公司VR背光显示产品获客户认可车载MiniLED业务稳定增长。</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<script>
// Echarts for Price Chart
var priceChartDom = document.getElementById('priceChart');
var priceChart = echarts.init(priceChartDom, 'dark'); // Using 'dark' theme for better contrast
var priceOption;
// Data points extracted and simplified for clear trend representation
// (Note: Minor inconsistencies between sources for the same month are averaged or prioritized based on detail)
const priceDates = [
'2024-03', '2024-07', '2024-08', '2024-09', '2024-11', '2024-12',
'2025-01', '2025-02', '2025-03', '2025-04'
];
const priceData32 = [
36, // 2024-03 (Witsview)
39, // 2024-07 (Runto, est. from -2.5%)
38, // 2024-08 (est. from drop)
37, // 2024-09 (est. from drop)
32, // 2024-11 (CINNO)
33, // 2024-12 (Omdia actual)
34, // 2025-01 (WitsView, est +1)
35.5, // 2025-02 (CTDZ)
36, // 2025-03 (CTDZ)
36 // 2025-04 (CTDZ)
];
const priceData55 = [
127, // 2024-03 (Witsview)
135, // 2024-07 (Runto, est. from -0.7%)
130, // 2024-08 (est. from drop)
125, // 2024-09 (est. from drop)
108, // 2024-11 (CINNO)
114, // 2024-12 (Omdia actual)
114, // 2025-01 (WitsView, est flat)
126, // 2025-02 (CTDZ)
127, // 2025-03 (CTDZ)
127 // 2025-04 (CTDZ)
];
const priceData65 = [
171, // 2024-03 (Witsview)
181, // 2024-07 (Runto, est. from -0.5%)
175, // 2024-08 (est. from drop)
170, // 2024-09 (est. from drop)
163, // 2024-11 (CINNO)
160, // 2024-12 (Omdia actual)
162, // 2025-01 (WitsView, est +2)
175, // 2025-02 (CTDZ)
177, // 2025-03 (CTDZ)
177 // 2025-04 (CTDZ)
];
priceOption = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['32吋', '55吋', '65吋'],
textStyle: {
color: '#a0aec0' // Gray text for legend
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: priceDates,
axisLabel: {
color: '#a0aec0' // Gray text for axis labels
}
},
yAxis: {
type: 'value',
name: '价格 (美元/片)',
nameTextStyle: {
color: '#a0aec0'
},
axisLabel: {
color: '#a0aec0'
},
splitLine: {
lineStyle: {
color: 'rgba(255, 255, 255, 0.1)' // Light grey dashed lines for grid
}
}
},
series: [
{
name: '32吋',
type: 'line',
smooth: true,
data: priceData32,
itemStyle: {
color: '#6EE7B7' // Green
}
},
{
name: '55吋',
type: 'line',
smooth: true,
data: priceData55,
itemStyle: {
color: '#3B82F6' // Blue
}
},
{
name: '65吋',
type: 'line',
smooth: true,
data: priceData65,
itemStyle: {
color: '#EC4899' // Pink
}
}
],
textStyle: {
color: '#a0aec0'
}
};
priceChart.setOption(priceOption);
// Echarts for Market Share Chart
var marketShareChartDom = document.getElementById('marketShareChart');
var marketShareChart = echarts.init(marketShareChartDom, 'dark'); // Using 'dark' theme
var marketShareOption;
const marketShareData = [
{ value: 27.7, name: '京东方A' },
{ value: 24.7, name: 'TCL科技' },
{ value: 5.3, name: '彩虹股份' },
{ value: 100 - (27.7 + 24.7 + 5.3), name: '其他厂商' }
];
marketShareOption = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
left: 'left',
data: ['京东方A', 'TCL科技', '彩虹股份', '其他厂商'],
textStyle: {
color: '#a0aec0'
}
},
series: [
{
name: 'TV面板市场份额(2025 Q3)',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: '20',
fontWeight: 'bold',
color: '#a0aec0'
}
},
labelLine: {
show: false
},
data: marketShareData,
itemStyle: {
borderRadius: 10,
borderColor: '#1e293b',
borderWidth: 2,
color: function(params) {
var colorList = ['#3B82F6', '#EC4899', '#6EE7B7', '#A78BFA']; // Blue, Pink, Green, Purple
return colorList[params.dataIndex];
}
}
}
],
textStyle: {
color: '#a0aec0'
}
};
marketShareChart.setOption(marketShareOption);
// Responsive charts
window.addEventListener('resize', function() {
priceChart.resize();
marketShareChart.resize();
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,68 @@
-- 概念分钟级异动数据表
-- 用于存储概念板块的实时异动信息,支持热点概览图表展示
CREATE TABLE IF NOT EXISTS concept_minute_alert (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
concept_id VARCHAR(32) NOT NULL COMMENT '概念ID',
concept_name VARCHAR(100) NOT NULL COMMENT '概念名称',
alert_time DATETIME NOT NULL COMMENT '异动时间(精确到分钟)',
alert_type VARCHAR(20) NOT NULL COMMENT '异动类型surge(急涨)/limit_up(涨停增加)/rank_jump(排名跃升)',
trade_date DATE NOT NULL COMMENT '交易日期',
-- 涨跌幅相关
change_pct DECIMAL(10,4) COMMENT '当时涨跌幅(%)',
prev_change_pct DECIMAL(10,4) COMMENT '之前涨跌幅(%)',
change_delta DECIMAL(10,4) COMMENT '涨幅变化量(%)',
-- 涨停相关
limit_up_count INT DEFAULT 0 COMMENT '当前涨停数量',
prev_limit_up_count INT DEFAULT 0 COMMENT '之前涨停数量',
limit_up_delta INT DEFAULT 0 COMMENT '涨停变化数量',
-- 排名相关
rank_position INT COMMENT '当前涨幅排名',
prev_rank_position INT COMMENT '之前涨幅排名',
rank_delta INT COMMENT '排名变化(负数表示上升)',
-- 指数位置用于图表Y轴定位
index_code VARCHAR(20) DEFAULT '000001.SH' COMMENT '参考指数代码',
index_price DECIMAL(12,4) COMMENT '异动时的指数点位',
index_change_pct DECIMAL(10,4) COMMENT '异动时的指数涨跌幅(%)',
-- 概念详情
stock_count INT COMMENT '概念包含股票数',
concept_type VARCHAR(20) DEFAULT 'leaf' COMMENT '概念类型leaf/lv1/lv2/lv3',
-- 额外信息JSON格式存储涨停股票列表等
extra_info JSON COMMENT '额外信息',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 索引
INDEX idx_trade_date (trade_date),
INDEX idx_alert_time (alert_time),
INDEX idx_concept_id (concept_id),
INDEX idx_alert_type (alert_type),
INDEX idx_trade_date_time (trade_date, alert_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='概念分钟级异动数据表';
-- 创建指数分时快照表(用于异动时获取指数位置)
CREATE TABLE IF NOT EXISTS index_minute_snapshot (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
index_code VARCHAR(20) NOT NULL COMMENT '指数代码',
trade_date DATE NOT NULL COMMENT '交易日期',
snapshot_time DATETIME NOT NULL COMMENT '快照时间',
price DECIMAL(12,4) COMMENT '指数点位',
open_price DECIMAL(12,4) COMMENT '开盘价',
high_price DECIMAL(12,4) COMMENT '最高价',
low_price DECIMAL(12,4) COMMENT '最低价',
prev_close DECIMAL(12,4) COMMENT '昨收价',
change_pct DECIMAL(10,4) COMMENT '涨跌幅(%)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_index_time (index_code, snapshot_time),
INDEX idx_trade_date (trade_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指数分时快照表';

View File

@@ -313,12 +313,29 @@ const StockChartAntdModal = ({
axisPointer: { type: 'cross' },
formatter: function(params) {
const d = params[0]?.dataIndex ?? 0;
const priceChangePercent = ((prices[d] - prevClose) / prevClose * 100);
const avgChangePercent = ((avgPrices[d] - prevClose) / prevClose * 100);
const price = prices[d];
const avgPrice = avgPrices[d];
const volume = volumes[d];
// 安全计算涨跌幅,处理 undefined/null/0 的情况
const safeCalcPercent = (val, base) => {
if (val == null || base == null || base === 0) return 0;
return ((val - base) / base * 100);
};
const priceChangePercent = safeCalcPercent(price, prevClose);
const avgChangePercent = safeCalcPercent(avgPrice, prevClose);
const priceColor = priceChangePercent >= 0 ? '#ef5350' : '#26a69a';
const avgColor = avgChangePercent >= 0 ? '#ef5350' : '#26a69a';
return `时间:${times[d]}<br/>现价:<span style="color: ${priceColor}">¥${prices[d]?.toFixed(2)} (${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed(2)}%)</span><br/>均价:<span style="color: ${avgColor}">¥${avgPrices[d]?.toFixed(2)} (${avgChangePercent >= 0 ? '+' : ''}${avgChangePercent.toFixed(2)}%)</span><br/>昨收:¥${prevClose?.toFixed(2)}<br/>成交量:${Math.round(volumes[d]/100)}`;
// 安全格式化数字
const safeFixed = (val, digits = 2) => (val != null && !isNaN(val)) ? val.toFixed(digits) : '-';
const formatPercent = (val) => {
if (val == null || isNaN(val)) return '-';
return (val >= 0 ? '+' : '') + val.toFixed(2) + '%';
};
return `时间:${times[d] || '-'}<br/>现价:<span style="color: ${priceColor}">¥${safeFixed(price)} (${formatPercent(priceChangePercent)})</span><br/>均价:<span style="color: ${avgColor}">¥${safeFixed(avgPrice)} (${formatPercent(avgChangePercent)})</span><br/>昨收:¥${safeFixed(prevClose)}<br/>成交量:${volume != null ? Math.round(volume/100) + '手' : '-'}`;
}
},
grid: [
@@ -337,6 +354,7 @@ const StockChartAntdModal = ({
position: 'left',
axisLabel: {
formatter: function(value) {
if (value == null || isNaN(value)) return '-';
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
}
},
@@ -354,11 +372,12 @@ const StockChartAntdModal = ({
position: 'right',
axisLabel: {
formatter: function(value) {
if (value == null || isNaN(value)) return '-';
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
}
}
},
{ type: 'value', gridIndex: 1, scale: true, axisLabel: { formatter: v => Math.round(v/100) + '手' } }
{ type: 'value', gridIndex: 1, scale: true, axisLabel: { formatter: v => (v != null && !isNaN(v)) ? Math.round(v/100) + '手' : '-' } }
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 },

View File

@@ -217,27 +217,34 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
if (dataIndex === undefined) return '';
const item = data[dataIndex];
const changeColor = item.change_percent >= 0 ? '#ef5350' : '#26a69a';
const changeSign = item.change_percent >= 0 ? '+' : '';
if (!item) return '';
// 安全格式化数字
const safeFixed = (val: any, digits = 2) =>
val != null && !isNaN(val) ? Number(val).toFixed(digits) : '-';
const changePercent = item.change_percent ?? 0;
const changeColor = changePercent >= 0 ? '#ef5350' : '#26a69a';
const changeSign = changePercent >= 0 ? '+' : '';
return `
<div style="padding: 8px;">
<div style="font-weight: bold; margin-bottom: 8px;">${item.time}</div>
<div style="font-weight: bold; margin-bottom: 8px;">${item.time || '-'}</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>价格:</span>
<span style="color: ${changeColor}; font-weight: bold; margin-left: 20px;">${item.price.toFixed(2)}</span>
<span style="color: ${changeColor}; font-weight: bold; margin-left: 20px;">${safeFixed(item.price)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>均价:</span>
<span style="color: #ffa726; margin-left: 20px;">${item.avg_price.toFixed(2)}</span>
<span style="color: #ffa726; margin-left: 20px;">${safeFixed(item.avg_price)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>涨跌幅:</span>
<span style="color: ${changeColor}; margin-left: 20px;">${changeSign}${item.change_percent.toFixed(2)}%</span>
<span style="color: ${changeColor}; margin-left: 20px;">${changeSign}${safeFixed(changePercent)}%</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>成交量:</span>
<span style="margin-left: 20px;">${(item.volume / 100).toFixed(0)}手</span>
<span style="margin-left: 20px;">${item.volume != null ? (item.volume / 100).toFixed(0) : '-'}手</span>
</div>
</div>
`;
@@ -314,7 +321,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => value.toFixed(2),
formatter: (value: number) => (value != null && !isNaN(value)) ? value.toFixed(2) : '-',
},
},
{
@@ -333,6 +340,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => {
if (value == null || isNaN(value)) return '-';
if (value >= 10000) {
return (value / 10000).toFixed(1) + '万';
}

View File

@@ -0,0 +1,232 @@
/**
* SubTabContainer - 二级导航容器组件
*
* 用于模块内的子功能切换(如公司档案下的股权结构、管理团队等)
* 与 TabContainer一级导航区分无 Card 包裹,直接融入父容器
*
* @example
* ```tsx
* <SubTabContainer
* tabs={[
* { key: 'tab1', name: 'Tab 1', icon: FaHome, component: Tab1 },
* { key: 'tab2', name: 'Tab 2', icon: FaUser, component: Tab2 },
* ]}
* componentProps={{ stockCode: '000001' }}
* onTabChange={(index, key) => console.log('切换到', key)}
* />
* ```
*/
import React, { useState, useCallback, memo } from 'react';
import {
Box,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Icon,
HStack,
Text,
Spacer,
} from '@chakra-ui/react';
import type { ComponentType } from 'react';
import type { IconType } from 'react-icons';
/**
* Tab 配置项
*/
export interface SubTabConfig {
key: string;
name: string;
icon?: IconType | ComponentType;
component?: ComponentType<any>;
}
/**
* 主题配置
*/
export interface SubTabTheme {
bg: string;
borderColor: string;
tabSelectedBg: string;
tabSelectedColor: string;
tabUnselectedColor: string;
tabHoverBg: string;
}
/**
* 预设主题
*/
const THEME_PRESETS: Record<string, SubTabTheme> = {
blackGold: {
bg: 'gray.900',
borderColor: 'rgba(212, 175, 55, 0.3)',
tabSelectedBg: '#D4AF37',
tabSelectedColor: 'gray.900',
tabUnselectedColor: '#D4AF37',
tabHoverBg: 'gray.600',
},
default: {
bg: 'white',
borderColor: 'gray.200',
tabSelectedBg: 'blue.500',
tabSelectedColor: 'white',
tabUnselectedColor: 'gray.600',
tabHoverBg: 'gray.100',
},
};
export interface SubTabContainerProps {
/** Tab 配置数组 */
tabs: SubTabConfig[];
/** 传递给 Tab 内容组件的 props */
componentProps?: Record<string, any>;
/** 默认选中的 Tab 索引 */
defaultIndex?: number;
/** 受控模式下的当前索引 */
index?: number;
/** Tab 变更回调 */
onTabChange?: (index: number, tabKey: string) => void;
/** 主题预设 */
themePreset?: 'blackGold' | 'default';
/** 自定义主题(优先级高于预设) */
theme?: Partial<SubTabTheme>;
/** 内容区内边距 */
contentPadding?: number;
/** 是否懒加载 */
isLazy?: boolean;
/** TabList 右侧自定义内容 */
rightElement?: React.ReactNode;
}
const SubTabContainer: React.FC<SubTabContainerProps> = memo(({
tabs,
componentProps = {},
defaultIndex = 0,
index: controlledIndex,
onTabChange,
themePreset = 'blackGold',
theme: customTheme,
contentPadding = 4,
isLazy = true,
rightElement,
}) => {
// 内部状态(非受控模式)
const [internalIndex, setInternalIndex] = useState(defaultIndex);
// 当前索引
const currentIndex = controlledIndex ?? internalIndex;
// 记录已访问的 Tab 索引(用于真正的懒加载)
const [visitedTabs, setVisitedTabs] = useState<Set<number>>(
() => new Set([controlledIndex ?? defaultIndex])
);
// 合并主题
const theme: SubTabTheme = {
...THEME_PRESETS[themePreset],
...customTheme,
};
/**
* 处理 Tab 切换
*/
const handleTabChange = useCallback(
(newIndex: number) => {
const tabKey = tabs[newIndex]?.key || '';
onTabChange?.(newIndex, tabKey);
// 记录已访问的 Tab用于懒加载
setVisitedTabs(prev => {
if (prev.has(newIndex)) return prev;
return new Set(prev).add(newIndex);
});
if (controlledIndex === undefined) {
setInternalIndex(newIndex);
}
},
[tabs, onTabChange, controlledIndex]
);
return (
<Box>
<Tabs
isLazy={isLazy}
variant="unstyled"
index={currentIndex}
onChange={handleTabChange}
>
<TabList
bg={theme.bg}
borderBottom="1px solid"
borderColor={theme.borderColor}
pl={0}
pr={2}
py={1.5}
flexWrap="nowrap"
gap={1}
alignItems="center"
overflowX="auto"
css={{
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
color={theme.tabUnselectedColor}
borderRadius="full"
px={2.5}
py={1.5}
fontSize="xs"
whiteSpace="nowrap"
flexShrink={0}
_selected={{
bg: theme.tabSelectedBg,
color: theme.tabSelectedColor,
fontWeight: 'bold',
}}
_hover={{
bg: theme.tabHoverBg,
}}
>
<HStack spacing={1}>
{tab.icon && <Icon as={tab.icon} boxSize={3} />}
<Text>{tab.name}</Text>
</HStack>
</Tab>
))}
{rightElement && (
<>
<Spacer />
<Box flexShrink={0}>{rightElement}</Box>
</>
)}
</TabList>
<TabPanels p={contentPadding}>
{tabs.map((tab, idx) => {
const Component = tab.component;
// 懒加载:只渲染已访问过的 Tab
const shouldRender = !isLazy || visitedTabs.has(idx);
return (
<TabPanel key={tab.key} p={0}>
{shouldRender && Component ? (
<Component {...componentProps} />
) : null}
</TabPanel>
);
})}
</TabPanels>
</Tabs>
</Box>
);
});
SubTabContainer.displayName = 'SubTabContainer';
export default SubTabContainer;

View File

@@ -0,0 +1,56 @@
/**
* TabNavigation 通用导航组件
*
* 渲染 Tab 按钮列表,支持图标 + 文字
*/
import React from 'react';
import { TabList, Tab, HStack, Icon, Text } from '@chakra-ui/react';
import type { TabNavigationProps } from './types';
const TabNavigation: React.FC<TabNavigationProps> = ({
tabs,
themeColors,
borderRadius = 'lg',
}) => {
return (
<TabList
bg={themeColors.bg}
borderBottom="1px solid"
borderColor={themeColors.dividerColor}
borderTopLeftRadius={borderRadius}
borderTopRightRadius={borderRadius}
pl={0}
pr={4}
py={2}
flexWrap="wrap"
gap={2}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
color={themeColors.unselectedText}
borderRadius="full"
px={4}
py={2}
fontSize="sm"
_selected={{
bg: themeColors.selectedBg,
color: themeColors.selectedText,
fontWeight: 'bold',
}}
_hover={{
bg: 'whiteAlpha.100',
}}
>
<HStack spacing={2}>
{tab.icon && <Icon as={tab.icon} boxSize={4} />}
<Text>{tab.name}</Text>
</HStack>
</Tab>
))}
</TabList>
);
};
export default TabNavigation;

View File

@@ -0,0 +1,55 @@
/**
* TabContainer 常量和主题预设
*/
import type { ThemeColors, ThemePreset } from './types';
/**
* 主题预设配置
*/
export const THEME_PRESETS: Record<ThemePreset, Required<ThemeColors>> = {
// 黑金主题(原 Company 模块风格)
blackGold: {
bg: '#1A202C',
selectedBg: '#C9A961',
selectedText: '#FFFFFF',
unselectedText: '#D4AF37',
dividerColor: 'gray.600',
},
// 默认主题Chakra 风格)
default: {
bg: 'white',
selectedBg: 'blue.500',
selectedText: 'white',
unselectedText: 'gray.600',
dividerColor: 'gray.200',
},
// 深色主题
dark: {
bg: 'gray.800',
selectedBg: 'blue.400',
selectedText: 'white',
unselectedText: 'gray.300',
dividerColor: 'gray.600',
},
// 浅色主题
light: {
bg: 'gray.50',
selectedBg: 'blue.500',
selectedText: 'white',
unselectedText: 'gray.700',
dividerColor: 'gray.300',
},
};
/**
* 默认配置
*/
export const DEFAULT_CONFIG = {
themePreset: 'blackGold' as ThemePreset,
isLazy: true,
size: 'lg' as const,
borderRadius: 'lg',
shadow: 'lg',
panelPadding: 0,
};

View File

@@ -0,0 +1,134 @@
/**
* TabContainer 通用 Tab 容器组件
*
* 功能:
* - 管理 Tab 切换状态(支持受控/非受控模式)
* - 动态渲染 Tab 导航和内容
* - 支持多种主题预设(黑金、默认、深色、浅色)
* - 支持自定义主题颜色
* - 支持懒加载
*
* @example
* // 基础用法(传入 components
* <TabContainer
* tabs={[
* { key: 'tab1', name: 'Tab 1', icon: FaHome, component: Tab1Content },
* { key: 'tab2', name: 'Tab 2', icon: FaUser, component: Tab2Content },
* ]}
* componentProps={{ userId: '123' }}
* onTabChange={(index, key) => console.log('切换到', key)}
* />
*
* @example
* // 自定义渲染用法(使用 children
* <TabContainer tabs={tabs} themePreset="dark">
* <TabPanel>自定义内容 1</TabPanel>
* <TabPanel>自定义内容 2</TabPanel>
* </TabContainer>
*/
import React, { useState, useCallback, useMemo } from 'react';
import {
Card,
CardBody,
Tabs,
TabPanels,
TabPanel,
} from '@chakra-ui/react';
import TabNavigation from './TabNavigation';
import { THEME_PRESETS, DEFAULT_CONFIG } from './constants';
import type { TabContainerProps, ThemeColors } from './types';
// 导出类型和常量
export type { TabConfig, ThemeColors, ThemePreset, TabContainerProps } from './types';
export { THEME_PRESETS } from './constants';
const TabContainer: React.FC<TabContainerProps> = ({
tabs,
componentProps = {},
onTabChange,
defaultIndex = 0,
index: controlledIndex,
themePreset = DEFAULT_CONFIG.themePreset,
themeColors: customThemeColors,
isLazy = DEFAULT_CONFIG.isLazy,
size = DEFAULT_CONFIG.size,
borderRadius = DEFAULT_CONFIG.borderRadius,
shadow = DEFAULT_CONFIG.shadow,
panelPadding = DEFAULT_CONFIG.panelPadding,
children,
}) => {
// 内部状态(非受控模式)
const [internalIndex, setInternalIndex] = useState(defaultIndex);
// 当前索引(支持受控/非受控)
const currentIndex = controlledIndex ?? internalIndex;
// 合并主题颜色(自定义颜色优先)
const themeColors: Required<ThemeColors> = useMemo(() => ({
...THEME_PRESETS[themePreset],
...customThemeColors,
}), [themePreset, customThemeColors]);
/**
* 处理 Tab 切换
*/
const handleTabChange = useCallback((newIndex: number) => {
const tabKey = tabs[newIndex]?.key || '';
// 触发回调
onTabChange?.(newIndex, tabKey, currentIndex);
// 非受控模式下更新内部状态
if (controlledIndex === undefined) {
setInternalIndex(newIndex);
}
}, [tabs, onTabChange, currentIndex, controlledIndex]);
/**
* 渲染 Tab 内容
*/
const renderTabPanels = () => {
// 如果传入了 children直接渲染 children
if (children) {
return children;
}
// 否则根据 tabs 配置渲染
return tabs.map((tab) => {
const Component = tab.component;
return (
<TabPanel key={tab.key} px={panelPadding} py={panelPadding}>
{Component ? <Component {...componentProps} /> : null}
</TabPanel>
);
});
};
return (
<Card shadow={shadow} bg={themeColors.bg} borderRadius={borderRadius}>
<CardBody p={0}>
<Tabs
isLazy={isLazy}
variant="unstyled"
size={size}
index={currentIndex}
onChange={handleTabChange}
>
{/* Tab 导航 */}
<TabNavigation
tabs={tabs}
themeColors={themeColors}
borderRadius={borderRadius}
/>
{/* Tab 内容面板 */}
<TabPanels>{renderTabPanels()}</TabPanels>
</Tabs>
</CardBody>
</Card>
);
};
export default TabContainer;

View File

@@ -0,0 +1,85 @@
/**
* TabContainer 通用 Tab 容器组件类型定义
*/
import type { ComponentType, ReactNode } from 'react';
import type { IconType } from 'react-icons';
/**
* Tab 配置项
*/
export interface TabConfig {
/** Tab 唯一标识 */
key: string;
/** Tab 显示名称 */
name: string;
/** Tab 图标(可选) */
icon?: IconType | ComponentType;
/** Tab 内容组件(可选,如果不传则使用 children 渲染) */
component?: ComponentType<any>;
}
/**
* 主题颜色配置
*/
export interface ThemeColors {
/** 容器背景色 */
bg?: string;
/** 选中 Tab 背景色 */
selectedBg?: string;
/** 选中 Tab 文字颜色 */
selectedText?: string;
/** 未选中 Tab 文字颜色 */
unselectedText?: string;
/** 分割线颜色 */
dividerColor?: string;
}
/**
* 预设主题类型
*/
export type ThemePreset = 'blackGold' | 'default' | 'dark' | 'light';
/**
* TabContainer 组件 Props
*/
export interface TabContainerProps {
/** Tab 配置数组 */
tabs: TabConfig[];
/** 传递给 Tab 内容组件的通用 props */
componentProps?: Record<string, any>;
/** Tab 变更回调 */
onTabChange?: (index: number, tabKey: string, prevIndex: number) => void;
/** 默认选中的 Tab 索引 */
defaultIndex?: number;
/** 受控模式下的当前索引 */
index?: number;
/** 主题预设 */
themePreset?: ThemePreset;
/** 自定义主题颜色(优先级高于预设) */
themeColors?: ThemeColors;
/** 是否启用懒加载 */
isLazy?: boolean;
/** Tab 尺寸 */
size?: 'sm' | 'md' | 'lg';
/** 容器圆角 */
borderRadius?: string;
/** 容器阴影 */
shadow?: string;
/** 自定义 Tab 面板内边距 */
panelPadding?: number | string;
/** 子元素(用于自定义渲染 Tab 内容) */
children?: ReactNode;
}
/**
* TabNavigation 组件 Props
*/
export interface TabNavigationProps {
/** Tab 配置数组 */
tabs: TabConfig[];
/** 主题颜色 */
themeColors: Required<ThemeColors>;
/** 容器圆角 */
borderRadius?: string;
}

View File

@@ -0,0 +1,100 @@
/**
* TabPanelContainer - Tab 面板通用容器组件
*
* 提供统一的:
* - Loading 状态处理
* - VStack 布局
* - 免责声明(可选)
*
* @example
* ```tsx
* <TabPanelContainer loading={loading} showDisclaimer>
* <YourContent />
* </TabPanelContainer>
* ```
*/
import React, { memo } from 'react';
import { VStack, Center, Spinner, Text, Box } from '@chakra-ui/react';
// 默认免责声明文案
const DEFAULT_DISCLAIMER =
'免责声明本内容由AI模型基于新闻、公告、研报等公开信息自动分析和生成未经许可严禁转载。所有内容仅供参考不构成任何投资建议请投资者注意风险独立审慎决策。';
export interface TabPanelContainerProps {
/** 是否处于加载状态 */
loading?: boolean;
/** 加载状态显示的文案 */
loadingMessage?: string;
/** 加载状态高度 */
loadingHeight?: string;
/** 子组件间距,默认 6 */
spacing?: number;
/** 内边距,默认 4 */
padding?: number;
/** 是否显示免责声明,默认 false */
showDisclaimer?: boolean;
/** 自定义免责声明文案 */
disclaimerText?: string;
/** 子组件 */
children: React.ReactNode;
}
/**
* 加载状态组件
*/
const LoadingState: React.FC<{ message: string; height: string }> = ({
message,
height,
}) => (
<Center h={height}>
<VStack spacing={3}>
<Spinner size="lg" color="#D4AF37" thickness="3px" />
<Text fontSize="sm" color="gray.500">
{message}
</Text>
</VStack>
</Center>
);
/**
* 免责声明组件
*/
const DisclaimerText: React.FC<{ text: string }> = ({ text }) => (
<Text mt={4} color="gray.500" fontSize="12px" lineHeight="1.5">
{text}
</Text>
);
/**
* Tab 面板通用容器
*/
const TabPanelContainer: React.FC<TabPanelContainerProps> = memo(
({
loading = false,
loadingMessage = '加载中...',
loadingHeight = '200px',
spacing = 6,
padding = 4,
showDisclaimer = false,
disclaimerText = DEFAULT_DISCLAIMER,
children,
}) => {
if (loading) {
return <LoadingState message={loadingMessage} height={loadingHeight} />;
}
return (
<Box p={padding}>
<VStack spacing={spacing} align="stretch">
{children}
</VStack>
{showDisclaimer && <DisclaimerText text={disclaimerText} />}
</Box>
);
}
);
TabPanelContainer.displayName = 'TabPanelContainer';
export default TabPanelContainer;

View File

@@ -661,6 +661,12 @@ export const NotificationProvider = ({ children }) => {
// ========== 连接到 Socket 服务(⚡ 异步初始化,不阻塞首屏) ==========
useEffect(() => {
// ⚡ Mock 模式下跳过 Socket 连接(避免连接生产服务器失败的错误)
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
logger.debug('NotificationContext', 'Mock 模式,跳过 Socket 连接');
return;
}
// ⚡ 防止 React Strict Mode 导致的重复初始化
if (socketInitialized) {
logger.debug('NotificationContext', 'Socket 已初始化跳过重复执行Strict Mode 保护)');

View File

@@ -5,6 +5,17 @@ import { BrowserRouter as Router } from 'react-router-dom';
// ⚡ 性能监控:在应用启动时尽早标记
import { performanceMonitor } from './utils/performanceMonitor';
// T0: HTML 加载完成时间点
if (document.readyState === 'complete') {
performanceMonitor.mark('html-loaded');
} else {
window.addEventListener('load', () => {
performanceMonitor.mark('html-loaded');
});
}
// T1: React 开始初始化
performanceMonitor.mark('app-start');
// ⚡ 已删除 brainwave.css项目未安装 Tailwind CSS该文件无效

View File

@@ -42,180 +42,412 @@ export const PINGAN_BANK_DATA = {
employees: 42099,
},
// 实际控制人信息
actualControl: {
controller_name: '中国平安保险(集团)股份有限公司',
controller_type: '企业',
shareholding_ratio: 52.38,
control_chain: '中国平安保险(集团)股份有限公司 -> 平安银行股份有限公司',
is_listed: true,
change_date: '2023-12-31',
remark: '中国平安通过直接和间接方式控股平安银行',
// 市场概览数据 - StockSummaryCard 使用
marketSummary: {
stock_code: '000001',
stock_name: '平安银行',
latest_trade: {
close: 11.28,
change_percent: 2.35,
volume: 58623400,
amount: 659800000,
turnover_rate: 0.30,
pe_ratio: 4.92
},
latest_funding: {
financing_balance: 5823000000,
securities_balance: 125600000
},
latest_pledge: {
pledge_ratio: 8.25
}
},
// 股权集中度
concentration: {
top1_ratio: 52.38,
top3_ratio: 58.42,
top5_ratio: 60.15,
top10_ratio: 63.28,
update_date: '2024-09-30',
concentration_level: '高度集中',
herfindahl_index: 0.2845,
// 当日分钟K线数据 - MinuteKLineChart 使用
minuteData: {
code: '000001',
name: '平安银行',
trade_date: '2024-12-12',
type: '1min',
data: [
// 上午交易时段 9:30 - 11:30
{ time: '09:30', open: 11.02, close: 11.05, high: 11.06, low: 11.01, volume: 1856000, amount: 20458000 },
{ time: '09:31', open: 11.05, close: 11.08, high: 11.09, low: 11.04, volume: 1423000, amount: 15782000 },
{ time: '09:32', open: 11.08, close: 11.06, high: 11.10, low: 11.05, volume: 1125000, amount: 12468000 },
{ time: '09:33', open: 11.06, close: 11.10, high: 11.11, low: 11.05, volume: 1678000, amount: 18623000 },
{ time: '09:34', open: 11.10, close: 11.12, high: 11.14, low: 11.09, volume: 2134000, amount: 23725000 },
{ time: '09:35', open: 11.12, close: 11.15, high: 11.16, low: 11.11, volume: 1892000, amount: 21082000 },
{ time: '09:40', open: 11.15, close: 11.18, high: 11.20, low: 11.14, volume: 1567000, amount: 17523000 },
{ time: '09:45', open: 11.18, close: 11.16, high: 11.19, low: 11.15, volume: 1234000, amount: 13782000 },
{ time: '09:50', open: 11.16, close: 11.20, high: 11.21, low: 11.15, volume: 1456000, amount: 16298000 },
{ time: '09:55', open: 11.20, close: 11.22, high: 11.24, low: 11.19, volume: 1789000, amount: 20068000 },
{ time: '10:00', open: 11.22, close: 11.25, high: 11.26, low: 11.21, volume: 2012000, amount: 22635000 },
{ time: '10:10', open: 11.25, close: 11.23, high: 11.26, low: 11.22, volume: 1345000, amount: 15123000 },
{ time: '10:20', open: 11.23, close: 11.26, high: 11.28, low: 11.22, volume: 1678000, amount: 18912000 },
{ time: '10:30', open: 11.26, close: 11.24, high: 11.27, low: 11.23, volume: 1123000, amount: 12645000 },
{ time: '10:40', open: 11.24, close: 11.27, high: 11.28, low: 11.23, volume: 1456000, amount: 16412000 },
{ time: '10:50', open: 11.27, close: 11.25, high: 11.28, low: 11.24, volume: 1234000, amount: 13902000 },
{ time: '11:00', open: 11.25, close: 11.28, high: 11.30, low: 11.24, volume: 1567000, amount: 17689000 },
{ time: '11:10', open: 11.28, close: 11.26, high: 11.29, low: 11.25, volume: 1089000, amount: 12278000 },
{ time: '11:20', open: 11.26, close: 11.28, high: 11.29, low: 11.25, volume: 1234000, amount: 13912000 },
{ time: '11:30', open: 11.28, close: 11.27, high: 11.29, low: 11.26, volume: 987000, amount: 11134000 },
// 下午交易时段 13:00 - 15:00
{ time: '13:00', open: 11.27, close: 11.30, high: 11.31, low: 11.26, volume: 1456000, amount: 16456000 },
{ time: '13:10', open: 11.30, close: 11.28, high: 11.31, low: 11.27, volume: 1123000, amount: 12689000 },
{ time: '13:20', open: 11.28, close: 11.32, high: 11.33, low: 11.27, volume: 1789000, amount: 20245000 },
{ time: '13:30', open: 11.32, close: 11.30, high: 11.33, low: 11.29, volume: 1345000, amount: 15212000 },
{ time: '13:40', open: 11.30, close: 11.33, high: 11.35, low: 11.29, volume: 1678000, amount: 18978000 },
{ time: '13:50', open: 11.33, close: 11.31, high: 11.34, low: 11.30, volume: 1234000, amount: 13956000 },
{ time: '14:00', open: 11.31, close: 11.34, high: 11.36, low: 11.30, volume: 1567000, amount: 17789000 },
{ time: '14:10', open: 11.34, close: 11.32, high: 11.35, low: 11.31, volume: 1123000, amount: 12712000 },
{ time: '14:20', open: 11.32, close: 11.30, high: 11.33, low: 11.29, volume: 1456000, amount: 16478000 },
{ time: '14:30', open: 11.30, close: 11.28, high: 11.31, low: 11.27, volume: 1678000, amount: 18956000 },
{ time: '14:40', open: 11.28, close: 11.26, high: 11.29, low: 11.25, volume: 1345000, amount: 15167000 },
{ time: '14:50', open: 11.26, close: 11.28, high: 11.30, low: 11.25, volume: 1892000, amount: 21345000 },
{ time: '15:00', open: 11.28, close: 11.28, high: 11.29, low: 11.27, volume: 2345000, amount: 26478000 }
]
},
// 高管信息
// 实际控制人信息(数组格式)
actualControl: [
{
actual_controller_name: '中国平安保险(集团)股份有限公司',
controller_name: '中国平安保险(集团)股份有限公司',
control_type: '企业法人',
controller_type: '企业',
holding_ratio: 52.38,
holding_shares: 10168542300,
end_date: '2024-09-30',
control_chain: '中国平安保险(集团)股份有限公司 -> 平安银行股份有限公司',
is_listed: true,
remark: '中国平安通过直接和间接方式控股平安银行',
}
],
// 股权集中度(数组格式,按统计项分组)
concentration: [
{ stat_item: '前1大股东', holding_ratio: 52.38, ratio_change: 0.00, end_date: '2024-09-30' },
{ stat_item: '前3大股东', holding_ratio: 58.42, ratio_change: 0.15, end_date: '2024-09-30' },
{ stat_item: '前5大股东', holding_ratio: 60.15, ratio_change: 0.22, end_date: '2024-09-30' },
{ stat_item: '前10大股东', holding_ratio: 63.28, ratio_change: 0.35, end_date: '2024-09-30' },
{ stat_item: '前1大股东', holding_ratio: 52.38, ratio_change: -0.12, end_date: '2024-06-30' },
{ stat_item: '前3大股东', holding_ratio: 58.27, ratio_change: -0.08, end_date: '2024-06-30' },
{ stat_item: '前5大股东', holding_ratio: 59.93, ratio_change: -0.15, end_date: '2024-06-30' },
{ stat_item: '前10大股东', holding_ratio: 62.93, ratio_change: -0.22, end_date: '2024-06-30' },
],
// 高管信息(包含高管、董事、监事、其他)
management: [
// === 高管 ===
{
name: '谢永林',
position: '董事长',
position_name: '董事长',
position_category: '高管',
gender: '男',
age: 56,
birth_year: '1968',
education: '硕士',
appointment_date: '2019-01-01',
annual_compensation: 723.8,
shareholding: 0,
background: '中国平安保险(集团)股份有限公司副总经理兼首席保险业务执行官',
nationality: '中国',
start_date: '2019-01-01',
status: 'active'
},
{
name: '冀光恒',
position: '行长',
position_name: '行长',
position_category: '高管',
gender: '男',
age: 52,
birth_year: '1972',
education: '博士',
appointment_date: '2023-08-01',
annual_compensation: 650.5,
shareholding: 0,
background: '原中国工商银行总行部门总经理',
nationality: '中国',
start_date: '2023-08-01',
status: 'active'
},
{
name: '周强',
position: '执行董事、副行长、董事会秘书',
position_name: '副行长、董事会秘书',
position_category: '高管',
gender: '男',
age: 54,
birth_year: '1970',
education: '硕士',
appointment_date: '2016-06-01',
annual_compensation: 542.3,
shareholding: 0.002,
background: '历任平安银行深圳分行行长',
nationality: '中国',
start_date: '2016-06-01',
status: 'active'
},
{
name: '郭世邦',
position: '执行董事、副行长、首席财务官',
position_name: '副行长、首席财务官',
position_category: '高管',
gender: '男',
age: 52,
birth_year: '1972',
education: '博士',
appointment_date: '2018-03-01',
annual_compensation: 498.6,
shareholding: 0.001,
background: '历任中国平安集团财务负责人',
nationality: '中国',
start_date: '2018-03-01',
status: 'active'
},
{
name: '项有志',
position: '副行长、首席信息官',
position_name: '副行长、首席信息官',
position_category: '高管',
gender: '男',
age: 49,
birth_year: '1975',
education: '硕士',
appointment_date: '2019-09-01',
annual_compensation: 425.1,
shareholding: 0,
background: '历任中国平安科技公司总经理',
nationality: '中国',
start_date: '2019-09-01',
status: 'active'
},
{
name: '张小璐',
position_name: '副行长、首席风险官',
position_category: '高管',
gender: '女',
birth_year: '1973',
education: '硕士',
nationality: '中国',
start_date: '2020-03-15',
status: 'active'
},
// === 董事 ===
{
name: '马明哲',
position_name: '非执行董事',
position_category: '董事',
gender: '男',
birth_year: '1955',
education: '博士',
nationality: '中国',
start_date: '2012-06-15',
status: 'active'
},
{
name: '孙建一',
position_name: '非执行董事',
position_category: '董事',
gender: '男',
birth_year: '1960',
education: '硕士',
nationality: '中国',
start_date: '2016-08-20',
status: 'active'
},
{
name: '陈心颖',
position_name: '非执行董事',
position_category: '董事',
gender: '女',
birth_year: '1977',
education: '硕士',
nationality: '新加坡',
start_date: '2018-06-01',
status: 'active'
},
{
name: '黄宝新',
position_name: '独立非执行董事',
position_category: '董事',
gender: '男',
birth_year: '1962',
education: '博士',
nationality: '中国',
start_date: '2019-06-20',
status: 'active'
},
{
name: '王志良',
position_name: '独立非执行董事',
position_category: '董事',
gender: '男',
birth_year: '1958',
education: '博士',
nationality: '美国',
start_date: '2020-06-18',
status: 'active'
},
{
name: '李曙光',
position_name: '独立非执行董事',
position_category: '董事',
gender: '男',
birth_year: '1963',
education: '博士',
nationality: '中国',
start_date: '2021-06-25',
status: 'active'
},
// === 监事 ===
{
name: '王选庆',
position_name: '监事会主席',
position_category: '监事',
gender: '男',
birth_year: '1965',
education: '硕士',
nationality: '中国',
start_date: '2017-06-15',
status: 'active'
},
{
name: '杨峻',
position_name: '职工监事',
position_category: '监事',
gender: '男',
birth_year: '1970',
education: '本科',
nationality: '中国',
start_date: '2019-06-20',
status: 'active'
},
{
name: '刘春华',
position_name: '外部监事',
position_category: '监事',
gender: '女',
birth_year: '1968',
education: '硕士',
nationality: '中国',
start_date: '2020-06-18',
status: 'active'
},
{
name: '张伟民',
position_name: '外部监事',
position_category: '监事',
gender: '男',
birth_year: '1966',
education: '博士',
nationality: '中国',
start_date: '2021-06-25',
status: 'active'
},
// === 其他 ===
{
name: '陈敏',
position_name: '合规总监',
position_category: '其他',
gender: '女',
birth_year: '1975',
education: '硕士',
nationality: '中国',
start_date: '2018-09-01',
status: 'active'
},
{
name: '李明',
position_name: '审计部总经理',
position_category: '其他',
gender: '男',
birth_year: '1978',
education: '硕士',
nationality: '中国',
start_date: '2019-03-15',
status: 'active'
},
{
name: '王建国',
position_name: '法务部总经理',
position_category: '其他',
gender: '男',
birth_year: '1972',
education: '博士',
nationality: '中国',
start_date: '2017-06-01',
status: 'active'
}
],
// 十大流通股东
// 十大流通股东(字段名与组件期望格式匹配)
topCirculationShareholders: [
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业' },
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人' },
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业' },
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司' },
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金' },
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金' },
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金' },
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司' },
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人' },
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人' }
{ shareholder_rank: 1, shareholder_name: '中国平安保险(集团)股份有限公司', holding_shares: 10168542300, circulation_share_ratio: 52.38, shareholder_type: '法人', end_date: '2024-09-30' },
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 542138600, circulation_share_ratio: 2.79, shareholder_type: 'QFII', end_date: '2024-09-30' },
{ shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 382456100, circulation_share_ratio: 1.97, shareholder_type: '保险', end_date: '2024-09-30' },
{ shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 298654200, circulation_share_ratio: 1.54, shareholder_type: '券商', end_date: '2024-09-30' },
{ shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 267842100, circulation_share_ratio: 1.38, shareholder_type: '法人', end_date: '2024-09-30' },
{ shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 156234500, circulation_share_ratio: 0.80, shareholder_type: '社保', end_date: '2024-09-30' },
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 142356700, circulation_share_ratio: 0.73, shareholder_type: '基金', end_date: '2024-09-30' },
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 128945600, circulation_share_ratio: 0.66, shareholder_type: '保险', end_date: '2024-09-30' },
{ shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 98765400, circulation_share_ratio: 0.51, shareholder_type: '基金', end_date: '2024-09-30' },
{ shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 87654300, circulation_share_ratio: 0.45, shareholder_type: '基金', end_date: '2024-09-30' }
],
// 十大股东
// 十大股东(字段名与组件期望格式匹配)
topShareholders: [
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业', is_restricted: false },
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人', is_restricted: false },
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业', is_restricted: false },
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司', is_restricted: false },
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金', is_restricted: false },
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金', is_restricted: false },
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金', is_restricted: false },
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司', is_restricted: false },
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人', is_restricted: false },
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人', is_restricted: false }
{ shareholder_rank: 1, shareholder_name: '中国平安保险(集团)股份有限公司', holding_shares: 10168542300, total_share_ratio: 52.38, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 542138600, total_share_ratio: 2.79, shareholder_type: 'QFII', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 382456100, total_share_ratio: 1.97, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 298654200, total_share_ratio: 1.54, shareholder_type: '券商', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 267842100, total_share_ratio: 1.38, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 156234500, total_share_ratio: 0.80, shareholder_type: '社保', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 142356700, total_share_ratio: 0.73, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 128945600, total_share_ratio: 0.66, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 98765400, total_share_ratio: 0.51, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 87654300, total_share_ratio: 0.45, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' }
],
// 分支机构
// 分支机构(字段与 BranchesPanel 组件匹配)
branches: [
{ name: '北京分行', address: '北京市朝阳区建国路88号SOHO现代城', phone: '010-85806888', type: '一级分行', establish_date: '2007-03-15' },
{ name: '上海分行', address: '上海市浦东新区陆家嘴环路1366号', phone: '021-38637777', type: '一级分行', establish_date: '2007-05-20' },
{ name: '广州分行', address: '广州市天河区珠江新城珠江东路32号', phone: '020-38390888', type: '一级分行', establish_date: '2007-06-10' },
{ name: '深圳分行', address: '深圳市福田区益田路5033号', phone: '0755-82538888', type: '一级分行', establish_date: '1995-01-01' },
{ name: '杭州分行', address: '杭州市江干区钱江路1366号', phone: '0571-87028888', type: '一级分行', establish_date: '2008-09-12' },
{ name: '成都分行', address: '成都市武侯区人民南路四段13号', phone: '028-85266888', type: '一级分行', establish_date: '2009-04-25' },
{ name: '南京分行', address: '南京市建邺区江东中路359号', phone: '025-86625888', type: '一级分行', establish_date: '2010-06-30' },
{ name: '武汉分行', address: '武汉市江汉区建设大道568号', phone: '027-85712888', type: '一级分行', establish_date: '2011-08-15' },
{ name: '西安分行', address: '西安市高新区唐延路35号', phone: '029-88313888', type: '一级分行', establish_date: '2012-10-20' },
{ name: '天津分行', address: '天津市和平区南京路189号', phone: '022-23399888', type: '一级分行', establish_date: '2013-03-18' }
{ branch_name: '平安银行股份有限公司北京分行', business_status: '存续', register_capital: '20亿元', legal_person: '张伟', register_date: '2007-03-15', related_company_count: 156 },
{ branch_name: '平安银行股份有限公司上海分行', business_status: '存续', register_capital: '25亿元', legal_person: '李明', register_date: '2007-05-20', related_company_count: 203 },
{ branch_name: '平安银行股份有限公司广州分行', business_status: '存续', register_capital: '18亿元', legal_person: '王芳', register_date: '2007-06-10', related_company_count: 142 },
{ branch_name: '平安银行股份有限公司深圳分行', business_status: '存续', register_capital: '30亿元', legal_person: '陈强', register_date: '1995-01-01', related_company_count: 287 },
{ branch_name: '平安银行股份有限公司杭州分行', business_status: '存续', register_capital: '15亿元', legal_person: '刘洋', register_date: '2008-09-12', related_company_count: 98 },
{ branch_name: '平安银行股份有限公司成都分行', business_status: '存续', register_capital: '12亿元', legal_person: '赵静', register_date: '2009-04-25', related_company_count: 76 },
{ branch_name: '平安银行股份有限公司南京分行', business_status: '存续', register_capital: '14亿元', legal_person: '周涛', register_date: '2010-06-30', related_company_count: 89 },
{ branch_name: '平安银行股份有限公司武汉分行', business_status: '存续', register_capital: '10亿元', legal_person: '吴磊', register_date: '2011-08-15', related_company_count: 65 },
{ branch_name: '平安银行股份有限公司西安分行', business_status: '存续', register_capital: '8亿元', legal_person: '郑华', register_date: '2012-10-20', related_company_count: 52 },
{ branch_name: '平安银行股份有限公司天津分行', business_status: '存续', register_capital: '10亿元', legal_person: '孙丽', register_date: '2013-03-18', related_company_count: 71 },
{ branch_name: '平安银行股份有限公司重庆分行', business_status: '存续', register_capital: '9亿元', legal_person: '钱峰', register_date: '2014-05-08', related_company_count: 58 },
{ branch_name: '平安银行股份有限公司苏州分行', business_status: '存续', register_capital: '6亿元', legal_person: '冯雪', register_date: '2015-07-22', related_company_count: 45 },
],
// 公告列表
announcements: [
{
title: '平安银行股份有限公司2024年第三季度报告',
publish_date: '2024-10-28',
type: '定期报告',
summary: '2024年前三季度实现营业收入1245.6亿元同比增长8.2%净利润402.3亿元同比增长12.5%',
announce_date: '2024-10-28',
info_type: '定期报告',
format: 'PDF',
file_size: 2580,
url: '/announcement/detail/ann_20241028_001'
},
{
title: '关于召开2024年第一次临时股东大会的通知',
publish_date: '2024-10-15',
type: '临时公告',
summary: '定于2024年11月5日召开2024年第一次临时股东大会审议关于调整董事会成员等议案',
announce_date: '2024-10-15',
info_type: '临时公告',
format: 'PDF',
file_size: 156,
url: '/announcement/detail/ann_20241015_001'
},
{
title: '平安银行股份有限公司关于完成注册资本变更登记的公告',
publish_date: '2024-09-20',
type: '临时公告',
summary: '公司已完成注册资本由人民币194.06亿元变更为194.06亿元的工商变更登记手续',
announce_date: '2024-09-20',
info_type: '临时公告',
format: 'PDF',
file_size: 89,
url: '/announcement/detail/ann_20240920_001'
},
{
title: '平安银行股份有限公司2024年半年度报告',
publish_date: '2024-08-28',
type: '定期报告',
summary: '2024年上半年实现营业收入828.5亿元同比增长7.8%净利润265.4亿元同比增长11.2%',
announce_date: '2024-08-28',
info_type: '定期报告',
format: 'PDF',
file_size: 3420,
url: '/announcement/detail/ann_20240828_001'
},
{
title: '关于2024年上半年利润分配预案的公告',
publish_date: '2024-08-20',
type: '分配方案',
summary: '拟以总股本194.06亿股为基数向全体股东每10股派发现金红利2.8元(含税)',
announce_date: '2024-08-20',
info_type: '分配方案',
format: 'PDF',
file_size: 245,
url: '/announcement/detail/ann_20240820_001'
}
],
// 披露时间表
disclosureSchedule: [
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
{ report_type: '2024年第四季度报告', planned_date: '2025-01-31', status: '未披露' },
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
{ report_type: '2024年半年度报告', planned_date: '2024-08-31', status: '已披露' },
{ report_type: '2024年第一季度报告', planned_date: '2024-04-30', status: '已披露' }
{ report_name: '2024年年度报告', is_disclosed: false, actual_date: null, latest_scheduled_date: '2025-04-30' },
{ report_name: '2024年第四季度报告', is_disclosed: false, actual_date: null, latest_scheduled_date: '2025-01-31' },
{ report_name: '2024年第三季度报告', is_disclosed: true, actual_date: '2024-10-28', latest_scheduled_date: '2024-10-31' },
{ report_name: '2024年半年度报告', is_disclosed: true, actual_date: '2024-08-28', latest_scheduled_date: '2024-08-31' },
{ report_name: '2024年第一季度报告', is_disclosed: true, actual_date: '2024-04-28', latest_scheduled_date: '2024-04-30' }
],
// 综合分析 - 结构与组件期望格式匹配
@@ -223,10 +455,66 @@ export const PINGAN_BANK_DATA = {
qualitative_analysis: {
core_positioning: {
one_line_intro: '中国领先的股份制商业银行,平安集团综合金融战略的核心载体',
investment_highlights: '1. 背靠平安集团,综合金融优势显著,交叉销售和客户资源共享带来持续增长动力;\n2. 零售转型成效显著零售业务收入占比超50%个人客户突破1.2亿户;\n3. 金融科技领先同业AI、大数据、区块链等技术应用深化运营效率持续提升\n4. 风险管理体系完善,不良贷款率控制在较低水平,拨备覆盖率保持充足。',
business_model_desc: '平安银行以零售银行业务为核心驱动,依托平安集团综合金融平台,构建"三位一体"(智能化银行、移动化银行、综合化银行)发展模式。通过科技赋能实现业务流程数字化,降本增效的同时提升客户体验。对公业务聚焦供应链金融和产业互联网,服务实体经济高质量发展。'
// 核心特性(显示在核心定位区域下方的两个卡片)
features: [
{
icon: 'bank',
title: '零售业务',
description: '收入占比超50%个人客户突破1.2亿户零售AUM 4.2万亿'
},
{
icon: 'fire',
title: '综合金融',
description: '交叉销售和客户资源共享带来持续增长,成本趋近于零'
}
],
// 结构化投资亮点
investment_highlights: [
{
icon: 'users',
title: '综合金融优势',
description: '背靠平安集团,客户资源共享和交叉销售带来持续增长动力'
},
{
icon: 'trending-up',
title: '零售转型成效',
description: '零售业务收入占比超50%个人客户突破1.2亿户'
},
{
icon: 'cpu',
title: '金融科技领先',
description: 'AI、大数据、区块链等技术深化应用运营效率持续提升'
},
{
icon: 'shield-check',
title: '风险管理体系',
description: '不良贷款率控制在较低水平,拨备覆盖率保持充足'
}
],
// 结构化商业模式
business_model_sections: [
{
title: '零售银行核心驱动',
description: '以零售银行业务为核心驱动,依托平安集团综合金融平台,构建智能化、移动化、综合化三位一体发展模式。'
},
{
title: '科技赋能转型',
description: '通过科技赋能实现业务流程数字化,降本增效的同时提升客户体验。',
tags: ['AI应用深化', '大数据分析']
},
{
title: '对公业务聚焦',
description: '聚焦供应链金融和产业互联网,服务实体经济高质量发展。'
}
],
// 兼容旧数据格式
investment_highlights_text: '1. 零售AUM 4.2万亿、抵押贷占比63%,低不良+高拨备形成稀缺安全垫\n2. 背靠平安集团,保险-银行-投资生态协同,交叉销售成本趋近于零\n3. 战略收缩高风险消费贷、发力科技/绿色/普惠"五篇大文章",资产重构带来息差与估值双升期权',
business_model_desc: '以零售金融为压舱石,通过按揭、私行财富、信用卡获取低成本负债;对公金融做精行业赛道,输出供应链金融与跨境金融解决方案;同业金融做专投资交易,赚取做市与波段收益。'
},
strategy: '坚持"科技引领、零售突破、对公做精"战略方针,深化数字化转型,打造智能化零售银行标杆。持续推进组织架构扁平化和敏捷化改革,提升经营效率。强化风险管理,保持资产质量稳定。'
strategy: {
strategy_description: '以"零售做强、对公做精、同业做专"为主线,通过压降高风险资产、深耕科技绿色普惠、强化集团协同,实现轻资本、弱周期、高股息的高质量增长。',
strategic_initiatives: '2025年AI 138个项目落地构建智能风控、智能投顾与智能运营目标3年降低单位成本10%以上发行800亿元资本债用于置换存量高成本次级债并支持科技绿色贷款扩张目标2026年科技绿色贷款占比提升至15%'
}
},
competitive_position: {
ranking: {
@@ -251,147 +539,65 @@ export const PINGAN_BANK_DATA = {
},
business_structure: [
{
business_name: '零售金融',
business_name: '舒泰清复方聚乙二醇电解质散IV',
business_level: 1,
revenue: 812300,
revenue: 17900,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 50.1,
gross_margin: 42.5
revenue_ratio: 55.16,
gross_margin: 78.21
},
growth_metrics: {
revenue_growth: 11.2
revenue_growth: -8.20
},
report_period: '2024Q3'
report_period: '2024年报'
},
{
business_name: '信用卡业务',
business_level: 2,
revenue: 325000,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 20.1,
gross_margin: 38.2
},
growth_metrics: {
revenue_growth: 15.8
},
report_period: '2024Q3'
},
{
business_name: '财富管理',
business_level: 2,
revenue: 280500,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 17.3,
gross_margin: 52.1
},
growth_metrics: {
revenue_growth: 22.5
},
report_period: '2024Q3'
},
{
business_name: '消费信贷',
business_level: 2,
revenue: 206800,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 12.7,
gross_margin: 35.8
},
growth_metrics: {
revenue_growth: 8.6
},
report_period: '2024Q3'
},
{
business_name: '对公金融',
business_name: '苏肽生(注射用鼠神经生长因子)',
business_level: 1,
revenue: 685400,
revenue: 13400,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 42.2,
gross_margin: 38.6
revenue_ratio: 41.21,
gross_margin: 89.11
},
growth_metrics: {
revenue_growth: 6.8
revenue_growth: -17.30
},
report_period: '2024Q3'
report_period: '2024年报'
},
{
business_name: '公司贷款',
business_level: 2,
revenue: 412000,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 25.4,
gross_margin: 36.2
},
growth_metrics: {
revenue_growth: 5.2
},
report_period: '2024Q3'
},
{
business_name: '供应链金融',
business_level: 2,
revenue: 185600,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 11.4,
gross_margin: 41.5
},
growth_metrics: {
revenue_growth: 18.3
},
report_period: '2024Q3'
},
{
business_name: '投资银行',
business_level: 2,
revenue: 87800,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 5.4,
gross_margin: 45.2
},
growth_metrics: {
revenue_growth: -2.3
},
report_period: '2024Q3'
},
{
business_name: '资金同业',
business_name: '舒斯通(复方聚乙二醇(3350)电解质散)',
business_level: 1,
revenue: 125800,
revenue: 771,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 7.7,
gross_margin: 28.2
revenue_ratio: 2.37
},
growth_metrics: {
revenue_growth: 3.5
report_period: '2024年报'
},
{
business_name: '阿司匹林肠溶片',
business_level: 1,
revenue: 396,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 1.22
},
report_period: '2024Q3'
report_period: '2024年报'
},
{
business_name: '研发业务',
business_level: 1,
report_period: '2024年报'
}
],
business_segments: [
{
segment_name: '信用卡业务',
description: '国内领先的信用卡发卡银行流通卡量超7000万张',
key_metrics: { cards_issued: 7200, transaction_volume: 28500, market_share: 8.5 }
},
{
segment_name: '财富管理',
description: '私人银行及财富管理业务快速发展AUM突破4万亿',
key_metrics: { aum: 42000, private_banking_customers: 125000, wealth_customers: 1200000 }
},
{
segment_name: '供应链金融',
description: '依托科技平台打造智慧供应链金融生态',
key_metrics: { platform_customers: 35000, financing_balance: 5600, digitization_rate: 95 }
segment_name: '已上市药品营销',
segment_description: '舒泰神已上市药品营销业务主要包括舒泰清(复方聚乙二醇电解质散IV)和苏肽生(注射用鼠神经生长因子)两大核心产品。2024年实现营业收入3.25亿元其中舒泰清贡献1.79亿元(55.16%)苏肽生贡献1.34亿元(41.21%)。尽管面临市场竞争压力产品毛利率保持高位综合毛利率达80.83%其中苏肽生毛利率高达89.11%。',
competitive_position: '舒泰清为《中国消化内镜诊疗肠道准备指南》和《慢性便秘诊治指南》一线用药苏肽生是国内首个国药准字鼠神经生长因子产品。公司医保目录产品舒斯通已落地并布局舒亦清、舒常轻等系列产品形成梯队构建了一定市场竞争优势。然而2024年集采中同类(III型)产品中选,对舒泰清(IV型)形成潜在价格压力。',
future_potential: '公司正在构建系列化产品线应对市场变化,研发投入保持高强度(1.62亿元占营收49.97%)。在研管线中STSP-0601血友病药物获FDA孤儿药资格BDB-001被纳入突破性治疗品种创新药研发持续推进。国家政策支持创新药发展行业环境向好同时国际化布局已有初步进展未来3-5年有望通过新产品上市实现业绩突破。'
}
]
},
@@ -998,59 +1204,90 @@ export const generateCompanyData = (stockCode, stockName = '示例公司') => {
business_scope: '电子产品、通信设备、计算机软硬件的研发、生产、销售;技术咨询、技术服务;货物进出口、技术进出口。',
employees: employeeCount,
},
actualControl: {
controller_name: '某控股集团有限公司',
controller_type: '企业',
shareholding_ratio: 35.5,
control_chain: `某控股集团有限公司 -> ${stockName}股份有限公司`,
is_listed: false,
change_date: '2023-12-31',
},
concentration: {
top1_ratio: 35.5,
top3_ratio: 52.3,
top5_ratio: 61.8,
top10_ratio: 72.5,
update_date: '2024-09-30',
concentration_level: '适度集中',
herfindahl_index: 0.1856,
},
management: [
{ name: '张三', position: '董事长', gender: '男', age: 55, education: '硕士', annual_compensation: 320.5, status: 'active' },
{ name: '李四', position: '总经理', gender: '男', age: 50, education: '硕士', annual_compensation: 280.3, status: 'active' },
{ name: '王五', position: '董事会秘书', gender: '女', age: 45, education: '本科', annual_compensation: 180.2, status: 'active' },
{ name: '赵六', position: '财务总监', gender: '男', age: 48, education: '硕士', annual_compensation: 200.5, status: 'active' },
{ name: '钱七', position: '技术总监', gender: '男', age: 42, education: '博士', annual_compensation: 250.8, status: 'active' },
actualControl: [
{
actual_controller_name: '某控股集团有限公司',
controller_name: '某控股集团有限公司',
control_type: '企业法人',
controller_type: '企业',
holding_ratio: 35.5,
holding_shares: 1560000000,
end_date: '2024-09-30',
control_chain: `某控股集团有限公司 -> ${stockName}股份有限公司`,
is_listed: false,
}
],
concentration: [
{ stat_item: '前1大股东', holding_ratio: 35.5, ratio_change: 0.12, end_date: '2024-09-30' },
{ stat_item: '前3大股东', holding_ratio: 52.3, ratio_change: 0.25, end_date: '2024-09-30' },
{ stat_item: '前5大股东', holding_ratio: 61.8, ratio_change: 0.18, end_date: '2024-09-30' },
{ stat_item: '前10大股东', holding_ratio: 72.5, ratio_change: 0.32, end_date: '2024-09-30' },
{ stat_item: '前1大股东', holding_ratio: 35.38, ratio_change: -0.08, end_date: '2024-06-30' },
{ stat_item: '前3大股东', holding_ratio: 52.05, ratio_change: -0.15, end_date: '2024-06-30' },
{ stat_item: '前5大股东', holding_ratio: 61.62, ratio_change: -0.10, end_date: '2024-06-30' },
{ stat_item: '前10大股东', holding_ratio: 72.18, ratio_change: -0.20, end_date: '2024-06-30' },
],
management: [
// 高管
{ name: '张三', position_name: '董事长', position_category: '高管', gender: '男', birth_year: '1969', education: '硕士', nationality: '中国', start_date: '2018-06-01', status: 'active' },
{ name: '李四', position_name: '总经理', position_category: '高管', gender: '男', birth_year: '1974', education: '硕士', nationality: '中国', start_date: '2019-03-15', status: 'active' },
{ name: '王五', position_name: '董事会秘书', position_category: '高管', gender: '女', birth_year: '1979', education: '本科', nationality: '中国', start_date: '2020-01-10', status: 'active' },
{ name: '赵六', position_name: '财务总监', position_category: '高管', gender: '男', birth_year: '1976', education: '硕士', nationality: '中国', start_date: '2017-09-01', status: 'active' },
{ name: '钱七', position_name: '技术总监', position_category: '高管', gender: '男', birth_year: '1982', education: '博士', nationality: '中国', start_date: '2021-06-01', status: 'active' },
// 董事
{ name: '孙八', position_name: '非执行董事', position_category: '董事', gender: '男', birth_year: '1965', education: '博士', nationality: '中国', start_date: '2016-06-15', status: 'active' },
{ name: '周九', position_name: '非执行董事', position_category: '董事', gender: '男', birth_year: '1968', education: '硕士', nationality: '中国', start_date: '2018-06-20', status: 'active' },
{ name: '吴十', position_name: '独立董事', position_category: '董事', gender: '女', birth_year: '1972', education: '博士', nationality: '美国', start_date: '2019-06-18', status: 'active' },
{ name: '郑十一', position_name: '独立董事', position_category: '董事', gender: '男', birth_year: '1970', education: '博士', nationality: '中国', start_date: '2020-06-25', status: 'active' },
// 监事
{ name: '冯十二', position_name: '监事会主席', position_category: '监事', gender: '男', birth_year: '1967', education: '硕士', nationality: '中国', start_date: '2017-06-15', status: 'active' },
{ name: '陈十三', position_name: '职工监事', position_category: '监事', gender: '女', birth_year: '1975', education: '本科', nationality: '中国', start_date: '2019-06-20', status: 'active' },
{ name: '楚十四', position_name: '外部监事', position_category: '监事', gender: '男', birth_year: '1971', education: '硕士', nationality: '中国', start_date: '2020-06-18', status: 'active' },
// 其他
{ name: '卫十五', position_name: '合规负责人', position_category: '其他', gender: '男', birth_year: '1978', education: '硕士', nationality: '中国', start_date: '2018-09-01', status: 'active' },
{ name: '蒋十六', position_name: '内审部负责人', position_category: '其他', gender: '女', birth_year: '1980', education: '硕士', nationality: '中国', start_date: '2019-03-15', status: 'active' },
],
topCirculationShareholders: [
{ shareholder_rank: 1, shareholder_name: '某控股集团有限公司', holding_shares: 560000000, circulation_share_ratio: 35.50, shareholder_type: '法人', end_date: '2024-09-30' },
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 156000000, circulation_share_ratio: 9.88, shareholder_type: 'QFII', end_date: '2024-09-30' },
{ shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 89000000, circulation_share_ratio: 5.64, shareholder_type: '保险', end_date: '2024-09-30' },
{ shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 67000000, circulation_share_ratio: 4.24, shareholder_type: '券商', end_date: '2024-09-30' },
{ shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 45000000, circulation_share_ratio: 2.85, shareholder_type: '法人', end_date: '2024-09-30' },
{ shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 34000000, circulation_share_ratio: 2.15, shareholder_type: '社保', end_date: '2024-09-30' },
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 28000000, circulation_share_ratio: 1.77, shareholder_type: '基金', end_date: '2024-09-30' },
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 23000000, circulation_share_ratio: 1.46, shareholder_type: '保险', end_date: '2024-09-30' },
{ shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 19000000, circulation_share_ratio: 1.20, shareholder_type: '基金', end_date: '2024-09-30' },
{ shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 15000000, circulation_share_ratio: 0.95, shareholder_type: '基金', end_date: '2024-09-30' }
],
topShareholders: [
{ shareholder_rank: 1, shareholder_name: '某控股集团有限公司', holding_shares: 560000000, total_share_ratio: 35.50, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 2, shareholder_name: '香港中央结算有限公司(陆股通)', holding_shares: 156000000, total_share_ratio: 9.88, shareholder_type: 'QFII', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 3, shareholder_name: '中国平安人寿保险股份有限公司-传统-普通保险产品', holding_shares: 89000000, total_share_ratio: 5.64, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 4, shareholder_name: '中国证券金融股份有限公司', holding_shares: 67000000, total_share_ratio: 4.24, shareholder_type: '券商', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 5, shareholder_name: '中央汇金资产管理有限责任公司', holding_shares: 45000000, total_share_ratio: 2.85, shareholder_type: '法人', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 6, shareholder_name: '全国社保基金一零三组合', holding_shares: 34000000, total_share_ratio: 2.15, shareholder_type: '社保', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 7, shareholder_name: '华夏上证50交易型开放式指数证券投资基金', holding_shares: 28000000, total_share_ratio: 1.77, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 8, shareholder_name: '中国人寿保险股份有限公司-分红-个人分红-005L-FH002深', holding_shares: 23000000, total_share_ratio: 1.46, shareholder_type: '保险', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 9, shareholder_name: '易方达沪深300交易型开放式指数发起式证券投资基金', holding_shares: 19000000, total_share_ratio: 1.20, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' },
{ shareholder_rank: 10, shareholder_name: '嘉实沪深300交易型开放式指数证券投资基金', holding_shares: 15000000, total_share_ratio: 0.95, shareholder_type: '基金', share_nature: '流通A股', end_date: '2024-09-30' }
],
topCirculationShareholders: Array(10).fill(null).map((_, i) => ({
shareholder_name: `股东${i + 1}`,
shares: Math.floor(Math.random() * 100000000),
ratio: parseFloat(((10 - i) * 0.8 + Math.random() * 2).toFixed(2)),
change: Math.floor(Math.random() * 10000000) - 5000000,
shareholder_type: i < 3 ? '企业' : (i < 6 ? '个人' : '机构')
})),
topShareholders: Array(10).fill(null).map((_, i) => ({
shareholder_name: `股东${i + 1}`,
shares: Math.floor(Math.random() * 100000000),
ratio: parseFloat(((10 - i) * 0.8 + Math.random() * 2).toFixed(2)),
change: Math.floor(Math.random() * 10000000) - 5000000,
shareholder_type: i < 3 ? '企业' : (i < 6 ? '个人' : '机构'),
is_restricted: i < 2
})),
branches: [
{ name: '北京分公司', address: '北京市朝阳区某路123号', phone: '010-12345678', type: '分公司', establish_date: '2012-05-01' },
{ name: '上海分公司', address: '上海市浦东新区某路456号', phone: '021-12345678', type: '分公司', establish_date: '2013-08-15' },
{ name: '广州分公司', address: '广州市天河区某路789号', phone: '020-12345678', type: '分公司', establish_date: '2014-03-20' },
{ branch_name: `${stockName}北京分公司`, business_status: '存续', register_capital: '5000万元', legal_person: '张伟', register_date: '2012-05-01', related_company_count: 23 },
{ branch_name: `${stockName}上海分公司`, business_status: '存续', register_capital: '8000万元', legal_person: '李明', register_date: '2013-08-15', related_company_count: 35 },
{ branch_name: `${stockName}广州分公司`, business_status: '存续', register_capital: '3000万元', legal_person: '王芳', register_date: '2014-03-20', related_company_count: 18 },
{ branch_name: `${stockName}深圳分公司`, business_status: '存续', register_capital: '6000万元', legal_person: '陈强', register_date: '2015-06-10', related_company_count: 28 },
{ branch_name: `${stockName}成都分公司`, business_status: '存续', register_capital: '2000万元', legal_person: '刘洋', register_date: '2018-09-25', related_company_count: 12 },
{ branch_name: `${stockName}武汉子公司`, business_status: '注销', register_capital: '1000万元', legal_person: '赵静', register_date: '2016-04-18', related_company_count: 5 },
],
announcements: [
{ title: `${stockName}2024年第三季度报告`, publish_date: '2024-10-28', type: '定期报告', summary: '业绩稳步增长', url: '#' },
{ title: `${stockName}2024年半年度报告`, publish_date: '2024-08-28', type: '定期报告', summary: '经营情况良好', url: '#' },
{ title: `关于重大合同签订的公告`, publish_date: '2024-07-15', type: '临时公告', summary: '签订重要销售合同', url: '#' },
{ title: `${stockName}2024年第三季度报告`, announce_date: '2024-10-28', info_type: '定期报告', format: 'PDF', file_size: 1850, url: '#' },
{ title: `${stockName}2024年半年度报告`, announce_date: '2024-08-28', info_type: '定期报告', format: 'PDF', file_size: 2340, url: '#' },
{ title: `关于重大合同签订的公告`, announce_date: '2024-07-15', info_type: '临时公告', format: 'PDF', file_size: 128, url: '#' },
],
disclosureSchedule: [
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
{ report_type: '2024年半年度报告', planned_date: '2024-08-31', status: '已披露' },
{ report_name: '2024年年度报告', is_disclosed: false, actual_date: null, latest_scheduled_date: '2025-04-30' },
{ report_name: '2024年第三季度报告', is_disclosed: true, actual_date: '2024-10-28', latest_scheduled_date: '2024-10-31' },
{ report_name: '2024年半年度报告', is_disclosed: true, actual_date: '2024-08-28', latest_scheduled_date: '2024-08-31' },
],
comprehensiveAnalysis: {
qualitative_analysis: {
@@ -1083,11 +1320,68 @@ export const generateCompanyData = (stockCode, stockName = '示例公司') => {
}
},
business_structure: [
{ business_name: '核心产品', revenue: baseRevenue * 0.6, ratio: 60, growth: 12.5, report_period: '2024Q3' },
{ business_name: '增值服务', revenue: baseRevenue * 0.25, ratio: 25, growth: 18.2, report_period: '2024Q3' },
{ business_name: '其他业务', revenue: baseRevenue * 0.15, ratio: 15, growth: 5.8, report_period: '2024Q3' }
{
business_name: '舒泰清复方聚乙二醇电解质散IV',
business_level: 1,
revenue: 17900,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 55.16,
gross_margin: 78.21
},
growth_metrics: {
revenue_growth: -8.20
},
report_period: '2024年报'
},
{
business_name: '苏肽生(注射用鼠神经生长因子)',
business_level: 1,
revenue: 13400,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 41.21,
gross_margin: 89.11
},
growth_metrics: {
revenue_growth: -17.30
},
report_period: '2024年报'
},
{
business_name: '舒斯通(复方聚乙二醇(3350)电解质散)',
business_level: 1,
revenue: 771,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 2.37
},
report_period: '2024年报'
},
{
business_name: '阿司匹林肠溶片',
business_level: 1,
revenue: 396,
revenue_unit: '万元',
financial_metrics: {
revenue_ratio: 1.22
},
report_period: '2024年报'
},
{
business_name: '研发业务',
business_level: 1,
report_period: '2024年报'
}
],
business_segments: []
business_segments: [
{
segment_name: '已上市药品营销',
segment_description: '舒泰神已上市药品营销业务主要包括舒泰清(复方聚乙二醇电解质散IV)和苏肽生(注射用鼠神经生长因子)两大核心产品。2024年实现营业收入3.25亿元其中舒泰清贡献1.79亿元(55.16%)苏肽生贡献1.34亿元(41.21%)。尽管面临市场竞争压力产品毛利率保持高位综合毛利率达80.83%其中苏肽生毛利率高达89.11%。',
competitive_position: '舒泰清为《中国消化内镜诊疗肠道准备指南》和《慢性便秘诊治指南》一线用药苏肽生是国内首个国药准字鼠神经生长因子产品。公司医保目录产品舒斯通已落地并布局舒亦清、舒常轻等系列产品形成梯队构建了一定市场竞争优势。然而2024年集采中同类(III型)产品中选,对舒泰清(IV型)形成潜在价格压力。',
future_potential: '公司正在构建系列化产品线应对市场变化,研发投入保持高强度(1.62亿元占营收49.97%)。在研管线中STSP-0601血友病药物获FDA孤儿药资格BDB-001被纳入突破性治疗品种创新药研发持续推进。国家政策支持创新药发展行业环境向好同时国际化布局已有初步进展未来3-5年有望通过新产品上市实现业绩突破。'
}
]
},
valueChainAnalysis: {
value_chain_flows: [

View File

@@ -874,8 +874,20 @@ export function generateMockEvents(params = {}) {
e.title.toLowerCase().includes(query) ||
e.description.toLowerCase().includes(query) ||
// keywords 是对象数组 { concept, score, ... },需要访问 concept 属性
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query))
e.keywords.some(k => k.concept && k.concept.toLowerCase().includes(query)) ||
// 搜索 related_stocks 中的股票名称和代码
(e.related_stocks && e.related_stocks.some(stock =>
(stock.stock_name && stock.stock_name.toLowerCase().includes(query)) ||
(stock.stock_code && stock.stock_code.toLowerCase().includes(query))
)) ||
// 搜索行业
(e.industry && e.industry.toLowerCase().includes(query))
);
// 如果搜索结果为空,返回所有事件(宽松模式)
if (filteredEvents.length === 0) {
filteredEvents = allEvents;
}
}
// 行业筛选
@@ -1042,7 +1054,7 @@ function generateTransmissionChain(industry, index) {
let nodeName;
if (nodeType === 'company' && industryStock) {
nodeName = industryStock.name;
nodeName = industryStock.stock_name;
} else if (nodeType === 'industry') {
nodeName = `${industry}产业`;
} else if (nodeType === 'policy') {
@@ -1133,7 +1145,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
const stock = industryStocks[j % industryStocks.length];
relatedStocks.push({
stock_code: stock.stock_code,
stock_name: stock.name,
stock_name: stock.stock_name,
relation_desc: relationDescriptions[j % relationDescriptions.length]
});
}
@@ -1145,7 +1157,7 @@ export function generateDynamicNewsEvents(timeRange = null, count = 30) {
if (!relatedStocks.some(s => s.stock_code === randomStock.stock_code)) {
relatedStocks.push({
stock_code: randomStock.stock_code,
stock_name: randomStock.name,
stock_name: randomStock.stock_name,
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
});
}

View File

@@ -10,73 +10,323 @@ export const generateFinancialData = (stockCode) => {
// 股票基本信息
stockInfo: {
code: stockCode,
name: stockCode === '000001' ? '平安银行' : '示例公司',
stock_code: stockCode,
stock_name: stockCode === '000001' ? '平安银行' : '示例公司',
industry: stockCode === '000001' ? '银行' : '制造业',
list_date: '1991-04-03',
market: 'SZ'
market: 'SZ',
// 关键指标
key_metrics: {
eps: 2.72,
roe: 16.23,
gross_margin: 71.92,
net_margin: 32.56,
roa: 1.05
},
// 增长率
growth_rates: {
revenue_growth: 8.2,
profit_growth: 12.5,
asset_growth: 5.6,
equity_growth: 6.8
},
// 财务概要
financial_summary: {
revenue: 162350,
net_profit: 52860,
total_assets: 5024560,
total_liabilities: 4698880
},
// 最新业绩预告
latest_forecast: {
forecast_type: '预增',
content: '预计全年净利润同比增长10%-17%'
}
},
// 资产负债表
// 资产负债表 - 嵌套结构
balanceSheet: periods.map((period, i) => ({
period,
total_assets: 5024560 - i * 50000, // 百万元
total_liabilities: 4698880 - i * 48000,
shareholders_equity: 325680 - i * 2000,
current_assets: 2512300 - i * 25000,
non_current_assets: 2512260 - i * 25000,
current_liabilities: 3456780 - i * 35000,
non_current_liabilities: 1242100 - i * 13000
assets: {
current_assets: {
cash: 856780 - i * 10000,
trading_financial_assets: 234560 - i * 5000,
notes_receivable: 12340 - i * 200,
accounts_receivable: 45670 - i * 1000,
prepayments: 8900 - i * 100,
other_receivables: 23450 - i * 500,
inventory: 156780 - i * 3000,
contract_assets: 34560 - i * 800,
other_current_assets: 67890 - i * 1500,
total: 2512300 - i * 25000
},
non_current_assets: {
long_term_equity_investments: 234560 - i * 5000,
investment_property: 45670 - i * 1000,
fixed_assets: 678900 - i * 15000,
construction_in_progress: 123450 - i * 3000,
right_of_use_assets: 34560 - i * 800,
intangible_assets: 89012 - i * 2000,
goodwill: 45670 - i * 1000,
deferred_tax_assets: 12340 - i * 300,
other_non_current_assets: 67890 - i * 1500,
total: 2512260 - i * 25000
},
total: 5024560 - i * 50000
},
liabilities: {
current_liabilities: {
short_term_borrowings: 456780 - i * 10000,
notes_payable: 23450 - i * 500,
accounts_payable: 234560 - i * 5000,
advance_receipts: 12340 - i * 300,
contract_liabilities: 34560 - i * 800,
employee_compensation_payable: 45670 - i * 1000,
taxes_payable: 23450 - i * 500,
other_payables: 78900 - i * 1500,
non_current_liabilities_due_within_one_year: 89012 - i * 2000,
total: 3456780 - i * 35000
},
non_current_liabilities: {
long_term_borrowings: 678900 - i * 15000,
bonds_payable: 234560 - i * 5000,
lease_liabilities: 45670 - i * 1000,
deferred_tax_liabilities: 12340 - i * 300,
other_non_current_liabilities: 89012 - i * 2000,
total: 1242100 - i * 13000
},
total: 4698880 - i * 48000
},
equity: {
share_capital: 19405,
capital_reserve: 89012 - i * 2000,
surplus_reserve: 45670 - i * 1000,
undistributed_profit: 156780 - i * 3000,
treasury_stock: 0,
other_comprehensive_income: 12340 - i * 300,
parent_company_equity: 315680 - i * 1800,
minority_interests: 10000 - i * 200,
total: 325680 - i * 2000
}
})),
// 利润表
// 利润表 - 嵌套结构
incomeStatement: periods.map((period, i) => ({
period,
revenue: 162350 - i * 4000, // 百万元
operating_cost: 45620 - i * 1200,
gross_profit: 116730 - i * 2800,
operating_profit: 68450 - i * 1500,
net_profit: 52860 - i * 1200,
eps: 2.72 - i * 0.06
revenue: {
total_operating_revenue: 162350 - i * 4000,
operating_revenue: 158900 - i * 3900,
other_income: 3450 - i * 100
},
costs: {
total_operating_cost: 93900 - i * 2500,
operating_cost: 45620 - i * 1200,
taxes_and_surcharges: 4560 - i * 100,
selling_expenses: 12340 - i * 300,
admin_expenses: 15670 - i * 400,
rd_expenses: 8900 - i * 200,
financial_expenses: 6810 - i * 300,
interest_expense: 8900 - i * 200,
interest_income: 2090 - i * 50,
three_expenses_total: 34820 - i * 1000,
four_expenses_total: 43720 - i * 1200,
asset_impairment_loss: 1200 - i * 50,
credit_impairment_loss: 2340 - i * 100
},
other_gains: {
fair_value_change: 1230 - i * 50,
investment_income: 3450 - i * 100,
investment_income_from_associates: 890 - i * 20,
exchange_income: 560 - i * 10,
asset_disposal_income: 340 - i * 10
},
profit: {
operating_profit: 68450 - i * 1500,
total_profit: 69500 - i * 1500,
income_tax_expense: 16640 - i * 300,
net_profit: 52860 - i * 1200,
parent_net_profit: 51200 - i * 1150,
minority_profit: 1660 - i * 50,
continuing_operations_net_profit: 52860 - i * 1200,
discontinued_operations_net_profit: 0
},
non_operating: {
non_operating_income: 1050 - i * 20,
non_operating_expenses: 450 - i * 10
},
per_share: {
basic_eps: 2.72 - i * 0.06,
diluted_eps: 2.70 - i * 0.06
},
comprehensive_income: {
other_comprehensive_income: 890 - i * 20,
total_comprehensive_income: 53750 - i * 1220,
parent_comprehensive_income: 52050 - i * 1170,
minority_comprehensive_income: 1700 - i * 50
}
})),
// 现金流量表
// 现金流量表 - 嵌套结构
cashflow: periods.map((period, i) => ({
period,
operating_cashflow: 125600 - i * 3000, // 百万元
investing_cashflow: -45300 - i * 1000,
financing_cashflow: -38200 + i * 500,
net_cashflow: 42100 - i * 1500,
cash_ending: 456780 - i * 10000
operating_activities: {
inflow: {
cash_from_sales: 178500 - i * 4500
},
outflow: {
cash_for_goods: 52900 - i * 1500
},
net_flow: 125600 - i * 3000
},
investment_activities: {
net_flow: -45300 - i * 1000
},
financing_activities: {
net_flow: -38200 + i * 500
},
cash_changes: {
net_increase: 42100 - i * 1500,
ending_balance: 456780 - i * 10000
},
key_metrics: {
free_cash_flow: 80300 - i * 2000
}
})),
// 财务指标
// 财务指标 - 嵌套结构
financialMetrics: periods.map((period, i) => ({
period,
roe: 16.23 - i * 0.3, // %
roa: 1.05 - i * 0.02,
gross_margin: 71.92 - i * 0.5,
net_margin: 32.56 - i * 0.3,
current_ratio: 0.73 + i * 0.01,
quick_ratio: 0.71 + i * 0.01,
debt_ratio: 93.52 + i * 0.05,
asset_turnover: 0.41 - i * 0.01,
inventory_turnover: 0, // 银行无库存
receivable_turnover: 0 // 银行特殊
profitability: {
roe: 16.23 - i * 0.3,
roe_deducted: 15.89 - i * 0.3,
roe_weighted: 16.45 - i * 0.3,
roa: 1.05 - i * 0.02,
gross_margin: 71.92 - i * 0.5,
net_profit_margin: 32.56 - i * 0.3,
operating_profit_margin: 42.16 - i * 0.4,
cost_profit_ratio: 115.8 - i * 1.2,
ebit: 86140 - i * 1800
},
per_share_metrics: {
eps: 2.72 - i * 0.06,
basic_eps: 2.72 - i * 0.06,
diluted_eps: 2.70 - i * 0.06,
deducted_eps: 2.65 - i * 0.06,
bvps: 16.78 - i * 0.1,
operating_cash_flow_ps: 6.47 - i * 0.15,
capital_reserve_ps: 4.59 - i * 0.1,
undistributed_profit_ps: 8.08 - i * 0.15
},
growth: {
revenue_growth: 8.2 - i * 0.5,
net_profit_growth: 12.5 - i * 0.8,
deducted_profit_growth: 11.8 - i * 0.7,
parent_profit_growth: 12.3 - i * 0.75,
operating_cash_flow_growth: 15.6 - i * 1.0,
total_asset_growth: 5.6 - i * 0.3,
equity_growth: 6.8 - i * 0.4,
fixed_asset_growth: 4.2 - i * 0.2
},
operational_efficiency: {
total_asset_turnover: 0.41 - i * 0.01,
fixed_asset_turnover: 2.35 - i * 0.05,
current_asset_turnover: 0.82 - i * 0.02,
receivable_turnover: 12.5 - i * 0.3,
receivable_days: 29.2 + i * 0.7,
inventory_turnover: 0, // 银行无库存
inventory_days: 0,
working_capital_turnover: 1.68 - i * 0.04
},
solvency: {
current_ratio: 0.73 + i * 0.01,
quick_ratio: 0.71 + i * 0.01,
cash_ratio: 0.25 + i * 0.005,
conservative_quick_ratio: 0.68 + i * 0.01,
asset_liability_ratio: 93.52 + i * 0.05,
interest_coverage: 8.56 - i * 0.2,
cash_to_maturity_debt_ratio: 0.45 - i * 0.01,
tangible_asset_debt_ratio: 94.12 + i * 0.05
},
expense_ratios: {
selling_expense_ratio: 7.60 + i * 0.1,
admin_expense_ratio: 9.65 + i * 0.1,
financial_expense_ratio: 4.19 + i * 0.1,
rd_expense_ratio: 5.48 + i * 0.1,
three_expense_ratio: 21.44 + i * 0.3,
four_expense_ratio: 26.92 + i * 0.4,
cost_ratio: 28.10 + i * 0.2
}
})),
// 主营业务
// 主营业务 - 按产品/业务分类
mainBusiness: {
by_product: [
{ name: '对公业务', revenue: 68540, ratio: 42.2, yoy_growth: 6.8 },
{ name: '零售业务', revenue: 81320, ratio: 50.1, yoy_growth: 11.2 },
{ name: '金融市场业务', revenue: 12490, ratio: 7.7, yoy_growth: 3.5 }
product_classification: [
{
period: '2024-09-30',
report_type: '2024年三季报',
products: [
{ content: '零售金融业务', revenue: 81320000000, gross_margin: 68.5, profit_margin: 42.3, profit: 34398160000 },
{ content: '对公金融业务', revenue: 68540000000, gross_margin: 62.8, profit_margin: 38.6, profit: 26456440000 },
{ content: '金融市场业务', revenue: 12490000000, gross_margin: 75.2, profit_margin: 52.1, profit: 6507290000 },
{ content: '合计', revenue: 162350000000, gross_margin: 67.5, profit_margin: 41.2, profit: 66883200000 },
]
},
{
period: '2024-06-30',
report_type: '2024年中报',
products: [
{ content: '零售金融业务', revenue: 78650000000, gross_margin: 67.8, profit_margin: 41.5, profit: 32639750000 },
{ content: '对公金融业务', revenue: 66280000000, gross_margin: 61.9, profit_margin: 37.8, profit: 25053840000 },
{ content: '金融市场业务', revenue: 11870000000, gross_margin: 74.5, profit_margin: 51.2, profit: 6077440000 },
{ content: '合计', revenue: 156800000000, gross_margin: 66.8, profit_margin: 40.5, profit: 63504000000 },
]
},
{
period: '2024-03-31',
report_type: '2024年一季报',
products: [
{ content: '零售金融业务', revenue: 38920000000, gross_margin: 67.2, profit_margin: 40.8, profit: 15879360000 },
{ content: '对公金融业务', revenue: 32650000000, gross_margin: 61.2, profit_margin: 37.1, profit: 12113150000 },
{ content: '金融市场业务', revenue: 5830000000, gross_margin: 73.8, profit_margin: 50.5, profit: 2944150000 },
{ content: '合计', revenue: 77400000000, gross_margin: 66.1, profit_margin: 39.8, profit: 30805200000 },
]
},
{
period: '2023-12-31',
report_type: '2023年年报',
products: [
{ content: '零售金融业务', revenue: 152680000000, gross_margin: 66.5, profit_margin: 40.2, profit: 61377360000 },
{ content: '对公金融业务', revenue: 128450000000, gross_margin: 60.5, profit_margin: 36.5, profit: 46884250000 },
{ content: '金融市场业务', revenue: 22870000000, gross_margin: 73.2, profit_margin: 49.8, profit: 11389260000 },
{ content: '合计', revenue: 304000000000, gross_margin: 65.2, profit_margin: 39.2, profit: 119168000000 },
]
},
],
by_region: [
{ name: '华南地区', revenue: 56800, ratio: 35.0, yoy_growth: 9.2 },
{ name: '华东地区', revenue: 48705, ratio: 30.0, yoy_growth: 8.5 },
{ name: '华北地区', revenue: 32470, ratio: 20.0, yoy_growth: 7.8 },
{ name: '其他地区', revenue: 24375, ratio: 15.0, yoy_growth: 6.5 }
industry_classification: [
{
period: '2024-09-30',
report_type: '2024年三季报',
industries: [
{ content: '华南地区', revenue: 56817500000, gross_margin: 69.2, profit_margin: 43.5, profit: 24715612500 },
{ content: '华东地区', revenue: 48705000000, gross_margin: 67.8, profit_margin: 41.2, profit: 20066460000 },
{ content: '华北地区', revenue: 32470000000, gross_margin: 65.5, profit_margin: 38.8, profit: 12598360000 },
{ content: '西南地区', revenue: 16235000000, gross_margin: 64.2, profit_margin: 37.5, profit: 6088125000 },
{ content: '其他地区', revenue: 8122500000, gross_margin: 62.8, profit_margin: 35.2, profit: 2859120000 },
{ content: '合计', revenue: 162350000000, gross_margin: 67.5, profit_margin: 41.2, profit: 66883200000 },
]
},
{
period: '2024-06-30',
report_type: '2024年中报',
industries: [
{ content: '华南地区', revenue: 54880000000, gross_margin: 68.5, profit_margin: 42.8, profit: 23488640000 },
{ content: '华东地区', revenue: 47040000000, gross_margin: 67.1, profit_margin: 40.5, profit: 19051200000 },
{ content: '华北地区', revenue: 31360000000, gross_margin: 64.8, profit_margin: 38.1, profit: 11948160000 },
{ content: '西南地区', revenue: 15680000000, gross_margin: 63.5, profit_margin: 36.8, profit: 5770240000 },
{ content: '其他地区', revenue: 7840000000, gross_margin: 62.1, profit_margin: 34.5, profit: 2704800000 },
{ content: '合计', revenue: 156800000000, gross_margin: 66.8, profit_margin: 40.5, profit: 63504000000 },
]
},
]
},
@@ -92,48 +342,74 @@ export const generateFinancialData = (stockCode) => {
publish_date: '2024-10-15'
},
// 行业排名
industryRank: {
industry: '银行',
total_companies: 42,
rankings: [
{ metric: '总资产', rank: 8, value: 5024560, percentile: 19 },
{ metric: '营业收入', rank: 9, value: 162350, percentile: 21 },
{ metric: '净利润', rank: 8, value: 52860, percentile: 19 },
{ metric: 'ROE', rank: 12, value: 16.23, percentile: 29 },
{ metric: '不良贷款率', rank: 18, value: 1.02, percentile: 43 }
]
},
// 行业排名(数组格式,符合 IndustryRankingView 组件要求)
industryRank: [
{
period: '2024-09-30',
report_type: '三季报',
rankings: [
{
industry_name: stockCode === '000001' ? '银行' : '制造业',
level_description: '一级行业',
metrics: {
eps: { value: 2.72, rank: 8, industry_avg: 1.85 },
bvps: { value: 15.23, rank: 12, industry_avg: 12.50 },
roe: { value: 16.23, rank: 10, industry_avg: 12.00 },
revenue_growth: { value: 8.2, rank: 15, industry_avg: 5.50 },
profit_growth: { value: 12.5, rank: 9, industry_avg: 8.00 },
operating_margin: { value: 32.56, rank: 6, industry_avg: 25.00 },
debt_ratio: { value: 92.5, rank: 35, industry_avg: 88.00 },
receivable_turnover: { value: 5.2, rank: 18, industry_avg: 4.80 }
}
}
]
}
],
// 期间对比
periodComparison: {
periods: ['Q3-2024', 'Q2-2024', 'Q1-2024', 'Q4-2023'],
metrics: [
{
name: '营业收入',
unit: '百万元',
values: [41500, 40800, 40200, 40850],
yoy: [8.2, 7.8, 8.5, 9.2]
},
{
name: '净利润',
unit: '百万元',
values: [13420, 13180, 13050, 13210],
yoy: [12.5, 11.2, 10.8, 12.3]
},
{
name: 'ROE',
unit: '%',
values: [16.23, 15.98, 15.75, 16.02],
yoy: [1.2, 0.8, 0.5, 1.0]
},
{
name: 'EPS',
unit: '元',
values: [0.69, 0.68, 0.67, 0.68],
yoy: [12.3, 11.5, 10.5, 12.0]
// 期间对比 - 营收与利润趋势数据
periodComparison: [
{
period: '2024-09-30',
performance: {
revenue: 41500000000, // 415亿
net_profit: 13420000000 // 134.2亿
}
]
}
},
{
period: '2024-06-30',
performance: {
revenue: 40800000000, // 408亿
net_profit: 13180000000 // 131.8亿
}
},
{
period: '2024-03-31',
performance: {
revenue: 40200000000, // 402亿
net_profit: 13050000000 // 130.5亿
}
},
{
period: '2023-12-31',
performance: {
revenue: 40850000000, // 408.5亿
net_profit: 13210000000 // 132.1亿
}
},
{
period: '2023-09-30',
performance: {
revenue: 38500000000, // 385亿
net_profit: 11920000000 // 119.2亿
}
},
{
period: '2023-06-30',
performance: {
revenue: 37800000000, // 378亿
net_profit: 11850000000 // 118.5亿
}
}
]
};
};

View File

@@ -24,8 +24,9 @@ export const generateMarketData = (stockCode) => {
low: parseFloat(low.toFixed(2)),
volume: Math.floor(Math.random() * 500000000) + 100000000, // 1-6亿股
amount: Math.floor(Math.random() * 7000000000) + 1300000000, // 13-80亿元
turnover_rate: (Math.random() * 2 + 0.5).toFixed(2), // 0.5-2.5%
change_pct: (Math.random() * 6 - 3).toFixed(2) // -3% to +3%
turnover_rate: parseFloat((Math.random() * 2 + 0.5).toFixed(2)), // 0.5-2.5%
change_percent: parseFloat((Math.random() * 6 - 3).toFixed(2)), // -3% to +3%
pe_ratio: parseFloat((Math.random() * 3 + 4).toFixed(2)) // 4-7
};
})
},
@@ -78,36 +79,45 @@ export const generateMarketData = (stockCode) => {
}))
},
// 股权质押
// 股权质押 - 匹配 PledgeData[] 类型
pledgeData: {
success: true,
data: {
total_pledged: 25.6, // 质押比例%
major_shareholders: [
{ name: '中国平安保险集团', pledged_shares: 0, total_shares: 10168542300, pledge_ratio: 0 },
{ name: '深圳市投资控股', pledged_shares: 50000000, total_shares: 382456100, pledge_ratio: 13.08 }
],
update_date: '2024-09-30'
}
data: Array(12).fill(null).map((_, i) => {
const date = new Date();
date.setMonth(date.getMonth() - (11 - i));
return {
end_date: date.toISOString().split('T')[0].slice(0, 7) + '-01',
unrestricted_pledge: Math.floor(Math.random() * 1000000000) + 500000000,
restricted_pledge: Math.floor(Math.random() * 200000000) + 50000000,
total_pledge: Math.floor(Math.random() * 1200000000) + 550000000,
total_shares: 19405918198,
pledge_ratio: parseFloat((Math.random() * 3 + 6).toFixed(2)), // 6-9%
pledge_count: Math.floor(Math.random() * 50) + 100 // 100-150
};
})
},
// 市场摘要
// 市场摘要 - 匹配 MarketSummary 类型
summaryData: {
success: true,
data: {
current_price: basePrice,
change: 0.25,
change_pct: 1.89,
open: 13.35,
high: 13.68,
low: 13.28,
volume: 345678900,
amount: 4678900000,
turnover_rate: 1.78,
pe_ratio: 4.96,
pb_ratio: 0.72,
total_market_cap: 262300000000,
circulating_market_cap: 262300000000
stock_code: stockCode,
stock_name: stockCode === '000001' ? '平安银行' : '示例股票',
latest_trade: {
close: basePrice,
change_percent: 1.89,
volume: 345678900,
amount: 4678900000,
turnover_rate: 1.78,
pe_ratio: 4.96
},
latest_funding: {
financing_balance: 5823000000,
securities_balance: 125600000
},
latest_pledge: {
pledge_ratio: 8.25
}
}
},
@@ -131,26 +141,57 @@ export const generateMarketData = (stockCode) => {
})
},
// 最新分时数据
// 最新分时数据 - 匹配 MinuteData 类型
latestMinuteData: {
success: true,
data: Array(240).fill(null).map((_, i) => {
const minute = 9 * 60 + 30 + i; // 从9:30开始
const hour = Math.floor(minute / 60);
const min = minute % 60;
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
const randomChange = (Math.random() - 0.5) * 0.1;
return {
time,
price: (basePrice + randomChange).toFixed(2),
volume: Math.floor(Math.random() * 2000000) + 500000,
avg_price: (basePrice + randomChange * 0.8).toFixed(2)
};
}),
data: (() => {
const minuteData = [];
// 上午 9:30-11:30 (120分钟)
for (let i = 0; i < 120; i++) {
const hour = 9 + Math.floor((30 + i) / 60);
const min = (30 + i) % 60;
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
const randomChange = (Math.random() - 0.5) * 0.1;
const open = parseFloat((basePrice + randomChange).toFixed(2));
const close = parseFloat((basePrice + randomChange + (Math.random() - 0.5) * 0.05).toFixed(2));
const high = parseFloat(Math.max(open, close, open + Math.random() * 0.05).toFixed(2));
const low = parseFloat(Math.min(open, close, close - Math.random() * 0.05).toFixed(2));
minuteData.push({
time,
open,
close,
high,
low,
volume: Math.floor(Math.random() * 2000000) + 500000,
amount: Math.floor(Math.random() * 30000000) + 5000000
});
}
// 下午 13:00-15:00 (120分钟)
for (let i = 0; i < 120; i++) {
const hour = 13 + Math.floor(i / 60);
const min = i % 60;
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
const randomChange = (Math.random() - 0.5) * 0.1;
const open = parseFloat((basePrice + randomChange).toFixed(2));
const close = parseFloat((basePrice + randomChange + (Math.random() - 0.5) * 0.05).toFixed(2));
const high = parseFloat(Math.max(open, close, open + Math.random() * 0.05).toFixed(2));
const low = parseFloat(Math.min(open, close, close - Math.random() * 0.05).toFixed(2));
minuteData.push({
time,
open,
close,
high,
low,
volume: Math.floor(Math.random() * 1500000) + 400000,
amount: Math.floor(Math.random() * 25000000) + 4000000
});
}
return minuteData;
})(),
code: stockCode,
name: stockCode === '000001' ? '平安银行' : '示例股票',
trade_date: new Date().toISOString().split('T')[0],
type: 'minute'
type: '1min'
}
};
};

View File

@@ -1,16 +1,28 @@
// src/mocks/handlers/bytedesk.js
/**
* Bytedesk 客服 Widget MSW Handler
* 使用 passthrough 让请求通过到真实服务器,消除 MSW 警告
* Mock 模式下返回模拟数据
*/
import { http, passthrough } from 'msw';
import { http, HttpResponse, passthrough } from 'msw';
export const bytedeskHandlers = [
// Bytedesk API 请求 - 直接 passthrough
// 匹配 /bytedesk/* 路径(通过代理访问后端)
// 未读消息数量
http.get('/bytedesk/visitor/api/v1/message/unread/count', () => {
return HttpResponse.json({
code: 200,
message: 'success',
data: { count: 0 },
});
}),
// 其他 Bytedesk API - 返回通用成功响应
http.all('/bytedesk/*', () => {
return passthrough();
return HttpResponse.json({
code: 200,
message: 'success',
data: null,
});
}),
// Bytedesk 外部 CDN/服务请求

View File

@@ -67,10 +67,19 @@ export const companyHandlers = [
await delay(150);
const { stockCode } = params;
const data = getCompanyData(stockCode);
const raw = data.actualControl;
// 数据已经是数组格式只做数值转换holding_ratio 从 0-100 转为 0-1
const formatted = Array.isArray(raw)
? raw.map(item => ({
...item,
holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio,
}))
: [];
return HttpResponse.json({
success: true,
data: data.actualControl
data: formatted
});
}),
@@ -79,10 +88,19 @@ export const companyHandlers = [
await delay(150);
const { stockCode } = params;
const data = getCompanyData(stockCode);
const raw = data.concentration;
// 数据已经是数组格式只做数值转换holding_ratio 从 0-100 转为 0-1
const formatted = Array.isArray(raw)
? raw.map(item => ({
...item,
holding_ratio: item.holding_ratio > 1 ? item.holding_ratio / 100 : item.holding_ratio,
}))
: [];
return HttpResponse.json({
success: true,
data: data.concentration
data: formatted
});
}),

View File

@@ -119,9 +119,12 @@ export const eventHandlers = [
try {
const result = generateMockEvents(params);
// 返回格式兼容 NewsPanel 期望的结构
// NewsPanel 期望: { success, data: [], pagination: {} }
return HttpResponse.json({
success: true,
data: result,
data: result.events, // 事件数组
pagination: result.pagination, // 分页信息
message: '获取成功'
});
} catch (error) {
@@ -135,16 +138,14 @@ export const eventHandlers = [
{
success: false,
error: '获取事件列表失败',
data: {
events: [],
pagination: {
page: 1,
per_page: 10,
total: 0,
pages: 0, // ← 对齐后端字段名
has_prev: false, // ← 对齐后端
has_next: false // ← 对齐后端
}
data: [],
pagination: {
page: 1,
per_page: 10,
total: 0,
pages: 0,
has_prev: false,
has_next: false
}
},
{ status: 500 }

View File

@@ -346,7 +346,173 @@ export const marketHandlers = [
});
}),
// 11. 市场统计数据(个股中心页面使用
// 11. 热点概览数据(大盘分时 + 概念异动
http.get('/api/market/hotspot-overview', async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const date = url.searchParams.get('date');
const tradeDate = date || new Date().toISOString().split('T')[0];
// 生成分时数据240个点9:30-11:30 + 13:00-15:00
const timeline = [];
const basePrice = 3900 + Math.random() * 100; // 基准价格 3900-4000
const prevClose = basePrice;
let currentPrice = basePrice;
let cumulativeVolume = 0;
// 上午时段 9:30-11:30 (120分钟)
for (let i = 0; i < 120; i++) {
const hour = 9 + Math.floor((i + 30) / 60);
const minute = (i + 30) % 60;
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
// 模拟价格波动
const volatility = 0.002; // 0.2%波动
const drift = (Math.random() - 0.5) * 0.001; // 微小趋势
currentPrice = currentPrice * (1 + (Math.random() - 0.5) * volatility + drift);
const volume = Math.floor(Math.random() * 500000 + 100000); // 成交量
cumulativeVolume += volume;
timeline.push({
time,
price: parseFloat(currentPrice.toFixed(2)),
volume: cumulativeVolume,
change_pct: parseFloat(((currentPrice - prevClose) / prevClose * 100).toFixed(2))
});
}
// 下午时段 13:00-15:00 (120分钟)
for (let i = 0; i < 120; i++) {
const hour = 13 + Math.floor(i / 60);
const minute = i % 60;
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
// 下午波动略小
const volatility = 0.0015;
const drift = (Math.random() - 0.5) * 0.0008;
currentPrice = currentPrice * (1 + (Math.random() - 0.5) * volatility + drift);
const volume = Math.floor(Math.random() * 400000 + 80000);
cumulativeVolume += volume;
timeline.push({
time,
price: parseFloat(currentPrice.toFixed(2)),
volume: cumulativeVolume,
change_pct: parseFloat(((currentPrice - prevClose) / prevClose * 100).toFixed(2))
});
}
// 生成概念异动数据
const conceptNames = [
'人工智能', 'AI眼镜', '机器人', '核电', '国企', '卫星导航',
'福建自贸区', '两岸融合', 'CRO', '三季报增长', '百货零售',
'人形机器人', '央企', '数据中心', 'CPO', '新能源', '电网设备',
'氢能源', '算力租赁', '厦门国资', '乳业', '低空安防', '创新药',
'商业航天', '控制权变更', '文化传媒', '海峡两岸'
];
const alertTypes = ['surge_up', 'surge_down', 'volume_spike', 'limit_up', 'rank_jump'];
// 生成 15-25 个异动
const alertCount = Math.floor(Math.random() * 10) + 15;
const alerts = [];
const usedTimes = new Set();
for (let i = 0; i < alertCount; i++) {
// 随机选择一个时间点
let timeIdx;
let attempts = 0;
do {
timeIdx = Math.floor(Math.random() * timeline.length);
attempts++;
} while (usedTimes.has(timeIdx) && attempts < 50);
if (attempts >= 50) continue;
// 同一时间可以有多个异动
const time = timeline[timeIdx].time;
const conceptName = conceptNames[Math.floor(Math.random() * conceptNames.length)];
const alertType = alertTypes[Math.floor(Math.random() * alertTypes.length)];
// 根据类型生成 alpha
let alpha;
if (alertType === 'surge_up') {
alpha = parseFloat((Math.random() * 3 + 2).toFixed(2)); // +2% ~ +5%
} else if (alertType === 'surge_down') {
alpha = parseFloat((-Math.random() * 3 - 1.5).toFixed(2)); // -1.5% ~ -4.5%
} else {
alpha = parseFloat((Math.random() * 4 - 1).toFixed(2)); // -1% ~ +3%
}
const finalScore = Math.floor(Math.random() * 40 + 45); // 45-85分
const ruleScore = Math.floor(Math.random() * 30 + 40);
const mlScore = Math.floor(Math.random() * 30 + 40);
alerts.push({
concept_id: `CONCEPT_${1000 + i}`,
concept_name: conceptName,
time,
alert_type: alertType,
alpha,
alpha_delta: parseFloat((Math.random() * 2 - 0.5).toFixed(2)),
amt_ratio: parseFloat((Math.random() * 5 + 1).toFixed(2)),
limit_up_count: alertType === 'limit_up' ? Math.floor(Math.random() * 5 + 1) : 0,
limit_up_ratio: parseFloat((Math.random() * 0.3).toFixed(3)),
final_score: finalScore,
rule_score: ruleScore,
ml_score: mlScore,
trigger_reason: finalScore >= 65 ? '规则强信号' : (mlScore >= 70 ? 'ML强信号' : '融合触发'),
importance_score: parseFloat((finalScore / 100).toFixed(2)),
index_price: timeline[timeIdx].price
});
}
// 按时间排序
alerts.sort((a, b) => a.time.localeCompare(b.time));
// 统计异动类型
const alertSummary = alerts.reduce((acc, alert) => {
acc[alert.alert_type] = (acc[alert.alert_type] || 0) + 1;
return acc;
}, {});
// 计算指数统计
const prices = timeline.map(t => t.price);
const latestPrice = prices[prices.length - 1];
const highPrice = Math.max(...prices);
const lowPrice = Math.min(...prices);
const changePct = ((latestPrice - prevClose) / prevClose * 100);
console.log('[Mock Market] 获取热点概览数据:', {
date: tradeDate,
timelinePoints: timeline.length,
alertCount: alerts.length
});
return HttpResponse.json({
success: true,
data: {
index: {
code: '000001.SH',
name: '上证指数',
latest_price: latestPrice,
prev_close: prevClose,
high: highPrice,
low: lowPrice,
change_pct: parseFloat(changePct.toFixed(2)),
timeline
},
alerts,
alert_summary: alertSummary
},
trade_date: tradeDate
});
}),
// 12. 市场统计数据(个股中心页面使用)
http.get('/api/market/statistics', async ({ request }) => {
await delay(200);
const url = new URL(request.url);

View File

@@ -341,6 +341,68 @@ export const stockHandlers = [
}
}),
// 获取股票业绩预告
http.get('/api/stock/:stockCode/forecast', async ({ params }) => {
await delay(200);
const { stockCode } = params;
console.log('[Mock Stock] 获取业绩预告:', { stockCode });
// 生成股票列表用于查找名称
const stockList = generateStockList();
const stockInfo = stockList.find(s => s.code === stockCode.replace(/\.(SH|SZ)$/i, ''));
const stockName = stockInfo?.name || `股票${stockCode}`;
// 业绩预告类型列表
const forecastTypes = ['预增', '预减', '略增', '略减', '扭亏', '续亏', '首亏', '续盈'];
// 生成业绩预告数据
const forecasts = [
{
forecast_type: '预增',
report_date: '2024年年报',
content: `${stockName}预计2024年度归属于上市公司股东的净利润为58亿元至62亿元同比增长10%至17%。`,
reason: '报告期内,公司主营业务收入稳步增长,产品结构持续优化,毛利率提升;同时公司加大研发投入,新产品市场表现良好。',
change_range: {
lower: 10,
upper: 17
},
publish_date: '2024-10-15'
},
{
forecast_type: '略增',
report_date: '2024年三季报',
content: `${stockName}预计2024年1-9月归属于上市公司股东的净利润为42亿元至45亿元同比增长5%至12%。`,
reason: '公司积极拓展市场渠道,销售规模持续扩大,经营效益稳步提升。',
change_range: {
lower: 5,
upper: 12
},
publish_date: '2024-07-12'
},
{
forecast_type: forecastTypes[Math.floor(Math.random() * forecastTypes.length)],
report_date: '2024年中报',
content: `${stockName}预计2024年上半年归属于上市公司股东的净利润为28亿元至30亿元。`,
reason: '受益于行业景气度回升及公司降本增效措施效果显现,经营业绩同比有所改善。',
change_range: {
lower: 3,
upper: 8
},
publish_date: '2024-04-20'
}
];
return HttpResponse.json({
success: true,
data: {
stock_code: stockCode,
stock_name: stockName,
forecasts: forecasts
}
});
}),
// 获取股票报价(批量)
http.post('/api/stock/quotes', async ({ request }) => {
await delay(200);
@@ -368,6 +430,25 @@ export const stockHandlers = [
stockMap[s.code] = s.name;
});
// 行业和指数映射表
const stockIndustryMap = {
'000001': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证180'] },
'600519': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300', '上证50'] },
'300750': { industry_l1: '工业', industry: '电池', index_tags: ['创业板50'] },
'601318': { industry_l1: '金融', industry: '保险', index_tags: ['沪深300', '上证50'] },
'600036': { industry_l1: '金融', industry: '银行', index_tags: ['沪深300', '上证50'] },
'000858': { industry_l1: '消费', industry: '白酒', index_tags: ['沪深300'] },
'002594': { industry_l1: '汽车', industry: '乘用车', index_tags: ['沪深300', '创业板指'] },
};
const defaultIndustries = [
{ industry_l1: '科技', industry: '软件' },
{ industry_l1: '医药', industry: '化学制药' },
{ industry_l1: '消费', industry: '食品' },
{ industry_l1: '金融', industry: '证券' },
{ industry_l1: '工业', industry: '机械' },
];
// 为每只股票生成报价数据
const quotesData = {};
codes.forEach(stockCode => {
@@ -380,6 +461,11 @@ export const stockHandlers = [
// 昨收
const prevClose = parseFloat((basePrice - change).toFixed(2));
// 获取行业和指数信息
const codeWithoutSuffix = stockCode.replace(/\.(SH|SZ)$/i, '');
const industryInfo = stockIndustryMap[codeWithoutSuffix] ||
defaultIndustries[Math.floor(Math.random() * defaultIndustries.length)];
quotesData[stockCode] = {
code: stockCode,
name: stockMap[stockCode] || `股票${stockCode}`,
@@ -393,7 +479,23 @@ export const stockHandlers = [
volume: Math.floor(Math.random() * 100000000),
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
update_time: new Date().toISOString()
update_time: new Date().toISOString(),
// 行业和指数标签
industry_l1: industryInfo.industry_l1,
industry: industryInfo.industry,
index_tags: industryInfo.index_tags || [],
// 关键指标
pe: parseFloat((Math.random() * 50 + 5).toFixed(2)),
eps: parseFloat((Math.random() * 5 + 0.1).toFixed(3)),
pb: parseFloat((Math.random() * 8 + 0.5).toFixed(2)),
market_cap: `${(Math.random() * 5000 + 100).toFixed(0)}亿`,
week52_low: parseFloat((basePrice * 0.7).toFixed(2)),
week52_high: parseFloat((basePrice * 1.3).toFixed(2)),
// 主力动态
main_net_inflow: parseFloat((Math.random() * 10 - 5).toFixed(2)),
institution_holding: parseFloat((Math.random() * 50 + 10).toFixed(2)),
buy_ratio: parseFloat((Math.random() * 40 + 30).toFixed(2)),
sell_ratio: parseFloat((100 - (Math.random() * 40 + 30)).toFixed(2))
};
});

View File

@@ -4,6 +4,56 @@ import { eventService, stockService } from '../../services/eventService';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
// ==================== Watchlist 缓存配置 ====================
const WATCHLIST_CACHE_KEY = 'watchlist_cache';
const WATCHLIST_CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7天
/**
* 从 localStorage 读取自选股缓存
*/
const loadWatchlistFromCache = () => {
try {
const cached = localStorage.getItem(WATCHLIST_CACHE_KEY);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
const now = Date.now();
// 检查缓存是否过期7天
if (now - timestamp > WATCHLIST_CACHE_DURATION) {
localStorage.removeItem(WATCHLIST_CACHE_KEY);
logger.debug('stockSlice', '自选股缓存已过期');
return null;
}
logger.debug('stockSlice', '自选股 localStorage 缓存命中', {
count: data?.length || 0,
age: Math.round((now - timestamp) / 1000 / 60) + '分钟前'
});
return data;
} catch (error) {
logger.error('stockSlice', 'loadWatchlistFromCache', error);
return null;
}
};
/**
* 保存自选股到 localStorage
*/
const saveWatchlistToCache = (data) => {
try {
localStorage.setItem(WATCHLIST_CACHE_KEY, JSON.stringify({
data,
timestamp: Date.now()
}));
logger.debug('stockSlice', '自选股已缓存到 localStorage', {
count: data?.length || 0
});
} catch (error) {
logger.error('stockSlice', 'saveWatchlistToCache', error);
}
};
// ==================== Async Thunks ====================
/**
@@ -153,13 +203,28 @@ export const fetchExpectationScore = createAsyncThunk(
/**
* 加载用户自选股列表(包含完整信息)
* 缓存策略Redux 内存缓存 → localStorage 持久缓存7天 → API 请求
*/
export const loadWatchlist = createAsyncThunk(
'stock/loadWatchlist',
async () => {
async (_, { getState }) => {
logger.debug('stockSlice', 'loadWatchlist');
try {
// 1. 先检查 Redux 内存缓存
const reduxCached = getState().stock.watchlist;
if (reduxCached && reduxCached.length > 0) {
logger.debug('stockSlice', 'Redux watchlist 缓存命中', { count: reduxCached.length });
return reduxCached;
}
// 2. 再检查 localStorage 持久缓存7天有效期
const localCached = loadWatchlistFromCache();
if (localCached && localCached.length > 0) {
return localCached;
}
// 3. 缓存无效,调用 API
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/api/account/watchlist`, {
credentials: 'include'
@@ -172,6 +237,10 @@ export const loadWatchlist = createAsyncThunk(
stock_code: item.stock_code,
stock_name: item.stock_name,
}));
// 保存到 localStorage 缓存
saveWatchlistToCache(watchlistData);
logger.debug('stockSlice', '自选股列表加载成功', {
count: watchlistData.length
});
@@ -490,9 +559,10 @@ const stockSlice = createSlice({
state.watchlist = state.watchlist.filter(item => item.stock_code !== stockCode);
}
})
// fulfilled: 乐观更新模式下状态已在 pending 更新,这里无需操作
.addCase(toggleWatchlist.fulfilled, () => {
// 状态已在 pending 时更新
// fulfilled: 同步更新 localStorage 缓存
.addCase(toggleWatchlist.fulfilled, (state) => {
// 状态已在 pending 时更新,这里同步到 localStorage
saveWatchlistToCache(state.watchlist);
});
}
});

View File

@@ -2,6 +2,7 @@
// 性能监控工具 - 统计白屏时间和性能指标
import { logger } from './logger';
import { reportPerformanceMetrics } from '../lib/posthog';
/**
* 性能指标接口
@@ -208,6 +209,9 @@ class PerformanceMonitor {
// 性能分析建议
this.analyzePerformance();
// 上报性能指标到 PostHog
reportPerformanceMetrics(this.metrics);
return this.metrics;
}

View File

@@ -103,3 +103,71 @@ export const PriceArrow = ({ value }) => {
return <Icon color={color} boxSize="16px" />;
};
// ==================== 货币/数值格式化 ====================
/**
* 格式化货币金额(自动选择单位:亿元/万元/元)
* @param {number|null|undefined} value - 金额(单位:元)
* @returns {string} 格式化后的金额字符串
*/
export const formatCurrency = (value) => {
if (value === null || value === undefined) return '-';
const absValue = Math.abs(value);
if (absValue >= 100000000) {
return (value / 100000000).toFixed(2) + '亿元';
} else if (absValue >= 10000) {
return (value / 10000).toFixed(2) + '万元';
}
return value.toFixed(2) + '元';
};
/**
* 格式化业务营收(支持指定单位)
* @param {number|null|undefined} value - 营收金额
* @param {string} [unit] - 原始单位(元/万元/亿元)
* @returns {string} 格式化后的营收字符串
*/
export const formatBusinessRevenue = (value, unit) => {
if (value === null || value === undefined) return '-';
if (unit) {
if (unit === '元') {
const absValue = Math.abs(value);
if (absValue >= 100000000) {
return (value / 100000000).toFixed(2) + '亿元';
} else if (absValue >= 10000) {
return (value / 10000).toFixed(2) + '万元';
}
return value.toFixed(0) + '元';
} else if (unit === '万元') {
const absValue = Math.abs(value);
if (absValue >= 10000) {
return (value / 10000).toFixed(2) + '亿元';
}
return value.toFixed(2) + '万元';
} else if (unit === '亿元') {
return value.toFixed(2) + '亿元';
} else {
return value.toFixed(2) + unit;
}
}
// 无单位时,假设为元
const absValue = Math.abs(value);
if (absValue >= 100000000) {
return (value / 100000000).toFixed(2) + '亿元';
} else if (absValue >= 10000) {
return (value / 10000).toFixed(2) + '万元';
}
return value.toFixed(2) + '元';
};
/**
* 格式化百分比
* @param {number|null|undefined} value - 百分比值
* @param {number} [decimals=2] - 小数位数
* @returns {string} 格式化后的百分比字符串
*/
export const formatPercentage = (value, decimals = 2) => {
if (value === null || value === undefined) return '-';
return value.toFixed(decimals) + '%';
};

View File

@@ -207,9 +207,12 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
const raw = chartData.rawData[idx];
if (!raw) return '';
// 安全格式化数字
const safeFixed = (val, digits = 2) => (val != null && !isNaN(val)) ? val.toFixed(digits) : '-';
// 计算涨跌
const prevClose = raw.prev_close || (idx > 0 ? chartData.rawData[idx - 1]?.close : raw.open) || raw.open;
const changeAmount = raw.close - prevClose;
const changeAmount = (raw.close != null && prevClose != null) ? (raw.close - prevClose) : 0;
const changePct = prevClose ? ((changeAmount / prevClose) * 100) : 0;
const isUp = changeAmount >= 0;
const color = isUp ? '#ef5350' : '#26a69a';
@@ -218,22 +221,22 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
return `
<div style="min-width: 180px;">
<div style="font-weight: bold; color: #FFD700; margin-bottom: 10px; font-size: 13px; border-bottom: 1px solid rgba(255,215,0,0.2); padding-bottom: 8px;">
📅 ${raw.time}
📅 ${raw.time || '-'}
</div>
<div style="display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; font-size: 12px;">
<span style="color: #999;">开盘</span>
<span style="text-align: right; font-family: monospace;">${raw.open.toFixed(2)}</span>
<span style="text-align: right; font-family: monospace;">${safeFixed(raw.open)}</span>
<span style="color: #999;">收盘</span>
<span style="text-align: right; font-weight: bold; color: ${color}; font-family: monospace;">${raw.close.toFixed(2)}</span>
<span style="text-align: right; font-weight: bold; color: ${color}; font-family: monospace;">${safeFixed(raw.close)}</span>
<span style="color: #999;">最高</span>
<span style="text-align: right; color: #ef5350; font-family: monospace;">${raw.high.toFixed(2)}</span>
<span style="text-align: right; color: #ef5350; font-family: monospace;">${safeFixed(raw.high)}</span>
<span style="color: #999;">最低</span>
<span style="text-align: right; color: #26a69a; font-family: monospace;">${raw.low.toFixed(2)}</span>
<span style="text-align: right; color: #26a69a; font-family: monospace;">${safeFixed(raw.low)}</span>
</div>
<div style="margin-top: 10px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1); display: flex; justify-content: space-between; align-items: center;">
<span style="color: #999; font-size: 11px;">涨跌幅</span>
<span style="color: ${color}; font-weight: bold; font-size: 14px; font-family: monospace;">
${sign}${changeAmount.toFixed(2)} (${sign}${changePct.toFixed(2)}%)
${sign}${safeFixed(changeAmount)} (${sign}${safeFixed(changePct)}%)
</span>
</div>
</div>
@@ -529,7 +532,7 @@ const FlowingConcepts = () => {
color={colors.text}
whiteSpace="nowrap"
>
{concept.change_pct > 0 ? '+' : ''}{concept.change_pct.toFixed(2)}%
{concept.change_pct > 0 ? '+' : ''}{concept.change_pct?.toFixed(2) ?? '-'}%
</Text>
</HStack>
</Box>

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +1,89 @@
// src/views/Company/components/CompanyHeader/SearchBar.js
// 股票搜索栏组件 - 金色主题
// 股票搜索栏组件 - 金色主题 + 模糊搜索下拉
import React from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import {
Box,
HStack,
Input,
Button,
InputGroup,
InputLeftElement,
Text,
VStack,
} from '@chakra-ui/react';
import { SearchIcon } from '@chakra-ui/icons';
import { useStockSearch } from '../../hooks/useStockSearch';
/**
* 股票搜索栏组件
* 股票搜索栏组件(带模糊搜索下拉)
*
* @param {Object} props
* @param {string} props.inputCode - 输入框当前值
* @param {Function} props.onInputChange - 输入变化回调
* @param {Function} props.onSearch - 搜索按钮点击回调
* @param {Function} props.onKeyPress - 键盘事件回调
* @param {Function} props.onKeyDown - 键盘事件回调
*/
const SearchBar = ({
inputCode,
onInputChange,
onSearch,
onKeyPress,
onKeyDown,
}) => {
// 下拉状态
const [showDropdown, setShowDropdown] = useState(false);
const containerRef = useRef(null);
// 从 Redux 获取全部股票列表
const allStocks = useSelector(state => state.stock.allStocks);
// 使用共享的搜索 Hook
const filteredStocks = useStockSearch(allStocks, inputCode, { limit: 10 });
// 根据搜索结果更新下拉显示状态
useEffect(() => {
setShowDropdown(filteredStocks.length > 0 && !!inputCode?.trim());
}, [filteredStocks, inputCode]);
// 点击外部关闭下拉
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 选择股票 - 直接触发搜索跳转
const handleSelectStock = (stock) => {
onInputChange(stock.code);
setShowDropdown(false);
onSearch(stock.code);
};
// 处理键盘事件
const handleKeyDownWrapper = (e) => {
if (e.key === 'Enter') {
setShowDropdown(false);
}
onKeyDown?.(e);
};
return (
<HStack spacing={3}>
<InputGroup size="lg" maxW="300px">
<Box ref={containerRef} position="relative" w="300px">
<InputGroup size="lg">
<InputLeftElement pointerEvents="none">
<SearchIcon color="#C9A961" />
</InputLeftElement>
<Input
placeholder="输入股票代码"
placeholder="输入股票代码或名称"
value={inputCode}
onChange={(e) => onInputChange(e.target.value)}
onKeyPress={onKeyPress}
onKeyDown={handleKeyDownWrapper}
onFocus={() => inputCode && filteredStocks.length > 0 && setShowDropdown(true)}
borderRadius="md"
color="white"
borderColor="#C9A961"
@@ -50,19 +97,50 @@ const SearchBar = ({
}}
/>
</InputGroup>
<Button
size="lg"
onClick={onSearch}
leftIcon={<SearchIcon />}
bg="#1A202C"
color="#C9A961"
borderWidth="1px"
borderColor="#C9A961"
_hover={{ bg: '#1a1a1a', borderColor: '#F4D03F', color: '#F4D03F' }}
>
查询
</Button>
</HStack>
{/* 模糊搜索下拉列表 */}
{showDropdown && (
<Box
position="absolute"
top="100%"
left={0}
mt={1}
w="100%"
bg="#1A202C"
border="1px solid #C9A961"
borderRadius="md"
maxH="300px"
overflowY="auto"
zIndex={1000}
boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)"
>
<VStack align="stretch" spacing={0}>
{filteredStocks.map((stock) => (
<Box
key={stock.code}
px={4}
py={2}
cursor="pointer"
_hover={{ bg: 'whiteAlpha.100' }}
onClick={() => handleSelectStock(stock)}
borderBottom="1px solid"
borderColor="whiteAlpha.100"
_last={{ borderBottom: 'none' }}
>
<HStack justify="space-between">
<Text color="#F4D03F" fontWeight="bold" fontSize="sm">
{stock.code}
</Text>
<Text color="#C9A961" fontSize="sm" noOfLines={1} maxW="180px">
{stock.name}
</Text>
</HStack>
</Box>
))}
</VStack>
</Box>
)}
</Box>
);
};

View File

@@ -18,20 +18,20 @@ import SearchBar from './SearchBar';
*
* 包含:
* - 页面标题和描述(金色主题)
* - 股票搜索栏
* - 股票搜索栏(支持模糊搜索)
*
* @param {Object} props
* @param {string} props.inputCode - 搜索输入框值
* @param {Function} props.onInputChange - 输入变化回调
* @param {Function} props.onSearch - 搜索回调
* @param {Function} props.onKeyPress - 键盘事件回调
* @param {Function} props.onKeyDown - 键盘事件回调
* @param {string} props.bgColor - 背景颜色
*/
const CompanyHeader = ({
inputCode,
onInputChange,
onSearch,
onKeyPress,
onKeyDown,
bgColor,
}) => {
return (
@@ -51,7 +51,7 @@ const CompanyHeader = ({
inputCode={inputCode}
onInputChange={onInputChange}
onSearch={onSearch}
onKeyPress={onKeyPress}
onKeyDown={onKeyDown}
/>
</HStack>
</CardBody>

View File

@@ -1,942 +0,0 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab.js
// 基本信息 Tab - 股权结构、管理团队、公司公告、分支机构、工商信息
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Heading,
Badge,
Icon,
Card,
CardBody,
CardHeader,
SimpleGrid,
Avatar,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Tag,
Tooltip,
Divider,
Center,
Code,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Stat,
StatLabel,
StatNumber,
StatHelpText,
IconButton,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
} from "@chakra-ui/react";
import { ExternalLinkIcon } from "@chakra-ui/icons";
import {
FaShareAlt,
FaUserTie,
FaBullhorn,
FaSitemap,
FaInfoCircle,
FaCrown,
FaChartPie,
FaUsers,
FaChartLine,
FaArrowUp,
FaArrowDown,
FaChartBar,
FaBuilding,
FaGlobe,
FaShieldAlt,
FaBriefcase,
FaCircle,
FaEye,
FaVenusMars,
FaGraduationCap,
FaPassport,
FaCalendarAlt,
} from "react-icons/fa";
// 格式化工具函数
const formatUtils = {
formatPercentage: (value) => {
if (value === null || value === undefined) return "-";
return `${(value * 100).toFixed(2)}%`;
},
formatNumber: (value) => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}`;
}
return value.toLocaleString();
},
formatShares: (value) => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿股`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}万股`;
}
return `${value.toLocaleString()}`;
},
formatDate: (dateStr) => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
},
};
// 股东类型标签组件
const ShareholderTypeBadge = ({ type }) => {
const typeConfig = {
基金: { color: "blue", icon: FaChartBar },
个人: { color: "green", icon: FaUserTie },
法人: { color: "purple", icon: FaBuilding },
QFII: { color: "orange", icon: FaGlobe },
社保: { color: "red", icon: FaShieldAlt },
保险: { color: "teal", icon: FaShieldAlt },
信托: { color: "cyan", icon: FaBriefcase },
券商: { color: "pink", icon: FaChartLine },
};
const config = Object.entries(typeConfig).find(([key]) =>
type?.includes(key)
)?.[1] || { color: "gray", icon: FaCircle };
return (
<Badge colorScheme={config.color} size="sm">
<Icon as={config.icon} mr={1} boxSize={3} />
{type}
</Badge>
);
};
/**
* 基本信息 Tab 组件
*
* Props:
* - basicInfo: 公司基本信息
* - actualControl: 实际控制人数组
* - concentration: 股权集中度数组
* - topShareholders: 前十大股东数组
* - topCirculationShareholders: 前十大流通股东数组
* - management: 管理层数组
* - announcements: 公告列表数组
* - branches: 分支机构数组
* - disclosureSchedule: 披露日程数组
* - cardBg: 卡片背景色
* - onAnnouncementClick: 公告点击回调 (announcement) => void
*/
const BasicInfoTab = ({
basicInfo,
actualControl = [],
concentration = [],
topShareholders = [],
topCirculationShareholders = [],
management = [],
announcements = [],
branches = [],
disclosureSchedule = [],
cardBg,
loading = false,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedAnnouncement, setSelectedAnnouncement] = React.useState(null);
// 管理层职位分类
const getManagementByCategory = () => {
const categories = {
高管: [],
董事: [],
监事: [],
其他: [],
};
management.forEach((person) => {
if (
person.position_category === "高管" ||
person.position_name?.includes("总")
) {
categories["高管"].push(person);
} else if (
person.position_category === "董事" ||
person.position_name?.includes("董事")
) {
categories["董事"].push(person);
} else if (
person.position_category === "监事" ||
person.position_name?.includes("监事")
) {
categories["监事"].push(person);
} else {
categories["其他"].push(person);
}
});
return categories;
};
// 计算股权集中度变化
const getConcentrationTrend = () => {
const grouped = {};
concentration.forEach((item) => {
if (!grouped[item.end_date]) {
grouped[item.end_date] = {};
}
grouped[item.end_date][item.stat_item] = item;
});
return Object.entries(grouped)
.sort((a, b) => b[0].localeCompare(a[0]))
.slice(0, 5);
};
// 处理公告点击
const handleAnnouncementClick = (announcement) => {
setSelectedAnnouncement(announcement);
onOpen();
};
return (
<>
<Card bg={cardBg} shadow="md">
<CardBody>
<Tabs variant="enclosed" colorScheme="blue">
<TabList flexWrap="wrap">
<Tab>
<Icon as={FaShareAlt} mr={2} />
股权结构
</Tab>
<Tab>
<Icon as={FaUserTie} mr={2} />
管理团队
</Tab>
<Tab>
<Icon as={FaBullhorn} mr={2} />
公司公告
</Tab>
<Tab>
<Icon as={FaSitemap} mr={2} />
分支机构
</Tab>
<Tab>
<Icon as={FaInfoCircle} mr={2} />
工商信息
</Tab>
</TabList>
<TabPanels>
{/* 股权结构标签页 */}
<TabPanel>
<VStack spacing={6} align="stretch">
{actualControl.length > 0 && (
<Box>
<HStack mb={4}>
<Icon as={FaCrown} color="gold" boxSize={5} />
<Heading size="sm">实际控制人</Heading>
</HStack>
<Card variant="outline">
<CardBody>
<HStack justify="space-between">
<VStack align="start">
<Text fontWeight="bold" fontSize="lg">
{actualControl[0].actual_controller_name}
</Text>
<HStack>
<Badge colorScheme="purple">
{actualControl[0].control_type}
</Badge>
<Text fontSize="sm" color="gray.500">
截至{" "}
{formatUtils.formatDate(
actualControl[0].end_date
)}
</Text>
</HStack>
</VStack>
<Stat textAlign="right">
<StatLabel>控制比例</StatLabel>
<StatNumber color="purple.500">
{formatUtils.formatPercentage(
actualControl[0].holding_ratio
)}
</StatNumber>
<StatHelpText>
{formatUtils.formatShares(
actualControl[0].holding_shares
)}
</StatHelpText>
</Stat>
</HStack>
</CardBody>
</Card>
</Box>
)}
{concentration.length > 0 && (
<Box>
<HStack mb={4}>
<Icon as={FaChartPie} color="blue.500" boxSize={5} />
<Heading size="sm">股权集中度</Heading>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{getConcentrationTrend()
.slice(0, 1)
.map(([date, items]) => (
<Card key={date} variant="outline">
<CardHeader pb={2}>
<Text fontSize="sm" color="gray.500">
{formatUtils.formatDate(date)}
</Text>
</CardHeader>
<CardBody pt={2}>
<VStack spacing={3} align="stretch">
{Object.entries(items).map(([key, item]) => (
<HStack key={key} justify="space-between">
<Text fontSize="sm">
{item.stat_item}
</Text>
<HStack>
<Text
fontWeight="bold"
color="blue.500"
>
{formatUtils.formatPercentage(
item.holding_ratio
)}
</Text>
{item.ratio_change && (
<Badge
colorScheme={
item.ratio_change > 0
? "red"
: "green"
}
>
<Icon
as={
item.ratio_change > 0
? FaArrowUp
: FaArrowDown
}
mr={1}
boxSize={3}
/>
{Math.abs(
item.ratio_change
).toFixed(2)}
%
</Badge>
)}
</HStack>
</HStack>
))}
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
)}
{topShareholders.length > 0 && (
<Box>
<HStack mb={4}>
<Icon as={FaUsers} color="green.500" boxSize={5} />
<Heading size="sm">十大股东</Heading>
<Badge>
{formatUtils.formatDate(topShareholders[0].end_date)}
</Badge>
</HStack>
<TableContainer>
<Table size="sm" variant="striped">
<Thead>
<Tr>
<Th>排名</Th>
<Th>股东名称</Th>
<Th>股东类型</Th>
<Th isNumeric>持股数量</Th>
<Th isNumeric>持股比例</Th>
<Th>股份性质</Th>
</Tr>
</Thead>
<Tbody>
{topShareholders
.slice(0, 10)
.map((shareholder, idx) => (
<Tr key={idx}>
<Td>
<Badge
colorScheme={idx < 3 ? "red" : "gray"}
>
{shareholder.shareholder_rank}
</Badge>
</Td>
<Td>
<Tooltip
label={shareholder.shareholder_name}
>
<Text noOfLines={1} maxW="200px">
{shareholder.shareholder_name}
</Text>
</Tooltip>
</Td>
<Td>
<ShareholderTypeBadge
type={shareholder.shareholder_type}
/>
</Td>
<Td isNumeric fontWeight="medium">
{formatUtils.formatShares(
shareholder.holding_shares
)}
</Td>
<Td isNumeric>
<Text color="blue.500" fontWeight="bold">
{formatUtils.formatPercentage(
shareholder.total_share_ratio
)}
</Text>
</Td>
<Td>
<Badge size="sm" variant="outline">
{shareholder.share_nature || "流通股"}
</Badge>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
)}
{topCirculationShareholders.length > 0 && (
<Box>
<HStack mb={4}>
<Icon as={FaChartLine} color="purple.500" boxSize={5} />
<Heading size="sm">十大流通股东</Heading>
<Badge>
{formatUtils.formatDate(
topCirculationShareholders[0].end_date
)}
</Badge>
</HStack>
<TableContainer>
<Table size="sm" variant="striped">
<Thead>
<Tr>
<Th>排名</Th>
<Th>股东名称</Th>
<Th>股东类型</Th>
<Th isNumeric>持股数量</Th>
<Th isNumeric>流通股比例</Th>
</Tr>
</Thead>
<Tbody>
{topCirculationShareholders
.slice(0, 10)
.map((shareholder, idx) => (
<Tr key={idx}>
<Td>
<Badge
colorScheme={idx < 3 ? "orange" : "gray"}
>
{shareholder.shareholder_rank}
</Badge>
</Td>
<Td>
<Tooltip
label={shareholder.shareholder_name}
>
<Text noOfLines={1} maxW="250px">
{shareholder.shareholder_name}
</Text>
</Tooltip>
</Td>
<Td>
<ShareholderTypeBadge
type={shareholder.shareholder_type}
/>
</Td>
<Td isNumeric fontWeight="medium">
{formatUtils.formatShares(
shareholder.holding_shares
)}
</Td>
<Td isNumeric>
<Text color="purple.500" fontWeight="bold">
{formatUtils.formatPercentage(
shareholder.circulation_share_ratio
)}
</Text>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
)}
</VStack>
</TabPanel>
{/* 管理团队标签页 */}
<TabPanel>
<VStack spacing={6} align="stretch">
{Object.entries(getManagementByCategory()).map(
([category, people]) =>
people.length > 0 && (
<Box key={category}>
<HStack mb={4}>
<Icon
as={
category === "高管"
? FaUserTie
: category === "董事"
? FaCrown
: category === "监事"
? FaEye
: FaUsers
}
color={
category === "高管"
? "blue.500"
: category === "董事"
? "purple.500"
: category === "监事"
? "green.500"
: "gray.500"
}
boxSize={5}
/>
<Heading size="sm">{category}</Heading>
<Badge>{people.length}</Badge>
</HStack>
<SimpleGrid
columns={{ base: 1, md: 2, lg: 3 }}
spacing={4}
>
{people.map((person, idx) => (
<Card key={idx} variant="outline" size="sm">
<CardBody>
<HStack spacing={3} align="start">
<Avatar
name={person.name}
size="md"
bg={
category === "高管"
? "blue.500"
: category === "董事"
? "purple.500"
: category === "监事"
? "green.500"
: "gray.500"
}
/>
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Text fontWeight="bold">
{person.name}
</Text>
{person.gender && (
<Icon
as={FaVenusMars}
color={
person.gender === "男"
? "blue.400"
: "pink.400"
}
boxSize={3}
/>
)}
</HStack>
<Text fontSize="sm" color="blue.600">
{person.position_name}
</Text>
<HStack spacing={2} flexWrap="wrap">
{person.education && (
<Tag size="sm" variant="subtle">
<Icon
as={FaGraduationCap}
mr={1}
boxSize={3}
/>
{person.education}
</Tag>
)}
{person.birth_year && (
<Tag size="sm" variant="subtle">
{new Date().getFullYear() -
parseInt(person.birth_year)}
</Tag>
)}
{person.nationality &&
person.nationality !== "中国" && (
<Tag size="sm" colorScheme="orange">
<Icon
as={FaPassport}
mr={1}
boxSize={3}
/>
{person.nationality}
</Tag>
)}
</HStack>
<Text fontSize="xs" color="gray.500">
任职日期
{formatUtils.formatDate(
person.start_date
)}
</Text>
</VStack>
</HStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
)
)}
</VStack>
</TabPanel>
{/* 公司公告标签页 */}
<TabPanel>
<VStack spacing={4} align="stretch">
{disclosureSchedule.length > 0 && (
<Box>
<HStack mb={3}>
<Icon as={FaCalendarAlt} color="orange.500" />
<Text fontWeight="bold">财报披露日程</Text>
</HStack>
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
{disclosureSchedule.slice(0, 4).map((schedule, idx) => (
<Card
key={idx}
variant="outline"
size="sm"
bg={
schedule.is_disclosed ? "green.50" : "orange.50"
}
>
<CardBody p={3}>
<VStack spacing={1}>
<Badge
colorScheme={
schedule.is_disclosed ? "green" : "orange"
}
>
{schedule.report_name}
</Badge>
<Text fontSize="sm" fontWeight="bold">
{schedule.is_disclosed ? "已披露" : "预计"}
</Text>
<Text fontSize="xs">
{formatUtils.formatDate(
schedule.is_disclosed
? schedule.actual_date
: schedule.latest_scheduled_date
)}
</Text>
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
)}
<Divider />
<Box>
<HStack mb={3}>
<Icon as={FaBullhorn} color="blue.500" />
<Text fontWeight="bold">最新公告</Text>
</HStack>
<VStack spacing={2} align="stretch">
{announcements.map((announcement, idx) => (
<Card
key={idx}
variant="outline"
size="sm"
cursor="pointer"
onClick={() => handleAnnouncementClick(announcement)}
_hover={{ bg: "gray.50" }}
>
<CardBody p={3}>
<HStack justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Badge size="sm" colorScheme="blue">
{announcement.info_type || "公告"}
</Badge>
<Text fontSize="xs" color="gray.500">
{formatUtils.formatDate(
announcement.announce_date
)}
</Text>
</HStack>
<Text
fontSize="sm"
fontWeight="medium"
noOfLines={1}
>
{announcement.title}
</Text>
</VStack>
<HStack>
{announcement.format && (
<Tag size="sm" variant="subtle">
{announcement.format}
</Tag>
)}
<IconButton
size="sm"
icon={<ExternalLinkIcon />}
variant="ghost"
onClick={(e) => {
e.stopPropagation();
window.open(announcement.url, "_blank");
}}
/>
</HStack>
</HStack>
</CardBody>
</Card>
))}
</VStack>
</Box>
</VStack>
</TabPanel>
{/* 分支机构标签页 */}
<TabPanel>
{branches.length > 0 ? (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{branches.map((branch, idx) => (
<Card key={idx} variant="outline">
<CardBody>
<VStack align="start" spacing={3}>
<HStack justify="space-between" w="full">
<Text fontWeight="bold">
{branch.branch_name}
</Text>
<Badge
colorScheme={
branch.business_status === "存续"
? "green"
: "red"
}
>
{branch.business_status}
</Badge>
</HStack>
<SimpleGrid columns={2} spacing={2} w="full">
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.500">
注册资本
</Text>
<Text fontSize="sm" fontWeight="medium">
{branch.register_capital || "-"}
</Text>
</VStack>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.500">
法人代表
</Text>
<Text fontSize="sm" fontWeight="medium">
{branch.legal_person || "-"}
</Text>
</VStack>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.500">
成立日期
</Text>
<Text fontSize="sm" fontWeight="medium">
{formatUtils.formatDate(branch.register_date)}
</Text>
</VStack>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.500">
关联企业
</Text>
<Text fontSize="sm" fontWeight="medium">
{branch.related_company_count || 0}
</Text>
</VStack>
</SimpleGrid>
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
) : (
<Center h="200px">
<VStack>
<Icon as={FaSitemap} boxSize={12} color="gray.300" />
<Text color="gray.500">暂无分支机构信息</Text>
</VStack>
</Center>
)}
</TabPanel>
{/* 工商信息标签页 */}
<TabPanel>
{basicInfo && (
<VStack spacing={4} align="stretch">
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
<Box>
<Heading size="sm" mb={3}>
工商信息
</Heading>
<VStack align="start" spacing={2}>
<HStack w="full">
<Text fontSize="sm" color="gray.600" minW="80px">
统一信用代码
</Text>
<Code fontSize="xs">{basicInfo.credit_code}</Code>
</HStack>
<HStack w="full">
<Text fontSize="sm" color="gray.600" minW="80px">
公司规模
</Text>
<Text fontSize="sm">{basicInfo.company_size}</Text>
</HStack>
<HStack w="full">
<Text fontSize="sm" color="gray.600" minW="80px">
注册地址
</Text>
<Text fontSize="sm" noOfLines={2}>
{basicInfo.reg_address}
</Text>
</HStack>
<HStack w="full">
<Text fontSize="sm" color="gray.600" minW="80px">
办公地址
</Text>
<Text fontSize="sm" noOfLines={2}>
{basicInfo.office_address}
</Text>
</HStack>
</VStack>
</Box>
<Box>
<Heading size="sm" mb={3}>
服务机构
</Heading>
<VStack align="start" spacing={2}>
<Box>
<Text fontSize="sm" color="gray.600">
会计师事务所
</Text>
<Text fontSize="sm" fontWeight="medium">
{basicInfo.accounting_firm}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
律师事务所
</Text>
<Text fontSize="sm" fontWeight="medium">
{basicInfo.law_firm}
</Text>
</Box>
</VStack>
</Box>
</SimpleGrid>
<Divider />
<Box>
<Heading size="sm" mb={3}>
主营业务
</Heading>
<Text fontSize="sm" lineHeight="tall">
{basicInfo.main_business}
</Text>
</Box>
<Box>
<Heading size="sm" mb={3}>
经营范围
</Heading>
<Text fontSize="sm" lineHeight="tall" color="gray.700">
{basicInfo.business_scope}
</Text>
</Box>
</VStack>
)}
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
{/* 公告详情模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<VStack align="start" spacing={1}>
<Text>{selectedAnnouncement?.title}</Text>
<HStack>
<Badge colorScheme="blue">
{selectedAnnouncement?.info_type || "公告"}
</Badge>
<Text fontSize="sm" color="gray.500">
{formatUtils.formatDate(selectedAnnouncement?.announce_date)}
</Text>
</HStack>
</VStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="start" spacing={3}>
<Text fontSize="sm">
文件格式{selectedAnnouncement?.format || "-"}
</Text>
<Text fontSize="sm">
文件大小{selectedAnnouncement?.file_size || "-"} KB
</Text>
</VStack>
</ModalBody>
<ModalFooter>
<Button
colorScheme="blue"
mr={3}
onClick={() => window.open(selectedAnnouncement?.url, "_blank")}
>
查看原文
</Button>
<Button variant="ghost" onClick={onClose}>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
export default BasicInfoTab;

View File

@@ -0,0 +1,157 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/AnnouncementsPanel.tsx
// 公司公告 Tab Panel
import React, { useState } from "react";
import {
Box,
VStack,
HStack,
Text,
Badge,
Card,
CardBody,
IconButton,
Button,
Tag,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
} from "@chakra-ui/react";
import { ExternalLinkIcon } from "@chakra-ui/icons";
import { useAnnouncementsData } from "../../hooks/useAnnouncementsData";
import { THEME } from "../config";
import { formatDate } from "../utils";
import LoadingState from "./LoadingState";
interface AnnouncementsPanelProps {
stockCode: string;
}
const AnnouncementsPanel: React.FC<AnnouncementsPanelProps> = ({ stockCode }) => {
const { announcements, loading } = useAnnouncementsData(stockCode);
const { isOpen, onOpen, onClose } = useDisclosure();
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null);
const handleAnnouncementClick = (announcement: any) => {
setSelectedAnnouncement(announcement);
onOpen();
};
if (loading) {
return <LoadingState message="加载公告数据..." />;
}
return (
<>
<VStack spacing={4} align="stretch">
{/* 最新公告 */}
<Box>
<VStack spacing={2} align="stretch">
{announcements.map((announcement: any, idx: number) => (
<Card
key={idx}
bg={THEME.tableBg}
border="1px solid"
borderColor={THEME.border}
size="sm"
cursor="pointer"
onClick={() => handleAnnouncementClick(announcement)}
_hover={{ bg: THEME.tableHoverBg }}
>
<CardBody p={3}>
<HStack justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<HStack>
<Badge size="sm" bg={THEME.gold} color="gray.900">
{announcement.info_type || "公告"}
</Badge>
<Text fontSize="xs" color={THEME.textSecondary}>
{formatDate(announcement.announce_date)}
</Text>
</HStack>
<Text fontSize="sm" fontWeight="medium" noOfLines={1} color={THEME.textPrimary}>
{announcement.title}
</Text>
</VStack>
<HStack>
{announcement.format && (
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
{announcement.format}
</Tag>
)}
<IconButton
size="sm"
icon={<ExternalLinkIcon />}
variant="ghost"
color={THEME.goldLight}
aria-label="查看原文"
onClick={(e) => {
e.stopPropagation();
window.open(announcement.url, "_blank");
}}
/>
</HStack>
</HStack>
</CardBody>
</Card>
))}
</VStack>
</Box>
</VStack>
{/* 公告详情模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent bg={THEME.cardBg}>
<ModalHeader color={THEME.textPrimary}>
<VStack align="start" spacing={1}>
<Text>{selectedAnnouncement?.title}</Text>
<HStack>
<Badge bg={THEME.gold} color="gray.900">
{selectedAnnouncement?.info_type || "公告"}
</Badge>
<Text fontSize="sm" color={THEME.textSecondary}>
{formatDate(selectedAnnouncement?.announce_date)}
</Text>
</HStack>
</VStack>
</ModalHeader>
<ModalCloseButton color={THEME.textPrimary} />
<ModalBody>
<VStack align="start" spacing={3}>
<Text fontSize="sm" color={THEME.textSecondary}>
{selectedAnnouncement?.format || "-"}
</Text>
<Text fontSize="sm" color={THEME.textSecondary}>
{selectedAnnouncement?.file_size || "-"} KB
</Text>
</VStack>
</ModalBody>
<ModalFooter>
<Button
bg={THEME.gold}
color="gray.900"
mr={3}
_hover={{ bg: THEME.goldLight }}
onClick={() => window.open(selectedAnnouncement?.url, "_blank")}
>
</Button>
<Button variant="ghost" color={THEME.textSecondary} onClick={onClose}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
export default AnnouncementsPanel;

View File

@@ -0,0 +1,168 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BranchesPanel.tsx
// 分支机构 Tab Panel - 黑金风格
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Icon,
SimpleGrid,
Center,
} from "@chakra-ui/react";
import { FaSitemap, FaBuilding, FaCheckCircle, FaTimesCircle } from "react-icons/fa";
import { useBranchesData } from "../../hooks/useBranchesData";
import { THEME } from "../config";
import { formatDate } from "../utils";
import LoadingState from "./LoadingState";
interface BranchesPanelProps {
stockCode: string;
}
// 黑金卡片样式
const cardStyles = {
bg: "linear-gradient(145deg, rgba(30, 30, 35, 0.95), rgba(20, 20, 25, 0.98))",
border: "1px solid",
borderColor: "rgba(212, 175, 55, 0.3)",
borderRadius: "12px",
overflow: "hidden",
transition: "all 0.3s ease",
_hover: {
borderColor: "rgba(212, 175, 55, 0.6)",
boxShadow: "0 4px 20px rgba(212, 175, 55, 0.15), inset 0 1px 0 rgba(212, 175, 55, 0.1)",
transform: "translateY(-2px)",
},
};
// 状态徽章样式
const getStatusBadgeStyles = (isActive: boolean) => ({
display: "inline-flex",
alignItems: "center",
gap: "4px",
px: 2,
py: 0.5,
borderRadius: "full",
fontSize: "xs",
fontWeight: "medium",
bg: isActive ? "rgba(212, 175, 55, 0.15)" : "rgba(255, 100, 100, 0.15)",
color: isActive ? THEME.gold : "#ff6b6b",
border: "1px solid",
borderColor: isActive ? "rgba(212, 175, 55, 0.3)" : "rgba(255, 100, 100, 0.3)",
});
// 信息项组件
const InfoItem: React.FC<{ label: string; value: string | number }> = ({ label, value }) => (
<VStack align="start" spacing={0.5}>
<Text fontSize="xs" color={THEME.textSecondary} letterSpacing="0.5px">
{label}
</Text>
<Text fontSize="sm" fontWeight="semibold" color={THEME.textPrimary}>
{value || "-"}
</Text>
</VStack>
);
const BranchesPanel: React.FC<BranchesPanelProps> = ({ stockCode }) => {
const { branches, loading } = useBranchesData(stockCode);
if (loading) {
return <LoadingState message="加载分支机构数据..." />;
}
if (branches.length === 0) {
return (
<Center h="200px">
<VStack spacing={3}>
<Box
p={4}
borderRadius="full"
bg="rgba(212, 175, 55, 0.1)"
border="1px solid"
borderColor="rgba(212, 175, 55, 0.2)"
>
<Icon as={FaSitemap} boxSize={10} color={THEME.gold} opacity={0.6} />
</Box>
<Text color={THEME.textSecondary} fontSize="sm">
</Text>
</VStack>
</Center>
);
}
return (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{branches.map((branch: any, idx: number) => {
const isActive = branch.business_status === "存续";
return (
<Box key={idx} sx={cardStyles}>
{/* 顶部金色装饰线 */}
<Box
h="2px"
bgGradient="linear(to-r, transparent, rgba(212, 175, 55, 0.6), transparent)"
/>
<Box p={4}>
<VStack align="start" spacing={4}>
{/* 标题行 */}
<HStack justify="space-between" w="full" align="flex-start">
<HStack spacing={2} flex={1}>
<Box
p={1.5}
borderRadius="md"
bg="rgba(212, 175, 55, 0.1)"
>
<Icon as={FaBuilding} boxSize={3.5} color={THEME.gold} />
</Box>
<Text
fontWeight="bold"
color={THEME.textPrimary}
fontSize="sm"
noOfLines={2}
lineHeight="tall"
>
{branch.branch_name}
</Text>
</HStack>
{/* 状态徽章 */}
<Box sx={getStatusBadgeStyles(isActive)}>
<Icon
as={isActive ? FaCheckCircle : FaTimesCircle}
boxSize={3}
/>
<Text>{branch.business_status}</Text>
</Box>
</HStack>
{/* 分隔线 */}
<Box
w="full"
h="1px"
bgGradient="linear(to-r, rgba(212, 175, 55, 0.3), transparent)"
/>
{/* 信息网格 */}
<SimpleGrid columns={2} spacing={3} w="full">
<InfoItem label="注册资本" value={branch.register_capital} />
<InfoItem label="法人代表" value={branch.legal_person} />
<InfoItem label="成立日期" value={formatDate(branch.register_date)} />
<InfoItem
label="关联企业"
value={`${branch.related_company_count || 0}`}
/>
</SimpleGrid>
</VStack>
</Box>
</Box>
);
})}
</SimpleGrid>
);
};
export default BranchesPanel;

View File

@@ -0,0 +1,121 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/BusinessInfoPanel.tsx
// 工商信息 Tab Panel
import React from "react";
import {
Box,
VStack,
HStack,
Text,
Heading,
SimpleGrid,
Divider,
Center,
Code,
Spinner,
} from "@chakra-ui/react";
import { THEME } from "../config";
import { useBasicInfo } from "../../hooks/useBasicInfo";
interface BusinessInfoPanelProps {
stockCode: string;
}
const BusinessInfoPanel: React.FC<BusinessInfoPanelProps> = ({ stockCode }) => {
const { basicInfo, loading } = useBasicInfo(stockCode);
if (loading) {
return (
<Center h="200px">
<Spinner size="lg" color={THEME.gold} />
</Center>
);
}
if (!basicInfo) {
return (
<Center h="200px">
<Text color={THEME.textSecondary}></Text>
</Center>
);
}
return (
<VStack spacing={4} align="stretch">
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={6}>
<Box>
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading>
<VStack align="start" spacing={2}>
<HStack w="full">
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
</Text>
<Code fontSize="xs" bg={THEME.tableHoverBg} color={THEME.goldLight}>
{basicInfo.credit_code}
</Code>
</HStack>
<HStack w="full">
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
</Text>
<Text fontSize="sm" color={THEME.textPrimary}>{basicInfo.company_size}</Text>
</HStack>
<HStack w="full" align="start">
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
</Text>
<Text fontSize="sm" noOfLines={2} color={THEME.textPrimary}>
{basicInfo.reg_address}
</Text>
</HStack>
<HStack w="full" align="start">
<Text fontSize="sm" color={THEME.textSecondary} minW="80px">
</Text>
<Text fontSize="sm" noOfLines={2} color={THEME.textPrimary}>
{basicInfo.office_address}
</Text>
</HStack>
</VStack>
</Box>
<Box>
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading>
<VStack align="start" spacing={2}>
<Box>
<Text fontSize="sm" color={THEME.textSecondary}></Text>
<Text fontSize="sm" fontWeight="medium" color={THEME.textPrimary}>
{basicInfo.accounting_firm}
</Text>
</Box>
<Box>
<Text fontSize="sm" color={THEME.textSecondary}></Text>
<Text fontSize="sm" fontWeight="medium" color={THEME.textPrimary}>
{basicInfo.law_firm}
</Text>
</Box>
</VStack>
</Box>
</SimpleGrid>
<Divider borderColor={THEME.border} />
<Box>
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading>
<Text fontSize="sm" lineHeight="tall" color={THEME.textSecondary}>
{basicInfo.main_business}
</Text>
</Box>
<Box>
<Heading size="sm" mb={3} color={THEME.textPrimary}></Heading>
<Text fontSize="sm" lineHeight="tall" color={THEME.textSecondary}>
{basicInfo.business_scope}
</Text>
</Box>
</VStack>
);
};
export default BusinessInfoPanel;

View File

@@ -0,0 +1,76 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/DisclosureSchedulePanel.tsx
// 财报披露日程 Tab Panel
import React from "react";
import {
Box,
VStack,
Text,
Badge,
Card,
CardBody,
SimpleGrid,
} from "@chakra-ui/react";
import { useDisclosureData } from "../../hooks/useDisclosureData";
import { THEME } from "../config";
import { formatDate } from "../utils";
import LoadingState from "./LoadingState";
interface DisclosureSchedulePanelProps {
stockCode: string;
}
const DisclosureSchedulePanel: React.FC<DisclosureSchedulePanelProps> = ({ stockCode }) => {
const { disclosureSchedule, loading } = useDisclosureData(stockCode);
if (loading) {
return <LoadingState message="加载披露日程..." />;
}
if (disclosureSchedule.length === 0) {
return (
<Box textAlign="center" py={8}>
<Text color={THEME.textSecondary}></Text>
</Box>
);
}
return (
<VStack spacing={4} align="stretch">
<Box>
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
{disclosureSchedule.map((schedule: any, idx: number) => (
<Card
key={idx}
bg={schedule.is_disclosed ? "green.900" : "orange.900"}
border="1px solid"
borderColor={schedule.is_disclosed ? "green.600" : "orange.600"}
size="sm"
>
<CardBody p={3}>
<VStack spacing={1}>
<Badge colorScheme={schedule.is_disclosed ? "green" : "orange"}>
{schedule.report_name}
</Badge>
<Text fontSize="sm" fontWeight="bold" color={THEME.textPrimary}>
{schedule.is_disclosed ? "已披露" : "预计"}
</Text>
<Text fontSize="xs" color={THEME.textSecondary}>
{formatDate(
schedule.is_disclosed
? schedule.actual_date
: schedule.latest_scheduled_date
)}
</Text>
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
</Box>
</VStack>
);
};
export default DisclosureSchedulePanel;

View File

@@ -0,0 +1,32 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/LoadingState.tsx
// 复用的加载状态组件
import React from "react";
import { Center, VStack, Spinner, Text } from "@chakra-ui/react";
import { THEME } from "../config";
interface LoadingStateProps {
message?: string;
height?: string;
}
/**
* 加载状态组件(黑金主题)
*/
const LoadingState: React.FC<LoadingStateProps> = ({
message = "加载中...",
height = "200px",
}) => {
return (
<Center h={height}>
<VStack>
<Spinner size="lg" color={THEME.gold} thickness="3px" />
<Text fontSize="sm" color={THEME.textSecondary}>
{message}
</Text>
</VStack>
</Center>
);
};
export default LoadingState;

View File

@@ -0,0 +1,60 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/ShareholderPanel.tsx
// 股权结构 Tab Panel - 使用拆分后的子组件
import React from "react";
import { SimpleGrid, Box } from "@chakra-ui/react";
import { useShareholderData } from "../../hooks/useShareholderData";
import {
ActualControlCard,
ConcentrationCard,
ShareholdersTable,
} from "../../components/shareholder";
import TabPanelContainer from "@components/TabPanelContainer";
interface ShareholderPanelProps {
stockCode: string;
}
/**
* 股权结构面板
* 使用拆分后的子组件:
* - ActualControlCard: 实际控制人卡片
* - ConcentrationCard: 股权集中度卡片
* - ShareholdersTable: 股东表格(合并版,支持十大股东和十大流通股东)
*/
const ShareholderPanel: React.FC<ShareholderPanelProps> = ({ stockCode }) => {
const {
actualControl,
concentration,
topShareholders,
topCirculationShareholders,
loading,
} = useShareholderData(stockCode);
return (
<TabPanelContainer loading={loading} loadingMessage="加载股权结构数据...">
{/* 实际控制人 + 股权集中度 左右分布 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
<Box>
<ActualControlCard actualControl={actualControl} />
</Box>
<Box>
<ConcentrationCard concentration={concentration} />
</Box>
</SimpleGrid>
{/* 十大股东 + 十大流通股东 左右分布 */}
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={6}>
<Box>
<ShareholdersTable type="top" shareholders={topShareholders} />
</Box>
<Box>
<ShareholdersTable type="circulation" shareholders={topCirculationShareholders} />
</Box>
</SimpleGrid>
</TabPanelContainer>
);
};
export default ShareholderPanel;

View File

@@ -0,0 +1,11 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/index.ts
// 组件导出
export { default as LoadingState } from "./LoadingState";
// TabPanelContainer 已提升为通用组件,从 @components/TabPanelContainer 导入
export { default as TabPanelContainer } from "@components/TabPanelContainer";
export { default as ShareholderPanel } from "./ShareholderPanel";
export { ManagementPanel } from "./management";
export { default as AnnouncementsPanel } from "./AnnouncementsPanel";
export { default as BranchesPanel } from "./BranchesPanel";
export { default as BusinessInfoPanel } from "./BusinessInfoPanel";

View File

@@ -0,0 +1,63 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/CategorySection.tsx
// 管理层分类区块组件
import React, { memo } from "react";
import {
Box,
HStack,
Heading,
Badge,
Icon,
SimpleGrid,
} from "@chakra-ui/react";
import type { IconType } from "react-icons";
import { THEME } from "../../config";
import ManagementCard from "./ManagementCard";
import type { ManagementPerson, ManagementCategory } from "./types";
interface CategorySectionProps {
category: ManagementCategory;
people: ManagementPerson[];
icon: IconType;
color: string;
}
const CategorySection: React.FC<CategorySectionProps> = ({
category,
people,
icon,
color,
}) => {
if (people.length === 0) {
return null;
}
return (
<Box>
{/* 分类标题 */}
<HStack mb={4}>
<Icon as={icon} color={color} boxSize={5} />
<Heading size="sm" color={THEME.textPrimary}>
{category}
</Heading>
<Badge bg={THEME.gold} color="gray.900">
{people.length}
</Badge>
</HStack>
{/* 人员卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{people.map((person, idx) => (
<ManagementCard
key={`${person.name}-${idx}`}
person={person}
categoryColor={color}
/>
))}
</SimpleGrid>
</Box>
);
};
export default memo(CategorySection);

View File

@@ -0,0 +1,100 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementCard.tsx
// 管理人员卡片组件
import React, { memo } from "react";
import {
HStack,
VStack,
Text,
Icon,
Card,
CardBody,
Avatar,
Tag,
} from "@chakra-ui/react";
import {
FaVenusMars,
FaGraduationCap,
FaPassport,
} from "react-icons/fa";
import { THEME } from "../../config";
import { formatDate } from "../../utils";
import type { ManagementPerson } from "./types";
interface ManagementCardProps {
person: ManagementPerson;
categoryColor: string;
}
const ManagementCard: React.FC<ManagementCardProps> = ({ person, categoryColor }) => {
const currentYear = new Date().getFullYear();
const age = person.birth_year ? currentYear - parseInt(person.birth_year, 10) : null;
return (
<Card
bg={THEME.tableBg}
border="1px solid"
borderColor={THEME.border}
size="sm"
>
<CardBody>
<HStack spacing={3} align="start">
<Avatar
name={person.name}
size="md"
bg={categoryColor}
/>
<VStack align="start" spacing={1} flex={1}>
{/* 姓名和性别 */}
<HStack>
<Text fontWeight="bold" color={THEME.textPrimary}>
{person.name}
</Text>
{person.gender && (
<Icon
as={FaVenusMars}
color={person.gender === "男" ? "blue.400" : "pink.400"}
boxSize={3}
/>
)}
</HStack>
{/* 职位 */}
<Text fontSize="sm" color={THEME.goldLight}>
{person.position_name}
</Text>
{/* 标签:学历、年龄、国籍 */}
<HStack spacing={2} flexWrap="wrap">
{person.education && (
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
<Icon as={FaGraduationCap} mr={1} boxSize={3} />
{person.education}
</Tag>
)}
{age && (
<Tag size="sm" bg={THEME.tableHoverBg} color={THEME.textSecondary}>
{age}
</Tag>
)}
{person.nationality && person.nationality !== "中国" && (
<Tag size="sm" bg="orange.600" color="white">
<Icon as={FaPassport} mr={1} boxSize={3} />
{person.nationality}
</Tag>
)}
</HStack>
{/* 任职日期 */}
<Text fontSize="xs" color={THEME.textSecondary}>
{formatDate(person.start_date)}
</Text>
</VStack>
</HStack>
</CardBody>
</Card>
);
};
export default memo(ManagementCard);

View File

@@ -0,0 +1,100 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/ManagementPanel.tsx
// 管理团队 Tab Panel重构版
import React, { useMemo } from "react";
import {
FaUserTie,
FaCrown,
FaEye,
FaUsers,
} from "react-icons/fa";
import { useManagementData } from "../../../hooks/useManagementData";
import { THEME } from "../../config";
import TabPanelContainer from "@components/TabPanelContainer";
import CategorySection from "./CategorySection";
import type {
ManagementPerson,
ManagementCategory,
CategorizedManagement,
CategoryConfig,
} from "./types";
interface ManagementPanelProps {
stockCode: string;
}
/**
* 分类配置映射
*/
const CATEGORY_CONFIG: Record<ManagementCategory, CategoryConfig> = {
: { icon: FaUserTie, color: THEME.gold },
: { icon: FaCrown, color: THEME.goldLight },
: { icon: FaEye, color: "green.400" },
: { icon: FaUsers, color: THEME.textSecondary },
};
/**
* 分类顺序
*/
const CATEGORY_ORDER: ManagementCategory[] = ["高管", "董事", "监事", "其他"];
/**
* 根据职位信息对管理人员进行分类
*/
const categorizeManagement = (management: ManagementPerson[]): CategorizedManagement => {
const categories: CategorizedManagement = {
: [],
: [],
: [],
: [],
};
management.forEach((person) => {
const positionCategory = person.position_category;
const positionName = person.position_name || "";
if (positionCategory === "高管" || positionName.includes("总")) {
categories["高管"].push(person);
} else if (positionCategory === "董事" || positionName.includes("董事")) {
categories["董事"].push(person);
} else if (positionCategory === "监事" || positionName.includes("监事")) {
categories["监事"].push(person);
} else {
categories["其他"].push(person);
}
});
return categories;
};
const ManagementPanel: React.FC<ManagementPanelProps> = ({ stockCode }) => {
const { management, loading } = useManagementData(stockCode);
// 使用 useMemo 缓存分类计算结果
const categorizedManagement = useMemo(
() => categorizeManagement(management as ManagementPerson[]),
[management]
);
return (
<TabPanelContainer loading={loading} loadingMessage="加载管理团队数据...">
{CATEGORY_ORDER.map((category) => {
const config = CATEGORY_CONFIG[category];
const people = categorizedManagement[category];
return (
<CategorySection
key={category}
category={category}
people={people}
icon={config.icon}
color={config.color}
/>
);
})}
</TabPanelContainer>
);
};
export default ManagementPanel;

View File

@@ -0,0 +1,7 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/index.ts
// 管理团队组件导出
export { default as ManagementPanel } from "./ManagementPanel";
export { default as ManagementCard } from "./ManagementCard";
export { default as CategorySection } from "./CategorySection";
export * from "./types";

View File

@@ -0,0 +1,36 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/components/management/types.ts
// 管理团队相关类型定义
import type { IconType } from "react-icons";
/**
* 管理人员信息
*/
export interface ManagementPerson {
name: string;
position_name?: string;
position_category?: string;
gender?: "男" | "女";
education?: string;
birth_year?: string;
nationality?: string;
start_date?: string;
}
/**
* 管理层分类
*/
export type ManagementCategory = "高管" | "董事" | "监事" | "其他";
/**
* 分类后的管理层数据
*/
export type CategorizedManagement = Record<ManagementCategory, ManagementPerson[]>;
/**
* 分类配置项
*/
export interface CategoryConfig {
icon: IconType;
color: string;
}

View File

@@ -0,0 +1,96 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/config.ts
// Tab 配置 + 黑金主题配置
import { IconType } from "react-icons";
import {
FaShareAlt,
FaUserTie,
FaSitemap,
FaInfoCircle,
} from "react-icons/fa";
// 主题类型定义
export interface Theme {
bg: string;
cardBg: string;
tableBg: string;
tableHoverBg: string;
gold: string;
goldLight: string;
textPrimary: string;
textSecondary: string;
border: string;
tabSelected: {
bg: string;
color: string;
};
tabUnselected: {
color: string;
};
}
// 黑金主题配置
export const THEME: Theme = {
bg: "gray.900",
cardBg: "gray.800",
tableBg: "gray.700",
tableHoverBg: "gray.600",
gold: "#D4AF37",
goldLight: "#F0D78C",
textPrimary: "white",
textSecondary: "gray.400",
border: "rgba(212, 175, 55, 0.3)",
tabSelected: {
bg: "#D4AF37",
color: "gray.900",
},
tabUnselected: {
color: "#D4AF37",
},
};
// Tab 配置类型
export interface TabConfig {
key: string;
name: string;
icon: IconType;
enabled: boolean;
}
// Tab 配置
export const TAB_CONFIG: TabConfig[] = [
{
key: "shareholder",
name: "股权结构",
icon: FaShareAlt,
enabled: true,
},
{
key: "management",
name: "管理团队",
icon: FaUserTie,
enabled: true,
},
{
key: "branches",
name: "分支机构",
icon: FaSitemap,
enabled: true,
},
{
key: "business",
name: "工商信息",
icon: FaInfoCircle,
enabled: true,
},
];
// 获取启用的 Tab 列表
export const getEnabledTabs = (enabledKeys?: string[]): TabConfig[] => {
if (!enabledKeys || enabledKeys.length === 0) {
return TAB_CONFIG.filter((tab) => tab.enabled);
}
return TAB_CONFIG.filter(
(tab) => tab.enabled && enabledKeys.includes(tab.key)
);
};

View File

@@ -0,0 +1,87 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/index.tsx
// 基本信息 Tab 组件 - 使用 SubTabContainer 通用组件
import React, { useMemo } from "react";
import { Card, CardBody } from "@chakra-ui/react";
import SubTabContainer, { type SubTabConfig } from "@components/SubTabContainer";
import { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
import {
ShareholderPanel,
ManagementPanel,
AnnouncementsPanel,
BranchesPanel,
BusinessInfoPanel,
} from "./components";
// Props 类型定义
export interface BasicInfoTabProps {
stockCode: string;
// 可配置项
enabledTabs?: string[]; // 指定显示哪些 Tab通过 key
defaultTabIndex?: number; // 默认选中 Tab
onTabChange?: (index: number, tabKey: string) => void;
}
// Tab 组件映射
const TAB_COMPONENTS: Record<string, React.FC<any>> = {
shareholder: ShareholderPanel,
management: ManagementPanel,
announcements: AnnouncementsPanel,
branches: BranchesPanel,
business: BusinessInfoPanel,
};
/**
* 构建 SubTabContainer 所需的 tabs 配置
*/
const buildTabsConfig = (enabledKeys?: string[]): SubTabConfig[] => {
const enabledTabs = getEnabledTabs(enabledKeys);
return enabledTabs.map((tab) => ({
key: tab.key,
name: tab.name,
icon: tab.icon,
component: TAB_COMPONENTS[tab.key],
}));
};
/**
* 基本信息 Tab 组件
*
* 特性:
* - 使用 SubTabContainer 通用组件
* - 可配置显示哪些 TabenabledTabs
* - 黑金主题
* - 懒加载
* - 支持 Tab 变更回调
*/
const BasicInfoTab: React.FC<BasicInfoTabProps> = ({
stockCode,
enabledTabs,
defaultTabIndex = 0,
onTabChange,
}) => {
// 构建 tabs 配置(缓存避免重复计算)
const tabs = useMemo(() => buildTabsConfig(enabledTabs), [enabledTabs]);
return (
<Card bg={THEME.cardBg} shadow="md" border="1px solid" borderColor={THEME.border}>
<CardBody p={0}>
<SubTabContainer
tabs={tabs}
componentProps={{ stockCode }}
defaultIndex={defaultTabIndex}
onTabChange={onTabChange}
themePreset="blackGold"
/>
</CardBody>
</Card>
);
};
export default BasicInfoTab;
// 导出配置和工具,供外部使用
export { THEME, TAB_CONFIG, getEnabledTabs } from "./config";
export * from "./utils";

View File

@@ -0,0 +1,52 @@
// src/views/Company/components/CompanyOverview/BasicInfoTab/utils.ts
// 格式化工具函数
/**
* 格式化百分比
*/
export const formatPercentage = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
return `${(value * 100).toFixed(2)}%`;
};
/**
* 格式化数字(自动转换亿/万)
*/
export const formatNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}`;
}
return value.toLocaleString();
};
/**
* 格式化股数(自动转换亿股/万股)
*/
export const formatShares = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "-";
if (value >= 100000000) {
return `${(value / 100000000).toFixed(2)}亿股`;
} else if (value >= 10000) {
return `${(value / 10000).toFixed(2)}万股`;
}
return `${value.toLocaleString()}`;
};
/**
* 格式化日期(去掉时间部分)
*/
export const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
};
// 导出工具对象(兼容旧代码)
export const formatUtils = {
formatPercentage,
formatNumber,
formatShares,
formatDate,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
/**
* 业务结构树形项组件
*
* 递归显示业务结构层级
* 使用位置:业务结构分析卡片
* 黑金主题风格
*/
import React from 'react';
import { Box, HStack, VStack, Text, Badge, Tag, TagLabel } from '@chakra-ui/react';
import { formatPercentage, formatBusinessRevenue } from '@utils/priceFormatters';
import type { BusinessTreeItemProps } from '../types';
// 黑金主题配置
const THEME = {
bg: 'gray.700',
gold: '#D4AF37',
goldLight: '#F0D78C',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
border: 'rgba(212, 175, 55, 0.5)',
};
const BusinessTreeItem: React.FC<BusinessTreeItemProps> = ({ business, depth = 0 }) => {
// 获取营收显示
const getRevenueDisplay = (): string => {
const revenue = business.revenue || business.financial_metrics?.revenue;
const unit = business.revenue_unit;
if (revenue !== undefined && revenue !== null) {
return formatBusinessRevenue(revenue, unit);
}
return '-';
};
return (
<Box
ml={depth * 6}
p={3}
bg={THEME.bg}
borderLeft={depth > 0 ? '4px solid' : 'none'}
borderLeftColor={THEME.gold}
borderRadius="md"
mb={2}
_hover={{ shadow: 'md', bg: 'gray.600' }}
transition="all 0.2s"
>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<HStack>
<Text fontWeight="bold" fontSize={depth === 0 ? 'md' : 'sm'} color={THEME.textPrimary}>
{business.business_name}
</Text>
{business.financial_metrics?.revenue_ratio &&
business.financial_metrics.revenue_ratio > 30 && (
<Badge bg={THEME.gold} color="gray.900" size="sm">
</Badge>
)}
</HStack>
<HStack spacing={4} flexWrap="wrap">
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
: {formatPercentage(business.financial_metrics?.revenue_ratio)}
</Tag>
<Tag size="sm" bg="gray.600" color={THEME.textPrimary}>
: {formatPercentage(business.financial_metrics?.gross_margin)}
</Tag>
{business.growth_metrics?.revenue_growth !== undefined && (
<Tag
size="sm"
bg={business.growth_metrics.revenue_growth > 0 ? 'red.600' : 'green.600'}
color="white"
>
<TagLabel>
: {business.growth_metrics.revenue_growth > 0 ? '+' : ''}
{formatPercentage(business.growth_metrics.revenue_growth)}
</TagLabel>
</Tag>
)}
</HStack>
</VStack>
<VStack align="end" spacing={0}>
<Text fontSize="lg" fontWeight="bold" color={THEME.gold}>
{getRevenueDisplay()}
</Text>
<Text fontSize="xs" color={THEME.textSecondary}>
</Text>
</VStack>
</HStack>
</Box>
);
};
export default BusinessTreeItem;

View File

@@ -0,0 +1,24 @@
/**
* 免责声明组件
*
* 显示 AI 分析内容的免责声明提示
* 使用位置:深度分析各 Card 底部(共 6 处)
*/
import React from 'react';
import { Text } from '@chakra-ui/react';
const DisclaimerBox: React.FC = () => {
return (
<Text
mb={4}
color="gray.500"
fontSize="12px"
lineHeight="1.5"
>
AI模型基于新闻
</Text>
);
};
export default DisclaimerBox;

View File

@@ -0,0 +1,128 @@
/**
* 关键因素卡片组件
*
* 显示单个关键因素的详细信息
* 使用位置:关键因素 Accordion 内
* 黑金主题设计
*/
import React from 'react';
import {
Card,
CardBody,
VStack,
HStack,
Text,
Badge,
Tag,
Icon,
} from '@chakra-ui/react';
import { FaArrowUp, FaArrowDown } from 'react-icons/fa';
import type { KeyFactorCardProps, ImpactDirection } from '../types';
// 黑金主题样式常量
const THEME = {
cardBg: '#252D3A',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
} as const;
/**
* 获取影响方向对应的颜色
*/
const getImpactColor = (direction?: ImpactDirection): string => {
const colorMap: Record<ImpactDirection, string> = {
positive: 'red',
negative: 'green',
neutral: 'gray',
mixed: 'yellow',
};
return colorMap[direction || 'neutral'] || 'gray';
};
/**
* 获取影响方向的中文标签
*/
const getImpactLabel = (direction?: ImpactDirection): string => {
const labelMap: Record<ImpactDirection, string> = {
positive: '正面',
negative: '负面',
neutral: '中性',
mixed: '混合',
};
return labelMap[direction || 'neutral'] || '中性';
};
const KeyFactorCard: React.FC<KeyFactorCardProps> = ({ factor }) => {
const impactColor = getImpactColor(factor.impact_direction);
return (
<Card
bg={THEME.cardBg}
border="1px solid"
borderColor="whiteAlpha.100"
size="sm"
>
<CardBody p={3}>
<VStack align="stretch" spacing={2}>
<HStack justify="space-between">
<Text fontWeight="medium" fontSize="sm" color={THEME.textColor}>
{factor.factor_name}
</Text>
<Badge
bg="transparent"
border="1px solid"
borderColor={`${impactColor}.400`}
color={`${impactColor}.400`}
size="sm"
>
{getImpactLabel(factor.impact_direction)}
</Badge>
</HStack>
<HStack spacing={2}>
<Text fontSize="lg" fontWeight="bold" color={`${impactColor}.400`}>
{factor.factor_value}
{factor.factor_unit && ` ${factor.factor_unit}`}
</Text>
{factor.year_on_year !== undefined && (
<Tag
size="sm"
bg="transparent"
border="1px solid"
borderColor={factor.year_on_year > 0 ? 'red.400' : 'green.400'}
color={factor.year_on_year > 0 ? 'red.400' : 'green.400'}
>
<Icon
as={factor.year_on_year > 0 ? FaArrowUp : FaArrowDown}
mr={1}
boxSize={3}
/>
{Math.abs(factor.year_on_year)}%
</Tag>
)}
</HStack>
{factor.factor_desc && (
<Text fontSize="xs" color={THEME.subtextColor} noOfLines={2}>
{factor.factor_desc}
</Text>
)}
<HStack justify="space-between">
<Text fontSize="xs" color={THEME.subtextColor}>
: {factor.impact_weight}
</Text>
{factor.report_period && (
<Text fontSize="xs" color={THEME.subtextColor}>
{factor.report_period}
</Text>
)}
</HStack>
</VStack>
</CardBody>
</Card>
);
};
export default KeyFactorCard;

View File

@@ -0,0 +1,170 @@
/**
* 产业链流程式导航组件
*
* 显示上游 → 核心 → 下游的流程式导航
* 带图标箭头连接符
*/
import React, { memo } from 'react';
import { HStack, VStack, Box, Text, Icon, Badge } from '@chakra-ui/react';
import { FaArrowRight } from 'react-icons/fa';
// 黑金主题配置
const THEME = {
gold: '#D4AF37',
textSecondary: 'gray.400',
upstream: {
active: 'orange.500',
activeBg: 'orange.900',
inactive: 'white',
inactiveBg: 'gray.700',
},
core: {
active: 'blue.500',
activeBg: 'blue.900',
inactive: 'white',
inactiveBg: 'gray.700',
},
downstream: {
active: 'green.500',
activeBg: 'green.900',
inactive: 'white',
inactiveBg: 'gray.700',
},
};
export type TabType = 'upstream' | 'core' | 'downstream';
interface ProcessNavigationProps {
activeTab: TabType;
onTabChange: (tab: TabType) => void;
upstreamCount: number;
coreCount: number;
downstreamCount: number;
}
interface NavItemProps {
label: string;
subtitle: string;
count: number;
isActive: boolean;
colorKey: 'upstream' | 'core' | 'downstream';
onClick: () => void;
}
const NavItem: React.FC<NavItemProps> = memo(({
label,
subtitle,
count,
isActive,
colorKey,
onClick,
}) => {
const colors = THEME[colorKey];
return (
<Box
px={4}
py={2}
borderRadius="lg"
cursor="pointer"
bg={isActive ? colors.activeBg : colors.inactiveBg}
borderWidth={2}
borderColor={isActive ? colors.active : 'gray.600'}
onClick={onClick}
transition="all 0.2s"
_hover={{
borderColor: colors.active,
transform: 'translateY(-2px)',
}}
>
<VStack spacing={1} align="center">
<HStack spacing={2}>
<Text
fontWeight={isActive ? 'bold' : 'medium'}
color={isActive ? colors.active : colors.inactive}
fontSize="sm"
>
{label}
</Text>
<Badge
bg={isActive ? colors.active : 'gray.600'}
color="white"
borderRadius="full"
px={2}
fontSize="xs"
>
{count}
</Badge>
</HStack>
<Text
fontSize="xs"
color={THEME.textSecondary}
>
{subtitle}
</Text>
</VStack>
</Box>
);
});
NavItem.displayName = 'NavItem';
const ProcessNavigation: React.FC<ProcessNavigationProps> = memo(({
activeTab,
onTabChange,
upstreamCount,
coreCount,
downstreamCount,
}) => {
return (
<HStack
spacing={2}
flexWrap="wrap"
gap={2}
>
<NavItem
label="上游供应链"
subtitle="原材料与供应商"
count={upstreamCount}
isActive={activeTab === 'upstream'}
colorKey="upstream"
onClick={() => onTabChange('upstream')}
/>
<Icon
as={FaArrowRight}
color={THEME.textSecondary}
boxSize={4}
/>
<NavItem
label="核心企业"
subtitle="公司主体与产品"
count={coreCount}
isActive={activeTab === 'core'}
colorKey="core"
onClick={() => onTabChange('core')}
/>
<Icon
as={FaArrowRight}
color={THEME.textSecondary}
boxSize={4}
/>
<NavItem
label="下游客户"
subtitle="客户与终端市场"
count={downstreamCount}
isActive={activeTab === 'downstream'}
colorKey="downstream"
onClick={() => onTabChange('downstream')}
/>
</HStack>
);
});
ProcessNavigation.displayName = 'ProcessNavigation';
export default ProcessNavigation;

View File

@@ -0,0 +1,51 @@
/**
* 评分进度条组件
*
* 显示带图标的评分进度条
* 使用位置:竞争力分析区域(共 8 处)
*/
import React from 'react';
import { Box, HStack, Text, Badge, Progress, Icon } from '@chakra-ui/react';
import type { ScoreBarProps } from '../types';
/**
* 根据分数百分比获取颜色方案
*/
const getColorScheme = (percentage: number): string => {
if (percentage >= 80) return 'purple';
if (percentage >= 60) return 'blue';
if (percentage >= 40) return 'yellow';
return 'orange';
};
const ScoreBar: React.FC<ScoreBarProps> = ({ label, score, icon }) => {
const percentage = ((score || 0) / 100) * 100;
const colorScheme = getColorScheme(percentage);
return (
<Box>
<HStack justify="space-between" mb={1}>
<HStack>
{icon && (
<Icon as={icon} boxSize={4} color={`${colorScheme}.500`} />
)}
<Text fontSize="sm" fontWeight="medium">
{label}
</Text>
</HStack>
<Badge colorScheme={colorScheme}>{score || 0}</Badge>
</HStack>
<Progress
value={percentage}
size="sm"
colorScheme={colorScheme}
borderRadius="full"
hasStripe
isAnimated
/>
</Box>
);
};
export default ScoreBar;

View File

@@ -0,0 +1,151 @@
/**
* 产业链筛选栏组件
*
* 提供类型筛选、重要度筛选和视图切换功能
*/
import React, { memo } from 'react';
import {
HStack,
Select,
Tabs,
TabList,
Tab,
} from '@chakra-ui/react';
// 黑金主题配置
const THEME = {
gold: '#D4AF37',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
inputBg: 'gray.700',
inputBorder: 'gray.600',
};
export type ViewMode = 'hierarchy' | 'flow';
// 节点类型选项
const TYPE_OPTIONS = [
{ value: 'all', label: '全部类型' },
{ value: 'company', label: '公司' },
{ value: 'supplier', label: '供应商' },
{ value: 'customer', label: '客户' },
{ value: 'regulator', label: '监管机构' },
{ value: 'product', label: '产品' },
{ value: 'service', label: '服务' },
{ value: 'channel', label: '渠道' },
{ value: 'raw_material', label: '原材料' },
{ value: 'end_user', label: '终端用户' },
];
// 重要度选项
const IMPORTANCE_OPTIONS = [
{ value: 'all', label: '全部重要度' },
{ value: 'high', label: '高 (≥80)' },
{ value: 'medium', label: '中 (50-79)' },
{ value: 'low', label: '低 (<50)' },
];
interface ValueChainFilterBarProps {
typeFilter: string;
onTypeChange: (value: string) => void;
importanceFilter: string;
onImportanceChange: (value: string) => void;
viewMode: ViewMode;
onViewModeChange: (value: ViewMode) => void;
}
const ValueChainFilterBar: React.FC<ValueChainFilterBarProps> = memo(({
typeFilter,
onTypeChange,
importanceFilter,
onImportanceChange,
viewMode,
onViewModeChange,
}) => {
return (
<HStack
spacing={3}
flexWrap="wrap"
gap={3}
>
{/* 左侧筛选区 */}
{/* <HStack spacing={3}>
<Select
value={typeFilter}
onChange={(e) => onTypeChange(e.target.value)}
size="sm"
w="140px"
bg={THEME.inputBg}
borderColor={THEME.inputBorder}
color={THEME.textPrimary}
_hover={{ borderColor: THEME.gold }}
_focus={{ borderColor: THEME.gold, boxShadow: 'none' }}
>
{TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} style={{ background: '#2D3748' }}>
{opt.label}
</option>
))}
</Select>
<Select
value={importanceFilter}
onChange={(e) => onImportanceChange(e.target.value)}
size="sm"
w="140px"
bg={THEME.inputBg}
borderColor={THEME.inputBorder}
color={THEME.textPrimary}
_hover={{ borderColor: THEME.gold }}
_focus={{ borderColor: THEME.gold, boxShadow: 'none' }}
>
{IMPORTANCE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} style={{ background: '#2D3748' }}>
{opt.label}
</option>
))}
</Select>
</HStack> */}
{/* 右侧视图切换 */}
<Tabs
index={viewMode === 'hierarchy' ? 0 : 1}
onChange={(index) => onViewModeChange(index === 0 ? 'hierarchy' : 'flow')}
variant="soft-rounded"
size="sm"
>
<TabList>
<Tab
color={THEME.textSecondary}
_selected={{
bg: THEME.gold,
color: 'gray.900',
}}
_hover={{
bg: 'gray.600',
}}
>
</Tab>
<Tab
color={THEME.textSecondary}
_selected={{
bg: THEME.gold,
color: 'gray.900',
}}
_hover={{
bg: 'gray.600',
}}
>
</Tab>
</TabList>
</Tabs>
</HStack>
);
});
ValueChainFilterBar.displayName = 'ValueChainFilterBar';
export default ValueChainFilterBar;

View File

@@ -0,0 +1,14 @@
/**
* 原子组件导出
*
* DeepAnalysisTab 内部使用的基础 UI 组件
*/
export { default as DisclaimerBox } from './DisclaimerBox';
export { default as ScoreBar } from './ScoreBar';
export { default as BusinessTreeItem } from './BusinessTreeItem';
export { default as KeyFactorCard } from './KeyFactorCard';
export { default as ProcessNavigation } from './ProcessNavigation';
export { default as ValueChainFilterBar } from './ValueChainFilterBar';
export type { TabType } from './ProcessNavigation';
export type { ViewMode } from './ValueChainFilterBar';

View File

@@ -0,0 +1,169 @@
/**
* 业务板块详情卡片
*
* 显示公司各业务板块的详细信息
* 黑金主题风格
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Badge,
Box,
Icon,
SimpleGrid,
Button,
} from '@chakra-ui/react';
import { FaIndustry, FaExpandAlt, FaCompressAlt } from 'react-icons/fa';
import type { BusinessSegment } from '../types';
// 黑金主题配置
const THEME = {
cardBg: 'gray.800',
innerCardBg: 'gray.700',
gold: '#D4AF37',
goldLight: '#F0D78C',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
border: 'rgba(212, 175, 55, 0.3)',
};
interface BusinessSegmentsCardProps {
businessSegments: BusinessSegment[];
expandedSegments: Record<number, boolean>;
onToggleSegment: (index: number) => void;
cardBg?: string;
}
const BusinessSegmentsCard: React.FC<BusinessSegmentsCardProps> = ({
businessSegments,
expandedSegments,
onToggleSegment,
}) => {
if (!businessSegments || businessSegments.length === 0) return null;
return (
<Card bg={THEME.cardBg} shadow="md">
<CardHeader>
<HStack>
<Icon as={FaIndustry} color={THEME.gold} />
<Heading size="sm" color={THEME.textPrimary}></Heading>
<Badge bg={THEME.gold} color="gray.900">{businessSegments.length} </Badge>
</HStack>
</CardHeader>
<CardBody px={2}>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{businessSegments.map((segment, idx) => {
const isExpanded = expandedSegments[idx];
return (
<Card key={idx} bg={THEME.innerCardBg}>
<CardBody px={2}>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontWeight="bold" fontSize="md" color={THEME.textPrimary}>
{segment.segment_name}
</Text>
<Button
size="sm"
variant="ghost"
leftIcon={
<Icon as={isExpanded ? FaCompressAlt : FaExpandAlt} />
}
onClick={() => onToggleSegment(idx)}
color={THEME.gold}
_hover={{ bg: 'gray.600' }}
>
{isExpanded ? '折叠' : '展开'}
</Button>
</HStack>
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text
fontSize="sm"
color={THEME.textPrimary}
noOfLines={isExpanded ? undefined : 3}
>
{segment.segment_description || '暂无描述'}
</Text>
</Box>
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text
fontSize="sm"
color={THEME.textPrimary}
noOfLines={isExpanded ? undefined : 2}
>
{segment.competitive_position || '暂无数据'}
</Text>
</Box>
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text
fontSize="sm"
noOfLines={isExpanded ? undefined : 2}
color={THEME.goldLight}
>
{segment.future_potential || '暂无数据'}
</Text>
</Box>
{isExpanded && segment.key_products && (
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Text fontSize="sm" color="green.300">
{segment.key_products}
</Text>
</Box>
)}
{isExpanded && segment.market_share !== undefined && (
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Badge bg="purple.600" color="white" fontSize="sm">
{segment.market_share}%
</Badge>
</Box>
)}
{isExpanded && segment.revenue_contribution !== undefined && (
<Box>
<Text fontSize="xs" color={THEME.textSecondary} mb={1}>
</Text>
<Badge bg={THEME.gold} color="gray.900" fontSize="sm">
{segment.revenue_contribution}%
</Badge>
</Box>
)}
</VStack>
</CardBody>
</Card>
);
})}
</SimpleGrid>
</CardBody>
</Card>
);
};
export default BusinessSegmentsCard;

View File

@@ -0,0 +1,65 @@
/**
* 业务结构分析卡片
*
* 显示公司业务结构树形图
* 黑金主题风格
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Heading,
Badge,
Icon,
} from '@chakra-ui/react';
import { FaChartPie } from 'react-icons/fa';
import { BusinessTreeItem } from '../atoms';
import type { BusinessStructure } from '../types';
// 黑金主题配置
const THEME = {
cardBg: 'gray.800',
gold: '#D4AF37',
textPrimary: '#D4AF37',
border: 'rgba(212, 175, 55, 0.3)',
};
interface BusinessStructureCardProps {
businessStructure: BusinessStructure[];
cardBg?: string;
}
const BusinessStructureCard: React.FC<BusinessStructureCardProps> = ({
businessStructure,
}) => {
if (!businessStructure || businessStructure.length === 0) return null;
return (
<Card bg={THEME.cardBg} shadow="md">
<CardHeader>
<HStack>
<Icon as={FaChartPie} color={THEME.gold} />
<Heading size="sm" color={THEME.textPrimary}></Heading>
<Badge bg={THEME.gold} color="gray.900">{businessStructure[0]?.report_period}</Badge>
</HStack>
</CardHeader>
<CardBody px={0}>
<VStack spacing={3} align="stretch">
{businessStructure.map((business, idx) => (
<BusinessTreeItem
key={idx}
business={business}
depth={business.business_level - 1}
/>
))}
</VStack>
</CardBody>
</Card>
);
};
export default BusinessStructureCard;

View File

@@ -0,0 +1,295 @@
/**
* 竞争地位分析卡片
*
* 显示竞争力评分、雷达图和竞争分析
* 包含行业排名弹窗功能
*/
import React, { memo, useMemo } from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Badge,
Tag,
TagLabel,
Grid,
GridItem,
Box,
Icon,
Divider,
SimpleGrid,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
} from '@chakra-ui/react';
import {
FaTrophy,
FaCog,
FaStar,
FaChartLine,
FaDollarSign,
FaFlask,
FaShieldAlt,
FaRocket,
FaUsers,
FaExternalLinkAlt,
} from 'react-icons/fa';
import ReactECharts from 'echarts-for-react';
import { ScoreBar } from '../atoms';
import { getRadarChartOption } from '../utils/chartOptions';
import { IndustryRankingView } from '../../../FinancialPanorama/components';
import type { ComprehensiveData, CompetitivePosition, IndustryRankData } from '../types';
// 黑金主题弹窗样式
const MODAL_STYLES = {
content: {
bg: 'gray.900',
borderColor: 'rgba(212, 175, 55, 0.3)',
borderWidth: '1px',
maxW: '900px',
},
header: {
color: 'yellow.500',
borderBottomColor: 'rgba(212, 175, 55, 0.2)',
borderBottomWidth: '1px',
},
closeButton: {
color: 'yellow.500',
_hover: { bg: 'rgba(212, 175, 55, 0.1)' },
},
} as const;
// 样式常量 - 避免每次渲染创建新对象
const CARD_STYLES = {
bg: 'transparent',
shadow: 'md',
} as const;
const CONTENT_BOX_STYLES = {
p: 4,
border: '1px solid',
borderColor: 'yellow.600',
borderRadius: 'md',
} as const;
const GRID_COLSPAN = { base: 2, lg: 1 } as const;
const CHART_STYLE = { height: '320px' } as const;
interface CompetitiveAnalysisCardProps {
comprehensiveData: ComprehensiveData;
industryRankData?: IndustryRankData[];
}
// 竞争对手标签组件
interface CompetitorTagsProps {
competitors: string[];
}
const CompetitorTags = memo<CompetitorTagsProps>(({ competitors }) => (
<Box mb={4}>
<Text fontWeight="bold" fontSize="sm" mb={2} color="yellow.500">
</Text>
<HStack spacing={2} flexWrap="wrap">
{competitors.map((competitor, idx) => (
<Tag
key={idx}
size="md"
variant="outline"
borderColor="yellow.600"
color="yellow.500"
borderRadius="full"
>
<Icon as={FaUsers} mr={1} />
<TagLabel>{competitor}</TagLabel>
</Tag>
))}
</HStack>
</Box>
));
CompetitorTags.displayName = 'CompetitorTags';
// 评分区域组件
interface ScoreSectionProps {
scores: CompetitivePosition['scores'];
}
const ScoreSection = memo<ScoreSectionProps>(({ scores }) => (
<VStack spacing={4} align="stretch">
<ScoreBar label="市场地位" score={scores?.market_position} icon={FaTrophy} />
<ScoreBar label="技术实力" score={scores?.technology} icon={FaCog} />
<ScoreBar label="品牌价值" score={scores?.brand} icon={FaStar} />
<ScoreBar label="运营效率" score={scores?.operation} icon={FaChartLine} />
<ScoreBar label="财务健康" score={scores?.finance} icon={FaDollarSign} />
<ScoreBar label="创新能力" score={scores?.innovation} icon={FaFlask} />
<ScoreBar label="风险控制" score={scores?.risk} icon={FaShieldAlt} />
<ScoreBar label="成长潜力" score={scores?.growth} icon={FaRocket} />
</VStack>
));
ScoreSection.displayName = 'ScoreSection';
// 竞争优劣势组件
interface AdvantagesSectionProps {
advantages?: string;
disadvantages?: string;
}
const AdvantagesSection = memo<AdvantagesSectionProps>(
({ advantages, disadvantages }) => (
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<Box {...CONTENT_BOX_STYLES}>
<Text fontWeight="bold" fontSize="sm" mb={2} color="green.400">
</Text>
<Text fontSize="sm" color="white">
{advantages || '暂无数据'}
</Text>
</Box>
<Box {...CONTENT_BOX_STYLES}>
<Text fontWeight="bold" fontSize="sm" mb={2} color="red.400">
</Text>
<Text fontSize="sm" color="white">
{disadvantages || '暂无数据'}
</Text>
</Box>
</SimpleGrid>
)
);
AdvantagesSection.displayName = 'AdvantagesSection';
const CompetitiveAnalysisCard: React.FC<CompetitiveAnalysisCardProps> = memo(
({ comprehensiveData, industryRankData }) => {
const competitivePosition = comprehensiveData.competitive_position;
const { isOpen, onOpen, onClose } = useDisclosure();
if (!competitivePosition) return null;
// 缓存雷达图配置
const radarOption = useMemo(
() => getRadarChartOption(comprehensiveData),
[comprehensiveData]
);
// 缓存竞争对手列表
const competitors = useMemo(
() =>
competitivePosition.analysis?.main_competitors
?.split(',')
.map((c) => c.trim()) || [],
[competitivePosition.analysis?.main_competitors]
);
// 判断是否有行业排名数据可展示
const hasIndustryRankData = industryRankData && industryRankData.length > 0;
return (
<>
<Card {...CARD_STYLES}>
<CardHeader>
<HStack>
<Icon as={FaTrophy} color="yellow.500" />
<Heading size="sm" color="yellow.500"></Heading>
{competitivePosition.ranking && (
<Badge
ml={2}
bg="transparent"
border="1px solid"
borderColor="yellow.600"
color="yellow.500"
cursor={hasIndustryRankData ? 'pointer' : 'default'}
onClick={hasIndustryRankData ? onOpen : undefined}
_hover={hasIndustryRankData ? { bg: 'rgba(212, 175, 55, 0.1)' } : undefined}
>
{competitivePosition.ranking.industry_rank}/
{competitivePosition.ranking.total_companies}
</Badge>
)}
{hasIndustryRankData && (
<Button
size="xs"
variant="ghost"
color="yellow.500"
rightIcon={<Icon as={FaExternalLinkAlt} boxSize={3} />}
onClick={onOpen}
_hover={{ bg: 'rgba(212, 175, 55, 0.1)' }}
>
</Button>
)}
</HStack>
</CardHeader>
<CardBody>
{/* 主要竞争对手 */}
{competitors.length > 0 && <CompetitorTags competitors={competitors} />}
{/* 评分和雷达图 */}
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
<GridItem colSpan={GRID_COLSPAN}>
<ScoreSection scores={competitivePosition.scores} />
</GridItem>
<GridItem colSpan={GRID_COLSPAN}>
{radarOption && (
<ReactECharts
option={radarOption}
style={CHART_STYLE}
theme="dark"
/>
)}
</GridItem>
</Grid>
<Divider my={4} borderColor="yellow.600" />
{/* 竞争优势和劣势 */}
<AdvantagesSection
advantages={competitivePosition.analysis?.competitive_advantages}
disadvantages={competitivePosition.analysis?.competitive_disadvantages}
/>
</CardBody>
</Card>
{/* 行业排名弹窗 - 黑金主题 */}
<Modal isOpen={isOpen} onClose={onClose} size="4xl" scrollBehavior="inside">
<ModalOverlay bg="blackAlpha.700" />
<ModalContent {...MODAL_STYLES.content}>
<ModalHeader {...MODAL_STYLES.header}>
<HStack>
<Icon as={FaTrophy} color="yellow.500" />
<Text></Text>
</HStack>
</ModalHeader>
<ModalCloseButton {...MODAL_STYLES.closeButton} />
<ModalBody py={4}>
{hasIndustryRankData && (
<IndustryRankingView
industryRank={industryRankData}
bgColor="gray.800"
borderColor="rgba(212, 175, 55, 0.3)"
/>
)}
</ModalBody>
</ModalContent>
</Modal>
</>
);
}
);
CompetitiveAnalysisCard.displayName = 'CompetitiveAnalysisCard';
export default CompetitiveAnalysisCard;

View File

@@ -0,0 +1,54 @@
/**
* 投资亮点卡片组件
*/
import React, { memo } from 'react';
import { Box, HStack, VStack, Icon, Text } from '@chakra-ui/react';
import { FaUsers } from 'react-icons/fa';
import { THEME, ICON_MAP, HIGHLIGHT_HOVER_STYLES } from '../theme';
import type { InvestmentHighlightItem } from '../../../types';
interface HighlightCardProps {
highlight: InvestmentHighlightItem;
}
export const HighlightCard = memo<HighlightCardProps>(({ highlight }) => {
const IconComponent = ICON_MAP[highlight.icon] || FaUsers;
return (
<Box
p={4}
bg={THEME.light.cardBg}
borderRadius="lg"
border="1px solid"
borderColor="whiteAlpha.100"
{...HIGHLIGHT_HOVER_STYLES}
transition="border-color 0.2s"
>
<HStack spacing={3} align="flex-start">
<Box
p={2}
bg="whiteAlpha.100"
borderRadius="md"
color={THEME.light.titleColor}
>
<Icon as={IconComponent} boxSize={4} />
</Box>
<VStack align="start" spacing={1} flex={1}>
<Text fontWeight="bold" color={THEME.light.textColor} fontSize="sm">
{highlight.title}
</Text>
<Text
fontSize="xs"
color={THEME.light.subtextColor}
lineHeight="tall"
>
{highlight.description}
</Text>
</VStack>
</HStack>
</Box>
);
});
HighlightCard.displayName = 'HighlightCard';

View File

@@ -0,0 +1,47 @@
/**
* 商业模式板块组件
*/
import React, { memo } from 'react';
import { Box, VStack, HStack, Text, Tag, Divider } from '@chakra-ui/react';
import { THEME } from '../theme';
import type { BusinessModelSection } from '../../../types';
interface ModelBlockProps {
section: BusinessModelSection;
isLast?: boolean;
}
export const ModelBlock = memo<ModelBlockProps>(({ section, isLast }) => (
<Box>
<VStack align="start" spacing={2}>
<Text fontWeight="bold" color={THEME.light.textColor} fontSize="sm">
{section.title}
</Text>
<Text fontSize="xs" color={THEME.light.subtextColor} lineHeight="tall">
{section.description}
</Text>
{section.tags && section.tags.length > 0 && (
<HStack spacing={2} flexWrap="wrap" mt={1}>
{section.tags.map((tag, idx) => (
<Tag
key={idx}
size="sm"
bg={THEME.light.tagBg}
color={THEME.light.tagColor}
borderRadius="full"
px={3}
py={1}
fontSize="xs"
>
{tag}
</Tag>
))}
</HStack>
)}
</VStack>
{!isLast && <Divider my={4} borderColor="whiteAlpha.100" />}
</Box>
));
ModelBlock.displayName = 'ModelBlock';

View File

@@ -0,0 +1,27 @@
/**
* 区域标题组件
*/
import React, { memo } from 'react';
import { HStack, Icon, Text } from '@chakra-ui/react';
import type { IconType } from 'react-icons';
import { THEME } from '../theme';
interface SectionHeaderProps {
icon: IconType;
title: string;
color?: string;
}
export const SectionHeader = memo<SectionHeaderProps>(
({ icon, title, color = THEME.dark.titleColor }) => (
<HStack spacing={2} mb={4}>
<Icon as={icon} color={color} boxSize={4} />
<Text fontWeight="bold" color={color} fontSize="md">
{title}
</Text>
</HStack>
)
);
SectionHeader.displayName = 'SectionHeader';

View File

@@ -0,0 +1,7 @@
/**
* CorePositioningCard 原子组件统一导出
*/
export { SectionHeader } from './SectionHeader';
export { HighlightCard } from './HighlightCard';
export { ModelBlock } from './ModelBlock';

View File

@@ -0,0 +1,204 @@
/**
* 核心定位卡片
*
* 显示公司的核心定位、投资亮点和商业模式
* 黑金主题设计
*/
import React, { memo, useMemo } from 'react';
import {
Card,
CardBody,
VStack,
Text,
Box,
Grid,
GridItem,
} from '@chakra-ui/react';
import { FaCrown, FaStar, FaBriefcase } from 'react-icons/fa';
import type {
QualitativeAnalysis,
InvestmentHighlightItem,
} from '../../types';
import {
THEME,
CARD_STYLES,
GRID_COLUMNS,
BORDER_RIGHT_RESPONSIVE,
} from './theme';
import { SectionHeader, HighlightCard, ModelBlock } from './atoms';
// ==================== 主组件 ====================
interface CorePositioningCardProps {
qualitativeAnalysis: QualitativeAnalysis;
cardBg?: string;
}
const CorePositioningCard: React.FC<CorePositioningCardProps> = memo(
({ qualitativeAnalysis }) => {
const corePositioning = qualitativeAnalysis.core_positioning;
// 判断是否有结构化数据
const hasStructuredData = useMemo(
() =>
!!(
corePositioning?.features?.length ||
(Array.isArray(corePositioning?.investment_highlights) &&
corePositioning.investment_highlights.length > 0) ||
corePositioning?.business_model_sections?.length
),
[corePositioning]
);
// 如果没有结构化数据,使用旧的文本格式渲染
if (!hasStructuredData) {
return (
<Card {...CARD_STYLES}>
<CardBody>
<VStack spacing={4} align="stretch">
<SectionHeader icon={FaCrown} title="核心定位" />
{corePositioning?.one_line_intro && (
<Box
p={4}
bg={THEME.dark.cardBg}
borderRadius="lg"
borderLeft="4px solid"
borderColor={THEME.dark.border}
>
<Text color={THEME.dark.textColor} fontWeight="medium">
{corePositioning.one_line_intro}
</Text>
</Box>
)}
<Grid templateColumns={GRID_COLUMNS.twoColumnMd} gap={4}>
<GridItem>
<Box p={4} bg={THEME.light.cardBg} borderRadius="lg">
<SectionHeader icon={FaStar} title="投资亮点" />
<Text
fontSize="sm"
color={THEME.light.subtextColor}
whiteSpace="pre-wrap"
>
{corePositioning?.investment_highlights_text ||
(typeof corePositioning?.investment_highlights === 'string'
? corePositioning.investment_highlights
: '暂无数据')}
</Text>
</Box>
</GridItem>
<GridItem>
<Box p={4} bg={THEME.light.cardBg} borderRadius="lg">
<SectionHeader icon={FaBriefcase} title="商业模式" />
<Text
fontSize="sm"
color={THEME.light.subtextColor}
whiteSpace="pre-wrap"
>
{corePositioning?.business_model_desc || '暂无数据'}
</Text>
</Box>
</GridItem>
</Grid>
</VStack>
</CardBody>
</Card>
);
}
// 结构化数据渲染 - 缓存数组计算
const highlights = useMemo(
() =>
(Array.isArray(corePositioning?.investment_highlights)
? corePositioning.investment_highlights
: []) as InvestmentHighlightItem[],
[corePositioning?.investment_highlights]
);
const businessSections = useMemo(
() => corePositioning?.business_model_sections || [],
[corePositioning?.business_model_sections]
);
return (
<Card {...CARD_STYLES}>
<CardBody p={0}>
<VStack spacing={0} align="stretch">
{/* 核心定位区域(深色背景) */}
<Box p={6} bg={THEME.dark.bg}>
<SectionHeader icon={FaCrown} title="核心定位" />
{/* 一句话介绍 */}
{corePositioning?.one_line_intro && (
<Box
p={4}
bg={THEME.dark.cardBg}
borderRadius="lg"
borderLeft="4px solid"
borderColor={THEME.dark.border}
>
<Text color={THEME.dark.textColor} fontWeight="medium">
{corePositioning.one_line_intro}
</Text>
</Box>
)}
</Box>
{/* 投资亮点 + 商业模式区域 */}
<Grid templateColumns={GRID_COLUMNS.twoColumn} bg={THEME.light.bg}>
{/* 投资亮点区域 */}
<GridItem
p={6}
borderRight={BORDER_RIGHT_RESPONSIVE}
borderColor="whiteAlpha.100"
>
<SectionHeader icon={FaStar} title="投资亮点" />
<VStack spacing={3} align="stretch">
{highlights.length > 0 ? (
highlights.map((highlight, idx) => (
<HighlightCard key={idx} highlight={highlight} />
))
) : (
<Text fontSize="sm" color={THEME.light.subtextColor}>
</Text>
)}
</VStack>
</GridItem>
{/* 商业模式区域 */}
<GridItem p={6}>
<SectionHeader icon={FaBriefcase} title="商业模式" />
<Box
p={4}
bg={THEME.light.cardBg}
borderRadius="lg"
border="1px solid"
borderColor="whiteAlpha.100"
>
{businessSections.length > 0 ? (
businessSections.map((section, idx) => (
<ModelBlock
key={idx}
section={section}
isLast={idx === businessSections.length - 1}
/>
))
) : (
<Text fontSize="sm" color={THEME.light.subtextColor}>
</Text>
)}
</Box>
</GridItem>
</Grid>
</VStack>
</CardBody>
</Card>
);
}
);
CorePositioningCard.displayName = 'CorePositioningCard';
export default CorePositioningCard;

View File

@@ -0,0 +1,83 @@
/**
* CorePositioningCard 主题和样式常量
*/
import {
FaUniversity,
FaFire,
FaUsers,
FaChartLine,
FaMicrochip,
FaShieldAlt,
} from 'react-icons/fa';
import type { IconType } from 'react-icons';
// ==================== 主题常量 ====================
export const THEME = {
// 深色背景区域(核心定位)
dark: {
bg: '#1A202C',
cardBg: '#252D3A',
border: '#C9A961',
borderGradient: 'linear-gradient(90deg, #C9A961, #8B7355)',
titleColor: '#C9A961',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
},
// 浅色背景区域(投资亮点/商业模式)
light: {
bg: '#1E2530',
cardBg: '#252D3A',
titleColor: '#C9A961',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
tagBg: 'rgba(201, 169, 97, 0.15)',
tagColor: '#C9A961',
},
} as const;
// ==================== 图标映射 ====================
export const ICON_MAP: Record<string, IconType> = {
bank: FaUniversity,
fire: FaFire,
users: FaUsers,
'trending-up': FaChartLine,
cpu: FaMicrochip,
'shield-check': FaShieldAlt,
};
// ==================== 样式常量 ====================
// 卡片通用样式(含顶部金色边框)
export const CARD_STYLES = {
bg: THEME.dark.bg,
shadow: 'lg',
border: '1px solid',
borderColor: 'whiteAlpha.100',
overflow: 'hidden',
position: 'relative',
_before: {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: THEME.dark.borderGradient,
},
} as const;
// HighlightCard hover 样式
export const HIGHLIGHT_HOVER_STYLES = {
_hover: { borderColor: 'whiteAlpha.200' },
} as const;
// 响应式布局常量
export const GRID_COLUMNS = {
twoColumn: { base: '1fr', lg: 'repeat(2, 1fr)' },
twoColumnMd: { base: '1fr', md: 'repeat(2, 1fr)' },
} as const;
export const BORDER_RIGHT_RESPONSIVE = { lg: '1px solid' } as const;

View File

@@ -0,0 +1,124 @@
/**
* 关键因素卡片
*
* 显示影响公司的关键因素列表
* 黑金主题设计
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Badge,
Box,
Icon,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
} from '@chakra-ui/react';
import { FaBalanceScale } from 'react-icons/fa';
import { KeyFactorCard } from '../atoms';
import type { KeyFactors } from '../types';
// 黑金主题样式常量
const THEME = {
bg: '#1A202C',
cardBg: '#252D3A',
border: '#C9A961',
borderGradient: 'linear-gradient(90deg, #C9A961, #8B7355)',
titleColor: '#C9A961',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
} as const;
const CARD_STYLES = {
bg: THEME.bg,
shadow: 'lg',
border: '1px solid',
borderColor: 'whiteAlpha.100',
overflow: 'hidden',
position: 'relative',
_before: {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: THEME.borderGradient,
},
} as const;
interface KeyFactorsCardProps {
keyFactors: KeyFactors;
cardBg?: string;
}
const KeyFactorsCard: React.FC<KeyFactorsCardProps> = ({ keyFactors }) => {
return (
<Card {...CARD_STYLES} h="full">
<CardHeader>
<HStack>
<Icon as={FaBalanceScale} color="yellow.500" />
<Heading size="sm" color={THEME.titleColor}>
</Heading>
<Badge
bg="transparent"
border="1px solid"
borderColor="yellow.600"
color="yellow.500"
>
{keyFactors.total_factors}
</Badge>
</HStack>
</CardHeader>
<CardBody>
<Accordion allowMultiple>
{keyFactors.categories.map((category, idx) => (
<AccordionItem key={idx} border="none">
<AccordionButton
bg={THEME.cardBg}
borderRadius="md"
mb={2}
_hover={{ bg: 'whiteAlpha.100' }}
>
<Box flex="1" textAlign="left">
<HStack>
<Text fontWeight="medium" color={THEME.textColor}>
{category.category_name}
</Text>
<Badge
bg="whiteAlpha.100"
color={THEME.subtextColor}
size="sm"
>
{category.factors.length}
</Badge>
</HStack>
</Box>
<AccordionIcon color={THEME.subtextColor} />
</AccordionButton>
<AccordionPanel pb={4}>
<VStack spacing={3} align="stretch">
{category.factors.map((factor, fidx) => (
<KeyFactorCard key={fidx} factor={factor} />
))}
</VStack>
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</CardBody>
</Card>
);
};
export default KeyFactorsCard;

View File

@@ -0,0 +1,133 @@
/**
* 战略分析卡片
*
* 显示公司战略方向和战略举措
*/
import React, { memo, useMemo } from 'react';
import {
Card,
CardBody,
CardHeader,
VStack,
HStack,
Text,
Heading,
Box,
Icon,
Grid,
GridItem,
Center,
} from '@chakra-ui/react';
import { FaRocket, FaChartBar } from 'react-icons/fa';
import type { Strategy } from '../types';
// 样式常量 - 避免每次渲染创建新对象
const CARD_STYLES = {
bg: 'transparent',
shadow: 'md',
} as const;
const CONTENT_BOX_STYLES = {
p: 4,
border: '1px solid',
borderColor: 'yellow.600',
borderRadius: 'md',
} as const;
const EMPTY_BOX_STYLES = {
border: '1px dashed',
borderColor: 'yellow.600',
borderRadius: 'md',
py: 12,
} as const;
const GRID_RESPONSIVE_COLSPAN = { base: 2, md: 1 } as const;
interface StrategyAnalysisCardProps {
strategy: Strategy;
cardBg?: string;
}
// 空状态组件 - 独立 memo 避免重复渲染
const EmptyState = memo(() => (
<Box {...EMPTY_BOX_STYLES}>
<Center>
<VStack spacing={3}>
<Icon as={FaChartBar} boxSize={10} color="yellow.600" />
<Text fontWeight="medium"></Text>
<Text fontSize="sm" color="gray.500">
</Text>
</VStack>
</Center>
</Box>
));
EmptyState.displayName = 'StrategyEmptyState';
// 内容项组件 - 复用结构
interface ContentItemProps {
title: string;
content: string;
}
const ContentItem = memo<ContentItemProps>(({ title, content }) => (
<VStack align="stretch" spacing={2}>
<Text fontWeight="bold" fontSize="sm" color="yellow.500">
{title}
</Text>
<Text fontSize="sm" color="white">
{content}
</Text>
</VStack>
));
ContentItem.displayName = 'StrategyContentItem';
const StrategyAnalysisCard: React.FC<StrategyAnalysisCardProps> = memo(
({ strategy }) => {
// 缓存数据检测结果
const hasData = useMemo(
() => !!(strategy?.strategy_description || strategy?.strategic_initiatives),
[strategy?.strategy_description, strategy?.strategic_initiatives]
);
return (
<Card {...CARD_STYLES}>
<CardHeader>
<HStack>
<Icon as={FaRocket} color="yellow.500" />
<Heading size="sm" color="yellow.500"></Heading>
</HStack>
</CardHeader>
<CardBody>
{!hasData ? (
<EmptyState />
) : (
<Box {...CONTENT_BOX_STYLES}>
<Grid templateColumns="repeat(2, 1fr)" gap={6}>
<GridItem colSpan={GRID_RESPONSIVE_COLSPAN}>
<ContentItem
title="战略方向"
content={strategy.strategy_description || '暂无数据'}
/>
</GridItem>
<GridItem colSpan={GRID_RESPONSIVE_COLSPAN}>
<ContentItem
title="战略举措"
content={strategy.strategic_initiatives || '暂无数据'}
/>
</GridItem>
</Grid>
</Box>
)}
</CardBody>
</Card>
);
}
);
StrategyAnalysisCard.displayName = 'StrategyAnalysisCard';
export default StrategyAnalysisCard;

View File

@@ -0,0 +1,95 @@
/**
* 发展时间线卡片
*
* 显示公司发展历程时间线
* 黑金主题设计
*/
import React from 'react';
import {
Card,
CardBody,
CardHeader,
HStack,
Heading,
Badge,
Box,
Icon,
} from '@chakra-ui/react';
import { FaHistory } from 'react-icons/fa';
import TimelineComponent from '../organisms/TimelineComponent';
import type { DevelopmentTimeline } from '../types';
// 黑金主题样式常量
const THEME = {
bg: '#1A202C',
cardBg: '#252D3A',
border: '#C9A961',
borderGradient: 'linear-gradient(90deg, #C9A961, #8B7355)',
titleColor: '#C9A961',
textColor: '#E2E8F0',
subtextColor: '#A0AEC0',
} as const;
const CARD_STYLES = {
bg: THEME.bg,
shadow: 'lg',
border: '1px solid',
borderColor: 'whiteAlpha.100',
overflow: 'hidden',
position: 'relative',
_before: {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: THEME.borderGradient,
},
} as const;
interface TimelineCardProps {
developmentTimeline: DevelopmentTimeline;
cardBg?: string;
}
const TimelineCard: React.FC<TimelineCardProps> = ({ developmentTimeline }) => {
return (
<Card {...CARD_STYLES} h="full">
<CardHeader>
<HStack>
<Icon as={FaHistory} color="yellow.500" />
<Heading size="sm" color={THEME.titleColor}>
线
</Heading>
<HStack spacing={1}>
<Badge
bg="transparent"
border="1px solid"
borderColor="red.400"
color="red.400"
>
{developmentTimeline.statistics?.positive_events || 0}
</Badge>
<Badge
bg="transparent"
border="1px solid"
borderColor="green.400"
color="green.400"
>
{developmentTimeline.statistics?.negative_events || 0}
</Badge>
</HStack>
</HStack>
</CardHeader>
<CardBody>
<Box maxH="600px" overflowY="auto" pr={2}>
<TimelineComponent events={developmentTimeline.events} />
</Box>
</CardBody>
</Card>
);
};
export default TimelineCard;

View File

@@ -0,0 +1,220 @@
/**
* 产业链分析卡片
*
* 显示产业链层级视图和流向关系
* 黑金主题风格 + 流程式导航
*/
import React, { useState, useMemo, memo } from 'react';
import {
Card,
CardBody,
CardHeader,
HStack,
Text,
Heading,
Badge,
Icon,
SimpleGrid,
Center,
Box,
Flex,
} from '@chakra-ui/react';
import { FaNetworkWired } from 'react-icons/fa';
import ReactECharts from 'echarts-for-react';
import {
ProcessNavigation,
ValueChainFilterBar,
} from '../atoms';
import type { TabType, ViewMode } from '../atoms';
import ValueChainNodeCard from '../organisms/ValueChainNodeCard';
import { getSankeyChartOption } from '../utils/chartOptions';
import type { ValueChainData, ValueChainNode } from '../types';
// 黑金主题配置
const THEME = {
cardBg: 'gray.800',
gold: '#D4AF37',
goldLight: '#F0D78C',
textPrimary: '#D4AF37',
textSecondary: 'gray.400',
};
interface ValueChainCardProps {
valueChainData: ValueChainData;
companyName?: string;
cardBg?: string;
}
const ValueChainCard: React.FC<ValueChainCardProps> = memo(({
valueChainData,
companyName = '目标公司',
}) => {
// 状态管理
const [activeTab, setActiveTab] = useState<TabType>('upstream');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [importanceFilter, setImportanceFilter] = useState<string>('all');
const [viewMode, setViewMode] = useState<ViewMode>('hierarchy');
// 解析节点数据
const nodesByLevel = valueChainData.value_chain_structure?.nodes_by_level;
// 获取上游节点
const upstreamNodes = useMemo(() => [
...(nodesByLevel?.['level_-2'] || []),
...(nodesByLevel?.['level_-1'] || []),
], [nodesByLevel]);
// 获取核心节点
const coreNodes = useMemo(() =>
nodesByLevel?.['level_0'] || [],
[nodesByLevel]);
// 获取下游节点
const downstreamNodes = useMemo(() => [
...(nodesByLevel?.['level_1'] || []),
...(nodesByLevel?.['level_2'] || []),
], [nodesByLevel]);
// 计算总节点数
const totalNodes = valueChainData.analysis_summary?.total_nodes ||
(upstreamNodes.length + coreNodes.length + downstreamNodes.length);
// 根据 activeTab 获取当前节点
const currentNodes = useMemo(() => {
switch (activeTab) {
case 'upstream':
return upstreamNodes;
case 'core':
return coreNodes;
case 'downstream':
return downstreamNodes;
default:
return [];
}
}, [activeTab, upstreamNodes, coreNodes, downstreamNodes]);
// 筛选节点
const filteredNodes = useMemo(() => {
let nodes = [...currentNodes];
// 类型筛选
if (typeFilter !== 'all') {
nodes = nodes.filter((n: ValueChainNode) => n.node_type === typeFilter);
}
// 重要度筛选
if (importanceFilter !== 'all') {
nodes = nodes.filter((n: ValueChainNode) => {
const score = n.importance_score || 0;
switch (importanceFilter) {
case 'high':
return score >= 80;
case 'medium':
return score >= 50 && score < 80;
case 'low':
return score < 50;
default:
return true;
}
});
}
return nodes;
}, [currentNodes, typeFilter, importanceFilter]);
// Sankey 图配置
const sankeyOption = useMemo(() =>
getSankeyChartOption(valueChainData),
[valueChainData]);
return (
<Card bg={THEME.cardBg} shadow="md">
{/* 头部区域 */}
<CardHeader py={0}>
<HStack flexWrap="wrap" gap={0}>
<Icon as={FaNetworkWired} color={THEME.gold} />
<Heading size="sm" color={THEME.textPrimary}>
</Heading>
<Text color={THEME.textSecondary} fontSize="sm">
| {companyName}
</Text>
<Badge bg={THEME.gold} color="gray.900">
{totalNodes}
</Badge>
</HStack>
</CardHeader>
<CardBody px={2}>
{/* 工具栏:左侧流程导航 + 右侧筛选 */}
<Flex
borderBottom="1px solid"
borderColor="gray.700"
justify="space-between"
align="center"
flexWrap="wrap"
>
{/* 左侧:流程式导航 - 仅在层级视图显示 */}
{viewMode === 'hierarchy' && (
<ProcessNavigation
activeTab={activeTab}
onTabChange={setActiveTab}
upstreamCount={upstreamNodes.length}
coreCount={coreNodes.length}
downstreamCount={downstreamNodes.length}
/>
)}
{/* 右侧:筛选与视图切换 - 始终靠右 */}
<Box ml="auto">
<ValueChainFilterBar
typeFilter={typeFilter}
onTypeChange={setTypeFilter}
importanceFilter={importanceFilter}
onImportanceChange={setImportanceFilter}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</Box>
</Flex>
{/* 内容区域 */}
<Box px={0} pt={4}>
{viewMode === 'hierarchy' ? (
filteredNodes.length > 0 ? (
<SimpleGrid columns={{ base: 2, md: 3, lg: 4 }} spacing={4}>
{filteredNodes.map((node, idx) => (
<ValueChainNodeCard
key={idx}
node={node}
isCompany={node.node_type === 'company'}
level={node.node_level}
/>
))}
</SimpleGrid>
) : (
<Center h="200px">
<Text color={THEME.textSecondary}></Text>
</Center>
)
) : sankeyOption ? (
<ReactECharts
option={sankeyOption}
style={{ height: '500px' }}
theme="dark"
/>
) : (
<Center h="200px">
<Text color={THEME.textSecondary}></Text>
</Center>
)}
</Box>
</CardBody>
</Card>
);
});
ValueChainCard.displayName = 'ValueChainCard';
export default ValueChainCard;

View File

@@ -0,0 +1,14 @@
/**
* Card 子组件导出
*
* DeepAnalysisTab 的各个区块组件
*/
export { default as CorePositioningCard } from './CorePositioningCard';
export { default as CompetitiveAnalysisCard } from './CompetitiveAnalysisCard';
export { default as BusinessStructureCard } from './BusinessStructureCard';
export { default as ValueChainCard } from './ValueChainCard';
export { default as KeyFactorsCard } from './KeyFactorsCard';
export { default as TimelineCard } from './TimelineCard';
export { default as BusinessSegmentsCard } from './BusinessSegmentsCard';
export { default as StrategyAnalysisCard } from './StrategyAnalysisCard';

Some files were not shown because too many files have changed in this diff Show More