M5-04: backtest.Replay — deterministic multi-requirement bar replay - #230
Conversation
Implements backtest.Replay (issue #212, M5-04), merging one marketdata.BarReader per strategy.DataRequirement into a single chronologically ordered stream over a requested span, reading only already-published canonical data (never a provider or the network). Per design-notes review on #212: - Merge order uses an intrinsic (bar timestamp, instrument ID, interval) tie-break, never caller-supplied requirement order, so reordering an equivalent requirement list can never change replay order or backtest results. - Duplicate (instrument, interval) requirements are rejected up front with ErrDuplicateRequirement. - NewReplay performs a full coverage preflight across every requirement before opening any reader, returning a structured *CoverageError naming every failing requirement (not just the first); errors.Is(err, marketdata.ErrDataUnavailable) succeeds against it. - If opening a later reader fails after earlier ones already opened, NewReplay closes everything already acquired before returning. - Close is idempotent; Next continues returning io.EOF after exhaustion or Close. - DataRequirement.WarmupBars is ignored entirely: Replay returns exactly the requested span, never silently widened. Tests cover deterministic/order-independent merging, span/requirement filtering, warm-up-ignored, duplicate rejection, multi-failure and partial coverage preflight, EOF/Close idempotency, and context cancellation, using a committed raw OANDA fixture copied from service/marketdata's own (backtest/testdata/raw/oanda), 88.9% coverage. Closes #212 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014JmhCNQ3Nh3veVXzZifMa9
There was a problem hiding this comment.
🟡 Changes recommended
The merge tie-break currently depends on display-only Interval.String() (risking unstable “canonical” ordering), and the single-failure coverage error message can be misleading when partitions are non-current without gaps.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds the initial backtest package deliverable, backtest.Replay, to merge multiple canonical bar streams into a single deterministic Next(ctx) (strategy.BarEvent, error) stream over a requested marketdata.TimeRange, plus fixtures and tests to validate ordering, coverage behavior, and EOF/Close semantics.
Changes:
- Introduces
backtest.Replaywith coverage preflight, duplicate-requirement detection, deterministic merge ordering, and idempotentClose. - Adds black-box + white-box tests covering ordering independence, coverage error aggregation, EOF/Close semantics, and context cancellation.
- Adds package docs, an architecture boundary test, and raw Oanda fixture CSVs used to build canonical data in tests.
File summaries
| File | Description |
|---|---|
| backtest/replay.go | Implements Replay, CoverageError, and deterministic merge logic with coverage preflight. |
| backtest/replay_test.go | Black-box tests for ordering, filtering, coverage failures, EOF/Close, and cancellation. |
| backtest/replay_internal_test.go | White-box tests for tie-break branches and helper behaviors. |
| backtest/doc.go | Establishes package-level responsibilities and architectural intent for backtest. |
| backtest/boundary_test.go | Guards package boundary (no imports of adapters/service/cmd from backtest). |
| backtest/testdata/raw/oanda/EURUSD/2024/01/EURUSD-2024-01-h1.csv | Raw H1 fixture data for tests. |
| backtest/testdata/raw/oanda/EURUSD/2024/01/EURUSD-2024-01-d1.csv | Raw D1 fixture data for tests. |
| backtest/testdata/raw/oanda/EURUSD/2024/02/EURUSD-2024-02-h1.csv | Additional raw fixture partition for test archive completeness. |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if ai, bi := a.req.Instrument.String(), b.req.Instrument.String(); ai != bi { | ||
| return ai < bi | ||
| } | ||
| return a.req.Interval.String() < b.req.Interval.String() | ||
| } |
| if len(e.Failures) == 1 { | ||
| f := e.Failures[0] | ||
| return fmt.Sprintf("backtest: replay: coverage unavailable for %s %s: %d gap(s)", | ||
| f.Requirement.Instrument, f.Requirement.Interval, len(f.Coverage.Gaps)) | ||
| } | ||
| return fmt.Sprintf("backtest: replay: coverage unavailable for %d requirement(s)", len(e.Failures)) | ||
| } |
| // ordered stream over one requested span. It reads only already- | ||
| // published canonical data through marketdata.Manager.Bars — never a | ||
| // provider or the network — so a reproducible backtest never silently | ||
| // changes its own input set (issue #212, M5-04, ADR-035). |
| if prev.Instrument.Equal(cur.Instrument) { | ||
| require.Less(t, prev.Interval.String(), cur.Interval.String()) | ||
| } else { | ||
| require.Less(t, prev.Instrument.String(), cur.Instrument.String()) | ||
| } |
| assert.True(t, lessStream(sameInstD1, eur), "D1 sorts before H1 lexically at an instrument tie") | ||
|
|
rustyeddy
left a comment
There was a problem hiding this comment.
The merge/replay mechanics look well structured, and I agree with Copilot's interval-ordering and diagnostic-message comments. I found one architectural issue that I think should be resolved before merge.
The Coverage() preflight makes replay depend on the raw archive, not just the persisted canonical dataset. marketdata.Coverage explicitly calls rawInventoryLookup, requires RawRoot for M1/H1/H4/D1, and uses the current raw fingerprint to classify a canonical partition as stale. That conflicts with #212's reproducibility boundary and with this PR's own statement that replay reads only already-published canonical data. A canonical dataset that Manager.Bars() can successfully replay can fail NewReplay merely because RawRoot is absent, or because the raw archive has changed since that canonical revision was published. In other words, replay validity can currently change because of state outside the persisted canonical input being replayed.
For a backtest, I think Manager.Bars() should be the authority for whether canonical history is replayable. Its contract already says it is strictly read-only, canonical-store-only, returns no partial result, and proves complete coverage of the requested range before returning a fully materialized BarReader. Replay should not additionally require the source material from which those canonical bars happened to be built.
I would either:
- pre-open
Bars()for every requirement and aggregate requirement-qualifiedErrDataUnavailablefailures before constructing Replay (the readers are already fully materialized and own no external resource), or - if M5 truly needs rich canonical-only gap diagnostics, add/use a canonical-store-only coverage primitive whose semantics do not include raw freshness. I would not reuse the existing M2
Coverage(), because its notion of "current" answers a data-maintenance question, not the backtest question "is this persisted canonical revision complete and readable?"
This distinction will become even more important when run manifests/provenance arrive: a reproducible historical run should be able to replay a pinned canonical revision even if the original raw archive is moved, removed, or later superseded.
The other open Copilot findings are worth fixing in the same pass: use intrinsic interval fields rather than display String() for canonical ordering, and don't report 0 gap(s) when failure is actually a non-current partition. But the raw-archive dependency is the one I consider substantive for #212's architecture/acceptance criteria.
Addresses PR #230 review (Rusty + Copilot): - NewReplay's preflight previously called manager.Coverage, which inspects the raw provider archive (requires RawRoot, classifies a canonical partition "stale" against the raw archive's current fingerprint). That made replay validity depend on state outside the persisted canonical input being replayed, contradicting #212's reproducibility boundary and this package's own "reads only already-published canonical data" claim. NewReplay now opens every requirement directly via manager.Bars — the same canonical-store-only call Replay itself later drains — and aggregates any ErrDataUnavailable failures into *CoverageError, closing every reader already opened before returning. CoverageError now carries the requirement plus Bars' own error (FailedRequirement) instead of a marketdata.Coverage value. - lessStream's interval tie-break now compares Interval's intrinsic Unit()/Count() instead of its display-only String(), so canonical merge order can never depend on Interval's string formatting. Tests updated to match: coverage-failure assertions now check FailedRequirement.Err/Requirement instead of a Coverage value, and tie-break assertions use Unit()/Count() ordering (H1 now sorts before D1 at an instrument tie, the reverse of the old lexical "D1" < "H1" ordering). Coverage remains 89.5%. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014JmhCNQ3Nh3veVXzZifMa9
|
Thanks both — addressed in e4238a4:
Tests updated to match (coverage-failure assertions now check |
rustyeddy
left a comment
There was a problem hiding this comment.
The major issue from my first pass is fixed correctly: NewReplay now uses Manager.Bars() as the replayability authority, so backtests depend only on the persisted canonical dataset and no longer require or reinterpret the raw archive. The interval tie-break is also now based on intrinsic Unit()/Count(), and the misleading 0 gap(s) diagnostic is gone.
I found one remaining error-classification issue before merge.
CoverageError now aggregates all Manager.Bars() failures, but its Is method still unconditionally reports true for marketdata.ErrDataUnavailable:
func (e *CoverageError) Is(target error) bool {
return target == marketdata.ErrDataUnavailable
}That was valid when CoverageError represented only an explicit coverage-preflight failure. It is no longer valid now that a FailedRequirement.Err can be any Bars() failure: invalid query/configuration, listing-resolution failure, ErrInconsistentData, store corruption, etc. A caller could therefore get errors.Is(err, marketdata.ErrDataUnavailable) == true for an error that is actually a data-integrity or configuration problem, which is exactly the kind of distinction the service/CLI layer will eventually need to preserve.
Please make CoverageError.Is reflect its contained causes instead of manufacturing ErrDataUnavailable. For example, return true only if at least one contained Failure.Err matches the requested target (or implement Unwrap() []error over the contained errors and let Go's normal errors.Is traversal do the work). If the intent is for this wrapper to contain only unavailable-data failures, then non-ErrDataUnavailable errors should instead abort NewReplay directly rather than being accumulated as coverage failures.
I prefer the latter semantic split: aggregate genuine ErrDataUnavailable failures across requirements, but return configuration/resolution/inconsistent-data errors immediately because they are not 'coverage unavailable' conditions.
With that classification fixed, this looks ready to merge from my pass.
Addresses Rusty's second PR #230 review pass: CoverageError.Is unconditionally reported true for marketdata.ErrDataUnavailable, but FailedRequirement.Err can now be any Bars() failure (invalid query, listing-resolution failure, ErrInconsistentData, store corruption, etc.) since NewReplay opens every requirement via Bars directly. That let errors.Is(err, marketdata.ErrDataUnavailable) report true for an error that was actually a configuration or data-integrity problem. NewReplay now classifies each Bars failure: only errors matching marketdata.ErrDataUnavailable are accumulated into CoverageError; anything else aborts NewReplay immediately with that error (wrapped, unaltered), closing any readers already opened. CoverageError drops its hand-rolled Is method in favor of Unwrap() []error over its contained causes, so errors.Is/errors.As use Go's normal multi-error traversal instead of an unconditional match. Tests added: a non-ErrDataUnavailable failure (unregistered instrument) aborts immediately rather than landing in CoverageError; CoverageError's Unwrap does not falsely match an unrelated sentinel. backtest package coverage: 93.7%. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014JmhCNQ3Nh3veVXzZifMa9
|
Fixed in bbf2966: NewReplay now classifies each `Bars` failure — only errors matching `marketdata.ErrDataUnavailable` are accumulated into `CoverageError`; any other error (invalid query, listing-resolution failure, `ErrInconsistentData`, etc.) aborts `NewReplay` immediately with that error, closing any readers already opened. `CoverageError` drops the hand-rolled `Is` method in favor of `Unwrap() []error` over its contained causes, so `errors.Is`/`errors.As` traverse the real errors instead of an unconditional match. Added a test proving a non-`ErrDataUnavailable` failure (unregistered instrument) aborts immediately rather than landing in `CoverageError`, plus one proving `CoverageError`'s `Unwrap` doesn't falsely match an unrelated sentinel. Full suite green, `backtest` coverage now 93.7%. |
BarReader.Close always returns nil, but errcheck still flags an ignored return value. Explicit _ = discard at both call sites CI flagged (backtest/replay.go:163,175). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014JmhCNQ3Nh3veVXzZifMa9
What changed
Adds the
backtestpackage's first deliverable:backtest.Replay, which merges onemarketdata.BarReaderperstrategy.DataRequirementinto a single, chronologically orderedNext(ctx) (strategy.BarEvent, error)stream over a requestedmarketdata.TimeRange, reading only already-published canonical data (never a provider or the network — true by construction viaManager.Bars).Per the design-notes review on #212:
(bar timestamp, instrument ID, interval)— an intrinsic property of the data, never caller-supplied requirement order.TestNewReplay_OrderIsIndependentOfRequirementInputOrderproves constructing the same requirement set in reverse input order produces an identical replay sequence.(instrument, interval)pair named twice is rejected up front withErrDuplicateRequirement, before any coverage check or reader is opened — this is what guarantees the merge order above is a genuine total order.NewReplaychecks every requirement's coverage before opening any reader. Incomplete coverage returns a structured*CoverageErrornaming every failing requirement (not just the first) plus each one'smarketdata.Coverage;errors.Is(err, marketdata.ErrDataUnavailable)succeeds against it via a customIsmethod.NewReplaycloses everything already acquired before returning the error.Closeis idempotent;Nextcontinues returningio.EOFafter exhaustion or afterClose.WarmupBarsis ignored entirely —Replayreturns exactly the requested span, never silently widened; warm-up is M5-05: Implement deterministic backtest scheduler #213/M5-06: Define and enforce no-lookahead and warm-up semantics #214's concern.Why it changed
Issue #212 (M5-04).
backtestneeds one deterministic, reproducible input stream before the scheduler (#213) and strategy runner (#214) can be built against it. ADR-035 assignsbacktestsole ownership of no-lookahead-safe replay; this is the first of that layered invariant's three parts (replay ordering — scheduler visibility and strategyViewaccess come later).How it was tested
go build ./...,go vet ./...,gofmt -l .all clean.go test ./... -racepasses across the whole module.backtestpackage coverage: 88.9%.backtest/replay_test.go, black-box) and (backtest/replay_internal_test.go, white-box) cover: deterministic and input-order-independent merging across two intervals; exact span/requirement filtering;WarmupBarsnever widening the span; duplicate-requirement rejection; multi-failure and partial coverage preflight (*CoverageError,errors.Isagainstmarketdata.ErrDataUnavailable);io.EOFrepeat-after-exhaustion and repeat-after-Close;Closeidempotency; context cancellation in bothNewReplayandNext; and the merge tie-break's instrument/interval branches directly.backtest/testdata/raw/oanda/— a committed copy ofservice/marketdata's own EUR/USD H1/D1 raw archive fixture (same convention: package-local copy, not a cross-package reference), built into canonical data at test time viaManager.Plan+Manager.Build, the same test-only shortcutservice/marketdata's own tests use.Which documentation changed
None — this issue is implementation against the already-accepted ADR-035/package-boundaries.org design; no architecture decision changed.
Closes #212