diff --git a/docs/期权用法.md b/docs/期权用法.md index c870737..ec25efb 100644 --- a/docs/期权用法.md +++ b/docs/期权用法.md @@ -106,14 +106,14 @@ OKX_OPTIONS_API_PASSPHRASE=... | 类型 | source_type | 来源 | 粒度 | |------|-------------|------|------| -| 纯期权 | `option_spot` | OKX `positions-history` 已全平仓位 | 一仓一条 | +| 纯期权 | `option_spot` | 本地 `options_trades` 已平仓 | 一仓一条 | | 永期对冲 | `perp_options` | 本地 `hedge_plans` 且 `status=closed` | **一计划一条** | | 期期对冲 | `options_options` | 同上 | **一计划一条** | -- 同步按钮:`POST /api/options/review/sync`(只读导入,不下单/不平仓). +- 打开复盘页即自动读取本地记录,**不访问交易所**. - 对冲盈亏主口径:`realized_pnl_total`;详情另显永续/期权分项. -- 若某纯期权 `inst_id` 已出现在对冲腿中,默认标记排除,避免总盈亏双计(可勾选「含已归属对冲的期权腿」查看). -- 人工复盘字段(策略/备注/图片)存在 `options_review_entries`,再次同步**不会覆盖**. +- 若某纯期权 `inst_id` 已出现在对冲腿中,默认标记排除,避免总盈亏双计. +- 人工复盘字段存在 `options_review_entries`,刷新本地源**不会覆盖**. ### 图片 diff --git a/lib/common/static/options_review.js b/lib/common/static/options_review.js index dd8de8c..ca9a7c5 100644 --- a/lib/common/static/options_review.js +++ b/lib/common/static/options_review.js @@ -74,34 +74,10 @@ 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() { + setSyncStatus("读取本地记录…"); + loadTrades(); + loadStats(); } function setActiveTab(source) { @@ -115,11 +91,6 @@ reloadAll(); } - function reloadAll() { - loadTrades(); - loadStats(); - } - function loadTrades() { var tbody = $("or-trades-tbody"); if (!tbody) return; @@ -129,6 +100,7 @@ return r.json(); }) .then(function (data) { + setSyncStatus("本地记录已加载"); if (!data.ok) { tbody.innerHTML = '加载失败'; return; @@ -136,7 +108,8 @@ var rows = data.trades || []; tradesCache = {}; if (!rows.length) { - tbody.innerHTML = '暂无记录,请先同步'; + tbody.innerHTML = + '暂无本地已平记录(期权页平仓后或对冲计划结束后会出现在此)'; return; } tbody.innerHTML = rows @@ -304,11 +277,17 @@ var hidden = row && row.querySelector(".or-upload-hidden"); if (!file) { if (hidden) hidden.value = ""; - if (status) status.textContent = ""; + if (status) { + status.textContent = ""; + status.className = "journal-upload-status or-upload-status"; + } return; } if (!draftId) draftId = newDraftId(); - if (status) status.textContent = "上传中…"; + if (status) { + status.textContent = "上传中…"; + status.className = "journal-upload-status or-upload-status journal-upload-status--pending"; + } var fd = new FormData(); fd.append("draft_id", draftId); fd.append("tf", input.getAttribute("data-tf") || ""); @@ -324,12 +303,18 @@ .then(function (data) { if (!data.ok) throw new Error(data.error || "fail"); if (hidden) hidden.value = data.file; - if (status) status.textContent = "上传成功 " + data.file; + if (status) { + status.textContent = "上传成功 " + data.file; + status.className = "journal-upload-status or-upload-status journal-upload-status--ok"; + } input.value = ""; }) .catch(function () { if (hidden) hidden.value = ""; - if (status) status.textContent = "上传失败"; + if (status) { + status.textContent = "上传失败"; + status.className = "journal-upload-status or-upload-status journal-upload-status--err"; + } }); }); }); @@ -394,7 +379,7 @@ var summary = $("or-journal-summary"); if (summary) { summary.textContent = - "先点「同步」拉记录,再在下方列表点选一笔填入;截图槽位 5m/15m/1h/4h 可先传."; + "点下方列表一笔记录自动填入;截图槽位与合约复盘相同(5m / 15m / 1h / 4h)."; } var legsHost = $("or-legs-host"); if (legsHost) legsHost.innerHTML = ""; @@ -509,7 +494,7 @@ function saveEntry() { var tradeId = Number(($("or-trade-id") || {}).value || 0); if (!tradeId) { - alert("请先在下方列表点选一笔记录(若无记录请先点同步)"); + alert("请先在下方列表点选一笔本地记录"); return; } var strategy = (($("or-f-strategy") || {}).value || "").trim(); @@ -576,12 +561,10 @@ setActiveTab(btn.getAttribute("data-source")); }); }); - var syncBtn = $("or-sync-btn"); var reloadBtn = $("or-reload-btn"); var saveBtn = $("or-save-btn"); var clearBtn = $("or-clear-btn"); var delBtn = $("or-del-btn"); - if (syncBtn) syncBtn.addEventListener("click", syncNow); if (reloadBtn) reloadBtn.addEventListener("click", reloadAll); if (saveBtn) saveBtn.addEventListener("click", saveEntry); if (clearBtn) clearBtn.addEventListener("click", hideJournalForm); diff --git a/lib/options/options_review_lib.py b/lib/options/options_review_lib.py index c0521a7..8da85ad 100644 --- a/lib/options/options_review_lib.py +++ b/lib/options/options_review_lib.py @@ -133,6 +133,69 @@ def upsert_option_history_row(conn: sqlite3.Connection, row: dict[str, Any]) -> return "inserted" +def sync_options_from_local_trades(conn: sqlite3.Connection) -> dict[str, Any]: + """从本地 options_trades 已平仓记录导入复盘快照(不访问交易所).""" + init_options_review_tables(conn) + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + rows = conn.execute( + """ + SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets, + open_quote, close_quote, premium_paid, realized_pnl, + created_at, closed_at, signal_note, status + FROM options_trades + WHERE status = 'closed' + ORDER BY id DESC + LIMIT 500 + """ + ).fetchall() + inserted = updated = skipped = 0 + for r in rows: + trade_id = int(r["id"]) + history_key = f"local_opt:{trade_id}" + pnl = _safe_float(r["realized_pnl"]) + opened_at = r["created_at"] + closed_at = r["closed_at"] + action = upsert_option_history_row( + conn, + { + "history_key": history_key, + "pos_id": f"local:{trade_id}", + "inst_id": r["inst_id"], + "underlying": r["underlying"], + "opt_type": r["opt_type"], + "strike": r["strike"], + "exp_time": r["exp_time"], + "sheets": r["sheets"], + "open_avg_px": r["open_quote"], + "close_avg_px": r["close_quote"], + "premium_paid": r["premium_paid"], + "realized_pnl": pnl, + "created_at": opened_at, + "closed_at": closed_at, + "status_label": "已平", + }, + ) + 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(rows))) + set_sync_state(conn, "options_sync_source", "local") + return { + "ok": True, + "source": "local", + "fetched": len(rows), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + } + + def sync_options_from_exchange( conn: sqlite3.Connection, ex: Any, @@ -141,7 +204,7 @@ def sync_options_from_exchange( fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, format_fn: Optional[Callable[..., dict[str, Any]]] = None, ) -> dict[str, Any]: - """从 OKX positions-history 导入已全平期权仓位.""" + """从 OKX positions-history 导入已全平期权仓位(可选,默认不用).""" init_options_review_tables(conn) from lib.exchange.okx_options_lib import ( fetch_all_option_positions_history, @@ -171,8 +234,10 @@ def sync_options_from_exchange( skipped += 1 set_sync_state(conn, "options_last_sync_at", _now_str()) set_sync_state(conn, "options_last_count", str(len(raw_rows))) + set_sync_state(conn, "options_sync_source", "exchange") return { "ok": True, + "source": "exchange", "fetched": len(raw_rows), "inserted": inserted, "updated": updated, @@ -326,24 +391,31 @@ def sync_hedge_plans_closed(conn: sqlite3.Connection) -> dict[str, Any]: def sync_all_review_sources( conn: sqlite3.Connection, - ex: Any | None, + ex: Any | None = None, *, options_limit: int = 500, + from_exchange: bool = False, fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, format_fn: Optional[Callable[..., dict[str, Any]]] = None, ) -> dict[str, Any]: + """默认只读本地 options_trades + 已结束对冲计划;不访问交易所.""" init_options_review_tables(conn) out: dict[str, Any] = {"ok": True, "options": None, "hedge": None} - if ex is not None: + if from_exchange and 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["options"] = sync_options_from_local_trades(conn) out["hedge"] = sync_hedge_plans_closed(conn) return out +def ensure_local_review_synced(conn: sqlite3.Connection) -> dict[str, Any]: + """列表/统计前轻量刷新本地源.""" + return sync_all_review_sources(conn, from_exchange=False) + + def _row_to_dict(row: Any) -> dict[str, Any]: return dict(row) if row is not None else {} diff --git a/lib/options/options_review_register.py b/lib/options/options_review_register.py index 229bb11..2b3f4f6 100644 --- a/lib/options/options_review_register.py +++ b/lib/options/options_review_register.py @@ -20,6 +20,7 @@ from lib.options.options_review_lib import ( SOURCE_LABELS, compute_review_stats, delete_review_entry, + ensure_local_review_synced, get_review_trade, list_review_trades, save_review_entry, @@ -103,14 +104,11 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s @app.route("/api/options/review/sync", methods=["POST"]) @lr def api_options_review_sync(): + """刷新本地 options_trades + 已结束对冲计划(不访问交易所).""" 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} + result = sync_all_review_sources(conn, from_exchange=False) conn.commit() return jsonify(result) finally: @@ -121,6 +119,8 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s def api_options_review_trades(): conn = cfg["get_db"]() try: + ensure_local_review_synced(conn) + conn.commit() items = list_review_trades( conn, source_type=(request.args.get("source_type") or "").strip() or None, @@ -214,6 +214,8 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s def api_options_review_stats(): conn = cfg["get_db"]() try: + ensure_local_review_synced(conn) + conn.commit() stats = compute_review_stats( conn, source_type=(request.args.get("source_type") or "").strip() or None, diff --git a/lib/options/templates/options_review_panel.html b/lib/options/templates/options_review_panel.html index 4138ef0..47b3634 100644 --- a/lib/options/templates/options_review_panel.html +++ b/lib/options/templates/options_review_panel.html @@ -1,67 +1,75 @@ -{# OKX 期权复盘:三类 Tab + 常驻复盘上传(同合约多周期截图) + 列表 #} +{# OKX 期权复盘:读本地记录;复盘上传区字体缩小,截图槽位对齐合约 #}
{% if not options_enabled %} -
期权未启用:请设置 OKX_OPTIONS_ENABLED=true 后重启.对冲计划仍可单独同步(若本地有已结束计划).
+
期权未启用:请设置 OKX_OPTIONS_ENABLED=true 后重启.
{% endif %} -
-
+
+

期权复盘

- - + +
-
- - - - - - -
- {# 与合约一致:复盘上传区常驻,不依赖先点列表 #} -
-

复盘记录上传(含截图)

-

先点「同步」拉记录,再在下方列表点选一笔,即可自动填入开平时间与盈亏;也可先上传截图再选记录保存.

+
+

复盘记录上传(含截图)

+

点下方列表一笔记录自动填入;截图槽位与合约复盘相同(5m / 15m / 1h / 4h).

@@ -94,21 +102,22 @@
- + -
+ +
{% for tf in ['5m', '15m', '1h', '4h'] %} -
- {{ tf }} - - - +
+ {{ tf }} + + +
{% endfor %}
-

可只传部分周期;选文件后即时上传(与合约复盘相同 5m/15m/1h/4h)

+

可只传部分周期;选文件后即时上传,保存后详情页四宫格查看

-
+
@@ -116,27 +125,27 @@
- -
+ +
-
+
-
+

统计

-
-
+
+

期权交易记录

-

点选一行,上方复盘表单会自动填入该笔数据.

+

点选一行,上方复盘表单自动填入.

@@ -158,4 +167,4 @@ - + diff --git a/tests/test_options_review_lib.py b/tests/test_options_review_lib.py index 9d68a3b..e8f2952 100644 --- a/tests/test_options_review_lib.py +++ b/tests/test_options_review_lib.py @@ -226,6 +226,32 @@ class OptionsReviewTests(unittest.TestCase): ).fetchone() self.assertEqual(float(row["realized_pnl_total"]), 1.23) + def test_local_options_trades_import(self): + conn = _conn() + from lib.options.options_db import init_options_tables + + init_options_tables(conn) + conn.execute( + """ + INSERT INTO options_trades + (inst_id, underlying, opt_type, strike, sheets, eth_amount, + open_quote, premium_paid, status, realized_pnl, created_at, closed_at) + VALUES ('ETH-USD-260328-2000-C','ETH','C',2000,2,0.02,0.01,0.5,'closed',3.2, + '2026-03-01 10:00:00','2026-03-01 12:00:00') + """ + ) + from lib.options.options_review_lib import sync_options_from_local_trades + + out = sync_options_from_local_trades(conn) + self.assertTrue(out["ok"]) + self.assertEqual(out["inserted"], 1) + row = conn.execute( + "SELECT history_key, realized_pnl_total, source_type FROM options_review_trades" + ).fetchone() + self.assertTrue(str(row["history_key"]).startswith("local_opt:")) + self.assertEqual(float(row["realized_pnl_total"]), 3.2) + self.assertEqual(row["source_type"], SOURCE_OPTION) + def test_image_namespace(self): with tempfile.TemporaryDirectory() as tmp: folder = options_review_upload_dir(tmp)