stock_fundamentals/src/static/js/industry.js

765 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 行业估值分析工具前端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;
}
}
});