From 3e96a320b9edeffe617197aca5fa4bc69303031f Mon Sep 17 00:00:00 2001 From: Sam Fredrickson Date: Thu, 14 Nov 2024 20:33:31 -0800 Subject: [PATCH] Implement some basic backtesting. --- model.py | 259 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) diff --git a/model.py b/model.py index 109cbbe..b481104 100644 --- a/model.py +++ b/model.py @@ -70,6 +70,9 @@ 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 = [] @@ -708,6 +711,206 @@ def visualize_cycle_patterns(df, cycle_returns, cycle_volatility): 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_with_cycles( + 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 + ax.semilogy( + training_df["Date"], + training_df["Close"], + "b-", + label=f'Historical Price (Training: {start_date.strftime("%Y-%m-%d")} to {backtest_date.strftime("%Y-%m-%d")})', + 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}%" + ) + 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 + + if __name__ == "__main__": analysis, df = analyze_bitcoin_prices("prices.csv") # create_plots(df) # Full history @@ -717,3 +920,59 @@ if __name__ == "__main__": projections = create_plots(df, start="2011-01-01", project_days=365 * 4) print("\nProjected Prices at Key Points:") print(projections.iloc[[29, 89, 179, 364]].round(2)) # 30, 90, 180, 365 days + + # Create multiple backtests for different periods + backtests = [ + # First to second halving + { + "start_date": "2012-11-28", # First halving + "backtest_date": "2016-07-09", # Second halving + "project_days": 1460, # 4 years + }, + # Second to third halving + { + "start_date": "2016-07-09", # Second halving + "backtest_date": "2020-05-11", # Third halving + "project_days": 1460, # 4 years + }, + # Third halving onwards + { + "start_date": "2020-05-11", # Third halving + "backtest_date": "2024-04-19", # Fourth halving (projected) + "project_days": 1460, # 4 years + }, + { + "start_date": "2016-07-09", # Second halving + "backtest_date": "2024-04-19", # Fourth halving (projected) + "project_days": 1460, # 4 years + }, + ] + + # Run all backtests + for params in backtests: + 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}" + )