Add delete for closed trade records.

Only closed groups can be removed from local history; equity is unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-07 16:06:36 +08:00
parent ec244c63c6
commit 42e56d940c
5 changed files with 331 additions and 68 deletions
+43
View File
@@ -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}
+26 -26
View File
@@ -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
+92
View File
@@ -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()
+130 -42
View File
@@ -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<string | null>(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 (
<button
key={g.group_id}
type="button"
className="trade-row"
onClick={() => openGroup(g.group_id)}
>
<div className="trade-row-main mono">
<span className="trade-row-id">{g.group_id}</span>
<span className="trade-row-meta">
{statusZh(g.status)} ·{" "}
{positionSidesZh(g.perp_side, g.option_side)}
</span>
<span className="trade-row-times">
{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`
: ""}
</span>
</div>
<div className={`trade-row-pnl mono ${pnlClass(listPnl)}`}>
<span> {fmt(listPnl)}</span>
{g.close_reason ? (
<span className="trade-row-reason">
{closeReasonZh(g.close_reason)}
<div key={g.group_id} className="trade-row-wrap">
<button
type="button"
className="trade-row"
onClick={() => openGroup(g.group_id)}
>
<div className="trade-row-main mono">
<span className="trade-row-id">{g.group_id}</span>
<span className="trade-row-meta">
{statusZh(g.status)} ·{" "}
{positionSidesZh(g.perp_side, g.option_side)}
</span>
) : null}
</div>
</button>
<span className="trade-row-times">
{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`
: ""}
</span>
</div>
<div className={`trade-row-pnl mono ${pnlClass(listPnl)}`}>
<span> {fmt(listPnl)}</span>
{g.close_reason ? (
<span className="trade-row-reason">
{closeReasonZh(g.close_reason)}
</span>
) : null}
</div>
</button>
{canDelete ? (
<button
type="button"
className="btn ghost trade-row-delete"
disabled={deletingId === g.group_id}
onClick={() => void deleteGroup(g.group_id, g.status)}
>
{deletingId === g.group_id ? "删除中…" : "删除"}
</button>
) : null}
</div>
);
}
@@ -262,7 +313,7 @@ export default function TradesPage() {
<div className="trades-page">
<h2 style={{ marginTop: 0 }}></h2>
<p className="trade-hold-note">
</p>
{err ? <div className="err">{err}</div> : null}
@@ -296,6 +347,7 @@ export default function TradesPage() {
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
@@ -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 (
<tr
key={g.group_id}
@@ -333,6 +387,25 @@ export default function TradesPage() {
? closeReasonZh(g.close_reason)
: "—"}
</td>
<td
className="trade-table-actions"
onClick={(ev) => ev.stopPropagation()}
>
{canDelete ? (
<button
type="button"
className="btn ghost trade-delete-btn"
disabled={deletingId === g.group_id}
onClick={() =>
void deleteGroup(g.group_id, g.status)
}
>
{deletingId === g.group_id ? "删除中…" : "删除"}
</button>
) : (
<span className="meta"></span>
)}
</td>
</tr>
);
})}
@@ -381,14 +454,29 @@ export default function TradesPage() {
<h3 id={titleId} className="modal-title mono">
{selected}
</h3>
<button
type="button"
className="btn ghost modal-close"
onClick={closeDetail}
aria-label="关闭"
>
</button>
<div className="modal-head-actions">
{selectedGroup &&
String(selectedGroup.status).toLowerCase() === "closed" ? (
<button
type="button"
className="btn danger"
disabled={deletingId === selected}
onClick={() =>
void deleteGroup(selected, selectedGroup.status)
}
>
{deletingId === selected ? "删除中…" : "删除记录"}
</button>
) : null}
<button
type="button"
className="btn ghost modal-close"
onClick={closeDetail}
aria-label="关闭"
>
</button>
</div>
</div>
<div className="modal-body">
+40
View File
@@ -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;