移植期权复盘自动K线、时区修复与保存后轮询展示,与今日 crypto_monitor 改动对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-09-15 11:51:41 +08:00
parent 45c7e73db5
commit d6bb99b5bb
13 changed files with 1165 additions and 368 deletions
+139 -17
View File
@@ -582,31 +582,103 @@
} }
} }
var detailChartPollTimer = null;
var detailChartPollTradeId = 0;
function hideDetail() { function hideDetail() {
hideLightbox(); hideLightbox();
stopDetailChartPoll();
var backdrop = $("or-detail-backdrop"); var backdrop = $("or-detail-backdrop");
if (backdrop) backdrop.hidden = true; 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"); var backdrop = $("or-detail-backdrop");
if (!backdrop) return; if (!backdrop) return;
stopDetailChartPoll();
backdrop.hidden = false; backdrop.hidden = false;
($("or-detail-title") || {}).textContent = "加载中…"; ($("or-detail-title") || {}).textContent = "加载中…";
($("or-detail-meta") || {}).innerHTML = ""; ($("or-detail-meta") || {}).innerHTML = "";
($("or-detail-text") || {}).innerHTML = ""; ($("or-detail-text") || {}).innerHTML = "";
($("or-detail-images") || {}).innerHTML = ""; ($("or-detail-images") || {}).innerHTML = waitAuto
? '<div class="or-detail-img-cell"><span class="or-detail-img-label">自动K线</span><div class="or-detail-img-miss">生成中…</div></div>'
: "";
fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" }) function applyTrade(trade, pendingAuto) {
.then(function (r) { renderDetail(trade, { pendingAuto: !!pendingAuto });
}
function fetchOnce() {
return fetch("/api/options/review/trades/" + tradeId, {
credentials: "same-origin",
}).then(function (r) {
return r.json(); return r.json();
}) });
}
fetchOnce()
.then(function (data) { .then(function (data) {
if (!data.ok || !data.trade) { if (!data.ok || !data.trade) {
($("or-detail-title") || {}).textContent = "加载失败"; ($("or-detail-title") || {}).textContent = "加载失败";
return; 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 () { .catch(function () {
($("or-detail-title") || {}).textContent = "加载失败"; ($("or-detail-title") || {}).textContent = "加载失败";
@@ -626,9 +698,29 @@
return base + encodeURIComponent(name); 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"); var imagesHost = $("or-detail-images");
if (!imagesHost) return; if (!imagesHost) return;
opts = opts || {};
var pendingAuto = !!opts.pendingAuto;
var byTf = {}; var byTf = {};
(images || []).forEach(function (img) { (images || []).forEach(function (img) {
var tf = String((img && img.tf) || "").trim(); var tf = String((img && img.tf) || "").trim();
@@ -637,16 +729,29 @@
var key = tf || "_"; var key = tf || "_";
byTf[key] = file; byTf[key] = file;
}); });
var order = ["5m", "15m", "1h", "4h"]; // 互斥:有自动K线只显示自动;否则只显示手动四周期
var hasAuto = !!byTf.auto;
if (pendingAuto && !hasAuto) {
imagesHost.innerHTML =
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">自动K线</span>' +
'<div class="or-detail-img-miss">生成中…</div>' +
"</div>";
return;
}
var order = hasAuto ? ["auto"] : ["5m", "15m", "1h", "4h"];
var keys = order.slice(); var keys = order.slice();
Object.keys(byTf).forEach(function (k) { if (!hasAuto) {
if (keys.indexOf(k) < 0) keys.push(k); Object.keys(byTf).forEach(function (k) {
}); if (k === "auto") return;
if (keys.indexOf(k) < 0) keys.push(k);
});
}
var cells = keys var cells = keys
.map(function (tf) { .map(function (tf) {
var file = byTf[tf]; var file = byTf[tf];
if (!file) { if (!file) {
if (order.indexOf(tf) < 0) return ""; if (hasAuto || order.indexOf(tf) < 0) return "";
return ( return (
'<div class="or-detail-img-cell">' + '<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' + '<span class="or-detail-img-label">' +
@@ -657,7 +762,7 @@
); );
} }
var src = optionsJournalImgSrc(file); var src = optionsJournalImgSrc(file);
var label = escapeHtml(tf === "_" ? "截图" : tf); var label = escapeHtml(chartTfLabel(tf));
return ( return (
'<div class="or-detail-img-cell">' + '<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' + '<span class="or-detail-img-label">' +
@@ -698,7 +803,7 @@
}); });
} }
function renderDetail(t) { function renderDetail(t, opts) {
var e = t.entry || {}; var e = t.entry || {};
reviewedCache[t.id] = t; reviewedCache[t.id] = t;
($("or-detail-title") || {}).textContent = ($("or-detail-title") || {}).textContent =
@@ -782,7 +887,7 @@
var imagesHost = $("or-detail-images"); var imagesHost = $("or-detail-images");
if (imagesHost) { if (imagesHost) {
renderDetailImages(e.images || []); renderDetailImages(e.images || [], opts || {});
} }
} }
@@ -992,6 +1097,8 @@
($("or-f-note") || {}).value = ""; ($("or-f-note") || {}).value = "";
setMoodTags(""); setMoodTags("");
resetUploadSlots(); resetUploadSlots();
resetChartOptions();
setChartOptionsVisible(false);
var summary = $("or-journal-summary"); var summary = $("or-journal-summary");
if (summary) { if (summary) {
summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h)."; summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h).";
@@ -1053,6 +1160,8 @@
setSelectValue($("or-f-entry"), e.entry_logic || ""); setSelectValue($("or-f-entry"), e.entry_logic || "");
($("or-f-note") || {}).value = e.note || ""; ($("or-f-note") || {}).value = e.note || "";
setMoodTags(e.mistake_tags); setMoodTags(e.mistake_tags);
resetChartOptions();
setChartOptionsVisible(t.source_type === "option_spot");
var summary = $("or-journal-summary"); var summary = $("or-journal-summary");
if (summary) { if (summary) {
@@ -1068,6 +1177,8 @@
} }
(e.images || []).forEach(function (img) { (e.images || []).forEach(function (img) {
var tf = String((img && img.tf) || "").trim();
if (tf === "auto") return;
var hidden = document.querySelector( var hidden = document.querySelector(
'#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]' '#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]'
); );
@@ -1167,6 +1278,15 @@
note: ($("or-f-note") || {}).value || "", note: ($("or-f-note") || {}).value || "",
images: collectImages(), 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 = "保存中…"; ($("or-save-status") || {}).textContent = "保存中…";
fetch("/api/options/review/entry", { fetch("/api/options/review/entry", {
method: "POST", method: "POST",
@@ -1182,10 +1302,12 @@
($("or-save-status") || {}).textContent = data.msg || "保存失败"; ($("or-save-status") || {}).textContent = data.msg || "保存失败";
return; return;
} }
($("or-save-status") || {}).textContent = "已保存"; ($("or-save-status") || {}).textContent = data.chart_queued
? "已保存,K线图后台生成中"
: "已保存";
hideJournalForm(); hideJournalForm();
reloadAll(); reloadAll();
openDetail(tradeId); openDetail(tradeId, { waitAutoChart: !!data.chart_queued });
}) })
.catch(function () { .catch(function () {
($("or-save-status") || {}).textContent = "保存失败"; ($("or-save-status") || {}).textContent = "保存失败";
+3 -9
View File
@@ -213,16 +213,10 @@ def option_history_row_key(
def _ms_to_iso(ms: Any) -> str | None: def _ms_to_iso(ms: Any) -> str | None:
val = _safe_float(ms) """OKX ms → 业务时区墙钟(默认 Asia/Shanghai);勿用系统本地时区."""
if val is None or val <= 0: from lib.options.options_time_lib import ms_to_options_app_str
return None
try:
from datetime import datetime, timezone
dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone() return ms_to_options_app_str(ms)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError, OSError):
return None
def option_instrument_meta_cached( def option_instrument_meta_cached(
+4 -1
View File
@@ -363,12 +363,15 @@ def close_option_by_bid1(
ELSE signal_note ELSE signal_note
END""" END"""
params.append(signal_note) 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"])) params.append(int(row["id"]))
conn.execute( conn.execute(
f""" f"""
UPDATE options_trades UPDATE options_trades
SET status = 'closed', close_quote = ?, premium_received = ?, 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} {note_sql}
WHERE id = ? WHERE id = ?
""", """,
+5 -2
View File
@@ -400,6 +400,8 @@ def open_coin_option_buy_full(
def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: 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 = 1 if kwargs.get("profit_exit_enabled") else 0
pe_mult = float(kwargs.get("profit_exit_mult") or 1.0) pe_mult = float(kwargs.get("profit_exit_mult") or 1.0)
pe_state = "active" if pe else "idle" 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, inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id, open_quote, premium_paid, status, signal_note, exchange_ord_id,
margin_mode, premium_ccy, bridge_id, budget_usdt, margin_mode, premium_ccy, bridge_id, budget_usdt,
profit_exit_enabled, profit_exit_mult, profit_exit_state profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
kwargs["inst_id"], kwargs["inst_id"],
@@ -430,6 +432,7 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
pe, pe,
pe_mult, pe_mult,
pe_state, pe_state,
options_app_now_str(),
), ),
) )
conn.commit() conn.commit()
+8 -24
View File
@@ -1,16 +1,12 @@
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步.""" """期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
from __future__ import annotations from __future__ import annotations
import os
import sqlite3 import sqlite3
import time import time
from datetime import datetime, timezone
from typing import Any, Callable 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 from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
from lib.options.options_time_lib import ms_to_options_app_str, options_app_now_str, options_wall_clock_to_ms
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
def _safe_float(v: Any) -> float | None: 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: def _created_at_ms(created_at: Any) -> int | None:
"""墙钟 created_at → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC.""" """墙钟 created_at → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
if not created_at: return options_wall_clock_to_ms(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
def _group_key_for_closed_trade(row: Any) -> str: 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) prem_recv = round(paid + float(realized_pnl), 4)
close_ms = close_info.get("close_ms") close_ms = close_info.get("close_ms")
if close_ms: if close_ms:
closed_at = datetime.fromtimestamp(int(close_ms) / 1000, tz=timezone.utc).strftime( closed_at = ms_to_options_app_str(close_ms)
"%Y-%m-%d %H:%M:%S"
)
elif exp_ms is not None and now_ms >= int(exp_ms): elif exp_ms is not None and now_ms >= int(exp_ms):
close_reason = "expired" close_reason = "expired"
close_quote = 0.0 close_quote = 0.0
prem_recv = 0.0 prem_recv = 0.0
realized_pnl = round(-paid, 4) realized_pnl = round(-paid, 4)
if exp_ms: if exp_ms:
closed_at = datetime.fromtimestamp(int(exp_ms) / 1000, tz=timezone.utc).strftime( closed_at = ms_to_options_app_str(exp_ms)
"%Y-%m-%d %H:%M:%S"
)
else: else:
continue continue
if not closed_at:
closed_at = options_app_now_str()
conn.execute( conn.execute(
""" """
UPDATE options_trades UPDATE options_trades
@@ -350,7 +334,7 @@ def sync_open_options_trades(
premium_received = ?, premium_received = ?,
realized_pnl = ?, realized_pnl = ?,
close_ord_id = COALESCE(?, close_ord_id), close_ord_id = COALESCE(?, close_ord_id),
closed_at = COALESCE(?, closed_at, CURRENT_TIMESTAMP), closed_at = COALESCE(?, closed_at),
signal_note = CASE signal_note = CASE
WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '') WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '')
THEN '到期结算' THEN '到期结算'
+9 -4
View File
@@ -1218,13 +1218,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
opt_type = meta.get("optType") opt_type = meta.get("optType")
open_underlying = u open_underlying = u
open_opt_type = opt_type open_opt_type = opt_type
from lib.options.options_time_lib import options_app_now_str
cur = conn.execute( cur = conn.execute(
""" """
INSERT INTO options_trades INSERT INTO options_trades
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, (inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id, open_quote, premium_paid, status, signal_note, exchange_ord_id,
profit_exit_enabled, profit_exit_mult, profit_exit_state) profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?)
""", """,
( (
inst_id, inst_id,
@@ -1241,6 +1243,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
1 if profit_exit_enabled else 0, 1 if profit_exit_enabled else 0,
profit_exit_mult if profit_exit_enabled else 1.0, profit_exit_mult if profit_exit_enabled else 1.0,
"active" if profit_exit_enabled else "idle", "active" if profit_exit_enabled else "idle",
options_app_now_str(),
), ),
) )
trade_id = int(cur.lastrowid) trade_id = int(cur.lastrowid)
@@ -1341,6 +1344,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
try: try:
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
from lib.options.options_time_lib import options_app_now_str
init_options_tables(conn) init_options_tables(conn)
conn.execute( 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 '委托撤销' WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销'
ELSE signal_note ELSE signal_note
END, END,
closed_at = CURRENT_TIMESTAMP closed_at = ?
WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open' WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open'
""", """,
(inst_id, ord_id), (options_app_now_str(), inst_id, ord_id),
) )
conn.commit() conn.commit()
finally: finally:
@@ -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()
+7 -1
View File
@@ -643,6 +643,9 @@ def ensure_local_review_synced(
backfill_exchange_pnl: bool = True, backfill_exchange_pnl: bool = True,
) -> dict[str, Any]: ) -> 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: if backfill_exchange_pnl and ex is not None:
try: try:
from lib.exchange.okx_options_lib import fetch_all_option_positions_history 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) backfill_hedge_option_legs_realized_pnl(conn, hist)
except Exception: except Exception:
pass 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]: def _row_to_dict(row: Any) -> dict[str, Any]:
+142
View File
@@ -93,6 +93,141 @@ def _require_ex(cfg: dict[str, Any]):
return ex, "" 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: def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None:
lr = cfg["login_required"] lr = cfg["login_required"]
@@ -216,9 +351,16 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
return jsonify({"ok": False, "msg": "trade_id 无效"}), 400 return jsonify({"ok": False, "msg": "trade_id 无效"}), 400
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
data = _prepare_review_entry_images(conn, trade_id, data)
out = save_review_entry(conn, trade_id, data) out = save_review_entry(conn, trade_id, data)
if out.get("ok"): if out.get("ok"):
conn.commit() 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) return jsonify(out), (200 if out.get("ok") else 400)
finally: finally:
conn.close() conn.close()
+121
View File
@@ -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}
@@ -327,6 +327,37 @@
</div> </div>
<p class="sub journal-upload-hint">可只传部分周期;选文件后即时上传</p> <p class="sub journal-upload-hint">可只传部分周期;选文件后即时上传</p>
<div class="form-row or-chart-options journal-chart-options hidden" id="or-chart-options" style="flex-wrap:wrap;align-items:center">
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem">
<input type="checkbox" id="or-exchange-chart" value="true">
保存后后台生成 K 线图并作为截图(不阻塞保存)
</label>
<label style="font-size:.82rem;color:#9aa">周期1</label>
<select id="or-chart-tf1" style="min-width:72px">
{% for tf in journal_chart_tf_choices %}
<option value="{{ tf }}" {% if tf == journal_chart_default_tf1 %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<label style="font-size:.82rem;color:#9aa">周期2</label>
<select id="or-chart-tf2" style="min-width:72px">
{% for tf in journal_chart_tf_choices %}
<option value="{{ tf }}" {% if tf == journal_chart_default_tf2 %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<label style="font-size:.82rem;color:#9aa">K线数</label>
<select id="or-chart-limit" style="min-width:72px">
{% for n in [100, 150, 200, 250, 300, 400, 500] %}
<option value="{{ n }}" {% if n == journal_chart_default_limit %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
<label style="font-size:.82rem;color:#9aa">K线截止</label>
<select id="or-chart-anchor" style="min-width:96px" title="K线窗口右端对齐的时间">
<option value="close" {% if journal_chart_default_anchor == 'close' %}selected{% endif %}>平仓时间</option>
<option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option>
</select>
</div>
<div class="sub or-chart-hint hidden" id="or-chart-hint" style="font-size:.72rem;margin-top:2px;margin-bottom:0">纯期权:按标的永续画双周期上下图;只按时间标记进场/出场,不标止损;有手动截图时不自动生成</div>
<div class="or-mood-grid mood-grid"> <div class="or-mood-grid mood-grid">
<label><input type="checkbox" class="or-mood" value="怕踏空">怕踏空</label> <label><input type="checkbox" class="or-mood" value="怕踏空">怕踏空</label>
<label><input type="checkbox" class="or-mood" value="报复开仓">报复开仓</label> <label><input type="checkbox" class="or-mood" value="报复开仓">报复开仓</label>
@@ -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()
+92
View File
@@ -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()