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:
+25
-10
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""实盘金额口径:统一折 USDT(USDC 等 1:1)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def to_usdt(amount: float, ccy: str | None = None) -> float:
|
||||
"""按约定将币种金额折成 USDT;USDC/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))
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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"
|
||||
@@ -23,6 +23,7 @@
|
||||
| 平仓类 | **目标平仓** A 双腿 / B 只平永续(远虚残留);**到期平仓** |
|
||||
| 模式 | 设置页 SIM/LIVE;切 LIVE 输入 `LIVE`;密钥写入 `.env` |
|
||||
| 同时仓 | 最多 1 组活跃;残留期权不挡新开 |
|
||||
| **LIVE 盈亏** | 手续费/永续 UPL/已实现/资金费以**交易所**为准;期权持仓浮盈用本地买一算法(期权净盈亏);USDC **1:1** 折 USDT;达标看组净盈亏(含资金费,不含估平仓费) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user