rl-trading-agent / forecasting.py
nirmanpatel's picture
Update forecasting.py
97c1f44 verified
Raw
History Blame Contribute Delete
21.9 kB
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'<div style="text-align:center;padding:12px 16px;'
f'background:#eff6ff;border-radius:8px;border:1px solid #bfdbfe">'
f'<div style="font-size:10px;color:{muted};letter-spacing:.08em;'
f'text-transform:uppercase;margin-bottom:4px">Agent Projected Return</div>'
f'<div style="font-size:20px;font-weight:700;color:{acc}">'
f"{proj_ret:+.2f}%</div></div>"
)
n_days = len(forecast["forecast_dates"])
html = f"""
<div style="
font-family:'IBM Plex Mono',monospace;
background:{panel};
border:1px solid {bdr};
border-radius:12px;
padding:18px 22px;
margin-bottom:12px;
">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px">
<span style="
background:{dir_color};color:#fff;
font-size:10px;font-weight:600;
letter-spacing:.1em;text-transform:uppercase;
padding:3px 10px;border-radius:6px;
">{dir_emoji} {direction}</span>
<span style="color:{muted};font-size:11px">
{ticker} · {n_days}-Day Forecast
</span>
<span style="color:{muted};font-size:10px;margin-left:auto;font-style:italic">
GBM · Black-Scholes price process · Not financial advice
</span>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px">
<div style="text-align:center;padding:12px 16px;
background:#f8fafc;border-radius:8px;border:1px solid {bdr}">
<div style="font-size:10px;color:{muted};letter-spacing:.08em;
text-transform:uppercase;margin-bottom:4px">Last Close</div>
<div style="font-size:20px;font-weight:700;color:{txt}">${last_price:,.2f}</div>
</div>
<div style="text-align:center;padding:12px 16px;
background:#f8fafc;border-radius:8px;border:1px solid {bdr}">
<div style="font-size:10px;color:{muted};letter-spacing:.08em;
text-transform:uppercase;margin-bottom:4px">Projected Price</div>
<div style="font-size:20px;font-weight:700;color:{dir_color}">${end_price:,.2f}</div>
</div>
<div style="text-align:center;padding:12px 16px;
background:#f8fafc;border-radius:8px;border:1px solid {bdr}">
<div style="font-size:10px;color:{muted};letter-spacing:.08em;
text-transform:uppercase;margin-bottom:4px">Price Change</div>
<div style="font-size:20px;font-weight:700;color:{dir_color}">{price_change:+.2f}%</div>
</div>
{proj_end}
</div>
</div>
"""
return html