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()