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>
This commit is contained in:
dekun
2026-07-17 09:45:44 +08:00
parent 10128d18bc
commit edcc04e88c
5 changed files with 395 additions and 251 deletions
+5 -1
View File
@@ -118,9 +118,13 @@ OKX_OPTIONS_API_PASSPHRASE=...
### 图片
- 目录:`static/images/options_journal/`
- 文件名:`options_journal_{draftId}_{chart|entry|exit|other}.ext`
- 文件名:`options_journal_{draftId}_{5m|15m|1h|4h}.ext`(与合约复盘同周期槽位)
- 备份时与 `crypto.db` 一并打包即可;勿与合约 `journal_*` 截图混用.
### 页面
顶部三个 Tab:**期权交易记录** / **期期对冲记录** / **永期对冲记录**.点击列表行后在下方打开「复盘记录上传」,支持四周期即时截图与情绪标签.
### 统计
同页 KPI + 分组:类型、标的、策略标签、对冲结束原因、持有周期、Call/Put.策略维度仅统计已填策略标签的记录.
+291 -217
View File
@@ -1,13 +1,19 @@
/**
* OKX 期权复盘(含对冲):列表 / 同步 / 统计 / 编辑上传.
* OKX 期权复盘:三类 Tab + 列表点选 + 下方多周期(5m/15m/1h/4h)复盘表单.
*/
(function (global) {
"use strict";
var SLOT_TFS = ["chart", "entry", "exit", "other"];
var SLOT_LABELS = { chart: "走势图", entry: "入场", exit: "离场", other: "其他" };
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);
@@ -17,8 +23,7 @@
if (v == null || v === "") return "—";
var n = Number(v);
if (Number.isNaN(n)) return "—";
var s = (n >= 0 ? "+" : "") + n.toFixed(2);
return s;
return (n >= 0 ? "+" : "") + n.toFixed(2);
}
function fmtHold(sec) {
@@ -29,24 +34,11 @@
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 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() {
@@ -58,6 +50,25 @@
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 || "";
@@ -93,6 +104,17 @@
});
}
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();
@@ -101,23 +123,25 @@
function loadTrades() {
var tbody = $("or-trades-tbody");
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="8" class="muted">加载中…</td></tr>';
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="8" class="muted">加载失败</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
return;
}
var rows = data.trades || [];
tradesCache = {};
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">暂无记录,请先同步</td></tr>';
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 || "—"
@@ -130,22 +154,25 @@
: Number(t.realized_pnl_total) < 0
? "color:#f07178"
: "";
var active = currentTradeId === t.id ? " or-row-active" : "";
return (
"<tr data-id=\"" +
'<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=\"" +
'<td style="' +
pnlClass +
"\">" +
'">' +
fmtPnl(t.realized_pnl_total) +
"</td>" +
"<td class=\"muted\" style=\"font-size:12px\">" +
'<td class="muted" style="font-size:12px">' +
(t.opened_at || "—") +
"<br>" +
(t.closed_at || "—") +
@@ -159,21 +186,18 @@
"<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")));
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="8" class="muted">加载失败</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="muted">加载失败</td></tr>';
});
}
@@ -189,7 +213,7 @@
.slice(0, 8)
.map(function (g) {
return (
"<div style=\"display:flex;justify-content:space-between;gap:8px;font-size:13px\">" +
'<div style="display:flex;justify-content:space-between;gap:8px;font-size:13px">' +
"<span>" +
g.key +
" · " +
@@ -204,7 +228,13 @@
);
})
.join("");
return '<div class="or-stat-card"><div style="font-weight:600;margin-bottom:6px">' + title + "</div>" + lines + "</div>";
return (
'<div class="or-stat-card"><div style="font-weight:600;margin-bottom:6px">' +
title +
"</div>" +
lines +
"</div>"
);
}
function loadStats() {
@@ -248,197 +278,231 @@
.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 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 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>';
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;
}
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);
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 = "上传失败";
});
});
// 回填已有图片文件名到 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 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 opt(v, cur) {
return '<option value="' + v + '"' + (cur === v ? " selected" : "") + ">" + v + "</option>";
function collectMoodTags() {
var out = [];
document.querySelectorAll(".or-mood:checked").forEach(function (cb) {
out.push(cb.value);
});
return out.join(",");
}
function collectImages() {
var body = $("or-edit-body");
if (!body) return [];
var out = [];
body.querySelectorAll(".or-upload-hidden").forEach(function (el) {
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() {
if (!currentTradeId) return;
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: currentTradeId,
strategy_tag: ($("or-f-strategy") || {}).value || "",
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: ($("or-f-mistakes") || {}).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",
@@ -450,21 +514,22 @@
})
.then(function (data) {
if (!data.ok) {
alert(data.msg || "保存失败");
($("or-save-status") || {}).textContent = data.msg || "保存失败";
return;
}
closeModal();
($("or-save-status") || {}).textContent = "已保存";
reloadAll();
})
.catch(function () {
alert("保存失败");
($("or-save-status") || {}).textContent = "保存失败";
});
}
function deleteEntry() {
if (!currentTradeId) return;
var tradeId = Number(($("or-trade-id") || {}).value || 0);
if (!tradeId) return;
if (!confirm("删除该条复盘内容与图片?")) return;
fetch("/api/options/review/entry/" + currentTradeId, {
fetch("/api/options/review/entry/" + tradeId, {
method: "DELETE",
credentials: "same-origin",
})
@@ -472,34 +537,43 @@
return r.json();
})
.then(function () {
closeModal();
hideJournalForm();
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;
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);
["or-filter-source", "or-filter-uly", "or-filter-opt", "or-filter-reviewed", "or-include-hedge-legs"].forEach(
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);
}
);
reloadAll();
bindUploadSlots();
setActiveTab("option_spot");
}
global.OptionsReview = { init: init, closeModal: closeModal, openEdit: openEdit };
global.OptionsReview = {
init: init,
openJournalForm: openJournalForm,
hideJournalForm: hideJournalForm,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
+3 -3
View File
@@ -1,4 +1,4 @@
"""期权复盘截图:独立命名空间,与合约 journal 混用."""
"""期权复盘截图:独立命名空间,与合约同款四周期 5m/15m/1h/4h."""
from __future__ import annotations
import json
@@ -6,11 +6,11 @@ import os
import re
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
OPTIONS_REVIEW_UPLOAD_TFS: tuple[str, ...] = ("chart", "entry", "exit", "other")
OPTIONS_REVIEW_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h")
OPTIONS_REVIEW_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"})
_DRAFT_ID_RE = re.compile(r"^[a-f0-9]{32}$")
_SLOT_FILE_RE = re.compile(
r"^options_journal_([a-f0-9]{32})_(chart|entry|exit|other)\.(png|jpg|jpeg|webp|gif|bmp)$",
r"^options_journal_([a-f0-9]{32})_(5m|15m|1h|4h)\.(png|jpg|jpeg|webp|gif|bmp)$",
re.I,
)
+93 -27
View File
@@ -1,28 +1,38 @@
{# OKX 期权复盘(含对冲):独立页,不混合约 journal #}
{# OKX 期权复盘:三类 Tab + 列表 + 下方多周期截图复盘表单 #}
<div class="options-review-wrap" id="options-review-root" style="grid-column:1/-1">
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权未启用:请设置 <code>OKX_OPTIONS_ENABLED=true</code> 后重启.对冲计划仍可单独同步(若本地有已结束计划).</div>
{% endif %}
<style>
.or-tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
.or-tab{border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;padding:8px 14px;border-radius:8px;cursor:pointer}
.or-tab.active{background:rgba(59,130,246,.25);border-color:rgba(59,130,246,.55)}
.or-badge{display:inline-block;padding:2px 8px;border-radius:999px;background:rgba(127,127,127,.2);font-size:12px}
.or-stat-card{border:1px solid rgba(127,127,127,.25);border-radius:8px;padding:10px}
.or-trades-table tr.or-row-active{outline:1px solid rgba(59,130,246,.55);background:rgba(59,130,246,.08)}
.or-trades-table tbody tr{cursor:pointer}
.or-upload-slots{display:flex;flex-direction:column;gap:8px;margin:10px 0}
.or-upload-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.or-upload-slot-label{min-width:36px;font-weight:600}
.or-mood-grid{display:flex;flex-wrap:wrap;gap:10px 14px;margin:10px 0}
.or-form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-bottom:8px}
.or-form-grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:8px;margin-bottom:8px}
</style>
<div class="card" style="margin-bottom:12px">
<div class="form-row" style="flex-wrap:wrap;align-items:center;gap:8px">
<h2 style="margin:0;margin-right:auto">期权复盘</h2>
<button type="button" class="btn-secondary" id="or-sync-btn">从 OKX / 对冲计划同步</button>
<span class="muted" id="or-sync-status"></span>
</div>
<p class="muted" style="margin:8px 0 0">同一列表三类:<strong>纯期权</strong>(交易所已平仓)、<strong>永期对冲</strong> / <strong>期期对冲</strong>(本地已结束计划,计划级一条).对冲盈亏用合计;归属对冲的期权腿默认不重复计入.</p>
<style>
.or-badge{display:inline-block;padding:2px 8px;border-radius:999px;background:rgba(127,127,127,.2);font-size:12px}
.or-stat-card{border:1px solid rgba(127,127,127,.25);border-radius:8px;padding:10px}
#or-edit-modal{align-items:center;justify-content:center;background:rgba(0,0,0,.55)}
</style>
<div class="or-tabs" role="tablist" aria-label="复盘分类">
<button type="button" class="or-tab active" data-source="option_spot" role="tab">期权交易记录</button>
<button type="button" class="or-tab" data-source="options_options" role="tab">期期对冲记录</button>
<button type="button" class="or-tab" data-source="perp_options" role="tab">永期对冲记录</button>
</div>
<p class="muted" style="margin:0">点列表行进入下方「复盘记录上传」;截图槽位与合约一致(5m / 15m / 1h / 4h).对冲盈亏用计划合计;归属对冲的期权腿默认不重复计入.</p>
<div class="form-row" style="flex-wrap:wrap;gap:8px;margin-top:10px">
<select id="or-filter-source">
<option value="">类型:全部</option>
<option value="option_spot">纯期权</option>
<option value="perp_options">永期对冲</option>
<option value="options_options">期期对冲</option>
</select>
<select id="or-filter-uly">
<option value="">标的:全部</option>
<option value="ETH">ETH</option>
@@ -54,10 +64,10 @@
<div id="or-stats-groups" style="margin-top:12px;display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px"></div>
</div>
<div class="card">
<h3 style="margin-top:0">交易列表</h3>
<div class="card" style="margin-bottom:12px">
<h3 style="margin-top:0" id="or-list-title">期权交易记录</h3>
<div class="options-strike-table-wrap">
<table class="options-strike-table" id="or-trades-table">
<table class="options-strike-table or-trades-table" id="or-trades-table">
<thead>
<tr>
<th>类型</th>
@@ -67,25 +77,81 @@
<th>持有</th>
<th>策略</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="or-trades-tbody">
<tr><td colspan="8" class="muted">加载中…</td></tr>
<tr><td colspan="7" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="detail-modal" id="or-edit-modal" style="display:none" onclick="OptionsReview.closeModal(event)">
<div class="panel" style="max-width:720px;width:94%" onclick="event.stopPropagation()">
<div class="panel-head">
<div class="panel-title" id="or-edit-title">复盘编辑</div>
<button type="button" class="panel-close" onclick="OptionsReview.closeModal()">关闭</button>
</div>
<div class="panel-body" id="or-edit-body"></div>
<div class="card" id="or-journal-card" hidden>
<h2 style="margin-top:0">复盘记录上传(含截图)</h2>
<p class="muted" id="or-journal-summary" style="margin-top:0"></p>
<form id="or-journal-form" onsubmit="return false;">
<input type="hidden" id="or-trade-id" value="">
<input type="hidden" id="or-draft-id" value="">
<div class="or-form-grid">
<input type="datetime-local" id="or-f-open" title="开仓时间" readonly>
<input type="datetime-local" id="or-f-close" title="平仓时间" readonly>
<input type="text" id="or-f-coin" placeholder="标的" readonly>
<input type="text" id="or-f-inst" placeholder="合约/计划" readonly>
<input type="text" id="or-f-pnl" placeholder="盈亏" readonly>
<input type="text" id="or-f-hold" placeholder="持有" readonly>
</div>
<div class="or-form-grid2">
<input type="text" id="or-f-strategy" placeholder="策略标签" required>
<input type="text" id="or-f-direction" placeholder="方向判断">
<select id="or-f-exit" title="离场原因">
<option value="">离场原因</option>
<option value="止盈">止盈</option>
<option value="止损">止损</option>
<option value="到期">到期</option>
<option value="目标价">目标价</option>
<option value="手动平仓">手动平仓</option>
<option value="其他">其他</option>
</select>
<select id="or-f-followed" title="是否按计划">
<option value="">是否按计划</option>
<option value="是"></option>
<option value="否"></option>
<option value="部分">部分</option>
</select>
<input type="text" id="or-f-result" placeholder="结果标签">
</div>
<textarea id="or-f-entry" rows="2" placeholder="入场逻辑" style="width:100%;margin-bottom:8px"></textarea>
<div class="or-upload-slots" id="or-upload-slots">
{% for tf in ['5m', '15m', '1h', '4h'] %}
<div class="or-upload-row journal-upload-row" data-tf="{{ tf }}">
<span class="or-upload-slot-label">{{ tf }}</span>
<input type="file" accept="image/*" class="or-upload-input" data-tf="{{ tf }}">
<input type="hidden" class="or-upload-hidden" data-tf="{{ tf }}" value="">
<span class="or-upload-status muted" data-tf="{{ tf }}" aria-live="polite"></span>
</div>
{% endfor %}
</div>
<p class="sub muted" style="margin-top:0">可只传部分周期;选文件后即时上传,保存后详情可查看</p>
<div class="or-mood-grid">
<label><input type="checkbox" class="or-mood" value="怕踏空">怕踏空</label>
<label><input type="checkbox" class="or-mood" value="报复开仓">报复开仓</label>
<label><input type="checkbox" class="or-mood" value="盈利飘了">盈利飘了</label>
<label><input type="checkbox" class="or-mood" value="拿不住单">拿不住单</label>
<label><input type="checkbox" class="or-mood" value="扛单">扛单</label>
<label><input type="checkbox" class="or-mood" value="重仓违规">重仓违规</label>
</div>
<textarea id="or-f-note" rows="2" placeholder="备注" style="width:100%"></textarea>
<div class="form-row" style="margin-top:10px;gap:8px">
<button type="button" class="btn" id="or-save-btn">保存复盘记录</button>
<button type="button" class="btn-secondary" id="or-clear-btn">收起</button>
<button type="button" class="btn-secondary" id="or-del-btn">删除复盘</button>
<span class="muted" id="or-save-status"></span>
</div>
<div id="or-legs-host" style="margin-top:12px"></div>
</form>
</div>
</div>
<script src="/static/options_review.js?v=1"></script>
<script src="/static/options_review.js?v=2"></script>
+3 -3
View File
@@ -230,14 +230,14 @@ class OptionsReviewTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
folder = options_review_upload_dir(tmp)
fname = build_options_review_slot_filename(
"a" * 32, "chart", ".png", secure_filename_fn=lambda x: x
"a" * 32, "5m", ".png", secure_filename_fn=lambda x: x
)
self.assertTrue(fname.startswith("options_journal_"))
self.assertTrue(is_valid_options_review_file(fname, "a" * 32, "chart"))
self.assertTrue(is_valid_options_review_file(fname, "a" * 32, "5m"))
item = save_options_review_slot_file(
_FakeFile("x.png"),
"a" * 32,
"chart",
"5m",
folder,
secure_filename_fn=lambda x: x,
)