All posts
7 min read

A trading risk engine as a pure function

How KAI Terminal's automated risk engine stays correct under fire: all the decisions live in a pure, deterministic function, and the I/O stays dumb. A functional core, imperative shell story.

dotnetarchitecturetestingrisk-engine

KAI Terminal sells index options, and the scariest code in the whole product is the bit that decides — automatically, while the market moves — when to exit a position or roll a strike. Get it wrong and you don't get a stack trace, you get a loss. So the design goal for that code wasn't cleverness. It was confidence: I want to be able to prove, with plain unit tests and no mocks, that a given market state produces a given decision.

The way we get there is an old idea — functional core, imperative shell — applied strictly to a real-time risk engine.

The decision is pure. The I/O is dumb.

The engine splits into two halves that never blur together.

The core is a pure evaluator. Given the current mark-to-market, the current time, and the rule's own prior state, it returns a decision and the next state. No database, no broker, no socket, no clock of its own — "now" is passed in. Same inputs, same output, every time.

// Shape only — illustrative, not the real thresholds.
public static RuleEvaluation Evaluate(
    RuleConfig rule,
    decimal markToMarket,
    DateTimeOffset now,
    TrailState prior)   // immutable snapshot from the last tick
{
    // ...pure decisioning: should we exit? hold? roll?
    // returns the decision AND the next TrailState — never mutates `prior`
}

That signature is the whole philosophy. The evaluator can't reach out for anything, so there's nothing to stub. A test is just: build a config, hand it a number and a timestamp, assert on the result.

[Fact]
public void Exits_once_loss_breaches_the_stop()
{
    var next = Evaluator.Evaluate(rule, markToMarket: -5000m, now, prior);
    Assert.Equal(Action.Exit, next.Action);
}

The shell is a background worker. It does only I/O: reload the armed rules, re-fetch each broker's live book, re-mark the position off the live last-traded price, call the evaluator, and — if the decision says so — place the exit. Then it persists the returned state for the next tick. The shell has no business ifs. It's deliberately boring.

tick → re-mark MTM → Evaluate(...) → (maybe) exit/roll → persist next state
        └─ shell ─┘   └─ core ─┘      └──── shell ────┘

Why "pure" earns its keep here

Three things fall out of this split, and each one matters more in a trading system than in a typical CRUD app.

Trailing logic becomes inspectable. A trailing stop has memory — it ratchets a floor upward as profit rises and never lets it fall. That "memory" is the TrailState we thread through. Because advancing it is a pure transition (prior in, next out, no mutation), the ratchet is just a function you can table-test across a whole price path. The state is data, not a field somebody mutates from three places.

Time stops being a source of flakiness. A time-based square-off ("flatten everything near the close") is notoriously annoying to test when the code reads the wall clock. Here the clock is an argument. Want to test the 3:20pm behaviour? Pass 3:20pm. We inject TimeProvider at the very edge of the system and nowhere else, so every time-dependent rule is deterministic.

Mid-session edits don't corrupt state. If a user retunes a rule while it's live, some transient state must reset and some must survive. Deciding which is itself a pure function — config-before and config-after in, the reset plan out. That used to be the kind of thing that hides a bug for months.

The bug that made me a believer

Early on, the mark-to-market math for a closed position lived inline in the worker — a few lines mixed in with the fetch-and-loop I/O. It had a subtle error, and because it was tangled up with broker calls, it was effectively untestable. It hid for weeks.

The fix wasn't just correcting the arithmetic. It was extracting it into a pure MtmCalculator with its own tests. The lesson generalised into a rule we now apply everywhere:

When you find non-trivial logic embedded in a worker, endpoint, or provider — extract a pure helper and test it.

The heuristic we use to draw the line is one question:

Could I unit-test this without a mock?

If yes, it's core — keep it pure. If it needs a mock (a broker, a DB, a clock, a socket), it's shell — keep it dumb, with no business logic in it.

It's not just the engine

Once you adopt the test, you start seeing "core" everywhere, not only in the risk loop:

  • Calculations — the blended P&L roll-up across brokers, the mark-to-market.
  • Selection — which strike to pick, which slice of the live book a rule actually watches.
  • Ordering — the safe exit sequence (close shorts first, then longs) computed as a pure plan against the live book before any order goes out.
  • Parsing — every broker response is parsed by a pure function, separate from the one HTTP call that fetched it, so the parser is testable on a captured payload.

Each of those is a small pure function with a matching test, and the imperative shell around them just moves bytes.

The trade-off

Purity has a cost: you pass a lot of state in and thread results out instead of reaching for it where you stand. There's more plumbing in the shell, and the core can feel verbose. For most apps that tax isn't worth it.

For code that moves real money on a live market, it absolutely is. I'll take a slightly chattier function signature in exchange for being able to prove the exit fires — before it ever has to.

SR

Suvrajit Ray

Founder & Engineer — KAI Terminal

Open to opportunities

I build low-latency trading systems end to end — a .NET real-time risk engine and a React/Next.js cockpit for Indian index-options sellers.