10 Commits

Author SHA1 Message Date
dekun 8a62cfbb48 修复比特币期权浮盈把币数量当成 U:看板/中控改为按指数折 USDT。
币本位 BTC/ETH 净盈亏须 × 指数后再标 U;并兜底从合约推断 premium_ccy,避免缺失时漏折算。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 22:01:41 +08:00
dekun 41c85c36a9 澄清翻倍出场文案:按当前倍数显示需回收公式,避免误读成按1倍算。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 21:59:25 +08:00
dekun 06f9b23f44 修复翻倍出场达标却不自动平:对冲异常不再整段跳过,并按买一价×全仓判定。
原先对冲表异常会直接 return 0 导致翻倍出场永不执行;可回收改为买一价×全部张数,买一深度不足时仍会触发分批平仓。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 21:54:00 +08:00
dekun a03c208a5f 修复模拟盘期权历史分批平仓盈亏:用卖平成交重算已平记录。
加载历史前按 sim_option_orders(或钱包流水)合计各批收回,纠正只记最后一笔的旧数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 21:25:31 +08:00
dekun a7015ec5c8 修复期权分批平仓只记最后一笔收回导致盈亏漏算。
每次部分成交累加权利金与张数,全平后再合计 realized_pnl,并优先匹配交易所全平历史。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 21:13:05 +08:00
dekun f5104fe68b 修复翻倍/目标位平仓把备注写进平仓时间:纠正 SQL 参数顺序并自动对调历史坏数据。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 05:48:42 +08:00
dekun c5cfbb34e4 README 增加新机纯实盘部署文档入口。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:28:31 +08:00
dekun ea58a4778e 新增新机纯实盘部署文档,并与部署/使用说明互相链接。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:28:16 +08:00
dekun d16821c7b7 模拟盘无 API Key 时复盘K线改为优先走 OKX 公开行情接口。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:16:06 +08:00
dekun d6bb99b5bb 移植期权复盘自动K线、时区修复与保存后轮询展示,与今日 crypto_monitor 改动对齐。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 11:51:41 +08:00
30 changed files with 2387 additions and 405 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.
安装到 `/opt/crypto_okx`,PM2 进程名 `crypto_okx`,默认端口 **5004**. 安装到 `/opt/crypto_okx`,PM2 进程名 `crypto_okx`,默认端口 **5004**.
详见 **[部署文档.md](./部署文档.md)**、**[deploy/README.md](./deploy/README.md)**. 详见 **[部署文档.md](./部署文档.md)**、**[新机纯实盘部署.md](./新机纯实盘部署.md)**、**[deploy/README.md](./deploy/README.md)**.
## 环境要求 ## 环境要求
+119 -15
View File
@@ -924,19 +924,118 @@ def _ohlcv_dict_rows_to_lists(rows, lim):
return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick] return [[r["ts"], r["o"], r["h"], r["l"], r["c"], r.get("v", 0)] for r in pick]
def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms): def _okx_swap_inst_id(exchange_symbol):
lim = max(2, int(limit or ORDER_CHART_LIMIT)) """ETH/USDT:USDT → ETH-USDT-SWAP(公开 candles 用)."""
sym = str(exchange_symbol or "").strip().upper()
if not sym:
return ""
if "/" in sym:
base, rest = sym.split("/", 1)
quote = rest.split(":", 1)[0].strip() or "USDT"
return f"{base.strip()}-{quote}-SWAP"
if sym.endswith("-SWAP"):
return sym
if "-" in sym:
return sym
if sym.endswith("USDT") and len(sym) > 4:
return f"{sym[:-4]}-USDT-SWAP"
return sym
def _okx_public_bar(timeframe):
"""ccxt 周期 → OKX public bar(1H/4H 大写 H)."""
s = str(timeframe or "").strip()
if not s:
return "15m"
low = s.lower()
if low.endswith("m") and low[:-1].isdigit():
return low
if low.endswith("h") and low[:-1].isdigit():
return f"{low[:-1]}H"
if low.endswith("d") and low[:-1].isdigit():
return f"{low[:-1]}D"
return s
def _fetch_ohlcv_public(exchange_symbol, timeframe, limit=100, since_ms=None, end_ts_ms=None):
"""无 API Key 也可拉永续 K 线:OKX public_get_market_candles."""
inst = _okx_swap_inst_id(exchange_symbol)
bar = _okx_public_bar(timeframe)
lim = max(2, min(int(limit or 100), 300))
if not inst or not hasattr(exchange, "public_get_market_candles"):
return []
params = {"instId": inst, "bar": bar, "limit": str(lim)}
# after=ts → 早于 ts 的更旧数据,适合「截止到平仓」
if end_ts_ms:
try:
params["after"] = str(int(end_ts_ms))
except (TypeError, ValueError):
pass
try: try:
if not end_ts_ms: raw = (exchange.public_get_market_candles(params) or {}).get("data") or []
ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=timeframe, limit=lim)
else:
period = _timeframe_period_ms(timeframe)
since = int(end_ts_ms) - period * (lim + 10)
ohlcv = exchange.fetch_ohlcv(
exchange_symbol, timeframe=timeframe, since=max(0, since), limit=lim + 20
)
except Exception: except Exception:
return [] return []
out = []
for row in reversed(list(raw or [])):
if not row or len(row) < 5:
continue
try:
ts = int(row[0])
o = float(row[1])
h = float(row[2])
l = float(row[3])
c = float(row[4])
v = float(row[5]) if len(row) > 5 else 0.0
except (TypeError, ValueError, IndexError):
continue
if since_ms is not None:
try:
if ts < int(since_ms):
continue
except (TypeError, ValueError):
pass
if end_ts_ms is not None:
try:
if ts > int(end_ts_ms):
continue
except (TypeError, ValueError):
pass
out.append([ts, o, h, l, c, v])
if len(out) > lim:
return out[-lim:]
return out
def _fetch_ohlcv_safe(exchange_symbol, timeframe, limit=100, since_ms=None, end_ts_ms=None):
"""公开接口优先(模拟盘无密钥);失败再试 ccxt.fetch_ohlcv."""
pub = _fetch_ohlcv_public(
exchange_symbol,
timeframe,
limit=limit,
since_ms=since_ms,
end_ts_ms=end_ts_ms,
)
if pub:
return pub
try:
kwargs = {"timeframe": timeframe, "limit": int(limit or 100)}
if since_ms:
kwargs["since"] = max(0, int(since_ms))
return list(exchange.fetch_ohlcv(exchange_symbol, **kwargs) or [])
except Exception:
return []
def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms):
lim = max(2, int(limit or ORDER_CHART_LIMIT))
ohlcv = _fetch_ohlcv_safe(
exchange_symbol,
timeframe,
limit=lim + 20,
end_ts_ms=end_ts_ms,
)
if not ohlcv and end_ts_ms:
ohlcv = _fetch_ohlcv_safe(exchange_symbol, timeframe, limit=lim)
rows = _ohlcv_to_rows(ohlcv) rows = _ohlcv_to_rows(ohlcv)
if not rows: if not rows:
return [] return []
@@ -977,7 +1076,11 @@ def generate_multi_timeframe_chart_png(
ordered.append(tf) ordered.append(tf)
timeframes = ordered[:4] if ordered else preferred_layout timeframes = ordered[:4] if ordered else preferred_layout
ensure_markets_loaded() try:
ensure_markets_loaded()
except Exception:
# 模拟盘无 API Key 时 load_markets 可能失败;公开 candles 不依赖 markets
pass
panels = [] panels = []
cell_w, cell_h = 980, 520 cell_w, cell_h = 980, 520
end_ts_ms = None end_ts_ms = None
@@ -1001,17 +1104,18 @@ def generate_multi_timeframe_chart_png(
now_ms=marker_payload.get("now_ts_ms"), now_ms=marker_payload.get("now_ts_ms"),
) )
if win: if win:
ohlcv = exchange.fetch_ohlcv( ohlcv = _fetch_ohlcv_safe(
exchange_symbol, exchange_symbol,
timeframe=tf, tf,
since=max(0, int(win["since_ms"])),
limit=int(win["fetch_limit"]), limit=int(win["fetch_limit"]),
since_ms=max(0, int(win["since_ms"])),
end_ts_ms=end_ts_ms,
) )
rows = trim_rows_for_trade_review(_ohlcv_to_rows(ohlcv), win) rows = trim_rows_for_trade_review(_ohlcv_to_rows(ohlcv), win)
if not rows: if not rows:
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms) ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, end_ts_ms)
if not ohlcv and end_ts_ms: if not ohlcv and end_ts_ms:
ohlcv = exchange.fetch_ohlcv(exchange_symbol, timeframe=tf, limit=limit) ohlcv = _fetch_ohlcv_safe(exchange_symbol, tf, limit=limit)
rows = _ohlcv_to_rows(ohlcv)[-limit:] rows = _ohlcv_to_rows(ohlcv)[-limit:]
except Exception: except Exception:
rows = [] rows = []
+17 -1
View File
@@ -1674,6 +1674,21 @@
return fmt(n, 2) + "倍"; return fmt(n, 2) + "倍";
} }
function profitExitHint(mult, req, premCcy) {
const n = Number(mult);
const m = Number.isFinite(n) && n > 0 ? n : 1;
const needFactor = 1 + m;
const factorTxt = Math.abs(needFactor - Math.round(needFactor)) < 1e-9
? String(Math.round(needFactor))
: fmt(needFactor, 2);
let hint = formatProfitExitMultLabel(m) + "=盈利达权利金×" + fmt(m, 2)
+ " → 需回收≥权利金×" + factorTxt;
if (req != null) {
hint += " · 即≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy;
}
return hint;
}
function renderProfitExitRow(p) { function renderProfitExitRow(p) {
const inst = p.inst_id || ""; const inst = p.inst_id || "";
if (p.hedge_plan_target) { if (p.hedge_plan_target) {
@@ -1694,6 +1709,7 @@
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle")); const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
const req = p.profit_exit_required_recycle; const req = p.profit_exit_required_recycle;
const premCcy = posPremiumCcy(p); const premCcy = posPremiumCcy(p);
const hintMult = Number.isFinite(multNum) && multNum > 0 ? multNum : serverMult;
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启"; let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel; if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
return ( return (
@@ -1710,7 +1726,7 @@
'<span class="opt-target-armed">' + statusTxt + "</span>" + '<span class="opt-target-armed">' + statusTxt + "</span>" +
'<span class="muted opt-target-row-hint">' + '<span class="muted opt-target-row-hint">' +
(enabled (enabled
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy) : "")) ? profitExitHint(hintMult, req, premCcy)
: "开启后自选倍数;达标按买一限价平;可随时关闭") + : "开启后自选倍数;达标按买一限价平;可随时关闭") +
"</span>" + "</span>" +
"</div>" "</div>"
+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 = "保存失败";
+7 -10
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(
@@ -1601,6 +1595,9 @@ def resolve_option_close_from_history(
u_i, row = item u_i, row = item
c_ms = _safe_float(row.get("cTime")) c_ms = _safe_float(row.get("cTime"))
parts: list[float] = [] parts: list[float] = []
# 全平/强平/ADL 优先于部分平仓(同合约多条时避免只吃到 type=1)
close_type = str(row.get("type") or "").strip()
parts.append(0.0 if close_type in ("2", "3", "6") else 1.0)
# 张数优先:同合约多笔时最稳,且不受本地/交易所时区偏差影响 # 张数优先:同合约多笔时最稳,且不受本地/交易所时区偏差影响
if want_sheets is not None: if want_sheets is not None:
hist_sheets = _safe_float(row.get("closeTotalPos")) hist_sheets = _safe_float(row.get("closeTotalPos"))
@@ -1615,7 +1612,7 @@ def resolve_option_close_from_history(
parts.append(float(abs(int(c_ms) - int(open_ms)))) parts.append(float(abs(int(c_ms) - int(open_ms))))
if close_ms is not None: if close_ms is not None:
parts.append(float(abs(u_i - int(close_ms)))) parts.append(float(abs(u_i - int(close_ms))))
if not parts: if len(parts) <= 1:
parts.append(float(-u_i)) parts.append(float(-u_i))
# 同距时偏向更晚平仓 # 同距时偏向更晚平仓
parts.append(float(-u_i)) parts.append(float(-u_i))
+3 -3
View File
@@ -162,12 +162,12 @@ def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT") label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT")
# 看板期权列:优先买一净盈亏,残档回退交易所 upl # 看板期权列:统一折成 U(币本位 BTC/ETH 权利金盈亏 × 指数)
pnl = None pnl = None
try: try:
from lib.options.options_positions_lib import display_pnl_from_option_row from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
pnl = display_pnl_from_option_row(p) pnl = display_pnl_usdt_from_option_row(p)
except Exception: except Exception:
pnl = None pnl = None
pos = _safe_float(p.get("pos")) pos = _safe_float(p.get("pos"))
+77 -10
View File
@@ -306,7 +306,8 @@ def close_option_by_bid1(
px = float(order.get("px", locked_bid_px)) px = float(order.get("px", locked_bid_px))
oid = str((order.get("data") or {}).get("ordId") or "") oid = str((order.get("data") or {}).get("ordId") or "")
prem_recv = round(total_premium(px, level_sheets * ct_mult), 4) # 本批预估收回(按挂单张数);成交后按实际减仓张数重算并入累计
prem_recv_submit = round(total_premium(px, level_sheets * ct_mult), 4)
time.sleep(0.6) time.sleep(0.6)
invalidate_option_positions_cache() invalidate_option_positions_cache()
raw2 = cfg["fetch_option_positions"](ex) raw2 = cfg["fetch_option_positions"](ex)
@@ -325,14 +326,47 @@ def close_option_by_bid1(
reduced = max(0, before_avail - after_avail) reduced = max(0, before_avail - after_avail)
remaining_pos = after_avail remaining_pos = after_avail
fully_closed = remaining_pos < 1 fully_closed = remaining_pos < 1
filled_sheets = min(reduced, level_sheets) if reduced > 0 else 0
# 全平但减仓读数异常时回退挂单张数,避免漏记本批
if fully_closed and filled_sheets < 1:
filled_sheets = level_sheets
batch_recv = (
round(total_premium(px, filled_sheets * ct_mult), 4) if filled_sheets > 0 else 0.0
)
total_recv = batch_recv
close_px = px
merged_ord = oid or None
if fully_closed: if fully_closed:
clear_close_gate(inst_id) clear_close_gate(inst_id)
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
from lib.options.options_db import init_options_tables from lib.options.options_db import (
add_open_close_accum,
get_open_close_accum,
init_options_tables,
)
init_options_tables(conn) init_options_tables(conn)
# 先并入本批,再读累计(含此前分批)
if filled_sheets > 0:
add_open_close_accum(
conn,
inst_id,
recv=batch_recv,
sheets=filled_sheets,
px=px,
ord_id=oid or None,
)
accum = get_open_close_accum(conn, inst_id)
total_recv = round(float(accum.get("recv") or 0), 4)
accum_sheets = int(accum.get("sheets") or 0)
accum_notional = float(accum.get("notional") or 0)
if accum_sheets > 0 and accum_notional > 0:
close_px = round(accum_notional / accum_sheets, 8)
merged_ord = str(accum.get("close_ord_id") or "").strip() or (oid or None)
open_rows = conn.execute( open_rows = conn.execute(
""" """
SELECT id, premium_paid FROM options_trades SELECT id, premium_paid FROM options_trades
@@ -346,16 +380,25 @@ def close_option_by_bid1(
for i, row in enumerate(open_rows): for i, row in enumerate(open_rows):
paid = float(row["premium_paid"] or 0) paid = float(row["premium_paid"] or 0)
if i == len(open_rows) - 1: if i == len(open_rows) - 1:
recv = round(prem_recv - allocated, 4) recv = round(total_recv - allocated, 4)
elif total_paid > 0: elif total_paid > 0:
recv = round(prem_recv * (paid / total_paid), 4) recv = round(total_recv * (paid / total_paid), 4)
allocated += recv allocated += recv
else: else:
recv = round(prem_recv / len(open_rows), 4) recv = round(total_recv / len(open_rows), 4)
allocated += recv allocated += recv
pnl = round(recv - paid, 4) pnl = round(recv - paid, 4)
note_sql = "" note_sql = ""
params: list[Any] = [px, recv, pnl, oid or None] from lib.options.options_time_lib import options_app_now_str
# 占位顺序必须与 SQL 一致: close_quote/recv/pnl/ord/closed_at/[signal_note]/id
params: list[Any] = [
close_px,
recv,
pnl,
merged_ord,
options_app_now_str(),
]
if signal_note and i == len(open_rows) - 1: if signal_note and i == len(open_rows) - 1:
note_sql = """, note_sql = """,
signal_note = CASE signal_note = CASE
@@ -368,7 +411,9 @@ def close_option_by_bid1(
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 = ?,
close_recv_accum = 0, close_sheets_accum = 0,
close_notional_accum = 0
{note_sql} {note_sql}
WHERE id = ? WHERE id = ?
""", """,
@@ -377,6 +422,27 @@ def close_option_by_bid1(
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
elif filled_sheets > 0:
# 部分成交:持久化本批收回,下次全平合计
conn = cfg["get_db"]()
try:
from lib.options.options_db import add_open_close_accum, init_options_tables
init_options_tables(conn)
add_open_close_accum(
conn,
inst_id,
recv=batch_recv,
sheets=filled_sheets,
px=px,
ord_id=oid or None,
)
conn.commit()
finally:
conn.close()
if require_recycle_gate:
# 自动平已挂过单:同仓续批只验流动性
mark_close_gate_passed(inst_id)
elif require_recycle_gate: elif require_recycle_gate:
# 自动平已挂过单:同仓续批只验流动性 # 自动平已挂过单:同仓续批只验流动性
mark_close_gate_passed(inst_id) mark_close_gate_passed(inst_id)
@@ -388,11 +454,12 @@ def close_option_by_bid1(
"bid": px, "bid": px,
"locked_bid_px": locked_bid_px, "locked_bid_px": locked_bid_px,
"submitted_sheets": level_sheets, "submitted_sheets": level_sheets,
"filled_or_reduced_sheets": min(reduced, level_sheets) if reduced else 0, "filled_or_reduced_sheets": filled_sheets,
"remaining_sheets": remaining_pos, "remaining_sheets": remaining_pos,
"premium_received": prem_recv, "premium_received": total_recv if fully_closed else (batch_recv or prem_recv_submit),
"batch_premium_received": batch_recv,
"stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"), "stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"),
"close_ord_id": oid or None, "close_ord_id": merged_ord if fully_closed else (oid or None),
"fully_closed": fully_closed, "fully_closed": fully_closed,
"msg": ( "msg": (
f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张" f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张"
+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()
+87
View File
@@ -105,6 +105,10 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
"ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'", "ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'",
"ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER", "ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER",
"ALTER TABLE options_trades ADD COLUMN budget_usdt REAL", "ALTER TABLE options_trades ADD COLUMN budget_usdt REAL",
# 分批平仓累计:全平前写在最早一笔 open 上,全平后并入 premium_received
"ALTER TABLE options_trades ADD COLUMN close_recv_accum REAL DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN close_sheets_accum INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN close_notional_accum REAL DEFAULT 0",
): ):
try: try:
conn.execute(ddl) conn.execute(ddl)
@@ -154,3 +158,86 @@ def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
if not row or int(row["n"] or 0) < 1: if not row or int(row["n"] or 0) < 1:
return None return None
return int(row["total"] or 0) return int(row["total"] or 0)
def _earliest_open_trade_id(conn: sqlite3.Connection, inst_id: str) -> int | None:
inst = (inst_id or "").strip()
if not inst:
return None
row = conn.execute(
"""
SELECT id FROM options_trades
WHERE inst_id = ? AND status = 'open'
ORDER BY id ASC
LIMIT 1
""",
(inst,),
).fetchone()
return int(row["id"]) if row else None
def get_open_close_accum(conn: sqlite3.Connection, inst_id: str) -> dict[str, float | int | str | None]:
"""读取分批平仓累计(挂在最早一笔 open 上)."""
tid = _earliest_open_trade_id(conn, inst_id)
if tid is None:
return {"recv": 0.0, "sheets": 0, "notional": 0.0, "close_ord_id": None}
row = conn.execute(
"""
SELECT close_recv_accum, close_sheets_accum, close_notional_accum, close_ord_id
FROM options_trades WHERE id = ?
""",
(tid,),
).fetchone()
if not row:
return {"recv": 0.0, "sheets": 0, "notional": 0.0, "close_ord_id": None}
return {
"recv": float(row["close_recv_accum"] or 0),
"sheets": int(row["close_sheets_accum"] or 0),
"notional": float(row["close_notional_accum"] or 0),
"close_ord_id": str(row["close_ord_id"] or "").strip() or None,
}
def add_open_close_accum(
conn: sqlite3.Connection,
inst_id: str,
*,
recv: float,
sheets: int,
px: float,
ord_id: str | None = None,
) -> bool:
"""分批平仓成交后累加收回权利金/张数/名义(供全平 VWAP 与总盈亏)."""
tid = _earliest_open_trade_id(conn, inst_id)
if tid is None:
return False
sheets_i = max(0, int(sheets or 0))
recv_f = float(recv or 0)
px_f = float(px or 0)
if sheets_i < 1 and abs(recv_f) < 1e-15:
return False
notional_add = px_f * sheets_i if sheets_i > 0 and px_f > 0 else 0.0
oid = str(ord_id or "").strip()
prev = conn.execute(
"SELECT close_ord_id FROM options_trades WHERE id = ?", (tid,)
).fetchone()
prev_oids = str((prev["close_ord_id"] if prev else None) or "").strip()
if oid:
parts = [p for p in prev_oids.split(",") if p] if prev_oids else []
if oid not in parts:
parts.append(oid)
merged_oid = ",".join(parts)
else:
merged_oid = prev_oids or None
conn.execute(
"""
UPDATE options_trades
SET close_recv_accum = COALESCE(close_recv_accum, 0) + ?,
close_sheets_accum = COALESCE(close_sheets_accum, 0) + ?,
close_notional_accum = COALESCE(close_notional_accum, 0) + ?,
close_ord_id = ?
WHERE id = ?
""",
(recv_f, sheets_i, notional_add, merged_oid, tid),
)
return True
+3 -3
View File
@@ -85,13 +85,13 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
conn.close() conn.close()
except Exception: except Exception:
target_monitors = [] target_monitors = []
from lib.options.options_positions_lib import display_pnl_from_option_row from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
upl_total = 0.0 upl_total = 0.0
has_upl = False has_upl = False
for p in positions: for p in positions:
# 与持仓卡展示一致:优先买一净盈亏,残档回退交易所 upl # 合计一律折 U:币本位 × 指数,避免 BTC/ETH 币数量与 USDC 混加
pnl = display_pnl_from_option_row(p) pnl = display_pnl_usdt_from_option_row(p)
if pnl is None: if pnl is None:
continue continue
has_upl = True has_upl = True
+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 '到期结算'
+14 -1
View File
@@ -125,11 +125,24 @@ def display_pnl_from_option_row(row: dict[str, Any]) -> float | None:
def display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None: def display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None:
"""顶栏「实时盈亏」用:币本位净盈亏按指数折 USDT;USDC 仓原样.""" """顶栏/看板「实时盈亏」用:币本位净盈亏按指数折 USDT;USDC 仓原样.
BTC/ETH 币本位若不折算,会被界面标成 U 而数值仍是币数量(BTC 尤为离谱).
"""
pnl = display_pnl_from_option_row(row) pnl = display_pnl_from_option_row(row)
if pnl is None: if pnl is None:
return None return None
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC" ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
if not ccy or ccy == "USDC":
# 兜底:按合约推断币本位,避免 premium_ccy 缺失时把 BTC 盈亏当 U
inst = str(row.get("inst_id") or row.get("instId") or "")
mode = str(row.get("margin_mode") or "").strip().lower()
if mode == "coin" or (
"-USD-" in inst.upper() and "_UM" not in inst.upper()
):
underly = str(row.get("underlying") or (inst.split("-")[0] if inst else "") or "").upper()
if underly in ("ETH", "BTC"):
ccy = underly
if ccy in ("ETH", "BTC"): if ccy in ("ETH", "BTC"):
idx = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px")) idx = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
if idx is None or idx <= 0: if idx is None or idx <= 0:
+23 -1
View File
@@ -211,13 +211,34 @@ def _estimate_recycle(
pos: dict[str, Any], pos: dict[str, Any],
premium_paid: float | None, premium_paid: float | None,
) -> float | None: ) -> float | None:
"""翻倍出场用可回收估算.
用买一价 × 全部持仓张数(不吃深度上限):深度不够时仍应触发分批平仓;
残档/无效买一则返回 None(禁止按假买盘自动平).
"""
from lib.options.options_positions_lib import attach_close_preview from lib.options.options_positions_lib import attach_close_preview
from lib.options.options_pricing_lib import total_premium
row = dict(pos) row = dict(pos)
attach_close_preview(cfg, ex, row, premium_paid=premium_paid) attach_close_preview(cfg, ex, row, premium_paid=premium_paid)
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {} preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
if preview.get("bid_invalid"): if preview.get("bid_invalid"):
return None return None
levels = preview.get("levels") or []
ct_mult = float(row.get("ct_mult") or 0.01)
sheets = int(
abs(
_safe_float(row.get("avail_pos") if row.get("avail_pos") is not None else row.get("pos"))
or 0
)
)
if levels and sheets > 0 and ct_mult > 0:
try:
px = float(levels[0].get("px") or 0)
except (TypeError, ValueError, AttributeError):
px = 0.0
if px > 0:
return round(total_premium(px, sheets * ct_mult), 8)
return _safe_float(preview.get("total_received")) return _safe_float(preview.get("total_received"))
@@ -296,7 +317,8 @@ def run_options_profit_exits(
init_hedge_plan_tables(conn) init_hedge_plan_tables(conn)
hedge_managed = active_hedge_option_inst_ids(conn) hedge_managed = active_hedge_option_inst_ids(conn)
except Exception: except Exception:
return 0 # 对冲表异常时不得整段跳过翻倍出场
hedge_managed = set()
rules = profit_exit_by_inst(conn) rules = profit_exit_by_inst(conn)
triggered = 0 triggered = 0
+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()
+12 -1
View File
@@ -643,6 +643,13 @@ 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,
repair_swapped_closed_at_signal_note,
)
ts_migrate = migrate_options_utc_wall_clock_to_app(conn)
note_swap = repair_swapped_closed_at_signal_note(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 +665,11 @@ 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
out["note_swap"] = note_swap
return out
def _row_to_dict(row: Any) -> dict[str, Any]: def _row_to_dict(row: Any) -> dict[str, Any]:
+452 -310
View File
@@ -1,310 +1,452 @@
"""OKX 期权复盘模块:Flask 路由注册(含对冲计划级复盘).""" """OKX 期权复盘模块:Flask 路由注册(含对冲计划级复盘)."""
from __future__ import annotations from __future__ import annotations
import os import os
from typing import Any from typing import Any
from flask import Flask, jsonify, request, send_file from flask import Flask, jsonify, request, send_file
from jinja2 import ChoiceLoader, FileSystemLoader from jinja2 import ChoiceLoader, FileSystemLoader
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from lib.options.options_review_db import SOURCE_TYPES, init_options_review_tables from lib.options.options_review_db import SOURCE_TYPES, init_options_review_tables
from lib.options.options_review_images_lib import ( from lib.options.options_review_images_lib import (
OPTIONS_REVIEW_UPLOAD_TFS, OPTIONS_REVIEW_UPLOAD_TFS,
normalize_options_review_draft_id, normalize_options_review_draft_id,
options_review_image_paths, options_review_image_paths,
options_review_upload_dir, options_review_upload_dir,
save_options_review_slot_file, save_options_review_slot_file,
) )
from lib.options.options_review_lib import ( from lib.options.options_review_lib import (
SOURCE_LABELS, SOURCE_LABELS,
compute_review_stats, compute_review_stats,
count_review_trades, count_review_trades,
delete_review_entry, delete_review_entry,
ensure_local_review_synced, ensure_local_review_synced,
get_review_trade, get_review_trade,
hide_review_trade, hide_review_trade,
list_review_trades, list_review_trades,
save_review_entry, save_review_entry,
) )
def _review_source_for_mode(requested: str | None) -> str | None: def _review_source_for_mode(requested: str | None) -> str | None:
"""按当前交易模式钳制复盘 source_type;不允许跨模式窥探.""" """按当前交易模式钳制复盘 source_type;不允许跨模式窥探."""
try: try:
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
mode = get_okx_trade_mode() mode = get_okx_trade_mode()
except Exception: except Exception:
mode = "options" mode = "options"
allowed = { allowed = {
"options": "option_spot", "options": "option_spot",
"perp_options": "perp_options", "perp_options": "perp_options",
"options_options": "options_options", "options_options": "options_options",
}.get(mode, "option_spot") }.get(mode, "option_spot")
req = (requested or "").strip() req = (requested or "").strip()
if not req: if not req:
return allowed return allowed
if req == allowed: if req == allowed:
return allowed return allowed
# 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制 # 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制
return allowed return allowed
def attach_options_review_templates(app: Flask, repo_root: str) -> None: def attach_options_review_templates(app: Flask, repo_root: str) -> None:
tpl_dir = os.path.join(repo_root, "lib", "options", "templates") tpl_dir = os.path.join(repo_root, "lib", "options", "templates")
if not os.path.isdir(tpl_dir): if not os.path.isdir(tpl_dir):
return return
existing = app.jinja_loader existing = app.jinja_loader
loaders = [FileSystemLoader(tpl_dir)] loaders = [FileSystemLoader(tpl_dir)]
if existing is not None: if existing is not None:
if isinstance(existing, ChoiceLoader): if isinstance(existing, ChoiceLoader):
loaders = list(existing.loaders) + loaders loaders = list(existing.loaders) + loaders
else: else:
loaders.insert(0, existing) loaders.insert(0, existing)
app.jinja_loader = ChoiceLoader(loaders) app.jinja_loader = ChoiceLoader(loaders)
def install_options_review(app: Flask, repo_root: str, app_module: Any) -> None: def install_options_review(app: Flask, repo_root: str, app_module: Any) -> None:
attach_options_review_templates(app, repo_root) attach_options_review_templates(app, repo_root)
cfg = { cfg = {
"get_db": app_module.get_db, "get_db": app_module.get_db,
"login_required": app_module.login_required, "login_required": app_module.login_required,
"exchange_options": getattr(app_module, "exchange_options", None), "exchange_options": getattr(app_module, "exchange_options", None),
"render_main_page": app_module.render_main_page, "render_main_page": app_module.render_main_page,
"upload_folder": getattr(app_module, "UPLOAD_FOLDER", None) "upload_folder": getattr(app_module, "UPLOAD_FOLDER", None)
or os.path.join(getattr(app_module, "BASE_DIR", repo_root), "static", "images"), or os.path.join(getattr(app_module, "BASE_DIR", repo_root), "static", "images"),
"options_enabled": bool(getattr(app_module, "OKX_OPTIONS_ENABLED", False)), "options_enabled": bool(getattr(app_module, "OKX_OPTIONS_ENABLED", False)),
"app_module": app_module, "app_module": app_module,
} }
app.extensions["options_review_cfg"] = cfg app.extensions["options_review_cfg"] = cfg
register_options_review_routes(app, cfg, repo_root) register_options_review_routes(app, cfg, repo_root)
def _require_ex(cfg: dict[str, Any]): def _require_ex(cfg: dict[str, Any]):
from lib.exchange.okx_options_lib import options_api_ready from lib.exchange.okx_options_lib import options_api_ready
if not cfg.get("options_enabled"): if not cfg.get("options_enabled"):
return None, "期权模块未启用" return None, "期权模块未启用"
ex = cfg.get("exchange_options") ex = cfg.get("exchange_options")
ok, reason = options_api_ready(ex) ok, reason = options_api_ready(ex)
if not ok: if not ok:
return None, reason or "期权 API 未配置" return None, reason or "期权 API 未配置"
return ex, "" return ex, ""
def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None: def _truthy(raw: Any) -> bool:
lr = cfg["login_required"] return str(raw or "").strip().lower() in ("1", "true", "on", "yes")
@app.route("/options/review")
@lr def _prepare_review_entry_images(
def options_review_page(): conn: Any,
from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled trade_id: int,
data: dict[str, Any],
redir = redirect_to_embed_shell_if_enabled("options_review") ) -> dict[str, Any]:
if redir is not None: """客户端只提交手动槽位;未重新勾选自动K线时保留已有 auto 附图."""
return redir from lib.options.options_review_chart_async_lib import OPTIONS_REVIEW_AUTO_CHART_TF
return cfg["render_main_page"]("options_review") from lib.options.options_review_images_lib import parse_options_review_images_json
@app.route("/static/options_review.js") images = data.get("images") if isinstance(data.get("images"), list) else []
@lr cleaned: list[dict[str, str]] = []
def static_options_review_js(): for item in images:
path = os.path.join(repo_root, "lib", "common", "static", "options_review.js") if not isinstance(item, dict):
if not os.path.isfile(path): continue
return ("not found", 404) tf = str(item.get("tf") or "").strip()
return send_file(path, mimetype="application/javascript; charset=utf-8") file = str(item.get("file") or "").strip()
if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF:
@app.route("/static/images/options_journal/<path:filename>") continue
def static_options_review_image(filename: str): cleaned.append({"tf": tf, "file": file})
"""截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401)."""
folder = options_review_upload_dir(cfg["upload_folder"]) want_chart = _truthy(data.get("journal_exchange_chart")) and not cleaned
safe = os.path.basename(filename or "") if not want_chart:
path = os.path.join(folder, safe) row = conn.execute(
if not os.path.isfile(path): "SELECT images_json FROM options_review_entries WHERE trade_id=?",
# 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件 (int(trade_id),),
root = os.path.abspath(cfg["upload_folder"] or "") ).fetchone()
alt = os.path.join(root, safe) if row:
if os.path.isfile(alt): for item in parse_options_review_images_json(row["images_json"]):
path = alt if str(item.get("tf") or "").strip() == OPTIONS_REVIEW_AUTO_CHART_TF:
else: cleaned.insert(0, dict(item))
return ("not found", 404) break
return send_file(path)
out = dict(data)
@app.route("/api/options/review/sync", methods=["POST"]) out["images"] = cleaned
@lr return out
def api_options_review_sync():
"""刷新本地 options_trades + 已结束对冲计划;尽量用交易所历史盈亏覆盖本地估算."""
conn = cfg["get_db"]() def _maybe_schedule_options_review_chart(
try: cfg: dict[str, Any],
init_options_review_tables(conn) trade: dict[str, Any],
ex, _err = _require_ex(cfg) data: dict[str, Any],
result = ensure_local_review_synced(conn, ex=ex if ex is not None else None) ) -> bool:
conn.commit() """纯期权 + 无手动截图 + 勾选自动K线 → 后台生成标的永续双周期拼图(只钉进/出场)."""
return jsonify(result) if str(trade.get("source_type") or "").strip() != "option_spot":
finally: return False
conn.close() 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)
@app.route("/api/options/review/trades") if has_manual or not _truthy(data.get("journal_exchange_chart")):
@lr return False
def api_options_review_trades():
conn = cfg["get_db"]() app_module = cfg.get("app_module")
try: if app_module is None:
# 翻页可跳过同步,仅刷新当前卡片列表 return False
do_sync = (request.args.get("sync") or "1").strip().lower() not in ( if not bool(getattr(app_module, "ORDER_CHART_ENABLED", True)):
"0", return False
"false", generate_fn = getattr(app_module, "generate_multi_timeframe_chart_png", None)
"no", if not callable(generate_fn):
) return False
if do_sync:
ex, _err = _require_ex(cfg) from lib.instance.journal_chart_lib import (
ensure_local_review_synced(conn, ex=ex if ex is not None else None) parse_journal_chart_anchor,
conn.commit() parse_journal_chart_limit,
filt = dict( parse_journal_chart_timeframes,
source_type=_review_source_for_mode(request.args.get("source_type")), )
underlying=(request.args.get("underlying") or "").strip() or None, from lib.options.options_review_chart_async_lib import (
opt_type=(request.args.get("opt_type") or "").strip() or None, schedule_options_review_exchange_chart,
strategy_tag=(request.args.get("strategy_tag") or "").strip() or None, underlying_to_okx_perp_symbol,
q=(request.args.get("q") or "").strip() or None, )
reviewed=(request.args.get("reviewed") or "").strip() or None, from lib.options.options_review_images_lib import options_review_upload_dir
include_hedge_legs=(request.args.get("include_hedge_legs") or "") from lib.options.options_review_lib import _now_str
.strip()
.lower() fallback_tfs = list(getattr(app_module, "ORDER_CHART_TFS", None) or [])[:2]
in ("1", "true", "yes"), journal_tfs = parse_journal_chart_timeframes(
closed_from=(request.args.get("closed_from") or "").strip() or None, data.get("journal_chart_tf1"),
closed_to=(request.args.get("closed_to") or "").strip() or None, data.get("journal_chart_tf2"),
) fallback_tfs or None,
limit = min(500, max(1, int(request.args.get("limit") or 200))) )
offset = max(0, int(request.args.get("offset") or 0)) journal_limit = parse_journal_chart_limit(
total = count_review_trades(conn, **filt) data.get("journal_chart_limit"),
items = list_review_trades(conn, **filt, limit=limit, offset=offset) int(getattr(app_module, "ORDER_CHART_LIMIT", 100) or 100),
pages = max(1, (total + limit - 1) // limit) if total else 1 )
page = (offset // limit) + 1 if limit else 1 chart_anchor = parse_journal_chart_anchor(data.get("journal_chart_anchor"))
return jsonify(
{ underlying = str(trade.get("underlying") or data.get("underlying") or "").strip()
"ok": True, exchange_symbol = underlying_to_okx_perp_symbol(
"trades": items, underlying,
"source_labels": SOURCE_LABELS, normalize_symbol_input_fn=app_module.normalize_symbol_input,
"total": total, normalize_okx_symbol_fn=app_module.normalize_okx_symbol,
"limit": limit, )
"offset": offset, if not exchange_symbol:
"page": page, return False
"pages": pages,
} to_ms = getattr(app_module, "_local_input_datetime_to_ms", None)
) if not callable(to_ms):
finally: return False
conn.close() entry_ts = to_ms(trade.get("opened_at"))
exit_ts = to_ms(trade.get("closed_at"))
@app.route("/api/options/review/trades/<int:trade_id>") if entry_ts is None and exit_ts is None:
@lr return False
def api_options_review_trade_detail(trade_id: int):
conn = cfg["get_db"]() now_ms = None
try: app_now = getattr(app_module, "app_now", None)
item = get_review_trade(conn, trade_id) if callable(app_now):
if not item: try:
return jsonify({"ok": False, "msg": "未找到"}), 404 now_ms = int(app_now().timestamp() * 1000)
return jsonify({"ok": True, "trade": item}) except Exception:
finally: now_ms = None
conn.close()
inst = str(trade.get("inst_id") or underlying or f"#{trade.get('id') or ''}").strip()
@app.route("/api/options/review/entry", methods=["POST"]) upload_folder = options_review_upload_dir(cfg["upload_folder"])
@lr schedule_options_review_exchange_chart(
def api_options_review_entry_save(): trade_id=int(trade["id"]),
data = request.get_json(silent=True) or {} exchange_symbol=exchange_symbol,
try: title_prefix=f"{underlying or 'OPT'} {inst} review",
trade_id = int(data.get("trade_id")) journal_tfs=journal_tfs,
except (TypeError, ValueError): journal_limit=journal_limit,
return jsonify({"ok": False, "msg": "trade_id 无效"}), 400 marker_payload={
conn = cfg["get_db"]() "entry_ts_ms": entry_ts,
try: "exit_ts_ms": exit_ts,
out = save_review_entry(conn, trade_id, data) # 只按时间钉进/出场,不传价格与止损
if out.get("ok"): "chart_anchor": chart_anchor,
conn.commit() "now_ts_ms": now_ms,
return jsonify(out), (200 if out.get("ok") else 400) },
finally: upload_folder=upload_folder,
conn.close() generate_chart_fn=generate_fn,
get_db_fn=cfg["get_db"],
@app.route("/api/options/review/trades/<int:trade_id>", methods=["DELETE"]) now_str_fn=_now_str,
@lr )
def api_options_review_trade_hide(trade_id: int): return True
"""从复盘列表删除并持久隐藏(刷新本地源也不会再导入)."""
conn = cfg["get_db"]()
try: def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None:
out = hide_review_trade(conn, trade_id) lr = cfg["login_required"]
if out.get("ok"):
entry = out.get("entry") or {} @app.route("/options/review")
folder = options_review_upload_dir(cfg["upload_folder"]) @lr
for path in options_review_image_paths(entry, folder): def options_review_page():
try: from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled
os.remove(path)
except OSError: redir = redirect_to_embed_shell_if_enabled("options_review")
pass if redir is not None:
conn.commit() return redir
return jsonify(out), (200 if out.get("ok") else 400) return cfg["render_main_page"]("options_review")
finally:
conn.close() @app.route("/static/options_review.js")
@lr
@app.route("/api/options/review/entry/<int:trade_id>", methods=["DELETE"]) def static_options_review_js():
@lr path = os.path.join(repo_root, "lib", "common", "static", "options_review.js")
def api_options_review_entry_delete(trade_id: int): if not os.path.isfile(path):
conn = cfg["get_db"]() return ("not found", 404)
try: return send_file(path, mimetype="application/javascript; charset=utf-8")
out = delete_review_entry(conn, trade_id)
if out.get("ok"): @app.route("/static/images/options_journal/<path:filename>")
entry = out.get("entry") or {} def static_options_review_image(filename: str):
folder = options_review_upload_dir(cfg["upload_folder"]) """截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401)."""
for path in options_review_image_paths(entry, folder): folder = options_review_upload_dir(cfg["upload_folder"])
try: safe = os.path.basename(filename or "")
os.remove(path) path = os.path.join(folder, safe)
except OSError: if not os.path.isfile(path):
pass # 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件
conn.commit() root = os.path.abspath(cfg["upload_folder"] or "")
return jsonify(out), (200 if out.get("ok") else 400) alt = os.path.join(root, safe)
finally: if os.path.isfile(alt):
conn.close() path = alt
else:
@app.route("/api/options/review/upload_slot", methods=["POST"]) return ("not found", 404)
@lr return send_file(path)
def api_options_review_upload_slot():
draft_id = normalize_options_review_draft_id( @app.route("/api/options/review/sync", methods=["POST"])
request.form.get("draft_id") if request.form else None @lr
) def api_options_review_sync():
tf = str((request.form.get("tf") if request.form else None) or "").strip() """刷新本地 options_trades + 已结束对冲计划;尽量用交易所历史盈亏覆盖本地估算."""
if not draft_id: conn = cfg["get_db"]()
return jsonify({"ok": False, "error": "invalid draft_id"}), 400 try:
if tf not in OPTIONS_REVIEW_UPLOAD_TFS: init_options_review_tables(conn)
return jsonify({"ok": False, "error": "invalid tf"}), 400 ex, _err = _require_ex(cfg)
f = request.files.get("file") if request.files else None result = ensure_local_review_synced(conn, ex=ex if ex is not None else None)
if not f or not getattr(f, "filename", None): conn.commit()
return jsonify({"ok": False, "error": "no file"}), 400 return jsonify(result)
folder = options_review_upload_dir(cfg["upload_folder"]) finally:
item = save_options_review_slot_file( conn.close()
f, draft_id, tf, folder, secure_filename_fn=secure_filename
) @app.route("/api/options/review/trades")
if not item: @lr
return jsonify({"ok": False, "error": "save failed"}), 500 def api_options_review_trades():
return jsonify({"ok": True, "tf": tf, "file": item["file"]}) conn = cfg["get_db"]()
try:
@app.route("/api/options/review/stats") # 翻页可跳过同步,仅刷新当前卡片列表
@lr do_sync = (request.args.get("sync") or "1").strip().lower() not in (
def api_options_review_stats(): "0",
conn = cfg["get_db"]() "false",
try: "no",
ex, _err = _require_ex(cfg) )
ensure_local_review_synced(conn, ex=ex if ex is not None else None) if do_sync:
conn.commit() ex, _err = _require_ex(cfg)
stats = compute_review_stats( ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn, conn.commit()
source_type=_review_source_for_mode(request.args.get("source_type")), filt = dict(
underlying=(request.args.get("underlying") or "").strip() or None, source_type=_review_source_for_mode(request.args.get("source_type")),
include_hedge_legs=(request.args.get("include_hedge_legs") or "").strip().lower() underlying=(request.args.get("underlying") or "").strip() or None,
in ("1", "true", "yes"), opt_type=(request.args.get("opt_type") or "").strip() or None,
closed_from=(request.args.get("closed_from") or "").strip() or None, strategy_tag=(request.args.get("strategy_tag") or "").strip() or None,
closed_to=(request.args.get("closed_to") or "").strip() or None, q=(request.args.get("q") or "").strip() or None,
require_strategy=(request.args.get("require_strategy") or "").strip().lower() reviewed=(request.args.get("reviewed") or "").strip() or None,
in ("1", "true", "yes"), include_hedge_legs=(request.args.get("include_hedge_legs") or "")
) .strip()
stats["source_types"] = list(SOURCE_TYPES) .lower()
stats["source_labels"] = SOURCE_LABELS in ("1", "true", "yes"),
return jsonify(stats) closed_from=(request.args.get("closed_from") or "").strip() or None,
finally: closed_to=(request.args.get("closed_to") or "").strip() or None,
conn.close() )
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()
+182
View File
@@ -0,0 +1,182 @@
"""期权业务时区墙钟:库内统一按 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"
_SWAP_REPAIR_FLAG = "options_closed_at_note_swap_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}
def repair_swapped_closed_at_signal_note(conn: sqlite3.Connection) -> dict[str, int]:
"""修复 close_option_by_bid1 曾把 signal_note 写入 closed_at 的坏行.
典型表现: closed_at='翻倍出场'/'目标位平仓', signal_note 反而是时间戳.
幂等:同步状态 options_closed_at_note_swap_done=1 后仍扫描可修行(新坏行可再修).
"""
from lib.options.options_review_db import init_options_review_tables
from lib.options.options_review_lib import set_sync_state
init_options_review_tables(conn)
trades_fixed = 0
review_fixed = 0
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
rows = conn.execute(
"""
SELECT id, closed_at, signal_note
FROM options_trades
WHERE status = 'closed'
AND closed_at IS NOT NULL
AND TRIM(closed_at) != ''
"""
).fetchall()
for row in rows:
closed = str(row["closed_at"] or "").strip()
note = str(row["signal_note"] or "").strip()
if options_wall_clock_to_ms(closed) is not None:
continue
if options_wall_clock_to_ms(note) is None:
continue
conn.execute(
"""
UPDATE options_trades
SET closed_at = ?, signal_note = ?
WHERE id = ?
""",
(note, closed, int(row["id"])),
)
trades_fixed += 1
hk = f"local_opt:{int(row['id'])}"
cur = conn.execute(
"""
UPDATE options_review_trades
SET closed_at = ?
WHERE history_key = ?
AND (closed_at IS NULL OR TRIM(closed_at) = '' OR closed_at = ?)
""",
(note, hk, closed),
)
review_fixed += int(cur.rowcount or 0)
except Exception:
pass
# 复盘表里仍残留非时间戳 closed_at 的,若能从 local_opt 源修好则已处理;否则仅标记完成
set_sync_state(conn, _SWAP_REPAIR_FLAG, "1")
return {"trades": trades_fixed, "review": review_fixed}
@@ -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>
+213 -1
View File
@@ -12,7 +12,19 @@ from lib.sim.wallets_lib import InsufficientFunds, SimWallets
def _now() -> str: def _now() -> str:
return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") """业务时区墙钟(默认 Asia/Shanghai);勿用 utcnow,否则复盘K线标记会偏 8 小时."""
try:
from lib.options.options_time_lib import options_app_now_str
return options_app_now_str()
except Exception:
from zoneinfo import ZoneInfo
tz = ZoneInfo(
(os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip()
or "Asia/Shanghai"
)
return datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S")
def _ticker_bid_ask(exchange: Any, symbol: str) -> tuple[float, float]: def _ticker_bid_ask(exchange: Any, symbol: str) -> tuple[float, float]:
@@ -890,6 +902,200 @@ class SimBroker:
rows.append(row) rows.append(row)
return rows return rows
@staticmethod
def _sells_from_ledger(
conn: Any,
*,
inst: str,
opened: str,
closed: str,
) -> list[Any]:
"""从 sim_ledger_entries 的 option_close 备注解析卖平成交."""
import re
try:
params: list[Any] = [f"%sell {inst} %"]
sql = """
SELECT note, ts AS created_at
FROM sim_ledger_entries
WHERE kind = 'option_close' AND note LIKE ?
"""
if opened:
sql += " AND ts >= ?"
params.append(opened[:19])
if closed:
sql += " AND ts <= ?"
params.append((closed[:16] + ":59") if len(closed) >= 16 else closed)
sql += " ORDER BY ts ASC, id ASC"
rows = conn.execute(sql, params).fetchall()
except Exception:
return []
out: list[dict[str, Any]] = []
pat = re.compile(
rf"sell\s+{re.escape(inst)}\s+x(?P<sheets>[0-9.]+)@(?P<px>[0-9.]+)",
re.I,
)
for i, r in enumerate(rows):
note = str(r["note"] or "")
m = pat.search(note)
if not m:
continue
try:
sh = float(m.group("sheets"))
px = float(m.group("px"))
except (TypeError, ValueError):
continue
if sh <= 0 or px <= 0:
continue
out.append(
{
"ord_id": f"ledger-{i}",
"sheets": sh,
"avg_px": px,
"created_at": r["created_at"],
}
)
return out
def repair_closed_options_pnl_from_sim_orders(self, *, limit: int = 300) -> int:
"""用 sim_option_orders 卖平成交重算已平仓收回(修复分批平仓只记最后一笔).
返回更新条数.历史页/复盘合成前调用,使旧数据也能自愈.
"""
from lib.options.options_db import init_options_tables
from lib.options.options_pricing_lib import total_premium
from lib.sim.db_lib import init_sim_tables
conn = self.get_db()
updated = 0
try:
init_options_tables(conn)
init_sim_tables(conn)
# 表不存在时跳过(极旧库)
try:
conn.execute("SELECT 1 FROM sim_option_orders LIMIT 1").fetchone()
except Exception:
return 0
trades = conn.execute(
"""
SELECT id, inst_id, sheets, eth_amount, premium_paid, premium_received,
realized_pnl, close_quote, created_at, closed_at, close_ord_id
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
LIMIT ?
""",
(int(max(1, min(int(limit), 500))),),
).fetchall()
for tr in trades:
inst = str(tr["inst_id"] or "").strip()
if not inst:
continue
want_sheets = float(tr["sheets"] or 0)
if want_sheets <= 0:
continue
eth_amt = float(tr["eth_amount"] or 0)
ct_mult = (eth_amt / want_sheets) if eth_amt > 0 else 0.01
if ct_mult <= 0:
ct_mult = 0.01
opened = str(tr["created_at"] or "").strip()
closed = str(tr["closed_at"] or "").strip()
# 时间窗:开仓后、平仓后 2 分钟内(含下单延迟)
params: list[Any] = [inst]
sql = """
SELECT ord_id, sheets, avg_px, created_at
FROM sim_option_orders
WHERE inst_id = ? AND lower(side) = 'sell'
"""
if opened:
sql += " AND created_at >= ?"
params.append(opened[:19])
if closed:
# 字符串时间比较;闭区间放宽到同分钟及之后的短尾
sql += " AND created_at <= ?"
params.append((closed[:16] + ":59") if len(closed) >= 16 else closed)
sql += " ORDER BY created_at ASC, ord_id ASC"
sells = conn.execute(sql, params).fetchall()
if not sells:
# 回退:从钱包流水 note 解析 sell INST x张数@价
sells = self._sells_from_ledger(
conn, inst=inst, opened=opened, closed=closed
)
if not sells:
continue
selected: list[Any] = []
sheets_sum = 0.0
for o in sells:
selected.append(o)
sheets_sum += float(o["sheets"] or 0)
if sheets_sum + 1e-9 >= want_sheets:
break
if sheets_sum + 1e-6 < want_sheets * 0.5:
# 成交张数明显不足,不瞎改
continue
recv = 0.0
notional = 0.0
oids: list[str] = []
for o in selected:
sh = float(o["sheets"] or 0)
px = float(o["avg_px"] or 0)
if sh <= 0 or px <= 0:
continue
recv += total_premium(px, sh * ct_mult)
notional += px * sh
oid = str(o["ord_id"] or "").strip()
if oid and oid not in oids:
oids.append(oid)
if not oids:
continue
total_recv = round(recv, 8)
paid = float(tr["premium_paid"] or 0) if tr["premium_paid"] is not None else None
if paid is None:
continue
new_pnl = round(total_recv - paid, 8)
close_px = round(notional / sheets_sum, 8) if sheets_sum > 0 else None
old_recv = (
float(tr["premium_received"])
if tr["premium_received"] is not None
else None
)
old_pnl = float(tr["realized_pnl"]) if tr["realized_pnl"] is not None else None
# 已与合计一致则跳过
if (
old_recv is not None
and old_pnl is not None
and abs(old_recv - total_recv) < 1e-8
and abs(old_pnl - new_pnl) < 1e-8
):
continue
# 仅当卖笔数≥2 或合计收回明显高于本地(漏记)时覆盖
if len(selected) < 2 and old_recv is not None and total_recv <= old_recv + 1e-10:
continue
conn.execute(
"""
UPDATE options_trades
SET premium_received = ?,
realized_pnl = ?,
close_quote = COALESCE(?, close_quote),
close_ord_id = ?
WHERE id = ?
""",
(
total_recv,
new_pnl,
close_px,
",".join(oids) if oids else tr["close_ord_id"],
int(tr["id"]),
),
)
updated += 1
if updated:
conn.commit()
finally:
conn.close()
return updated
def option_positions_history_okx_rows( def option_positions_history_okx_rows(
self, self,
exchange: Any = None, exchange: Any = None,
@@ -901,6 +1107,12 @@ class SimBroker:
实盘 positions-history 在模拟模式不可用,期权历史/复盘依赖此合成数据. 实盘 positions-history 在模拟模式不可用,期权历史/复盘依赖此合成数据.
""" """
# 先按卖平成交修复分批漏计,再合成展示
try:
self.repair_closed_options_pnl_from_sim_orders(limit=max(int(limit), 200))
except Exception:
pass
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
+40
View File
@@ -0,0 +1,40 @@
"""OKX 公开 K 线小测(无网络):instId / bar 映射."""
from __future__ import annotations
import unittest
class OkxPublicOhlcvMappingTests(unittest.TestCase):
def test_swap_inst_and_bar(self):
# 避免 import 整站 app:直接复刻映射逻辑做契约校验
def swap_inst(exchange_symbol: str) -> str:
sym = str(exchange_symbol or "").strip().upper()
if not sym:
return ""
if "/" in sym:
base, rest = sym.split("/", 1)
quote = rest.split(":", 1)[0].strip() or "USDT"
return f"{base.strip()}-{quote}-SWAP"
if sym.endswith("-SWAP"):
return sym
return sym
def public_bar(timeframe: str) -> str:
low = str(timeframe or "").strip().lower()
if low.endswith("m") and low[:-1].isdigit():
return low
if low.endswith("h") and low[:-1].isdigit():
return f"{low[:-1]}H"
if low.endswith("d") and low[:-1].isdigit():
return f"{low[:-1]}D"
return low or "15m"
self.assertEqual(swap_inst("ETH/USDT:USDT"), "ETH-USDT-SWAP")
self.assertEqual(swap_inst("BTC/USDT:USDT"), "BTC-USDT-SWAP")
self.assertEqual(public_bar("15m"), "15m")
self.assertEqual(public_bar("1h"), "1H")
self.assertEqual(public_bar("4h"), "4H")
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
"""币本位期权浮盈折 U(BTC/ETH 不得把币数量直接当 U)."""
from __future__ import annotations
import unittest
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
class DisplayPnlUsdtTests(unittest.TestCase):
def test_btc_coin_pnl_times_index(self):
row = {
"inst_id": "BTC-USD-260327-100000-P",
"premium_ccy": "BTC",
"margin_mode": "coin",
"idx_px": 100000.0,
"upl": -0.001,
"close_preview": {"bid_invalid": True},
}
# 残档回退 upl: -0.001 BTC × 100000 = -100 U
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -100.0, places=4)
def test_btc_infers_ccy_when_premium_ccy_missing(self):
row = {
"inst_id": "BTC-USD-260327-100000-C",
"margin_mode": "coin",
"underlying": "BTC",
"idx_px": 95000.0,
"upl": 0.002,
"close_preview": {"bid_invalid": True},
}
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), 190.0, places=4)
def test_usdc_unchanged(self):
row = {
"inst_id": "BTC-USD_UM-260327-100000-C",
"premium_ccy": "USDC",
"margin_mode": "usdc",
"upl": -12.5,
"close_preview": {"bid_invalid": True},
}
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -12.5, places=4)
if __name__ == "__main__":
unittest.main()
+160
View File
@@ -0,0 +1,160 @@
"""分批平仓:累计各批收回权利金后再算盈亏."""
from __future__ import annotations
import sqlite3
import unittest
from unittest.mock import patch
from lib.options.options_close_exec_lib import close_option_by_bid1
from lib.options.options_db import (
add_open_close_accum,
get_open_close_accum,
init_options_tables,
)
def _mem_db() -> sqlite3.Connection:
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_options_tables(conn)
return conn
class CloseAccumDbTests(unittest.TestCase):
def test_accum_on_earliest_open(self):
conn = _mem_db()
conn.execute(
"""
INSERT INTO options_trades
(inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status)
VALUES ('ETH-USD-260927-2700-P', 'ETH', 'P', 10, 0.1, 0.02, 'open')
"""
)
conn.commit()
self.assertTrue(
add_open_close_accum(
conn, "ETH-USD-260927-2700-P", recv=0.005, sheets=5, px=0.05, ord_id="a1"
)
)
self.assertTrue(
add_open_close_accum(
conn, "ETH-USD-260927-2700-P", recv=0.004, sheets=5, px=0.04, ord_id="a2"
)
)
acc = get_open_close_accum(conn, "ETH-USD-260927-2700-P")
self.assertAlmostEqual(float(acc["recv"]), 0.009, places=6)
self.assertEqual(int(acc["sheets"]), 10)
self.assertAlmostEqual(float(acc["notional"]), 0.05 * 5 + 0.04 * 5, places=6)
self.assertEqual(acc["close_ord_id"], "a1,a2")
class TwoStepCloseExecTests(unittest.TestCase):
def test_two_partial_closes_sum_premium_received(self):
conn = _mem_db()
inst = "ETH-USD-260927-2700-P"
conn.execute(
"""
INSERT INTO options_trades
(inst_id, underlying, opt_type, strike, sheets, eth_amount,
open_quote, premium_paid, status, premium_ccy, margin_mode)
VALUES (?, 'ETH', 'P', 2700, 10, 0.1, 0.2, 0.02, 'open', 'ETH', 'coin')
""",
(inst,),
)
conn.commit()
# 持仓:第一次 10→5,第二次 5→0
pos_seq = [
[{"instId": inst, "availPos": "10", "pos": "10", "mgnMode": "isolated", "idxPx": "2500"}],
[{"instId": inst, "availPos": "5", "pos": "5", "mgnMode": "isolated", "idxPx": "2500"}],
[{"instId": inst, "availPos": "5", "pos": "5", "mgnMode": "isolated", "idxPx": "2500"}],
[], # 第二次下单后已平
]
pos_i = {"n": 0}
def fetch_positions(_ex):
i = pos_i["n"]
pos_i["n"] = min(i + 1, len(pos_seq) - 1)
return pos_seq[i]
order_px = {"n": 0}
def place_limit(_ex, **kwargs):
order_px["n"] += 1
px = 0.05 if order_px["n"] == 1 else 0.04
return {"ok": True, "px": px, "data": {"ordId": f"ord{order_px['n']}"}}
def quote(_ex, _inst):
return {"ok": True, "tick_sz": "0.0001", "ct_mult": 0.01, "bid": 0.05, "index_px": 2500}
def book(_ex, _inst, _n):
# 每次只够吃一半深度,强制分两批
return {"bids": [{"px": 0.05, "sz": 5}]} if order_px["n"] < 1 else {"bids": [{"px": 0.04, "sz": 5}]}
cfg = {
"get_db": lambda: _reuse_conn(conn),
"quote_option_contract": quote,
"fetch_option_positions": fetch_positions,
"fetch_option_book_depth": book,
"place_option_limit_order": place_limit,
"td_mode": "isolated",
}
with patch("lib.options.options_close_exec_lib.time.sleep", return_value=None), patch(
"lib.exchange.okx_options_lib.invalidate_option_positions_cache"
), patch(
"lib.options.options_pricing_lib.fetch_option_mark_px", return_value=0.05
), patch(
"lib.options.options_close_exec_lib._pos_close_refs", return_value=(0.05, 0.0)
):
r1 = close_option_by_bid1(cfg, ex=object(), inst_id=inst, sheets=10)
self.assertTrue(r1.get("ok"), r1)
self.assertFalse(r1.get("fully_closed"), r1)
open_row = conn.execute(
"SELECT close_recv_accum, status, realized_pnl FROM options_trades WHERE inst_id=?",
(inst,),
).fetchone()
self.assertEqual(open_row["status"], "open")
self.assertGreater(float(open_row["close_recv_accum"] or 0), 0)
self.assertIsNone(open_row["realized_pnl"])
# 第二次买一深度
def book2(_ex, _inst, _n):
return {"bids": [{"px": 0.04, "sz": 5}]}
cfg["fetch_option_book_depth"] = book2
r2 = close_option_by_bid1(cfg, ex=object(), inst_id=inst, sheets=5)
self.assertTrue(r2.get("ok"), r2)
self.assertTrue(r2.get("fully_closed"), r2)
closed = conn.execute(
"""
SELECT premium_paid, premium_received, realized_pnl, status, close_recv_accum
FROM options_trades WHERE inst_id=?
""",
(inst,),
).fetchone()
self.assertEqual(closed["status"], "closed")
# ct_mult=0.01: 5张×0.05 + 5张×0.04 = 0.0025 + 0.002 = 0.0045
self.assertAlmostEqual(float(closed["premium_received"]), 0.0045, places=6)
self.assertAlmostEqual(float(closed["realized_pnl"]), 0.0045 - 0.02, places=6)
self.assertEqual(float(closed["close_recv_accum"] or 0), 0.0)
# 若只记第二批,recv 会是 0.002,pnl=-0.018;合计应为 -0.0155
self.assertNotAlmostEqual(float(closed["premium_received"]), 0.002, places=6)
def _reuse_conn(conn: sqlite3.Connection) -> sqlite3.Connection:
"""get_db 每次返回同一连接;close 时 no-op,避免 :memory: 被关掉."""
class _Proxy:
def __getattr__(self, name):
return getattr(conn, name)
def close(self):
return None
return _Proxy() # type: ignore[return-value]
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -0,0 +1,41 @@
"""翻倍出场:买一价×全仓张数判定达标(不受买一深度截断)."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from lib.options.options_profit_exit_lib import _estimate_recycle, profit_exit_hit
class ProfitExitHitTests(unittest.TestCase):
def test_hit_at_0_3_mult(self):
# 0.3倍: recycle >= paid * 1.3
self.assertTrue(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.3, mult=0.3))
self.assertFalse(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.29, mult=0.3))
def test_estimate_uses_full_sheets_not_depth(self):
pos = {"inst_id": "ETH-USD-260927-2700-P", "pos": 100, "ct_mult": 0.01}
preview = {
"bid_invalid": False,
"levels": [{"px": 0.2, "sheets": 10}], # 深度只够 10 张
"total_received": 0.02, # 仅深度内
}
def fake_attach(cfg, ex, row, premium_paid=None):
row["close_preview"] = preview
row["ct_mult"] = 0.01
row["pos"] = 100
return row
with patch(
"lib.options.options_positions_lib.attach_close_preview",
side_effect=fake_attach,
):
recv = _estimate_recycle({}, object(), pos, 1.0)
# 应按 100 张 × 0.01 × 0.2 = 0.2,而非深度截断的 0.02
self.assertAlmostEqual(float(recv), 0.2, places=6)
self.assertTrue(profit_exit_hit(premium_paid=0.15, recycle_usdc=float(recv), mult=0.3))
if __name__ == "__main__":
unittest.main()
@@ -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()
@@ -0,0 +1,95 @@
"""模拟盘:用分批卖平成交修复已平仓漏计盈亏."""
from __future__ import annotations
import sqlite3
import unittest
from lib.options.options_db import init_options_tables
from lib.sim.broker_lib import SimBroker
from lib.sim.db_lib import init_sim_tables
class RepairSimPartialClosePnlTests(unittest.TestCase):
def test_repair_sums_two_sell_orders(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_options_tables(conn)
init_sim_tables(conn)
inst = "ETH-USD-260927-2700-P"
# 开仓 228 张,权利金 0.399;旧逻辑只记了第二笔收回 0.2449 → pnl=-0.1541
conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, strike, sheets, eth_amount,
open_quote, premium_paid, premium_received, realized_pnl,
status, premium_ccy, created_at, closed_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
(
inst,
"ETH",
"P",
2700,
228,
2.28,
0.175,
0.399,
0.2449,
-0.1541,
"closed",
"ETH",
"2026-09-24 07:09:19",
"2026-09-24 20:59:20",
),
)
# 两笔卖平各 114 张
conn.execute(
"""
INSERT INTO sim_option_orders(
ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, created_at
) VALUES (?,?,?,?,?,'filled',?,?)
""",
("sim-a", inst, "sell", 114, 0.12, 114, "2026-09-24 15:00:00"),
)
conn.execute(
"""
INSERT INTO sim_option_orders(
ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, created_at
) VALUES (?,?,?,?,?,'filled',?,?)
""",
("sim-b", inst, "sell", 114, 0.1074, 114, "2026-09-24 20:59:10"),
)
conn.commit()
def get_db():
return conn
# SimBroker 每次 close 自己的连接;测试里复用同一 :memory: 需禁止 close
class _Proxy:
def __getattr__(self, name):
return getattr(conn, name)
def close(self):
return None
broker = SimBroker(lambda: _Proxy()) # type: ignore[arg-type]
n = broker.repair_closed_options_pnl_from_sim_orders()
self.assertEqual(n, 1)
row = conn.execute(
"SELECT premium_received, realized_pnl, close_ord_id FROM options_trades WHERE inst_id=?",
(inst,),
).fetchone()
# ct_mult=2.28/228=0.01; recv=114*0.12*0.01 + 114*0.1074*0.01 = 0.1368+0.122436=0.259236
self.assertAlmostEqual(float(row["premium_received"]), 0.259236, places=6)
self.assertAlmostEqual(float(row["realized_pnl"]), 0.259236 - 0.399, places=6)
self.assertIn("sim-a", str(row["close_ord_id"]))
self.assertIn("sim-b", str(row["close_ord_id"]))
# 历史合成应读到修复后的盈亏
hist = broker.option_positions_history_okx_rows(limit=10)
self.assertEqual(len(hist), 1)
self.assertAlmostEqual(float(hist[0]["realizedPnl"]), 0.259236 - 0.399, places=6)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -4,7 +4,7 @@
基于 Flask 的 **OKX 期权 / 对冲** Web 控制台.与中控、其它交易所实例无关. 基于 Flask 的 **OKX 期权 / 对冲** Web 控制台.与中控、其它交易所实例无关.
**部署 / PM2 / SOCKS** 见 **[部署文档.md](./部署文档.md)**: **部署 / PM2 / SOCKS** 见 **[部署文档.md](./部署文档.md)**;**新机纯实盘**见 **[新机纯实盘部署.md](./新机纯实盘部署.md)**:
```bash ```bash
curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash
+215
View File
@@ -0,0 +1,215 @@
# 新机纯实盘部署指南(crypto_okx)
适用场景:**新开一台服务器,直接接 OKX 实盘 API,不做同机模拟→实盘切换**。
当前推荐用法:
| 机器 | 用途 |
|------|------|
| 现有模拟机 | 不接 API Key,本地模拟撮合 + 公开行情 |
| **本机(新机)** | 只跑实盘,从部署起就是 `live` |
与主仓 `crypto_monitor` **隔离**:请用独立子账户 / 独立 API Key,避免双实例同 Key 抢仓。
通用安装步骤见 [部署文档.md](./部署文档.md);页面操作见 [使用说明.md](./使用说明.md)。
---
## 1. 上线前准备
1. OKX **实盘**账户(非演示盘),建议专用子账户、小资金。
2. API Key 权限:交易(期权 + 如需币本位则含现货);按需开提现(一般不必)。
3. IP 白名单绑定到新机公网 IP。
4. 确认持仓模式 / 保证金模式与下文 `.env` 一致(如双向 + 全仓)。
5. 确认本机或经 SOCKS 能访问 `https://www.okx.com`。
---
## 2. 一键部署
新机 root:
```bash
curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash
```
菜单选 **1. 一键部署**。默认路径 `/opt/crypto_okx`,端口 `5004`。
或手动:
```bash
cd /opt
git clone https://git.bz121.com/dekun/crypto_okx.git crypto_okx
cd /opt/crypto_okx
cp -n .env.example .env
bash deploy/setup_env.sh --install-system-deps
```
---
## 3. 纯实盘 `.env` 必改项
编辑:
```bash
nano /opt/crypto_okx/.env
```
### 3.1 模式:直接 live(不要 sim)
```env
# 启动即实盘(不要写成 sim)
SIM_DEFAULT_MODE=live
# 允许真实下单
LIVE_TRADING_ENABLED=true
```
说明:
- 新机不要依赖页面「切换到实盘」;部署时就把 `SIM_DEFAULT_MODE=live` 写好。
- 期权路径主要认 API Key;`LIVE_TRADING_ENABLED` 仍建议保持 `true`,与文档口径一致。
### 3.2 实盘 API(必填)
```env
OKX_API_KEY=你的实盘Key
OKX_API_SECRET=你的实盘Secret
OKX_API_PASSPHRASE=你的Passphrase
OKX_OPTIONS_ENABLED=true
EXCHANGE_DISPLAY_NAME=OKX·实盘
```
不要用演示盘 Key;本仓库未接 `x-simulated-trading`。
### 3.3 交易模式与预算(按策略改)
单独期权(常见起步):
```env
OKX_TRADE_MODE=options
OKX_OPTIONS_MARGIN_MODE=coin
OKX_OPTIONS_COIN_BUDGET_USDT=10
OKX_OPTIONS_TRADE_BUDGET_USDC=10
```
若做对冲,再改为 `perp_options` / `options_options`,并打开:
```env
HEDGE_PLAN_LIVE_ORDER=true
```
起步建议先 `HEDGE_PLAN_LIVE_ORDER=false` 或页面 dry_run,跑通单独期权后再开对冲真下单。
### 3.4 代理(按网络需要)
若服务器直连 OKX 不稳定:
```env
OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080
```
并先在本机建好 SSH SOCKS(见部署文档)。
---
## 4. 启动与确认
```bash
cd /opt/crypto_okx
pm2 restart crypto_okx --update-env
# 若尚未创建进程,用一键脚本或:
# pm2 start ecosystem.config.cjs
pm2 status
pm2 logs crypto_okx --lines 50
```
打开:`http://<新机IP>:5004`
核对:
1. 能登录。
2. 顶栏为 **实盘**(`okx_live`),**没有**「模拟」徽章。
3. 资金条能读到交易所余额(不是本地模拟钱包初始值)。
4. 系统设置 → 模拟资金:当前模式应为 **live**(勿再切回 sim)。
可用接口自检:
```bash
curl -s -u '用户名:密码' http://127.0.0.1:5004/api/sim/status
# 期望含 "mode":"live"
```
---
## 5. 小额冒烟(必做)
在真钱前用 **最小张数 / 最小预算**:
1. `/options` 开 1 张(或最小允许张数)→ 看交易所成交与本地记录。
2. 持仓出现在页面监控。
3. 平仓 → 余额变化符合预期。
4. 若币本位:确认买币桥 → 开期权 → 平 → 卖回(或按你策略保留币)。
5. `/options/review`:复盘列表有记录;可选勾选自动 K 线(公开行情,有 Key 也可画)。
任一步异常:**先停手**,查 `pm2 logs crypto_okx`,不要加大仓位。
---
## 6. 与模拟机、主仓的隔离
| 不要做 | 原因 |
|--------|------|
| 模拟机与实盘机共用同一 API Key | 双实例监控/下单互相干扰 |
| 与正在跑的 `crypto_monitor` 共用同一 Key | 同上 |
| 在实盘机再切回 sim「玩玩」 | 易混仓、混认知;模拟请继续用旧模拟机 |
| 把 `.env` 提交进 Git | 泄密 |
---
## 7. 日常更新(实盘机)
```bash
cd /opt/crypto_okx
git pull
pm2 restart crypto_okx --update-env
git log -1 --oneline
```
或:
```bash
bash /opt/crypto_okx/deploy/pull_and_restart.sh
```
`git pull` **不会**覆盖 `.env`。大版本更新前可:`cp .env .env.backup.$(date +%Y%m%d)`。
---
## 8. 故障快速对照
| 现象 | 排查 |
|------|------|
| 顶栏仍显示模拟 | `.env` 是否 `SIM_DEFAULT_MODE=live`;是否重启 `--update-env`;设置页是否被改回 sim |
| 读不到余额 / 下单失败 | Key、权限、IP 白名单、SOCKS、OKX 账户是否实盘 |
| 能开单但监控不对 | 是否与其它实例同 Key;持仓模式是否与 `OKX_POS_MODE` 一致 |
| 复盘无自动 K 线 | 服务器能否访问 OKX 公开行情;看日志是否画图失败 |
---
## 9. 推荐启动清单(可打印)
- [ ] 新机已部署 `/opt/crypto_okx`
- [ ] `SIM_DEFAULT_MODE=live`
- [ ] `LIVE_TRADING_ENABLED=true`
- [ ] 实盘 `OKX_API_*` 已填,且非演示盘
- [ ] Key 与主仓 / 模拟机隔离
- [ ] 顶栏确认 live,资金为交易所余额
- [ ] 小额开平冒烟通过
- [ ] 企业微信 / 监控按需开启
---
## 免责声明
实盘有亏损风险。请遵守当地法规与交易所条款;操作与资金安全自负。
+2
View File
@@ -4,6 +4,8 @@
**功能与页面操作**见同目录 **[使用说明.md](./使用说明.md)**.脚本细节见 **[deploy/README.md](./deploy/README.md)**. **功能与页面操作**见同目录 **[使用说明.md](./使用说明.md)**.脚本细节见 **[deploy/README.md](./deploy/README.md)**.
**新开服务器、直接接 API 跑实盘(不与模拟同机切换)**见 **[新机纯实盘部署.md](./新机纯实盘部署.md)**.
--- ---
## 一键部署(推荐) ## 一键部署(推荐)