import matplotlib import numpy as np import pandas as pd matplotlib.use("Agg") import matplotlib.gridspec as gridspec import matplotlib.pyplot as plt import matplotlib.ticker as mticker from sklearn.linear_model import LinearRegression # ── Try SB3 import ───────────────────────────────────────────── try: from stable_baselines3 import PPO SB3_AVAILABLE = True except ImportError: SB3_AVAILABLE = False try: from trading_env import TradingEnv ENV_AVAILABLE = True except ImportError: ENV_AVAILABLE = False from sklearn.preprocessing import MinMaxScaler # PRICE TREND FORECAST def forecast_price_trend( raw_df: pd.DataFrame, forecast_days: int = 30, lookback_days: int = 60, ) -> dict: prices = raw_df["close"].values[-lookback_days:] S0 = float(prices[-1]) # ── Estimate GBM parameters from daily log returns ─────────────── log_ret = np.diff(np.log(prices)) mu_daily = float(np.mean(log_ret)) # drift of log-returns sigma_daily = float(np.std(log_ret, ddof=1)) # daily volatility if not np.isfinite(sigma_daily) or sigma_daily < 1e-9: sigma_daily = 1e-9 # ── Horizon grid (in trading days) ─────────────────────────────── t = np.arange(1, forecast_days + 1, dtype=np.float64) # Expected price path under GBM: E[S_t] = S0 * exp(mu * t) # (using the log-return mean as drift; mu already includes -0.5 sigma^2 # implicitly because it is the mean of realised log returns). log_mean = np.log(S0) + mu_daily * t log_sd = sigma_daily * np.sqrt(t) # sqrt-time uncertainty growth # Central projection = median of the lognormal (exp of the log-mean path). forecast_vals = np.exp(log_mean) # Lognormal quantile bands (multiplicative, always positive, fan out). upper_1 = np.exp(log_mean + 1.0 * log_sd) lower_1 = np.exp(log_mean - 1.0 * log_sd) upper_2 = np.exp(log_mean + 2.0 * log_sd) lower_2 = np.exp(log_mean - 2.0 * log_sd) # ── Future trading dates ───────────────────────────────────────── last_date = raw_df.index[-1] future_dates = pd.bdate_range( start=last_date + pd.Timedelta(days=1), periods=forecast_days, ) # ── Trend direction from annualised drift ──────────────────────── mu_annual_pct = mu_daily * 252 * 100 if mu_daily * 100 > 0.05: direction = "bullish" elif mu_daily * 100 < -0.05: direction = "bearish" else: direction = "neutral" return { "forecast_dates": future_dates, "forecast_prices": forecast_vals, "upper_1sigma": upper_1, "lower_1sigma": lower_1, "upper_2sigma": upper_2, "lower_2sigma": lower_2, "last_price": S0, "trend_direction": direction, "trend_strength": round(mu_daily * S0, 4), # ~$/day drift at S0 "slope_pct_day": round(mu_daily * 100, 4), # drift %/day "mu_annual_pct": round(mu_annual_pct, 2), # annualised drift "sigma_annual_pct": round( sigma_daily * np.sqrt(252) * 100, 2 ), # annualised vol "model": "GBM (Black-Scholes price process)", "lookback_days": lookback_days, } # AGENT PORTFOLIO FORECAST def _engineer_features_for_forecast(df: pd.DataFrame) -> pd.DataFrame: import preprocess as pp df = pp.handle_missing(df.copy()) for fn in [ pp.add_sma, pp.add_ema, pp.add_rsi, pp.add_macd, pp.add_bollinger_bands, pp.add_obv, pp.add_returns, pp.add_adx, pp.add_long_momentum, pp.add_roc, pp.add_realised_vol, ]: df = fn(df) df.dropna(inplace=True) feature_cols = list(df.columns) scaler = MinMaxScaler(feature_range=(0, 1)) df[feature_cols] = scaler.fit_transform(df[feature_cols]) return df def _infer_lookback(model, n_features: int) -> int: 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: return market // n_features return 30 def forecast_agent_portfolio( ticker: str, raw_df: pd.DataFrame, forecast_trend: dict, model_dir: str = "models", initial_capital: float = 10_000.0, transaction_cost: float = 0.001, n_simulations: int = 3, ) -> dict: if not SB3_AVAILABLE or not ENV_AVAILABLE: return {"model_loaded": False, "sim_values": [], "forecast_dates": []} # Try best_model first (current retrained discrete-action models), then # legacy tuned. Loading a stale *_tuned model would mismatch the obs shape. model_path = None import os for name in ["best_model", "best_model_tuned"]: path = f"{model_dir}/{ticker}/{name}.zip" if os.path.exists(path): model_path = path break if model_path is None: return { "model_loaded": False, "sim_values": [], "forecast_dates": [], "error": f"No model found in {model_dir}/{ticker}/", } model = PPO.load(model_path) forecast_dates = forecast_trend["forecast_dates"] forecast_prices = forecast_trend["forecast_prices"] sigma = forecast_trend["upper_1sigma"] - forecast_prices all_sim_values = [] _last_shape = None for sim_idx in range(n_simulations): # Add small Gaussian noise to create scenario variation if sim_idx == 0: noise = np.zeros(len(forecast_prices)) # central elif sim_idx == 1: noise = 0.5 * sigma # optimistic else: noise = -0.5 * sigma # pessimistic sim_prices = np.maximum(forecast_prices + noise, 0.01) # Build a synthetic DataFrame extending the real history # with the forecasted prices so the agent has a proper # lookback window when it starts predicting extended_raw = raw_df.copy() for i, (date, price) in enumerate(zip(forecast_dates, sim_prices)): new_row = pd.DataFrame( { "open": [price * 0.999], "high": [price * 1.005], "low": [price * 0.995], "close": [price], "volume": [extended_raw["volume"].mean()], }, index=[date], ) extended_raw = pd.concat([extended_raw, new_row]) # Engineer features on the full extended series extended_feat = _engineer_features_for_forecast(extended_raw) # Trim to just the forecast period rows n_features = len(extended_feat.columns) lookback_window = _infer_lookback(model, n_features) # Only keep the last (lookback + forecast_days) rows forecast_len = len(forecast_dates) needed = lookback_window + forecast_len extended_feat = extended_feat.iloc[-needed:].reset_index(drop=True) # Build env on the forecast slice env = TradingEnv( df=extended_feat, initial_capital=initial_capital, base_cost_pct=transaction_cost, market_impact_pct=0.0005, overtrading_threshold=20, overtrading_penalty_pct=0.0001, lookback_window=lookback_window, ) obs, _ = env.reset() # Check shape compatibility if obs.shape[0] != model.observation_space.shape[0]: env.close() _last_shape = (obs.shape[0], model.observation_space.shape[0]) continue # Step the agent and record the direction it executes each day. The # env trades on NORMALISED prices, so its internal portfolio_value is # not real dollars and must NOT be rescaled. Instead we replay the agent's executed # directions on the REAL forecast prices to get a faithful dollar P&L. directions = [] done = truncated = False while not done and not truncated: action, _ = model.predict(obs, deterministic=(sim_idx == 0)) obs, _, done, truncated, info = env.step(action) directions.append(int(info["direction"])) env.close() # Replay on real prices: sim_prices are the GBM forecast in dollars. # Align directions to the forecast horizon (one decision per day). cash = float(initial_capital) shares = 0.0 dollar_values = [] n_steps = min(len(directions), len(sim_prices)) for k in range(n_steps): price = float(sim_prices[k]) d = directions[k] if d == 1 and cash > 0 and price > 1e-8: # buy with all cash buy_price = price * (1.0 + 0.0005) qty = cash / buy_price cash -= qty * buy_price * (1.0 + transaction_cost) shares += qty elif d == 2 and shares > 0: # sell all sell_price = price * (1.0 - 0.0005) cash += shares * sell_price * (1.0 - transaction_cost) shares = 0.0 dollar_values.append(cash + shares * price) # Pad/trim to the forecast horizon dollar_values = dollar_values[:forecast_len] while len(dollar_values) < forecast_len: dollar_values.append( dollar_values[-1] if dollar_values else initial_capital ) all_sim_values.append(dollar_values) result = { "model_loaded": True, "sim_values": all_sim_values, "forecast_dates": forecast_dates, "base_projection": all_sim_values[0] if all_sim_values else [], } if not all_sim_values and _last_shape is not None: result["error"] = ( f"Observation shape mismatch: env produced {_last_shape[0]}, " f"model expects {_last_shape[1]}. The model was trained with a " f"different feature/lookback config — retrain or check features." ) return result # PLOT — PRICE FORECAST def plot_price_forecast( ticker: str, raw_df: pd.DataFrame, forecast: dict, theme: dict, history_days: int = 90, ) -> "matplotlib.figure.Figure": acc = theme["accent"] grn = theme["green"] red = theme["red"] bg = theme["bg"] panel = theme["panel"] bdr = theme["border"] muted = theme["muted"] txt = theme["text"] # Historical slice hist_prices = raw_df["close"].values[-history_days:] hist_dates = raw_df.index[-history_days:] returns = np.diff(hist_prices) / hist_prices[:-1] * 100 fig = plt.figure(figsize=(13, 8), facecolor=bg) gs = gridspec.GridSpec(2, 1, height_ratios=[3, 1], hspace=0.1, figure=fig) ax1 = fig.add_subplot(gs[0]) ax2 = fig.add_subplot(gs[1]) direction_color = ( grn if forecast["trend_direction"] == "bullish" else (red if forecast["trend_direction"] == "bearish" else muted) ) direction_label = forecast["trend_direction"].upper() fig.suptitle( f" {ticker} · {history_days}-Day History + {len(forecast['forecast_dates'])}-Day Forecast" f" [{direction_label} {forecast['slope_pct_day']:+.3f}%/day]", fontsize=13, fontweight="bold", color=direction_color, x=0.02, ha="left", y=0.98, ) ax1.set_facecolor(panel) # Historical price ax1.plot( hist_dates, hist_prices, color=txt, linewidth=1.6, label="Historical Close", zorder=3, ) # Confidence bands fd = forecast["forecast_dates"] fp = forecast["forecast_prices"] ax1.fill_between( fd, forecast["lower_2sigma"], forecast["upper_2sigma"], alpha=0.10, color=direction_color, label="±2σ band", ) ax1.fill_between( fd, forecast["lower_1sigma"], forecast["upper_1sigma"], alpha=0.20, color=direction_color, label="±1σ band", ) # Central forecast line ax1.plot( fd, fp, color=direction_color, linewidth=2.0, linestyle="--", label=f"Trend forecast", zorder=4, ) # Vertical separator at today ax1.axvline(x=hist_dates[-1], color=muted, linewidth=1.0, linestyle=":", alpha=0.7) ax1.text( hist_dates[-1], ax1.get_ylim()[0] if ax1.get_ylim()[0] != ax1.get_ylim()[1] else hist_prices.min(), " Today", color=muted, fontsize=8, va="bottom", ) ax1.set_ylabel("Price ($)", color=muted, fontsize=10) ax1.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"${x:,.2f}")) ax1.legend(loc="upper left", fontsize=9, framealpha=0.9) ax1.grid(True, linestyle="--", alpha=0.35) plt.setp(ax1.get_xticklabels(), visible=False) # ── Return distribution ──────────────────────────────────── ax2.set_facecolor(panel) pos_returns = returns[returns >= 0] neg_returns = returns[returns < 0] ax2.hist(pos_returns, bins=30, color=grn, alpha=0.7, label="Positive") ax2.hist(neg_returns, bins=30, color=red, alpha=0.7, label="Negative") ax2.axvline(x=0, color=muted, linewidth=0.8) ax2.axvline( x=float(np.mean(returns)), color=direction_color, linewidth=1.5, linestyle="--", label=f"Mean {np.mean(returns):+.2f}%", ) ax2.set_ylabel("Frequency", color=muted, fontsize=9) ax2.set_xlabel("Daily Return (%)", color=muted, fontsize=9) ax2.set_title("Daily Return Distribution", color=txt, fontsize=10, pad=6) ax2.legend(fontsize=8, framealpha=0.85) ax2.grid(True, linestyle="--", alpha=0.3) for ax in [ax1, ax2]: ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.spines["bottom"].set_edgecolor(bdr) ax.spines["left"].set_edgecolor(bdr) plt.tight_layout(rect=(0, 0, 1, 0.96)) return fig # PLOT — AGENT PORTFOLIO FORECAST def plot_portfolio_forecast( ticker: str, forecast_trend: dict, portfolio_forecast: dict, initial_capital: float, theme: dict, ) -> "matplotlib.figure.Figure": """ Plot the three simulated portfolio trajectories: Central (no noise), Optimistic (+0.5σ), Pessimistic (−0.5σ) alongside the trend-implied Buy & Hold baseline. """ acc = theme["accent"] grn = theme["green"] red = theme["red"] amb = "#f59e0b" bg = theme["bg"] panel = theme["panel"] bdr = theme["border"] muted = theme["muted"] txt = theme["text"] fig, ax = plt.subplots(figsize=(13, 5), facecolor=bg) ax.set_facecolor(panel) dates = forecast_trend["forecast_dates"] if not portfolio_forecast["model_loaded"] or not portfolio_forecast["sim_values"]: ax.text( 0.5, 0.5, f"No trained model found for {ticker}.\nPortfolio forecast unavailable.", ha="center", va="center", color=muted, fontsize=12, transform=ax.transAxes, ) ax.axis("off") return fig sim_values = portfolio_forecast["sim_values"] labels = ["Central (base case)", "Optimistic (+0.5σ)", "Pessimistic (−0.5σ)"] colors = [acc, grn, red] styles = ["-", "--", "-."] alphas = [1.0, 0.75, 0.75] for i, (vals, label, color, ls, alpha) in enumerate( zip(sim_values, labels, colors, styles, alphas) ): min_len = min(len(dates), len(vals)) ret = (vals[min_len - 1] - initial_capital) / initial_capital * 100 ax.plot( dates[:min_len], vals[:min_len], label=f"{label} ({ret:+.1f}%)", color=color, linewidth=2.0 if i == 0 else 1.4, linestyle=ls, alpha=alpha, ) # Buy & Hold baseline using trend forecast prices fp = forecast_trend["forecast_prices"] lp = forecast_trend["last_price"] if lp > 1e-8: bnh_shares = int(initial_capital // lp) bnh_vals = [initial_capital + bnh_shares * (p - lp) for p in fp] else: bnh_vals = [initial_capital] * len(fp) bnh_ret = (bnh_vals[-1] - initial_capital) / initial_capital * 100 min_len = min(len(dates), len(bnh_vals)) ax.plot( dates[:min_len], bnh_vals[:min_len], label=f"Buy & Hold (trend) ({bnh_ret:+.1f}%)", color=amb, linewidth=1.4, linestyle=":", alpha=0.85, ) ax.axhline(y=initial_capital, color=muted, linewidth=0.8, linestyle=":", alpha=0.5) ax.set_title( f"{ticker} · Agent Portfolio Forecast — Next {len(dates)} Trading Days", color=acc, fontsize=12, loc="left", ) ax.set_ylabel("Projected Portfolio Value ($)", color=muted, fontsize=10) ax.set_xlabel("Date", color=muted, fontsize=10) ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, _: f"${x:,.0f}")) ax.legend(loc="upper left", fontsize=9, framealpha=0.9) ax.grid(True, linestyle="--", alpha=0.35) ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.spines["bottom"].set_edgecolor(bdr) ax.spines["left"].set_edgecolor(bdr) # Disclaimer ax.text( 0.99, 0.02, "Forecast based on Geometric Brownian Motion (Black-Scholes). Not financial advice.", transform=ax.transAxes, ha="right", va="bottom", fontsize=8, color=muted, style="italic", ) plt.tight_layout() return fig def build_forecast_summary_html( ticker: str, forecast: dict, portfolio_forecast: dict, initial_capital: float, theme: dict, ) -> str: """ Small HTML card summarising the forecast in numbers. Sits above the forecast charts in the dashboard. """ acc = theme["accent"] grn = theme["green"] red = theme["red"] txt = theme["text"] muted = theme["muted"] panel = theme["panel"] bdr = theme["border"] direction = forecast["trend_direction"] color_map = {"bullish": grn, "bearish": red, "neutral": muted} dir_color = color_map.get(direction, muted) dir_emoji = {"bullish": "↑", "bearish": "↓", "neutral": "→"}.get(direction, "→") last_price = forecast["last_price"] end_price = forecast["forecast_prices"][-1] price_change = (end_price - last_price) / last_price * 100 proj_end = "" if portfolio_forecast["model_loaded"] and portfolio_forecast["sim_values"]: vals = portfolio_forecast["sim_values"][0] proj_end_val = vals[-1] if vals else initial_capital proj_ret = (proj_end_val - initial_capital) / initial_capital * 100 proj_end = ( f'
' f'
Agent Projected Return
' f'
' f"{proj_ret:+.2f}%
" ) n_days = len(forecast["forecast_dates"]) html = f"""
{dir_emoji} {direction} {ticker} · {n_days}-Day Forecast GBM · Black-Scholes price process · Not financial advice
Last Close
${last_price:,.2f}
Projected Price
${end_price:,.2f}
Price Change
{price_change:+.2f}%
{proj_end}
""" return html