feat: risk
This commit is contained in:
@@ -3342,6 +3342,23 @@ def get_instruments_watchlist() -> List[Dict]:
|
||||
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:
|
||||
"""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
|
||||
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user