You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
826 lines
29 KiB
826 lines
29 KiB
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 => `
|
|
<div class="futures-card" onclick="showDetailView('${item.symbol}')">
|
|
<div class="card-header">
|
|
<div class="card-title">
|
|
<span class="card-name">${item.name}</span>
|
|
<span class="card-code">(${item.symbol})</span>
|
|
</div>
|
|
<div class="card-price">
|
|
<div class="price-value ${item.change >= 0 ? 'up' : 'down'}">¥${formatNumber(item.price)}</div>
|
|
<div class="price-change ${item.change >= 0 ? 'up' : 'down'}">
|
|
<i class="fas fa-arrow-${item.change >= 0 ? 'up' : 'down'}"></i>
|
|
+${formatNumber(item.change)} (+${item.changePct.toFixed(2)}%)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span class="suggestion-badge ${item.suggestionType}">${item.suggestion}</span>
|
|
<div class="card-section">
|
|
<div class="section-label"><i class="far fa-clock"></i> 多周期趋势</div>
|
|
<div class="period-tags">
|
|
<span class="period-tag ${item.periods['5']}"><i class="fas fa-arrow-${getArrow(item.periods['5'])}"></i> 5分</span>
|
|
<span class="period-tag ${item.periods['15']}"><i class="fas fa-arrow-${getArrow(item.periods['15'])}"></i> 15分</span>
|
|
<span class="period-tag ${item.periods['30']}"><i class="fas fa-arrow-${getArrow(item.periods['30'])}"></i> 30分</span>
|
|
<span class="period-tag ${item.periods['60']}"><i class="fas fa-arrow-${getArrow(item.periods['60'])}"></i> 60分</span>
|
|
</div>
|
|
</div>
|
|
<div class="card-section">
|
|
<div class="section-label"><i class="fas fa-chart-bar"></i> 交易成功率</div>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill ${item.successRate >= 70 ? 'up' : item.successRate >= 60 ? 'orange' : 'down'}" style="width: ${item.successRate}%"></div>
|
|
</div>
|
|
<div class="progress-info">
|
|
<span class="progress-label"></span>
|
|
<span class="progress-value ${item.successRate >= 70 ? 'up' : item.successRate >= 60 ? '' : 'down'}">${item.successRate}%</span>
|
|
</div>
|
|
</div>
|
|
<div class="card-section">
|
|
<div class="section-label">趋势评分</div>
|
|
<div class="progress-bar">
|
|
<div class="progress-fill ${item.trendScore >= 70 ? 'up' : item.trendScore >= 50 ? 'orange' : 'down'}" style="width: ${item.trendScore}%"></div>
|
|
</div>
|
|
<div class="progress-info">
|
|
<span class="progress-label"></span>
|
|
<span class="progress-value ${item.trendScore >= 70 ? 'up' : item.trendScore >= 50 ? '' : 'down'}">${item.trendScore}/100</span>
|
|
</div>
|
|
</div>
|
|
<div class="card-section">
|
|
<div class="section-label"><i class="fas fa-crosshairs"></i> 关键点位</div>
|
|
<div class="key-levels-row">
|
|
<span class="level-label">压力: <span class="level-value down">${formatNumber(item.resistance)}</span></span>
|
|
<span class="level-label">支撑: <span class="level-value up">${formatNumber(item.support)}</span></span>
|
|
</div>
|
|
</div>
|
|
<div class="card-footer">
|
|
<span class="detail-link">查看详情 <i class="fas fa-chevron-right"></i></span>
|
|
</div>
|
|
</div>
|
|
`).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 = `<i class="fas fa-arrow-${changeIcon}"></i> ${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]) => `
|
|
<div class="consistency-row">
|
|
<span class="period-name">${periodNames[period]}</span>
|
|
<span class="consistency-badge ${trend}">
|
|
<i class="fas fa-arrow-${trend === 'up' ? 'up' : trend === 'down' ? 'down' : 'right'}"></i>
|
|
${trend === 'up' ? '上涨' : trend === 'down' ? '下跌' : '震荡'}
|
|
</span>
|
|
</div>
|
|
`).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 = `<div style="font-weight:600;margin-bottom:6px">${params[0].axisValue}</div>`;
|
|
params.forEach(p => {
|
|
if (p.seriesName === 'K线' && p.data) {
|
|
const [o, c, l, h] = p.data;
|
|
result += `开: ${o} 收: ${c}<br/>低: ${l} 高: ${h}`;
|
|
} else if (p.seriesName === '成交量') {
|
|
result += `<br/>成交量: ${p.data}`;
|
|
} else if (p.seriesName === 'DIF' || p.seriesName === 'DEA') {
|
|
result += `<br/>${p.seriesName}: ${p.data}`;
|
|
} else if (p.seriesName === 'MACD') {
|
|
result += `<br/>MACD: ${p.data}`;
|
|
} else {
|
|
result += `<br/>${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;
|
|
}
|