Risk validation
Risk validation
A pure, pre-trade veto layer: the trading service runs every order through an
IRiskValidator against per-account RiskLimits before it reaches a provider, and
refuses the first failing rule with a typed RiskRejection.
On this page
What it is & when it runs
The risk layer is a pre-trade veto. The trading service builds a snapshot of the account state,
asks the validator to check it, and only routes the order to its provider when the validator returns
null. A failing rule produces a RiskRejection, which the service surfaces as the
synthesized order's Order.RejectReason — the same shape as a broker reject, so the UI
shows sim, RMS and broker rejections in one place.
It is a pure function: the snapshot it inspects is supplied by the caller, so the implementation has no dependency on the trading service, persistence or the network. That keeps every rule exhaustively testable with hand-built snapshots — no fakes, no DI gymnastics. Limits apply uniformly across Sim / Paper / Live modes (a fat-finger guard in a sim account catches the same reflex that would blow a live one).
graph TD;
A[PlaceAsync request]-->B[service builds RiskSnapshot];
B-->C[validator.Validate];
C-->|null = pass|D[route to provider];
C-->|RiskRejection|E[synthesize Rejected order with RejectReason];
The IRiskValidator seam
The validator has two entry points: one for a fresh placement, one for a modification. A modify that does
not enlarge quantity is risk-neutral and always passes (price-only and TIF-only modifies land here too); a
modify that enlarges quantity is validated as if the larger order were newly placed — same
caps, except MaxOpenOrders (a modify changes an existing order, it never adds one).
IRiskValidator.csusing TradeStrike.Pipeline.Trading;
using TradeStrike.Pipeline.Trading.Risk;
public interface IRiskValidator
{
// null on pass; a RiskRejection describing the FIRST failing rule.
RiskRejection? Validate(OrderRequest request, AccountId account, RiskSnapshot snapshot);
RiskRejection? ValidateModification(
OrderRequest currentOrder, decimal newQuantity, AccountId account, RiskSnapshot snapshot);
}
// The state the validator inspects, built by the caller at validation time.
public sealed record RiskSnapshot(
Account Account, // drives the realized-PnL checks
Position? CurrentPosition, // (account, instrument) position; null when flat
int OpenOrderCount, // working orders on this account (cross-instrument)
AccountRiskState? RiskState = null); // running-tally state; null = registry not wired
RiskValidator takes a
per-account limits lookup — typically wired inline as new RiskValidator(registry.GetLimits)
— and is safe to call from the service's order-placement path concurrently.
RiskLimits — the caps
RiskLimits is a per-account record of optional caps. Any field set to null disables that
rule; a blank RiskLimits() is "RMS configured but unconstrained".
RiskLimits.csusing TradeStrike.Pipeline.Trading.Risk;
public sealed record RiskLimits(
decimal? MaxPositionSize = null, // absolute |signedQty| ceiling; reductions always allowed
decimal? MaxDailyRealizedLoss = null, // positive magnitude; breach when RealizedPnL ≤ −limit
int? MaxOpenOrders = null, // concurrent working orders per account
decimal? MaxOrderQuantity = null, // fat-finger cap on a SINGLE order's quantity
decimal? MaxTotalLoss = null, // positive; measures Realized + Unrealized; reducers exempt
decimal? DailyProfitLock = null, // positive; lock the win — block risk-increasing orders
int? MaxConsecutiveLosers = null);// streak cap; needs RiskState; reducers exempt; resets on UTC day roll
| Limit | What it measures | Reductions exempt? |
|---|---|---|
MaxPositionSize |
|signedQty| after the order |
yes |
MaxDailyRealizedLoss |
Account.RealizedPnL vs −limit
|
— |
MaxOpenOrders |
OpenOrderCount at validation time |
— |
MaxOrderQuantity |
this single order's quantity | — |
MaxTotalLoss |
Realized + Unrealized vs −limit
|
yes |
DailyProfitLock |
RealizedPnL reaching +limit
|
yes |
MaxConsecutiveLosers |
losing trades in a row (from RiskState) |
yes |
Risk-reducing orders are exempt from the loss / streak / profit-lock rules — a loss cap must never trap a trader in the position they are cutting.
RiskRejection — typed reasons
RiskRejection is a sealed-record hierarchy so a UI banner or log writer can pattern-match the
concrete type and pull the specific fields, no string parsing. Every rejection carries a human-readable
Reason that becomes the order's RejectReason.
RiskRejection.csusing TradeStrike.Pipeline.Trading.Risk;
public abstract record RiskRejection(string Reason);
public sealed record PositionSizeExceeded(decimal CurrentSignedQuantity, decimal ProjectedSignedQuantity, decimal Limit, string Reason) : RiskRejection(Reason);
public sealed record DailyLossLimitHit(decimal RealizedPnL, decimal LossLimit, string Reason) : RiskRejection(Reason);
public sealed record OpenOrderCountExceeded(int CurrentCount, int Limit, string Reason) : RiskRejection(Reason);
public sealed record OrderQuantityExceeded(decimal RequestedQuantity, decimal Limit, string Reason) : RiskRejection(Reason);
public sealed record TotalLossLimitHit(decimal RealizedPnL, decimal UnrealizedPnL, decimal LossLimit, string Reason) : RiskRejection(Reason);
public sealed record DailyProfitTargetReached(decimal RealizedPnL, decimal ProfitTarget, string Reason) : RiskRejection(Reason);
public sealed record ConsecutiveLossLimitHit(int ConsecutiveLosers, int Limit, string Reason) : RiskRejection(Reason);
public sealed record VenueNotConnected(string Venue, string Reason) : RiskRejection(Reason);
VenueNotConnected is special. It is surfaced by the trading service itself, not the
limit-based validator, so it applies whether or not RMS limits are wired — and only to new
placements. Risk-reducing cancel / modify / flatten still pass so a trader can always unwind into a degraded
link.
Running-tally state & the registries
Most rules read directly from the snapshot's Account and Position. One rule —
MaxConsecutiveLosers — needs running-tally state: the streak of losing trades since the
last winner. That state is split into a read side and a write side so test fixtures can mock either
independently; the shipping implementation implements both in one class.
AccountRiskState.cs & registriesusing TradeStrike.Pipeline.Trading;
using TradeStrike.Pipeline.Trading.Risk;
public sealed record AccountRiskState(int ConsecutiveLosers, DateOnly TradingDate)
{
public static AccountRiskState ForDate(DateOnly tradingDate); // "no losses this date"
}
// Read side — the validator reads the streak; UI observes changes.
public interface IAccountRiskStateRegistry
{
AccountRiskState GetState(AccountId account); // never null; day rollover applied transparently
event Action<AccountId>? StateChanged;
}
// Write side — a trade-close observer feeds outcomes in.
public interface IRiskTradeOutcomeSink
{
// delta < 0 increments the streak; delta > 0 resets it; delta == 0 (scratch) leaves it unchanged.
void OnTradeClosed(AccountId account, decimal realizedPnLDelta, DateTime timestampUtc);
}
The trading service populates RiskSnapshot.RiskState automatically when an
IAccountRiskStateRegistry is wired. When no registry is wired the field is null
and rules needing it become no-ops — the streak rule degrades gracefully. Day rollover is handled by
the registry: GetState for an account whose last-seen date is older than today returns a fresh
zeroed AccountRiskState.ForDate(today).
Per-account RiskLimits follow the same read/observe split via
IRiskLimitsRegistry (GetLimits(account) + a LimitsChanged event), and
persist through IRiskLimitsStore:
IRiskLimitsRegistry & IRiskLimitsStoreusing TradeStrike.Pipeline.Trading.Risk;
public interface IRiskLimitsRegistry
{
RiskLimits? GetLimits(AccountId account); // null when no entry exists
event Action<AccountId>? LimitsChanged;
}
public interface IRiskLimitsStore
{
IReadOnlyDictionary<AccountId, RiskLimits> Load(); // empty on missing/corrupt file — never throws
void Save(IReadOnlyDictionary<AccountId, RiskLimits> limits); // atomic; throws on null / IO failure
}
Writing a custom rule
To add a bespoke pre-trade rule, implement IRiskValidator and decorate or replace the shipping
validator. Because the inputs all arrive in the RiskSnapshot, a custom validator stays a pure
function — trivially unit-testable. The example below blocks any new short entry on an
instrument during the final five minutes before midnight UTC, then delegates everything else to an inner
validator.
NoLateShortsValidator.csusing TradeStrike.Pipeline.Trading;
using TradeStrike.Pipeline.Trading.Risk;
public sealed class NoLateShortsValidator : IRiskValidator
{
private readonly IRiskValidator _inner;
public NoLateShortsValidator(IRiskValidator inner) => _inner = inner;
public RiskRejection? Validate(OrderRequest request, AccountId account, RiskSnapshot snapshot)
{
bool increasingShort =
request.Side == OrderSide.Sell &&
(snapshot.CurrentPosition?.Quantity ?? 0m) <= 0m; // flat or already short
var nowUtc = DateTime.UtcNow.TimeOfDay;
if (increasingShort && nowUtc >= new TimeSpan(23, 55, 0))
return new OrderQuantityExceeded(
request.Quantity, request.Quantity,
"No new short entries in the last 5 minutes before the UTC session roll.");
return _inner.Validate(request, account, snapshot); // chain to the standard caps
}
public RiskRejection? ValidateModification(
OrderRequest currentOrder, decimal newQuantity, AccountId account, RiskSnapshot snapshot)
=> _inner.ValidateModification(currentOrder, newQuantity, account, snapshot);
}
RiskRejection subtype when one fits the breach you are modelling.