feat: sim snapshot per-position marks and margin-aware buying power - #424
Conversation
Implements #412 (ADR-066): - account.Snapshot gains per-position marks: PositionMark carries the listing key, price, and the time it was observed. It is validated against the open positions (it must name an open position, appear at most once, have a positive price, and be observed no later than the snapshot). Marks are optional per position; Marks() and Mark(key) expose them. - The simulator reports its marks with the clock time each was set (fill, Advance, or ObserveMark), and replaces its private positionKey with account.ListingKey. - sim.AccountConfig.InitialMarginRatio (optional, positive) configures the account's margin model. When set, MarginUsed = required margin on gross notional, MarginAvailable = equity - MarginUsed (negative when over the limit, with no forced action), and BuyingPower = MarginAvailable / ratio, floored at zero. All are computed with the shared internal/account/margin calculation. When nil, the legacy fields are unchanged. - AccountConfig.MarginModelInfo describes the choice ("none", or initial-margin-ratio v1 with ratio=<r>) for the run manifest (#414). Closes #412 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Zero-price inputs can poison simulator snapshots, and JSONL round trips currently discard the newly added marks.
Review effort: Balanced
Findings: 2
Open (4)
What changed in this PR
Adds per-position snapshot marks and optional margin-aware simulator accounting for #412.
Changes:
- Adds validated, ordered position marks to account snapshots.
- Adds configurable initial-margin calculations and model metadata.
- Adds tests for marks, margin fields, and sizing behavior.
| File | Description |
|---|---|
internal/account/mark.go |
Defines and validates position marks. |
internal/account/mark_test.go |
Tests mark validation and ordering. |
internal/account/snapshot.go |
Exposes marks through snapshots. |
internal/adapters/broker/sim/config.go |
Adds margin-ratio configuration. |
internal/adapters/broker/sim/broker.go |
Initializes mark and margin state. |
internal/adapters/broker/sim/advance.go |
Timestamps observed marks. |
internal/adapters/broker/sim/account.go |
Computes margin-aware snapshot fields. |
internal/adapters/broker/sim/margin_test.go |
Tests simulator margin behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| key := account.KeyOf(listing) | ||
| s.commitPosition(key, outcome.position) | ||
| s.marks[key] = outcome.mark | ||
| s.marks[key] = mark{price: outcome.mark, at: at} |
There was a problem hiding this comment.
Fixed in c67b0c1: buildFill rejects a zero final price (after tick rounding and slippage, before commission or anything else is committed) with ErrInvalidFill. TestZeroFillPriceLeavesAccountUnchanged checks that there are no positions, marks, or open orders, that AsOf is unchanged, and that the order was never stored, and that the same request fills once a valid price exists.
| continue | ||
| } | ||
| s.marks[key] = close | ||
| s.marks[key] = mark{price: close, at: now} |
There was a problem hiding this comment.
Fixed in c67b0c1: ObserveMark rejects a zero price with ErrInvalidObservation before mutating, and Observation.validate now requires a positive Low. That covers Advance and AdvanceBar, the line-197 path. TestZeroObservationPricesRejectedBeforeMutation covers all three and checks that marks and AsOf are unchanged.
| // Marks is the current valuation price of open positions, at most | ||
| // one per position (see PositionMark). Every entry must name an | ||
| // entry of Positions by its ListingKey. It may omit positions, or be | ||
| // empty, when the reporter has no current price for them. | ||
| Marks []PositionMark |
There was a problem hiding this comment.
Fixed in c67b0c1: accountWire gains marks (markWire: instrument/provider/venue, price, as_of, with omitempty so older entries still decode). On read, each mark's listing is resolved against the entry's own decoded positions; a mark naming no position is ErrCorruptEntry. TestWriterReaderRoundTripsAccountMarks round-trips two listings, one with a venue, with different AsOf values.
| marks := make(margin.Marks, len(s.marks)) | ||
| for key, m := range s.marks { | ||
| marks[key] = m.price | ||
| } |
There was a problem hiding this comment.
Fixed in c67b0c1: marginFieldsLocked builds margin.Marks while iterating the current positions, so historical marks for closed listings are no longer copied.
rustyeddy
left a comment
There was a problem hiding this comment.
Reviewed PR #424 at head db3fea2accdb4b5480412eb7197f784b2b0dcea0.
The overall architecture looks right: Snapshot gains explicit per-listing marks, the simulator preserves legacy behavior when no margin model is configured, and the configured path derives margin fields from the shared #421 calculation.
I agree with the four existing Copilot findings. Two are correctness blockers, one is persistence/audit correctness, and one is an avoidable scaling problem.
1. Reject zero fill prices before they can become marks/state
A fill price that is zero must be rejected before commitFill mutates the position/mark state.
The new account.PositionMark validation correctly rejects a zero price, so allowing a zero fill to commit means the simulator can enter a state that can no longer produce a valid Snapshot.
That is particularly dangerous because mutation has already happened by the time snapshot construction reports the problem.
Please validate the fill price on the fill path before committing any position, mark, cash/PnL, order, or event state, and add a regression test proving a rejected zero-price fill leaves the account unchanged.
2. Reject invalid ObserveMark prices before mutation
ObserveMark currently calls observeMark directly and observeMark writes the supplied price into s.marks without validating it.
Again, a zero mark can poison an otherwise valid open position so the next Snapshot fails validation.
AdvanceBar goes through Observation.validate(), but the narrower ObserveMark path needs the same basic positive-price guarantee.
This should return an error and leave marks / asOf untouched.
3. JSONL journal round-trip must preserve marks
This is a real audit/reproducibility issue, not just serialization polish.
account.Snapshot now contains semantically meaningful marks and mark timestamps, but internal/adapters/journal/jsonl/accountWire still serializes only positions/open orders and the numeric account fields.
On read-back, fromAccountWire reconstructs the Snapshot with no Marks, silently losing information.
Given the JSONL adapter's own stated contract—explicit wire shapes so audit-relevant state is not silently discarded—marks need a wire representation and round-trip coverage.
I would add something like:
type positionMarkWire struct {
Listing ... // listing identity or a stable key representation
Price num.Price
AsOf time.Time
}and verify a snapshot with multiple listings/mark times survives write/read exactly.
4. Build the margin mark map only from current positions
I agree with Copilot's performance finding.
s.marks intentionally retains historical marks after positions close. Copying the entire map into margin.Marks on every snapshot makes margin-aware snapshot work grow with every listing ever traded rather than current exposure.
The shared margin calculation only needs marks for the current positions, so build the input map while iterating those positions.
That also has a nice correctness property: it makes explicit that historical/closed marks are display/history state, not inputs to current margin.
5. Minor semantic point: prefer the supplied observation timestamp for mark AsOf
Both ObserveMark(..., at time.Time) and AdvanceBar(..., at time.Time) receive the market observation timestamp, but the implementation stamps marks with deps.Clock.Now().
If the scheduler always advances the simulated clock to exactly at, these happen to match today. But the interface already carries the authoritative observation time, and PositionMark.AsOf is documented as when the price was observed.
I would prefer storing at for bar/observation-derived marks and the fill timestamp (or corresponding execution time) for fill-derived marks. That avoids coupling mark provenance to an implicit clock synchronization invariant.
I would treat this as a small design cleanup rather than a blocker if the scheduler invariant is explicitly guaranteed and tested.
What looks good
The core direction is solid:
PositionMarkis listing-scoped and preserves independent AsOf times;- marks are optional in the generic account Snapshot but required by consumers that need valuation;
MarginAvailableis allowed to go negative whileBuyingPowerfloors at zero;- no maintenance-margin liquidation is introduced;
- no configured margin model preserves the old simulator behavior;
- the configured calculation reuses
internal/account/margin; account.ListingKeyreplaces the simulator's duplicate private identity type;- model metadata makes the margin policy distinguishable for reproducibility.
One operational note: GitHub currently reports this PR as not mergeable at the current base/head, independently of the code-review findings.
Current state: changes requested before merge. The zero-price mutation paths and JSONL mark loss are the important correctness blockers; I would fix the historical-mark copy as part of the same pass.
…y margin marks - Reject a zero final fill price in buildFill, after tick rounding and slippage and before anything is committed, so a zero price from Deps.Prices or a SlippageModel can't poison later snapshots. A regression test shows the account is unchanged and the same request fills later. - Reject a zero ObserveMark price, and any zero Observation price (Low must be positive), with ErrInvalidObservation before mutation. Covers ObserveMark, Advance, and AdvanceBar. - The JSONL journal now round-trips account marks (markWire). Each mark's listing is resolved against the entry's own positions, and a mark naming no position is ErrCorruptEntry. - marginFieldsLocked builds its mark map from open positions only, so cost doesn't grow with every listing ever traded. - Keep clock-derived mark times (sim.Deps.Clock's documented rule), and pin the Scheduler invariant that the clock equals the bar time when marks are observed. Refs #412 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Addressed the review in c67b0c1 (replies are on each Copilot thread):
Coverage: |
rustyeddy
left a comment
There was a problem hiding this comment.
Re-review of PR #424 at head c67b0c1b318f8d38179fe7298c4ee5a950b22846.
The prior review blockers are addressed cleanly.
Verified fixes
-
Zero fill prices
- the fill path now rejects a zero final execution price before state is committed;
- the regression test verifies position/mark/order/AsOf state remains unchanged.
-
Zero observation marks
ObserveMarkrejects zero before mutation;- the observation validation path also rejects invalid zero-price bar inputs before
Advance/AdvanceBarcan write marks.
-
JSONL mark preservation
accountWirenow includes explicitmarkWireentries;- decode resolves each mark against the snapshot's decoded positions and treats an unknown mark/listing as a corrupt journal entry;
account.NewSnapshotreceives the reconstructed Marks, so round-trip fidelity is preserved.
-
Margin snapshot scaling
marginFieldsLockednow builds the margin mark map from current open positions only, rather than copying the simulator's historical mark cache.
-
Mark timestamp semantics
- the simulator deliberately keeps all produced timestamps clock-derived;
- the scheduler invariant that clock time equals bar time when calling mark/intrabar hooks is now explicitly pinned by a test.
- Given the simulator's existing timestamp ownership rule, I think this is a coherent choice.
Architecture check
The resulting structure remains consistent with ADR-066 and the milestone plan:
- snapshot marks are per listing and independently timestamped;
- missing marks remain explicit rather than falling back to AvgPrice;
- configured margin fields reuse the shared #421 calculation;
MarginAvailablemay be negative after drift whileBuyingPowerfloors at zero;- no maintenance-margin liquidation is introduced;
- unset margin config preserves legacy simulator behavior;
account.ListingKeyis reused consistently across snapshot/simulator/margin code;- JSONL now preserves the new account state rather than silently dropping it.
GitHub now reports the PR as mergeable.
I do not see another substantive blocker in the latest diff.
PR #424 is merge-ready from my review.
GitHub currently shows no combined-status entries for this head, so this assessment is based on the latest code, tests represented in the PR, and review state rather than an external CI status signal.


What changed
internal/accountPositionMark {Listing ListingKey, Price, AsOf}andSnapshotParams.Marks. Validation: each mark must name an open position, appear at most once, have a positive price, and have a setAsOfthat is not after the snapshot'sAsOf.AvgPrice.Snapshot.Marks()(a copy, inPositionsorder) andSnapshot.Mark(key).internal/adapters/broker/simAdvance, orObserveMark) and are reported on every snapshot.positionKeyis replaced byaccount.ListingKey, per the feat: shared gross-notional / initial-margin calculation #421 follow-up.AccountConfig.InitialMarginRatio(positive), stored as amargin.Ratio.When the ratio is set, the snapshot's margin fields come from the shared
internal/account/margincalculation:MarginUsedMarginAvailableBuyingPowerWhen it's nil (every existing caller), the fields are unchanged:
BuyingPower=MarginAvailable= cash, andMarginUsed= 0.AccountConfig.MarginModelInfo(): returns{Name: "none"}when unset, or{initial-margin-ratio, v1, "ratio=<r>"}. feat: backtest initial_margin_ratio config and risk-engine composition #414 will record it in the manifest.Why
#412, step 3 of the Account Margin Admission milestone (#409). The risk rule (#413) needs a current price for every open position, and the simulator needs real margin fields.
One observation, now pinned by a test: the simulator's cash moves only by realized P&L and fees, not by notional. So in legacy mode
BuyingPowerstays at the full starting cash no matter how much is open (a 4.4× position still reports $10,000 of buying power). That is the #409 gap. With a ratio configured, the fields are computed from equity instead.How it was tested
go test -racepasses:simat 88.5% coverage,accountat 100%. New tests:MarginModelInforeturns none, or ratio-specific values that are distinguishable.MarginAvailable−34000,BuyingPower0MarginAvailable−2000,BuyingPower0. The position is untouched and there are no orders.Positionsorder, deterministic across callsAdvanceupdates only the observed listing, so the other listing's mark is olderObserveMarkupdates price and timeAll existing
simtests pass unchanged, because no ratio keeps the legacy behavior. No golden fixtures changed.make fmt-check vet lintis clean. The only failure ingo test ./...isTestPublicSurfacefrom the strayclock/package onmain, which #423 fixes.Documentation changed
Go doc comments:
PositionMark(including the staleness semantics),SnapshotParams.Marks,AccountConfig.InitialMarginRatio,MarginModelInfo, andmarginFieldsLocked.Closes #412
🤖 Generated with Claude Code