Files
OpenFin/backend/routers/journal.py
OpenSquared ee69f3cbd9 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>
2026-06-19 14:17:29 +02:00

245 lines
8.6 KiB
Python

from fastapi import APIRouter, HTTPException
from typing import Any, Dict, List, Optional
import math
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:
"""Replace NaN/Inf with None recursively for JSON compliance."""
if isinstance(obj, dict):
return {k: _sanitize(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_sanitize(v) for v in obj]
if isinstance(obj, float) and (math.isnan(obj) or math.isinf(obj)):
return None
return obj
router = APIRouter(prefix="/api/journal", tags=["journal"])
# Bearish strategies — P&L is inverted (profit when price falls)
_BEARISH_KEYWORDS = {"bear", "put", "short", "sell", "vente", "baissier"}
def _is_bearish(strategy: str) -> bool:
s = (strategy or "").lower()
return any(kw in s for kw in _BEARISH_KEYWORDS)
@router.get("/macro-history")
def macro_history(days: int = 15):
"""Macro regime snapshots for the last N days."""
return _sanitize({"history": get_macro_regime_history(days), "days": days})
@router.get("/geo-history")
def geo_history(days: int = 30):
"""Geo alert score history for the last N days."""
return {"history": get_geo_alert_history(days), "days": days}
@router.get("/trade-mtm")
def trade_mtm(days: int = 30):
"""
Mark-to-market for all logged trade suggestions.
Enriches with live prices via shared _fetch_live_prices utility.
"""
entries = get_trade_entry_prices(days)
tickers_needed = list({(e.get("underlying") or "").upper() for e in entries if e.get("underlying")})
current_prices = _fetch_live_prices(tickers_needed, timeout=20)
from datetime import date as _date
result: List[Dict[str, Any]] = []
for e in entries:
ticker = (e.get("underlying") or "").upper()
entry_price = e.get("entry_price")
current_price = current_prices.get(ticker)
pnl_pct = None
if entry_price and current_price and entry_price > 0:
raw_pnl = (current_price - entry_price) / entry_price * 100
pnl_pct = round(-raw_pnl if _is_bearish(e.get("strategy", "")) else raw_pnl, 2)
days_held = None
try:
days_held = (_date.today() - _date.fromisoformat(e["entry_date"])).days
except Exception:
pass
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,
"pnl_pct": pnl_pct,
"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."""
reset_journal_history()
return {"reset": True, "message": "Journal de bord réinitialisé"}
@router.get("/summary")
def journal_summary():
"""Quick stats for the Journal de Bord header."""
macro = get_macro_regime_history(15)
geo = get_geo_alert_history(30)
trades = get_trade_entry_prices(30)
# Detect regime transitions (consecutive different dominants)
transitions = []
for i in range(1, len(macro)):
if macro[i - 1]["dominant"] != macro[i]["dominant"]:
transitions.append({
"from": macro[i]["dominant"],
"to": macro[i - 1]["dominant"],
"at": macro[i - 1]["timestamp"],
})
return {
"macro_snapshots": len(macro),
"regime_transitions": transitions[:5],
"current_dominant": macro[0]["dominant"] if macro else None,
"geo_alerts": len(geo),
"avg_geo_score": round(sum(g["geo_score"] for g in geo) / len(geo), 1) if geo else None,
"max_geo_score": max((g["geo_score"] for g in geo), default=None),
"trade_entries_logged": len(trades),
}