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
@@ -0,0 +1,205 @@
# 规格:期权盈亏按交易账户结算(一次一仓)
> 状态:已定稿,按本文实现
> 适用范围:币本位期权(模拟盘优先落地;实盘同规则)
> 目标:复盘盈亏、数据统计盈亏与交易账户真实 USDT 变动一致,可对账、可冻结、可商用
---
## 1. 目标与原则
### 1.1 要解决什么
此前单笔盈亏混用多套口径(权利金×指数、卖回−买币预算、实时指数重算),导致:
- 持仓「合计盈亏」与复盘单笔不一致
- 数字随行情跳动
- 后期账本无法审计
### 1.2 原则
| 原则 | 说明 |
|------|------|
| 真源 | **交易账户 USDT 快照差分** 为本单已实现盈亏真源 |
| 一次一仓 | 同一账户同一时间只允许一笔期权持仓 |
| 卖回后结算 | 未卖回标的币前,本单不进入已实现 |
| 冻结 | 结算完成后永不修改 |
| 禁划转 | 持仓期间禁止资金划转(前端灰 + 后端拒) |
| 双账清晰 | 顶栏余额 = 钱包;合计/复盘盈亏 = 冻结历史 +(持仓时)浮盈 |
---
## 2. 产品规则(用户可见)
### 2.1 开仓前
1. **有可卖标的币(ETH/BTC)→ 拒开仓**
- 提示:先卖回 USDT(可走自动扫尾/卖回)。
- 粉尘:低于现货最小可卖量可忽略;达到可卖量一律视为有币并拒开。
2. **交易账户 USDT 快照** `trading_usdt_before`
- 取开仓前(买币前)交易账户 USDT。
- 开仓前若有过多次划转:以**开仓前最后一次划转完成后**的余额为准(即开仓那一刻的余额)。
3. 通过检查后才允许买币并开期权。
### 2.2 持仓中
1. **划转按钮灰色不可点**;API 同步拒绝。
2. **合计盈亏(动态)** =
`Σ已冻结历史盈亏` + `当前持仓浮盈`
3. 本单未卖回结算前:不计入「已冻结历史」;浮盈跟盘更新。
### 2.3 平仓与卖回
1. 期权平仓后触发**自动卖回**残留标的币。
2. **卖回未完成**:本单状态为「待结算」,已实现盈亏不写死。
3. **卖回成功后**:再拍交易账户 USDT 快照 `trading_usdt_after`。
### 2.4 结算与冻结
```
本单已实现盈亏(U) = trading_usdt_after − trading_usdt_before
```
1. 写入本单并标记已冻结。
2. **此后不得因划转、指数变动、复盘同步而修改。**
3. 期权复盘、数据统计的已实现盈亏均读此冻结值。
### 2.5 划转与资金
| 时机 | 行为 |
|------|------|
| 无持仓、无待卖回 | 允许划转;顶栏资金立即刷新 |
| 持仓中或待卖回 | 禁止划转 |
| 划转后 | 只影响下一笔开仓前的 `usdt_before`;**不重算**已冻结历史单 |
---
## 3. 生命周期
```text
[空仓, 无残留币]
│
├─ 有可卖币? ──是──> 拒开仓「请先卖回」
│
├─ 快照 trading_usdt_before
├─ 买币 → 开期权
│
[持仓中] 划转禁用;合计 = 冻结历史 + 浮盈
│
├─ 平仓
│
[待卖回] 已实现未结算;划转仍禁用
│
├─ 自动/重试卖回成功
├─ 快照 trading_usdt_after
├─ realized = after − before → 冻结
│
[已结算] 复盘/统计只读冻结值
```
---
## 4. 数据字段(实现约定)
在 `options_trades` 增加(或等价持久化):
| 字段 | 含义 |
|------|------|
| `trading_usdt_before` | 开仓前交易账户 USDT |
| `trading_usdt_after` | 卖回完成后交易账户 USDT |
| `realized_pnl_usdt` | 冻结已实现 = after − before(结算后写入) |
| `pnl_settled` | 0/1,是否已按账户差分结算冻结 |
说明:
- `budget_usdt` 仍表示本笔买币花费,**不得**当作开仓前账户快照。
- 币数量 `realized_pnl` 可保留供期权历史展示(币),与 U 冻结分离。
- 统计/复盘的 **U 盈亏** 只认 `pnl_settled=1` 时的 `realized_pnl_usdt`。
---
## 5. 模块行为
### 5.1 开仓 `open_coin_option_buy_full`
1. 检查一次一仓 / 未完成桥。
2. `fetch_trading_coin_available`:可卖则拒开。
3. 读取交易账户 USDT → `trading_usdt_before`。
4. 买币、下单、写库(带 before,`pnl_settled=0`)。
### 5.2 划转 `POST /api/options/transfer`
1. 若存在 `options_trades.status='open'` 或桥状态 ∈ {bought, holding, pending_sell} → **403/业务错误**。
2. 前端:持仓或待卖回时划转按钮 `disabled` + 提示「持仓中不可划转」。
### 5.3 卖回结算
在 `sell_residual_after_option_flat`(及 retry-sell)桥关闭成功后:
1. 读取交易账户 USDT → `trading_usdt_after`。
2. 对本桥关联且未结算的已平仓位:
`realized_pnl_usdt = after - before`,`pnl_settled=1`。
3. **停止**用「权利金×指数」或「卖回−预算」覆盖已结算单。
粉尘关闭桥(无法卖出):
- 仍拍 `usdt_after` 并结算(差额含粉尘损失);或
- 记 `pnl_settled` 并附 message「粉尘忽略」——实现取:拍售后余额并冻结,保证可对账。
### 5.4 数据统计
- `net_realized_pnl` / 已平相关:仅 `pnl_settled=1` 的冻结值之和。
- `total_pnl`(持仓页合计)= `net_realized_pnl` + `open_float_pnl`。
- 待结算已平单:不计入已实现,也不重复计入浮盈(浮盈仅当前期权持仓)。
### 5.5 期权复盘
- 同步本地已平:优先读 `realized_pnl_usdt`(且已结算)。
- 未结算:复盘显示「待结算」或暂不导入已实现 U。
- 禁止再用实时指数改写已冻结 U。
### 5.6 期权历史列表
- **仍按币**展示权利金/币盈亏(不强制改成 U)。
- U 口径只用于复盘与数据统计。
---
## 6. 对账关系(商用)
```
Σ 已冻结单笔盈亏 + 当前浮盈 ≈ 合计盈亏展示
Σ 已冻结单笔盈亏 + 期间净划转 ≈ 交易账户相对「首笔 before」的累计变化
```
差异来源应可归因:粉尘、手续费计入方式、卖回滑点(已含在 after−before 内)。
禁止用展示公式回写钱包余额。
---
## 7. 非目标 / 不做
- 不用买币预算 `budget_usdt` 当单笔盈利。
- 不用「净币 × 单一指数」覆盖已结算账户差分。
- 持仓中不因行情重算已冻结历史。
- 不把期权历史列表强制改为 U 展示。
---
## 8. 验收清单
- [ ] 开仓前有可卖 BTC/ETH → 拒开并提示卖回
- [ ] 开仓写入 `trading_usdt_before`
- [ ] 持仓中划转按钮灰 + API 拒绝
- [ ] 平仓未卖回前,统计已实现不含本单
- [ ] 卖回后 `realized = after − before` 并 `pnl_settled=1`
- [ ] 再次同步复盘/统计,冻结值不变
- [ ] 合计盈亏 = 冻结历史 + 浮盈(有持仓时动态)
- [ ] 空仓划转后顶栏资金更新,历史冻结单不变
---
## 9. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-10-09 | 初版定稿:交易账户快照差分 + 一次一仓 + 持仓禁划转 + 卖回后冻结 |
+6
View File
@@ -2598,6 +2598,12 @@
paintPositions(list); paintPositions(list);
paintSpotSellBanner(d, list); paintSpotSellBanner(d, list);
paintCondOrderLock(d); paintCondOrderLock(d);
if (typeof window.OptionsFundsSetTransferBlocked === "function") {
window.OptionsFundsSetTransferBlocked(
!!(d && d.transfer_blocked),
(d && d.transfer_block_msg) || "持仓或待卖回期间不可划转"
);
}
const fromPos = list.reduce(function (targets, p) { const fromPos = list.reduce(function (targets, p) {
if (!p) return targets; if (!p) return targets;
if (p.target_index != null) { if (p.target_index != null) {
+28
View File
@@ -353,4 +353,32 @@
intMsg: "opt-page-int-msg", intMsg: "opt-page-int-msg",
}); });
} }
/** 持仓/待卖回时划转按钮置灰(规格). */
window.OptionsFundsSetTransferBlocked = function (blocked, msg) {
const idsList = [
["opt-page-int-btn", "opt-page-int-all-btn", "opt-page-int-amount", "opt-page-int-msg"],
["opt-set-int-btn", "opt-set-int-all-btn", "opt-set-int-amount", "opt-set-int-msg"],
];
idsList.forEach(function (ids) {
const btn = document.getElementById(ids[0]);
const allBtn = document.getElementById(ids[1]);
const amount = document.getElementById(ids[2]);
const msgEl = document.getElementById(ids[3]);
[btn, allBtn].forEach(function (el) {
if (!el) return;
el.disabled = !!blocked;
el.style.opacity = blocked ? "0.45" : "";
el.title = blocked ? String(msg || "持仓中不可划转") : "";
});
if (amount) amount.disabled = !!blocked;
if (blocked && msgEl && msg) {
msgEl.textContent = String(msg);
msgEl.classList.add("text-warn");
} else if (!blocked && msgEl && /持仓|划转|卖回/.test(String(msgEl.textContent || ""))) {
msgEl.textContent = "";
msgEl.classList.remove("text-warn");
}
});
};
})(); })();
+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, SELECT id, premium_paid, open_fee, premium_ccy, underlying,
exchange_ord_id, budget_usdt, open_index_px, fee_ccy, exchange_ord_id, budget_usdt, open_index_px, fee_ccy,
eth_amount, open_quote eth_amount, open_quote, trading_usdt_before
FROM options_trades FROM options_trades
WHERE inst_id = ? AND status = 'open' WHERE inst_id = ? AND status = 'open'
ORDER BY id ASC 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 ""), fee_ccy=str(row["fee_ccy"] or row["premium_ccy"] or row_ccy or ""),
) )
pnl = settled.get("realized_pnl") 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 = "" note_sql = ""
# 占位: close_quote/recv/pnl/ord/closed_at/open_fee/close_fee/fee_ccy/close_idx/open_idx/pnl_u/[note]/id # 占位: 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() conn.commit()
except Exception: except Exception:
pass 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,不全额兑换预算 # 1) 仅买「权利金×现货缓冲」所需 USDT,不全额兑换预算
coin_before = _spot_bridge.fetch_trading_coin_available(ex, underlying) or 0.0 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) 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, premium_ccy=premium_ccy,
open_fee=open_fee, open_fee=open_fee,
open_index_px=idx_open, open_index_px=idx_open,
trading_usdt_before=trading_usdt_before,
profit_exit_enabled=profit_exit_enabled, profit_exit_enabled=profit_exit_enabled,
profit_exit_mult=profit_exit_mult, profit_exit_mult=profit_exit_mult,
) )
@@ -594,15 +605,20 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
open_index_px = None open_index_px = None
except (TypeError, ValueError): except (TypeError, ValueError):
open_index_px = None 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( cur = conn.execute(
""" """
INSERT INTO options_trades( INSERT INTO options_trades(
inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id, open_quote, premium_paid, status, signal_note, exchange_ord_id,
margin_mode, premium_ccy, bridge_id, budget_usdt, 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 profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
""", """,
( (
kwargs["inst_id"], kwargs["inst_id"],
@@ -622,6 +638,7 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
open_fee, open_fee,
fee_ccy, fee_ccy,
open_index_px, open_index_px,
tusdt_before,
pe, pe,
pe_mult, pe_mult,
pe_state, pe_state,
@@ -668,18 +685,18 @@ def maybe_sell_spot_after_close(
underlying=underlying, underlying=underlying,
inst_id=inst_id, inst_id=inst_id,
) )
# 模拟盘:卖回后按桥上真实 USDT 净额回写已实现盈亏 # 卖回成功:交易账户 after − 开仓 before → 冻结本单盈亏
try: try:
from lib.sim.mode_lib import is_sim_mode from lib.options.options_account_pnl_lib import try_settle_after_sellback
from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt
if is_sim_mode(cfg["get_db"]): if (result or {}).get("ok"):
conn = cfg["get_db"]() bid = (result or {}).get("bridge_id")
try: try_settle_after_sellback(
sync_sim_cash_realized_pnl_usdt(conn, ex) cfg["get_db"],
conn.commit() ex,
finally: bridge_id=int(bid) if bid is not None else None,
conn.close() cfg=cfg,
)
except Exception: except Exception:
pass pass
return result return result
@@ -749,6 +766,19 @@ def sweep_spot_after_options_flat(
underlying=uly, underlying=uly,
inst_id=None, 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 {})}) sold.append({"underlying": uly, **(out or {})})
except Exception as e: except Exception as e:
sold.append({"underlying": uly, "ok": False, "msg": str(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 close_index_px REAL",
"ALTER TABLE options_trades ADD COLUMN open_index_px REAL", "ALTER TABLE options_trades ADD COLUMN open_index_px REAL",
"ALTER TABLE options_trades ADD COLUMN realized_pnl_usdt 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: try:
conn.execute(ddl) conn.execute(ddl)
+39
View File
@@ -1026,6 +1026,21 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn_c.close() conn_c.close()
except Exception: except Exception:
pass 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( return jsonify(
{ {
"ok": True, "ok": True,
@@ -1034,6 +1049,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
"cond_order_locked": cond_locked, "cond_order_locked": cond_locked,
"cond_order_msg": cond_msg, "cond_order_msg": cond_msg,
"cond_order": cond_order, "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, inst_id=inst_id,
) )
if out.get("ok"): 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) _mark_balances_stale(cfg)
return jsonify(out) return jsonify(out)
@@ -1215,6 +1244,16 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
ex, err = _require_options_ex(cfg) ex, err = _require_options_ex(cfg)
if ex is None: if ex is None:
return jsonify({"ok": False, "msg": err}) return jsonify({"ok": False, "msg": err})
# 持仓/待卖回禁止划转(规格)
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 {} data = request.get_json(silent=True) or {}
ccy = (data.get("ccy") or "USDC").upper() ccy = (data.get("ccy") or "USDC").upper()
from_acct = (data.get("from") or "funding").strip() 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, SELECT id, inst_id, underlying, opt_type, strike, exp_time, sheets,
open_quote, close_quote, premium_paid, premium_received, realized_pnl, open_quote, close_quote, premium_paid, premium_received, realized_pnl,
realized_pnl_usdt, close_index_px, open_index_px, budget_usdt, 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 FROM options_trades
WHERE status = 'closed' WHERE status = 'closed'
ORDER BY id DESC ORDER BY id DESC
@@ -409,6 +410,12 @@ def sync_options_from_local_trades(
if not ccy: if not ccy:
ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly) ccy = premium_ccy_for_mode(margin_mode_from_inst_id(inst), underly)
frozen_usdt = _safe_float(r["realized_pnl_usdt"]) 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"]) close_idx = _safe_float(r["close_index_px"])
open_idx = _safe_float(r["open_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: if paid0 is not None and recv0 is not None:
pnl = recv0 - paid0 pnl = recv0 - paid0
prem = _safe_float(r["premium_paid"]) 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) pnl_u = round(float(frozen_usdt), 4)
else: else:
pnl_u = _to_usdt(pnl, ccy=ccy, idx=idx) 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 created_at, closed_at, inst_id, underlying, premium_ccy
FROM options_trades FROM options_trades
WHERE status = 'closed' 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' WHERE status = 'closed'
AND realized_pnl_usdt IS NULL AND realized_pnl_usdt IS NULL
AND realized_pnl IS NOT NULL AND realized_pnl IS NOT NULL
AND trading_usdt_before IS NULL
ORDER BY id DESC ORDER BY id DESC
LIMIT 300 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: def sync_sim_cash_realized_pnl_usdt(conn, ex: Any = None) -> int:
"""模拟盘:收回×卖回价 − 初始U权利金 − 手续费U. """兼容入口:已按交易账户结算(pnl_settled=1)的永不改写.
盈利=(权利金币+盈利币)×卖回价−开仓U权利金−费; 有 trading_usdt_before 的待结算单留给卖回结算;
亏损=剩余权利金U−费−开仓U(同一式).不用买币预算差额. 仅无账户快照的旧单才回退权利金×指数冻结.
""" """
del ex
init_options_tables(conn) init_options_tables(conn)
from lib.options.options_pnl_settle_lib import resolve_close_index_px, settle_closed_pnl # 账户快照路径由 sell_residual / maybe_sell 结算,此处不覆盖
return 0
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
def load_local_options_stats_history(conn, ex: Any = None) -> list[dict[str, Any]]: def load_local_options_stats_history(conn, ex: Any = None) -> list[dict[str, Any]]:
+104
View File
@@ -0,0 +1,104 @@
"""交易账户快照盈亏结算."""
from __future__ import annotations
import sqlite3
import unittest
class AccountPnlSettleTests(unittest.TestCase):
def test_settle_after_minus_before(self):
from lib.options.options_account_pnl_lib import (
settle_closed_trades_from_trading_account,
)
from lib.options.options_db import init_options_tables
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_options_tables(conn)
conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, sheets, eth_amount,
premium_paid, status, bridge_id, trading_usdt_before, pnl_settled,
created_at, closed_at
) VALUES (
'BTC-USD-261008-86500-P', 'BTC', 'P', 1, 0.01,
0.01, 'closed', 7, 1000.0, 0,
'2026-10-05 05:11:30', '2026-10-07 10:03:53'
)
"""
)
conn.commit()
n = settle_closed_trades_from_trading_account(
conn, trading_usdt_after=1947.05, bridge_id=7
)
self.assertEqual(n, 1)
row = conn.execute(
"SELECT trading_usdt_after, realized_pnl_usdt, pnl_settled FROM options_trades WHERE id=1"
).fetchone()
self.assertAlmostEqual(float(row["trading_usdt_after"]), 1947.05, places=2)
self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 947.05, places=2)
self.assertEqual(int(row["pnl_settled"]), 1)
# 已冻结不再改
n2 = settle_closed_trades_from_trading_account(
conn, trading_usdt_after=9999.0, bridge_id=7
)
self.assertEqual(n2, 0)
row2 = conn.execute(
"SELECT realized_pnl_usdt FROM options_trades WHERE id=1"
).fetchone()
self.assertAlmostEqual(float(row2["realized_pnl_usdt"]), 947.05, places=2)
conn.close()
def test_stats_only_count_settled_account_trades(self):
from lib.options.options_db import init_options_tables
from lib.options.options_stats_lib import (
compute_options_stats_from_history,
history_pnl_to_usdt,
load_local_options_stats_history,
)
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_options_tables(conn)
# 待结算:不进统计
conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, sheets, eth_amount,
premium_paid, premium_received, realized_pnl, premium_ccy,
status, trading_usdt_before, pnl_settled, created_at, closed_at
) VALUES (
'ETH-USD-261009-2500-P', 'ETH', 'P', 1, 0.01,
0.01, 0.02, 0.01, 'ETH',
'closed', 500.0, 0, '2026-10-06 10:00:00', '2026-10-08 14:00:00'
)
"""
)
# 已结算
conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, sheets, eth_amount,
premium_paid, premium_received, realized_pnl, premium_ccy,
status, trading_usdt_before, trading_usdt_after,
realized_pnl_usdt, pnl_settled, created_at, closed_at
) VALUES (
'ETH-USD-261009-2500-P', 'ETH', 'P', 1, 0.01,
0.01, 0.02, 0.01, 'ETH',
'closed', 1000.0, 1952.96,
952.96, 1, '2026-10-01 10:00:00', '2026-10-02 14:00:00'
)
"""
)
conn.commit()
hist = load_local_options_stats_history(conn, None)
stats = compute_options_stats_from_history(history_pnl_to_usdt(hist, None))
self.assertEqual(int(stats["total_closed"]), 1)
self.assertAlmostEqual(float(stats["net_realized_pnl"]), 952.96, places=2)
conn.close()
if __name__ == "__main__":
unittest.main()
+8 -53
View File
@@ -104,83 +104,38 @@ class SettleClosedPnlTests(unittest.TestCase):
class SimPremiumPnlStatsTests(unittest.TestCase): class SimPremiumPnlStatsTests(unittest.TestCase):
def test_sim_uses_premium_net_not_bridge_cash(self): def test_sync_sim_no_longer_overwrites_account_settled(self):
"""卖回−预算会把权利金本金算进盈利;必须用(收回−已付)×平仓指数.""" """账户结算后 sync 兼容入口不得改写."""
import sqlite3 import sqlite3
from lib.options.options_db import init_options_tables from lib.options.options_db import init_options_tables
from lib.options.options_spot_bridge_lib import ensure_bridge_table
from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt
conn = sqlite3.connect(":memory:") conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
init_options_tables(conn) init_options_tables(conn)
ensure_bridge_table(conn)
# 桥上现金差额 952.96,但权利金净盈亏仅 0.01 ETH × 2500 = 25U
conn.execute(
"""
INSERT INTO options_spot_bridge(
id, underlying, status, budget_usdt, usdt_recovered
) VALUES (1, 'ETH', 'closed', 1000.0, 1952.96)
"""
)
conn.execute( conn.execute(
""" """
INSERT INTO options_trades( INSERT INTO options_trades(
inst_id, underlying, opt_type, sheets, eth_amount, inst_id, underlying, opt_type, sheets, eth_amount,
premium_paid, premium_received, realized_pnl, premium_ccy, premium_paid, premium_received, realized_pnl, premium_ccy,
status, bridge_id, budget_usdt, realized_pnl_usdt, close_index_px, status, trading_usdt_before, trading_usdt_after,
created_at, closed_at realized_pnl_usdt, pnl_settled, created_at, closed_at
) VALUES ( ) VALUES (
'ETH-USD-261009-2500-P', 'ETH', 'P', 1, 0.01, 'ETH-USD-261009-2500-P', 'ETH', 'P', 1, 0.01,
0.01, 0.02, 0.01, 'ETH', 0.01, 0.02, 0.01, 'ETH',
'closed', 1, 1000.0, 1947.05, 2500.0, 'closed', 1000.0, 1952.96,
'2026-10-06 10:00:00', '2026-10-08 14:00:00' 952.96, 1, '2026-10-06 10:00:00', '2026-10-08 14:00:00'
) )
""" """
) )
conn.commit() conn.commit()
n = sync_sim_cash_realized_pnl_usdt(conn, None) n = sync_sim_cash_realized_pnl_usdt(conn, None)
self.assertEqual(n, 1) self.assertEqual(n, 0)
row = conn.execute(
"SELECT realized_pnl_usdt, realized_pnl FROM options_trades WHERE id=1"
).fetchone()
# (0.02-0.01)*2500 = 25, 绝不是 1952.96-1000
self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 25.0, places=2)
self.assertAlmostEqual(float(row["realized_pnl"]), 0.01, places=8)
conn.close()
def test_sim_deducts_fees_from_premium_pnl(self):
import sqlite3
from lib.options.options_db import init_options_tables
from lib.options.options_stats_lib import sync_sim_cash_realized_pnl_usdt
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_options_tables(conn)
conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, sheets, eth_amount,
premium_paid, premium_received, realized_pnl, premium_ccy,
open_fee, close_fee, status, realized_pnl_usdt, close_index_px,
created_at, closed_at
) VALUES (
'BTC-USD-261008-86500-P', 'BTC', 'P', 1, 0.01,
0.01, 0.02, 0.01, 'BTC',
0.0001, 0.0001, 'closed', 1947.05, 80000.0,
'2026-10-05 05:11:30', '2026-10-07 10:03:53'
)
"""
)
conn.commit()
sync_sim_cash_realized_pnl_usdt(conn, None)
row = conn.execute( row = conn.execute(
"SELECT realized_pnl_usdt FROM options_trades WHERE id=1" "SELECT realized_pnl_usdt FROM options_trades WHERE id=1"
).fetchone() ).fetchone()
# (0.02-0.01-0.0002)*80000 = 784 self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 952.96, places=2)
self.assertAlmostEqual(float(row["realized_pnl_usdt"]), 784.0, places=2)
conn.close() conn.close()