移植期权复盘自动K线、时区修复与保存后轮询展示,与今日 crypto_monitor 改动对齐。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
? '<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" })
|
||||
.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 =
|
||||
'<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();
|
||||
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 (
|
||||
'<div class="or-detail-img-cell">' +
|
||||
'<span class="or-detail-img-label">' +
|
||||
@@ -657,7 +762,7 @@
|
||||
);
|
||||
}
|
||||
var src = optionsJournalImgSrc(file);
|
||||
var label = escapeHtml(tf === "_" ? "截图" : tf);
|
||||
var label = escapeHtml(chartTfLabel(tf));
|
||||
return (
|
||||
'<div class="or-detail-img-cell">' +
|
||||
'<span class="or-detail-img-label">' +
|
||||
@@ -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 = "保存失败";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = ?
|
||||
""",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 '到期结算'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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]:
|
||||
|
||||
@@ -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/<path:filename>")
|
||||
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/<int:trade_id>")
|
||||
@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/<int:trade_id>", 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/<int:trade_id>", 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/<path:filename>")
|
||||
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/<int:trade_id>")
|
||||
@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/<int:trade_id>", 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/<int:trade_id>", 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()
|
||||
|
||||
@@ -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>
|
||||
<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">
|
||||
<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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user