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, get_skipped_trades, delete_trade, ) 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") is_closed = e.get("status") == "closed" # For closed trades: pnl_pct is fixed from pnl_realized (locked in). # For open trades: compute live from current market price. if is_closed: pnl_realized = e.get("pnl_realized") capital = e.get("capital_invested") or entry_price or 0 if pnl_realized is not None and capital and capital > 0: pnl_pct = round(pnl_realized / capital * 100, 2) elif e.get("close_price") and entry_price and entry_price > 0: raw = (e["close_price"] - entry_price) / entry_price * 100 pnl_pct = round(-raw if _is_bearish(e.get("strategy", "")) else raw, 2) else: pnl_pct = None current_price = e.get("close_price") price_warning = None if pnl_pct is not None else "no_close_price" else: 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) price_warning = None if entry_price is None and current_price is None: price_warning = "no_price_data" elif entry_price is None: price_warning = "no_entry_price" elif current_price is None: price_warning = "no_live_price" 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 = "closed" if is_closed else _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 not is_closed and 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, "price_warning": price_warning, "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.get("/portfolio-risk") def portfolio_risk(): """Risk analysis of the open simulation portfolio (asset class concentration, conflicts).""" import json as _json from services.portfolio_risk import analyze_simulation_portfolio from services.database import get_system_logs result = analyze_simulation_portfolio() # Attach latest AI monitor recommendation if available logs = get_system_logs(source="portfolio_monitor", limit=1) if logs: raw = logs[0].get("details") if raw: try: result["ai_monitor"] = _json.loads(raw) result["ai_monitor_ts"] = logs[0].get("ts") except Exception: pass return _sanitize(result) class TradeCheckRequest(BaseModel): underlying: str strategy: str asset_class: str = "" @router.post("/trade-check") def trade_check(body: TradeCheckRequest): """Pre-entry check: would adding this trade create conflicts or concentration issues?""" from services.portfolio_risk import check_new_trade return check_new_trade(body.underlying, body.strategy, body.asset_class) @router.get("/skipped-trades") def skipped_trades_endpoint(days: int = 30): """Trades suggested by cycle that didn't pass any risk profile threshold.""" trades = get_skipped_trades(days) return _sanitize({"trades": trades, "days": days, "count": len(trades)}) @router.delete("/trades/{trade_id}") def delete_trade_endpoint(trade_id: int): ok = delete_trade(trade_id) if not ok: raise HTTPException(404, "Trade non trouvé") return {"deleted": True, "trade_id": trade_id} @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), }