diff --git a/strategy/boundary_test.go b/strategy/boundary_test.go new file mode 100644 index 0000000..715dc2a --- /dev/null +++ b/strategy/boundary_test.go @@ -0,0 +1,88 @@ +package strategy + +import ( + "go/parser" + "go/token" + "os" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestStrategyNeverImportsBrokerExecutionRiskOrPipeline is issue #210 +// (M5-02)'s own architectural guard, mirroring execution/ +// boundary_test.go's and risk/boundary_test.go's own: strategy must +// never import broker, execution, risk, or pipeline (ADR-005: a +// strategy that can name a broker package can be coupled to one; the +// same reasoning excludes execution/risk/pipeline, none of which a +// broker-neutral strategy contract should ever depend on). It parses +// every non-test .go file's own import block directly, per-file, +// rather than a package-level `go list` that would merge every file's +// imports together. +// +// A forbidden root is matched exact-or-prefix-with-"/", not by exact +// string equality, so a future subpackage import (e.g. +// "github.com/rustyeddy/trader/broker/foo" or +// "github.com/rustyeddy/trader/pipeline/internal/...") is caught too — +// the same fix review feedback on PR #194 established for execution's +// own guard, and #210's own review asked to carry forward here. +func TestStrategyNeverImportsBrokerExecutionRiskOrPipeline(t *testing.T) { + forbiddenRoots := []string{ + "github.com/rustyeddy/trader/broker", + "github.com/rustyeddy/trader/execution", + "github.com/rustyeddy/trader/risk", + "github.com/rustyeddy/trader/pipeline", + } + + entries, err := os.ReadDir(".") + require.NoError(t, err) + + fset := token.NewFileSet() + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || len(name) < 3 || name[len(name)-3:] != ".go" { + continue + } + if len(name) > 8 && name[len(name)-8:] == "_test.go" { + continue + } + + f, err := parser.ParseFile(fset, name, nil, parser.ImportsOnly) + require.NoError(t, err, name) + + for _, imp := range f.Imports { + path, err := strconv.Unquote(imp.Path.Value) + require.NoError(t, err, name) + + for _, root := range forbiddenRoots { + require.False(t, isForbiddenImport(path, root), + "%s must not import %s or any of its subpackages (ADR-005/ADR-035, package-boundaries.org)", name, root) + } + } + } +} + +// isForbiddenImport reports whether path is root itself or a +// subpackage of root — exact-or-prefix-with-"/". +func isForbiddenImport(path, root string) bool { + return path == root || strings.HasPrefix(path, root+"/") +} + +func TestIsForbiddenImport(t *testing.T) { + const root = "github.com/rustyeddy/trader/broker" + + tests := []struct { + path string + want bool + }{ + {"github.com/rustyeddy/trader/broker", true}, + {"github.com/rustyeddy/trader/broker/foo", true}, + {"github.com/rustyeddy/trader/order", false}, + {"github.com/rustyeddy/trader/brokerage", false}, + } + for _, tt := range tests { + require.Equal(t, tt.want, isForbiddenImport(tt.path, root), tt.path) + } +} diff --git a/strategy/descriptor.go b/strategy/descriptor.go new file mode 100644 index 0000000..033351c --- /dev/null +++ b/strategy/descriptor.go @@ -0,0 +1,43 @@ +package strategy + +import ( + "github.com/rustyeddy/trader/instrument" + "github.com/rustyeddy/trader/marketdata" +) + +// Descriptor identifies one strategy and states what data it needs +// before it can be run. Kept narrow for v0 (issue #210's own review): +// ParameterSchema and StateVersion are not added until #215 (run +// manifest) or #218 (journal/state) shows exactly what they need to +// carry — a strategy's own parameters remain strongly-typed +// configuration passed when the concrete Strategy value is +// constructed, never a generic runtime parameter bag. +type Descriptor struct { + // Name identifies this strategy, for example "ema_cross". Used as + // the Source on every order.Intent the strategy's own + // IntentFactory builds (see Environment). + Name string + // Version distinguishes revisions of the same strategy — for + // example when its logic changes in a way that would make two runs + // incomparable. + Version string + // Requirements states the market data this strategy needs before + // its first OnBar call, so a runner can validate the environment + // (and complete any warm-up) ahead of time rather than discovering + // a missing requirement mid-run. + Requirements []DataRequirement +} + +// DataRequirement names one instrument/interval a strategy needs bars +// for, and how many bars of warm-up history it needs before its first +// decision is meaningful. +type DataRequirement struct { + // Instrument is the canonical instrument identity required. + Instrument instrument.ID + // Interval is the bar interval required for Instrument. + Interval marketdata.Interval + // WarmupBars is how many bars of history before the run's own + // start time this requirement needs replayed (but not decided on) + // before OnBar is first called for real. + WarmupBars int +} diff --git a/strategy/doc.go b/strategy/doc.go new file mode 100644 index 0000000..598e8f0 --- /dev/null +++ b/strategy/doc.go @@ -0,0 +1,48 @@ +// Package strategy defines Trader's broker-neutral strategy runtime +// contract (issue #210, M5-02): how a strategy receives market +// observations and emits order.Intent values, without knowing whether +// it is running inside a backtest or a future live session. +// +// # Scope +// +// Strategy is deliberately small — Describe, Start, and OnBar — per +// the M5-02 design review: TickHandler, FillHandler, +// AccountEventHandler, StateManager, and DataRequirement.NeedTicks are +// not published here. Each is additive, optional-capability surface +// area for a later issue with a concrete consumer to shape it against, +// not something to speculate into place now (the architecture +// document's own "small required core plus capability discovery" +// guidance). +// +// View is similarly minimal: Account() account.Snapshot is the one +// read it exposes today. A historical-bar lookup method is +// deliberately deferred until the replay/scheduler work (#212/#213) +// proves the access pattern a real backtest run needs — freezing it +// now risked designing against the wrong shape. +// +// # Dependency direction +// +// strategy depends only on order, marketdata, instrument, account, +// clock, id, num, and log/slog — never on broker, execution, risk, or +// pipeline (ADR-005, ADR-035). See boundary_test.go for the mechanical +// guard. +// +// # Intent construction and correlation +// +// OnBar returns fully-formed, canonical order.Intent values — never a +// parallel strategy.Intent DTO (ADR-005's own settled decision, +// reaffirmed on #210's own review). A strategy never touches Trader's +// ID-generation machinery directly to build one: Environment.Intents +// is a narrow IntentFactory capability the runtime injects, which +// generates deterministic IntentID/EventID/CorrelationID values and +// calls order.NewIntent on the strategy's behalf. The strategy still +// owns trading *semantics* — in particular, whether several intents +// returned from one OnBar call belong to one correlation group (for +// example a reversal expressed as an exit intent and an enter intent) +// — via IntentFactory.NewCorrelationID/WithCorrelation, without ever +// needing to know how a CorrelationID is actually generated. This +// keeps deterministic ID generation as runtime infrastructure that can +// later be represented cleanly across an out-of-process strategy +// protocol boundary, rather than something every strategy author +// reimplements. +package strategy diff --git a/strategy/environment.go b/strategy/environment.go new file mode 100644 index 0000000..a89d138 --- /dev/null +++ b/strategy/environment.go @@ -0,0 +1,31 @@ +package strategy + +import ( + "log/slog" + + "github.com/rustyeddy/trader/clock" +) + +// Environment is Start's own injected-dependency bundle: capabilities +// only, never configuration (issue #210's own review) — a strategy's +// own parameters remain strongly-typed configuration supplied when the +// concrete Strategy value is constructed, not a field here. +// +// Environment carries no hidden global: a strategy that only ever +// calls time through Clock, and only ever builds an Intent through +// Intents, is reproducible across independent runs sharing the same +// initial Clock/Intents state — the same determinism guarantee every +// other M3/M4 component already provides via injected dependencies. +type Environment struct { + // Clock is the sole time source a strategy may consult. + Clock clock.Clock + // Intents builds every order.Intent this strategy emits. See + // IntentFactory's own doc comment for why a strategy never touches + // Trader's ID-generation machinery directly. + Intents IntentFactory + // Logger receives this strategy's own structured records, if any. + // A nil Logger is never handed to a strategy; a runner injects + // logging.Discard() when the caller supplied none, matching every + // other Trader composition-root convention. + Logger *slog.Logger +} diff --git a/strategy/event.go b/strategy/event.go new file mode 100644 index 0000000..0e7004a --- /dev/null +++ b/strategy/event.go @@ -0,0 +1,17 @@ +package strategy + +import ( + "github.com/rustyeddy/trader/instrument" + "github.com/rustyeddy/trader/marketdata" +) + +// BarEvent is one instrument's completed bar, the trigger for OnBar. +type BarEvent struct { + // Instrument is the canonical instrument identity this bar + // belongs to. + Instrument instrument.ID + // Interval is Bar's own aggregation interval. + Interval marketdata.Interval + // Bar is the completed bar itself. + Bar marketdata.Bar +} diff --git a/strategy/intent.go b/strategy/intent.go new file mode 100644 index 0000000..a0d8cb0 --- /dev/null +++ b/strategy/intent.go @@ -0,0 +1,129 @@ +package strategy + +import ( + "github.com/rustyeddy/trader/clock" + "github.com/rustyeddy/trader/id" + "github.com/rustyeddy/trader/instrument" + "github.com/rustyeddy/trader/num" + "github.com/rustyeddy/trader/order" +) + +// IntentFactory is the narrow, runtime-injected capability a Strategy +// uses to build canonical order.Intent values (issue #210's own +// review): it owns deterministic ID generation +// (IntentID/EventID/CorrelationID) and calls order.NewIntent on the +// strategy's behalf, so every Intent it returns is valid from the +// instant it exists (order.NewIntent's own contract) without any +// strategy needing direct access to an *id.Generator. +// +// The strategy still owns trading semantics: whether several intents +// it emits from one OnBar call belong to one correlation group is the +// strategy's own decision, expressed via NewCorrelationID/ +// WithCorrelation, not something IntentFactory decides on its behalf. +// Every call not explicitly grouped via WithCorrelation mints its own +// fresh CorrelationID — an independent intent, not implicitly +// correlated with any other. +type IntentFactory interface { + // Enter builds an order.IntentEnter for instID/side. + Enter(instID instrument.ID, side order.Side) (order.Intent, error) + // Exit builds an order.IntentExit for instID. + Exit(instID instrument.ID) (order.Intent, error) + // AdjustStop builds an order.IntentAdjustStop moving instID's + // protective stop to stopPrice. + AdjustStop(instID instrument.ID, stopPrice num.Price) (order.Intent, error) + // TargetExposure builds an order.IntentTargetExposure for instID, + // side, and quantity. + TargetExposure(instID instrument.ID, side order.Side, quantity num.Quantity) (order.Intent, error) + + // NewCorrelationID mints a fresh, deterministic id.CorrelationID a + // caller can pass to WithCorrelation to group multiple intents — + // for example a reversal expressed as an exit intent and an enter + // intent that should be recognized as one causally-related move. + NewCorrelationID() (id.CorrelationID, error) + // WithCorrelation returns an IntentFactory that builds every + // intent under corr instead of minting a fresh CorrelationID per + // call. The returned factory shares this one's clock, ID + // generator, and Source; only its correlation behavior differs. + WithCorrelation(corr id.CorrelationID) IntentFactory +} + +// NewIntentFactory returns an IntentFactory that generates identifiers +// from ids and timestamps from c, attributing every built Intent's +// Metadata.Source to source (conventionally the owning strategy's own +// Descriptor.Name — see the package doc comment). +func NewIntentFactory(c clock.Clock, ids *id.Generator, source id.Source) IntentFactory { + return &intentFactory{clock: c, ids: ids, source: source} +} + +type intentFactory struct { + clock clock.Clock + ids *id.Generator + source id.Source + corr *id.CorrelationID // nil: mint a fresh CorrelationID per call +} + +func (f *intentFactory) NewCorrelationID() (id.CorrelationID, error) { + return id.GenerateCorrelationID(f.ids) +} + +func (f *intentFactory) WithCorrelation(corr id.CorrelationID) IntentFactory { + return &intentFactory{clock: f.clock, ids: f.ids, source: f.source, corr: &corr} +} + +func (f *intentFactory) Enter(instID instrument.ID, side order.Side) (order.Intent, error) { + return f.build(order.IntentEnter, instID, side, nil, nil) +} + +func (f *intentFactory) Exit(instID instrument.ID) (order.Intent, error) { + return f.build(order.IntentExit, instID, 0, nil, nil) +} + +func (f *intentFactory) AdjustStop(instID instrument.ID, stopPrice num.Price) (order.Intent, error) { + return f.build(order.IntentAdjustStop, instID, 0, nil, &stopPrice) +} + +func (f *intentFactory) TargetExposure(instID instrument.ID, side order.Side, quantity num.Quantity) (order.Intent, error) { + return f.build(order.IntentTargetExposure, instID, side, &quantity, nil) +} + +// build assembles and validates one Intent, resolving this factory's +// correlation policy (a shared corr if WithCorrelation was used, +// otherwise a fresh one per call) before delegating to order.NewIntent +// for full field validation. +func (f *intentFactory) build(kind order.IntentKind, instID instrument.ID, side order.Side, quantity *num.Quantity, stopPrice *num.Price) (order.Intent, error) { + intentID, err := id.GenerateIntentID(f.ids) + if err != nil { + return order.Intent{}, err + } + eventID, err := id.GenerateEventID(f.ids) + if err != nil { + return order.Intent{}, err + } + + corrID, err := f.correlationID() + if err != nil { + return order.Intent{}, err + } + + return order.NewIntent(order.Intent{ + IntentID: intentID, + Kind: kind, + Instrument: instID, + Side: side, + Quantity: quantity, + StopPrice: stopPrice, + Metadata: id.Metadata{ + EventID: eventID, + CorrelationID: corrID, + Timestamp: f.clock.Now(), + Source: f.source, + }, + }) +} + +func (f *intentFactory) correlationID() (id.CorrelationID, error) { + if f.corr != nil { + return *f.corr, nil + } + return id.GenerateCorrelationID(f.ids) +} diff --git a/strategy/intent_test.go b/strategy/intent_test.go new file mode 100644 index 0000000..7643c88 --- /dev/null +++ b/strategy/intent_test.go @@ -0,0 +1,163 @@ +package strategy + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/rustyeddy/trader/clock" + "github.com/rustyeddy/trader/id" + "github.com/rustyeddy/trader/instrument" + "github.com/rustyeddy/trader/num" + "github.com/rustyeddy/trader/order" +) + +var testStart = time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC) + +func testFactory(t *testing.T) (IntentFactory, *id.Generator) { + t.Helper() + c := clock.NewSimulated(testStart) + gen := id.NewGenerator(c, id.NewDeterministic(1, 2)) + return NewIntentFactory(c, gen, "strategy.test"), gen +} + +func testInstrumentID(t *testing.T) instrument.ID { + t.Helper() + inst, err := instrument.NewCurrencyPair(num.MustParseCurrency("EUR"), num.MustParseCurrency("USD")) + require.NoError(t, err) + return inst.ID() +} + +func TestIntentFactory_Enter(t *testing.T) { + f, _ := testFactory(t) + instID := testInstrumentID(t) + + in, err := f.Enter(instID, order.Buy) + require.NoError(t, err) + assert.Equal(t, order.IntentEnter, in.Kind) + assert.Equal(t, instID, in.Instrument) + assert.Equal(t, order.Buy, in.Side) + assert.Nil(t, in.Quantity) + assert.Nil(t, in.StopPrice) + assert.False(t, in.IntentID.IsZero()) + assert.False(t, in.Metadata.EventID.IsZero()) + assert.False(t, in.Metadata.CorrelationID.IsZero()) + assert.True(t, in.Metadata.CausationID.IsZero(), "an Intent is the first stage of its own workflow") + assert.Equal(t, testStart, in.Metadata.Timestamp) + assert.Equal(t, id.Source("strategy.test"), in.Metadata.Source) +} + +func TestIntentFactory_Exit(t *testing.T) { + f, _ := testFactory(t) + instID := testInstrumentID(t) + + in, err := f.Exit(instID) + require.NoError(t, err) + assert.Equal(t, order.IntentExit, in.Kind) + assert.Equal(t, instID, in.Instrument) +} + +func TestIntentFactory_AdjustStop(t *testing.T) { + f, _ := testFactory(t) + instID := testInstrumentID(t) + stop := num.MustParsePrice("1.05000") + + in, err := f.AdjustStop(instID, stop) + require.NoError(t, err) + assert.Equal(t, order.IntentAdjustStop, in.Kind) + require.NotNil(t, in.StopPrice) + assert.True(t, in.StopPrice.Equal(stop)) +} + +func TestIntentFactory_TargetExposure(t *testing.T) { + f, _ := testFactory(t) + instID := testInstrumentID(t) + qty := num.MustParseQuantity("1000") + + in, err := f.TargetExposure(instID, order.Sell, qty) + require.NoError(t, err) + assert.Equal(t, order.IntentTargetExposure, in.Kind) + assert.Equal(t, order.Sell, in.Side) + require.NotNil(t, in.Quantity) + assert.True(t, in.Quantity.Equal(qty)) +} + +// TestIntentFactory_DefaultCorrelationIsFreshPerCall proves the +// documented default: two independent calls, neither grouped via +// WithCorrelation, get their own distinct CorrelationID. +func TestIntentFactory_DefaultCorrelationIsFreshPerCall(t *testing.T) { + f, _ := testFactory(t) + instID := testInstrumentID(t) + + first, err := f.Enter(instID, order.Buy) + require.NoError(t, err) + second, err := f.Exit(instID) + require.NoError(t, err) + + assert.NotEqual(t, first.Metadata.CorrelationID, second.Metadata.CorrelationID) +} + +// TestIntentFactory_WithCorrelationGroupsIntents proves a strategy can +// explicitly group multiple intents (for example a reversal expressed +// as an exit plus an enter) under one shared CorrelationID. +func TestIntentFactory_WithCorrelationGroupsIntents(t *testing.T) { + f, _ := testFactory(t) + instID := testInstrumentID(t) + + corr, err := f.NewCorrelationID() + require.NoError(t, err) + grouped := f.WithCorrelation(corr) + + exit, err := grouped.Exit(instID) + require.NoError(t, err) + enter, err := grouped.Enter(instID, order.Buy) + require.NoError(t, err) + + assert.Equal(t, corr, exit.Metadata.CorrelationID) + assert.Equal(t, corr, enter.Metadata.CorrelationID) + assert.NotEqual(t, exit.IntentID, enter.IntentID, "grouped intents still get distinct identities") + assert.NotEqual(t, exit.Metadata.EventID, enter.Metadata.EventID) +} + +// TestIntentFactory_WithCorrelationDoesNotMutateOriginal proves +// WithCorrelation returns an independent factory: the original keeps +// minting a fresh CorrelationID per call. +func TestIntentFactory_WithCorrelationDoesNotMutateOriginal(t *testing.T) { + f, _ := testFactory(t) + instID := testInstrumentID(t) + + corr, err := f.NewCorrelationID() + require.NoError(t, err) + _ = f.WithCorrelation(corr) + + first, err := f.Enter(instID, order.Buy) + require.NoError(t, err) + second, err := f.Exit(instID) + require.NoError(t, err) + + assert.NotEqual(t, corr, first.Metadata.CorrelationID) + assert.NotEqual(t, first.Metadata.CorrelationID, second.Metadata.CorrelationID) +} + +// TestIntentFactory_DeterministicAcrossIndependentInstances proves two +// factories built from identical initial deterministic inputs produce +// identical Intent values — the same cross-instance determinism +// guarantee execution.Planner and risk.Sizer already establish. +func TestIntentFactory_DeterministicAcrossIndependentInstances(t *testing.T) { + instID := testInstrumentID(t) + + build := func() order.Intent { + c := clock.NewSimulated(testStart) + gen := id.NewGenerator(c, id.NewDeterministic(1, 2)) + f := NewIntentFactory(c, gen, "strategy.test") + in, err := f.Enter(instID, order.Buy) + require.NoError(t, err) + return in + } + + first := build() + second := build() + assert.Equal(t, first, second) +} diff --git a/strategy/strategy.go b/strategy/strategy.go new file mode 100644 index 0000000..41be6d9 --- /dev/null +++ b/strategy/strategy.go @@ -0,0 +1,43 @@ +package strategy + +import ( + "context" + + "github.com/rustyeddy/trader/order" +) + +// Strategy is Trader's broker-neutral strategy runtime contract +// (ADR-005): a policy that interprets market/portfolio state and +// emits order.Intent values, never a broker handle and never an order +// submission call. The same Strategy value runs identically inside a +// backtest or a future live session — a strategy cannot tell which +// mode it is running in, since Environment and View expose only +// capabilities and read-only state, never a mode flag. +// +// The contract is deliberately small; see the package doc comment for +// why optional capability interfaces (tick handling, fill handling, +// account-event handling, state persistence) are not published here +// yet. +type Strategy interface { + // Describe returns this strategy's identity and data requirements. + // A runner calls this once, before Start, to validate its own + // environment (data availability, warm-up) ahead of the first + // OnBar call. + Describe() Descriptor + + // Start is called once, before any OnBar call, with this run's own + // Environment. A strategy performs one-time setup here — it does + // not yet have a View, since no bar has been replayed. OnBar itself + // never receives an Environment: a strategy that needs Environment + // or any of its capabilities (in particular Intents, to build the + // order.Intent values OnBar returns) must retain what it needs from + // env here, typically by storing it on the Strategy's own value. + Start(ctx context.Context, env Environment) error + + // OnBar is called once per completed bar this strategy required + // (Descriptor.Requirements), after any configured warm-up period + // has elapsed. It returns zero or more order.Intent values, built + // via the IntentFactory Start's own Environment.Intents provided — + // never a broker call, never a direct order submission. + OnBar(ctx context.Context, event BarEvent, view View) ([]order.Intent, error) +} diff --git a/strategy/strategy_test.go b/strategy/strategy_test.go new file mode 100644 index 0000000..31de376 --- /dev/null +++ b/strategy/strategy_test.go @@ -0,0 +1,161 @@ +package strategy + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/rustyeddy/trader/account" + "github.com/rustyeddy/trader/clock" + "github.com/rustyeddy/trader/id" + "github.com/rustyeddy/trader/instrument" + "github.com/rustyeddy/trader/logging" + "github.com/rustyeddy/trader/marketdata" + "github.com/rustyeddy/trader/num" + "github.com/rustyeddy/trader/order" +) + +// fakeView is a minimal View test double: an account.Snapshot fixed at +// construction, standing in for whatever a real runner would compute +// from already-visible state. +type fakeView struct { + snap account.Snapshot +} + +func (v fakeView) Account() account.Snapshot { return v.snap } + +func mustSnapshot(t *testing.T) account.Snapshot { + t.Helper() + c := clock.NewSimulated(testStart) + gen := id.NewGenerator(c, id.NewDeterministic(3, 4)) + accountID, err := id.GenerateAccountID(gen) + require.NoError(t, err) + usd := num.MustParseCurrency("USD") + snap, err := account.NewSnapshot(account.SnapshotParams{ + AccountID: accountID, + Broker: "sim", + Currency: usd, + AsOf: testStart, + CashBalances: []num.Money{num.MustParseMoney("10000", usd)}, + Equity: num.MustParseMoney("10000", usd), + BuyingPower: num.MustParseMoney("10000", usd), + MarginUsed: num.MustParseMoney("0", usd), + MarginAvailable: num.MustParseMoney("10000", usd), + RealizedPnL: num.MustParseMoney("0", usd), + UnrealizedPnL: num.MustParseMoney("0", usd), + Fees: num.MustParseMoney("0", usd), + Financing: num.MustParseMoney("0", usd), + }) + require.NoError(t, err) + return snap +} + +func mustEurUsdInstrumentID(t *testing.T) instrument.ID { + t.Helper() + inst, err := instrument.NewCurrencyPair(num.MustParseCurrency("EUR"), num.MustParseCurrency("USD")) + require.NoError(t, err) + return inst.ID() +} + +// buyOnFirstBarStrategy is a minimal, representative Strategy +// implementation (issue #210's own "tests demonstrate representative +// strategy invocation and intent emission" acceptance criterion, the +// same "test double, not a real trading strategy" scope risk's own +// fakeRule established): it enters long on the first bar it ever +// sees, and does nothing on every bar after — enough to exercise +// Describe/Start/OnBar and IntentFactory together without inventing +// real trading logic this issue does not need. +// +// It retains the IntentFactory Start receives via env and calls it +// from OnBar — the same lifecycle contract a real strategy follows: +// the runtime injects capabilities once, in Start, and strategy logic +// uses those retained capabilities across every later OnBar call, +// since OnBar itself never receives an Environment (review feedback +// on PR #228). +type buyOnFirstBarStrategy struct { + instID instrument.ID + intents IntentFactory + entered bool +} + +func (s *buyOnFirstBarStrategy) Describe() Descriptor { + return Descriptor{ + Name: "buy_on_first_bar", + Version: "v0", + Requirements: []DataRequirement{ + {Instrument: s.instID, Interval: marketdata.H1, WarmupBars: 0}, + }, + } +} + +func (s *buyOnFirstBarStrategy) Start(ctx context.Context, env Environment) error { + s.intents = env.Intents + return nil +} + +func (s *buyOnFirstBarStrategy) OnBar(ctx context.Context, event BarEvent, view View) ([]order.Intent, error) { + if s.entered { + return nil, nil + } + s.entered = true + in, err := s.intents.Enter(s.instID, order.Buy) + if err != nil { + return nil, err + } + return []order.Intent{in}, nil +} + +var _ Strategy = (*buyOnFirstBarStrategy)(nil) + +// TestStrategy_RepresentativeInvocation drives a Strategy through the +// real contract end to end: Describe, Start with an Environment, and +// OnBar with a View — proving a strategy can retain Start's injected +// IntentFactory and use it from OnBar to actually emit an +// order.Intent, the way a runner would exercise it. +func TestStrategy_RepresentativeInvocation(t *testing.T) { + ctx := context.Background() + instID := mustEurUsdInstrumentID(t) + s := &buyOnFirstBarStrategy{instID: instID} + + desc := s.Describe() + assert.Equal(t, "buy_on_first_bar", desc.Name) + require.Len(t, desc.Requirements, 1) + assert.Equal(t, instID, desc.Requirements[0].Instrument) + + c := clock.NewSimulated(testStart) + gen := id.NewGenerator(c, id.NewDeterministic(1, 2)) + env := Environment{ + Clock: c, + Intents: NewIntentFactory(c, gen, id.Source(desc.Name)), + Logger: logging.Discard(), + } + require.NoError(t, s.Start(ctx, env)) + + bar := marketdata.Bar{ + Time: testStart, + Open: num.MustParsePrice("1.10000"), + High: num.MustParsePrice("1.10500"), + Low: num.MustParsePrice("1.09500"), + Close: num.MustParsePrice("1.10200"), + } + event := BarEvent{Instrument: instID, Interval: marketdata.H1, Bar: bar} + view := fakeView{snap: mustSnapshot(t)} + + intents, err := s.OnBar(ctx, event, view) + require.NoError(t, err) + require.Len(t, intents, 1, "the first bar must emit exactly the one Enter intent this fixture describes") + assert.Equal(t, order.IntentEnter, intents[0].Kind) + assert.Equal(t, instID, intents[0].Instrument) + assert.Equal(t, order.Buy, intents[0].Side) + assert.False(t, intents[0].IntentID.IsZero()) + + // A second OnBar call on the same strategy instance is a no-op, + // proving state carries across calls the way a real strategy's + // own internal state would. + intents, err = s.OnBar(ctx, event, view) + require.NoError(t, err) + assert.Empty(t, intents) + assert.True(t, s.entered) +} diff --git a/strategy/view.go b/strategy/view.go new file mode 100644 index 0000000..55b62d2 --- /dev/null +++ b/strategy/view.go @@ -0,0 +1,22 @@ +package strategy + +import "github.com/rustyeddy/trader/account" + +// View is the read-only market/portfolio state OnBar may consult. It +// is never a broker handle and never reaches execution/risk types — +// only state a runner has already decided a strategy may see. +// +// View is deliberately minimal for v0 (issue #210's own review): +// Account is backed directly by the canonical account.Snapshot value +// (no parallel type needed), while a historical-bar lookup method is +// intentionally not added here yet — see the package doc comment for +// why. A View implementation is expected to expose only data the +// owning runner has already made visible as of the current simulated +// or live time, which is what makes it the layer that owns *what* +// historical state is accessible (ADR-035's own "no-lookahead is a +// layered invariant" decision — View is one of three cooperating +// layers, not the only one). +type View interface { + // Account returns the current, authoritative account snapshot. + Account() account.Snapshot +}