BF Link (Per-Group Carino)¶
1. Calculation Name¶
Brinson–Fachler Group Linking (Carino Method)
2. Description and Mathematical Formula¶
The BF Link plugin scales period-by-period allocation/selection/interaction (A/S/I) effects into a horizon total while preserving attribution across groups.
For each period \( t \):
- Compute the raw active return \( a_t = r^P_t - r^B_t \)
- Sum raw group contributions \( \sum_{g} E_{g,t}^{\text{raw}} = a_t \) after normalization
- Carino scaling factor:
\[ C_t = \frac{1 + r^P_t}{1 + r^B_t} \prod_{k = t+1}^{T} \frac{1 + r^B_k}{1 + r^P_k} \]
- Linked group effect:
\[ E^{\text{linked}}_{g} = \sum_{t=1}^{T} E_{g,t}^{\text{raw}} \times C_t \]
3. Input Sample Data¶
| Month | Group | \( r^P_t \) | \( r^B_t \) | Allocation (raw) | Selection (raw) | Interaction (raw) |
|---|---|---|---|---|---|---|
| Jan | Rates | 2.50% | 2.20% | 0.06% | 0.10% | 0.02% |
| Jan | Spread | 2.50% | 2.20% | 0.04% | 0.03% | 0.05% |
| Feb | Rates | 1.80% | 1.50% | 0.07% | 0.11% | 0.03% |
| Feb | Spread | 1.80% | 1.50% | 0.05% | 0.04% | 0.00% |
| Mar | Rates | -0.50% | -0.80% | 0.05% | 0.12% | 0.04% |
| Mar | Spread | -0.50% | -0.80% | 0.03% | 0.04% | 0.02% |
Each month’s raw contributions sum to the month’s active return (0.30%).
4. Mathematical Solution¶
- Carino factors using the monthly returns:
\( C_{\text{Jan}} = 0.9969648 \), \( C_{\text{Feb}} = 0.9999317 \), \( C_{\text{Mar}} = 1.0030242 \) - Normalize raw contributions so they add to monthly active return.
- Apply each Carino factor to the normalized group effects.
- Sum across months to obtain linked group totals.
Linked results (basis points):
- Rates: Allocation 18.00, Selection 33.01, Interaction 9.01
- Spread: Allocation 11.99, Selection 11.00, Interaction 6.99
- Horizon active (all groups) ≈ 90.00 bps
5. Sample Python and R Code¶
import pandas as pd
import numpy as np
data = pd.DataFrame(
{
"month": ["Jan", "Jan", "Feb", "Feb", "Mar", "Mar"],
"group": ["Rates", "Spread", "Rates", "Spread", "Rates", "Spread"],
"rp": [0.025, 0.025, 0.018, 0.018, -0.005, -0.005],
"rb": [0.022, 0.022, 0.015, 0.015, -0.008, -0.008],
"alloc": [0.0006, 0.0004, 0.0007, 0.0005, 0.0005, 0.0003],
"select": [0.0010, 0.0003, 0.0011, 0.0004, 0.0012, 0.0004],
"inter": [0.0002, 0.0005, 0.0003, 0.0000, 0.0004, 0.0002],
}
)
factors = []
months = data["month"].unique()
for month in months:
row = data.loc[data["month"] == month].iloc[0]
rp = row["rp"]
rb = row["rb"]
tail = 1.0
later = data[data["month"].isin(months[list(months).index(month)+1:])]
for _, later_row in later.drop_duplicates("month").iterrows():
tail *= (1 + later_row["rb"]) / (1 + later_row["rp"])
factor = ((1 + rp) / (1 + rb)) * tail
factors.append((month, factor))
factor_map = dict(factors)
def normalize(group):
active = group["rp"].iloc[0] - group["rb"].iloc[0]
total_raw = (group["alloc"] + group["select"] + group["inter"]).sum()
scale = active / total_raw if total_raw else 0
return group.assign(
alloc_norm=group["alloc"] * scale,
select_norm=group["select"] * scale,
inter_norm=group["inter"] * scale,
)
normalized = data.groupby("month", group_keys=False).apply(normalize)
normalized["factor"] = normalized["month"].map(factor_map)
normalized["alloc_linked"] = normalized["alloc_norm"] * normalized["factor"]
normalized["select_linked"] = normalized["select_norm"] * normalized["factor"]
normalized["inter_linked"] = normalized["inter_norm"] * normalized["factor"]
totals = (
normalized.groupby("group")[["alloc_linked", "select_linked", "inter_linked"]]
.sum()
.mul(10000) # convert to bps
)
print(totals)
library(dplyr)
data <- tibble::tibble(
month = c("Jan", "Jan", "Feb", "Feb", "Mar", "Mar"),
group = c("Rates", "Spread", "Rates", "Spread", "Rates", "Spread"),
rp = c(0.025, 0.025, 0.018, 0.018, -0.005, -0.005),
rb = c(0.022, 0.022, 0.015, 0.015, -0.008, -0.008),
alloc = c(0.0006, 0.0004, 0.0007, 0.0005, 0.0005, 0.0003),
select = c(0.0010, 0.0003, 0.0011, 0.0004, 0.0012, 0.0004),
inter = c(0.0002, 0.0005, 0.0003, 0.0000, 0.0004, 0.0002)
)
months <- unique(data$month)
factors <- purrr::map_dbl(seq_along(months), function(i) {
rp <- data %>% filter(month == months[i]) %>% pull(rp) %>% first()
rb <- data %>% filter(month == months[i]) %>% pull(rb) %>% first()
tail_ratio <- prod((1 + (data %>% filter(month %in% months[(i+1):length(months)]) %>% distinct(month, rb, rp) %>% pull(rb))) /
(1 + (data %>% filter(month %in% months[(i+1):length(months)]) %>% distinct(month, rb, rp) %>% pull(rp))))
((1 + rp) / (1 + rb)) * ifelse(is.finite(tail_ratio), tail_ratio, 1)
})
factor_map <- setNames(factors, months)
normalized <- data %>%
group_by(month) %>%
mutate(
active = first(rp) - first(rb),
total_raw = sum(alloc + select + inter),
scale = ifelse(total_raw == 0, 0, active / total_raw),
alloc_norm = alloc * scale,
select_norm = select * scale,
inter_norm = inter * scale,
factor = factor_map[month]
) %>%
ungroup() %>%
mutate(
alloc_linked = alloc_norm * factor,
select_linked = select_norm * factor,
inter_linked = inter_norm * factor
)
totals <- normalized %>%
group_by(group) %>%
summarise(
alloc_bps = sum(alloc_linked) * 10000,
select_bps = sum(select_linked) * 10000,
inter_bps = sum(inter_linked) * 10000,
.groups = "drop"
)
totals
6. Output Table¶
| Group | Allocation (bps) | Selection (bps) | Interaction (bps) | Total (bps) |
|---|---|---|---|---|
| Rates | 18.00 | 33.01 | 9.01 | 60.01 |
| Spread | 11.99 | 11.00 | 6.99 | 29.99 |
| Total | 29.99 | 44.01 | 16.00 | 90.00 |
7. Conclusion¶
This template demonstrates how FinFacts scales A/S/I contributions across groups using Carino linking. It is ideal for validating horizon totals against desktop exports and for onboarding analysts to the group-aware attribution workflow.