| """ |
| 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"] |
|
|
| 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() |
|
|
| |
|
|
| 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_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) |
|
|
| |
| 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] |
|
|
| |
| 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 {} |
|
|
| |
|
|
| 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. |
| """ |
| |
| orig_adj = self.adj_weights |
| orig_base = self.base_weights |
|
|
| |
| 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() |
|
|
| try: |
| factor_exposures = self._portfolio_factor_exposures() |
| narratives = self._plain_english_narratives(factor_exposures) |
|
|
| |
| 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: |
| |
| 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" |
| ) |
|
|
| |
| 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} |
|
|
| |
|
|
| 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() |
| ] |
|
|
| |
|
|
| 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')}." |
| ) |
|
|
| |
| 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"] |
|
|
| |
| 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)}. " |
|
|
| |
| 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_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]) |
|
|
| |
| 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 = 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. " |
| ) |
|
|
| |
| 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)) |
|
|
| |
| 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. " |
| ) |
|
|
| |
| 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 |
|
|
| |
| 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. " |
| ) |
|
|
| |
| 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} |
|
|