The Analyst's Path

Phase 12Finance Plus, AI and the quant-code trackFinance trackCS2.0616,872 words1 h 34 min of readingabout 20 hours of studyfree

Software Engineering: Testing, Clean Code & AI-Assisted Dev

One promise for the hours ahead. By the end you will be able to take a function you did not write, in a language you did not choose, about a domain you half-know, and in fifteen minutes produce (a) a failing test that pins its worst defect, (b) a corrected…

Advertisement

Branch: Computer Science Foundations (Quant-Code region) · Node CS2.06 · CROWN · ~20 focused hours · Crown mastery gate ≥ 90%

Every module before this one asked can you make the computer produce the right answer? This one asks a harder question, and it is the question that separates someone who can code from someone who can be trusted with production: how do you know it is right, and how would you find out if it stopped being right? Those are different skills. The first is programming. The second is software engineering, and it is what you are paid for the day your notebook stops being a scratchpad and starts being something a portfolio manager acts on. A pricing script that is correct on the three tickers you tried and silently wrong on the fourth has not failed loudly; it has failed quietly, into a spreadsheet, into a slide, into a decision. Quiet failure is the only kind that matters at scale, and everything in this module, tests, types, linters, logs, packaging, and the review discipline for AI-written code, exists for exactly one purpose: to convert quiet failure into loud failure, early, cheaply, and automatically.

Advertisement

Here is the whole module compressed into a sentence you will spend twenty hours earning the right to believe: code is a claim; a test is the evidence. Running a function and eyeballing the output is not evidence, it is an anecdote, unrepeatable, undocumented, and gone the moment you close the tab. A test is a claim about behaviour, written down, executable by anyone, on any machine, forever. It is the difference between "it worked when I ran it" and "it works, and here is the button that proves it in four seconds." That difference is the whole discipline. And it has never been worth more than it is now, because of the second thing this module is about.

Generating code has become nearly free. An assistant will write you a plausible sharpe_ratio in two seconds, with a confident docstring and a tidy example that runs. What has not become free (what has become the scarce, expensive, career-defining skill) is deciding whether generated code is correct. The economics have inverted: the bottleneck used to be typing and it is now judgment. An analyst who can read a forty-line AI-written function and say precisely "line 12 uses the population standard deviation where the contract wants the sample estimator, line 18 hardcodes 252 on monthly data, and the docstring claims it handles missing values, which it does not" is worth ten who prompt fluently and paste. This module builds that reviewer. Its centrepiece is a dedicated unit on reviewing AI-generated code: the six defect classes by name (hallucinated APIs, happy-path-only logic, silent numeric defects, insecure patterns, over-confident comments, untestable structure) and then seeded AI-written code debugged the only way that works, with tests that fail first.

One promise for the hours ahead. By the end you will be able to take a function you did not write, in a language you did not choose, about a domain you half-know, and in fifteen minutes produce (a) a failing test that pins its worst defect, (b) a corrected implementation, (c) a type signature a checker accepts, and (d) a one-line log statement that would have caught the failure in production without leaking a secret. That is the crown of this branch, and it is graded at 90% because there is no partial credit on a number that reaches a client.

App link. This node ships a live code pack: app/public/code-packs/CS2.06.json, nine graded exercises (CS2.06-e01 … CS2.06-e09) that run in your browser with no install and no account. Every one carries hidden tests you cannot see and cannot hardcode against, and every one also ships a fill-in-the-blank structured fallback so the entire node is completable offline with zero runtime. The last exercise, CS2.06-e09, is the region's bring-your-own-AI pattern in its purest form: you generate the implementation in whichever assistant you already use, review it yourself, paste it back, and our hidden tests grade it locally. The app never calls a model (R10), your tool, your prompt, our tests.


Learning objectives

By the end you can:

  1. State and defend the module's spine (code is a claim, a test is the evidence) and explain why "I ran it and it looked right" is not a verification procedure.
  2. Write pytest tests fluently: assert-based test functions, pytest.approx for floats, pytest.raises for error paths, @pytest.mark.parametrize for tables of cases, fixtures for shared setup, and conftest.py for shared fixtures, and run them with -q, -x, -k, and coverage.
  3. Execute the TDD loop (red, green, refactor) and explain, in mechanism, why a test that has never been seen to fail is not known to test anything.
  4. Choose what to test using a systematic tree (the contract, equivalence classes, boundary values, error paths, and invariants) and explain why the happy path is the least informative test you can write.
  5. Name the oracle problem and solve it four ways: hand computation, an independent second implementation, a closed-form or published reference, and a property/invariant, and recognise a change-detector test (one whose expected value came from running the code under test) as evidence of nothing.
  6. Diagnose and prevent silent numeric defects: float equality, ddof (population vs sample), integer division, annualisation factors, percent vs basis points, arithmetic vs geometric returns, in-place mutation of a caller's data, and off-by-one windowing, and set numeric tolerances that are tight enough to catch a real error and loose enough to survive IEEE-754.
  7. Apply clean-code discipline where it pays: intention-revealing names, one job per function, pure functions and explicit dependencies, guard clauses over nesting, no magic numbers, no mutable default arguments, and comments that explain why rather than restate what.
  8. Add type hints and read a mypy error, knowing exactly what static typing does and does not guarantee at runtime; run a linter/formatter (ruff, black) and articulate what a linter catches that a test cannot, and vice versa.
  9. Replace print with structured logging: a module-level logger, correct levels, logger.exception inside except, the run's seed and config logged for reproducibility, and never leak a secret or personal datum into a log line.
  10. Package a project properly: src/ layout, pyproject.toml, editable install, pinned dependencies, semantic versioning, and a one-command test entry point, so "it works on my machine" becomes a claim anyone can falsify on theirs.
  11. (The crown unit.) Review AI-generated code to a professional standard: name the six defect classes (hallucinated API · happy-path-only logic · silent numeric defect · insecure pattern · over-confident comment · untestable structure), run the REVIEW protocol (Read · Execute · Check APIs · Interrogate numbers · Examine security · Write the test) end to end, and state the two laws that govern the whole practice, understand it yourself first, and never let the assistant write the test for the code it just wrote.
  12. (Productivity objective: R10 duality.) Use a coding copilot the way a senior engineer does, for scaffolding, boilerplate, test-case enumeration, error-message archaeology, and refactoring drudgery, while keeping the specification, the oracle, and the final judgement entirely in your own hands.

The duality, stated once (R10). This module carries two objectives at once. The understanding objective (items 1–11) is what the crown gate rewards: you earn the belt by writing the failing test by hand, naming the defect class, and fixing the number. The productivity objective (item 12) is the payoff you keep. The order is not negotiable and it is enforced by the exercises: the by-hand version is gated before the AI shortcut in every callout below. A copilot makes a good engineer fast; it makes a bad engineer dangerous at speed, because it removes the one thing that used to slow a wrong idea down, the time it took to type it.


Prerequisites & connections

Builds on. CS1.01 and CS1.02 (functions, list/dict/set choice, comprehensions, mutability and aliasing, simple classes, this module tests code, it does not teach you to write it). CS1.04 (algorithmic thinking and Big-O: the brute-force O(n²) oracle in CS2.06-e06 is obviously correct only because you can see it is exhaustive). CS2.01 (command line and virtual environments, pytest, ruff and mypy are all invoked from a shell inside an environment you control). CS2.02 (Git: a suite's real power arrives when it runs on every commit, and a bisect is only useful if there is a test to bisect against). CS2.03 (SQL: the injection material in CS2.06-e05 assumes you can read a SELECT). Nothing here requires statistics beyond a mean and a standard deviation; where a financial quantity appears (Sharpe, drawdown, annualised volatility) the formula is given to you, because this module grades whether you can verify an implementation, not whether you can derive the theory.

The binding C-3 boundary, say it out loud. The QM branch of the Finance-Plus ring owns statistics and machine-learning concepts: intuition, derivations, failure modes. This region owns those ideas in code: implementation, validation, pipelines, evaluation. So when this module says "use the sample standard deviation, ddof=1," it does not re-derive why the divisor is n − 1: QM1.01 does that, and you should re-read its estimation section if the Bessel correction is not automatic for you. When it says "annualise by sqrt(k)," the square-root-of-time rule and its i.i.d. assumption belong to QM1.03; here you only ever ask whether line 18 actually implements it. Cross-link, never re-derive.

Feeds forward. Everything downstream is code someone will act on, so everything downstream inherits this discipline. DA1.01–DA1.05 are where the ddof and float-equality traps below meet vectorised data and multiply. DA2.01–DA2.02 turn "write a test" into "write a leak-free cross-validation harness", the same idea with a time axis. DA2.03 builds a from-scratch NumPy MLP whose backward pass is only checkable by a numerical-gradient test, a direct application of the independent-oracle technique taught here. QD1.03 ships the no_lookahead harness: a test, in this module's sense, that a backtest must pass before its results mean anything. QD2.04 turns logging, seeds and config discipline into a reproducible research protocol. And QD2.05 , the region capstone, is graded in part on four harnesses (no_lookahead, cost_sensitivity, determinism, oos_split), every one a test you now know how to write, read and trust. On the AI side, AI0.01 established the root cause you will keep meeting (a model optimises for plausible, not true) and AI0.06 drills detection on prose and figures; this module points the same discipline at source code, where the verification tool is not a filing but a failing test.

The one-sentence version of this module. Generated code is a plausible-looking claim about behaviour; a test is the only evidence that converts it into knowledge, so write the test first, review every generated line against the six defect classes, and let the machine do the typing while you keep the judgement.


Core teaching content

The claim and the evidence

Start with the sentence and unpack it: code is a claim; a test is the evidence. When you write def sharpe_ratio(returns, rf=0.0, ppy=252): ..., you have made a claim about behaviour, given these inputs, this function produces that output, and raises in these circumstances. Nothing about the code proves the claim. Running it once and looking at the number does not prove it either, for four reasons that are worth naming individually because each one kills a different bad habit. It is unrepeatable (nobody else can re-run your eyeball). It is undocumented (in six weeks you will not remember which inputs you tried). It is non-comprehensive (you tried the case you had in mind, which is the case the code was written for, which is precisely the case least likely to be wrong). And it is not automatic (it will not re-run itself when a colleague changes line 12 next March).

A test fixes all four at once. assert sharpe_ratio([0.01, 0.02], 0.0, 12) == pytest.approx(...) is repeatable, self-documenting, extensible, and (the critical property) automatic. This is the mental shift the whole module rests on: you are not writing tests to prove the code works today. Today you already know it works; you just wrote it. You are writing tests to find out, without human attention, on some Tuesday months from now, the moment it stops working. That is why "I don't need a test, it's a two-line function" is such a persistent error. The length of the function has nothing to do with it. The question is whether anyone (you, a colleague, a refactor, a library upgrade, an assistant's suggestion) will ever touch it again. If yes, it needs evidence attached.

There is a second, subtler payoff that nobody believes until they have felt it: tests are a design tool. Code that is hard to test is almost always badly designed, and the difficulty is diagnostic. A function you cannot test without a database is a function that mixed computation with I/O. A function you cannot test without waiting is a function that reached for the wall clock instead of accepting a date. A function whose result depends on how many times you have already called it is a function with hidden state. In every case the pain of testing is not a tax; it is the design telling you where the seams should have been. Engineers who write tests first end up with more modular code not because they are more disciplined but because untestable designs get rejected before they are finished.

pytest, concretely

pytest is the industry-standard Python test runner, and its ergonomics are the reason it won: a test is a plain function whose name starts with test_, in a file whose name starts with test_, and it passes if it does not raise. There is no class to inherit, no assertEqual vocabulary to memorise, you use the ordinary assert statement, and pytest rewrites it behind the scenes so that a failure prints the actual values rather than a bare AssertionError. Collect and run everything with pytest; quieten it with -q; stop at the first failure with -x; run only the tests whose names match a substring with -k drawdown; measure which lines were executed with --cov.

The canonical shape of a single test is Arrange, Act, Assert, three visually separated blocks, in that order. Arrange: build the inputs. Act: call the thing under test, exactly once. Assert: state what must be true. Two blocks of arrangement and four scattered calls is not a test, it is a script; if a failure fires you will not know which call produced it. One behaviour per test function, and a name that reads like a sentence: test_max_drawdown_measures_against_the_prior_peak tells a reader what broke without opening the body, which is the entire value of the name at 4 p.m. on a release day.

Four pytest tools carry most of the real work. pytest.approx exists because 0.1 + 0.2 == 0.3 is False in every IEEE-754 language on earth, so float equality in a test is a latent, machine-dependent failure: write assert got == pytest.approx(expected, rel=1e-9) and pick the tolerance deliberately. pytest.raises turns an error path into a first-class assertion: with pytest.raises(ValueError): annualised_vol([0.01], 12), and it is the only way to test that your validation actually validates; an untested raise is a comment. @pytest.mark.parametrize turns a table of cases into a table of tests: one decorator, six rows, six independently reported results, and adding a seventh case is one line rather than one copy-paste. Fixtures (@pytest.fixture) build shared setup (a sample price series, a temporary directory via the built-in tmp_path, a patched environment via monkeypatch) and pytest injects them by parameter name; put fixtures shared across files in conftest.py.

One warning about coverage, the most misread number in the industry. It measures which lines executed, not whether anything meaningful was asserted about them, a test that calls every line and asserts nothing scores 100%. Coverage is a floor detector, excellent at "is any part of this file never exercised?" and useless as a quality target, because chasing the percentage produces tests written to touch lines, which are exactly the tests that assert nothing. Aim at branch coverage of the risky paths and treat the number as a smoke alarm, not a certificate.

Test-driven development, and why red comes first

TDD is three steps repeated: red: write a test for behaviour that does not exist yet and watch it fail; green, write the least code that makes it pass; refactor, clean up with the test holding the behaviour still. Beginners skip the red step because it feels like theatre. It is not theatre; it is the only calibration you will ever get. A test that has never been observed to fail is not known to test anything. A typo'd function name, a test file pytest never collected because it was misnamed, an assertion inside an if that is never entered, a fixture that silently returned None, every one of these produces a green suite that checks nothing, and the only thing that distinguishes it from a real green suite is that you once saw it red for the right reason. Watching the failure message and confirming it says what you expected is the calibration step. Skip it and your suite's colour is decoration.

"The least code that makes it pass" is the second half people get wrong. It is deliberately, almost insultingly minimal, and the discipline it buys is that every line of production code exists because a test demanded it. When you find yourself writing a branch no test requires, you have just learned something: either the branch is unnecessary (delete it) or there is a case you have not specified (write the test). Then refactor, which means changing structure without changing behaviour, and the test is what makes that sentence meaningful. Without a suite, "refactor" is a synonym for "rewrite and hope."

Two corollaries. The regression test: every bug gets a test that reproduces it before it gets a fix (reproduce, fix, watch it go green) because a bug that happened once will happen again, and the test is the only thing standing between you and fixing it twice. And the bug report as a test: when someone says "it gives the wrong number for the March data," the first move is not a debugger but turning that sentence into a failing assertion. If you cannot, you do not yet understand the report.

What to test: the tree, and why the happy path is the least useful branch

Testing is not "try some inputs." It is a systematic walk of a small tree, and once you have the tree you will never again stare at a blank test file.

1, The contract. What does the function promise? Inputs, output, units, error conditions, and side effects (ideally none). Write the contract down first, in the docstring, in plain sentences. Half of all defects are ambiguity in the contract rather than error in the code: is the risk-free rate annual or per-period? is not a coding question, and getting it wrong produces a beautifully implemented wrong answer.

2: Equivalence classes. Partition the input space into groups whose members are handled identically, then test one representative of each. For a return series: a normal series, an all-positive series, an all-negative series, a constant series, a series of length exactly two.

3: Boundaries. Bugs cluster at edges, and the classic off-by-one is a boundary bug by definition. Empty input, one element, exactly the minimum, exactly the maximum, exactly at the threshold, one either side. In CS2.06-e04 the boundary is the exercise: a 3-day window over 5 observations yields 3 values, not 5 and not 2, and window == len(xs) must yield exactly one.

4: Error paths. Every documented raise needs a test that provokes it, or the validation is decoration. And the type of the error is part of the contract: a ValueError that a caller can catch is a different promise from a ZeroDivisionError that escapes from three frames down.

5: Invariants and properties. These are the highest-value assertions in quantitative code because they hold for every input and therefore do not need a hand-computed answer. Maximum drawdown is always in [0, 1). Sorting a curve ascending makes its drawdown exactly zero. Annualising by k scales the un-annualised ratio by exactly sqrt(k). Adding a constant to every return leaves the standard deviation unchanged. Doubling every price leaves every simple return unchanged. Property assertions catch whole families of defects at once and, crucially, they are hard to satisfy by accident, which is exactly why CS2.06-e02 grades you on writing one.

And the punchline: the happy path is the least informative test you can write. It is the case the author had in mind while writing the code, so it is the case the code was shaped to handle. It will pass. Its passing tells you almost nothing you did not already believe. This is not an argument against having it (you need one example that pins the intended semantics) it is an argument against stopping there, which is precisely where an assistant's generated test suite stops, because the assistant generated the tests from the same understanding that generated the code.

The oracle problem: where does the expected value come from?

Here is the question that separates a real test from a ritual. When you write assert got == 1.41434, where did 1.41434 come from? If the answer is "I ran the function and pasted what it printed," you have not written a correctness test. You have written a change-detector: it will tell you if the behaviour changes, which is genuinely useful for refactoring, and it will never, ever tell you the original number was wrong. A codebase full of change-detectors can be 100% green and 100% incorrect, and this is the single most common way a wrong number survives review.

An oracle is an independent source of truth. There are four practical ones, and a professional reaches for them in roughly this order.

Hand computation. For a small case, do it on paper. Twelve returns, a mean, a sample standard deviation, a square root: fifteen minutes with a calculator, and the number you get is genuinely independent of the code. This is why every worked example below computes its expected value by hand before showing an implementation.

An independent implementation. Compute the same quantity a different way and require agreement. In CS2.06-e06 the hidden oracle is a brute-force double loop over every (i, j) pair with i ≤ j, quadratic, far too slow for production, and obviously correct because it is exhaustive. That is the archetype: the oracle is allowed to be slow and stupid, because its only job is to be right.

A closed form or a published reference. A textbook worked example, a regulator's published table, a vendor's documented sample calculation. Cite it in the test's docstring so a future reader can re-verify.

A property. Often the strongest option, because it needs no number at all, it asserts a relationship the contract guarantees for all inputs. It is also the one an assistant is least likely to satisfy by accident.

The rule that follows, and it is the crown unit's second law: never let the assistant write the test for the code it just wrote. A test generated from the same mental model as the bug encodes the bug as the expected answer and goes green. If you want AI help with tests, and it is genuinely useful for enumerating cases you forgot, ask it for the case list, not the expected values, and then supply every expected value from an oracle of your own.

{
  "skill": "Enumerating the test cases you would have forgotten (case list, never expected values)",
  "use": "After you have written the contract and your own first tests by hand, ask an assistant to list edge cases, boundary conditions, and error paths it thinks you have missed - then you decide which are real and you supply every expected value yourself from an independent oracle.",
  "tools": ["Chat assistants - Claude, ChatGPT, Gemini", "IDE copilots - GitHub Copilot, Cursor, JetBrains AI", "Local/offline models via Ollama or LM Studio when the code cannot leave your machine"],
  "prompt": "Here is a function contract (not the implementation): <CONTRACT>. List every edge case, boundary condition, and error path a thorough reviewer would test. Group them as equivalence classes, boundaries, error paths, and invariants. Do NOT write the tests and do NOT give me expected values - list the CASES only, and flag any case where the contract itself is ambiguous.",
  "verify": "Treat the list as a checklist to challenge, not a spec to obey: strike the cases that are not real, add the domain cases it could not know, and compute every expected value yourself by hand, by an independent implementation, or as a property. Never let the assistant write the test for the code it just wrote - a test built from the same wrong mental model passes the bug (REVIEW step W).",
  "diy": "You must first be able to derive the case tree yourself - contract, equivalence classes, boundaries, error paths, invariants - which is exactly what this section builds. If you cannot produce a case list unaided, an AI-generated one only tells you what you cannot check.",
  "market": "IN"
}

Silent numeric defects: right shape, wrong number

A crash is a gift. It is loud, it has a traceback, and it stops the pipeline before the number reaches a slide. The dangerous defect is the one that produces a plausible float of the right magnitude with no exception anywhere, and quantitative code is unusually rich in these. Learn them as a named list, because you will meet every one of them in generated code.

Float equality. 0.1 + 0.2 is 0.30000000000000004; sum([0.1] * 10) is 0.9999999999999999. Binary floating point cannot represent most decimal fractions exactly, so == on computed floats depends on the order of operations. Compare with a tolerance and choose the kind deliberately: relative for quantities that scale, absolute for quantities near zero. 1e-6 on a ₹-crore figure is meaninglessly loose; 1e-15 on anything you summed is a flaky test that fails on someone else's machine.

ddof: population versus sample. statistics.pstdev divides by n; statistics.stdev divides by n − 1. NumPy's np.std defaults to ddof=0, the opposite of what almost every finance formula wants, and it is the most common silent defect in generated quantitative code. The error is sqrt(n/(n−1)): 4.4% at n = 12, 0.2% at n = 250: small enough to look right, large enough to move a Sharpe past a threshold. (Why the divisor is n − 1 is QM1.01's derivation; your job is to notice which one line 12 called.)

The annualisation factor. Volatility scales with sqrt(k), k being periods per year: 252 daily, 52 weekly, 12 monthly, 4 quarterly. Two failure modes: hardcoding 252 on non-daily data (on monthly data that inflates by sqrt(21) ≈ 4.58×), and scaling by k instead of sqrt(k) (a further sqrt(252) ≈ 15.9× on daily data). Both produce a number; neither raises.

Units and scale. Percent versus decimal versus basis points; ₹ crore versus ₹ lakh versus ₹ million; annual versus per-period. A 6% annual risk-free rate supplied as 0.06 and subtracted from a monthly return without dividing by 12 is a 12× error on the risk premium: CS2.06-e09's third planted trap, precisely because assistants make it constantly. Put the unit in the parameter name, and test the conversion.

Integer division. In Python 3, / is float division and // truncates toward negative infinity: -7 // 2 is -4, not -3. Code translated from, or generated with the semantics of, another language gets this wrong quietly.

Arithmetic versus geometric. The mean of [+50%, −50%] is 0%; the compounded outcome is 1.5 × 0.5 = 0.75, a 25% loss. A naive 14.88% average on Worked Example 1's series compounds ₹1,250 to ₹2,501 against an actual ₹2,180. Any function that averages returns must declare which mean it means.

In-place mutation of a caller's data. list.sort(), df.sort_values(inplace=True), arr += 1 and dict.update() change the object the caller still holds, so their next computation silently uses different data. Functions that take data and return data should never modify the input, and the test is two lines: snapshot, call, assert unchanged. Every exercise in this pack asserts it.

Off-by-one in windows and lags. A 20-day rolling mean over 120 observations yields 101 values. A signal computed from data through time t and applied at t is look-ahead bias, a one-index error that inflates a backtest into fantasy, and the reason QD1.03 ships an entire no_lookahead harness. One index. Whole strategies.

Perishable specifics, as of 2026-07; verify current. The ideas above are durable; every tool name, version boundary, and default below is a spec to re-check against your own environment, never a constant. Library defaults change and are part of the bug surface: numpy.std has historically defaulted to ddof=0 and pandas.Series.std to ddof=1, confirm both in the version you have installed rather than trusting memory (yours or a model's). APIs are removed on real dates: pandas.rolling_mean was deprecated in pandas 0.18 and removed in 0.23; DataFrame.append was deprecated in 1.4 and removed in 2.0; numpy.float was deprecated in NumPy 1.20 and removed in 1.24; sklearn.metrics.mean_squared_error(squared=False) was superseded by root_mean_squared_error in scikit-learn 1.4. An assistant trained across many versions will happily hand you any of them. Tooling: ruff has largely consolidated the roles of flake8, isort and pyupgrade; black and ruff format both format; mypy and pyright are the two mainstream type checkers; pyproject.toml is the current packaging standard (PEP 621) and setup.py is legacy. Read the changelog of the exact version you have pinned.

Clean code, in the only sense that pays

"Clean code" attracts more dogma than any other topic in software, so take only the parts with a measurable payoff: the ones that reduce the probability that a future reader (usually you, in three months, under time pressure) misunderstands what the code does.

Names carry the meaning. annualised_vol beats calc, periods_per_year beats p. A good name is a compressed comment that cannot go stale. Length is not the enemy; ambiguity is, rate is a bad name in finance because it does not say annual or per-period, decimal or percent, and that ambiguity is a defect waiting for a caller.

One function, one job. A function that fetches, cleans, computes and plots cannot be tested, reused, or reasoned about. Split by seam: I/O at the edges, computation in the middle. The middle is pure (same inputs, same outputs, no side effects, no clock, no network, no global state) and pure functions are trivially testable, which is the entire argument.

Explicit over implicit. Pass dependencies in rather than reaching for module-level globals: pass the date in rather than calling datetime.now() inside, pass a seeded generator in rather than calling random.random(). Not purity for its own sake, it is what makes a test deterministic, and determinism is what makes a failure reproducible.

Guard clauses beat nesting. Validate and raise at the top, then let the happy path run flat. Four levels of nesting is where readers lose the thread.

No magic numbers. A bare 252 mid-formula is an unexplained constant; TRADING_DAYS_PER_YEAR = 252 at module level is a documented decision. The real cost of a magic number is that when it is wrong, nothing in the source says what it should have been.

No mutable default arguments. def add_trade(trade, book=[]) evaluates that list once, at function-definition time, so every defaulted call shares one object: the first call is perfect, the second inherits the first's data. Use None as the sentinel: CS2.06-e07 grades exactly this, signature included.

Comments explain why, docstrings state the contract. A comment restating the code (# add one to i) is noise that will eventually contradict the line beneath it; a comment recording a decision (# NSE settles T+1 since Jan 2023, so the lag is 1 not 2) is the most valuable line in the file. Treat the docstring as the specification (inputs, units, output, errors, side effects) which is what makes it testable, and what makes a lying docstring the defect of Worked Example 6.

DRY, but not prematurely. Duplicating a line twice is cheaper than the wrong abstraction. Wait for the third occurrence, and for the duplicates to be the same idea rather than merely the same characters.

Type hints and static checking

Type hints annotate what flows through your functions: def annualised_vol(returns: Sequence[float], periods_per_year: int) -> float:. Python does not enforce them at runtime, pass a string and nothing complains until an operation fails somewhere downstream. Their value is that a static checker reads them and finds a class of bug before you run anything.

The payoff case is the common one. A function typed -> float | None (a lookup that may miss) whose result is fed straight into arithmetic earns you Unsupported operand types for / ("float" and "None") from mypy, a bug found in an editor in half a second that would otherwise have surfaced as a TypeError at 3 a.m. on the one ticker missing from the vendor file. Optionality is the highest-yield thing to annotate in data code, because missing data is the normal case, not the exception.

Practical guidance: use built-in generics (list[float]) rather than the old typing.List; accept the widest input type and return the narrowest (Sequence[float] in, list[float] out) so callers can hand you a tuple; use X | None and make the caller handle it; adopt gradually, non-strict on legacy code and --strict for new modules. And know the limit precisely: type hints prove shape, never value. -> float is satisfied by returning -999.0, and no checker will ever notice you used the population standard deviation. Types and tests cover disjoint failure modes, which is why you run both.

Linters, formatters, and what each layer actually catches

Four tools, four distinct jobs, and confusing them wastes arguments:

  • The formatter (black, ruff format) makes layout decisions so nobody has to. The value is not beauty; it is that formatting stops being a topic in review and diffs stop containing whitespace noise.
  • The linter (ruff, historically flake8 + plugins) reads code without running it and flags unused imports, undefined names, shadowed builtins, mutable default arguments, bare except:, unreachable code, and dozens of known bug shapes. A lint error is a statement about the source, not about behaviour, which is why it catches things no test ever will.
  • The type checker (mypy, pyright) proves shape-level consistency across function boundaries.
  • The test suite proves behaviour on the inputs you thought of.

A linter will never know your Sharpe is 4.6× too large; a test will never know you left a debug import in; a type checker will never know your docstring lies. The professional setup runs all four automatically on every commit (a pre-commit hook locally, CI on the server) because a check that depends on someone remembering to run it will not run on the day it matters.

Logging: seeing inside a run without leaking it

print does not survive contact with production: no severity, no timestamp, no source, no destination control, no off switch. Replace it with the standard library's logging.

Get a module-level logger with logger = logging.getLogger(__name__), the __name__ namespaces every message by module and lets an operator turn one noisy module up or down without touching code. Use the levels as designed: DEBUG for internals you want only while diagnosing; INFO for milestones a normal run should announce ("loaded 1,240 rows for 5 tickers"); WARNING for something recoverable but suspicious (a gap that was forward-filled); ERROR for a failed operation; CRITICAL for a failed process. Inside except, use logger.exception("..."), which logs at ERROR and attaches the traceback, the highest-value logging habit there is. Configure handlers once, at the application entry point, never in a library module.

Two rules with teeth. Log what makes the run reproducible, seed, config hash, input files and row counts, library versions, the date range actually used. A quant run whose seed was not logged cannot be reproduced, and an irreproducible result is not a result (QD2.04's whole thesis, in one line). And never log a secret or a personal datum: logger.info("calling vendor with %s", config) is how a live API key reaches a plain-text file, a screenshot and a support ticket, and the same line carrying a client identifier is a privacy incident. The defence is structural, not attentional, redact at the boundary so the safe call is the easy one, which is what CS2.06-e08 makes you build. (Use lazy %s formatting so the string is only built if the level is enabled.)

Packaging: making "works on my machine" falsifiable

The last mile is making your work runnable by someone who is not you. The modern Python answer is small and worth doing once properly.

Layout. Importable code under src/yourpkg/, tests under tests/. The src/ layout's specific virtue is that it makes accidental imports from the working directory impossible, so your tests exercise the installed package, what your users will actually get.

pyproject.toml. One declarative file for name, version, Python requirement, dependencies, and tool configuration ([tool.pytest.ini_options], [tool.ruff], [tool.mypy]). It replaces setup.py, setup.cfg, and the pile of dotfiles.

Editable install. pip install -e . inside a virtual environment is the correct fix for the ModuleNotFoundError that tempts people into sys.path.append(".."), a hack that works on your machine and only on your machine.

Pinning, versioning, one command. Declare compatible ranges for people who install your library; pin exact versions in a lock file for the environment that produces your results, because "the numbers changed and no code changed" is almost always an unpinned dependency (see the ddof/removed-API list above; CS2.01 owns the environment discipline itself). Semantic versioning (MAJOR breaking, MINOR compatible addition, PATCH fix) is a promise about what an upgrade may do to your callers. And give the project one command that runs everything: pytest && ruff check . && mypy src. If it takes more than one, it will not be run.


The crown unit: reviewing AI-generated code

Everything above is classical software engineering and would have been true in 2015. This section is why the module is a crown.

The economics, stated plainly

Two facts about generated code, and the second is the one people miss. First: it is often good (idiomatic, fast to produce, correct on the obvious case) and refusing to use it is a productivity decision you cannot defend. Second, decisively: its defects are distributed differently from human defects. A junior engineer's bugs cluster where the work was hard, and it shows, in tangled code that looks uncertain. A model's bugs cluster where the training text was thin or contradictory, and they arrive wearing exactly the same confident, idiomatic, well-commented clothes as its correct output. There is no tell in the style. AI0.01 named the root cause once and it holds unchanged: the model optimises for plausible, not true, and plausible-looking code is precisely code that reads correct.

Hence the routing rule this unit turns on: delegate in inverse proportion to the cost of checking the answer. Boilerplate, a pyproject.toml, an argparse block, a regex, a matplotlib incantation you have looked up nine times: cheap to check, delegate freely. A numerical routine whose output is a plausible float, a security-sensitive path, a look-ahead-sensitive backtest step, expensive to check, so write it yourself or budget the full review. The mistake is not using the tool; it is spending the time saved instead of spending it on verification.

The six defect classes

Learn these by name. Naming a defect class is what turns "something feels off" into a specific place to look.

Class 1: Hallucinated API. A call to something that does not exist, or no longer exists, or exists with different arguments: statistics.rolling_mean, sklearn.metrics.rmse, pd.rolling_mean(s, 20), DataFrame.append, np.float, a keyword argument that was renamed two versions ago. This is the cheapest class to catch, run it once and it raises AttributeError or TypeError, and therefore the one whose presence tells you something devastating: if a hallucinated API reached you, nobody ran the code, and every other class is still in there unexamined. Note the especially seductive sub-case: APIs that were real and were removed. The model is not inventing; it is quoting a version you are not running. CS2.06-e04 is this class.

Class 2: Happy-path-only logic. Correct on the example in the prompt, wrong on the second case. 1 - min(curve)/max(curve) is a genuinely correct maximum drawdown if the trough follows the peak, which it did in the example, and it reports a 17.4% drawdown on a curve that never fell. Off-by-one on a window, a shift() in the wrong direction, an unsorted series assumed sorted, a dropna() applied after the computation instead of before. CS2.06-e06 is this class.

Class 3: Silent numeric defect. The whole list from the section above, arriving with a tidy docstring: ddof, hardcoded annualisation, an annual rate subtracted per period, float equality, percent-versus-decimal, arithmetic-versus-geometric. No exception, right magnitude, wrong number. CS2.06-e03 and CS2.06-e09 are this class.

Class 4: Insecure pattern. Working code that opens a hole: string-interpolated SQL (injection); eval/exec on anything a user can influence; pickle.load on a downloaded file (deserialisation is arbitrary code execution); subprocess(..., shell=True) on a composed string; verify=False on an HTTPS call; a hardcoded key that then enters version control forever; yaml.load where safe_load was wanted; random where secrets was wanted; unvalidated paths permitting traversal. Every one appears in generated code because every one appears, abundantly, in the tutorials the model read. CS2.06-e05 is this class.

Class 5: Over-confident comment or docstring. The prose describes the intent, the code does something else, and the skimming reviewer believes the prose, a docstring saying "annualised volatility, ignoring missing values" attached to four lines that annualise by the wrong factor and never mention NaN. Uniquely dangerous, because the artefact that would normally help you review is the one lying, and readers trust natural language over code by a wide margin. Treat a generated docstring as a hypothesis to test, never as documentation.

Class 6: Untestable structure. Not wrong today, unverifiable forever: hidden global state, a bare except: pass that swallows the traceback, datetime.now() or an unseeded RNG buried inside a computation, network I/O welded into a calculation, one function doing five jobs. The tell is that you cannot write a test for it without changing it. That difficulty is the finding.

The REVIEW protocol

Six gates, in order, memorised. This is to code what the Primary-Source Guardrail (AI0.01) is to figures, and it is referenced as REVIEW 1–6 for the rest of the region.

  1. R, Read every line. No line ships unread. If a line does something you cannot explain to a colleague, it is not yours yet, and "the AI wrote it" is not a defence anyone accepts after an incident. Read the whole diff, including the parts that look like boilerplate.
  2. E: Execute it. Run it, once, before anything else. This costs seconds and eliminates Class 1 entirely. Then run it on a second input that is not the one in the prompt, that costs seconds more and starts on Class 2.
  3. V: Verify every API. Every unfamiliar call gets checked against the documentation for the version you have pinned, not against memory. If it does not appear in the docs for your version, it does not exist for you, however real it was in 2021.
  4. I: Interrogate the numbers. Units, scale, period, ddof, annualisation factor, sign convention, float comparison. Recompute one case by hand or with an independent method. This is the gate that catches Class 3, and it is the one people skip because it is the only one that takes real work.
  5. E: Examine the security surface. Where does external input enter? Is anything interpolated into a query, a shell command, a path, or an eval? Is anything deserialised? Are credentials present in source, or headed for a log? Class 4 lives here.
  6. W, Write the test that would have caught it. For every defect found, add a failing test first, then fix. The test is the permanent asset: it outlives the review, it protects against the same defect being regenerated next month, and, because assistants regenerate similar defects, it will earn its keep more than once.

Two laws sit above the six gates. Law one: understand it yourself first. You cannot review code that implements something you do not understand; you can only proofread its syntax. If you could not have written a passable version of this function unaided, close the assistant and go learn the thing, that is why every AI-Augment callout in this program gates the by-hand version before the shortcut. Law two: never let the assistant write the test for the code it just wrote. Ask it for the case list; supply the oracle yourself.

Bring your own AI, graded locally (R10)

This app never calls a model, no key, no network dependency, nothing you type leaves your browser. That is a decision about learning, not plumbing: an app that could call a model for you would let you pass by delegating, and the belt would mean nothing.

So AI exercises follow one pattern, and CS2.06-e09 is its reference implementation. You generate the code in whichever assistant you use, from the exercise's exact contract. You run REVIEW 1–6 on the result. You paste it back. Our hidden tests grade it locally, built around the four defects an assistant most reliably produces on that contract, population instead of sample standard deviation, a hardcoded 252, an annual risk-free rate subtracted without conversion, and no guard for zero volatility. The grade is on the reviewed artefact, so the skill measured is your review, not your prompt, which is the right thing to measure, because prompting is a technique with a shelf life and reviewing is a discipline that outlives every model version.

{
  "skill": "Turning a copilot's draft implementation into code you can defend (the REVIEW protocol in practice)",
  "use": "Let the assistant produce the first draft of a well-specified function - the typing is the part that was expensive - then spend the saved time running all six REVIEW gates on it instead of shipping the draft.",
  "tools": ["IDE copilots - GitHub Copilot, Cursor, JetBrains AI, Codeium", "Chat assistants - Claude, ChatGPT, Gemini", "Local models via Ollama or LM Studio for code that must not leave the machine"],
  "prompt": "Implement this exact contract in Python, standard library only: <CONTRACT WITH UNITS, ERROR CONDITIONS AND SIDE-EFFECT RULES>. Then, separately and before I read the code, list every assumption you made that the contract did not state, and every line where a reviewer should check units, ddof, an annualisation factor, or a boundary index.",
  "verify": "Run REVIEW 1-6 on the output: Read every line; Execute it on a second input, not the prompt's; Verify each API against the docs for your pinned version; Interrogate ddof / annualisation / units / sign / float comparison by recomputing one case independently; Examine the security surface (interpolated SQL, eval, pickle, shell, secrets, logging); Write the failing test for every defect before fixing it. Treat the generated docstring as a hypothesis to test, never as documentation.",
  "diy": "You must be able to implement and hand-verify this function without help first - that is the gate. If you could not have written a passable version yourself, you cannot review the generated one; you can only proofread its syntax.",
  "market": "US"
}

Where this leaves you

You now hold a stack of checks whose layers fail independently: a formatter for consistency, a linter for known bug shapes, a type checker for shape across boundaries, a test suite for behaviour, and (above all four) a human review protocol for the defects a machine wrote and a machine cannot see. The productivity payoff (objective 12, R10) is real: let the copilot write the scaffolding, the parametrize table, the argparse block, the docstring skeleton, the tedious refactor. Keep for yourself the three things that do not delegate, the specification, the oracle, and the final judgement. That division of labour is the modern engineer's job description, and the crown gate below is where you prove you can hold up your half of it.


Common mistakes & how experts think differently

  1. "It ran and the number looked right." The most consequential error here, because it feels like verification. A plausible float is exactly what every silent numeric defect produces, that is the definition of the class. The expert's reflex is "right compared to what?", and they refuse the answer until an oracle exists. Eyeballing is a screening step, never a verification step.
  2. "The tests pass, so the code is correct." Tests prove the code does what the tests say and nothing more; if the expected values were harvested by running the code, green means "unchanged," not "correct." Experts read a suite by asking two questions: where did each expected value come from, and what would this suite fail to notice?
  3. "100% coverage means it's well tested." Coverage counts lines executed, not claims asserted, a test that calls everything and asserts nothing scores 100%. Experts read the assertions, not the percentage, and note the inverse case too: 60% coverage where the covered 60% is the risky arithmetic beats 100% where it is logging boilerplate.
  4. "I'll write the tests after." Tests written after inherit the author's mental model, so they test what the code does rather than what it should do, and they are never watched failing. Red is the only proof the test is wired up.
  5. "It's a two-line function, it doesn't need a test." Length is irrelevant; the question is whether anyone will touch it again, including a library upgrade that changes a default, or an assistant that "improves" it next month. 1 - min(curve)/max(curve) is one line and wrong. Test by risk and reach, not by line count.
  6. "The AI's docstring says it handles missing values." Generated prose comes from the same process, with the same plausible-not-true objective, as the generated code, and it describes intent rather than behaviour. Experts read a docstring as a hypothesis and write one test per claim.
  7. "I asked it to write the tests too, so it's covered." A test generated from the same mental model as the bug encodes the bug as the expected answer and passes. Use a copilot for the case list; supply every expected value yourself.
  8. "A linter/type checker/formatter is bureaucracy." Each layer catches a disjoint failure class, a mutable default no test would surface, a None crossing a boundary two files away, a wrong number no static tool can see. Experts run all four on every commit, because a check that relies on memory does not run on the day it matters.
  9. "print is fine for now." No level, no timestamp, no source, no destination, no off switch, so it is either deleted (visibility lost) or left in (noise, sometimes containing a key). Log from a __name__ logger from the first commit, use logger.exception inside except, and log the seed and config.
  10. "It works on my machine." Usually an unpinned dependency, a package imported from the working directory, or an environment variable only you have. Experts make the claim falsifiable: src/ layout, pyproject.toml, pip install -e ., a lock file, one command, so "works" is something a stranger can test in two minutes.

The thread through all ten: the expert does not trust their own attention. They assume that they, their colleague, and their assistant will each be wrong sooner or later, and build a stack of automatic, independent checks that notices before a client does. Attention is not a control; a suite that runs on every commit is.


Worked examples

Worked example 1: Red, green, refactor on a ₹ CAGR function (India, ₹)

A colleague needs an annualised growth rate for a note. The series is explicitly synthetic but realistically scaled for an NSE-listed large cap: ₹1,250.00 on 1 April 2019 and ₹2,180.00 on 31 March 2024: five whole years. Write cagr(begin, end, years) test-first.

Solution. Step 1: red. Before any implementation, write the test with an expected value from an oracle, which here is arithmetic done by hand. The ratio is 2180 / 1250 = 1.744. Its natural log is ln(1.744) = 0.556181; divide by 5 to get 0.111236; exponentiate to get 1.117659. So the expected CAGR is 0.117659 (11.7659%). The test: assert cagr(1250.0, 2180.0, 5) == pytest.approx(0.117659, rel=1e-5), is written against a function that does not exist, run, and observed to fail with NameError. That failure is the calibration: the test is wired up and it can fail.

Step 2: green, minimally. return (end / begin) ** (1 / years) - 1. Run: green. Then check the arithmetic in the other direction, which is the cheapest independent oracle available: 1250 × 1.117659⁵ = 2180.00. Two independent routes to the same figure.

Step 3, grow the tree, one red at a time. Boundaries and error paths: years = 0 raises ValueError (not a ZeroDivisionError escaping from the exponent); begin <= 0 raises; end == begin returns exactly 0.0. Each red first, then a guard clause at the top. A property: doubling both begin and end leaves the answer unchanged, a scale invariance that needs no expected value and catches a whole family of defects.

Step 4, the defect this pins. The naive alternative is a simple average: (2180/1250 − 1) / 5 = 0.744 / 5 = 0.1488, or 14.88%, and it looks fine. Compound it and the lie surfaces: 1250 × 1.1488⁵ = ₹2,501.11 against an actual ₹2,180, an overstatement of ₹321 on a ₹1,250 start. This is the arithmetic-versus-geometric defect from the numeric list, and the reason the test's expected value had to come from an oracle rather than from running a candidate implementation.

Read it. Nothing here is difficult. What makes it engineering rather than arithmetic is the order: the oracle came before the code, the failure was observed before the fix, and the boundaries were specified rather than discovered. Run the same loop in CS2.06-e01.

Worked example 2: Debugging AI-written max_drawdown with a test (US, $)

An assistant is asked for maximum drawdown and returns this, with a tidy docstring and one test:

def max_drawdown(curve):
    """Maximum peak-to-trough decline of an equity curve."""
    return 1 - min(curve) / max(curve)

# the assistant's test
assert max_drawdown([100, 80]) == 0.20

The test passes. The function is wrong. (Curve values are $ illustrative.)

Solution. Step 1: REVIEW gate E: execute on a second input. The assistant's example rises to one peak and then falls, which is the one shape where its formula is correct. Try [100, 120, 90, 130, 110]. By hand: the running peak is 100, 120, 120, 130, 130; the drawdown at each point is 0, 0, (120−90)/120 = 0.25, 0, (130−110)/130 = 0.1538; the maximum is 0.25. The function returns 1 − 90/130 = 0.3077. First failure, in under a minute.

Step 2, find the shape of the defect, not just an instance. The formula ignores time order: it compares the global minimum to the global maximum regardless of which came first. Two inputs make that unmissable. [100, 60, 200], the trough precedes the peak, has a true drawdown of (100−60)/100 = 0.40, and the function reports 1 − 60/200 = 0.70. And [100, 110, 121], a strictly rising curve that never fell at all, gets reported as a 17.36% drawdown (1 − 100/121). A number that is not merely imprecise but categorically impossible is the clearest possible confirmation that the concept, not the arithmetic, is wrong. This is Class 2, happy-path-only logic.

Step 3: REVIEW gate W: write the tests first, then fix. Three failing tests go in before a line changes: the two-peak case expecting 0.25, the trough-before-peak case expecting 0.40, and the monotone case expecting exactly 0.0. Then the fix, carry a running peak and measure each point against it:

peak, worst = curve[0], 0.0
for v in curve:
    peak = max(peak, v)
    worst = max(worst, (peak - v) / peak)
return worst

Step 4, add the oracle and the invariants. The independent oracle is a brute-force double loop over every pair i ≤ j taking max((curve[i] − curve[j]) / curve[i]), quadratic, unusable in production, obviously correct because exhaustive. Assert agreement on a seeded 240-point curve. Then two properties that need no expected value: the result is always in [0, 1), and max_drawdown(sorted(curve)) is exactly 0.0. Exactly this battery runs as the hidden tests of CS2.06-e06.

Read it. The assistant's code was idiomatic, commented, and shipped with a passing test, and wrong on the second input anyone would try. The defect was not caught by reading harder; it was caught by executing a case the author had not chosen. That is why gate E sits second in REVIEW, ahead of every gate that requires thought.

Worked example 3: The hallucinated API, and the version trap underneath it

Two assistants, two requests, four suggestions:

out = statistics.rolling_mean(xs, 20)          # (a)
err = sklearn.metrics.rmse(y_true, y_pred)     # (b)
avg = pd.rolling_mean(series, 20)              # (c)
book = book.append(new_row)                    # (d)

Solution. Step 1: execute (gate E). (a) raises AttributeError: module 'statistics' has no attribute 'rolling_mean'. (b) raises AttributeError, scikit-learn has never exposed rmse under that name. Both die in one second. The lesson is not that the calls were wrong; it is what their presence proves: nobody ran this code. Once you know that, you must assume every other defect class is also still present and unexamined.

Step 2, the harder half. (c) and (d) are far more dangerous because they were real: pandas.rolling_mean and DataFrame.append both existed and were both removed (see the dated block above for the exact version boundaries, and re-check them against your own install). The model is not inventing, it is quoting a version you are not running, because its training text spans many versions with no reliable date stamp on any of them.

Step 3, the fix, and the gate. Replace with the current API: a rolling mean is series.rolling(20).mean(); RMSE is root_mean_squared_error(y_true, y_pred) (or mean_squared_error(...) ** 0.5); appending a row is pd.concat([book, new_row]). Then install the discipline that makes this automatic: REVIEW gate V: verify every unfamiliar call against the documentation for the version you have pinned, not against memory and not against a search result whose date you did not read. If it is not in your version's docs, it does not exist for you; pinning in pyproject.toml is what makes "your version" a well-defined phrase at all.

Read it. Class 1 is the cheapest defect to catch and the most informative one to find (one execution) and it is a proxy alarm for the whole review: hallucinated API present ⇒ code unrun ⇒ assume Classes 2 through 6 are present too. Practise the replacement in CS2.06-e04.

Worked example 4: The silent numeric defect: ddof and the annualisation factor (US, $)

A fund's explicitly synthetic monthly returns for one year, as decimals:

0.021, −0.013, 0.034, 0.008, −0.027, 0.019, 0.041, −0.006, 0.012, −0.018, 0.029, 0.005

The assistant's Sharpe ratio (risk-free rate zero) is np.mean(r) / np.std(r) * np.sqrt(252). It runs. It returns a number. It is wrong twice.

Solution. Step 1, compute the pieces by hand. The twelve returns sum to 0.105, so the mean monthly return is 0.105 / 12 = 0.00875. The population standard deviation (divisor n = 12, which is np.std's default and statistics.pstdev) is 0.0205188. The sample standard deviation (divisor n − 1 = 11, which is what the finance convention wants and what statistics.stdev gives) is 0.0214312. The ratio between them is sqrt(12/11) = 1.04447, the sample figure is 4.4% larger.

Step 2, the correct answer. (0.00875 / 0.0214312) × sqrt(12) = 0.408284 × 3.464102 = 1.41434.

Step 3, price each defect separately, because that is how you diagnose a wrong number in the wild. Using the population sd but the correct sqrt(12): (0.00875 / 0.0205188) × 3.464102 = 1.47723: 4.4% too high, and utterly invisible. Using the sample sd but a hardcoded sqrt(252): 0.408284 × 15.874508 = 6.48130: sqrt(252/12) = sqrt(21) = 4.583× too high. Both defects together, which is what the assistant actually wrote: 6.76950, a factor of 4.786× the truth. A Sharpe of 6.8 should trigger disbelief in any experienced reader; a Sharpe of 1.48 will pass every review on earth. The dangerous defect is the small one.

Step 4, the tests that pin each defect independently. Assert the value against the hand computation with a tight relative tolerance (rel=1e-9, not 1e-2, a loose tolerance would swallow the 4.4% ddof error). Then assert two properties: that the answer differs from the population-sd variant by more than 1e-3, and that sharpe(r, ppy=1) × sqrt(12) equals sharpe(r, ppy=12) exactly, which fails instantly for any hardcoded factor. CS2.06-e03 and CS2.06-e09 grade precisely these.

Read it. No exception was raised at any point. Both defects are one identifier and one constant, both idiomatic, and one of them (np.std's ddof=0 default) is arguably a documentation problem rather than a coding error. This is REVIEW gate I, the gate people skip, because it is the only one that requires arithmetic rather than reading.

Worked example 5: The insecure pattern: an injected DROP (India, ₹)

An assistant writes a price lookup against the bundled finance database and it works perfectly on every NSE ticker tried:

sql = f"SELECT date, close FROM prices WHERE ticker = '{ticker}' AND date >= '{start}'"
cur.execute(sql)

Solution. Step 1: REVIEW gate E (examine the security surface): where does external input enter? ticker and start come from outside the function and are pasted directly into the SQL text. Whatever the caller supplies becomes part of the statement, which means the caller, a web form, a CSV column, a config file someone else edits: is writing your SQL.

Step 2: construct the payload. Set ticker = "RELIANCE'; DROP TABLE prices;--". The f-string produces:

SELECT date, close FROM prices WHERE ticker = 'RELIANCE'; DROP TABLE prices;--' AND date >= '2024-04-01'

The learner's own quote closes the string literal, the semicolon ends the statement, a second statement is now free-standing, and -- comments out the wreckage. A subtler and quieter payload (TATAMOTORS' OR '1'='1) does not destroy anything; it silently returns the entire table, which is a data-exfiltration bug that no test of the happy path will ever notice.

Step 3, the fix, which is structural rather than a patch. Do not sanitise; parameterise. Ship a constant SQL string with ? placeholders and pass the values separately, so the driver binds them as data and they can never be parsed as SQL:

sql = "SELECT date, close FROM prices WHERE ticker = ? AND date >= ? AND date <= ? ORDER BY date"
return sql, (ticker, start, end)

Step 4, the tests. The value-carrying assertions are the interesting ones: the SQL text must be identical for every input (so build_price_query(evil, ...)[0] == build_price_query("INFY", ...)[0]), the payload must appear in params and never in sql, and sql must contain no ;. That last one is a cheap structural guard against statement chaining. CS2.06-e05 grades all of it, with an Indian and a US payload.

Read it. Note what did not happen: the code was never broken. It ran, returned correct rows, and passed its author's test. Working and safe are different properties and only the first is visible in the happy path, which is why security is its own REVIEW gate rather than something you hope to notice while reading. (Escaping and driver details belong to CS2.03; the discipline of looking for the seam is this module's.)

Worked example 6: The over-confident docstring

Inherited code, generated some months ago and never questioned:

def annualised_vol(returns, periods_per_year):
    """Annualised volatility (%), ignoring any missing values."""
    return statistics.pstdev(returns) * math.sqrt(252)

Solution. Step 1, treat the docstring as a specification and enumerate its claims. It makes three: the result is (i) annualised, (ii) expressed in percent, and (iii) computed ignoring missing values. Three claims, three tests, before touching the body.

Step 2: test each claim. (i) Annualised: the function accepts periods_per_year and never reads it, so on monthly data it annualises by sqrt(252) instead of sqrt(12), an overstatement of sqrt(21) = 4.583×; the property test vol(r, 1) * sqrt(12) == vol(r, 12) fails immediately. (ii) Percent, on a series whose sample sd is a known 0.02 monthly, the docstring implies roughly 6.93 and the code returns 0.0693. Whichever convention you pick, docstring and code disagree, and a caller who multiplies by 100 on the docstring's word is now off by 100×. (iii) Ignoring missing values, pass a series containing None and it raises TypeError. The claim is simply false.

Step 3, the fourth defect, which the docstring never mentions. pstdev is the population estimator where the sample estimator was wanted. No test derived from the docstring would have found it, the limit of contract testing, and the reason gate I exists separately from gate R.

Step 4: fix in the right order. Correct the code to statistics.stdev(returns) * math.sqrt(periods_per_year), add the guard for fewer than two returns, then rewrite the docstring to match the behaviour you actually implemented, a fraction, not a percent; no NaN handling, so state that missing values must be removed by the caller. A docstring corrected last is a docstring that stays true.

Read it. Class 5 is uniquely dangerous because the artefact that normally helps a reviewer is the one doing the lying, and readers trust prose over code by a wide margin. The habit that neutralises it: read the docstring as a hypothesis, and write one test per claim. Three tests found three defects, and none required understanding the finance.

Worked example 7: Type hints catching None before it reaches production (India, ₹)

A dividend-yield helper over a bundled Indian equity panel. Figures are explicitly synthetic but realistically scaled: dividend per share ₹9.00, price ₹2,850.00.

def latest_price(ticker: str) -> float | None:
    """Latest close, or None if the ticker is not in the panel."""
    ...

def dividend_yield(dps: float, ticker: str) -> float:
    return dps / latest_price(ticker)          # mypy: error

Solution. Step 1, the happy path is correct and proves nothing. For a ticker that exists, 9.00 / 2850.00 = 0.0031579, i.e. a 0.3158% yield. Every test written against a present ticker passes, forever.

Step 2: what the checker sees. mypy reports Unsupported operand types for / ("float" and "None") on the division, because the declared return type of latest_price admits None and the code divides by it unconditionally. No test data, no execution, no waiting for the one ticker that is missing from a vendor file on a Tuesday, the bug is found statically, in an editor, in under a second.

Step 3, fix by making the contract explicit rather than by silencing the checker. Two defensible choices, and the checker forces you to choose, which is the real value: either propagate the optionality (-> float | None, returning None when the price is missing, and the caller handles it), or convert it to a loud failure (price = latest_price(ticker), then if price is None: raise KeyError(ticker)). What you must not do is # type: ignore, which converts a found bug into a hidden one.

Step 4: what types could not do. A checker will happily accept return dps / price * 100 (percent instead of fraction), return price / dps (inverted), or return -9.99. All are float. Types prove shape, never value, which is exactly why the stack has four independent layers and why the numeric assertions of Worked Example 4 are not optional just because mypy is clean.

Read it. Notice the division of labour across one small function: the type checker found the None, a test would find an inverted ratio, a linter would find the unused import above it, and only human review would notice a docstring claiming the result was a percentage. Four layers, four disjoint defect classes, one function.

{
  "skill": "Reading an unfamiliar traceback or a mypy error you have never seen before",
  "use": "Paste the exact error text and the minimal surrounding code and ask for the mechanism - what the checker or interpreter is actually complaining about and which invariant is violated - so you learn the error class rather than collecting a one-off patch.",
  "tools": ["Chat assistants - Claude, ChatGPT, Gemini", "IDE copilots with inline error explanation - Cursor, GitHub Copilot, JetBrains AI", "The official docs and the library's own changelog, which is where the answer must ultimately be confirmed"],
  "prompt": "Here is an exact error and the minimal code that produces it: <ERROR + CODE>. Explain the mechanism - what invariant is violated and why the checker/interpreter says this specific thing. Then give me two or three DIFFERENT fixes with their trade-offs, and tell me which one changes the function's contract. Do not suppress the error with an ignore comment.",
  "verify": "Reproduce the error yourself first and confirm the explanation predicts the behaviour you see; check any API named in the fix against the docs for your pinned version (REVIEW gate V); then write the failing test that the fix should turn green before you apply it (gate W). A suggested `# type: ignore` or bare `except:` is a red flag, not a fix - it converts a found bug into a hidden one.",
  "diy": "You must be able to read a traceback yourself - bottom frame first, then the call chain - and state what a mypy Optional error means, before outsourcing the interpretation. This section builds exactly that.",
  "market": "IN"
}

Practice set

Work each problem fully before reading its solution (write before you peek). A calculator is assumed; numeric answers are to about ±2% unless a tolerance is stated. All company and price figures are explicitly synthetic unless traced to a named source.

P1 (guided). The spine. State the module's payoff sentence, then give the four separate reasons "I ran it and the number looked right" is not a verification procedure.

Solution. Code is a claim; a test is the evidence. Eyeballing fails on four independent counts: it is unrepeatable (nobody else can re-run your glance), undocumented (in six weeks you will not recall which inputs you tried), non-comprehensive (you tried the case the code was written for, which is the case least likely to be wrong), and not automatic (it will not re-run when someone edits line 12 next March). A test fixes all four, and the fourth is the one that matters most: you are not writing tests to prove it works today (you know that) but to find out, without human attention, the moment it stops.

P2 (guided). Float equality. What does 0.1 + 0.2 == 0.3 evaluate to in Python, and what is sum([0.1] * 10)? State the correct way to assert both in a test.

Solution. 0.1 + 0.2 == 0.3 is False: the sum is 0.30000000000000004. sum([0.1] * 10) is 0.9999999999999999, not 1.0. Binary floating point cannot represent most decimal fractions exactly, so equality on computed floats depends on the order of operations and on the machine. Assert with a deliberate tolerance: assert got == pytest.approx(0.3, rel=1e-9), choosing relative tolerance for quantities that scale and absolute for quantities near zero (abs=1e-12 for something that should be exactly 0.0).

P3 (guided). The size of the ddof error. A Sharpe ratio computed on 12 monthly returns using the population standard deviation comes out at 1.47723. What is the correct value using the sample standard deviation, and by what factor were you wrong?

Solution. The two estimators differ by exactly sqrt(n / (n − 1)) = sqrt(12/11) = 1.04447, with the sample standard deviation the larger one, so a Sharpe built on the population sd is too high by that factor. The correct value is 1.47723 / 1.04447 = 1.41434. The error is 4.4%: far too small to look wrong, and quite large enough to move a number across a mandate threshold. This is why the tolerance on a numeric test must be tight (rel=1e-9); a rel=1e-2 tolerance would swallow this defect whole.

P4. The annualisation factor. Monthly returns are annualised with a hardcoded sqrt(252) instead of sqrt(12). By what factor is the answer overstated? What if the code had multiplied by k instead of sqrt(k) on daily data?

Solution. sqrt(252) / sqrt(12) = sqrt(21) = 4.583× overstated. Scaling by k rather than sqrt(k) on daily data multiplies by an extra 252 / sqrt(252) = sqrt(252) = 15.875×. Neither raises an exception; both produce a number of a plausible type. The general defence is a property test rather than a magic constant: f(r, periods_per_year=1) * sqrt(k) == f(r, periods_per_year=k) fails instantly for any hardcoded factor, whatever the constant happens to be.

P5. Maximum drawdown by hand. For the equity curve [100, 120, 90, 130, 110], compute the true maximum drawdown, and compute what 1 - min(curve)/max(curve) returns.

Solution. Running peak: 100, 120, 120, 130, 130. Drawdown at each point: 0, 0, (120−90)/120 = 0.25, 0, (130−110)/130 = 0.1538. True maximum drawdown = 0.25 (25%). The shortcut returns 1 − 90/130 = 0.3077 (30.8%), because it compares the global minimum to the global maximum with no regard for which came first.

P6. The impossible number. Apply 1 - min(curve)/max(curve) to [100, 110, 121], a curve that rises every period. What does it report, and why is that result diagnostically more useful than an ordinary wrong answer?

Solution. It reports 1 − 100/121 = 0.1736 (17.36%), a 17.4% drawdown on a curve that never fell. The result is diagnostically superior to a merely inaccurate one because it is categorically impossible: no arithmetic slip produces a drawdown on a monotone series, so the concept must be wrong, not the precision. Impossible outputs are the cheapest defects to find, which is why "assert the invariant" (a monotone curve has exactly zero drawdown) belongs in every quantitative suite.

P7. The oracle problem. Four candidate sources for the expected value in assert sharpe(r) == pytest.approx(X). Which are valid oracles, and which is not? (a) You computed it with a calculator from the definition. (b) You ran the function and pasted its output. (c) You wrote a slow brute-force version and it agrees. (d) You asserted sharpe(r, ppy=1) * sqrt(12) == sharpe(r, ppy=12).

Solution. (a), (c) and (d) are valid oracles, hand computation, an independent implementation, and a property. (b) is not an oracle at all: it makes the test a change-detector, which reports if behaviour changes and can never report that the original was wrong. A suite built from (b) can be 100% green and 100% incorrect. Note that (d) is the strongest of the three in one respect: it requires no expected number, holds for every input, and is very hard to satisfy by accident.

P8. Mutable default argument. Predict the output:

def add_trade(trade, book=[]):
    book.append(trade)
    return book

print(add_trade("A"))
print(add_trade("B"))

Solution. ['A'] then ['A', 'B']: not ['B']. The default list is created once, when the function is defined, so every defaulted call shares one object and each call inherits the previous one's data. The bug is invisible on the first call, which is the only call a happy-path test makes. Fix with the None sentinel: def add_trade(trade, book=None): then if book is None: book = [], and return list(book) + [trade] so the caller's list is never mutated either. (ruff flags this as a lint error without running anything; CS2.06-e07 grades it, signature included.)

P9. Off-by-one in a window. How many values does a 20-day trailing rolling mean produce from 120 daily observations? What are the two boundary cases a test must include?

Solution. 120 − 20 + 1 = 101 values. The two boundaries are window == 1 (which returns the input unchanged, length 120) and window == len(xs) (which returns exactly one value, the mean of everything). Test both, plus window == 0 and window > len(xs) as error paths. Off-by-one is a boundary defect by definition, so boundary tests are not optional garnish here, they are the whole detection mechanism.

P10. Write the property test. An implementation of sharpe_fn(returns, periods_per_year) may be hardcoding its annualisation factor. Write the assertion that catches this for any hardcoded constant, without re-implementing the formula.

Solution.

base = sharpe_fn(r, 1)
for k in (4, 12, 52):
    assert sharpe_fn(r, k) == pytest.approx(base * math.sqrt(k), rel=1e-9)

The oracle is the contract's own scaling law, not a re-derivation of the ratio, so the test is independent of how the ratio is computed and catches sqrt(252), sqrt(12), or any other frozen constant. Testing several k values also catches the sneakier variant that happens to match on one input. This is CS2.06-e02.

P11. The injection payload. Given sql = f"SELECT date, close FROM prices WHERE ticker = '{ticker}'", write out the SQL produced by ticker = "RELIANCE'; DROP TABLE prices;--", then give the fix and the two assertions that prove it.

Solution. The produced statement is SELECT date, close FROM prices WHERE ticker = 'RELIANCE'; DROP TABLE prices;--', the quote closes the literal, the semicolon ends the statement, a second destructive statement stands free, and -- comments out the remainder. The fix is to parameterise, not to sanitise: a constant "... WHERE ticker = ? ..." plus a separate params tuple, so the driver binds the value as data. The two assertions: (1) the SQL text is identical whatever the input (build(evil)[0] == build("INFY")[0]), and (2) the payload appears in params and never in sql (plus the cheap structural guard ";" not in sql).

P12. Logging discipline. Rewrite print("fetching", ticker, "with", config) (where config holds api_key) as a professional log line, and name the two rules you applied.

Solution. logger.info("fetching %s with config %s", ticker, redact(config)), where logger = logging.getLogger(__name__) at module level. Two rules: never log a secret (redact structurally at the boundary, so the safe call is the easy one, rather than relying on the author remembering), and use lazy %s formatting so the string is only built when the level is enabled. Two further habits belong in the same file: logger.exception(...) inside except so the traceback is attached, and logging the run's seed, config hash, row counts and date range so the result is reproducible at all.

P13. What mypy sees. latest_price(ticker: str) -> float | None is called as dps / latest_price(ticker). What does mypy report, what are the two legitimate fixes, and what is the illegitimate one?

Solution. It reports Unsupported operand types for / ("float" and "None"), statically, with no test data and no execution. The two legitimate fixes: propagate the optionality (declare -> float | None and make the caller handle a missing price), or convert it to a loud failure (price = latest_price(t), then if price is None: raise KeyError(t)). The illegitimate one is # type: ignore, which turns a found bug into a hidden one. And note the limit: mypy would happily accept an inverted price / dps or a returned -9.99: types prove shape, never value.

P14. Classify the defect. Name the class (1–6) for each: (a) sklearn.metrics.rmse(...); (b) 1 - min(c)/max(c) as maximum drawdown; (c) np.std(r) in a Sharpe ratio; (d) pickle.load(open(downloaded)); (e) a docstring claiming NaN handling that does not exist; (f) except: pass.

Solution. (a) Class 1, hallucinated API. (b) Class 2, happy-path-only logic. (c) Class 3, silent numeric defect (ddof=0 where the sample estimator was wanted). (d) Class 4, insecure pattern, deserialising untrusted data is arbitrary code execution. (e) Class 5, over-confident comment. (f) Class 6, untestable structure, the traceback is swallowed, so the failure becomes invisible.

P15. Gate routing. For each defect class, name the REVIEW gate that is most likely to catch it, and state which gate is most often skipped and why.

Solution. Class 1 → E (Execute) and V (Check APIs). Class 2 → E, provided you execute a second input rather than the prompt's. Class 3 → I (Interrogate the numbers). Class 4 → E (Examine the security surface). Class 5 → R (Read) combined with W (Write the test for each docstring claim). Class 6 → W, because the difficulty of writing the test is the finding. The most-skipped gate is I: every other gate is reading or running, and I is the only one that demands independent arithmetic, which is precisely why the defects it catches are the ones that reach production.

P16. The bug report. A colleague says "the volatility number is wrong for the March data." What is the first thing you do, and why is it not opening a debugger?

Solution. Turn the sentence into a failing test that reproduces the reported behaviour. Three reasons it comes first: if you cannot write it, you do not yet understand the report (and should go ask, not guess); once written it is an unambiguous, shareable definition of "fixed"; and it becomes the permanent regression test that stops the same defect returning next quarter. Reproduce, then fix, then watch it go green, every bug earns a test before it earns a patch.

P17. Coverage. A colleague reports 100% line coverage on a module and proposes shipping. Construct a suite that scores 100% and proves nothing, then say what you would actually read.

Solution. A single test that calls every public function with valid arguments and asserts nothing (or asserts only is not None) executes every line and scores 100%. Coverage counts lines executed, not claims asserted. What to read instead: how many assertions exist per behaviour; where each expected value came from (P7's oracle question); whether error paths and boundaries are exercised; and whether any property/invariant is asserted. Coverage is a floor detector, good at "is anything never exercised at all?", and worthless as a quality target, because tests written to raise the number are exactly the tests that assert nothing.

P18. Arithmetic versus geometric. A stock returns +50% then −50%. Give the arithmetic mean return and the actual compounded outcome. Then: a ₹1,250.00 position (explicitly synthetic) grows to ₹2,180.00 over exactly five years, give the CAGR, and give the terminal value the naive average would have implied.

Solution. The arithmetic mean is 0%; the compounded outcome is 1.5 × 0.5 = 0.75, a 25% loss. For the ₹ series: CAGR = (2180/1250)^(1/5) − 1 = 1.744^0.2 − 1 = 0.117659 (11.766%). The naive average is (1.744 − 1)/5 = 14.88%, which compounds ₹1,250 to 1250 × 1.1488⁵ = ₹2,501.11: ₹321 above the actual ₹2,180. Any function that averages returns must state in its docstring which mean it means, and the test must be built from the geometric oracle.

P19. Routing (productivity, R10). Rank these four tasks by how freely you would delegate them to a copilot, and give the one-line rule behind your ranking: (i) a pyproject.toml; (ii) an argparse block; (iii) a Sharpe-ratio implementation; (iv) the step in a backtest that lags a signal before applying it.

Solution. Delegate most freely (i) and (ii), boilerplate whose correctness is cheap to check because failure is loud and immediate. Delegate (iii) only with the full REVIEW budget: its failure mode is a plausible float and checking it requires independent arithmetic. Delegate (iv) least, or not at all: a one-index error there is look-ahead bias, it is silent, it inflates every downstream result, and it is expensive to detect (which is why QD1.03 ships a dedicated no_lookahead harness). The rule: delegate in inverse proportion to the cost of checking the answer, and spend the time you saved on the checking, not on more generating.


Applied mini-project

Inherit perf.py and make it trustworthy.

You have joined a small research desk. In the repository sits perf.py, generated by an assistant some months ago, committed without review, and now quietly feeding a weekly performance note. Nobody has touched it. Everything it produces looks plausible.

# perf.py - generated by an assistant, committed without review
import statistics, math

TRADING_DAYS = 252

def returns(prices, out=[]):
    """Simple returns. Ignores missing prices."""
    for i in range(len(prices)):
        out.append(prices[i] / prices[i - 1] - 1)
    return out

def annualised_vol(rets, periods_per_year):
    """Annualised volatility in percent."""
    return statistics.pstdev(rets) * math.sqrt(TRADING_DAYS)

def max_drawdown(curve):
    """Maximum peak-to-trough decline."""
    return 1 - min(curve) / max(curve)

def summary(ticker, conn):
    sql = f"SELECT close FROM prices WHERE ticker = '{ticker}'"
    rows = conn.execute(sql).fetchall()
    print("summary for", ticker, "using key", API_KEY)
    try:
        return {"vol": annualised_vol(returns([r[0] for r in rows]), 252)}
    except:
        pass

There are ten substantive defects here, spanning all six classes. Your job is not to rewrite the file from scratch, that is the move that loses the knowledge. Your job is to make its failures loud, then fix them, then leave behind a suite that will not let them come back.

Do this, in order:

  1. REVIEW it on paper first (gates R and V). Read every line and list, without running anything, every claim the code or its docstrings make. Verify each API against the docs for your pinned Python version.
  2. Execute it (gate E), on a second input. Feed it a short price list of your own construction, at least one series that rises monotonically, and one where the trough precedes the peak.
  3. Interrogate the numbers (gate I). Compute, by hand, the correct simple returns, the correct annualised volatility, and the correct maximum drawdown for a small ₹-denominated series and a small $-denominated series (R6, one of each, and state the units in your write-up).
  4. Examine the security surface (gate E). Name every place external input reaches an interpreter, a filesystem, a log, or a network, and every place a credential appears.
  5. Write the failing test for every defect (gate W): before any fix. Watch each one go red for the reason you predicted. Every expected value must come from a named oracle: a hand computation, an independent implementation, a published reference, or a property. State which, per test.
  6. Fix, one red at a time, and refactor with the suite holding the behaviour still.
  7. Harden the stack: type hints on every public function with mypy clean; ruff check clean; a __name__ logger replacing print, with logger.exception in the error path, the secret redacted, and the run's seed/config/row-counts logged.
  8. Package it: src/ layout, pyproject.toml, editable install, and one command that runs pytest && ruff check . && mypy src.
  9. Write the review memo: one page, each defect named by class (1–6) and by the REVIEW gate that catches it, plus one sentence on how a test would have prevented it reaching the weekly note.

Bring-your-own-AI extension (R10). Do steps 1–5 entirely unaided; that is the gate. Only then may you paste perf.py into your own assistant and ask it for a defect list. Compare its list to yours: what did it find that you missed, what did you find that it missed, and (the interesting one) what did it confidently claim was a defect that is not? Then have it draft the corrected implementation, run all six REVIEW gates on that draft, and grade the result against your own test suite, which it never saw. The app never calls a model; your tool, your prompt, your review, our tests.

Deliverable and rubric (20 points; crown pass at 18/20, with no zero on any line).

#CriterionPts
1A failing test exists for each of the ten defects, and each was observed red before its fix2
2Every expected value is traced to a named oracle; none was harvested by running the code under test2
3All ten defects fixed; the suite is green and the fixes are minimal2
4At least three property/invariant assertions (e.g. drawdown ∈ [0,1); monotone ⇒ 0; sqrt(k) scaling)2
5Both an India (₹) and a US ($) worked case, with units stated (R6)2
6Type hints on every public function; mypy clean, with no # type: ignore2
7ruff check clean and formatter applied; the mutable default and bare except are gone2
8Logging: __name__ logger, correct levels, logger.exception in the error path, secret redacted, seed/config logged2
9SQL parameterised, with a test proving the payload never reaches the SQL text2
10One-page review memo naming every defect by class (1–6) and by the REVIEW gate that catches it2

Self-check before you score. Delete any one of your fixes at random and re-run the suite. If it stays green, that fix had no test and line 1 scores zero for it. This is the only honest way to audit your own suite, and it is the exercise's real lesson: a test you did not watch fail is a test you do not have.

Lab: the nine graded exercises of this node's code pack: CS2.06-e01 … CS2.06-e09 in app/public/code-packs/CS2.06.json, are the rehearsal for every step above, and CS2.06-e09 is the bring-your-own-AI extension in graded form.


Reading & resources

Tags: ✓ free · $ paid · ▲ beginner · ▼ advanced. Everything below is an anchor to learn from and verify against; nothing here is a substitute for reading the documentation of the exact version you have pinned.

Testing and TDD (the core spine):

  • pytest official documentation. "Get Started," then fixtures, parametrize, pytest.approx/pytest.raises. The single most useful bookmark in this module. ✓ ▲
  • Brian Okken, Python Testing with pytest: the standard book-length treatment; the parametrize and fixture chapters repay their cost immediately. $ ▲
  • Kent Beck, Test-Driven Development: By Example: the original red-green-refactor argument. Read it for the rhythm, not the Java. $ ▲
  • Hypothesis documentation (property-based testing), the industrial version of this module's "assert an invariant": you declare the property, the library hunts for the input that breaks it. ✓ ▼

Clean code, types and tooling:

  • PEP 8 (style) and PEP 484 / PEP 604 (type hints, X | None), the primary sources, short enough to read once end to end. ✓ ▲
  • mypy docs, especially "Type hints cheat sheet" and "Common issues", where Worked Example 7's Optional error is explained properly. ✓ ▲
  • ruff's rule index and black's "The Black code style", read the rule list: it is a catalogue of known bug shapes, and skimming it is the fastest way to learn what a linter can see. ✓ ▲
  • Robert C. Martin, Clean Code: influential and worth arguing with; take the naming and function-size chapters. $ ▲
  • Ousterhout, A Philosophy of Software Design: the sharpest counterweight to Clean Code's dogma; "deep modules" is the idea to steal. $ ▼

Logging, packaging and reproducibility:

  • The logging HOWTO and Logging Cookbook: levels, __name__ loggers, logger.exception, configuring handlers at the entry point. ✓ ▲
  • The Python Packaging User Guide (pyproject.toml, src/ layout, editable installs) and PEP 621. ✓ ▲
  • Semantic Versioning (semver.org), three paragraphs defining what an upgrade may do to your callers. ✓ ▲

Reviewing AI-generated code (the crown unit):

  • OWASP Top Ten and the OWASP Cheat Sheet Series (SQL injection prevention, deserialisation, secrets management), the canonical catalogue behind Class 4. ✓ ▲–▼
  • CWE Top 25 Most Dangerous Software Weaknesses, the same territory, formalised; use it to name what you found. ✓ ▼
  • Bandit (Python security linter) and pip-audit / safety, the automatable slice of REVIEW gate E. ✓ ▲
  • Studies on the security and correctness of AI-generated code (e.g. the "Asleep at the Keyboard" work on Copilot-generated vulnerabilities, and its replications), read at least one, noting that the rates are perishable while the defect classes are not. ✓ ▼
  • AI0.01 (this program), the mechanism behind all of it: a model optimises for plausible, not true. AI0.06 drills the same discipline on prose and figures. ✓ ▲

Where the next layer lives (C-3 boundary): the statistics behind ddof, the square-root-of-time rule, and the Sharpe ratio itself belong to QM1.01 and QM1.03; the code that industrialises them is DA1.05, QD2.03 (performance and attribution) and QD2.02 (risk systems). Cross-link, never re-derive.

Do this, not just read: take any function you wrote in CS1.01–CS1.04, add a tests/ directory, and write one test per branch of the tree in §"What to test", contract, equivalence class, boundary, error path, invariant. Then break the function deliberately, one character at a time, and check that something goes red. Twenty minutes of deliberately breaking your own code teaches more about test quality than any amount of reading, because it measures the only thing that matters: what would this suite fail to notice?


Teach it back & journal

Feynman prompt. Explain to a competent colleague who does not write tests, and who has just started pasting AI-generated functions into a live notebook, why "it ran and the number looked right" is not verification. Use the sentence "code is a claim; a test is the evidence," and make it concrete with the max_drawdown case: show them 1 - min(curve)/max(curve), ask them to try [100, 110, 121], and let the impossible 17.4% drawdown on a curve that never fell do the arguing for you. Then push one level further: explain why the small defect, the ddof slip that makes a Sharpe 4.4% too high, is more dangerous than the large one, because a Sharpe of 6.8 triggers disbelief and a Sharpe of 1.48 passes every review. If your colleague can, unprompted, ask you "where did your expected value come from?" about the next number you show them, you have installed the oracle habit, which is the point of the whole module.

Journal prompt. For one working week, run the REVIEW protocol on every piece of code you accept from an assistant (every completion, every pasted function, every "fix this" suggestion) and keep a tally. How many hallucinated APIs did you find, and did any of them survive to a second file? How many defects were caught by gate E (execute a second input) versus gate I (interrogate the numbers), and which gate did you skip when you were in a hurry? Which defect class showed up most often in your domain, and does that change what you will delegate next week? Then write a paragraph on this: the cost of generating code has collapsed and the cost of verifying it has not moved at all, so the value of an analyst has migrated almost entirely into the second activity. Which single habit from this node, writing the failing test first, demanding an oracle for every expected number, or executing on a second input before reading a line, would most change what a senior colleague thinks of your work, and what will you do this week to make it automatic rather than remembered?


This module's flashcards and mastery quiz are wired into the app: see the node's Quiz and Reviews.

Advertisement

The gate

The module's flashcards and its quiz are in the app. The quiz is the gate: you need 90% to pass it, and the next module opens when you do.