const API_BASE = '/api/v1/futures'; let klineChart = null; let currentSymbol = null; let currentPeriod = '15'; let allFuturesData = []; let watchedSymbols = []; let currentDetailData = null; document.addEventListener('DOMContentLoaded', function() { addScoreGradient(); updateTime(); setInterval(updateTime, 1000); initEventListeners(); loadWatchedSymbols(); loadFuturesList(); }); function addScoreGradient() { const svg = document.querySelector('.score-ring svg'); if (svg && !svg.querySelector('defs')) { const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient'); gradient.setAttribute('id', 'scoreGradient'); gradient.setAttribute('x1', '0%'); gradient.setAttribute('y1', '0%'); gradient.setAttribute('x2', '100%'); gradient.setAttribute('y2', '0%'); const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop'); stop1.setAttribute('offset', '0%'); stop1.setAttribute('stop-color', '#ef4444'); const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop'); stop2.setAttribute('offset', '50%'); stop2.setAttribute('stop-color', '#f59e0b'); const stop3 = document.createElementNS('http://www.w3.org/2000/svg', 'stop'); stop3.setAttribute('offset', '100%'); stop3.setAttribute('stop-color', '#10b981'); gradient.appendChild(stop1); gradient.appendChild(stop2); gradient.appendChild(stop3); defs.appendChild(gradient); svg.insertBefore(defs, svg.firstChild); } } function updateTime() { const now = new Date(); const timeStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0') + ' ' + String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0') + ':' + String(now.getSeconds()).padStart(2, '0'); document.getElementById('current-time').textContent = timeStr; } function initEventListeners() { document.getElementById('back-btn').addEventListener('click', showListView); document.getElementById('theme-toggle').addEventListener('click', toggleTheme); document.getElementById('suggestion-card').addEventListener('click', function() { if (currentDetailData) { showSuggestionModal(currentDetailData); } }); const savedTheme = localStorage.getItem('futures-theme'); if (savedTheme === 'dark') { document.body.classList.remove('theme-minimal'); updateThemeIcon(false); } else { document.body.classList.add('theme-minimal'); updateThemeIcon(true); } document.querySelectorAll('.period-btn').forEach(btn => { btn.addEventListener('click', function() { document.querySelectorAll('.period-btn').forEach(b => b.classList.remove('active')); this.classList.add('active'); currentPeriod = this.dataset.period; loadKlineData(currentSymbol, currentPeriod); }); }); document.getElementById('search-input').addEventListener('input', function() { filterFuturesList(this.value); }); document.querySelectorAll('.filter-tab').forEach(tab => { tab.addEventListener('click', function() { document.querySelectorAll('.filter-tab').forEach(t => t.classList.remove('active')); this.classList.add('active'); filterByCategory(this.dataset.category); }); }); document.getElementById('sort-select').addEventListener('change', function() { sortFuturesList(this.value); }); document.querySelectorAll('.modal-overlay').forEach(modal => { modal.addEventListener('click', function(e) { if (e.target === this) { this.classList.remove('active'); } }); }); } function closeModal(modalId) { document.getElementById(modalId).classList.remove('active'); } function showListView() { document.getElementById('list-view').classList.add('active'); document.getElementById('detail-view').classList.remove('active'); if (klineChart) { klineChart.dispose(); klineChart = null; } } function showDetailView(symbol) { currentSymbol = symbol; document.getElementById('list-view').classList.remove('active'); document.getElementById('detail-view').classList.add('active'); loadFuturesDetail(symbol); loadKlineData(symbol, currentPeriod); loadHistoryList(symbol); } async function loadWatchedSymbols() { try { const response = await fetch(`${API_BASE}/watched`); const data = await response.json(); if (data.success) { watchedSymbols = data.data.map(s => s.symbol); document.getElementById('count-watched').textContent = watchedSymbols.length; } } catch (error) { console.error('加载自选列表失败:', error); watchedSymbols = []; } } async function toggleWatch(symbol, name, event) { event.stopPropagation(); const isWatched = watchedSymbols.includes(symbol); try { if (isWatched) { const response = await fetch(`${API_BASE}/watched/${symbol}`, { method: 'DELETE' }); const data = await response.json(); if (data.success) { watchedSymbols = watchedSymbols.filter(s => s !== symbol); } } else { const response = await fetch(`${API_BASE}/watched`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ symbol, name }) }); const data = await response.json(); if (data.success) { watchedSymbols.push(symbol); } } document.getElementById('count-watched').textContent = watchedSymbols.length; const activeTab = document.querySelector('.filter-tab.active'); if (activeTab && activeTab.dataset.category === 'watched') { filterByCategory('watched'); } else { renderFuturesGrid(getCurrentFilteredData()); } } catch (error) { console.error('切换自选失败:', error); } } function getCurrentFilteredData() { const activeTab = document.querySelector('.filter-tab.active'); const category = activeTab ? activeTab.dataset.category : 'all'; return filterDataByCategory(allFuturesData, category); } function filterDataByCategory(data, category) { if (category === 'all') return data; if (category === 'watched') { return data.filter(item => watchedSymbols.includes(item.symbol)); } const categoryMap = { 'energy': ['SC', 'FU', 'LU', 'BU', 'RU', 'NR'], 'metal': ['AU', 'AG', 'CU', 'AL', 'ZN', 'NI', 'SN', 'PB', 'SS', 'RB', 'HC', 'I', 'J', 'JM', 'AO', 'SI', 'LC', 'PS'], 'agriculture': ['M', 'RM', 'C', 'CS', 'A', 'B', 'Y', 'P', 'OI', 'CF', 'SR', 'AP', 'LH'], 'finance': ['IF', 'IC', 'IH', 'IM', 'T', 'TF', 'TS', 'TL'] }; const symbols = categoryMap[category] || []; return data.filter(item => { const symbolBase = item.symbol.replace(/[0-9]/g, '').toUpperCase(); return symbols.includes(symbolBase); }); } async function loadFuturesList() { try { const response = await fetch(`${API_BASE}/list`); const data = await response.json(); if (data.success) { allFuturesData = data.data; renderFuturesGrid(allFuturesData); updateStats(allFuturesData); } } catch (error) { console.error('加载品种列表失败:', error); loadFuturesFromConfig(); } } async function loadFuturesFromConfig() { try { const response = await fetch('/api/v1/config'); const config = await response.json(); const futuresConfig = config.futures || {}; allFuturesData = Object.entries(futuresConfig).map(([name, symbol]) => ({ symbol: symbol, name: name, price: 0, change: 0, changePct: 0, suggestion: '等待数据', suggestionType: 'neutral', periods: { '5': 'neutral', '15': 'neutral', '30': 'neutral', '60': 'neutral' }, successRate: 0, trendScore: 0, resistance: 0, support: 0, open: 0, high: 0, low: 0, volume: 0 })); renderFuturesGrid(allFuturesData); updateStats(allFuturesData); } catch (error) { console.error('加载配置失败:', error); const mockData = generateMockFuturesData(); allFuturesData = mockData; renderFuturesGrid(mockData); updateStats(mockData); } } function generateMockFuturesData() { return [ { symbol: 'SC2606', name: '原油', price: 528.6, change: 12.1, changePct: 2.35, suggestion: '逢低做多', suggestionType: 'up', periods: { '5': 'up', '15': 'up', '30': 'up', '60': 'neutral' }, successRate: 72, trendScore: 85, resistance: 535, support: 518, open: 516.5, high: 530, low: 515, volume: 78000 }, { symbol: 'AU2606', name: '黄金', price: 685.2, change: 12.45, changePct: 1.85, suggestion: '逢低做多', suggestionType: 'up', periods: { '5': 'up', '15': 'up', '30': 'up', '60': 'up' }, successRate: 78, trendScore: 92, resistance: 692, support: 678, open: 672.75, high: 688, low: 670, volume: 128000 }, { symbol: 'AG2606', name: '白银', price: 8250, change: 165, changePct: 2.04, suggestion: '逢低做多', suggestionType: 'up', periods: { '5': 'up', '15': 'up', '30': 'up', '60': 'up' }, successRate: 75, trendScore: 88, resistance: 8350, support: 8100, open: 8085, high: 8280, low: 8050, volume: 95000 }, { symbol: 'CU2606', name: '沪铜', price: 80610, change: 112, changePct: 0.14, suggestion: '观望等待', suggestionType: 'neutral', periods: { '5': 'neutral', '15': 'up', '30': 'neutral', '60': 'up' }, successRate: 58, trendScore: 65, resistance: 81200, support: 79800, open: 80498, high: 80850, low: 80200, volume: 42000 }, { symbol: 'M2609', name: '豆粕', price: 2985, change: -51, changePct: -1.68, suggestion: '逢高做空', suggestionType: 'down', periods: { '5': 'down', '15': 'down', '30': 'down', '60': 'neutral' }, successRate: 65, trendScore: 35, resistance: 3050, support: 2920, open: 3036, high: 3040, low: 2980, volume: 185000 } ]; } function renderFuturesGrid(data) { const grid = document.getElementById('futures-grid'); if (data.length === 0) { grid.innerHTML = '
暂无数据
'; return; } grid.innerHTML = data.map(item => { const isWatched = watchedSymbols.includes(item.symbol); return `
${item.symbol.replace(/[0-9]/g, '').substring(0, 2)}
${item.name}
${item.symbol}
¥${formatNumber(item.price)}
${item.change >= 0 ? '+' : ''}${formatNumber(item.change)} (${item.changePct >= 0 ? '+' : ''}${item.changePct.toFixed(2)}%)
${item.suggestion}
成功率
${item.successRate}%
趋势评分
${item.trendScore}
`}).join(''); } function formatNumber(num) { if (num === 0 || num === undefined || num === null) return '--'; return num.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); } function calcPriceChangePercent(current, target) { if (!current || !target || current === 0) return '--'; const pct = ((target - current) / current * 100).toFixed(2); return (pct >= 0 ? '+' : '') + pct + '%'; } function updateStats(data) { const total = data.length; const upCount = data.filter(d => d.change > 0).length; const downCount = data.filter(d => d.change < 0).length; const neutralCount = total - upCount - downCount; document.getElementById('total-count').textContent = total; document.getElementById('up-count').textContent = upCount; document.getElementById('down-count').textContent = downCount; document.getElementById('neutral-count').textContent = neutralCount; document.getElementById('count-all').textContent = total; document.getElementById('count-watched').textContent = watchedSymbols.length; } function filterFuturesList(keyword) { keyword = keyword.toLowerCase(); const activeTab = document.querySelector('.filter-tab.active'); const category = activeTab ? activeTab.dataset.category : 'all'; let filtered = filterDataByCategory(allFuturesData, category); if (keyword) { filtered = filtered.filter(item => item.name.toLowerCase().includes(keyword) || item.symbol.toLowerCase().includes(keyword) ); } renderFuturesGrid(filtered); } function filterByCategory(category) { let filtered = filterDataByCategory(allFuturesData, category); renderFuturesGrid(filtered); } function sortFuturesList(sortBy) { let sorted = [...getCurrentFilteredData()]; switch(sortBy) { case 'success_rate': sorted.sort((a, b) => b.successRate - a.successRate); break; case 'trend_score': sorted.sort((a, b) => b.trendScore - a.trendScore); break; case 'change_pct': sorted.sort((a, b) => b.changePct - a.changePct); break; case 'name': sorted.sort((a, b) => a.name.localeCompare(b.name, 'zh')); break; } renderFuturesGrid(sorted); } async function loadFuturesDetail(symbol) { try { const response = await fetch(`${API_BASE}/detail/${symbol}`); const data = await response.json(); if (data.success) { currentDetailData = data.data; updateDetailView(data.data); } } catch (error) { console.error('加载详情失败:', error); } } function updateDetailView(data) { document.getElementById('detail-name').textContent = data.name || '--'; document.getElementById('detail-symbol').textContent = data.symbol || '--'; const priceEl = document.getElementById('detail-price'); priceEl.textContent = '¥' + formatNumber(data.price); priceEl.className = 'price-value ' + (data.change >= 0 ? 'up' : 'down'); const changeEl = document.getElementById('detail-change'); changeEl.className = 'price-change ' + (data.change >= 0 ? 'up' : 'down'); changeEl.innerHTML = ` ${data.change >= 0 ? '+' : ''}${formatNumber(data.change)} (${data.changePct >= 0 ? '+' : ''}${data.changePct.toFixed(2)}%)`; document.getElementById('detail-open').textContent = formatNumber(data.open); document.getElementById('detail-high').textContent = formatNumber(data.high); document.getElementById('detail-low').textContent = formatNumber(data.low); document.getElementById('detail-volume').textContent = formatNumber(data.volume); const r1 = data.resistances ? data.resistances[0] : data.resistance; const s1 = data.supports ? data.supports[0] : data.support; document.getElementById('detail-r1').textContent = `R1: ${formatNumber(r1)} (${calcPriceChangePercent(data.price, r1)})`; document.getElementById('detail-s1').textContent = `S1: ${formatNumber(s1)} (${calcPriceChangePercent(data.price, s1)})`; const badge = document.getElementById('suggestion-badge'); badge.textContent = data.suggestion || '--'; badge.className = 'suggestion-badge ' + (data.suggestionType || 'neutral'); document.getElementById('suggestion-reason').textContent = data.suggestionReason || '--'; document.getElementById('entry-price').textContent = formatNumber(data.entryPrice); document.getElementById('target-price').textContent = formatNumber(data.targetPrice); document.getElementById('stop-loss').textContent = formatNumber(data.stopLoss); document.getElementById('risk-level').textContent = data.riskLevel || '--'; if (data.macd) { document.getElementById('macd-signal').textContent = data.macd.signal; document.getElementById('macd-detail').textContent = data.macd.detail; } if (data.rsi) { document.getElementById('rsi-value').textContent = data.rsi.value; document.getElementById('rsi-status').textContent = data.rsi.status; } if (data.boll) { document.getElementById('boll-signal').textContent = data.boll.signal; document.getElementById('boll-detail').textContent = data.boll.detail; } if (data.kdj) { document.getElementById('kdj-signal').textContent = data.kdj.signal; document.getElementById('kdj-detail').textContent = data.kdj.detail; } if (data.resistances) { for (let i = 0; i < 2; i++) { const el = document.getElementById(`resistance-${i + 1}`); if (el) { el.querySelector('span:last-child').textContent = formatNumber(data.resistances[i]); } } } if (data.supports) { for (let i = 0; i < 2; i++) { const el = document.getElementById(`support-${i + 1}`); if (el) { el.querySelector('span:last-child').textContent = formatNumber(data.supports[i]); } } } if (data.pivotPoint) { document.getElementById('pivot-point').querySelector('span:last-child').textContent = formatNumber(data.pivotPoint); } if (data.periodConsistency) { const container = document.getElementById('period-trends'); const periodNames = { '5': '5分钟', '15': '15分钟', '30': '30分钟', '60': '60分钟' }; container.innerHTML = Object.entries(data.periodConsistency).map(([period, trend]) => `
${periodNames[period]} ${trend === 'up' ? '上涨' : trend === 'down' ? '下跌' : '震荡'}
`).join(''); } if (data.trendScore !== undefined) { document.getElementById('trend-score').textContent = data.trendScore; const circle = document.getElementById('score-fill'); const circumference = 2 * Math.PI * 45; const offset = circumference - (data.trendScore / 100) * circumference; circle.style.strokeDasharray = circumference; circle.style.strokeDashoffset = offset; } } async function loadHistoryList(symbol) { try { const response = await fetch(`${API_BASE}/analysis/history/${symbol}?limit=10`); const data = await response.json(); if (data.success) { renderHistoryList(data.data); } } catch (error) { console.error('加载历史记录失败:', error); document.getElementById('history-list').innerHTML = '
暂无历史记录
'; } } function renderHistoryList(records) { const container = document.getElementById('history-list'); if (!records || records.length === 0) { container.innerHTML = '
暂无历史记录
'; return; } container.innerHTML = records.map(record => `
${record.analysis_time ? record.analysis_time.replace('T', ' ').substring(0, 16) : '--'} ${record.suggestion || '--'} 评分: ${record.trend_score || '--'}
MACD ${record.macd_signal || '--'}
RSI ${record.rsi_value || '--'}
`).join(''); } function showSuggestionModal(data) { const body = document.getElementById('suggestion-modal-body'); body.innerHTML = ` `; document.getElementById('suggestion-modal').classList.add('active'); } function showHistoryModal(record) { const body = document.getElementById('history-modal-body'); body.innerHTML = ` `; document.getElementById('history-modal').classList.add('active'); } async function loadKlineData(symbol, period) { try { const response = await fetch(`${API_BASE}/kline/${symbol}?period=${period}`); const data = await response.json(); if (data.success) { renderKlineChart(data.data); } } catch (error) { console.error('加载K线数据失败:', error); } } function renderKlineChart(data) { if (klineChart) { klineChart.dispose(); } const chartDom = document.getElementById('kline-chart'); klineChart = echarts.init(chartDom, 'dark'); const dates = data.map(d => d[0]); const values = data.map(d => [parseFloat(d[1]), parseFloat(d[2]), parseFloat(d[3]), parseFloat(d[4])]); const volumes = data.map(d => [parseInt(d[5]), d[2] >= d[1] ? 1 : -1]); const ma5 = calculateMA(data, 5); const ma10 = calculateMA(data, 10); const ma20 = calculateMA(data, 20); const macdData = calculateMACD(data); const option = { backgroundColor: 'transparent', animation: false, tooltip: { trigger: 'axis', axisPointer: { type: 'cross' }, backgroundColor: 'rgba(10, 15, 25, 0.95)', borderColor: 'rgba(56, 189, 248, 0.2)', textStyle: { color: '#e2e8f0', fontSize: 12 } }, axisPointer: { link: [{ xAxisIndex: 'all' }], label: { backgroundColor: '#06b6d4' } }, grid: [ { left: 70, right: 20, top: 10, height: '50%' }, { left: 70, right: 20, top: '56%', height: '16%' }, { left: 70, right: 20, top: '76%', height: '16%' } ], xAxis: [ { type: 'category', data: dates, boundaryGap: true, axisLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }, axisLabel: { color: '#64748b', fontSize: 10 }, splitLine: { show: false } }, { type: 'category', gridIndex: 1, data: dates, axisLine: { show: false }, axisLabel: { show: false }, splitLine: { show: false } }, { type: 'category', gridIndex: 2, data: dates, axisLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } }, axisLabel: { color: '#64748b', fontSize: 10 }, splitLine: { show: false } } ], yAxis: [ { scale: true, axisLine: { show: false }, axisLabel: { color: '#64748b' }, splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)', type: 'dashed' } } }, { scale: true, gridIndex: 1, axisLine: { show: false }, axisLabel: { show: false }, splitLine: { show: false } }, { scale: true, gridIndex: 2, axisLine: { show: false }, axisLabel: { color: '#64748b', fontSize: 10 }, splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)', type: 'dashed' } } } ], dataZoom: [ { type: 'inside', xAxisIndex: [0, 1, 2], start: 50, end: 100 }, { show: true, xAxisIndex: [0, 1, 2], type: 'slider', bottom: 5, height: 16, borderColor: 'transparent', backgroundColor: 'rgba(15, 20, 30, 0.5)', fillerColor: 'rgba(6, 182, 212, 0.15)', handleStyle: { color: '#06b6d4' }, textStyle: { color: '#64748b' } } ], series: [ { name: 'K线', type: 'candlestick', data: values, itemStyle: { color: '#10b981', color0: '#ef4444', borderColor: '#10b981', borderColor0: '#ef4444' } }, { name: 'MA5', type: 'line', data: ma5, lineStyle: { width: 1, color: '#f59e0b' }, symbol: 'none' }, { name: 'MA10', type: 'line', data: ma10, lineStyle: { width: 1, color: '#3b82f6' }, symbol: 'none' }, { name: 'MA20', type: 'line', data: ma20, lineStyle: { width: 1, color: '#8b5cf6' }, symbol: 'none' }, { name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1, data: volumes.map(v => ({ value: v[0], itemStyle: { color: v[1] >= 0 ? 'rgba(16,185,129,0.5)' : 'rgba(239,68,68,0.5)' } })) }, { name: 'DIF', type: 'line', xAxisIndex: 2, yAxisIndex: 2, data: macdData.dif, lineStyle: { width: 1.5, color: '#3b82f6' }, symbol: 'none' }, { name: 'DEA', type: 'line', xAxisIndex: 2, yAxisIndex: 2, data: macdData.dea, lineStyle: { width: 1.5, color: '#f59e0b' }, symbol: 'none' }, { name: 'MACD', type: 'bar', xAxisIndex: 2, yAxisIndex: 2, data: macdData.macd.map(val => ({ value: val, itemStyle: { color: val >= 0 ? 'rgba(16,185,129,0.6)' : 'rgba(239,68,68,0.6)' } })) } ] }; klineChart.setOption(option); window.addEventListener('resize', () => klineChart && klineChart.resize()); } function calculateMA(data, dayCount) { const result = []; for (let i = 0; i < data.length; i++) { if (i < dayCount - 1) { result.push('-'); continue; } let sum = 0; for (let j = 0; j < dayCount; j++) { sum += parseFloat(data[i - j][2]); } result.push(parseFloat((sum / dayCount).toFixed(2))); } return result; } function toggleTheme() { const isMinimal = document.body.classList.toggle('theme-minimal'); localStorage.setItem('futures-theme', isMinimal ? 'minimal' : 'dark'); updateThemeIcon(isMinimal); } function updateThemeIcon(isMinimal) { const icon = document.querySelector('#theme-toggle i'); if (icon) { icon.className = isMinimal ? 'fas fa-sun' : 'fas fa-moon'; } } function calculateMACD(data) { const closes = data.map(d => parseFloat(d[2])); const ema12 = calcEMA(closes, 12); const ema26 = calcEMA(closes, 26); const dif = []; for (let i = 0; i < closes.length; i++) { if (ema12[i] !== null && ema26[i] !== null) { dif.push(ema12[i] - ema26[i]); } else { dif.push(0); } } const dea = calcEMA(dif, 9); const macd = dif.map((d, i) => 2 * (d - (dea[i] || 0))); return { dif, dea, macd }; } function calcEMA(data, period) { const result = new Array(data.length).fill(null); const multiplier = 2 / (period + 1); if (data.length < period) return result; let sum = 0; for (let i = 0; i < period; i++) sum += data[i]; result[period - 1] = sum / period; for (let i = period; i < data.length; i++) { result[i] = (data[i] - result[i - 1]) * multiplier + result[i - 1]; } return result; }