Branch: Quant Developer (quant-dev, Quant-Code region) · Node QD2.03 · ~18 focused hours · Gate ≥ 85% (quiz + code pack)
QD2.02 taught you to answer "how much could I lose?" This module answers the question that comes right after the year is over: "was what I actually made worth anything?" Every tool in this module takes the same raw material (a return series) and asks it a progressively harder question. Sharpe asks whether the reward was worth the total wobble. Sortino refines the question to the wobble an investor actually dislikes. Drawdown asks what living through it felt like. Brinson and factor attribution ask where the return came from. And the deflated Sharpe ratio asks the hardest, least comfortable question of all: if you tried enough things, how high would the best of them look by pure chance, and does your number clear that bar, or is it just the bar?
That last question is the one this module is built around, because it is the one most reports skip. A backtest, a fund factsheet, an in-house "signal that works" all arrive the same way: a number, stated with total confidence, that looks like evidence of skill. Bailey and López de Prado's deflated Sharpe ratio is the arithmetic that tells you how much of that confidence survives once you admit how many things were tried before this one was chosen. You will implement it here, run it on a real search across fifteen genuine tickers from this program's own bundled dataset, and watch a Sharpe ratio of 2.65 (spectacular by any informal reading) fail to clear a 95% confidence bar the moment you honestly account for having searched fifteen candidates to find it.
Advertisement
The one-sentence version. Every number in this module is a summary statistic of a return series, and every summary statistic answers a narrower question than it appears to. Sharpe is not "is this good," it is "reward per unit of total wobble"; a raw Sharpe of 2.2 is not "this strategy works," it is "the best of however many things you tried, which is a different, and usually much smaller, number once you deflate it."
One promise for the hours ahead: by the end you will be able to take a return series (in code, on data from both an Indian and a US market context) and produce a complete, honest performance report: annualized return and risk, Sharpe and Sortino with the annualization and ddof you actually meant, the maximum drawdown and how long it took to recover, a Brinson-Fachler decomposition that reconciles to the last decimal, a factor-based decomposition of what part of the return was style versus stock-picking, and a deflated Sharpe ratio that tells you honestly whether any of it survives the fact that you looked.
Learning objectives
By the end you can:
- Compute period-over-period returns in code (simple and log) from a raw price series, know which one is additive across time and which across positions, and reconcile a chain-linked return series back to the total holding-period return exactly.
- Implement an annualized Sharpe ratio and Sortino ratio with an explicit, deliberately-chosen
ddofand annualization scaling (never a library default nobody typed) and explain in code terms why Sortino's downside-only deviation can rank two managers differently than Sharpe does. - Compute maximum drawdown, drawdown duration, and recovery time from a return series, and explain why a Sharpe ratio alone never answers "what did living through this actually feel like."
- Implement the Probabilistic Sharpe Ratio (PSR), adjusting for the sample's own skewness and kurtosis, and name the exact library-default trap (
scipy.stats.kurtosis'sfisher=True) that silently corrupts it if you do not catch it. - Implement the Bailey–López de Prado deflated Sharpe ratio (DSR) end to end: the expected maximum Sharpe ratio achievable by chance across
Nindependent trials, and the PSR of your observed Sharpe benchmarked against that bar, and use it to state, in one number, how much of an apparently "significant" Sharpe survives honest accounting for how many things were tried. - Implement a Brinson-Fachler attribution that decomposes active return into allocation, selection, and interaction, reconciling exactly to the active return on every input, the reconciliation identity as a runnable assertion, not a hope.
- Implement a factor-based attribution that decomposes a realized return into systematic (factor-exposure-driven) and specific (residual) components, given factor exposures and factor returns as inputs.
- Read the whole toolkit together and render an honest verdict: separate a genuinely repeatable edge from a number that only looks good because it was the best of many, in code, with a stated confidence level, never by eyeballing a single ratio.
- Dissect at least one India (₹) and one US ($) performance-analytics problem end to end, in code, verified against hidden tests that defeat a hardcoded answer.
- (Productivity objective: R10 duality.) Use an AI coding copilot to accelerate writing and cross-checking performance-analytics code, a second, independent implementation to diff against your own, a first draft of a report's prose, without ever letting it be the thing that decides whether a number is honest.
The duality, stated once (R10). Objectives 1–9 are the understanding objective the mastery gate rewards: you pass by writing, from memory, code that computes these statistics correctly and by explaining what each one does and does not tell you. Objective 10 is the productivity payoff you keep afterward. A copilot can draft the
numpy/scipyboilerplate for any of these functions in seconds, it cannot tell you that a 2.6 Sharpe ratio deserves suspicion before admiration, and it cannot know how many trials you actually ran to find it. That judgment is this module's entire content, and no tool sells it to you.
Prerequisites & connections
Builds on. QD2.02 (Risk Systems in Code) is this module's immediate predecessor and closest relative in spirit: it quantified how much you could lose (historical, parametric, and Monte Carlo VaR and ES, backtested with the Kupiec test) and this module quantifies how much of what you actually made was skill versus luck, continuing the exact same "a single lucky-looking number proves nothing without a distribution behind it" instinct. QD1.03 (Building a Backtesting Engine) already introduced the expected-maximum-Sharpe-of-N-trials machinery, in its own overfitting section, as a pass/fail gate wired into a backtesting engine, a mechanical check that a discovered strategy's Sharpe survives having been the best of a parameter search before the strategy is trusted at all. This module reuses that exact mathematics, unchanged, for a different job: a reporting statistic that belongs in the performance memo you hand to someone else, rather than a gate that blocks a strategy from proceeding. Re-derive nothing from QD1.03, the formula, the Euler–Mascheroni constant, the "per-observation, never annualized" warning are all identical; only the use changes, from research-integrity control to honest disclosure. QD1.02 (Signal & Factor Construction and Evaluation) owns building factors and testing whether they predict forward returns, the Information Coefficient, the Information Ratio of an IC series, decay, turnover, double-counting. This module's factor attribution assumes exposures and factor returns already exist (however QD1.02 built and validated them) and asks a different, later question: given what actually happened, how much of the realized return do those same factors explain? Confusing "does this factor predict" (QD1.02) with "how much of this return did factors explain" (here) is a category error worth naming once, in code, so it never happens twice. DA1.01 (NumPy) and DA1.05's scipy/statsmodels fluency are assumed without re-teaching; if array.std(ddof=1) or scipy.stats.norm.cdf are unfamiliar as code, not merely as concepts, that is the place to pause, not here.
The theory this module does not re-derive: go to AA1.03. AA1.03 (Performance Measurement, Attribution & GIPS, Finance-Plus) is the theory owner of everything this module implements: why Sharpe, Treynor, the Information Ratio, M², and Jensen's alpha are five different denominators over one numerator and what each assumes; the full derivation of Brinson-Hood-Beebower versus Brinson-Fachler and why subtracting the total benchmark return is the economically meaningful convention; Cariño-style multi-period linking; the seven properties of a valid benchmark; and the whole of GIPS. This module owns turning that theory into runnable, tested code, annualization scaling, the exact ddof and library-default traps that corrupt a formula silently, the reconciliation identity as an executable assertion, and two entries the theory module does not cover at all: the Sortino ratio and the deflated Sharpe ratio. Where AA1.03 derives why a measure means what it means, re-read it there; this module will point back to it by section number rather than repeat it. QM1.02 (Hypothesis Testing, Correlation & Regression) and QM1.03 (Time-Series, Monte Carlo & ML) own the statistical machinery underneath the deflated Sharpe ratio itself, the sampling distribution of a Sharpe-ratio estimator, and the general logic of a null hypothesis and a confidence level; this module uses scipy.stats.norm.cdf/.ppf as an already-derived tool, the way QD2.02 uses them for VaR, and cites the exact estimator-variance formula (Var(SR̂) ≈ (1 + SR̂²/2)/T under normality) as a known result, not something to prove from scratch here.
Feeds forward. QD2.04 (Reproducible Research, Deployment & the Quant Stack) takes every function this module builds and asks the next question: is it seeded, configured, and logged well enough that someone else can run it and get your exact numbers back? QD2.05, the region's capstone, requires a full performance-and-attribution section (including the deflated Sharpe ratio, computed honestly against the real number of variants tried, and an in-sample-versus-out-of-sample comparison) as a non-negotiable, separately-graded line item; the capstone's honesty scoring (a strategy that only works in-sample still passes if that is reported plainly; one that hides its overfitting fails) is this module's entire ninth learning objective, applied under pressure. Every function you write here is load-bearing there.
Core teaching content
1. The ground floor: returns in code
Every statistic in this module is a function of a return series, get the return convention wrong at the bottom and every ratio built on it inherits the error silently. Two conventions matter, and they are not interchangeable.
Simple return: r_t = P_t / P_{t-1} - 1. This is the return an investor actually experiences, and it is what you compound across time by multiplying growth factors: (1+r_1)(1+r_2)...(1+r_n) - 1 gives the true total holding-period return, and it is also what you weight-average across positions in a portfolio at a point in time (Σ w_i r_i is a real portfolio return; Σ w_i · log(1+r_i) is not).
Log (continuously compounded) return: r_t = ln(P_t / P_{t-1}). Log returns are additive across time (Σ log-returns = ln(P_n/P_0), the total log-return, with no compounding arithmetic needed) which is exactly why they show up constantly in statistical work (return series are closer to symmetric, and variances of sums are easy). The cost is that log returns are not additive across positions: the log-return of a two-stock equal-weighted portfolio is not the average of the two stocks' log-returns, because log of a sum is not the sum of logs.
The practical rule this module holds you to: use simple returns whenever you are aggregating across positions at a point in time (portfolio returns, attribution, anything with weights) and log returns whenever you are aggregating across time for a single series and want additive, symmetric-ish statistics to feed a model. Mixing them (log-returns into a Brinson decomposition, simple returns summed instead of chain-linked across a year) produces numbers that are wrong in a way no error message will ever flag. Exercise QD2.03-e01 grades returns_from_prices(prices, kind='simple'|'log') and its own hidden tests specifically check that a chain-linked simple-return series and a summed-then-exponentiated log-return series both reconcile exactly back to the direct total return computed from the first and last price, the same reconciliation discipline AA1.03 §12 demands of every attribution table, applied here to the return series itself, one level down.
Annualized Sharpe ratio
The annualized Sharpe ratio is the average return above the risk-free rate divided by the standard deviation of that excess return, then multiplied by the square root of the number of periods in a year. The usual trap is ddof, the divisor setting: by default numpy's standard deviation divides by the number of returns and pandas' by that number minus 1, so the same data gives two different Sharpe ratios. On a modest sample the gap is big enough to reorder a manager ranking.
3 min read, on its own page
Maximum drawdown
Maximum drawdown is the largest percentage fall in an account's value from a peak to the lowest point after it. Neither Sharpe nor Sortino tells you what a client remembers: how far under water the account went, and for how long. Maximum drawdown answers the first question; drawdown duration and recovery time answer the second. Recovery may never happen inside the data you have, which is worth reporting.
3 min read, on its own page
4. The Probabilistic Sharpe Ratio: is this Sharpe even reliable?
Every Sharpe ratio you have computed so far is a point estimate from a finite sample, it has a standard error, and that standard error depends not just on the sample length but on the shape of the return distribution. The Probabilistic Sharpe Ratio (Bailey & López de Prado, 2012) answers a sharper question than "what is the Sharpe ratio": given this sample, what is the probability that the TRUE Sharpe ratio exceeds some stated benchmark, often, but not only, zero?
PSR(SR*) = Φ( (SR̂ − SR*) · sqrt(T−1) / sqrt(1 − γ₃·SR̂ + ((γ₄−1)/4)·SR̂²) )
where SR̂ is the per-period, non-annualized Sharpe ratio, T is the number of return observations, γ₃ is the sample skewness, γ₄ is the sample kurtosis using the regular convention where a normal distribution has kurtosis 3 (not the excess-kurtosis convention where normal is 0), and Φ is the standard normal CDF. Substitute a normal distribution's own values (γ₃=0, γ₄=3) and the denominator collapses to sqrt(1 + SR̂²/2), the familiar large-sample standard error of a Sharpe-ratio estimator (Lo, 2002; Mertens, 2002), confirming the general formula is just that same result generalized to skewed, fat-tailed returns, which almost all real return series are.
The trap, named exactly once and worth remembering forever: scipy.stats.kurtosis() defaults to fisher=True, returning excess kurtosis (normal distribution = 0), while the PSR formula above needs the regular convention (normal = 3). Call scipy.stats.kurtosis(returns, fisher=False) explicitly, every time, or your denominator, and therefore your entire PSR, is silently wrong on any return series whose kurtosis is not exactly the library's zero-point. This is the exact same disease as AA1.03's ddof trap and CS2.06's whole unit on reviewing AI-generated code: a keyword nobody typed, producing a confidently wrong number with no error, no warning, nothing but a plausible-looking float where a correct one should be. Exercise QD2.03-e05 grades probabilistic_sharpe_ratio(returns, sr_benchmark, ddof), and its hidden tests include a deliberately fat-tailed (Student-t-generated) return series specifically chosen so that the fisher=True-versus-fisher=False distinction produces a materially different, checkable answer, a solution that used the library default fails, not "loses a few decimal places."
Deflated Sharpe ratio
The deflated Sharpe ratio (DSR) is the probability that a strategy's Sharpe ratio holds up once you allow for how many strategies you tried to find it. The best Sharpe you'd expect from N strategies with no skill rises every time N grows. A DSR near 1.0 says the observed Sharpe genuinely clears the bar that N trials of pure luck would set; a DSR near or below 0.5 says the observed Sharpe is unremarkable, or worse, once the search is honestly priced in.
5 min read, on its own page
6. Brinson-Fachler attribution, in code, reconciling exactly
AA1.03 §4–§5 derives the Brinson decomposition end to end, why it is an identity, why Brinson-Fachler's (w_P − w_B)(r_B − R_B) allocation term is the economically meaningful convention over the plain Brinson-Hood-Beebower (w_P − w_B)·r_B, and works a full five-sector reconciliation by hand. This module's job is narrower and purely mechanical: implement that exact identity in vectorized code, and make the reconciliation an executable assertion rather than an arithmetic hope.
For per-segment portfolio weights w_P, benchmark weights w_B, portfolio returns r_P, and benchmark returns r_B:
allocation_i = (w_P,i − w_B,i)(r_B,i − R_B), selection_i = w_B,i(r_P,i − r_B,i), interaction_i = (w_P,i − w_B,i)(r_P,i − r_B,i)
and the identity every implementation must satisfy on every input, with no residual and no rounding hand-wave: Σ(allocation_i + selection_i + interaction_i) = R_P − R_B, the active return, exactly. AA1.03 §12's verification discipline is not optional prose here, it is a runnable assert. Exercise QD2.03-e07's hidden tests reconcile the identity to 1e-9 on inputs you have never seen, and one hidden test uses a plain Brinson-Hood-Beebower implementation's specific per-segment allocation numbers as the thing to distinguish from, a solution that quietly uses r_B,i alone instead of (r_B,i − R_B) produces a different, wrong per-segment allocation array even though (as AA1.03 §5 notes) the grand total across all three terms is unaffected, which is exactly why checking only the total is not enough, and the hidden tests check the full per-segment array.
7. Factor-based attribution, in code
AA1.03 §6 names the factor-based model as the fourth attribution frame (decompose active (or total) return into exposures to priced factors, times those factors' realized returns, plus a residual) and hands off the vectorized build to this branch. QD1.02 built and evaluated the factors themselves (value, momentum, quality, low-volatility) as predictive signals, scored by their Information Coefficient and Information Ratio. This section asks a different, later question: given a set of factor exposures and the factor returns that period, how much of what actually happened does that combination explain, and how much is left over?
systematic\_return = Σ_k exposure_k × factor\_return_k, specific\_return = realized\_return − systematic\_return
The specific_return is not automatically "skill", it bundles genuine idiosyncratic stock-picking together with anything the chosen factor model simply fails to capture (an omitted factor, a nonlinearity, a regime the linear model does not fit). That caveat is not a technicality; it is the honest boundary of what this decomposition can tell you, and it is why QM1.02's regression-diagnostics discipline (heteroskedasticity, misspecification, the F-test on whether the whole regression is even meaningful) matters upstream of this exercise, on whatever process actually estimated the exposures and factor returns you feed in here, this exercise assumes those inputs already exist and are trustworthy; it does not re-validate them. Exercise QD2.03-e08 grades factor_attribution(exposures, factor_returns, realized_return) against real exposures and factor returns computed directly from the bundled synthetic factor panel (the same dataset QD1.02's IC/IR exercises use), with a hidden test confirming the degenerate case (zero factors supplied) correctly attributes the entire return to specific_return, nothing hidden, nothing silently dropped.
8. Reading the whole toolkit together: skill, or luck?
No single number in this module, alone, answers "was this skill." Read together, the toolkit does, the same way AA1.03 §8 teaches that five disagreeing risk-adjusted ratios are diagnostic, not contradictory, once you read the pattern rather than any one number in isolation. Four honest readings this module equips you to make, in code, not by eyeballing a headline ratio:
- A high Sharpe with a deflated Sharpe near or below 0.5. The raw number is what a large-enough search would produce from nothing. Ask how many variants were actually tried (including ones a colleague tried and discarded before handing you "the" strategy) before repeating the headline number to anyone.
- A high Sharpe with a genuinely high deflated Sharpe (comfortably above 0.95), a modest maximum drawdown, and a Sortino not wildly divorced from the Sharpe. This is the pattern that deserves real interest, internally consistent evidence across four independent checks, not one flattering number standing alone.
- A Brinson or factor attribution where nearly all of the active return is "allocation" or "systematic" (factor) return, with
interaction/specificnear zero. This is a process-consistency finding in AA1.03's sense (§"Common mistakes" item 2): it tells you what kind of edge is being claimed (a top-down or style-tilt story, not a stock-picking one) which is a more useful fact than the sign of the total. - Everything above looks clean, and the track record is short. A DSR computed from thirty observations and a DSR computed from three years of daily data are not the same kind of confidence, even at an identical numeric value:
Tsits directly inside every formula in this module, and a shortTinflates the standard error behind every one of them. State the track-record length next to every ratio you report; a ratio without itsTis a number without its error bar.
{
"skill": "Drafting a skill-vs-luck verdict paragraph from a full performance-analytics run",
"use": "After you have run every function in this pack yourself and have the actual numbers -- Sharpe, Sortino, max drawdown, PSR, DSR, Brinson and factor attribution -- have an assistant draft the prose verdict paragraph a client or investment committee would read, so you spend your own time on the judgment rather than the paragraph structure.",
"tools": ["Chat assistants -- Claude, ChatGPT, Gemini", "A spreadsheet or notebook holding the actual computed numbers to paste in"],
"prompt": "Here are the following computed performance statistics for a strategy: <PASTE YOUR ACTUAL NUMBERS -- Sharpe, Sortino, max drawdown + duration + recovery, PSR, DSR at the actual number of trials I ran, Brinson or factor attribution totals, and T>. Draft a plain-English, three-paragraph verdict for an investment committee: what happened, where the return came from, and whether the deflated Sharpe suggests this is distinguishable from a lucky draw among the alternatives I tried. Do not invent any number I have not given you.",
"verify": "Recompute every number in the draft against your own script's actual output before it leaves your hands -- an assistant asked to write a confident paragraph will write one whether or not every clause is still true of your specific numbers. Confirm it did not quietly round DSR up into 'statistically significant' language your actual number does not support, and that it stated the real n_trials and T you gave it rather than a generic-sounding round number. This is the highest AI-over-trust risk region in the whole quant-code branch for exactly the reason AA1.03 SS12 names for its own domain: a wrong verdict paragraph is fluent and looks exactly like a right one.",
"diy": "You must already be able to read the four patterns above yourself, from the raw numbers, before asking anything to draft prose around them -- otherwise you cannot tell a verdict that fits your numbers from one that merely sounds authoritative.",
"market": "US"
}
Common mistakes & how experts think differently
- Annualizing a Sharpe ratio before feeding it into the deflated Sharpe formula. Every quantity inside PSR/DSR (
SR̂,SR*,σ_SR) is per-observation. Annualizing first inflates the numerator by roughlysqrt(periods_per_year)relative to what the formula expects, corrupting the whole statistic silently. QD1.03 names this exact trap in identical language; it is worth naming twice because it is the single most common way this number is reported wrong in practice. - Using
scipy.stats.kurtosis()'s default (fisher=True, excess kurtosis) inside a PSR/DSR calculation. The formula's(γ₄−1)/4term is written for the regular convention where a normal distribution scores 3. Passfisher=Falseexplicitly. A model tested only on returns that happen to be close to normal will never catch this bug, it only bites on genuinely skewed or fat-tailed data, which is most real return series. - Letting
ddofdefault silently, anywhere.numpy's default isddof=0;pandas's isddof=1. Mixing calls from the two libraries inside one pipeline, or copying a snippet from one context into another, changes every downstream Sharpe, Sortino, and standard-deviation-based figure by a small but real amount, small enough to look like rounding, large enough to reorder a ranking. - Treating a raw, un-deflated Sharpe ratio as the final word. A 2.6 Sharpe found by trying fifteen things is not the same claim as a 2.6 Sharpe found by trying one thing, identical numbers, completely different evidentiary weight. The expert's reflex on seeing an impressive Sharpe is not admiration; it is "how many did you try, and what does the deflated version say."
- Confusing "the search was thorough" with "the search was informative." QD1.03's own grid-search example is the sharpest illustration: a thousand correlated parameter-grid cells behave like roughly seventy independent trials, not a thousand, treating a correlated grid as
N=1,000independent trials over-deflates; treating it asN=1(the untested status quo) under-deflates just as badly. The honest number sits in between, and estimating it is real, load-bearing work, not a rounding nicety. - Reporting Brinson-Hood-Beebower's per-segment numbers while claiming the Brinson-Fachler convention (or the reverse). AA1.03 §5 shows the two conventions' grand totals agree exactly while individual segment stories differ, a solution that silently uses the wrong per-segment formula will pass a total-only sanity check and still be wrong, which is exactly why this module's hidden tests check the full per-segment array, not merely the sum.
- Treating a factor attribution's
specific_returnas proof of stock-picking skill. It is a residual, genuine idiosyncratic return bundled indistinguishably with anything the chosen factor model fails to capture. A large, consistently positive specific return across many periods is a hypothesis about skill worth investigating further (with QD1.02's IC/IR discipline, applied prospectively), never a conclusion in itself. - Computing max drawdown but not duration or recovery, and reporting the magnitude alone. A −15% drawdown that recovers in six weeks and a −15% drawdown that never recovers within the track record you are examining are not the same fact for a client deciding whether they can live through the next one, report the shape, not just the depth.
- Assuming a return series that "looks fine" on a small sample has a reliable Sharpe ratio.
Tsits inside the standard error of every ratio in this module. A Sharpe ratio computed from thirty daily observations carries an enormous confidence interval around it, regardless of how clean the point estimate looks, stateTnext to every ratio, always. - Trusting a plausible-looking performance report (human-written or AI-drafted) without recomputing the headline numbers. This is AA1.03 §12's own warning, restated for code: a wrong Sharpe, wrong DSR, or non-reconciling attribution table has the right shape, plausible magnitude, and confident tone of a correct one. The defense is identical here as there: recompute, do not read.
The thread through all ten: performance analytics is where a fluent, plausible, wrong number is nearly indistinguishable from a correct one until you actually re-run the arithmetic, which is precisely why every function in this module's code pack ships hidden tests that recompute the expected answer from the same formula independently, rather than trusting a single hand-typed literal, and why your own verification habit should do exactly the same thing before any number here reaches someone else's decision.
Worked examples
Worked example 1: Returns in code: simple, log, and the reconciliation that must hold exactly (India, ₹)
An analyst is handed fifteen consecutive daily closing levels for a synthetic NSE-style large-cap name (illustrative/synthetic, from this program's bundled nse-eod dataset, ticker NILAM): 1129.08, 1126.63, 1128.91, 1139.87, 1111.92, 1115.09, 1092.86, 1072.12, 1066.89, 1069.60, 1075.14, 1109.72, 1087.60, 1100.01, 1085.58, and wants both the day-by-day simple returns and confirmation that they reconcile to the direct total return.
Solution. Step 1, the first and last simple returns, by formula. r_1 = 1126.63/1129.08 − 1 = −0.2170%; r_14 = 1085.58/1100.01 − 1 = −1.3118%. Fourteen such returns sit between the fifteen prices.
Step 2, the direct total return. 1085.58 / 1129.08 − 1 = −3.8527%, the answer to "what happened to one rupee invested on day one and held through day fifteen."
Step 3, the reconciliation. Chain-link the fourteen simple returns: (1+r_1)(1+r_2)...(1+r_14) − 1. Computed directly for this module, this equals −3.8527%, matching the direct total return to nine decimal places. Separately, compute log returns for the same series and sum them: Σ ln(P_t/P_{t-1}) = ln(1085.58/1129.08) = −0.039289...; exponentiate and subtract one, e^{−0.039289} − 1 = −3.8527%, the identical answer, arrived at through the additive-across-time route instead of the compounding one.
Read it. Both routes must land on the same number, to machine precision, that is Exercise QD2.03-e01's core hidden-test discipline, applied here by hand first. A solution that silently averages returns instead of chain-linking them (mean(r) × 14, which for fourteen returns is just their sum) returns −3.7656% against the true −3.8527%, an error of 0.0871 percentage points, or 2.26% of the answer. Notice how small that is, and treat the smallness as the warning rather than the reassurance: on a gentle series the shortcut is nearly indistinguishable from the correct answer, so nothing in the output tells you the code is wrong, and the bug ships. Feed the same function a series with real intra-period swings and it stops hiding. Take two periods, +50% then −50%: chain-linking gives 1.50 × 0.50 − 1 = −25.00%, which is what actually happened to the money, while summing gives 0.50 − 0.50 = 0.00%, a full 25 points of manufactured performance. The arithmetic mean of returns is not a return, and the only series on which it looks like one are the quiet ones.
Worked example 2: Sharpe and Sortino, and why they can disagree (US, $)
A learner has 252 real daily closing prices for a synthetic US-style name from the bundled us-eod dataset (ticker CLRRDG, illustrative/synthetic) and wants both ratios, annualized, with rf = 4.0% (an illustrative US risk-free rate) and ddof=1.
Solution. Step 1, the per-period figures, computed directly for this module. 251 daily simple returns give an annualized mean return of +17.56% and an annualized standard deviation of 28.98% (ddof=1).
Step 2: Sharpe. (0.1756 − 0.04)/0.2898 × sqrt(252)/sqrt(252), worked through the per-period formula directly (subtract 0.04/252 from every daily return, take the mean over the ddof=1 standard deviation, multiply by sqrt(252)) gives Sharpe = 0.468.
Step 3: Sortino, same series, MAR=0. Only the days with negative excess return contribute to the downside deviation. Computed directly for this module: downside deviation is materially smaller than the total standard deviation (the series has more, and larger, up-days than down-days over this window), and the resulting annualized Sortino = 0.920, nearly double the Sharpe figure.
Read it. The gap is the whole lesson: this series' volatility skews toward the upside (this is exactly the "shrink a downside move, Sortino must rise" property Exercise QD2.03-e03's hidden test checks directly), so charging it for total wobble (Sharpe) understates how comfortable the ride actually was relative to charging it only for the wobble an investor dislikes (Sortino). Neither number is wrong; they answer AA1.03 §7's different questions, computed here in code rather than by hand, with an explicit, checked ddof at every step.
Worked example 3: Maximum drawdown, duration, and recovery, traced by hand (neutral, illustrative)
An eight-period illustrative return series, [0.02, −0.01, −0.05, 0.01, 0.10, −0.20, 0.03, 0.15], needs its full drawdown story before anyone asks "but what did it feel like to hold this."
Solution. Step 1: build the wealth index. Chain-linking gives approximately 1.020, 1.010, 0.959, 0.969, 1.066, 0.853, 0.878, 1.010 (rounded).
Step 2: running maximum and drawdown. The running maximum climbs 1.020, 1.020, 1.020, 1.020, 1.066, 1.066, 1.066, 1.066 (a new high at index 4, held thereafter within this window). The drawdown series, computed from the unrounded wealth index rather than from the three-decimal display above, is 0, −0.0100, −0.0595, −0.0501, 0, −0.2000, −0.1760, −0.0524. Recompute it from the rounded figures and the last value comes out near −0.0525 instead, which is the ordinary cost of feeding a display value back into a calculation.
Step 3, read off peak, trough, duration, recovery. The most negative drawdown, −20.00% exactly, sits at index 5, the trough. The last index at or before it with a zero drawdown is index 4, the peak. Drawdown duration is 5 − 4 = 1 period: this fall happened in a single, brutal step. Scanning forward from the trough, the drawdown never returns to zero within this eight-period window (it reaches only −5.24% by the last period), recovery time is undefined (None) within the data shown, a fact to report explicitly, not paper over.
Read it. This exact series, and a second illustrative series that does recover ([0.10, −0.20, 0.05, 0.05, 0.05, 0.05, 0.05], whose identical −20% trough at index 1 fully recovers by index 6, five periods later), are Exercise QD2.03-e04's two central hidden-test shapes, a solution that assumes every drawdown eventually recovers within whatever window it is given, without checking, silently mishandles exactly the first series here.
Worked example 4: The deflated Sharpe ratio: how many trials manufacture a "significant" Sharpe? (India ₹ + US $)
Fifteen fictional NSE-style tickers sit in this program's bundled nse-eod dataset. Scan all fifteen over the same 252-trading-day window and rank them by annualized Sharpe ratio, an entirely ordinary thing to do when hunting for "which of these looks best." The winner, ticker TARANG, posts an annualized Sharpe of +2.655, a genuinely eye-catching number by any informal reading, computed directly from real (synthetic, seeded) data, not invented for this example.
Solution. Step 1, the raw, un-deflated read. T = 252 daily returns; the per-period Sharpe is 0.1672. Plugged into the plain PSR formula (§4) at SR* = 0: PSR = 0.996, a 99.6% probability the true Sharpe exceeds zero. Read in isolation, this looks like about as strong a confirmation as a single number can offer.
Step 2, price in the fact that fifteen candidates were searched. Using sr_std = 1/sqrt(T−1) = 1/sqrt(251), the expected maximum Sharpe of 15 skill-less trials (§5's formula) is a per-period value of 0.1118, or +1.77 annualized, a substantial number, manufactured from nothing but the arithmetic of taking the best of fifteen noisy draws. Deflating TARANG's Sharpe against that bar instead of zero: DSR = 0.810. Still respectable, but a full 18.6 points below the naive 0.996, and short of the conventional 95% confidence bar.
Step 3, ask "what if the search had actually been bigger?" A researcher who quietly tuned parameters, tried both markets, or ran a modest grid easily searches on the order of a hundred variants without ever writing "I tried 100 things" anywhere. At N=100: DSR = 0.547, essentially a coin flip, the point at which "is this skill" stops being answerable from this evidence alone. At N=1,000, not an unrealistic count for an automated parameter sweep, and the exact scale QD1.03's own demonstration uses: DSR = 0.272: the observed Sharpe is now less than what a thousand-trial search of pure noise would be expected to produce at its best.
Read it. Nothing about TARANG's actual price path changed across these three lines, only the honestly-stated number of trials did, and the DSR fell from "looks decisive" to "unremarkable" to "worse than what luck alone would produce" without touching a single price. This is precisely QD1.03's own 1,000-noise-strategy demonstration (best in-sample Sharpe 2.23 against a true edge of exactly zero, DSR 0.746 at N=1,000 trials over T=755) arriving from the opposite direction: there, a known-zero edge was deflated down to an honest verdict; here, a real, searched-for Sharpe from genuine (synthetic) market data is deflated the identical way, and the lesson is the same, the question is never "is this number impressive," it is "how many did you try, and does it clear that bar." This is Exercise QD2.03-e06, and its third hidden test independently confirms the closed-form formula against a brute-force simulation of a thousand actual zero-skill trials, so you are never asked to trust the algebra without also seeing it happen.
Worked example 5: Brinson-Fachler attribution, reconciling to the last decimal (India, ₹)
A ₹-denominated portfolio is split across five illustrative sectors (IT, Financials, Energy, Consumer, Healthcare) with portfolio weights [30%, 20%, 10%, 25%, 15%] against benchmark weights [20%, 25%, 15%, 20%, 20%], and portfolio returns [12%, 4%, −6%, 9%, 5%] against benchmark returns [9%, 5%, −8%, 7%, 4%] (illustrative figures, same shape as AA1.03 §4's own worked table).
Solution. Step 1, the two totals. R_P = 0.30(12) + 0.20(4) + 0.10(−6) + 0.25(9) + 0.15(5) = 6.80%. R_B = 0.20(9) + 0.25(5) + 0.15(−8) + 0.20(7) + 0.20(4) = 4.05%. Active return = +2.75 points.
Step 2: Brinson-Fachler allocation, segment by segment. Energy's allocation (segment 3): (w_P − w_B)(r_B − R_B) = (0.10 − 0.15)(−8 − 4.05) = (−0.05)(−12.05) = +0.6025 points, the portfolio was under-weight a sector that badly under-performed the benchmark's own average, a genuinely good call, and Brinson-Fachler credits it correctly (the plain Brinson-Hood-Beebower version, (w_P−w_B)·r_B = (−0.05)(−8) = +0.40, understates exactly this call, for the reason AA1.03 §5 derives).
Step 3, full reconciliation, run as code, not just checked by eye. Summed across all five segments: allocation totals +1.20, selection totals +1.25, interaction totals +0.30. 1.20 + 1.25 + 0.30 = 2.75, the active return, exactly, to the last decimal, on the nose.
Read it. This is Exercise QD2.03-e07's exact fixture, and its hidden tests run the identical reconciliation on segment tables you have not seen, checking the full per-segment allocation array (not merely the grand total) against the true Brinson-Fachler formula, catching precisely the "used BHB, called it BF" mistake Common Mistake 6 names, which a total-only sanity check would miss entirely.
Worked example 6: Factor attribution: how much was style, not stock-picking? (US, $)
A cross-sectional regression of one month's forward returns on four style factors (value, momentum, quality, low-volatility) across sixty tickers in the bundled synthetic factor panel (the same dataset QD1.02's Information Coefficient exercises use) produces, for that period, estimated factor returns of value: +0.826%, momentum: +2.006%, quality: −0.063%, low_vol: +0.003% (computed directly via ordinary least squares for this module, the regression step itself, and its diagnostics, are QM1.02's territory, used here as an already-validated input). One US-style synthetic ticker in that panel had factor exposures value: 1.030, momentum: −1.034, quality: −0.479, low_vol: 1.898 and a realized forward return of +0.435% that period.
Solution. Step 1, the per-factor contribution. value: 1.030 × 0.00826 = +0.851%; momentum: −1.034 × 0.02006 = −2.075%; quality: −0.479 × (−0.00063) = +0.030%; low_vol: 1.898 × 0.00003 = +0.006%.
Step 2: sum to the systematic return. 0.851 − 2.075 + 0.030 + 0.006 = −1.188%.
Step 3, the residual. specific_return = realized − systematic = 0.435% − (−1.188%) = +1.623%.
Read it. This position's factor exposures were, on net, a headwind that period (heavily negative momentum exposure in a period where momentum paid off), the systematic component alone would have predicted a loss of −1.19%. The position nonetheless returned +0.435%, meaning its specific_return of +1.62 points did all of the work, and then some, just to offset the style headwind. Whether that +1.62% is genuine stock-picking skill or an artifact of an incomplete four-factor model is exactly the question Common Mistake 7 warns against answering from this one period alone, it is a hypothesis this decomposition generates, not a verdict it proves, in precisely the sense AA1.03's "attribution is a hypothesis generator, not a scoreboard" teaches for the Brinson case. This is Exercise QD2.03-e08's fixture; its hidden tests include the degenerate zero-factor case, confirming a solution correctly attributes the entire return to specific_return when no factors are supplied at all.
Worked example 7: Total return per position, in SQL (India ₹ + US $)
A prices table holds ticker, country, day (a trading-day sequence number, not a calendar date), and close for several positions, mixing Indian and US tickers in one table, a realistic shape for a cross-market book. The task: one row per ticker, its total holding-period return.
Solution. Step 1, find each ticker's first and last recorded close via a correlated subquery keyed on MIN(day) and MAX(day) per ticker, exactly the window-function-adjacent discipline CS2.03 teaches, applied here to a returns question instead of a general aggregation one. Step 2, join the two on ticker and compute last_close / first_close − 1. On a small illustrative seed. India's NILAM from ₹1,129.08 down to ₹1,111.92 over five trading days, US's BRPEAK from $88.19 down to $87.69 over the same span, the query returns BRPEAK: −0.567%, NILAM: −1.520%, ordered by ticker.
Read it. This is Exercise QD2.03-e09's exact shape, and every hidden test reseeds the same two-column pattern with entirely different tickers and prices (including one where a position moves in the opposite direction, to confirm the query does not silently assume returns are always positive). This is the "returns in code" question from §1, restated in the language this program's Data & SQL branch already gave you (CS2.03), the same computation, a different tool, because in a real shop the price history usually lives in a database, not a Python list.
Practice set
Work each problem fully (by hand, with a calculator) before reading its solution.
P1 (guided). Simple vs. log return, and the reconciliation. A price moves 100 → 90 → 108. Compute the two simple returns and the two log returns, then confirm both routes give the same total return.
Solution. Simple returns: 90/100 − 1 = −10.00%; 108/90 − 1 = +20.00%. Chain-linked: 0.90 × 1.20 − 1 = +8.00%. Direct total: 108/100 − 1 = +8.00%: matches. Log returns: ln(0.90) = −0.10536; ln(1.20) = +0.18232; sum = 0.07696; e^{0.07696} − 1 = +8.00%, matches by the additive-across-time route as well.
P2 (guided). The ddof gap, made concrete. A five-value return series has ddof=1 standard deviation of 0.0200 and ddof=0 standard deviation of 0.0179 (rounding aside, ddof=0 is always ≤ ddof=1 for the same data). If the mean excess return is 0.0050, compute the per-period Sharpe under each convention and state the percentage difference.
Solution. ddof=1: 0.0050/0.0200 = 0.250. ddof=0: 0.0050/0.0179 = 0.279. Difference: (0.279−0.250)/0.250 = +11.6%, an eleven-percent swing in the reported Sharpe ratio from a keyword nobody typed, on a small sample where the ddof=0/ddof=1 gap is largest.
P3. Annualizing correctly. A per-period (monthly) Sharpe ratio is 0.09. What is the annualized figure, and what assumption does the sqrt(12) scaling make?
Solution. 0.09 × sqrt(12) = 0.312. The scaling assumes returns are approximately independent and identically distributed month to month, genuine autocorrelation (a real feature of many return series) would make this annualization systematically too aggressive or too conservative, and is a convention to state, not a law to apply blindly.
P4. Sortino by hand. Returns are [0.02, −0.03, 0.01, −0.01, 0.04], MAR=0, ddof=1. Compute the downside deviation and the per-period Sortino ratio.
Solution. Downside values: min(0.02,0)=0, min(−0.03,0)=−0.03, min(0.01,0)=0, min(−0.01,0)=−0.01, min(0.04,0)=0. Sum of squares = 0.0009 + 0.0001 = 0.0010. n−ddof = 5−1=4. Downside deviation = sqrt(0.0010/4) = 0.01581. Mean excess return = (0.02−0.03+0.01−0.01+0.04)/5 = 0.006. Sortino = 0.006/0.01581 = 0.3795.
P5. Max drawdown, hand-traced. Wealth index (already computed): [1.00, 1.05, 0.95, 1.10, 0.90, 1.20]. Identify the peak, trough, and maximum drawdown.
Solution. Running max: [1.00, 1.05, 1.05, 1.10, 1.10, 1.20]. Drawdown: [0, 0, −0.0952, 0, −0.1818, 0]. Trough is index 4 (−18.18%, the more negative of the two dips), and the peak it fell from is index 3 (wealth=1.10, the running high immediately before it), not index 1, even though index 1 also shows a smaller dip, because the peak is defined relative to its own trough, and the −18.18% dip's own preceding high-water mark is index 3.
P6. Recovery time. Using P5's series, does the drawdown at index 4 recover within the window shown? If so, when?
Solution. Drawdown returns to 0 at index 5 (wealth=1.20 = running_max), one period after the trough at index 4. Recovery time = 5 − 4 = 1 period.
P7. Regular vs. excess kurtosis. A return series has excess kurtosis (scipy's fisher=True default) of 1.2. What is its regular kurtosis (the convention the PSR formula needs), and what does a value above 3 indicate about the distribution's tails relative to normal?
Solution. Regular kurtosis = excess + 3 = 4.2. A value above 3 indicates fatter tails than a normal distribution, more extreme observations than a bell curve would predict, which (holding everything else fixed) widens the PSR/DSR denominator and therefore lowers the resulting probability relative to naively assuming normality.
P8. PSR by hand, normal case. A return series has T=101, per-period SR̂ = 0.05, skewness 0, and regular kurtosis exactly 3 (so the denominator collapses to sqrt(1+SR̂²/2)). Compute PSR(SR*=0).
Solution. Denominator = sqrt(1 + 0.05²/2) = sqrt(1.00125) = 1.000625. z = (0.05 − 0)×sqrt(100)/1.000625 = 0.5×0.9994 = 0.4997. Φ(0.4997) ≈ 0.6915, roughly 69% probability the true Sharpe exceeds zero; a positive but not overwhelming result from a modest sample.
P9. Expected max Sharpe, N=1 and beyond. Explain, without computing, why expected_max_sharpe(1, sr_std) must equal exactly 0, and what happens to the value as N grows very large.
Solution. With exactly one trial there is nothing to select from, the "maximum of one skill-less draw" is just its expectation, which is 0 by construction (the null is zero true skill). As N grows, you are taking the maximum of an ever-larger sample of noisy draws, and the expected maximum of a growing sample from any fixed distribution strictly increases without bound (slowly, logarithmically in N, since it scales like sqrt(2 ln N) for a standard normal), never decreases, and never plateaus at a fixed number of trials.
P10. Deflating a Sharpe by hand, approximately. A per-period SR̂ = 0.10 was found by searching N=50 trials with T=101 observations each; assume normal returns (γ₃=0, γ₄=3) so Var(SR̂) ≈ (1+SR̂²/2)/(T−1). Using sr_std ≈ 1/sqrt(T−1) = 0.1 (the simplifying null approximation) and the fact that at N=50, (1−γ)Φ⁻¹(1−1/50) + γΦ⁻¹(1−1/(50e)) ≈ 2.25, estimate E[max SR_50] and state whether the observed 0.10 clears it.
Solution. E[max SR_50] ≈ 0.1 × 2.25 = 0.225. The observed SR̂ = 0.10 is below this bar, meaning, at face value, this result looks like exactly what fifty skill-less trials would be expected to produce at best, or worse. (A full DSR would also need skewness/kurtosis and run the PSR formula against this bar rather than a bare comparison, but the direction of the conclusion, this specific number does not clear a fifty-trial bar, is already visible from the comparison alone.)
P11 (India, ₹). Brinson-Fachler, one segment. Benchmark weight in a segment is 18%, portfolio weight is 25%, the segment's benchmark return is −4%, and the total benchmark return is 6%. Compute the Brinson-Fachler allocation effect for this segment, and contrast it with the plain Brinson-Hood-Beebower figure.
Solution. Brinson-Fachler: (0.25−0.18)(−4−6) = (0.07)(−10) = −0.70 points, a real, sizeable penalty, because the portfolio over-weighted a segment that lagged the benchmark's average by a full ten points. Brinson-Hood-Beebower: (0.25−0.18)(−4) = (0.07)(−4) = −0.28 points, understates the penalty by more than half, because it measures the bet against the segment's own return rather than what the money would have earned in the benchmark as a whole.
P12. Reconciliation, verify the identity holds. Segment allocation, selection, and interaction total +0.60, +0.45, and −0.10 respectively. A colleague reports the portfolio's active return as +0.85. Is the table internally consistent?
Solution. No. 0.60 + 0.45 + (−0.10) = 0.95, not 0.85. A ten-point gap means either a segment is missing from the sums, the weight vectors do not both sum to 1.0000, or the colleague is reporting the wrong active-return figure, exactly the reconciliation check AA1.03 §12 and this module's Exercise QD2.03-e07 both run mechanically before trusting a single downstream number.
P13 (US, $). Factor attribution, two factors. A position has exposures {momentum: 0.6, quality: −0.4} and that period's factor returns are {momentum: 0.02, quality: 0.01}. The position's realized return was +0.5%. Compute the systematic and specific returns.
Solution. systematic = 0.6×0.02 + (−0.4)×0.01 = 0.012 − 0.004 = 0.008 = 0.80%. specific = 0.50% − 0.80% = −0.30%, the factor exposures alone would have predicted a better return than what actually happened; this position under-performed its own style tilt by 0.30 points that period.
P14. Zero-factor degenerate case. Using the exercise's own contract, what must factor_attribution({}, {}, realized_return) return for systematic_return and specific_return, and why?
Solution. systematic_return = 0 (the sum over an empty set of factor contributions is zero, by definition) and specific_return = realized_return (with nothing to explain the return systematically, all of it is, by construction, unexplained/idiosyncratic). This is the honest, mechanical behavior of the formula at its boundary, not a special case to code around.
P15. Reading the pattern, not one ratio. A strategy shows: Sharpe 2.40, DSR (at the actual, honestly-counted number of trials) 0.42, max drawdown −6%, Brinson allocation effect near-zero, selection effect explaining nearly all of the active return. Write the two-sentence honest verdict.
Solution. The headline Sharpe is exactly what a large-enough search of skill-less variants would be expected to produce at its best (DSR well below the 0.95 bar), so this specific number is not distinguishable from luck on the evidence given, regardless of how modest the drawdown looks. If genuine skill exists here at all, the attribution says it would show up as security selection rather than a top-down allocation call, which is at least a testable, narrower hypothesis for further (prospective, out-of-sample) investigation, not a conclusion this one report can reach on its own.
P16. The one law, stated back in your own words. In one or two sentences, explain why every quantity inside the PSR/DSR formulas must be per-observation rather than annualized, and name the one place in this module where annualizing is the correct, final step instead.
Solution. The formulas' sqrt(T−1) term and the Euler–Mascheroni-based expected-maximum term are both derived directly against the per-observation sampling distribution of the Sharpe estimator; annualizing first changes the scale of SR̂ without changing T, breaking the relationship the formula assumes and silently corrupting the result by a factor of roughly sqrt(periods_per_year). Annualizing is correct, and expected, only as the very last, purely cosmetic reporting step applied to a plain Sharpe or Sortino ratio (§2) for human-readable output, never anywhere inside the PSR/DSR machinery of §4–§5.
Applied mini-project
Build one honest performance & attribution report, on both markets.
This project is the module's synthesis: run every function you built (returns, Sharpe, Sortino, drawdown, PSR, DSR, Brinson-Fachler, factor attribution) on a real position from each market, and write the honest verdict the whole point of this module has been building toward.
Do this, in order:
- Source two return series (~20 min). Using this program's bundled
nse-eodandus-eoddatasets, pick one India ticker and one US ticker, and pull at least 100 consecutive trading days of closes for each (R6: both markets, real seeded data, not invented numbers). - Returns and risk-adjusted ratios (~30 min). Compute simple returns for both series. Compute annualized Sharpe and Sortino for each, with a stated, deliberate
ddofand a stated risk-free/MAR assumption for each market. StateTnext to every ratio. - Drawdown (~20 min). Compute maximum drawdown, drawdown duration, and recovery time (or explicitly "no recovery within this window") for both series.
- The deflated Sharpe ratio, honestly counted (~35 min). State, in writing, before computing anything, how many candidate tickers you actually looked at before choosing these two (all fifteen in each market, if that is what you did, the honest answer is very often larger than the number that first comes to mind). Compute the PSR at
SR*=0for each chosen ticker, then compute the deflated Sharpe ratio using the realNyou just stated. Write one sentence on how much the number moved between the two. - Attribution (~40 min). Build a small, illustrative 4–5 segment Brinson-Fachler table for one of your two markets (segments, weights, and returns may be constructed/illustrative, label them so), and confirm the reconciliation identity holds to
1e-9. Separately, using the bundled synthetic factor panel, pick one ticker and one period, and run a factor attribution decomposing its realized return into systematic and specific components. - The verdict (~25 min). One page, ≤500 words: what each series returned, at what risk, whether the deflated Sharpe survives honest accounting for the number of trials, where the (illustrative) active return came from, and (the discipline this whole module rests on) one paragraph stating every place a number here rests on an assumption (a risk-free rate, an illustrative segment table, a stated but unverifiable trial count) rather than a fully independent source.
Scoring rubric. Self-score honestly. A criterion is a 2 only if it is complete, correct, and the reconciliation/consistency check for that section actually passed.
| # | Criterion | 0 points | 1 point | 2 points |
|---|---|---|---|---|
| 1 | Both markets sourced from the real bundled datasets, ≥100 trading days each | One market, or invented data | Both, but under 100 days or undocumented ddof/annualization choices | Both, ≥100 days, every convention (ddof, rf/MAR, periods_per_year) explicitly stated |
| 2 | Sharpe and Sortino computed and contrasted | Only one ratio computed | Both computed, no interpretation of the gap | Both computed, T stated, and the gap between them correctly interpreted |
| 3 | Drawdown, duration, recovery all reported | Magnitude only | Magnitude and duration, recovery omitted | All three, including an explicit "no recovery within window" where true |
| 4 | The trial count for DSR is honestly stated before computing | Not stated, or invented after the fact | Stated but implausibly small given the actual search performed | Stated honestly and in writing before computation, matching the actual search performed |
| 5 | DSR computed correctly at the stated N | Not attempted, or annualized values fed into the formula | Computed, but fisher=True default or wrong ddof used | Computed correctly, per-observation throughout, both markets |
| 6 | Brinson-Fachler reconciles exactly | Not attempted, or residual plugged | Attempted, off by more than rounding | Reconciles to 1e-9, per-segment array shown, not just the total |
| 7 | Factor attribution computed | Not attempted | Systematic/specific computed, no sanity check | Computed, and systematic + specific = realized verified explicitly |
| 8 | Honesty of the verdict | False precision, no caveats stated | Some caveats | Every assumption named, every illustrative figure labeled as such, no number oversold |
Pass: ≥12/16 with no criterion at 0. This is not a crown node, but the honesty bar is not lower for that, a report that plugs a residual or hides the real trial count has not passed, however clean the prose reads.
Code Lab: open the QD2.03 code pack in the Practice tab: nine exercises, QD2.03-e01 through QD2.03-e09 (eight in Python, one in SQL), covering returns, Sharpe, Sortino, drawdown, the Probabilistic Sharpe Ratio, the deflated Sharpe ratio, Brinson-Fachler attribution, factor attribution, and total return in SQL, and clear every one of its hidden tests, not merely the single visible example shown for each.
Reading & resources
Core (do these).
- Bailey, D. H. and López de Prado, M. (2014), "The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest Overfitting, and Non-Normality," Journal of Portfolio Management, the primary source for §5, and worth reading in full for the exact derivation of the expected-maximum-Sharpe formula this module implements. [Paid, though widely available as a working paper] [Advanced]
- Bailey, D. H. and López de Prado, M. (2012), "The Sharpe Ratio Efficient Frontier," Journal of Risk, the primary source for the Probabilistic Sharpe Ratio (§4), including the skewness/kurtosis-adjusted denominator this module's Exercise
QD2.03-e05implements exactly. [Paid, widely available as a working paper] [Advanced] - Sortino, F. A. and van der Meer, R. (1991), "Downside Risk," Journal of Portfolio Management, the original source for the Sortino ratio and downside deviation (§2). [Paid] [Intermediate]
- AA1.03 (this program, Finance-Plus): the theory owner of every risk-adjusted ratio and the Brinson decomposition this module implements; read it first if any formula here feels unfamiliar rather than merely unfamiliar-in-code. [Free, in-app] [Intermediate]
- QD1.03 (this program, Quant-Code): builds the identical expected-maximum-Sharpe machinery as a backtest-overfitting gate; read its "Overfitting: the winner is an order statistic" section alongside this module's §5 for the two complementary uses of one formula. [Free, in-app] [Intermediate–Advanced]
Going deeper.
- Lo, A. W. (2002), "The Statistics of Sharpe Ratios," Financial Analysts Journal, the large-sample standard error of a Sharpe-ratio estimator under normality, the special case §4's PSR denominator collapses to. [Paid] [Advanced]
- Mertens, E. (2002), "Comments on Variance of the IID Estimator in Lo (2002)", a widely-cited technical refinement of the same estimator-variance result. [Free, working paper] [Advanced]
- Harvey, C. R. and Liu, Y. (2015), "Backtesting," Journal of Portfolio Management, a complementary treatment of the multiple-testing problem in strategy research, from a different angle than Bailey & López de Prado's. [Paid] [Advanced]
- López de Prado, M. (2018), Advances in Financial Machine Learning, Chapter 8 ("The Dangers of Backtesting"), a full-length practitioner treatment of everything in §5 and §8, with worked code. [Paid] [Advanced]
Data and tools (free).
numpy,scipy.stats: every function in this module's code pack is built on these two libraries alone;scipy.stats.skew,.kurtosis,.norm.cdf,.norm.ppfare the four functions doing the real statistical work in §4–§5. [Free]- This program's bundled
nse-eod,us-eod, andsynthetic-factor-paneldatasets (app/public/datasets/), seeded, synthetic, India- and US-flavored price and factor data; every real number quoted in this module's worked examples was computed directly from these files, not invented. [Free, in-app]
Do this, not just read: pick any return series you have on hand (even a spreadsheet of a fund's monthly NAV) and compute its Sharpe ratio two ways, once with ddof=0 and once with ddof=1. Watch the number actually move. Then ask yourself, honestly, how many other funds, tickers, or parameter choices you glanced at before landing on the one you just tested, and compute what a deflated Sharpe ratio would say about it at that real count. Ten minutes of doing this once will install this module's central habit faster than any amount of additional reading.
Teach it back & journal
Feynman prompt. Explain to a colleague who just discovered a strategy with a "2.6 Sharpe ratio" and wants to raise money on it why you are not celebrating with them yet. Use the actual mechanism: that a Sharpe ratio is a point estimate from a finite sample, that searching more candidates mechanically raises the best observed Sharpe even with zero true skill behind any of them, and that the deflated Sharpe ratio is the arithmetic that prices in exactly how many candidates were searched. Then compute it live, with them, using the real number of things they actually tried, not the flattering, rounded-down number that first comes to mind. If your colleague can, unprompted, ask "wait, how many other things did I look at before I got here?" before you even mention the deflated Sharpe ratio by name, you have taught the module rather than the formula.
Journal prompt. Take one performance number you have actually been shown recently, a fund factsheet's Sharpe ratio, a colleague's backtest result, an AI-generated performance summary, or your own applied mini-project's output, and run this module's reconciliation checks against it: does a chain-linked return series match the reported total return? Does an attribution table's allocation, selection, and interaction actually sum to the active return, exactly, or is there an unexplained residual? If a Sharpe ratio is quoted, is its sample length (T) stated anywhere, and would you know how to ask for the honest count of "how many things were tried" behind it? Write down what you found. Then write a paragraph on the thing this module is really about: a performance report's greatest risk is not an obviously wrong number, it is a plausible one (the right shape, a believable magnitude, a confident tone) that turns out, on actual recomputation, to have skipped exactly the question (how many trials, what ddof, which kurtosis convention) that would have changed the verdict. Which single habit from this module, stating T next to every ratio, running the reconciliation identity before reading a single attribution number, asking "how many did you try" before admiring a Sharpe ratio, would most change the quality of the reports you put your name on, and what will you do this week to make it automatic rather than aspirational?
This module's flashcards and mastery quiz are wired into the app: see the node's Quiz and Reviews.