EQ-384: milestone-completion equivalence gate for SMA Long Hold - #395
Conversation
Upgrades cmd/trader/backtest/sma_long_hold_equivalence_test.go from #383's narrower trades/account-only comparison into issue #384's own milestone-completion gate for External Strategies v1: strategy/ smatrend (in-process) and examples/sma-long-hold (a real external.Launch subprocess) are compared across every dimension that issue's "compare at minimum" list names, all driven through the real service/backtest runtime (both sides call svcbacktest.Service.Run, never a private shortcut): - strategy descriptor / data requirements - every journaled record kind produced by this scenario — RunStarted, Intent, Proposal, Decision, Request, ReplaceRequest, Order, Fill, Trade, Signal, RunCompleted — compared kind-by-kind and field-by- field, in order, so intent ordering and correlation relationships are proven, not just final aggregates - closed/open trades and final account state (carried over from #383) - the equity curve - manifest universe/dataset identity Every opaque identifier (RunID, AccountID, IntentID, OrderID, FillID, EventID/CorrelationID/CausationID) is normalized through a new idNormalizer (a close port of backtest/determinism_test.go's own identical type from issue #223 — that suite proved two independently- seeded runs of the *same* strategy share a causal shape despite different literal ULIDs; the identical technique applies unchanged to two *different* Strategy implementations of the same trading logic). Every intentionally different, transport-only field — each side's own Descriptor.Name/Version and the resulting Manifest.StrategyName/ StrategyParameters/journal.Signal.Strategy — is named explicitly in the test's own doc comment and excluded from comparison, not silently skipped (issue #384's own "explicitly normalized/documented" requirement). Sub-tests (t.Run per dimension) plus per-record/per-field require/assert.Equalf with the record index in every message satisfy "failure output pinpoints the first semantic divergence." examples/sma-long-hold/main.go now also emits journal.Signal decision evidence on entry/adjust-stop, mirroring strategy/smatrend.Strategy. recordSignal's own Values map shape and keys exactly — needed for the new signal comparison to be meaningful rather than trivially empty on both sides. Tests: full go test ./... green under -race, golangci-lint clean (only the 3 known pre-existing unrelated research-runs/ errcheck findings), no stray processes/sockets. examples/sma-long-hold coverage 91.5%. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fVsVuQCgkrhiYaXLxyUF3
rustyeddy
left a comment
There was a problem hiding this comment.
Reviewed PR #395 (EQ-384: milestone-completion equivalence gate for SMA Long Hold). This is the right direction for the milestone gate: it compares the actual causal/journal shape rather than only final P&L, and adding signal evidence to the external reference makes that comparison meaningful.
I see two issues to address before merge:
- The journal semantic comparison is materially weaker than the determinism test it is described as porting.
Several fields that backtest/determinism_test.go already treats as part of the canonical semantics are omitted here:
KindDecision: onlyAllowedand rule names are compared; decision-level violations/warnings and each RuleResult's violations/warnings are not.KindOrder:AcceptedLimitPriceandAcceptedQuantityare not compared.KindFill: commission, BrokerOrderID, and the Fill's own Metadata/correlation/causation are not compared.KindRequest/other order payloads should be checked against the current canonical comparer as well; this gate should not silently become a looser fork as those types evolve.
That means the new milestone gate can pass even if risk evidence, accepted order terms, commissions, or fill causal metadata diverge between the in-tree and external paths. Since the file explicitly says this is a close port of the existing determinism comparator, I would copy the current semantic checks from backtest/determinism_test.go wholesale and then add the two things unique here: KindReplaceRequest and KindSignal. This also reduces the chance that the two equivalence/determinism gates drift semantically.
- The required manifest strategy/config identity is currently excluded rather than normalized and compared.
Issue #384 explicitly lists “manifest strategy/config identity” under “Compare at minimum.” This test deliberately excludes Manifest.StrategyName, StrategyParameters, and ConfigDigest, and compareRecordSemantics does not compare RunStarted.Header at all. On the external side, svc.Run is also invoked without StrategyParameters, while the in-tree side passes strat.Config().
So the test proves universe/dataset identity, but it does not prove the manifest/config provenance requirement. In fact, a regression that completely drops or changes the external run's strategy parameters would still pass this section.
I agree the literal strategy names/versions cannot be equal because these are intentionally separate implementations. But that calls for normalization, not omission. I would define a normalized semantic strategy identity for this gate — e.g. SMA period + trailing-stop percent + instrument + interval + selected rule names — and assert both manifests encode that same semantic configuration, while separately documenting/name-normalizing the implementation identity. At minimum, ensure the external RunRequest.StrategyParameters contains the external config actually used and compare the two parameter sets after normalization. Then compare the normalized RunStarted.Header/manifest too.
A smaller follow-on: the manifest dataset comparison currently checks provider/instrument/interval/revision, while the canonical determinism gate also checks span, basis, and raw fingerprint. For a milestone-completion equivalence gate, I would carry those over as well so “identical dataset” means the full semantic dataset identity, not just its digest.
The signal/correlation addition looks good, and the required-kind guard is a strong idea. Once the gate compares the full canonical payload semantics and normalized manifest/config identity, it will be a much stronger Milestone 19 completion criterion.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Several observable journal, intent, execution, manifest, and account fields remain unchecked by the equivalence gate.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 6
Open (6)
Compare metadata in all causal payloads · New Compare intent kind fields and payload metadata · New Compare complete proposal semantics and metadata · New Compare aggregate and per-rule diagnostics · New Compare accepted order prices and quantity · New Compare complete fill fields and metadata · New
What changed in this PR
This PR strengthens the SMA Long Hold equivalence test into an External Strategies v1 milestone gate.
Changes:
- Adds correlated signal evidence to the external strategy.
- Expands comparisons across journals, trades, equity, descriptors, and datasets.
- Updates documentation and unit tests.
| File | Summary |
|---|---|
examples/sma-long-hold/README.md |
Documents expanded equivalence coverage. |
examples/sma-long-hold/main.go |
Emits correlated entry and stop-adjustment signals. |
examples/sma-long-hold/main_test.go |
Tests emitted signal evidence. |
cmd/trader/backtest/sma_long_hold_equivalence_test.go |
Adds comprehensive cross-implementation comparisons. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| e1, c1, cause1 := n1.metadata(r1.Metadata) | ||
| e2, c2, cause2 := n2.metadata(r2.Metadata) | ||
| assert.Equalf(t, e1, e2, "record[%d]: metadata event id shape mismatch", i) | ||
| assert.Equalf(t, c1, c2, "record[%d]: metadata correlation id shape mismatch", i) | ||
| assert.Equalf(t, cause1, cause2, "record[%d]: metadata causation id shape mismatch", i) | ||
| assert.Truef(t, r1.Metadata.Timestamp.Equal(r2.Metadata.Timestamp), "record[%d]: metadata timestamp mismatch: got %s want %s", i, r2.Metadata.Timestamp, r1.Metadata.Timestamp) |
| case journal.KindIntent: | ||
| in1, in2 := r1.Intent, r2.Intent | ||
| assert.Equalf(t, in1.Kind, in2.Kind, "record[%d]/intent: kind mismatch", i) | ||
| assert.Truef(t, in1.Instrument.Equal(in2.Instrument), "record[%d]/intent: instrument mismatch", i) | ||
| assert.Equalf(t, in1.Side, in2.Side, "record[%d]/intent: side mismatch", i) | ||
| assert.Equalf(t, n1.intent(in1.IntentID), n2.intent(in2.IntentID), "record[%d]/intent: intent id shape mismatch", i) |
| p1, p2 := r1.Proposal, r2.Proposal | ||
| assert.Truef(t, p1.Listing.InstrumentID().Equal(p2.Listing.InstrumentID()), "record[%d]/proposal: instrument mismatch", i) | ||
| assert.Equalf(t, p1.Side, p2.Side, "record[%d]/proposal: side mismatch", i) | ||
| assert.Equalf(t, p1.Type, p2.Type, "record[%d]/proposal: type mismatch", i) | ||
| assert.Equalf(t, p1.TimeInForce, p2.TimeInForce, "record[%d]/proposal: time in force mismatch", i) | ||
| assert.Truef(t, p1.Quantity.Equal(p2.Quantity), "record[%d]/proposal: quantity mismatch: got %s want %s", i, p2.Quantity, p1.Quantity) | ||
| assert.Equalf(t, n1.account(p1.AccountID), n2.account(p2.AccountID), "record[%d]/proposal: account id shape mismatch", i) |
| assert.Equalf(t, d1.Allowed, d2.Allowed, "record[%d]/decision: allowed mismatch", i) | ||
| require.Equalf(t, len(d1.RuleResults), len(d2.RuleResults), "record[%d]/decision: rule result count mismatch", i) | ||
| for j := range d1.RuleResults { | ||
| assert.Equalf(t, d1.RuleResults[j].Rule, d2.RuleResults[j].Rule, "record[%d]/decision: rule_results[%d] name mismatch", i, j) | ||
| } |
| o1, o2 := r1.Order, r2.Order | ||
| assert.Equalf(t, o1.Status, o2.Status, "record[%d]/order: status mismatch", i) | ||
| assert.Equalf(t, n1.order(o1.Request.OrderID), n2.order(o2.Request.OrderID), "record[%d]/order: order id shape mismatch", i) | ||
| assert.Equalf(t, n1.brokerOrderID(o1.BrokerOrderID), n2.brokerOrderID(o2.BrokerOrderID), "record[%d]/order: broker order id shape mismatch", i) | ||
| comparePrice(t, i, "order.accepted_stop_price", o1.AcceptedStopPrice, o2.AcceptedStopPrice) | ||
| assert.Truef(t, o1.FilledQuantity.Equal(o2.FilledQuantity), "record[%d]/order: filled quantity mismatch: got %s want %s", i, o2.FilledQuantity, o1.FilledQuantity) |
| f1, f2 := r1.Fill, r2.Fill | ||
| assert.Truef(t, f1.Listing.InstrumentID().Equal(f2.Listing.InstrumentID()), "record[%d]/fill: instrument mismatch", i) | ||
| assert.Equalf(t, f1.Side, f2.Side, "record[%d]/fill: side mismatch", i) | ||
| assert.Truef(t, f1.Price.Equal(f2.Price), "record[%d]/fill: price mismatch: got %s want %s", i, f2.Price, f1.Price) | ||
| assert.Truef(t, f1.Quantity.Equal(f2.Quantity), "record[%d]/fill: quantity mismatch", i) | ||
| assert.Truef(t, f1.Timestamp.Equal(f2.Timestamp), "record[%d]/fill: timestamp mismatch: got %s want %s", i, f2.Timestamp, f1.Timestamp) | ||
| assert.Equalf(t, n1.fill(f1.FillID), n2.fill(f2.FillID), "record[%d]/fill: fill id shape mismatch", i) | ||
| assert.Equalf(t, n1.order(f1.OrderID), n2.order(f2.OrderID), "record[%d]/fill: order id shape mismatch", i) | ||
| assert.Equalf(t, n1.account(f1.AccountID), n2.account(f2.AccountID), "record[%d]/fill: account id shape mismatch", i) |
…manifest identity Addresses PR #395 review findings (Rusty + Copilot's 6 aligned findings): 1. compareRecordSemantics is now a full copy of backtest/ determinism_test.go's own current function, restoring every field an earlier version of this port silently dropped: KindDecision's violations/warnings (both decision-level and per-RuleResult), KindOrder's AcceptedLimitPrice/AcceptedQuantity, and KindFill's commission/BrokerOrderID/Metadata event-correlation-causation. compareViolations/compareWarnings/compareMoney are copied alongside it. Only two additions beyond that copy: KindReplaceRequest (this scenario's three ratcheting AdjustStop intents) and KindSignal (decision evidence, unique to this gate). 2. "Manifest strategy/config identity" is now normalized and compared, not excluded. New smaLongHoldSemanticIdentity type (sma_period/ trailing_stop_percent/exit_rule/reentry_rule/initial_entry_mode) with JSON tags matching smatrend.Config's own tags exactly, so both manifests' real StrategyParameters decode into it symmetrically and compare directly. The in-tree Config now sets ExitRuleName/ ReEntryRuleName/InitialEntryModeName explicitly (previously left empty, which would have recorded "" rather than the actual resolved rule names). The external run's own RunRequest.StrategyParameters — previously omitted entirely — now carries the guest's actual decoded configuration (a deliberate, documented divergence from run.go's own real --strategy-exec convention of recording launch metadata instead, since this dedicated equivalence test needs the real semantic configuration to assert against). Smaller follow-on also addressed: dataset comparison now also checks span, basis, and raw fingerprint (previously only provider/ instrument/interval/revision), matching the canonical determinism gate's own full dataset-identity comparison. Verification: go build/vet clean, gofmt clean, full go test ./... green under -race, golangci-lint clean (only the 3 known pre-existing unrelated research-runs/ errcheck findings), no stray processes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fVsVuQCgkrhiYaXLxyUF3
|
Pushed 8acb407 addressing both findings (Rusty's two points, and Copilot's 6 findings — all aligned with Rusty's point 1):
Verification: |
rustyeddy
left a comment
There was a problem hiding this comment.
Rereviewed latest head 8acb407.
The two issues from my previous review are substantially fixed:
- the canonical determinism checks for decision diagnostics, accepted order values, fill commission/BrokerOrderID, and fill metadata are now restored;
- semantic strategy/config identity is now normalized and compared explicitly on both manifests, and the dataset comparison now includes span, basis, raw fingerprint, and revision.
I still see one remaining class of blocker before calling this a Milestone 19 completion gate:
The journal comparison still omits semantic fields from Intent / Proposal / Request (and part of Fill identity). Copilot's new findings are valid here.
Concretely:
-
Intent
KindIntentcompares Kind/Instrument/Side/IntentID, but not:QuantityStopPrice- the Intent payload's own
Metadata
This matters immediately in this fixture because every
AdjustStopintent carries a non-nil StopPrice. Two implementations could choose different stop prices while still passing the Intent comparison, with the discrepancy only potentially surfacing later. Since issue #384 explicitly asks for emitted intent semantics and correlation relationships, compare these fields at the Intent stage itself. -
Proposal
KindProposalomits:LimitPriceStopPriceReduceOnly- Proposal
Metadata
These are execution-planning semantics. A proposal with a different stop/limit or reduce-only setting should fail this gate immediately at the proposal record where the divergence occurred.
-
Request
KindRequestcompares only instrument/side/quantity/OrderID even though Request embeds the full Proposal. Compare the embedded Proposal semantics here too (Type, TIF, limit/stop, ReduceOnly, AccountID, Metadata), not merely a subset. Otherwise a request-level divergence can hide behind the earlier Proposal record and the failure is no longer guaranteed to pinpoint the first semantic divergence. -
Order
The accepted values are now covered, which is good, but the embeddedRequestis still only represented by OrderID. For a true field-by-field order comparison, compare the embedded request semantics as well, or factor a helper so Proposal/Request/Order all reuse the same canonical comparison. -
Fill
Commission/BrokerOrderID/Metadata are now covered, butBrokerFillIDis still omitted. BrokerFillID is observable execution identity and is specifically used by Trader for duplicate execution detection. If it is intentionally normalized rather than compared literally, add abrokerFillIDnormalizer analogous tobrokerOrderID.
I would factor this rather than continuing to hand-copy subsets:
compareMetadatacompareIntentcompareProposalcompareRequest- then let
KindOrdercallcompareRequestbefore checking broker-effective fields.
That would make this gate both stricter and much harder to drift as the order model evolves.
One documentation cleanup is also needed: the file-level/test comments still say Manifest.StrategyParameters are intentionally excluded, but the code now correctly normalizes and compares them. Update those comments so the stated gate matches the implementation.
The manifest/config fix itself looks good now; my remaining concern is the completeness of the causal/journal semantics comparison.
…ed helpers Addresses Rusty's re-review of 8acb407 on PR #395 (Copilot's 6 findings from the previous round all fall under this same gap). - New compareMetadata/compareIntent/compareProposal/compareRequest helpers, so Proposal's shared embedded-Proposal semantics are checked identically everywhere they appear (KindProposal, KindRequest, and KindOrder's own embedded Request) instead of three independent, driftable hand-copies of the same fields. - KindIntent now compares Quantity, StopPrice, and the intent's own Metadata — previously omitted, meaning two implementations could choose different stop prices at the intent stage (every AdjustStop intent in this scenario carries one) and still pass. - KindProposal now compares LimitPrice, StopPrice, ReduceOnly, and Metadata — previously omitted execution-planning semantics. - KindRequest now compares the embedded Proposal's full semantics via compareRequest, not just instrument/side/quantity/OrderID. - KindOrder now compares its own embedded Request via the identical compareRequest helper, so a divergence there can no longer hide behind an earlier, separately-passing KindRequest record for the same order. - KindFill now also compares BrokerFillID (currently always "" on both sides — adapters/broker/sim never populates it — but checked explicitly rather than silently dropped, so this stops protecting the field the moment that changes). - Doc comments (top of file and the test function's own) corrected: they previously said Manifest.StrategyParameters was excluded, which was already stale after the previous round's fix — it is normalized and compared via smaLongHoldSemanticIdentity; only StrategyName (each side's own implementation identity) is excluded. Verification: go build/vet clean, gofmt clean, full go test ./... green under -race, golangci-lint clean (only the 3 known pre-existing unrelated research-runs/ errcheck findings), no stray processes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fVsVuQCgkrhiYaXLxyUF3
|
Pushed 632b437 addressing the remaining gap from your re-review of 8acb407 (Copilot's 6 findings from the previous round all fall under this same issue):
Verification: |
rustyeddy
left a comment
There was a problem hiding this comment.
Rereviewed latest head 632b437.
The remaining journal-semantic gaps from the prior review are fixed cleanly:
compareIntentnow covers Quantity, StopPrice, IntentID, and the Intent's own Metadata;compareProposalnow covers limit/stop prices, ReduceOnly, AccountID, and Metadata in addition to the existing order mechanics;compareRequestreuses that full Proposal comparison and is used both forKindRequestand the Request embedded inKindOrder;- Fill now explicitly compares BrokerFillID in addition to commission, BrokerOrderID, IDs, and causal Metadata;
- the shared
compareMetadatahelper keeps correlation/causation comparisons consistent instead of duplicating partial checks; - the stale comments saying Manifest.StrategyParameters was excluded are corrected; semantic config is normalized and compared as intended.
I also rechecked the earlier manifest/config and dataset-identity fixes. The gate now exercises descriptor/data requirements, normalized strategy config, full dataset identity, ordered journal semantics/correlation, trades, equity curve, and final account state through the normal backtest runtime.
I don't see another correctness blocker. PR #395 looks merge-ready from my review and is a credible Milestone 19 equivalence gate.
One future-proofing note only: BrokerFillID is compared literally because sim currently leaves it empty. If sim later begins assigning run-specific broker fill IDs, that field will likely need normalization analogous to BrokerOrderID. The current explicit equality is correct for today's simulator behavior and is not a blocker.
GitHub currently exposes no commit-status entries for this head through the connector, so the merge-ready assessment is based on the code/tests plus the reported local verification.

Closes #384.
What changed
Upgrades
cmd/trader/backtest/sma_long_hold_equivalence_test.go(from #383/PR #394) into issue #384's own milestone-completion gate for External Strategies v1 — "a hello-world RPC round trip alone is not sufficient proof."Both
strategy/smatrend(in-process) andexamples/sma-long-hold(a realexternal.Launchsubprocess) run through the identicalservice/backtestcomposition, and are now compared across every dimension issue #384's "compare at minimum" list names:RunStarted,Intent,Proposal,Decision,Request,ReplaceRequest,Order,Fill,Trade,Signal,RunCompleted— compared kind-by-kind and field-by-field, in order, so intent ordering and correlation relationships are actually proven, not just final aggregatesKey design points
idNormalizer— a close port ofbacktest/determinism_test.go's own identical type from issue M5-15: Add deterministic backtest regression/golden suite #223. That suite proved two independently-seeded runs of the same strategy share a causal shape despite different literal ULIDs; the identical technique applies unchanged here to two different Strategy implementations of the same trading logic.Descriptor.Name/Versionand the resultingManifest.StrategyName/StrategyParameters/journal.Signal.Strategy— is named explicitly in the test's own doc comment and excluded from comparison, not silently skipped (issue external-strategy: deterministic equivalence test for in-tree vs out-of-tree SMA Long Hold #384's own "explicitly normalized/documented" requirement).t.Runper dimension) plus per-record/per-fieldrequire/assert.Equalfwith the record index in every message.examples/sma-long-hold/main.gonow also emitsjournal.Signaldecision evidence on entry/adjust-stop, mirroringstrategy/smatrend.Strategy.recordSignal's own Values map shape and keys exactly — needed for the new signal comparison to be meaningful rather than trivially empty on both sides.Testing
Full
go test ./...green under-race,golangci-lintclean (only the 3 known pre-existing unrelatedresearch-runs/errcheck findings), no stray processes/sockets.examples/sma-long-holdcoverage 91.5%.🤖 Generated with Claude Code