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:
@@ -190,3 +190,46 @@ async def group_detail(
|
|||||||
"fills": [_row(x) for x in fills],
|
"fills": [_row(x) for x in fills],
|
||||||
"pnl_summary": gr.get("pnl_summary"),
|
"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}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -157,14 +157,24 @@ export default function TradesPage() {
|
|||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [detailErr, setDetailErr] = useState("");
|
const [detailErr, setDetailErr] = useState("");
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
function loadGroups() {
|
||||||
apiFetch<{ groups: Group[] }>("/api/trades/groups")
|
return apiFetch<{ groups: Group[] }>("/api/trades/groups")
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setGroups(r.groups);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -220,13 +230,41 @@ 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) {
|
function renderMobileRow(g: Group) {
|
||||||
const listPnl = groupPnl(g);
|
const listPnl = groupPnl(g);
|
||||||
const openMs = g.hold_open_at_ms ?? g.open_at_ms;
|
const openMs = g.hold_open_at_ms ?? g.open_at_ms;
|
||||||
const closeMs = g.hold_close_at_ms ?? g.close_at_ms;
|
const closeMs = g.hold_close_at_ms ?? g.close_at_ms;
|
||||||
|
const canDelete = String(g.status).toLowerCase() === "closed";
|
||||||
return (
|
return (
|
||||||
|
<div key={g.group_id} className="trade-row-wrap">
|
||||||
<button
|
<button
|
||||||
key={g.group_id}
|
|
||||||
type="button"
|
type="button"
|
||||||
className="trade-row"
|
className="trade-row"
|
||||||
onClick={() => openGroup(g.group_id)}
|
onClick={() => openGroup(g.group_id)}
|
||||||
@@ -240,7 +278,9 @@ export default function TradesPage() {
|
|||||||
<span className="trade-row-times">
|
<span className="trade-row-times">
|
||||||
开 {fmtTime(openMs)} · 平 {fmtTime(closeMs)} · 周期{" "}
|
开 {fmtTime(openMs)} · 平 {fmtTime(closeMs)} · 周期{" "}
|
||||||
{fmtHold(g.hold_ms)}
|
{fmtHold(g.hold_ms)}
|
||||||
{g.move_points != null ? ` · 波动 ${fmtMovePoints(g.move_points)}` : ""}
|
{g.move_points != null
|
||||||
|
? ` · 波动 ${fmtMovePoints(g.move_points)}`
|
||||||
|
: ""}
|
||||||
{g.option_leverage != null
|
{g.option_leverage != null
|
||||||
? ` · 期权杠杆 ${fmt(g.option_leverage, 0)}x`
|
? ` · 期权杠杆 ${fmt(g.option_leverage, 0)}x`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -255,6 +295,17 @@ export default function TradesPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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">
|
<div className="trades-page">
|
||||||
<h2 style={{ marginTop: 0 }}>交易记录</h2>
|
<h2 style={{ marginTop: 0 }}>交易记录</h2>
|
||||||
<p className="trade-hold-note">
|
<p className="trade-hold-note">
|
||||||
持仓周期按目标平仓计时;净盈利达标且只平永续时,以永续平仓时间为准(不含期权残留至到期)。点击一条可查看明细。
|
持仓周期按目标平仓计时;净盈利达标且只平永续时,以永续平仓时间为准(不含期权残留至到期)。点击一条可查看明细。已平仓记录可删除(仅清本地记录,不回滚资金)。
|
||||||
</p>
|
</p>
|
||||||
{err ? <div className="err">{err}</div> : null}
|
{err ? <div className="err">{err}</div> : null}
|
||||||
|
|
||||||
@@ -296,6 +347,7 @@ export default function TradesPage() {
|
|||||||
<th>波动点数</th>
|
<th>波动点数</th>
|
||||||
<th>盈亏金额</th>
|
<th>盈亏金额</th>
|
||||||
<th>平仓方式</th>
|
<th>平仓方式</th>
|
||||||
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -304,6 +356,8 @@ export default function TradesPage() {
|
|||||||
const openMs = g.hold_open_at_ms ?? g.open_at_ms;
|
const openMs = g.hold_open_at_ms ?? g.open_at_ms;
|
||||||
const closeMs = g.hold_close_at_ms ?? g.close_at_ms;
|
const closeMs = g.hold_close_at_ms ?? g.close_at_ms;
|
||||||
const seq = (safePage - 1) * PAGE_SIZE + i + 1;
|
const seq = (safePage - 1) * PAGE_SIZE + i + 1;
|
||||||
|
const canDelete =
|
||||||
|
String(g.status).toLowerCase() === "closed";
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={g.group_id}
|
key={g.group_id}
|
||||||
@@ -333,6 +387,25 @@ export default function TradesPage() {
|
|||||||
? closeReasonZh(g.close_reason)
|
? closeReasonZh(g.close_reason)
|
||||||
: "—"}
|
: "—"}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -381,6 +454,20 @@ export default function TradesPage() {
|
|||||||
<h3 id={titleId} className="modal-title mono">
|
<h3 id={titleId} className="modal-title mono">
|
||||||
{selected}
|
{selected}
|
||||||
</h3>
|
</h3>
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn ghost modal-close"
|
className="btn ghost modal-close"
|
||||||
@@ -390,6 +477,7 @@ export default function TradesPage() {
|
|||||||
关闭
|
关闭
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
{detailLoading ? (
|
{detailLoading ? (
|
||||||
|
|||||||
@@ -849,6 +849,46 @@ input {
|
|||||||
font-weight: 600;
|
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 {
|
.trade-pager {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user