const API_BASE = '/api/v1/futures'; let klineChart = null; let currentSymbol = null; let currentPeriod = '15'; let allFuturesData = []; document.addEventListener('DOMContentLoaded', function() { updateTime(); setInterval(updateTime, 1000); initEventListeners(); loadFuturesList(); }); 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.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-btn').forEach(btn => { btn.addEventListener('click', function() { document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); this.classList.add('active'); filterByCategory(this.dataset.category); }); }); document.getElementById('sort-select').addEventListener('change', function() { sortFuturesList(this.value); }); } 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); } 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: 'EC', name: '集运指数', price: 2150, change: 196, changePct: 10.06, suggestion: '逢低做多', suggestionType: 'up', periods: { '5': 'up', '15': 'up', '30': 'up', '60': 'up' }, successRate: 80, trendScore: 90, resistance: 2200, support: 2000, open: 1960, high: 2200, low: 1940, volume: 45600 }, { symbol: 'AU', 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: 'AG', 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: 'SC', 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: 'I', name: '铁矿石', price: 785.5, change: 28, changePct: 3.7, suggestion: '逢低做多', suggestionType: 'up', periods: { '5': 'up', '15': 'up', '30': 'up', '60': 'up' }, successRate: 68, trendScore: 82, resistance: 792, support: 770, open: 757.5, high: 788, low: 755, volume: 156000 }, { symbol: 'CU', 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: 'P', name: '棕榈油', price: 8750, change: 0, changePct: 0, suggestion: '观望等待', suggestionType: 'neutral', periods: { '5': 'neutral', '15': 'neutral', '30': 'neutral', '60': 'neutral' }, successRate: 52, trendScore: 50, resistance: 8850, support: 8650, open: 8750, high: 8780, low: 8720, volume: 65000 }, { symbol: 'M', 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'); grid.innerHTML = data.map(item => `
${item.name} (${item.symbol})
¥${formatNumber(item.price)}
+${formatNumber(item.change)} (+${item.changePct.toFixed(2)}%)
${item.suggestion}
5分 15分 30分 60分
${item.successRate}%
${item.trendScore}/100
压力: ${formatNumber(item.resistance)} 支撑: ${formatNumber(item.support)}
`).join(''); } function getArrow(type) { if (type === 'up') return 'up'; if (type === 'down') return 'down'; return 'right'; } function formatNumber(num) { return num.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); } function updateStats(data) { document.getElementById('total-count').textContent = data.length; const upCount = data.filter(d => d.change >= 0).length; const downCount = data.length - upCount; document.getElementById('up-count').textContent = upCount; document.getElementById('down-count').textContent = downCount; } function filterFuturesList(keyword) { keyword = keyword.toLowerCase(); const filtered = allFuturesData.filter(item => item.name.toLowerCase().includes(keyword) || item.symbol.toLowerCase().includes(keyword) ); renderFuturesGrid(filtered); updateStats(filtered); } function filterByCategory(category) { if (category === 'all') { renderFuturesGrid(allFuturesData); updateStats(allFuturesData); } else { const categoryMap = { 'energy': ['SC', 'EC', 'FU', 'LU', 'BU', 'RU', 'NR', 'ZC'], 'metal': ['AU', 'AG', 'CU', 'AL', 'ZN', 'NI', 'SN', 'PB', 'SS', 'RB', 'HC', 'I', 'J', 'JM', 'SF', 'SM'], 'agriculture': ['M', 'RM', 'C', 'CS', 'A', 'B', 'Y', 'P', 'OI', 'CF', 'SR', 'AP', 'JD', 'LH', 'MA', 'TA', 'EG', 'PP', 'L', 'V', 'SA', 'FG', 'UR', 'SP'], 'finance': ['IF', 'IC', 'IH', 'IM', 'T', 'TF', 'TS', 'TL'] }; const symbols = categoryMap[category] || []; const filtered = allFuturesData.filter(item => { const symbolBase = item.symbol.replace(/[0-9]/g, '').toUpperCase(); return symbols.includes(symbolBase); }); renderFuturesGrid(filtered); updateStats(filtered); } } function sortFuturesList(sortBy) { let sorted = [...allFuturesData]; 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) { updateDetailView(data.data); } } catch (error) { console.error('加载详情失败:', error); const item = allFuturesData.find(d => d.symbol === symbol); if (item) { updateDetailView({ ...item, entryPrice: item.price * 0.99, targetPrice: item.resistance, stopLoss: item.support, riskLevel: item.trendScore >= 80 ? '低' : item.trendScore >= 60 ? '中' : '高', macd: { signal: '金叉', detail: 'DIF: -0.0147' }, rsi: { value: 47, status: '正常' }, boll: { signal: '中轨', detail: '区间: 2086-2215' }, kdj: { signal: '中性', detail: 'K: 71 D: 87' }, resistances: [item.resistance, item.resistance * 1.05, item.resistance * 1.1], supports: [item.support, item.support * 0.95, item.support * 0.9], periodConsistency: { '5': item.periods['5'], '15': item.periods['15'], '30': item.periods['30'], '60': item.periods['60'] }, suggestionReason: '技术面突破,趋势明确' }); } } } function updateDetailView(data) { document.getElementById('detail-price').textContent = '¥' + formatNumber(data.price); document.getElementById('detail-price').className = 'current-price ' + (data.change >= 0 ? 'up' : 'down'); const changeEl = document.getElementById('detail-change'); const changeIcon = data.change >= 0 ? 'up' : 'down'; 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 suggestionBox = document.getElementById('suggestion-box'); suggestionBox.className = 'suggestion-box ' + data.suggestionType; document.getElementById('suggestion-action').textContent = data.suggestion; document.getElementById('suggestion-reason').textContent = data.suggestionReason || ''; document.getElementById('entry-price').textContent = formatNumber(data.entryPrice || data.price * 0.99); document.getElementById('target-price').textContent = formatNumber(data.targetPrice || data.resistance); document.getElementById('stop-loss').textContent = formatNumber(data.stopLoss || data.support); 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 < 3; i++) { const el = document.getElementById(`resistance-${i + 1}`); if (el && data.resistances[i]) { el.querySelector('.level-value').textContent = formatNumber(data.resistances[i]); } } } if (data.supports) { for (let i = 0; i < 3; i++) { const el = document.getElementById(`support-${i + 1}`); if (el && data.supports[i]) { el.querySelector('.level-value').textContent = formatNumber(data.supports[i]); } } } if (data.periodConsistency) { const container = document.getElementById('period-consistency'); 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(''); } } 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); const mockKline = generateMockKlineData(); renderKlineChart(mockKline); } } function generateMockKlineData() { const data = []; let basePrice = 2100; const now = new Date(); now.setHours(13, 0, 0, 0); for (let i = 0; i < 60; i++) { const time = new Date(now.getTime() + i * 15 * 60000); const timeStr = String(time.getHours()).padStart(2, '0') + ':' + String(time.getMinutes()).padStart(2, '0'); const open = basePrice + (Math.random() - 0.5) * 20; const close = open + (Math.random() - 0.45) * 25; const high = Math.max(open, close) + Math.random() * 10; const low = Math.min(open, close) - Math.random() * 10; const volume = Math.floor(Math.random() * 1000 + 200); data.push([timeStr, open.toFixed(2), close.toFixed(2), low.toFixed(2), high.toFixed(2), volume]); basePrice = close; } return data; } 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, legend: { data: ['K线', 'MA5', 'MA10', 'MA20', 'DIF', 'DEA', 'MACD'], top: 10, left: 10, textStyle: { color: '#9aa0ab', fontSize: 11 } }, tooltip: { trigger: 'axis', axisPointer: { type: 'cross', crossStyle: { color: '#999' } }, backgroundColor: 'rgba(26, 29, 40, 0.95)', borderColor: '#2a2d3a', textStyle: { color: '#e8eaed', fontSize: 12 }, formatter: function(params) { if (!params || params.length === 0) return ''; let result = `
${params[0].axisValue}
`; params.forEach(p => { if (p.seriesName === 'K线' && p.data) { const [o, c, l, h] = p.data; result += `开: ${o} 收: ${c}
低: ${l} 高: ${h}`; } else if (p.seriesName === '成交量') { result += `
成交量: ${p.data}`; } else if (p.seriesName === 'DIF' || p.seriesName === 'DEA') { result += `
${p.seriesName}: ${p.data}`; } else if (p.seriesName === 'MACD') { result += `
MACD: ${p.data}`; } else { result += `
${p.seriesName}: ${p.data}`; } }); return result; } }, axisPointer: { link: [{ xAxisIndex: 'all' }], label: { backgroundColor: '#22c55e' } }, grid: [ { left: 70, right: 20, top: 60, height: '48%' }, { left: 70, right: 20, top: '54%', height: '14%' }, { left: 70, right: 20, top: '73%', height: '17%' } ], xAxis: [ { type: 'category', data: dates, boundaryGap: true, axisLine: { lineStyle: { color: '#2a2d3a' } }, axisLabel: { color: '#9aa0ab', fontSize: 10 }, splitLine: { show: false } }, { type: 'category', gridIndex: 1, data: dates, boundaryGap: true, axisLine: { lineStyle: { color: '#2a2d3a' } }, axisLabel: { show: false }, splitLine: { show: false } }, { type: 'category', gridIndex: 2, data: dates, boundaryGap: true, axisLine: { lineStyle: { color: '#2a2d3a' } }, axisLabel: { color: '#9aa0ab', fontSize: 10 }, splitLine: { show: false } } ], yAxis: [ { scale: true, axisLine: { lineStyle: { color: '#2a2d3a' } }, axisLabel: { color: '#9aa0ab' }, splitLine: { lineStyle: { color: '#2a2d3a', type: 'dashed' } } }, { scale: true, gridIndex: 1, axisLine: { show: false }, axisTick: { show: false }, axisLabel: { show: false }, splitLine: { show: false } }, { scale: true, gridIndex: 2, axisLine: { lineStyle: { color: '#2a2d3a' } }, axisLabel: { color: '#9aa0ab', fontSize: 10 }, splitLine: { lineStyle: { color: '#2a2d3a', type: 'dashed' } } } ], dataZoom: [ { type: 'inside', xAxisIndex: [0, 1, 2], start: 50, end: 100 }, { show: true, xAxisIndex: [0, 1, 2], type: 'slider', bottom: 5, height: 18, borderColor: 'transparent', backgroundColor: '#1a1d28', fillerColor: 'rgba(34, 197, 94, 0.15)', handleStyle: { color: '#22c55e' }, textStyle: { color: '#9aa0ab' } } ], series: [ { name: 'K线', type: 'candlestick', data: values, itemStyle: { color: '#22c55e', color0: '#ef4444', borderColor: '#22c55e', 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 ? '#22c55e' : '#ef4444', opacity: 0.6 } })) }, { 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, idx) => ({ value: val, itemStyle: { color: val >= 0 ? '#22c55e' : '#ef4444', opacity: 0.7 } })) } ] }; 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 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; }