First-class multi-strategy selection for the investing domain#16
Merged
Conversation
Decompose the win-probability prior into weighted components (neutral weights reproduce the blended baseline bit-for-bit), define StrategyParams lenses (value/momentum/defensive) that overlay scoring config with tighten-only risk caps, and add the internal/strategy package: goal-derived utility, hard-constraint admissibility filter, deterministic selection with hysteresis and outcome weights, and a materiality-quantized disagreement penalty. FinancePlanner gains a StrategyID that suffixes its name so strategy variants key the plan cache separately.
The investing pack declares its strategy lenses (value/momentum/defensive with regime applicability); wire builds one finance child per strategy — each TTL-cached under its strategy-suffixed name — and composes them under a SelectorPlanner that gates by regime, runs children in parallel, selects via the pure goal-utility math, applies the quantized disagreement penalty, and narrates the winner once in hybrid mode. The full competition lands in DecisionProvenance (selected_strategy, regime, strategy_candidates), the replan path threads the incumbent strategy for hysteresis, and policy gains a per-domain strategy block (enabled/disable/weights/params/margin), validated at load. Selection is opt-in via policy until backtest gates flip the default.
…rket regime Cross-lens comparability fix: a strategy lens ranks its theses with its own composite weights, but states confidence and the impact bucket on the domain's base weighting — without this, a risk-heavy lens structurally inflates its composite on calm tapes and wins every competition regardless of evidence. Lenses now differ only through their evidence channels (prior tilts, reward:risk), on one scale. Add the pure regime classifier (trailing-window monthly vol and drawdown for high_vol, Kaufman efficiency ratio for trend vs range, unknown below 40 bars and unknown gates nothing) and wire it into the selector: per-ticker point-in-time bars as of the sim/real clock, combined with high_vol dominance and trend/range majority.
…ance gates RunStrategyMatrix replays each scenario under every strategy mode (legacy single, each lens pinned via policy, full selector); Decision records the winning strategy and Report counts strategy share and winner flips. Four fixture scenarios with engineered bar series (year-long trend, sawtooth range, high-vol bleed, trend rolling into a crash) form the comparison corpus. Gates asserted in tests: the selector strictly beats the single planner's mean Brier across the corpus, never regresses any scenario beyond one transient regime-turn step, never reacts to noise the single planner ignored, stays within per-scenario flip budgets, hands high-vol tapes to the defensive lens, and is byte-deterministic across runs. dde backtest gains --compare-strategies to print the table for a scenario.
The backtest gates passed (selector strictly beats the single planner's mean Brier on the regime corpus, no per-scenario regression beyond one regime-turn step, no noise reactions, flip budgets held), so a domain that declares strategies now competes them by default. Policy remains the off switch: strategy.enabled: false restores the legacy single planner byte-for-byte.
dde strategy-fit mirrors dde calibrate for the strategy competition: Service.StrategySamples attributes every decisive outcome to the strategy that won the plan version (with the regime recorded at plan time), finance.FitStrategyWeights folds them into Laplace-shrunk, clamped multipliers per strategy and per strategy@regime (a 50% record is exactly the identity; buckets under 5 outcomes are omitted), and the command prints the policy snippet that installs them. WalkForwardStrategies evaluates the table out-of-sample by re-weighing recorded competitions offline — both arms run the same selection rule and differ only in the table — gated on weighted never scoring worse than unweighted.
…ens overrides Two review findings. The selector's disagreement haircut wrote into winner.RankedMoves in place; a winner served from the child TTL cache shares that backing array with the cached entry, so the penalty compounded on every cache hit (0.70 -> 0.65 -> 0.60 ...) and the drift re-triggered materiality each replan. The moves are now copied before the haircut, with a regression test proving repeated identical requests ship one stable confidence and the cached entry stays pristine. A policy strategy.params override previously replaced the lens's StrategyParams wholesale, so tuning one knob zeroed the prior, which Normalize turned into the neutral blend — silently erasing the lens's identity. Policy params now Merge field-by-field over the descriptor's declared params, the same partial-override semantics as every other policy knob; Name stays identity and is never overridable.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The engine now makes named investing strategies compete for every decision instead of routing to one planner. Three lenses — value, momentum, defensive — each produce a full candidate plan; a generic
SelectorPlannergates them by market regime, scores each candidate against a goal-derived utility (hard-constraint filter → utility argmax → deterministic tie-break with incumbent hysteresis), and ships the winner with the entire competition recorded in provenance (selected_strategy,regime,strategy_candidates[]with every loser's score and filter reason).How
internal/strategy(utility, admissibility, selection, disagreement penalty) andinternal/finance(lens params with tighten-only risk scaling, decomposed prior tilts, regime classifier, outcome-weight fitting) are deterministic and I/O-free, per the architecture rules. Neutral prior weights reproduce the legacy prior bit-for-bit.high_vol, efficiency ratio →trendvsrange); unknown gates nothing, defensive declares no regimes so the field can never be emptied.RunStrategyMatrix(dde backtest --compare-strategies). The competition is on by default only because the gates passed: selector strictly beats the single planner's mean Brier across the corpus, momentum wins the trend, defensive owns high-vol tapes, zero noise reactions, byte-deterministic.strategy.enabled: falsein policy restores the legacy planner exactly.dde strategy-fitfits Laplace-shrunk per-strategy (and per-regime) selection weights from recorded outcomes;WalkForwardStrategiesproves a table out-of-sample before it ships.Post-implementation review fixed two real bugs with regression tests: the disagreement penalty mutating a cache-shared moves slice (compounding confidence drift across cache hits), and policy lens overrides replacing params wholesale (a one-knob override silently neutralized the lens's prior).
Known follow-ups (non-blocking)
Strategy children fetch market data independently (~4× I/O per decision — latent until real vendors land); regime strings in pack descriptors are unvalidated; the penalty grid hardcodes investing's 0.05 materiality step;
id@regimeweight-key syntax needs a shared helper; the outcome-sampling loop duplicates the calibration sampler.