"""Correlation Analyst Agent. Specializes in cross-asset correlation analysis and sector rotation detection: - Cross-asset correlation (stocks vs bonds, commodities, currencies) - Sector rotation analysis and leadership changes - Rolling correlation calculations - Correlation breakdown detection (regime changes) - Inter-market analysis for divergence signals Issue #15: [AGENT-14] Correlation Analyst - cross-asset, sector rotation """ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.tools import tool from typing import Annotated, Dict, Any, List, Optional from enum import Enum import pandas as pd import numpy as np from tradingagents.agents.utils.agent_utils import get_stock_data from tradingagents.dataflows.interface import route_to_vendor # ============================================================================ # Correlation Enums # ============================================================================ class CorrelationStrength(str, Enum): """Classification of correlation strength.""" VERY_STRONG_POSITIVE = "very_strong_positive" STRONG_POSITIVE = "strong_positive" MODERATE_POSITIVE = "moderate_positive" WEAK_POSITIVE = "weak_positive" NEGLIGIBLE = "negligible" WEAK_NEGATIVE = "weak_negative" MODERATE_NEGATIVE = "moderate_negative" STRONG_NEGATIVE = "strong_negative" VERY_STRONG_NEGATIVE = "very_strong_negative" class SectorPhase(str, Enum): """Economic cycle phase for sector rotation.""" EARLY_CYCLE = "early_cycle" MID_CYCLE = "mid_cycle" LATE_CYCLE = "late_cycle" RECESSION = "recession" class SectorLeadership(str, Enum): """Sector leadership classification.""" LEADING = "leading" LAGGING = "lagging" IMPROVING = "improving" WEAKENING = "weakening" # ============================================================================ # Helper Functions # ============================================================================ def _calculate_correlation(series1: pd.Series, series2: pd.Series) -> float: """Calculate Pearson correlation between two series.""" if len(series1) < 2 or len(series2) < 2: return 0.0 # Align series lengths min_len = min(len(series1), len(series2)) s1 = series1.iloc[-min_len:].values s2 = series2.iloc[-min_len:].values # Handle constant series if np.std(s1) == 0 or np.std(s2) == 0: return 0.0 return float(np.corrcoef(s1, s2)[0, 1]) def _calculate_rolling_correlation( series1: pd.Series, series2: pd.Series, window: int = 20 ) -> pd.Series: """Calculate rolling correlation between two series.""" if len(series1) < window or len(series2) < window: return pd.Series([]) min_len = min(len(series1), len(series2)) s1 = series1.iloc[-min_len:] s2 = series2.iloc[-min_len:] rolling_corr = s1.rolling(window=window).corr(s2) return rolling_corr.dropna() def _classify_correlation(corr: float) -> CorrelationStrength: """Classify correlation coefficient into strength categories.""" if corr >= 0.8: return CorrelationStrength.VERY_STRONG_POSITIVE elif corr >= 0.6: return CorrelationStrength.STRONG_POSITIVE elif corr >= 0.4: return CorrelationStrength.MODERATE_POSITIVE elif corr >= 0.2: return CorrelationStrength.WEAK_POSITIVE elif corr > -0.2: return CorrelationStrength.NEGLIGIBLE elif corr > -0.4: return CorrelationStrength.WEAK_NEGATIVE elif corr > -0.6: return CorrelationStrength.MODERATE_NEGATIVE elif corr > -0.8: return CorrelationStrength.STRONG_NEGATIVE else: return CorrelationStrength.VERY_STRONG_NEGATIVE def _detect_correlation_breakdown( rolling_corr: pd.Series, threshold_change: float = 0.3 ) -> Dict[str, Any]: """Detect significant correlation breakdown events.""" if len(rolling_corr) < 10: return {"detected": False, "details": "Insufficient data"} # Calculate correlation changes corr_diff = rolling_corr.diff() # Look for large changes large_changes = corr_diff[abs(corr_diff) > threshold_change] if len(large_changes) == 0: return {"detected": False, "details": "No significant correlation changes"} # Get the most recent significant change recent_change = corr_diff.iloc[-20:] if len(corr_diff) >= 20 else corr_diff max_change_idx = recent_change.abs().idxmax() max_change = recent_change.loc[max_change_idx] return { "detected": abs(max_change) > threshold_change, "change_magnitude": float(max_change), "direction": "increasing" if max_change > 0 else "decreasing", "current_correlation": float(rolling_corr.iloc[-1]), "prior_correlation": float(rolling_corr.iloc[-1] - max_change) } def _calculate_relative_strength( returns: pd.Series, benchmark_returns: pd.Series, window: int = 20 ) -> pd.Series: """Calculate relative strength vs benchmark.""" if len(returns) < window or len(benchmark_returns) < window: return pd.Series([]) min_len = min(len(returns), len(benchmark_returns)) ret = returns.iloc[-min_len:] bench = benchmark_returns.iloc[-min_len:] # Cumulative returns ratio cum_ret = (1 + ret).cumprod() cum_bench = (1 + bench).cumprod() relative_strength = cum_ret / cum_bench return relative_strength def _classify_sector_leadership( relative_strength: pd.Series, window: int = 20 ) -> SectorLeadership: """Classify sector leadership based on relative strength trend.""" if len(relative_strength) < window: return SectorLeadership.LAGGING recent = relative_strength.iloc[-window:] # Calculate trend rs_start = recent.iloc[0] rs_end = recent.iloc[-1] rs_mid = recent.iloc[window//2] current_vs_start = (rs_end - rs_start) / rs_start if rs_start != 0 else 0 current_vs_mid = (rs_end - rs_mid) / rs_mid if rs_mid != 0 else 0 if rs_end > 1 and current_vs_start > 0.02: return SectorLeadership.LEADING elif rs_end > 1 and current_vs_start < 0: return SectorLeadership.WEAKENING elif rs_end < 1 and current_vs_start > 0: return SectorLeadership.IMPROVING else: return SectorLeadership.LAGGING def _identify_cycle_phase(indicators: Dict[str, float]) -> SectorPhase: """Identify economic cycle phase from market indicators.""" # Simplified cycle identification based on key metrics yield_curve_slope = indicators.get('yield_curve_slope', 0) leading_index = indicators.get('leading_index', 0) pmi = indicators.get('pmi', 50) if pmi > 50 and leading_index > 0 and yield_curve_slope > 0: return SectorPhase.EARLY_CYCLE elif pmi > 50 and leading_index > 0: return SectorPhase.MID_CYCLE elif pmi > 50 and leading_index < 0: return SectorPhase.LATE_CYCLE else: return SectorPhase.RECESSION def _get_cycle_sector_recommendations(phase: SectorPhase) -> Dict[str, List[str]]: """Get sector recommendations for each cycle phase.""" recommendations = { SectorPhase.EARLY_CYCLE: { "overweight": ["XLF", "XLY", "XLI", "XLB"], # Financials, Consumer Discretionary, Industrials, Materials "underweight": ["XLP", "XLU", "XLRE"], # Consumer Staples, Utilities, Real Estate "rationale": "Economic recovery favors cyclical sectors with high operating leverage" }, SectorPhase.MID_CYCLE: { "overweight": ["XLK", "XLI", "XLB"], # Technology, Industrials, Materials "underweight": ["XLU", "XLP"], # Utilities, Consumer Staples "rationale": "Sustained growth benefits sectors with secular trends and industrial production" }, SectorPhase.LATE_CYCLE: { "overweight": ["XLE", "XLB", "XLI"], # Energy, Materials, Industrials "underweight": ["XLK", "XLY", "XLF"], # Tech, Consumer Discretionary, Financials "rationale": "Inflation hedge and commodity exposure preferred as cycle matures" }, SectorPhase.RECESSION: { "overweight": ["XLU", "XLP", "XLV"], # Utilities, Consumer Staples, Healthcare "underweight": ["XLY", "XLI", "XLB"], # Consumer Discretionary, Industrials, Materials "rationale": "Defensive sectors with stable cash flows outperform during contractions" } } return recommendations.get(phase, {"overweight": [], "underweight": [], "rationale": "Unknown phase"}) def _interpret_cross_asset_correlation( stock_bond_corr: float, stock_gold_corr: float, stock_oil_corr: float ) -> str: """Interpret cross-asset correlations for market regime.""" interpretations = [] # Stock-Bond correlation (typically negative in normal markets) if stock_bond_corr > 0.3: interpretations.append("RISK-OFF REGIME: Positive stock-bond correlation suggests flight to quality") elif stock_bond_corr < -0.3: interpretations.append("NORMAL REGIME: Negative stock-bond correlation indicates balanced risk appetite") else: interpretations.append("TRANSITIONAL REGIME: Low stock-bond correlation may signal regime change") # Stock-Gold correlation if stock_gold_corr < -0.3: interpretations.append("HEDGING ACTIVE: Gold acting as portfolio hedge against equity risk") elif stock_gold_corr > 0.3: interpretations.append("LIQUIDITY DRIVEN: Both assets rising suggests monetary expansion") # Stock-Oil correlation if stock_oil_corr > 0.5: interpretations.append("GROWTH SENSITIVE: Strong stock-oil correlation reflects economic growth expectations") elif stock_oil_corr < -0.3: interpretations.append("SUPPLY SHOCK: Negative correlation may indicate energy cost pressure on equities") return "\n".join(interpretations) if interpretations else "Normal cross-asset relationships" def _format_correlation_signal(corr: float) -> str: """Format correlation value with directional signal.""" strength = _classify_correlation(corr) if corr > 0: return f"+{corr:.3f} ({strength.value.replace('_', ' ').title()})" else: return f"{corr:.3f} ({strength.value.replace('_', ' ').title()})" # ============================================================================ # Correlation Analysis Tools # ============================================================================ # Sector ETFs for rotation analysis SECTOR_ETFS = { "XLK": "Technology", "XLV": "Healthcare", "XLF": "Financials", "XLY": "Consumer Discretionary", "XLP": "Consumer Staples", "XLE": "Energy", "XLI": "Industrials", "XLB": "Materials", "XLU": "Utilities", "XLRE": "Real Estate", "XLC": "Communication Services" } # Cross-asset benchmarks CROSS_ASSET_SYMBOLS = { "SPY": "S&P 500 (Equities)", "TLT": "Long-term Treasuries (Bonds)", "GLD": "Gold", "USO": "Oil", "UUP": "US Dollar Index" } @tool def get_cross_asset_correlation_analysis( symbol: Annotated[str, "Ticker symbol to analyze correlations for"], curr_date: Annotated[str, "Current trading date in YYYY-MM-DD format"], lookback_days: Annotated[int, "Days of history for correlation (default: 60)"] = 60, ) -> str: """ Analyze cross-asset correlations to understand market regime and diversification. Examines correlations between the target asset and: - Bonds (TLT) - risk-on/off indicator - Gold (GLD) - safe haven correlation - Oil (USO) - economic growth sensitivity - Dollar (UUP) - currency risk exposure Returns comprehensive cross-asset correlation analysis with regime interpretation. """ try: # Get data for all assets assets_data = {} # Get target symbol data target_data = route_to_vendor("get_stock_data", symbol, curr_date, lookback_days + 20) if isinstance(target_data, str) and "error" in target_data.lower(): return f"Error retrieving data for {symbol}: {target_data}" if isinstance(target_data, str): from io import StringIO assets_data[symbol] = pd.read_csv(StringIO(target_data)) else: assets_data[symbol] = target_data # Get cross-asset data cross_assets = ["TLT", "GLD", "USO", "UUP"] for asset in cross_assets: try: data = route_to_vendor("get_stock_data", asset, curr_date, lookback_days + 20) if isinstance(data, str) and "error" not in data.lower(): from io import StringIO assets_data[asset] = pd.read_csv(StringIO(data)) elif not isinstance(data, str): assets_data[asset] = data except Exception: continue if len(assets_data) < 2: return "Insufficient cross-asset data available for correlation analysis." # Calculate returns returns_data = {} for asset, df in assets_data.items(): close_col = 'close' if 'close' in df.columns else 'Close' if close_col in df.columns: returns_data[asset] = df[close_col].pct_change().dropna() # Calculate correlations with target correlations = {} rolling_correlations = {} target_returns = returns_data.get(symbol) if target_returns is None or len(target_returns) < 20: return "Insufficient return data for target symbol." for asset in cross_assets: if asset in returns_data: asset_returns = returns_data[asset] corr = _calculate_correlation(target_returns, asset_returns) correlations[asset] = corr rolling_correlations[asset] = _calculate_rolling_correlation( target_returns, asset_returns, window=20 ) # Detect correlation breakdowns breakdowns = {} for asset, rolling in rolling_correlations.items(): if len(rolling) > 0: breakdowns[asset] = _detect_correlation_breakdown(rolling) # Interpret regime stock_bond_corr = correlations.get("TLT", 0) stock_gold_corr = correlations.get("GLD", 0) stock_oil_corr = correlations.get("USO", 0) regime_interpretation = _interpret_cross_asset_correlation( stock_bond_corr, stock_gold_corr, stock_oil_corr ) # Build report report = f""" ## Cross-Asset Correlation Analysis for {symbol} Analysis Date: {curr_date} Lookback Period: {lookback_days} days ### Correlation Matrix | Asset | Description | Correlation | Strength | |-------|-------------|-------------|----------| """ for asset in cross_assets: if asset in correlations: corr = correlations[asset] desc = CROSS_ASSET_SYMBOLS.get(asset, asset) strength = _classify_correlation(corr) signal = "🟢" if corr > 0.3 else ("šŸ”“" if corr < -0.3 else "⚪") report += f"| {asset} | {desc} | {corr:.3f} | {signal} {strength.value.replace('_', ' ')} |\n" report += f""" ### Market Regime Interpretation {regime_interpretation} ### Correlation Breakdown Detection """ for asset, breakdown in breakdowns.items(): if breakdown.get("detected"): direction = breakdown.get("direction", "unknown") magnitude = breakdown.get("change_magnitude", 0) report += f"\nāš ļø **{asset}**: Significant correlation {direction} (change: {magnitude:.3f})" if not any(b.get("detected", False) for b in breakdowns.values()): report += "\nāœ… No significant correlation breakdowns detected." report += f""" ### Portfolio Implications 1. **Diversification**: """ # Assess diversification avg_abs_corr = np.mean([abs(c) for c in correlations.values()]) if avg_abs_corr < 0.3: report += "Strong diversification potential with low cross-asset correlations.\n" elif avg_abs_corr < 0.5: report += "Moderate diversification - some hedging benefit available.\n" else: report += "Limited diversification - high correlation with other assets.\n" report += "\n2. **Hedging Opportunities**:\n" for asset, corr in correlations.items(): if corr < -0.3: report += f" - {asset} ({CROSS_ASSET_SYMBOLS.get(asset, asset)}): Potential hedge (corr: {corr:.3f})\n" if all(c > -0.3 for c in correlations.values()): report += " - No strong negative correlations for hedging.\n" return report.strip() except Exception as e: return f"Error in cross-asset correlation analysis: {str(e)}" @tool def get_sector_rotation_analysis( curr_date: Annotated[str, "Current trading date in YYYY-MM-DD format"], lookback_days: Annotated[int, "Days of history for analysis (default: 60)"] = 60, ) -> str: """ Analyze sector rotation patterns and identify leadership changes. Examines all 11 S&P 500 sector ETFs to: - Calculate relative strength vs SPY benchmark - Identify leading and lagging sectors - Detect sector rotation patterns - Provide cycle-based sector recommendations Returns comprehensive sector rotation analysis with actionable signals. """ try: # Get benchmark data (SPY) spy_data = route_to_vendor("get_stock_data", "SPY", curr_date, lookback_days + 20) if isinstance(spy_data, str) and "error" in spy_data.lower(): return f"Error retrieving benchmark data: {spy_data}" if isinstance(spy_data, str): from io import StringIO spy_df = pd.read_csv(StringIO(spy_data)) else: spy_df = spy_data close_col = 'close' if 'close' in spy_df.columns else 'Close' spy_returns = spy_df[close_col].pct_change().dropna() # Get sector data sector_analysis = {} for etf, sector_name in SECTOR_ETFS.items(): try: sector_data = route_to_vendor("get_stock_data", etf, curr_date, lookback_days + 20) if isinstance(sector_data, str): if "error" in sector_data.lower(): continue from io import StringIO sector_df = pd.read_csv(StringIO(sector_data)) else: sector_df = sector_data if sector_df.empty: continue close_col = 'close' if 'close' in sector_df.columns else 'Close' sector_returns = sector_df[close_col].pct_change().dropna() # Calculate metrics relative_strength = _calculate_relative_strength(sector_returns, spy_returns) if len(relative_strength) < 5: continue leadership = _classify_sector_leadership(relative_strength) correlation = _calculate_correlation(sector_returns, spy_returns) # Performance metrics total_return = (sector_df[close_col].iloc[-1] / sector_df[close_col].iloc[0] - 1) * 100 spy_total_return = (spy_df[close_col].iloc[-1] / spy_df[close_col].iloc[0] - 1) * 100 relative_return = total_return - spy_total_return sector_analysis[etf] = { "name": sector_name, "leadership": leadership, "relative_strength": float(relative_strength.iloc[-1]) if len(relative_strength) > 0 else 1.0, "correlation": correlation, "total_return": total_return, "relative_return": relative_return } except Exception: continue if len(sector_analysis) < 3: return "Insufficient sector data available for rotation analysis." # Sort sectors by relative strength sorted_sectors = sorted( sector_analysis.items(), key=lambda x: x[1]["relative_strength"], reverse=True ) # Identify leaders and laggards leaders = [s for s in sorted_sectors[:3]] laggards = [s for s in sorted_sectors[-3:]] # Determine cycle phase from sector leadership patterns # Simplified: if defensives leading -> late cycle/recession, if cyclicals -> early/mid cycle defensive_sectors = {"XLU", "XLP", "XLV"} cyclical_sectors = {"XLY", "XLF", "XLI", "XLB"} defensive_avg_rs = np.mean([ sector_analysis[s]["relative_strength"] for s in defensive_sectors if s in sector_analysis ]) if any(s in sector_analysis for s in defensive_sectors) else 1.0 cyclical_avg_rs = np.mean([ sector_analysis[s]["relative_strength"] for s in cyclical_sectors if s in sector_analysis ]) if any(s in sector_analysis for s in cyclical_sectors) else 1.0 if defensive_avg_rs > cyclical_avg_rs * 1.05: inferred_phase = SectorPhase.LATE_CYCLE elif cyclical_avg_rs > defensive_avg_rs * 1.05: inferred_phase = SectorPhase.EARLY_CYCLE else: inferred_phase = SectorPhase.MID_CYCLE recommendations = _get_cycle_sector_recommendations(inferred_phase) # Build report report = f""" ## Sector Rotation Analysis Analysis Date: {curr_date} Lookback Period: {lookback_days} days ### Sector Performance Rankings | Rank | ETF | Sector | Relative Strength | Period Return | vs SPY | Leadership | |------|-----|--------|-------------------|---------------|--------|------------| """ for i, (etf, data) in enumerate(sorted_sectors, 1): rs = data["relative_strength"] ret = data["total_return"] rel_ret = data["relative_return"] leadership = data["leadership"].value.replace("_", " ").title() signal = "🟢" if rel_ret > 0 else "šŸ”“" report += f"| {i} | {etf} | {data['name']} | {rs:.3f} | {ret:.1f}% | {signal} {rel_ret:+.1f}% | {leadership} |\n" report += f""" ### Leadership Analysis **Current Leaders** (Outperforming): """ for etf, data in leaders: report += f"- {data['name']} ({etf}): RS={data['relative_strength']:.3f}, {data['leadership'].value.replace('_', ' ')}\n" report += f""" **Current Laggards** (Underperforming): """ for etf, data in laggards: report += f"- {data['name']} ({etf}): RS={data['relative_strength']:.3f}, {data['leadership'].value.replace('_', ' ')}\n" report += f""" ### Cycle Phase Assessment **Inferred Phase**: {inferred_phase.value.replace('_', ' ').title()} - Defensive vs Cyclical RS Ratio: {defensive_avg_rs:.3f} vs {cyclical_avg_rs:.3f} **Cycle-Based Recommendations**: - Overweight: {', '.join(recommendations.get('overweight', []))} - Underweight: {', '.join(recommendations.get('underweight', []))} - Rationale: {recommendations.get('rationale', 'N/A')} ### Rotation Signals """ # Identify rotation signals improving = [s for s, d in sector_analysis.items() if d["leadership"] == SectorLeadership.IMPROVING] weakening = [s for s, d in sector_analysis.items() if d["leadership"] == SectorLeadership.WEAKENING] if improving: report += f"\nšŸ”„ **Improving Sectors** (potential rotation into):\n" for etf in improving: report += f" - {sector_analysis[etf]['name']} ({etf})\n" if weakening: report += f"\nāš ļø **Weakening Sectors** (potential rotation out of):\n" for etf in weakening: report += f" - {sector_analysis[etf]['name']} ({etf})\n" if not improving and not weakening: report += "\nāœ… Stable sector dynamics - no clear rotation signals.\n" return report.strip() except Exception as e: return f"Error in sector rotation analysis: {str(e)}" @tool def get_correlation_matrix( symbols: Annotated[str, "Comma-separated list of ticker symbols"], curr_date: Annotated[str, "Current trading date in YYYY-MM-DD format"], lookback_days: Annotated[int, "Days of history for correlation (default: 60)"] = 60, ) -> str: """ Generate correlation matrix for a set of securities. Calculates pairwise correlations between all provided symbols, useful for portfolio construction and diversification analysis. Returns correlation matrix with strength classifications. """ try: # Parse symbols symbol_list = [s.strip().upper() for s in symbols.split(",")] if len(symbol_list) < 2: return "Please provide at least 2 symbols for correlation analysis." if len(symbol_list) > 10: return "Maximum 10 symbols supported for matrix analysis." # Get data for all symbols returns_data = {} for symbol in symbol_list: try: data = route_to_vendor("get_stock_data", symbol, curr_date, lookback_days + 20) if isinstance(data, str): if "error" in data.lower(): continue from io import StringIO df = pd.read_csv(StringIO(data)) else: df = data if df.empty: continue close_col = 'close' if 'close' in df.columns else 'Close' returns_data[symbol] = df[close_col].pct_change().dropna() except Exception: continue if len(returns_data) < 2: return "Insufficient data available for requested symbols." # Build correlation matrix available_symbols = list(returns_data.keys()) n = len(available_symbols) corr_matrix = np.zeros((n, n)) for i, sym1 in enumerate(available_symbols): for j, sym2 in enumerate(available_symbols): if i == j: corr_matrix[i, j] = 1.0 elif i < j: corr = _calculate_correlation(returns_data[sym1], returns_data[sym2]) corr_matrix[i, j] = corr corr_matrix[j, i] = corr # Calculate portfolio metrics avg_correlation = np.mean(corr_matrix[np.triu_indices(n, k=1)]) max_corr = np.max(corr_matrix[np.triu_indices(n, k=1)]) min_corr = np.min(corr_matrix[np.triu_indices(n, k=1)]) # Find most/least correlated pairs upper_tri = np.triu_indices(n, k=1) corr_pairs = [(available_symbols[i], available_symbols[j], corr_matrix[i, j]) for i, j in zip(upper_tri[0], upper_tri[1])] corr_pairs.sort(key=lambda x: x[2], reverse=True) # Build report report = f""" ## Correlation Matrix Analysis Analysis Date: {curr_date} Lookback Period: {lookback_days} days Symbols Analyzed: {', '.join(available_symbols)} ### Correlation Matrix | | {' | '.join(available_symbols)} | |--------|{'|'.join(['--------' for _ in available_symbols])}| """ for i, sym in enumerate(available_symbols): row = [f"{corr_matrix[i, j]:.2f}" for j in range(n)] report += f"| **{sym}** | {' | '.join(row)} |\n" report += f""" ### Summary Statistics - **Average Correlation**: {avg_correlation:.3f} - **Highest Correlation**: {max_corr:.3f} - **Lowest Correlation**: {min_corr:.3f} ### Most Correlated Pairs (Top 3) """ for sym1, sym2, corr in corr_pairs[:3]: strength = _classify_correlation(corr) report += f"- {sym1} ↔ {sym2}: {corr:.3f} ({strength.value.replace('_', ' ')})\n" report += f""" ### Least Correlated Pairs (Diversification Opportunities) """ for sym1, sym2, corr in corr_pairs[-3:]: strength = _classify_correlation(corr) report += f"- {sym1} ↔ {sym2}: {corr:.3f} ({strength.value.replace('_', ' ')})\n" report += f""" ### Diversification Assessment """ if avg_correlation < 0.3: report += "āœ… **Excellent Diversification**: Low average correlation provides strong risk reduction benefits." elif avg_correlation < 0.5: report += "āš ļø **Moderate Diversification**: Some diversification benefit, but consider adding uncorrelated assets." else: report += "āŒ **Poor Diversification**: High average correlation - portfolio may behave like single asset." # Clustering warning high_corr_count = sum(1 for _, _, c in corr_pairs if c > 0.7) if high_corr_count > 0: report += f"\n\nāš ļø Warning: {high_corr_count} pair(s) with correlation > 0.7 - consider consolidating positions." return report.strip() except Exception as e: return f"Error in correlation matrix analysis: {str(e)}" @tool def get_rolling_correlation_trend( symbol1: Annotated[str, "First ticker symbol"], symbol2: Annotated[str, "Second ticker symbol"], curr_date: Annotated[str, "Current trading date in YYYY-MM-DD format"], lookback_days: Annotated[int, "Days of history (default: 120)"] = 120, window: Annotated[int, "Rolling window size (default: 20)"] = 20, ) -> str: """ Analyze rolling correlation trend between two securities. Tracks how correlation evolves over time to detect: - Correlation breakdowns (regime changes) - Correlation convergence/divergence - Hedging relationship stability Returns time-series analysis of correlation dynamics. """ try: # Get data for both symbols data1 = route_to_vendor("get_stock_data", symbol1, curr_date, lookback_days + window) data2 = route_to_vendor("get_stock_data", symbol2, curr_date, lookback_days + window) if isinstance(data1, str) and "error" in data1.lower(): return f"Error retrieving data for {symbol1}: {data1}" if isinstance(data2, str) and "error" in data2.lower(): return f"Error retrieving data for {symbol2}: {data2}" # Parse data if isinstance(data1, str): from io import StringIO df1 = pd.read_csv(StringIO(data1)) else: df1 = data1 if isinstance(data2, str): from io import StringIO df2 = pd.read_csv(StringIO(data2)) else: df2 = data2 # Calculate returns close1 = df1['close'] if 'close' in df1.columns else df1['Close'] close2 = df2['close'] if 'close' in df2.columns else df2['Close'] returns1 = close1.pct_change().dropna() returns2 = close2.pct_change().dropna() # Calculate rolling correlation rolling_corr = _calculate_rolling_correlation(returns1, returns2, window) if len(rolling_corr) < 10: return "Insufficient data for rolling correlation analysis." # Analyze trends current_corr = rolling_corr.iloc[-1] avg_corr = rolling_corr.mean() std_corr = rolling_corr.std() min_corr = rolling_corr.min() max_corr = rolling_corr.max() # Recent trend recent_corr = rolling_corr.iloc[-20:] if len(rolling_corr) >= 20 else rolling_corr trend_direction = "increasing" if recent_corr.iloc[-1] > recent_corr.iloc[0] else "decreasing" # Detect breakdowns breakdown = _detect_correlation_breakdown(rolling_corr) # Stability analysis if std_corr < 0.1: stability = "STABLE - Correlation remains consistent over time" elif std_corr < 0.2: stability = "MODERATE - Some variation but generally predictable" else: stability = "UNSTABLE - High correlation volatility, unreliable for hedging" # Build report report = f""" ## Rolling Correlation Analysis: {symbol1} ↔ {symbol2} Analysis Date: {curr_date} Lookback Period: {lookback_days} days Rolling Window: {window} days ### Current State - **Current Correlation**: {current_corr:.3f} - **Strength**: {_classify_correlation(current_corr).value.replace('_', ' ').title()} - **Recent Trend**: {trend_direction.title()} ### Historical Statistics | Metric | Value | |--------|-------| | Average | {avg_corr:.3f} | | Std Dev | {std_corr:.3f} | | Maximum | {max_corr:.3f} | | Minimum | {min_corr:.3f} | | Range | {max_corr - min_corr:.3f} | ### Stability Assessment {stability} ### Correlation Dynamics """ # Show correlation at key intervals intervals = [5, 10, 20, 40, 60] report += "\n| Days Ago | Correlation | vs Current |\n|----------|-------------|------------|\n" for days in intervals: if len(rolling_corr) > days: past_corr = rolling_corr.iloc[-(days+1)] diff = current_corr - past_corr signal = "šŸŸ¢ā†‘" if diff > 0.1 else ("šŸ”“ā†“" if diff < -0.1 else "āšŖā†’") report += f"| {days} | {past_corr:.3f} | {signal} {diff:+.3f} |\n" report += "\n### Breakdown Detection\n" if breakdown.get("detected"): report += f""" āš ļø **Correlation Breakdown Detected** - Direction: {breakdown.get('direction', 'unknown').title()} - Magnitude: {breakdown.get('change_magnitude', 0):.3f} - Current: {breakdown.get('current_correlation', 0):.3f} - Prior: {breakdown.get('prior_correlation', 0):.3f} """ else: report += "āœ… No significant correlation breakdowns detected.\n" report += f""" ### Implications """ if current_corr > 0.7: report += "- **High Positive Correlation**: Assets move together; limited diversification benefit.\n" report += "- Consider reducing one position if seeking diversification.\n" elif current_corr < -0.5: report += "- **Strong Negative Correlation**: Excellent hedging pair.\n" report += "- Can be used for portfolio protection.\n" elif abs(current_corr) < 0.2: report += "- **Low Correlation**: Independent movements provide diversification.\n" report += "- Good for portfolio construction.\n" else: report += "- **Moderate Correlation**: Some relationship but not dominant.\n" return report.strip() except Exception as e: return f"Error in rolling correlation analysis: {str(e)}" # ============================================================================ # Correlation Analyst Factory # ============================================================================ def create_correlation_analyst(llm): """ Factory function to create the Correlation Analyst agent. Args: llm: Language model to use for the agent Returns: Callable node function for the agent graph """ tools = [ get_cross_asset_correlation_analysis, get_sector_rotation_analysis, get_correlation_matrix, get_rolling_correlation_trend ] tool_names = ", ".join([t.name for t in tools]) prompt = ChatPromptTemplate.from_messages([ ("system", f"""You are a specialized Correlation Analyst focusing on cross-asset relationships and sector rotation dynamics. You have access to these tools: {tool_names} Your expertise includes: 1. Cross-asset correlation analysis (stocks, bonds, commodities, currencies) 2. Sector rotation patterns and leadership identification 3. Portfolio diversification assessment 4. Correlation breakdown detection for regime changes 5. Rolling correlation trend analysis When analyzing correlations: - Consider both current state and historical trends - Identify correlation breakdowns that may signal regime changes - Assess diversification implications for portfolio construction - Provide actionable sector rotation recommendations Always provide: - Current correlation metrics with classification - Historical context and trends - Diversification and hedging implications - Specific recommendations based on findings Be quantitative and precise in your analysis."""), MessagesPlaceholder(variable_name="messages"), ]) chain = prompt | llm.bind_tools(tools) def correlation_analyst_node(state): """Execute the Correlation Analyst agent.""" messages = state.get("messages", []) trade_date = state.get("trade_date", "") company = state.get("company_of_interest", "") # Add context if not in messages if trade_date and company: context_msg = f"Analyze correlations for {company} as of {trade_date}." from langchain_core.messages import HumanMessage if not any(context_msg in str(m) for m in messages): messages = [HumanMessage(content=context_msg)] + list(messages) response = chain.invoke({"messages": messages}) # Extract report from tool responses report = "" if hasattr(response, 'tool_calls') and response.tool_calls: report = "Correlation analysis executed. See tool results for details." elif hasattr(response, 'content'): report = response.content return { "messages": [response], "correlation_report": report } return correlation_analyst_node