| |
| """ |
| Backtest evaluation for OpenMedallion-FinTS models. |
| |
| Runs walk-forward or expanding window backtesting to evaluate model |
| performance on realistic out-of-sample data with temporal validation. |
| """ |
|
|
| import argparse |
| import json |
| from pathlib import Path |
| from typing import Dict, List, Tuple |
| import warnings |
|
|
| import joblib |
| import numpy as np |
| import pandas as pd |
| from tqdm import tqdm |
|
|
| from openmedallion_fints import ( |
| load_asset_class, |
| build_features, |
| make_target, |
| make_direction_target, |
| walk_forward_split, |
| expanding_window_split, |
| LGBMForecaster, |
| calculate_all_metrics, |
| print_metrics_report |
| ) |
|
|
| warnings.filterwarnings('ignore') |
|
|
|
|
| def backtest_walk_forward( |
| df: pd.DataFrame, |
| model_type: str, |
| lookback: int, |
| horizon: int, |
| window_size: int, |
| step_size: int, |
| **model_kwargs |
| ) -> Tuple[np.ndarray, np.ndarray, List[Dict]]: |
| """ |
| Walk-forward backtesting. |
| |
| Returns: |
| predictions, actuals, fold_metrics |
| """ |
| all_preds = [] |
| all_actuals = [] |
| fold_metrics = [] |
| |
| splits = walk_forward_split(df, window_size=window_size, step_size=step_size) |
| |
| print(f"Running {len(splits)} walk-forward folds...") |
| |
| for fold_idx, (train_idx, test_idx) in enumerate(tqdm(splits, desc="Backtesting")): |
| train_df = df.iloc[train_idx].copy() |
| test_df = df.iloc[test_idx].copy() |
| |
| |
| train_features = build_features(train_df, lookback=lookback) |
| test_features = build_features(test_df, lookback=lookback) |
| |
| |
| train_y = make_target(train_df, horizon=horizon) |
| test_y = make_target(test_df, horizon=horizon) |
| |
| train_dir_y = make_direction_target(train_df, horizon=horizon) |
| test_dir_y = make_direction_target(test_df, horizon=horizon) |
| |
| |
| train_features, train_y, train_dir_y = ( |
| train_features.iloc[lookback:].reset_index(drop=True), |
| train_y.iloc[lookback:].reset_index(drop=True), |
| train_dir_y.iloc[lookback:].reset_index(drop=True) |
| ) |
| |
| test_features, test_y, test_dir_y = ( |
| test_features.iloc[lookback:].reset_index(drop=True), |
| test_y.iloc[lookback:].reset_index(drop=True), |
| test_dir_y.iloc[lookback:].reset_index(drop=True) |
| ) |
| |
| |
| train_mask = ~(train_features.isna().any(axis=1) | train_y.isna() | train_dir_y.isna()) |
| test_mask = ~(test_features.isna().any(axis=1) | test_y.isna() | test_dir_y.isna()) |
| |
| X_train = train_features[train_mask] |
| y_train = train_y[train_mask] |
| dir_train = train_dir_y[train_mask] |
| |
| X_test = test_features[test_mask] |
| y_test = test_y[test_mask] |
| dir_test = test_dir_y[test_mask] |
| |
| if len(X_train) < 50 or len(X_test) < 10: |
| continue |
| |
| |
| if model_type == 'lgbm': |
| model = LGBMForecaster(task='regression', **model_kwargs) |
| model.fit(X_train, y_train) |
| preds = model.predict(X_test) |
| else: |
| raise ValueError(f"Unknown model type: {model_type}") |
| |
| |
| all_preds.extend(preds) |
| all_actuals.extend(y_test.values) |
| |
| |
| fold_met = calculate_all_metrics(y_test.values, preds, dir_test.values) |
| fold_metrics.append({ |
| 'fold': fold_idx, |
| 'train_size': len(X_train), |
| 'test_size': len(X_test), |
| **fold_met |
| }) |
| |
| return np.array(all_preds), np.array(all_actuals), fold_metrics |
|
|
|
|
| def backtest_expanding_window( |
| df: pd.DataFrame, |
| model_type: str, |
| lookback: int, |
| horizon: int, |
| initial_train: int, |
| step_size: int, |
| **model_kwargs |
| ) -> Tuple[np.ndarray, np.ndarray, List[Dict]]: |
| """ |
| Expanding window backtesting. |
| |
| Returns: |
| predictions, actuals, fold_metrics |
| """ |
| all_preds = [] |
| all_actuals = [] |
| fold_metrics = [] |
| |
| splits = expanding_window_split(df, initial_train=initial_train, step_size=step_size) |
| |
| print(f"Running {len(splits)} expanding window folds...") |
| |
| for fold_idx, (train_idx, test_idx) in enumerate(tqdm(splits, desc="Backtesting")): |
| train_df = df.iloc[train_idx].copy() |
| test_df = df.iloc[test_idx].copy() |
| |
| |
| train_features = build_features(train_df, lookback=lookback) |
| test_features = build_features(test_df, lookback=lookback) |
| |
| |
| train_y = make_target(train_df, horizon=horizon) |
| test_y = make_target(test_df, horizon=horizon) |
| |
| train_dir_y = make_direction_target(train_df, horizon=horizon) |
| test_dir_y = make_direction_target(test_df, horizon=horizon) |
| |
| |
| train_features, train_y, train_dir_y = ( |
| train_features.iloc[lookback:].reset_index(drop=True), |
| train_y.iloc[lookback:].reset_index(drop=True), |
| train_dir_y.iloc[lookback:].reset_index(drop=True) |
| ) |
| |
| test_features, test_y, test_dir_y = ( |
| test_features.iloc[lookback:].reset_index(drop=True), |
| test_y.iloc[lookback:].reset_index(drop=True), |
| test_dir_y.iloc[lookback:].reset_index(drop=True) |
| ) |
| |
| |
| train_mask = ~(train_features.isna().any(axis=1) | train_y.isna() | train_dir_y.isna()) |
| test_mask = ~(test_features.isna().any(axis=1) | test_y.isna() | test_dir_y.isna()) |
| |
| X_train = train_features[train_mask] |
| y_train = train_y[train_mask] |
| dir_train = train_dir_y[train_mask] |
| |
| X_test = test_features[test_mask] |
| y_test = test_y[test_mask] |
| dir_test = test_dir_y[test_mask] |
| |
| if len(X_train) < 50 or len(X_test) < 10: |
| continue |
| |
| |
| if model_type == 'lgbm': |
| model = LGBMForecaster(task='regression', **model_kwargs) |
| model.fit(X_train, y_train) |
| preds = model.predict(X_test) |
| else: |
| raise ValueError(f"Unknown model type: {model_type}") |
| |
| |
| all_preds.extend(preds) |
| all_actuals.extend(y_test.values) |
| |
| |
| fold_met = calculate_all_metrics(y_test.values, preds, dir_test.values) |
| fold_metrics.append({ |
| 'fold': fold_idx, |
| 'train_size': len(X_train), |
| 'test_size': len(X_test), |
| **fold_met |
| }) |
| |
| return np.array(all_preds), np.array(all_actuals), fold_metrics |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description='Backtest FinTS model') |
| parser.add_argument('--asset-class', type=str, required=True, |
| choices=['crypto', 'forex', 'commodities', 'equities'], |
| help='Asset class to backtest') |
| parser.add_argument('--data-dir', type=str, required=True, |
| help='Data directory root') |
| parser.add_argument('--output-dir', type=str, required=True, |
| help='Output directory for backtest results') |
| parser.add_argument('--model-type', type=str, default='lgbm', |
| choices=['lgbm'], |
| help='Model type (default: lgbm)') |
| parser.add_argument('--backtest-mode', type=str, default='walk_forward', |
| choices=['walk_forward', 'expanding'], |
| help='Backtesting mode (default: walk_forward)') |
| parser.add_argument('--lookback', type=int, default=20, |
| help='Lookback period for features (default: 20)') |
| parser.add_argument('--horizon', type=int, default=1, |
| help='Forecast horizon (default: 1)') |
| parser.add_argument('--window-size', type=int, default=500, |
| help='Window size for walk-forward (default: 500)') |
| parser.add_argument('--step-size', type=int, default=100, |
| help='Step size for walk-forward (default: 100)') |
| parser.add_argument('--initial-train', type=int, default=500, |
| help='Initial training size for expanding window (default: 500)') |
| parser.add_argument('--min-rows', type=int, default=100, |
| help='Minimum rows per file (default: 100)') |
| parser.add_argument('--n-estimators', type=int, default=100, |
| help='Number of trees for LightGBM (default: 100)') |
| parser.add_argument('--learning-rate', type=float, default=0.05, |
| help='Learning rate for LightGBM (default: 0.05)') |
| parser.add_argument('--max-depth', type=int, default=5, |
| help='Max depth for LightGBM (default: 5)') |
| |
| args = parser.parse_args() |
| |
| data_dir = Path(args.data_dir) |
| output_dir = Path(args.output_dir) |
| |
| if not data_dir.exists(): |
| raise FileNotFoundError(f"Data directory not found: {data_dir}") |
| |
| output_dir.mkdir(parents=True, exist_ok=True) |
| |
| print("=" * 70) |
| print("OPENMEDALLION-FINTS BACKTESTING") |
| print("=" * 70) |
| print(f"Asset class: {args.asset_class}") |
| print(f"Data directory: {data_dir}") |
| print(f"Model: {args.model_type}") |
| print(f"Mode: {args.backtest_mode}") |
| print(f"Lookback: {args.lookback}, Horizon: {args.horizon}") |
| print("=" * 70) |
| |
| |
| print(f"\nLoading {args.asset_class} data...") |
| df = load_asset_class(data_dir, args.asset_class, min_rows=args.min_rows) |
| print(f"Total rows: {len(df)}") |
| print(f"Date range: {df.index.min()} to {df.index.max()}") |
| |
| |
| model_kwargs = { |
| 'n_estimators': args.n_estimators, |
| 'learning_rate': args.learning_rate, |
| 'max_depth': args.max_depth |
| } |
| |
| |
| if args.backtest_mode == 'walk_forward': |
| predictions, actuals, fold_metrics = backtest_walk_forward( |
| df, args.model_type, args.lookback, args.horizon, |
| args.window_size, args.step_size, **model_kwargs |
| ) |
| else: |
| predictions, actuals, fold_metrics = backtest_expanding_window( |
| df, args.model_type, args.lookback, args.horizon, |
| args.initial_train, args.step_size, **model_kwargs |
| ) |
| |
| |
| |
| pred_dir = (predictions > 0).astype(int) |
| actual_dir = (actuals > 0).astype(int) |
| |
| overall_metrics = calculate_all_metrics(actuals, predictions, actual_dir) |
| |
| print("\n" + "=" * 70) |
| print("OVERALL BACKTEST RESULTS") |
| print("=" * 70) |
| print(f"Total predictions: {len(predictions)}") |
| print(f"Number of folds: {len(fold_metrics)}") |
| print_metrics_report(overall_metrics) |
| |
| |
| results = { |
| 'asset_class': args.asset_class, |
| 'model_type': args.model_type, |
| 'backtest_mode': args.backtest_mode, |
| 'lookback': args.lookback, |
| 'horizon': args.horizon, |
| 'overall_metrics': overall_metrics, |
| 'fold_metrics': fold_metrics, |
| 'num_predictions': len(predictions), |
| 'num_folds': len(fold_metrics) |
| } |
| |
| results_path = output_dir / f'backtest_results_{args.asset_class}_{args.model_type}.json' |
| print(f"\nSaving backtest results to {results_path}...") |
| |
| with open(results_path, 'w') as f: |
| json.dump(results, f, indent=2) |
| |
| |
| preds_df = pd.DataFrame({ |
| 'prediction': predictions, |
| 'actual': actuals, |
| 'pred_direction': pred_dir, |
| 'actual_direction': actual_dir |
| }) |
| |
| preds_path = output_dir / f'backtest_predictions_{args.asset_class}_{args.model_type}.csv' |
| preds_df.to_csv(preds_path, index=False) |
| print(f"Saved predictions to {preds_path}") |
| |
| print("\nBacktesting complete!") |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|