rl-trading-agent / analysis.py
nirmanpatel's picture
Upload 49 files
05b2d96 verified
Raw
History Blame Contribute Delete
15.8 kB
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.")