From 9a55c616782a5847d09dae8ea6e7e3c4e4c11e41 Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 26 Jun 2026 11:26:41 +0800 Subject: [PATCH] Align position margin with account balance and show deducted open commission only. Co-authored-by: Cursor --- install_trading.py | 96 ++++++++++++++++++++++++++++++++++++++++++++-- static/js/trade.js | 2 +- vnpy_bridge.py | 38 +++++++++++++++++- 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/install_trading.py b/install_trading.py index 401bd18..4234d7b 100644 --- a/install_trading.py +++ b/install_trading.py @@ -88,11 +88,13 @@ from vnpy_bridge import ( _ctp_td_lock, ctp_cancel_order, ctp_connect, + ctp_account_margin_used, ctp_estimate_margin_one_lot, ctp_get_account, ctp_get_tick_price, ctp_list_active_orders, ctp_list_positions, + ctp_list_trades, ctp_status, execute_order, get_bridge, @@ -247,6 +249,56 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se return round(float(mon_margin), 2), "db" return None, "estimate" + def _apply_account_margin_to_rows( + rows: list[dict], + mode: str, + capital: float, + ) -> list[dict]: + """CTP 已连接时,用「权益−可用」校正占用保证金与仓位占比。""" + if not ctp_status(mode).get("connected"): + return rows + total_used = ctp_account_margin_used(mode) + if not total_used: + return rows + active = [ + r for r in rows + if r.get("order_state") != "pending" and int(r.get("lots") or 0) > 0 + ] + if not active: + return rows + if len(active) == 1: + row = active[0] + row["margin"] = total_used + row["margin_source"] = "ctp" + if capital > 0: + row["position_pct"] = round(total_used / capital * 100, 2) + return rows + weights: list[float] = [] + for row in active: + sym = (row.get("symbol_code") or "").strip() + lots = int(row.get("lots") or 0) + entry = float(row.get("entry_price") or 0) + if sym and lots > 0 and entry > 0: + spec = get_contract_spec(sym) + weights.append(entry * spec["mult"] * lots) + else: + weights.append(0.0) + total_weight = sum(weights) + assigned = 0.0 + for i, row in enumerate(active): + if total_weight <= 0: + margin = round(total_used / len(active), 2) + elif i == len(active) - 1: + margin = round(total_used - assigned, 2) + else: + margin = round(total_used * weights[i] / total_weight, 2) + assigned += margin + row["margin"] = margin + row["margin_source"] = "ctp" + if capital > 0: + row["position_pct"] = round(margin / capital * 100, 2) + return rows + def _ensure_monitors_from_ctp(conn, mode: str) -> None: """CTP 有持仓但本地无监控时,自动补写一条 active 记录供展示。""" if not ctp_status(mode).get("connected"): @@ -307,6 +359,34 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se pass return False + def _open_commission_from_ctp_trades( + mode: str, sym: str, direction: str, + ) -> Optional[float]: + """汇总该持仓开仓成交的柜台手续费(成交回报中的 commission)。""" + if not ctp_status(mode).get("connected"): + return None + try: + trades = ctp_list_trades(mode) + except Exception: + return None + total = 0.0 + has_commission = False + for t in trades: + if (t.get("offset") or "").strip().lower() != "open": + continue + pos_dir = ( + t.get("position_direction") or t.get("direction") or "long" + ).strip().lower() + if pos_dir != (direction or "long").strip().lower(): + continue + if not _match_ctp_symbol(t.get("symbol") or "", sym): + continue + comm = float(t.get("commission") or 0) + total += comm + if comm > 0: + has_commission = True + return round(total, 2) if has_commission else None + def _holding_duration(open_time: str, now_iso: str) -> str: try: from app import calc_holding_duration @@ -773,9 +853,16 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se fee_info = calc_fee_breakdown( sym, entry, close_est, lots, open_time or now_iso, now_iso, trading_mode=mode, ) + open_commission = _open_commission_from_ctp_trades(mode, sym, direction) + if open_commission is not None: + display_fee = open_commission + fee_source = "ctp" + else: + display_fee = fee_info["open_fee"] + fee_source = fee_info.get("fee_source") or "local" est_net = None if float_pnl is not None: - est_net = round(float(float_pnl) - fee_info["total_fee"], 2) + est_net = round(float(float_pnl) - fee_info["close_fee"], 2) pos_metrics = calc_position_metrics( direction, entry, sl if sl is not None else entry, tp if tp is not None else entry, lots, mark, capital, sym, @@ -853,11 +940,11 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se "risk_pct": pos_metrics.get("risk_pct") if sl is not None else None, "rr_ratio": pos_metrics.get("rr_ratio") if sl is not None and tp is not None else None, "float_pnl": float_pnl, - "est_fee": fee_info["total_fee"], - "est_fee_open": fee_info["open_fee"], + "est_fee": display_fee, + "est_fee_open": display_fee, "est_fee_close": fee_info["close_fee"], "est_fee_close_type": fee_info["close_type"], - "fee_source": fee_info.get("fee_source") or "local", + "fee_source": fee_source, "est_pnl_net": est_net, "sl_order_active": order_st.get("sl_monitoring"), "tp_order_active": order_st.get("tp_monitoring"), @@ -1082,6 +1169,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se if ctp_st.get("connected"): _ensure_monitors_from_ctp(conn, mode) rows = _build_trading_live_rows(conn, fast=fast) + rows = _apply_account_margin_to_rows(rows, mode, capital) pending_orders = _build_pending_orders(conn, mode) risk = get_risk_status(conn, active_count=_effective_active_position_count(conn, mode)) return { diff --git a/static/js/trade.js b/static/js/trade.js index d92b7e0..6a860b2 100644 --- a/static/js/trade.js +++ b/static/js/trade.js @@ -926,7 +926,7 @@ } return ''; }()); - var feeLabel = row.fee_source === 'ctp' ? '手续费(柜台)' : '手续费'; + var feeLabel = row.fee_source === 'ctp' ? '已扣手续费(柜台)' : '已扣手续费'; var marginLabel = row.margin_source === 'ctp' ? '占用保证金(柜台)' : '占用保证金'; var openLabel = '开仓'; return ( diff --git a/vnpy_bridge.py b/vnpy_bridge.py index 6db8881..580a0fb 100644 --- a/vnpy_bridge.py +++ b/vnpy_bridge.py @@ -251,7 +251,21 @@ class CtpBridge: except ImportError: return - def _on_position(_event) -> None: + def _on_position(event) -> None: + try: + pos = event.data + vol = int(getattr(pos, "volume", 0) or 0) + if vol <= 0: + return + sym = getattr(pos, "symbol", "") or "" + d = "long" if _is_long_direction(getattr(pos, "direction", None)) else "short" + for attr in ("margin", "use_margin", "UseMargin"): + raw = float(getattr(pos, attr, 0) or 0) + if raw > 0: + self._position_margins[self._position_margin_key(sym, d)] = raw + break + except Exception as exc: + logger.debug("position margin cache: %s", exc) _fire_position_refresh_callback_debounced() self._ee.register(EVENT_POSITION, _on_position) @@ -1177,6 +1191,7 @@ class CtpBridge: "lots": vol, "price": float(getattr(trade, "price", 0) or 0), "datetime": dt, + "commission": round(float(getattr(trade, "commission", 0) or 0), 2), } except Exception as exc: logger.debug("trade_row_from_vnpy: %s", exc) @@ -1216,6 +1231,9 @@ class CtpBridge: "lots": vol, "price": price, "datetime": dt, + "commission": round( + float(data.get("Commission") or data.get("commission") or 0), 2, + ), } except Exception as exc: logger.debug("trade_row_from_ctp_dict: %s", exc) @@ -1476,6 +1494,24 @@ def ctp_get_account(mode: str) -> dict[str, Any]: return b.get_account() +def ctp_account_margin_used(mode: str) -> Optional[float]: + """账户实际占用保证金 ≈ 权益 − 可用(与顶栏柜台资金一致)。""" + b = get_bridge() + if b.connected_mode != mode or not b.ping(): + return None + try: + acc = b.get_account() + balance = float(acc.get("balance") or 0) + available = float(acc.get("available") or 0) + if balance <= 0: + return None + used = balance - available + return round(used, 2) if used > 0 else None + except Exception as exc: + logger.debug("ctp_account_margin_used: %s", exc) + return None + + def ctp_list_positions( mode: str, *,