diff --git a/backend/app/api/trades.py b/backend/app/api/trades.py index e087df2..5cab179 100644 --- a/backend/app/api/trades.py +++ b/backend/app/api/trades.py @@ -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, } diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index aeee4fb..1e5621a 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -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( diff --git a/backend/app/live/binance_trade.py b/backend/app/live/binance_trade.py index 6cc1db5..ed4755e 100644 --- a/backend/app/live/binance_trade.py +++ b/backend/app/live/binance_trade.py @@ -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 + diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index d396558..0dfb4fd 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -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( diff --git a/backend/app/live/live_pnl.py b/backend/app/live/live_pnl.py new file mode 100644 index 0000000..945505d --- /dev/null +++ b/backend/app/live/live_pnl.py @@ -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) diff --git a/backend/app/live/money.py b/backend/app/live/money.py new file mode 100644 index 0000000..114ba85 --- /dev/null +++ b/backend/app/live/money.py @@ -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)) diff --git a/backend/app/live/okx_trade.py b/backend/app/live/okx_trade.py index b630440..8f75e83 100644 --- a/backend/app/live/okx_trade.py +++ b/backend/app/live/okx_trade.py @@ -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 + diff --git a/backend/app/models/db.py b/backend/app/models/db.py index 2eae6e9..c190ace 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -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]) diff --git a/backend/tests/test_live_pnl.py b/backend/tests/test_live_pnl.py new file mode 100644 index 0000000..52d5936 --- /dev/null +++ b/backend/tests/test_live_pnl.py @@ -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" diff --git a/docs/实盘策略说明.md b/docs/实盘策略说明.md index 88914bc..f0eacf4 100644 --- a/docs/实盘策略说明.md +++ b/docs/实盘策略说明.md @@ -23,6 +23,7 @@ | 平仓类 | **目标平仓** A 双腿 / B 只平永续(远虚残留);**到期平仓** | | 模式 | 设置页 SIM/LIVE;切 LIVE 输入 `LIVE`;密钥写入 `.env` | | 同时仓 | 最多 1 组活跃;残留期权不挡新开 | +| **LIVE 盈亏** | 手续费/永续 UPL/已实现/资金费以**交易所**为准;期权持仓浮盈用本地买一算法(期权净盈亏);USDC **1:1** 折 USDT;达标看组净盈亏(含资金费,不含估平仓费) | --- diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 663d6c4..91c5844 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index f725259..c4f5f8a 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -275,13 +275,27 @@ export default function PlanPage() {
- 永续浮盈 + 永续浮盈{plan?.mode === "LIVE" ? "(交易所)" : ""} {fmt(pos?.perp_upl)}
- 期权浮盈 + {plan?.mode === "LIVE" ? "期权净盈亏" : "期权浮盈"} {fmt(pos?.option_upl)}
+ {plan?.mode === "LIVE" && open ? ( + <> +
+ 已付手续费 + {fmt(pos?.fees_paid)} +
+
+ 资金费 + + {fmt(pos?.funding_usdt)} + +
+ + ) : null} {plan?.last_error ? (
最近错误 @@ -390,7 +404,9 @@ export default function PlanPage() { {fmt(pos?.option_mark_px)}
- 浮盈亏 + + {plan?.mode === "LIVE" ? "净盈亏" : "浮盈亏"} + {fmt(pos?.option_upl)}