btc-cycle-lab

tests

A local research lab for testing whether the Bitcoin halving cycle and macro events explain anything in the price of BTC.

The project is built around a single premise: finding a pattern is easy, showing it is not chance is hard. So every result leaves here with a confidence interval, a count of observations and a correction for the number of hypotheses tested — and every backtest is compared against buy-and-hold.

Its own headline finding is negative. On data from 2011 to 2026 the apparent halving effect does not survive correction for multiple testing, does not replicate out of sample, cannot be distinguished from what the NASDAQ did over the same windows, and survives none of 160 ways of specifying the question. What remains once a pre-event baseline is subtracted is drift. See Results.

This is not investment advice. Read DISCLAIMER.md before using any number from this tool to decide what to do with money.


Quick start

You need Python 3.11 or newer. Everything else happens automatically — the launcher creates a virtual environment and installs dependencies on first run. Nothing is installed system-wide.

Windows

.\btc.cmd ingest --what all

macOS and Linux

./btc.sh ingest --what all

Any platform, no wrapper:

python run.py ingest --what all

The wrappers are one-line shims around run.py; use whichever is convenient.

Without a terminal at all (Windows): double-click dashboard.cmd in the folder. It sets everything up on first run and opens the dashboard in a browser. Double-clicking btc.cmd shows the list of commands instead, since a launcher with no command has nothing to launch.

Platform support

platform Python verified
Windows 10/11 3.11, 3.13 every push
Linux (Ubuntu) 3.11, 3.13 every push
macOS 3.11, 3.13 every push

All six combinations run the full offline suite through run.py, so each one exercises the real installation path — interpreter discovery, virtual environment creation, dependency install — not just the tests. The two POSIX jobs additionally assert that ./btc.sh still has its execute bit and runs.

Six jobs on every push. Actions minutes are free on public repositories, so there is no reason to hold macOS back to a schedule - and one workflow covering everything cannot drift out of step with a second one testing the same thing differently. The examples below use run.py because it is identical everywhere.

Then run any of these:

command what it does
run.py ingest --what all download prices, macro, control group and events
run.py quality data quality report and the seam between exchanges
run.py features build and save the feature frame
run.py study --post 365 event study around halvings and event categories
run.py control control group: NASDAQ, S&P 500, gold
run.py macro liquidity axis: real M2 vs the dollar proxy
run.py validate hypothesis scan with correction + out-of-sample replication
run.py walkforward walk-forward validation across 13 disjoint windows
run.py speccurve specification curve: 160 ways of asking the same question (~10 min)
run.py forecast directional forecast, scored against three baselines
run.py range --days 10 how far the price may move, as a calibrated interval
run.py sizing how much to hold, from the volatility forecast
run.py ledger --record write today’s forecast down; without the flag, score the ones that came due
run.py paper advance the public paper portfolio and print its trades
run.py publish build the static site into site/ for a web host
run.py backtest strategies vs buy-and-hold
run.py all everything in sequence
run.py dashboard browser dashboard on port 8511
run.py test the test suite (test offline skips network tests)
run.py doctor environment diagnostics — start here in a bug report

Notes per platform

Windows. On PowerShell the .\ prefix is required; in cmd.exe it is required when NoDefaultCurrentDirectoryInExePath is set, so the examples always include it. The wrapper is .cmd rather than .ps1 on purpose: the default PowerShell execution policy blocks unsigned .ps1 files.

Keep the folder close to the drive root, e.g. C:\projects\btc. Windows limits paths to 260 characters and installing Streamlit unpacks deeply nested example files; with a repo path longer than about 120 characters the install fails with No such file or directory. The launcher warns about this before it starts.

Debian and Ubuntu. python3-venv is a separate package and is required:

sudo apt install python3 python3-venv python3-pip

macOS. The system Python is usually old enough to matter; brew install python@3.13 or the installer from python.org both work.

If ./btc.sh is not executable (the bit does not survive a zip download), either chmod +x btc.sh once or just use python3 run.py.

The POSIX wrapper is btc.sh rather than plain btc for the sake of Windows: Explorer hides known extensions by default, so an extensionless btc and btc.cmd appear in the folder as two identical entries called “btc”, with the unrunnable one sorting first. Anyone double-clicking picks the wrong file and sees nothing happen.

Portability

The whole folder can be copied to a USB stick or another machine. A virtual environment stores absolute paths internally, so it breaks when moved — the launcher detects this (by trying to import the dependencies) and rebuilds it from scratch. The database, configuration and results live relative to the project folder, so they travel with it.

What moving does not carry: the .env file with your FRED key, which is outside version control. The launcher recreates it from the template, but the key has to be pasted again. Without it everything works except the M2 series.


Where the data comes from

Source Coverage API key Role
Binance (BTC/USDT) from 2017-08-17 no deepest market — source of truth from 2017
Bitstamp (BTC/USD) from 2011-08-18 no the only one covering the 2012 and 2016 halvings
Coinbase (BTC/USD) from 2015-07-20 no cross-validation
Yahoo Finance from 2009 no DXY, S&P 500, yields, gold
Yahoo Finance (control) from 2011 no NASDAQ (^IXIC), S&P 500, gold
FRED from the 1960s yes (free) M2, industrial production, Fed funds, unemployment

Binance has the most data in terms of depth and volume, but its history starts in 2017 — the exchange did not exist before that. So the series is stitched: Bitstamp up to 2017-08, Binance afterwards. The seam is explicit and checked on the overlap (median divergence on shared days: 0.06%, maximum 9% in December 2017, at the peak of the mania, when exchanges genuinely diverged).

M2 and PMI have no free key-less API. M2 comes from FRED once you paste a free key into .env; ISM PMI has a licence that forbids redistribution, so drop your own file into data/raw/manual/pmi.csv and the tool will pick it up. Without a FRED key the macro phase falls back to market proxies (DXY, the rate curve) — and run.py macro reports which source the liquidity axis is built from, plus, when both are available, how much they agree.

See DATA_SOURCES.md for attribution requirements and known redistribution restrictions. Yahoo Finance prohibits redistributing its data — this matters if you host the dashboard publicly.

First releases, not revisions

FRED data is fetched as first releases (output_type=4), so available_from is the date the number actually entered circulation. Measured on the real M2SL vintages: the median publication lag is 43 days, maximum 58 — two weeks more than the intuitive “a month after the period ends”. All 559 observations received a date from the vintage archive, with zero fallbacks.

Three edge cases, each with a test in tests/test_fred_vintages.py:

Error messages pass through ingest.http.redact — the API key travels in the query string, so a raw URL inside an exception would leak it into logs.


The time contract — the most important thing in this repo

Every row in the database carries two dates:

For prices the difference is one day (the bar for day D closes at midnight). For M2 it is about six weeks. The entire analysis filters on available_from, never on date.

features/checks.py verifies this point-in-time: for a chosen day t the features are built twice — once with the whole history, once with only the data published up to t — and must come out identical. The test deliberately includes a planted leak (normalising by the median of the whole sample) to prove the detector works.


Forecasting: what the tool will and will not do

It does not forecast a price level. At 4% daily volatility the confidence interval around a 30-day price is wider than the forecast, so the number would carry no information.

It does estimate the probability that the 30-day forward return is positive, and then spends most of its effort deciding whether that estimate is worth anything. The model is a ridge-penalised logistic regression on the same point-in-time features as everything else; the penalty is chosen on an inner chronological split of each training window, never on the test window.

Three baselines have to be beaten before the model means anything:

baseline the objection it answers
always_up “the asset just goes up” — uses the training-window base rate
coin_flip a constant 0.5, the reference point for the Brier score
momentum “last month continues” — the cheapest real predictor

always_up is the one that matters. Bitcoin rose in 59% of the 30-day windows in this sample, so a model reporting 58% accuracy has demonstrated nothing — and that is how most “BTC prediction models” are presented, against an implied 50% nobody actually competes with.

Two further details the report insists on:

Control group: the placebo test

A halving is a Bitcoin-only event. The NASDAQ over the same window has no way of knowing about it — so if it reacts the same way, we are measuring the common move of risk assets rather than a reward halving.

The test is paired across events (difference in differences): for each halving we compute Bitcoin’s CAR and the control’s CAR over the same window and infer from the distribution of their difference. Pairing matters — the 2020 halving fell in the middle of the pandemic rebound, which lifted both assets; comparing two separate means would lose that.

The calendar is not a detail. The NASDAQ trades ~252 days a year, Bitcoin

  1. Without alignment, “365 days after the halving” means 365 rows for the NASDAQ, i.e. about 511 calendar days — and an event falling on a weekend does not exist in the index at all. On test data that costs 3 of 4 events (test_without_calendar_alignment_most_events_are_lost). So the control series is mapped onto the full calendar with ffill, a strictly backward-looking operation.

Methodological decisions (and why)

The unit of observation is the event, not the day. With four halvings the uncertainty comes from having had four halvings, not from having had 1460 days. Inference therefore uses the t distribution with n−1 degrees of freedom across events. The percentile bootstrap is reported alongside but does not decide: at n = 3–5 it produced ~30% false discoveries instead of 5% (measured in test_false_positive_rate_stays_near_nominal).

Abnormal returns are measured against a pre-event window (−250..−31 days), not against the full-sample mean — the full-sample mean already contains whatever the event is supposed to explain.

The “window vs rest of sample” test uses circular shifts of the mask. A plain t-test on daily returns assumes independence, which prices do not have, and systematically overstates significance. Rotation preserves the autocorrelation of both series.

An embargo between training and test. The target fwd_return_90d on day t contains prices from t+90, so without a gap the last training days see the test set.

Walk-forward instead of a single split. Splitting by cycle gives one test set — “it did not replicate” is a single observation there. Walk-forward gives 13 disjoint yearly windows (2013-11 → 2026-09) and lets us count how often the sign of an effect survives the move from training to test. Under the null that is a coin flip, so the number of agreeing folds is binomial.

The step between folds must be at least the length of the test window, otherwise the windows overlap and the t-test across folds stops being valid. The pipeline checks disjointness on the actual indices, not on the parameters, and reports no t-test when they overlap. The first version of the config had this bug (a 182-day step with a 365-day window) and overstated significance.

Costs are charged on turnover, not per trade. A signal from day t takes effect with a lag — zero lag means trading at a price that has not settled yet. test_execution_lag_blocks_same_day_knowledge shows the difference: the same “signal” returns +1000× with no lag and nothing with one.


Results

On 5496 days, 2011-08-18 → 2026-09-03. The full snapshot lives in data/processed/RESULTS.md.

Event study, halvings. CAR after 365 days = +125.2%, confidence interval [−190.5%, +441.0%], p = 0.296, n = 4. The effect may be enormous or negative — with four observations there is no way to tell. That is not a failure of the method; it is all the information there is.

Control group (CAR at 365 days, paired across events):

  CAR BTC CAR control difference 95% CI p
vs NASDAQ +125.2% +17.0% +108.2% [−169.4%, +385.8%] 0.303
vs S&P 500 +125.2% +13.7% +111.5% [−167.7%, +390.7%] 0.293
vs gold +125.2% −17.3% +142.5% [−200.5%, +485.4%] 0.278

None of these differences is distinguishable from zero. The placebo works in the other direction: run on the NASDAQ, the method does not find a halving effect there (+17.0%, p = 0.307) — so the absence of a result for BTC is not the method being powerless.

Validation. 26 hypotheses (halving windows × event categories × macro phases): 0 significant raw, 0 after Benjamini-Hochberg correction. Chance alone would have given ~1.3. One hypothesis passes the out-of-sample replication rule, event_credit_event_7d, and it is reported with the two facts that sink it: it was not significant in the training window (p = 0.42), so there was nothing to replicate, and its corrected q = 0.75.

Placebo group. The protocol_upgrade category holds the five consensus changes enforced on Bitcoin mainnet inside the price window — complete by construction and pre-announced, so nothing could have been learned on those dates. In walk-forward it produces the lowest raw p-value in the table (0.032), and in the forecast model the third-largest coefficient. Uncorrected, this pipeline would announce that Bitcoin reacts to software upgrades whose dates were public months in advance. Read it as calibration: a raw p between 0.03 and 0.25 is routinely produced here by a variable known to be empty.

Specification curve. 160 combinations of price series, horizon, return type, abnormal-or-raw and estimation window. Median CAR +5.2%, 2 of 160 significant uncorrected. Shifting the same four dates to a random place in the history and rebuilding the whole curve, 200 times, produces 15.1 significant specifications on average — seven times more than the real halvings. Curve level p = 0.706. The curve also names what the apparent effect is: removing the pre-event baseline grows it eightfold, moving that baseline closer to the event nearly erases it, and it scales with the horizon without limit. Those are the signatures of drift.

Position sizing, the one place the volatility model pays off. Since volatility is forecastable and direction is not, run.py sizing holds target volatility / forecast volatility - a large position in a quiet market, a small one in a violent market, so the RISK stays constant rather than the quantity held. It contains no view about where the price is going.

  Sharpe max drawdown volatility turnover/yr costs
buy and hold 1.136 −83.2% 66.7% 0.09 0.3%
vol target, no band 1.095 −72.3% 52.6% 8.87 24.5%
vol target, 30% band 1.131 −66.4% 46.9% 1.14 3.2%
vol target, EWMA 10% band 1.153 −72.6% 53.0% 2.18 6.0%

The forecast carries real information: the correlation between the position held and the next day’s absolute move is −0.255 — a smaller position before a bigger move, computed strictly causally. Volatility falls from 67% to 47% and the worst drawdown from −83% to −66%.

It does not raise the Sharpe ratio. Returns fall about as much as risk does, and the permutation test on the best variant gives p = 0.40. This is a risk-control tool, not a way to earn more. A −66% drawdown is survivable where −83% often is not, which is worth something to anyone actually holding the position — but it is not extra money, and a backtest claiming otherwise would be selling something.

Two details worth keeping. Without a rebalance band the rule retrades 8.9 times a year and pays 24.5% of capital in costs, destroying the benefit it exists to provide. And the expensive model loses: an exponentially weighted standard deviation sizes positions at least as well as the GARCH fit that takes six minutes to compute.

What is predictable: the range, not the direction. Everything above says direction is not forecastable here. None of it says the size of the next move is not, and it is: volatility clusters. run.py range --days 10 fits GARCH(1,1) on a trailing window, resamples its own standardised residuals to simulate the horizon forward, and quotes an interval — but only after that interval has passed a coverage backtest on non-overlapping windows.

Across 403 independent 10-day windows:

level covered verdict
50% 49.1% passes (p = 0.77)
68% 64.8% passes (p = 0.17)
90% 87.6% passes (p = 0.11)
95% 91.8% fails (p = 0.006) — withheld

So the command quotes 50%, 68% and 90%, and refuses to give a 95% number at all. The far tail is the hardest thing to get right and this model does not have it; saying so is the point of the gate.

Three modelling choices were forced by measured failures rather than picked in advance, and two of them reverse an argument that sounded good. Centring the interval on today’s price — no drift, on the grounds that fitting a trend encodes the unpredictable — failed: 41 outcomes finished above the interval against 19 below. Zero drift is not neutral, it is the claim that the expected move is exactly zero. Reusing a fit between refits froze the conditional variance for up to a month, which is harmless in a calm stretch and exactly wrong in the week a crash starts. And Student-t shocks were not enough, so the simulation resamples the residuals instead of assuming a shape.

Walk-forward. 13 disjoint windows: 0 significant after correction, on both the sign test and the out-of-sample effect. The strongest case is phase_expanding_rising with 5 of 5 matching signs — the best possible outcome — and still p = 0.0625, because at five windows significance is unreachable.

Liquidity axis: real M2 vs the dollar proxy. The two agree on 42.3% of 5130 compared days — below chance level (50% for a binary axis), because in this sample they are opposed. The phase “liquidity rising, rates rising” returns −20.6% a year computed from M2 and +105.9% computed from the proxy. Same label, opposite conclusion. Do not treat the proxy as an approximation of M2.

Backtest (10 bps fees + 15 bps slippage):

strategy return CAGR Sharpe maxDD exposure
buy and hold +715,635% 80.3% 1.14 −84.9% 100%
trend 50/200 +885,999% 82.9% 1.27 −78.3% 61%
halving +365d +254,351% 68.4% 1.42 −71.0% 27%
halving +180d +2,555% 24.3% 0.87 −70.3% 13%
macro (M2 up, rates down) +257% 8.8% 0.46 −57.4% 10%

The only strategy with a better Sharpe than buy-and-hold at meaningful exposure — halving +365d, 1.42 at 27% time in market — fails every statistical check above and is indistinguishable from the NASDAQ. Four cycles are four observations, and “be in the market for a year after the halving” largely overlaps with “be in the market during a bull run”.

Does the timing beat chance? A higher Sharpe is not an edge, so each rule is now compared against itself applied at random times: the same position series, circularly shifted, so exposure, turnover and costs are unchanged and only the alignment with the price is destroyed.

strategy Sharpe random timing p episodes p without one fragile
buy and hold 0.71 0.72 0.96 1 0.97  
trend 50/200 0.90 0.56 0.20 12 0.28  
halving +90d 0.70 0.19 0.085 4 0.52  
halving +365d 1.18 0.34 0.0125 4 0.16 yes
macro 0.32 0.22 0.38 25 0.64  

Buy-and-hold returning p = 0.96 is the validity check: a constant position is unchanged by a shift, so it cannot have a timing edge and the test says so.

halving +365d is the only rule under 0.05 raw, and it survives nothing. Seven rules were tested, so after Benjamini-Hochberg its q = 0.0875. And it is fragile: remove the 2012 halving — after which BTC rose roughly a hundredfold — and p goes from 0.0125 to 0.158. One window carries the whole result.

That fragility column exists because this test can otherwise mislead badly. Its p-value is as precise as the number of permutation draws and only as strong as the number of trades behind it, and those are not the same number: the event study on the same four halvings reports p = 0.30 with an interval from −190% to +441%, because its unit of observation is the event. Multiplying arrangements does not multiply evidence.


Layout

run.py           cross-platform launcher and installer
btc.cmd / btc.sh thin wrappers around it
src/
├── ingest/      fetching (4 exchanges, FRED, Yahoo, manual CSV) + quality checks
├── features/    halving distance, macro phase, event flags + look-ahead detector
├── analysis/    event study, HAC correlations, control group (placebo)
├── backtest/    engine with costs and slippage + strategies
├── validation/  splits, walk-forward, Bonferroni/BH, synthetic data
├── pipeline.py  assembles everything - used by both the CLI and the dashboard
└── cli.py
dashboard/       Streamlit - presentation only, no logic (a test enforces this)
tests/           176 offline tests + network tests, one file per phase

The dashboard must not compute statistics. If a number appears on screen it comes from pipeline.py, so the terminal and the browser can never disagree.


Limitations worth remembering

What next

  1. Fill in source in the event registry and add events the market ignored.
  2. Add a second control asset class outside equities.
  3. Package for PyPI so pip install works.

Documents

File Contents
DISCLAIMER.md not investment advice — read this first
LICENSE MIT
TERMS.md terms of use
PRIVACY.md privacy policy (the tool collects nothing)
DATA_SOURCES.md attribution and redistribution restrictions
CONTRIBUTING.md how to contribute, and the house rule
SECURITY.md reporting vulnerabilities
CHANGELOG.md what changed, and which changes moved the numbers