Commissions

Trading providers

Commissions

A pure, stateless commission model prices one fill at a time. The same model instance is safe to share across the simulator, the live fill decorator and the backtester — identical inputs always yield identical outputs.

What it is & when it runs

A commission model turns one fill into a charge. The built-in simulator calls it as it generates fills; the live fill decorator calls it to enrich a broker fill that arrived with no fee. Whether a fill needs enrichment at all is gated by the provider's ReportsCommission flag — a venue that reports its own fees (crypto, or a sim engine charging its configured commission) is left untouched, so a fee is never double-charged. The futures brokers report 0 and the platform computes it.

The model is pure and stateless. Per-order floor and cap (which span partial fills) are handled without the model holding state: the caller threads the running raw total back in on the next fill.

The ICommissionModel seam

ICommissionModel.csusing TradeStrike.Pipeline.Trading.Commissions;

public interface ICommissionModel
{
    // Price one fill. Identical inputs always yield identical outputs.
    CommissionResult Compute(in CommissionContext ctx);
}

Context & result

CommissionContext carries the per-fill inputs. It is deliberately asset-class-free — asset class is a Desktop concept that cannot leak into this contracts assembly — so the Desktop template model resolves symbol → rate itself and hands the math a plain context.

CommissionContext.csusing TradeStrike.Pipeline.Trading;
using TradeStrike.Pipeline.Trading.Commissions;

public readonly record struct CommissionContext(
    string Symbol,
    decimal Quantity,                       // THIS fill's quantity (positive magnitude)
    decimal Price,
    decimal Multiplier,                     // notional = Price × Quantity × Multiplier
    OrderSide Side,
    Liquidity Liquidity,                    // Maker / Taker / Unknown (selects maker/taker rate)
    string Venue,
    decimal PriorOrderRawCommission = 0m,   // running pre-clamp raw on this order before this fill (0 on the first)
    string? InstrumentKey = null);          // canonical id for exact asset-class classification

public readonly record struct CommissionResult(
    decimal Amount,                  // commission to stamp on the fill (a cost > 0, a rebate < 0)
    decimal CumulativeRawCommission) // running pre-clamp raw INCLUDING this fill — thread back on the next
{
    public static readonly CommissionResult Zero;  // zero charge, zero accumulator
}
Threading the accumulator. Persist CumulativeRawCommission per order and pass it back as the next fill's PriorOrderRawCommission. That is what lets a per-order minimum be paid exactly once across partial fills and a per-order cap never be exceeded — while the model stays pure.

CommissionRate & the basis

A CommissionRate is the atomic rule: one basis, its value, plus optional per-order floor / cap and crypto maker/taker overrides. A commission template stores one of these per asset-class or per-symbol.

CommissionRate.cs & enumsusing TradeStrike.Pipeline.Trading.Commissions;

public enum CommissionBasis
{
    PerUnit,          // Value per filled unit (futures: per contract; equities: per share)
    PerOrder,         // flat Value once per order, regardless of quantity (ticket fee)
    PercentNotional,  // Value percent of notional (0.1 = 0.1%)
    BasisPoints,      // Value basis points of notional (5 = 0.05%)
}

public enum CommissionAccrual
{
    PerSide,   // charged on every side/fill (NinjaTrader model). Round trip = 2 × Value
    PerRound,  // Value is the round-turn cost; each side pays half (Sierra-Chart model)
}

public enum Liquidity { Unknown, Maker, Taker }  // simulator always supplies Unknown → uses plain Value

public sealed record CommissionRate
{
    public static readonly CommissionRate Zero;   // PerUnit, 0

    public CommissionRate(
        CommissionBasis basis,
        decimal value,                 // may be negative to express a maker rebate
        decimal minFee = 0m,           // per-order floor on positive cost only. 0 = none
        decimal maxFee = 0m,           // per-order cap. 0 = uncapped
        decimal? makerValue = null,    // overrides Value for a maker fill
        decimal? takerValue = null,    // overrides Value for a taker fill
        CommissionAccrual accrual = CommissionAccrual.PerSide);

    public CommissionBasis Basis { get; init; }
    public decimal Value { get; init; }
    public decimal MinFee { get; init; }   // validated non-negative
    public decimal MaxFee { get; init; }   // validated non-negative
    public decimal? MakerValue { get; init; }
    public decimal? TakerValue { get; init; }
    public CommissionAccrual Accrual { get; init; }
}

Built-in models

RateCommissionModel is the one model that holds the math; every convenience wrapper delegates to it. CommissionModels provides factories for the trivial cases so call sites never construct a bare rate.

CommissionModels.csusing TradeStrike.Pipeline.Trading.Commissions;

// Shared zero-cost model — the default when no commission is configured.
ICommissionModel zero = CommissionModels.Zero;

// Flat per-unit, per-side model (the back-compat equivalent of a "per contract" charge).
ICommissionModel flat = CommissionModels.Flat(perUnit: 2.10m);   // returns Zero when perUnit == 0

// A futures per-contract rate with a $0.50 per-order minimum, applied directly.
var rate  = new CommissionRate(CommissionBasis.PerUnit, value: 0.85m, minFee: 0.50m);
ICommissionModel model = new RateCommissionModel(rate);

// A crypto percent-of-notional rate with maker rebate / taker fee.
var crypto = new CommissionRate(
    CommissionBasis.PercentNotional, value: 0.10m,
    makerValue: -0.02m,    // maker rebate
    takerValue:  0.05m);   // taker fee
ICommissionModel cryptoModel = new RateCommissionModel(crypto);

How the math works

RateCommissionModel.Compute is the single source of truth for basis, maker/taker, accrual and the per-order floor/cap. The floor and cap apply to the whole order, not each partial fill — the model threads the running raw total so a minimum is paid exactly once and a cap is never exceeded:

RateCommissionModel.cs (essence)// This fill's charge = clamp(priorRaw + rawThisFill) − clamp(priorRaw)
decimal cumulativeRaw = priorRaw + RawContribution(rate, ctx);
decimal amount = Clamp(rate, cumulativeRaw) - Clamp(rate, priorRaw);
return new CommissionResult(amount, cumulativeRaw);
  • BasisPerUnit charges Value × Quantity; PercentNotional and BasisPoints charge against Price × Quantity × Multiplier; PerOrder contributes the flat fee only on the first fill (when nothing has accrued yet).
  • Accrual — a PerRound rate is applied as a 0.5× per-side factor so a round trip sums to the full round-turn cost.
  • Maker/TakerMakerValue / TakerValue override Value when the fill's Liquidity matches; the simulator supplies Unknown and falls back to Value.
  • Rebates — a negative Value yields a negative charge; the floor is applied to positive cost only, so a rebate is never forced positive.
pricing partial fillsvar model = new RateCommissionModel(new CommissionRate(CommissionBasis.PerUnit, 0.85m, minFee: 1.00m));

// First partial: 1 contract. Raw = 0.85, floored up to the 1.00 minimum.
var f1 = model.Compute(new CommissionContext("ES", 1m, 5000m, 50m, OrderSide.Buy, Liquidity.Unknown, "sim"));
// f1.Amount == 1.00, f1.CumulativeRawCommission == 0.85

// Second partial: 1 more. Thread the accumulator back in.
var f2 = model.Compute(new CommissionContext("ES", 1m, 5000m, 50m, OrderSide.Buy, Liquidity.Unknown, "sim",
    PriorOrderRawCommission: f1.CumulativeRawCommission));
// raw now 1.70 (> minimum) → f2.Amount == 0.70, so the order paid 1.70 total — the minimum charged once.

Writing a custom model

Most needs are met by configuring a CommissionRate and wrapping it in RateCommissionModel. Implement ICommissionModel directly only when you must select a rate per fill — the canonical example is a per-symbol / per-asset-class lookup that resolves a rate then delegates to RateCommissionModel (do not re-implement the math). Keep your Compute pure: identical inputs, identical output.

PerVenueCommissionModel.csusing System.Collections.Generic;
using TradeStrike.Pipeline.Trading.Commissions;

// Resolves a per-venue rate, then delegates to the one model that holds the math.
public sealed class PerVenueCommissionModel : ICommissionModel
{
    private readonly IReadOnlyDictionary<string, RateCommissionModel> _byVenue;
    private readonly RateCommissionModel _fallback;

    public PerVenueCommissionModel(
        IReadOnlyDictionary<string, CommissionRate> ratesByVenue, CommissionRate fallback)
    {
        var map = new Dictionary<string, RateCommissionModel>();
        foreach (var kv in ratesByVenue) map[kv.Key] = new RateCommissionModel(kv.Value);
        _byVenue  = map;
        _fallback = new RateCommissionModel(fallback);
    }

    public CommissionResult Compute(in CommissionContext ctx)
    {
        var model = _byVenue.TryGetValue(ctx.Venue, out var m) ? m : _fallback;
        return model.Compute(in ctx);   // single source of truth for the math
    }
}
Never hold per-fill state in the model. Per-order floor/cap state belongs in the CumulativeRawCommission the caller threads back — a stateful model would break the "shared across sim, live and backtest" guarantee and produce non-deterministic backtests.