' +
@@ -657,7 +762,7 @@
);
}
var src = optionsJournalImgSrc(file);
- var label = escapeHtml(tf === "_" ? "截图" : tf);
+ var label = escapeHtml(chartTfLabel(tf));
return (
'' +
'
' +
@@ -698,7 +803,7 @@
});
}
- function renderDetail(t) {
+ function renderDetail(t, opts) {
var e = t.entry || {};
reviewedCache[t.id] = t;
($("or-detail-title") || {}).textContent =
@@ -782,7 +887,7 @@
var imagesHost = $("or-detail-images");
if (imagesHost) {
- renderDetailImages(e.images || []);
+ renderDetailImages(e.images || [], opts || {});
}
}
@@ -992,6 +1097,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 +1160,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 +1177,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 +1278,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,10 +1302,12 @@
($("or-save-status") || {}).textContent = data.msg || "保存失败";
return;
}
- ($("or-save-status") || {}).textContent = "已保存";
+ ($("or-save-status") || {}).textContent = data.chart_queued
+ ? "已保存,K线图后台生成中"
+ : "已保存";
hideJournalForm();
reloadAll();
- openDetail(tradeId);
+ openDetail(tradeId, { waitAutoChart: !!data.chart_queued });
})
.catch(function () {
($("or-save-status") || {}).textContent = "保存失败";
diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py
index 8b2dd93..7e9714e 100644
--- a/lib/exchange/okx_options_lib.py
+++ b/lib/exchange/okx_options_lib.py
@@ -213,16 +213,10 @@ def option_history_row_key(
def _ms_to_iso(ms: Any) -> str | None:
- val = _safe_float(ms)
- if val is None or val <= 0:
- return None
- try:
- from datetime import datetime, timezone
+ """OKX ms → 业务时区墙钟(默认 Asia/Shanghai);勿用系统本地时区."""
+ from lib.options.options_time_lib import ms_to_options_app_str
- dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone()
- return dt.strftime("%Y-%m-%d %H:%M:%S")
- except (TypeError, ValueError, OSError):
- return None
+ return ms_to_options_app_str(ms)
def option_instrument_meta_cached(
diff --git a/lib/options/options_close_exec_lib.py b/lib/options/options_close_exec_lib.py
index f37d42b..56ddea6 100644
--- a/lib/options/options_close_exec_lib.py
+++ b/lib/options/options_close_exec_lib.py
@@ -363,12 +363,15 @@ def close_option_by_bid1(
ELSE signal_note
END"""
params.append(signal_note)
+ from lib.options.options_time_lib import options_app_now_str
+
+ params.append(options_app_now_str())
params.append(int(row["id"]))
conn.execute(
f"""
UPDATE options_trades
SET status = 'closed', close_quote = ?, premium_received = ?,
- realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP
+ realized_pnl = ?, close_ord_id = ?, closed_at = ?
{note_sql}
WHERE id = ?
""",
diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py
index 1ed86ca..af9dce4 100644
--- a/lib/options/options_coin_open_lib.py
+++ b/lib/options/options_coin_open_lib.py
@@ -400,6 +400,8 @@ def open_coin_option_buy_full(
def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
+ from lib.options.options_time_lib import options_app_now_str
+
pe = 1 if kwargs.get("profit_exit_enabled") else 0
pe_mult = float(kwargs.get("profit_exit_mult") or 1.0)
pe_state = "active" if pe else "idle"
@@ -409,8 +411,8 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id,
margin_mode, premium_ccy, bridge_id, budget_usdt,
- profit_exit_enabled, profit_exit_mult, profit_exit_state
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?)
+ profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?)
""",
(
kwargs["inst_id"],
@@ -430,6 +432,7 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
pe,
pe_mult,
pe_state,
+ options_app_now_str(),
),
)
conn.commit()
diff --git a/lib/options/options_monitor_lib.py b/lib/options/options_monitor_lib.py
index 63baadc..1c94db8 100644
--- a/lib/options/options_monitor_lib.py
+++ b/lib/options/options_monitor_lib.py
@@ -1,16 +1,12 @@
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
from __future__ import annotations
-import os
import sqlite3
import time
-from datetime import datetime, timezone
from typing import Any, Callable
-from zoneinfo import ZoneInfo
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
-
-_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
+from lib.options.options_time_lib import ms_to_options_app_str, options_app_now_str, options_wall_clock_to_ms
def _safe_float(v: Any) -> float | None:
@@ -151,18 +147,7 @@ def run_options_profit_alerts(
def _created_at_ms(created_at: Any) -> int | None:
"""墙钟 created_at → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
- if not created_at:
- return None
- raw = str(created_at).strip()
- if not raw:
- return None
- for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
- try:
- dt = datetime.strptime(raw[:ln], fmt).replace(tzinfo=_APP_TZ)
- return int(dt.timestamp() * 1000)
- except ValueError:
- continue
- return None
+ return options_wall_clock_to_ms(created_at)
def _group_key_for_closed_trade(row: Any) -> str:
@@ -327,21 +312,20 @@ def sync_open_options_trades(
prem_recv = round(paid + float(realized_pnl), 4)
close_ms = close_info.get("close_ms")
if close_ms:
- closed_at = datetime.fromtimestamp(int(close_ms) / 1000, tz=timezone.utc).strftime(
- "%Y-%m-%d %H:%M:%S"
- )
+ closed_at = ms_to_options_app_str(close_ms)
elif exp_ms is not None and now_ms >= int(exp_ms):
close_reason = "expired"
close_quote = 0.0
prem_recv = 0.0
realized_pnl = round(-paid, 4)
if exp_ms:
- closed_at = datetime.fromtimestamp(int(exp_ms) / 1000, tz=timezone.utc).strftime(
- "%Y-%m-%d %H:%M:%S"
- )
+ closed_at = ms_to_options_app_str(exp_ms)
else:
continue
+ if not closed_at:
+ closed_at = options_app_now_str()
+
conn.execute(
"""
UPDATE options_trades
@@ -350,7 +334,7 @@ def sync_open_options_trades(
premium_received = ?,
realized_pnl = ?,
close_ord_id = COALESCE(?, close_ord_id),
- closed_at = COALESCE(?, closed_at, CURRENT_TIMESTAMP),
+ closed_at = COALESCE(?, closed_at),
signal_note = CASE
WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '')
THEN '到期结算'
diff --git a/lib/options/options_register.py b/lib/options/options_register.py
index a5b929c..c379177 100644
--- a/lib/options/options_register.py
+++ b/lib/options/options_register.py
@@ -1218,13 +1218,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
opt_type = meta.get("optType")
open_underlying = u
open_opt_type = opt_type
+ from lib.options.options_time_lib import options_app_now_str
+
cur = conn.execute(
"""
INSERT INTO options_trades
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id,
- profit_exit_enabled, profit_exit_mult, profit_exit_state)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?)
+ profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?)
""",
(
inst_id,
@@ -1241,6 +1243,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
1 if profit_exit_enabled else 0,
profit_exit_mult if profit_exit_enabled else 1.0,
"active" if profit_exit_enabled else "idle",
+ options_app_now_str(),
),
)
trade_id = int(cur.lastrowid)
@@ -1341,6 +1344,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
try:
conn = cfg["get_db"]()
try:
+ from lib.options.options_time_lib import options_app_now_str
+
init_options_tables(conn)
conn.execute(
"""
@@ -1350,10 +1355,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销'
ELSE signal_note
END,
- closed_at = CURRENT_TIMESTAMP
+ closed_at = ?
WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open'
""",
- (inst_id, ord_id),
+ (options_app_now_str(), inst_id, ord_id),
)
conn.commit()
finally:
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_lib.py b/lib/options/options_review_lib.py
index 73e1d5b..cbdd0ab 100644
--- a/lib/options/options_review_lib.py
+++ b/lib/options/options_review_lib.py
@@ -643,6 +643,9 @@ def ensure_local_review_synced(
backfill_exchange_pnl: bool = True,
) -> dict[str, Any]:
"""列表/统计前轻量刷新本地源;有交易所时先用历史仓位盈亏覆盖本地再导入复盘."""
+ from lib.options.options_time_lib import migrate_options_utc_wall_clock_to_app
+
+ ts_migrate = migrate_options_utc_wall_clock_to_app(conn)
if backfill_exchange_pnl and ex is not None:
try:
from lib.exchange.okx_options_lib import fetch_all_option_positions_history
@@ -658,7 +661,10 @@ def ensure_local_review_synced(
backfill_hedge_option_legs_realized_pnl(conn, hist)
except Exception:
pass
- return sync_all_review_sources(conn, ex=ex, from_exchange=False)
+ out = sync_all_review_sources(conn, ex=ex, from_exchange=False)
+ if isinstance(out, dict):
+ out["ts_migrate"] = ts_migrate
+ return out
def _row_to_dict(row: Any) -> dict[str, Any]:
diff --git a/lib/options/options_review_register.py b/lib/options/options_review_register.py
index 3a387a3..dd65e31 100644
--- a/lib/options/options_review_register.py
+++ b/lib/options/options_review_register.py
@@ -1,310 +1,452 @@
-"""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,
- count_review_trades,
- delete_review_entry,
- ensure_local_review_synced,
- get_review_trade,
- hide_review_trade,
- list_review_trades,
- save_review_entry,
-)
-
-
-def _review_source_for_mode(requested: str | None) -> str | None:
- """按当前交易模式钳制复盘 source_type;不允许跨模式窥探."""
- try:
- from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
-
- mode = get_okx_trade_mode()
- except Exception:
- mode = "options"
- allowed = {
- "options": "option_spot",
- "perp_options": "perp_options",
- "options_options": "options_options",
- }.get(mode, "option_spot")
- req = (requested or "").strip()
- if not req:
- return allowed
- if req == allowed:
- return allowed
- # 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制
- return allowed
-
-
-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(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/")
- def static_options_review_image(filename: str):
- """截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401)."""
- 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):
- # 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件
- root = os.path.abspath(cfg["upload_folder"] or "")
- alt = os.path.join(root, safe)
- if os.path.isfile(alt):
- path = alt
- else:
- return ("not found", 404)
- return send_file(path)
-
- @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 = ensure_local_review_synced(conn, ex=ex if ex is not None else None)
- 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:
- # 翻页可跳过同步,仅刷新当前卡片列表
- do_sync = (request.args.get("sync") or "1").strip().lower() not in (
- "0",
- "false",
- "no",
- )
- if do_sync:
- ex, _err = _require_ex(cfg)
- ensure_local_review_synced(conn, ex=ex if ex is not None else None)
- conn.commit()
- filt = dict(
- source_type=_review_source_for_mode(request.args.get("source_type")),
- 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,
- q=(request.args.get("q") 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))
- total = count_review_trades(conn, **filt)
- items = list_review_trades(conn, **filt, limit=limit, offset=offset)
- pages = max(1, (total + limit - 1) // limit) if total else 1
- page = (offset // limit) + 1 if limit else 1
- return jsonify(
- {
- "ok": True,
- "trades": items,
- "source_labels": SOURCE_LABELS,
- "total": total,
- "limit": limit,
- "offset": offset,
- "page": page,
- "pages": pages,
- }
- )
- 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/trades/", methods=["DELETE"])
- @lr
- def api_options_review_trade_hide(trade_id: int):
- """从复盘列表删除并持久隐藏(刷新本地源也不会再导入)."""
- conn = cfg["get_db"]()
- try:
- out = hide_review_trade(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/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:
- ex, _err = _require_ex(cfg)
- ensure_local_review_synced(conn, ex=ex if ex is not None else None)
- conn.commit()
- stats = compute_review_stats(
- conn,
- source_type=_review_source_for_mode(request.args.get("source_type")),
- 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()
+"""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,
+ count_review_trades,
+ delete_review_entry,
+ ensure_local_review_synced,
+ get_review_trade,
+ hide_review_trade,
+ list_review_trades,
+ save_review_entry,
+)
+
+
+def _review_source_for_mode(requested: str | None) -> str | None:
+ """按当前交易模式钳制复盘 source_type;不允许跨模式窥探."""
+ try:
+ from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
+
+ mode = get_okx_trade_mode()
+ except Exception:
+ mode = "options"
+ allowed = {
+ "options": "option_spot",
+ "perp_options": "perp_options",
+ "options_options": "options_options",
+ }.get(mode, "option_spot")
+ req = (requested or "").strip()
+ if not req:
+ return allowed
+ if req == allowed:
+ return allowed
+ # 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制
+ return allowed
+
+
+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(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 _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"]
+
+ @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/")
+ def static_options_review_image(filename: str):
+ """截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401)."""
+ 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):
+ # 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件
+ root = os.path.abspath(cfg["upload_folder"] or "")
+ alt = os.path.join(root, safe)
+ if os.path.isfile(alt):
+ path = alt
+ else:
+ return ("not found", 404)
+ return send_file(path)
+
+ @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 = ensure_local_review_synced(conn, ex=ex if ex is not None else None)
+ 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:
+ # 翻页可跳过同步,仅刷新当前卡片列表
+ do_sync = (request.args.get("sync") or "1").strip().lower() not in (
+ "0",
+ "false",
+ "no",
+ )
+ if do_sync:
+ ex, _err = _require_ex(cfg)
+ ensure_local_review_synced(conn, ex=ex if ex is not None else None)
+ conn.commit()
+ filt = dict(
+ source_type=_review_source_for_mode(request.args.get("source_type")),
+ 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,
+ q=(request.args.get("q") 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))
+ total = count_review_trades(conn, **filt)
+ items = list_review_trades(conn, **filt, limit=limit, offset=offset)
+ pages = max(1, (total + limit - 1) // limit) if total else 1
+ page = (offset // limit) + 1 if limit else 1
+ return jsonify(
+ {
+ "ok": True,
+ "trades": items,
+ "source_labels": SOURCE_LABELS,
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ "page": page,
+ "pages": pages,
+ }
+ )
+ 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:
+ 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()
+
+ @app.route("/api/options/review/trades/", methods=["DELETE"])
+ @lr
+ def api_options_review_trade_hide(trade_id: int):
+ """从复盘列表删除并持久隐藏(刷新本地源也不会再导入)."""
+ conn = cfg["get_db"]()
+ try:
+ out = hide_review_trade(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/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:
+ ex, _err = _require_ex(cfg)
+ ensure_local_review_synced(conn, ex=ex if ex is not None else None)
+ conn.commit()
+ stats = compute_review_stats(
+ conn,
+ source_type=_review_source_for_mode(request.args.get("source_type")),
+ 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/options_time_lib.py b/lib/options/options_time_lib.py
new file mode 100644
index 0000000..83b2fcb
--- /dev/null
+++ b/lib/options/options_time_lib.py
@@ -0,0 +1,121 @@
+"""期权业务时区墙钟:库内统一按 APP_TIMEZONE(默认 Asia/Shanghai)存储,不可当 UTC."""
+
+from __future__ import annotations
+
+import os
+import sqlite3
+from datetime import datetime, timezone
+from typing import Any, Optional
+from zoneinfo import ZoneInfo
+
+_APP_TZ = ZoneInfo(
+ (os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip()
+ or "Asia/Shanghai"
+)
+_MIGRATE_FLAG = "options_ts_utc_wall_to_app_done"
+
+
+def options_app_tz() -> ZoneInfo:
+ return _APP_TZ
+
+
+def options_app_now_str() -> str:
+ return datetime.now(_APP_TZ).strftime("%Y-%m-%d %H:%M:%S")
+
+
+def ms_to_options_app_str(ms: Any) -> Optional[str]:
+ """交易所 ms(UTC 绝对时间) → 业务时区墙钟字符串."""
+ try:
+ val = float(ms)
+ except (TypeError, ValueError):
+ return None
+ if val <= 0:
+ return None
+ try:
+ dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone(_APP_TZ)
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
+ except (TypeError, ValueError, OSError, OverflowError):
+ return None
+
+
+def options_wall_clock_to_ms(raw: Any) -> Optional[int]:
+ """业务时区墙钟 → UTC ms(与合约复盘 _local_input_datetime_to_ms 同口径)."""
+ if raw is None or raw == "":
+ return None
+ s = str(raw).strip().replace("T", " ")
+ for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16)):
+ try:
+ dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ)
+ return int(dt.timestamp() * 1000)
+ except ValueError:
+ continue
+ return None
+
+
+def utc_wall_clock_to_app_str(raw: Any) -> Optional[str]:
+ """把误存成 UTC 墙钟的字符串改写为业务时区墙钟(+8 等)."""
+ if raw is None or raw == "":
+ return None
+ s = str(raw).strip().replace("T", " ")
+ for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16)):
+ try:
+ dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=timezone.utc)
+ return dt.astimezone(_APP_TZ).strftime("%Y-%m-%d %H:%M:%S")
+ except ValueError:
+ continue
+ return None
+
+
+def migrate_options_utc_wall_clock_to_app(conn: sqlite3.Connection) -> dict[str, int]:
+ """一次性:历史 UTC 墙钟(SQLite CURRENT_TIMESTAMP / 旧 monitor)改为业务时区.
+
+ 幂等:同步状态 options_ts_utc_wall_to_app_done=1 后跳过.
+ """
+ from lib.options.options_review_db import init_options_review_tables
+ from lib.options.options_review_lib import get_sync_state, set_sync_state
+
+ init_options_review_tables(conn)
+ if get_sync_state(conn, _MIGRATE_FLAG) == "1":
+ return {"skipped": 1, "trades": 0, "review": 0, "hidden": 0}
+
+ def _shift_table(table: str, cols: tuple[str, ...]) -> int:
+ try:
+ rows = conn.execute(
+ f"SELECT rowid AS _rid, {', '.join(cols)} FROM {table}"
+ ).fetchall()
+ except sqlite3.Error:
+ return 0
+ n = 0
+ for row in rows:
+ sets: list[str] = []
+ vals: list[Any] = []
+ for col in cols:
+ old = row[col]
+ new = utc_wall_clock_to_app_str(old)
+ if new and str(old or "").strip() != new:
+ sets.append(f"{col}=?")
+ vals.append(new)
+ if not sets:
+ continue
+ vals.append(int(row["_rid"]))
+ conn.execute(
+ f"UPDATE {table} SET {', '.join(sets)} WHERE rowid=?",
+ vals,
+ )
+ n += 1
+ return n
+
+ trades = 0
+ try:
+ from lib.options.options_db import init_options_tables
+
+ init_options_tables(conn)
+ trades = _shift_table("options_trades", ("created_at", "closed_at"))
+ except Exception:
+ trades = 0
+
+ review = _shift_table("options_review_trades", ("opened_at", "closed_at"))
+ hidden = _shift_table("options_review_hidden", ("closed_at",))
+
+ set_sync_state(conn, _MIGRATE_FLAG, "1")
+ return {"skipped": 0, "trades": trades, "review": review, "hidden": hidden}
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()
diff --git a/tests/test_options_time_lib.py b/tests/test_options_time_lib.py
new file mode 100644
index 0000000..6b26944
--- /dev/null
+++ b/tests/test_options_time_lib.py
@@ -0,0 +1,92 @@
+"""期权业务时区与 UTC 墙钟迁移单测."""
+from __future__ import annotations
+
+import sqlite3
+import unittest
+from datetime import datetime, timezone
+from zoneinfo import ZoneInfo
+
+from lib.options.options_db import init_options_tables
+from lib.options.options_review_db import init_options_review_tables
+from lib.options.options_review_lib import get_sync_state, upsert_option_history_row
+from lib.options.options_time_lib import (
+ migrate_options_utc_wall_clock_to_app,
+ ms_to_options_app_str,
+ options_wall_clock_to_ms,
+ utc_wall_clock_to_app_str,
+)
+
+
+class OptionsTimeLibTests(unittest.TestCase):
+ def test_ms_to_shanghai_wall_clock(self):
+ # 2026-09-13 21:41:57 UTC → 2026-09-14 05:41:57 Asia/Shanghai
+ ms = int(
+ datetime(2026, 9, 13, 21, 41, 57, tzinfo=timezone.utc).timestamp() * 1000
+ )
+ self.assertEqual(ms_to_options_app_str(ms), "2026-09-14 05:41:57")
+
+ def test_utc_wall_to_app(self):
+ self.assertEqual(
+ utc_wall_clock_to_app_str("2026-09-13 21:41:57"),
+ "2026-09-14 05:41:57",
+ )
+
+ def test_wall_clock_to_ms_roundtrip(self):
+ raw = "2026-09-14 05:41:57"
+ ms = options_wall_clock_to_ms(raw)
+ self.assertIsNotNone(ms)
+ dt = datetime.fromtimestamp(ms / 1000.0, tz=ZoneInfo("Asia/Shanghai"))
+ self.assertEqual(dt.strftime("%Y-%m-%d %H:%M:%S"), raw)
+
+ def test_migrate_once(self):
+ conn = sqlite3.connect(":memory:")
+ conn.row_factory = sqlite3.Row
+ init_options_tables(conn)
+ init_options_review_tables(conn)
+ conn.execute(
+ """
+ INSERT INTO options_trades(
+ inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status,
+ created_at, closed_at
+ ) VALUES ('ETH-USD-260917-2525-P', 'ETH', 'P', 1, 0.01, 1, 'closed',
+ '2026-09-13 21:41:57', '2026-09-14 02:31:00')
+ """
+ )
+ upsert_option_history_row(
+ conn,
+ {
+ "history_key": "local_opt:1",
+ "inst_id": "ETH-USD-260917-2525-P",
+ "underlying": "ETH",
+ "opt_type": "P",
+ "realized_pnl": 0.5,
+ "created_at": "2026-09-13 21:41:57",
+ "closed_at": "2026-09-14 02:31:00",
+ },
+ )
+ conn.commit()
+
+ first = migrate_options_utc_wall_clock_to_app(conn)
+ self.assertEqual(first["skipped"], 0)
+ self.assertGreaterEqual(first["trades"], 1)
+ self.assertGreaterEqual(first["review"], 1)
+
+ row = conn.execute(
+ "SELECT created_at, closed_at FROM options_trades WHERE id=1"
+ ).fetchone()
+ self.assertEqual(row["created_at"], "2026-09-14 05:41:57")
+ self.assertEqual(row["closed_at"], "2026-09-14 10:31:00")
+
+ rev = conn.execute(
+ "SELECT opened_at, closed_at FROM options_review_trades WHERE history_key='local_opt:1'"
+ ).fetchone()
+ self.assertEqual(rev["opened_at"], "2026-09-14 05:41:57")
+ self.assertEqual(rev["closed_at"], "2026-09-14 10:31:00")
+ self.assertEqual(get_sync_state(conn, "options_ts_utc_wall_to_app_done"), "1")
+
+ second = migrate_options_utc_wall_clock_to_app(conn)
+ self.assertEqual(second["skipped"], 1)
+
+
+if __name__ == "__main__":
+ unittest.main()