diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 9876512..e66fe8c 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -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, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index db84811..01a5daa 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 8b9c100..9f3d2ad 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -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 = { + idle: "空闲", + wait_signal: "等待信号", + opening: "开仓中", + open: "持仓中", + closing: "平仓中", + resting: "休息中", + paused: "已暂停", + stopped: "已停开", + outside_window: "窗外", + liquidity_wait: "流动性等待", +}; + export default function PlanPage() { const [snap, setSnap] = useState(null); const [plan, setPlan] = useState(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 = { - 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 (
@@ -125,7 +131,7 @@ export default function PlanPage() { {busy ? {busy}… : null}
-
+
策略 @@ -154,29 +160,24 @@ export default function PlanPage() {
权益 - {fmt(plan?.ledger?.equity)} / 可用 {fmt(plan?.ledger?.available)} + + {fmt(plan?.ledger?.equity)} / 可用 {fmt(plan?.ledger?.available)} +
当前组 {pos?.group_id || "—"}
- 方向 - - {pos?.has_position - ? `永续${pos.perp_side === "long" ? "多" : "空"} + 买${pos.option_side === "call" ? "Call" : "Put"}` - : "—"}{" "} - {biasTag} - -
-
- 初始权利金 - {fmt(pos?.initial_premium)} + 信号方向 + {biasTag}
永续浮盈 / 距覆盖 - {fmt(pos?.perp_upl)} / {fmt(pos?.premium_gap)} + {fmt(pos?.perp_upl)} + {" / "} + {fmt(pos?.premium_gap)}
@@ -193,6 +194,112 @@ export default function PlanPage() {
) : null} + +
+ {open ? ( + <> +
+
+
+ {pos?.perp_inst_id || "ETH-USDT-SWAP"} + + {pos?.perp_side === "long" ? "做多" : "做空"} + +
+
+
+ 永续持仓 + 组 {pos?.group_id} + 数量 {fmt(pos?.perp_qty_eth, 2)} ETH +
+
+
+ 开仓价 + {fmt(pos?.perp_entry_px)} +
+
+ 可平价 + {fmt(pos?.perp_mark_px)} +
+
+ 浮盈亏 + + {fmt(pos?.perp_upl)} + +
+
+ 指数 + {fmt(pos?.index_px)} +
+
+ 开仓指数 + {fmt(pos?.entry_index_px)} +
+
+ 波动点数 + {fmt(move, 1)} +
+
+
+ +
+
+
+ {pos?.option_inst_id || "期权"} + + {pos?.option_side === "call" ? "看涨 Call" : "看跌 Put"} + +
+
+
+ 期权持仓 + 到期 {pos?.expiry_ymd || "—"} + 行权 {fmt(pos?.strike, 0)} + + {fmt(pos?.option_qty_eth, 2)} ETH · {fmt(pos?.option_qty_contracts, 0)} 张 + +
+
+
+ 开仓均价 + {fmt(pos?.option_entry_px)} +
+
+ 标记/买一 + {fmt(pos?.option_mark_px)} +
+
+ 浮盈亏 + + {fmt(pos?.option_upl)} + +
+
+ 初始权利金 + {fmt(pos?.initial_premium)} +
+
+
+ + ) : ( + <> +
永续持仓 · 暂无
+
期权持仓 · 暂无
+ + )} +
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 5bd194b..9fa8355 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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; +}