e8b4dbbaca
新增 stats_engine 与 stats_cache,提供 API 自动加载 8 种统计维度;交易与复盘变更时自动刷新缓存。 Co-authored-by: Cursor <cursoragent@cursor.com>
174 lines
6.7 KiB
JavaScript
174 lines
6.7 KiB
JavaScript
(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')
|
|
.then(function (r) { return r.json(); })
|
|
.then(applyData)
|
|
.catch(function () {
|
|
var updated = document.getElementById('stats-updated');
|
|
if (updated) updated.textContent = '加载失败,请刷新页面';
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
var viewSel = document.getElementById('stats-view-select');
|
|
var refreshBtn = document.getElementById('stats-refresh-btn');
|
|
if (viewSel) {
|
|
viewSel.addEventListener('change', function () {
|
|
renderBreakdown(this.value);
|
|
});
|
|
}
|
|
if (refreshBtn) {
|
|
refreshBtn.addEventListener('click', function () {
|
|
var btn = this;
|
|
btn.disabled = true;
|
|
btn.textContent = '计算中…';
|
|
fetch('/api/stats/refresh', { method: 'POST' })
|
|
.then(function (r) { return r.json(); })
|
|
.then(applyData)
|
|
.catch(function () {
|
|
alert('重新计算失败');
|
|
})
|
|
.finally(function () {
|
|
btn.disabled = false;
|
|
btn.textContent = '重新计算';
|
|
});
|
|
});
|
|
}
|
|
loadStats();
|
|
});
|
|
})();
|