feat: risk

This commit is contained in:
OpenSquared
2026-07-26 14:10:49 +02:00
parent ad7ab35d1d
commit 3704724b0b
8 changed files with 719 additions and 57 deletions

View File

@@ -40,15 +40,13 @@ class NotesRequest(BaseModel):
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
"""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
legs = pos.get("legs", [])
if not legs:
return {**pos, "current_value": pos["capital_invested"], "pnl": 0, "pnl_pct": 0,
"current_underlying": S, "greeks": {}}
underlying = pos["underlying"]
# Compute days to expiry
expiry_date = pos.get("expiry_date") or ""
@@ -62,17 +60,27 @@ def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]:
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
from services.data_fetcher import compute_historical_iv as get_iv
sigma = get_iv(underlying)
r = 0.05
chain, surface = resolve_saxo_chain(underlying, target_days=max(int(days_to_expiry), 1))
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
# 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)
@@ -81,11 +89,20 @@ def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]:
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)
days_to_expiry_entry = max(0.001, (exp_dt - entry_dt).days)
except Exception:
T_entry = max(0.001, pos.get("expiry_days", 90) / 365)
days_to_expiry_entry = max(0.001, pos.get("expiry_days", 90))
else:
T_entry = max(0.001, pos.get("expiry_days", 90) / 365)
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
@@ -93,21 +110,26 @@ def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]:
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
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
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
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 BS at entry conditions)
# 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
@@ -115,13 +137,19 @@ def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]:
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)),
"sigma_used": round(sigma, 4),
# 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),
@@ -138,6 +166,28 @@ def list_positions(status: str = "open"):
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")
@@ -209,6 +259,8 @@ TICKER_HINTS: Dict[str, str] = {
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
@@ -216,36 +268,46 @@ def add_pos(req: AddPositionRequest):
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
# 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()
# 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)
# 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").
chain, surface = resolve_saxo_chain(normalized, target_days=data.get("expiry_days", 90))
S = chain["spot"] if chain else None
sigma = None
if S is None:
q = get_quote(normalized)
S = q.get("price") if q else None
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"):
sigma = compute_historical_iv(req.underlying)
T = max(0.001, data.get("expiry_days", 90) / 365)
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:
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)
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"}