Skip to content
Merged
85 changes: 77 additions & 8 deletions cmd/dde/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,7 @@ func newSignalCommand() *cobra.Command {
func newBacktestCommand() *cobra.Command {
var input string
var asJSON bool
var compareStrategies bool
cmd := &cobra.Command{
Use: "backtest",
Short: "Replay a signal timeline and report decision-quality metrics (offline)",
Expand All @@ -504,14 +505,6 @@ func newBacktestCommand() *cobra.Command {
if err := readJSONFile(input, &sc); err != nil {
return err
}
var provOpts []marketdata.OfflineOption
if sc.FixtureDir != "" {
provOpts = append(provOpts, marketdata.WithFixtureDir(sc.FixtureDir))
}
provider, err := marketdata.NewOfflineProvider(provOpts...)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
Expand All @@ -524,6 +517,30 @@ func newBacktestCommand() *cobra.Command {
if err != nil {
return err
}

// --compare-strategies replays the scenario under every strategy
// mode (legacy single, each lens pinned, full selector) and prints
// the comparison table instead of a single report.
if compareStrategies {
cells, err := backtest.RunStrategyMatrix(cmd.Context(), reg, pol, []backtest.Scenario{sc})
if err != nil {
return err
}
if asJSON {
return printJSON(cells)
}
backtest.RenderStrategyMatrix(os.Stdout, cells)
return nil
}

var provOpts []marketdata.OfflineOption
if sc.FixtureDir != "" {
provOpts = append(provOpts, marketdata.WithFixtureDir(sc.FixtureDir))
}
provider, err := marketdata.NewOfflineProvider(provOpts...)
if err != nil {
return err
}
h := backtest.New(reg, pol, provider)
rep, err := h.Run(cmd.Context(), sc)
if err != nil {
Expand All @@ -538,6 +555,8 @@ func newBacktestCommand() *cobra.Command {
}
cmd.Flags().StringVar(&input, "input", "", "path to a backtest scenario JSON file (required)")
cmd.Flags().BoolVar(&asJSON, "json", false, "emit the report as JSON")
cmd.Flags().BoolVar(&compareStrategies, "compare-strategies", false,
"replay under every strategy mode (single, each lens pinned, selector) and print the comparison table")
_ = cmd.MarkFlagRequired("input")
return cmd
}
Expand Down Expand Up @@ -591,6 +610,56 @@ func newCalibrateCommand() *cobra.Command {
return cmd
}

// newStrategyFitCommand fits per-strategy selection weights from the outcomes
// recorded against a domain's goals and prints the policy snippet that installs
// them — the strategy analogue of `dde calibrate`. Only plans produced by the
// strategy selector (provenance carries selected_strategy) contribute.
func newStrategyFitCommand() *cobra.Command {
var domainKey string
var asJSON bool
cmd := &cobra.Command{
Use: "strategy-fit",
Short: "Fit strategy-selection weights from recorded outcomes (prints a policy snippet)",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
log := logging.New(cfg.LogLevel, cfg.LogFormat)
svc, cleanup, err := buildService(cmd.Context(), cfg, log, api.NewMetrics())
if err != nil {
return err
}
defer cleanup()

samples, err := svc.StrategySamples(cmd.Context(), domainKey)
if err != nil {
return err
}
weights := finance.FitStrategyWeights(samples)
if len(samples) < 10 {
fmt.Fprintf(os.Stderr, "warning: only %d decisive outcomes with a selected strategy; the weights are weak evidence — keep recording outcomes\n", len(samples))
}
snippet := policy.Policy{Domains: map[string]policy.DomainPolicy{
domainKey: {Strategy: &policy.StrategySelection{Weights: weights}},
}}
if asJSON {
return printJSON(snippet)
}
out, err := yaml.Marshal(snippet)
if err != nil {
return err
}
fmt.Fprintf(os.Stdout, "# fitted from %d decisive outcomes for domain %q (omitted buckets mean weight 1.0)\n%s",
len(samples), domainKey, out)
return nil
},
}
cmd.Flags().StringVar(&domainKey, "domain", "investing", "domain whose outcomes to fit strategy weights against")
cmd.Flags().BoolVar(&asJSON, "json", false, "emit the policy snippet as JSON")
return cmd
}

// newMemoryService builds an in-memory, offline service for the CLI commands
// (evaluate, signal), reusing the production wiring — registry, planner router
// (incl. the finance planner on offline fixtures) and per-domain evaluators — with
Expand Down
1 change: 1 addition & 0 deletions cmd/dde/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func main() {
newSignalCommand(),
newBacktestCommand(),
newCalibrateCommand(),
newStrategyFitCommand(),
newVersionCommand(),
)

Expand Down
84 changes: 84 additions & 0 deletions internal/app/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,90 @@ func (s *Service) CalibrationSamples(ctx context.Context, domainKey string) ([]f
}
}

// StrategySamples pairs every decisive recorded outcome for goals in the given
// domain with the strategy that won the plan version it addresses (and the
// market regime recorded at plan time). Plans that predate the strategy
// selector — no SelectedStrategy in provenance — contribute nothing. The
// samples feed finance.FitStrategyWeights (`dde strategy-fit`).
func (s *Service) StrategySamples(ctx context.Context, domainKey string) ([]finance.StrategySample, error) {
var samples []finance.StrategySample
page := storage.Page{Limit: storage.MaxPageLimit}
for {
goals, err := s.repo.ListGoals(ctx, storage.GoalFilter{}, page)
if err != nil {
return nil, err
}
for _, g := range goals {
if g.Domain != domainKey {
continue
}
gs, err := s.goalStrategySamples(ctx, g.ID)
if err != nil {
return nil, err
}
samples = append(samples, gs...)
}
if len(goals) < page.Limit {
return samples, nil
}
page.Offset += page.Limit
}
}

func (s *Service) goalStrategySamples(ctx context.Context, goalID string) ([]finance.StrategySample, error) {
plan, err := s.repo.GetPlanByGoal(ctx, goalID)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
return nil, nil // goal never planned; nothing to learn from
}
return nil, err
}

var samples []finance.StrategySample
versions := map[int]domain.PlanVersion{}
page := storage.Page{Limit: storage.MaxPageLimit}
for {
outcomes, err := s.repo.ListOutcomes(ctx, goalID, page)
if err != nil {
return nil, err
}
for _, o := range outcomes {
var success bool
switch o.Result {
case domain.OutcomeSuccess:
success = true
case domain.OutcomeFailure:
success = false
default:
continue // partial/inconclusive carry no binary label
}
v, ok := versions[o.PlanVersion]
if !ok {
v, err = s.repo.GetPlanVersion(ctx, plan.ID, o.PlanVersion)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
continue
}
return nil, err
}
versions[o.PlanVersion] = v
}
if v.Provenance.SelectedStrategy == "" {
continue // pre-selector plan: no strategy to attribute
}
samples = append(samples, finance.StrategySample{
Strategy: v.Provenance.SelectedStrategy,
Regime: finance.Regime(v.Provenance.Regime),
Success: success,
})
}
if len(outcomes) < page.Limit {
return samples, nil
}
page.Offset += page.Limit
}
}

func (s *Service) goalCalibrationSamples(ctx context.Context, goalID string) ([]finance.CalibrationSample, error) {
plan, err := s.repo.GetPlanByGoal(ctx, goalID)
if err != nil {
Expand Down
125 changes: 125 additions & 0 deletions internal/app/strategyfit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package app

import (
"context"
"testing"

"github.com/vingrad/dynamic-decision-engine/internal/domain"
"github.com/vingrad/dynamic-decision-engine/internal/engine"
"github.com/vingrad/dynamic-decision-engine/internal/finance"
"github.com/vingrad/dynamic-decision-engine/internal/llm"
"github.com/vingrad/dynamic-decision-engine/internal/pack"
"github.com/vingrad/dynamic-decision-engine/internal/storage"
)

// selectorStubPlanner emits plans stamped the way the strategy selector does,
// so StrategySamples' attribution can be tested without market data.
type selectorStubPlanner struct{ strategy, regime string }

func (p *selectorStubPlanner) Name() string { return "multi:selector" }

func (p *selectorStubPlanner) GeneratePlan(_ context.Context, req llm.PlanRequest) (llm.PlanResult, error) {
return llm.PlanResult{
Summary: "stub",
RankedMoves: []domain.RankedMove{
{Rank: 1, Key: "thesis:ACME", Title: "Thesis: ACME", Confidence: 0.6,
ExpectedImpact: domain.LevelMedium, Effort: domain.LevelLow, Risk: domain.LevelMedium,
Experiment: domain.Experiment{Title: "t", DurationDays: 1}},
},
Provenance: domain.DecisionProvenance{
ReasoningSummary: "stub",
Planner: "finance:" + p.strategy,
Strategy: "selector",
SelectedStrategy: p.strategy,
Regime: p.regime,
},
}, nil
}

func TestStrategySamples(t *testing.T) {
stub := &selectorStubPlanner{strategy: "momentum", regime: "trend"}
s := New(storage.NewMemory(), engine.New(stub), WithRegistry(pack.NewRegistry()))
ctx := context.Background()

g, err := s.CreateGoal(ctx, CreateGoalInput{
Domain: "investing",
Objective: "Build a thesis-driven position",
Context: domain.Context{
Assets: []domain.Asset{{Name: "ACME", Kind: "ticker"}},
},
})
if err != nil {
t.Fatal(err)
}
v1, err := s.GeneratePlan(ctx, g.ID)
if err != nil {
t.Fatal(err)
}

record := func(result domain.OutcomeResult) {
t.Helper()
if _, err := s.RecordOutcome(ctx, OutcomeInput{
GoalID: g.ID, PlanVersion: v1.Version, MoveRank: 1, Result: result,
}); err != nil {
t.Fatal(err)
}
}
record(domain.OutcomeSuccess)
record(domain.OutcomeFailure)
record(domain.OutcomePartial) // no binary label -> skipped

samples, err := s.StrategySamples(ctx, "investing")
if err != nil {
t.Fatal(err)
}
if len(samples) != 2 {
t.Fatalf("expected 2 decisive samples, got %d: %+v", len(samples), samples)
}
for _, sm := range samples {
if sm.Strategy != "momentum" || sm.Regime != finance.Regime("trend") {
t.Errorf("sample attribution wrong: %+v", sm)
}
}
wins := 0
for _, sm := range samples {
if sm.Success {
wins++
}
}
if wins != 1 {
t.Errorf("expected exactly one success sample, got %d", wins)
}
}

// TestStrategySamplesSkipsPreSelectorPlans: plans whose provenance carries no
// selected strategy (the entire pre-selector history) contribute nothing.
func TestStrategySamplesSkipsPreSelectorPlans(t *testing.T) {
s := newTestServiceWithRegistry() // mock planner: no SelectedStrategy
ctx := context.Background()

g, err := s.CreateGoal(ctx, CreateGoalInput{
Domain: "investing",
Objective: "Build a thesis-driven position",
Context: domain.Context{Assets: []domain.Asset{{Name: "ACME", Kind: "ticker"}}},
})
if err != nil {
t.Fatal(err)
}
v1, err := s.GeneratePlan(ctx, g.ID)
if err != nil {
t.Fatal(err)
}
if _, err := s.RecordOutcome(ctx, OutcomeInput{
GoalID: g.ID, PlanVersion: v1.Version, MoveRank: 1, Result: domain.OutcomeSuccess,
}); err != nil {
t.Fatal(err)
}

samples, err := s.StrategySamples(ctx, "investing")
if err != nil {
t.Fatal(err)
}
if len(samples) != 0 {
t.Errorf("pre-selector plans must contribute nothing, got %+v", samples)
}
}
14 changes: 14 additions & 0 deletions internal/backtest/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,21 @@ func (h *Harness) Run(ctx context.Context, sc Scenario) (Report, error) {
TopConfidence: top.Confidence,
TopRawConfidence: top.RawConfidence,
ShouldKill: ev.ShouldKill,
SelectedStrategy: res.Candidate.Provenance.SelectedStrategy,
Regime: res.Candidate.Provenance.Regime,
Candidates: res.Candidate.Provenance.StrategyCandidates,
})
if s := res.Candidate.Provenance.SelectedStrategy; s != "" {
if rep.StrategyShare == nil {
rep.StrategyShare = map[string]int{}
}
rep.StrategyShare[s]++
if n := len(rep.Decisions); n >= 2 {
if prev := rep.Decisions[n-2].SelectedStrategy; prev != "" && prev != s {
rep.StrategyFlips++
}
}
}

if ev.ShouldKill {
shouldKills++
Expand Down
18 changes: 1 addition & 17 deletions internal/backtest/matrix.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"sort"

"github.com/vingrad/dynamic-decision-engine/internal/finance"
"github.com/vingrad/dynamic-decision-engine/internal/marketdata"
"github.com/vingrad/dynamic-decision-engine/internal/pack"
"github.com/vingrad/dynamic-decision-engine/internal/policy"
)
Expand All @@ -32,22 +31,7 @@ func RunMatrix(ctx context.Context, reg *pack.Registry, basePol policy.Policy, s
}
sort.Strings(names)

providers := map[string]marketdata.Provider{}
providerFor := func(fixtureDir string) (marketdata.Provider, error) {
if p, ok := providers[fixtureDir]; ok {
return p, nil
}
var opts []marketdata.OfflineOption
if fixtureDir != "" {
opts = append(opts, marketdata.WithFixtureDir(fixtureDir))
}
p, err := marketdata.NewOfflineProvider(opts...)
if err != nil {
return nil, err
}
providers[fixtureDir] = p
return p, nil
}
providerFor := fixtureProviders()

var cells []MatrixCell
for _, name := range names {
Expand Down
Loading