feat(hub): add symbol archive with permanent 5m klines
Add /archive page, hub_symbol_archive.db, trade overlay, 4h background sync, and instance /api/hub/trades/archive. Document in hub-symbol-archive-kline.md with cross-links. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3852,3 +3852,193 @@ body.hub-page-ai #page-ai {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* —— 币种档案 —— */
|
||||
.archive-toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.archive-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.archive-field select,
|
||||
.archive-field input {
|
||||
min-width: 120px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--inset-surface);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
}
|
||||
.archive-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
min-height: 520px;
|
||||
}
|
||||
.archive-list-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 200px);
|
||||
}
|
||||
.archive-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.archive-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 2px 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
}
|
||||
.archive-row:hover,
|
||||
.archive-row.is-active {
|
||||
background: var(--inset-surface);
|
||||
}
|
||||
.archive-row-sym {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.archive-row-ex {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.archive-row-stat {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.archive-row-meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--accent);
|
||||
align-self: start;
|
||||
}
|
||||
.archive-detail-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.archive-detail-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
.archive-detail-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.archive-detail-stats {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.archive-chart-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.archive-tf-tabs {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.archive-tf-btn {
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--inset-surface);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.archive-tf-btn.is-active {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
.archive-chart-host {
|
||||
height: 360px;
|
||||
min-height: 280px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
.archive-trades {
|
||||
overflow: auto;
|
||||
max-height: 280px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
}
|
||||
.archive-trades-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.archive-trades-table th,
|
||||
.archive-trades-table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
text-align: left;
|
||||
}
|
||||
.archive-trades-table th {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--panel);
|
||||
}
|
||||
.archive-trade-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
.archive-trade-row.is-active {
|
||||
background: var(--inset-surface);
|
||||
}
|
||||
.archive-trades-table td.pos {
|
||||
color: #22c55e;
|
||||
}
|
||||
.archive-trades-table td.neg {
|
||||
color: #ef4444;
|
||||
}
|
||||
.archive-tag-select,
|
||||
.archive-note-input {
|
||||
width: 100%;
|
||||
max-width: 140px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--inset-surface);
|
||||
color: var(--text);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.archive-empty {
|
||||
padding: 16px;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.archive-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.archive-list-panel {
|
||||
max-height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -624,6 +624,7 @@
|
||||
function currentPage() {
|
||||
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
|
||||
if (p.includes("settings")) return "settings";
|
||||
if (p.includes("archive")) return "archive";
|
||||
if (p.includes("market")) return "market";
|
||||
if (p.includes("/ai")) return "ai";
|
||||
return "monitor";
|
||||
@@ -631,6 +632,7 @@
|
||||
|
||||
function pageElementId(page) {
|
||||
if (page === "settings") return "page-settings";
|
||||
if (page === "archive") return "page-archive";
|
||||
if (page === "market") return "page-market";
|
||||
if (page === "ai") return "page-ai";
|
||||
return "page-monitor";
|
||||
@@ -654,6 +656,11 @@
|
||||
else stopMonitorPoll();
|
||||
if (page === "settings") loadSettingsUI();
|
||||
if (page === "ai") loadAiPage();
|
||||
if (page === "archive" && window.hubArchivePage) {
|
||||
window.hubArchivePage.init();
|
||||
} else if (window.hubArchivePage && window.hubArchivePage.destroy) {
|
||||
window.hubArchivePage.destroy();
|
||||
}
|
||||
if (page === "market" && window.hubMarketChart) {
|
||||
window.hubMarketChart.init();
|
||||
} else if (window.hubMarketChart) {
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* 中控币种档案:列表筛选、交易时间线、永久 K 线(lightweight-charts)。
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-archive");
|
||||
if (!page) return;
|
||||
|
||||
const elExchange = document.getElementById("archive-exchange");
|
||||
const elFilterProfit = document.getElementById("archive-filter-profit");
|
||||
const elFilterLoss = document.getElementById("archive-filter-loss");
|
||||
const elFilterSick = document.getElementById("archive-filter-sick");
|
||||
const elFilterEmotion = document.getElementById("archive-filter-emotion");
|
||||
const elBtnRefresh = document.getElementById("archive-btn-refresh");
|
||||
const elBtnSync = document.getElementById("archive-btn-sync");
|
||||
const elStatus = document.getElementById("archive-status");
|
||||
const elList = document.getElementById("archive-list");
|
||||
const elDetailPanel = document.getElementById("archive-detail-panel");
|
||||
const elDetailTitle = document.getElementById("archive-detail-title");
|
||||
const elDetailStats = document.getElementById("archive-detail-stats");
|
||||
const elTfTabs = document.getElementById("archive-tf-tabs");
|
||||
const elViewMode = document.getElementById("archive-view-mode");
|
||||
const elJumpAt = document.getElementById("archive-jump-at");
|
||||
const elBtnJump = document.getElementById("archive-btn-jump");
|
||||
const elBtnReloadChart = document.getElementById("archive-btn-reload-chart");
|
||||
const elChartHost = document.getElementById("archive-chart");
|
||||
const elTrades = document.getElementById("archive-trades");
|
||||
|
||||
const TF_MS = {
|
||||
"5m": 5 * 60_000,
|
||||
"15m": 15 * 60_000,
|
||||
"1h": 60 * 60_000,
|
||||
"4h": 4 * 60 * 60_000,
|
||||
};
|
||||
|
||||
let meta = null;
|
||||
let listRows = [];
|
||||
let selected = null;
|
||||
let trades = [];
|
||||
let selectedTradeId = null;
|
||||
let timeframe = "15m";
|
||||
let chart = null;
|
||||
let candleSeries = null;
|
||||
let volumeSeries = null;
|
||||
let inited = false;
|
||||
|
||||
function fmt(n, d) {
|
||||
if (n == null || n === "" || !Number.isFinite(Number(n))) return "—";
|
||||
return Number(n).toFixed(d == null ? 2 : d);
|
||||
}
|
||||
|
||||
function fmtPnl(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
const s = (n >= 0 ? "+" : "") + n.toFixed(2);
|
||||
return s;
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || Math.abs(n) < 1e-6) return "";
|
||||
return n > 0 ? "pos" : "neg";
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
if (elStatus) elStatus.textContent = text || "";
|
||||
}
|
||||
|
||||
async function apiFetch(url, opts) {
|
||||
const r = await fetch(url, opts);
|
||||
if (r.status === 401) {
|
||||
location.href = "/login?next=" + encodeURIComponent(location.pathname);
|
||||
throw new Error("未登录");
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
function queryListParams() {
|
||||
const q = new URLSearchParams();
|
||||
const ex = (elExchange && elExchange.value) || "";
|
||||
if (ex) q.set("exchange_key", ex);
|
||||
if (elFilterProfit && elFilterProfit.checked) q.set("filter_profit", "1");
|
||||
if (elFilterLoss && elFilterLoss.checked) q.set("filter_loss", "1");
|
||||
if (elFilterSick && elFilterSick.checked) q.set("filter_sick", "1");
|
||||
if (elFilterEmotion && elFilterEmotion.checked) q.set("filter_emotion", "1");
|
||||
return q.toString();
|
||||
}
|
||||
|
||||
function renderExchangeOptions() {
|
||||
if (!elExchange || !meta) return;
|
||||
const cur = elExchange.value;
|
||||
elExchange.innerHTML = '<option value="">全部</option>';
|
||||
(meta.exchanges || []).forEach(function (ex) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = ex.key || "";
|
||||
opt.textContent = (ex.name || ex.key || "") + " (" + (ex.key || "") + ")";
|
||||
elExchange.appendChild(opt);
|
||||
});
|
||||
if (cur) elExchange.value = cur;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!elList) return;
|
||||
if (!listRows.length) {
|
||||
elList.innerHTML = '<p class="archive-empty">暂无档案数据。点击「同步交易与 K 线」从四所拉取。</p>';
|
||||
return;
|
||||
}
|
||||
elList.innerHTML = listRows
|
||||
.map(function (row) {
|
||||
const active =
|
||||
selected &&
|
||||
selected.exchange_key === row.exchange_key &&
|
||||
selected.symbol === row.symbol
|
||||
? " is-active"
|
||||
: "";
|
||||
const seed = row.seed_complete ? "已建档" : "待种子";
|
||||
return (
|
||||
'<button type="button" class="archive-row' +
|
||||
active +
|
||||
'" data-ex="' +
|
||||
row.exchange_key +
|
||||
'" data-sym="' +
|
||||
row.symbol +
|
||||
'">' +
|
||||
'<span class="archive-row-sym">' +
|
||||
row.symbol +
|
||||
"</span>" +
|
||||
'<span class="archive-row-ex">' +
|
||||
row.exchange_key +
|
||||
"</span>" +
|
||||
'<span class="archive-row-stat">' +
|
||||
row.trade_count +
|
||||
" 笔 · " +
|
||||
fmtPnl(row.total_pnl) +
|
||||
" U</span>" +
|
||||
'<span class="archive-row-meta">' +
|
||||
seed +
|
||||
"</span>" +
|
||||
"</button>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
elList.querySelectorAll(".archive-row").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
openDetail(btn.getAttribute("data-ex"), btn.getAttribute("data-sym"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function pickAnchorTrade() {
|
||||
if (!trades.length) return null;
|
||||
if (selectedTradeId != null) {
|
||||
const hit = trades.find(function (t) {
|
||||
return String(t.trade_id || t.id) === String(selectedTradeId);
|
||||
});
|
||||
if (hit) return hit;
|
||||
}
|
||||
return trades[0];
|
||||
}
|
||||
|
||||
function anchorMsForTrade(tr) {
|
||||
if (!tr) return null;
|
||||
const mode = (elViewMode && elViewMode.value) || "hold";
|
||||
if (mode === "entry") {
|
||||
return tr.opened_at_ms || null;
|
||||
}
|
||||
return tr.closed_at_ms || tr.opened_at_ms || null;
|
||||
}
|
||||
|
||||
function destroyChart() {
|
||||
if (chart) {
|
||||
chart.remove();
|
||||
chart = null;
|
||||
candleSeries = null;
|
||||
volumeSeries = null;
|
||||
}
|
||||
if (elChartHost) elChartHost.innerHTML = "";
|
||||
}
|
||||
|
||||
function ensureChart() {
|
||||
if (!elChartHost || !window.LightweightCharts) return;
|
||||
if (chart) return;
|
||||
const isDark = document.documentElement.getAttribute("data-theme") !== "light";
|
||||
chart = LightweightCharts.createChart(elChartHost, {
|
||||
layout: {
|
||||
background: { color: isDark ? "#0b0e18" : "#f8f9fc" },
|
||||
textColor: isDark ? "#9aa4b8" : "#4a5568",
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
|
||||
horzLines: { color: isDark ? "#1a2030" : "#e8ecf2" },
|
||||
},
|
||||
rightPriceScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2" },
|
||||
timeScale: { borderColor: isDark ? "#2a3348" : "#d0d7e2", timeVisible: true },
|
||||
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||||
});
|
||||
candleSeries = chart.addCandlestickSeries({
|
||||
upColor: "#22c55e",
|
||||
downColor: "#ef4444",
|
||||
borderVisible: false,
|
||||
wickUpColor: "#22c55e",
|
||||
wickDownColor: "#ef4444",
|
||||
});
|
||||
volumeSeries = chart.addHistogramSeries({
|
||||
color: "#3b82f680",
|
||||
priceFormat: { type: "volume" },
|
||||
priceScaleId: "",
|
||||
});
|
||||
volumeSeries.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.82, bottom: 0 },
|
||||
});
|
||||
new ResizeObserver(function () {
|
||||
if (chart && elChartHost) {
|
||||
chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight });
|
||||
}
|
||||
}).observe(elChartHost);
|
||||
chart.applyOptions({ width: elChartHost.clientWidth, height: elChartHost.clientHeight });
|
||||
}
|
||||
|
||||
async function loadChart() {
|
||||
if (!selected) return;
|
||||
const tr = pickAnchorTrade();
|
||||
const anchor = anchorMsForTrade(tr);
|
||||
const jump = (elJumpAt && elJumpAt.value || "").trim();
|
||||
const params = new URLSearchParams({
|
||||
exchange_key: selected.exchange_key,
|
||||
symbol: selected.symbol,
|
||||
timeframe: timeframe,
|
||||
mode: (elViewMode && elViewMode.value) || "hold",
|
||||
bars: "200",
|
||||
});
|
||||
if (jump) params.set("at", jump);
|
||||
else if (anchor) params.set("anchor_ms", String(anchor));
|
||||
setStatus("加载 K 线…");
|
||||
const r = await apiFetch("/api/archive/ohlcv?" + params.toString());
|
||||
const j = await r.json();
|
||||
if (!r.ok) {
|
||||
setStatus(j.detail || "K 线加载失败");
|
||||
return;
|
||||
}
|
||||
ensureChart();
|
||||
const candles = j.candles || [];
|
||||
candleSeries.setData(
|
||||
candles.map(function (c) {
|
||||
return { time: c.time, open: c.open, high: c.high, low: c.low, close: c.close };
|
||||
})
|
||||
);
|
||||
volumeSeries.setData(
|
||||
candles.map(function (c) {
|
||||
return {
|
||||
time: c.time,
|
||||
value: c.volume || 0,
|
||||
color: c.close >= c.open ? "#22c55e55" : "#ef444455",
|
||||
};
|
||||
})
|
||||
);
|
||||
if (candles.length > 10) {
|
||||
chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 });
|
||||
}
|
||||
setStatus("K 线 " + candles.length + " 根 · " + timeframe);
|
||||
}
|
||||
|
||||
function renderTrades() {
|
||||
if (!elTrades) return;
|
||||
if (!trades.length) {
|
||||
elTrades.innerHTML = '<p class="archive-empty">该币种暂无已平仓记录。</p>';
|
||||
return;
|
||||
}
|
||||
elTrades.innerHTML =
|
||||
'<table class="archive-trades-table"><thead><tr>' +
|
||||
"<th>平仓</th><th>方向</th><th>结果</th><th>盈亏</th><th>标签</th><th>备注</th>" +
|
||||
"</tr></thead><tbody>" +
|
||||
trades
|
||||
.map(function (t) {
|
||||
const tid = t.trade_id || t.id;
|
||||
const active = String(tid) === String(selectedTradeId) ? " is-active" : "";
|
||||
const tag = t.behavior_tag || "";
|
||||
return (
|
||||
'<tr class="archive-trade-row' +
|
||||
active +
|
||||
'" data-id="' +
|
||||
tid +
|
||||
'">' +
|
||||
"<td>" +
|
||||
(t.closed_at || "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
(t.direction || "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
(t.result || "—") +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(t.pnl_amount) +
|
||||
'">' +
|
||||
fmtPnl(t.pnl_amount) +
|
||||
"</td>" +
|
||||
'<td><select class="archive-tag-select" data-id="' +
|
||||
tid +
|
||||
'">' +
|
||||
'<option value=""' +
|
||||
(tag === "" ? " selected" : "") +
|
||||
">—</option>" +
|
||||
'<option value="sick"' +
|
||||
(tag === "sick" ? " selected" : "") +
|
||||
">犯病</option>" +
|
||||
'<option value="emotion"' +
|
||||
(tag === "emotion" ? " selected" : "") +
|
||||
">情绪</option>" +
|
||||
"</select></td>" +
|
||||
'<td><input class="archive-note-input" data-id="' +
|
||||
tid +
|
||||
'" value="' +
|
||||
String(t.note || "").replace(/"/g, """) +
|
||||
'" placeholder="备注" /></td>' +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("") +
|
||||
"</tbody></table>";
|
||||
|
||||
elTrades.querySelectorAll(".archive-trade-row").forEach(function (row) {
|
||||
row.addEventListener("click", function (ev) {
|
||||
if (ev.target.closest("select") || ev.target.closest("input")) return;
|
||||
selectedTradeId = row.getAttribute("data-id");
|
||||
renderTrades();
|
||||
loadChart();
|
||||
});
|
||||
});
|
||||
elTrades.querySelectorAll(".archive-tag-select").forEach(function (sel) {
|
||||
sel.addEventListener("change", function () {
|
||||
saveOverlay(sel.getAttribute("data-id"), sel.value, null);
|
||||
});
|
||||
});
|
||||
elTrades.querySelectorAll(".archive-note-input").forEach(function (inp) {
|
||||
inp.addEventListener("change", function () {
|
||||
const row = inp.closest(".archive-trade-row");
|
||||
const tagSel = row && row.querySelector(".archive-tag-select");
|
||||
saveOverlay(inp.getAttribute("data-id"), tagSel ? tagSel.value : "", inp.value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveOverlay(tradeId, tag, note) {
|
||||
if (!selected) return;
|
||||
const body = { behavior_tag: tag || "", note: note != null ? note : undefined };
|
||||
if (note == null) {
|
||||
const row = elTrades.querySelector('.archive-trade-row[data-id="' + tradeId + '"]');
|
||||
const inp = row && row.querySelector(".archive-note-input");
|
||||
body.note = inp ? inp.value : "";
|
||||
}
|
||||
await apiFetch("/api/archive/trade/" + selected.exchange_key + "/" + tradeId, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const tr = trades.find(function (t) {
|
||||
return String(t.trade_id || t.id) === String(tradeId);
|
||||
});
|
||||
if (tr) {
|
||||
tr.behavior_tag = body.behavior_tag;
|
||||
tr.note = body.note;
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(exchangeKey, symbol) {
|
||||
selected = { exchange_key: exchangeKey, symbol: symbol };
|
||||
if (elDetailPanel) elDetailPanel.classList.remove("hidden");
|
||||
const row = listRows.find(function (r) {
|
||||
return r.exchange_key === exchangeKey && r.symbol === symbol;
|
||||
});
|
||||
if (elDetailTitle) {
|
||||
elDetailTitle.textContent = symbol + " · " + exchangeKey;
|
||||
}
|
||||
if (elDetailStats && row) {
|
||||
elDetailStats.textContent =
|
||||
row.trade_count +
|
||||
" 笔 · 胜 " +
|
||||
row.win_count +
|
||||
" / 负 " +
|
||||
row.loss_count +
|
||||
" · 合计 " +
|
||||
fmtPnl(row.total_pnl) +
|
||||
" U";
|
||||
}
|
||||
renderList();
|
||||
setStatus("加载交易明细…");
|
||||
const r = await apiFetch(
|
||||
"/api/archive/detail?exchange_key=" +
|
||||
encodeURIComponent(exchangeKey) +
|
||||
"&symbol=" +
|
||||
encodeURIComponent(symbol)
|
||||
);
|
||||
const j = await r.json();
|
||||
trades = j.trades || [];
|
||||
selectedTradeId = trades.length ? String(trades[0].trade_id || trades[0].id) : null;
|
||||
renderTrades();
|
||||
await loadChart();
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
setStatus("加载列表…");
|
||||
const r = await apiFetch("/api/archive/list?" + queryListParams());
|
||||
const j = await r.json();
|
||||
listRows = j.rows || [];
|
||||
renderList();
|
||||
setStatus("共 " + listRows.length + " 个币种档案 · " + new Date().toLocaleTimeString());
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const r = await apiFetch("/api/archive/meta");
|
||||
meta = await r.json();
|
||||
timeframe = (meta && meta.default_timeframe) || "15m";
|
||||
renderExchangeOptions();
|
||||
if (elTfTabs) {
|
||||
elTfTabs.querySelectorAll(".archive-tf-btn").forEach(function (btn) {
|
||||
btn.classList.toggle("is-active", btn.getAttribute("data-tf") === timeframe);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAll() {
|
||||
setStatus("同步中(可能需数分钟)…");
|
||||
elBtnSync && (elBtnSync.disabled = true);
|
||||
try {
|
||||
const r = await apiFetch("/api/archive/sync", { method: "POST" });
|
||||
const j = await r.json();
|
||||
const okN = (j.results || []).filter(function (x) {
|
||||
return x.ok !== false;
|
||||
}).length;
|
||||
setStatus("同步完成 · " + okN + "/" + (j.exchanges || 0) + " 所");
|
||||
await loadList();
|
||||
if (selected) await openDetail(selected.exchange_key, selected.symbol);
|
||||
} catch (e) {
|
||||
setStatus(String(e));
|
||||
} finally {
|
||||
elBtnSync && (elBtnSync.disabled = false);
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadList);
|
||||
if (elBtnSync) elBtnSync.addEventListener("click", syncAll);
|
||||
if (elExchange) elExchange.addEventListener("change", loadList);
|
||||
[elFilterProfit, elFilterLoss, elFilterSick, elFilterEmotion].forEach(function (el) {
|
||||
if (el) el.addEventListener("change", loadList);
|
||||
});
|
||||
if (elTfTabs) {
|
||||
elTfTabs.addEventListener("click", function (ev) {
|
||||
const btn = ev.target.closest(".archive-tf-btn");
|
||||
if (!btn) return;
|
||||
timeframe = btn.getAttribute("data-tf") || "15m";
|
||||
elTfTabs.querySelectorAll(".archive-tf-btn").forEach(function (b) {
|
||||
b.classList.toggle("is-active", b === btn);
|
||||
});
|
||||
loadChart();
|
||||
});
|
||||
}
|
||||
if (elViewMode) elViewMode.addEventListener("change", loadChart);
|
||||
if (elBtnReloadChart) elBtnReloadChart.addEventListener("click", loadChart);
|
||||
if (elBtnJump) {
|
||||
elBtnJump.addEventListener("click", function () {
|
||||
loadChart();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!document.getElementById("page-archive") || document.getElementById("page-archive").classList.contains("hidden")) {
|
||||
return;
|
||||
}
|
||||
if (!inited) {
|
||||
bindEvents();
|
||||
inited = true;
|
||||
}
|
||||
await loadMeta();
|
||||
await loadList();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyChart();
|
||||
}
|
||||
|
||||
window.hubArchivePage = { init: init, destroy: destroy };
|
||||
})();
|
||||
@@ -15,7 +15,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260607-hub-board-v1" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260607-hub-archive-v1" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-bg" aria-hidden="true"></div>
|
||||
@@ -46,6 +46,7 @@
|
||||
<nav class="top-nav">
|
||||
<a href="/monitor" id="nav-monitor">监控区</a>
|
||||
<a href="/market" id="nav-market">行情区</a>
|
||||
<a href="/archive" id="nav-archive">币种档案</a>
|
||||
<a href="/ai" id="nav-ai">AI 教练</a>
|
||||
<a href="/settings" id="nav-settings">系统设置</a>
|
||||
</nav>
|
||||
@@ -185,6 +186,60 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-archive" class="page hidden">
|
||||
<div class="page-head">
|
||||
<h1><span class="head-tag">ARC</span> 币种档案</h1>
|
||||
<p class="page-desc">一所一币一行 · 交易时间线 · 永久 5m K 线(15m/1h/4h 聚合)</p>
|
||||
</div>
|
||||
<div class="archive-toolbar toolbar">
|
||||
<label class="archive-field">
|
||||
<span>交易所</span>
|
||||
<select id="archive-exchange"><option value="">全部</option></select>
|
||||
</label>
|
||||
<label class="chk-label"><input type="checkbox" id="archive-filter-profit" /> 有盈利单</label>
|
||||
<label class="chk-label"><input type="checkbox" id="archive-filter-loss" /> 有亏损单</label>
|
||||
<label class="chk-label"><input type="checkbox" id="archive-filter-sick" /> 犯病</label>
|
||||
<label class="chk-label"><input type="checkbox" id="archive-filter-emotion" /> 情绪</label>
|
||||
<button type="button" id="archive-btn-refresh" class="primary">刷新列表</button>
|
||||
<button type="button" id="archive-btn-sync" class="ghost">同步交易与 K 线</button>
|
||||
<span id="archive-status" class="toolbar-meta"></span>
|
||||
</div>
|
||||
<div class="archive-layout">
|
||||
<section class="archive-list-panel">
|
||||
<div id="archive-list" class="archive-list" role="list"></div>
|
||||
</section>
|
||||
<section class="archive-detail-panel hidden" id="archive-detail-panel">
|
||||
<div class="archive-detail-head">
|
||||
<h2 id="archive-detail-title">—</h2>
|
||||
<span id="archive-detail-stats" class="archive-detail-stats"></span>
|
||||
</div>
|
||||
<div class="archive-chart-toolbar toolbar">
|
||||
<div class="archive-tf-tabs" id="archive-tf-tabs" role="tablist">
|
||||
<button type="button" class="archive-tf-btn" data-tf="5m">5m</button>
|
||||
<button type="button" class="archive-tf-btn is-active" data-tf="15m">15m</button>
|
||||
<button type="button" class="archive-tf-btn" data-tf="1h">1h</button>
|
||||
<button type="button" class="archive-tf-btn" data-tf="4h">4h</button>
|
||||
</div>
|
||||
<label class="archive-field">
|
||||
<span>视窗</span>
|
||||
<select id="archive-view-mode">
|
||||
<option value="hold">持仓过程(锚平仓)</option>
|
||||
<option value="entry">进场决策(锚开仓)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="archive-field">
|
||||
<span>跳转时间</span>
|
||||
<input id="archive-jump-at" type="text" placeholder="2026-06-07 14:30" autocomplete="off" />
|
||||
</label>
|
||||
<button type="button" id="archive-btn-jump" class="ghost">跳转</button>
|
||||
<button type="button" id="archive-btn-reload-chart" class="primary">重载图表</button>
|
||||
</div>
|
||||
<div id="archive-chart" class="archive-chart-host"></div>
|
||||
<div id="archive-trades" class="archive-trades"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="instance-frame-shell" class="instance-frame-shell hidden" aria-hidden="true">
|
||||
<div class="instance-frame-toolbar">
|
||||
<button type="button" id="instance-frame-back" class="ghost">← 返回监控</button>
|
||||
@@ -294,7 +349,8 @@
|
||||
<div id="toast"></div>
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<script src="/assets/chart.js?v=20260604-upnl-contracts"></script>
|
||||
<script src="/assets/archive.js?v=20260607-hub-archive-v1"></script>
|
||||
<script src="/assets/ai_review_render.js?v=2"></script>
|
||||
<script src="/assets/app.js?v=20260607-hub-board-v1"></script>
|
||||
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user