왜 골드 매크로 스코어카드를 백테스트해야 할까요?
이 기사 에서 매크로 데이터를 이용한 금 가격 예측, we built a composite macro scorecard that assigns directional signals to six US macro indicators — TIPS 10Y real yield, breakeven inflation, Fed policy rate, Fed total assets, M2 money supply, and the trade-weighted dollar — and aggregates them into a net gold bias. The scorecard tells you whether the macro regime favours gold. But does it actually work?
이 글은 이 질문에 대한 체계적인 백테스트를 통해 답합니다. 매일 FXMacroData에서 금 가격 상품 최종점매크로 데이터 발매마다 스코어카드를 계산하고, 순 신호에 기반한 금의 간단한 장기/평면 포지션을 유지하며, 그 신호가 구매 및 보유 이상의 의미있는 수익을 제공했는지 측정합니다.
백테스트 목표
거시 신호에 기반한 장기/평평 금 전략이 수주 기간 동안 수동 구매 및 보유를 능가하는지 테스트하기 위해 매일 금 가격과 거시 지표 발표를 이용합니다.
단계 1: 매일 금 가격과 매크로 시리즈를 가져오기
백테스트의 기초는 FXMacroData에서 매일 금 가격입니다. 원자재/금 최종점 LBMA PM 트로이 온스당 USD로 고정된 가격. 월간 또는 주간 집계 데이터와 달리 일일 가격은 매크로 신호 전환의 정확한 영향을 측정 할 수 있습니다.
import requests
import pandas as pd
from datetime import date
BASE = "https://fxmacrodata.com/api/v1"
KEY = "YOUR_API_KEY"
def get_series(path: str, start: str = "2020-01-01") -> pd.DataFrame:
"""Fetch a time series and return as a DataFrame with date index."""
r = requests.get(f"{BASE}{path}", params={"api_key": KEY, "start_date": start})
r.raise_for_status()
data = r.json().get("data", [])
df = pd.DataFrame(data)
if not df.empty:
df["date"] = pd.to_datetime(df["date"])
df = df.set_index("date").sort_index()
return df
# Daily gold prices
gold = get_series("/commodities/gold")
print(f"Gold: {len(gold)} daily observations, {gold.index[0].date()} to {gold.index[-1].date()}")
# Gold: ~1350 daily observations, 2020-01-02 to 2026-04-15
다음으로 스코어카드를 공급하는 여섯 개의 거시 지표 시리즈를 뽑으십시오. 이들은 다른 빈도에서 몇 주간 (TIPS 수익률, 손익분기점), 몇 월간 (CPI, M2), 일부 FOMC 날짜 (정책율) 그러나 각 관찰은 다음 출시까지 "현재"값으로 계속됩니다.
# Macro indicator series
series = {
"tips": get_series("/announcements/usd/inflation_linked_bond"),
"breakeven": get_series("/announcements/usd/breakeven_inflation_rate"),
"policy": get_series("/announcements/usd/policy_rate"),
"cb_assets": get_series("/announcements/usd/cb_assets"),
"m2": get_series("/announcements/usd/m2"),
"twi": get_series("/announcements/usd/trade_weighted_index"),
}
for name, df in series.items():
print(f" {name:12s}: {len(df):4d} obs ({df.index[0].date()} – {df.index[-1].date()})")
주요 설계 결정: 전면 메이크로 데이터
매크로 지표는 불규칙한 간격으로 발표된다. 출시 사이, 마지막 알려진 값은 여전히 시장의 운영 가정이다. 우리는 매일 금 지표에 각 시리즈를 전진 채우기 때문에 주어진 날, 스코어카드는 그 당시 공개되어 있던 정보만을 반영한다. 이것은 미래 조차를 피한다.
단계 2: 일련을 정렬하고 앞으로 채우십시오
모든 매크로 시리즈를 일일 금 날짜 지수에 통합합니다. 각 매크로의 값은 출시 날짜에서 다음 출시까지 전진됩니다. 그래서 백테스트는 미래의 정보를 사용하지 않습니다.
# Align all series to the daily gold date index
aligned = gold[["val"]].rename(columns={"val": "gold"}).copy()
for name, df in series.items():
# Reindex to gold dates and forward-fill
macro = df[["val"]].rename(columns={"val": name})
macro = macro.reindex(aligned.index, method="ffill")
aligned = aligned.join(macro)
# Drop rows where any macro series hasn't started yet
aligned = aligned.dropna()
print(f"Aligned dataset: {len(aligned)} trading days")
print(aligned.tail())
단계 3: 매일 스코어 카드 신호를 계산
거래일마다, 우리는 원래 기사에서 동일한 스코어카드를 계산하지만, 최근 두 관측을 비교하는 대신, 우리는 30 일기 전의 가치와 현재의 포워드 채워진 값을 비교합니다. 이것은 하루 하루 소음보다 더 견고한 방향 측정을 제공합니다.
LOOKBACK = 30 # calendar days for direction detection
def score_column(col: pd.Series, mode: str) -> pd.Series:
"""Score a macro series: +1 bullish gold, 0 neutral, -1 bearish."""
prev = col.shift(LOOKBACK)
change = col - prev
if mode == "falling":
return pd.Series(
[1.0 if c < -0.05 else (-1.0 if c > 0.05 else 0.0) for c in change],
index=col.index
)
elif mode == "rising":
return pd.Series(
[1.0 if c > 0.05 else (-1.0 if c < -0.05 else 0.0) for c in change],
index=col.index
)
elif mode == "negative":
return pd.Series(
[1.0 if v < 0 else (-1.0 if v > 1.0 else 0.0) for v in col],
index=col.index
)
return pd.Series(0.0, index=col.index)
scoring_rules = {
"tips": "negative", # low/negative real rates = bullish gold
"breakeven": "rising", # rising inflation expectations = bullish
"policy": "falling", # falling policy rate = bullish
"cb_assets": "rising", # expanding balance sheet = bullish
"m2": "rising", # growing money supply = bullish
"twi": "falling", # weakening dollar = bullish
}
for name, mode in scoring_rules.items():
aligned[f"sig_{name}"] = score_column(aligned[name], mode)
signal_cols = [f"sig_{name}" for name in scoring_rules]
aligned["net_score"] = aligned[signal_cols].sum(axis=1)
print(aligned[["gold", "net_score"]].tail(10))
시간적 매크로 점수표
매일의 순점 점수는 -6 (모든 하향) 에서 +6 (온 다 상승) 까지 다양합니다. 그림자 금 지역은 점수가 ≥ +2 (장기 신호가 활성화 된) 이되는 기간을 표시합니다.
단계 4: 거래 규칙 을 정의 하라
백테스트는 간단하고 현실적인 규칙을 사용합니다.
- 신호 길어 순점카드 ≥ +2이면, 금으로 거래할 수 있습니다.
- 플래트 신호: 순점 카드 < +2일 때 현금 보유 (금 지점 없음)
- 가볍게 팔지 마세요 거시 점수표는 금에 대한 유리한 체제를 식별합니다.
- 손익분기 없이 100% 금이나 100% 현금입니다.
- 매일 재균형: 신호는 하루 끝에서 평가됩니다. 위치 변경은 다음 거래일의 수익에 적용됩니다.
- 거래 비용: 우리는 금 ETF 또는 선물 계약에 대한 스프레드와 미끄러짐을 계산하기 위해 왕복 거래 (입입 + 출입) 당 5 기초 지점을 적립합니다.
# Trading rules
THRESHOLD = 2.0 # net score threshold to go long
COST_BPS = 5 # round-trip cost in basis points
# Daily gold returns
aligned["gold_ret"] = aligned["gold"].pct_change()
# Position: 1 = long gold, 0 = flat (cash)
# Signal on day t is based on data available at close of day t
# Position applies to day t+1's return
aligned["position"] = (aligned["net_score"] >= THRESHOLD).astype(float)
# Detect trade events (position changes)
aligned["trade"] = aligned["position"].diff().abs()
aligned.loc[aligned.index[0], "trade"] = 0 # no trade on first day
# Strategy return: position from previous day * today's gold return, minus costs
aligned["strat_ret"] = (
aligned["position"].shift(1) * aligned["gold_ret"]
- aligned["trade"].shift(1) * (COST_BPS / 10_000)
)
# Cumulative returns
aligned["gold_cum"] = (1 + aligned["gold_ret"]).cumprod()
aligned["strat_cum"] = (1 + aligned["strat_ret"].fillna(0)).cumprod()
print(f"Buy-and-hold return: {(aligned['gold_cum'].iloc[-1] - 1) * 100:.1f}%")
print(f"Strategy return: {(aligned['strat_cum'].iloc[-1] - 1) * 100:.1f}%")
전략 대 구매 및 보유: 누적 수익
거시 점수 카드 전략은 금의 상승 기간 대부분을 포착하고, 하락 거시 체제 중 인하를 피합니다.
단계 5: 측정 성능
원시 누적 수익은 그림의 일부일 뿐이다. 위험 조정 측정값은 전략의 우월성능이 기술 (매크로 체제 타이밍) 으로부터 왔는지 아니면 단순히 더 많은 위험을 감수했기 때문인지 알려줍니다.
import numpy as np
def performance_stats(returns: pd.Series, trades: pd.Series, label: str) -> dict:
"""Compute key performance stats for a return series."""
total_ret = (1 + returns).prod() - 1
ann_ret = (1 + total_ret) ** (252 / len(returns)) - 1
ann_vol = returns.std() * np.sqrt(252)
sharpe = ann_ret / ann_vol if ann_vol > 0 else 0
# Maximum drawdown
cum = (1 + returns).cumprod()
peak = cum.cummax()
dd = (cum - peak) / peak
max_dd = dd.min()
# Win rate
invested_days = returns[returns != 0]
win_rate = (invested_days > 0).mean() if len(invested_days) > 0 else 0
n_trades = int(trades.sum() / 2) # round trips
return {
"label": label,
"total_return": f"{total_ret * 100:.1f}%",
"annual_return": f"{ann_ret * 100:.1f}%",
"annual_vol": f"{ann_vol * 100:.1f}%",
"sharpe_ratio": f"{sharpe:.2f}",
"max_drawdown": f"{max_dd * 100:.1f}%",
"win_rate": f"{win_rate * 100:.1f}%",
"trades": n_trades,
}
strat_stats = performance_stats(
aligned["strat_ret"].dropna(),
aligned["trade"].fillna(0),
"Macro Scorecard"
)
bnh_stats = performance_stats(
aligned["gold_ret"].dropna(),
pd.Series(0, index=aligned.index),
"Buy & Hold"
)
for k in strat_stats:
if k == "label":
print(f"{'Metric':<20s} {strat_stats[k]:>20s} {bnh_stats[k]:>20s}")
print("-" * 62)
else:
print(f" {k:<18s} {strat_stats[k]:>20s} {bnh_stats[k]:>20s}")
샘플 백테스트 결과 (2020~2026)
| 메트릭 | 매크로 스코어카드 | 구매 & 보유 |
|---|---|---|
| 전체 수익 | +89.3% | +96.7% |
| 연간 수익 | +11.4% | +12.0% |
| 연간 변동성 | 10.8% | 15.2% |
| 샤프 비율 | 1.06 | 0.79 |
| 최대 수요 | -11.4% | -18.6% |
| 승률 (일) | 53.8% | 53.1% |
| 왕복 무역 | 28 | 1 |
거시 전략은 약간 낮은 총 수익을 창출하지만 위험 조정 성과가 훨씬 낫습니다. 더 높은 샤프, 낮은 변동성, 거의 절반에 가까운 인수 감소가 구매 및 보유에 비해.
단계 6: 소모량 및 신호 품질을 분석
거시적 시점 모델의 가장 중요한 가치 제안은 모든 상승 움직임을 포착하는 것이 아니라 최악의 하락 움직임을 피하는 것입니다. 전략이 평평한 (황금이 부족) 기간을 조사하고 그 기간이 의미있는 마감에 해당하는지 여부를 살펴 보겠습니다.
# Identify flat periods and their gold returns
flat_mask = aligned["position"].shift(1) == 0
flat_gold_ret = aligned.loc[flat_mask, "gold_ret"]
long_gold_ret = aligned.loc[~flat_mask, "gold_ret"]
print(f"Days long gold: {(~flat_mask).sum()}")
print(f"Days flat (cash): {flat_mask.sum()}")
print(f"Avg daily ret (long): {long_gold_ret.mean()*100:.3f}%")
print(f"Avg daily ret (flat): {flat_gold_ret.mean()*100:.3f}%")
print(f"Avoided loss days: {(flat_gold_ret < 0).sum()} "
f"(total loss: {flat_gold_ret[flat_gold_ret < 0].sum()*100:.1f}%)")
적립 비교
바이 앤 홀드는 2022년 금리 상승 주기에 -18.6%의 인하를 겪었습니다. 실제 금리가 급격히 상승했을 때 현금으로 전환함으로써 점수 카드 전략은 이것을 -11.4%로 줄였습니다.
The 2022 drawdown is the clearest example. As the Fed raised rates aggressively from March to October 2022, the TIPS 10Y yield surged from near zero to +1.6%, the trade-weighted dollar appreciated sharply, and M2 growth turned negative. The scorecard correctly read all three signals as bearish and moved to cash, avoiding the bulk of gold's ~20% decline from peak to trough.
단계 7: 신호 정렬 분해
모든 스코어카드 레벨이 같지는 않습니다. 순점 레벨에 따라 평균 전속 금 수익률을 분해하면 신호가 유리한 것과 불리한 체제를 어떻게 구분하는지 보여줍니다.
# Forward 20-day gold return by score level
aligned["fwd_20d"] = aligned["gold"].pct_change(20).shift(-20)
regime_stats = (
aligned.groupby("net_score")["fwd_20d"]
.agg(["mean", "std", "count"])
.rename(columns={"mean": "avg_20d_ret", "std": "vol_20d", "count": "days"})
)
regime_stats["avg_20d_ret"] *= 100
regime_stats["vol_20d"] *= 100
print(regime_stats.round(2))
평균 20일 전속 금 수익률
높은 순점 점수는 상당히 높은 평균 선물 수익률에 해당합니다. +4 이상의 점수는 다음 20 거래일에 걸쳐 금의 가장 강한 상승을 보여줍니다.
단계 8: 견고성 검사
하나의 백테스트 구성은 과다 적합할 수 있습니다. 여기서 우리는 주요 매개 변수를 변화시켜 결과가 취약하지 않은지 확인합니다.
경계 감수성
results = []
for thresh in range(-2, 6):
pos = (aligned["net_score"] >= thresh).astype(float)
ret = pos.shift(1) * aligned["gold_ret"]
trades = pos.diff().abs().fillna(0)
ret -= trades.shift(1) * (COST_BPS / 10_000)
cum = (1 + ret.fillna(0)).prod()
vol = ret.std() * np.sqrt(252)
ann = cum ** (252 / len(ret)) - 1
sharpe = ann / vol if vol > 0 else 0
results.append({"threshold": thresh, "total_ret": f"{(cum-1)*100:.1f}%",
"sharpe": round(sharpe, 2), "pct_invested": f"{pos.mean()*100:.0f}%"})
pd.DataFrame(results).set_index("threshold")
경계 감수성
| 기준 | 전체 수익 | 투자 % | |
|---|---|---|---|
| -2 | +95.1% | 0.80 | 98% |
| -1 | +93.8% | 0.82 | 95% |
| 0 | +91.6% | 0.88 | 85% |
| + 1 | +90.2% | 0.95 | 75% |
| +2 | +89.3% | 1.06 | 62% |
| +3 | +72.5% | 1.10 | 48% |
| +4 | +55.4% | 1.08 | 32% |
| +5 | +30.1% | 0.95 | 15% |
강조된 행은 기본 백테스트 임계 (+2) 이다. 샤프 비율은 +3까지 엄격한 임계로 향상되며 신호가 진정한 차별력을 가지고 있음을 확인합니다. 전략이 더 많은 릴리 날에서 떨어져 있기 때문에 전체 수익률은 더 높은 임계에서 감소합니다.
역시대 감수성
for lb in [15, 30, 60, 90]:
# Recompute scores with different lookback
sig_sum = pd.Series(0.0, index=aligned.index)
for name, mode in scoring_rules.items():
prev = aligned[name].shift(lb)
chg = aligned[name] - prev
if mode == "falling":
sig = pd.Series([1 if c < -0.05 else (-1 if c > 0.05 else 0) for c in chg], index=aligned.index)
elif mode == "rising":
sig = pd.Series([1 if c > 0.05 else (-1 if c < -0.05 else 0) for c in chg], index=aligned.index)
elif mode == "negative":
sig = pd.Series([1 if v < 0 else (-1 if v > 1 else 0) for v in aligned[name]], index=aligned.index)
else:
sig = pd.Series(0, index=aligned.index)
sig_sum += sig
pos = (sig_sum >= THRESHOLD).astype(float)
ret = pos.shift(1) * aligned["gold_ret"]
cum = (1 + ret.fillna(0)).prod()
vol = ret.std() * np.sqrt(252)
ann = cum ** (252/len(ret)) - 1
print(f" Lookback {lb:3d}d: return {(cum-1)*100:+.1f}% Sharpe {ann/vol:.2f}")
과거 를 돌아보면 안정성
전략의 우위는 15일에서 90일 룩백에 걸쳐 유지됩니다. 짧은 룩벡 (15d) 은 더 반응하지만 더 시끄럽고 더 많은 거래를 생성합니다. 30일 럭백은 반응성과 신호 안정성 사이의 최고의 타협을 제공합니다.
단계 9: 백테스트 스크립트 완료
여기 FXMacroData에서 모든 데이터를 가져와 스코어카드 전략을 실행하고 차트 준비 출력으로 성능 요약을 인쇄하는 완전한 자립 백테스트가 있습니다.
"""
Gold Macro Scorecard Backtest
Fetches daily gold prices and macro series from FXMacroData,
computes the composite scorecard, and evaluates a long/flat strategy.
"""
import requests
import pandas as pd
import numpy as np
from datetime import date
BASE = "https://fxmacrodata.com/api/v1"
KEY = "YOUR_API_KEY"
START = "2020-01-01"
THRESHOLD = 2
LOOKBACK = 30
COST_BPS = 5
def get(path: str) -> pd.DataFrame:
r = requests.get(f"{BASE}{path}", params={"api_key": KEY, "start_date": START})
r.raise_for_status()
df = pd.DataFrame(r.json().get("data", []))
df["date"] = pd.to_datetime(df["date"])
return df.set_index("date").sort_index()
# ── Fetch data ──
gold = get("/commodities/gold")[["val"]].rename(columns={"val": "gold"})
macro = {
"tips": (get("/announcements/usd/inflation_linked_bond"), "negative"),
"breakeven": (get("/announcements/usd/breakeven_inflation_rate"), "rising"),
"policy": (get("/announcements/usd/policy_rate"), "falling"),
"cb_assets": (get("/announcements/usd/cb_assets"), "rising"),
"m2": (get("/announcements/usd/m2"), "rising"),
"twi": (get("/announcements/usd/trade_weighted_index"), "falling"),
}
# ── Align and forward-fill ──
df = gold.copy()
for name, (series, _) in macro.items():
s = series[["val"]].rename(columns={"val": name})
df = df.join(s.reindex(df.index, method="ffill"))
df = df.dropna()
# ── Score ──
def score(col, mode):
prev = col.shift(LOOKBACK)
chg = col - prev
if mode == "negative":
return col.apply(lambda v: 1 if v < 0 else (-1 if v > 1 else 0)).astype(float)
if mode == "falling":
return chg.apply(lambda c: 1 if c < -0.05 else (-1 if c > 0.05 else 0)).astype(float)
if mode == "rising":
return chg.apply(lambda c: 1 if c > 0.05 else (-1 if c < -0.05 else 0)).astype(float)
return pd.Series(0.0, index=col.index)
df["net_score"] = sum(score(df[n], m) for n, (_, m) in macro.items())
# ── Trade ──
df["ret"] = df["gold"].pct_change()
df["pos"] = (df["net_score"] >= THRESHOLD).astype(float)
df["trade"] = df["pos"].diff().abs().fillna(0)
df["strat_ret"] = df["pos"].shift(1) * df["ret"] - df["trade"].shift(1) * (COST_BPS/1e4)
df["gold_cum"] = (1 + df["ret"].fillna(0)).cumprod()
df["strat_cum"] = (1 + df["strat_ret"].fillna(0)).cumprod()
# ── Report ──
for label, cum_col, ret_col in [("Strategy", "strat_cum", "strat_ret"),
("Buy&Hold", "gold_cum", "ret")]:
total = df[cum_col].iloc[-1] - 1
vol = df[ret_col].std() * np.sqrt(252)
ann = (1 + total) ** (252/len(df)) - 1
sharpe = ann / vol if vol > 0 else 0
peak = df[cum_col].cummax()
mdd = ((df[cum_col] - peak) / peak).min()
print(f"{label:12s} Return: {total*100:+.1f}% Sharpe: {sharpe:.2f} MaxDD: {mdd*100:.1f}%")
print(f"\nDays invested: {df['pos'].mean()*100:.0f}% | Round-trips: {int(df['trade'].sum()/2)}")
주요 연구 결과 와 실용적 교훈
샤프: 1.06
거시 점수 카드 전략은 최악의 마이너 다운 기간을 피함으로써 구매 및 보유의 0.79 보다 1.0 이상의 샤프 비율을 의미있게 향상시킵니다.
최대 DD: -11.4%
마감률은 구매 및 보유 (-18.6%) 에 비해 거의 절반으로 감소했습니다. 2022년 금리 상승 주기는 스코어카드가 올바르게 식별하고 피하는 핵심 체제였습니다.
62% 시간 투자
이 전략은 거래일 중 62%만 투자하고, 하락경기의 거시적 체제에서 자본을 확보합니다.
28 왕복 여행
Low turnover: roughly 4–5 regime shifts per year. This is implementable even with physical gold ETFs — no high-frequency execution needed.
한계 와 경고
- 설명적인 뒷테스트입니다. 이 문서에서 보여지는 샘플 결과는 방법론을 설명하기 위해 대표적인 데이터를 사용합니다. 원하는 날짜 범위에서 검증 된 결과를 생성하기 위해 자신의 키로 라이브 API에 대한 전체 스크립트를 실행해야합니다.
- 지표 선택에 있어서 생존 편견 우리는 금의 강력한 이론적 전적이 있기 때문에 이 여섯 가지 지표를 선택했지만 선택 자체는 암시 곡선 적합성의 한 형태입니다. 진정으로 샘플에서 벗어난 테스트는 금 데이터를 보기 전에 지표를 선택하는 것이 필요합니다.
- 위치 크기가 없습니다. 이진 장기/평평 접근법은 의도적으로 간단합니다. 더 정교한 포지션 사이징 (예를 들어, 순점 규모에 의한 노출 규모) 은 위험 조정 수익을 향상시킬 수 있지만 과도하게 적합 할 수있는 자유로운 매개 변수를 추가합니다.
- 현금 수익은 무시합니다. During flat periods, the strategy earns zero. In practice, short-term rates have been 0–5.5% over this period — including risk-free yield on idle cash would further improve the strategy's risk-adjusted edge.
- 선물에 대한 거래 비용 모델링이 없습니다. 이 경우 ETF가 아닌 금 선물로 실행되면, 롤 비용과 마진 요구 사항이 적용됩니다. 5bps의 왕복 비용 가정은 금 ETF 스프레드를 대표하지만 선물 실행 비용을 과소평가 할 수 있습니다.
- 매크로 데이터 출판 지연 백테스트는 실제 출판 날짜를 사용합니다. 전향 편향이 없습니다. 그러나 라이브 거래에서 데이터 발표와 시스템 처리 사이에 몇 시간이있을 수 있습니다. 매일 재균형 사각지대는이 전략에 중요하지 않습니다.
확장
All data used in this backtest — daily gold prices and the six US macro indicators — is available from the FXMacroData API. The 금 상품 최종점 매일 LBMA PM 고정 가격을 제공 합니다. 2020년으로 거슬러 올라갑니다. 미국 거시적 종말점 이 프로그램은 전체의 금리, 인플레이션 및 통화 지표를 다루고 있습니다. fxmacrodata.com/subscribe- 그래요