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 @@ + + + + + + 沪深港通资金流向监控 + + + + + + +
+
+
+

沪深港通资金流向监控 + +

+

+
+
+ + +
+
+
+
+ 北向资金流向 (单位:亿元) +
+
+

提示:该走势图为陆股通指数成分股大单资金流向,非北向资金,但具备一定参考价值。

+
+
+
+
--
+
北向资金净流入(亿元)
+
+
+
+
+
--
+
沪股通净流入(亿元)
+
+
+
+
+
--
+
深股通净流入(亿元)
+
+
+
+
+
+
+
+ + +
+
+
+ 南向资金流向 (单位:亿元) +
+
+

从内地流入港股的资金

+
+
+
+
--
+
南向资金净流入(亿元)
+
+
+
+
+
--
+
沪市港股通净流入(亿元)
+
+
+
+
+
--
+
深市港股通净流入(亿元)
+
+
+
+
+
+
+
+
+ + +
+
+
+
+ 数据说明 +
+
+
    +
  • 数据来源:同花顺数据,每分钟更新
  • +
  • 北向资金:是指从香港流入A股的资金,通过沪股通和深股通进入
  • +
  • 南向资金:是指从内地流入港股的资金,通过沪市港股通和深市港股通进入
  • +
  • 净流入为正表示买入大于卖出,资金流入(红色);净流入为负表示卖出大于买入,资金流出(绿色
  • +
  • 交易时间:北向9:30-11:30, 13:00-15:00;南向9:30-12:00, 13:00-16:00
  • +
+
+
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/src/valuation_analysis/hsgt_monitor.py b/src/valuation_analysis/hsgt_monitor.py new file mode 100644 index 0000000..c7faf22 --- /dev/null +++ b/src/valuation_analysis/hsgt_monitor.py @@ -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)}") \ No newline at end of file