Show OKX-style perp/option position cards on plan page.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-24 17:32:57 +08:00
parent 5125c6702b
commit 1155cf082d
4 changed files with 309 additions and 29 deletions
+28 -1
View File
@@ -389,6 +389,7 @@ class Matcher:
}
gw = get_gateway()
snap = gw.snapshot()
s = get_settings()
index_px = snap.index_px
if index_px is None and snap.perp:
index_px = snap.perp.mark_px
@@ -425,11 +426,37 @@ class Matcher:
move = abs(float(index_px) - entry_idx) if index_px is not None and entry_idx else 0.0
initial_premium = float(pos["initial_premium"] or 0)
premium_gap = initial_premium - perp_upl
group_id = pos.get("group_id")
g = (
self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
if group_id
else None
)
strike = float(g["strike"]) if g and g["strike"] is not None else None
expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None
perp_inst_id = (
str(g["perp_inst_id"])
if g and g["perp_inst_id"]
else s.perp_inst_id
)
return {
"has_position": True,
"group_id": pos["group_id"],
"group_id": group_id,
"perp_side": perp_side,
"option_side": option_side,
"perp_inst_id": perp_inst_id,
"perp_entry_px": perp_entry,
"perp_qty_eth": perp_qty,
"perp_mark_px": float(mark) if mark is not None else None,
"option_inst_id": pos.get("option_inst_id"),
"option_entry_px": float(pos["option_entry_px"] or 0),
"option_qty_eth": float(pos["option_qty_eth"] or 0),
"option_qty_contracts": float(pos["option_qty_contracts"] or 0),
"option_mark_px": float(opt_mark) if opt_mark is not None else None,
"strike": strike,
"expiry_ymd": expiry_ymd,
"perp_upl": perp_upl,
"option_upl": option_upl,
"index_px": index_px,
+11
View File
@@ -129,6 +129,17 @@ export type PlanState = {
group_id?: string;
perp_side?: string;
option_side?: string;
perp_inst_id?: string;
perp_entry_px?: number;
perp_qty_eth?: number;
perp_mark_px?: number | null;
option_inst_id?: string;
option_entry_px?: number;
option_qty_eth?: number;
option_qty_contracts?: number;
option_mark_px?: number | null;
strike?: number | null;
expiry_ymd?: string | null;
perp_upl?: number;
option_upl?: number;
index_px?: number | null;
+135 -28
View File
@@ -6,6 +6,24 @@ function fmt(n: number | null | undefined, d = 2) {
return n.toFixed(d);
}
function pnlClass(n: number | null | undefined) {
if (n == null || Number.isNaN(n) || n === 0) return "";
return n > 0 ? "pos-pnl-profit" : "pos-pnl-loss";
}
const PHASE_ZH: Record<string, string> = {
idle: "空闲",
wait_signal: "等待信号",
opening: "开仓中",
open: "持仓中",
closing: "平仓中",
resting: "休息中",
paused: "已暂停",
stopped: "已停开",
outside_window: "窗外",
liquidity_wait: "流动性等待",
};
export default function PlanPage() {
const [snap, setSnap] = useState<MarketSnapshot | null>(null);
const [plan, setPlan] = useState<PlanState | null>(null);
@@ -56,22 +74,10 @@ export default function PlanPage() {
);
const pos = plan?.position;
const open = !!pos?.has_position;
const exitN = plan?.exit_move_points ?? 30;
const move = pos?.move_points ?? 0;
const phaseZh: Record<string, string> = {
idle: "空闲",
wait_signal: "等待信号",
opening: "开仓中",
open: "持仓中",
closing: "平仓中",
resting: "休息中",
paused: "已暂停",
stopped: "已停开",
outside_window: "窗外",
liquidity_wait: "流动性等待",
};
const phaseLabel = phaseZh[plan?.phase || ""] || plan?.phase || "—";
const phaseLabel = PHASE_ZH[plan?.phase || ""] || plan?.phase || "—";
return (
<div>
@@ -125,7 +131,7 @@ export default function PlanPage() {
{busy ? <span className="meta">{busy}</span> : null}
</div>
<div className="card" style={{ marginBottom: 12 }}>
<div className="card plan-summary" style={{ marginBottom: 12 }}>
<div className="kv">
<span></span>
<span>
@@ -154,29 +160,24 @@ export default function PlanPage() {
</div>
<div className="kv">
<span></span>
<span className="mono">{fmt(plan?.ledger?.equity)} / {fmt(plan?.ledger?.available)}</span>
<span className="mono">
{fmt(plan?.ledger?.equity)} / {fmt(plan?.ledger?.available)}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{pos?.group_id || "—"}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{pos?.has_position
? `永续${pos.perp_side === "long" ? "多" : "空"} + 买${pos.option_side === "call" ? "Call" : "Put"}`
: "—"}{" "}
{biasTag}
</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmt(pos?.initial_premium)}</span>
<span></span>
<span className="mono">{biasTag}</span>
</div>
<div className="kv">
<span> / </span>
<span className="mono">
{fmt(pos?.perp_upl)} / {fmt(pos?.premium_gap)}
<span className={pnlClass(pos?.perp_upl)}>{fmt(pos?.perp_upl)}</span>
{" / "}
{fmt(pos?.premium_gap)}
</span>
</div>
<div className="kv">
@@ -193,6 +194,112 @@ export default function PlanPage() {
</span>
</div>
) : null}
<div className="pos-embed-grid">
{open ? (
<>
<div className="pos-card">
<div className="pos-card-head">
<div className="pos-card-symbol">
<strong>{pos?.perp_inst_id || "ETH-USDT-SWAP"}</strong>
<span
className={
pos?.perp_side === "long"
? "pos-side-badge pos-side-long"
: "pos-side-badge pos-side-short"
}
>
{pos?.perp_side === "long" ? "做多" : "做空"}
</span>
</div>
</div>
<div className="pos-meta">
<span className="pos-meta-item"></span>
<span className="pos-meta-item"> {pos?.group_id}</span>
<span className="pos-meta-item mono"> {fmt(pos?.perp_qty_eth, 2)} ETH</span>
</div>
<div className="pos-grid">
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{fmt(pos?.perp_entry_px)}</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{fmt(pos?.perp_mark_px)}</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className={`pos-value mono ${pnlClass(pos?.perp_upl)}`}>
{fmt(pos?.perp_upl)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{fmt(pos?.index_px)}</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{fmt(pos?.entry_index_px)}</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{fmt(move, 1)}</span>
</div>
</div>
</div>
<div className="pos-card">
<div className="pos-card-head">
<div className="pos-card-symbol">
<strong>{pos?.option_inst_id || "期权"}</strong>
<span
className={
pos?.option_side === "call"
? "pos-side-badge pos-side-long"
: "pos-side-badge pos-side-short"
}
>
{pos?.option_side === "call" ? "看涨 Call" : "看跌 Put"}
</span>
</div>
</div>
<div className="pos-meta">
<span className="pos-meta-item"></span>
<span className="pos-meta-item"> {pos?.expiry_ymd || "—"}</span>
<span className="pos-meta-item"> {fmt(pos?.strike, 0)}</span>
<span className="pos-meta-item mono">
{fmt(pos?.option_qty_eth, 2)} ETH · {fmt(pos?.option_qty_contracts, 0)}
</span>
</div>
<div className="pos-grid">
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{fmt(pos?.option_entry_px)}</span>
</div>
<div className="pos-cell">
<span className="pos-label">/</span>
<span className="pos-value mono">{fmt(pos?.option_mark_px)}</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className={`pos-value mono ${pnlClass(pos?.option_upl)}`}>
{fmt(pos?.option_upl)}
</span>
</div>
<div className="pos-cell">
<span className="pos-label"></span>
<span className="pos-value mono">{fmt(pos?.initial_premium)}</span>
</div>
</div>
</div>
</>
) : (
<>
<div className="pos-empty"> · </div>
<div className="pos-empty"> · </div>
</>
)}
</div>
</div>
<div className="grid-2">
+135
View File
@@ -290,3 +290,138 @@ input {
color: var(--down);
border-color: rgba(246, 70, 93, 0.35);
}
/* OKX-style position cards (copied/adapted from crypto_monitor instance_page) */
.pos-embed-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
@media (max-width: 800px) {
.pos-embed-grid {
grid-template-columns: 1fr;
}
}
.pos-card {
background: #141923;
border: 1px solid #2a3348;
border-radius: 10px;
padding: 12px 14px;
}
.pos-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.pos-card-symbol {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}
.pos-card-symbol strong {
font-size: 0.95rem;
color: #fff;
font-weight: 600;
word-break: break-all;
}
.pos-side-badge {
padding: 3px 8px;
border-radius: 6px;
font-size: 0.72rem;
font-weight: 500;
line-height: 1.2;
}
.pos-side-long {
background: #253a6e;
color: #6eb5ff;
}
.pos-side-short {
background: #4a2230;
color: #ff8a8a;
}
.pos-meta {
font-size: 0.74rem;
color: #8b95a8;
line-height: 1.45;
margin-bottom: 12px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 0;
}
.pos-meta-item {
display: inline-flex;
align-items: center;
}
.pos-meta-item:not(:last-child)::after {
content: "|";
margin: 0 8px;
color: #3d4659;
}
.pos-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 14px;
}
@media (max-width: 520px) {
.pos-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.pos-cell {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.pos-label {
font-size: 0.72rem;
color: #7d8799;
}
.pos-value {
font-size: 0.88rem;
color: #e8ecf4;
font-weight: 500;
line-height: 1.25;
}
.pos-empty {
padding: 18px;
text-align: center;
color: #8892b0;
font-size: 0.85rem;
background: #141923;
border: 1px dashed #2a3348;
border-radius: 10px;
}
.pos-pnl-profit {
color: #7ee787 !important;
}
.pos-pnl-loss {
color: #ff8b8b !important;
}