feat: risk
This commit is contained in:
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user