Use exchange fees, UPL, funding for LIVE PnL; USDC 1:1 to USDT.

Option open P&L stays local (option net); exit target ignores estimated close fees.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 21:49:20 +08:00
parent b7e055e37b
commit ca66c494e7
12 changed files with 685 additions and 25 deletions
+25 -10
View File
@@ -22,15 +22,23 @@ async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict:
groups = []
for r in rows:
g = _row(r)
if g.get("status") == "closed":
fills = db.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC",
(g["group_id"],),
)
summary = summarize_fills_pnl(fills)
g["pnl_summary"] = summary
if summary.get("net_pnl") is not None:
g["net_pnl"] = summary["net_pnl"]
fills = db.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC",
(g["group_id"],),
)
summary = summarize_fills_pnl(fills)
# LIVE:优先 groups.realized_pnl(已按交易所回写,含资金费)
if str(g.get("exec_mode") or "").upper() == "LIVE" and g.get("realized_pnl") is not None:
summary = dict(summary)
summary["net_pnl"] = float(g["realized_pnl"])
if g.get("funding_usdt") is not None:
summary["funding_usdt"] = float(g["funding_usdt"])
summary["pnl_source"] = "live_exchange"
g["pnl_summary"] = summary
if summary.get("net_pnl") is not None:
g["net_pnl"] = summary["net_pnl"]
elif g.get("realized_pnl") is not None:
g["net_pnl"] = float(g["realized_pnl"])
groups.append(g)
return {"groups": groups}
@@ -47,8 +55,15 @@ async def group_detail(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
)
summary = summarize_fills_pnl(fills)
gr = _row(g)
if str(gr.get("exec_mode") or "").upper() == "LIVE" and gr.get("realized_pnl") is not None:
summary = dict(summary)
summary["net_pnl"] = float(gr["realized_pnl"])
if gr.get("funding_usdt") is not None:
summary["funding_usdt"] = float(gr["funding_usdt"])
summary["pnl_source"] = "live_exchange"
return {
"group": _row(g),
"group": gr,
"fills": [_row(x) for x in fills],
"pnl_summary": summary,
}
+58 -1
View File
@@ -32,6 +32,41 @@ class BinanceLiveExecutor(Matcher):
return reason
return None
def unrealized(self) -> dict:
base = super().unrealized()
if not base.get("has_position"):
return base
from .live_pnl import enrich_live_unrealized
s = get_settings()
gid = base.get("group_id")
open_at = None
if gid:
g = self.db.fetchone(
"SELECT open_at_ms, perp_inst_id FROM groups WHERE group_id=?",
(gid,),
)
if g:
open_at = int(g["open_at_ms"] or 0) or None
perp_inst = str(g["perp_inst_id"] or s.perp_inst_id)
else:
perp_inst = s.perp_inst_id
else:
perp_inst = s.perp_inst_id
try:
client = self._client()
except Exception:
return base
return enrich_live_unrealized(
base=base,
db=self.db,
client=client,
exchange="binance",
perp_inst_id=perp_inst,
perp_side=str(base.get("perp_side") or ""),
open_at_ms=open_at,
)
def open_group(
self,
*,
@@ -747,10 +782,32 @@ class BinanceLiveExecutor(Matcher):
)
self.db._conn.commit()
from .live_pnl import reconcile_closed_group_pnl
g2 = self.db.fetchone(
"SELECT open_at_ms, perp_inst_id FROM groups WHERE group_id=?",
(group_id,),
)
net = reconcile_closed_group_pnl(
db=self.db,
client=self._client(),
exchange="binance",
group_id=group_id,
perp_inst_id=str((g2["perp_inst_id"] if g2 else None) or s.perp_inst_id),
open_at_ms=int(g2["open_at_ms"]) if g2 and g2["open_at_ms"] else None,
local_net=float(net) if net is not None else None,
)
return CloseResult(
ok=True,
detail="closed_live_binance",
data={"group_id": group_id, "reason": reason, "net_pnl": net, "exec_mode": "LIVE"},
data={
"group_id": group_id,
"reason": reason,
"net_pnl": net,
"exec_mode": "LIVE",
"pnl_source": "live_exchange",
},
)
def close_perp_abandon_option(
+150 -7
View File
@@ -163,16 +163,18 @@ class BinanceTradeClient:
data = q
if not avg or avg <= 0:
raise RuntimeError(f"币安永续无成交均价: {data}")
# 手续费:优先 cumCommission;否则用名义×费率估
from .money import abs_fee_usdt
fee = abs(safe_float(data.get("cumCommission")) or 0.0)
if fee <= 0:
fee = float(avg) * float(sz or 0) * float(self.settings.fee_rate)
fee_asset = str(data.get("commissionAsset") or "USDT")
if fee <= 0 and ord_id:
fee, fee_asset = self.sum_perp_trade_fees(symbol, ord_id)
return LiveFill(
inst_id=symbol,
side=str(data.get("side") or "").lower(),
avg_px=float(avg),
sz=float(sz or 0),
fee=float(fee),
fee=abs_fee_usdt(fee, fee_asset),
ord_id=ord_id,
raw=data if isinstance(data, dict) else {},
)
@@ -223,15 +225,156 @@ class BinanceTradeClient:
raise RuntimeError(f"币安期权订单失败 status={st} {q}")
if not avg or avg <= 0:
raise RuntimeError(f"币安期权无成交均价: {data}")
from .money import abs_fee_usdt
fee = abs(safe_float(data.get("fee")) or 0.0)
if fee <= 0:
fee = float(avg) * float(sz or 0) * float(self.settings.fee_rate)
fee_asset = "USDT"
if fee <= 0 and ord_id:
fee, fee_asset = self.sum_option_trade_fees(symbol, ord_id)
return LiveFill(
inst_id=symbol,
side=str(data.get("side") or "").lower(),
avg_px=float(avg),
sz=float(sz or 0),
fee=float(fee),
fee=abs_fee_usdt(fee, fee_asset),
ord_id=ord_id,
raw=data if isinstance(data, dict) else {},
)
def sum_perp_trade_fees(self, symbol: str, order_id: str) -> tuple[float, str]:
try:
rows = self._signed(
self._fapi,
"GET",
"/fapi/v1/userTrades",
{"symbol": symbol, "orderId": order_id},
)
except Exception as e:
logger.warning("binance perp userTrades fee failed: %s", e)
return 0.0, "USDT"
if not isinstance(rows, list):
rows = [rows] if isinstance(rows, dict) else []
total = 0.0
asset = "USDT"
for row in rows:
total += abs(safe_float(row.get("commission")) or 0.0)
if row.get("commissionAsset"):
asset = str(row.get("commissionAsset"))
return total, asset
def sum_option_trade_fees(self, symbol: str, order_id: str) -> tuple[float, str]:
try:
rows = self._signed(
self._eapi,
"GET",
"/eapi/v1/userTrades",
{"symbol": symbol, "orderId": order_id},
)
except Exception as e:
logger.warning("binance option userTrades fee failed: %s", e)
return 0.0, "USDT"
if not isinstance(rows, list):
rows = [rows] if isinstance(rows, dict) else []
total = 0.0
asset = "USDT"
for row in rows:
total += abs(safe_float(row.get("commission")) or safe_float(row.get("fee")) or 0.0)
if row.get("commissionAsset") or row.get("feeAsset"):
asset = str(row.get("commissionAsset") or row.get("feeAsset"))
return total, asset
def get_perp_upl_usdt(self, symbol: str, *, position_side: str | None = None) -> float | None:
from .money import to_usdt
try:
rows = self._signed(
self._fapi, "GET", "/fapi/v2/positionRisk", {"symbol": symbol}
)
except Exception as e:
logger.warning("binance positionRisk failed: %s", e)
return None
if isinstance(rows, dict):
rows = [rows]
want = (position_side or "").strip().upper()
for row in rows:
amt = safe_float(row.get("positionAmt")) or 0.0
if abs(amt) < 1e-12:
continue
ps = str(row.get("positionSide") or "").upper()
if want and ps and ps not in ("BOTH",) and ps != want:
continue
upl = safe_float(row.get("unRealizedProfit"))
if upl is None:
continue
return to_usdt(float(upl), "USDT")
return 0.0
def get_funding_usdt(
self, symbol: str, *, begin_ms: int, end_ms: int | None = None
) -> float:
from .money import to_usdt
end = int(end_ms or int(time.time() * 1000))
try:
rows = self._signed(
self._fapi,
"GET",
"/fapi/v1/income",
{
"symbol": symbol,
"incomeType": "FUNDING_FEE",
"startTime": int(begin_ms),
"endTime": end,
"limit": 1000,
},
)
except Exception as e:
logger.warning("binance funding income failed: %s", e)
return 0.0
if isinstance(rows, dict):
rows = [rows]
total = 0.0
for row in rows:
raw = safe_float(row.get("income"))
if raw is None:
continue
asset = str(row.get("asset") or "USDT")
total += to_usdt(float(raw), asset)
return total
def get_closed_perp_pnl_usdt(
self, symbol: str, *, begin_ms: int, end_ms: int | None = None
) -> float | None:
"""用 REALIZED_PNL income 近似已实现(含部分平仓);资金费另计。"""
from .money import to_usdt
end = int(end_ms or int(time.time() * 1000))
try:
rows = self._signed(
self._fapi,
"GET",
"/fapi/v1/income",
{
"symbol": symbol,
"incomeType": "REALIZED_PNL",
"startTime": int(begin_ms),
"endTime": end,
"limit": 1000,
},
)
except Exception as e:
logger.warning("binance realized income failed: %s", e)
return None
if isinstance(rows, dict):
rows = [rows]
if not rows:
return None
total = 0.0
for row in rows:
raw = safe_float(row.get("income"))
if raw is None:
continue
asset = str(row.get("asset") or "USDT")
total += to_usdt(float(raw), asset)
return total
+58 -1
View File
@@ -36,6 +36,41 @@ class OkxLiveExecutor(Matcher):
return reason
return None
def unrealized(self) -> dict:
base = super().unrealized()
if not base.get("has_position"):
return base
from .live_pnl import enrich_live_unrealized
s = get_settings()
gid = base.get("group_id")
open_at = None
if gid:
g = self.db.fetchone(
"SELECT open_at_ms, perp_inst_id FROM groups WHERE group_id=?",
(gid,),
)
if g:
open_at = int(g["open_at_ms"] or 0) or None
perp_inst = str(g["perp_inst_id"] or s.perp_inst_id)
else:
perp_inst = s.perp_inst_id
else:
perp_inst = s.perp_inst_id
try:
client = self._client()
except Exception:
return base
return enrich_live_unrealized(
base=base,
db=self.db,
client=client,
exchange="okx",
perp_inst_id=perp_inst,
perp_side=str(base.get("perp_side") or ""),
open_at_ms=open_at,
)
def open_group(
self,
*,
@@ -761,10 +796,32 @@ class OkxLiveExecutor(Matcher):
)
self.db._conn.commit()
from .live_pnl import reconcile_closed_group_pnl
g2 = self.db.fetchone(
"SELECT open_at_ms, perp_inst_id FROM groups WHERE group_id=?",
(group_id,),
)
net = reconcile_closed_group_pnl(
db=self.db,
client=self._client(),
exchange="okx",
group_id=group_id,
perp_inst_id=str((g2["perp_inst_id"] if g2 else None) or s.perp_inst_id),
open_at_ms=int(g2["open_at_ms"]) if g2 and g2["open_at_ms"] else None,
local_net=float(net) if net is not None else None,
)
return CloseResult(
ok=True,
detail="closed_live",
data={"group_id": group_id, "reason": reason, "net_pnl": net, "exec_mode": "LIVE"},
data={
"group_id": group_id,
"reason": reason,
"net_pnl": net,
"exec_mode": "LIVE",
"pnl_source": "live_exchange",
},
)
def close_perp_abandon_option(
+151
View File
@@ -0,0 +1,151 @@
"""LIVE 组净盈亏:交易所永续 UPL/资金费 + 本地期权净盈亏 − 真实手续费。"""
from __future__ import annotations
import logging
import time
from typing import Any, Protocol
logger = logging.getLogger(__name__)
class _FeeFundingClient(Protocol):
def get_perp_upl_usdt(self, *args: Any, **kwargs: Any) -> float | None: ...
def get_funding_usdt(self, *args: Any, **kwargs: Any) -> float: ...
def get_closed_perp_pnl_usdt(self, *args: Any, **kwargs: Any) -> float | None: ...
def group_paid_fees_usdt(db: Any, group_id: str) -> float:
rows = db.fetchall(
"SELECT fee FROM fills WHERE group_id=?",
(group_id,),
)
return sum(abs(float(r["fee"] or 0)) for r in rows)
def enrich_live_unrealized(
*,
base: dict[str, Any],
db: Any,
client: Any,
exchange: str,
perp_inst_id: str,
perp_side: str,
open_at_ms: int | None,
) -> dict[str, Any]:
"""
在 Matcher.unrealized 结果上覆盖 LIVE 口径:
net = 永续交易所UPL + 期权净盈亏(本地) − 已付手续费 + 资金费(signed)
不再扣预估平仓费。
"""
if not base.get("has_position"):
return base
group_id = str(base.get("group_id") or "")
fees_paid = group_paid_fees_usdt(db, group_id) if group_id else 0.0
begin = int(open_at_ms or 0)
funding = 0.0
perp_upl = float(base.get("perp_upl") or 0.0)
ex = (exchange or "").lower()
try:
if ex == "binance":
side = "LONG" if perp_side == "long" else "SHORT"
upl = client.get_perp_upl_usdt(perp_inst_id, position_side=side)
if upl is not None:
perp_upl = float(upl)
if begin > 0:
funding = float(
client.get_funding_usdt(perp_inst_id, begin_ms=begin) or 0.0
)
else:
upl = client.get_perp_upl_usdt(perp_inst_id, pos_side=perp_side)
if upl is not None:
perp_upl = float(upl)
if begin > 0:
funding = float(
client.get_funding_usdt(perp_inst_id, begin_ms=begin) or 0.0
)
except Exception as e:
logger.warning("live unrealized exchange overlay failed: %s", e)
option_upl = float(base.get("option_upl") or 0.0) # 期权净盈亏(本地)
# 资金费 signed:付出为负,直接加总
net_pnl = perp_upl + option_upl - fees_paid + funding
out = dict(base)
out["perp_upl"] = perp_upl
out["option_upl"] = option_upl
out["fees_paid"] = fees_paid
out["funding_usdt"] = funding
out["est_close_fees"] = 0.0 # LIVE 不估平仓费
out["net_pnl"] = net_pnl
out["pnl_source"] = "live_exchange"
return out
def reconcile_closed_group_pnl(
*,
db: Any,
client: Any,
exchange: str,
group_id: str,
perp_inst_id: str,
open_at_ms: int | None,
local_net: float | None,
) -> float:
"""
平仓后回写:净盈亏优先用 交易所永续已实现 + 本地期权腿盈亏 − 手续费 + 资金费。
失败则退回 local_net。
"""
from ..sim.pnl import summarize_fills_pnl
fills = db.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
)
summary = summarize_fills_pnl(list(fills))
fees = float(summary.get("fees_total") or 0.0)
option_pnl = summary.get("option_pnl")
local_perp = summary.get("perp_pnl")
begin = int(open_at_ms or 0)
end = int(time.time() * 1000)
funding = 0.0
exch_perp: float | None = None
ex = (exchange or "").lower()
try:
if begin > 0:
funding = float(client.get_funding_usdt(perp_inst_id, begin_ms=begin, end_ms=end) or 0.0)
exch_perp = client.get_closed_perp_pnl_usdt(
perp_inst_id, begin_ms=begin, end_ms=end
)
except Exception as e:
logger.warning("reconcile exchange pnl failed: %s", e)
perp_pnl = float(exch_perp) if exch_perp is not None else (
float(local_perp) if local_perp is not None else 0.0
)
opt = float(option_pnl) if option_pnl is not None else 0.0
net = perp_pnl + opt - fees + funding
if local_net is not None and exch_perp is None and abs(funding) < 1e-12:
# 交易所永续已实现拉不到且无资金费 → 保持本地
net = float(local_net)
with db._lock:
try:
db._conn.execute(
"UPDATE groups SET realized_pnl=?, funding_usdt=?, fees=? WHERE group_id=?",
(float(net), float(funding), float(fees), group_id),
)
db._conn.commit()
except Exception:
# funding_usdt 列未迁移时降级
db._conn.execute(
"UPDATE groups SET realized_pnl=?, fees=? WHERE group_id=?",
(float(net), float(fees), group_id),
)
db._conn.commit()
return float(net)
+20
View File
@@ -0,0 +1,20 @@
"""实盘金额口径:统一折 USDT(USDC 等 1:1)。"""
from __future__ import annotations
def to_usdt(amount: float, ccy: str | None = None) -> float:
"""按约定将币种金额折成 USDTUSDC/USD/USDT 一律 1:1。"""
a = float(amount or 0.0)
if a == 0.0:
return 0.0
c = (ccy or "USDT").strip().upper()
if c in ("USDT", "USDC", "USD", ""):
return a
# 其他币种暂按面值记(极少见);后续可扩汇率
return a
def abs_fee_usdt(fee: float, ccy: str | None = None) -> float:
"""手续费记为正成本(USDT)。"""
return abs(to_usdt(fee, ccy))
+117 -2
View File
@@ -169,14 +169,20 @@ class OkxTradeClient:
state = str(last.get("state") or "")
avg = safe_float(last.get("avgPx"))
if state in ("filled", "partially_filled") and avg and avg > 0:
fee = abs(safe_float(last.get("fee")) or 0.0)
sz = safe_float(last.get("accFillSz")) or safe_float(last.get("sz")) or 0.0
fee = abs(safe_float(last.get("fee")) or 0.0)
fee_ccy = str(last.get("feeCcy") or "USDT")
# 订单上 fee 常为空,再查成交明细
if fee <= 0 and ord_id:
fee, fee_ccy = self.sum_fill_fees(inst_id, ord_id)
from .money import abs_fee_usdt
return LiveFill(
inst_id=inst_id,
side=str(last.get("side") or ""),
avg_px=float(avg),
sz=float(sz),
fee=float(fee),
fee=abs_fee_usdt(fee, fee_ccy),
ord_id=ord_id,
raw=last,
)
@@ -184,3 +190,112 @@ class OkxTradeClient:
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
time.sleep(0.25)
raise RuntimeError(f"OKX 订单未成交 ordId={ord_id} last={last}")
def sum_fill_fees(self, inst_id: str, ord_id: str) -> tuple[float, str]:
"""成交明细手续费合计(原币种金额, 币种)。"""
path = f"/api/v5/trade/fills?instId={inst_id}&ordId={ord_id}"
try:
rows = self._request("GET", path)
except Exception as e:
logger.warning("okx fills fee query failed: %s", e)
return 0.0, "USDT"
total = 0.0
ccy = "USDT"
for row in rows:
f = abs(safe_float(row.get("fee")) or 0.0)
total += f
if row.get("feeCcy"):
ccy = str(row.get("feeCcy"))
return total, ccy
def get_perp_upl_usdt(self, inst_id: str, *, pos_side: str | None = None) -> float | None:
"""当前永续未实现盈亏(USDT,1:1)。"""
from .money import to_usdt
try:
rows = self._request(
"GET", f"/api/v5/account/positions?instId={inst_id}"
)
except Exception as e:
logger.warning("okx positions failed: %s", e)
return None
want = (pos_side or "").strip().lower()
for row in rows:
ps = str(row.get("posSide") or "").lower()
pos = safe_float(row.get("pos")) or 0.0
if abs(pos) < 1e-12:
continue
if want and want not in ("net", "") and ps and ps != want and ps != "net":
continue
upl = safe_float(row.get("upl"))
if upl is None:
continue
ccy = str(row.get("ccy") or row.get("settleCcy") or "USDT")
return to_usdt(float(upl), ccy)
return 0.0
def get_funding_usdt(
self, inst_id: str, *, begin_ms: int, end_ms: int | None = None
) -> float:
"""资金费合计(已计入账户的 signed 金额,USDT 1:1)。付费为负。"""
from .money import to_usdt
end = int(end_ms or int(time.time() * 1000))
# type=8 funding fee
path = (
f"/api/v5/account/bills?instType=SWAP&instId={inst_id}"
f"&type=8&begin={int(begin_ms)}&end={end}"
)
total = 0.0
try:
rows = self._request("GET", path)
except Exception as e:
logger.warning("okx funding bills failed: %s", e)
return 0.0
for row in rows:
# balChg / pnl 视接口;资金费常用 pnl 或 balChg
raw = safe_float(row.get("pnl"))
if raw is None:
raw = safe_float(row.get("balChg"))
if raw is None:
continue
ccy = str(row.get("ccy") or "USDT")
total += to_usdt(float(raw), ccy)
return total
def get_closed_perp_pnl_usdt(
self, inst_id: str, *, begin_ms: int, end_ms: int | None = None
) -> float | None:
"""平仓后从历史仓位取已实现盈亏(不含手续费;含部分仓位盈亏)。"""
from .money import to_usdt
end = int(end_ms or int(time.time() * 1000))
path = (
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}"
f"&before={end}&after={int(begin_ms)}"
)
try:
# positions-history 用 GET query;部分环境用 before/after 语义相反,失败则返回 None
rows = self._request(
"GET",
f"/api/v5/account/positions-history?instType=SWAP&instId={inst_id}",
)
except Exception as e:
logger.warning("okx positions-history failed: %s", e)
return None
total = 0.0
hit = False
for row in rows:
u_time = int(safe_float(row.get("uTime")) or safe_float(row.get("cTime")) or 0)
if u_time and (u_time < int(begin_ms) - 60_000 or u_time > end + 60_000):
continue
rpnl = safe_float(row.get("realizedPnl"))
if rpnl is None:
rpnl = safe_float(row.get("pnl"))
if rpnl is None:
continue
hit = True
ccy = str(row.get("ccy") or "USDT")
total += to_usdt(float(rpnl), ccy)
return total if hit else None
+4 -1
View File
@@ -41,7 +41,8 @@ CREATE TABLE IF NOT EXISTS groups (
fees REAL DEFAULT 0,
slip_cost REAL DEFAULT 0,
note TEXT,
exec_mode TEXT
exec_mode TEXT,
funding_usdt REAL
);
CREATE TABLE IF NOT EXISTS fills (
@@ -150,7 +151,9 @@ class Database:
with self._lock:
for table, col, decl in (
("groups", "exec_mode", "TEXT"),
("groups", "funding_usdt", "REAL"),
("fills", "exec_mode", "TEXT"),
("fills", "fee_ccy", "TEXT"),
):
cols = {
str(r[1])
+79
View File
@@ -0,0 +1,79 @@
"""LIVE 金额与组净盈亏口径。"""
from __future__ import annotations
from app.live.live_pnl import enrich_live_unrealized, group_paid_fees_usdt
from app.live.money import abs_fee_usdt, to_usdt
def test_to_usdt_one_to_one() -> None:
assert to_usdt(12.5, "USDC") == 12.5
assert to_usdt(-3.0, "USDT") == -3.0
assert abs_fee_usdt(-0.2, "USDC") == 0.2
def test_group_paid_fees(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("MODE", "SIM")
from app.models.db import Database
db = Database(tmp_path / "f.db")
with db._lock:
db._conn.execute(
"""INSERT INTO groups(group_id, status, open_at_ms, fees)
VALUES ('G1','open',1,0)"""
)
db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth,
fill_px, fee, slip, notional, ts_ms)
VALUES ('G1','option','open','long','OPT',2,10,0.5,0,20,1)"""
)
db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth,
fill_px, fee, slip, notional, ts_ms)
VALUES ('G1','perp','open','short','SWAP',1,100,-0.3,0,100,2)"""
)
db._conn.commit()
assert group_paid_fees_usdt(db, "G1") == 0.8
db.close()
class _FakeClient:
def get_perp_upl_usdt(self, *a, **k):
return 8.0
def get_funding_usdt(self, *a, **k):
return -1.5
def test_enrich_live_unrealized_no_est_close_fee() -> None:
base = {
"has_position": True,
"group_id": "G1",
"perp_side": "short",
"perp_upl": 1.0,
"option_upl": 5.0,
"est_close_fees": 9.9,
"net_pnl": -3.9,
}
class _Db:
def fetchall(self, *a, **k):
return [{"fee": 0.4}, {"fee": 0.1}]
out = enrich_live_unrealized(
base=base,
db=_Db(),
client=_FakeClient(),
exchange="okx",
perp_inst_id="ETH-USDT-SWAP",
perp_side="short",
open_at_ms=1,
)
assert out["perp_upl"] == 8.0
assert out["option_upl"] == 5.0
assert out["fees_paid"] == 0.5
assert out["funding_usdt"] == -1.5
assert out["est_close_fees"] == 0.0
# 8 + 5 - 0.5 + (-1.5) = 11
assert abs(out["net_pnl"] - 11.0) < 1e-9
assert out["pnl_source"] == "live_exchange"
+1
View File
@@ -23,6 +23,7 @@
| 平仓类 | **目标平仓** A 双腿 / B 只平永续(远虚残留);**到期平仓** |
| 模式 | 设置页 SIM/LIVE;切 LIVE 输入 `LIVE`;密钥写入 `.env` |
| 同时仓 | 最多 1 组活跃;残留期权不挡新开 |
| **LIVE 盈亏** | 手续费/永续 UPL/已实现/资金费以**交易所**为准;期权持仓浮盈用本地买一算法(期权净盈亏);USDC **1:1** 折 USDT;达标看组净盈亏(含资金费,不含估平仓费) |
---
+3
View File
@@ -157,8 +157,11 @@ export type PlanState = {
expiry_ms?: number | null;
perp_upl?: number;
option_upl?: number;
fees_paid?: number;
funding_usdt?: number;
est_close_fees?: number;
net_pnl?: number;
pnl_source?: string;
index_px?: number | null;
entry_index_px?: number;
move_points?: number;
+19 -3
View File
@@ -275,13 +275,27 @@ export default function PlanPage() {
</span>
</div>
<div className="kv">
<span></span>
<span>{plan?.mode === "LIVE" ? "(交易所)" : ""}</span>
<span className={`mono ${pnlClass(pos?.perp_upl)}`}>{fmt(pos?.perp_upl)}</span>
</div>
<div className="kv">
<span></span>
<span>{plan?.mode === "LIVE" ? "期权净盈亏" : "期权浮盈"}</span>
<span className={`mono ${pnlClass(pos?.option_upl)}`}>{fmt(pos?.option_upl)}</span>
</div>
{plan?.mode === "LIVE" && open ? (
<>
<div className="kv">
<span></span>
<span className="mono">{fmt(pos?.fees_paid)}</span>
</div>
<div className="kv">
<span></span>
<span className={`mono ${pnlClass(pos?.funding_usdt)}`}>
{fmt(pos?.funding_usdt)}
</span>
</div>
</>
) : null}
{plan?.last_error ? (
<div className="kv">
<span></span>
@@ -390,7 +404,9 @@ export default function PlanPage() {
<span className="pos-value mono">{fmt(pos?.option_mark_px)}</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-label">
{plan?.mode === "LIVE" ? "净盈亏" : "浮盈亏"}
</span>
<span className={`pos-value mono ${pnlClass(pos?.option_upl)}`}>
{fmt(pos?.option_upl)}
</span>