What It Takes to Tell a Trader Their Risk Is Still Zero - Part 1: The Backend

Two-part engineering notes from a Findev team building a pre-trade risk and allocation platform for a buy-side client. Zero, here, means zero deviation from the account's bogey, not the absence of market risk. Part one covers the backend. Part two covers what happens once those numbers have to land on a screen a trader is staring at.

For context: the backend is .NET running on AWS EKS, the frontend is React, and the stack running underneath includes Kafka, Valkey, SignalR, and AG Grid, all covered in what follows. None of what's hard about this system is specific to that stack. The same graph-shaped calculation problem and the same live-grid consistency problem would have to be solved whatever the backend or frontend were written in.

What the platform is for

Institutional fixed income managers don't just buy and sell bonds against a single house view. Each account is typically managed against a target, often called a bogey: a defined duration and risk profile the account is expected to track, set per client or per mandate. A single portfolio manager or trading desk runs that comparison across many accounts at once, each one drifting from its own bogey in its own way.

The moment a trade is on the table, every one of those accounts has to be repriced against its own bogey, not against one shared number.

The platform sits between "we want to do this trade" and execution: given a prospective trade, it sizes and allocates it across the accounts it touches, and computes each account's resulting risk profile. Two motions come out of that:

  • Hedging: sizing so an account's deviation from its bogey stays at zero.
  • A deliberate risk shift: sizing so the account's risk moves against its bogey on purpose.

Cash flow matters as much as the trade itself: known future deposits and withdrawals change what the right allocation size is today, so sizing against current positions alone and ignoring next week's redemption gives the wrong number.

A single working session can involve up to 5,000 allocations, with a user-configurable set of data columns computed per allocation, typically around 200: duration, DV01, and every other risk figure a portfolio manager needs to see before approving it (the average session runs closer to 400 to 500 allocations). Most of those columns aren't static reference data: they're the direct answer to "what does this account's risk look like if we do this," and, in principle, any one of them can depend on any other. A handful are plain security attributes a user adds directly, a price, a duration, a currency, with no calculation behind them.

That's why the platform is hard to build twice over: once as a calculation problem, once as the screen a trader stares at while it runs. Part one covers the first, part two the second.

Why the requirements
are harder than they look

Six things compound into one hard problem, not because any single one is exotic, but because they all hit the same engine at once.

  • Soft real-time latency A trader changes an input and expects results within seconds, not "eventually." There's no hard deadline in the industrial-control sense, but a slow answer reads as a broken tool.
  • A dependency graph, not a list Risk metrics aren't independent numbers computed one at a time. They form a densely connected graph: recomputing one value can trigger recomputation of several others. Treat it like a flat list of fields and the system either recomputes far more than it needs to, or misses something downstream. There's a second dimension to that graph on top of which fields depend on which: when a value changes, the system also has to work out which other allocations it affects, since account, security, and formula references can all link allocations to each other, and that scope has to be resolved before the system knows what else needs recomputing.
  • Data sources that were never built for this The data behind a calculation lives across a handful of very different systems, none of them built for real-time reads. Response times run from tens of seconds to minutes, and batching often isn't supported at the source at all. The engine has to look fast even though most of what feeds it is not.
  • Volume Roughly 5,000 allocations and around 200 columns per allocation, the exact count is configurable per user, works out to on the order of five million data points to resolve per session, some from cache, some from a live source, most of them feeding into something else.
  • Per-instrument customization The same metric means a different calculation depending on what it's attached to. Duration on a futures position isn't computed the same way as duration on a bond, and neither matches duration on a sector fund. A naive implementation turns into a wall of conditionals, or a data model bloated with every field any instrument type might ever need.
  • Position-level overrides Price, bogey, and cash flow can each be overridden at the level of a single position. The architecture has to treat that as first-class, not as an edge case bolted on afterward.

The architecture

Risk calculation runs in its own stateless service in front of Kafka. It holds no session state; every request carries a full snapshot of its inputs, which is what makes it horizontally scalable and easy to restart.

Calculators are pure functions. Each one implements a single interface, roughly ICalculator<TModel, TResult>, exposing one method: Calculate(TModel model). A calculator has no mutable state and never calls an external service directly. Every input it needs, including the results of other calculators, is declared as an attribute on fields of its model class. That declaration is what the execution engine reads to build the graph.

This has a direct payoff for testing. A calculator with declared dependencies and no I/O can be unit tested by setting fields on a model and asserting on the return value. Nothing to mock, nothing to stand up.

The request goes through a fixed pipeline:

  1. 1. Analyze the incoming request and select the calculators the final result actually needs.
  2. 2. Assemble the dependency graph: calculators are nodes, declared model dependencies are edges.
  3. 3. Reduce the graph so shared subgraphs are computed once instead of once per consumer.
  4. 4. Order same-level nodes with a speed heuristic, so faster calculations run first.
  5. 5. Load external context: check the cache first, fall back to the source aggregator over a gRPC stream on a miss.
  6. 6. Evaluate the graph in parallel, running every node on the same level concurrently.
  7. 7. Publish each node's result to Kafka the moment it's ready, rather than waiting for the whole graph to finish.

That last step matters as much as the graph logic: results stream back incrementally rather than arriving as one block at the end, which is part of how soft real-time holds even at five million values a session.

Customization without a wall of if-statements. Take the duration example from the requirements table: futures, bonds, and sector funds each need their own formula, and the engine can't know which one applies until it knows what position it's actually looking at. Rather than hard-coding that as a chain of if checks inside every calculator that runs into it, a calculator like this declares a policy: a small piece of logic whose only job is to look at the position and decide which model variant actually applies. A policy can depend on other calculations, not just raw data, so that decision can itself be informed by earlier results in the graph.

Once it has decided, dispatch to the matching calculation logic goes through the Visitor pattern, a standard way to route to the right handler for whichever variant showed up, without the engine core growing a longer if/else chain every time a new instrument type is added.

Transform rules sit in front of all of this: they clean up and normalize incoming values before a calculator ever sees them, so the calculator itself never has to guard against messy input.

Caching, in two layers. The distributed cache is Valkey, a Redis-compatible in-memory key-value store.

  • The problem it doesn't solve on its own. Valkey is fast per key, but a batch read at this volume still takes tens of seconds, too slow for a soft-real-time budget on every request.
  • The fix. A local in-process layer sits in front of it: check memory, then Valkey, then the external source only on a double miss.
  • How it stays populated. Warmed on start with a simple heuristic: load whatever's been touched in the last three weeks.

Serialization uses MemoryPack, a high-throughput binary format, since intermediate values can reach the tens of millions and JSON at that volume would itself become a bottleneck.

Invalidating that cache across a cluster is its own problem: two replicas disagreeing means two traders seeing two different numbers for the same account. The fix is a leader/replica handshake, the leader waits for every replica to acknowledge before signaling invalidation complete.

One problem remains open: upstream systems don't emit change events, so intraday edits can go stale between refresh cycles with nothing to announce it.

Notes

Bogey. A benchmark account with a target risk profile, duration above all, that a real account is expected to track. Standard term in institutional fixed income, used by asset managers, insurers, and pension funds managing against a mandate.

Weighted-average duration. A position's or account's interest-rate sensitivity, expressed as the present-value-weighted average time until its cash flows arrive. The engine calculates how this number would change if a given trade were actually booked.

DV01. Dollar value of one basis point (also called PV01): the change in a position's present value if the yield curve shifts by 0.01 percentage points. A standard, linear measure of interest-rate risk used in pre-trade checks.

Soft real-time. Timing matters and a result is expected within a few seconds, but missing that window isn't a system failure. Weaker than hard real-time (industrial control), stronger than ordinary batch processing.

Stateless service. A process that keeps no client session state between messages. Every request carries a full snapshot of what it needs, which is what makes a service like this safe to scale horizontally and safe to restart.

Kafka. A distributed commit-log style message bus. Here it carries inbound calculation requests and outbound incremental results, decoupling the UI session from the compute workers.

Valkey. An open-source, Redis-compatible in-memory key-value store, used as the shared cache for source data and intermediate calculation results.

gRPC stream. gRPC is a high-performance RPC protocol on HTTP/2. A stream lets a data aggregator push records as they become available instead of waiting to assemble one large batch response, which matters when the underlying sources are slow and don't support batching anyway.

Visitor pattern. An object-oriented dispatch technique: a visitor walks a structure of model variants and calls the matching handler, without a long chain of type checks accumulating in the engine core.

MemoryPack. A binary serializer built for very high throughput and compact payloads (originally for .NET). It's what makes it practical to write and read tens of millions of intermediate values to and from the distributed cache.