What the skill tells Claude
This is the skill's instruction file, SKILL.md, as Claude reads it.
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 <user request>
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
{
"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
- Check whether the user uploaded an xlsx file.
- Check whether the message contains JSON with
"holdings"key. - Otherwise, enter prompted collection mode.
Step 2 — Validate inputs (scripts/validate_inputs.py)
Run the validator against the detected input. The validator checks:
portfolio_weightsums to 1.0 ± 1e-4 per periodbenchmark_weightsums to 1.0 ± 1e-4 per period- No null
segment_l1values portfolio_returnandbenchmark_returnare numericperiod_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:
{ "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)
- Instantiate
AttributionEnginefromAttributionEngine.py - Call
run_all_periods_from_holdings()withsegment_level="segment_l1" - Call
carino_link() - Call
save_json()→attribution_results.json
Step 5 — Generate output report
- Build
attribution_report.xlsxusing the same report builder pattern asbuild_attribution_xlsx.py— Cover, Q1_Summary, one sheet per period, Security_Detail, Methodology. - 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
carinolinking 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
Shared as is, with no warranty. Not investment, legal or tax advice. See Disclosures.