feat: shared gross-notional / initial-margin calculation - #421
Conversation
Add internal/account/margin, the one pure calculation of gross notional exposure and required initial margin (ADR-066). The account-level risk rule (#413) and the simulator's fill-time check (#415) will both use it. - Notional: |quantity| x price x multiplier, in exact num.Money. - Policy: the per-position required-margin function ADR-066 reserves for per-instrument margin. Ratio (|notional| x initial_margin_ratio) is the only v1 implementation; no mixed policies. - Account: current gross and required margin, with each open position valued at its mark (never AvgPrice). - Assess: current vs prospective requirement for one Change. The changed instrument is valued at the change's price in both states; other positions at their marks. - Requirement.Within and Assessment.Increases for the admission invariant and the de-risking comparison. - Classifiable errors: ErrMissingMark, ErrCurrencyMismatch, ErrInvalidPolicy, ErrInvalidInput. Closes #411 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Five unresolved moderate correctness issues remain in the core calculation logic.
Review effort: Lite
Findings: 2
Open (2)
What changed in this PR
Adds a shared, pure package for gross-notional and initial-margin calculations.
Changes:
- Implements account, policy, and prospective assessment calculations.
- Adds validation, error handling, and comprehensive tests.
- Documents the package boundary and ADR-066 behavior.
| File | Summary |
|---|---|
internal/account/margin/margin.go |
Core calculations; five unresolved moderate issues involving listing consistency, mark identity, policy validation, input validation order, and gross-vs-rounded comparisons. |
internal/account/margin/margin_test.go |
Calculation and edge-case tests. |
internal/account/margin/errors.go |
Calculation error definitions. |
internal/account/margin/doc.go |
Package documentation and ADR-066 semantics. |
docs/arch/package-boundaries.org |
Documents the new package boundary. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| func newAccumulator(policy Policy, currency num.Currency) (*accumulator, error) { | ||
| if policy == nil { | ||
| return nil, fmt.Errorf("%w: policy must be set", ErrInvalidPolicy) | ||
| } | ||
| zero, err := num.ParseMoney("0", currency) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("%w: account currency: %v", ErrInvalidInput, err) | ||
| } | ||
| return &accumulator{policy: policy, currency: currency, req: Requirement{Gross: zero, Required: zero}}, nil |
There was a problem hiding this comment.
Fixed in 5be0759: Policy now has Validate() error, and newAccumulator calls it up front. Tests cover a zero-value Ratio on a flat Account and on a closing Assess.
rustyeddy
left a comment
There was a problem hiding this comment.
Reviewed PR #421 at head 7c4e2ace52b0f6f026f0746aab2605962df3e47b.
The overall direction is good: a small pure package below both risk and sim is exactly the right architectural home for this calculation, and I like the explicit Policy extension point rather than baking percentage margin into the aggregate API.
I see three correctness issues that should be fixed before merge.
1. Assessment.Increases() must compare gross notional, not required margin
I agree with Copilot here.
ADR-066 defines de-risking as:
prospective gross notional <= current gross notional
but Assessment.Increases() currently compares:
a.Prospective.Required.Cmp(a.Current.Required)That can misclassify a small increase when Money × Rate rounds to the same required-margin amount.
For this v1 rule, the classification should be based on:
a.Prospective.Gross.Cmp(a.Current.Gross)The required-margin values are what we compare against equity for admission; gross is what determines whether the change is increasing or de-risking.
Please add a regression test where gross increases by the smallest representable amount under a sub-unit ratio but required margin rounds to the same value.
2. Validate the Policy at accumulator construction
Also agree with Copilot.
newAccumulator rejects only a nil interface. A zero-value Ratio{} can silently succeed for:
- a flat account;
- an
Assesswhose resulting quantity is zero; - any path that never reaches
RequiredMargin.
That contradicts the package contract that a zero-value Ratio is invalid.
I would make policy validity an explicit part of the interface/contract rather than relying on an actual position to exercise it. For example:
type Policy interface {
Validate() error
RequiredMargin(Valued, num.Currency) (num.Money, error)
}or an equivalent internal validation mechanism.
Then newAccumulator validates once up front.
3. Marks and changed positions are keyed at the wrong identity level
This is the larger issue I found beyond Copilot's comments.
The new package defines:
type Marks map[instrument.ID]num.Priceand Assess matches the changed position only by:
p.Listing.InstrumentID().Equal(changedID)But Trader's runtime position model explicitly says:
one net position per account/listing pair
and the simulator already preserves that distinction with:
type positionKey struct {
instrumentID instrument.ID
provider string
venue string
}Its marks are also stored by that same listing-level key.
That distinction matters here because two Listings for the same economic instrument can have different:
- provider / venue;
- contract multiplier;
- settlement mechanics/currency;
- mark.
With the current Marks map[instrument.ID]...:
- two open listings of the same instrument cannot carry different marks;
- one mark can accidentally value both listings;
Assesstreats two legitimate positions in different listings of the same instrument as an invalid duplicate;- a
Change.Listingfor a different provider/venue can be matched against the wrong existing position solely because the economic instrument ID is equal.
I think the margin package should use the same listing identity semantics as the account/simulator.
Something along the lines of a small shared/listing key:
type ListingKey struct {
InstrumentID instrument.ID
Provider string
Venue string
}(or an existing canonical equivalent if one already exists).
Then:
type Marks map[ListingKey]num.Priceand Assess matches the changed position by listing identity, not merely instrument identity.
I would avoid solving this by simply documenting "one listing per instrument" because that would contradict the existing Position and simulator model.
What looks good
The rest of the package direction looks solid:
- exact numeric types throughout;
- current/prospective values use one consistent price for the changed listing;
- other positions use marks, never AvgPrice;
- exact-limit equality is admitted;
- ratio policy is isolated behind the future margin-policy seam;
- flat positions need no mark;
- currency mismatch is explicit rather than silently converted;
- package boundary is clean and does not make
simimportrisk.
The test coverage is also broad and useful, but I would add tests specifically for the three cases above.
Current state: changes requested before merge.
Once gross-vs-required classification, policy validation, and listing-level mark/change identity are corrected, I expect the package to be in good shape for #413/#415 to build on.
…ting identity - Assessment.Increases compares gross notional, not rounded required margin, so the smallest real increase in exposure is never classified as de-risking. - Policy gains Validate(). Account and Assess validate the policy up front, so a zero-value Ratio is rejected even for a flat account or a closing change. - Marks and the changed position are keyed by listing (instrument, provider, venue), the level at which an account holds one net position, not by instrument alone. Adds an exported account.ListingKey/KeyOf and uses it for the snapshot's duplicate check, replacing the private listingKey. Refs #411 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Addressed all three items in 5be0759:
A consequence for #413 (I'll note it on the issue): |
rustyeddy
left a comment
There was a problem hiding this comment.
Re-review of PR #421 at head 5be07599006d3c551399950b7a548eede872f614.
The three issues from my previous review are fixed:
-
Gross-vs-required classification
Assessment.Increases()now comparesProspective.GrosstoCurrent.Gross.- The regression test covers the rounding case where gross increases but required margin rounds to the same value.
-
Policy validation
Policynow hasValidate() error.newAccumulatorvalidates up front, so a zero-valueRatio{}is rejected even for flat/closing paths.- Tests cover both cases.
-
Listing-level identity
- A shared
account.ListingKeynow represents the same identity already used by account snapshots/simulator positions: instrument + provider + venue. Marksis keyed byListingKey.Assessmatches the changed position by listing identity rather than economic instrument alone.- Tests cover two listings of the same instrument with separate marks and verify that changing one does not accidentally match the other.
- A shared
I also like that ListingKey was added to internal/account rather than duplicated inside margin; that aligns the new package with the existing snapshot uniqueness rule.
The rest of the package still looks consistent with ADR-066:
- exact numeric arithmetic;
- changed listing valued at one consistent price in both current/prospective states;
- other listings use marks, never AvgPrice;
- exact-limit equality is admitted;
- v1 ratio policy remains behind a general per-position margin policy seam;
- currency mismatches remain explicit;
simstill does not importrisk.
I do not see another substantive blocker in the latest diff.
PR #421 is merge-ready from my review.
GitHub currently shows no combined-status entries for this head, so this assessment is based on the current diff, tests described in the PR, and review state rather than an external CI status signal.

What changed
Adds
internal/account/margin, a pure calculation package with no I/O and no state (ADR-066). Both the account-level risk rule (#413) and the simulator's fill-time check (#415) will import it, so there's exactly one definition of gross exposure and required margin.internal/accountalready sits below bothriskandsim, andsimstill doesn't importrisk.Notional(Valued, currency)|qty| × price × multiplier, exactnum.MoneyPolicyinterface /RatioRatio(|notional| × initial_margin_ratio) is the only v1 policy.Account(positions, Marks, policy, currency)AvgPriceAssess(positions, Marks, Change, policy, currency)Change.Pricein both states (so the comparison is pure quantity) and needs no mark; other positions use marks.Change.Resultingis the caller's resulting-position magnitude.Requirement.Within(equity)Assessment.Increases()Errors:
ErrMissingMark,ErrCurrencyMismatch,ErrInvalidPolicy(nil policy, non-positive or zero-valueRatio),ErrInvalidInput(unconstructed listing, or two open positions in the changed instrument).Deciding whether a proposal is de-risking before touching prices stays with the caller (#413), per ADR-066. This package only does the arithmetic.
Why
#411, step 2 of the Account Margin Admission milestone (#409).
How it was tested
go test -race -cover ./internal/account/margin/passes, 91.5% coverage; the uncovered lines are arithmetic-overflow error paths. Tests use testify and cover every case the issue lists:No floating point is used.
make fmt-check vet lintis clean. The fullgo test ./...passes exceptTestPublicSurface, which fails onmaintoo because of the top-levelclock/package. It's unrelated to this PR.Documentation changed
internal/account/margin/doc.go)docs/arch/package-boundaries.org: new row forinternal/account/marginCloses #411
🤖 Generated with Claude Code