Files
crypto_monitor/lib/common/static/options_review.js
T
dekun 10128d18bc Add OKX options review module with hedge plan entries.
Import closed OKX option history and closed hedge plans into one list for journaling, images, and stats without mixing contract reviews.

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

510 lines
17 KiB
JavaScript

/**
* OKX 期权复盘(含对冲):列表 / 同步 / 统计 / 编辑上传.
*/
(function (global) {
"use strict";
var SLOT_TFS = ["chart", "entry", "exit", "other"];
var SLOT_LABELS = { chart: "走势图", entry: "入场", exit: "离场", other: "其他" };
var currentTradeId = null;
var draftId = "";
function $(id) {
return document.getElementById(id);
}
function fmtPnl(v) {
if (v == null || v === "") return "—";
var n = Number(v);
if (Number.isNaN(n)) return "—";
var s = (n >= 0 ? "+" : "") + n.toFixed(2);
return s;
}
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 qs() {
var p = new URLSearchParams();
var source = ($("or-filter-source") || {}).value || "";
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 (source) p.set("source_type", source);
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 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 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 reloadAll() {
loadTrades();
loadStats();
}
function loadTrades() {
var tbody = $("or-trades-tbody");
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="8" 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="8" class="muted">加载失败</td></tr>';
return;
}
var rows = data.trades || [];
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">暂无记录,请先同步</td></tr>';
return;
}
tbody.innerHTML = rows
.map(function (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"
: "";
return (
"<tr 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>" +
"<td><button type=\"button\" class=\"btn-secondary or-edit-btn\" data-id=\"" +
t.id +
"\">复盘</button></td>" +
"</tr>"
);
})
.join("");
tbody.querySelectorAll(".or-edit-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
openEdit(Number(btn.getAttribute("data-id")));
});
});
})
.catch(function () {
tbody.innerHTML = '<tr><td colspan="8" 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 uploadSlot(input, file) {
if (!draftId || !file) return;
var status = input.parentElement && input.parentElement.querySelector(".or-upload-status");
var hidden = input.parentElement && input.parentElement.querySelector(".or-upload-hidden");
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 = "已上传";
input.value = "";
})
.catch(function () {
if (hidden) hidden.value = "";
if (status) status.textContent = "失败";
});
}
function openEdit(tradeId) {
currentTradeId = tradeId;
draftId = newDraftId();
var modal = $("or-edit-modal");
var body = $("or-edit-body");
var title = $("or-edit-title");
if (!modal || !body) return;
body.innerHTML = '<div class="muted">加载中…</div>';
modal.style.display = "flex";
fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" })
.then(function (r) {
return r.json();
})
.then(function (data) {
if (!data.ok || !data.trade) {
body.innerHTML = '<div class="muted">加载失败</div>';
return;
}
var t = data.trade;
var e = t.entry || {};
if (title) title.textContent = (t.source_label || "") + " · 复盘 #" + t.id;
var legsHtml = "";
if (t.legs && t.legs.length) {
legsHtml =
'<table class="options-strike-table" style="margin:8px 0"><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>";
}
var slots = SLOT_TFS.map(function (tf) {
return (
'<div class="or-upload-row" style="margin:6px 0">' +
"<label>" +
(SLOT_LABELS[tf] || tf) +
' <input type="file" accept="image/*" class="or-upload-input" data-tf="' +
tf +
'">' +
"</label>" +
'<input type="hidden" class="or-upload-hidden" data-tf="' +
tf +
'">' +
' <span class="or-upload-status muted"></span></div>'
);
}).join("");
body.innerHTML =
'<div class="muted" style="font-size:13px;margin-bottom:8px">' +
(t.inst_id || t.underlying || "") +
" · 盈亏 " +
fmtPnl(t.realized_pnl_total) +
(t.is_hedge
? " (永续 " +
fmtPnl(t.realized_pnl_perp) +
" / 期权 " +
fmtPnl(t.realized_pnl_options) +
")"
: "") +
"<br>开 " +
(t.opened_at || "—") +
" → 平 " +
(t.closed_at || "—") +
"</div>" +
legsHtml +
'<div class="form-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:8px">' +
'<input id="or-f-strategy" placeholder="策略标签" value="' +
esc(e.strategy_tag) +
'">' +
'<input id="or-f-direction" placeholder="方向判断" value="' +
esc(e.direction_view) +
'">' +
'<input id="or-f-exit" placeholder="离场原因" value="' +
esc(e.exit_reason) +
'">' +
'<select id="or-f-followed"><option value="">是否按计划</option>' +
opt("是", e.followed_plan) +
opt("否", e.followed_plan) +
opt("部分", e.followed_plan) +
"</select>" +
'<input id="or-f-result" placeholder="结果标签" value="' +
esc(e.result_tag) +
'">' +
'<input id="or-f-mistakes" placeholder="错误标签(逗号分隔)" value="' +
esc(e.mistake_tags) +
'">' +
"</div>" +
'<textarea id="or-f-entry" rows="2" placeholder="入场逻辑" style="width:100%;margin-top:8px">' +
esc(e.entry_logic) +
"</textarea>" +
'<textarea id="or-f-note" rows="3" placeholder="复盘备注" style="width:100%;margin-top:8px">' +
esc(e.note) +
"</textarea>" +
'<div style="margin-top:10px"><strong>截图</strong>' +
slots +
"</div>" +
'<div class="form-row" style="margin-top:12px;gap:8px">' +
'<button type="button" class="btn" id="or-save-btn">保存复盘</button>' +
'<button type="button" class="btn-secondary" id="or-del-btn">删除复盘</button>' +
"</div>";
body.querySelectorAll(".or-upload-input").forEach(function (input) {
input.addEventListener("change", function () {
var file = input.files && input.files[0];
if (file) uploadSlot(input, file);
});
});
// 回填已有图片文件名到 hidden(仅展示状态)
(e.images || []).forEach(function (img) {
var hidden = body.querySelector('.or-upload-hidden[data-tf="' + img.tf + '"]');
var status = hidden && hidden.parentElement.querySelector(".or-upload-status");
if (hidden && img.file) {
hidden.value = img.file;
if (status) status.textContent = "已有 " + img.file;
}
});
$("or-save-btn").addEventListener("click", saveEntry);
$("or-del-btn").addEventListener("click", deleteEntry);
});
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/"/g, "&quot;");
}
function opt(v, cur) {
return '<option value="' + v + '"' + (cur === v ? " selected" : "") + ">" + v + "</option>";
}
function collectImages() {
var body = $("or-edit-body");
if (!body) return [];
var out = [];
body.querySelectorAll(".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 saveEntry() {
if (!currentTradeId) return;
var payload = {
trade_id: currentTradeId,
strategy_tag: ($("or-f-strategy") || {}).value || "",
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: ($("or-f-mistakes") || {}).value || "",
entry_logic: ($("or-f-entry") || {}).value || "",
note: ($("or-f-note") || {}).value || "",
images: collectImages(),
};
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) {
alert(data.msg || "保存失败");
return;
}
closeModal();
reloadAll();
})
.catch(function () {
alert("保存失败");
});
}
function deleteEntry() {
if (!currentTradeId) return;
if (!confirm("删除该条复盘内容与图片?")) return;
fetch("/api/options/review/entry/" + currentTradeId, {
method: "DELETE",
credentials: "same-origin",
})
.then(function (r) {
return r.json();
})
.then(function () {
closeModal();
reloadAll();
});
}
function closeModal(ev) {
if (ev && ev.target && ev.target.id !== "or-edit-modal") return;
var modal = $("or-edit-modal");
if (modal) modal.style.display = "none";
currentTradeId = null;
}
function init() {
if (!$("options-review-root")) return;
var syncBtn = $("or-sync-btn");
var reloadBtn = $("or-reload-btn");
if (syncBtn) syncBtn.addEventListener("click", syncNow);
if (reloadBtn) reloadBtn.addEventListener("click", reloadAll);
["or-filter-source", "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);
}
);
reloadAll();
}
global.OptionsReview = { init: init, closeModal: closeModal, openEdit: openEdit };
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(typeof window !== "undefined" ? window : globalThis);