8ebad6e8a2
Calendar shows daily closed trade count and PnL with emotion-day highlighting; day click loads review-first trade list. Use exchange-only entry average and improve vnpy position sync after CTP reconnect.
442 lines
17 KiB
JavaScript
442 lines
17 KiB
JavaScript
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||
* 详见 LICENSE.zh-CN.txt
|
||
*/
|
||
(function () {
|
||
var cache = null;
|
||
|
||
function fmtNum(v, suffix) {
|
||
if (v === null || v === undefined || v === '') return '-';
|
||
var n = Number(v);
|
||
if (isNaN(n)) return String(v);
|
||
var s = Number.isInteger(n) ? String(n) : n.toFixed(2);
|
||
return suffix ? s + suffix : s;
|
||
}
|
||
|
||
function fmtMoney(v) {
|
||
if (v === null || v === undefined) return '-';
|
||
return fmtNum(v) + ' 元';
|
||
}
|
||
|
||
function fmtPct(v) {
|
||
if (v === null || v === undefined) return '-';
|
||
return fmtNum(v) + '%';
|
||
}
|
||
|
||
function setSummary(s) {
|
||
var map = {
|
||
total_trades: function () { return fmtNum(s.total_trades); },
|
||
win_rate: function () { return fmtPct(s.win_rate); },
|
||
avg_profit: function () { return fmtMoney(s.avg_profit); },
|
||
avg_loss: function () { return fmtMoney(s.avg_loss); },
|
||
profit_loss_ratio: function () { return fmtNum(s.profit_loss_ratio); },
|
||
consecutive_losses: function () { return fmtNum(s.consecutive_losses); },
|
||
max_drawdown: function () {
|
||
var amt = fmtMoney(s.max_drawdown);
|
||
var pct = s.max_drawdown_pct ? ' (' + fmtPct(s.max_drawdown_pct) + ')' : '';
|
||
return amt + pct;
|
||
},
|
||
max_loss_amount: function () { return fmtMoney(s.max_loss_amount); },
|
||
max_loss_pct: function () { return fmtPct(s.max_loss_pct); },
|
||
max_profit_amount: function () { return fmtMoney(s.max_profit_amount); },
|
||
max_profit_pct: function () { return fmtPct(s.max_profit_pct); },
|
||
total_fee: function () { return fmtMoney(s.total_fee); },
|
||
emotion_count: function () { return fmtNum(s.emotion_count); },
|
||
emotion_ratio: function () { return fmtPct(s.emotion_ratio); },
|
||
};
|
||
document.querySelectorAll('#stats-summary [data-k]').forEach(function (el) {
|
||
var key = el.getAttribute('data-k');
|
||
el.textContent = map[key] ? map[key]() : '-';
|
||
});
|
||
}
|
||
|
||
function fillViewSelect(views, selected) {
|
||
var sel = document.getElementById('stats-view-select');
|
||
if (!sel) return;
|
||
sel.innerHTML = '';
|
||
views.forEach(function (v) {
|
||
var opt = document.createElement('option');
|
||
opt.value = v.key;
|
||
opt.textContent = v.label;
|
||
if (v.key === selected) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function cellClass(key, val) {
|
||
if (key === 'total_net' || key === 'max_profit' || key === 'avg_profit') {
|
||
if (val > 0) return 'text-profit';
|
||
if (val < 0) return 'text-loss';
|
||
}
|
||
if (key === 'max_loss' || key === 'avg_loss' || key === 'total_fee') {
|
||
return 'text-loss';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function renderBreakdown(key) {
|
||
if (!cache || !cache.breakdowns) return;
|
||
var block = cache.breakdowns[key];
|
||
var head = document.getElementById('stats-breakdown-head');
|
||
var body = document.getElementById('stats-breakdown-body');
|
||
if (!block || !head || !body) return;
|
||
|
||
head.innerHTML = '';
|
||
block.columns.forEach(function (col) {
|
||
var th = document.createElement('th');
|
||
th.textContent = col.label;
|
||
head.appendChild(th);
|
||
});
|
||
|
||
body.innerHTML = '';
|
||
if (!block.rows || !block.rows.length) {
|
||
var tr = document.createElement('tr');
|
||
var td = document.createElement('td');
|
||
td.colSpan = block.columns.length;
|
||
td.className = 'text-muted';
|
||
td.textContent = '暂无数据';
|
||
tr.appendChild(td);
|
||
body.appendChild(tr);
|
||
return;
|
||
}
|
||
|
||
block.rows.forEach(function (row) {
|
||
var tr = document.createElement('tr');
|
||
block.columns.forEach(function (col) {
|
||
var td = document.createElement('td');
|
||
var val = row[col.key];
|
||
if (col.key === 'win_rate') {
|
||
td.textContent = fmtPct(val);
|
||
} else if (col.key === 'label') {
|
||
td.textContent = val || '-';
|
||
} else if (typeof val === 'number') {
|
||
td.textContent = fmtNum(val);
|
||
td.className = cellClass(col.key, val);
|
||
} else {
|
||
td.textContent = val != null ? val : '-';
|
||
}
|
||
tr.appendChild(td);
|
||
});
|
||
body.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
function applyData(data) {
|
||
cache = data;
|
||
setSummary(data.summary || {});
|
||
var views = data.views || [];
|
||
var sel = document.getElementById('stats-view-select');
|
||
var current = sel && sel.value ? sel.value : (views[0] && views[0].key);
|
||
fillViewSelect(views, current);
|
||
renderBreakdown(current);
|
||
var updated = document.getElementById('stats-updated');
|
||
if (updated) {
|
||
updated.textContent = data.updated_at
|
||
? '统计更新于 ' + data.updated_at.replace('T', ' ')
|
||
: '统计已加载';
|
||
}
|
||
}
|
||
|
||
function loadStats() {
|
||
fetch('/api/stats', { credentials: 'same-origin' })
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
return r.json();
|
||
})
|
||
.then(applyData)
|
||
.catch(function () {
|
||
var updated = document.getElementById('stats-updated');
|
||
if (updated) updated.textContent = '加载失败,请刷新页面';
|
||
});
|
||
}
|
||
|
||
var calYear = 0;
|
||
var calMonth = 0;
|
||
var calDays = [];
|
||
var selectedDate = '';
|
||
|
||
function pad2(n) {
|
||
return n < 10 ? '0' + n : String(n);
|
||
}
|
||
|
||
function todayIso() {
|
||
var d = new Date();
|
||
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
|
||
}
|
||
|
||
function pnlClass(v) {
|
||
if (v > 0) return 'is-profit';
|
||
if (v < 0) return 'is-loss';
|
||
return 'is-flat';
|
||
}
|
||
|
||
function fmtPnlShort(v) {
|
||
if (v === null || v === undefined) return '-';
|
||
var n = Number(v);
|
||
if (isNaN(n)) return '-';
|
||
var s = Number.isInteger(n) ? String(n) : n.toFixed(0);
|
||
return (n > 0 ? '+' : '') + s;
|
||
}
|
||
|
||
function fmtTime(v) {
|
||
if (!v) return '-';
|
||
return String(v).replace('T', ' ').slice(0, 16);
|
||
}
|
||
|
||
function fmtTags(item) {
|
||
var tags = item.behavior_tags || '';
|
||
if (item.is_emotion) {
|
||
return tags ? '情绪单 · ' + tags : '情绪单';
|
||
}
|
||
return tags || '';
|
||
}
|
||
|
||
function setCalendarTitle() {
|
||
var title = document.getElementById('stats-cal-title');
|
||
if (title) title.textContent = calYear + '年' + calMonth + '月';
|
||
}
|
||
|
||
function renderCalendar(data) {
|
||
var grid = document.getElementById('stats-calendar-grid');
|
||
if (!grid) return;
|
||
calDays = data.days || [];
|
||
setCalendarTitle();
|
||
var html = '';
|
||
var pad = data.weekday_start || 0;
|
||
var i;
|
||
for (i = 0; i < pad; i++) {
|
||
html += '<div class="stats-cal-cell is-empty"></div>';
|
||
}
|
||
calDays.forEach(function (day) {
|
||
var dayNum = day.date.slice(8, 10).replace(/^0/, '');
|
||
var classes = ['stats-cal-cell'];
|
||
if (day.count > 0) classes.push('is-clickable');
|
||
if (day.date === todayIso()) classes.push('is-today');
|
||
if (day.date === selectedDate) classes.push('is-selected');
|
||
if (day.has_emotion) classes.push('is-emotion');
|
||
html += '<div class="' + classes.join(' ') + '" data-date="' + day.date + '" role="button" tabindex="' + (day.count > 0 ? '0' : '-1') + '">';
|
||
html += '<div class="stats-cal-day-num">' + dayNum + '</div>';
|
||
if (day.count > 0) {
|
||
html += '<div class="stats-cal-meta"><div class="stats-cal-count">' + day.count + ' 笔</div>';
|
||
html += '<div class="stats-cal-pnl ' + pnlClass(day.total_net) + '">' + fmtPnlShort(day.total_net) + '</div>';
|
||
if (day.has_emotion) {
|
||
html += '<span class="stats-cal-emotion">情绪' + (day.emotion_count > 1 ? '×' + day.emotion_count : '') + '</span>';
|
||
}
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
});
|
||
grid.innerHTML = html;
|
||
}
|
||
|
||
function loadCalendar() {
|
||
fetch('/api/stats/calendar?year=' + calYear + '&month=' + calMonth, { credentials: 'same-origin' })
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
renderCalendar(data);
|
||
if (selectedDate && selectedDate.slice(0, 7) === calYear + '-' + pad2(calMonth)) {
|
||
loadDayDetail(selectedDate, false);
|
||
} else {
|
||
hideDayDetail();
|
||
}
|
||
})
|
||
.catch(function () {
|
||
var grid = document.getElementById('stats-calendar-grid');
|
||
if (grid) grid.innerHTML = '<div class="text-muted" style="grid-column:1/-1">日历加载失败</div>';
|
||
});
|
||
}
|
||
|
||
function hideDayDetail() {
|
||
var panel = document.getElementById('stats-day-detail');
|
||
if (panel) panel.hidden = true;
|
||
}
|
||
|
||
function renderDayDetail(data) {
|
||
var panel = document.getElementById('stats-day-detail');
|
||
var title = document.getElementById('stats-day-detail-title');
|
||
var summary = document.getElementById('stats-day-summary');
|
||
var list = document.getElementById('stats-day-list');
|
||
if (!panel || !list) return;
|
||
|
||
var label = data.date.replace(/-/g, '/');
|
||
if (title) title.textContent = label + ' 交易记录';
|
||
if (summary) {
|
||
var parts = [data.count + ' 笔', '净盈亏 ' + fmtMoney(data.total_net)];
|
||
if (data.emotion_count) parts.push('情绪单 ' + data.emotion_count);
|
||
summary.textContent = parts.join(' · ');
|
||
}
|
||
|
||
list.innerHTML = '';
|
||
if (!data.items || !data.items.length) {
|
||
list.innerHTML = '<div class="text-muted">当日无平仓记录</div>';
|
||
panel.hidden = false;
|
||
return;
|
||
}
|
||
|
||
data.items.forEach(function (item) {
|
||
var card = document.createElement('div');
|
||
card.className = 'stats-day-item' + (item.is_emotion ? ' is-emotion' : '');
|
||
|
||
var head = document.createElement('div');
|
||
head.className = 'stats-day-item-head';
|
||
var sym = document.createElement('div');
|
||
sym.className = 'stats-day-item-symbol';
|
||
sym.textContent = (item.symbol || item.symbol_code || '-') + ' · ' + (item.direction || '-');
|
||
var pnl = document.createElement('div');
|
||
pnl.className = 'stats-day-item-pnl ' + pnlClass(item.pnl_net);
|
||
pnl.textContent = fmtMoney(item.pnl_net);
|
||
head.appendChild(sym);
|
||
head.appendChild(pnl);
|
||
|
||
var meta = document.createElement('div');
|
||
meta.className = 'stats-day-item-meta';
|
||
var badges = '';
|
||
if (item.source === 'review') {
|
||
badges += '<span class="stats-day-badge review">复盘</span>';
|
||
}
|
||
if (item.is_emotion) {
|
||
badges += '<span class="stats-day-badge emotion">情绪单</span>';
|
||
}
|
||
var metaParts = [
|
||
badges,
|
||
'平仓 ' + fmtTime(item.close_time),
|
||
item.lots != null ? item.lots + ' 手' : '',
|
||
item.entry_price != null ? '开 ' + item.entry_price : '',
|
||
item.close_price != null ? '平 ' + item.close_price : '',
|
||
];
|
||
if (item.source === 'review' && item.open_type) metaParts.push(item.open_type);
|
||
if (item.source === 'review' && item.exit_trigger) metaParts.push('出场: ' + item.exit_trigger);
|
||
if (item.result) metaParts.push(item.result);
|
||
meta.innerHTML = metaParts.filter(Boolean).join(' · ');
|
||
|
||
card.appendChild(head);
|
||
card.appendChild(meta);
|
||
|
||
var tags = fmtTags(item);
|
||
if (tags) {
|
||
var tagEl = document.createElement('div');
|
||
tagEl.className = 'stats-day-item-notes';
|
||
tagEl.textContent = tags;
|
||
card.appendChild(tagEl);
|
||
}
|
||
if (item.notes) {
|
||
var notes = document.createElement('div');
|
||
notes.className = 'stats-day-item-notes';
|
||
notes.textContent = item.notes;
|
||
card.appendChild(notes);
|
||
}
|
||
if (item.screenshot) {
|
||
var shot = document.createElement('div');
|
||
shot.className = 'stats-day-item-shot';
|
||
shot.innerHTML = '<img src="/uploads/' + item.screenshot + '" alt="复盘截图">';
|
||
card.appendChild(shot);
|
||
}
|
||
list.appendChild(card);
|
||
});
|
||
panel.hidden = false;
|
||
}
|
||
|
||
function loadDayDetail(dateStr, scroll) {
|
||
if (scroll === undefined) scroll = true;
|
||
selectedDate = dateStr;
|
||
document.querySelectorAll('.stats-cal-cell.is-selected').forEach(function (el) {
|
||
el.classList.remove('is-selected');
|
||
});
|
||
var cell = document.querySelector('.stats-cal-cell[data-date="' + dateStr + '"]');
|
||
if (cell) cell.classList.add('is-selected');
|
||
|
||
fetch('/api/stats/calendar/day?date=' + encodeURIComponent(dateStr), { credentials: 'same-origin' })
|
||
.then(function (r) {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
renderDayDetail(data);
|
||
if (scroll) {
|
||
var panel = document.getElementById('stats-day-detail');
|
||
if (panel) panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
}
|
||
})
|
||
.catch(function () {
|
||
var panel = document.getElementById('stats-day-detail');
|
||
var list = document.getElementById('stats-day-list');
|
||
if (panel && list) {
|
||
list.innerHTML = '<div class="text-muted">加载失败</div>';
|
||
panel.hidden = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
function shiftCalendarMonth(delta) {
|
||
calMonth += delta;
|
||
if (calMonth > 12) {
|
||
calMonth = 1;
|
||
calYear += 1;
|
||
} else if (calMonth < 1) {
|
||
calMonth = 12;
|
||
calYear -= 1;
|
||
}
|
||
loadCalendar();
|
||
}
|
||
|
||
function bindCalendar() {
|
||
var grid = document.getElementById('stats-calendar-grid');
|
||
if (!grid || grid.dataset.statsCalBound) return;
|
||
grid.dataset.statsCalBound = '1';
|
||
|
||
grid.addEventListener('click', function (e) {
|
||
var cell = e.target.closest('.stats-cal-cell.is-clickable');
|
||
if (!cell) return;
|
||
loadDayDetail(cell.getAttribute('data-date'));
|
||
});
|
||
grid.addEventListener('keydown', function (e) {
|
||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||
var cell = e.target.closest('.stats-cal-cell.is-clickable');
|
||
if (!cell) return;
|
||
e.preventDefault();
|
||
loadDayDetail(cell.getAttribute('data-date'));
|
||
});
|
||
|
||
var prev = document.getElementById('stats-cal-prev');
|
||
var next = document.getElementById('stats-cal-next');
|
||
var todayBtn = document.getElementById('stats-cal-today');
|
||
if (prev) prev.addEventListener('click', function () { shiftCalendarMonth(-1); });
|
||
if (next) next.addEventListener('click', function () { shiftCalendarMonth(1); });
|
||
if (todayBtn) todayBtn.addEventListener('click', function () {
|
||
var d = new Date();
|
||
calYear = d.getFullYear();
|
||
calMonth = d.getMonth() + 1;
|
||
loadCalendar();
|
||
});
|
||
}
|
||
|
||
function initCalendar() {
|
||
if (!document.getElementById('stats-calendar-grid')) return;
|
||
var d = new Date();
|
||
calYear = d.getFullYear();
|
||
calMonth = d.getMonth() + 1;
|
||
bindCalendar();
|
||
loadCalendar();
|
||
}
|
||
|
||
function bootStatsPage() {
|
||
if (!document.getElementById('stats-summary')) return;
|
||
var viewSel = document.getElementById('stats-view-select');
|
||
if (viewSel && !viewSel.dataset.statsBound) {
|
||
viewSel.dataset.statsBound = '1';
|
||
viewSel.addEventListener('change', function () {
|
||
renderBreakdown(this.value);
|
||
});
|
||
}
|
||
loadStats();
|
||
initCalendar();
|
||
}
|
||
|
||
if (window.qihuoPageBoot) window.qihuoPageBoot(bootStatsPage, '#stats-summary');
|
||
else if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootStatsPage);
|
||
else bootStatsPage();
|
||
})();
|