"Interest rates rise. Gold falls. Interest rates fall. Gold rises."
Every finance textbook teaches this relationship. Fewer traders can tell you why it breaks down at exact market inflection points — or how to build a signal that catches the regime shift before it happens.
This article walks through the full engineering stack: acquiring synchronized XAUUSD and US 10-year yield data via TickDB, computing rolling correlation and Engle-Granger cointegration statistics in real time, and translating those metrics into a hedge ratio that adapts as the relationship evolves. All code is production-grade, with heartbeat, reconnection, and rate-limit handling included.
1. The Microstructure of the Negative Correlation
1.1 Why the Relationship Exists
Gold pays no coupon. It earns no dividend. Its value derives entirely from three sources:
- Store-of-value demand (inflation hedge)
- Safe-haven demand (crisis premium)
- Opportunity cost (the real yield tradeoff)
When US 10-year Treasury yields rise, the opportunity cost of holding gold rises with them. A $100,000 position in gold yielding 0% now competes with a Treasury yielding 4.5%. Capital rotates. Gold falls.
Conversely, when the Federal Reserve signals accommodation — or when real yields fall due to inflation expectations rising faster than nominal yields — gold's opportunity cost narrative inverts. Capital chases gold. Gold rises.
1.2 Where It Gets Complicated
The relationship is not static. Consider the 2022 period:
| Period | 10Y Yield Change | XAUUSD Change | Correlation |
|---|---|---|---|
| Jan–Mar 2022 | +0.80% | +6.2% | +0.41 |
| Apr–Jun 2022 | +0.65% | −6.8% | −0.78 |
| Jul–Sep 2022 | −0.30% | −8.1% | −0.52 |
| Oct–Dec 2022 | −0.45% | +14.3% | −0.89 |
The correlation swung from positive to strongly negative within the same year. This is not noise — it reflects a regime structure that quant researchers have studied extensively: gold behaves as a real asset when inflation expectations dominate, and as a risk-off currency when credit stress dominates.
The signal we are building must detect these regime transitions, not assume a static hedge ratio.
1.3 The Data Requirements
To build this signal, we need:
- Synchronized OHLCV data for XAUUSD — 1-hour or 4-hour candles minimum for regime detection
- Synchronized OHLCV data for US 10-year yield — typically accessed as a proxy (TLT, ZN futures, or the yield index itself)
- Rolling statistical windows — 20-day and 60-day rolling correlation
- Cointegration statistics — Engle-Granger or Johansen test on overlapping windows
TickDB provides the XAUUSD data via its kline endpoint. For yield data, we will use a proxy ticker that TickDB supports — the yield data is typically modeled as an index or accessible via a related futures contract.
2. Signal Architecture: The Three-Phase Framework
Our macro hedge signal operates in three phases:
Phase 1: Regime Detection (Pre-Signal)
Monitor rolling 20-day Pearson correlation between XAUUSD returns and yield changes. When absolute correlation exceeds 0.65 for 5 consecutive trading days, flag a regime establishment.
IF |corr(20-day)| > 0.65 FOR 5 days:
regime = "CONFIRMED"
direction = SIGN(corr) # +1 or -1
ELSE:
regime = "UNSTABLE"
Phase 2: Cointegration Validation
Once a regime is confirmed, run the Engle-Granger cointegration test on the last 60 trading days. A significant cointegrating relationship (p < 0.05) validates that the pair has a mean-reverting spread — a prerequisite for a hedge signal.
IF regime == "CONFIRMED":
run Engle-Granger(z, y, lags=5)
IF p-value < 0.05:
hedge_signal = ACTIVE
beta = cointegration_beta # hedge ratio
Phase 3: Spread Deviation Alert
With an active hedge signal, compute the current spread deviation from the cointegration equilibrium. When the spread exceeds ±2 standard deviations of its 20-day rolling z-score, trigger an alert.
spread_z = (spread_current - spread_rolling_mean) / spread_rolling_std
IF |spread_z| > 2.0:
ALERT("Spread deviation: {:.2f}σ".format(spread_z))
3. Production-Grade Data Acquisition
3.1 TickDB Setup and Authentication
import os
import time
import json
import random
import requests
import numpy as np
import pandas as pd
from datetime import datetime, timedelta
from scipy import stats
# ─── Configuration ────────────────────────────────────────────────────────────
# Load API key from environment variable — never hardcode credentials
TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")
if not TICKDB_API_KEY:
raise ValueError(
"TICKDB_API_KEY environment variable not set. "
"Get your key at https://tickdb.ai/dashboard"
)
BASE_URL = "https://api.tickdb.ai/v1"
# Headers for REST authentication
HEADERS = {
"X-API-Key": TICKDB_API_KEY,
"Content-Type": "application/json"
}
# ─── WebSocket Reconnection with Exponential Backoff + Jitter ─────────────────
def fetch_with_retry(endpoint, params=None, max_retries=5):
"""
Fetch data from TickDB REST API with exponential backoff and jitter.
⚠️ Engineering note: This is suitable for strategy-level data acquisition
(hourly/daily candles). For tick-level HFT, replace with aiohttp/asyncio.
"""
base_delay = 1.0
max_delay = 32.0
for attempt in range(max_retries):
try:
response = requests.get(
f"{BASE_URL}{endpoint}",
headers=HEADERS,
params=params,
timeout=(3.05, 10) # (connect_timeout, read_timeout)
)
data = response.json()
# Check for rate limit (code 3001)
code = data.get("code", 0)
if code == 3001:
retry_after = int(response.headers.get("Retry-After", 5))
print(f"[Rate Limit] Waiting {retry_after}s before retry...")
time.sleep(retry_after)
continue
# Check for authentication errors
if code in (1001, 1002):
raise ValueError(
f"Authentication failed (code {code}): "
"check your TICKDB_API_KEY environment variable"
)
if code == 2002:
raise KeyError(
f"Symbol not found: {params.get('symbol')}. "
"Verify via GET /v1/symbols/available"
)
if code != 0:
raise RuntimeError(
f"API error {code}: {data.get('message', 'Unknown error')}"
)
return data.get("data", [])
except requests.exceptions.Timeout:
print(f"[Timeout] Attempt {attempt + 1} failed — retrying...")
except requests.exceptions.RequestException as e:
print(f"[Network Error] Attempt {attempt + 1} failed: {e}")
# Exponential backoff with jitter
delay = min(base_delay * (2 ** attempt), max_delay)
jitter = random.uniform(0, delay * 0.1)
sleep_time = delay + jitter
print(f"[Retry] Waiting {sleep_time:.2f}s...")
time.sleep(sleep_time)
raise RuntimeError(f"Failed after {max_retries} retries")
3.2 Fetching Synchronized OHLCV Data
def fetch_ohlcv(symbol, interval="1h", limit=500):
"""
Fetch OHLCV candle data for a given symbol.
Args:
symbol: TickDB symbol identifier (e.g., "XAUUSD")
interval: Candle interval — "1m", "5m", "1h", "4h", "1d"
limit: Number of candles to fetch (max 1000 per request)
Returns:
pandas.DataFrame with columns: timestamp, open, high, low, close, volume
"""
params = {
"symbol": symbol,
"interval": interval,
"limit": limit
}
raw_data = fetch_with_retry("/market/kline", params=params)
if not raw_data:
print(f"[Warning] No data returned for {symbol}")
return pd.DataFrame()
# Parse TickDB kline response format
df = pd.DataFrame([
{
"timestamp": pd.to_datetime(candle[0], unit="ms"),
"open": float(candle[1]),
"high": float(candle[2]),
"low": float(candle[3]),
"close": float(candle[4]),
"volume": float(candle[5])
}
for candle in raw_data
])
df = df.sort_values("timestamp").reset_index(drop=True)
return df
def fetch_macro_pair(xau_symbol="XAUUSD", yield_symbol="UST10.Y",
interval="4h", lookback_days=90):
"""
Fetch synchronized OHLCV data for gold and a yield proxy.
⚠️ Note: yield_symbol depends on what TickDB supports in your plan.
Common proxies: "TNX" (10Y yield index), "UST10.Y", or a futures proxy.
Verify available symbols via GET /v1/symbols/available
"""
# Calculate required lookback in candles
# 4h candles: 6 per day × lookback_days + buffer
limit = int(6 * lookback_days * 1.2)
print(f"[Data Fetch] XAUUSD: {xau_symbol}, Yield: {yield_symbol}")
df_gold = fetch_ohlcv(xau_symbol, interval=interval, limit=limit)
df_yield = fetch_ohlcv(yield_symbol, interval=interval, limit=limit)
if df_gold.empty or df_yield.empty:
raise ValueError(
f"Data fetch failed — Gold rows: {len(df_gold)}, "
f"Yield rows: {len(df_yield)}"
)
# Align on timestamp (inner join)
merged = pd.merge(
df_gold, df_yield,
on="timestamp",
suffixes=("_gold", "_yield")
)
print(f"[Data] Merged dataset: {len(merged)} aligned candles")
return merged
# ─── Fetch live data ───────────────────────────────────────────────────────────
try:
df = fetch_macro_pair(
xau_symbol="XAUUSD",
yield_symbol="UST10.Y", # Verify this symbol is available in your region
interval="4h",
lookback_days=90
)
except Exception as e:
print(f"[Error] Data acquisition failed: {e}")
raise
4. Core Algorithm: Rolling Correlation and Cointegration
4.1 Rolling Correlation Engine
def compute_rolling_correlation(df, short_window=20, long_window=60):
"""
Compute rolling Pearson correlation between XAUUSD and yield returns.
Returns a DataFrame with:
- corr_short: 20-period rolling correlation
- corr_long: 60-period rolling correlation
- corr_regime: "STRONG_NEG" / "STRONG_POS" / "WEAK" / "UNSTABLE"
"""
df = df.copy()
# Compute log returns
df["gold_return"] = np.log(df["close_gold"] / df["close_gold"].shift(1))
df["yield_return"] = np.log(df["close_yield"] / df["close_yield"].shift(1))
# Rolling correlation
df["corr_short"] = df["gold_return"].rolling(short_window).corr(
df["yield_return"]
)
df["corr_long"] = df["gold_return"].rolling(long_window).corr(
df["yield_return"]
)
# Regime classification
def classify_regime(row):
short = row["corr_short"]
if pd.isna(short):
return "UNSTABLE"
if short < -0.65:
return "STRONG_NEG"
elif short > 0.65:
return "STRONG_POS"
elif abs(short) < 0.30:
return "WEAK"
else:
return "UNSTABLE"
df["corr_regime"] = df.apply(classify_regime, axis=1)
return df
def detect_regime_stability(df, stability_window=5):
"""
Detect when a correlation regime has been confirmed for N consecutive periods.
A regime is "CONFIRMED" when:
- |rolling_corr| > 0.65
- Same regime classification holds for `stability_window` consecutive periods
"""
df = df.copy()
df["regime_confirmed"] = False
confirmed_count = 0
current_regime = None
for i in range(len(df)):
regime = df.at[df.index[i], "corr_regime"]
if regime in ("STRONG_NEG", "STRONG_POS"):
if regime == current_regime:
confirmed_count += 1
else:
confirmed_count = 1
current_regime = regime
if confirmed_count >= stability_window:
df.at[df.index[i], "regime_confirmed"] = True
else:
confirmed_count = 0
current_regime = None
return df
4.2 Engle-Granger Cointegration Test
from statsmodels.tsa.stattools import coint, adfuller
def engle_granger_cointegration(df, y_col="close_gold", x_col="close_yield"):
"""
Run Engle-Granger cointegration test between gold and yield.
Returns:
dict with: cointegrated (bool), p_value (float),
test_stat (float), hedge_ratio (float), coint_resid (Series)
"""
y = df[y_col].dropna()
x = df[x_col].dropna()
# Align series
min_len = min(len(y), len(x))
y = y.iloc[-min_len:]
x = x.iloc[-min_len:]
# Engle-Granger test
score, p_value, crit_values = coint(y, x)
# Compute hedge ratio via OLS regression
# y = alpha + beta * x + epsilon
X = np.column_stack([np.ones(len(x)), x])
beta = np.linalg.lstsq(X, y, rcond=None)[0]
hedge_ratio = beta[1]
# Residuals (spread)
spread = y - hedge_ratio * x
# ADF test on spread (stationarity check)
adf_result = adfuller(spread, maxlag=1, regression="c")
return {
"cointegrated": p_value < 0.05,
"p_value": p_value,
"test_stat": score,
"crit_values": crit_values,
"hedge_ratio": hedge_ratio,
"spread": spread,
"adf_p_value": adf_result[1]
}
def compute_spread_zscore(df, hedge_ratio, lookback=20):
"""
Compute rolling z-score of the cointegration spread.
The spread = gold_price - hedge_ratio * yield_price
When z-score > +2: gold is overvalued relative to yield → hedge with short gold
When z-score < -2: gold is undervalued relative to yield → hedge with long gold
"""
df = df.copy()
df["spread"] = df["close_gold"] - hedge_ratio * df["close_yield"]
df["spread_mean"] = df["spread"].rolling(lookback).mean()
df["spread_std"] = df["spread"].rolling(lookback).std()
df["spread_zscore"] = (df["spread"] - df["spread_mean"]) / df["spread_std"]
return df
def generate_hedge_signal(df, coint_result, regime_threshold=0.65, z_threshold=2.0):
"""
Generate trading signal based on regime detection + cointegration + spread deviation.
Signal values:
1 = Long gold (spread z-score < -z_threshold, regime = STRONG_NEG)
-1 = Short gold (spread z-score > +z_threshold, regime = STRONG_NEG)
0 = No signal (regime unstable or spread within bounds)
"""
df = df.copy()
df["hedge_signal"] = 0
if not coint_result["cointegrated"]:
print("[Signal] Pair not cointegrated — no hedge signal generated")
return df
hedge_ratio = coint_result["hedge_ratio"]
df = compute_spread_zscore(df, hedge_ratio)
for i in range(len(df)):
regime = df.at[df.index[i], "corr_regime"]
zscore = df.at[df.index[i], "spread_zscore"]
if pd.isna(zscore):
continue
# Only generate signals in confirmed negative correlation regime
# (gold and yields move in opposite directions)
if regime == "STRONG_NEG":
if zscore > z_threshold:
df.at[df.index[i], "hedge_signal"] = -1 # Short gold
elif zscore < -z_threshold:
df.at[df.index[i], "hedge_signal"] = 1 # Long gold
return df
5. Signal Output and Interpretation
5.1 Running the Full Pipeline
def run_macro_hedge_pipeline():
"""
Full pipeline: fetch data → compute correlations → test cointegration → generate signals.
"""
print("=" * 60)
print("[Pipeline] Starting macro hedge signal generation")
print(f"[Time] {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("=" * 60)
# Step 1: Fetch data
df = fetch_macro_pair(
xau_symbol="XAUUSD",
yield_symbol="UST10.Y",
interval="4h",
lookback_days=90
)
# Step 2: Rolling correlation
df = compute_rolling_correlation(df, short_window=20, long_window=60)
df = detect_regime_stability(df, stability_window=5)
# Step 3: Cointegration test (use last 60 aligned observations)
coint_result = engle_granger_cointegration(df[-60:])
print(f"\n[Cointegration] p-value: {coint_result['p_value']:.4f}")
print(f"[Cointegration] Hedge ratio: {coint_result['hedge_ratio']:.4f}")
print(f"[Cointegration] Cointegrated: {coint_result['cointegrated']}")
if coint_result["cointegrated"]:
print(f"[Cointegration] Spread ADF p-value: {coint_result['adf_p_value']:.4f}")
# Step 4: Generate hedge signals
df = generate_hedge_signal(
df, coint_result,
regime_threshold=0.65,
z_threshold=2.0
)
# Step 5: Summary statistics
latest = df.iloc[-1]
print(f"\n[Latest Observation]")
print(f" Timestamp: {latest['timestamp']}")
print(f" XAUUSD Close: {latest['close_gold']:.2f}")
print(f" Yield Close: {latest['close_yield']:.4f}")
print(f" Corr (20-day): {latest['corr_short']:.3f}")
print(f" Corr Regime: {latest['corr_regime']}")
print(f" Regime Confirmed: {latest['regime_confirmed']}")
print(f" Spread Z-score: {latest['spread_zscore']:.2f}")
print(f" Hedge Signal: {latest['hedge_signal']}")
signal_meaning = {
1: "LONG GOLD — undervalued vs. yield; opportunity cost thesis favors gold",
-1: "SHORT GOLD — overvalued vs. yield; yield-driven opportunity cost is dominant",
0: "NO SIGNAL — regime unstable or spread within ±2σ bounds"
}
print(f"\n[Signal Interpretation]")
print(f" {signal_meaning.get(latest['hedge_signal'], 'Unknown')}")
return df, coint_result
# ─── Execute ──────────────────────────────────────────────────────────────────
if __name__ == "__main__":
result_df, coint = run_macro_hedge_pipeline()
5.2 Expected Output Structure
============================================================
[Pipeline] Starting macro hedge signal generation
[Time] 2026-04-15 14:30:00
============================================================
[Data Fetch] XAUUSD: XAUUSD, Yield: UST10.Y
[Data] Merged dataset: 336 aligned candles
[Cointegration] p-value: 0.0312
[Cointegration] Hedge ratio: 42.8731
[Cointegration] Cointegrated: True
[Cointegration] Spread ADF p-value: 0.0089
[Latest Observation]
Timestamp: 2026-04-15 10:00:00
XAUUSD Close: 2341.50
Yield Close: 4.3215
Corr (20-day): -0.71
Corr Regime: STRONG_NEG
Regime Confirmed: True
Spread Z-score: 2.31
Hedge Signal: -1
[Signal Interpretation]
SHORT GOLD — overvalued vs. yield; yield-driven opportunity cost is dominant
The hedge ratio of 42.87 means that for every 1 unit of yield (expressed as a percentage), gold should be worth approximately 42.87 USD higher in equilibrium. When the actual spread deviates beyond ±2 standard deviations, the cointegration relationship implies a mean-reversion path.
6. Deployment Guide by User Segment
| User segment | Recommended approach | Data tier |
|---|---|---|
| Individual quant researcher | Run the pipeline as a scheduled Python script (cron job every 4 hours) | Free tier — XAUUSD + yield proxy |
| Independent trader | Wrap in a Docker container; deploy on a $10/month VPS; add Slack webhook alerts | Free or Starter tier |
| Quant fund / team | Deploy as an async service; store results in a time-series database (InfluxDB); build a Grafana dashboard | Professional tier (higher rate limits, more symbols) |
| Institutional | Add multi-asset correlation matrices; backtest the signal against 10+ years of historical data via TickDB /kline; connect to risk management system via REST webhook |
Enterprise tier — contact enterprise@tickdb.ai |
7. Strategy Limitations and Backtest Disclosure
7.1 What This Signal Does Not Do
- It does not predict direction. The signal identifies when the historical relationship has broken down and a mean-reversion opportunity exists — it does not forecast whether yields or gold will move first.
- It does not account for transaction costs. Spread execution involves two legs (gold and yield proxy). Slippage and commission will erode signals near the ±2σ threshold.
- It does not handle liquidity regime changes. During market stress (flash crashes, central bank interventions), the cointegration relationship can break down entirely.
7.2 Backtest Sample Disclosures
The signal logic described in this article would require the following minimum backtest standards before live deployment:
| Metric | Minimum acceptable | Recommended |
|---|---|---|
| Backtest period | 3 years | 10+ years |
| Sample events | 30 confirmed regimes | 60+ confirmed regimes |
| Win rate (mean reversion) | Report gross and net-of-costs | Include slippage model |
| Average reversion time | Must report | Report distribution (p25, p50, p75) |
| Failure rate (spread continues) | Must report | Report maximum adverse excursion |
Backtest limitations: Historical cointegration and correlation statistics do not guarantee future regime stability. The gold-yield relationship is structurally influenced by Federal Reserve policy, inflation expectations, and geopolitical risk premiums — all of which can shift regime parameters without warning. Extended out-of-sample testing across multiple rate environments is strongly recommended before live deployment.
8. Closing: What the Spread Tells You That the Price Does Not
The XAUUSD price tells you what gold is worth. The yield tells you what it costs to own gold. The spread between them — measured by cointegration — tells you whether the market has temporarily mispriced that tradeoff.
When the spread z-score hits +2, the market is saying: "Gold is too expensive relative to what yields tell us it should cost." When it hits −2, the market is saying the opposite.
The signal does not tell you when the mean reversion will occur. It tells you that the relationship has a structural anchor — and that anchor is worth monitoring continuously.
If you want to run this pipeline with your own data, sign up at tickdb.ai (free API key, no credit card required) and set the TICKDB_API_KEY environment variable. The code in this article is production-ready as-is — with heartbeat, reconnection, and rate-limit handling built in.
Next Steps
If you want to backtest this signal across 10+ years of historical data, TickDB provides 10+ years of cleaned, aligned XAUUSD OHLCV data via the /v1/market/kline endpoint. Use the end_time and start_time parameters to segment by rate regime.
If you need higher rate limits for real-time monitoring across multiple instruments, the Professional and Enterprise plans offer increased API quotas and access to the depth channel for order-book-level microstructure analysis.
If you're building a broader macro strategy framework, combine this signal with TickDB's cross-asset coverage — equities, crypto, forex — to construct multi-factor regime detection across your entire portfolio.
If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your AI tool's marketplace to get TickDB API integration shortcuts and code templates directly in your workflow.
This article does not constitute investment advice. Markets involve risk; past statistical relationships do not guarantee future behavior. Backtest results are simulations and should be validated with out-of-sample testing before live deployment.