|
|
|
|
|
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() {
|
|
|
|
|
|
const backBtn = document.getElementById('back-btn');
|
|
|
|
|
|
if (backBtn) backBtn.addEventListener('click', showListView);
|
|
|
|
|
|
|
|
|
|
|
|
const themeToggle = document.getElementById('theme-toggle');
|
|
|
|
|
|
if (themeToggle) themeToggle.addEventListener('click', toggleTheme);
|
|
|
|
|
|
|
|
|
|
|
|
const refreshAllBtn = document.getElementById('refresh-all-btn');
|
|
|
|
|
|
if (refreshAllBtn) refreshAllBtn.addEventListener('click', refreshAllSymbols);
|
|
|
|
|
|
|
|
|
|
|
|
const aiAnalyzeAllBtn = document.getElementById('ai-analyze-all-btn');
|
|
|
|
|
|
if (aiAnalyzeAllBtn) aiAnalyzeAllBtn.addEventListener('click', analyzeAllSymbols);
|
|
|
|
|
|
|
|
|
|
|
|
const refreshSymbolBtn = document.getElementById('refresh-symbol-btn');
|
|
|
|
|
|
if (refreshSymbolBtn) refreshSymbolBtn.addEventListener('click', function() {
|
|
|
|
|
|
if (currentSymbol) {
|
|
|
|
|
|
refreshSingleSymbol(currentSymbol);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
|
|
|
|
if (searchInput) searchInput.addEventListener('input', function() {
|
|
|
|
|
|
filterFuturesList(this.value);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('.pill').forEach(tab => {
|
|
|
|
|
|
if (tab.dataset.category) {
|
|
|
|
|
|
tab.addEventListener('click', function() {
|
|
|
|
|
|
document.querySelectorAll('.pill').forEach(t => t.classList.remove('active'));
|
|
|
|
|
|
this.classList.add('active');
|
|
|
|
|
|
filterByCategory(this.dataset.category);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const sortSelect = document.getElementById('sort-select');
|
|
|
|
|
|
if (sortSelect) sortSelect.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');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 导航项点击事件
|
|
|
|
|
|
document.querySelectorAll('.nav-item[data-page]').forEach(navItem => {
|
|
|
|
|
|
navItem.addEventListener('click', function(e) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const page = this.dataset.page;
|
|
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
|
|
|
|
|
|
this.classList.add('active');
|
|
|
|
|
|
|
|
|
|
|
|
if (page === 'review') {
|
|
|
|
|
|
showReviewView();
|
|
|
|
|
|
} else if (page === 'trade-review') {
|
|
|
|
|
|
showTradeReviewView();
|
|
|
|
|
|
} else if (page === 'watched') {
|
|
|
|
|
|
showWatchedView();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showListView();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
document.getElementById('review-view').classList.remove('active');
|
|
|
|
|
|
document.getElementById('watched-view').classList.remove('active');
|
|
|
|
|
|
document.getElementById('trade-review-view').classList.remove('active');
|
|
|
|
|
|
if (klineChart) {
|
|
|
|
|
|
klineChart.dispose();
|
|
|
|
|
|
klineChart = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showWatchedView() {
|
|
|
|
|
|
document.getElementById('list-view').classList.remove('active');
|
|
|
|
|
|
document.getElementById('detail-view').classList.remove('active');
|
|
|
|
|
|
document.getElementById('review-view').classList.remove('active');
|
|
|
|
|
|
document.getElementById('watched-view').classList.add('active');
|
|
|
|
|
|
document.getElementById('trade-review-view').classList.remove('active');
|
|
|
|
|
|
if (klineChart) {
|
|
|
|
|
|
klineChart.dispose();
|
|
|
|
|
|
klineChart = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderWatchedList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showReviewView() {
|
|
|
|
|
|
document.getElementById('list-view').classList.remove('active');
|
|
|
|
|
|
document.getElementById('detail-view').classList.remove('active');
|
|
|
|
|
|
document.getElementById('review-view').classList.add('active');
|
|
|
|
|
|
document.getElementById('watched-view').classList.remove('active');
|
|
|
|
|
|
document.getElementById('trade-review-view').classList.remove('active');
|
|
|
|
|
|
if (klineChart) {
|
|
|
|
|
|
klineChart.dispose();
|
|
|
|
|
|
klineChart = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化复盘计划功能
|
|
|
|
|
|
initReviewPlan();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showTradeReviewView() {
|
|
|
|
|
|
document.getElementById('list-view').classList.remove('active');
|
|
|
|
|
|
document.getElementById('detail-view').classList.remove('active');
|
|
|
|
|
|
document.getElementById('review-view').classList.remove('active');
|
|
|
|
|
|
document.getElementById('watched-view').classList.remove('active');
|
|
|
|
|
|
document.getElementById('trade-review-view').classList.add('active');
|
|
|
|
|
|
if (klineChart) {
|
|
|
|
|
|
klineChart.dispose();
|
|
|
|
|
|
klineChart = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
initTradeReview();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function showDetailView(symbol) {
|
|
|
|
|
|
currentSymbol = symbol;
|
|
|
|
|
|
document.getElementById('list-view').classList.remove('active');
|
|
|
|
|
|
document.getElementById('detail-view').classList.add('active');
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 加载行情数据
|
|
|
|
|
|
loadFuturesDetail(symbol);
|
|
|
|
|
|
loadKlineData(symbol, currentPeriod);
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 加载历史记录
|
|
|
|
|
|
await loadHistoryListForAnalysis(symbol);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadHistoryListForAnalysis(symbol) {
|
|
|
|
|
|
console.log(`[AI分析] 开始加载 ${symbol} 的历史分析记录...`);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/ai-analysis/${symbol}/history?limit=20`);
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[AI分析] API响应:`, data);
|
|
|
|
|
|
|
|
|
|
|
|
if (data.success) {
|
|
|
|
|
|
renderHistoryList(data.data);
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 查找今天的最新分析记录
|
|
|
|
|
|
const today = new Date();
|
|
|
|
|
|
const todayStr = today.toISOString().split('T')[0];
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`[AI分析] 查找日期: ${todayStr}`);
|
|
|
|
|
|
console.log(`[AI分析] 历史记录数量: ${data.data?.length || 0}`);
|
|
|
|
|
|
|
|
|
|
|
|
let todayRecord = null;
|
|
|
|
|
|
if (data.data && data.data.length > 0) {
|
|
|
|
|
|
for (const record of data.data) {
|
|
|
|
|
|
const recordDate = new Date(record.analysis_time);
|
|
|
|
|
|
const recordDateStr = recordDate.toISOString().split('T')[0];
|
|
|
|
|
|
console.log(`[AI分析] 检查记录: ${record.analysis_time} -> ${recordDateStr}`);
|
|
|
|
|
|
if (recordDateStr === todayStr) {
|
|
|
|
|
|
todayRecord = record;
|
|
|
|
|
|
console.log(`[AI分析] ✓ 找到今天的记录!`);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 根据是否有今天的记录进行不同处理
|
|
|
|
|
|
if (todayRecord) {
|
|
|
|
|
|
console.log(`[AI分析] 显示AI分析结果...`);
|
|
|
|
|
|
currentAIAnalysis = {
|
|
|
|
|
|
id: todayRecord.id,
|
|
|
|
|
|
symbol: todayRecord.symbol,
|
|
|
|
|
|
analysis_time: todayRecord.analysis_time,
|
|
|
|
|
|
result: todayRecord.analysis_data
|
|
|
|
|
|
};
|
|
|
|
|
|
displayAIAnalysisResult(currentAIAnalysis);
|
|
|
|
|
|
syncAIToPanels(todayRecord.analysis_data);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(`[AI分析] 没有找到今天的分析记录,显示占位符`);
|
|
|
|
|
|
showAIAnalysisPlaceholder();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(`[AI分析] API返回失败,显示占位符`);
|
|
|
|
|
|
showAIAnalysisPlaceholder();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[AI分析] 加载历史记录失败:', error);
|
|
|
|
|
|
showAIAnalysisPlaceholder();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showAIAnalysisPlaceholder() {
|
|
|
|
|
|
console.log('[AI分析] 显示占位符...');
|
|
|
|
|
|
const content = document.getElementById('ai-analysis-content');
|
|
|
|
|
|
console.log('[AI分析] ai-analysis-content元素:', content);
|
|
|
|
|
|
if (!content) {
|
|
|
|
|
|
console.error('[AI分析] 错误: 找不到ai-analysis-content元素!');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
content.innerHTML = `
|
|
|
|
|
|
<div style="text-align: center; padding: 28px; color: var(--text-tertiary);">
|
|
|
|
|
|
<p>点击"智能分析"按钮获取AI分析结果</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
console.log('[AI分析] 占位符已显示');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
} 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);
|
|
|
|
|
|
showToast('success', '已取消自选', `${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);
|
|
|
|
|
|
showToast('success', '已添加自选', `${name}(${symbol}) 已添加到自选列表`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 重新渲染当前视图
|
|
|
|
|
|
const activePill = document.querySelector('.pill.active');
|
|
|
|
|
|
const category = activePill ? activePill.dataset.category : 'all';
|
|
|
|
|
|
|
|
|
|
|
|
// 如果当前视图不是自选页面,重新渲染主网格
|
|
|
|
|
|
if (category !== 'watched') {
|
|
|
|
|
|
renderFuturesGrid(getCurrentFilteredData());
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('切换自选失败:', error);
|
|
|
|
|
|
showToast('error', '操作失败', '网络错误,请稍后重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleFav(symbol, name, event) {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
|
|
|
|
|
|
// 调用原有的toggleWatch函数处理API请求和数据更新
|
|
|
|
|
|
toggleWatch(symbol, name, event);
|
|
|
|
|
|
|
|
|
|
|
|
// 如果当前在自选页面,刷新自选列表
|
|
|
|
|
|
const watchedView = document.getElementById('watched-view');
|
|
|
|
|
|
if (watchedView && watchedView.classList.contains('active')) {
|
|
|
|
|
|
setTimeout(() => renderWatchedList(), 200);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getCurrentFilteredData() {
|
|
|
|
|
|
const activePill = document.querySelector('.pill.active');
|
|
|
|
|
|
const category = activePill ? activePill.dataset.category : 'all';
|
|
|
|
|
|
return filterDataByCategory(allFuturesData, category);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function filterDataByCategory(data, category) {
|
|
|
|
|
|
if (category === 'all') return data;
|
|
|
|
|
|
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 {
|
|
|
|
|
|
console.log('正在加载品种列表...');
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/list`);
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
console.log('品种列表响应:', data);
|
|
|
|
|
|
if (data.success) {
|
|
|
|
|
|
allFuturesData = data.data.map(item => ({
|
|
|
|
|
|
...item,
|
|
|
|
|
|
hasAIAnalysis: false // 默认没有AI分析数据
|
|
|
|
|
|
}));
|
|
|
|
|
|
console.log('加载的品种数据:', allFuturesData.length, '个');
|
|
|
|
|
|
renderFuturesGrid(allFuturesData);
|
|
|
|
|
|
updateStats(allFuturesData);
|
|
|
|
|
|
|
|
|
|
|
|
// 异步加载每个合约的最新AI分析结果
|
|
|
|
|
|
loadAllAIAnalysis();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('加载品种列表失败:', data);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载品种列表失败:', error);
|
|
|
|
|
|
loadFuturesFromConfig();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadAllAIAnalysis() {
|
|
|
|
|
|
console.log('开始加载所有合约的AI分析结果...');
|
|
|
|
|
|
|
|
|
|
|
|
// 获取今天的日期字符串用于比较
|
|
|
|
|
|
const today = new Date();
|
|
|
|
|
|
const todayStr = today.toISOString().split('T')[0]; // YYYY-MM-DD
|
|
|
|
|
|
|
|
|
|
|
|
// 分批加载,避免并发请求过多
|
|
|
|
|
|
const batchSize = 5;
|
|
|
|
|
|
for (let i = 0; i < allFuturesData.length; i += batchSize) {
|
|
|
|
|
|
const batch = allFuturesData.slice(i, i + batchSize);
|
|
|
|
|
|
const promises = batch.map(async (item) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 获取历史记录
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/ai-analysis/${item.symbol}/history?limit=1`);
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (data.success && data.data && data.data.length > 0) {
|
|
|
|
|
|
const latestRecord = data.data[0]; // 最新的一条记录
|
|
|
|
|
|
const analysisTime = latestRecord.analysis_time;
|
|
|
|
|
|
|
|
|
|
|
|
// 判断是否是今天的记录
|
|
|
|
|
|
const recordDate = new Date(analysisTime);
|
|
|
|
|
|
const recordDateStr = recordDate.toISOString().split('T')[0];
|
|
|
|
|
|
|
|
|
|
|
|
if (recordDateStr === todayStr) {
|
|
|
|
|
|
// 是今天的记录,加载数据
|
|
|
|
|
|
const result = latestRecord.analysis_data;
|
|
|
|
|
|
const analysisItem = allFuturesData.find(d => d.symbol === item.symbol);
|
|
|
|
|
|
if (analysisItem) {
|
|
|
|
|
|
analysisItem.hasAIAnalysis = true;
|
|
|
|
|
|
analysisItem.aiResult = result;
|
|
|
|
|
|
analysisItem.analysisTime = analysisTime;
|
|
|
|
|
|
|
|
|
|
|
|
// 更新操作建议
|
|
|
|
|
|
if (result.trading_suggestion?.direction) {
|
|
|
|
|
|
analysisItem.suggestion = result.trading_suggestion.direction;
|
|
|
|
|
|
analysisItem.suggestionType = result.trading_suggestion.direction === '做多' ? 'up' : result.trading_suggestion.direction === '做空' ? 'down' : 'neutral';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新压力支撑位
|
|
|
|
|
|
if (result.pivot_points) {
|
|
|
|
|
|
if (result.pivot_points.r1) analysisItem.resistance = result.pivot_points.r1;
|
|
|
|
|
|
if (result.pivot_points.s1) analysisItem.support = result.pivot_points.s1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新多周期趋势
|
|
|
|
|
|
if (result.four_dimensional) {
|
|
|
|
|
|
const periodMap = { '60min': '60', '30min': '30', '15min': '15', '5min': '5' };
|
|
|
|
|
|
analysisItem.periods = {};
|
|
|
|
|
|
Object.entries(result.four_dimensional).forEach(([period, pdata]) => {
|
|
|
|
|
|
const periodNum = periodMap[period];
|
|
|
|
|
|
if (periodNum) {
|
|
|
|
|
|
const trend = pdata.conclusion || pdata.macd?.trend || 'neutral';
|
|
|
|
|
|
analysisItem.periods[periodNum] = trend.includes('多') || trend === 'up' ? 'up' : trend.includes('空') || trend === 'down' ? 'down' : 'neutral';
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新趋势评分(使用AI置信度)
|
|
|
|
|
|
if (result.trading_suggestion?.confidence) {
|
|
|
|
|
|
analysisItem.trendScore = result.trading_suggestion.confidence;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新成功率(根据判断方向设置一致概率)
|
|
|
|
|
|
const direction = analysisItem.suggestionType;
|
|
|
|
|
|
if (direction === 'up') {
|
|
|
|
|
|
analysisItem.successRate = 85; // 做多成功率
|
|
|
|
|
|
} else if (direction === 'down') {
|
|
|
|
|
|
analysisItem.successRate = 82; // 做空成功率
|
|
|
|
|
|
} else {
|
|
|
|
|
|
analysisItem.successRate = 60; // 观望成功率
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(`${item.symbol} 的分析记录不是今天的 (${recordDateStr}),不加载`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(`${item.symbol} 没有AI分析记录`);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(`加载 ${item.symbol} AI分析失败:`, error);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
await Promise.all(promises);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 所有批次加载完成后只渲染一次
|
|
|
|
|
|
renderFuturesGrid(allFuturesData);
|
|
|
|
|
|
console.log('所有合约AI分析结果加载完成');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
console.log('渲染品种网格,数据量:', data.length);
|
|
|
|
|
|
if (data.length === 0) {
|
|
|
|
|
|
grid.innerHTML = '<div class="empty-state">暂无数据</div>';
|
|
|
|
|
|
console.log('显示暂无数据');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
grid.innerHTML = data.map(item => {
|
|
|
|
|
|
const code = item.symbol.replace(/[0-9]/g, '').substring(0, 2);
|
|
|
|
|
|
const changeIcon = item.change >= 0 ? '↑' : '↓';
|
|
|
|
|
|
const changeSign = item.change >= 0 ? '+' : '';
|
|
|
|
|
|
const pctSign = item.changePct >= 0 ? '+' : '';
|
|
|
|
|
|
|
|
|
|
|
|
let actionPillClass = 'watch';
|
|
|
|
|
|
let actionPillText = '观望';
|
|
|
|
|
|
if (item.suggestion?.includes('做多') || item.suggestion?.includes('试多')) {
|
|
|
|
|
|
actionPillClass = 'do-more';
|
|
|
|
|
|
actionPillText = '做多';
|
|
|
|
|
|
} else if (item.suggestion?.includes('做空') || item.suggestion?.includes('试空')) {
|
|
|
|
|
|
actionPillClass = 'do-more';
|
|
|
|
|
|
actionPillText = '做空';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const priceClass = item.change >= 0 ? 'up' : 'down';
|
|
|
|
|
|
const changeClass = item.change >= 0 ? 'up' : 'down';
|
|
|
|
|
|
|
|
|
|
|
|
const successRate = item.successRate || 0;
|
|
|
|
|
|
const trendScore = item.trendScore || 0;
|
|
|
|
|
|
const successColor = successRate >= 70 ? 'var(--color-down)' : successRate >= 60 ? 'var(--color-neutral)' : 'var(--color-up)';
|
|
|
|
|
|
const trendColor = trendScore >= 70 ? 'var(--color-down)' : trendScore >= 50 ? 'var(--color-neutral)' : 'var(--color-up)';
|
|
|
|
|
|
|
|
|
|
|
|
const periods = item.periods || {};
|
|
|
|
|
|
const resistance = item.resistance ? formatNumber(item.resistance) : '--';
|
|
|
|
|
|
const support = item.support ? formatNumber(item.support) : '--';
|
|
|
|
|
|
|
|
|
|
|
|
const isWatched = watchedSymbols.includes(item.symbol);
|
|
|
|
|
|
const watchIcon = isWatched ? 'fa-star' : 'fa-star-o';
|
|
|
|
|
|
const watchClass = isWatched ? 'active' : '';
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="card" onclick="showDetailView('${item.symbol}')">
|
|
|
|
|
|
<div class="card-header">
|
|
|
|
|
|
<div class="card-left">
|
|
|
|
|
|
<div class="code-box">${code}</div>
|
|
|
|
|
|
<div class="info-group">
|
|
|
|
|
|
<div class="name-row">${item.name} <span class="fav-icon ${isWatched ? 'active' : ''}" onclick="toggleFav('${item.symbol}', '${item.name}', event)">★</span></div>
|
|
|
|
|
|
<div class="contract-code">${item.symbol}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="price-area">
|
|
|
|
|
|
<div class="price ${priceClass}">¥${formatNumber(item.price)}</div>
|
|
|
|
|
|
<div class="change ${changeClass}">${changeIcon} ${changeSign}${formatNumber(item.change)} (${pctSign}${item.changePct.toFixed(2)}%)</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="action-pill ${actionPillClass}">${actionPillText}</div>
|
|
|
|
|
|
<div class="metrics">
|
|
|
|
|
|
<div class="metric">
|
|
|
|
|
|
<div class="metric-label">成功率 ${successRate}%</div>
|
|
|
|
|
|
<div class="bar-bg"><div class="bar-fill" style="width:${successRate}%; background:${successColor};"></div></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="metric">
|
|
|
|
|
|
<div class="metric-label">趋势评分 ${trendScore}</div>
|
|
|
|
|
|
<div class="bar-bg"><div class="bar-fill" style="width:${trendScore}%; background:${trendColor};"></div></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="timeframes">
|
|
|
|
|
|
<div class="tf ${periods['5'] === 'up' ? 'active' : ''}">5M</div>
|
|
|
|
|
|
<div class="tf ${periods['15'] === 'up' ? 'active' : ''}">15M</div>
|
|
|
|
|
|
<div class="tf ${periods['30'] === 'up' ? 'active' : ''}">30M</div>
|
|
|
|
|
|
<div class="tf ${periods['60'] === 'up' ? 'active' : ''}">1H</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card-footer">
|
|
|
|
|
|
<div class="support-resist">
|
|
|
|
|
|
<span>压力 <b class="red">${resistance}</b></span>
|
|
|
|
|
|
<span>支撑 <b class="green">${support}</b></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<a href="#" class="link" onclick="event.stopPropagation(); showDetailView('${item.symbol}'); return false;">详情 →</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 交易反思子系统 ====================
|
|
|
|
|
|
|
|
|
|
|
|
let trReflCurrentDate = null;
|
|
|
|
|
|
let trReflData = null;
|
|
|
|
|
|
let trReflTags = [];
|
|
|
|
|
|
let trReflExperiences = [];
|
|
|
|
|
|
let trReflTagState = new Set();
|
|
|
|
|
|
let trExpPage = 1;
|
|
|
|
|
|
let trExpPageSize = 12;
|
|
|
|
|
|
let trExpTotal = 0;
|
|
|
|
|
|
let trReflCurrentPairId = null;
|
|
|
|
|
|
let trReflCurrentReflectionId = null;
|
|
|
|
|
|
let trReflCurrentAIResult = null;
|
|
|
|
|
|
let trReflAIHistory = [];
|
|
|
|
|
|
let trReflCurrentAnalysis = null;
|
|
|
|
|
|
|
|
|
|
|
|
function trSwitchSubtab(tab) {
|
|
|
|
|
|
document.querySelectorAll('#tr-subtabs .tr-subtab').forEach(btn => {
|
|
|
|
|
|
btn.classList.toggle('active', btn.dataset.subtab === tab);
|
|
|
|
|
|
});
|
|
|
|
|
|
document.querySelectorAll('#trade-review-view > .tr-tab-panel').forEach(panel => {
|
|
|
|
|
|
panel.classList.toggle('active', panel.id === 'tr-tab-' + tab);
|
|
|
|
|
|
});
|
|
|
|
|
|
if (tab === 'experience') trExpLoad();
|
|
|
|
|
|
if (tab === 'data') trLoadDataOverview();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trSwitchToReflection(date) {
|
|
|
|
|
|
trSwitchSubtab('reflection');
|
|
|
|
|
|
trReflSetDate(date);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflInit() {
|
|
|
|
|
|
const dateInput = document.getElementById('tr-refl-current-date');
|
|
|
|
|
|
if (!dateInput) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 默认日期取统计分析的最新交易日或今天
|
|
|
|
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
|
|
|
|
trReflSetDate(today);
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('tr-refl-prev').addEventListener('click', () => trReflChangeDate(-1));
|
|
|
|
|
|
document.getElementById('tr-refl-next').addEventListener('click', () => trReflChangeDate(1));
|
|
|
|
|
|
document.getElementById('tr-refl-edit-daily').addEventListener('click', trReflOpenDailyModal);
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('tr-refl-edit-form').addEventListener('submit', e => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
trReflSaveEdit();
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById('tr-refl-daily-form').addEventListener('submit', e => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
trReflSaveDaily();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const saveAndAiBtn = document.getElementById('tr-refl-save-and-ai');
|
|
|
|
|
|
if (saveAndAiBtn) {
|
|
|
|
|
|
saveAndAiBtn.addEventListener('click', e => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
trReflSaveAndAnalyze();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const manualPairBtn = document.getElementById('tr-refl-manual-pair-btn');
|
|
|
|
|
|
if (manualPairBtn) manualPairBtn.addEventListener('click', trReflManualPair);
|
|
|
|
|
|
|
|
|
|
|
|
const selectAll = document.getElementById('tr-refl-unpaired-select-all');
|
|
|
|
|
|
if (selectAll) {
|
|
|
|
|
|
selectAll.addEventListener('change', function() {
|
|
|
|
|
|
document.querySelectorAll('.tr-refl-unpaired-check').forEach(cb => cb.checked = this.checked);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll('#tr-refl-discipline-score .tr-form-star, #tr-refl-daily-discipline-score .tr-form-star').forEach(star => {
|
|
|
|
|
|
star.addEventListener('click', () => {
|
|
|
|
|
|
const container = star.parentElement;
|
|
|
|
|
|
const score = parseInt(star.dataset.score);
|
|
|
|
|
|
container.dataset.value = score;
|
|
|
|
|
|
trReflUpdateStars(container, score);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('tr-exp-search').addEventListener('input', () => { trExpPage = 1; trExpLoad(); });
|
|
|
|
|
|
document.getElementById('tr-exp-tag-filter').addEventListener('change', () => { trExpPage = 1; trExpLoad(); });
|
|
|
|
|
|
document.getElementById('tr-exp-type-filter').addEventListener('change', () => { trExpPage = 1; trExpLoad(); });
|
|
|
|
|
|
document.getElementById('tr-exp-prev').addEventListener('click', () => { if (trExpPage > 1) { trExpPage--; trExpLoad(); } });
|
|
|
|
|
|
document.getElementById('tr-exp-next').addEventListener('click', () => { if (trExpPage * trExpPageSize < trExpTotal) { trExpPage++; trExpLoad(); } });
|
|
|
|
|
|
document.querySelectorAll('.tr-exp-viewtab').forEach(btn => {
|
|
|
|
|
|
btn.addEventListener('click', () => {
|
|
|
|
|
|
document.querySelectorAll('.tr-exp-viewtab').forEach(b => b.classList.remove('active'));
|
|
|
|
|
|
btn.classList.add('active');
|
|
|
|
|
|
trExpRender();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
trReflLoadTags();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflSetDate(date) {
|
|
|
|
|
|
trReflCurrentDate = date;
|
|
|
|
|
|
document.getElementById('tr-refl-current-date').textContent = date;
|
|
|
|
|
|
trReflLoadData();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflChangeDate(days) {
|
|
|
|
|
|
const d = new Date(trReflCurrentDate);
|
|
|
|
|
|
d.setDate(d.getDate() + days);
|
|
|
|
|
|
trReflSetDate(d.toISOString().slice(0, 10));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trReflLoadData() {
|
|
|
|
|
|
if (!trReflCurrentDate) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/daily-trades/${trReflCurrentDate}`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
trReflData = json.success ? json.data : null;
|
|
|
|
|
|
if (!trReflData) trReflData = { pairs: [], unpaired: [], daily_reflection: null, summary: {} };
|
|
|
|
|
|
trReflRender();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('加载反思数据失败', e);
|
|
|
|
|
|
trReflData = { pairs: [], unpaired: [], daily_reflection: null, summary: {} };
|
|
|
|
|
|
trReflRender();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflRender() {
|
|
|
|
|
|
const data = trReflData || { pairs: [], unpaired: [], daily_reflection: null, summary: {} };
|
|
|
|
|
|
const summary = data.summary || {};
|
|
|
|
|
|
const daily = data.daily_reflection || {};
|
|
|
|
|
|
|
|
|
|
|
|
const pnl = summary.total_pnl || 0;
|
|
|
|
|
|
const pnlEl = document.getElementById('tr-refl-stat-pnl');
|
|
|
|
|
|
pnlEl.textContent = (pnl >= 0 ? '+' : '') + pnl.toFixed(2);
|
|
|
|
|
|
pnlEl.className = 'tr-refl-statvalue ' + (pnl >= 0 ? 'profit' : 'loss');
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('tr-refl-stat-trades').textContent = summary.total_trades || 0;
|
|
|
|
|
|
document.getElementById('tr-refl-stat-winrate').textContent = (summary.win_rate || 0) + '%';
|
|
|
|
|
|
|
|
|
|
|
|
const reflectedEl = document.getElementById('tr-refl-stat-reflected');
|
|
|
|
|
|
const reflected = summary.reflected_count || 0;
|
|
|
|
|
|
const totalPairs = (data.pairs || []).length;
|
|
|
|
|
|
if (totalPairs === 0) {
|
|
|
|
|
|
reflectedEl.innerHTML = '<span class="tr-refl-reflected no">无交易</span>';
|
|
|
|
|
|
} else if (reflected >= totalPairs) {
|
|
|
|
|
|
reflectedEl.innerHTML = '<span class="tr-refl-reflected yes">✓ 已完成</span>';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
reflectedEl.innerHTML = `<span class="tr-refl-reflected no">${reflected}/${totalPairs}</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('tr-refl-emotion').textContent = trReflTextMap.emotion[daily.emotion_state] || daily.emotion_state || '未填写';
|
|
|
|
|
|
document.getElementById('tr-refl-emotion').className = 'tr-refl-daily-value ' + (daily.emotion_state ? '' : 'empty');
|
|
|
|
|
|
document.getElementById('tr-refl-discipline').textContent = daily.discipline_score ? daily.discipline_score + '/5' : '未填写';
|
|
|
|
|
|
document.getElementById('tr-refl-discipline').className = 'tr-refl-daily-value ' + (daily.discipline_score ? '' : 'empty');
|
|
|
|
|
|
document.getElementById('tr-refl-overall').textContent = trReflTextMap.overall[daily.overall_rating] || daily.overall_rating || '未填写';
|
|
|
|
|
|
document.getElementById('tr-refl-overall').className = 'tr-refl-daily-value ' + (daily.overall_rating ? '' : 'empty');
|
|
|
|
|
|
document.getElementById('tr-refl-market').textContent = trReflTextMap.market[daily.market_judgment] || daily.market_judgment || '未填写';
|
|
|
|
|
|
document.getElementById('tr-refl-market').className = 'tr-refl-daily-value ' + (daily.market_judgment ? '' : 'empty');
|
|
|
|
|
|
|
|
|
|
|
|
const pairsContainer = document.getElementById('tr-refl-pairs');
|
|
|
|
|
|
const pairs = data.pairs || [];
|
|
|
|
|
|
if (!pairs.length) {
|
|
|
|
|
|
pairsContainer.innerHTML = '<div class="tr-empty" style="grid-column:1/-1;"><div class="tr-empty-icon">📝</div><div class="tr-empty-text">当日无交易配对</div></div>';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
pairsContainer.innerHTML = pairs.map(p => trReflRenderPairCard(p)).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const unpairedBody = document.getElementById('tr-refl-unpaired-body');
|
|
|
|
|
|
const unpairedEmpty = document.getElementById('tr-refl-unpaired-empty');
|
|
|
|
|
|
const selectAll = document.getElementById('tr-refl-unpaired-select-all');
|
|
|
|
|
|
const unpaired = data.unpaired || [];
|
|
|
|
|
|
if (!unpaired.length) {
|
|
|
|
|
|
unpairedBody.innerHTML = '';
|
|
|
|
|
|
unpairedEmpty.style.display = 'flex';
|
|
|
|
|
|
if (selectAll) selectAll.checked = false;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
unpairedEmpty.style.display = 'none';
|
|
|
|
|
|
unpairedBody.innerHTML = unpaired.map(u => {
|
|
|
|
|
|
const pnl = u.net_pnl || u.close_pnl || 0;
|
|
|
|
|
|
const pnlCls = pnl > 0 ? 'tr-pnl-positive' : (pnl < 0 ? 'tr-pnl-negative' : '');
|
|
|
|
|
|
return `<tr>
|
|
|
|
|
|
<td><input type="checkbox" class="tr-refl-unpaired-check" data-id="${u.id}" data-offset="${u.offset}" data-direction="${u.direction}" data-symbol="${u.symbol || u.variety || ''}"></td>
|
|
|
|
|
|
<td>${u.trade_time || '-'}</td>
|
|
|
|
|
|
<td>${u.variety}${u.symbol_name ? '(' + u.symbol_name + ')' : ''}</td>
|
|
|
|
|
|
<td>${u.offset || '-'}</td>
|
|
|
|
|
|
<td>${u.direction || '-'}</td>
|
|
|
|
|
|
<td>${(u.price || 0).toFixed(2)}</td>
|
|
|
|
|
|
<td>${u.volume || 0}</td>
|
|
|
|
|
|
<td class="${pnlCls}">${pnl !== 0 ? (pnl >= 0 ? '+' : '') + pnl.toFixed(2) : '-'}</td>
|
|
|
|
|
|
</tr>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trReflManualPair() {
|
|
|
|
|
|
const checked = Array.from(document.querySelectorAll('.tr-refl-unpaired-check:checked'));
|
|
|
|
|
|
if (!checked.length) {
|
|
|
|
|
|
showToast('warning', '请选择记录', '请至少选择一条未配对交易记录');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const openRecords = checked.filter(cb => cb.dataset.offset === '开');
|
|
|
|
|
|
const closeRecords = checked.filter(cb => cb.dataset.offset === '平');
|
|
|
|
|
|
|
|
|
|
|
|
if (!openRecords.length) {
|
|
|
|
|
|
showToast('warning', '缺少开仓记录', '手动配对需要至少一条开仓记录(开)');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!closeRecords.length) {
|
|
|
|
|
|
showToast('warning', '缺少平仓记录', '手动配对需要至少一条平仓记录(平)');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const symbols = new Set(checked.map(cb => cb.dataset.symbol));
|
|
|
|
|
|
if (symbols.size > 1) {
|
|
|
|
|
|
showToast('warning', '品种不一致', '请选择同一品种的开仓和平仓记录');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const openDirection = openRecords[0].dataset.direction;
|
|
|
|
|
|
const closeDirection = closeRecords[0].dataset.direction;
|
|
|
|
|
|
|
|
|
|
|
|
// 多头:开=买,平=卖;空头:开=卖,平=买
|
|
|
|
|
|
const isLong = openDirection === '买' && closeDirection === '卖';
|
|
|
|
|
|
const isShort = openDirection === '卖' && closeDirection === '买';
|
|
|
|
|
|
if (!isLong && !isShort) {
|
|
|
|
|
|
showToast('warning', '方向不匹配', '多头配对:开仓为买、平仓为卖;空头配对:开仓为卖、平仓为买');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const openRecordIds = openRecords.map(cb => parseInt(cb.dataset.id));
|
|
|
|
|
|
const closeRecordIds = closeRecords.map(cb => parseInt(cb.dataset.id));
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/trade-pairs`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ open_record_ids: openRecordIds, close_record_ids: closeRecordIds }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
showToast('success', '配对成功', '交易配对已创建');
|
|
|
|
|
|
trReflLoadData();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showToast('error', '配对失败', json.message || '请重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
showToast('error', '配对失败', e.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflRenderPairCard(p) {
|
|
|
|
|
|
const pnl = p.net_pnl || 0;
|
|
|
|
|
|
const pnlCls = pnl >= 0 ? 'profit' : 'loss';
|
|
|
|
|
|
const dirCls = p.direction === '多' ? 'long' : 'short';
|
|
|
|
|
|
const dirText = p.direction === '多' ? '做多' : '做空';
|
|
|
|
|
|
const tags = (p.tags || []).map(t => `<span class="tr-refl-tag">${typeof t === 'string' ? t : t.name}</span>`).join('');
|
|
|
|
|
|
const hasReflection = p.reflection_status === 'done';
|
|
|
|
|
|
return `<div class="tr-refl-card ${pnlCls}">
|
|
|
|
|
|
<div class="tr-refl-card-header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="tr-refl-card-symbol">${p.symbol_name || p.variety || p.symbol || '-'}</div>
|
|
|
|
|
|
<span class="tr-refl-card-dir ${dirCls}">${dirText}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="tr-refl-card-pnl ${pnlCls}">${pnl >= 0 ? '+' : ''}${pnl.toFixed(2)}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="tr-refl-card-nodes">
|
|
|
|
|
|
开仓:${p.open_date || '-'} ${p.open_time || ''} @ ${(p.open_price || 0).toFixed(2)}<br>
|
|
|
|
|
|
平仓:${p.close_date || '-'} ${p.close_time || ''} @ ${(p.close_price || 0).toFixed(2)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="tr-refl-card-tags">${tags}</div>
|
|
|
|
|
|
<div class="tr-refl-card-actions">
|
|
|
|
|
|
<button class="tr-btn tr-btn-primary" onclick='trReflOpenEditModal(${p.id})'>
|
|
|
|
|
|
<span>${hasReflection ? '✏️' : '✍️'}</span><span>${hasReflection ? '编辑反思' : '写反思'}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button class="tr-btn tr-btn-secondary" onclick='trReflOpenTagsModal(${p.id})'>
|
|
|
|
|
|
<span>🏷️</span><span>标签</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button class="tr-btn tr-btn-secondary" onclick='trReflOpenAIModal(${p.id})'>
|
|
|
|
|
|
<span>🤖</span><span>查看分析</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trReflLoadTags() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/tags`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) trReflTags = json.data || [];
|
|
|
|
|
|
} catch (e) { console.error('加载标签失败', e); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 反思编辑 Modal =====
|
|
|
|
|
|
async function trReflOpenEditModal(pairId) {
|
|
|
|
|
|
const data = trReflData || { pairs: [] };
|
|
|
|
|
|
const pair = data.pairs.find(p => p.id === pairId);
|
|
|
|
|
|
if (!pair) return;
|
|
|
|
|
|
trReflCurrentPairId = pairId;
|
|
|
|
|
|
trReflCurrentReflectionId = null;
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('tr-refl-edit-pair-id').value = pairId;
|
|
|
|
|
|
|
|
|
|
|
|
let reflection = {};
|
|
|
|
|
|
let selectedTagNames = new Set();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [reflRes, tagsRes] = await Promise.all([
|
|
|
|
|
|
fetch(`${TR_API_BASE}/reflections?trade_pair_id=${pairId}`),
|
|
|
|
|
|
fetch(`${TR_API_BASE}/trade-pairs/${pairId}/tags`),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const reflJson = await reflRes.json();
|
|
|
|
|
|
const tagsJson = await tagsRes.json();
|
|
|
|
|
|
if (reflJson.success && reflJson.data && reflJson.data.length) {
|
|
|
|
|
|
reflection = reflJson.data[0];
|
|
|
|
|
|
trReflCurrentReflectionId = reflection.id || null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (tagsJson.success && tagsJson.data) {
|
|
|
|
|
|
selectedTagNames = new Set(tagsJson.data.map(t => typeof t === 'string' ? t : t.name));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('加载反思/标签失败', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('tr-refl-entry-reason').value = reflection.entry_reason || '';
|
|
|
|
|
|
document.getElementById('tr-refl-exit-reason').value = reflection.exit_reason || '';
|
|
|
|
|
|
document.getElementById('tr-refl-entry-timing').value = reflection.entry_timing || '';
|
|
|
|
|
|
document.getElementById('tr-refl-exit-timing').value = reflection.exit_timing || '';
|
|
|
|
|
|
document.getElementById('tr-refl-position').value = reflection.position_management || '';
|
|
|
|
|
|
document.getElementById('tr-refl-free').value = reflection.free_reflection || '';
|
|
|
|
|
|
|
|
|
|
|
|
const score = reflection.discipline_score || 0;
|
|
|
|
|
|
const container = document.getElementById('tr-refl-discipline-score');
|
|
|
|
|
|
container.dataset.value = score;
|
|
|
|
|
|
trReflUpdateStars(container, score);
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染标签选择
|
|
|
|
|
|
const tagPool = document.getElementById('tr-refl-edit-tags');
|
|
|
|
|
|
tagPool.innerHTML = trReflTags.map(t => {
|
|
|
|
|
|
const name = typeof t === 'string' ? t : t.name;
|
|
|
|
|
|
const active = selectedTagNames.has(name) ? 'selected' : '';
|
|
|
|
|
|
return `<span class="tr-tag-chip ${active}" onclick="this.classList.toggle('selected')">${name}</span>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('tr-refl-edit-modal').classList.add('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflCloseEditModal() {
|
|
|
|
|
|
document.getElementById('tr-refl-edit-modal').classList.remove('show');
|
|
|
|
|
|
trReflCurrentPairId = null;
|
|
|
|
|
|
trReflCurrentReflectionId = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trReflSaveEdit(andAnalyze = false) {
|
|
|
|
|
|
const pairId = parseInt(document.getElementById('tr-refl-edit-pair-id').value);
|
|
|
|
|
|
if (!pairId) return;
|
|
|
|
|
|
|
|
|
|
|
|
const body = {
|
|
|
|
|
|
trade_pair_id: pairId,
|
|
|
|
|
|
trade_date: trReflCurrentDate,
|
|
|
|
|
|
entry_reason: document.getElementById('tr-refl-entry-reason').value,
|
|
|
|
|
|
exit_reason: document.getElementById('tr-refl-exit-reason').value,
|
|
|
|
|
|
entry_timing: document.getElementById('tr-refl-entry-timing').value,
|
|
|
|
|
|
exit_timing: document.getElementById('tr-refl-exit-timing').value,
|
|
|
|
|
|
position_management: document.getElementById('tr-refl-position').value,
|
|
|
|
|
|
discipline_score: parseInt(document.getElementById('tr-refl-discipline-score').dataset.value || '0'),
|
|
|
|
|
|
free_reflection: document.getElementById('tr-refl-free').value,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = trReflCurrentReflectionId
|
|
|
|
|
|
? `${TR_API_BASE}/reflections/${trReflCurrentReflectionId}`
|
|
|
|
|
|
: `${TR_API_BASE}/reflections`;
|
|
|
|
|
|
const method = trReflCurrentReflectionId ? 'PUT' : 'POST';
|
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
|
|
method,
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
|
});
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (!json.success) {
|
|
|
|
|
|
showToast('error', '保存失败', json.message || '请重试');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 同步标签(编辑 Modal 中的标签选择)
|
|
|
|
|
|
const selectedTagNames = Array.from(document.querySelectorAll('#tr-refl-edit-tags .tr-tag-chip.selected')).map(el => el.textContent);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await trReflSyncPairTags(pairId, selectedTagNames);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('标签同步失败', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (andAnalyze) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const reRes = await fetch(`${TR_API_BASE}/reanalyze`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ trade_pair_id: pairId }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const reJson = await reRes.json();
|
|
|
|
|
|
if (reJson.success && reJson.data) {
|
|
|
|
|
|
// 重新分析接口不返回 id,需要再取一次 latest 获取完整记录
|
|
|
|
|
|
const latestRes = await fetch(`${TR_API_BASE}/reanalyze/${pairId}/latest`);
|
|
|
|
|
|
const latestJson = await latestRes.json();
|
|
|
|
|
|
if (latestJson.success && latestJson.data) {
|
|
|
|
|
|
trReflCurrentAIResult = latestJson.data;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
trReflCurrentAIResult = reJson.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
trReflCloseEditModal();
|
|
|
|
|
|
trReflOpenAIModal(pairId, false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
showToast('error', 'AI分析失败', reJson.message || '请重试');
|
|
|
|
|
|
return;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
showToast('error', 'AI分析失败', e.message);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
showToast('success', '保存成功', '反思已保存');
|
|
|
|
|
|
trReflCloseEditModal();
|
|
|
|
|
|
trReflLoadData();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
showToast('error', '保存失败', e.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trReflSaveAndAnalyze() {
|
|
|
|
|
|
await trReflSaveEdit(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trReflSyncPairTags(pairId, selectedNames) {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/trade-pairs/${pairId}/tags`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
const currentTags = (json.success ? json.data : []) || [];
|
|
|
|
|
|
const currentNames = new Set(currentTags.map(t => typeof t === 'string' ? t : t.name));
|
|
|
|
|
|
|
|
|
|
|
|
// 新增标签
|
|
|
|
|
|
for (const name of selectedNames) {
|
|
|
|
|
|
if (currentNames.has(name)) continue;
|
|
|
|
|
|
const tag = trReflTags.find(t => (typeof t === 'string' ? t : t.name) === name);
|
|
|
|
|
|
if (!tag || typeof tag === 'string') continue;
|
|
|
|
|
|
await fetch(`${TR_API_BASE}/trade-pairs/${pairId}/tags?tag_id=${tag.id}`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 移除标签
|
|
|
|
|
|
for (const tag of currentTags) {
|
|
|
|
|
|
const name = typeof tag === 'string' ? tag : tag.name;
|
|
|
|
|
|
if (selectedNames.includes(name)) continue;
|
|
|
|
|
|
const tagId = typeof tag === 'string' ? null : tag.id;
|
|
|
|
|
|
if (!tagId) continue;
|
|
|
|
|
|
await fetch(`${TR_API_BASE}/trade-pairs/${pairId}/tags/${tagId}`, { method: 'DELETE' });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 当日反思 Modal =====
|
|
|
|
|
|
function trReflOpenDailyModal() {
|
|
|
|
|
|
const daily = (trReflData && trReflData.daily_reflection) || {};
|
|
|
|
|
|
document.getElementById('tr-refl-daily-emotion').value = daily.emotion_state || '';
|
|
|
|
|
|
document.getElementById('tr-refl-daily-overall').value = daily.overall_rating || '';
|
|
|
|
|
|
document.getElementById('tr-refl-daily-market').value = daily.market_judgment || '';
|
|
|
|
|
|
document.getElementById('tr-refl-daily-summary').value = daily.summary || '';
|
|
|
|
|
|
document.getElementById('tr-refl-daily-improvement').value = daily.improvements || '';
|
|
|
|
|
|
|
|
|
|
|
|
const score = daily.discipline_score || 0;
|
|
|
|
|
|
const container = document.getElementById('tr-refl-daily-discipline-score');
|
|
|
|
|
|
container.dataset.value = score;
|
|
|
|
|
|
trReflUpdateStars(container, score);
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('tr-refl-daily-modal').classList.add('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflCloseDailyModal() {
|
|
|
|
|
|
document.getElementById('tr-refl-daily-modal').classList.remove('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trReflSaveDaily() {
|
|
|
|
|
|
const body = {
|
|
|
|
|
|
reflection_date: trReflCurrentDate,
|
|
|
|
|
|
emotion_state: document.getElementById('tr-refl-daily-emotion').value,
|
|
|
|
|
|
overall_rating: document.getElementById('tr-refl-daily-overall').value,
|
|
|
|
|
|
market_judgment: document.getElementById('tr-refl-daily-market').value,
|
|
|
|
|
|
discipline_score: parseInt(document.getElementById('tr-refl-daily-discipline-score').dataset.value || '0'),
|
|
|
|
|
|
summary: document.getElementById('tr-refl-daily-summary').value,
|
|
|
|
|
|
improvements: document.getElementById('tr-refl-daily-improvement').value,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/daily-reflections`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
|
});
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
showToast('success', '保存成功', '当日反思已保存');
|
|
|
|
|
|
trReflCloseDailyModal();
|
|
|
|
|
|
trReflLoadData();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showToast('error', '保存失败', json.message || '请重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
showToast('error', '保存失败', e.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 标签管理 Modal =====
|
|
|
|
|
|
async function trReflOpenTagsModal(pairId) {
|
|
|
|
|
|
const data = trReflData || { pairs: [] };
|
|
|
|
|
|
const pair = data.pairs.find(p => p.id === pairId);
|
|
|
|
|
|
if (!pair) return;
|
|
|
|
|
|
trReflCurrentPairId = pairId;
|
|
|
|
|
|
trReflTagState = new Set();
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/trade-pairs/${pairId}/tags`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success && json.data) {
|
|
|
|
|
|
trReflTagState = new Set(json.data.map(t => typeof t === 'string' ? t : t.name));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) { console.error('加载交易标签失败', e); }
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('tr-refl-tags-pair-id').value = pairId;
|
|
|
|
|
|
trReflRenderTagPreset();
|
|
|
|
|
|
document.getElementById('tr-refl-tags-modal').classList.add('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflRenderTagPreset() {
|
|
|
|
|
|
const container = document.getElementById('tr-refl-tags-preset');
|
|
|
|
|
|
const grouped = trReflGroupTags(trReflTags);
|
|
|
|
|
|
container.innerHTML = Object.entries(grouped).map(([category, tags]) => `
|
|
|
|
|
|
<div class="tr-tag-section">
|
|
|
|
|
|
<div class="tr-tag-section-title">${category}</div>
|
|
|
|
|
|
<div class="tr-tag-pool">
|
|
|
|
|
|
${tags.map(t => {
|
|
|
|
|
|
const name = typeof t === 'string' ? t : t.name;
|
|
|
|
|
|
const selected = trReflTagState.has(name) ? 'selected' : '';
|
|
|
|
|
|
return `<span class="tr-tag-chip ${selected}" onclick="trReflToggleTag('${name.replace(/'/g, "\\'")}')">${name}</span>`;
|
|
|
|
|
|
}).join('')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflGroupTags(tags) {
|
|
|
|
|
|
const groups = {};
|
|
|
|
|
|
tags.forEach(t => {
|
|
|
|
|
|
const category = (typeof t === 'object' && t.category) ? t.category : '常用';
|
|
|
|
|
|
if (!groups[category]) groups[category] = [];
|
|
|
|
|
|
groups[category].push(t);
|
|
|
|
|
|
});
|
|
|
|
|
|
return groups;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflToggleTag(tag) {
|
|
|
|
|
|
if (trReflTagState.has(tag)) trReflTagState.delete(tag);
|
|
|
|
|
|
else trReflTagState.add(tag);
|
|
|
|
|
|
trReflRenderTagPreset();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflAddCustomTag() {
|
|
|
|
|
|
const input = document.getElementById('tr-refl-tag-input');
|
|
|
|
|
|
const val = input.value.trim();
|
|
|
|
|
|
if (!val) return;
|
|
|
|
|
|
trReflTagState.add(val);
|
|
|
|
|
|
input.value = '';
|
|
|
|
|
|
trReflRenderTagPreset();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trReflResolveTagId(name) {
|
|
|
|
|
|
const existing = trReflTags.find(t => (typeof t === 'string' ? t : t.name) === name);
|
|
|
|
|
|
if (existing && typeof existing === 'object' && existing.id) return existing.id;
|
|
|
|
|
|
|
|
|
|
|
|
// 对自定义标签创建后再使用
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/tags`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ name, category: 'custom' }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success && json.data && json.data.id) {
|
|
|
|
|
|
trReflTags.push({ id: json.data.id, name, category: 'custom', is_preset: false });
|
|
|
|
|
|
return json.data.id;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) { console.error('创建标签失败', e); }
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trReflSaveTags() {
|
|
|
|
|
|
const pairId = parseInt(document.getElementById('tr-refl-tags-pair-id').value);
|
|
|
|
|
|
if (!pairId) return;
|
|
|
|
|
|
|
|
|
|
|
|
const selectedNames = Array.from(trReflTagState);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/trade-pairs/${pairId}/tags`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
const currentTags = (json.success ? json.data : []) || [];
|
|
|
|
|
|
const currentMap = new Map(currentTags.map(t => [
|
|
|
|
|
|
typeof t === 'string' ? t : t.name,
|
|
|
|
|
|
typeof t === 'string' ? null : t.id,
|
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
|
|
// 新增标签
|
|
|
|
|
|
for (const name of selectedNames) {
|
|
|
|
|
|
if (currentMap.has(name)) continue;
|
|
|
|
|
|
const tagId = await trReflResolveTagId(name);
|
|
|
|
|
|
if (!tagId) continue;
|
|
|
|
|
|
await fetch(`${TR_API_BASE}/trade-pairs/${pairId}/tags?tag_id=${tagId}`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 移除标签
|
|
|
|
|
|
for (const [name, tagId] of currentMap) {
|
|
|
|
|
|
if (selectedNames.includes(name) || !tagId) continue;
|
|
|
|
|
|
await fetch(`${TR_API_BASE}/trade-pairs/${pairId}/tags/${tagId}`, { method: 'DELETE' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
showToast('success', '保存成功', '标签已更新');
|
|
|
|
|
|
trReflCloseTagsModal();
|
|
|
|
|
|
trReflLoadData();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
showToast('error', '保存失败', e.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflCloseTagsModal() {
|
|
|
|
|
|
document.getElementById('tr-refl-tags-modal').classList.remove('show');
|
|
|
|
|
|
trReflCurrentPairId = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== AI 分析结果 Modal =====
|
|
|
|
|
|
async function trReflOpenAIModal(pairId, fetchLatest = true) {
|
|
|
|
|
|
const data = trReflData || { pairs: [] };
|
|
|
|
|
|
const pair = data.pairs.find(p => p.id === pairId);
|
|
|
|
|
|
if (!pair) return;
|
|
|
|
|
|
trReflCurrentPairId = pairId;
|
|
|
|
|
|
trReflAIHistory = [];
|
|
|
|
|
|
trReflCurrentAnalysis = null;
|
|
|
|
|
|
|
|
|
|
|
|
// 加载历史版本列表
|
|
|
|
|
|
try {
|
|
|
|
|
|
const historyRes = await fetch(`${TR_API_BASE}/reanalyze/${pairId}/history`);
|
|
|
|
|
|
const historyJson = await historyRes.json();
|
|
|
|
|
|
if (historyJson.success && historyJson.data) {
|
|
|
|
|
|
trReflAIHistory = Array.isArray(historyJson.data) ? historyJson.data : (historyJson.data.items || []);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) { console.error('获取AI分析历史失败', e); }
|
|
|
|
|
|
|
|
|
|
|
|
if (fetchLatest) {
|
|
|
|
|
|
trReflCurrentAIResult = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/reanalyze/${pairId}/latest`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success && json.data) {
|
|
|
|
|
|
trReflCurrentAIResult = json.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) { console.error('获取最新AI分析失败', e); }
|
|
|
|
|
|
|
|
|
|
|
|
// 没有最新结果时,自动基于反思重新分析
|
|
|
|
|
|
if (!trReflCurrentAIResult) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const reRes = await fetch(`${TR_API_BASE}/reanalyze`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ trade_pair_id: pairId }),
|
|
|
|
|
|
});
|
|
|
|
|
|
const reJson = await reRes.json();
|
|
|
|
|
|
if (reJson.success && reJson.data) {
|
|
|
|
|
|
const latestRes = await fetch(`${TR_API_BASE}/reanalyze/${pairId}/latest`);
|
|
|
|
|
|
const latestJson = await latestRes.json();
|
|
|
|
|
|
if (latestJson.success && latestJson.data) {
|
|
|
|
|
|
trReflCurrentAIResult = latestJson.data;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
trReflCurrentAIResult = reJson.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) { console.error('AI重新分析失败', e); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果历史列表为空但拿到最新结果,补充进历史列表
|
|
|
|
|
|
if (!trReflAIHistory.length && trReflCurrentAIResult) {
|
|
|
|
|
|
trReflAIHistory = [trReflCurrentAIResult];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 优先从历史列表中选中与当前结果匹配的版本,否则默认第一个
|
|
|
|
|
|
let selected = trReflAIHistory[0] || trReflCurrentAIResult || null;
|
|
|
|
|
|
if (trReflCurrentAIResult && trReflCurrentAIResult.id && trReflAIHistory.length) {
|
|
|
|
|
|
const matched = trReflAIHistory.find(a => a.id === trReflCurrentAIResult.id);
|
|
|
|
|
|
if (matched) selected = matched;
|
|
|
|
|
|
}
|
|
|
|
|
|
trReflCurrentAnalysis = selected;
|
|
|
|
|
|
|
|
|
|
|
|
trReflRenderAIVersionSelect();
|
|
|
|
|
|
trReflDisplayAIAnalysis(trReflCurrentAnalysis);
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('tr-refl-ai-pair-id').value = pairId;
|
|
|
|
|
|
document.getElementById('tr-refl-ai-modal').classList.add('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflRenderAIVersionSelect() {
|
|
|
|
|
|
const select = document.getElementById('tr-refl-ai-version');
|
|
|
|
|
|
if (!select) return;
|
|
|
|
|
|
if (!trReflAIHistory.length) {
|
|
|
|
|
|
select.innerHTML = '<option value="">暂无版本</option>';
|
|
|
|
|
|
select.disabled = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
select.disabled = false;
|
|
|
|
|
|
select.innerHTML = trReflAIHistory.map((a, idx) => {
|
|
|
|
|
|
const version = a.version || (trReflAIHistory.length - idx);
|
|
|
|
|
|
const created = a.created_at ? a.created_at.slice(0, 19).replace('T', ' ') : '';
|
|
|
|
|
|
const selected = (trReflCurrentAnalysis && trReflCurrentAnalysis.id === a.id) ? 'selected' : '';
|
|
|
|
|
|
return `<option value="${a.id || idx}" ${selected}>版本 ${version}${created ? ' · ' + created : ''}</option>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
select.onchange = trReflSwitchAIVersion;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflSwitchAIVersion() {
|
|
|
|
|
|
const select = document.getElementById('tr-refl-ai-version');
|
|
|
|
|
|
if (!select) return;
|
|
|
|
|
|
const value = select.value;
|
|
|
|
|
|
const analysis = trReflAIHistory.find(a => String(a.id) === value);
|
|
|
|
|
|
if (!analysis) return;
|
|
|
|
|
|
trReflCurrentAnalysis = analysis;
|
|
|
|
|
|
trReflDisplayAIAnalysis(analysis);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflDisplayAIAnalysis(analysis) {
|
|
|
|
|
|
const result = analysis || {};
|
|
|
|
|
|
document.getElementById('tr-refl-ai-overall').textContent = result.overall_evaluation || result.analysis || '暂无 AI 分析结果(请先保存反思)';
|
|
|
|
|
|
document.getElementById('tr-refl-ai-strengths').innerHTML = (result.strengths || []).map(s => `<li>${s}</li>`).join('') || '<li>-</li>';
|
|
|
|
|
|
document.getElementById('tr-refl-ai-weaknesses').innerHTML = (result.weaknesses || []).map(s => `<li>${s}</li>`).join('') || '<li>-</li>';
|
|
|
|
|
|
document.getElementById('tr-refl-ai-suggestion').textContent = result.experience_suggestion || result.suggestion || '暂无建议';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trReflSaveExperience() {
|
|
|
|
|
|
const pairId = parseInt(document.getElementById('tr-refl-ai-pair-id').value);
|
|
|
|
|
|
const data = trReflData || { pairs: [] };
|
|
|
|
|
|
const pair = data.pairs.find(p => p.id === pairId);
|
|
|
|
|
|
const result = trReflCurrentAnalysis || trReflCurrentAIResult;
|
|
|
|
|
|
if (!pair || !result) return;
|
|
|
|
|
|
|
|
|
|
|
|
const analysisId = result.id;
|
|
|
|
|
|
if (!analysisId) {
|
|
|
|
|
|
showToast('error', '保存失败', '缺少分析记录ID');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const body = {
|
|
|
|
|
|
title: `${pair.symbol_name || pair.variety || pair.symbol} ${pair.direction === '多' ? '做多' : '做空'} 经验`,
|
|
|
|
|
|
content: result.overall_evaluation || result.analysis || result.experience_suggestion || '',
|
|
|
|
|
|
tags: ['AI分析'],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/ai-analysis/${analysisId}/save-experience`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
|
});
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
showToast('success', '保存成功', '已保存到经验库');
|
|
|
|
|
|
trReflCloseAIModal();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showToast('error', '保存失败', json.message || '请重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
showToast('error', '保存失败', e.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trReflCloseAIModal() {
|
|
|
|
|
|
document.getElementById('tr-refl-ai-modal').classList.remove('show');
|
|
|
|
|
|
trReflCurrentPairId = null;
|
|
|
|
|
|
trReflCurrentAIResult = null;
|
|
|
|
|
|
trReflCurrentAnalysis = null;
|
|
|
|
|
|
trReflAIHistory = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 经验库 =====
|
|
|
|
|
|
async function trExpLoad() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
|
params.set('page', trExpPage);
|
|
|
|
|
|
params.set('page_size', trExpPageSize);
|
|
|
|
|
|
const keyword = document.getElementById('tr-exp-search').value.trim();
|
|
|
|
|
|
const tag = document.getElementById('tr-exp-tag-filter').value;
|
|
|
|
|
|
const type = document.getElementById('tr-exp-type-filter').value;
|
|
|
|
|
|
if (keyword) params.set('keyword', keyword);
|
|
|
|
|
|
if (tag) params.set('tag', tag);
|
|
|
|
|
|
if (type) params.set('type', type);
|
|
|
|
|
|
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/experiences?${params.toString()}`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success && json.data) {
|
|
|
|
|
|
trReflExperiences = json.data.items || json.data || [];
|
|
|
|
|
|
trExpTotal = json.data.total || trReflExperiences.length;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
trReflExperiences = [];
|
|
|
|
|
|
trExpTotal = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
trExpPopulateTagFilter();
|
|
|
|
|
|
trExpRender();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('加载经验库失败', e);
|
|
|
|
|
|
trReflExperiences = [];
|
|
|
|
|
|
trExpTotal = 0;
|
|
|
|
|
|
trExpRender();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trExpPopulateTagFilter() {
|
|
|
|
|
|
const select = document.getElementById('tr-exp-tag-filter');
|
|
|
|
|
|
const allTags = new Set();
|
|
|
|
|
|
trReflExperiences.forEach(e => (e.tags || []).forEach(t => allTags.add(typeof t === 'string' ? t : t.name)));
|
|
|
|
|
|
const current = select.value;
|
|
|
|
|
|
select.innerHTML = '<option value="">全部标签</option>' + Array.from(allTags).sort().map(t => `<option value="${t}">${t}</option>`).join('');
|
|
|
|
|
|
select.value = current;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trExpRender() {
|
|
|
|
|
|
const container = document.getElementById('tr-exp-container');
|
|
|
|
|
|
const empty = document.getElementById('tr-exp-empty');
|
|
|
|
|
|
const pagination = document.getElementById('tr-exp-pagination');
|
|
|
|
|
|
const pageInfo = document.getElementById('tr-exp-pageinfo');
|
|
|
|
|
|
const view = document.querySelector('.tr-exp-viewtab.active').dataset.view;
|
|
|
|
|
|
|
|
|
|
|
|
const list = trReflExperiences;
|
|
|
|
|
|
|
|
|
|
|
|
if (!list.length) {
|
|
|
|
|
|
container.innerHTML = '';
|
|
|
|
|
|
container.className = 'tr-exp-grid';
|
|
|
|
|
|
empty.style.display = 'flex';
|
|
|
|
|
|
if (pagination) pagination.style.display = 'none';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
empty.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
const totalPages = Math.max(1, Math.ceil(trExpTotal / trExpPageSize));
|
|
|
|
|
|
if (pagination) {
|
|
|
|
|
|
pagination.style.display = 'flex';
|
|
|
|
|
|
pageInfo.textContent = `第 ${trExpPage} / ${totalPages} 页,共 ${trExpTotal} 条`;
|
|
|
|
|
|
document.getElementById('tr-exp-prev').disabled = trExpPage <= 1;
|
|
|
|
|
|
document.getElementById('tr-exp-next').disabled = trExpPage >= totalPages;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (view === 'timeline') {
|
|
|
|
|
|
container.className = '';
|
|
|
|
|
|
const sorted = [...list].sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
|
|
|
|
|
|
container.innerHTML = `<div style="display:flex;flex-direction:column;gap:16px;">${sorted.map(e => trExpRenderCard(e, true)).join('')}</div>`;
|
|
|
|
|
|
} else if (view === 'category') {
|
|
|
|
|
|
container.className = '';
|
|
|
|
|
|
const grouped = {};
|
|
|
|
|
|
list.forEach(e => {
|
|
|
|
|
|
const k = e.type || '其他';
|
|
|
|
|
|
if (!grouped[k]) grouped[k] = [];
|
|
|
|
|
|
grouped[k].push(e);
|
|
|
|
|
|
});
|
|
|
|
|
|
const typeNames = { lesson: '教训', insight: '心得', strategy: '策略', discipline: '纪律' };
|
|
|
|
|
|
container.innerHTML = Object.entries(grouped).map(([k, items]) => `
|
|
|
|
|
|
<div style="margin-bottom:24px;">
|
|
|
|
|
|
<div style="font-size:16px;font-weight:700;margin-bottom:12px;color:var(--text-primary);">${typeNames[k] || k}</div>
|
|
|
|
|
|
<div class="tr-exp-grid">${items.map(e => trExpRenderCard(e)).join('')}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
container.className = 'tr-exp-grid';
|
|
|
|
|
|
container.innerHTML = list.map(e => trExpRenderCard(e)).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trExpRenderCard(e, timeline = false) {
|
|
|
|
|
|
const typeNames = { lesson: '教训', insight: '心得', strategy: '策略', discipline: '纪律' };
|
|
|
|
|
|
const tags = (e.tags || []).map(t => `<span class="tr-refl-tag">${typeof t === 'string' ? t : t.name}</span>`).join('');
|
|
|
|
|
|
const date = e.created_at ? e.created_at.slice(0, 10) : '';
|
|
|
|
|
|
return `<div class="tr-exp-card" onclick="trExpOpenDetailModal(${e.id})" style="cursor:pointer;">
|
|
|
|
|
|
<div class="tr-exp-card-title">${e.title || '未命名经验'}</div>
|
|
|
|
|
|
<div class="tr-exp-card-meta">${typeNames[e.type] || e.type || '其他'} ${date ? '· ' + date : ''}</div>
|
|
|
|
|
|
<div class="tr-exp-card-content">${(e.content || '').slice(0, 120)}${(e.content || '').length > 120 ? '...' : ''}</div>
|
|
|
|
|
|
<div class="tr-exp-card-tags">${tags}</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trExpOpenDetailModal(id) {
|
|
|
|
|
|
const e = trReflExperiences.find(item => item.id === id);
|
|
|
|
|
|
if (!e) return;
|
|
|
|
|
|
|
|
|
|
|
|
const typeNames = { lesson: '教训', insight: '心得', strategy: '策略', discipline: '纪律' };
|
|
|
|
|
|
const tags = (e.tags || []).map(t => `<span class="tr-refl-tag">${typeof t === 'string' ? t : t.name}</span>`).join('');
|
|
|
|
|
|
const created = e.created_at ? e.created_at.slice(0, 19).replace('T', ' ') : '-';
|
|
|
|
|
|
const sourceDate = e.source_trade_date || (e.source_pair ? e.source_pair.trade_date : '');
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('tr-exp-detail-title').textContent = e.title || '经验详情';
|
|
|
|
|
|
document.getElementById('tr-exp-detail-meta').textContent = `${typeNames[e.type] || e.type || '其他'} · 创建于 ${created}${sourceDate ? ' · 来源交易日期 ' + sourceDate : ''}`;
|
|
|
|
|
|
document.getElementById('tr-exp-detail-content').textContent = e.content || '';
|
|
|
|
|
|
document.getElementById('tr-exp-detail-tags').innerHTML = tags || '<span style="color:var(--text-tertiary);font-size:13px;">无标签</span>';
|
|
|
|
|
|
|
|
|
|
|
|
const sourceEl = document.getElementById('tr-exp-detail-source');
|
|
|
|
|
|
if (e.source_pair_id) {
|
|
|
|
|
|
sourceEl.innerHTML = `<span class="link" onclick="trExpViewSourcePair(${e.source_pair_id})">查看原交易</span>`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
sourceEl.innerHTML = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('tr-exp-detail-modal').classList.add('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trExpCloseDetailModal() {
|
|
|
|
|
|
document.getElementById('tr-exp-detail-modal').classList.remove('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trExpViewSourcePair(pairId) {
|
|
|
|
|
|
console.log('查看原交易配对:', pairId);
|
|
|
|
|
|
alert(`查看原交易配对:${pairId}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 星级评分辅助 =====
|
|
|
|
|
|
function trReflUpdateStars(container, score) {
|
|
|
|
|
|
container.querySelectorAll('.tr-form-star').forEach(s => {
|
|
|
|
|
|
s.classList.toggle('active', parseInt(s.dataset.score) <= score);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 文本映射 =====
|
|
|
|
|
|
const trReflTextMap = {
|
|
|
|
|
|
emotion: {
|
|
|
|
|
|
calm: '平静', confident: '自信', anxious: '焦虑', greedy: '贪婪',
|
|
|
|
|
|
fearful: '恐惧', impulsive: '冲动', tired: '疲惫'
|
|
|
|
|
|
},
|
|
|
|
|
|
overall: {
|
|
|
|
|
|
excellent: '优秀', good: '良好', average: '一般', poor: '较差', bad: '糟糕'
|
|
|
|
|
|
},
|
|
|
|
|
|
market: {
|
|
|
|
|
|
accurate: '准确', basically: '基本准确', deviated: '有所偏差', wrong: '错误'
|
|
|
|
|
|
},
|
|
|
|
|
|
timing: {
|
|
|
|
|
|
excellent: '优秀', good: '良好', average: '一般', poor: '较差', bad: '糟糕'
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 交易复盘功能 ====================
|
|
|
|
|
|
|
|
|
|
|
|
const TR_API_BASE = '/api/v1/trade-review';
|
|
|
|
|
|
let trInitialized = false;
|
|
|
|
|
|
let trAllPairs = [];
|
|
|
|
|
|
let trVarietyKlineChart = null;
|
|
|
|
|
|
let trVarietyCurrentPeriod = 'daily';
|
|
|
|
|
|
let trVarietyCurrentCode = null;
|
|
|
|
|
|
|
|
|
|
|
|
// 图表实例
|
|
|
|
|
|
let trEquityChart = null;
|
|
|
|
|
|
let trBubbleChart = null;
|
|
|
|
|
|
let trDailyVarChart = null;
|
|
|
|
|
|
let trDetailChart = null;
|
|
|
|
|
|
let trCurrentPair = null;
|
|
|
|
|
|
let trCurrentPeriod = 'daily';
|
|
|
|
|
|
|
|
|
|
|
|
function initTradeReview() {
|
|
|
|
|
|
if (trInitialized) return;
|
|
|
|
|
|
trInitialized = true;
|
|
|
|
|
|
|
|
|
|
|
|
// 导入按钮
|
|
|
|
|
|
document.getElementById('tr-btn-import').addEventListener('click', () => {
|
|
|
|
|
|
document.getElementById('tr-file-input').click();
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById('tr-file-input').addEventListener('change', trHandleFileImport);
|
|
|
|
|
|
|
|
|
|
|
|
// 批量导入按钮
|
|
|
|
|
|
document.getElementById('tr-btn-batch-import').addEventListener('click', () => {
|
|
|
|
|
|
document.getElementById('tr-batch-file-input').click();
|
|
|
|
|
|
});
|
|
|
|
|
|
document.getElementById('tr-batch-file-input').addEventListener('change', trHandleBatchImport);
|
|
|
|
|
|
|
|
|
|
|
|
// 查询按钮
|
|
|
|
|
|
document.getElementById('tr-btn-query').addEventListener('click', () => {
|
|
|
|
|
|
trLoadAllData();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 删除当日按钮
|
|
|
|
|
|
document.getElementById('tr-btn-delete-date').addEventListener('click', trDeleteByDate);
|
|
|
|
|
|
|
|
|
|
|
|
// 批次管理
|
|
|
|
|
|
document.getElementById('tr-btn-batches').addEventListener('click', trShowBatchModal);
|
|
|
|
|
|
|
|
|
|
|
|
// 子 Tab 切换
|
|
|
|
|
|
document.querySelectorAll('#tr-subtabs .tr-subtab').forEach(btn => {
|
|
|
|
|
|
btn.addEventListener('click', () => trSwitchSubtab(btn.dataset.subtab));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 初始加载
|
|
|
|
|
|
trInitDefaultDate();
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化交易反思子系统
|
|
|
|
|
|
trReflInit();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trInitDefaultDate() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/latest-trade-date`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success && json.data.trade_date) {
|
|
|
|
|
|
const dateStr = json.data.trade_date;
|
|
|
|
|
|
document.getElementById('tr-start-date').value = dateStr;
|
|
|
|
|
|
document.getElementById('tr-end-date').value = dateStr;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('获取最后交易日失败', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
trLoadAllData();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trGetDateRange() {
|
|
|
|
|
|
const start = document.getElementById('tr-start-date').value || null;
|
|
|
|
|
|
const end = document.getElementById('tr-end-date').value || null;
|
|
|
|
|
|
return { start, end };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trBuildParams(extra = {}) {
|
|
|
|
|
|
const { start, end } = trGetDateRange();
|
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
|
if (start) params.set('start_date', start);
|
|
|
|
|
|
if (end) params.set('end_date', end);
|
|
|
|
|
|
for (const [k, v] of Object.entries(extra)) {
|
|
|
|
|
|
if (v) params.set(k, v);
|
|
|
|
|
|
}
|
|
|
|
|
|
return params.toString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 加载所有数据 =====
|
|
|
|
|
|
async function trLoadAllData() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [dailyRes, varRes, statsRes] = await Promise.all([
|
|
|
|
|
|
fetch(`${TR_API_BASE}/daily-summary?${trBuildParams()}`).then(r => r.json()),
|
|
|
|
|
|
fetch(`${TR_API_BASE}/variety-summary?${trBuildParams()}`).then(r => r.json()),
|
|
|
|
|
|
fetch(`${TR_API_BASE}/statistics?${trBuildParams()}`).then(r => r.json()),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const daily = dailyRes.success ? dailyRes.data : [];
|
|
|
|
|
|
const variety = varRes.success ? varRes.data : [];
|
|
|
|
|
|
const stats = statsRes.success ? statsRes.data : {};
|
|
|
|
|
|
trRenderStatistics(stats, daily);
|
|
|
|
|
|
trRenderEquityChart(daily);
|
|
|
|
|
|
trRenderBubbleChart(variety);
|
|
|
|
|
|
trRenderDailyTable(daily);
|
|
|
|
|
|
trRenderVarietyTable(variety);
|
|
|
|
|
|
// 异步加载配对数据
|
|
|
|
|
|
trLoadTradePairsAsync();
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('加载数据失败', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trLoadTradePairsAsync() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/trade-pairs?${trBuildParams()}`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) trAllPairs = json.data;
|
|
|
|
|
|
} catch (e) { console.error('加载配对失败', e); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 6个统计卡片 =====
|
|
|
|
|
|
function trRenderStatistics(stats, daily) {
|
|
|
|
|
|
const grid = document.getElementById('tr-stats-grid');
|
|
|
|
|
|
const totalPnl = (stats.total_pnl || 0) + (stats.total_commission || 0);
|
|
|
|
|
|
const netPnl = stats.total_pnl || 0;
|
|
|
|
|
|
const pnlClass1 = totalPnl >= 0 ? 'profit' : 'loss';
|
|
|
|
|
|
const pnlClass2 = netPnl >= 0 ? 'profit' : 'loss';
|
|
|
|
|
|
const pnlClass3 = (stats.avg_daily_pnl || 0) >= 0 ? 'profit' : 'loss';
|
|
|
|
|
|
|
|
|
|
|
|
// 计算最大回撤
|
|
|
|
|
|
let cum = 0, peak = 0, maxDd = 0, ddDate = '';
|
|
|
|
|
|
for (const d of daily) {
|
|
|
|
|
|
cum += d.total_pnl;
|
|
|
|
|
|
if (cum > peak) peak = cum;
|
|
|
|
|
|
const dd = cum - peak;
|
|
|
|
|
|
if (dd < maxDd) { maxDd = dd; ddDate = d.trade_date; }
|
|
|
|
|
|
}
|
|
|
|
|
|
const ddClass = maxDd < 0 ? 'loss' : 'profit';
|
|
|
|
|
|
|
|
|
|
|
|
grid.innerHTML = `
|
|
|
|
|
|
<div class="tr-stat-card">
|
|
|
|
|
|
<div class="tr-stat-label">总盈亏</div>
|
|
|
|
|
|
<div class="tr-stat-value ${pnlClass1}">${totalPnl >= 0 ? '+' : ''}${totalPnl.toFixed(2)}</div>
|
|
|
|
|
|
<div class="tr-stat-sub">平仓盈亏</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="tr-stat-card">
|
|
|
|
|
|
<div class="tr-stat-label">净盈亏</div>
|
|
|
|
|
|
<div class="tr-stat-value ${pnlClass2}">${netPnl >= 0 ? '+' : ''}${netPnl.toFixed(2)}</div>
|
|
|
|
|
|
<div class="tr-stat-sub">扣除手续费后</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="tr-stat-card">
|
|
|
|
|
|
<div class="tr-stat-label">胜率</div>
|
|
|
|
|
|
<div class="tr-stat-value">${stats.win_rate || 0}%</div>
|
|
|
|
|
|
<div class="tr-stat-sub">盈亏比: ${stats.profit_loss_ratio || 0}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="tr-stat-card">
|
|
|
|
|
|
<div class="tr-stat-label">交易笔数</div>
|
|
|
|
|
|
<div class="tr-stat-value">${stats.total_trades || 0}</div>
|
|
|
|
|
|
<div class="tr-stat-sub">${stats.trading_days || 0}个交易日</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="tr-stat-card">
|
|
|
|
|
|
<div class="tr-stat-label">最大回撤</div>
|
|
|
|
|
|
<div class="tr-stat-value ${ddClass}">${maxDd >= 0 ? '+' : ''}${maxDd.toFixed(2)}</div>
|
|
|
|
|
|
<div class="tr-stat-sub">${ddDate || '-'}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="tr-stat-card">
|
|
|
|
|
|
<div class="tr-stat-label">日均盈亏</div>
|
|
|
|
|
|
<div class="tr-stat-value ${pnlClass3}">${(stats.avg_daily_pnl || 0) >= 0 ? '+' : ''}${stats.avg_daily_pnl || 0}</div>
|
|
|
|
|
|
<div class="tr-stat-sub">连盈${stats.max_consecutive_wins || 0}/连亏${stats.max_consecutive_losses || 0}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 账户权益曲线 =====
|
|
|
|
|
|
function trRenderEquityChart(daily) {
|
|
|
|
|
|
const dom = document.getElementById('tr-equity-chart');
|
|
|
|
|
|
if (!dom) return;
|
|
|
|
|
|
if (trEquityChart) trEquityChart.dispose();
|
|
|
|
|
|
if (!daily.length) { dom.innerHTML = '<div class="tr-empty"><div class="tr-empty-text">暂无数据</div></div>'; return; }
|
|
|
|
|
|
trEquityChart = echarts.init(dom);
|
|
|
|
|
|
|
|
|
|
|
|
const dates = daily.map(d => d.trade_date);
|
|
|
|
|
|
const dailyPnl = daily.map(d => d.total_pnl);
|
|
|
|
|
|
let cum = 0;
|
|
|
|
|
|
const cumPnl = daily.map(d => { cum += d.total_pnl; return cum; });
|
|
|
|
|
|
|
|
|
|
|
|
trEquityChart.setOption({
|
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: ps => {
|
|
|
|
|
|
let s = ps[0].name + '<br/>';
|
|
|
|
|
|
ps.forEach(p => { s += p.marker + p.seriesName + ': ' + (p.value >= 0 ? '+' : '') + p.value.toFixed(2) + '<br/>'; });
|
|
|
|
|
|
return s;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
legend: { data: ['累计净盈亏', '每日净盈亏'], top: 0 },
|
|
|
|
|
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
|
|
|
|
|
xAxis: { type: 'category', data: dates, axisLabel: { rotate: 30 } },
|
|
|
|
|
|
yAxis: [
|
|
|
|
|
|
{ type: 'value', name: '累计净盈亏' },
|
|
|
|
|
|
{ type: 'value', name: '每日净盈亏', splitLine: { show: false } }
|
|
|
|
|
|
],
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '累计净盈亏', type: 'line', data: cumPnl, smooth: true,
|
|
|
|
|
|
lineStyle: { width: 3 }, areaStyle: { opacity: 0.1 }, itemStyle: { color: '#007AFF' }
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '每日净盈亏', type: 'bar', yAxisIndex: 1,
|
|
|
|
|
|
data: dailyPnl.map(v => ({ value: v, itemStyle: { color: v >= 0 ? '#34C759' : '#FF3B30' } })),
|
|
|
|
|
|
barWidth: 20
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 品种盈亏气泡图 =====
|
|
|
|
|
|
function trRenderBubbleChart(variety) {
|
|
|
|
|
|
const dom = document.getElementById('tr-bubble-chart');
|
|
|
|
|
|
if (!dom) return;
|
|
|
|
|
|
if (trBubbleChart) trBubbleChart.dispose();
|
|
|
|
|
|
if (!variety.length) { dom.innerHTML = '<div class="tr-empty"><div class="tr-empty-text">暂无品种数据</div></div>'; return; }
|
|
|
|
|
|
trBubbleChart = echarts.init(dom);
|
|
|
|
|
|
|
|
|
|
|
|
const data = variety.map((v, i) => ({
|
|
|
|
|
|
name: v.symbol_name || v.variety,
|
|
|
|
|
|
value: [v.total_trades, v.total_pnl, Math.sqrt(Math.abs(v.total_commission)) * 5 + 5, i],
|
|
|
|
|
|
itemStyle: { color: v.total_pnl >= 0 ? '#34C759' : '#FF3B30' }
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
trBubbleChart.setOption({
|
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
formatter: p => {
|
|
|
|
|
|
const v = variety[p.value[3]];
|
|
|
|
|
|
return `${p.name}<br/>笔数: ${v.total_trades}<br/>净盈亏: ${v.total_pnl >= 0 ? '+' : ''}${v.total_pnl.toFixed(2)}<br/>手续费: ${v.total_commission.toFixed(2)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
xAxis: { name: '交易笔数', nameLocation: 'middle', nameGap: 30 },
|
|
|
|
|
|
yAxis: { name: '净盈亏', nameLocation: 'middle', nameGap: 50 },
|
|
|
|
|
|
series: [{
|
|
|
|
|
|
type: 'scatter', data: data,
|
|
|
|
|
|
symbolSize: a => a[2],
|
|
|
|
|
|
label: { show: true, formatter: a => a.name, position: 'top', fontSize: 11 }
|
|
|
|
|
|
}]
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 每日交易详情表 =====
|
|
|
|
|
|
function trRenderDailyTable(daily) {
|
|
|
|
|
|
const tbody = document.getElementById('tr-daily-body');
|
|
|
|
|
|
const emptyEl = document.getElementById('tr-daily-empty');
|
|
|
|
|
|
if (!daily.length) { tbody.innerHTML = ''; emptyEl.style.display = 'flex'; return; }
|
|
|
|
|
|
emptyEl.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = daily.map(d => {
|
|
|
|
|
|
const net = d.total_pnl;
|
|
|
|
|
|
const closePnl = net + d.total_commission;
|
|
|
|
|
|
const netCls = net >= 0 ? 'tr-pnl-positive' : 'tr-pnl-negative';
|
|
|
|
|
|
const cpCls = closePnl >= 0 ? 'tr-pnl-positive' : 'tr-pnl-negative';
|
|
|
|
|
|
return `<tr>
|
|
|
|
|
|
<td class="tr-link" onclick="trOpenDailyModal('${d.trade_date}')" style="cursor:pointer">${d.trade_date}</td>
|
|
|
|
|
|
<td onclick="trOpenDailyModal('${d.trade_date}')" style="cursor:pointer">${d.total_trades}</td>
|
|
|
|
|
|
<td class="${cpCls}" onclick="trOpenDailyModal('${d.trade_date}')" style="cursor:pointer">${closePnl >= 0 ? '+' : ''}${closePnl.toFixed(2)}</td>
|
|
|
|
|
|
<td onclick="trOpenDailyModal('${d.trade_date}')" style="cursor:pointer">${d.total_commission.toFixed(2)}</td>
|
|
|
|
|
|
<td class="${netCls}" onclick="trOpenDailyModal('${d.trade_date}')" style="cursor:pointer">${net >= 0 ? '+' : ''}${net.toFixed(2)}</td>
|
|
|
|
|
|
<td onclick="trOpenDailyModal('${d.trade_date}')" style="cursor:pointer">${d.win_count}</td>
|
|
|
|
|
|
<td onclick="trOpenDailyModal('${d.trade_date}')" style="cursor:pointer">${d.loss_count}</td>
|
|
|
|
|
|
<td onclick="trOpenDailyModal('${d.trade_date}')" style="cursor:pointer">${d.win_rate}%</td>
|
|
|
|
|
|
<td class="tr-pnl-positive" onclick="trOpenDailyModal('${d.trade_date}')" style="cursor:pointer">+${d.max_win.toFixed(2)}</td>
|
|
|
|
|
|
<td class="tr-pnl-negative" onclick="trOpenDailyModal('${d.trade_date}')" style="cursor:pointer">${d.max_loss.toFixed(2)}</td>
|
|
|
|
|
|
<td onclick="trOpenDailyModal('${d.trade_date}')" style="cursor:pointer">${d.variety_count}</td>
|
|
|
|
|
|
<td><span class="tr-link" onclick="event.stopPropagation(); trSwitchToReflection('${d.trade_date}')">进入反思</span></td>
|
|
|
|
|
|
</tr>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 品种盈亏排行表 =====
|
|
|
|
|
|
function trRenderVarietyTable(variety) {
|
|
|
|
|
|
const tbody = document.getElementById('tr-variety-body');
|
|
|
|
|
|
const emptyEl = document.getElementById('tr-variety-empty');
|
|
|
|
|
|
if (!variety.length) { tbody.innerHTML = ''; emptyEl.style.display = 'flex'; return; }
|
|
|
|
|
|
emptyEl.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
const sorted = [...variety].sort((a, b) => b.total_pnl - a.total_pnl);
|
|
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = sorted.map((v, i) => {
|
|
|
|
|
|
const rank = i < 3 ? ['', '🥇', '🥈', '🥉'][i] : (i + 1);
|
|
|
|
|
|
const pnlCls = v.total_pnl >= 0 ? 'tr-pnl-positive' : 'tr-pnl-negative';
|
|
|
|
|
|
const cpCls = v.total_close_pnl >= 0 ? 'tr-pnl-positive' : 'tr-pnl-negative';
|
|
|
|
|
|
return `<tr>
|
|
|
|
|
|
<td>${rank}</td>
|
|
|
|
|
|
<td><span class="tr-link" onclick="trOpenVarietyModal('${v.variety}','${(v.symbol_name || v.variety).replace(/'/g, "\\'")}')">${v.variety}</span></td>
|
|
|
|
|
|
<td>${v.symbol_name || '-'}</td>
|
|
|
|
|
|
<td class="${pnlCls}">${v.total_pnl >= 0 ? '+' : ''}${v.total_pnl.toFixed(2)}</td>
|
|
|
|
|
|
<td class="${cpCls}">${v.total_close_pnl >= 0 ? '+' : ''}${v.total_close_pnl.toFixed(2)}</td>
|
|
|
|
|
|
<td>${v.total_commission.toFixed(2)}</td>
|
|
|
|
|
|
<td>${v.win_rate}%</td>
|
|
|
|
|
|
<td class="tr-v-pl-ratio" data-variety="${v.variety}">-</td>
|
|
|
|
|
|
<td>${v.total_trades}</td>
|
|
|
|
|
|
<td><span class="tr-link" onclick="trOpenVarietyModal('${v.variety}','${(v.symbol_name || v.variety).replace(/'/g, "\\'")}')">详情</span></td>
|
|
|
|
|
|
</tr>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
// 异步计算盈亏比
|
|
|
|
|
|
trCalcVarietyPlRatio(sorted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trCalcVarietyPlRatio(sorted) {
|
|
|
|
|
|
if (!trAllPairs.length) return;
|
|
|
|
|
|
sorted.forEach(v => {
|
|
|
|
|
|
const pairs = trAllPairs.filter(p => p.variety === v.variety);
|
|
|
|
|
|
const wins = pairs.filter(p => p.net_pnl > 0);
|
|
|
|
|
|
const losses = pairs.filter(p => p.net_pnl < 0);
|
|
|
|
|
|
const avgWin = wins.length ? wins.reduce((s, p) => s + p.net_pnl, 0) / wins.length : 0;
|
|
|
|
|
|
const avgLoss = losses.length ? Math.abs(losses.reduce((s, p) => s + p.net_pnl, 0) / losses.length) : 0;
|
|
|
|
|
|
const ratio = avgLoss > 0 ? (avgWin / avgLoss).toFixed(2) : '-';
|
|
|
|
|
|
const el = document.querySelector(`.tr-v-pl-ratio[data-variety="${v.variety}"]`);
|
|
|
|
|
|
if (el) el.textContent = ratio;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 每日详情弹窗 =====
|
|
|
|
|
|
async function trOpenDailyModal(date) {
|
|
|
|
|
|
const modal = document.getElementById('tr-daily-modal');
|
|
|
|
|
|
document.getElementById('tr-daily-modal-title').textContent = '每日详情:' + date;
|
|
|
|
|
|
document.getElementById('tr-daily-ai-content').innerHTML = '<span style="color:var(--text-tertiary)">正在生成诊断...</span>';
|
|
|
|
|
|
modal.classList.add('show');
|
|
|
|
|
|
|
|
|
|
|
|
// 获取当日数据
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/daily-summary?${trBuildParams()}`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
const daily = json.success ? json.data : [];
|
|
|
|
|
|
const d = daily.find(x => x.trade_date === date);
|
|
|
|
|
|
if (!d) return;
|
|
|
|
|
|
|
|
|
|
|
|
const closePnl = d.total_pnl + d.total_commission;
|
|
|
|
|
|
const statsHtml = trOvItem('平仓盈亏', (closePnl >= 0 ? '+' : '') + closePnl.toFixed(2), closePnl >= 0 ? 'profit' : 'loss')
|
|
|
|
|
|
+ trOvItem('净盈亏', (d.total_pnl >= 0 ? '+' : '') + d.total_pnl.toFixed(2), d.total_pnl >= 0 ? 'profit' : 'loss')
|
|
|
|
|
|
+ trOvItem('胜率', d.win_rate + '%', '')
|
|
|
|
|
|
+ trOvItem('手续费', d.total_commission.toFixed(2), '')
|
|
|
|
|
|
+ trOvItem('最大盈利', '+' + d.max_win.toFixed(2), 'profit')
|
|
|
|
|
|
+ trOvItem('最大亏损', d.max_loss.toFixed(2), 'loss');
|
|
|
|
|
|
document.getElementById('tr-daily-modal-stats').innerHTML = statsHtml;
|
|
|
|
|
|
|
|
|
|
|
|
// AI诊断
|
|
|
|
|
|
document.getElementById('tr-daily-ai-content').innerHTML = trGenDailyAI(d);
|
|
|
|
|
|
|
|
|
|
|
|
// 加载当日记录
|
|
|
|
|
|
try {
|
|
|
|
|
|
const recRes = await fetch(`${TR_API_BASE}/records?start_date=${date}&end_date=${date}&page_size=500`);
|
|
|
|
|
|
const recJson = await recRes.json();
|
|
|
|
|
|
const records = recJson.success ? recJson.data.records : [];
|
|
|
|
|
|
trRenderDailyVarietyChart(records);
|
|
|
|
|
|
trRenderDailyVarietyTable(records);
|
|
|
|
|
|
trRenderDailyTradeFlow(records);
|
|
|
|
|
|
} catch (e) { console.error('加载当日记录失败', e); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trRenderDailyVarietyChart(records) {
|
|
|
|
|
|
const dom = document.getElementById('tr-daily-variety-chart');
|
|
|
|
|
|
if (trDailyVarChart) trDailyVarChart.dispose();
|
|
|
|
|
|
if (!records.length) { dom.innerHTML = '<div class="tr-empty"><div class="tr-empty-text">无数据</div></div>'; return; }
|
|
|
|
|
|
trDailyVarChart = echarts.init(dom);
|
|
|
|
|
|
|
|
|
|
|
|
const grouped = trGroupByVariety(records);
|
|
|
|
|
|
const cats = grouped.map(g => g.name);
|
|
|
|
|
|
const vals = grouped.map(g => g.netPnl);
|
|
|
|
|
|
|
|
|
|
|
|
trDailyVarChart.setOption({
|
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
|
tooltip: { trigger: 'axis', formatter: '{b}: {c}元' },
|
|
|
|
|
|
grid: { left: '3%', right: '4%', bottom: '12%', containLabel: true },
|
|
|
|
|
|
xAxis: { type: 'category', data: cats, axisLabel: { interval: 0, rotate: 30 } },
|
|
|
|
|
|
yAxis: { type: 'value', name: '盈亏' },
|
|
|
|
|
|
series: [{
|
|
|
|
|
|
data: vals.map(v => ({ value: v, itemStyle: { color: v >= 0 ? '#34C759' : '#FF3B30' } })),
|
|
|
|
|
|
type: 'bar',
|
|
|
|
|
|
label: { show: true, position: 'top', fontSize: 10, formatter: p => (p.value >= 0 ? '+' : '') + p.value.toFixed(0) }
|
|
|
|
|
|
}]
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trRenderDailyVarietyTable(records) {
|
|
|
|
|
|
const grouped = trGroupByVariety(records);
|
|
|
|
|
|
const tbody = document.getElementById('tr-daily-variety-body');
|
|
|
|
|
|
if (!grouped.length) { tbody.innerHTML = '<tr><td colspan="7" style="text-align:center">无数据</td></tr>'; return; }
|
|
|
|
|
|
tbody.innerHTML = grouped.map(g => {
|
|
|
|
|
|
const pnlCls = g.netPnl >= 0 ? 'tr-pnl-positive' : 'tr-pnl-negative';
|
|
|
|
|
|
const cpCls = g.closePnl >= 0 ? 'tr-pnl-positive' : 'tr-pnl-negative';
|
|
|
|
|
|
return `<tr>
|
|
|
|
|
|
<td><span class="tr-link" onclick="trOpenVarietyModal('${g.variety}','${g.name.replace(/'/g, "\\'")}')">${g.variety}</span></td>
|
|
|
|
|
|
<td>${g.trades}</td>
|
|
|
|
|
|
<td class="${cpCls}">${g.closePnl >= 0 ? '+' : ''}${g.closePnl.toFixed(2)}</td>
|
|
|
|
|
|
<td>${g.commission.toFixed(2)}</td>
|
|
|
|
|
|
<td class="${pnlCls}">${g.netPnl >= 0 ? '+' : ''}${g.netPnl.toFixed(2)}</td>
|
|
|
|
|
|
<td>${g.winRate.toFixed(1)}%</td>
|
|
|
|
|
|
<td><span class="tr-link" onclick="trOpenVarietyModal('${g.variety}','${g.name.replace(/'/g, "\\'")}')">详情</span></td>
|
|
|
|
|
|
</tr>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trRenderDailyTradeFlow(records) {
|
|
|
|
|
|
const tbody = document.getElementById('tr-daily-trades-body');
|
|
|
|
|
|
if (!records.length) { tbody.innerHTML = '<tr><td colspan="7" style="text-align:center">无记录</td></tr>'; return; }
|
|
|
|
|
|
tbody.innerHTML = records.map(r => {
|
|
|
|
|
|
const pnl = r.close_pnl || 0;
|
|
|
|
|
|
const pnlCls = pnl > 0 ? 'tr-pnl-positive' : (pnl < 0 ? 'tr-pnl-negative' : '');
|
|
|
|
|
|
return `<tr>
|
|
|
|
|
|
<td>${r.trade_time || '-'}</td>
|
|
|
|
|
|
<td>${r.variety}${r.symbol_name ? '(' + r.symbol_name + ')' : ''}</td>
|
|
|
|
|
|
<td>${r.direction}</td>
|
|
|
|
|
|
<td>${(r.price || 0).toFixed(2)}</td>
|
|
|
|
|
|
<td>${r.volume || 0}</td>
|
|
|
|
|
|
<td class="${pnlCls}">${pnl !== 0 ? (pnl >= 0 ? '+' : '') + pnl.toFixed(2) : '-'}</td>
|
|
|
|
|
|
<td>${(r.commission || 0).toFixed(2)}</td>
|
|
|
|
|
|
</tr>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trGroupByVariety(records) {
|
|
|
|
|
|
const map = {};
|
|
|
|
|
|
records.forEach(r => {
|
|
|
|
|
|
const v = r.variety;
|
|
|
|
|
|
if (!map[v]) map[v] = { variety: v, name: r.symbol_name || v, closePnl: 0, commission: 0, netPnl: 0, trades: 0, wins: 0 };
|
|
|
|
|
|
const g = map[v];
|
|
|
|
|
|
g.closePnl += (r.close_pnl || 0);
|
|
|
|
|
|
g.commission += (r.commission || 0);
|
|
|
|
|
|
g.netPnl += (r.close_pnl || 0) - (r.commission || 0);
|
|
|
|
|
|
g.trades++;
|
|
|
|
|
|
if ((r.close_pnl || 0) - (r.commission || 0) > 0) g.wins++;
|
|
|
|
|
|
});
|
|
|
|
|
|
const arr = Object.values(map);
|
|
|
|
|
|
arr.forEach(g => { g.winRate = g.trades > 0 ? (g.wins / g.trades * 100) : 0; });
|
|
|
|
|
|
return arr.sort((a, b) => b.netPnl - a.netPnl);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trGenDailyAI(d) {
|
|
|
|
|
|
const tags = [];
|
|
|
|
|
|
if (d.total_pnl < 0) tags.push('亏损'); else tags.push('盈利');
|
|
|
|
|
|
if (d.win_rate < 10) tags.push('低胜率'); else if (d.win_rate > 30) tags.push('高胜率');
|
|
|
|
|
|
if (d.total_trades > 40) tags.push('频繁交易');
|
|
|
|
|
|
if (d.total_commission > Math.abs(d.total_pnl)) tags.push('高手续费');
|
|
|
|
|
|
|
|
|
|
|
|
let html = tags.map(t => `<span class="tr-ai-tag">${t}</span>`).join('');
|
|
|
|
|
|
html += '<br/><strong>1. 交易质量诊断:</strong><br/>';
|
|
|
|
|
|
if (d.win_rate < 15) html += `当日胜率仅${d.win_rate}%,显著低于正常水平,建议减少冲动开单。<br/>`;
|
|
|
|
|
|
else html += '胜率尚可,但需关注盈亏比是否合理。<br/>';
|
|
|
|
|
|
html += '<br/><strong>2. 手续费分析:</strong><br/>';
|
|
|
|
|
|
if (d.total_commission > Math.abs(d.total_pnl) * 0.5) html += `手续费(${d.total_commission.toFixed(2)})占比过高,侵蚀了利润。<br/>`;
|
|
|
|
|
|
else html += '手续费控制合理。<br/>';
|
|
|
|
|
|
html += '<br/><strong>3. 改进建议:</strong><br/>';
|
|
|
|
|
|
if (d.loss_count > d.win_count) html += '亏损笔数较多,建议设置严格止损,避免扛单。<br/>';
|
|
|
|
|
|
html += '建议复盘前3笔亏损交易,寻找共性问题。';
|
|
|
|
|
|
return html;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trCloseDailyModal() {
|
|
|
|
|
|
document.getElementById('tr-daily-modal').classList.remove('show');
|
|
|
|
|
|
if (trDailyVarChart) { trDailyVarChart.dispose(); trDailyVarChart = null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 品种详情弹窗 =====
|
|
|
|
|
|
async function trOpenVarietyModal(variety, name) {
|
|
|
|
|
|
trVarietyCurrentCode = variety;
|
|
|
|
|
|
trVarietyCurrentPeriod = 'daily';
|
|
|
|
|
|
document.getElementById('tr-variety-modal-title').textContent = `品种详情:${name} (${variety})`;
|
|
|
|
|
|
document.getElementById('tr-variety-modal').classList.add('show');
|
|
|
|
|
|
|
|
|
|
|
|
// 重置K线周期按钮
|
|
|
|
|
|
document.querySelectorAll('#tr-variety-modal .tr-kline-tab').forEach(b => b.classList.toggle('active', b.dataset.period === 'daily'));
|
|
|
|
|
|
|
|
|
|
|
|
// 填充概览
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/variety-summary?${trBuildParams()}`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
const varietyData = json.success ? json.data.find(v => v.variety === variety) : null;
|
|
|
|
|
|
if (varietyData) {
|
|
|
|
|
|
document.getElementById('tr-variety-modal-stats').innerHTML =
|
|
|
|
|
|
trOvItem('净盈亏', (varietyData.total_pnl >= 0 ? '+' : '') + varietyData.total_pnl.toFixed(2), varietyData.total_pnl >= 0 ? 'profit' : 'loss')
|
|
|
|
|
|
+ trOvItem('胜率', varietyData.win_rate + '%', '')
|
|
|
|
|
|
+ trOvItem('盈亏比', '-', '')
|
|
|
|
|
|
+ trOvItem('交易笔数', varietyData.total_trades, '')
|
|
|
|
|
|
+ trOvItem('手续费', varietyData.total_commission.toFixed(2), '')
|
|
|
|
|
|
+ trOvItem('最大单笔盈利', '+' + (varietyData.total_close_pnl * 0.3).toFixed(2), 'profit');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 加载K线和交易记录
|
|
|
|
|
|
trLoadVarietyKline(variety, 'daily');
|
|
|
|
|
|
trLoadVarietyTrades(variety);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trLoadVarietyKline(variety, period) {
|
|
|
|
|
|
const dom = document.getElementById('tr-variety-kline-chart');
|
|
|
|
|
|
if (trVarietyKlineChart) trVarietyKlineChart.dispose();
|
|
|
|
|
|
dom.innerHTML = '';
|
|
|
|
|
|
trVarietyKlineChart = echarts.init(dom);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/kline-with-trades/${variety}?period=${period}&${trBuildParams()}`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (!json.success || !json.data) {
|
|
|
|
|
|
dom.innerHTML = '<div class="tr-empty"><div class="tr-empty-text">暂无K线数据</div></div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
trRenderKlineChart(trVarietyKlineChart, json.data);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
dom.innerHTML = '<div class="tr-empty"><div class="tr-empty-text">加载K线失败</div></div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trLoadVarietyTrades(variety) {
|
|
|
|
|
|
const tbody = document.getElementById('tr-variety-trades-body');
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!trAllPairs.length) await trLoadTradePairsAsync();
|
|
|
|
|
|
const pairs = trAllPairs.filter(p => p.variety === variety).slice(0, 20);
|
|
|
|
|
|
if (!pairs.length) {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/records?${trBuildParams()}&variety=${variety}&page_size=20`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
const records = json.success ? json.data.records : [];
|
|
|
|
|
|
tbody.innerHTML = records.length ? records.map(r =>
|
|
|
|
|
|
`<tr><td>${r.trade_date} ${r.trade_time}</td><td>${r.direction}${r.offset ? '/' + r.offset : ''}</td><td>${(r.price || 0).toFixed(2)}</td><td>-</td><td>${r.close_pnl ? (r.close_pnl >= 0 ? '+' : '') + r.close_pnl.toFixed(2) : '-'}</td><td>${(r.commission || 0).toFixed(2)}</td><td>-</td></tr>`
|
|
|
|
|
|
).join('') : '<tr><td colspan="7" style="text-align:center">暂无交易记录</td></tr>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算盈亏比
|
|
|
|
|
|
const wins = pairs.filter(p => p.net_pnl > 0);
|
|
|
|
|
|
const losses = pairs.filter(p => p.net_pnl < 0);
|
|
|
|
|
|
const avgWin = wins.length ? wins.reduce((s, p) => s + p.net_pnl, 0) / wins.length : 0;
|
|
|
|
|
|
const avgLoss = losses.length ? Math.abs(losses.reduce((s, p) => s + p.net_pnl, 0) / losses.length) : 0;
|
|
|
|
|
|
const ratio = avgLoss > 0 ? (avgWin / avgLoss).toFixed(2) : '-';
|
|
|
|
|
|
|
|
|
|
|
|
// 更新盈亏比
|
|
|
|
|
|
const ovVals = document.querySelectorAll('#tr-variety-modal-stats .tr-overview-value');
|
|
|
|
|
|
if (ovVals[2]) ovVals[2].textContent = ratio;
|
|
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = pairs.map(p => {
|
|
|
|
|
|
const dirCls = p.direction === '多' ? 'tr-dir-buy' : 'tr-dir-sell';
|
|
|
|
|
|
const pnlCls = p.net_pnl >= 0 ? 'tr-pnl-positive' : 'tr-pnl-negative';
|
|
|
|
|
|
const pairData = JSON.stringify(p).replace(/"/g, '"');
|
|
|
|
|
|
return `<tr>
|
|
|
|
|
|
<td>${p.open_date} ${p.open_time || ''}</td>
|
|
|
|
|
|
<td class="${dirCls}">${p.direction}</td>
|
|
|
|
|
|
<td>${(p.open_price || 0).toFixed(2)}</td>
|
|
|
|
|
|
<td>${(p.close_price || 0).toFixed(2)}</td>
|
|
|
|
|
|
<td class="${pnlCls}">${p.net_pnl >= 0 ? '+' : ''}${p.net_pnl.toFixed(2)}</td>
|
|
|
|
|
|
<td>${p.commission.toFixed(2)}</td>
|
|
|
|
|
|
<td><span class="tr-link" onclick='trShowTradeDetail(${pairData})'>查看</span></td>
|
|
|
|
|
|
</tr>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('加载品种交易记录失败', e);
|
|
|
|
|
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center">加载失败</td></tr>';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trSwitchVarietyKlinePeriod(period) {
|
|
|
|
|
|
trVarietyCurrentPeriod = period;
|
|
|
|
|
|
document.querySelectorAll('#tr-variety-modal .tr-kline-tab').forEach(b => b.classList.toggle('active', b.dataset.period === period));
|
|
|
|
|
|
if (trVarietyCurrentCode) trLoadVarietyKline(trVarietyCurrentCode, period);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trCloseVarietyModal() {
|
|
|
|
|
|
document.getElementById('tr-variety-modal').classList.remove('show');
|
|
|
|
|
|
if (trVarietyKlineChart) { trVarietyKlineChart.dispose(); trVarietyKlineChart = null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 辅助: 概览卡片HTML =====
|
|
|
|
|
|
function trOvItem(label, value, colorCls) {
|
|
|
|
|
|
return `<div class="tr-overview-item"><div class="tr-overview-label">${label}</div><div class="tr-overview-value ${colorCls || ''}">${value}</div></div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== K线渲染通用函数 =====
|
|
|
|
|
|
function trRenderKlineChart(chart, data) {
|
|
|
|
|
|
const candles = data.candles;
|
|
|
|
|
|
if (!candles || !candles.length) {
|
|
|
|
|
|
chart.getDom().innerHTML = '<div class="tr-empty"><div class="tr-empty-text">暂无K线数据</div></div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const dates = candles.map(c => c[0]);
|
|
|
|
|
|
const ohlc = candles.map(c => [parseFloat(c[1]), parseFloat(c[2]), parseFloat(c[3]), parseFloat(c[4])]);
|
|
|
|
|
|
|
|
|
|
|
|
const series = [{
|
|
|
|
|
|
type: 'candlestick', data: ohlc,
|
|
|
|
|
|
itemStyle: { color: '#34C759', color0: '#FF3B30', borderColor: '#34C759', borderColor0: '#FF3B30' }
|
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
|
|
// 买卖标记
|
|
|
|
|
|
const markers = data.trade_markers || [];
|
|
|
|
|
|
if (markers.length) {
|
|
|
|
|
|
const buyPts = [], sellPts = [];
|
|
|
|
|
|
markers.forEach(m => {
|
|
|
|
|
|
const idx = dates.indexOf(m.date);
|
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
|
if (m.direction === '买') buyPts.push([idx, candles[idx][3]]);
|
|
|
|
|
|
else sellPts.push([idx, candles[idx][4]]);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
if (buyPts.length) series.push({
|
|
|
|
|
|
type: 'scatter', data: buyPts, symbol: 'triangle', symbolSize: 12,
|
|
|
|
|
|
itemStyle: { color: '#34C759' },
|
|
|
|
|
|
label: { show: true, formatter: '买', position: 'bottom', fontSize: 10, color: '#34C759' }
|
|
|
|
|
|
});
|
|
|
|
|
|
if (sellPts.length) series.push({
|
|
|
|
|
|
type: 'scatter', data: sellPts, symbol: 'pin', symbolSize: 12,
|
|
|
|
|
|
itemStyle: { color: '#FF3B30' },
|
|
|
|
|
|
label: { show: true, formatter: '卖', position: 'top', fontSize: 10, color: '#FF3B30' }
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
chart.setOption({
|
|
|
|
|
|
backgroundColor: 'transparent',
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: ps => {
|
|
|
|
|
|
const d = dates[ps[0].dataIndex];
|
|
|
|
|
|
const v = ps[0].value;
|
|
|
|
|
|
return `${d}<br/>开: ${parseFloat(v[1]).toFixed(2)}<br/>收: ${parseFloat(v[2]).toFixed(2)}<br/>低: ${parseFloat(v[3]).toFixed(2)}<br/>高: ${parseFloat(v[4]).toFixed(2)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
grid: { left: '3%', right: '3%', bottom: '15%', containLabel: true },
|
|
|
|
|
|
xAxis: { type: 'category', data: dates, axisLabel: { rotate: 45, fontSize: 10 }, splitLine: { show: false } },
|
|
|
|
|
|
yAxis: { type: 'value', scale: true, splitLine: { lineStyle: { type: 'dashed' } } },
|
|
|
|
|
|
dataZoom: [
|
|
|
|
|
|
{ type: 'inside', start: Math.max(0, 100 - Math.min(60, dates.length)), end: 100 },
|
|
|
|
|
|
{ type: 'slider', show: true, start: Math.max(0, 100 - Math.min(60, dates.length)), end: 100, height: 20, bottom: 0 }
|
|
|
|
|
|
],
|
|
|
|
|
|
series
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 交易详情弹窗 =====
|
|
|
|
|
|
function trSwitchKlinePeriod(period) {
|
|
|
|
|
|
trCurrentPeriod = period;
|
|
|
|
|
|
document.querySelectorAll('#tr-detail-modal .tr-kline-tab').forEach(btn => {
|
|
|
|
|
|
btn.classList.toggle('active', btn.dataset.period === period);
|
|
|
|
|
|
});
|
|
|
|
|
|
if (trCurrentPair) trLoadDetailKline(trCurrentPair);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trShowTradeDetail(pair) {
|
|
|
|
|
|
trCurrentPair = pair;
|
|
|
|
|
|
const modal = document.getElementById('tr-detail-modal');
|
|
|
|
|
|
const title = document.getElementById('tr-detail-title');
|
|
|
|
|
|
const info = document.getElementById('tr-detail-info');
|
|
|
|
|
|
const aiContent = document.getElementById('tr-detail-ai-content');
|
|
|
|
|
|
|
|
|
|
|
|
title.textContent = `${pair.symbol_name || pair.symbol} ${pair.direction === '多' ? '做多' : '做空'} 交易详情`;
|
|
|
|
|
|
aiContent.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
const pnlClass = pair.net_pnl >= 0 ? 'profit' : 'loss';
|
|
|
|
|
|
info.innerHTML = trOvItem('品种', pair.symbol_name || pair.variety, '')
|
|
|
|
|
|
+ trOvItem('方向', pair.direction === '多' ? '做多' : '做空', pair.direction === '多' ? 'profit' : 'loss')
|
|
|
|
|
|
+ trOvItem('开仓日期', pair.open_date || '-', '')
|
|
|
|
|
|
+ trOvItem('开仓时间', pair.open_time || '-', '')
|
|
|
|
|
|
+ trOvItem('开仓价', pair.open_price != null ? pair.open_price.toFixed(2) : '-', '')
|
|
|
|
|
|
+ trOvItem('平仓价', pair.close_price != null ? pair.close_price.toFixed(2) : '-', '')
|
|
|
|
|
|
+ trOvItem('手数', pair.volume || '-', '')
|
|
|
|
|
|
+ trOvItem('手续费', pair.commission != null ? pair.commission.toFixed(2) : '-', '')
|
|
|
|
|
|
+ trOvItem('净盈亏', (pair.net_pnl >= 0 ? '+' : '') + (pair.net_pnl || 0).toFixed(2), pnlClass);
|
|
|
|
|
|
|
|
|
|
|
|
modal.classList.add('show');
|
|
|
|
|
|
|
|
|
|
|
|
// 重置K线周期
|
|
|
|
|
|
trCurrentPeriod = 'daily';
|
|
|
|
|
|
document.querySelectorAll('#tr-detail-modal .tr-kline-tab').forEach(b => b.classList.toggle('active', b.dataset.period === 'daily'));
|
|
|
|
|
|
|
|
|
|
|
|
// 绑定按钮
|
|
|
|
|
|
document.getElementById('tr-detail-reload-kline').onclick = () => trLoadDetailKline(pair);
|
|
|
|
|
|
document.getElementById('tr-detail-ai-btn').onclick = () => trAnalyzeInDetail(pair);
|
|
|
|
|
|
|
|
|
|
|
|
await trLoadDetailKline(pair);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trLoadDetailKline(pair) {
|
|
|
|
|
|
const dom = document.getElementById('tr-detail-kline');
|
|
|
|
|
|
if (trDetailChart) { trDetailChart.dispose(); trDetailChart = null; }
|
|
|
|
|
|
trDetailChart = echarts.init(dom);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const openDate = pair.open_date ? new Date(pair.open_date) : new Date();
|
|
|
|
|
|
const closeDate = pair.close_date ? new Date(pair.close_date) : openDate;
|
|
|
|
|
|
const startDate = new Date(openDate); startDate.setDate(startDate.getDate() - 7);
|
|
|
|
|
|
const endDate = new Date(closeDate); endDate.setDate(endDate.getDate() + 7);
|
|
|
|
|
|
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/kline-with-trades/${pair.symbol}?start_date=${startDate.toISOString().slice(0,10)}&end_date=${endDate.toISOString().slice(0,10)}&period=${trCurrentPeriod}`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (!json.success || !json.data || !json.data.candles || !json.data.candles.length) {
|
|
|
|
|
|
dom.innerHTML = '<div class="tr-empty"><div class="tr-empty-text">暂无K线数据</div></div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
trRenderKlineChart(trDetailChart, json.data);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
dom.innerHTML = '<div class="tr-empty"><div class="tr-empty-text">加载K线失败</div></div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trCloseDetailModal() {
|
|
|
|
|
|
document.getElementById('tr-detail-modal').classList.remove('show');
|
|
|
|
|
|
if (trDetailChart) { trDetailChart.dispose(); trDetailChart = null; }
|
|
|
|
|
|
trCurrentPair = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trAnalyzeInDetail(pair) {
|
|
|
|
|
|
const aiContent = document.getElementById('tr-detail-ai-content');
|
|
|
|
|
|
aiContent.style.display = '';
|
|
|
|
|
|
aiContent.innerHTML = '<div class="tr-ai-title">🤖 AI 交易分析</div><div class="tr-ai-content" style="color:var(--text-tertiary)">正在分析中,请稍候...</div>';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/analyze-trade`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
|
symbol: pair.symbol,
|
|
|
|
|
|
open_date: pair.open_date,
|
|
|
|
|
|
open_time: pair.open_time,
|
|
|
|
|
|
close_date: pair.close_date,
|
|
|
|
|
|
close_time: pair.close_time,
|
|
|
|
|
|
direction: pair.direction,
|
|
|
|
|
|
open_price: pair.open_price,
|
|
|
|
|
|
close_price: pair.close_price,
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
aiContent.innerHTML = `<div class="tr-ai-title">🤖 AI 交易分析</div><div class="tr-ai-content">${json.data.analysis.replace(/\n/g, '<br/>')}</div>`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
aiContent.innerHTML = `<div class="tr-ai-content" style="color:var(--color-up)">${json.message || '分析失败'}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
aiContent.innerHTML = `<div class="tr-ai-content" style="color:var(--color-up)">分析请求失败: ${e.message}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 文件导入 =====
|
|
|
|
|
|
async function trHandleFileImport(e) {
|
|
|
|
|
|
const file = e.target.files[0];
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
e.target.value = '';
|
|
|
|
|
|
|
|
|
|
|
|
const btn = document.getElementById('tr-btn-import');
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
btn.querySelector('span:last-child').textContent = '导入中...';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
|
formData.append('file', file);
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/import`, { method: 'POST', body: formData });
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
trShowImportResult(json.message, json.skipped_details || []);
|
|
|
|
|
|
trLoadAllData();
|
|
|
|
|
|
trLoadDataOverview();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert('导入失败: ' + (json.message || '未知错误'));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
alert('导入失败: ' + err.message);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
btn.querySelector('span:last-child').textContent = '导入结算单';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 显示导入结果(含去重详情)
|
|
|
|
|
|
function trShowImportResult(message, skippedDetails) {
|
|
|
|
|
|
let html = `<div style="padding:16px;max-width:500px;">
|
|
|
|
|
|
<div style="font-size:14px;margin-bottom:12px;">${message}</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
if (skippedDetails && skippedDetails.length > 0) {
|
|
|
|
|
|
html += `<div style="margin-top:12px;border-top:1px solid #eee;padding-top:12px;">
|
|
|
|
|
|
<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:#666;">
|
|
|
|
|
|
被过滤的重复记录(${skippedDetails.length} 条):
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="max-height:200px;overflow-y:auto;font-size:12px;background:#f9f9f9;border-radius:4px;padding:8px;">`;
|
|
|
|
|
|
|
|
|
|
|
|
// 按类型分组显示
|
|
|
|
|
|
const grouped = {};
|
|
|
|
|
|
skippedDetails.forEach(d => {
|
|
|
|
|
|
const key = d.type || '未知';
|
|
|
|
|
|
if (!grouped[key]) grouped[key] = [];
|
|
|
|
|
|
grouped[key].push(d);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
for (const [type, items] of Object.entries(grouped)) {
|
|
|
|
|
|
html += `<div style="margin-bottom:6px;"><strong>${type}</strong>(${items.length} 条):</div>`;
|
|
|
|
|
|
items.slice(0, 10).forEach(d => {
|
|
|
|
|
|
html += `<div style="padding:2px 0;color:#555;">• ${d.variety} ${d.date} ${d.time} @ ${d.price}</div>`;
|
|
|
|
|
|
});
|
|
|
|
|
|
if (items.length > 10) {
|
|
|
|
|
|
html += `<div style="color:#999;">... 还有 ${items.length - 10} 条</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
html += `</div></div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
// 使用自定义弹窗
|
|
|
|
|
|
const modal = document.createElement('div');
|
|
|
|
|
|
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;';
|
|
|
|
|
|
modal.innerHTML = `<div style="background:white;border-radius:8px;max-width:600px;max-height:80vh;overflow-y:auto;">
|
|
|
|
|
|
${html}
|
|
|
|
|
|
<div style="padding:12px 16px;border-top:1px solid #eee;text-align:right;">
|
|
|
|
|
|
<button onclick="this.closest('div[style*=fixed]').remove()" style="padding:6px 16px;background:#007aff;color:white;border:none;border-radius:4px;cursor:pointer;">确定</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trHandleBatchImport(e) {
|
|
|
|
|
|
const files = e.target.files;
|
|
|
|
|
|
if (!files || files.length === 0) return;
|
|
|
|
|
|
e.target.value = '';
|
|
|
|
|
|
|
|
|
|
|
|
const btn = document.getElementById('tr-btn-batch-import');
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
btn.querySelector('span:last-child').textContent = '导入中...';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
|
for (let i = 0; i < files.length; i++) formData.append('files', files[i]);
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/batch-import`, { method: 'POST', body: formData });
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
trShowBatchImportResult(json);
|
|
|
|
|
|
if (json.data.total_futures > 0 || json.data.total_options > 0) {
|
|
|
|
|
|
trLoadAllData();
|
|
|
|
|
|
trLoadDataOverview();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert('批量导入失败: ' + (json.message || '未知错误'));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
alert('批量导入失败: ' + err.message);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
btn.querySelector('span:last-child').textContent = '批量导入';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trShowBatchImportResult(json) {
|
|
|
|
|
|
const modal = document.getElementById('tr-batch-import-modal');
|
|
|
|
|
|
const summaryEl = document.getElementById('tr-batch-import-summary');
|
|
|
|
|
|
const detailsEl = document.getElementById('tr-batch-import-details');
|
|
|
|
|
|
const data = json.data;
|
|
|
|
|
|
summaryEl.textContent = json.message;
|
|
|
|
|
|
detailsEl.innerHTML = data.details.map(d => {
|
|
|
|
|
|
let icon = d.success ? (d.new_count > 0 ? '✅' : '⚠️') : '❌';
|
|
|
|
|
|
let skippedHtml = '';
|
|
|
|
|
|
if (d.skipped_details && d.skipped_details.length > 0) {
|
|
|
|
|
|
const grouped = {};
|
|
|
|
|
|
d.skipped_details.forEach(item => {
|
|
|
|
|
|
const key = item.type || '未知';
|
|
|
|
|
|
if (!grouped[key]) grouped[key] = [];
|
|
|
|
|
|
grouped[key].push(item);
|
|
|
|
|
|
});
|
|
|
|
|
|
skippedHtml = `<div style="margin-top:6px;font-size:11px;color:#888;max-height:80px;overflow-y:auto;">`;
|
|
|
|
|
|
for (const [type, items] of Object.entries(grouped)) {
|
|
|
|
|
|
skippedHtml += `<div><strong>${type}</strong> ${items.length}条: `;
|
|
|
|
|
|
skippedHtml += items.slice(0, 5).map(item => `${item.variety}@${item.price}`).join(', ');
|
|
|
|
|
|
if (items.length > 5) skippedHtml += ` ...`;
|
|
|
|
|
|
skippedHtml += `</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
skippedHtml += `</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `<div style="padding:10px 12px;margin-bottom:8px;border-radius:6px;background:#F5F5F7;border-left:3px solid ${d.success ? '#34C759' : '#FF3B30'};">
|
|
|
|
|
|
<div style="font-weight:600;font-size:13px;">${icon} ${d.filename}</div>
|
|
|
|
|
|
<div style="font-size:12px;color:var(--text-tertiary);margin-top:4px;">${d.message}</div>
|
|
|
|
|
|
${skippedHtml}
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
modal.classList.add('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trCloseBatchImportModal() {
|
|
|
|
|
|
document.getElementById('tr-batch-import-modal').classList.remove('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 批次管理 =====
|
|
|
|
|
|
async function trShowBatchModal() {
|
|
|
|
|
|
const modal = document.getElementById('tr-batch-modal');
|
|
|
|
|
|
modal.classList.add('show');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/batches`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) trRenderBatchList(json.data);
|
|
|
|
|
|
} catch (e) { console.error('加载批次失败', e); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trRenderBatchList(batches) {
|
|
|
|
|
|
const list = document.getElementById('tr-batch-list');
|
|
|
|
|
|
if (!batches || !batches.length) {
|
|
|
|
|
|
list.innerHTML = '<div class="tr-empty" style="min-height:100px;"><div class="tr-empty-text">暂无导入记录</div></div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
list.innerHTML = batches.map(b => `
|
|
|
|
|
|
<div class="tr-batch-item">
|
|
|
|
|
|
<div class="tr-batch-info">
|
|
|
|
|
|
<div class="tr-batch-file">${b.source_file}</div>
|
|
|
|
|
|
<div class="tr-batch-meta">期货 ${b.futures_count} 条 | 期权 ${b.options_count} 条 | ${b.trade_dates || '未知日期'} | ${b.created_at}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button class="tr-batch-delete" onclick="trDeleteBatch('${b.batch_id}')">删除</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function trDeleteBatch(batchId) {
|
|
|
|
|
|
if (!confirm('确认删除该批次的所有交易记录?')) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/records/${batchId}`, { method: 'DELETE' });
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
alert(json.message);
|
|
|
|
|
|
trShowBatchModal();
|
|
|
|
|
|
trLoadAllData();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) { alert('删除失败: ' + e.message); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trCloseBatchModal() {
|
|
|
|
|
|
document.getElementById('tr-batch-modal').classList.remove('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 删除当日 =====
|
|
|
|
|
|
async function trDeleteByDate() {
|
|
|
|
|
|
const tradeDate = document.getElementById('tr-delete-date-input').value;
|
|
|
|
|
|
if (!tradeDate) { alert('请先选择要删除的日期'); return; }
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const queryRes = await fetch(`${TR_API_BASE}/records?start_date=${tradeDate}&end_date=${tradeDate}&page=1&page_size=1`);
|
|
|
|
|
|
const queryJson = await queryRes.json();
|
|
|
|
|
|
const recordCount = queryJson.success ? queryJson.data.total : 0;
|
|
|
|
|
|
if (recordCount === 0) { alert(`${tradeDate} 没有交易记录`); return; }
|
|
|
|
|
|
|
|
|
|
|
|
if (!confirm(`⚠️ 确定删除 ${tradeDate} 的 ${recordCount} 条交易记录吗?此操作不可恢复!`)) return;
|
|
|
|
|
|
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/records-by-date/${tradeDate}`, { method: 'DELETE' });
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success) {
|
|
|
|
|
|
alert(`✅ ${json.message}`);
|
|
|
|
|
|
trLoadAllData();
|
|
|
|
|
|
trLoadDataOverview();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert(json.message || '删除失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) { alert('删除失败: ' + e.message); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== 数据概览 =====
|
|
|
|
|
|
async function trLoadDataOverview() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 获取总记录数和日期范围
|
|
|
|
|
|
const res = await fetch(`${TR_API_BASE}/statistics`);
|
|
|
|
|
|
const json = await res.json();
|
|
|
|
|
|
if (json.success && json.data) {
|
|
|
|
|
|
document.getElementById('tr-total-records').textContent = json.data.total_trades || 0;
|
|
|
|
|
|
document.getElementById('tr-total-batches').textContent = json.data.trading_days || 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取批次信息
|
|
|
|
|
|
const batchRes = await fetch(`${TR_API_BASE}/batches`);
|
|
|
|
|
|
const batchJson = await batchRes.json();
|
|
|
|
|
|
if (batchJson.success && batchJson.data && batchJson.data.length > 0) {
|
|
|
|
|
|
document.getElementById('tr-total-batches').textContent = batchJson.data.length;
|
|
|
|
|
|
// 获取最早和最新日期
|
|
|
|
|
|
const dates = batchJson.data.map(b => b.trade_dates).filter(d => d);
|
|
|
|
|
|
if (dates.length > 0) {
|
|
|
|
|
|
const allDates = [];
|
|
|
|
|
|
dates.forEach(d => {
|
|
|
|
|
|
const parts = d.split(' ~ ');
|
|
|
|
|
|
parts.forEach(p => {
|
|
|
|
|
|
const date = p.trim();
|
|
|
|
|
|
if (date) allDates.push(date);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
if (allDates.length > 0) {
|
|
|
|
|
|
allDates.sort();
|
|
|
|
|
|
document.getElementById('tr-earliest-date').textContent = allDates[0];
|
|
|
|
|
|
document.getElementById('tr-latest-date').textContent = allDates[allDates.length - 1];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('加载数据概览失败:', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
|
|
// 根据AI分析结果统计趋势(字符串包含判断)
|
|
|
|
|
|
const upCount = data.filter(d =>
|
|
|
|
|
|
d.suggestion?.includes('做多') || d.suggestion?.includes('试多')
|
|
|
|
|
|
).length;
|
|
|
|
|
|
|
|
|
|
|
|
const downCount = data.filter(d =>
|
|
|
|
|
|
d.suggestion?.includes('做空') || d.suggestion?.includes('试空')
|
|
|
|
|
|
).length;
|
|
|
|
|
|
|
|
|
|
|
|
const neutralCount = data.filter(d =>
|
|
|
|
|
|
d.suggestion?.includes('观望')
|
|
|
|
|
|
).length;
|
|
|
|
|
|
|
|
|
|
|
|
// 安全检查:确保元素存在再设置textContent
|
|
|
|
|
|
const totalCountEl = document.getElementById('total-count');
|
|
|
|
|
|
if (totalCountEl) totalCountEl.textContent = total;
|
|
|
|
|
|
|
|
|
|
|
|
const upCountEl = document.getElementById('up-count');
|
|
|
|
|
|
if (upCountEl) upCountEl.textContent = upCount;
|
|
|
|
|
|
|
|
|
|
|
|
const downCountEl = document.getElementById('down-count');
|
|
|
|
|
|
if (downCountEl) downCountEl.textContent = downCount;
|
|
|
|
|
|
|
|
|
|
|
|
const neutralCountEl = document.getElementById('neutral-count');
|
|
|
|
|
|
if (neutralCountEl) neutralCountEl.textContent = neutralCount;
|
|
|
|
|
|
|
|
|
|
|
|
const countAllEl = document.getElementById('count-all');
|
|
|
|
|
|
if (countAllEl) countAllEl.textContent = total;
|
|
|
|
|
|
|
|
|
|
|
|
const countWatchedEl = document.getElementById('count-watched');
|
|
|
|
|
|
if (countWatchedEl) countWatchedEl.textContent = watchedSymbols.length;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function filterByTrend(trend) {
|
|
|
|
|
|
console.log('按趋势筛选:', trend);
|
|
|
|
|
|
let filtered = allFuturesData;
|
|
|
|
|
|
|
|
|
|
|
|
if (trend === 'up') {
|
|
|
|
|
|
filtered = allFuturesData.filter(d =>
|
|
|
|
|
|
d.suggestion?.includes('做多') || d.suggestion?.includes('试多')
|
|
|
|
|
|
);
|
|
|
|
|
|
} else if (trend === 'down') {
|
|
|
|
|
|
filtered = allFuturesData.filter(d =>
|
|
|
|
|
|
d.suggestion?.includes('做空') || d.suggestion?.includes('试空')
|
|
|
|
|
|
);
|
|
|
|
|
|
} else if (trend === 'neutral') {
|
|
|
|
|
|
filtered = allFuturesData.filter(d =>
|
|
|
|
|
|
d.suggestion?.includes('观望')
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renderFuturesGrid(filtered);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function filterFuturesList(keyword) {
|
|
|
|
|
|
keyword = keyword.toLowerCase();
|
|
|
|
|
|
const activePill = document.querySelector('.pill.active');
|
|
|
|
|
|
const category = activePill ? activePill.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) {
|
|
|
|
|
|
const nameEl = document.getElementById('detail-name');
|
|
|
|
|
|
if (nameEl) nameEl.textContent = data.name || '--';
|
|
|
|
|
|
|
|
|
|
|
|
const symbolEl = document.getElementById('detail-symbol');
|
|
|
|
|
|
if (symbolEl) symbolEl.textContent = data.symbol || '--';
|
|
|
|
|
|
|
|
|
|
|
|
const priceEl = document.getElementById('detail-price');
|
|
|
|
|
|
if (priceEl) {
|
|
|
|
|
|
priceEl.textContent = '¥' + formatNumber(data.price);
|
|
|
|
|
|
priceEl.className = 'price-value ' + (data.change >= 0 ? 'up' : 'down');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const changeEl = document.getElementById('detail-change');
|
|
|
|
|
|
if (changeEl) {
|
|
|
|
|
|
changeEl.className = 'price-change ' + (data.change >= 0 ? 'up' : 'down');
|
|
|
|
|
|
changeEl.innerHTML = `${data.change >= 0 ? '↑' : '↓'} ${data.change >= 0 ? '+' : ''}${formatNumber(data.change)} (${data.changePct >= 0 ? '+' : ''}${data.changePct.toFixed(2)}%)`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const openEl = document.getElementById('detail-open');
|
|
|
|
|
|
if (openEl) openEl.textContent = formatNumber(data.open);
|
|
|
|
|
|
|
|
|
|
|
|
const highEl = document.getElementById('detail-high');
|
|
|
|
|
|
if (highEl) highEl.textContent = formatNumber(data.high);
|
|
|
|
|
|
|
|
|
|
|
|
const lowEl = document.getElementById('detail-low');
|
|
|
|
|
|
if (lowEl) lowEl.textContent = formatNumber(data.low);
|
|
|
|
|
|
|
|
|
|
|
|
const volumeEl = document.getElementById('detail-volume');
|
|
|
|
|
|
if (volumeEl) volumeEl.textContent = formatNumber(data.volume);
|
|
|
|
|
|
|
|
|
|
|
|
// 注意: detail-r1 和 detail-s1 在新HTML中不存在,已移除
|
|
|
|
|
|
|
|
|
|
|
|
// 注意: suggestion-badge, suggestion-reason, entry-price, target-price, stop-loss, risk-level 在新HTML中不存在,已移除
|
|
|
|
|
|
|
|
|
|
|
|
if (data.macd) {
|
|
|
|
|
|
const macdSignalEl = document.getElementById('macd-signal');
|
|
|
|
|
|
if (macdSignalEl) macdSignalEl.textContent = data.macd.signal;
|
|
|
|
|
|
const macdDetailEl = document.getElementById('macd-detail');
|
|
|
|
|
|
if (macdDetailEl) macdDetailEl.textContent = data.macd.detail;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (data.rsi) {
|
|
|
|
|
|
const rsiValueEl = document.getElementById('rsi-value');
|
|
|
|
|
|
if (rsiValueEl) rsiValueEl.textContent = data.rsi.value;
|
|
|
|
|
|
const rsiStatusEl = document.getElementById('rsi-status');
|
|
|
|
|
|
if (rsiStatusEl) rsiStatusEl.textContent = data.rsi.status;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (data.boll) {
|
|
|
|
|
|
const bollSignalEl = document.getElementById('boll-signal');
|
|
|
|
|
|
if (bollSignalEl) bollSignalEl.textContent = data.boll.signal;
|
|
|
|
|
|
const bollDetailEl = document.getElementById('boll-detail');
|
|
|
|
|
|
if (bollDetailEl) bollDetailEl.textContent = data.boll.detail;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (data.kdj) {
|
|
|
|
|
|
const kdjSignalEl = document.getElementById('kdj-signal');
|
|
|
|
|
|
if (kdjSignalEl) kdjSignalEl.textContent = data.kdj.signal;
|
|
|
|
|
|
const kdjDetailEl = document.getElementById('kdj-detail');
|
|
|
|
|
|
if (kdjDetailEl) kdjDetailEl.textContent = data.kdj.detail;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.resistances) {
|
|
|
|
|
|
for (let i = 0; i < 2; i++) {
|
|
|
|
|
|
const el = document.getElementById(`resistance-${i + 1}`);
|
|
|
|
|
|
if (el) {
|
|
|
|
|
|
el.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.textContent = formatNumber(data.supports[i]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.pivotPoint) {
|
|
|
|
|
|
const ppEl = document.getElementById('pivot-point');
|
|
|
|
|
|
if (ppEl) ppEl.textContent = formatNumber(data.pivotPoint);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (data.periodConsistency) {
|
|
|
|
|
|
const container = document.getElementById('period-trends');
|
|
|
|
|
|
if (container) {
|
|
|
|
|
|
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('');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 注意: trend-score 和 score-fill 在新HTML中不存在,已移除
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadHistoryList(symbol) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/ai-analysis/${symbol}/history?limit=20`);
|
|
|
|
|
|
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>';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadAIAnalysis() {
|
|
|
|
|
|
if (!currentSymbol) return;
|
|
|
|
|
|
|
|
|
|
|
|
const content = document.getElementById('ai-analysis-content');
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log(`加载合约 ${currentSymbol} 的AI分析...`);
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/ai-analysis/${currentSymbol}`);
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`合约 ${currentSymbol} AI分析响应:`, data);
|
|
|
|
|
|
|
|
|
|
|
|
if (data.success && data.data) {
|
|
|
|
|
|
console.log(`合约 ${currentSymbol} 分析数据 - symbol:`, data.data.symbol);
|
|
|
|
|
|
currentAIAnalysis = data.data;
|
|
|
|
|
|
displayAIAnalysisResult(data.data);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log(`合约 ${currentSymbol} 无分析结果`);
|
|
|
|
|
|
content.innerHTML = `
|
|
|
|
|
|
<div style="text-align: center; padding: 28px; color: var(--text-tertiary);">
|
|
|
|
|
|
<p>点击"智能分析"按钮获取AI分析结果</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(`加载合约 ${currentSymbol} AI分析失败:`, error);
|
|
|
|
|
|
content.innerHTML = `
|
|
|
|
|
|
<div style="text-align: center; padding: 28px; color: var(--text-tertiary);">
|
|
|
|
|
|
<p>点击"智能分析"按钮获取AI分析结果</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderHistoryList(records) {
|
|
|
|
|
|
const container = document.getElementById('history-list');
|
|
|
|
|
|
if (!records || records.length === 0) {
|
|
|
|
|
|
container.innerHTML = '<div class="empty-state">暂无历史记录</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('渲染历史记录,记录数量:', records.length);
|
|
|
|
|
|
console.log('历史记录合约分布:', records.map(r => r.symbol));
|
|
|
|
|
|
|
|
|
|
|
|
container.innerHTML = records.map(record => {
|
|
|
|
|
|
const analysisData = record.analysis_data || {};
|
|
|
|
|
|
const suggestion = analysisData.trading_suggestion || {};
|
|
|
|
|
|
const timeStr = record.analysis_time ? record.analysis_time.replace('T', ' ').substring(0, 16) : '--';
|
|
|
|
|
|
const summary = analysisData.summary || '--';
|
|
|
|
|
|
const direction = suggestion.direction || '--';
|
|
|
|
|
|
const confidence = suggestion.confidence || 0;
|
|
|
|
|
|
|
|
|
|
|
|
console.log(`历史记录 ID:${record.id} 合约:${record.symbol}`);
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="history-item" onclick="showAIHistoryDetail(${record.id})">
|
|
|
|
|
|
<div class="history-item-left">
|
|
|
|
|
|
<span class="history-time">${timeStr}</span>
|
|
|
|
|
|
<span class="history-suggestion">${summary.substring(0, 30)}${summary.length > 30 ? '...' : ''}</span>
|
|
|
|
|
|
<span class="history-score">方向: ${direction} | 置信度: ${confidence}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="history-item-right">
|
|
|
|
|
|
<button class="history-detail-btn" onclick="event.stopPropagation(); showAIHistoryDetail(${record.id})">
|
|
|
|
|
|
<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) {
|
|
|
|
|
|
if (!symbol) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/kline/${symbol}?period=${period}`);
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (data.success && data.data) {
|
|
|
|
|
|
renderKlineChart(data.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载K线数据失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function showAIHistoryDetail(recordId) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/ai-analysis/history/${recordId}`);
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (data.success && data.data) {
|
|
|
|
|
|
const record = data.data;
|
|
|
|
|
|
const result = record.analysis_data;
|
|
|
|
|
|
const timestamp = new Date(record.analysis_time).toLocaleString('zh-CN');
|
|
|
|
|
|
|
|
|
|
|
|
// 构建弹窗内容
|
|
|
|
|
|
const modalBody = document.getElementById('ai-analysis-modal-body');
|
|
|
|
|
|
|
|
|
|
|
|
const direction = result.trading_suggestion?.direction || '观望';
|
|
|
|
|
|
const directionClass = direction === '做多' ? 'long' : direction === '做空' ? 'short' : 'neutral';
|
|
|
|
|
|
const directionIcon = direction === '做多' ? 'fa-arrow-up' : direction === '做空' ? 'fa-arrow-down' : 'fa-arrows-left-right';
|
|
|
|
|
|
const confidence = result.trading_suggestion?.confidence || 0;
|
|
|
|
|
|
|
|
|
|
|
|
modalBody.innerHTML = `
|
|
|
|
|
|
<div class="ai-history-detail">
|
|
|
|
|
|
<div class="detail-header">
|
|
|
|
|
|
<h4><i class="fas fa-file-alt"></i> ${record.symbol} AI分析报告</h4>
|
|
|
|
|
|
<span class="detail-time"><i class="fas fa-clock"></i> ${timestamp}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="detail-summary">
|
|
|
|
|
|
<i class="fas fa-quote-left"></i>
|
|
|
|
|
|
<p>${result.summary || '暂无总结'}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- AI交易建议卡片已隐藏 -->
|
|
|
|
|
|
<!-- <div class="detail-suggestion">
|
|
|
|
|
|
<div class="suggestion-card ${directionClass}">
|
|
|
|
|
|
<i class="fas ${directionIcon}"></i>
|
|
|
|
|
|
<span class="suggestion-text">${direction}</span>
|
|
|
|
|
|
<span class="confidence-text">置信度: ${confidence}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div> -->
|
|
|
|
|
|
|
|
|
|
|
|
${result.four_dimensional ? `
|
|
|
|
|
|
<div class="detail-section">
|
|
|
|
|
|
<h5><i class="fas fa-brain"></i> AI思维分析</h5>
|
|
|
|
|
|
<table class="four-d-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>周期</th>
|
|
|
|
|
|
<th>MACD趋势</th>
|
|
|
|
|
|
<th>成交量</th>
|
|
|
|
|
|
<th>KDJ状态</th>
|
|
|
|
|
|
<th>结论</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
${(() => {
|
|
|
|
|
|
const periodNames = { '60min': '60分钟', '30min': '30分钟', '15min': '15分钟', '5min': '5分钟' };
|
|
|
|
|
|
const periodOrder = ['60min', '30min', '15min', '5min'];
|
|
|
|
|
|
const sortedEntries = Object.entries(result.four_dimensional).sort((a, b) => {
|
|
|
|
|
|
return periodOrder.indexOf(a[0]) - periodOrder.indexOf(b[0]);
|
|
|
|
|
|
});
|
|
|
|
|
|
return sortedEntries.map(([period, d]) => `
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><strong>${periodNames[period] || period}</strong></td>
|
|
|
|
|
|
<td>${d.macd?.trend || '--'}</td>
|
|
|
|
|
|
<td>${d.volume?.status || '--'}</td>
|
|
|
|
|
|
<td>${d.kdj?.status || '--'}</td>
|
|
|
|
|
|
<td>${d.conclusion || '--'}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
})()}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
` : ''}
|
|
|
|
|
|
|
|
|
|
|
|
<div class="detail-metrics">
|
|
|
|
|
|
<div class="metric-card">
|
|
|
|
|
|
<span class="metric-label">入场区间</span>
|
|
|
|
|
|
<span class="metric-value">${result.trading_suggestion?.entry_range?.min || '--'}-${result.trading_suggestion?.entry_range?.max || '--'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="metric-card">
|
|
|
|
|
|
<span class="metric-label">止损位</span>
|
|
|
|
|
|
<span class="metric-value down">${result.trading_suggestion?.stop_loss || '--'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="metric-card">
|
|
|
|
|
|
<span class="metric-label">建议仓位</span>
|
|
|
|
|
|
<span class="metric-value">${result.trading_suggestion?.position_size || '--'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="metric-card">
|
|
|
|
|
|
<span class="metric-label">纪律评分</span>
|
|
|
|
|
|
<span class="metric-value">${result.discipline_score?.total || '--'}/${result.discipline_score?.max || '11'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
${result.kdj_diagnosis ? `
|
|
|
|
|
|
<div class="detail-section">
|
|
|
|
|
|
<h5><i class="fas fa-stethoscope"></i> KDJ诊断</h5>
|
|
|
|
|
|
<div class="kdj-diagnosis-grid">
|
|
|
|
|
|
<div class="kdj-item">
|
|
|
|
|
|
<span class="kdj-label">当前状态</span>
|
|
|
|
|
|
<span class="kdj-value">${result.kdj_diagnosis.current_status || '--'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="kdj-item">
|
|
|
|
|
|
<span class="kdj-label">背离</span>
|
|
|
|
|
|
<span class="kdj-value">${result.kdj_diagnosis.divergence || '--'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="kdj-item">
|
|
|
|
|
|
<span class="kdj-label">钝化</span>
|
|
|
|
|
|
<span class="kdj-value">${result.kdj_diagnosis.paralysis || '--'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="kdj-item" style="grid-column: 1 / -1;">
|
|
|
|
|
|
<span class="kdj-label">建议</span>
|
|
|
|
|
|
<span class="kdj-value">${result.kdj_diagnosis.recommendation || '--'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
` : ''}
|
|
|
|
|
|
|
|
|
|
|
|
${result.pivot_points ? `
|
|
|
|
|
|
<div class="detail-section">
|
|
|
|
|
|
<h5><i class="fas fa-crosshairs"></i> 关键点位</h5>
|
|
|
|
|
|
<div class="pivot-points-grid">
|
|
|
|
|
|
<div class="pivot-item resistance">
|
|
|
|
|
|
<span>R2</span><strong>${result.pivot_points.r2 || '--'}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="pivot-item resistance">
|
|
|
|
|
|
<span>R1</span><strong>${result.pivot_points.r1 || '--'}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="pivot-item center">
|
|
|
|
|
|
<span>PP</span><strong>${result.pivot_points.pp || '--'}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="pivot-item support">
|
|
|
|
|
|
<span>S1</span><strong>${result.pivot_points.s1 || '--'}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="pivot-item support">
|
|
|
|
|
|
<span>S2</span><strong>${result.pivot_points.s2 || '--'}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
` : ''}
|
|
|
|
|
|
|
|
|
|
|
|
${result.risk_warnings && result.risk_warnings.length > 0 ? `
|
|
|
|
|
|
<div class="detail-section">
|
|
|
|
|
|
<h5><i class="fas fa-exclamation-triangle"></i> 风险提示</h5>
|
|
|
|
|
|
<ul class="warning-list">
|
|
|
|
|
|
${result.risk_warnings.map(w => `<li>${w}</li>`).join('')}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
// 显示弹窗
|
|
|
|
|
|
document.getElementById('ai-analysis-modal').classList.add('active');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showToast('error', '加载失败', data.error || '记录不存在');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('加载历史记录详情失败:', error);
|
|
|
|
|
|
showToast('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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== Toast 提示 ====================
|
|
|
|
|
|
|
|
|
|
|
|
function showToast(type, title, message, duration = 3000) {
|
|
|
|
|
|
const container = document.getElementById('toast-container');
|
|
|
|
|
|
|
|
|
|
|
|
const iconMap = {
|
|
|
|
|
|
success: 'fas fa-check',
|
|
|
|
|
|
info: 'fas fa-info',
|
|
|
|
|
|
warning: 'fas fa-exclamation',
|
|
|
|
|
|
error: 'fas fa-times'
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const toast = document.createElement('div');
|
|
|
|
|
|
toast.className = `toast ${type}`;
|
|
|
|
|
|
toast.innerHTML = `
|
|
|
|
|
|
<div class="toast-icon"><i class="${iconMap[type]}"></i></div>
|
|
|
|
|
|
<div class="toast-content">
|
|
|
|
|
|
<div class="toast-title">${title}</div>
|
|
|
|
|
|
<div class="toast-message">${message}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
container.appendChild(toast);
|
|
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
toast.classList.add('removing');
|
|
|
|
|
|
setTimeout(() => toast.remove(), 300);
|
|
|
|
|
|
}, duration);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 数据刷新功能 ====================
|
|
|
|
|
|
|
|
|
|
|
|
let isRefreshing = false;
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshAllSymbols() {
|
|
|
|
|
|
if (isRefreshing) return;
|
|
|
|
|
|
|
|
|
|
|
|
const btn = document.getElementById('refresh-all-btn');
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
btn.classList.add('spinning');
|
|
|
|
|
|
isRefreshing = true;
|
|
|
|
|
|
|
|
|
|
|
|
showToast('info', '开始刷新', '正在同步所有品种数据...');
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/refresh-all`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (data.success) {
|
|
|
|
|
|
pollRefreshStatus();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showToast('error', '刷新失败', data.message || '请稍后重试');
|
|
|
|
|
|
resetRefreshButton(btn);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
showToast('error', '刷新失败', '网络错误,请稍后重试');
|
|
|
|
|
|
resetRefreshButton(btn);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function pollRefreshStatus() {
|
|
|
|
|
|
const btn = document.getElementById('refresh-all-btn');
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/refresh-status`);
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (data.success && data.data) {
|
|
|
|
|
|
const status = data.data;
|
|
|
|
|
|
|
|
|
|
|
|
if (!status.running) {
|
|
|
|
|
|
resetRefreshButton(btn);
|
|
|
|
|
|
|
|
|
|
|
|
await loadFuturesList();
|
|
|
|
|
|
|
|
|
|
|
|
if (currentSymbol) {
|
|
|
|
|
|
await loadFuturesDetail(currentSymbol);
|
|
|
|
|
|
await loadKlineData(currentSymbol, currentPeriod);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
showToast('success', '刷新完成', `已同步 ${status.total} 个品种数据`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const progress = status.progress || 0;
|
|
|
|
|
|
const total = status.total || 0;
|
|
|
|
|
|
const percentage = total > 0 ? Math.round((progress / total) * 100) : 0;
|
|
|
|
|
|
btn.innerHTML = `<i class="fas fa-sync-alt fa-spin"></i><span>刷新中 ${progress}/${total} (${percentage}%)</span>`;
|
|
|
|
|
|
showToast('info', '刷新进度', `${status.message || `正在刷新 ${progress}/${total}`}`);
|
|
|
|
|
|
setTimeout(pollRefreshStatus, 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
resetRefreshButton(btn);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetRefreshButton(btn) {
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
btn.classList.remove('spinning');
|
|
|
|
|
|
btn.innerHTML = '<i class="fas fa-sync-alt"></i><span>刷新全部</span>';
|
|
|
|
|
|
isRefreshing = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshSingleSymbol(symbol, btnElement = null) {
|
|
|
|
|
|
// 优先使用传入的按钮元素,其次尝试从事件获取,最后使用详情页按钮
|
|
|
|
|
|
let btn = btnElement;
|
|
|
|
|
|
if (!btn) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const evt = event;
|
|
|
|
|
|
if (evt && evt.target) {
|
|
|
|
|
|
const cardBtn = evt.target.closest('.card-refresh-btn');
|
|
|
|
|
|
if (cardBtn) {
|
|
|
|
|
|
btn = cardBtn;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// event 不存在时忽略
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!btn) {
|
|
|
|
|
|
btn = document.getElementById('refresh-symbol-btn');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!btn) {
|
|
|
|
|
|
showToast('error', '刷新失败', '无法找到刷新按钮');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const originalContent = btn.innerHTML;
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
|
|
|
|
|
|
|
|
|
|
|
showToast('info', '检查数据', `正在检查 ${symbol} 数据新鲜度...`);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 刷新K线数据
|
|
|
|
|
|
await loadKlineData(currentSymbol, currentPeriod);
|
|
|
|
|
|
showToast('success', '刷新成功', `${symbol} 数据已更新`);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
showToast('error', '刷新失败', error.message || '网络错误');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
btn.innerHTML = originalContent;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function analyzeSingleSymbol(symbol, name, btnElement = null) {
|
|
|
|
|
|
let btn = btnElement;
|
|
|
|
|
|
if (!btn) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const evt = event;
|
|
|
|
|
|
if (evt && evt.target) {
|
|
|
|
|
|
btn = evt.target.closest('.card-ai-btn');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!btn) {
|
|
|
|
|
|
showToast('error', '分析失败', '无法找到分析按钮');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const originalContent = btn.innerHTML;
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
btn.classList.add('analyzing');
|
|
|
|
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
|
|
|
|
|
|
|
|
|
|
|
showToast('info', 'AI分析中', `正在分析 ${symbol}...`);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/ai-analysis/${symbol}?force_refresh=false`);
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (data.success && data.data) {
|
|
|
|
|
|
const result = data.data.result;
|
|
|
|
|
|
syncAIToSymbolCard(symbol, result);
|
|
|
|
|
|
showToast('success', '分析完成', `${symbol} AI分析已更新`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showToast('warning', '分析失败', data.error || 'AI分析失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('AI分析失败:', error);
|
|
|
|
|
|
showToast('error', '分析失败', '网络错误,请稍后重试');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
btn.classList.remove('analyzing');
|
|
|
|
|
|
btn.innerHTML = originalContent;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function analyzeAllSymbols() {
|
|
|
|
|
|
const allBtn = document.getElementById('ai-analyze-all-btn');
|
|
|
|
|
|
if (!allBtn) return;
|
|
|
|
|
|
|
|
|
|
|
|
const originalContent = allBtn.innerHTML;
|
|
|
|
|
|
allBtn.disabled = true;
|
|
|
|
|
|
allBtn.classList.add('spinning');
|
|
|
|
|
|
|
|
|
|
|
|
showToast('info', '批量分析', '开始对所有合约进行AI分析...');
|
|
|
|
|
|
|
|
|
|
|
|
const symbols = allFuturesData.map(item => item.symbol);
|
|
|
|
|
|
const totalSymbols = symbols.length;
|
|
|
|
|
|
let completedCount = 0;
|
|
|
|
|
|
let successCount = 0;
|
|
|
|
|
|
|
|
|
|
|
|
const batchSize = 3;
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < symbols.length; i += batchSize) {
|
|
|
|
|
|
const batch = symbols.slice(i, i + batchSize);
|
|
|
|
|
|
const promises = batch.map(async (symbol) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/ai-analysis/${symbol}?force_refresh=false`);
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (data.success && data.data) {
|
|
|
|
|
|
syncAIToSymbolCard(symbol, data.data.result);
|
|
|
|
|
|
return { symbol, success: true };
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return { symbol, success: false, error: data.error };
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
return { symbol, success: false, error: error.message };
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const results = await Promise.all(promises);
|
|
|
|
|
|
const batchSuccessCount = results.filter(r => r.success).length;
|
|
|
|
|
|
completedCount += batch.length;
|
|
|
|
|
|
successCount += batchSuccessCount;
|
|
|
|
|
|
|
|
|
|
|
|
const percentage = Math.round((completedCount / totalSymbols) * 100);
|
|
|
|
|
|
allBtn.innerHTML = `<i class="fas fa-brain fa-spin"></i> 分析中 ${completedCount}/${totalSymbols} (${percentage}%)`;
|
|
|
|
|
|
showToast('info', '分析进度', `已完成 ${completedCount}/${totalSymbols},成功 ${successCount} 个 (${percentage}%)`);
|
|
|
|
|
|
|
|
|
|
|
|
if (i + batchSize < symbols.length) {
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
allBtn.disabled = false;
|
|
|
|
|
|
allBtn.classList.remove('spinning');
|
|
|
|
|
|
allBtn.innerHTML = originalContent;
|
|
|
|
|
|
showToast('success', '批量分析完成', `共分析 ${totalSymbols} 个合约,成功 ${successCount} 个`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function syncAIToSymbolCard(symbol, result) {
|
|
|
|
|
|
// 标记该合约已有AI分析数据
|
|
|
|
|
|
const item = allFuturesData.find(d => d.symbol === symbol);
|
|
|
|
|
|
if (item) {
|
|
|
|
|
|
item.hasAIAnalysis = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 移除无AI数据的样式
|
|
|
|
|
|
const card = document.querySelector(`.futures-card[onclick="showDetailView('${symbol}')"]`);
|
|
|
|
|
|
if (card) {
|
|
|
|
|
|
card.classList.remove('no-ai-data');
|
|
|
|
|
|
const hint = card.querySelector('.ai-hint');
|
|
|
|
|
|
if (hint) hint.remove();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const suggestion = result.trading_suggestion || {};
|
|
|
|
|
|
const fourDim = result.four_dimensional || {};
|
|
|
|
|
|
const pivotPoints = result.pivot_points || {};
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 更新操作建议
|
|
|
|
|
|
const suggestionEl = document.getElementById(`suggestion-${symbol}`);
|
|
|
|
|
|
if (suggestionEl && suggestion.direction) {
|
|
|
|
|
|
suggestionEl.textContent = suggestion.direction;
|
|
|
|
|
|
suggestionEl.className = `suggestion-badge ${suggestion.direction === '做多' ? 'up' : suggestion.direction === '做空' ? 'down' : 'neutral'}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 更新压力支撑位
|
|
|
|
|
|
const resistanceEl = document.getElementById(`resistance-${symbol}`);
|
|
|
|
|
|
const supportEl = document.getElementById(`support-${symbol}`);
|
|
|
|
|
|
if (resistanceEl && pivotPoints.r1) resistanceEl.textContent = pivotPoints.r1;
|
|
|
|
|
|
if (supportEl && pivotPoints.s1) supportEl.textContent = pivotPoints.s1;
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 更新多周期趋势
|
|
|
|
|
|
const periodNames = { '60min': '60', '30min': '30', '15min': '15', '5min': '5' };
|
|
|
|
|
|
Object.entries(fourDim).forEach(([period, data]) => {
|
|
|
|
|
|
const periodNum = periodNames[period];
|
|
|
|
|
|
if (!periodNum) return;
|
|
|
|
|
|
|
|
|
|
|
|
const trendEl = document.getElementById(`period-${periodNum}-${symbol}`);
|
|
|
|
|
|
if (trendEl) {
|
|
|
|
|
|
const trend = data.conclusion || data.macd?.trend || 'neutral';
|
|
|
|
|
|
const trendClass = trend.includes('多') || trend === 'up' ? 'up' : trend.includes('空') || trend === 'down' ? 'down' : 'neutral';
|
|
|
|
|
|
trendEl.className = `period-tag ${trendClass}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== AI智能分析功能 ====================
|
|
|
|
|
|
|
|
|
|
|
|
let currentAIAnalysis = null;
|
|
|
|
|
|
|
|
|
|
|
|
async function runAIAnalysis(forceRefresh = false) {
|
|
|
|
|
|
if (!currentSymbol) {
|
|
|
|
|
|
showToast('warning', '提示', '请先选择一个品种');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const btn = document.getElementById('ai-analyze-btn');
|
|
|
|
|
|
const content = document.getElementById('ai-analysis-content');
|
|
|
|
|
|
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
btn.textContent = '分析中...';
|
|
|
|
|
|
|
|
|
|
|
|
content.innerHTML = `
|
|
|
|
|
|
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 28px; gap: 14px;">
|
|
|
|
|
|
<div style="font-size: 32px; color: var(--color-ai); animation: spin 1.5s linear infinite;">⟳</div>
|
|
|
|
|
|
<span>AI正在分析中...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
// 设置5分钟超时
|
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
|
const timeoutId = setTimeout(() => controller.abort(), 300000); // 5分钟 = 300000毫秒
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(`${API_BASE}/ai-analysis/${currentSymbol}?force_refresh=${forceRefresh}`, {
|
|
|
|
|
|
signal: controller.signal
|
|
|
|
|
|
});
|
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
if (data.success) {
|
|
|
|
|
|
currentAIAnalysis = data.data;
|
|
|
|
|
|
displayAIAnalysisResult(data.data);
|
|
|
|
|
|
showToast('success', '分析完成', `${currentSymbol} AI分析已完成`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
content.innerHTML = `
|
|
|
|
|
|
<div style="text-align: center; padding: 28px; color: var(--text-tertiary);">
|
|
|
|
|
|
<p>${data.error || 'AI分析失败,请稍后重试'}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
showToast('error', '分析失败', data.error || 'AI分析失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
|
console.error('AI分析请求失败:', error);
|
|
|
|
|
|
|
|
|
|
|
|
if (error.name === 'AbortError') {
|
|
|
|
|
|
content.innerHTML = `
|
|
|
|
|
|
<div style="text-align: center; padding: 28px; color: var(--text-tertiary);">
|
|
|
|
|
|
<p>分析超时,请稍后重试或联系管理员</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
showToast('error', '请求超时', 'AI分析耗时过长,请稍后重试');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
content.innerHTML = `
|
|
|
|
|
|
<div style="text-align: center; padding: 28px; color: var(--text-tertiary);">
|
|
|
|
|
|
<p>网络错误,请检查网络连接</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
showToast('error', '请求失败', '网络错误,请稍后重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
btn.textContent = '智能分析';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function displayAIAnalysisResult(data) {
|
|
|
|
|
|
console.log('[AI分析] 开始显示AI分析结果...', data);
|
|
|
|
|
|
const content = document.getElementById('ai-analysis-content');
|
|
|
|
|
|
console.log('[AI分析] ai-analysis-content元素:', content);
|
|
|
|
|
|
if (!content) {
|
|
|
|
|
|
console.error('[AI分析] 错误: 找不到ai-analysis-content元素!');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const result = data.result;
|
|
|
|
|
|
const timestamp = new Date(data.analysis_time).toLocaleString('zh-CN');
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[AI分析] 分析结果数据:', result);
|
|
|
|
|
|
|
|
|
|
|
|
const direction = result.trading_suggestion?.direction || '观望';
|
|
|
|
|
|
const directionClass = direction === '做多' ? 'long' : direction === '做空' ? 'short' : 'neutral';
|
|
|
|
|
|
const directionIcon = direction === '做多' ? '↑' : direction === '做空' ? '↓' : '↔';
|
|
|
|
|
|
const confidence = result.trading_suggestion?.confidence || 0;
|
|
|
|
|
|
|
|
|
|
|
|
const entryMin = result.trading_suggestion?.entry_range?.min || '--';
|
|
|
|
|
|
const entryMax = result.trading_suggestion?.entry_range?.max || '--';
|
|
|
|
|
|
const stopLoss = result.trading_suggestion?.stop_loss || '--';
|
|
|
|
|
|
const positionSize = result.trading_suggestion?.position_size || '--';
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[AI分析] 生成HTML模板...');
|
|
|
|
|
|
content.innerHTML = `
|
|
|
|
|
|
<div class="ai-analysis-result">
|
|
|
|
|
|
<div class="ai-summary">${result.summary || '暂无总结'}</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ai-suggestion-row">
|
|
|
|
|
|
<div class="ai-suggestion-direction ${directionClass}">
|
|
|
|
|
|
<span style="font-size:18px;font-weight:700;">${directionIcon}</span>
|
|
|
|
|
|
<span style="font-weight:600;">${direction}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-confidence">
|
|
|
|
|
|
<span>置信度</span>
|
|
|
|
|
|
<div class="ai-confidence-bar">
|
|
|
|
|
|
<div class="ai-confidence-fill" style="width: ${confidence}%"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span>${confidence}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ai-key-metrics">
|
|
|
|
|
|
<div class="ai-metric-item">
|
|
|
|
|
|
<span class="label">入场区间</span>
|
|
|
|
|
|
<span class="value">${entryMin}-${entryMax}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-metric-item">
|
|
|
|
|
|
<span class="label">止损位</span>
|
|
|
|
|
|
<span class="value down">${stopLoss}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-metric-item">
|
|
|
|
|
|
<span class="label">建议仓位</span>
|
|
|
|
|
|
<span class="value">${positionSize}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-metric-item">
|
|
|
|
|
|
<span class="label">纪律评分</span>
|
|
|
|
|
|
<span class="value">${result.discipline_score?.total || '--'}/${result.discipline_score?.max || '11'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ai-timestamp">
|
|
|
|
|
|
<span>🕐</span> 分析时间: ${timestamp}
|
|
|
|
|
|
<button class="ai-detail-btn" onclick="showAIDetailModal()" style="margin-left: 8px; background: none; border: 1px solid var(--border-color); padding: 4px 12px; border-radius: 4px; color: var(--cyan); cursor: pointer; font-size: 11px;">
|
|
|
|
|
|
查看详情 →
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[AI分析] HTML已设置到DOM');
|
|
|
|
|
|
|
|
|
|
|
|
// 同步AI分析数据到主面板各个卡片
|
|
|
|
|
|
syncAIToPanels(result);
|
|
|
|
|
|
console.log('[AI分析] AI分析结果显示完成');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function syncAIToPanels(result) {
|
|
|
|
|
|
const suggestion = result.trading_suggestion || {};
|
|
|
|
|
|
const fourDim = result.four_dimensional || {};
|
|
|
|
|
|
const pivotPoints = result.pivot_points || {};
|
|
|
|
|
|
const kdjDiag = result.kdj_diagnosis || {};
|
|
|
|
|
|
const scenarios = result.scenario_plans || {};
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 同步到技术指标卡片
|
|
|
|
|
|
// 从60min周期提取MACD和KDJ信息
|
|
|
|
|
|
const macd60 = fourDim['60min']?.macd || {};
|
|
|
|
|
|
const kdj60 = fourDim['60min']?.kdj || {};
|
|
|
|
|
|
|
|
|
|
|
|
const macdSignalEl = document.getElementById('macd-signal');
|
|
|
|
|
|
if (macdSignalEl) macdSignalEl.textContent = macd60.trend || '--';
|
|
|
|
|
|
|
|
|
|
|
|
const macdDetailEl = document.getElementById('macd-detail');
|
|
|
|
|
|
if (macdDetailEl) macdDetailEl.textContent = macd60.position ? `${macd60.position} | ${macd60.histogram || ''}` : '--';
|
|
|
|
|
|
|
|
|
|
|
|
const kdjSignalEl = document.getElementById('kdj-signal');
|
|
|
|
|
|
if (kdjSignalEl) kdjSignalEl.textContent = kdj60.status || '--';
|
|
|
|
|
|
|
|
|
|
|
|
const kdjDetailEl = document.getElementById('kdj-detail');
|
|
|
|
|
|
if (kdjDetailEl) kdjDetailEl.textContent = kdj60.signal || '--';
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 同步到关键点位卡片
|
|
|
|
|
|
if (pivotPoints.r1) {
|
|
|
|
|
|
const r1El = document.getElementById('resistance-1');
|
|
|
|
|
|
if (r1El) r1El.textContent = pivotPoints.r1;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pivotPoints.r2) {
|
|
|
|
|
|
const r2El = document.getElementById('resistance-2');
|
|
|
|
|
|
if (r2El) r2El.textContent = pivotPoints.r2;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pivotPoints.pp) {
|
|
|
|
|
|
const ppEl = document.getElementById('pivot-point');
|
|
|
|
|
|
if (ppEl) ppEl.textContent = pivotPoints.pp;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pivotPoints.s1) {
|
|
|
|
|
|
const s1El = document.getElementById('support-1');
|
|
|
|
|
|
if (s1El) s1El.textContent = pivotPoints.s1;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pivotPoints.s2) {
|
|
|
|
|
|
const s2El = document.getElementById('support-2');
|
|
|
|
|
|
if (s2El) s2El.textContent = pivotPoints.s2;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 同步到多周期趋势卡片
|
|
|
|
|
|
const periodTrendsEl = document.getElementById('period-trends');
|
|
|
|
|
|
if (periodTrendsEl && Object.keys(fourDim).length > 0) {
|
|
|
|
|
|
const periodNames = { '60min': '60分钟', '30min': '30分钟', '15min': '15分钟', '5min': '5分钟' };
|
|
|
|
|
|
const periodOrder = ['60min', '30min', '15min', '5min'];
|
|
|
|
|
|
|
|
|
|
|
|
// 按固定顺序排列周期
|
|
|
|
|
|
const sortedEntries = Object.entries(fourDim).sort((a, b) => {
|
|
|
|
|
|
return periodOrder.indexOf(a[0]) - periodOrder.indexOf(b[0]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
periodTrendsEl.innerHTML = sortedEntries.map(([period, data]) => {
|
|
|
|
|
|
const trend = data.conclusion || data.macd?.trend || 'neutral';
|
|
|
|
|
|
const trendClass = trend.includes('多') || trend === 'up' ? 'up' : trend.includes('空') || trend === 'down' ? 'down' : 'neutral';
|
|
|
|
|
|
const trendText = trend.includes('多') ? '偏多' : trend.includes('空') ? '偏空' : '震荡';
|
|
|
|
|
|
return `<div class="trend-item"><span class="trend-period">${periodNames[period] || period}</span><span class="trend-badge ${trendClass}">${trendText}</span></div>`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 5. 同步到情景预案卡片
|
|
|
|
|
|
const scenarioPanel = document.getElementById('scenario-panel');
|
|
|
|
|
|
const scenarioPlansEl = document.getElementById('scenario-plans');
|
|
|
|
|
|
if (scenarioPanel && scenarioPlansEl && Object.keys(scenarios).length > 0) {
|
|
|
|
|
|
scenarioPanel.style.display = 'block';
|
|
|
|
|
|
const scenarioNames = {
|
|
|
|
|
|
'breakthrough': '突破',
|
|
|
|
|
|
'consolidation': '震荡',
|
|
|
|
|
|
'reversal': '反转',
|
|
|
|
|
|
'news_impact': '消息影响'
|
|
|
|
|
|
};
|
|
|
|
|
|
scenarioPlansEl.innerHTML = Object.entries(scenarios).map(([key, data]) => `
|
|
|
|
|
|
<div class="scenario-item">
|
|
|
|
|
|
<span class="scenario-name">${scenarioNames[key] || key}</span>
|
|
|
|
|
|
<span class="scenario-probability">${data.probability || 0}%</span>
|
|
|
|
|
|
<span class="scenario-action">${data.action || '--'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
} else if (scenarioPanel) {
|
|
|
|
|
|
scenarioPanel.style.display = 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showAIDetailModal() {
|
|
|
|
|
|
if (!currentAIAnalysis) {
|
|
|
|
|
|
showToast('warning', '提示', '暂无AI分析数据');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = currentAIAnalysis.result;
|
|
|
|
|
|
const modalBody = document.getElementById('ai-analysis-modal-body');
|
|
|
|
|
|
|
|
|
|
|
|
let fourDimensionalHTML = '';
|
|
|
|
|
|
const periods = ['60min', '30min', '15min', '5min'];
|
|
|
|
|
|
const periodNames = { '60min': '60分钟', '30min': '30分钟', '15min': '15分钟', '5min': '5分钟' };
|
|
|
|
|
|
periods.forEach(period => {
|
|
|
|
|
|
const data = result.four_dimensional?.[period];
|
|
|
|
|
|
if (data) {
|
|
|
|
|
|
fourDimensionalHTML += `
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td class="period-cell">${periodNames[period] || period}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div>趋势: ${data.macd?.trend || '--'}</div>
|
|
|
|
|
|
<div>位置: ${data.macd?.position || '--'}</div>
|
|
|
|
|
|
<div>柱状图: ${data.macd?.histogram || '--'}</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div>状态: ${data.volume?.status || '--'}</div>
|
|
|
|
|
|
<div>量比: ${data.volume?.ratio || '--'}</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<div>K: ${data.kdj?.k || '--'} D: ${data.kdj?.d || '--'}</div>
|
|
|
|
|
|
<div>信号: ${data.kdj?.signal || '--'}</div>
|
|
|
|
|
|
<div>状态: ${data.kdj?.status || '--'}</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>${data.conclusion || '--'}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let scenariosHTML = '';
|
|
|
|
|
|
const scenarios = result.scenario_plans || {};
|
|
|
|
|
|
const scenarioIcons = {
|
|
|
|
|
|
'breakthrough': 'fa-rocket',
|
|
|
|
|
|
'consolidation': 'fa-exchange-alt',
|
|
|
|
|
|
'reversal': 'fa-undo',
|
|
|
|
|
|
'news_impact': 'fa-newspaper'
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Object.entries(scenarios).forEach(([key, scenario]) => {
|
|
|
|
|
|
scenariosHTML += `
|
|
|
|
|
|
<div class="scenario-card ${key}">
|
|
|
|
|
|
<div class="scenario-probability">${scenario.probability || 0}%</div>
|
|
|
|
|
|
<div class="scenario-action">${scenario.action || '--'}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let redLinesHTML = '';
|
|
|
|
|
|
if (result.red_lines_check?.violated?.length > 0) {
|
|
|
|
|
|
result.red_lines_check.violated.forEach(line => {
|
|
|
|
|
|
redLinesHTML += `<div class="red-line-item"><i class="fas fa-exclamation-circle"></i> ${line}</div>`;
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
redLinesHTML = '<div class="red-line-item pass"><i class="fas fa-check-circle"></i> 未触碰交易红线</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let disciplineHTML = '';
|
|
|
|
|
|
const discipline = result.discipline_score?.details || {};
|
|
|
|
|
|
const disciplineLabels = {
|
|
|
|
|
|
'trend': '趋势',
|
|
|
|
|
|
'position': '位置',
|
|
|
|
|
|
'signal': '信号',
|
|
|
|
|
|
'risk': '风险',
|
|
|
|
|
|
'mindset': '心态'
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Object.entries(disciplineLabels).forEach(([key, label]) => {
|
|
|
|
|
|
const isPass = discipline[key];
|
|
|
|
|
|
disciplineHTML += `
|
|
|
|
|
|
<div class="discipline-item ${isPass ? 'pass' : 'fail'}">
|
|
|
|
|
|
<i class="fas ${isPass ? 'fa-check-circle' : 'fa-times-circle'}"></i>
|
|
|
|
|
|
<span class="discipline-label">${label}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let experiencesHTML = '';
|
|
|
|
|
|
const experiences = result.experience_lessons || [];
|
|
|
|
|
|
if (experiences.length > 0) {
|
|
|
|
|
|
experiences.forEach(exp => {
|
|
|
|
|
|
experiencesHTML += `<div class="experience-item"><i class="fas fa-lightbulb"></i> ${exp}</div>`;
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
experiencesHTML = '<div class="experience-item"><i class="fas fa-info-circle"></i> 暂无经验提醒</div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
modalBody.innerHTML = `
|
|
|
|
|
|
<div class="ai-modal-section">
|
|
|
|
|
|
<div class="ai-modal-section-title">
|
|
|
|
|
|
<i class="fas fa-chart-bar"></i>
|
|
|
|
|
|
四维联合信号表 (4D-XV)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<table class="four-dimensional-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>周期</th>
|
|
|
|
|
|
<th>MACD(趋势)</th>
|
|
|
|
|
|
<th>成交量(资金)</th>
|
|
|
|
|
|
<th>KDJ(时机)</th>
|
|
|
|
|
|
<th>结论</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
${fourDimensionalHTML || '<tr><td colspan="5">暂无数据</td></tr>'}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ai-modal-section">
|
|
|
|
|
|
<div class="ai-modal-section-title">
|
|
|
|
|
|
<i class="fas fa-crosshairs"></i>
|
|
|
|
|
|
关键点位 (Pivot Point)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-key-metrics">
|
|
|
|
|
|
<div class="ai-metric-item">
|
|
|
|
|
|
<span class="label">R2</span>
|
|
|
|
|
|
<span class="value down">${result.pivot_points?.r2 || '--'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-metric-item">
|
|
|
|
|
|
<span class="label">R1</span>
|
|
|
|
|
|
<span class="value down">${result.pivot_points?.r1 || '--'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-metric-item">
|
|
|
|
|
|
<span class="label">PP</span>
|
|
|
|
|
|
<span class="value" style="color: var(--purple);">${result.pivot_points?.pp || '--'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-metric-item">
|
|
|
|
|
|
<span class="label">S1</span>
|
|
|
|
|
|
<span class="value up">${result.pivot_points?.s1 || '--'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-metric-item">
|
|
|
|
|
|
<span class="label">S2</span>
|
|
|
|
|
|
<span class="value up">${result.pivot_points?.s2 || '--'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ai-modal-section">
|
|
|
|
|
|
<div class="ai-modal-section-title">
|
|
|
|
|
|
<i class="fas fa-balance-scale"></i>
|
|
|
|
|
|
交易红线审查
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="red-lines-list">
|
|
|
|
|
|
${redLinesHTML}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ai-modal-section">
|
|
|
|
|
|
<div class="ai-modal-section-title">
|
|
|
|
|
|
<i class="fas fa-check-double"></i>
|
|
|
|
|
|
11项纪律检查
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="discipline-grid">
|
|
|
|
|
|
${disciplineHTML}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ai-modal-section">
|
|
|
|
|
|
<div class="ai-modal-section-title">
|
|
|
|
|
|
<i class="fas fa-project-diagram"></i>
|
|
|
|
|
|
情景预案
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="scenario-cards">
|
|
|
|
|
|
${scenariosHTML || '<div class="scenario-card"><div class="scenario-action">暂无情景预案</div></div>'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ai-modal-section">
|
|
|
|
|
|
<div class="ai-modal-section-title">
|
|
|
|
|
|
<i class="fas fa-lightbulb"></i>
|
|
|
|
|
|
经验教训提醒
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-experience-list">
|
|
|
|
|
|
${experiencesHTML}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="ai-modal-section">
|
|
|
|
|
|
<div class="ai-modal-section-title">
|
|
|
|
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
|
|
|
|
风险提示
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ai-experience-list">
|
|
|
|
|
|
${(result.risk_warnings || []).map(w => `<div class="experience-item"><i class="fas fa-exclamation-circle"></i> ${w}</div>`).join('')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('ai-analysis-modal').classList.add('active');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 复盘计划功能 V2 ====================
|
|
|
|
|
|
|
|
|
|
|
|
const RV_API_BASE = '/api/v1/review';
|
|
|
|
|
|
let rvCurrentPlanId = null;
|
|
|
|
|
|
let rvCurrentPlanData = null;
|
|
|
|
|
|
|
|
|
|
|
|
function initReviewPlan() {
|
|
|
|
|
|
document.getElementById('rv-date-selector').addEventListener('change', function (e) {
|
|
|
|
|
|
const selectedDate = e.target.value;
|
|
|
|
|
|
if (selectedDate) {
|
|
|
|
|
|
rvLoadPlanByDate(selectedDate);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('rv-btn-generate').addEventListener('click', function () {
|
|
|
|
|
|
rvHandleGenerate();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('rv-btn-clear').addEventListener('click', function () {
|
|
|
|
|
|
rvHandleClear();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
rvSetupSortButtons();
|
|
|
|
|
|
|
|
|
|
|
|
rvLoadLatestPlan();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvHandleGenerate() {
|
|
|
|
|
|
const dateSelector = document.getElementById('rv-date-selector');
|
|
|
|
|
|
let reviewDate = dateSelector.value;
|
|
|
|
|
|
|
|
|
|
|
|
if (!reviewDate) {
|
|
|
|
|
|
reviewDate = new Date().toISOString().split('T')[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
rvDoGenerate(reviewDate);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvDoGenerate(dateStr) {
|
|
|
|
|
|
rvShowLoading();
|
|
|
|
|
|
fetch(`${RV_API_BASE}/generate`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ review_date: dateStr })
|
|
|
|
|
|
})
|
|
|
|
|
|
.then(res => res.json())
|
|
|
|
|
|
.then(result => {
|
|
|
|
|
|
rvHideLoading();
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
const s = result.data.summary;
|
|
|
|
|
|
alert(
|
|
|
|
|
|
`复盘与计划生成成功!\n` +
|
|
|
|
|
|
`日期: ${s.review_date} ${s.week_day}\n` +
|
|
|
|
|
|
`品种数: ${s.symbol_count}\n` +
|
|
|
|
|
|
`交易机会: ${s.opportunity_count} 个`
|
|
|
|
|
|
);
|
|
|
|
|
|
rvLoadLatestPlan();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert('生成失败: ' + (result.message || '未知错误'));
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(err => {
|
|
|
|
|
|
rvHideLoading();
|
|
|
|
|
|
console.error('生成复盘计划失败:', err);
|
|
|
|
|
|
alert('生成失败,请稍后重试');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvHandleClear() {
|
|
|
|
|
|
if (!confirm('确定要清除所有复盘与交易计划数据吗?此操作不可恢复。')) return;
|
|
|
|
|
|
|
|
|
|
|
|
fetch(`${RV_API_BASE}/v2/clear`, { method: 'DELETE' })
|
|
|
|
|
|
.then(res => res.json())
|
|
|
|
|
|
.then(data => {
|
|
|
|
|
|
if (data.success) {
|
|
|
|
|
|
alert('复盘数据已清除');
|
|
|
|
|
|
document.getElementById('rv-date-selector').value = '';
|
|
|
|
|
|
rvCurrentPlanId = null;
|
|
|
|
|
|
rvCurrentPlanData = null;
|
|
|
|
|
|
rvHideAllSections();
|
|
|
|
|
|
document.getElementById('rv-empty-state').style.display = 'flex';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert(data.message || '清除失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(err => {
|
|
|
|
|
|
console.error('清除数据失败:', err);
|
|
|
|
|
|
alert('清除失败,请稍后重试');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvLoadLatestPlan() {
|
|
|
|
|
|
fetch(`${RV_API_BASE}/v2/plans?limit=1`)
|
|
|
|
|
|
.then(res => res.json())
|
|
|
|
|
|
.then(data => {
|
|
|
|
|
|
if (data.success && data.data && data.data.length > 0) {
|
|
|
|
|
|
const plan = data.data[0];
|
|
|
|
|
|
const dateSelector = document.getElementById('rv-date-selector');
|
|
|
|
|
|
if (dateSelector && plan.review_date) {
|
|
|
|
|
|
dateSelector.value = plan.review_date;
|
|
|
|
|
|
}
|
|
|
|
|
|
rvLoadAndRender(plan.id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
rvHideAllSections();
|
|
|
|
|
|
document.getElementById('rv-empty-state').style.display = 'flex';
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(err => {
|
|
|
|
|
|
console.error('加载最新计划失败:', err);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvLoadPlanByDate(date) {
|
|
|
|
|
|
rvShowLoading();
|
|
|
|
|
|
fetch(`${RV_API_BASE}/v2/plan/by-date/${date}`)
|
|
|
|
|
|
.then(res => res.json())
|
|
|
|
|
|
.then(data => {
|
|
|
|
|
|
rvHideLoading();
|
|
|
|
|
|
if (data.success && data.data) {
|
|
|
|
|
|
rvCurrentPlanId = data.data.meta.id;
|
|
|
|
|
|
rvCurrentPlanData = data.data;
|
|
|
|
|
|
rvRenderAll(data.data);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
rvHideAllSections();
|
|
|
|
|
|
document.getElementById('rv-empty-state').style.display = 'flex';
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(err => {
|
|
|
|
|
|
rvHideLoading();
|
|
|
|
|
|
console.error('按日期加载计划失败:', err);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvLoadAndRender(planId) {
|
|
|
|
|
|
rvShowLoading();
|
|
|
|
|
|
fetch(`${RV_API_BASE}/v2/plan/${planId}`)
|
|
|
|
|
|
.then(res => res.json())
|
|
|
|
|
|
.then(result => {
|
|
|
|
|
|
rvHideLoading();
|
|
|
|
|
|
if (result.success && result.data) {
|
|
|
|
|
|
rvCurrentPlanId = planId;
|
|
|
|
|
|
rvCurrentPlanData = result.data;
|
|
|
|
|
|
rvRenderAll(result.data);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
alert('加载计划详情失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(err => {
|
|
|
|
|
|
rvHideLoading();
|
|
|
|
|
|
console.error('加载计划详情失败:', err);
|
|
|
|
|
|
alert('加载失败,请稍后重试');
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvRenderAll(data) {
|
|
|
|
|
|
const contentArea = document.getElementById('rv-content-area');
|
|
|
|
|
|
if (contentArea) contentArea.style.display = '';
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('rv-empty-state').style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
const { meta, scores, plans, sectors } = data;
|
|
|
|
|
|
|
|
|
|
|
|
rvRenderHero(meta);
|
|
|
|
|
|
rvRenderStats(meta, scores);
|
|
|
|
|
|
rvRenderRisk(meta);
|
|
|
|
|
|
rvRenderGreenSection(plans, scores);
|
|
|
|
|
|
rvRenderYellowSection(scores);
|
|
|
|
|
|
rvRenderRedSection(scores);
|
|
|
|
|
|
rvRenderRankingTable(scores);
|
|
|
|
|
|
rvRenderDetails(scores);
|
|
|
|
|
|
rvRenderSectors(sectors);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvRenderHero(meta) {
|
|
|
|
|
|
const el = document.getElementById('rv-hero-section');
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
|
|
|
|
|
|
el.innerHTML = `
|
|
|
|
|
|
<h1>${meta.review_date} <span style="font-size: 18px; opacity: 0.8;">${meta.week_day || ''}</span></h1>
|
|
|
|
|
|
<div class="rv-hero-subtitle">${meta.data_basis || ''}</div>
|
|
|
|
|
|
<div class="rv-hero-conclusion">${meta.core_conclusion || ''}</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
el.style.display = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvRenderStats(meta, scores) {
|
|
|
|
|
|
const el = document.getElementById('rv-stats-grid');
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
|
|
|
|
|
|
const top3 = scores.slice(0, 3);
|
|
|
|
|
|
const top3Text = top3.map(s => `${s.symbol} ${s.name}(${s.composite_score}分)`).join('、');
|
|
|
|
|
|
|
|
|
|
|
|
el.innerHTML = `
|
|
|
|
|
|
<div class="rv-stat-card">
|
|
|
|
|
|
<div class="rv-stat-label">核心结论</div>
|
|
|
|
|
|
<div class="rv-stat-text">${meta.core_conclusion || '-'}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="rv-stat-card">
|
|
|
|
|
|
<div class="rv-stat-label">TOP 3 机会</div>
|
|
|
|
|
|
<div class="rv-stat-value" style="font-size: 14px;">${top3Text || '-'}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="rv-stat-card">
|
|
|
|
|
|
<div class="rv-stat-label">市场概览</div>
|
|
|
|
|
|
<div class="rv-stat-text">多头 ${meta.bull_count || 0} | 空头 ${meta.bear_count || 0} | 震荡 ${meta.neutral_count || 0}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
el.style.display = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvRenderRisk(meta) {
|
|
|
|
|
|
const banner = document.getElementById('rv-risk-banner');
|
|
|
|
|
|
const list = document.getElementById('rv-risk-list');
|
|
|
|
|
|
if (!banner || !list) return;
|
|
|
|
|
|
|
|
|
|
|
|
const warnings = meta.risk_warnings || [];
|
|
|
|
|
|
if (warnings.length === 0) {
|
|
|
|
|
|
banner.style.display = 'none';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
list.innerHTML = warnings.map(w => `<li>${w}</li>`).join('');
|
|
|
|
|
|
banner.style.display = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvFormatPrice(value, refPrice) {
|
|
|
|
|
|
if (!value && value !== 0) return '-';
|
|
|
|
|
|
// 根据参考价格确定小数位数
|
|
|
|
|
|
let precision = 2;
|
|
|
|
|
|
if (refPrice >= 10000) precision = 0;
|
|
|
|
|
|
else if (refPrice >= 1000) precision = 1;
|
|
|
|
|
|
return Number(value).toFixed(precision);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvScoreBarColor(score) {
|
|
|
|
|
|
if (score > 70) return '#34C759'; // --color-down
|
|
|
|
|
|
if (score > 40) return '#007AFF'; // --color-brand
|
|
|
|
|
|
if (score > 20) return '#FF9F0A'; // --color-neutral
|
|
|
|
|
|
return '#FF3B30'; // --color-up
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvScoreBar(score, label) {
|
|
|
|
|
|
const color = rvScoreBarColor(score);
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="rv-score-bar-item">
|
|
|
|
|
|
<span class="rv-score-bar-label">${label}</span>
|
|
|
|
|
|
<div class="rv-score-bar-track">
|
|
|
|
|
|
<div class="rv-score-bar-fill" style="width: ${Math.min(100, score)}%; background: ${color};"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="rv-score-bar-value" style="color: ${color};">${Math.round(score)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvDirectionTag(tag) {
|
|
|
|
|
|
let cls = 'rv-tag-neutral';
|
|
|
|
|
|
if (tag === '强多' || tag === '偏多') cls = 'rv-tag-bull';
|
|
|
|
|
|
else if (tag === '强空' || tag === '偏空') cls = 'rv-tag-bear';
|
|
|
|
|
|
return `<span class="rv-item-tag ${cls}">${tag || '震荡'}</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvRenderGreenSection(plans, scores) {
|
|
|
|
|
|
const section = document.getElementById('rv-green-section');
|
|
|
|
|
|
const grid = document.getElementById('rv-green-grid');
|
|
|
|
|
|
if (!section || !grid) return;
|
|
|
|
|
|
|
|
|
|
|
|
const greenPlans = plans.filter(p => p.category === 'green');
|
|
|
|
|
|
if (greenPlans.length === 0) {
|
|
|
|
|
|
section.style.display = 'none';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
grid.innerHTML = greenPlans.map(p => {
|
|
|
|
|
|
const score = scores.find(s => s.symbol === p.symbol) || {};
|
|
|
|
|
|
const dirText = p.direction === 'long' ? '做多' : '做空';
|
|
|
|
|
|
const dirColor = p.direction === 'long' ? 'var(--color-down)' : 'var(--color-up)';
|
|
|
|
|
|
const dataDateTag = score.data_date ? `<span class="rv-data-date-tag">${score.data_date}</span>` : '';
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="rv-opp-card" onclick="rvShowDetail('${p.symbol}','${(p.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
|
|
|
|
|
|
<div class="rv-opp-header">
|
|
|
|
|
|
<span class="rv-opp-symbol">${p.symbol} ${p.name}</span>
|
|
|
|
|
|
<span class="rv-opp-score">${p.composite_score}分</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="rv-opp-info">
|
|
|
|
|
|
<div class="rv-opp-info-item"><span class="rv-opp-info-label">方向</span><span style="color: ${dirColor}; font-weight: 600;">${dirText}</span></div>
|
|
|
|
|
|
<div class="rv-opp-info-item"><span class="rv-opp-info-label">收盘价</span><span>${score.close_price || '-'} ${dataDateTag}</span></div>
|
|
|
|
|
|
<div class="rv-opp-info-item"><span class="rv-opp-info-label">涨跌幅</span><span>${score.change_pct || 0}%</span></div>
|
|
|
|
|
|
<div class="rv-opp-info-item"><span class="rv-opp-info-label">量比</span><span>${score.volume_ratio || '-'}</span></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="rv-score-bars">
|
|
|
|
|
|
${rvScoreBar(p.amplitude_score || 0, '振幅')}
|
|
|
|
|
|
${rvScoreBar(p.volume_score || 0, '量能')}
|
|
|
|
|
|
${rvScoreBar(p.trend_score || 0, '趋势')}
|
|
|
|
|
|
${rvScoreBar(p.activity_score || 0, '活跃')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="rv-plan-box">
|
|
|
|
|
|
<div class="rv-plan-row"><span>入场区间</span><span>${rvFormatPrice(p.entry_low, score.close_price)} ~ ${rvFormatPrice(p.entry_high, score.close_price)}</span></div>
|
|
|
|
|
|
<div class="rv-plan-row"><span>止损位</span><span style="color: var(--color-up);">${rvFormatPrice(p.stop_loss, score.close_price)}</span></div>
|
|
|
|
|
|
<div class="rv-plan-row"><span>目标一</span><span style="color: var(--color-down);">${rvFormatPrice(p.target1, score.close_price)}</span></div>
|
|
|
|
|
|
<div class="rv-plan-row"><span>目标二</span><span style="color: var(--color-down);">${rvFormatPrice(p.target2, score.close_price)}</span></div>
|
|
|
|
|
|
<div class="rv-plan-trigger">触发条件: ${p.trigger || '-'}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
section.style.display = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvRenderYellowSection(scores) {
|
|
|
|
|
|
const section = document.getElementById('rv-yellow-section');
|
|
|
|
|
|
const tbody = document.getElementById('rv-yellow-list');
|
|
|
|
|
|
if (!section || !tbody) return;
|
|
|
|
|
|
|
|
|
|
|
|
const yellowItems = scores.filter(s => s.category === 'yellow');
|
|
|
|
|
|
if (yellowItems.length === 0) {
|
|
|
|
|
|
section.style.display = 'none';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = yellowItems.map((s, i) => {
|
|
|
|
|
|
const changeColor = s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)';
|
|
|
|
|
|
return `
|
|
|
|
|
|
<tr onclick="rvShowDetail('${s.symbol}','${(s.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
|
|
|
|
|
|
<td>${i + 1}</td>
|
|
|
|
|
|
<td><strong>${s.symbol}</strong> ${s.name}</td>
|
|
|
|
|
|
<td>${s.close_price || '-'}${s.data_date ? '<span class="rv-data-date-tag">' + s.data_date + '</span>' : ''}</td>
|
|
|
|
|
|
<td style="color: ${changeColor};">${s.change_pct >= 0 ? '+' : ''}${s.change_pct || 0}%</td>
|
|
|
|
|
|
<td><strong>${s.composite_score}</strong></td>
|
|
|
|
|
|
<td>${s.amplitude_pct || 0}%</td>
|
|
|
|
|
|
<td>${s.volume_ratio || '-'}</td>
|
|
|
|
|
|
<td>${rvDirectionTag(s.direction_tag)}</td>
|
|
|
|
|
|
<td style="font-size:11px;color:var(--text-tertiary);">${s.data_date || '-'}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
section.style.display = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvRenderRedSection(scores) {
|
|
|
|
|
|
const section = document.getElementById('rv-red-section');
|
|
|
|
|
|
const tbody = document.getElementById('rv-red-list');
|
|
|
|
|
|
if (!section || !tbody) return;
|
|
|
|
|
|
|
|
|
|
|
|
const redItems = scores.filter(s => s.category === 'red');
|
|
|
|
|
|
if (redItems.length === 0) {
|
|
|
|
|
|
section.style.display = 'none';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = redItems.map((s, i) => {
|
|
|
|
|
|
const changeColor = s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)';
|
|
|
|
|
|
return `
|
|
|
|
|
|
<tr onclick="rvShowDetail('${s.symbol}','${(s.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
|
|
|
|
|
|
<td>${i + 1}</td>
|
|
|
|
|
|
<td><strong>${s.symbol}</strong> ${s.name}</td>
|
|
|
|
|
|
<td>${s.close_price || '-'}${s.data_date ? '<span class="rv-data-date-tag">' + s.data_date + '</span>' : ''}</td>
|
|
|
|
|
|
<td style="color: ${changeColor};">${s.change_pct >= 0 ? '+' : ''}${s.change_pct || 0}%</td>
|
|
|
|
|
|
<td><strong>${s.composite_score}</strong></td>
|
|
|
|
|
|
<td>${s.amplitude_pct || 0}%</td>
|
|
|
|
|
|
<td>${s.volume_ratio || '-'}</td>
|
|
|
|
|
|
<td>${rvDirectionTag(s.direction_tag)}</td>
|
|
|
|
|
|
<td style="font-size:11px;color:var(--text-tertiary);">${s.data_date || '-'}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
section.style.display = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let rvCurrentSortField = 'composite_score';
|
|
|
|
|
|
let rvCurrentSortOrder = 'desc';
|
|
|
|
|
|
|
|
|
|
|
|
function rvRenderRankingTable(scores) {
|
|
|
|
|
|
const section = document.getElementById('rv-table-section');
|
|
|
|
|
|
const table = document.getElementById('rv-ranking-table');
|
|
|
|
|
|
if (!section || !table) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!scores || scores.length === 0) {
|
|
|
|
|
|
section.style.display = 'none';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 排序
|
|
|
|
|
|
const sorted = [...scores].sort((a, b) => {
|
|
|
|
|
|
let va = a[rvCurrentSortField];
|
|
|
|
|
|
let vb = b[rvCurrentSortField];
|
|
|
|
|
|
|
|
|
|
|
|
// 方向排序特殊处理
|
|
|
|
|
|
if (rvCurrentSortField === 'direction_tag') {
|
|
|
|
|
|
const dirOrder = { '强多': 1, '偏多': 2, '多头共振': 1, '震荡': 3, '偏空': 4, '强空': 5, '空头共振': 5 };
|
|
|
|
|
|
va = dirOrder[va] || 3;
|
|
|
|
|
|
vb = dirOrder[vb] || 3;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (va == null) va = -Infinity;
|
|
|
|
|
|
if (vb == null) vb = -Infinity;
|
|
|
|
|
|
|
|
|
|
|
|
if (rvCurrentSortOrder === 'asc') return va > vb ? 1 : va < vb ? -1 : 0;
|
|
|
|
|
|
return va < vb ? 1 : va > vb ? -1 : 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let html = `
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>#</th>
|
|
|
|
|
|
<th>品种</th>
|
|
|
|
|
|
<th>收盘价</th>
|
|
|
|
|
|
<th>综合评分</th>
|
|
|
|
|
|
<th>振幅%</th>
|
|
|
|
|
|
<th>涨跌幅%</th>
|
|
|
|
|
|
<th>量比</th>
|
|
|
|
|
|
<th>趋势分</th>
|
|
|
|
|
|
<th>方向</th>
|
|
|
|
|
|
<th>活跃度</th>
|
|
|
|
|
|
<th>数据日期</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
sorted.forEach((s, i) => {
|
|
|
|
|
|
const scoreColor = rvScoreBarColor(s.composite_score);
|
|
|
|
|
|
const changeColor = s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)';
|
|
|
|
|
|
|
|
|
|
|
|
html += `
|
|
|
|
|
|
<tr onclick="rvShowDetail('${s.symbol}','${(s.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
|
|
|
|
|
|
<td>${i + 1}</td>
|
|
|
|
|
|
<td><strong>${s.symbol}</strong> ${s.name}</td>
|
|
|
|
|
|
<td>${s.close_price || '-'}${s.data_date ? '<span class="rv-data-date-tag">' + s.data_date + '</span>' : ''}</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<strong>${s.composite_score}</strong>
|
|
|
|
|
|
<div class="rv-table-score-bar">
|
|
|
|
|
|
<div class="rv-table-score-fill" style="width: ${Math.min(100, s.composite_score)}%; background: ${scoreColor};"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>${s.amplitude_pct || 0}</td>
|
|
|
|
|
|
<td style="color: ${changeColor};">${s.change_pct >= 0 ? '+' : ''}${s.change_pct || 0}</td>
|
|
|
|
|
|
<td>${s.volume_ratio || '-'}</td>
|
|
|
|
|
|
<td>${s.trend_score || 0}</td>
|
|
|
|
|
|
<td>${rvDirectionTag(s.direction_tag)}</td>
|
|
|
|
|
|
<td>${s.activity_score || 0}</td>
|
|
|
|
|
|
<td style="font-size:11px;color:var(--text-tertiary);">${s.data_date || '-'}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
html += '</tbody>';
|
|
|
|
|
|
table.innerHTML = html;
|
|
|
|
|
|
section.style.display = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let rvSortButtonsInitialized = false;
|
|
|
|
|
|
|
|
|
|
|
|
function rvSetupSortButtons() {
|
|
|
|
|
|
if (rvSortButtonsInitialized) return;
|
|
|
|
|
|
|
|
|
|
|
|
const controls = document.getElementById('rv-sort-controls');
|
|
|
|
|
|
if (!controls) return;
|
|
|
|
|
|
|
|
|
|
|
|
controls.addEventListener('click', function (e) {
|
|
|
|
|
|
const btn = e.target.closest('.rv-sort-btn');
|
|
|
|
|
|
if (!btn) return;
|
|
|
|
|
|
|
|
|
|
|
|
const field = btn.dataset.sort;
|
|
|
|
|
|
const defaultOrder = btn.dataset.order || 'desc';
|
|
|
|
|
|
|
|
|
|
|
|
// 切换排序方向
|
|
|
|
|
|
if (rvCurrentSortField === field) {
|
|
|
|
|
|
rvCurrentSortOrder = rvCurrentSortOrder === 'desc' ? 'asc' : 'desc';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
rvCurrentSortField = field;
|
|
|
|
|
|
rvCurrentSortOrder = defaultOrder;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新按钮状态
|
|
|
|
|
|
controls.querySelectorAll('.rv-sort-btn').forEach(b => {
|
|
|
|
|
|
b.classList.remove('active');
|
|
|
|
|
|
b.textContent = b.textContent.replace(/ [▲▼]$/, '');
|
|
|
|
|
|
});
|
|
|
|
|
|
btn.classList.add('active');
|
|
|
|
|
|
const arrow = rvCurrentSortOrder === 'desc' ? ' ▼' : ' ▲';
|
|
|
|
|
|
btn.textContent = btn.textContent + arrow;
|
|
|
|
|
|
|
|
|
|
|
|
// 重新渲染
|
|
|
|
|
|
if (rvCurrentPlanData) {
|
|
|
|
|
|
rvRenderRankingTable(rvCurrentPlanData.scores);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
rvSortButtonsInitialized = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvRenderDetails(scores) {
|
|
|
|
|
|
const section = document.getElementById('rv-details-section');
|
|
|
|
|
|
const grid = document.getElementById('rv-details-grid');
|
|
|
|
|
|
if (!section || !grid) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!scores || scores.length === 0) {
|
|
|
|
|
|
section.style.display = 'none';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
grid.innerHTML = scores.slice(0, 12).map(s => `
|
|
|
|
|
|
<div class="rv-detail-card">
|
|
|
|
|
|
<div class="rv-detail-title">${s.symbol} ${s.name}</div>
|
|
|
|
|
|
<div class="rv-detail-metrics">
|
|
|
|
|
|
<div class="rv-detail-metric"><span>收盘价</span><span>${s.close_price || '-'}</span></div>
|
|
|
|
|
|
<div class="rv-detail-metric"><span>涨跌幅</span><span style="color: ${s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)'};">${s.change_pct >= 0 ? '+' : ''}${s.change_pct || 0}%</span></div>
|
|
|
|
|
|
<div class="rv-detail-metric"><span>振幅</span><span>${s.amplitude_pct || 0}%</span></div>
|
|
|
|
|
|
<div class="rv-detail-metric"><span>量比</span><span>${s.volume_ratio || '-'}</span></div>
|
|
|
|
|
|
<div class="rv-detail-metric"><span>60m趋势</span><span>${s.trend_60m || 0}</span></div>
|
|
|
|
|
|
<div class="rv-detail-metric"><span>15m趋势</span><span>${s.trend_15m || 0}</span></div>
|
|
|
|
|
|
<div class="rv-detail-metric"><span>5m趋势</span><span>${s.trend_5m || 0}</span></div>
|
|
|
|
|
|
<div class="rv-detail-metric"><span>综合评分</span><span style="color: ${rvScoreBarColor(s.composite_score)};">${s.composite_score}</span></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="rv-pivot-tags">
|
|
|
|
|
|
<span class="rv-pivot-tag resist">R2: ${rvFormatPrice(s.r2, s.close_price)}</span>
|
|
|
|
|
|
<span class="rv-pivot-tag resist">R1: ${rvFormatPrice(s.r1, s.close_price)}</span>
|
|
|
|
|
|
<span class="rv-pivot-tag pivot">P: ${rvFormatPrice(s.pivot, s.close_price)}</span>
|
|
|
|
|
|
<span class="rv-pivot-tag support">S1: ${rvFormatPrice(s.s1, s.close_price)}</span>
|
|
|
|
|
|
<span class="rv-pivot-tag support">S2: ${rvFormatPrice(s.s2, s.close_price)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`).join('');
|
|
|
|
|
|
|
|
|
|
|
|
section.style.display = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvRenderSectors(sectors) {
|
|
|
|
|
|
const section = document.getElementById('rv-sectors-section');
|
|
|
|
|
|
const grid = document.getElementById('rv-sectors-grid');
|
|
|
|
|
|
if (!section || !grid) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!sectors || sectors.length === 0) {
|
|
|
|
|
|
section.style.display = 'none';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
grid.innerHTML = sectors.map(sec => {
|
|
|
|
|
|
const heatIcon = sec.heat_level >= 2 ? '🔥' : sec.heat_level === 1 ? '🌡️' : '🧊';
|
|
|
|
|
|
const dirColor = sec.direction === '多头' ? 'var(--color-down)' : sec.direction === '空头' ? 'var(--color-up)' : 'var(--text-tertiary)';
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="rv-sector-card">
|
|
|
|
|
|
<div class="rv-sector-header">
|
|
|
|
|
|
<span class="rv-sector-name">${sec.sector_name}</span>
|
|
|
|
|
|
<span class="rv-sector-heat">${heatIcon}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="rv-sector-info">
|
|
|
|
|
|
均分: <strong>${sec.avg_score}</strong> |
|
|
|
|
|
|
趋势: <span style="color: ${dirColor};">${sec.avg_trend}</span> |
|
|
|
|
|
|
方向: <span style="color: ${dirColor};">${sec.direction}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="rv-sector-info">龙头: ${sec.leader_symbol} (${sec.leader_score}分)</div>
|
|
|
|
|
|
<div class="rv-sector-members">
|
|
|
|
|
|
${(sec.members || []).map(m => `<span class="rv-sector-member">${m.name} ${m.score}</span>`).join('')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
section.style.display = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvShowLoading() {
|
|
|
|
|
|
const el = document.getElementById('rv-loading');
|
|
|
|
|
|
if (el) el.classList.add('active');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvHideLoading() {
|
|
|
|
|
|
const el = document.getElementById('rv-loading');
|
|
|
|
|
|
if (el) el.classList.remove('active');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvHideAllSections() {
|
|
|
|
|
|
const contentArea = document.getElementById('rv-content-area');
|
|
|
|
|
|
if (contentArea) contentArea.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
const sections = ['rv-hero-section', 'rv-stats-grid', 'rv-risk-banner', 'rv-green-section', 'rv-yellow-section', 'rv-red-section', 'rv-table-section', 'rv-details-section', 'rv-sectors-section'];
|
|
|
|
|
|
sections.forEach(id => {
|
|
|
|
|
|
const el = document.getElementById(id);
|
|
|
|
|
|
if (el) el.style.display = 'none';
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 复盘品种详情弹窗 ====================
|
|
|
|
|
|
|
|
|
|
|
|
function rvShowDetail(symbol, name) {
|
|
|
|
|
|
const scores = rvCurrentPlanData ? rvCurrentPlanData.scores : [];
|
|
|
|
|
|
const plans = rvCurrentPlanData ? rvCurrentPlanData.plans : [];
|
|
|
|
|
|
const s = scores.find(x => x.symbol === symbol);
|
|
|
|
|
|
if (!s) return;
|
|
|
|
|
|
|
|
|
|
|
|
const plan = plans.find(p => p.symbol === symbol);
|
|
|
|
|
|
const modal = document.getElementById('rv-detail-modal');
|
|
|
|
|
|
if (!modal) return;
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById('rv-modal-title').textContent = `${symbol} ${name || s.name || ''}`;
|
|
|
|
|
|
const body = document.getElementById('rv-modal-body');
|
|
|
|
|
|
|
|
|
|
|
|
// 交易计划部分
|
|
|
|
|
|
let planHtml = '';
|
|
|
|
|
|
if (plan) {
|
|
|
|
|
|
const dirText = plan.direction === 'long' ? '做多' : '做空';
|
|
|
|
|
|
const dirColor = plan.direction === 'long' ? 'var(--color-down)' : 'var(--color-up)';
|
|
|
|
|
|
planHtml = `
|
|
|
|
|
|
<div class="rv-modal-section">
|
|
|
|
|
|
<div class="rv-modal-section-title">交易计划</div>
|
|
|
|
|
|
<div class="rv-modal-plan-box">
|
|
|
|
|
|
<div class="rv-modal-plan-row"><span class="mp-label">方向</span><span class="mp-val" style="color:${dirColor}">${dirText}</span></div>
|
|
|
|
|
|
<div class="rv-modal-plan-row"><span class="mp-label">入场区间</span><span class="mp-val">${rvFormatPrice(plan.entry_low, s.close_price)} ~ ${rvFormatPrice(plan.entry_high, s.close_price)}</span></div>
|
|
|
|
|
|
<div class="rv-modal-plan-row"><span class="mp-label">止损位</span><span class="mp-val stop">${rvFormatPrice(plan.stop_loss, s.close_price)}</span></div>
|
|
|
|
|
|
<div class="rv-modal-plan-row"><span class="mp-label">目标一</span><span class="mp-val target">${rvFormatPrice(plan.target1, s.close_price)}</span></div>
|
|
|
|
|
|
<div class="rv-modal-plan-row"><span class="mp-label">目标二</span><span class="mp-val target">${rvFormatPrice(plan.target2, s.close_price)}</span></div>
|
|
|
|
|
|
<div class="rv-modal-plan-row"><span class="mp-label">触发条件</span><span class="mp-val">${plan.trigger || '-'}</span></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const changeColor = s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)';
|
|
|
|
|
|
|
|
|
|
|
|
body.innerHTML = `
|
|
|
|
|
|
<div class="rv-modal-section">
|
|
|
|
|
|
<div class="rv-modal-section-title">基本信息</div>
|
|
|
|
|
|
<div class="rv-modal-metrics-grid">
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">收盘价</span><span class="mm-val">${s.close_price || '-'}${s.data_date ? '<span class="rv-data-date-tag">' + s.data_date + '</span>' : ''}</span></div>
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">涨跌幅</span><span class="mm-val" style="color:${changeColor}">${s.change_pct >= 0 ? '+' : ''}${s.change_pct || 0}%</span></div>
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">振幅</span><span class="mm-val">${s.amplitude_pct || 0}%</span></div>
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">量比</span><span class="mm-val">${s.volume_ratio || '-'}</span></div>
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">综合评分</span><span class="mm-val" style="color:${rvScoreBarColor(s.composite_score)}"><b>${s.composite_score}</b></span></div>
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">排名</span><span class="mm-val">#${s.rank || '-'}</span></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
${planHtml}
|
|
|
|
|
|
<div class="rv-modal-section">
|
|
|
|
|
|
<div class="rv-modal-section-title">评分明细</div>
|
|
|
|
|
|
<div class="rv-modal-metrics-grid">
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">振幅评分</span><span class="mm-val">${s.amplitude_score || 0}</span></div>
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">量能评分</span><span class="mm-val">${s.volume_score || 0}</span></div>
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">趋势评分</span><span class="mm-val">${s.trend_score || 0}</span></div>
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">活跃度</span><span class="mm-val">${s.activity_score || 0}</span></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="rv-modal-section">
|
|
|
|
|
|
<div class="rv-modal-section-title">多周期趋势</div>
|
|
|
|
|
|
<div class="rv-modal-metrics-grid">
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">60分钟</span><span class="mm-val">${s.trend_60m || 0}</span></div>
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">15分钟</span><span class="mm-val">${s.trend_15m || 0}</span></div>
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">5分钟</span><span class="mm-val">${s.trend_5m || 0}</span></div>
|
|
|
|
|
|
<div class="rv-modal-metric"><span class="mm-label">方向</span><span class="mm-val">${rvDirectionTag(s.direction_tag)}</span></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="rv-modal-section">
|
|
|
|
|
|
<div class="rv-modal-section-title">枢轴点位</div>
|
|
|
|
|
|
<div class="rv-modal-pivots">
|
|
|
|
|
|
<span class="rv-pivot-tag resist">R2: ${rvFormatPrice(s.r2, s.close_price)}</span>
|
|
|
|
|
|
<span class="rv-pivot-tag resist">R1: ${rvFormatPrice(s.r1, s.close_price)}</span>
|
|
|
|
|
|
<span class="rv-pivot-tag pivot">P: ${rvFormatPrice(s.pivot, s.close_price)}</span>
|
|
|
|
|
|
<span class="rv-pivot-tag support">S1: ${rvFormatPrice(s.s1, s.close_price)}</span>
|
|
|
|
|
|
<span class="rv-pivot-tag support">S2: ${rvFormatPrice(s.s2, s.close_price)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
modal.classList.add('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rvCloseDetailModal() {
|
|
|
|
|
|
const modal = document.getElementById('rv-detail-modal');
|
|
|
|
|
|
if (modal) modal.classList.remove('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 自选品种功能 ====================
|
|
|
|
|
|
|
|
|
|
|
|
function renderWatchedList() {
|
|
|
|
|
|
const grid = document.getElementById('watched-grid');
|
|
|
|
|
|
const emptyState = document.getElementById('watched-empty');
|
|
|
|
|
|
const totalCountEl = document.getElementById('watched-total-count');
|
|
|
|
|
|
|
|
|
|
|
|
if (watchedSymbols.length === 0) {
|
|
|
|
|
|
grid.style.display = 'none';
|
|
|
|
|
|
emptyState.style.display = 'flex';
|
|
|
|
|
|
totalCountEl.textContent = '0 个品种';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
grid.style.display = 'grid';
|
|
|
|
|
|
emptyState.style.display = 'none';
|
|
|
|
|
|
totalCountEl.textContent = `${watchedSymbols.length} 个品种`;
|
|
|
|
|
|
|
|
|
|
|
|
const watchedData = allFuturesData.filter(item => watchedSymbols.includes(item.symbol));
|
|
|
|
|
|
|
|
|
|
|
|
grid.innerHTML = watchedData.map(item => {
|
|
|
|
|
|
const code = item.symbol.replace(/[0-9]/g, '').substring(0, 2);
|
|
|
|
|
|
const changeIcon = item.change >= 0 ? '↑' : '↓';
|
|
|
|
|
|
const changeSign = item.change >= 0 ? '+' : '';
|
|
|
|
|
|
const pctSign = item.changePct >= 0 ? '+' : '';
|
|
|
|
|
|
|
|
|
|
|
|
let actionPillClass = 'watch';
|
|
|
|
|
|
let actionPillText = '观望';
|
|
|
|
|
|
if (item.suggestion?.includes('做多') || item.suggestion?.includes('试多')) {
|
|
|
|
|
|
actionPillClass = 'do-more';
|
|
|
|
|
|
actionPillText = '做多';
|
|
|
|
|
|
} else if (item.suggestion?.includes('做空') || item.suggestion?.includes('试空')) {
|
|
|
|
|
|
actionPillClass = 'do-more';
|
|
|
|
|
|
actionPillText = '做空';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const priceClass = item.change >= 0 ? 'up' : 'down';
|
|
|
|
|
|
const changeClass = item.change >= 0 ? 'up' : 'down';
|
|
|
|
|
|
|
|
|
|
|
|
const successRate = item.successRate || 0;
|
|
|
|
|
|
const trendScore = item.trendScore || 0;
|
|
|
|
|
|
const successColor = successRate >= 70 ? 'var(--color-down)' : successRate >= 60 ? 'var(--color-neutral)' : 'var(--color-up)';
|
|
|
|
|
|
const trendColor = trendScore >= 70 ? 'var(--color-down)' : trendScore >= 50 ? 'var(--color-neutral)' : 'var(--color-up)';
|
|
|
|
|
|
|
|
|
|
|
|
const periods = item.periods || {};
|
|
|
|
|
|
const resistance = item.resistance ? formatNumber(item.resistance) : '--';
|
|
|
|
|
|
const support = item.support ? formatNumber(item.support) : '--';
|
|
|
|
|
|
|
|
|
|
|
|
const isWatched = true;
|
|
|
|
|
|
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div class="card" onclick="showDetailView('${item.symbol}')">
|
|
|
|
|
|
<div class="card-header">
|
|
|
|
|
|
<div class="card-left">
|
|
|
|
|
|
<div class="code-box">${code}</div>
|
|
|
|
|
|
<div class="info-group">
|
|
|
|
|
|
<div class="name-row">${item.name} <span class="fav-icon active" onclick="toggleFav('${item.symbol}', '${item.name}', event)">★</span></div>
|
|
|
|
|
|
<div class="contract-code">${item.symbol}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="price-area">
|
|
|
|
|
|
<div class="price ${priceClass}">¥${formatNumber(item.price)}</div>
|
|
|
|
|
|
<div class="change ${changeClass}">${changeIcon} ${changeSign}${formatNumber(item.change)} (${pctSign}${item.changePct.toFixed(2)}%)</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="action-pill ${actionPillClass}">${actionPillText}</div>
|
|
|
|
|
|
<div class="metrics">
|
|
|
|
|
|
<div class="metric">
|
|
|
|
|
|
<div class="metric-label">成功率 ${successRate}%</div>
|
|
|
|
|
|
<div class="bar-bg"><div class="bar-fill" style="width:${successRate}%; background:${successColor};"></div></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="metric">
|
|
|
|
|
|
<div class="metric-label">趋势评分 ${trendScore}</div>
|
|
|
|
|
|
<div class="bar-bg"><div class="bar-fill" style="width:${trendScore}%; background:${trendColor};"></div></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="timeframes">
|
|
|
|
|
|
<div class="tf ${periods['5'] === 'up' ? 'active' : ''}">5M</div>
|
|
|
|
|
|
<div class="tf ${periods['15'] === 'up' ? 'active' : ''}">15M</div>
|
|
|
|
|
|
<div class="tf ${periods['30'] === 'up' ? 'active' : ''}">30M</div>
|
|
|
|
|
|
<div class="tf ${periods['60'] === 'up' ? 'active' : ''}">1H</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card-footer">
|
|
|
|
|
|
<div class="support-resist">
|
|
|
|
|
|
<span>压力 <b class="red">${resistance}</b></span>
|
|
|
|
|
|
<span>支撑 <b class="green">${support}</b></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<a href="#" class="link" onclick="event.stopPropagation(); showDetailView('${item.symbol}'); return false;">详情 →</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|