Skip to content

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

  1. Carino factors using the monthly returns:
    \( C_{\text{Jan}} = 0.9969648 \), \( C_{\text{Feb}} = 0.9999317 \), \( C_{\text{Mar}} = 1.0030242 \)
  2. Normalize raw contributions so they add to monthly active return.
  3. Apply each Carino factor to the normalized group effects.
  4. 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.