The return duality: Why compounding semantics break backtests
In quantitative finance and algorithmic strategy development, returns are the universal currency. Every performance metric—from the Sharpe ratio to maximum drawdown—relies on a sequence of returns derived from raw price observations. Yet many researchers casually treat log returns vs simple returns as interchangeable approximations, relying on the introductory calculus premise that ln(1 + R) ≈ R when relative changes remain small.
In production backtesting and portfolio accounting, treating these two return definitions interchangeably introduces subtle, catastrophic bugs. Summing simple returns across time overstates compounding performance and manufactures phantom alpha. Conversely, cross-sectionally averaging log returns across a portfolio of assets systematically underestimates real capital growth due to Jensen's inequality. Furthermore, feeding catastrophic drawdowns or non-positive price series into logarithmic functions triggers silent NaN propagation or unhandled -inf runtime exceptions.
flowchart TD
A["Raw Price Path: P_0, P_1, ..., P_T"] --> B["Return Representation Selection"]
B --> C["Simple Return (R_t)\nR_t = (P_t - P_{t-1}) / P_{t-1}\nDiscrete Percentage Growth"]
B --> D["Log Return (r_t)\nr_t = ln(P_t / P_{t-1}) = ln(1 + R_t)\nContinuously Compounded Growth"]
C --> E["Cross-Sectional Portfolio Aggregation\nAdditive across assets: R_p = sum(w_i * R_i)\nEssential for cash ledgers & order sizing"]
C --> F["Time Aggregation Hazard\nMultiplicative across time: prod(1 + R_t) - 1\nSumming simple returns creates phantom drift"]
D --> G["Temporal Time Aggregation\nAdditive across time: r_{0,T} = sum(r_t)\nTelescoping sum simplifies multi-period models"]
D --> H["Portfolio Aggregation Hazard\nNon-additive across assets: ln(sum(w_i * e^{r_i}))\nAveraging log returns underestimates arithmetic growth"]To build robust quantitative backtests, you must master the fundamental duality of financial returns: simple returns aggregate cleanly across space (portfolios), while log returns aggregate cleanly across time.
This guide breaks down the mathematical foundations of both return formulations, analyzes their algebraic traps, demonstrates numerical stability techniques using log1p returns and cumulative simple return calculations in NumPy, and establishes how modern event-driven backtesting engines reconcile discrete cash ledgers with continuous statistical modeling.
Mathematical formulation: Arithmetic vs logarithmic mechanics
To understand where algorithmic models diverge, we must begin with formal definitions. Consider a discrete historical price series P_t evaluated at consecutive observation boundaries t = 0, 1, 2, ..., T.
1. Discrete simple return (arithmetic return)
The discrete simple return R_t measures the relative percentage change in asset price between two consecutive time intervals:
R_t = (P_t - P_{t-1}) / P_{t-1} = (P_t / P_{t-1}) - 1If you start with an initial capital allocation V_0 and purchase an asset at price P_0, the asset value at time t equals V_t = V_{t-1} * (1 + R_t). The return directly reflects the cash profit or loss generated per dollar invested:
Dollar PnL = V_{t-1} * R_tSimple returns possess a vital structural property: spatial additivity. In a portfolio of N assets where asset i has portfolio weight w_i (such that sum(w_i) = 1.0), the portfolio simple return R_{p,t} is the exact linear combination of individual asset simple returns:
R_{p,t} = sum_{i=1}^N w_i * R_{i,t}However, simple returns are not additive over time. If an asset gains +10% in period 1 and loses -10% in period 2, the two-period return is not 0.0%, but (1 + 0.10) * (1 - 0.10) - 1 = -0.01 (-1.0%).
2. Continuously compounded return (log return)
The log return r_t (also known as the continuously compounded return) is the natural logarithm of the gross price relative:
r_t = ln(P_t / P_{t-1}) = ln(1 + R_t)By the fundamental properties of logarithms, the division inside the logarithm converts to subtraction:
r_t = ln(P_t) - ln(P_{t-1})Log returns possess the inverse structural property: temporal additivity. Across T consecutive periods, the total cumulative log return is the linear sum of all intermediate period log returns:
r_{0,T} = sum_{t=1}^T r_t = ln(P_1 / P_0) + ln(P_2 / P_1) + ... + ln(P_T / P_{T-1}) = ln(P_T / P_0)The intermediate price terms telescope out entirely. However, log returns are not additive across assets. The log return of a portfolio is not the weighted average of individual asset log returns:
r_{p,t} = ln(sum_{i=1}^N w_i * (1 + R_{i,t})) != sum_{i=1}^N w_i * r_{i,t}Taylor expansion and the volatility drag gap
Why do simple returns and log returns approximate each other for small percentage movements? Expanding ln(1 + R) around R = 0 via a Taylor series yields:
ln(1 + R) = R - (R^2 / 2) + (R^3 / 3) - (R^4 / 4) + ...For small price changes (such as daily equity shifts where |R| < 0.01), higher-order terms like R^2 / 2 are negligible, meaning r_t ≈ R_t. But as volatility increases or return horizons widen, the second-order term -(R^2 / 2) becomes significant.
This quadratic term represents the mathematical root of volatility drag: the persistent geometric penalty that volatile price fluctuations impose on compounded wealth.
Comprehensive structural comparison
| Dimension | Simple Return (R_t) | Log Return (r_t) |
|---|---|---|
| Formula | (P_t - P_{t-1}) / P_{t-1} | ln(P_t / P_{t-1}) = ln(1 + R_t) |
| Inverse Formula | R_t = exp(r_t) - 1 | r_t = ln(1 + R_t) |
| Time Aggregation | Multiplicative: prod(1 + R_t) - 1 | Additive: sum(r_t) |
| Portfolio Aggregation | Additive: sum(w_i * R_i) | Non-additive: ln(sum(w_i * exp(r_i))) |
Total Loss Boundary (P_t = 0) | Exactly -1.0 (-100%) | Approaches -inf |
Negative Prices (P_t < 0) | Computable if shifted | Undefined in real domain (NaN) |
| Distributional Assumption | Bounded below at -1.0, right-skewed | Unbounded (-inf, +inf), closer to Normal |
| Primary Use in Quant Finance | Cash ledgers, order sizing, investor tear sheets | Time-series econometric models, GARCH, Ito calculus |
The temporal compounding trap: Summing simple returns vs cumulative simple return
The single most prevalent compounding error in algorithmic trading backtests is calculating cumulative performance by summing simple returns:
# CRITICAL BACKTESTING BUG: Naive summation of simple returns
flawed_cumulative_return = df["simple_return"].sum()Summing simple returns assumes linear, non-compounding payoffs where the investor recalibrates their position size at every bar to match the initial capital V_0, without accounting for compounding gains or path-dependent drawdown penalties.
The asymmetry of percentage losses
Consider an asset that drops by -50% on Day 1 and gains +50% on Day 2. If you naively sum the simple returns:
Flawed Return = (+0.50) + (-0.50) = 0.00 (0.0%)In financial reality, your cash tells an entirely different story. Beginning with $100,000:
- Day 1:
$100,000 * (1 - 0.50) = $50,000 - Day 2:
$50,000 * (1 + 0.50) = $75,000
You suffered a net loss of -$25,000 (-25.0%). Recovering from a -50% drawdown requires a +100% gain, not a +50% gain.
Because simple returns have an asymmetrical recovery profile, summing simple returns introduces a persistent upward bias (or downward distortion depending on the sequence) that completely misrepresents strategy equity.
The cumulative simple return formula
To track true portfolio equity across time using simple returns, you must compute the compounded product of gross returns:
P_T = P_0 * prod_{t=1}^T (1 + R_t)
Cumulative Simple Return = prod_{t=1}^T (1 + R_t) - 1In vectorized Python with NumPy or Pandas, this is calculated as:
import numpy as np
import pandas as pd
# Correct cumulative simple return calculation
cumulative_simple_return = np.prod(1.0 + df["simple_return"].dropna()) - 1.0
# Vectorized cumulative equity curve indexed from 1.0
equity_curve = (1.0 + df["simple_return"].dropna()).cumprod()How log returns eliminate the compounding sum trap
Because log returns are logarithms of price ratios, the sum of log returns equals the log of the product of gross price ratios:
sum_{t=1}^T r_t = sum_{t=1}^T ln(1 + R_t) = ln( prod_{t=1}^T (1 + R_t) )Applying the exponential function recovers the exact cumulative simple return:
Cumulative Simple Return = exp( sum_{t=1}^T r_t ) - 1Testing this on our -50% and +50% example:
r_1 = ln(1 - 0.50) = ln(0.5) ≈ -0.69315r_2 = ln(1 + 0.50) = ln(1.5) ≈ +0.40547sum(r) = -0.69315 + 0.40547 = -0.28768exp(-0.28768) - 1 = 0.75 - 1 = -0.25 (-25.0%)
The sum of log returns accurately captures the -25.0% capital erosion. In statistical research, log returns allow linear matrix additions without distorting path dependency.
The cross-sectional aggregation hazard: Jensen's inequality and portfolio weighting
While quantitative researchers love log returns for their time-additive properties, attempting to aggregate log returns across a portfolio of multiple assets creates an equally dangerous mathematical error.
Why quants make the portfolio aggregation mistake
When building multi-asset backtests, it is tempting to maintain return matrices entirely in log space. A developer might compute the log returns for ten assets, construct a portfolio weight vector w, and attempt to compute portfolio performance as:
# CRITICAL PORTFOLIO ERROR: Weighted average of log returns
flawed_portfolio_log_return = np.sum(weights * asset_log_returns)This operation is mathematically invalid. It violates the definition of portfolio wealth.
Derivation via Jensen's inequality
Suppose you allocate initial capital V_0 across N assets with weights w_i, where sum(w_i) = 1.0. At the end of period t, the total capital value is:
V_t = sum_{i=1}^N w_i * V_0 * (1 + R_{i,t}) = V_0 * ( 1 + sum_{i=1}^N w_i * R_{i,t} )The true portfolio simple return is strictly linear: R_{p,t} = sum(w_i * R_{i,t}).
The true portfolio log return is therefore:
r_{p,t} = ln(1 + R_{p,t}) = ln( sum_{i=1}^N w_i * (1 + R_{i,t}) ) = ln( sum_{i=1}^N w_i * exp(r_{i,t}) )Because the natural logarithm function f(x) = ln(x) is strictly concave (f''(x) = -1 / x^2 < 0 for all x > 0), Jensen's inequality dictates that the value of the function at an average is strictly greater than the average of the function values:
ln( sum_{i=1}^N w_i * X_i ) > sum_{i=1}^N w_i * ln(X_i)Setting X_i = 1 + R_i = exp(r_i):
ln( 1 + R_p ) > sum_{i=1}^N w_i * r_iThe convexity gap
Averaging log returns across assets systematically underestimates the true return of the portfolio. The size of this discrepancy is known as the convexity gap (or diversification bonus).
Under a second-order Taylor expansion for log-normal assets:
ln( 1 + R_p ) ≈ sum_{i=1}^N w_i * r_i + 0.5 * ( sum_{i=1}^N w_i * Var(R_i) - Var(R_p) )Because portfolio variance Var(R_p) is reduced by diversification relative to the weighted individual variances sum(w_i * Var(R_i)), the convexity term is strictly positive.
flowchart LR
A["Asset Returns: R_1, R_2, ..., R_N"] --> B["Arithmetic Weighting\nR_p = sum(w_i * R_i)"]
B --> C["True Portfolio Log Return\nr_p = ln(1 + R_p)"]
A --> D["Convert to Log Returns\nr_i = ln(1 + R_i)"]
D --> E["Flawed Weighted Average\nr_flawed = sum(w_i * r_i)"]
C -. "Convexity Gap > 0\n(Jensen's Inequality)" .-> EIf you calculate portfolio returns by taking the weighted average of log returns, your simulated portfolio will underperform its real mathematical counterpart by dozens to hundreds of basis points each year.
Floating-point precision and catastrophic cancellation: Why np.log1p and np.expm1 matter
When implementing return math in Python, numerical precision is not merely an academic detail—it directly impacts strategy validation.
In standard IEEE 754 double-precision floating-point format (float64), numbers are stored with 53 bits of mantissa, providing approximately 15 to 17 significant decimal digits of precision.
Catastrophic cancellation in naive log returns
In intraday, tick-level, or high-frequency backtests, individual bar returns are frequently very small:
R_t ≈ 10^{-5} to 10^{-8}When evaluating np.log(1.0 + R), Python first computes 1.0 + R. If R is small, adding it to 1.0 shifts R's least significant bits beyond the 53-bit mantissa boundary, discarding lower-order bits:
1.0 + 1.0e-16 = 1.0000000000000000... (truncated to exactly 1.0)
np.log(1.0) = 0.0Even before reaching total truncation at 10^{-16}, significant relative error occurs at 10^{-8} to 10^{-12}. This loss of precision when adding small numbers to 1.0 and taking the logarithm is known as catastrophic cancellation.
The solution: np.log1p and np.expm1
NumPy and Python's math module provide specialized C99-standard math functions designed to evaluate these transformations without adding 1.0 in float space:
-
np.log1p(x): Computesln(1 + x)directly. For smallx, it uses a direct Taylor or Chebyshev polynomial expansion:Code log1p(x) = x - x^2/2 + x^3/3 - x^4/4 + ...This maintains full 64-bit precision without ever evaluating
1.0 + x. -
np.expm1(x): Computesexp(x) - 1directly. For smallx:Code expm1(x) = x + x^2/2! + x^3/3! + ...This prevents cancellation errors when converting accumulated log returns back to simple cumulative returns.
Numerical benchmark: Naive vs log1p
The following table demonstrates the relative error between naive np.log(1.0 + d) and np.log1p(d) across decreasing step magnitudes:
Delta (R) | Naive np.log(1.0 + R) | Precise np.log1p(R) | Relative Error
--------------------------------------------------------------------------------------
1.0e-04 | 9.9995000333297321e-05 | 9.9995000333308339e-05 | 1.10e-13
1.0e-08 | 9.9999998892252911e-09 | 9.9999999500000010e-09 | 6.08e-09
1.0e-12 | 1.0000889005818410e-12 | 9.9999999999950000e-13 | 8.89e-05
1.0e-15 | 1.1102230246251559e-15 | 9.9999999999999950e-16 | 1.10e-01 (11% error!)
1.0e-17 | 0.0000000000000000e+00 | 1.0000000000000001e-17 | 1.00e+00 (100% loss)At 1e-15, naive np.log(1.0 + R) generates an 11% relative error. At 1e-17, naive addition drops the return entirely, producing 0.0 (a complete 100% loss of signal).
For high-precision volatility estimation and intraday bar conversions, always use log1p returns via np.log1p(R) and convert back via np.expm1(r).
Extreme drawdowns and boundary breakdowns: Zero equity and negative prices
When backtesting through volatile crisis periods or stress testing tail risk, simple returns and log returns display sharply divergent behaviors at numerical boundaries.
flowchart TD
A["Stress Event: Price Drop or Anomaly"] --> B{"Terminal Condition"}
B -->|"Total Loss: P_t = 0"| C["Simple Return: R_t = -1.0 (-100%)\nCash Ledger: Equity = $0.00\nEngine state: Liquidated"]
B -->|"Total Loss: P_t = 0"| D["Log Return: r_t = ln(0) = -inf\nVector math: Breaks mean, std, Sharpe\nRequires NaN/Inf guards"]
B -->|"Account Deficit: Equity < 0"| E["Simple Return: R_t < -1.0\nReflects real negative balance / debt"]
B -->|"Account Deficit: Equity < 0"| F["Log Return: ln(negative) = NaN\nCrash: RuntimeWarning invalid value"]
B -->|"Negative Commodity Price"| G["WTI Futures: -$37.63\nLog price ln(P) mathematically impossible"]1. Total loss and liquidation (P_t = 0)
When a trading asset goes bankrupt or an account is liquidated to zero:
- Simple Return:
R_t = (0 - P_{t-1}) / P_{t-1} = -1.0(-100%). The cash balance reaches$0.00. Subsequent returns are zero if trading terminates. - Log Return:
r_t = ln(0 / P_{t-1}) = -inf.
An -inf entry in a NumPy array or Pandas Series ruins downstream matrix calculations. Functions like np.mean(), np.std(), and rolling Sharpe ratio calculations produce NaN or -inf.
If an algorithmic backtest fails to catch an equity wipeout, a single -inf will quietly invalidate all subsequent trial statistics.
2. Leveraged deficit and negative equity (Equity < 0)
In leveraged margin trading, gap openings and extreme slippage can drive account equity below zero before stop-loss orders execute.
- Simple Return: An equity drop from
$10,000to-$1,000corresponds to a simple return of-1.10(-110%). While financially painful, the arithmetic remains defined. - Log Return: Evaluating
np.log(1 + (-1.10))attempts to computeln(-0.10). In real-number arithmetic, this producesNaNand triggersRuntimeWarning: invalid value encountered in log1p.
3. Negative commodity and energy prices
On April 20, 2020, the May 2020 WTI Crude Oil futures contract settled at -$37.63 per barrel due to storage exhaustion. Similar negative pricing occurs routinely in wholesale electricity markets (such as ERCOT and European power exchanges) during periods of excessive renewable generation and inflexible baseload output.
Log returns rely on positive prices because ln(P_t / P_{t-1}) requires P_t / P_{t-1} > 0. If prices cross zero into negative territory, logarithmic returns completely break down.
When backtesting commodities, physical power markets, or synthetic market-neutral spreads (such as crack spreads or calendar spreads where price differences can be negative), backtesters must use discrete simple returns or absolute dollar PnL accounting.
End-to-end Python implementation: Ledger audit and return reconciliation
The following production-grade Python script demonstrates the full return engineering lifecycle:
- Simulating an authentic discrete price path with realistic swings.
- Converting between simple and log returns defensively using
np.log1pandnp.expm1. - Demonstrating the compounding sum trap vs true cumulative simple return.
- Illustrating the cross-sectional portfolio aggregation trap (Jensen's convexity gap).
- Running an exact bar-by-bar cash ledger simulation to verify theoretical returns against executed account balances.
"""
Return Mechanics and Compounding Reconciliation Audit
Author: OpenBacktesting Research
Python Version: >=3.12
"""
import numpy as np
import pandas as pd
def run_return_mechanics_audit():
# -------------------------------------------------------------
# 1. Price Path and Return Generation
# -------------------------------------------------------------
dates = pd.date_range("2025-01-01", periods=10, freq="B")
prices = np.array([100.0, 102.5, 101.0, 106.0, 103.5, 108.0, 105.0, 107.5, 112.0, 110.0])
df = pd.DataFrame({"close": prices}, index=dates)
# Compute discrete simple return R_t
df["simple_return"] = df["close"].pct_change()
# Compute log return r_t using numerically stable log1p
df["log_return"] = np.log1p(df["simple_return"])
print("=== Step 1: Return Series Comparison ===")
print(df[["close", "simple_return", "log_return"]].dropna().round(6))
# -------------------------------------------------------------
# 2. Multi-Period Compounding Audit
# -------------------------------------------------------------
valid_simple = df["simple_return"].dropna()
valid_log = df["log_return"].dropna()
# Direct terminal return from endpoints
terminal_return = (df["close"].iloc[-1] / df["close"].iloc[0]) - 1.0
# True cumulative simple return via product of gross returns
true_cum_simple = np.prod(1.0 + valid_simple) - 1.0
# Sum of log returns converted back via expm1
sum_log = valid_log.sum()
reconstructed_simple = np.expm1(sum_log)
# Flawed naive sum of simple returns
faulty_sum = valid_simple.sum()
print("\n=== Step 2: Multi-Period Compounding Audit ===")
print(f"Direct endpoint price return: {terminal_return:.6f} ({terminal_return * 100:.2f}%)")
print(f"Product of simple returns: {true_cum_simple:.6f} ({true_cum_simple * 100:.2f}%)")
print(f"Sum of log returns (via expm1): {reconstructed_simple:.6f} ({reconstructed_simple * 100:.2f}%)")
print(f"Faulty sum of simple returns: {faulty_sum:.6f} ({faulty_sum * 100:.2f}%)")
print(f"Summation error (phantom drift): {(faulty_sum - terminal_return) * 100:.4f}% points")
# -------------------------------------------------------------
# 3. Portfolio Cross-Sectional Aggregation Trap
# -------------------------------------------------------------
weights = np.array([0.40, 0.35, 0.25])
asset_simple_ret = np.array([0.15, -0.08, 0.04])
asset_log_ret = np.log1p(asset_simple_ret)
# True portfolio simple return (linear combination)
p_simple_ret = np.sum(weights * asset_simple_ret)
p_true_log_ret = np.log1p(p_simple_ret)
# Flawed weighted average of log returns
p_flawed_log_ret = np.sum(weights * asset_log_ret)
p_implied_simple_from_flawed_log = np.expm1(p_flawed_log_ret)
print("\n=== Step 3: Portfolio Cross-Sectional Aggregation ===")
print(f"True portfolio simple return: {p_simple_ret:.6f} ({p_simple_ret * 100:.2f}%)")
print(f"True portfolio log return: {p_true_log_ret:.6f}")
print(f"Weighted average of log returns: {p_flawed_log_ret:.6f}")
print(f"Implied simple from flawed log: {p_implied_simple_from_flawed_log:.6f} ({p_implied_simple_from_flawed_log * 100:.2f}%)")
print(f"Convexity underestimation: {(p_simple_ret - p_implied_simple_from_flawed_log) * 10000:.2f} bps")
# -------------------------------------------------------------
# 4. Cash Ledger Simulation Verification
# -------------------------------------------------------------
initial_cash = 100_000.0
shares = initial_cash / prices[0] # Buy and hold 1,000 shares at $100
ledger_records = []
for bar_idx, p in enumerate(prices):
equity = shares * p
ledger_records.append({
"bar": bar_idx,
"price": p,
"equity": equity
})
ledger_df = pd.DataFrame(ledger_records)
ledger_df["bar_simple_ret"] = ledger_df["equity"].pct_change().fillna(0.0)
ledger_df["bar_log_ret"] = np.log1p(ledger_df["bar_simple_ret"])
ledger_df["naive_sum_ret"] = ledger_df["bar_simple_ret"].cumsum()
ledger_df["cum_simple_ret"] = (ledger_df["equity"] / initial_cash) - 1.0
ledger_df["compounded_ret"] = (1.0 + ledger_df["bar_simple_ret"]).cumprod() - 1.0
ledger_df["reconstructed_ret"] = np.expm1(ledger_df["bar_log_ret"].cumsum())
print("\n=== Step 4: Bar-by-Bar Ledger Audit ===")
pd.set_option("display.max_columns", None)
pd.set_option("display.width", 1000)
pd.set_option("display.float_format", lambda x: f"{x:9.4f}")
print(ledger_df[["bar", "price", "equity", "bar_simple_ret", "naive_sum_ret", "cum_simple_ret", "compounded_ret", "reconstructed_ret"]])
if __name__ == "__main__":
run_return_mechanics_audit()Execution output
Running this script yields exact mathematical reconciliation across all four validation layers:
=== Step 1: Return Series Comparison ===
close simple_return log_return
2025-01-02 102.5 0.025000 0.024693
2025-01-03 101.0 -0.014634 -0.014742
2025-01-06 106.0 0.049505 0.048319
2025-01-07 103.5 -0.023585 -0.023867
2025-01-08 108.0 0.043478 0.042560
2025-01-09 105.0 -0.027778 -0.028171
2025-01-10 107.5 0.023810 0.023530
2025-01-13 112.0 0.041860 0.041008
2025-01-14 110.0 -0.017857 -0.018019
=== Step 2: Multi-Period Compounding Audit ===
Direct endpoint price return: 0.100000 (10.00%)
Product of simple returns: 0.100000 (10.00%)
Sum of log returns (via expm1): 0.100000 (10.00%)
Faulty sum of simple returns: 0.099799 (9.98%)
Summation error (phantom drift): -0.0201% points
=== Step 3: Portfolio Cross-Sectional Aggregation ===
True portfolio simple return: 0.042000 (4.20%)
True portfolio log return: 0.041142
Weighted average of log returns: 0.036526
Implied simple from flawed log: 0.037202 (3.72%)
Convexity underestimation: 47.98 bps
=== Step 4: Bar-by-Bar Ledger Audit ===
bar price equity bar_simple_ret naive_sum_ret cum_simple_ret compounded_ret reconstructed_ret
0 0 100.0000 100000.0000 0.0000 0.0000 0.0000 0.0000 0.0000
1 1 102.5000 102500.0000 0.0250 0.0250 0.0250 0.0250 0.0250
2 2 101.0000 101000.0000 -0.0146 0.0104 0.0100 0.0100 0.0100
3 3 106.0000 106000.0000 0.0495 0.0599 0.0600 0.0600 0.0600
4 4 103.5000 103500.0000 -0.0236 0.0363 0.0350 0.0350 0.0350
5 5 108.0000 108000.0000 0.0435 0.0798 0.0800 0.0800 0.0800
6 6 105.0000 105000.0000 -0.0278 0.0520 0.0500 0.0500 0.0500
7 7 107.5000 107500.0000 0.0238 0.0758 0.0750 0.0750 0.0750
8 8 112.0000 112000.0000 0.0419 0.1177 0.1200 0.1200 0.1200
9 9 110.0000 110000.0000 -0.0179 0.0998 0.1000 0.1000 0.1000Notice the key revelations in this execution output:
- Endpoint Reconciliation: Both
np.prod(1 + R) - 1andnp.expm1(sum(r))match the direct price return of+10.00%down to machine precision. Naive summation (0.099799) introduces a negative drift error of0.0201%points across just 9 bars. - Convexity Underestimation: Averaging asset log returns directly produces an implied return of
3.72%, severely understating the true portfolio performance of4.20%by47.98 basis points. - Ledger Alignment: In the bar-by-bar ledger,
cum_simple_ret,compounded_ret, andreconstructed_retalign perfectly with true account equity at every step, whilenaive_sum_retdiverges persistently.
OpenBacktesting architectural principle: Cash ledger vs statistical modeling
The dual nature of financial returns explains why backtesting frameworks cannot use a one-size-fits-all return abstraction. In modern algorithmic systems, clean architectural separation between accounting ledgers and statistical modeling is essential.
Cash ledger accounting demands simple returns
At the execution layer, real-world brokerage accounts operate strictly in discrete currency units. When an algorithm places an order, the exchange does not execute logarithmic fractions—it trades discrete share lots at specific transaction prices.
An institutional backtester must account for:
- Discrete dollar balances:
Cash_t = Cash_{t-1} - (Filled_Price * Shares) - Commissions - Slippage - Mark-to-market position value:
Equity_t = Cash_t + (Shares * Close_t) - Accurate trade-by-trade simple returns:
Return = (Exit_Price - Entry_Price) / Entry_Price
Using log returns in a cash ledger introduces rounding errors, violates balance sheet conservation laws, and obscures transaction costs. For step-by-step guidance on constructing robust event-driven trading logic, explore our comprehensive python backtesting tutorial.
Statistical research demands log returns
Conversely, alpha researchers building predictive time-series models require the mathematical conveniences of log returns:
- Stationarity and Normality: Price series are non-stationary with unit roots. Log price differences
r_t = ln(P_t) - ln(P_{t-1})approximate stationary Gaussian sequences required for ARMA, GARCH, and Kalman filter estimators. - Continuous Compounding in Derivatives: Black-Scholes-Merton option pricing and stochastic differential equations (geometric Brownian motion) require log returns to satisfy Ito's lemma.
- Vectorized Lookback Transformations: Rolling log returns can be computed via simple sliding window differences
diff(k)on log prices, which is computationally faster than rolling cumulative products. To handle raw DataFrame inputs cleanly, review our deep dive on pandas pct_change returns.
How OpenBacktesting solves the divide
OpenBacktesting (0.2.1) is an independent Python backtesting engine built for Python >=3.12,<3.15 that enforces a strict architectural boundary for single-asset historical OHLCV bar-by-bar research:
- Deterministic Discrete Execution: All order fills, slippage calculations, broker commission deductions, and cash equity updates occur strictly through discrete arithmetic cash ledgers.
- Inspectable SQLite Trials: Every simulation trial records inspectable orders, trade fills, and equity points directly into local SQLite storage, eliminating hidden compounding assumptions.
- Studio Visualization: Researchers inspect real dollar equity curves alongside standardized metrics like annualized return in Python and maximum drawdown inside the local Studio UI without worrying about logarithmic distortions or mathematical artifacts.
Summary checklist: Decision framework for log returns vs simple returns
To prevent costly compounding traps and math errors in your quantitative pipeline, apply this defensive engineering checklist:
[✓] Time Series Aggregation (Multi-Period Returns)
--> Use Log Returns: sum(r_t) telescopes cleanly.
--> If using Simple Returns, NEVER sum them: use np.prod(1 + R) - 1.
--> Convert cumulative log returns back using np.expm1(sum(r)).
[✓] Cross-Sectional Aggregation (Portfolios & Baskets)
--> NEVER average log returns across assets: sum(w_i * r_i) violates Jensen's inequality.
--> Use Simple Returns: R_p = sum(w_i * R_i).
--> If log returns are needed for the portfolio, compute ln(1 + R_p) from the portfolio simple return.
[✓] Numerical Precision & Small Increments
--> Avoid naive np.log(1.0 + R) and np.exp(r) - 1.0.
--> Use np.log1p(R) to compute log returns without IEEE 754 catastrophic cancellation.
--> Use np.expm1(r) to recover simple returns from log returns.
[✓] Tail Risk & Boundary Conditions
--> Protect against total bankruptcy: P_t = 0 causes log returns to evaluate to -inf.
--> Protect against negative equity: Leveraged debt produces NaN in log returns.
--> For assets with zero or negative prices (power markets, crude oil spikes, spread contracts),
NEVER use log returns; rely exclusively on discrete dollar PnL or shifted simple returns.
[✓] Backtesting Engine Ledger
--> Enforce discrete arithmetic simple returns for all cash balances, commissions, and trade executions.
--> Restrict log returns to alpha factor modeling, volatility forecasting, and econometric tests.By respecting the mathematical boundary between simple returns across space and log returns across time, you ensure that your algorithmic backtests reflect real-world market dynamics and reproducible financial performance.
