Skip to content

First-class multi-strategy selection for the investing domain#16

Merged
vingrad merged 7 commits into
mainfrom
feat/strategy-selection
Jun 12, 2026
Merged

First-class multi-strategy selection for the investing domain#16
vingrad merged 7 commits into
mainfrom
feat/strategy-selection

Conversation

@vingrad

@vingrad vingrad commented Jun 12, 2026

Copy link
Copy Markdown
Owner

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 SelectorPlanner gates 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

  • Pure math first: internal/strategy (utility, admissibility, selection, disagreement penalty) and internal/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.
  • Comparable claims: a lens ranks its theses with its own composite weights but states confidence and impact on the domain's shared base weighting — without this, a risk-heavy lens structurally inflates its composite on calm tapes and wins every competition regardless of evidence.
  • Regime gating: trailing-window classification (monthly vol / drawdown → high_vol, efficiency ratio → trend vs range); unknown gates nothing, defensive declares no regimes so the field can never be emptied.
  • Backtest-gated rollout: four engineered regime scenarios + 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: false in policy restores the legacy planner exactly.
  • Closed loop: dde strategy-fit fits Laplace-shrunk per-strategy (and per-regime) selection weights from recorded outcomes; WalkForwardStrategies proves 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@regime weight-key syntax needs a shared helper; the outcome-sampling loop duplicates the calibration sampler.

vingrad added 7 commits June 12, 2026 22:08
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.
@vingrad vingrad merged commit 0074b6c into main Jun 12, 2026
3 checks passed
@vingrad vingrad deleted the feat/strategy-selection branch June 12, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant