Risk validation

Trading providers

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.

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.

The snapshot is the whole input. A validator reads what the caller put in it and never re-reads a registry on the order path. That is what keeps it a pure function, and it is also why a caller building a snapshot by hand must fill ProfileRules / ProfileSchedule / LocalNow if it wants those rules evaluated at all.
Validators are stateless and concurrency-safe. The host's own validator reads its caps through a per-account limits lookup and is safe to call from the service's order-placement path concurrently. Write yours the same way.

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.
Four more rejections come from the day state, not from 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.

Assignment takes effect immediately and is frozen for the rest of the day by a hard lock or by Confirm for day. The host persists profiles, assignments, daily counters and the confirm stamp on this machine, so a restart keeps today's freeze and lock.

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);
}
Return the first failure, then stop. The contract is "first failing rule wins": return as soon as a rule fails rather than collecting every breach, so the rejection the trader sees is deterministic and actionable. Reuse a built-in RiskRejection subtype when one fits the breach you are modelling.