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>
This commit is contained in:
@@ -9,6 +9,8 @@
|
||||
strategy: "/strategy",
|
||||
strategy_records: "/strategy/records",
|
||||
options: "/options",
|
||||
options_review: "/options/review",
|
||||
hedge_plan: "/hedge-plan",
|
||||
records: "/records",
|
||||
stats: "/stats",
|
||||
risk_policy: "/risk_policy",
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
records: "show_nav_records",
|
||||
stats: "show_nav_stats",
|
||||
options: "show_nav_options",
|
||||
"options-review": "show_nav_options_review",
|
||||
options_review: "show_nav_options_review",
|
||||
"hedge-plan": "show_nav_hedge_plan",
|
||||
hedge_plan: "show_nav_hedge_plan",
|
||||
risk_policy: "show_nav_risk_policy",
|
||||
@@ -50,6 +52,8 @@
|
||||
records: "show_nav_records",
|
||||
stats: "show_nav_stats",
|
||||
options: "show_nav_options",
|
||||
"options-review": "show_nav_options_review",
|
||||
options_review: "show_nav_options_review",
|
||||
"hedge-plan": "show_nav_hedge_plan",
|
||||
hedge_plan: "show_nav_hedge_plan",
|
||||
risk_policy: "show_nav_risk_policy",
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user