Files
sam b0243adf61 Composable models and A/B tests of the 2024 ideas; add powerlaw.
Models are now a Composite of drift, volatility and (optional) shape
components, so an experiment can swap one part against a fixed control.

btcmodel/experiments.py holds seven experiments built from the ideas in the
old branches (catalogued in docs/2024-ideas.md), each with its hypothesis
and source, and a verdict rule fixed before anything ran. `just ab` runs
them on development data. Results:

- Shrinking the cycle drift, and a power-law trend (plain or reverting),
  beat their controls. The power law beats the random walk by 53-63% at
  3-4 years with unbiased outcomes, so it is promoted to MODELS.
- Every alternative volatility estimate (EWMA blends, other windows,
  reversion to a level or trend) is worse than the trailing 365-day window.
  Cycle-dependent volatility, heavy tails and stretched cycle phase show no
  reliable effect.
2026-09-24 03:01:46 -07:00

42 lines
1.6 KiB
Python

"""Halving calendar and position within the halving cycle."""
import numpy as np
import pandas as pd
GENESIS = pd.Timestamp("2009-01-03")
# Block heights 210k, 420k, 630k, 840k (UTC dates).
HALVINGS = pd.DatetimeIndex(["2012-11-28", "2016-07-09", "2020-05-11", "2024-04-20"])
# Later halvings are projected at the length of the last cycle. Block times drift
# by weeks per cycle, which is noise at the resolution this is used.
_LAST_CYCLE = HALVINGS[-1] - HALVINGS[-2]
_PROJECTED = pd.DatetimeIndex([HALVINGS[-1] + k * _LAST_CYCLE for k in range(1, 6)])
# Genesis starts cycle 0.
CYCLE_STARTS = pd.DatetimeIndex([GENESIS]).append(HALVINGS).append(_PROJECTED)
def cycle_position(dates) -> tuple[np.ndarray, np.ndarray]:
"""
For each date, return (cycle index, days since that cycle began).
Cycle 0 runs from genesis to the first halving; a halving day is day 0 of
the cycle it starts.
"""
dates = pd.DatetimeIndex(dates)
if (dates < GENESIS).any():
raise ValueError("date before genesis")
if (dates >= CYCLE_STARTS[-1]).any():
raise ValueError("date beyond projected halvings")
index = CYCLE_STARTS.searchsorted(dates, side="right") - 1
days = (dates - CYCLE_STARTS[index]).days
return np.asarray(index), np.asarray(days)
def cycle_fraction(dates) -> tuple[np.ndarray, np.ndarray]:
"""For each date, return (cycle index, fraction of that cycle elapsed, in [0, 1))."""
index, days = cycle_position(dates)
lengths = (CYCLE_STARTS[index + 1] - CYCLE_STARTS[index]).days
return index, days / np.asarray(lengths)