7b0b8996fe
Support today/week/month/custom range selection with sick count, PnL, and per-exchange breakdown; update docs. Co-authored-by: Cursor <cursoragent@cursor.com>
1233 lines
40 KiB
JavaScript
1233 lines
40 KiB
JavaScript
/**
|
||
* 内照明心:复盘语录 + 当日交易记录 + 按需 K 线。
|
||
*/
|
||
(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 elPeriodTabs = document.getElementById("archive-period-tabs");
|
||
const elTradingDay = document.getElementById("archive-trading-day");
|
||
const elPeriodRangeWrap = document.getElementById("archive-period-range-wrap");
|
||
const elDateFrom = document.getElementById("archive-date-from");
|
||
const elDateTo = document.getElementById("archive-date-to");
|
||
const elSearch = document.getElementById("archive-search");
|
||
const elBtnChartToggle = document.getElementById("archive-btn-chart-toggle");
|
||
const elBtnRefresh = document.getElementById("archive-btn-refresh");
|
||
const elBtnSync = document.getElementById("archive-btn-sync");
|
||
const elStatus = document.getElementById("archive-status");
|
||
const elStats = document.getElementById("archive-stats");
|
||
const elQuotesList = document.getElementById("archive-quotes-list");
|
||
const elQuotesCount = document.getElementById("archive-quotes-count");
|
||
const elQuoteForm = document.getElementById("archive-quote-form");
|
||
const elQuoteDate = document.getElementById("archive-quote-date");
|
||
const elQuoteContent = document.getElementById("archive-quote-content");
|
||
const elChartSection = document.getElementById("archive-chart-section");
|
||
const elChartTitle = document.getElementById("archive-chart-title");
|
||
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 elMarkAuto = document.getElementById("archive-mark-auto");
|
||
const elTrades = document.getElementById("archive-trades");
|
||
const ARCHIVE_MARK_AUTO_KEY = "hubArchiveMarkAuto";
|
||
|
||
const TF_MS = {
|
||
"5m": 5 * 60_000,
|
||
"15m": 15 * 60_000,
|
||
"1h": 60 * 60_000,
|
||
"4h": 4 * 60 * 60_000,
|
||
};
|
||
const CHART_TZ_OFFSET_SEC = 8 * 60 * 60;
|
||
|
||
let meta = null;
|
||
let quotes = [];
|
||
let dailyTrades = [];
|
||
let dailyStats = { open_count: 0, by_exchange: {} };
|
||
let periodMode = "today";
|
||
let periodLabel = "";
|
||
let dateFrom = "";
|
||
let dateTo = "";
|
||
let tradingDay = "";
|
||
let selected = null;
|
||
let trades = [];
|
||
let selectedTradeId = null;
|
||
let timeframe = "15m";
|
||
let chart = null;
|
||
let candleSeries = null;
|
||
let volumeSeries = null;
|
||
let inited = false;
|
||
let markAuto = true;
|
||
let lastCandles = [];
|
||
let searchTimer = null;
|
||
|
||
function esc(s) {
|
||
return String(s == null ? "" : s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function loadMarkAutoPref() {
|
||
try {
|
||
const raw = localStorage.getItem(ARCHIVE_MARK_AUTO_KEY);
|
||
if (raw === "0" || raw === "false") markAuto = false;
|
||
else if (raw === "1" || raw === "true") markAuto = true;
|
||
} catch (_) {}
|
||
syncMarkAutoBtn();
|
||
}
|
||
|
||
function syncMarkAutoBtn() {
|
||
if (!elMarkAuto) return;
|
||
elMarkAuto.classList.toggle("is-on", markAuto);
|
||
elMarkAuto.setAttribute("aria-pressed", markAuto ? "true" : "false");
|
||
}
|
||
|
||
function saveMarkAutoPref() {
|
||
try {
|
||
localStorage.setItem(ARCHIVE_MARK_AUTO_KEY, markAuto ? "1" : "0");
|
||
} catch (_) {}
|
||
}
|
||
|
||
function tradeHistoryBounds(tradeList) {
|
||
let minOpen = null;
|
||
let maxClose = null;
|
||
(tradeList || []).forEach(function (tr) {
|
||
const o = tradeOpenMs(tr);
|
||
const c = tradeCloseMs(tr);
|
||
if (o != null) minOpen = minOpen == null ? o : Math.min(minOpen, o);
|
||
if (c != null) maxClose = maxClose == null ? c : Math.max(maxClose, c);
|
||
});
|
||
return { minOpen: minOpen, maxClose: maxClose };
|
||
}
|
||
|
||
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 "—";
|
||
return (n >= 0 ? "+" : "") + n.toFixed(2);
|
||
}
|
||
|
||
function pad2(n) {
|
||
return n < 10 ? "0" + n : String(n);
|
||
}
|
||
|
||
function utcSecToBjDate(utcSec) {
|
||
return new Date((Number(utcSec) + CHART_TZ_OFFSET_SEC) * 1000);
|
||
}
|
||
|
||
function formatChartTimeBj(utcSec, withDate) {
|
||
const d = utcSecToBjDate(utcSec);
|
||
const h = pad2(d.getUTCHours());
|
||
const mi = pad2(d.getUTCMinutes());
|
||
if (!withDate) return h + ":" + mi;
|
||
return (
|
||
d.getUTCFullYear() +
|
||
"-" +
|
||
pad2(d.getUTCMonth() + 1) +
|
||
"-" +
|
||
pad2(d.getUTCDate()) +
|
||
" " +
|
||
h +
|
||
":" +
|
||
mi
|
||
);
|
||
}
|
||
|
||
function chartLocalizationBj() {
|
||
return {
|
||
locale: "zh-CN",
|
||
dateFormat: "yyyy-MM-dd",
|
||
timeFormatter: function (time) {
|
||
if (typeof time === "number") return formatChartTimeBj(time, true);
|
||
if (time && typeof time === "object" && time.year) {
|
||
return time.year + "-" + pad2(time.month) + "-" + pad2(time.day);
|
||
}
|
||
return "";
|
||
},
|
||
tickMarkFormatter: function (time, tickMarkType) {
|
||
if (typeof time !== "number") {
|
||
if (time && typeof time === "object" && time.year) {
|
||
return time.year + "-" + pad2(time.month) + "-" + pad2(time.day);
|
||
}
|
||
return "";
|
||
}
|
||
const d = utcSecToBjDate(time);
|
||
if (tickMarkType === 0) return String(d.getUTCFullYear());
|
||
if (tickMarkType === 1) return pad2(d.getUTCMonth() + 1);
|
||
if (tickMarkType === 2) return pad2(d.getUTCDate());
|
||
return formatChartTimeBj(time, false);
|
||
},
|
||
};
|
||
}
|
||
|
||
function fmtDt(raw) {
|
||
if (raw == null || raw === "") return "—";
|
||
return String(raw).replace("T", " ").slice(0, 16);
|
||
}
|
||
|
||
function fmtHoldMinutes(tr) {
|
||
if (!tr) return "—";
|
||
const text = tr.hold_minutes_text;
|
||
if (text) return text;
|
||
const n = Number(tr.hold_minutes);
|
||
if (!Number.isFinite(n) || n <= 0) return "0分钟";
|
||
const hours = Math.floor(n / 60);
|
||
const mins = Math.floor(n % 60);
|
||
if (hours) return hours + "小时" + mins + "分钟";
|
||
return mins + "分钟";
|
||
}
|
||
|
||
const ENTRY_TYPE_LABELS = {
|
||
trend_pullback: "趋势回调",
|
||
roll: "顺势加仓",
|
||
trend: "趋势回调",
|
||
};
|
||
|
||
function fmtEntryType(tr) {
|
||
if (!tr) return "—";
|
||
const raw = String(
|
||
tr.entry_type || tr.entry_reason || tr.reviewed_entry_reason || ""
|
||
).trim();
|
||
if (raw) return ENTRY_TYPE_LABELS[raw] || raw;
|
||
const mt = String(tr.monitor_type || "").trim();
|
||
if (mt && mt !== "下单监控") return ENTRY_TYPE_LABELS[mt] || mt;
|
||
return mt || "—";
|
||
}
|
||
|
||
function reviewMark(tr) {
|
||
return tr && tr.reviewed ? "复" : "";
|
||
}
|
||
|
||
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 || "";
|
||
}
|
||
|
||
function tradeRowExchange(tr) {
|
||
if (!tr) return "—";
|
||
const exKey = tr.exchange_key || tr.account_exchange_key || "";
|
||
if (exKey) return exchangeLabel(exKey);
|
||
const name = tr.account_name || tr.exchange_name || "";
|
||
return name || "—";
|
||
}
|
||
|
||
function exchangeLabel(exKey) {
|
||
const key = String(exKey || "").toLowerCase();
|
||
if (!key) return "—";
|
||
const hit = (meta && meta.exchanges || []).find(function (ex) {
|
||
return String(ex.key || "").toLowerCase() === key;
|
||
});
|
||
return hit ? hit.name || hit.key : exKey;
|
||
}
|
||
|
||
function scheduleChartResize() {
|
||
requestAnimationFrame(function () {
|
||
if (chart && elChartHost) {
|
||
const w = elChartHost.clientWidth;
|
||
const h = elChartHost.clientHeight;
|
||
if (w > 0 && h > 0) chart.applyOptions({ width: w, height: h });
|
||
}
|
||
requestAnimationFrame(function () {
|
||
if (chart && elChartHost) {
|
||
const w = elChartHost.clientWidth;
|
||
const h = elChartHost.clientHeight;
|
||
if (w > 0 && h > 0) chart.applyOptions({ width: w, height: h });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function ensureChartSelection() {
|
||
if (selected && selected.exchange_key && selected.symbol) return;
|
||
if (!dailyTrades.length) return;
|
||
const tr = dailyTrades.find(function (t) {
|
||
return t.exchange_key && t.symbol;
|
||
});
|
||
if (!tr) return;
|
||
selected = { exchange_key: tr.exchange_key, symbol: tr.symbol };
|
||
selectedTradeId = String(tr.trade_id || tr.id);
|
||
await loadSymbolTradesForChart(tr.exchange_key, tr.symbol);
|
||
}
|
||
|
||
function isChartOpen() {
|
||
return !!(elChartSection && elChartSection.open);
|
||
}
|
||
|
||
function setChartOpen(on) {
|
||
if (!elChartSection) return;
|
||
elChartSection.open = !!on;
|
||
if (elBtnChartToggle) {
|
||
elBtnChartToggle.classList.toggle("is-active", !!on);
|
||
}
|
||
if (!on) {
|
||
destroyChart();
|
||
return;
|
||
}
|
||
scheduleChartResize();
|
||
}
|
||
|
||
function updateChartTitle() {
|
||
if (!elChartTitle) return;
|
||
if (!selected) {
|
||
elChartTitle.textContent = "—";
|
||
return;
|
||
}
|
||
elChartTitle.textContent = selected.symbol + " · " + exchangeLabel(selected.exchange_key);
|
||
}
|
||
|
||
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 syncPeriodUI() {
|
||
if (elPeriodTabs) {
|
||
elPeriodTabs.querySelectorAll(".archive-period-btn").forEach(function (btn) {
|
||
btn.classList.toggle("is-active", btn.getAttribute("data-period") === periodMode);
|
||
});
|
||
}
|
||
if (elTradingDay) {
|
||
elTradingDay.classList.toggle("hidden", periodMode !== "today");
|
||
}
|
||
if (elPeriodRangeWrap) {
|
||
elPeriodRangeWrap.classList.toggle("hidden", periodMode !== "range");
|
||
}
|
||
}
|
||
|
||
function setPeriodMode(mode) {
|
||
periodMode = mode || "today";
|
||
syncPeriodUI();
|
||
}
|
||
|
||
function queryDailyParams() {
|
||
const q = new URLSearchParams();
|
||
q.set("period", periodMode);
|
||
if (periodMode === "today" && elTradingDay && elTradingDay.value) {
|
||
q.set("trading_day", elTradingDay.value);
|
||
}
|
||
if (periodMode === "range") {
|
||
if (elDateFrom && elDateFrom.value) q.set("date_from", elDateFrom.value);
|
||
if (elDateTo && elDateTo.value) q.set("date_to", elDateTo.value);
|
||
}
|
||
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 (elSearch && elSearch.value.trim()) q.set("search", elSearch.value.trim());
|
||
return q.toString();
|
||
}
|
||
|
||
function fmtPnlStat(v) {
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n)) return "—";
|
||
const cls = n >= 0 ? "pnl-pos" : "pnl-neg";
|
||
const text = (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
|
||
return '<span class="' + cls + '">' + text + "</span>";
|
||
}
|
||
|
||
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 renderStats() {
|
||
if (!elStats) return;
|
||
const st = dailyStats || { open_count: 0, by_exchange: {} };
|
||
const label = periodLabel || "本日";
|
||
const sickPct = st.sick_pct != null ? st.sick_pct : 0;
|
||
let html =
|
||
'<div class="archive-stats-line"><strong>' +
|
||
esc(label) +
|
||
"</strong> · 开仓 " +
|
||
(st.open_count || 0) +
|
||
" 次 · 犯病 " +
|
||
(st.sick_count || 0) +
|
||
" 次(" +
|
||
sickPct +
|
||
"%) · 盈亏 " +
|
||
fmtPnlStat(st.pnl_total) +
|
||
" · 剔除犯病盈亏 " +
|
||
fmtPnlStat(st.pnl_ex_sick) +
|
||
"</div>";
|
||
const byEx = st.by_exchange || {};
|
||
const exKeys = Object.keys(byEx).sort();
|
||
if (exKeys.length) {
|
||
const exParts = exKeys.map(function (ex) {
|
||
const e = byEx[ex] || {};
|
||
const sickN = e.sick_count || 0;
|
||
const openN = e.open_count || 0;
|
||
const sickShare = openN ? Math.round((sickN / openN) * 1000) / 10 : 0;
|
||
return (
|
||
esc(exchangeLabel(ex)) +
|
||
" " +
|
||
openN +
|
||
"次/犯病" +
|
||
sickN +
|
||
"(" +
|
||
sickShare +
|
||
"%)/盈亏" +
|
||
fmtPnlStat(e.pnl_total) +
|
||
"/剔犯" +
|
||
fmtPnlStat(e.pnl_ex_sick)
|
||
);
|
||
});
|
||
html += '<div class="archive-stats-sub">' + exParts.join(" · ") + "</div>";
|
||
}
|
||
elStats.innerHTML = html;
|
||
}
|
||
|
||
function quotePreview(text) {
|
||
const s = String(text || "").replace(/\s+/g, " ").trim();
|
||
if (!s) return "(空)";
|
||
return s.length > 36 ? s.slice(0, 36) + "…" : s;
|
||
}
|
||
|
||
function renderQuotes() {
|
||
if (!elQuotesList) return;
|
||
if (elQuotesCount) {
|
||
elQuotesCount.textContent = quotes.length ? quotes.length + " 条" : "";
|
||
}
|
||
if (!quotes.length) {
|
||
elQuotesList.innerHTML = '<p class="archive-empty">暂无复盘语录,可在上方添加。</p>';
|
||
return;
|
||
}
|
||
elQuotesList.innerHTML = quotes
|
||
.map(function (q) {
|
||
return (
|
||
'<details class="archive-quote-card">' +
|
||
'<summary class="archive-quote-summary">' +
|
||
'<span class="archive-quote-date">' +
|
||
esc(q.quote_date) +
|
||
"</span>" +
|
||
'<span class="archive-quote-preview">' +
|
||
esc(quotePreview(q.content)) +
|
||
"</span>" +
|
||
"</summary>" +
|
||
'<div class="archive-quote-body">' +
|
||
'<textarea class="archive-quote-edit" data-id="' +
|
||
q.id +
|
||
'" rows="4">' +
|
||
esc(q.content) +
|
||
"</textarea>" +
|
||
'<div class="archive-quote-actions">' +
|
||
'<button type="button" class="ghost archive-quote-save" data-id="' +
|
||
q.id +
|
||
'">保存</button>' +
|
||
'<button type="button" class="archive-del-btn archive-quote-del" data-id="' +
|
||
q.id +
|
||
'">删除</button>' +
|
||
"</div></div></details>"
|
||
);
|
||
})
|
||
.join("");
|
||
|
||
elQuotesList.querySelectorAll(".archive-quote-save").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
const id = btn.getAttribute("data-id");
|
||
const card = btn.closest(".archive-quote-card");
|
||
const ta = card && card.querySelector(".archive-quote-edit");
|
||
const dateEl = card && card.querySelector(".archive-quote-date");
|
||
if (!id || !ta) return;
|
||
void saveQuote(id, dateEl ? dateEl.textContent : "", ta.value, card);
|
||
});
|
||
});
|
||
elQuotesList.querySelectorAll(".archive-quote-del").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
void deleteQuote(btn.getAttribute("data-id"));
|
||
});
|
||
});
|
||
}
|
||
|
||
async function loadQuotes() {
|
||
const r = await apiFetch("/api/archive/quotes");
|
||
const j = await r.json();
|
||
quotes = j.quotes || [];
|
||
renderQuotes();
|
||
}
|
||
|
||
async function addQuote(ev) {
|
||
if (ev) ev.preventDefault();
|
||
const date = elQuoteDate && elQuoteDate.value;
|
||
const content = elQuoteContent && elQuoteContent.value.trim();
|
||
if (!date || !content) return;
|
||
const r = await apiFetch("/api/archive/quotes", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ quote_date: date, content: content }),
|
||
});
|
||
const j = await r.json();
|
||
if (!r.ok) {
|
||
setStatus(j.detail || "添加失败");
|
||
return;
|
||
}
|
||
if (elQuoteContent) elQuoteContent.value = "";
|
||
await loadQuotes();
|
||
setStatus("语录已添加");
|
||
}
|
||
|
||
async function saveQuote(id, quoteDate, content, cardEl) {
|
||
let card = cardEl;
|
||
if (!card && elQuotesList) {
|
||
const ta = elQuotesList.querySelector('.archive-quote-edit[data-id="' + id + '"]');
|
||
card = ta ? ta.closest(".archive-quote-card") : null;
|
||
}
|
||
const date =
|
||
quoteDate ||
|
||
(card &&
|
||
card.querySelector(".archive-quote-date") &&
|
||
card.querySelector(".archive-quote-date").textContent) ||
|
||
"";
|
||
const r = await apiFetch("/api/archive/quotes/" + id, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ quote_date: date.trim(), content: content }),
|
||
});
|
||
const j = await r.json();
|
||
if (!r.ok) {
|
||
setStatus(j.detail || "保存失败");
|
||
return;
|
||
}
|
||
if (card) card.open = false;
|
||
await loadQuotes();
|
||
setStatus("语录已保存");
|
||
}
|
||
|
||
async function deleteQuote(id) {
|
||
if (!id || !window.confirm("确定删除这条复盘语录?")) return;
|
||
const r = await apiFetch("/api/archive/quotes/" + id, { method: "DELETE" });
|
||
if (!r.ok) {
|
||
const j = await r.json().catch(function () {
|
||
return {};
|
||
});
|
||
setStatus(j.detail || "删除失败");
|
||
return;
|
||
}
|
||
await loadQuotes();
|
||
setStatus("语录已删除");
|
||
}
|
||
|
||
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 parseTimeMs(raw) {
|
||
if (raw == null || raw === "") return null;
|
||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||
const v = Math.trunc(raw);
|
||
return v > 1e12 ? v : v * 1000;
|
||
}
|
||
const s = String(raw).trim().replace("Z", "").replace("T", " ");
|
||
if (!s) return null;
|
||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2})(?::(\d{2}))?)?/);
|
||
if (!m) return null;
|
||
const ms =
|
||
Date.UTC(
|
||
Number(m[1]),
|
||
Number(m[2]) - 1,
|
||
Number(m[3]),
|
||
Number(m[4] || 0),
|
||
Number(m[5] || 0),
|
||
Number(m[6] || 0)
|
||
) -
|
||
CHART_TZ_OFFSET_SEC * 1000;
|
||
return Number.isFinite(ms) ? ms : null;
|
||
}
|
||
|
||
function tradeOpenMs(tr) {
|
||
if (!tr) return null;
|
||
return tr.opened_at_ms || parseTimeMs(tr.opened_at);
|
||
}
|
||
|
||
function tradeCloseMs(tr) {
|
||
if (!tr) return null;
|
||
return tr.closed_at_ms || parseTimeMs(tr.closed_at);
|
||
}
|
||
|
||
function anchorMsForTrade(tr) {
|
||
if (!tr) return null;
|
||
const mode = (elViewMode && elViewMode.value) || "hold";
|
||
if (mode === "entry") return tradeOpenMs(tr);
|
||
return tradeCloseMs(tr) || tradeOpenMs(tr);
|
||
}
|
||
|
||
function msToBarTime(ms, tf) {
|
||
const period = TF_MS[tf] || TF_MS["15m"];
|
||
const aligned = Math.floor(Number(ms) / period) * period;
|
||
return Math.floor(aligned / 1000);
|
||
}
|
||
|
||
function snapToCandleTime(targetSec, candles) {
|
||
if (!candles || !candles.length) return targetSec;
|
||
let best = candles[0].time;
|
||
let bestDiff = Math.abs(candles[0].time - targetSec);
|
||
for (let i = 0; i < candles.length; i++) {
|
||
const d = Math.abs(candles[i].time - targetSec);
|
||
if (d < bestDiff) {
|
||
bestDiff = d;
|
||
best = candles[i].time;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
const OPEN_ARROW_LONG = "#22c55e";
|
||
const OPEN_ARROW_SHORT = "#ef4444";
|
||
const OPEN_ARROW_LONG_HI = "#4ade80";
|
||
const OPEN_ARROW_SHORT_HI = "#f87171";
|
||
|
||
function isLongDirection(dir) {
|
||
const d = String(dir || "").trim().toLowerCase();
|
||
if (d === "short" || d === "空" || d === "sell" || d === "做空" || d === "shorts") return false;
|
||
if (d === "long" || d === "多" || d === "buy" || d === "做多" || d === "longs") return true;
|
||
return true;
|
||
}
|
||
|
||
function openArrowColor(long, highlight) {
|
||
if (long) return highlight ? OPEN_ARROW_LONG_HI : OPEN_ARROW_LONG;
|
||
return highlight ? OPEN_ARROW_SHORT_HI : OPEN_ARROW_SHORT;
|
||
}
|
||
|
||
function buildTradeMarkers(tr, candles, tf, opts) {
|
||
if (!tr || !candles.length) return [];
|
||
const options = opts || {};
|
||
const suffix = options.labelSuffix ? String(options.labelSuffix) : "";
|
||
const highlight = !!options.highlight;
|
||
const long = isLongDirection(tr.direction);
|
||
const openMs = tradeOpenMs(tr);
|
||
const closeMs = tradeCloseMs(tr);
|
||
const openColor = openArrowColor(long, highlight);
|
||
let closeColor = highlight ? "#fbbf24" : "#f59e0b";
|
||
const pnl = Number(tr.pnl_amount);
|
||
if (!highlight && Number.isFinite(pnl) && pnl < -0.0001) closeColor = "#a855f7";
|
||
const markers = [];
|
||
if (openMs) {
|
||
markers.push({
|
||
time: snapToCandleTime(msToBarTime(openMs, tf), candles),
|
||
position: long ? "belowBar" : "aboveBar",
|
||
color: openColor,
|
||
shape: long ? "arrowUp" : "arrowDown",
|
||
text: "开" + suffix,
|
||
});
|
||
}
|
||
if (closeMs) {
|
||
markers.push({
|
||
time: snapToCandleTime(msToBarTime(closeMs, tf), candles),
|
||
position: long ? "aboveBar" : "belowBar",
|
||
color: closeColor,
|
||
shape: long ? "arrowDown" : "arrowUp",
|
||
text: "平" + suffix,
|
||
});
|
||
}
|
||
return markers;
|
||
}
|
||
|
||
function buildChartMarkers(candles, tf) {
|
||
if (!candles.length) return [];
|
||
const tr = pickAnchorTrade();
|
||
if (!markAuto || !trades.length) {
|
||
return buildTradeMarkers(tr, candles, tf, { highlight: true });
|
||
}
|
||
const sorted = trades.slice().sort(function (a, b) {
|
||
return (tradeOpenMs(a) || 0) - (tradeOpenMs(b) || 0);
|
||
});
|
||
const multi = sorted.length > 1;
|
||
const out = [];
|
||
sorted.forEach(function (row, idx) {
|
||
const tid = String(row.trade_id || row.id);
|
||
const parts = buildTradeMarkers(row, candles, tf, {
|
||
labelSuffix: multi ? String(idx + 1) : "",
|
||
highlight: tid === String(selectedTradeId),
|
||
});
|
||
out.push.apply(out, parts);
|
||
});
|
||
return out.sort(function (a, b) {
|
||
return a.time > b.time ? 1 : a.time < b.time ? -1 : 0;
|
||
});
|
||
}
|
||
|
||
function applyChartMarkers() {
|
||
if (!candleSeries || !candleSeries.setMarkers || !lastCandles.length) return;
|
||
candleSeries.setMarkers(buildChartMarkers(lastCandles, timeframe));
|
||
}
|
||
|
||
function focusInitialTradeView(candles, tr, tf) {
|
||
if (!chart || !candles.length || !tr) return;
|
||
const mode = (elViewMode && elViewMode.value) || "hold";
|
||
const openSec = tradeOpenMs(tr) ? msToBarTime(tradeOpenMs(tr), tf) : null;
|
||
const closeSec = tradeCloseMs(tr) ? msToBarTime(tradeCloseMs(tr), tf) : null;
|
||
let openIdx = 0;
|
||
let closeIdx = candles.length - 1;
|
||
if (openSec != null) {
|
||
for (let i = 0; i < candles.length; i++) {
|
||
if (candles[i].time >= openSec) {
|
||
openIdx = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (closeSec != null) {
|
||
for (let i = candles.length - 1; i >= 0; i--) {
|
||
if (candles[i].time <= closeSec) {
|
||
closeIdx = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
const span = Math.max(24, closeIdx - openIdx + 20);
|
||
let fromIdx;
|
||
let toIdx;
|
||
if (mode === "entry") {
|
||
fromIdx = Math.max(0, openIdx - Math.floor(span * 0.35));
|
||
toIdx = Math.min(candles.length - 1, openIdx + Math.floor(span * 0.65));
|
||
} else {
|
||
fromIdx = Math.max(0, openIdx - 10);
|
||
toIdx = Math.min(candles.length - 1, closeIdx + 14);
|
||
}
|
||
if (toIdx <= fromIdx) toIdx = Math.min(candles.length - 1, fromIdx + 80);
|
||
chart.timeScale().setVisibleLogicalRange({ from: fromIdx, to: toIdx + 4 });
|
||
}
|
||
|
||
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", autoScale: true },
|
||
localization: chartLocalizationBj(),
|
||
timeScale: {
|
||
borderColor: isDark ? "#2a3348" : "#d0d7e2",
|
||
timeVisible: true,
|
||
secondsVisible: false,
|
||
},
|
||
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||
handleScroll: {
|
||
mouseWheel: true,
|
||
pressedMouseMove: true,
|
||
horzTouchDrag: true,
|
||
vertTouchDrag: false,
|
||
},
|
||
handleScale: {
|
||
axisPressedMouseMove: true,
|
||
mouseWheel: true,
|
||
pinch: true,
|
||
},
|
||
});
|
||
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 loadSymbolTradesForChart(exKey, sym) {
|
||
const r = await apiFetch(
|
||
"/api/archive/detail?exchange_key=" +
|
||
encodeURIComponent(exKey) +
|
||
"&symbol=" +
|
||
encodeURIComponent(sym)
|
||
);
|
||
const j = await r.json();
|
||
trades = j.trades || [];
|
||
}
|
||
|
||
async function loadChart() {
|
||
if (!selected || !isChartOpen()) return;
|
||
const tr = pickAnchorTrade();
|
||
const jump = (elJumpAt && elJumpAt.value) || "";
|
||
let openMs = null;
|
||
let closeMs = null;
|
||
if (markAuto && trades.length) {
|
||
const bounds = tradeHistoryBounds(trades);
|
||
openMs = bounds.minOpen;
|
||
closeMs = bounds.maxClose;
|
||
} else if (tr) {
|
||
openMs = tradeOpenMs(tr);
|
||
closeMs = tradeCloseMs(tr);
|
||
}
|
||
const params = new URLSearchParams({
|
||
exchange_key: selected.exchange_key,
|
||
symbol: selected.symbol,
|
||
timeframe: timeframe,
|
||
mode: (elViewMode && elViewMode.value) || "hold",
|
||
});
|
||
if (openMs && closeMs) {
|
||
params.set("range", "history");
|
||
params.set("opened_ms", String(openMs));
|
||
params.set("closed_ms", String(closeMs));
|
||
} else {
|
||
params.set("bars", "200");
|
||
const anchor = anchorMsForTrade(tr);
|
||
if (jump.trim()) params.set("at", jump.trim());
|
||
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;
|
||
}
|
||
if (chart) {
|
||
destroyChart();
|
||
}
|
||
ensureChart();
|
||
scheduleChartResize();
|
||
const candles = j.candles || [];
|
||
lastCandles = 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",
|
||
};
|
||
})
|
||
);
|
||
applyChartMarkers();
|
||
if (tr && tradeOpenMs(tr) && tradeCloseMs(tr)) {
|
||
focusInitialTradeView(candles, tr, timeframe);
|
||
} else if (candles.length > 10) {
|
||
chart.timeScale().setVisibleLogicalRange({ from: candles.length - 120, to: candles.length + 5 });
|
||
}
|
||
updateChartTitle();
|
||
scheduleChartResize();
|
||
setStatus("K 线 " + candles.length + " 根 · " + timeframe);
|
||
}
|
||
|
||
async function openTradeChart(tr) {
|
||
if (!tr) return;
|
||
const exKey = tr.exchange_key || tr.account_exchange_key || "";
|
||
const sym = tr.symbol || "";
|
||
if (!exKey || !sym) {
|
||
setStatus("该笔交易缺少交易所或合约,无法加载图表");
|
||
return;
|
||
}
|
||
selected = { exchange_key: exKey, symbol: sym };
|
||
selectedTradeId = String(tr.trade_id || tr.id);
|
||
setChartOpen(true);
|
||
await loadSymbolTradesForChart(exKey, sym);
|
||
await loadChart();
|
||
}
|
||
|
||
function renderTrades() {
|
||
if (!elTrades) return;
|
||
if (!dailyTrades.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><th>结果</th><th>盈亏</th><th>标签</th><th>备注</th><th>操作</th>" +
|
||
"</tr></thead><tbody>" +
|
||
dailyTrades
|
||
.map(function (t) {
|
||
const tid = t.trade_id || t.id;
|
||
const exKey = t.exchange_key || t.account_exchange_key || "";
|
||
const tag = t.behavior_tag || "";
|
||
const sick = tag === "sick";
|
||
const active = String(tid) === String(selectedTradeId) ? " is-active" : "";
|
||
const rev = reviewMark(t);
|
||
return (
|
||
'<tr class="archive-trade-row' +
|
||
active +
|
||
(sick ? " archive-trade-sick" : "") +
|
||
'" data-id="' +
|
||
tid +
|
||
'" data-ex="' +
|
||
esc(exKey) +
|
||
'" data-sym="' +
|
||
esc(t.symbol || "") +
|
||
'">' +
|
||
"<td>" +
|
||
esc(tradeRowExchange(t)) +
|
||
"</td>" +
|
||
"<td>" +
|
||
(rev ? '<span class="archive-review-mark">' + rev + "</span>" : "") +
|
||
esc(fmtEntryType(t)) +
|
||
"</td>" +
|
||
'<td class="archive-dt">' +
|
||
fmtDt(t.opened_at) +
|
||
"</td>" +
|
||
'<td class="archive-dt">' +
|
||
fmtDt(t.closed_at) +
|
||
"</td>" +
|
||
'<td class="archive-hold">' +
|
||
fmtHoldMinutes(t) +
|
||
"</td>" +
|
||
"<td>" +
|
||
esc(t.direction || "—") +
|
||
"</td>" +
|
||
"<td>" +
|
||
esc(t.result || "—") +
|
||
"</td>" +
|
||
'<td class="' +
|
||
pnlClass(t.pnl_amount) +
|
||
'">' +
|
||
fmtPnl(t.pnl_amount) +
|
||
"</td>" +
|
||
'<td><select class="archive-tag-select" data-id="' +
|
||
tid +
|
||
'" data-ex="' +
|
||
esc(exKey) +
|
||
'">' +
|
||
'<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 +
|
||
'" data-ex="' +
|
||
esc(exKey) +
|
||
'" value="' +
|
||
esc(t.note || "") +
|
||
'" placeholder="备注" /></td>' +
|
||
'<td class="archive-actions-cell">' +
|
||
'<button type="button" class="ghost archive-chart-btn" data-id="' +
|
||
tid +
|
||
'">图表</button>' +
|
||
'<button type="button" class="archive-del-btn" data-id="' +
|
||
tid +
|
||
'">删除</button>' +
|
||
"</td></tr>"
|
||
);
|
||
})
|
||
.join("") +
|
||
"</tbody></table>";
|
||
|
||
elTrades.querySelectorAll(".archive-del-btn").forEach(function (btn) {
|
||
btn.addEventListener("click", function (ev) {
|
||
ev.stopPropagation();
|
||
const row = btn.closest(".archive-trade-row");
|
||
void deleteTrade(btn.getAttribute("data-id"), row && row.getAttribute("data-ex"));
|
||
});
|
||
});
|
||
elTrades.querySelectorAll(".archive-chart-btn").forEach(function (btn) {
|
||
btn.addEventListener("click", function (ev) {
|
||
ev.stopPropagation();
|
||
const row = btn.closest(".archive-trade-row");
|
||
const tid = btn.getAttribute("data-id");
|
||
const tr = dailyTrades.find(function (t) {
|
||
return String(t.trade_id || t.id) === String(tid);
|
||
});
|
||
if (tr) void openTradeChart(tr);
|
||
else if (row) {
|
||
selectedTradeId = tid;
|
||
renderTrades();
|
||
}
|
||
});
|
||
});
|
||
elTrades.querySelectorAll(".archive-tag-select").forEach(function (sel) {
|
||
sel.addEventListener("change", function () {
|
||
saveOverlay(sel.getAttribute("data-id"), sel.getAttribute("data-ex"), 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"),
|
||
inp.getAttribute("data-ex"),
|
||
tagSel ? tagSel.value : "",
|
||
inp.value
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function deleteTrade(tradeId, exchangeKey) {
|
||
const exKey = exchangeKey || (selected && selected.exchange_key);
|
||
if (!exKey || tradeId == null) return;
|
||
if (!window.confirm("从档案移除该笔交易?(不影响交易所实例里的复盘记录)")) return;
|
||
const r = await apiFetch("/api/archive/trade/" + exKey + "/" + tradeId, { method: "DELETE" });
|
||
if (!r.ok) {
|
||
const j = await r.json().catch(function () {
|
||
return {};
|
||
});
|
||
setStatus(j.detail || j.msg || "删除失败");
|
||
return;
|
||
}
|
||
if (String(selectedTradeId) === String(tradeId)) selectedTradeId = null;
|
||
await loadDailyTrades();
|
||
setStatus("已移除 1 笔档案记录");
|
||
}
|
||
|
||
async function saveOverlay(tradeId, exchangeKey, tag, note) {
|
||
const exKey = exchangeKey || (selected && selected.exchange_key);
|
||
if (!exKey) 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/" + exKey + "/" + tradeId, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const tr = dailyTrades.find(function (t) {
|
||
return String(t.trade_id || t.id) === String(tradeId);
|
||
});
|
||
if (tr) {
|
||
tr.behavior_tag = body.behavior_tag;
|
||
tr.note = body.note;
|
||
}
|
||
renderTrades();
|
||
}
|
||
|
||
async function loadDailyTrades() {
|
||
setStatus("加载交易记录…");
|
||
const r = await apiFetch("/api/archive/daily-trades?" + queryDailyParams());
|
||
const j = await r.json();
|
||
if (!r.ok) {
|
||
setStatus(j.detail || "加载失败");
|
||
return;
|
||
}
|
||
periodMode = j.period || periodMode || "today";
|
||
periodLabel = j.period_label || periodLabel || "";
|
||
dateFrom = j.date_from || dateFrom || "";
|
||
dateTo = j.date_to || dateTo || "";
|
||
tradingDay = j.trading_day || tradingDay;
|
||
if (elTradingDay && tradingDay) elTradingDay.value = tradingDay;
|
||
if (elDateFrom && dateFrom) elDateFrom.value = dateFrom;
|
||
if (elDateTo && dateTo) elDateTo.value = dateTo;
|
||
if (elQuoteDate && tradingDay && !elQuoteDate.value) elQuoteDate.value = tradingDay;
|
||
syncPeriodUI();
|
||
dailyTrades = j.trades || [];
|
||
dailyStats = j.stats || { open_count: 0, by_exchange: {} };
|
||
renderStats();
|
||
renderTrades();
|
||
setStatus(
|
||
(periodLabel || tradingDay || "当日") +
|
||
" · 列表 " +
|
||
dailyTrades.length +
|
||
" 笔 · " +
|
||
new Date().toLocaleTimeString()
|
||
);
|
||
}
|
||
|
||
async function loadMeta() {
|
||
const r = await apiFetch("/api/archive/meta");
|
||
meta = await r.json();
|
||
timeframe = (meta && meta.default_timeframe) || "15m";
|
||
if (meta && meta.last_sync && elStatus && !elStatus.textContent) {
|
||
setStatus(formatSyncSummary(meta.last_sync));
|
||
}
|
||
renderExchangeOptions();
|
||
if (elTfTabs) {
|
||
elTfTabs.querySelectorAll(".archive-tf-btn").forEach(function (btn) {
|
||
btn.classList.toggle("is-active", btn.getAttribute("data-tf") === timeframe);
|
||
});
|
||
}
|
||
}
|
||
|
||
function formatSyncSummary(j) {
|
||
const results = j.results || [];
|
||
const okN = results.filter(function (x) {
|
||
return x.ok !== false;
|
||
}).length;
|
||
const parts = ["同步完成 · " + okN + "/" + (j.exchanges || 0) + " 所"];
|
||
results.forEach(function (row) {
|
||
const label = row.exchange_key || row.name || "?";
|
||
if (row.ok === false) parts.push(label + " 失败: " + (row.msg || "未知错误"));
|
||
else {
|
||
let line = label + " " + (row.trade_count != null ? row.trade_count : row.trades || 0) + " 笔";
|
||
if (row.trades_removed > 0) line += " 清" + row.trades_removed;
|
||
parts.push(line);
|
||
}
|
||
});
|
||
return parts.join(" · ");
|
||
}
|
||
|
||
async function syncAll() {
|
||
setStatus("同步中(可能需数分钟)…");
|
||
if (elBtnSync) elBtnSync.disabled = true;
|
||
try {
|
||
const r = await apiFetch("/api/archive/sync", { method: "POST" });
|
||
const j = await r.json();
|
||
setStatus(formatSyncSummary(j));
|
||
await loadDailyTrades();
|
||
await loadQuotes();
|
||
if (isChartOpen() && selected) await loadChart();
|
||
} catch (e) {
|
||
setStatus(String(e));
|
||
} finally {
|
||
if (elBtnSync) elBtnSync.disabled = false;
|
||
}
|
||
}
|
||
|
||
function bindEvents() {
|
||
if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadDailyTrades);
|
||
if (elBtnSync) elBtnSync.addEventListener("click", syncAll);
|
||
if (elExchange) elExchange.addEventListener("change", loadDailyTrades);
|
||
if (elPeriodTabs) {
|
||
elPeriodTabs.addEventListener("click", function (ev) {
|
||
const btn = ev.target.closest(".archive-period-btn");
|
||
if (!btn) return;
|
||
const next = btn.getAttribute("data-period") || "today";
|
||
if (next === periodMode) return;
|
||
setPeriodMode(next);
|
||
loadDailyTrades();
|
||
});
|
||
}
|
||
if (elTradingDay) elTradingDay.addEventListener("change", loadDailyTrades);
|
||
if (elDateFrom) elDateFrom.addEventListener("change", loadDailyTrades);
|
||
if (elDateTo) elDateTo.addEventListener("change", loadDailyTrades);
|
||
[elFilterProfit, elFilterLoss, elFilterSick].forEach(function (el) {
|
||
if (el) el.addEventListener("change", loadDailyTrades);
|
||
});
|
||
if (elSearch) {
|
||
elSearch.addEventListener("input", function () {
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(loadDailyTrades, 320);
|
||
});
|
||
}
|
||
if (elBtnChartToggle) {
|
||
elBtnChartToggle.addEventListener("click", async function () {
|
||
const next = !isChartOpen();
|
||
setChartOpen(next);
|
||
if (next) {
|
||
await ensureChartSelection();
|
||
void loadChart();
|
||
}
|
||
});
|
||
}
|
||
if (elChartSection) {
|
||
elChartSection.addEventListener("toggle", async function () {
|
||
if (elBtnChartToggle) elBtnChartToggle.classList.toggle("is-active", elChartSection.open);
|
||
if (elChartSection.open) {
|
||
await ensureChartSelection();
|
||
void loadChart();
|
||
} else {
|
||
destroyChart();
|
||
}
|
||
});
|
||
}
|
||
if (elQuoteForm) elQuoteForm.addEventListener("submit", addQuote);
|
||
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 (elMarkAuto) {
|
||
elMarkAuto.addEventListener("click", function () {
|
||
markAuto = !markAuto;
|
||
syncMarkAutoBtn();
|
||
saveMarkAutoPref();
|
||
loadChart();
|
||
});
|
||
}
|
||
if (elBtnJump) elBtnJump.addEventListener("click", loadChart);
|
||
}
|
||
|
||
async function init() {
|
||
if (!page || page.classList.contains("hidden")) return;
|
||
if (!inited) {
|
||
loadMarkAutoPref();
|
||
setChartOpen(false);
|
||
syncPeriodUI();
|
||
bindEvents();
|
||
inited = true;
|
||
}
|
||
await loadMeta();
|
||
await loadQuotes();
|
||
await loadDailyTrades();
|
||
}
|
||
|
||
function destroy() {
|
||
destroyChart();
|
||
}
|
||
|
||
window.hubArchivePage = { init: init, destroy: destroy };
|
||
})();
|