diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 59cc6e7..07dc57d 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -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: diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index d5feb6f..fe54bdd 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -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 diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index cc0971f..7a43a2d 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -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 diff --git a/docs/更新文档.md b/docs/更新文档.md index eb74a5e..f20c681 100644 --- a/docs/更新文档.md +++ b/docs/更新文档.md @@ -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 + 三行输入 ### 修改原因 diff --git a/lib/hub/hub_calculator_lib.py b/lib/hub/hub_calculator_lib.py index 287ffb0..524ebcf 100644 --- a/lib/hub/hub_calculator_lib.py +++ b/lib/hub/hub_calculator_lib.py @@ -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( { diff --git a/lib/strategy/strategy_roll_lib.py b/lib/strategy/strategy_roll_lib.py index 7ab8cc6..1979e64 100644 --- a/lib/strategy/strategy_roll_lib.py +++ b/lib/strategy/strategy_roll_lib.py @@ -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( diff --git a/lib/strategy/strategy_roll_ui_lib.py b/lib/strategy/strategy_roll_ui_lib.py index c0fb32c..cef900f 100644 --- a/lib/strategy/strategy_roll_ui_lib.py +++ b/lib/strategy/strategy_roll_ui_lib.py @@ -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]: diff --git a/lib/strategy/strategy_trend_lib.py b/lib/strategy/strategy_trend_lib.py index 50a5f56..f0dcad2 100644 --- a/lib/strategy/strategy_trend_lib.py +++ b/lib/strategy/strategy_trend_lib.py @@ -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 diff --git a/lib/trade/trade_fee_lib.py b/lib/trade/trade_fee_lib.py new file mode 100644 index 0000000..ed27312 --- /dev/null +++ b/lib/trade/trade_fee_lib.py @@ -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) diff --git a/tests/test_hub_calculator_lib.py b/tests/test_hub_calculator_lib.py index c2cdb64..cc5bb86 100644 --- a/tests/test_hub_calculator_lib.py +++ b/tests/test_hub_calculator_lib.py @@ -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): diff --git a/tests/test_order_monitor_display_lib.py b/tests/test_order_monitor_display_lib.py index 5ec9b3d..8700b23 100644 --- a/tests/test_order_monitor_display_lib.py +++ b/tests/test_order_monitor_display_lib.py @@ -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(): diff --git a/tests/test_strategy_roll_ui_lib.py b/tests/test_strategy_roll_ui_lib.py index 201963a..80f29e6 100644 --- a/tests/test_strategy_roll_ui_lib.py +++ b/tests/test_strategy_roll_ui_lib.py @@ -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 diff --git a/tests/test_trade_fee_lib.py b/tests/test_trade_fee_lib.py new file mode 100644 index 0000000..ba70530 --- /dev/null +++ b/tests/test_trade_fee_lib.py @@ -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()