Files
crypto_monitor/lib/common/static/options_review.js
T
dekun edcc04e88c Improve options review tabs and multi-timeframe uploads.
Add source-type tabs, open an inline journal form from list rows, and match contract-style 5m/15m/1h/4h screenshot slots.

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

584 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 syncNow() {
setSyncStatus("同步中…");
fetch("/api/options/review/sync", { method: "POST", credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
var parts = [];
if (data.options) {
parts.push(
data.options.ok
? "期权 +" + (data.options.inserted || 0) + "/更" + (data.options.updated || 0)
: "期权:" + (data.options.msg || "失败")
);
}
if (data.hedge) {
parts.push(
data.hedge.ok
? "对冲 +" + (data.hedge.inserted || 0) + "/更" + (data.hedge.updated || 0)
: "对冲失败"
);
}
setSyncStatus(parts.join(" · ") || "完成");
reloadAll();
})
.catch(function () {
setSyncStatus("同步失败");
});
}
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 reloadAll() {
loadTrades();
loadStats();
}
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) {
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 = "";
return;
}
if (!draftId) draftId = newDraftId();
if (status) status.textContent = "上传中…";
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;
input.value = "";
})
.catch(function () {
if (hidden) hidden.value = "";
if (status) status.textContent = "上传失败";
});
});
});
}
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.hidden = true;
document.querySelectorAll(".or-trade-row").forEach(function (tr) {
tr.classList.remove("or-row-active");
});
}
function openJournalForm(tradeId) {
currentTradeId = tradeId;
var card = $("or-journal-card");
if (!card) return;
card.hidden = false;
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 = "";
})
.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) 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 syncBtn = $("or-sync-btn");
var reloadBtn = $("or-reload-btn");
var saveBtn = $("or-save-btn");
var clearBtn = $("or-clear-btn");
var delBtn = $("or-del-btn");
if (syncBtn) syncBtn.addEventListener("click", syncNow);
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();
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);