value-at-risk / src /utils.py
kameshcodes's picture
fix: add environment-aware logging with prod/dev/test levels and fix CSS not applying in Gradio 6
e60df7c
"""
utils.py -- Shared utilities: data fetching, return computation, and plotting.
"""
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import yfinance as yf
from loguru import logger
def fetch_prices(
ticker: str,
lookback: int | None = None,
var_date: pd.Timestamp | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> pd.Series:
"""Download close prices for *ticker*.
Two modes of operation:
**Lookback mode** (default): Supply *lookback* and optionally *var_date*.
Fetches the last *lookback* trading days ending before *var_date*.
**Date-range mode**: Supply *start_date* and *end_date* (YYYY-MM-DD strings).
Fetches all trading days in that window, plus one prior day so the
first daily return falls on or near *start_date*.
"""
if start_date and end_date:
# Date-range mode (stress periods)
start = pd.to_datetime(start_date) - pd.Timedelta(days=10)
end = pd.to_datetime(end_date) + pd.Timedelta(days=1) # yfinance 'end' is exclusive
logger.debug(
f"Fetching {ticker}: {start.strftime('%Y-%m-%d')} to {end_date}"
)
try:
df = yf.download(
ticker,
start=start.strftime("%Y-%m-%d"),
end=end.strftime("%Y-%m-%d"),
progress=False,
interval="1d",
auto_adjust=True,
)
except Exception:
raise ValueError(
f"No data returned for ticker '{ticker}' ({start_date} to {end_date})."
)
if not isinstance(df, pd.DataFrame) or df.empty:
raise ValueError(
f"No data returned for ticker '{ticker}' ({start_date} to {end_date})."
)
prices = pd.Series(df["Close"].squeeze())
prices.name = ticker
# Trim to one trading day before start_date through end_date
start_ts = pd.to_datetime(start_date)
start_idx = prices.index.searchsorted(start_ts)
start_idx = max(0, start_idx - 1)
prices = prices.iloc[start_idx:]
prices = prices.loc[:end_date]
logger.debug(
f"Fetched {len(prices)} trading days for {ticker} "
f"({prices.index[0].strftime('%Y-%m-%d')} to {prices.index[-1].strftime('%Y-%m-%d')})"
)
return prices
# Lookback mode (historical VaR)
if var_date is None:
var_date = pd.Timestamp((pd.Timestamp.today() - pd.offsets.BDay()).date())
if lookback is None:
raise ValueError("lookback is required when start_date/end_date are not provided.")
calendar_days = int(lookback * 1.6)
# yfinance 'end' is exclusive, so passing var_date fetches up to the day before
start = var_date - pd.Timedelta(days=calendar_days)
logger.debug(
f"Fetching {ticker}: {start.strftime('%Y-%m-%d')} to {var_date.strftime('%Y-%m-%d')} (lookback={lookback})"
)
try:
df = yf.download(
ticker,
start=start.strftime("%Y-%m-%d"),
end=var_date.strftime("%Y-%m-%d"),
progress=False,
interval="1d",
auto_adjust=True
)
except Exception:
raise ValueError(f"No data returned for ticker '{ticker}'.")
if not isinstance(df, pd.DataFrame) or df.empty:
raise ValueError(f"No data returned for ticker '{ticker}'.")
prices = pd.Series(df["Close"].squeeze())
prices.name = ticker
result = prices.tail(lookback)
logger.debug(
f"Fetched {len(result)} trading days for {ticker} (last date: {result.index[-1].strftime('%Y-%m-%d')})"
)
return result
# ------------------------------------------------------------------
# Return computation
# ------------------------------------------------------------------
def compute_returns(prices: pd.Series, kind: str = "arithmetic") -> pd.Series:
"""Compute daily returns from a price series.
Parameters
----------
kind : "arithmetic" or "log"
arithmetic -> (P_t - P_{t-1}) / P_{t-1}
log -> log(P_t) - log(P_{t-1})
"""
if kind == "log":
log_prices = pd.Series(np.log(prices))
returns = log_prices - log_prices.shift(1)
name = "Daily Log Return"
else:
returns = (prices - prices.shift(1)) / prices.shift(1)
name = "Daily Return"
returns = pd.Series(returns, name=name)
return returns.dropna()
# ------------------------------------------------------------------
# Plotting (Plotly)
# ------------------------------------------------------------------
def plot_distribution(
returns: pd.Series,
var_cutoff: float,
var_label: str = "VaR",
es_cutoff: float | None = None,
es_label: str = "ES",
var_date: str = "",
method: str = "",
ticker: str = "",
) -> go.Figure:
"""Return a histogram of the daily P&L distribution highlighting VaR and ES tail risk."""
fig = go.Figure()
# Split the distribution at the VaR cutoff (P&L below VaR are in the left tail)
normal_returns = returns[returns >= var_cutoff]
tail_returns = returns[returns < var_cutoff]
fig.add_trace(
go.Histogram(
x=normal_returns.values,
marker_color="steelblue",
opacity=0.8,
)
)
fig.add_trace(
go.Histogram(
x=tail_returns.values,
marker_color="darkorange",
opacity=0.8,
)
)
if var_cutoff is not None:
fig.add_vline(x=var_cutoff, line_width=1.5, line_dash="dot", line_color="black")
fig.add_annotation(
x=var_cutoff, xref="x",
y=0.5, yref="paper",
text=f"{var_label}<br>= ${abs(var_cutoff):,.2f}",
xanchor="left", yanchor="middle",
xshift=6,
showarrow=False,
font=dict(size=9, color="#444444"),
)
if es_cutoff is not None:
fig.add_vline(x=es_cutoff, line_width=1.5, line_dash="dash", line_color="darkred")
fig.add_annotation(
x=es_cutoff, xref="x",
y=0.5, yref="paper",
text=f"{es_label}<br>= ${abs(es_cutoff):,.2f}",
xanchor="right", yanchor="middle",
xshift=-6,
showarrow=False,
font=dict(size=9, color="darkred"),
)
title = "Daily Portfolio P&L Distribution with VaR & ES Thresholds"
fig.update_layout(
title=dict(text=title, font=dict(size=14)),
xaxis_title=dict(text="P&L ($)", font=dict(size=12)),
yaxis_title=dict(text="Frequency", font=dict(size=12)),
barmode="stack",
template="plotly_white",
yaxis=dict(showgrid=False),
margin=dict(t=80, b=40),
height=391,
showlegend=False,
)
if var_date:
fig.add_annotation(
text=f"VaR Date: {var_date}",
xref="paper", yref="paper",
x=1.08, y=1.22,
xanchor="right", yanchor="top",
showarrow=False,
font=dict(size=9, color="#444444"),
)
if method:
fig.add_annotation(
text=f"Method: {method}",
xref="paper", yref="paper",
x=1.08, y=1.16,
xanchor="right", yanchor="top",
showarrow=False,
font=dict(size=9, color="#444444"),
)
if ticker:
fig.add_annotation(
text=f"Ticker: {ticker}",
xref="paper", yref="paper",
x=1.08, y=1.10,
xanchor="right", yanchor="top",
showarrow=False,
font=dict(size=9, color="#444444"),
)
return fig