Accounting in Quantitative Finance and Algorithmic Trading
This article includes interactive NumPy cells that verify the numerical examples in each section. NumPy runs in a WebAssembly Python runtime (approx. 5 MB, cached after first load). You can also load it on demand from any code cell below.
0. Introduction
This post is for accountants and CPAs who work with, or want to work with, quantitative trading firms, hedge funds, or algorithmic trading operations. It covers the conceptual and computational foundations that connect your existing expertise in accounting to the language and methods of quantitative finance.
How to read this post. The main text is written at a level any CPA can follow: prose, tables, and straightforward arithmetic. No stochastic calculus required. Throughout the post, you will encounter two types of collapsible sections:
Formal Treatment
These sections contain the full mathematical machinery: formal definitions, theorems, proofs, and numbered equations. They use the notation and style of a graduate mathematics or finance textbook. These sections are optional on a first reading, but recommended for a complete understanding. If you want to see the rigour behind a claim made in the main text, open the corresponding formal treatment block.
Verify It Yourself
These sections contain interactive Python (NumPy) cells that reproduce the numerical examples from the main text. Click the green Run button to execute them in your browser. No installation required. You can load the NumPy environment using the green banner at the top of the page, or on demand from any code cell.
Roadmap. We begin with the accounting equation reframed as a conservation law (Section 1), then cover mark-to-market accounting (Section 2), the Greeks as balance sheet sensitivities (Section 3), P&L attribution (Section 4), the Sharpe ratio (Section 5), risk measures and capital adequacy (Section 6), transaction cost analysis (Section 7), settlement and reconciliation (Section 8), and tax lot accounting for algorithmic strategies (Section 9).
1. The Accounting Equation as a Conservation Law
Every CPA knows the fundamental identity: Assets = Liabilities + Equity. Every transaction preserves this identity. Double-entry bookkeeping is the enforcement mechanism: every debit has an equal and offsetting credit. What is less commonly appreciated is that this identity is a conservation law in exactly the same sense as conservation of energy in physics. Value is neither created nor destroyed by a transaction; it is rearranged among accounts.
Consider a simple example. You start a trading firm with $100,000 in cash equity. You then purchase 1,000 shares of stock XYZ at $50 per share, financing half with a broker margin loan.
| Step | Cash | Securities | Total Assets | Margin Loan | Equity | L + E |
|---|---|---|---|---|---|---|
| Initial capital | 100,000 | 0 | 100,000 | 0 | 100,000 | 100,000 |
| Buy 1,000 XYZ @ $50 | 75,000 | 50,000 | 125,000 | 25,000 | 100,000 | 125,000 |
The purchase simultaneously increases Securities (+$50,000), decreases Cash (-$25,000 paid from equity), and creates a Margin Loan (+$25,000). Assets = Liabilities + Equity holds at every step. The $25,000 you paid from cash plus the $25,000 borrowed equals the $50,000 of stock acquired.
Now extend this to derivatives. Suppose you buy 10 call option contracts (each covering 100 shares) at a premium of $3.00 per share. The strike is $50 and the underlying trades at $48.
| Account | Before | Debit | Credit | After |
|---|---|---|---|---|
| Cash | 75,000 | 3,000 | 72,000 | |
| Options (fair value) | 0 | 3,000 | 3,000 | |
| Securities (XYZ) | 50,000 | 50,000 | ||
| Total Assets | 125,000 | 125,000 | ||
| Margin Loan | 25,000 | 25,000 | ||
| Equity | 100,000 | 100,000 | ||
| L + E | 125,000 | 125,000 |
The option premium is an asset swap: cash decreases by $3,000 (10 contracts x 100 shares x $3.00), and the options appear on the balance sheet at their fair value of $3,000. Total assets unchanged. This is the key insight: buying a derivative does not change your net worth at the moment of purchase. What changes is the composition of your assets and, critically, how that composition responds to future market moves.
Formal Treatment: the accounting equation as a linear constraint
Let be the vector of account balances for a firm with accounts. Partition the index set into three disjoint subsets (assets), (liabilities), and (equity). The accounting equation is the constraint
A transaction is a vector satisfying
Equivalently, define the signed indicator with for and for . Then [eq:txn-conservation] is equivalent to .
If satisfies [eq:acct-eq] and satisfies [eq:txn-conservation], then satisfies [eq:acct-eq].
Proof. We have (the accounting equation) and (the transaction constraint). By linearity, .
This is precisely why the accounting equation is a conservation law: it defines a hyperplane , and every valid transaction keeps the state on .
Verify It Yourself: balance sheet arithmetic
import numpy as np
# Account vector: [Cash, Securities, Options, MarginLoan, Equity]
# Sign vector: +1 for assets, -1 for liabilities/equity
s = np.array([1, 1, 1, -1, -1])
# Initial state
a = np.array([100_000, 0, 0, 0, 100_000])
print(f"Initial: {a} | s.a = {s @ a}")
# Transaction 1: buy 1000 XYZ @ $50, half on margin
t1 = np.array([-25_000, 50_000, 0, 25_000, 0])
a = a + t1
print(f"After buy XYZ: {a} | s.a = {s @ a}")
# Transaction 2: buy 10 call contracts @ $3.00 (1000 shares notional)
t2 = np.array([-3_000, 0, 3_000, 0, 0])
a = a + t2
print(f"After buy opt: {a} | s.a = {s @ a}")
print(f"\nAssets = {a[0] + a[1] + a[2]:,}")
print(f"L + E = {a[3] + a[4]:,}")
print(f"Balance = {'OK' if s @ a == 0 else 'BROKEN'}")Conservation of the accounting equation
2. Mark-to-Market vs Historical Cost
Under historical cost accounting, you record an asset at its acquisition price and recognise gains or losses only when you dispose of it. This is the default for most non-financial assets under US GAAP. Under mark-to-market (fair value) accounting, you revalue the asset to its current market price at each reporting date. The change in value flows through the income statement (or other comprehensive income, depending on classification).
Trading firms operate almost entirely under mark-to-market. The reason is simple: you cannot manage risk against a number that is days, weeks, or months stale. If you bought a stock at $40 and it now trades at $25, your risk exposure is based on $25, not $40. Historical cost tells you what you paid; mark-to-market tells you what you have.
The fair value hierarchy under ASC 820 (US GAAP) and IFRS 13 classifies inputs into three levels. Level 1: quoted prices in active markets (exchange-traded stocks, futures). Level 2: observable inputs other than Level 1 (OTC derivatives priced from observable curves). Level 3: unobservable, model-based inputs (illiquid structured products). Most algorithmic trading operates in Level 1.
Consider a position of 500 shares of XYZ purchased at $40.00. We track it over five trading days, then sell on Day 5.
| Day | Price | Position Value | Unrealised P&L | Daily Change |
|---|---|---|---|---|
| 0 (buy) | 40.00 | 20,000 | 0 | |
| 1 | 41.00 | 20,500 | +500 | +500 |
| 2 | 39.00 | 19,500 | -500 | -1,000 |
| 3 | 42.00 | 21,000 | +1,000 | +1,500 |
| 4 | 43.00 | 21,500 | +1,500 | +500 |
| 5 (sell) | 41.00 | 20,500 | -1,000 |
Under historical cost, nothing appears in the income statement until Day 5, when the entire realised gain of $500 (= 500 x ($41.00 - $40.00)) is recognised. Under mark-to-market, the income statement reflects the daily change column: +500, -1,000, +1,500, +500, -1,000. The sum of these daily changes is +500, matching the realised gain. The two methods agree on the total P&L but disagree on when it is recognised.
For a trading desk, the daily change column is what matters. The risk manager, the portfolio manager, and the CFO all look at the daily mark-to-market P&L. This is the number that drives risk limits, bonus accruals, and capital allocation.
Formal Treatment: the mark-to-market operator
Let a portfolio consist of instruments with quantities and market prices at time . The mark-to-market value is
For a portfolio with no intraday trades, the daily P&L decomposes as
where the unrealised P&L on unchanged positions is
and the realised P&L on positions closed at time is
The cumulative daily unrealised P&L over a holding period (with no interim trades) equals the total mark-to-market change:
Proof. Expand [eq:pnl-unreal] and note the telescoping sum: for each instrument. Multiply by and sum over .
This is why daily mark-to-market and historical cost agree on total P&L: the daily changes telescope to the same endpoint.
Verify It Yourself: mark-to-market P&L
import numpy as np
qty = 500
buy_price = 40.00
prices = np.array([40.00, 41.00, 39.00, 42.00, 43.00, 41.00])
position_value = qty * prices
unrealised_pnl = qty * (prices - buy_price)
daily_change = np.diff(position_value)
print("Day | Price | Value | Unreal P&L | Daily Chg")
print("----|--------|---------|------------|----------")
for i, p in enumerate(prices):
dc = f"{daily_change[i-1]:+,.0f}" if i > 0 else ""
up = f"{unrealised_pnl[i]:+,.0f}" if i < len(prices) - 1 else ""
print(f" {i} | {p:6.2f} | {position_value[i]:7,.0f} | {up:>10} | {dc:>8}")
print(f"\nSum of daily changes: {daily_change.sum():+,.0f}")
print(f"Total realised (sell - buy): {qty * (prices[-1] - prices[0]):+,.0f}")
print(f"Telescoping check: {'OK' if abs(daily_change.sum() - qty*(prices[-1]-prices[0])) < 0.01 else 'FAIL'}")Mark-to-market telescoping
3. The Greeks as Balance Sheet Sensitivities
If you have ever run a sensitivity analysis on a financial model ("if revenue drops 10%, what happens to net income?"), you already understand the Greeks. They answer the same question for a derivatives portfolio: if the underlying price, volatility, time, or interest rate changes by a small amount, how much does the portfolio value change?
Delta (): the change in option value per $1 change in the underlying. A delta of 0.55 means the option gains roughly $0.55 for each $1 the stock rises. Delta is the first-order sensitivity, analogous to a first derivative.
Gamma (): how fast delta itself changes. Gamma is the second derivative of option value with respect to the underlying price. A high-gamma position means delta shifts rapidly as the stock moves, producing large, potentially surprising P&L swings. Think of it as the "acceleration" of your exposure.
Theta (): the daily erosion of option value from the passage of time alone, holding everything else constant. Theta is almost always negative for long option positions. It accrues like depreciation: every day that passes costs you money if you hold options.
Vega (): sensitivity to implied volatility. A vega of 0.15 means the option gains $0.15 for each one-percentage-point increase in implied vol. Implied volatility is not directly observable; it is model-derived (Level 2 under ASC 820).
Rho (): sensitivity to interest rates. Typically small for short-dated options but significant for long-dated positions (LEAPS, multi-year swaps).
The following table shows a small options book. All positions are on the same underlying, currently trading at $100.
| Position | Qty | Delta | Gamma | Theta/day | Vega |
|---|---|---|---|---|---|
| 105-strike call | +20 | +0.42 | +0.031 | -0.045 | +0.18 |
| 95-strike put | +10 | -0.35 | +0.028 | -0.038 | +0.16 |
| 100-strike call | -15 | +0.55 | +0.034 | -0.052 | +0.20 |
| Portfolio | +1.65 | +0.39 | -1.06 | +2.20 | |
Reading the totals: the portfolio delta of +1.65 means you are net long, roughly equivalent to holding 165 shares. The portfolio gamma of +0.39 means your delta will increase by 0.39 for each $1 the underlying rises. Theta of -1.06 means you lose $106 per day (since each contract covers 100 shares) from time decay alone. Vega of +2.20 means a 1% vol increase adds $220 to the portfolio.
The portfolio totals are computed as: where is the signed quantity of position (positive for long, negative for short). The same applies to gamma, theta, and vega. For the short 100-strike call, the contribution is to delta, to gamma, and so on.
Now suppose the following happens overnight: the underlying rises $1.50, implied volatility drops 0.5 percentage points, and one day passes.
| Greek | Market Move | Formula | P&L Contribution |
|---|---|---|---|
| Delta | |||
| Gamma | |||
| Theta | day | ||
| Vega | |||
| Total explained (per share) | +0.754 | ||
| Total explained (x100 shares/contract) | +$75.40 | ||
The residual (actual P&L minus explained P&L) should be small. If it is large, either the Greeks are stale, the model is wrong, or there is a booking error. This attribution table is the quant equivalent of a variance analysis report.
Formal Treatment: Taylor expansion and the Greeks
Let be the value of a portfolio as a function of the underlying price , implied volatility , time , and risk-free rate . For small changes , the second-order Taylor expansion gives
The residual from truncating the Taylor series is (cross-gamma terms). For most liquid options with daily rebalancing, these higher-order terms are small.
Under geometric Brownian motion assumptions, the European call option value has closed-form Greeks. With and denoting the standard normal CDF:
where is the standard normal PDF. These are exact under Black-Scholes, not approximations.
Verify It Yourself: P&L attribution from Greeks
import numpy as np
# Portfolio Greeks (per-share units)
delta_port = 1.65
gamma_port = 0.39
theta_port = -1.06
vega_port = 2.20
# Market moves
dS = 1.50 # underlying up $1.50
dsig = -0.5 # implied vol down 0.5 percentage points
dt = 1 # one day
# P&L attribution (per share)
pnl_delta = delta_port * dS
pnl_gamma = 0.5 * gamma_port * dS**2
pnl_theta = theta_port * dt
pnl_vega = vega_port * dsig
pnl_total = pnl_delta + pnl_gamma + pnl_theta + pnl_vega
print("P&L Attribution (per share)")
print(f" Delta: {pnl_delta:+.3f}")
print(f" Gamma: {pnl_gamma:+.3f}")
print(f" Theta: {pnl_theta:+.3f}")
print(f" Vega: {pnl_vega:+.3f}")
print(f" Total: {pnl_total:+.3f}")
print(f"\nTotal (x100 shares/contract): $" + f"{pnl_total * 100:+.2f}")Greek P&L attribution
4. P&L Attribution and Decomposition
In cost accounting, variance analysis breaks the difference between budgeted and actual costs into components: price variance, volume variance, efficiency variance. Each component isolates one driver of the total difference. P&L attribution in quantitative finance does exactly the same thing, but for a portfolio of derivatives.
The idea is straightforward. If you hold an option and the market moves, your profit or loss comes from several sources: the underlying stock moved (delta and gamma effects), time passed (theta), and implied volatility changed (vega). P&L attribution decomposes the total change in option value into these components, just as a CPA decomposes a manufacturing cost variance into its constituent parts.
Consider an at-the-money call option with strike , 30 days to expiry, and risk-free rate . We track it over five trading days as the stock price and implied volatility change:
| Day | Stock | IV | Option Value | Delta P&L | Gamma P&L | Theta P&L | Vega P&L | Explained | Actual | Residual |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 100.00 | 0.250 | 3.063 | |||||||
| 1 | 101.50 | 0.245 | 3.821 | 0.806 | 0.062 | -0.054 | -0.057 | 0.757 | 0.758 | 0.001 |
| 2 | 99.00 | 0.260 | 2.554 | -1.552 | 0.170 | -0.054 | 0.163 | -1.273 | -1.266 | 0.007 |
| 3 | 101.00 | 0.255 | 3.513 | 0.960 | 0.112 | -0.057 | -0.055 | 0.960 | 0.959 | -0.001 |
| 4 | 102.50 | 0.240 | 4.257 | 0.887 | 0.062 | -0.058 | -0.160 | 0.732 | 0.744 | 0.013 |
| 5 | 100.50 | 0.250 | 3.054 | -1.364 | 0.109 | -0.054 | 0.098 | -1.212 | -1.203 | 0.009 |
Each row's attribution works as follows. Delta P&L equals yesterday's delta times today's stock move. Gamma P&L equals half of yesterday's gamma times the square of the stock move (the second-order correction). Theta P&L is yesterday's theta (one day of time decay). Vega P&L equals yesterday's vega times the change in implied volatility. The "Explained" column sums these four components; the "Actual" column is the true change in Black-Scholes value; the "Residual" is the difference.
Notice how small the residuals are, typically under one cent. This is because the Taylor expansion through second order (delta, gamma, theta, vega) captures nearly all the variation. The residuals come from higher-order terms (the options analog of higher-order variances in cost accounting) and from the fact that we use discrete daily changes rather than infinitesimal ones.
For a CPA, the analogy is direct. Delta P&L corresponds to the "price variance" (how much did the underlying price move contribute?). Gamma P&L is the "convexity adjustment" (the nonlinear correction that has no direct analog in linear cost accounting, but is conceptually similar to volume-mix interactions). Theta is the "time cost" (analogous to period costs that accrue with the passage of time). Vega is the "volatility variance" (how much did the change in the market's uncertainty contribute?). Together, they form a complete variance analysis of the trading book's P&L.
Formal Treatment: the Black-Scholes PDE and theta-gamma linkage
Under Black-Scholes assumptions, the option value satisfies the PDE
For any portfolio of European options under Black-Scholes dynamics, theta and gamma are linked by [eq:bs-pde]. Rearranging:
In particular, for a delta-neutral portfolio () far from expiry, . Positive gamma (convexity that benefits from large moves) is "paid for" by negative theta (daily time decay).
Proof. This is the Black-Scholes PDE itself. Substitute the definitions , , into the PDE and rearrange.
This is a fundamental relationship in options trading. A CPA auditing a trading desk's P&L should expect theta and gamma to be approximately offsetting for delta-hedged books. If theta is large and negative but gamma is near zero (or vice versa), something is wrong with the model or the booking.
Verify It Yourself: 5-day P&L attribution
from math import log, sqrt, exp, erf
def phi_cdf(x):
return 0.5 * (1.0 + erf(x / sqrt(2.0)))
def phi_pdf(x):
return exp(-0.5 * x * x) / sqrt(2.0 * 3.141592653589793)
def bs_call(S, K, T, r, sigma):
if T <= 0:
return max(S - K, 0), (1.0 if S > K else 0.0), 0.0, 0.0, 0.0
d1 = (log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * sqrt(T))
d2 = d1 - sigma * sqrt(T)
price = S * phi_cdf(d1) - K * exp(-r * T) * phi_cdf(d2)
delta = phi_cdf(d1)
gamma = phi_pdf(d1) / (S * sigma * sqrt(T))
theta = (-(S * phi_pdf(d1) * sigma) / (2 * sqrt(T))
- r * K * exp(-r * T) * phi_cdf(d2)) / 365
vega = S * phi_pdf(d1) * sqrt(T) / 100
return price, delta, gamma, theta, vega
K, r = 100.0, 0.05
days_to_exp = 30
stocks = [100.00, 101.50, 99.00, 101.00, 102.50, 100.50]
ivs = [0.25, 0.245, 0.26, 0.255, 0.24, 0.25]
# Compute Greeks at each observation
results = []
for i in range(len(stocks)):
T = (days_to_exp - i) / 365.0
results.append(bs_call(stocks[i], K, T, r, ivs[i]))
header = (
f"{'Day':>3} {'Stock':>7} {'IV':>6} {'OptVal':>7}"
f" {'dPnL':>7} {'gPnL':>7} {'tPnL':>7} {'vPnL':>7}"
f" {'Expl':>7} {'Actual':>7} {'Resid':>7}"
)
print(header)
print("-" * len(header))
p0 = results[0][0]
print(f" 0 {stocks[0]:7.2f} {ivs[0]:6.3f} {p0:7.3f}"
+ " - - - -"
+ " - - -")
for i in range(1, len(results)):
S_prev = stocks[i - 1]
iv_prev = ivs[i - 1]
p_prev, d_prev, g_prev, t_prev, v_prev = results[i - 1]
S_curr = stocks[i]
iv_curr = ivs[i]
p_curr = results[i][0]
dS = S_curr - S_prev
dIV = iv_curr - iv_prev
delta_pnl = d_prev * dS
gamma_pnl = 0.5 * g_prev * dS * dS
theta_pnl = t_prev
vega_pnl = v_prev * (dIV * 100)
explained = delta_pnl + gamma_pnl + theta_pnl + vega_pnl
actual = p_curr - p_prev
residual = actual - explained
print(f" {i} {S_curr:7.2f} {iv_curr:6.3f} {p_curr:7.3f}"
f" {delta_pnl:+7.3f} {gamma_pnl:+7.3f} {theta_pnl:+7.3f}"
f" {vega_pnl:+7.3f} {explained:+7.3f} {actual:+7.3f}"
f" {residual:+7.3f}")
total_actual = results[-1][0] - results[0][0]
print(f"\n5-day total P&L: $" + f"{total_actual:+.3f} per share")5-day P&L attribution from scratch
5. The Sharpe Ratio and Performance Measurement
Traditional accounting measures of performance, such as return on investment (ROI) and return on equity (ROE), answer the question: how much did we earn relative to what we invested? These are perfectly sensible measures for a manufacturing firm or a retail chain, where the variance of returns is modest and largely under management's control. For a trading operation, they are insufficient. Two strategies might both earn 12% annually, but if one does so with monthly returns that vary by 0.1% while the other swings between +5% and -5%, they are not equivalent. The Sharpe ratio captures this distinction by measuring return per unit of risk.
The idea is straightforward. Take the mean return of a strategy, subtract the risk-free rate (what you could have earned with no risk, for example from Treasury bills), and divide by the standard deviation of returns. A higher Sharpe ratio means you are being better compensated for each unit of volatility you endure.
Consider two strategies tracked over twelve months. Strategy A is a market-neutral statistical arbitrage portfolio with small, consistent returns. Strategy B is a concentrated directional bet on technology stocks with much larger swings.
| Month | Strategy A (%) | Strategy B (%) |
|---|---|---|
| 1 | 1.0 | 3.2 |
| 2 | 1.1 | -1.5 |
| 3 | 0.9 | 2.8 |
| 4 | 1.0 | 0.3 |
| 5 | 1.2 | 4.1 |
| 6 | 0.8 | -5.0 |
| 7 | 1.1 | 3.5 |
| 8 | 1.0 | 2.0 |
| 9 | 0.9 | -2.1 |
| 10 | 1.0 | 5.2 |
| 11 | 1.1 | 1.8 |
| 12 | 0.9 | 3.7 |
Using a risk-free rate of 0.4% per month, the summary statistics are:
| Metric | Strategy A | Strategy B |
|---|---|---|
| Mean monthly return (%) | 1.0000 | 1.5000 |
| Std dev of monthly returns (%) | 0.1128 | 3.0130 |
| Excess mean return (%) | 0.6000 | 1.1000 |
| Sharpe ratio (monthly) | 5.3184 | 0.3651 |
| Sharpe ratio (annualised) | 18.4236 | 1.2647 |
Strategy B has a higher mean return (1.50% vs 1.00% per month), and a naive ROI comparison would favour it. But Strategy A earns its returns with remarkably little volatility. Its monthly Sharpe ratio of 5.32 dwarfs Strategy B's 0.37. From a risk-adjusted perspective, Strategy A is the far superior strategy.
Annualisation. Monthly Sharpe ratios are converted to annual figures by multiplying by . This scaling follows from the assumption that monthly returns are independent and identically distributed: if the mean scales by and the standard deviation scales by , their ratio scales by . In practice, returns exhibit serial correlation (trending or mean-reverting behaviour), which means the simple rule is an approximation. Andrew Lo's 2002 paper provides the correction for autocorrelated returns, which we cover in the formal treatment below.
Pitfalls for the practitioner. Several subtleties deserve attention:
- Frequency dependence. A strategy that looks attractive at monthly frequency may look mediocre at daily frequency, or vice versa, because the Sharpe ratio depends on the sampling interval.
- Non-normal returns. The Sharpe ratio treats upside and downside volatility symmetrically. A strategy with large positive skew (occasional big wins) is penalised the same as one with large negative skew (occasional big losses). The Sortino ratio, which uses only downside deviation, addresses this.
- Stale pricing. If a portfolio contains illiquid assets whose prices do not update frequently, the measured volatility will be artificially low, inflating the Sharpe ratio. This is a particular concern for funds holding private assets, thinly traded bonds, or real estate.
- Look-back bias. Computing the Sharpe ratio over a historical period that was selected because the strategy performed well guarantees a flattering number. Out-of-sample evaluation is essential.
Formal Treatment: the Sharpe ratio as a test statistic
Let be a sequence of portfolio returns and let be the risk-free rate (matched to the same period). Define the excess return . The (ex-post) Sharpe ratio is
Under the null hypothesis (the strategy earns no excess return), the Sharpe ratio satisfies
where is the standard one-sample t-statistic for testing whether the mean excess return is zero.
Proof. The one-sample t-statistic for testing is .
This relationship is fundamental. It says that the Sharpe ratio is not merely a performance summary; it is directly proportional to the statistical significance of the strategy's excess return. A monthly Sharpe of 0.37 over 12 months gives , which is not statistically significant at conventional levels. A monthly Sharpe of 5.32 over 12 months gives , which is overwhelmingly significant. This tells you something important: Strategy A's outperformance is almost certainly real, while Strategy B's could plausibly be noise.
If the excess returns are independent and identically distributed with mean and variance , then the annualised Sharpe ratio satisfies
where is the number of periods per year (e.g., for monthly, for daily).
Proof. Over IID periods, the annual excess return has mean and variance , hence standard deviation . The annualised Sharpe ratio is .
When returns exhibit serial correlation with autocorrelations , the correct annualisation replaces with
For a first-order autoregressive process with parameter , positive autocorrelation (, trending) inflates the naive annualised Sharpe, while negative autocorrelation (, mean-reverting) deflates it. Ignoring this correction is one of the most common errors in hedge fund performance reporting. See Lo, A. W. (2002), "The Statistics of Sharpe Ratios," Financial Analysts Journal, 58(4), 36--52.
Verify It Yourself: Sharpe ratio computation
import numpy as np
# Monthly returns (%) for two strategies
returns_a = np.array([1.0, 1.1, 0.9, 1.0, 1.2, 0.8, 1.1, 1.0, 0.9, 1.0, 1.1, 0.9])
returns_b = np.array([3.2, -1.5, 2.8, 0.3, 4.1, -5.0, 3.5, 2.0, -2.1, 5.2, 1.8, 3.7])
rf = 0.4 # risk-free rate per month (%)
for name, r in [("A", returns_a), ("B", returns_b)]:
mu = np.mean(r)
sigma = np.std(r, ddof=1)
excess = mu - rf
sharpe_m = excess / sigma
sharpe_a = sharpe_m * np.sqrt(12)
t_stat = sharpe_m * np.sqrt(len(r))
print(f"Strategy {name}")
print(f" Mean monthly return: {mu:.4f}%")
print(f" Std dev (sample): {sigma:.4f}%")
print(f" Excess mean return: {excess:.4f}%")
print(f" Sharpe ratio (monthly): {sharpe_m:.4f}")
print(f" Sharpe ratio (annual): {sharpe_a:.4f}")
print(f" t-statistic: {t_stat:.4f}")
print(f" Significant at 5%? {'Yes' if abs(t_stat) > 2.201 else 'No'}")
print(f" (critical value for T=12, two-sided 5%: 2.201)")
print()Sharpe ratio for both strategies
6. Risk Measures and Capital Adequacy
The Sharpe ratio tells you how well a strategy compensates you for risk. It does not tell you how much money you could lose on a bad day. That is the job of a risk measure: a function that takes a portfolio's return distribution and produces a single number summarising the potential for loss. Regulators, risk committees, and auditors all rely on risk measures to answer a concrete question: how much capital must we hold in reserve to survive adverse scenarios?
The two dominant risk measures in practice are Value at Risk (VaR) and Expected Shortfall (ES), also called Conditional VaR or CVaR. Both start from the same data: a distribution (or sample) of portfolio returns, sorted from worst to best.
Suppose we observe 20 daily returns (in percent), sorted:
| Rank | Return (%) | Notes |
|---|---|---|
| 1 | -4.20 | Worst return |
| 2 | -2.80 | 2nd worst |
| 3 | -1.90 | |
| 4 | -1.30 | |
| 5 | -0.70 | |
| 6 | -0.40 | |
| 7 | -0.20 | |
| 8 | 0.10 | |
| 9 | 0.30 | |
| 10 | 0.50 | |
| 11 | 0.60 | |
| 12 | 0.80 | |
| 13 | 1.00 | |
| 14 | 1.10 | |
| 15 | 1.30 | |
| 16 | 1.50 | |
| 17 | 1.70 | |
| 18 | 2.00 | |
| 19 | 2.40 | |
| 20 | 3.10 |
VaR at the 90% confidence level. "90% VaR" means we want the loss threshold such that only 10% of outcomes are worse. With 20 observations, 10% corresponds to the worst 2 returns. The VaR is the boundary of that tail: the 2nd-worst return, which is -2.80%. Interpretation: on 90% of days, we expect to lose no more than 2.80%.
ES at the 90% level. Expected Shortfall asks: given that we are in the worst 10%, how bad is it on average? We average the worst 2 returns: . ES is always at least as severe as VaR, because it accounts for the depth of the tail, not just its boundary.
VaR at the 95% level. Now only 5% of outcomes qualify, which is the single worst return out of 20. The 95% VaR is -4.20%.
ES at the 95% level. The average of the worst 1 return is simply -4.20%. With only one observation in the tail, ES equals VaR.
| Confidence Level | VaR | ES |
|---|---|---|
| 90% | -2.80% | -3.50% |
| 95% | -4.20% | -4.20% |
Connection to capital requirements. Under Basel III, banks must hold capital against market risk computed using Expected Shortfall at the 97.5% confidence level (replacing the pre-2016 VaR-based regime). The reason regulators moved from VaR to ES is a mathematical property called coherence, which we discuss in the formal treatment below. In short: VaR can tell you that diversification increases risk (a paradox), while ES never does.
For the accountant, the practical takeaway is this. When you see a trading desk report "1-day 95% VaR = $2.4M," it means the desk expects to lose more than $2.4M on only 5% of trading days. When the same desk reports "1-day 95% ES = $3.1M," it means that on the days when losses exceed $2.4M, the average loss is $3.1M. ES gives you information about the severity of tail events that VaR deliberately ignores.
Formal Treatment: VaR, ES, and coherence
Let be a loss random variable (positive values represent losses). The Value at Risk at confidence level is
Equivalently, is the left-continuous -quantile of the loss distribution.
The Expected Shortfall (also called Conditional Value at Risk) at confidence level is
When the loss distribution is continuous, this reduces to .
A risk measure is called coherent (Artzner, Delbaen, Eber, and Heath, 1999) if it satisfies the following four axioms for all random losses and constants , :
- Monotonicity. If almost surely, then .
- Translation invariance. .
- Positive homogeneity. .
- Sub-additivity. .
Sub-additivity is the diversification axiom: merging two portfolios should not increase risk. A risk measure that violates sub-additivity can penalise diversification.
There exist random losses and such that
Hence VaR is not a coherent risk measure.
Proof. Consider two independent digital options, each of which either loses 100 or loses 0:
and identically for , with and independent. Set .
For each individual option, , so . By the same argument, .
For the combined position , the distribution is:
We need . Since , the threshold does not suffice. But , so .
Therefore , and sub-additivity fails.
Expected Shortfall satisfies all four axioms and is therefore a coherent risk measure. The proof of sub-additivity for ES relies on the dual representation theorem (see Acerbi and Tasche, 2002). This is the fundamental reason Basel III replaced VaR with ES for market risk capital calculations.
Verify It Yourself: VaR, ES, and sub-additivity failure
import numpy as np
# Sorted daily returns (%)
returns = np.array([
-4.20, -2.80, -1.90, -1.30, -0.70, -0.40, -0.20, 0.10, 0.30, 0.50,
0.60, 0.80, 1.00, 1.10, 1.30, 1.50, 1.70, 2.00, 2.40, 3.10
])
n = len(returns)
# Convert returns to losses (positive = bad)
losses = -returns
# Sort losses descending (worst first)
losses_sorted = np.sort(losses)[::-1]
print("=== VaR and ES from the 20-return sample ===")
print()
for alpha, label in [(0.90, "90%"), (0.95, "95%")]:
tail_count = int(np.round(n * (1 - alpha)))
tail_losses = losses_sorted[:tail_count]
var = tail_losses[-1] # boundary of the tail
es = np.mean(tail_losses)
print(f"Confidence level: {label}")
print(f" Tail count (worst {int((1-alpha)*100)}%): {tail_count}")
print(f" Tail losses: {tail_losses}")
print(f" VaR = {var:.2f}% (return = {-var:.2f}%)")
print(f" ES = {es:.2f}% (return = {-es:.2f}%)")
print()
# === Sub-additivity failure ===
print("=== VaR sub-additivity failure (Monte Carlo) ===")
print()
rng = np.random.default_rng(42)
n_sim = 1_000_000
p_loss = 0.04
alpha = 0.95
# Two independent digital options: loss = 100 with prob 0.04, else 0
X = (rng.random(n_sim) < p_loss).astype(float) * 100
Y = (rng.random(n_sim) < p_loss).astype(float) * 100
Z = X + Y
var_X = np.percentile(X, alpha * 100)
var_Y = np.percentile(Y, alpha * 100)
var_Z = np.percentile(Z, alpha * 100)
print(f"P(loss) = {p_loss}, alpha = {alpha}")
print(f" VaR_{int(alpha*100)}%(X) = {var_X:.1f}")
print(f" VaR_{int(alpha*100)}%(Y) = {var_Y:.1f}")
print(f" VaR_{int(alpha*100)}%(X) + VaR_{int(alpha*100)}%(Y) = {var_X + var_Y:.1f}")
print(f" VaR_{int(alpha*100)}%(X+Y) = {var_Z:.1f}")
print()
if var_Z > var_X + var_Y:
print("Sub-additivity VIOLATED: VaR(X+Y) > VaR(X) + VaR(Y)")
print("Diversifying actually INCREASED measured risk under VaR.")
else:
print("Sub-additivity holds in this simulation.")
print()
print("Exact probabilities for Z = X + Y:")
p0 = (1 - p_loss)**2
p100 = 2 * p_loss * (1 - p_loss)
p200 = p_loss**2
print(f" P(Z=0) = {p0:.4f} (simulated: {np.mean(Z == 0):.4f})")
print(f" P(Z=100) = {p100:.4f} (simulated: {np.mean(Z == 100):.4f})")
print(f" P(Z=200) = {p200:.4f} (simulated: {np.mean(Z == 200):.4f})")
print(f" P(Z<=0) = {p0:.4f} < 0.95, so VaR_95%(Z) > 0")
print(f" P(Z<=100)= {p0 + p100:.4f} >= 0.95, so VaR_95%(Z) = 100")VaR, ES, and sub-additivity failure
7. Transaction Cost Analysis
Every trade you execute costs more than the commission line on your statement. The gap between the price you wanted and the price you got, summed across all the friction layers, is the domain of transaction cost analysis (TCA). For an accounting professional auditing a trading operation, TCA is the bridge between the portfolio manager's intended return and the realised return that appears on the books.
There are four cost layers in a typical equity trade:
- Exchange fees and commissions. The explicit charges billed by the broker and exchange. These appear directly on the trade confirmation. A common retail rate is $0.003 per share.
- Bid-ask spread. The market maker charges a toll for providing immediacy. If the mid-price is $50.10 and you buy at the ask of $50.11, you pay a half-spread of $0.01 per share. For 500 shares that is $5.00.
- Slippage. The price moves between your decision to trade and the moment your order fills. If you decided to buy at $50.10 but the fill arrives at $50.12, the slippage is $0.02 per share.
- Market impact. Your own order pushes the price. A 500-share order in a liquid large-cap stock has negligible impact, but the same order in a thinly traded small-cap can move the price by several cents. Empirically, impact scales with the square root of order size relative to average daily volume.
The sum of these four layers is the total cost of the trade. The following table shows a 10-trade sequence with all four cost components broken out.
| # | Side | Shares | Decision | Fill | Spread | Fee | Slippage | Impact | Total Cost |
|---|---|---|---|---|---|---|---|---|---|
| 1 | BUY | 500 | 50.10 | 50.12 | 5.00 | 1.50 | 5.00 | 5.00 | 16.50 |
| 2 | BUY | 300 | 50.15 | 50.17 | 3.00 | 0.90 | 3.00 | 3.00 | 9.90 |
| 3 | SELL | 200 | 50.30 | 50.27 | 2.00 | 0.60 | 3.00 | 3.00 | 8.60 |
| 4 | BUY | 400 | 50.05 | 50.08 | 4.00 | 1.20 | 6.00 | 6.00 | 17.20 |
| 5 | SELL | 600 | 50.40 | 50.36 | 6.00 | 1.80 | 12.00 | 12.00 | 31.80 |
| 6 | BUY | 250 | 50.20 | 50.22 | 2.50 | 0.75 | 2.50 | 2.50 | 8.25 |
| 7 | SELL | 350 | 50.35 | 50.32 | 3.50 | 1.05 | 5.25 | 5.25 | 15.05 |
| 8 | BUY | 150 | 50.18 | 50.20 | 1.50 | 0.45 | 1.50 | 1.50 | 4.95 |
| 9 | SELL | 300 | 50.28 | 50.25 | 3.00 | 0.90 | 4.50 | 4.50 | 12.90 |
| 10 | SELL | 150 | 50.32 | 50.29 | 1.50 | 0.45 | 2.25 | 2.25 | 6.45 |
| Totals | 32.00 | 9.60 | 45.00 | 45.00 | 131.60 | ||||
The total friction across these 10 trades is $131.60. Of that, only $9.60 (about 7.3%) comes from explicit fees. The remaining 92.7% is implicit cost: spread, slippage, and market impact. This is why TCA matters for auditing. If you only look at the commission line, you miss 93% of the execution cost.
Implementation shortfall is the standard single-number summary of execution quality. It measures the difference between the paper return (what you would have earned if every trade filled at the decision price with zero friction) and the actual return. For a single trade, the shortfall is simply the signed price difference times the number of shares: you decided to buy 500 shares at $50.10 but filled at $50.12, so the shortfall is 500 x ($50.12 - $50.10) = $10.00. For sells, the sign flips: filling below the decision price is a cost. Summing across all trades gives the total implementation shortfall for the programme.
Formal Treatment: implementation shortfall and market impact
Definition (Implementation Shortfall). Given a programme of trades, let denote the sign of trade (+1 for buys, -1 for sells), the number of shares, the fill price, and the decision price. The implementation shortfall is
A positive value means the execution was more costly than the paper portfolio. Each term captures the per-trade shortfall: for a buy (), filling above the decision price is positive cost; for a sell (), filling below the decision price is also positive cost.
Remark (Square-Root Market Impact). Empirical studies (Almgren, Chriss, and others) consistently find that the temporary price impact of an order scales as the square root of its size relative to average daily volume:
where is the daily volatility, is the order size in shares, and is the average daily volume. This sub-linear scaling means that doubling your order size does not double your impact; it increases it by a factor of . The square-root law has important implications for optimal execution: it favours splitting large orders into smaller child orders, which is the basis of VWAP and TWAP algorithms.
Verify It Yourself: transaction cost analysis
import numpy as np
# Trade data: [side (+1=buy, -1=sell), shares, decision_price, fill_price]
trades = np.array([
[+1, 500, 50.10, 50.12],
[+1, 300, 50.15, 50.17],
[-1, 200, 50.30, 50.27],
[+1, 400, 50.05, 50.08],
[-1, 600, 50.40, 50.36],
[+1, 250, 50.20, 50.22],
[-1, 350, 50.35, 50.32],
[+1, 150, 50.18, 50.20],
[-1, 300, 50.28, 50.25],
[-1, 150, 50.32, 50.29],
])
sides = trades[:, 0]
shares = trades[:, 1]
decision = trades[:, 2]
fill = trades[:, 3]
# Cost components
half_spread = 0.01 # half-spread per share
fee_per_share = 0.003
spread_cost = shares * half_spread
fee_cost = shares * fee_per_share
slippage = shares * np.abs(fill - decision)
# Implementation shortfall per trade: epsilon * q * (fill - decision)
is_per_trade = sides * shares * (fill - decision)
print("Transaction Cost Analysis")
print("=" * 75)
print(f"{'#':>2} {'Side':<4} {'Shares':>6} {'Decision':>8} {'Fill':>8} "
f"{'Spread':>7} {'Fee':>6} {'Slip':>7} {'IS':>8}")
print("-" * 75)
for i in range(len(trades)):
side_str = "BUY" if sides[i] > 0 else "SELL"
print(f"{i+1:>2} {side_str:<4} {shares[i]:>6.0f} "
+ "$" + f"{decision[i]:.2f}" + " "
+ "$" + f"{fill[i]:.2f}" + " "
+ "$" + f"{spread_cost[i]:.2f}" + " "
+ "$" + f"{fee_cost[i]:.2f}" + " "
+ "$" + f"{slippage[i]:.2f}" + " "
+ "$" + f"{is_per_trade[i]:+.2f}")
print("-" * 75)
total_spread = spread_cost.sum()
total_fee = fee_cost.sum()
total_slippage = slippage.sum()
total_is = is_per_trade.sum()
print(f"{'Totals':>38}"
+ "$" + f"{total_spread:.2f}" + " "
+ "$" + f"{total_fee:.2f}" + " "
+ "$" + f"{total_slippage:.2f}" + " "
+ "$" + f"{total_is:+.2f}")
print()
print("Summary")
print(f" Total spread cost: " + "$" + f"{total_spread:.2f}")
print(f" Total fees: " + "$" + f"{total_fee:.2f}")
print(f" Total slippage: " + "$" + f"{total_slippage:.2f}")
print(f" Implementation shortfall: " + "$" + f"{total_is:.2f}")
print(f" Fees as pct of total: {total_fee / (total_spread + total_fee + total_slippage) * 100:.1f}%")
print()
print("Note: implementation shortfall is the signed sum.")
print("Positive IS = execution was more costly than paper portfolio.")Transaction cost analysis from trade data
8. Settlement, Reconciliation, and Position Keeping
A trade is not finished when it executes. Between execution and final settlement, the trade exists in a liminal state: you have economic exposure but the cash and securities have not yet changed hands. The gap between trade date and settlement date is one of the oldest sources of accounting complexity in finance, and it matters more than ever in a world where equities settle T+1, futures settle same-day through margin, and crypto settles on-chain within minutes.
Trade-date vs settlement-date accounting. Under trade-date accounting, you record the position the moment the trade executes. Under settlement-date accounting, you record it only when the cash and securities physically move. US GAAP (ASC 480) and most broker systems use trade-date accounting for trading books: your risk starts when you click "buy," not when the shares land in your account. But your cash ledger may not reflect the outflow until settlement day. This creates a temporary mismatch between your position ledger and your cash ledger, which is normal and expected.
Settlement conventions by asset class. US equities moved to T+1 settlement in May 2024, meaning a trade executed on Monday settles on Tuesday. Before that, equities settled T+2. Futures are fundamentally different: there is no delivery of securities at trade time. Instead, the exchange collects initial margin and then marks positions to market daily, moving variation margin in cash each evening. The "settlement" is continuous. Crypto is different again: on-chain transactions settle as soon as the block is confirmed, which for Bitcoin is roughly 10 minutes and for Ethereum roughly 12 seconds. In practice, exchanges treat crypto as T+0.
Consider a week of trading across asset classes:
| Trade Date | Instrument | Side | Qty | Price | Type | Settlement | Cash Moves |
|---|---|---|---|---|---|---|---|
| Mon | BTC/USD | BUY | 0.5 | 65,000 | Crypto | Mon (on-chain) | Mon |
| Mon | AAPL | BUY | 100 | 178.50 | Equity | Tue (T+1) | Tue |
| Tue | ES (Jun) | SELL | 2 | 5,250 | Futures | Tue (margin) | Tue |
| Wed | AAPL | SELL | 50 | 180.00 | Equity | Thu (T+1) | Thu |
| Thu | ETH/USD | BUY | 5.0 | 3,200 | Crypto | Thu (on-chain) | Thu |
On Tuesday evening, your trade-date position ledger shows all five instruments (assuming Thursday's trades have not happened yet). But your cash ledger only reflects the BTC purchase (settled Monday), the AAPL cash outflow (settled Tuesday), and the ES margin movement (settled Tuesday). The Wednesday AAPL sale and the Thursday ETH purchase have not yet settled. This is the unsettled position: the gap between what you own on paper and what your custodian confirms.
Reconciliation. At the end of each day, a trading firm reconciles its internal records against external records from brokers, exchanges, custodians, and clearinghouses. The goal is to confirm that every trade the firm believes it executed actually occurred, at the correct price and quantity, and that positions and cash balances match. When they do not match, the difference is called a break.
Common break types include:
- Quantity mismatch: the firm's records show 100 shares bought, but the broker confirms 99. This can happen due to partial fills that were not fully reported.
- Price mismatch: the firm recorded a fill at 178.50, but the broker shows 178.52. Even two cents matters at scale.
- Missing trade: the firm has no record of a trade that the broker reports, or vice versa. This can indicate a booking error or a communication failure.
- Duplicate entry: the same trade appears twice in the firm's system, doubling the apparent position. This is common when retry logic in automated systems fires twice.
Breaks must be investigated and resolved, typically within one business day. Unresolved breaks accumulate and can mask serious problems: a missing trade today might be a rogue position tomorrow.
Formal Treatment: trade events and the position function
A trade event is a tuple where is the instrument identifier, is the trade timestamp, is the side (+1 for buy, -1 for sell), is the unsigned quantity, and is the execution price. A trade log is a finite sequence ordered by timestamp.
Given a trade log , the position in instrument at time is
This is a right-continuous step function: jumps at each trade timestamp and is constant between trades.
The position function [eq:position-fn] is an instance of event sourcing: the current state (position) is fully determined by replaying the ordered sequence of events (trades). This has a practical consequence for reconciliation. If two systems disagree on the current position, you can diff their event logs to find exactly which trade event is missing or incorrect. The position function is the fold (left reduction) of the trade log over the addition operator. Any corruption or omission in the log will propagate to all subsequent states.
Verify It Yourself: settled vs unsettled positions
import numpy as np
# Trade log: (day_index, instrument, side, qty, price, type, settle_day_index)
# Days: Mon=0, Tue=1, Wed=2, Thu=3, Fri=4
instruments = ["BTC/USD", "AAPL", "ES (Jun)", "ETH/USD"]
# (trade_day, instrument_idx, side, qty, settle_day)
trades = [
(0, 0, +1, 0.5, 0), # Mon: BUY 0.5 BTC, settles Mon
(0, 1, +1, 100, 1), # Mon: BUY 100 AAPL, settles Tue
(1, 2, -1, 2, 1), # Tue: SELL 2 ES, settles Tue
(2, 1, -1, 50, 3), # Wed: SELL 50 AAPL, settles Thu
(3, 3, +1, 5.0, 3), # Thu: BUY 5 ETH, settles Thu
]
day_names = ["Mon", "Tue", "Wed", "Thu", "Fri"]
print("Position report: trade-date vs settled positions")
print("=" * 65)
for d in range(5):
print(f"\nEnd of {day_names[d]}:")
print(f" {'Instrument':<12} {'Trade-date':>12} {'Settled':>12} {'Unsettled':>12}")
print(f" {'-'*12} {'-'*12} {'-'*12} {'-'*12}")
for idx, name in enumerate(instruments):
# Trade-date position: all trades with trade_day <= d
td_pos = sum(
side * qty
for (trade_day, inst, side, qty, settle_day) in trades
if inst == idx and trade_day <= d
)
# Settled position: all trades with settle_day <= d
st_pos = sum(
side * qty
for (trade_day, inst, side, qty, settle_day) in trades
if inst == idx and settle_day <= d
)
unsettled = td_pos - st_pos
if td_pos != 0 or st_pos != 0:
flag = " <-- BREAK" if unsettled != 0 else ""
print(f" {name:<12} {td_pos:>12.1f} {st_pos:>12.1f} {unsettled:>12.1f}{flag}")
print()
print("Rows marked with '<-- BREAK' have unsettled differences.")
print("These are expected from settlement lag, not errors.")
print()
# Demonstrate break detection for reconciliation
print("Break detection example")
print("=" * 65)
# Firm's log vs broker's log for AAPL
firm_aapl = [(0, +1, 100, 178.50), (2, -1, 50, 180.00)]
broker_aapl = [(0, +1, 99, 178.52), (2, -1, 50, 180.00)]
break_types = []
for i in range(len(firm_aapl)):
f_day, f_side, f_qty, f_price = firm_aapl[i]
b_day, b_side, b_qty, b_price = broker_aapl[i]
day_label = day_names[f_day]
if f_qty != b_qty:
msg = (f" {day_label}: QTY MISMATCH - firm=" + str(f_qty)
+ " broker=" + str(b_qty)
+ " diff=" + str(f_qty - b_qty))
break_types.append(msg)
if abs(f_price - b_price) > 0.001:
msg = (f" {day_label}: PRICE MISMATCH - firm="
+ "$" + f"{f_price:.2f}"
+ " broker=" + "$" + f"{b_price:.2f}"
+ " diff=" + "$" + f"{abs(f_price - b_price):.2f}")
break_types.append(msg)
if break_types:
print("AAPL reconciliation breaks found:")
for b in break_types:
print(b)
else:
print("No breaks found.")Settled vs unsettled positions and break detection
9. Tax Lot Accounting at Scale
Every time you buy shares, you create a tax lot: a record of the quantity, price, and timestamp of that purchase. When you sell, you must decide which lot you are selling from. The choice determines your cost basis, your realised gain or loss, and therefore your tax liability. For a retail investor with a handful of trades, this is bookkeeping. For an algorithmic trading firm executing thousands of trades per day across hundreds of instruments, it becomes a computational problem.
Cost basis methods. The three primary methods are:
- FIFO (First In, First Out): sell the oldest lots first. This is the default method assumed by the IRS if you do not specify otherwise.
- LIFO (Last In, First Out): sell the newest lots first. In a rising market, LIFO produces higher cost bases and therefore lower taxable gains.
- Specific Identification: choose exactly which lots to sell, trade by trade. This gives maximum flexibility for tax optimisation but requires meticulous record-keeping and broker support.
Consider a trader building and unwinding a position over three days. The trade sequence is:
| # | Time | Side | Qty | Price | Running Position |
|---|---|---|---|---|---|
| 1 | Mon 09:31 | BUY | 200 | 48.00 | 200 |
| 2 | Mon 10:15 | BUY | 300 | 49.50 | 500 |
| 3 | Mon 14:00 | BUY | 100 | 51.00 | 600 |
| 4 | Tue 09:45 | SELL | 400 | 52.00 | 200 |
| 5 | Tue 11:30 | BUY | 150 | 50.00 | 350 |
| 6 | Wed 10:00 | SELL | 200 | 53.00 | 150 |
When Trade #4 executes (sell 400 shares at $52.00), the method you choose determines the gain:
| Method | Lot Consumed | Qty Sold | Cost | Proceeds | Gain |
|---|---|---|---|---|---|
| FIFO | Lot 1 @ $48.00 | 200 | $9,600 | $10,400 | $800 |
| Lot 2 @ $49.50 | 200 | $9,900 | $10,400 | $500 | |
| FIFO Total | $1,300 | ||||
| Specific ID | Lot 3 @ $51.00 | 100 | $5,100 | $5,200 | $100 |
| Lot 2 @ $49.50 | 300 | $14,850 | $15,600 | $750 | |
| Specific ID Total | $850 | ||||
By choosing the highest-cost lots first, Specific Identification produces a gain of $850 instead of $1,300. The tax savings at a 35% marginal rate would be 0.35 x ($1,300 - $850) = $157.50 on this single trade. Across thousands of trades per year, the cumulative effect is substantial.
The wash sale rule. The IRS wash sale rule (Section 1091) prevents you from realising a tax loss and then immediately repurchasing a "substantially identical" security. If you sell at a loss and buy back within 30 calendar days before or after the sale, the loss is disallowed and instead added to the cost basis of the replacement lot.
Consider this sequence:
| # | Date | Side | Qty | Price | Event |
|---|---|---|---|---|---|
| 1 | Mar 1 | BUY | 100 | $50.00 | Open position |
| 2 | Mar 10 | SELL | 100 | $47.00 | Realise loss of -$300 |
| 3 | Mar 15 | BUY | 100 | $48.00 | Repurchase within 30 days |
Trade #2 produces a loss of 100 x ($47.00 - $50.00) = -$300. However, Trade #3 is a repurchase of the same security within 30 days. The wash sale rule disallows the $300 loss. Instead, the loss is added to the cost basis of the new lot: the new basis becomes $48.00 + $3.00 = $51.00 per share. The loss is not eliminated, only deferred until the new lot is eventually sold.
For algorithmic traders, wash sales are not occasional inconveniences. They are a near-certainty. Any strategy that trades the same instrument more than once a month will trigger wash sales routinely. Automated wash sale detection and basis adjustment must be built into the accounting pipeline.
Formal Treatment: tax lot priority queues and wash sale detection
A tax lot is a tuple where is the acquisition timestamp, is the remaining quantity, and is the per-unit cost basis. The set of open lots for instrument at time is .
A cost basis method is a priority function that determines the order in which lots are consumed during a sale:
A sell order of quantity at price is matched by extracting lots from the priority queue in order of until , splitting the last lot if necessary.
Let be the timestamp of a sale that realises a loss, and let be the timestamp of a purchase of a substantially identical security. A wash sale occurs when
If the sale realises a loss and a wash sale is detected with a replacement lot , then the loss is disallowed and the replacement lot's basis is adjusted to
where is the quantity sold at a loss.
Verify It Yourself: FIFO vs Specific ID gains and wash sale adjustment
import numpy as np
# Trade sequence: (time_label, side, qty, price)
trades = [
("Mon 09:31", "BUY", 200, 48.00),
("Mon 10:15", "BUY", 300, 49.50),
("Mon 14:00", "BUY", 100, 51.00),
("Tue 09:45", "SELL", 400, 52.00),
("Tue 11:30", "BUY", 150, 50.00),
("Wed 10:00", "SELL", 200, 53.00),
]
# ---- FIFO for Trade #4 (sell 400 @ $52) ----
# Lots available before Trade #4: lot1(200@48), lot2(300@49.5), lot3(100@51)
lots_fifo = [(200, 48.00), (300, 49.50), (100, 51.00)]
sell_qty = 400
sell_price = 52.00
print("FIFO: Trade #4 (sell 400 @ " + "$" + "52.00)")
print("=" * 55)
fifo_gain = 0.0
remaining = sell_qty
for i, (q, c) in enumerate(lots_fifo):
if remaining <= 0:
break
used = min(q, remaining)
gain = used * (sell_price - c)
fifo_gain += gain
print(f" Lot {i+1}: sell {used} from {q} @ " + "$"
+ f"{c:.2f} gain = " + "$" + f"{gain:.0f}")
remaining -= used
print(f" Total FIFO gain: " + "$" + f"{fifo_gain:,.0f}")
# ---- Specific ID for Trade #4 (highest cost first) ----
print()
print("Specific ID: Trade #4 (sell 400 @ " + "$" + "52.00)")
print("=" * 55)
lots_spec = sorted(lots_fifo, key=lambda x: -x[1]) # highest cost first
spec_gain = 0.0
remaining = sell_qty
for i, (q, c) in enumerate(lots_spec):
if remaining <= 0:
break
used = min(q, remaining)
gain = used * (sell_price - c)
spec_gain += gain
orig_idx = lots_fifo.index((q, c)) + 1
print(f" Lot {orig_idx}: sell {used} from {q} @ " + "$"
+ f"{c:.2f} gain = " + "$" + f"{gain:.0f}")
remaining -= used
print(f" Total Specific ID gain: " + "$" + f"{spec_gain:,.0f}")
savings = fifo_gain - spec_gain
tax_rate = 0.35
tax_saved = tax_rate * savings
print()
print(f"Tax savings at 35%: " + "$" + f"{tax_saved:,.2f}")
# ---- Wash sale example ----
print()
print("Wash Sale Example")
print("=" * 55)
buy_price_1 = 50.00
sell_price_ws = 47.00
buy_price_2 = 48.00
qty_ws = 100
loss = qty_ws * (sell_price_ws - buy_price_1)
print(f" Mar 1: BUY 100 @ " + "$" + f"{buy_price_1:.2f}")
print(f" Mar 10: SELL 100 @ " + "$" + f"{sell_price_ws:.2f}"
+ " loss = " + "$" + f"{loss:.0f}")
print(f" Mar 15: BUY 100 @ " + "$" + f"{buy_price_2:.2f}"
+ " (within 30 days)")
print()
days_apart = 5 # Mar 15 - Mar 10
print(f" Days between sale and repurchase: {days_apart}")
print(f" Wash sale triggered: {days_apart <= 30}")
print()
disallowed = abs(loss)
adj_basis = buy_price_2 + disallowed / qty_ws
print(f" Loss disallowed: " + "$" + f"{disallowed:.0f}")
print(f" Original new lot basis: " + "$" + f"{buy_price_2:.2f}")
print(f" Adjusted new lot basis: " + "$" + f"{adj_basis:.2f}"
+ " (" + "$" + f"{buy_price_2:.2f} + "
+ "$" + f"{disallowed/qty_ws:.2f} per share)")FIFO vs Specific ID gains and wash sale adjustment
References
- Hull, J.C. Options, Futures, and Other Derivatives, 11th ed. Pearson, 2022.
- Artzner, P., Delbaen, F., Eber, J.-M., and Heath, D. "Coherent Measures of Risk." Mathematical Finance 9(3): 203-228, 1999.
- Sharpe, W.F. "The Sharpe Ratio." Journal of Portfolio Management 21(1): 49-58, 1994.
- Lo, A.W. "The Statistics of Sharpe Ratios." Financial Analysts Journal 58(4): 36-52, 2002.
- Almgren, R. and Chriss, N. "Optimal Execution of Portfolio Transactions." Journal of Risk 3(2): 5-39, 2001.
- Bouchaud, J.-P., Farmer, J.D., and Lillo, F. "How Markets Slowly Digest Changes in Supply and Demand." In Handbook of Financial Markets: Dynamics and Evolution, 57-160, 2009.
- FASB ASC 820: Fair Value Measurement. Financial Accounting Standards Board.
- FASB ASC 815: Derivatives and Hedging. Financial Accounting Standards Board.
- IFRS 13: Fair Value Measurement. International Accounting Standards Board, 2011.
- IRS Publication 550: Investment Income and Expenses. Internal Revenue Service.
- Basel Committee on Banking Supervision. "Minimum Capital Requirements for Market Risk." Bank for International Settlements, 2016 (revised 2019).