An order has a limit price of 9.9500 and a quantity of 1.005. Its exact notional is 9.9997500. The interface displays 10.00, and a validation service accepts it against a minimum order value of 10.00.
The service has changed the answer by rounding too early. Both inputs can be valid on their respective grids while their product remains below the minimum. Choosing decimal instead of double would not repair a comparison made against an already rounded amount.
This article builds a small .NET 10 order validator around that failure. It handles exact numeric input, price increments, quantity increments, notional bounds, and versioned rule snapshots. The instrument and rules are fictional. The code is a deterministic validation laboratory; it does not connect to a broker or submit orders.
Separate representation, trading increments, and rounding
These are three different contracts. Representation determines which input values the application can preserve. A trading increment determines which of those values a venue accepts. Rounding determines how a value is reduced to another precision for a specified purpose.
A price stored with four decimal places is not necessarily tradable in increments of 0.0001. In our example the storage scale is four, but the price increment is 0.0500. The value 19.9600 fits the representation perfectly and still fails the increment rule.
Real APIs expose these distinctions. Binance’s spot filter documentation describes separate price, quantity, and notional constraints. That is a concrete API example, not a universal equity-market contract. Each broker or venue adapter must map the rules that actually apply to its instrument and order type.
| Fictional LAB rule | Price | Quantity |
|---|---|---|
| Storage scale | 4 decimal places | 3 decimal places |
| Increment, measured from zero | 0.0500 | 0.005 |
| Inclusive minimum | 0.0500 | 0.005 |
| Inclusive maximum | 10000.0000 | 1000.000 |
The fictional instrument is LAB:DEMO-FUND, quoted in USD, with an inclusive notional range of 10.00 through 50000.00. Fractional quantities are allowed only because this laboratory says so. These values are not current trading rules for a listed security.
Choose an exact boundary representation
Microsoft’s C# numeric-type reference explains why binary floating-point types cannot exactly represent many decimal fractions, including 0.1. decimal is a useful choice for many financial applications, but it has finite precision and range. It also cannot choose your business rounding policy; the Decimal documentation explicitly discusses the continued need for rounding.
For this validator, we use scaled integers backed by BigInteger. A price of 19.9500 becomes 199500 price units at scale four. A quantity of 0.505 becomes 505 quantity units at scale three. Their integer product is 100747500 at scale seven, representing exactly 10.0747500.
The relationship is straightforward: notionalUnits = priceUnits × quantityUnits, and notionalScale = priceScale + quantityScale. Minimum and maximum notional values must be expressed at that same resulting scale before comparison. Comparing price units with currency minor units would be a dimensional error even if every variable had an integer type.
BigInteger avoids a fixed integer-width limit, at the cost of allocation and work that grow with operand size. We bound input to 64 characters and each input scale to 18. This is a correctness-oriented implementation, with no latency or throughput benchmark claimed. A fixed-width representation can be appropriate when its bounds and multiplication behavior are proven for the supported instruments.
Parse numeric strings without changing the requested value
The wire contract uses unsigned ASCII decimal strings. It accepts 19.95 and 019.9500000 as the same scale-four value because removing the extra zeros is exact. It rejects 19.95001; rounding it into an acceptable request would change the customer’s input.
Exponent notation, commas, signs, whitespace, missing integer parts, and non-ASCII digits are rejected. A Turkish interface may display localized numbers, but its transport layer must produce the declared wire format explicitly. The server should not guess whether a comma was intended as a decimal separator or a grouping separator.
JSON syntax alone does not guarantee that every participant preserves an arbitrary decimal value. RFC 8259 discusses implementation limits and numeric interoperability. String fields let this API enforce its own grammar before conversion. The client must preserve the user’s value too; converting through a binary floating-point number and then creating a string can already have lost information.
This is the complete FixedPoint.cs used by the executable and tests:
using System.Globalization;
using System.Numerics;
using System.Text.RegularExpressions;
namespace OrderLab;
public static class FixedPoint
{
public static BigInteger Parse(string? text, int scale)
{
if (scale is < 0 or > 18)
throw new ArgumentOutOfRangeException(nameof(scale));
if (text is null || text.Length is < 1 or > 64 ||
!Regex.IsMatch(text, @"\A[0-9]+(?:\.[0-9]+)?\z",
RegexOptions.CultureInvariant | RegexOptions.NonBacktracking))
throw new FormatException("Expected an unsigned ASCII decimal string.");
var parts = text.Split('.');
var fraction = parts.Length == 2 ? parts[1] : "";
if (fraction.Length > scale)
{
if (fraction[scale..].Any(c => c != '0'))
throw new FormatException("Value exceeds the supported scale.");
fraction = fraction[..scale];
}
var whole = BigInteger.Parse(parts[0], CultureInfo.InvariantCulture);
var tail = scale == 0 ? BigInteger.Zero :
BigInteger.Parse(fraction.PadRight(scale, '0'), CultureInfo.InvariantCulture);
return whole * BigInteger.Pow(10, scale) + tail;
}
public static string Format(BigInteger units, int scale)
{
if (units.Sign < 0 || scale is < 0 or > 36)
throw new ArgumentOutOfRangeException(nameof(scale));
var digits = units.ToString(CultureInfo.InvariantCulture).PadLeft(scale + 1, '0');
return scale == 0 ? digits : digits[..^scale] + "." + digits[^scale..];
}
// An explicit presentation policy; never used by order eligibility checks.
public static string DisplayToEven(BigInteger units, int sourceScale, int displayScale)
{
if (units.Sign < 0 || sourceScale is < 0 or > 36 ||
displayScale < 0 || displayScale > sourceScale)
throw new ArgumentOutOfRangeException(nameof(displayScale));
var divisor = BigInteger.Pow(10, sourceScale - displayScale);
var rounded = BigInteger.DivRem(units, divisor, out var remainder);
if (remainder * 2 > divisor ||
(remainder * 2 == divisor && !rounded.IsEven))
rounded++;
return Format(rounded, displayScale);
}
}
The parser never passes through double or decimal. Its output is an integer in the scale selected by trusted instrument metadata. The formatting method emits an invariant decimal string. The display-rounding method is deliberately separate and is never called by the eligibility checks.
Make rule identity part of the decision
A rule snapshot contains the instrument, quote currency, version, scales, increments, bounds, and a validity interval. The caller echoes a version; the application supplies the actual snapshot from a trusted source. Accepting caller-supplied minimums or increments would let the request define its own acceptance criteria.
The sample checks a half-open interval: the snapshot applies at EffectiveFrom and stops applying at ExpiresAt. Passing the current time as an argument makes these boundaries deterministic to test. A serving application should obtain that time from its own clock, not from an arbitrary request field.
The validity window is a local policy in this laboratory. It is not evidence that a venue promises its rules will remain unchanged throughout the window. A production adapter needs update, invalidation, and stale-data behavior, and should retain the actual rule values or an immutable content reference for later investigation.
This complete file, OrderValidation.cs, contains the request, snapshot, decision, and validator:
using System.Numerics;
namespace OrderLab;
public sealed record LimitOrder(
string Instrument, string QuoteCurrency, string Price, string Quantity, string RuleVersion);
public sealed record RuleSnapshot(
string Instrument, string QuoteCurrency, string Version,
DateTimeOffset EffectiveFrom, DateTimeOffset ExpiresAt,
int PriceScale, int QuantityScale,
BigInteger MinPrice, BigInteger MaxPrice, BigInteger PriceStep,
BigInteger MinQuantity, BigInteger MaxQuantity, BigInteger QuantityStep,
BigInteger MinNotional, BigInteger MaxNotional)
{
public int NotionalScale => PriceScale + QuantityScale;
public void CheckConfiguration()
{
if (string.IsNullOrWhiteSpace(Instrument) || string.IsNullOrWhiteSpace(QuoteCurrency) ||
string.IsNullOrWhiteSpace(Version) || EffectiveFrom >= ExpiresAt ||
PriceScale is < 0 or > 18 || QuantityScale is < 0 or > 18 ||
MinPrice <= 0 || MaxPrice < MinPrice || PriceStep <= 0 ||
MinQuantity <= 0 || MaxQuantity < MinQuantity || QuantityStep <= 0 ||
MinNotional < 0 || MaxNotional < MinNotional)
throw new ArgumentException("Invalid trusted rule snapshot.");
}
}
public sealed record Decision(bool Accepted, string Code, string RuleVersion, string? ExactNotional);
public static class OrderValidator
{
public static Decision Validate(LimitOrder order, RuleSnapshot rules, DateTimeOffset now)
{
rules.CheckConfiguration();
Decision Reject(string code) => new(false, code, rules.Version, null);
if (order.Instrument != rules.Instrument || order.QuoteCurrency != rules.QuoteCurrency)
return Reject("instrument_or_currency_mismatch");
if (order.RuleVersion != rules.Version)
return Reject("rule_version_mismatch");
if (now < rules.EffectiveFrom || now >= rules.ExpiresAt)
return Reject("rules_outside_validity_window");
BigInteger price, quantity;
try { price = FixedPoint.Parse(order.Price, rules.PriceScale); }
catch (FormatException) { return Reject("invalid_price_format_or_precision"); }
try { quantity = FixedPoint.Parse(order.Quantity, rules.QuantityScale); }
catch (FormatException) { return Reject("invalid_quantity_format_or_precision"); }
if (price < rules.MinPrice || price > rules.MaxPrice)
return Reject("price_out_of_range");
if (price % rules.PriceStep != 0)
return Reject("price_off_grid");
if (quantity < rules.MinQuantity || quantity > rules.MaxQuantity)
return Reject("quantity_out_of_range");
if (quantity % rules.QuantityStep != 0)
return Reject("quantity_off_grid");
var notional = price * quantity;
if (notional < rules.MinNotional || notional > rules.MaxNotional)
return Reject("notional_out_of_range");
return new(true, "accepted", rules.Version, FixedPoint.Format(notional, rules.NotionalScale));
}
}
Configuration defects throw an exception; malformed or ineligible orders receive stable rejection codes. The function returns the first failed check in a deterministic order. An HTTP endpoint could map those codes into its own response contract without exposing stack traces. This project keeps transport concerns outside the arithmetic example.
The request and rules are immutable records, and the function does not read balances or mutate account state. That keeps the result reproducible for a given request, snapshot, and time. It also means the validator does not reserve funds, authorize a customer, or prevent concurrent orders from spending the same balance.
A price grid is more than a number of decimal places
The modulo checks implement a zero-origin grid: an accepted price is an integer multiple of the price step, and an accepted quantity is an integer multiple of the quantity step. Inclusive minimum and maximum checks are applied separately. A nonzero minimum does not automatically become the grid’s origin.
This distinction matters when translating venue documentation into code. A rule expressed as price % step == 0 differs from (price - minimum) % step == 0 when the minimum is not itself a step multiple. Use the documented rule instead of assuming that a field called “minimum” defines both concepts.
A single increment may also be insufficient. Interactive Brokers documents market rules that determine the minimum increment for a given price. A banded implementation needs explicit band boundaries and tests immediately below, at, and above each boundary. Our fixed-step snapshot does not implement those bands.
Do not silently snap a submitted limit price onto a grid. Moving a buy limit upward or a sell limit downward changes the permitted execution economics. A suggested replacement can be a separate user decision, but the validator should preserve the distinction between the submitted order and that suggestion.
Run the .NET laboratory
Download the complete .NET order-validation project and xUnit suite (ZIP). It includes both projects, all source files, pinned package references, NuGet lockfiles, and a runner. The application itself uses only the .NET base class library.
The recorded environment is .NET SDK 10.0.101 and Microsoft.NETCore.App 10.0.1 on macOS arm64. The package’s global.json selects that SDK feature band with patch roll-forward. With a compatible SDK installed, run these commands from the extracted project directory:
dotnet restore OrderLab.Tests/OrderLab.Tests.csproj --locked-mode
dotnet test OrderLab.Tests/OrderLab.Tests.csproj -c Release --no-restore
dotnet run --project OrderLab/OrderLab.csproj -c Release --no-restore
The package also offers bash run-lab.sh to save build, test, runtime, and demo output. Its fictional rules are created by DemoRules.cs:
namespace OrderLab;
public static class DemoRules
{
// Entirely fictional instrument and rules; not exchange or broker metadata.
public static RuleSnapshot Create() => new(
"LAB:DEMO-FUND", "USD", "lab-v1",
new DateTimeOffset(2026, 9, 18, 0, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 9, 19, 0, 0, 0, TimeSpan.Zero),
PriceScale: 4, QuantityScale: 3,
MinPrice: FixedPoint.Parse("0.0500", 4),
MaxPrice: FixedPoint.Parse("10000.0000", 4),
PriceStep: FixedPoint.Parse("0.0500", 4),
MinQuantity: FixedPoint.Parse("0.005", 3),
MaxQuantity: FixedPoint.Parse("1000.000", 3),
QuantityStep: FixedPoint.Parse("0.005", 3),
MinNotional: FixedPoint.Parse("10.00", 7),
MaxNotional: FixedPoint.Parse("50000.00", 7));
}
The console entry point uses a fixed timestamp within that fictional validity window. Keeping the test clock fixed makes the example reproducible after the demonstration date:
using OrderLab;
var rules = DemoRules.Create();
var now = new DateTimeOffset(2026, 9, 18, 12, 0, 0, TimeSpan.Zero);
var samples = new[]
{
("9.9500", "1.005"),
("19.9500", "0.505"),
("19.9600", "1.000"),
("19.9500", "1.001"),
("10.0000", "1.000")
};
foreach (var (price, quantity) in samples)
{
var order = new LimitOrder(rules.Instrument, rules.QuoteCurrency, price, quantity, rules.Version);
var result = OrderValidator.Validate(order, rules, now);
Console.WriteLine($"{price} x {quantity}: {result.Code}; exact={result.ExactNotional ?? "n/a"}");
}
var raw = FixedPoint.Parse("9.9500", 4) * FixedPoint.Parse("1.005", 3);
Console.WriteLine($"Rejected order: exact={FixedPoint.Format(raw, 7)}, display={FixedPoint.DisplayToEven(raw, 7, 2)}");
The actual console output was:
9.9500 x 1.005: notional_out_of_range; exact=n/a
19.9500 x 0.505: accepted; exact=10.0747500
19.9600 x 1.000: price_off_grid; exact=n/a
19.9500 x 1.001: quantity_off_grid; exact=n/a
10.0000 x 1.000: accepted; exact=10.0000000
Rejected order: exact=9.9997500, display=10.00
The first order fails the minimum-notional check even though its display amount is 10.00. The second passes with its full product retained. The next two distinguish an off-grid price from an off-grid quantity. The final order exercises equality at the minimum notional.
Round only at a named boundary
The display helper rounds a nonnegative integer quantity to a specified decimal scale using nearest-value rounding with midpoint ties to even. For example, 1.005 displays as 1.00 at scale two, while 1.015 displays as 1.02. Those cases are covered by tests.
The quotient and remainder calculation identifies the midpoint exactly; it does not compare an approximate floating-point remainder to 0.5. Microsoft’s MidpointRounding reference describes the distinction between midpoint strategies and directed rounding.
This display rule is not a settlement rule. Fees, tax amounts, cash postings, and allocations may require different scales and policies. Rounding each partial fill and then summing can differ from summing exact fills and rounding once. Record where rounding occurs, its mode, the currency, and the policy version so the accounting result can be reconstructed.
Our notional calculation also assumes a unit contract multiplier and one quote currency. Derivatives, FX conversions, and instruments with other pricing conventions need additional dimensions. An exact product with the wrong multiplier remains the wrong financial amount.
Test the contract at its boundaries
The Release build completed with zero warnings and zero errors. All 39 xUnit test cases passed. They cover accepted products, inclusive bounds, excessive precision, bad increments, zero values, out-of-range inputs, stale versions, currency mismatches, and exact rule-expiration boundaries.
The suite also verifies Turkish-culture behavior, numeric-string JSON round-tripping, rejection of a JSON numeric token for a string field, midpoint rounding, 64-character input limits, and an integer product beyond decimal‘s range. One case checks every representable price unit from 500 through 2000: 1,501 grid classifications under controlled rules.
These checks validate a local numeric and metadata contract. They do not validate a broker protocol, market-rule feed, database schema, account ledger, or exchange session. Keep those as separate integration tests, with recorded rule snapshots and representative rejected orders from the specific adapter.
Carry the decision into the order workflow
At the service boundary, authenticate the account, resolve the instrument, load trusted rules, validate the exact request, and apply the account’s risk policy. Preserve the submitted strings and the normalized units, together with the snapshot version and validation result.
A successful local decision is not an exchange acknowledgement. Rules may change before submission; the account may lose available buying power; an order may be rejected for a condition this validator does not model. The downstream workflow needs explicit states for local validation, reservation, submission, acknowledgement, rejection, cancellation, and fills.
Buying-power checks need atomic reservation or another concurrency-safe accounting design. Two individually valid orders can otherwise both pass against the same available funds. Likewise, transport retries need stable order identity and reconciliation after uncertain outcomes; repeating a send is not a numeric-validation problem.
The useful boundary is a small one: preserve the requested value, apply the identified rules without premature rounding, and return a reproducible decision. That gives the rest of a .NET investment platform a result it can audit instead of an amount whose meaning changed somewhere between the form and the order service.
What do you think?