Improve OKX options sizing, expiry sync, and multi-position accordion UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3254,6 +3254,102 @@ html[data-theme="light"] .options-estimate-row {
|
|||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
.opt-pos-cards--accordion {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.opt-pos-accordion-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.opt-pos-bar {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #141923;
|
||||||
|
border: 1px solid #2a3348;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.opt-pos-bar:hover {
|
||||||
|
border-color: #3d4d6e;
|
||||||
|
background: #171d2a;
|
||||||
|
}
|
||||||
|
.opt-pos-accordion-item.is-expanded .opt-pos-bar {
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
background: #171d2a;
|
||||||
|
}
|
||||||
|
.opt-pos-bar-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.opt-pos-bar-side {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.opt-pos-bar-title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 11rem;
|
||||||
|
}
|
||||||
|
.opt-pos-bar-meta {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
color: #8b95b0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.opt-pos-bar-cd {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
color: #8b95b0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.opt-pos-bar-pnl,
|
||||||
|
.opt-pos-bar-roi {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.opt-pos-bar-chevron {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
color: #8b95b0;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.opt-pos-accordion-item.is-expanded .opt-pos-bar-chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.opt-pos-accordion-body {
|
||||||
|
border: 1px solid #2a3348;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #141923;
|
||||||
|
}
|
||||||
|
.opt-pos-card--nested {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
.options-page-wrap .opt-pos-card .pos-card-symbol strong {
|
.options-page-wrap .opt-pos-card .pos-card-symbol strong {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
@@ -3441,6 +3537,29 @@ html[data-theme="light"] .options-settings-subtitle {
|
|||||||
color: #142232 !important;
|
color: #142232 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-theme="light"] .opt-pos-bar {
|
||||||
|
background: #f6f9fc;
|
||||||
|
border-color: #c8d4e0;
|
||||||
|
color: #142232;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .opt-pos-bar:hover,
|
||||||
|
html[data-theme="light"] .opt-pos-accordion-item.is-expanded .opt-pos-bar {
|
||||||
|
background: #eef3f8;
|
||||||
|
border-color: #9eb0c4;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .opt-pos-bar-title {
|
||||||
|
color: #142232;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .opt-pos-bar-meta,
|
||||||
|
html[data-theme="light"] .opt-pos-bar-cd,
|
||||||
|
html[data-theme="light"] .opt-pos-bar-chevron {
|
||||||
|
color: #5a6d82;
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .opt-pos-accordion-body {
|
||||||
|
background: #f6f9fc;
|
||||||
|
border-color: #c8d4e0;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme="light"] .options-page-wrap .options-hint,
|
html[data-theme="light"] .options-page-wrap .options-hint,
|
||||||
html[data-theme="light"] .options-page-wrap #opt-index-line,
|
html[data-theme="light"] .options-page-wrap #opt-index-line,
|
||||||
html[data-theme="light"] .options-order-grid .k,
|
html[data-theme="light"] .options-order-grid .k,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
chain: panelCache.chain || null,
|
chain: panelCache.chain || null,
|
||||||
selectedInst: null,
|
selectedInst: null,
|
||||||
orderQuote: null,
|
orderQuote: null,
|
||||||
|
expandedPosInst: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function fmt(v, d) {
|
function fmt(v, d) {
|
||||||
@@ -427,14 +428,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPositionCard(p) {
|
function renderPositionCardInner(p) {
|
||||||
const upl = p.upl;
|
const upl = p.upl;
|
||||||
const uplCls = upl > 0 ? "pos-pnl-profit" : upl < 0 ? "pos-pnl-loss" : "";
|
const uplCls = upl > 0 ? "pos-pnl-profit" : upl < 0 ? "pos-pnl-loss" : "";
|
||||||
const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long";
|
const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long";
|
||||||
const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time;
|
const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time;
|
||||||
const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
|
const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
|
||||||
return (
|
return (
|
||||||
'<div class="pos-card opt-pos-card" data-inst="' + (p.inst_id || "") + '">' +
|
|
||||||
'<div class="pos-card-head">' +
|
'<div class="pos-card-head">' +
|
||||||
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' +
|
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' +
|
||||||
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span></div>" +
|
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span></div>" +
|
||||||
@@ -458,10 +458,77 @@
|
|||||||
'<div class="pos-cell"><span class="pos-label">浮盈亏</span><span class="pos-value ' + uplCls + '">' + fmt(p.upl, 2) + "</span></div>" +
|
'<div class="pos-cell"><span class="pos-label">浮盈亏</span><span class="pos-value ' + uplCls + '">' + fmt(p.upl, 2) + "</span></div>" +
|
||||||
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
|
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
|
||||||
(p.upl_ratio_pct != null ? p.upl_ratio_pct + "%" : "—") + "</span></div>" +
|
(p.upl_ratio_pct != null ? p.upl_ratio_pct + "%" : "—") + "</span></div>" +
|
||||||
"</div></div>"
|
"</div>"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPositionCard(p) {
|
||||||
|
return (
|
||||||
|
'<div class="pos-card opt-pos-card" data-inst="' + (p.inst_id || "") + '">' +
|
||||||
|
renderPositionCardInner(p) +
|
||||||
|
"</div>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPositionAccordionItem(p, expanded) {
|
||||||
|
const upl = p.upl;
|
||||||
|
const uplCls = upl > 0 ? "pos-pnl-profit" : upl < 0 ? "pos-pnl-loss" : "";
|
||||||
|
const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long";
|
||||||
|
const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time;
|
||||||
|
const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
|
||||||
|
const inst = p.inst_id || "";
|
||||||
|
return (
|
||||||
|
'<div class="opt-pos-accordion-item' + (expanded ? " is-expanded" : "") + '" data-inst="' + inst + '">' +
|
||||||
|
'<button type="button" class="opt-pos-bar" aria-expanded="' + (expanded ? "true" : "false") + '">' +
|
||||||
|
'<span class="opt-pos-bar-main">' +
|
||||||
|
'<span class="opt-pos-bar-chevron" aria-hidden="true">▶</span>' +
|
||||||
|
'<strong class="opt-pos-bar-title">' + inst + "</strong>" +
|
||||||
|
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span>" +
|
||||||
|
'<span class="opt-pos-bar-meta">行权 ' + fmt(p.strike, 0) + " · " + fmt(p.pos, 0) + "张</span>" +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="opt-pos-bar-side">' +
|
||||||
|
(expAttr
|
||||||
|
? '<span class="opt-pos-bar-cd">到期 <span class="opt-expiry-cd" data-opt-exp-ms="' + expAttr + '">—</span></span>'
|
||||||
|
: "") +
|
||||||
|
'<span class="opt-pos-bar-pnl ' + uplCls + '">' + fmt(p.upl, 2) + " USDC</span>" +
|
||||||
|
'<span class="opt-pos-bar-roi ' + uplCls + '">' +
|
||||||
|
(p.upl_ratio_pct != null ? p.upl_ratio_pct + "%" : "—") + "</span>" +
|
||||||
|
"</span>" +
|
||||||
|
"</button>" +
|
||||||
|
'<div class="opt-pos-accordion-body"' + (expanded ? "" : ' hidden') + ">" +
|
||||||
|
'<div class="pos-card opt-pos-card opt-pos-card--nested" data-inst="' + inst + '">' +
|
||||||
|
renderPositionCardInner(p) +
|
||||||
|
"</div></div></div>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindPositionActions(container) {
|
||||||
|
if (!container) return;
|
||||||
|
container.querySelectorAll(".opt-close-btn").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
closePosition(btn.getAttribute("data-inst"), btn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
container.querySelectorAll(".opt-pos-bar").forEach(function (bar) {
|
||||||
|
bar.addEventListener("click", function () {
|
||||||
|
const item = bar.closest(".opt-pos-accordion-item");
|
||||||
|
if (!item) return;
|
||||||
|
const inst = item.getAttribute("data-inst");
|
||||||
|
state.expandedPosInst = state.expandedPosInst === inst ? null : inst;
|
||||||
|
const wrap = document.getElementById("opt-pos-cards");
|
||||||
|
wrap.querySelectorAll(".opt-pos-accordion-item").forEach(function (el) {
|
||||||
|
const open = el.getAttribute("data-inst") === state.expandedPosInst;
|
||||||
|
el.classList.toggle("is-expanded", open);
|
||||||
|
const body = el.querySelector(".opt-pos-accordion-body");
|
||||||
|
const btn = el.querySelector(".opt-pos-bar");
|
||||||
|
if (body) body.hidden = !open;
|
||||||
|
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function closePosition(inst, btn) {
|
async function closePosition(inst, btn) {
|
||||||
const q = await apiJson("/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=sheets&sheets=1");
|
const q = await apiJson("/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=sheets&sheets=1");
|
||||||
if (!q.ok) {
|
if (!q.ok) {
|
||||||
@@ -504,16 +571,27 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
empty.style.display = "none";
|
empty.style.display = "none";
|
||||||
list.forEach(function (p) {
|
const multi = list.length >= 2;
|
||||||
const div = document.createElement("div");
|
wrap.classList.toggle("opt-pos-cards--accordion", multi);
|
||||||
div.innerHTML = renderPositionCard(p);
|
if (multi) {
|
||||||
wrap.appendChild(div.firstChild);
|
const ids = list.map(function (p) { return p.inst_id; });
|
||||||
});
|
if (!state.expandedPosInst || ids.indexOf(state.expandedPosInst) < 0) {
|
||||||
wrap.querySelectorAll(".opt-close-btn").forEach(function (btn) {
|
state.expandedPosInst = list[0].inst_id || null;
|
||||||
btn.addEventListener("click", function () {
|
}
|
||||||
closePosition(btn.getAttribute("data-inst"), btn);
|
list.forEach(function (p) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.innerHTML = renderPositionAccordionItem(p, p.inst_id === state.expandedPosInst);
|
||||||
|
wrap.appendChild(div.firstChild);
|
||||||
});
|
});
|
||||||
});
|
} else {
|
||||||
|
state.expandedPosInst = null;
|
||||||
|
list.forEach(function (p) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.innerHTML = renderPositionCard(p);
|
||||||
|
wrap.appendChild(div.firstChild);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bindPositionActions(wrap);
|
||||||
if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) {
|
if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) {
|
||||||
OptionsExpiryCountdown.ensureTimer();
|
OptionsExpiryCountdown.ensureTimer();
|
||||||
}
|
}
|
||||||
@@ -560,6 +638,15 @@
|
|||||||
refreshAllPositions();
|
refreshAllPositions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optHistoryStatus(h) {
|
||||||
|
if (h.status !== "closed") return "持仓中";
|
||||||
|
if ((h.signal_note || "").indexOf("到期结算") >= 0) return "到期";
|
||||||
|
if (h.premium_received === 0 && h.realized_pnl != null && h.realized_pnl < 0 && !h.close_ord_id) {
|
||||||
|
return "到期";
|
||||||
|
}
|
||||||
|
return "已平";
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshHistory() {
|
async function refreshHistory() {
|
||||||
const d = await apiJson("/api/options/history");
|
const d = await apiJson("/api/options/history");
|
||||||
const tbody = document.getElementById("opt-history-tbody");
|
const tbody = document.getElementById("opt-history-tbody");
|
||||||
@@ -579,7 +666,7 @@
|
|||||||
"<td><code>" + (h.inst_id || "") + "</code></td>" +
|
"<td><code>" + (h.inst_id || "") + "</code></td>" +
|
||||||
"<td>" + fmt(h.sheets, 0) + "</td>" +
|
"<td>" + fmt(h.sheets, 0) + "</td>" +
|
||||||
"<td>" + fmt(prem, 4) + "</td>" +
|
"<td>" + fmt(prem, 4) + "</td>" +
|
||||||
"<td>" + (h.status === "closed" ? "已平" : "持仓中") + "</td>" +
|
"<td>" + optHistoryStatus(h) + "</td>" +
|
||||||
'<td class="' + pnlCls + '">' + pnlTxt + "</td>" +
|
'<td class="' + pnlCls + '">' + pnlTxt + "</td>" +
|
||||||
"<td>" + (h.closed_at || h.created_at || "—") + "</td>" +
|
"<td>" + (h.closed_at || h.created_at || "—") + "</td>" +
|
||||||
'<td><button type="button" class="btn-secondary btn-sm opt-history-del" data-id="' + h.id + '" data-status="' + (h.status || "") + '">删除</button></td>';
|
'<td><button type="button" class="btn-secondary btn-sm opt-history-del" data-id="' + h.id + '" data-status="' + (h.status || "") + '">删除</button></td>';
|
||||||
|
|||||||
@@ -644,6 +644,60 @@ def fetch_option_positions(ex: ccxt.okx) -> list[dict[str, Any]]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_option_position_history(
|
||||||
|
ex: ccxt.okx,
|
||||||
|
inst_id: str,
|
||||||
|
*,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""OKX 期权历史仓位(含到期结算/平仓)."""
|
||||||
|
inst_id = (inst_id or "").strip()
|
||||||
|
if not inst_id:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
resp = ex.private_get_account_positions_history(
|
||||||
|
{
|
||||||
|
"instType": "OPTION",
|
||||||
|
"instId": inst_id,
|
||||||
|
"limit": str(max(1, min(int(limit), 100))),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows = (resp or {}).get("data") or []
|
||||||
|
return [r for r in rows if isinstance(r, dict)]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_option_close_from_history(
|
||||||
|
hist_rows: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
open_ms: int | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""从 positions-history 中选取最近一条有效平仓/结算记录."""
|
||||||
|
best: dict[str, Any] | None = None
|
||||||
|
best_utime = -1
|
||||||
|
for row in hist_rows:
|
||||||
|
u_ms = _safe_float(row.get("uTime"))
|
||||||
|
if u_ms is None or u_ms <= 0:
|
||||||
|
continue
|
||||||
|
if open_ms is not None and u_ms < int(open_ms) - 60_000:
|
||||||
|
continue
|
||||||
|
if u_ms > best_utime:
|
||||||
|
best = row
|
||||||
|
best_utime = int(u_ms)
|
||||||
|
if not best:
|
||||||
|
return None
|
||||||
|
realized = _safe_float(best.get("realizedPnl"))
|
||||||
|
if realized is None:
|
||||||
|
realized = _safe_float(best.get("pnl"))
|
||||||
|
return {
|
||||||
|
"close_quote": _safe_float(best.get("closeAvgPx")),
|
||||||
|
"realized_pnl": realized,
|
||||||
|
"close_ms": best_utime,
|
||||||
|
"pos_id": str(best.get("posId") or "").strip() or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def fetch_options_unrealized_pnl_usdc(ex: ccxt.okx) -> float | None:
|
def fetch_options_unrealized_pnl_usdc(ex: ccxt.okx) -> float | None:
|
||||||
"""期权持仓未实现盈亏合计(USDC,统计口径与 USDT 1:1)."""
|
"""期权持仓未实现盈亏合计(USDC,统计口径与 USDT 1:1)."""
|
||||||
total = 0.0
|
total = 0.0
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""期权持仓监控:浮盈翻倍微信提醒."""
|
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(v: Any) -> float | None:
|
def _safe_float(v: Any) -> float | None:
|
||||||
if v is None:
|
if v is None:
|
||||||
@@ -101,6 +104,109 @@ def run_options_profit_alerts(
|
|||||||
return sent
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
def _created_at_ms(created_at: Any) -> int | None:
|
||||||
|
if not created_at:
|
||||||
|
return None
|
||||||
|
raw = str(created_at).strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%f"):
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(raw[:26], fmt).replace(tzinfo=timezone.utc)
|
||||||
|
return int(dt.timestamp() * 1000)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def sync_open_options_trades(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
live_inst_ids: set[str],
|
||||||
|
fetch_history_fn: Callable[[str], list[dict[str, Any]]],
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
交易所已无持仓时,将本地 open 记录同步为 closed.
|
||||||
|
优先用 positions-history 回填盈亏;否则到期后按归零处理.
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, inst_id, premium_paid, exp_time, created_at
|
||||||
|
FROM options_trades
|
||||||
|
WHERE status = 'open'
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
updated = 0
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
for row in rows:
|
||||||
|
inst_id = str(row["inst_id"] or "")
|
||||||
|
if not inst_id or inst_id in live_inst_ids:
|
||||||
|
continue
|
||||||
|
paid = _safe_float(row["premium_paid"]) or 0.0
|
||||||
|
open_ms = _created_at_ms(row["created_at"])
|
||||||
|
exp_ms = normalize_option_exp_ms(row["exp_time"], inst_id)
|
||||||
|
close_quote: float | None = None
|
||||||
|
prem_recv: float | None = None
|
||||||
|
realized_pnl: float | None = None
|
||||||
|
close_ord_id: str | None = None
|
||||||
|
closed_at: str | None = None
|
||||||
|
close_reason = "exchange"
|
||||||
|
|
||||||
|
close_info = resolve_option_close_from_history(
|
||||||
|
fetch_history_fn(inst_id),
|
||||||
|
open_ms=open_ms,
|
||||||
|
)
|
||||||
|
if close_info:
|
||||||
|
close_quote = close_info.get("close_quote")
|
||||||
|
realized_pnl = close_info.get("realized_pnl")
|
||||||
|
close_ord_id = close_info.get("pos_id")
|
||||||
|
if realized_pnl is not None:
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE options_trades
|
||||||
|
SET status = 'closed',
|
||||||
|
close_quote = ?,
|
||||||
|
premium_received = ?,
|
||||||
|
realized_pnl = ?,
|
||||||
|
close_ord_id = COALESCE(?, close_ord_id),
|
||||||
|
closed_at = COALESCE(?, closed_at, CURRENT_TIMESTAMP),
|
||||||
|
signal_note = CASE
|
||||||
|
WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '')
|
||||||
|
THEN '到期结算'
|
||||||
|
ELSE signal_note
|
||||||
|
END
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
close_quote,
|
||||||
|
prem_recv,
|
||||||
|
realized_pnl,
|
||||||
|
close_ord_id,
|
||||||
|
closed_at,
|
||||||
|
close_reason,
|
||||||
|
int(row["id"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
def options_monitor_loop(
|
def options_monitor_loop(
|
||||||
*,
|
*,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
@@ -111,6 +217,7 @@ def options_monitor_loop(
|
|||||||
send_wechat: Callable[[str], None],
|
send_wechat: Callable[[str], None],
|
||||||
account_label: str,
|
account_label: str,
|
||||||
profit_ratio: float,
|
profit_ratio: float,
|
||||||
|
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
|
||||||
stop_event: Any = None,
|
stop_event: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not enabled:
|
if not enabled:
|
||||||
@@ -130,6 +237,8 @@ def options_monitor_loop(
|
|||||||
account_label=account_label,
|
account_label=account_label,
|
||||||
ticker_bid_fn=ticker_bid_fn,
|
ticker_bid_fn=ticker_bid_fn,
|
||||||
)
|
)
|
||||||
|
if sync_trades_fn is not None:
|
||||||
|
sync_trades_fn(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -132,6 +132,38 @@ def _require_options_ex(cfg: dict[str, Any]):
|
|||||||
return ex, ""
|
return ex, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _budget_full_usdc(cfg: dict[str, Any], ex: Any) -> tuple[float | None, str]:
|
||||||
|
"""交易账户 USDC 可用余额(由 calc_order_size 再乘 budget_buffer 留余量)."""
|
||||||
|
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
|
||||||
|
|
||||||
|
raw = fetch_options_trading_usdc(ex)
|
||||||
|
if raw is None or float(raw) <= 0:
|
||||||
|
return None, "交易账户 USDC 可用余额不足"
|
||||||
|
return float(raw), ""
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_options_trades(cfg: dict[str, Any]) -> None:
|
||||||
|
ex = cfg.get("exchange_options")
|
||||||
|
if ex is None:
|
||||||
|
return
|
||||||
|
from lib.exchange.okx_options_lib import fetch_option_position_history
|
||||||
|
from lib.options.options_monitor_lib import sync_open_options_trades
|
||||||
|
|
||||||
|
raw = cfg["fetch_option_positions"](ex)
|
||||||
|
live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")}
|
||||||
|
|
||||||
|
def _hist(inst_id: str):
|
||||||
|
return fetch_option_position_history(ex, inst_id)
|
||||||
|
|
||||||
|
conn = cfg["get_db"]()
|
||||||
|
try:
|
||||||
|
init_options_tables(conn)
|
||||||
|
sync_open_options_trades(conn, live_inst_ids=live_ids, fetch_history_fn=_hist)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
||||||
lr = cfg["login_required"]
|
lr = cfg["login_required"]
|
||||||
|
|
||||||
@@ -177,6 +209,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
min_sz = q.get("min_sz") or 1
|
min_sz = q.get("min_sz") or 1
|
||||||
mode = (request.args.get("mode") or "budget_full").strip()
|
mode = (request.args.get("mode") or "budget_full").strip()
|
||||||
budget = cfg["trade_budget"]
|
budget = cfg["trade_budget"]
|
||||||
|
budget_cap = cfg["trade_budget"]
|
||||||
|
available_usdc = None
|
||||||
|
if mode == "budget_full":
|
||||||
|
budget, budget_err = _budget_full_usdc(cfg, ex)
|
||||||
|
if budget is None:
|
||||||
|
return jsonify({"ok": False, "msg": budget_err})
|
||||||
|
budget_cap = budget
|
||||||
|
from lib.exchange.okx_options_lib import fetch_options_trading_usdc
|
||||||
|
|
||||||
|
available_usdc = fetch_options_trading_usdc(ex)
|
||||||
eth_amount = None
|
eth_amount = None
|
||||||
sheet_count = None
|
sheet_count = None
|
||||||
try:
|
try:
|
||||||
@@ -199,7 +241,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
budget_buffer=cfg["budget_buffer"],
|
budget_buffer=cfg["budget_buffer"],
|
||||||
eth_amount=eth_amount if mode == "eth_amount" else None,
|
eth_amount=eth_amount if mode == "eth_amount" else None,
|
||||||
sheets=sheet_count if mode == "sheets" else None,
|
sheets=sheet_count if mode == "sheets" else None,
|
||||||
budget_cap=cfg["trade_budget"],
|
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||||
)
|
)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
@@ -207,6 +249,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
"quote_per_unit": ask,
|
"quote_per_unit": ask,
|
||||||
"premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)),
|
"premium_per_sheet": premium_per_sheet(float(ask), float(ct_mult)),
|
||||||
"sizing": sizing,
|
"sizing": sizing,
|
||||||
|
"available_usdc": available_usdc,
|
||||||
|
"budget_full_usdc": budget if mode == "budget_full" else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -242,15 +286,22 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
sheet_count = int(data.get("sheets"))
|
sheet_count = int(data.get("sheets"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return jsonify({"ok": False, "msg": "张数无效"})
|
return jsonify({"ok": False, "msg": "张数无效"})
|
||||||
|
budget = cfg["trade_budget"]
|
||||||
|
budget_cap = cfg["trade_budget"]
|
||||||
|
if mode == "budget_full":
|
||||||
|
budget, budget_err = _budget_full_usdc(cfg, ex)
|
||||||
|
if budget is None:
|
||||||
|
return jsonify({"ok": False, "msg": budget_err})
|
||||||
|
budget_cap = budget
|
||||||
sizing = calc_order_size(
|
sizing = calc_order_size(
|
||||||
quote_per_unit=float(ask),
|
quote_per_unit=float(ask),
|
||||||
ct_mult=ct_mult,
|
ct_mult=ct_mult,
|
||||||
min_sz=min_sz,
|
min_sz=min_sz,
|
||||||
budget_usdc=cfg["trade_budget"] if mode == "budget_full" else None,
|
budget_usdc=budget if mode == "budget_full" else None,
|
||||||
budget_buffer=cfg["budget_buffer"],
|
budget_buffer=cfg["budget_buffer"],
|
||||||
eth_amount=eth_amount,
|
eth_amount=eth_amount,
|
||||||
sheets=sheet_count,
|
sheets=sheet_count,
|
||||||
budget_cap=cfg["trade_budget"],
|
budget_cap=budget_cap if mode in ("budget_full", "sheets", "eth_amount") else None,
|
||||||
)
|
)
|
||||||
if not sizing.get("ok"):
|
if not sizing.get("ok"):
|
||||||
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
|
return jsonify({"ok": False, "msg": sizing.get("msg") or "张数计算失败", "sizing": sizing})
|
||||||
@@ -304,6 +355,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
ex, err = _require_options_ex(cfg)
|
ex, err = _require_options_ex(cfg)
|
||||||
if ex is None:
|
if ex is None:
|
||||||
return jsonify({"ok": False, "msg": err})
|
return jsonify({"ok": False, "msg": err})
|
||||||
|
_sync_options_trades(cfg)
|
||||||
raw = cfg["fetch_option_positions"](ex)
|
raw = cfg["fetch_option_positions"](ex)
|
||||||
rows = [cfg["format_position_row"](p) for p in raw]
|
rows = [cfg["format_position_row"](p) for p in raw]
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
@@ -554,6 +606,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
ex, err = _require_options_ex(cfg)
|
ex, err = _require_options_ex(cfg)
|
||||||
if ex is None:
|
if ex is None:
|
||||||
return jsonify({"ok": False, "msg": err})
|
return jsonify({"ok": False, "msg": err})
|
||||||
|
_sync_options_trades(cfg)
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
init_options_tables(conn)
|
init_options_tables(conn)
|
||||||
@@ -660,6 +713,21 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
raw = cfg["fetch_option_positions"](ex)
|
raw = cfg["fetch_option_positions"](ex)
|
||||||
return [cfg["format_position_row"](p) for p in raw]
|
return [cfg["format_position_row"](p) for p in raw]
|
||||||
|
|
||||||
|
def _sync(conn):
|
||||||
|
from lib.exchange.okx_options_lib import fetch_option_position_history
|
||||||
|
from lib.options.options_monitor_lib import sync_open_options_trades
|
||||||
|
|
||||||
|
ex = cfg.get("exchange_options")
|
||||||
|
if ex is None:
|
||||||
|
return 0
|
||||||
|
raw = cfg["fetch_option_positions"](ex)
|
||||||
|
live_ids = {str(p.get("instId") or "") for p in raw if str(p.get("instId") or "")}
|
||||||
|
return sync_open_options_trades(
|
||||||
|
conn,
|
||||||
|
live_inst_ids=live_ids,
|
||||||
|
fetch_history_fn=lambda inst_id: fetch_option_position_history(ex, inst_id),
|
||||||
|
)
|
||||||
|
|
||||||
t = threading.Thread(
|
t = threading.Thread(
|
||||||
target=options_monitor_loop,
|
target=options_monitor_loop,
|
||||||
kwargs={
|
kwargs={
|
||||||
@@ -671,6 +739,7 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
"send_wechat": cfg["send_wechat"],
|
"send_wechat": cfg["send_wechat"],
|
||||||
"account_label": cfg["account_label"],
|
"account_label": cfg["account_label"],
|
||||||
"profit_ratio": cfg["profit_ratio"],
|
"profit_ratio": cfg["profit_ratio"],
|
||||||
|
"sync_trades_fn": _sync,
|
||||||
},
|
},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
name="options-monitor",
|
name="options-monitor",
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<div class="form-row options-order-mode-row">
|
<div class="form-row options-order-mode-row">
|
||||||
<label><input type="radio" name="opt-size-mode" value="sheets" checked> 指定张数</label>
|
<label><input type="radio" name="opt-size-mode" value="sheets" checked> 指定张数</label>
|
||||||
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数">
|
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数">
|
||||||
<label><input type="radio" name="opt-size-mode" value="budget_full"> 按单笔上限打满</label>
|
<label><input type="radio" name="opt-size-mode" value="budget_full"> 按可用余额打满</label>
|
||||||
<label><input type="radio" name="opt-size-mode" value="eth_amount"> 指定币数量</label>
|
<label><input type="radio" name="opt-size-mode" value="eth_amount"> 指定币数量</label>
|
||||||
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none">
|
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none">
|
||||||
<input type="text" id="opt-signal-note" placeholder="备注(关键位说明)">
|
<input type="text" id="opt-signal-note" placeholder="备注(关键位说明)">
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""期权平仓/到期状态同步单测."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from lib.exchange.okx_options_lib import resolve_option_close_from_history
|
||||||
|
from lib.options.options_db import init_options_tables
|
||||||
|
from lib.options.options_monitor_lib import sync_open_options_trades
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_option_close_from_history_picks_latest():
|
||||||
|
rows = [
|
||||||
|
{"instId": "ETH-USD_UM-260709-1700-P", "uTime": "1000", "realizedPnl": "-1.0", "closeAvgPx": "0"},
|
||||||
|
{"instId": "ETH-USD_UM-260709-1700-P", "uTime": "2000", "realizedPnl": "-1.24", "closeAvgPx": "0", "posId": "9"},
|
||||||
|
]
|
||||||
|
got = resolve_option_close_from_history(rows, open_ms=500)
|
||||||
|
assert got is not None
|
||||||
|
assert got["realized_pnl"] == -1.24
|
||||||
|
assert got["pos_id"] == "9"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_open_options_trades_marks_expired_closed():
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
init_options_tables(conn)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO options_trades
|
||||||
|
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||||
|
open_quote, premium_paid, status)
|
||||||
|
VALUES (?, 'ETH', 'P', 1700, '', 20, 0.2, 6.2, 1.24, 'open')
|
||||||
|
""",
|
||||||
|
("ETH-USD_UM-260709-1700-P",),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
n = sync_open_options_trades(
|
||||||
|
conn,
|
||||||
|
live_inst_ids=set(),
|
||||||
|
fetch_history_fn=lambda _inst: [],
|
||||||
|
)
|
||||||
|
assert n == 1
|
||||||
|
row = conn.execute("SELECT status, premium_received, realized_pnl, signal_note FROM options_trades").fetchone()
|
||||||
|
assert row["status"] == "closed"
|
||||||
|
assert row["premium_received"] == 0.0
|
||||||
|
assert row["realized_pnl"] == -1.24
|
||||||
|
assert "到期结算" in (row["signal_note"] or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_open_options_trades_uses_exchange_history():
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
init_options_tables(conn)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO options_trades
|
||||||
|
(inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
|
||||||
|
open_quote, premium_paid, status, created_at)
|
||||||
|
VALUES (?, 'ETH', 'P', 1700, '', 20, 0.2, 6.2, 1.24, 'open', '2026-07-08 02:32:44')
|
||||||
|
""",
|
||||||
|
("ETH-USD_UM-260709-1700-P",),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def _hist(_inst):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"instId": "ETH-USD_UM-260709-1700-P",
|
||||||
|
"uTime": "1784000000000",
|
||||||
|
"realizedPnl": "-0.5",
|
||||||
|
"closeAvgPx": "0.1",
|
||||||
|
"posId": "pos-1",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
n = sync_open_options_trades(
|
||||||
|
conn,
|
||||||
|
live_inst_ids=set(),
|
||||||
|
fetch_history_fn=_hist,
|
||||||
|
)
|
||||||
|
assert n == 1
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT status, premium_received, realized_pnl, close_ord_id FROM options_trades"
|
||||||
|
).fetchone()
|
||||||
|
assert row["status"] == "closed"
|
||||||
|
assert row["realized_pnl"] == -0.5
|
||||||
|
assert row["premium_received"] == 0.74
|
||||||
|
assert row["close_ord_id"] == "pos-1"
|
||||||
Reference in New Issue
Block a user