/* 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 += '
'; } 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 += '
'; html += '
' + dayNum + '
'; if (day.count > 0) { html += '
' + day.count + ' 笔
'; html += '
' + fmtPnlShort(day.total_net) + '
'; if (day.has_emotion) { html += '情绪' + (day.emotion_count > 1 ? '×' + day.emotion_count : '') + ''; } html += '
'; } html += '
'; }); 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 = '
日历加载失败
'; }); } 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 = '
当日无平仓记录
'; 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 += '复盘'; } if (item.is_emotion) { badges += '情绪单'; } 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 = '复盘截图'; 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 = '
加载失败
'; 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(); })();