Does the Power Law Actually Hold?
Bitcoin follows a power law with R² = 0.968, but not for the reason most charts suggest. I tested it properly using OLS regression, and the result both validates and complicates the popular narrative.
What Is the Bitcoin Power Law?
The Bitcoin power law is the claim that Bitcoin's price grows as a power function of time:
log(price) = ln(a) + b · log(days since genesis)If true, price plotted in log-log space should fall on a straight line.
I arrived on Earth and noticed you humans arguing about this chart. Some insisted it was real. Others called it cope. Nobody seemed to have actually tested it against competing models.
So I did.
What Is Wrong With the Popular Power Law Charts?
The charts you keep sharing are not regression outputs. Someone chose parameters that looked good, drew the line, then pointed at it as proof. This is what my species calls circular reasoning.
The correct approach is to fit the model to the data with no prior assumptions, then check whether it beats alternative explanations. That is what OLS regression does.
How I Tested It
I ran Ordinary Least Squares regression in log-log space using Bitcoin price data from October 2009 to today — the full available history. No hardcoded parameters. No manual tuning.
I then tested three competing models head to head:
Power law: log(price) = ln(a) + b · log(days)Exponential: log(price) = a + b · daysLog-linear: price = a + b · log(days)The x-axis is anchored to the Bitcoin genesis block (January 3 2009) to match Santostasi's convention and make parameters directly comparable.
What Did the Regression Find?
Power law won cleanly.
Power law R² = 0.968 ★
Exponential R² = 0.889
Log-linear R² = 0.493
b = 5.9781 (Santostasi's published value: ~5.8)
ln(a) = -40.36The independently derived exponent b ≈ 5.98 converged on Santostasi's published ~5.8 without being told to. That is what independent validation looks like. The gap between power law and exponential at 0.968 vs 0.889 is not close: it is decisive over 15 years of data.
What Is the Power Law Floor — and Is It Real?
The floor is real, but it is not a fundamental law. It is the OLS center line shifted down to the 5th percentile of historical residuals — same slope b, different intercept ln(a).
My data-derived values:
Floor ln(a) = -41.25 (Santostasi publishes ≈ -37)
Ceiling ln(a) = -39.91 (unpublished — every major Bitcoin top has respected it)
Center ln(a) = -40.36 (OLS fair value)
The gap between my floor and Santostasi's exists because my dataset includes pre-exchange NLS price data from October 2009, which pulls the 5th percentile lower. His floor is calibrated to exchange-traded prices only. Neither is wrong, they answer slightly different questions.
Does Bitcoin Price Ever Break the Floor?
Yes. And that is expected. The floor is defined as the 5th percentile of historical residuals, which means by construction roughly 5% of observations should fall below it.
Price broke below the floor during the 2011 crash and the 2015 bear market. In both cases the breach was temporary and price recovered back above the floor within months.
What the floor actually claims is not "price never goes below" but "price does not stay below." That is a weaker but more honest statement, and so far it has held across 15 years of data including the 2018, 2019, and 2022 drawdowns which did not breach it at all.
Santostasi's version of the floor, calibrated to exchange-traded data only, claims zero breaches. That claim depends on where you draw the floor and which dataset you use. My OLS-derived floor using the full history including 2009 pre-exchange data is more conservative, which is why breaches show up.
What Does R² = 0.968 Actually Mean?
R² = 0.968 means the power law model explains 96.8% of the variance in log(price) over the full history. This is measured in log-log space, which is the correct space for evaluating a power law fit.
It does not mean 96.8% of price moves are explained. It means the long-term structural trend is captured with high fidelity. The remaining 3.2% is the cyclical residual — which maps suspiciously well to the halving cycle. That is a question for another experiment.
How Were the Floor and Ceiling Calculated?
Floor and ceiling use decay-weighted residual percentiles. Each historical residual is weighted by
exp(λ · chronological_position)where λ = ln(2) / half-life. This means:
Recent residuals carry more weight than old ones
The bands naturally compress over time as Bitcoin's volatility decays each halving cycle
No manual parameter tuning required — the bands are entirely data-derived
The default half-life is 104 weekly bars (approximately 2 years), matching roughly one halving cycle.
Can I Use This Indicator Myself?
Yes. The indicator is built in TradingView Pine Script v6 and runs on the weekly timeframe. Key technical decisions:
Genesis-anchored x-axis for parameters comparable to published literature
Binary-search insertion sort — O(n²) total at load time, not per bar
Decay-weighted percentiles for self-compressing bands
Pure OLS — no hardcoded values anywhere
Conclusion: Is the Bitcoin Power Law Real?
Yes. R² = 0.968 over 15 years, independently derived exponent b ≈ 5.98 matching Santostasi's ~5.8, beating exponential and log-linear alternatives decisively.
The power law is real. The popular charts just show it wrong.
POOT is an alien from Uranus who does not give financial advice. The power law predicts structural trend, not timing. Past fit does not guarantee future performance. Do your own regression.
Pinescript Code (For Tradingview):
//@version=6
// Developed in Uranus by POOT
// v3 - power law only, genesis-anchored, decay-weighted floor/ceiling
indicator("Power Law Log-Log View", overlay = false, max_bars_back = 5000)
// ─── Inputs ───────────────────────────────────────────────────────────────────
src = input.source(close, "Source", tooltip = "Price series used for the power-law fit. Must be greater than zero.")
showFit = input.bool(true, "Show center line", tooltip = "Shows the OLS power-law regression line — center of price history.")
showResidual = input.bool(true, "Show residual", tooltip = "Residual = log(price) - fitted log(price). Near zero = price close to trend.")
showCeiling = input.bool(true, "Show ceiling", tooltip = "Ceiling = fit shifted up by upper percentile offset. Red line.")
upperPct = input.float(95.0, "Ceiling percentile", minval = 50.0, maxval = 100.0, step = 0.1, tooltip = "Ceiling uses this percentile of historical residuals.")
lowerPct = input.float(5.0, "Floor percentile", minval = 0.0, maxval = 50.0, step = 0.1, tooltip = "Floor uses this percentile of historical residuals.")
showHelp = input.bool(true, "Show help box", tooltip = "Displays a guide for interpreting the indicator.")
showStats = input.bool(true, "Show stats label", tooltip = "Shows b, ln(a), R², and floor/ceiling ln(a) on the latest bar.")
// ─── Timeframe-aware decay half-life ─────────────────────────────────────────
// Target: ~2 years of bars regardless of timeframe.
// timeframe.in_seconds() gives the bar duration in seconds.
// 2 years = 730 days = 63,072,000 seconds.
// Clamped to a minimum of 10 bars to avoid degenerate behaviour on very high TFs.
int decayHalfLife = math.max(10, math.round(63072000 / timeframe.in_seconds()))
// ─── Genesis anchor (Jan 3 2009) ─────────────────────────────────────────────
// Santostasi counts days from the genesis block, not the first exchange trade.
// Hardcoding this makes b directly comparable to the published ~5.8 figure.
// Exchange data starts Aug 2011 here — only the x-axis origin shifts, not the
// regression data itself.
float GENESIS_MS = 1230940800000.0
// ─── Running sums for log-log OLS ─────────────────────────────────────────────
var float sumX = 0.0
var float sumY = 0.0
var float sumXX = 0.0
var float sumXY = 0.0
var float sumYY = 0.0
var int n = 0
float lxNow = na
float lyNow = na
if src > 0
float days = math.max(1.0, (time - GENESIS_MS) / 86400000.0)
lxNow := math.log(days)
lyNow := math.log(src)
sumX += lxNow
sumY += lyNow
sumXX += lxNow * lxNow
sumXY += lxNow * lyNow
sumYY += lyNow * lyNow
n += 1
// ─── OLS coefficients ─────────────────────────────────────────────────────────
float b = na
float lnA = na
float r2 = na
float denom = n * sumXX - sumX * sumX
if n > 1 and denom != 0
b := (n * sumXY - sumX * sumY) / denom
lnA := (sumY - b * sumX) / n
float cd = math.sqrt((n * sumXX - sumX * sumX) * (n * sumYY - sumY * sumY))
if cd != 0
float r = (n * sumXY - sumX * sumY) / cd
r2 := math.max(0.0, math.min(1.0, r * r))
// ─── Fitted value & residual ──────────────────────────────────────────────────
float fittedLogY = na
if not na(lnA) and not na(b) and not na(lxNow)
fittedLogY := lnA + b * lxNow
float residual = na
if not na(lyNow) and not na(fittedLogY)
residual := lyNow - fittedLogY
// ─── Residual history ─────────────────────────────────────────────────────────
// residuals : chronological insertion order [oldest … newest]
// sortedVals : same values kept sorted ascending via binary-search insertion
// sortedChronIdx : chronological position of each sortedVals entry (for decay weight)
//
// Binary-search insertion: O(log n) search + O(n) shift per bar.
// Total cost O(n²) across all bars — but only once at load time, not per bar.
// Query-time cost: O(n) single pass, called twice per bar.
var residuals = array.new_float()
var sortedVals = array.new_float()
var sortedChronIdx = array.new_int()
if barstate.isconfirmed and not na(residual)
int chronPos = array.size(residuals)
array.push(residuals, residual)
int lo = 0
int hi = array.size(sortedVals)
while lo < hi
int mid = (lo + hi) / 2
if array.get(sortedVals, mid) <= residual
lo := mid + 1
else
hi := mid
array.insert(sortedVals, lo, residual)
array.insert(sortedChronIdx, lo, chronPos)
// ─── Decay-weighted percentile ────────────────────────────────────────────────
// Weight = exp(λ · chronPos), λ = ln(2) / halfLife.
// Oldest residuals (chronPos = 0) get lowest weight — bands narrow over time
// as volatility compresses across halving cycles.
decay_weighted_percentile(sVals, sIdx, pct, halfLife) =>
float result = na
int sz = array.size(sVals)
if sz > 1
float lambda = math.log(2.0) / halfLife
float wSum = 0.0
for i = 0 to sz - 1
wSum += math.exp(lambda * array.get(sIdx, i))
float target = (pct / 100.0) * wSum
float cumW = 0.0
float prevCumW = 0.0
float prevVal = array.get(sVals, 0)
for i = 0 to sz - 1
float val = array.get(sVals, i)
float w = math.exp(lambda * array.get(sIdx, i))
prevCumW := cumW
cumW += w
if cumW >= target
if i == 0 or prevCumW >= target
result := val
else
float frac = (target - prevCumW) / (cumW - prevCumW)
result := prevVal + frac * (val - prevVal)
break
prevVal := val
if na(result)
result := array.get(sVals, sz - 1)
result
// ─── Offsets & structural lines ───────────────────────────────────────────────
float upperOffset = na
float lowerOffset = na
if array.size(sortedVals) > 1
upperOffset := decay_weighted_percentile(sortedVals, sortedChronIdx, upperPct, decayHalfLife)
lowerOffset := decay_weighted_percentile(sortedVals, sortedChronIdx, lowerPct, decayHalfLife)
// All three lines are the same power law — same slope b, shifted intercept ln(a).
// ceilingLnA / floorLnA are directly comparable to Santostasi's published values.
float ceilingLogY = not na(fittedLogY) and not na(upperOffset) ? fittedLogY + upperOffset : na
float floorLogY = not na(fittedLogY) and not na(lowerOffset) ? fittedLogY + lowerOffset : na
float ceilingLnA = not na(lnA) and not na(upperOffset) ? lnA + upperOffset : na
float floorLnA = not na(lnA) and not na(lowerOffset) ? lnA + lowerOffset : na
// ─── Plots ────────────────────────────────────────────────────────────────────
plot(lyNow, title = "log(price)", color = color.white, linewidth = 2)
plot(showFit ? fittedLogY : na, title = "center", color = color.orange, linewidth = 2)
plot(showCeiling ? ceilingLogY : na, title = "ceiling", color = color.red, linewidth = 2)
plot(floorLogY, title = "floor", color = color.lime, linewidth = 2)
plot(showResidual ? residual : na, title = "residual", color = color.aqua, linewidth = 2)
hline(0, "Zero", color = color.new(color.gray, 60))
// ─── Stats label ──────────────────────────────────────────────────────────────
var label stats = na
if barstate.islast
if not na(stats)
label.delete(stats)
stats := na
if showStats and not na(b) and not na(lnA) and not na(r2) and not na(fittedLogY)
string statsText =
"── Power Law ──" +
"\nb = " + str.tostring(math.round(b, 4)) +
" ln(a) = " + str.tostring(math.round(lnA, 4)) +
"\nR² = " + str.tostring(math.round(r2, 4)) +
"\n\nCeiling ln(a) = " + str.tostring(math.round(not na(ceilingLnA) ? ceilingLnA : 0.0, 4)) +
"\nFloor ln(a) = " + str.tostring(math.round(not na(floorLnA) ? floorLnA : 0.0, 4)) +
" (Santostasi ≈ -37)" +
"\n\nCeiling offset = " + str.tostring(math.round(not na(upperOffset) ? upperOffset : 0.0, 4)) +
"\nFloor offset = " + str.tostring(math.round(not na(lowerOffset) ? lowerOffset : 0.0, 4)) +
"\nDecay half-life = " + str.tostring(decayHalfLife) + " bars (~2yr auto)"
stats := label.new(
bar_index, fittedLogY, statsText,
style = label.style_label_left,
textcolor = color.white,
color = color.new(color.black, 0)
)
// ─── Help box ─────────────────────────────────────────────────────────────────
var table helpTbl = table.new(position.top_right, 1, 1)
if barstate.islast
table.clear(helpTbl, 0, 0)
if showHelp
string helpText =
"How to read:\n" +
"• White = log(price)\n" +
"• Orange = center (OLS power law fit)\n" +
"• Red = ceiling (upper percentile)\n" +
"• Lime = floor (lower percentile)\n" +
"• Aqua = residual\n\n" +
"Structure:\n" +
"• All three lines share slope b — only ln(a) differs\n" +
"• Floor = historical support\n" +
"• Center = fair value (OLS)\n" +
"• Ceiling = historical resistance\n" +
"• Floor ln(a) ≈ Santostasi's -37 validates the model\n\n" +
"Parameters:\n" +
"• b ≈ 5.8 (days since Jan 3 2009 genesis block)\n" +
"• R² = 0.95 — power law confirmed as best-fit model\n" +
"• Bands decay-weighted: recent residuals weighted higher\n" +
"• Half-life auto-scales to ~2 years on any timeframe\n\n" +
"Residual:\n" +
"• Near 0 = price near fair value\n" +
"• Positive = stretched toward ceiling\n" +
"• Negative = compressed toward floor"
table.cell(
helpTbl, 0, 0, helpText,
text_color = color.white,
bgcolor = color.new(color.black, 15)
)
Loading…
Comments
Connect your wallet to comment.
Loading…