Show option/perp PnL and fee-deducted net in trade details.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,17 +97,18 @@ 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),
|
||||||
)
|
)
|
||||||
con.execute(
|
if abs(cash_delta) > 1e-12:
|
||||||
|
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,
|
||||||
f"repair {GROUP}: option close {old_base:.4f}->{new_base:.4f} intrinsic={intrinsic:.4f}", now),
|
f"repair {GROUP}: option close {old_base:.4f}->{new_base:.4f} intrinsic={intrinsic:.4f}", now),
|
||||||
)
|
)
|
||||||
# also fix close_option entry amount if present
|
# also fix close_option entry amount if present
|
||||||
row = con.execute(
|
row = con.execute(
|
||||||
"SELECT id, amount FROM ledger_entries WHERE group_id=? AND kind='close_option' ORDER BY id DESC LIMIT 1",
|
"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,
|
"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}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user