8518a207a7
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>
375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|