Our ISDA Standard Initial Margin Model (SIMM) calculator has run in Python for several years. We planned to move it into cpStudio, our cross-asset pricing and risk platform, but we had not decided which language to move it to.

Instead of choosing in advance, we ran the port three times: once in F#, once in idiomatic C#, and once in a deliberately immutable style of C#. Each port ran on its own Git branch. Claude Code built each one autonomously, with no human intervention during the build.

Design constraints set before implementation

Porting the same code three times introduces two risks:

  • Whatever the first port produces becomes an informal template for the others.
  • Structural choices in the source language carry over by default rather than by decision.

To limit both risks, we set two constraints before any code was written.

Do not inherit structure from an unrelated project

Another regulatory calculator already existed in cpStudio, ported using the same three-branch approach. Reusing that project's file layout would have been convenient. We prohibited it. Its modules are named for concepts in its own domain, which has little in common with SIMM. Applying one calculator's structure to another looks like reuse, but it produces a worse design.

Do not assume the Python file boundaries are correct

The Python engine contains 12 nearly identical files: one for each asset class, multiplied by delta, vega, and curvature calculations. Rather than request a direct transcription, we asked each agent to determine whether the underlying mathematics was genuinely shared, and to design accordingly.

It is shared. Every delta and vega file reduces to the same two-step calculation: a within-bucket capital figure, followed by a cross-bucket margin over a sum clipped by a concentration ratio. Only the calibration inputs differ by asset class.

All three ports identified this independently. Each extracted the shared calculation, and all three arrived at nearly identical signatures for the core function:

// F# - Aggregation.sumSquaresPlusCross
let sumSquaresPlusCross (rho: 'k -> 'k -> float) (ws: ('k * float) list) : float
// C# - SimmMath.QuadraticForm
public static double QuadraticForm<TKey>(
    IReadOnlyDictionary<TKey, double> ws, Func<TKey, TKey, double> rho)
// C# immutable - Aggregation.WithinBucketK
public static double WithinBucketK<TKey>(
    ImmutableDictionary<TKey, double> ws, Func<TKey, TKey, double> rho)

Each function takes the same two arguments: the weighted sensitivities, and a correlation function supplied as a closure so that each risk class can provide its own. The differences are idiomatic. F# uses a list of tuples, idiomatic C# uses a dictionary interface, and the immutable port uses an immutable dictionary. Two functions return the value that goes under the square root, and the third takes the root itself.

Each port also drew the same boundary around what not to unify. Threshold lookups, per-class correlation formulas, and the sigma conversion used in vega calculations differ genuinely between risk classes. All three ports kept those separate rather than forcing a shared abstraction. No instruction specified this design.

Code size and complexity

Size

Codebase Files Lines of code Change from Python
Python (in scope) 20 2,821 —
F# 14 2,201 −22%
C# 52 1,970 −30%
C# immutable 31 1,745 −38%

All three ports contain fewer lines than the Python they replace. This result is unexpected, because C# and F# are more verbose languages by most measures. Removing 12 duplicated implementations saved more than the additional syntax cost. The immutable port is the shortest of the three, which also runs counter to the assumption that immutability requires additional boilerplate. C# positional records are compact.

The file counts show the difference in structural approach more clearly than the line counts. F# averages 157 lines across 14 modules. Idiomatic C# averages 38 lines across 52 smaller classes. The two ports implement the same logic at similar total size, but organise it differently.

Complexity

Two metrics, briefly. Cyclomatic complexity counts the distinct paths through a function: every if, loop, and/or and pattern-match branch adds one. Score 50 and there are fifty cases to reason about and fifty routes a test suite has to cover. The usual guidance is that above 10 deserves a look and above 20 wants refactoring. Nesting depth counts how many structures you are inside at the deepest point — a loop in an if in a loop is 3 — and tracks readability most directly, because every level is another condition to keep in mind while reading what is below it.

We report the median, the 95th percentile and the worst case, because averages conceal exactly the functions you care about. All four codebases were measured with one rule set of our own rather than a Python-specific tool for the Python and a .NET analyser for the ports, for reasons given below.

Codebase Cyclomatic: median / mean / 95th / max Nesting: median / max
Python 3 / 5.5 / 22 / 50 2 / 9
F# 2 / 2.7 / 6 / 10 1 / 7
C# 2 / 3.9 / 10 / 30 1 / 6
C# immutable 3 / 3.2 / 6 / 29 1 / 3

The medians are the same across all four codebases. A function selected at random is equally simple in any of the four languages. The differences appear at the extremes.

The most complex Python function has 50 paths through it and 9 levels of nesting. One in 20 Python functions scores above 22, which exceeds the refactoring threshold described earlier. The equivalent figures for the ports range from 6 to 10.

In practical terms, all four codebases are readable most of the time. The Python contains a small number of locations that require close attention, and the ports largely do not. This result reflects the duplication described earlier: each of the 12 nearly identical files contained its own branching aggregation logic, so consolidating them reduced the concentration of complexity rather than the average.

The immutable port produces the strongest result on nesting depth: a maximum of 3 and a 95th percentile of 2. A pipeline of pure functions provides no location for a nested mutation loop. The immutable port also has the longest median function and the shortest maximum function, making it the most uniform of the four codebases.

Three limitations, none of which changes the conclusion: F#'s function count includes local let bindings that C# would express inline, so its per-function figures are computed over smaller units and flatter themselves; our C# method detection uses regular expressions and misses some expression-bodied members; and branch counting favours Python, because pandas does its per-row branching inside compiled C where we cannot see it. Python understates its own branching and still comes last.

Correctness criteria

Regulatory margin figures require exact reproduction rather than approximation. The standard we set was to reproduce the output of the live Python engine.

The ports therefore use the same formulas, the same float type throughout, and the same inverse normal cumulative distribution function in the curvature margin calculation. We did not convert to decimal. The quantities involved are already the products of square roots and correlation weights, and Python's float is the same 64-bit IEEE 754 double that .NET provides.

We found early that the Python test suite's expected-results file was out of date, and that a weighting update had been done recently. All three agents caught this independently and, rather than validating against a broken target, each rebuilt a fresh reference by running the live Python engine. That is the correct instinct, and none of them were told to have it.

Final validation used a production-scale trade book containing tens of thousands of sensitivity rows, several thousand trades, and several dozen counterparties. The engine implements two methods:

  • Sensitivity-based SIMM: the risk-weighted, correlation-aggregated ISDA model that most of this document describes.
  • Schedule-based initial margin: a simpler calculation used where the model method does not apply.

Each method supports three operations:

  • Base run: a base calculation on the standard CRIF file as input.
  • Compare: a comparison against an altered trade book, where a chunk of the trades were removed to simulate a business case in which trades suddenly drop off.
  • Impact analysis: a per-trade marginal impact analysis, where each trade is removed iteratively and the total recalculated to assess that trade's impact on the SIMM amount.

That produces six combinations for each of the three ports, or 18 comparisons against the Python reference. All 18 matched Python to between 1e-13 and 1e-16 relative deviation on every figure both sides produced, other than columns holding differences between two near-equal values, where the small denominator inflates the relative figure to as much as 1e-5.

Bit-level identity was never achievable, and it is worth knowing why before anyone claims otherwise. NumPy sums pairwise where a direct loop sums left to right, so the last few bits always differ. Nothing close to operationally significant, but treat any reimplementation claiming bit-identical agreement with a pandas original with caution.

Independent verification of the agent reports

Each autonomous build produced a report describing what it had implemented. We treated none of these reports as authoritative. Instead, a separate agent audited each branch, with instructions to rebuild the code, rerun the tests, and verify each claim against the repository rather than against the other reports.

The audit found three discrepancies:

  • The F# port exposed only the base calculation through its public interface, even though the comparison and impact-analysis operations were fully implemented beneath it. The facade was planned, never completed, and never reported as incomplete.
  • The C# port's report gave an incorrect count of its own test suite.
  • The immutable C# port had dropped a feature listed in its own plan. The audit identified this only because the planning document still described functionality the code did not contain.

None of these were incorrect calculations. In each case, the report stated that work was complete when it was not. Reading the report could not reveal this. Rerunning the code could. If you take one operational lesson from this exercise, this is the one.

Performance measurement

We measured this wrong twice. Both times we got a clean, plausible, internally consistent table, and the two tables ranked the three languages differently. It is worth showing why, because both mistakes are easy to make.

First error: timing the file read with the calculation

The first harness measured each operation from reading the CRIF (Common Risk Interchange Format) file through to a result in memory, which seemed like the honest definition.

The CRIF parse dominates every short operation. All three ports use the same library, CsvHelper, but each configures and consumes it differently enough to produce a 3.6-fold difference in parse time. The resulting table ranked the three languages in exactly the order of their parse times, and therefore measured the CSV reader rather than the margin calculation.

Second error: insufficient warm-up iterations

The second attempt moved the parse outside the timer and used five warm iterations after a discarded cold one. Standard enough. But one port then reported that a base run, a second run over an altered book, and a six-sheet diff between them together cost about 1.2 times a single base run. Two runs plus a merge cannot cost 1.2 of one run.

The culprit was .NET warming up, and it is worth spelling out because it is easy to under-estimate how long it takes. .NET does not ship machine code, it ships an intermediate language, and the runtime compiles that to real instructions while the program runs. Compiling well is expensive, so it does the job twice: the first calls into a method get a fast, barely-optimised version, and once the method has been called enough times the runtime decides it is worth the investment and recompiles it properly. On top of that it watches the cheap version running — which branches actually get taken, which concrete type actually turns up at an interface call — and optimises against what it observed. So the same method genuinely executes different machine code at call five than at call five hundred.

Which is what wrecked the ratio. We timed the base run first, so it absorbed almost all the warm-up cost, because it was the thing calling those methods for the first time. By the time the compare operation ran — and internally that does two base runs — the runtime had already optimised exactly the methods it needed. The numerator was fine. The denominator was inflated.

You can watch it happen. Here are ordinary C#'s per-decile medians for a base run across 300 warm iterations, in milliseconds:

28.5  15.5  12.8  12.6  12.0  11.8  11.8  12.7  11.9  12.2

The first decile is 2.4 times the settled value. A five-iteration harness never gets out of that first number. Three hundred iterations finally settled, and the ratio came out at 2.3–2.5x, which is what two runs and a merge ought to cost. Between 5 and 300 iterations the cheap operations dropped by somewhere between 1.5x and 2.6x depending on the port.

The thing that caught it was a ratio, not a number. Nothing about any individual timing looked wrong.

Final measurement protocol

The third harness parsed the inputs and built the altered trade books once, outside the timed region, then ran 300 warm iterations of each sub-second operation and 10 of the slow one, discarding the first and taking the median of the back half. It also recorded whether each operation's timings had actually stopped falling, which is the question the first two attempts never asked.

One addition mattered more than the rest. Every iteration's output was fingerprinted once its timer stopped, and every fingerprint for a given operation matched, so the result we later compared against Python is provably the one the timed runs produced. Without that, "this port is correct" and "this port takes 12 ms" are two claims about two different runs.

Parse, warm

Input Python F# C# C# immutable
SIMM CRIF (26,470 rows) 42 ms 70 ms 254 ms 163 ms
IMCSA agreements 0.8 ms 0.3 ms 0.3 ms 2.1 ms

The schedule-method input is 92 rows and parses in under 3 ms everywhere, so it is left out of this table.

Calculation only

Operation Python F# C# C# immutable
SIMM base run ~5.86 s 18.2 ms 12.0 ms 12.4 ms
SIMM compare ~10.1 s 42.1 ms 30.7 ms 30.7 ms
SIMM impact analysis 588 s ~4.0 s ~2.5 s ~3.1 s
Schedule base run ~10 ms 0.056 ms 0.069 ms 0.076 ms
Schedule compare ~20 ms 0.098 ms 0.076 ms 0.123 ms
Schedule impact analysis ~976 ms 1.19 ms 0.48 ms 0.68 ms

Parse plus calculation, which is what someone actually waits for

Operation Python F# C# C# immutable
SIMM base run 5.90 s 89 ms 266 ms 177 ms
SIMM compare 10.16 s 113 ms 285 ms 196 ms
SIMM impact analysis 588 s 4.07 s 2.75 s 3.27 s
Schedule base run 12 ms 0.43 ms 1.01 ms 2.88 ms
Schedule compare 22 ms 0.47 ms 1.02 ms 2.92 ms
Schedule impact analysis 978 ms 1.56 ms 1.42 ms 3.48 ms

Leaving Python is worth 150 to 500 times on calculation across the SIMM operations. Our earlier and much more modest figure was the first mistake talking: .NET's slower parsing had been hiding most of its own advantage. End to end it is smaller but still decisive, 22 to 66 times on a base run and around 145 to 215 on impact analysis.

There isn't a fastest language, though, which is the part we would have got wrong if we had stopped at one table. On the short operations F# wins end to end, 89 ms against C#'s 266, while being the slowest of the three at actual calculation — its parsing is 3.6x quicker and parsing is about 80% of a base run. On impact analysis, where the calculation runs for seconds and the loader stops mattering, C# wins at 2.75 s against F#'s 4.07. Which one is "faster" is a question about your workload, dressed up as a question about languages.

And parsing is now the bottleneck, not the maths. An ordinary C# base run spends 254 ms reading a file to feed 12 ms of calculation. If we wanted these ports quicker, the loader is where to go; the ISDA aggregation is not the problem.

The parse spread between the ports is all design rather than library, since they share the library. F# is fastest because it keeps dates as strings and defers parsing to the one filter that needs them, skipping tens of thousands of date conversions the C# ports both do eagerly at load time. The immutable port beats ordinary C# because it uses CsvHelper's compiled record mapping rather than looking up each field by column name. Ordinary C# pays both costs, which is why it is slowest.

Pandas beating all three at parsing was not what we expected, and that one is architectural rather than fixable. It builds contiguous columns in C and converts whole date columns in a single pass, where the .NET ports allocate an object per row. It is the same property that makes pandas lose the calculation by two to three orders of magnitude: a fast columnar engine for moving bulk data, wrapped around a slow interpreter for the row-by-row arithmetic SIMM actually wants.

On immutability, which was the whole point of having a third branch: it is essentially free. 12.0 ms against 12.4 on a base run, 30.7 against 30.7 on compare. The cost turns up in allocation instead.

Allocated per call C# C# immutable F#
SIMM base run 11.9 MB 13.8 MB 21.0 MB
SIMM impact analysis 4.02 GB 4.88 GB 7.54 GB

About a fifth more garbage for the immutable style and no measurable time penalty. F# is the heavy one, roughly 1.5x the immutable port and 1.9x ordinary C# on both measurements, and that explains something we had been puzzled by: F#'s impact analysis was the only operation that did not get faster with more warm-up. It is bound by memory bandwidth and collection, not by JIT.

Everything above is steady-state, and cold start is much worse. The immutable port's first impact analysis in a fresh process takes 37 to 48 seconds against about 3 warm. If you are running a service that stays up, that is a startup detail. If you are not, it is the only number that matters.

The warm figure is still the one that changes what the feature is. Ten minutes gets run reluctantly, once, when someone insists. Three seconds gets run constantly.

A few things to hold lightly. Python's impact-analysis timing includes building an Excel workbook in memory, because that function does not separate calculation from output — at 588 seconds against 3 that is not what is driving the gap, but it is not a clean comparison either. The impact-analysis row moves 10–15% run to run in all three ports, so read it as a range. And the sub-millisecond schedule figures are close enough to our measurement floor that we would not defend them to two significant figures.

What it cost

Port Wall clock Cost
F# 16 min $13.34
C# immutable 63 min $26.91
C# idiomatic 77 min $41.25

All three got to the same validated place. The C# runs took four to five times as long as the F# one and cost two to three times as much. We do not know why, and we are not going to invent a reason, but it is a real measured spread across three runs of the same job.

Worth being careful with those numbers, though, because they are the build only. They do not include the audits that found the unfinished facade, or the work to close the gaps, or the cross-validation, or the three attempts at benchmarking. All of that took longer put together than the builds did. If you want the honest cost of an AI-written port you would actually put in front of a client, it is the build plus a proper verification pass, and the build is the small half.

What we'd keep

Three correct ports of a business-critical calculator, all shorter than the original, all with a flatter complexity profile, all two to three orders of magnitude faster, from under three hours of build time.

That came out of a few specific things. Deciding what "correct" meant before any code existed. Refusing to let another project's structure, or Python's own layout, decide the design. Never accepting a self-reported "done" without rerunning the code.

And being as suspicious of our own measurements as we were of the agent's claims, which took us two goes to learn. The first performance table measured a CSV parser and called it a margin engine. The second used five warm iterations because five looked like plenty. Both were plausible, both were internally consistent, and both would have been published if nobody had thought to check the ratios between the numbers instead of the numbers themselves.

The AI didn't put those errors there. We did.