성과 어트리뷰션이란?
성과 어트리뷰션(Performance Attribution)은 포트폴리오 수익률을 구성 요소별로 분해하여 어디서 수익이 발생했고, 어디서 손실이 생겼는지를 정량적으로 파악하는 분석 기법입니다. 기관 투자에서 펀드매니저 평가에 필수적으로 사용되며, 퀀트 자동매매에서도 전략의 강점과 약점을 객관적으로 진단하는 핵심 도구입니다.
단순히 “이번 달 수익률 5%”라는 결과만 보면, 시장이 올라서 번 건지 전략이 잘 작동한 건지 구분할 수 없습니다. 성과 어트리뷰션은 이 질문에 정확한 숫자로 답합니다.
어트리뷰션의 3가지 분해 방식
| 분해 방식 | 핵심 질문 | 적합 상황 |
|---|---|---|
| Brinson 모델 | 배분 vs 종목선택 중 뭐가 기여? | 멀티 자산 포트폴리오 |
| 팩터 기반 | 어떤 팩터가 수익을 만들었나? | 팩터 투자 전략 |
| 거래 기반 | 개별 거래별 기여도는? | 자동매매 전략 |
자동매매에서는 거래 기반 어트리뷰션과 팩터 기반 어트리뷰션을 결합하면 가장 실용적인 인사이트를 얻을 수 있습니다.
Brinson 모델 파이썬 구현
Brinson-Fachler 모델은 포트폴리오 초과수익을 배분 효과(Allocation), 선택 효과(Selection), 상호작용 효과(Interaction)로 분해합니다.
import pandas as pd
import numpy as np
from dataclasses import dataclass
@dataclass
class BrinsonResult:
asset: str
allocation: float
selection: float
interaction: float
total: float
class BrinsonAttribution:
def __init__(
self,
portfolio_weights: dict[str, float],
benchmark_weights: dict[str, float],
portfolio_returns: dict[str, float],
benchmark_returns: dict[str, float]
):
self.pw = portfolio_weights
self.bw = benchmark_weights
self.pr = portfolio_returns
self.br = benchmark_returns
self.assets = list(portfolio_weights.keys())
def decompose(self) -> list[BrinsonResult]:
"""Brinson-Fachler 분해"""
results = []
total_br = sum(
self.bw[a] * self.br[a] for a in self.assets
)
for asset in self.assets:
w_diff = self.pw[asset] - self.bw[asset]
r_diff = self.pr[asset] - self.br[asset]
allocation = w_diff * (self.br[asset] - total_br)
selection = self.bw[asset] * r_diff
interaction = w_diff * r_diff
results.append(BrinsonResult(
asset=asset,
allocation=round(allocation * 100, 4),
selection=round(selection * 100, 4),
interaction=round(interaction * 100, 4),
total=round(
(allocation + selection + interaction)
* 100, 4
)
))
return results
def summary(self) -> dict:
results = self.decompose()
port_ret = sum(
self.pw[a] * self.pr[a] for a in self.assets
)
bench_ret = sum(
self.bw[a] * self.br[a] for a in self.assets
)
return {
'portfolio_return_pct': round(port_ret * 100, 4),
'benchmark_return_pct': round(bench_ret * 100, 4),
'excess_return_pct': round(
(port_ret - bench_ret) * 100, 4
),
'total_allocation_pct': round(
sum(r.allocation for r in results), 4
),
'total_selection_pct': round(
sum(r.selection for r in results), 4
),
'total_interaction_pct': round(
sum(r.interaction for r in results), 4
),
'by_asset': [vars(r) for r in results]
}
거래 기반 어트리뷰션 구현
자동매매 전략에 더 적합한 방식입니다. 개별 거래의 수익 기여도를 타이밍, 사이징, 방향성으로 분해합니다.
@dataclass
class TradeAttribution:
trade_id: str
pnl: float
timing_contrib: float # 진입 타이밍 기여
sizing_contrib: float # 포지션 크기 기여
direction_contrib: float # 방향 판단 기여
holding_hours: float
pnl_per_hour: float
class TradeBasedAttribution:
def __init__(self, trades: pd.DataFrame):
"""
trades 컬럼: trade_id, entry_time, exit_time,
side, entry_price, exit_price, quantity,
market_return (동일 기간 벤치마크 수익률)
"""
self.trades = trades.copy()
self._calculate()
def _calculate(self):
df = self.trades
df['pnl_pct'] = np.where(
df['side'] == 'buy',
(df['exit_price'] - df['entry_price'])
/ df['entry_price'],
(df['entry_price'] - df['exit_price'])
/ df['entry_price']
)
df['pnl_usd'] = (
df['pnl_pct'] * df['entry_price'] * df['quantity']
)
df['notional'] = df['entry_price'] * df['quantity']
df['holding_hours'] = (
pd.to_datetime(df['exit_time'])
- pd.to_datetime(df['entry_time'])
).dt.total_seconds() / 3600
# 방향성 기여: 시장 방향과 같은 쪽이면 양수
df['direction_contrib'] = np.where(
df['side'] == 'buy',
df['market_return'] * df['notional'],
-df['market_return'] * df['notional']
)
# 타이밍 기여: 실제 수익 - 시장 수익
df['timing_contrib'] = (
df['pnl_usd'] - df['direction_contrib']
)
# 사이징 기여: 수익 거래에 큰 비중 할당했는지
avg_notional = df['notional'].mean()
df['sizing_contrib'] = (
(df['notional'] - avg_notional)
/ avg_notional * df['pnl_pct']
) * avg_notional
def attribute(self) -> list[TradeAttribution]:
results = []
for _, row in self.trades.iterrows():
results.append(TradeAttribution(
trade_id=row['trade_id'],
pnl=round(row['pnl_usd'], 2),
timing_contrib=round(
row['timing_contrib'], 2
),
sizing_contrib=round(
row['sizing_contrib'], 2
),
direction_contrib=round(
row['direction_contrib'], 2
),
holding_hours=round(row['holding_hours'], 1),
pnl_per_hour=round(
row['pnl_usd'] / max(
row['holding_hours'], 0.01
), 2
)
))
return results
def summary(self) -> dict:
df = self.trades
winners = df[df['pnl_usd'] > 0]
losers = df[df['pnl_usd'] <= 0]
return {
'total_trades': len(df),
'total_pnl': round(df['pnl_usd'].sum(), 2),
'win_rate': round(
len(winners) / len(df) * 100, 1
),
'avg_win': round(
winners['pnl_usd'].mean(), 2
) if len(winners) else 0,
'avg_loss': round(
losers['pnl_usd'].mean(), 2
) if len(losers) else 0,
'timing_total': round(
df['timing_contrib'].sum(), 2
),
'sizing_total': round(
df['sizing_contrib'].sum(), 2
),
'direction_total': round(
df['direction_contrib'].sum(), 2
),
'best_pnl_per_hour': round(
(df['pnl_usd'] / df['holding_hours']
.clip(lower=0.01)).max(), 2
),
'avg_holding_hours': round(
df['holding_hours'].mean(), 1
)
}
시간대별·요일별 성과 분해
자동매매에서 언제 수익이 나고 손실이 나는지를 파악하는 것은 전략 개선의 핵심입니다.
def temporal_attribution(
trades: pd.DataFrame
) -> dict:
"""시간대·요일별 성과 분해"""
df = trades.copy()
df['entry_dt'] = pd.to_datetime(df['entry_time'])
df['hour'] = df['entry_dt'].dt.hour
df['weekday'] = df['entry_dt'].dt.day_name()
# 시간대별 집계
hourly = df.groupby('hour').agg(
count=('pnl_usd', 'count'),
total_pnl=('pnl_usd', 'sum'),
avg_pnl=('pnl_usd', 'mean'),
win_rate=('pnl_usd', lambda x: (x > 0).mean())
).round(2)
# 요일별 집계
daily = df.groupby('weekday').agg(
count=('pnl_usd', 'count'),
total_pnl=('pnl_usd', 'sum'),
avg_pnl=('pnl_usd', 'mean'),
win_rate=('pnl_usd', lambda x: (x > 0).mean())
).round(2)
# 최적·최악 시간대
best_hour = hourly['avg_pnl'].idxmax()
worst_hour = hourly['avg_pnl'].idxmin()
return {
'hourly': hourly.to_dict('index'),
'daily': daily.to_dict('index'),
'best_hour': int(best_hour),
'worst_hour': int(worst_hour),
'recommendation': (
f"UTC {worst_hour}시 진입 회피 권장, "
f"UTC {best_hour}시 비중 확대 고려"
)
}
실전 분석 예시
바이낸스 선물 자동매매 1개월 성과를 어트리뷰션하는 전체 흐름입니다.
# 거래 로그 로드
trades = pd.DataFrame([
{'trade_id': 'T001', 'entry_time': '2026-03-01 09:00',
'exit_time': '2026-03-01 13:00', 'side': 'buy',
'entry_price': 83500, 'exit_price': 84200,
'quantity': 0.02, 'market_return': 0.005},
{'trade_id': 'T002', 'entry_time': '2026-03-02 14:30',
'exit_time': '2026-03-02 18:00', 'side': 'sell',
'entry_price': 84800, 'exit_price': 84100,
'quantity': 0.015, 'market_return': -0.008},
{'trade_id': 'T003', 'entry_time': '2026-03-03 02:00',
'exit_time': '2026-03-03 05:30', 'side': 'buy',
'entry_price': 83900, 'exit_price': 83600,
'quantity': 0.025, 'market_return': -0.003},
])
# 어트리뷰션 실행
attr = TradeBasedAttribution(trades)
report = attr.summary()
print("=== 성과 어트리뷰션 요약 ===")
for k, v in report.items():
print(f" {k}: {v}")
# 결과 해석:
# timing_total > 0 → 진입 타이밍이 우수
# direction_total > 0 → 방향 판단이 정확
# sizing_total < 0 → 포지션 사이징 개선 필요
어트리뷰션 결과 활용법
- 타이밍 기여가 낮으면: 진입 조건(RSI, 볼린저 밴드 등)의 파라미터를 재최적화하거나, 확인 신호를 추가합니다.
- 사이징 기여가 음수면: 손실 거래에 과도한 비중이 할당된 것이므로, 신뢰도 기반 동적 사이징을 도입합니다.
- 특정 시간대 손실 집중: 해당 시간대의 거래를 비활성화하거나, 별도 파라미터 세트를 적용합니다.
- 방향성 기여만 높으면: 시장 추세에 의존하는 전략이므로, 횡보장 대응 로직을 보강해야 합니다.
- 주간 리포트 자동화: 매주 어트리뷰션 리포트를 생성하여 전략 드리프트를 조기에 감지합니다.
마무리
성과 어트리뷰션은 자동매매 전략의 "왜 벌었고, 왜 잃었는가"에 답하는 필수 도구입니다. Brinson 모델로 배분·선택 효과를 분리하고, 거래 기반 분석으로 타이밍·사이징·방향성 기여를 정량화하면 전략의 진짜 엣지가 무엇인지 명확해집니다. 백테스트 수익률에만 집착하지 말고, 수익의 원천을 분해하는 습관을 들이면 전략 개선 속도가 크게 빨라집니다.
관련 글도 함께 확인해 보세요: