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],
|
||||
"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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user