From 1487de393c8ddad67041371431a03d5ab3bdd356 Mon Sep 17 00:00:00 2001 From: vingrad Date: Wed, 10 Jun 2026 22:42:17 +0200 Subject: [PATCH] Improve investing decision quality: constraints, EV neutrality, eval metrics Three fixes to the finance planner and backtest harness: - Honor per-goal risk constraints in sizing. drawdown_limit and risk_tolerance constraints were validated and warned about but never read; the risk budget came only from pack/policy config. A drawdown limit now caps the concentration and per-trade risk budgets, and a conservative/aggressive tolerance scales the Kelly fraction. The move rationale names what bound. - Stop rewarding volatility in the EV score. With a flat 0.5 win probability and winFrac = ratio*lossFrac, expected value grew monotonically with volatility, so the EV component favoured the most volatile ticker on every initial plan. The EV score is now neutral (0.5) until a signal tilts the odds, and a signal hint only informs the ticker it names. - Add decision-quality metrics to the backtest report: a Brier score of top-move confidence against realized forward returns, per-decision forward-return attribution, and a noise-robustness rate (share of non-kill events that did not trigger a replan). Forward returns are computed after the replay, so no future data reaches a decision. --- internal/backtest/harness.go | 69 +++++++++++--- internal/backtest/harness_test.go | 20 ++++ internal/backtest/report.go | 19 +++- internal/finance/constraints.go | 76 +++++++++++++++ internal/finance/constraints_test.go | 136 +++++++++++++++++++++++++++ internal/finance/scoring.go | 6 ++ internal/llm/finance_planner.go | 32 +++++-- internal/llm/finance_planner_test.go | 89 ++++++++++++++++++ 8 files changed, 425 insertions(+), 22 deletions(-) create mode 100644 internal/finance/constraints.go create mode 100644 internal/finance/constraints_test.go diff --git a/internal/backtest/harness.go b/internal/backtest/harness.go index 5ab4c89..e8d6483 100644 --- a/internal/backtest/harness.go +++ b/internal/backtest/harness.go @@ -59,7 +59,7 @@ func (h *Harness) Run(ctx context.Context, sc Scenario) (Report, error) { } rep := Report{Scenario: sc.Name} - var reacts, correctReacts, shouldKills int + var reacts, correctReacts, shouldKills, noiseEvents, noiseReacts int for _, ev := range sc.Events { h.sim.t = ev.At @@ -85,6 +85,11 @@ func (h *Harness) Run(ctx context.Context, sc Scenario) (Report, error) { if ev.ShouldKill { shouldKills++ + } else { + noiseEvents++ + if res.Material { + noiseReacts++ + } } if res.Material { reacts++ @@ -102,27 +107,67 @@ func (h *Harness) Run(ctx context.Context, sc Scenario) (Report, error) { if shouldKills > 0 { rep.KillRecall = float64(correctReacts) / float64(shouldKills) } - rep.HypotheticalPnL = h.hypotheticalPnL(ctx, current, start, h.sim.t) + rep.NoiseRobustness = 1 + if noiseEvents > 0 { + rep.NoiseRobustness = 1 - float64(noiseReacts)/float64(noiseEvents) + } + h.scoreCalibration(ctx, &rep, h.sim.t) + finalTop, _ := topMove(current) + rep.HypotheticalPnL, _ = h.tickerReturn(ctx, tickerFromTitle(finalTop), start, h.sim.t) return rep, nil } -// hypotheticalPnL is an illustrative buy-and-hold return of the final top thesis's -// ticker over the scenario window. It is NOT a strategy return. -func (h *Harness) hypotheticalPnL(ctx context.Context, current domain.PlanVersion, from, to time.Time) float64 { - top, _ := topMove(current) - ticker := strings.TrimPrefix(top, "Thesis: ") - if ticker == top || ticker == "" { - return 0 +// scoreCalibration fills per-decision forward returns and the report's Brier +// score. The forward window runs from each decision to the scenario end; it is +// computed after the replay, so no future data ever reaches a decision. The +// outcome label is 1 when the top thesis's forward return is positive, falling +// back to the analyst kill label when no return is resolvable. +func (h *Harness) scoreCalibration(ctx context.Context, rep *Report, end time.Time) { + if len(rep.Decisions) == 0 { + return + } + var sum float64 + for i := range rep.Decisions { + d := &rep.Decisions[i] + fr, ok := h.tickerReturn(ctx, tickerFromTitle(d.TopMove), d.At, end) + if ok { + d.ForwardReturn = fr + } + var label float64 + if (ok && fr > 0) || (!ok && !d.ShouldKill) { + label = 1 + } + diff := d.TopConfidence - label + sum += diff * diff + } + rep.BrierScore = sum / float64(len(rep.Decisions)) +} + +// tickerReturn is the buy-and-hold return of one ticker over [from, to] from +// point-in-time quotes. It reports ok=false when either quote is unavailable. +func (h *Harness) tickerReturn(ctx context.Context, ticker string, from, to time.Time) (float64, bool) { + if ticker == "" { + return 0, false } startQ, err := h.provider.Quote(ctx, ticker, from) if err != nil || startQ.Price == 0 { - return 0 + return 0, false } endQ, err := h.provider.Quote(ctx, ticker, to) if err != nil { - return 0 + return 0, false + } + return (endQ.Price - startQ.Price) / startQ.Price, true +} + +// tickerFromTitle extracts the ticker from a finance-planner move title +// ("Thesis: ACME" -> "ACME"); non-thesis titles yield "". +func tickerFromTitle(title string) string { + ticker := strings.TrimPrefix(title, "Thesis: ") + if ticker == title { + return "" } - return (endQ.Price - startQ.Price) / startQ.Price + return ticker } func topMove(v domain.PlanVersion) (string, float64) { diff --git a/internal/backtest/harness_test.go b/internal/backtest/harness_test.go index 904f792..1c783e1 100644 --- a/internal/backtest/harness_test.go +++ b/internal/backtest/harness_test.go @@ -3,6 +3,7 @@ package backtest import ( "context" "encoding/json" + "math" "os" "testing" @@ -56,4 +57,23 @@ func TestHarnessRun(t *testing.T) { if rep.HypotheticalPnL <= 0 { t.Errorf("expected positive illustrative pnl from fixtures, got %v", rep.HypotheticalPnL) } + + // The valuation_change event is labeled non-kill and produces no material + // replan, so the run never reacts to noise. + if rep.NoiseRobustness != 1 { + t.Errorf("expected noise robustness 1, got %v", rep.NoiseRobustness) + } + // Calibration: confidence should beat a coin flip (Brier 0.25) on this + // scenario — ACME rallies while held, and the broken thesis ends at zero + // confidence with a zero forward return. + if rep.BrierScore <= 0 || rep.BrierScore >= 0.25 { + t.Errorf("expected brier score in (0, 0.25), got %v", rep.BrierScore) + } + first, last := rep.Decisions[0], rep.Decisions[len(rep.Decisions)-1] + if math.Abs(first.ForwardReturn-0.08) > 1e-9 { + t.Errorf("expected +8%% forward return on first decision from fixtures, got %v", first.ForwardReturn) + } + if last.ForwardReturn != 0 { + t.Errorf("final decision has no forward window, expected 0, got %v", last.ForwardReturn) + } } diff --git a/internal/backtest/report.go b/internal/backtest/report.go index 491ea48..8710a54 100644 --- a/internal/backtest/report.go +++ b/internal/backtest/report.go @@ -15,6 +15,9 @@ type Decision struct { TopMove string `json:"top_move"` TopConfidence float64 `json:"top_confidence"` ShouldKill bool `json:"should_kill"` + // ForwardReturn is the top thesis ticker's return from decision time to the + // end of the scenario (evaluation-only attribution; never a planner input). + ForwardReturn float64 `json:"forward_return"` } // Report summarises a backtest run. The metrics describe decision/replanning @@ -25,7 +28,15 @@ type Report struct { VersionsCreated int `json:"versions_created"` KillPrecision float64 `json:"kill_precision"` // correct reactions / total reactions KillRecall float64 `json:"kill_recall"` // correct reactions / total should-kill events - HypotheticalPnL float64 `json:"hypothetical_pnl"` + // BrierScore is the mean squared error between each decision's top-move + // confidence and its realized outcome label (1 if the top thesis's forward + // return was positive, falling back to the analyst kill label). Lower is + // better; a coin-flip confidence of 0.5 scores 0.25. + BrierScore float64 `json:"brier_score"` + // NoiseRobustness is the share of non-kill events that did NOT trigger a + // material replan (1 == never reacted to noise). + NoiseRobustness float64 `json:"noise_robustness"` + HypotheticalPnL float64 `json:"hypothetical_pnl"` } // Render writes a human-readable report to w. @@ -34,6 +45,8 @@ func (r Report) Render(w io.Writer) { fmt.Fprintf(w, " versions created : %d\n", r.VersionsCreated) fmt.Fprintf(w, " kill precision : %.2f\n", r.KillPrecision) fmt.Fprintf(w, " kill recall : %.2f\n", r.KillRecall) + fmt.Fprintf(w, " brier score : %.3f (lower is better; 0.25 == coin-flip confidence)\n", r.BrierScore) + fmt.Fprintf(w, " noise robustness : %.2f (share of non-kill events not reacted to)\n", r.NoiseRobustness) fmt.Fprintf(w, " hypothetical pnl : %+.2f%% (illustrative, not a strategy return)\n", r.HypotheticalPnL*100) fmt.Fprintln(w, " decisions:") for _, d := range r.Decisions { @@ -41,7 +54,7 @@ func (r Report) Render(w io.Writer) { if d.Material { mark = "*" } - fmt.Fprintf(w, " %s %s [%s] material=%v should_kill=%v top=%q conf=%.2f — %s\n", - mark, d.At.Format("2006-01-02"), d.Kind, d.Material, d.ShouldKill, d.TopMove, d.TopConfidence, d.Reason) + fmt.Fprintf(w, " %s %s [%s] material=%v should_kill=%v top=%q conf=%.2f fwd=%+.2f%% — %s\n", + mark, d.At.Format("2006-01-02"), d.Kind, d.Material, d.ShouldKill, d.TopMove, d.TopConfidence, d.ForwardReturn*100, d.Reason) } } diff --git a/internal/finance/constraints.go b/internal/finance/constraints.go new file mode 100644 index 0000000..ab4ea0b --- /dev/null +++ b/internal/finance/constraints.go @@ -0,0 +1,76 @@ +package finance + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/vingrad/dynamic-decision-engine/internal/domain" +) + +var ( + percentRe = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*%`) + conservativeRe = regexp.MustCompile(`\b(conservative|cautious|low)\b`) + aggressiveRe = regexp.MustCompile(`\b(aggressive|high)\b`) +) + +// EffectiveRiskBudget tightens a base risk budget with the goal's stated +// drawdown_limit and risk_tolerance constraints, so per-goal risk preferences +// actually bind position sizing. Constraints that don't parse leave the base +// unchanged. The returned note names what bound, for the move rationale. +func EffectiveRiskBudget(base RiskBudget, constraints []domain.Constraint) (RiskBudget, string) { + out := base + var notes []string + for _, c := range constraints { + switch strings.ToLower(strings.TrimSpace(c.Kind)) { + case "drawdown_limit": + dd, ok := parsePercent(c.Name) + if !ok { + dd, ok = parsePercent(c.Description) + } + if !ok || dd <= 0 || dd >= 1 { + continue + } + // A total loss of a single position must not exceed the stated + // drawdown limit, and a run of five stopped-out trades must stay + // within it. + if out.MaxPositionPct == 0 || dd < out.MaxPositionPct { + out.MaxPositionPct = dd + } + if perTrade := dd / 5; out.MaxPortfolioRiskPct == 0 || perTrade < out.MaxPortfolioRiskPct { + out.MaxPortfolioRiskPct = perTrade + } + notes = append(notes, fmt.Sprintf("drawdown_limit %.0f%%", dd*100)) + case "risk_tolerance": + text := strings.ToLower(c.Name + " " + c.Description) + switch { + case conservativeRe.MatchString(text): + out.KellyFraction *= 0.5 + out.MaxPositionPct *= 0.5 + notes = append(notes, "risk_tolerance conservative") + case aggressiveRe.MatchString(text): + // Aggressive loosens the Kelly multiplier only; the hard caps + // (concentration, per-trade risk) remain guardrails. + out.KellyFraction *= 1.5 + notes = append(notes, "risk_tolerance aggressive") + } + // Moderate or unrecognised wording: the base budget already + // encodes a moderate stance. + } + } + return out, strings.Join(notes, ", ") +} + +// 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 +} diff --git a/internal/finance/constraints_test.go b/internal/finance/constraints_test.go new file mode 100644 index 0000000..bacead5 --- /dev/null +++ b/internal/finance/constraints_test.go @@ -0,0 +1,136 @@ +package finance + +import ( + "testing" + + "github.com/vingrad/dynamic-decision-engine/internal/domain" +) + +func baseBudget() RiskBudget { + return RiskBudget{MaxPortfolioRiskPct: 0.02, MaxPositionPct: 0.20, KellyFraction: 0.25} +} + +func TestEffectiveRiskBudget(t *testing.T) { + cases := []struct { + name string + constraints []domain.Constraint + want RiskBudget + note string + }{ + { + name: "no constraints leaves base unchanged", + want: baseBudget(), + }, + { + name: "unrelated kinds leave base unchanged", + constraints: []domain.Constraint{ + {Name: "2 year horizon", Kind: "time_horizon"}, + {Name: "no leverage", Kind: "mandate"}, + }, + want: baseBudget(), + }, + { + name: "drawdown limit tightens position and per-trade caps", + constraints: []domain.Constraint{ + {Name: "max 5% drawdown", Kind: "drawdown_limit"}, + }, + want: RiskBudget{MaxPortfolioRiskPct: 0.01, MaxPositionPct: 0.05, KellyFraction: 0.25}, + note: "drawdown_limit 5%", + }, + { + name: "drawdown limit looser than base does not loosen it", + constraints: []domain.Constraint{ + {Name: "max 50% drawdown", Kind: "drawdown_limit"}, + }, + want: baseBudget(), + note: "drawdown_limit 50%", + }, + { + name: "drawdown percent parsed from description", + constraints: []domain.Constraint{ + {Name: "drawdown cap", Kind: "drawdown_limit", Description: "no more than 10% peak-to-trough"}, + }, + want: RiskBudget{MaxPortfolioRiskPct: 0.02, MaxPositionPct: 0.10, KellyFraction: 0.25}, + note: "drawdown_limit 10%", + }, + { + name: "unparseable drawdown is ignored", + constraints: []domain.Constraint{ + {Name: "keep drawdowns small", Kind: "drawdown_limit"}, + }, + want: baseBudget(), + }, + { + name: "conservative tolerance halves kelly and concentration", + constraints: []domain.Constraint{ + {Name: "conservative risk tolerance", Kind: "risk_tolerance"}, + }, + want: RiskBudget{MaxPortfolioRiskPct: 0.02, MaxPositionPct: 0.10, KellyFraction: 0.125}, + note: "risk_tolerance conservative", + }, + { + name: "aggressive tolerance scales kelly only", + constraints: []domain.Constraint{ + {Name: "aggressive", Kind: "risk_tolerance"}, + }, + want: RiskBudget{MaxPortfolioRiskPct: 0.02, MaxPositionPct: 0.20, KellyFraction: 0.375}, + note: "risk_tolerance aggressive", + }, + { + name: "moderate tolerance is a no-op", + constraints: []domain.Constraint{ + {Name: "moderate risk tolerance", Kind: "risk_tolerance"}, + }, + want: baseBudget(), + }, + { + name: "drawdown and tolerance combine", + constraints: []domain.Constraint{ + {Name: "max 10% drawdown", Kind: "drawdown_limit"}, + {Name: "low risk appetite", Kind: "risk_tolerance"}, + }, + want: RiskBudget{MaxPortfolioRiskPct: 0.02, MaxPositionPct: 0.05, KellyFraction: 0.125}, + note: "drawdown_limit 10%, risk_tolerance conservative", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, note := EffectiveRiskBudget(baseBudget(), tc.constraints) + if got != tc.want { + t.Errorf("budget = %+v, want %+v", got, tc.want) + } + if note != tc.note { + t.Errorf("note = %q, want %q", note, tc.note) + } + }) + } +} + +func TestEffectiveRiskBudgetNoFalseKeywordMatch(t *testing.T) { + // "low" must match as a word, not inside e.g. "follow". + got, note := EffectiveRiskBudget(baseBudget(), []domain.Constraint{ + {Name: "follow the mandate", Kind: "risk_tolerance"}, + }) + if got != baseBudget() || note != "" { + t.Errorf("substring should not trigger a tolerance match: %+v %q", got, note) + } +} + +func TestParsePercent(t *testing.T) { + cases := []struct { + in string + want float64 + ok bool + }{ + {"max 10% drawdown", 0.10, true}, + {"2.5 %", 0.025, true}, + {"no percent here", 0, false}, + {"", 0, false}, + } + for _, tc := range cases { + got, ok := parsePercent(tc.in) + if got != tc.want || ok != tc.ok { + t.Errorf("parsePercent(%q) = (%v, %v), want (%v, %v)", tc.in, got, ok, tc.want, tc.ok) + } + } +} diff --git a/internal/finance/scoring.go b/internal/finance/scoring.go index 9a7f6c9..ea75a7e 100644 --- a/internal/finance/scoring.go +++ b/internal/finance/scoring.go @@ -86,6 +86,12 @@ func ExpectedValue(winProb, winFrac, lossFrac float64) float64 { // EVScore maps a signed expected value into a [0,1] score centred at 0.5. func EVScore(ev float64) float64 { return clamp01(0.5 + ev) } +// NeutralEVScore is the EV component used when the win probability carries no +// information (flat prior, no signal hint). Scoring the volatility-scaled win/ +// loss magnitudes under a flat prior would reward the most volatile asset, so +// the EV component stays neutral until a signal tilts the odds. +const NeutralEVScore = 0.5 + // RiskScore maps volatility and drawdown into a [0,1] safety score (higher safer). func RiskScore(vol, maxDD float64) float64 { return clamp01(1 - 0.5*vol - 0.5*maxDD) diff --git a/internal/llm/finance_planner.go b/internal/llm/finance_planner.go index 696a0f3..1fe2a40 100644 --- a/internal/llm/finance_planner.go +++ b/internal/llm/finance_planner.go @@ -91,10 +91,14 @@ func (p *FinancePlanner) GeneratePlan(ctx context.Context, req PlanRequest) (Pla } } + // Per-goal constraints (drawdown_limit, risk_tolerance) tighten the + // configured risk budget before any sizing happens. + budget, budgetNote := finance.EffectiveRiskBudget(p.scoring.Risk, g.Context.Constraints) + tickers := candidateTickers(g, sig) moves := make([]domain.RankedMove, 0, len(tickers)) for _, ticker := range tickers { - moves = append(moves, p.scoreThesis(ctx, ticker, g, sig, asOf)) + moves = append(moves, p.scoreThesis(ctx, ticker, g, sig, asOf, budget, budgetNote)) } if len(moves) == 0 { moves = append(moves, insufficientDataMove(g)) @@ -135,7 +139,7 @@ func (p *FinancePlanner) GeneratePlan(ctx context.Context, req PlanRequest) (Pla } // scoreThesis builds a fully-scored ranked move for one ticker. -func (p *FinancePlanner) scoreThesis(ctx context.Context, ticker string, g domain.Goal, sig *finance.MarketSignal, asOf time.Time) domain.RankedMove { +func (p *FinancePlanner) scoreThesis(ctx context.Context, ticker string, g domain.Goal, sig *finance.MarketSignal, asOf time.Time, budget finance.RiskBudget, budgetNote string) domain.RankedMove { var ( vol, maxDD, avgDollarVol float64 ) @@ -147,10 +151,14 @@ func (p *FinancePlanner) scoreThesis(ctx context.Context, ticker string, g domai maxDD = finance.MaxDrawdown(bars) } + // A signal hint informs the win probability only for the ticker it names + // (an unnamed signal, e.g. macro, applies to every candidate). winProb := 0.5 - if sig != nil { + informed := false + if sig != nil && (sig.Ticker == "" || strings.EqualFold(sig.Ticker, ticker)) { if wp, ok := sig.WinProbHint(); ok { winProb = wp + informed = true } } lossFrac := vol @@ -166,24 +174,34 @@ func (p *FinancePlanner) scoreThesis(ctx context.Context, ticker string, g domai // Intended position notional drives liquidity fit. When account equity is known // it is equity * the concentration cap; otherwise fall back to a fixed notional. notional := intendedNotional - if eq := p.scoring.Risk.AccountEquity; eq > 0 && p.scoring.Risk.MaxPositionPct > 0 { - notional = eq * p.scoring.Risk.MaxPositionPct + if eq := budget.AccountEquity; eq > 0 && budget.MaxPositionPct > 0 { + notional = eq * budget.MaxPositionPct + } + + // With no signal hint the win probability is a flat prior, and the + // volatility-scaled EV would rank the most volatile name highest. + evScore := finance.NeutralEVScore + if informed { + evScore = finance.EVScore(finance.ExpectedValue(winProb, winFrac, lossFrac)) } score := finance.ThesisScore{ Ticker: ticker, - ExpectedValueScore: finance.EVScore(finance.ExpectedValue(winProb, winFrac, lossFrac)), + ExpectedValueScore: evScore, RiskScore: finance.RiskScore(vol, maxDD), LiquidityFitScore: finance.LiquidityFit(avgDollarVol, notional), HorizonFitScore: finance.HorizonFit(goalHorizonDays(g), signalWindow(sig)), } score.Composite = finance.Composite(score, p.scoring.Weights) - score.Position = finance.PositionFractionKelly(winProb, winFrac, lossFrac, p.scoring.Risk) + score.Position = finance.PositionFractionKelly(winProb, winFrac, lossFrac, budget) score.Explain = fmt.Sprintf( "ev=%.2f risk=%.2f liq=%.2f horizon=%.2f -> composite=%.2f; suggested size %.0f%% of equity (%s%s)", score.ExpectedValueScore, score.RiskScore, score.LiquidityFitScore, score.HorizonFitScore, score.Composite, score.Position.SuggestedFraction*100, score.Position.SizingMethod, capSuffix(score.Position.BindingCap), ) + if budgetNote != "" { + score.Explain += "; risk budget per goal constraints: " + budgetNote + } impact, effort, risk := finance.MapToLevels(score) confidence := finance.CompositeToConfidence(score.Composite) diff --git a/internal/llm/finance_planner_test.go b/internal/llm/finance_planner_test.go index da3a423..0909114 100644 --- a/internal/llm/finance_planner_test.go +++ b/internal/llm/finance_planner_test.go @@ -2,6 +2,8 @@ package llm import ( "context" + "regexp" + "strconv" "strings" "sync" "testing" @@ -153,6 +155,93 @@ func TestFinancePlannerUsesHorizon(t *testing.T) { } } +var suggestedSizeRe = regexp.MustCompile(`suggested size (\d+(?:\.\d+)?)% of equity`) + +func suggestedSize(t *testing.T, m domain.RankedMove) float64 { + t.Helper() + match := suggestedSizeRe.FindStringSubmatch(m.Rationale) + if match == nil { + t.Fatalf("no suggested size in rationale: %q", m.Rationale) + } + size, err := strconv.ParseFloat(match[1], 64) + if err != nil { + t.Fatal(err) + } + return size +} + +func TestFinancePlannerHonorsRiskConstraints(t *testing.T) { + p := financeTestPlanner(t) + base, err := p.GeneratePlan(context.Background(), PlanRequest{Goal: investingGoal()}) + if err != nil { + t.Fatal(err) + } + + g := investingGoal() + g.Context.Constraints = []domain.Constraint{ + {Name: "max 10% drawdown", Kind: "drawdown_limit"}, + {Name: "conservative risk tolerance", Kind: "risk_tolerance"}, + } + constrained, err := p.GeneratePlan(context.Background(), PlanRequest{Goal: g}) + if err != nil { + t.Fatal(err) + } + + for i, m := range constrained.RankedMoves { + if !strings.Contains(m.Rationale, "risk budget per goal constraints: drawdown_limit 10%, risk_tolerance conservative") { + t.Errorf("rationale should name the binding constraints: %q", m.Rationale) + } + baseSize := suggestedSize(t, base.RankedMoves[i]) + gotSize := suggestedSize(t, m) + if gotSize >= baseSize { + t.Errorf("%s: constrained size %v%% should be below unconstrained %v%%", m.Key, gotSize, baseSize) + } + } +} + +func TestFinancePlannerNeutralEVWithoutSignal(t *testing.T) { + p := financeTestPlanner(t) + res, err := p.GeneratePlan(context.Background(), PlanRequest{Goal: investingGoal()}) + if err != nil { + t.Fatal(err) + } + // With no signal the win probability is a flat prior; the EV component must + // be neutral rather than rewarding the more volatile ticker. + for _, m := range res.RankedMoves { + if !strings.Contains(m.Rationale, "ev=0.50") { + t.Errorf("%s: flat-prior EV should be neutral 0.50, rationale: %q", m.Key, m.Rationale) + } + } +} + +func TestFinancePlannerHintAppliesOnlyToNamedTicker(t *testing.T) { + p := financeTestPlanner(t) + res, err := p.GeneratePlan(context.Background(), PlanRequest{ + Goal: investingGoal(), // ACME and GLOBEX + SignalKind: "valuation_change", + SignalPayload: map[string]any{ + "kind": "valuation_change", "ticker": "ACME", "metric": "pe", + "value": 14.0, "fair_value": 20.0, "gap_pct": 0.30, + }, + }) + if err != nil { + t.Fatal(err) + } + for _, m := range res.RankedMoves { + neutral := strings.Contains(m.Rationale, "ev=0.50") + switch m.Key { + case "thesis:ACME": + if neutral { + t.Errorf("ACME should be tilted by its valuation hint: %q", m.Rationale) + } + case "thesis:GLOBEX": + if !neutral { + t.Errorf("GLOBEX must not inherit ACME's hint: %q", m.Rationale) + } + } + } +} + func TestFinancePlannerConcurrent(t *testing.T) { p := financeTestPlanner(t) var wg sync.WaitGroup