mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-24 14:38:04 +00:00
227 lines
8.0 KiB
Python
227 lines
8.0 KiB
Python
"""Exogenous data — a BTC/hashrate spread as a signal.
|
|
|
|
Thesis: Bitcoin hashrate is a proxy for miner commitment and network
|
|
security. When BTC price drops but hashrate holds (or rises), miners
|
|
are still profitable and the sell-off is likely transient — buy the dip.
|
|
When price rises but hashrate lags, the rally lacks fundamental backing.
|
|
|
|
The strategy normalizes both BTC price and hashrate via EMA ratios
|
|
(price/EMA and hashrate/EMA), then computes a spread between the two.
|
|
A rolling z-score of the spread generates the signal: negative z means
|
|
price is cheap relative to hashrate (long), positive means expensive.
|
|
|
|
Demonstrates:
|
|
- an exogenous series ASOF-joined onto the bar grid
|
|
- a spread between two EMA-normalised series as a signal
|
|
- a rolling z-score turning that spread into a position
|
|
|
|
Data: shared store — real BTC bars from `data/`, plus a hashrate series
|
|
(fetched, or generated by the sample generator below when absent).
|
|
See examples/README.md.
|
|
|
|
Exogenous data flow:
|
|
1. Fetch hashrate CSV (or use sample generator below)
|
|
2. Register via mbt.register_exo("hashrate", df)
|
|
3. Declare in BacktestConfig(exo_data=["hashrate"])
|
|
4. Access with exo("hashrate") in expressions
|
|
|
|
Prerequisite:
|
|
Binance BTC perp data + hashrate exo registered in data/mega/exo/
|
|
|
|
Usage:
|
|
python examples/16_hashrate_exogene.py
|
|
"""
|
|
|
|
import time
|
|
import numpy as np
|
|
import manifoldbt as mbt
|
|
from manifoldbt.indicators import ema, close
|
|
from manifoldbt.expr import col, exo, lit, when, hold
|
|
from manifoldbt.helpers import time_range, Interval, Slippage
|
|
|
|
|
|
# =============================================================================
|
|
# Parameters
|
|
# =============================================================================
|
|
SMOOTH = 30 # EMA period for normalization
|
|
ZSCORE_WINDOW = 90 # Rolling z-score lookback (days)
|
|
ENTRY_Z = -1.5 # Long when spread z < -1.5 (price cheap vs hashrate)
|
|
EXIT_Z = 0.0 # Exit when spread reverts to mean
|
|
SHORT_Z = 1.5 # Short when spread z > 1.5 (price expensive vs hashrate)
|
|
SIZE = 0.5 # Position size (fraction of capital)
|
|
|
|
|
|
# =============================================================================
|
|
# Indicators
|
|
# =============================================================================
|
|
|
|
# Normalize price: ratio to its own EMA (>1 = above trend, <1 = below)
|
|
price_ratio = close / ema(close, SMOOTH)
|
|
|
|
# Normalize hashrate the same way
|
|
hr = exo("hashrate")
|
|
hr_ratio = hr / ema(hr, SMOOTH)
|
|
|
|
# Spread: price_ratio - hr_ratio
|
|
# Positive = price running ahead of hashrate, negative = price lagging
|
|
spread = price_ratio - hr_ratio
|
|
|
|
# Z-score of the spread (rolling mean & std)
|
|
spread_z = spread.zscore(ZSCORE_WINDOW)
|
|
|
|
|
|
# =============================================================================
|
|
# Sizing
|
|
# =============================================================================
|
|
z = col("spread_z")
|
|
|
|
size = when(
|
|
z < lit(ENTRY_Z), lit(SIZE), # price cheap vs hashrate -> long
|
|
when(z > lit(SHORT_Z), -lit(SIZE), # price expensive vs hashrate -> short
|
|
when((z > lit(EXIT_Z)) & (z < lit(SHORT_Z)), 0.0, # neutral zone -> flat
|
|
hold())),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Strategy
|
|
# =============================================================================
|
|
strategy = (
|
|
mbt.Strategy.create("hashrate_spread")
|
|
.signal("price_ratio", price_ratio)
|
|
.signal("hr_ratio", hr_ratio)
|
|
.signal("spread", spread)
|
|
.signal("spread_z", spread_z)
|
|
.size(size)
|
|
.describe("BTC vs Hashrate spread z-score mean-reversion")
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Config
|
|
# =============================================================================
|
|
START, END = time_range("2021-06-01", "2026-03-01")
|
|
|
|
config = mbt.BacktestConfig(
|
|
universe={"binance": ["BTC-USDT:perp"]},
|
|
time_range_start=START,
|
|
time_range_end=END,
|
|
bar_interval=Interval.days(1),
|
|
initial_capital=10_000,
|
|
warmup_bars=ZSCORE_WINDOW + SMOOTH,
|
|
exo_data=["hashrate"],
|
|
execution=mbt.ExecutionConfig(signal_delay=1, allow_short=True),
|
|
fees=mbt.FeeConfig.binance_perps(),
|
|
slippage=Slippage.fixed_bps(3),
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Hashrate data helper
|
|
# =============================================================================
|
|
def fetch_hashrate_csv(path: str = "hashrate.csv"):
|
|
"""Load hashrate from a CSV with columns: timestamp, hashrate.
|
|
|
|
Public sources (daily, free):
|
|
- https://api.blockchain.info/charts/hash-rate?timespan=5years&format=csv
|
|
- Glassnode, CoinMetrics (API key)
|
|
|
|
The CSV should have:
|
|
timestamp — date or datetime (parsed automatically)
|
|
hashrate — daily avg hashrate in EH/s (float)
|
|
"""
|
|
import pandas as pd
|
|
df = pd.read_csv(path, parse_dates=["timestamp"])
|
|
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
|
|
df = df.sort_values("timestamp").reset_index(drop=True)
|
|
return df
|
|
|
|
|
|
def generate_sample_hashrate(start="2020-01-01", end="2026-03-01"):
|
|
"""Generate synthetic hashrate data for testing.
|
|
|
|
Mimics the real BTC hashrate trajectory:
|
|
- Exponential growth trend (~50% annual)
|
|
- China ban crash (May-Jul 2021): -50%
|
|
- Recovery + continued growth
|
|
- Random noise (~5% daily vol)
|
|
"""
|
|
import pandas as pd
|
|
|
|
dates = pd.date_range(start, end, freq="D", tz="UTC")
|
|
n = len(dates)
|
|
|
|
# Base: exponential growth from ~120 EH/s to ~800 EH/s
|
|
t = np.arange(n) / 365.25
|
|
base = 120 * np.exp(0.40 * t) # ~50% annual growth
|
|
|
|
# China ban shock: May-Jul 2021
|
|
ban_start = pd.Timestamp("2021-05-15", tz="UTC")
|
|
ban_end = pd.Timestamp("2021-07-15", tz="UTC")
|
|
recovery_end = pd.Timestamp("2022-01-01", tz="UTC")
|
|
|
|
shock = np.ones(n)
|
|
for i, d in enumerate(dates):
|
|
if ban_start <= d <= ban_end:
|
|
# Linear drop to 50%
|
|
frac = (d - ban_start) / (ban_end - ban_start)
|
|
shock[i] = 1.0 - 0.50 * frac
|
|
elif ban_end < d < recovery_end:
|
|
# Recovery from 50% back to 100%
|
|
frac = (d - ban_end) / (recovery_end - ban_end)
|
|
shock[i] = 0.50 + 0.50 * frac
|
|
|
|
# Random noise (geometric brownian)
|
|
rng = np.random.default_rng(42)
|
|
noise = np.exp(np.cumsum(rng.normal(0, 0.02, n)))
|
|
noise /= noise[0]
|
|
|
|
hashrate = base * shock * noise
|
|
|
|
return pd.DataFrame({"timestamp": dates, "hashrate": hashrate})
|
|
|
|
|
|
# =============================================================================
|
|
# Run
|
|
# =============================================================================
|
|
if __name__ == "__main__":
|
|
import os
|
|
|
|
root = os.path.dirname(os.path.abspath(__file__))
|
|
data_root = os.path.abspath(os.path.join(root, "..", "data"))
|
|
meta_db = os.path.join(root, "..", "metadata", "metadata.sqlite")
|
|
|
|
store = mbt.DataStore(
|
|
data_root=data_root,
|
|
metadata_db=meta_db,
|
|
arrow_dir=os.path.join(data_root, "mega"),
|
|
)
|
|
|
|
# -- Register hashrate exo data -------------------------------------------
|
|
csv_path = os.path.join(root, "hashrate.csv")
|
|
if os.path.exists(csv_path):
|
|
print("Loading hashrate from CSV...")
|
|
hr_df = fetch_hashrate_csv(csv_path)
|
|
else:
|
|
print("No hashrate.csv found — generating synthetic data for demo...")
|
|
hr_df = generate_sample_hashrate()
|
|
|
|
mbt.register_exo("hashrate", hr_df, store=store)
|
|
print(f" Registered {len(hr_df)} hashrate data points")
|
|
print(f" Range: {hr_df['timestamp'].iloc[0]} -> {hr_df['timestamp'].iloc[-1]}")
|
|
print()
|
|
|
|
# -- Run backtest ---------------------------------------------------------
|
|
print("Running: hashrate_spread")
|
|
print(" Long when spread z < -1.5 (price cheap vs hashrate)")
|
|
print(" Short when spread z > +1.5 (price expensive vs hashrate)")
|
|
print()
|
|
|
|
t0 = time.perf_counter()
|
|
result = mbt.run(strategy, config, store)
|
|
elapsed = time.perf_counter() - t0
|
|
|
|
print(result.summary())
|
|
print(f"\nElapsed: {elapsed:.3f}s")
|
|
result.plot_equity()
|