Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions strategy/boundary_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
43 changes: 43 additions & 0 deletions strategy/descriptor.go
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 48 additions & 0 deletions strategy/doc.go
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions strategy/environment.go
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions strategy/event.go
Original file line number Diff line number Diff line change
@@ -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
}
129 changes: 129 additions & 0 deletions strategy/intent.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading