diff --git a/lib/common/static/options_review.js b/lib/common/static/options_review.js index bc80c7b..c7cf7f3 100644 --- a/lib/common/static/options_review.js +++ b/lib/common/static/options_review.js @@ -582,31 +582,103 @@ } } + var detailChartPollTimer = null; + var detailChartPollTradeId = 0; + function hideDetail() { hideLightbox(); + stopDetailChartPoll(); var backdrop = $("or-detail-backdrop"); if (backdrop) backdrop.hidden = true; } - function openDetail(tradeId) { + function stopDetailChartPoll() { + if (detailChartPollTimer) { + clearTimeout(detailChartPollTimer); + detailChartPollTimer = null; + } + detailChartPollTradeId = 0; + } + + function tradeHasAutoChart(t) { + var images = ((t && t.entry) || {}).images || []; + for (var i = 0; i < images.length; i++) { + var tf = String((images[i] && images[i].tf) || "").trim(); + var file = String((images[i] && images[i].file) || "").trim(); + if (tf === "auto" && file) return true; + } + return false; + } + + function openDetail(tradeId, opts) { + opts = opts || {}; + var waitAuto = !!opts.waitAutoChart; var backdrop = $("or-detail-backdrop"); if (!backdrop) return; + stopDetailChartPoll(); backdrop.hidden = false; ($("or-detail-title") || {}).textContent = "加载中…"; ($("or-detail-meta") || {}).innerHTML = ""; ($("or-detail-text") || {}).innerHTML = ""; - ($("or-detail-images") || {}).innerHTML = ""; + ($("or-detail-images") || {}).innerHTML = waitAuto + ? '
自动K线
生成中…
' + : ""; - fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" }) - .then(function (r) { + function applyTrade(trade, pendingAuto) { + renderDetail(trade, { pendingAuto: !!pendingAuto }); + } + + function fetchOnce() { + return fetch("/api/options/review/trades/" + tradeId, { + credentials: "same-origin", + }).then(function (r) { return r.json(); - }) + }); + } + + fetchOnce() .then(function (data) { if (!data.ok || !data.trade) { ($("or-detail-title") || {}).textContent = "加载失败"; return; } - renderDetail(data.trade); + if (!waitAuto || tradeHasAutoChart(data.trade)) { + applyTrade(data.trade, false); + return; + } + applyTrade(data.trade, true); + detailChartPollTradeId = tradeId; + var tries = 0; + var maxTries = 20; + function poll() { + if (detailChartPollTradeId !== tradeId) return; + tries += 1; + fetchOnce() + .then(function (again) { + if (detailChartPollTradeId !== tradeId) return; + if (again.ok && again.trade && tradeHasAutoChart(again.trade)) { + stopDetailChartPoll(); + applyTrade(again.trade, false); + loadReviewed({ sync: false, soft: true }); + return; + } + if (tries >= maxTries) { + stopDetailChartPoll(); + applyTrade((again && again.trade) || data.trade, false); + return; + } + detailChartPollTimer = setTimeout(poll, 1500); + }) + .catch(function () { + if (detailChartPollTradeId !== tradeId) return; + if (tries >= maxTries) { + stopDetailChartPoll(); + return; + } + detailChartPollTimer = setTimeout(poll, 1500); + }); + } + detailChartPollTimer = setTimeout(poll, 1200); }) .catch(function () { ($("or-detail-title") || {}).textContent = "加载失败"; @@ -626,9 +698,29 @@ return base + encodeURIComponent(name); } - function renderDetailImages(images) { + 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, opts) { var imagesHost = $("or-detail-images"); if (!imagesHost) return; + opts = opts || {}; + var pendingAuto = !!opts.pendingAuto; var byTf = {}; (images || []).forEach(function (img) { var tf = String((img && img.tf) || "").trim(); @@ -637,16 +729,29 @@ var key = tf || "_"; byTf[key] = file; }); - var order = ["5m", "15m", "1h", "4h"]; + // 互斥:有自动K线只显示自动;否则只显示手动四周期 + var hasAuto = !!byTf.auto; + if (pendingAuto && !hasAuto) { + imagesHost.innerHTML = + '
' + + '自动K线' + + '
生成中…
' + + "
"; + return; + } + var order = hasAuto ? ["auto"] : ["5m", "15m", "1h", "4h"]; var keys = order.slice(); - Object.keys(byTf).forEach(function (k) { - if (keys.indexOf(k) < 0) keys.push(k); - }); + if (!hasAuto) { + Object.keys(byTf).forEach(function (k) { + if (k === "auto") return; + if (keys.indexOf(k) < 0) keys.push(k); + }); + } var cells = keys .map(function (tf) { var file = byTf[tf]; if (!file) { - if (order.indexOf(tf) < 0) return ""; + if (hasAuto || order.indexOf(tf) < 0) return ""; return ( '
' + '' + @@ -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()