diff --git a/backend/app/api/hold_timing.py b/backend/app/api/hold_timing.py new file mode 100644 index 0000000..bc6f527 --- /dev/null +++ b/backend/app/api/hold_timing.py @@ -0,0 +1,76 @@ +"""交易组持仓周期:目标出场以策略平仓时刻为准;永续先平则以永续平仓为准。""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence + +# 目标平仓(含 15U / 权利金倍数 / 只平永续) +_TARGET_REASONS = frozenset({"fixed_usdt", "premium_multiple", "target_perp_only"}) + + +def _ts(v: Any) -> int | None: + if v is None: + return None + try: + n = int(v) + except (TypeError, ValueError): + return None + return n if n > 0 else None + + +def first_perp_close_ts_ms(fills: Sequence[Mapping[str, Any]]) -> int | None: + """永续平仓成交时间(目标只平永续时作为持仓结束时刻)。""" + best: int | None = None + for f in fills: + if str(f.get("leg") or "") != "perp": + continue + if str(f.get("action") or "") != "close": + continue + ts = _ts(f.get("ts_ms")) + if ts is None: + continue + if best is None or ts < best: + best = ts + return best + + +def hold_timing( + group: Mapping[str, Any], fills: Sequence[Mapping[str, Any]] +) -> dict[str, Any]: + """ + 返回展示用开仓/平仓/持仓时长。 + + - 开仓:groups.open_at_ms + - 平仓(策略持仓周期): + - `target_perp_only` / `option_residual`:永续平仓 fill 时间 + - 其它已平:groups.close_at_ms(缺则回退成交) + """ + open_ms = _ts(group.get("open_at_ms")) + status = str(group.get("status") or "") + reason = str(group.get("close_reason") or "") + group_close = _ts(group.get("close_at_ms")) + perp_close = first_perp_close_ts_ms(fills) + + use_perp = reason == "target_perp_only" or status == "option_residual" + if use_perp: + close_ms = perp_close or group_close + basis = "perp" + elif status == "open": + close_ms = None + basis = "open" + else: + close_ms = group_close + if close_ms is None and reason in _TARGET_REASONS: + close_ms = perp_close + basis = "group" + + hold_ms: int | None = None + if open_ms is not None and close_ms is not None and close_ms >= open_ms: + hold_ms = close_ms - open_ms + + return { + "hold_open_at_ms": open_ms, + "hold_close_at_ms": close_ms, + "hold_ms": hold_ms, + "hold_basis": basis, + } diff --git a/backend/app/api/trades.py b/backend/app/api/trades.py index 5cab179..bf1d279 100644 --- a/backend/app/api/trades.py +++ b/backend/app/api/trades.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException from ..models.db import get_db from ..sim.pnl import summarize_fills_pnl from .auth import require_user +from .hold_timing import hold_timing router = APIRouter(prefix="/api/trades", tags=["trades"]) @@ -15,6 +16,24 @@ def _row(r: Any) -> dict: return dict(r) +def _enrich_group(g: dict, fills: list) -> dict: + summary = summarize_fills_pnl(fills) + # LIVE:优先 groups.realized_pnl(已按交易所回写,含资金费) + if str(g.get("exec_mode") or "").upper() == "LIVE" and g.get("realized_pnl") is not None: + summary = dict(summary) + summary["net_pnl"] = float(g["realized_pnl"]) + if g.get("funding_usdt") is not None: + summary["funding_usdt"] = float(g["funding_usdt"]) + summary["pnl_source"] = "live_exchange" + g["pnl_summary"] = summary + if summary.get("net_pnl") is not None: + g["net_pnl"] = summary["net_pnl"] + elif g.get("realized_pnl") is not None: + g["net_pnl"] = float(g["realized_pnl"]) + g.update(hold_timing(g, fills)) + return g + + @router.get("/groups") async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict: db = get_db() @@ -26,20 +45,7 @@ async def list_groups(_user: Annotated[str, Depends(require_user)]) -> dict: "SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (g["group_id"],), ) - summary = summarize_fills_pnl(fills) - # LIVE:优先 groups.realized_pnl(已按交易所回写,含资金费) - if str(g.get("exec_mode") or "").upper() == "LIVE" and g.get("realized_pnl") is not None: - summary = dict(summary) - summary["net_pnl"] = float(g["realized_pnl"]) - if g.get("funding_usdt") is not None: - summary["funding_usdt"] = float(g["funding_usdt"]) - summary["pnl_source"] = "live_exchange" - g["pnl_summary"] = summary - if summary.get("net_pnl") is not None: - g["net_pnl"] = summary["net_pnl"] - elif g.get("realized_pnl") is not None: - g["net_pnl"] = float(g["realized_pnl"]) - groups.append(g) + groups.append(_enrich_group(g, fills)) return {"groups": groups} @@ -54,16 +60,9 @@ async def group_detail( fills = db.fetchall( "SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,) ) - summary = summarize_fills_pnl(fills) - gr = _row(g) - if str(gr.get("exec_mode") or "").upper() == "LIVE" and gr.get("realized_pnl") is not None: - summary = dict(summary) - summary["net_pnl"] = float(gr["realized_pnl"]) - if gr.get("funding_usdt") is not None: - summary["funding_usdt"] = float(gr["funding_usdt"]) - summary["pnl_source"] = "live_exchange" + gr = _enrich_group(_row(g), fills) return { "group": gr, "fills": [_row(x) for x in fills], - "pnl_summary": summary, + "pnl_summary": gr.get("pnl_summary"), } diff --git a/backend/app/live/binance_executor.py b/backend/app/live/binance_executor.py index d4358dd..85dfda8 100644 --- a/backend/app/live/binance_executor.py +++ b/backend/app/live/binance_executor.py @@ -1017,10 +1017,11 @@ class BinanceLiveExecutor(Matcher): ), ) self.db._conn.execute( - """UPDATE groups SET status=?, close_reason=?, realized_pnl=?, + """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, fees=?, slip_cost=?, note=?, exec_mode=? WHERE group_id=?""", ( "option_residual", + now, reason, interim_net, fees, diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index c2a1f94..fc65d16 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -1017,10 +1017,11 @@ class OkxLiveExecutor(Matcher): ), ) self.db._conn.execute( - """UPDATE groups SET status=?, close_reason=?, realized_pnl=?, + """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, fees=?, slip_cost=?, note=?, exec_mode=? WHERE group_id=?""", ( "option_residual", + now, reason, interim_net, fees, diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 50192cd..ba3a3ae 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -729,10 +729,11 @@ class Matcher: ), ) self.db._conn.execute( - """UPDATE groups SET status=?, close_reason=?, realized_pnl=?, + """UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?, fees=?, slip_cost=?, note=? WHERE group_id=?""", ( "option_residual", + now, reason, interim_net, fees, @@ -898,7 +899,8 @@ class Matcher: ), ) self.db._conn.execute( - """UPDATE groups SET status=?, close_at_ms=?, realized_pnl=?, fees=?, slip_cost=? + """UPDATE groups SET status=?, close_at_ms=COALESCE(close_at_ms, ?), + realized_pnl=?, fees=?, slip_cost=? WHERE group_id=?""", ("closed", now_ms, float(net), fees, slip, group_id), ) diff --git a/backend/tests/test_hold_timing.py b/backend/tests/test_hold_timing.py new file mode 100644 index 0000000..79b9c4d --- /dev/null +++ b/backend/tests/test_hold_timing.py @@ -0,0 +1,63 @@ +"""hold_timing unit tests.""" + +from backend.app.api.hold_timing import hold_timing + + +def test_hold_target_perp_only_uses_perp_close(): + g = { + "open_at_ms": 1_000, + "close_at_ms": 9_000, # later residual settle would have overwritten + "status": "closed", + "close_reason": "target_perp_only", + } + fills = [ + {"leg": "option", "action": "open", "ts_ms": 1_000}, + {"leg": "perp", "action": "open", "ts_ms": 1_100}, + {"leg": "perp", "action": "close", "ts_ms": 5_000}, + {"leg": "option", "action": "close", "ts_ms": 9_000}, + ] + h = hold_timing(g, fills) + assert h["hold_open_at_ms"] == 1_000 + assert h["hold_close_at_ms"] == 5_000 + assert h["hold_ms"] == 4_000 + assert h["hold_basis"] == "perp" + + +def test_hold_option_residual_uses_perp(): + g = { + "open_at_ms": 100, + "close_at_ms": 500, + "status": "option_residual", + "close_reason": "target_perp_only", + } + fills = [ + {"leg": "perp", "action": "close", "ts_ms": 500}, + ] + h = hold_timing(g, fills) + assert h["hold_close_at_ms"] == 500 + assert h["hold_ms"] == 400 + + +def test_hold_dual_leg_uses_group_close(): + g = { + "open_at_ms": 100, + "close_at_ms": 800, + "status": "closed", + "close_reason": "fixed_usdt", + } + fills = [ + {"leg": "option", "action": "close", "ts_ms": 790}, + {"leg": "perp", "action": "close", "ts_ms": 800}, + ] + h = hold_timing(g, fills) + assert h["hold_close_at_ms"] == 800 + assert h["hold_ms"] == 700 + assert h["hold_basis"] == "group" + + +def test_hold_open_no_close(): + g = {"open_at_ms": 100, "close_at_ms": None, "status": "open", "close_reason": None} + h = hold_timing(g, []) + assert h["hold_close_at_ms"] is None + assert h["hold_ms"] is None + assert h["hold_basis"] == "open" diff --git a/docs/更新说明.md b/docs/更新说明.md index b988ff4..dd9154d 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,17 @@ --- +## 2026-07-27 — 交易记录:开仓/平仓时间与持仓周期 + +### 变更 + +1. 交易记录列表与明细展示:**开仓时间、平仓时间、持仓周期**(上海时区)。 +2. **持仓周期按目标平仓计时**:净盈利达标(含默认 15U)出场为准。 +3. **只平永续 / 期权残留**:平仓时间与周期以**永续平仓**时刻为准,不含期权拖到到期。 +4. 只平永续时写入 `close_at_ms`;残留期权到期结算不再覆盖该策略平仓时间。 + +--- + ## 2026-07-27 — 安卓竖屏:全屏后系统锁定 ### 变更 diff --git a/frontend/src/pages/Trades.tsx b/frontend/src/pages/Trades.tsx index f856fbb..7e8fade 100644 --- a/frontend/src/pages/Trades.tsx +++ b/frontend/src/pages/Trades.tsx @@ -27,6 +27,10 @@ type Group = { 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; pnl_summary?: PnlSummary; }; @@ -50,9 +54,37 @@ function fmt(n: number | null | undefined, digits = 2) { 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 [groups, setGroups] = useState([]); const [selected, setSelected] = useState(null); + const [selectedGroup, setSelectedGroup] = useState(null); const [fills, setFills] = useState([]); const [summary, setSummary] = useState(null); const [err, setErr] = useState(""); @@ -66,12 +98,16 @@ export default function TradesPage() { async function openGroup(id: string) { setSelected(id); setSummary(null); + setSelectedGroup(null); try { - const r = await apiFetch<{ fills: Fill[]; pnl_summary: PnlSummary }>( - `/api/trades/groups/${id}` - ); + 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) { setErr(e instanceof Error ? e.message : String(e)); } @@ -80,6 +116,9 @@ export default function TradesPage() { return (

交易记录

+

+ 持仓周期按目标平仓计时;净盈利达标且只平永续时,以永续平仓时间为准(不含期权残留至到期)。 +

{err ?
{err}
: null}
{groups.length === 0 ? ( @@ -88,6 +127,8 @@ export default function TradesPage() { 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 (
净盈亏 {fmt(listPnl)} @@ -118,6 +163,31 @@ export default function TradesPage() { {selected ? (

{selected} 成交明细

+ {selectedGroup ? ( +
+
+ 开仓时间 + + {fmtTime(selectedGroup.hold_open_at_ms ?? selectedGroup.open_at_ms)} + +
+
+ 平仓时间 + + {fmtTime( + selectedGroup.hold_close_at_ms ?? selectedGroup.close_at_ms, + )} + {selectedGroup.hold_basis === "perp" ? ( + (永续) + ) : null} + +
+
+ 持仓周期 + {fmtHold(selectedGroup.hold_ms)} +
+
+ ) : null}

成交价为成交均价(未预先扣费);手续费单独列出。净盈亏 = 期权盈亏 + 永续盈亏 − 全部手续费。 diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index b09d7c5..e277abf 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -558,6 +558,32 @@ input { word-break: break-word; } +.trade-row-times { + color: var(--muted); + font-size: 12px; + line-height: 1.4; + word-break: break-word; +} + +.trade-hold-note { + margin: -4px 0 12px; + color: var(--muted); + font-size: 13px; + line-height: 1.4; +} + +.trade-hold-summary { + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--line); +} + +.trade-hold-basis { + margin-left: 6px; + color: var(--muted); + font-size: 12px; +} + .trade-row-pnl { display: flex; flex-direction: column;