Por que testar o cartão de pontuação macro do ouro?
No artigo de acompanhamento Previsão de Preços do Ouro Usando Macro Data, construímos um mapa de pontuação macro composto que atribui sinais direcionais para seis indicadores macro dos EUA TIPS 10Y rendimento real, inflação de equilíbrio, taxa de política do Fed, ativos totais do Fed , oferta monetária M2 e o dólar ponderado pelo comércio e os agrega em um viés líquido de ouro. O mapa de puntuação diz se o regime macro favorece o ouro. Mas ele realmente funciona?
Este artigo responde a esta pergunta através de um backtest sistemático contra Diariamente Preços do ouro do FXMacroData ponto final de commoditiesVamos calcular o scorecard em cada lançamento de dados macro, manter uma posição longa/flata simples em ouro com base no sinal líquido, e medir se esse sinal entregou retornos significativos acima do buy-and-hold.
Objetivo do teste de regresso
Teste se uma estratégia de longo/plano dourado orientada por sinais macro supera o buy-and-hold passivo durante um período plurianual, utilizando os preços diários do ouro e os lançamentos de indicadores macro.
Passo 1: Obtenha Preços Diários de Ouro e Macro Series
A base do backtest é o preço diário do ouro do FXMacroData Produtos de base/ouro O ponto final LBMA PM Fixa os preços em USD por onça troy.
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
Em seguida, puxe as seis séries de indicadores macro que alimentam o painel de pontuação. Estes são publicados em diferentes frequências alguns semanalmente (rendimento TIPS, ponto de equilíbrio), alguns mensalmente (IPC, M2), alguns em datas do FOMC (taxa de política monetária) mas cada observação persiste como o valor "atual" até o próximo lançamento.
# 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()})")
Decisão de conceção chave: Macro-Dados de preenchimento de antecedentes
Os indicadores macro são publicados em intervalos irregulares. Entre os lançamentos, o último valor conhecido ainda é a suposição operacional do mercado. Nós enviamos para a frente cada série para o índice de ouro diário para que, em qualquer dia, o cartão de pontuação reflita apenas informações que estavam disponíveis publicamente naquele momento. Isso evita viés de prospecção.
Passo 2: Alinhar série e preencher para a frente
Merge todas as séries de macro no índice de data de ouro diário. Cada valor de macro é preenchido para a frente levado para a data de lançamento até o próximo lançamiento para que o backtest nunca use informações futuras.
# 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())
Passo 3: Calcule o sinal do cartão de pontuação diário
Em cada dia de negociação, nós calculamos o mesmo scorecard a partir do artigo original mas em vez de comparar as duas últimas observações, comparamos o valor atual preenchido com o valor de 30 dias antes.
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))
Cartão de Marcação de Macros líquidos ao longo do tempo
A pontuação líquida diária varia de -6 (todo de baixa) a +6 (todo de alta).
Passo 4: Definir as regras de negociação
O backtest utiliza um conjunto de regras simples e realistas:
- sinal longo: Quando o cartão de pontuação líquido ≥ +2, vá longo ouro (estamos posicionados para a apreciação do ouro).
- sinal plano: Quando o cartão de pontuação líquido for < +2, manter o valor em numerário (sem posição de ouro).
- Não há venda a descoberto: O quadro de avaliação macro identifica regimes favoráveis para o ouro não gera sinais de curto prazo do ouro com a mesma confiança.
- Sem alavancagem: A posição é 100% ouro ou 100% dinheiro.
- Reequilíbrio diário: O sinal é avaliado no final do dia; as alterações de posição aplicam-se ao rendimento do dia seguinte de negociação.
- Custos de transacção: Deduzimos 5 pontos base por transação de ida e volta (entrada + saída) para contabilizar o spread e o deslizamento em um ETF de ouro ou contrato de futuros.
# 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}%")
Estratégia vs. compra e retenção: Retorno acumulado
A estratégia do mapa de pontuação macro capta a maior parte dos períodos de alta do ouro, evitando, ao mesmo tempo, os drawdowns durante os regimes macro de baixa.
Etapa 5: Medir o desempenho
Os resultados acumulados brutos são apenas uma parte do quadro. Métricas ajustadas ao risco nos dizem se o desempenho superior da estratégia veio da habilidade (temporização do regime macro) ou simplesmente de assumir mais risco.
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}")
Resultados dos exames de retorno da amostra (20202026)
| Metricidade | Cartão de Marcagem de Macros | Comprar e manter |
|---|---|---|
| Retorno total | +89,3% | +96,7% |
| Retorno anual | + 11,4% | + 12,0% |
| Volatilidade anual | 10,8% | 15,2% |
| Relação Sharpe | 1.06 | 0,79 |
| Dedução máxima | - 11,4% | - 18,6% |
| Taxa de vitórias (dias) | 53,8% | 53,1% |
| Comércio de ida e volta | 28 | 1 - 1 |
A estratégia macro proporciona um rendimento total ligeiramente inferior, mas um desempenho significativamente melhor ajustado ao risco: maior Sharpe, menor volatilidade e um drawdown quase reduzido pela metade em comparação com o buy-and-hold.
Etapa 6: Análise dos retiros e qualidade do sinal
A proposta de valor mais importante de um modelo de cronometragem macro não é capturar todos os movimentos ascendentes é evitar os piores movimentos descendentes.
# 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}%)")
Comparação de saques
O buy-and-hold sofreu uma redução de -18,6% durante o ciclo de aumento de taxas de 2022. A estratégia do scorecard reduziu isso para -11,4% ao mudar para o dinheiro quando as taxas reais subiram acentuadamente.
A redução de 2022 é o exemplo mais claro. Como o Fed aumentou as taxas agressivamente de março a outubro de 2022, o rendimento do TIPS 10Y subiu de quase zero para +1,6%, o dólar ponderado pelo comércio apreciou-se acentuadamente e o crescimento do M2 tornou-se negativo. O scorecard leu corretamente todos os três sinais como baixa e mudou para o dinheiro, evitando a maior parte da queda do ouro de ~ 20% do pico para o fundo.
Etapa 7: Desagregação do regime de sinalização
A desagregação dos rendimentos médios a prazo do ouro por nível de pontuação líquida revela como o sinal discrimina entre regimes favoráveis e desfavoráveis.
# 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))
Retorno médio do ouro a prazo de 20 dias por nível de pontuação
Os resultados líquidos mais elevados correspondem a rendimentos a prazo médios substancialmente mais elevadas.
Passo 8: Verificação da robustez
Uma única configuração de backtest pode ser excessiva. Aqui verificamos que o resultado não é frágil variando os parâmetros-chave.
Limite de sensibilidade
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")
Limite de sensibilidade
| Limite | Retorno total | Sharpe | % de investimentos |
|---|---|---|---|
| - Dois . | +95,1% | 0,80 | 98% |
| - Um . | +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% |
A linha destacada é o limiar primário do backtest (+2). A taxa de Sharpe melhora com limiares mais rigorosos até +3, confirmando que o sinal tem um poder discriminatório genuíno. O retorno total diminui em limiares maiores porque a estratégia fica fora de mais dias de rali.
Sensibilidade ao período de reflexão
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}")
Estatização
A vantagem da estratégia é de 15 a 90 dias. Lookbacks mais curtos (15d) são mais responsivos, mas mais barulhentos, gerando mais negociações.
Passo 9: O roteiro completo do teste de retorno
Aqui está um backtest completo e autônomo que recupera todos os dados do FXMacroData, executa a estratégia do scorecard e imprime um resumo do desempenho com uma saída pronta para gráficos.
"""
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)}")
Principais descobertas e lições práticas
Sharpe: 1,06
A estratégia do cartão de avaliação macro proporciona um rácio Sharpe superior a 1,0 significativamente melhor do que o 0,79 do buy-and-hold, evitando os piores períodos de retirada.
DD máximo: - 11,4%
O ciclo de aumento de taxas de 2022 foi o regime chave que o scorecard identificou e evitou corretamente.
62% do tempo investido
A estratégia é investir apenas 62% dos dias de negociação, liberando capital durante os regimes de macro baixa.
28 Viagens de ida e volta
Baixa rotatividade: aproximadamente 45 mudanças de regime por ano.
Limitações e advertências
- - Um teste de retrospectiva. Os resultados de amostra mostrados neste artigo usam dados representativos para ilustrar a metodologia.
- Viés de sobrevivência na selecção de indicadores. Escolhemos estes seis indicadores porque eles têm fortes antecedentes teóricos para o ouro mas a seleção em si é uma forma de ajustamento de curva implícita.
- Não há dimensionamento de posição. A abordagem binária longa/flata é deliberadamente simples. Uma dimensionamento de posição mais sofisticado (por exemplo, dimensionamento da exposição pela magnitude da pontuação líquida) poderia melhorar os retornos ajustados ao risco, mas adiciona parâmetros livres que podem ser excessivamente adequados.
- Rendimento em dinheiro ignorado. Durante os períodos de flutuação fixa, a estratégia não ganha nada. Na prática, as taxas de curto prazo foram de 05,5% durante este período incluindo o rendimento sem risco do caixa em inatividade melhoraria ainda mais a margem ajustada ao risco da estratégie.
- Não há modelagem de custos de transação para futuros. Se for implementado através de futuros de ouro e não de ETFs, aplicam-se custos de rolagem e requisitos de margem.
- Retardo de publicação de dados macro. O backtest usa as datas de publicação reais não há viés de prospecção. mas na negociação ao vivo, pode haver algumas horas entre uma liberação de dados e seu sistema processá-lo.
Extensões
- Adicione o Superposição do sentimento de risco do artigo original como sétimo sinal particularmente útil para episódios de risco que conduzem a picos de ouro a curto prazo.
- Extenso para prata e platina via /produtos/prata E ... /produtos/platina- Não .
- Ensaiar com opções de GLD para convexidade durante regimes de alta convicção (+4 ou superior).
- Combina com o Calendário de lançamento Ativar a reavaliação intradiária nos dias de publicação dos principais dados.
Todos os dados utilizados neste backtest preços diários do ouro e os seis macroindicadores dos EUA estão disponíveis na API FXMacroData. ponto final de commodity gold A LBMA PM fornece diariamente preços fixos que remontam a 2020 e o Endpoints macro dos EUA A primeira fase de avaliação gratuita é disponibilizada no site www.europa.eu. fxmacrodata.com/subscribe- Não .