Deduct round-trip taker fees from estimated perp PnL.

Unify hub/instance TP profit, calc_pnl, and push/accounting to net of 0.05% per side; leave exchange floating PnL unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-17 15:12:52 +08:00
parent d5e6132b13
commit 2269bc51ff
13 changed files with 307 additions and 20 deletions
+42 -4
View File
@@ -2281,7 +2281,7 @@ def format_hold_minutes(minutes):
def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage, notional_usdt=None):
"""估算盈亏(USDT).优先用名义价值 notional_usdt,否则 margin×leverage."""
"""估算盈亏(USDT).优先用名义价值 notional_usdt,否则 margin×leverage;扣双边 taker 费."""
try:
trigger = float(trigger_price)
exit_p = float(exit_price)
@@ -2299,7 +2299,14 @@ def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage, not
pnl_ratio = (trigger - exit_p) / trigger
else:
pnl_ratio = (exit_p - trigger) / trigger
return round(notional * pnl_ratio, FUNDS_DECIMALS)
gross = notional * pnl_ratio
try:
from lib.trade.trade_fee_lib import net_pnl_after_fee
net = net_pnl_after_fee(gross, trigger, exit_p, open_notional=notional)
return round(float(net), FUNDS_DECIMALS) if net is not None else round(gross, FUNDS_DECIMALS)
except Exception:
return round(gross, FUNDS_DECIMALS)
except Exception:
return 0.0
@@ -2449,7 +2456,7 @@ def _sum_binance_income(entries, income_types, trade_ids=None):
def calc_pnl_from_closing_trades(direction, entry_price, trades, exchange_symbol=None):
"""按减仓成交数量×价差汇总盈亏(不含资金费;比单点标记价更接近交易所)."""
"""按减仓成交数量×价差汇总盈亏(扣固定双边 taker 费;不含资金费)."""
try:
entry = float(entry_price)
except (TypeError, ValueError):
@@ -2465,6 +2472,7 @@ def calc_pnl_from_closing_trades(direction, entry_price, trades, exchange_symbol
contract_size = 1.0
pnl = 0.0
qty = 0.0
notional_close = 0.0
for t in trades:
try:
price = float(t.get("price") or 0)
@@ -2474,13 +2482,22 @@ def calc_pnl_from_closing_trades(direction, entry_price, trades, exchange_symbol
if price <= 0 or amount <= 0:
continue
qty += amount
notional_close += amount * price
if direction == "short":
pnl += amount * (entry - price)
else:
pnl += amount * (price - entry)
if qty <= 0:
return None
return round(pnl, FUNDS_DECIMALS)
exit_px = (notional_close / qty) if qty > 0 else entry
try:
from lib.trade.trade_fee_lib import net_pnl_after_fee
# amount 已乘 contractSize,此处面值用 1
net = net_pnl_after_fee(pnl, entry, exit_px, qty, 1.0)
return round(float(net), FUNDS_DECIMALS) if net is not None else round(pnl, FUNDS_DECIMALS)
except Exception:
return round(pnl, FUNDS_DECIMALS)
def resolve_trade_pnl_amount(
@@ -2535,10 +2552,31 @@ def resolve_trade_pnl_amount(
ex_sym, direction, open_ms, close_ms, closing_trades=closing_trades
)
if net is not None:
# income 已含真实手续费,直接用.
return net, exit_price, eo, ec, sync_key
if closing_trades:
trade_pnl = calc_binance_realized_pnl_from_trades(closing_trades)
if trade_pnl is not None:
# fill.realizedPnl 通常不含 commission,补固定双边费.
try:
from lib.trade.trade_fee_lib import net_pnl_after_fee
entry = float(entry_price or 0)
exit_p = float(exit_price or entry or 0)
if entry > 0 and exit_p > 0:
open_n = get_plan_notional_usdt(row)
if open_n is None:
margin = row["margin_capital"] if hasattr(row, "keys") else row.get("margin_capital")
lev = row["leverage"] if hasattr(row, "keys") else row.get("leverage")
try:
open_n = float(margin or 0) * float(lev or 1)
except (TypeError, ValueError):
open_n = None
adj = net_pnl_after_fee(trade_pnl, entry, exit_p, open_notional=open_n)
if adj is not None:
trade_pnl = adj
except Exception:
pass
return trade_pnl, exit_price, None, None, None
fill_pnl = calc_pnl_from_closing_trades(direction, entry_price, closing_trades, ex_sym)
if fill_pnl is not None:
+10 -1
View File
@@ -2261,6 +2261,7 @@ def format_hold_minutes(minutes):
def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage):
"""估算净盈亏(USDT):价差毛利 − 双边 taker 费(默认各 0.05%)."""
try:
trigger = float(trigger_price)
exit_p = float(exit_price)
@@ -2272,7 +2273,15 @@ def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage):
pnl_ratio = (trigger - exit_p) / trigger
else:
pnl_ratio = (exit_p - trigger) / trigger
return round(margin * lev * pnl_ratio, 4)
notional = margin * lev
gross = notional * pnl_ratio
try:
from lib.trade.trade_fee_lib import net_pnl_after_fee
net = net_pnl_after_fee(gross, trigger, exit_p, open_notional=notional)
return float(net) if net is not None else round(gross, 4)
except Exception:
return round(gross, 4)
except Exception:
return 0.0
+10 -1
View File
@@ -2157,6 +2157,7 @@ def format_hold_minutes(minutes):
def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage):
"""估算净盈亏(USDT):价差毛利 − 双边 taker 费(默认各 0.05%)."""
try:
trigger = float(trigger_price)
exit_p = float(exit_price)
@@ -2168,7 +2169,15 @@ def calc_pnl(direction, trigger_price, exit_price, margin_capital, leverage):
pnl_ratio = (trigger - exit_p) / trigger
else:
pnl_ratio = (exit_p - trigger) / trigger
return round(margin * lev * pnl_ratio, 4)
notional = margin * lev
gross = notional * pnl_ratio
try:
from lib.trade.trade_fee_lib import net_pnl_after_fee
net = net_pnl_after_fee(gross, trigger, exit_p, open_notional=notional)
return float(net) if net is not None else round(gross, 4)
except Exception:
return round(gross, 4)
except Exception:
return 0.0
+48
View File
@@ -4,6 +4,54 @@
---
## 2026-07-17 · 永续估算盈亏统一扣双边 taker 手续费
### 修改原因
中控/实例「盈利金额」、微信推送「本单盈亏」、交易记录 `pnl_amount` 使用价差毛利,未扣开平手续费,与交易所实际净盈亏及盈亏比体感偏差较大。
### 定稿口径
| 项 | 约定 |
|----|------|
| 浮盈亏 | 仍读交易所,不改 |
| 费率 | taker 单边 **0.05%**`PERP_TAKER_FEE_RATE`,默认 `0.0005`),开+平双边 |
| 净盈亏 | 毛利 − 开仓名义×费率 − 平仓名义×费率(不考虑滑点) |
| RR | 净盈利 / 原风险(风险侧加费第二步再做) |
| 历史记录 | 不回算 |
### 修改的地方
| 文件 | 改动摘要 |
|------|----------|
| `lib/trade/trade_fee_lib.py` | 新增公共扣费 / 净盈亏 |
| `lib/strategy/strategy_roll_ui_lib.py` | `reward_at_tp_usdt` → 净盈利 |
| `lib/strategy/strategy_roll_lib.py` | 同上 |
| `lib/strategy/strategy_trend_lib.py` | `calc_tp_profit_usdt` → 净盈利 |
| `lib/hub/hub_calculator_lib.py` | 滚仓预览止盈盈利 / 首仓盈利扣费;RR 跟净盈利 |
| `crypto_monitor_okx/app.py` | `calc_pnl` → 净盈亏(推送/记账) |
| `crypto_monitor_gate/app.py` | 同上 |
| `crypto_monitor_binance/app.py` | `calc_pnl` / 成交回退扣费;income 真费路径优先不改 |
| `tests/test_trade_fee_lib.py` | 新增 |
| `tests/test_strategy_roll_ui_lib.py` | 断言改净额 |
| `tests/test_order_monitor_display_lib.py` | 断言改净额 |
### 达成的目标
1. 中控持仓「盈利金额」、实例「盈利金额」、计算器止盈盈利、趋势/滚仓预览一致为净盈亏。
2. 微信推送与新建交易记录的 `pnl_amount` 与上述估算口径一致。
3. 币安若能拉到 income 净额(已含真实手续费)仍优先用交易所数。
4. 浮盈亏展示仍跟交易所。
### 交付之后的验收
1. 同一笔持仓:中控盈利金额 ≈ 实例盈利金额(均为扣费后)。
2. 平仓推送「本单盈亏」与新写入记录接近,不再明显大于交易所净利。
3. 浮盈亏与交易所 App 一致(本改不动)。
4. 单测:`python -m unittest tests.test_trade_fee_lib tests.test_strategy_roll_ui_lib tests.test_order_monitor_display_lib tests.test_trend_preview_tp -v` 通过。
---
## 2026-07-16 · 计算器左侧 Tab + 三行输入
### 修改原因
+20 -4
View File
@@ -315,10 +315,18 @@ def _roll_leg_preview(
cs = float(contract_size) if contract_size else 1.0
if direction == "long":
loss_at_sl = (new_avg - sl) * new_qty * cs
reward_at_tp = (tp - new_avg) * new_qty * cs
reward_gross = (tp - new_avg) * new_qty * cs
else:
loss_at_sl = (sl - new_avg) * new_qty * cs
reward_at_tp = (new_avg - tp) * new_qty * cs
reward_gross = (new_avg - tp) * new_qty * cs
try:
from lib.trade.trade_fee_lib import net_pnl_after_fee
reward_at_tp = net_pnl_after_fee(reward_gross, new_avg, tp, new_qty, cs)
if reward_at_tp is None:
reward_at_tp = reward_gross
except Exception:
reward_at_tp = reward_gross
return {
"add_amount_raw": q2,
"qty_after": new_qty,
@@ -412,10 +420,18 @@ def calc_roll_calculator(
if direction == "long":
first_loss = (avg - initial_sl) * qty_f * cs
first_profit = (tp - avg) * qty_f * cs
first_profit_gross = (tp - avg) * qty_f * cs
else:
first_loss = (initial_sl - avg) * qty_f * cs
first_profit = (avg - tp) * qty_f * cs
first_profit_gross = (avg - tp) * qty_f * cs
try:
from lib.trade.trade_fee_lib import net_pnl_after_fee
first_profit = net_pnl_after_fee(first_profit_gross, avg, tp, qty_f, cs)
if first_profit is None:
first_profit = first_profit_gross
except Exception:
first_profit = first_profit_gross
rows.append(
{
+10 -2
View File
@@ -143,8 +143,16 @@ def reward_at_tp_usdt(
cs = float(contract_size or 1.0)
direction = (direction or "long").strip().lower()
if direction == "short":
return (float(avg) - float(take_profit)) * float(qty) * cs
return (float(take_profit) - float(avg)) * float(qty) * cs
gross = (float(avg) - float(take_profit)) * float(qty) * cs
else:
gross = (float(take_profit) - float(avg)) * float(qty) * cs
try:
from lib.trade.trade_fee_lib import net_pnl_after_fee
net = net_pnl_after_fee(gross, avg, take_profit, qty, cs)
return float(net) if net is not None else gross
except Exception:
return gross
def roll_fib_trigger_crossed(
+11 -3
View File
@@ -16,7 +16,7 @@ def reward_at_tp_usdt(
*,
contract_size: float = 1.0,
) -> Optional[float]:
"""与 strategy_roll_lib.preview_roll 一致:线性合约 U 本位盈利."""
"""与 strategy_roll_lib.preview_roll 一致:线性合约 U 本位盈利(扣双边 taker 费)."""
try:
avg = float(avg_entry)
tp = float(take_profit)
@@ -28,8 +28,16 @@ def reward_at_tp_usdt(
return None
direction = (direction or "long").strip().lower()
if direction == "short":
return (avg - tp) * q * cs
return (tp - avg) * q * cs
gross = (avg - tp) * q * cs
else:
gross = (tp - avg) * q * cs
try:
from lib.trade.trade_fee_lib import net_pnl_after_fee
net = net_pnl_after_fee(gross, avg, tp, q, cs)
return net
except Exception:
return gross
def leg_fill_price(leg: dict) -> Optional[float]:
+8 -2
View File
@@ -306,13 +306,19 @@ def calc_tp_profit_usdt(
contracts: float,
contract_size: float = 1.0,
) -> Optional[float]:
"""到达止盈价时,按累计张数与加仓后均价的盈利 U."""
"""到达止盈价时,按累计张数与加仓后均价的盈利 U(扣双边 taker 费)."""
try:
from lib.hub.hub_position_metrics import estimate_linear_swap_upnl_usdt
from lib.trade.trade_fee_lib import net_pnl_after_fee
return estimate_linear_swap_upnl_usdt(
gross = estimate_linear_swap_upnl_usdt(
direction, float(avg_entry), float(take_profit_price), float(contracts), float(contract_size)
)
if gross is None:
return None
return net_pnl_after_fee(
gross, avg_entry, take_profit_price, contracts, contract_size
)
except (TypeError, ValueError):
return None
+94
View File
@@ -0,0 +1,94 @@
"""永续估算盈亏:固定 taker 手续费(默认单边 0.05%,开+平双边).
浮盈亏仍读交易所;本模块只服务「盈利金额 / 止盈盈利 / 推送 / 记账 pnl_amount」等估算口径.
"""
from __future__ import annotations
import math
import os
from typing import Optional
def _finite(v) -> Optional[float]:
try:
f = float(v)
return f if math.isfinite(f) else None
except (TypeError, ValueError):
return None
def taker_fee_rate() -> float:
"""单边 taker 费率,默认 0.0005(=0.05%)."""
raw = os.getenv("PERP_TAKER_FEE_RATE", "0.0005")
rate = _finite(raw)
if rate is None or rate < 0:
return 0.0005
return rate
def notional_usdt(price, qty, contract_size: float = 1.0) -> Optional[float]:
"""名义价值 U = 价格 × 张数 × 合约面值."""
p = _finite(price)
q = _finite(qty)
cs = _finite(contract_size)
if p is None or q is None or p <= 0 or q <= 0:
return None
if cs is None or cs <= 0:
cs = 1.0
return abs(q) * p * cs
def estimate_roundtrip_fee_usdt(
entry_price,
exit_price,
qty=None,
contract_size: float = 1.0,
*,
open_notional: float | None = None,
rate: float | None = None,
) -> float:
"""开+平双边手续费(各单边 rate).
优先用 价×张×面值;若无张数则用 open_notional 估开仓名义,
平仓名义按 exit/entry 缩放.
"""
fee_rate = taker_fee_rate() if rate is None else float(rate)
if fee_rate <= 0:
return 0.0
entry = _finite(entry_price)
exit_p = _finite(exit_price)
open_n = notional_usdt(entry, qty, contract_size) if qty is not None else None
if open_n is None:
open_n = _finite(open_notional)
if open_n is None or open_n <= 0:
return 0.0
if entry is not None and entry > 0 and exit_p is not None and exit_p > 0:
close_n = open_n * (exit_p / entry)
else:
close_n = open_n
return round(open_n * fee_rate + close_n * fee_rate, 8)
def net_pnl_after_fee(
gross_pnl,
entry_price,
exit_price,
qty=None,
contract_size: float = 1.0,
*,
open_notional: float | None = None,
rate: float | None = None,
) -> Optional[float]:
"""毛利扣双边手续费后的净盈亏;gross 无效则返回 None."""
gross = _finite(gross_pnl)
if gross is None:
return None
fee = estimate_roundtrip_fee_usdt(
entry_price,
exit_price,
qty,
contract_size,
open_notional=open_notional,
rate=rate,
)
return round(gross - fee, 4)
+2 -1
View File
@@ -88,7 +88,8 @@ class HubCalculatorLibTests(unittest.TestCase):
self.assertEqual(data["first_contracts"], 10.0)
self.assertEqual(len(data["rows"]), 1)
self.assertEqual(data["rows"][0]["loss_at_sl_u"], 50.0)
self.assertEqual(data["rows"][0]["profit_at_tp_u"], 200.0)
# 毛利 200 双边费 (1000+1200)*0.0005=1.1 → 198.9
self.assertEqual(data["rows"][0]["profit_at_tp_u"], 198.9)
@patch("lib.hub.hub_calculator_lib._resolve_market", return_value=_mock_resolve())
def test_roll_calculator_chain_two_legs(self, _mock):
+2 -1
View File
@@ -136,7 +136,8 @@ def test_apply_order_price_display_fields_gate_contract_size():
avg_entry_price=62063.4,
)
assert payload["reward_at_tp_usdt"] is not None
assert abs(payload["reward_at_tp_usdt"] - 6.73) < 0.1
# 毛利约 6.73, 扣双边 0.05% 后约 6.25
assert abs(payload["reward_at_tp_usdt"] - 6.25) < 0.1
def test_calc_latest_risk_amount_long():
+2 -1
View File
@@ -43,4 +43,5 @@ def test_infer_initial_position_from_live():
def test_reward_at_tp_long():
assert roll_ui.reward_at_tp_usdt("long", 100.0, 110.0, 2.0) == 20.0
# 毛利 20, 双边费 (200+220)*0.0005=0.21 → 净 19.79
assert abs(roll_ui.reward_at_tp_usdt("long", 100.0, 110.0, 2.0) - 19.79) < 1e-6
+48
View File
@@ -0,0 +1,48 @@
"""永续固定费率净盈亏."""
from __future__ import annotations
import os
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from lib.trade.trade_fee_lib import ( # noqa: E402
estimate_roundtrip_fee_usdt,
net_pnl_after_fee,
notional_usdt,
taker_fee_rate,
)
class TestTradeFeeLib(unittest.TestCase):
def test_default_rate(self):
os.environ.pop("PERP_TAKER_FEE_RATE", None)
self.assertAlmostEqual(taker_fee_rate(), 0.0005)
def test_notional(self):
self.assertAlmostEqual(notional_usdt(100, 2, 1.0), 200.0)
self.assertAlmostEqual(notional_usdt(62000, 78, 0.0001), 483.6, places=2)
def test_roundtrip_fee_qty(self):
# 开 100*2=200, 平 110*2=220, 费=(200+220)*0.0005=0.21
fee = estimate_roundtrip_fee_usdt(100, 110, 2.0, 1.0, rate=0.0005)
self.assertAlmostEqual(fee, 0.21, places=6)
def test_net_long_matches_checklist(self):
# 毛利 20, 费 0.21 → 净 19.79
net = net_pnl_after_fee(20.0, 100, 110, 2.0, 1.0, rate=0.0005)
self.assertAlmostEqual(net, 19.79, places=4)
def test_open_notional_fallback(self):
# 无张数:开名义 1000, 出场 110/100 → 平 1100, 费=1.05
fee = estimate_roundtrip_fee_usdt(100, 110, open_notional=1000, rate=0.0005)
self.assertAlmostEqual(fee, 1.05, places=6)
net = net_pnl_after_fee(50.0, 100, 110, open_notional=1000, rate=0.0005)
self.assertAlmostEqual(net, 48.95, places=4)
if __name__ == "__main__":
unittest.main()