Show option/perp PnL and fee-deducted net in trade details.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 16:14:48 +08:00
parent 21b8f587a2
commit 96e8e5cb70
4 changed files with 206 additions and 38 deletions
+86 -5
View File
@@ -14,16 +14,92 @@ def _row(r: Any) -> dict:
return dict(r) 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") @router.get("/groups")
async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict: async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict:
rows = get_db().fetchall( db = get_db()
"SELECT * FROM groups ORDER BY open_at_ms DESC LIMIT 200" 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"],),
) )
return {"groups": [_row(x) for x in rows]} 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}") @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() db = get_db()
g = db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,)) g = db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
if g is None: if g is None:
@@ -31,4 +107,9 @@ async def group_detail(group_id: str, _user: Annotated[str, Depends(require_user
fills = db.fetchall( fills = db.fetchall(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,) "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,
}
+15 -2
View File
@@ -483,6 +483,17 @@ class Matcher:
) )
net = perp_pnl + opt_pnl - pf.fee - of.fee 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) now = int(time.time() * 1000)
with self.db._lock: with self.db._lock:
@@ -535,7 +546,7 @@ class Matcher:
self.db._conn.execute( self.db._conn.execute(
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
fees=?, slip_cost=?, note=NULL WHERE group_id=?""", 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( self.db._conn.execute(
"""UPDATE positions SET """UPDATE positions SET
@@ -554,7 +565,9 @@ class Matcher:
"reason": reason, "reason": reason,
"perp_pnl": perp_pnl, "perp_pnl": perp_pnl,
"option_pnl": opt_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"], "close_sequence": ["option", "perp"],
"cash_delta": opt_cash + perp_pnl - pf.fee, "cash_delta": opt_cash + perp_pnl - pf.fee,
"option_close_bid": float(close_bid), "option_close_bid": float(close_bid),
+72 -6
View File
@@ -7,6 +7,14 @@ import {
statusZh, statusZh,
} from "../labels"; } 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 = { type Group = {
group_id: string; group_id: string;
status: string; status: string;
@@ -15,9 +23,11 @@ type Group = {
perp_side: string | null; perp_side: string | null;
initial_premium: number; initial_premium: number;
realized_pnl: number; realized_pnl: number;
net_pnl?: number | null;
close_reason: string | null; close_reason: string | null;
open_at_ms: number | null; open_at_ms: number | null;
close_at_ms: number | null; close_at_ms: number | null;
pnl_summary?: PnlSummary;
}; };
type Fill = { type Fill = {
@@ -30,10 +40,21 @@ type Fill = {
qty_eth: number; 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() { export default function TradesPage() {
const [groups, setGroups] = useState<Group[]>([]); const [groups, setGroups] = useState<Group[]>([]);
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
const [fills, setFills] = useState<Fill[]>([]); const [fills, setFills] = useState<Fill[]>([]);
const [summary, setSummary] = useState<PnlSummary | null>(null);
const [err, setErr] = useState(""); const [err, setErr] = useState("");
useEffect(() => { useEffect(() => {
@@ -44,9 +65,13 @@ export default function TradesPage() {
async function openGroup(id: string) { async function openGroup(id: string) {
setSelected(id); setSelected(id);
setSummary(null);
try { 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); setFills(r.fills);
setSummary(r.pnl_summary);
} catch (e) { } catch (e) {
setErr(e instanceof Error ? e.message : String(e)); setErr(e instanceof Error ? e.message : String(e));
} }
@@ -60,7 +85,10 @@ export default function TradesPage() {
{groups.length === 0 ? ( {groups.length === 0 ? (
<p style={{ color: "var(--muted)" }}></p> <p style={{ color: "var(--muted)" }}></p>
) : ( ) : (
groups.map((g) => ( groups.map((g) => {
const listPnl =
g.net_pnl ?? g.pnl_summary?.net_pnl ?? g.realized_pnl;
return (
<div <div
key={g.group_id} key={g.group_id}
className="kv" className="kv"
@@ -71,17 +99,21 @@ export default function TradesPage() {
{g.group_id} · {statusZh(g.status)} ·{" "} {g.group_id} · {statusZh(g.status)} ·{" "}
{positionSidesZh(g.perp_side, g.option_side)} {positionSidesZh(g.perp_side, g.option_side)}
</span> </span>
<span className="mono"> <span className={`mono ${pnlClass(listPnl)}`}>
{Number(g.realized_pnl || 0).toFixed(2)} ·{" "} {fmt(listPnl)} · {closeReasonZh(g.close_reason)}
{closeReasonZh(g.close_reason)}
</span> </span>
</div> </div>
)) );
})
)} )}
</div> </div>
{selected ? ( {selected ? (
<div className="card"> <div className="card">
<h3 style={{ marginTop: 0 }}>{selected} </h3> <h3 style={{ marginTop: 0 }}>{selected} </h3>
<p style={{ color: "var(--muted)", fontSize: 13, marginTop: 0 }}>
= +
</p>
{fills.map((f) => ( {fills.map((f) => (
<div key={f.id} className="kv"> <div key={f.id} className="kv">
<span className="mono"> <span className="mono">
@@ -93,6 +125,40 @@ export default function TradesPage() {
</span> </span>
</div> </div>
))} ))}
{summary ? (
<div
style={{
marginTop: 14,
paddingTop: 12,
borderTop: "1px solid var(--border, #333)",
}}
>
<div className="kv">
<span></span>
<span className={`mono ${pnlClass(summary.option_pnl)}`}>
{fmt(summary.option_pnl)}
</span>
</div>
<div className="kv">
<span></span>
<span className={`mono ${pnlClass(summary.perp_pnl)}`}>
{fmt(summary.perp_pnl)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmt(summary.fees_total, 4)}</span>
</div>
<div className="kv">
<span>
<strong></strong>
</span>
<span className={`mono ${pnlClass(summary.net_pnl)}`}>
<strong>{fmt(summary.net_pnl)}</strong>
</span>
</div>
</div>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
+15 -7
View File
@@ -53,10 +53,6 @@ old_base = float(o_close["base_px"] or o_close["fill_px"])
# 到期对齐实盘:严格内在价值,无盘口滑点 # 到期对齐实盘:严格内在价值,无盘口滑点
new_base = intrinsic new_base = intrinsic
new_fill = 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"]) qty = float(o_close["qty_eth"])
new_notional = new_fill * qty new_notional = new_fill * qty
new_fee = new_notional * FEE new_fee = new_notional * FEE
@@ -64,6 +60,7 @@ new_slip = 0.0
old_cash = float(o_close["notional"]) - float(o_close["fee"]) old_cash = float(o_close["notional"]) - float(o_close["fee"])
new_cash = new_notional - new_fee new_cash = new_notional - new_fee
cash_delta = new_cash - old_cash cash_delta = new_cash - old_cash
# 即使价已修好,也继续同步 realized_pnl(扣完全部手续费)
opt_entry = float(o_open["fill_px"]) opt_entry = float(o_open["fill_px"])
opt_pnl = (new_fill - opt_entry) * qty 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"]) perp_pnl = (float(p_close["fill_px"]) - perp_entry) * float(p_open["qty_eth"])
else: else:
perp_pnl = (perp_entry - float(p_close["fill_px"])) * float(p_open["qty_eth"]) 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 # fees on group: replace option close fee contribution
old_opt_fee = float(o_close["fee"]) old_opt_fee = float(o_close["fee"])
@@ -93,12 +97,13 @@ con.execute(
) )
con.execute( con.execute(
"UPDATE groups SET realized_pnl=?, fees=?, slip_cost=?, note=? WHERE group_id=?", "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( con.execute(
"UPDATE ledger_meta SET equity=?, available=?, updated_at_ms=? WHERE id=1", "UPDATE ledger_meta SET equity=?, available=?, updated_at_ms=? WHERE id=1",
(eq, av, now), (eq, av, now),
) )
if abs(cash_delta) > 1e-12:
con.execute( con.execute(
"INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms) VALUES (?,?,?,?,?,?)", "INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms) VALUES (?,?,?,?,?,?)",
(GROUP, "repair_option_intrinsic", cash_delta, eq, (GROUP, "repair_option_intrinsic", cash_delta, eq,
@@ -121,9 +126,12 @@ print({
"intrinsic": intrinsic, "intrinsic": intrinsic,
"old_base": old_base, "old_base": old_base,
"new_base": new_base, "new_base": new_base,
"option_pnl": opt_pnl,
"perp_pnl": perp_pnl,
"fees_all": fees_all,
"cash_delta": cash_delta, "cash_delta": cash_delta,
"new_equity": eq, "new_equity": eq,
"new_realized_pnl": net, "new_realized_pnl": net_after_all_fees,
}) })
''' % {"db": DB, "group": GROUP} ''' % {"db": DB, "group": GROUP}