Spaces:
Running
Running
File size: 7,428 Bytes
cafdd88 c3aab0c cafdd88 c3aab0c cafdd88 c3aab0c cafdd88 c3aab0c e6c4c6c c3aab0c e6c4c6c c3aab0c e6c4c6c c3aab0c cafdd88 aca4b95 cafdd88 aca4b95 cafdd88 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | import pandas as pd
import numpy as np
from typing import Dict, List
import logging
from core.schema import AttributionReport
logger = logging.getLogger(__name__)
class AttributionEngine:
"""
Implements the Brinson-Fachler Attribution Model.
Decomposes portfolio excess return into:
1. Allocation Effect: Value added by sector weighting decisions.
2. Selection Effect: Value added by stock picking within sectors.
"""
def __init__(self):
pass
def generate_attribution_report(self,
portfolio_weights: Dict[str, float],
benchmark_weights: Dict[str, float],
asset_returns: pd.Series,
sector_map: Dict[str, str]) -> AttributionReport:
"""
Calculates attribution effects.
Args:
portfolio_weights: Ticker -> Weight
benchmark_weights: Ticker -> Weight
asset_returns: Ticker -> Return (period)
sector_map: Ticker -> Sector
Returns:
AttributionReport object
"""
# Create a DataFrame for calculation
all_tickers = set(portfolio_weights.keys()) | set(benchmark_weights.keys())
df = pd.DataFrame(index=list(all_tickers))
df['wp'] = df.index.map(portfolio_weights).fillna(0.0)
df['wb'] = df.index.map(benchmark_weights).fillna(0.0)
df['ret'] = df.index.map(asset_returns).fillna(0.0)
df['sector'] = df.index.map(sector_map).fillna("Unknown")
# Calculate Sector Level Data
# Sector Portfolio Return (R_pi), Sector Benchmark Return (R_bi)
# Sector Portfolio Weight (w_pi), Sector Benchmark Weight (w_bi)
sector_groups = df.groupby('sector')
attribution_rows = []
total_benchmark_return = (df['wb'] * df['ret']).sum()
for sector, data in sector_groups:
w_p = data['wp'].sum()
w_b = data['wb'].sum()
# Avoid division by zero if weight is 0
R_p = (data['wp'] * data['ret']).sum() / w_p if w_p > 0 else 0
R_b = (data['wb'] * data['ret']).sum() / w_b if w_b > 0 else 0
# Brinson-Fachler Allocation: (w_p - w_b) * (R_b - R_total_benchmark)
allocation_effect = (w_p - w_b) * (R_b - total_benchmark_return)
# Selection Effect: w_b * (R_p - R_b)
# Note: Often interaction is w_p * ... or split.
# Brinson-Beebower uses w_b for selection.
selection_effect = w_b * (R_p - R_b)
# Interaction: (w_p - w_b) * (R_p - R_b)
interaction_effect = (w_p - w_b) * (R_p - R_b)
attribution_rows.append({
'sector': sector,
'allocation': allocation_effect,
'selection': selection_effect,
'interaction': interaction_effect,
'total_effect': allocation_effect + selection_effect + interaction_effect
})
attr_df = pd.DataFrame(attribution_rows)
total_allocation = attr_df['allocation'].sum()
total_selection = attr_df['selection'].sum() # + interaction usually bundled
total_interaction = attr_df['interaction'].sum()
# Calculate Top Contributors/Detractors to active return
# Active Weight * Asset Return (Truth Table Logic)
df['active_weight'] = df['wp'] - df['wb']
df['contribution'] = df['active_weight'] * df['ret']
# Sort by active contribution
sorted_contrib = df.sort_values(by='contribution', ascending=False)
def get_status(row):
if row['wp'] == 0.0 and row['wb'] > 0.0:
return "Excluded"
elif row['wp'] > row['wb']:
return "Overweight"
elif row['wp'] < row['wb']:
return "Underweight"
else:
return "Neutral"
def build_truth_table(dataframe, n=5):
results = []
for ticker, row in dataframe.head(n).iterrows():
status = get_status(row)
ret_val = row['ret']
active_contrib = row['contribution']
# Directional Signage Logic (The "Why")
reasoning = "Neutral"
if status == "Excluded":
if ret_val < 0:
reasoning = "Protected (Avoided Loss)"
else:
reasoning = "Drag (Missed Rally)"
elif status == "Underweight":
if ret_val < 0:
reasoning = "Protected (Underweight Loser)"
else:
reasoning = "Drag (Underweight Winner)"
elif status == "Overweight":
if ret_val > 0:
reasoning = "Value Add (Stock Picking)"
else:
reasoning = "Detractor (Overweight Loser)"
results.append({
"Ticker": ticker,
"Sector": row['sector'],
"Status": status,
"active_contribution_raw": float(row['contribution']), # Keep raw for sorting helper if needed
"Active_Contribution": f"{row['contribution']:.4f}",
"Return": f"{row['ret']:.2%}",
"Reasoning": reasoning
})
return results
# Top 5 Winners (Contributors) & Losers (Detractors)
top_contributors = build_truth_table(sorted_contrib, 5)
top_detractors = build_truth_table(sorted_contrib.sort_values(by='contribution', ascending=True), 5)
# Build Sector Exposure Truth Table
sector_exposure = []
for sector, data in sector_groups:
w_p = data['wp'].sum()
w_b = data['wb'].sum()
if w_p == 0.0 and w_b > 0.0:
status = "Excluded"
elif w_p < w_b:
status = "Underweight"
elif w_p > w_b:
status = "Overweight"
else:
status = "Neutral"
sector_exposure.append({
"Sector": sector,
"Portfolio_Weight": f"{w_p:.2%}",
"Benchmark_Weight": f"{w_b:.2%}",
"Status": status
})
# Narrative skeleton (to be filled by AI)
narrative_raw = (
f"Total Active Return: {(total_allocation + total_selection + total_interaction):.4f}. "
f"Allocation Effect: {total_allocation:.4f}. "
f"Selection Effect: {total_selection + total_interaction:.4f}."
)
return AttributionReport(
allocation_effect=total_allocation,
selection_effect=total_selection + total_interaction,
total_active_return=(total_allocation + total_selection + total_interaction),
top_contributors=top_contributors,
top_detractors=top_detractors,
sector_exposure=sector_exposure,
narrative=narrative_raw
)
|