为什么要对黄金宏观成绩单进行反测试?
在同行文章中 采用宏观数据预测黄金价格我们建立了一个综合宏观评分卡,将指向信号分配给六个美国宏观指标 TIPS 10Y实际收益率,平衡通胀率,美联储政策利率,Fed总资产,M2货币供应量和贸易加权美元,并将它们汇总成净黄金偏差.
这篇文章通过对 每日 黄金价格从FXMacroData 商品终点我们将在每次宏观数据发布时计算分数表, 基于净信号, 持有金币的简单长/平仓,
后验目标
测试基于宏观信号的长期/平坦黄金策略是否在多年内使用每日黄金价格和宏观指标发布来优于被动买入持有.
步骤1:获取每日黄金价格和宏观系列
后台测试的基础是FXMacroData的每日黄金价格. 商品/黄金 终点 LBMA PM 固定价格以美元为特洛伊司.与月度或每周的汇总数据不同,每日价格使我们能够准确衡量每个宏观信号转换的影响.
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 (长信号活跃) 的时期.
第四步:确定交易规则
后台测试使用了一套简单,现实的规则:
- 长信号: 如果净分数卡 ≥ +2,就去黄金交易 (我们有机会看到黄金升值).
- 没有信号: 如果净分数卡 < +2,持有现金 (没有黄金位置).
- 没有空头销售: 宏观指数表确定了黄金的有利模式,但没有同样的信心来产生黄金空期信号.
- 没有杆: 位置是100%的黄金或100%的现金.
- 每天重新平衡: 信号在日结束时进行评估; 位置变化适用于下一天的交易回报.
- 交易成本: 我们每次往返交易 (入口+出口) 扣除5个基点,以计算黄金ETF或期货合约的差距和滑动.
# 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}%")
战略与买入持有:累积回报
宏观评分卡策略捕捉了黄金大部分反弹时期,同时避免在下跌宏观模式期间的下跌.
第五步:测量性能
粗积累性回报只是图片的一部分. 风险调整后的指标告诉我们,战略的表现是否来自技能 (计时宏观制度) 或者只是因为承担更多的风险.
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年)
| 标准 | 宏观得分卡 | 买入和持有 |
|---|---|---|
| 总回报 | 其他 | 其他地区 |
| 年报 | 增加了11.4% | 其他 |
| 年度波动性 | 其他 | 其他 |
| 沙普比例 | 欧洲 | 没有 |
| 最大的吸收量 | -11.4% | 其他 |
| 获胜率 (几天) | 其他 | 其他 |
| 往返贸易 | 其他 | 其他 |
宏观战略的总回报率略低,但风险调整后的表现显著好:比买入持有率要高的夏普,波动性较低,收益率几乎减半.
第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%.
2022年拉回率是最明确的例子.随着美联储从3月至10月份激烈提高利率,2022年,TIPS 10Y收益率从接近零升至1.6%,贸易加权美元大幅升值,M2增长变为负值.分数表正确读出所有三个信号都是下行,并转向现金,避免黄金从峰值到低谷下跌20%左右.
第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个交易日内黄金升值最强.
第八步:稳定性检查
一个单个后验配置可能过量.在这里,我们通过变化关键参数来检查结果是否脆弱.
极限敏感度
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")
极限敏感度
| 关键值 | 总回报 | 的 | 投资的百分比 |
|---|---|---|---|
| 没有 | 其他 | 没有 | 百分之九十八 |
| - 一个 | 其他地区 | 没有 | 95% 的 |
| 其他 | 其他 | 没有 | 85% 的 |
| 没有 | 其他 | 没有 | 75% 的 |
| 其他 | 其他 | 欧洲 | 其他 |
| 其他 | 其他 | 欧洲 | 其他 |
| 其他 | 其他 | 欧洲 | 其他 |
| 其他 | 其他 | 没有 | 其他 |
突出列是主要的后测门 (+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
宏观评分卡策略通过避免最糟糕的吸收期,提供了1.0以上的夏普比率,比买入持有0.79好得多.
最大DD: -11.4%
收益率几乎减半,而买入持有率 (-18.6%). 2022年加息周期是得分卡正确识别和避免的关键模式.
62% 时间投入
只有62%的交易日投资,在下跌宏观体制中释放资本.
28 往返的旅行
低周转:每年大约45个模式转换.即使是物理黄金ETF也可以实现.
限制和警告
- 解释性后台测试. 本文中显示的样本结果使用代表性数据来说明方法.您应该使用自己的密钥对活跃API运行完整的脚本,以产生您喜欢的日期范围内的验证结果.
- 选择指标时存在生存偏差. 我们选择了这六个指标,因为它们对黄金有很强的理论先验,但选择本身是一种隐含曲线适应的形式.真正的样本外测试需要在查看黄金数据之前选择指标.
- 没有位置尺寸. 双边长/平的方法是故意简单的.更复杂的仓位大小 (例如,按净分数大小进行扩展) 可以提高风险调整的回报率,但增加可能过度适用的自由参数.
- 现金收益被忽略. 在平稳期内,该策略的收益为零.在实践中,短期利率在此期间为0.5.5% 包括空置现金的无风险收益率将进一步提高该策划的风险调整边缘.
- 没有期货的交易成本建模. 如果通过黄金期货而不是ETF实施,则适用滚动成本和保证金要求. 假设5个基础点的往返成本代表黄金ETF差距,但可能低估期货执行成本.
- 宏观数据发布延迟 后台测试使用实际发布日期,没有前性.但在实时交易中,数据发布和您的系统处理之间可能会有几个小时.每日重新平衡的节奏使得这对这个策略无关紧要.
扩展
在本后台测试中使用的所有数据 日均黄金价格和六个美国宏观指标均可从FXMacroData API中获取. 黄金商品终点 提供每天的LBMA PM固定价格, 美国宏观终点 开始免费试用在 没有任何信息可以提供.现在我们要做什么?