84 lines
3.3 KiB
Python
84 lines
3.3 KiB
Python
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, CycleModel
|
||
|
|
|
||
|
|
|
||
|
|
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 = CycleModel(prior_days=0).drift_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 = CycleModel(recency_half_life=0.25).drift_by_cycle_day(prices)
|
||
|
|
equal = CycleModel(recency_half_life=1e9).drift_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
|