diff --git a/justfile b/justfile index 2924b35..1a0d231 100644 --- a/justfile +++ b/justfile @@ -1,11 +1,5 @@ run *name: - python3 ./model.py - @if [[ ! -z "{{ name }}" ]]; then \ - OUT_DIR="output/{{ name }}"; \ - rm -rf "${OUT_DIR}"; \ - mkdir -p "${OUT_DIR}"; \ - mv bitcoin_*.png bitcoin_*.txt "${OUT_DIR}"; \ - fi + python3 ./model.py -n "{{ name }}" fmt: black ./*.py @@ -14,4 +8,4 @@ lint: ruff check ./model.py clean: - rm -f bitcoin_*.png bitcoin_*.txt + rm -rf output diff --git a/model.py b/model.py index d4b95aa..bee0242 100644 --- a/model.py +++ b/model.py @@ -1,10 +1,58 @@ +import argparse import pandas as pd import numpy as np import matplotlib.pyplot as plt +import os import seaborn as sns +import shutil import warnings from datetime import timedelta from multiprocessing import Pool +from typing import BinaryIO + + +# Output + + +class Output: + """ + Output ensures result files get written to the right directory. + + Example: + output = Output("output", "test-1") + with output.create("summary.txt") as f: + # path to f is "output/test-1/summary.txt" + """ + + out_dir: str + + def __init__(self, base_dir: str, name: str): + """ + Initialize the output manager. This will both fully delete any existing + output directory, and then create an empty directory for the output. + + Args: + base_dir: The root directory for outputs + name: The subdir of the root, where the results files go + """ + self.out_dir = os.path.join(base_dir, name) + shutil.rmtree(self.out_dir, ignore_errors=True) + os.makedirs(self.out_dir) + + def create(self, filename) -> BinaryIO: + """ + Create a new file for writing in the output directory. + """ + full_path = self.named(filename) + f = open(full_path, "w") + return f + + def named(self, filename) -> str: + """ + Get the full path within the output directory for a named results file. + """ + full_path = os.path.join(self.out_dir, filename) + return full_path # Utility functions @@ -640,7 +688,7 @@ def analyze_bitcoin_prices(csv_path): # Main plotting functions -def create_plots(df, start=None, end=None, project_days=365): +def create_plots(df, output: Output, start=None, end=None, project_days=365): """ Create enhanced plots including market maturity visualization. """ @@ -832,7 +880,9 @@ def create_plots(df, start=None, end=None, project_days=365): # 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" + filename = output.named( + f"bitcoin_analysis_{start_str}_to_{end_str}_with_projections.png" + ) # Use tight_layout with adjusted parameters plt.tight_layout(pad=2.0) @@ -842,7 +892,7 @@ def create_plots(df, start=None, end=None, project_days=365): return projections -def visualize_cycle_patterns(df, cycle_returns, cycle_volatility): +def visualize_cycle_patterns(df, output: Output, cycle_returns, cycle_volatility): """ Create enhanced visualization of Bitcoin's behavior across halving cycles. """ @@ -1041,12 +1091,17 @@ def visualize_cycle_patterns(df, cycle_returns, cycle_volatility): plt.tight_layout() # Save the plot - plt.savefig("bitcoin_cycle_patterns.png", dpi=300, bbox_inches="tight") + filename = output.named("bitcoin_cycle_patterns.png") + plt.savefig(filename, 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 + df, + output: Output, + 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. @@ -1246,7 +1301,9 @@ def create_backtest_plot( # 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' + filename = output.named( + 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() @@ -1254,11 +1311,11 @@ def create_backtest_plot( def run_projection(args): - df, start = args - _ = create_plots(df, start=start, project_days=365 * 4) + df, start, output = args + _ = create_plots(df, output, start=start, project_days=365 * 4) -def run_projections(df): +def run_projections(df, output: Output): # Create main projection projection_starts = [ "2011-01-01", @@ -1268,7 +1325,7 @@ def run_projections(df): "2015-01-01", "2016-07-09", ] - args = [(df, start) for start in projection_starts] + args = [(df, start, output) for start in projection_starts] with Pool() as pool: pool.map(run_projection, args) @@ -1281,13 +1338,13 @@ def run_single_backtest(args): Args: args: tuple of (params dict, DataFrame) """ - params, df = args + params, df, output = args try: # Create a copy of params without the description backtest_params = params.copy() backtest_params.pop("description", None) - projections, metrics = create_backtest_plot(df, **backtest_params) + projections, metrics = create_backtest_plot(df, output, **backtest_params) # Ensure metrics has all required keys with default values if metrics is None: @@ -1317,7 +1374,9 @@ def run_single_backtest(args): return {"params": params, "error": str(e), "success": False} -def run_systematic_backtests(df, validation_years=2, min_training_years=8): +def run_systematic_backtests( + df, output: Output, validation_years=2, min_training_years=8 +): """ Run a comprehensive suite of backtests with consistent validation periods. Uses sliding windows for both start and end dates. @@ -1463,7 +1522,7 @@ def run_systematic_backtests(df, validation_years=2, min_training_years=8): print(f"- {period['description']}") # Create args tuples with params and DataFrame - args = [(params, df) for params in unique_periods] + args = [(params, df, output) for params in unique_periods] # Use multiprocessing with Pool() as pool: @@ -1517,7 +1576,7 @@ def run_systematic_backtests(df, validation_years=2, min_training_years=8): stress_metrics = calculate_category_metrics(stress_periods_results) # Write detailed results - with open("bitcoin_backtest_results_summary.txt", "w") as f: + with output.create("bitcoin_backtest_results_summary.txt") as f: f.write("Systematic Backtest Results\n") f.write("==========================\n\n") @@ -1601,20 +1660,38 @@ def run_systematic_backtests(df, validation_years=2, min_training_years=8): ) -# if __name__ == "__main__": -# analysis, df = analyze_bitcoin_prices("prices.csv") -# procs = [] -# -# for proc in procs: -# proc.join() -# +# CLI -if __name__ == "__main__": +def get_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="model", + description="Bitcoin price model", + ) + parser.add_argument( + "-o", + "--output", + help="output base directory", + default="./output", + ) + parser.add_argument( + "-n", + "--name", + help="subdir of output base directory", + default="baseline", + ) + return parser.parse_args() + + +def main(): + args = get_args() + global output + output = Output(args.output, args.name) + analysis, df = analyze_bitcoin_prices("prices.csv") - run_projections(df) + run_projections(df, output) normal_metrics, stress_metrics, normal_results, stress_results, failed_tests = ( - run_systematic_backtests(df) + run_systematic_backtests(df, output) ) print("\nAggregate Metrics:") @@ -1631,3 +1708,7 @@ if __name__ == "__main__": for test in failed_tests: print(f"Period: {test['params']['description']}") print(f"Error: {test['error']}\n") + + +if __name__ == "__main__": + main()