feat: PnL五项拆解 (gross/fee/funding/slippage/net)

后端:
- /api/live/trades 返回 gross_pnl_r, fee_r, funding_r, slippage_r, net_pnl_r
- gross = 方向盈亏(含TP1半仓锁定)
- fee_r = 实际手续费/risk_usd
- funding_r = 不利资金费/risk_usd
- slippage_r = 滑点损失估算
- net = pnl_r(已是净值)

前端L10:
- 表头改为: Gross | Fee | FR | Slip | Net
- 颜色: gross绿/红, fee橙, FR紫, slip灰, net加粗绿/红
- 每笔交易一目了然:赚了多少、扣了多少、净剩多少
This commit is contained in:
root 2026-03-02 10:13:51 +00:00
parent ab27e5a4da
commit fb0c3806b5
2 changed files with 61 additions and 10 deletions

View File

@ -1322,11 +1322,51 @@ async def live_trades(
f"SELECT id, symbol, direction, score, tier, strategy, entry_price, exit_price, "
f"entry_ts, exit_ts, pnl_r, status, tp1_hit, score_factors, "
f"binance_order_id, fill_price, slippage_bps, fee_usdt, funding_fee_usdt, "
f"protection_gap_ms, signal_to_order_ms, order_to_fill_ms "
f"protection_gap_ms, signal_to_order_ms, order_to_fill_ms, risk_distance, "
f"tp1_price, tp2_price, sl_price "
f"FROM live_trades WHERE {where} ORDER BY exit_ts DESC LIMIT ${idx}",
*params
)
return {"count": len(rows), "data": rows}
# PnL拆解
result_data = []
for r in rows:
d = dict(r)
entry = r["entry_price"] or 0
exit_p = r["exit_price"] or 0
rd = r["risk_distance"] or 1
direction = r["direction"]
tp1_hit = r["tp1_hit"]
tp1_price = r.get("tp1_price") or 0
# gross_pnl_r不含任何费用
if direction == "LONG":
raw_r = (exit_p - entry) / rd if rd > 0 else 0
else:
raw_r = (entry - exit_p) / rd if rd > 0 else 0
if tp1_hit and tp1_price:
tp1_r = abs(tp1_price - entry) / rd if rd > 0 else 0
gross_r = 0.5 * tp1_r + 0.5 * raw_r
else:
gross_r = raw_r
fee_usdt = r["fee_usdt"] or 0
funding_usdt = r["funding_fee_usdt"] or 0
risk_usd = 2 # $2=1R
fee_r = fee_usdt / risk_usd if risk_usd > 0 else 0
funding_r = abs(funding_usdt) / risk_usd if funding_usdt < 0 else 0
# slippage_r: 滑点造成的R损失
slippage_bps = r["slippage_bps"] or 0
slippage_usdt = abs(slippage_bps) / 10000 * entry * (risk_usd / rd) if rd > 0 else 0
slippage_r = slippage_usdt / risk_usd if risk_usd > 0 else 0
d["gross_pnl_r"] = round(gross_r, 4)
d["fee_r"] = round(fee_r, 4)
d["funding_r"] = round(funding_r, 4)
d["slippage_r"] = round(slippage_r, 4)
d["net_pnl_r"] = r["pnl_r"] # 已经是net
result_data.append(d)
return {"count": len(result_data), "data": result_data}
@app.get("/api/live/equity-curve")

View File

@ -482,28 +482,39 @@ function L10_TradeHistory() {
{(["all","win","loss"] as FR[]).map(r => (<button key={r} onClick={()=>setResult(r)} className={`px-2 py-0.5 rounded text-[10px] ${result===r?"bg-slate-800 text-white":"text-slate-500 hover:bg-slate-100"}`}>{r==="all"?"全部":r==="win"?"盈":"亏"}</button>))}
</div>
</div>
<div className="max-h-72 overflow-y-auto">
<div className="max-h-96 overflow-y-auto">
{trades.length === 0 ? <div className="text-center text-slate-400 text-sm py-6"></div> : (
<table className="w-full text-[10px]">
<thead className="bg-slate-50 sticky top-0"><tr className="text-slate-500">
<th className="px-1.5 py-1 text-left"></th><th className="px-1.5 py-1"></th>
<th className="px-1.5 py-1 text-right"></th><th className="px-1.5 py-1 text-right"></th><th className="px-1.5 py-1 text-right"></th>
<th className="px-1.5 py-1 text-right">PnL</th><th className="px-1.5 py-1"></th>
<th className="px-1.5 py-1 text-right"></th><th className="px-1.5 py-1 text-right"></th><th className="px-1.5 py-1 text-right"></th>
<th className="px-1.5 py-1 text-right"></th><th className="px-1.5 py-1 text-right"></th>
<th className="px-1.5 py-1 text-right">Gross</th>
<th className="px-1.5 py-1 text-right">Fee</th>
<th className="px-1.5 py-1 text-right">FR</th>
<th className="px-1.5 py-1 text-right">Slip</th>
<th className="px-1.5 py-1 text-right font-bold">Net</th>
<th className="px-1.5 py-1"></th>
<th className="px-1.5 py-1 text-right"></th>
</tr></thead>
<tbody className="divide-y divide-slate-50">
{trades.map((t: any) => {
const hm = t.exit_ts && t.entry_ts ? Math.round((t.exit_ts-t.entry_ts)/60000) : 0;
const gross = t.gross_pnl_r || 0;
const fee = t.fee_r || 0;
const fr = t.funding_r || 0;
const slip = t.slippage_r || 0;
const net = t.net_pnl_r ?? t.pnl_r ?? 0;
return (<tr key={t.id} className="hover:bg-slate-50">
<td className="px-1.5 py-1 font-mono">{t.symbol?.replace("USDT","")}</td>
<td className={`px-1.5 py-1 text-center font-bold ${t.direction==="LONG"?"text-emerald-600":"text-red-500"}`}>{t.direction==="LONG"?"▲":"▼"}</td>
<td className="px-1.5 py-1 text-right font-mono">{fmtPrice(t.entry_price)}</td>
<td className="px-1.5 py-1 text-right font-mono">{t.fill_price?fmtPrice(t.fill_price):"-"}</td>
<td className="px-1.5 py-1 text-right font-mono">{t.exit_price?fmtPrice(t.exit_price):"-"}</td>
<td className={`px-1.5 py-1 text-right font-mono font-bold ${(t.pnl_r||0)>0?"text-emerald-600":(t.pnl_r||0)<0?"text-red-500":"text-slate-500"}`}>{(t.pnl_r||0)>0?"+":""}{(t.pnl_r||0).toFixed(2)}R</td>
<td className={`px-1.5 py-1 text-right font-mono ${gross>=0?"text-emerald-600":"text-red-500"}`}>{gross>=0?"+":""}{gross.toFixed(2)}</td>
<td className="px-1.5 py-1 text-right font-mono text-amber-600">-{fee.toFixed(2)}</td>
<td className="px-1.5 py-1 text-right font-mono text-violet-600">{fr > 0 ? `-${fr.toFixed(2)}` : "0"}</td>
<td className="px-1.5 py-1 text-right font-mono text-slate-500">{slip > 0 ? `-${slip.toFixed(2)}` : "0"}</td>
<td className={`px-1.5 py-1 text-right font-mono font-bold ${net>0?"text-emerald-600":net<0?"text-red-500":"text-slate-500"}`}>{net>0?"+":""}{net.toFixed(2)}R</td>
<td className="px-1.5 py-1 text-center"><span className={`px-1 py-0.5 rounded text-[8px] ${t.status==="tp"?"bg-emerald-100 text-emerald-700":t.status==="sl"?"bg-red-100 text-red-700":t.status==="sl_be"?"bg-amber-100 text-amber-700":"bg-slate-100 text-slate-600"}`}>{t.status==="tp"?"止盈":t.status==="sl"?"止损":t.status==="sl_be"?"保本":t.status}</span></td>
<td className={`px-1.5 py-1 text-right font-mono ${Math.abs(t.slippage_bps||0)>8?"text-red-500":"text-slate-600"}`}>{(t.slippage_bps||0).toFixed(1)}</td>
<td className="px-1.5 py-1 text-right font-mono text-amber-600">${(t.fee_usdt||0).toFixed(2)}</td>
<td className="px-1.5 py-1 text-right text-slate-400">{hm}m</td>
</tr>);
})}