Initial CoinOX instance: coin-margined options + USDT hedge.

Forked from crypto_monitor OKX instance (copy-only). Keeps standalone options, perp-options and options-options hedge; drops standalone perp entrypoints; forces coin margin; adds sim wallets; default port 5010.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-14 00:18:40 +08:00
commit 88b7fb7a0a
381 changed files with 104628 additions and 0 deletions
View File
+442
View File
@@ -0,0 +1,442 @@
"""期权平仓执行:只锁买一限价卖出;永不市价."""
from __future__ import annotations
import time
from typing import Any
from lib.options.options_close_gate_lib import (
clear_close_gate,
is_close_gate_passed,
mark_close_gate_passed,
update_close_gate,
)
from lib.options.options_pricing_lib import (
PREMIUM_DECIMALS_COIN,
estimate_close_by_bids,
fetch_option_mark_px,
is_stub_bid_px,
round_premium_amount,
total_premium,
)
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _open_premium_paid(cfg: dict[str, Any], inst_id: str) -> float | None:
try:
conn = cfg["get_db"]()
try:
from lib.options.options_db import init_options_tables, sum_open_premium_paid
init_options_tables(conn)
return sum_open_premium_paid(conn, inst_id)
finally:
conn.close()
except Exception:
pass
return None
def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
from lib.exchange.okx_options_lib import option_fields_from_inst_id
from lib.options.options_pricing_lib import close_ref_prices
inst_id = str(pos.get("instId") or pos.get("inst_id") or "")
mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark"))
if mark is None:
mark = fetch_option_mark_px(ex, inst_id)
opt_type = pos.get("optType") or (quote or {}).get("opt_type")
strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike"))
if not opt_type or strike is None:
pt, ps = option_fields_from_inst_id(inst_id)
opt_type = opt_type or pt
if strike is None:
strike = ps
idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
return close_ref_prices(
mark_px=mark,
opt_type=str(opt_type or ""),
strike=strike,
index_px=idx,
inst_id=inst_id,
)
def _avail_sheets(pos: dict[str, Any]) -> int:
avail = _safe_float(pos.get("availPos"))
if avail is None or avail <= 0:
avail = abs(_safe_float(pos.get("pos")) or 0)
return max(0, int(avail or 0))
def _cancel_sell_pending(ex: Any, inst_id: str) -> None:
try:
pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {}
for o in pending.get("data") or []:
if str(o.get("side") or "").lower() != "sell":
continue
oid = o.get("ordId")
if not oid:
continue
try:
ex.private_post_trade_cancel_order({"instId": inst_id, "ordId": oid})
except Exception:
pass
except Exception:
pass
def close_option_by_bid1(
cfg: dict[str, Any],
ex: Any,
inst_id: str,
*,
sheets: int | None = None,
require_recycle_gate: bool = False,
signal_note: str | None = None,
) -> dict[str, Any]:
"""
本轮只吃买一深度:
- 本批张数 = min(请求张数, 持仓, 买一深度)
- 限价 = 校验通过时锁定的买一价
- 永不市价
- 始终校验有效流动性(残档买一禁止)
- require_recycle_gate=True 时:首次还需目标门控(权利金×倍数或净盈亏,U 口径)并持续 hold 秒;
一旦通过后对同仓续批只验流动性
"""
from lib.exchange.okx_options_lib import (
_pos_side_from_position,
invalidate_option_positions_cache,
)
inst_id = (inst_id or "").strip()
if not inst_id:
return {"ok": False, "msg": "缺少 inst_id"}
q = cfg["quote_option_contract"](ex, inst_id)
if not q.get("ok"):
return {"ok": False, "msg": q.get("msg") or "报价失败"}
tick_sz = q.get("tick_sz")
ct_mult = float(q.get("ct_mult") or 0.01)
raw_positions = cfg["fetch_option_positions"](ex)
if raw_positions is None:
return {"ok": False, "msg": "获取期权持仓失败"}
pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
if not pos:
clear_close_gate(inst_id)
return {"ok": False, "msg": "未找到持仓", "already_flat": True}
avail = _avail_sheets(pos)
want = int(sheets) if sheets else avail
want = min(want, avail)
if want < 1:
clear_close_gate(inst_id)
return {"ok": False, "msg": "可平张数不足", "already_flat": True}
td_mode = str(pos.get("mgnMode") or cfg.get("td_mode") or "isolated")
pos_side = _pos_side_from_position(pos) or "net"
mark_px, intrinsic_px = _pos_close_refs(ex, pos, q)
premium_paid = _open_premium_paid(cfg, inst_id)
if premium_paid is None:
premium_paid = _safe_float(pos.get("premium_paid"))
premium_ccy = str(q.get("premium_ccy") or pos.get("premium_ccy") or "USDC").strip().upper() or "USDC"
index_px = _safe_float(pos.get("idxPx")) or _safe_float(q.get("index_px"))
# 已有未成交卖平单:等成交,不撤不重挂
try:
pending = ex.private_get_trade_orders_pending({"instType": "OPTION", "instId": inst_id}) or {}
sell_pending = [
o
for o in (pending.get("data") or [])
if str(o.get("side") or "").lower() == "sell" and o.get("ordId")
]
if sell_pending:
time.sleep(0.5)
invalidate_option_positions_cache()
raw_positions = cfg["fetch_option_positions"](ex)
if raw_positions is None:
return {"ok": False, "msg": "获取期权持仓失败"}
pos = next((p for p in raw_positions if str(p.get("instId")) == inst_id), None)
if not pos or _avail_sheets(pos) < 1:
clear_close_gate(inst_id)
return {
"ok": True,
"already_flat": True,
"msg": "已有限价卖单成交",
"close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending),
"fully_closed": True,
"submitted_sheets": want,
"remaining_sheets": 0,
"mode": "bid1",
}
return {
"ok": False,
"msg": "等待已有买一限价卖单成交",
"stopped_reason": "pending_close_order",
"close_ord_id": ",".join(str(o.get("ordId")) for o in sell_pending),
}
except Exception:
pass
book = cfg["fetch_option_book_depth"](ex, inst_id, 1)
preview = estimate_close_by_bids(
book.get("bids") or [],
want,
ct_mult=ct_mult,
premium_paid=premium_paid,
mark_px=mark_px,
intrinsic_px=intrinsic_px,
max_levels=1,
)
if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
# 不撤他人挂单:仅拒绝本轮下单
update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return {
"ok": False,
"msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓",
"stopped_reason": "stub_bid",
"auto_close_blocked": True,
"liquidity_blocked": True,
}
levels = preview.get("levels") or []
if not levels:
bid_px = _safe_float(q.get("bid"))
stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
if stub or bid_px is None or bid_px <= 0:
update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return {
"ok": False,
"msg": stub_reason or "暂无买一,无法限价平仓",
"stopped_reason": "stub_bid" if stub else "no_bid",
"auto_close_blocked": True,
"liquidity_blocked": True,
}
return {
"ok": False,
"msg": "暂无买一深度,无法平仓",
"stopped_reason": "no_bid_depth",
"liquidity_blocked": True,
}
level = levels[0]
level_sheets = int(level.get("sheets") or 0)
level_px = float(level.get("px") or 0)
if level_sheets <= 0 or level_px <= 0:
return {"ok": False, "msg": "买一深度无效", "stopped_reason": "invalid_bid_depth"}
stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
if stub_lv:
update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return {
"ok": False,
"msg": stub_lv_reason or "暂无有效买盘,禁止平仓",
"stopped_reason": "stub_bid",
"auto_close_blocked": True,
"liquidity_blocked": True,
}
# 自动平仓:2×权利金门控(首次);通过后同仓续批只验流动性
gate = update_close_gate(
inst_id,
recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"):
return {
"ok": False,
"msg": gate.get("msg") or "平仓门控未就绪(需可回收≥2×权利金并持续一段时间)",
"stopped_reason": "close_gate",
"auto_close_blocked": True,
"close_gate": gate,
}
locked_bid_px = level_px
before_avail = avail
order = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="sell",
sheets=level_sheets,
price=locked_bid_px,
td_mode=td_mode,
tick_sz=tick_sz,
reduce_only=True,
pos_side=pos_side,
)
if not order.get("ok"):
return {
"ok": False,
"msg": order.get("msg") or "买一限价平仓失败",
"stopped_reason": "order_failed",
"locked_bid_px": locked_bid_px,
"batch_sheets": level_sheets,
}
# 仅下单被接受后才记门控已通过,避免下单失败却跳过后续 2× 等待
if require_recycle_gate and gate.get("ready"):
mark_close_gate_passed(inst_id)
px = float(order.get("px", locked_bid_px))
oid = str((order.get("data") or {}).get("ordId") or "")
prem_recv = round_premium_amount(
total_premium(px, level_sheets * ct_mult), decimals=PREMIUM_DECIMALS_COIN
) or 0.0
time.sleep(0.6)
invalidate_option_positions_cache()
raw2 = cfg["fetch_option_positions"](ex)
if raw2 is None:
return {
"ok": False,
"msg": "下单后获取持仓失败,未确认是否成交",
"stopped_reason": "position_fetch_failed",
"locked_bid_px": locked_bid_px,
"batch_sheets": level_sheets,
"close_ord_id": oid or None,
"fully_closed": False,
}
after_pos = next((p for p in raw2 if str(p.get("instId")) == inst_id), None)
after_avail = _avail_sheets(after_pos) if after_pos else 0
reduced = max(0, before_avail - after_avail)
remaining_pos = after_avail
fully_closed = remaining_pos < 1
if fully_closed:
clear_close_gate(inst_id)
conn = cfg["get_db"]()
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
open_rows = conn.execute(
"""
SELECT id, premium_paid FROM options_trades
WHERE inst_id = ? AND status = 'open'
ORDER BY id ASC
""",
(inst_id,),
).fetchall()
total_paid = sum(float(r["premium_paid"] or 0) for r in open_rows)
allocated = 0.0
for i, row in enumerate(open_rows):
paid = float(row["premium_paid"] or 0)
if i == len(open_rows) - 1:
recv = round_premium_amount(
prem_recv - allocated, decimals=PREMIUM_DECIMALS_COIN
) or 0.0
elif total_paid > 0:
recv = round_premium_amount(
prem_recv * (paid / total_paid), decimals=PREMIUM_DECIMALS_COIN
) or 0.0
allocated += recv
else:
recv = round_premium_amount(
prem_recv / len(open_rows), decimals=PREMIUM_DECIMALS_COIN
) or 0.0
allocated += recv
pnl = round_premium_amount(recv - paid, decimals=PREMIUM_DECIMALS_COIN) or 0.0
note_sql = ""
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] = [px, recv, pnl, oid or None, options_app_now_str()]
if signal_note and i == len(open_rows) - 1:
note_sql = """,
signal_note = CASE
WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN ?
ELSE signal_note
END"""
params.append(signal_note)
params.append(int(row["id"]))
conn.execute(
f"""
UPDATE options_trades
SET status = 'closed', close_quote = ?, premium_received = ?,
realized_pnl = ?, close_ord_id = ?, closed_at = ?
{note_sql}
WHERE id = ?
""",
tuple(params),
)
conn.commit()
finally:
conn.close()
elif require_recycle_gate:
# 自动平已挂过单:同仓续批只验流动性
mark_close_gate_passed(inst_id)
out = {
"ok": True,
"mode": "bid1",
"orders": [{"order": order, "px": px, "sheets": level_sheets}],
"bid": px,
"locked_bid_px": locked_bid_px,
"submitted_sheets": level_sheets,
"filled_or_reduced_sheets": min(reduced, level_sheets) if reduced else 0,
"remaining_sheets": remaining_pos,
"premium_received": prem_recv,
"stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"),
"close_ord_id": oid or None,
"fully_closed": fully_closed,
"msg": (
f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张"
+ ("" if fully_closed else f",剩余 {remaining_pos} 张待下次平仓")
),
}
if fully_closed:
try:
from lib.options.options_coin_open_lib import maybe_sell_spot_after_close
spot_sell = maybe_sell_spot_after_close(cfg, ex, inst_id=inst_id, close_result=out)
if spot_sell is not None:
out["spot_sell"] = spot_sell
if spot_sell.get("bridge_status") == "pending_sell_spot":
out["msg"] = str(out.get("msg") or "") + ";卖回 USDT 失败,请重试卖回"
except Exception as e:
out["spot_sell"] = {"ok": False, "msg": str(e)}
return out
# 兼容旧名
def close_option_by_bid_depth(
cfg: dict[str, Any],
ex: Any,
inst_id: str,
*,
sheets: int | None = None,
) -> dict[str, Any]:
return close_option_by_bid1(
cfg,
ex,
inst_id,
sheets=sheets,
require_recycle_gate=True,
signal_note="目标位平仓",
)
+334
View File
@@ -0,0 +1,334 @@
"""期权按买盘平仓门控:可回收/净盈亏换算为 USDT 后校验,并持续 hold 秒才允许平仓."""
from __future__ import annotations
import os
import threading
import time
from typing import Any
def _env_float(key: str, default: float) -> float:
try:
return float(os.getenv(key, str(default)))
except (TypeError, ValueError):
return default
def _env_optional_float(key: str) -> float | None:
raw = os.getenv(key)
if raw is None or str(raw).strip() == "":
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0)
def close_net_pnl_min_u() -> float:
return _env_float("OKX_OPTIONS_CLOSE_NET_PNL_MIN_U", 0.0)
def close_gate_mode() -> str:
m = (os.getenv("OKX_OPTIONS_CLOSE_GATE_MODE") or "premium").strip().lower()
return m if m in ("premium", "net_pnl") else "premium"
def resolve_close_recycle_mult(premium_ccy: str | None, min_mult: float | None = None) -> float:
if min_mult is not None:
m = float(min_mult)
return m if m > 0 else 1.05
global_mult = _env_optional_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT")
if global_mult is not None and global_mult > 0:
return global_mult
ccy = (premium_ccy or "USDC").strip().upper() or "USDC"
if ccy in ("ETH", "BTC"):
return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", 1.05)
return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_USDC", 2.0)
# 兼容旧引用
CLOSE_RECYCLE_MIN_MULT = resolve_close_recycle_mult("USDC")
_lock = threading.Lock()
# inst_id -> {"ok_since": float|None, "recycle": float, "premium": float, "updated": float}
_gates: dict[str, dict[str, Any]] = {}
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _normalize_premium_ccy(premium_ccy: str | None) -> str:
ccy = (premium_ccy or "USDC").strip().upper() or "USDC"
if ccy not in ("ETH", "BTC", "USDC"):
ccy = "USDC"
return ccy
def _to_usdt(amount: float | None, ccy: str, index_px: float | None) -> float | None:
if amount is None:
return None
unit = _normalize_premium_ccy(ccy)
if unit in ("ETH", "BTC"):
idx = _safe_float(index_px)
if idx is None or idx <= 0:
return None
return float(amount) * float(idx)
return float(amount)
def _fmt_gate_amt(v: float, *, ccy: str) -> str:
unit = _normalize_premium_ccy(ccy)
if unit in ("ETH", "BTC"):
txt = f"{float(v):.8f}".rstrip("0").rstrip(".")
return txt or "0"
return f"{float(v):.4f}"
def _fmt_usdt(v: float | None) -> str:
if v is None:
return "—"
return f"{float(v):.2f}"
def clear_close_gate(inst_id: str | None = None) -> None:
with _lock:
if inst_id:
_gates.pop(str(inst_id).strip(), None)
else:
_gates.clear()
def mark_close_gate_passed(inst_id: str) -> None:
"""标记同仓已通过门控,续批平仓只验流动性."""
inst = (inst_id or "").strip()
if not inst:
return
with _lock:
st = _gates.get(inst) or {}
st["passed"] = True
st["updated"] = time.time()
_gates[inst] = st
def is_close_gate_passed(inst_id: str) -> bool:
inst = (inst_id or "").strip()
if not inst:
return False
with _lock:
return bool((_gates.get(inst) or {}).get("passed"))
def update_close_gate(
inst_id: str,
*,
recycle_usdc: float | None,
premium_paid: float | None,
now: float | None = None,
min_mult: float | None = None,
hold_seconds: float | None = None,
premium_ccy: str | None = None,
index_px: float | None = None,
) -> dict[str, Any]:
"""
根据当前买盘可回收金额刷新门控(比较口径均为 USDT 估值).
premium 模式:可回收(U) ≥ 权利金(U) × 倍数
net_pnl 模式:净盈亏(U) > OKX_OPTIONS_CLOSE_NET_PNL_MIN_U
"""
inst = (inst_id or "").strip()
if not inst:
return {
"ok": False,
"ready": False,
"recycle_ok": False,
"msg": "缺少合约",
}
ts = float(now if now is not None else time.time())
mode = close_gate_mode()
hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS)
if hold < 0:
hold = 0.0
with _lock:
prev_ccy = (_gates.get(inst) or {}).get("premium_ccy")
ccy = _normalize_premium_ccy(premium_ccy or prev_ccy)
mult = resolve_close_recycle_mult(ccy, min_mult)
min_pnl_u = close_net_pnl_min_u()
prem = _safe_float(premium_paid)
recv = _safe_float(recycle_usdc)
recv_u = _to_usdt(recv, ccy, index_px)
prem_u = _to_usdt(prem, ccy, index_px)
net_u = round(recv_u - prem_u, 4) if recv_u is not None and prem_u is not None else None
need_u = round(prem_u * mult, 4) if prem_u is not None and prem_u > 0 and mode == "premium" else None
missing_index = ccy in ("ETH", "BTC") and (index_px is None or _safe_float(index_px) is None or _safe_float(index_px) <= 0)
if missing_index and prem is not None and prem > 0 and recv is not None:
recycle_ok = False
elif mode == "net_pnl":
recycle_ok = bool(net_u is not None and net_u > min_pnl_u + 1e-9)
else:
recycle_ok = bool(
prem_u is not None
and prem_u > 0
and recv_u is not None
and need_u is not None
and recv_u + 1e-9 >= need_u
)
with _lock:
prev = _gates.get(inst) or {}
ok_since = prev.get("ok_since")
if recycle_ok:
if ok_since is None:
ok_since = ts
else:
ok_since = None
held = (ts - float(ok_since)) if ok_since is not None else 0.0
ready = bool(recycle_ok and held + 1e-9 >= hold)
prev_passed = bool(prev.get("passed"))
passed = prev_passed or ready
state = {
"ok_since": ok_since,
"recycle": recv,
"premium": prem,
"need": need_u,
"updated": ts,
"min_mult": mult,
"hold_seconds": hold,
"passed": passed,
"premium_ccy": ccy,
"gate_mode": mode,
"index_px": _safe_float(index_px),
"recycle_usdt": recv_u,
"premium_usdt": prem_u,
"need_recycle_usdt": need_u,
"net_pnl_usdt": net_u,
"net_pnl_min_u": min_pnl_u if mode == "net_pnl" else None,
}
_gates[inst] = state
remain = max(0.0, hold - held) if recycle_ok and not ready else None
if prem is None or prem <= 0:
msg = "缺少权利金,无法校验平仓门控"
elif recv is None:
msg = "暂无有效买盘可回收金额"
elif missing_index:
msg = "缺少指数价,无法按 USDT 校验目标平仓门控"
elif mode == "net_pnl":
if not recycle_ok:
msg = (
f"净盈亏 {_fmt_usdt(net_u)}U(估) ≤ {_fmt_usdt(min_pnl_u)}U,"
f"目标平仓门控未过"
)
elif not ready:
msg = (
f"净盈亏 {_fmt_usdt(net_u)}U(估) 已>{_fmt_usdt(min_pnl_u)}U,"
f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过"
)
else:
msg = (
f"净盈亏 {_fmt_usdt(net_u)}U(估) 已>{_fmt_usdt(min_pnl_u)}U"
f"且持续≥{hold:.0f}s,目标触达后可按买一平仓"
)
elif not recycle_ok:
msg = (
f"可回收 {_fmt_usdt(recv_u)}U(估) < 权利金×{mult:g}"
f"({_fmt_usdt(need_u)}U),目标平仓门控未过"
)
elif not ready:
msg = (
f"可回收 {_fmt_usdt(recv_u)}U(估) 已达×{mult:g}"
f"({_fmt_usdt(recv_u)}/{_fmt_usdt(need_u)}U),"
f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过"
)
else:
msg = (
f"可回收 {_fmt_usdt(recv_u)}U(估) 已达×{mult:g}且持续≥{hold:.0f}s,"
f"目标触达后可按买一平仓"
)
auto_blocked = not (ready or passed)
return {
"ok": True,
"ready": ready,
"passed": passed,
"recycle_ok": recycle_ok,
"recycle_usdc": recv,
"premium_paid": prem,
"need_recycle_usdc": need_u,
"premium_ccy": ccy,
"min_mult": mult,
"hold_seconds": hold,
"held_seconds": round(held, 1) if recycle_ok else 0.0,
"remain_seconds": round(remain, 1) if remain is not None else None,
"ok_since": ok_since,
"msg": msg,
"gate_mode": mode,
"index_px": _safe_float(index_px),
"recycle_usdt": recv_u,
"premium_usdt": prem_u,
"need_recycle_usdt": need_u,
"net_pnl_usdt": net_u,
"net_pnl_min_u": min_pnl_u if mode == "net_pnl" else None,
"auto_close_blocked": auto_blocked,
"close_gate_blocked": auto_blocked,
}
def check_close_gate(
inst_id: str,
*,
recycle_usdc: float | None = None,
premium_paid: float | None = None,
premium_ccy: str | None = None,
index_px: float | None = None,
refresh: bool = True,
) -> dict[str, Any]:
"""检查是否允许平仓;默认先用最新回收/权利金刷新."""
inst = (inst_id or "").strip()
if refresh:
if recycle_usdc is None or premium_paid is None or premium_ccy is None or index_px is None:
with _lock:
prev = _gates.get(inst) or {}
if recycle_usdc is None:
recycle_usdc = prev.get("recycle")
if premium_paid is None:
premium_paid = prev.get("premium")
if premium_ccy is None:
premium_ccy = prev.get("premium_ccy")
if index_px is None:
index_px = prev.get("index_px")
return update_close_gate(
inst,
recycle_usdc=recycle_usdc,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
with _lock:
prev = _gates.get(inst)
if not prev:
return update_close_gate(
inst,
recycle_usdc=recycle_usdc,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return update_close_gate(
inst,
recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"),
premium_paid=premium_paid if premium_paid is not None else prev.get("premium"),
premium_ccy=premium_ccy if premium_ccy is not None else prev.get("premium_ccy"),
index_px=index_px if index_px is not None else prev.get("index_px"),
)
+484
View File
@@ -0,0 +1,484 @@
"""币本位单笔期权:买满 USDT→币 → 开满期权 → 平后卖回."""
from __future__ import annotations
import os
import time
from typing import Any
from lib.exchange.okx_options_lib import (
cap_option_buy_sheets_to_ask_depth,
option_buy_liquidity_ok,
td_mode_for_option_buy,
wait_option_order_full_fill,
)
from lib.options.options_margin_mode_lib import (
calc_sheets_from_coin_balance,
compute_coin_budget_usdt,
is_coin_margin_mode,
margin_mode_from_inst_id,
normalize_options_margin_mode,
plan_coin_open_by_budget,
premium_ccy_for_mode,
)
from lib.options.options_spot_bridge_lib import (
BRIDGE_BOUGHT,
BRIDGE_HOLDING,
bridge_blocks_new_open_msg,
fetch_trading_coin_available,
insert_bridge,
rollback_bought_coin_to_usdt,
sell_residual_after_option_flat,
spot_market_buy_coin_with_usdt,
update_bridge,
)
def coin_budget_preview(cfg: dict[str, Any], ex: Any) -> dict[str, Any]:
from lib.exchange.okx_options_lib import fetch_options_balances
bal = cfg.get("fetch_options_balances")(ex, force=True) if callable(cfg.get("fetch_options_balances")) else fetch_options_balances(ex, force=True)
trading = bal.get("trading_usdt_avail")
if trading is None:
trading = bal.get("trading_usdt")
try:
trading_f = float(trading or 0)
except (TypeError, ValueError):
trading_f = 0.0
buf = float(cfg.get("budget_buffer") or 0.95)
return compute_coin_budget_usdt(trading_f, buffer=buf)
def open_coin_option_buy_full(
cfg: dict[str, Any],
ex: Any,
*,
inst_id: str,
signal_note: str = "",
target_index: float | None = None,
profit_exit_enabled: bool = False,
profit_exit_mult: float = 1.0,
target_sheets: int | None = None,
) -> dict[str, Any]:
"""先按最大可开张数估权利金×现货缓冲买币,再开对应张数(不全额兑换预算)."""
from lib.options.options_db import init_options_tables
from lib.options.options_position_limit_lib import (
compound_full_single_position_block_msg,
option_position_limit_block_msg,
)
if not is_coin_margin_mode():
return {"ok": False, "msg": "当前非币本位模式"}
if margin_mode_from_inst_id(inst_id) != "coin":
return {"ok": False, "msg": "合约不是币本位期权(请确认未选中 USD_UM 合约)"}
# 解析标的
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
conn = cfg["get_db"]()
try:
init_options_tables(conn)
block = bridge_blocks_new_open_msg(conn)
if block:
return {"ok": False, "msg": block, "can_open": False}
compound_block = compound_full_single_position_block_msg(
ex, fetch_positions=cfg.get("fetch_option_positions")
)
if compound_block:
return {"ok": False, "msg": compound_block, "can_open": False}
pos_limit_msg = option_position_limit_block_msg(
ex,
opening_inst_id=inst_id,
fetch_positions=cfg.get("fetch_option_positions"),
)
if pos_limit_msg:
return {"ok": False, "msg": pos_limit_msg, "can_open": False}
budget_info = coin_budget_preview(cfg, ex)
if not budget_info.get("ok"):
return {"ok": False, "msg": budget_info.get("msg") or "USDT 预算无效", "budget": budget_info}
budget_usdt = float(budget_info["budget_usdt"])
q = cfg["quote_option_contract"](ex, inst_id)
if not q.get("ok"):
return q
ask = q.get("ask")
ask_sz = q.get("ask_sz")
can_open, block_msg = option_buy_liquidity_ok(ask, ask_sz)
if not can_open:
return {
"ok": False,
"msg": block_msg or "暂无卖一深度,无法买入",
"can_open": False,
}
ct_mult = float(q.get("ct_mult") or 0.01)
min_sz = int(q.get("min_sz") or 1)
idx = None
try:
idx = float(q.get("index_px") or q.get("idxPx") or 0)
except (TypeError, ValueError):
idx = 0.0
if idx <= 0:
try:
from lib.exchange.okx_options_lib import fetch_index_price
idx = float(fetch_index_price(ex, f"{underlying}-USD") or 0)
except Exception:
idx = 0.0
plan = plan_coin_open_by_budget(
quote_per_unit=float(ask),
ct_mult=ct_mult,
min_sz=min_sz,
budget_usdt=budget_usdt,
index_px=float(idx),
ask_sz=ask_sz,
target_sheets=target_sheets,
)
if not plan.get("ok"):
return {
"ok": False,
"msg": plan.get("msg") or "无法规划买币张数",
"plan": plan,
"budget": budget_info,
"can_open": False,
}
buy_usdt = float(plan["buy_usdt"])
sheets = int(plan["sheets"])
# 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算
coin_before = fetch_trading_coin_available(ex, underlying) or 0.0
buy = spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt)
if not buy.get("ok"):
return {
"ok": False,
"msg": f"现货买入 {underlying} 失败: {buy.get('msg')}",
"budget": budget_info,
"plan": plan,
}
bridge_id = insert_bridge(
conn,
underlying=underlying,
status=BRIDGE_BOUGHT,
budget_usdt=buy_usdt,
buy_ord_id=str(buy.get("ord_id") or ""),
inst_id=inst_id,
message="已买币,待开期权",
)
# 等余额落账
time.sleep(1.5)
try:
from lib.exchange.okx_options_lib import invalidate_options_balance_cache
invalidate_options_balance_cache()
except Exception:
pass
coin_after = fetch_trading_coin_available(ex, underlying)
if coin_after is None:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买币后读不到可用余额"
)
return {
"ok": False,
"msg": "买币后读不到可用余额,已尝试卖回 USDT",
"rollback": rb,
"budget": budget_info,
"plan": plan,
}
coin_bought = max(0.0, float(coin_after) - float(coin_before or 0))
if coin_bought <= 0:
# 落账延迟时退化为用当前可用,但仍写入上限提示
coin_bought = float(coin_after)
if coin_bought <= 0:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="买入量无效"
)
return {"ok": False, "msg": "买币后可用增量无效", "rollback": rb, "budget": budget_info}
update_bridge(conn, bridge_id, coin_bought=float(coin_bought))
sizing = calc_sheets_from_coin_balance(
quote_per_unit=float(ask),
ct_mult=ct_mult,
min_sz=min_sz,
coin_available=float(coin_bought),
)
if not sizing.get("ok"):
rb = rollback_bought_coin_to_usdt(
conn,
ex,
bridge_id=bridge_id,
underlying=underlying,
reason=sizing.get("msg") or "张数不足",
coin_amount=float(coin_bought),
)
return {"ok": False, "msg": sizing.get("msg"), "sizing": sizing, "rollback": rb, "budget": budget_info, "plan": plan}
# 实盘以买到的币为准,但不超过规划张数
sheets = min(int(sizing["sheets"]), int(plan["sheets"]))
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=min_sz)
if capped is None:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason=cap_msg or "深度不足"
)
return {"ok": False, "msg": cap_msg or "卖一深度不足", "rollback": rb}
if capped < sheets:
sheets = int(capped)
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
"ask_depth_capped": True,
}
else:
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
}
tick_sz = q.get("tick_sz")
order = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="buy",
sheets=sheets,
price=float(ask),
td_mode=td_mode_for_option_buy(cfg.get("td_mode")),
tick_sz=tick_sz,
ord_type="ioc",
)
# 51008 时自动减半张数再试一次(买币已到位,避免整笔回滚)
if (not order.get("ok")) and sheets > 1:
msg_l = str(order.get("msg") or "").lower()
if "51008" in str(order.get("raw") or "").lower() or "不足" in str(order.get("msg") or ""):
sheets2 = max(1, sheets // 2)
if sheets2 < sheets:
order2 = cfg["place_option_limit_order"](
ex,
inst_id=inst_id,
side="buy",
sheets=sheets2,
price=float(ask),
td_mode=td_mode_for_option_buy(cfg.get("td_mode")),
tick_sz=tick_sz,
ord_type="ioc",
)
if order2.get("ok"):
order = order2
sheets = sheets2
sizing = {
"ok": True,
"sheets": sheets,
"eth_amount": round(sheets * ct_mult, 8),
"coin_premium": round(sheets * float(ask) * ct_mult, 8),
"retried_half": True,
}
if not order.get("ok"):
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason=order.get("msg") or "下单失败"
)
return {"ok": False, "msg": order.get("msg") or "期权下单失败", "order": order, "rollback": rb}
ord_id = str((order.get("data") or {}).get("ordId") or "").strip()
if not ord_id:
rb = rollback_bought_coin_to_usdt(
conn, ex, bridge_id=bridge_id, underlying=underlying, reason="无订单号"
)
return {"ok": False, "msg": "下单成功但未返回订单号", "rollback": rb}
try:
fill_timeout = max(2.0, float(os.getenv("OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC") or "12"))
except (TypeError, ValueError):
fill_timeout = 12.0
fill = wait_option_order_full_fill(
ex,
inst_id=inst_id,
ord_id=ord_id,
need_sheets=int(sheets),
timeout_sec=fill_timeout,
cancel_on_timeout=True,
)
if not fill.get("ok"):
filled_n = int(fill.get("filled_sheets") or 0)
if filled_n <= 0:
rb = rollback_bought_coin_to_usdt(
conn,
ex,
bridge_id=bridge_id,
underlying=underlying,
reason=fill.get("msg") or "未成交",
)
return {"ok": False, "msg": fill.get("msg") or "未完全成交", "fill": fill, "rollback": rb}
sheets = filled_n
eth_amount = round(float(sheets) * ct_mult, 8)
premium_paid = round(float(ask) * eth_amount, 8)
premium_ccy = premium_ccy_for_mode("coin", underlying)
update_bridge(
conn,
bridge_id,
status=BRIDGE_HOLDING,
inst_id=inst_id,
message="期权持仓中",
)
trade_id = _insert_coin_trade(
conn,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
strike=q.get("strike"),
exp_time=q.get("exp_time"),
sheets=int(sheets),
eth_amount=eth_amount,
open_quote=float(ask),
premium_paid=premium_paid,
signal_note=signal_note,
exchange_ord_id=ord_id,
bridge_id=bridge_id,
budget_usdt=buy_usdt,
premium_ccy=premium_ccy,
profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult,
)
# 目标位 / 翻倍离场 — 复用现有逻辑若存在
try:
if target_index is not None:
from lib.options.options_target_lib import upsert_target_monitor
upsert_target_monitor(
conn,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
target_index=float(target_index),
trade_id=trade_id,
sheets=int(sheets),
)
except Exception:
pass
try:
from lib.options.options_notify_lib import notify_options_open
notify_options_open(
cfg,
conn,
trade_id=trade_id,
inst_id=inst_id,
underlying=underlying,
opt_type=str(q.get("opt_type") or ""),
sheets=int(sheets),
premium_paid=premium_paid,
open_quote=float(ask),
target_index=target_index,
signal_note=signal_note,
premium_ccy=premium_ccy,
margin_mode="coin",
)
except Exception:
pass
return {
"ok": True,
"msg": f"币本位开仓成功 {sheets} 张",
"margin_mode": "coin",
"budget": budget_info,
"sizing": sizing,
"sheets": sheets,
"eth_amount": eth_amount,
"premium_paid": premium_paid,
"premium_ccy": premium_ccy,
"bridge_id": bridge_id,
"trade_id": trade_id,
"order": order,
"fill": fill,
}
finally:
try:
conn.close()
except Exception:
pass
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"
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,
margin_mode, premium_ccy, bridge_id, budget_usdt,
profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?)
""",
(
kwargs["inst_id"],
kwargs["underlying"],
kwargs["opt_type"],
kwargs.get("strike"),
str(kwargs.get("exp_time") or ""),
kwargs["sheets"],
kwargs["eth_amount"],
kwargs.get("open_quote"),
kwargs.get("premium_paid"),
kwargs.get("signal_note") or "",
kwargs.get("exchange_ord_id"),
kwargs.get("premium_ccy") or "ETH",
kwargs.get("bridge_id"),
kwargs.get("budget_usdt"),
pe,
pe_mult,
pe_state,
options_app_now_str(),
),
)
conn.commit()
return int(cur.lastrowid)
def maybe_sell_spot_after_close(
cfg: dict[str, Any],
ex: Any,
*,
inst_id: str,
close_result: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""期权平仓后若该合约为币本位且已空仓,卖回本桥残留币."""
if margin_mode_from_inst_id(inst_id) != "coin":
return None
# 仍有仓则不卖
try:
rows = cfg["fetch_option_positions"](ex) or []
for p in rows:
if str(p.get("instId") or p.get("inst_id") or "") != inst_id:
continue
try:
if abs(float(p.get("pos") or 0)) > 1e-12:
return {"ok": True, "skipped": True, "msg": "仍有持仓,暂不卖币"}
except (TypeError, ValueError):
pass
except Exception:
pass
parts = inst_id.split("-")
underlying = (parts[0] if parts else "ETH").upper()
conn = cfg["get_db"]()
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
return sell_residual_after_option_flat(conn, ex, underlying=underlying, inst_id=inst_id)
finally:
try:
conn.close()
except Exception:
pass
+85
View File
@@ -0,0 +1,85 @@
"""实例数据看板用的轻量期权持仓(无余额/历史;含 close_preview 供净盈亏)."""
from __future__ import annotations
from typing import Any
def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict[str, Any]]:
"""
拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合.
不走 build_options_hub_snapshot(避免余额/历史).
"""
if not cfg.get("enabled"):
return []
ex = cfg.get("exchange_options")
ready_fn = cfg.get("options_api_ready")
if not callable(ready_fn):
return []
ok, _reason = ready_fn(ex)
if not ok:
return []
fetch_fn = cfg.get("fetch_option_positions")
if not callable(fetch_fn):
return []
raw = fetch_fn(ex)
if raw is None:
return []
if not raw:
return []
from lib.options.options_db import init_options_tables, sum_open_premium_paid
from lib.options.options_history_lib import enrich_position_row_display
from lib.options.options_positions_lib import attach_close_preview
meta_cache: dict[str, dict[str, Any] | None] = {}
rows: list[dict[str, Any]] = []
get_db = cfg.get("get_db")
if not callable(get_db):
for p in raw:
if not isinstance(p, dict):
continue
row = enrich_position_row_display(cfg, ex, p, meta_cache=meta_cache)
attach_close_preview(cfg, ex, row)
rows.append(row)
return rows
conn = get_db()
try:
init_options_tables(conn)
try:
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
from lib.options.options_target_lib import targets_by_inst
tgt_map = targets_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn)
except Exception:
tgt_map = {}
hedge_target_map = {}
for p in raw:
if not isinstance(p, dict):
continue
inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None
row = enrich_position_row_display(
cfg,
ex,
p,
meta_cache=meta_cache,
premium_override=premium_override,
)
attach_close_preview(cfg, ex, row, premium_paid=premium_override)
mon = tgt_map.get(str(row.get("inst_id") or ""))
if mon:
row["target_index"] = mon.get("target_index")
row["target_monitor_id"] = mon.get("id")
row["target_monitor"] = mon
hedge_target = hedge_target_map.get(str(row.get("inst_id") or ""))
if hedge_target:
row["hedge_plan_target"] = hedge_target
if not mon:
row["target_index"] = hedge_target.get("target_index")
rows.append(row)
finally:
conn.close()
return rows
+156
View File
@@ -0,0 +1,156 @@
"""期权模块 SQLite 表."""
from __future__ import annotations
import sqlite3
def init_options_tables(conn: sqlite3.Connection) -> None:
from lib.options.options_review_db import init_options_review_tables
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inst_id TEXT NOT NULL,
underlying TEXT NOT NULL,
opt_type TEXT NOT NULL,
strike REAL,
exp_time TEXT,
sheets INTEGER NOT NULL,
eth_amount REAL NOT NULL,
open_quote REAL,
premium_paid REAL,
status TEXT DEFAULT 'open',
close_quote REAL,
premium_received REAL,
realized_pnl REAL,
profit_alert_sent INTEGER DEFAULT 0,
signal_note TEXT,
exchange_ord_id TEXT,
close_ord_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_convert_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_ccy TEXT,
to_ccy TEXT,
rfq_sz REAL,
received_sz REAL,
quote_id TEXT,
status TEXT,
message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_history_hidden (
history_key TEXT PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_transfer_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ccy TEXT,
amount REAL,
from_account TEXT,
to_account TEXT,
status TEXT,
message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_target_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inst_id TEXT NOT NULL,
underlying TEXT,
opt_type TEXT,
target_index REAL NOT NULL,
trade_id INTEGER,
sheets INTEGER,
status TEXT DEFAULT 'active',
trigger_idx REAL,
close_ord_id TEXT,
message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
triggered_at TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status
ON options_target_monitors(status)
"""
)
for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
"ALTER TABLE options_trades ADD COLUMN margin_mode TEXT DEFAULT 'usdc'",
"ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'",
"ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER",
"ALTER TABLE options_trades ADD COLUMN budget_usdt REAL",
):
try:
conn.execute(ddl)
except Exception:
pass
try:
from lib.options.options_spot_bridge_lib import ensure_bridge_table
ensure_bridge_table(conn)
except Exception:
pass
init_options_review_tables(conn)
def sum_open_premium_paid(conn: sqlite3.Connection, inst_id: str) -> float | None:
"""同合约所有 open 腿权利金合计(加仓后显示/门控用)."""
inst = (inst_id or "").strip()
if not inst:
return None
row = conn.execute(
"""
SELECT SUM(premium_paid) AS total, COUNT(*) AS n
FROM options_trades
WHERE inst_id = ? AND status = 'open' AND premium_paid IS NOT NULL
""",
(inst,),
).fetchone()
if not row or int(row["n"] or 0) < 1:
return None
# 币本位权利金常 <1e-4,保留 8 位避免被裁成 0
return round(float(row["total"] or 0), 8)
def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
"""同合约所有 open 腿张数合计."""
inst = (inst_id or "").strip()
if not inst:
return None
row = conn.execute(
"""
SELECT SUM(sheets) AS total, COUNT(*) AS n
FROM options_trades
WHERE inst_id = ? AND status = 'open'
""",
(inst,),
).fetchone()
if not row or int(row["n"] or 0) < 1:
return None
return int(row["total"] or 0)
+149
View File
@@ -0,0 +1,149 @@
"""期权历史列表(交易所 positions-history + 当前持仓)."""
from __future__ import annotations
from typing import Any
from lib.exchange.okx_options_lib import format_premium_amount
from lib.options.options_db import init_options_tables, sum_open_premium_paid
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
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)
row_mode = margin_mode_from_inst_id(inst_id) if inst_id else "usdc"
underly = str(row.get("underlying") or (inst_id.split("-")[0] if inst_id else "ETH") or "ETH")
premium_ccy = premium_ccy_for_mode(row_mode, underly)
row["margin_mode"] = row_mode
row["premium_ccy"] = premium_ccy
row["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC"
if premium_override is not None:
row["premium_paid"] = premium_override
row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=premium_ccy)
return row
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _load_local_closed_by_inst(conn: Any) -> dict[str, dict[str, Any]]:
"""同合约取最新已平本地单,用于补交易所历史权利金/盈亏."""
out: dict[str, dict[str, Any]] = {}
try:
rows = conn.execute(
"""
SELECT inst_id, premium_paid, realized_pnl, premium_ccy, margin_mode,
open_quote, close_quote, sheets, closed_at
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
"""
).fetchall()
except Exception:
return out
for r in rows:
inst = str(r["inst_id"] or "").strip()
if not inst or inst in out:
continue
out[inst] = dict(r)
return out
def _overlay_local_closed(row: dict[str, Any], local: dict[str, Any] | None) -> dict[str, Any]:
if not local:
return row
prem = _safe_float(row.get("premium_paid"))
pnl = _safe_float(row.get("realized_pnl"))
local_prem = _safe_float(local.get("premium_paid"))
local_pnl = _safe_float(local.get("realized_pnl"))
# 交易所缺数或被两位小数抹成 0 时,用本地币本位落库值
if (prem is None or abs(prem) < 1e-10) and local_prem is not None and abs(local_prem) > 0:
row["premium_paid"] = local_prem
if (pnl is None or abs(pnl) < 1e-10) and local_pnl is not None and abs(local_pnl) > 0:
row["realized_pnl"] = local_pnl
if not row.get("premium_ccy") and local.get("premium_ccy"):
row["premium_ccy"] = local.get("premium_ccy")
if not row.get("margin_mode") and local.get("margin_mode"):
row["margin_mode"] = local.get("margin_mode")
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
row["premium_paid_fmt"] = format_premium_amount(row.get("premium_paid"), ccy=ccy)
return row
def load_options_history(ex: Any, cfg: dict[str, Any]) -> list[dict[str, Any]]:
"""与期权历史页相同的数据源:交易所全平记录 + 当前持仓,排除本地隐藏项."""
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 []
conn = cfg["get_db"]()
try:
init_options_tables(conn)
hidden_keys = {
str(r["history_key"])
for r in conn.execute("SELECT history_key FROM options_history_hidden").fetchall()
}
local_closed = _load_local_closed_by_inst(conn)
for p in raw_live:
inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None
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))
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)
row = format_option_history_row(raw, tick_sz=tick_sz, ct_mult=ct_mult)
items.append(_overlay_local_closed(row, local_closed.get(inst_id)))
finally:
conn.close()
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)
return [
x
for x in (open_rows + closed)
if str(x.get("history_key") or "") not in hidden_keys
]
+185
View File
@@ -0,0 +1,185 @@
"""中控只读聚合:OKX 期权持仓 / 资金(轻量,不含历史统计)."""
from __future__ import annotations
import os
from typing import Any
def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
if not cfg.get("enabled"):
return {"ok": True, "enabled": False}
ex = cfg.get("exchange_options")
ready_fn = cfg.get("options_api_ready")
if not callable(ready_fn):
return {"ok": False, "enabled": True, "msg": "期权模块未就绪"}
ok, reason = ready_fn(ex)
if not ok:
return {"ok": False, "enabled": True, "msg": reason or "期权 API 未配置"}
try:
from lib.options.options_position_limit_lib import options_max_active_positions
from lib.options.options_positions_lib import build_display_option_positions
raw = cfg["fetch_option_positions"](ex)
if raw is None:
return {"ok": False, "enabled": True, "msg": "获取期权持仓失败"}
positions = build_display_option_positions(cfg, ex, raw)
target_monitors: list[dict[str, Any]] = []
try:
conn = cfg["get_db"]()
try:
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst
target_monitors = list_active_targets(conn) + list_closing_targets(conn)
tgt_map = targets_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn)
profit_exit_map = profit_exit_by_inst(conn)
target_monitors.extend(hedge_target_map.values())
for pe in profit_exit_map.values():
if pe.get("profit_exit_enabled"):
target_monitors.append(
{
"inst_id": pe.get("inst_id"),
"exit_mode": "profit_exit",
"profit_exit_mult": pe.get("profit_exit_mult"),
"profit_exit_enabled": True,
}
)
for p in positions:
mon = tgt_map.get(str(p.get("inst_id") or ""))
if mon:
p["target_index"] = mon.get("target_index")
p["target_monitor_id"] = mon.get("id")
p["target_monitor"] = mon
pe = profit_exit_map.get(str(p.get("inst_id") or ""))
if pe:
p["profit_exit_enabled"] = pe.get("profit_exit_enabled")
p["profit_exit_mult"] = pe.get("profit_exit_mult")
p["profit_exit_state"] = pe.get("profit_exit_state")
p["profit_exit_required_recycle"] = pe.get("required_recycle")
hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
if hedge_target:
p["hedge_plan_target"] = hedge_target
if not mon:
# 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。
p["target_index"] = hedge_target.get("target_index")
try:
from lib.instance.instance_dashboard_lib import (
_format_options_target,
_resolve_options_source,
)
inst = str(p.get("inst_id") or "")
source_key, source_label, source_plan_id = _resolve_options_source(conn, inst)
p["source"] = source_key
p["source_label"] = source_label
p["source_plan_id"] = source_plan_id
p["target_monitor_text"] = _format_options_target(p)
except Exception:
p.setdefault("source_label", "—")
p.setdefault("source_plan_id", None)
p.setdefault("target_monitor_text", "—")
finally:
conn.close()
except Exception:
target_monitors = []
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
upl_total = 0.0
has_upl = False
for p in positions:
# 合计一律折 U:币本位 × 指数,避免 BTC/ETH 币数量与 USDC 混加
pnl = display_pnl_usdt_from_option_row(p)
if pnl is None:
continue
has_upl = True
upl_total += float(pnl)
bal = cfg["fetch_options_balances"](ex)
from lib.options.options_margin_mode_lib import (
is_coin_margin_mode,
normalize_options_margin_mode,
premium_ccy_for_mode,
)
margin_mode = normalize_options_margin_mode()
for p in positions:
mid = str(p.get("inst_id") or "")
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id
row_mode = margin_mode_from_inst_id(mid) if mid else margin_mode
p["margin_mode"] = row_mode
p["premium_ccy"] = p.get("premium_ccy") or premium_ccy_for_mode(
row_mode, str(p.get("underlying") or mid.split("-")[0] if mid else "ETH")
)
p["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC"
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
options_index_px = None
try:
from lib.exchange.okx_options_lib import fetch_index_price
options_index_px = fetch_index_price(ex, underly)
except Exception:
options_index_px = None
if options_index_px is None:
for p in positions:
try:
px = float(p.get("idx_px") or p.get("idxPx") or 0)
except (TypeError, ValueError):
px = 0
if px > 0:
options_index_px = px
break
coin_budget = None
bridge_status = None
open_bridges = []
if is_coin_margin_mode():
try:
from lib.options.options_coin_open_lib import coin_budget_preview
coin_budget = coin_budget_preview(cfg, ex)
except Exception:
coin_budget = None
try:
conn_b = cfg["get_db"]()
try:
from lib.options.options_spot_bridge_lib import list_open_bridges
open_bridges = list_open_bridges(conn_b)
if open_bridges:
bridge_status = str(open_bridges[0].get("status") or "")
finally:
conn_b.close()
except Exception:
open_bridges = []
return {
"ok": True,
"enabled": True,
"positions": positions,
"position_count": len(positions),
"target_monitors": target_monitors,
"upl_total_usdc": round(upl_total, 4) if has_upl else None,
"balances": bal,
"funding_usdc": bal.get("funding_usdc"),
"funding_usdt": bal.get("funding_usdt"),
"trading_usdc": bal.get("trading_usdc"),
"trading_usdt": bal.get("trading_usdt"),
# 监控区不用历史统计;保留空对象兼容旧调用方
"stats": {},
"trade_budget": cfg.get("trade_budget"),
"account_label": cfg.get("account_label") or "OKX期权",
"max_active_positions": options_max_active_positions(),
"options_margin_mode": margin_mode,
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC",
"options_underly": underly,
"options_index_px": options_index_px,
"coin_budget": coin_budget,
"bridge_status": bridge_status,
"open_bridges": open_bridges,
}
except Exception as e:
return {"ok": False, "enabled": True, "msg": str(e)}
+315
View File
@@ -0,0 +1,315 @@
"""OKX 单笔期权本位模式与币本位 USDT 预算."""
from __future__ import annotations
import os
from typing import Any
MODE_USDC = "usdc"
MODE_COIN = "coin"
def _env_bool(name: str, default: bool = False) -> bool:
v = (os.getenv(name) or "").strip().lower()
if not v:
return default
return v in ("1", "true", "yes", "on", "y")
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name) or default)
except (TypeError, ValueError):
return float(default)
def normalize_options_margin_mode(raw: Any = None) -> str:
"""CoinOX 固定币本位期权;忽略 USDC 配置."""
return MODE_COIN
def is_coin_margin_mode(raw: Any = None) -> bool:
return normalize_options_margin_mode(raw) == MODE_COIN
def inst_family_for_underlying(underlying: str, *, margin_mode: str | None = None) -> str:
u = (underlying or "ETH").strip().upper() or "ETH"
mode = normalize_options_margin_mode(margin_mode)
if mode == MODE_COIN:
return f"{u}-USD"
return f"{u}-USD_UM"
def margin_mode_from_inst_id(inst_id: str) -> str:
inst = (inst_id or "").strip().upper()
if not inst:
return normalize_options_margin_mode()
if "_UM" in inst:
return MODE_USDC
# ETH-USD-260701-2500-C / BTC-USD-...
if "-USD-" in inst and "_UM" not in inst:
return MODE_COIN
return normalize_options_margin_mode()
def premium_ccy_for_mode(margin_mode: str, underlying: str = "ETH") -> str:
if normalize_options_margin_mode(margin_mode) == MODE_COIN:
return (underlying or "ETH").strip().upper() or "ETH"
return "USDC"
def spot_quote_inst_id(underlying: str) -> str:
"""现货市价买卖: ETH-USDT / BTC-USDT."""
u = (underlying or "ETH").strip().upper() or "ETH"
return f"{u}-USDT"
def compute_coin_budget_usdt(
trading_usdt: float,
*,
compound: bool | None = None,
buffer: float | None = None,
fixed_budget_usdt: float | None = None,
max_enabled: bool | None = None,
max_usdt: float | None = None,
) -> dict[str, Any]:
"""
币本位单笔 USDT 预算.
复利开: trading_usdt × buffer; 复利关: fixed × buffer.
上限开: min(..., max_usdt).
"""
bal = max(0.0, float(trading_usdt or 0))
use_compound = _env_bool("OKX_OPTIONS_COIN_COMPOUND", True) if compound is None else bool(compound)
buf = float(buffer) if buffer is not None else _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
if buf <= 0:
buf = 0.95
fixed = (
float(fixed_budget_usdt)
if fixed_budget_usdt is not None
else _env_float("OKX_OPTIONS_COIN_BUDGET_USDT", 10.0)
)
if use_compound:
raw = bal * buf
source = "compound"
else:
raw = max(0.0, fixed) * buf
source = "fixed"
capped = False
max_on = (
_env_bool("OKX_OPTIONS_COIN_MAX_USDT_ENABLED", False)
if max_enabled is None
else bool(max_enabled)
)
max_n = (
float(max_usdt)
if max_usdt is not None
else _env_float("OKX_OPTIONS_COIN_MAX_USDT", 50.0)
)
budget = raw
if max_on and max_n > 0 and budget > max_n:
budget = max_n
capped = True
return {
"ok": budget > 0,
"budget_usdt": round(budget, 8),
"raw_usdt": round(raw, 8),
"trading_usdt": round(bal, 8),
"buffer": buf,
"compound": use_compound,
"source": source,
"max_enabled": max_on,
"max_usdt": max_n if max_on else None,
"capped_by_max": capped,
"msg": "" if budget > 0 else "交易账户 USDT 不足,无法计算币本位预算",
}
def normalize_coin_spot_buy_buffer(raw: Any = None) -> float:
"""
现货买入相对权利金的倍数缓冲.
env OKX_OPTIONS_COIN_SPOT_BUY_BUFFER 默认 1.10(=多买 10%).
也可写 0.10 表示 +10%.
"""
if raw is None:
v = _env_float("OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", 1.10)
else:
try:
v = float(raw)
except (TypeError, ValueError):
v = 1.10
if v <= 0:
return 1.10
if v < 1.0:
return 1.0 + v
return v
def plan_coin_open_by_budget(
*,
quote_per_unit: float,
ct_mult: float,
min_sz: int,
budget_usdt: float,
index_px: float,
ask_sz: float | None = None,
spot_buy_buffer: float | None = None,
target_sheets: int | None = None,
) -> dict[str, Any]:
"""
先按预算/卖一估最大可开张数,再按「权利金 × 现货缓冲」算应买现货 USDT.
不全额把预算换成币.
"""
import math
from lib.exchange.okx_options_lib import cap_option_buy_sheets_to_ask_depth
ask = float(quote_per_unit or 0)
mult = float(ct_mult or 0.01)
need = max(1, int(min_sz or 1))
budget = max(0.0, float(budget_usdt or 0))
idx = float(index_px or 0)
buf = normalize_coin_spot_buy_buffer(spot_buy_buffer)
if ask <= 0 or mult <= 0:
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "buy_usdt": 0.0}
if idx <= 0:
return {"ok": False, "msg": "缺少指数价,无法估算买币 USDT", "sheets": 0, "buy_usdt": 0.0}
if budget <= 0:
return {"ok": False, "msg": "USDT 预算无效", "sheets": 0, "buy_usdt": 0.0}
per_sheet_coin = ask * mult
# 每张开仓需买的币(含缓冲)及其约合 USDT
per_sheet_buy_coin = per_sheet_coin * buf
per_sheet_usdt = per_sheet_buy_coin * idx
if per_sheet_usdt <= 0:
return {"ok": False, "msg": "无法计算单张买币成本", "sheets": 0, "buy_usdt": 0.0}
max_by_budget = int(math.floor((budget / per_sheet_usdt) + 1e-12))
if target_sheets is not None:
try:
want = int(target_sheets)
except (TypeError, ValueError):
want = 0
if want < need:
return {
"ok": False,
"msg": f"指定张数无效(需≥{need})",
"sheets": 0,
"buy_usdt": 0.0,
"max_by_budget": max_by_budget,
}
sheets = min(want, max_by_budget)
if sheets < want:
return {
"ok": False,
"msg": (
f"预算约可开 {max_by_budget} 张(含现货缓冲×{buf:g}),"
f"不足指定 {want} 张"
),
"sheets": 0,
"buy_usdt": 0.0,
"max_by_budget": max_by_budget,
"spot_buy_buffer": buf,
}
else:
sheets = max_by_budget
capped, cap_msg = cap_option_buy_sheets_to_ask_depth(sheets, ask_sz, min_sz=need)
ask_depth_capped = False
if capped is None:
return {
"ok": False,
"msg": cap_msg or "卖一深度不足",
"sheets": 0,
"buy_usdt": 0.0,
"spot_buy_buffer": buf,
}
if int(capped) < sheets:
sheets = int(capped)
ask_depth_capped = True
if sheets < need:
return {
"ok": False,
"msg": (
f"预算不足,无法买入 {need} 张"
f"(单张约需 {per_sheet_usdt:.4f} USDT,含现货缓冲×{buf:g})"
),
"sheets": sheets,
"buy_usdt": 0.0,
"per_sheet_usdt": round(per_sheet_usdt, 8),
"spot_buy_buffer": buf,
"max_by_budget": max_by_budget,
}
premium_coin = sheets * per_sheet_coin
buy_coin = premium_coin * buf
buy_usdt = min(budget, buy_coin * idx)
# 再保险:向下对齐,避免浮点导致略超预算
buy_usdt = min(budget, round(buy_usdt, 8))
out = {
"ok": True,
"msg": "" if not ask_depth_capped else (cap_msg or f"已按卖一深度限制为 {sheets} 张"),
"sheets": sheets,
"eth_amount": round(sheets * mult, 8),
"coin_premium": round(premium_coin, 8),
"total_premium": round(premium_coin, 8),
"per_sheet_coin": per_sheet_coin,
"buy_coin": round(buy_coin, 8),
"buy_usdt": round(buy_usdt, 8),
"budget_usdt": round(budget, 8),
"spot_buy_buffer": buf,
"index_px": idx,
"max_by_budget": max_by_budget,
"ask_depth_capped": ask_depth_capped,
"est_note": (
f"按最大可开 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;"
f"不全额兑换预算"
),
}
if target_sheets is not None:
out["est_note"] = (
f"指定 {sheets} 张×卖一权利金×现货缓冲{buf:g}估买币;不全额兑换"
)
out["target_sheets"] = int(target_sheets)
return out
def calc_sheets_from_coin_balance(
*,
quote_per_unit: float,
ct_mult: float,
min_sz: int,
coin_available: float,
) -> dict[str, Any]:
"""用可用标的币尽量开满(权利金以币计)."""
import math
ask = float(quote_per_unit or 0)
mult = float(ct_mult or 0.01)
need = max(1, int(min_sz or 1))
coin = max(0.0, float(coin_available or 0))
# 留一点手续费/精度缓冲,避免算满张后下单 51008
coin_eff = coin * 0.97
if ask <= 0 or mult <= 0:
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "coin_premium": 0.0}
per_sheet = ask * mult
if per_sheet <= 0:
return {"ok": False, "msg": "无法计算单张权利金(币)", "sheets": 0, "coin_premium": 0.0}
sheets = int(math.floor((coin_eff / per_sheet) + 1e-12))
if sheets < need:
return {
"ok": False,
"msg": f"可用币不足,无法买入 {need} 张(单张约 {per_sheet:.8g} 币,可用 {coin:g})",
"sheets": sheets,
"coin_premium": round(sheets * per_sheet, 8),
"per_sheet_coin": per_sheet,
}
prem = sheets * per_sheet
return {
"ok": True,
"msg": "",
"sheets": sheets,
"coin_premium": round(prem, 8),
"per_sheet_coin": per_sheet,
"eth_amount": round(sheets * mult, 8),
}
+483
View File
@@ -0,0 +1,483 @@
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
from __future__ import annotations
import sqlite3
import time
from typing import Any, Callable
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
from lib.options.options_time_lib import ms_to_options_app_str, options_app_now_str, options_wall_clock_to_ms
def _safe_float(v: Any) -> float | None:
if v is None:
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def build_profit_alert_message(
*,
account_label: str,
inst_id: str,
premium_paid: float,
upl: float,
upl_ratio: float | None,
bid: float | None,
premium_ccy: str | None = None,
) -> str:
from lib.options.options_notify_lib import resolve_options_premium_ccy
ccy = resolve_options_premium_ccy(inst_id=inst_id, premium_ccy=premium_ccy)
d = 6 if ccy in ("ETH", "BTC") else 4
pct = f"{upl_ratio * 100:.1f}%" if upl_ratio is not None else "—"
bid_txt = f"{bid:.{d}f}" if bid is not None else "—"
mode = "币本位" if ccy != "USDC" else "USDC"
return "\n".join(
[
"【OKX期权·翻倍提醒】",
f"账户:{account_label}",
f"本位:{mode}",
f"合约:{inst_id}",
f"已付权利金:{premium_paid:.{d}f} {ccy}",
f"未实现盈亏:{upl:+.{d}f} {ccy}({pct})",
f"当前买一:{bid_txt}(可考虑限价平仓锁利)",
]
)
def run_options_profit_alerts(
conn: sqlite3.Connection,
positions: list[dict[str, Any]],
*,
profit_ratio: float,
send_wechat: Callable[[str], None],
account_label: str,
ticker_bid_fn: Callable[[str], float | None],
) -> int:
"""
对比 DB 中 open 记录与交易所持仓;达到阈值发微信.
返回发送条数.
"""
sent = 0
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
rows = conn.execute(
"""
SELECT id, inst_id, premium_paid, profit_alert_sent
FROM options_trades
WHERE status = 'open'
ORDER BY id ASC
"""
).fetchall()
# 同合约多腿加仓:按合约汇总权利金,整仓只告警一次
by_inst: dict[str, dict[str, Any]] = {}
for row in rows:
inst_id = str(row["inst_id"] or "")
if not inst_id:
continue
bucket = by_inst.setdefault(
inst_id,
{"ids": [], "premium": 0.0, "all_sent": True, "has_prem": False},
)
bucket["ids"].append(int(row["id"]))
prem = _safe_float(row["premium_paid"])
if prem is not None:
bucket["premium"] += float(prem)
bucket["has_prem"] = True
if not int(row["profit_alert_sent"] or 0):
bucket["all_sent"] = False
for inst_id, bucket in by_inst.items():
if bucket["all_sent"] or not bucket["has_prem"] or bucket["premium"] <= 0:
continue
pos = pos_by_inst.get(inst_id)
if not pos:
continue
prem = float(bucket["premium"])
upl = _safe_float(pos.get("upl"))
upl_ratio = _safe_float(pos.get("upl_ratio_pct"))
if upl_ratio is not None:
ratio = upl_ratio / 100.0
elif upl is not None:
ratio = upl / prem
else:
continue
if ratio < float(profit_ratio):
continue
bid = ticker_bid_fn(inst_id)
msg = build_profit_alert_message(
account_label=account_label,
inst_id=inst_id,
premium_paid=prem,
upl=upl or 0.0,
upl_ratio=ratio,
bid=bid,
)
try:
send_wechat(msg)
conn.execute(
f"UPDATE options_trades SET profit_alert_sent = 1 WHERE id IN ({','.join('?' * len(bucket['ids']))})",
tuple(bucket["ids"]),
)
sent += 1
except Exception:
pass
return sent
def _created_at_ms(created_at: Any) -> int | None:
"""墙钟 created_at → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
return options_wall_clock_to_ms(created_at)
def _group_key_for_closed_trade(row: Any) -> str:
inst = str(row["inst_id"] or "").strip()
closed = str(row["closed_at"] or "").strip()
close_prefix = closed[:16] if closed else ""
ord_id = str(row["close_ord_id"] or "").strip() if "close_ord_id" in row.keys() else ""
# 即使 close_ord_id/posId 相同,也要按平仓时间拆开(OKX 可能复用 posId)
if ord_id:
return f"{inst}|ord:{ord_id}|close:{close_prefix}"
return f"{inst}|close:{close_prefix}"
def backfill_closed_options_realized_pnl_from_history(
conn: sqlite3.Connection,
hist_rows: list[dict[str, Any]],
*,
trade_limit: int = 200,
) -> int:
"""
用 OKX positions-history 的 realizedPnl 覆盖本地已平记录.
同一次平仓多笔本地 open(加仓)按权利金占比分摊交易所总盈亏.
"""
by_inst: dict[str, list[dict[str, Any]]] = {}
for raw in hist_rows or []:
if not isinstance(raw, dict):
continue
inst = str(raw.get("instId") or "").strip()
if not inst:
continue
by_inst.setdefault(inst, []).append(raw)
rows = conn.execute(
"""
SELECT id, inst_id, sheets, premium_paid, realized_pnl, close_quote,
created_at, closed_at, close_ord_id
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
LIMIT ?
""",
(int(trade_limit),),
).fetchall()
if not rows:
return 0
groups: dict[str, list[Any]] = {}
for row in rows:
inst = str(row["inst_id"] or "").strip()
if not inst or inst not in by_inst:
continue
groups.setdefault(_group_key_for_closed_trade(row), []).append(row)
updated = 0
for group in groups.values():
inst = str(group[0]["inst_id"] or "").strip()
open_candidates = [_created_at_ms(r["created_at"]) for r in group]
open_ms = min((x for x in open_candidates if x is not None), default=None)
close_candidates = [_created_at_ms(r["closed_at"]) for r in group]
close_ms = max((x for x in close_candidates if x is not None), default=None)
sheets_hint = None
try:
sheets_hint = sum(float(_safe_float(r["sheets"]) or 0.0) for r in group) or None
except (TypeError, ValueError):
sheets_hint = None
close_info = resolve_option_close_from_history(
by_inst.get(inst) or [],
open_ms=open_ms,
close_ms=close_ms,
sheets=sheets_hint,
)
if not close_info:
continue
ex_pnl = _safe_float(close_info.get("realized_pnl"))
if ex_pnl is None:
continue
close_quote = _safe_float(close_info.get("close_quote"))
matched_pos = str(close_info.get("pos_id") or "").strip() or None
total_paid = 0.0
for r in group:
total_paid += float(_safe_float(r["premium_paid"]) or 0.0)
allocated = 0.0
for i, r in enumerate(group):
paid = float(_safe_float(r["premium_paid"]) or 0.0)
if i == len(group) - 1:
share = round(float(ex_pnl) - allocated, 4)
elif total_paid > 0:
share = round(float(ex_pnl) * (paid / total_paid), 4)
allocated += share
else:
share = round(float(ex_pnl) / len(group), 4)
allocated += share
local = _safe_float(r["realized_pnl"])
local_close = _safe_float(r["close_quote"])
local_ord = str(r["close_ord_id"] or "").strip()
pnl_ok = local is not None and abs(local - share) < 1e-6
quote_ok = close_quote is None or (
local_close is not None and abs(local_close - float(close_quote)) < 1e-6
)
ord_ok = (not matched_pos) or (local_ord == matched_pos)
if pnl_ok and quote_ok and ord_ok:
continue
prem_recv = round(paid + share, 4)
conn.execute(
"""
UPDATE options_trades
SET realized_pnl = ?,
premium_received = ?,
close_quote = COALESCE(?, close_quote),
close_ord_id = COALESCE(?, close_ord_id)
WHERE id = ?
""",
(share, prem_recv, close_quote, matched_pos, int(r["id"])),
)
updated += 1
return updated
def sync_open_options_trades(
conn: sqlite3.Connection,
*,
live_inst_ids: set[str],
fetch_history_fn: Callable[[str], list[dict[str, Any]]],
notify_cfg: dict[str, Any] | None = None,
) -> 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 = 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 = ms_to_options_app_str(exp_ms)
else:
continue
if not closed_at:
closed_at = options_app_now_str()
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),
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
if notify_cfg is not None:
try:
from lib.options.options_notify_lib import notify_options_close
reason = "到期结算" if close_reason == "expired" else "交易所平仓"
notify_options_close(
notify_cfg,
conn,
inst_id=inst_id,
reason=reason,
trade_id=int(row["id"]),
premium_paid=paid,
premium_received=prem_recv,
realized_pnl=realized_pnl,
close_quote=close_quote,
)
except Exception:
pass
return updated
def reconcile_live_open_trades(
conn: sqlite3.Connection,
*,
live_inst_ids: set[str],
) -> int:
"""交易所有持仓但本地误标 closed 时恢复为 open."""
fixed = 0
for inst_id in live_inst_ids:
if not inst_id:
continue
open_row = conn.execute(
"SELECT id FROM options_trades WHERE inst_id = ? AND status = 'open' LIMIT 1",
(inst_id,),
).fetchone()
if open_row:
continue
row = conn.execute(
"""
SELECT id, close_ord_id, realized_pnl
FROM options_trades
WHERE inst_id = ? AND status = 'closed'
ORDER BY id DESC LIMIT 1
""",
(inst_id,),
).fetchone()
if not row:
continue
if row["close_ord_id"]:
continue
if row["realized_pnl"] is not None:
continue
conn.execute(
"""
UPDATE options_trades
SET status = 'open',
close_quote = NULL,
premium_received = NULL,
realized_pnl = NULL,
closed_at = NULL,
signal_note = CASE
WHEN signal_note = '到期结算' THEN NULL
ELSE signal_note
END
WHERE id = ?
""",
(int(row["id"]),),
)
fixed += 1
return fixed
def options_monitor_loop(
*,
enabled: bool,
poll_seconds: float,
get_db: Callable[[], sqlite3.Connection],
fetch_positions: Callable[[], list[dict[str, Any]]],
ticker_bid_fn: Callable[[str], float | None],
send_wechat: Callable[[str], None],
account_label: str,
profit_ratio: float,
sync_trades_fn: Callable[[sqlite3.Connection], int] | None = None,
target_close_fn: Callable[[str], dict[str, Any]] | None = None,
profit_exit_close_fn: Callable[[str], dict[str, Any]] | None = None,
profit_exit_cfg: dict[str, Any] | None = None,
stale_pending_fn: Callable[[], dict[str, Any]] | None = None,
stop_event: Any = None,
) -> None:
if not enabled:
return
while True:
if stop_event is not None and getattr(stop_event, "is_set", lambda: False)():
break
try:
conn = get_db()
try:
positions = fetch_positions()
run_options_profit_alerts(
conn,
positions,
profit_ratio=profit_ratio,
send_wechat=send_wechat,
account_label=account_label,
ticker_bid_fn=ticker_bid_fn,
)
if target_close_fn is not None:
from lib.options.options_target_lib import run_options_target_closes
run_options_target_closes(
conn,
positions,
close_fn=target_close_fn,
send_wechat=send_wechat,
account_label=account_label,
cfg={"send_wechat": send_wechat, "account_label": account_label},
)
if profit_exit_close_fn is not None:
from lib.options.options_profit_exit_lib import run_options_profit_exits
pe_cfg = dict(profit_exit_cfg or {})
pe_cfg.setdefault("send_wechat", send_wechat)
pe_cfg.setdefault("account_label", account_label)
run_options_profit_exits(
conn,
positions,
close_fn=profit_exit_close_fn,
send_wechat=send_wechat,
account_label=account_label,
cfg=pe_cfg,
ex=pe_cfg.get("exchange_options"),
)
if sync_trades_fn is not None:
sync_trades_fn(conn)
conn.commit()
finally:
conn.close()
# 平仓限价挂单超时撤单(独立于 DB 事务)
if stale_pending_fn is not None:
try:
stale_pending_fn()
except Exception:
pass
except Exception:
pass
time.sleep(max(5.0, float(poll_seconds)))
+442
View File
@@ -0,0 +1,442 @@
"""OKX 期权开仓/平仓企业微信推送(必发,幂等落库标记).支持 USDC / 币本位(ETH/BTC)."""
from __future__ import annotations
import sqlite3
from typing import Any, Callable, Optional
def _fmt(v: Any, d: int = 4) -> str:
try:
if v is None or v == "":
return "—"
return f"{float(v):.{d}f}"
except (TypeError, ValueError):
return str(v)
def _opt_type_label(opt_type: Any) -> str:
t = str(opt_type or "").strip().upper()
if t in ("C", "CALL"):
return "Call"
if t in ("P", "PUT"):
return "Put"
return t or "—"
def resolve_options_premium_ccy(
*,
inst_id: str = "",
underlying: str = "",
premium_ccy: Any = None,
margin_mode: Any = None,
row: dict[str, Any] | None = None,
) -> str:
"""权利金计价币种:USDC 或 ETH/BTC."""
raw = premium_ccy
if (raw is None or str(raw).strip() == "") and row:
raw = row.get("premium_ccy")
ccy = str(raw or "").strip().upper()
if ccy:
return ccy
try:
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
mid = str(inst_id or (row or {}).get("inst_id") or "").strip()
mode = margin_mode if margin_mode is not None else (row or {}).get("margin_mode")
if mode is None and mid:
mode = margin_mode_from_inst_id(mid)
u = str(
underlying
or (row or {}).get("underlying")
or (mid.split("-")[0] if mid else "ETH")
or "ETH"
).strip().upper() or "ETH"
return premium_ccy_for_mode(str(mode or "usdc"), u)
except Exception:
return "USDC"
def _amount_decimals(ccy: str) -> int:
c = (ccy or "USDC").strip().upper()
if c in ("ETH", "BTC"):
return 6
return 4
def _mode_tag(*, inst_id: str = "", premium_ccy: str = "", margin_mode: Any = None) -> str:
ccy = (premium_ccy or "").strip().upper()
if ccy and ccy != "USDC":
return "币本位"
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode, margin_mode_from_inst_id
mode = margin_mode
if mode is None and inst_id:
mode = margin_mode_from_inst_id(inst_id)
if is_coin_margin_mode(mode):
return "币本位"
except Exception:
pass
return "USDC"
def ensure_options_notify_columns(conn: sqlite3.Connection) -> None:
for ddl in (
"ALTER TABLE options_trades ADD COLUMN wechat_open_sent INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN wechat_close_sent INTEGER DEFAULT 0",
):
try:
conn.execute(ddl)
except Exception:
pass
def notify_options_send(cfg: dict[str, Any], content: str) -> bool:
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
if not callable(send):
return False
try:
send(content)
return True
except Exception:
return False
def build_options_open_message(
*,
account_label: str,
inst_id: str,
underlying: str = "",
opt_type: Any = None,
sheets: Any = None,
premium_paid: Any = None,
open_quote: Any = None,
target_index: Any = None,
signal_note: str = "",
trade_id: Any = None,
premium_ccy: Any = None,
margin_mode: Any = None,
) -> str:
ccy = resolve_options_premium_ccy(
inst_id=inst_id,
underlying=underlying,
premium_ccy=premium_ccy,
margin_mode=margin_mode,
)
d = _amount_decimals(ccy)
mode = _mode_tag(inst_id=inst_id, premium_ccy=ccy, margin_mode=margin_mode)
lines = [
"【OKX期权·开仓】",
f"账户:{account_label or 'OKX期权'}",
f"本位:{mode}",
]
if trade_id is not None:
lines.append(f"本地单号:#{trade_id}")
lines.extend(
[
f"合约:{inst_id}",
f"标的:{(underlying or '—')} · {_opt_type_label(opt_type)}",
f"张数:{sheets if sheets is not None else '—'}",
f"开仓报价:{_fmt(open_quote, d)} {ccy}",
f"权利金:{_fmt(premium_paid, d)} {ccy}",
]
)
if target_index is not None and str(target_index).strip() != "":
try:
lines.append(f"目标指数:{float(target_index):g}")
except (TypeError, ValueError):
lines.append(f"目标指数:{target_index}")
if signal_note:
lines.append(f"备注:{signal_note[:200]}")
return "\n".join(lines)
def build_options_close_message(
*,
account_label: str,
inst_id: str,
reason: str = "",
underlying: str = "",
opt_type: Any = None,
sheets: Any = None,
premium_paid: Any = None,
premium_received: Any = None,
realized_pnl: Any = None,
close_quote: Any = None,
target_index: Any = None,
trigger_idx: Any = None,
trade_id: Any = None,
premium_ccy: Any = None,
margin_mode: Any = None,
) -> str:
ccy = resolve_options_premium_ccy(
inst_id=inst_id,
underlying=underlying,
premium_ccy=premium_ccy,
margin_mode=margin_mode,
)
d = _amount_decimals(ccy)
mode = _mode_tag(inst_id=inst_id, premium_ccy=ccy, margin_mode=margin_mode)
lines = [
"【OKX期权·平仓】",
f"账户:{account_label or 'OKX期权'}",
f"本位:{mode}",
]
if trade_id is not None:
lines.append(f"本地单号:#{trade_id}")
lines.extend(
[
f"合约:{inst_id}",
f"标的:{(underlying or '—')} · {_opt_type_label(opt_type)}",
f"原因:{(reason or '平仓').strip()}",
f"张数:{sheets if sheets is not None else '—'}",
f"平仓报价:{_fmt(close_quote, d)} {ccy}",
f"已付/收回:{_fmt(premium_paid, d)} / {_fmt(premium_received, d)} {ccy}",
f"实现盈亏:{_fmt(realized_pnl, d)} {ccy}",
]
)
if target_index is not None and str(target_index).strip() != "":
try:
lines.append(f"目标指数:{float(target_index):g}")
except (TypeError, ValueError):
lines.append(f"目标指数:{target_index}")
if trigger_idx is not None and str(trigger_idx).strip() != "":
try:
lines.append(f"触发指数:{float(trigger_idx):g}")
except (TypeError, ValueError):
lines.append(f"触发指数:{trigger_idx}")
return "\n".join(lines)
def notify_options_open(
cfg: dict[str, Any],
conn: sqlite3.Connection | None,
*,
trade_id: int | None,
inst_id: str,
underlying: str = "",
opt_type: Any = None,
sheets: Any = None,
premium_paid: Any = None,
open_quote: Any = None,
target_index: Any = None,
signal_note: str = "",
premium_ccy: Any = None,
margin_mode: Any = None,
) -> bool:
ensure_options_notify_columns(conn) if conn is not None else None
row_ccy = premium_ccy
row_mode = margin_mode
if conn is not None and trade_id is not None:
row = conn.execute(
"SELECT wechat_open_sent, premium_ccy, margin_mode, underlying FROM options_trades WHERE id=?",
(int(trade_id),),
).fetchone()
if row and int(row["wechat_open_sent"] or 0):
return False
if row:
if row_ccy is None:
row_ccy = row["premium_ccy"] if "premium_ccy" in row.keys() else None
if row_mode is None:
row_mode = row["margin_mode"] if "margin_mode" in row.keys() else None
if not underlying:
underlying = str(row["underlying"] or "") if "underlying" in row.keys() else underlying
msg = build_options_open_message(
account_label=str(cfg.get("account_label") or "OKX期权"),
inst_id=inst_id,
underlying=underlying,
opt_type=opt_type,
sheets=sheets,
premium_paid=premium_paid,
open_quote=open_quote,
target_index=target_index,
signal_note=signal_note,
trade_id=trade_id,
premium_ccy=row_ccy,
margin_mode=row_mode,
)
ok = notify_options_send(cfg, msg)
if ok and conn is not None and trade_id is not None:
conn.execute(
"UPDATE options_trades SET wechat_open_sent=1 WHERE id=?",
(int(trade_id),),
)
try:
conn.commit()
except Exception:
pass
return ok
def _load_trade_row(conn: sqlite3.Connection, trade_id: int) -> dict[str, Any] | None:
row = conn.execute("SELECT * FROM options_trades WHERE id=?", (int(trade_id),)).fetchone()
return dict(row) if row else None
def notify_options_close(
cfg: dict[str, Any],
conn: sqlite3.Connection | None,
*,
inst_id: str,
reason: str = "平仓",
trade_id: int | None = None,
underlying: str = "",
opt_type: Any = None,
sheets: Any = None,
premium_paid: Any = None,
premium_received: Any = None,
realized_pnl: Any = None,
close_quote: Any = None,
target_index: Any = None,
trigger_idx: Any = None,
premium_ccy: Any = None,
margin_mode: Any = None,
force: bool = False,
) -> bool:
"""平仓必发.默认按 trade_id / 同合约未标记行幂等."""
if conn is not None:
ensure_options_notify_columns(conn)
rows: list[dict[str, Any]] = []
if conn is not None and trade_id is not None:
r = _load_trade_row(conn, int(trade_id))
if r:
rows = [r]
elif conn is not None and inst_id:
q = conn.execute(
"""
SELECT * FROM options_trades
WHERE inst_id=? AND status='closed'
AND COALESCE(wechat_close_sent,0)=0
ORDER BY id DESC
LIMIT 20
""",
(inst_id,),
).fetchall()
rows = [dict(x) for x in q]
if not rows and force:
q2 = conn.execute(
"""
SELECT * FROM options_trades
WHERE inst_id=? AND status='closed'
ORDER BY id DESC LIMIT 1
""",
(inst_id,),
).fetchone()
if q2:
rows = [dict(q2)]
if not rows:
# 已有平仓记录且均已推送:幂等跳过,避免再走「无库行」重复推
exists = conn.execute(
"""
SELECT 1 FROM options_trades
WHERE inst_id=? AND status='closed'
LIMIT 1
""",
(inst_id,),
).fetchone()
if exists:
return False
if rows:
# 同次平仓可能多腿:合并一条推送,逐条标记
total_paid = sum(float(r.get("premium_paid") or 0) for r in rows)
total_recv = sum(float(r.get("premium_received") or 0) for r in rows if r.get("premium_received") is not None)
pnls = [float(r["realized_pnl"]) for r in rows if r.get("realized_pnl") is not None]
total_pnl = sum(pnls) if pnls else None
if total_pnl is None and (premium_received is not None or realized_pnl is not None):
total_pnl = realized_pnl
total_recv = premium_received if premium_received is not None else total_recv
total_paid = premium_paid if premium_paid is not None else total_paid
head = rows[0]
pending = [r for r in rows if not int(r.get("wechat_close_sent") or 0)]
if not pending and not force:
return False
msg = build_options_close_message(
account_label=str(cfg.get("account_label") or "OKX期权"),
inst_id=inst_id or str(head.get("inst_id") or ""),
reason=reason,
underlying=underlying or str(head.get("underlying") or ""),
opt_type=opt_type or head.get("opt_type"),
sheets=sheets if sheets is not None else sum(int(r.get("sheets") or 0) for r in rows),
premium_paid=total_paid,
premium_received=total_recv if rows else premium_received,
realized_pnl=total_pnl,
close_quote=close_quote if close_quote is not None else head.get("close_quote"),
target_index=target_index,
trigger_idx=trigger_idx,
trade_id=head.get("id") if len(rows) == 1 else None,
premium_ccy=premium_ccy or head.get("premium_ccy"),
margin_mode=margin_mode or head.get("margin_mode"),
)
ok = notify_options_send(cfg, msg)
if ok and conn is not None:
for r in pending or rows:
conn.execute(
"UPDATE options_trades SET wechat_close_sent=1 WHERE id=?",
(int(r["id"]),),
)
try:
conn.commit()
except Exception:
pass
return ok
# 无库行时仍发一条(尽量不丢提醒)
msg = build_options_close_message(
account_label=str(cfg.get("account_label") or "OKX期权"),
inst_id=inst_id,
reason=reason,
underlying=underlying,
opt_type=opt_type,
sheets=sheets,
premium_paid=premium_paid,
premium_received=premium_received,
realized_pnl=realized_pnl,
close_quote=close_quote,
target_index=target_index,
trigger_idx=trigger_idx,
trade_id=trade_id,
premium_ccy=premium_ccy,
margin_mode=margin_mode,
)
return notify_options_send(cfg, msg)
def notify_options_close_trade_ids(
cfg: dict[str, Any],
conn: sqlite3.Connection,
trade_ids: list[int],
*,
reason: str,
) -> bool:
ids = [int(x) for x in trade_ids if x is not None]
if not ids:
return False
ensure_options_notify_columns(conn)
placeholders = ",".join("?" for _ in ids)
rows = conn.execute(
f"""
SELECT * FROM options_trades
WHERE id IN ({placeholders}) AND COALESCE(wechat_close_sent,0)=0
""",
ids,
).fetchall()
if not rows:
return False
first = dict(rows[0])
return notify_options_close(
cfg,
conn,
inst_id=str(first.get("inst_id") or ""),
reason=reason,
trade_id=int(first["id"]) if len(rows) == 1 else None,
underlying=str(first.get("underlying") or ""),
opt_type=first.get("opt_type"),
sheets=sum(int(r["sheets"] or 0) for r in rows),
premium_paid=sum(float(r["premium_paid"] or 0) for r in rows),
premium_received=sum(float(r["premium_received"] or 0) for r in rows if r["premium_received"] is not None),
realized_pnl=sum(float(r["realized_pnl"]) for r in rows if r["realized_pnl"] is not None),
close_quote=first.get("close_quote"),
premium_ccy=first.get("premium_ccy"),
margin_mode=first.get("margin_mode"),
)
+124
View File
@@ -0,0 +1,124 @@
"""期权限价挂单:展示 enrichment + 超时自动撤单."""
from __future__ import annotations
import time
from typing import Any
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def order_age_seconds(order: dict[str, Any], *, now_ms: float | None = None) -> float | None:
"""根据交易所 cTime(ms) 估算挂单时长(秒)."""
ct = _safe_float(order.get("c_time") or order.get("cTime"))
if ct is None or ct <= 0:
return None
# OKX 一般为毫秒时间戳
if ct < 1e12:
ct *= 1000.0
now = float(now_ms if now_ms is not None else time.time() * 1000.0)
age = (now - ct) / 1000.0
return age if age >= 0 else 0.0
def is_close_pending_order(order: dict[str, Any]) -> bool:
"""平仓向限价挂单:卖出 / reduceOnly."""
side = str(order.get("side") or "").lower()
if side == "sell":
return True
return bool(order.get("reduce_only"))
def enrich_pending_orders(
orders: list[dict[str, Any]] | None,
*,
ttl_seconds: float = 600.0,
now_ms: float | None = None,
) -> list[dict[str, Any]]:
"""为 UI 附加挂单时长与自动撤倒计时."""
ttl = max(0.0, float(ttl_seconds or 0))
now = float(now_ms if now_ms is not None else time.time() * 1000.0)
out: list[dict[str, Any]] = []
for raw in orders or []:
o = dict(raw)
age = order_age_seconds(o, now_ms=now)
is_close = is_close_pending_order(o)
o["age_sec"] = round(age, 1) if age is not None else None
o["is_close_order"] = is_close
o["auto_cancel_enabled"] = bool(is_close and ttl > 0)
if age is not None and is_close and ttl > 0:
remain = max(0.0, ttl - age)
o["ttl_seconds"] = ttl
o["expire_in_sec"] = round(remain, 1)
o["stale"] = remain <= 0
else:
o["ttl_seconds"] = ttl if is_close else None
o["expire_in_sec"] = None
o["stale"] = False
out.append(o)
return out
def cancel_stale_close_pending_orders(
*,
fetch_pending: Any,
cancel_order: Any,
ttl_seconds: float = 600.0,
now_ms: float | None = None,
ex: Any = None,
) -> dict[str, Any]:
"""
平仓限价挂单超过 ttl 自动撤销.
fetch_pending(ex) -> list; cancel_order(ex, inst_id=..., ord_id=...).
"""
ttl = float(ttl_seconds or 0)
if ttl <= 0:
return {"ok": True, "cancelled": 0, "checked": 0, "skipped": "ttl_disabled"}
try:
orders = fetch_pending(ex) if ex is not None else fetch_pending()
except TypeError:
orders = fetch_pending(ex)
except Exception as e:
return {"ok": False, "msg": str(e), "cancelled": 0, "checked": 0}
enriched = enrich_pending_orders(orders or [], ttl_seconds=ttl, now_ms=now_ms)
cancelled: list[dict[str, Any]] = []
errors: list[str] = []
checked = 0
for o in enriched:
if not o.get("is_close_order"):
continue
checked += 1
if not o.get("stale"):
continue
inst = str(o.get("inst_id") or "").strip()
oid = str(o.get("ord_id") or "").strip()
if not inst or not oid:
continue
try:
if ex is not None:
res = cancel_order(ex, inst_id=inst, ord_id=oid)
else:
res = cancel_order(inst_id=inst, ord_id=oid)
except TypeError:
res = cancel_order(ex, inst_id=inst, ord_id=oid)
except Exception as e:
errors.append(f"{oid}:{e}")
continue
if res.get("ok"):
cancelled.append({"inst_id": inst, "ord_id": oid, "age_sec": o.get("age_sec")})
else:
errors.append(f"{oid}:{res.get('msg') or 'cancel_failed'}")
return {
"ok": True,
"cancelled": len(cancelled),
"checked": checked,
"orders": cancelled,
"errors": errors,
"ttl_seconds": ttl,
}
+144
View File
@@ -0,0 +1,144 @@
"""OKX 期权持仓笔数上限(env: OKX_OPTIONS_MAX_ACTIVE_POSITIONS)."""
from __future__ import annotations
import os
from typing import Any, Optional, Sequence
def options_max_active_positions() -> int:
"""同时持有的期权合约笔数上限;0=不限制.热更读 env."""
raw = os.getenv("OKX_OPTIONS_MAX_ACTIVE_POSITIONS", "0")
try:
v = int(float(str(raw).strip()))
except (TypeError, ValueError):
return 0
return max(0, v)
def count_live_option_positions(rows: Optional[list[dict[str, Any]]]) -> int:
if not rows:
return 0
n = 0
for r in rows:
if not isinstance(r, dict):
continue
try:
pos = float(r.get("pos") or 0)
except (TypeError, ValueError):
continue
if abs(pos) >= 1e-12:
n += 1
return n
def _inst_already_open(rows: list[dict[str, Any]], inst_id: str) -> bool:
want = (inst_id or "").strip()
if not want:
return False
for r in rows:
if str(r.get("instId") or r.get("inst_id") or "").strip() == want:
return True
return False
def _normalize_inst_ids(
opening_inst_id: str = "",
opening_inst_ids: Optional[Sequence[str]] = None,
) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for raw in list(opening_inst_ids or []) + ([opening_inst_id] if opening_inst_id else []):
iid = str(raw or "").strip()
if not iid or iid in seen:
continue
seen.add(iid)
out.append(iid)
return out
def option_position_limit_block_msg(
ex: Any,
*,
opening_inst_id: str = "",
opening_inst_ids: Optional[Sequence[str]] = None,
new_positions: Optional[int] = None,
max_active: Optional[int] = None,
fetch_positions=None,
) -> Optional[str]:
"""若禁止新开买期权则返回中文原因,否则 None.
- max_active<=0:不限制
- opening_inst_ids:本次要开的合约;已在持仓中的不占新笔数
- new_positions:显式指定还需新占几笔(默认按 opening_inst_ids 推算)
- 期期两腿应一次传入两个 inst_id,在开仓前预检,避免上限=1 时开出半边仓
- 拉持仓失败:拒绝开仓(避免绕过上限)
"""
try:
from lib.hedge_plan.okx_trade_mode_lib import standalone_options_open_allowed
# 对冲模式用 MAX_ACTIVE_HEDGE_PLANS 管「组数」,不占用期权笔数上限
if max_active is None and not standalone_options_open_allowed():
return None
except Exception:
pass
mx = options_max_active_positions() if max_active is None else int(max_active)
if mx <= 0:
return None
fetch = fetch_positions
if fetch is None:
from lib.exchange.okx_options_lib import fetch_option_positions
fetch = fetch_option_positions
try:
rows = fetch(ex)
except Exception:
rows = None
if rows is None:
return f"无法获取期权持仓,暂不可开仓(上限 {mx} 笔)"
active = count_live_option_positions(rows)
ids = _normalize_inst_ids(opening_inst_id, opening_inst_ids)
if new_positions is None:
if ids:
already = sum(1 for i in ids if _inst_already_open(rows, i))
need = max(0, len(ids) - already)
else:
need = 1
else:
need = max(0, int(new_positions))
if need <= 1 and len(ids) == 1 and _inst_already_open(rows, ids[0]):
return None
if need <= 0:
return None
if active + need <= mx:
return None
if need >= 2:
return (
f"期期对冲需新开 {need} 笔期权,当前已有 {active} 笔、上限 {mx};"
f"请将 OKX_OPTIONS_MAX_ACTIVE_POSITIONS 设为 0(不限制)或不小于 {active + need},或先平仓"
)
return f"期权持仓已达上限({active}/{mx}),请先平仓后再开"
def compound_full_single_position_block_msg(
ex: Any,
*,
fetch_positions=None,
) -> Optional[str]:
"""全仓复利:账户内已有任意期权持仓则禁止再开(仅允许 1 笔)."""
fetch = fetch_positions
if fetch is None:
from lib.exchange.okx_options_lib import fetch_option_positions
fetch = fetch_option_positions
try:
rows = fetch(ex)
except Exception:
rows = None
if rows is None:
return "无法获取期权持仓,全仓复利模式暂不可开仓"
active = count_live_option_positions(rows)
if active >= 1:
return f"全仓复利模式仅允许同时持有 1 笔仓位(当前 {active} 笔),请先平仓"
return None
+213
View File
@@ -0,0 +1,213 @@
"""期权持仓展示(实例页 / 中控快照共用)."""
from __future__ import annotations
from typing import Any
from lib.options.options_db import init_options_tables, sum_open_premium_paid
from lib.options.options_history_lib import enrich_position_row_display
from lib.options.options_close_gate_lib import clear_close_gate, is_close_gate_passed, update_close_gate
from lib.options.options_pricing_lib import estimate_close_by_bids, intrinsic_px_per_unit
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def attach_close_preview(
cfg: dict[str, Any],
ex: Any,
row: dict[str, Any],
*,
sheets: int | None = None,
premium_paid: float | None = None,
) -> dict[str, Any]:
inst_id = str(row.get("inst_id") or row.get("instId") or "").strip()
if not inst_id:
return row
ct_mult = float(row.get("ct_mult") or 0.01)
target_sheets = int(sheets) if sheets is not None else int(abs(_safe_float(row.get("pos")) or 0))
paid = premium_paid if premium_paid is not None else _safe_float(row.get("premium_paid"))
book = cfg["fetch_option_book_depth"](ex, inst_id, 5)
row["bid_depth"] = book.get("bids") or []
row["ask_depth"] = book.get("asks") or []
mark_px = _safe_float(row.get("mark_px") or row.get("markPx"))
intrinsic = intrinsic_px_per_unit(
row.get("opt_type") or row.get("optType"),
_safe_float(row.get("strike") or row.get("stk")),
_safe_float(row.get("idx_px") or row.get("idxPx")),
inst_id=inst_id,
margin_mode=row.get("margin_mode"),
)
# 与实盘一致:只按买一估算本轮可平
preview = estimate_close_by_bids(
row["bid_depth"],
target_sheets,
ct_mult=ct_mult,
premium_paid=paid,
mark_px=mark_px,
intrinsic_px=intrinsic,
max_levels=1,
)
premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
index_px = _safe_float(row.get("idx_px") or row.get("idxPx"))
# 残档时不累计门控;有效买一时刷新计时(仅自动平仓需要)
if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
gate = update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
preview["close_gate"] = gate
preview["close_gate_blocked"] = True
preview["close_gate_msg"] = preview.get("bid_invalid_reason") or gate.get("msg")
preview["manual_close_blocked"] = True
preview["liquidity_ok"] = False
else:
gate = update_close_gate(
inst_id,
recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready"))
preview["close_gate"] = gate
preview["close_gate_blocked"] = not passed
preview["close_gate_msg"] = gate.get("msg")
preview["manual_close_blocked"] = False
preview["liquidity_ok"] = True
if not passed:
preview["auto_close_blocked"] = True
row["close_preview"] = preview
return row
def forget_close_gate_for_inst(inst_id: str) -> None:
clear_close_gate(inst_id)
def net_pnl_from_display_row(row: dict[str, Any]) -> float | None:
"""与持仓卡「净盈亏」同口径:买一可回收 − 权利金;残档买一则无净值."""
from lib.options.options_pricing_lib import PREMIUM_DECIMALS_COIN, round_premium_amount
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
if preview.get("bid_invalid"):
return None
net = preview.get("estimated_pnl")
if net is not None:
try:
return float(net)
except (TypeError, ValueError):
pass
# 仅当实际吃到买盘张数时,才用 total_received − 权利金(避免 bid 无效时 total_received=0 算出 −权利金假亏)
try:
covered = float(preview.get("covered_sheets") or 0)
except (TypeError, ValueError):
covered = 0.0
recv = _safe_float(preview.get("total_received"))
paid = _safe_float(row.get("premium_paid"))
if covered > 0 and recv is not None and paid is not None:
ccy = str(row.get("premium_ccy") or "").strip().upper()
decimals = PREMIUM_DECIMALS_COIN if ccy in ("ETH", "BTC") else None
return round_premium_amount(recv - paid, ccy=ccy or None, decimals=decimals)
return None
def display_pnl_from_option_row(row: dict[str, Any]) -> float | None:
"""展示用盈亏:优先买一净盈亏;残档/无买一时回退交易所标记浮盈 upl."""
net = net_pnl_from_display_row(row)
if net is not None:
return net
return _safe_float(row.get("upl"))
def display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None:
"""顶栏/看板/中控「实时盈亏」用:币本位净盈亏按指数折 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:
# 无指数时无法折算,跳过该仓以免把「币数量」当成 U
return None
return float(pnl) * float(idx)
return float(pnl)
def sum_options_net_pnl_usdc(
cfg: dict[str, Any],
ex: Any,
raw_positions: list[dict[str, Any]] | None = None,
) -> float | None:
"""
期权浮盈合计(USDT/USDC 口径),与顶栏实时盈亏/中控对齐:
各仓买一可回收 − 权利金;币本位再 × 指数折成 U;残档回退该仓 upl(同样折算).
获取失败返回 None;无持仓返回 0.
"""
raw = raw_positions
if raw is None:
raw = cfg["fetch_option_positions"](ex)
if raw is None:
return None
if not raw:
return 0.0
positions = build_display_option_positions(cfg, ex, raw)
total = 0.0
found = False
for p in positions:
pnl = display_pnl_usdt_from_option_row(p)
if pnl is None:
continue
found = True
total += float(pnl)
return round(total, 4) if found else (0.0 if not positions else None)
def build_display_option_positions(
cfg: dict[str, Any],
ex: Any,
raw_positions: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""与实例 /api/options/positions 相同 enrichment + close_preview."""
meta_cache: dict[str, dict[str, Any] | None] = {}
rows: list[dict[str, Any]] = []
conn = cfg["get_db"]()
try:
init_options_tables(conn)
for p in raw_positions:
inst = str(p.get("instId") or "").strip()
premium_override = sum_open_premium_paid(conn, inst) if inst else None
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")))
rows.append(row)
finally:
conn.close()
return rows
+719
View File
@@ -0,0 +1,719 @@
"""OKX USDⓈ 期权:张数与权利金计算."""
from __future__ import annotations
import math
from typing import Any
# 币本位权利金常为 1e-4~1e-5 量级(尤其 BTC);round(...,4) 会把净盈亏抹成 0
PREMIUM_DECIMALS_COIN = 8
PREMIUM_DECIMALS_FIAT = 4
def round_premium_amount(v: float | None, *, ccy: str | None = None, decimals: int | None = None) -> float | None:
"""权利金/回收/币盈亏入账精度:ETH/BTC 用 8 位,USDC 等用法币 4 位."""
if v is None:
return None
try:
n = float(v)
except (TypeError, ValueError):
return None
if decimals is None:
unit = (ccy or "").strip().upper()
decimals = PREMIUM_DECIMALS_COIN if unit in ("ETH", "BTC") else PREMIUM_DECIMALS_FIAT
return round(n, int(decimals))
def ct_mult_from_meta(meta: dict[str, Any] | None) -> float:
if not meta:
return 0.01
try:
return float(meta.get("ctMult") or 0.01)
except (TypeError, ValueError):
return 0.01
def min_sz_from_meta(meta: dict[str, Any] | None) -> int:
if not meta:
return 1
try:
return max(1, int(float(meta.get("minSz") or 1)))
except (TypeError, ValueError):
return 1
def premium_per_sheet(quote_per_unit: float, ct_mult: float = 0.01) -> float:
"""报价为每 1 ETH/BTC;每张权利金 = 报价 × ctMult."""
return float(quote_per_unit) * float(ct_mult)
def format_quote_liquidity(px: float | None, sz: float | None, *, px_decimals: int = 4) -> str | None:
"""盘口展示:价格/张数,如 17.2/150."""
if px is None:
return None
try:
price = f"{float(px):.{px_decimals}f}".rstrip("0").rstrip(".")
except (TypeError, ValueError):
return None
if sz is None:
return price
try:
s = float(sz)
size = str(int(s)) if abs(s - int(s)) < 1e-9 else str(s).rstrip("0").rstrip(".")
except (TypeError, ValueError):
return price
return f"{price}/{size}"
def total_premium(quote_per_unit: float, eth_amount: float, ct_mult: float = 0.01) -> float:
return float(quote_per_unit) * float(eth_amount)
# 买一相对标记价/内在价值低于该比例 → 视为残档,禁止按买盘自动/多档平仓
BID_CLOSE_MIN_RATIO = 0.3
def _safe_px(v: Any) -> float | None:
if v is None or v == "":
return None
try:
x = float(v)
except (TypeError, ValueError):
return None
return x if x > 0 else None
def _quote_in_coin_from_context(
*,
quote_in_coin: bool | None = None,
inst_id: str | None = None,
margin_mode: str | None = None,
) -> bool:
"""币本位(ETH-USD/BTC-USD)权利金按币报价;USDC(USD_UM)按美元点差."""
if quote_in_coin is not None:
return bool(quote_in_coin)
if inst_id:
try:
from lib.options.options_margin_mode_lib import MODE_COIN, margin_mode_from_inst_id
return margin_mode_from_inst_id(inst_id) == MODE_COIN
except Exception:
pass
if margin_mode is not None:
try:
from lib.options.options_margin_mode_lib import is_coin_margin_mode
return is_coin_margin_mode(margin_mode)
except Exception:
return str(margin_mode).strip().lower() in ("coin", "coin_margin", "crypto")
return False
def intrinsic_px_per_unit(
opt_type: str | None,
strike: float | None,
index_px: float | None,
*,
quote_in_coin: bool | None = None,
inst_id: str | None = None,
margin_mode: str | None = None,
) -> float | None:
"""
与盘口同单位的内在价值(每 1 标的).
- USDC / USD_UM: 美元点差 max(0, S−K) / max(0, K−S)
- 币本位 ETH-USD / BTC-USD: 币报价 max(0, S−K)/S / max(0, K−S)/S
"""
o = (opt_type or "").strip().upper()
if strike is None or index_px is None:
return None
try:
k = float(strike)
idx = float(index_px)
except (TypeError, ValueError):
return None
if o == "C" and idx > k:
points = idx - k
elif o == "P" and idx < k:
points = k - idx
else:
return None
if _quote_in_coin_from_context(
quote_in_coin=quote_in_coin, inst_id=inst_id, margin_mode=margin_mode
):
if idx <= 0:
return None
return points / idx
return points
def is_stub_bid_px(
bid_px: float | None,
*,
mark_px: float | None = None,
intrinsic_px: float | None = None,
min_ratio: float = BID_CLOSE_MIN_RATIO,
) -> tuple[bool, str]:
"""
判断买一是否为无效残档(如标记 42、买一 0.2).
返回 (is_stub, reason).
"""
bid = _safe_px(bid_px)
if bid is None:
return True, "无买一"
ref = _safe_px(mark_px)
ref_name = "标记价"
intrinsic = _safe_px(intrinsic_px)
if intrinsic is not None and (ref is None or intrinsic > ref):
ref = intrinsic
ref_name = "内在价值"
if ref is None:
return False, ""
ratio = float(min_ratio) if min_ratio and min_ratio > 0 else BID_CLOSE_MIN_RATIO
if bid < ref * ratio:
return True, f"买一{bid:g}远低于{ref_name}{ref:g},属无效残档,禁止按买盘自动平仓"
return False, ""
def fetch_option_mark_px(ex: Any, inst_id: str) -> float | None:
"""优先 mark-price 接口,失败则 None."""
inst_id = (inst_id or "").strip()
if not inst_id or ex is None:
return None
try:
rows = ex.public_get_public_mark_price({"instType": "OPTION", "instId": inst_id}).get("data") or []
if rows:
return _safe_px(rows[0].get("markPx"))
except Exception:
pass
return None
def close_ref_prices(
*,
mark_px: float | None = None,
opt_type: str | None = None,
strike: float | None = None,
index_px: float | None = None,
quote_in_coin: bool | None = None,
inst_id: str | None = None,
margin_mode: str | None = None,
) -> tuple[float | None, float | None]:
"""返回 (mark_px, intrinsic_px) 供残档判断;intrinsic 与盘口同单位."""
return _safe_px(mark_px), intrinsic_px_per_unit(
opt_type,
strike,
index_px,
quote_in_coin=quote_in_coin,
inst_id=inst_id,
margin_mode=margin_mode,
)
def filter_bids_for_close(
bids: list[dict[str, Any]] | None,
*,
mark_px: float | None = None,
intrinsic_px: float | None = None,
min_ratio: float = BID_CLOSE_MIN_RATIO,
) -> tuple[list[dict[str, Any]], bool, str]:
"""过滤不可用于平仓的残档买盘.返回 (usable_bids, had_stub_only, reason)."""
raw = list(bids or [])
usable: list[dict[str, Any]] = []
stub_reason = ""
for level in raw:
px = _safe_px(level.get("px") if isinstance(level, dict) else None)
stub, reason = is_stub_bid_px(px, mark_px=mark_px, intrinsic_px=intrinsic_px, min_ratio=min_ratio)
if stub:
if not stub_reason:
stub_reason = reason or "买一无效"
continue
usable.append(level)
if raw and not usable:
return [], True, stub_reason or "暂无有效买盘"
return usable, False, ""
def estimate_close_by_bids(
bids: list[dict[str, Any]] | None,
sheets: int | float,
*,
ct_mult: float = 0.01,
premium_paid: float | None = None,
mark_px: float | None = None,
intrinsic_px: float | None = None,
min_bid_ratio: float = BID_CLOSE_MIN_RATIO,
max_levels: int = 1,
) -> dict[str, Any]:
"""按买盘估算限价卖出可收回金额;默认只估算买一(与实盘平仓一致);残档不参与."""
target = max(0, int(float(sheets or 0)))
remaining = target
total_received = 0.0
levels: list[dict[str, Any]] = []
max_lv = max(1, int(max_levels or 1))
empty = {
"levels": [],
"covered_sheets": 0,
"uncovered_sheets": target,
"total_received": 0.0,
"avg_px": None,
"estimated_pnl": None,
"estimated_pnl_ratio_pct": None,
"bid_invalid": False,
"bid_invalid_reason": None,
"auto_close_blocked": False,
"max_levels": max_lv,
}
if target <= 0 or ct_mult <= 0:
return empty
usable, stub_only, stub_reason = filter_bids_for_close(
bids, mark_px=mark_px, intrinsic_px=intrinsic_px, min_ratio=min_bid_ratio
)
if stub_only:
out = dict(empty)
out["bid_invalid"] = True
out["bid_invalid_reason"] = stub_reason
out["auto_close_blocked"] = True
out["raw_bid_px"] = _safe_px((bids or [{}])[0].get("px")) if bids else None
return out
for i, level in enumerate(usable[:max_lv], start=1):
if remaining <= 0:
break
try:
px = float(level.get("px"))
sz = int(float(level.get("sz")))
except (AttributeError, TypeError, ValueError):
continue
if px <= 0 or sz <= 0:
continue
take = min(remaining, sz)
eth_amount = eth_amount_from_sheets(take, ct_mult)
received = total_premium(px, eth_amount)
levels.append(
{
"level": i,
"px": px,
"available_sheets": sz,
"sheets": take,
"eth_amount": eth_amount,
"received": round_premium_amount(received, decimals=PREMIUM_DECIMALS_COIN),
}
)
total_received += received
remaining -= take
covered = target - remaining
avg_px = (total_received / eth_amount_from_sheets(covered, ct_mult)) if covered > 0 else None
# 净盈亏 = 本轮买盘可回收 − 全部权利金(买一不够时剩余张数计入 uncovered)
estimated_pnl = None
estimated_pnl_ratio_pct = None
if premium_paid is not None and covered > 0:
paid = float(premium_paid)
# 必须用 8 位:BTC 权利金常约 1e-4,round(...,4) 会把盈亏抹成 0
estimated_pnl = round_premium_amount(total_received - paid, decimals=PREMIUM_DECIMALS_COIN)
if paid > 0 and estimated_pnl is not None:
estimated_pnl_ratio_pct = round(estimated_pnl / paid * 100.0, 2)
return {
"levels": levels,
"covered_sheets": covered,
"uncovered_sheets": remaining,
"total_received": round_premium_amount(total_received, decimals=PREMIUM_DECIMALS_COIN),
"avg_px": round(avg_px, 8) if avg_px is not None else None,
"estimated_pnl": estimated_pnl,
"estimated_pnl_ratio_pct": estimated_pnl_ratio_pct,
"bid_invalid": False,
"bid_invalid_reason": None,
"auto_close_blocked": False,
"max_levels": max_lv,
}
def sheets_from_eth_amount(eth_amount: float, ct_mult: float = 0.01) -> int:
if eth_amount <= 0 or ct_mult <= 0:
return 0
return int(math.floor(eth_amount / ct_mult + 1e-12))
def eth_amount_from_sheets(sheets: int, ct_mult: float = 0.01) -> float:
return round(int(sheets) * float(ct_mult), 8)
def resolve_budget_full_usdc(trading_usdc: float, trade_budget_usdc: float) -> float:
"""按可用余额打满:余额大于预算用预算,否则用余额."""
return min(float(trading_usdc), float(trade_budget_usdc))
def resolve_compound_full_usdc(
trading_usdc: float,
*,
cap_enabled: bool = False,
cap_usdc: float | None = None,
) -> float:
"""全仓复利:默认用期权交易户全部可用;上限开关开启时再封顶."""
bal = max(0.0, float(trading_usdc or 0))
if not cap_enabled:
return bal
try:
cap = float(cap_usdc) if cap_usdc is not None else 0.0
except (TypeError, ValueError):
cap = 0.0
if cap <= 0:
return bal
return min(bal, cap)
def calc_order_size(
*,
quote_per_unit: float,
ct_mult: float,
min_sz: int,
budget_usdc: float | None = None,
budget_buffer: float = 0.95,
eth_amount: float | None = None,
sheets: int | None = None,
budget_cap: float | None = None,
) -> dict[str, Any]:
"""
返回 sheets, eth_amount, total_premium.
mode: budget_full / eth_amount / sheets.
"""
if quote_per_unit <= 0:
return {"ok": False, "msg": "卖一价无效", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}
if sheets is not None and int(sheets) > 0:
sheets = int(sheets)
elif eth_amount is not None and eth_amount > 0:
sheets = sheets_from_eth_amount(eth_amount, ct_mult)
elif budget_usdc is not None and budget_usdc > 0:
eff = float(budget_usdc) * float(budget_buffer)
per_sheet = premium_per_sheet(quote_per_unit, ct_mult)
if per_sheet <= 0:
return {"ok": False, "msg": "无法计算单张权利金", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}
sheets = int(math.floor(eff / per_sheet))
else:
return {"ok": False, "msg": "请指定预算,币数量或张数", "sheets": 0, "eth_amount": 0.0, "total_premium": 0.0}
if sheets < min_sz:
per = premium_per_sheet(quote_per_unit, ct_mult)
return {
"ok": False,
"msg": f"预算不足,无法买入 {min_sz} 张(单张约 {per:.4f} USDC)",
"sheets": sheets,
"eth_amount": eth_amount_from_sheets(sheets, ct_mult),
"total_premium": total_premium(quote_per_unit, eth_amount_from_sheets(sheets, ct_mult)),
}
eth = eth_amount_from_sheets(sheets, ct_mult)
prem = total_premium(quote_per_unit, eth)
if budget_cap is not None and prem > float(budget_cap) + 1e-9:
return {
"ok": False,
"msg": f"权利金 {prem:.4f} 超过单笔上限 {budget_cap} USDC",
"sheets": sheets,
"eth_amount": eth,
"total_premium": prem,
}
return {"ok": True, "msg": "", "sheets": sheets, "eth_amount": eth, "total_premium": prem}
def is_shallow_itm(
*,
opt_type: str,
strike: float,
index_px: float,
max_dist_usd: float,
) -> bool:
o = (opt_type or "").upper()
if o == "C":
if strike >= index_px:
return False
return (index_px - strike) <= max_dist_usd
if o == "P":
if strike <= index_px:
return False
return (strike - index_px) <= max_dist_usd
return False
def option_moneyness(*, opt_type: str, strike: float, index_px: float) -> str:
"""返回 itm / otm / atm."""
o = (opt_type or "").upper()
if strike is None or index_px is None or index_px <= 0:
return "unknown"
atm_band = max(index_px * 0.002, 2.0)
if abs(strike - index_px) <= atm_band:
return "atm"
if o == "C":
return "itm" if strike < index_px else "otm"
if o == "P":
return "itm" if strike > index_px else "otm"
return "unknown"
def option_moneyness_label(moneyness: str) -> str:
return {"itm": "实值", "otm": "虚值", "atm": "平值"}.get((moneyness or "").lower(), "")
def expiry_breakeven_from_ask(
*,
opt_type: str,
strike: float | None,
ask_px: float | None,
mark_px: float | None = None,
quote_in_coin: bool | None = None,
inst_id: str | None = None,
margin_mode: str | None = None,
) -> float | None:
"""买入前预估到期平衡:权利金按卖一;无卖一时回退标记价."""
prem = ask_px if ask_px is not None and ask_px > 0 else mark_px
return expiry_breakeven_px(
opt_type=opt_type,
strike=strike,
avg_px=prem,
quote_in_coin=quote_in_coin,
inst_id=inst_id,
margin_mode=margin_mode,
)
def expiry_breakeven_px(
*,
opt_type: str,
strike: float | None,
avg_px: float | None,
be_px_api: float | None = None,
quote_in_coin: bool | None = None,
inst_id: str | None = None,
margin_mode: str | None = None,
) -> float | None:
"""到期平衡点:持有至到期时标的指数盈亏为 0 的价格.优先 OKX bePx."""
if be_px_api is not None and be_px_api > 0:
return round(float(be_px_api), 2)
if strike is None or avg_px is None:
return None
try:
k = float(strike)
p = float(avg_px)
except (TypeError, ValueError):
return None
if p <= 0:
return None
o = (opt_type or "").upper()
coin = _quote_in_coin_from_context(
quote_in_coin=quote_in_coin, inst_id=inst_id, margin_mode=margin_mode
)
if coin:
# 币本位:权利金为币报价;到期结算 payoff 亦为币 → K/(1±p)
if o == "C":
if p >= 1:
return None
return round(k / (1 - p), 2)
if o == "P":
return round(k / (1 + p), 2)
return None
if o == "C":
return round(k + p, 2)
if o == "P":
return round(k - p, 2)
return None
def close_breakeven_idx(
*,
opt_type: str,
idx_px: float | None,
mark_px: float | None,
avg_px: float | None,
delta_pa: float | None = None,
pos: float = 0,
ct_mult: float = 0.01,
) -> float | None:
"""
平掉回本:标的指数达到该价位时,按标记价平仓近似盈亏为 0.
优先用 deltaPA 线性外推,否则用时间价值近似(适合短期轻度实值).
"""
if idx_px is None or mark_px is None or avg_px is None:
return None
eth_amt = abs(float(pos)) * float(ct_mult)
if eth_amt > 1e-12 and delta_pa is not None and abs(float(delta_pa)) > 1e-12:
slope = float(delta_pa) / eth_amt
return round(float(idx_px) + (float(avg_px) - float(mark_px)) / slope, 2)
o = (opt_type or "").upper()
if o == "C":
return round(float(idx_px) + float(avg_px) - float(mark_px), 2)
if o == "P":
return round(float(idx_px) + float(mark_px) - float(avg_px), 2)
return None
def idx_distance_to_be(idx_px: float | None, be_px: float | None) -> float | None:
"""指数距平衡点(正=指数需上涨才到平衡点)."""
if idx_px is None or be_px is None:
return None
return round(float(be_px) - float(idx_px), 2)
def strike_distance_to_be(
strike: float | None,
be_px: float | None,
*,
opt_type: str | None = None,
) -> float | None:
"""行权价到到期平衡价的价差(Call:BE−K, Put:K−BE)."""
if strike is None or be_px is None:
return None
k = float(strike)
be = float(be_px)
o = (opt_type or "").upper()
if o == "C":
return round(be - k, 2)
if o == "P":
return round(k - be, 2)
return round(abs(be - k), 2)
def format_options_breakeven_line(
*,
expiry_be_px: float | None,
close_be_px: float | None,
idx_px: float | None = None,
) -> str:
"""持仓摘要行:到期平衡 / 平掉回本."""
parts: list[str] = []
if expiry_be_px is not None:
parts.append(f"到期平衡{expiry_be_px:.0f}")
if close_be_px is not None:
parts.append(f"平掉回本{close_be_px:.0f}")
if idx_px is not None and parts:
return " ".join(parts) + f"(指数{idx_px:.0f})"
return " ".join(parts)
def estimate_expiry_value_at_index(
*,
opt_type: str,
strike: float | None,
target_idx: float | None,
eth_amount: float | None,
) -> float | None:
"""到期测算:目标指数价下期权内在价值总额(不含已付权利金)."""
if strike is None or target_idx is None or eth_amount is None:
return None
if eth_amount <= 0:
return None
o = (opt_type or "").upper()
if o == "C":
intrinsic = max(0.0, float(target_idx) - float(strike))
elif o == "P":
intrinsic = max(0.0, float(strike) - float(target_idx))
else:
return None
return round(intrinsic * float(eth_amount), 2)
def estimate_expiry_profit_at_index(
*,
opt_type: str,
strike: float | None,
target_idx: float | None,
entry_px: float | None,
eth_amount: float | None,
total_premium: float | None = None,
) -> float | None:
"""到期测算:目标指数价下净盈利 = 预计价值 − 权利金."""
value = estimate_expiry_value_at_index(
opt_type=opt_type,
strike=strike,
target_idx=target_idx,
eth_amount=eth_amount,
)
if value is None:
return None
prem = total_premium
if prem is None and entry_px is not None and eth_amount is not None:
prem = float(entry_px) * float(eth_amount)
if prem is None:
return None
return round(float(value) - float(prem), 2)
def equivalent_contract_leverage(
*,
index_px: float | None,
eth_amount: float | None,
total_premium: float | None,
margin_mode: str | None = None,
) -> float | None:
"""名义价值 / 权利金,近似相当于永续合约杠杆倍数(测算用).
USDC: 权利金为美元 → index×eth/premium.
币本位: 权利金为币 → eth/premium(=1/ask 当 premium=ask×eth).
"""
if index_px is None or eth_amount is None or total_premium is None:
return None
if eth_amount <= 0 or total_premium <= 0:
return None
try:
from lib.options.options_margin_mode_lib import normalize_options_margin_mode
mode = normalize_options_margin_mode(margin_mode)
except Exception:
mode = (str(margin_mode or "usdc").strip().lower() or "usdc")
if mode == "coin":
return round(float(eth_amount) / float(total_premium), 1)
return round(float(index_px) * float(eth_amount) / float(total_premium), 1)
def straddle_ask_per_unit(
call_ask: float | None,
put_ask: float | None,
) -> float | None:
"""跨式双买:每 1 标的币的卖一报价之和."""
if call_ask is None or put_ask is None:
return None
if float(call_ask) <= 0 or float(put_ask) <= 0:
return None
return round(float(call_ask) + float(put_ask), 4)
def straddle_premium_total(
call_ask: float | None,
put_ask: float | None,
eth_amount: float | None,
) -> float | None:
"""跨式双买权利金总额(USDC)."""
per = straddle_ask_per_unit(call_ask, put_ask)
if per is None or eth_amount is None or float(eth_amount) <= 0:
return None
return round(per * float(eth_amount), 2)
def straddle_breakeven_band(
strike: float | None,
combined_ask_per_unit: float | None = None,
*,
call_ask: float | None = None,
put_ask: float | None = None,
quote_in_coin: bool = False,
) -> tuple[float | None, float | None]:
"""跨式到期平衡带:下平衡 ~ 上平衡."""
if strike is None:
return None, None
k = float(strike)
if quote_in_coin:
pc = _safe_px(call_ask)
pp = _safe_px(put_ask)
if pc is None or pp is None or pc <= 0 or pp <= 0 or pc >= 1:
return None, None
return round(k / (1 + pp), 2), round(k / (1 - pc), 2)
if combined_ask_per_unit is None:
return None, None
d = float(combined_ask_per_unit)
return round(k - d, 2), round(k + d, 2)
def format_straddle_band(
strike: float | None,
combined_ask_per_unit: float | None,
) -> str:
lo, hi = straddle_breakeven_band(strike, combined_ask_per_unit)
if lo is None or hi is None:
return ""
return f"{lo:.0f} ~ {hi:.0f}"
+435
View File
@@ -0,0 +1,435 @@
"""单独期权翻倍出场:盈利达权利金×倍数后按买一限价平仓.
1 倍 = 盈利金额等于初始权利金 ⇒ 买一可回收 ≥ 权利金 × (1 + 倍数).
与「目标位」并行;与仅微信提醒的 OKX_OPTIONS_PROFIT_ALERT_RATIO 独立.
"""
from __future__ import annotations
import sqlite3
from typing import Any, Callable
from lib.options.options_db import init_options_tables, sum_open_premium_paid
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def ensure_profit_exit_columns(conn: sqlite3.Connection) -> None:
init_options_tables(conn)
for ddl in (
"ALTER TABLE options_trades ADD COLUMN profit_exit_enabled INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_mult REAL DEFAULT 1.0",
"ALTER TABLE options_trades ADD COLUMN profit_exit_state TEXT DEFAULT 'idle'",
):
try:
conn.execute(ddl)
except Exception:
pass
def normalize_profit_exit_mult(raw: Any, *, default: float = 1.0) -> float:
try:
mult = float(raw)
except (TypeError, ValueError):
mult = float(default)
if mult <= 0:
mult = float(default)
return round(mult, 4)
def profit_exit_hit(
*,
premium_paid: float,
recycle_usdc: float,
mult: float,
) -> bool:
"""1倍:盈利=权利金 ⇒ recycle ≥ premium×(1+mult)."""
prem = float(premium_paid or 0)
recv = float(recycle_usdc or 0)
m = float(mult or 0)
if prem <= 0 or m <= 0 or recv <= 0:
return False
return recv + 1e-9 >= prem * (1.0 + m)
def required_recycle_usdc(premium_paid: float, mult: float) -> float | None:
from lib.options.options_pricing_lib import PREMIUM_DECIMALS_COIN, round_premium_amount
prem = float(premium_paid or 0)
m = float(mult or 0)
if prem <= 0 or m <= 0:
return None
return round_premium_amount(prem * (1.0 + m), decimals=PREMIUM_DECIMALS_COIN)
def set_profit_exit(
conn: sqlite3.Connection,
*,
inst_id: str,
enabled: bool,
mult: float | None = None,
) -> dict[str, Any]:
ensure_profit_exit_columns(conn)
inst = (inst_id or "").strip()
if not inst:
return {"ok": False, "msg": "缺少 inst_id"}
m = normalize_profit_exit_mult(mult if mult is not None else 1.0)
rows = conn.execute(
"""
SELECT id FROM options_trades
WHERE inst_id = ? AND status = 'open'
""",
(inst,),
).fetchall()
if not rows:
return {"ok": False, "msg": "未找到该合约的本地开仓记录"}
if enabled:
conn.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 1,
profit_exit_mult = ?,
profit_exit_state = 'active'
WHERE inst_id = ? AND status = 'open'
""",
(m, inst),
)
else:
conn.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0,
profit_exit_state = 'idle'
WHERE inst_id = ? AND status = 'open'
""",
(inst,),
)
return {
"ok": True,
"inst_id": inst,
"profit_exit_enabled": bool(enabled),
"profit_exit_mult": m if enabled else None,
"updated": len(rows),
}
def profit_exit_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
"""进行中(active/closing)的翻倍出场,按合约取最新一条规则."""
ensure_profit_exit_columns(conn)
rows = conn.execute(
"""
SELECT inst_id, profit_exit_enabled, profit_exit_mult, profit_exit_state
FROM options_trades
WHERE status = 'open'
AND (
CAST(COALESCE(profit_exit_enabled, 0) AS INTEGER) = 1
OR COALESCE(profit_exit_state, 'idle') IN ('active', 'closing')
)
ORDER BY id DESC
"""
).fetchall()
out: dict[str, dict[str, Any]] = {}
for r in rows:
inst = str(r["inst_id"] or "").strip()
if not inst or inst in out:
continue
enabled = int(r["profit_exit_enabled"] or 0) == 1
state = str(r["profit_exit_state"] or "idle")
if not enabled and state not in ("active", "closing"):
continue
mult = normalize_profit_exit_mult(r["profit_exit_mult"], default=1.0)
out[inst] = {
"inst_id": inst,
"profit_exit_enabled": enabled or state in ("active", "closing"),
"profit_exit_mult": mult,
"profit_exit_state": state if state in ("active", "closing") else ("active" if enabled else "idle"),
"required_recycle": None,
}
for inst, info in out.items():
prem = sum_open_premium_paid(conn, inst)
if prem is not None:
info["premium_paid"] = prem
info["required_recycle"] = required_recycle_usdc(prem, float(info["profit_exit_mult"]))
return out
def _mark_state(conn: sqlite3.Connection, inst_id: str, state: str) -> None:
conn.execute(
"""
UPDATE options_trades
SET profit_exit_state = ?
WHERE inst_id = ? AND status = 'open'
""",
(state, inst_id),
)
def _commit(conn: sqlite3.Connection) -> None:
try:
conn.commit()
except Exception:
pass
def _result_fully_done(result: dict[str, Any]) -> bool:
if result.get("already_flat"):
return True
if result.get("fully_closed"):
return True
remaining = result.get("remaining_sheets")
if remaining is not None and int(remaining) <= 0 and result.get("ok"):
return True
return False
def close_option_by_bid_profit_exit(
cfg: dict[str, Any],
ex: Any,
inst_id: str,
*,
sheets: int | None = None,
) -> dict[str, Any]:
from lib.options.options_close_exec_lib import close_option_by_bid1
return close_option_by_bid1(
cfg,
ex,
inst_id,
sheets=sheets,
require_recycle_gate=False,
signal_note="翻倍出场",
)
def _estimate_recycle(
cfg: dict[str, Any],
ex: Any,
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"))
def _notify_profit_exit_close(
cfg: dict[str, Any] | None,
send_wechat: Callable[[str], None] | None,
*,
account_label: str,
inst_id: str,
mult: float,
premium_paid: float | None,
recycle: float | None,
result: dict[str, Any],
conn: Any = None,
) -> None:
from lib.options.options_notify_lib import resolve_options_premium_ccy
ccy = resolve_options_premium_ccy(inst_id=inst_id)
d = 6 if ccy in ("ETH", "BTC") else 4
mode = "币本位" if ccy != "USDC" else "USDC"
reason = f"翻倍出场({mult:g}倍)"
if result.get("fully_closed") or result.get("already_flat"):
if cfg is not None:
try:
from lib.options.options_notify_lib import notify_options_close
notify_options_close(
cfg,
conn,
inst_id=inst_id,
reason=reason,
sheets=result.get("submitted_sheets"),
premium_received=result.get("premium_received"),
close_quote=result.get("locked_bid_px") or result.get("bid"),
premium_ccy=ccy,
)
# 无论首次/幂等跳过,全平路径不再走下方 fallback,避免重复推
return
except Exception:
pass
if not send_wechat:
return
try:
prem_txt = f"{float(premium_paid):.{d}f}" if premium_paid is not None else "—"
recv_txt = f"{float(recycle):.{d}f}" if recycle is not None else "—"
send_wechat(
"\n".join(
[
"【OKX期权·翻倍出场】",
f"账户:{account_label}",
f"本位:{mode}",
f"合约:{inst_id}",
f"倍数:{mult:g}(1倍=盈利=权利金)",
f"权利金:{prem_txt} {ccy}",
f"可回收:{recv_txt} {ccy}",
f"提交张数:{result.get('submitted_sheets') or '—'}",
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
]
)
)
except Exception:
pass
def run_options_profit_exits(
conn: sqlite3.Connection,
positions: list[dict[str, Any]],
*,
close_fn: Callable[[str], dict[str, Any]],
recycle_fn: Callable[[dict[str, Any], float | None], float | None] | None = None,
send_wechat: Callable[[str], None] | None = None,
account_label: str = "OKX期权",
cfg: dict[str, Any] | None = None,
ex: Any = None,
) -> int:
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数."""
ensure_profit_exit_columns(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
hedge_managed: set[str] = set()
try:
from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
init_hedge_plan_tables(conn)
hedge_managed = active_hedge_option_inst_ids(conn)
except Exception:
# 对冲表异常时不得整段跳过翻倍出场
hedge_managed = set()
rules = profit_exit_by_inst(conn)
triggered = 0
for inst_id, info in list(rules.items()):
if not inst_id:
continue
if inst_id in hedge_managed:
_mark_state(conn, inst_id, "idle")
conn.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0, profit_exit_state = 'idle'
WHERE inst_id = ? AND status = 'open'
""",
(inst_id,),
)
_commit(conn)
continue
pos = pos_by_inst.get(inst_id)
if not pos:
# 持仓已平:收尾
_mark_state(conn, inst_id, "done")
_commit(conn)
continue
state = str(info.get("profit_exit_state") or "active")
mult = normalize_profit_exit_mult(info.get("profit_exit_mult"), default=1.0)
prem = sum_open_premium_paid(conn, inst_id)
if prem is None or prem <= 0:
continue
if state == "closing":
result = close_fn(inst_id)
if result.get("already_flat") or _result_fully_done(result):
_mark_state(conn, inst_id, "done")
_commit(conn)
_notify_profit_exit_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
mult=mult,
premium_paid=prem,
recycle=None,
result={**result, "fully_closed": True},
conn=conn,
)
else:
_mark_state(conn, inst_id, "closing")
_commit(conn)
continue
if not info.get("profit_exit_enabled"):
continue
if recycle_fn is not None:
recycle = recycle_fn(pos, prem)
elif cfg is not None and ex is not None:
recycle = _estimate_recycle(cfg, ex, pos, prem)
else:
continue
if recycle is None:
continue
if not profit_exit_hit(premium_paid=prem, recycle_usdc=recycle, mult=mult):
continue
result = close_fn(inst_id)
if result.get("already_flat"):
_mark_state(conn, inst_id, "done")
_commit(conn)
triggered += 1
_notify_profit_exit_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
mult=mult,
premium_paid=prem,
recycle=recycle,
result=result,
conn=conn,
)
continue
if not result.get("ok"):
_mark_state(conn, inst_id, "active")
_commit(conn)
continue
done = _result_fully_done(result)
_mark_state(conn, inst_id, "done" if done else "closing")
_commit(conn)
triggered += 1
_notify_profit_exit_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
mult=mult,
premium_paid=prem,
recycle=recycle,
result=result,
conn=conn,
)
return triggered
File diff suppressed because it is too large Load Diff
@@ -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()
+147
View File
@@ -0,0 +1,147 @@
"""期权复盘(含对冲) SQLite 表."""
from __future__ import annotations
import sqlite3
SOURCE_OPTION = "option_spot"
SOURCE_PERP_OPTIONS = "perp_options"
SOURCE_OPTIONS_OPTIONS = "options_options"
SOURCE_TYPES = (SOURCE_OPTION, SOURCE_PERP_OPTIONS, SOURCE_OPTIONS_OPTIONS)
def init_options_review_tables(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_review_trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_type TEXT NOT NULL,
history_key TEXT NOT NULL UNIQUE,
underlying TEXT,
opened_at TEXT,
closed_at TEXT,
hold_seconds INTEGER,
realized_pnl_total REAL,
status_raw TEXT,
synced_at TEXT,
-- 纯期权
pos_id TEXT,
inst_id TEXT,
opt_type TEXT,
strike REAL,
exp_time TEXT,
sheets INTEGER,
open_avg REAL,
close_avg REAL,
premium_paid REAL,
realized_pnl REAL,
-- 对冲计划
hedge_plan_id INTEGER,
plan_close_reason TEXT,
realized_pnl_perp REAL,
realized_pnl_options REAL,
premium_total REAL,
direction TEXT,
tp REAL,
sl REAL,
target_price REAL,
target_price_up REAL,
target_price_down REAL,
legs_json TEXT,
-- 双计防护:纯期权腿已归属对冲计划
linked_hedge_plan_id INTEGER,
excluded_as_hedge_leg INTEGER DEFAULT 0
)
"""
)
conn.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_options_review_trades_history_key
ON options_review_trades(history_key)
"""
)
conn.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_options_review_trades_hedge_plan
ON options_review_trades(hedge_plan_id)
WHERE hedge_plan_id IS NOT NULL
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_options_review_trades_closed
ON options_review_trades(closed_at)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_options_review_trades_source
ON options_review_trades(source_type)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_review_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_id INTEGER NOT NULL UNIQUE,
strategy_tag TEXT,
direction_view TEXT,
entry_logic TEXT,
exit_reason TEXT,
followed_plan TEXT,
mistake_tags TEXT,
result_tag TEXT,
note TEXT,
images_json TEXT,
image TEXT,
reviewed_at TEXT,
updated_at TEXT,
FOREIGN KEY(trade_id) REFERENCES options_review_trades(id)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_review_sync_state (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_review_hidden (
history_key TEXT PRIMARY KEY,
inst_id TEXT,
closed_at TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_options_review_hidden_inst
ON options_review_hidden(inst_id, closed_at)
"""
)
_ensure_column(conn, "options_review_trades", "linked_hedge_plan_id", "INTEGER")
_ensure_column(conn, "options_review_trades", "excluded_as_hedge_leg", "INTEGER DEFAULT 0")
_ensure_column(conn, "options_review_trades", "target_price_up", "REAL")
_ensure_column(conn, "options_review_trades", "target_price_down", "REAL")
_ensure_column(conn, "options_review_trades", "profit_rr", "REAL")
_ensure_column(conn, "options_review_trades", "premium_ccy", "TEXT")
_ensure_column(conn, "options_review_trades", "pnl_quote_ccy", "TEXT")
_ensure_column(conn, "options_review_trades", "idx_px", "REAL")
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
names: set[str] = set()
for r in rows:
try:
names.add(str(r["name"]))
except (TypeError, KeyError, IndexError):
names.add(str(r[1]))
if col not in names:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
+144
View File
@@ -0,0 +1,144 @@
"""期权复盘截图:独立命名空间,与合约同款四周期 5m/15m/1h/4h."""
from __future__ import annotations
import json
import os
import re
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
OPTIONS_REVIEW_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h")
OPTIONS_REVIEW_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"})
_DRAFT_ID_RE = re.compile(r"^[a-f0-9]{32}$")
_SLOT_FILE_RE = re.compile(
r"^options_journal_([a-f0-9]{32})_(5m|15m|1h|4h)\.(png|jpg|jpeg|webp|gif|bmp)$",
re.I,
)
def normalize_options_review_draft_id(raw: Any) -> Optional[str]:
s = str(raw or "").strip().lower()
if _DRAFT_ID_RE.match(s):
return s
return None
def _safe_ext(filename: str) -> str:
ext = os.path.splitext(str(filename or ""))[1].lower()
return ext if ext in OPTIONS_REVIEW_ALLOWED_EXT else ".png"
def options_review_upload_dir(base_upload_folder: str) -> str:
"""独立子目录 static/images/options_journal."""
base = os.path.abspath(base_upload_folder or "")
path = os.path.join(base, "options_journal")
os.makedirs(path, exist_ok=True)
return path
def build_options_review_slot_filename(
draft_id: str,
tf: str,
ext: str,
*,
secure_filename_fn: Callable[[str], str],
) -> str:
ext = ext if ext.startswith(".") else f".{ext}"
ext = _safe_ext(f"x{ext}")
fname = secure_filename_fn(f"options_journal_{draft_id}_{tf}{ext}")
return fname or ""
def is_valid_options_review_file(filename: str, draft_id: str, tf: str) -> bool:
fn = os.path.basename(str(filename or "").strip())
if not fn or fn != str(filename or "").strip():
return False
m = _SLOT_FILE_RE.match(fn)
if not m:
return False
return m.group(1) == draft_id.lower() and m.group(2) == tf
def save_options_review_slot_file(
file,
draft_id: str,
tf: str,
upload_folder: str,
*,
secure_filename_fn: Callable[[str], str],
) -> Optional[Dict[str, str]]:
if tf not in OPTIONS_REVIEW_UPLOAD_TFS or not draft_id or not upload_folder:
return None
if not file or not getattr(file, "filename", None):
return None
ext = _safe_ext(file.filename)
fname = build_options_review_slot_filename(
draft_id, tf, ext, secure_filename_fn=secure_filename_fn
)
if not fname:
return None
os.makedirs(upload_folder, exist_ok=True)
path = os.path.join(upload_folder, fname)
file.save(path)
return {"tf": tf, "file": fname}
def parse_options_review_images_json(raw: Any) -> List[Dict[str, str]]:
if not raw:
return []
if isinstance(raw, list):
data = raw
else:
try:
data = json.loads(str(raw))
except (TypeError, ValueError, json.JSONDecodeError):
return []
if not isinstance(data, list):
return []
out: List[Dict[str, str]] = []
for item in data:
if not isinstance(item, dict):
continue
tf = str(item.get("tf") or "").strip()
file = str(item.get("file") or "").strip()
if file:
out.append({"tf": tf, "file": file})
return out
def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]:
if not items:
return None
return json.dumps(list(items), ensure_ascii=False, separators=(",", ":"))
def options_review_image_paths(row: Any, upload_folder: str) -> List[str]:
upload_root = os.path.abspath(upload_folder or "")
options_dir = options_review_upload_dir(upload_root)
paths: List[str] = []
seen: set[str] = set()
def _add(name: Optional[str]) -> None:
if not name:
return
base = os.path.basename(str(name).strip())
if not base:
return
for folder in (options_dir, upload_root):
p = os.path.abspath(os.path.join(folder, base))
if os.path.isfile(p) and p not in seen:
seen.add(p)
paths.append(p)
return
try:
keys = row.keys() if hasattr(row, "keys") else ()
except Exception:
keys = ()
images = parse_options_review_images_json(
row["images_json"] if "images_json" in keys else getattr(row, "images_json", None)
)
for item in images:
_add(item.get("file"))
if "image" in keys or hasattr(row, "image"):
_add(row["image"] if "image" in keys else getattr(row, "image", None))
return paths
File diff suppressed because it is too large Load Diff
+452
View File
@@ -0,0 +1,452 @@
"""OKX 期权复盘模块:Flask 路由注册(含对冲计划级复盘)."""
from __future__ import annotations
import os
from typing import Any
from flask import Flask, jsonify, request, send_file
from jinja2 import ChoiceLoader, FileSystemLoader
from werkzeug.utils import secure_filename
from lib.options.options_review_db import SOURCE_TYPES, init_options_review_tables
from lib.options.options_review_images_lib import (
OPTIONS_REVIEW_UPLOAD_TFS,
normalize_options_review_draft_id,
options_review_image_paths,
options_review_upload_dir,
save_options_review_slot_file,
)
from lib.options.options_review_lib import (
SOURCE_LABELS,
compute_review_stats,
count_review_trades,
delete_review_entry,
ensure_local_review_synced,
get_review_trade,
hide_review_trade,
list_review_trades,
save_review_entry,
)
def _review_source_for_mode(requested: str | None) -> str | None:
"""按当前交易模式钳制复盘 source_type;不允许跨模式窥探."""
try:
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
mode = get_okx_trade_mode()
except Exception:
mode = "options"
allowed = {
"options": "option_spot",
"perp_options": "perp_options",
"options_options": "options_options",
}.get(mode, "option_spot")
req = (requested or "").strip()
if not req:
return allowed
if req == allowed:
return allowed
# 显式 all=1 仍拒绝跨模式,除非管理员扩展;此处一律钳制
return allowed
def attach_options_review_templates(app: Flask, repo_root: str) -> None:
tpl_dir = os.path.join(repo_root, "lib", "options", "templates")
if not os.path.isdir(tpl_dir):
return
existing = app.jinja_loader
loaders = [FileSystemLoader(tpl_dir)]
if existing is not None:
if isinstance(existing, ChoiceLoader):
loaders = list(existing.loaders) + loaders
else:
loaders.insert(0, existing)
app.jinja_loader = ChoiceLoader(loaders)
def install_options_review(app: Flask, repo_root: str, app_module: Any) -> None:
attach_options_review_templates(app, repo_root)
cfg = {
"get_db": app_module.get_db,
"login_required": app_module.login_required,
"exchange_options": getattr(app_module, "exchange_options", None),
"render_main_page": app_module.render_main_page,
"upload_folder": getattr(app_module, "UPLOAD_FOLDER", None)
or os.path.join(os.path.dirname(getattr(app_module, "BASE_DIR", repo_root)), "static", "images"),
"options_enabled": bool(getattr(app_module, "OKX_OPTIONS_ENABLED", False)),
"app_module": app_module,
}
app.extensions["options_review_cfg"] = cfg
register_options_review_routes(app, cfg, repo_root)
def _require_ex(cfg: dict[str, Any]):
from lib.exchange.okx_options_lib import options_api_ready
if not cfg.get("options_enabled"):
return None, "期权模块未启用"
ex = cfg.get("exchange_options")
ok, reason = options_api_ready(ex)
if not ok:
return None, reason or "期权 API 未配置"
return ex, ""
def _truthy(raw: Any) -> bool:
return str(raw or "").strip().lower() in ("1", "true", "on", "yes")
def _prepare_review_entry_images(
conn: Any,
trade_id: int,
data: dict[str, Any],
) -> dict[str, Any]:
"""客户端只提交手动槽位;未重新勾选自动K线时保留已有 auto 附图."""
from lib.options.options_review_chart_async_lib import OPTIONS_REVIEW_AUTO_CHART_TF
from lib.options.options_review_images_lib import parse_options_review_images_json
images = data.get("images") if isinstance(data.get("images"), list) else []
cleaned: list[dict[str, str]] = []
for item in images:
if not isinstance(item, dict):
continue
tf = str(item.get("tf") or "").strip()
file = str(item.get("file") or "").strip()
if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF:
continue
cleaned.append({"tf": tf, "file": file})
want_chart = _truthy(data.get("journal_exchange_chart")) and not cleaned
if not want_chart:
row = conn.execute(
"SELECT images_json FROM options_review_entries WHERE trade_id=?",
(int(trade_id),),
).fetchone()
if row:
for item in parse_options_review_images_json(row["images_json"]):
if str(item.get("tf") or "").strip() == OPTIONS_REVIEW_AUTO_CHART_TF:
cleaned.insert(0, dict(item))
break
out = dict(data)
out["images"] = cleaned
return out
def _maybe_schedule_options_review_chart(
cfg: dict[str, Any],
trade: dict[str, Any],
data: dict[str, Any],
) -> bool:
"""纯期权 + 无手动截图 + 勾选自动K线 → 后台生成标的永续双周期拼图(只钉进/出场)."""
if str(trade.get("source_type") or "").strip() != "option_spot":
return False
images = data.get("images") if isinstance(data.get("images"), list) else []
has_manual = any(str((x or {}).get("file") or "").strip() for x in images)
if has_manual or not _truthy(data.get("journal_exchange_chart")):
return False
app_module = cfg.get("app_module")
if app_module is None:
return False
if not bool(getattr(app_module, "ORDER_CHART_ENABLED", True)):
return False
generate_fn = getattr(app_module, "generate_multi_timeframe_chart_png", None)
if not callable(generate_fn):
return False
from lib.instance.journal_chart_lib import (
parse_journal_chart_anchor,
parse_journal_chart_limit,
parse_journal_chart_timeframes,
)
from lib.options.options_review_chart_async_lib import (
schedule_options_review_exchange_chart,
underlying_to_okx_perp_symbol,
)
from lib.options.options_review_images_lib import options_review_upload_dir
from lib.options.options_review_lib import _now_str
fallback_tfs = list(getattr(app_module, "ORDER_CHART_TFS", None) or [])[:2]
journal_tfs = parse_journal_chart_timeframes(
data.get("journal_chart_tf1"),
data.get("journal_chart_tf2"),
fallback_tfs or None,
)
journal_limit = parse_journal_chart_limit(
data.get("journal_chart_limit"),
int(getattr(app_module, "ORDER_CHART_LIMIT", 100) or 100),
)
chart_anchor = parse_journal_chart_anchor(data.get("journal_chart_anchor"))
underlying = str(trade.get("underlying") or data.get("underlying") or "").strip()
exchange_symbol = underlying_to_okx_perp_symbol(
underlying,
normalize_symbol_input_fn=app_module.normalize_symbol_input,
normalize_okx_symbol_fn=app_module.normalize_okx_symbol,
)
if not exchange_symbol:
return False
to_ms = getattr(app_module, "_local_input_datetime_to_ms", None)
if not callable(to_ms):
return False
entry_ts = to_ms(trade.get("opened_at"))
exit_ts = to_ms(trade.get("closed_at"))
if entry_ts is None and exit_ts is None:
return False
now_ms = None
app_now = getattr(app_module, "app_now", None)
if callable(app_now):
try:
now_ms = int(app_now().timestamp() * 1000)
except Exception:
now_ms = None
inst = str(trade.get("inst_id") or underlying or f"#{trade.get('id') or ''}").strip()
upload_folder = options_review_upload_dir(cfg["upload_folder"])
schedule_options_review_exchange_chart(
trade_id=int(trade["id"]),
exchange_symbol=exchange_symbol,
title_prefix=f"{underlying or 'OPT'} {inst} review",
journal_tfs=journal_tfs,
journal_limit=journal_limit,
marker_payload={
"entry_ts_ms": entry_ts,
"exit_ts_ms": exit_ts,
# 只按时间钉进/出场,不传价格与止损
"chart_anchor": chart_anchor,
"now_ts_ms": now_ms,
},
upload_folder=upload_folder,
generate_chart_fn=generate_fn,
get_db_fn=cfg["get_db"],
now_str_fn=_now_str,
)
return True
def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None:
lr = cfg["login_required"]
@app.route("/options/review")
@lr
def options_review_page():
from lib.instance.instance_embed_lib import redirect_to_embed_shell_if_enabled
redir = redirect_to_embed_shell_if_enabled("options_review")
if redir is not None:
return redir
return cfg["render_main_page"]("options_review")
@app.route("/static/options_review.js")
@lr
def static_options_review_js():
path = os.path.join(repo_root, "lib", "common", "static", "options_review.js")
if not os.path.isfile(path):
return ("not found", 404)
return send_file(path, mimetype="application/javascript; charset=utf-8")
@app.route("/static/images/options_journal/<path:filename>")
def static_options_review_image(filename: str):
"""截图文件名含 32 位 draft id,按静态资源提供(不强制登录,避免 iframe img 偶发 401)."""
folder = options_review_upload_dir(cfg["upload_folder"])
safe = os.path.basename(filename or "")
path = os.path.join(folder, safe)
if not os.path.isfile(path):
# 兼容误走合约 journal 上传、落在 UPLOAD_FOLDER 根目录的文件
root = os.path.abspath(cfg["upload_folder"] or "")
alt = os.path.join(root, safe)
if os.path.isfile(alt):
path = alt
else:
return ("not found", 404)
return send_file(path)
@app.route("/api/options/review/sync", methods=["POST"])
@lr
def api_options_review_sync():
"""刷新本地 options_trades + 已结束对冲计划;尽量用交易所历史盈亏覆盖本地估算."""
conn = cfg["get_db"]()
try:
init_options_review_tables(conn)
ex, _err = _require_ex(cfg)
result = ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
return jsonify(result)
finally:
conn.close()
@app.route("/api/options/review/trades")
@lr
def api_options_review_trades():
conn = cfg["get_db"]()
try:
# 翻页可跳过同步,仅刷新当前卡片列表
do_sync = (request.args.get("sync") or "1").strip().lower() not in (
"0",
"false",
"no",
)
if do_sync:
ex, _err = _require_ex(cfg)
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
filt = dict(
source_type=_review_source_for_mode(request.args.get("source_type")),
underlying=(request.args.get("underlying") or "").strip() or None,
opt_type=(request.args.get("opt_type") or "").strip() or None,
strategy_tag=(request.args.get("strategy_tag") or "").strip() or None,
q=(request.args.get("q") or "").strip() or None,
reviewed=(request.args.get("reviewed") or "").strip() or None,
include_hedge_legs=(request.args.get("include_hedge_legs") or "")
.strip()
.lower()
in ("1", "true", "yes"),
closed_from=(request.args.get("closed_from") or "").strip() or None,
closed_to=(request.args.get("closed_to") or "").strip() or None,
)
limit = min(500, max(1, int(request.args.get("limit") or 200)))
offset = max(0, int(request.args.get("offset") or 0))
total = count_review_trades(conn, **filt)
items = list_review_trades(conn, **filt, limit=limit, offset=offset)
pages = max(1, (total + limit - 1) // limit) if total else 1
page = (offset // limit) + 1 if limit else 1
return jsonify(
{
"ok": True,
"trades": items,
"source_labels": SOURCE_LABELS,
"total": total,
"limit": limit,
"offset": offset,
"page": page,
"pages": pages,
}
)
finally:
conn.close()
@app.route("/api/options/review/trades/<int:trade_id>")
@lr
def api_options_review_trade_detail(trade_id: int):
conn = cfg["get_db"]()
try:
item = get_review_trade(conn, trade_id)
if not item:
return jsonify({"ok": False, "msg": "未找到"}), 404
return jsonify({"ok": True, "trade": item})
finally:
conn.close()
@app.route("/api/options/review/entry", methods=["POST"])
@lr
def api_options_review_entry_save():
data = request.get_json(silent=True) or {}
try:
trade_id = int(data.get("trade_id"))
except (TypeError, ValueError):
return jsonify({"ok": False, "msg": "trade_id 无效"}), 400
conn = cfg["get_db"]()
try:
data = _prepare_review_entry_images(conn, trade_id, data)
out = save_review_entry(conn, trade_id, data)
if out.get("ok"):
conn.commit()
chart_queued = _maybe_schedule_options_review_chart(
cfg, out.get("trade") or {}, data
)
if chart_queued:
out = dict(out)
out["chart_queued"] = True
return jsonify(out), (200 if out.get("ok") else 400)
finally:
conn.close()
@app.route("/api/options/review/trades/<int:trade_id>", methods=["DELETE"])
@lr
def api_options_review_trade_hide(trade_id: int):
"""从复盘列表删除并持久隐藏(刷新本地源也不会再导入)."""
conn = cfg["get_db"]()
try:
out = hide_review_trade(conn, trade_id)
if out.get("ok"):
entry = out.get("entry") or {}
folder = options_review_upload_dir(cfg["upload_folder"])
for path in options_review_image_paths(entry, folder):
try:
os.remove(path)
except OSError:
pass
conn.commit()
return jsonify(out), (200 if out.get("ok") else 400)
finally:
conn.close()
@app.route("/api/options/review/entry/<int:trade_id>", methods=["DELETE"])
@lr
def api_options_review_entry_delete(trade_id: int):
conn = cfg["get_db"]()
try:
out = delete_review_entry(conn, trade_id)
if out.get("ok"):
entry = out.get("entry") or {}
folder = options_review_upload_dir(cfg["upload_folder"])
for path in options_review_image_paths(entry, folder):
try:
os.remove(path)
except OSError:
pass
conn.commit()
return jsonify(out), (200 if out.get("ok") else 400)
finally:
conn.close()
@app.route("/api/options/review/upload_slot", methods=["POST"])
@lr
def api_options_review_upload_slot():
draft_id = normalize_options_review_draft_id(
request.form.get("draft_id") if request.form else None
)
tf = str((request.form.get("tf") if request.form else None) or "").strip()
if not draft_id:
return jsonify({"ok": False, "error": "invalid draft_id"}), 400
if tf not in OPTIONS_REVIEW_UPLOAD_TFS:
return jsonify({"ok": False, "error": "invalid tf"}), 400
f = request.files.get("file") if request.files else None
if not f or not getattr(f, "filename", None):
return jsonify({"ok": False, "error": "no file"}), 400
folder = options_review_upload_dir(cfg["upload_folder"])
item = save_options_review_slot_file(
f, draft_id, tf, folder, secure_filename_fn=secure_filename
)
if not item:
return jsonify({"ok": False, "error": "save failed"}), 500
return jsonify({"ok": True, "tf": tf, "file": item["file"]})
@app.route("/api/options/review/stats")
@lr
def api_options_review_stats():
conn = cfg["get_db"]()
try:
ex, _err = _require_ex(cfg)
ensure_local_review_synced(conn, ex=ex if ex is not None else None)
conn.commit()
stats = compute_review_stats(
conn,
source_type=_review_source_for_mode(request.args.get("source_type")),
underlying=(request.args.get("underlying") or "").strip() or None,
include_hedge_legs=(request.args.get("include_hedge_legs") or "").strip().lower()
in ("1", "true", "yes"),
closed_from=(request.args.get("closed_from") or "").strip() or None,
closed_to=(request.args.get("closed_to") or "").strip() or None,
require_strategy=(request.args.get("require_strategy") or "").strip().lower()
in ("1", "true", "yes"),
)
stats["source_types"] = list(SOURCE_TYPES)
stats["source_labels"] = SOURCE_LABELS
return jsonify(stats)
finally:
conn.close()
+416
View File
@@ -0,0 +1,416 @@
"""币本位期权:USDT↔标的币现货桥与本地状态."""
from __future__ import annotations
import sqlite3
import time
from typing import Any
from lib.options.options_margin_mode_lib import spot_quote_inst_id
BRIDGE_BOUGHT = "bought_pending_open"
BRIDGE_HOLDING = "holding"
BRIDGE_PENDING_SELL = "pending_sell_spot"
BRIDGE_CLOSED = "closed"
def ensure_bridge_table(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_spot_bridge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
underlying TEXT NOT NULL,
status TEXT NOT NULL,
budget_usdt REAL,
buy_ord_id TEXT,
coin_bought REAL,
sell_ord_id TEXT,
coin_sold REAL,
usdt_recovered REAL,
inst_id TEXT,
message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
closed_at TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_options_spot_bridge_status
ON options_spot_bridge(status)
"""
)
def list_open_bridges(conn: sqlite3.Connection) -> list[dict[str, Any]]:
ensure_bridge_table(conn)
cur = conn.execute(
"""
SELECT id, underlying, status, budget_usdt, buy_ord_id, coin_bought,
sell_ord_id, coin_sold, usdt_recovered, inst_id, message,
created_at, updated_at, closed_at
FROM options_spot_bridge
WHERE status IN (?, ?, ?)
ORDER BY id DESC
""",
(BRIDGE_BOUGHT, BRIDGE_HOLDING, BRIDGE_PENDING_SELL),
)
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
def has_unfinished_bridge(conn: sqlite3.Connection) -> bool:
return bool(list_open_bridges(conn))
def insert_bridge(
conn: sqlite3.Connection,
*,
underlying: str,
status: str,
budget_usdt: float | None = None,
buy_ord_id: str | None = None,
coin_bought: float | None = None,
inst_id: str | None = None,
message: str | None = None,
) -> int:
ensure_bridge_table(conn)
cur = conn.execute(
"""
INSERT INTO options_spot_bridge(
underlying, status, budget_usdt, buy_ord_id, coin_bought, inst_id, message, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""",
(
(underlying or "ETH").upper(),
status,
budget_usdt,
buy_ord_id,
coin_bought,
inst_id,
message,
),
)
conn.commit()
return int(cur.lastrowid)
def update_bridge(
conn: sqlite3.Connection,
bridge_id: int,
*,
status: str | None = None,
buy_ord_id: str | None = None,
coin_bought: float | None = None,
sell_ord_id: str | None = None,
coin_sold: float | None = None,
usdt_recovered: float | None = None,
inst_id: str | None = None,
message: str | None = None,
close: bool = False,
) -> None:
ensure_bridge_table(conn)
fields: list[str] = ["updated_at=CURRENT_TIMESTAMP"]
vals: list[Any] = []
if status is not None:
fields.append("status=?")
vals.append(status)
if buy_ord_id is not None:
fields.append("buy_ord_id=?")
vals.append(buy_ord_id)
if coin_bought is not None:
fields.append("coin_bought=?")
vals.append(coin_bought)
if sell_ord_id is not None:
fields.append("sell_ord_id=?")
vals.append(sell_ord_id)
if coin_sold is not None:
fields.append("coin_sold=?")
vals.append(coin_sold)
if usdt_recovered is not None:
fields.append("usdt_recovered=?")
vals.append(usdt_recovered)
if inst_id is not None:
fields.append("inst_id=?")
vals.append(inst_id)
if message is not None:
fields.append("message=?")
vals.append(message)
if close or status == BRIDGE_CLOSED:
fields.append("closed_at=CURRENT_TIMESTAMP")
vals.append(int(bridge_id))
conn.execute(
f"UPDATE options_spot_bridge SET {', '.join(fields)} WHERE id=?",
vals,
)
conn.commit()
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def fetch_trading_coin_available(ex: Any, ccy: str) -> float | None:
"""交易账户标的币可用."""
from lib.exchange.okx_options_lib import _extract_ccy_free, _safe_float as _sf
ccy_u = (ccy or "").upper()
if not ccy_u:
return None
try:
bal = ex.fetch_balance(params={"type": "trading"})
free = _extract_ccy_free(bal, ccy_u)
if free is not None:
return float(free)
# 部分账户结构只有 total
from lib.exchange.okx_options_lib import _extract_ccy_balance
tot = _extract_ccy_balance(bal, ccy_u)
return float(tot) if tot is not None else None
except Exception:
return None
def spot_market_buy_coin_with_usdt(
ex: Any,
*,
underlying: str,
usdt_amount: float,
) -> dict[str, Any]:
"""交易账户:用 USDT 市价买入标的币."""
if usdt_amount <= 0:
return {"ok": False, "msg": "USDT 数量须大于 0"}
inst_id = spot_quote_inst_id(underlying)
try:
body = {
"instId": inst_id,
"tdMode": "cash",
"side": "buy",
"ordType": "market",
"sz": str(usdt_amount),
"tgtCcy": "quote_ccy",
}
resp = ex.private_post_trade_order(body)
data = (resp or {}).get("data") or []
if data and str(data[0].get("sCode")) == "0":
return {
"ok": True,
"inst_id": inst_id,
"ord_id": str(data[0].get("ordId") or ""),
"data": data[0],
"raw": resp,
}
from lib.exchange.okx_options_lib import _okx_trade_error_message
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp}
except Exception as e:
from lib.exchange.okx_options_lib import _okx_trade_error_message
return {"ok": False, "msg": _okx_trade_error_message(e)}
def spot_market_sell_coin_to_usdt(
ex: Any,
*,
underlying: str,
coin_amount: float | None = None,
) -> dict[str, Any]:
"""交易账户:市价卖出标的币换 USDT.
默认/推荐:coin_amount 为空 → 卖光交易账户全部可用币(全部卖出).
传入 coin_amount 时仍不超过可用余额,且尽量按可用全额卖出(不故意留粉尘).
"""
ccy = (underlying or "ETH").upper()
avail = fetch_trading_coin_available(ex, ccy)
if avail is None or float(avail) <= 0:
return {"ok": False, "msg": f"交易账户无可用 {ccy}"}
avail_f = float(avail)
# 全部卖出:以可用余额为准;若传入数量则不超过可用(开仓失败回滚用)
if coin_amount is None or float(coin_amount) <= 0:
sell_sz = avail_f
else:
sell_sz = min(float(coin_amount), avail_f)
if sell_sz <= 0:
return {"ok": False, "msg": f"{ccy} 数量须大于 0"}
inst_id = spot_quote_inst_id(ccy)
try:
# 现货卖出:向下截到 8 位,避免超过可用被拒;不再 *0.999 故意留残
sz = f"{sell_sz:.8f}".rstrip("0").rstrip(".")
if not sz or float(sz) <= 0:
return {"ok": False, "msg": f"{ccy} 可卖数量过小"}
# 二次钳制:格式化后仍不得超过可用
if float(sz) > avail_f:
sz = f"{avail_f:.8f}".rstrip("0").rstrip(".")
body = {
"instId": inst_id,
"tdMode": "cash",
"side": "sell",
"ordType": "market",
"sz": sz,
"tgtCcy": "base_ccy",
}
resp = ex.private_post_trade_order(body)
data = (resp or {}).get("data") or []
if data and str(data[0].get("sCode")) == "0":
return {
"ok": True,
"inst_id": inst_id,
"ord_id": str(data[0].get("ordId") or ""),
"coin_sold": float(sz),
"data": data[0],
"raw": resp,
}
from lib.exchange.okx_options_lib import _okx_trade_error_message
return {"ok": False, "msg": _okx_trade_error_message(resp=resp), "raw": resp}
except Exception as e:
from lib.exchange.okx_options_lib import _okx_trade_error_message
return {"ok": False, "msg": _okx_trade_error_message(e)}
def rollback_bought_coin_to_usdt(
conn: sqlite3.Connection,
ex: Any,
*,
bridge_id: int,
underlying: str,
reason: str = "",
coin_amount: float | None = None,
) -> dict[str, Any]:
"""买币后开期权失败:卖回 USDT 并关闭桥.优先卖 bridge 记录的买入量."""
amt = coin_amount
if amt is None or float(amt) <= 0:
ensure_bridge_table(conn)
row = conn.execute(
"SELECT coin_bought FROM options_spot_bridge WHERE id=?",
(int(bridge_id),),
).fetchone()
if row:
try:
amt = float(row[0] if not isinstance(row, dict) else row.get("coin_bought") or 0)
except (TypeError, ValueError, KeyError, IndexError):
amt = None
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=amt)
if not sell.get("ok"):
update_bridge(
conn,
bridge_id,
status=BRIDGE_PENDING_SELL,
message=(reason or "") + " | 回滚卖币失败: " + str(sell.get("msg") or ""),
)
return {"ok": False, "msg": sell.get("msg") or "回滚卖币失败", "bridge_status": BRIDGE_PENDING_SELL}
update_bridge(
conn,
bridge_id,
status=BRIDGE_CLOSED,
sell_ord_id=str(sell.get("ord_id") or ""),
coin_sold=_safe_float(sell.get("coin_sold")),
message=reason or "开仓失败已卖回 USDT",
close=True,
)
return {"ok": True, "sell": sell, "bridge_status": BRIDGE_CLOSED}
def sell_residual_after_option_flat(
conn: sqlite3.Connection,
ex: Any,
*,
underlying: str,
inst_id: str | None = None,
) -> dict[str, Any]:
"""期权已平:卖掉本桥残留标的币;优先关闭 matching holding/pending 桥."""
ensure_bridge_table(conn)
bridges = list_open_bridges(conn)
target = None
for b in bridges:
if str(b.get("status")) in (BRIDGE_HOLDING, BRIDGE_PENDING_SELL, BRIDGE_BOUGHT):
if not underlying or str(b.get("underlying") or "").upper() == underlying.upper():
target = b
break
# 平仓后全部卖出交易账户可用标的币(含权利金盈亏留下的币),不按 bridge 记账量限卖
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=None)
if target is None:
if not sell.get("ok"):
msg = str(sell.get("msg") or "")
if "无可用" in msg or "过小" in msg:
return {"ok": True, "msg": "无残留币需卖回", "skipped": True}
return {"ok": False, "msg": msg, "bridge_status": BRIDGE_PENDING_SELL}
return {"ok": True, "sell": sell, "bridge_status": None}
bid = int(target["id"])
if not sell.get("ok"):
update_bridge(
conn,
bid,
status=BRIDGE_PENDING_SELL,
inst_id=inst_id,
message=str(sell.get("msg") or "卖回 USDT 失败"),
)
return {
"ok": False,
"msg": sell.get("msg") or "卖回 USDT 失败",
"bridge_id": bid,
"bridge_status": BRIDGE_PENDING_SELL,
}
update_bridge(
conn,
bid,
status=BRIDGE_CLOSED,
sell_ord_id=str(sell.get("ord_id") or ""),
coin_sold=_safe_float(sell.get("coin_sold")),
inst_id=inst_id,
message="期权已平,币已卖回 USDT",
close=True,
)
return {"ok": True, "sell": sell, "bridge_id": bid, "bridge_status": BRIDGE_CLOSED}
def bridge_blocks_new_open_msg(conn: sqlite3.Connection) -> str | None:
bridges = list_open_bridges(conn)
if not bridges:
return None
st = str(bridges[0].get("status") or "")
if st == BRIDGE_PENDING_SELL:
return "存在待卖回 USDT 的币本位桥残留,请先到期权页重试卖回后再开仓"
if st == BRIDGE_BOUGHT:
return "存在已买币未完成开仓的桥流程,请等待回滚或联系处理后重试"
if st == BRIDGE_HOLDING:
return "币本位桥仍在持仓中(一次仅一笔),请先平仓并卖回 USDT"
return "存在未完成的币本位资金桥,暂不可开仓"
def mode_switch_block_msg(conn: sqlite3.Connection, ex: Any | None = None) -> str | None:
"""有单笔期权仓或未完成桥时禁止切换本位."""
if has_unfinished_bridge(conn):
return "存在未完成的币本位资金桥,禁止切换期权本位模式"
if ex is not None:
try:
from lib.exchange.okx_options_lib import fetch_option_positions
rows = fetch_option_positions(ex) or []
for p in rows:
try:
pos = float(p.get("pos") or 0)
except (TypeError, ValueError):
pos = 0.0
if abs(pos) > 1e-12:
return "存在未平期权持仓,禁止切换期权本位模式"
except Exception:
pass
# 本地 open 交易记录
try:
row = conn.execute(
"SELECT COUNT(*) FROM options_trades WHERE status='open'"
).fetchone()
n = int(row[0] if not isinstance(row, dict) else row.get("COUNT(*)") or list(row.values())[0])
if n > 0:
return "本地仍有未平期权记录,禁止切换期权本位模式"
except Exception:
pass
return None
+192
View File
@@ -0,0 +1,192 @@
"""期权本地交易统计(胜率 / 盈亏 / 持仓时长)."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from lib.instance.instance_embed_context_lib import profit_loss_ratio_from_averages
from lib.options.options_db import init_options_tables
from lib.options.options_margin_mode_lib import margin_mode_from_inst_id, premium_ccy_for_mode
def _parse_ts(raw: Any) -> datetime | None:
if raw is None or raw == "":
return None
s = str(raw).strip().replace(" ", "T", 1)
try:
return datetime.fromisoformat(s)
except (TypeError, ValueError):
return None
def _hold_seconds(created_at: Any, closed_at: Any) -> float | None:
start = _parse_ts(created_at)
end = _parse_ts(closed_at)
if start is None or end is None:
return None
sec = (end - start).total_seconds()
return sec if sec >= 0 else None
def _avg_seconds(values: list[float]) -> float | None:
if not values:
return None
return round(sum(values) / len(values), 1)
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _row_premium_ccy(row: dict[str, Any]) -> str:
ccy = str(row.get("premium_ccy") or "").strip().upper()
if ccy:
return ccy
inst = str(row.get("inst_id") or "").strip()
mode = str(row.get("margin_mode") or "").strip().lower()
underly = str(row.get("underlying") or (inst.split("-")[0] if inst else "ETH") or "ETH")
if mode:
return premium_ccy_for_mode(mode, underly)
if not inst:
# 旧统计行无合约信息时按 USDC 口径,避免默认币本位把盈亏跳过
return "USDC"
return premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly)
def _pnl_as_usdt(row: dict[str, Any], *, fallback_index: float | None = None) -> float | None:
"""已平/浮盈统一折算为 USDT(币本位×指数;USDC 原样)."""
pnl = _safe_float(row.get("realized_pnl"))
if pnl is None:
pnl = _safe_float(row.get("upl"))
if pnl is None:
return None
ccy = _row_premium_ccy(row)
if ccy in ("ETH", "BTC"):
px = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
if px is None or px <= 0:
px = fallback_index
if px is None or px <= 0:
return None
return float(pnl) * float(px)
return float(pnl)
def _history_index_px(history: list[dict[str, Any]]) -> float | None:
for row in history:
px = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
if px is not None and px > 0:
return px
return None
def compute_options_stats_from_history(
history: list[dict[str, Any]],
*,
index_px: float | None = None,
) -> dict[str, Any]:
"""基于期权历史列表计算统计;币本位盈亏按指数折算为 U."""
wins: list[float] = []
losses: list[float] = []
win_holds: list[float] = []
loss_holds: list[float] = []
all_holds: list[float] = []
open_holds: list[float] = []
now = datetime.now()
fallback_idx = index_px if index_px is not None and index_px > 0 else _history_index_px(history)
coinish = False
for row in history:
ccy = _row_premium_ccy(row)
if ccy in ("ETH", "BTC"):
coinish = True
if row.get("status") == "open":
start = _parse_ts(row.get("created_at"))
if start is not None:
sec = (now - start).total_seconds()
if sec >= 0:
open_holds.append(sec)
continue
pnl = _pnl_as_usdt(row, fallback_index=fallback_idx)
if pnl is None:
continue
hold = _hold_seconds(row.get("created_at"), row.get("closed_at"))
if hold is not None:
all_holds.append(hold)
if pnl > 0:
wins.append(pnl)
if hold is not None:
win_holds.append(hold)
elif pnl < 0:
losses.append(pnl)
if hold is not None:
loss_holds.append(hold)
total_closed = len(wins) + len(losses)
win_rate = round(len(wins) / total_closed * 100, 2) if total_closed else 0
avg_win = sum(wins) / len(wins) if wins else None
avg_loss = sum(losses) / len(losses) if losses else None
total_profit = round(sum(wins), 4) if wins else 0.0
total_loss = round(abs(sum(losses)), 4) if losses else 0.0
net_realized = round(sum(wins) + sum(losses), 4)
return {
"total_closed": total_closed,
"win_count": len(wins),
"loss_count": len(losses),
"win_rate": win_rate,
"profit_loss_ratio": profit_loss_ratio_from_averages(avg_win, avg_loss),
"avg_win": round(avg_win, 4) if avg_win is not None else None,
"avg_loss": round(abs(avg_loss), 4) if avg_loss is not None else None,
"total_profit": total_profit,
"total_loss": total_loss,
"net_realized_pnl": net_realized,
"avg_hold_sec": _avg_seconds(all_holds),
"avg_win_hold_sec": _avg_seconds(win_holds),
"avg_loss_hold_sec": _avg_seconds(loss_holds),
"open_count": len(open_holds),
"avg_open_hold_sec": _avg_seconds(open_holds),
"pnl_unit": "U" if coinish else "USDC",
"index_px": fallback_idx,
}
def compute_options_stats(get_db) -> dict[str, Any]:
conn = get_db()
try:
init_options_tables(conn)
closed_rows = conn.execute(
"""
SELECT realized_pnl, created_at, closed_at, inst_id, premium_ccy, margin_mode
FROM options_trades
WHERE status = 'closed' AND realized_pnl IS NOT NULL
"""
).fetchall()
open_rows = conn.execute(
"""
SELECT created_at FROM options_trades WHERE status = 'open'
"""
).fetchall()
finally:
conn.close()
hist = []
for row in closed_rows:
hist.append(
{
"status": "closed",
"realized_pnl": row["realized_pnl"],
"created_at": row["created_at"],
"closed_at": row["closed_at"],
"inst_id": row["inst_id"] if "inst_id" in row.keys() else None,
"premium_ccy": row["premium_ccy"] if "premium_ccy" in row.keys() else None,
"margin_mode": row["margin_mode"] if "margin_mode" in row.keys() else None,
}
)
for row in open_rows:
hist.append({"status": "open", "created_at": row["created_at"]})
return compute_options_stats_from_history(hist)
+546
View File
@@ -0,0 +1,546 @@
"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算)."""
from __future__ import annotations
import sqlite3
import time
from typing import Any, Callable
from lib.options.options_db import init_options_tables
from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px
def _safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
from lib.exchange.okx_options_lib import option_fields_from_inst_id
inst_id = str(pos.get("instId") or pos.get("inst_id") or "")
mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark"))
if mark is None:
mark = fetch_option_mark_px(ex, inst_id)
opt_type = pos.get("optType") or (quote or {}).get("opt_type")
strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike"))
if not opt_type or strike is None:
pt, ps = option_fields_from_inst_id(inst_id)
opt_type = opt_type or pt
if strike is None:
strike = ps
idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
return close_ref_prices(
mark_px=mark,
opt_type=str(opt_type or ""),
strike=strike,
index_px=idx,
inst_id=inst_id,
)
def ensure_target_tables(conn: sqlite3.Connection) -> None:
init_options_tables(conn)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS options_target_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inst_id TEXT NOT NULL,
underlying TEXT,
opt_type TEXT,
target_index REAL NOT NULL,
trade_id INTEGER,
sheets INTEGER,
status TEXT DEFAULT 'active',
trigger_idx REAL,
close_ord_id TEXT,
message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
triggered_at TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status
ON options_target_monitors(status)
"""
)
def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
"""Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓."""
ot = (opt_type or "").strip().upper()
if ot == "P":
return index_px <= target_index
return index_px >= target_index
def upsert_target_monitor(
conn: sqlite3.Connection,
*,
inst_id: str,
target_index: float,
underlying: str | None = None,
opt_type: str | None = None,
trade_id: int | None = None,
sheets: int | None = None,
) -> dict[str, Any]:
ensure_target_tables(conn)
inst_id = (inst_id or "").strip()
if not inst_id:
return {"ok": False, "msg": "缺少 inst_id"}
if target_index is None or float(target_index) <= 0:
return {"ok": False, "msg": "目标位无效"}
target_index = float(target_index)
row = conn.execute(
"""
SELECT id FROM options_target_monitors
WHERE inst_id = ? AND status IN ('active', 'closing')
ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'closing' THEN 1 ELSE 2 END, id DESC
LIMIT 1
""",
(inst_id,),
).fetchone()
if row:
conn.execute(
"""
UPDATE options_target_monitors
SET target_index = ?,
underlying = COALESCE(?, underlying),
opt_type = COALESCE(?, opt_type),
trade_id = COALESCE(?, trade_id),
sheets = COALESCE(?, sheets),
status = 'active',
trigger_idx = NULL,
close_ord_id = NULL,
message = NULL,
triggered_at = NULL
WHERE id = ?
""",
(target_index, underlying, opt_type, trade_id, sheets, int(row["id"])),
)
mon_id = int(row["id"])
# 同一合约其他进行中的委托取消,避免双轨触发重复推送
conn.execute(
"""
UPDATE options_target_monitors
SET status = 'cancelled', message = '被新目标位覆盖'
WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
""",
(inst_id, mon_id),
)
else:
cur = conn.execute(
"""
INSERT INTO options_target_monitors
(inst_id, underlying, opt_type, target_index, trade_id, sheets, status)
VALUES (?, ?, ?, ?, ?, ?, 'active')
""",
(inst_id, underlying, opt_type, target_index, trade_id, sheets),
)
mon_id = int(cur.lastrowid)
return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index}
def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int:
ensure_target_tables(conn)
if monitor_id is not None:
cur = conn.execute(
"""
UPDATE options_target_monitors
SET status = 'cancelled', message = '手动取消'
WHERE id = ? AND status IN ('active', 'closing')
""",
(int(monitor_id),),
)
return int(cur.rowcount or 0)
if inst_id:
cur = conn.execute(
"""
UPDATE options_target_monitors
SET status = 'cancelled', message = '手动取消'
WHERE inst_id = ? AND status IN ('active', 'closing')
""",
(inst_id.strip(),),
)
return int(cur.rowcount or 0)
return 0
def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
return {
"id": int(r["id"]),
"inst_id": r["inst_id"],
"underlying": r["underlying"],
"opt_type": r["opt_type"],
"target_index": _safe_float(r["target_index"]),
"trade_id": r["trade_id"],
"sheets": r["sheets"],
"status": r["status"],
"message": r["message"],
"created_at": r["created_at"],
}
def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
ensure_target_tables(conn)
rows = conn.execute(
"""
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
status, message, created_at
FROM options_target_monitors
WHERE status = 'active'
ORDER BY id DESC
"""
).fetchall()
return [_row_to_target(r) for r in rows]
def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""已挂出平仓单、等待成交的目标(不再重复推送微信)."""
ensure_target_tables(conn)
rows = conn.execute(
"""
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
status, message, created_at
FROM options_target_monitors
WHERE status = 'closing'
ORDER BY id DESC
"""
).fetchall()
return [_row_to_target(r) for r in rows]
def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
"""UI/持仓挂载:active 与 closing 都算进行中."""
out: dict[str, dict[str, Any]] = {}
for t in list_closing_targets(conn) + list_active_targets(conn):
inst = str(t.get("inst_id") or "")
if inst and inst not in out:
out[inst] = t
return out
def mark_monitor(
conn: sqlite3.Connection,
monitor_id: int,
*,
status: str,
trigger_idx: float | None = None,
close_ord_id: str | None = None,
message: str | None = None,
) -> None:
conn.execute(
"""
UPDATE options_target_monitors
SET status = ?,
trigger_idx = COALESCE(?, trigger_idx),
close_ord_id = COALESCE(?, close_ord_id),
message = COALESCE(?, message),
triggered_at = CASE
WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP)
ELSE triggered_at
END
WHERE id = ?
""",
(status, trigger_idx, close_ord_id, message, status, int(monitor_id)),
)
def cancel_orphans_without_position(
conn: sqlite3.Connection,
*,
live_inst_ids: set[str],
) -> int:
"""持仓已消失的目标委托标记为 expired(到期/已平),不挂止损."""
ensure_target_tables(conn)
rows = list_active_targets(conn) + list_closing_targets(conn)
n = 0
for t in rows:
inst = str(t.get("inst_id") or "")
if inst and inst not in live_inst_ids:
mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束")
n += 1
return n
def _commit_monitor(conn: sqlite3.Connection) -> None:
"""状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送."""
try:
conn.commit()
except Exception:
pass
def close_option_by_bid_depth(
cfg: dict[str, Any],
ex: Any,
inst_id: str,
*,
sheets: int | None = None,
) -> dict[str, Any]:
"""目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性)."""
from lib.options.options_close_exec_lib import close_option_by_bid1
return close_option_by_bid1(
cfg,
ex,
inst_id,
sheets=sheets,
require_recycle_gate=True,
signal_note="目标位平仓",
)
def _notify_target_close(
cfg: dict[str, Any] | None,
send_wechat: Callable[[str], None] | None,
*,
account_label: str,
inst_id: str,
target: float,
idx: float,
result: dict[str, Any],
conn: Any = None,
) -> None:
"""目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
from lib.options.options_notify_lib import resolve_options_premium_ccy
ccy = resolve_options_premium_ccy(inst_id=inst_id)
d = 6 if ccy in ("ETH", "BTC") else 4
mode = "币本位" if ccy != "USDC" else "USDC"
if result.get("fully_closed") or result.get("already_flat"):
if cfg is not None:
try:
from lib.options.options_notify_lib import notify_options_close
notify_options_close(
cfg,
conn,
inst_id=inst_id,
reason="目标位平仓",
sheets=result.get("submitted_sheets"),
premium_received=result.get("premium_received"),
close_quote=result.get("locked_bid_px") or result.get("bid"),
target_index=target,
trigger_idx=idx,
premium_ccy=ccy,
)
# 无论首次/幂等跳过,全平路径不再走下方 fallback,避免重复推
return
except Exception:
pass
if not send_wechat:
return
try:
recv = result.get("premium_received")
recv_txt = f"{float(recv):.{d}f} {ccy}" if recv is not None else f"— {ccy}"
send_wechat(
"\n".join(
[
"【OKX期权·目标位平仓】",
f"账户:{account_label}",
f"本位:{mode}",
f"合约:{inst_id}",
f"目标指数:{target:g}",
f"触发指数:{idx:g}",
f"提交张数:{result.get('submitted_sheets') or '—'}",
f"预估收回:{recv_txt}",
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
]
)
)
except Exception:
pass
def _result_fully_done(result: dict[str, Any]) -> bool:
if result.get("already_flat"):
return True
if result.get("fully_closed"):
return True
remaining = result.get("remaining_sheets")
if remaining is not None and int(remaining) <= 0 and result.get("ok"):
return True
return False
def run_options_target_closes(
conn: sqlite3.Connection,
positions: list[dict[str, Any]],
*,
close_fn: Callable[[str], dict[str, Any]],
index_fn: Callable[[dict[str, Any]], float | None] | None = None,
send_wechat: Callable[[str], None] | None = None,
account_label: str = "OKX期权",
cfg: dict[str, Any] | None = None,
) -> int:
"""
扫描 active 目标委托;指数到位后限价平仓.
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
未完全成交进入 closing,仅重试平仓不再推送.
返回本次新触发(并推送)的条数.
"""
ensure_target_tables(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
live_ids = {k for k in pos_by_inst if k}
hedge_managed: set[str] = set()
try:
from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
init_hedge_plan_tables(conn)
hedge_managed = active_hedge_option_inst_ids(conn)
except Exception:
# fail-closed:本轮不执行任何单独目标平仓,避免误平对冲腿
return 0
cancel_orphans_without_position(conn, live_inst_ids=live_ids)
_commit_monitor(conn)
# 先处理已挂单等待成交的;首次触发已推过「挂单中」,此处仅在全平时走幂等平仓推送
for mon in list_closing_targets(conn):
inst_id = str(mon.get("inst_id") or "")
if not inst_id:
continue
if inst_id in hedge_managed:
mark_monitor(
conn,
int(mon["id"]),
status="expired",
message="已移交对冲计划托管,跳过单独目标平仓",
)
_commit_monitor(conn)
continue
if inst_id not in pos_by_inst:
mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
_commit_monitor(conn)
target = _safe_float(mon.get("target_index"))
idx = _safe_float(mon.get("trigger_idx"))
_notify_target_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
target=float(target) if target is not None else 0.0,
idx=float(idx) if idx is not None else 0.0,
result={"already_flat": True, "fully_closed": True, "ok": True},
conn=conn,
)
continue
result = close_fn(inst_id)
idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
if result.get("already_flat") or _result_fully_done(result):
mark_monitor(
conn,
int(mon["id"]),
status="triggered",
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message="目标位限价平仓完成",
)
_commit_monitor(conn)
target = _safe_float(mon.get("target_index"))
_notify_target_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
target=float(target) if target is not None else 0.0,
idx=float(idx) if idx is not None else 0.0,
result={**result, "fully_closed": True},
conn=conn,
)
continue
mark_monitor(
conn,
int(mon["id"]),
status="closing",
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
)
_commit_monitor(conn)
triggered = 0
for mon in list_active_targets(conn):
inst_id = str(mon.get("inst_id") or "")
target = _safe_float(mon.get("target_index"))
if not inst_id or target is None:
continue
if inst_id in hedge_managed:
mark_monitor(
conn,
int(mon["id"]),
status="expired",
message="已移交对冲计划托管,跳过单独目标平仓",
)
_commit_monitor(conn)
continue
pos = pos_by_inst.get(inst_id)
if not pos:
continue
if index_fn is not None:
idx = index_fn(pos)
else:
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
if idx is None:
continue
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
continue
result = close_fn(inst_id)
if result.get("already_flat"):
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
_commit_monitor(conn)
triggered += 1
_notify_target_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
target=target,
idx=idx,
result=result,
conn=conn,
)
continue
if not result.get("ok"):
mark_monitor(
conn,
int(mon["id"]),
status="active",
trigger_idx=idx,
message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"),
)
_commit_monitor(conn)
continue
done = _result_fully_done(result)
status = "triggered" if done else "closing"
mark_monitor(
conn,
int(mon["id"]),
status=status,
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
)
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
_commit_monitor(conn)
triggered += 1
_notify_target_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
target=target,
idx=idx,
result=result,
conn=conn,
)
return triggered
+181
View File
@@ -0,0 +1,181 @@
"""期权业务时区墙钟:库内统一按 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
set_sync_state(conn, _SWAP_REPAIR_FLAG, "1")
return {"trades": trades_fixed, "review": review_fixed}
+383
View File
@@ -0,0 +1,383 @@
<div class="options-page-wrap" style="grid-column:1/-1" id="options-root"
data-default-underly="{{ options_default_underly | default('ETH') }}"
data-budget-buffer="{{ options_budget_buffer | default(0.95) }}"
data-trade-budget="{{ options_trade_budget | default(10) }}"
data-compound-full-enabled="{% if options_compound_full_enabled %}1{% else %}0{% endif %}"
data-compound-cap-enabled="{% if options_compound_full_cap_enabled %}1{% else %}0{% endif %}"
data-compound-cap-usdc="{{ '%.2f'|format(options_compound_full_cap_usdc|default(300)|float) }}"
data-ask-liq-filter="{% if options_chain_ask_liq_filter is defined %}{{ '1' if options_chain_ask_liq_filter else '0' }}{% else %}1{% endif %}">
{% set compound_on = options_compound_full_enabled if options_compound_full_enabled is defined else true %}
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权未启用:请在 <code>crypto_monitor_okx/.env</code> 设置 <code>OKX_OPTIONS_ENABLED=true</code> 及 <code>OKX_API_*</code>(永续与期权共用),然后 <code>pm2 restart crypto_okx --update-env</code>.</div>
{% endif %}
{% if options_enabled and options_open_allowed is defined and not options_open_allowed %}
<div class="flash" style="margin-bottom:12px">当前交易模式为对冲(永期/期期),不可单独开期权;持仓可在此查看/平仓.切换请到 env「交易模式」.</div>
{% endif %}
<div class="options-dual-grid">
<div class="card options-order-card"{% if options_open_allowed is defined and not options_open_allowed %} style="opacity:.72"{% endif %}>
<h2>期权下单{% if options_open_allowed is defined and not options_open_allowed %} <small class="muted">(对冲模式已禁用开仓)</small>{% endif %}</h2>
<details class="opt-close-rule opt-open-rule">
<summary>开平仓规则说明</summary>
<div class="opt-close-rule-body">
<p><strong>开仓</strong> · 报价单位为每 1 ETH/BTC;1 张 = 0.01。默认选中<strong>最近一期</strong>到期,可手动改。</p>
<ul>
<li><strong>列表</strong>含卖一/买一;<strong>T 型</strong>仅卖一(买方开仓),中间为跨式双买测算。</li>
<li>环境配置「链上仅显示有卖一」开启时,隐藏无真实卖一或深度不足 1 张的合约(估算价 <strong>~</strong> 亦不显示)。</li>
<li><strong>开仓只认真实卖一价且卖一深度≥1</strong>;无深度时面板显示参考标记价并禁用买入。</li>
<li>链展示近 <span id="opt-chain-dte">14</span> 日到期;列表与 T 型默认<strong>平值 + 实值3档 + 虚值3档</strong>,勾选「展开全部」看全部行权价(若当前为实值/虚值筛选会自动切回「全部」)。</li>
<li>「按可用余额打满」可用额度 = min(交易户可用 USDC, 单笔预算 <strong id="opt-trade-budget">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</strong>),再 × 预算缓冲 <strong id="opt-budget-buf">{{ '%.2f'|format(options_budget_buffer|default(0.95)|float) }}</strong> 算张数(env 可改)。</li>
<li>「全仓复利」用期权交易户<strong>全部可用</strong>×缓冲开仓(不受单笔预算限制);可选开启全仓上限;该模式下仅允许同时 1 笔持仓。</li>
<li><strong>币本位</strong>(env <code>OKX_OPTIONS_MARGIN_MODE=coin</code>):按交易户 USDT×缓冲买满 ETH/BTC 再开满期权;平仓后自动卖回 USDT;对冲仍仅 USDC。有仓勿切换本位。</li>
<li><strong>翻倍出场</strong>:开仓时可勾选;1倍=盈利等于权利金,买一可回收达标后限价平;持仓卡可改倍数或关闭。</li>
</ul>
<p><strong>平仓(买一)</strong> · 平仓前重新读盘口并校验有效流动性;市价平仓已禁用。</p>
<ul>
<li>本轮只锁<strong>买一</strong>:张数 = min(持仓, 买一深度),限价 = 当场买一。</li>
<li>买一不够时只平能吃掉的部分,剩余等下次再点「买一平仓」。</li>
<li>手动平仓只验有效买一(非残档);目标触达后才平,2×权利金只是门控(到 2× 本身不会自动平)。</li>
<li><strong>翻倍出场</strong>:开启后可自选倍数(默认1);1倍=盈利等于权利金,买一可回收达标即限价平;可随时关闭。</li>
<li>全程 <code>reduceOnly</code> 限价卖,不吃买二及以下、不走市价。</li>
<li>币本位平仓后自动卖回 USDT;失败可点持仓区「重试卖回」按交易户全部可用量市价卖出。</li>
</ul>
<p><a href="/options/guide" target="_blank" rel="noopener">打开《期权开平仓与监控说明》</a></p>
</div>
</details>
<div class="form-row options-chain-toolbar">
<button type="button" class="btn-secondary opt-uly-btn active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary opt-uly-btn" data-uly="BTC">BTC</button>
<select id="opt-exp-select"><option value="">选择到期日</option></select>
<span class="opt-chain-view-group">
<button type="button" class="btn-secondary opt-view-btn active" data-view="list">列表</button>
<button type="button" class="btn-secondary opt-view-btn" data-view="t">T 型</button>
</span>
<span id="opt-type-btn-group" class="opt-type-btn-group">
<button type="button" class="btn-secondary opt-type-btn active" data-type="C">看涨 Call</button>
<button type="button" class="btn-secondary opt-type-btn" data-type="P">看跌 Put</button>
</span>
<button type="button" class="btn-secondary opt-money-btn active" data-money="all">全部</button>
<button type="button" class="btn-secondary opt-money-btn" data-money="itm">实值</button>
<button type="button" class="btn-secondary opt-money-btn" data-money="otm">虚值</button>
<label id="opt-strike-expand-wrap" class="opt-strike-expand-label">
<input type="checkbox" id="opt-strike-expand-all"> 展开全部
</label>
<button type="button" class="btn-secondary" id="opt-load-chain">刷新链</button>
</div>
<div id="opt-index-line" class="muted"></div>
<div class="options-strike-table-wrap" id="opt-strike-table-wrap">
<table class="options-strike-table" id="opt-strike-table">
<thead>
<tr id="opt-strike-head-list">
<th>行权价</th>
<th>类型</th>
<th>合约</th>
<th>卖一/张</th>
<th title="USDC:指数÷卖一;币本位:1÷卖一(卖一为币报价)">杠杆</th>
<th>买一/张</th>
<th>到期平衡</th>
<th>平衡价差</th>
<th>操作</th>
</tr>
<tr id="opt-strike-head-t" class="hidden" hidden>
<th colspan="3" class="opt-t-head-call">Call</th>
<th colspan="3" class="opt-t-head-mid">跨式</th>
<th colspan="3" class="opt-t-head-put">Put</th>
</tr>
<tr id="opt-strike-head-t-cols" class="hidden" hidden>
<th>卖一/张</th>
<th>类型</th>
<th>操作</th>
<th>行权价</th>
<th title="Call卖一+Put卖一(每1币)">双买/币</th>
<th title="到期测算平衡带">平衡带</th>
<th>类型</th>
<th>卖一/张</th>
<th>操作</th>
</tr>
</thead>
<tbody id="opt-strike-tbody">
<tr><td colspan="9" class="muted">请选择到期日</td></tr>
</tbody>
</table>
</div>
<div id="opt-order-panel-host" class="opt-order-backdrop" hidden aria-hidden="true">
<div id="opt-order-panel" class="opt-order-dialog" role="dialog" aria-modal="true" aria-labelledby="opt-order-dialog-title" style="display:none">
<div class="opt-order-dialog-head">
<h3 class="opt-order-title" id="opt-order-dialog-title">下单</h3>
<button type="button" class="btn-secondary" id="opt-order-close-btn" style="font-size:.72rem;padding:2px 10px">取消</button>
</div>
<div class="opt-order-layout">
<div class="opt-order-main">
<div id="opt-order-inst" class="options-order-inst"></div>
<div class="options-order-grid">
<div><span class="k">卖一/张</span><span id="opt-order-ask" class="v">—</span></div>
<div><span class="k">买一/张</span><span id="opt-order-bid" class="v">—</span></div>
<div><span class="k">参考标记价</span><span id="opt-order-ref-ask" class="v muted">—</span></div>
<div><span class="k">张数</span><span id="opt-order-sheets" class="v">—</span></div>
<div><span class="k" id="opt-order-eth-label">ETH 数量</span><span id="opt-order-eth" class="v">—</span></div>
<div><span class="k">预估权利金</span><span id="opt-order-premium" class="v">—</span></div>
<div><span class="k">合约杠杆</span><span id="opt-order-leverage" class="v" title="名义价值÷权利金,测算用">—</span></div>
<div><span class="k">到期平衡</span><span id="opt-order-expiry-be" class="v">—</span></div>
<div><span class="k">平衡价差</span><span id="opt-order-dist-be" class="v">—</span></div>
</div>
<div class="options-estimate-row">
<div class="opt-est-main">
<label class="btn-secondary opt-order-chip" for="opt-target-idx" title="仅作到期实值估算参考">目标位(指数)</label>
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="参考指数·到期实值"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<span class="k">预计价值</span>
<span id="opt-est-value" class="v">—</span>
<span class="k">盈利</span>
<span id="opt-est-profit" class="v">—</span>
<span class="k">盈亏比</span>
<span id="opt-est-rr" class="v" title="盈利金额÷本合约权利金">—</span>
</div>
<span class="muted opt-est-note">目标位仅参考(按到期实值估);盈亏比=盈利÷权利金;到位后按买一限价平;无止损,到期即止损</span>
</div>
<div class="options-estimate-row opt-profit-exit-row">
<div class="opt-est-main">
<label class="btn-secondary opt-order-chip" for="opt-profit-exit-enabled" title="开启后监控买一可回收;达标按买一限价平">
<input type="checkbox" id="opt-profit-exit-enabled">
<span>翻倍出场</span>
</label>
<label class="k" for="opt-profit-exit-mult">倍数</label>
<input type="number" id="opt-profit-exit-mult" class="opt-profit-exit-mult" min="0.1" step="0.1" value="1"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
</div>
<span class="muted opt-est-note">1倍=盈利等于权利金(可回收≥2×权利金);可开可关,与目标位并行</span>
</div>
<div class="form-row options-order-mode-row">
<div class="opt-size-mode-bar">
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="sheets"{% if not compound_on %} checked{% endif %}>
<span>指定张数</span>
</label>
<input type="number" id="opt-sheets-amount" min="1" step="1" value="1" placeholder="张数"
autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-budget-wrap"{% if compound_on %} hidden{% endif %}>
<input type="radio" name="opt-size-mode" value="budget_full"{% if compound_on %} disabled{% endif %}>
<span>按可用余额打满</span>
</label>
<label class="btn-secondary opt-order-chip opt-size-mode-chip" id="opt-size-mode-compound-wrap"{% if not compound_on %} hidden{% endif %}>
<input type="radio" name="opt-size-mode" value="compound_full"{% if compound_on %} checked{% endif %}{% if not compound_on %} disabled{% endif %}>
<span>全仓复利</span>
</label>
<label class="btn-secondary opt-order-chip opt-size-mode-chip">
<input type="radio" name="opt-size-mode" value="eth_amount" id="opt-size-mode-eth">
<span>指定币数量</span>
</label>
<input type="number" id="opt-eth-amount" min="0.01" step="0.01" placeholder="如 0.5" style="display:none"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
</div>
<p class="muted opt-budget-full-hint" id="opt-budget-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
余额 &gt; 单笔预算(<span id="opt-budget-full-cap">{{ '%.2f'|format(options_trade_budget|default(10)|float) }}</span>U)时按预算;余额不足时按余额;再乘预算缓冲算张数。
</p>
<p class="muted opt-compound-full-hint" id="opt-compound-full-hint" style="display:none;margin:6px 0 0;font-size:.82rem;line-height:1.4">
用期权交易户全部可用×缓冲开仓;不受单笔预算限制。<span id="opt-compound-cap-line">全仓上限关闭</span>。仅允许同时持有 1 笔仓位。
</p>
<input type="text" id="opt-signal-note" name="opt_signal_note" class="opt-signal-note" placeholder="备注(关键位说明)"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
</div>
<div class="opt-order-dialog-actions">
<button type="button" class="btn-primary" id="opt-open-btn">限价买入 @ 卖一</button>
<button type="button" class="btn-secondary" id="opt-order-cancel-btn">取消</button>
</div>
<div id="opt-order-msg" class="muted"></div>
</div>
</div>
</div>
</div>
</div>
<div class="card options-pos-card-wrap">
<div class="options-pos-head">
<h2>持仓</h2>
<div class="options-pos-head-actions">
<span id="opt-bridge-sell-hint" class="muted opt-bridge-sell-hint" hidden></span>
<button type="button" class="btn-secondary opt-retry-sell-coin-btn" id="opt-retry-sell-coin" hidden title="市价卖出交易账户全部可用标的币换回 USDT">重试卖回</button>
<button type="button" class="btn-secondary" id="opt-refresh-positions">刷新</button>
</div>
</div>
<div class="options-pos-tabs" role="tablist" aria-label="持仓面板">
<button type="button" class="btn-secondary opt-pos-tab active" data-opt-pos-tab="live" role="tab" aria-selected="true" id="opt-pos-tab-live">当前持仓</button>
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="pending" role="tab" aria-selected="false" id="opt-pos-tab-pending">当前委托</button>
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="stats" role="tab" aria-selected="false" id="opt-pos-tab-stats">数据统计</button>
<button type="button" class="btn-secondary opt-pos-tab" data-opt-pos-tab="history" role="tab" aria-selected="false" id="opt-pos-tab-history">期权历史</button>
</div>
<div class="options-pos-tab-body">
<div class="options-pos-pane is-active" data-opt-pos-pane="live" role="tabpanel" aria-labelledby="opt-pos-tab-live">
<div id="opt-target-monitors" class="opt-target-monitors" hidden>
<div class="opt-target-monitors-head">目标监控</div>
<div id="opt-target-monitors-list"></div>
</div>
<div id="opt-pos-live" class="panel-scroll pos-list options-pos-live-pane">
<div class="pos-empty" id="opt-pos-empty">暂无持仓</div>
<div id="opt-pos-cards"></div>
</div>
</div>
<div class="options-pos-pane" data-opt-pos-pane="pending" role="tabpanel" aria-labelledby="opt-pos-tab-pending" hidden>
<div class="opt-pos-pending-pane">
<div class="opt-order-pending-head">
<p class="muted opt-pending-ttl-hint" id="opt-pending-ttl-hint" style="margin:0;flex:1">平仓限价超 10 分未成交将自动撤销</p>
<button type="button" class="btn-secondary" id="opt-pending-refresh">刷新</button>
</div>
<div id="opt-pending-list" class="opt-pending-list opt-pending-list--tab">
<div class="muted opt-pending-empty">暂无未成交委托</div>
</div>
</div>
</div>
<div class="options-pos-pane" data-opt-pos-pane="stats" role="tabpanel" aria-labelledby="opt-pos-tab-stats" hidden>
<div class="options-stats-panel">
<div class="options-stats-pnl-summary" id="opt-stats-pnl-summary">
<div class="options-stat-item opt-stats-net-item">
<span class="k">合计盈亏</span>
<span class="v" id="opt-stats-total-pnl">—</span>
</div>
<div class="options-stat-item">
<span class="k">已平净盈亏</span>
<span class="v" id="opt-stats-net-realized">—</span>
</div>
<div class="options-stat-item">
<span class="k">持仓浮盈</span>
<span class="v" id="opt-stats-open-float">—</span>
</div>
</div>
<div class="options-stats-charts">
<div class="opt-stats-chart opt-stats-chart--ring">
<div class="opt-stats-ring" id="opt-stats-ring" style="--win-pct: 0">
<span class="opt-stats-ring-label" id="opt-stats-ring-label">—</span>
</div>
<span class="opt-stats-chart-caption">胜率</span>
</div>
<div class="opt-stats-chart opt-stats-chart--pnl">
<div class="opt-stats-bar-row">
<span class="k">平均盈利</span>
<div class="opt-stats-bar-track">
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-profit"></div>
</div>
<span class="v pos-pnl-profit" id="opt-stats-bar-profit-label">—</span>
</div>
<div class="opt-stats-bar-row">
<span class="k">平均亏损</span>
<div class="opt-stats-bar-track">
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss"></div>
</div>
<span class="v pos-pnl-loss" id="opt-stats-bar-loss-label">—</span>
</div>
</div>
</div>
<div class="opt-stats-chart opt-stats-chart--hold">
<div class="opt-stats-chart-title">持仓时长对比</div>
<div class="opt-stats-bar-row">
<span class="k">盈单</span>
<div class="opt-stats-bar-track">
<div class="opt-stats-bar-fill opt-stats-bar-fill--profit" id="opt-stats-bar-win-hold"></div>
</div>
<span class="v" id="opt-stats-win-hold-label">—</span>
</div>
<div class="opt-stats-bar-row">
<span class="k">亏单</span>
<div class="opt-stats-bar-track">
<div class="opt-stats-bar-fill opt-stats-bar-fill--loss" id="opt-stats-bar-loss-hold"></div>
</div>
<span class="v" id="opt-stats-loss-hold-label">—</span>
</div>
</div>
<div class="options-stats-grid">
<div class="options-stat-item">
<span class="k">胜率</span>
<span class="v" id="opt-stats-winrate">—</span>
</div>
<div class="options-stat-item">
<span class="k">盈亏比</span>
<span class="v" id="opt-stats-plr">—</span>
</div>
<div class="options-stat-item">
<span class="k">已平笔数</span>
<span class="v" id="opt-stats-closed">—</span>
</div>
<div class="options-stat-item">
<span class="k">平均盈利</span>
<span class="v pos-pnl-profit" id="opt-stats-profit">—</span>
</div>
<div class="options-stat-item">
<span class="k">平均亏损</span>
<span class="v pos-pnl-loss" id="opt-stats-loss">—</span>
</div>
<div class="options-stat-item">
<span class="k">均持仓</span>
<span class="v" id="opt-stats-avg-hold">—</span>
</div>
<div class="options-stat-item">
<span class="k">盈单持仓</span>
<span class="v" id="opt-stats-win-hold">—</span>
</div>
<div class="options-stat-item">
<span class="k">亏单持仓</span>
<span class="v" id="opt-stats-loss-hold">—</span>
</div>
<div class="options-stat-item">
<span class="k">持仓中</span>
<span class="v" id="opt-stats-open-hold">—</span>
</div>
</div>
</div>
</div>
<div class="options-pos-pane" data-opt-pos-pane="history" role="tabpanel" aria-labelledby="opt-pos-tab-history" hidden>
<div class="options-history-table-wrap">
<table class="options-strike-table opt-history-table" id="opt-history-table">
<thead>
<tr>
<th>合约</th>
<th>张数</th>
<th>权利金</th>
<th>状态</th>
<th>盈亏</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="opt-history-tbody">
<tr><td colspan="7" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<details class="opt-pos-transfer" id="opt-pos-transfer">
<summary class="opt-pos-transfer-head">
<span class="opt-pos-transfer-title">划转</span>
<span class="opt-pos-transfer-open-hint muted">收起</span>
<span class="opt-pos-transfer-closed-hint muted">展开</span>
</summary>
<div class="opt-pos-transfer-body">
<div class="options-settings-subtitle">账户内划转</div>
<div class="form-row opt-pos-transfer-form" autocomplete="off">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="opt-pos-xfer-ccy" aria-label="币种" autocomplete="off">
<option value="USDT" selected>USDT</option>
<option value="USDC">USDC</option>
</select>
<select id="opt-pos-xfer-from" aria-label="划出账户">
<option value="funding" selected>from: 资金</option>
<option value="trading">from: 交易</option>
</select>
<select id="opt-pos-xfer-to" aria-label="划入账户">
<option value="trading" selected>to: 交易</option>
<option value="funding">to: 资金</option>
</select>
<input type="number" id="opt-pos-xfer-amount" name="cm_opt_pos_xfer_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<button type="button" class="btn-secondary btn-sm" id="opt-pos-xfer-all-btn">全部划转</button>
<button type="button" class="btn-primary btn-sm" id="opt-pos-xfer-btn">划转</button>
</div>
<div id="opt-pos-xfer-msg" class="muted opt-pos-xfer-msg"></div>
</div>
</details>
</div>
</div>
</div>
<script src="/static/options_expiry_countdown.js?v=1"></script>
<script src="/static/options_panel.js?v=70"></script>
@@ -0,0 +1,450 @@
{# OKX 期权复盘:交易记录 → 复盘表单 → 复盘记录 → 统计 #}
<div class="options-review-wrap" id="options-review-root" style="grid-column:1/-1">
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px;font-size:.82rem">期权未启用:请设置 <code>OKX_OPTIONS_ENABLED=true</code> 后重启.</div>
{% endif %}
<style>
.options-review-wrap{font-size:.82rem;display:flex;flex-direction:column;gap:14px}
.options-review-wrap h2,.options-review-wrap h3{margin:0}
.or-page-head{
display:flex;align-items:center;gap:10px;flex-wrap:wrap;
padding:2px 2px 0;
}
.or-page-head h2{font-size:1.05rem;font-weight:650;margin-right:auto;letter-spacing:.02em}
.or-section{
margin:0;padding:14px 16px 16px;
border:1px solid var(--or-border, rgba(127,127,127,.28));
border-radius:12px;
background:var(--or-section-bg, rgba(18,23,38,.55));
box-shadow:var(--or-section-shadow, 0 1px 0 rgba(255,255,255,.03) inset);
color:var(--or-text, inherit);
}
.or-section-head{
display:flex;align-items:flex-start;gap:10px;flex-wrap:wrap;
margin-bottom:12px;padding-bottom:10px;
border-bottom:1px solid var(--or-border-soft, rgba(127,127,127,.22));
}
.or-section-head > div{min-width:0;flex:1}
.or-step{
flex-shrink:0;width:1.55rem;height:1.55rem;border-radius:999px;
display:inline-flex;align-items:center;justify-content:center;
font-size:.72rem;font-weight:700;
background:var(--or-accent-bg, rgba(99,102,241,.28));
color:var(--or-accent-fg, #c7c9ff);
border:1px solid var(--or-accent-border, rgba(129,140,248,.45));
}
.or-section-title{font-size:.95rem;font-weight:650;line-height:1.3;color:var(--or-title, inherit)}
.or-section-desc{margin:4px 0 0;font-size:.72rem;opacity:.72;line-height:1.45;color:var(--or-muted, inherit)}
.or-tabs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
.or-toolbar{display:flex;flex-direction:column;gap:0;margin:0}
.or-tab{
border:1px solid var(--or-border, rgba(127,127,127,.35));background:transparent;color:inherit;
padding:6px 12px;border-radius:8px;cursor:pointer;font-size:.78rem;
}
.or-tab.active{
background:var(--or-accent-bg, rgba(99,102,241,.28));
border-color:var(--or-accent-border, rgba(129,140,248,.55));
color:var(--or-accent-fg, #e8e9ff);font-weight:600;
}
.or-filters{
display:flex;flex-wrap:wrap;gap:8px;align-items:center;
margin:0;padding:10px 12px;border-radius:10px;
background:var(--or-filters-bg, rgba(0,0,0,.22));
border:1px solid var(--or-border-soft, rgba(127,127,127,.18));
}
.or-filters select,.or-filters input[type="search"],.or-filters input[type="datetime-local"]{
font-size:.76rem;min-height:2rem;
}
.or-filters #or-filter-q{max-width:168px}
.or-filters label{display:flex;align-items:center;gap:5px;font-size:.72rem;opacity:.85}
.or-badge{
display:inline-block;padding:1px 7px;border-radius:999px;
background:var(--or-badge-bg, rgba(127,127,127,.22));font-size:.7rem;vertical-align:middle;
}
.or-list-title{display:none}
.or-trades-table,.or-reviewed-table{font-size:.78rem}
.or-trades-table tr.or-row-active{
outline:1px solid var(--or-accent-border, rgba(129,140,248,.55));
background:var(--or-row-active-bg, rgba(99,102,241,.1));
}
.or-reviewed-table tbody tr{cursor:pointer}
.or-reviewed-table tbody tr:hover{background:var(--or-row-hover-bg, rgba(99,102,241,.08))}
.or-pager{
display:flex;align-items:center;gap:8px;margin-top:10px;
padding-top:8px;border-top:1px dashed var(--or-border-soft, rgba(127,127,127,.2));font-size:.74rem;
}
.or-list-loading{opacity:.55;pointer-events:none;transition:opacity .12s ease}
.or-trades-table-wrap,.or-reviewed-table-wrap{min-height:9.5rem;overflow-x:auto}
.or-reviewed-table{min-width:980px}
.or-kpi-row{
display:grid;grid-template-columns:repeat(6,minmax(0,1fr));
gap:8px;margin-bottom:12px;
}
.or-kpi-tile{
border:1px solid var(--or-border-soft, rgba(127,127,127,.22));border-radius:10px;
padding:10px 12px;background:var(--or-tile-bg, rgba(0,0,0,.2));min-width:0;
}
.or-kpi-label{font-size:.7rem;opacity:.7;margin-bottom:4px}
.or-kpi-value{font-size:.95rem;font-weight:650;letter-spacing:.01em;word-break:break-all}
.or-stats-grid{
display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;
}
.or-stat-card{
border:1px solid var(--or-border-soft, rgba(127,127,127,.22));border-radius:10px;
padding:10px 12px;background:var(--or-tile-bg, rgba(0,0,0,.16));font-size:.76rem;
}
.or-stat-card-title{
font-weight:650;margin-bottom:8px;font-size:.78rem;
padding-bottom:6px;border-bottom:1px solid var(--or-border-soft, rgba(127,127,127,.18));
}
.or-stat-row{
display:flex;justify-content:space-between;align-items:baseline;gap:10px;
padding:5px 0;border-bottom:1px solid var(--or-border-faint, rgba(127,127,127,.1));
}
.or-stat-row:last-child{border-bottom:none;padding-bottom:0}
.or-stat-key{opacity:.9;min-width:0;overflow:hidden;text-overflow:ellipsis}
.or-stat-val{flex-shrink:0;font-variant-numeric:tabular-nums;opacity:.85}
.or-journal-card{font-size:.78rem}
.or-journal-card h2{font-size:.92rem}
.or-journal-card input,
.or-journal-card select,
.or-journal-card textarea,
.or-journal-card button{font-size:.76rem}
.or-journal-card .or-form-grid,
.or-journal-card .or-form-grid2{
display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px;margin-bottom:6px;
}
.or-journal-card .or-mood-grid{display:flex;flex-wrap:wrap;gap:6px 12px;margin:8px 0;font-size:.74rem}
.or-journal-card .muted,.or-journal-card .sub{font-size:.7rem}
.or-journal-card.hidden{display:none!important}
.or-detail-backdrop{
position:fixed;inset:0;z-index:1300;
display:flex;align-items:center;justify-content:center;
padding:16px;background:var(--or-backdrop, rgba(0,0,0,.72));
}
.or-detail-backdrop[hidden]{display:none!important}
.or-img-lightbox{
position:fixed;inset:0;z-index:2200;
display:flex;align-items:center;justify-content:center;
padding:16px;background:rgba(0,0,0,.86);cursor:zoom-out;
}
.or-img-lightbox[hidden]{display:none!important}
.or-img-lightbox img{
max-width:min(96vw,1200px);max-height:92vh;
object-fit:contain;border-radius:8px;
box-shadow:0 12px 40px rgba(0,0,0,.55);
}
.or-detail-modal{
width:min(96vw,920px);max-height:90vh;overflow:auto;
background:var(--or-modal-bg, var(--card-bg, #121726));color:var(--or-text, inherit);
border:1px solid var(--or-border, rgba(127,127,127,.35));border-radius:10px;
padding:14px 16px 18px;box-shadow:var(--or-modal-shadow, 0 12px 40px rgba(0,0,0,.45));
}
.or-detail-modal-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.or-detail-modal-head h3{margin:0;margin-right:auto;font-size:.95rem;color:var(--or-title, inherit)}
.or-detail-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:6px 12px;font-size:.76rem;margin-bottom:8px}
.or-detail-images{
display:grid;grid-template-columns:repeat(2,minmax(0,1fr));
gap:10px;margin:10px 0 4px;
}
.or-detail-img-cell{
min-width:0;border:1px solid var(--or-border-soft, rgba(127,127,127,.25));border-radius:8px;
padding:8px;display:flex;flex-direction:column;gap:6px;
background:var(--or-tile-bg, rgba(0,0,0,.18));
}
.or-detail-img-label{font-size:.72rem;opacity:.85;font-weight:600}
.or-detail-img-thumb{
width:100%;max-height:280px;object-fit:contain;
border-radius:6px;cursor:zoom-in;background:var(--or-img-bg, rgba(0,0,0,.25));
}
.or-detail-img-miss{
min-height:120px;display:flex;align-items:center;justify-content:center;
font-size:.72rem;opacity:.65;border-radius:6px;background:rgba(127,127,127,.12);
}
.or-slot-thumb{
display:block;margin-top:6px;max-width:160px;max-height:90px;
object-fit:contain;border-radius:4px;border:1px solid var(--or-border, rgba(127,127,127,.3));
background:var(--or-img-bg, rgba(0,0,0,.2));cursor:zoom-in;
}
@media (max-width:900px){
.or-kpi-row{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media (max-width:640px){
.or-kpi-row{grid-template-columns:repeat(2,minmax(0,1fr))}
.or-detail-images{grid-template-columns:1fr}
.or-detail-img-thumb{max-height:220px}
}
</style>
<div class="or-page-head">
<h2>期权复盘</h2>
<span class="muted" id="or-sync-status" style="font-size:.72rem"></span>
<button type="button" class="btn-secondary" id="or-reload-btn" style="font-size:.76rem;padding:4px 10px">刷新</button>
</div>
{# Tab + 筛选:放在各内容卡片上方,全局作用于下方列表/统计 #}
<div class="or-toolbar">
<div class="or-tabs" role="tablist" aria-label="复盘分类" data-okx-trade-mode="{{ okx_trade_mode|default('options') }}">
{% if okx_trade_mode|default('options') == 'options' %}
<button type="button" class="or-tab active" data-source="option_spot" role="tab">期权交易记录</button>
{% elif okx_trade_mode == 'options_options' %}
<button type="button" class="or-tab active" data-source="options_options" role="tab">期期对冲记录</button>
{% elif okx_trade_mode == 'perp_options' %}
<button type="button" class="or-tab active" data-source="perp_options" role="tab">永期对冲记录</button>
{% else %}
<button type="button" class="or-tab active" data-source="option_spot" role="tab">期权交易记录</button>
{% endif %}
</div>
<div class="or-filters">
<select id="or-filter-uly" autocomplete="off">
<option value="">标的:全部</option>
<option value="ETH">ETH</option>
<option value="BTC">BTC</option>
</select>
<select id="or-filter-opt" autocomplete="off">
<option value="">Call/Put:全部</option>
<option value="C">Call</option>
<option value="P">Put</option>
</select>
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<input type="search" id="or-filter-q" name="or_filter_q" placeholder="搜索标的/合约/策略"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other" readonly>
<input type="datetime-local" id="or-filter-from" title="平仓起" autocomplete="off">
<input type="datetime-local" id="or-filter-to" title="平仓止" autocomplete="off">
{% if okx_trade_mode|default('options') == 'options' %}
<label class="muted">
<input type="checkbox" id="or-include-hedge-legs"> 含已归属对冲的期权腿
</label>
{% endif %}
</div>
</div>
{# 1. 交易记录 #}
<section class="or-section" aria-labelledby="or-list-title">
<div class="or-section-head">
<span class="or-step" aria-hidden="true">1</span>
<div>
<div class="or-section-title" id="or-list-title">期权交易记录</div>
<p class="or-section-desc">点「复盘」填写表单;已复盘仍保留在此,也可在下方查看详情。</p>
</div>
</div>
<div class="options-strike-table-wrap or-trades-table-wrap" id="or-trades-wrap">
<table class="options-strike-table or-trades-table" id="or-trades-table">
<thead>
<tr>
<th>类型</th>
<th>标的/合约</th>
<th>盈亏</th>
<th>开仓时间</th>
<th>平仓时间</th>
<th>持有</th>
<th>操作</th>
</tr>
</thead>
<tbody id="or-trades-tbody">
<tr><td colspan="7" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
<div class="or-pager" id="or-trades-pager">
<button type="button" class="btn-secondary" id="or-trades-prev" style="font-size:.72rem;padding:2px 8px">上一页</button>
<span class="muted" id="or-trades-page-label">第 1 / 1 页</span>
<button type="button" class="btn-secondary" id="or-trades-next" style="font-size:.72rem;padding:2px 8px">下一页</button>
</div>
</section>
{# 2. 复盘上传(默认隐藏) #}
<section class="or-section journal-card or-journal-card hidden" id="or-journal-card">
<div class="or-section-head">
<span class="or-step" aria-hidden="true">✎</span>
<div>
<div class="or-section-title">填写复盘</div>
<p class="or-section-desc" id="or-journal-summary">截图槽位 5m / 15m / 1h / 4h,选文件后即时上传。</p>
</div>
</div>
<div class="or-journal-body">
<form id="or-journal-form" onsubmit="return false;">
<input type="hidden" id="or-trade-id" value="">
<input type="hidden" id="or-draft-id" value="">
<div class="or-form-grid">
<input type="datetime-local" id="or-f-open" title="开仓时间" autocomplete="off">
<input type="datetime-local" id="or-f-close" title="平仓时间" autocomplete="off">
<input type="text" id="or-f-coin" name="or_f_coin" placeholder="标的(如 ETH)"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="text" id="or-f-inst" name="or_f_inst" placeholder="合约/计划"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="text" id="or-f-pnl" name="or_f_pnl" placeholder="盈亏(U)"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
<input type="text" id="or-f-hold" name="or_f_hold" placeholder="持有时长" readonly autocomplete="off">
</div>
<div class="or-form-grid2">
<select id="or-f-strategy" title="策略标签" required>
<option value="">策略标签</option>
</select>
<select id="or-f-direction" title="方向判断">
<option value="">方向判断</option>
</select>
<select id="or-f-exit" title="离场原因">
<option value="">离场原因</option>
<option value="止盈">止盈</option>
<option value="止损">止损</option>
<option value="到期">到期</option>
<option value="目标价">目标价</option>
<option value="手动平仓">手动平仓</option>
<option value="其他">其他</option>
</select>
<select id="or-f-followed" title="是否按计划">
<option value="">是否按计划</option>
<option value="是">是</option>
<option value="否">否</option>
<option value="部分">部分</option>
</select>
<select id="or-f-result" title="结果标签">
<option value="">结果标签</option>
<option value="盈利">盈利</option>
<option value="亏损">亏损</option>
<option value="持平">持平</option>
</select>
</div>
<select id="or-f-entry" title="入场逻辑" style="width:100%;margin-bottom:6px;box-sizing:border-box">
<option value="">入场逻辑</option>
</select>
<div class="journal-upload-slots" id="or-upload-slots">
{% for tf in ['5m', '15m', '1h', '4h'] %}
<div class="journal-upload-row" data-tf="{{ tf }}">
<span class="journal-upload-slot-label">{{ tf }}</span>
<input type="file" accept="image/*" class="or-upload-input" data-tf="{{ tf }}">
<input type="hidden" class="or-upload-hidden" data-tf="{{ tf }}" value="">
<span class="journal-upload-status or-upload-status" data-tf="{{ tf }}" aria-live="polite"></span>
</div>
{% endfor %}
</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>
<label><input type="checkbox" class="or-mood" value="盈利飘了">盈利飘了</label>
<label><input type="checkbox" class="or-mood" value="拿不住单">拿不住单</label>
<label><input type="checkbox" class="or-mood" value="扛单">扛单</label>
<label><input type="checkbox" class="or-mood" value="重仓违规">重仓违规</label>
</div>
<textarea id="or-f-note" rows="2" placeholder="备注" style="width:100%;box-sizing:border-box"></textarea>
<div class="form-row" style="margin-top:8px;gap:6px">
<button type="button" class="btn" id="or-save-btn">保存复盘记录</button>
<button type="button" class="btn-secondary" id="or-clear-btn">取消</button>
<button type="button" class="btn-secondary" id="or-del-btn">删除复盘内容</button>
<span class="muted" id="or-save-status"></span>
</div>
<div id="or-legs-host" style="margin-top:10px;font-size:.74rem"></div>
</form>
</div>
</section>
{# 3. 已复盘记录 #}
<section class="or-section" aria-labelledby="or-reviewed-heading">
<div class="or-section-head">
<span class="or-step" aria-hidden="true">2</span>
<div>
<div class="or-section-title" id="or-reviewed-heading">复盘记录</div>
<p class="or-section-desc">已保存的复盘内容,点一行查看详情与截图。</p>
</div>
</div>
<div class="options-strike-table-wrap or-reviewed-table-wrap" id="or-reviewed-wrap">
<table class="options-strike-table or-reviewed-table" id="or-reviewed-table">
<thead>
<tr>
<th>类型</th>
<th>标的/合约</th>
<th>方向</th>
<th>盈亏</th>
<th>开仓时间</th>
<th>平仓时间</th>
<th>持仓时长</th>
<th>策略</th>
<th>入场逻辑</th>
<th>结果</th>
<th>复盘时间</th>
</tr>
</thead>
<tbody id="or-reviewed-tbody">
<tr><td colspan="11" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
<div class="or-pager" id="or-reviewed-pager">
<button type="button" class="btn-secondary" id="or-reviewed-prev" style="font-size:.72rem;padding:2px 8px">上一页</button>
<span class="muted" id="or-reviewed-page-label">第 1 / 1 页</span>
<button type="button" class="btn-secondary" id="or-reviewed-next" style="font-size:.72rem;padding:2px 8px">下一页</button>
</div>
</section>
{# 详情 / 放大 #}
<div id="or-detail-backdrop" class="or-detail-backdrop" hidden>
<div class="or-detail-modal" role="dialog" aria-modal="true" aria-labelledby="or-detail-title" id="or-detail-panel">
<div class="or-detail-modal-head">
<h3 id="or-detail-title">复盘详情</h3>
<button type="button" class="btn-secondary" id="or-detail-edit-btn" style="font-size:.72rem;padding:2px 8px">编辑</button>
<button type="button" class="btn-secondary" id="or-detail-close-btn" style="font-size:.72rem;padding:2px 8px">关闭</button>
</div>
<div class="or-detail-grid" id="or-detail-meta"></div>
<div id="or-detail-text" style="font-size:.76rem;line-height:1.5;margin-bottom:8px"></div>
<div class="or-detail-images" id="or-detail-images"></div>
</div>
</div>
<div id="or-img-lightbox" class="or-img-lightbox" hidden>
<img id="or-img-lightbox-img" src="" alt="截图放大">
</div>
{# 4. 统计 #}
<section class="or-section" aria-labelledby="or-stats-heading">
<div class="or-section-head">
<span class="or-step" aria-hidden="true">3</span>
<div>
<div class="or-section-title" id="or-stats-heading">统计</div>
<p class="or-section-desc">跟随上方 Tab 与筛选条件汇总。</p>
</div>
</div>
<div id="or-kpi" class="or-kpi-row"></div>
<div id="or-stats-groups" class="or-stats-grid"></div>
</section>
</div>
<script src="/static/options_review.js?v=23"></script>
@@ -0,0 +1,3 @@
{# 期权设置脚本挂载点(卡片在 settings_panel 中拆分) #}
<div id="options-settings-root" hidden></div>
<script src="/static/options_settings.js?v=10"></script>
@@ -0,0 +1,16 @@
<div class="options-settings-section">
<p class="options-settings-hint">账户资金账户:USDT ↔ USDC 现货市价单.</p>
<div class="form-row settings-transfer-form options-settings-row">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="opt-set-swap-dir" aria-label="兑换方向" autocomplete="off">
<option value="usdt_to_usdc" selected>USDT → USDC</option>
<option value="usdc_to_usdt">USDC → USDT</option>
</select>
<input type="number" id="opt-set-swap-amount" name="cm_opt_swap_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<button type="button" class="btn-secondary btn-sm" id="opt-set-swap-all-btn">全部兑换</button>
<button type="button" class="btn-primary btn-sm" id="opt-set-swap-btn">市价兑换</button>
</div>
<div id="opt-set-swap-msg" class="options-settings-msg muted"></div>
</div>
@@ -0,0 +1,24 @@
<div class="options-settings-section">
<div class="options-settings-subtitle">账户内划转</div>
<div class="form-row settings-transfer-form options-settings-row">
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="opt-set-int-ccy" aria-label="币种" autocomplete="off">
<option value="USDC" selected>USDC</option>
<option value="USDT">USDT</option>
</select>
<select id="opt-set-int-from" aria-label="划出账户">
<option value="funding" selected>from: 资金</option>
<option value="trading">from: 交易</option>
</select>
<select id="opt-set-int-to" aria-label="划入账户">
<option value="trading" selected>to: 交易</option>
<option value="funding">to: 资金</option>
</select>
<input type="number" id="opt-set-int-amount" name="cm_opt_int_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly>
<button type="button" class="btn-secondary btn-sm" id="opt-set-int-all-btn">全部划转</button>
<button type="button" class="btn-primary btn-sm" id="opt-set-int-btn">划转</button>
</div>
<div id="opt-set-int-msg" class="options-settings-msg muted"></div>
</div>