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.
928 lines
38 KiB
928 lines
38 KiB
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 = '<div class="empty-state">暂无数据</div>';
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = data.map(item => {
|
|
const isWatched = watchedSymbols.includes(item.symbol);
|
|
return `
|
|
<div class="futures-card" onclick="showDetailView('${item.symbol}')">
|
|
<div class="card-top">
|
|
<div class="card-symbol">
|
|
<div class="symbol-tag">${item.symbol.replace(/[0-9]/g, '').substring(0, 2)}</div>
|
|
<div>
|
|
<div class="card-name">${item.name}</div>
|
|
<div class="card-code">${item.symbol}</div>
|
|
</div>
|
|
</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>
|
|
${item.change >= 0 ? '+' : ''}${formatNumber(item.change)} (${item.changePct >= 0 ? '+' : ''}${item.changePct.toFixed(2)}%)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span class="suggestion-badge ${item.suggestionType}">${item.suggestion}</span>
|
|
<div class="card-metrics">
|
|
<div class="metric-item">
|
|
<span class="metric-label">成功率</span>
|
|
<div class="metric-bar"><div class="metric-fill ${item.successRate >= 70 ? 'up' : item.successRate >= 60 ? 'orange' : 'down'}" style="width: ${item.successRate}%"></div></div>
|
|
<span class="metric-value ${item.successRate >= 70 ? 'up' : item.successRate >= 60 ? '' : 'down'}">${item.successRate}%</span>
|
|
</div>
|
|
<div class="metric-item">
|
|
<span class="metric-label">趋势评分</span>
|
|
<div class="metric-bar"><div class="metric-fill ${item.trendScore >= 70 ? 'up' : item.trendScore >= 50 ? 'orange' : 'down'}" style="width: ${item.trendScore}%"></div></div>
|
|
<span class="metric-value ${item.trendScore >= 70 ? 'up' : item.trendScore >= 50 ? '' : 'down'}">${item.trendScore}</span>
|
|
</div>
|
|
</div>
|
|
<div class="period-trends">
|
|
<span class="period-tag ${item.periods['5']}">5M</span>
|
|
<span class="period-tag ${item.periods['15']}">15M</span>
|
|
<span class="period-tag ${item.periods['30']}">30M</span>
|
|
<span class="period-tag ${item.periods['60']}">1H</span>
|
|
</div>
|
|
<div class="card-footer">
|
|
<div class="key-levels">
|
|
<span><span class="label">压力</span> <span class="down">${formatNumber(item.resistance)}</span></span>
|
|
<span><span class="label">支撑</span> <span class="up">${formatNumber(item.support)}</span></span>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:8px;">
|
|
<button class="watch-btn ${isWatched ? 'active' : ''}" onclick="toggleWatch('${item.symbol}', '${item.name}', event)" title="${isWatched ? '取消自选' : '加入自选'}">
|
|
<i class="fas fa-star"></i>
|
|
</button>
|
|
<span class="detail-link"><i class="fas fa-arrow-right"></i> 详情</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`}).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 = `<i class="fas fa-arrow-${data.change >= 0 ? 'up' : 'down'}"></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 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]) => `
|
|
<div class="trend-row">
|
|
<span class="trend-period">${periodNames[period]}</span>
|
|
<span class="trend-badge ${trend}">
|
|
${trend === 'up' ? '上涨' : trend === 'down' ? '下跌' : '震荡'}
|
|
</span>
|
|
</div>
|
|
`).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 = '<div class="empty-state">暂无历史记录</div>';
|
|
}
|
|
}
|
|
|
|
function renderHistoryList(records) {
|
|
const container = document.getElementById('history-list');
|
|
if (!records || records.length === 0) {
|
|
container.innerHTML = '<div class="empty-state">暂无历史记录</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = records.map(record => `
|
|
<div class="history-item" onclick="showHistoryModal(${JSON.stringify(record).replace(/"/g, '"')})">
|
|
<div class="history-item-left">
|
|
<span class="history-time">${record.analysis_time ? record.analysis_time.replace('T', ' ').substring(0, 16) : '--'}</span>
|
|
<span class="history-suggestion ${record.suggestion_type || 'neutral'}">${record.suggestion || '--'}</span>
|
|
<span class="history-score">评分: ${record.trend_score || '--'}</span>
|
|
</div>
|
|
<div class="history-item-right">
|
|
<div class="history-metric">
|
|
<span class="history-metric-label">MACD</span>
|
|
<span class="history-metric-value">${record.macd_signal || '--'}</span>
|
|
</div>
|
|
<div class="history-metric">
|
|
<span class="history-metric-label">RSI</span>
|
|
<span class="history-metric-value">${record.rsi_value || '--'}</span>
|
|
</div>
|
|
<button class="history-detail-btn" onclick="event.stopPropagation(); showHistoryModal(${JSON.stringify(record).replace(/"/g, '"')})">
|
|
<i class="fas fa-chevron-right"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function showSuggestionModal(data) {
|
|
const body = document.getElementById('suggestion-modal-body');
|
|
body.innerHTML = `
|
|
<div class="modal-suggestion-main">
|
|
<div class="suggestion-badge ${data.suggestionType || 'neutral'}" style="font-size:24px;font-weight:700;">${data.suggestion || '--'}</div>
|
|
<div class="suggestion-reason" style="margin-top:8px;">${data.suggestionReason || '--'}</div>
|
|
</div>
|
|
<div class="modal-params-grid">
|
|
<div class="modal-param-card">
|
|
<span class="param-label">建议入场</span>
|
|
<span class="param-value">${formatNumber(data.entryPrice)}</span>
|
|
</div>
|
|
<div class="modal-param-card">
|
|
<span class="param-label">目标价位</span>
|
|
<span class="param-value up">${formatNumber(data.targetPrice)}</span>
|
|
</div>
|
|
<div class="modal-param-card">
|
|
<span class="param-label">止损价位</span>
|
|
<span class="param-value down">${formatNumber(data.stopLoss)}</span>
|
|
</div>
|
|
<div class="modal-param-card">
|
|
<span class="param-label">风险等级</span>
|
|
<span class="param-value">${data.riskLevel || '--'}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.getElementById('suggestion-modal').classList.add('active');
|
|
}
|
|
|
|
function showHistoryModal(record) {
|
|
const body = document.getElementById('history-modal-body');
|
|
body.innerHTML = `
|
|
<div class="modal-section">
|
|
<div class="modal-section-title"><i class="fas fa-robot"></i> AI交易建议</div>
|
|
<div class="modal-suggestion-main" style="margin-bottom:0;">
|
|
<div class="suggestion-badge ${record.suggestion_type || 'neutral'}" style="font-size:20px;font-weight:700;">${record.suggestion || '--'}</div>
|
|
</div>
|
|
<div class="modal-params-grid" style="margin-top:12px;">
|
|
<div class="modal-param-card">
|
|
<span class="param-label">入场</span>
|
|
<span class="param-value">${formatNumber(record.entry_price)}</span>
|
|
</div>
|
|
<div class="modal-param-card">
|
|
<span class="param-label">目标</span>
|
|
<span class="param-value up">${formatNumber(record.target_price)}</span>
|
|
</div>
|
|
<div class="modal-param-card">
|
|
<span class="param-label">止损</span>
|
|
<span class="param-value down">${formatNumber(record.stop_loss)}</span>
|
|
</div>
|
|
<div class="modal-param-card">
|
|
<span class="param-label">风险</span>
|
|
<span class="param-value">${record.risk_level || '--'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-section">
|
|
<div class="modal-section-title"><i class="fas fa-wave-pulse"></i> 技术指标</div>
|
|
<div class="modal-indicators-grid">
|
|
<div class="modal-indicator-item">
|
|
<span class="indicator-label">MACD</span>
|
|
<span class="indicator-value">${record.macd_signal || '--'}</span>
|
|
</div>
|
|
<div class="modal-indicator-item">
|
|
<span class="indicator-label">RSI</span>
|
|
<span class="indicator-value">${record.rsi_value || '--'}</span>
|
|
</div>
|
|
<div class="modal-indicator-item">
|
|
<span class="indicator-label">BOLL</span>
|
|
<span class="indicator-value">${record.boll_signal || '--'}</span>
|
|
</div>
|
|
<div class="modal-indicator-item">
|
|
<span class="indicator-label">KDJ</span>
|
|
<span class="indicator-value">${record.kdj_signal || '--'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-section">
|
|
<div class="modal-section-title"><i class="fas fa-crosshairs"></i> 关键点位</div>
|
|
<div class="modal-levels-list">
|
|
${(record.resistance_levels || []).map((v, i) => `
|
|
<div class="modal-level-row">
|
|
<span class="level-label">压力${i + 1}</span>
|
|
<span class="level-value down">${formatNumber(v)}</span>
|
|
</div>
|
|
`).join('')}
|
|
${record.pivot_point ? `
|
|
<div class="modal-level-row" style="background:rgba(139,92,246,0.1);border-radius:8px;">
|
|
<span class="level-label" style="color:#8b5cf6;font-weight:600;">中枢 (PP)</span>
|
|
<span class="level-value" style="color:#8b5cf6;">${formatNumber(record.pivot_point)}</span>
|
|
</div>
|
|
` : ''}
|
|
${(record.support_levels || []).map((v, i) => `
|
|
<div class="modal-level-row">
|
|
<span class="level-label">支撑${i + 1}</span>
|
|
<span class="level-value up">${formatNumber(v)}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="modal-section">
|
|
<div class="modal-section-title"><i class="fas fa-timeline"></i> 多周期趋势</div>
|
|
<div class="modal-trends-list">
|
|
${Object.entries(record.period_trends || {}).map(([period, trend]) => {
|
|
const names = { '5': '5分钟', '15': '15分钟', '30': '30分钟', '60': '60分钟' };
|
|
return `<div class="modal-trend-row">
|
|
<span class="modal-trend-period">${names[period] || period}</span>
|
|
<span class="modal-trend-badge ${trend}">${trend === 'up' ? '上涨' : trend === 'down' ? '下跌' : '震荡'}</span>
|
|
</div>`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="modal-section">
|
|
<div class="modal-section-title"><i class="fas fa-gauge-high"></i> 趋势评分</div>
|
|
<div class="modal-param-card" style="text-align:center;">
|
|
<span class="param-value" style="font-size:32px;">${record.trend_score || '--'}</span>
|
|
<span class="param-label" style="display:block;margin-top:4px;">综合评分</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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;
|
|
}
|