From 42e56d940c0e0a4861fb3aac36f92ff0b04a9706 Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 7 Aug 2026 16:06:36 +0800 Subject: [PATCH] Add delete for closed trade records. Only closed groups can be removed from local history; equity is unchanged. Co-authored-by: Cursor --- backend/app/api/trades.py | 43 ++++++ backend/app/strategy/open_capacity.py | 52 +++---- backend/tests/test_delete_trade_group.py | 92 ++++++++++++ frontend/src/pages/Trades.tsx | 172 +++++++++++++++++------ frontend/src/styles/app.css | 40 ++++++ 5 files changed, 331 insertions(+), 68 deletions(-) create mode 100644 backend/tests/test_delete_trade_group.py diff --git a/backend/app/api/trades.py b/backend/app/api/trades.py index 62a0f19..217d3d8 100644 --- a/backend/app/api/trades.py +++ b/backend/app/api/trades.py @@ -190,3 +190,46 @@ async def group_detail( "fills": [_row(x) for x in fills], "pnl_summary": gr.get("pnl_summary"), } + + +@router.delete("/groups/{group_id}") +async def delete_group( + group_id: str, _user: Annotated[str, Depends(require_user)] +) -> dict: + """删除一条已平仓交易记录(组/成交/残留/相关账本流水)。不回滚权益。""" + db = get_db() + g = db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,)) + if g is None: + raise HTTPException(status_code=404, detail="group not found") + status = str(g["status"] or "").lower() + if status != "closed": + raise HTTPException( + status_code=409, + detail="只能删除已平仓记录;持仓中或开仓中的组不可删", + ) + pos = db.fetchone("SELECT group_id FROM positions WHERE id=1") + if pos and str(pos["group_id"] or "") == group_id: + raise HTTPException( + status_code=409, + detail="当前持仓仍引用该组,不可删除", + ) + with db._lock: + db._conn.execute("DELETE FROM fills WHERE group_id=?", (group_id,)) + db._conn.execute( + "DELETE FROM residual_options WHERE group_id=?", (group_id,) + ) + db._conn.execute( + "DELETE FROM ledger_entries WHERE group_id=?", (group_id,) + ) + cur = db._conn.execute( + "DELETE FROM groups WHERE group_id=? AND status='closed'", + (group_id,), + ) + if cur.rowcount <= 0: + db._conn.rollback() + raise HTTPException( + status_code=409, + detail="删除失败:组状态已变更", + ) + db._conn.commit() + return {"ok": True, "group_id": group_id} diff --git a/backend/app/strategy/open_capacity.py b/backend/app/strategy/open_capacity.py index 2d9073f..54e3b6f 100644 --- a/backend/app/strategy/open_capacity.py +++ b/backend/app/strategy/open_capacity.py @@ -139,34 +139,34 @@ def assess_open_capacity( idx, ask_book = _index_and_option_ask() ask = float(option_ask) if option_ask is not None and float(option_ask) > 0 else ask_book - if hedge == "option_option": - ca = float(call_ask) if call_ask is not None and float(call_ask) > 0 else None - pa = float(put_ask) if put_ask is not None and float(put_ask) > 0 else None - if ca is None or pa is None: - # 回退:用监控对 call/put 卖一 - try: - from .session import get_session + if hedge == "option_option": + ca = float(call_ask) if call_ask is not None and float(call_ask) > 0 else None + pa = float(put_ask) if put_ask is not None and float(put_ask) > 0 else None + if ca is None or pa is None: + # 回退:用监控对 call/put 卖一 + try: + from .session import get_session - snap = get_session().snapshot() - if ca is None and snap.call and snap.call.ask: - ca = float(snap.call.ask) - if pa is None and snap.put and snap.put.ask: - pa = float(snap.put.ask) - except Exception: - pass - cush = float( - ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion) - or s.oo_budget_cushion - ) - cush = min(1.0, max(0.5, cush)) - if ca is not None and pa is not None and ca > 0 and pa > 0: - # 与定仓一致:按预留后的权利金需求估资金门 - premium_need = (ca + pa) * opt_qty * (1.0 + fee_rate) * cush - else: - premium_need = None - margin_need = 0.0 - perp_qty = 0.0 + snap = get_session().snapshot() + if ca is None and snap.call and snap.call.ask: + ca = float(snap.call.ask) + if pa is None and snap.put and snap.put.ask: + pa = float(snap.put.ask) + except Exception: + pass + cush = float( + ledger.get_setting_float("oo_budget_cushion", s.oo_budget_cushion) + or s.oo_budget_cushion + ) + cush = min(1.0, max(0.5, cush)) + if ca is not None and pa is not None and ca > 0 and pa > 0: + # 与定仓一致:按预留后的权利金需求估资金门 + premium_need = (ca + pa) * opt_qty * (1.0 + fee_rate) * cush else: + premium_need = None + margin_need = 0.0 + perp_qty = 0.0 + else: margin_need = (float(idx) * perp_qty / lev) if idx and idx > 0 else None premium_need = ( float(ask) * opt_qty * (1.0 + fee_rate) if ask is not None and ask > 0 else None diff --git a/backend/tests/test_delete_trade_group.py b/backend/tests/test_delete_trade_group.py new file mode 100644 index 0000000..dfe03ba --- /dev/null +++ b/backend/tests/test_delete_trade_group.py @@ -0,0 +1,92 @@ +"""删除单条已平仓交易记录。""" + +from __future__ import annotations + +import asyncio + +import pytest +from fastapi import HTTPException + +from app.api.trades import delete_group +from app.models.db import Database + + +def _seed_closed(db: Database, gid: str = "G-DEL-1") -> None: + with db._lock: + db._conn.execute( + """INSERT INTO groups(group_id, status, open_at_ms, close_at_ms, realized_pnl) + VALUES (?, 'closed', 1, 2, 1.5)""", + (gid,), + ) + db._conn.execute( + """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, + fill_px, fee, slip, notional, ts_ms) + VALUES (?, 'option', 'open', 'buy', 'ETH-C', 1, 10, 0.1, 0, 10, 1)""", + (gid,), + ) + db._conn.execute( + """INSERT INTO residual_options( + group_id, option_inst_id, option_side, option_qty_eth, + option_entry_px, status, created_at_ms + ) VALUES (?, 'ETH-C', 'call', 1, 10, 'pending', 1)""", + (gid,), + ) + db._conn.execute( + """INSERT INTO ledger_entries(group_id, kind, amount, balance_after, note, ts_ms) + VALUES (?, 'pnl', 1.5, 10001.5, 't', 2)""", + (gid,), + ) + db._conn.commit() + + +def test_delete_closed_group(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "SIM") + db = Database(tmp_path / "del.db") + monkeypatch.setattr("app.api.trades.get_db", lambda: db) + _seed_closed(db) + + out = asyncio.run(delete_group("G-DEL-1", _user="t")) + assert out["ok"] is True + assert db.fetchone("SELECT COUNT(*) AS c FROM groups")["c"] == 0 + assert db.fetchone("SELECT COUNT(*) AS c FROM fills")["c"] == 0 + assert db.fetchone("SELECT COUNT(*) AS c FROM residual_options")["c"] == 0 + assert db.fetchone("SELECT COUNT(*) AS c FROM ledger_entries")["c"] == 0 + db.close() + + +def test_delete_open_group_refused(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "SIM") + db = Database(tmp_path / "del_open.db") + monkeypatch.setattr("app.api.trades.get_db", lambda: db) + with db._lock: + db._conn.execute( + """INSERT INTO groups(group_id, status, open_at_ms) + VALUES ('G-OPEN', 'open', 1)""" + ) + db._conn.commit() + + with pytest.raises(HTTPException) as ei: + asyncio.run(delete_group("G-OPEN", _user="t")) + assert ei.value.status_code == 409 + assert db.fetchone("SELECT COUNT(*) AS c FROM groups")["c"] == 1 + db.close() + + +def test_delete_active_position_refused(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("MODE", "SIM") + db = Database(tmp_path / "del_pos.db") + monkeypatch.setattr("app.api.trades.get_db", lambda: db) + _seed_closed(db, "G-POS") + with db._lock: + # status closed but still referenced (edge) + db._conn.execute( + "UPDATE positions SET group_id=?, status='open' WHERE id=1", + ("G-POS",), + ) + db._conn.commit() + + with pytest.raises(HTTPException) as ei: + asyncio.run(delete_group("G-POS", _user="t")) + assert ei.value.status_code == 409 + assert db.fetchone("SELECT COUNT(*) AS c FROM groups")["c"] == 1 + db.close() diff --git a/frontend/src/pages/Trades.tsx b/frontend/src/pages/Trades.tsx index 97626c6..7711e79 100644 --- a/frontend/src/pages/Trades.tsx +++ b/frontend/src/pages/Trades.tsx @@ -157,14 +157,24 @@ export default function TradesPage() { const [detailLoading, setDetailLoading] = useState(false); const [detailErr, setDetailErr] = useState(""); const [err, setErr] = useState(""); + const [deletingId, setDeletingId] = useState(null); - useEffect(() => { - apiFetch<{ groups: Group[] }>("/api/trades/groups") + function loadGroups() { + return apiFetch<{ groups: Group[] }>("/api/trades/groups") .then((r) => { setGroups(r.groups); - setPage(1); + return r.groups; }) - .catch((e) => setErr(e instanceof Error ? e.message : String(e))); + .catch((e) => { + setErr(e instanceof Error ? e.message : String(e)); + return null; + }); + } + + useEffect(() => { + loadGroups().then((list) => { + if (list) setPage(1); + }); }, []); useEffect(() => { @@ -220,41 +230,82 @@ export default function TradesPage() { } } + async function deleteGroup(id: string, status: string) { + if (String(status).toLowerCase() !== "closed") { + window.alert("只能删除已平仓记录"); + return; + } + if ( + !window.confirm( + `确认删除交易记录 ${id}?\n仅从本系统移除记录,不会回滚资金,也不会动交易所仓位。`, + ) + ) { + return; + } + setDeletingId(id); + setErr(""); + try { + await apiFetch<{ ok: boolean }>(`/api/trades/groups/${id}`, { + method: "DELETE", + }); + if (selected === id) closeDetail(); + setGroups((prev) => prev.filter((g) => g.group_id !== id)); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setDeletingId(null); + } + } + function renderMobileRow(g: Group) { const listPnl = groupPnl(g); const openMs = g.hold_open_at_ms ?? g.open_at_ms; const closeMs = g.hold_close_at_ms ?? g.close_at_ms; + const canDelete = String(g.status).toLowerCase() === "closed"; return ( - + + 开 {fmtTime(openMs)} · 平 {fmtTime(closeMs)} · 周期{" "} + {fmtHold(g.hold_ms)} + {g.move_points != null + ? ` · 波动 ${fmtMovePoints(g.move_points)}` + : ""} + {g.option_leverage != null + ? ` · 期权杠杆 ${fmt(g.option_leverage, 0)}x` + : ""} + + +
+ 净盈亏 {fmt(listPnl)} + {g.close_reason ? ( + + {closeReasonZh(g.close_reason)} + + ) : null} +
+ + {canDelete ? ( + + ) : null} + ); } @@ -262,7 +313,7 @@ export default function TradesPage() {

交易记录

- 持仓周期按目标平仓计时;净盈利达标且只平永续时,以永续平仓时间为准(不含期权残留至到期)。点击一条可查看明细。 + 持仓周期按目标平仓计时;净盈利达标且只平永续时,以永续平仓时间为准(不含期权残留至到期)。点击一条可查看明细。已平仓记录可删除(仅清本地记录,不回滚资金)。

{err ?
{err}
: null} @@ -296,6 +347,7 @@ export default function TradesPage() { 波动点数 盈亏金额 平仓方式 + 操作 @@ -304,6 +356,8 @@ export default function TradesPage() { const openMs = g.hold_open_at_ms ?? g.open_at_ms; const closeMs = g.hold_close_at_ms ?? g.close_at_ms; const seq = (safePage - 1) * PAGE_SIZE + i + 1; + const canDelete = + String(g.status).toLowerCase() === "closed"; return ( + ev.stopPropagation()} + > + {canDelete ? ( + + ) : ( + + )} + ); })} @@ -381,14 +454,29 @@ export default function TradesPage() {

{selected}

- +
+ {selectedGroup && + String(selectedGroup.status).toLowerCase() === "closed" ? ( + + ) : null} + +
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 1919aac..6ea6e66 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -849,6 +849,46 @@ input { font-weight: 600; } +.trade-table-actions { + width: 1%; +} + +.trade-delete-btn { + padding: 4px 10px; + font-size: 12px; +} + +.trade-row-wrap { + display: flex; + align-items: stretch; + gap: 8px; + border-bottom: 1px solid var(--line); +} + +.trade-row-wrap:last-child { + border-bottom: 0; +} + +.trade-row-wrap .trade-row { + flex: 1; + border-bottom: 0; + min-width: 0; +} + +.trade-row-delete { + align-self: center; + flex-shrink: 0; + padding: 6px 10px; + font-size: 12px; + color: var(--danger, #e85d5d); +} + +.modal-head-actions { + display: flex; + align-items: center; + gap: 8px; +} + .trade-pager { display: flex; align-items: center;