Portfolio-Risk-Platform / backend /services /narrative_engine.py
sheikhkmmtahmid's picture
Initial commit: ML-Powered Portfolio Stress Testing Platform
031a2d6
"""
Narrative Engine — Phase 9
Reads Phase 7 (portfolio) and Phase 8 (SHAP) outputs and generates:
- Portfolio-level factor exposure decomposition (weighted SHAP)
- Plain-English insights modelled on quant portfolio commentary
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Dict, List
import numpy as np
import pandas as pd
ASSETS = ["spx", "ndx", "gold", "btc"]
PORTFOLIO_ASSETS = ["spx", "ndx", "gold"] # BTC excluded from MVO weights
ASSET_LABELS = {
"spx": "S&P 500 (SPX)",
"ndx": "Nasdaq 100 (NDX)",
"gold": "Gold",
"btc": "Bitcoin (BTC)",
}
FACTOR_LABELS = {
"us_cpi_yoy": "US CPI inflation",
"vix_level": "market volatility (VIX)",
"us10y_yield": "10-year Treasury yield",
"us2y_yield": "2-year Treasury yield",
"yield_spread": "yield curve (2s10s spread)",
"high_yield_spread": "high-yield credit spreads",
"spx_vol_3m": "S&P 500 realized volatility",
"eurusd_return": "EUR/USD exchange rate",
"gbpusd_return": "GBP/USD exchange rate",
"spx_return": "S&P 500 momentum",
"ndx_return": "Nasdaq momentum",
"gold_return": "Gold momentum",
"ecb_level": "ECB policy rate",
"ecb_yoy": "ECB rate change (YoY)",
"regime_confidence": "regime certainty",
"dxy_return": "US Dollar index",
"qqq_return": "Nasdaq QQQ momentum",
"btc_return": "Bitcoin momentum",
}
REGIME_CONTEXT = {
"calm": {
"label": "calm growth",
"description": "low volatility, steady growth — risk assets typically perform well",
"equity_bias": "positive",
"gold_bias": "neutral",
"fx_gold_bias": "the typical inverse gold-dollar relationship holds; dollar strength modestly pressures gold",
},
"inflation_stress": {
"label": "inflation stress",
"description": "elevated inflation with central bank tightening — growth equities under pressure, commodities supported",
"equity_bias": "negative",
"gold_bias": "positive",
"fx_gold_bias": "gold rallies despite potential dollar strength as inflation-hedge demand overrides the usual FX drag",
},
"credit_stress": {
"label": "credit stress",
"description": "widening credit spreads and risk-off sentiment — quality and defensive assets preferred",
"equity_bias": "cautious",
"gold_bias": "positive",
"fx_gold_bias": "safe-haven flows lift both gold and the dollar simultaneously, temporarily breaking their typical inverse link",
},
"crisis": {
"label": "acute crisis",
"description": "systemic market stress — correlations spike and diversification breaks down",
"equity_bias": "very negative",
"gold_bias": "mixed (initial sell-off then recovery)",
"fx_gold_bias": "an initial dollar surge pressures gold, followed by a safe-haven recovery once acute liquidity stress eases",
},
}
class NarrativeEngine:
"""
Generates quant + plain-English explanations from Phase 7 & 8 outputs.
Call .generate() to get the full explanation payload.
"""
def __init__(self, backend_root: str | Path) -> None:
self.root = Path(backend_root)
self._load()
# ── Data loading ──────────────────────────────────────────────────────────
def _load(self) -> None:
p = self.root / "data"
m = self.root / "models"
self.weights_adj = pd.read_csv(p / "portfolio" / "portfolio_weights_regime_adjusted.csv")
self.weights_base = pd.read_csv(p / "portfolio" / "portfolio_weights.csv")
self.stress = pd.read_csv(p / "portfolio" / "stress_test_results.csv")
self.cov_matrix = pd.read_csv(p / "portfolio" / "covariance_matrix.csv", index_col=0)
self.er_series = pd.read_csv(p / "portfolio" / "expected_returns.csv")
with open(p / "portfolio" / "portfolio_metrics.json", "r", encoding="utf-8") as f:
self.metrics = json.load(f)
with open(p / "explainability" / "explainability_report.json", "r", encoding="utf-8") as f:
self.shap_report = json.load(f)
self.shap_global: Dict[str, pd.DataFrame] = {}
for asset in ASSETS:
path = p / "explainability" / f"shap_global_xgb_{asset}.csv"
if path.exists():
self.shap_global[asset] = pd.read_csv(path)
# Gold-FX correlations (saved by portfolio_engine step 7.2b)
gold_fx_path = p / "portfolio" / "gold_fx_correlations.json"
if gold_fx_path.exists():
with open(gold_fx_path, "r", encoding="utf-8") as f:
self.gold_fx_corr: dict = json.load(f)
else:
self.gold_fx_corr = self._compute_gold_fx_corr_inline(p)
# Current regime from weights file (most recent row)
self.regime = self.weights_adj["regime"].iloc[0]
self.regime_conf = float(self.weights_adj["regime_confidence"].iloc[0])
self.as_of_date = self.weights_adj["as_of_date"].iloc[0]
# Weight dicts
self.adj_weights = dict(zip(self.weights_adj["asset"], self.weights_adj["weight"]))
self.base_weights = dict(zip(self.weights_base["asset"], self.weights_base["weight"]))
def _compute_gold_fx_corr_inline(self, data_path: Path, window: int = 36) -> dict:
"""Compute gold-FX correlations from the features file if the saved JSON is absent."""
try:
feat_path = data_path / "features" / "features_monthly_full_history.csv"
if not feat_path.exists():
return {}
cols = ["gold_return", "eurusd_return", "gbpusd_return", "dxy_return"]
df = pd.read_csv(feat_path, usecols=lambda c: c in cols)
df = df.dropna().tail(window)
if len(df) < 12 or "gold_return" not in df.columns:
return {}
corr = df.corr()
return {
"window_months": window,
"n_observations": len(df),
"gold_eurusd": round(float(corr.loc["gold_return", "eurusd_return"]), 4) if "eurusd_return" in corr.columns else None,
"gold_gbpusd": round(float(corr.loc["gold_return", "gbpusd_return"]), 4) if "gbpusd_return" in corr.columns else None,
"gold_dxy": round(float(corr.loc["gold_return", "dxy_return"]), 4) if "dxy_return" in corr.columns else None,
}
except Exception:
return {}
# ── Public API ────────────────────────────────────────────────────────────
def generate(self) -> dict:
"""Return full explanation payload using pipeline-computed weights."""
factor_exposures = self._portfolio_factor_exposures()
return {
"as_of_date": self.as_of_date,
"regime": self.regime,
"regime_confidence": self.regime_conf,
"quant": {
"factor_exposures": factor_exposures,
"per_asset_top_factors": self._per_asset_top_factors(),
"stress_factor_map": self._stress_factor_map(),
},
"narratives": self._plain_english_narratives(factor_exposures),
}
def generate_for_weights(self, custom_weights: Dict[str, float],
scenario_result: dict | None = None) -> dict:
"""
Generate a fully dynamic narrative for any user-supplied weights.
custom_weights – e.g. {"spx": 0.5, "ndx": 0.3, "gold": 0.2}
scenario_result – optional output from /api/scenario/run, used to enrich
the stress narrative paragraph.
"""
# Temporarily override weight state
orig_adj = self.adj_weights
orig_base = self.base_weights
# Normalise to sum = 1
total = sum(custom_weights.values()) or 1.0
self.adj_weights = {k: v / total for k, v in custom_weights.items()}
self.base_weights = self.adj_weights.copy() # no regime-tilt delta shown
try:
factor_exposures = self._portfolio_factor_exposures()
narratives = self._plain_english_narratives(factor_exposures)
# Append a scenario-specific paragraph if the caller supplied one
if scenario_result:
narratives.append(self._narrative_scenario_impact(scenario_result))
return {
"as_of_date": self.as_of_date,
"regime": self.regime,
"regime_confidence": self.regime_conf,
"weights_used": self.adj_weights,
"quant": {
"factor_exposures": factor_exposures,
"per_asset_top_factors": self._per_asset_top_factors(),
},
"narratives": narratives,
}
finally:
# Always restore original state
self.adj_weights = orig_adj
self.base_weights = orig_base
def _narrative_scenario_impact(self, scenario_result: dict) -> dict:
"""Generate a paragraph interpreting the result of a specific scenario run."""
sc_id = scenario_result.get("scenario", "unknown")
pf_ret = scenario_result.get("portfolio_return", 0.0)
contribs = scenario_result.get("contributions", {})
sc_desc = scenario_result.get("description") or sc_id.replace("_", " ").title()
direction = "loss" if pf_ret < 0 else "gain"
severity = (
"severe" if pf_ret < -0.20 else
"significant" if pf_ret < -0.10 else
"moderate" if pf_ret < -0.05 else
"mild"
)
# Find worst-contributing asset
if contribs:
worst_asset = min(contribs, key=lambda a: contribs.get(a, 0))
worst_val = contribs.get(worst_asset, 0)
else:
worst_asset, worst_val = None, 0
text = (
f"Under the '{sc_desc}' scenario, your portfolio would experience "
f"a {severity} {direction} of {abs(pf_ret):.1%}. "
)
if worst_asset and worst_val < -0.01:
text += (
f"{ASSET_LABELS.get(worst_asset, worst_asset.upper())} is the largest detractor, "
f"contributing {worst_val:.2%} to the total portfolio return. "
)
regime_rc = REGIME_CONTEXT.get(self.regime, {})
if regime_rc:
text += (
f"Given the current {regime_rc.get('label', self.regime)} regime, "
f"{regime_rc.get('description', 'macro conditions are shifting')}."
)
return {"type": "scenario_impact", "title": f"Scenario Impact: {sc_desc}", "text": text}
# ── Quant: factor exposure decomposition ──────────────────────────────────
def _portfolio_factor_exposures(self) -> List[dict]:
"""
Portfolio-level factor importance = Σ_i (weight_i × mean_abs_shap_i_f)
across portfolio assets (SPX, NDX, Gold). Sorted descending.
"""
factor_totals: Dict[str, float] = {}
for asset in PORTFOLIO_ASSETS:
weight = self.adj_weights.get(asset, 0.0)
if weight == 0 or asset not in self.shap_global:
continue
df = self.shap_global[asset]
for _, row in df.iterrows():
feature = row["feature"]
shap = float(row["mean_abs_shap"])
factor_totals[feature] = factor_totals.get(feature, 0.0) + weight * shap
rows = [
{
"feature": f,
"label": FACTOR_LABELS.get(f, f),
"portfolio_shap": round(v, 6),
"rank": i + 1,
}
for i, (f, v) in enumerate(
sorted(factor_totals.items(), key=lambda x: -x[1])
)
]
return rows
def _per_asset_top_factors(self, top_n: int = 5) -> Dict[str, List[dict]]:
result = {}
for asset in ASSETS:
if asset not in self.shap_global:
continue
df = self.shap_global[asset].head(top_n)
result[asset] = [
{
"rank": i + 1,
"feature": row["feature"],
"label": FACTOR_LABELS.get(row["feature"], row["feature"]),
"mean_abs_shap": round(float(row["mean_abs_shap"]), 6),
}
for i, (_, row) in enumerate(df.iterrows())
]
return result
def _stress_factor_map(self) -> List[dict]:
"""Historical stress scenarios sorted by portfolio impact."""
hist = self.stress[self.stress["stress_type"] == "historical"].copy()
hist = hist.sort_values("portfolio_total_return")
return [
{
"scenario": row["scenario"],
"portfolio_return_pct": round(float(row["portfolio_total_return"]) * 100, 2),
"spx_return_pct": round(float(row["spx_total_return"]) * 100, 2) if pd.notna(row.get("spx_total_return")) else None,
"ndx_return_pct": round(float(row["ndx_total_return"]) * 100, 2) if pd.notna(row.get("ndx_total_return")) else None,
"gold_return_pct": round(float(row["gold_total_return"]) * 100, 2) if pd.notna(row.get("gold_total_return")) else None,
}
for _, row in hist.iterrows()
]
# ── Plain-English narratives ───────────────────────────────────────────────
def _plain_english_narratives(self, factor_exposures: List[dict]) -> List[dict]:
narratives = []
narratives.append(self._narrative_composition())
narratives.append(self._narrative_dominant_factor(factor_exposures))
narratives.append(self._narrative_regime_context())
narratives.append(self._narrative_stress_vulnerability())
narratives.append(self._narrative_diversification())
narratives.append(self._narrative_hedge_effectiveness())
return [n for n in narratives if n is not None]
def _narrative_composition(self) -> dict:
active = {a: w for a, w in self.adj_weights.items() if w > 0.01}
sorted_assets = sorted(active.items(), key=lambda x: -x[1])
parts = [f"{ASSET_LABELS[a]} ({w:.0%})" for a, w in sorted_assets]
alloc_str = " and ".join(parts) if len(parts) <= 2 else ", ".join(parts[:-1]) + f", and {parts[-1]}"
rc = REGIME_CONTEXT.get(self.regime, {})
regime_label = rc.get("label", self.regime.replace("_", " "))
text = (
f"Your portfolio is allocated to {alloc_str}. "
f"This positioning reflects the current {regime_label} regime "
f"(confidence: {self.regime_conf:.0%}), "
f"characterised by {rc.get('description', 'shifting macro conditions')}."
)
# Regime tilt commentary
base_eq = sum(self.base_weights.get(a, 0) for a in ["spx", "ndx"])
adj_eq = sum(self.adj_weights.get(a, 0) for a in ["spx", "ndx"])
delta_eq = adj_eq - base_eq
if abs(delta_eq) > 0.03:
direction = "reduced" if delta_eq < 0 else "increased"
text += (
f" Compared to the unconstrained optimal, equity exposure has been "
f"{direction} by {abs(delta_eq):.0%} to reflect regime risk."
)
return {"type": "portfolio_composition", "title": "Portfolio Composition", "text": text}
def _narrative_dominant_factor(self, factor_exposures: List[dict]) -> dict:
if not factor_exposures:
return None
top = factor_exposures[0]
second = factor_exposures[1] if len(factor_exposures) > 1 else None
feature = top["feature"]
label = top["label"]
shap = top["portfolio_shap"]
# Per-asset sensitivity to this factor
asset_sensitivity = []
for asset in PORTFOLIO_ASSETS:
w = self.adj_weights.get(asset, 0)
if w < 0.01 or asset not in self.shap_global:
continue
df = self.shap_global[asset]
row = df[df["feature"] == feature]
if not row.empty:
asset_sensitivity.append((asset, w, float(row["mean_abs_shap"].iloc[0])))
text = (
f"The dominant macro driver across your portfolio is {label} "
f"(weighted SHAP importance: {shap:.4f}). "
)
if asset_sensitivity:
sens_parts = [
f"{ASSET_LABELS[a]} ({w:.0%} weight, SHAP={s:.4f})"
for a, w, s in sorted(asset_sensitivity, key=lambda x: -x[2])
]
text += f"It significantly influences {', '.join(sens_parts)}. "
# Contextual interpretation for known factors
if feature == "us_cpi_yoy":
text += (
"Rising inflation historically pressures growth equities by compressing valuation multiples, "
"while providing partial support for Gold as an inflation hedge."
)
elif feature == "us10y_yield":
text += (
"Rising long-term yields increase the discount rate on future earnings, "
"weighing on growth equities (especially Nasdaq) while supporting the US Dollar."
)
elif feature == "vix_level":
text += (
"Elevated market volatility triggers risk-off rotation, reducing equity returns "
"and typically supporting defensive assets like Gold."
)
elif feature == "high_yield_spread":
text += (
"Widening credit spreads signal deteriorating credit conditions, "
"which historically leads to equity drawdowns and safe-haven flows into Gold."
)
elif feature == "dxy_return":
text += (
"The US Dollar Index is one of gold's strongest inverse drivers. "
"A rising DXY makes gold more expensive in local currency terms globally, suppressing demand, "
"while a falling DXY acts as a direct tailwind for gold prices."
)
elif feature == "eurusd_return":
text += (
"A rising EUR/USD (weaker US Dollar) historically supports gold, "
"as dollar-denominated commodities become cheaper for foreign buyers, boosting global demand. "
"The inverse also holds: dollar strength from a falling EUR/USD typically weighs on gold."
)
elif feature == "gbpusd_return":
text += (
"GBP/USD movements signal broader USD directional trends. "
"A strengthening dollar (falling GBP/USD) tends to pressure gold, "
"since gold is priced in dollars and becomes relatively more expensive globally."
)
if second:
text += (
f" The second-largest factor is {second['label']} "
f"(portfolio SHAP: {second['portfolio_shap']:.4f})."
)
return {"type": "dominant_risk_factor", "title": "Dominant Risk Factor", "text": text}
def _narrative_regime_context(self) -> dict:
rc = REGIME_CONTEXT.get(self.regime, {})
regime_label = rc.get("label", self.regime.replace("_", " "))
# Regime shock return from stress tests
regime_stress = self.stress[
self.stress["scenario"] == f"regime_{self.regime}"
]
regime_return = None
if not regime_stress.empty:
regime_return = float(regime_stress["portfolio_total_return"].iloc[0])
# Worst historical scenario
hist = self.stress[self.stress["stress_type"] == "historical"].copy()
worst = hist.sort_values("portfolio_total_return").iloc[0]
worst_ret = float(worst["portfolio_total_return"])
worst_name = worst["scenario"].replace("_", " ")
text = (
f"The model detects a {regime_label} regime with {self.regime_conf:.0%} confidence as of "
f"{str(self.as_of_date)[:10]}. "
f"This regime is characterised by {rc.get('description', 'elevated macro uncertainty')}. "
)
if regime_return is not None:
text += (
f"Historical {regime_label} periods produced an annualised portfolio return of "
f"{regime_return * 100:+.1f}% for the current allocation. "
)
text += (
f"The most damaging historical scenario for this allocation is the "
f"'{worst_name}' episode ({worst_ret * 100:+.1f}% cumulative). "
)
equity_bias = rc.get("equity_bias", "neutral")
gold_bias = rc.get("gold_bias", "neutral")
text += (
f"In this regime type, equities typically show {equity_bias} momentum "
f"and Gold tends to be {gold_bias}."
)
return {"type": "regime_context", "title": "Regime Context", "text": text}
def _narrative_stress_vulnerability(self) -> dict:
hist = self.stress[self.stress["stress_type"] == "historical"].copy()
hist = hist.sort_values("portfolio_total_return")
worst = hist.iloc[0]
second = hist.iloc[1] if len(hist) > 1 else None
worst_ret = float(worst["portfolio_total_return"])
worst_name = worst["scenario"].replace("_", " ")
# Macro scenarios (worst)
macro = self.stress[self.stress["stress_type"] == "macro_scenario"].copy()
macro_worst = macro.sort_values("portfolio_total_return").iloc[0] if not macro.empty else None
text = (
f"Your portfolio's greatest historical stress was the '{worst_name}' episode, "
f"producing a {worst_ret * 100:+.1f}% cumulative loss. "
)
# Decompose: which asset contributed most to that loss?
spx_ret = float(worst.get("spx_total_return", 0) or 0)
ndx_ret = float(worst.get("ndx_total_return", 0) or 0)
gold_ret = float(worst.get("gold_total_return", 0) or 0)
w_spx = self.adj_weights.get("spx", 0)
w_ndx = self.adj_weights.get("ndx", 0)
w_gold = self.adj_weights.get("gold", 0)
contributions = {
"spx": w_spx * spx_ret,
"ndx": w_ndx * ndx_ret,
"gold": w_gold * gold_ret,
}
biggest_loss = min(contributions, key=contributions.get)
biggest_contrib = contributions[biggest_loss]
biggest_offset = max(contributions, key=contributions.get)
offset_contrib = contributions[biggest_offset]
if biggest_contrib < -0.005:
text += (
f"{ASSET_LABELS[biggest_loss]} (weight: {self.adj_weights[biggest_loss]:.0%}) "
f"contributed {biggest_contrib * 100:+.1f}% to the loss. "
)
if offset_contrib > 0.005:
text += (
f"{ASSET_LABELS[biggest_offset]} (weight: {self.adj_weights[biggest_offset]:.0%}) "
f"partially offset losses, contributing {offset_contrib * 100:+.1f}%. "
)
if second is not None:
second_ret = float(second["portfolio_total_return"])
second_name = second["scenario"].replace("_", " ")
text += (
f"The second-worst scenario is '{second_name}' ({second_ret * 100:+.1f}%). "
)
if macro_worst is not None:
mw_ret = float(macro_worst["portfolio_total_return"])
mw_name = macro_worst["scenario"].replace("_", " ")
text += (
f"Among forward-looking macro shocks, a '{mw_name}' scenario "
f"would cost {mw_ret * 100:+.1f}%."
)
return {"type": "stress_vulnerability", "title": "Stress Vulnerability", "text": text}
def _narrative_diversification(self) -> dict:
div_ratio = float(self.metrics.get("diversification_ratio", 1.0))
max_dd = float(self.metrics.get("max_drawdown", 0.0))
var_95 = float(self.metrics.get("var_95_monthly", 0.0))
sharpe = float(self.metrics.get("sharpe_ratio", 0.0))
# SPX-NDX correlation
try:
spx_ndx_cov = float(self.cov_matrix.loc["spx", "ndx"])
spx_vol = float(self.cov_matrix.loc["spx", "spx"]) ** 0.5
ndx_vol = float(self.cov_matrix.loc["ndx", "ndx"]) ** 0.5
spx_ndx_corr = spx_ndx_cov / (spx_vol * ndx_vol) if spx_vol * ndx_vol > 0 else 0
except (KeyError, ZeroDivisionError):
spx_ndx_corr = None
try:
ndx_gold_cov = float(self.cov_matrix.loc["ndx", "gold"])
gold_vol = float(self.cov_matrix.loc["gold", "gold"]) ** 0.5
ndx_gold_corr = ndx_gold_cov / (ndx_vol * gold_vol) if ndx_vol * gold_vol > 0 else 0
except (KeyError, ZeroDivisionError):
ndx_gold_corr = None
text = (
f"The portfolio achieves a diversification ratio of {div_ratio:.2f} "
f"(>1 indicates genuine diversification benefit). "
)
if ndx_gold_corr is not None:
corr_label = "near-zero" if abs(ndx_gold_corr) < 0.15 else ("positive" if ndx_gold_corr > 0 else "negative")
text += (
f"Gold and Nasdaq carry a {corr_label} 36-month rolling correlation ({ndx_gold_corr:.2f}), "
f"providing genuine risk offset between the two largest positions. "
)
if spx_ndx_corr is not None and spx_ndx_corr > 0.7:
text += (
f"However, S&P 500 and Nasdaq are highly correlated ({spx_ndx_corr:.2f}). "
f"Holding both increases equity concentration without proportional diversification benefit — "
f"in a broad equity sell-off, they tend to fall together. "
)
# Gold-FX correlation narrative
if self.gold_fx_corr:
gold_dxy = self.gold_fx_corr.get("gold_dxy")
gold_eurusd = self.gold_fx_corr.get("gold_eurusd")
fx_window = self.gold_fx_corr.get("window_months", 36)
rc = REGIME_CONTEXT.get(self.regime, {})
fx_gold_bias = rc.get("fx_gold_bias", "")
if gold_dxy is not None:
if gold_dxy < -0.4:
dxy_label = "strongly negative"
elif gold_dxy < -0.15:
dxy_label = "negative"
elif abs(gold_dxy) < 0.15:
dxy_label = "near-zero"
else:
dxy_label = "positive"
typical = "typical" if gold_dxy < -0.15 else ("weakened" if abs(gold_dxy) < 0.15 else "atypical")
text += (
f"Gold's {fx_window}-month rolling correlation with the US Dollar Index (DXY) is "
f"{dxy_label} ({gold_dxy:+.2f}), reflecting the {typical} inverse gold-dollar relationship. "
)
if fx_gold_bias:
text += f"In the current {rc.get('label', self.regime)} regime, {fx_gold_bias}. "
if gold_eurusd is not None:
if gold_eurusd > 0.15:
eurusd_label = "positive"
elif abs(gold_eurusd) < 0.15:
eurusd_label = "near-zero"
else:
eurusd_label = "negative"
text += (
f"The Gold-EUR/USD correlation is {eurusd_label} ({gold_eurusd:+.2f}): "
f"since EUR/USD and DXY move inversely, gold and EUR/USD tend to track together "
f"through the dollar channel. "
)
text += (
f"The monthly VaR at 95% confidence is {var_95 * 100:+.1f}%, and the historical max drawdown "
f"for this allocation is {max_dd * 100:+.1f}%. "
f"The Sharpe ratio stands at {sharpe:.2f} annualised."
)
return {"type": "diversification", "title": "Diversification Analysis", "text": text}
def _narrative_hedge_effectiveness(self) -> dict:
w_gold = self.adj_weights.get("gold", 0)
w_ndx = self.adj_weights.get("ndx", 0)
w_spx = self.adj_weights.get("spx", 0)
w_eq = w_ndx + w_spx
# Gold vs inflation scenario
inflation_scenario = self.stress[
self.stress["scenario"].str.contains("inflation", case=False, na=False)
]
text = ""
if w_gold > 0.40:
text += (
f"Gold carries a significant {w_gold:.0%} portfolio weight, acting as the primary hedge. "
)
if not inflation_scenario.empty:
inf_row = inflation_scenario.iloc[0]
port_ret = float(inf_row["portfolio_total_return"])
gold_ret = float(inf_row.get("gold_total_return", 0) or 0)
text += (
f"In the historical inflation spike scenario, Gold returned {gold_ret * 100:+.1f}% "
f"while the total portfolio delivered {port_ret * 100:+.1f}%. "
)
elif w_gold > 0.15:
text += (
f"Gold provides a modest hedge at {w_gold:.0%} of the portfolio. "
f"This partially offsets equity drawdowns but may be insufficient in a severe sell-off. "
)
else:
text += (
f"Gold allocation is minimal ({w_gold:.0%}). "
f"The portfolio carries substantial unhedged equity risk. "
)
# Equity concentration commentary
if w_eq > 0.50:
text += (
f"With {w_eq:.0%} in equities, the portfolio is highly exposed to growth equities and "
f"will lose more under rate-hike or risk-off scenarios. "
f"Consider whether the current regime justifies this concentration."
)
elif w_eq > 0.20:
text += (
f"The {w_eq:.0%} equity allocation provides growth exposure while the gold position "
f"acts as a partial counterweight in stress periods."
)
else:
text += (
f"Equity exposure is low ({w_eq:.0%}), which reduces upside participation "
f"but limits drawdown risk in the current {self.regime.replace('_', ' ')} environment."
)
return {"type": "hedge_effectiveness", "title": "Hedge Effectiveness", "text": text}