EQ-383: SMA Long Hold out-of-tree reference implementation - #394
Conversation
Adds examples/sma-long-hold, a real, non-trivial strategysdk.Strategy proving Strategy Protocol v1 (ADR-062) against something more than a hello-world guest: SMA/indicator state, a genuine cross-above entry decision, and a ratcheting protective stop — built on strategysdk exactly as examples/strategysdk-minimal is. Scope (per discussion): reproduces only strategy/smatrend's own default configuration (ExitRuleName "trailing-stop", ReEntryRuleName "fresh-cross", InitialEntryModeName "fresh-cross" — EQS-01's own original, only mechanism before issue #347/#349's pluggable rules), not the full pluggable-rule surface, and lives in this repository's examples/ tree rather than a separate GitHub repository. For exactly this default combination, onFlat's own entry decision reduces to one condition (crossedAbove) regardless of position history — see main.go's own doc comment — so no re-entry-rule/initial-entry-rule state machine, and no OnFill capability, is needed at all. - examples/sma-long-hold/main.go: TRADER_STRATEGY_CONFIG-driven (JSON; defaults to EUR/USD H1, sma_period 20, trailing_stop_percent 0.10, matching strategysdk-minimal's own zero-config convention). Reuses indicator.SMA directly (a pure analytical package, no broker/execution surface) for bit-for-bit numerical agreement with strategy/smatrend's own use of it, rather than a hand-rolled second implementation. boundary_test.go mechanically enforces "no broker/ risk/execution/pipeline/strategy/backtest/service/cmd/adapters access." - cmd/trader/backtest/sma_long_hold_equivalence_test.go (package backtest, internal — needs environmentFactory/ nextBarOpenPriceSource): drives strategy/smatrend in-process and examples/sma-long-hold as a real external.Launch subprocess through identical service/backtest compositions over identical canonical data, and asserts their closed/open trades (side, timestamps, RealizedPnL, Costs — deliberately excluding IDs, which are never expected to agree) and final account equity/RealizedPnL are identical. A new engineered fixture (testdata/raw/oanda/EURUSD/2024/06) was needed: the repository's other EURUSD months happen to stay continuously above their own short-period SMA once warmed up, so "fresh-cross" never actually fires against them and the comparison would be trivial. - config/arch_test.go: examples/sma-long-hold exempted from the no-os.Getenv rule, for the same reason strategysdk/cmd already are — its main() is a guest binary's own composition root. - .gitignore: guards against `go build ./...`-from-root writing stray example/cmd binaries (a real accident this branch's own verification hit) into the repo root. Tests: examples/sma-long-hold gets direct, fast unit coverage (config validation, crossState, a full synthetic entry/ratchet/ exit OnBar walk-through, Start, newFromEnv) at 89.8% package coverage, independent of the slower cross-process equivalence proof. Full go test ./... green under -race, golangci-lint clean (only the 3 known pre-existing unrelated research-runs/ errcheck findings), no stray processes/sockets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fVsVuQCgkrhiYaXLxyUF3
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Three moderate findings remain unresolved in environment handling and configuration validation.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (2)
What changed in this PR
Adds a configurable SMA long-hold strategysdk reference strategy and verifies equivalence with the in-tree implementation.
Changes:
- Implements SMA cross-entry and ratcheting trailing-stop behavior.
- Adds unit, boundary, fixture, and cross-process equivalence tests.
- Updates documentation, architecture exemptions, and build-output ignores.
| File | Summary |
|---|---|
examples/sma-long-hold/README.md |
Usage and configuration documentation |
examples/sma-long-hold/main.go |
External strategy implementation |
examples/sma-long-hold/main_test.go |
Unit and behavior coverage |
examples/sma-long-hold/boundary_test.go |
Import-boundary enforcement |
config/arch_test.go |
Guest environment exemption |
cmd/trader/backtest/testdata/raw/oanda/EURUSD/2024/06/EURUSD-2024-06-h1.csv |
Engineered trading fixture |
cmd/trader/backtest/sma_long_hold_equivalence_test.go |
Cross-process equivalence test |
.gitignore |
Example build-output exclusions |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| quote, err := num.ParseCurrency(cfg.Quote) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("sma-long-hold: quote currency: %w", err) | ||
| } |
| trailingStopPercent, err := num.ParseRate(cfg.TrailingStopPercent) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("sma-long-hold: trailing_stop_percent: %w", err) | ||
| } | ||
| one := num.MustParseRate("1") | ||
| retain, err := one.Sub(trailingStopPercent) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("sma-long-hold: computing stop retention fraction: %w", err) | ||
| } |
rustyeddy
left a comment
There was a problem hiding this comment.
Reviewed PR #394 (EQ-383: SMA Long Hold out-of-tree reference implementation). The overall direction is strong: this is a genuinely non-trivial external strategy, it stays behind the intended SDK boundary, and the cross-process equivalence test is much more valuable than another transport-only example.
I see three things to tighten before merge:
-
Reject base == quote in the external strategy config. Copilot's finding is valid.
num.ParseCurrencyvalidates each currency independently, butinstrument.CurrencyPairID(base, quote)can produce a zero/invalid ID for something like USD/USD. The in-tree side constructs a real CurrencyPair and would reject that semantic invalidity earlier. Useinstrument.NewCurrencyPair(base, quote)(or an explicit equality check) and derive the ID from that validated pair. Add a config regression test. -
Match smatrend's strict trailing-stop range validation. This reference claims equivalence with the in-tree default configuration, but
newLongHoldcurrently only parses the rate; it does not enforce0 < trailing_stop_percent < 1.strategy/smatrend.Config.Validate()explicitly enforces both bounds for the default trailing-stop rule. Negative, zero, 1, or >1 values therefore behave differently out-of-tree and can create nonsensical stop geometry. Mirror those exact bounds and test at least 0, 1, a negative value, and >1. -
The equivalence assertion is slightly too weak for the claim “identical results.”
tradeKeycompares side/timestamps/PnL/costs but omits the trade's Listing/instrument, and the final account check compares only equity, realized PnL, and the number of positions:
assert.Equal(t, len(inTree.Account.Positions()), len(external.Account.Positions()))For an equivalence gate, I would compare the economically meaningful final position contents too (instrument, side, average price, and quantity if present in the account position type), and include the trade's listing/instrument in tradeKey. IDs should still be excluded, as you already do. Otherwise two runs could theoretically end with the same aggregate money values and position count while differing in actual held state.
The engineered fixture and the requirement that the scenario actually produce a closed trade are both good choices; they make this a meaningful protocol/strategy equivalence test rather than a trivial no-op comparison.
The issue originally asked for a separate repository and broader re-entry/OnFill coverage, but the PR explicitly records that scope was narrowed by prior discussion, so I'm not treating those as review blockers here.
…alence Addresses PR #394 review findings (Copilot + Rusty): - newLongHold now constructs the instrument via instrument.NewCurrencyPair(base, quote), not CurrencyPairID directly: the latter would happily produce a semantically invalid instrument for base == quote (e.g. USD/USD), which a real in-tree strategy/smatrend Strategy already rejects one layer up at instrument construction. Rejected explicitly now, with a regression test. - newLongHold now enforces 0 < trailing_stop_percent < 1, mirroring strategy/smatrend.Config.Validate's own exact bounds for the "trailing-stop" rule: 0/negative would place the stop at or above the high-water mark itself, 1+ would place it at or below zero. Parsing alone didn't reject any of these before, so an out-of-range value silently behaved differently from the in-tree strategy this reference claims equivalence with. Tests cover 0, a negative value, 1, and >1. - The equivalence test's tradeKey now includes the trade's own instrument (previously omitted — two runs trading different instruments could otherwise still compare equal), and the final open-position comparison now checks instrument/side/quantity/ avg-price via a new positionKey, not just the position *count*. 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. examples/sma-long-hold coverage 90.5%. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015fVsVuQCgkrhiYaXLxyUF3
|
Pushed 485f0e1 addressing all three findings:
Verification: |
rustyeddy
left a comment
There was a problem hiding this comment.
Rereviewed latest head 485f0e1.
All three findings from the previous review are addressed correctly:
- instrument construction now goes through
instrument.NewCurrencyPair, so semantically invalid pairs such as USD/USD fail locally before Handshake; trailing_stop_percentnow enforces the same strict0 < value < 1bounds asstrategy/smatrend.Config.Validate(), with regression coverage for zero, negative, one, and >1 values;- the equivalence gate now includes trade instrument and compares full economically meaningful open-position state (instrument/side/quantity/average price), rather than only position count.
I rechecked the surrounding strategy logic and the engineered entry/ratchet/stop-out fixture as well. For the deliberately scoped default trailing-stop / fresh-cross / fresh-cross scenario, the external implementation and the equivalence harness now look consistent with the in-tree behavior.
I don't see another correctness blocker. PR #394 looks merge-ready from my review.
Two tiny test-hygiene nits, neither worth holding the PR:
- the once-built temporary binary directory is not explicitly removed, so the test process can leave a small temp directory behind;
- the equivalence test's cleanup calls
Process.Stop(t.Context()); depending on Go's testing-context cleanup timing, usingcontext.Background()there would make the intended shutdown behavior less coupled to test cancellation.
GitHub currently exposes no commit-status entries for this head through the connector, so the merge-ready assessment is based on the PR code/tests plus the reported local verification.
Addresses the two small nits from Rusty's merge-ready re-review of 485f0e1 on PR #394: - buildSMALongHoldBinary now builds into t.TempDir() (auto-cleaned) instead of a package-level sync.Once-cached os.MkdirTemp directory nothing ever removed. - The equivalence test's process.Stop is now deferred on context.Background(), not t.Context(), so its own SIGTERM-then- grace-then-SIGKILL shutdown runs to completion on its configured grace period rather than being coupled to t.Context()'s own cancellation timing relative to t.Cleanup. 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 a58ec7f addressing both nits from the merge-ready re-review:
Verification: full |

Closes #383 (scoped per discussion — see below).
What changed
Adds
examples/sma-long-hold, a real, non-trivialstrategysdk.Strategyproving Strategy Protocol v1 against something more than a hello-world guest: SMA/indicator state, a genuine cross-above entry decision, and a ratcheting protective stop.Scoping decisions (confirmed with Rusty before implementation)
examples/tree (likeexamples/strategysdk-minimal), not a separate GitHub repository — the issue's own "separate repository" suggestion wasn't taken.strategy/smatrend's own default configuration (trailing-stop/fresh-cross/fresh-cross— EQS-01's original, only mechanism before issue smatrend: pluggable ExitRule/ReEntryRule (SMA-trend v2) #347/smatrend: implement full SMA Long Hold state machine (FLAT/PROBATION/TRENDING) #349 made the exit/re-entry/initial-entry rules pluggable), not the full pluggable-rule surface. For exactly this combination, the entry decision reduces to one condition (crossedAbove) regardless of position history — seemain.go's own doc comment — so no re-entry-rule state machine orOnFillcapability is needed at all.Implementation
examples/sma-long-hold/main.go:TRADER_STRATEGY_CONFIG-driven (JSON; defaults to EUR/USD H1, sma_period 20, trailing_stop_percent 0.10). Reusesindicator.SMAdirectly (a pure analytical package, no broker/execution surface) for bit-for-bit numerical agreement withstrategy/smatrend's own use of it, rather than a hand-rolled second implementation.boundary_test.gomechanically enforces "no broker/risk/execution/pipeline/strategy/backtest/service/cmd/adapters access" (the issue's explicit constraint).cmd/trader/backtest/sma_long_hold_equivalence_test.go(packagebacktest, internal — needsenvironmentFactory/nextBarOpenPriceSource): drivesstrategy/smatrendin-process andexamples/sma-long-holdas a realexternal.Launchsubprocess through identicalservice/backtestcompositions over identical canonical data, then asserts their closed/open trades (side, timestamps, RealizedPnL, Costs — deliberately excluding IDs, which are never expected to agree between two independent runs) and final account equity/RealizedPnL are identical.testdata/raw/oanda/EURUSD/2024/06) was needed: the repo's other EURUSD months happen to stay continuously above their own short-period SMA once warmed up, sofresh-crossnever actually fires against them and the comparison would have been trivial (both runs doing nothing). The new fixture engineers a below-SMA warm-up, a sharp cross-above, three bars ratcheting the trailing stop, then a sharp reversal that stops the position out via the real broker-side resting-order mechanism (ADR-026) — a genuine entry/ratchet/exit episode.config/arch_test.go:examples/sma-long-holdexempted from the no-os.Getenvrule, same reasonstrategysdk/cmdalready are..gitignore: guards againstgo build ./...-from-root writing stray example/cmd binaries into the repo root (a real accident hit while verifying this branch).Testing
examples/sma-long-holdgets direct, fast unit coverage (config validation, crossState, a full synthetic entry/ratchet/exitOnBarwalk-through,Start,newFromEnv) independent of the slower cross-process equivalence proof — 89.8% package coverage.go test ./...green under-race,golangci-lintclean (only the 3 known pre-existing unrelatedresearch-runs/errcheck findings), no stray processes/sockets after the test run.🤖 Generated with Claude Code