| {% extends "layout.html" %}
|
|
|
| {% block title %}股票详情 - {{ stock_code }} - 智能分析系统{% endblock %}
|
|
|
| {% block content %}
|
| <div class="container-fluid py-1" style="padding-top: 0.2rem !important; padding-bottom: 0.2rem !important;">
|
|
|
| <div id="alerts-container"></div>
|
|
|
|
|
| <div class="d-flex justify-content-between align-items-center mb-1" style="margin-top: 0.2rem; margin-bottom: 0.2rem;">
|
| <h4 id="stock-title" class="mb-0 fw-bold" style="font-size: 1.1rem; line-height: 1.2;">股票详情加载中...</h4>
|
| <div class="d-flex align-items-center">
|
| <select class="form-select form-select-sm me-2" id="market-type" style="max-width: 100px; height: 32px; padding-top: 2px; padding-bottom: 2px;">
|
| <option value="A" {% if market_type == 'A' %}selected{% endif %}>A股</option>
|
| <option value="HK" {% if market_type == 'HK' %}selected{% endif %}>港股</option>
|
| <option value="US" {% if market_type == 'US' %}selected{% endif %}>美股</option>
|
| </select>
|
| <select class="form-select form-select-sm me-2" id="analysis-period" style="max-width: 100px; height: 32px; padding-top: 2px; padding-bottom: 2px;">
|
| <option value="1m">1个月</option>
|
| <option value="3m">3个月</option>
|
| <option value="6m">6个月</option>
|
| <option value="1y" selected>1年</option>
|
| </select>
|
| <button id="refresh-btn" class="btn btn-primary btn-sm" style="height: 32px; padding: 2px 8px;">
|
| <i class="fas fa-sync-alt"></i>
|
| </button>
|
| </div>
|
| </div>
|
|
|
| <div id="loading-panel" class="text-center py-5">
|
| <div class="spinner-border text-primary" role="status">
|
| <span class="visually-hidden">Loading...</span>
|
| </div>
|
| <p class="mt-3 mb-0">正在加载股票数据和分析结果...</p>
|
| <p class="text-muted small mt-2">
|
| <i class="fas fa-info-circle"></i>
|
| AI分析需要30-300秒,已处理<span id="processing-time">0</span>秒
|
| </p>
|
| <div class="progress mt-3" style="height: 5px;">
|
| <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%"></div>
|
| </div>
|
| <button id="cancel-analysis-btn" class="btn btn-outline-secondary mt-3">
|
| <i class="fas fa-times"></i> 取消分析
|
| </button>
|
| </div>
|
|
|
| <div id="error-retry" class="text-center mt-3" style="display: none;">
|
| <button id="retry-button" class="btn btn-primary mt-2">
|
| <i class="fas fa-sync-alt"></i> 重试分析
|
| </button>
|
| <p class="text-muted small mt-2">
|
| 如果重试失败,请访问<a href="/dashboard">仪表盘</a>尝试其他股票
|
| </p>
|
| </div>
|
|
|
| <div id="analysis-result" style="display: none;">
|
| <div class="row g-3 mb-3">
|
| <div class="col-md-6">
|
| <div class="card h-100">
|
| <div class="card-header py-2">
|
| <h5 class="mb-0">股票概要</h5>
|
| </div>
|
| <div class="card-body">
|
| <div class="row mb-3">
|
| <div class="col-md-7">
|
| <h3 id="stock-name" class="mb-0 fs-4"></h3>
|
| <p id="stock-info" class="text-muted mb-0 small"></p>
|
| </div>
|
| <div class="col-md-5 text-end">
|
| <h2 id="stock-price" class="mb-0 fs-4"></h2>
|
| <p id="price-change" class="mb-0"></p>
|
| </div>
|
| </div>
|
| <div class="row">
|
| <div class="col-md-6">
|
| <div class="mb-2">
|
| <span class="text-muted small">综合评分:</span>
|
| <div class="mt-1">
|
| <span id="total-score" class="badge rounded-pill score-pill"></span>
|
| </div>
|
| </div>
|
| <div class="mb-2">
|
| <span class="text-muted small">投资建议:</span>
|
| <p id="recommendation" class="mb-0 text-strong"></p>
|
| </div>
|
| </div>
|
| <div class="col-md-6">
|
| <div class="mb-2">
|
| <span class="text-muted small">技术面指标:</span>
|
| <ul class="list-unstyled mt-1 mb-0 small">
|
| <li><span class="text-muted">RSI:</span> <span id="rsi-value"></span></li>
|
| <li><span class="text-muted">MA趋势:</span> <span id="ma-trend"></span></li>
|
| <li><span class="text-muted">MACD信号:</span> <span id="macd-signal"></span></li>
|
| <li><span class="text-muted">成交量:</span> <span id="volume-status"></span></li>
|
| </ul>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| <div class="col-md-6">
|
| <div class="card h-100">
|
| <div class="card-header py-2">
|
| <h5 class="mb-0">多维度评分</h5>
|
| </div>
|
| <div class="card-body">
|
| <div class="row">
|
| <div class="col-md-6">
|
| <div id="score-chart" style="height: 180px;"></div>
|
| </div>
|
| <div class="col-md-6">
|
| <div id="radar-chart" style="height: 180px;"></div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
|
|
| <div class="row g-3 mb-3">
|
| <div class="col-12">
|
| <div class="card">
|
| <div class="card-header py-2">
|
| <h5 class="mb-0">价格与技术指标</h5>
|
| </div>
|
| <div class="card-body p-0">
|
| <div id="price-chart" style="height: 400px;"></div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
|
|
| <div class="row g-3 mb-3">
|
| <div class="col-12">
|
| <div class="card">
|
| <div class="card-header py-2">
|
| <h5 class="mb-0">MACD & RSI 指标</h5>
|
| </div>
|
| <div class="card-body p-0">
|
| <div id="indicators-chart" style="height: 350px;"></div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
|
|
| <div class="row g-3 mb-3">
|
| <div class="col-md-4">
|
| <div class="card h-100">
|
| <div class="card-header py-2">
|
| <h5 class="mb-0">支撑与压力位</h5>
|
| </div>
|
| <div class="card-body">
|
| <table class="table table-sm">
|
| <thead>
|
| <tr>
|
| <th>类型</th>
|
| <th>价格</th>
|
| <th>距离</th>
|
| </tr>
|
| </thead>
|
| <tbody id="support-resistance-table">
|
|
|
| </tbody>
|
| </table>
|
| </div>
|
| </div>
|
| </div>
|
| <div class="col-md-8">
|
| <div class="card h-100">
|
| <div class="card-header py-2">
|
| <h5 class="mb-0">AI分析建议</h5>
|
| </div>
|
| <div class="card-body">
|
| <div id="ai-analysis" class="analysis-section">
|
|
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| </div>
|
| {% endblock %}
|
|
|
| {% block scripts %}
|
| <script>
|
| const stockCode = '{{ stock_code }}';
|
| let marketType = '{{ market_type }}';
|
| let period = '1y';
|
| let stockData = [];
|
| let analysisResult = null;
|
|
|
| $(document).ready(function() {
|
|
|
| loadStockData();
|
|
|
|
|
| $('#refresh-btn').click(function() {
|
| marketType = $('#market-type').val();
|
| period = $('#analysis-period').val();
|
| loadStockData();
|
| });
|
|
|
|
|
| $('#market-type').change(function() {
|
| marketType = $(this).val();
|
| });
|
|
|
|
|
| $('#analysis-period').change(function() {
|
| period = $(this).val();
|
| });
|
| });
|
|
|
|
|
| function loadStockData() {
|
| $('#loading-panel').show();
|
| $('#analysis-result').hide();
|
|
|
|
|
| $.ajax({
|
| url: `/api/stock_data?stock_code=${stockCode}&market_type=${marketType}&period=${period}`,
|
| type: 'GET',
|
| dataType: 'json',
|
| success: function(response) {
|
|
|
|
|
| if (!response.data) {
|
| $('#loading-panel').hide();
|
| showError('响应格式不正确: 缺少data字段');
|
| return;
|
| }
|
|
|
| if (response.data.length === 0) {
|
| $('#loading-panel').hide();
|
| showError('未找到股票数据');
|
| return;
|
| }
|
|
|
| stockData = response.data;
|
|
|
|
|
| loadAnalysisResult();
|
| },
|
| error: function(xhr, status, error) {
|
| $('#loading-panel').hide();
|
|
|
| let errorMsg = '获取股票数据失败';
|
| if (xhr.responseJSON && xhr.responseJSON.error) {
|
| errorMsg += ': ' + xhr.responseJSON.error;
|
| } else if (error) {
|
| errorMsg += ': ' + error;
|
| }
|
| showError(errorMsg);
|
| }
|
| });
|
| }
|
|
|
|
|
| function loadAnalysisResult() {
|
|
|
|
|
| $('#loading-panel').show();
|
| $('#analysis-result').hide();
|
| $('#error-retry').hide();
|
|
|
|
|
| let processingTime = 0;
|
| const processingTimer = setInterval(function() {
|
| processingTime++;
|
| $('#processing-time').text(processingTime);
|
| }, 1000);
|
|
|
|
|
| $.ajax({
|
| url: '/api/start_stock_analysis',
|
| type: 'POST',
|
| contentType: 'application/json',
|
| data: JSON.stringify({
|
| stock_code: stockCode,
|
| market_type: marketType
|
| }),
|
| success: function(response) {
|
|
|
|
|
| if (response.status === 'completed' && response.result) {
|
|
|
| handleAnalysisResult(response.result);
|
| clearInterval(processingTimer);
|
| return;
|
| }
|
|
|
|
|
| pollAnalysisStatus(response.task_id, processingTime, processingTimer);
|
| },
|
| error: function(xhr, status, error) {
|
| clearInterval(processingTimer);
|
| handleAnalysisError(xhr, status, error);
|
| }
|
| });
|
| }
|
|
|
|
|
| function pollAnalysisStatus(taskId, startTime, timerInterval) {
|
| let elapsedTime = startTime || 0;
|
| let pollInterval;
|
|
|
|
|
| window.currentAnalysisTaskId = taskId;
|
|
|
|
|
| checkStatus();
|
|
|
| function checkStatus() {
|
| $.ajax({
|
| url: `/api/analysis_status/${taskId}`,
|
| type: 'GET',
|
| success: function(response) {
|
|
|
| elapsedTime = startTime + 1;
|
| const progress = response.progress || 0;
|
|
|
|
|
| $('#processing-time').text(elapsedTime);
|
|
|
|
|
| if (response.status === 'completed') {
|
|
|
| clearInterval(pollInterval);
|
| clearInterval(timerInterval);
|
|
|
|
|
| handleAnalysisResult(response.result);
|
| } else if (response.status === 'failed') {
|
|
|
| clearInterval(pollInterval);
|
| clearInterval(timerInterval);
|
|
|
| $('#loading-panel').hide();
|
|
|
| showError('分析失败: ' + (response.error || '未知错误'));
|
| $('#error-retry').show();
|
| } else {
|
|
|
| if (!pollInterval) {
|
| pollInterval = setInterval(checkStatus, 2000);
|
| }
|
| }
|
| },
|
| error: function(xhr, status, error) {
|
| if (!pollInterval) {
|
| pollInterval = setInterval(checkStatus, 3000);
|
| }
|
| }
|
| });
|
| }
|
| }
|
|
|
|
|
| function handleAnalysisResult(result) {
|
| try {
|
|
|
| analysisResult = result;
|
|
|
|
|
| renderAnalysisResult();
|
|
|
|
|
| $('#loading-panel').hide();
|
| $('#analysis-result').show();
|
| } catch (error) {
|
| $('#loading-panel').hide();
|
| showError('处理分析结果时出错: ' + error.message);
|
| }
|
| }
|
|
|
|
|
| function handleAnalysisError(xhr, status, error) {
|
| $('#loading-panel').hide();
|
|
|
| let errorMsg = '获取分析数据失败';
|
| if (status === 'timeout') {
|
| errorMsg = '请求超时,分析可能需要较长时间,请稍后再试';
|
| } else if (xhr.status === 524 || xhr.status === 504) {
|
| errorMsg = '请求超时,服务器处理时间过长';
|
| } else if (xhr.responseJSON && xhr.responseJSON.error) {
|
| errorMsg += ': ' + xhr.responseJSON.error;
|
| } else if (error) {
|
| errorMsg += ': ' + error;
|
| }
|
|
|
| showError(errorMsg);
|
| $('#error-retry').show();
|
| }
|
|
|
|
|
| $('#cancel-analysis-btn').click(function() {
|
| if (window.currentAnalysisTaskId) {
|
| $.ajax({
|
| url: `/api/cancel_analysis/${window.currentAnalysisTaskId}`,
|
| type: 'POST',
|
| success: function(response) {
|
| $('#loading-panel').hide();
|
| showInfo('分析已取消');
|
| },
|
| error: function(error) {
|
| console.error('取消分析失败:', error);
|
| }
|
| });
|
| } else {
|
| $('#loading-panel').hide();
|
| }
|
| });
|
|
|
|
|
| $('#retry-button').click(function() {
|
| $('#error-retry').hide();
|
| loadAnalysisResult();
|
| });
|
|
|
|
|
| function safeFormat(value, decimals=2) {
|
| try {
|
|
|
| if (value && typeof value === 'object') {
|
| if (value._dtype === 'float64') {
|
| return parseFloat(value._values[0]).toFixed(decimals);
|
| }
|
| if (value._dtype === 'int64') {
|
| return parseInt(value._values[0]);
|
| }
|
| }
|
| return parseFloat(value).toFixed(decimals);
|
| } catch (e) {
|
| console.error('Format error:', e);
|
| return '--';
|
| }
|
| }
|
|
|
|
|
| $('#rsi-value').text(safeFormat(analysisResult.technical_analysis.indicators.rsi));
|
|
|
|
|
| function renderAnalysisResult() {
|
| if (!analysisResult) {
|
| showError("分析结果为空");
|
| return;
|
| }
|
|
|
| try {
|
|
|
|
|
| function safeGet(obj, path, defaultValue) {
|
| if (!obj) return defaultValue;
|
|
|
| const props = path.split('.');
|
| let current = obj;
|
|
|
| for (let i = 0; i < props.length; i++) {
|
| if (current === undefined || current === null) {
|
| console.warn(`属性路径 ${path} 在 ${props.slice(0, i).join('.')} 处中断`);
|
| return defaultValue;
|
| }
|
| current = current[props[i]];
|
| }
|
|
|
| return current !== undefined && current !== null ? current : defaultValue;
|
| }
|
|
|
|
|
| if (!analysisResult.technical_analysis) {
|
| analysisResult.technical_analysis = {
|
| trend: {ma_trend: 'UNKNOWN', ma_status: '未知', ma_values: {}},
|
| indicators: {rsi: 50, macd: 0, macd_signal: 0, macd_histogram: 0, volatility: 0},
|
| volume: {current_volume: 0, volume_ratio: 0, volume_status: 'NORMAL'},
|
| support_resistance: {support_levels: {}, resistance_levels: {}}
|
| };
|
| }
|
|
|
|
|
| const stockName = safeGet(analysisResult, 'basic_info.stock_name', '未知');
|
| const stockCode = safeGet(analysisResult, 'basic_info.stock_code', '未知');
|
| const industry = safeGet(analysisResult, 'basic_info.industry', '未知');
|
| const analysisDate = safeGet(analysisResult, 'basic_info.analysis_date', '未知日期');
|
|
|
|
|
| $('#stock-title').text(`${stockName} (${stockCode}) 股票分析`);
|
|
|
|
|
| $('#stock-name').text(`${stockName} (${stockCode})`);
|
| $('#stock-info').text(`${industry} | ${analysisDate}`);
|
|
|
|
|
| const currentPrice = safeGet(analysisResult, 'price_data.current_price', 0);
|
| const priceChange = safeGet(analysisResult, 'price_data.price_change', 0);
|
| const priceChangeValue = safeGet(analysisResult, 'price_data.price_change_value', 0);
|
|
|
| $('#stock-price').text('¥' + formatNumber(currentPrice, 2));
|
| const priceChangeClass = priceChange >= 0 ? 'trend-up' : 'trend-down';
|
| const priceChangeIcon = priceChange >= 0 ? '<i class="fas fa-caret-up"></i>' : '<i class="fas fa-caret-down"></i>';
|
| $('#price-change').html(`<span class="${priceChangeClass}">${priceChangeIcon} ${formatNumber(priceChangeValue, 2)} (${formatPercent(priceChange, 2)})</span>`);
|
|
|
|
|
| const totalScore = safeGet(analysisResult, 'scores.total', 0);
|
| const scoreClass = getScoreColorClass(totalScore);
|
| $('#total-score').text(totalScore).addClass(scoreClass);
|
| $('#recommendation').text(safeGet(analysisResult, 'recommendation.action', '无建议'));
|
|
|
|
|
| $('#rsi-value').text(formatNumber(safeGet(analysisResult, 'technical_analysis.indicators.rsi', 0), 2));
|
|
|
| const maTrend = safeGet(analysisResult, 'technical_analysis.trend.ma_trend', 'UNKNOWN');
|
| const maStatus = safeGet(analysisResult, 'technical_analysis.trend.ma_status', '未知');
|
| const maTrendClass = getTrendColorClass(maTrend);
|
| const maTrendIcon = getTrendIcon(maTrend);
|
| $('#ma-trend').html(`<span class="${maTrendClass}">${maTrendIcon} ${maStatus}</span>`);
|
|
|
|
|
| const macd = safeGet(analysisResult, 'technical_analysis.indicators.macd', 0);
|
| const macdSignal = safeGet(analysisResult, 'technical_analysis.indicators.macd_signal', 0);
|
| const macdStatus = macd > macdSignal ? 'BUY' : 'SELL';
|
| const macdClass = macdStatus === 'BUY' ? 'trend-up' : 'trend-down';
|
| const macdIcon = macdStatus === 'BUY' ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
|
| $('#macd-signal').html(`<span class="${macdClass}">${macdIcon} ${macdStatus}</span>`);
|
|
|
|
|
| const volumeStatus = safeGet(analysisResult, 'technical_analysis.volume.volume_status', 'NORMAL');
|
| const volumeClass = volumeStatus === 'HIGH' ? 'trend-up' : 'trend-down';
|
| const volumeIcon = volumeStatus === 'HIGH' ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
|
| $('#volume-status').html(`<span class="${volumeClass}">${volumeIcon} ${volumeStatus}</span>`);
|
|
|
|
|
| let supportResistanceHtml = '';
|
|
|
|
|
| const shortTermResistance = safeGet(analysisResult, 'technical_analysis.support_resistance.resistance_levels.short_term', []);
|
| if (shortTermResistance.length > 0) {
|
| const resistance = shortTermResistance[0];
|
| const distance = ((resistance - currentPrice) / currentPrice * 100).toFixed(2);
|
| supportResistanceHtml += `
|
| <tr>
|
| <td><span class="badge bg-danger">短期压力</span></td>
|
| <td>${formatNumber(resistance, 2)}</td>
|
| <td>+${distance}%</td>
|
| </tr>
|
| `;
|
| }
|
|
|
| const mediumTermResistance = safeGet(analysisResult, 'technical_analysis.support_resistance.resistance_levels.medium_term', []);
|
| if (mediumTermResistance.length > 0) {
|
| const resistance = mediumTermResistance[0];
|
| const distance = ((resistance - currentPrice) / currentPrice * 100).toFixed(2);
|
| supportResistanceHtml += `
|
| <tr>
|
| <td><span class="badge bg-warning text-dark">中期压力</span></td>
|
| <td>${formatNumber(resistance, 2)}</td>
|
| <td>+${distance}%</td>
|
| </tr>
|
| `;
|
| }
|
|
|
|
|
| const shortTermSupport = safeGet(analysisResult, 'technical_analysis.support_resistance.support_levels.short_term', []);
|
| if (shortTermSupport.length > 0) {
|
| const support = shortTermSupport[0];
|
| const distance = ((support - currentPrice) / currentPrice * 100).toFixed(2);
|
| supportResistanceHtml += `
|
| <tr>
|
| <td><span class="badge bg-success">短期支撑</span></td>
|
| <td>${formatNumber(support, 2)}</td>
|
| <td>${distance}%</td>
|
| </tr>
|
| `;
|
| }
|
|
|
| const mediumTermSupport = safeGet(analysisResult, 'technical_analysis.support_resistance.support_levels.medium_term', []);
|
| if (mediumTermSupport.length > 0) {
|
| const support = mediumTermSupport[0];
|
| const distance = ((support - currentPrice) / currentPrice * 100).toFixed(2);
|
| supportResistanceHtml += `
|
| <tr>
|
| <td><span class="badge bg-info">中期支撑</span></td>
|
| <td>${formatNumber(support, 2)}</td>
|
| <td>${distance}%</td>
|
| </tr>
|
| `;
|
| }
|
|
|
| if (supportResistanceHtml === '') {
|
| supportResistanceHtml = '<tr><td colspan="3" class="text-center">暂无支撑压力位数据</td></tr>';
|
| }
|
|
|
| $('#support-resistance-table').html(supportResistanceHtml);
|
|
|
|
|
| const aiAnalysis = safeGet(analysisResult, 'ai_analysis', '暂无AI分析');
|
| $('#ai-analysis').html(formatAIAnalysis(aiAnalysis));
|
|
|
|
|
| try {
|
| renderScoreChart();
|
| } catch (e) {
|
| $('#score-chart').html('<div class="text-center text-muted">评分图表渲染失败</div>');
|
| }
|
|
|
| try {
|
| renderRadarChart();
|
| } catch (e) {
|
| $('#radar-chart').html('<div class="text-center text-muted">雷达图表渲染失败</div>');
|
| }
|
|
|
| try {
|
| renderPriceChart();
|
| } catch (e) {
|
| $('#price-chart').html('<div class="text-center text-muted">价格图表渲染失败</div>');
|
| }
|
|
|
| try {
|
| renderIndicatorsChart();
|
| } catch (e) {
|
| $('#indicators-chart').html('<div class="text-center text-muted">指标图表渲染失败</div>');
|
| }
|
|
|
| } catch (error) {
|
| showError(`渲染分析结果时出错: ${error.message}`);
|
| }
|
| }
|
|
|
|
|
| function renderScoreChart() {
|
| if (!analysisResult) return;
|
|
|
| const totalScore = analysisResult.scores.total || 0;
|
|
|
| const options = {
|
| series: [totalScore],
|
| chart: {
|
| height: 180,
|
| type: 'radialBar',
|
| toolbar: {
|
| show: false
|
| }
|
| },
|
| plotOptions: {
|
| radialBar: {
|
| hollow: {
|
| size: '70%',
|
| },
|
| dataLabels: {
|
| showOn: 'always',
|
| name: {
|
| show: true,
|
| fontSize: '14px',
|
| fontWeight: 600,
|
| offsetY: -10
|
| },
|
| value: {
|
| formatter: function(val) {
|
| return val;
|
| },
|
| fontSize: '22px',
|
| fontWeight: 700,
|
| offsetY: 5
|
| }
|
| }
|
| }
|
| },
|
| fill: {
|
| type: 'gradient',
|
| gradient: {
|
| shade: 'dark',
|
| type: 'horizontal',
|
| gradientToColors: ['#ABE5A1'],
|
| stops: [0, 100]
|
| }
|
| },
|
| stroke: {
|
| lineCap: 'round'
|
| },
|
| labels: ['总分'],
|
| colors: ['#20E647']
|
| };
|
|
|
|
|
| $('#score-chart').empty();
|
|
|
| const chart = new ApexCharts(document.querySelector("#score-chart"), options);
|
| chart.render();
|
| }
|
|
|
|
|
| function renderRadarChart() {
|
| if (!analysisResult) return;
|
|
|
| const trendScore = analysisResult.scores.trend || 0;
|
| const indicatorsScore = analysisResult.scores.indicators || 0;
|
| const supportResistanceScore = analysisResult.scores.support_resistance || 0;
|
| const volatilityVolumeScore = analysisResult.scores.volatility_volume || 0;
|
|
|
| const options = {
|
| series: [{
|
| name: '评分',
|
| data: [
|
| trendScore,
|
| indicatorsScore,
|
| supportResistanceScore,
|
| volatilityVolumeScore
|
| ]
|
| }],
|
| chart: {
|
| height: 180,
|
| type: 'radar',
|
| toolbar: {
|
| show: false
|
| }
|
| },
|
| xaxis: {
|
| categories: ['趋势', '指标', '支压', '波动量']
|
| },
|
| yaxis: {
|
| max: 10,
|
| min: 0
|
| },
|
| fill: {
|
| opacity: 0.5,
|
| colors: ['#4e73df']
|
| },
|
| markers: {
|
| size: 4
|
| }
|
| };
|
|
|
|
|
| $('#radar-chart').empty();
|
|
|
| const chart = new ApexCharts(document.querySelector("#radar-chart"), options);
|
| chart.render();
|
| }
|
|
|
|
|
| function renderPriceChart() {
|
|
|
| try {
|
|
|
| const closePrices = stockData.map(item => {
|
|
|
| let dateStr = item.date;
|
| if (dateStr && typeof dateStr === 'object') {
|
| dateStr = dateStr.toString().split('T')[0];
|
| }
|
|
|
| return {
|
| x: new Date(dateStr + 'T00:00:00'),
|
| y: safeFormat(item.close)
|
| };
|
| });
|
|
|
| const ma5Data = stockData.map(item => ({
|
| x: new Date(item.date),
|
| y: parseFloat(item.MA5 || 0)
|
| }));
|
|
|
| const ma20Data = stockData.map(item => ({
|
| x: new Date(item.date),
|
| y: parseFloat(item.MA20 || 0)
|
| }));
|
|
|
| const ma60Data = stockData.map(item => ({
|
| x: new Date(item.date),
|
| y: parseFloat(item.MA60 || 0)
|
| }));
|
|
|
|
|
| const priceOptions = {
|
| series: [
|
| {
|
| name: '收盘价',
|
| type: 'line',
|
| data: closePrices
|
| },
|
| {
|
| name: 'MA5',
|
| type: 'line',
|
| data: ma5Data
|
| },
|
| {
|
| name: 'MA20',
|
| type: 'line',
|
| data: ma20Data
|
| },
|
| {
|
| name: 'MA60',
|
| type: 'line',
|
| data: ma60Data
|
| }
|
| ],
|
| chart: {
|
| height: 400,
|
| type: 'line',
|
| toolbar: {
|
| show: true
|
| },
|
| animations: {
|
| enabled: false
|
| }
|
| },
|
| stroke: {
|
| width: [3, 2, 2, 2],
|
| curve: 'straight'
|
| },
|
| title: {
|
| text: `价格走势图`,
|
| align: 'left'
|
| },
|
| xaxis: {
|
| type: 'datetime'
|
| },
|
| yaxis: {
|
| labels: {
|
| formatter: function(value) {
|
| return formatNumber(value, 2);
|
| }
|
| }
|
| },
|
| tooltip: {
|
| enabled: true,
|
| shared: true,
|
| intersect: false,
|
| x: {
|
| format: 'yyyy-MM-dd'
|
| },
|
| y: {
|
| formatter: function(value) {
|
| return formatNumber(value, 2);
|
| }
|
| }
|
| },
|
| legend: {
|
| show: true,
|
| position: 'top',
|
| horizontalAlign: 'left'
|
| },
|
| markers: {
|
| size: 0
|
| },
|
| grid: {
|
| show: true
|
| }
|
| };
|
|
|
|
|
| $('#price-chart').empty();
|
|
|
|
|
| const chart = new ApexCharts(document.querySelector("#price-chart"), priceOptions);
|
|
|
| chart.render();
|
|
|
|
|
| } catch (error) {
|
|
|
| $('#price-chart').html('<div class="alert alert-danger">图表加载失败: ' + error.message + '</div>');
|
| }
|
| }
|
|
|
|
|
| function formatAIAnalysis(text) {
|
| if (!text) return '';
|
|
|
|
|
| const safeText = text
|
| .replace(/&/g, '&')
|
| .replace(/</g, '<')
|
| .replace(/>/g, '>');
|
|
|
|
|
| let formatted = safeText
|
|
|
| .replace(/\*\*(.*?)\*\*/g, '<strong class="keyword">$1</strong>')
|
| .replace(/__(.*?)__/g, '<strong>$1</strong>')
|
|
|
|
|
| .replace(/\*(.*?)\*/g, '<em>$1</em>')
|
| .replace(/_(.*?)_/g, '<em>$1</em>')
|
|
|
|
|
| .replace(/^# (.*?)$/gm, '<h4 class="mt-3 mb-2">$1</h4>')
|
| .replace(/^## (.*?)$/gm, '<h5 class="mt-2 mb-2">$1</h5>')
|
|
|
|
|
| .replace(/支撑位/g, '<span class="keyword">支撑位</span>')
|
| .replace(/压力位/g, '<span class="keyword">压力位</span>')
|
| .replace(/趋势/g, '<span class="keyword">趋势</span>')
|
| .replace(/均线/g, '<span class="keyword">均线</span>')
|
| .replace(/MACD/g, '<span class="term">MACD</span>')
|
| .replace(/RSI/g, '<span class="term">RSI</span>')
|
| .replace(/KDJ/g, '<span class="term">KDJ</span>')
|
|
|
|
|
| .replace(/([上涨升])/g, '<span class="trend-up">$1</span>')
|
| .replace(/([下跌降])/g, '<span class="trend-down">$1</span>')
|
| .replace(/(买入|做多|多头|突破)/g, '<span class="trend-up">$1</span>')
|
| .replace(/(卖出|做空|空头|跌破)/g, '<span class="trend-down">$1</span>')
|
|
|
|
|
| .replace(/(\d+\.\d{2})/g, '<span class="price">$1</span>')
|
|
|
|
|
| .replace(/\n\n+/g, '</p><p class="analysis-para">')
|
| .replace(/\n/g, '<br>');
|
|
|
|
|
| return '<p class="analysis-para">' + formatted + '</p>';
|
| }
|
|
|
| function renderPriceChartWithOHLC() {
|
|
|
|
|
| try {
|
|
|
| let ohlcTableHtml = '<div class="ohlc-data mt-3"><h6>价格数据 (最近5天)</h6><table class="table table-sm table-bordered">';
|
| ohlcTableHtml += '<thead><tr><th>日期</th><th>开盘</th><th>最高</th><th>最低</th><th>收盘</th></tr></thead><tbody>';
|
|
|
|
|
| const recentData = stockData.slice(-5);
|
| recentData.forEach(item => {
|
| ohlcTableHtml += `<tr>
|
| <td>${item.date}</td>
|
| <td>${formatNumber(item.open, 2)}</td>
|
| <td>${formatNumber(item.high, 2)}</td>
|
| <td>${formatNumber(item.low, 2)}</td>
|
| <td>${formatNumber(item.close, 2)}</td>
|
| </tr>`;
|
| });
|
| ohlcTableHtml += '</tbody></table></div>';
|
|
|
|
|
| const closePrices = stockData.map(item => {
|
|
|
| let dateStr = item.date;
|
| if (dateStr && typeof dateStr === 'object') {
|
| dateStr = dateStr.toString().split('T')[0];
|
| }
|
|
|
| return {
|
| x: new Date(dateStr + 'T00:00:00'),
|
| y: safeFormat(item.close)
|
| };
|
| });
|
|
|
| const ma5Data = stockData.map(item => ({
|
| x: new Date(item.date),
|
| y: parseFloat(item.MA5 || 0)
|
| }));
|
|
|
| const ma20Data = stockData.map(item => ({
|
| x: new Date(item.date),
|
| y: parseFloat(item.MA20 || 0)
|
| }));
|
|
|
| const ma60Data = stockData.map(item => ({
|
| x: new Date(item.date),
|
| y: parseFloat(item.MA60 || 0)
|
| }));
|
|
|
| const priceOptions = {
|
| series: [
|
| {
|
| name: '收盘价',
|
| type: 'line',
|
| data: closePrices
|
| },
|
| {
|
| name: 'MA5',
|
| type: 'line',
|
| data: ma5Data
|
| },
|
| {
|
| name: 'MA20',
|
| type: 'line',
|
| data: ma20Data
|
| },
|
| {
|
| name: 'MA60',
|
| type: 'line',
|
| data: ma60Data
|
| }
|
| ],
|
| chart: {
|
| height: 350,
|
| type: 'line',
|
| toolbar: {
|
| show: true
|
| },
|
| animations: {
|
| enabled: false
|
| }
|
| },
|
| stroke: {
|
| width: [3, 2, 2, 2]
|
| },
|
| colors: ['#FF4560', '#008FFB', '#00E396', '#775DD0'],
|
| title: {
|
| text: `价格走势图 (收盘价)`,
|
| align: 'left'
|
| },
|
| xaxis: {
|
| type: 'datetime'
|
| },
|
| yaxis: {
|
| labels: {
|
| formatter: function(value) {
|
| return formatNumber(value, 2);
|
| }
|
| }
|
| },
|
| tooltip: {
|
| enabled: true,
|
| shared: true,
|
| intersect: false,
|
| x: {
|
| format: 'yyyy-MM-dd'
|
| }
|
| },
|
| legend: {
|
| show: true
|
| }
|
| };
|
|
|
|
|
|
|
| $('#price-chart').empty();
|
|
|
|
|
| $('#price-chart').append('<div id="price-line-chart"></div>');
|
|
|
| const chart = new ApexCharts(document.querySelector("#price-line-chart"), priceOptions);
|
|
|
| chart.render();
|
|
|
|
|
| $('#price-chart').append(ohlcTableHtml);
|
|
|
| } catch (error) {
|
| $('#price-chart').html('<div class="alert alert-danger">图表加载失败: ' + error.message + '</div>');
|
| }
|
| }
|
|
|
|
|
|
|
| function renderIndicatorsChart() {
|
|
|
| try {
|
|
|
| const indicatorOptions = {
|
| series: [
|
| {
|
| name: 'MACD',
|
| type: 'line',
|
| data: stockData.map(item => ({
|
| x: new Date(item.date),
|
| y: parseFloat(item.MACD || 0)
|
| }))
|
| },
|
| {
|
| name: 'Signal',
|
| type: 'line',
|
| data: stockData.map(item => ({
|
| x: new Date(item.date),
|
| y: parseFloat(item.Signal || 0)
|
| }))
|
| },
|
| {
|
| name: 'Histogram',
|
| type: 'bar',
|
| data: stockData.map(item => ({
|
| x: new Date(item.date),
|
| y: parseFloat(item.MACD_hist || 0)
|
| }))
|
| },
|
| {
|
| name: 'RSI',
|
| type: 'line',
|
| data: stockData.map(item => ({
|
| x: new Date(item.date),
|
| y: parseFloat(item.RSI || 0)
|
| }))
|
| }
|
| ],
|
| chart: {
|
| height: 350,
|
| type: 'line',
|
| stacked: false,
|
| toolbar: {
|
| show: true
|
| },
|
|
|
| animations: {
|
| enabled: false
|
| }
|
| },
|
| stroke: {
|
| width: [3, 3, 0, 3],
|
| curve: 'smooth'
|
| },
|
| xaxis: {
|
| type: 'datetime'
|
| },
|
| yaxis: [
|
| {
|
| title: {
|
| text: 'MACD',
|
| },
|
| seriesName: 'MACD',
|
| labels: {
|
| formatter: function(value) {
|
| return formatNumber(value, 3);
|
| }
|
| }
|
| },
|
| {
|
| show: false,
|
| seriesName: 'Signal'
|
| },
|
| {
|
| show: false,
|
| seriesName: 'Histogram'
|
| },
|
| {
|
| opposite: true,
|
| title: {
|
| text: 'RSI'
|
| },
|
| min: 0,
|
| max: 100,
|
| seriesName: 'RSI',
|
| labels: {
|
| formatter: function(value) {
|
| return formatNumber(value, 2);
|
| }
|
| }
|
| }
|
| ],
|
|
|
| tooltip: {
|
| enabled: true,
|
| shared: true,
|
| intersect: false,
|
| hideEmptySeries: true,
|
| x: {
|
| format: 'yyyy-MM-dd'
|
| },
|
| y: {
|
| formatter: function(value, { seriesIndex }) {
|
|
|
| if (seriesIndex === 0) return `MACD: ${formatNumber(value, 3)}`;
|
| if (seriesIndex === 1) return `Signal: ${formatNumber(value, 3)}`;
|
| if (seriesIndex === 2) return `Histogram: ${formatNumber(value, 3)}`;
|
| if (seriesIndex === 3) return `RSI: ${formatNumber(value, 2)}`;
|
| return formatNumber(value, 2);
|
| }
|
| }
|
| },
|
| colors: ['#008FFB', '#00E396', '#CED4DC', '#FEB019'],
|
| legend: {
|
| show: true,
|
| position: 'top',
|
| horizontalAlign: 'left',
|
| floating: false
|
| },
|
|
|
| markers: {
|
| size: 4,
|
| strokeWidth: 0
|
| }
|
| };
|
|
|
| $('#indicators-chart').empty();
|
|
|
| const chart = new ApexCharts(document.querySelector("#indicators-chart"), indicatorOptions);
|
|
|
| chart.render();
|
|
|
| } catch (error) {
|
| $('#indicators-chart').html('<div class="alert alert-danger">指标图表加载失败: ' + error.message + '</div>');
|
| }
|
| }
|
|
|
|
|
| $('#retry-button').click(function() {
|
|
|
| $('#error-retry').hide();
|
|
|
| loadAnalysisResult();
|
| });
|
|
|
| </script>
|
| {% endblock %} |