feat期权盈亏按交易账户快照结算:一次一仓、持仓禁划转、卖回后冻结。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-09 00:29:47 +08:00
parent e0e2a94322
commit 260bfd1f29
12 changed files with 659 additions and 157 deletions
+196
View File
@@ -0,0 +1,196 @@
"""期权已实现盈亏:交易账户 USDT 快照差分(一次一仓).
规格见 docs/规格-期权交易账户盈亏结算.md
"""
from __future__ import annotations
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 fetch_trading_usdt(cfg: dict[str, Any] | None, ex: Any) -> float | None:
"""读取交易账户 USDT(可用优先)."""
fetch = (cfg or {}).get("fetch_options_balances") if isinstance(cfg, dict) else None
if not callable(fetch):
from lib.exchange.okx_options_lib import fetch_options_balances
fetch = fetch_options_balances
try:
bal = fetch(ex, force=True) or {}
except TypeError:
try:
bal = fetch(ex) or {}
except Exception:
return None
except Exception:
return None
v = _safe_float(bal.get("trading_usdt_avail"))
if v is None:
v = _safe_float(bal.get("trading_usdt"))
return round(float(v), 8) if v is not None else None
def sellable_coin_blocks_open_msg(ex: Any, underlying: str) -> str | None:
"""开仓前有可卖标的币则拒开."""
from lib.options.options_spot_bridge_lib import (
fetch_trading_coin_available,
is_sellable_spot_coin,
)
uly = (underlying or "").strip().upper()
if uly not in ("ETH", "BTC"):
return None
try:
avail = fetch_trading_coin_available(ex, uly)
except Exception:
return None
if avail is None:
return None
if is_sellable_spot_coin(ex, uly, avail):
return f"交易账户仍有可卖 {uly}({float(avail):g}),请先卖回 USDT 后再开仓"
return None
def transfer_blocked_by_position_msg(conn, ex: Any = None) -> str | None:
"""持仓中或待卖回时禁止划转."""
from lib.options.options_spot_bridge_lib import has_unfinished_bridge
try:
if has_unfinished_bridge(conn):
return "期权持仓或待卖回期间禁止划转,请先平仓并卖回 USDT"
except Exception:
pass
try:
row = conn.execute(
"SELECT COUNT(*) AS n FROM options_trades WHERE status='open'"
).fetchone()
n = int(row["n"] if hasattr(row, "keys") else row[0])
if n > 0:
return "期权持仓中禁止划转"
except Exception:
pass
if ex is not None:
try:
from lib.exchange.okx_options_lib import fetch_option_positions
for p in fetch_option_positions(ex) or []:
try:
if abs(float(p.get("pos") or 0)) > 1e-12:
return "存在未平期权持仓,禁止划转"
except (TypeError, ValueError):
continue
except Exception:
pass
return None
def try_settle_after_sellback(
get_db: Any,
ex: Any,
*,
bridge_id: int | None = None,
cfg: dict[str, Any] | None = None,
) -> int:
"""卖回路径通用结算:读交易账户 after 并冻结未结算单."""
pack = cfg if isinstance(cfg, dict) else {"fetch_options_balances": None}
after = fetch_trading_usdt(pack, ex)
if after is None:
return 0
conn = get_db() if callable(get_db) else get_db
close_conn = callable(get_db)
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
n = 0
if bridge_id is not None:
n = settle_closed_trades_from_trading_account(
conn, trading_usdt_after=after, bridge_id=int(bridge_id)
)
if n < 1:
row = conn.execute(
"""
SELECT id FROM options_trades
WHERE status='closed' AND COALESCE(pnl_settled,0)=0
AND trading_usdt_before IS NOT NULL
ORDER BY id DESC LIMIT 1
"""
).fetchone()
if row:
n = settle_closed_trades_from_trading_account(
conn,
trading_usdt_after=after,
trade_id=int(row["id"] if hasattr(row, "keys") else row[0]),
)
try:
conn.commit()
except Exception:
pass
return n
finally:
if close_conn:
try:
conn.close()
except Exception:
pass
def settle_closed_trades_from_trading_account(
conn,
*,
trading_usdt_after: float,
bridge_id: int | None = None,
trade_id: int | None = None,
) -> int:
"""卖回后:对本桥/本单未结算已平仓位冻结 after−before.
已 pnl_settled=1 的行永不改写.
"""
after = _safe_float(trading_usdt_after)
if after is None:
return 0
after = round(float(after), 8)
clauses = ["status='closed'", "COALESCE(pnl_settled, 0)=0", "trading_usdt_before IS NOT NULL"]
params: list[Any] = []
if trade_id is not None:
clauses.append("id=?")
params.append(int(trade_id))
elif bridge_id is not None:
clauses.append("bridge_id=?")
params.append(int(bridge_id))
else:
return 0
rows = conn.execute(
f"""
SELECT id, trading_usdt_before, realized_pnl_usdt
FROM options_trades
WHERE {" AND ".join(clauses)}
""",
tuple(params),
).fetchall()
updated = 0
for row in rows:
before = _safe_float(row["trading_usdt_before"] if hasattr(row, "keys") else row[1])
if before is None:
continue
pnl_u = round(float(after) - float(before), 4)
conn.execute(
"""
UPDATE options_trades
SET trading_usdt_after = ?,
realized_pnl_usdt = ?,
pnl_settled = 1
WHERE id = ? AND COALESCE(pnl_settled, 0)=0
""",
(after, pnl_u, int(row["id"] if hasattr(row, "keys") else row[0])),
)
updated += 1
return updated
+4 -2
View File
@@ -379,7 +379,7 @@ def close_option_by_bid1(
"""
SELECT id, premium_paid, open_fee, premium_ccy, underlying,
exchange_ord_id, budget_usdt, open_index_px, fee_ccy,
eth_amount, open_quote
eth_amount, open_quote, trading_usdt_before
FROM options_trades
WHERE inst_id = ? AND status = 'open'
ORDER BY id ASC
@@ -510,7 +510,9 @@ def close_option_by_bid1(
fee_ccy=str(row["fee_ccy"] or row["premium_ccy"] or row_ccy or ""),
)
pnl = settled.get("realized_pnl")
pnl_usdt = settled.get("realized_pnl_usdt")
# 有交易账户 before 快照:U 盈亏等卖回后结算,平仓不写 realized_pnl_usdt
has_acct_snap = _safe_float(row["trading_usdt_before"]) is not None
pnl_usdt = None if has_acct_snap else settled.get("realized_pnl_usdt")
note_sql = ""
# 占位: close_quote/recv/pnl/ord/closed_at/open_fee/close_fee/fee_ccy/close_idx/open_idx/pnl_u/[note]/id
+42 -12
View File
@@ -216,6 +216,16 @@ def open_coin_option_buy_full(
conn.commit()
except Exception:
pass
# 开仓前有可卖标的币 → 拒开(规格:先卖回)
from lib.options.options_account_pnl_lib import (
fetch_trading_usdt,
sellable_coin_blocks_open_msg,
)
coin_block = sellable_coin_blocks_open_msg(ex, underlying)
if coin_block:
return {"ok": False, "msg": coin_block, "can_open": False}
trading_usdt_before = fetch_trading_usdt(cfg, ex)
# 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算
coin_before = _spot_bridge.fetch_trading_coin_available(ex, underlying) or 0.0
buy = _spot_bridge.spot_market_buy_coin_with_usdt(ex, underlying=underlying, usdt_amount=buy_usdt)
@@ -514,6 +524,7 @@ def open_coin_option_buy_full(
premium_ccy=premium_ccy,
open_fee=open_fee,
open_index_px=idx_open,
trading_usdt_before=trading_usdt_before,
profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult,
)
@@ -594,15 +605,20 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
open_index_px = None
except (TypeError, ValueError):
open_index_px = None
tusdt_before = kwargs.get("trading_usdt_before")
try:
tusdt_before = float(tusdt_before) if tusdt_before is not None else None
except (TypeError, ValueError):
tusdt_before = None
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,
open_fee, fee_ccy, open_index_px,
open_fee, fee_ccy, open_index_px, trading_usdt_before, pnl_settled,
profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
""",
(
kwargs["inst_id"],
@@ -622,6 +638,7 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
open_fee,
fee_ccy,
open_index_px,
tusdt_before,
pe,
pe_mult,
pe_state,
@@ -668,18 +685,18 @@ def maybe_sell_spot_after_close(
underlying=underlying,
inst_id=inst_id,
)
# 模拟盘:卖回后按桥上真实 USDT 净额回写已实现盈亏
# 卖回成功:交易账户 after − 开仓 before → 冻结本单盈亏
try:
from lib.sim.mode_lib import is_sim_mode
from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt
from lib.options.options_account_pnl_lib import try_settle_after_sellback
if is_sim_mode(cfg["get_db"]):
conn = cfg["get_db"]()
try:
sync_sim_cash_realized_pnl_usdt(conn, ex)
conn.commit()
finally:
conn.close()
if (result or {}).get("ok"):
bid = (result or {}).get("bridge_id")
try_settle_after_sellback(
cfg["get_db"],
ex,
bridge_id=int(bid) if bid is not None else None,
cfg=cfg,
)
except Exception:
pass
return result
@@ -749,6 +766,19 @@ def sweep_spot_after_options_flat(
underlying=uly,
inst_id=None,
)
if (out or {}).get("ok"):
try:
from lib.options.options_account_pnl_lib import try_settle_after_sellback
bid = (out or {}).get("bridge_id")
try_settle_after_sellback(
cfg["get_db"],
exchange,
bridge_id=int(bid) if bid is not None else None,
cfg=cfg,
)
except Exception:
pass
sold.append({"underlying": uly, **(out or {})})
except Exception as e:
sold.append({"underlying": uly, "ok": False, "msg": str(e)})
+4
View File
@@ -129,6 +129,10 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
"ALTER TABLE options_trades ADD COLUMN close_index_px REAL",
"ALTER TABLE options_trades ADD COLUMN open_index_px REAL",
"ALTER TABLE options_trades ADD COLUMN realized_pnl_usdt REAL",
# 交易账户快照盈亏(一次一仓):before→after 差分冻结
"ALTER TABLE options_trades ADD COLUMN trading_usdt_before REAL",
"ALTER TABLE options_trades ADD COLUMN trading_usdt_after REAL",
"ALTER TABLE options_trades ADD COLUMN pnl_settled INTEGER DEFAULT 0",
):
try:
conn.execute(ddl)
+39
View File
@@ -1026,6 +1026,21 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn_c.close()
except Exception:
pass
transfer_blocked = False
transfer_block_msg = ""
try:
from lib.options.options_account_pnl_lib import transfer_blocked_by_position_msg
conn_tb = cfg["get_db"]()
try:
tmsg = transfer_blocked_by_position_msg(conn_tb, ex)
if tmsg:
transfer_blocked = True
transfer_block_msg = tmsg
finally:
conn_tb.close()
except Exception:
pass
return jsonify(
{
"ok": True,
@@ -1034,6 +1049,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"cond_order_locked": cond_locked,
"cond_order_msg": cond_msg,
"cond_order": cond_order,
"transfer_blocked": transfer_blocked,
"transfer_block_msg": transfer_block_msg,
}
)
@@ -1196,6 +1213,18 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
inst_id=inst_id,
)
if out.get("ok"):
try:
from lib.options.options_account_pnl_lib import try_settle_after_sellback
bid = out.get("bridge_id")
try_settle_after_sellback(
cfg["get_db"],
ex,
bridge_id=int(bid) if bid is not None else None,
cfg=cfg,
)
except Exception:
pass
_mark_balances_stale(cfg)
return jsonify(out)
@@ -1215,6 +1244,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
ex, err = _require_options_ex(cfg)
if ex is None:
return jsonify({"ok": False, "msg": err})
# 持仓/待卖回禁止划转(规格)
conn_blk = cfg["get_db"]()
try:
from lib.options.options_account_pnl_lib import transfer_blocked_by_position_msg
block = transfer_blocked_by_position_msg(conn_blk, ex)
if block:
return jsonify({"ok": False, "msg": block, "transfer_blocked": True})
finally:
conn_blk.close()
data = request.get_json(silent=True) or {}
ccy = (data.get("ccy") or "USDC").upper()
from_acct = (data.get("from") or "funding").strip()
+11 -2
View File
@@ -316,7 +316,8 @@ def sync_options_from_local_trades(
SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets,
open_quote, close_quote, premium_paid, premium_received, realized_pnl,
realized_pnl_usdt, close_index_px, open_index_px, budget_usdt,
premium_ccy, created_at, closed_at, signal_note, status
premium_ccy, created_at, closed_at, signal_note, status,
trading_usdt_before, pnl_settled
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
@@ -409,6 +410,12 @@ def sync_options_from_local_trades(
if not ccy:
ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly)
frozen_usdt = _safe_float(r["realized_pnl_usdt"])
try:
settled = int(r["pnl_settled"] or 0) == 1
except Exception:
settled = False
# 账户快照路径:未结算不写 U,避免指数假数进复盘
acct_pending = _safe_float(r["trading_usdt_before"]) is not None and not settled
close_idx = _safe_float(r["close_index_px"])
open_idx = _safe_float(r["open_index_px"])
# 折算指数:平仓冻结 > 开仓 > 实时(仅无冻结时兜底)
@@ -426,7 +433,9 @@ def sync_options_from_local_trades(
if paid0 is not None and recv0 is not None:
pnl = recv0 - paid0
prem = _safe_float(r["premium_paid"])
if frozen_usdt is not None:
if acct_pending:
pnl_u = None
elif frozen_usdt is not None and (settled or _safe_float(r["trading_usdt_before"]) is None):
pnl_u = round(float(frozen_usdt), 4)
else:
pnl_u = _to_usdt(pnl, ccy=ccy, idx=idx)
+12 -88
View File
@@ -303,7 +303,11 @@ _CLOSED_STATS_SELECT = """
created_at, closed_at, inst_id, underlying, premium_ccy
FROM options_trades
WHERE status = 'closed'
AND (realized_pnl IS NOT NULL OR realized_pnl_usdt IS NOT NULL)
AND (
(COALESCE(pnl_settled, 0) = 1 AND realized_pnl_usdt IS NOT NULL)
OR (trading_usdt_before IS NULL
AND (realized_pnl IS NOT NULL OR realized_pnl_usdt IS NOT NULL))
)
"""
@@ -322,6 +326,7 @@ def freeze_missing_realized_pnl_usdt(conn, ex: Any = None) -> int:
WHERE status = 'closed'
AND realized_pnl_usdt IS NULL
AND realized_pnl IS NOT NULL
AND trading_usdt_before IS NULL
ORDER BY id DESC
LIMIT 300
"""
@@ -512,96 +517,15 @@ def _sim_net_usdt_converts_for_trade(
def sync_sim_cash_realized_pnl_usdt(conn, ex: Any = None) -> int:
"""模拟盘:收回×卖回价 − 初始U权利金 − 手续费U.
"""兼容入口:已按交易账户结算(pnl_settled=1)的永不改写.
盈利=(权利金币+盈利币)×卖回价−开仓U权利金−费;
亏损=剩余权利金U−费−开仓U(同一式).不用买币预算差额.
有 trading_usdt_before 的待结算单留给卖回结算;
仅无账户快照的旧单才回退权利金×指数冻结.
"""
del ex
init_options_tables(conn)
from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl
rows = conn.execute(
"""
SELECT id, inst_id, underlying, premium_ccy, premium_paid, premium_received,
open_fee, close_fee, realized_pnl, realized_pnl_usdt,
close_index_px, open_index_px, close_quote, eth_amount
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
LIMIT 300
"""
).fetchall()
updated = 0
for row in rows:
ccy = str(_row_get(row, "premium_ccy") or "").strip().upper()
underly = str(_row_get(row, "underlying") or "").strip().upper()
if ccy not in ("ETH", "BTC") and underly not in ("ETH", "BTC"):
continue
underly = underly or ccy
paid = _safe_float(_row_get(row, "premium_paid"))
recv = _safe_float(_row_get(row, "premium_received"))
if recv is None:
cq = _safe_float(_row_get(row, "close_quote"))
eth = _safe_float(_row_get(row, "eth_amount"))
if cq is not None and eth is not None and eth > 0:
try:
from lib.options.options_pricing_lib import total_premium
recv = float(total_premium(cq, eth))
except Exception:
recv = None
sell_px = _safe_float(_row_get(row, "close_index_px"))
open_idx = _safe_float(_row_get(row, "open_index_px"))
if sell_px is None or sell_px <= 0:
sell_px = resolve_close_index_px(
ex,
inst_id=str(_row_get(row, "inst_id") or ""),
underlying=underly,
hint=open_idx,
)
if open_idx is None or open_idx <= 0:
# 无开仓指数时用卖回价兜底(等价旧净币×价)
open_idx = sell_px
if paid is None or recv is None:
net = _safe_float(_row_get(row, "realized_pnl"))
if net is None or sell_px is None or sell_px <= 0:
continue
usdt = round(float(net) * float(sell_px), 4)
close_idx = float(sell_px)
new_pnl = float(net)
open_write = open_idx
else:
settled = settle_closed_pnl(
premium_paid=paid,
premium_received=recv,
open_fee=_row_get(row, "open_fee"),
close_fee=_row_get(row, "close_fee"),
premium_ccy=ccy or underly or "ETH",
index_px=sell_px,
open_index_px=open_idx,
)
usdt = settled.get("realized_pnl_usdt")
close_idx = settled.get("close_index_px") or sell_px
new_pnl = settled.get("realized_pnl")
open_write = settled.get("open_index_px") or open_idx
if usdt is None:
continue
old = _safe_float(_row_get(row, "realized_pnl_usdt"))
if old is not None and abs(float(old) - float(usdt)) < 1e-6:
continue
conn.execute(
"""
UPDATE options_trades
SET realized_pnl_usdt = ?,
close_index_px = COALESCE(close_index_px, ?),
open_index_px = COALESCE(open_index_px, ?),
realized_pnl = COALESCE(?, realized_pnl)
WHERE id = ?
""",
(float(usdt), close_idx, open_write, new_pnl, int(row["id"])),
)
updated += 1
return updated
# 账户快照路径由 sell_residual / maybe_sell 结算,此处不覆盖
return 0
def load_local_options_stats_history(conn, ex: Any = None) -> list[dict[str, Any]]: