Resampling and returns
Lesson 7 · about 12 min
Two operations sit underneath everything else in this course: changing the bar size (resampling) and turning prices into returns. Both look trivial and both have a wrong version that is only slightly wrong, which is the worst kind. This lesson pins down the correct versions and shows the small tests that prove them.
Resampling bars
To build weekly bars from daily bars, each column needs a different rule: the week's open is the first open, the high is the max, the low is the min, the close is the last close, the volume is the sum.
import numpy as np
import pandas as pd
from src.data import synthetic_ohlcv
OHLCV_AGG = {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}
daily = synthetic_ohlcv(600, seed=11)
weekly = daily.resample("W-FRI").agg(OHLCV_AGG).dropna()
monthly = daily.resample("ME").agg(OHLCV_AGG).dropna()
print(daily.tail(6)[["open", "high", "low", "close"]].round(2))
print(weekly.tail(2)[["open", "high", "low", "close"]].round(2))
"W-FRI" labels each week by its Friday and includes Monday to Friday. "ME" is month end (older pandas used "M"). dropna() removes periods with no bars, which otherwise appear as all-NaN rows.
The same code produces hourly from minute bars, or 4-hour from hourly; only the frequency string changes. For intraday data with a UTC index, remember that "1D" boundaries fall at midnight UTC. If you want daily bars that end at the New York close, convert the index to America/New_York before resampling and back afterwards, or use resample("1D", offset="..."); the point is to decide, not to accept a default.
A check worth keeping as a test: the last close of the weekly frame must equal the last close of the daily frame, and the weekly high must be at least the daily high on every day inside that week.
assert weekly["close"].iloc[-1] == daily["close"].iloc[-1]
last_week = daily.loc[weekly.index[-2] + pd.Timedelta(days=1): weekly.index[-1]]
assert weekly["high"].iloc[-1] >= last_week["high"].max() - 1e-12
Simple returns
The simple return of a bar is the close divided by the previous close, minus one.
ret = daily["close"].pct_change()
print(ret.describe().round(5))
The first value is NaN because there is no previous close. Do not fillna(0) blindly here; in a backtest it is often right, but for statistics it inflates the count by one zero.
Simple returns are what your account actually experiences, and they are the only kind that adds up correctly across positions: a portfolio that is half in A and half in B earns half of A's simple return plus half of B's. They do not add up across time; two consecutive days of +10% are +21%, not +20%.
Compounding over time is a product:
growth = (1 + ret).cumprod()
total_return = growth.iloc[-1] - 1
print(f"total simple return: {total_return:.2%}")
Log returns
The log return is the natural log of the price ratio.
log_ret = np.log(daily["close"]).diff()
print(f"sum of log returns: {log_ret.sum():.5f}")
print(f"log of total growth: {np.log(1 + total_return):.5f}")
Those two numbers are identical. Log returns add across time, which makes them convenient for statistics: the annualised mean is just the daily mean times 252, the variance scales the same way, and long sequences do not suffer from the compounding asymmetry where a 50% loss needs a 100% gain.
The conversion in both directions:
back_to_simple = np.exp(log_ret) - 1
assert np.allclose(back_to_simple.dropna(), ret.dropna())
For daily moves of a percent or two, simple and log returns differ in the third decimal place. For a 30% crypto day they differ a lot: log(1.30) = 0.262, and log(0.70) = −0.357.
Which to use where
- Backtest P&L, position sizing, anything a broker will see: simple returns.
- Statistics across time (mean, volatility, Sharpe inputs), normality tests, anything you will sum: log returns.
- Portfolio aggregation across instruments on the same day: simple returns, weighted by exposure.
Mixing them silently is a standard bug. A function that takes returns should say in its docstring which kind, and a metrics.py that computes Sharpe from log returns but drawdown from simple returns should label both.
Key idea: Simple returns add across assets; log returns add across time. Choose deliberately, name the variable so the choice is visible, and convert explicitly when you must switch.
Multi-period returns without loops
The 20-bar return of every bar, vectorized:
ret_20 = daily["close"].pct_change(20) # close / close.shift(20) - 1
ret_20_from_log = np.exp(log_ret.rolling(20).sum()) - 1
assert np.allclose(ret_20.dropna(), ret_20_from_log.dropna())
Both give the same series. The rolling-sum-of-logs form is the one that generalises to weighting schemes and to "return since signal" calculations later.
Forward returns, the return from this bar to some future bar, are what you use to ask whether a signal predicts anything:
fwd_5 = daily["close"].shift(-5) / daily["close"] - 1
Negative shifts look into the future. That is exactly right for evaluating a signal after the fact and exactly wrong inside a signal. Module 5 is about keeping the two apart; for now, notice that shift(-5) is a phrase that should only ever appear in analysis code, never in strategy code.
Try it: Resample the synthetic daily bars to weekly and compute weekly simple returns two ways: from the weekly closes, and by compounding the daily returns inside each week with
(1 + ret).resample("W-FRI").prod() - 1. Confirm they match. Then do the same with log returns and.sum(). Which one required fewer steps?
Recap
- Resample OHLCV with first/max/min/last/sum, then
dropna(); decide your daily boundary explicitly for intraday and 24-hour markets. - Simple returns are what the account earns and add across assets on the same day.
- Log returns add across time and are the right input for statistics.
- Convert explicitly with
np.log(1 + r)andnp.exp(l) - 1; never mix silently. - Negative shifts belong in analysis code only.
See it drawn
Original diagrams for the ideas on this page. Illustrative, not real market data.