Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a62cfbb48 | |||
| 41c85c36a9 | |||
| 06f9b23f44 | |||
| a03c208a5f | |||
| a7015ec5c8 | |||
| f5104fe68b | |||
| c5cfbb34e4 | |||
| ea58a4778e | |||
| d16821c7b7 | |||
| d6bb99b5bb |
@@ -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**.
|
||||
|
||||
详见 **[部署文档.md](./部署文档.md)**、**[deploy/README.md](./deploy/README.md)**.
|
||||
详见 **[部署文档.md](./部署文档.md)**、**[新机纯实盘部署.md](./新机纯实盘部署.md)**、**[deploy/README.md](./deploy/README.md)**.
|
||||
|
||||
## 环境要求
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
def _fetch_ohlcv_ending_at(exchange_symbol, timeframe, limit, end_ts_ms):
|
||||
lim = max(2, int(limit or ORDER_CHART_LIMIT))
|
||||
def _okx_swap_inst_id(exchange_symbol):
|
||||
"""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:
|
||||
if not end_ts_ms:
|
||||
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
|
||||
)
|
||||
params["after"] = str(int(end_ts_ms))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
raw = (exchange.public_get_market_candles(params) or {}).get("data") or []
|
||||
except Exception:
|
||||
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)
|
||||
if not rows:
|
||||
return []
|
||||
@@ -977,7 +1076,11 @@ def generate_multi_timeframe_chart_png(
|
||||
ordered.append(tf)
|
||||
timeframes = ordered[:4] if ordered else preferred_layout
|
||||
|
||||
try:
|
||||
ensure_markets_loaded()
|
||||
except Exception:
|
||||
# 模拟盘无 API Key 时 load_markets 可能失败;公开 candles 不依赖 markets
|
||||
pass
|
||||
panels = []
|
||||
cell_w, cell_h = 980, 520
|
||||
end_ts_ms = None
|
||||
@@ -1001,17 +1104,18 @@ def generate_multi_timeframe_chart_png(
|
||||
now_ms=marker_payload.get("now_ts_ms"),
|
||||
)
|
||||
if win:
|
||||
ohlcv = exchange.fetch_ohlcv(
|
||||
ohlcv = _fetch_ohlcv_safe(
|
||||
exchange_symbol,
|
||||
timeframe=tf,
|
||||
since=max(0, int(win["since_ms"])),
|
||||
tf,
|
||||
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)
|
||||
if not rows:
|
||||
ohlcv = _fetch_ohlcv_ending_at(exchange_symbol, tf, limit, 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:]
|
||||
except Exception:
|
||||
rows = []
|
||||
|
||||
@@ -1674,6 +1674,21 @@
|
||||
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) {
|
||||
const inst = p.inst_id || "";
|
||||
if (p.hedge_plan_target) {
|
||||
@@ -1694,6 +1709,7 @@
|
||||
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
|
||||
const req = p.profit_exit_required_recycle;
|
||||
const premCcy = posPremiumCcy(p);
|
||||
const hintMult = Number.isFinite(multNum) && multNum > 0 ? multNum : serverMult;
|
||||
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
|
||||
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
|
||||
return (
|
||||
@@ -1710,7 +1726,7 @@
|
||||
'<span class="opt-target-armed">' + statusTxt + "</span>" +
|
||||
'<span class="muted opt-target-row-hint">' +
|
||||
(enabled
|
||||
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy) : ""))
|
||||
? profitExitHint(hintMult, req, premCcy)
|
||||
: "开启后自选倍数;达标按买一限价平;可随时关闭") +
|
||||
"</span>" +
|
||||
"</div>"
|
||||
|
||||
@@ -582,31 +582,103 @@
|
||||
}
|
||||
}
|
||||
|
||||
var detailChartPollTimer = null;
|
||||
var detailChartPollTradeId = 0;
|
||||
|
||||
function hideDetail() {
|
||||
hideLightbox();
|
||||
stopDetailChartPoll();
|
||||
var backdrop = $("or-detail-backdrop");
|
||||
if (backdrop) backdrop.hidden = true;
|
||||
}
|
||||
|
||||
function openDetail(tradeId) {
|
||||
function stopDetailChartPoll() {
|
||||
if (detailChartPollTimer) {
|
||||
clearTimeout(detailChartPollTimer);
|
||||
detailChartPollTimer = null;
|
||||
}
|
||||
detailChartPollTradeId = 0;
|
||||
}
|
||||
|
||||
function tradeHasAutoChart(t) {
|
||||
var images = ((t && t.entry) || {}).images || [];
|
||||
for (var i = 0; i < images.length; i++) {
|
||||
var tf = String((images[i] && images[i].tf) || "").trim();
|
||||
var file = String((images[i] && images[i].file) || "").trim();
|
||||
if (tf === "auto" && file) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function openDetail(tradeId, opts) {
|
||||
opts = opts || {};
|
||||
var waitAuto = !!opts.waitAutoChart;
|
||||
var backdrop = $("or-detail-backdrop");
|
||||
if (!backdrop) return;
|
||||
stopDetailChartPoll();
|
||||
backdrop.hidden = false;
|
||||
($("or-detail-title") || {}).textContent = "加载中…";
|
||||
($("or-detail-meta") || {}).innerHTML = "";
|
||||
($("or-detail-text") || {}).innerHTML = "";
|
||||
($("or-detail-images") || {}).innerHTML = "";
|
||||
($("or-detail-images") || {}).innerHTML = waitAuto
|
||||
? '<div class="or-detail-img-cell"><span class="or-detail-img-label">自动K线</span><div class="or-detail-img-miss">生成中…</div></div>'
|
||||
: "";
|
||||
|
||||
fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
function applyTrade(trade, pendingAuto) {
|
||||
renderDetail(trade, { pendingAuto: !!pendingAuto });
|
||||
}
|
||||
|
||||
function fetchOnce() {
|
||||
return fetch("/api/options/review/trades/" + tradeId, {
|
||||
credentials: "same-origin",
|
||||
}).then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fetchOnce()
|
||||
.then(function (data) {
|
||||
if (!data.ok || !data.trade) {
|
||||
($("or-detail-title") || {}).textContent = "加载失败";
|
||||
return;
|
||||
}
|
||||
renderDetail(data.trade);
|
||||
if (!waitAuto || tradeHasAutoChart(data.trade)) {
|
||||
applyTrade(data.trade, false);
|
||||
return;
|
||||
}
|
||||
applyTrade(data.trade, true);
|
||||
detailChartPollTradeId = tradeId;
|
||||
var tries = 0;
|
||||
var maxTries = 20;
|
||||
function poll() {
|
||||
if (detailChartPollTradeId !== tradeId) return;
|
||||
tries += 1;
|
||||
fetchOnce()
|
||||
.then(function (again) {
|
||||
if (detailChartPollTradeId !== tradeId) return;
|
||||
if (again.ok && again.trade && tradeHasAutoChart(again.trade)) {
|
||||
stopDetailChartPoll();
|
||||
applyTrade(again.trade, false);
|
||||
loadReviewed({ sync: false, soft: true });
|
||||
return;
|
||||
}
|
||||
if (tries >= maxTries) {
|
||||
stopDetailChartPoll();
|
||||
applyTrade((again && again.trade) || data.trade, false);
|
||||
return;
|
||||
}
|
||||
detailChartPollTimer = setTimeout(poll, 1500);
|
||||
})
|
||||
.catch(function () {
|
||||
if (detailChartPollTradeId !== tradeId) return;
|
||||
if (tries >= maxTries) {
|
||||
stopDetailChartPoll();
|
||||
return;
|
||||
}
|
||||
detailChartPollTimer = setTimeout(poll, 1500);
|
||||
});
|
||||
}
|
||||
detailChartPollTimer = setTimeout(poll, 1200);
|
||||
})
|
||||
.catch(function () {
|
||||
($("or-detail-title") || {}).textContent = "加载失败";
|
||||
@@ -626,9 +698,29 @@
|
||||
return base + encodeURIComponent(name);
|
||||
}
|
||||
|
||||
function renderDetailImages(images) {
|
||||
function chartTfLabel(tf) {
|
||||
if (tf === "auto") return "自动K线";
|
||||
if (tf === "_") return "截图";
|
||||
return tf;
|
||||
}
|
||||
|
||||
function setChartOptionsVisible(visible) {
|
||||
var box = $("or-chart-options");
|
||||
var hint = $("or-chart-hint");
|
||||
if (box) box.classList.toggle("hidden", !visible);
|
||||
if (hint) hint.classList.toggle("hidden", !visible);
|
||||
}
|
||||
|
||||
function resetChartOptions() {
|
||||
var cb = $("or-exchange-chart");
|
||||
if (cb) cb.checked = false;
|
||||
}
|
||||
|
||||
function renderDetailImages(images, opts) {
|
||||
var imagesHost = $("or-detail-images");
|
||||
if (!imagesHost) return;
|
||||
opts = opts || {};
|
||||
var pendingAuto = !!opts.pendingAuto;
|
||||
var byTf = {};
|
||||
(images || []).forEach(function (img) {
|
||||
var tf = String((img && img.tf) || "").trim();
|
||||
@@ -637,16 +729,29 @@
|
||||
var key = tf || "_";
|
||||
byTf[key] = file;
|
||||
});
|
||||
var order = ["5m", "15m", "1h", "4h"];
|
||||
// 互斥:有自动K线只显示自动;否则只显示手动四周期
|
||||
var hasAuto = !!byTf.auto;
|
||||
if (pendingAuto && !hasAuto) {
|
||||
imagesHost.innerHTML =
|
||||
'<div class="or-detail-img-cell">' +
|
||||
'<span class="or-detail-img-label">自动K线</span>' +
|
||||
'<div class="or-detail-img-miss">生成中…</div>' +
|
||||
"</div>";
|
||||
return;
|
||||
}
|
||||
var order = hasAuto ? ["auto"] : ["5m", "15m", "1h", "4h"];
|
||||
var keys = order.slice();
|
||||
if (!hasAuto) {
|
||||
Object.keys(byTf).forEach(function (k) {
|
||||
if (k === "auto") return;
|
||||
if (keys.indexOf(k) < 0) keys.push(k);
|
||||
});
|
||||
}
|
||||
var cells = keys
|
||||
.map(function (tf) {
|
||||
var file = byTf[tf];
|
||||
if (!file) {
|
||||
if (order.indexOf(tf) < 0) return "";
|
||||
if (hasAuto || order.indexOf(tf) < 0) return "";
|
||||
return (
|
||||
'<div class="or-detail-img-cell">' +
|
||||
'<span class="or-detail-img-label">' +
|
||||
@@ -657,7 +762,7 @@
|
||||
);
|
||||
}
|
||||
var src = optionsJournalImgSrc(file);
|
||||
var label = escapeHtml(tf === "_" ? "截图" : tf);
|
||||
var label = escapeHtml(chartTfLabel(tf));
|
||||
return (
|
||||
'<div class="or-detail-img-cell">' +
|
||||
'<span class="or-detail-img-label">' +
|
||||
@@ -698,7 +803,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
function renderDetail(t) {
|
||||
function renderDetail(t, opts) {
|
||||
var e = t.entry || {};
|
||||
reviewedCache[t.id] = t;
|
||||
($("or-detail-title") || {}).textContent =
|
||||
@@ -782,7 +887,7 @@
|
||||
|
||||
var imagesHost = $("or-detail-images");
|
||||
if (imagesHost) {
|
||||
renderDetailImages(e.images || []);
|
||||
renderDetailImages(e.images || [], opts || {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -992,6 +1097,8 @@
|
||||
($("or-f-note") || {}).value = "";
|
||||
setMoodTags("");
|
||||
resetUploadSlots();
|
||||
resetChartOptions();
|
||||
setChartOptionsVisible(false);
|
||||
var summary = $("or-journal-summary");
|
||||
if (summary) {
|
||||
summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h).";
|
||||
@@ -1053,6 +1160,8 @@
|
||||
setSelectValue($("or-f-entry"), e.entry_logic || "");
|
||||
($("or-f-note") || {}).value = e.note || "";
|
||||
setMoodTags(e.mistake_tags);
|
||||
resetChartOptions();
|
||||
setChartOptionsVisible(t.source_type === "option_spot");
|
||||
|
||||
var summary = $("or-journal-summary");
|
||||
if (summary) {
|
||||
@@ -1068,6 +1177,8 @@
|
||||
}
|
||||
|
||||
(e.images || []).forEach(function (img) {
|
||||
var tf = String((img && img.tf) || "").trim();
|
||||
if (tf === "auto") return;
|
||||
var hidden = document.querySelector(
|
||||
'#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]'
|
||||
);
|
||||
@@ -1167,6 +1278,15 @@
|
||||
note: ($("or-f-note") || {}).value || "",
|
||||
images: collectImages(),
|
||||
};
|
||||
var chartBox = $("or-chart-options");
|
||||
var chartVisible = chartBox && !chartBox.classList.contains("hidden");
|
||||
if (chartVisible && ($("or-exchange-chart") || {}).checked) {
|
||||
payload.journal_exchange_chart = true;
|
||||
payload.journal_chart_tf1 = ($("or-chart-tf1") || {}).value || "";
|
||||
payload.journal_chart_tf2 = ($("or-chart-tf2") || {}).value || "";
|
||||
payload.journal_chart_limit = ($("or-chart-limit") || {}).value || "";
|
||||
payload.journal_chart_anchor = ($("or-chart-anchor") || {}).value || "close";
|
||||
}
|
||||
($("or-save-status") || {}).textContent = "保存中…";
|
||||
fetch("/api/options/review/entry", {
|
||||
method: "POST",
|
||||
@@ -1182,10 +1302,12 @@
|
||||
($("or-save-status") || {}).textContent = data.msg || "保存失败";
|
||||
return;
|
||||
}
|
||||
($("or-save-status") || {}).textContent = "已保存";
|
||||
($("or-save-status") || {}).textContent = data.chart_queued
|
||||
? "已保存,K线图后台生成中"
|
||||
: "已保存";
|
||||
hideJournalForm();
|
||||
reloadAll();
|
||||
openDetail(tradeId);
|
||||
openDetail(tradeId, { waitAutoChart: !!data.chart_queued });
|
||||
})
|
||||
.catch(function () {
|
||||
($("or-save-status") || {}).textContent = "保存失败";
|
||||
|
||||
@@ -213,16 +213,10 @@ def option_history_row_key(
|
||||
|
||||
|
||||
def _ms_to_iso(ms: Any) -> str | None:
|
||||
val = _safe_float(ms)
|
||||
if val is None or val <= 0:
|
||||
return None
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
"""OKX ms → 业务时区墙钟(默认 Asia/Shanghai);勿用系统本地时区."""
|
||||
from lib.options.options_time_lib import ms_to_options_app_str
|
||||
|
||||
dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone()
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (TypeError, ValueError, OSError):
|
||||
return None
|
||||
return ms_to_options_app_str(ms)
|
||||
|
||||
|
||||
def option_instrument_meta_cached(
|
||||
@@ -1601,6 +1595,9 @@ def resolve_option_close_from_history(
|
||||
u_i, row = item
|
||||
c_ms = _safe_float(row.get("cTime"))
|
||||
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:
|
||||
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))))
|
||||
if close_ms is not None:
|
||||
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))
|
||||
|
||||
@@ -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 "-"
|
||||
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")
|
||||
# 看板期权列:优先买一净盈亏,残档回退交易所 upl
|
||||
# 看板期权列:统一折成 U(币本位 BTC/ETH 权利金盈亏 × 指数)
|
||||
pnl = None
|
||||
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:
|
||||
pnl = None
|
||||
pos = _safe_float(p.get("pos"))
|
||||
|
||||
@@ -306,7 +306,8 @@ def close_option_by_bid1(
|
||||
|
||||
px = float(order.get("px", locked_bid_px))
|
||||
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)
|
||||
invalidate_option_positions_cache()
|
||||
raw2 = cfg["fetch_option_positions"](ex)
|
||||
@@ -325,14 +326,47 @@ def close_option_by_bid1(
|
||||
reduced = max(0, before_avail - after_avail)
|
||||
remaining_pos = after_avail
|
||||
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:
|
||||
clear_close_gate(inst_id)
|
||||
conn = cfg["get_db"]()
|
||||
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)
|
||||
# 先并入本批,再读累计(含此前分批)
|
||||
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(
|
||||
"""
|
||||
SELECT id, premium_paid FROM options_trades
|
||||
@@ -346,16 +380,25 @@ def close_option_by_bid1(
|
||||
for i, row in enumerate(open_rows):
|
||||
paid = float(row["premium_paid"] or 0)
|
||||
if i == len(open_rows) - 1:
|
||||
recv = round(prem_recv - allocated, 4)
|
||||
recv = round(total_recv - allocated, 4)
|
||||
elif total_paid > 0:
|
||||
recv = round(prem_recv * (paid / total_paid), 4)
|
||||
recv = round(total_recv * (paid / total_paid), 4)
|
||||
allocated += recv
|
||||
else:
|
||||
recv = round(prem_recv / len(open_rows), 4)
|
||||
recv = round(total_recv / len(open_rows), 4)
|
||||
allocated += recv
|
||||
pnl = round(recv - paid, 4)
|
||||
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:
|
||||
note_sql = """,
|
||||
signal_note = CASE
|
||||
@@ -368,7 +411,9 @@ def close_option_by_bid1(
|
||||
f"""
|
||||
UPDATE options_trades
|
||||
SET status = 'closed', close_quote = ?, premium_received = ?,
|
||||
realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP
|
||||
realized_pnl = ?, close_ord_id = ?, closed_at = ?,
|
||||
close_recv_accum = 0, close_sheets_accum = 0,
|
||||
close_notional_accum = 0
|
||||
{note_sql}
|
||||
WHERE id = ?
|
||||
""",
|
||||
@@ -377,6 +422,27 @@ def close_option_by_bid1(
|
||||
conn.commit()
|
||||
finally:
|
||||
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:
|
||||
# 自动平已挂过单:同仓续批只验流动性
|
||||
mark_close_gate_passed(inst_id)
|
||||
@@ -388,11 +454,12 @@ def close_option_by_bid1(
|
||||
"bid": px,
|
||||
"locked_bid_px": locked_bid_px,
|
||||
"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,
|
||||
"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"),
|
||||
"close_ord_id": oid or None,
|
||||
"close_ord_id": merged_ord if fully_closed else (oid or None),
|
||||
"fully_closed": fully_closed,
|
||||
"msg": (
|
||||
f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张"
|
||||
|
||||
@@ -400,6 +400,8 @@ def open_coin_option_buy_full(
|
||||
|
||||
|
||||
def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
|
||||
from lib.options.options_time_lib import options_app_now_str
|
||||
|
||||
pe = 1 if kwargs.get("profit_exit_enabled") else 0
|
||||
pe_mult = float(kwargs.get("profit_exit_mult") or 1.0)
|
||||
pe_state = "active" if pe else "idle"
|
||||
@@ -409,8 +411,8 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
|
||||
inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, signal_note, exchange_ord_id,
|
||||
margin_mode, premium_ccy, bridge_id, budget_usdt,
|
||||
profit_exit_enabled, profit_exit_mult, profit_exit_state
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?)
|
||||
profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
kwargs["inst_id"],
|
||||
@@ -430,6 +432,7 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
|
||||
pe,
|
||||
pe_mult,
|
||||
pe_state,
|
||||
options_app_now_str(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -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 bridge_id INTEGER",
|
||||
"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:
|
||||
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:
|
||||
return None
|
||||
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
|
||||
|
||||
@@ -85,13 +85,13 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
conn.close()
|
||||
except Exception:
|
||||
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
|
||||
has_upl = False
|
||||
for p in positions:
|
||||
# 与持仓卡展示一致:优先买一净盈亏,残档回退交易所 upl
|
||||
pnl = display_pnl_from_option_row(p)
|
||||
# 合计一律折 U:币本位 × 指数,避免 BTC/ETH 币数量与 USDC 混加
|
||||
pnl = display_pnl_usdt_from_option_row(p)
|
||||
if pnl is None:
|
||||
continue
|
||||
has_upl = True
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
|
||||
|
||||
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
|
||||
from lib.options.options_time_lib import ms_to_options_app_str, options_app_now_str, options_wall_clock_to_ms
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
@@ -151,18 +147,7 @@ def run_options_profit_alerts(
|
||||
|
||||
def _created_at_ms(created_at: Any) -> int | None:
|
||||
"""墙钟 created_at → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
|
||||
if not created_at:
|
||||
return None
|
||||
raw = str(created_at).strip()
|
||||
if not raw:
|
||||
return None
|
||||
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
|
||||
try:
|
||||
dt = datetime.strptime(raw[:ln], fmt).replace(tzinfo=_APP_TZ)
|
||||
return int(dt.timestamp() * 1000)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
return options_wall_clock_to_ms(created_at)
|
||||
|
||||
|
||||
def _group_key_for_closed_trade(row: Any) -> str:
|
||||
@@ -327,21 +312,20 @@ def sync_open_options_trades(
|
||||
prem_recv = round(paid + float(realized_pnl), 4)
|
||||
close_ms = close_info.get("close_ms")
|
||||
if close_ms:
|
||||
closed_at = datetime.fromtimestamp(int(close_ms) / 1000, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
closed_at = ms_to_options_app_str(close_ms)
|
||||
elif exp_ms is not None and now_ms >= int(exp_ms):
|
||||
close_reason = "expired"
|
||||
close_quote = 0.0
|
||||
prem_recv = 0.0
|
||||
realized_pnl = round(-paid, 4)
|
||||
if exp_ms:
|
||||
closed_at = datetime.fromtimestamp(int(exp_ms) / 1000, tz=timezone.utc).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
closed_at = ms_to_options_app_str(exp_ms)
|
||||
else:
|
||||
continue
|
||||
|
||||
if not closed_at:
|
||||
closed_at = options_app_now_str()
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE options_trades
|
||||
@@ -350,7 +334,7 @@ def sync_open_options_trades(
|
||||
premium_received = ?,
|
||||
realized_pnl = ?,
|
||||
close_ord_id = COALESCE(?, close_ord_id),
|
||||
closed_at = COALESCE(?, closed_at, CURRENT_TIMESTAMP),
|
||||
closed_at = COALESCE(?, closed_at),
|
||||
signal_note = CASE
|
||||
WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '')
|
||||
THEN '到期结算'
|
||||
|
||||
@@ -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:
|
||||
"""顶栏「实时盈亏」用:币本位净盈亏按指数折 USDT;USDC 仓原样."""
|
||||
"""顶栏/看板「实时盈亏」用:币本位净盈亏按指数折 USDT;USDC 仓原样.
|
||||
|
||||
BTC/ETH 币本位若不折算,会被界面标成 U 而数值仍是币数量(BTC 尤为离谱).
|
||||
"""
|
||||
pnl = display_pnl_from_option_row(row)
|
||||
if pnl is None:
|
||||
return None
|
||||
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"):
|
||||
idx = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
|
||||
if idx is None or idx <= 0:
|
||||
|
||||
@@ -211,13 +211,34 @@ def _estimate_recycle(
|
||||
pos: dict[str, Any],
|
||||
premium_paid: float | None,
|
||||
) -> float | None:
|
||||
"""翻倍出场用可回收估算.
|
||||
|
||||
用买一价 × 全部持仓张数(不吃深度上限):深度不够时仍应触发分批平仓;
|
||||
残档/无效买一则返回 None(禁止按假买盘自动平).
|
||||
"""
|
||||
from lib.options.options_positions_lib import attach_close_preview
|
||||
from lib.options.options_pricing_lib import total_premium
|
||||
|
||||
row = dict(pos)
|
||||
attach_close_preview(cfg, ex, row, premium_paid=premium_paid)
|
||||
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
|
||||
if preview.get("bid_invalid"):
|
||||
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"))
|
||||
|
||||
|
||||
@@ -296,7 +317,8 @@ def run_options_profit_exits(
|
||||
init_hedge_plan_tables(conn)
|
||||
hedge_managed = active_hedge_option_inst_ids(conn)
|
||||
except Exception:
|
||||
return 0
|
||||
# 对冲表异常时不得整段跳过翻倍出场
|
||||
hedge_managed = set()
|
||||
|
||||
rules = profit_exit_by_inst(conn)
|
||||
triggered = 0
|
||||
|
||||
@@ -1218,13 +1218,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
opt_type = meta.get("optType")
|
||||
open_underlying = u
|
||||
open_opt_type = opt_type
|
||||
from lib.options.options_time_lib import options_app_now_str
|
||||
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO options_trades
|
||||
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||
open_quote, premium_paid, status, signal_note, exchange_ord_id,
|
||||
profit_exit_enabled, profit_exit_mult, profit_exit_state)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?)
|
||||
profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
inst_id,
|
||||
@@ -1241,6 +1243,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
1 if profit_exit_enabled else 0,
|
||||
profit_exit_mult if profit_exit_enabled else 1.0,
|
||||
"active" if profit_exit_enabled else "idle",
|
||||
options_app_now_str(),
|
||||
),
|
||||
)
|
||||
trade_id = int(cur.lastrowid)
|
||||
@@ -1341,6 +1344,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
try:
|
||||
conn = cfg["get_db"]()
|
||||
try:
|
||||
from lib.options.options_time_lib import options_app_now_str
|
||||
|
||||
init_options_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -1350,10 +1355,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||
WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销'
|
||||
ELSE signal_note
|
||||
END,
|
||||
closed_at = CURRENT_TIMESTAMP
|
||||
closed_at = ?
|
||||
WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open'
|
||||
""",
|
||||
(inst_id, ord_id),
|
||||
(options_app_now_str(), inst_id, ord_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""期权复盘自动 K 线:后台线程生成标的永续双周期拼图,写回 images_json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.options.options_review_images_lib import (
|
||||
images_json_dumps,
|
||||
parse_options_review_images_json,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPTIONS_REVIEW_AUTO_CHART_TF = "auto"
|
||||
|
||||
|
||||
def underlying_to_okx_perp_symbol(
|
||||
underlying: str,
|
||||
*,
|
||||
normalize_symbol_input_fn: Callable[[str], str],
|
||||
normalize_okx_symbol_fn: Callable[[str], str],
|
||||
) -> str:
|
||||
"""ETH / ETH-USD / ETH-USDT-SWAP → ETH/USDT:USDT."""
|
||||
raw = str(underlying or "").strip().upper()
|
||||
if not raw:
|
||||
return ""
|
||||
base = raw.split("-", 1)[0].split("/", 1)[0].strip()
|
||||
if not base:
|
||||
return ""
|
||||
guess = normalize_symbol_input_fn(base) or f"{base}/USDT"
|
||||
return str(normalize_okx_symbol_fn(guess) or "").strip()
|
||||
|
||||
|
||||
def merge_auto_chart_into_images(
|
||||
existing: Optional[list],
|
||||
filename: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""保留手动槽位,用 tf=auto 放置拼图(方案甲一张总图)."""
|
||||
fname = str(filename or "").strip()
|
||||
out: list[dict[str, str]] = []
|
||||
for item in existing or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
tf = str(item.get("tf") or "").strip()
|
||||
file = str(item.get("file") or "").strip()
|
||||
if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF:
|
||||
continue
|
||||
out.append({"tf": tf, "file": file})
|
||||
if fname:
|
||||
out.insert(0, {"tf": OPTIONS_REVIEW_AUTO_CHART_TF, "file": fname})
|
||||
return out
|
||||
|
||||
|
||||
def build_options_review_chart_filename(trade_id: int) -> str:
|
||||
return f"options_journal_chart_{int(trade_id)}.png"
|
||||
|
||||
|
||||
def schedule_options_review_exchange_chart(
|
||||
*,
|
||||
trade_id: int,
|
||||
exchange_symbol: str,
|
||||
title_prefix: str,
|
||||
journal_tfs: list[str],
|
||||
journal_limit: int,
|
||||
marker_payload: dict[str, Any],
|
||||
upload_folder: str,
|
||||
generate_chart_fn: Callable[..., str | None],
|
||||
get_db_fn: Callable[[], Any],
|
||||
now_str_fn: Callable[[], str],
|
||||
) -> None:
|
||||
"""保存后立即返回;线程内画图并合并进 options_review_entries.images_json."""
|
||||
tid = int(trade_id or 0)
|
||||
if tid <= 0 or not callable(generate_chart_fn) or not callable(get_db_fn):
|
||||
return
|
||||
symbol = str(exchange_symbol or "").strip()
|
||||
if not symbol:
|
||||
return
|
||||
tfs = [str(x).strip() for x in (journal_tfs or []) if str(x).strip()]
|
||||
if not tfs:
|
||||
return
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
chart_fname = build_options_review_chart_filename(tid)
|
||||
saved = generate_chart_fn(
|
||||
symbol,
|
||||
title_prefix,
|
||||
timeframes=tfs,
|
||||
limit=journal_limit,
|
||||
out_dir=upload_folder,
|
||||
filename=chart_fname,
|
||||
filename_prefix="options_journal_chart",
|
||||
marker_payload=marker_payload,
|
||||
marker_timeframes={x.lower() for x in tfs},
|
||||
layout="vertical",
|
||||
)
|
||||
if not saved:
|
||||
logger.warning("options review chart async empty trade_id=%s", tid)
|
||||
return
|
||||
conn = get_db_fn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT images_json FROM options_review_entries WHERE trade_id=?",
|
||||
(tid,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
logger.warning(
|
||||
"options review chart async missing entry trade_id=%s", tid
|
||||
)
|
||||
return
|
||||
images = parse_options_review_images_json(row["images_json"])
|
||||
merged = merge_auto_chart_into_images(images, saved)
|
||||
primary = merged[0]["file"] if merged else saved
|
||||
conn.execute(
|
||||
"""UPDATE options_review_entries
|
||||
SET images_json=?, image=?, updated_at=?
|
||||
WHERE trade_id=?""",
|
||||
(
|
||||
images_json_dumps(merged),
|
||||
primary,
|
||||
now_str_fn(),
|
||||
tid,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
logger.exception("options review chart async failed trade_id=%s", tid)
|
||||
|
||||
threading.Thread(
|
||||
target=_run,
|
||||
name=f"options-review-chart-{tid}",
|
||||
daemon=True,
|
||||
).start()
|
||||
@@ -643,6 +643,13 @@ def ensure_local_review_synced(
|
||||
backfill_exchange_pnl: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""列表/统计前轻量刷新本地源;有交易所时先用历史仓位盈亏覆盖本地再导入复盘."""
|
||||
from lib.options.options_time_lib import (
|
||||
migrate_options_utc_wall_clock_to_app,
|
||||
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:
|
||||
try:
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
return sync_all_review_sources(conn, ex=ex, from_exchange=False)
|
||||
out = sync_all_review_sources(conn, ex=ex, from_exchange=False)
|
||||
if isinstance(out, dict):
|
||||
out["ts_migrate"] = ts_migrate
|
||||
out["note_swap"] = note_swap
|
||||
return out
|
||||
|
||||
|
||||
def _row_to_dict(row: Any) -> dict[str, Any]:
|
||||
|
||||
@@ -93,6 +93,141 @@ def _require_ex(cfg: dict[str, Any]):
|
||||
return ex, ""
|
||||
|
||||
|
||||
def _truthy(raw: Any) -> bool:
|
||||
return str(raw or "").strip().lower() in ("1", "true", "on", "yes")
|
||||
|
||||
|
||||
def _prepare_review_entry_images(
|
||||
conn: Any,
|
||||
trade_id: int,
|
||||
data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""客户端只提交手动槽位;未重新勾选自动K线时保留已有 auto 附图."""
|
||||
from lib.options.options_review_chart_async_lib import OPTIONS_REVIEW_AUTO_CHART_TF
|
||||
from lib.options.options_review_images_lib import parse_options_review_images_json
|
||||
|
||||
images = data.get("images") if isinstance(data.get("images"), list) else []
|
||||
cleaned: list[dict[str, str]] = []
|
||||
for item in images:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
tf = str(item.get("tf") or "").strip()
|
||||
file = str(item.get("file") or "").strip()
|
||||
if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF:
|
||||
continue
|
||||
cleaned.append({"tf": tf, "file": file})
|
||||
|
||||
want_chart = _truthy(data.get("journal_exchange_chart")) and not cleaned
|
||||
if not want_chart:
|
||||
row = conn.execute(
|
||||
"SELECT images_json FROM options_review_entries WHERE trade_id=?",
|
||||
(int(trade_id),),
|
||||
).fetchone()
|
||||
if row:
|
||||
for item in parse_options_review_images_json(row["images_json"]):
|
||||
if str(item.get("tf") or "").strip() == OPTIONS_REVIEW_AUTO_CHART_TF:
|
||||
cleaned.insert(0, dict(item))
|
||||
break
|
||||
|
||||
out = dict(data)
|
||||
out["images"] = cleaned
|
||||
return out
|
||||
|
||||
|
||||
def _maybe_schedule_options_review_chart(
|
||||
cfg: dict[str, Any],
|
||||
trade: dict[str, Any],
|
||||
data: dict[str, Any],
|
||||
) -> bool:
|
||||
"""纯期权 + 无手动截图 + 勾选自动K线 → 后台生成标的永续双周期拼图(只钉进/出场)."""
|
||||
if str(trade.get("source_type") or "").strip() != "option_spot":
|
||||
return False
|
||||
images = data.get("images") if isinstance(data.get("images"), list) else []
|
||||
has_manual = any(str((x or {}).get("file") or "").strip() for x in images)
|
||||
if has_manual or not _truthy(data.get("journal_exchange_chart")):
|
||||
return False
|
||||
|
||||
app_module = cfg.get("app_module")
|
||||
if app_module is None:
|
||||
return False
|
||||
if not bool(getattr(app_module, "ORDER_CHART_ENABLED", True)):
|
||||
return False
|
||||
generate_fn = getattr(app_module, "generate_multi_timeframe_chart_png", None)
|
||||
if not callable(generate_fn):
|
||||
return False
|
||||
|
||||
from lib.instance.journal_chart_lib import (
|
||||
parse_journal_chart_anchor,
|
||||
parse_journal_chart_limit,
|
||||
parse_journal_chart_timeframes,
|
||||
)
|
||||
from lib.options.options_review_chart_async_lib import (
|
||||
schedule_options_review_exchange_chart,
|
||||
underlying_to_okx_perp_symbol,
|
||||
)
|
||||
from lib.options.options_review_images_lib import options_review_upload_dir
|
||||
from lib.options.options_review_lib import _now_str
|
||||
|
||||
fallback_tfs = list(getattr(app_module, "ORDER_CHART_TFS", None) or [])[:2]
|
||||
journal_tfs = parse_journal_chart_timeframes(
|
||||
data.get("journal_chart_tf1"),
|
||||
data.get("journal_chart_tf2"),
|
||||
fallback_tfs or None,
|
||||
)
|
||||
journal_limit = parse_journal_chart_limit(
|
||||
data.get("journal_chart_limit"),
|
||||
int(getattr(app_module, "ORDER_CHART_LIMIT", 100) or 100),
|
||||
)
|
||||
chart_anchor = parse_journal_chart_anchor(data.get("journal_chart_anchor"))
|
||||
|
||||
underlying = str(trade.get("underlying") or data.get("underlying") or "").strip()
|
||||
exchange_symbol = underlying_to_okx_perp_symbol(
|
||||
underlying,
|
||||
normalize_symbol_input_fn=app_module.normalize_symbol_input,
|
||||
normalize_okx_symbol_fn=app_module.normalize_okx_symbol,
|
||||
)
|
||||
if not exchange_symbol:
|
||||
return False
|
||||
|
||||
to_ms = getattr(app_module, "_local_input_datetime_to_ms", None)
|
||||
if not callable(to_ms):
|
||||
return False
|
||||
entry_ts = to_ms(trade.get("opened_at"))
|
||||
exit_ts = to_ms(trade.get("closed_at"))
|
||||
if entry_ts is None and exit_ts is None:
|
||||
return False
|
||||
|
||||
now_ms = None
|
||||
app_now = getattr(app_module, "app_now", None)
|
||||
if callable(app_now):
|
||||
try:
|
||||
now_ms = int(app_now().timestamp() * 1000)
|
||||
except Exception:
|
||||
now_ms = None
|
||||
|
||||
inst = str(trade.get("inst_id") or underlying or f"#{trade.get('id') or ''}").strip()
|
||||
upload_folder = options_review_upload_dir(cfg["upload_folder"])
|
||||
schedule_options_review_exchange_chart(
|
||||
trade_id=int(trade["id"]),
|
||||
exchange_symbol=exchange_symbol,
|
||||
title_prefix=f"{underlying or 'OPT'} {inst} review",
|
||||
journal_tfs=journal_tfs,
|
||||
journal_limit=journal_limit,
|
||||
marker_payload={
|
||||
"entry_ts_ms": entry_ts,
|
||||
"exit_ts_ms": exit_ts,
|
||||
# 只按时间钉进/出场,不传价格与止损
|
||||
"chart_anchor": chart_anchor,
|
||||
"now_ts_ms": now_ms,
|
||||
},
|
||||
upload_folder=upload_folder,
|
||||
generate_chart_fn=generate_fn,
|
||||
get_db_fn=cfg["get_db"],
|
||||
now_str_fn=_now_str,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None:
|
||||
lr = cfg["login_required"]
|
||||
|
||||
@@ -216,9 +351,16 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
|
||||
return jsonify({"ok": False, "msg": "trade_id 无效"}), 400
|
||||
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()
|
||||
|
||||
@@ -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>
|
||||
<p class="sub journal-upload-hint">可只传部分周期;选文件后即时上传</p>
|
||||
|
||||
<div class="form-row or-chart-options journal-chart-options hidden" id="or-chart-options" style="flex-wrap:wrap;align-items:center">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem">
|
||||
<input type="checkbox" id="or-exchange-chart" value="true">
|
||||
保存后后台生成 K 线图并作为截图(不阻塞保存)
|
||||
</label>
|
||||
<label style="font-size:.82rem;color:#9aa">周期1</label>
|
||||
<select id="or-chart-tf1" style="min-width:72px">
|
||||
{% for tf in journal_chart_tf_choices %}
|
||||
<option value="{{ tf }}" {% if tf == journal_chart_default_tf1 %}selected{% endif %}>{{ tf }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label style="font-size:.82rem;color:#9aa">周期2</label>
|
||||
<select id="or-chart-tf2" style="min-width:72px">
|
||||
{% for tf in journal_chart_tf_choices %}
|
||||
<option value="{{ tf }}" {% if tf == journal_chart_default_tf2 %}selected{% endif %}>{{ tf }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label style="font-size:.82rem;color:#9aa">K线数</label>
|
||||
<select id="or-chart-limit" style="min-width:72px">
|
||||
{% for n in [100, 150, 200, 250, 300, 400, 500] %}
|
||||
<option value="{{ n }}" {% if n == journal_chart_default_limit %}selected{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label style="font-size:.82rem;color:#9aa">K线截止</label>
|
||||
<select id="or-chart-anchor" style="min-width:96px" title="K线窗口右端对齐的时间">
|
||||
<option value="close" {% if journal_chart_default_anchor == 'close' %}selected{% endif %}>平仓时间</option>
|
||||
<option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sub or-chart-hint hidden" id="or-chart-hint" style="font-size:.72rem;margin-top:2px;margin-bottom:0">纯期权:按标的永续画双周期上下图;只按时间标记进场/出场,不标止损;有手动截图时不自动生成</div>
|
||||
|
||||
<div class="or-mood-grid mood-grid">
|
||||
<label><input type="checkbox" class="or-mood" value="怕踏空">怕踏空</label>
|
||||
<label><input type="checkbox" class="or-mood" value="报复开仓">报复开仓</label>
|
||||
|
||||
+213
-1
@@ -12,7 +12,19 @@ from lib.sim.wallets_lib import InsufficientFunds, SimWallets
|
||||
|
||||
|
||||
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]:
|
||||
@@ -890,6 +902,200 @@ class SimBroker:
|
||||
rows.append(row)
|
||||
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(
|
||||
self,
|
||||
exchange: Any = None,
|
||||
@@ -901,6 +1107,12 @@ class SimBroker:
|
||||
|
||||
实盘 positions-history 在模拟模式不可用,期权历史/复盘依赖此合成数据.
|
||||
"""
|
||||
# 先按卖平成交修复分批漏计,再合成展示
|
||||
try:
|
||||
self.repair_closed_options_pnl_from_sim_orders(limit=max(int(limit), 200))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
基于 Flask 的 **OKX 期权 / 对冲** Web 控制台.与中控、其它交易所实例无关.
|
||||
|
||||
**部署 / PM2 / SOCKS** 见 **[部署文档.md](./部署文档.md)**:
|
||||
**部署 / PM2 / SOCKS** 见 **[部署文档.md](./部署文档.md)**;**新机纯实盘**见 **[新机纯实盘部署.md](./新机纯实盘部署.md)**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash
|
||||
|
||||
+215
@@ -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,资金为交易所余额
|
||||
- [ ] 小额开平冒烟通过
|
||||
- [ ] 企业微信 / 监控按需开启
|
||||
|
||||
---
|
||||
|
||||
## 免责声明
|
||||
|
||||
实盘有亏损风险。请遵守当地法规与交易所条款;操作与资金安全自负。
|
||||
Reference in New Issue
Block a user