Python has become the working language of buyside research because one environment covers the whole job: loading data, cleaning it, computing statistics, testing ideas and drawing charts. The workhorse library is pandas, which gives you the DataFrame, a table indexed by date that knows how to align, resample and roll. NumPy sits underneath for fast arithmetic, and matplotlib draws the charts.
This topic covers the handful of operations that make up most daily analysis. Every listing is self contained and uses toy data defined inline, so it runs without downloading anything. In real work the only change is the first step: reading prices from a file, a database or a vendor API instead of generating them.
Key takeaways
- Keep prices in a DataFrame with a DatetimeIndex and one column per security; almost every operation then aligns on dates automatically.
- Simple returns come from pct_change and compound by multiplication; log returns come from the difference of log prices and add up over time.
- Rolling statistics such as volatility use rolling(window), and daily volatility annualises by multiplying by the square root of 252.
- Resample to month end with resample('ME') and take the last price, or compound daily returns within each month; both give the same monthly returns.
- When joining series from different sources, normalise the dates first (for example to monthly periods), because month end labels rarely match exactly.
Setting up
Install a recent Python 3 and create an isolated environment for each project, so library versions do not collide. The standard library tool venv is enough; many quant teams use conda or uv instead, which also manage the Python version. Install pandas, NumPy and matplotlib, and work either in scripts or in Jupyter notebooks, which suit exploratory analysis because each step's output stays on screen.
Pin your versions. pandas in particular has changed defaults and names between releases, and an analysis that ran last year can warn or fail today. The code below assumes pandas 2.2 or later, where month end resampling uses the alias 'ME' (older versions used 'M').
# In a terminal, once per project:
# python -m venv .venv
# source .venv/bin/activate (Windows: .venv\Scripts\activate)
# pip install "pandas>=2.2" numpy matplotlib
import numpy as np
import pandas as pd
print(pd.__version__, np.__version__)Prices in a DataFrame, and computing returns
A price table is a DataFrame whose index is a DatetimeIndex and whose columns are securities. Building it that way is worth the effort, because pandas then aligns every calculation on dates: dividing one column by another, or one table by another, matches rows by date rather than by position.
The simple return from one day to the next is the new price divided by the old one, minus one; pct_change computes it for every column at once. Simple returns compound by multiplication, so the total return over a period is the product of one plus each daily return, minus one. Log returns, the difference of natural log prices, add instead of multiplying, which is convenient for statistics. The two are close for small moves and drift apart for large ones.
Both start with a missing value on the first row, because there is no prior price. Drop it, or fill it with zero when you need the series to keep its full length, but decide deliberately: a silent NaN propagates through later sums and products.
import numpy as np
import pandas as pd
close = pd.Series(
[100.0, 101.0, 99.99, 102.49, 103.51],
index=pd.bdate_range("2024-01-02", periods=5),
name="AAA",
)
simple = close.pct_change().dropna()
for day, r in simple.items():
print(f"{day:%Y-%m-%d} {r:+.4%}")
total = close.iloc[-1] / close.iloc[0] - 1
log_sum = np.log(close).diff().sum()
print(f"total {total:.4%}")
print(f"sum of log returns {log_sum:.6f}")2024-01-03 +1.0000% 2024-01-04 -1.0000% 2024-01-05 +2.5003% 2024-01-08 +0.9952% total 3.5100% sum of log returns 0.034498
import numpy as np
import pandas as pd
rng = np.random.default_rng(seed=7)
dates = pd.bdate_range("2023-01-02", periods=522) # through 2024-12-31
daily = rng.normal(loc=[0.0004, 0.0003], scale=[0.012, 0.009], size=(len(dates), 2))
prices = pd.DataFrame(
100 * np.cumprod(1 + daily, axis=0),
index=dates,
columns=["AAA", "BBB"],
)
prices.index.name = "date"
returns = prices.pct_change().dropna()
print(prices.tail(3).round(2))
print(returns.describe().round(4))Rolling volatility
Volatility is the standard deviation of returns. Over the whole sample it is one number per security; over a moving window it becomes a time series that shows how risk changes. rolling(63) creates windows of 63 observations (about three months of trading days), and .std() computes the standard deviation within each. The first 62 values are missing because the window is not yet full.
Daily volatility annualises by multiplying by the square root of the number of trading periods in a year, conventionally 252 for daily data. A daily standard deviation of 1.2 percent becomes 1.2 x sqrt(252), about 19.05 percent a year. The scaling assumes returns are independent from day to day, which is roughly true for liquid prices and badly wrong for smoothed series such as private fund NAVs.
TRADING_DAYS = 252
ann_vol = returns.std() * np.sqrt(TRADING_DAYS)
rolling_vol = returns.rolling(window=63).std() * np.sqrt(TRADING_DAYS)
print(ann_vol.round(4))
print(rolling_vol.dropna().tail(3).round(4))
# An exponentially weighted alternative that reacts faster to recent moves
ewm_vol = returns.ewm(span=63).std() * np.sqrt(TRADING_DAYS)Resampling to monthly
Many analyses run on monthly data: factor regressions, fund comparisons, risk reports. resample groups a daily series into calendar buckets. For prices, take the last observation in each month. For returns, compound within each month: the product of one plus each daily return, minus one.
Both routes give the same monthly returns from the second month onward. The first month differs only because the price route has no prior month end to compare against, so it drops that month, while the return route compounds whatever days it has. Checking that the two agree is a cheap test that your data has no gaps or duplicated dates.
month_end = prices.resample("ME").last()
monthly_from_prices = month_end.pct_change().dropna()
monthly_from_daily = (1 + returns).resample("ME").prod() - 1
aligned = monthly_from_daily.loc[monthly_from_prices.index]
print(np.allclose(aligned, monthly_from_prices))
# Calendar year returns follow the same pattern
yearly = (1 + returns).resample("YE").prod() - 1
print(yearly.round(4))Joining two series, and plotting
Analysis often combines data from different sources: a fund's monthly NAV from an administrator and a benchmark from a market data vendor. The dates rarely match exactly. A fund may label March as 28 March (the last business day) while the resampled benchmark labels it 31 March. Joined naively on dates, those rows do not meet, and the result is quietly empty or full of missing values.
The fix is to normalise the index before joining. Converting both to monthly periods with to_period('M') makes March 28 and March 31 the same key. An inner join then keeps only months present in both series. From there, correlation and beta are one line each: beta is the covariance of fund and benchmark returns divided by the variance of the benchmark.
For charts, pandas calls matplotlib directly. A growth of one chart (cumulative product of one plus returns) and a rolling volatility panel cover most first looks at a series. Save figures to a file in scripts; in notebooks they display inline.
fund_nav = pd.Series(
[100.0, 101.2, 100.7, 102.0, 103.1, 102.4, 104.0, 105.3, 104.1, 105.9, 107.2, 108.0],
index=pd.to_datetime([
"2024-01-31", "2024-02-29", "2024-03-28", "2024-04-30",
"2024-05-31", "2024-06-28", "2024-07-31", "2024-08-30",
"2024-09-30", "2024-10-31", "2024-11-29", "2024-12-31",
]),
name="fund",
)
fund = fund_nav.pct_change().rename("fund")
bench = prices["AAA"].resample("ME").last().pct_change().rename("bench")
fund.index = fund.index.to_period("M")
bench.index = bench.index.to_period("M")
df = pd.concat([fund, bench], axis=1, join="inner").dropna()
beta = df["fund"].cov(df["bench"]) / df["bench"].var()
corr = df["fund"].corr(df["bench"])
print(f"months {len(df)}, beta {beta:.2f}, correlation {corr:.2f}")import matplotlib.pyplot as plt
growth = (1 + returns).cumprod()
fig, axes = plt.subplots(2, 1, figsize=(9, 6), sharex=True)
growth.plot(ax=axes[0], title="Growth of 1")
rolling_vol.plot(ax=axes[1], title="63 day rolling volatility, annualised")
axes[1].yaxis.set_major_formatter(lambda y, _: f"{y:.0%}")
fig.tight_layout()
fig.savefig("prices.png", dpi=150)Templates for this lesson
Included with a Premieur membership.
- NotebookPython analysis notebookPrice tables, simple and log returns, rolling volatility, monthly resampling and a fund against its benchmark, in pandas.Download
- CSVSample pricesDaily closes for three hypothetical tickers over the 252 trading days of 2024, the data the notebooks load.Download