Files
eth_hedge_sim/frontend/src/pages/Trades.tsx
T
dekun 8518a207a7 Show expiry settlement as index vs strike intrinsic.
Persist settle_index_px and surface formula in trade detail so expiry closes are not mistaken for book fills.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:15:22 +08:00

375 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useId, useState } from "react";
import { apiFetch } from "../api/client";
import {
closeReasonZh,
fillDescZh,
positionSidesZh,
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 ExpirySettle = {
settle_index_px: number | null;
strike: number | null;
intrinsic: number | null;
formula: string;
perp_note: string;
};
type Group = {
group_id: string;
status: string;
bias: string | null;
option_side: string | null;
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;
hold_open_at_ms?: number | null;
hold_close_at_ms?: number | null;
hold_ms?: number | null;
hold_basis?: string | null;
strike?: number | null;
settle_index_px?: number | null;
expiry_settle?: ExpirySettle | null;
pnl_summary?: PnlSummary;
};
type Fill = {
id: number;
leg: string;
action: string;
side: string;
fill_px: number;
fee: 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);
}
/** 上海时区开/平仓时间 */
function fmtTime(ms: number | null | undefined) {
if (ms == null || !Number.isFinite(ms) || ms <= 0) return "—";
return new Date(ms).toLocaleString("zh-CN", {
timeZone: "Asia/Shanghai",
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
/** 持仓周期:x时y分z秒 */
function fmtHold(ms: number | null | undefined) {
if (ms == null || !Number.isFinite(ms) || ms < 0) return "—";
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (h > 0) return `${h}${m}${s}`;
if (m > 0) return `${m}${s}`;
return `${s}`;
}
export default function TradesPage() {
const titleId = useId();
const [groups, setGroups] = useState<Group[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
const [fills, setFills] = useState<Fill[]>([]);
const [summary, setSummary] = useState<PnlSummary | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailErr, setDetailErr] = useState("");
const [err, setErr] = useState("");
useEffect(() => {
apiFetch<{ groups: Group[] }>("/api/trades/groups")
.then((r) => setGroups(r.groups))
.catch((e) => setErr(e instanceof Error ? e.message : String(e)));
}, []);
useEffect(() => {
if (!selected) return;
const onKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") closeDetail();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [selected]);
function closeDetail() {
setSelected(null);
setSelectedGroup(null);
setFills([]);
setSummary(null);
setDetailErr("");
setDetailLoading(false);
}
async function openGroup(id: string) {
setSelected(id);
setSummary(null);
setSelectedGroup(null);
setFills([]);
setDetailErr("");
setDetailLoading(true);
try {
const r = await apiFetch<{
group: Group;
fills: Fill[];
pnl_summary: PnlSummary;
}>(`/api/trades/groups/${id}`);
setFills(r.fills);
setSummary(r.pnl_summary);
setSelectedGroup(r.group);
} catch (e) {
setDetailErr(e instanceof Error ? e.message : String(e));
} finally {
setDetailLoading(false);
}
}
return (
<div className="trades-page">
<h2 style={{ marginTop: 0 }}></h2>
<p className="trade-hold-note">
</p>
{err ? <div className="err">{err}</div> : null}
<div className="card trade-list" style={{ marginBottom: 12 }}>
{groups.length === 0 ? (
<p style={{ color: "var(--muted)" }}></p>
) : (
groups.map((g) => {
const listPnl =
g.net_pnl ?? g.pnl_summary?.net_pnl ?? g.realized_pnl;
const openMs = g.hold_open_at_ms ?? g.open_at_ms;
const closeMs = g.hold_close_at_ms ?? g.close_at_ms;
return (
<button
key={g.group_id}
type="button"
className="trade-row"
onClick={() => openGroup(g.group_id)}
>
<div className="trade-row-main mono">
<span className="trade-row-id">{g.group_id}</span>
<span className="trade-row-meta">
{statusZh(g.status)} ·{" "}
{positionSidesZh(g.perp_side, g.option_side)}
</span>
<span className="trade-row-times">
{fmtTime(openMs)} · {fmtTime(closeMs)} · {" "}
{fmtHold(g.hold_ms)}
</span>
</div>
<div className={`trade-row-pnl mono ${pnlClass(listPnl)}`}>
<span> {fmt(listPnl)}</span>
{g.close_reason ? (
<span className="trade-row-reason">
{closeReasonZh(g.close_reason)}
</span>
) : null}
</div>
</button>
);
})
)}
</div>
{selected ? (
<div
className="modal-backdrop"
role="presentation"
onClick={closeDetail}
>
<div
className="modal-dialog trade-detail-modal"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(ev) => ev.stopPropagation()}
>
<div className="modal-head">
<h3 id={titleId} className="modal-title mono">
{selected}
</h3>
<button
type="button"
className="btn ghost modal-close"
onClick={closeDetail}
aria-label="关闭"
>
</button>
</div>
<div className="modal-body">
{detailLoading ? (
<p className="meta"></p>
) : null}
{detailErr ? <div className="err">{detailErr}</div> : null}
{!detailLoading && !detailErr && selectedGroup ? (
<>
<div className="trade-hold-summary">
<div className="kv">
<span></span>
<span className="mono">
{statusZh(selectedGroup.status)}
{selectedGroup.close_reason
? ` · ${closeReasonZh(selectedGroup.close_reason)}`
: ""}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{positionSidesZh(
selectedGroup.perp_side,
selectedGroup.option_side,
)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmtTime(
selectedGroup.hold_open_at_ms ??
selectedGroup.open_at_ms,
)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmtTime(
selectedGroup.hold_close_at_ms ??
selectedGroup.close_at_ms,
)}
{selectedGroup.hold_basis === "perp" ? (
<span className="trade-hold-basis"></span>
) : null}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmtHold(selectedGroup.hold_ms)}
</span>
</div>
</div>
<p className="trade-detail-hint">
{selectedGroup.close_reason === "expiry"
? "到期结算:期权按「指数 vs 行权价」的内在价值入账(非盘口);永续仍按市价平。净盈亏 = 期权盈亏 + 永续盈亏 − 全部手续费。"
: "成交价为成交均价(未预先扣费);手续费单独列出。净盈亏 = 期权盈亏 + 永续盈亏 − 全部手续费。"}
</p>
{selectedGroup.expiry_settle ? (
<div className="trade-hold-summary" style={{ marginBottom: 10 }}>
<div className="kv">
<span></span>
<span className="mono">
{fmt(selectedGroup.expiry_settle.settle_index_px)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmt(selectedGroup.expiry_settle.strike, 0)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{fmt(selectedGroup.expiry_settle.intrinsic)}
{selectedGroup.expiry_settle.formula
? ` · ${selectedGroup.expiry_settle.formula}`
: ""}
</span>
</div>
</div>
) : null}
{fills.map((f) => (
<div key={f.id} className="trade-fill kv">
<span className="mono">
{fillDescZh(
f.leg,
f.action,
f.side,
selectedGroup.close_reason,
)}
</span>
<span className="mono">
{f.fill_px.toFixed(4)} · {f.qty_eth} · {" "}
{f.fee.toFixed(4)}
</span>
</div>
))}
{summary ? (
<div className="trade-pnl-block">
<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}
</>
) : null}
</div>
</div>
</div>
) : null}
</div>
);
}