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>
312 lines
11 KiB
Python
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
|