なぜゴールドマクロスコアカードをバックテストする?
関連記事 マクロデータを使用して金価格を予測する, 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?
この記事では,この質問に回答するために, システム的なバックテストを行います. 日常 通貨の価格について 商品の最終点ネットワーク信号に基づいて 金貨のシンプルなロング/フラットポジションを保持し, その信号が 買取・保有値以上の 有意義なリターンを 提供しているか測定します.
バックテストの目標
マクロシグナルに基づく長期・フラットゴールド戦略が,日々の金価格とマクロ指標のリリースを使用して,多年期間の間,受動的な買いと保持を上回るか検証する.
ステップ1: 日々の金価格とマクロシリーズを取得
バックテストの基礎はFXMacroDataからの日々の金価格です. 商品/金 endpoint — LBMA PM Fix prices in USD per troy ounce. Unlike monthly or weekly aggregated data, daily prices let us measure the precise impact of each macro signal transition.
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
Next, pull the six macro indicator series that feed the scorecard. These are published at different frequencies — some weekly (TIPS yield, breakeven), some monthly (CPI, M2), some on FOMC dates (policy rate) — but each observation persists as the "current" value until the next release.
# 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: 日々のスコアカード信号を計算する
On each trading day, we compute the same scorecard from the original article — but instead of comparing the latest two observations, we compare the current forward-filled value against the value from 30 calendar days prior. This gives a more robust measure of direction than day-over-day noise.
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%現金か
- 日々の再バランス: 信号は日末に評価され,ポジション変更は次の取引日の収益に適用されます.
- 取引費用 円回取引 (入場+出場) につき 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}%")
戦略 vs 買取・保有:累積的収益
マクロスコアカード戦略は,黄金のレッリ期の大半を把握し,下落マクロレジムの間に引き下げを回避します.
ステップ 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 に比べて,独自のキーで実行する必要があります.
- 指標の選択における生存率偏差 We chose these six indicators because they have strong theoretical priors for gold — but the selection itself is a form of implicit curve-fitting. A truly out-of-sample test would require selecting indicators before seeing the gold data.
- 位置のサイズも バイナリー・ロング/フラットアプローチは意図的にシンプルである.より洗練されたポジションサイズ (例えば,純スコア大きさによるエクスポージャーのスケーリング) はリスク調整回帰を向上させることができるが,過剰に適合する自由パラメータを追加する.
- 現金回収は無視 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.
- 取引コストモデルを 未来期間のモデル化には使わない 取引先の代わりに金先の先物取引先で実施される場合,ロールコストとマーージン要件が適用されます.回帰コスト5bpsの仮定は,金先物交易所のスプレッドを代表しますが,先物売買取引所の実行コストを過小評価することがあります.
- マクロデータ公開遅延 バックテストは実際の公開日数を使用します.前向きな偏見はありません. しかし,ライブ取引では,データリリースとシステム処理の間には数時間かかることがあります. 日々の再バランスペースは,この戦略にとって無関係です.
拡張
- 追加する リスクセンチメントの重複 特別に短期的な金物急上昇を誘発するリスクオフエピソードに役立つ.
- 銀とプラチナに拡張します 商品/銀 ほら /商品/プラチナわかった
- 高圧縮 (+4以上) の状態でGLDオプションで凸度検査を行う.
- 組み合わせる リリースカレンダー 主要データ公開日に日内再評価を誘発する.
All data used in this backtest — daily gold prices and the six US macro indicators — is available from the FXMacroData API. The 金貨の最終点 固定価格を2020年まで提供し, 総額で評価される 利率,インフレ,通貨指標の全範囲をカバーします. fxmacrodata.com/subscribe 登録するわかった