From 10128d18bceaea00f4937e565f905778a631a1e4 Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 17 Jul 2026 09:35:19 +0800 Subject: [PATCH] Add OKX options review module with hedge plan entries. Import closed OKX option history and closed hedge plans into one list for journaling, images, and stats without mixing contract reviews. Co-authored-by: Cursor --- crypto_monitor_okx/app.py | 4 + docs/期权用法.md | 31 +- lib/common/static/instance_embed.js | 2 + lib/common/static/instance_settings_prefs.js | 4 + lib/common/static/options_review.js | 509 +++++++++++++ lib/instance/instance_display_prefs_lib.py | 4 + lib/instance/instance_embed_lib.py | 2 + .../templates/embed_page_fragment.html | 2 + lib/instance/templates/embed_shell.html | 3 + lib/instance/templates/index.html | 7 +- lib/options/options_db.py | 3 + lib/options/options_review_db.py | 127 ++++ lib/options/options_review_images_lib.py | 138 ++++ lib/options/options_review_lib.py | 693 ++++++++++++++++++ lib/options/options_review_register.py | 232 ++++++ .../templates/options_review_panel.html | 91 +++ tests/test_options_review_lib.py | 284 +++++++ 17 files changed, 2133 insertions(+), 3 deletions(-) create mode 100644 lib/common/static/options_review.js create mode 100644 lib/options/options_review_db.py create mode 100644 lib/options/options_review_images_lib.py create mode 100644 lib/options/options_review_lib.py create mode 100644 lib/options/options_review_register.py create mode 100644 lib/options/templates/options_review_panel.html create mode 100644 tests/test_options_review_lib.py 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 ( + '
' + + title + + '
无数据
' + ); + } + 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 ( + "" + ); + }) + .join("") + + "
合约盈亏原因
" + + (leg.leg_role || "") + + "" + + (leg.inst_id || leg.symbol || "") + + "" + + fmtPnl(leg.realized_pnl) + + "" + + (leg.close_reason || "") + + "
"; + } + 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()