The market maturity score only complicated the model with no clear benefit. Still working on getting the various backtests tuned.
1111 lines
36 KiB
Python
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()
|