Skip to content
GetProfitable
Search

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) and np.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.

Compounding against a flat returnTwo account balances over fifteen years at the same yearly rate: one curve bends upwards as gains are left in, the other rises in a straight line.ACCOUNT VALUE$10k$20k$30k$40k051015YEARSCOMPOUNDED 10% a yearSIMPLE: 10% of the original sumboth start at $10,000 and run 15 years$41,772DIFFERENCE$16,772$25,000
Compounding against a flat return. Two accounts start at $10,000 and earn 10% a year for fifteen years. Leaving the gains in means each year earns on a larger balance, so the curve bends away from the straight line and ends $16,772 higher.
How a position size is worked outAccount size, risk per trade and stop distance feed into one box giving the number of shares.ACCOUNT SIZE$25,000your capitalRISK PER TRADE1%of the accountSTOP DISTANCE$0.50entry to stopPOSITION SIZE500 sharesrisk budget: $25,000 × 1% = $250position size: $250 ÷ $0.50 = 500 shares
Working out a position size. Three numbers decide how big a trade is: the account, the share of it put at risk, and the distance from entry to stop. One percent of $25,000 is a $250 budget, and a $0.50 stop divides into that 500 times.