diff --git a/cmd/dde/commands.go b/cmd/dde/commands.go index a1ad0ec..a542e4c 100644 --- a/cmd/dde/commands.go +++ b/cmd/dde/commands.go @@ -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)", @@ -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 @@ -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 { @@ -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 } @@ -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 diff --git a/cmd/dde/main.go b/cmd/dde/main.go index 333220b..bd429da 100644 --- a/cmd/dde/main.go +++ b/cmd/dde/main.go @@ -32,6 +32,7 @@ func main() { newSignalCommand(), newBacktestCommand(), newCalibrateCommand(), + newStrategyFitCommand(), newVersionCommand(), ) diff --git a/internal/app/service.go b/internal/app/service.go index e5480a9..bc78c82 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -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 { diff --git a/internal/app/strategyfit_test.go b/internal/app/strategyfit_test.go new file mode 100644 index 0000000..970de1b --- /dev/null +++ b/internal/app/strategyfit_test.go @@ -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) + } +} diff --git a/internal/backtest/harness.go b/internal/backtest/harness.go index b618abb..c49b793 100644 --- a/internal/backtest/harness.go +++ b/internal/backtest/harness.go @@ -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++ diff --git a/internal/backtest/matrix.go b/internal/backtest/matrix.go index 6840286..17182c2 100644 --- a/internal/backtest/matrix.go +++ b/internal/backtest/matrix.go @@ -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" ) @@ -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 { diff --git a/internal/backtest/report.go b/internal/backtest/report.go index c46c19a..37c71ef 100644 --- a/internal/backtest/report.go +++ b/internal/backtest/report.go @@ -4,6 +4,8 @@ import ( "fmt" "io" "time" + + "github.com/vingrad/dynamic-decision-engine/internal/domain" ) // Decision records one step of the replay. @@ -27,6 +29,14 @@ type Decision struct { // 1 when the top thesis's forward return was positive (falling back to the // analyst kill label), else 0. Evaluation-only. Label float64 `json:"label"` + // SelectedStrategy is the strategy lens that won this decision's candidate + // plan (Provenance.SelectedStrategy); empty when no selector is in play. + SelectedStrategy string `json:"selected_strategy,omitempty"` + // Regime is the market-regime label recorded at decision time. + Regime string `json:"regime,omitempty"` + // Candidates is the recorded strategy competition (evaluation-only): the + // walk-forward re-weighs these after the fact without re-running the engine. + Candidates []domain.StrategyCandidate `json:"candidates,omitempty"` } // Report summarises a backtest run. The metrics describe decision/replanning @@ -46,6 +56,12 @@ type Report struct { // material replan (1 == never reacted to noise). NoiseRobustness float64 `json:"noise_robustness"` HypotheticalPnL float64 `json:"hypothetical_pnl"` + // StrategyShare counts decisions per winning strategy (selector runs only), + // making winner flapping visible at a glance. + StrategyShare map[string]int `json:"strategy_share,omitempty"` + // StrategyFlips counts decisions whose winning strategy differed from the + // previous decision's — the flapping metric the backtest gates bound. + StrategyFlips int `json:"strategy_flips,omitempty"` } // Render writes a human-readable report to w. diff --git a/internal/backtest/strategymatrix.go b/internal/backtest/strategymatrix.go new file mode 100644 index 0000000..280e96d --- /dev/null +++ b/internal/backtest/strategymatrix.go @@ -0,0 +1,130 @@ +package backtest + +import ( + "context" + "fmt" + "io" + + "github.com/vingrad/dynamic-decision-engine/internal/marketdata" + "github.com/vingrad/dynamic-decision-engine/internal/pack" + "github.com/vingrad/dynamic-decision-engine/internal/policy" +) + +// RunStrategyMatrix replays every scenario under each strategy mode for the +// investing domain: "single" (selection disabled — the legacy blended +// planner), one "-only" config per declared strategy (that lens pinned via +// policy), and "selector" (the full competition). Cells are ordered (config, +// scenario) with configs in a fixed order — single, the pack's canonical +// strategy order, selector — so two runs of the same corpus are byte-identical. +func RunStrategyMatrix(ctx context.Context, reg *pack.Registry, basePol policy.Policy, scenarios []Scenario) ([]MatrixCell, error) { + d, ok := reg.Get("investing") + if !ok { + return nil, fmt.Errorf("backtest: no investing pack in registry") + } + if len(d.Strategies) == 0 { + return nil, fmt.Errorf("backtest: investing pack declares no strategies") + } + + type config struct { + name string + pol policy.Policy + } + off, on := false, true + configs := []config{{name: "single", pol: overrideStrategy(basePol, policy.StrategySelection{Enabled: &off})}} + for _, s := range d.Strategies { + var disable []string + for _, other := range d.Strategies { + if other.ID != s.ID { + disable = append(disable, other.ID) + } + } + configs = append(configs, config{ + name: s.ID + "-only", + pol: overrideStrategy(basePol, policy.StrategySelection{Enabled: &on, Disable: disable}), + }) + } + configs = append(configs, config{name: "selector", pol: overrideStrategy(basePol, policy.StrategySelection{Enabled: &on})}) + + providerFor := fixtureProviders() + var cells []MatrixCell + for _, cfg := range configs { + for _, sc := range scenarios { + provider, err := providerFor(sc.FixtureDir) + if err != nil { + return nil, err + } + rep, err := New(reg, cfg.pol, provider).Run(ctx, sc) + if err != nil { + return nil, fmt.Errorf("backtest: config %q scenario %q: %w", cfg.name, sc.Name, err) + } + cells = append(cells, MatrixCell{Scenario: sc.Name, Config: cfg.name, Report: rep}) + } + } + return cells, nil +} + +// overrideStrategy returns a copy of pol with the investing domain's strategy +// selection replaced; all other overrides are preserved. +func overrideStrategy(pol policy.Policy, sel policy.StrategySelection) policy.Policy { + out := policy.Policy{Domains: map[string]policy.DomainPolicy{}} + for k, v := range pol.Domains { + out.Domains[k] = v + } + dp := out.Domains["investing"] + dp.Strategy = &sel + out.Domains["investing"] = dp + return out +} + +// fixtureProviders returns a memoised loader of offline providers per fixture +// directory ("" = the embedded defaults), shared by the comparison runners. +func fixtureProviders() func(dir string) (marketdata.Provider, error) { + providers := map[string]marketdata.Provider{} + return func(dir string) (marketdata.Provider, error) { + if p, ok := providers[dir]; ok { + return p, nil + } + var opts []marketdata.OfflineOption + if dir != "" { + opts = append(opts, marketdata.WithFixtureDir(dir)) + } + p, err := marketdata.NewOfflineProvider(opts...) + if err != nil { + return nil, err + } + providers[dir] = p + return p, nil + } +} + +// RenderStrategyMatrix writes the comparison table plus each selector run's +// strategy share, so winner flapping is visible next to the quality metrics. +func RenderStrategyMatrix(w io.Writer, cells []MatrixCell) { + fmt.Fprintf(w, "%-28s %-16s %8s %10s %8s %8s %6s %s\n", + "scenario", "config", "brier", "precision", "recall", "noise", "flips", "strategy share") + for _, c := range cells { + share := "" + for _, k := range sortedKeys(c.Report.StrategyShare) { + if share != "" { + share += " " + } + share += fmt.Sprintf("%s:%d", k, c.Report.StrategyShare[k]) + } + fmt.Fprintf(w, "%-28s %-16s %8.3f %10.2f %8.2f %8.2f %6d %s\n", + c.Scenario, c.Config, c.Report.BrierScore, c.Report.KillPrecision, + c.Report.KillRecall, c.Report.NoiseRobustness, c.Report.StrategyFlips, share) + } +} + +func sortedKeys(m map[string]int) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + for i := 1; i < len(keys); i++ { + for j := i; j > 0 && keys[j] < keys[j-1]; j-- { + keys[j], keys[j-1] = keys[j-1], keys[j] + } + } + return keys +} diff --git a/internal/backtest/strategymatrix_test.go b/internal/backtest/strategymatrix_test.go new file mode 100644 index 0000000..4f6c67d --- /dev/null +++ b/internal/backtest/strategymatrix_test.go @@ -0,0 +1,170 @@ +package backtest + +import ( + "context" + "reflect" + "testing" + + "github.com/vingrad/dynamic-decision-engine/internal/pack" + "github.com/vingrad/dynamic-decision-engine/internal/policy" +) + +// regimeCorpus is the strategy-comparison corpus: fixtures engineered so the +// lenses genuinely disagree (a year-long trend, a sideways range, a high-vol +// bleed, and a trend that rolls into a crash). +var regimeCorpus = []string{ + "testdata/scenario_regime_trend.json", + "testdata/scenario_regime_range.json", + "testdata/scenario_regime_highvol.json", + "testdata/scenario_regime_mixed.json", +} + +func loadRegimeCorpus(t *testing.T) []Scenario { + t.Helper() + var out []Scenario + for _, f := range regimeCorpus { + out = append(out, loadScenario(t, f)) + } + return out +} + +// TestStrategyMatrixGates asserts the acceptance gates for enabling the +// selector by default: +// 1. the selector never does worse than the legacy single planner on Brier, +// 2. it never reacts to noise the single planner ignored, +// 3. the winning strategy doesn't flap (at most one switch per scenario), +// 4. every decision under the selector records its winning strategy. +func TestStrategyMatrixGates(t *testing.T) { + scenarios := loadRegimeCorpus(t) + cells, err := RunStrategyMatrix(context.Background(), pack.NewRegistry(), policy.Policy{}, scenarios) + if err != nil { + t.Fatal(err) + } + + single := map[string]Report{} + selector := map[string]Report{} + for _, c := range cells { + switch c.Config { + case "single": + single[c.Scenario] = c.Report + case "selector": + selector[c.Scenario] = c.Report + } + } + if len(single) != len(scenarios) || len(selector) != len(scenarios) { + t.Fatalf("matrix incomplete: %d single, %d selector reports", len(single), len(selector)) + } + + // Flip budgets are per scenario: a stable tape must never flap, while the + // scenarios engineered to cross regimes are ALLOWED exactly their designed + // transitions and no more. + maxFlips := map[string]int{ + "ACME persistent uptrend": 0, + "ACME range-bound value": 0, + "ACME high-vol bleed": 1, + "ACME trend into crash": 2, + } + // Per-scenario, the selector may pay at most one small transient step at a + // regime turn (the classifier necessarily lags the tape — at a crash onset + // the trailing window still reads "trend"); it must never be meaningfully + // worse. Across the corpus it must be a strict net improvement. + const regimeTurnTolerance = 0.01 + var sumSel, sumSingle float64 + for name, sel := range selector { + base := single[name] + sumSel += sel.BrierScore + sumSingle += base.BrierScore + if sel.BrierScore > base.BrierScore+regimeTurnTolerance { + t.Errorf("%s: selector brier %.3f worse than single %.3f beyond tolerance", name, sel.BrierScore, base.BrierScore) + } + if sel.NoiseRobustness < base.NoiseRobustness { + t.Errorf("%s: selector noise robustness %.2f below single %.2f", name, sel.NoiseRobustness, base.NoiseRobustness) + } + if sel.StrategyFlips > maxFlips[name] { + t.Errorf("%s: winning strategy flapped %d times (budget %d)", name, sel.StrategyFlips, maxFlips[name]) + } + for _, d := range sel.Decisions { + if d.SelectedStrategy == "" { + t.Errorf("%s: decision at %s missing its selected strategy", name, d.At) + } + } + } + + if sumSel >= sumSingle { + t.Errorf("selector mean brier %.4f must strictly beat single %.4f across the corpus", + sumSel/float64(len(selector)), sumSingle/float64(len(single))) + } + + // The trend scenario is where the competition should genuinely pay: the + // selector must match the best single lens there, not just the legacy one. + if sel, ok := selector["ACME persistent uptrend"]; ok { + if sel.Decisions[len(sel.Decisions)-1].SelectedStrategy != "momentum" { + t.Errorf("trend scenario should belong to the momentum lens, got %q", + sel.Decisions[len(sel.Decisions)-1].SelectedStrategy) + } + } + + // Regime gating must hand the high-volatility tapes to the defensive lens: + // once the bleed is classified high_vol, trend-and-range lenses are out. + for _, name := range []string{"ACME high-vol bleed", "ACME trend into crash"} { + ds := selector[name].Decisions + if last := ds[len(ds)-1]; last.SelectedStrategy != "defensive" { + t.Errorf("%s: final decision should belong to defensive under high_vol gating, got %q", + name, last.SelectedStrategy) + } + } + + // The competition must actually differentiate somewhere on this corpus: + // at least two distinct lenses win across the scenarios. + winners := map[string]bool{} + for _, sel := range selector { + for s := range sel.StrategyShare { + winners[s] = true + } + } + if len(winners) < 2 { + t.Errorf("corpus never separated the lenses: winners %v", winners) + } +} + +// TestStrategyMatrixDeterministic: two full runs of the comparison must be +// byte-identical — selection has no incidental ordering anywhere. +func TestStrategyMatrixDeterministic(t *testing.T) { + scenarios := loadRegimeCorpus(t) + run := func() []MatrixCell { + cells, err := RunStrategyMatrix(context.Background(), pack.NewRegistry(), policy.Policy{}, scenarios) + if err != nil { + t.Fatal(err) + } + return cells + } + if a, b := run(), run(); !reflect.DeepEqual(a, b) { + t.Error("two identical matrix runs produced different results") + } +} + +// TestStrategyMatrixPinnedLensDiffers proves the pinned-lens baselines are +// real lenses, not the single planner relabelled: at least one pinned config +// produces a different Brier than "single" somewhere in the corpus. +func TestStrategyMatrixPinnedLensDiffers(t *testing.T) { + scenarios := loadRegimeCorpus(t) + cells, err := RunStrategyMatrix(context.Background(), pack.NewRegistry(), policy.Policy{}, scenarios) + if err != nil { + t.Fatal(err) + } + base := map[string]float64{} + for _, c := range cells { + if c.Config == "single" { + base[c.Scenario] = c.Report.BrierScore + } + } + differs := false + for _, c := range cells { + if c.Config != "single" && c.Config != "selector" && c.Report.BrierScore != base[c.Scenario] { + differs = true + } + } + if !differs { + t.Error("no pinned lens ever differed from the single planner — the lenses are not differentiating") + } +} diff --git a/internal/backtest/testdata/regime_highvol/bars.json b/internal/backtest/testdata/regime_highvol/bars.json new file mode 100644 index 0000000..f5b5876 --- /dev/null +++ b/internal/backtest/testdata/regime_highvol/bars.json @@ -0,0 +1,564 @@ +{ + "ACME": [ + { + "date": "2025-06-02T00:00:00Z", + "open": 80.0, + "high": 80.4, + "low": 79.6, + "close": 80.0, + "volume": 500000 + }, + { + "date": "2025-06-09T00:00:00Z", + "open": 80.0, + "high": 80.8, + "low": 79.6, + "close": 80.4, + "volume": 500000 + }, + { + "date": "2025-06-16T00:00:00Z", + "open": 80.4, + "high": 81.21, + "low": 80.0, + "close": 80.8, + "volume": 500000 + }, + { + "date": "2025-06-23T00:00:00Z", + "open": 80.8, + "high": 81.61, + "low": 80.4, + "close": 81.21, + "volume": 500000 + }, + { + "date": "2025-06-30T00:00:00Z", + "open": 81.21, + "high": 82.02, + "low": 80.8, + "close": 81.61, + "volume": 500000 + }, + { + "date": "2025-07-07T00:00:00Z", + "open": 81.61, + "high": 82.43, + "low": 81.2, + "close": 82.02, + "volume": 500000 + }, + { + "date": "2025-07-14T00:00:00Z", + "open": 82.02, + "high": 82.84, + "low": 81.61, + "close": 82.43, + "volume": 500000 + }, + { + "date": "2025-07-21T00:00:00Z", + "open": 82.43, + "high": 83.26, + "low": 82.02, + "close": 82.84, + "volume": 500000 + }, + { + "date": "2025-07-28T00:00:00Z", + "open": 82.84, + "high": 83.67, + "low": 82.43, + "close": 83.26, + "volume": 500000 + }, + { + "date": "2025-08-04T00:00:00Z", + "open": 83.26, + "high": 84.09, + "low": 82.84, + "close": 83.67, + "volume": 500000 + }, + { + "date": "2025-08-11T00:00:00Z", + "open": 83.67, + "high": 84.51, + "low": 83.25, + "close": 84.09, + "volume": 500000 + }, + { + "date": "2025-08-18T00:00:00Z", + "open": 84.09, + "high": 84.93, + "low": 83.67, + "close": 84.51, + "volume": 500000 + }, + { + "date": "2025-08-25T00:00:00Z", + "open": 84.51, + "high": 85.36, + "low": 84.09, + "close": 84.93, + "volume": 500000 + }, + { + "date": "2025-09-01T00:00:00Z", + "open": 84.93, + "high": 85.79, + "low": 84.51, + "close": 85.36, + "volume": 500000 + }, + { + "date": "2025-09-08T00:00:00Z", + "open": 85.36, + "high": 86.21, + "low": 84.93, + "close": 85.79, + "volume": 500000 + }, + { + "date": "2025-09-15T00:00:00Z", + "open": 85.79, + "high": 86.65, + "low": 85.36, + "close": 86.21, + "volume": 500000 + }, + { + "date": "2025-09-22T00:00:00Z", + "open": 86.21, + "high": 87.08, + "low": 85.78, + "close": 86.65, + "volume": 500000 + }, + { + "date": "2025-09-29T00:00:00Z", + "open": 86.65, + "high": 87.51, + "low": 86.21, + "close": 87.08, + "volume": 500000 + }, + { + "date": "2025-10-06T00:00:00Z", + "open": 87.08, + "high": 87.95, + "low": 86.64, + "close": 87.51, + "volume": 500000 + }, + { + "date": "2025-10-13T00:00:00Z", + "open": 87.51, + "high": 88.39, + "low": 87.08, + "close": 87.95, + "volume": 500000 + }, + { + "date": "2025-10-20T00:00:00Z", + "open": 87.95, + "high": 88.83, + "low": 87.51, + "close": 88.39, + "volume": 500000 + }, + { + "date": "2025-10-27T00:00:00Z", + "open": 88.39, + "high": 89.28, + "low": 87.95, + "close": 88.83, + "volume": 500000 + }, + { + "date": "2025-11-03T00:00:00Z", + "open": 88.83, + "high": 89.72, + "low": 88.39, + "close": 89.28, + "volume": 500000 + }, + { + "date": "2025-11-10T00:00:00Z", + "open": 89.28, + "high": 90.17, + "low": 88.83, + "close": 89.72, + "volume": 500000 + }, + { + "date": "2025-11-17T00:00:00Z", + "open": 89.72, + "high": 90.62, + "low": 89.28, + "close": 90.17, + "volume": 500000 + }, + { + "date": "2025-11-24T00:00:00Z", + "open": 90.17, + "high": 91.08, + "low": 89.72, + "close": 90.62, + "volume": 500000 + }, + { + "date": "2025-12-01T00:00:00Z", + "open": 90.62, + "high": 91.53, + "low": 90.17, + "close": 91.08, + "volume": 500000 + }, + { + "date": "2025-12-08T00:00:00Z", + "open": 91.08, + "high": 91.99, + "low": 90.62, + "close": 91.53, + "volume": 500000 + }, + { + "date": "2025-12-15T00:00:00Z", + "open": 91.53, + "high": 92.45, + "low": 91.07, + "close": 91.99, + "volume": 500000 + }, + { + "date": "2025-12-22T00:00:00Z", + "open": 91.99, + "high": 92.91, + "low": 91.53, + "close": 92.45, + "volume": 500000 + }, + { + "date": "2025-12-29T00:00:00Z", + "open": 92.45, + "high": 93.38, + "low": 91.99, + "close": 92.91, + "volume": 500000 + }, + { + "date": "2026-01-05T00:00:00Z", + "open": 92.91, + "high": 93.84, + "low": 92.45, + "close": 93.38, + "volume": 500000 + }, + { + "date": "2026-01-12T00:00:00Z", + "open": 93.38, + "high": 94.31, + "low": 92.91, + "close": 93.84, + "volume": 500000 + }, + { + "date": "2026-01-19T00:00:00Z", + "open": 93.84, + "high": 94.78, + "low": 93.37, + "close": 94.31, + "volume": 500000 + }, + { + "date": "2026-01-26T00:00:00Z", + "open": 94.31, + "high": 95.26, + "low": 93.84, + "close": 94.78, + "volume": 500000 + }, + { + "date": "2026-02-02T00:00:00Z", + "open": 94.78, + "high": 95.73, + "low": 94.31, + "close": 95.26, + "volume": 500000 + }, + { + "date": "2026-02-09T00:00:00Z", + "open": 95.26, + "high": 96.21, + "low": 94.78, + "close": 95.73, + "volume": 500000 + }, + { + "date": "2026-02-16T00:00:00Z", + "open": 95.73, + "high": 96.69, + "low": 95.26, + "close": 96.21, + "volume": 500000 + }, + { + "date": "2026-02-23T00:00:00Z", + "open": 96.21, + "high": 97.18, + "low": 95.73, + "close": 96.69, + "volume": 500000 + }, + { + "date": "2026-03-02T00:00:00Z", + "open": 96.69, + "high": 97.66, + "low": 96.21, + "close": 97.18, + "volume": 500000 + }, + { + "date": "2026-03-09T00:00:00Z", + "open": 97.18, + "high": 98.15, + "low": 96.69, + "close": 97.66, + "volume": 500000 + }, + { + "date": "2026-03-16T00:00:00Z", + "open": 97.66, + "high": 98.64, + "low": 97.18, + "close": 98.15, + "volume": 500000 + }, + { + "date": "2026-03-23T00:00:00Z", + "open": 98.15, + "high": 99.14, + "low": 97.66, + "close": 98.64, + "volume": 500000 + }, + { + "date": "2026-03-30T00:00:00Z", + "open": 98.64, + "high": 99.63, + "low": 98.15, + "close": 99.14, + "volume": 500000 + }, + { + "date": "2026-04-06T00:00:00Z", + "open": 99.14, + "high": 100.13, + "low": 98.64, + "close": 99.63, + "volume": 500000 + }, + { + "date": "2026-04-13T00:00:00Z", + "open": 99.63, + "high": 100.13, + "low": 96.66, + "close": 97.14, + "volume": 500000 + }, + { + "date": "2026-04-20T00:00:00Z", + "open": 97.14, + "high": 97.63, + "low": 94.24, + "close": 94.71, + "volume": 500000 + }, + { + "date": "2026-04-27T00:00:00Z", + "open": 94.71, + "high": 95.19, + "low": 91.88, + "close": 92.34, + "volume": 500000 + }, + { + "date": "2026-05-04T00:00:00Z", + "open": 92.34, + "high": 92.81, + "low": 89.59, + "close": 90.04, + "volume": 500000 + }, + { + "date": "2026-05-11T00:00:00Z", + "open": 90.04, + "high": 90.49, + "low": 87.35, + "close": 87.78, + "volume": 500000 + }, + { + "date": "2026-05-18T00:00:00Z", + "open": 87.78, + "high": 88.22, + "low": 85.16, + "close": 85.59, + "volume": 500000 + }, + { + "date": "2026-05-25T00:00:00Z", + "open": 85.59, + "high": 86.02, + "low": 83.03, + "close": 83.45, + "volume": 500000 + }, + { + "date": "2026-06-01T00:00:00Z", + "open": 83.45, + "high": 83.87, + "low": 80.96, + "close": 81.36, + "volume": 500000 + }, + { + "date": "2026-06-08T00:00:00Z", + "open": 81.36, + "high": 81.77, + "low": 78.93, + "close": 79.33, + "volume": 500000 + }, + { + "date": "2026-06-15T00:00:00Z", + "open": 79.33, + "high": 79.73, + "low": 76.96, + "close": 77.35, + "volume": 500000 + }, + { + "date": "2026-06-22T00:00:00Z", + "open": 77.35, + "high": 77.73, + "low": 75.04, + "close": 75.41, + "volume": 500000 + }, + { + "date": "2026-06-29T00:00:00Z", + "open": 75.41, + "high": 75.79, + "low": 73.16, + "close": 73.53, + "volume": 500000 + }, + { + "date": "2026-07-06T00:00:00Z", + "open": 73.53, + "high": 73.9, + "low": 71.33, + "close": 71.69, + "volume": 500000 + }, + { + "date": "2026-07-13T00:00:00Z", + "open": 71.69, + "high": 72.05, + "low": 69.55, + "close": 69.9, + "volume": 500000 + }, + { + "date": "2026-07-20T00:00:00Z", + "open": 69.9, + "high": 70.25, + "low": 67.81, + "close": 68.15, + "volume": 500000 + }, + { + "date": "2026-07-27T00:00:00Z", + "open": 68.15, + "high": 68.49, + "low": 66.11, + "close": 66.45, + "volume": 500000 + }, + { + "date": "2026-08-03T00:00:00Z", + "open": 66.45, + "high": 66.78, + "low": 64.46, + "close": 64.79, + "volume": 500000 + }, + { + "date": "2026-08-10T00:00:00Z", + "open": 64.79, + "high": 65.11, + "low": 62.85, + "close": 63.17, + "volume": 500000 + }, + { + "date": "2026-08-17T00:00:00Z", + "open": 63.17, + "high": 63.48, + "low": 61.28, + "close": 61.59, + "volume": 500000 + }, + { + "date": "2026-08-24T00:00:00Z", + "open": 61.59, + "high": 61.89, + "low": 59.75, + "close": 60.05, + "volume": 500000 + }, + { + "date": "2026-08-31T00:00:00Z", + "open": 60.05, + "high": 60.35, + "low": 58.25, + "close": 58.55, + "volume": 500000 + }, + { + "date": "2026-09-07T00:00:00Z", + "open": 58.55, + "high": 58.84, + "low": 56.8, + "close": 57.08, + "volume": 500000 + }, + { + "date": "2026-09-14T00:00:00Z", + "open": 57.08, + "high": 57.37, + "low": 55.38, + "close": 55.65, + "volume": 500000 + }, + { + "date": "2026-09-21T00:00:00Z", + "open": 55.65, + "high": 55.93, + "low": 53.99, + "close": 54.26, + "volume": 500000 + }, + { + "date": "2026-09-28T00:00:00Z", + "open": 54.26, + "high": 54.53, + "low": 52.64, + "close": 52.91, + "volume": 500000 + } + ] +} \ No newline at end of file diff --git a/internal/backtest/testdata/regime_highvol/fundamentals.json b/internal/backtest/testdata/regime_highvol/fundamentals.json new file mode 100644 index 0000000..b455a45 --- /dev/null +++ b/internal/backtest/testdata/regime_highvol/fundamentals.json @@ -0,0 +1,12 @@ +{ + "ACME": [ + { + "ticker": "ACME", + "as_of": "2025-06-02T00:00:00Z", + "pe": 28.0, + "pb": 4.2, + "eps": -1.0, + "market_cap": 7000000000.0 + } + ] +} \ No newline at end of file diff --git a/internal/backtest/testdata/regime_highvol/quotes.json b/internal/backtest/testdata/regime_highvol/quotes.json new file mode 100644 index 0000000..d7deffd --- /dev/null +++ b/internal/backtest/testdata/regime_highvol/quotes.json @@ -0,0 +1,424 @@ +{ + "ACME": [ + { + "ticker": "ACME", + "price": 80.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-02T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 80.4, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-09T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 80.8, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-16T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 81.21, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-23T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 81.61, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-30T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 82.02, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-07T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 82.43, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-14T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 82.84, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-21T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 83.26, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-28T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 83.67, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-04T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 84.09, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-11T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 84.51, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-18T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 84.93, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-25T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 85.36, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-01T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 85.79, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-08T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 86.21, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-15T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 86.65, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-22T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 87.08, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-29T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 87.51, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-06T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 87.95, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-13T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 88.39, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-20T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 88.83, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-27T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 89.28, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-03T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 89.72, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-10T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 90.17, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-17T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 90.62, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-24T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 91.08, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-01T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 91.53, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-08T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 91.99, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-15T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 92.45, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-22T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 92.91, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-29T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 93.38, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-05T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 93.84, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-12T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 94.31, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-19T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 94.78, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-26T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 95.26, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-02T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 95.73, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-09T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 96.21, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-16T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 96.69, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-23T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.18, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-02T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.66, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-09T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 98.15, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-16T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 98.64, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-23T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 99.14, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-30T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 99.63, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-06T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.14, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-13T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 94.71, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-20T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 92.34, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-27T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 90.04, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-04T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 87.78, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-11T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 85.59, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-18T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 83.45, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-25T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 81.36, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-01T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 79.33, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-08T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 77.35, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-15T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 75.41, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-22T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 73.53, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-29T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 71.69, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-06T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 69.9, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-13T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 68.15, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-20T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 66.45, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-27T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 64.79, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-03T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 63.17, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-10T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 61.59, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-17T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 60.05, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-24T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 58.55, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-31T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 57.08, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-07T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 55.65, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-14T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 54.26, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-21T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 52.91, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-28T00:00:00Z" + } + ] +} \ No newline at end of file diff --git a/internal/backtest/testdata/regime_mixed/bars.json b/internal/backtest/testdata/regime_mixed/bars.json new file mode 100644 index 0000000..29f70e3 --- /dev/null +++ b/internal/backtest/testdata/regime_mixed/bars.json @@ -0,0 +1,572 @@ +{ + "ACME": [ + { + "date": "2025-06-02T00:00:00Z", + "open": 70.0, + "high": 70.35, + "low": 69.65, + "close": 70.0, + "volume": 500000 + }, + { + "date": "2025-06-09T00:00:00Z", + "open": 70.0, + "high": 71.05, + "low": 69.65, + "close": 70.7, + "volume": 500000 + }, + { + "date": "2025-06-16T00:00:00Z", + "open": 70.7, + "high": 71.76, + "low": 70.35, + "close": 71.41, + "volume": 500000 + }, + { + "date": "2025-06-23T00:00:00Z", + "open": 71.41, + "high": 72.48, + "low": 71.05, + "close": 72.12, + "volume": 500000 + }, + { + "date": "2025-06-30T00:00:00Z", + "open": 72.12, + "high": 73.21, + "low": 71.76, + "close": 72.84, + "volume": 500000 + }, + { + "date": "2025-07-07T00:00:00Z", + "open": 72.84, + "high": 73.94, + "low": 72.48, + "close": 73.57, + "volume": 500000 + }, + { + "date": "2025-07-14T00:00:00Z", + "open": 73.57, + "high": 74.68, + "low": 73.2, + "close": 74.31, + "volume": 500000 + }, + { + "date": "2025-07-21T00:00:00Z", + "open": 74.31, + "high": 75.42, + "low": 73.93, + "close": 75.05, + "volume": 500000 + }, + { + "date": "2025-07-28T00:00:00Z", + "open": 75.05, + "high": 76.18, + "low": 74.67, + "close": 75.8, + "volume": 500000 + }, + { + "date": "2025-08-04T00:00:00Z", + "open": 75.8, + "high": 76.94, + "low": 75.42, + "close": 76.56, + "volume": 500000 + }, + { + "date": "2025-08-11T00:00:00Z", + "open": 76.56, + "high": 77.71, + "low": 76.18, + "close": 77.32, + "volume": 500000 + }, + { + "date": "2025-08-18T00:00:00Z", + "open": 77.32, + "high": 78.49, + "low": 76.94, + "close": 78.1, + "volume": 500000 + }, + { + "date": "2025-08-25T00:00:00Z", + "open": 78.1, + "high": 79.27, + "low": 77.71, + "close": 78.88, + "volume": 500000 + }, + { + "date": "2025-09-01T00:00:00Z", + "open": 78.88, + "high": 80.06, + "low": 78.48, + "close": 79.67, + "volume": 500000 + }, + { + "date": "2025-09-08T00:00:00Z", + "open": 79.67, + "high": 80.87, + "low": 79.27, + "close": 80.46, + "volume": 500000 + }, + { + "date": "2025-09-15T00:00:00Z", + "open": 80.46, + "high": 81.67, + "low": 80.06, + "close": 81.27, + "volume": 500000 + }, + { + "date": "2025-09-22T00:00:00Z", + "open": 81.27, + "high": 82.49, + "low": 80.86, + "close": 82.08, + "volume": 500000 + }, + { + "date": "2025-09-29T00:00:00Z", + "open": 82.08, + "high": 83.32, + "low": 81.67, + "close": 82.9, + "volume": 500000 + }, + { + "date": "2025-10-06T00:00:00Z", + "open": 82.9, + "high": 84.15, + "low": 82.49, + "close": 83.73, + "volume": 500000 + }, + { + "date": "2025-10-13T00:00:00Z", + "open": 83.73, + "high": 84.99, + "low": 83.31, + "close": 84.57, + "volume": 500000 + }, + { + "date": "2025-10-20T00:00:00Z", + "open": 84.57, + "high": 85.84, + "low": 84.14, + "close": 85.41, + "volume": 500000 + }, + { + "date": "2025-10-27T00:00:00Z", + "open": 85.41, + "high": 86.7, + "low": 84.99, + "close": 86.27, + "volume": 500000 + }, + { + "date": "2025-11-03T00:00:00Z", + "open": 86.27, + "high": 87.57, + "low": 85.84, + "close": 87.13, + "volume": 500000 + }, + { + "date": "2025-11-10T00:00:00Z", + "open": 87.13, + "high": 88.44, + "low": 86.69, + "close": 88.0, + "volume": 500000 + }, + { + "date": "2025-11-17T00:00:00Z", + "open": 88.0, + "high": 89.33, + "low": 87.56, + "close": 88.88, + "volume": 500000 + }, + { + "date": "2025-11-24T00:00:00Z", + "open": 88.88, + "high": 90.22, + "low": 88.44, + "close": 89.77, + "volume": 500000 + }, + { + "date": "2025-12-01T00:00:00Z", + "open": 89.77, + "high": 91.12, + "low": 89.32, + "close": 90.67, + "volume": 500000 + }, + { + "date": "2025-12-08T00:00:00Z", + "open": 90.67, + "high": 92.03, + "low": 90.21, + "close": 91.57, + "volume": 500000 + }, + { + "date": "2025-12-15T00:00:00Z", + "open": 91.57, + "high": 92.95, + "low": 91.12, + "close": 92.49, + "volume": 500000 + }, + { + "date": "2025-12-22T00:00:00Z", + "open": 92.49, + "high": 93.88, + "low": 92.03, + "close": 93.42, + "volume": 500000 + }, + { + "date": "2025-12-29T00:00:00Z", + "open": 93.42, + "high": 94.82, + "low": 92.95, + "close": 94.35, + "volume": 500000 + }, + { + "date": "2026-01-05T00:00:00Z", + "open": 94.35, + "high": 95.77, + "low": 93.88, + "close": 95.29, + "volume": 500000 + }, + { + "date": "2026-01-12T00:00:00Z", + "open": 95.29, + "high": 96.73, + "low": 94.82, + "close": 96.25, + "volume": 500000 + }, + { + "date": "2026-01-19T00:00:00Z", + "open": 96.25, + "high": 97.69, + "low": 95.76, + "close": 97.21, + "volume": 500000 + }, + { + "date": "2026-01-26T00:00:00Z", + "open": 97.21, + "high": 98.67, + "low": 96.72, + "close": 98.18, + "volume": 500000 + }, + { + "date": "2026-02-02T00:00:00Z", + "open": 98.18, + "high": 99.66, + "low": 97.69, + "close": 99.16, + "volume": 500000 + }, + { + "date": "2026-02-09T00:00:00Z", + "open": 99.16, + "high": 100.65, + "low": 98.67, + "close": 100.15, + "volume": 500000 + }, + { + "date": "2026-02-16T00:00:00Z", + "open": 100.15, + "high": 101.66, + "low": 99.65, + "close": 101.16, + "volume": 500000 + }, + { + "date": "2026-02-23T00:00:00Z", + "open": 101.16, + "high": 102.68, + "low": 100.65, + "close": 102.17, + "volume": 500000 + }, + { + "date": "2026-03-02T00:00:00Z", + "open": 102.17, + "high": 103.7, + "low": 101.66, + "close": 103.19, + "volume": 500000 + }, + { + "date": "2026-03-09T00:00:00Z", + "open": 103.19, + "high": 104.74, + "low": 102.67, + "close": 104.22, + "volume": 500000 + }, + { + "date": "2026-03-16T00:00:00Z", + "open": 104.22, + "high": 105.79, + "low": 103.7, + "close": 105.26, + "volume": 500000 + }, + { + "date": "2026-03-23T00:00:00Z", + "open": 105.26, + "high": 106.85, + "low": 104.74, + "close": 106.32, + "volume": 500000 + }, + { + "date": "2026-03-30T00:00:00Z", + "open": 106.32, + "high": 107.92, + "low": 105.78, + "close": 107.38, + "volume": 500000 + }, + { + "date": "2026-04-06T00:00:00Z", + "open": 107.38, + "high": 108.99, + "low": 106.84, + "close": 108.45, + "volume": 500000 + }, + { + "date": "2026-04-13T00:00:00Z", + "open": 108.45, + "high": 110.08, + "low": 107.91, + "close": 109.54, + "volume": 500000 + }, + { + "date": "2026-04-20T00:00:00Z", + "open": 109.54, + "high": 111.19, + "low": 108.99, + "close": 110.63, + "volume": 500000 + }, + { + "date": "2026-04-27T00:00:00Z", + "open": 110.63, + "high": 112.3, + "low": 110.08, + "close": 111.74, + "volume": 500000 + }, + { + "date": "2026-05-04T00:00:00Z", + "open": 111.74, + "high": 113.42, + "low": 111.18, + "close": 112.86, + "volume": 500000 + }, + { + "date": "2026-05-11T00:00:00Z", + "open": 112.86, + "high": 114.55, + "low": 112.29, + "close": 113.98, + "volume": 500000 + }, + { + "date": "2026-05-18T00:00:00Z", + "open": 113.98, + "high": 115.7, + "low": 113.41, + "close": 115.12, + "volume": 500000 + }, + { + "date": "2026-05-25T00:00:00Z", + "open": 115.12, + "high": 116.86, + "low": 114.55, + "close": 116.28, + "volume": 500000 + }, + { + "date": "2026-06-01T00:00:00Z", + "open": 116.28, + "high": 118.03, + "low": 115.69, + "close": 117.44, + "volume": 500000 + }, + { + "date": "2026-06-08T00:00:00Z", + "open": 117.44, + "high": 119.21, + "low": 116.85, + "close": 118.61, + "volume": 500000 + }, + { + "date": "2026-06-15T00:00:00Z", + "open": 118.61, + "high": 120.4, + "low": 118.02, + "close": 119.8, + "volume": 500000 + }, + { + "date": "2026-06-22T00:00:00Z", + "open": 119.8, + "high": 121.6, + "low": 119.2, + "close": 121.0, + "volume": 500000 + }, + { + "date": "2026-06-29T00:00:00Z", + "open": 121.0, + "high": 121.6, + "low": 116.78, + "close": 117.37, + "volume": 500000 + }, + { + "date": "2026-07-06T00:00:00Z", + "open": 117.37, + "high": 117.95, + "low": 113.28, + "close": 113.85, + "volume": 500000 + }, + { + "date": "2026-07-13T00:00:00Z", + "open": 113.85, + "high": 114.42, + "low": 109.88, + "close": 110.43, + "volume": 500000 + }, + { + "date": "2026-07-20T00:00:00Z", + "open": 110.43, + "high": 110.98, + "low": 106.58, + "close": 107.12, + "volume": 500000 + }, + { + "date": "2026-07-27T00:00:00Z", + "open": 107.12, + "high": 107.65, + "low": 103.38, + "close": 103.9, + "volume": 500000 + }, + { + "date": "2026-08-03T00:00:00Z", + "open": 103.9, + "high": 104.42, + "low": 100.28, + "close": 100.79, + "volume": 500000 + }, + { + "date": "2026-08-10T00:00:00Z", + "open": 100.79, + "high": 101.29, + "low": 97.27, + "close": 97.76, + "volume": 500000 + }, + { + "date": "2026-08-17T00:00:00Z", + "open": 97.76, + "high": 98.25, + "low": 94.36, + "close": 94.83, + "volume": 500000 + }, + { + "date": "2026-08-24T00:00:00Z", + "open": 94.83, + "high": 95.3, + "low": 94.36, + "close": 94.83, + "volume": 500000 + }, + { + "date": "2026-08-31T00:00:00Z", + "open": 94.83, + "high": 95.3, + "low": 94.36, + "close": 94.83, + "volume": 500000 + }, + { + "date": "2026-09-07T00:00:00Z", + "open": 94.83, + "high": 95.3, + "low": 94.36, + "close": 94.83, + "volume": 500000 + }, + { + "date": "2026-09-14T00:00:00Z", + "open": 94.83, + "high": 95.3, + "low": 94.36, + "close": 94.83, + "volume": 500000 + }, + { + "date": "2026-09-21T00:00:00Z", + "open": 94.83, + "high": 95.3, + "low": 94.36, + "close": 94.83, + "volume": 500000 + }, + { + "date": "2026-09-28T00:00:00Z", + "open": 94.83, + "high": 95.3, + "low": 94.36, + "close": 94.83, + "volume": 500000 + }, + { + "date": "2026-10-05T00:00:00Z", + "open": 94.83, + "high": 95.3, + "low": 94.36, + "close": 94.83, + "volume": 500000 + } + ] +} \ No newline at end of file diff --git a/internal/backtest/testdata/regime_mixed/fundamentals.json b/internal/backtest/testdata/regime_mixed/fundamentals.json new file mode 100644 index 0000000..f18db53 --- /dev/null +++ b/internal/backtest/testdata/regime_mixed/fundamentals.json @@ -0,0 +1,12 @@ +{ + "ACME": [ + { + "ticker": "ACME", + "pe": 22.0, + "pb": 3.0, + "eps": 2.0, + "market_cap": 8000000000.0, + "as_of": "2025-06-02T00:00:00Z" + } + ] +} \ No newline at end of file diff --git a/internal/backtest/testdata/regime_mixed/quotes.json b/internal/backtest/testdata/regime_mixed/quotes.json new file mode 100644 index 0000000..7bff36d --- /dev/null +++ b/internal/backtest/testdata/regime_mixed/quotes.json @@ -0,0 +1,430 @@ +{ + "ACME": [ + { + "ticker": "ACME", + "price": 70.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-02T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 70.7, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-09T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 71.41, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-16T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 72.12, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-23T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 72.84, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-30T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 73.57, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-07T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 74.31, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-14T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 75.05, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-21T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 75.8, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-28T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 76.56, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-04T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 77.32, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-11T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 78.1, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-18T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 78.88, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-25T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 79.67, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-01T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 80.46, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-08T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 81.27, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-15T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 82.08, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-22T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 82.9, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-29T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 83.73, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-06T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 84.57, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-13T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 85.41, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-20T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 86.27, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-27T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 87.13, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-03T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 88.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-10T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 88.88, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-17T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 89.77, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-24T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 90.67, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-01T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 91.57, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-08T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 92.49, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-15T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 93.42, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-22T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 94.35, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-29T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 95.29, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-05T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 96.25, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-12T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.21, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-19T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 98.18, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-26T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 99.16, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-02T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.15, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-09T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 101.16, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-16T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.17, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-23T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 103.19, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-02T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 104.22, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-09T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 105.26, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-16T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 106.32, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-23T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 107.38, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-30T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 108.45, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-06T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 109.54, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-13T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 110.63, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-20T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 111.74, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-27T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 112.86, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-04T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 113.98, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-11T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 115.12, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-18T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 116.28, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-25T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 117.44, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-01T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 118.61, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-08T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 119.8, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-15T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 121.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-22T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 117.37, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-29T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 113.85, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-06T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 110.43, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-13T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 107.12, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-20T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 103.9, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-27T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.79, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-03T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.76, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-10T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 94.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-17T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 94.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-24T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 94.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-31T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 94.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-07T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 94.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-14T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 94.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-21T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 94.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-28T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 94.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-10-05T00:00:00Z" + } + ] +} \ No newline at end of file diff --git a/internal/backtest/testdata/regime_range/bars.json b/internal/backtest/testdata/regime_range/bars.json new file mode 100644 index 0000000..5ab2f0e --- /dev/null +++ b/internal/backtest/testdata/regime_range/bars.json @@ -0,0 +1,564 @@ +{ + "ACME": [ + { + "date": "2025-06-02T00:00:00Z", + "open": 100.0, + "high": 100.5, + "low": 99.5, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2025-06-09T00:00:00Z", + "open": 100.0, + "high": 103.34, + "low": 99.5, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2025-06-16T00:00:00Z", + "open": 102.83, + "high": 104.52, + "low": 102.31, + "close": 104.0, + "volume": 500000 + }, + { + "date": "2025-06-23T00:00:00Z", + "open": 104.0, + "high": 104.52, + "low": 102.31, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2025-06-30T00:00:00Z", + "open": 102.83, + "high": 103.34, + "low": 99.5, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2025-07-07T00:00:00Z", + "open": 100.0, + "high": 100.5, + "low": 96.69, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2025-07-14T00:00:00Z", + "open": 97.17, + "high": 97.66, + "low": 95.52, + "close": 96.0, + "volume": 500000 + }, + { + "date": "2025-07-21T00:00:00Z", + "open": 96.0, + "high": 97.66, + "low": 95.52, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2025-07-28T00:00:00Z", + "open": 97.17, + "high": 100.5, + "low": 96.69, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2025-08-04T00:00:00Z", + "open": 100.0, + "high": 103.34, + "low": 99.5, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2025-08-11T00:00:00Z", + "open": 102.83, + "high": 104.52, + "low": 102.31, + "close": 104.0, + "volume": 500000 + }, + { + "date": "2025-08-18T00:00:00Z", + "open": 104.0, + "high": 104.52, + "low": 102.31, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2025-08-25T00:00:00Z", + "open": 102.83, + "high": 103.34, + "low": 99.5, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2025-09-01T00:00:00Z", + "open": 100.0, + "high": 100.5, + "low": 96.69, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2025-09-08T00:00:00Z", + "open": 97.17, + "high": 97.66, + "low": 95.52, + "close": 96.0, + "volume": 500000 + }, + { + "date": "2025-09-15T00:00:00Z", + "open": 96.0, + "high": 97.66, + "low": 95.52, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2025-09-22T00:00:00Z", + "open": 97.17, + "high": 100.5, + "low": 96.69, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2025-09-29T00:00:00Z", + "open": 100.0, + "high": 103.34, + "low": 99.5, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2025-10-06T00:00:00Z", + "open": 102.83, + "high": 104.52, + "low": 102.31, + "close": 104.0, + "volume": 500000 + }, + { + "date": "2025-10-13T00:00:00Z", + "open": 104.0, + "high": 104.52, + "low": 102.31, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2025-10-20T00:00:00Z", + "open": 102.83, + "high": 103.34, + "low": 99.5, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2025-10-27T00:00:00Z", + "open": 100.0, + "high": 100.5, + "low": 96.69, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2025-11-03T00:00:00Z", + "open": 97.17, + "high": 97.66, + "low": 95.52, + "close": 96.0, + "volume": 500000 + }, + { + "date": "2025-11-10T00:00:00Z", + "open": 96.0, + "high": 97.66, + "low": 95.52, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2025-11-17T00:00:00Z", + "open": 97.17, + "high": 100.5, + "low": 96.69, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2025-11-24T00:00:00Z", + "open": 100.0, + "high": 103.34, + "low": 99.5, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2025-12-01T00:00:00Z", + "open": 102.83, + "high": 104.52, + "low": 102.31, + "close": 104.0, + "volume": 500000 + }, + { + "date": "2025-12-08T00:00:00Z", + "open": 104.0, + "high": 104.52, + "low": 102.31, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2025-12-15T00:00:00Z", + "open": 102.83, + "high": 103.34, + "low": 99.5, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2025-12-22T00:00:00Z", + "open": 100.0, + "high": 100.5, + "low": 96.69, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2025-12-29T00:00:00Z", + "open": 97.17, + "high": 97.66, + "low": 95.52, + "close": 96.0, + "volume": 500000 + }, + { + "date": "2026-01-05T00:00:00Z", + "open": 96.0, + "high": 97.66, + "low": 95.52, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2026-01-12T00:00:00Z", + "open": 97.17, + "high": 100.5, + "low": 96.69, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2026-01-19T00:00:00Z", + "open": 100.0, + "high": 103.34, + "low": 99.5, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2026-01-26T00:00:00Z", + "open": 102.83, + "high": 104.52, + "low": 102.31, + "close": 104.0, + "volume": 500000 + }, + { + "date": "2026-02-02T00:00:00Z", + "open": 104.0, + "high": 104.52, + "low": 102.31, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2026-02-09T00:00:00Z", + "open": 102.83, + "high": 103.34, + "low": 99.5, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2026-02-16T00:00:00Z", + "open": 100.0, + "high": 100.5, + "low": 96.69, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2026-02-23T00:00:00Z", + "open": 97.17, + "high": 97.66, + "low": 95.52, + "close": 96.0, + "volume": 500000 + }, + { + "date": "2026-03-02T00:00:00Z", + "open": 96.0, + "high": 97.66, + "low": 95.52, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2026-03-09T00:00:00Z", + "open": 97.17, + "high": 100.5, + "low": 96.69, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2026-03-16T00:00:00Z", + "open": 100.0, + "high": 103.34, + "low": 99.5, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2026-03-23T00:00:00Z", + "open": 102.83, + "high": 104.52, + "low": 102.31, + "close": 104.0, + "volume": 500000 + }, + { + "date": "2026-03-30T00:00:00Z", + "open": 104.0, + "high": 104.52, + "low": 102.31, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2026-04-06T00:00:00Z", + "open": 102.83, + "high": 103.34, + "low": 99.5, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2026-04-13T00:00:00Z", + "open": 100.0, + "high": 100.5, + "low": 96.69, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2026-04-20T00:00:00Z", + "open": 97.17, + "high": 97.66, + "low": 95.52, + "close": 96.0, + "volume": 500000 + }, + { + "date": "2026-04-27T00:00:00Z", + "open": 96.0, + "high": 97.66, + "low": 95.52, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2026-05-04T00:00:00Z", + "open": 97.17, + "high": 100.5, + "low": 96.69, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2026-05-11T00:00:00Z", + "open": 100.0, + "high": 103.34, + "low": 99.5, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2026-05-18T00:00:00Z", + "open": 102.83, + "high": 104.52, + "low": 102.31, + "close": 104.0, + "volume": 500000 + }, + { + "date": "2026-05-25T00:00:00Z", + "open": 104.0, + "high": 104.52, + "low": 102.31, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2026-06-01T00:00:00Z", + "open": 102.83, + "high": 103.34, + "low": 99.5, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2026-06-08T00:00:00Z", + "open": 100.0, + "high": 100.5, + "low": 96.69, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2026-06-15T00:00:00Z", + "open": 97.17, + "high": 97.66, + "low": 95.52, + "close": 96.0, + "volume": 500000 + }, + { + "date": "2026-06-22T00:00:00Z", + "open": 96.0, + "high": 97.66, + "low": 95.52, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2026-06-29T00:00:00Z", + "open": 97.17, + "high": 100.5, + "low": 96.69, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2026-07-06T00:00:00Z", + "open": 100.0, + "high": 103.34, + "low": 99.5, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2026-07-13T00:00:00Z", + "open": 102.83, + "high": 104.52, + "low": 102.31, + "close": 104.0, + "volume": 500000 + }, + { + "date": "2026-07-20T00:00:00Z", + "open": 104.0, + "high": 104.52, + "low": 102.31, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2026-07-27T00:00:00Z", + "open": 102.83, + "high": 103.34, + "low": 99.5, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2026-08-03T00:00:00Z", + "open": 100.0, + "high": 100.5, + "low": 96.69, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2026-08-10T00:00:00Z", + "open": 97.17, + "high": 97.66, + "low": 95.52, + "close": 96.0, + "volume": 500000 + }, + { + "date": "2026-08-17T00:00:00Z", + "open": 96.0, + "high": 97.66, + "low": 95.52, + "close": 97.17, + "volume": 500000 + }, + { + "date": "2026-08-24T00:00:00Z", + "open": 97.17, + "high": 100.5, + "low": 96.69, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2026-08-31T00:00:00Z", + "open": 100.0, + "high": 103.34, + "low": 99.5, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2026-09-07T00:00:00Z", + "open": 102.83, + "high": 104.52, + "low": 102.31, + "close": 104.0, + "volume": 500000 + }, + { + "date": "2026-09-14T00:00:00Z", + "open": 104.0, + "high": 104.52, + "low": 102.31, + "close": 102.83, + "volume": 500000 + }, + { + "date": "2026-09-21T00:00:00Z", + "open": 102.83, + "high": 103.34, + "low": 99.5, + "close": 100.0, + "volume": 500000 + }, + { + "date": "2026-09-28T00:00:00Z", + "open": 100.0, + "high": 100.5, + "low": 96.69, + "close": 97.17, + "volume": 500000 + } + ] +} \ No newline at end of file diff --git a/internal/backtest/testdata/regime_range/fundamentals.json b/internal/backtest/testdata/regime_range/fundamentals.json new file mode 100644 index 0000000..4885199 --- /dev/null +++ b/internal/backtest/testdata/regime_range/fundamentals.json @@ -0,0 +1,12 @@ +{ + "ACME": [ + { + "ticker": "ACME", + "as_of": "2025-06-02T00:00:00Z", + "pe": 11.0, + "pb": 1.3, + "eps": 6.0, + "market_cap": 6000000000.0 + } + ] +} \ No newline at end of file diff --git a/internal/backtest/testdata/regime_range/quotes.json b/internal/backtest/testdata/regime_range/quotes.json new file mode 100644 index 0000000..99707d3 --- /dev/null +++ b/internal/backtest/testdata/regime_range/quotes.json @@ -0,0 +1,424 @@ +{ + "ACME": [ + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-02T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-09T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 104.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-16T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-23T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-30T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-07T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 96.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-14T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-21T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-28T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-04T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 104.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-11T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-18T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-25T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-01T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 96.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-08T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-15T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-22T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-29T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 104.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-06T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-13T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-20T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-27T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 96.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-03T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-10T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-17T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-24T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 104.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-01T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-08T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-15T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-22T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 96.0, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-29T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-05T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-12T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-19T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 104.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-26T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-02T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-09T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-16T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 96.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-23T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-02T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-09T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-16T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 104.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-23T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-30T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-06T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-13T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 96.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-20T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-27T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-04T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-11T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 104.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-18T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-25T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-01T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-08T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 96.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-15T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-22T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-29T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-06T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 104.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-13T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-20T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-27T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-03T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 96.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-10T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-17T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-24T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-31T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 104.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-07T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-14T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.0, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-21T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.17, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-28T00:00:00Z" + } + ] +} \ No newline at end of file diff --git a/internal/backtest/testdata/regime_trend/bars.json b/internal/backtest/testdata/regime_trend/bars.json new file mode 100644 index 0000000..0116e74 --- /dev/null +++ b/internal/backtest/testdata/regime_trend/bars.json @@ -0,0 +1,564 @@ +{ + "ACME": [ + { + "date": "2025-06-02T00:00:00Z", + "open": 60.18, + "high": 60.48, + "low": 59.88, + "close": 60.18, + "volume": 500000 + }, + { + "date": "2025-06-09T00:00:00Z", + "open": 60.18, + "high": 60.72, + "low": 59.88, + "close": 60.42, + "volume": 500000 + }, + { + "date": "2025-06-16T00:00:00Z", + "open": 60.42, + "high": 61.7, + "low": 60.12, + "close": 61.39, + "volume": 500000 + }, + { + "date": "2025-06-23T00:00:00Z", + "open": 61.39, + "high": 61.94, + "low": 61.08, + "close": 61.63, + "volume": 500000 + }, + { + "date": "2025-06-30T00:00:00Z", + "open": 61.63, + "high": 62.94, + "low": 61.32, + "close": 62.62, + "volume": 500000 + }, + { + "date": "2025-07-07T00:00:00Z", + "open": 62.62, + "high": 63.19, + "low": 62.31, + "close": 62.87, + "volume": 500000 + }, + { + "date": "2025-07-14T00:00:00Z", + "open": 62.87, + "high": 64.2, + "low": 62.56, + "close": 63.88, + "volume": 500000 + }, + { + "date": "2025-07-21T00:00:00Z", + "open": 63.88, + "high": 64.46, + "low": 63.56, + "close": 64.14, + "volume": 500000 + }, + { + "date": "2025-07-28T00:00:00Z", + "open": 64.14, + "high": 65.49, + "low": 63.81, + "close": 65.17, + "volume": 500000 + }, + { + "date": "2025-08-04T00:00:00Z", + "open": 65.17, + "high": 65.75, + "low": 64.84, + "close": 65.42, + "volume": 500000 + }, + { + "date": "2025-08-11T00:00:00Z", + "open": 65.42, + "high": 66.81, + "low": 65.1, + "close": 66.48, + "volume": 500000 + }, + { + "date": "2025-08-18T00:00:00Z", + "open": 66.48, + "high": 67.07, + "low": 66.14, + "close": 66.74, + "volume": 500000 + }, + { + "date": "2025-08-25T00:00:00Z", + "open": 66.74, + "high": 68.15, + "low": 66.41, + "close": 67.81, + "volume": 500000 + }, + { + "date": "2025-09-01T00:00:00Z", + "open": 67.81, + "high": 68.42, + "low": 67.47, + "close": 68.08, + "volume": 500000 + }, + { + "date": "2025-09-08T00:00:00Z", + "open": 68.08, + "high": 69.52, + "low": 67.74, + "close": 69.18, + "volume": 500000 + }, + { + "date": "2025-09-15T00:00:00Z", + "open": 69.18, + "high": 69.8, + "low": 68.83, + "close": 69.45, + "volume": 500000 + }, + { + "date": "2025-09-22T00:00:00Z", + "open": 69.45, + "high": 70.92, + "low": 69.1, + "close": 70.57, + "volume": 500000 + }, + { + "date": "2025-09-29T00:00:00Z", + "open": 70.57, + "high": 71.2, + "low": 70.21, + "close": 70.85, + "volume": 500000 + }, + { + "date": "2025-10-06T00:00:00Z", + "open": 70.85, + "high": 72.34, + "low": 70.49, + "close": 71.98, + "volume": 500000 + }, + { + "date": "2025-10-13T00:00:00Z", + "open": 71.98, + "high": 72.63, + "low": 71.62, + "close": 72.27, + "volume": 500000 + }, + { + "date": "2025-10-20T00:00:00Z", + "open": 72.27, + "high": 73.8, + "low": 71.91, + "close": 73.43, + "volume": 500000 + }, + { + "date": "2025-10-27T00:00:00Z", + "open": 73.43, + "high": 74.09, + "low": 73.06, + "close": 73.72, + "volume": 500000 + }, + { + "date": "2025-11-03T00:00:00Z", + "open": 73.72, + "high": 75.28, + "low": 73.35, + "close": 74.91, + "volume": 500000 + }, + { + "date": "2025-11-10T00:00:00Z", + "open": 74.91, + "high": 75.58, + "low": 74.53, + "close": 75.2, + "volume": 500000 + }, + { + "date": "2025-11-17T00:00:00Z", + "open": 75.2, + "high": 76.79, + "low": 74.83, + "close": 76.41, + "volume": 500000 + }, + { + "date": "2025-11-24T00:00:00Z", + "open": 76.41, + "high": 77.1, + "low": 76.03, + "close": 76.72, + "volume": 500000 + }, + { + "date": "2025-12-01T00:00:00Z", + "open": 76.72, + "high": 78.34, + "low": 76.33, + "close": 77.95, + "volume": 500000 + }, + { + "date": "2025-12-08T00:00:00Z", + "open": 77.95, + "high": 78.65, + "low": 77.56, + "close": 78.26, + "volume": 500000 + }, + { + "date": "2025-12-15T00:00:00Z", + "open": 78.26, + "high": 79.91, + "low": 77.87, + "close": 79.52, + "volume": 500000 + }, + { + "date": "2025-12-22T00:00:00Z", + "open": 79.52, + "high": 80.23, + "low": 79.12, + "close": 79.83, + "volume": 500000 + }, + { + "date": "2025-12-29T00:00:00Z", + "open": 79.83, + "high": 81.52, + "low": 79.43, + "close": 81.11, + "volume": 500000 + }, + { + "date": "2026-01-05T00:00:00Z", + "open": 81.11, + "high": 81.84, + "low": 80.71, + "close": 81.43, + "volume": 500000 + }, + { + "date": "2026-01-12T00:00:00Z", + "open": 81.43, + "high": 83.16, + "low": 81.03, + "close": 82.74, + "volume": 500000 + }, + { + "date": "2026-01-19T00:00:00Z", + "open": 82.74, + "high": 83.49, + "low": 82.33, + "close": 83.07, + "volume": 500000 + }, + { + "date": "2026-01-26T00:00:00Z", + "open": 83.07, + "high": 84.83, + "low": 82.66, + "close": 84.41, + "volume": 500000 + }, + { + "date": "2026-02-02T00:00:00Z", + "open": 84.41, + "high": 85.16, + "low": 83.99, + "close": 84.74, + "volume": 500000 + }, + { + "date": "2026-02-09T00:00:00Z", + "open": 84.74, + "high": 86.53, + "low": 84.32, + "close": 86.1, + "volume": 500000 + }, + { + "date": "2026-02-16T00:00:00Z", + "open": 86.1, + "high": 86.88, + "low": 85.67, + "close": 86.44, + "volume": 500000 + }, + { + "date": "2026-02-23T00:00:00Z", + "open": 86.44, + "high": 88.27, + "low": 86.01, + "close": 87.83, + "volume": 500000 + }, + { + "date": "2026-03-02T00:00:00Z", + "open": 87.83, + "high": 88.62, + "low": 87.4, + "close": 88.18, + "volume": 500000 + }, + { + "date": "2026-03-09T00:00:00Z", + "open": 88.18, + "high": 90.05, + "low": 87.74, + "close": 89.6, + "volume": 500000 + }, + { + "date": "2026-03-16T00:00:00Z", + "open": 89.6, + "high": 90.4, + "low": 89.15, + "close": 89.95, + "volume": 500000 + }, + { + "date": "2026-03-23T00:00:00Z", + "open": 89.95, + "high": 91.86, + "low": 89.5, + "close": 91.4, + "volume": 500000 + }, + { + "date": "2026-03-30T00:00:00Z", + "open": 91.4, + "high": 92.22, + "low": 90.94, + "close": 91.76, + "volume": 500000 + }, + { + "date": "2026-04-06T00:00:00Z", + "open": 91.76, + "high": 93.7, + "low": 91.3, + "close": 93.24, + "volume": 500000 + }, + { + "date": "2026-04-13T00:00:00Z", + "open": 93.24, + "high": 94.08, + "low": 92.77, + "close": 93.61, + "volume": 500000 + }, + { + "date": "2026-04-20T00:00:00Z", + "open": 93.61, + "high": 95.59, + "low": 93.14, + "close": 95.11, + "volume": 500000 + }, + { + "date": "2026-04-27T00:00:00Z", + "open": 95.11, + "high": 95.97, + "low": 94.64, + "close": 95.49, + "volume": 500000 + }, + { + "date": "2026-05-04T00:00:00Z", + "open": 95.49, + "high": 97.51, + "low": 95.01, + "close": 97.02, + "volume": 500000 + }, + { + "date": "2026-05-11T00:00:00Z", + "open": 97.02, + "high": 97.89, + "low": 96.54, + "close": 97.41, + "volume": 500000 + }, + { + "date": "2026-05-18T00:00:00Z", + "open": 97.41, + "high": 99.47, + "low": 96.92, + "close": 98.97, + "volume": 500000 + }, + { + "date": "2026-05-25T00:00:00Z", + "open": 98.97, + "high": 99.86, + "low": 98.48, + "close": 99.37, + "volume": 500000 + }, + { + "date": "2026-06-01T00:00:00Z", + "open": 99.37, + "high": 101.47, + "low": 98.87, + "close": 100.96, + "volume": 500000 + }, + { + "date": "2026-06-08T00:00:00Z", + "open": 100.96, + "high": 101.87, + "low": 100.46, + "close": 101.36, + "volume": 500000 + }, + { + "date": "2026-06-15T00:00:00Z", + "open": 101.36, + "high": 103.51, + "low": 100.86, + "close": 102.99, + "volume": 500000 + }, + { + "date": "2026-06-22T00:00:00Z", + "open": 102.99, + "high": 103.92, + "low": 102.48, + "close": 103.4, + "volume": 500000 + }, + { + "date": "2026-06-29T00:00:00Z", + "open": 103.4, + "high": 105.59, + "low": 102.88, + "close": 105.06, + "volume": 500000 + }, + { + "date": "2026-07-06T00:00:00Z", + "open": 105.06, + "high": 106.01, + "low": 104.54, + "close": 105.48, + "volume": 500000 + }, + { + "date": "2026-07-13T00:00:00Z", + "open": 105.48, + "high": 107.71, + "low": 104.95, + "close": 107.17, + "volume": 500000 + }, + { + "date": "2026-07-20T00:00:00Z", + "open": 107.17, + "high": 108.14, + "low": 106.64, + "close": 107.6, + "volume": 500000 + }, + { + "date": "2026-07-27T00:00:00Z", + "open": 107.6, + "high": 109.88, + "low": 107.06, + "close": 109.33, + "volume": 500000 + }, + { + "date": "2026-08-03T00:00:00Z", + "open": 109.33, + "high": 110.31, + "low": 108.78, + "close": 109.76, + "volume": 500000 + }, + { + "date": "2026-08-10T00:00:00Z", + "open": 109.76, + "high": 112.08, + "low": 109.21, + "close": 111.53, + "volume": 500000 + }, + { + "date": "2026-08-17T00:00:00Z", + "open": 111.53, + "high": 112.53, + "low": 110.97, + "close": 111.97, + "volume": 500000 + }, + { + "date": "2026-08-24T00:00:00Z", + "open": 111.97, + "high": 114.34, + "low": 111.41, + "close": 113.77, + "volume": 500000 + }, + { + "date": "2026-08-31T00:00:00Z", + "open": 113.77, + "high": 114.79, + "low": 113.2, + "close": 114.22, + "volume": 500000 + }, + { + "date": "2026-09-07T00:00:00Z", + "open": 114.22, + "high": 116.64, + "low": 113.65, + "close": 116.05, + "volume": 500000 + }, + { + "date": "2026-09-14T00:00:00Z", + "open": 116.05, + "high": 117.1, + "low": 115.47, + "close": 116.51, + "volume": 500000 + }, + { + "date": "2026-09-21T00:00:00Z", + "open": 116.51, + "high": 118.98, + "low": 115.93, + "close": 118.39, + "volume": 500000 + }, + { + "date": "2026-09-28T00:00:00Z", + "open": 118.39, + "high": 119.45, + "low": 117.8, + "close": 118.86, + "volume": 500000 + } + ] +} \ No newline at end of file diff --git a/internal/backtest/testdata/regime_trend/fundamentals.json b/internal/backtest/testdata/regime_trend/fundamentals.json new file mode 100644 index 0000000..7862af7 --- /dev/null +++ b/internal/backtest/testdata/regime_trend/fundamentals.json @@ -0,0 +1,12 @@ +{ + "ACME": [ + { + "ticker": "ACME", + "as_of": "2025-06-02T00:00:00Z", + "pe": 24.0, + "pb": 4.5, + "eps": 3.0, + "market_cap": 9000000000.0 + } + ] +} \ No newline at end of file diff --git a/internal/backtest/testdata/regime_trend/quotes.json b/internal/backtest/testdata/regime_trend/quotes.json new file mode 100644 index 0000000..5c9cd78 --- /dev/null +++ b/internal/backtest/testdata/regime_trend/quotes.json @@ -0,0 +1,424 @@ +{ + "ACME": [ + { + "ticker": "ACME", + "price": 60.18, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-02T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 60.42, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-09T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 61.39, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-16T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 61.63, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-23T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 62.62, + "avg_dollar_volume": 50000000, + "as_of": "2025-06-30T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 62.87, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-07T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 63.88, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-14T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 64.14, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-21T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 65.17, + "avg_dollar_volume": 50000000, + "as_of": "2025-07-28T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 65.42, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-04T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 66.48, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-11T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 66.74, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-18T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 67.81, + "avg_dollar_volume": 50000000, + "as_of": "2025-08-25T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 68.08, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-01T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 69.18, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-08T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 69.45, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-15T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 70.57, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-22T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 70.85, + "avg_dollar_volume": 50000000, + "as_of": "2025-09-29T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 71.98, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-06T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 72.27, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-13T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 73.43, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-20T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 73.72, + "avg_dollar_volume": 50000000, + "as_of": "2025-10-27T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 74.91, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-03T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 75.2, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-10T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 76.41, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-17T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 76.72, + "avg_dollar_volume": 50000000, + "as_of": "2025-11-24T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 77.95, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-01T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 78.26, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-08T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 79.52, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-15T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 79.83, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-22T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 81.11, + "avg_dollar_volume": 50000000, + "as_of": "2025-12-29T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 81.43, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-05T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 82.74, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-12T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 83.07, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-19T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 84.41, + "avg_dollar_volume": 50000000, + "as_of": "2026-01-26T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 84.74, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-02T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 86.1, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-09T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 86.44, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-16T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 87.83, + "avg_dollar_volume": 50000000, + "as_of": "2026-02-23T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 88.18, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-02T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 89.6, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-09T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 89.95, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-16T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 91.4, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-23T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 91.76, + "avg_dollar_volume": 50000000, + "as_of": "2026-03-30T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 93.24, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-06T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 93.61, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-13T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 95.11, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-20T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 95.49, + "avg_dollar_volume": 50000000, + "as_of": "2026-04-27T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.02, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-04T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 97.41, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-11T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 98.97, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-18T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 99.37, + "avg_dollar_volume": 50000000, + "as_of": "2026-05-25T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 100.96, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-01T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 101.36, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-08T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 102.99, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-15T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 103.4, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-22T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 105.06, + "avg_dollar_volume": 50000000, + "as_of": "2026-06-29T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 105.48, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-06T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 107.17, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-13T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 107.6, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-20T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 109.33, + "avg_dollar_volume": 50000000, + "as_of": "2026-07-27T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 109.76, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-03T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 111.53, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-10T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 111.97, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-17T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 113.77, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-24T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 114.22, + "avg_dollar_volume": 50000000, + "as_of": "2026-08-31T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 116.05, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-07T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 116.51, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-14T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 118.39, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-21T00:00:00Z" + }, + { + "ticker": "ACME", + "price": 118.86, + "avg_dollar_volume": 50000000, + "as_of": "2026-09-28T00:00:00Z" + } + ] +} \ No newline at end of file diff --git a/internal/backtest/testdata/scenario_regime_highvol.json b/internal/backtest/testdata/scenario_regime_highvol.json new file mode 100644 index 0000000..534d532 --- /dev/null +++ b/internal/backtest/testdata/scenario_regime_highvol.json @@ -0,0 +1,60 @@ +{ + "name": "ACME high-vol bleed", + "start_at": "2026-06-08T00:00:00Z", + "fixture_dir": "testdata/regime_highvol", + "goal": { + "domain": "investing", + "objective": "Hold ACME through a violent tape", + "metric": "calibrated thesis conviction", + "context": { + "situation": "ACME rolled over after a long grind up; volatility has exploded.", + "assets": [ + { + "name": "ACME", + "kind": "ticker", + "description": "Primary thesis instrument" + } + ], + "constraints": [ + { + "name": "moderate risk tolerance", + "kind": "risk_tolerance" + }, + { + "name": "1 year horizon", + "kind": "time_horizon" + } + ] + } + }, + "events": [ + { + "at": "2026-06-15T00:00:00Z", + "kind": "price_move", + "payload": { + "ticker": "ACME", + "pct_change": -0.03, + "window_days": 7 + } + }, + { + "at": "2026-07-13T00:00:00Z", + "kind": "price_move", + "payload": { + "ticker": "ACME", + "pct_change": -0.08, + "window_days": 7 + }, + "should_kill": true + }, + { + "at": "2026-08-10T00:00:00Z", + "kind": "thesis_break", + "payload": { + "ticker": "ACME", + "reason": "management withdrew full-year guidance" + }, + "should_kill": true + } + ] +} diff --git a/internal/backtest/testdata/scenario_regime_mixed.json b/internal/backtest/testdata/scenario_regime_mixed.json new file mode 100644 index 0000000..35e0d14 --- /dev/null +++ b/internal/backtest/testdata/scenario_regime_mixed.json @@ -0,0 +1,68 @@ +{ + "name": "ACME trend into crash", + "start_at": "2025-11-03T00:00:00Z", + "fixture_dir": "testdata/regime_mixed", + "goal": { + "domain": "investing", + "objective": "Stay long ACME while the trend holds", + "metric": "calibrated thesis conviction", + "context": { + "situation": "A year-long uptrend is showing its first cracks.", + "assets": [ + { + "name": "ACME", + "kind": "ticker", + "description": "Primary thesis instrument" + } + ], + "constraints": [ + { + "name": "moderate risk tolerance", + "kind": "risk_tolerance" + }, + { + "name": "1 year horizon", + "kind": "time_horizon" + } + ] + } + }, + "events": [ + { + "at": "2025-11-17T00:00:00Z", + "kind": "price_move", + "payload": { + "ticker": "ACME", + "pct_change": 0.02, + "window_days": 7 + } + }, + { + "at": "2025-12-15T00:00:00Z", + "kind": "macro", + "payload": { + "note": "soft landing consensus holds" + } + }, + { + "at": "2026-07-27T00:00:00Z", + "kind": "price_move", + "payload": { + "ticker": "ACME", + "pct_change": -0.09, + "window_days": 21 + }, + "should_kill": true + }, + { + "at": "2026-08-31T00:00:00Z", + "kind": "price_move", + "payload": { + "ticker": "ACME", + "pct_change": -0.13, + "window_days": 35 + }, + "should_kill": true + } + ] +} \ No newline at end of file diff --git a/internal/backtest/testdata/scenario_regime_range.json b/internal/backtest/testdata/scenario_regime_range.json new file mode 100644 index 0000000..10505f5 --- /dev/null +++ b/internal/backtest/testdata/scenario_regime_range.json @@ -0,0 +1,61 @@ +{ + "name": "ACME range-bound value", + "start_at": "2026-06-08T00:00:00Z", + "fixture_dir": "testdata/regime_range", + "goal": { + "domain": "investing", + "objective": "Accumulate cheap ACME while the tape goes nowhere", + "metric": "calibrated thesis conviction", + "context": { + "situation": "ACME has oscillated in a band for a year while earnings stayed strong.", + "assets": [ + { + "name": "ACME", + "kind": "ticker", + "description": "Primary thesis instrument" + } + ], + "constraints": [ + { + "name": "moderate risk tolerance", + "kind": "risk_tolerance" + }, + { + "name": "1 year horizon", + "kind": "time_horizon" + } + ] + } + }, + "events": [ + { + "at": "2026-06-22T00:00:00Z", + "kind": "price_move", + "payload": { + "ticker": "ACME", + "pct_change": 0.035, + "window_days": 7 + } + }, + { + "at": "2026-07-20T00:00:00Z", + "kind": "price_move", + "payload": { + "ticker": "ACME", + "pct_change": -0.04, + "window_days": 7 + } + }, + { + "at": "2026-08-17T00:00:00Z", + "kind": "valuation_change", + "payload": { + "ticker": "ACME", + "metric": "pe", + "value": 11.0, + "fair_value": 13.0, + "gap_pct": 0.18 + } + } + ] +} diff --git a/internal/backtest/testdata/scenario_regime_trend.json b/internal/backtest/testdata/scenario_regime_trend.json new file mode 100644 index 0000000..07b9625 --- /dev/null +++ b/internal/backtest/testdata/scenario_regime_trend.json @@ -0,0 +1,57 @@ +{ + "name": "ACME persistent uptrend", + "start_at": "2026-06-08T00:00:00Z", + "fixture_dir": "testdata/regime_trend", + "goal": { + "domain": "investing", + "objective": "Ride a durable uptrend in ACME", + "metric": "calibrated thesis conviction", + "context": { + "situation": "ACME has compounded steadily for a year on improving demand.", + "assets": [ + { + "name": "ACME", + "kind": "ticker", + "description": "Primary thesis instrument" + } + ], + "constraints": [ + { + "name": "moderate risk tolerance", + "kind": "risk_tolerance" + }, + { + "name": "1 year horizon", + "kind": "time_horizon" + } + ] + } + }, + "events": [ + { + "at": "2026-06-22T00:00:00Z", + "kind": "price_move", + "payload": { + "ticker": "ACME", + "pct_change": 0.02, + "window_days": 7 + } + }, + { + "at": "2026-07-20T00:00:00Z", + "kind": "price_move", + "payload": { + "ticker": "ACME", + "pct_change": -0.015, + "window_days": 7 + } + }, + { + "at": "2026-08-17T00:00:00Z", + "kind": "macro", + "payload": { + "note": "rates unchanged; growth backdrop intact" + } + } + ] +} diff --git a/internal/backtest/walkforward.go b/internal/backtest/walkforward.go index 1e8e561..f458c80 100644 --- a/internal/backtest/walkforward.go +++ b/internal/backtest/walkforward.go @@ -4,9 +4,11 @@ import ( "context" "fmt" + "github.com/vingrad/dynamic-decision-engine/internal/domain" "github.com/vingrad/dynamic-decision-engine/internal/finance" "github.com/vingrad/dynamic-decision-engine/internal/pack" "github.com/vingrad/dynamic-decision-engine/internal/policy" + "github.com/vingrad/dynamic-decision-engine/internal/strategy" ) // WalkForwardResult compares raw against calibrated confidence on held-out @@ -61,6 +63,99 @@ func WalkForward(ctx context.Context, reg *pack.Registry, pol policy.Policy, sce return res, nil } +// WalkForwardStrategiesResult compares unweighted against outcome-weighted +// strategy selection on held-out decisions, the strategy analogue of +// WalkForwardResult: the weights are fit on the first trainN decisions and +// the remaining competitions are re-weighed offline. +type WalkForwardStrategiesResult struct { + TrainDecisions int `json:"train_decisions"` + EvalDecisions int `json:"eval_decisions"` + BrierUnweighted float64 `json:"brier_unweighted"` + BrierWeighted float64 `json:"brier_weighted"` + Weights map[string]float64 `json:"weights"` +} + +// WalkForwardStrategies replays the scenarios with the strategy selector on, +// fits per-strategy weights on the first trainN decisions, and re-weighs the +// remaining decisions' RECORDED competitions with and without the table — the +// engine is not re-run, which evaluates exactly the mapping `dde strategy-fit` +// would install via policy. Both arms score the chosen candidate's recorded +// top confidence against the realized label, so the comparison isolates the +// weight table. Decisions that recorded no competition are skipped. +func WalkForwardStrategies(ctx context.Context, reg *pack.Registry, pol policy.Policy, scenarios []Scenario, trainN int) (WalkForwardStrategiesResult, error) { + on := true + cells, err := RunStrategyMatrix(ctx, reg, overrideStrategy(pol, policy.StrategySelection{Enabled: &on}), scenarios) + if err != nil { + return WalkForwardStrategiesResult{}, err + } + var decisions []Decision + for _, c := range cells { + if c.Config != "selector" { + continue + } + for _, d := range c.Report.Decisions { + if len(d.Candidates) > 0 { + decisions = append(decisions, d) + } + } + } + if trainN <= 0 || trainN >= len(decisions) { + return WalkForwardStrategiesResult{}, fmt.Errorf("backtest: trainN must split %d decisions, got %d", len(decisions), trainN) + } + + train, eval := decisions[:trainN], decisions[trainN:] + samples := make([]finance.StrategySample, 0, len(train)) + for _, d := range train { + samples = append(samples, finance.StrategySample{ + Strategy: d.SelectedStrategy, + Regime: finance.Regime(d.Regime), + Success: d.Label == 1, + }) + } + weights := finance.FitStrategyWeights(samples) + + // Both arms run the SAME offline selection rule (utility argmax over the + // recorded competition, recorded winner as the all-filtered fallback) and + // differ only in the weight table — so the comparison isolates the table, + // not the absence of hysteresis or degraded-mode handling. + res := WalkForwardStrategiesResult{TrainDecisions: len(train), EvalDecisions: len(eval), Weights: weights} + pick := func(d Decision, w map[string]float64) int { + if i := strategy.ReWeigh(d.Candidates, w, d.Regime); i >= 0 { + return i + } + return recordedWinner(d) + } + for _, d := range eval { + unweighted := candidateConfidence(d.Candidates, pick(d, nil)) - d.Label + reweighed := candidateConfidence(d.Candidates, pick(d, weights)) - d.Label + res.BrierUnweighted += unweighted * unweighted + res.BrierWeighted += reweighed * reweighed + } + res.BrierUnweighted /= float64(len(eval)) + res.BrierWeighted /= float64(len(eval)) + return res, nil +} + +// recordedWinner finds the recorded competition entry of the strategy that +// actually won the decision; -1 when it cannot be resolved. +func recordedWinner(d Decision) int { + for i, c := range d.Candidates { + if c.StrategyID == d.SelectedStrategy { + return i + } + } + return -1 +} + +// candidateConfidence reads a recorded candidate's top confidence; an +// unresolvable index scores as a coin flip rather than skewing either arm. +func candidateConfidence(cands []domain.StrategyCandidate, i int) float64 { + if i < 0 || i >= len(cands) { + return 0.5 + } + return cands[i].TopConfidence +} + // rawConfidence is a decision's pre-calibration confidence, falling back to the // shipped confidence for planners (or old records) that don't report one. func rawConfidence(d Decision) float64 { diff --git a/internal/backtest/walkforward_test.go b/internal/backtest/walkforward_test.go index cc52717..1604c32 100644 --- a/internal/backtest/walkforward_test.go +++ b/internal/backtest/walkforward_test.go @@ -2,6 +2,7 @@ package backtest import ( "context" + "reflect" "testing" "github.com/vingrad/dynamic-decision-engine/internal/pack" @@ -54,3 +55,38 @@ func TestWalkForwardRejectsBadSplit(t *testing.T) { t.Error("trainN beyond the decision count should be rejected") } } + +func TestWalkForwardStrategies(t *testing.T) { + scenarios := loadRegimeCorpus(t) + res, err := WalkForwardStrategies(context.Background(), pack.NewRegistry(), policy.Policy{}, scenarios, 7) + if err != nil { + t.Fatal(err) + } + if res.TrainDecisions != 7 || res.EvalDecisions == 0 { + t.Fatalf("unexpected split: %+v", res) + } + // Gate: the weight table must not hurt out-of-sample. With thin training + // evidence the fit shrinks toward the identity, so equality is a pass. + if res.BrierWeighted > res.BrierUnweighted+1e-9 { + t.Errorf("weighted brier %.4f worse than unweighted %.4f out-of-sample", res.BrierWeighted, res.BrierUnweighted) + } + for k, w := range res.Weights { + if w < 0.5 || w > 1.5 { + t.Errorf("weight %q = %v escaped the clamp", k, w) + } + } + + // Determinism: same corpus, same result. + res2, err := WalkForwardStrategies(context.Background(), pack.NewRegistry(), policy.Policy{}, scenarios, 7) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(res, res2) { + t.Error("walk-forward not deterministic") + } + + // An invalid split must error, not panic. + if _, err := WalkForwardStrategies(context.Background(), pack.NewRegistry(), policy.Policy{}, scenarios, 0); err == nil { + t.Error("trainN=0 must error") + } +} diff --git a/internal/domain/provenance.go b/internal/domain/provenance.go index 5fc1576..6252fbb 100644 --- a/internal/domain/provenance.go +++ b/internal/domain/provenance.go @@ -22,8 +22,9 @@ type DecisionProvenance struct { PackID string `json:"pack_id,omitempty"` PackVersion string `json:"pack_version,omitempty"` - // Strategy is the planning strategy: "single" for one model, or "verify", - // "route", "ensemble" for multi-model compositions. Empty is treated as single. + // Strategy is the planning strategy: "single" for one model, "verify", + // "route", "ensemble" for multi-model compositions, or "selector" when named + // domain strategies competed and one was selected. Empty is treated as single. Strategy string `json:"strategy,omitempty"` // Contributors records every model that participated and its role, so a // multi-model decision is auditable end to end. @@ -32,6 +33,19 @@ type DecisionProvenance struct { // flags, agreement summary, or an escalation reason. Notes string `json:"notes,omitempty"` + // SelectedStrategy names the domain strategy whose candidate plan won the + // selection (e.g. "momentum"). Set only when Strategy == "selector". + SelectedStrategy string `json:"selected_strategy,omitempty"` + // Regime is the market-regime label in effect when strategies were gated + // ("trend", "range", "high_vol"); empty when unknown or not classified. It is + // recorded even when gating changed nothing, so per-regime outcome analysis + // stays possible after the fact. + Regime string `json:"regime,omitempty"` + // StrategyCandidates is the audit trail of the strategy competition: every + // candidate that ran, its utility score, and why the losers lost (or were + // filtered). Present only when Strategy == "selector". + StrategyCandidates []StrategyCandidate `json:"strategy_candidates,omitempty"` + // SourceContributions records every external data source consulted before // planning: its identity, when it was fetched, the verbatim payload, and // whether the data was stale. The fetched data itself is folded into the goal @@ -40,6 +54,20 @@ type DecisionProvenance struct { SourceContributions []SourceContribution `json:"source_contributions,omitempty"` } +// StrategyCandidate is the audit record for one strategy's candidate plan in a +// selection: its identity, its goal-derived utility (and the outcome weight +// applied), its top recommendation, and — when it lost or was filtered — why. +type StrategyCandidate struct { + StrategyID string `json:"strategy_id"` + Planner string `json:"planner"` + UtilityScore float64 `json:"utility_score"` + Weight float64 `json:"weight,omitempty"` + TopMoveKey string `json:"top_move_key,omitempty"` + TopConfidence float64 `json:"top_confidence,omitempty"` + Filtered bool `json:"filtered,omitempty"` + Reason string `json:"reason,omitempty"` +} + // SourceContribution is the audit record for one external data source consulted // during a decision. Raw and FetchedAt exist for audit only and never enter the // input snapshot hash, so they cannot affect reproducibility. diff --git a/internal/engine/replanner.go b/internal/engine/replanner.go index 03fc189..fd6c6e3 100644 --- a/internal/engine/replanner.go +++ b/internal/engine/replanner.go @@ -37,7 +37,7 @@ func (e *Engine) Replan(ctx context.Context, goal domain.Goal, current domain.Pl // Enrich after the gate so a signal the domain ignores never pays for a fetch. goal, contribs := e.enrich(ctx, goal, signalKind, signalPayload) - res, err := e.planner.GeneratePlan(ctx, llm.PlanRequest{Goal: goal, SignalNote: signalNote, SignalKind: signalKind, SignalPayload: signalPayload, CurrentMoves: current.RankedMoves}) + res, err := e.planner.GeneratePlan(ctx, llm.PlanRequest{Goal: goal, SignalNote: signalNote, SignalKind: signalKind, SignalPayload: signalPayload, CurrentMoves: current.RankedMoves, CurrentStrategy: current.Provenance.SelectedStrategy}) if err != nil { return ReplanResult{}, fmt.Errorf("engine: replan: %w", err) } diff --git a/internal/finance/prior.go b/internal/finance/prior.go index 6a4789b..d02d24d 100644 --- a/internal/finance/prior.go +++ b/internal/finance/prior.go @@ -10,72 +10,128 @@ const ( priorCeil = 0.65 ) -// WinProbPrior derives a heuristic win-probability prior from point-in-time -// fundamentals and trailing price momentum. Each component contributes a small, -// transparent tilt around the 0.5 base rate: +// PriorTilts are the signed, per-component tilts around the 0.5 base rate. Each +// OK flag reports whether the component carried any information at all — a tilt +// of zero with OK=true means "looked, found the neutral band", while OK=false +// means "no data to look at". +type PriorTilts struct { + Valuation float64 // PE bands + price/book bands, summed + ValuationOK bool + Quality float64 // earnings-quality (EPS sign) tilt + QualityOK bool + Momentum float64 // trailing cumulative-return tilt + MomentumOK bool +} + +// ComputePriorTilts evaluates the per-component tilts from point-in-time +// fundamentals and trailing price momentum: // -// - valuation: cheap PE tilts up, expensive PE tilts down (neutral band 16–22) -// - price/book: very cheap up, very rich down -// - earnings quality: positive EPS slightly up, losses down +// - valuation: cheap PE tilts up, expensive PE tilts down (neutral band 16–22); +// very cheap price/book up, very rich down +// - quality: positive EPS slightly up, losses down // - momentum: trailing cumulative return beyond ±10% tilts with its sign -// -// It returns ok=false when neither fundamentals nor enough price history carry -// any information, so callers can keep the EV component neutral. -func WinProbPrior(f marketdata.Fundamentals, returns []float64) (float64, bool) { - tilt := 0.0 - informed := false +func ComputePriorTilts(f marketdata.Fundamentals, returns []float64) PriorTilts { + var t PriorTilts if f.PE > 0 { - informed = true + t.ValuationOK = true switch { case f.PE <= 12: - tilt += 0.05 + t.Valuation += 0.05 case f.PE <= 16: - tilt += 0.03 + t.Valuation += 0.03 case f.PE <= 22: // neutral band case f.PE <= 30: - tilt -= 0.02 + t.Valuation -= 0.02 default: - tilt -= 0.05 + t.Valuation -= 0.05 } } if f.PB > 0 { - informed = true + t.ValuationOK = true switch { case f.PB <= 1.5: - tilt += 0.02 + t.Valuation += 0.02 case f.PB >= 4: - tilt -= 0.02 + t.Valuation -= 0.02 } } if f.EPS != 0 { - informed = true + t.QualityOK = true if f.EPS > 0 { - tilt += 0.01 + t.Quality += 0.01 } else { - tilt -= 0.04 + t.Quality -= 0.04 } } if cum, ok := cumulativeReturn(returns); ok { - informed = true + t.MomentumOK = true switch { case cum >= 0.10: - tilt += 0.03 + t.Momentum += 0.03 case cum <= -0.10: - tilt -= 0.03 + t.Momentum -= 0.03 } } + return t +} - // A zero net tilt carries no information: reporting it as informed would - // re-enable the volatility-scaled EV path under an effectively flat prior — - // the exact pathology NeutralEVScore exists to prevent. +// PriorWeights scales each tilt component; {1, 1, 1} reproduces the blended +// baseline exactly. A zero value normalizes to the baseline so a strategy that +// doesn't care about the prior can leave it unset. +type PriorWeights struct { + Valuation float64 `json:"valuation"` + Quality float64 `json:"quality"` + Momentum float64 `json:"momentum"` +} + +// Normalize maps the zero value onto the neutral {1, 1, 1} weighting so an +// unset field block never silently zeroes the prior. +func (w PriorWeights) Normalize() PriorWeights { + if w == (PriorWeights{}) { + return PriorWeights{Valuation: 1, Quality: 1, Momentum: 1} + } + return w +} + +// WinProbPriorWeighted blends the component tilts under the given weights. Only +// components that carried information contribute. It returns ok=false when no +// component is informed or the weighted net tilt is zero — reporting a flat +// prior as informed would re-enable the volatility-scaled EV path, the exact +// pathology NeutralEVScore exists to prevent. The clamp stays the global +// [priorFloor, priorCeil]: no weighting gets to be more "convinced" than the +// blended baseline ever was. +func WinProbPriorWeighted(t PriorTilts, w PriorWeights) (float64, bool) { + tilt := 0.0 + informed := false + if t.ValuationOK { + informed = true + tilt += w.Valuation * t.Valuation + } + if t.QualityOK { + informed = true + tilt += w.Quality * t.Quality + } + if t.MomentumOK { + informed = true + tilt += w.Momentum * t.Momentum + } if !informed || tilt == 0 { return 0, false } return clampRange(0.5+tilt, priorFloor, priorCeil), true } +// WinProbPrior derives a heuristic win-probability prior from point-in-time +// fundamentals and trailing price momentum, blending every component at its +// baseline weight. It returns ok=false when neither fundamentals nor enough +// price history carry any information, so callers can keep the EV component +// neutral. +func WinProbPrior(f marketdata.Fundamentals, returns []float64) (float64, bool) { + return WinProbPriorWeighted(ComputePriorTilts(f, returns), PriorWeights{Valuation: 1, Quality: 1, Momentum: 1}) +} + // cumulativeReturn compounds a return series; it needs at least two observations // to say anything about momentum. func cumulativeReturn(returns []float64) (float64, bool) { diff --git a/internal/finance/regime.go b/internal/finance/regime.go new file mode 100644 index 0000000..598d956 --- /dev/null +++ b/internal/finance/regime.go @@ -0,0 +1,127 @@ +package finance + +import ( + "fmt" + "math" + + "github.com/vingrad/dynamic-decision-engine/internal/marketdata" +) + +// The market regime is a COARSE DESCRIPTIVE LABEL of recent price action, only +// ever known in hindsight. It gates which strategy lenses compete for a goal; +// it does not forecast and must never be read as market timing. Per the +// package doc's honesty framing, RegimeUnknown is a first-class answer — too +// little history, or nothing distinctive about the tape — and an unknown +// regime gates nothing. + +// Regime labels recent price action. +type Regime string + +const ( + RegimeTrend Regime = "trend" + RegimeRange Regime = "range" + RegimeHighVol Regime = "high_vol" + RegimeUnknown Regime = "" +) + +const ( + // regimeMinBars refuses to label fewer than ~2 months of daily bars (or ~9 + // months of weekly bars) — below that the label would be noise. + regimeMinBars = 40 + // regimeWindowBars classifies on the trailing window only, so an old crash + // cannot keep the label stuck on high_vol forever. + regimeWindowBars = 126 + // regimeHighVolMonthly: a 21-day scaled volatility at or above 12% reads as + // a high-volatility tape regardless of direction. + regimeHighVolMonthly = 0.12 + // regimeHighVolDrawdown: a 20% peak-to-trough fall inside the window reads + // as high_vol even when day-to-day volatility looks tame (slow bleeds). + regimeHighVolDrawdown = 0.20 + // regimeTrendER is the Kaufman efficiency-ratio threshold for a trend: net + // movement at least 30% of total movement. + regimeTrendER = 0.30 +) + +// RegimeReading is one classification with the numbers behind it, so the +// label is auditable. +type RegimeReading struct { + Regime Regime `json:"regime"` + TrendStrength float64 `json:"trend_strength"` // efficiency ratio in [0, 1] + MonthlyVol float64 `json:"monthly_vol"` // 21-day scaled volatility + Note string `json:"note"` +} + +// ClassifyRegime labels the trailing window of a bar series. It is pure and +// deterministic: same bars, same label. The high-volatility check runs first — +// risk dominates direction — then trend vs. range by efficiency ratio. +func ClassifyRegime(bars []marketdata.Bar) RegimeReading { + if len(bars) < regimeMinBars { + return RegimeReading{Regime: RegimeUnknown, Note: fmt.Sprintf("only %d bars; too little history to classify", len(bars))} + } + window := bars + if len(window) > regimeWindowBars { + window = window[len(window)-regimeWindowBars:] + } + + rets := Returns(window) + vol := Volatility(rets) + monthlyVol := ScaledVol(vol, BarIntervalDays(window), 21) + dd := MaxDrawdown(window) + er := efficiencyRatio(window) + + r := RegimeReading{TrendStrength: er, MonthlyVol: monthlyVol} + switch { + case monthlyVol >= regimeHighVolMonthly || dd >= regimeHighVolDrawdown: + r.Regime = RegimeHighVol + r.Note = fmt.Sprintf("monthly vol %.1f%%, window drawdown %.1f%%", monthlyVol*100, dd*100) + case er >= regimeTrendER: + r.Regime = RegimeTrend + r.Note = fmt.Sprintf("efficiency ratio %.2f over %d bars", er, len(window)) + default: + r.Regime = RegimeRange + r.Note = fmt.Sprintf("efficiency ratio %.2f over %d bars", er, len(window)) + } + return r +} + +// efficiencyRatio is Kaufman's signal-to-noise measure: net close-to-close +// movement over total close-to-close movement, in [0, 1]. A zero denominator +// (a perfectly flat series) reads as 0 — no trend. +func efficiencyRatio(bars []marketdata.Bar) float64 { + if len(bars) < 2 { + return 0 + } + var total float64 + for i := 1; i < len(bars); i++ { + total += math.Abs(bars[i].Close - bars[i-1].Close) + } + if total == 0 { + return 0 + } + return math.Abs(bars[len(bars)-1].Close-bars[0].Close) / total +} + +// CombineRegimes folds per-ticker readings into one goal-level label: any +// high_vol wins (risk dominates), else the strict majority of trend vs. range; +// ties, empty input and all-unknown read as unknown. +func CombineRegimes(readings []RegimeReading) Regime { + trend, rng := 0, 0 + for _, r := range readings { + switch r.Regime { + case RegimeHighVol: + return RegimeHighVol + case RegimeTrend: + trend++ + case RegimeRange: + rng++ + } + } + switch { + case trend > rng: + return RegimeTrend + case rng > trend: + return RegimeRange + default: + return RegimeUnknown + } +} diff --git a/internal/finance/regime_test.go b/internal/finance/regime_test.go new file mode 100644 index 0000000..b5581e6 --- /dev/null +++ b/internal/finance/regime_test.go @@ -0,0 +1,109 @@ +package finance + +import ( + "testing" + "time" + + "github.com/vingrad/dynamic-decision-engine/internal/marketdata" +) + +// dailyBars builds a daily bar series from closes, starting at a fixed date. +func dailyBars(closes []float64) []marketdata.Bar { + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + bars := make([]marketdata.Bar, len(closes)) + prev := closes[0] + for i, c := range closes { + bars[i] = marketdata.Bar{ + Date: start.AddDate(0, 0, i), Open: prev, + High: max(prev, c) * 1.001, Low: min(prev, c) * 0.999, + Close: c, Volume: 100000, + } + prev = c + } + return bars +} + +func TestClassifyRegime(t *testing.T) { + linearUp := make([]float64, 80) + for i := range linearUp { + linearUp[i] = 100 * (1 + 0.002*float64(i)) // steady grind, ER near 1 + } + + sawtooth := make([]float64, 80) + for i := range sawtooth { + sawtooth[i] = 100 + if i%2 == 1 { + sawtooth[i] = 102 // alternating ±2%: total movement huge, net ~0 + } + } + + crash := make([]float64, 80) + for i := range crash { + crash[i] = 100 + if i >= 50 { + crash[i] = 100 * (1 - 0.01*float64(i-50)) // slow 29% bleed + } + } + + wild := make([]float64, 80) + for i := range wild { + wild[i] = 100 + if i%2 == 1 { + wild[i] = 104 // alternating ±4% daily: monthly vol >> 12% + } + } + + cases := []struct { + name string + bars []marketdata.Bar + want Regime + }{ + {"too little history", dailyBars(linearUp[:39]), RegimeUnknown}, + {"steady uptrend", dailyBars(linearUp), RegimeTrend}, + {"sawtooth range", dailyBars(sawtooth), RegimeRange}, + {"slow deep drawdown", dailyBars(crash), RegimeHighVol}, + {"violent chop", dailyBars(wild), RegimeHighVol}, + {"no bars", nil, RegimeUnknown}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := ClassifyRegime(tc.bars) + if r.Regime != tc.want { + t.Errorf("regime = %q (note %q), want %q", r.Regime, r.Note, tc.want) + } + if r.Note == "" { + t.Error("every reading must explain itself") + } + }) + } + + // Determinism: same bars, same reading. + a, b := ClassifyRegime(dailyBars(crash)), ClassifyRegime(dailyBars(crash)) + if a != b { + t.Errorf("classification not deterministic: %+v vs %+v", a, b) + } +} + +func TestCombineRegimes(t *testing.T) { + r := func(reg Regime) RegimeReading { return RegimeReading{Regime: reg} } + cases := []struct { + name string + readings []RegimeReading + want Regime + }{ + {"empty", nil, RegimeUnknown}, + {"all unknown", []RegimeReading{r(RegimeUnknown), r(RegimeUnknown)}, RegimeUnknown}, + {"high_vol dominates", []RegimeReading{r(RegimeTrend), r(RegimeTrend), r(RegimeHighVol)}, RegimeHighVol}, + {"trend majority", []RegimeReading{r(RegimeTrend), r(RegimeTrend), r(RegimeRange)}, RegimeTrend}, + {"range majority", []RegimeReading{r(RegimeRange), r(RegimeRange), r(RegimeTrend)}, RegimeRange}, + {"tie is unknown", []RegimeReading{r(RegimeTrend), r(RegimeRange)}, RegimeUnknown}, + {"unknowns do not vote", []RegimeReading{r(RegimeUnknown), r(RegimeTrend)}, RegimeTrend}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := CombineRegimes(tc.readings); got != tc.want { + t.Errorf("CombineRegimes = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/finance/strategy.go b/internal/finance/strategy.go new file mode 100644 index 0000000..a0231c4 --- /dev/null +++ b/internal/finance/strategy.go @@ -0,0 +1,133 @@ +package finance + +// Strategies are named heuristic LENSES over the same transparent scoring math +// — prior component weights, composite weights, reward:risk assumption and a +// risk-budget scale. They are not calibrated alpha and must never be read as +// trading systems; per the package doc, this remains decision support. Which +// lens wins for a given goal is decided elsewhere (the strategy selector); this +// file only defines what a lens is, as pure data. + +// RiskScale multiplies the base risk budget. The hard-cap multipliers +// (PerTradeRisk, Aggregate) are clamped to (0, 1] by Apply — a strategy may +// only TIGHTEN caps, never loosen them, mirroring EffectiveRiskBudget's +// "aggressive loosens Kelly only" rule. Kelly may scale either way; the caps +// remain guardrails around it. Zero fields mean 1.0 (no change). +type RiskScale struct { + Kelly float64 `json:"kelly,omitempty"` + PerTradeRisk float64 `json:"per_trade_risk,omitempty"` + Aggregate float64 `json:"aggregate,omitempty"` +} + +// StrategyParams is one named lens over the scoring math. Zero-valued fields +// leave the base configuration untouched, so a partial override tunes one knob +// without dragging the rest to zero. +type StrategyParams struct { + Name string `json:"name"` + Prior PriorWeights `json:"prior"` + Weights ScoreWeights `json:"weights"` + RewardRiskRatio float64 `json:"reward_risk_ratio,omitempty"` + RiskScale RiskScale `json:"risk_scale,omitempty"` +} + +// Apply overlays the strategy onto a base config: it replaces Weights and +// RewardRiskRatio when set and multiplies the risk budget by RiskScale (caps +// clamped to tighten-only). Order of operations in the planner is +// +// pack/policy base -> strategy.Apply -> EffectiveRiskBudget(goal constraints) +// +// so the goal's own constraints always have the last (tightening) word. +func (s StrategyParams) Apply(base ScoringConfig) ScoringConfig { + out := base.Normalize() + if s.Weights != (ScoreWeights{}) { + out.Weights = s.Weights + } + if s.RewardRiskRatio > 0 { + out.RewardRiskRatio = s.RewardRiskRatio + } + if k := s.RiskScale.Kelly; k > 0 { + out.Risk.KellyFraction *= k + } + if v := tightenOnly(s.RiskScale.PerTradeRisk); v < 1 { + out.Risk.MaxPortfolioRiskPct *= v + out.Risk.MaxPositionPct *= v + } + if v := tightenOnly(s.RiskScale.Aggregate); v < 1 && out.Risk.MaxAggregateRiskPct > 0 { + // A negative aggregate cap is the explicit "disabled" spelling; scaling + // it would be meaningless, so only a positive cap tightens. + out.Risk.MaxAggregateRiskPct *= v + } + return out +} + +// Merge overlays the SET fields of an override onto the receiver, leaving +// zero-valued fields of the override alone — the same partial-override +// semantics every policy knob follows. A policy tuning one knob of a lens +// (e.g. its Kelly scale) must not silently strip the lens's other parameters: +// without this, an unset Prior would normalize to the neutral blend and erase +// the lens's identity. Name is identity, never overridable. +func (s StrategyParams) Merge(o StrategyParams) StrategyParams { + if o.Prior != (PriorWeights{}) { + s.Prior = o.Prior + } + if o.Weights != (ScoreWeights{}) { + s.Weights = o.Weights + } + if o.RewardRiskRatio > 0 { + s.RewardRiskRatio = o.RewardRiskRatio + } + if o.RiskScale.Kelly > 0 { + s.RiskScale.Kelly = o.RiskScale.Kelly + } + if o.RiskScale.PerTradeRisk > 0 { + s.RiskScale.PerTradeRisk = o.RiskScale.PerTradeRisk + } + if o.RiskScale.Aggregate > 0 { + s.RiskScale.Aggregate = o.RiskScale.Aggregate + } + return s +} + +// tightenOnly maps a cap multiplier onto (0, 1]: zero (unset) and anything +// above 1 become 1 (no change); only a genuine tightening passes through. +func tightenOnly(v float64) float64 { + if v <= 0 || v >= 1 { + return 1 + } + return v +} + +// DefaultStrategySet returns the investing pack's standard lenses in their +// canonical, fixed order — that order is the last-resort tie-break during +// selection, so it must never depend on map iteration. +// +// - value buys weakness: the valuation tilt dominates, momentum is nearly +// ignored, targets are larger (2.5 reward:risk) and held longer. +// - momentum rides strength and pays for its exits: the momentum tilt +// dominates, liquidity weighs more, and the 1.8 reward:risk keeps targets +// tight. +// - defensive preserves capital: quality-heavy prior, risk-heavy composite, +// halved Kelly and tightened caps. It is the always-admissible fallback — +// its pack descriptor declares no regime restrictions. +func DefaultStrategySet() []StrategyParams { + return []StrategyParams{ + { + Name: "value", + Prior: PriorWeights{Valuation: 1.6, Quality: 1.0, Momentum: 0.4}, + Weights: ScoreWeights{EV: 0.45, Risk: 0.25, Liquidity: 0.15, Horizon: 0.15}, + RewardRiskRatio: 2.5, + }, + { + Name: "momentum", + Prior: PriorWeights{Valuation: 0.3, Quality: 0.7, Momentum: 1.8}, + Weights: ScoreWeights{EV: 0.50, Risk: 0.20, Liquidity: 0.20, Horizon: 0.10}, + RewardRiskRatio: 1.8, + }, + { + Name: "defensive", + Prior: PriorWeights{Valuation: 0.8, Quality: 1.6, Momentum: 0.6}, + Weights: ScoreWeights{EV: 0.25, Risk: 0.45, Liquidity: 0.15, Horizon: 0.15}, + RewardRiskRatio: 2.0, + RiskScale: RiskScale{Kelly: 0.5, PerTradeRisk: 0.75, Aggregate: 0.75}, + }, + } +} diff --git a/internal/finance/strategy_test.go b/internal/finance/strategy_test.go new file mode 100644 index 0000000..1bbebfd --- /dev/null +++ b/internal/finance/strategy_test.go @@ -0,0 +1,206 @@ +package finance + +import ( + "testing" + + "github.com/vingrad/dynamic-decision-engine/internal/marketdata" +) + +// TestWinProbPriorWeightedNeutralMatchesBaseline runs representative inputs +// through both the legacy blended path and the decomposed weighted path at +// neutral weights: the decomposition must be bit-for-bit invisible. +func TestWinProbPriorWeightedNeutralMatchesBaseline(t *testing.T) { + cases := []struct { + name string + f marketdata.Fundamentals + returns []float64 + }{ + {name: "no information"}, + {name: "single return", returns: []float64{0.04}}, + {name: "cheap PE positive EPS", f: marketdata.Fundamentals{PE: 14, PB: 2.0, EPS: 6.6}}, + {name: "very cheap PE and PB", f: marketdata.Fundamentals{PE: 10, PB: 1.2, EPS: 3.0}}, + {name: "rich PE and PB", f: marketdata.Fundamentals{PE: 30, PB: 5.0, EPS: 1.3}}, + {name: "loss-maker extreme valuation", f: marketdata.Fundamentals{PE: 45, PB: 6.0, EPS: -2.0}}, + {name: "neutral PE band alone", f: marketdata.Fundamentals{PE: 20}}, + {name: "positive momentum", returns: []float64{0.05, 0.05, 0.05}}, + {name: "negative momentum", returns: []float64{-0.05, -0.05, -0.05}}, + {name: "small momentum alone", returns: []float64{0.01, 0.01}}, + {name: "max bearish at floor", f: marketdata.Fundamentals{PE: 45, PB: 6.0, EPS: -2.0}, returns: []float64{-0.10, -0.10}}, + } + neutral := PriorWeights{Valuation: 1, Quality: 1, Momentum: 1} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wantP, wantOK := WinProbPrior(tc.f, tc.returns) + gotP, gotOK := WinProbPriorWeighted(ComputePriorTilts(tc.f, tc.returns), neutral) + if gotOK != wantOK || gotP != wantP { + t.Errorf("weighted neutral = (%v, %v), baseline = (%v, %v)", gotP, gotOK, wantP, wantOK) + } + }) + } +} + +func TestWinProbPriorWeightedTilts(t *testing.T) { + // A trending loss-maker: momentum up, quality down, valuation rich. + f := marketdata.Fundamentals{PE: 35, EPS: -1.0} + returns := []float64{0.06, 0.06, 0.06} + tilts := ComputePriorTilts(f, returns) + + momentum, okM := WinProbPriorWeighted(tilts, PriorWeights{Valuation: 0.3, Quality: 0.7, Momentum: 1.8}) + value, okV := WinProbPriorWeighted(tilts, PriorWeights{Valuation: 1.6, Quality: 1.0, Momentum: 0.4}) + if !okM || !okV { + t.Fatalf("both lenses should be informed: momentum ok=%v value ok=%v", okM, okV) + } + // The momentum lens forgives valuation and rides the trend; the value lens + // punishes the rich multiple and the losses. + if momentum <= value { + t.Errorf("momentum lens %v should rank the trending name above the value lens %v", momentum, value) + } + + // Clamp bounds hold under extreme weights. + if p, ok := WinProbPriorWeighted(tilts, PriorWeights{Valuation: 100, Quality: 100, Momentum: 0.0001}); ok && (p < priorFloor || p > priorCeil) { + t.Errorf("weighted prior %v escaped clamp [%v, %v]", p, priorFloor, priorCeil) + } + + // Weights that cancel the net tilt are uninformed, preserving the + // NeutralEVScore guard. + cancel := PriorWeights{Valuation: 0, Quality: 0, Momentum: 0} + if _, ok := WinProbPriorWeighted(tilts, cancel); ok { + t.Error("zero weights must read as uninformed, not an informed 0.50") + } +} + +func TestPriorWeightsNormalize(t *testing.T) { + if got := (PriorWeights{}).Normalize(); got != (PriorWeights{Valuation: 1, Quality: 1, Momentum: 1}) { + t.Errorf("zero value should normalize to neutral, got %+v", got) + } + set := PriorWeights{Valuation: 1.6, Quality: 1, Momentum: 0.4} + if got := set.Normalize(); got != set { + t.Errorf("set weights must pass through unchanged, got %+v", got) + } +} + +func TestStrategyParamsApply(t *testing.T) { + base := DefaultScoringConfig() + + t.Run("zero strategy is identity on a normalized base", func(t *testing.T) { + if got := (StrategyParams{}).Apply(base); got != base { + t.Errorf("Apply(zero) = %+v, want base %+v", got, base) + } + }) + + t.Run("weights and ratio replace when set", func(t *testing.T) { + s := StrategyParams{ + Weights: ScoreWeights{EV: 0.5, Risk: 0.2, Liquidity: 0.2, Horizon: 0.1}, + RewardRiskRatio: 1.8, + } + got := s.Apply(base) + if got.Weights != s.Weights || got.RewardRiskRatio != 1.8 { + t.Errorf("Apply did not overlay weights/ratio: %+v", got) + } + if got.Risk != base.Risk { + t.Errorf("risk budget must be untouched without a RiskScale: %+v", got.Risk) + } + }) + + t.Run("risk scale tightens caps and scales kelly", func(t *testing.T) { + s := StrategyParams{RiskScale: RiskScale{Kelly: 0.5, PerTradeRisk: 0.75, Aggregate: 0.75}} + got := s.Apply(base) + if !approx(got.Risk.KellyFraction, base.Risk.KellyFraction*0.5) { + t.Errorf("kelly = %v, want halved", got.Risk.KellyFraction) + } + if !approx(got.Risk.MaxPortfolioRiskPct, base.Risk.MaxPortfolioRiskPct*0.75) || + !approx(got.Risk.MaxPositionPct, base.Risk.MaxPositionPct*0.75) { + t.Errorf("per-trade caps not tightened: %+v", got.Risk) + } + if !approx(got.Risk.MaxAggregateRiskPct, base.Risk.MaxAggregateRiskPct*0.75) { + t.Errorf("aggregate cap = %v, want tightened", got.Risk.MaxAggregateRiskPct) + } + }) + + t.Run("caps may only tighten", func(t *testing.T) { + s := StrategyParams{RiskScale: RiskScale{PerTradeRisk: 2.0, Aggregate: 1.5}} + got := s.Apply(base) + if got.Risk.MaxPortfolioRiskPct != base.Risk.MaxPortfolioRiskPct || + got.Risk.MaxPositionPct != base.Risk.MaxPositionPct || + got.Risk.MaxAggregateRiskPct != base.Risk.MaxAggregateRiskPct { + t.Errorf("loosening multipliers must be ignored: %+v", got.Risk) + } + }) + + t.Run("disabled aggregate cap stays disabled", func(t *testing.T) { + b := base + b.Risk.MaxAggregateRiskPct = -1 + s := StrategyParams{RiskScale: RiskScale{Aggregate: 0.5}} + if got := s.Apply(b); got.Risk.MaxAggregateRiskPct != -1 { + t.Errorf("negative (disabled) aggregate cap must pass through, got %v", got.Risk.MaxAggregateRiskPct) + } + }) +} + +func TestDefaultStrategySet(t *testing.T) { + set := DefaultStrategySet() + if len(set) != 3 { + t.Fatalf("expected 3 strategies, got %d", len(set)) + } + // The order is canonical: it is the last-resort selection tie-break. + wantOrder := []string{"value", "momentum", "defensive"} + seen := map[string]bool{} + for i, s := range set { + if s.Name != wantOrder[i] { + t.Errorf("strategy[%d] = %q, want %q", i, s.Name, wantOrder[i]) + } + if seen[s.Name] { + t.Errorf("duplicate strategy name %q", s.Name) + } + seen[s.Name] = true + if s.RiskScale.PerTradeRisk > 1 || s.RiskScale.Aggregate > 1 { + t.Errorf("%s: cap multipliers must not loosen: %+v", s.Name, s.RiskScale) + } + } +} + +func TestStrategyParamsMerge(t *testing.T) { + base := DefaultStrategySet()[1] // momentum: Prior{0.3,0.7,1.8}, RRR 1.8 + + t.Run("partial override keeps the lens identity", func(t *testing.T) { + got := base.Merge(StrategyParams{RiskScale: RiskScale{Kelly: 0.8}}) + if got.Prior != base.Prior { + t.Errorf("an override that only scales Kelly must keep the prior tilts: %+v", got.Prior) + } + if got.Weights != base.Weights || got.RewardRiskRatio != base.RewardRiskRatio { + t.Errorf("weights/ratio must survive a partial override: %+v", got) + } + if got.RiskScale.Kelly != 0.8 { + t.Errorf("the set field must apply, got %v", got.RiskScale.Kelly) + } + }) + + t.Run("set fields win, name never changes", func(t *testing.T) { + got := base.Merge(StrategyParams{ + Name: "renamed", + Prior: PriorWeights{Valuation: 2, Quality: 1, Momentum: 1}, + RewardRiskRatio: 2.2, + }) + if got.Name != base.Name { + t.Errorf("name is identity and must not be overridable, got %q", got.Name) + } + if got.Prior != (PriorWeights{Valuation: 2, Quality: 1, Momentum: 1}) || got.RewardRiskRatio != 2.2 { + t.Errorf("set fields must override: %+v", got) + } + }) + + t.Run("zero override is identity", func(t *testing.T) { + if got := base.Merge(StrategyParams{}); got != base { + t.Errorf("Merge(zero) = %+v, want base unchanged", got) + } + }) + + t.Run("risk scale merges per field", func(t *testing.T) { + withScale := base + withScale.RiskScale = RiskScale{Kelly: 0.5, PerTradeRisk: 0.75, Aggregate: 0.75} + got := withScale.Merge(StrategyParams{RiskScale: RiskScale{Aggregate: 0.9}}) + if got.RiskScale != (RiskScale{Kelly: 0.5, PerTradeRisk: 0.75, Aggregate: 0.9}) { + t.Errorf("risk scale must merge field-by-field: %+v", got.RiskScale) + } + }) +} diff --git a/internal/finance/strategyfit.go b/internal/finance/strategyfit.go new file mode 100644 index 0000000..0d5bb0a --- /dev/null +++ b/internal/finance/strategyfit.go @@ -0,0 +1,83 @@ +package finance + +import "sort" + +// Outcome-weighted strategy selection, the strategy analogue of calibrate.go: +// recorded outcomes are folded into per-strategy utility multipliers that the +// selector applies when lenses compete. Like the calibration curve, an empty +// result never changes behaviour, shrinkage keeps thin evidence near the +// identity, and the fit is deterministic — same samples, same weights. + +// StrategySample is one decisive recorded outcome attributed to the strategy +// that won the decision, with the market regime in effect at plan time +// (RegimeUnknown when none was recorded). +type StrategySample struct { + Strategy string `json:"strategy" yaml:"strategy"` + Regime Regime `json:"regime" yaml:"regime"` + Success bool `json:"success" yaml:"success"` +} + +const ( + // strategyFitMinSamples: buckets with fewer decisive outcomes are omitted + // (identity weight) — a handful of trades is not evidence. + strategyFitMinSamples = 5 + // strategyFitFloor / strategyFitCeil clamp the fitted multiplier so even a + // perfect or abysmal record only tilts selection, never dictates it. + strategyFitFloor = 0.5 + strategyFitCeil = 1.5 +) + +// FitStrategyWeights fits selection weights from decisive outcomes. For every +// strategy it fits a pooled bucket (key "id") and, where the regime was +// recorded, a per-regime bucket (key "id@regime" — the selector resolves the +// regime-specific key first). Each bucket's weight is a Laplace-shrunk success +// rate mapped so that a 50% record is exactly 1.0 (no-op): +// +// p̂ = (successes + 1) / (n + 2) +// weight = clamp(2·p̂, 0.5, 1.5) +// +// Buckets below the minimum sample count are omitted entirely, which the +// selector reads as weight 1.0. +func FitStrategyWeights(samples []StrategySample) map[string]float64 { + type bucket struct{ n, wins int } + buckets := map[string]*bucket{} + add := func(key string, success bool) { + b, ok := buckets[key] + if !ok { + b = &bucket{} + buckets[key] = b + } + b.n++ + if success { + b.wins++ + } + } + for _, s := range samples { + if s.Strategy == "" { + continue + } + add(s.Strategy, s.Success) + if s.Regime != RegimeUnknown { + add(s.Strategy+"@"+string(s.Regime), s.Success) + } + } + + // Sorted key iteration keeps any float work order-independent and makes + // rendered snippets stable. + keys := make([]string, 0, len(buckets)) + for k := range buckets { + keys = append(keys, k) + } + sort.Strings(keys) + + out := map[string]float64{} + for _, k := range keys { + b := buckets[k] + if b.n < strategyFitMinSamples { + continue + } + p := (float64(b.wins) + 1) / (float64(b.n) + 2) + out[k] = clampRange(2*p, strategyFitFloor, strategyFitCeil) + } + return out +} diff --git a/internal/finance/strategyfit_test.go b/internal/finance/strategyfit_test.go new file mode 100644 index 0000000..f885e9b --- /dev/null +++ b/internal/finance/strategyfit_test.go @@ -0,0 +1,84 @@ +package finance + +import ( + "reflect" + "testing" +) + +func strategySamples(strategy string, regime Regime, wins, losses int) []StrategySample { + var out []StrategySample + for i := 0; i < wins; i++ { + out = append(out, StrategySample{Strategy: strategy, Regime: regime, Success: true}) + } + for i := 0; i < losses; i++ { + out = append(out, StrategySample{Strategy: strategy, Regime: regime, Success: false}) + } + return out +} + +func TestFitStrategyWeights(t *testing.T) { + t.Run("empty samples yield an empty (identity) table", func(t *testing.T) { + if got := FitStrategyWeights(nil); len(got) != 0 { + t.Errorf("expected empty table, got %v", got) + } + }) + + t.Run("thin buckets are omitted", func(t *testing.T) { + got := FitStrategyWeights(strategySamples("momentum", RegimeTrend, 4, 0)) + if len(got) != 0 { + t.Errorf("4 samples must not produce a weight, got %v", got) + } + }) + + t.Run("shrinkage arithmetic at known counts", func(t *testing.T) { + // 5 wins, 0 losses: p̂ = 6/7, weight = 12/7 → clamped to 1.5. + got := FitStrategyWeights(strategySamples("momentum", RegimeUnknown, 5, 0)) + if got["momentum"] != 1.5 { + t.Errorf("perfect record must clamp to 1.5, got %v", got["momentum"]) + } + // 0 wins, 5 losses: p̂ = 1/7, weight = 2/7 → clamped to 0.5. + got = FitStrategyWeights(strategySamples("value", RegimeUnknown, 0, 5)) + if got["value"] != 0.5 { + t.Errorf("losing record must clamp to 0.5, got %v", got["value"]) + } + // 3 wins, 3 losses: p̂ = 4/8 = 0.5 → weight exactly 1.0 (no-op). + got = FitStrategyWeights(strategySamples("defensive", RegimeUnknown, 3, 3)) + if got["defensive"] != 1.0 { + t.Errorf("a 50%% record must be the identity, got %v", got["defensive"]) + } + }) + + t.Run("regime buckets fit alongside the pooled bucket", func(t *testing.T) { + samples := append(strategySamples("momentum", RegimeTrend, 5, 0), + strategySamples("momentum", RegimeHighVol, 0, 5)...) + got := FitStrategyWeights(samples) + if got["momentum@trend"] != 1.5 || got["momentum@high_vol"] != 0.5 { + t.Errorf("regime buckets wrong: %v", got) + } + // Pooled: 5 wins 5 losses → p̂ = 6/12 = 0.5 → 1.0. + if got["momentum"] != 1.0 { + t.Errorf("pooled bucket wrong: %v", got) + } + }) + + t.Run("unknown regime feeds only the pooled bucket", func(t *testing.T) { + got := FitStrategyWeights(strategySamples("value", RegimeUnknown, 5, 0)) + if _, ok := got["value@"]; ok { + t.Error("unknown regime must not create a regime bucket") + } + }) + + t.Run("anonymous samples are skipped", func(t *testing.T) { + if got := FitStrategyWeights(strategySamples("", RegimeTrend, 9, 0)); len(got) != 0 { + t.Errorf("samples without a strategy must be ignored, got %v", got) + } + }) + + t.Run("deterministic", func(t *testing.T) { + samples := append(strategySamples("momentum", RegimeTrend, 7, 2), + strategySamples("value", RegimeRange, 3, 4)...) + if a, b := FitStrategyWeights(samples), FitStrategyWeights(samples); !reflect.DeepEqual(a, b) { + t.Errorf("same samples produced different tables: %v vs %v", a, b) + } + }) +} diff --git a/internal/llm/client.go b/internal/llm/client.go index f739762..de5575f 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -35,6 +35,13 @@ type PlanRequest struct { // it to preserve a move's stable Key when the move is semantically unchanged, so // rewording a title does not read as a new plan. Nil for an initial plan. CurrentMoves []domain.RankedMove + + // CurrentStrategy is the domain strategy that won the current plan version + // (Provenance.SelectedStrategy), the incumbent for the selector's hysteresis. + // Only the SelectorPlanner reads it; strategy children never see it, and it is + // deliberately excluded from plan-cache keys (the selector itself is never + // cached). Empty for an initial plan or when no selector is in play. + CurrentStrategy string } // PlanResult is a planner's output. The engine wraps this into an immutable diff --git a/internal/llm/finance_planner.go b/internal/llm/finance_planner.go index 9df3436..edaaa96 100644 --- a/internal/llm/finance_planner.go +++ b/internal/llm/finance_planner.go @@ -34,6 +34,9 @@ type FinancePlanner struct { packVersion string promptTemplate string calibration *finance.CalibrationCurve + strategyID string + priorWeights finance.PriorWeights + confWeights finance.ScoreWeights } // FinanceConfig configures the finance planner. @@ -50,6 +53,22 @@ type FinanceConfig struct { // Calibration, when non-nil and fitted, maps stated confidence onto observed // outcome frequency (see finance.FitCalibration). Nil/empty is the identity. Calibration *finance.CalibrationCurve + // StrategyID names the strategy lens this planner instance embodies (e.g. + // "value"). It suffixes Name() — and therefore the plan cache key — so two + // strategy variants can never collide on one cache entry. Empty means the + // single blended planner, whose name stays exactly "finance". + StrategyID string + // PriorWeights tilts the win-probability prior's components per the strategy + // lens. The zero value normalizes to the neutral {1,1,1} blend. + PriorWeights finance.PriorWeights + // ConfidenceWeights, when set, is the SHARED composite weighting used to + // state move confidence — the common yardstick across competing strategy + // lenses. A lens still ranks its theses (and buckets impact) with its own + // Scoring.Weights; only the stated confidence uses this blend, so two + // lenses' confidences are comparable: same scale, different evidence (their + // prior tilts and reward:risk genuinely move the sub-scores). Zero means + // "use Scoring.Weights" — the single-planner behaviour, bit-for-bit. + ConfidenceWeights finance.ScoreWeights } // NewFinancePlanner constructs the planner with sane defaults. @@ -64,6 +83,9 @@ func NewFinancePlanner(cfg FinanceConfig) *FinancePlanner { if cfg.PackID == "" { cfg.PackID = "investing" } + if cfg.ConfidenceWeights == (finance.ScoreWeights{}) { + cfg.ConfidenceWeights = cfg.Scoring.Weights + } return &FinancePlanner{ provider: cfg.Provider, scoring: cfg.Scoring, @@ -73,11 +95,21 @@ func NewFinancePlanner(cfg FinanceConfig) *FinancePlanner { packVersion: cfg.PackVersion, promptTemplate: cfg.PromptTemplate, calibration: cfg.Calibration, + strategyID: cfg.StrategyID, + priorWeights: cfg.PriorWeights.Normalize(), + confWeights: cfg.ConfidenceWeights, } } -// Name implements Planner. -func (*FinancePlanner) Name() string { return "finance" } +// Name implements Planner. A strategy variant is named "finance:" so +// the strategy enters provenance and the plan cache key; the plain planner +// stays "finance". +func (p *FinancePlanner) Name() string { + if p.strategyID == "" { + return "finance" + } + return "finance:" + p.strategyID +} const financeDisclaimer = "Educational decision-support only. Not financial advice. Not a recommendation to buy or sell any security." @@ -126,23 +158,43 @@ func (p *FinancePlanner) GeneratePlan(ctx context.Context, req PlanRequest) (Pla } scale, aggregate := finance.ScaleToRiskCap(positions, budget.MaxAggregateRiskPct) - moves := make([]domain.RankedMove, 0, len(theses)) - for _, th := range theses { - moves = append(moves, p.buildMove(th, scale, aggregate, budget.MaxAggregateRiskPct, budgetNote)) + type rankedEntry struct { + move domain.RankedMove + // rankScore is the LENS composite (zero for a broken thesis): the + // strategy's own weighting orders its theses, while the stated + // confidence may sit on the shared cross-lens yardstick. + rankScore float64 } - if len(moves) == 0 { - moves = append(moves, insufficientDataMove(g)) + entries := make([]rankedEntry, 0, len(theses)) + for _, th := range theses { + e := rankedEntry{move: p.buildMove(th, scale, aggregate, budget.MaxAggregateRiskPct, budgetNote)} + if !th.broken { + e.rankScore = th.score.Composite + } + entries = append(entries, e) } - // Rank by confidence descending. Calibration can flatten distinct composites - // onto one calibrated value, so ties break on the raw (pre-calibration) - // confidence — never on incidental input order. - sort.SliceStable(moves, func(i, j int) bool { - if moves[i].Confidence != moves[j].Confidence { - return moves[i].Confidence > moves[j].Confidence + // Rank by the lens composite descending; ties break on the raw stated + // confidence, then the stable move key — never on incidental input order. + // (For the single planner the composite IS the raw confidence, and any + // calibration curve is monotone, so this ordering matches the historical + // sort-by-confidence behaviour.) + sort.SliceStable(entries, func(i, j int) bool { + if entries[i].rankScore != entries[j].rankScore { + return entries[i].rankScore > entries[j].rankScore + } + if entries[i].move.RawConfidence != entries[j].move.RawConfidence { + return entries[i].move.RawConfidence > entries[j].move.RawConfidence } - return moves[i].RawConfidence > moves[j].RawConfidence + return entries[i].move.Key < entries[j].move.Key }) + moves := make([]domain.RankedMove, 0, len(entries)) + for _, e := range entries { + moves = append(moves, e.move) + } + if len(moves) == 0 { + moves = append(moves, insufficientDataMove(g)) + } for i := range moves { moves[i].Rank = i + 1 } @@ -179,7 +231,7 @@ func (p *FinancePlanner) GeneratePlan(ctx context.Context, req PlanRequest) (Pla prov := domain.DecisionProvenance{ ReasoningSummary: reasoning, InputSnapshotID: inputSnapshotID(g, req.SignalNote), - Planner: "finance", + Planner: p.Name(), PromptVersion: financePromptVersion, Model: "none", Strategy: "single", @@ -241,7 +293,7 @@ func (p *FinancePlanner) scoreThesis(ctx context.Context, ticker string, g domai // every candidate). winProb := 0.5 informed := false - if prior, ok := finance.WinProbPrior(funds, returns); ok { + if prior, ok := finance.WinProbPriorWeighted(finance.ComputePriorTilts(funds, returns), p.priorWeights); ok { winProb = prior informed = true } @@ -352,8 +404,15 @@ func (p *FinancePlanner) buildMove(th scoredThesis, scale, aggregate, aggregateC scale, aggregate*100, aggregateCap*100) } - impact, effort, risk := finance.MapToLevels(score) - raw := finance.CompositeToConfidence(score.Composite) + // Confidence and the impact bucket are stated on the shared yardstick + // (confWeights) so competing lenses' claims are comparable — the lens's own + // composite orders its theses but must not inflate what it tells the + // selector. For the single planner the yardstick IS the lens weighting and + // nothing changes. + sharedScore := score + sharedScore.Composite = finance.Composite(score, p.confWeights) + impact, effort, risk := finance.MapToLevels(sharedScore) + raw := finance.CompositeToConfidence(sharedScore.Composite) confidence := raw if p.calibration != nil && !p.calibration.Empty() { confidence = p.calibration.Apply(confidence) diff --git a/internal/llm/selector.go b/internal/llm/selector.go new file mode 100644 index 0000000..ba8aa11 --- /dev/null +++ b/internal/llm/selector.go @@ -0,0 +1,291 @@ +package llm + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/vingrad/dynamic-decision-engine/internal/domain" + "github.com/vingrad/dynamic-decision-engine/internal/strategy" +) + +// SelectorPlanner makes named domain strategies compete: every eligible child +// generates a full candidate plan, the pure selection math in +// internal/strategy scores each candidate against the goal, and the winner's +// plan is returned with the whole competition recorded in provenance. The +// engine is unaware of the difference — like every composite in multi.go, +// this is just another Planner. + +// StrategyChild is one competing strategy: a stable ID, the planner that +// embodies it, and the market regimes it declares itself applicable to (empty +// means always eligible). +type StrategyChild struct { + ID string + Planner Planner + Regimes []string +} + +// RegimeFn classifies the market regime for a goal at plan time ("trend", +// "range", "high_vol"; "" means unknown — and unknown gates nothing). Nil +// disables regime gating entirely. +type RegimeFn func(ctx context.Context, goal domain.Goal) (string, error) + +// defaultIncumbentMargin is the hysteresis a challenger must clear over the +// incumbent strategy. It is deliberately at least the investing pack's +// materiality ConfidenceDelta so a winner flip is never triggered by sub- +// materiality noise. +const defaultIncumbentMargin = 0.05 + +// SelectorConfig assembles a SelectorPlanner. +type SelectorConfig struct { + Children []StrategyChild + // Weights are outcome-fit multipliers ("id" or "id@regime" keys); empty is + // the identity. + Weights map[string]float64 + // Regime, when non-nil, classifies the market regime to gate children and + // stamp provenance. + Regime RegimeFn + // IncumbentMargin overrides the hysteresis margin; zero means the default. + IncumbentMargin float64 + // Inner, when non-nil, narrates the WINNING plan only (hybrid mode) — one + // model call per decision regardless of how many strategies competed. + // Children must be built without their own narrator. + Inner Planner + // PromptTemplate is the pack's domain guidance, handed to the narrator as + // its system prompt override. + PromptTemplate string +} + +// SelectorPlanner implements Planner over a set of competing strategy children. +type SelectorPlanner struct { + children []StrategyChild + weights map[string]float64 + regime RegimeFn + margin float64 + inner Planner + prompt string +} + +// NewSelectorPlanner validates and constructs a selector. It requires at +// least two children (one strategy has nothing to compete with) and unique +// child IDs (the ID is the audit identity). +func NewSelectorPlanner(cfg SelectorConfig) (*SelectorPlanner, error) { + if len(cfg.Children) < 2 { + return nil, fmt.Errorf("llm: selector needs at least 2 strategy children, got %d", len(cfg.Children)) + } + seen := make(map[string]bool, len(cfg.Children)) + for _, c := range cfg.Children { + if c.ID == "" || c.Planner == nil { + return nil, fmt.Errorf("llm: selector child needs an ID and a planner") + } + if seen[c.ID] { + return nil, fmt.Errorf("llm: duplicate selector child ID %q", c.ID) + } + seen[c.ID] = true + } + margin := cfg.IncumbentMargin + if margin == 0 { + margin = defaultIncumbentMargin + } + return &SelectorPlanner{ + children: cfg.Children, + weights: cfg.Weights, + regime: cfg.Regime, + margin: margin, + inner: cfg.Inner, + prompt: cfg.PromptTemplate, + }, nil +} + +// Name implements Planner. +func (*SelectorPlanner) Name() string { return "multi:selector" } + +// GeneratePlan implements Planner. +func (p *SelectorPlanner) GeneratePlan(ctx context.Context, req PlanRequest) (PlanResult, error) { + // Classify the regime first; a failed or unknown classification gates + // nothing (honesty: never filter on a label we couldn't compute). + regime := "" + if p.regime != nil { + if r, err := p.regime(ctx, req.Goal); err == nil { + regime = r + } + } + + eligible := p.eligibleChildren(regime) + gateNote := "" + if len(eligible) == 0 { + // Gating must narrow the field, never empty it. + eligible = p.children + gateNote = fmt.Sprintf("regime %q gated out every strategy; competing all instead", regime) + } + + // Children never see the incumbent: it exists only for selection + // hysteresis, and keeping it out of child requests keeps it out of child + // cache keys. + childReq := req + childReq.CurrentStrategy = "" + + results := make([]PlanResult, len(eligible)) + errs := make([]error, len(eligible)) + var wg sync.WaitGroup + for i, c := range eligible { + wg.Add(1) + go func(i int, c StrategyChild) { + defer wg.Done() + results[i], errs[i] = c.Planner.GeneratePlan(ctx, childReq) + }(i, c) + } + wg.Wait() + + cands := make([]strategy.Candidate, len(eligible)) + var lastErr error + failed := 0 + for i := range eligible { + cands[i] = strategy.Candidate{ + ID: eligible[i].ID, + PlannerName: eligible[i].Planner.Name(), + Moves: results[i].RankedMoves, + Err: errs[i], + } + if errs[i] != nil { + failed++ + lastErr = errs[i] + } + } + if failed == len(eligible) { + return PlanResult{}, fmt.Errorf("llm: all %d strategy children failed: %w", failed, lastErr) + } + + sel, err := strategy.Select(req.Goal, cands, strategy.Options{ + Weights: p.weights, + Regime: regime, + Incumbent: req.CurrentStrategy, + IncumbentMargin: p.margin, + }) + if err != nil { + return PlanResult{}, fmt.Errorf("llm: strategy selection: %w", err) + } + + winner := results[sel.Winner] + winnerID := sel.Scored[sel.Winner].ID + + // Disagreement among admissible strategies is honest uncertainty about the + // decision, quantized to material steps so it can never churn plans on + // sub-threshold noise. The moves are COPIED before the haircut: the winner + // may be a cache hit whose slice shares its backing array with the child's + // plan-cache entry, and an in-place write would corrupt the cached result + // (compounding the penalty on every subsequent hit). + penalty := strategy.DisagreementPenalty(sel.Scored, sel.Scored[sel.Winner].TopMoveKey) + if penalty > 0 { + moves := append([]domain.RankedMove(nil), winner.RankedMoves...) + for i := range moves { + moves[i].Confidence = clampConfidence(moves[i].Confidence - penalty) + } + winner.RankedMoves = moves + } + + contributors := p.buildContributors(eligible, results, errs, sel.Winner) + + // Hybrid narration runs once, on the winner only — the competition never + // multiplies model cost. + if p.inner != nil { + override := req.SystemPromptOverride + if override == "" { + override = p.prompt + } + r, nerr := p.inner.GeneratePlan(ctx, PlanRequest{ + Goal: req.Goal, + SignalNote: narrationNote(req.SignalNote, winner.RankedMoves), + SystemPromptOverride: override, + }) + if nerr == nil && r.Summary != "" { + winner.Provenance.ReasoningSummary = r.Summary + " " + winner.Provenance.ReasoningSummary + contributors = append(contributors, domain.ModelContribution{ + Planner: p.inner.Name(), + Model: r.Invocation.Model, + Role: "narrator", + PromptTokens: r.Invocation.PromptTokens, + CompletionTokens: r.Invocation.CompletionTokens, + }) + } + } + + winner.Provenance.Strategy = "selector" + winner.Provenance.SelectedStrategy = winnerID + winner.Provenance.Regime = regime + winner.Provenance.StrategyCandidates = buildCandidateAudit(sel.Scored, cands) + winner.Provenance.Contributors = contributors + winner.Provenance.Notes = composeSelectorNotes(sel.Reason, gateNote, penalty) + return winner, nil +} + +// eligibleChildren filters children by declared regime applicability. An +// unknown regime ("") and children with no declared regimes always pass. +func (p *SelectorPlanner) eligibleChildren(regime string) []StrategyChild { + if regime == "" { + return p.children + } + var out []StrategyChild + for _, c := range p.children { + if len(c.Regimes) == 0 || containsString(c.Regimes, regime) { + out = append(out, c) + } + } + return out +} + +// buildContributors records every child that produced a plan, marking the +// winner's role distinctly so the competition is auditable end to end. +func (p *SelectorPlanner) buildContributors(eligible []StrategyChild, results []PlanResult, errs []error, winner int) []domain.ModelContribution { + contributors := make([]domain.ModelContribution, 0, len(eligible)) + for i := range eligible { + if errs[i] != nil { + continue + } + role := "strategy-candidate" + if i == winner { + role = "strategy-selected" + } + contributors = append(contributors, contribOf(results[i], role)) + } + return contributors +} + +// buildCandidateAudit maps the selection's scored records into provenance. +func buildCandidateAudit(scored []strategy.Scored, cands []strategy.Candidate) []domain.StrategyCandidate { + out := make([]domain.StrategyCandidate, len(scored)) + for i, s := range scored { + out[i] = domain.StrategyCandidate{ + StrategyID: s.ID, + Planner: cands[i].PlannerName, + UtilityScore: s.Utility, + Weight: s.Weight, + TopMoveKey: s.TopMoveKey, + TopConfidence: s.TopConfidence, + Filtered: s.Filtered, + Reason: s.Reason, + } + } + return out +} + +func composeSelectorNotes(reason, gateNote string, penalty float64) string { + parts := []string{reason} + if gateNote != "" { + parts = append(parts, gateNote) + } + if penalty > 0 { + parts = append(parts, fmt.Sprintf("strategy disagreement: confidence reduced by %.2f", penalty)) + } + return strings.Join(parts, " | ") +} + +func containsString(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} diff --git a/internal/llm/selector_test.go b/internal/llm/selector_test.go new file mode 100644 index 0000000..b2c3690 --- /dev/null +++ b/internal/llm/selector_test.go @@ -0,0 +1,384 @@ +package llm + +import ( + "context" + "errors" + "testing" + + "github.com/vingrad/dynamic-decision-engine/internal/domain" +) + +// strategyMove builds a keyed move so selection and disagreement work on +// stable identities, the way real planners emit them. +func strategyMove(key string, conf float64, impact, effort, risk domain.Level) domain.RankedMove { + return domain.RankedMove{ + Rank: 1, Key: key, Title: key, Confidence: conf, RawConfidence: conf, + ExpectedImpact: impact, Effort: effort, Risk: risk, + } +} + +func selectorGoal() domain.Goal { + return domain.Goal{ID: "g1", Domain: "investing", Objective: "grow the portfolio"} +} + +func TestNewSelectorPlannerValidation(t *testing.T) { + one := []StrategyChild{{ID: "value", Planner: &fakePlanner{name: "finance:value"}}} + if _, err := NewSelectorPlanner(SelectorConfig{Children: one}); err == nil { + t.Error("one child must be rejected") + } + dup := []StrategyChild{ + {ID: "value", Planner: &fakePlanner{name: "a"}}, + {ID: "value", Planner: &fakePlanner{name: "b"}}, + } + if _, err := NewSelectorPlanner(SelectorConfig{Children: dup}); err == nil { + t.Error("duplicate IDs must be rejected") + } +} + +func TestSelectorPicksWinnerAndStampsProvenance(t *testing.T) { + weak := &fakePlanner{name: "finance:value", model: "none", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.4, domain.LevelMedium, domain.LevelLow, domain.LevelMedium), + }} + strong := &fakePlanner{name: "finance:momentum", model: "none", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.8, domain.LevelHigh, domain.LevelLow, domain.LevelMedium), + }} + p, err := NewSelectorPlanner(SelectorConfig{Children: []StrategyChild{ + {ID: "value", Planner: weak}, + {ID: "momentum", Planner: strong}, + }}) + if err != nil { + t.Fatal(err) + } + + res, err := p.GeneratePlan(context.Background(), PlanRequest{Goal: selectorGoal()}) + if err != nil { + t.Fatal(err) + } + if res.Provenance.Strategy != "selector" { + t.Errorf("Strategy = %q, want selector", res.Provenance.Strategy) + } + if res.Provenance.SelectedStrategy != "momentum" { + t.Errorf("SelectedStrategy = %q, want momentum", res.Provenance.SelectedStrategy) + } + if len(res.Provenance.StrategyCandidates) != 2 { + t.Fatalf("expected 2 strategy candidates in provenance, got %d", len(res.Provenance.StrategyCandidates)) + } + for _, c := range res.Provenance.StrategyCandidates { + if c.StrategyID == "" || c.Planner == "" || c.Reason == "" { + t.Errorf("candidate audit incomplete: %+v", c) + } + } + roles := map[string]int{} + for _, c := range res.Provenance.Contributors { + roles[c.Role]++ + } + if roles["strategy-selected"] != 1 || roles["strategy-candidate"] != 1 { + t.Errorf("contributor roles = %v, want one selected + one candidate", roles) + } + // Both children agree on the top move key → no disagreement penalty. + if got := res.RankedMoves[0].Confidence; got != 0.8 { + t.Errorf("agreed competition must not haircut confidence, got %v", got) + } +} + +func TestSelectorDisagreementPenalty(t *testing.T) { + a := &fakePlanner{name: "finance:value", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.8, domain.LevelHigh, domain.LevelLow, domain.LevelMedium), + }} + b := &fakePlanner{name: "finance:momentum", moves: []domain.RankedMove{ + strategyMove("thesis:GLOBEX", 0.4, domain.LevelMedium, domain.LevelLow, domain.LevelMedium), + }} + p, err := NewSelectorPlanner(SelectorConfig{Children: []StrategyChild{ + {ID: "value", Planner: a}, + {ID: "momentum", Planner: b}, + }}) + if err != nil { + t.Fatal(err) + } + res, err := p.GeneratePlan(context.Background(), PlanRequest{Goal: selectorGoal()}) + if err != nil { + t.Fatal(err) + } + // Two admissible candidates, split top keys → agreement 1/2 → penalty 0.05. + if got := res.RankedMoves[0].Confidence; got != 0.75 { + t.Errorf("split competition should haircut 0.05: confidence = %v, want 0.75", got) + } + if res.RankedMoves[0].RawConfidence != 0.8 { + t.Errorf("RawConfidence must stay untouched, got %v", res.RankedMoves[0].RawConfidence) + } +} + +func TestSelectorChildErrorIsFilteredCandidate(t *testing.T) { + bad := &fakePlanner{name: "finance:value", err: errors.New("provider down")} + good := &fakePlanner{name: "finance:momentum", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.6, domain.LevelMedium, domain.LevelLow, domain.LevelMedium), + }} + p, err := NewSelectorPlanner(SelectorConfig{Children: []StrategyChild{ + {ID: "value", Planner: bad}, + {ID: "momentum", Planner: good}, + }}) + if err != nil { + t.Fatal(err) + } + res, err := p.GeneratePlan(context.Background(), PlanRequest{Goal: selectorGoal()}) + if err != nil { + t.Fatal(err) + } + if res.Provenance.SelectedStrategy != "momentum" { + t.Errorf("winner = %q, want momentum", res.Provenance.SelectedStrategy) + } + var errored *domain.StrategyCandidate + for i := range res.Provenance.StrategyCandidates { + if res.Provenance.StrategyCandidates[i].StrategyID == "value" { + errored = &res.Provenance.StrategyCandidates[i] + } + } + if errored == nil || !errored.Filtered { + t.Errorf("errored child must appear as a filtered candidate: %+v", errored) + } +} + +func TestSelectorAllChildrenFailed(t *testing.T) { + p, err := NewSelectorPlanner(SelectorConfig{Children: []StrategyChild{ + {ID: "value", Planner: &fakePlanner{name: "a", err: errors.New("down")}}, + {ID: "momentum", Planner: &fakePlanner{name: "b", err: errors.New("also down")}}, + }}) + if err != nil { + t.Fatal(err) + } + if _, err := p.GeneratePlan(context.Background(), PlanRequest{Goal: selectorGoal()}); err == nil { + t.Error("all children failing must surface an error") + } +} + +func TestSelectorRegimeGating(t *testing.T) { + trendOnly := &fakePlanner{name: "finance:momentum", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.9, domain.LevelHigh, domain.LevelLow, domain.LevelMedium), + }} + always := &fakePlanner{name: "finance:defensive", moves: []domain.RankedMove{ + strategyMove("thesis:GLOBEX", 0.5, domain.LevelMedium, domain.LevelLow, domain.LevelLow), + }} + mk := func(regime string) *SelectorPlanner { + p, err := NewSelectorPlanner(SelectorConfig{ + Children: []StrategyChild{ + {ID: "momentum", Planner: trendOnly, Regimes: []string{"trend"}}, + {ID: "defensive", Planner: always}, + }, + Regime: func(context.Context, domain.Goal) (string, error) { return regime, nil }, + }) + if err != nil { + t.Fatal(err) + } + return p + } + + // In a high_vol regime momentum is gated out: only defensive competes. + res, err := mk("high_vol").GeneratePlan(context.Background(), PlanRequest{Goal: selectorGoal()}) + if err != nil { + t.Fatal(err) + } + if res.Provenance.SelectedStrategy != "defensive" || len(res.Provenance.StrategyCandidates) != 1 { + t.Errorf("high_vol should gate momentum out: winner %q, %d candidates", + res.Provenance.SelectedStrategy, len(res.Provenance.StrategyCandidates)) + } + if res.Provenance.Regime != "high_vol" { + t.Errorf("Regime = %q, want high_vol", res.Provenance.Regime) + } + + // Unknown regime gates nothing. + res, err = mk("").GeneratePlan(context.Background(), PlanRequest{Goal: selectorGoal()}) + if err != nil { + t.Fatal(err) + } + if len(res.Provenance.StrategyCandidates) != 2 { + t.Errorf("unknown regime must not gate, got %d candidates", len(res.Provenance.StrategyCandidates)) + } +} + +func TestSelectorRegimeGateNeverEmptiesField(t *testing.T) { + a := &fakePlanner{name: "finance:value", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.6, domain.LevelMedium, domain.LevelLow, domain.LevelMedium), + }} + b := &fakePlanner{name: "finance:momentum", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.7, domain.LevelMedium, domain.LevelLow, domain.LevelMedium), + }} + p, err := NewSelectorPlanner(SelectorConfig{ + Children: []StrategyChild{ + {ID: "value", Planner: a, Regimes: []string{"range"}}, + {ID: "momentum", Planner: b, Regimes: []string{"trend"}}, + }, + Regime: func(context.Context, domain.Goal) (string, error) { return "high_vol", nil }, + }) + if err != nil { + t.Fatal(err) + } + res, err := p.GeneratePlan(context.Background(), PlanRequest{Goal: selectorGoal()}) + if err != nil { + t.Fatal(err) + } + if len(res.Provenance.StrategyCandidates) != 2 { + t.Errorf("a gate that empties the field must fall back to all children, got %d", len(res.Provenance.StrategyCandidates)) + } +} + +func TestSelectorHysteresisHoldsIncumbent(t *testing.T) { + inc := &fakePlanner{name: "finance:value", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.60, domain.LevelMedium, domain.LevelLow, domain.LevelMedium), + }} + chal := &fakePlanner{name: "finance:momentum", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.64, domain.LevelMedium, domain.LevelLow, domain.LevelMedium), + }} + p, err := NewSelectorPlanner(SelectorConfig{Children: []StrategyChild{ + {ID: "value", Planner: inc}, + {ID: "momentum", Planner: chal}, + }}) + if err != nil { + t.Fatal(err) + } + + // On a replan with the incumbent set, a marginally better challenger holds. + res, err := p.GeneratePlan(context.Background(), PlanRequest{Goal: selectorGoal(), CurrentStrategy: "value"}) + if err != nil { + t.Fatal(err) + } + if res.Provenance.SelectedStrategy != "value" { + t.Errorf("incumbent should hold within the default margin, got %q", res.Provenance.SelectedStrategy) + } + + // Initial plan (no incumbent): the better candidate wins outright. + res, err = p.GeneratePlan(context.Background(), PlanRequest{Goal: selectorGoal()}) + if err != nil { + t.Fatal(err) + } + if res.Provenance.SelectedStrategy != "momentum" { + t.Errorf("without an incumbent the better candidate wins, got %q", res.Provenance.SelectedStrategy) + } +} + +// capturePlanner captures the requests it receives. +type capturePlanner struct { + fakePlanner + gotStrategy []string + gotOverride []string +} + +func (r *capturePlanner) GeneratePlan(ctx context.Context, req PlanRequest) (PlanResult, error) { + r.gotStrategy = append(r.gotStrategy, req.CurrentStrategy) + r.gotOverride = append(r.gotOverride, req.SystemPromptOverride) + return r.fakePlanner.GeneratePlan(ctx, req) +} + +func TestSelectorChildrenNeverSeeIncumbentAndNarratorRunsOnce(t *testing.T) { + child1 := &capturePlanner{fakePlanner: fakePlanner{name: "finance:value", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.6, domain.LevelMedium, domain.LevelLow, domain.LevelMedium), + }}} + child2 := &capturePlanner{fakePlanner: fakePlanner{name: "finance:momentum", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.7, domain.LevelMedium, domain.LevelLow, domain.LevelMedium), + }}} + narrator := &capturePlanner{fakePlanner: fakePlanner{name: "mock", model: "mock-1", moves: nil}} + + p, err := NewSelectorPlanner(SelectorConfig{ + Children: []StrategyChild{ + {ID: "value", Planner: child1}, + {ID: "momentum", Planner: child2}, + }, + Inner: narrator, + PromptTemplate: "DOMAIN: INVESTING", + }) + if err != nil { + t.Fatal(err) + } + res, err := p.GeneratePlan(context.Background(), PlanRequest{Goal: selectorGoal(), CurrentStrategy: "value"}) + if err != nil { + t.Fatal(err) + } + + for _, got := range append(child1.gotStrategy, child2.gotStrategy...) { + if got != "" { + t.Errorf("children must never see the incumbent, got %q", got) + } + } + if n := len(narrator.gotStrategy); n != 1 { + t.Fatalf("narrator must run exactly once, ran %d times", n) + } + if narrator.gotOverride[0] != "DOMAIN: INVESTING" { + t.Errorf("narrator must receive the pack template, got %q", narrator.gotOverride[0]) + } + var narrated int + for _, c := range res.Provenance.Contributors { + if c.Role == "narrator" { + narrated++ + } + } + if narrated != 1 { + t.Errorf("expected one narrator contribution, got %d", narrated) + } +} + +func TestSelectorDeterministicAcrossRuns(t *testing.T) { + mk := func() *SelectorPlanner { + p, err := NewSelectorPlanner(SelectorConfig{Children: []StrategyChild{ + {ID: "value", Planner: &fakePlanner{name: "finance:value", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.6, domain.LevelMedium, domain.LevelLow, domain.LevelMedium), + }}}, + {ID: "momentum", Planner: &fakePlanner{name: "finance:momentum", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.6, domain.LevelMedium, domain.LevelLow, domain.LevelMedium), + }}}, + }}) + if err != nil { + t.Fatal(err) + } + return p + } + // Identical candidates tie all the way down; canonical order must decide, + // regardless of goroutine completion order, on every run. + for i := 0; i < 50; i++ { + res, err := mk().GeneratePlan(context.Background(), PlanRequest{Goal: selectorGoal()}) + if err != nil { + t.Fatal(err) + } + if res.Provenance.SelectedStrategy != "value" { + t.Fatalf("run %d: winner %q, want canonical-order value", i, res.Provenance.SelectedStrategy) + } + } +} + +// TestSelectorPenaltyDoesNotCorruptChildCache: the disagreement haircut must +// never write into a cached child result. With cached children and a split +// competition, repeated identical requests must ship the SAME confidence — +// an in-place penalty would compound through the shared backing array on +// every cache hit. +func TestSelectorPenaltyDoesNotCorruptChildCache(t *testing.T) { + a := &fakePlanner{name: "finance:value", moves: []domain.RankedMove{ + strategyMove("thesis:ACME", 0.8, domain.LevelHigh, domain.LevelLow, domain.LevelMedium), + }} + b := &fakePlanner{name: "finance:momentum", moves: []domain.RankedMove{ + strategyMove("thesis:GLOBEX", 0.4, domain.LevelMedium, domain.LevelLow, domain.LevelMedium), + }} + cache := NewMemoryCache(16) + p, err := NewSelectorPlanner(SelectorConfig{Children: []StrategyChild{ + {ID: "value", Planner: NewCachingPlanner(a, cache, nil)}, + {ID: "momentum", Planner: NewCachingPlanner(b, cache, nil)}, + }}) + if err != nil { + t.Fatal(err) + } + + req := PlanRequest{Goal: selectorGoal()} + for i := 0; i < 3; i++ { + res, err := p.GeneratePlan(context.Background(), req) + if err != nil { + t.Fatal(err) + } + // Split top keys → penalty 0.05 → 0.75, on EVERY request identically. + if got := res.RankedMoves[0].Confidence; got != 0.75 { + t.Fatalf("request %d: confidence = %v, want 0.75 (penalty must not compound through the cache)", i+1, got) + } + } + // The cached child entry itself must be untouched. + if cached, ok := cache.Get(cacheKey("finance:value", req)); !ok { + t.Fatal("expected a cached child entry") + } else if got := cached.RankedMoves[0].Confidence; got != 0.8 { + t.Errorf("cached child confidence = %v, want pristine 0.8", got) + } +} diff --git a/internal/pack/investing.go b/internal/pack/investing.go index c945081..c9dfef5 100644 --- a/internal/pack/investing.go +++ b/internal/pack/investing.go @@ -26,9 +26,34 @@ stated horizon. Do not inflate it; uncertainty is normal and expected. You MUST include verbatim in the plan summary: "Educational decision-support only. Not financial advice. Not a recommendation to buy or sell any security."` +// investingStrategies maps finance.DefaultStrategySet onto pack descriptors, +// attaching each lens's regime applicability: value works in trends and +// ranges, momentum needs a trend, and defensive declares no regimes so the +// candidate set can never be emptied by gating. +func investingStrategies() []StrategyDescriptor { + regimes := map[string][]string{ + "value": {"trend", "range"}, + "momentum": {"trend"}, + // defensive: applicable everywhere + } + set := finance.DefaultStrategySet() + out := make([]StrategyDescriptor, len(set)) + for i := range set { + params := set[i] + out[i] = StrategyDescriptor{ + ID: params.Name, + Name: params.Name, + Regimes: regimes[params.Name], + Scoring: ¶ms, + } + } + return out +} + // investingPack adds thesis-oriented prompting, a tighter materiality threshold -// (calibration matters more for investing), and default scoring tunables consumed -// by the optional numeric finance planner. +// (calibration matters more for investing), default scoring tunables consumed +// by the optional numeric finance planner, and the named strategy lenses that +// compete under the selector (opt-in via policy until backtest gates pass). func investingPack() Descriptor { scoring := finance.DefaultScoringConfig() return Descriptor{ @@ -40,6 +65,7 @@ func investingPack() Descriptor { PlannerKind: "finance", Eval: EvaluatorConfig{ConfidenceDelta: 0.05}, Scoring: &scoring, + Strategies: investingStrategies(), Vocab: Vocabulary{ AssetKinds: []string{"capital", "edge", "information", "conviction", "liquidity", "time_horizon"}, ConstraintKinds: []string{"risk_tolerance", "liquidity", "time_horizon", "drawdown_limit", "mandate", "tax"}, diff --git a/internal/pack/pack.go b/internal/pack/pack.go index 6453b93..5efba3e 100644 --- a/internal/pack/pack.go +++ b/internal/pack/pack.go @@ -97,12 +97,37 @@ type Descriptor struct { Scoring any `json:"-" yaml:"-"` Vocab Vocabulary `json:"vocab" yaml:"vocab"` + // Strategies declares the named strategy variants that compete for this + // domain. The wiring layer builds one child planner per strategy (via the + // domain's PlannerKind) and composes them under a selector. Empty — every + // pre-existing pack — means exactly the single-planner behaviour. The slice + // order is canonical: it is the selection's last-resort tie-break. + Strategies []StrategyDescriptor `json:"strategies,omitempty" yaml:"strategies,omitempty"` + // Validation is the declarative validation policy for the domain: a set of // shape rules plus an optional vocabulary check. It is pure data so a domain // (including one loaded from config) needs no code. Evaluate it via Validate. Validation Validation `json:"validation" yaml:"validation"` } +// StrategyDescriptor names one competing strategy variant for a domain. Like +// the Descriptor it is pure data: what the strategy means numerically rides on +// the opaque Scoring field (e.g. *finance.StrategyParams) and is interpreted +// by the wiring layer's planner builder. +type StrategyDescriptor struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + // Regimes lists the market regimes this strategy declares itself applicable + // to (e.g. "trend", "high_vol"). Empty means all regimes — at least one + // strategy per domain should leave this empty so regime gating can never + // empty the candidate set. + Regimes []string `json:"regimes,omitempty" yaml:"regimes,omitempty"` + // Scoring is the opaque per-strategy tuning consumed by the domain's planner + // builder. Like Descriptor.Scoring it is not config-loadable; tune via the + // policy file instead. + Scoring any `json:"-" yaml:"-"` +} + // KindScope names where a kind-based rule looks for a kind. type KindScope string diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 1a3f00f..4430a65 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -29,6 +29,29 @@ type PlannerSpec struct { Model string `json:"model,omitempty" yaml:"model"` // overrides the global LLM model id } +// StrategySelection tunes a domain's multi-strategy selector. All fields are +// optional; an absent block leaves the pack default (selection disabled until +// the domain's backtest gates flip it on). +type StrategySelection struct { + // Enabled turns the strategy competition on or off for the domain. Nil + // leaves the wiring default (off). + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + // Disable drops named strategies from the competition (e.g. to retire a + // lens without editing the pack). Unknown IDs are ignored with a warning at + // wire time so a policy file survives pack evolution. + Disable []string `json:"disable,omitempty" yaml:"disable,omitempty"` + // Weights are outcome-fit utility multipliers keyed by strategy ID, with an + // optional regime-specific "id@regime" entry that wins over the plain key. + // Fit offline by `dde strategy-fit`; absent keys mean 1.0. + Weights map[string]float64 `json:"weights,omitempty" yaml:"weights,omitempty"` + // IncumbentMargin overrides the selection hysteresis (how much a challenger + // must beat the incumbent strategy by). Nil leaves the default. + IncumbentMargin *float64 `json:"incumbent_margin,omitempty" yaml:"incumbent_margin,omitempty"` + // Params overrides a named strategy's numeric tuning (prior tilts, weights, + // risk scale), keyed by strategy ID. + Params map[string]*finance.StrategyParams `json:"params,omitempty" yaml:"params,omitempty"` +} + // DomainPolicy overrides a single domain's tunables. Pointer/optional fields mean // "leave the pack default" when absent. type DomainPolicy struct { @@ -48,6 +71,8 @@ type DomainPolicy struct { // observed outcome frequencies. Fit offline by `dde calibrate`; an empty curve // is the identity. Calibration *finance.CalibrationCurve `json:"calibration,omitempty" yaml:"calibration,omitempty"` + // Strategy, when non-nil, tunes the domain's multi-strategy selector. + Strategy *StrategySelection `json:"strategy,omitempty" yaml:"strategy,omitempty"` } // Policy is the full set of per-domain overrides, keyed by domain id. @@ -101,13 +126,48 @@ var singleBackends = map[string]bool{ // silently degrading (a verify spec whose verifier can't verify). func (p Policy) Validate() error { for id, dp := range p.Domains { - if dp.Planner == nil { - continue + if dp.Planner != nil { + if err := dp.Planner.validate(); err != nil { + return fmt.Errorf("domain %q planner: %w", id, err) + } + } + if dp.Strategy != nil { + if err := dp.Strategy.validate(); err != nil { + return fmt.Errorf("domain %q strategy: %w", id, err) + } + } + } + return nil +} + +// validate rejects strategy-selection overrides that would corrupt selection +// rather than tune it: a non-positive weight inverts or zeroes utilities, and +// empty keys can never match a strategy. +func (s StrategySelection) validate() error { + for key, w := range s.Weights { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("weights: empty strategy key") + } + if w <= 0 { + return fmt.Errorf("weights[%q]: must be > 0, got %v", key, w) } - if err := dp.Planner.validate(); err != nil { - return fmt.Errorf("domain %q planner: %w", id, err) + } + for _, id := range s.Disable { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("disable: empty strategy id") } } + for id, params := range s.Params { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("params: empty strategy id") + } + if params == nil { + return fmt.Errorf("params[%q]: null override", id) + } + } + if s.IncumbentMargin != nil && *s.IncumbentMargin < 0 { + return fmt.Errorf("incumbent_margin: must be >= 0, got %v", *s.IncumbentMargin) + } return nil } diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index 343a6a1..8c3fdb5 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/vingrad/dynamic-decision-engine/internal/finance" ) func TestLoadEmptyPath(t *testing.T) { @@ -112,3 +114,31 @@ func TestLoadRejectsBadPlanner(t *testing.T) { t.Error("Load should fail on an invalid planner spec") } } + +func TestStrategySelectionValidate(t *testing.T) { + on := true + neg := -0.1 + cases := []struct { + name string + sel StrategySelection + ok bool + }{ + {"empty block", StrategySelection{}, true}, + {"enabled with weights", StrategySelection{Enabled: &on, Weights: map[string]float64{"momentum": 1.2, "value@trend": 0.8}}, true}, + {"zero weight", StrategySelection{Weights: map[string]float64{"momentum": 0}}, false}, + {"negative weight", StrategySelection{Weights: map[string]float64{"momentum": -1}}, false}, + {"empty weight key", StrategySelection{Weights: map[string]float64{" ": 1}}, false}, + {"empty disable id", StrategySelection{Disable: []string{""}}, false}, + {"null params", StrategySelection{Params: map[string]*finance.StrategyParams{"value": nil}}, false}, + {"negative margin", StrategySelection{IncumbentMargin: &neg}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := Policy{Domains: map[string]DomainPolicy{"investing": {Strategy: &tc.sel}}} + err := p.Validate() + if (err == nil) != tc.ok { + t.Errorf("Validate() error = %v, want ok=%v", err, tc.ok) + } + }) + } +} diff --git a/internal/strategy/select.go b/internal/strategy/select.go new file mode 100644 index 0000000..4d66885 --- /dev/null +++ b/internal/strategy/select.go @@ -0,0 +1,281 @@ +package strategy + +import ( + "fmt" + "strings" + + "github.com/vingrad/dynamic-decision-engine/internal/domain" +) + +// Candidate is one strategy's proposed plan, as handed to Select. A non-nil +// Err marks a strategy whose planner failed; it stays in the audit trail as a +// filtered candidate but can never win. +type Candidate struct { + ID string + PlannerName string + Moves []domain.RankedMove + Err error +} + +// Scored is the audit record for one candidate: its utility, the outcome +// weight applied, whether the hard filter removed it and why. The slice +// returned by Select is parallel to the input candidates so provenance can +// show every competitor, not just the winner. +type Scored struct { + ID string + Utility float64 // pre-weight, rounded to 4 decimals + Weight float64 // outcome weight applied (1.0 when none) + Weighted float64 // Utility × Weight, rounded to 4 decimals + TopMoveKey string + TopConfidence float64 + Filtered bool + Reason string // filter reason, or the utility explain string +} + +// Options tunes a selection. +type Options struct { + // Weights are outcome-fit multipliers keyed by strategy ID, with an optional + // regime-specific entry "id@regime" that wins over the plain "id" key. + // Missing keys mean 1.0 — an empty map never changes behaviour. + Weights map[string]float64 + // Regime is the market regime label used for weight lookup ("" = unknown). + Regime string + // Incumbent is the strategy that won the current plan version ("" = none). + // When it survives the filter, a challenger must beat its weighted utility + // by more than IncumbentMargin — hysteresis against materiality flapping. + Incumbent string + // IncumbentMargin is the strict margin a challenger must clear. Callers set + // it explicitly; zero means no hysteresis. + IncumbentMargin float64 +} + +// Selection is the outcome of one competition. +type Selection struct { + // Winner indexes the winning candidate in the input slice. + Winner int + // Scored is parallel to the input candidates. + Scored []Scored + // Reason is a short, audit-friendly account of why the winner won. + Reason string +} + +// Select filters, scores and picks one candidate. The input order is the +// canonical strategy declaration order and serves as the last-resort +// tie-break, so callers must pass candidates in a fixed order — never one +// derived from map iteration. It errors only on an empty candidate list: when +// every candidate is filtered it degrades to utility argmax over all of them +// (noting the degraded mode in Reason) rather than returning nothing. +func Select(goal domain.Goal, cands []Candidate, opts Options) (Selection, error) { + if len(cands) == 0 { + return Selection{}, fmt.Errorf("strategy: no candidates to select from") + } + + scored := make([]Scored, len(cands)) + for i, c := range cands { + s := Scored{ID: c.ID, Weight: weightFor(opts, c.ID)} + if c.Err != nil { + s.Filtered = true + s.Reason = "planner error: " + c.Err.Error() + } else { + // Utility is computed even for filtered candidates: it feeds the + // all-filtered fallback and makes the audit trail comparable. + u, explain := Utility(goal, c.Moves) + s.Utility = u + s.Weighted = applyWeight(u, s.Weight) + s.TopMoveKey = topKey(c.Moves) + if len(c.Moves) > 0 { + s.TopConfidence = c.Moves[0].Confidence + } + if reason, filtered := HardFilter(goal, c.Moves); filtered { + s.Filtered = true + s.Reason = reason + } else { + s.Reason = explain + } + } + scored[i] = s + } + + winner := pick(scored, cands, false) + degraded := winner < 0 + if degraded { + // Every candidate was filtered. The least-bad plan still beats no plan: + // pick by the same chain ignoring filters. Candidates whose planner + // errored stay excluded — they have no plan to return — unless nothing + // else exists at all. + winner = pick(scored, cands, true) + } + + reason := fmt.Sprintf("selected %q: weighted utility %.4f", scored[winner].ID, scored[winner].Weighted) + if degraded { + reason = fmt.Sprintf("no admissible candidate; degraded to best inadmissible: %q (%s)", + scored[winner].ID, scored[winner].Reason) + } else if inc := incumbentIndex(scored, opts.Incumbent); inc >= 0 && inc != winner { + // Hysteresis: the incumbent holds unless the challenger clears the margin. + challenger := winner + if scored[challenger].Weighted <= scored[inc].Weighted+opts.IncumbentMargin { + winner = inc + reason = fmt.Sprintf("incumbent %q held: challenger %q margin %.4f within hysteresis %.4f", + scored[inc].ID, scored[challenger].ID, scored[challenger].Weighted-scored[inc].Weighted, opts.IncumbentMargin) + } else { + reason = fmt.Sprintf("selected %q: weighted utility %.4f beats incumbent %q by more than %.4f", + scored[winner].ID, scored[winner].Weighted, scored[inc].ID, opts.IncumbentMargin) + } + } + if rejected := filteredIDs(scored); len(rejected) > 0 && !degraded { + reason += "; filtered: " + strings.Join(rejected, ", ") + } + + return Selection{Winner: winner, Scored: scored, Reason: reason}, nil +} + +// applyWeight scales a utility by an outcome weight so that a weight above 1 +// always FAVOURS the strategy and below 1 always penalises it, regardless of +// the utility's sign — a plain product would invert the meaning on negative +// utilities (a 1.3 weight making a −0.05 plan read worse, not better). +func applyWeight(u, w float64) float64 { + if u < 0 { + return round4(u / w) + } + return round4(u * w) +} + +// weightFor resolves the outcome weight for a strategy: the regime-specific +// "id@regime" entry wins over the plain "id" entry; absent keys mean 1.0. +func weightFor(opts Options, id string) float64 { + if opts.Regime != "" { + if w, ok := opts.Weights[id+"@"+opts.Regime]; ok && w > 0 { + return w + } + } + if w, ok := opts.Weights[id]; ok && w > 0 { + return w + } + return 1.0 +} + +// pick returns the index of the best candidate by the deterministic chain: +// higher weighted utility → higher top-move raw confidence → earlier canonical +// (input) order. With includeFiltered=false it considers only admissible +// candidates and returns -1 when none exist. Candidates with a planner error +// have no plan to return, so even the degraded pass skips them; only when +// every candidate errored does it fall back to the first. +func pick(scored []Scored, cands []Candidate, includeFiltered bool) int { + best := -1 + for i := range scored { + if cands[i].Err != nil { + continue + } + if !includeFiltered && scored[i].Filtered { + continue + } + if best < 0 || better(scored, cands, i, best) { + best = i + } + } + if best < 0 && includeFiltered { + return 0 + } + return best +} + +// better reports whether candidate i strictly beats candidate j. Equal on +// every key falls through to false, keeping the earlier (canonical-order) +// candidate — the last-resort tie-break. +func better(scored []Scored, cands []Candidate, i, j int) bool { + if scored[i].Weighted != scored[j].Weighted { + return scored[i].Weighted > scored[j].Weighted + } + if ri, rj := topRawConfidence(cands[i].Moves), topRawConfidence(cands[j].Moves); ri != rj { + return ri > rj + } + return false +} + +// topRawConfidence reads the top move's pre-calibration confidence, falling +// back to the stated confidence for plans that predate RawConfidence. +func topRawConfidence(moves []domain.RankedMove) float64 { + if len(moves) == 0 { + return 0 + } + if moves[0].RawConfidence > 0 { + return moves[0].RawConfidence + } + return moves[0].Confidence +} + +// incumbentIndex finds the incumbent among admissible candidates; -1 when the +// incumbent is unset, unknown, or was filtered out (a filtered incumbent holds +// nothing). +func incumbentIndex(scored []Scored, incumbent string) int { + if incumbent == "" { + return -1 + } + for i := range scored { + if scored[i].ID == incumbent && !scored[i].Filtered { + return i + } + } + return -1 +} + +func filteredIDs(scored []Scored) []string { + var out []string + for _, s := range scored { + if s.Filtered { + out = append(out, fmt.Sprintf("%s (%s)", s.ID, s.Reason)) + } + } + return out +} + +// ReWeigh picks the winning index among RECORDED candidates under a new set +// of outcome weights — the offline evaluation primitive behind the strategy +// walk-forward: it re-runs the weight mapping over a past competition without +// re-running any planner. Filtered candidates stay out; ties break on the +// candidate's recorded top confidence, then on recorded (canonical) order. +// Returns -1 when no admissible candidate was recorded. +func ReWeigh(cands []domain.StrategyCandidate, weights map[string]float64, regime string) int { + opts := Options{Weights: weights, Regime: regime} + best := -1 + var bestW float64 + for i, c := range cands { + if c.Filtered { + continue + } + w := applyWeight(c.UtilityScore, weightFor(opts, c.StrategyID)) + switch { + case best < 0, w > bestW: + best, bestW = i, w + case w == bestW && c.TopConfidence > cands[best].TopConfidence: + best = i + } + } + return best +} + +// DisagreementPenalty converts strategy disagreement into a confidence +// haircut, quantized to steps of 0.05 — the investing pack's materiality +// ConfidenceDelta — so disagreement either leaves stated confidence alone or +// moves it by a deliberately material step; sub-threshold churn from a +// flickering minority opinion is impossible by construction. Agreement is the +// share of admissible candidates whose top move matches the winner's. +func DisagreementPenalty(scored []Scored, winnerTopKey string) float64 { + admissible, agree := 0, 0 + for _, s := range scored { + if s.Filtered { + continue + } + admissible++ + if s.TopMoveKey == winnerTopKey { + agree++ + } + } + if admissible < 2 || agree == admissible { + return 0 + } + if float64(agree)/float64(admissible) >= 0.5 { + return 0.05 + } + return 0.10 +} diff --git a/internal/strategy/strategy_test.go b/internal/strategy/strategy_test.go new file mode 100644 index 0000000..312f44d --- /dev/null +++ b/internal/strategy/strategy_test.go @@ -0,0 +1,300 @@ +package strategy + +import ( + "errors" + "math" + "testing" + + "github.com/vingrad/dynamic-decision-engine/internal/domain" +) + +func goalWith(constraints ...domain.Constraint) domain.Goal { + return domain.Goal{ + ID: "goal-1", + Objective: "grow the portfolio", + Context: domain.Context{Constraints: constraints}, + } +} + +func move(key string, conf float64, impact, effort, risk domain.Level) domain.RankedMove { + return domain.RankedMove{ + Key: key, Title: key, Confidence: conf, RawConfidence: conf, + ExpectedImpact: impact, Effort: effort, Risk: risk, + } +} + +func approx(a, b float64) bool { return math.Abs(a-b) < 1e-9 } + +func TestUtilityHandComputed(t *testing.T) { + g := goalWith() // default risk aversion 0.6 + + // Single move: v = 0.8*0.9 − 0.6*0.5 − 0.25*0.2 = 0.72 − 0.30 − 0.05 = 0.37. + u, _ := Utility(g, []domain.RankedMove{ + move("a", 0.8, domain.LevelHigh, domain.LevelLow, domain.LevelMedium), + }) + if !approx(u, 0.37) { + t.Errorf("single-move utility = %v, want 0.37", u) + } + + // Two moves: decay 1, 0.7 normalized. + // v1 = 0.37 (above); v2 = 0.5*0.5 − 0.6*0.2 − 0.25*0.5 = 0.25 − 0.12 − 0.125 = 0.005 + // U = (1*0.37 + 0.7*0.005) / 1.7 = 0.3735/1.7 = 0.2197...→ round4 0.2197 + u, _ = Utility(g, []domain.RankedMove{ + move("a", 0.8, domain.LevelHigh, domain.LevelLow, domain.LevelMedium), + move("b", 0.5, domain.LevelMedium, domain.LevelMedium, domain.LevelLow), + }) + if !approx(u, 0.2197) { + t.Errorf("two-move utility = %v, want 0.2197", u) + } +} + +func TestUtilityRiskAversion(t *testing.T) { + moves := []domain.RankedMove{ + move("a", 0.8, domain.LevelHigh, domain.LevelLow, domain.LevelHigh), + } + uCons, _ := Utility(goalWith(domain.Constraint{Kind: "risk_tolerance", Name: "conservative"}), moves) + uDef, _ := Utility(goalWith(), moves) + uAggr, _ := Utility(goalWith(domain.Constraint{Kind: "risk_tolerance", Name: "aggressive"}), moves) + if !(uCons < uDef && uDef < uAggr) { + t.Errorf("risk aversion ordering broken: conservative %v, default %v, aggressive %v", uCons, uDef, uAggr) + } +} + +func TestHardFilter(t *testing.T) { + cases := []struct { + name string + goal domain.Goal + moves []domain.RankedMove + filtered bool + }{ + {"no moves", goalWith(), nil, true}, + {"all zero confidence", goalWith(), []domain.RankedMove{ + move("a", 0, domain.LevelLow, domain.LevelLow, domain.LevelHigh), + }, true}, + {"viable default goal", goalWith(), []domain.RankedMove{ + move("a", 0.6, domain.LevelHigh, domain.LevelLow, domain.LevelHigh), + }, false}, + {"conservative tolerance rejects high-risk top", goalWith(domain.Constraint{Kind: "risk_tolerance", Name: "conservative"}), []domain.RankedMove{ + move("a", 0.6, domain.LevelHigh, domain.LevelLow, domain.LevelHigh), + }, true}, + {"tight drawdown limit rejects high-risk top", goalWith(domain.Constraint{Kind: "drawdown_limit", Name: "10%"}), []domain.RankedMove{ + move("a", 0.6, domain.LevelHigh, domain.LevelLow, domain.LevelHigh), + }, true}, + {"loose drawdown limit allows high-risk top", goalWith(domain.Constraint{Kind: "drawdown_limit", Name: "25%"}), []domain.RankedMove{ + move("a", 0.6, domain.LevelHigh, domain.LevelLow, domain.LevelHigh), + }, false}, + {"conservative tolerance keeps medium-risk top", goalWith(domain.Constraint{Kind: "risk_tolerance", Name: "conservative"}), []domain.RankedMove{ + move("a", 0.6, domain.LevelHigh, domain.LevelLow, domain.LevelMedium), + }, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + reason, filtered := HardFilter(tc.goal, tc.moves) + if filtered != tc.filtered { + t.Errorf("filtered = %v (%q), want %v", filtered, reason, tc.filtered) + } + if filtered && reason == "" { + t.Error("filtered candidates must carry a reason") + } + }) + } +} + +func TestSelectPicksHighestWeightedUtility(t *testing.T) { + g := goalWith() + cands := []Candidate{ + {ID: "value", Moves: []domain.RankedMove{move("thesis:ACME", 0.5, domain.LevelMedium, domain.LevelLow, domain.LevelMedium)}}, + {ID: "momentum", Moves: []domain.RankedMove{move("thesis:ACME", 0.8, domain.LevelHigh, domain.LevelLow, domain.LevelMedium)}}, + } + sel, err := Select(g, cands, Options{}) + if err != nil { + t.Fatal(err) + } + if sel.Scored[sel.Winner].ID != "momentum" { + t.Errorf("winner = %q, want momentum", sel.Scored[sel.Winner].ID) + } + if len(sel.Scored) != 2 { + t.Fatalf("scored must be parallel to candidates, got %d", len(sel.Scored)) + } + for _, s := range sel.Scored { + if s.Reason == "" { + t.Errorf("candidate %q has no audit reason", s.ID) + } + } +} + +func TestSelectWeights(t *testing.T) { + g := goalWith() + same := []domain.RankedMove{move("thesis:ACME", 0.6, domain.LevelMedium, domain.LevelLow, domain.LevelMedium)} + cands := []Candidate{ + {ID: "value", Moves: same}, + {ID: "momentum", Moves: same}, + } + + // Plain weight flips the winner. + sel, err := Select(g, cands, Options{Weights: map[string]float64{"momentum": 1.3}}) + if err != nil { + t.Fatal(err) + } + if sel.Scored[sel.Winner].ID != "momentum" { + t.Errorf("weighted winner = %q, want momentum", sel.Scored[sel.Winner].ID) + } + + // Regime-specific entry beats the plain entry. + sel, err = Select(g, cands, Options{ + Regime: "trend", + Weights: map[string]float64{"value": 1.2, "value@trend": 0.5, "momentum": 1.0}, + }) + if err != nil { + t.Fatal(err) + } + if sel.Scored[sel.Winner].ID != "momentum" { + t.Errorf("regime-weighted winner = %q, want momentum", sel.Scored[sel.Winner].ID) + } +} + +func TestSelectTieBreaks(t *testing.T) { + g := goalWith() + + // Identical utility, higher raw confidence wins... but identical moves tie + // all the way down, so canonical (input) order decides. + same := []domain.RankedMove{move("thesis:ACME", 0.6, domain.LevelMedium, domain.LevelLow, domain.LevelMedium)} + sel, err := Select(g, []Candidate{{ID: "b-strategy", Moves: same}, {ID: "a-strategy", Moves: same}}, Options{}) + if err != nil { + t.Fatal(err) + } + if got := sel.Scored[sel.Winner].ID; got != "b-strategy" { + t.Errorf("full tie must keep canonical input order, got %q", got) + } + + // Same utility by construction differs only in raw confidence after + // calibration flattened the stated confidence. + a := move("thesis:ACME", 0.6, domain.LevelMedium, domain.LevelLow, domain.LevelMedium) + b := a + a.RawConfidence = 0.61 + b.RawConfidence = 0.66 + sel, err = Select(g, []Candidate{ + {ID: "first", Moves: []domain.RankedMove{a}}, + {ID: "second", Moves: []domain.RankedMove{b}}, + }, Options{}) + if err != nil { + t.Fatal(err) + } + if got := sel.Scored[sel.Winner].ID; got != "second" { + t.Errorf("raw-confidence tie-break winner = %q, want second", got) + } +} + +func TestSelectDeterministicUnderPermutation(t *testing.T) { + g := goalWith() + mk := func(id string, conf float64) Candidate { + return Candidate{ID: id, Moves: []domain.RankedMove{move("thesis:"+id, conf, domain.LevelMedium, domain.LevelLow, domain.LevelMedium)}} + } + // Distinct utilities: the winner must be identical under any input order. + perm1 := []Candidate{mk("a", 0.4), mk("b", 0.9), mk("c", 0.6)} + perm2 := []Candidate{mk("c", 0.6), mk("a", 0.4), mk("b", 0.9)} + s1, err1 := Select(g, perm1, Options{}) + s2, err2 := Select(g, perm2, Options{}) + if err1 != nil || err2 != nil { + t.Fatal(err1, err2) + } + if s1.Scored[s1.Winner].ID != "b" || s2.Scored[s2.Winner].ID != "b" { + t.Errorf("permutation changed the winner: %q vs %q", s1.Scored[s1.Winner].ID, s2.Scored[s2.Winner].ID) + } +} + +func TestSelectHysteresis(t *testing.T) { + g := goalWith() + inc := []domain.RankedMove{move("thesis:ACME", 0.60, domain.LevelMedium, domain.LevelLow, domain.LevelMedium)} + chal := []domain.RankedMove{move("thesis:GLOBEX", 0.63, domain.LevelMedium, domain.LevelLow, domain.LevelMedium)} + cands := []Candidate{{ID: "value", Moves: inc}, {ID: "momentum", Moves: chal}} + + // Challenger ahead, but within the margin: incumbent holds. + sel, err := Select(g, cands, Options{Incumbent: "value", IncumbentMargin: 0.05}) + if err != nil { + t.Fatal(err) + } + if got := sel.Scored[sel.Winner].ID; got != "value" { + t.Errorf("incumbent should hold within margin, winner = %q", got) + } + + // No margin: the challenger takes it. + sel, err = Select(g, cands, Options{Incumbent: "value"}) + if err != nil { + t.Fatal(err) + } + if got := sel.Scored[sel.Winner].ID; got != "momentum" { + t.Errorf("without margin the better candidate wins, got %q", got) + } + + // A filtered incumbent holds nothing. + brokenInc := []domain.RankedMove{move("thesis:ACME", 0, domain.LevelLow, domain.LevelLow, domain.LevelHigh)} + sel, err = Select(g, []Candidate{{ID: "value", Moves: brokenInc}, {ID: "momentum", Moves: chal}}, + Options{Incumbent: "value", IncumbentMargin: 0.5}) + if err != nil { + t.Fatal(err) + } + if got := sel.Scored[sel.Winner].ID; got != "momentum" { + t.Errorf("filtered incumbent must not hold, winner = %q", got) + } +} + +func TestSelectErrorAndFallback(t *testing.T) { + g := goalWith(domain.Constraint{Kind: "risk_tolerance", Name: "conservative"}) + + // A child error is recorded as filtered, and cannot win. + ok := []domain.RankedMove{move("thesis:ACME", 0.6, domain.LevelMedium, domain.LevelLow, domain.LevelMedium)} + sel, err := Select(g, []Candidate{ + {ID: "value", Err: errors.New("provider down")}, + {ID: "momentum", Moves: ok}, + }, Options{}) + if err != nil { + t.Fatal(err) + } + if got := sel.Scored[sel.Winner].ID; got != "momentum" { + t.Errorf("winner = %q, want momentum", got) + } + if !sel.Scored[0].Filtered { + t.Error("errored candidate must be marked filtered") + } + + // All candidates inadmissible: degrade to best inadmissible, never empty. + risky := []domain.RankedMove{move("thesis:ACME", 0.8, domain.LevelHigh, domain.LevelLow, domain.LevelHigh)} + riskier := []domain.RankedMove{move("thesis:GLOBEX", 0.4, domain.LevelLow, domain.LevelLow, domain.LevelHigh)} + sel, err = Select(g, []Candidate{{ID: "value", Moves: risky}, {ID: "momentum", Moves: riskier}}, Options{}) + if err != nil { + t.Fatal(err) + } + if got := sel.Scored[sel.Winner].ID; got != "value" { + t.Errorf("degraded winner = %q, want value (higher utility)", got) + } + + // Empty candidate list is the only hard error. + if _, err := Select(g, nil, Options{}); err == nil { + t.Error("empty candidate list must error") + } +} + +func TestDisagreementPenalty(t *testing.T) { + mk := func(id, top string, filtered bool) Scored { + return Scored{ID: id, TopMoveKey: top, Filtered: filtered} + } + cases := []struct { + name string + scored []Scored + want float64 + }{ + {"single admissible", []Scored{mk("a", "x", false), mk("b", "y", true)}, 0}, + {"full agreement", []Scored{mk("a", "x", false), mk("b", "x", false)}, 0}, + {"majority agreement", []Scored{mk("a", "x", false), mk("b", "x", false), mk("c", "y", false)}, 0.05}, + {"minority agreement", []Scored{mk("a", "x", false), mk("b", "y", false), mk("c", "z", false)}, 0.10}, + {"filtered do not vote", []Scored{mk("a", "x", false), mk("b", "y", true), mk("c", "y", true)}, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := DisagreementPenalty(tc.scored, "x"); got != tc.want { + t.Errorf("penalty = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/strategy/utility.go b/internal/strategy/utility.go new file mode 100644 index 0000000..237d05f --- /dev/null +++ b/internal/strategy/utility.go @@ -0,0 +1,192 @@ +// Package strategy holds the pure, deterministic math that picks one candidate +// plan out of several competing strategies' proposals. It knows nothing about +// planners, models or market data: it scores []domain.RankedMove against the +// goal that framed them, filters candidates the goal's hard constraints rule +// out, and breaks ties deterministically. Everything here must stay free of +// I/O and map-iteration ordering so a selection is reproducible byte-for-byte. +package strategy + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" + + "github.com/vingrad/dynamic-decision-engine/internal/domain" +) + +// Level scores map the qualitative low/medium/high vocabulary onto [0, 1] for +// the utility blend. Unknown levels read as medium. +const ( + levelScoreLow = 0.2 + levelScoreMedium = 0.5 + levelScoreHigh = 0.9 +) + +// Risk-aversion weights derived from the goal's risk_tolerance constraint: how +// hard a move's risk level subtracts from its utility. +const ( + riskAversionConservative = 1.0 + riskAversionDefault = 0.6 + riskAversionAggressive = 0.3 +) + +// effortPenalty is the fixed weight on a move's effort level — effort matters, +// but never as much as risk. +const effortPenalty = 0.25 + +// rankDecay geometrically discounts moves below the top recommendation: the +// top move dominates a plan's utility but a strong bench still counts. +const rankDecay = 0.7 + +// conservativeDrawdownLimit is the drawdown_limit fraction at or below which a +// goal is treated as conservative for the hard filter (10%). +const conservativeDrawdownLimit = 0.10 + +// Keyword parses mirror internal/finance/constraints.go so the selector reads +// risk_tolerance wording exactly the way sizing does. Duplicated (three small +// regexes) rather than imported to keep this package dependent on domain only. +var ( + conservativeRe = regexp.MustCompile(`\b(conservative|cautious|low)\b`) + aggressiveRe = regexp.MustCompile(`\b(aggressive|high)\b`) + percentRe = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*%`) +) + +func levelScore(l domain.Level) float64 { + switch l { + case domain.LevelLow: + return levelScoreLow + case domain.LevelHigh: + return levelScoreHigh + default: + return levelScoreMedium + } +} + +// riskAversion derives the λ_risk utility weight from the goal's stated +// risk_tolerance constraint; absent or unrecognised wording reads as moderate. +func riskAversion(goal domain.Goal) float64 { + for _, c := range goal.Context.Constraints { + if !strings.EqualFold(strings.TrimSpace(c.Kind), "risk_tolerance") { + continue + } + text := strings.ToLower(c.Name + " " + c.Description) + switch { + case conservativeRe.MatchString(text): + return riskAversionConservative + case aggressiveRe.MatchString(text): + return riskAversionAggressive + } + } + return riskAversionDefault +} + +// conservativeGoal reports whether the goal's constraints read conservative: +// an explicitly conservative risk_tolerance, or a drawdown_limit at or below +// conservativeDrawdownLimit. +func conservativeGoal(goal domain.Goal) bool { + for _, c := range goal.Context.Constraints { + switch strings.ToLower(strings.TrimSpace(c.Kind)) { + case "risk_tolerance": + text := strings.ToLower(c.Name + " " + c.Description) + if conservativeRe.MatchString(text) { + return true + } + case "drawdown_limit": + dd, ok := parsePercent(c.Name) + if !ok { + dd, ok = parsePercent(c.Description) + } + if ok && dd > 0 && dd <= conservativeDrawdownLimit { + return true + } + } + } + return false +} + +// parsePercent extracts the first "N%" in s as a fraction (10% -> 0.10). +func parsePercent(s string) (float64, bool) { + m := percentRe.FindStringSubmatch(s) + if m == nil { + return 0, false + } + n, err := strconv.ParseFloat(m[1], 64) + if err != nil { + return 0, false + } + return n / 100, true +} + +// HardFilter applies the goal's hard gates to one candidate's moves. It +// returns filtered=true with a human-readable reason when the candidate is +// inadmissible: it proposes nothing viable, or its top recommendation carries +// a risk level the goal's stated tolerance rules out. Sizing-level constraints +// are already enforced inside each strategy's planner; this is the +// selector-level backstop on the plan's shape. +func HardFilter(goal domain.Goal, moves []domain.RankedMove) (reason string, filtered bool) { + if len(moves) == 0 { + return "no moves proposed", true + } + viable := false + for _, m := range moves { + if m.Confidence > 0 { + viable = true + break + } + } + if !viable { + return "no viable moves (every move has confidence 0)", true + } + if conservativeGoal(goal) && moves[0].Risk == domain.LevelHigh { + return "top move risk \"high\" exceeds the goal's conservative risk tolerance", true + } + return "", false +} + +// Utility scores a candidate's moves against the goal: per-move value is +// confidence-weighted impact minus risk (weighted by the goal's risk aversion) +// minus effort, blended down the ranking with geometric decay so the top move +// dominates without the bench being ignored. The result is rounded to 4 +// decimals so downstream comparisons and tie-breaks are stable. The returned +// explain string names the inputs for provenance. +func Utility(goal domain.Goal, moves []domain.RankedMove) (float64, string) { + if len(moves) == 0 { + return 0, "no moves" + } + lambdaRisk := riskAversion(goal) + + var sum, weights float64 + g := 1.0 + for _, m := range moves { + v := m.Confidence*levelScore(m.ExpectedImpact) - + lambdaRisk*levelScore(m.Risk) - + effortPenalty*levelScore(m.Effort) + sum += g * v + weights += g + g *= rankDecay + } + u := round4(sum / weights) + explain := fmt.Sprintf("utility %.4f over %d moves (risk aversion %.2f, top move %q conf %.2f)", + u, len(moves), lambdaRisk, topKey(moves), moves[0].Confidence) + return u, explain +} + +// topKey returns the stable identity of a move list's top recommendation, +// falling back to the title when no key was minted. +func topKey(moves []domain.RankedMove) string { + if len(moves) == 0 { + return "" + } + if moves[0].Key != "" { + return moves[0].Key + } + return moves[0].Title +} + +// round4 stabilises utilities at 4 decimals so equality comparisons (and so +// the tie-break chain) never hinge on sub-noise float drift. +func round4(x float64) float64 { + return math.Round(x*10000) / 10000 +} diff --git a/internal/wire/builders.go b/internal/wire/builders.go index cfad072..f50c16e 100644 --- a/internal/wire/builders.go +++ b/internal/wire/builders.go @@ -1,6 +1,11 @@ package wire import ( + "context" + "strings" + "time" + + "github.com/vingrad/dynamic-decision-engine/internal/domain" "github.com/vingrad/dynamic-decision-engine/internal/finance" "github.com/vingrad/dynamic-decision-engine/internal/llm" "github.com/vingrad/dynamic-decision-engine/internal/marketdata" @@ -31,13 +36,25 @@ var plannerBuilders = map[string]PlannerBuilder{ // buildFinancePlanner builds the numeric finance planner for a "finance"-kind // domain. It pulls the "marketdata" data source; if it is absent or of the wrong // type, it declines (returns nil) so the domain falls back to the guided text -// planner — matching the prior "investing without a provider" behaviour. +// planner — matching the prior "investing without a provider" behaviour. When +// the descriptor declares strategies and policy enables selection, it builds +// one child planner per strategy and composes them under a selector instead. func buildFinancePlanner(d pack.Descriptor, pol policy.Policy, deps PlannerDeps) (llm.Planner, error) { provider, ok := deps.DataSources[marketDataKey].(marketdata.Provider) if !ok { return nil, nil // no market data wired → decline to guided fallback } + if strategies := activeStrategies(d, pol); selectionEnabled(d, pol) && len(strategies) >= 2 { + return buildFinanceSelector(d, pol, deps, provider, strategies) + } else if selectionEnabled(d, pol) && len(strategies) == 1 { + // Exactly one strategy left (policy disabled the rest): pin that lens as + // the single planner — no competition, but the lens's prior tilts and + // scoring overlay still apply. This is how a fixed-strategy baseline is + // expressed (backtest comparisons, or an operator pinning a lens). + return buildFinanceChild(d, pol, deps, provider, strategies[0], deps.FinanceInner), nil + } + cfg := llm.FinanceConfig{ Provider: provider, Scoring: effectiveScoring(d, pol), @@ -59,6 +76,144 @@ func buildFinancePlanner(d pack.Descriptor, pol policy.Policy, deps PlannerDeps) return fin, nil } +// selectionEnabled reports whether the strategy competition is on for a domain. +// An explicit policy setting wins; otherwise a domain that declares strategies +// competes them by default — the investing pack's backtest gates +// (TestStrategyMatrixGates) earned the flip, and policy remains the off switch. +func selectionEnabled(d pack.Descriptor, pol policy.Policy) bool { + if dp, ok := pol.For(d.ID); ok && dp.Strategy != nil && dp.Strategy.Enabled != nil { + return *dp.Strategy.Enabled + } + return len(d.Strategies) > 0 +} + +// activeStrategies returns the descriptor's strategies minus any the policy +// disables. Unknown IDs in the disable list are simply ignored so a policy +// file survives pack evolution. +func activeStrategies(d pack.Descriptor, pol policy.Policy) []pack.StrategyDescriptor { + disabled := map[string]bool{} + if dp, ok := pol.For(d.ID); ok && dp.Strategy != nil { + for _, id := range dp.Strategy.Disable { + disabled[id] = true + } + } + var out []pack.StrategyDescriptor + for _, s := range d.Strategies { + if !disabled[s.ID] { + out = append(out, s) + } + } + return out +} + +// buildFinanceSelector assembles the competing finance children — one per +// strategy lens, each caching independently (the strategy ID is in the child's +// name and so its cache key) — under a SelectorPlanner. The hybrid narrator +// attaches to the selector, never to children, so narration runs once on the +// winner. The selector itself is never cached: selection is cheap and pure, +// and its hysteresis input (the incumbent strategy) must stay live. +func buildFinanceSelector(d pack.Descriptor, pol policy.Policy, deps PlannerDeps, provider marketdata.Provider, strategies []pack.StrategyDescriptor) (llm.Planner, error) { + dp, hasPolicy := pol.For(d.ID) + + children := make([]llm.StrategyChild, 0, len(strategies)) + for _, s := range strategies { + // Children carry no narrator: narration runs once on the winner, inside + // the selector. + child := buildFinanceChild(d, pol, deps, provider, s, nil) + children = append(children, llm.StrategyChild{ID: s.ID, Planner: child, Regimes: s.Regimes}) + } + + selCfg := llm.SelectorConfig{ + Children: children, + Inner: deps.FinanceInner, + PromptTemplate: d.PromptTemplate, + Regime: financeRegimeFn(provider, deps.FinanceNow), + } + if hasPolicy && dp.Strategy != nil { + selCfg.Weights = dp.Strategy.Weights + if dp.Strategy.IncumbentMargin != nil { + selCfg.IncumbentMargin = *dp.Strategy.IncumbentMargin + } + } + return llm.NewSelectorPlanner(selCfg) +} + +// buildFinanceChild builds one strategy lens as a finance planner: the lens's +// params overlay the domain's base scoring, its prior tilts apply, and its +// strategy-suffixed name keys the TTL cache separately per lens. +func buildFinanceChild(d pack.Descriptor, pol policy.Policy, deps PlannerDeps, provider marketdata.Provider, s pack.StrategyDescriptor, inner llm.Planner) llm.Planner { + dp, hasPolicy := pol.For(d.ID) + params := strategyParams(s, dp, hasPolicy) + base := effectiveScoring(d, pol).Normalize() + cfg := llm.FinanceConfig{ + Provider: provider, + Scoring: params.Apply(base), + Inner: inner, + Now: deps.FinanceNow, + PackID: d.ID, + PackVersion: d.Version, + PromptTemplate: d.PromptTemplate, + StrategyID: s.ID, + PriorWeights: params.Prior, + // All lenses state confidence on the domain's BASE weighting, so the + // selector compares like with like (same scale, different evidence). + ConfidenceWeights: base.Weights, + } + if hasPolicy && dp.Calibration != nil { + cfg.Calibration = dp.Calibration + } + child := llm.Planner(llm.NewFinancePlanner(cfg)) + if deps.FinanceCache != nil { + child = llm.NewCachingPlanner(child, deps.FinanceCache, deps.CacheObs) + } + return child +} + +// financeRegimeFn classifies the goal-level market regime from one year of +// point-in-time bars per ticker asset, as of the (possibly simulated) clock. +// Fetch failures and thin history simply leave that ticker unknown — an +// unknown regime gates nothing, so degraded data can only widen the field, +// never narrow it. The regime is recorded in provenance either way, which is +// what makes per-regime outcome fitting possible later. +func financeRegimeFn(provider marketdata.Provider, now func() time.Time) llm.RegimeFn { + if now == nil { + now = time.Now + } + return func(ctx context.Context, g domain.Goal) (string, error) { + asOf := now() + var readings []finance.RegimeReading + for _, a := range g.Context.Assets { + if !strings.EqualFold(a.Kind, "ticker") { + continue + } + bars, err := provider.HistoricalBars(ctx, strings.ToUpper(strings.TrimSpace(a.Name)), asOf.AddDate(-1, 0, 0), asOf) + if err != nil { + continue + } + readings = append(readings, finance.ClassifyRegime(bars)) + } + return string(finance.CombineRegimes(readings)), nil + } +} + +// strategyParams resolves one strategy's numeric tuning: the descriptor's +// opaque Scoring (when it holds *finance.StrategyParams) is the base, and a +// policy override MERGES onto it field-by-field — a policy that tunes one +// knob keeps the lens's other parameters (its prior tilts in particular) +// instead of silently resetting them. +func strategyParams(s pack.StrategyDescriptor, dp policy.DomainPolicy, hasPolicy bool) finance.StrategyParams { + params := finance.StrategyParams{Name: s.ID} + if p, ok := s.Scoring.(*finance.StrategyParams); ok && p != nil { + params = *p + } + if hasPolicy && dp.Strategy != nil { + if p, ok := dp.Strategy.Params[s.ID]; ok && p != nil { + params = params.Merge(*p) + } + } + return params +} + // marketDataKey is the DataSources registry key for the market-data provider. const marketDataKey = "marketdata" diff --git a/internal/wire/builders_test.go b/internal/wire/builders_test.go index a78d917..1fc483c 100644 --- a/internal/wire/builders_test.go +++ b/internal/wire/builders_test.go @@ -5,7 +5,9 @@ import ( "testing" "github.com/vingrad/dynamic-decision-engine/internal/domain" + "github.com/vingrad/dynamic-decision-engine/internal/finance" "github.com/vingrad/dynamic-decision-engine/internal/llm" + "github.com/vingrad/dynamic-decision-engine/internal/marketdata" "github.com/vingrad/dynamic-decision-engine/internal/pack" "github.com/vingrad/dynamic-decision-engine/internal/policy" ) @@ -66,3 +68,113 @@ func TestFinanceBuilderDeclinesWithoutDataSource(t *testing.T) { t.Errorf("investing without market data should fall back to guided base: %+v", inv.Provenance) } } + +// TestFinanceSelectorBuildsWhenEnabled verifies the strategy competition seam: +// with strategies declared on the pack and policy enabling selection, the +// investing route is a selector whose provenance records every candidate; with +// the policy absent the route stays the single finance planner, byte-for-byte. +func TestFinanceSelectorBuildsWhenEnabled(t *testing.T) { + provider, err := marketdata.NewOfflineProvider() + if err != nil { + t.Fatal(err) + } + deps := PlannerDeps{ + Base: llm.NewMockPlanner(), + DataSources: map[string]DataSource{marketDataKey: provider}, + } + goal := domain.Goal{ + Domain: "investing", + Objective: "grow", + Context: domain.Context{ + Assets: []domain.Asset{{Name: "ACME", Kind: "ticker"}}, + }, + } + + // Policy off-switch: explicit enabled:false restores the single planner, + // byte-for-byte the legacy provenance. + off := false + polOff := policy.Policy{Domains: map[string]policy.DomainPolicy{ + "investing": {Strategy: &policy.StrategySelection{Enabled: &off}}, + }} + router := BuildPlannerRouter(pack.NewRegistry(), polOff, deps) + res, err := router.GeneratePlan(context.Background(), llm.PlanRequest{Goal: goal}) + if err != nil { + t.Fatal(err) + } + if res.Provenance.Planner != "finance" || res.Provenance.Strategy != "single" { + t.Errorf("with selection off the single finance planner must serve: %+v", res.Provenance) + } + + // Default (no policy): the pack declares strategies, so the selector serves + // and candidates are recorded. + on := true + router = BuildPlannerRouter(pack.NewRegistry(), policy.Policy{}, deps) + res, err = router.GeneratePlan(context.Background(), llm.PlanRequest{Goal: goal}) + if err != nil { + t.Fatal(err) + } + if res.Provenance.Strategy != "selector" { + t.Fatalf("expected selector provenance, got %+v", res.Provenance) + } + if res.Provenance.SelectedStrategy == "" || len(res.Provenance.StrategyCandidates) != 3 { + t.Errorf("expected a winner among 3 candidates, got %q / %d", + res.Provenance.SelectedStrategy, len(res.Provenance.StrategyCandidates)) + } + for _, c := range res.Provenance.StrategyCandidates { + if c.Planner != "finance:"+c.StrategyID { + t.Errorf("child planner name %q must carry its strategy id %q", c.Planner, c.StrategyID) + } + } + + // Disabling a strategy removes it from the field. + pol := policy.Policy{Domains: map[string]policy.DomainPolicy{}} + pol.Domains["investing"] = policy.DomainPolicy{Strategy: &policy.StrategySelection{ + Enabled: &on, Disable: []string{"momentum"}, + }} + router = BuildPlannerRouter(pack.NewRegistry(), pol, deps) + res, err = router.GeneratePlan(context.Background(), llm.PlanRequest{Goal: goal}) + if err != nil { + t.Fatal(err) + } + if len(res.Provenance.StrategyCandidates) != 2 { + t.Errorf("disabling momentum should leave 2 candidates, got %d", len(res.Provenance.StrategyCandidates)) + } +} + +// TestPolicyStrategyParamsMergeKeepsPrior: a policy that tunes one knob of a +// lens must not strip the lens's prior tilts — the regression here was a +// wholesale replacement whose zero Prior normalized to the neutral blend. +func TestPolicyStrategyParamsMergeKeepsPrior(t *testing.T) { + reg := pack.NewRegistry() + d, _ := reg.Get("investing") + pol := policy.Policy{Domains: map[string]policy.DomainPolicy{ + "investing": {Strategy: &policy.StrategySelection{ + Params: map[string]*finance.StrategyParams{ + "momentum": {RiskScale: finance.RiskScale{Kelly: 0.8}}, + }, + }}, + }} + dp, ok := pol.For("investing") + if !ok { + t.Fatal("policy lookup failed") + } + + var momentum pack.StrategyDescriptor + for _, s := range d.Strategies { + if s.ID == "momentum" { + momentum = s + } + } + declared := momentum.Scoring.(*finance.StrategyParams) + + got := strategyParams(momentum, dp, true) + if got.Prior != declared.Prior { + t.Errorf("partial policy override stripped the prior: got %+v, want %+v", got.Prior, declared.Prior) + } + if got.RewardRiskRatio != declared.RewardRiskRatio { + t.Errorf("reward:risk lost: got %v, want %v", got.RewardRiskRatio, declared.RewardRiskRatio) + } + if got.RiskScale.Kelly != 0.8 { + t.Errorf("the overridden knob must apply, got %v", got.RiskScale.Kelly) + } +} diff --git a/internal/wire/planner_test.go b/internal/wire/planner_test.go index 6e35feb..b2435b2 100644 --- a/internal/wire/planner_test.go +++ b/internal/wire/planner_test.go @@ -66,10 +66,14 @@ func TestBuildPlannerRouterRoutes(t *testing.T) { FinanceNow: func() time.Time { return time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC) }, }) - // Investing -> finance planner (numeric), provenance reflects it. + // Investing -> finance strategy selector (the pack declares strategies and + // the competition is on by default), provenance reflects the competition. inv := plan(t, router, "investing") - if inv.Provenance.Planner != "finance" || inv.Provenance.PackID != "investing" { - t.Errorf("investing should route to finance: %+v", inv.Provenance) + if inv.Provenance.Strategy != "selector" || inv.Provenance.PackID != "investing" { + t.Errorf("investing should route to the strategy selector: %+v", inv.Provenance) + } + if inv.Provenance.SelectedStrategy == "" || len(inv.Provenance.StrategyCandidates) == 0 { + t.Errorf("selector provenance must record the competition: %+v", inv.Provenance) } // Generic -> base, no pack stamp (byte-for-byte preserved).