diff --git a/backend/app/api/funds.py b/backend/app/api/funds.py index 543eed9..0a29843 100644 --- a/backend/app/api/funds.py +++ b/backend/app/api/funds.py @@ -66,11 +66,26 @@ async def funds_summary(_user: Annotated[str, Depends(require_user)]) -> dict[st exchange = str(st.get("exchange") or s.exchange or "okx").upper() trading_day = datetime.now(SH).strftime("%Y-%m-%d") - # 顶栏「总交易 / 胜率 / 盈亏比」统一历史累计(全部已平组) - closed = db.fetchall( - "SELECT realized_pnl FROM groups WHERE status='closed'" - ) - pnls = [float(r["realized_pnl"] or 0) for r in closed] + # 顶栏「总交易 / 胜率 / 盈亏比」:用展示口径净盈亏(含到期内在价值修复) + closed = db.fetchall("SELECT * FROM groups WHERE status='closed'") + pnls: list[float] = [] + try: + from .trades import _enrich_group, persist_expiry_overlay_if_needed + + for r in closed: + g = dict(r) + fills = db.fetchall( + "SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", + (g["group_id"],), + ) + gr = _enrich_group(g, fills) + try: + persist_expiry_overlay_if_needed(db, gr, list(fills)) + except Exception: + pass + pnls.append(float(gr.get("net_pnl") or gr.get("realized_pnl") or 0)) + except Exception: + pnls = [float(r["realized_pnl"] or 0) for r in closed] n = len(pnls) wins = sum(1 for x in pnls if x > 0) win_rate = (wins / n) if n else 0.0 @@ -124,13 +139,42 @@ async def funds_summary(_user: Annotated[str, Depends(require_user)]) -> dict[st } finally: client.close() + elif exchange in ("BINANCE", "BN"): + from ..live.binance_trade import BinanceTradeClient + + client = BinanceTradeClient() + try: + bal = client.fetch_balances() + funding_usdt = bal.get("funding_usdt") + trading_usdt = bal.get("trading_usdt") + funding_usdc = bal.get("funding_usdc") + trading_usdc = bal.get("trading_usdc") + r = float(rate) if rate and rate > 0 else 1.0 + total = round( + (funding_usdt or 0.0) + + (trading_usdt or 0.0) + + ((funding_usdc or 0.0) + (trading_usdc or 0.0)) * r, + 2, + ) + except Exception as e: + return { + "ok": False, + "mode": mode, + "exchange": exchange, + "detail": str(e), + } + finally: + try: + client.close() + except Exception: + pass else: - # 非 OKX LIVE:暂无统一资金接口,不回退模拟账本(避免实盘显示假资金) + # 其它交易所:不回退模拟账本(避免实盘显示假资金) return { "ok": False, "mode": mode, "exchange": exchange, - "detail": f"{exchange} 实盘资金摘要暂未接入,请用 OKX 或交易所 App 查看", + "detail": f"{exchange} 实盘资金摘要暂未接入,请用交易所 App 查看", } return { "ok": True, diff --git a/backend/app/api/trades.py b/backend/app/api/trades.py index cf7d09d..f810995 100644 --- a/backend/app/api/trades.py +++ b/backend/app/api/trades.py @@ -330,9 +330,122 @@ def _enrich_group(g: dict, fills: list) -> dict: if is_oo: g["option2_leverage"] = _option_leverage_for_leg(g, fills, leg="option2") g["_view_fills"] = view_fills + g["_overlay_settle"] = float(settle) if settle is not None else None + g["_overlaid"] = overlaid return g +def persist_expiry_overlay_if_needed(db: Any, g: dict, raw_fills: list) -> None: + """把内在价值覆盖写回库:fills / realized_pnl / settle_index,并补本地账本差额。""" + if not g.get("_overlaid"): + return + view_fills = g.get("_view_fills") or [] + net = g.get("net_pnl") + if net is None: + return + settle = g.get("_overlay_settle") + group_id = str(g.get("group_id") or "") + if not group_id: + return + old_net = float(g.get("realized_pnl") or 0) + # 已对齐则跳过(避免每次列表刷库) + if abs(old_net - float(net)) < 0.02: + stored = g.get("settle_index_px") + try: + if settle is None or ( + stored is not None and abs(float(stored) - float(settle)) < 0.05 + ): + # 仍可能 fills 未写回;检查是否还有 overlay 标记需要落库 + pass + else: + pass + except (TypeError, ValueError): + pass + + raw_close = { + str(dict(f).get("leg")): dict(f) + for f in raw_fills + if str(dict(f).get("action") or "") == "close" + and str(dict(f).get("leg") or "") in ("option", "option2") + } + cash_delta = 0.0 + updates: list[tuple] = [] + for vf in view_fills: + if not isinstance(vf, dict) or not vf.get("_overlay_intrinsic"): + continue + leg = str(vf.get("leg") or "") + old = raw_close.get(leg) + if not old or old.get("id") is None: + continue + try: + old_px = float(old.get("fill_px") or 0) + new_px = float(vf.get("fill_px") or 0) + qty = float(vf.get("qty_eth") or old.get("qty_eth") or 0) + except (TypeError, ValueError): + continue + if abs(old_px - new_px) <= 1e-9: + continue + cash_delta += (new_px - old_px) * qty + updates.append( + ( + new_px, + new_px, + new_px * qty, + int(old["id"]), + ) + ) + + if not updates and abs(old_net - float(net)) < 0.02: + # 只缺 settle + if settle is None: + return + try: + if g.get("settle_index_px") is not None and abs( + float(g["settle_index_px"]) - float(settle) + ) < 0.05: + return + except (TypeError, ValueError): + pass + + with db._lock: + for base, fill, notional, fid in updates: + db._conn.execute( + "UPDATE fills SET base_px=?, fill_px=?, notional=?, slip=0 WHERE id=?", + (base, fill, notional, fid), + ) + db._conn.execute( + """UPDATE groups SET realized_pnl=?, + settle_index_px=COALESCE(?, settle_index_px), + note=CASE + WHEN instr(COALESCE(note,''), 'expiry_intrinsic_repair')>0 THEN note + ELSE trim(COALESCE(note,'') || ' | expiry_intrinsic_repair') + END + WHERE group_id=? AND status='closed'""", + ( + float(net), + float(settle) if settle is not None else None, + group_id, + ), + ) + db._conn.commit() + + if abs(cash_delta) > 1e-9: + try: + from ..sim.ledger import Ledger + + Ledger(db).apply_cash( + cash_delta, + kind="repair_option_intrinsic", + group_id=group_id, + note=f"expiry intrinsic overlay cash_delta={cash_delta:.4f}", + allow_negative=True, + ) + except Exception: + pass + # 刷新内存中的 realized,供同请求后续使用 + g["realized_pnl"] = float(net) + + @router.get("/groups") async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict: db = get_db() @@ -345,7 +458,13 @@ async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict: (g["group_id"],), ) gr = _enrich_group(g, fills) + try: + persist_expiry_overlay_if_needed(db, gr, list(fills)) + except Exception: + pass gr.pop("_view_fills", None) + gr.pop("_overlay_settle", None) + gr.pop("_overlaid", None) groups.append(gr) return {"groups": groups} @@ -362,11 +481,21 @@ async def group_detail( "SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,) ) gr = _enrich_group(_row(g), fills) + try: + persist_expiry_overlay_if_needed(db, gr, list(fills)) + except Exception: + pass view_fills = gr.pop("_view_fills", None) or fills + gr.pop("_overlay_settle", None) + gr.pop("_overlaid", None) return { "group": gr, "fills": [ - {k: v for k, v in (dict(x) if not isinstance(x, dict) else x).items() if k != "_overlay_intrinsic"} + { + k: v + for k, v in (dict(x) if not isinstance(x, dict) else x).items() + if k != "_overlay_intrinsic" + } for x in view_fills ], "pnl_summary": gr.get("pnl_summary"), diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index 083c933..2f7d215 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -1035,6 +1035,12 @@ class BinanceLiveExecutor(Matcher): ), ) now = int(time.time() * 1000) + settle_px = None + if reason == "expiry": + try: + settle_px = self._close_spot_px(get_session().snapshot()) + except Exception: + settle_px = None for i, (leg, inst, qty, contracts) in enumerate(legs): if not inst: continue @@ -1042,6 +1048,29 @@ class BinanceLiveExecutor(Matcher): px, fee, notional, cash = self._live_option_settlement_fill( option_inst_id=inst, qty_eth=qty, group_id=group_id ) + if settle_px is not None and qty > 0: + if leg == "option": + side = str(pos.get("option_side") or "call") + strike = self._group_strike(group_id, inst) + else: + side = str(pos.get("option2_side") or "put") + try: + strike = float(pos.get("strike2") or 0) or None + except (TypeError, ValueError): + strike = None + if strike is not None: + iv = float( + option_intrinsic( + option_side=side, + strike=float(strike), + spot=float(settle_px), + ) + ) + tol = max(0.5, abs(iv) * 0.05) + if abs(float(px) - iv) > tol: + px = iv + notional = iv * float(qty) + cash = notional - float(fee or 0) else: px, fee, notional, cash = 0.0, 0.0, 0.0, 0.0 if abs(cash) > 1e-12: @@ -1082,12 +1111,6 @@ class BinanceLiveExecutor(Matcher): ) summary = summarize_fills_pnl(list(fill_rows)) net = float(summary.get("net_pnl") or 0.0) - settle_px = None - if reason == "expiry": - try: - settle_px = self._close_spot_px(get_session().snapshot()) - except Exception: - settle_px = None with self.db._lock: self.db._conn.execute( """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index 047c896..7df0bcd 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -1082,6 +1082,12 @@ class OkxLiveExecutor(Matcher): ), ) now = int(time.time() * 1000) + settle_px = None + if reason == "expiry": + try: + settle_px = self._close_spot_px(get_session().snapshot()) + except Exception: + settle_px = None for i, (leg, inst, qty, contracts) in enumerate(legs): if not inst: continue @@ -1089,6 +1095,30 @@ class OkxLiveExecutor(Matcher): px, fee, notional, cash = self._live_option_settlement_fill( option_inst_id=inst, qty_eth=qty, group_id=group_id ) + # 账单近零但指数已知:按内在价值对齐本地成交与入账 + if settle_px is not None and qty > 0: + if leg == "option": + side = str(pos.get("option_side") or "call") + strike = self._group_strike(group_id, inst) + else: + side = str(pos.get("option2_side") or "put") + try: + strike = float(pos.get("strike2") or 0) or None + except (TypeError, ValueError): + strike = None + if strike is not None: + iv = float( + option_intrinsic( + option_side=side, + strike=float(strike), + spot=float(settle_px), + ) + ) + tol = max(0.5, abs(iv) * 0.05) + if abs(float(px) - iv) > tol: + px = iv + notional = iv * float(qty) + cash = notional - float(fee or 0) else: px, fee, notional, cash = 0.0, 0.0, 0.0, 0.0 if abs(cash) > 1e-12: @@ -1129,12 +1159,6 @@ class OkxLiveExecutor(Matcher): ) summary = summarize_fills_pnl(list(fill_rows)) net = float(summary.get("net_pnl") or 0.0) - settle_px = None - if reason == "expiry": - try: - settle_px = self._close_spot_px(get_session().snapshot()) - except Exception: - settle_px = None with self.db._lock: self.db._conn.execute( """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, diff --git a/docs/更新说明.md b/docs/更新说明.md index 6285df6..b79dd10 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,20 @@ --- +## 2026-08-11 — 顶栏资金/胜率与期期到期账对齐 + +### 变更 + +1. 到期内在价值覆盖写回 `fills` / `realized_pnl` / `settle_index`,并补本地账本差额。 +2. 顶栏胜率按修复后净盈亏统计(不再因库内错误 realized 显示 0%)。 +3. LIVE 币安接入 `fetch_balances` 资金摘要;期期到期账单近零时按内在价值入账。 + +### 审计 + +详情已显示 +115.62,但顶栏胜率 0%、资金仍按旧账/OKX 余额 → 展示盈亏未落库,且币安成交对不上 OKX 资金条。 + +--- + ## 2026-08-11 — 期期到期盈亏:错误近零成交按内在价值覆盖 ### 变更