Skip to content

M5-03: Implement deterministic simulation clock for backtests - #229

Merged
rustyeddy merged 2 commits into
mainfrom
feature/211-deterministic-simulation-clock
Aug 28, 2026
Merged

M5-03: Implement deterministic simulation clock for backtests#229
rustyeddy merged 2 commits into
mainfrom
feature/211-deterministic-simulation-clock

Conversation

@rustyeddy

Copy link
Copy Markdown
Owner

What changed

Adds clock.Simulated.AdvanceTo(t time.Time) error: the primitive a historical-timestamp-driven backtest scheduler (#213) needs, so it never has to compute t.Sub(Now()) itself at every call site across irregularly-spaced bars (weekends, holidays, gaps).

Why it changed

Per #211's design-notes review: no context.Context parameter. AdvanceTo is a synchronous, bounded state mutation with no blocking work — the same character Advance already has. Adding ctx here would put cancellation policy at the wrong layer: the scheduler owns the potentially long-running replay loop and should check ctx between observations before calling the clock, not make every deterministic domain primitive context-aware.

Implementation shares a new private advanceBy(d) core with the existing Advance, both acquiring the lock once and delegating to it, so the two can never disagree about what "advance" means and AdvanceTo never races against a concurrent Advance/AdvanceTo call between reading Now() and applying the delta.

Semantics, each covered by a dedicated test:

  • t canonicalized to UTC with any monotonic reading stripped (matching NewSimulated's own canonicalization) before comparison, so an equivalent instant in a different location or carrying monotonic metadata never produces different clock state.
  • t == Now() is a valid no-op, matching Advance(0) — it still fires any timer already due.
  • t < Now() returns ErrNegativeAdvance, leaving both time and timer state unchanged.
  • Crossing multiple timers' deadlines in one call preserves the existing deadline/creation-order firing behavior, and Now() lands exactly on the requested target.
  • Two independently constructed clocks given the same start, timers, and AdvanceTo sequence produce identical observations.

No new package or type — purely additive to clock, the same way ADR-025/027/030 each added one missing primitive to num when a consuming M3/M4 issue needed it.

The repo-wide TestDomainCodeDoesNotCallTimeDirectly guard (clock/arch_test.go) already mechanically covers this issue's own "no wall-clock dependency" acceptance criterion for every package outside clock/cmd/adapters. Per review, a fuller end-to-end demonstration that a deterministic run's observable results depend only on simulated time (not just the clock primitive in isolation) is expected once #213's own scheduler loop exists to exercise it — there's no scheduler yet for that integration test to drive.

How it was tested

  • TestSimulatedAdvanceTo*: exact-target movement, no-op-at-current-time (including still firing due timers), rejection of a past target (leaving time and timer state unchanged), UTC/monotonic canonicalization, multi-timer-deadline crossing in one call, and cross-instance determinism.
  • Every existing clock test still passes unmodified.
  • go build ./..., go vet ./..., gofmt -l ., go test ./... -race all clean. clock package coverage: 100.0%.

Which documentation changed

None required — this is a purely additive method on an already-Accepted, pre-existing type; no new architectural decision was introduced.

Closes #211.

Adds clock.Simulated.AdvanceTo(t time.Time) error: the primitive a
historical-timestamp-driven backtest scheduler (#213) needs, so it
never has to compute t.Sub(Now()) itself at every call site across
irregularly-spaced bars (weekends, holidays, gaps).

Per design-notes review: no context.Context parameter. AdvanceTo is a
synchronous, bounded state mutation with no blocking work, the same
character Advance already has; adding ctx here would put cancellation
policy at the wrong layer -- the scheduler owns the potentially
long-running replay loop and should check ctx between observations
before calling the clock, not make every deterministic domain
primitive context-aware.

Implementation shares a new private advanceBy(d) core with the
existing Advance, both acquiring the lock once and delegating to it,
so the two can never disagree about what "advance" means and AdvanceTo
never races against a concurrent Advance/AdvanceTo call between
reading Now() and applying the delta.

Semantics, each covered by a dedicated test:
- t canonicalized to UTC with any monotonic reading stripped (matching
  NewSimulated's own canonicalization) before comparison, so an
  equivalent instant in a different location or carrying monotonic
  metadata never produces different clock state.
- t == Now() is a valid no-op, matching Advance(0) -- it still fires
  any timer already due.
- t < Now() returns ErrNegativeAdvance, leaving both time and timer
  state unchanged.
- Crossing multiple timers' deadlines in one call preserves the
  existing deadline/creation-order firing behavior, and Now() lands
  exactly on the requested target.
- Two independently constructed clocks given the same start, timers,
  and AdvanceTo sequence produce identical observations.

No new package or type -- purely additive to clock, the same way
ADR-025/027/030 each added one missing primitive to num when a
consuming M3/M4 issue needed it. The repo-wide
TestDomainCodeDoesNotCallTimeDirectly guard (clock/arch_test.go)
already mechanically covers this issue's own "no wall-clock dependency"
acceptance criterion for every package outside clock/cmd/adapters; a
fuller end-to-end demonstration that a deterministic run's observable
results depend only on simulated time is expected once #213's own
scheduler loop exists to exercise, per review.

Tested: go build ./..., go vet ./..., gofmt -l ., go test ./... -race
all clean. clock package coverage: 100.0%.
Copilot AI lite review requested due to automatic review settings August 28, 2026 19:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

One of the new tests is currently ineffective (it passes regardless of AdvanceTo behavior because it uses NewTimer(0) which is already ready on creation).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a deterministic, timestamp-targeted advancement primitive to the simulated clock to support historical/backtest replay without callers computing relative deltas (and keeps timer-firing semantics consistent with existing Advance behavior).

Changes:

  • Refactors Simulated.Advance to delegate to a shared, lock-held advanceBy(d) core.
  • Adds (*Simulated).AdvanceTo(t time.Time) error with UTC + monotonic-stripping canonicalization and backward-move rejection.
  • Introduces a dedicated clock/advance_to_test.go suite covering target movement, canonicalization, rejection, timer deadlines, and determinism.
File summaries
File Description
clock/simulated.go Adds AdvanceTo and centralizes advance semantics via advanceBy under a single lock.
clock/advance_to_test.go Adds unit tests for AdvanceTo behavior and determinism.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread clock/advance_to_test.go Outdated
Comment on lines +34 to +45
func TestSimulatedAdvanceToEqualToNowStillFiresDueTimers(t *testing.T) {
start := mustParse(t, "2026-01-01T00:00:00Z")
c := NewSimulated(start)
timer := c.NewTimer(0)

require.NoError(t, c.AdvanceTo(start))
select {
case <-timer.C():
default:
t.Fatal("a timer already due must fire on a no-op AdvanceTo")
}
}

@rustyeddy rustyeddy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation itself looks good to me: AdvanceTo canonicalizes the target, holds the mutex across target-delta calculation and mutation, reuses the same advanceBy core as Advance, preserves the existing timer semantics, and keeps cancellation at the scheduler layer as discussed on #211.

I agree with Copilot's one test finding: TestSimulatedAdvanceToEqualToNowStillFiresDueTimers does not prove what its name/comment claims. NewTimer(0) fires immediately inside NewTimer, before AdvanceTo is called, so the assertion would pass even if AdvanceTo(start) never called fireDue().

There is also a useful design observation here: through the public API there normally cannot be a pending timer whose deadline is already <= Now() — non-positive timers fire immediately, and positive timers become due only as the clock advances (which itself calls fireDue). So rather than reaching into private timer state just to manufacture an otherwise unreachable condition, I would simply remove that test and soften the AdvanceTo documentation from "matching Advance(0), which still fires any timer already due" to just state that equal-to-now is a valid no-op. The shared advanceBy(0) still calls fireDue(), but that is an implementation detail rather than an externally meaningful semantic requiring a special test.

With that cleanup, I consider #229 ready to merge. I found no substantive issue in the implementation.

Rusty + Copilot both caught the same real issue:
TestSimulatedAdvanceToEqualToNowStillFiresDueTimers didn't prove what
it claimed. NewTimer(0) fires immediately inside NewTimer itself
(never added to the active timer set at all), so the assertion passed
regardless of whether AdvanceTo(start) called fireDue() -- the test
was ineffective, not a real regression guard.

Removed the test rather than reaching into private timer state to
manufacture an otherwise-unreachable condition (through the public API
there cannot be a pending timer whose deadline is already <= Now():
non-positive timers fire immediately, and positive ones become due
only as the clock advances, which itself already calls fireDue).
Softened AdvanceTo's own doc comment to state only that t == Now() is
a valid no-op, without asserting a timer-firing detail that was never
actually externally observable or meaningfully testable at this
target-equals-now boundary.

Tested: go build ./..., go vet ./..., gofmt -l ., go test ./... -race
all clean. clock package coverage unchanged at 100.0%.
@rustyeddy

Copy link
Copy Markdown
Owner Author

Addressed the finding: `TestSimulatedAdvanceToEqualToNowStillFiresDueTimers` didn't prove what it claimed — `NewTimer(0)` fires immediately inside `NewTimer` itself (never added to the active timer set), so the assertion passed regardless of whether `AdvanceTo` called `fireDue()`. Verified directly against `NewTimer`'s own code before agreeing.

Removed the test rather than reaching into private timer state to manufacture an otherwise-unreachable condition — through the public API there can't be a pending timer whose deadline is already <= Now(). Softened `AdvanceTo`'s doc comment to state only that `t == Now()` is a valid no-op, without asserting the untestable timer-firing detail.

Full suite (`go build`, `go vet`, `gofmt -l`, `go test ./... -race`) clean. Coverage unchanged at 100.0%.

@rustyeddy
rustyeddy merged commit ed0bee4 into main Aug 28, 2026
1 check passed
@rustyeddy
rustyeddy deleted the feature/211-deterministic-simulation-clock branch August 28, 2026 19:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

M5-03: Implement deterministic simulation clock for backtests

2 participants