feat: trade lifecycle management — close, archive, target/stop alerts
- DB: 9 new columns on trade_entry_prices (status, closed_at, close_reason,
close_note, pnl_realized, close_price, target_pct, stop_loss_pct, signal_threshold)
via ALTER TABLE migration; close_trade(), get_closed_trades(),
update_trade_exit_params() helpers; exit_defaults config key
- Backend: PATCH /trades/{id}/close, PATCH /trades/{id}/exit-params,
GET/PUT /exit-defaults, GET /closed-trades with win-rate/avg-PnL stats;
trade-mtm now computes alert_type (target_reached|stop_loss) per trade
- Journal: new "Fermés" tab with closed trades table + stats banner (win rate,
avg PnL, total PnL, best trade); open trades show Cible/Stop progress bar +
🎯/🛑 alert badges + 1-click close modal (price, reason, note)
- Config: new "Paramètres de sortie" panel — target_pct, stop_loss_pct,
signal_reversal_mode, signal_reversal_threshold with live sliders
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
from fastapi import APIRouter
|
||||
from typing import Any, Dict, List
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Any, Dict, List, Optional
|
||||
import math
|
||||
from services.database import get_macro_regime_history, get_geo_alert_history, get_trade_entry_prices, reset_journal_history, _fetch_live_prices, _trade_maturity
|
||||
from pydantic import BaseModel
|
||||
from services.database import (
|
||||
get_macro_regime_history, get_geo_alert_history, get_trade_entry_prices,
|
||||
get_closed_trades, close_trade, update_trade_exit_params,
|
||||
get_trade_entry_by_id, get_config, set_config, reset_journal_history,
|
||||
_fetch_live_prices, _trade_maturity,
|
||||
)
|
||||
import json
|
||||
|
||||
|
||||
def _sanitize(obj: Any) -> Any:
|
||||
@@ -69,6 +76,17 @@ def trade_mtm(days: int = 30):
|
||||
horizon = e.get("horizon_days") or 90
|
||||
maturity = _trade_maturity(days_held or 0, horizon)
|
||||
|
||||
defaults = _get_exit_defaults()
|
||||
target = e.get("target_pct") if e.get("target_pct") is not None else defaults.get("target_pct", 30.0)
|
||||
stop = e.get("stop_loss_pct") if e.get("stop_loss_pct") is not None else defaults.get("stop_loss_pct", -50.0)
|
||||
|
||||
alert_type = None
|
||||
if pnl_pct is not None:
|
||||
if pnl_pct >= target:
|
||||
alert_type = "target_reached"
|
||||
elif pnl_pct <= stop:
|
||||
alert_type = "stop_loss"
|
||||
|
||||
result.append({
|
||||
**e,
|
||||
"current_price": current_price,
|
||||
@@ -76,11 +94,121 @@ def trade_mtm(days: int = 30):
|
||||
"days_held": days_held,
|
||||
"direction": "bearish" if _is_bearish(e.get("strategy", "")) else "bullish",
|
||||
"maturity": maturity,
|
||||
"alert_type": alert_type,
|
||||
"target_pct": target,
|
||||
"stop_loss_pct": stop,
|
||||
})
|
||||
|
||||
return _sanitize({"trades": result, "days": days, "tickers_fetched": len(current_prices)})
|
||||
|
||||
|
||||
def _get_exit_defaults() -> Dict[str, Any]:
|
||||
raw = get_config("exit_defaults")
|
||||
if raw:
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
pass
|
||||
return {"target_pct": 30.0, "stop_loss_pct": -50.0,
|
||||
"signal_reversal_mode": "badge_only", "signal_reversal_threshold": 25}
|
||||
|
||||
|
||||
@router.get("/exit-defaults")
|
||||
def exit_defaults():
|
||||
return _get_exit_defaults()
|
||||
|
||||
|
||||
class ExitDefaultsRequest(BaseModel):
|
||||
target_pct: Optional[float] = None
|
||||
stop_loss_pct: Optional[float] = None
|
||||
signal_reversal_mode: Optional[str] = None
|
||||
signal_reversal_threshold: Optional[float] = None
|
||||
|
||||
|
||||
@router.put("/exit-defaults")
|
||||
def save_exit_defaults(body: ExitDefaultsRequest):
|
||||
current = _get_exit_defaults()
|
||||
if body.target_pct is not None:
|
||||
current["target_pct"] = body.target_pct
|
||||
if body.stop_loss_pct is not None:
|
||||
current["stop_loss_pct"] = body.stop_loss_pct
|
||||
if body.signal_reversal_mode is not None:
|
||||
current["signal_reversal_mode"] = body.signal_reversal_mode
|
||||
if body.signal_reversal_threshold is not None:
|
||||
current["signal_reversal_threshold"] = body.signal_reversal_threshold
|
||||
set_config("exit_defaults", json.dumps(current))
|
||||
return current
|
||||
|
||||
|
||||
@router.get("/closed-trades")
|
||||
def closed_trades(days: int = 180):
|
||||
trades = get_closed_trades(days)
|
||||
if not trades:
|
||||
return _sanitize({"trades": [], "days": days, "stats": {}})
|
||||
pnls = [t["pnl_realized"] for t in trades if t.get("pnl_realized") is not None]
|
||||
wins = [p for p in pnls if p >= 0]
|
||||
losses = [p for p in pnls if p < 0]
|
||||
stats = {
|
||||
"total": len(trades),
|
||||
"with_pnl": len(pnls),
|
||||
"win_rate": round(len(wins) / len(pnls) * 100, 1) if pnls else None,
|
||||
"avg_pnl": round(sum(pnls) / len(pnls), 2) if pnls else None,
|
||||
"total_pnl": round(sum(pnls), 2) if pnls else None,
|
||||
"avg_win": round(sum(wins) / len(wins), 2) if wins else None,
|
||||
"avg_loss": round(sum(losses) / len(losses), 2) if losses else None,
|
||||
"best": max(pnls, default=None),
|
||||
"worst": min(pnls, default=None),
|
||||
}
|
||||
return _sanitize({"trades": trades, "days": days, "stats": stats})
|
||||
|
||||
|
||||
class CloseTradeRequest(BaseModel):
|
||||
close_price: float
|
||||
pnl_realized: Optional[float] = None
|
||||
close_reason: str = "manual"
|
||||
close_note: str = ""
|
||||
|
||||
|
||||
class ExitParamsRequest(BaseModel):
|
||||
target_pct: Optional[float] = None
|
||||
stop_loss_pct: Optional[float] = None
|
||||
signal_threshold: Optional[float] = None
|
||||
|
||||
|
||||
@router.patch("/trades/{trade_id}/exit-params")
|
||||
def set_exit_params(trade_id: int, body: ExitParamsRequest):
|
||||
ok = update_trade_exit_params(
|
||||
trade_id,
|
||||
target_pct=body.target_pct,
|
||||
stop_loss_pct=body.stop_loss_pct,
|
||||
signal_threshold=body.signal_threshold,
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Trade non trouvé")
|
||||
return {"updated": True}
|
||||
|
||||
|
||||
@router.patch("/trades/{trade_id}/close")
|
||||
def close_trade_endpoint(trade_id: int, body: CloseTradeRequest):
|
||||
trade = get_trade_entry_by_id(trade_id)
|
||||
if not trade:
|
||||
raise HTTPException(404, "Trade non trouvé")
|
||||
if trade.get("status") == "closed":
|
||||
raise HTTPException(409, "Trade déjà clôturé")
|
||||
|
||||
pnl = body.pnl_realized
|
||||
if pnl is None and trade.get("entry_price") and body.close_price > 0:
|
||||
raw = (body.close_price - trade["entry_price"]) / trade["entry_price"] * 100
|
||||
is_bearish = any(kw in (trade.get("strategy") or "").lower()
|
||||
for kw in _BEARISH_KEYWORDS)
|
||||
pnl = round(-raw if is_bearish else raw, 2)
|
||||
|
||||
ok = close_trade(trade_id, body.close_price, pnl, body.close_reason, body.close_note)
|
||||
if not ok:
|
||||
raise HTTPException(409, "Impossible de clôturer ce trade")
|
||||
return {"closed": True, "trade_id": trade_id, "pnl_realized": pnl}
|
||||
|
||||
|
||||
@router.delete("/reset")
|
||||
def reset_journal():
|
||||
"""Truncate all journal history (trades, macro, geo, cycles). Irreversible."""
|
||||
|
||||
@@ -209,6 +209,15 @@ def init_db():
|
||||
("capital_invested", "REAL"),
|
||||
("strike_guidance", "TEXT"),
|
||||
("expiry_days_at_entry", "INTEGER"),
|
||||
("status", "TEXT DEFAULT 'open'"),
|
||||
("closed_at", "TEXT"),
|
||||
("close_reason", "TEXT"),
|
||||
("close_note", "TEXT"),
|
||||
("pnl_realized", "REAL"),
|
||||
("close_price", "REAL"),
|
||||
("target_pct", "REAL"),
|
||||
("stop_loss_pct", "REAL"),
|
||||
("signal_threshold", "REAL"),
|
||||
]:
|
||||
try:
|
||||
c.execute(f"ALTER TABLE trade_entry_prices ADD COLUMN {col} {definition}")
|
||||
@@ -216,6 +225,7 @@ def init_db():
|
||||
pass
|
||||
try:
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_tep_date ON trade_entry_prices(entry_date DESC)")
|
||||
c.execute("CREATE INDEX IF NOT EXISTS idx_tep_status ON trade_entry_prices(status, closed_at DESC)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -273,6 +283,12 @@ def init_db():
|
||||
"auto_cycle_similarity_threshold": "0.30",
|
||||
"min_ev_threshold": "0.0",
|
||||
"min_score_threshold": "0",
|
||||
"exit_defaults": json.dumps({
|
||||
"target_pct": 30.0,
|
||||
"stop_loss_pct": -50.0,
|
||||
"signal_reversal_mode": "badge_only",
|
||||
"signal_reversal_threshold": 25,
|
||||
}),
|
||||
}
|
||||
for k, v in defaults.items():
|
||||
c.execute("INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)", (k, v))
|
||||
@@ -1160,7 +1176,8 @@ def get_trade_entry_prices(days: int = 30) -> List[Dict[str, Any]]:
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM trade_entry_prices
|
||||
WHERE entry_date >= date('now', ?)
|
||||
WHERE (status IS NULL OR status = 'open')
|
||||
AND entry_date >= date('now', ?)
|
||||
ORDER BY entry_date DESC, score_at_entry DESC""",
|
||||
(f"-{days} days",)
|
||||
).fetchall()
|
||||
@@ -1168,6 +1185,57 @@ def get_trade_entry_prices(days: int = 30) -> List[Dict[str, Any]]:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_closed_trades(days: int = 180) -> List[Dict[str, Any]]:
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM trade_entry_prices
|
||||
WHERE status = 'closed'
|
||||
AND closed_at >= date('now', ?)
|
||||
ORDER BY closed_at DESC""",
|
||||
(f"-{days} days",)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def close_trade(trade_id: int, close_price: float, pnl_realized: float,
|
||||
close_reason: str, close_note: str = "") -> bool:
|
||||
conn = get_conn()
|
||||
cur = conn.execute(
|
||||
"""UPDATE trade_entry_prices
|
||||
SET status='closed', closed_at=datetime('now'), close_price=?,
|
||||
pnl_realized=?, close_reason=?, close_note=?
|
||||
WHERE id=? AND (status IS NULL OR status='open')""",
|
||||
(close_price, pnl_realized, close_reason, close_note, trade_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def update_trade_exit_params(trade_id: int, target_pct: float = None,
|
||||
stop_loss_pct: float = None,
|
||||
signal_threshold: float = None) -> bool:
|
||||
updates: List[str] = []
|
||||
vals: List[Any] = []
|
||||
if target_pct is not None:
|
||||
updates.append("target_pct=?"); vals.append(target_pct)
|
||||
if stop_loss_pct is not None:
|
||||
updates.append("stop_loss_pct=?"); vals.append(stop_loss_pct)
|
||||
if signal_threshold is not None:
|
||||
updates.append("signal_threshold=?"); vals.append(signal_threshold)
|
||||
if not updates:
|
||||
return False
|
||||
conn = get_conn()
|
||||
cur = conn.execute(
|
||||
f"UPDATE trade_entry_prices SET {', '.join(updates)} WHERE id=?",
|
||||
vals + [trade_id]
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def get_trade_entry_by_id(trade_id: int) -> Optional[Dict[str, Any]]:
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT * FROM trade_entry_prices WHERE id=?", (trade_id,)).fetchone()
|
||||
|
||||
Reference in New Issue
Block a user