Add stats trading calendar and fix CTP position avg/sync.

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.
This commit is contained in:
dekun
2026-06-30 11:59:25 +08:00
parent d07fc4b70d
commit 8ebad6e8a2
8 changed files with 926 additions and 198 deletions
+273
View File
@@ -150,6 +150,278 @@
});
}
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');
@@ -160,6 +432,7 @@
});
}
loadStats();
initCalendar();
}
if (window.qihuoPageBoot) window.qihuoPageBoot(bootStatsPage, '#stats-summary');