Align option display precision with OKX and use exchange position history.
Format prices by tickSz, move bid depth/recovery to card end with plain styling, and load option history from OKX positions-history instead of local DB. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3600,62 +3600,15 @@ html[data-theme="light"] .options-estimate-row {
|
|||||||
.options-page-wrap .opt-pos-cell--depth {
|
.options-page-wrap .opt-pos-cell--depth {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
.options-page-wrap .opt-bid-depth {
|
.options-page-wrap .opt-bid-plain {
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 5px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.options-page-wrap .opt-bid-level {
|
|
||||||
display: inline-grid;
|
|
||||||
grid-template-columns: auto auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
min-height: 24px;
|
|
||||||
padding: 3px 9px;
|
|
||||||
border: 1px solid rgba(82, 101, 143, 0.55);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(18, 24, 37, 0.72);
|
|
||||||
color: #dbe6ff;
|
color: #dbe6ff;
|
||||||
line-height: 1;
|
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
.options-page-wrap .opt-bid-level--best {
|
.options-page-wrap .opt-close-value {
|
||||||
border-color: rgba(93, 143, 255, 0.9);
|
|
||||||
background: linear-gradient(135deg, rgba(47, 86, 170, 0.72), rgba(28, 39, 68, 0.84));
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(122, 164, 255, 0.1), 0 6px 14px rgba(24, 46, 92, 0.24);
|
|
||||||
}
|
|
||||||
.options-page-wrap .opt-bid-rank {
|
|
||||||
color: #91a4cc;
|
|
||||||
font-size: 0.64rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.options-page-wrap .opt-bid-level--best .opt-bid-rank {
|
|
||||||
color: #c8d8ff;
|
|
||||||
}
|
|
||||||
.options-page-wrap .opt-bid-price {
|
|
||||||
color: #ffffff;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
|
||||||
.options-page-wrap .opt-close-preview {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.options-page-wrap .opt-close-main {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 4px;
|
|
||||||
color: #f2f6ff;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 800;
|
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
line-height: 1.05;
|
|
||||||
}
|
|
||||||
.options-page-wrap .opt-close-main span {
|
|
||||||
color: #9aa8c2;
|
|
||||||
font-size: 0.62rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
.options-page-wrap .opt-close-rule {
|
.options-page-wrap .opt-close-rule {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -3774,11 +3727,6 @@ html[data-theme="light"] .options-estimate-row {
|
|||||||
.opt-history-table td:nth-child(6) {
|
.opt-history-table td:nth-child(6) {
|
||||||
width: 20%;
|
width: 20%;
|
||||||
}
|
}
|
||||||
.opt-history-table th:nth-child(7),
|
|
||||||
.opt-history-table td:nth-child(7) {
|
|
||||||
width: 8%;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.opt-hist-time {
|
.opt-hist-time {
|
||||||
font-size: 0.64rem;
|
font-size: 0.64rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -30,6 +30,24 @@
|
|||||||
return Number(v).toFixed(d == null ? 2 : d);
|
return Number(v).toFixed(d == null ? 2 : d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtDisplay(v, fallback) {
|
||||||
|
if (v !== null && v !== undefined && String(v).trim() !== "") return String(v);
|
||||||
|
if (fallback !== undefined) return fmtDisplay(fallback);
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtOptionPx(v, tickSz) {
|
||||||
|
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
|
||||||
|
const n = Number(v);
|
||||||
|
const tick = Number(tickSz);
|
||||||
|
if (!tickSz || Number.isNaN(tick) || tick <= 0) return String(n);
|
||||||
|
let decimals = 0;
|
||||||
|
if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick)));
|
||||||
|
else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length;
|
||||||
|
let s = n.toFixed(decimals).replace(/\.?0+$/, "");
|
||||||
|
return s || "0";
|
||||||
|
}
|
||||||
|
|
||||||
async function apiJson(url, opts) {
|
async function apiJson(url, opts) {
|
||||||
const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
|
const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
|
||||||
return r.json();
|
return r.json();
|
||||||
@@ -185,28 +203,32 @@
|
|||||||
return price + "/" + size;
|
return price + "/" + size;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtCloseLevels(preview) {
|
function fmtCloseLevels(preview, tickSz) {
|
||||||
const levels = ((preview && preview.levels) || []).slice(0, 5);
|
const levels = ((preview && preview.levels) || []).slice(0, 5);
|
||||||
if (!levels.length) return "—";
|
if (!levels.length) return "—";
|
||||||
return '<div class="opt-bid-depth">' + levels.map(function (x, idx) {
|
return levels.map(function (x, idx) {
|
||||||
const levelNo = x.level != null ? x.level : idx + 1;
|
const levelNo = x.level != null ? x.level : idx + 1;
|
||||||
const levelCls = idx === 0 ? " opt-bid-level--best" : "";
|
const pxTxt = fmtOptionPx(x.px, tickSz);
|
||||||
return (
|
return "买" + levelNo + " " + pxTxt;
|
||||||
'<span class="opt-bid-level' + levelCls + '">' +
|
}).join(" · ");
|
||||||
'<span class="opt-bid-rank">买' + levelNo + "</span>" +
|
|
||||||
'<span class="opt-bid-price">' + fmt(x.px, 4) + "</span>" +
|
|
||||||
"</span>"
|
|
||||||
);
|
|
||||||
}).join("") + "</div>";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtClosePreview(preview) {
|
function fmtUsdc(v) {
|
||||||
|
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
|
||||||
|
return Number(v).toFixed(4).replace(/\.?0+$/, "") || "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtClosePreview(preview, premiumPaid) {
|
||||||
if (!preview || preview.total_received == null) return "—";
|
if (!preview || preview.total_received == null) return "—";
|
||||||
return (
|
const recvTxt = fmtUsdc(preview.total_received);
|
||||||
'<div class="opt-close-preview">' +
|
let cls = "";
|
||||||
'<div class="opt-close-main">' + fmt(preview.total_received, 4) + '<span>USDC</span></div>' +
|
const prem = Number(premiumPaid);
|
||||||
"</div>"
|
const recv = Number(preview.total_received);
|
||||||
);
|
if (!Number.isNaN(prem) && !Number.isNaN(recv)) {
|
||||||
|
if (recv > prem) cls = " pos-pnl-profit";
|
||||||
|
else if (recv < prem) cls = " pos-pnl-loss";
|
||||||
|
}
|
||||||
|
return '<span class="opt-close-value' + cls + '">' + recvTxt + " USDC</span>";
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtClosePreviewText(preview) {
|
function fmtClosePreviewText(preview) {
|
||||||
@@ -502,6 +524,10 @@
|
|||||||
const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
|
const expAttr = expMs != null && expMs !== "" ? String(expMs) : "";
|
||||||
const closePreview = p.close_preview || {};
|
const closePreview = p.close_preview || {};
|
||||||
const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos;
|
const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos;
|
||||||
|
const tickSz = p.tick_sz;
|
||||||
|
const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmt(p.premium_paid, 4).replace(/\.?0+$/, "") : null);
|
||||||
|
const avgTxt = fmtDisplay(p.avg_px_fmt, fmtOptionPx(p.avg_px, tickSz));
|
||||||
|
const markTxt = fmtDisplay(p.mark_px_fmt, fmtOptionPx(p.mark_px, tickSz));
|
||||||
return (
|
return (
|
||||||
'<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>' +
|
||||||
@@ -517,17 +543,17 @@
|
|||||||
: "") +
|
: "") +
|
||||||
"</div>" +
|
"</div>" +
|
||||||
'<div class="pos-grid">' +
|
'<div class="pos-grid">' +
|
||||||
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + fmt(p.premium_paid, 4) + " USDC</span></div>" +
|
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + premTxt + " USDC</span></div>" +
|
||||||
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + fmt(p.avg_px, 4) + "</span></div>" +
|
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
|
||||||
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + fmt(p.mark_px, 4) + "</span></div>" +
|
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</span></div>" +
|
||||||
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
|
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
|
||||||
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value">' + fmtCloseLevels(closePreview) + "</span></div>" +
|
|
||||||
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘收回</span><span class="pos-value">' + fmtClosePreview(closePreview) + "</span></div>" +
|
|
||||||
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
|
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
|
||||||
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
|
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</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 + '">' + 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 ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "</span></div>" +
|
(p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "</span></div>" +
|
||||||
|
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
|
||||||
|
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' + fmtClosePreview(closePreview, p.premium_paid) + "</span></div>" +
|
||||||
"</div>"
|
"</div>"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -873,25 +899,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteHistoryRow(id, status) {
|
|
||||||
const warn = status === "open"
|
|
||||||
? "该记录仍为持仓中,仅删除本地记录,不影响交易所持仓.确认删除?"
|
|
||||||
: "确认删除该条期权历史记录?";
|
|
||||||
if (!confirm(warn)) return;
|
|
||||||
const r = await apiJson("/api/options/history/" + encodeURIComponent(id), { method: "DELETE" });
|
|
||||||
if (!r.ok) {
|
|
||||||
alert(r.msg || "删除失败");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
refreshAllPositions();
|
|
||||||
}
|
|
||||||
|
|
||||||
function optHistoryStatus(h) {
|
function optHistoryStatus(h) {
|
||||||
|
if (h.status_label) return h.status_label;
|
||||||
|
if (h.status === "open") return "持仓中";
|
||||||
if (h.status !== "closed") return "持仓中";
|
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 "已平";
|
return "已平";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,7 +910,7 @@
|
|||||||
const s = optHistoryStatus(h);
|
const s = optHistoryStatus(h);
|
||||||
let cls = "opt-hist-status";
|
let cls = "opt-hist-status";
|
||||||
if (s === "已平") cls += " opt-hist-status--closed";
|
if (s === "已平") cls += " opt-hist-status--closed";
|
||||||
else if (s === "到期") cls += " opt-hist-status--expired";
|
else if (s === "到期" || s === "强平") cls += " opt-hist-status--expired";
|
||||||
else cls += " opt-hist-status--open";
|
else cls += " opt-hist-status--open";
|
||||||
return '<span class="' + cls + '">' + s + "</span>";
|
return '<span class="' + cls + '">' + s + "</span>";
|
||||||
}
|
}
|
||||||
@@ -910,13 +921,14 @@
|
|||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
const list = (d.ok && d.history) || [];
|
const list = (d.ok && d.history) || [];
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="muted">暂无历史记录</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" class="muted">暂无历史记录</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.forEach(function (h) {
|
list.forEach(function (h) {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
const premTxt = h.premium_paid != null ? fmt(h.premium_paid, 2) : "—";
|
const premTxt = fmtDisplay(h.premium_paid_fmt, h.premium_paid != null ? fmt(h.premium_paid, 2) : null);
|
||||||
const pnl = h.realized_pnl;
|
const isOpen = h.status === "open";
|
||||||
|
const pnl = isOpen ? null : h.realized_pnl;
|
||||||
const pnlTxt = pnl != null ? fmt(pnl, 2) : "—";
|
const pnlTxt = pnl != null ? fmt(pnl, 2) : "—";
|
||||||
const pnlCls = pnl > 0 ? "pos-pnl-profit" : pnl < 0 ? "pos-pnl-loss" : "";
|
const pnlCls = pnl > 0 ? "pos-pnl-profit" : pnl < 0 ? "pos-pnl-loss" : "";
|
||||||
const timeTxt = (h.closed_at || h.created_at || "—").replace("T", " ").slice(0, 19);
|
const timeTxt = (h.closed_at || h.created_at || "—").replace("T", " ").slice(0, 19);
|
||||||
@@ -926,15 +938,9 @@
|
|||||||
"<td>" + premTxt + "</td>" +
|
"<td>" + premTxt + "</td>" +
|
||||||
"<td>" + optHistoryStatusHtml(h) + "</td>" +
|
"<td>" + optHistoryStatusHtml(h) + "</td>" +
|
||||||
'<td class="' + pnlCls + '">' + pnlTxt + "</td>" +
|
'<td class="' + pnlCls + '">' + pnlTxt + "</td>" +
|
||||||
"<td class=\"opt-hist-time\">" + timeTxt + "</td>" +
|
'<td class="opt-hist-time">' + timeTxt + "</td>";
|
||||||
'<td><button type="button" class="btn-secondary btn-sm opt-history-del" data-id="' + h.id + '" data-status="' + (h.status || "") + '">删除</button></td>';
|
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
tbody.querySelectorAll(".opt-history-del").forEach(function (btn) {
|
|
||||||
btn.addEventListener("click", function () {
|
|
||||||
deleteHistoryRow(btn.getAttribute("data-id"), btn.getAttribute("data-status"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshAllPositions() {
|
function refreshAllPositions() {
|
||||||
|
|||||||
@@ -116,6 +116,53 @@ def format_option_px(px: float, tick_sz: Any) -> str:
|
|||||||
return f"{px:.{decimals}f}".rstrip("0").rstrip(".") or "0"
|
return f"{px:.{decimals}f}".rstrip("0").rstrip(".") or "0"
|
||||||
|
|
||||||
|
|
||||||
|
def format_usdc_amount(v: float | None) -> str | None:
|
||||||
|
"""USDC 金额展示(与交易所持仓/历史一致,最多 4 位小数)."""
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
return f"{float(v):.4f}".rstrip("0").rstrip(".") or "0"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def option_instrument_meta_cached(
|
||||||
|
ex: ccxt.okx,
|
||||||
|
inst_id: str,
|
||||||
|
cache: dict[str, dict[str, Any] | None] | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
inst_id = (inst_id or "").strip()
|
||||||
|
if not inst_id:
|
||||||
|
return None
|
||||||
|
if cache is not None and inst_id in cache:
|
||||||
|
return cache[inst_id]
|
||||||
|
meta = fetch_option_instrument_meta(ex, inst_id)
|
||||||
|
if cache is not None:
|
||||||
|
cache[inst_id] = meta
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def tick_sz_and_ct_mult(
|
||||||
|
ex: ccxt.okx,
|
||||||
|
inst_id: str,
|
||||||
|
cache: dict[str, dict[str, Any] | None] | None = None,
|
||||||
|
) -> tuple[Any, float]:
|
||||||
|
meta = option_instrument_meta_cached(ex, inst_id, cache)
|
||||||
|
tick_sz = meta.get("tickSz") if meta else None
|
||||||
|
ct_mult = _safe_float(meta.get("ctMult")) if meta else None
|
||||||
|
return tick_sz, ct_mult or 0.01
|
||||||
|
|
||||||
|
|
||||||
def _intrinsic_px_per_unit(opt_type: str, strike: float, index_px: float) -> float | None:
|
def _intrinsic_px_per_unit(opt_type: str, strike: float, index_px: float) -> float | None:
|
||||||
o = (opt_type or "").upper()
|
o = (opt_type or "").upper()
|
||||||
if o == "C" and index_px > strike:
|
if o == "C" and index_px > strike:
|
||||||
@@ -732,6 +779,144 @@ def fetch_option_position_history(
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all_option_positions_history(
|
||||||
|
ex: ccxt.okx,
|
||||||
|
*,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""拉取 OKX 期权全部历史仓位(分页,按平仓时间倒序)."""
|
||||||
|
cap = max(1, min(int(limit), 500))
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
after: str | None = None
|
||||||
|
while len(out) < cap:
|
||||||
|
page_limit = min(100, cap - len(out))
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"instType": "OPTION",
|
||||||
|
"limit": str(page_limit),
|
||||||
|
}
|
||||||
|
if after is not None:
|
||||||
|
params["after"] = after
|
||||||
|
try:
|
||||||
|
resp = ex.private_get_account_positions_history(params)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
rows = (resp or {}).get("data") or []
|
||||||
|
batch = [r for r in rows if isinstance(r, dict)]
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
out.extend(batch)
|
||||||
|
if len(batch) < page_limit:
|
||||||
|
break
|
||||||
|
utimes = [_safe_float(r.get("uTime")) for r in batch]
|
||||||
|
utimes = [int(u) for u in utimes if u is not None and u > 0]
|
||||||
|
if not utimes:
|
||||||
|
break
|
||||||
|
oldest = min(utimes)
|
||||||
|
if after is not None and str(oldest) == after:
|
||||||
|
break
|
||||||
|
after = str(oldest)
|
||||||
|
out.sort(key=lambda r: int(_safe_float(r.get("uTime")) or 0), reverse=True)
|
||||||
|
return out[:cap]
|
||||||
|
|
||||||
|
|
||||||
|
def format_option_history_row(
|
||||||
|
raw: dict[str, Any],
|
||||||
|
*,
|
||||||
|
tick_sz: Any = None,
|
||||||
|
ct_mult: float = 0.01,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""标准化 OKX positions-history 单条记录供前端展示."""
|
||||||
|
from lib.options.options_pricing_lib import total_premium
|
||||||
|
|
||||||
|
inst_id = str(raw.get("instId") or "").strip()
|
||||||
|
open_avg = _safe_float(raw.get("openAvgPx"))
|
||||||
|
close_avg = _safe_float(raw.get("closeAvgPx"))
|
||||||
|
sheets = _safe_float(raw.get("closeTotalPos"))
|
||||||
|
if sheets is None or sheets <= 0:
|
||||||
|
sheets = _safe_float(raw.get("openMaxPos"))
|
||||||
|
sheets_i = int(abs(sheets or 0))
|
||||||
|
eth_amount = round(abs(sheets or 0) * ct_mult, 8) if sheets else 0.0
|
||||||
|
premium_paid = (
|
||||||
|
round(total_premium(open_avg, eth_amount), 8)
|
||||||
|
if open_avg is not None and eth_amount > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
realized = _safe_float(raw.get("realizedPnl"))
|
||||||
|
if realized is None:
|
||||||
|
realized = _safe_float(raw.get("pnl"))
|
||||||
|
pnl_ratio = _safe_float(raw.get("pnlRatio"))
|
||||||
|
close_type = str(raw.get("type") or "").strip()
|
||||||
|
utime = _safe_float(raw.get("uTime"))
|
||||||
|
ctime = _safe_float(raw.get("cTime"))
|
||||||
|
opt_type, strike = option_fields_from_inst_id(inst_id)
|
||||||
|
uly = str(raw.get("uly") or inst_id.split("-")[0] or "").replace("-USD_UM", "").replace("-USD", "")
|
||||||
|
if close_type in ("3", "4"):
|
||||||
|
status_label = "强平"
|
||||||
|
else:
|
||||||
|
status_label = "已平"
|
||||||
|
return {
|
||||||
|
"source": "exchange",
|
||||||
|
"pos_id": str(raw.get("posId") or "").strip() or None,
|
||||||
|
"inst_id": inst_id,
|
||||||
|
"underlying": uly,
|
||||||
|
"opt_type": opt_type,
|
||||||
|
"strike": strike,
|
||||||
|
"sheets": sheets_i,
|
||||||
|
"eth_amount": eth_amount,
|
||||||
|
"open_avg_px": open_avg,
|
||||||
|
"open_avg_px_fmt": format_option_px(open_avg, tick_sz) if open_avg is not None else None,
|
||||||
|
"close_avg_px": close_avg,
|
||||||
|
"close_avg_px_fmt": format_option_px(close_avg, tick_sz) if close_avg is not None else None,
|
||||||
|
"premium_paid": premium_paid,
|
||||||
|
"premium_paid_fmt": format_usdc_amount(premium_paid),
|
||||||
|
"realized_pnl": realized,
|
||||||
|
"pnl_ratio_pct": round(pnl_ratio * 100, 2) if pnl_ratio is not None else None,
|
||||||
|
"status": "closed",
|
||||||
|
"status_label": status_label,
|
||||||
|
"close_type": close_type,
|
||||||
|
"created_at": _ms_to_iso(ctime),
|
||||||
|
"closed_at": _ms_to_iso(utime),
|
||||||
|
"close_ms": int(utime) if utime is not None else None,
|
||||||
|
"tick_sz": tick_sz,
|
||||||
|
"raw": raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_live_option_history_row(
|
||||||
|
row: dict[str, Any],
|
||||||
|
*,
|
||||||
|
open_ms: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""将当前持仓格式化为历史列表中的「持仓中」行."""
|
||||||
|
inst_id = str(row.get("inst_id") or "").strip()
|
||||||
|
return {
|
||||||
|
"source": "live",
|
||||||
|
"pos_id": str((row.get("raw") or {}).get("posId") or "").strip() or None,
|
||||||
|
"inst_id": inst_id,
|
||||||
|
"underlying": str(row.get("underlying") or inst_id.split("-")[0] or ""),
|
||||||
|
"opt_type": row.get("opt_type"),
|
||||||
|
"strike": row.get("strike"),
|
||||||
|
"sheets": int(abs(_safe_float(row.get("pos")) or 0)),
|
||||||
|
"eth_amount": row.get("eth_amount"),
|
||||||
|
"open_avg_px": row.get("avg_px"),
|
||||||
|
"open_avg_px_fmt": row.get("avg_px_fmt"),
|
||||||
|
"close_avg_px": None,
|
||||||
|
"close_avg_px_fmt": None,
|
||||||
|
"premium_paid": row.get("premium_paid"),
|
||||||
|
"premium_paid_fmt": row.get("premium_paid_fmt"),
|
||||||
|
"realized_pnl": row.get("upl"),
|
||||||
|
"pnl_ratio_pct": row.get("upl_ratio_pct"),
|
||||||
|
"status": "open",
|
||||||
|
"status_label": "持仓中",
|
||||||
|
"close_type": None,
|
||||||
|
"created_at": _ms_to_iso(open_ms),
|
||||||
|
"closed_at": None,
|
||||||
|
"close_ms": open_ms,
|
||||||
|
"tick_sz": row.get("tick_sz"),
|
||||||
|
"raw": row.get("raw"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def resolve_option_close_from_history(
|
def resolve_option_close_from_history(
|
||||||
hist_rows: list[dict[str, Any]],
|
hist_rows: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
@@ -947,7 +1132,12 @@ def transfer_main_sub_account(
|
|||||||
return {"ok": False, "msg": str(e)}
|
return {"ok": False, "msg": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def format_position_row(pos: dict[str, Any], ct_mult: float = 0.01) -> dict[str, Any]:
|
def format_position_row(
|
||||||
|
pos: dict[str, Any],
|
||||||
|
ct_mult: float = 0.01,
|
||||||
|
*,
|
||||||
|
tick_sz: Any = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
from lib.options.options_pricing_lib import (
|
from lib.options.options_pricing_lib import (
|
||||||
close_breakeven_idx,
|
close_breakeven_idx,
|
||||||
expiry_breakeven_px,
|
expiry_breakeven_px,
|
||||||
@@ -971,7 +1161,7 @@ def format_position_row(pos: dict[str, Any], ct_mult: float = 0.01) -> dict[str,
|
|||||||
strike = parsed_strike
|
strike = parsed_strike
|
||||||
eth_amount = round(abs(sheets) * ct_mult, 8)
|
eth_amount = round(abs(sheets) * ct_mult, 8)
|
||||||
premium_paid = (
|
premium_paid = (
|
||||||
round(total_premium(avg, eth_amount), 4) if avg is not None and eth_amount > 0 else None
|
round(total_premium(avg, eth_amount), 8) if avg is not None and eth_amount > 0 else None
|
||||||
)
|
)
|
||||||
delta_pa = _safe_float(pos.get("deltaPA"))
|
delta_pa = _safe_float(pos.get("deltaPA"))
|
||||||
expiry_be = expiry_breakeven_px(
|
expiry_be = expiry_breakeven_px(
|
||||||
@@ -996,6 +1186,11 @@ def format_position_row(pos: dict[str, Any], ct_mult: float = 0.01) -> dict[str,
|
|||||||
"eth_amount": eth_amount,
|
"eth_amount": eth_amount,
|
||||||
"avg_px": avg,
|
"avg_px": avg,
|
||||||
"mark_px": mark,
|
"mark_px": mark,
|
||||||
|
"avg_px_fmt": format_option_px(avg, tick_sz) if avg is not None else None,
|
||||||
|
"mark_px_fmt": format_option_px(mark, tick_sz) if mark is not None else None,
|
||||||
|
"premium_paid_fmt": format_usdc_amount(premium_paid),
|
||||||
|
"tick_sz": tick_sz,
|
||||||
|
"ct_mult": ct_mult,
|
||||||
"idx_px": idx_px,
|
"idx_px": idx_px,
|
||||||
"premium_paid": premium_paid,
|
"premium_paid": premium_paid,
|
||||||
"upl": upl,
|
"upl": upl,
|
||||||
|
|||||||
@@ -189,6 +189,27 @@ def _refresh_position_avail(cfg: dict[str, Any], ex: Any, inst_id: str) -> int |
|
|||||||
return _position_avail_sheets(pos)
|
return _position_avail_sheets(pos)
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_position_row_display(
|
||||||
|
cfg: dict[str, Any],
|
||||||
|
ex: Any,
|
||||||
|
raw_pos: dict[str, Any],
|
||||||
|
*,
|
||||||
|
meta_cache: dict[str, dict[str, Any] | None] | None = None,
|
||||||
|
premium_override: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from lib.exchange.okx_options_lib import format_position_row, tick_sz_and_ct_mult
|
||||||
|
|
||||||
|
inst_id = str(raw_pos.get("instId") or "").strip()
|
||||||
|
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
|
||||||
|
row = format_position_row(raw_pos, ct_mult=ct_mult, tick_sz=tick_sz)
|
||||||
|
if premium_override is not None:
|
||||||
|
row["premium_paid"] = premium_override
|
||||||
|
from lib.exchange.okx_options_lib import format_usdc_amount
|
||||||
|
|
||||||
|
row["premium_paid_fmt"] = format_usdc_amount(premium_override)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
def _attach_close_preview(
|
def _attach_close_preview(
|
||||||
cfg: dict[str, Any],
|
cfg: dict[str, Any],
|
||||||
ex: Any,
|
ex: Any,
|
||||||
@@ -469,13 +490,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
if raw is None:
|
if raw is None:
|
||||||
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
|
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
|
||||||
_sync_options_trades(cfg, raw_positions=raw)
|
_sync_options_trades(cfg, raw_positions=raw)
|
||||||
rows = [cfg["format_position_row"](p) for p in raw]
|
meta_cache: dict[str, dict[str, Any] | None] = {}
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
for row in rows:
|
rows = []
|
||||||
inst = row.get("inst_id")
|
for p in raw:
|
||||||
if not inst:
|
inst = str(p.get("instId") or "").strip()
|
||||||
continue
|
premium_override = None
|
||||||
|
if inst:
|
||||||
rec = conn.execute(
|
rec = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT premium_paid FROM options_trades
|
SELECT premium_paid FROM options_trades
|
||||||
@@ -485,8 +507,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
(inst,),
|
(inst,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if rec and rec["premium_paid"] is not None:
|
if rec and rec["premium_paid"] is not None:
|
||||||
row["premium_paid"] = round(float(rec["premium_paid"]), 4)
|
premium_override = float(rec["premium_paid"])
|
||||||
|
row = _enrich_position_row_display(
|
||||||
|
cfg,
|
||||||
|
ex,
|
||||||
|
p,
|
||||||
|
meta_cache=meta_cache,
|
||||||
|
premium_override=premium_override,
|
||||||
|
)
|
||||||
_attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
|
_attach_close_preview(cfg, ex, row, premium_paid=_safe_float(row.get("premium_paid")))
|
||||||
|
rows.append(row)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": True, "positions": rows})
|
return jsonify({"ok": True, "positions": rows})
|
||||||
@@ -838,24 +868,66 @@ 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)
|
from lib.exchange.okx_options_lib import (
|
||||||
|
fetch_all_option_positions_history,
|
||||||
|
format_live_option_history_row,
|
||||||
|
format_option_history_row,
|
||||||
|
tick_sz_and_ct_mult,
|
||||||
|
)
|
||||||
|
|
||||||
|
meta_cache: dict[str, dict[str, Any] | None] = {}
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
raw_live = cfg["fetch_option_positions"](ex)
|
||||||
|
if raw_live is None:
|
||||||
|
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
|
||||||
conn = cfg["get_db"]()
|
conn = cfg["get_db"]()
|
||||||
try:
|
try:
|
||||||
init_options_tables(conn)
|
for p in raw_live:
|
||||||
rows = conn.execute(
|
inst = str(p.get("instId") or "").strip()
|
||||||
|
premium_override = None
|
||||||
|
if inst:
|
||||||
|
rec = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, inst_id, underlying, opt_type, strike, sheets, eth_amount,
|
SELECT premium_paid FROM options_trades
|
||||||
open_quote, premium_paid, close_quote, premium_received,
|
WHERE inst_id = ? AND status = 'open'
|
||||||
realized_pnl, status, signal_note, created_at, closed_at
|
ORDER BY id DESC LIMIT 1
|
||||||
FROM options_trades
|
""",
|
||||||
ORDER BY id DESC
|
(inst,),
|
||||||
LIMIT 200
|
).fetchone()
|
||||||
"""
|
if rec and rec["premium_paid"] is not None:
|
||||||
).fetchall()
|
premium_override = float(rec["premium_paid"])
|
||||||
items = [dict(r) for r in rows]
|
row = _enrich_position_row_display(
|
||||||
|
cfg,
|
||||||
|
ex,
|
||||||
|
p,
|
||||||
|
meta_cache=meta_cache,
|
||||||
|
premium_override=premium_override,
|
||||||
|
)
|
||||||
|
open_ms = None
|
||||||
|
ctime = p.get("cTime") or (row.get("raw") or {}).get("cTime")
|
||||||
|
try:
|
||||||
|
if ctime is not None and str(ctime).strip():
|
||||||
|
open_ms = int(float(ctime))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
open_ms = None
|
||||||
|
items.append(format_live_option_history_row(row, open_ms=open_ms))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": True, "history": items})
|
|
||||||
|
hist_raw = fetch_all_option_positions_history(ex, limit=200)
|
||||||
|
for raw in hist_raw:
|
||||||
|
inst_id = str(raw.get("instId") or "").strip()
|
||||||
|
tick_sz, ct_mult = tick_sz_and_ct_mult(ex, inst_id, meta_cache)
|
||||||
|
items.append(format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult))
|
||||||
|
|
||||||
|
open_rows = [x for x in items if x.get("status") == "open"]
|
||||||
|
closed = [x for x in items if x.get("status") != "open"]
|
||||||
|
closed.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True)
|
||||||
|
open_rows.sort(key=lambda x: int(x.get("close_ms") or 0), reverse=True)
|
||||||
|
history = open_rows + closed
|
||||||
|
live_ids = {str(x.get("inst_id") or "") for x in open_rows}
|
||||||
|
return jsonify({"ok": True, "history": history, "live_inst_ids": sorted(live_ids)})
|
||||||
|
|
||||||
@app.route("/api/options/stats")
|
@app.route("/api/options/stats")
|
||||||
@lr
|
@lr
|
||||||
@@ -867,26 +939,13 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
return jsonify({"ok": True, **compute_options_stats(cfg["get_db"])})
|
return jsonify({"ok": True, **compute_options_stats(cfg["get_db"])})
|
||||||
|
|
||||||
@app.route("/api/options/history/<int:trade_id>", methods=["DELETE"])
|
@app.route("/api/options/history/<path:trade_id>", methods=["DELETE"])
|
||||||
@lr
|
@lr
|
||||||
def api_options_history_delete(trade_id: int):
|
def api_options_history_delete(trade_id: str):
|
||||||
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})
|
||||||
conn = cfg["get_db"]()
|
return jsonify({"ok": False, "msg": "历史仓位来自交易所,不支持本地删除"})
|
||||||
try:
|
|
||||||
init_options_tables(conn)
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT id, status FROM options_trades WHERE id = ?",
|
|
||||||
(trade_id,),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return jsonify({"ok": False, "msg": "记录不存在"})
|
|
||||||
conn.execute("DELETE FROM options_trades WHERE id = ?", (trade_id,))
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"ok": True})
|
|
||||||
|
|
||||||
|
|
||||||
def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
||||||
|
|||||||
@@ -198,11 +198,10 @@
|
|||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>盈亏</th>
|
<th>盈亏</th>
|
||||||
<th>时间</th>
|
<th>时间</th>
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="opt-history-tbody">
|
<tbody id="opt-history-tbody">
|
||||||
<tr><td colspan="7" class="muted">加载中…</td></tr>
|
<tr><td colspan="6" class="muted">加载中…</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,4 +211,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||||
<script src="/static/options_panel.js?v=23"></script>
|
<script src="/static/options_panel.js?v=24"></script>
|
||||||
|
|||||||
@@ -1,11 +1,44 @@
|
|||||||
"""期权平仓/到期状态同步单测."""
|
"""期权平仓/到期状态同步单测."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from lib.exchange.okx_options_lib import resolve_option_close_from_history
|
from lib.exchange.okx_options_lib import (
|
||||||
|
format_option_history_row,
|
||||||
|
format_usdc_amount,
|
||||||
|
resolve_option_close_from_history,
|
||||||
|
)
|
||||||
from lib.options.options_db import init_options_tables
|
from lib.options.options_db import init_options_tables
|
||||||
from lib.options.options_monitor_lib import sync_open_options_trades
|
from lib.options.options_monitor_lib import sync_open_options_trades
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_usdc_amount():
|
||||||
|
assert format_usdc_amount(4.896) == "4.896"
|
||||||
|
assert format_usdc_amount(4.90) == "4.9"
|
||||||
|
assert format_usdc_amount(4.0) == "4"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_option_history_row():
|
||||||
|
raw = {
|
||||||
|
"instId": "BTC-USD_UM-260710-62000-P",
|
||||||
|
"openAvgPx": "380",
|
||||||
|
"closeAvgPx": "0",
|
||||||
|
"closeTotalPos": "1",
|
||||||
|
"openMaxPos": "1",
|
||||||
|
"realizedPnl": "-3.99",
|
||||||
|
"pnlRatio": "-1.049",
|
||||||
|
"type": "2",
|
||||||
|
"cTime": "1784000000000",
|
||||||
|
"uTime": "1784088035000",
|
||||||
|
"posId": "pos-btc",
|
||||||
|
}
|
||||||
|
row = format_option_history_row(raw, tick_sz="0.1", ct_mult=0.01)
|
||||||
|
assert row["inst_id"] == "BTC-USD_UM-260710-62000-P"
|
||||||
|
assert row["sheets"] == 1
|
||||||
|
assert row["realized_pnl"] == -3.99
|
||||||
|
assert row["status_label"] == "已平"
|
||||||
|
assert row["open_avg_px_fmt"] == "380"
|
||||||
|
assert row["premium_paid_fmt"] == "3.8"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_option_close_from_history_picks_latest():
|
def test_resolve_option_close_from_history_picks_latest():
|
||||||
rows = [
|
rows = [
|
||||||
{"instId": "ETH-USD_UM-260709-1700-P", "uTime": "1000", "realizedPnl": "-1.0", "closeAvgPx": "0"},
|
{"instId": "ETH-USD_UM-260709-1700-P", "uTime": "1000", "realizedPnl": "-1.0", "closeAvgPx": "0"},
|
||||||
|
|||||||
Reference in New Issue
Block a user