diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py
index 64708fe..b2e571e 100644
--- a/crypto_monitor_okx/app.py
+++ b/crypto_monitor_okx/app.py
@@ -9572,6 +9572,10 @@ from lib.options.options_register import install_options_trading
install_options_trading(app, _REPO_ROOT, app_module=sys.modules[__name__])
+from lib.options.options_review_register import install_options_review
+
+install_options_review(app, _REPO_ROOT, app_module=sys.modules[__name__])
+
from lib.hedge_plan.hedge_plan_register import install_hedge_plan
install_hedge_plan(app, _REPO_ROOT, app_module=sys.modules[__name__])
diff --git a/docs/期权用法.md b/docs/期权用法.md
index d4721d9..9ed3158 100644
--- a/docs/期权用法.md
+++ b/docs/期权用法.md
@@ -98,7 +98,34 @@ OKX_OPTIONS_API_PASSPHRASE=...
| `OKX_OPTIONS_ITM_MAX_DIST_USD` | 30 | 轻度实值:价内不超过多少 USD |
| `OKX_OPTIONS_PROFIT_ALERT_RATIO` | 1.0 | 浮盈/权利金 ≥ 此值推送 |
-## 8. 常见问题
+## 8. 期权复盘(含对冲)
+
+仅 **OKX** 实例提供独立页 **期权复盘**(`/options/review`),与合约「交易记录与复盘」完全隔离.
+
+### 数据来源
+
+| 类型 | source_type | 来源 | 粒度 |
+|------|-------------|------|------|
+| 纯期权 | `option_spot` | OKX `positions-history` 已全平仓位 | 一仓一条 |
+| 永期对冲 | `perp_options` | 本地 `hedge_plans` 且 `status=closed` | **一计划一条** |
+| 期期对冲 | `options_options` | 同上 | **一计划一条** |
+
+- 同步按钮:`POST /api/options/review/sync`(只读导入,不下单/不平仓).
+- 对冲盈亏主口径:`realized_pnl_total`;详情另显永续/期权分项.
+- 若某纯期权 `inst_id` 已出现在对冲腿中,默认标记排除,避免总盈亏双计(可勾选「含已归属对冲的期权腿」查看).
+- 人工复盘字段(策略/备注/图片)存在 `options_review_entries`,再次同步**不会覆盖**.
+
+### 图片
+
+- 目录:`static/images/options_journal/`
+- 文件名:`options_journal_{draftId}_{chart|entry|exit|other}.ext`
+- 备份时与 `crypto.db` 一并打包即可;勿与合约 `journal_*` 截图混用.
+
+### 统计
+
+同页 KPI + 分组:类型、标的、策略标签、对冲结束原因、持有周期、Call/Put.策略维度仅统计已填策略标签的记录.
+
+## 9. 常见问题
**Q:为什么买不了?**
- 交易账户 USDC 不足 → 先兑换再划转
@@ -111,7 +138,7 @@ OKX_OPTIONS_API_PASSPHRASE=...
**Q:子账户能开期权吗?**
- 本系统期权走主账户 API;子账户永续不受影响.
-## 9. 风险说明
+## 10. 风险说明
- 买方最大亏损为 **权利金**;近期实值仍会时间衰减
- 限价单可能因无流动性未成交
diff --git a/lib/common/static/instance_embed.js b/lib/common/static/instance_embed.js
index f5c3d2c..b6f5c9e 100644
--- a/lib/common/static/instance_embed.js
+++ b/lib/common/static/instance_embed.js
@@ -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",
diff --git a/lib/common/static/instance_settings_prefs.js b/lib/common/static/instance_settings_prefs.js
index c5a7544..cf235fb 100644
--- a/lib/common/static/instance_settings_prefs.js
+++ b/lib/common/static/instance_settings_prefs.js
@@ -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",
diff --git a/lib/common/static/options_review.js b/lib/common/static/options_review.js
new file mode 100644
index 0000000..8c55e53
--- /dev/null
+++ b/lib/common/static/options_review.js
@@ -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 = '
| 加载中… |
';
+ fetch("/api/options/review/trades?" + qs(), { credentials: "same-origin" })
+ .then(function (r) {
+ return r.json();
+ })
+ .then(function (data) {
+ if (!data.ok) {
+ tbody.innerHTML = '| 加载失败 |
';
+ return;
+ }
+ var rows = data.trades || [];
+ if (!rows.length) {
+ tbody.innerHTML = '| 暂无记录,请先同步 |
';
+ 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 (
+ "" +
+ "| " +
+ (t.source_label || t.source_type) +
+ " | " +
+ "" +
+ title +
+ " | " +
+ "" +
+ fmtPnl(t.realized_pnl_total) +
+ " | " +
+ "" +
+ (t.opened_at || "—") +
+ " " +
+ (t.closed_at || "—") +
+ " | " +
+ "" +
+ fmtHold(t.hold_seconds) +
+ " | " +
+ "" +
+ (t.strategy_tag || "—") +
+ " | " +
+ "" +
+ (t.reviewed ? "已复盘" : "待复盘") +
+ " | " +
+ " | " +
+ "
"
+ );
+ })
+ .join("");
+ tbody.querySelectorAll(".or-edit-btn").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ openEdit(Number(btn.getAttribute("data-id")));
+ });
+ });
+ })
+ .catch(function () {
+ tbody.innerHTML = '| 加载失败 |
';
+ });
+ }
+
+ function renderGroup(title, items) {
+ if (!items || !items.length) {
+ return (
+ ''
+ );
+ }
+ var lines = items
+ .slice(0, 8)
+ .map(function (g) {
+ return (
+ "" +
+ "" +
+ g.key +
+ " · " +
+ g.count +
+ "笔" +
+ "" +
+ fmtPnl(g.pnl_sum) +
+ " / 胜" +
+ (g.win_rate || 0) +
+ "%" +
+ "
"
+ );
+ })
+ .join("");
+ return '' + title + "
" + lines + "
";
+ }
+
+ 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 (
+ '' +
+ pair[0] +
+ '
' +
+ pair[1] +
+ "
"
+ );
+ })
+ .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 = '加载中…
';
+ 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 = '加载失败
';
+ 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 =
+ '| 腿 | 合约 | 盈亏 | 原因 |
' +
+ t.legs
+ .map(function (leg) {
+ return (
+ "| " +
+ (leg.leg_role || "") +
+ " | " +
+ (leg.inst_id || leg.symbol || "") +
+ " | " +
+ fmtPnl(leg.realized_pnl) +
+ " | " +
+ (leg.close_reason || "") +
+ " |
"
+ );
+ })
+ .join("") +
+ "
";
+ }
+ var slots = SLOT_TFS.map(function (tf) {
+ return (
+ '' +
+ "" +
+ '' +
+ '
'
+ );
+ }).join("");
+ body.innerHTML =
+ '' +
+ (t.inst_id || t.underlying || "") +
+ " · 盈亏 " +
+ fmtPnl(t.realized_pnl_total) +
+ (t.is_hedge
+ ? " (永续 " +
+ fmtPnl(t.realized_pnl_perp) +
+ " / 期权 " +
+ fmtPnl(t.realized_pnl_options) +
+ ")"
+ : "") +
+ "
开 " +
+ (t.opened_at || "—") +
+ " → 平 " +
+ (t.closed_at || "—") +
+ "
" +
+ legsHtml +
+ '' +
+ '' +
+ '' +
+ '' +
+ '" +
+ '' +
+ '' +
+ "
" +
+ '" +
+ '" +
+ '截图' +
+ slots +
+ "
" +
+ '' +
+ '' +
+ '' +
+ "
";
+ 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(/" + v + "";
+ }
+
+ 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);
diff --git a/lib/instance/instance_display_prefs_lib.py b/lib/instance/instance_display_prefs_lib.py
index e5ad58b..f3f4d4e 100644
--- a/lib/instance/instance_display_prefs_lib.py
+++ b/lib/instance/instance_display_prefs_lib.py
@@ -15,6 +15,7 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
"show_nav_risk_policy": True,
"show_nav_env_config": True,
"show_nav_options": True,
+ "show_nav_options_review": True,
"show_nav_hedge_plan": True,
"show_settings_transfer": True,
"show_settings_export": True,
@@ -31,6 +32,7 @@ DISPLAY_LABELS: dict[str, str] = {
"show_nav_risk_policy": "风控说明",
"show_nav_env_config": "env配置",
"show_nav_options": "期权",
+ "show_nav_options_review": "期权复盘",
"show_nav_hedge_plan": "对冲计划",
"show_settings_transfer": "资金划转",
"show_settings_export": "数据导出",
@@ -47,6 +49,7 @@ NAV_TAB_ALLOWED: dict[str, str] = {
"risk_policy": "show_nav_risk_policy",
"env_config": "show_nav_env_config",
"options": "show_nav_options",
+ "options_review": "show_nav_options_review",
"hedge_plan": "show_nav_hedge_plan",
}
@@ -107,6 +110,7 @@ def display_meta_for_ui() -> list[dict[str, Any]]:
"show_nav_risk_policy",
"show_nav_env_config",
"show_nav_options",
+ "show_nav_options_review",
"show_nav_hedge_plan",
]
settings_keys = [
diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py
index 4f85483..812900b 100644
--- a/lib/instance/instance_embed_lib.py
+++ b/lib/instance/instance_embed_lib.py
@@ -16,6 +16,7 @@ EMBED_TABS: tuple[str, ...] = (
"strategy",
"strategy_records",
"options",
+ "options_review",
"hedge_plan",
"records",
"stats",
@@ -33,6 +34,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
"/strategy/roll": "strategy",
"/strategy/records": "strategy_records",
"/options": "options",
+ "/options/review": "options_review",
"/hedge-plan": "hedge_plan",
"/records": "records",
"/stats": "stats",
diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html
index 34a8d78..9ba7d14 100644
--- a/lib/instance/templates/embed_page_fragment.html
+++ b/lib/instance/templates/embed_page_fragment.html
@@ -293,6 +293,8 @@
{% include 'strategy_records_page.html' %}
{% elif page == 'options' %}
{% include 'options_panel.html' %}
+ {% elif page == 'options_review' %}
+ {% include 'options_review_panel.html' %}
{% elif page == 'hedge_plan' %}
{% include 'hedge_plan_panel.html' %}
{% endif %}
diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html
index d74309e..c9dbebe 100644
--- a/lib/instance/templates/embed_shell.html
+++ b/lib/instance/templates/embed_shell.html
@@ -46,6 +46,9 @@
{% if options_nav_visible and display.show_nav_options %}
期权
{% endif %}
+ {% if options_nav_visible and display.show_nav_options_review %}
+ 期权复盘
+ {% endif %}
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
对冲计划
{% endif %}
diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html
index 229798c..684995d 100644
--- a/lib/instance/templates/index.html
+++ b/lib/instance/templates/index.html
@@ -133,6 +133,9 @@
{% if options_nav_visible and display.show_nav_options %}
期权
{% endif %}
+ {% if options_nav_visible and display.show_nav_options_review %}
+ 期权复盘
+ {% endif %}
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
对冲计划
{% endif %}
@@ -147,7 +150,7 @@
{% with msg=get_flashed_messages() %}{% if msg %}{{ msg[0] }}
{% endif %}{% endwith %}
{% include 'instance_header_panel.html' %}
- {% if page not in ('settings', 'risk_policy', 'env_config', 'options', 'hedge_plan') %}
+ {% if page not in ('settings', 'risk_policy', 'env_config', 'options', 'options_review', 'hedge_plan') %}
{% include 'instance_top_bar.html' %}
{% endif %}
@@ -364,6 +367,8 @@
{% include 'strategy_records_page.html' %}
{% elif page == 'options' %}
{% include 'options_panel.html' %}
+ {% elif page == 'options_review' %}
+ {% include 'options_review_panel.html' %}
{% elif page == 'hedge_plan' %}
{% include 'hedge_plan_panel.html' %}
{% endif %}
diff --git a/lib/options/options_db.py b/lib/options/options_db.py
index f30bdb1..2d68110 100644
--- a/lib/options/options_db.py
+++ b/lib/options/options_db.py
@@ -5,6 +5,8 @@ import sqlite3
def init_options_tables(conn: sqlite3.Connection) -> None:
+ from lib.options.options_review_db import init_options_review_tables
+
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_trades (
@@ -93,6 +95,7 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
ON options_target_monitors(status)
"""
)
+ init_options_review_tables(conn)
def sum_open_premium_paid(conn: sqlite3.Connection, inst_id: str) -> float | None:
diff --git a/lib/options/options_review_db.py b/lib/options/options_review_db.py
new file mode 100644
index 0000000..5a31c88
--- /dev/null
+++ b/lib/options/options_review_db.py
@@ -0,0 +1,127 @@
+"""期权复盘(含对冲) SQLite 表."""
+from __future__ import annotations
+
+import sqlite3
+
+
+SOURCE_OPTION = "option_spot"
+SOURCE_PERP_OPTIONS = "perp_options"
+SOURCE_OPTIONS_OPTIONS = "options_options"
+SOURCE_TYPES = (SOURCE_OPTION, SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS)
+
+
+def init_options_review_tables(conn: sqlite3.Connection) -> None:
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS options_review_trades (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source_type TEXT NOT NULL,
+ history_key TEXT NOT NULL UNIQUE,
+ underlying TEXT,
+ opened_at TEXT,
+ closed_at TEXT,
+ hold_seconds INTEGER,
+ realized_pnl_total REAL,
+ status_raw TEXT,
+ synced_at TEXT,
+ -- 纯期权
+ pos_id TEXT,
+ inst_id TEXT,
+ opt_type TEXT,
+ strike REAL,
+ exp_time TEXT,
+ sheets INTEGER,
+ open_avg REAL,
+ close_avg REAL,
+ premium_paid REAL,
+ realized_pnl REAL,
+ -- 对冲计划
+ hedge_plan_id INTEGER,
+ plan_close_reason TEXT,
+ realized_pnl_perp REAL,
+ realized_pnl_options REAL,
+ premium_total REAL,
+ direction TEXT,
+ tp REAL,
+ sl REAL,
+ target_price REAL,
+ target_price_up REAL,
+ target_price_down REAL,
+ legs_json TEXT,
+ -- 双计防护:纯期权腿已归属对冲计划
+ linked_hedge_plan_id INTEGER,
+ excluded_as_hedge_leg INTEGER DEFAULT 0
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_options_review_trades_history_key
+ ON options_review_trades(history_key)
+ """
+ )
+ conn.execute(
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_options_review_trades_hedge_plan
+ ON options_review_trades(hedge_plan_id)
+ WHERE hedge_plan_id IS NOT NULL
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_options_review_trades_closed
+ ON options_review_trades(closed_at)
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS idx_options_review_trades_source
+ ON options_review_trades(source_type)
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS options_review_entries (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ trade_id INTEGER NOT NULL UNIQUE,
+ strategy_tag TEXT,
+ direction_view TEXT,
+ entry_logic TEXT,
+ exit_reason TEXT,
+ followed_plan TEXT,
+ mistake_tags TEXT,
+ result_tag TEXT,
+ note TEXT,
+ images_json TEXT,
+ image TEXT,
+ reviewed_at TEXT,
+ updated_at TEXT,
+ FOREIGN KEY(trade_id) REFERENCES options_review_trades(id)
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS options_review_sync_state (
+ key TEXT PRIMARY KEY,
+ value TEXT,
+ updated_at TEXT
+ )
+ """
+ )
+ _ensure_column(conn, "options_review_trades", "linked_hedge_plan_id", "INTEGER")
+ _ensure_column(conn, "options_review_trades", "excluded_as_hedge_leg", "INTEGER DEFAULT 0")
+ _ensure_column(conn, "options_review_trades", "target_price_up", "REAL")
+ _ensure_column(conn, "options_review_trades", "target_price_down", "REAL")
+
+
+def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
+ rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
+ names: set[str] = set()
+ for r in rows:
+ try:
+ names.add(str(r["name"]))
+ except (TypeError, KeyError, IndexError):
+ names.add(str(r[1]))
+ if col not in names:
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
diff --git a/lib/options/options_review_images_lib.py b/lib/options/options_review_images_lib.py
new file mode 100644
index 0000000..d22d5f0
--- /dev/null
+++ b/lib/options/options_review_images_lib.py
@@ -0,0 +1,138 @@
+"""期权复盘截图:独立命名空间,不与合约 journal 混用."""
+from __future__ import annotations
+
+import json
+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_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)$",
+ re.I,
+)
+
+
+def normalize_options_review_draft_id(raw: Any) -> Optional[str]:
+ s = str(raw or "").strip().lower()
+ if _DRAFT_ID_RE.match(s):
+ return s
+ return None
+
+
+def _safe_ext(filename: str) -> str:
+ ext = os.path.splitext(str(filename or ""))[1].lower()
+ return ext if ext in OPTIONS_REVIEW_ALLOWED_EXT else ".png"
+
+
+def options_review_upload_dir(base_upload_folder: str) -> str:
+ """独立子目录 static/images/options_journal."""
+ base = os.path.abspath(base_upload_folder or "")
+ path = os.path.join(base, "options_journal")
+ os.makedirs(path, exist_ok=True)
+ return path
+
+
+def build_options_review_slot_filename(
+ draft_id: str,
+ tf: str,
+ ext: str,
+ *,
+ secure_filename_fn: Callable[[str], str],
+) -> str:
+ ext = ext if ext.startswith(".") else f".{ext}"
+ ext = _safe_ext(f"x{ext}")
+ fname = secure_filename_fn(f"options_journal_{draft_id}_{tf}{ext}")
+ return fname or ""
+
+
+def is_valid_options_review_file(filename: str, draft_id: str, tf: str) -> bool:
+ fn = os.path.basename(str(filename or "").strip())
+ if not fn or fn != str(filename or "").strip():
+ return False
+ m = _SLOT_FILE_RE.match(fn)
+ if not m:
+ return False
+ return m.group(1) == draft_id.lower() and m.group(2) == tf
+
+
+def save_options_review_slot_file(
+ file,
+ draft_id: str,
+ tf: str,
+ upload_folder: str,
+ *,
+ secure_filename_fn: Callable[[str], str],
+) -> Optional[Dict[str, str]]:
+ if tf not in OPTIONS_REVIEW_UPLOAD_TFS or not draft_id or not upload_folder:
+ return None
+ if not file or not getattr(file, "filename", None):
+ return None
+ ext = _safe_ext(file.filename)
+ fname = build_options_review_slot_filename(
+ draft_id, tf, ext, secure_filename_fn=secure_filename_fn
+ )
+ if not fname:
+ return None
+ os.makedirs(upload_folder, exist_ok=True)
+ path = os.path.join(upload_folder, fname)
+ file.save(path)
+ return {"tf": tf, "file": fname}
+
+
+def parse_options_review_images_json(raw: Any) -> List[Dict[str, str]]:
+ if not raw:
+ return []
+ if isinstance(raw, list):
+ data = raw
+ else:
+ try:
+ data = json.loads(str(raw))
+ except (TypeError, ValueError, json.JSONDecodeError):
+ return []
+ if not isinstance(data, list):
+ return []
+ out: List[Dict[str, str]] = []
+ for item in data:
+ if not isinstance(item, dict):
+ continue
+ tf = str(item.get("tf") or "").strip()
+ file = str(item.get("file") or "").strip()
+ if file:
+ out.append({"tf": tf, "file": file})
+ return out
+
+
+def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]:
+ if not items:
+ return None
+ return json.dumps(list(items), ensure_ascii=False, separators=(",", ":"))
+
+
+def options_review_image_paths(row: Any, upload_folder: str) -> List[str]:
+ upload_folder = os.path.abspath(upload_folder or "")
+ paths: List[str] = []
+ seen: set[str] = set()
+
+ def _add(name: Optional[str]) -> None:
+ if not name:
+ return
+ p = os.path.abspath(os.path.join(upload_folder, str(name).strip()))
+ if os.path.isfile(p) and p not in seen:
+ seen.add(p)
+ paths.append(p)
+
+ try:
+ keys = row.keys() if hasattr(row, "keys") else ()
+ except Exception:
+ keys = ()
+ images = parse_options_review_images_json(
+ row["images_json"] if "images_json" in keys else getattr(row, "images_json", None)
+ )
+ for item in images:
+ _add(item.get("file"))
+ if "image" in keys or hasattr(row, "image"):
+ _add(row["image"] if "image" in keys else getattr(row, "image", None))
+ return paths
diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py
new file mode 100644
index 0000000..c0521a7
--- /dev/null
+++ b/lib/options/options_review_lib.py
@@ -0,0 +1,693 @@
+"""期权复盘业务:OKX 已平期权导入 + 已结束对冲计划导入 + 复盘 CRUD + 统计."""
+from __future__ import annotations
+
+import json
+import sqlite3
+from datetime import datetime
+from typing import Any, Callable, Optional
+
+from lib.options.options_review_db import (
+ SOURCE_OPTION,
+ SOURCE_OPTIONS_OPTIONS,
+ SOURCE_PERP_OPTIONS,
+ SOURCE_TYPES,
+ init_options_review_tables,
+)
+from lib.options.options_review_images_lib import (
+ images_json_dumps,
+ parse_options_review_images_json,
+)
+
+SOURCE_LABELS = {
+ SOURCE_OPTION: "纯期权",
+ SOURCE_PERP_OPTIONS: "永期对冲",
+ SOURCE_OPTIONS_OPTIONS: "期期对冲",
+}
+
+HOLD_BUCKETS = (
+ ("0-1h", 0, 3600),
+ ("1-6h", 3600, 6 * 3600),
+ ("6-24h", 6 * 3600, 24 * 3600),
+ ("1-3d", 24 * 3600, 3 * 24 * 3600),
+ (">3d", 3 * 24 * 3600, None),
+)
+
+
+def _now_str() -> str:
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+
+def _parse_ts(raw: Any) -> Optional[datetime]:
+ if raw is None or raw == "":
+ return None
+ s = str(raw).strip().replace(" ", "T", 1)
+ try:
+ return datetime.fromisoformat(s)
+ except (TypeError, ValueError):
+ return None
+
+
+def _hold_seconds(opened_at: Any, closed_at: Any) -> Optional[int]:
+ start = _parse_ts(opened_at)
+ end = _parse_ts(closed_at)
+ if start is None or end is None:
+ return None
+ sec = int((end - start).total_seconds())
+ return sec if sec >= 0 else None
+
+
+def _safe_float(v: Any) -> Optional[float]:
+ if v is None or v == "":
+ return None
+ try:
+ return float(v)
+ except (TypeError, ValueError):
+ return None
+
+
+def get_sync_state(conn: sqlite3.Connection, key: str) -> Optional[str]:
+ row = conn.execute(
+ "SELECT value FROM options_review_sync_state WHERE key=?", (key,)
+ ).fetchone()
+ return str(row["value"]) if row and row["value"] is not None else None
+
+
+def set_sync_state(conn: sqlite3.Connection, key: str, value: str) -> None:
+ conn.execute(
+ """
+ INSERT INTO options_review_sync_state(key, value, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
+ """,
+ (key, value, _now_str()),
+ )
+
+
+def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) -> str:
+ """幂等写入纯期权快照;不触碰 options_review_entries."""
+ history_key = str(row.get("history_key") or "").strip()
+ if not history_key:
+ return "skip"
+ opened_at = row.get("created_at") or row.get("opened_at")
+ closed_at = row.get("closed_at")
+ pnl = _safe_float(row.get("realized_pnl"))
+ hold = _hold_seconds(opened_at, closed_at)
+ existing = conn.execute(
+ "SELECT id FROM options_review_trades WHERE history_key=?", (history_key,)
+ ).fetchone()
+ fields = {
+ "source_type": SOURCE_OPTION,
+ "history_key": history_key,
+ "underlying": str(row.get("underlying") or "").strip() or None,
+ "opened_at": opened_at,
+ "closed_at": closed_at,
+ "hold_seconds": hold,
+ "realized_pnl_total": pnl,
+ "status_raw": str(row.get("status_label") or row.get("status") or "closed"),
+ "synced_at": _now_str(),
+ "pos_id": str(row.get("pos_id") or "").strip() or None,
+ "inst_id": str(row.get("inst_id") or "").strip() or None,
+ "opt_type": str(row.get("opt_type") or "").strip() or None,
+ "strike": _safe_float(row.get("strike")),
+ "exp_time": str(row.get("exp_time") or "").strip() or None,
+ "sheets": int(row.get("sheets") or 0) or None,
+ "open_avg": _safe_float(row.get("open_avg_px") if row.get("open_avg_px") is not None else row.get("open_avg")),
+ "close_avg": _safe_float(row.get("close_avg_px") if row.get("close_avg_px") is not None else row.get("close_avg")),
+ "premium_paid": _safe_float(row.get("premium_paid")),
+ "realized_pnl": pnl,
+ }
+ cols = list(fields.keys())
+ if existing:
+ sets = ", ".join(f"{c}=?" for c in cols if c != "history_key")
+ vals = [fields[c] for c in cols if c != "history_key"]
+ conn.execute(
+ f"UPDATE options_review_trades SET {sets} WHERE history_key=?",
+ [*vals, history_key],
+ )
+ return "updated"
+ placeholders = ",".join(["?"] * len(cols))
+ conn.execute(
+ f"INSERT INTO options_review_trades ({','.join(cols)}) VALUES ({placeholders})",
+ [fields[c] for c in cols],
+ )
+ return "inserted"
+
+
+def sync_options_from_exchange(
+ conn: sqlite3.Connection,
+ ex: Any,
+ *,
+ limit: int = 500,
+ fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
+ format_fn: Optional[Callable[..., dict[str, Any]]] = None,
+) -> dict[str, Any]:
+ """从 OKX positions-history 导入已全平期权仓位."""
+ init_options_review_tables(conn)
+ from lib.exchange.okx_options_lib import (
+ fetch_all_option_positions_history,
+ format_option_history_row,
+ tick_sz_and_ct_mult,
+ )
+
+ fetch = fetch_fn or fetch_all_option_positions_history
+ fmt = format_fn or format_option_history_row
+ raw_rows = fetch(ex, limit=limit)
+ meta_cache: dict[str, dict[str, Any] | None] = {}
+ inserted = updated = skipped = 0
+ for raw in raw_rows:
+ inst_id = str(raw.get("instId") or "").strip()
+ tick_sz, ct_mult = None, 0.01
+ try:
+ tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
+ except Exception:
+ pass
+ formatted = fmt(raw, tick_sz=tick_sz, ct_mult=ct_mult)
+ action = upsert_option_history_row(conn, formatted)
+ if action == "inserted":
+ inserted += 1
+ elif action == "updated":
+ updated += 1
+ else:
+ skipped += 1
+ set_sync_state(conn, "options_last_sync_at", _now_str())
+ set_sync_state(conn, "options_last_count", str(len(raw_rows)))
+ return {
+ "ok": True,
+ "fetched": len(raw_rows),
+ "inserted": inserted,
+ "updated": updated,
+ "skipped": skipped,
+ }
+
+
+def _legs_json_from_plan(legs: list[dict[str, Any]]) -> str:
+ slim = []
+ for leg in legs:
+ slim.append(
+ {
+ "id": leg.get("id"),
+ "leg_role": leg.get("leg_role"),
+ "symbol": leg.get("symbol"),
+ "inst_id": leg.get("inst_id"),
+ "opt_type": leg.get("opt_type"),
+ "strike": leg.get("strike"),
+ "side": leg.get("side"),
+ "size": leg.get("size"),
+ "avg_open": leg.get("avg_open"),
+ "premium": leg.get("premium"),
+ "status": leg.get("status"),
+ "realized_pnl": leg.get("realized_pnl"),
+ "close_reason": leg.get("close_reason"),
+ "opened_at": leg.get("opened_at"),
+ "closed_at": leg.get("closed_at"),
+ }
+ )
+ return json.dumps(slim, ensure_ascii=False, separators=(",", ":"))
+
+
+def upsert_hedge_plan_row(
+ conn: sqlite3.Connection,
+ plan: dict[str, Any],
+ legs: list[dict[str, Any]],
+) -> str:
+ plan_id = int(plan["id"])
+ history_key = f"hedge:{plan_id}"
+ plan_type = str(plan.get("plan_type") or "").strip()
+ if plan_type not in (SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS):
+ return "skip"
+ opened_at = plan.get("opened_at") or plan.get("created_at")
+ closed_at = plan.get("closed_at")
+ total = _safe_float(plan.get("realized_pnl_total"))
+ hold = _hold_seconds(opened_at, closed_at)
+ fields = {
+ "source_type": plan_type,
+ "history_key": history_key,
+ "underlying": str(plan.get("underlying") or "").strip() or None,
+ "opened_at": opened_at,
+ "closed_at": closed_at,
+ "hold_seconds": hold,
+ "realized_pnl_total": total,
+ "status_raw": str(plan.get("status") or "closed"),
+ "synced_at": _now_str(),
+ "hedge_plan_id": plan_id,
+ "plan_close_reason": str(plan.get("close_reason") or "").strip() or None,
+ "realized_pnl_perp": _safe_float(plan.get("realized_pnl_perp")),
+ "realized_pnl_options": _safe_float(plan.get("realized_pnl_options")),
+ "premium_total": _safe_float(plan.get("premium_total")),
+ "direction": str(plan.get("direction") or "").strip() or None,
+ "tp": _safe_float(plan.get("tp")),
+ "sl": _safe_float(plan.get("sl")),
+ "target_price": _safe_float(plan.get("target_price")),
+ "target_price_up": _safe_float(plan.get("target_price_up")),
+ "target_price_down": _safe_float(plan.get("target_price_down")),
+ "legs_json": _legs_json_from_plan(legs),
+ }
+ existing = conn.execute(
+ "SELECT id FROM options_review_trades WHERE history_key=?", (history_key,)
+ ).fetchone()
+ cols = list(fields.keys())
+ if existing:
+ sets = ", ".join(f"{c}=?" for c in cols if c != "history_key")
+ vals = [fields[c] for c in cols if c != "history_key"]
+ conn.execute(
+ f"UPDATE options_review_trades SET {sets} WHERE history_key=?",
+ [*vals, history_key],
+ )
+ trade_id = int(existing["id"])
+ action = "updated"
+ else:
+ placeholders = ",".join(["?"] * len(cols))
+ cur = conn.execute(
+ f"INSERT INTO options_review_trades ({','.join(cols)}) VALUES ({placeholders})",
+ [fields[c] for c in cols],
+ )
+ trade_id = int(cur.lastrowid)
+ action = "inserted"
+ _mark_option_legs_excluded(conn, plan_id, legs)
+ del trade_id
+ return action
+
+
+def _mark_option_legs_excluded(
+ conn: sqlite3.Connection,
+ plan_id: int,
+ legs: list[dict[str, Any]],
+) -> int:
+ """纯期权记录若 inst_id 出现在对冲腿中,标记排除以免双计."""
+ inst_ids = {
+ str(leg.get("inst_id") or "").strip()
+ for leg in legs
+ if str(leg.get("leg_role") or "").startswith("option") and str(leg.get("inst_id") or "").strip()
+ }
+ if not inst_ids:
+ return 0
+ n = 0
+ for inst_id in inst_ids:
+ cur = conn.execute(
+ """
+ UPDATE options_review_trades
+ SET excluded_as_hedge_leg = 1, linked_hedge_plan_id = ?
+ WHERE source_type = ? AND inst_id = ? AND excluded_as_hedge_leg = 0
+ """,
+ (plan_id, SOURCE_OPTION, inst_id),
+ )
+ n += int(cur.rowcount or 0)
+ return n
+
+
+def sync_hedge_plans_closed(conn: sqlite3.Connection) -> dict[str, Any]:
+ """从本地 hedge_plans 导入已结束计划(计划级)."""
+ init_options_review_tables(conn)
+ from lib.hedge_plan.hedge_plan_db import get_plan_legs, init_hedge_plan_tables, list_plans
+
+ init_hedge_plan_tables(conn)
+ plans = list_plans(conn, status="closed", limit=500)
+ inserted = updated = skipped = 0
+ for plan in plans:
+ legs = get_plan_legs(conn, int(plan["id"]))
+ action = upsert_hedge_plan_row(conn, plan, legs)
+ if action == "inserted":
+ inserted += 1
+ elif action == "updated":
+ updated += 1
+ else:
+ skipped += 1
+ last_id = max((int(p["id"]) for p in plans), default=0)
+ set_sync_state(conn, "hedge_last_sync_at", _now_str())
+ set_sync_state(conn, "hedge_last_plan_id", str(last_id))
+ return {
+ "ok": True,
+ "fetched": len(plans),
+ "inserted": inserted,
+ "updated": updated,
+ "skipped": skipped,
+ }
+
+
+def sync_all_review_sources(
+ conn: sqlite3.Connection,
+ ex: Any | None,
+ *,
+ options_limit: int = 500,
+ fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
+ format_fn: Optional[Callable[..., dict[str, Any]]] = None,
+) -> dict[str, Any]:
+ init_options_review_tables(conn)
+ out: dict[str, Any] = {"ok": True, "options": None, "hedge": None}
+ if ex is not None:
+ out["options"] = sync_options_from_exchange(
+ conn, ex, limit=options_limit, fetch_fn=fetch_fn, format_fn=format_fn
+ )
+ else:
+ out["options"] = {"ok": False, "msg": "期权 exchange 未就绪"}
+ out["hedge"] = sync_hedge_plans_closed(conn)
+ return out
+
+
+def _row_to_dict(row: Any) -> dict[str, Any]:
+ return dict(row) if row is not None else {}
+
+
+def enrich_trade_row(row: dict[str, Any], entry: dict[str, Any] | None = None) -> dict[str, Any]:
+ out = dict(row)
+ out["source_label"] = SOURCE_LABELS.get(str(out.get("source_type") or ""), out.get("source_type"))
+ out["is_hedge"] = str(out.get("source_type") or "") in (SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS)
+ legs = []
+ if out.get("legs_json"):
+ try:
+ legs = json.loads(str(out["legs_json"]))
+ except (TypeError, ValueError, json.JSONDecodeError):
+ legs = []
+ out["legs"] = legs if isinstance(legs, list) else []
+ out["reviewed"] = bool(entry)
+ if entry:
+ out["entry"] = dict(entry)
+ out["entry"]["images"] = parse_options_review_images_json(entry.get("images_json"))
+ out["strategy_tag"] = entry.get("strategy_tag")
+ out["result_tag"] = entry.get("result_tag")
+ else:
+ out["entry"] = None
+ out["strategy_tag"] = None
+ out["result_tag"] = None
+ return out
+
+
+def list_review_trades(
+ conn: sqlite3.Connection,
+ *,
+ source_type: str | None = None,
+ underlying: str | None = None,
+ opt_type: str | None = None,
+ strategy_tag: str | None = None,
+ reviewed: str | None = None,
+ include_hedge_legs: bool = False,
+ closed_from: str | None = None,
+ closed_to: str | None = None,
+ limit: int = 200,
+ offset: int = 0,
+) -> list[dict[str, Any]]:
+ init_options_review_tables(conn)
+ wheres: list[str] = []
+ args: list[Any] = []
+ if source_type and source_type in SOURCE_TYPES:
+ wheres.append("t.source_type=?")
+ args.append(source_type)
+ if underlying:
+ wheres.append("UPPER(COALESCE(t.underlying,''))=?")
+ args.append(underlying.strip().upper())
+ if opt_type:
+ ot = opt_type.strip().upper()
+ if ot in ("C", "P", "CALL", "PUT"):
+ if ot.startswith("C"):
+ ot = "C"
+ elif ot.startswith("P"):
+ ot = "P"
+ wheres.append(
+ """(
+ UPPER(COALESCE(t.opt_type,''))=?
+ OR (
+ t.legs_json IS NOT NULL
+ AND t.legs_json LIKE '%' || '"opt_type":"' || ? || '%'
+ )
+ )"""
+ )
+ args.extend([ot, ot])
+ if not include_hedge_legs:
+ wheres.append("COALESCE(t.excluded_as_hedge_leg,0)=0")
+ if closed_from:
+ wheres.append("COALESCE(t.closed_at,'')>=?")
+ args.append(closed_from)
+ if closed_to:
+ wheres.append("COALESCE(t.closed_at,'')<=?")
+ args.append(closed_to)
+ if strategy_tag:
+ wheres.append("e.strategy_tag=?")
+ args.append(strategy_tag)
+ if reviewed == "1" or reviewed == "yes":
+ wheres.append("e.id IS NOT NULL")
+ elif reviewed == "0" or reviewed == "no":
+ wheres.append("e.id IS NULL")
+ where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
+ rows = conn.execute(
+ f"""
+ SELECT t.*, e.id AS entry_id, e.strategy_tag AS e_strategy_tag,
+ e.direction_view, e.entry_logic, e.exit_reason, e.followed_plan,
+ e.mistake_tags, e.result_tag, e.note, e.images_json, e.image,
+ e.reviewed_at, e.updated_at
+ FROM options_review_trades t
+ LEFT JOIN options_review_entries e ON e.trade_id = t.id
+ {where}
+ ORDER BY COALESCE(t.closed_at, t.opened_at, '') DESC, t.id DESC
+ LIMIT ? OFFSET ?
+ """,
+ [*args, int(limit), int(offset)],
+ ).fetchall()
+ out: list[dict[str, Any]] = []
+ for r in rows:
+ d = _row_to_dict(r)
+ entry = None
+ if d.get("entry_id"):
+ entry = {
+ "id": d.pop("entry_id", None),
+ "strategy_tag": d.pop("e_strategy_tag", None),
+ "direction_view": d.pop("direction_view", None),
+ "entry_logic": d.pop("entry_logic", None),
+ "exit_reason": d.pop("exit_reason", None),
+ "followed_plan": d.pop("followed_plan", None),
+ "mistake_tags": d.pop("mistake_tags", None),
+ "result_tag": d.pop("result_tag", None),
+ "note": d.pop("note", None),
+ "images_json": d.pop("images_json", None),
+ "image": d.pop("image", None),
+ "reviewed_at": d.pop("reviewed_at", None),
+ "updated_at": d.pop("updated_at", None),
+ }
+ else:
+ for k in (
+ "entry_id",
+ "e_strategy_tag",
+ "direction_view",
+ "entry_logic",
+ "exit_reason",
+ "followed_plan",
+ "mistake_tags",
+ "result_tag",
+ "note",
+ "images_json",
+ "image",
+ "reviewed_at",
+ "updated_at",
+ ):
+ d.pop(k, None)
+ out.append(enrich_trade_row(d, entry))
+ return out
+
+
+def get_review_trade(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any] | None:
+ init_options_review_tables(conn)
+ row = conn.execute(
+ "SELECT * FROM options_review_trades WHERE id=?", (int(trade_id),)
+ ).fetchone()
+ if not row:
+ return None
+ entry_row = conn.execute(
+ "SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),)
+ ).fetchone()
+ entry = _row_to_dict(entry_row) if entry_row else None
+ return enrich_trade_row(_row_to_dict(row), entry)
+
+
+def save_review_entry(
+ conn: sqlite3.Connection,
+ trade_id: int,
+ payload: dict[str, Any],
+) -> dict[str, Any]:
+ """保存/更新人工复盘;不影响 trades 快照字段."""
+ init_options_review_tables(conn)
+ trade = conn.execute(
+ "SELECT id FROM options_review_trades WHERE id=?", (int(trade_id),)
+ ).fetchone()
+ if not trade:
+ return {"ok": False, "msg": "交易不存在"}
+ images = payload.get("images")
+ if images is None and payload.get("images_json") is not None:
+ images = parse_options_review_images_json(payload.get("images_json"))
+ if not isinstance(images, list):
+ images = []
+ images_json = images_json_dumps(images)
+ primary = None
+ if images:
+ primary = str(images[0].get("file") or "").strip() or None
+ fields = {
+ "strategy_tag": str(payload.get("strategy_tag") or "").strip() or None,
+ "direction_view": str(payload.get("direction_view") or "").strip() or None,
+ "entry_logic": str(payload.get("entry_logic") or "").strip() or None,
+ "exit_reason": str(payload.get("exit_reason") or "").strip() or None,
+ "followed_plan": str(payload.get("followed_plan") or "").strip() or None,
+ "mistake_tags": str(payload.get("mistake_tags") or "").strip() or None,
+ "result_tag": str(payload.get("result_tag") or "").strip() or None,
+ "note": str(payload.get("note") or "").strip() or None,
+ "images_json": images_json,
+ "image": primary or (str(payload.get("image") or "").strip() or None),
+ "updated_at": _now_str(),
+ }
+ existing = conn.execute(
+ "SELECT id, reviewed_at FROM options_review_entries WHERE trade_id=?",
+ (int(trade_id),),
+ ).fetchone()
+ if existing:
+ sets = ", ".join(f"{k}=?" for k in fields)
+ conn.execute(
+ f"UPDATE options_review_entries SET {sets} WHERE trade_id=?",
+ [*fields.values(), int(trade_id)],
+ )
+ else:
+ fields["trade_id"] = int(trade_id)
+ fields["reviewed_at"] = _now_str()
+ cols = list(fields.keys())
+ conn.execute(
+ f"INSERT INTO options_review_entries ({','.join(cols)}) VALUES ({','.join(['?']*len(cols))})",
+ [fields[c] for c in cols],
+ )
+ return {"ok": True, "trade": get_review_trade(conn, int(trade_id))}
+
+
+def delete_review_entry(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any]:
+ init_options_review_tables(conn)
+ entry = conn.execute(
+ "SELECT * FROM options_review_entries WHERE trade_id=?", (int(trade_id),)
+ ).fetchone()
+ if not entry:
+ return {"ok": False, "msg": "无复盘记录"}
+ conn.execute("DELETE FROM options_review_entries WHERE trade_id=?", (int(trade_id),))
+ return {"ok": True, "entry": _row_to_dict(entry)}
+
+
+def _hold_bucket(sec: Optional[int]) -> str:
+ if sec is None:
+ return "未知"
+ for label, lo, hi in HOLD_BUCKETS:
+ if sec >= lo and (hi is None or sec < hi):
+ return label
+ return "未知"
+
+
+def _group_stats(rows: list[dict[str, Any]], key_fn) -> list[dict[str, Any]]:
+ buckets: dict[str, dict[str, Any]] = {}
+ for row in rows:
+ key = str(key_fn(row) or "未填")
+ b = buckets.setdefault(
+ key,
+ {"key": key, "count": 0, "wins": 0, "losses": 0, "pnl_sum": 0.0, "hold_sum": 0.0, "hold_n": 0},
+ )
+ pnl = _safe_float(row.get("realized_pnl_total"))
+ if pnl is None:
+ continue
+ b["count"] += 1
+ b["pnl_sum"] = round(b["pnl_sum"] + pnl, 4)
+ if pnl > 0:
+ b["wins"] += 1
+ elif pnl < 0:
+ b["losses"] += 1
+ hs = row.get("hold_seconds")
+ if hs is not None:
+ try:
+ b["hold_sum"] += float(hs)
+ b["hold_n"] += 1
+ except (TypeError, ValueError):
+ pass
+ out = []
+ for b in buckets.values():
+ c = b["count"]
+ out.append(
+ {
+ "key": b["key"],
+ "count": c,
+ "wins": b["wins"],
+ "losses": b["losses"],
+ "win_rate": round(b["wins"] / c * 100, 2) if c else 0,
+ "pnl_sum": round(b["pnl_sum"], 4),
+ "avg_pnl": round(b["pnl_sum"] / c, 4) if c else None,
+ "avg_hold_sec": round(b["hold_sum"] / b["hold_n"], 1) if b["hold_n"] else None,
+ }
+ )
+ out.sort(key=lambda x: abs(float(x.get("pnl_sum") or 0)), reverse=True)
+ return out
+
+
+def compute_review_stats(
+ conn: sqlite3.Connection,
+ *,
+ source_type: str | None = None,
+ underlying: str | None = None,
+ include_hedge_legs: bool = False,
+ closed_from: str | None = None,
+ closed_to: str | None = None,
+ require_strategy: bool = False,
+) -> dict[str, Any]:
+ rows = list_review_trades(
+ conn,
+ source_type=source_type,
+ underlying=underlying,
+ include_hedge_legs=include_hedge_legs,
+ closed_from=closed_from,
+ closed_to=closed_to,
+ limit=5000,
+ offset=0,
+ )
+ if require_strategy:
+ rows = [r for r in rows if str(r.get("strategy_tag") or "").strip()]
+
+ wins = losses = reviewed = 0
+ pnl_sum = 0.0
+ hold_vals: list[float] = []
+ for r in rows:
+ if r.get("reviewed"):
+ reviewed += 1
+ pnl = _safe_float(r.get("realized_pnl_total"))
+ if pnl is None:
+ continue
+ pnl_sum += pnl
+ if pnl > 0:
+ wins += 1
+ elif pnl < 0:
+ losses += 1
+ if r.get("hold_seconds") is not None:
+ hold_vals.append(float(r["hold_seconds"]))
+
+ total = wins + losses
+ kpi = {
+ "total": len(rows),
+ "pnl_count": total,
+ "reviewed": reviewed,
+ "review_rate": round(reviewed / len(rows) * 100, 2) if rows else 0,
+ "wins": wins,
+ "losses": losses,
+ "win_rate": round(wins / total * 100, 2) if total else 0,
+ "pnl_sum": round(pnl_sum, 4),
+ "avg_pnl": round(pnl_sum / total, 4) if total else None,
+ "avg_hold_sec": round(sum(hold_vals) / len(hold_vals), 1) if hold_vals else None,
+ }
+
+ strategy_rows = [r for r in rows if str(r.get("strategy_tag") or "").strip()]
+ return {
+ "ok": True,
+ "kpi": kpi,
+ "by_source_type": _group_stats(rows, lambda r: SOURCE_LABELS.get(str(r.get("source_type") or ""), r.get("source_type"))),
+ "by_underlying": _group_stats(rows, lambda r: r.get("underlying") or "未填"),
+ "by_opt_type": _group_stats(
+ [r for r in rows if r.get("source_type") == SOURCE_OPTION],
+ lambda r: r.get("opt_type") or "未填",
+ ),
+ "by_strategy": _group_stats(strategy_rows, lambda r: r.get("strategy_tag")),
+ "by_close_reason": _group_stats(
+ [r for r in rows if r.get("is_hedge")],
+ lambda r: r.get("plan_close_reason") or "未填",
+ ),
+ "by_hold_bucket": _group_stats(rows, lambda r: _hold_bucket(r.get("hold_seconds"))),
+ "sync": {
+ "options_last_sync_at": get_sync_state(conn, "options_last_sync_at"),
+ "hedge_last_sync_at": get_sync_state(conn, "hedge_last_sync_at"),
+ "hedge_last_plan_id": get_sync_state(conn, "hedge_last_plan_id"),
+ },
+ }
diff --git a/lib/options/options_review_register.py b/lib/options/options_review_register.py
new file mode 100644
index 0000000..229bb11
--- /dev/null
+++ b/lib/options/options_review_register.py
@@ -0,0 +1,232 @@
+"""OKX 期权复盘模块:Flask 路由注册(含对冲计划级复盘)."""
+from __future__ import annotations
+
+import os
+from typing import Any
+
+from flask import Flask, jsonify, request, send_file
+from jinja2 import ChoiceLoader, FileSystemLoader
+from werkzeug.utils import secure_filename
+
+from lib.options.options_review_db import SOURCE_TYPES, init_options_review_tables
+from lib.options.options_review_images_lib import (
+ OPTIONS_REVIEW_UPLOAD_TFS,
+ normalize_options_review_draft_id,
+ options_review_image_paths,
+ options_review_upload_dir,
+ save_options_review_slot_file,
+)
+from lib.options.options_review_lib import (
+ SOURCE_LABELS,
+ compute_review_stats,
+ delete_review_entry,
+ get_review_trade,
+ list_review_trades,
+ save_review_entry,
+ sync_all_review_sources,
+)
+
+
+def attach_options_review_templates(app: Flask, repo_root: str) -> None:
+ tpl_dir = os.path.join(repo_root, "lib", "options", "templates")
+ if not os.path.isdir(tpl_dir):
+ return
+ existing = app.jinja_loader
+ loaders = [FileSystemLoader(tpl_dir)]
+ if existing is not None:
+ if isinstance(existing, ChoiceLoader):
+ loaders = list(existing.loaders) + loaders
+ else:
+ loaders.insert(0, existing)
+ app.jinja_loader = ChoiceLoader(loaders)
+
+
+def install_options_review(app: Flask, repo_root: str, app_module: Any) -> None:
+ attach_options_review_templates(app, repo_root)
+ cfg = {
+ "get_db": app_module.get_db,
+ "login_required": app_module.login_required,
+ "exchange_options": getattr(app_module, "exchange_options", None),
+ "render_main_page": app_module.render_main_page,
+ "upload_folder": getattr(app_module, "UPLOAD_FOLDER", None)
+ or os.path.join(os.path.dirname(getattr(app_module, "BASE_DIR", repo_root)), "static", "images"),
+ "options_enabled": bool(getattr(app_module, "OKX_OPTIONS_ENABLED", False)),
+ "app_module": app_module,
+ }
+ app.extensions["options_review_cfg"] = cfg
+ register_options_review_routes(app, cfg, repo_root)
+
+
+def _require_ex(cfg: dict[str, Any]):
+ from lib.exchange.okx_options_lib import options_api_ready
+
+ if not cfg.get("options_enabled"):
+ return None, "期权模块未启用"
+ ex = cfg.get("exchange_options")
+ ok, reason = options_api_ready(ex)
+ if not ok:
+ return None, reason or "期权 API 未配置"
+ return ex, ""
+
+
+def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None:
+ lr = cfg["login_required"]
+
+ @app.route("/options/review")
+ @lr
+ def options_review_page():
+ from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled
+
+ redir = redirect_to_embed_shell_if_enabled("options_review")
+ if redir is not None:
+ return redir
+ return cfg["render_main_page"]("options_review")
+
+ @app.route("/static/options_review.js")
+ @lr
+ def static_options_review_js():
+ path = os.path.join(repo_root, "lib", "common", "static", "options_review.js")
+ if not os.path.isfile(path):
+ return ("not found", 404)
+ return send_file(path, mimetype="application/javascript; charset=utf-8")
+
+ @app.route("/static/images/options_journal/")
+ @lr
+ def static_options_review_image(filename: str):
+ folder = options_review_upload_dir(cfg["upload_folder"])
+ safe = os.path.basename(filename or "")
+ path = os.path.join(folder, safe)
+ if not os.path.isfile(path):
+ return ("not found", 404)
+ return send_file(path)
+
+ @app.route("/api/options/review/sync", methods=["POST"])
+ @lr
+ def api_options_review_sync():
+ conn = cfg["get_db"]()
+ try:
+ init_options_review_tables(conn)
+ ex, err = _require_ex(cfg)
+ # 对冲可无交易所密钥;期权历史需要密钥
+ result = sync_all_review_sources(conn, ex if ex is not None else None)
+ if ex is None and result.get("options"):
+ result["options"] = {"ok": False, "msg": err}
+ conn.commit()
+ return jsonify(result)
+ finally:
+ conn.close()
+
+ @app.route("/api/options/review/trades")
+ @lr
+ def api_options_review_trades():
+ conn = cfg["get_db"]()
+ try:
+ items = list_review_trades(
+ conn,
+ source_type=(request.args.get("source_type") or "").strip() or None,
+ underlying=(request.args.get("underlying") or "").strip() or None,
+ opt_type=(request.args.get("opt_type") or "").strip() or None,
+ strategy_tag=(request.args.get("strategy_tag") or "").strip() or None,
+ reviewed=(request.args.get("reviewed") or "").strip() or None,
+ include_hedge_legs=(request.args.get("include_hedge_legs") or "").strip().lower()
+ in ("1", "true", "yes"),
+ closed_from=(request.args.get("closed_from") or "").strip() or None,
+ closed_to=(request.args.get("closed_to") or "").strip() or None,
+ limit=min(500, max(1, int(request.args.get("limit") or 200))),
+ offset=max(0, int(request.args.get("offset") or 0)),
+ )
+ return jsonify({"ok": True, "trades": items, "source_labels": SOURCE_LABELS})
+ finally:
+ conn.close()
+
+ @app.route("/api/options/review/trades/")
+ @lr
+ def api_options_review_trade_detail(trade_id: int):
+ conn = cfg["get_db"]()
+ try:
+ item = get_review_trade(conn, trade_id)
+ if not item:
+ return jsonify({"ok": False, "msg": "未找到"}), 404
+ return jsonify({"ok": True, "trade": item})
+ finally:
+ conn.close()
+
+ @app.route("/api/options/review/entry", methods=["POST"])
+ @lr
+ def api_options_review_entry_save():
+ data = request.get_json(silent=True) or {}
+ try:
+ trade_id = int(data.get("trade_id"))
+ except (TypeError, ValueError):
+ return jsonify({"ok": False, "msg": "trade_id 无效"}), 400
+ conn = cfg["get_db"]()
+ try:
+ out = save_review_entry(conn, trade_id, data)
+ if out.get("ok"):
+ conn.commit()
+ return jsonify(out), (200 if out.get("ok") else 400)
+ finally:
+ conn.close()
+
+ @app.route("/api/options/review/entry/", methods=["DELETE"])
+ @lr
+ def api_options_review_entry_delete(trade_id: int):
+ conn = cfg["get_db"]()
+ try:
+ out = delete_review_entry(conn, trade_id)
+ if out.get("ok"):
+ entry = out.get("entry") or {}
+ folder = options_review_upload_dir(cfg["upload_folder"])
+ for path in options_review_image_paths(entry, folder):
+ try:
+ os.remove(path)
+ except OSError:
+ pass
+ conn.commit()
+ return jsonify(out), (200 if out.get("ok") else 400)
+ finally:
+ conn.close()
+
+ @app.route("/api/options/review/upload_slot", methods=["POST"])
+ @lr
+ def api_options_review_upload_slot():
+ draft_id = normalize_options_review_draft_id(
+ request.form.get("draft_id") if request.form else None
+ )
+ tf = str((request.form.get("tf") if request.form else None) or "").strip()
+ if not draft_id:
+ return jsonify({"ok": False, "error": "invalid draft_id"}), 400
+ if tf not in OPTIONS_REVIEW_UPLOAD_TFS:
+ return jsonify({"ok": False, "error": "invalid tf"}), 400
+ f = request.files.get("file") if request.files else None
+ if not f or not getattr(f, "filename", None):
+ return jsonify({"ok": False, "error": "no file"}), 400
+ folder = options_review_upload_dir(cfg["upload_folder"])
+ item = save_options_review_slot_file(
+ f, draft_id, tf, folder, secure_filename_fn=secure_filename
+ )
+ if not item:
+ return jsonify({"ok": False, "error": "save failed"}), 500
+ return jsonify({"ok": True, "tf": tf, "file": item["file"]})
+
+ @app.route("/api/options/review/stats")
+ @lr
+ def api_options_review_stats():
+ conn = cfg["get_db"]()
+ try:
+ stats = compute_review_stats(
+ conn,
+ source_type=(request.args.get("source_type") or "").strip() or None,
+ underlying=(request.args.get("underlying") or "").strip() or None,
+ include_hedge_legs=(request.args.get("include_hedge_legs") or "").strip().lower()
+ in ("1", "true", "yes"),
+ closed_from=(request.args.get("closed_from") or "").strip() or None,
+ closed_to=(request.args.get("closed_to") or "").strip() or None,
+ require_strategy=(request.args.get("require_strategy") or "").strip().lower()
+ in ("1", "true", "yes"),
+ )
+ stats["source_types"] = list(SOURCE_TYPES)
+ stats["source_labels"] = SOURCE_LABELS
+ return jsonify(stats)
+ finally:
+ conn.close()
diff --git a/lib/options/templates/options_review_panel.html b/lib/options/templates/options_review_panel.html
new file mode 100644
index 0000000..c66384f
--- /dev/null
+++ b/lib/options/templates/options_review_panel.html
@@ -0,0 +1,91 @@
+{# OKX 期权复盘(含对冲):独立页,不混合约 journal #}
+
+ {% if not options_enabled %}
+
期权未启用:请设置 OKX_OPTIONS_ENABLED=true 后重启.对冲计划仍可单独同步(若本地有已结束计划).
+ {% endif %}
+
+
+
+
+
+
+
交易列表
+
+
+
+
+ | 类型 |
+ 标的/合约 |
+ 盈亏 |
+ 开/平 |
+ 持有 |
+ 策略 |
+ 状态 |
+ 操作 |
+
+
+
+ | 加载中… |
+
+
+
+
+
+
+
+
+
diff --git a/tests/test_options_review_lib.py b/tests/test_options_review_lib.py
new file mode 100644
index 0000000..e2c1c4e
--- /dev/null
+++ b/tests/test_options_review_lib.py
@@ -0,0 +1,284 @@
+"""期权复盘(含对冲)单元测试:导入去重、双计防护、复盘不被覆盖、统计."""
+from __future__ import annotations
+
+import sqlite3
+import tempfile
+import unittest
+from pathlib import Path
+
+from lib.hedge_plan.hedge_plan_db import init_hedge_plan_tables, insert_leg, insert_plan
+from lib.options.options_review_db import SOURCE_OPTION, SOURCE_PERP_OPTIONS, init_options_review_tables
+from lib.options.options_review_images_lib import (
+ build_options_review_slot_filename,
+ is_valid_options_review_file,
+ options_review_upload_dir,
+ save_options_review_slot_file,
+)
+from lib.options.options_review_lib import (
+ compute_review_stats,
+ list_review_trades,
+ save_review_entry,
+ sync_hedge_plans_closed,
+ sync_options_from_exchange,
+ upsert_option_history_row,
+)
+
+
+def _conn() -> sqlite3.Connection:
+ c = sqlite3.connect(":memory:")
+ c.row_factory = sqlite3.Row
+ init_options_review_tables(c)
+ init_hedge_plan_tables(c)
+ return c
+
+
+class _FakeFile:
+ def __init__(self, name: str, data: bytes = b"img"):
+ self.filename = name
+ self._data = data
+
+ def save(self, path: str) -> None:
+ Path(path).write_bytes(self._data)
+
+
+class OptionsReviewTests(unittest.TestCase):
+ def test_option_upsert_idempotent(self):
+ conn = _conn()
+ row = {
+ "history_key": "ex:pos1",
+ "pos_id": "pos1",
+ "inst_id": "ETH-USD-260328-2000-C",
+ "underlying": "ETH",
+ "opt_type": "C",
+ "strike": 2000,
+ "sheets": 10,
+ "open_avg_px": 0.01,
+ "close_avg_px": 0.02,
+ "premium_paid": 1.0,
+ "realized_pnl": 5.5,
+ "created_at": "2026-03-01 10:00:00",
+ "closed_at": "2026-03-01 12:00:00",
+ "status_label": "已平",
+ }
+ self.assertEqual(upsert_option_history_row(conn, row), "inserted")
+ row["realized_pnl"] = 6.0
+ self.assertEqual(upsert_option_history_row(conn, row), "updated")
+ n = conn.execute("SELECT COUNT(*) AS c FROM options_review_trades").fetchone()["c"]
+ self.assertEqual(n, 1)
+ pnl = conn.execute(
+ "SELECT realized_pnl_total FROM options_review_trades WHERE history_key='ex:pos1'"
+ ).fetchone()["realized_pnl_total"]
+ self.assertEqual(float(pnl), 6.0)
+
+ def test_entry_not_overwritten_by_resync(self):
+ conn = _conn()
+ upsert_option_history_row(
+ conn,
+ {
+ "history_key": "ex:p2",
+ "inst_id": "ETH-USD-260328-1800-P",
+ "underlying": "ETH",
+ "opt_type": "P",
+ "realized_pnl": 1.0,
+ "created_at": "2026-03-02 10:00:00",
+ "closed_at": "2026-03-02 11:00:00",
+ },
+ )
+ tid = conn.execute("SELECT id FROM options_review_trades").fetchone()["id"]
+ save_review_entry(
+ conn,
+ tid,
+ {"strategy_tag": "突破追涨", "note": "keep-me", "images": []},
+ )
+ upsert_option_history_row(
+ conn,
+ {
+ "history_key": "ex:p2",
+ "inst_id": "ETH-USD-260328-1800-P",
+ "underlying": "ETH",
+ "opt_type": "P",
+ "realized_pnl": 2.0,
+ "created_at": "2026-03-02 10:00:00",
+ "closed_at": "2026-03-02 11:00:00",
+ },
+ )
+ note = conn.execute(
+ "SELECT note, strategy_tag FROM options_review_entries WHERE trade_id=?",
+ (tid,),
+ ).fetchone()
+ self.assertEqual(note["note"], "keep-me")
+ self.assertEqual(note["strategy_tag"], "突破追涨")
+ pnl = conn.execute(
+ "SELECT realized_pnl_total FROM options_review_trades WHERE id=?", (tid,)
+ ).fetchone()["realized_pnl_total"]
+ self.assertEqual(float(pnl), 2.0)
+
+ def test_hedge_import_and_double_count_guard(self):
+ conn = _conn()
+ upsert_option_history_row(
+ conn,
+ {
+ "history_key": "ex:leg1",
+ "inst_id": "ETH-USD-260328-2000-C",
+ "underlying": "ETH",
+ "opt_type": "C",
+ "realized_pnl": -3.0,
+ "created_at": "2026-03-03 09:00:00",
+ "closed_at": "2026-03-03 18:00:00",
+ },
+ )
+ plan_id = insert_plan(
+ conn,
+ {
+ "plan_type": SOURCE_PERP_OPTIONS,
+ "status": "closed",
+ "underlying": "ETH",
+ "direction": "long",
+ "realized_pnl_perp": 20.0,
+ "realized_pnl_options": -3.0,
+ "realized_pnl_total": 17.0,
+ "close_reason": "tp",
+ "opened_at": "2026-03-03 09:00:00",
+ "closed_at": "2026-03-03 18:00:00",
+ "premium_total": 3.0,
+ },
+ )
+ insert_leg(
+ conn,
+ {
+ "plan_id": plan_id,
+ "leg_role": "perp",
+ "symbol": "ETH-USDT-SWAP",
+ "status": "closed",
+ "realized_pnl": 20.0,
+ },
+ )
+ insert_leg(
+ conn,
+ {
+ "plan_id": plan_id,
+ "leg_role": "option_hedge",
+ "inst_id": "ETH-USD-260328-2000-C",
+ "opt_type": "C",
+ "status": "closed",
+ "realized_pnl": -3.0,
+ },
+ )
+ out = sync_hedge_plans_closed(conn)
+ self.assertTrue(out["ok"])
+ self.assertEqual(out["inserted"], 1)
+
+ listed = list_review_trades(conn, include_hedge_legs=False)
+ types = {r["source_type"] for r in listed}
+ self.assertIn(SOURCE_PERP_OPTIONS, types)
+ self.assertNotIn(SOURCE_OPTION, types)
+
+ listed_all = list_review_trades(conn, include_hedge_legs=True)
+ self.assertEqual(len(listed_all), 2)
+
+ stats = compute_review_stats(conn, include_hedge_legs=False)
+ self.assertEqual(stats["kpi"]["total"], 1)
+ self.assertEqual(stats["kpi"]["pnl_sum"], 17.0)
+
+ def test_sync_options_from_mock_exchange(self):
+ conn = _conn()
+
+ def fetch(_ex, limit=500):
+ return [
+ {
+ "instId": "ETH-USD-260328-2100-C",
+ "posId": "mock1",
+ "openAvgPx": "0.01",
+ "closeAvgPx": "0.02",
+ "closeTotalPos": "5",
+ "realizedPnl": "1.23",
+ "type": "2",
+ "cTime": "1700000000000",
+ "uTime": "1700003600000",
+ "uly": "ETH-USD",
+ }
+ ]
+
+ def fmt(raw, tick_sz=None, ct_mult=0.01):
+ return {
+ "history_key": f"ex:{raw['posId']}",
+ "pos_id": raw["posId"],
+ "inst_id": raw["instId"],
+ "underlying": "ETH",
+ "opt_type": "C",
+ "sheets": 5,
+ "open_avg_px": 0.01,
+ "close_avg_px": 0.02,
+ "premium_paid": 0.5,
+ "realized_pnl": float(raw["realizedPnl"]),
+ "created_at": "2026-01-01 00:00:00",
+ "closed_at": "2026-01-01 01:00:00",
+ "status_label": "已平",
+ }
+
+ result = sync_options_from_exchange(
+ conn, object(), limit=10, fetch_fn=fetch, format_fn=fmt
+ )
+ self.assertTrue(result["ok"])
+ self.assertEqual(result["inserted"], 1)
+ row = conn.execute(
+ "SELECT realized_pnl_total FROM options_review_trades WHERE history_key='ex:mock1'"
+ ).fetchone()
+ self.assertEqual(float(row["realized_pnl_total"]), 1.23)
+
+ def test_image_namespace(self):
+ 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
+ )
+ self.assertTrue(fname.startswith("options_journal_"))
+ self.assertTrue(is_valid_options_review_file(fname, "a" * 32, "chart"))
+ item = save_options_review_slot_file(
+ _FakeFile("x.png"),
+ "a" * 32,
+ "chart",
+ folder,
+ secure_filename_fn=lambda x: x,
+ )
+ self.assertIsNotNone(item)
+ self.assertTrue((Path(folder) / item["file"]).is_file())
+
+ def test_strategy_stats_only_tagged(self):
+ conn = _conn()
+ upsert_option_history_row(
+ conn,
+ {
+ "history_key": "ex:a",
+ "inst_id": "ETH-USD-1-C",
+ "underlying": "ETH",
+ "opt_type": "C",
+ "realized_pnl": 10,
+ "created_at": "2026-01-01 00:00:00",
+ "closed_at": "2026-01-01 02:00:00",
+ },
+ )
+ upsert_option_history_row(
+ conn,
+ {
+ "history_key": "ex:b",
+ "inst_id": "ETH-USD-2-P",
+ "underlying": "ETH",
+ "opt_type": "P",
+ "realized_pnl": -4,
+ "created_at": "2026-01-01 00:00:00",
+ "closed_at": "2026-01-01 05:00:00",
+ },
+ )
+ tid = conn.execute(
+ "SELECT id FROM options_review_trades WHERE history_key='ex:a'"
+ ).fetchone()["id"]
+ save_review_entry(conn, tid, {"strategy_tag": "假破", "images": []})
+ stats = compute_review_stats(conn)
+ self.assertEqual(len(stats["by_strategy"]), 1)
+ self.assertEqual(stats["by_strategy"][0]["key"], "假破")
+ self.assertEqual(stats["kpi"]["total"], 2)
+
+
+if __name__ == "__main__":
+ unittest.main()