commit;
This commit is contained in:
		
							parent
							
								
									0920615bbd
								
							
						
					
					
						commit
						7f478d91f4
					
				
							
								
								
									
										169
									
								
								src/app.py
								
								
								
								
							
							
						
						
									
										169
									
								
								src/app.py
								
								
								
								
							|  | @ -27,6 +27,9 @@ from src.valuation_analysis.pe_pb_analysis import ValuationAnalyzer | ||||||
| # 导入行业估值分析器 | # 导入行业估值分析器 | ||||||
| from src.valuation_analysis.industry_analysis import IndustryAnalyzer | from src.valuation_analysis.industry_analysis import IndustryAnalyzer | ||||||
| 
 | 
 | ||||||
|  | # 导入沪深港通监控器 | ||||||
|  | from src.valuation_analysis.hsgt_monitor import HSGTMonitor | ||||||
|  | 
 | ||||||
| # 设置日志 | # 设置日志 | ||||||
| logging.basicConfig( | logging.basicConfig( | ||||||
|     level=logging.INFO, |     level=logging.INFO, | ||||||
|  | @ -59,6 +62,9 @@ valuation_analyzer = ValuationAnalyzer() | ||||||
| # 创建行业分析器实例 | # 创建行业分析器实例 | ||||||
| industry_analyzer = IndustryAnalyzer() | industry_analyzer = IndustryAnalyzer() | ||||||
| 
 | 
 | ||||||
|  | # 创建监控器实例 | ||||||
|  | hsgt_monitor = HSGTMonitor() | ||||||
|  | 
 | ||||||
| # 获取项目根目录 | # 获取项目根目录 | ||||||
| ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | ||||||
| REPORTS_DIR = os.path.join(ROOT_DIR, 'src', 'reports') | REPORTS_DIR = os.path.join(ROOT_DIR, 'src', 'reports') | ||||||
|  | @ -1770,5 +1776,168 @@ def industry_page(): | ||||||
|     """渲染行业分析页面""" |     """渲染行业分析页面""" | ||||||
|     return render_template('industry.html') |     return render_template('industry.html') | ||||||
| 
 | 
 | ||||||
|  | @app.route('/hsgt') | ||||||
|  | def hsgt_page(): | ||||||
|  |     """渲染沪深港通监控页面""" | ||||||
|  |     return render_template('hsgt_monitor.html') | ||||||
|  | 
 | ||||||
|  | @app.route('/api/hsgt/northbound', methods=['GET']) | ||||||
|  | def get_northbound_data(): | ||||||
|  |     """获取北向资金流向数据接口 | ||||||
|  |      | ||||||
|  |     参数: | ||||||
|  |     - start_time: 可选,开始时间戳(秒) | ||||||
|  |     - end_time: 可选,结束时间戳(秒) | ||||||
|  |      | ||||||
|  |     返回北向资金流向数据 | ||||||
|  |     """ | ||||||
|  |     try: | ||||||
|  |         # 获取请求参数 | ||||||
|  |         start_time = request.args.get('start_time') | ||||||
|  |         end_time = request.args.get('end_time') | ||||||
|  |          | ||||||
|  |         # 转换为整数 | ||||||
|  |         if start_time: | ||||||
|  |             start_time = int(start_time) | ||||||
|  |         if end_time: | ||||||
|  |             end_time = int(end_time) | ||||||
|  |              | ||||||
|  |         # 调用数据获取方法 | ||||||
|  |         result = hsgt_monitor.fetch_northbound_data(start_time, end_time) | ||||||
|  |          | ||||||
|  |         if result.get('success'): | ||||||
|  |             return jsonify({ | ||||||
|  |                 "status": "success", | ||||||
|  |                 "data": result | ||||||
|  |             }) | ||||||
|  |         else: | ||||||
|  |             return jsonify({ | ||||||
|  |                 "status": "error", | ||||||
|  |                 "message": result.get('message', '获取北向资金数据失败') | ||||||
|  |             }), 500 | ||||||
|  |              | ||||||
|  |     except ValueError as e: | ||||||
|  |         return jsonify({ | ||||||
|  |             "status": "error", | ||||||
|  |             "message": f"参数格式错误: {str(e)}" | ||||||
|  |         }), 400 | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"获取北向资金数据异常: {str(e)}") | ||||||
|  |         return jsonify({ | ||||||
|  |             "status": "error", | ||||||
|  |             "message": f"服务器错误: {str(e)}" | ||||||
|  |         }), 500 | ||||||
|  | 
 | ||||||
|  | @app.route('/api/hsgt/southbound', methods=['GET']) | ||||||
|  | def get_southbound_data(): | ||||||
|  |     """获取南向资金流向数据接口 | ||||||
|  |      | ||||||
|  |     参数: | ||||||
|  |     - start_time: 可选,开始时间戳(秒) | ||||||
|  |     - end_time: 可选,结束时间戳(秒) | ||||||
|  |      | ||||||
|  |     返回南向资金流向数据 | ||||||
|  |     """ | ||||||
|  |     try: | ||||||
|  |         # 获取请求参数 | ||||||
|  |         start_time = request.args.get('start_time') | ||||||
|  |         end_time = request.args.get('end_time') | ||||||
|  |          | ||||||
|  |         # 转换为整数 | ||||||
|  |         if start_time: | ||||||
|  |             start_time = int(start_time) | ||||||
|  |         if end_time: | ||||||
|  |             end_time = int(end_time) | ||||||
|  |              | ||||||
|  |         # 调用数据获取方法 | ||||||
|  |         result = hsgt_monitor.fetch_southbound_data(start_time, end_time) | ||||||
|  |          | ||||||
|  |         if result.get('success'): | ||||||
|  |             return jsonify({ | ||||||
|  |                 "status": "success", | ||||||
|  |                 "data": result | ||||||
|  |             }) | ||||||
|  |         else: | ||||||
|  |             return jsonify({ | ||||||
|  |                 "status": "error", | ||||||
|  |                 "message": result.get('message', '获取南向资金数据失败') | ||||||
|  |             }), 500 | ||||||
|  |              | ||||||
|  |     except ValueError as e: | ||||||
|  |         return jsonify({ | ||||||
|  |             "status": "error", | ||||||
|  |             "message": f"参数格式错误: {str(e)}" | ||||||
|  |         }), 400 | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"获取南向资金数据异常: {str(e)}") | ||||||
|  |         return jsonify({ | ||||||
|  |             "status": "error", | ||||||
|  |             "message": f"服务器错误: {str(e)}" | ||||||
|  |         }), 500 | ||||||
|  | 
 | ||||||
|  | @app.route('/api/stock/tracks', methods=['GET']) | ||||||
|  | def get_stock_tracks(): | ||||||
|  |     """根据股票代码获取相关赛道信息 | ||||||
|  |      | ||||||
|  |     参数: | ||||||
|  |     - stock_code: 必须,股票代码 | ||||||
|  |      | ||||||
|  |     返回赛道列表 | ||||||
|  |     """ | ||||||
|  |     try: | ||||||
|  |         # 获取股票代码参数 | ||||||
|  |         stock_code = request.args.get('stock_code') | ||||||
|  |          | ||||||
|  |         # 验证参数 | ||||||
|  |         if not stock_code: | ||||||
|  |             return jsonify({ | ||||||
|  |                 "status": "error", | ||||||
|  |                 "message": "缺少必要参数: stock_code" | ||||||
|  |             }), 400 | ||||||
|  |          | ||||||
|  |         # 查询赛道关联信息 | ||||||
|  |         track_query = text(""" | ||||||
|  |             SELECT DISTINCT pc.stock_code, ci.belong_industry | ||||||
|  |             FROM gp_product_category pc | ||||||
|  |             JOIN gp_category_industry ci ON pc.category_name = ci.category_name | ||||||
|  |             WHERE pc.stock_code = :stock_code | ||||||
|  |         """) | ||||||
|  |          | ||||||
|  |         # 获取赛道信息 | ||||||
|  |         tracks = [] | ||||||
|  |         try: | ||||||
|  |             # 获取数据库连接 | ||||||
|  |             db_session = next(get_db()) | ||||||
|  |             # 执行查询 | ||||||
|  |             result = db_session.execute(track_query, {"stock_code": stock_code}) | ||||||
|  |             for row in result: | ||||||
|  |                 if row.belong_industry:  # 确保不为空 | ||||||
|  |                     tracks.append(row.belong_industry) | ||||||
|  |         except Exception as e: | ||||||
|  |             logger.error(f"查询赛道信息失败: {str(e)}") | ||||||
|  |             return jsonify({ | ||||||
|  |                 "status": "error", | ||||||
|  |                 "message": f"查询赛道信息失败: {str(e)}" | ||||||
|  |             }), 500 | ||||||
|  |         finally: | ||||||
|  |             if 'db_session' in locals() and db_session is not None: | ||||||
|  |                 db_session.close()  # 关闭会话 | ||||||
|  |          | ||||||
|  |         # 返回结果 | ||||||
|  |         return jsonify({ | ||||||
|  |             "status": "success", | ||||||
|  |             "data": { | ||||||
|  |                 "stock_code": stock_code, | ||||||
|  |                 "tracks": tracks | ||||||
|  |             } | ||||||
|  |         }) | ||||||
|  |              | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"获取股票赛道信息异常: {str(e)}") | ||||||
|  |         return jsonify({ | ||||||
|  |             "status": "error", | ||||||
|  |             "message": f"服务器错误: {str(e)}" | ||||||
|  |         }), 500 | ||||||
|  | 
 | ||||||
| if __name__ == '__main__': | if __name__ == '__main__': | ||||||
|     app.run(host='0.0.0.0', port=5000, debug=True)  |     app.run(host='0.0.0.0', port=5000, debug=True)  | ||||||
|  | @ -0,0 +1,595 @@ | ||||||
|  | /** | ||||||
|  |  * 沪深港通资金流向监控JS | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | document.addEventListener('DOMContentLoaded', function() { | ||||||
|  |     // 初始化图表
 | ||||||
|  |     let northChart = null; | ||||||
|  |     let southChart = null; | ||||||
|  |      | ||||||
|  |     // 初始化图表函数,确保DOM元素存在
 | ||||||
|  |     function initCharts() { | ||||||
|  |         try { | ||||||
|  |             const northChartDom = document.getElementById('northChart'); | ||||||
|  |             const southChartDom = document.getElementById('southChart'); | ||||||
|  |              | ||||||
|  |             if (northChartDom && !northChart) { | ||||||
|  |                 try { | ||||||
|  |                     northChart = echarts.init(northChartDom); | ||||||
|  |                     console.log('北向资金图表初始化成功'); | ||||||
|  |                 } catch (e) { | ||||||
|  |                     console.error('北向资金图表初始化失败:', e); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             if (southChartDom && !southChart) { | ||||||
|  |                 try { | ||||||
|  |                     southChart = echarts.init(southChartDom); | ||||||
|  |                     console.log('南向资金图表初始化成功'); | ||||||
|  |                 } catch (e) { | ||||||
|  |                     console.error('南向资金图表初始化失败:', e); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             return northChart && southChart; | ||||||
|  |         } catch (e) { | ||||||
|  |             console.error('图表初始化过程中发生错误:', e); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     // 确保DOM加载完毕后初始化图表
 | ||||||
|  |     // 使用setTimeout确保DOM元素完全渲染
 | ||||||
|  |     setTimeout(function() { | ||||||
|  |         if (!initCharts()) { | ||||||
|  |             console.log('首次初始化图表不成功,将在数据加载时再次尝试'); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // 开始加载数据
 | ||||||
|  |         loadData(); | ||||||
|  |          | ||||||
|  |         // 设置自动刷新 (每分钟刷新一次)
 | ||||||
|  |         setInterval(loadData, 60000); | ||||||
|  |     }, 100); | ||||||
|  |      | ||||||
|  |     // 设置图表自适应
 | ||||||
|  |     window.addEventListener('resize', function() { | ||||||
|  |         if (northChart) { | ||||||
|  |             try { | ||||||
|  |                 northChart.resize(); | ||||||
|  |             } catch (e) { | ||||||
|  |                 console.error('北向资金图表调整大小失败:', e); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         if (southChart) { | ||||||
|  |             try { | ||||||
|  |                 southChart.resize(); | ||||||
|  |             } catch (e) { | ||||||
|  |                 console.error('南向资金图表调整大小失败:', e); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     // 刷新按钮事件
 | ||||||
|  |     const refreshBtn = document.getElementById('refreshBtn'); | ||||||
|  |     if (refreshBtn) { | ||||||
|  |         refreshBtn.addEventListener('click', function() { | ||||||
|  |             loadData(); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 加载北向和南向资金数据 | ||||||
|  |      */ | ||||||
|  |     function loadData() { | ||||||
|  |         // 确保图表已经初始化
 | ||||||
|  |         initCharts(); | ||||||
|  |          | ||||||
|  |         // 显示加载中状态
 | ||||||
|  |         showLoading(true); | ||||||
|  |          | ||||||
|  |         console.log('开始加载北向资金数据...'); | ||||||
|  |          | ||||||
|  |         // 获取北向资金数据 (从香港流入A股的资金)
 | ||||||
|  |         fetch('/api/hsgt/northbound') | ||||||
|  |             .then(response => { | ||||||
|  |                 if (!response.ok) { | ||||||
|  |                     return response.json().then(data => { | ||||||
|  |                         throw new Error(data.message || '获取北向资金数据失败'); | ||||||
|  |                     }); | ||||||
|  |                 } | ||||||
|  |                 return response.json(); | ||||||
|  |             }) | ||||||
|  |             .then(data => { | ||||||
|  |                 console.log('北向资金数据获取成功:', data.status); | ||||||
|  |                  | ||||||
|  |                 if (data.status === 'success' && data.data && data.data.success) { | ||||||
|  |                     // 检查数据结构
 | ||||||
|  |                     console.log('北向资金数据结构:', { | ||||||
|  |                         hasTimeArray: Array.isArray(data.data.times), | ||||||
|  |                         timeLength: data.data.times ? data.data.times.length : 0, | ||||||
|  |                         hasDataObj: !!data.data.data, | ||||||
|  |                         totalLength: data.data.data && data.data.data.total ? data.data.data.total.length : 0, | ||||||
|  |                         hasCurrent: !!data.data.current | ||||||
|  |                     }); | ||||||
|  |                      | ||||||
|  |                     // 渲染北向资金数据
 | ||||||
|  |                     renderNorthboundData(data.data); | ||||||
|  |                 } else { | ||||||
|  |                     const errorMessage = data.data && data.data.message  | ||||||
|  |                         ? data.data.message  | ||||||
|  |                         : (data.message || '北向资金数据格式错误'); | ||||||
|  |                     showError('北向资金数据获取失败: ' + errorMessage); | ||||||
|  |                      | ||||||
|  |                     // 显示空数据状态
 | ||||||
|  |                     renderEmptyNorthboundChart(); | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |             .catch(error => { | ||||||
|  |                 showError('请求北向资金数据错误: ' + error.message); | ||||||
|  |                 console.error('北向资金数据请求异常:', error); | ||||||
|  |                 // 显示空数据状态
 | ||||||
|  |                 renderEmptyNorthboundChart(); | ||||||
|  |             }) | ||||||
|  |             .finally(() => { | ||||||
|  |                 // 无论成功失败,都开始请求南向资金数据
 | ||||||
|  |                 loadSouthboundData(); | ||||||
|  |             }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 渲染空的北向资金图表 | ||||||
|  |      */ | ||||||
|  |     function renderEmptyNorthboundChart() { | ||||||
|  |         if (!northChart) return; | ||||||
|  |          | ||||||
|  |         // 更新统计卡片为暂无数据
 | ||||||
|  |         updateStatCard('northTotal', null, '暂无数据'); | ||||||
|  |         updateStatCard('northSH', null, '暂无数据'); | ||||||
|  |         updateStatCard('northSZ', null, '暂无数据'); | ||||||
|  |          | ||||||
|  |         // 初始化一个简单的图表提示暂无数据
 | ||||||
|  |         northChart.setOption({ | ||||||
|  |             title: { | ||||||
|  |                 text: '陆股通资金流向(北向)', | ||||||
|  |                 left: 'center', | ||||||
|  |                 top: 0 | ||||||
|  |             }, | ||||||
|  |             graphic: { | ||||||
|  |                 elements: [{ | ||||||
|  |                     type: 'text', | ||||||
|  |                     left: 'center', | ||||||
|  |                     top: 'middle', | ||||||
|  |                     style: { | ||||||
|  |                         text: '暂无数据', | ||||||
|  |                         fontSize: 20, | ||||||
|  |                         fill: '#999' | ||||||
|  |                     } | ||||||
|  |                 }] | ||||||
|  |             } | ||||||
|  |         }, true); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 加载南向资金数据 | ||||||
|  |      */ | ||||||
|  |     function loadSouthboundData() { | ||||||
|  |         console.log('开始加载南向资金数据...'); | ||||||
|  |          | ||||||
|  |         fetch('/api/hsgt/southbound') | ||||||
|  |             .then(response => { | ||||||
|  |                 if (!response.ok) { | ||||||
|  |                     return response.json().then(data => { | ||||||
|  |                         throw new Error(data.message || '获取南向资金数据失败'); | ||||||
|  |                     }); | ||||||
|  |                 } | ||||||
|  |                 return response.json(); | ||||||
|  |             }) | ||||||
|  |             .then(data => { | ||||||
|  |                 console.log('南向资金数据获取成功:', data.status); | ||||||
|  |                  | ||||||
|  |                 if (data.status === 'success' && data.data && data.data.success) { | ||||||
|  |                     // 检查数据结构
 | ||||||
|  |                     console.log('南向资金数据结构:', { | ||||||
|  |                         hasTimeArray: Array.isArray(data.data.times), | ||||||
|  |                         timeLength: data.data.times ? data.data.times.length : 0, | ||||||
|  |                         hasDataObj: !!data.data.data, | ||||||
|  |                         totalLength: data.data.data && data.data.data.total ? data.data.data.total.length : 0, | ||||||
|  |                         hasCurrent: !!data.data.current | ||||||
|  |                     }); | ||||||
|  |                      | ||||||
|  |                     // 渲染南向资金数据
 | ||||||
|  |                     renderSouthboundData(data.data); | ||||||
|  |                 } else { | ||||||
|  |                     const errorMessage = data.data && data.data.message  | ||||||
|  |                         ? data.data.message  | ||||||
|  |                         : (data.message || '南向资金数据格式错误'); | ||||||
|  |                     showError('南向资金数据获取失败: ' + errorMessage); | ||||||
|  |                      | ||||||
|  |                     // 显示空数据状态
 | ||||||
|  |                     renderEmptySouthboundChart(); | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |             .catch(error => { | ||||||
|  |                 showError('请求南向资金数据错误: ' + error.message); | ||||||
|  |                 console.error('南向资金数据请求异常:', error); | ||||||
|  |                 // 显示空数据状态
 | ||||||
|  |                 renderEmptySouthboundChart(); | ||||||
|  |             }) | ||||||
|  |             .finally(() => { | ||||||
|  |                 // 隐藏加载中状态
 | ||||||
|  |                 showLoading(false); | ||||||
|  |             }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 渲染空的南向资金图表 | ||||||
|  |      */ | ||||||
|  |     function renderEmptySouthboundChart() { | ||||||
|  |         if (!southChart) return; | ||||||
|  |          | ||||||
|  |         // 更新统计卡片为暂无数据
 | ||||||
|  |         updateStatCard('southTotal', null, '暂无数据'); | ||||||
|  |         updateStatCard('southHKSH', null, '暂无数据'); | ||||||
|  |         updateStatCard('southHKSZ', null, '暂无数据'); | ||||||
|  |          | ||||||
|  |         // 初始化一个简单的图表提示暂无数据
 | ||||||
|  |         southChart.setOption({ | ||||||
|  |             title: { | ||||||
|  |                 text: '港股通资金流向(南向)', | ||||||
|  |                 left: 'center', | ||||||
|  |                 top: 0 | ||||||
|  |             }, | ||||||
|  |             graphic: { | ||||||
|  |                 elements: [{ | ||||||
|  |                     type: 'text', | ||||||
|  |                     left: 'center', | ||||||
|  |                     top: 'middle', | ||||||
|  |                     style: { | ||||||
|  |                         text: '暂无数据', | ||||||
|  |                         fontSize: 20, | ||||||
|  |                         fill: '#999' | ||||||
|  |                     } | ||||||
|  |                 }] | ||||||
|  |             } | ||||||
|  |         }, true); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 渲染北向资金数据 (从香港流入A股的资金) | ||||||
|  |      */ | ||||||
|  |     function renderNorthboundData(data) { | ||||||
|  |         if (!northChart) return; | ||||||
|  |          | ||||||
|  |         try { | ||||||
|  |             // 验证数据有效性
 | ||||||
|  |             if (!data || !data.data || !data.times || !data.current) { | ||||||
|  |                 renderEmptyNorthboundChart(); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 准备简化的图表数据
 | ||||||
|  |             const times = data.times; | ||||||
|  |             const seriesData = []; | ||||||
|  |              | ||||||
|  |             // 处理北向资金总量
 | ||||||
|  |             if (data.data.total && Array.isArray(data.data.total) && data.data.total.length > 0) { | ||||||
|  |                 seriesData.push({ | ||||||
|  |                     name: '北向资金', | ||||||
|  |                     type: 'line', | ||||||
|  |                     data: data.data.total, | ||||||
|  |                     lineStyle: {width: 3} | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 处理沪股通
 | ||||||
|  |             if (data.data.sh && Array.isArray(data.data.sh) && data.data.sh.length > 0) { | ||||||
|  |                 seriesData.push({ | ||||||
|  |                     name: '沪股通', | ||||||
|  |                     type: 'line', | ||||||
|  |                     data: data.data.sh | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 处理深股通
 | ||||||
|  |             if (data.data.sz && Array.isArray(data.data.sz) && data.data.sz.length > 0) { | ||||||
|  |                 seriesData.push({ | ||||||
|  |                     name: '深股通', | ||||||
|  |                     type: 'line', | ||||||
|  |                     data: data.data.sz | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 如果没有有效的系列数据,显示空图表
 | ||||||
|  |             if (seriesData.length === 0) { | ||||||
|  |                 renderEmptyNorthboundChart(); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 更新统计卡片
 | ||||||
|  |             updateStatCard('northTotal', data.current.total); | ||||||
|  |             updateStatCard('northSH', data.current.sh); | ||||||
|  |             updateStatCard('northSZ', data.current.sz); | ||||||
|  |              | ||||||
|  |             // 更新时间
 | ||||||
|  |             const updateTimeElem = document.getElementById('updateTime'); | ||||||
|  |             if (updateTimeElem) { | ||||||
|  |                 updateTimeElem.textContent = '最后更新时间: ' + data.update_time; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 创建简单的图表配置
 | ||||||
|  |             const option = { | ||||||
|  |                 title: { | ||||||
|  |                     text: '陆股通资金流向(北向)', | ||||||
|  |                     left: 'center', | ||||||
|  |                     top: 0 | ||||||
|  |                 }, | ||||||
|  |                 tooltip: { | ||||||
|  |                     trigger: 'axis', | ||||||
|  |                     formatter: function(params) { | ||||||
|  |                         if (!params || params.length === 0) return ''; | ||||||
|  |                          | ||||||
|  |                         const time = params[0].axisValue; | ||||||
|  |                         let result = `${time}<br/>`; | ||||||
|  |                          | ||||||
|  |                         params.forEach(param => { | ||||||
|  |                             if (param && param.value !== undefined) { | ||||||
|  |                                 const value = param.value; | ||||||
|  |                                 const color = param.color; | ||||||
|  |                                 const marker = `<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`; | ||||||
|  |                                 let valueText = value !== null ? value.toFixed(2) : 'N/A'; | ||||||
|  |                                 if (value > 0) valueText = '+' + valueText; | ||||||
|  |                                 result += `${marker}${param.seriesName}: ${valueText} 亿<br/>`; | ||||||
|  |                             } | ||||||
|  |                         }); | ||||||
|  |                          | ||||||
|  |                         return result; | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 legend: { | ||||||
|  |                     data: seriesData.map(item => item.name), | ||||||
|  |                     top: 30 | ||||||
|  |                 }, | ||||||
|  |                 grid: { | ||||||
|  |                     left: '3%', | ||||||
|  |                     right: '4%', | ||||||
|  |                     bottom: '3%', | ||||||
|  |                     top: 80, | ||||||
|  |                     containLabel: true | ||||||
|  |                 }, | ||||||
|  |                 xAxis: { | ||||||
|  |                     type: 'category', | ||||||
|  |                     boundaryGap: false, | ||||||
|  |                     data: times, | ||||||
|  |                     axisLabel: { | ||||||
|  |                         rotate: 45 | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 yAxis: { | ||||||
|  |                     type: 'value', | ||||||
|  |                     name: '净流入金额(亿元)', | ||||||
|  |                     axisLabel: { | ||||||
|  |                         formatter: function(value) { | ||||||
|  |                             return value.toFixed(1); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 series: seriesData, | ||||||
|  |                 color: ['#ec0000', '#1e88e5', '#ff9800'] | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             // 安全地设置图表
 | ||||||
|  |             if (northChart && northChart.setOption) { | ||||||
|  |                 northChart.setOption(option, true); | ||||||
|  |             } else { | ||||||
|  |                 console.error('北向资金图表实例无效'); | ||||||
|  |                 const northChartDom = document.getElementById('northChart'); | ||||||
|  |                 if (northChartDom) { | ||||||
|  |                     northChart = echarts.init(northChartDom); | ||||||
|  |                     northChart.setOption(option, true); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } catch (error) { | ||||||
|  |             console.error('设置北向资金图表选项时出错:', error); | ||||||
|  |             renderEmptyNorthboundChart(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 渲染南向资金数据 (从内地流入港股的资金) | ||||||
|  |      */ | ||||||
|  |     function renderSouthboundData(data) { | ||||||
|  |         if (!southChart) return; | ||||||
|  |          | ||||||
|  |         try { | ||||||
|  |             // 验证数据有效性
 | ||||||
|  |             if (!data || !data.data || !data.times || !data.current) { | ||||||
|  |                 renderEmptySouthboundChart(); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 准备简化的图表数据
 | ||||||
|  |             const times = data.times; | ||||||
|  |             const seriesData = []; | ||||||
|  |              | ||||||
|  |             // 处理南向资金总量
 | ||||||
|  |             if (data.data.total && Array.isArray(data.data.total) && data.data.total.length > 0) { | ||||||
|  |                 seriesData.push({ | ||||||
|  |                     name: '南向资金', | ||||||
|  |                     type: 'line', | ||||||
|  |                     data: data.data.total, | ||||||
|  |                     lineStyle: {width: 3} | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 处理沪市港股通
 | ||||||
|  |             if (data.data.hk_sh && Array.isArray(data.data.hk_sh) && data.data.hk_sh.length > 0) { | ||||||
|  |                 seriesData.push({ | ||||||
|  |                     name: '沪市港股通', | ||||||
|  |                     type: 'line', | ||||||
|  |                     data: data.data.hk_sh | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 处理深市港股通
 | ||||||
|  |             if (data.data.hk_sz && Array.isArray(data.data.hk_sz) && data.data.hk_sz.length > 0) { | ||||||
|  |                 seriesData.push({ | ||||||
|  |                     name: '深市港股通', | ||||||
|  |                     type: 'line', | ||||||
|  |                     data: data.data.hk_sz | ||||||
|  |                 }); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 如果没有有效的系列数据,显示空图表
 | ||||||
|  |             if (seriesData.length === 0) { | ||||||
|  |                 renderEmptySouthboundChart(); | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 更新统计卡片
 | ||||||
|  |             updateStatCard('southTotal', data.current.total); | ||||||
|  |             updateStatCard('southHKSH', data.current.hk_sh); | ||||||
|  |             updateStatCard('southHKSZ', data.current.hk_sz); | ||||||
|  |              | ||||||
|  |             // 创建简单的图表配置
 | ||||||
|  |             const option = { | ||||||
|  |                 title: { | ||||||
|  |                     text: '港股通资金流向(南向)', | ||||||
|  |                     left: 'center', | ||||||
|  |                     top: 0 | ||||||
|  |                 }, | ||||||
|  |                 tooltip: { | ||||||
|  |                     trigger: 'axis', | ||||||
|  |                     formatter: function(params) { | ||||||
|  |                         if (!params || params.length === 0) return ''; | ||||||
|  |                          | ||||||
|  |                         const time = params[0].axisValue; | ||||||
|  |                         let result = `${time}<br/>`; | ||||||
|  |                          | ||||||
|  |                         params.forEach(param => { | ||||||
|  |                             if (param && param.value !== undefined) { | ||||||
|  |                                 const value = param.value; | ||||||
|  |                                 const color = param.color; | ||||||
|  |                                 const marker = `<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`; | ||||||
|  |                                 let valueText = value !== null ? value.toFixed(2) : 'N/A'; | ||||||
|  |                                 if (value > 0) valueText = '+' + valueText; | ||||||
|  |                                 result += `${marker}${param.seriesName}: ${valueText} 亿<br/>`; | ||||||
|  |                             } | ||||||
|  |                         }); | ||||||
|  |                          | ||||||
|  |                         return result; | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 legend: { | ||||||
|  |                     data: seriesData.map(item => item.name), | ||||||
|  |                     top: 30 | ||||||
|  |                 }, | ||||||
|  |                 grid: { | ||||||
|  |                     left: '3%', | ||||||
|  |                     right: '4%', | ||||||
|  |                     bottom: '3%', | ||||||
|  |                     top: 80, | ||||||
|  |                     containLabel: true | ||||||
|  |                 }, | ||||||
|  |                 xAxis: { | ||||||
|  |                     type: 'category', | ||||||
|  |                     boundaryGap: false, | ||||||
|  |                     data: times, | ||||||
|  |                     axisLabel: { | ||||||
|  |                         rotate: 45 | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 yAxis: { | ||||||
|  |                     type: 'value', | ||||||
|  |                     name: '净流入金额(亿元)', | ||||||
|  |                     axisLabel: { | ||||||
|  |                         formatter: function(value) { | ||||||
|  |                             return value.toFixed(1); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 series: seriesData, | ||||||
|  |                 color: ['#ec0000', '#1e88e5', '#ff9800'] | ||||||
|  |             }; | ||||||
|  |              | ||||||
|  |             // 安全地设置图表
 | ||||||
|  |             if (southChart && southChart.setOption) { | ||||||
|  |                 southChart.setOption(option, true); | ||||||
|  |             } else { | ||||||
|  |                 console.error('南向资金图表实例无效'); | ||||||
|  |                 const southChartDom = document.getElementById('southChart'); | ||||||
|  |                 if (southChartDom) { | ||||||
|  |                     southChart = echarts.init(southChartDom); | ||||||
|  |                     southChart.setOption(option, true); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } catch (error) { | ||||||
|  |             console.error('设置南向资金图表选项时出错:', error); | ||||||
|  |             renderEmptySouthboundChart(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 更新统计卡片 | ||||||
|  |      */ | ||||||
|  |     function updateStatCard(id, value, customText) { | ||||||
|  |         const statCard = document.getElementById(id); | ||||||
|  |         if (!statCard) return; | ||||||
|  |          | ||||||
|  |         const statValue = statCard.querySelector('.stat-value'); | ||||||
|  |         if (!statValue) return; | ||||||
|  |          | ||||||
|  |         if (customText) { | ||||||
|  |             statValue.textContent = customText; | ||||||
|  |             statValue.classList.remove('money-inflow', 'money-outflow'); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         if (value === null || value === undefined) { | ||||||
|  |             statValue.textContent = '--'; | ||||||
|  |             statValue.classList.remove('money-inflow', 'money-outflow'); | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // 格式化显示,保留两位小数
 | ||||||
|  |         const formattedValue = value.toFixed(2); | ||||||
|  |          | ||||||
|  |         // 根据正负值设置样式
 | ||||||
|  |         if (value > 0) { | ||||||
|  |             statValue.textContent = '+' + formattedValue; | ||||||
|  |             statValue.classList.add('money-inflow'); | ||||||
|  |             statValue.classList.remove('money-outflow'); | ||||||
|  |         } else if (value < 0) { | ||||||
|  |             statValue.textContent = formattedValue; | ||||||
|  |             statValue.classList.add('money-outflow'); | ||||||
|  |             statValue.classList.remove('money-inflow'); | ||||||
|  |         } else { | ||||||
|  |             statValue.textContent = formattedValue; | ||||||
|  |             statValue.classList.remove('money-inflow', 'money-outflow'); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 显示错误信息 | ||||||
|  |      */ | ||||||
|  |     function showError(message) { | ||||||
|  |         console.error(message); | ||||||
|  |         // 可以添加Toast或其他UI提示
 | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 显示/隐藏加载状态 | ||||||
|  |      */ | ||||||
|  |     function showLoading(isLoading) { | ||||||
|  |         if (!northChart || !southChart) return; | ||||||
|  |          | ||||||
|  |         // 实现加载中状态显示
 | ||||||
|  |         if (isLoading) { | ||||||
|  |             northChart.showLoading({text: '加载中...'}); | ||||||
|  |             southChart.showLoading({text: '加载中...'}); | ||||||
|  |         } else { | ||||||
|  |             northChart.hideLoading(); | ||||||
|  |             southChart.hideLoading(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | });  | ||||||
|  | @ -0,0 +1,765 @@ | ||||||
|  | /** | ||||||
|  |  * 行业估值分析工具前端JS | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | document.addEventListener('DOMContentLoaded', function() { | ||||||
|  |     // 获取DOM元素
 | ||||||
|  |     const industryForm = document.getElementById('industryForm'); | ||||||
|  |     const industryNameSelect = document.getElementById('industryName'); | ||||||
|  |     const startDateInput = document.getElementById('startDate'); | ||||||
|  |     const metricSelect = document.getElementById('metric'); | ||||||
|  |     const showCrowdingCheckbox = document.getElementById('showCrowding'); | ||||||
|  |     const analyzeBtn = document.getElementById('analyzeBtn'); | ||||||
|  |     const resetBtn = document.getElementById('resetBtn'); | ||||||
|  |     const loadingSpinner = document.getElementById('loadingSpinner'); | ||||||
|  |     const resultCard = document.getElementById('resultCard'); | ||||||
|  |     const resultTitle = document.getElementById('resultTitle'); | ||||||
|  |     const errorAlert = document.getElementById('errorAlert'); | ||||||
|  |     const errorMessage = document.getElementById('errorMessage'); | ||||||
|  |     const percentileTable = document.getElementById('percentileTable'); | ||||||
|  |     const crowdingStats = document.getElementById('crowdingStats'); | ||||||
|  |     const crowdingTable = document.getElementById('crowdingTable'); | ||||||
|  |     const industryStatsTable = document.getElementById('industryStatsTable'); | ||||||
|  |     const crowdingChartRow = document.getElementById('crowdingChartRow'); | ||||||
|  |      | ||||||
|  |     // 定义图表实例
 | ||||||
|  |     let valuationChart = null; | ||||||
|  |     let crowdingChart = null; | ||||||
|  |      | ||||||
|  |     // 初始化 - 加载行业列表
 | ||||||
|  |     loadIndustryList(); | ||||||
|  |      | ||||||
|  |     // 监听表单提交事件
 | ||||||
|  |     industryForm.addEventListener('submit', function(event) { | ||||||
|  |         event.preventDefault(); | ||||||
|  |         analyzeIndustry(); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     // 监听重置按钮点击事件
 | ||||||
|  |     resetBtn.addEventListener('click', function() { | ||||||
|  |         resetForm(); | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     // 拥挤度显示控制
 | ||||||
|  |     showCrowdingCheckbox.addEventListener('change', function() { | ||||||
|  |         if (crowdingStats.classList.contains('d-none')) { | ||||||
|  |             // 如果还没有数据,不做任何操作
 | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         if (this.checked) { | ||||||
|  |             // 显示拥挤度
 | ||||||
|  |             crowdingStats.classList.remove('d-none'); | ||||||
|  |             crowdingChartRow.classList.remove('d-none'); | ||||||
|  |         } else { | ||||||
|  |             // 隐藏拥挤度
 | ||||||
|  |             crowdingStats.classList.add('d-none'); | ||||||
|  |             crowdingChartRow.classList.add('d-none'); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 加载行业列表 | ||||||
|  |      */ | ||||||
|  |     function loadIndustryList() { | ||||||
|  |         showLoading(true); | ||||||
|  |          | ||||||
|  |         fetch('/api/industry/list') | ||||||
|  |             .then(response => { | ||||||
|  |                 if (!response.ok) { | ||||||
|  |                     return response.json().then(data => { | ||||||
|  |                         throw new Error(data.message || '请求失败'); | ||||||
|  |                     }); | ||||||
|  |                 } | ||||||
|  |                 return response.json(); | ||||||
|  |             }) | ||||||
|  |             .then(data => { | ||||||
|  |                 if (data.status === 'success') { | ||||||
|  |                     // 填充行业下拉列表
 | ||||||
|  |                     populateIndustrySelect(data.data); | ||||||
|  |                 } else { | ||||||
|  |                     showError(data.message || '获取行业列表失败'); | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |             .catch(error => { | ||||||
|  |                 showError(error.message || '请求失败,请检查网络连接'); | ||||||
|  |             }) | ||||||
|  |             .finally(() => { | ||||||
|  |                 showLoading(false); | ||||||
|  |             }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 填充行业下拉列表 | ||||||
|  |      */ | ||||||
|  |     function populateIndustrySelect(industries) { | ||||||
|  |         // 清空选项(保留第一个默认选项)
 | ||||||
|  |         industryNameSelect.innerHTML = '<option value="" selected disabled>请选择行业</option>'; | ||||||
|  |          | ||||||
|  |         // 排序行业列表(按名称)
 | ||||||
|  |         industries.sort((a, b) => a.name.localeCompare(b.name, 'zh')); | ||||||
|  |          | ||||||
|  |         // 添加行业选项
 | ||||||
|  |         industries.forEach(industry => { | ||||||
|  |             const option = document.createElement('option'); | ||||||
|  |             option.value = industry.name; | ||||||
|  |             option.textContent = industry.name; | ||||||
|  |             industryNameSelect.appendChild(option); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 分析行业估值 | ||||||
|  |      */ | ||||||
|  |     function analyzeIndustry() { | ||||||
|  |         // 显示加载中状态
 | ||||||
|  |         showLoading(true); | ||||||
|  |          | ||||||
|  |         // 隐藏之前的结果和错误信息
 | ||||||
|  |         resultCard.classList.add('d-none'); | ||||||
|  |         errorAlert.classList.add('d-none'); | ||||||
|  |          | ||||||
|  |         // 获取表单数据
 | ||||||
|  |         const industryName = industryNameSelect.value; | ||||||
|  |         const startDate = startDateInput.value; | ||||||
|  |         const metric = metricSelect.value; | ||||||
|  |          | ||||||
|  |         // 构建请求URL
 | ||||||
|  |         let url = `/api/industry/analysis?industry_name=${encodeURIComponent(industryName)}&metric=${metric}`; | ||||||
|  |          | ||||||
|  |         if (startDate) { | ||||||
|  |             url += `&start_date=${startDate}`; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // 发送API请求
 | ||||||
|  |         fetch(url) | ||||||
|  |             .then(response => { | ||||||
|  |                 if (!response.ok) { | ||||||
|  |                     return response.json().then(data => { | ||||||
|  |                         throw new Error(data.message || '请求失败'); | ||||||
|  |                     }); | ||||||
|  |                 } | ||||||
|  |                 return response.json(); | ||||||
|  |             }) | ||||||
|  |             .then(data => { | ||||||
|  |                 if (data.status === 'success') { | ||||||
|  |                     // 渲染分析结果
 | ||||||
|  |                     renderIndustryResults(data); | ||||||
|  |                 } else { | ||||||
|  |                     showError(data.message || '分析失败,请稍后再试'); | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |             .catch(error => { | ||||||
|  |                 showError(error.message || '请求失败,请检查网络连接'); | ||||||
|  |             }) | ||||||
|  |             .finally(() => { | ||||||
|  |                 showLoading(false); | ||||||
|  |             }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 渲染行业分析结果 | ||||||
|  |      */ | ||||||
|  |     function renderIndustryResults(data) { | ||||||
|  |         // 显示结果卡片(在渲染图表前确保容器可见)
 | ||||||
|  |         resultCard.classList.remove('d-none'); | ||||||
|  |          | ||||||
|  |         // 更新标题
 | ||||||
|  |         resultTitle.textContent = data.data.title.text; | ||||||
|  |          | ||||||
|  |         // 渲染分位数数据表格
 | ||||||
|  |         renderPercentileTable(data.data.percentiles, data.data.yAxis[0].name); | ||||||
|  |          | ||||||
|  |         // 渲染行业基本统计
 | ||||||
|  |         renderIndustryStats(data.data.percentiles); | ||||||
|  |          | ||||||
|  |         // 渲染拥挤度数据(如果有)
 | ||||||
|  |         if (data.data.crowding) { | ||||||
|  |             renderCrowdingStats(data.data.crowding); | ||||||
|  |              | ||||||
|  |             // 根据复选框状态决定是否显示拥挤度
 | ||||||
|  |             if (showCrowdingCheckbox.checked) { | ||||||
|  |                 crowdingStats.classList.remove('d-none'); | ||||||
|  |                 crowdingChartRow.classList.remove('d-none'); | ||||||
|  |             } else { | ||||||
|  |                 crowdingStats.classList.add('d-none'); | ||||||
|  |                 crowdingChartRow.classList.add('d-none'); | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             crowdingStats.classList.add('d-none'); | ||||||
|  |             crowdingChartRow.classList.add('d-none'); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // 渲染图表(等待DOM更新后)
 | ||||||
|  |         setTimeout(() => { | ||||||
|  |             renderValuationChart(data.data); | ||||||
|  |              | ||||||
|  |             // 如果有拥挤度数据且复选框被选中,渲染拥挤度图表
 | ||||||
|  |             if (data.data.crowding && showCrowdingCheckbox.checked) { | ||||||
|  |                 renderCrowdingChart(data.data); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // 滚动到结果区域
 | ||||||
|  |             resultCard.scrollIntoView({ behavior: 'smooth', block: 'start' }); | ||||||
|  |         }, 100); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 渲染估值分位数ECharts图表 | ||||||
|  |      */ | ||||||
|  |     function renderValuationChart(chartData) { | ||||||
|  |         // 初始化图表
 | ||||||
|  |         const chartContainer = document.getElementById('valuationChart'); | ||||||
|  |          | ||||||
|  |         // 确保容器可见
 | ||||||
|  |         resultCard.classList.remove('d-none'); | ||||||
|  |          | ||||||
|  |         // 如果已经存在图表实例,则销毁
 | ||||||
|  |         if (valuationChart) { | ||||||
|  |             valuationChart.dispose(); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // 创建新的图表实例
 | ||||||
|  |         valuationChart = echarts.init(chartContainer); | ||||||
|  |          | ||||||
|  |         // 处理图例数据
 | ||||||
|  |         let legendData = chartData.legend.data.filter(item => !item.includes('拥挤度')); | ||||||
|  |          | ||||||
|  |         // 处理系列数据
 | ||||||
|  |         let seriesData = chartData.series.filter(series =>  | ||||||
|  |             !series.name.includes('拥挤度') && series.yAxisIndex !== 1 | ||||||
|  |         ); | ||||||
|  |          | ||||||
|  |         // 去掉所有折线上的圆圈标记
 | ||||||
|  |         seriesData.forEach(series => { | ||||||
|  |             series.symbol = 'none'; // 不显示折线上的圆点标记
 | ||||||
|  |         }); | ||||||
|  |          | ||||||
|  |         // 检查数据有效性
 | ||||||
|  |         // 注意:现在所有图例都是存在的,因为我们使用行业平均值的历史统计生成它们
 | ||||||
|  |         // 不再需要检查q1_values, q3_values等是否存在
 | ||||||
|  |          | ||||||
|  |         // 计算Y轴范围
 | ||||||
|  |         const percentiles = chartData.percentiles; | ||||||
|  |         const avgValues = chartData.valuation.avg_values.filter(v => v !== null && v !== undefined); | ||||||
|  |          | ||||||
|  |         // 找出数据的最小值和最大值
 | ||||||
|  |         let minValue = percentiles.min; | ||||||
|  |         let maxValue = percentiles.max; | ||||||
|  |          | ||||||
|  |         // 如果有avgValues,也考虑其最小值和最大值
 | ||||||
|  |         if (avgValues.length > 0) { | ||||||
|  |             const avgMin = Math.min(...avgValues); | ||||||
|  |             const avgMax = Math.max(...avgValues); | ||||||
|  |             minValue = Math.min(minValue, avgMin); | ||||||
|  |             maxValue = Math.max(maxValue, avgMax); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // 计算Y轴范围,给出更好的视觉展示
 | ||||||
|  |         // 下限:取最小值的80%或最小值减去范围的20%,但不小于0
 | ||||||
|  |         // 上限:取最大值的120%或最大值加上范围的20%
 | ||||||
|  |         const range = maxValue - minValue; | ||||||
|  |         const padding = range * 0.2; | ||||||
|  |         let yMinValue = Math.max(0, minValue - padding); | ||||||
|  |         let yMaxValue = maxValue + padding; | ||||||
|  |          | ||||||
|  |         // 如果最小值与最大值之间的差距太小,进一步扩大范围以便更好地展示
 | ||||||
|  |         if (range / maxValue < 0.1) { // 如果范围小于最大值的10%
 | ||||||
|  |             yMinValue = Math.max(0, minValue * 0.5); // 下限可以更低,但不低于0
 | ||||||
|  |             yMaxValue = maxValue * 1.3; // 上限可以更高
 | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // 设置图表选项
 | ||||||
|  |         const option = { | ||||||
|  |             title: { | ||||||
|  |                 text: chartData.title.text, | ||||||
|  |                 subtext: chartData.title.subtext, | ||||||
|  |                 left: 'center', | ||||||
|  |                 top: 5 | ||||||
|  |             }, | ||||||
|  |             tooltip: { | ||||||
|  |                 trigger: 'axis', | ||||||
|  |                 axisPointer: { | ||||||
|  |                     type: 'cross', | ||||||
|  |                     label: { | ||||||
|  |                         backgroundColor: '#6a7985' | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 formatter: function(params) { | ||||||
|  |                     const date = params[0].axisValue; | ||||||
|  |                     let result = `${date}<br/>`; | ||||||
|  |                      | ||||||
|  |                     params.forEach(param => { | ||||||
|  |                         const value = param.value; | ||||||
|  |                         const color = param.color; | ||||||
|  |                         const marker = `<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`; | ||||||
|  |                         const name = param.seriesName; | ||||||
|  |                          | ||||||
|  |                         if (name.includes('历史')) { | ||||||
|  |                             // 对于历史统计线,使用固定值显示
 | ||||||
|  |                             result += `${marker}${name}: ${value !== null ? value.toFixed(2) : 'N/A'}<br/>`; | ||||||
|  |                         } else { | ||||||
|  |                             // 对于当前值,显示更多小数位
 | ||||||
|  |                             result += `${marker}${name}: ${value !== null ? value.toFixed(2) : 'N/A'}<br/>`; | ||||||
|  |                         } | ||||||
|  |                     }); | ||||||
|  |                      | ||||||
|  |                     return result; | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             legend: { | ||||||
|  |                 data: legendData, | ||||||
|  |                 top: 60 | ||||||
|  |             }, | ||||||
|  |             grid: chartData.grid[0], | ||||||
|  |             toolbox: chartData.toolbox, | ||||||
|  |             xAxis: chartData.xAxis[0], | ||||||
|  |             yAxis: { | ||||||
|  |                 type: 'value', | ||||||
|  |                 name: chartData.yAxis[0].name, | ||||||
|  |                 min: yMinValue, | ||||||
|  |                 max: yMaxValue, | ||||||
|  |                 axisLabel: { | ||||||
|  |                     formatter: function(value) { return value.toFixed(1); } | ||||||
|  |                 }, | ||||||
|  |                 splitLine: { | ||||||
|  |                     show: true, | ||||||
|  |                     lineStyle: { | ||||||
|  |                         type: 'dashed' | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             dataZoom: [ | ||||||
|  |                 { | ||||||
|  |                     type: 'inside', | ||||||
|  |                     start: 0, | ||||||
|  |                     end: 100, | ||||||
|  |                     xAxisIndex: [0] | ||||||
|  |                 }, | ||||||
|  |                 { | ||||||
|  |                     start: 0, | ||||||
|  |                     end: 100, | ||||||
|  |                     xAxisIndex: [0] | ||||||
|  |                 } | ||||||
|  |             ], | ||||||
|  |             series: seriesData | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // 设置图表选项
 | ||||||
|  |         valuationChart.setOption(option); | ||||||
|  |          | ||||||
|  |         // 手动触发一次resize,确保图表正确渲染
 | ||||||
|  |         setTimeout(() => { | ||||||
|  |             valuationChart.resize(); | ||||||
|  |         }, 50); | ||||||
|  |          | ||||||
|  |         // 响应式调整
 | ||||||
|  |         window.addEventListener('resize', function() { | ||||||
|  |             if (valuationChart) { | ||||||
|  |                 valuationChart.resize(); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 渲染拥挤度ECharts图表 | ||||||
|  |      */ | ||||||
|  |     function renderCrowdingChart(chartData) { | ||||||
|  |         // 初始化图表
 | ||||||
|  |         const chartContainer = document.getElementById('crowdingChart'); | ||||||
|  |          | ||||||
|  |         // 确保容器可见
 | ||||||
|  |         crowdingChartRow.classList.remove('d-none'); | ||||||
|  |          | ||||||
|  |         // 如果已经存在图表实例,则销毁
 | ||||||
|  |         if (crowdingChart) { | ||||||
|  |             crowdingChart.dispose(); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // 创建新的图表实例
 | ||||||
|  |         crowdingChart = echarts.init(chartContainer); | ||||||
|  |          | ||||||
|  |         // 使用拥挤度的完整数据,不受其他时间筛选影响
 | ||||||
|  |         // 拥挤度数据在后端已经固定为3年数据
 | ||||||
|  |         const crowdingDates = chartData.crowding.dates; | ||||||
|  |         const crowdingPercentiles = chartData.crowding.percentiles; | ||||||
|  |          | ||||||
|  |         // 筛选拥挤度相关的系列
 | ||||||
|  |         const crowdingSeries = { | ||||||
|  |             name: '行业交易拥挤度历史百分位', | ||||||
|  |             type: 'line', | ||||||
|  |             data: crowdingPercentiles, | ||||||
|  |             symbol: 'none', // 不显示折线上的圆点标记
 | ||||||
|  |             lineStyle: {width: 2, color: '#ff7f50'}, | ||||||
|  |             areaStyle: {opacity: 0.2, color: '#ff7f50'}, | ||||||
|  |             markLine: { | ||||||
|  |                 data: [ | ||||||
|  |                     {name: "20%", yAxis: 20, lineStyle: {color: "#28a745", type: "dashed"}}, | ||||||
|  |                     {name: "80%", yAxis: 80, lineStyle: {color: "#dc3545", type: "dashed"}} | ||||||
|  |                 ] | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // 获取拥挤度数据,用于计算Y轴范围
 | ||||||
|  |         // 使用百分位数值替代原始比例
 | ||||||
|  |         let percentileValues = crowdingPercentiles.filter(v => v !== null && v !== undefined); | ||||||
|  |          | ||||||
|  |         // 计算Y轴范围
 | ||||||
|  |         let yMinValue = 0; // 默认最小值为0
 | ||||||
|  |         let yMaxValue = 100; // 默认最大值为100(百分比)
 | ||||||
|  |          | ||||||
|  |         // 如果有实际数据,可以根据数据调整范围
 | ||||||
|  |         if (percentileValues.length > 0) { | ||||||
|  |             const minPercentile = Math.min(...percentileValues); | ||||||
|  |             const maxPercentile = Math.max(...percentileValues); | ||||||
|  |              | ||||||
|  |             // 但拥挤度图表通常应该保持0-100的范围,所以只在必要时调整
 | ||||||
|  |             if (minPercentile > 20 && maxPercentile < 80) { | ||||||
|  |                 // 如果数据都集中在20-80之间,可以适当缩小范围
 | ||||||
|  |                 yMinValue = Math.max(0, Math.floor(minPercentile / 10) * 10 - 10); // 向下取整到最接近的10的倍数再减10
 | ||||||
|  |                 yMaxValue = Math.min(100, Math.ceil(maxPercentile / 10) * 10 + 10); // 向上取整到最接近的10的倍数再加10
 | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         // 设置图表选项
 | ||||||
|  |         const option = { | ||||||
|  |             title: { | ||||||
|  |                 text: '行业交易拥挤度分析', | ||||||
|  |                 subtext: `当前拥挤度历史百分位: ${chartData.crowding.current_percentile.toFixed(2)}%`, | ||||||
|  |                 left: 'center', | ||||||
|  |                 top: 5 | ||||||
|  |             }, | ||||||
|  |             tooltip: { | ||||||
|  |                 trigger: 'axis', | ||||||
|  |                 axisPointer: { | ||||||
|  |                     type: 'cross', | ||||||
|  |                     label: { | ||||||
|  |                         backgroundColor: '#6a7985' | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 formatter: function(params) { | ||||||
|  |                     const date = params[0].axisValue; | ||||||
|  |                     let result = `${date}<br/>`; | ||||||
|  |                      | ||||||
|  |                     params.forEach(param => { | ||||||
|  |                         const value = param.value; | ||||||
|  |                         const color = param.color; | ||||||
|  |                         const marker = `<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`; | ||||||
|  |                          | ||||||
|  |                         result += `${marker}${param.seriesName}: ${value ? value.toFixed(2) : 'N/A'}%<br/>`; | ||||||
|  |                     }); | ||||||
|  |                      | ||||||
|  |                     return result; | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             legend: { | ||||||
|  |                 data: ['行业交易拥挤度历史百分位'], | ||||||
|  |                 top: 60 | ||||||
|  |             }, | ||||||
|  |             grid: { | ||||||
|  |                 left: '3%', | ||||||
|  |                 right: '4%', | ||||||
|  |                 bottom: '10%', | ||||||
|  |                 top: '100', | ||||||
|  |                 containLabel: true | ||||||
|  |             }, | ||||||
|  |             xAxis: { | ||||||
|  |                 type: 'category', | ||||||
|  |                 boundaryGap: false, | ||||||
|  |                 data: crowdingDates, | ||||||
|  |                 axisLabel: { | ||||||
|  |                     rotate: 45 | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             yAxis: { | ||||||
|  |                 type: 'value', | ||||||
|  |                 name: '历史百分位 (%)', | ||||||
|  |                 min: yMinValue, | ||||||
|  |                 max: yMaxValue, | ||||||
|  |                 axisLine: { | ||||||
|  |                     show: true | ||||||
|  |                 }, | ||||||
|  |                 axisLabel: { | ||||||
|  |                     formatter: function(value) { return value.toFixed(1) + '%'; } | ||||||
|  |                 }, | ||||||
|  |                 splitLine: { | ||||||
|  |                     show: true, | ||||||
|  |                     lineStyle: { | ||||||
|  |                         type: 'dashed' | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             dataZoom: [ | ||||||
|  |                 { | ||||||
|  |                     type: 'inside', | ||||||
|  |                     start: 0, | ||||||
|  |                     end: 100 | ||||||
|  |                 }, | ||||||
|  |                 { | ||||||
|  |                     start: 0, | ||||||
|  |                     end: 100 | ||||||
|  |                 } | ||||||
|  |             ], | ||||||
|  |             series: [crowdingSeries], | ||||||
|  |             visualMap: { | ||||||
|  |                 show: false, | ||||||
|  |                 dimension: 1, | ||||||
|  |                 pieces: [ | ||||||
|  |                     {min: 0, max: 20, color: '#91cc75'},  // 绿色 - 不拥挤
 | ||||||
|  |                     {min: 20, max: 40, color: '#91cc75'},  // 绿色 - 较不拥挤
 | ||||||
|  |                     {min: 40, max: 60, color: '#fac858'},  // 黄色 - 中性
 | ||||||
|  |                     {min: 60, max: 80, color: '#ee6666'},  // 红色 - 较为拥挤
 | ||||||
|  |                     {min: 80, max: 100, color: '#ee6666'}  // 红色 - 极度拥挤
 | ||||||
|  |                 ] | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // 设置图表选项
 | ||||||
|  |         crowdingChart.setOption(option); | ||||||
|  |          | ||||||
|  |         // 手动触发一次resize,确保图表正确渲染
 | ||||||
|  |         setTimeout(() => { | ||||||
|  |             crowdingChart.resize(); | ||||||
|  |         }, 50); | ||||||
|  |          | ||||||
|  |         // 响应式调整
 | ||||||
|  |         window.addEventListener('resize', function() { | ||||||
|  |             if (crowdingChart) { | ||||||
|  |                 crowdingChart.resize(); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 渲染分位数表格 | ||||||
|  |      */ | ||||||
|  |     function renderPercentileTable(percentiles, metricName) { | ||||||
|  |         // 清空表格
 | ||||||
|  |         percentileTable.innerHTML = ''; | ||||||
|  |          | ||||||
|  |         // 格式化函数
 | ||||||
|  |         const formatValue = value => value ? value.toFixed(2) : 'N/A'; | ||||||
|  |          | ||||||
|  |         // 获取百分位类别
 | ||||||
|  |         const getPercentileClass = value => { | ||||||
|  |             if (value <= 33) return 'percent-low'; | ||||||
|  |             if (value <= 66) return 'percent-medium'; | ||||||
|  |             return 'percent-high'; | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // 创建表格行
 | ||||||
|  |         const rows = [ | ||||||
|  |             {  | ||||||
|  |                 label: '当前行业均值',  | ||||||
|  |                 value: formatValue(percentiles.current), | ||||||
|  |                 class: 'current-value'  // 添加特殊样式类
 | ||||||
|  |             }, | ||||||
|  |             {  | ||||||
|  |                 label: '历史百分位',  | ||||||
|  |                 value: `${formatValue(percentiles.percentile)}%`, | ||||||
|  |                 class: getPercentileClass(percentiles.percentile) | ||||||
|  |             }, | ||||||
|  |             {  | ||||||
|  |                 label: '历史最小值',  | ||||||
|  |                 value: formatValue(percentiles.min), | ||||||
|  |                 class: 'percent-low'  // 绿色
 | ||||||
|  |             }, | ||||||
|  |             {  | ||||||
|  |                 label: '历史最大值',  | ||||||
|  |                 value: formatValue(percentiles.max), | ||||||
|  |                 class: 'percent-high'  // 红色
 | ||||||
|  |             }, | ||||||
|  |             {  | ||||||
|  |                 label: '历史均值',  | ||||||
|  |                 value: formatValue(percentiles.mean) | ||||||
|  |             }, | ||||||
|  |             {  | ||||||
|  |                 label: '历史中位数',  | ||||||
|  |                 value: formatValue(percentiles.median) | ||||||
|  |             }, | ||||||
|  |             {  | ||||||
|  |                 label: '历史第一四分位',  | ||||||
|  |                 value: formatValue(percentiles.q1), | ||||||
|  |                 class: 'percent-low'  // 绿色
 | ||||||
|  |             }, | ||||||
|  |             {  | ||||||
|  |                 label: '历史第三四分位',  | ||||||
|  |                 value: formatValue(percentiles.q3), | ||||||
|  |                 class: 'percent-high'  // 红色
 | ||||||
|  |             } | ||||||
|  |         ]; | ||||||
|  |          | ||||||
|  |         // 添加行到表格
 | ||||||
|  |         rows.forEach(row => { | ||||||
|  |             const tr = document.createElement('tr'); | ||||||
|  |              | ||||||
|  |             // 添加特殊类以突出当前值行
 | ||||||
|  |             if (row.label === '当前行业均值' || row.label === '历史百分位') { | ||||||
|  |                 tr.classList.add('highlight-row'); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             const tdLabel = document.createElement('td'); | ||||||
|  |             tdLabel.textContent = row.label; | ||||||
|  |             tr.appendChild(tdLabel); | ||||||
|  |              | ||||||
|  |             const tdValue = document.createElement('td'); | ||||||
|  |             tdValue.textContent = row.value; | ||||||
|  |              | ||||||
|  |             if (row.class) { | ||||||
|  |                 tdValue.classList.add('percent-value', row.class); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             tr.appendChild(tdValue); | ||||||
|  |             percentileTable.appendChild(tr); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 渲染行业统计数据 | ||||||
|  |      */ | ||||||
|  |     function renderIndustryStats(percentiles) { | ||||||
|  |         // 清空表格
 | ||||||
|  |         industryStatsTable.innerHTML = ''; | ||||||
|  |          | ||||||
|  |         // 获取用户选择的开始日期,如果没有选择则显示"最近三年"
 | ||||||
|  |         const userStartDate = startDateInput.value ? startDateInput.value : "最近三年"; | ||||||
|  |          | ||||||
|  |         // 创建表格行
 | ||||||
|  |         const rows = [ | ||||||
|  |             { label: '行业成份股数量', value: percentiles.stock_count + '只' }, | ||||||
|  |             { label: '分析开始日期', value: userStartDate } | ||||||
|  |         ]; | ||||||
|  |          | ||||||
|  |         // 添加行到表格
 | ||||||
|  |         rows.forEach(row => { | ||||||
|  |             const tr = document.createElement('tr'); | ||||||
|  |              | ||||||
|  |             const tdLabel = document.createElement('td'); | ||||||
|  |             tdLabel.textContent = row.label; | ||||||
|  |             tr.appendChild(tdLabel); | ||||||
|  |              | ||||||
|  |             const tdValue = document.createElement('td'); | ||||||
|  |             tdValue.textContent = row.value; | ||||||
|  |             tr.appendChild(tdValue); | ||||||
|  |              | ||||||
|  |             industryStatsTable.appendChild(tr); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 渲染拥挤度统计数据 | ||||||
|  |      */ | ||||||
|  |     function renderCrowdingStats(crowding) { | ||||||
|  |         // 清空表格
 | ||||||
|  |         crowdingTable.innerHTML = ''; | ||||||
|  |          | ||||||
|  |         // 获取拥挤度级别的颜色类
 | ||||||
|  |         const getCrowdingLevelClass = level => { | ||||||
|  |             switch(level) { | ||||||
|  |                 case '不拥挤': | ||||||
|  |                 case '较不拥挤': | ||||||
|  |                     return 'percent-low';  // 绿色
 | ||||||
|  |                 case '中性': | ||||||
|  |                     return 'percent-medium';  // 黄色
 | ||||||
|  |                 case '较为拥挤': | ||||||
|  |                 case '极度拥挤': | ||||||
|  |                     return 'percent-high';  // 红色
 | ||||||
|  |                 default: | ||||||
|  |                     return ''; | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |          | ||||||
|  |         // 创建表格行
 | ||||||
|  |         const rows = [ | ||||||
|  |             {  | ||||||
|  |                 label: '当前成交占比',  | ||||||
|  |                 value: crowding.current_ratio.toFixed(2) + '%', | ||||||
|  |                 tooltip: '行业成交额占全市场成交额的百分比' | ||||||
|  |             }, | ||||||
|  |             {  | ||||||
|  |                 label: '历史百分位',  | ||||||
|  |                 value: crowding.current_percentile.toFixed(2) + '%', | ||||||
|  |                 class: getCrowdingLevelClass(crowding.level), | ||||||
|  |                 tooltip: '当前行业成交占比在历史分布中的位置' | ||||||
|  |             }, | ||||||
|  |             {  | ||||||
|  |                 label: '拥挤度评级',  | ||||||
|  |                 value: crowding.level, | ||||||
|  |                 class: getCrowdingLevelClass(crowding.level), | ||||||
|  |                 tooltip: '根据历史百分位划分的拥挤度等级' | ||||||
|  |             } | ||||||
|  |         ]; | ||||||
|  |          | ||||||
|  |         // 添加行到表格
 | ||||||
|  |         rows.forEach(row => { | ||||||
|  |             const tr = document.createElement('tr'); | ||||||
|  |              | ||||||
|  |             const tdLabel = document.createElement('td'); | ||||||
|  |             tdLabel.textContent = row.label; | ||||||
|  |              | ||||||
|  |             // 添加工具提示
 | ||||||
|  |             if (row.tooltip) { | ||||||
|  |                 tdLabel.title = row.tooltip; | ||||||
|  |                 tdLabel.style.cursor = 'help'; | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             tr.appendChild(tdLabel); | ||||||
|  |              | ||||||
|  |             const tdValue = document.createElement('td'); | ||||||
|  |             tdValue.textContent = row.value; | ||||||
|  |              | ||||||
|  |             if (row.class) { | ||||||
|  |                 tdValue.classList.add('percent-value', row.class); | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             tr.appendChild(tdValue); | ||||||
|  |             crowdingTable.appendChild(tr); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 显示错误信息 | ||||||
|  |      */ | ||||||
|  |     function showError(message) { | ||||||
|  |         errorMessage.textContent = message; | ||||||
|  |         errorAlert.classList.remove('d-none'); | ||||||
|  |          | ||||||
|  |         // 滚动到错误信息
 | ||||||
|  |         errorAlert.scrollIntoView({ behavior: 'smooth', block: 'start' }); | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 重置表单 | ||||||
|  |      */ | ||||||
|  |     function resetForm() { | ||||||
|  |         industryForm.reset(); | ||||||
|  |          | ||||||
|  |         // 隐藏结果和错误信息
 | ||||||
|  |         resultCard.classList.add('d-none'); | ||||||
|  |         errorAlert.classList.add('d-none'); | ||||||
|  |          | ||||||
|  |         // 销毁图表
 | ||||||
|  |         if (valuationChart) { | ||||||
|  |             valuationChart.dispose(); | ||||||
|  |             valuationChart = null; | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         if (crowdingChart) { | ||||||
|  |             crowdingChart.dispose(); | ||||||
|  |             crowdingChart = null; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     /** | ||||||
|  |      * 显示/隐藏加载状态 | ||||||
|  |      */ | ||||||
|  |     function showLoading(isLoading) { | ||||||
|  |         if (isLoading) { | ||||||
|  |             loadingSpinner.classList.remove('d-none'); | ||||||
|  |             analyzeBtn.disabled = true; | ||||||
|  |         } else { | ||||||
|  |             loadingSpinner.classList.add('d-none'); | ||||||
|  |             analyzeBtn.disabled = false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | });  | ||||||
|  | @ -0,0 +1,170 @@ | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="zh-CN"> | ||||||
|  | <head> | ||||||
|  |     <meta charset="UTF-8"> | ||||||
|  |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|  |     <title>沪深港通资金流向监控</title> | ||||||
|  |     <!-- Bootstrap CSS --> | ||||||
|  |     <link rel="stylesheet" href="../static/css/bootstrap.min.css"> | ||||||
|  |     <!-- 自定义样式 --> | ||||||
|  |     <style> | ||||||
|  |         .card { | ||||||
|  |             margin-bottom: 20px; | ||||||
|  |             box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); | ||||||
|  |         } | ||||||
|  |         .card-header { | ||||||
|  |             background-color: #f8f9fa; | ||||||
|  |             font-weight: bold; | ||||||
|  |         } | ||||||
|  |         .money-inflow { | ||||||
|  |             color: #d9534f; | ||||||
|  |             font-weight: bold; | ||||||
|  |         } | ||||||
|  |         .money-outflow { | ||||||
|  |             color: #5cb85c; | ||||||
|  |             font-weight: bold; | ||||||
|  |         } | ||||||
|  |         .chart-container { | ||||||
|  |             height: 350px; | ||||||
|  |             margin-bottom: 20px; | ||||||
|  |         } | ||||||
|  |         .stat-card { | ||||||
|  |             text-align: center; | ||||||
|  |             padding: 10px; | ||||||
|  |             border-radius: 5px; | ||||||
|  |             margin-bottom: 10px; | ||||||
|  |         } | ||||||
|  |         .stat-value { | ||||||
|  |             font-size: 24px; | ||||||
|  |             font-weight: bold; | ||||||
|  |         } | ||||||
|  |         .stat-title { | ||||||
|  |             font-size: 14px; | ||||||
|  |             color: #666; | ||||||
|  |         } | ||||||
|  |         .refresh-btn { | ||||||
|  |             margin-left: 10px; | ||||||
|  |         } | ||||||
|  |         .update-time { | ||||||
|  |             font-size: 12px; | ||||||
|  |             color: #666; | ||||||
|  |             margin-top: 5px; | ||||||
|  |         } | ||||||
|  |         .flow-direction { | ||||||
|  |             font-size: 13px; | ||||||
|  |             color: #666; | ||||||
|  |             font-style: italic; | ||||||
|  |             margin-top: -5px; | ||||||
|  |             margin-bottom: 10px; | ||||||
|  |         } | ||||||
|  |     </style> | ||||||
|  | </head> | ||||||
|  | <body> | ||||||
|  |     <div class="container-fluid py-4"> | ||||||
|  |         <div class="row mb-4"> | ||||||
|  |             <div class="col-12"> | ||||||
|  |                 <h2 class="text-center">沪深港通资金流向监控 | ||||||
|  |                     <button id="refreshBtn" class="btn btn-sm btn-outline-primary refresh-btn"> | ||||||
|  |                         <i class="bi bi-arrow-clockwise"></i> 刷新数据 | ||||||
|  |                     </button> | ||||||
|  |                 </h2> | ||||||
|  |                 <p class="text-center update-time" id="updateTime"></p> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <!-- 北向资金卡片 --> | ||||||
|  |         <div class="row"> | ||||||
|  |             <div class="col-md-6"> | ||||||
|  |                 <div class="card"> | ||||||
|  |                     <div class="card-header"> | ||||||
|  |                         北向资金流向 (单位:亿元) | ||||||
|  |                     </div> | ||||||
|  |                     <div class="card-body"> | ||||||
|  |                         <p class="flow-direction text-center">提示:该走势图为陆股通指数成分股大单资金流向,非北向资金,但具备一定参考价值。</p> | ||||||
|  |                         <div class="row mb-3"> | ||||||
|  |                             <div class="col-md-4"> | ||||||
|  |                                 <div class="stat-card" id="northTotal"> | ||||||
|  |                                     <div class="stat-value">--</div> | ||||||
|  |                                     <div class="stat-title">北向资金净流入(亿元)</div> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                             <div class="col-md-4"> | ||||||
|  |                                 <div class="stat-card" id="northSH"> | ||||||
|  |                                     <div class="stat-value">--</div> | ||||||
|  |                                     <div class="stat-title">沪股通净流入(亿元)</div> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                             <div class="col-md-4"> | ||||||
|  |                                 <div class="stat-card" id="northSZ"> | ||||||
|  |                                     <div class="stat-value">--</div> | ||||||
|  |                                     <div class="stat-title">深股通净流入(亿元)</div> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                         </div> | ||||||
|  |                         <div id="northChart" class="chart-container"></div> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             <!-- 南向资金卡片 --> | ||||||
|  |             <div class="col-md-6"> | ||||||
|  |                 <div class="card"> | ||||||
|  |                     <div class="card-header"> | ||||||
|  |                         南向资金流向 (单位:亿元) | ||||||
|  |                     </div> | ||||||
|  |                     <div class="card-body"> | ||||||
|  |                         <p class="flow-direction text-center">从内地流入港股的资金</p> | ||||||
|  |                         <div class="row mb-3"> | ||||||
|  |                             <div class="col-md-4"> | ||||||
|  |                                 <div class="stat-card" id="southTotal"> | ||||||
|  |                                     <div class="stat-value">--</div> | ||||||
|  |                                     <div class="stat-title">南向资金净流入(亿元)</div> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                             <div class="col-md-4"> | ||||||
|  |                                 <div class="stat-card" id="southHKSH"> | ||||||
|  |                                     <div class="stat-value">--</div> | ||||||
|  |                                     <div class="stat-title">沪市港股通净流入(亿元)</div> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                             <div class="col-md-4"> | ||||||
|  |                                 <div class="stat-card" id="southHKSZ"> | ||||||
|  |                                     <div class="stat-value">--</div> | ||||||
|  |                                     <div class="stat-title">深市港股通净流入(亿元)</div> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                         </div> | ||||||
|  |                         <div id="southChart" class="chart-container"></div> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <!-- 说明信息 --> | ||||||
|  |         <div class="row mt-4"> | ||||||
|  |             <div class="col-12"> | ||||||
|  |                 <div class="card"> | ||||||
|  |                     <div class="card-header"> | ||||||
|  |                         数据说明 | ||||||
|  |                     </div> | ||||||
|  |                     <div class="card-body"> | ||||||
|  |                         <ul> | ||||||
|  |                             <li>数据来源:同花顺数据,每分钟更新</li> | ||||||
|  |                             <li><strong>北向资金</strong>:是指从<strong>香港</strong>流入<strong>A股</strong>的资金,通过沪股通和深股通进入</li> | ||||||
|  |                             <li><strong>南向资金</strong>:是指从<strong>内地</strong>流入<strong>港股</strong>的资金,通过沪市港股通和深市港股通进入</li> | ||||||
|  |                             <li>净流入为正表示买入大于卖出,资金流入(<span class="money-inflow">红色</span>);净流入为负表示卖出大于买入,资金流出(<span class="money-outflow">绿色</span>)</li> | ||||||
|  |                             <li>交易时间:北向9:30-11:30, 13:00-15:00;南向9:30-12:00, 13:00-16:00</li> | ||||||
|  |                         </ul> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <!-- JavaScript 库 --> | ||||||
|  |     <script src="../static/js/jquery.min.js"></script> | ||||||
|  |     <script src="../static/js/bootstrap.bundle.min.js"></script> | ||||||
|  |     <script src="../static/js/echarts.min.js"></script> | ||||||
|  |     <script src="../static/js/hsgt_monitor.js"></script> | ||||||
|  | </body> | ||||||
|  | </html>  | ||||||
|  | @ -0,0 +1,272 @@ | ||||||
|  | #!/usr/bin/env python | ||||||
|  | # -*- coding: utf-8 -*- | ||||||
|  | 
 | ||||||
|  | import requests | ||||||
|  | import json | ||||||
|  | from datetime import datetime, timedelta | ||||||
|  | import logging | ||||||
|  | import pandas as pd | ||||||
|  | import time | ||||||
|  | 
 | ||||||
|  | logger = logging.getLogger(__name__) | ||||||
|  | 
 | ||||||
|  | class HSGTMonitor: | ||||||
|  |     """沪深港通资金流向监控类""" | ||||||
|  |      | ||||||
|  |     def __init__(self): | ||||||
|  |         # 初始化请求头 | ||||||
|  |         self.headers = { | ||||||
|  |             "Accept": "application/json, text/plain, */*", | ||||||
|  |             "Accept-Encoding": "gzip, deflate, br, zstd", | ||||||
|  |             "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", | ||||||
|  |             "Connection": "keep-alive", | ||||||
|  |             "Content-Type": "application/json;charset=UTF-8", | ||||||
|  |             "Host": "dataq.10jqka.com.cn", | ||||||
|  |             "Origin": "https://data.10jqka.com.cn", | ||||||
|  |             "Platform": "web", | ||||||
|  |             "Referer": "https://data.10jqka.com.cn/", | ||||||
|  |             "Source-Id": "b2cweb-hsgtconnect", | ||||||
|  |             "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", | ||||||
|  |             "sec-ch-ua": "\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"", | ||||||
|  |             "sec-ch-ua-mobile": "?0", | ||||||
|  |             "sec-ch-ua-platform": "\"Windows\"" | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         # 请求URL | ||||||
|  |         self.url = "https://dataq.10jqka.com.cn/fetch-data-server/fetch/v1/interval_data" | ||||||
|  |          | ||||||
|  |         # 北向资金代码映射 (从香港流入A股的资金) | ||||||
|  |         self.northbound_codes = { | ||||||
|  |             "total": "48:883957",     # 陆股通整体(北向资金总和) | ||||||
|  |             "sh": "16:1A0001",        # 沪股通 | ||||||
|  |             "sz": "32:399001"         # 深股通 | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         # 南向资金代码映射 (从内地流入港股的资金) | ||||||
|  |         self.southbound_codes = { | ||||||
|  |             "total": "48:883957",    # 港股通整体(南向资金总和) | ||||||
|  |             "hk_sh": "16:1A0001",    # 沪市港股通 | ||||||
|  |             "hk_sz": "32:399001"     # 深市港股通 | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         # 北向和南向资金的API参数 | ||||||
|  |         self.index_ids = { | ||||||
|  |             "northbound": "hsgt_main_money",  # 北向资金 | ||||||
|  |             "southbound": "ggt_net_buy"       # 南向资金 | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |     def get_trading_hours_timestamp(self, date=None): | ||||||
|  |         """获取指定日期的交易时段时间戳 | ||||||
|  |          | ||||||
|  |         交易时段: 9:30-11:30, 13:00-15:00 | ||||||
|  |         为了完整捕捉开盘前的资金流向,开始时间设为9:20 | ||||||
|  |         """ | ||||||
|  |         if date is None: | ||||||
|  |             date = datetime.now().date() | ||||||
|  |         elif isinstance(date, str): | ||||||
|  |             date = datetime.strptime(date, "%Y-%m-%d").date() | ||||||
|  |              | ||||||
|  |         # 创建当天交易开始和结束时间 | ||||||
|  |         morning_start = datetime.combine(date, datetime.strptime("09:20", "%H:%M").time()) | ||||||
|  |         trading_end = datetime.combine(date, datetime.strptime("15:00", "%H:%M").time()) | ||||||
|  |          | ||||||
|  |         # 检查当前是否已经过了交易结束时间 | ||||||
|  |         current_time = datetime.now() | ||||||
|  |         if current_time > trading_end: | ||||||
|  |             end_time = trading_end | ||||||
|  |         else: | ||||||
|  |             end_time = current_time | ||||||
|  |              | ||||||
|  |         # 转换为时间戳(秒) | ||||||
|  |         start_timestamp = int(morning_start.timestamp()) | ||||||
|  |         end_timestamp = int(end_time.timestamp()) | ||||||
|  |          | ||||||
|  |         return start_timestamp, end_timestamp | ||||||
|  |      | ||||||
|  |     def fetch_northbound_data(self, start_timestamp=None, end_timestamp=None): | ||||||
|  |         """获取北向资金流向数据 (从香港流入A股的资金)""" | ||||||
|  |         return self._fetch_hsgt_data( | ||||||
|  |             "northbound",  | ||||||
|  |             self.northbound_codes,  | ||||||
|  |             self.index_ids["northbound"],  | ||||||
|  |             start_timestamp,  | ||||||
|  |             end_timestamp | ||||||
|  |         ) | ||||||
|  |      | ||||||
|  |     def fetch_southbound_data(self, start_timestamp=None, end_timestamp=None): | ||||||
|  |         """获取南向资金流向数据 (从内地流入港股的资金)""" | ||||||
|  |         return self._fetch_hsgt_data( | ||||||
|  |             "southbound",  | ||||||
|  |             self.southbound_codes,  | ||||||
|  |             self.index_ids["southbound"],  | ||||||
|  |             start_timestamp,  | ||||||
|  |             end_timestamp | ||||||
|  |         ) | ||||||
|  |      | ||||||
|  |     def _fetch_hsgt_data(self, flow_type, code_map, index_id, start_timestamp=None, end_timestamp=None): | ||||||
|  |         """通用的沪深港通资金流向数据获取方法 | ||||||
|  |          | ||||||
|  |         参数: | ||||||
|  |             flow_type: 资金流向类型,"northbound" 或 "southbound" | ||||||
|  |             code_map: 对应的代码映射 | ||||||
|  |             index_id: API的index_id参数 | ||||||
|  |             start_timestamp: 开始时间戳 | ||||||
|  |             end_timestamp: 结束时间戳 | ||||||
|  |         """ | ||||||
|  |         # 如果没有提供时间戳,使用当天的交易时段 | ||||||
|  |         if start_timestamp is None or end_timestamp is None: | ||||||
|  |             start_timestamp, end_timestamp = self.get_trading_hours_timestamp() | ||||||
|  |          | ||||||
|  |         # 获取代码列表 | ||||||
|  |         if flow_type == "northbound": | ||||||
|  |             codes = [ | ||||||
|  |                 code_map["total"], | ||||||
|  |                 code_map["sh"], | ||||||
|  |                 code_map["sz"] | ||||||
|  |             ] | ||||||
|  |         else:  # southbound | ||||||
|  |             codes = [ | ||||||
|  |                 code_map["total"], | ||||||
|  |                 code_map["hk_sh"], | ||||||
|  |                 code_map["hk_sz"] | ||||||
|  |             ] | ||||||
|  |              | ||||||
|  |         # 构建请求体 | ||||||
|  |         payload = { | ||||||
|  |             "indexes": [{ | ||||||
|  |                 "codes": codes, | ||||||
|  |                 "index_info": [{ | ||||||
|  |                     "index_id": index_id | ||||||
|  |                 }] | ||||||
|  |             }], | ||||||
|  |             "time_range": { | ||||||
|  |                 "time_type": "TREND", | ||||||
|  |                 "start": str(start_timestamp), | ||||||
|  |                 "end": str(end_timestamp) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         logger.info(f"请求{flow_type}资金数据: start={start_timestamp}, end={end_timestamp}, index_id={index_id}") | ||||||
|  |          | ||||||
|  |         try: | ||||||
|  |             # 发送请求 | ||||||
|  |             response = requests.post(self.url, headers=self.headers, json=payload) | ||||||
|  |             response.raise_for_status() | ||||||
|  |              | ||||||
|  |             # 解析响应 | ||||||
|  |             result = response.json() | ||||||
|  |              | ||||||
|  |             if result["status_code"] == 0 and "data" in result: | ||||||
|  |                 # 提取时间和各通道的数据 | ||||||
|  |                 time_range = [int(ts) for ts in result["data"]["time_range"]] | ||||||
|  |                 time_formatted = [datetime.fromtimestamp(ts).strftime('%H:%M') for ts in time_range] | ||||||
|  |                  | ||||||
|  |                 # 提取各通道的数据 | ||||||
|  |                 data_dict = {} | ||||||
|  |                  | ||||||
|  |                 # 检查是否有数据 | ||||||
|  |                 if "data" in result["data"] and result["data"]["data"]: | ||||||
|  |                     for item in result["data"]["data"]: | ||||||
|  |                         code = item["code"] | ||||||
|  |                         # 增加安全检查,确保values列表存在且不为空 | ||||||
|  |                         if "values" in item and len(item["values"]) > 0 and "values" in item["values"][0]: | ||||||
|  |                             # 北向资金 | ||||||
|  |                             if flow_type == "northbound": | ||||||
|  |                                 if code == code_map["total"]: | ||||||
|  |                                     data_dict["total"] = item["values"][0]["values"] | ||||||
|  |                                 elif code == code_map["sh"]: | ||||||
|  |                                     data_dict["sh"] = item["values"][0]["values"] | ||||||
|  |                                 elif code == code_map["sz"]: | ||||||
|  |                                     data_dict["sz"] = item["values"][0]["values"] | ||||||
|  |                             # 南向资金 | ||||||
|  |                             else: | ||||||
|  |                                 if code == code_map["total"]: | ||||||
|  |                                     data_dict["total"] = item["values"][0]["values"] | ||||||
|  |                                 elif code == code_map["hk_sh"]: | ||||||
|  |                                     data_dict["hk_sh"] = item["values"][0]["values"] | ||||||
|  |                                 elif code == code_map["hk_sz"]: | ||||||
|  |                                     data_dict["hk_sz"] = item["values"][0]["values"] | ||||||
|  |                  | ||||||
|  |                 # 检查是否获取到了任何数据 | ||||||
|  |                 if not data_dict: | ||||||
|  |                     logger.warning(f"{flow_type}资金数据响应中没有有效数据") | ||||||
|  |                     return { | ||||||
|  |                         "success": False, | ||||||
|  |                         "message": f"{flow_type}资金数据响应中没有有效数据" | ||||||
|  |                     } | ||||||
|  |                  | ||||||
|  |                 # 计算当前值(增加更多的安全检查) | ||||||
|  |                 current_values = {} | ||||||
|  |                  | ||||||
|  |                 # 安全地获取当前值 | ||||||
|  |                 for key in data_dict: | ||||||
|  |                     if data_dict[key] and len(data_dict[key]) > 0:  # 确保数据列表不为空 | ||||||
|  |                         value = data_dict[key][-1] | ||||||
|  |                         # 确保值不是None | ||||||
|  |                         if value is not None: | ||||||
|  |                             current_values[key] = value / 100000000 | ||||||
|  |                         else: | ||||||
|  |                             current_values[key] = 0 | ||||||
|  |                             logger.warning(f"{flow_type}资金{key}最后一个值为None") | ||||||
|  |                     else: | ||||||
|  |                         current_values[key] = 0 | ||||||
|  |                         logger.warning(f"{flow_type}资金{key}数据为空") | ||||||
|  |                  | ||||||
|  |                 # 转换为亿元单位,处理None值 | ||||||
|  |                 for key in data_dict: | ||||||
|  |                     if data_dict[key]:  # 确保数据列表不为空 | ||||||
|  |                         # 安全转换,处理None值 | ||||||
|  |                         data_dict[key] = [ | ||||||
|  |                             (value / 100000000 if value is not None else 0)  | ||||||
|  |                             for value in data_dict[key] | ||||||
|  |                         ] | ||||||
|  |                  | ||||||
|  |                 return { | ||||||
|  |                     "success": True, | ||||||
|  |                     "timestamps": time_range, | ||||||
|  |                     "times": time_formatted, | ||||||
|  |                     "data": data_dict, | ||||||
|  |                     "current": current_values, | ||||||
|  |                     "flow_type": flow_type, | ||||||
|  |                     "update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") | ||||||
|  |                 } | ||||||
|  |             else: | ||||||
|  |                 error_msg = result.get("status_msg", "Unknown error") | ||||||
|  |                 logger.error(f"请求{flow_type}资金数据失败: {error_msg}") | ||||||
|  |                 return { | ||||||
|  |                     "success": False, | ||||||
|  |                     "message": f"返回错误: {error_msg}" | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|  |         except requests.exceptions.RequestException as e: | ||||||
|  |             logger.error(f"请求{flow_type}资金数据异常: {str(e)}") | ||||||
|  |             return { | ||||||
|  |                 "success": False, | ||||||
|  |                 "message": f"请求异常: {str(e)}" | ||||||
|  |             } | ||||||
|  |         except Exception as e: | ||||||
|  |             logger.error(f"处理{flow_type}资金数据异常: {str(e)}") | ||||||
|  |             return { | ||||||
|  |                 "success": False, | ||||||
|  |                 "message": f"处理异常: {str(e)}" | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  | # 测试代码 | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     monitor = HSGTMonitor() | ||||||
|  |      | ||||||
|  |     # 获取北向资金数据 | ||||||
|  |     try: | ||||||
|  |         north_data = monitor.fetch_northbound_data() | ||||||
|  |         print("北向资金数据:") | ||||||
|  |         print(json.dumps(north_data, ensure_ascii=False, indent=2)) | ||||||
|  |     except Exception as e: | ||||||
|  |         print(f"测试北向资金数据获取失败: {str(e)}") | ||||||
|  |      | ||||||
|  |     # 获取南向资金数据 | ||||||
|  |     try: | ||||||
|  |         south_data = monitor.fetch_southbound_data() | ||||||
|  |         print("\n南向资金数据:") | ||||||
|  |         print(json.dumps(south_data, ensure_ascii=False, indent=2)) | ||||||
|  |     except Exception as e: | ||||||
|  |         print(f"测试南向资金数据获取失败: {str(e)}")  | ||||||
		Loading…
	
		Reference in New Issue