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