diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example index 209324b..bf410f6 100644 --- a/crypto_monitor_binance/.env.example +++ b/crypto_monitor_binance/.env.example @@ -75,6 +75,8 @@ BINANCE_TRIGGER_WORKING_TYPE=CONTRACT_PRICE # EXCHANGE_DISPLAY_NAME=Binance # 企业微信推送里展示的账户备注 # BINANCE_ACCOUNT_LABEL=binance实盘账户 +# 盈亏同步口径:false=与 App「仓位历史-实现盈亏」一致(不含资金费);true=含资金费等完整流水 +# BINANCE_PNL_INCLUDE_FUNDING=false # ============================================================================= # 关键位门控(页面「关键位监控」规则条与 _key_hard_checks 共用) diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 4d79498..85210be 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -162,9 +162,16 @@ ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控" KEY_MONITOR_AUTO_TYPES = frozenset({"箱体突破", "收敛突破"}) EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or "").strip() EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, int(os.getenv("EXCHANGE_POSITION_HISTORY_LIMIT", "200")))) +# 与币安 App「仓位历史-实现盈亏」对齐:仅已实现盈亏 + 手续费(不含资金费) +BINANCE_APP_PNL_INCOME_TYPES = frozenset({"REALIZED_PNL", "COMMISSION"}) BINANCE_NET_INCOME_TYPES = frozenset( {"REALIZED_PNL", "COMMISSION", "FUNDING_FEE", "INSURANCE_CLEAR", "INTERNAL_AUTO_CLOSE"} ) +BINANCE_PNL_INCLUDE_FUNDING = os.getenv("BINANCE_PNL_INCLUDE_FUNDING", "false").lower() in ( + "1", + "true", + "yes", +) _LAST_EXCHANGE_PNL_SYNC_AT = 0.0 KEY_MONITOR_ALERT_ONLY_TYPES = frozenset({"关键阻力位", "关键支撑位"}) AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true" @@ -2086,6 +2093,48 @@ def _income_entry_trade_id(entry): return "" +def calc_binance_realized_pnl_from_trades(trades): + """ + 按 Binance 成交回报汇总:sum(realizedPnl) + 手续费(与 App「仓位历史-实现盈亏」一致)。 + """ + if not trades: + return None + total = 0.0 + has = False + for t in trades: + info = t.get("info") if isinstance(t.get("info"), dict) else {} + for src in (info, t): + if not isinstance(src, dict): + continue + for key in ("realizedPnl", "realized_pnl"): + v = src.get(key) + if v is None or str(v).strip() == "": + continue + try: + total += float(v) + has = True + except (TypeError, ValueError): + pass + fee = t.get("fee") + if isinstance(fee, dict) and fee.get("cost") is not None: + try: + total += float(fee["cost"]) + has = True + except (TypeError, ValueError): + pass + comm = info.get("commission") + if comm is not None and str(comm).strip() != "": + try: + c = float(comm) + total -= c if c > 0 else -abs(c) + has = True + except (TypeError, ValueError): + pass + if not has: + return None + return round(total, FUNDS_DECIMALS) + + def calc_pnl_from_closing_trades(direction, entry_price, trades, exchange_symbol=None): """按减仓成交数量×价差汇总盈亏(不含资金费;比单点标记价更接近交易所)。""" try: @@ -2173,6 +2222,9 @@ def resolve_trade_pnl_amount( if net is not None: 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: + 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: return fill_pnl, exit_price, None, None, None @@ -5430,24 +5482,48 @@ def fetch_binance_net_pnl_for_trade( ): if open_ms is None or close_ms is None or close_ms < open_ms: return None, None, None, None - buffer_ms = 5 * 60 * 1000 + trade_ids = _trade_ids_from_fills(closing_trades) if closing_trades else None + if closing_trades: + trade_pnl = calc_binance_realized_pnl_from_trades(closing_trades) + if trade_pnl is not None: + first_t = None + last_t = None + for t in closing_trades: + ts = _coerce_ts_ms(t.get("timestamp")) + if ts: + first_t = ts if first_t is None else min(first_t, ts) + last_t = ts if last_t is None else max(last_t, ts) + ensure_markets_loaded() + market = exchange.market(exchange_symbol) + cid = market.get("id") or exchange_symbol + sync_key = f"trades|{cid}|{direction}|{open_ms}|{close_ms}|{trade_pnl}" + eo = ms_to_app_local_str(first_t) if first_t else None + ec = ms_to_app_local_str(last_t) if last_t else None + return trade_pnl, sync_key, eo, ec + income_types = ( + BINANCE_NET_INCOME_TYPES + if BINANCE_PNL_INCLUDE_FUNDING + else BINANCE_APP_PNL_INCOME_TYPES + ) + buffer_ms = 90 * 1000 if trade_ids else 5 * 60 * 1000 entries = _fetch_binance_income_entries( exchange_symbol, max(0, int(open_ms) - buffer_ms), int(close_ms) + buffer_ms ) if not entries: return None, None, None, None - trade_ids = _trade_ids_from_fills(closing_trades) if closing_trades else None net = 0.0 first_t = None last_t = None for e in entries: it = (e.get("incomeType") or e.get("income_type") or "").strip() - if it not in BINANCE_NET_INCOME_TYPES: + if it not in income_types: continue if trade_ids and it in ("REALIZED_PNL", "COMMISSION"): tid = _income_entry_trade_id(e) if tid and tid not in trade_ids: continue + if trade_ids and it == "FUNDING_FEE": + continue try: net += float(e.get("income") or 0) except (TypeError, ValueError): @@ -5509,9 +5585,11 @@ def sync_trade_record_exchange_pnl(conn, record_id, commit=True, force=False): if net is not None and sync_key: break if net is None: - net = calc_pnl_from_closing_trades( - direction, tr["trigger_price"], closing_trades, ex_sym - ) + net = calc_binance_realized_pnl_from_trades(closing_trades) + if net is None: + net = calc_pnl_from_closing_trades( + direction, tr["trigger_price"], closing_trades, ex_sym + ) if net is not None: try: ensure_markets_loaded() diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index 4867d5f..d662d84 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -1018,7 +1018,7 @@ function syncExchangePnl(force){ } document.getElementById("btn-sync-exchange-pnl")?.addEventListener("click", function(){ - if(confirm("从 Binance 流水回填盈亏(含手续费)?将覆盖未复盘记录的展示盈亏。")){ + if(confirm("从 Binance 成交/流水回填盈亏(与 App 仓位历史口径一致,不含资金费)?将覆盖未复盘记录的展示盈亏。")){ syncExchangePnl(true); } });