diff --git a/backend/app/api/trades.py b/backend/app/api/trades.py index 58e65f4..a9b3679 100644 --- a/backend/app/api/trades.py +++ b/backend/app/api/trades.py @@ -14,16 +14,92 @@ def _row(r: Any) -> dict: return dict(r) +def summarize_fills_pnl(fills: list[Any]) -> dict[str, float | None]: + """ + 从成交明细重算腿盈亏与净盈亏。 + 价差盈亏按 fill_px(成交价);手续费另扣。 + 净盈亏 = 期权盈亏 + 永续盈亏 − 全部手续费(开+平)。 + """ + rows = [dict(x) for x in fills] + opt_open = next( + (f for f in rows if f.get("leg") == "option" and f.get("action") == "open"), + None, + ) + opt_close = next( + (f for f in rows if f.get("leg") == "option" and f.get("action") == "close"), + None, + ) + perp_open = next( + (f for f in rows if f.get("leg") == "perp" and f.get("action") == "open"), + None, + ) + perp_close = next( + (f for f in rows if f.get("leg") == "perp" and f.get("action") == "close"), + None, + ) + + option_pnl: float | None = None + if opt_open and opt_close: + qty = float(opt_open.get("qty_eth") or opt_close.get("qty_eth") or 0) + option_pnl = (float(opt_close["fill_px"]) - float(opt_open["fill_px"])) * qty + + perp_pnl: float | None = None + if perp_open and perp_close: + qty = float(perp_open.get("qty_eth") or perp_close.get("qty_eth") or 0) + side = str(perp_open.get("side") or "") + o = float(perp_open["fill_px"]) + c = float(perp_close["fill_px"]) + if side == "long": + perp_pnl = (c - o) * qty + else: + perp_pnl = (o - c) * qty + + fees_total = sum(float(f.get("fee") or 0) for f in rows) + gross = None + net = None + if option_pnl is not None and perp_pnl is not None: + gross = option_pnl + perp_pnl + net = gross - fees_total + elif option_pnl is not None: + gross = option_pnl + net = option_pnl - fees_total + elif perp_pnl is not None: + gross = perp_pnl + net = perp_pnl - fees_total + + return { + "option_pnl": option_pnl, + "perp_pnl": perp_pnl, + "fees_total": fees_total, + "gross_pnl": gross, + "net_pnl": net, + } + + @router.get("/groups") async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict: - rows = get_db().fetchall( - "SELECT * FROM groups ORDER BY open_at_ms DESC LIMIT 200" - ) - return {"groups": [_row(x) for x in rows]} + db = get_db() + rows = db.fetchall("SELECT * FROM groups ORDER BY open_at_ms DESC LIMIT 200") + groups = [] + for r in rows: + g = _row(r) + if g.get("status") == "closed": + fills = db.fetchall( + "SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", + (g["group_id"],), + ) + summary = summarize_fills_pnl(fills) + g["pnl_summary"] = summary + if summary.get("net_pnl") is not None: + g["net_pnl"] = summary["net_pnl"] + groups.append(g) + return {"groups": groups} @router.get("/groups/{group_id}") -async def group_detail(group_id: str, _user: Annotated[str, Depends(require_user)]) -> dict: +async def group_detail( + group_id: str, _user: Annotated[str, Depends(require_user)] +) -> dict: db = get_db() g = db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,)) if g is None: @@ -31,4 +107,9 @@ async def group_detail(group_id: str, _user: Annotated[str, Depends(require_user fills = db.fetchall( "SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,) ) - return {"group": _row(g), "fills": [_row(x) for x in fills]} + summary = summarize_fills_pnl(fills) + return { + "group": _row(g), + "fills": [_row(x) for x in fills], + "pnl_summary": summary, + } diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 3359a26..1c660a6 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -483,6 +483,17 @@ class Matcher: ) net = perp_pnl + opt_pnl - pf.fee - of.fee + # 组已累计开仓手续费;实现净盈亏扣开+平全部手续费 + open_fees = float( + ( + self.db.fetchone( + "SELECT fees FROM groups WHERE group_id=?", (group_id,) + ) + or {"fees": 0} + )["fees"] + or 0 + ) + net_after_all_fees = perp_pnl + opt_pnl - open_fees - pf.fee - of.fee now = int(time.time() * 1000) with self.db._lock: @@ -535,7 +546,7 @@ class Matcher: self.db._conn.execute( """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, fees=?, slip_cost=?, note=NULL WHERE group_id=?""", - ("closed", now, reason, net, fees, slip, group_id), + ("closed", now, reason, net_after_all_fees, fees, slip, group_id), ) self.db._conn.execute( """UPDATE positions SET @@ -554,7 +565,9 @@ class Matcher: "reason": reason, "perp_pnl": perp_pnl, "option_pnl": opt_pnl, - "net": net, + "net": net_after_all_fees, + "fees_open": open_fees, + "fees_close": pf.fee + of.fee, "close_sequence": ["option", "perp"], "cash_delta": opt_cash + perp_pnl - pf.fee, "option_close_bid": float(close_bid), diff --git a/frontend/src/pages/Trades.tsx b/frontend/src/pages/Trades.tsx index e1dad38..c305610 100644 --- a/frontend/src/pages/Trades.tsx +++ b/frontend/src/pages/Trades.tsx @@ -7,6 +7,14 @@ import { statusZh, } from "../labels"; +type PnlSummary = { + option_pnl: number | null; + perp_pnl: number | null; + fees_total: number; + gross_pnl: number | null; + net_pnl: number | null; +}; + type Group = { group_id: string; status: string; @@ -15,9 +23,11 @@ type Group = { perp_side: string | null; initial_premium: number; realized_pnl: number; + net_pnl?: number | null; close_reason: string | null; open_at_ms: number | null; close_at_ms: number | null; + pnl_summary?: PnlSummary; }; type Fill = { @@ -30,10 +40,21 @@ type Fill = { qty_eth: number; }; +function pnlClass(n: number | null | undefined) { + if (n == null || Number.isNaN(n)) return ""; + return n > 0 ? "pos-pnl-profit" : n < 0 ? "pos-pnl-loss" : ""; +} + +function fmt(n: number | null | undefined, digits = 2) { + if (n == null || Number.isNaN(n)) return "—"; + return Number(n).toFixed(digits); +} + export default function TradesPage() { const [groups, setGroups] = useState([]); const [selected, setSelected] = useState(null); const [fills, setFills] = useState([]); + const [summary, setSummary] = useState(null); const [err, setErr] = useState(""); useEffect(() => { @@ -44,9 +65,13 @@ export default function TradesPage() { async function openGroup(id: string) { setSelected(id); + setSummary(null); try { - const r = await apiFetch<{ fills: Fill[] }>(`/api/trades/groups/${id}`); + const r = await apiFetch<{ fills: Fill[]; pnl_summary: PnlSummary }>( + `/api/trades/groups/${id}` + ); setFills(r.fills); + setSummary(r.pnl_summary); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } @@ -60,28 +85,35 @@ export default function TradesPage() { {groups.length === 0 ? (

暂无成交组

) : ( - groups.map((g) => ( -
openGroup(g.group_id)} - > - - {g.group_id} · {statusZh(g.status)} ·{" "} - {positionSidesZh(g.perp_side, g.option_side)} - - - 盈亏 {Number(g.realized_pnl || 0).toFixed(2)} ·{" "} - {closeReasonZh(g.close_reason)} - -
- )) + groups.map((g) => { + const listPnl = + g.net_pnl ?? g.pnl_summary?.net_pnl ?? g.realized_pnl; + return ( +
openGroup(g.group_id)} + > + + {g.group_id} · {statusZh(g.status)} ·{" "} + {positionSidesZh(g.perp_side, g.option_side)} + + + 净盈亏 {fmt(listPnl)} · {closeReasonZh(g.close_reason)} + +
+ ); + }) )} {selected ? (

{selected} 成交明细

+

+ 成交价为成交均价(未预先扣费);手续费单独列出。净盈亏 = 期权盈亏 + 永续盈亏 − + 全部手续费。 +

{fills.map((f) => (
@@ -93,6 +125,40 @@ export default function TradesPage() {
))} + {summary ? ( +
+
+ 期权盈亏 + + {fmt(summary.option_pnl)} + +
+
+ 永续盈亏 + + {fmt(summary.perp_pnl)} + +
+
+ 手续费合计 + {fmt(summary.fees_total, 4)} +
+
+ + 净盈亏(扣费后) + + + {fmt(summary.net_pnl)} + +
+
+ ) : null}
) : null} diff --git a/scripts/repair_expiry_settle.py b/scripts/repair_expiry_settle.py index 0c993eb..0261e27 100644 --- a/scripts/repair_expiry_settle.py +++ b/scripts/repair_expiry_settle.py @@ -53,10 +53,6 @@ old_base = float(o_close["base_px"] or o_close["fill_px"]) # 到期对齐实盘:严格内在价值,无盘口滑点 new_base = intrinsic new_fill = intrinsic -if abs(new_base - old_base) < 1e-9 and abs(float(o_close["fill_px"]) - new_fill) < 1e-9: - print("already ok", old_base, intrinsic) - raise SystemExit(0) - qty = float(o_close["qty_eth"]) new_notional = new_fill * qty new_fee = new_notional * FEE @@ -64,6 +60,7 @@ new_slip = 0.0 old_cash = float(o_close["notional"]) - float(o_close["fee"]) new_cash = new_notional - new_fee cash_delta = new_cash - old_cash +# 即使价已修好,也继续同步 realized_pnl(扣完全部手续费) opt_entry = float(o_open["fill_px"]) opt_pnl = (new_fill - opt_entry) * qty @@ -73,7 +70,14 @@ if perp_side == "long": perp_pnl = (float(p_close["fill_px"]) - perp_entry) * float(p_open["qty_eth"]) else: perp_pnl = (perp_entry - float(p_close["fill_px"])) * float(p_open["qty_eth"]) -net = perp_pnl + opt_pnl - float(p_close["fee"]) - new_fee + +fees_all = ( + float(o_open["fee"]) + + float(p_open["fee"]) + + float(p_close["fee"]) + + new_fee +) +net_after_all_fees = perp_pnl + opt_pnl - fees_all # fees on group: replace option close fee contribution old_opt_fee = float(o_close["fee"]) @@ -93,17 +97,18 @@ con.execute( ) con.execute( "UPDATE groups SET realized_pnl=?, fees=?, slip_cost=?, note=? WHERE group_id=?", - (net, fees, slip, f"repaired_intrinsic:{intrinsic:.4f}", GROUP), + (net_after_all_fees, fees, slip, f"repaired_intrinsic:{intrinsic:.4f}", GROUP), ) con.execute( "UPDATE ledger_meta SET equity=?, available=?, updated_at_ms=? WHERE id=1", (eq, av, now), ) -con.execute( - "INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms) VALUES (?,?,?,?,?,?)", - (GROUP, "repair_option_intrinsic", cash_delta, eq, - f"repair {GROUP}: option close {old_base:.4f}->{new_base:.4f} intrinsic={intrinsic:.4f}", now), -) +if abs(cash_delta) > 1e-12: + con.execute( + "INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms) VALUES (?,?,?,?,?,?)", + (GROUP, "repair_option_intrinsic", cash_delta, eq, + f"repair {GROUP}: option close {old_base:.4f}->{new_base:.4f} intrinsic={intrinsic:.4f}", now), + ) # also fix close_option entry amount if present row = con.execute( "SELECT id, amount FROM ledger_entries WHERE group_id=? AND kind='close_option' ORDER BY id DESC LIMIT 1", @@ -121,9 +126,12 @@ print({ "intrinsic": intrinsic, "old_base": old_base, "new_base": new_base, + "option_pnl": opt_pnl, + "perp_pnl": perp_pnl, + "fees_all": fees_all, "cash_delta": cash_delta, "new_equity": eq, - "new_realized_pnl": net, + "new_realized_pnl": net_after_all_fees, }) ''' % {"db": DB, "group": GROUP}