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 = ( '
' "ai_analysis.py not found. Place it in the same directory as app.py." "
" ) log(" ai_analysis.py not found — skipping AI analysis") # ── Step 7b: price forecast ──────────────────────────── update_progress(0.87, desc="Computing price forecast...") if FORECAST_MODULE_AVAILABLE: log(f" Computing {forecast_days}-day price trend forecast...") forecast_trend = forecast_price_trend( raw_df=raw_df, forecast_days=forecast_days, lookback_days=min(90, len(raw_df) // 3), ) fig_price_forecast = plot_price_forecast( ticker=ticker, raw_df=raw_df, forecast=forecast_trend, theme=THEME, history_days=min(120, len(raw_df) // 2), ) log( f" Trend: {forecast_trend['trend_direction']} " f"({forecast_trend['slope_pct_day']:+.3f}%/day)" ) # Agent portfolio forecast log(f" Simulating agent over {forecast_days}-day forecast...") portfolio_forecast = forecast_agent_portfolio( ticker=ticker, raw_df=raw_df, forecast_trend=forecast_trend, model_dir=MODEL_DIR, initial_capital=INITIAL_CAPITAL, transaction_cost=TRANSACTION_COST, n_simulations=3, ) fig_portfolio_forecast = plot_portfolio_forecast( ticker=ticker, forecast_trend=forecast_trend, portfolio_forecast=portfolio_forecast, initial_capital=INITIAL_CAPITAL, theme=THEME, ) forecast_summary_html = build_forecast_summary_html( ticker=ticker, forecast=forecast_trend, portfolio_forecast=portfolio_forecast, initial_capital=INITIAL_CAPITAL, theme=THEME, ) if portfolio_forecast["model_loaded"]: log(" Portfolio forecast complete — 3 scenarios generated") else: log(" Portfolio forecast skipped — model not found") else: fig_price_forecast = _empty_fig("forecasting.py not found") fig_portfolio_forecast = _empty_fig("forecasting.py not found") forecast_summary_html = "" log(" forecasting.py not found — skipping forecast") # ── Done ─────────────────────────────────────────────── update_progress(1.0, desc="Done.") log("Pipeline complete.") status = "\n".join(logs) return ( status, kpi_df, fig_equity, fig_actions, fig_kpi_bar, fig_signals, analysis_html, fig_price_forecast, fig_portfolio_forecast, forecast_summary_html, ) except Exception as e: import traceback err = f"ERROR: {e}\n\n{traceback.format_exc()}" empty_df = pd.DataFrame({"Error": [str(e)]}) fig_err, ax = plt.subplots(facecolor=THEME["bg"]) ax.set_facecolor(THEME["bg"]) ax.text( 0.5, 0.5, f"Error:\n{e}", ha="center", va="center", color=THEME["red"], fontsize=11, transform=ax.transAxes, wrap=True, ) ax.axis("off") empty_html = ( '
ERROR: {e}
' ) return ( err, empty_df, fig_err, fig_err, fig_err, fig_err, empty_html, fig_err, fig_err, empty_html, ) # GRADIO INTERFACE CSS = f""" @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@300;400;500;600&display=swap'); @import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css'); :root{{ --bg:#f8fafc; --panel:#ffffff; --border:#e2e8f0; --accent:#2563eb; --accent-hover:#1d4ed8; --text:#0f172a; --muted:#64748b; --success:#16a34a; --danger:#dc2626; }} body, .gradio-container{{ background:var(--bg)!important; color:var(--text)!important; font-family:'IBM Plex Sans',sans-serif!important; }} .hero-header{{ position:relative; overflow:hidden; background: repeating-linear-gradient( 0deg, #e0f2fe 0px, #e0f2fe 1px, transparent 1px, transparent 20px ), repeating-linear-gradient( 90deg, #e0f2fe 0px, #e0f2fe 1px, transparent 1px, transparent 20px ), white; border:1px solid #dbeafe; border-radius:22px; padding:34px; }} .hero-header::before{{ content:""; position:absolute; left:-40px; bottom:-80px; width:3800px; height:380px; background-image:url("data:image/png;base64,{candlestick_img}"); background-size:contain; background-repeat:no-repeat; opacity:.5; pointer-events:none; z-index:0; }} .hero-header::after{{ content:""; position:absolute; right:20px; top:20px; width:220px; height:220px; background-image:url("data:image/png;base64,{sb3_img}"); background-size:contain; background-repeat:no-repeat; opacity:.7; pointer-events:none; z-index:0; }} .hero-content{{ position:relative; z-index:2; }} .hero-badge{{ display:inline-flex; align-items:center; padding:8px 14px; border-radius:999px; background:rgba(255,255,255,0.9); border:1px solid #4b5563; color:#374151; font-family:'IBM Plex Mono',monospace; font-size:11px; letter-spacing:.08em; text-transform:uppercase; margin-bottom:16px; }} .hero-header h1{{ margin:0; font-family:'IBM Plex Sans',sans-serif; font-size:38px; font-weight:700; letter-spacing:-0.02em; line-height:1.1; }} .hero-header p{{ margin-top:10px; font-size:15px; opacity:.92; font-weight:400; }} .hero-stats{{ display:flex; flex-wrap:wrap; gap:16px; margin-top:26px; }} .hero-stats div{{ background: rgba(255,255,255,0.85); border: 1px solid #dbeafe; border-radius: 14px; padding: 14px 18px; min-width: 150px; box-shadow: 0 1px 3px rgba(15,23,42,0.04); transition: all 0.2s ease; }} .hero-stats div:hover{{ border-color: #93c5fd; box-shadow: 0 4px 12px rgba(37,99,235,0.08); transform: translateY(-1px); }} .hero-stats span{{ display:block; font-family:'IBM Plex Mono',monospace; font-size:10px; letter-spacing:.08em; text-transform:uppercase; opacity:.8; margin-bottom:4px; }} .hero-stats strong{{ font-size:15px; font-weight:600; }} .hero-features{{ display:flex; flex-wrap:wrap; gap:10px; margin-top:18px; margin-bottom:24px; }} .feature-pill{{ display:flex; align-items:center; gap:8px; padding:8px 14px; border-radius:999px; font-family:'IBM Plex Mono', monospace; font-size:11px; letter-spacing:0.04em; font-weight:500; border:1px solid; transition:all .2s ease; }} .feature-pill i{{ font-size:12px; }} .feature-pill:hover{{ transform:translateY(-1px); }} /* RL */ .feature-pill.rl{{ color:#7c3aed; border-color:#ddd6fe; background:#f5f3ff; }} /* PPO */ .feature-pill.ppo{{ color:#2563eb; border-color:#bfdbfe; background:#eff6ff; }} /* Market Data */ .feature-pill.market{{ color:#059669; border-color:#a7f3d0; background:#ecfdf5; }} /* Backtesting */ .feature-pill.backtest{{ color:#f59e0b; border-color:#fde68a; background:#fffbeb; }} /* Risk */ .feature-pill.risk{{ color:#dc2626; border-color:#fecaca; background:#fef2f2; }} .hero-features span{{ display:flex; align-items:center; gap:8px; padding:8px 14px; border-radius:999px; background:rgba(255,255,255,0.85); border:1px solid #dbeafe; color:#334155; font-family:'IBM Plex Mono', monospace; font-size:11px; letter-spacing:0.04em; box-shadow: 0 1px 4px rgba(15,23,42,0.04); }} .feature-pill i{{ color:inherit; font-size:12px; }} .gr-box, .gr-form, .gr-panel, .gr-block{{ background:var(--panel)!important; border:1px solid var(--border)!important; border-radius:18px!important; box-shadow: 0 2px 10px rgba(15,23,42,.04)!important; }} .gr-button-primary{{ background:var(--accent)!important; color:white!important; border:none!important; border-radius:14px!important; height:52px!important; font-family:'IBM Plex Mono',monospace!important; font-weight:500!important; letter-spacing:.05em!important; }} .gr-button-primary:hover{{ background:var(--accent-hover)!important; }} label, .gr-label{{ color:var(--muted)!important; font-family:'IBM Plex Mono',monospace!important; font-size:11px!important; letter-spacing:.08em!important; font-weight:500!important; }} input, textarea, select, .gr-dropdown{{ font-family:'IBM Plex Mono',monospace!important; border-radius:12px!important; border:1px solid var(--border)!important; }} .gr-dataframe table{{ font-family:'IBM Plex Mono',monospace!important; font-size:12px!important; }} .gr-dataframe th{{ background:#eff6ff!important; color:#2563eb!important; font-weight:600!important; letter-spacing:.04em!important; }} .gr-dataframe td{{ color:var(--text)!important; }} .status-box textarea{{ background:#ffffff!important; color:#0f172a!important; font-family:'IBM Plex Mono',monospace!important; font-size:12px!important; border:1px solid var(--border)!important; border-radius:12px!important; }} .section-label{{ color:var(--muted)!important; font-family:'IBM Plex Mono',monospace!important; font-size:10px!important; letter-spacing:.12em!important; text-transform:uppercase!important; font-weight:600!important; }} canvas{{ border-radius:16px!important; }} #output-tabs {{ margin-top: 32px !important; }} #output-tabs [role="tablist"] {{ background: transparent !important; border-radius: 0 !important; padding: 0 !important; gap: 8px !important; border-bottom: 1px solid #cbd5e1 !important; display: flex !important; }} #output-tabs [role="tab"] {{ background: #f1f5f9 !important; color: #64748b !important; border: 1px solid #cbd5e1 !important; border-bottom: none !important; border-radius: 10px 10px 0 0 !important; padding: 10px 24px !important; font-family: 'IBM Plex Mono', monospace !important; font-size: 11px !important; font-weight: 500 !important; transition: all 0.2s ease !important; margin-bottom: -1px !important; }} #output-tabs [role="tab"][aria-selected="true"], #output-tabs [role="tab"].selected {{ background: #ffffff !important; color: var(--accent) !important; font-weight: 700 !important; border: 1px solid #cbd5e1 !important; border-bottom: 1px solid #ffffff !important; box-shadow: none !important; }} .footer{{ font-family:'IBM Plex Mono',monospace!important; color:#94a3b8!important; font-size:11px!important; letter-spacing:.08em!important; }} """ HEADER_HTML = """
AI Driven Portfolio Analytics

Algorithm-Reinforced Trading Agent

Reinforcement Learning PPO Agent Live Market Data Backtesting Engine Risk Analytics
Algorithm PPO
Data Source Yahoo Finance
Framework Stable Baselines3
Mode Real-Time
""" MODEL_STATUS_HTML = f"""
{ "✓ SB3 + TradingEnv loaded: PPO inference ready" if SB3_AVAILABLE and ENV_AVAILABLE else "⚠ Model dependencies missing — baseline comparison only" }
""" def build_interface() -> gr.Blocks: with gr.Blocks( css=CSS, title="RL Trading Agent", theme=themes.Default( primary_hue="blue", neutral_hue="slate", font=themes.GoogleFont("IBM Plex Mono"), ), ) as demo: # ── Header ───────────────────────────────────────────── gr.HTML(HEADER_HTML) # ── Main Row ────────────────────────────────────────── with gr.Row(): # ── Main content column ─────────────────────────── with gr.Column(scale=4): # ── Controls row ─────────────────────────────── with gr.Row(): with gr.Column(scale=1): gr.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=( '
' "Run the pipeline to generate AI analysis.
" ) ) gr.HTML("""
Powered by Gemini · Analyses strengths, weaknesses, failure diagnosis, and improvement roadmap based on your backtest results.
""") # TAB 3 — Price Forecast with gr.Tab("Price Forecast"): gr.HTML("""
Geometric Brownian Motion (Black-Scholes) with ±1σ / ±2σ lognormal bands. Drift & volatility estimated from recent log-returns · Not financial advice.
""") forecast_summary_out = gr.HTML( value=( '
' "Run the pipeline to generate forecast.
" ) ) gr.HTML( '

' "Historical price + trend projection

" ) price_forecast_plot = gr.Plot(label="") # TAB 4 — Agent Portfolio Forecast with gr.Tab("Agent Portfolio Forecast"): gr.HTML("""
Projects the trained PPO agent's portfolio value over the forecast horizon under three price scenarios: central, optimistic (+0.5σ), and pessimistic (−0.5σ). Compared against a trend-implied Buy & Hold baseline.
""") gr.HTML( '

' "Projected portfolio value — 3 scenarios

" ) portfolio_forecast_plot = gr.Plot(label="") gr.HTML("""
Forecasts use Geometric Brownian Motion (Black-Scholes) for price and agent simulation on the projected paths. They do not constitute financial advice and should not be used as the basis for real trading decisions. Past model performance does not guarantee future results.
""") with gr.Tab("Multi-Asset Portfolio"): 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

""") ma_run_btn = gr.Button("▶ Run Portfolio Agent", variant="primary", size="lg") ma_status = gr.Textbox(label="Status", lines=2, interactive=False) gr.HTML('

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("""
Research analysis, not financial advice. A single live window is illustrative, not proof of edge.
""") # ── Right Sidebar (works on both Gradio 4.x and 5.x) ─────────── # gr.Sidebar exists only in Gradio 5+. Fall back to a Column on 4.x # so the app runs regardless of which Gradio version the Space pins. _sidebar_ctx = ( gr.Sidebar(position="right", width=330) if hasattr(gr, "Sidebar") else gr.Column(scale=1, min_width=330) ) with _sidebar_ctx: gr.HTML(MODEL_STATUS_HTML) gr.HTML('

Configuration

') # Agent setup gr.HTML("""

Agent Setup

Algorithm: PPO (Proximal Policy Optimization)
Action Space: Multi-Discrete (Direction × Size) — categorical, collapse-resistant
Observation: 30-day window of OHLCV + 23 technical indicators (699-dim)
Reward: Differential Sharpe with drawdown, idle & overtrading penalties
Execution: Next-bar fills + slippage (no lookahead)
""") # Validation gr.HTML("""

Validation

Walk-forward across rolling 4y-train / 6mo-test folds spanning 2015–2026, scaler fit per-fold (no leakage).
Scored on alpha vs Buy & Hold + information ratio, with bootstrap significance — not raw return.
""") # Forecast & extensions gr.HTML("""

Forecast & Extensions

Price model: Geometric Brownian Motion (Black-Scholes), lognormal ±1σ/±2σ bands
Data: Live Yahoo Finance fetch with warmup padding; custom date range supported
Multi-asset: Cross-sectional portfolio agent over 16 stocks vs equal-weight (separate tab)
""") gr.HTML("""

ⓘ Select a ticker on the left and click "Run Pipeline" to generate the full performance report and AI insights.

""") # ── Toggle custom date inputs ────────────────────────── def _toggle_dates(use_custom): return ( gr.update(visible=use_custom), gr.update(visible=use_custom), gr.update(interactive=not use_custom), ) use_custom_dates.change( fn=_toggle_dates, inputs=[use_custom_dates], outputs=[start_date, end_date, period_dd], show_progress="hidden", ) # ── Wire up the run button ───────────────────────────── run_btn.click( fn=run_pipeline, inputs=[ticker_dd, period_dd, show_signals, forecast_slider, use_custom_dates, start_date, end_date], outputs=[ status_box, # pipeline log kpi_table, # Tab 1 — metrics table equity_plot, # Tab 1 — equity + drawdown action_plot, # Tab 1 — action donut kpi_bar_plot, # Tab 1 — KPI bars signal_plot, # Tab 1 — price + signals analysis_html_out, # Tab 2 — AI analysis HTML price_forecast_plot, # Tab 3 — price forecast portfolio_forecast_plot, # Tab 4 — portfolio forecast forecast_summary_out, # Tab 3 — forecast summary card ], ) # ── Wire up the multi-asset portfolio tab ────────────── ma_period = period_dd # reuse the same period selector def _run_ma(period): if not MULTI_ASSET_TAB: return (None, None, None, None, "Multi-asset module unavailable.") try: return run_multi_asset_pipeline(period) except Exception as e: return (None, None, None, None, f"Error: {e}") ma_run_btn.click( fn=_run_ma, inputs=[ma_period], outputs=[ma_kpi_table, ma_equity_plot, ma_weights_plot, ma_contrib_plot, ma_status], ) return demo if __name__ == "__main__": demo = build_interface() demo.queue() demo.launch( server_name="0.0.0.0", server_port=7860, # default Spaces port share=False, # set True for a public ngrok link locally )