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, 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
Validators are stateless and concurrency-safe. The shipping 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);
}
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.