--- name: equity-attribution-bf description: > Runs Brinson-Fachler (1985) equity performance attribution with Carino (1999) multi-period logarithmic linking. Accepts a holdings xlsx file or structured JSON input, validates and normalises all inputs, prompts the user for anything missing, and delivers a full segment-level attribution report (xlsx + JSON). Use this skill whenever a user asks to run attribution, explain active return, decompose allocation vs selection vs interaction, or analyse a portfolio against its benchmark over one or more sub-periods. metadata: display_name: "Equity Attribution (Brinson-Fachler)" short_description: "Decompose active equity return into allocation, selection, and interaction effects." default_prompt: "Run Brinson-Fachler attribution on my equity portfolio for Q1 2026 and show me where the alpha came from." icon_path: "assets/icon.png" --- # Equity Attribution — Brinson-Fachler Skill ## Purpose Decompose a portfolio's active return versus its benchmark into three additive effects per GICS sector: | Effect | Formula | What it measures | |---|---|---| | **Allocation** | `(w_p − w_b) × (r_b_seg − R_b_total)` | Was the sector over/under-weight decision correct? | | **Selection** | `w_b × (r_p_seg − r_b_seg)` | Did stock picks within the sector beat the benchmark? | | **Interaction** | `(w_p − w_b) × (r_p_seg − r_b_seg)` | Combined weight + return divergence | Multi-period effects are chained with **Carino (1999) logarithmic smoothing** so the linked total always reconciles to the cumulative active return. --- ## Invocation ``` /equity-attribution-bf ``` Examples: - `/equity-attribution-bf Here is my portfolio xlsx, benchmark is S&P 500, Q1 2026` - `/equity-attribution-bf Run attribution on the uploaded holdings file` - `/equity-attribution-bf [paste JSON holdings]` --- ## Input Schema The skill accepts input in **three forms** (in order of preference): ### Form 1 — Spec-compliant xlsx (preferred) A file produced by `build_portfolio_xlsx.py` or matching the `attribution_data_specifications.md` schema. Must have a sheet whose name contains `"holding"` or `"security"` (case-insensitive) with these columns: | Field | Required | Notes | |---|---|---| | `period_id` | YES | e.g. `"2026-01"` | | `period_start` | YES | `YYYY-MM-DD` | | `period_end` | YES | `YYYY-MM-DD` | | `security_id` | YES | ISIN / CUSIP | | `security_name` | YES | Human-readable name | | `ticker` | YES | Exchange ticker | | `segment_l1` | YES | GICS Sector | | `segment_l2` | NO | GICS Industry Group | | `country` | YES | ISO 3166 | | `currency` | YES | ISO 4217 | | `portfolio_weight` | YES | Decimal, BOD, sums to 1.0 | | `benchmark_weight` | YES | Decimal, BOD, sums to 1.0 | | `portfolio_return` | YES | Decimal total return | | `benchmark_return` | YES | Decimal; use `R_b_total` for off-benchmark securities | | `portfolio_mv_start` | RECOMMENDED | Market value in base currency | | `portfolio_mv_end` | RECOMMENDED | Market value in base currency | ### Form 2 — JSON holdings ```json { "portfolio_name": "My Fund", "benchmark_name": "S&P 500", "base_currency": "USD", "linking_method": "carino", "periods": [ { "period_id": "2026-01", "period_start": "2025-12-31", "period_end": "2026-01-31", "holdings": [ { "ticker": "MSFT", "security_name": "Microsoft Corp", "security_id": "US5949181045", "segment_l1": "Information Technology", "country": "US", "currency": "USD", "portfolio_weight": 0.14, "benchmark_weight": 0.12, "portfolio_return": 0.080, "benchmark_return": 0.080 } ] } ] } ``` ### Form 3 — Natural language + prompted collection If neither file nor JSON is provided, the skill prompts for each required field in sequence (see **Prompt Rules** below). --- ## Step-by-Step Workflow The skill MUST execute these steps in order: ### Step 1 — Detect input form 1. Check whether the user uploaded an xlsx file. 2. Check whether the message contains JSON with `"holdings"` key. 3. Otherwise, enter **prompted collection mode**. ### Step 2 — Validate inputs (`scripts/validate_inputs.py`) Run the validator against the detected input. The validator checks: - `portfolio_weight` sums to 1.0 ± 1e-4 per period - `benchmark_weight` sums to 1.0 ± 1e-4 per period - No null `segment_l1` values - `portfolio_return` and `benchmark_return` are numeric - `period_start` < `period_end` - At least one period present - At least two securities present per period If validation **passes** → proceed to Step 3. If validation **fails** → display a clear error table with column, issue, and fix instructions. **Do not run attribution on invalid data.** ### Step 3 — Normalise inputs Convert the validated input into the canonical `holdings_by_period` dict: ```python { "2026-01": [ {row_dict}, ... ], "2026-02": [...], ... } ``` Apply these normalisations automatically: - Strip whitespace from string fields - Convert period dates to `YYYY-MM-DD` strings - Cast weights and returns to `float` - Infer `benchmark_return` for off-benchmark securities (set = period benchmark total return) - Infer `segment_l1 = "UNCLASSIFIED"` if missing (and warn) ### Step 4 — Run attribution (`scripts/run_attribution.py`) 1. Instantiate `AttributionEngine` from `AttributionEngine.py` 2. Call `run_all_periods_from_holdings()` with `segment_level="segment_l1"` 3. Call `carino_link()` 4. Call `save_json()` → `attribution_results.json` ### Step 5 — Generate output report 1. Build `attribution_report.xlsx` using the same report builder pattern as `build_attribution_xlsx.py` — Cover, Q1_Summary, one sheet per period, Security_Detail, Methodology. 2. Save to `/workspace/attribution_report.xlsx` ### Step 6 — Deliver results Present a structured summary: ``` Q1 2026 Attribution Summary — [Portfolio Name] ───────────────────────────────────────────────── Portfolio Return : +X.XX% Benchmark Return : +X.XX% Active Return : +X.XX% Effect Linked Value ──────────────────────────────── Allocation +X.XX% Selection +X.XX% Interaction +X.XX% ───────────────────────────────── Total Active +X.XX% ✓ reconciles Key insight: [Top positive segment] drove [+X.XX%] from [allocation/selection]. [Biggest drag] detracted [−X.XX%]. ``` Attach `attribution_report.xlsx` and `attribution_results.json`. --- ## Prompt Rules (Prompted Collection Mode) When inputs are missing or incomplete, prompt for them **one group at a time**: ### Group 1 — Portfolio identity (ask once) > "I need a few details to run the attribution. Please provide: > 1. **Portfolio name** (e.g. 'US Core Equity Fund') > 2. **Benchmark name** (e.g. 'S&P 500') > 3. **Base currency** (e.g. 'USD') > 4. **Reporting period(s)** (e.g. 'Jan, Feb, Mar 2026' or 'Q1 2026')" ### Group 2 — Holdings data (ask if no file/JSON) > "Now I need the holdings for each period. For each security please provide: > ticker, segment (GICS sector), portfolio weight, benchmark weight, return. > You can paste a table, CSV snippet, or upload an xlsx file." ### Group 3 — Missing fields (ask per field only if needed) Prompt individually only for fields that are missing after partial input: - Missing `segment_l1`: "What GICS sector does [TICKER] belong to?" - Missing `benchmark_weight`: "Is [TICKER] in the benchmark? If yes, what is its weight?" - Missing `benchmark_return`: "What was the benchmark return for [TICKER] in [PERIOD]? (If it's not in the benchmark, I'll use the total benchmark return as a proxy.)" - Missing period dates: "What are the start and end dates for [PERIOD_ID]?" **Never ask for information that can be inferred:** - Off-benchmark securities get `benchmark_weight = 0.0` automatically - Off-benchmark `benchmark_return` defaults to that period's total benchmark return - `security_id` defaults to ticker when ISIN is unavailable (with a warning) --- ## Validation Error Messages | Check | Error message template | |---|---| | Portfolio weights ≠ 1.0 | `Period {id}: portfolio weights sum to {sum:.4f}, expected 1.0. Adjust weights to sum exactly to 1.0.` | | Benchmark weights ≠ 1.0 | `Period {id}: benchmark weights sum to {sum:.4f}, expected 1.0. Adjust or confirm benchmark composition.` | | Missing segment_l1 | `{ticker}: segment_l1 is null. Provide a GICS sector (e.g. 'Information Technology').` | | Non-numeric return | `{ticker} period {id}: portfolio_return '{value}' is not a number. Provide a decimal return (e.g. 0.042 for 4.2%).` | | period_start ≥ period_end | `Period {id}: start date {start} is not before end date {end}.` | | Fewer than 2 securities | `Period {id}: only {n} security found. Attribution requires at least 2 securities.` | --- ## Output Format | File | Contents | |---|---| | `attribution_report.xlsx` | Cover, Q1_Summary, per-period sheets, Security_Detail, Methodology | | `attribution_results.json` | Machine-readable full dataset with segment detail and Carino k-factors | --- ## Constraints - Weights MUST be beginning-of-period (BOD). Never use end-of-period weights. - Returns MUST be in decimal form (0.042, not 4.2). - The attribution residual MUST be < 1e-6 per period. If not, re-check weight sums. - Only `carino` linking is supported in this version. - Fixed income, FX overlay, and derivatives attribution are out of scope (use Campisi or Karnosky-Singer skills). - Maximum 200 securities per period; maximum 12 periods per run. --- ## Files in This Skill Package ``` equity-attribution-bf/ ├── SKILL.md ← This file ├── assets/ │ └── icon.png ← Skill icon ├── scripts/ │ ├── validate_inputs.py ← Input validator and normaliser │ └── run_attribution.py ← Attribution runner (wraps AttributionEngine) └── references/ ├── input_schema.md ← Full field-level schema reference └── bf_formulas.md ← Brinson-Fachler formula derivations ```