nirmanpatel's picture
Update app.py
68ff6d9 verified
Raw
History Blame Contribute Delete
76.7 kB
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 &amp; 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 &amp; 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 &amp; 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 &amp; 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
)