from fastapi import APIRouter, HTTPException import traceback as tb_mod from pydantic import BaseModel from typing import Optional, List, Dict, Any from datetime import datetime, date, timedelta from services.database import ( add_position, get_positions, close_position, update_position_notes, compute_ib_fees ) from services.data_fetcher import get_quote from services.options_pricer import black_scholes from services.data_fetcher import compute_historical_iv import math router = APIRouter(prefix="/api/portfolio", tags=["portfolio"]) class AddPositionRequest(BaseModel): title: str underlying: str strategy: str asset_class: Optional[str] = "indices" entry_date: Optional[str] = None expiry_date: Optional[str] = None expiry_days: Optional[int] = 90 legs: List[Dict[str, Any]] capital_invested: float entry_underlying_price: Optional[float] = None geo_trigger: Optional[str] = "" rationale: Optional[str] = "" notes: Optional[str] = "" class ClosePositionRequest(BaseModel): close_value: float class NotesRequest(BaseModel): notes: str def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]: """Compute current value of a position using live prices + Black-Scholes.""" underlying = pos["underlying"] q = get_quote(underlying) S = (q.get("price") if q else None) or pos.get("entry_underlying_price") or 100.0 legs = pos.get("legs", []) if not legs: return {**pos, "current_value": pos["capital_invested"], "pnl": 0, "pnl_pct": 0, "current_underlying": S, "greeks": {}} # Compute days to expiry expiry_date = pos.get("expiry_date") or "" if expiry_date: try: exp = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() T = max(0.001, (exp - date.today()).days / 365) except Exception: T = max(0.001, (pos.get("expiry_days", 90) - 30) / 365) else: entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date() days_elapsed = (date.today() - entry).days T = max(0.001, (pos.get("expiry_days", 90) - days_elapsed) / 365) from services.data_fetcher import compute_historical_iv as get_iv sigma = get_iv(underlying) r = 0.05 total_current_value = 0.0 total_entry_value = 0.0 net_delta = 0.0 net_theta = 0.0 net_vega = 0.0 entry_from_legs = False # T at entry (full original duration) — used to reprice legs at entry if premium_paid not stored S_entry = float(pos.get("entry_underlying_price") or S) entry_date_str = pos.get("entry_date", "") if expiry_date and entry_date_str: try: exp_dt = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() entry_dt = datetime.strptime(entry_date_str[:10], "%Y-%m-%d").date() T_entry = max(0.001, (exp_dt - entry_dt).days / 365) except Exception: T_entry = max(0.001, pos.get("expiry_days", 90) / 365) else: T_entry = max(0.001, pos.get("expiry_days", 90) / 365) for leg in legs: K = leg.get("strike") or S K_entry = leg.get("strike") or S_entry opt_type = leg.get("option_type", "call") qty = leg.get("quantity", 1) sign = 1 if leg.get("position", "long") == "long" else -1 bs = black_scholes(S, K, T, r, sigma, opt_type) leg_value = bs["price"] * qty * 100 * sign total_current_value += leg_value net_delta += bs["delta"] * qty * sign net_theta += bs["theta"] * qty * sign net_vega += bs["vega"] * qty * sign if leg.get("premium_paid") is not None: total_entry_value += leg["premium_paid"] * qty * 100 * sign entry_from_legs = True else: # No stored premium: reprice at entry conditions for a consistent PnL baseline bs_entry = black_scholes(S_entry, K_entry, T_entry, r, sigma, opt_type) total_entry_value += bs_entry["price"] * qty * 100 * sign # Entry reference: always from legs (either stored premium or BS at entry conditions) ib_entry = pos.get("ib_fees_entry", 0) entry_ref = total_entry_value if total_entry_value != 0 else pos["capital_invested"] pnl = total_current_value - entry_ref - ib_entry pnl_pct = (pnl / max(abs(entry_ref), 1) * 100) if entry_ref else 0 return { **pos, "current_underlying": round(S, 4), "current_value": round(total_current_value, 2), "entry_ref": round(entry_ref, 2), "pnl": round(pnl, 2), "pnl_pct": round(pnl_pct, 2), "days_remaining": max(0, int(T * 365)), "sigma_used": round(sigma, 4), "greeks": { "net_delta": round(net_delta, 4), "net_theta": round(net_theta, 4), "net_vega": round(net_vega, 4), }, } @router.get("/positions") def list_positions(status: str = "open"): positions = get_positions(status) if status == "open": return [mark_to_market(p) for p in positions] return positions @router.get("/summary") def portfolio_summary(): open_pos = get_positions("open") closed_pos = get_positions("closed") marked = [mark_to_market(p) for p in open_pos] total_invested = sum(p["capital_invested"] for p in open_pos) total_current = sum(p.get("current_value", p["capital_invested"]) for p in marked) total_pnl = sum(p.get("pnl", 0) for p in marked) total_fees = sum(p.get("ib_fees_entry", 0) for p in open_pos) realized_pnl = 0.0 for p in closed_pos: if p.get("close_value") is not None: realized_pnl += (p["close_value"] - p["capital_invested"] - p.get("ib_fees_entry", 0) - p.get("ib_fees_exit", 0)) return { "open_positions": len(open_pos), "closed_positions": len(closed_pos), "total_invested": round(total_invested, 2), "total_current_value": round(total_current, 2), "unrealized_pnl": round(total_pnl, 2), "unrealized_pnl_pct": round(total_pnl / total_invested * 100, 2) if total_invested else 0, "realized_pnl": round(realized_pnl, 2), "total_fees_paid": round(total_fees, 2), "net_pnl": round(total_pnl + realized_pnl, 2), } TICKER_HINTS: Dict[str, str] = { # Indices "s&p 500": "^GSPC", "sp500": "^GSPC", "s&p500": "^GSPC", "spx": "^GSPC", "nasdaq": "^NDX", "nasdaq 100": "^NDX", "ndx": "^NDX", "qqq": "QQQ", "dow jones": "^DJI", "djia": "^DJI", "vix": "^VIX", "euro stoxx": "^STOXX50E", "stoxx50": "^STOXX50E", "nikkei": "^N225", # Metals "gold": "GC=F", "or": "GC=F", "gold futures": "GC=F", "silver": "SI=F", "argent": "SI=F", "copper": "HG=F", "cuivre": "HG=F", "platinum": "PL=F", "platine": "PL=F", # Energy "wti": "CL=F", "crude oil": "CL=F", "pétrole": "CL=F", "crude": "CL=F", "brent": "BZ=F", "natural gas": "NG=F", "gaz naturel": "NG=F", "natgas": "NG=F", # Agriculture "corn": "ZC=F", "maïs": "ZC=F", "wheat": "ZW=F", "blé": "ZW=F", "soybean": "ZS=F", "soja": "ZS=F", "coffee": "KC=F", "café": "KC=F", # Forex — yfinance format: {BASE}{QUOTE}=X "eurusd": "EURUSD=X", "eur/usd": "EURUSD=X", "euro": "EURUSD=X", "usdjpy": "USDJPY=X", "usd/jpy": "USDJPY=X", "gbpusd": "GBPUSD=X", "gbp/usd": "GBPUSD=X", "usdchf": "USDCHF=X", "usd/chf": "USDCHF=X", "usdcnh": "USDCNH=X", "usd/cnh": "USDCNH=X", "usdcny": "USDCNY=X", "usd/cny": "USDCNY=X", "cny": "USDCNY=X", "usdrub": "USDRUB=X", "usd/rub": "USDRUB=X", "usdtry": "USDTRY=X", "usd/try": "USDTRY=X", "usdmxn": "USDMXN=X", "usd/mxn": "USDMXN=X", "audusd": "AUDUSD=X", "aud/usd": "AUDUSD=X", "dxy": "DX-Y.NYB", "dollar index": "DX-Y.NYB", } @router.post("/add") def add_pos(req: AddPositionRequest): import traceback try: data = req.model_dump() # Normalize common names to yfinance tickers raw = req.underlying.strip() normalized = TICKER_HINTS.get(raw.lower(), raw) data["underlying"] = normalized # Fetch live underlying price q = get_quote(normalized) S = q.get("price") if q else None if not S: hint = TICKER_HINTS.get(raw.lower()) tip = f" Essayez '{hint}'." if hint else " Utilisez le symbole Yahoo Finance (ex: ^GSPC pour S&P 500, GC=F pour Or, CL=F pour WTI)." raise HTTPException(status_code=422, detail=f"Ticker '{raw}' introuvable sur Yahoo Finance.{tip}") if not data.get("entry_underlying_price"): data["entry_underlying_price"] = S # Auto-fill entry date and expiry if not data.get("entry_date"): data["entry_date"] = datetime.utcnow().isoformat()[:10] if not data.get("expiry_date") and data.get("expiry_days"): data["expiry_date"] = (date.today() + timedelta(days=data["expiry_days"])).isoformat() # Auto-price legs that have no premium_paid using BS at entry # This ensures P&L starts at ~0 on day 1 (tracking change from entry, not vs. budget) if S and data.get("legs"): sigma = compute_historical_iv(req.underlying) T = max(0.001, data.get("expiry_days", 90) / 365) r = 0.05 for leg in data["legs"]: if leg.get("premium_paid") is None: K = leg.get("strike") or S # ATM if no explicit strike if not leg.get("strike"): leg["strike"] = round(S, 2) opt_type = leg.get("option_type", "call") bs = black_scholes(S, K, T, r, sigma, opt_type) leg["premium_paid"] = round(bs["price"], 4) pos_id = add_position(data) return {"id": pos_id, "status": "added"} except HTTPException: raise except Exception as e: tb = traceback.format_exc() raise HTTPException(status_code=500, detail=f"{str(e)}\n\n{tb}") @router.post("/close/{pos_id}") def close_pos(pos_id: str, req: ClosePositionRequest): return close_position(pos_id, req.close_value) @router.delete("/purge-all") def purge_all_positions(): """Delete ALL portfolio positions (open + closed) and related trade entry prices.""" from services.database import get_conn conn = get_conn() conn.execute("DELETE FROM portfolio") conn.execute("DELETE FROM trade_entry_prices") n = conn.total_changes conn.commit() conn.close() return {"deleted": n} @router.delete("/{pos_id}") def delete_pos(pos_id: str): from services.database import get_conn conn = get_conn() conn.execute("DELETE FROM portfolio WHERE id=?", (pos_id,)) conn.commit() conn.close() return {"status": "deleted", "id": pos_id} @router.patch("/notes/{pos_id}") def update_notes(pos_id: str, req: NotesRequest): update_position_notes(pos_id, req.notes) return {"status": "ok"} @router.get("/pnl-history") def pnl_history(): """Equity curve from closed positions.""" closed = get_positions("closed") closed_sorted = sorted(closed, key=lambda p: p.get("close_date", "")) curve = [] cumulative = 0.0 for p in closed_sorted: if p.get("close_value") is not None: pnl = (p["close_value"] - p["capital_invested"] - p.get("ib_fees_entry", 0) - p.get("ib_fees_exit", 0)) cumulative += pnl curve.append({ "date": p.get("close_date", ""), "pnl": round(pnl, 2), "cumulative": round(cumulative, 2), "title": p.get("title", p.get("underlying", "")), }) return curve