diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index ba3a3ae..e29b13b 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -1039,6 +1039,7 @@ class Matcher: ) 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 + open_at_ms = int(g["open_at_ms"]) if g and g["open_at_ms"] else None expiry_ms = None if expiry_ymd and len(expiry_ymd) == 6: try: @@ -1056,6 +1057,7 @@ class Matcher: return { "has_position": True, "group_id": group_id, + "open_at_ms": open_at_ms, "perp_side": perp_side, "option_side": option_side, "perp_inst_id": perp_inst_id, diff --git a/docs/更新说明.md b/docs/更新说明.md index 58e0d77..4ff5f5f 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,15 @@ --- +## 2026-07-29 — 持仓卡显示开仓时间 / 持仓时长 + +### 变更 + +1. 监控页永续、期权持仓卡增加 **开仓时间**(上海时区)与 **持仓时长**(每秒刷新)。 +2. 接口 `position.open_at_ms` 取自当前组成交组开仓时刻。 + +--- + ## 2026-07-27 — 残留期权一行一条 ### 变更 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b20a29a..144b2d9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -238,6 +238,7 @@ export type PlanState = { position: { has_position: boolean; group_id?: string; + open_at_ms?: number | null; perp_side?: string; option_side?: string; perp_inst_id?: string; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index c256f9a..8377d0c 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -41,6 +41,33 @@ function formatCountdown(msLeft: number): string { return `${m}分 ${String(sec).padStart(2, "0")}秒`; } +/** 上海时区开仓时间 */ +function fmtOpenTime(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, + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +/** 持仓时长(实时) */ +function fmtHoldDuration(openAtMs: number | null | undefined, now: number) { + if (openAtMs == null || !Number.isFinite(openAtMs) || openAtMs <= 0) return "—"; + const ms = Math.max(0, now - openAtMs); + 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}秒`; +} + const PHASE_ZH: Record = { idle: "空闲", wait_signal: "等待信号", @@ -132,6 +159,9 @@ export default function PlanPage() { plan?.mode === "LIVE" ? `实盘·${(snap?.exchange || "okx").toUpperCase()}` : "模拟盘"; + const openAtMs = pos?.open_at_ms ?? null; + const openTimeLabel = fmtOpenTime(openAtMs); + const holdDurationLabel = open ? fmtHoldDuration(openAtMs, nowMs) : "—"; return (
@@ -429,6 +459,14 @@ export default function PlanPage() { 波动比例 {fmt(movePct, 2)}%
+
+ 开仓时间 + {openTimeLabel} +
+
+ 持仓时长 + {holdDurationLabel} +
@@ -482,6 +520,14 @@ export default function PlanPage() { 初始权利金 {fmt(pos?.initial_premium)} +
+ 开仓时间 + {openTimeLabel} +
+
+ 持仓时长 + {holdDurationLabel} +