import base64 import os import joblib try: from multi_asset_app import run_multi_asset_pipeline MULTI_ASSET_TAB = True except Exception: MULTI_ASSET_TAB = False import warnings import gradio as gr import matplotlib import matplotlib.gridspec as gridspec import matplotlib.pyplot as plt import matplotlib.ticker as mticker import numpy as np import pandas as pd import yfinance as yf from gradio import themes from sklearn.preprocessing import MinMaxScaler # Initialize conditionally imported modules to avoid unbound warnings format_analysis_as_html = None get_ai_analysis = None build_forecast_summary_html = None forecast_agent_portfolio = None forecast_price_trend = None plot_portfolio_forecast = None plot_price_forecast = None TradingEnv = None PPO = None matplotlib.use("Agg") try: from ai_analysis import format_analysis_as_html, get_ai_analysis AI_MODULE_AVAILABLE = True except ImportError: AI_MODULE_AVAILABLE = False print("ai_analysis.py not found — AI analysis disabled") try: from forecasting import ( build_forecast_summary_html, forecast_agent_portfolio, forecast_price_trend, plot_portfolio_forecast, plot_price_forecast, ) FORECAST_MODULE_AVAILABLE = True except ImportError: FORECAST_MODULE_AVAILABLE = False print("forecasting.py not found — forecast tab disabled") def image_to_base64(path): with open(path, "rb") as f: return base64.b64encode(f.read()).decode() candlestick_img = image_to_base64("assets/candlestick.png") sb3_img = image_to_base64("assets/sb3.png") warnings.filterwarnings("ignore") # ── Try to import SB3 — graceful fallback if models not loaded ─ try: from stable_baselines3 import PPO SB3_AVAILABLE = True except ImportError: SB3_AVAILABLE = False print("stable-baselines3 not found — model inference disabled") # ── Try to import TradingEnv from local file ─────────────────── try: from trading_env import TradingEnv ENV_AVAILABLE = True except ImportError: ENV_AVAILABLE = False print("trading_env.py not found — backtest disabled") # CONSTANTS & THEME TICKERS = ["AAPL", "MSFT", "AMZN", "GOOGL", "NVDA"] LOOKBACK_WINDOW = 30 INITIAL_CAPITAL = 10_000.0 TRANSACTION_COST = 0.001 # base_cost_pct MARKET_IMPACT_PCT = 0.0005 # market impact per trade OVERTRADING_THRESHOLD = 20 # trades before overtrading penalty kicks in OVERTRADING_PENALTY_PCT = 0.0001 # penalty per trade above threshold MODEL_DIR = "models" DATA_PERIOD = "2y" THEME = { "bg": "#f8fafc", "panel": "#ffffff", "border": "#e2e8f0", "accent": "#2563eb", "accent2": "#f59e0b", "accent3": "#dc2626", "text": "#0f172a", "muted": "#64748b", "green": "#16a34a", "red": "#dc2626", "grid": "#e5e7eb", } # Apply matplotlib dark theme plt.rcParams.update( { "figure.facecolor": THEME["bg"], "axes.facecolor": THEME["panel"], "axes.edgecolor": THEME["border"], "axes.labelcolor": THEME["text"], "axes.titlecolor": THEME["text"], "text.color": THEME["text"], "xtick.color": THEME["muted"], "ytick.color": THEME["muted"], "grid.color": THEME["grid"], "grid.linewidth": 0.6, "legend.facecolor": THEME["panel"], "legend.edgecolor": THEME["border"], "legend.labelcolor": THEME["text"], "font.family": "monospace", "font.size": 10, } ) # STEP 1 — REAL-TIME DATA FETCHING def fetch_realtime_data( ticker: str, period: str = DATA_PERIOD, start: str = None, end: str = None, ) -> pd.DataFrame: df = None """ Download recent OHLCV data from Yahoo Finance. Two modes: • period mode : period="6mo"/"1y"/"2y"/"5y" (default) • custom range : pass start="YYYY-MM-DD" and end="YYYY-MM-DD" In BOTH modes we pad the fetch with ~300 extra calendar days of history BEFORE the requested start, because the feature warmup (SMA-200 etc.) consumes the first ~200 rows. The padded warmup rows are dropped during feature engineering, leaving the user's requested window intact. Without this padding a short window (e.g. 6mo) loses every row to warmup and the scaler receives an empty frame. """ WARMUP_DAYS = 320 # calendar days; ~220 trading days, covers SMA-200 if start is not None and end is not None: fetch_start = (pd.to_datetime(start) - pd.Timedelta(days=WARMUP_DAYS)).strftime("%Y-%m-%d") print(f" Fetching {ticker} [{start} → {end}] (+{WARMUP_DAYS}d warmup) from Yahoo Finance...") df = yf.download(ticker, start=fetch_start, end=end, auto_adjust=True, progress=False) user_start = pd.to_datetime(start) else: # Period mode: fetch one tier longer so warmup rows exist, then trim. _pad = {"6mo": "1y", "1y": "2y", "2y": "3y", "5y": "6y"} fetch_period = _pad.get(period, period) print(f" Fetching {ticker} ({period}, fetched {fetch_period} for warmup) from Yahoo Finance...") df = yf.download(ticker, period=fetch_period, auto_adjust=True, progress=False) user_start = None if df is None or df.empty: raise ValueError(f"No data returned for {ticker}. Check ticker symbol / dates.") # Flatten MultiIndex columns if present if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.get_level_values(0) df.columns = [c.lower() for c in df.columns] df.index.name = "date" df.sort_index(inplace=True) df = df.ffill().bfill() # Tag where the user's requested window begins so engineer_features can # trim warmup rows AFTER computing indicators. For period mode we keep the # requested tail length. if user_start is not None: df.attrs["user_start"] = user_start else: df.attrs["user_period"] = period print( f" Fetched {len(df)} rows | {pd.to_datetime(df.index[0]).date()} → {pd.to_datetime(df.index[-1]).date()}" ) return df # STEP 2 — FEATURE ENGINEERING def engineer_features(df: pd.DataFrame, ticker: str = None) -> tuple[pd.DataFrame, pd.DataFrame]: """ Must exactly match data/preprocess.py feature set. Models were trained on this exact column set. Returns (normalized_df, unnormalized_df) for alignment. """ # ── existing indicators (keep as-is) ────────────────────── df["sma_20"] = df["close"].rolling(20).mean() df["sma_50"] = df["close"].rolling(50).mean() df["ema_12"] = df["close"].ewm(span=12, adjust=False).mean() df["ema_26"] = df["close"].ewm(span=26, adjust=False).mean() delta = df["close"].diff() gain = delta.clip(lower=0) loss = -delta.clip(upper=0) avg_gain = gain.ewm(com=13, min_periods=14).mean() avg_loss = loss.ewm(com=13, min_periods=14).mean() rs = avg_gain / avg_loss df["rsi_14"] = 100 - (100 / (1 + rs)) df["macd_line"] = df["ema_12"] - df["ema_26"] df["macd_signal"] = df["macd_line"].ewm(span=9, adjust=False).mean() df["macd_hist"] = df["macd_line"] - df["macd_signal"] sma20 = df["close"].rolling(20).mean() std20 = df["close"].rolling(20).std() df["bb_upper"] = sma20 + 2 * std20 df["bb_lower"] = sma20 - 2 * std20 df["bb_width"] = (df["bb_upper"] - df["bb_lower"]) / sma20 direction = np.sign(df["close"].diff()).fillna(0) df["obv"] = (direction * df["volume"]).cumsum() df["daily_return"] = df["close"].pct_change() df["log_return"] = np.log(df["close"] / df["close"].shift(1)) # ── NEW: regime indicators (added in upgraded pipeline) ──── # ADX-14 high = df["high"].values low = df["low"].values close = df["close"].values tr = np.maximum( high[1:] - low[1:], np.maximum(np.abs(high[1:] - close[:-1]), np.abs(low[1:] - close[:-1])), ) dmp = np.where( high[1:] - high[:-1] > low[:-1] - low[1:], np.maximum(high[1:] - high[:-1], 0), 0, ) dmm = np.where( low[:-1] - low[1:] > high[1:] - high[:-1], np.maximum(low[:-1] - low[1:], 0), 0 ) atr = pd.Series(tr).ewm(span=14, adjust=False).mean().values pdmi = ( pd.Series(dmp / np.where(atr > 0, atr, 1e-8)) .ewm(span=14, adjust=False) .mean() .values ) mdmi = ( pd.Series(dmm / np.where(atr > 0, atr, 1e-8)) .ewm(span=14, adjust=False) .mean() .values ) denom = np.where(pdmi + mdmi > 0, pdmi + mdmi, 1e-8) dx = 100 * np.abs(pdmi - mdmi) / denom adx = pd.Series(np.concatenate([[np.nan], dx])).ewm(span=14, adjust=False).mean() df["adx_14"] = (adx.values / 100).clip(0.0, 1.0) # SMA ratio (long-term momentum) sma100 = df["close"].rolling(100).mean() sma200 = df["close"].rolling(200).mean() ratio = (sma100 / sma200).clip(0.8, 1.2) df["sma_ratio"] = (ratio - 0.8) / 0.4 # Rate of change df["roc_20"] = df["close"].pct_change(periods=20) # Realised volatility log_ret = np.log(df["close"] / df["close"].shift(1)) rv_daily = log_ret.rolling(20).std() df["realised_vol_20"] = (rv_daily * np.sqrt(252) / 0.80).clip(0.0, 1.0) # ── Drop warmup rows (SMA-200 needs 200 bars) ───────────── df.dropna(inplace=True) # ── Trim to the user's requested window ─────────────────── # Indicators were computed using the padded warmup history above; now # restrict to what the user actually asked to see. user_start = df.attrs.get("user_start") user_period = df.attrs.get("user_period") if user_start is not None: df = df[df.index >= user_start] elif user_period is not None: _tail = {"6mo": 126, "1y": 252, "2y": 504, "5y": 1260}.get(user_period) if _tail is not None and len(df) > _tail: df = df.iloc[-_tail:] # ── Guard: enough rows for the model + scaler ───────────── if len(df) < (LOOKBACK_WINDOW + 1): raise ValueError( f"Not enough data after feature warmup ({len(df)} rows). " f"Need at least {LOOKBACK_WINDOW + 1}. Select a longer period " f"or an earlier start date." ) # ── Save unnormalized for later alignment ────────────── feat_df_unnorm = df.copy() # ── Normalise to [0, 1] ─────────────────────────────────── # Use the SAVED TRAINING scaler (transform only) to avoid lookahead/ # leakage — fitting a fresh scaler on the live window leaks the window's # own min/max into every observation. Fall back to a fitted scaler only # if the training scaler is unavailable. feature_cols = list(df.columns) scaler_path = os.path.join("data", "processed", f"{ticker}_scaler.pkl") if ticker is not None and os.path.exists(scaler_path): scaler = joblib.load(scaler_path) df[feature_cols] = scaler.transform(df[feature_cols]) else: if ticker is not None: print(f" WARNING: saved scaler not found at {scaler_path}; " "fitting on live window (leakage risk).") scaler = MinMaxScaler(feature_range=(0, 1)) df[feature_cols] = scaler.fit_transform(df[feature_cols]) return df, feat_df_unnorm # STEP 3 — BASELINE SIMULATIONS def simulate_buy_and_hold( df: pd.DataFrame, initial_capital: float = INITIAL_CAPITAL, tc: float = TRANSACTION_COST, ) -> list: if df is None or df.empty: return [initial_capital] """Buy everything on day 0, hold until the last day.""" if df.empty: return [initial_capital] cash = initial_capital shares = 0 values = [] entry = float(df.iloc[0]["close"]) if entry > 1e-8: shares = int(cash // entry) cash -= shares * entry * (1 + tc) for i in range(len(df)): values.append(cash + shares * float(df.iloc[i]["close"])) if shares > 0: exit_price = float(df.iloc[-1]["close"]) values[-1] = shares * exit_price * (1 - tc) + cash return values def simulate_sma_crossover( df: pd.DataFrame, initial_capital: float = INITIAL_CAPITAL, tc: float = TRANSACTION_COST, ) -> list: if df is None or df.empty: return [initial_capital] """Buy on golden cross (SMA20 > SMA50), sell on death cross.""" if df.empty: return [initial_capital] cash = initial_capital shares = 0 values = [] pos = "out" sma_s = df["sma_20"].values sma_l = df["sma_50"].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) cash -= shares * price * (1 + tc) pos = "in" elif prev_above and not curr_above and pos == "in": cash += shares * price * (1 - tc) shares = 0 pos = "out" values.append(cash + shares * price) if shares > 0: values[-1] = shares * float(close[-1]) * (1 - tc) + cash return values # STEP 4 — PPO BACKTEST # STEP 5 — KPI COMPUTATION def compute_kpis( values: list, initial_capital: float = INITIAL_CAPITAL, rf: float = 0.04, trading_days: int = 252, ) -> dict: """Compute the full KPI suite from a portfolio value series.""" v = np.array(values, dtype=np.float64) returns = np.diff(v) / v[:-1] n_years = len(v) / trading_days daily_rf = rf / trading_days excess = returns - daily_rf cum_ret = (v[-1] - initial_capital) / initial_capital # Guard: a flat / non-trading equity curve has ~zero return variance. # Floating-point dust makes np.std a tiny non-zero value (e.g. 1e-18), # so a bare "std > 0" check still divides by ~0 and produces an absurd # Sharpe like -9e16. Use a real epsilon and return clean zeros. EPS = 1e-9 if len(returns) == 0 or np.std(returns) < EPS: return { "Final Value": f"${v[-1]:,.2f}", "Total Return": f"{cum_ret * 100:+.2f}%", "Ann. Return": "+0.00%", "Ann. Volatility": "0.00%", "Sharpe Ratio": "0.0000", "Sortino Ratio": "0.0000", "Max Drawdown": "0.00%", "Calmar Ratio": "0.0000", } ann_ret = float((v[-1] / v[0]) ** (1 / n_years) - 1) if n_years > 0 else 0.0 ann_vol = float(np.std(returns) * np.sqrt(trading_days)) sharpe = ( float(np.mean(excess) / np.std(excess) * np.sqrt(trading_days)) if np.std(excess) > EPS else 0.0 ) peak = np.maximum.accumulate(v) max_dd = float(np.min((v - peak) / peak)) downside = excess[excess < 0] sortino = ( float(np.mean(excess) / np.std(downside) * np.sqrt(trading_days)) if len(downside) > 0 and np.std(downside) > EPS else 0.0 ) calmar = float(ann_ret / abs(max_dd)) if abs(max_dd) > EPS else 0.0 return { "Final Value": f"${v[-1]:,.2f}", "Total Return": f"{cum_ret * 100:+.2f}%", "Ann. Return": f"{ann_ret * 100:+.2f}%", "Ann. Volatility": f"{ann_vol * 100:.2f}%", "Sharpe Ratio": f"{sharpe:.4f}", "Sortino Ratio": f"{sortino:.4f}", "Max Drawdown": f"{max_dd * 100:.2f}%", "Calmar Ratio": f"{calmar:.4f}", } # STEP 6 — PLOT GENERATION def plot_equity_and_drawdown( ticker: str, dates: pd.DatetimeIndex, rl_values: list, bnh_values: list, sma_values: list, ) -> "matplotlib.figure.Figure": """ Two-panel chart: Top — equity curves for all three strategies Bottom — drawdown curves Bloomberg dark theme throughout. """ def dd_series(v): v = np.array(v) peak = np.maximum.accumulate(v) return (v - peak) / peak * 100 min_len = min(len(dates), len(rl_values), len(bnh_values), len(sma_values)) dates = dates[-min_len:] rl_v = np.array(rl_values[-min_len:]) bnh_v = np.array(bnh_values[-min_len:]) sma_v = np.array(sma_values[-min_len:]) fig = plt.figure(figsize=(13, 8), facecolor=THEME["bg"]) gs = gridspec.GridSpec(2, 1, height_ratios=[3, 1], hspace=0.06, figure=fig) ax_top = fig.add_subplot(gs[0]) ax_bot = fig.add_subplot(gs[1], sharex=ax_top) fig.suptitle( f" {ticker} · Strategy Comparison · Live Data", fontsize=14, fontweight="bold", color=THEME["accent"], x=0.02, ha="left", y=0.98, ) # ── Equity curves ────────────────────────────────────────── strategies = [ ("RL Agent (PPO)", rl_v, THEME["accent"], "-", 2.5), ("Buy & Hold", bnh_v, THEME["accent2"], "--", 1.8), ("SMA Crossover", sma_v, THEME["accent3"], "-.", 1.8), ] for label, v, color, ls, lw in strategies: ret = (v[-1] - INITIAL_CAPITAL) / INITIAL_CAPITAL * 100 lbl = f"{label} {ret:+.1f}%" ax_top.plot( dates, v, label=lbl, color=color, linestyle=ls, linewidth=lw, alpha=0.95 ) ax_top.axhline( y=INITIAL_CAPITAL, color=THEME["muted"], linestyle=":", linewidth=1.0, alpha=0.5, ) # Shade area under RL curve ax_top.fill_between( dates, rl_v, INITIAL_CAPITAL, where=(rl_v >= INITIAL_CAPITAL).tolist(), alpha=0.06, color=THEME["accent"], ) ax_top.fill_between( dates, rl_v, INITIAL_CAPITAL, where=(rl_v < INITIAL_CAPITAL).tolist(), alpha=0.06, color=THEME["red"], ) ax_top.set_ylabel("Portfolio Value ($)", color=THEME["muted"], fontsize=10) ax_top.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"${x:,.0f}")) ax_top.legend(loc="upper left", fontsize=9.5, framealpha=0.85) ax_top.grid(True, linestyle="--", alpha=0.4) plt.setp(ax_top.get_xticklabels(), visible=False) # ── Drawdown panel ───────────────────────────────────────── for label, v, color, ls, lw in strategies: dd = dd_series(v) ax_bot.plot( dates, dd, color=color, linestyle=ls, linewidth=lw * 0.75, alpha=0.9 ) ax_bot.fill_between(dates, dd, 0, alpha=0.12, color=color) ax_bot.set_ylabel("Drawdown (%)", color=THEME["muted"], fontsize=10) ax_bot.set_xlabel("Date", color=THEME["muted"], fontsize=10) ax_bot.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"{x:.0f}%")) ax_bot.grid(True, linestyle="--", alpha=0.4) plt.tight_layout(rect=(0, 0, 1, 0.96)) return fig def plot_action_distribution( ticker: str, actions: list, ) -> "matplotlib.figure.Figure": """ Donut chart showing the agent's action breakdown: Hold / Buy / Sell as percentages of total steps. """ if not actions: fig, ax = plt.subplots(figsize=(5, 5), facecolor=THEME["bg"]) ax.text( 0.5, 0.5, "No model\navailable", ha="center", va="center", color=THEME["muted"], fontsize=12, transform=ax.transAxes, ) ax.axis("off") return fig counts = [ actions.count(0), # Hold actions.count(1), # Buy actions.count(2), # Sell ] labels = ["Hold", "Buy", "Sell"] colors = [THEME["muted"], THEME["green"], THEME["red"]] total = sum(counts) pcts = [c / total * 100 for c in counts] fig, ax = plt.subplots(figsize=(5, 5), facecolor=THEME["bg"]) ax.set_facecolor(THEME["bg"]) wedges, _ = ax.pie( counts, colors=colors, startangle=90, wedgeprops=dict(width=0.55, edgecolor=THEME["bg"], linewidth=2), ) # Centre text ax.text( 0, 0.1, ticker, ha="center", va="center", fontsize=16, fontweight="bold", color=THEME["accent"], ) ax.text( 0, -0.15, "actions", ha="center", va="center", fontsize=9, color=THEME["muted"] ) # Legend with percentages legend_labels = [f"{l} {p:.1f}%" for l, p in zip(labels, pcts)] ax.legend( wedges, legend_labels, loc="lower center", bbox_to_anchor=(0.5, -0.12), ncol=3, fontsize=9, framealpha=0, ) ax.set_title("Action Distribution", color=THEME["text"], fontsize=11, pad=12) return fig def plot_price_with_signals( ticker: str, raw_df: pd.DataFrame, actions: list, ) -> "matplotlib.figure.Figure": """ Actual (un-normalised) closing price with buy/sell signals overlaid as markers. Gives visual confirmation that the agent is trading at sensible price points. """ fig, ax = plt.subplots(figsize=(13, 4), facecolor=THEME["bg"]) ax.set_facecolor(THEME["panel"]) prices = raw_df["close"].values dates = raw_df.index # Trim to match action length if actions: n = min(len(prices), len(actions)) prices_plot = prices[-n:] dates_plot = dates[-n:] actions_arr = actions[:n] else: prices_plot = prices dates_plot = dates actions_arr = [] ax.plot( dates_plot, prices_plot, color=THEME["text"], linewidth=1.2, alpha=0.9, label="Close Price", ) if actions_arr: buy_idx = [i for i, a in enumerate(actions_arr) if a == 1] sell_idx = [i for i, a in enumerate(actions_arr) if a == 2] if buy_idx: ax.scatter( dates_plot[buy_idx], prices_plot[buy_idx], marker="^", color=THEME["green"], s=60, zorder=5, label=f"Buy ({len(buy_idx)})", alpha=0.85, ) if sell_idx: ax.scatter( dates_plot[sell_idx], prices_plot[sell_idx], marker="v", color=THEME["red"], s=60, zorder=5, label=f"Sell ({len(sell_idx)})", alpha=0.85, ) ax.set_title( f"{ticker} · Price & Agent Signals", color=THEME["accent"], fontsize=12, loc="left", ) ax.set_ylabel("Price ($)", color=THEME["muted"], fontsize=10) ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"${x:,.2f}")) ax.legend(fontsize=9, framealpha=0.8) ax.grid(True, linestyle="--", alpha=0.35) plt.tight_layout() return fig def plot_kpi_bars( kpis_rl: dict, kpis_bnh: dict, kpis_sma: dict, ) -> "matplotlib.figure.Figure": """ KPI comparison dashboard: • Total Return • Sharpe Ratio • Max Drawdown Side-by-side horizontal bar charts with proper spacing, centered layout, and support for negative values. """ def parse(v: str) -> float: return float(v.replace("$", "").replace("%", "").replace(",", "").strip()) metrics = ["Total Return", "Sharpe Ratio", "Max Drawdown"] labels = ["RL Agent", "Buy & Hold", "SMA Crossover"] colors = [THEME["accent"], THEME["accent2"], THEME["accent3"]] data = { "RL Agent": [parse(kpis_rl.get(m, "0")) for m in metrics], "Buy & Hold": [parse(kpis_bnh.get(m, "0")) for m in metrics], "SMA Crossover": [parse(kpis_sma.get(m, "0")) for m in metrics], } # Figure fig, axes = plt.subplots(1, 3, figsize=(18, 4.8), facecolor=THEME["bg"]) fig.subplots_adjust(left=0.05, right=0.98, bottom=0.18, top=0.80, wspace=0.65) fig.suptitle( "KPI Comparison", fontsize=15, fontweight="bold", color=THEME["text"], y=0.92 ) # Individual KPI Charts for ax, metric, idx in zip(axes, metrics, range(len(metrics))): ax.set_facecolor(THEME["panel"]) vals = [data[label][idx] for label in labels] bars = ax.barh( labels, vals, color=colors, alpha=0.90, edgecolor=THEME["bg"], linewidth=1.4, height=0.55, ) xmin = min(vals) xmax = max(vals) span = max(abs(xmax - xmin), 1) offset = span * 0.03 # Value Labels for bar, val in zip(bars, vals): if metric == "Sharpe Ratio": text = f"{val:+.2f}" else: text = f"{val:+.2f}%" if val >= 0: x = val + offset ha = "left" else: x = val - offset ha = "right" ax.text( x, bar.get_y() + bar.get_height() / 2, text, va="center", ha=ha, fontsize=9, color=THEME["text"], fontweight="medium", ) # Zero line ax.axvline(x=0, color=THEME["muted"], linewidth=0.8, alpha=0.6) # Title ax.set_title( metric, color=THEME["accent"], fontsize=11, fontweight="bold", pad=10 ) ax.set_xlabel("Value", fontsize=9, color=THEME["muted"]) ax.tick_params(axis="x", colors=THEME["muted"]) ax.tick_params(axis="y", colors=THEME["muted"]) ax.grid(True, axis="x", linestyle="--", alpha=0.25) # Nice card-like border for spine in ax.spines.values(): spine.set_edgecolor(THEME["border"]) spine.set_linewidth(1) return fig # STEP 4 — PPO BACKTEST (shape-aware) def infer_lookback_from_model(model, n_features: int) -> int: """ Work backwards from the model's saved observation size to find the lookback window it was trained with. obs_size = lookback_window * n_features + n_portfolio_scalars Current env uses 9 scalars (5 portfolio + 4 regime); older phases used 5 then 2. Tries 9 first so current models infer correctly instead of relying on the fallback. """ expected = model.observation_space.shape[0] for n_scalars in [9, 5, 2]: market = expected - n_scalars if market > 0 and market % n_features == 0: inferred = market // n_features print( f" Inferred lookback={inferred} " f"(obs={expected}, features={n_features}, scalars={n_scalars})" ) return inferred print( " WARNING: Could not infer lookback " f"(obs={expected}, features={n_features}). " f"Falling back to {LOOKBACK_WINDOW}." ) return LOOKBACK_WINDOW def run_ppo_backtest(ticker: str, df: pd.DataFrame) -> tuple: if df is None or df.empty: return None, 0, 0 if not SB3_AVAILABLE or not ENV_AVAILABLE: return None, 0, 0 model_path = None for model_name in ["best_model_tuned", "best_model"]: path = os.path.join(MODEL_DIR, ticker, f"{model_name}.zip") if os.path.exists(path): model_path = path break if model_path is None: print(f" No model found for {ticker} in {MODEL_DIR}/") return None, 0, 0 print(f" Loading {model_path}...") model = PPO.load(model_path) n_features = len(df.columns) lookback_window = infer_lookback_from_model(model, n_features) env = TradingEnv( df=df, initial_capital=INITIAL_CAPITAL, base_cost_pct=TRANSACTION_COST, market_impact_pct=MARKET_IMPACT_PCT, overtrading_threshold=OVERTRADING_THRESHOLD, overtrading_penalty_pct=OVERTRADING_PENALTY_PCT, lookback_window=lookback_window, # Match training-time execution: next-bar fills + slippage, and no # forced-entry warmup (the live agent must make its own decisions). next_bar_execution=True, slippage_pct=0.0005, warmup_buy_episodes=0, ) obs, _ = env.reset() actual_obs = obs.shape[0] expected_obs = model.observation_space.shape[0] if actual_obs != expected_obs: env.close() print( f" SKIPPING {ticker}: shape mismatch after inference.\n" f" Model expects {expected_obs}, env produced {actual_obs}.\n" " Retrain with the current feature pipeline to fix." ) return None, 0, 0 done = truncated = False actions = [] trades = 0 while not done and not truncated: action, _ = model.predict(obs, deterministic=True) obs, _, done, truncated, info = env.step(action) # Record the direction the env actually acted on (0/1/2). Using # info["direction"] is correct for the MultiDiscrete action space and # also captures the env's true decision regardless of action encoding. actions.append(int(info["direction"])) if info["trade_executed"]: trades += 1 env.close() return actions, trades, lookback_window def replay_trades_on_raw_prices( actions: list, raw_df: pd.DataFrame, initial_capital: float = INITIAL_CAPITAL, tc: float = TRANSACTION_COST, size_fraction: float = 0.75, ) -> list: """ Re-simulate the agent's recorded action sequence against actual dollar closing prices so all strategies are compared on the same real-dollar basis. size_fraction: 0.75 matches the most common size tier seen across trained agents in Phase 08/09 evaluation. """ cash = initial_capital shares = 0 values = [] for i, direction in enumerate(actions): if i >= len(raw_df): break price = float(raw_df.iloc[i]["close"]) if price < 1e-8: values.append(cash + shares * price) continue if direction == 1 and cash >= price: shares_to_buy = int(cash * size_fraction // price) if shares_to_buy > 0: cost = shares_to_buy * price fee = cost * tc cash -= cost + fee shares += shares_to_buy elif direction == 2 and shares > 0: shares_to_sell = max(int(shares * size_fraction), 1) proceeds = shares_to_sell * price fee = proceeds * tc cash += proceeds - fee shares -= shares_to_sell values.append(cash + shares * price) # Pad to full raw_df length if actions ran short while len(values) < len(raw_df): price = float(raw_df.iloc[len(values)]["close"]) values.append(cash + shares * price) return values # MASTER PIPELINE FUNCTION (called by Gradio) def run_pipeline( ticker: str, period: str, show_signals: bool, forecast_days: int, use_custom_dates: bool = False, start_date: str = None, end_date: str = None, progress: gr.Progress = gr.Progress(track_tqdm=True), ) -> tuple: """ Full pipeline triggered by the Gradio Run button. Data flow: raw_df — real Yahoo Finance OHLCV prices in actual dollars used for baselines, price charts, and forecasting feat_df — MinMaxScaler normalised feature DataFrame used ONLY as input to the PPO agent raw_trimmed — raw_df trimmed to match feat_df after warmup drop rl_values — real-dollar values from replay_trades_on_raw_prices() Returns 10 outputs consumed by the Gradio interface: status, kpi_df, fig_equity, fig_actions, fig_kpi_bar, fig_signals, analysis_html, fig_price_forecast, fig_portfolio_forecast, forecast_summary_html """ logs = [] log = lambda msg: logs.append(msg) def update_progress(pct, desc): if progress is not None and callable(progress): try: progress(pct, desc=desc) except Exception: pass forecast_days = int(forecast_days) # ── Helper: empty forecast figures for error returns ─────── def _empty_fig(msg="Unavailable"): f, a = plt.subplots(figsize=(13, 4), facecolor=THEME["bg"]) a.set_facecolor(THEME["bg"]) a.text( 0.5, 0.5, msg, ha="center", va="center", color=THEME["muted"], fontsize=11, transform=a.transAxes, ) a.axis("off") return f try: # ── Step 1: fetch raw data ───────────────────────────── update_progress(0.05, desc="Fetching live data from Yahoo Finance...") if use_custom_dates and start_date and end_date: log(f"[1/7] Fetching {ticker} [{start_date} → {end_date}] from Yahoo Finance") raw_df = fetch_realtime_data(ticker, start=start_date, end=end_date) else: log(f"[1/7] Fetching {ticker} ({period}) from Yahoo Finance") raw_df = fetch_realtime_data(ticker, period) log( f" {len(raw_df)} rows | " f"{pd.to_datetime(raw_df.index[0]).date()} → {pd.to_datetime(raw_df.index[-1]).date()}" ) # ── Step 2: feature engineering on a copy ───────────── # raw_df stays in real dollar prices throughout update_progress(0.15, desc="Engineering technical indicators...") log("[2/7] Computing SMA, EMA, RSI, MACD, Bollinger, OBV...") feat_df, feat_df_unnorm = engineer_features(raw_df.copy(), ticker=ticker) log( f" {len(feat_df)} rows after warmup drop | {feat_df.shape[1]} features" ) # Align raw_df to feat_df — drop the same warmup rows raw_trimmed = feat_df_unnorm.copy() # ── Step 3: PPO backtest on normalised features ──────── update_progress(0.30, desc="Running PPO agent backtest...") log("[3/7] Loading PPO model — running deterministic backtest") actions, trades, lb = run_ppo_backtest(ticker, feat_df) if actions is None: log( f" WARNING: No compatible model for {ticker}. " "Showing baselines only." ) actions = [] trades = 0 lb = 0 rl_values = [INITIAL_CAPITAL] * len(raw_trimmed) else: log(f" Backtest complete — {trades} trades executed") # Re-simulate actions against REAL dollar prices rl_values = replay_trades_on_raw_prices(actions, raw_trimmed.iloc[lb:]) log(f" RL final value (real $): ${rl_values[-1]:,.2f}") # ── Step 4: baselines on real dollar prices ──────────── update_progress(0.45, desc="Simulating baseline strategies...") log("[4/7] Simulating Buy & Hold and SMA Crossover on real prices") bnh_values = simulate_buy_and_hold(raw_trimmed.iloc[lb:]) sma_values = simulate_sma_crossover(raw_trimmed.iloc[lb:]) log( f" B&H final: ${bnh_values[-1]:,.2f} | " f"SMA final: ${sma_values[-1]:,.2f}" ) # ── Step 5: KPIs on real dollar values ──────────────── update_progress(0.55, desc="Computing performance metrics...") log("[5/7] Computing KPIs — Sharpe, Sortino, Drawdown, Calmar") kpis_rl = compute_kpis(rl_values) kpis_bnh = compute_kpis(bnh_values) kpis_sma = compute_kpis(sma_values) kpi_df = pd.DataFrame( { "Metric": list(kpis_rl.keys()), "RL Agent (PPO)": list(kpis_rl.values()), "Buy & Hold": list(kpis_bnh.values()), "SMA Crossover": list(kpis_sma.values()), } ) log( f" RL → Return: {kpis_rl['Total Return']} | " f"Sharpe: {kpis_rl['Sharpe Ratio']}" ) log( f" B&H → Return: {kpis_bnh['Total Return']} | " f"Sharpe: {kpis_bnh['Sharpe Ratio']}" ) # ── Step 6: backtest plots ───────────────────────────── update_progress(0.65, desc="Generating backtest charts...") log("[6/7] Rendering equity curve, drawdown, action charts") dates = raw_trimmed.index[lb:] if lb < len(raw_trimmed) else raw_trimmed.index fig_equity = plot_equity_and_drawdown( ticker, dates, rl_values, bnh_values, sma_values ) fig_actions = plot_action_distribution(ticker, actions) fig_kpi_bar = plot_kpi_bars(kpis_rl, kpis_bnh, kpis_sma) if show_signals: fig_signals = plot_price_with_signals(ticker, raw_trimmed, actions) else: fig_signals = _empty_fig("Signal chart disabled — toggle above to enable") # ── Step 7a: AI analysis ─────────────────────────────── update_progress(0.75, desc="Generating AI performance analysis...") log("[7/7] Calling AI model for performance analysis...") if AI_MODULE_AVAILABLE: analysis = get_ai_analysis( ticker=ticker, kpis_rl=kpis_rl, kpis_bnh=kpis_bnh, kpis_sma=kpis_sma, actions=actions, trades=trades, period=period, ) analysis_html = format_analysis_as_html(analysis, ticker, THEME) log( " AI analysis complete — verdict: " f"{str(analysis.get('verdict', 'N/A'))[:60]}..." ) else: analysis_html = ( '
Data Controls
') ticker_dd = gr.Dropdown( choices=TICKERS, value="AAPL", label="Ticker Symbol", ) period_dd = gr.Dropdown( choices=["6mo", "1y", "2y", "5y"], value="2y", label="Data Period (live fetch)", ) use_custom_dates = gr.Checkbox( value=False, label="📅 Use custom date range", info="Overrides the data period above", ) start_date = gr.Textbox( label="Start date", placeholder="YYYY-MM-DD e.g. 2023-01-01", visible=False, ) end_date = gr.Textbox( label="End date", placeholder="YYYY-MM-DD e.g. 2024-12-31", visible=False, ) forecast_slider = gr.Slider( minimum=10, maximum=90, value=30, step=5, label="Forecast Horizon (trading days)", ) show_signals = gr.Checkbox( value=True, label="Show price chart with buy/sell signals", ) run_btn = gr.Button( "▶ Run Pipeline", variant="primary", size="lg", ) with gr.Column(scale=3): gr.HTML('Pipeline log
') status_box = gr.Textbox( label="Output Status", lines=12, interactive=False, elem_classes=["status-box"], placeholder="""Select a ticker and click Run Pipeline The pipeline will: 1. Fetch live data from Yahoo Finance 2. Engineer 19 technical features 3. Run PPO agent backtest 4. Simulate Buy & Hold and SMA Crossover 5. Compute full KPI suite 6. Generate all charts 7. AI performance analysis + price forecast""", ) # ── Tabbed output area ───────────────────────────── with gr.Tabs(elem_id="output-tabs"): # TAB 1 — Backtest Results with gr.Tab("Backtest Results"): gr.HTML( '' "Performance metrics
" ) kpi_table = gr.Dataframe( headers=[ "Metric", "RL Agent (PPO)", "Buy & Hold", "SMA Crossover", ], label="", wrap=True, ) gr.HTML( '' "Equity curves & drawdown
" ) equity_plot = gr.Plot(label="") with gr.Row(): with gr.Column(scale=1): gr.HTML( 'Agent action breakdown
' ) action_plot = gr.Plot(label="") with gr.Column(scale=2): gr.HTML('KPI comparison
') kpi_bar_plot = gr.Plot(label="") gr.HTML( '' "Price chart with agent signals
" ) signal_plot = gr.Plot(label="") # TAB 2 — AI Analysis with gr.Tab("AI Analysis"): analysis_html_out = gr.HTML( value=( '' "Historical price + trend projection
" ) price_forecast_plot = gr.Plot(label="") # TAB 4 — Agent Portfolio Forecast with gr.Tab("Agent Portfolio Forecast"): gr.HTML("""' "Projected portfolio value — 3 scenarios
" ) portfolio_forecast_plot = gr.Plot(label="") gr.HTML("""What it does
A cross-sectional agent that reallocates across a 16-stock universe each day, outputting portfolio weights that sum to 1. It decides relative conviction between stocks — not single-stock direction.
How it's scored
Benchmarked against an equal-weight portfolio of the same universe, so alpha measures allocation skill above naive diversification. Holding everything equally scores zero alpha by construction.
Where models come from
Uses agents from models/multiasset/,
trained via walk_forward strategy.
Fully independent of the single-asset agent in the other tabs.
Universe · 16 stocks across 6 sectors
TECHNOLOGY
AAPL · MSFT · GOOGL · AMZN · NVDA
FINANCIALS
JPM · BAC
HEALTHCARE
JNJ · UNH · PFE
ENERGY
XOM · CVX
CONSUMER STAPLES
PG · KO · WMT
INDUSTRIALS
CAT
Portfolio KPIs vs Equal-Weight
') ma_kpi_table = gr.Dataframe(interactive=False, wrap=True) gr.HTML('Portfolio value vs equal-weight
') ma_equity_plot = gr.Plot(label="") gr.HTML('Allocation weights over time
') ma_weights_plot = gr.Plot(label="") gr.HTML('Average allocation by asset
') ma_contrib_plot = gr.Plot(label="") gr.HTML("""Configuration
') # Agent setup gr.HTML("""Agent Setup
Validation
Forecast & Extensions
ⓘ Select a ticker on the left and click "Run Pipeline" to generate the full performance report and AI insights.