380 lines
16 KiB
Python
380 lines
16 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 — 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("/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
|