Files
crypto_monitor/lib/common/static/options_review.js
T
dekun 48afc8ebe3 Load options review from local records with compact journal UI.
Drop exchange sync for the review page, auto-import closed local options and hedge plans, and match contract-style multi-timeframe upload styling with smaller fonts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 09:54:35 +08:00

595 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OKX 期权复盘:三类 Tab + 列表点选 + 下方多周期(5m/15m/1h/4h)复盘表单.
*/
(function (global) {
"use strict";
var SLOT_TFS = ["5m", "15m", "1h", "4h"];
var TAB_LABELS = {
option_spot: "期权交易记录",
options_options: "期期对冲记录",
perp_options: "永期对冲记录",
};
var activeSource = "option_spot";
var currentTradeId = null;
var draftId = "";
var tradesCache = {};
function $(id) {
return document.getElementById(id);
}
function fmtPnl(v) {
if (v == null || v === "") return "—";
var n = Number(v);
if (Number.isNaN(n)) return "—";
return (n >= 0 ? "+" : "") + n.toFixed(2);
}
function fmtHold(sec) {
if (sec == null) return "—";
var s = Math.max(0, Number(sec) || 0);
if (s < 3600) return Math.round(s / 60) + "m";
if (s < 86400) return (s / 3600).toFixed(1) + "h";
return (s / 86400).toFixed(1) + "d";
}
function toLocalInput(ts) {
if (!ts) return "";
var s = String(ts).trim().replace(" ", "T");
if (s.length >= 16) return s.slice(0, 16);
return s;
}
function newDraftId() {
if (global.crypto && typeof global.crypto.randomUUID === "function") {
return global.crypto.randomUUID().replace(/-/g, "");
}
var s = "";
for (var i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16);
return s;
}
function qs() {
var p = new URLSearchParams();
p.set("source_type", activeSource);
var uly = ($("or-filter-uly") || {}).value || "";
var opt = ($("or-filter-opt") || {}).value || "";
var reviewed = ($("or-filter-reviewed") || {}).value || "";
var strategy = (($("or-filter-strategy") || {}).value || "").trim();
var from = ($("or-filter-from") || {}).value || "";
var to = ($("or-filter-to") || {}).value || "";
if (uly) p.set("underlying", uly);
if (opt) p.set("opt_type", opt);
if (reviewed) p.set("reviewed", reviewed);
if (strategy) p.set("strategy_tag", strategy);
if (from) p.set("closed_from", from.replace("T", " ") + ":00");
if (to) p.set("closed_to", to.replace("T", " ") + ":00");
if (($("or-include-hedge-legs") || {}).checked) p.set("include_hedge_legs", "1");
return p.toString();
}
function setSyncStatus(text) {
var el = $("or-sync-status");
if (el) el.textContent = text || "";
}
function reloadAll() {
setSyncStatus("读取本地记录…");
loadTrades();
loadStats();
}
function setActiveTab(source) {
activeSource = source || "option_spot";
document.querySelectorAll(".or-tab").forEach(function (btn) {
btn.classList.toggle("active", btn.getAttribute("data-source") === activeSource);
});
var title = $("or-list-title");
if (title) title.textContent = TAB_LABELS[activeSource] || "记录";
hideJournalForm();
reloadAll();
}
function loadTrades() {
var tbody = $("or-trades-tbody");
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载中…</td></tr>';
fetch("/api/options/review/trades?" + qs(), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
setSyncStatus("本地记录已加载");
if (!data.ok) {
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
return;
}
var rows = data.trades || [];
tradesCache = {};
if (!rows.length) {
tbody.innerHTML =
'<tr><td colspan="7" class="muted">暂无本地已平记录(期权页平仓后或对冲计划结束后会出现在此)</td></tr>';
return;
}
tbody.innerHTML = rows
.map(function (t) {
tradesCache[t.id] = t;
var title =
t.source_type === "option_spot"
? t.inst_id || "—"
: (t.underlying || "") +
(t.direction ? " " + t.direction : "") +
(t.plan_close_reason ? " · " + t.plan_close_reason : "");
var pnlClass =
Number(t.realized_pnl_total) > 0
? "color:#3dd68c"
: Number(t.realized_pnl_total) < 0
? "color:#f07178"
: "";
var active = currentTradeId === t.id ? " or-row-active" : "";
return (
'<tr class="or-trade-row' +
active +
'" data-id="' +
t.id +
'">' +
"<td><span class=\"or-badge\">" +
(t.source_label || t.source_type) +
"</span></td>" +
"<td>" +
title +
"</td>" +
'<td style="' +
pnlClass +
'">' +
fmtPnl(t.realized_pnl_total) +
"</td>" +
'<td class="muted" style="font-size:12px">' +
(t.opened_at || "—") +
"<br>" +
(t.closed_at || "—") +
"</td>" +
"<td>" +
fmtHold(t.hold_seconds) +
"</td>" +
"<td>" +
(t.strategy_tag || "—") +
"</td>" +
"<td>" +
(t.reviewed ? "已复盘" : "待复盘") +
"</td>" +
"</tr>"
);
})
.join("");
tbody.querySelectorAll(".or-trade-row").forEach(function (tr) {
tr.addEventListener("click", function () {
openJournalForm(Number(tr.getAttribute("data-id")));
});
});
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
});
}
function renderGroup(title, items) {
if (!items || !items.length) {
return (
'<div class="or-stat-card"><div class="muted">' +
title +
'</div><div class="muted">无数据</div></div>'
);
}
var lines = items
.slice(0, 8)
.map(function (g) {
return (
'<div style="display:flex;justify-content:space-between;gap:8px;font-size:13px">' +
"<span>" +
g.key +
" · " +
g.count +
"笔</span>" +
"<span>" +
fmtPnl(g.pnl_sum) +
" / 胜" +
(g.win_rate || 0) +
"%</span>" +
"</div>"
);
})
.join("");
return (
'<div class="or-stat-card"><div style="font-weight:600;margin-bottom:6px">' +
title +
"</div>" +
lines +
"</div>"
);
}
function loadStats() {
var kpi = $("or-kpi");
var groups = $("or-stats-groups");
if (!kpi || !groups) return;
fetch("/api/options/review/stats?" + qs(), { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data.ok) return;
var k = data.kpi || {};
kpi.innerHTML = [
["笔数", k.total],
["已复盘率", (k.review_rate || 0) + "%"],
["胜率", (k.win_rate || 0) + "%"],
["累计盈亏", fmtPnl(k.pnl_sum)],
["平均盈亏", fmtPnl(k.avg_pnl)],
["平均持有", fmtHold(k.avg_hold_sec)],
]
.map(function (pair) {
return (
'<div><div class="muted" style="font-size:12px">' +
pair[0] +
'</div><div style="font-weight:600">' +
pair[1] +
"</div></div>"
);
})
.join("");
groups.innerHTML = [
renderGroup("按类型", data.by_source_type),
renderGroup("按标的", data.by_underlying),
renderGroup("按策略", data.by_strategy),
renderGroup("对冲结束原因", data.by_close_reason),
renderGroup("持有周期", data.by_hold_bucket),
renderGroup("Call/Put", data.by_opt_type),
].join("");
})
.catch(function () {});
}
function resetUploadSlots() {
draftId = newDraftId();
var draftEl = $("or-draft-id");
if (draftEl) draftEl.value = draftId;
document.querySelectorAll("#or-upload-slots .or-upload-hidden").forEach(function (el) {
el.value = "";
});
document.querySelectorAll("#or-upload-slots .or-upload-input").forEach(function (el) {
el.value = "";
});
document.querySelectorAll("#or-upload-slots .or-upload-status").forEach(function (el) {
el.textContent = "";
});
}
function bindUploadSlots() {
document.querySelectorAll("#or-upload-slots .or-upload-input").forEach(function (input) {
if (input.dataset.orBound === "1") return;
input.dataset.orBound = "1";
input.addEventListener("change", function () {
var file = input.files && input.files[0];
var row = input.closest(".or-upload-row");
var status = row && row.querySelector(".or-upload-status");
var hidden = row && row.querySelector(".or-upload-hidden");
if (!file) {
if (hidden) hidden.value = "";
if (status) {
status.textContent = "";
status.className = "journal-upload-status or-upload-status";
}
return;
}
if (!draftId) draftId = newDraftId();
if (status) {
status.textContent = "上传中…";
status.className = "journal-upload-status or-upload-status journal-upload-status--pending";
}
var fd = new FormData();
fd.append("draft_id", draftId);
fd.append("tf", input.getAttribute("data-tf") || "");
fd.append("file", file);
fetch("/api/options/review/upload_slot", {
method: "POST",
body: fd,
credentials: "same-origin",
})
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data.ok) throw new Error(data.error || "fail");
if (hidden) hidden.value = data.file;
if (status) {
status.textContent = "上传成功 " + data.file;
status.className = "journal-upload-status or-upload-status journal-upload-status--ok";
}
input.value = "";
})
.catch(function () {
if (hidden) hidden.value = "";
if (status) {
status.textContent = "上传失败";
status.className = "journal-upload-status or-upload-status journal-upload-status--err";
}
});
});
});
}
function setMoodTags(raw) {
var set = {};
String(raw || "")
.split(/[,]/)
.map(function (x) {
return x.trim();
})
.filter(Boolean)
.forEach(function (x) {
set[x] = true;
});
document.querySelectorAll(".or-mood").forEach(function (cb) {
cb.checked = !!set[cb.value];
});
}
function collectMoodTags() {
var out = [];
document.querySelectorAll(".or-mood:checked").forEach(function (cb) {
out.push(cb.value);
});
return out.join(",");
}
function collectImages() {
var out = [];
document.querySelectorAll("#or-upload-slots .or-upload-hidden").forEach(function (el) {
var file = (el.value || "").trim();
if (file) out.push({ tf: el.getAttribute("data-tf") || "", file: file });
});
return out;
}
function hideJournalForm() {
currentTradeId = null;
var card = $("or-journal-card");
if (card) card.classList.add("or-journal-idle");
document.querySelectorAll(".or-trade-row").forEach(function (tr) {
tr.classList.remove("or-row-active");
});
($("or-trade-id") || {}).value = "";
($("or-f-open") || {}).value = "";
($("or-f-close") || {}).value = "";
($("or-f-coin") || {}).value = "";
($("or-f-inst") || {}).value = "";
($("or-f-pnl") || {}).value = "";
($("or-f-hold") || {}).value = "";
($("or-f-strategy") || {}).value = "";
($("or-f-direction") || {}).value = "";
($("or-f-exit") || {}).value = "";
($("or-f-followed") || {}).value = "";
($("or-f-result") || {}).value = "";
($("or-f-entry") || {}).value = "";
($("or-f-note") || {}).value = "";
setMoodTags("");
resetUploadSlots();
var summary = $("or-journal-summary");
if (summary) {
summary.textContent =
"点下方列表一笔记录自动填入;截图槽位与合约复盘相同(5m / 15m / 1h / 4h).";
}
var legsHost = $("or-legs-host");
if (legsHost) legsHost.innerHTML = "";
($("or-save-status") || {}).textContent = "";
}
function openJournalForm(tradeId) {
currentTradeId = tradeId;
var card = $("or-journal-card");
if (!card) return;
card.classList.remove("or-journal-idle");
card.scrollIntoView({ behavior: "smooth", block: "start" });
document.querySelectorAll(".or-trade-row").forEach(function (tr) {
tr.classList.toggle("or-row-active", Number(tr.getAttribute("data-id")) === tradeId);
});
resetUploadSlots();
bindUploadSlots();
($("or-save-status") || {}).textContent = "加载中…";
fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data.ok || !data.trade) {
($("or-save-status") || {}).textContent = "加载失败";
return;
}
fillForm(data.trade);
($("or-save-status") || {}).textContent = "已选中 #" + tradeId;
})
.catch(function () {
($("or-save-status") || {}).textContent = "加载失败";
});
}
function fillForm(t) {
var e = t.entry || {};
($("or-trade-id") || {}).value = String(t.id || "");
($("or-f-open") || {}).value = toLocalInput(t.opened_at);
($("or-f-close") || {}).value = toLocalInput(t.closed_at);
($("or-f-coin") || {}).value = t.underlying || "";
($("or-f-inst") || {}).value =
t.source_type === "option_spot"
? t.inst_id || ""
: (t.source_label || "") + (t.plan_close_reason ? " · " + t.plan_close_reason : "");
($("or-f-pnl") || {}).value = fmtPnl(t.realized_pnl_total);
($("or-f-hold") || {}).value = fmtHold(t.hold_seconds);
($("or-f-strategy") || {}).value = e.strategy_tag || "";
($("or-f-direction") || {}).value = e.direction_view || t.direction || "";
($("or-f-exit") || {}).value = e.exit_reason || t.plan_close_reason || "";
($("or-f-followed") || {}).value = e.followed_plan || "";
($("or-f-result") || {}).value = e.result_tag || "";
($("or-f-entry") || {}).value = e.entry_logic || "";
($("or-f-note") || {}).value = e.note || "";
setMoodTags(e.mistake_tags);
var summary = $("or-journal-summary");
if (summary) {
summary.textContent =
(t.source_label || "") +
" · " +
(t.inst_id || t.underlying || "#" + t.id) +
" · 盈亏 " +
fmtPnl(t.realized_pnl_total) +
(t.is_hedge
? " (永续 " + fmtPnl(t.realized_pnl_perp) + " / 期权 " + fmtPnl(t.realized_pnl_options) + ")"
: "");
}
// 已有截图回填状态(文件名保留,新上传会换 draft)
(e.images || []).forEach(function (img) {
var hidden = document.querySelector(
'#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]'
);
var status = document.querySelector(
'#or-upload-slots .or-upload-status[data-tf="' + img.tf + '"]'
);
if (hidden && img.file) {
hidden.value = img.file;
if (status) status.textContent = "已有 " + img.file;
}
});
var legsHost = $("or-legs-host");
if (legsHost) {
if (t.legs && t.legs.length) {
legsHost.innerHTML =
"<h3>计划腿</h3><table class=\"options-strike-table\"><thead><tr><th>腿</th><th>合约</th><th>盈亏</th><th>原因</th></tr></thead><tbody>" +
t.legs
.map(function (leg) {
return (
"<tr><td>" +
(leg.leg_role || "") +
"</td><td>" +
(leg.inst_id || leg.symbol || "") +
"</td><td>" +
fmtPnl(leg.realized_pnl) +
"</td><td>" +
(leg.close_reason || "") +
"</td></tr>"
);
})
.join("") +
"</tbody></table>";
} else {
legsHost.innerHTML = "";
}
}
}
function saveEntry() {
var tradeId = Number(($("or-trade-id") || {}).value || 0);
if (!tradeId) {
alert("请先在下方列表点选一笔本地记录");
return;
}
var strategy = (($("or-f-strategy") || {}).value || "").trim();
if (!strategy) {
alert("请填写策略标签");
return;
}
var payload = {
trade_id: tradeId,
strategy_tag: strategy,
direction_view: ($("or-f-direction") || {}).value || "",
exit_reason: ($("or-f-exit") || {}).value || "",
followed_plan: ($("or-f-followed") || {}).value || "",
result_tag: ($("or-f-result") || {}).value || "",
mistake_tags: collectMoodTags(),
entry_logic: ($("or-f-entry") || {}).value || "",
note: ($("or-f-note") || {}).value || "",
images: collectImages(),
};
($("or-save-status") || {}).textContent = "保存中…";
fetch("/api/options/review/entry", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data.ok) {
($("or-save-status") || {}).textContent = data.msg || "保存失败";
return;
}
($("or-save-status") || {}).textContent = "已保存";
reloadAll();
})
.catch(function () {
($("or-save-status") || {}).textContent = "保存失败";
});
}
function deleteEntry() {
var tradeId = Number(($("or-trade-id") || {}).value || 0);
if (!tradeId) return;
if (!confirm("删除该条复盘内容与图片?")) return;
fetch("/api/options/review/entry/" + tradeId, {
method: "DELETE",
credentials: "same-origin",
})
.then(function (r) {
return r.json();
})
.then(function () {
hideJournalForm();
reloadAll();
});
}
function init() {
if (!$("options-review-root")) return;
document.querySelectorAll(".or-tab").forEach(function (btn) {
btn.addEventListener("click", function () {
setActiveTab(btn.getAttribute("data-source"));
});
});
var reloadBtn = $("or-reload-btn");
var saveBtn = $("or-save-btn");
var clearBtn = $("or-clear-btn");
var delBtn = $("or-del-btn");
if (reloadBtn) reloadBtn.addEventListener("click", reloadAll);
if (saveBtn) saveBtn.addEventListener("click", saveEntry);
if (clearBtn) clearBtn.addEventListener("click", hideJournalForm);
if (delBtn) delBtn.addEventListener("click", deleteEntry);
["or-filter-uly", "or-filter-opt", "or-filter-reviewed", "or-include-hedge-legs"].forEach(
function (id) {
var el = $(id);
if (el) el.addEventListener("change", reloadAll);
}
);
bindUploadSlots();
hideJournalForm();
setActiveTab("option_spot");
}
global.OptionsReview = {
init: init,
openJournalForm: openJournalForm,
hideJournalForm: hideJournalForm,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(typeof window !== "undefined" ? window : globalThis);