Spaces:
Sleeping
Sleeping
| import os | |
| import pickle | |
| import numpy as np | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| import matplotlib.gridspec as gridspec | |
| import matplotlib.ticker as mticker | |
| from typing import Optional | |
| RESULTS_DIR = "results" | |
| PLOTS_DIR = os.path.join(RESULTS_DIR, "plots") | |
| VALID_TICKERS = ["AAPL", "MSFT", "AMZN", "GOOGL", "NVDA"] | |
| os.makedirs(PLOTS_DIR, exist_ok=True) | |
| def load_results() -> tuple: | |
| backtest_path = os.path.join(RESULTS_DIR, "backtest_results.pkl") | |
| baseline_path = os.path.join(RESULTS_DIR, "baseline_results.pkl") | |
| if not os.path.exists(backtest_path): | |
| raise FileNotFoundError(f"Run evaluate.py first β {backtest_path} missing.") | |
| if not os.path.exists(baseline_path): | |
| raise FileNotFoundError(f"Run baselines.py first β {baseline_path} missing.") | |
| with open(backtest_path, "rb") as f: | |
| backtest_results = pickle.load(f) | |
| with open(baseline_path, "rb") as f: | |
| baseline_results = pickle.load(f) | |
| print(f"Loaded backtest results : {list(backtest_results.keys())}") | |
| print(f"Loaded baseline results : {list(baseline_results.keys())}") | |
| return backtest_results, baseline_results | |
| def calc_cumulative_return(values: np.ndarray, initial: float) -> float: | |
| return (values[-1] - initial) / initial | |
| def calc_annualised_return(values: np.ndarray, trading_days: int = 252) -> float: | |
| n_years = len(values) / trading_days | |
| if n_years <= 0 or values[0] <= 0: | |
| return 0.0 | |
| return float((values[-1] / values[0]) ** (1 / n_years) - 1) | |
| def calc_annualised_volatility(values: np.ndarray, trading_days: int = 252) -> float: | |
| returns = np.diff(values) / values[:-1] | |
| return float(np.std(returns) * np.sqrt(trading_days)) | |
| def calc_sharpe_ratio( | |
| values: np.ndarray, | |
| risk_free_rate: float = 0.04, | |
| trading_days: int = 252, | |
| ) -> float: | |
| returns = np.diff(values) / values[:-1] | |
| daily_rf = risk_free_rate / trading_days | |
| excess = returns - daily_rf | |
| if np.std(excess) == 0: | |
| return 0.0 | |
| return float(np.mean(excess) / np.std(excess) * np.sqrt(trading_days)) | |
| def calc_max_drawdown(values: np.ndarray) -> float: | |
| peak = np.maximum.accumulate(values) | |
| drawdowns = (values - peak) / peak | |
| return float(np.min(drawdowns)) | |
| def calc_sortino_ratio( | |
| values: np.ndarray, | |
| risk_free_rate: float = 0.04, | |
| trading_days: int = 252, | |
| ) -> float: | |
| returns = np.diff(values) / values[:-1] | |
| daily_rf = risk_free_rate / trading_days | |
| excess = returns - daily_rf | |
| downside = excess[excess < 0] | |
| if len(downside) == 0 or np.std(downside) == 0: | |
| return 0.0 | |
| downside_std = float(np.std(downside) * np.sqrt(trading_days)) | |
| return float(np.mean(excess) * trading_days / downside_std) | |
| def calc_calmar_ratio(values: np.ndarray, trading_days: int = 252) -> float: | |
| ann_return = calc_annualised_return(values, trading_days) | |
| max_dd = calc_max_drawdown(values) | |
| if max_dd == 0: | |
| return 0.0 | |
| return float(ann_return / abs(max_dd)) | |
| def compute_all_metrics( | |
| values: np.ndarray, | |
| initial_capital: float, | |
| total_trades: Optional[int] = None, | |
| risk_free_rate: float = 0.04, | |
| trading_days: int = 252, | |
| ) -> dict: | |
| values = np.array(values, dtype=np.float64) | |
| return { | |
| "Final Value ($)": round(float(values[-1]), 2), | |
| "Cumulative Return": round(calc_cumulative_return(values, initial_capital), 4), | |
| "Annualised Return": round(calc_annualised_return(values, trading_days), 4), | |
| "Annualised Volatility": round(calc_annualised_volatility(values, trading_days), 4), | |
| "Sharpe Ratio": round(calc_sharpe_ratio(values, risk_free_rate, trading_days), 4), | |
| "Max Drawdown": round(calc_max_drawdown(values), 4), | |
| "Sortino Ratio": round(calc_sortino_ratio(values, risk_free_rate, trading_days), 4), | |
| "Calmar Ratio": round(calc_calmar_ratio(values, trading_days), 4), | |
| "Total Trades": total_trades if total_trades is not None else "N/A", | |
| } | |
| def compute_drawdown_series(values: np.ndarray) -> np.ndarray: | |
| values = np.array(values, dtype=np.float64) | |
| peak = np.maximum.accumulate(values) | |
| return (values - peak) / peak * 100 | |
| def _simulate_buy_and_hold( | |
| df: pd.DataFrame, | |
| initial_capital: float, | |
| transaction_cost: float = 0.001, | |
| ) -> tuple: | |
| cash = initial_capital | |
| shares = 0 | |
| values = [] | |
| entry_price = float(df.iloc[0]["close"]) | |
| if entry_price > 1e-8: | |
| shares = int(cash // entry_price) | |
| cost = shares * entry_price | |
| cash -= cost * (1 + transaction_cost) | |
| for i in range(len(df)): | |
| price = float(df.iloc[i]["close"]) | |
| values.append(cash + shares * price) | |
| exit_price = float(df.iloc[-1]["close"]) | |
| if shares > 0 and exit_price > 1e-8: | |
| final_cash = shares * exit_price * (1 - transaction_cost) + cash | |
| values[-1] = final_cash | |
| return values, 2 | |
| def _simulate_sma_crossover( | |
| df: pd.DataFrame, | |
| initial_capital: float, | |
| short_window: int = 20, | |
| long_window: int = 50, | |
| transaction_cost: float = 0.001, | |
| ) -> tuple: | |
| cash = initial_capital | |
| shares = 0 | |
| values = [] | |
| trades = 0 | |
| pos = "out" | |
| if "sma_20" in df.columns and "sma_50" in df.columns: | |
| sma_s = df["sma_20"].values | |
| sma_l = df["sma_50"].values | |
| else: | |
| sma_s = df["close"].rolling(short_window).mean().values | |
| sma_l = df["close"].rolling(long_window).mean().values | |
| close = df["close"].values | |
| for i in range(len(df)): | |
| price = float(close[i]) | |
| if (i > 0 | |
| and not np.isnan(sma_s[i]) and not np.isnan(sma_l[i]) | |
| and not np.isnan(sma_s[i-1]) and not np.isnan(sma_l[i-1])): | |
| prev_above = sma_s[i-1] > sma_l[i-1] | |
| curr_above = sma_s[i] > sma_l[i] | |
| if not prev_above and curr_above and pos == "out" and price > 1e-8: | |
| shares = int(cash // price) | |
| cost = shares * price | |
| cash -= cost * (1 + transaction_cost) | |
| pos = "in" | |
| trades += 1 | |
| elif prev_above and not curr_above and pos == "in": | |
| proceeds = shares * price * (1 - transaction_cost) | |
| cash += proceeds | |
| shares = 0 | |
| pos = "out" | |
| trades += 1 | |
| values.append(cash + shares * price) | |
| if shares > 0: | |
| proceeds = shares * float(close[-1]) * (1 - transaction_cost) | |
| cash += proceeds | |
| values[-1] = cash | |
| trades += 1 | |
| return values, trades | |
| def build_comparison_table( | |
| ticker: str, | |
| backtest_results: dict, | |
| baseline_results: dict, | |
| initial_capital: float = 10_000.0, | |
| ) -> pd.DataFrame: | |
| rows = {} | |
| test_df = backtest_results[ticker]["test_df"] | |
| rl_values = backtest_results[ticker]["history_df"]["portfolio_values"].values | |
| rl_trades = backtest_results[ticker]["total_trades"] | |
| rows["RL Agent (PPO)"] = compute_all_metrics(rl_values, initial_capital, rl_trades) | |
| bnh_values, bnh_trades = _simulate_buy_and_hold(test_df, initial_capital) | |
| rows["Buy and Hold"] = compute_all_metrics(bnh_values, initial_capital, bnh_trades) | |
| sma_values, sma_trades = _simulate_sma_crossover(test_df, initial_capital) | |
| rows["SMA Crossover"] = compute_all_metrics(sma_values, initial_capital, sma_trades) | |
| return pd.DataFrame(rows) | |
| def print_kpi_table(ticker: str, df: pd.DataFrame) -> None: | |
| FORMAT_MAP = { | |
| "Final Value ($)": lambda v: f"${v:>12,.2f}", | |
| "Cumulative Return": lambda v: f"{v*100:>11.2f}%", | |
| "Annualised Return": lambda v: f"{v*100:>11.2f}%", | |
| "Annualised Volatility": lambda v: f"{v*100:>11.2f}%", | |
| "Sharpe Ratio": lambda v: f"{v:>12.4f}", | |
| "Max Drawdown": lambda v: f"{v*100:>11.2f}%", | |
| "Sortino Ratio": lambda v: f"{v:>12.4f}", | |
| "Calmar Ratio": lambda v: f"{v:>12.4f}", | |
| "Total Trades": lambda v: f"{v:>12}", | |
| } | |
| LOWER_IS_BETTER = {"Annualised Volatility", "Max Drawdown"} | |
| col_w = 18 | |
| label_w = 26 | |
| print(f"\n{'-'*75}") | |
| print(f" KPI Comparison β {ticker}") | |
| print(f"{'-'*75}") | |
| print(f"{'Metric':<{label_w}}" + "".join( | |
| f"{col:>{col_w}}" for col in df.columns | |
| )) | |
| print(f"{'β'*75}") | |
| for metric in df.index: | |
| if metric not in FORMAT_MAP: | |
| continue | |
| row_values = df.loc[metric] | |
| fmt = FORMAT_MAP[metric] | |
| numeric_vals = {} | |
| for col in df.columns: | |
| val = row_values[col] | |
| if isinstance(val, (int, float)) and not np.isnan(float(val)): | |
| numeric_vals[col] = float(val) | |
| if numeric_vals: | |
| best_col = (min if metric in LOWER_IS_BETTER else max)( | |
| numeric_vals, key=numeric_vals.get | |
| ) | |
| else: | |
| best_col = None | |
| row = f" {metric:<{label_w-2}}" | |
| for col in df.columns: | |
| val = row_values[col] | |
| try: | |
| formatted = fmt(float(val)) | |
| if col == best_col: | |
| formatted = f"{formatted.strip() + ' β':>{col_w}}" | |
| except (ValueError, TypeError): | |
| formatted = f"{'N/A':>{col_w}}" | |
| row += formatted | |
| print(row) | |
| print(f"{'-'*75}") | |
| def compute_drawdown_series(values: np.ndarray) -> np.ndarray: | |
| values = np.array(values, dtype=np.float64) | |
| peak = np.maximum.accumulate(values) | |
| return (values - peak) / peak * 100 | |
| def plot_equity_curves( | |
| ticker: str, | |
| backtest_results: dict, | |
| initial_capital: float = 10_000.0, | |
| save: bool = True, | |
| ) -> None: | |
| if ticker not in backtest_results: | |
| print(f" No backtest results for {ticker} β skipping plot.") | |
| return | |
| test_df = backtest_results[ticker]["test_df"] | |
| dates = test_df.index | |
| rl_values = backtest_results[ticker]["history_df"]["portfolio_values"].values | |
| bnh_values, _ = _simulate_buy_and_hold(test_df, initial_capital) | |
| sma_values, _ = _simulate_sma_crossover(test_df, initial_capital) | |
| bnh_values = np.array(bnh_values) | |
| sma_values = np.array(sma_values) | |
| min_len = min(len(rl_values), len(bnh_values), len(sma_values), len(dates)) | |
| dates = dates[-min_len:] | |
| rl_values = rl_values[-min_len:] | |
| bnh_values = bnh_values[-min_len:] | |
| sma_values = sma_values[-min_len:] | |
| rl_dd = compute_drawdown_series(rl_values) | |
| bnh_dd = compute_drawdown_series(bnh_values) | |
| sma_dd = compute_drawdown_series(sma_values) | |
| COLORS = { | |
| "RL Agent (PPO)": "#2563EB", | |
| "Buy and Hold": "#16A34A", | |
| "SMA Crossover": "#DC2626", | |
| } | |
| STYLES = { | |
| "RL Agent (PPO)": "-", | |
| "Buy and Hold": "--", | |
| "SMA Crossover": "-.", | |
| } | |
| fig = plt.figure(figsize=(14, 9)) | |
| fig.suptitle( | |
| f"{ticker} β Strategy Comparison " | |
| f"({dates[0].date()} β {dates[-1].date()})", | |
| fontsize=14, fontweight="bold", y=0.98, | |
| ) | |
| gs = gridspec.GridSpec(2, 1, height_ratios=[3, 1], hspace=0.08) | |
| ax_top = fig.add_subplot(gs[0]) | |
| ax_bot = fig.add_subplot(gs[1], sharex=ax_top) | |
| for label, values in [ | |
| ("RL Agent (PPO)", rl_values), | |
| ("Buy and Hold", bnh_values), | |
| ("SMA Crossover", sma_values), | |
| ]: | |
| final_return = (values[-1] - initial_capital) / initial_capital * 100 | |
| full_label = f"{label} ({final_return:+.1f}%)" | |
| ax_top.plot( | |
| dates, values, | |
| label = full_label, | |
| color = COLORS[label], | |
| linestyle = STYLES[label], | |
| linewidth = 2.0 if label == "RL Agent (PPO)" else 1.5, | |
| alpha = 1.0 if label == "RL Agent (PPO)" else 0.85, | |
| zorder = 3 if label == "RL Agent (PPO)" else 2, | |
| ) | |
| ax_top.axhline( | |
| y=initial_capital, color="gray", | |
| linestyle=":", linewidth=1.0, alpha=0.6, label="Initial Capital", | |
| ) | |
| ax_top.set_ylabel("Portfolio Value ($)", fontsize=11) | |
| ax_top.yaxis.set_major_formatter( | |
| mticker.FuncFormatter(lambda x, _: f"${x:,.0f}") | |
| ) | |
| ax_top.legend(loc="upper left", fontsize=10, framealpha=0.9) | |
| ax_top.grid(True, alpha=0.3, linestyle="--") | |
| ax_top.set_title("Portfolio Value Over Time", fontsize=11, pad=8) | |
| plt.setp(ax_top.get_xticklabels(), visible=False) | |
| for label, dd in [ | |
| ("RL Agent (PPO)", rl_dd), | |
| ("Buy and Hold", bnh_dd), | |
| ("SMA Crossover", sma_dd), | |
| ]: | |
| ax_bot.fill_between(dates, dd, 0, alpha=0.25, color=COLORS[label]) | |
| ax_bot.plot( | |
| dates, dd, | |
| color=COLORS[label], linestyle=STYLES[label], linewidth=1.5, | |
| ) | |
| ax_bot.set_ylabel("Drawdown (%)", fontsize=11) | |
| ax_bot.set_xlabel("Date", fontsize=11) | |
| ax_bot.yaxis.set_major_formatter( | |
| mticker.FuncFormatter(lambda x, _: f"{x:.0f}%") | |
| ) | |
| ax_bot.grid(True, alpha=0.3, linestyle="--") | |
| ax_bot.set_title("Drawdown from Peak", fontsize=11, pad=8) | |
| plt.tight_layout() | |
| if save: | |
| path = os.path.join(PLOTS_DIR, f"{ticker}_equity_curve.png") | |
| plt.savefig(path, dpi=150, bbox_inches="tight") | |
| print(f" Plot saved β {path}") | |
| plt.show() | |
| plt.close() | |
| def save_kpi_tables(all_tables: dict) -> None: | |
| for ticker, df in all_tables.items(): | |
| path = os.path.join(RESULTS_DIR, f"{ticker}_kpi_table.csv") | |
| df.to_csv(path) | |
| print(f" KPI table saved β {path}") | |
| def print_cross_ticker_summary(all_tables: dict) -> None: | |
| print(f"\n{'+'*65}") | |
| print(f" Project Summary β RL Agent Across All Tickers") | |
| print(f"{'+'*65}") | |
| print(f" {'Ticker':<8} {'Cum. Return':>13} {'Sharpe':>10} " | |
| f"{'Sortino':>10} {'Max DD':>10}") | |
| print(f"{'β'*65}") | |
| for ticker, table in all_tables.items(): | |
| col = "RL Agent (PPO)" | |
| if col not in table.columns: | |
| continue | |
| cum_ret = table.loc["Cumulative Return", col] | |
| sharpe = table.loc["Sharpe Ratio", col] | |
| sortino = table.loc["Sortino Ratio", col] | |
| max_dd = table.loc["Max Drawdown", col] | |
| print( | |
| f" {ticker:<8} " | |
| f"{cum_ret*100:>12.2f}% " | |
| f"{sharpe:>10.4f} " | |
| f"{sortino:>10.4f} " | |
| f"{max_dd*100:>10.2f}%" | |
| ) | |
| print(f"{'-'*65}") | |
| print(f"\n Plots β {PLOTS_DIR}/") | |
| print(f" Tables β {RESULTS_DIR}/") | |
| def run_analysis( | |
| tickers: list = VALID_TICKERS, | |
| initial_capital: float = 10_000.0, | |
| ) -> dict: | |
| backtest_results, baseline_results = load_results() | |
| all_tables = {} | |
| for ticker in tickers: | |
| if ticker not in backtest_results: | |
| print(f"\n Skipping {ticker} β no backtest results.") | |
| continue | |
| print(f"\nAnalysing {ticker}...") | |
| table = build_comparison_table( | |
| ticker = ticker, | |
| backtest_results = backtest_results, | |
| baseline_results = baseline_results, | |
| initial_capital = initial_capital, | |
| ) | |
| all_tables[ticker] = table | |
| print_kpi_table(ticker, table) | |
| plot_equity_curves( | |
| ticker = ticker, | |
| backtest_results = backtest_results, | |
| initial_capital = initial_capital, | |
| save = True, | |
| ) | |
| print("\nSaving KPI tables...") | |
| save_kpi_tables(all_tables) | |
| print_cross_ticker_summary(all_tables) | |
| return all_tables | |
| if __name__ == "__main__": | |
| tables = run_analysis() | |
| print("\nAnalysis complete.") |