2026-09-24 02:00:56 -07:00
|
|
|
"""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)
|
2026-09-24 02:30:38 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|