Files
bitcoin-model/tests/test_models.py
T

84 lines
3.3 KiB
Python
Raw Normal View History

import numpy as np
import pandas as pd
import pytest
from btcmodel import data
from btcmodel.evaluate import backtest
from btcmodel.halving import HALVINGS, cycle_position
from btcmodel.models import MODELS, CycleDrift
def synthetic_prices(daily_return, start="2011-01-01", end="2024-11-26", noise=0.0, seed=0):
"""Prices whose daily log return is `daily_return(cycle_day)` plus optional noise."""
dates = pd.date_range(start, end, freq="D", name="date")
_, day = cycle_position(dates)
r = daily_return(day) + noise * np.random.default_rng(seed).standard_normal(len(dates))
return pd.DataFrame({"close": 100 * np.exp(np.cumsum(r))}, index=dates)
def test_cycle_position():
index, day = cycle_position(pd.DatetimeIndex(["2009-01-03", "2012-11-27", *HALVINGS]))
assert list(index) == [0, 0, 1, 2, 3, 4]
assert list(day) == [0, 1424, 0, 0, 0, 0]
# A projected halving about four years after the last one starts cycle 5.
index, _ = cycle_position(pd.DatetimeIndex(["2028-06-01"]))
assert index[0] == 5
def test_cycle_model_recovers_a_cycle_shaped_drift():
# Up for the first half of each cycle, down in the second half.
def shape(day):
return np.where(day < 700, 0.002, -0.001)
prices = synthetic_prices(shape)
drift = CycleDrift(prior_days=0).by_cycle_day(prices)
assert drift[300] == pytest.approx(0.002, abs=2e-4)
assert drift[1100] == pytest.approx(-0.001, abs=2e-4)
def test_cycle_model_weights_recent_cycles_more():
# The same day of the cycle returns less in each later cycle.
prices = synthetic_prices(lambda day: np.zeros_like(day, dtype=float))
cycle, _ = cycle_position(prices.index)
r = 0.004 / 2.0**cycle
prices["close"] = 100 * np.exp(np.cumsum(r))
drift = CycleDrift(recency_half_life=0.25).by_cycle_day(prices)
equal = CycleDrift(recency_half_life=1e9).by_cycle_day(prices)
latest_complete = 0.004 / 2.0**3
assert abs(drift[900] - latest_complete) < abs(equal[900] - latest_complete)
@pytest.mark.parametrize("name", list(MODELS))
def test_models_produce_valid_forecasts(name):
prices = synthetic_prices(lambda day: 0.001 + 0 * day, noise=0.03)
horizons = np.array([1, 30, 365, 1460])
f = MODELS[name].forecast(prices, horizons)
assert f.log_quantiles.shape == (4, 100)
assert np.all(np.diff(f.log_quantiles, axis=1) >= 0)
lo, hi = f.interval(0.8)
assert np.all(np.diff(hi - lo) > 0), "uncertainty should grow with horizon"
def test_backtest_never_shows_models_the_future():
prices = synthetic_prices(lambda day: 0.001 + 0 * day, noise=0.03)
class Spy:
name = "random_walk"
def forecast(self, history, horizons):
origin = history.index[-1]
assert history.index.max() == origin
assert (origin + pd.Timedelta(days=int(horizons.min()))) > history.index.max()
return MODELS["random_walk"].forecast(history, horizons)
scores = backtest([Spy()], prices)
assert len(scores) > 0
targets = scores.origin + pd.to_timedelta(scores.horizon, unit="D")
assert targets.max() <= prices.index[-1]
def test_development_data_stops_at_cutoff():
prices = data.load_prices(until=data.DEV_CUTOFF)
assert prices.index[0] == data.DATA_START
assert prices.index[-1] == data.DEV_CUTOFF