diff --git a/src/app.py b/src/app.py
index ed3ae94..26e819c 100644
--- a/src/app.py
+++ b/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.hsgt_monitor import HSGTMonitor
+
# 设置日志
logging.basicConfig(
level=logging.INFO,
@@ -59,6 +62,9 @@ valuation_analyzer = ValuationAnalyzer()
# 创建行业分析器实例
industry_analyzer = IndustryAnalyzer()
+# 创建监控器实例
+hsgt_monitor = HSGTMonitor()
+
# 获取项目根目录
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REPORTS_DIR = os.path.join(ROOT_DIR, 'src', 'reports')
@@ -1770,5 +1776,168 @@ def industry_page():
"""渲染行业分析页面"""
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__':
app.run(host='0.0.0.0', port=5000, debug=True)
\ No newline at end of file
diff --git a/src/static/js/hsgt_monitor.js b/src/static/js/hsgt_monitor.js
new file mode 100644
index 0000000..f24daf7
--- /dev/null
+++ b/src/static/js/hsgt_monitor.js
@@ -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}
`;
+
+ params.forEach(param => {
+ if (param && param.value !== undefined) {
+ const value = param.value;
+ const color = param.color;
+ const marker = ``;
+ let valueText = value !== null ? value.toFixed(2) : 'N/A';
+ if (value > 0) valueText = '+' + valueText;
+ result += `${marker}${param.seriesName}: ${valueText} 亿
`;
+ }
+ });
+
+ 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}
`;
+
+ params.forEach(param => {
+ if (param && param.value !== undefined) {
+ const value = param.value;
+ const color = param.color;
+ const marker = ``;
+ let valueText = value !== null ? value.toFixed(2) : 'N/A';
+ if (value > 0) valueText = '+' + valueText;
+ result += `${marker}${param.seriesName}: ${valueText} 亿
`;
+ }
+ });
+
+ 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();
+ }
+ }
+});
\ No newline at end of file
diff --git a/src/static/js/industry.js b/src/static/js/industry.js
new file mode 100644
index 0000000..cb7cd9e
--- /dev/null
+++ b/src/static/js/industry.js
@@ -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 = '';
+
+ // 排序行业列表(按名称)
+ 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}
`;
+
+ params.forEach(param => {
+ const value = param.value;
+ const color = param.color;
+ const marker = ``;
+ const name = param.seriesName;
+
+ if (name.includes('历史')) {
+ // 对于历史统计线,使用固定值显示
+ result += `${marker}${name}: ${value !== null ? value.toFixed(2) : 'N/A'}
`;
+ } else {
+ // 对于当前值,显示更多小数位
+ result += `${marker}${name}: ${value !== null ? value.toFixed(2) : 'N/A'}
`;
+ }
+ });
+
+ 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}
`;
+
+ params.forEach(param => {
+ const value = param.value;
+ const color = param.color;
+ const marker = ``;
+
+ result += `${marker}${param.seriesName}: ${value ? value.toFixed(2) : 'N/A'}%
`;
+ });
+
+ 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;
+ }
+ }
+});
\ No newline at end of file
diff --git a/src/templates/hsgt_monitor.html b/src/templates/hsgt_monitor.html
new file mode 100644
index 0000000..3e3e250
--- /dev/null
+++ b/src/templates/hsgt_monitor.html
@@ -0,0 +1,170 @@
+
+
+
提示:该走势图为陆股通指数成分股大单资金流向,非北向资金,但具备一定参考价值。
+从内地流入港股的资金
+