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) and as a
RiskRejectionEvent carrying the typed rejection, 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. 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, with the same
caps except MaxOpenOrders (a modify changes an existing order, it never adds one).
IRiskValidator.csusing SabrTrader.Pipeline.Venues.Trading;
using SabrTrader.Pipeline.Venues.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 + day state; null = registry not wired
RiskProfileRules? ProfileRules = null, // armed profile's rules; null = no profile armed
RiskSchedule? ProfileSchedule = null, // armed profile's weekdays + time windows
DateTime? LocalNow = null, // the local clock the schedule is judged against
decimal WorkingBuyQuantity = 0m, // quantity resting in working buy orders on the instrument
decimal WorkingSellQuantity = 0m); // quantity resting in working sell orders on the instrument
WorkingBuyQuantity and WorkingSellQuantity are the quantity still resting in
working orders on the instrument, per side. They drive the position-size check for an order that adds to
the position.
ProfileRules / ProfileSchedule /
LocalNow if it wants those rules evaluated at all.
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 SabrTrader.Pipeline.Venues.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
|
no |
MaxOpenOrders |
OpenOrderCount at validation time |
no |
MaxOrderQuantity |
this single order's quantity | no |
MaxTotalLoss |
Realized + Unrealized vs −limit
|
yes |
DailyProfitLock |
RealizedPnL reaching +limit
|
yes |
MaxConsecutiveLosers |
losing trades in a row (from RiskState) |
yes |
An order that only closes or scales out of the current position passes the total-loss, streak and
profit-lock rules even when the account is in breach, so a cap never traps a trader in the position they
are cutting. A reverse (long 2 to short 2) opens new risk and is judged like any entry.
MaxDailyRealizedLoss, MaxOpenOrders and MaxOrderQuantity apply to
every order; once the account is hard-locked for the day, closing and scaling out pass them too.
A PnL rule needs the figure it measures. When MaxDailyRealizedLoss,
MaxTotalLoss or DailyProfitLock is armed and the account's
RealizedPnL is null (the venue does not report it), new risk is refused with
RealizedPnLUnavailable. When MaxTotalLoss is armed and
Account.IsUnrealizedPartial is true, new risk is refused with UnrealizedPnLPartial.
Both let reducing orders through.
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 SabrTrader.Pipeline.Venues.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);
public sealed record DuplicateSubmission(string Reason) : RiskRejection(Reason);
public sealed record FlattenAllRunning(string Reason) : RiskRejection(Reason);
public sealed record RealizedPnLUnavailable(string Reason) : RiskRejection(Reason);
public sealed record UnrealizedPnLPartial(int ExcludedPositions, string Reason) : RiskRejection(Reason);
public sealed record AccountHardLocked(RiskDailyLockReason LockReason, string Reason) : RiskRejection(Reason);
public sealed record DailyTradeLimitHit(int TradesToday, int Limit, string Reason) : RiskRejection(Reason);
public sealed record DailyTrailingDrawdownHit(decimal PeakTotalPnL, decimal CurrentTotalPnL, decimal DrawdownUsed, decimal Limit, string Reason) : RiskRejection(Reason);
public sealed record TradingScheduleBlocked(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. DuplicateSubmission also comes from the service: it refuses an order identical
to one routed moments earlier (same account, instrument, side, type, quantity, prices, origin and tag),
and submitting the same order again goes through. Risk-reducing cancel / modify / flatten still pass so a trader can always unwind into a degraded
link. FlattenAllRunning comes from the service as well: while a Flatten All runs, no order is
placed on any account until every close has ended and been confirmed. Cancel, modify and flatten are never
refused for it, and the block ends by itself.
RiskLimits.
DailyTradeLimitHit and DailyTrailingDrawdownHit come from the armed profile's
RiskProfileRules; TradingScheduleBlocked from its RiskSchedule; and
AccountHardLocked whenever the account is locked for the day, carrying a
RiskDailyLockReason that says which rule locked it. A hard lock blocks NEW risk only:
flatten and scale-out still pass, so a trader can always unwind. Switch on the rejection TYPE; the
Reason string is for the trader, not for your logic.
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. The host keeps that state, feeds closed-trade outcomes into it, and exposes the read side
below: a loss increments the streak, a win resets it, and a scratch (zero realized PnL) leaves it
unchanged.
AccountRiskState.cs & the read sideusing SabrTrader.Pipeline.Venues.Trading;
using SabrTrader.Pipeline.Venues.Trading.Risk;
public sealed record AccountRiskState(
int ConsecutiveLosers,
DateOnly TradingDate,
AccountRiskDailyState? DailyState = null) // trades today, peak PnL, the day lock
{
public static AccountRiskState ForDate(DateOnly tradingDate); // "no losses this date"
}
// 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;
}
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 pass every order. 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).
The day state (AccountRiskDailyState: trades today, peak total PnL, the day lock) is kept by
the host, which updates it as trades open and close and tracks the peak the trailing-drawdown rule
measures from. It reaches the validator as RiskSnapshot.RiskState.DailyState.
Per-account RiskLimits live in the host's limits table. Edits there raise a change event, so
nothing can change limits without the displays hearing about it.
Profiles & the day freeze
Caps are normally not set per account by hand but come from a named risk profile assigned to the account. Assignment arms a frozen snapshot of that profile for the local day, and what pre-trade validation reads is that armed snapshot rather than the profile's current contents. Editing a profile mid-session therefore cannot loosen the caps a trader is already running under.
RiskProfileusing SabrTrader.Pipeline.Venues.Trading.Risk;
public sealed record RiskProfile(
RiskProfileId Id, string Name, RiskLimits Limits, RiskProfileRules Rules, RiskSchedule Schedule)
{
public bool HasAnyCap { get; }
}
Confirm for day is the trader locking their own hands: it freezes today's armed caps for every account until the next local day. Trading continues, but the catalog and the assignments stay put. It cannot be cleared the same day, and it is refused when nothing is assigned or it has already been confirmed today. The Risk Master explains why an edit is refused. Unassign is refused for the same reason while a frozen or hard-locked account holds the profile: allowing it would be a same-day RMS bypass.
Writing a custom rule
The platform registers one validator at startup and applies it to every order the trading service and
the trade copier route. IRiskValidator is public so code that owns its own composition (a
test harness, a backtest rig, a tool that pre-checks orders before handing them to the service) can
implement or wrap it. Because the inputs all arrive in the RiskSnapshot, a validator stays a
pure function. The example blocks any new short entry in the last five minutes before local
midnight, judged on the snapshot's clock, then delegates everything else to an inner validator.
NoLateShortsValidator.csusing System;
using SabrTrader.Pipeline.Venues.Trading;
using SabrTrader.Pipeline.Venues.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
TimeSpan now = (snapshot.LocalNow ?? DateTime.Now).TimeOfDay; // the snapshot's clock keeps it pure
if (increasingShort && now >= new TimeSpan(23, 55, 0))
return new TradingScheduleBlocked(
"No new short entries in the last 5 minutes before midnight.");
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.