Files
OpenFin/backend/routers/portfolio.py
OpenSquared 66f6607568 feat: Data Management tab — purge endpoints for all major data stores
Backend — new DELETE /purge-all endpoints:
  - /api/logs/purge-all          → truncate system_logs (immediate, no 30d wait)
  - /api/reports/purge-all       → cycle_reports + ai_reports
  - /api/portfolio/purge-all     → portfolio + trade_entry_prices
  - /api/analytics/purge-all     → pattern_score_history, regime_clusters,
                                    pattern_embeddings, cycle_runs,
                                    macro_regime_history, geo_alert_history
  - /api/var/purge-all           → var_snapshots + pnl_snapshots
  - /api/pattern-lab/purge-all   → backtest_lab_runs

Frontend — Config.tsx: new 'Data Management' tab
  - PurgeButton component with inline double-confirm (click Purge → confirm)
  - Shows table names affected + row count deleted
  - 6 purge actions: Logs, AI Reports, Portfolio, Analytics, VaR, Pattern Lab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:25:01 +02:00

312 lines
11 KiB
Python

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