NewsletterStudioAdvertise
BUYSIDERS®
Dealroom
BUYSIDERS®
News
Deals
Funds
Allocators
Learn
Loading
© 2026 Buysiders Group Holdings All rights reserved.
AboutMembershipSupportPrivacyTermsRefundsupport@buysidersgroup.com
Learn / Quants and programming / Python for financial analysis

Python for financial analysis

Buysiders Institute · Read time: 15 minutes

Setting up Python and pandas, then the everyday tasks: price tables, returns, rolling volatility, monthly resampling, joining two series and plotting.

$

Contents

Setting upPrices in a DataFrame, and computing returnsRolling volatilityResampling to monthlyJoining two series, and plotting

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').

Environment and importsPython
# 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__)
The import aliases np and pd are universal convention. Use them so other people can read your code.

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.

Simple and log returns on a five day seriesPython
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}")
Output
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
The sum of log returns equals the log of the total gross return: ln(1.0351) = 0.034498. Simple returns do not add: the four daily figures sum to 3.4955%, close to but not the same as the true compounded 3.5100%.
Two years of toy prices for two securitiesPython
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))
A seeded generator makes the toy data identical on every run. bdate_range skips weekends but not public holidays; real data will have its own calendar.

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.

Full sample and rolling annualised volatilityPython
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)
Continues from the toy prices above. pandas std uses the sample formula (dividing by n minus 1) by default, unlike NumPy, which divides by n unless told otherwise.
Where this goes wrong

Mixing pandas and NumPy standard deviations

Series.std() in pandas defaults to ddof=1 (sample standard deviation). np.std defaults to ddof=0 (population). On short windows the difference is visible, and it is a common reason two analysts report different volatilities from the same data.

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 and monthly returns, two waysPython
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))
The labels are calendar month ends ('2023-03-31'), even when the last trading day was earlier. That matters when joining to data labelled by the last business day, as the next section shows.

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.

Aligning a monthly fund NAV with a benchmarkPython
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}")
Without the to_period step, March, June, August and November would fail to match, because the fund uses the last business day and the resampled benchmark uses the calendar month end.
Growth of one and rolling volatility, saved to a filePython
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)
Where this goes wrong

Forward filling across a join

When series do not align, it is tempting to call ffill() to fill gaps. For prices on a holiday that is usually fine. For fundamentals or NAVs it can carry a stale value forward for weeks, which understates volatility and, in a backtest, can leak information if the fill is done before the data was actually published.

Templates for this lesson

Included with a Buysiders 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
Go deeper with Membership →