Show trade open/close times and hold period by target exit.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
+22
-23
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user