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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user