From 8f37d02494253151ac86adae746099b3fb95c1ec Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 15 Sep 2026 11:06:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=9F=E6=9D=83=E5=A4=8D=E7=9B=98=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=BA=AF=E6=9C=9F=E6=9D=83=E8=87=AA=E5=8A=A8=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=A0=87=E7=9A=84=E6=B0=B8=E7=BB=AD=E5=8F=8C=E5=91=A8?= =?UTF-8?q?=E6=9C=9FK=E7=BA=BF=EF=BC=8C=E6=8C=89=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E8=BF=9B=E5=9C=BA=E5=87=BA=E5=9C=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- lib/common/static/options_review.js | 43 ++++- lib/options/options_review_chart_async_lib.py | 137 +++++++++++++++ lib/options/options_review_register.py | 142 ++++++++++++++++ .../templates/options_review_panel.html | 31 ++++ tests/test_options_review_chart_async_lib.py | 157 ++++++++++++++++++ 5 files changed, 506 insertions(+), 4 deletions(-) create mode 100644 lib/options/options_review_chart_async_lib.py create mode 100644 tests/test_options_review_chart_async_lib.py diff --git a/lib/common/static/options_review.js b/lib/common/static/options_review.js index bc80c7b..6ad3c34 100644 --- a/lib/common/static/options_review.js +++ b/lib/common/static/options_review.js @@ -626,6 +626,24 @@ return base + encodeURIComponent(name); } + function chartTfLabel(tf) { + if (tf === "auto") return "自动K线"; + if (tf === "_") return "截图"; + return tf; + } + + function setChartOptionsVisible(visible) { + var box = $("or-chart-options"); + var hint = $("or-chart-hint"); + if (box) box.classList.toggle("hidden", !visible); + if (hint) hint.classList.toggle("hidden", !visible); + } + + function resetChartOptions() { + var cb = $("or-exchange-chart"); + if (cb) cb.checked = false; + } + function renderDetailImages(images) { var imagesHost = $("or-detail-images"); if (!imagesHost) return; @@ -637,7 +655,7 @@ var key = tf || "_"; byTf[key] = file; }); - var order = ["5m", "15m", "1h", "4h"]; + var order = ["auto", "5m", "15m", "1h", "4h"]; var keys = order.slice(); Object.keys(byTf).forEach(function (k) { if (keys.indexOf(k) < 0) keys.push(k); @@ -646,7 +664,7 @@ .map(function (tf) { var file = byTf[tf]; if (!file) { - if (order.indexOf(tf) < 0) return ""; + if (order.indexOf(tf) < 0 || tf === "auto") return ""; return ( '
' + '' + @@ -657,7 +675,7 @@ ); } var src = optionsJournalImgSrc(file); - var label = escapeHtml(tf === "_" ? "截图" : tf); + var label = escapeHtml(chartTfLabel(tf)); return ( '
' + '' + @@ -992,6 +1010,8 @@ ($("or-f-note") || {}).value = ""; setMoodTags(""); resetUploadSlots(); + resetChartOptions(); + setChartOptionsVisible(false); var summary = $("or-journal-summary"); if (summary) { summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h)."; @@ -1053,6 +1073,8 @@ setSelectValue($("or-f-entry"), e.entry_logic || ""); ($("or-f-note") || {}).value = e.note || ""; setMoodTags(e.mistake_tags); + resetChartOptions(); + setChartOptionsVisible(t.source_type === "option_spot"); var summary = $("or-journal-summary"); if (summary) { @@ -1068,6 +1090,8 @@ } (e.images || []).forEach(function (img) { + var tf = String((img && img.tf) || "").trim(); + if (tf === "auto") return; var hidden = document.querySelector( '#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]' ); @@ -1167,6 +1191,15 @@ note: ($("or-f-note") || {}).value || "", images: collectImages(), }; + var chartBox = $("or-chart-options"); + var chartVisible = chartBox && !chartBox.classList.contains("hidden"); + if (chartVisible && ($("or-exchange-chart") || {}).checked) { + payload.journal_exchange_chart = true; + payload.journal_chart_tf1 = ($("or-chart-tf1") || {}).value || ""; + payload.journal_chart_tf2 = ($("or-chart-tf2") || {}).value || ""; + payload.journal_chart_limit = ($("or-chart-limit") || {}).value || ""; + payload.journal_chart_anchor = ($("or-chart-anchor") || {}).value || "close"; + } ($("or-save-status") || {}).textContent = "保存中…"; fetch("/api/options/review/entry", { method: "POST", @@ -1182,7 +1215,9 @@ ($("or-save-status") || {}).textContent = data.msg || "保存失败"; return; } - ($("or-save-status") || {}).textContent = "已保存"; + ($("or-save-status") || {}).textContent = data.chart_queued + ? "已保存,K线图后台生成中" + : "已保存"; hideJournalForm(); reloadAll(); openDetail(tradeId); diff --git a/lib/options/options_review_chart_async_lib.py b/lib/options/options_review_chart_async_lib.py new file mode 100644 index 0000000..3b18e0a --- /dev/null +++ b/lib/options/options_review_chart_async_lib.py @@ -0,0 +1,137 @@ +"""期权复盘自动 K 线:后台线程生成标的永续双周期拼图,写回 images_json.""" + +from __future__ import annotations + +import logging +import threading +from typing import Any, Callable, Optional + +from lib.options.options_review_images_lib import ( + images_json_dumps, + parse_options_review_images_json, +) + +logger = logging.getLogger(__name__) + +OPTIONS_REVIEW_AUTO_CHART_TF = "auto" + + +def underlying_to_okx_perp_symbol( + underlying: str, + *, + normalize_symbol_input_fn: Callable[[str], str], + normalize_okx_symbol_fn: Callable[[str], str], +) -> str: + """ETH / ETH-USD / ETH-USDT-SWAP → ETH/USDT:USDT.""" + raw = str(underlying or "").strip().upper() + if not raw: + return "" + base = raw.split("-", 1)[0].split("/", 1)[0].strip() + if not base: + return "" + guess = normalize_symbol_input_fn(base) or f"{base}/USDT" + return str(normalize_okx_symbol_fn(guess) or "").strip() + + +def merge_auto_chart_into_images( + existing: Optional[list], + filename: str, +) -> list[dict[str, str]]: + """保留手动槽位,用 tf=auto 放置拼图(方案甲一张总图).""" + fname = str(filename or "").strip() + out: list[dict[str, str]] = [] + for item in existing or []: + if not isinstance(item, dict): + continue + tf = str(item.get("tf") or "").strip() + file = str(item.get("file") or "").strip() + if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF: + continue + out.append({"tf": tf, "file": file}) + if fname: + out.insert(0, {"tf": OPTIONS_REVIEW_AUTO_CHART_TF, "file": fname}) + return out + + +def build_options_review_chart_filename(trade_id: int) -> str: + return f"options_journal_chart_{int(trade_id)}.png" + + +def schedule_options_review_exchange_chart( + *, + trade_id: int, + exchange_symbol: str, + title_prefix: str, + journal_tfs: list[str], + journal_limit: int, + marker_payload: dict[str, Any], + upload_folder: str, + generate_chart_fn: Callable[..., str | None], + get_db_fn: Callable[[], Any], + now_str_fn: Callable[[], str], +) -> None: + """保存后立即返回;线程内画图并合并进 options_review_entries.images_json.""" + tid = int(trade_id or 0) + if tid <= 0 or not callable(generate_chart_fn) or not callable(get_db_fn): + return + symbol = str(exchange_symbol or "").strip() + if not symbol: + return + tfs = [str(x).strip() for x in (journal_tfs or []) if str(x).strip()] + if not tfs: + return + + def _run() -> None: + try: + chart_fname = build_options_review_chart_filename(tid) + saved = generate_chart_fn( + symbol, + title_prefix, + timeframes=tfs, + limit=journal_limit, + out_dir=upload_folder, + filename=chart_fname, + filename_prefix="options_journal_chart", + marker_payload=marker_payload, + marker_timeframes={x.lower() for x in tfs}, + layout="vertical", + ) + if not saved: + logger.warning("options review chart async empty trade_id=%s", tid) + return + conn = get_db_fn() + try: + row = conn.execute( + "SELECT images_json FROM options_review_entries WHERE trade_id=?", + (tid,), + ).fetchone() + if not row: + logger.warning( + "options review chart async missing entry trade_id=%s", tid + ) + return + images = parse_options_review_images_json(row["images_json"]) + merged = merge_auto_chart_into_images(images, saved) + primary = merged[0]["file"] if merged else saved + conn.execute( + """UPDATE options_review_entries + SET images_json=?, image=?, updated_at=? + WHERE trade_id=?""", + ( + images_json_dumps(merged), + primary, + now_str_fn(), + tid, + ), + ) + conn.commit() + finally: + conn.close() + except Exception: + logger.exception("options review chart async failed trade_id=%s", tid) + + threading.Thread( + target=_run, + name=f"options-review-chart-{tid}", + daemon=True, + ).start() diff --git a/lib/options/options_review_register.py b/lib/options/options_review_register.py index 209a5d0..1e6b673 100644 --- a/lib/options/options_review_register.py +++ b/lib/options/options_review_register.py @@ -93,6 +93,141 @@ def _require_ex(cfg: dict[str, Any]): return ex, "" +def _truthy(raw: Any) -> bool: + return str(raw or "").strip().lower() in ("1", "true", "on", "yes") + + +def _prepare_review_entry_images( + conn: Any, + trade_id: int, + data: dict[str, Any], +) -> dict[str, Any]: + """客户端只提交手动槽位;未重新勾选自动K线时保留已有 auto 附图.""" + from lib.options.options_review_chart_async_lib import OPTIONS_REVIEW_AUTO_CHART_TF + from lib.options.options_review_images_lib import parse_options_review_images_json + + images = data.get("images") if isinstance(data.get("images"), list) else [] + cleaned: list[dict[str, str]] = [] + for item in images: + if not isinstance(item, dict): + continue + tf = str(item.get("tf") or "").strip() + file = str(item.get("file") or "").strip() + if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF: + continue + cleaned.append({"tf": tf, "file": file}) + + want_chart = _truthy(data.get("journal_exchange_chart")) and not cleaned + if not want_chart: + row = conn.execute( + "SELECT images_json FROM options_review_entries WHERE trade_id=?", + (int(trade_id),), + ).fetchone() + if row: + for item in parse_options_review_images_json(row["images_json"]): + if str(item.get("tf") or "").strip() == OPTIONS_REVIEW_AUTO_CHART_TF: + cleaned.insert(0, dict(item)) + break + + out = dict(data) + out["images"] = cleaned + return out + + +def _maybe_schedule_options_review_chart( + cfg: dict[str, Any], + trade: dict[str, Any], + data: dict[str, Any], +) -> bool: + """纯期权 + 无手动截图 + 勾选自动K线 → 后台生成标的永续双周期拼图(只钉进/出场).""" + if str(trade.get("source_type") or "").strip() != "option_spot": + return False + images = data.get("images") if isinstance(data.get("images"), list) else [] + has_manual = any(str((x or {}).get("file") or "").strip() for x in images) + if has_manual or not _truthy(data.get("journal_exchange_chart")): + return False + + app_module = cfg.get("app_module") + if app_module is None: + return False + if not bool(getattr(app_module, "ORDER_CHART_ENABLED", True)): + return False + generate_fn = getattr(app_module, "generate_multi_timeframe_chart_png", None) + if not callable(generate_fn): + return False + + from lib.instance.journal_chart_lib import ( + parse_journal_chart_anchor, + parse_journal_chart_limit, + parse_journal_chart_timeframes, + ) + from lib.options.options_review_chart_async_lib import ( + schedule_options_review_exchange_chart, + underlying_to_okx_perp_symbol, + ) + from lib.options.options_review_images_lib import options_review_upload_dir + from lib.options.options_review_lib import _now_str + + fallback_tfs = list(getattr(app_module, "ORDER_CHART_TFS", None) or [])[:2] + journal_tfs = parse_journal_chart_timeframes( + data.get("journal_chart_tf1"), + data.get("journal_chart_tf2"), + fallback_tfs or None, + ) + journal_limit = parse_journal_chart_limit( + data.get("journal_chart_limit"), + int(getattr(app_module, "ORDER_CHART_LIMIT", 100) or 100), + ) + chart_anchor = parse_journal_chart_anchor(data.get("journal_chart_anchor")) + + underlying = str(trade.get("underlying") or data.get("underlying") or "").strip() + exchange_symbol = underlying_to_okx_perp_symbol( + underlying, + normalize_symbol_input_fn=app_module.normalize_symbol_input, + normalize_okx_symbol_fn=app_module.normalize_okx_symbol, + ) + if not exchange_symbol: + return False + + to_ms = getattr(app_module, "_local_input_datetime_to_ms", None) + if not callable(to_ms): + return False + entry_ts = to_ms(trade.get("opened_at")) + exit_ts = to_ms(trade.get("closed_at")) + if entry_ts is None and exit_ts is None: + return False + + now_ms = None + app_now = getattr(app_module, "app_now", None) + if callable(app_now): + try: + now_ms = int(app_now().timestamp() * 1000) + except Exception: + now_ms = None + + inst = str(trade.get("inst_id") or underlying or f"#{trade.get('id') or ''}").strip() + upload_folder = options_review_upload_dir(cfg["upload_folder"]) + schedule_options_review_exchange_chart( + trade_id=int(trade["id"]), + exchange_symbol=exchange_symbol, + title_prefix=f"{underlying or 'OPT'} {inst} review", + journal_tfs=journal_tfs, + journal_limit=journal_limit, + marker_payload={ + "entry_ts_ms": entry_ts, + "exit_ts_ms": exit_ts, + # 只按时间钉进/出场,不传价格与止损 + "chart_anchor": chart_anchor, + "now_ts_ms": now_ms, + }, + upload_folder=upload_folder, + generate_chart_fn=generate_fn, + get_db_fn=cfg["get_db"], + now_str_fn=_now_str, + ) + return True + + def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None: lr = cfg["login_required"] @@ -216,9 +351,16 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s return jsonify({"ok": False, "msg": "trade_id 无效"}), 400 conn = cfg["get_db"]() try: + data = _prepare_review_entry_images(conn, trade_id, data) out = save_review_entry(conn, trade_id, data) if out.get("ok"): conn.commit() + chart_queued = _maybe_schedule_options_review_chart( + cfg, out.get("trade") or {}, data + ) + if chart_queued: + out = dict(out) + out["chart_queued"] = True return jsonify(out), (200 if out.get("ok") else 400) finally: conn.close() diff --git a/lib/options/templates/options_review_panel.html b/lib/options/templates/options_review_panel.html index f67311b..54aeeaf 100644 --- a/lib/options/templates/options_review_panel.html +++ b/lib/options/templates/options_review_panel.html @@ -327,6 +327,37 @@

可只传部分周期;选文件后即时上传

+ + +
diff --git a/tests/test_options_review_chart_async_lib.py b/tests/test_options_review_chart_async_lib.py new file mode 100644 index 0000000..fb55dde --- /dev/null +++ b/tests/test_options_review_chart_async_lib.py @@ -0,0 +1,157 @@ +"""期权复盘自动 K 线:单元测试.""" +from __future__ import annotations + +import sqlite3 +import unittest +from unittest.mock import MagicMock, patch + +from lib.options.options_review_chart_async_lib import ( + OPTIONS_REVIEW_AUTO_CHART_TF, + build_options_review_chart_filename, + merge_auto_chart_into_images, + schedule_options_review_exchange_chart, + underlying_to_okx_perp_symbol, +) +from lib.options.options_review_db import init_options_review_tables +from lib.options.options_review_lib import save_review_entry, upsert_option_history_row + + +def _conn() -> sqlite3.Connection: + c = sqlite3.connect(":memory:") + c.row_factory = sqlite3.Row + init_options_review_tables(c) + return c + + +class OptionsReviewChartAsyncTests(unittest.TestCase): + def test_underlying_to_okx_perp_symbol(self): + self.assertEqual( + underlying_to_okx_perp_symbol( + "ETH-USD", + normalize_symbol_input_fn=lambda s: f"{s}/USDT", + normalize_okx_symbol_fn=lambda s: f"{s.split('/')[0]}/USDT:USDT" + if "/" in s + else s, + ), + "ETH/USDT:USDT", + ) + + def test_merge_auto_chart_keeps_manual_slots(self): + merged = merge_auto_chart_into_images( + [ + {"tf": "auto", "file": "old.png"}, + {"tf": "15m", "file": "m15.png"}, + ], + "new_auto.png", + ) + self.assertEqual(merged[0]["tf"], OPTIONS_REVIEW_AUTO_CHART_TF) + self.assertEqual(merged[0]["file"], "new_auto.png") + self.assertEqual(merged[1], {"tf": "15m", "file": "m15.png"}) + + def test_chart_filename(self): + self.assertEqual( + build_options_review_chart_filename(12), "options_journal_chart_12.png" + ) + + @patch("lib.options.options_review_chart_async_lib.threading.Thread") + def test_schedule_starts_daemon_thread(self, mock_thread_cls): + thread = MagicMock() + mock_thread_cls.return_value = thread + schedule_options_review_exchange_chart( + trade_id=7, + exchange_symbol="ETH/USDT:USDT", + title_prefix="t", + journal_tfs=["15m", "1h"], + journal_limit=100, + marker_payload={}, + upload_folder="/tmp", + generate_chart_fn=MagicMock(), + get_db_fn=MagicMock(), + now_str_fn=lambda: "2026-09-15 12:00:00", + ) + mock_thread_cls.assert_called_once() + self.assertTrue(mock_thread_cls.call_args.kwargs.get("daemon")) + thread.start.assert_called_once() + + def test_writeback_merges_auto_image(self): + import tempfile + from pathlib import Path + + from lib.options.options_review_images_lib import parse_options_review_images_json + + with tempfile.TemporaryDirectory() as td: + db_path = Path(td) / "t.db" + + def get_db(): + c = sqlite3.connect(str(db_path)) + c.row_factory = sqlite3.Row + return c + + conn = get_db() + init_options_review_tables(conn) + upsert_option_history_row( + conn, + { + "history_key": "ex:chart1", + "inst_id": "ETH-USD-260328-2000-C", + "underlying": "ETH", + "opt_type": "C", + "realized_pnl": 1.0, + "created_at": "2026-09-01 10:00:00", + "closed_at": "2026-09-01 12:00:00", + }, + ) + trade_id = conn.execute( + "SELECT id FROM options_review_trades WHERE history_key='ex:chart1'" + ).fetchone()["id"] + save_review_entry( + conn, + trade_id, + { + "strategy_tag": "测试", + "images": [{"tf": "15m", "file": "options_journal_x_15m.png"}], + }, + ) + conn.commit() + conn.close() + + chart_name = build_options_review_chart_filename(trade_id) + generate = MagicMock(return_value=chart_name) + + with patch( + "lib.options.options_review_chart_async_lib.threading.Thread" + ) as mock_thread_cls: + thread = MagicMock() + mock_thread_cls.return_value = thread + schedule_options_review_exchange_chart( + trade_id=trade_id, + exchange_symbol="ETH/USDT:USDT", + title_prefix="ETH review", + journal_tfs=["15m", "1h"], + journal_limit=200, + marker_payload={"entry_ts_ms": 1, "exit_ts_ms": 2}, + upload_folder=td, + generate_chart_fn=generate, + get_db_fn=get_db, + now_str_fn=lambda: "2026-09-15 12:00:00", + ) + target = mock_thread_cls.call_args.kwargs["target"] + target() + + conn = get_db() + try: + row = conn.execute( + "SELECT images_json, image FROM options_review_entries WHERE trade_id=?", + (trade_id,), + ).fetchone() + self.assertEqual(row["image"], chart_name) + images = parse_options_review_images_json(row["images_json"]) + self.assertEqual(images[0]["tf"], "auto") + self.assertEqual(images[0]["file"], chart_name) + self.assertTrue(any(x.get("tf") == "15m" for x in images)) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main()