Spaces:
Running
Running
| 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 = ( | |
| '<div style="padding:20px;color:#64748b;' | |
| 'font-family:IBM Plex Mono,monospace">' | |
| "ai_analysis.py not found. Place it in the same directory as app.py." | |
| "</div>" | |
| ) | |
| 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 = ( | |
| '<div style="padding:20px;color:#ef4444;' | |
| f'font-family:IBM Plex Mono,monospace">ERROR: {e}</div>' | |
| ) | |
| 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 = """ | |
| <div class="hero-header"> | |
| <div class="hero-content"> | |
| <div class="hero-badge"> | |
| AI Driven Portfolio Analytics | |
| </div> | |
| <h1> | |
| Algorithm-Reinforced Trading Agent | |
| </h1> | |
| <div class="hero-features"> | |
| <span class="feature-pill rl"> | |
| <i class="fa-solid fa-brain"></i> | |
| Reinforcement Learning | |
| </span> | |
| <span class="feature-pill ppo"> | |
| <i class="fa-solid fa-robot"></i> | |
| PPO Agent | |
| </span> | |
| <span class="feature-pill market"> | |
| <i class="fa-solid fa-chart-line"></i> | |
| Live Market Data | |
| </span> | |
| <span class="feature-pill backtest"> | |
| <i class="fa-solid fa-chart-column"></i> | |
| Backtesting Engine | |
| </span> | |
| <span class="feature-pill risk"> | |
| <i class="fa-solid fa-shield-halved"></i> | |
| Risk Analytics | |
| </span> | |
| </div> | |
| <div class="hero-stats"> | |
| <div> | |
| <span>Algorithm</span> | |
| <strong>PPO</strong> | |
| </div> | |
| <div> | |
| <span>Data Source</span> | |
| <strong>Yahoo Finance</strong> | |
| </div> | |
| <div> | |
| <span>Framework</span> | |
| <strong>Stable Baselines3</strong> | |
| </div> | |
| <div> | |
| <span>Mode</span> | |
| <strong>Real-Time</strong> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| MODEL_STATUS_HTML = f""" | |
| <div style=" | |
| font-family: 'IBM Plex Mono', monospace; | |
| font-size: 11px; | |
| color: {"#16a34a" if SB3_AVAILABLE and ENV_AVAILABLE else "#dc2626"}; | |
| background: #ffffff; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 12px; | |
| padding: 10px 14px; | |
| letter-spacing: 0.04em; | |
| box-shadow: 0 2px 8px rgba(15,23,42,0.04); | |
| "> | |
| { | |
| "β SB3 + TradingEnv loaded: PPO inference ready" | |
| if SB3_AVAILABLE and ENV_AVAILABLE | |
| else "β Model dependencies missing β baseline comparison only" | |
| } | |
| </div> | |
| """ | |
| 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('<p class="section-label">Data Controls</p>') | |
| 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('<p class="section-label">Pipeline log</p>') | |
| 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( | |
| '<p class="section-label" style="margin-top:12px">' | |
| "Performance metrics</p>" | |
| ) | |
| kpi_table = gr.Dataframe( | |
| headers=[ | |
| "Metric", | |
| "RL Agent (PPO)", | |
| "Buy & Hold", | |
| "SMA Crossover", | |
| ], | |
| label="", | |
| wrap=True, | |
| ) | |
| gr.HTML( | |
| '<p class="section-label" style="margin-top:16px">' | |
| "Equity curves & drawdown</p>" | |
| ) | |
| equity_plot = gr.Plot(label="") | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.HTML( | |
| '<p class="section-label">Agent action breakdown</p>' | |
| ) | |
| action_plot = gr.Plot(label="") | |
| with gr.Column(scale=2): | |
| gr.HTML('<p class="section-label">KPI comparison</p>') | |
| kpi_bar_plot = gr.Plot(label="") | |
| gr.HTML( | |
| '<p class="section-label" style="margin-top:16px">' | |
| "Price chart with agent signals</p>" | |
| ) | |
| signal_plot = gr.Plot(label="") | |
| # TAB 2 β AI Analysis | |
| with gr.Tab("AI Analysis"): | |
| analysis_html_out = gr.HTML( | |
| value=( | |
| '<div style="padding:40px;text-align:center;' | |
| 'font-family:IBM Plex Mono,monospace;color:#64748b">' | |
| "Run the pipeline to generate AI analysis.</div>" | |
| ) | |
| ) | |
| gr.HTML(""" | |
| <div style=" | |
| font-family:'IBM Plex Mono',monospace; | |
| font-size:11px; | |
| color:#64748b; | |
| padding:16px 0 4px; | |
| letter-spacing:.04em; | |
| border-top: 1px solid #e2e8f0; | |
| margin-top: 12px; | |
| "> | |
| Powered by Gemini Β· Analyses strengths, weaknesses, | |
| failure diagnosis, and improvement roadmap | |
| based on your backtest results. | |
| </div> | |
| """) | |
| # TAB 3 β Price Forecast | |
| with gr.Tab("Price Forecast"): | |
| gr.HTML(""" | |
| <div style=" | |
| font-family:'IBM Plex Mono',monospace; | |
| font-size:11px; | |
| color:#64748b; | |
| padding:10px 0 4px; | |
| letter-spacing:.04em; | |
| "> | |
| Geometric Brownian Motion (Black-Scholes) with Β±1Ο / Β±2Ο lognormal bands. | |
| Drift & volatility estimated from recent log-returns Β· Not financial advice. | |
| </div> | |
| """) | |
| forecast_summary_out = gr.HTML( | |
| value=( | |
| '<div style="padding:20px;text-align:center;' | |
| 'font-family:IBM Plex Mono,monospace;color:#64748b">' | |
| "Run the pipeline to generate forecast.</div>" | |
| ) | |
| ) | |
| gr.HTML( | |
| '<p class="section-label" style="margin-top:4px">' | |
| "Historical price + trend projection</p>" | |
| ) | |
| price_forecast_plot = gr.Plot(label="") | |
| # TAB 4 β Agent Portfolio Forecast | |
| with gr.Tab("Agent Portfolio Forecast"): | |
| gr.HTML(""" | |
| <div style=" | |
| font-family:'IBM Plex Mono',monospace; | |
| font-size:11px; | |
| color:#64748b; | |
| padding:10px 0 4px; | |
| letter-spacing:.04em; | |
| "> | |
| 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. | |
| </div> | |
| """) | |
| gr.HTML( | |
| '<p class="section-label" style="margin-top:8px">' | |
| "Projected portfolio value β 3 scenarios</p>" | |
| ) | |
| portfolio_forecast_plot = gr.Plot(label="") | |
| gr.HTML(""" | |
| <div style=" | |
| font-family:'IBM Plex Mono',monospace; | |
| font-size:10px; | |
| color:#94a3b8; | |
| padding:12px 0 4px; | |
| letter-spacing:.04em; | |
| border-top:1px solid #e2e8f0; | |
| margin-top:12px; | |
| "> | |
| 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. | |
| </div> | |
| """) | |
| with gr.Tab("Multi-Asset Portfolio"): | |
| gr.HTML(""" | |
| <div style="font-family:'IBM Plex Sans',sans-serif;padding:6px 0 4px"> | |
| <!-- Info cards: column layout --> | |
| <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:18px"> | |
| <div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:16px 18px"> | |
| <p style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:#2563eb;text-transform:uppercase;letter-spacing:.12em;margin:0 0 8px;font-weight:600">What it does</p> | |
| <p style="font-size:13px;color:#0f172a;margin:0;line-height:1.55"> | |
| A cross-sectional agent that reallocates across a 16-stock universe each day, | |
| outputting portfolio weights that sum to 1. It decides <i>relative</i> conviction | |
| between stocks β not single-stock direction. | |
| </p> | |
| </div> | |
| <div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:16px 18px"> | |
| <p style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:#16a34a;text-transform:uppercase;letter-spacing:.12em;margin:0 0 8px;font-weight:600">How it's scored</p> | |
| <p style="font-size:13px;color:#0f172a;margin:0;line-height:1.55"> | |
| Benchmarked against an <b>equal-weight</b> portfolio of the same universe, so | |
| alpha measures allocation skill <i>above naive diversification</i>. Holding | |
| everything equally scores zero alpha by construction. | |
| </p> | |
| </div> | |
| <div style="background:#ffffff;border:1px solid #e2e8f0;border-radius:14px;padding:16px 18px"> | |
| <p style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:#f59e0b;text-transform:uppercase;letter-spacing:.12em;margin:0 0 8px;font-weight:600">Where models come from</p> | |
| <p style="font-size:13px;color:#0f172a;margin:0;line-height:1.55"> | |
| Uses agents from <code style="background:#f1f5f9;padding:1px 5px;border-radius:4px;font-size:11px">models/multiasset/</code>, | |
| trained via <code style="background:#f1f5f9;padding:1px 5px;border-radius:4px;font-size:11px">walk_forward</code> strategy. | |
| Fully independent of the single-asset agent in the other tabs. | |
| </p> | |
| </div> | |
| </div> | |
| <!-- Universe by sector --> | |
| <p style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:#64748b;text-transform:uppercase;letter-spacing:.12em;margin:0 0 10px;font-weight:600">Universe Β· 16 stocks across 6 sectors</p> | |
| <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:8px"> | |
| <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:12px 14px"> | |
| <p style="font-size:10px;color:#2563eb;font-weight:600;margin:0 0 6px;font-family:'IBM Plex Mono',monospace">TECHNOLOGY</p> | |
| <p style="font-size:12px;color:#334155;margin:0;font-family:'IBM Plex Mono',monospace;line-height:1.7">AAPL Β· MSFT Β· GOOGL Β· AMZN Β· NVDA</p> | |
| </div> | |
| <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:12px 14px"> | |
| <p style="font-size:10px;color:#2563eb;font-weight:600;margin:0 0 6px;font-family:'IBM Plex Mono',monospace">FINANCIALS</p> | |
| <p style="font-size:12px;color:#334155;margin:0;font-family:'IBM Plex Mono',monospace;line-height:1.7">JPM Β· BAC</p> | |
| </div> | |
| <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:12px 14px"> | |
| <p style="font-size:10px;color:#2563eb;font-weight:600;margin:0 0 6px;font-family:'IBM Plex Mono',monospace">HEALTHCARE</p> | |
| <p style="font-size:12px;color:#334155;margin:0;font-family:'IBM Plex Mono',monospace;line-height:1.7">JNJ Β· UNH Β· PFE</p> | |
| </div> | |
| <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:12px 14px"> | |
| <p style="font-size:10px;color:#2563eb;font-weight:600;margin:0 0 6px;font-family:'IBM Plex Mono',monospace">ENERGY</p> | |
| <p style="font-size:12px;color:#334155;margin:0;font-family:'IBM Plex Mono',monospace;line-height:1.7">XOM Β· CVX</p> | |
| </div> | |
| <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:12px 14px"> | |
| <p style="font-size:10px;color:#2563eb;font-weight:600;margin:0 0 6px;font-family:'IBM Plex Mono',monospace">CONSUMER STAPLES</p> | |
| <p style="font-size:12px;color:#334155;margin:0;font-family:'IBM Plex Mono',monospace;line-height:1.7">PG Β· KO Β· WMT</p> | |
| </div> | |
| <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:12px 14px"> | |
| <p style="font-size:10px;color:#2563eb;font-weight:600;margin:0 0 6px;font-family:'IBM Plex Mono',monospace">INDUSTRIALS</p> | |
| <p style="font-size:12px;color:#334155;margin:0;font-family:'IBM Plex Mono',monospace;line-height:1.7">CAT</p> | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| ma_run_btn = gr.Button("βΆ Run Portfolio Agent", | |
| variant="primary", size="lg") | |
| ma_status = gr.Textbox(label="Status", lines=2, | |
| interactive=False) | |
| gr.HTML('<p class="section-label" style="margin-top:8px">Portfolio KPIs vs Equal-Weight</p>') | |
| ma_kpi_table = gr.Dataframe(interactive=False, wrap=True) | |
| gr.HTML('<p class="section-label">Portfolio value vs equal-weight</p>') | |
| ma_equity_plot = gr.Plot(label="") | |
| gr.HTML('<p class="section-label">Allocation weights over time</p>') | |
| ma_weights_plot = gr.Plot(label="") | |
| gr.HTML('<p class="section-label">Average allocation by asset</p>') | |
| ma_contrib_plot = gr.Plot(label="") | |
| gr.HTML(""" | |
| <div style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:#94a3b8;padding:12px 0 4px;letter-spacing:.04em;border-top:1px solid #e2e8f0;margin-top:12px"> | |
| Research analysis, not financial advice. A single live window is illustrative, | |
| not proof of edge. | |
| </div> | |
| """) | |
| # ββ 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('<p class="section-label" style="margin-top:24px">Configuration</p>') | |
| # Agent setup | |
| gr.HTML(""" | |
| <div style="background:#f8fafc; border:1px solid #e2e8f0; border-radius:14px; padding:12px 14px; margin-top:12px;"> | |
| <p class="section-label" style="margin-bottom:12px;">Agent Setup</p> | |
| <div style="font-family:IBM Plex Mono,monospace; font-size:11px; color:#475569; line-height:1.6;"> | |
| <div style="margin-bottom:10px;"> | |
| <strong style="color:#2563eb;">Algorithm:</strong> PPO (Proximal Policy Optimization) | |
| </div> | |
| <div style="margin-bottom:10px;"> | |
| <strong style="color:#2563eb;">Action Space:</strong> Multi-Discrete (Direction Γ Size) β categorical, collapse-resistant | |
| </div> | |
| <div style="margin-bottom:10px;"> | |
| <strong style="color:#2563eb;">Observation:</strong> 30-day window of OHLCV + 23 technical indicators (699-dim) | |
| </div> | |
| <div style="margin-bottom:10px;"> | |
| <strong style="color:#2563eb;">Reward:</strong> Differential Sharpe with drawdown, idle & overtrading penalties | |
| </div> | |
| <div> | |
| <strong style="color:#2563eb;">Execution:</strong> Next-bar fills + slippage (no lookahead) | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| # Validation | |
| gr.HTML(""" | |
| <div style="background:#f0f9ff; border:1px solid #bae6fd; border-radius:14px; padding:12px 14px; margin-top:16px;"> | |
| <p class="section-label" style="color:#0369a1 !important; margin-bottom:12px;">Validation</p> | |
| <div style="font-family:IBM Plex Mono,monospace; font-size:11px; color:#0c4a6e; line-height:1.6;"> | |
| <div style="margin-bottom:8px;"> | |
| <strong>Walk-forward</strong> across rolling 4y-train / 6mo-test folds spanning 2015β2026, scaler fit per-fold (no leakage). | |
| </div> | |
| <div> | |
| Scored on <strong>alpha vs Buy & Hold</strong> + information ratio, with bootstrap significance β not raw return. | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| # Forecast & extensions | |
| gr.HTML(""" | |
| <div style="background:#f8fafc; border:1px solid #e2e8f0; border-radius:14px; padding:12px 14px; margin-top:16px;"> | |
| <p class="section-label" style="margin-bottom:12px;">Forecast & Extensions</p> | |
| <div style="font-family:IBM Plex Mono,monospace; font-size:11px; color:#475569; line-height:1.6;"> | |
| <div style="margin-bottom:10px;"> | |
| <strong style="color:#2563eb;">Price model:</strong> Geometric Brownian Motion (Black-Scholes), lognormal Β±1Ο/Β±2Ο bands | |
| </div> | |
| <div style="margin-bottom:10px;"> | |
| <strong style="color:#2563eb;">Data:</strong> Live Yahoo Finance fetch with warmup padding; custom date range supported | |
| </div> | |
| <div> | |
| <strong style="color:#2563eb;">Multi-asset:</strong> Cross-sectional portfolio agent over 16 stocks vs equal-weight (separate tab) | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| gr.HTML(""" | |
| <div style="margin-top:24px; padding:0 10px;"> | |
| <p style="font-family:IBM Plex Mono,monospace; font-size:10px; color:#94a3b8; line-height:1.5;"> | |
| β Select a ticker on the left and click "Run Pipeline" to generate the full performance report and AI insights. | |
| </p> | |
| </div> | |
| """) | |
| # ββ 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 | |
| ) |