Files
OpenFin/backend/routers/portfolio.py
2026-07-28 11:14:31 +02:00

395 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from fastapi import APIRouter, HTTPException, Query
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 — Saxo-first per leg (services.portfolio_pricing)
when the underlying has a saxo_option_symbol linked in the Watchlist, yfinance
historical-vol Black-Scholes otherwise or as a fallback on any Saxo failure. Each leg's
`pricing_source` in the response says plainly which one was actually used."""
from services.portfolio_pricing import resolve_saxo_chain, price_leg
underlying = pos["underlying"]
# 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)
days_to_expiry = T * 365
r = 0.05
chain, surface = resolve_saxo_chain(underlying, target_days=max(int(days_to_expiry), 1),
sanity_reference=pos.get("entry_underlying_price"))
# yfinance fallback inputs — only actually fetched if no usable Saxo chain, so a
# Saxo-linked instrument never pays for a yfinance round-trip it doesn't need.
fallback_spot = pos.get("entry_underlying_price") or 100.0
fallback_sigma = 0.20
if chain is None:
q = get_quote(underlying)
fallback_spot = (q.get("price") if q else None) or fallback_spot
from services.data_fetcher import compute_historical_iv as get_iv
fallback_sigma = get_iv(underlying)
S = chain["spot"] if chain else fallback_spot
legs = pos.get("legs", [])
if not legs:
return {**pos, "current_value": pos["capital_invested"], "pnl": 0, "pnl_pct": 0,
"current_underlying": S, "greeks": {}, "pricing_sources": []}
# 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()
days_to_expiry_entry = max(0.001, (exp_dt - entry_dt).days)
except Exception:
days_to_expiry_entry = max(0.001, pos.get("expiry_days", 90))
else:
days_to_expiry_entry = max(0.001, pos.get("expiry_days", 90))
total_current_value = 0.0
total_entry_value = 0.0
net_delta = 0.0
net_theta = 0.0
net_vega = 0.0
priced_legs = []
sources_seen: set = set()
sigmas_seen: List[float] = []
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
priced = price_leg(K, opt_type, days_to_expiry, r, chain, surface, expiry_date, fallback_spot, fallback_sigma)
bs = black_scholes(S, K, T, r, priced["sigma"], opt_type) # for greeks, at the same sigma just resolved
sources_seen.add(priced["source"])
sigmas_seen.append(priced["sigma"])
leg_value = priced["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
priced_legs.append({**leg, "current_premium": round(priced["price"], 4), "pricing_source": priced["source"]})
if leg.get("premium_paid") is not None:
total_entry_value += leg["premium_paid"] * qty * 100 * sign
else:
# No stored premium: reprice at entry conditions for a consistent PnL baseline
priced_entry = price_leg(K_entry, opt_type, days_to_expiry_entry, r, chain, surface, expiry_date, S_entry, fallback_sigma)
total_entry_value += priced_entry["price"] * qty * 100 * sign
# Entry reference: always from legs (either stored premium or repriced 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,
"legs": priced_legs,
"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)),
# Kept for the frontend's existing "σ (hist. IV)" display — now an average across
# legs since each can carry its own skew-aware sigma when Saxo-priced, rather than
# one flat value for the whole position like the old yfinance-only path.
"sigma_used": round(sum(sigmas_seen) / len(sigmas_seen), 4) if sigmas_seen else None,
"pricing_sources": sorted(sources_seen),
"pricing_source_summary": next(iter(sources_seen)) if len(sources_seen) == 1 else ("mixed" if sources_seen else "yfinance_bs"),
"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("/positions/{pos_id}/payoff")
def position_payoff(pos_id: str):
"""P&L-vs-underlying-price payoff diagram for one position — see
services.portfolio_pricing.compute_payoff for the at-expiry vs. today (current Saxo
vol held fixed) two-curve methodology."""
from services.portfolio_pricing import compute_payoff
pos = next((p for p in get_positions("open") + get_positions("closed") if p["id"] == pos_id), None)
if not pos:
raise HTTPException(status_code=404, detail=f"Position '{pos_id}' introuvable")
return compute_payoff(pos)
@router.get("/positions/{pos_id}/retrospective-optimal")
def position_retrospective_optimal(pos_id: str, as_of: str = Query(None)):
""""What would have been optimal, in hindsight?" — reprices the position's real legs
and runs the Strategy Builder optimizer against the REAL historical Saxo chain and the
REALIZED spot/IV move since entry (not a guessed scenario) — see
services.strategy_comparison.compute_retrospective_comparison. Expensive (runs the full
template-search optimizer), so this is on-demand from the Portfolio position detail, not
auto-computed for every open position."""
from services.strategy_comparison import compute_retrospective_comparison
pos = next((p for p in get_positions("open") + get_positions("closed") if p["id"] == pos_id), None)
if not pos:
raise HTTPException(status_code=404, detail=f"Position '{pos_id}' introuvable")
return compute_retrospective_comparison(pos, as_of=as_of)
@router.get("/scenario-exposure")
def scenario_exposure():
"""Reprices every open position under a handful of named macro scenarios (Risk-Off,
Risk-On, inflation persistante, dollar fort, baisse des matières premières) to surface
concentration on a single underlying bet across differently-named positions — see
services.portfolio_scenarios.compute_scenario_exposure for the methodology."""
from services.portfolio_scenarios import compute_scenario_exposure
return compute_scenario_exposure()
@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:
from services.portfolio_pricing import resolve_saxo_chain, price_leg
data = req.model_dump()
# Normalize common names to yfinance tickers
raw = req.underlying.strip()
normalized = TICKER_HINTS.get(raw.lower(), raw)
data["underlying"] = normalized
# Auto-fill entry date and expiry (needed before pricing, to size the Saxo chain fetch)
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()
# Underlying price — Saxo option chain's own spot first (real, and consistent with
# whatever prices the legs below), yfinance only if this underlying has no
# saxo_option_symbol link at all (Config -> Instruments Watchlist -> "Option"). The
# yfinance quote is fetched unconditionally (cheap, one-time at creation) to also
# serve as resolve_saxo_chain's sanity_reference — guards against a Saxo chain whose
# spot is on a different scale (observed on COMEX copper, ~100x too large).
q = get_quote(normalized)
yf_spot = q.get("price") if q else None
chain, surface = resolve_saxo_chain(normalized, target_days=data.get("expiry_days", 90),
sanity_reference=yf_spot)
S = chain["spot"] if chain else None
sigma = None
if S is None:
S = yf_spot
if S is not None:
sigma = compute_historical_iv(req.underlying)
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), ou liez-le à un option chain Saxo (Config → Instruments Watchlist)."
raise HTTPException(status_code=422, detail=f"Ticker '{raw}' introuvable sur Yahoo Finance ni lié à un chain Saxo.{tip}")
if not data.get("entry_underlying_price"):
data["entry_underlying_price"] = S
# Auto-price legs that have no premium_paid — Saxo-first (real quote, else the
# Saxo-fitted vol surface), yfinance-historical-vol Black-Scholes as a last resort.
# This ensures P&L starts at ~0 on day 1 (tracking change from entry, not vs. budget).
if S and data.get("legs"):
r = 0.05
expiry_days = data.get("expiry_days", 90)
for leg in data["legs"]:
if not leg.get("strike"):
leg["strike"] = round(S, 2) # ATM if no explicit strike
if leg.get("premium_paid") is None:
priced = price_leg(
leg["strike"], leg.get("option_type", "call"), expiry_days, r,
chain, surface, data.get("expiry_date"), S, sigma or 0.20,
)
leg["premium_paid"] = round(priced["price"], 4)
leg["pricing_source"] = priced["source"]
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