' +
@@ -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()