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]:
|
def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Compute current value of a position using live prices + Black-Scholes."""
|
"""Compute current value of a position — Saxo-first per leg (services.portfolio_pricing)
|
||||||
underlying = pos["underlying"]
|
when the underlying has a saxo_option_symbol linked in the Watchlist, yfinance
|
||||||
q = get_quote(underlying)
|
historical-vol Black-Scholes otherwise or as a fallback on any Saxo failure. Each leg's
|
||||||
S = (q.get("price") if q else None) or pos.get("entry_underlying_price") or 100.0
|
`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", [])
|
underlying = pos["underlying"]
|
||||||
if not legs:
|
|
||||||
return {**pos, "current_value": pos["capital_invested"], "pnl": 0, "pnl_pct": 0,
|
|
||||||
"current_underlying": S, "greeks": {}}
|
|
||||||
|
|
||||||
# Compute days to expiry
|
# Compute days to expiry
|
||||||
expiry_date = pos.get("expiry_date") or ""
|
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()
|
entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date()
|
||||||
days_elapsed = (date.today() - entry).days
|
days_elapsed = (date.today() - entry).days
|
||||||
T = max(0.001, (pos.get("expiry_days", 90) - days_elapsed) / 365)
|
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
|
r = 0.05
|
||||||
|
chain, surface = resolve_saxo_chain(underlying, target_days=max(int(days_to_expiry), 1))
|
||||||
|
|
||||||
total_current_value = 0.0
|
# yfinance fallback inputs — only actually fetched if no usable Saxo chain, so a
|
||||||
total_entry_value = 0.0
|
# Saxo-linked instrument never pays for a yfinance round-trip it doesn't need.
|
||||||
net_delta = 0.0
|
fallback_spot = pos.get("entry_underlying_price") or 100.0
|
||||||
net_theta = 0.0
|
fallback_sigma = 0.20
|
||||||
net_vega = 0.0
|
if chain is None:
|
||||||
entry_from_legs = False
|
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
|
# 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)
|
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:
|
try:
|
||||||
exp_dt = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date()
|
exp_dt = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date()
|
||||||
entry_dt = datetime.strptime(entry_date_str[: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:
|
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:
|
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:
|
for leg in legs:
|
||||||
K = leg.get("strike") or S
|
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")
|
opt_type = leg.get("option_type", "call")
|
||||||
qty = leg.get("quantity", 1)
|
qty = leg.get("quantity", 1)
|
||||||
sign = 1 if leg.get("position", "long") == "long" else -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
|
total_current_value += leg_value
|
||||||
net_delta += bs["delta"] * qty * sign
|
net_delta += bs["delta"] * qty * sign
|
||||||
net_theta += bs["theta"] * qty * sign
|
net_theta += bs["theta"] * qty * sign
|
||||||
net_vega += bs["vega"] * 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:
|
if leg.get("premium_paid") is not None:
|
||||||
total_entry_value += leg["premium_paid"] * qty * 100 * sign
|
total_entry_value += leg["premium_paid"] * qty * 100 * sign
|
||||||
entry_from_legs = True
|
|
||||||
else:
|
else:
|
||||||
# No stored premium: reprice at entry conditions for a consistent PnL baseline
|
# 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)
|
priced_entry = price_leg(K_entry, opt_type, days_to_expiry_entry, r, chain, surface, expiry_date, S_entry, fallback_sigma)
|
||||||
total_entry_value += bs_entry["price"] * qty * 100 * sign
|
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)
|
ib_entry = pos.get("ib_fees_entry", 0)
|
||||||
entry_ref = total_entry_value if total_entry_value != 0 else pos["capital_invested"]
|
entry_ref = total_entry_value if total_entry_value != 0 else pos["capital_invested"]
|
||||||
pnl = total_current_value - entry_ref - ib_entry
|
pnl = total_current_value - entry_ref - ib_entry
|
||||||
@@ -115,13 +137,19 @@ def mark_to_market(pos: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
**pos,
|
**pos,
|
||||||
|
"legs": priced_legs,
|
||||||
"current_underlying": round(S, 4),
|
"current_underlying": round(S, 4),
|
||||||
"current_value": round(total_current_value, 2),
|
"current_value": round(total_current_value, 2),
|
||||||
"entry_ref": round(entry_ref, 2),
|
"entry_ref": round(entry_ref, 2),
|
||||||
"pnl": round(pnl, 2),
|
"pnl": round(pnl, 2),
|
||||||
"pnl_pct": round(pnl_pct, 2),
|
"pnl_pct": round(pnl_pct, 2),
|
||||||
"days_remaining": max(0, int(T * 365)),
|
"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": {
|
"greeks": {
|
||||||
"net_delta": round(net_delta, 4),
|
"net_delta": round(net_delta, 4),
|
||||||
"net_theta": round(net_theta, 4),
|
"net_theta": round(net_theta, 4),
|
||||||
@@ -138,6 +166,28 @@ def list_positions(status: str = "open"):
|
|||||||
return 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")
|
@router.get("/summary")
|
||||||
def portfolio_summary():
|
def portfolio_summary():
|
||||||
open_pos = get_positions("open")
|
open_pos = get_positions("open")
|
||||||
@@ -209,6 +259,8 @@ TICKER_HINTS: Dict[str, str] = {
|
|||||||
def add_pos(req: AddPositionRequest):
|
def add_pos(req: AddPositionRequest):
|
||||||
import traceback
|
import traceback
|
||||||
try:
|
try:
|
||||||
|
from services.portfolio_pricing import resolve_saxo_chain, price_leg
|
||||||
|
|
||||||
data = req.model_dump()
|
data = req.model_dump()
|
||||||
|
|
||||||
# Normalize common names to yfinance tickers
|
# Normalize common names to yfinance tickers
|
||||||
@@ -216,36 +268,46 @@ def add_pos(req: AddPositionRequest):
|
|||||||
normalized = TICKER_HINTS.get(raw.lower(), raw)
|
normalized = TICKER_HINTS.get(raw.lower(), raw)
|
||||||
data["underlying"] = normalized
|
data["underlying"] = normalized
|
||||||
|
|
||||||
# Fetch live underlying price
|
# Auto-fill entry date and expiry (needed before pricing, to size the Saxo chain fetch)
|
||||||
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"):
|
if not data.get("entry_date"):
|
||||||
data["entry_date"] = datetime.utcnow().isoformat()[:10]
|
data["entry_date"] = datetime.utcnow().isoformat()[:10]
|
||||||
if not data.get("expiry_date") and data.get("expiry_days"):
|
if not data.get("expiry_date") and data.get("expiry_days"):
|
||||||
data["expiry_date"] = (date.today() + timedelta(days=data["expiry_days"])).isoformat()
|
data["expiry_date"] = (date.today() + timedelta(days=data["expiry_days"])).isoformat()
|
||||||
|
|
||||||
# Auto-price legs that have no premium_paid using BS at entry
|
# Underlying price — Saxo option chain's own spot first (real, and consistent with
|
||||||
# This ensures P&L starts at ~0 on day 1 (tracking change from entry, not vs. budget)
|
# 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"):
|
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
|
r = 0.05
|
||||||
|
expiry_days = data.get("expiry_days", 90)
|
||||||
for leg in data["legs"]:
|
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:
|
if leg.get("premium_paid") is None:
|
||||||
K = leg.get("strike") or S # ATM if no explicit strike
|
priced = price_leg(
|
||||||
if not leg.get("strike"):
|
leg["strike"], leg.get("option_type", "call"), expiry_days, r,
|
||||||
leg["strike"] = round(S, 2)
|
chain, surface, data.get("expiry_date"), S, sigma or 0.20,
|
||||||
opt_type = leg.get("option_type", "call")
|
)
|
||||||
bs = black_scholes(S, K, T, r, sigma, opt_type)
|
leg["premium_paid"] = round(priced["price"], 4)
|
||||||
leg["premium_paid"] = round(bs["price"], 4)
|
leg["pricing_source"] = priced["source"]
|
||||||
|
|
||||||
pos_id = add_position(data)
|
pos_id = add_position(data)
|
||||||
return {"id": pos_id, "status": "added"}
|
return {"id": pos_id, "status": "added"}
|
||||||
|
|||||||
@@ -3342,6 +3342,23 @@ def get_instruments_watchlist() -> List[Dict]:
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_saxo_option_symbol_for_ticker(ticker: str) -> Optional[str]:
|
||||||
|
"""Cockpit Watchlist's saxo_option_symbol link for this yfinance-style ticker
|
||||||
|
(e.g. '^NDX' -> 'NQ:XCME') — Portfolio positions store `underlying` in the same raw
|
||||||
|
format the Watchlist's own `ticker` column uses, so this is a direct case-insensitive
|
||||||
|
match, no suffix-stripping or alias table needed (see services.instrument_service.
|
||||||
|
resolve_watchlist_ticker for the OTHER case, where a catalog id and a Watchlist ticker
|
||||||
|
genuinely differ — that doesn't apply here). Used by services.portfolio_pricing to
|
||||||
|
decide whether a position's legs can be priced off the real Saxo option chain instead
|
||||||
|
of a yfinance-historical-vol Black-Scholes simulation."""
|
||||||
|
conn = get_conn()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT saxo_option_symbol FROM instruments_watchlist WHERE ticker = ? COLLATE NOCASE", (ticker,)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row["saxo_option_symbol"] if row and row["saxo_option_symbol"] else None
|
||||||
|
|
||||||
|
|
||||||
def set_instrument_watchlist_saxo_option_symbol(ticker: str, saxo_symbol: Optional[str]) -> bool:
|
def set_instrument_watchlist_saxo_option_symbol(ticker: str, saxo_symbol: Optional[str]) -> bool:
|
||||||
"""Link (or unlink, if saxo_symbol is None/empty) a tracked instrument to the Saxo
|
"""Link (or unlink, if saxo_symbol is None/empty) a tracked instrument to the Saxo
|
||||||
symbol whose OPTIONS CHAIN Options Lab should analyze for it — e.g. CL=F -> MCL:XCME
|
symbol whose OPTIONS CHAIN Options Lab should analyze for it — e.g. CL=F -> MCL:XCME
|
||||||
|
|||||||
159
backend/services/portfolio_pricing.py
Normal file
159
backend/services/portfolio_pricing.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Saxo-first pricing for Portfolio positions — options legs are priced off this Cockpit's own
|
||||||
|
accumulated Saxo option-chain history (services.option_chain, services.vol_surface) whenever
|
||||||
|
the position's underlying has a saxo_option_symbol link in the Watchlist (Config ->
|
||||||
|
Instruments Watchlist -> "Option"), the SAME real market data Options Lab and Strategy
|
||||||
|
Builder already use. Mirrors services.strategy_engine.entry_price()'s own two-tier pattern:
|
||||||
|
an exact Saxo bid/ask quote for the listed contract if one happens to exist ("saxo_quote"),
|
||||||
|
else the real Saxo-fitted vol smile (services.vol_surface.Surface) priced through
|
||||||
|
Black-Scholes ("saxo_surface") — both grounded in real Saxo data, unlike the previous
|
||||||
|
unconditional fallback to yfinance's historical realized vol as a stand-in for implied vol
|
||||||
|
("yfinance_bs"), which is what silently produced a materially different premium than Saxo's
|
||||||
|
real chain (27.2% yfinance-historical vs Saxo's real ~32% chain IV on the ^NDX example that
|
||||||
|
prompted this).
|
||||||
|
|
||||||
|
An exact "saxo_quote" match is rare in practice: positions carry a nominal expiry_date/
|
||||||
|
expiry_days the AI or user chose freely, not necessarily a real listed Saxo expiry — so
|
||||||
|
most legs land on "saxo_surface" (real Saxo-implied vol, interpolated to the requested
|
||||||
|
strike/tenor) rather than a literal listed-contract quote. That's still a real improvement
|
||||||
|
over yfinance historical vol, and every priced leg carries its `source` so the Portfolio UI
|
||||||
|
can say plainly which basis was used instead of always labeling everything "Black-Scholes"
|
||||||
|
regardless of where the inputs actually came from.
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
from services.options_pricer import black_scholes
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_saxo_chain(underlying: str, target_days: int) -> Tuple[Optional[Dict[str, Any]], Optional[Any]]:
|
||||||
|
"""Returns (chain_slice, Surface) for this underlying's linked Saxo option chain, or
|
||||||
|
(None, None) if it isn't linked, or the chain can't be built right now (Saxo down, no
|
||||||
|
snapshot yet, entitlement gap, etc.) — callers fall back to yfinance pricing in that case."""
|
||||||
|
from services.database import get_saxo_option_symbol_for_ticker
|
||||||
|
saxo_symbol = get_saxo_option_symbol_for_ticker(underlying)
|
||||||
|
if not saxo_symbol:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
from services.option_chain import get_chain_slice
|
||||||
|
from services.vol_surface import Surface
|
||||||
|
chain = get_chain_slice(saxo_symbol, target_days=max(target_days, 1))
|
||||||
|
if not chain.get("spot"):
|
||||||
|
return None, None
|
||||||
|
surface = Surface(chain["spot"], chain["expiries"])
|
||||||
|
return chain, surface
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def price_leg(
|
||||||
|
strike: float, option_type: str, days_to_expiry: float, r: float,
|
||||||
|
chain: Optional[Dict[str, Any]], surface: Optional[Any], expiry_date: Optional[str],
|
||||||
|
fallback_spot: float, fallback_sigma: float,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""One leg's {price, spot, sigma, source}."""
|
||||||
|
T = max(days_to_expiry, 1) / 365
|
||||||
|
if chain is not None and surface is not None:
|
||||||
|
quote = None
|
||||||
|
if expiry_date:
|
||||||
|
from services.option_chain import find_quote
|
||||||
|
quote = find_quote(chain, expiry_date, strike, option_type)
|
||||||
|
if quote and quote.get("bid", 0) > 0 and quote.get("ask", 0) > 0:
|
||||||
|
return {"price": quote["mid"], "spot": chain["spot"], "sigma": quote["iv"], "source": "saxo_quote"}
|
||||||
|
sigma = surface.iv_at(strike, max(days_to_expiry, 1))
|
||||||
|
price = black_scholes(chain["spot"], strike, T, r, sigma, option_type)["price"]
|
||||||
|
return {"price": price, "spot": chain["spot"], "sigma": sigma, "source": "saxo_surface"}
|
||||||
|
price = black_scholes(fallback_spot, strike, T, r, fallback_sigma, option_type)["price"]
|
||||||
|
return {"price": price, "spot": fallback_spot, "sigma": fallback_sigma, "source": "yfinance_bs"}
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE_LABELS = {
|
||||||
|
"saxo_quote": "Cotation Saxo réelle",
|
||||||
|
"saxo_surface": "Surface de vol Saxo (réelle)",
|
||||||
|
"yfinance_bs": "Black-Scholes (vol historique yfinance)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _intrinsic(S: float, K: float, option_type: str) -> float:
|
||||||
|
return max(0.0, S - K) if option_type == "call" else max(0.0, K - S)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_payoff(pos: Dict[str, Any], n_points: int = 61, range_pct: float = 0.25) -> Dict[str, Any]:
|
||||||
|
"""P&L vs. underlying price across a ±range_pct band around the current spot — two
|
||||||
|
curves: "at_expiry" (pure intrinsic value, no vol at all — the textbook payoff diagram)
|
||||||
|
and "today" (Black-Scholes reprice at each hypothetical spot, holding each leg's
|
||||||
|
CURRENT implied vol fixed — from the real Saxo surface when linked, so the time-value
|
||||||
|
bulge/skew asymmetry actually reflects Saxo's real market vol instead of a flat
|
||||||
|
textbook number). Both curves net out entry cost and entry fees, so y=0 is genuine
|
||||||
|
breakeven, matching what the Position card's PnL already shows at the current spot."""
|
||||||
|
from datetime import date, datetime
|
||||||
|
from services.data_fetcher import get_quote
|
||||||
|
|
||||||
|
underlying = pos["underlying"]
|
||||||
|
legs = pos.get("legs", [])
|
||||||
|
if not legs:
|
||||||
|
return {"spot_range": [], "at_expiry": [], "today": [], "current_spot": None,
|
||||||
|
"entry_spot": pos.get("entry_underlying_price"), "strikes": [], "pricing_source": None}
|
||||||
|
|
||||||
|
expiry_date = pos.get("expiry_date") or ""
|
||||||
|
if expiry_date:
|
||||||
|
try:
|
||||||
|
exp = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date()
|
||||||
|
days_remaining = max(0, (exp - date.today()).days)
|
||||||
|
except ValueError:
|
||||||
|
days_remaining = 0
|
||||||
|
else:
|
||||||
|
entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date()
|
||||||
|
days_remaining = max(0, pos.get("expiry_days", 90) - (date.today() - entry).days)
|
||||||
|
|
||||||
|
r = 0.05
|
||||||
|
chain, surface = resolve_saxo_chain(underlying, target_days=max(days_remaining, 1))
|
||||||
|
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
|
||||||
|
fallback_sigma = compute_historical_iv(underlying)
|
||||||
|
S = chain["spot"] if chain else fallback_spot
|
||||||
|
|
||||||
|
resolved_legs = []
|
||||||
|
for leg in legs:
|
||||||
|
K = leg.get("strike") or S
|
||||||
|
opt_type = leg.get("option_type", "call")
|
||||||
|
entry_premium = leg.get("premium_paid")
|
||||||
|
if entry_premium is None:
|
||||||
|
entry_premium = price_leg(K, opt_type, pos.get("expiry_days", 90), r, chain, surface,
|
||||||
|
expiry_date, fallback_spot, fallback_sigma)["price"]
|
||||||
|
priced_now = price_leg(K, opt_type, days_remaining, r, chain, surface, expiry_date, fallback_spot, fallback_sigma)
|
||||||
|
resolved_legs.append({
|
||||||
|
"strike": K, "option_type": opt_type, "qty": leg.get("quantity", 1),
|
||||||
|
"sign": 1 if leg.get("position", "long") == "long" else -1,
|
||||||
|
"entry_premium": entry_premium, "sigma": priced_now["sigma"],
|
||||||
|
})
|
||||||
|
|
||||||
|
ib_entry = pos.get("ib_fees_entry", 0)
|
||||||
|
lo, hi = S * (1 - range_pct), S * (1 + range_pct)
|
||||||
|
spot_range = [lo + (hi - lo) * i / (n_points - 1) for i in range(n_points)]
|
||||||
|
T_remaining = days_remaining / 365
|
||||||
|
|
||||||
|
at_expiry, today = [], []
|
||||||
|
for Sx in spot_range:
|
||||||
|
pnl_exp = -ib_entry
|
||||||
|
pnl_today = -ib_entry
|
||||||
|
for leg in resolved_legs:
|
||||||
|
pnl_exp += leg["sign"] * leg["qty"] * 100 * (_intrinsic(Sx, leg["strike"], leg["option_type"]) - leg["entry_premium"])
|
||||||
|
bs_price = (black_scholes(Sx, leg["strike"], T_remaining, r, leg["sigma"], leg["option_type"])["price"]
|
||||||
|
if T_remaining > 0 else _intrinsic(Sx, leg["strike"], leg["option_type"]))
|
||||||
|
pnl_today += leg["sign"] * leg["qty"] * 100 * (bs_price - leg["entry_premium"])
|
||||||
|
at_expiry.append(round(pnl_exp, 2))
|
||||||
|
today.append(round(pnl_today, 2))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"spot_range": [round(s, 4) for s in spot_range],
|
||||||
|
"at_expiry": at_expiry,
|
||||||
|
"today": today,
|
||||||
|
"current_spot": round(S, 4),
|
||||||
|
"entry_spot": pos.get("entry_underlying_price"),
|
||||||
|
"strikes": sorted({leg["strike"] for leg in resolved_legs}),
|
||||||
|
"pricing_source": "saxo" if chain else "yfinance_bs",
|
||||||
|
}
|
||||||
229
backend/services/portfolio_scenarios.py
Normal file
229
backend/services/portfolio_scenarios.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"""
|
||||||
|
Portfolio scenario-exposure — answers "if macro scenario X happens, how does my ACTUAL
|
||||||
|
book of open positions react, and how many of my positions are really the same bet wearing
|
||||||
|
different tickers?" Distinct from two pre-existing, coarser tools:
|
||||||
|
- services.data_fetcher.score_macro_scenarios(): the GLOBAL 8-scenario macro regime,
|
||||||
|
qualitative asset-class bias only (bullish/bearish/neutral), used for the top-level
|
||||||
|
regime badge — not calibrated to numeric spot shocks and has no gold-bearish or
|
||||||
|
forex-directional case, so it can't tell two option positions apart.
|
||||||
|
- services.database.get_risk_dashboard()/get_risk_clusters(): buckets capital by
|
||||||
|
asset_class and by a geopolitical-trigger keyword match — blind to whether a position
|
||||||
|
is long or short its underlying, so a bullish and a bearish position on the same
|
||||||
|
ticker land in the same bucket.
|
||||||
|
This module instead reprices each position's REAL legs (same Saxo-first pricing as
|
||||||
|
services.portfolio_pricing, used by mark-to-market and the payoff chart) under a small set
|
||||||
|
of named spot/vol shocks, so positions on different tickers that both profit from the same
|
||||||
|
shock get flagged as the SAME risk bet — e.g. a short S&P call spread, a long gold put
|
||||||
|
spread and a short crude call spread can all really be "one Risk-Off bet, three times."
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
SCENARIOS: List[Dict[str, str]] = [
|
||||||
|
{"key": "risk_off", "label": "Risk-Off / Ralentissement"},
|
||||||
|
{"key": "risk_on", "label": "Reprise économique / Risk-On"},
|
||||||
|
{"key": "inflation_persistante", "label": "Inflation persistante"},
|
||||||
|
{"key": "dollar_fort", "label": "Dollar fort"},
|
||||||
|
{"key": "commodities_baisse", "label": "Baisse des matières premières"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# asset_class -> scenario_key -> (spot_shock_pct, vol_shock_abs added to the leg's resolved sigma)
|
||||||
|
_DIMENSION_SHOCKS: Dict[str, Dict[str, Tuple[float, float]]] = {
|
||||||
|
"indices": {
|
||||||
|
"risk_off": (-0.08, 0.06), "risk_on": (0.07, -0.03), "inflation_persistante": (-0.05, 0.04),
|
||||||
|
"dollar_fort": (-0.02, 0.01), "commodities_baisse": (0.01, -0.01),
|
||||||
|
},
|
||||||
|
"equities": {
|
||||||
|
"risk_off": (-0.08, 0.06), "risk_on": (0.07, -0.03), "inflation_persistante": (-0.05, 0.04),
|
||||||
|
"dollar_fort": (-0.02, 0.01), "commodities_baisse": (0.01, -0.01),
|
||||||
|
},
|
||||||
|
"energy": {
|
||||||
|
"risk_off": (-0.10, 0.08), "risk_on": (0.08, -0.04), "inflation_persistante": (0.10, 0.05),
|
||||||
|
"dollar_fort": (-0.05, 0.02), "commodities_baisse": (-0.12, 0.03),
|
||||||
|
},
|
||||||
|
"metals": {
|
||||||
|
"risk_off": (0.05, 0.03), "risk_on": (-0.04, -0.02), "inflation_persistante": (0.08, 0.04),
|
||||||
|
"dollar_fort": (-0.06, 0.02), "commodities_baisse": (-0.08, 0.02),
|
||||||
|
},
|
||||||
|
"agriculture": {
|
||||||
|
"risk_off": (-0.03, 0.03), "risk_on": (0.03, -0.02), "inflation_persistante": (0.09, 0.04),
|
||||||
|
"dollar_fort": (-0.04, 0.01), "commodities_baisse": (-0.10, 0.03),
|
||||||
|
},
|
||||||
|
"forex": {
|
||||||
|
# Expressed as "USD strength" moves — sign is flipped per-pair by _fx_dollar_sign()
|
||||||
|
# depending on whether USD is the base or quote currency.
|
||||||
|
"risk_off": (0.03, 0.03), "risk_on": (-0.03, -0.02), "inflation_persistante": (-0.02, 0.03),
|
||||||
|
"dollar_fort": (0.05, 0.01), "commodities_baisse": (0.01, -0.01),
|
||||||
|
},
|
||||||
|
"rates": {
|
||||||
|
"risk_off": (0.04, 0.02), "risk_on": (-0.03, -0.01), "inflation_persistante": (-0.06, 0.03),
|
||||||
|
"dollar_fort": (0.01, 0.01), "commodities_baisse": (0.01, -0.01),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fx_dollar_sign(ticker: str) -> int:
|
||||||
|
"""+1 if USD is the base currency (pair rises when USD strengthens, e.g. USDJPY),
|
||||||
|
-1 if USD is the quote currency (pair falls when USD strengthens, e.g. EURUSD),
|
||||||
|
0 for a non-USD cross where the "dollar strength" dimension doesn't clearly apply."""
|
||||||
|
t = (ticker or "").upper().replace("=X", "").replace("/", "")
|
||||||
|
if t.startswith("USD"):
|
||||||
|
return 1
|
||||||
|
if t.endswith("USD"):
|
||||||
|
return -1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _reprice_position(pos: Dict[str, Any], spot_shock_pct: float, vol_shock_abs: float) -> Optional[float]:
|
||||||
|
"""Real Black-Scholes reprice of this position's legs at a shocked spot/vol, mirroring
|
||||||
|
services.portfolio_pricing.compute_payoff's methodology but at ONE target spot instead
|
||||||
|
of a curve (no time decay applied — a "if this happened right now" snapshot). Returns
|
||||||
|
estimated P&L in currency units, or None if the position has no legs to price."""
|
||||||
|
from datetime import date, datetime
|
||||||
|
from services.portfolio_pricing import resolve_saxo_chain, price_leg
|
||||||
|
from services.options_pricer import black_scholes
|
||||||
|
from services.data_fetcher import get_quote, compute_historical_iv
|
||||||
|
|
||||||
|
underlying = pos["underlying"]
|
||||||
|
legs = pos.get("legs", [])
|
||||||
|
if not legs:
|
||||||
|
return None
|
||||||
|
|
||||||
|
expiry_date = pos.get("expiry_date") or ""
|
||||||
|
if expiry_date:
|
||||||
|
try:
|
||||||
|
exp = datetime.strptime(expiry_date[:10], "%Y-%m-%d").date()
|
||||||
|
days_remaining = max(0, (exp - date.today()).days)
|
||||||
|
except ValueError:
|
||||||
|
days_remaining = 0
|
||||||
|
else:
|
||||||
|
entry = datetime.strptime(pos["entry_date"][:10], "%Y-%m-%d").date()
|
||||||
|
days_remaining = max(0, pos.get("expiry_days", 90) - (date.today() - entry).days)
|
||||||
|
|
||||||
|
r = 0.05
|
||||||
|
chain, surface = resolve_saxo_chain(underlying, target_days=max(days_remaining, 1))
|
||||||
|
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
|
||||||
|
fallback_sigma = compute_historical_iv(underlying)
|
||||||
|
S = chain["spot"] if chain else fallback_spot
|
||||||
|
S_shocked = S * (1 + spot_shock_pct)
|
||||||
|
T_remaining = days_remaining / 365
|
||||||
|
|
||||||
|
pnl = -pos.get("ib_fees_entry", 0)
|
||||||
|
for leg in legs:
|
||||||
|
K = leg.get("strike") or S
|
||||||
|
opt_type = leg.get("option_type", "call")
|
||||||
|
qty = leg.get("quantity", 1)
|
||||||
|
sign = 1 if leg.get("position", "long") == "long" else -1
|
||||||
|
priced_now = price_leg(K, opt_type, days_remaining, r, chain, surface, expiry_date, fallback_spot, fallback_sigma)
|
||||||
|
entry_premium = leg.get("premium_paid")
|
||||||
|
if entry_premium is None:
|
||||||
|
entry_premium = priced_now["price"]
|
||||||
|
sigma = max(0.01, priced_now["sigma"] + vol_shock_abs)
|
||||||
|
if T_remaining > 0:
|
||||||
|
shocked_price = black_scholes(S_shocked, K, T_remaining, r, sigma, opt_type)["price"]
|
||||||
|
else:
|
||||||
|
shocked_price = max(0.0, S_shocked - K) if opt_type == "call" else max(0.0, K - S_shocked)
|
||||||
|
pnl += sign * qty * 100 * (shocked_price - entry_premium)
|
||||||
|
return pnl
|
||||||
|
|
||||||
|
|
||||||
|
def compute_scenario_exposure() -> Dict[str, Any]:
|
||||||
|
"""Reprices every open position under each named scenario, then aggregates two views:
|
||||||
|
- `scenarios`: per-scenario portfolio-wide estimated P&L (the "sensitivity matrix").
|
||||||
|
- `concentration`: for each position, the scenario that would benefit it MOST, then
|
||||||
|
the capital-weighted % of the portfolio sharing that same dominant scenario (the
|
||||||
|
"X% of your book is really one bet" bars) — the whole point being to surface when
|
||||||
|
several differently-named positions are actually the same directional wager.
|
||||||
|
"""
|
||||||
|
from services.database import get_positions
|
||||||
|
|
||||||
|
positions = get_positions("open")
|
||||||
|
if not positions:
|
||||||
|
return {"positions": 0, "total_capital": 0, "scenarios": [], "concentration": [],
|
||||||
|
"dominant_scenario": None, "unpriced": [], "warning": None}
|
||||||
|
|
||||||
|
priced: List[Dict[str, Any]] = []
|
||||||
|
unpriced: List[Dict[str, Any]] = []
|
||||||
|
for pos in positions:
|
||||||
|
ac = (pos.get("asset_class") or "indices").lower()
|
||||||
|
dims = _DIMENSION_SHOCKS.get(ac, _DIMENSION_SHOCKS["indices"])
|
||||||
|
fx_sign = _fx_dollar_sign(pos["underlying"]) if ac == "forex" else 1
|
||||||
|
|
||||||
|
scenario_pnl: Dict[str, Optional[float]] = {}
|
||||||
|
for scen in SCENARIOS:
|
||||||
|
key = scen["key"]
|
||||||
|
spot_shock, vol_shock = dims.get(key, (0.0, 0.0))
|
||||||
|
if ac == "forex":
|
||||||
|
spot_shock = spot_shock * fx_sign
|
||||||
|
scenario_pnl[key] = _reprice_position(pos, spot_shock, vol_shock)
|
||||||
|
|
||||||
|
if all(v is None for v in scenario_pnl.values()):
|
||||||
|
unpriced.append({"id": pos["id"], "title": pos.get("title", pos["underlying"])})
|
||||||
|
continue
|
||||||
|
|
||||||
|
priced.append({
|
||||||
|
"id": pos["id"], "title": pos.get("title", pos["underlying"]),
|
||||||
|
"underlying": pos["underlying"], "asset_class": ac,
|
||||||
|
"capital_invested": max(pos.get("capital_invested") or 0, 0),
|
||||||
|
"scenario_pnl": scenario_pnl,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not priced:
|
||||||
|
return {"positions": len(positions), "total_capital": 0, "scenarios": [], "concentration": [],
|
||||||
|
"dominant_scenario": None, "unpriced": unpriced, "warning": None}
|
||||||
|
|
||||||
|
total_capital = sum(p["capital_invested"] for p in priced) or 1.0
|
||||||
|
|
||||||
|
scenario_results = []
|
||||||
|
for scen in SCENARIOS:
|
||||||
|
key = scen["key"]
|
||||||
|
total_pnl = sum(p["scenario_pnl"].get(key) or 0 for p in priced)
|
||||||
|
scenario_results.append({
|
||||||
|
"key": key, "label": scen["label"],
|
||||||
|
"portfolio_pnl": round(total_pnl, 2),
|
||||||
|
"portfolio_pnl_pct": round(total_pnl / total_capital * 100, 2),
|
||||||
|
"positions": [
|
||||||
|
{
|
||||||
|
"id": p["id"], "title": p["title"],
|
||||||
|
"pnl": round(p["scenario_pnl"].get(key) or 0, 2),
|
||||||
|
"pnl_pct": round((p["scenario_pnl"].get(key) or 0) / max(p["capital_invested"], 1) * 100, 1),
|
||||||
|
}
|
||||||
|
for p in priced
|
||||||
|
],
|
||||||
|
})
|
||||||
|
scenario_results.sort(key=lambda s: -abs(s["portfolio_pnl_pct"]))
|
||||||
|
|
||||||
|
# Concentration: which scenario is each position's single most favorable outcome?
|
||||||
|
weight_by_scenario: Dict[str, float] = {s["key"]: 0.0 for s in SCENARIOS}
|
||||||
|
for p in priced:
|
||||||
|
best_key = max(p["scenario_pnl"], key=lambda k: (p["scenario_pnl"].get(k) if p["scenario_pnl"].get(k) is not None else float("-inf")))
|
||||||
|
weight_by_scenario[best_key] = weight_by_scenario.get(best_key, 0.0) + p["capital_invested"]
|
||||||
|
|
||||||
|
concentration = [
|
||||||
|
{"key": key, "label": next(s["label"] for s in SCENARIOS if s["key"] == key),
|
||||||
|
"pct_of_portfolio": round(w / total_capital * 100, 1)}
|
||||||
|
for key, w in weight_by_scenario.items() if w > 0
|
||||||
|
]
|
||||||
|
concentration.sort(key=lambda c: -c["pct_of_portfolio"])
|
||||||
|
dominant_scenario = concentration[0] if concentration else None
|
||||||
|
|
||||||
|
warning = None
|
||||||
|
if dominant_scenario and dominant_scenario["pct_of_portfolio"] >= 60 and len(priced) >= 3:
|
||||||
|
warning = (
|
||||||
|
f"{dominant_scenario['pct_of_portfolio']:.0f}% du portefeuille gagne surtout dans le même "
|
||||||
|
f"scénario ({dominant_scenario['label']}) — vos {len(priced)} positions ne sont pas aussi "
|
||||||
|
f"diversifiées qu'il n'y paraît, c'est en grande partie un seul pari macro répété."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"positions": len(priced),
|
||||||
|
"total_capital": round(total_capital, 2),
|
||||||
|
"scenarios": scenario_results,
|
||||||
|
"concentration": concentration,
|
||||||
|
"dominant_scenario": dominant_scenario,
|
||||||
|
"unpriced": unpriced,
|
||||||
|
"warning": warning,
|
||||||
|
}
|
||||||
@@ -317,6 +317,25 @@ export const usePnlHistory = () =>
|
|||||||
queryFn: () => api.get('/portfolio/pnl-history').then(r => r.data),
|
queryFn: () => api.get('/portfolio/pnl-history').then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// P&L-vs-underlying-price payoff diagram for one position (services.portfolio_pricing.
|
||||||
|
// compute_payoff) — at-expiry (pure intrinsic) + today (current vol held fixed) curves.
|
||||||
|
export const usePositionPayoff = (posId: string, enabled: boolean) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['portfolio-payoff', posId],
|
||||||
|
queryFn: () => api.get(`/portfolio/positions/${posId}/payoff`).then(r => r.data),
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// when several differently-named positions are really the same underlying bet.
|
||||||
|
export const usePortfolioScenarioExposure = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['portfolio-scenario-exposure'],
|
||||||
|
queryFn: () => api.get('/portfolio/scenario-exposure').then(r => r.data),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
export const useAddPosition = () => {
|
export const useAddPosition = () => {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
useRiskDashboard, useRiskRadar, useGeoNews,
|
useRiskDashboard, useRiskRadar, useGeoNews,
|
||||||
useCycleStatus,
|
useCycleStatus,
|
||||||
useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useWatchlistHistory, useQuickAddInstrument, useSaxoIvWatchlist, useLatestCycleReport,
|
useInstrumentsWatchlist, useInstrumentsWatchlistQuotes, useWatchlistHistory, useQuickAddInstrument, useSaxoIvWatchlist, useLatestCycleReport,
|
||||||
useWatchlistCurveRegimes,
|
useWatchlistCurveRegimes, usePortfolioScenarioExposure,
|
||||||
} from '../hooks/useApi'
|
} from '../hooks/useApi'
|
||||||
import { Clock, Globe, ArrowUpRight, Newspaper, Waves, Link2 } from 'lucide-react'
|
import { Clock, Globe, ArrowUpRight, Newspaper, Waves, Link2 } from 'lucide-react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
@@ -117,6 +117,7 @@ export default function Dashboard() {
|
|||||||
const { data: openPositionsData } = usePortfolioPositions('open')
|
const { data: openPositionsData } = usePortfolioPositions('open')
|
||||||
const { data: riskDashboard } = useRiskDashboard()
|
const { data: riskDashboard } = useRiskDashboard()
|
||||||
const { data: riskRadarData } = useRiskRadar()
|
const { data: riskRadarData } = useRiskRadar()
|
||||||
|
const { data: scenarioExposure } = usePortfolioScenarioExposure()
|
||||||
const { data: cycleStatusData } = useCycleStatus()
|
const { data: cycleStatusData } = useCycleStatus()
|
||||||
const { data: geoNews } = useGeoNews()
|
const { data: geoNews } = useGeoNews()
|
||||||
const { data: watchlistItems } = useInstrumentsWatchlist()
|
const { data: watchlistItems } = useInstrumentsWatchlist()
|
||||||
@@ -222,9 +223,19 @@ export default function Dashboard() {
|
|||||||
// suffix-stripping needed, those were solving a mismatch that didn't actually exist.
|
// suffix-stripping needed, those were solving a mismatch that didn't actually exist.
|
||||||
const nameByUnderlyingTicker: Record<string, string> = {}
|
const nameByUnderlyingTicker: Record<string, string> = {}
|
||||||
for (const w of ((watchlistItems as any[]) ?? [])) nameByUnderlyingTicker[String(w.ticker).trim().toUpperCase()] = w.name || w.ticker
|
for (const w of ((watchlistItems as any[]) ?? [])) nameByUnderlyingTicker[String(w.ticker).trim().toUpperCase()] = w.name || w.ticker
|
||||||
|
// A handful of positions trade via a liquid options-ETF proxy rather than the
|
||||||
|
// Watchlist's own quote ticker for the same underlying (e.g. Brent futures options
|
||||||
|
// aren't broadly available, so Brent exposure trades via BNO — the United States Brent
|
||||||
|
// Oil Fund ETF — instead of the BZ=F/BRENT quote ticker). Extend here if another proxy
|
||||||
|
// ticker shows up unrenamed.
|
||||||
|
const PROXY_TICKER_ALIASES: Record<string, string> = { 'BNO': 'BRENT' }
|
||||||
const underlyingDisplayName = (underlying: string): string => {
|
const underlyingDisplayName = (underlying: string): string => {
|
||||||
if (!underlying) return underlying
|
if (!underlying) return underlying
|
||||||
return nameByUnderlyingTicker[underlying.trim().toUpperCase()] || underlying
|
const upper = underlying.trim().toUpperCase()
|
||||||
|
if (nameByUnderlyingTicker[upper]) return nameByUnderlyingTicker[upper]
|
||||||
|
const proxyTicker = PROXY_TICKER_ALIASES[upper]
|
||||||
|
if (proxyTicker && nameByUnderlyingTicker[proxyTicker]) return nameByUnderlyingTicker[proxyTicker]
|
||||||
|
return underlying
|
||||||
}
|
}
|
||||||
|
|
||||||
// Patterns from the last cycle only (filter by created_at >= cycle started_at)
|
// Patterns from the last cycle only (filter by created_at >= cycle started_at)
|
||||||
@@ -816,6 +827,8 @@ export default function Dashboard() {
|
|||||||
.filter(d => d.value > 0)
|
.filter(d => d.value > 0)
|
||||||
.sort((a, b) => b.value - a.value)
|
.sort((a, b) => b.value - a.value)
|
||||||
const radarAxes = ((riskRadarData as any)?.axes ?? []).map((a: any) => ({ ...a, value: a.value ?? 0 }))
|
const radarAxes = ((riskRadarData as any)?.axes ?? []).map((a: any) => ({ ...a, value: a.value ?? 0 }))
|
||||||
|
const dominantScenario = (scenarioExposure as any)?.dominant_scenario
|
||||||
|
const scenarioWarning = (scenarioExposure as any)?.warning
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to="/risk" className="card flex flex-col overflow-y-auto hover:border-slate-600/60 transition-all cursor-pointer"
|
<Link to="/risk" className="card flex flex-col overflow-y-auto hover:border-slate-600/60 transition-all cursor-pointer"
|
||||||
@@ -827,6 +840,12 @@ export default function Dashboard() {
|
|||||||
<div className={clsx('text-[10px] font-bold mt-0.5 flex items-center gap-2', alertCount > 0 ? 'text-red-400' : 'text-emerald-400')}>
|
<div className={clsx('text-[10px] font-bold mt-0.5 flex items-center gap-2', alertCount > 0 ? 'text-red-400' : 'text-emerald-400')}>
|
||||||
{alertCount > 0 ? `${alertCount} alert${alertCount > 1 ? 's' : ''}` : 'OK'}
|
{alertCount > 0 ? `${alertCount} alert${alertCount > 1 ? 's' : ''}` : 'OK'}
|
||||||
</div>
|
</div>
|
||||||
|
{scenarioWarning && dominantScenario && (
|
||||||
|
<div className="mt-1 text-[9px] leading-snug text-amber-400 bg-amber-900/10 border border-amber-700/20 rounded px-1.5 py-1">
|
||||||
|
⚠ {dominantScenario.pct_of_portfolio.toFixed(0)}% du book sur le même pari macro
|
||||||
|
(<span className="font-semibold">{dominantScenario.label}</span>)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{radarAxes.length > 0 && (
|
{radarAxes.length > 0 && (
|
||||||
<ResponsiveContainer width="100%" height={180}>
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
<RadarChart data={radarAxes}>
|
<RadarChart data={radarAxes}>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
usePortfolioPositions, usePortfolioSummary, usePnlHistory,
|
usePortfolioPositions, usePortfolioSummary, usePnlHistory,
|
||||||
useAddPosition, useClosePosition
|
useAddPosition, useClosePosition, usePositionPayoff
|
||||||
} from '../hooks/useApi'
|
} from '../hooks/useApi'
|
||||||
import { useQueryClient, useMutation } from '@tanstack/react-query'
|
import { useQueryClient, useMutation } from '@tanstack/react-query'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||||
CartesianGrid, ReferenceLine, BarChart, Bar
|
CartesianGrid, ReferenceLine, BarChart, Bar, LineChart, Line
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { TrendingUp, TrendingDown, Plus, X, DollarSign, BarChart2, RefreshCw, Trash2, ExternalLink, ChevronDown, ChevronUp } from 'lucide-react'
|
import { TrendingUp, TrendingDown, Plus, X, DollarSign, BarChart2, RefreshCw, Trash2, ExternalLink, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import type { TradeIdea } from '../types'
|
import type { TradeIdea } from '../types'
|
||||||
@@ -25,6 +25,12 @@ const useDeletePosition = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PRICING_SOURCE_LABELS: Record<string, string> = {
|
||||||
|
saxo_quote: 'Cotation Saxo réelle',
|
||||||
|
saxo_surface: 'Surface de vol Saxo',
|
||||||
|
yfinance_bs: 'BS (vol yfinance)',
|
||||||
|
}
|
||||||
|
|
||||||
const STRATEGIES = ['Long Call', 'Long Put', 'Bull Call Spread', 'Bear Put Spread', 'Long Straddle', 'Long Strangle', 'Covered Call']
|
const STRATEGIES = ['Long Call', 'Long Put', 'Bull Call Spread', 'Bear Put Spread', 'Long Straddle', 'Long Strangle', 'Covered Call']
|
||||||
const ASSET_CLASSES = ['energy', 'metals', 'agriculture', 'equities', 'indices', 'forex']
|
const ASSET_CLASSES = ['energy', 'metals', 'agriculture', 'equities', 'indices', 'forex']
|
||||||
|
|
||||||
@@ -205,6 +211,58 @@ function AddPositionModal({ prefill, onClose }: AddModalProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P&L-vs-underlying-price payoff diagram — "at expiry" (pure intrinsic, no vol) vs "today"
|
||||||
|
// (Black-Scholes reprice holding each leg's current implied vol fixed, from the real Saxo
|
||||||
|
// surface when the option chain is linked — see services.portfolio_pricing.compute_payoff).
|
||||||
|
function PositionPayoffChart({ posId, enabled, legs }: { posId: string; enabled: boolean; legs: any[] }) {
|
||||||
|
const { data, isLoading } = usePositionPayoff(posId, enabled)
|
||||||
|
if (!enabled) return null
|
||||||
|
if (isLoading) return <div className="text-slate-600 text-center py-6 mt-2">Calcul du payoff…</div>
|
||||||
|
if (!data || !data.spot_range?.length) return null
|
||||||
|
|
||||||
|
const chartData = data.spot_range.map((s: number, i: number) => ({
|
||||||
|
spot: s, atExpiry: data.at_expiry[i], today: data.today[i],
|
||||||
|
}))
|
||||||
|
const strikes: number[] = data.strikes ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-700/30">
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<span className="text-slate-500 uppercase tracking-wide text-[10px]">Payoff — P&L vs. spot sous-jacent</span>
|
||||||
|
<span className={clsx('text-[10px]', data.pricing_source === 'yfinance_bs' ? 'text-amber-400' : 'text-emerald-400')}>
|
||||||
|
{data.pricing_source === 'yfinance_bs' ? 'Vol yfinance (pas de chain Saxo lié)' : 'Vol Saxo réelle'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 4, right: 8, left: -14, bottom: 0 }}>
|
||||||
|
<CartesianGrid stroke="#1e2d4d" strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="spot" tick={{ fontSize: 9, fill: '#64748b' }}
|
||||||
|
tickFormatter={(v: number) => v.toFixed(v >= 1000 ? 0 : 2)} />
|
||||||
|
<YAxis tick={{ fontSize: 9, fill: '#64748b' }} tickFormatter={(v: number) => `${(v / 1000).toFixed(0)}k`} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10, padding: '4px 8px' }}
|
||||||
|
formatter={(v: number, name: string) => [`${v.toFixed(2)}€`, name === 'atExpiry' ? 'À échéance' : "Aujourd'hui"]}
|
||||||
|
labelFormatter={(v: number) => `Spot ${v.toFixed(2)}`}
|
||||||
|
/>
|
||||||
|
<ReferenceLine y={0} stroke="#475569" />
|
||||||
|
{data.current_spot != null && <ReferenceLine x={data.current_spot} stroke="#3b82f6" strokeDasharray="4 2" label={{ value: 'Spot', fontSize: 9, fill: '#3b82f6', position: 'top' }} />}
|
||||||
|
{data.entry_spot != null && <ReferenceLine x={data.entry_spot} stroke="#64748b" strokeDasharray="2 2" />}
|
||||||
|
{strikes.map((k: number) => (
|
||||||
|
<ReferenceLine key={k} x={k} stroke="#f59e0b" strokeOpacity={0.4} strokeDasharray="2 2" />
|
||||||
|
))}
|
||||||
|
<Line type="monotone" dataKey="atExpiry" stroke="#22c55e" strokeWidth={1.5} dot={false} isAnimationActive={false} name="atExpiry" />
|
||||||
|
<Line type="monotone" dataKey="today" stroke="#3b82f6" strokeWidth={1.5} strokeDasharray="4 2" dot={false} isAnimationActive={false} name="today" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex items-center gap-3 text-[9px] text-slate-600 mt-1">
|
||||||
|
<span className="flex items-center gap-1"><span className="w-2.5 h-0.5 bg-emerald-500 inline-block" /> À échéance</span>
|
||||||
|
<span className="flex items-center gap-1"><span className="w-2.5 h-0.5 bg-blue-500 inline-block" style={{ borderTop: '1.5px dashed #3b82f6', background: 'none' }} /> Aujourd'hui (vol actuelle)</span>
|
||||||
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-500/40 inline-block" /> Strike{legs.length > 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function PositionCard({ pos }: { pos: Record<string, any> }) {
|
function PositionCard({ pos }: { pos: Record<string, any> }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [showClose, setShowClose] = useState(false)
|
const [showClose, setShowClose] = useState(false)
|
||||||
@@ -307,7 +365,14 @@ function PositionCard({ pos }: { pos: Record<string, any> }) {
|
|||||||
className="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400 mb-2 transition-colors"
|
className="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-400 mb-2 transition-colors"
|
||||||
>
|
>
|
||||||
{showDetails ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
{showDetails ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||||
Black-Scholes simulation detail
|
Détail de la valorisation
|
||||||
|
{pos.pricing_source_summary && (
|
||||||
|
<span className={clsx('ml-1 px-1.5 py-0.5 rounded text-[10px] font-normal',
|
||||||
|
pos.pricing_source_summary === 'yfinance_bs' ? 'bg-amber-900/30 text-amber-400' : 'bg-emerald-900/30 text-emerald-400'
|
||||||
|
)}>
|
||||||
|
{PRICING_SOURCE_LABELS[pos.pricing_source_summary] ?? pos.pricing_source_summary}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -319,7 +384,7 @@ function PositionCard({ pos }: { pos: Record<string, any> }) {
|
|||||||
<span>Entry spot: <span className="text-slate-300 font-mono">${Number(pos.entry_underlying_price).toFixed(2)}</span></span>
|
<span>Entry spot: <span className="text-slate-300 font-mono">${Number(pos.entry_underlying_price).toFixed(2)}</span></span>
|
||||||
)}
|
)}
|
||||||
{pos.sigma_used != null && (
|
{pos.sigma_used != null && (
|
||||||
<span>σ (hist. IV): <span className="text-slate-300 font-mono">{(pos.sigma_used * 100).toFixed(1)}%</span></span>
|
<span>σ (moy. legs): <span className="text-slate-300 font-mono">{(pos.sigma_used * 100).toFixed(1)}%</span></span>
|
||||||
)}
|
)}
|
||||||
<span>r: <span className="text-slate-300">5%</span></span>
|
<span>r: <span className="text-slate-300">5%</span></span>
|
||||||
{pos.expiry_days != null && (
|
{pos.expiry_days != null && (
|
||||||
@@ -334,6 +399,7 @@ function PositionCard({ pos }: { pos: Record<string, any> }) {
|
|||||||
<th className="text-right pb-1 font-normal">Qty</th>
|
<th className="text-right pb-1 font-normal">Qty</th>
|
||||||
<th className="text-right pb-1 font-normal">Premium/contract</th>
|
<th className="text-right pb-1 font-normal">Premium/contract</th>
|
||||||
<th className="text-right pb-1 font-normal">Total cost</th>
|
<th className="text-right pb-1 font-normal">Total cost</th>
|
||||||
|
<th className="text-right pb-1 font-normal">Source</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -364,14 +430,23 @@ function PositionCard({ pos }: { pos: Record<string, any> }) {
|
|||||||
<td className="py-1 text-right font-mono text-white">
|
<td className="py-1 text-right font-mono text-white">
|
||||||
{legTotal != null ? `$${legTotal.toFixed(2)}` : '—'}
|
{legTotal != null ? `$${legTotal.toFixed(2)}` : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-1 text-right">
|
||||||
|
{leg.pricing_source && (
|
||||||
|
<span className={clsx('text-[10px]', leg.pricing_source === 'yfinance_bs' ? 'text-amber-400' : 'text-emerald-400')}>
|
||||||
|
{PRICING_SOURCE_LABELS[leg.pricing_source] ?? leg.pricing_source}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div className="text-slate-600 italic">
|
<div className="text-slate-600 italic">
|
||||||
Black-Scholes · 1 contract = 100 shares · Price computed at entry time (spot, historical IV, r=5%)
|
1 contract = 100 shares · r=5% · Prix réel Saxo si l'option chain de cet instrument est liée (Config → Instruments Watchlist), sinon Black-Scholes sur vol historique yfinance
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PositionPayoffChart posId={pos.id} enabled={showDetails} legs={pos.legs} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -473,7 +548,7 @@ export default function Portfolio() {
|
|||||||
<DollarSign className="w-5 h-5 text-blue-400" /> Portfolio
|
<DollarSign className="w-5 h-5 text-blue-400" /> Portfolio
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-slate-500 mt-0.5">
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
Real-time tracking · Mark-to-market Black-Scholes · Simulated IB fees
|
Real-time tracking · Mark-to-market Saxo (option chain lié) ou Black-Scholes · Simulated IB fees
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRiskDashboard, usePatternCorrelations, usePnlTimeline, useRiskExposure, useSimPortfolioRisk } from '../hooks/useApi'
|
import { useRiskDashboard, usePatternCorrelations, usePnlTimeline, useRiskExposure, useSimPortfolioRisk, usePortfolioScenarioExposure } from '../hooks/useApi'
|
||||||
import { ASSET_CLASS_COLORS } from '../constants/assetColors'
|
import { ASSET_CLASS_COLORS } from '../constants/assetColors'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { ShieldAlert, TrendingUp, GitBranch, AlertTriangle, CheckCircle, Activity, Brain, RefreshCw, PieChart } from 'lucide-react'
|
import { ShieldAlert, TrendingUp, GitBranch, AlertTriangle, CheckCircle, Activity, Brain, RefreshCw, PieChart, Layers } from 'lucide-react'
|
||||||
|
|
||||||
// ── Gauge component ──────────────────────────────────────────────────────────
|
// ── Gauge component ──────────────────────────────────────────────────────────
|
||||||
function ConcentrationGauge({ label, pct, threshold = 50 }: { label: string; pct: number; threshold?: number }) {
|
function ConcentrationGauge({ label, pct, threshold = 50 }: { label: string; pct: number; threshold?: number }) {
|
||||||
@@ -131,6 +131,85 @@ function RecommendationCard({ rec }: { rec: any }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Scenario concentration ("same bet, different ticker") ───────────────────
|
||||||
|
function ScenarioExposureCard() {
|
||||||
|
const { data, isLoading } = usePortfolioScenarioExposure()
|
||||||
|
const exp: any = data
|
||||||
|
|
||||||
|
if (isLoading) return <div className="card animate-pulse h-40 bg-dark-700" />
|
||||||
|
if (!exp || !exp.positions) return null
|
||||||
|
|
||||||
|
const concentration: any[] = exp.concentration ?? []
|
||||||
|
const scenarios: any[] = exp.scenarios ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-sm font-semibold text-white mb-1 flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4 text-purple-400" /> Concentration par scénario macro
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-500 mb-3">
|
||||||
|
Repricing Black-Scholes réel (pricing Saxo-first) de chaque position sous 5 scénarios —
|
||||||
|
révèle quand plusieurs positions différentes sont en réalité le même pari répété.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exp.warning && (
|
||||||
|
<div className="mb-3 flex items-start gap-2 text-xs px-3 py-2 rounded border bg-amber-900/20 border-amber-700/30 text-amber-300">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||||
|
{exp.warning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
|
{/* Concentration bars */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-slate-400 mb-2">
|
||||||
|
% du book dont c'est le scénario le plus favorable
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{concentration.map((c: any) => (
|
||||||
|
<div key={c.key}>
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span className="text-slate-300">{c.label}</span>
|
||||||
|
<span className={clsx('font-mono font-bold', c.pct_of_portfolio >= 60 ? 'text-amber-400' : 'text-slate-300')}>
|
||||||
|
{c.pct_of_portfolio}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full bg-purple-500/70" style={{ width: `${Math.min(c.pct_of_portfolio, 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sensitivity matrix */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-slate-400 mb-2">P&L estimé du portefeuille par scénario</div>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<tbody>
|
||||||
|
{scenarios.map((s: any) => (
|
||||||
|
<tr key={s.key} className="border-b border-slate-800/40">
|
||||||
|
<td className="py-1.5 pr-3 text-slate-300">{s.label}</td>
|
||||||
|
<td className={clsx('py-1.5 text-right font-mono font-bold whitespace-nowrap',
|
||||||
|
s.portfolio_pnl_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||||
|
{s.portfolio_pnl_pct >= 0 ? '+' : ''}{s.portfolio_pnl_pct}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exp.unpriced?.length > 0 && (
|
||||||
|
<div className="text-[10px] text-slate-600 mt-3 pt-2 border-t border-slate-700/30">
|
||||||
|
{exp.unpriced.length} position(s) non pricée(s) (pas de legs/données) : {exp.unpriced.map((u: any) => u.title).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main page ────────────────────────────────────────────────────────────────
|
// ── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SimRiskPanel() {
|
function SimRiskPanel() {
|
||||||
@@ -416,6 +495,9 @@ export default function RiskDashboard() {
|
|||||||
{/* Recommendation */}
|
{/* Recommendation */}
|
||||||
<RecommendationCard rec={d.recommendation} />
|
<RecommendationCard rec={d.recommendation} />
|
||||||
|
|
||||||
|
{/* Scenario concentration — same bet, different ticker */}
|
||||||
|
<ScenarioExposureCard />
|
||||||
|
|
||||||
{/* Alerts */}
|
{/* Alerts */}
|
||||||
{(d.concentration_alerts ?? []).length > 0 && (
|
{(d.concentration_alerts ?? []).length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user