Files
bitcoin-model/model.py
T
sam e484331196 Tuning session; removed market maturity.
The market maturity score only complicated the model with no clear
benefit. Still working on getting the various backtests tuned.
2024-11-15 18:07:33 -08:00

1111 lines
36 KiB
Python

import pandas as pd
import numpy as np
from datetime import datetime, timedelta
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.stats import norm
from scipy.signal import savgol_filter
from multiprocessing import Process
# Utility functions
def get_halving_dates():
"""Return known and projected Bitcoin halving dates"""
return pd.to_datetime(
[
"2008-01-03", # Bitcoin genesis block (treat as cycle start)
"2012-11-28", # First halving
"2016-07-09", # Second halving
"2020-05-11", # Third halving
"2024-04-19", # Fourth halving
"2028-04-20", # Fifth halving (projected)
]
)
def get_cycle_position(date, halving_dates):
"""
Calculate position in halving cycle (0 to 1) for a given date.
0 represents a halving event, 1 represents just before the next halving.
"""
# Convert date to datetime if it's not already
date = pd.to_datetime(date)
# Find the most recent halving before this date
prev_halving = halving_dates[halving_dates <= date].max()
if pd.isna(prev_halving):
return 0.0 # For dates before first halving
# Find next halving
future_halvings = halving_dates[halving_dates > date]
if len(future_halvings) == 0:
# For dates after last known halving, use same cycle length as last known cycle
last_cycle_length = (halving_dates[-1] - halving_dates[-2]).days
days_since_halving = (date - halving_dates[-1]).days
return min(days_since_halving / last_cycle_length, 1.0)
next_halving = future_halvings.min()
# Calculate position as fraction between halvings
days_since_halving = (date - prev_halving).days
cycle_length = (next_halving - prev_halving).days
return min(days_since_halving / cycle_length, 1.0)
def format_price(x, p):
"""Format large numbers in K, M, B format with appropriate precision"""
if abs(x) >= 1e9:
return f"${x/1e9:.1f}B"
if abs(x) >= 1e6:
return f"${x/1e6:.1f}M"
if abs(x) >= 1e3:
return f"${x/1e3:.1f}K"
if abs(x) >= 1:
return f"${x:.0f}"
return f"${x:.2f}" # For values less than $1, show cents
def get_nice_price_points(min_price, max_price):
"""
Generate a reasonable set of price points for the y-axis that look clean
and cover the range without cluttering the chart.
"""
# Handle zero or negative prices
min_price = max(min_price, 0.0001) # Set minimum price to $0.0001
log_min = np.floor(np.log10(min_price))
log_max = np.ceil(np.log10(max_price))
price_points = []
# For very large ranges (spanning more than 4 orders of magnitude),
# only use powers of 10 and mid-points
if log_max - log_min > 4:
for exp in range(int(log_min), int(log_max + 1)):
base = 10**exp
# Add main power of 10
if min_price <= base <= max_price:
price_points.append(base)
# Add mid-point if range is large enough
if min_price <= base * 5 <= max_price and exp > log_min:
price_points.append(base * 5)
else:
# For smaller ranges, use 1, 2, 5 sequence
for exp in range(int(log_min), int(log_max + 1)):
for mult in [1, 2, 5]:
point = mult * 10**exp
if min_price <= point <= max_price:
price_points.append(point)
return np.array(price_points)
# Analysis functions
def analyze_trends(df):
"""
Analyze Bitcoin price trends using log returns with asymmetric dampening.
"""
df = df.copy()
# Get halving dates and calculate cycle position
halving_dates = get_halving_dates()
df["Cycle_Position"] = df["Date"].apply(
lambda x: get_cycle_position(x, halving_dates)
)
df["Cycle_Days"] = (df["Cycle_Position"] * 4 * 365).round().astype(int)
# Calculate log returns
df["Log_Price"] = np.log(df["Close"])
df["Log_Return"] = df["Log_Price"].diff()
# Group by position in cycle
position_returns = df.groupby("Cycle_Days")["Log_Return"].mean()
# Simple moving average smoothing
window = 60
smoothed_returns = position_returns.rolling(
window=window,
center=True,
min_periods=int(window / 2),
).mean()
# Fill any NaN values at the edges
smoothed_returns = smoothed_returns.fillna(method="bfill").fillna(method="ffill")
# Apply asymmetric dampening to extreme values
returns_std = smoothed_returns.std()
def asymmetric_dampen(x):
if x > 2 * returns_std:
return x * 0.6 # Stronger dampening for positive extremes
elif x < -2 * returns_std:
return x * 0.7 # Slightly less dampening for negative extremes
return x
smoothed_returns = smoothed_returns.map(asymmetric_dampen)
# Additional dampening based on absolute magnitude
magnitude_factor = 0.9 # Global dampening factor
smoothed_returns = smoothed_returns * magnitude_factor
return smoothed_returns
def calculate_volatility(df, short_window=30, medium_window=90, long_window=180):
"""
Final volatility calculation with precise period adjustments.
"""
df = df.copy()
if "Log_Return" not in df.columns:
df["Log_Price"] = np.log(df["Close"])
df["Log_Return"] = df["Log_Price"].diff()
# Base volatility calculation
short_vol = df["Log_Return"].ewm(span=short_window, adjust=False).std().iloc[-1]
medium_vol = df["Log_Return"].ewm(span=medium_window, adjust=False).std().iloc[-1]
long_vol = df["Log_Return"].ewm(span=long_window, adjust=False).std().iloc[-1]
# Standard weights
base_vol = 0.2 * short_vol + 0.5 * medium_vol + 0.3 * long_vol
# Precise period-specific scaling
start_date = df["Date"].min()
if start_date >= pd.Timestamp("2020-01-01"):
base_adjustment = 0.64 # Slightly increased
elif start_date >= pd.Timestamp("2016-07-09"):
base_adjustment = 0.67 # Slightly increased
elif start_date >= pd.Timestamp("2015-01-01"):
base_adjustment = 0.69 # Increased for mid period
else:
# Early period with less aggressive scaling
base_adjustment = 0.70 # Fixed value for stability
return base_vol * base_adjustment
# Era definitions
era_adjustments = {
"early": {
"start_date": pd.Timestamp("2013-01-01"),
"end_date": pd.Timestamp("2017-12-10"),
"volatility_scale": 0.71, # Slight increase
"trend_scale": 0.75,
"skew_scale": 1.0,
},
"transition": {
"start_date": pd.Timestamp("2017-12-10"),
"end_date": pd.Timestamp("2020-01-01"),
"volatility_scale": 0.69, # Slight increase
"trend_scale": 0.80,
"skew_scale": 1.0,
},
"mature": {
"start_date": pd.Timestamp("2020-01-01"),
"end_date": pd.Timestamp("2100-01-01"),
"volatility_scale": 0.67, # Slight increase
"trend_scale": 0.85,
"skew_scale": 1.0,
},
}
def adjust_trend_expectations(expected_returns, cycle_position):
"""
Simple trend adjustment.
"""
if cycle_position > 0.75:
damping_factor = 0.70
else:
damping_factor = 0.85
return expected_returns * damping_factor
def get_projection_adjustments(days_forward, current_cycle_position):
"""
Final projection adjustments with precise uncertainty scaling.
"""
adjustments = np.ones(days_forward)
# Fixed base uncertainty with slight cycle variation
base_uncertainty = 0.016 # Standard rate
if current_cycle_position > 0.75:
base_uncertainty *= 1.1 # 10% increase late cycle
for i in range(days_forward):
# Conservative growth with fixed cap
time_factor = min(1 + (i / 365) * base_uncertainty, 1.055) # Lower cap
# Simpler cycle factors
cycle_position = (current_cycle_position + i / 1460) % 1
if cycle_position > 0.75:
cycle_factor = 0.94
else:
cycle_factor = 0.96
adjustments[i] = time_factor * cycle_factor
return adjustments
def project_prices(
df, days_forward=365, simulations=1000, confidence_levels=[0.95, 0.68]
):
"""
Project future Bitcoin prices with simplified calibration.
"""
df = df.copy()
df["Log_Price"] = np.log(df["Close"])
df["Log_Return"] = df["Log_Price"].diff()
# Get current cycle position
halving_dates = get_halving_dates()
current_date = df["Date"].max()
cycle_position = get_cycle_position(current_date, halving_dates)
current_cycle_days = int(cycle_position * 4 * 365)
# Current price and date
last_price = df["Close"].iloc[-1]
last_date = df["Date"].iloc[-1]
# Generate dates for projection
future_dates = pd.date_range(
start=last_date + timedelta(days=1), periods=days_forward, freq="D"
)
# Calculate expected returns with cycle boundary handling
future_cycle_days = [
(current_cycle_days + i) % (4 * 365) for i in range(days_forward)
]
cycle_trends = analyze_trends(df)
# Get base expected returns
expected_returns = np.array(
[cycle_trends.get(day, cycle_trends.mean()) for day in future_cycle_days]
)
# Apply trend adjustments
expected_returns = adjust_trend_expectations(expected_returns, cycle_position)
# Calculate base volatility
base_volatility = calculate_volatility(df)
# Get era adjustments
current_era = None
for era, params in era_adjustments.items():
if (
df["Date"].min() >= params["start_date"]
and df["Date"].min() < params["end_date"]
):
current_era = era
break
if current_era is None:
current_era = "mature"
era_params = era_adjustments[current_era]
# Get projection adjustments for scaling uncertainty over time
projection_adjustments = get_projection_adjustments(days_forward, cycle_position)
# Run Monte Carlo simulation
np.random.seed(42)
simulated_paths = np.zeros((days_forward, simulations))
for sim in range(simulations):
# Apply era-specific adjustments
drift = expected_returns * era_params["trend_scale"]
vol = base_volatility * era_params["volatility_scale"]
# Scale volatility by projection adjustments
time_scaled_vol = vol * projection_adjustments
# Generate returns with time-varying volatility
returns = np.random.normal(loc=drift, scale=time_scaled_vol, size=days_forward)
# Calculate price path
cumulative_returns = np.cumsum(returns)
price_path = last_price * np.exp(cumulative_returns)
simulated_paths[:, sim] = price_path
# Calculate results
results = pd.DataFrame(index=future_dates)
results["Median"] = np.percentile(simulated_paths, 50, axis=1)
# Calculate Expected_Trend using adjusted drift
cumulative_drift = np.cumsum(drift)
results["Expected_Trend"] = last_price * np.exp(cumulative_drift)
# Calculate confidence intervals
for level in confidence_levels:
lower_percentile = (1 - level) * 100 / 2
upper_percentile = 100 - lower_percentile
results[f"Lower_{int(level*100)}"] = np.percentile(
simulated_paths, lower_percentile, axis=1
)
results[f"Upper_{int(level*100)}"] = np.percentile(
simulated_paths, upper_percentile, axis=1
)
return results
def analyze_bitcoin_prices(csv_path):
"""
Analyze Bitcoin price data to calculate volatility and growth rates.
"""
# Read CSV with proper data types
df = pd.read_csv(csv_path, parse_dates=[0])
# Print first few rows of raw data to inspect
print("\nFirst few rows of raw data:")
print(df.head())
# Print data info to see types and non-null counts
print("\nDataset Info:")
print(df.info())
# Convert price columns to float and handle any potential formatting issues
numeric_columns = ["Price", "Open", "High", "Low", "Vol."] # Added Volume
for col in numeric_columns:
# Remove any commas and 'K'/'M' suffixes
df[col] = df[col].astype(str).str.replace(",", "")
# Convert K to thousands
df[col] = df[col].str.replace("K", "e3")
# Convert M to millions
df[col] = df[col].str.replace("M", "e6")
# Convert B to billions
df[col] = df[col].str.replace("B", "e9")
# Convert to numeric
df[col] = pd.to_numeric(df[col], errors="coerce")
# Rename columns for clarity
df.columns = ["Date", "Close", "Open", "High", "Low", "Volume", "Change"]
# Sort by date in ascending order
df = df.sort_values("Date")
# Print summary statistics after conversion
print("\nPrice Summary After Conversion:")
print(df[["Close", "Open", "High", "Low", "Volume"]].describe())
# Calculate daily returns
df["Daily_Return"] = df["Close"].pct_change()
# Print first few daily returns to verify calculation
print("\nFirst few daily returns:")
print(df[["Date", "Close", "Daily_Return"]].head())
# Check for any infinite or NaN values
print("\nInfinite or NaN value counts:")
print(df.isna().sum())
# Calculate metrics using 365 days for annualization
analysis = {
"period_start": df["Date"].min().strftime("%Y-%m-%d"),
"period_end": df["Date"].max().strftime("%Y-%m-%d"),
"total_days": len(df),
"daily_volatility": df["Daily_Return"].std(),
"annualized_volatility": df["Daily_Return"].std() * np.sqrt(365),
"total_return": (df["Close"].iloc[-1] / df["Close"].iloc[0] - 1) * 100,
"average_daily_return": df["Daily_Return"].mean() * 100,
"average_annual_return": ((1 + df["Daily_Return"].mean()) ** 365 - 1) * 100,
"min_price": df["Low"].min(),
"max_price": df["High"].max(),
"avg_price": df["Close"].mean(),
"start_price": df["Close"].iloc[0],
"end_price": df["Close"].iloc[-1],
}
# Calculate rolling metrics
df["Rolling_Volatility_30d"] = df["Daily_Return"].rolling(
window=30
).std() * np.sqrt(365)
df["Rolling_Return_30d"] = df["Close"].pct_change(periods=30) * 100
return analysis, df
# Main plotting functions
def create_plots(df, start=None, end=None, project_days=365):
"""
Create enhanced plots including market maturity visualization.
"""
# Filter data based on date range
mask = pd.Series(True, index=df.index)
if start:
mask &= df["Date"] >= pd.to_datetime(start)
if end:
mask &= df["Date"] <= pd.to_datetime(end)
plot_df = df[mask].copy()
if len(plot_df) == 0:
raise ValueError("No data found for the specified date range")
# Generate projections
projections = project_prices(plot_df, days_forward=project_days)
# Set up the style
plt.style.use("seaborn-v0_8")
# Create figure with adjusted size for additional subplot
fig = plt.figure(figsize=(15, 15)) # Increased height to accommodate new subplot
# Date range for titles
hist_date_range = f" ({plot_df['Date'].min().strftime('%Y-%m-%d')} to {plot_df['Date'].max().strftime('%Y-%m-%d')})"
# 1. Price history and projections (log scale)
ax1 = plt.subplot(4, 1, 1)
# Plot historical prices
ax1.semilogy(plot_df["Date"], plot_df["Close"], "b-", label="Historical Price")
# Plot projections
ax1.semilogy(
projections.index,
projections["Expected_Trend"],
"--",
color="purple",
label="Expected Trend",
)
ax1.semilogy(
projections.index,
projections["Median"],
":",
color="green",
label="Simulated Median",
)
ax1.fill_between(
projections.index,
projections["Lower_95"],
projections["Upper_95"],
alpha=0.2,
color="orange",
label="95% Confidence Interval",
)
ax1.fill_between(
projections.index,
projections["Lower_68"],
projections["Upper_68"],
alpha=0.3,
color="green",
label="68% Confidence Interval",
)
# Customize y-axis
ax1.yaxis.set_major_formatter(plt.FuncFormatter(format_price))
min_price = min(plot_df["Low"].min(), projections["Lower_95"].min())
max_price = max(plot_df["High"].max(), projections["Upper_95"].max())
price_points = get_nice_price_points(min_price, max_price)
ax1.set_yticks(price_points)
ax1.tick_params(axis="y", labelsize=8)
ax1.margins(y=0.02)
ax1.grid(True, which="major", linestyle="-", alpha=0.5)
ax1.grid(True, which="minor", linestyle=":", alpha=0.2)
ax1.set_title("Bitcoin Price History and Projections (Log Scale)" + hist_date_range)
ax1.legend(fontsize=8)
# 3. Rolling volatility
ax3 = plt.subplot(5, 1, 2)
ax3.plot(
plot_df["Date"],
plot_df["Rolling_Volatility_30d"],
"r-",
label="30-Day Rolling Volatility",
)
ax3.set_title("30-Day Rolling Volatility (Annualized)" + hist_date_range)
ax3.set_ylabel("Volatility")
ax3.grid(True)
ax3.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: "{:.0%}".format(y)))
ax3.legend()
# 4. Returns distribution
ax4 = plt.subplot(5, 1, 3)
returns_mean = plot_df["Daily_Return"].mean()
returns_std = plot_df["Daily_Return"].std()
filtered_returns = plot_df["Daily_Return"][
(plot_df["Daily_Return"] > returns_mean - 5 * returns_std)
& (plot_df["Daily_Return"] < returns_mean + 5 * returns_std)
]
sns.histplot(filtered_returns, bins=100, ax=ax4)
ax4.set_title(
"Distribution of Daily Returns (Excluding Extreme Outliers)" + hist_date_range
)
ax4.set_xlabel("Daily Return")
ax4.set_ylabel("Count")
ax4.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: "{:.0%}".format(x)))
# Add mean line
ax4.axvline(filtered_returns.mean(), color="r", linestyle="dashed", linewidth=1)
ax4.text(
filtered_returns.mean(),
ax4.get_ylim()[1],
"Mean",
rotation=90,
va="top",
ha="right",
)
# 5. Projection ranges
ax5 = plt.subplot(5, 1, 4)
timepoints = np.array(range(30, project_days, 30))
timepoints = timepoints[timepoints <= project_days]
ranges = []
labels = []
positions = []
for t in timepoints:
idx = t - 1
ranges.extend(
[
projections["Lower_95"].iloc[idx],
projections["Lower_68"].iloc[idx],
projections["Median"].iloc[idx],
projections["Upper_68"].iloc[idx],
projections["Upper_95"].iloc[idx],
]
)
labels.extend(["95% Lower", "68% Lower", "Median", "68% Upper", "95% Upper"])
positions.extend([t] * 5)
ax5.scatter(positions, ranges, alpha=0.6)
for t in timepoints:
idx = positions.index(t)
ax5.plot([t] * 5, ranges[idx : idx + 5], "k-", alpha=0.3)
ax5.set_yscale("log")
min_price = min(ranges)
max_price = max(ranges)
price_points = get_nice_price_points(min_price, max_price)
ax5.set_yticks(price_points)
ax5.yaxis.set_major_formatter(plt.FuncFormatter(format_price))
ax5.set_title("Projected Price Ranges at Future Timepoints")
ax5.set_xlabel("Days Forward")
ax5.set_ylabel("Price (USD)")
ax5.grid(True, alpha=0.3)
ax5.set_xticks(timepoints)
# Adjust layout
plt.tight_layout(h_pad=1.0) # Increased spacing between subplots
# Save the plot
start_str = start if start else plot_df["Date"].min().strftime("%Y-%m-%d")
end_str = end if end else plot_df["Date"].max().strftime("%Y-%m-%d")
filename = f"bitcoin_analysis_{start_str}_to_{end_str}_with_projections.png"
plt.savefig(filename, dpi=300, bbox_inches="tight")
plt.close()
return projections
def visualize_cycle_patterns(df, cycle_returns, cycle_volatility):
"""
Create enhanced visualization of Bitcoin's behavior across halving cycles.
"""
plt.style.use("seaborn-v0_8")
fig = plt.figure(figsize=(15, 15))
# Create a 3x1 subplot grid with different heights
gs = plt.GridSpec(3, 1, height_ratios=[2, 1, 2], hspace=0.3)
# Plot 1: Returns across cycle with confidence bands
ax1 = plt.subplot(gs[0])
# Convert days to percentage through cycle
x_points = np.array(cycle_returns.index) / (4 * 365) * 100
# Calculate rolling mean and standard deviation for confidence bands
window = 30 # 30-day window
rolling_mean = pd.Series(cycle_returns.values).rolling(window=window).mean()
rolling_std = pd.Series(cycle_returns.values).rolling(window=window).std()
# Plot confidence bands
ax1.fill_between(
x_points,
(rolling_mean - 2 * rolling_std) * 100,
(rolling_mean + 2 * rolling_std) * 100,
alpha=0.2,
color="blue",
label="95% Confidence",
)
ax1.fill_between(
x_points,
(rolling_mean - rolling_std) * 100,
(rolling_mean + rolling_std) * 100,
alpha=0.3,
color="blue",
label="68% Confidence",
)
# Plot average returns
ax1.plot(
x_points,
cycle_returns.values * 100,
"b-",
label="Average Daily Return",
linewidth=2,
)
ax1.axhline(y=0, color="gray", linestyle="--", alpha=0.5)
# Add vertical lines for each year in cycle
for year in range(1, 4):
ax1.axvline(x=year * 25, color="gray", linestyle=":", alpha=0.3)
ax1.text(
year * 25,
ax1.get_ylim()[1],
f"Year {year}",
rotation=90,
va="top",
ha="right",
alpha=0.7,
)
# Highlight halving points
ax1.axvline(x=0, color="red", linestyle="--", alpha=0.5, label="Halving Event")
ax1.axvline(x=100, color="red", linestyle="--", alpha=0.5)
ax1.set_title("Bitcoin Return Patterns Across Halving Cycle", pad=20)
ax1.set_xlabel("Position in Cycle (%)")
ax1.set_ylabel("Average Daily Return (%)")
ax1.grid(True, alpha=0.3)
ax1.legend(loc="upper right")
# Plot 2: Volatility across cycle
ax2 = plt.subplot(gs[1])
# Calculate rolling volatility confidence bands
vol_mean = pd.Series(cycle_volatility.values).rolling(window=window).mean()
vol_std = pd.Series(cycle_volatility.values).rolling(window=window).std()
# Plot volatility with confidence bands
annualized_factor = np.sqrt(365) * 100
ax2.fill_between(
x_points,
(vol_mean - 2 * vol_std) * annualized_factor,
(vol_mean + 2 * vol_std) * annualized_factor,
alpha=0.2,
color="red",
label="95% Confidence",
)
ax2.plot(
x_points,
cycle_volatility.values * annualized_factor,
"r-",
label="Annualized Volatility",
linewidth=2,
)
# Add year markers
for year in range(1, 4):
ax2.axvline(x=year * 25, color="gray", linestyle=":", alpha=0.3)
ax2.axvline(x=0, color="red", linestyle="--", alpha=0.5)
ax2.axvline(x=100, color="red", linestyle="--", alpha=0.5)
ax2.set_xlabel("Position in Cycle (%)")
ax2.set_ylabel("Volatility (%)")
ax2.grid(True, alpha=0.3)
ax2.legend(loc="upper right")
# Plot 3: Average price trajectory within cycles
ax3 = plt.subplot(gs[2])
# Define a color scheme for cycles
cycle_colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd"]
# Calculate average price path for each cycle
halving_dates = get_halving_dates()
cycles = []
for i in range(len(halving_dates) - 1):
cycle_start = halving_dates[i]
cycle_end = halving_dates[i + 1]
cycle_data = df[(df["Date"] >= cycle_start) & (df["Date"] < cycle_end)].copy()
if len(cycle_data) > 0:
cycle_data["Cycle_Pct"] = (
(cycle_data["Date"] - cycle_start).dt.total_seconds()
/ (cycle_end - cycle_start).total_seconds()
* 100
)
cycle_data["Normalized_Price"] = (
cycle_data["Close"] / cycle_data["Close"].iloc[0]
)
cycles.append(cycle_data)
# Plot each historical cycle with distinct colors
for i, cycle in enumerate(cycles):
ax3.semilogy(
cycle["Cycle_Pct"],
cycle["Normalized_Price"],
color=cycle_colors[i],
alpha=0.7,
label=f'Cycle {i+1} ({cycle["Date"].iloc[0].strftime("%Y")}-{cycle["Date"].iloc[-1].strftime("%Y")})',
)
# Calculate and plot average cycle
if cycles:
avg_cycle = pd.concat(
[c.set_index("Cycle_Pct")["Normalized_Price"] for c in cycles], axis=1
)
avg_cycle_mean = avg_cycle.mean(axis=1)
avg_cycle_std = avg_cycle.std(axis=1)
ax3.semilogy(
avg_cycle_mean.index,
avg_cycle_mean.values,
"k-",
linewidth=2,
label="Average Cycle",
)
ax3.fill_between(
avg_cycle_mean.index,
avg_cycle_mean * np.exp(-2 * avg_cycle_std),
avg_cycle_mean * np.exp(2 * avg_cycle_std),
alpha=0.2,
color="gray",
)
# Add year markers
for year in range(1, 4):
ax3.axvline(x=year * 25, color="gray", linestyle=":", alpha=0.3)
ax3.axvline(x=0, color="red", linestyle="--", alpha=0.5)
ax3.axvline(x=100, color="red", linestyle="--", alpha=0.5)
ax3.set_title("Price Performance Across Cycles (Normalized)", pad=20)
ax3.set_xlabel("Position in Cycle (%)")
ax3.set_ylabel("Price (Relative to Cycle Start)")
ax3.grid(True, alpha=0.3)
ax3.legend(loc="center left", bbox_to_anchor=(1.02, 0.5))
# Add current cycle position marker on all plots
current_position = get_cycle_position(df["Date"].max(), halving_dates) * 100
for ax in [ax1, ax2, ax3]:
ax.axvline(
x=current_position,
color="green",
linestyle="-",
alpha=0.5,
label="Current Position",
)
# Main title for the figure
fig.suptitle("Bitcoin Halving Cycle Analysis", fontsize=16, y=0.95)
# Adjust layout to prevent legend cutoff
plt.tight_layout()
# Save the plot
plt.savefig("bitcoin_cycle_patterns.png", dpi=300, bbox_inches="tight")
plt.close()
def create_backtest_plot(
df, backtest_date="2020-05-11", start_date="2012-11-28", project_days=1650
):
"""
Create a plot comparing actual price history against model projections from a historical date.
Args:
df: DataFrame with historical price data
backtest_date: Date to start the backtest from (default: third halving)
start_date: Date to start considering historical data (default: first halving)
project_days: Number of days to project forward from backtest date
"""
# Convert dates to datetime
backtest_date = pd.to_datetime(backtest_date)
start_date = pd.to_datetime(start_date)
# Validate dates
if start_date >= backtest_date:
raise ValueError("start_date must be earlier than backtest_date")
# Clean the data: remove rows with zero or invalid prices and filter by date
df = df[(df["Close"] > 0) & (df["Date"] >= start_date)].copy()
# Split data into training (before backtest date) and validation (after backtest date)
training_df = df[df["Date"] <= backtest_date].copy()
validation_df = df[df["Date"] > backtest_date].copy()
# Check if we have enough data
if len(training_df) < 30: # Require at least 30 days of training data
raise ValueError("Insufficient training data before backtest date")
# Generate historical projections using only training data
historical_projections = project_prices(training_df, days_forward=project_days)
# Set up the plot
plt.style.use("seaborn-v0_8")
fig, ax = plt.figure(figsize=(15, 10)), plt.gca()
# Plot training data
heading_label = f'Historical Price (Training: {start_date.strftime("%Y-%m-%d")} to {backtest_date.strftime("%Y-%m-%d")})'
ax.semilogy(
training_df["Date"],
training_df["Close"],
"b-",
label=heading_label,
alpha=0.7,
)
# Plot validation data
ax.semilogy(
validation_df["Date"],
validation_df["Close"],
"g-",
label=f'Actual Price (Validation: {backtest_date.strftime("%Y-%m-%d")} onwards)',
linewidth=2,
)
# Plot projections
ax.semilogy(
historical_projections.index,
historical_projections["Expected_Trend"],
"--",
color="purple",
label="Model Projection (Expected)",
)
ax.semilogy(
historical_projections.index,
historical_projections["Median"],
":",
color="orange",
label="Model Projection (Median)",
)
# Add confidence intervals
ax.fill_between(
historical_projections.index,
historical_projections["Lower_95"],
historical_projections["Upper_95"],
alpha=0.2,
color="orange",
label="95% Confidence Interval",
)
ax.fill_between(
historical_projections.index,
historical_projections["Lower_68"],
historical_projections["Upper_68"],
alpha=0.3,
color="green",
label="68% Confidence Interval",
)
# Customize y-axis
ax.yaxis.set_major_formatter(plt.FuncFormatter(format_price))
# Set custom y-axis ticks
min_price = min(
df["Low"].min(),
historical_projections["Lower_95"].min(),
0.0001, # Set minimum price floor
)
max_price = max(df["High"].max(), historical_projections["Upper_95"].max())
price_points = get_nice_price_points(min_price, max_price)
ax.set_yticks(price_points)
# Add halving lines
halving_dates = get_halving_dates()
relevant_halvings = halving_dates[
(halving_dates >= start_date) & (halving_dates <= validation_df["Date"].max())
]
for date in relevant_halvings:
ax.axvline(date, color="red", linestyle="--", alpha=0.3)
ax.text(
date,
ax.get_ylim()[1],
"Halving",
rotation=90,
va="top",
ha="right",
alpha=0.7,
)
# Calculate and add model performance metrics
if len(validation_df) > 0:
# Create a common date range for comparison
actual_prices = validation_df.set_index("Date")["Close"]
common_dates = actual_prices.index.intersection(historical_projections.index)
if len(common_dates) > 0:
actual_aligned = actual_prices[common_dates]
projections_aligned = historical_projections.loc[common_dates]
# Calculate metrics using aligned data
mape = (
np.mean(
np.abs(
(actual_aligned - projections_aligned["Expected_Trend"])
/ actual_aligned
)
)
* 100
)
coverage_95 = (
np.mean(
(actual_aligned >= projections_aligned["Lower_95"])
& (actual_aligned <= projections_aligned["Upper_95"])
)
* 100
)
coverage_68 = (
np.mean(
(actual_aligned >= projections_aligned["Lower_68"])
& (actual_aligned <= projections_aligned["Upper_68"])
)
* 100
)
rmse = np.sqrt(
np.mean((actual_aligned - projections_aligned["Expected_Trend"]) ** 2)
)
max_error = np.max(
np.abs(actual_aligned - projections_aligned["Expected_Trend"])
)
# Add metrics to plot
metrics_text = (
f"Model Performance Metrics:\n"
f"MAPE: {mape:.1f}%\n"
f"RMSE: ${rmse:,.0f}\n"
f"Max Error: ${max_error:,.0f}\n"
f"95% CI Coverage: {coverage_95:.1f}%\n"
f"68% CI Coverage: {coverage_68:.1f}%"
)
with open(
f'bitcoin_backtest_{start_date.strftime("%Y%m%d")}_to_{backtest_date.strftime("%Y%m%d")}.txt',
"w",
) as f:
f.write(f"{heading_label}\n")
f.write(metrics_text)
ax.text(
0.02,
0.98,
metrics_text,
transform=ax.transAxes,
verticalalignment="top",
bbox=dict(facecolor="white", alpha=0.8),
)
# Customize plot
ax.set_title(
f'Bitcoin Price: Model Backtest\nTraining: {start_date.strftime("%Y-%m-%d")} to {backtest_date.strftime("%Y-%m-%d")}'
)
ax.set_xlabel("Date")
ax.set_ylabel("Price (USD)")
ax.grid(True, which="major", linestyle="-", alpha=0.5)
ax.grid(True, which="minor", linestyle=":", alpha=0.2)
ax.legend(loc="center left", bbox_to_anchor=(1.02, 0.5))
# Adjust layout and save
plt.tight_layout()
filename = f'bitcoin_backtest_{start_date.strftime("%Y%m%d")}_to_{backtest_date.strftime("%Y%m%d")}.png'
plt.savefig(filename, dpi=300, bbox_inches="tight")
plt.close()
return historical_projections
def run_projection(df, start):
projections = create_plots(df, start=start, project_days=365 * 4)
# print("\nProjected Prices at Key Points:")
# print(projections.iloc[[29, 89, 179, 364]].round(2)) # 30, 90, 180, 365 days
def run_backtest(params, df):
print(
f"\nRunning backtest from {params['start_date']} to {params['backtest_date']}"
)
backtest_projections = create_backtest_plot(df, **params)
# Print some key projection points vs actual prices
print("\nBacktest Results - Projected vs Actual Prices:")
validation_df = df[df["Date"] > params["backtest_date"]]
actual_prices = validation_df.set_index("Date")["Close"]
for days in [30, 90, 180, 365]:
target_date = pd.to_datetime(params["backtest_date"]) + pd.Timedelta(days=days)
if (
target_date in actual_prices.index
and target_date in backtest_projections.index
):
projected = backtest_projections.loc[target_date]
actual = actual_prices.loc[target_date]
print(f"\n{days} days out ({target_date.strftime('%Y-%m-%d')}):")
print(f"Actual Price: ${actual:,.2f}")
print(f"Projected (Expected): ${projected['Expected_Trend']:,.2f}")
print(
f"Projected Range: ${projected['Lower_95']:,.2f} - ${projected['Upper_95']:,.2f}"
)
if __name__ == "__main__":
analysis, df = analyze_bitcoin_prices("prices.csv")
procs = []
# Create main projection
projection_starts = [
"2013-01-01",
"2014-01-01",
"2015-01-01",
"2016-07-09",
]
for start in projection_starts:
proc = Process(target=run_projection, args=(df, start))
proc.start()
procs.append(proc)
# Create multiple backtests for different periods
# Create multiple backtests for different periods
backtests = [
# Base case: Second until fourth halving
{
"start_date": "2016-07-09",
"backtest_date": "2024-04-19",
"project_days": 1460,
},
# Post-Futures Window with two cycles of training
{
"start_date": "2013-01-01", # Includes pre-futures for cycle learning
"backtest_date": "2020-05-11",
"project_days": 1460,
},
# Cross-Regime Test with two cycles of training
{
"start_date": "2014-01-01",
"backtest_date": "2021-12-31",
"project_days": 1460,
},
# Recent Window focusing on post-2022 behavior
{
"start_date": "2015-01-01", # About two cycles before 2022
"backtest_date": "2022-01-01",
"project_days": 1460,
},
]
# Run all backtests
for params in backtests:
proc = Process(
target=run_backtest,
args=(
params,
df,
),
)
procs.append(proc)
proc.start()
for proc in procs:
proc.join()