\[ % MathJax has no bm package; redefine \bm in terms of \boldsymbol, which it supports natively \newcommand{\bm}[1]{\boldsymbol{#1}} % --- Operators ----------------------------------------------------------- % Expectation operator \DeclareMathOperator{\E}{E} % Variance operator \DeclareMathOperator{\Var}{Var} % Covariance operator \DeclareMathOperator{\Cov}{Cov} % Correlation operator \DeclareMathOperator{\Corr}{Corr} % Rank operator (Spearman rank correlation) \DeclareMathOperator{\Rank}{Rank} % Skewness operator \DeclareMathOperator{\Skewness}{Skewness} % Kurtosis operator \DeclareMathOperator{\Kurtosis}{Kurtosis} % Difference/differential operator (upright d per ISO 80000-2) \newcommand{\Diff}{\mathrm{d}} % --- Risk Measures ------------------------------------------------------- % Value at Risk \DeclareMathOperator{\VaR}{VaR} % Expected Shortfall \DeclareMathOperator{\ES}{ES} % Marginal Value at Risk \DeclareMathOperator{\MVaR}{MVaR} % Component Value at Risk \DeclareMathOperator{\CompVaR}{CVaR} % Incremental Value at Risk \DeclareMathOperator{\IVaR}{IVaR} % Component Expected Shortfall \DeclareMathOperator{\CompES}{CES} % Weighted sensitivity for risk factor in FRTB SBM bucket \DeclareMathOperator{\WS}{WS} % Hedge-benefit ratio for FRTB DRC bucket \DeclareMathOperator{\HBR}{HBR} % Stress scenario risk measure for FRTB NMRF capital add-on \DeclareMathOperator{\SES}{SES} % Jump-to-default exposure for obligor (FRTB DRC) \DeclareMathOperator{\JTD}{JTD} % Loss given default for obligor (FRTB DRC) \DeclareMathOperator{\LGD}{LGD} % Default risk weight for obligor (FRTB DRC) \DeclareMathOperator{\RW}{RW} % ES ratio function \newcommand{\ESratio}{\lambda} % Arbitrary risk measure function (coherence axioms) \newcommand{\RiskMeasure}{\varphi} % Asset position (coherence axioms) \newcommand{\Asset}{A} % First named asset in examples \newcommand{\AssetA}{A} % Second named asset in examples \newcommand{\AssetB}{B} % Third named asset in examples \newcommand{\AssetC}{C} % Constant (risk measure axioms) \newcommand{\Constant}{c} % Bucket-level aggregate sensitivity in FRTB SBM \newcommand{\BucketAgg}{S} % Cross-bucket correlation in FRTB SBM \newcommand{\CrossBucketCorr}{\gamma} % Notional amount for an instrument (FRTB DRC and RRAO) \newcommand{\Notional}{\text{Notional}} % --- Distributions ------------------------------------------------------- % Binomial distribution \DeclareMathOperator{\Binomial}{Binomial} % Uniform distribution \DeclareMathOperator{\Uniform}{Uniform} % Normal distribution \newcommand{\NormalDist}{\mathcal{N}} % Student-t CDF \newcommand{\StudentCDF}{t} % Student-t PDF \newcommand{\StudentPDF}{f} % --- Returns ------------------------------------------------------------- % Price \newcommand{\Price}{P} % Simple (arithmetic) return \newcommand{\SimpleReturns}{R} % Log (compound) return \newcommand{\CompoundReturns}{y} % Matrix of compound returns \newcommand{\ReturnMatrix}{\bm{y}} % Dividend payment \newcommand{\dividend}{d} % --- Volatility ---------------------------------------------------------- % Realised variance \DeclareMathOperator{\RealisedVar}{RV} % Realised volatility (square root of realised variance) \DeclareMathOperator{\RealisedVol}{RVol} % Volatility \newcommand{\Vol}{\sigma} % GARCH intercept parameter \newcommand{\GARCHconst}{\omega} % ARCH coefficient (weight on past squared returns) \newcommand{\ARCHcoeff}{\alpha} % GARCH coefficient (weight on past variance) \newcommand{\GARCHcoeff}{\beta} % EWMA decay factor (typically 0.94) \newcommand{\EWMAdecay}{\lambda} % Standardized residual / error term \newcommand{\StdNormal}{\epsilon} % Leverage parameter in apARCH \newcommand{\APARCHleverage}{\zeta} % Power parameter in apARCH \newcommand{\APARCHpower}{\delta} % Degrees of freedom (Student-t) \newcommand{\DOF}{\nu} % GJR-GARCH leverage parameter \newcommand{\GJRleverage}{\gamma} % GARCH-X external regressor coefficient \newcommand{\RegressorCoeff}{\xi} % Mean (first moment) \newcommand{\Mean}{\mu} % Lag order in volatility models \newcommand{\Lag}{L} % Dummy variable (indicator in regressions) \newcommand{\DummyVar}{D} % --- Portfolio ----------------------------------------------------------- % Portfolio weight (scalar) \newcommand{\weight}{w} % Portfolio weight vector \newcommand{\weights}{\bm{w}} % Covariance matrix \newcommand{\CovMatrix}{\bm{\Sigma}} % Correlation matrix \newcommand{\CorrMatrix}{\bm{C}} % Correlation coefficient \newcommand{\correlation}{\rho} % Number of assets in portfolio \newcommand{\NumberAssets}{K} % Portfolio value \newcommand{\PortfolioValue}{\vartheta} % Asset beta (CAPM) \newcommand{\AssetBeta}{\beta} % Ledoit-Wolf shrinkage intensity \newcommand{\ShrinkageIntensity}{\delta} % Sample covariance matrix \newcommand{\SampleCov}{\bm{S}} % --- Time Series --------------------------------------------------------- % Sample size \newcommand{\SampleSize}{T} % Generic count (number of lags, payments, etc.) \newcommand{\Count}{N} % Estimation window length \newcommand{\EstWindow}{W_E} % Testing window length \newcommand{\TestWindow}{W_T} % Stress window length \newcommand{\StressWindow}{W_S} % AR coefficient \newcommand{\ARcoeff}{\phi} % MA coefficient \newcommand{\MAcoeff}{\psi} % --- Probability --------------------------------------------------------- % Probability level (e.g. 0.01 for 1% VaR) \newcommand{\probability}{p} % Cumulative sorted scenario weight \newcommand{\CumScenarioWeight}{\Omega} % p-quantile of the P and L distribution \newcommand{\Quantile}{q} % Quantile function \newcommand{\QuantileFunction}{Q} % Probability density function \newcommand{\PDF}{f} % Cumulative distribution function \newcommand{\CDF}{F} % Standard normal CDF \newcommand{\NormalCDF}{\Phi} % Inverse standard normal (quantile function) \newcommand{\NormalQuantile}{\Phi^{-1}} % Standard normal density function \newcommand{\NormalPDF}{\phi} % Likelihood function \newcommand{\lik}{\mathcal{L}} % Log-likelihood \newcommand{\LogLikelihood}{\ell} % Parameter vector (MLE estimation) \newcommand{\ParamSet}{\theta} % Parameter space \newcommand{\ParamSpace}{\Theta} % Fisher information matrix \newcommand{\FisherInfo}{\mathcal{I}} % Statistical power (Type II error rate) \newcommand{\StatPower}{\beta} % Significance level (CI coverage complement) \newcommand{\SignifLevel}{\gamma} % --- Options ------------------------------------------------------------- % Call option label \newcommand{\CallOption}{\text{call}} % Put option label \newcommand{\PutOption}{\text{put}} % Strike price \newcommand{\Strike}{X} % Risk-free interest rate \newcommand{\RiskFree}{r_f} % Option maturity (time to expiration) \newcommand{\OptionMaturity}{\tau} % Option delta \newcommand{\OptionDelta}{\Delta} % Option gamma \newcommand{\OptionGamma}{\Gamma} % Option vega \newcommand{\Vega}{\mathcal{V}} % Holding period \newcommand{\HoldingPeriod}{H} % Simulation profit/loss (signed; negative is a loss) \newcommand{\ProfitLoss}{\Pi} % Futures price \newcommand{\Futures}{F} % Hedge ratio \newcommand{\HedgeRatio}{h} % Units of basic asset held \newcommand{\StockHolding}{x^b} % Units of options held \newcommand{\OptionHolding}{x^o} % --- Interest Rates ------------------------------------------------------ % Bond convexity \newcommand{\Convexity}{C} % Interest rate / yield \newcommand{\Yields}{r} % Key rate change, basis points (scalar tenor component); used as \KeyRateChange_k or \KeyRateChange_{t,k}. EXCEPTION to the no-macro-to-macro-indirection rule, decided 2026-08-09: composed from \Yields on purpose, so a future change to the rate glyph propagates here without a follow-up edit. \Yields is defined earlier in generated output (same interest_rates category, TOML order), so expansion order is safe; verify this still holds if either entry is reordered. \newcommand{\KeyRateChange}{\Delta \Yields^{\mathrm{bp}}} % Key rate changes, basis points (vector); used bare or as \KeyRateChanges_t. EXCEPTION to the no-macro-to-macro-indirection rule, decided 2026-08-09: composed from \Yields on purpose, so a future change to the rate glyph propagates here without a follow-up edit. \bm{\Yields} depends on \bm/\boldsymbol correctly expanding a macro argument rather than a bare letter --- confirm this renders correctly the first time either consumer compiles it. \newcommand{\KeyRateChanges}{\Delta \bm{\Yields}^{\mathrm{bp}}} % Dollar value of a basis point \newcommand{\DV}{\text{DV01}} % Mean reversion speed (interest rate models) \newcommand{\MeanReversion}{\varkappa} % Modified duration \newcommand{\ModDur}{D} % Macaulay duration \newcommand{\MacDur}{D_{\text{Mac}}} % Effective duration \newcommand{\EffDur}{D_{\text{eff}}} % DV01 vector \newcommand{\DVvec}{\bm{d}} % Long-run mean rate (Vasicek/CIR/Hull-White) \newcommand{\LongRunRate}{\theta} % Hull-White time-varying drift \newcommand{\HullWhiteDrift}{\varphi} % Cash flow (coupon/principal payment) \newcommand{\CashFlow}{c} % --- Extreme Value ------------------------------------------------------- % Tail index (EVT, Pareto-type tails) \newcommand{\TailIndex}{\iota} % Shape parameter (xi = 1/iota) \newcommand{\ShapeParam}{\varsigma} % Threshold value (EVT) \newcommand{\Threshold}{u} % Extremal index (Leadbetter 1983) \newcommand{\ExtremalIndex}{\theta} % Multivariate extremal coefficient, theta in [1,d]. theta=1 complete tail dependence; theta=d asymptotic independence. Glyph collides with ExtremalIndex and ParamSet; disambiguated at source by macro name. \newcommand{\ExtremalCoef}{\theta} % GEV distribution function \newcommand{\GEV}{\mathcal{H}} % GPD distribution function \newcommand{\GPD}{\mathcal{G}} % GPD scale parameter \newcommand{\GPDscale}{\beta} % GPD scale parameter at a threshold u, distinct from the generic GPD scale \newcommand{\GPDscaleAtThreshold}{\beta_u} % Number of threshold exceedances (EVT) \newcommand{\TailCount}{C} % Sample maximum (block maxima, EVT) \newcommand{\SampleMax}{M} % Kurtosis value (realized, e.g. sample kurtosis) \newcommand{\KurtosisVal}{\kappa} % Pareto scaling constant (EVT) \newcommand{\ParetoConst}{\mathcal{A}} % Generic constant (EVT asymptotics) \newcommand{\RemainderConst}{\mathcal{C}} % Little-o asymptotic notation \newcommand{\LittleO}{o} % Moment order (EVT) \newcommand{\MomentOrder}{\mathscr{m}} % Doubled sample size (EVT block maxima) \newcommand{\DoubleSample}{D} % GEV normalizing location constant \newcommand{\GEVloc}{a} % GEV normalizing scale constant \newcommand{\GEVscale}{b} % Negative return (loss, EVT context) \newcommand{\NegativeReturn}{L} % --- Copulas ------------------------------------------------------------- % Copula function \newcommand{\Copula}{\mathcal{C}} % Copula density \newcommand{\CopulaDensity}{c} % Generator function (Archimedean copulas) \newcommand{\generator}{\varphi} % Copula dependence parameter \newcommand{\CopulaParam}{\theta} % Copula marginal parameter set \newcommand{\MarginalParamSet}{\eta} % Lower tail dependence coefficient \newcommand{\LowerTailDep}{\lambda_L} % Upper tail dependence coefficient \newcommand{\UpperTailDep}{\lambda_U} % Marginal CDF (second variable) \newcommand{\MarginalCDF}{G} % Joint density function \newcommand{\JointDensity}{h} % Marginal density function \newcommand{\MarginalDensity}{g} % Joint distribution function \newcommand{\JointCDF}{H} % Gaussian copula correlation parameter \newcommand{\CopulaCorr}{\rho} % --- Multivariate -------------------------------------------------------- % Diagonal matrix of conditional volatilities \newcommand{\DiagVolD}{\bm{D}} % Idiosyncratic variance matrix (diagonal, factor models) \newcommand{\IdioVarMatrix}{\bm{\Psi}} % Covariance matrix entry (two indices) \newcommand{\CovElement}{\sigma} % Auxiliary matrix in DCC dynamics \newcommand{\DCCauxQ}{\bm{Q}} % BEKK constant matrix \newcommand{\BEKKconst}{\bm{\Omega}} % BEKK ARCH parameter matrix \newcommand{\BEKKarch}{\bm{A}} % BEKK GARCH parameter matrix \newcommand{\BEKKgarch}{\bm{B}} % DCC rescaling diagonal matrix \newcommand{\DCCrescaleZ}{\bm{Z}} % DCC auxiliary matrix element \newcommand{\DCCelement}{q} % Factor loadings matrix (loadings/eigenvectors of the covariance matrix) \newcommand{\FactorLoadings}{\bm{\Lambda}} % Factor subscript label \newcommand{\factor}{\text{factor}} % PCA eigenvalue \newcommand{\Eigenvalue}{\lambda} % Number of factors in factor model \newcommand{\NumberFactors}{m} % Factor return vector \newcommand{\FactorReturn}{\bm{g}} % PCA factor score (scalar; not an eigenvector) \newcommand{\PCAFactor}{F} % BEKK cross-covariance coefficient \newcommand{\BEKKcross}{\delta} % Factor loading element \newcommand{\FactorLoading}{\Lambda} % DCC correlation persistence parameter \newcommand{\DCCxi}{\xi} % DCC news coefficient (weight on recent shocks) \newcommand{\DCCzeta}{\zeta} % --- Simulation ---------------------------------------------------------- % Number of simulation paths \newcommand{\NumberSims}{B} % Cholesky factor of covariance matrix \newcommand{\Cholesky}{\bm{L}} % Uniform random number \newcommand{\UniformDraw}{\tilde{u}} % --- Backtesting --------------------------------------------------------- % Violation ratio (observed/expected violations) \DeclareMathOperator{\ViolRatio}{VR} % Stressed Value at Risk \DeclareMathOperator{\StressedVaR}{SVaR} % QLIKE loss function for variance forecasts \DeclareMathOperator{\QLIKE}{QLIKE} % Mean squared error (forecast loss function) \DeclareMathOperator{\MSE}{MSE} % Mean absolute error (forecast loss function) \DeclareMathOperator{\MAE}{MAE} % Markov transition probability \newcommand{\TransProb}{\pi} % Exception indicator: 1 if y_t < -VaR_t \newcommand{\ExceptionInd}{\eta} % Exception count \newcommand{\ExceptionCount}{\upsilon} % Probability Integral Transform \newcommand{\ProbIntegralTransform}{\hat{u}} % Quantile score (pinball loss function) \newcommand{\QuantileScore}{\mathcal{S}} % Kolmogorov-Smirnov test statistic \newcommand{\KSstat}{D} % Test statistic (generic base letter) \newcommand{\TestStat}{J} % --- General ------------------------------------------------------------- % Regression intercept \newcommand{\RegressionIntercept}{a} % Regression slope \newcommand{\RegressionSlope}{\beta} % Risk factor \newcommand{\RiskFactor}{x} % Forecast horizon \newcommand{\ForecastHorizon}{h} % Block length (bootstrap) \newcommand{\BlockLength}{\ell} % Capital tau variant \newcommand{\DeliveryTime}{\Upsilon} % Today's calendar time in years (option pricing input; distinct from trading-date index t) \newcommand{\CalendarTime}{t^*} % Probability measure \newcommand{\ProbMeasure}{\mathbb{P}} % Risk-neutral measure \newcommand{\RiskNeutral}{\mathbb{Q}} % Pricing function (Black-Scholes, bond pricing, etc.) \newcommand{\PricingFn}{V} % Indicator function \newcommand{\Indicator}{\bm{1}} % Vector of ones \newcommand{\OnesVector}{\bm{1}} % Risk factor sensitivity (first-order P&L) \newcommand{\Sensitivity}{\delta} % Second-order sensitivity (convexity/gamma) \newcommand{\ConvexitySens}{\gamma} % Output floor percentage (Basel) \newcommand{\OutputFloor}{\alpha} % --- Subscript Labels ---------------------------------------------------- % Subscript label: annualized \newcommand{\Annual}{a} % Subscript label: implied \newcommand{\Implied}{I} % Subscript label: portfolio \newcommand{\Portfolio}{\pi} % --- Text Abbreviations -------------------------------------------- % S&P 500 index \newcommand{\SP}{\text{S\&P-500}} % Student-t distribution (text) \newcommand{\St}{\text{Student-t}} % Geopolitical Risk index (Caldara and Iacoviello) \newcommand{\GPR}{\text{GPR}} \]

13  Time series

Every volatility model and risk forecast in this book depends on correctly ordered, properly aligned observations. A time series is a sequence of data points indexed by time. In finance, nearly all data takes this form. Stock prices, returns, interest rates, volatility measures and trading volumes are all recorded at specific moments. The temporal ordering matters, because current values depend on past values. So does the spacing between observations.

Time series data presents several practical challenges. Markets close on weekends and holidays, creating gaps in daily data. Data may be tick-by-tick, daily, monthly or annual, and converting between frequencies requires care. Global markets operate in different time zones, complicating cross-market analysis. Leap years, daylight saving time and varying month lengths affect date arithmetic.

The practical solution is to keep data as simple numeric vectors and only convert to proper time series objects when needed — typically for plotting, reporting and aggregation.

Each language handles dates and time series differently:

The underlying concepts are the same across languages, though the syntax differs. This chapter covers date handling, time series plotting and common operations like lagging, differencing and aggregation. For general plotting concepts, see Chapter 12.

13.1 Data and libraries

Time series work needs a date-aware container in each language — zoo in R, pandas in Python, DataFrames with Dates in Julia.

library(ggplot2)
library(lubridate)
library(zoo)
library(reshape2)
source("common/functions.r", chdir = TRUE)
import sys
import os
import numpy as np
import pandas as pd
from datetime import datetime
sys.path.insert(0, 'common')
from functions import ProcessRawData
from plotnine import ggplot, aes, geom_line, labs, theme_minimal, scale_y_log10, theme

os.makedirs("_figs", exist_ok=True)
using Dates, Statistics
using TidierPlots, CairoMakie

include("common/functions.jl");
isdir("_figs") || mkdir("_figs");
data = ProcessRawData()
sp500 = data$sp500
sp500tr = data$sp500tr
Price = data$Price
Return = data$Return
Ticker = data$Ticker
data = ProcessRawData()
sp500 = data['sp500']
sp500tr = data['sp500tr']
Price = data['Price']
Return = data['Return']
Ticker = data['Ticker']
data = ProcessRawData();
sp500 = data["sp500"];
sp500tr = data["sp500tr"];
Price = data["Price"];
Return = data["Return"];
Ticker = data["Ticker"];

13.2 Date handling

ProcessRawData() already returns sp500 with a typed R date.ts column, so the R tab below only inspects it. Python and Julia still hold the raw integer date, so those tabs convert it to a proper date object. See Chapter 5 for details on date parsing functions and format patterns.

tail(sp500, 2)
         date   price           y    date.ts        y.ts
8938 20250627 6173.07 0.005205431 2025-06-27 0.005205431
8939 20250630 6204.95 0.005151078 2025-06-30 0.005151078
sp500['date_ts'] = pd.to_datetime(sp500['date'], format='%Y%m%d')
print(sp500.tail(2))
          date    price         y    date_ts
8936  20250627  6173.07  0.005205 2025-06-27
8937  20250630  6204.95  0.005151 2025-06-30
sp500.date_ts = Date.(string.(sp500.date), dateformat"yyyymmdd");
println(last(sp500, 2))
2×4 DataFrame
 Row │ date      price    y           date_ts
     │ Int64     Float64  Float64?    Date
─────┼───────────────────────────────────────────
   1 │ 20250627  6173.07  0.00520543  2025-06-27
   2 │ 20250630  6204.95  0.00515108  2025-06-30

13.3 Plotting time series

13.3.1 Without dates

Plot the S&P 500 against observation number first. That strips away the calendar and lets us inspect the shape of the series.

df = data.frame(day = 1:nrow(sp500), price = sp500$price)

ggplot(df, aes(x = day, y = price)) +
  geom_line(colour = "blue", linewidth = 0.8) +
  labs(title = "The S&P 500 index", x = "Day", y = "Price") +
  theme_minimal()

df = pd.DataFrame({'day': range(1, len(sp500) + 1), 'price': sp500['price'].values})

p = (ggplot(df, aes(x="day", y="price"))
 + geom_line(colour="blue", size=0.8)
 + labs(title="The S&P 500 index", x="Day", y="Price")
 + theme_minimal())
p.save("_figs/ts_simple_py.png", width=6, height=4, dpi=100, verbose=False)

df = DataFrame(day = 1:nrow(sp500), price = sp500.price);

p = ggplot(df, @aes(x = day, y = price)) +
    geom_line(color = "blue", linewidth = 0.8) +
    labs(title = "The S&P 500 index", x = "Day", y = "Price") +
    theme_minimal();
ggsave("_figs/ts_simple_jl.png", p);

13.3.2 With dates

A time series plot becomes far more informative when the x-axis shows actual dates.

ggplot(sp500, aes(x = date.ts, y = price)) +
  geom_line(colour = "blue", linewidth = 0.8) +
  labs(title = "The S&P 500 index", x = "Date", y = "Price") +
  theme_minimal()

You can make it a log plot by using scale_y_log10():

ggplot(sp500, aes(x = date.ts, y = price)) +
  geom_line(colour = "blue", linewidth = 0.8) +
  scale_y_log10() +
  labs(title = "The S&P 500 index", x = "Date", y = "Price") +
  theme_minimal()

We can customise the x-axis with date breaks. This axis customisation is shown in R only.

ggplot(sp500, aes(x = date.ts, y = price)) +
  geom_line(colour = "blue", linewidth = 0.8) +
  scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
  labs(title = "The S&P 500 index", x = "Date", y = "Price") +
  theme_minimal()

p = (ggplot(sp500, aes(x="date_ts", y="price"))
 + geom_line(colour="blue", size=0.8)
 + labs(title="The S&P 500 index", x="Date", y="Price")
 + theme_minimal())
p.save("_figs/ts_dates_py.png", width=6, height=4, dpi=100, verbose=False)

You can make it a log plot by using scale_y_log10():

p = (ggplot(sp500, aes(x="date_ts", y="price"))
 + geom_line(colour="blue", size=0.8)
 + scale_y_log10()
 + labs(title="The S&P 500 index", x="Date", y="Price")
 + theme_minimal())
p.save("_figs/ts_log_py.png", width=6, height=4, dpi=100, verbose=False)

p = ggplot(sp500, @aes(x = date_ts, y = price)) +
    geom_line(color = "blue", linewidth = 0.8) +
    labs(title = "The S&P 500 index", x = "Date", y = "Price") +
    theme_minimal();
ggsave("_figs/ts_dates_jl.png", p);

13.3.3 Returns

Each language keeps the return series attached to its date.

ProcessRawData() already returns sp500$y.ts as a date-indexed zoo series.

df_returns = data.frame(
  date = index(sp500$y.ts),
  returns = as.numeric(sp500$y.ts)
)

ggplot(df_returns, aes(x = date, y = returns)) +
  geom_line(colour = "steelblue", linewidth = 0.3) +
  labs(title = "S&P 500 Daily Return", x = "Date", y = "Return") +
  theme_minimal()

p = (ggplot(sp500, aes(x="date_ts", y="y"))
 + geom_line(colour="steelblue", size=0.3)
 + labs(title="S&P 500 Daily Return", x="Date", y="Return")
 + theme_minimal())
p.save("_figs/ts_returns_py.png", width=6, height=4, dpi=100, verbose=False)

p = ggplot(sp500, @aes(x = date_ts, y = y)) +
    geom_line(color = "steelblue", linewidth = 0.3) +
    labs(title = "S&P 500 Daily Return", x = "Date", y = "Return") +
    theme_minimal();
ggsave("_figs/ts_returns_jl.png", p);

13.3.4 Multivariate

For multivariate time series, we reshape the data to long format and use colour mapping.

price_long = melt(Price, id.vars = "date", measure.vars = Ticker,
                  variable.name = "stock", value.name = "price")

ggplot(price_long, aes(x = date, y = price, colour = stock)) +
  geom_line(linewidth = 0.5) +
  labs(x = "Date", y = "Price", colour = "Stock") +
  theme_minimal()

To compare stock performance, renormalise each series to start at 1:

pn = Price
for (i in Ticker) {
  pn[[i]] = pn[[i]] / pn[[i]][1]
}

pn_long = melt(pn, id.vars = "date", measure.vars = Ticker,
               variable.name = "stock", value.name = "price")

ggplot(pn_long, aes(x = date, y = price, colour = stock)) +
  geom_line(linewidth = 0.5) +
  labs(x = "Date", y = "Price (normalised)", colour = "Stock") +
  theme_minimal() +
  theme(legend.position = "top")

A log scale makes relative performance easier to compare:

ggplot(pn_long, aes(x = date, y = price, colour = stock)) +
  geom_line(linewidth = 0.5) +
  scale_y_log10() +
  labs(x = "Date", y = "Price (normalised)", colour = "Stock") +
  theme_minimal() +
  theme(legend.position = "top")

Price and Ticker are already loaded, and Price['date'] is already a typed date column, so this section reuses them directly.

price_long = Price.melt(id_vars='date', value_vars=Ticker,
                        var_name='stock', value_name='price')

p = (ggplot(price_long, aes(x="date", y="price", colour="stock"))
 + geom_line(size=0.5)
 + labs(x="Date", y="Price", colour="Stock")
 + theme_minimal())
p.save("_figs/ts_multi_py.png", width=7, height=4, dpi=100, verbose=False)

Renormalised to start at 1:

pn = Price.copy()
for col in Ticker:
    pn[col] = pn[col] / pn[col].iloc[0]

pn_long = pn.melt(id_vars='date', value_vars=Ticker,
                  var_name='stock', value_name='price')

p = (ggplot(pn_long, aes(x="date", y="price", colour="stock"))
 + geom_line(size=0.5)
 + labs(x="Date", y="Price (normalised)", colour="Stock")
 + theme_minimal()
 + theme(legend_position="top"))
p.save("_figs/ts_norm_lin_py.png", width=7, height=4, dpi=100, verbose=False)

A log scale makes relative performance easier to compare:

p = (ggplot(pn_long, aes(x="date", y="price", colour="stock"))
 + geom_line(size=0.5)
 + scale_y_log10()
 + labs(x="Date", y="Price (normalised)", colour="Stock")
 + theme_minimal()
 + theme(legend_position="top"))
p.save("_figs/ts_norm_py.png", width=7, height=4, dpi=100, verbose=False)

Price.date is already a typed Date column, so no conversion is needed here.

price_long = stack(Price, Ticker, [:date], variable_name = :stock, value_name = :price);

p = ggplot(price_long, @aes(x = date, y = price, color = stock)) +
    geom_line(linewidth = 0.5) +
    labs(x = "Date", y = "Price") +
    theme_minimal();
ggsave("_figs/ts_multi_jl.png", p);

To compare stock performance, renormalise each series to start at 1:

using DataFrames, TidierPlots
pn = deepcopy(Price);
for t in Ticker
    pn[!, t] = pn[!, t] ./ pn[1, t]
end

pn_long = stack(pn, Ticker, [:date], variable_name = :stock, value_name = :price);

p = ggplot(pn_long, @aes(x = date, y = price, color = stock)) +
    geom_line(linewidth = 0.5) +
    labs(x = "Date", y = "Price (normalised)") +
    theme_minimal();
ggsave("_figs/ts_norm_lin_jl.png", p);

A log scale makes relative performance easier to compare:

using TidierPlots
p = ggplot(pn_long, @aes(x = date, y = price, color = stock)) +
    geom_line(linewidth = 0.5) +
    scale_y_log10() +
    labs(x = "Date", y = "Price (normalised)") +
    theme_minimal();
ggsave("_figs/ts_norm_jl.png", p);

In this dataset, AAPL’s price rises roughly 570-fold over the sample period.

13.4 Time series operations

Each language provides tools for common time series operations like lagging, differencing, subsetting and aggregation. In R, the zoo package provides a mature, date-indexed time series class. In Python, pandas DataFrame objects can use dates as the index. Julia uses standard DataFrame operations with the Dates module.

13.4.1 Lag

The lag operation shifts a series forward or backward in time. Conventions differ across languages and even across R packages:

  • R’s zoo::lag follows the time series convention where positive k moves future values to earlier dates (a lead), and negative k moves past values to later dates (a true lag). This is the opposite of what many users expect.
  • Python’s shift(n) with positive n moves each value to a later date (a true lag), inserting NaN at the start.
  • R’s dplyr::lag() matches the Python shift() convention, so lag(x, 2) returns the value from 2 periods earlier.

Because zoo::lag with positive k produces a lead rather than a lag, we demonstrate both directions below.

head(sp500$y.ts, 5)
  1990-01-03   1990-01-04   1990-01-05   1990-01-08   1990-01-09 
-0.002588908 -0.008650307 -0.009804139  0.004504321 -0.011856666

With zoo::lag, positive k is a lead (shifts the time index backward so future values appear at earlier dates):

head(lag(sp500$y.ts, k = 2), 5)
  1990-01-03   1990-01-04   1990-01-05   1990-01-08   1990-01-09 
-0.009804139  0.004504321 -0.011856666 -0.006629097  0.003506557

To get a true lag (past values at later dates), use negative k:

head(lag(sp500$y.ts, k = -2), 5)
  1990-01-05   1990-01-08   1990-01-09   1990-01-10   1990-01-11 
-0.002588908 -0.008650307 -0.009804139  0.004504321 -0.011856666

pandas shift() with a positive argument produces a true lag — each value moves to a later position, and NaN fills the start:

print(sp500['y'].head())
print(sp500['y'].shift(2).head())
0   -0.002589
1   -0.008650
2   -0.009804
3    0.004504
4   -0.011857
Name: y, dtype: float64
0         NaN
1         NaN
2   -0.002589
3   -0.008650
4   -0.009804
Name: y, dtype: float64

Julia does not have a built-in lag function, but we can create lagged values using vector operations:

println(first(sp500.y, 5))

# Lag by 2 periods (prepend missing values)
lagged = [fill(missing, 2); sp500.y[1:end-2]];
println(first(lagged, 5))
Union{Missing, Float64}[-0.002588908120090494, -0.008650306588847911, -0.009804138598848766, 0.004504320707883203, -0.01185666638695082]
Union{Missing, Float64}[missing, missing, -0.002588908120090494, -0.008650306588847911, -0.009804138598848766]

13.4.2 Difference

The diff function computes the lagged difference of a series: \(x_t - x_{t-1}\).

head(diff(sp500$y.ts, lag = 1, na.pad = TRUE), 5)
  1990-01-03   1990-01-04   1990-01-05   1990-01-08   1990-01-09 
          NA -0.006061398 -0.001153832  0.014308459 -0.016360987
print(sp500['y'].diff().head())
0         NaN
1   -0.006061
2   -0.001154
3    0.014308
4   -0.016361
Name: y, dtype: float64
println(first([missing; diff(sp500.y)], 5))
Union{Missing, Float64}[missing, -0.006061398468757417, -0.0011538320100008548, 0.014308459306731969, -0.016360987094834023]

13.4.3 Subset by date

To isolate a stress episode, subset the series by date. February to April 2020 captures the COVID-19 sell-off:

sub_y.ts = window(sp500$y.ts, start = ymd("20200201"), end = ymd("20200401"))

df_covid = data.frame(
  date = index(sub_y.ts),
  returns = as.numeric(sub_y.ts)
)

ggplot(df_covid, aes(x = date, y = returns)) +
  geom_line(colour = "mediumblue", linewidth = 0.8) +
  labs(title = "Returns in COVID-19", x = "Date", y = "Returns") +
  theme_minimal()

covid = sp500[(sp500['date_ts'] >= '2020-02-01') & (sp500['date_ts'] <= '2020-04-01')]

p = (ggplot(covid, aes(x="date_ts", y="y"))
 + geom_line(colour="mediumblue", size=0.8)
 + labs(title="Returns in COVID-19", x="Date", y="Returns")
 + theme_minimal())
p.save("_figs/ts_covid_py.png", width=6, height=4, dpi=100, verbose=False)

covid = sp500[(sp500.date_ts .>= Date(2020, 2, 1)) .& (sp500.date_ts .<= Date(2020, 4, 1)), :];

p = ggplot(covid, @aes(x = date_ts, y = y)) +
    geom_line(color = "mediumblue", linewidth = 0.8) +
    labs(title = "Returns in COVID-19", x = "Date", y = "Returns") +
    theme_minimal();
ggsave("_figs/ts_covid_jl.png", p);

13.4.4 Aggregate

We often need to aggregate time series data to a lower frequency. For example, calculate the monthly mean return or the within-month volatility of daily returns. Both series share the same monthly index, so combining them into one frame keeps the alignment intact.

monthly_mean = aggregate(sp500$y.ts, as.yearmon, mean)
monthly_vol = aggregate(sp500$y.ts, as.yearmon, sd)

monthly = data.frame(
  yearmonth = index(monthly_mean),
  mean_return = as.numeric(monthly_mean),
  volatility = as.numeric(monthly_vol)
)
head(monthly, 5)
  yearmonth   mean_return  volatility
1  Jan 1990 -0.0042353175 0.010561799
2  Feb 1990  0.0004475109 0.007468453
3  Mar 1990  0.0010893429 0.006909350
4  Apr 1990 -0.0013627584 0.007005099
5  May 1990  0.0040000414 0.006865567

This plot of monthly mean return against monthly volatility is shown in R only.

ggplot(monthly, aes(x = mean_return, y = volatility)) +
  geom_point(colour = "red", size = 2) +
  geom_smooth(method = "lm", se = FALSE, colour = "green", linewidth = 1.5) +
  scale_x_continuous(labels = scales::percent) +
  scale_y_continuous(labels = scales::percent) +
  labs(
    title = "S&P 500 monthly mean and volatility",
    x = "Mean",
    y = "Volatility"
  ) +
  theme_minimal()
`geom_smooth()` using formula = 'y ~ x'

pandas resample() aggregates time series data:

sp500_ts = sp500.set_index('date_ts')
monthly = pd.DataFrame({
    'mean_return': sp500_ts['y'].resample('ME').mean(),
    'volatility': sp500_ts['y'].resample('ME').std()
})
print(monthly.head())
            mean_return  volatility
date_ts                            
1990-01-31    -0.004235    0.010562
1990-02-28     0.000448    0.007468
1990-03-31     0.001089    0.006909
1990-04-30    -0.001363    0.007005
1990-05-31     0.004000    0.006866

Use groupby and combine from DataFrames for aggregation:

sp500.yearmonth = Dates.format.(sp500.date_ts, "yyyy-mm");

monthly = combine(groupby(sp500, :yearmonth),
    :y => mean => :mean_return,
    :y => std => :volatility
);

println(first(monthly, 5))
5×3 DataFrame
 Row │ yearmonth  mean_return   volatility
     │ String     Float64       Float64
─────┼─────────────────────────────────────
   1 │ 1990-01    -0.00423532   0.0105618
   2 │ 1990-02     0.000447511  0.00746845
   3 │ 1990-03     0.00108934   0.00690935
   4 │ 1990-04    -0.00136276   0.0070051
   5 │ 1990-05     0.00400004   0.00686557

13.5 Summary

The operations in this chapter — date parsing, plotting, lagging, differencing, subsetting and aggregation — are the ones every later chapter assumes.

Differencing logged prices is how the return series is built in Chapter 11. Date-aligned subsetting is what lets Chapter 27 isolate the Covid window and read violations against it. Aggregation to a lower frequency is how a daily model gets compared with a monthly one. None of this is difficult, and all of it is unforgiving — a series that is out of order, or misaligned against its dates by one row, produces a volatility estimate that looks entirely reasonable and is wrong.