
Strategy Pivot Designer
- 912 installs
- 2.6k repo stars
- Updated August 4, 2026
- tradermonty/claude-trading-skills
strategy-pivot-designer is an agent skill that detects backtest iteration stagnation and generates structurally different trading strategy architectures so developers who run quantitative backtests escape local optima in
About
strategy-pivot-designer is a tradermonty agent skill that acts as a feedback loop in the Edge quantitative trading pipeline (hint-extractor, concept-synthesizer, strategy-designer, candidate-agent). When backtest scores plateau despite multiple refinement iterations, the skill diagnoses stagnation and proposes structurally different strategy pivot architectures—redesigning the strategy skeleton rather than tweaking parameters. Developers reach for strategy-pivot-designer when parameter tuning reaches a local optimum, when backtest metrics stop improving across iterations, or when they need agent-generated alternative strategy concepts to break out of repetitive optimization loops in automated trading research workflows.
- Detects iteration stagnation using accumulated backtest-expert evaluations
- Generates structurally different strategy skeletons rather than parameter tweaks
- Outputs research-only and export-ready YAML drafts compatible with candidate-agent
- Produces human-readable pivot analysis report and diagnosis JSON
- Breaks local optima in the Edge pipeline (hint-extractor → concept-synthesizer → strategy-designer → candidate-agent)
Strategy Pivot Designer by the numbers
- 912 all-time installs (skills.sh)
- +77 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,197 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tradermonty/claude-trading-skills --skill strategy-pivot-designerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 912 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | August 4, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do you pivot a stagnating backtest strategy architecture?
Automatically detect backtest stagnation and generate structurally different trading strategy architectures instead of endlessly tuning parameters.
Who is it for?
Quantitative developers running automated backtest iteration pipelines who need structural strategy redesigns when parameter tuning plateaus.
Skip if: Casual traders without backtesting infrastructure, or teams seeking only incremental parameter optimization on a working strategy.
When should I use this skill?
Backtest scores plateau after multiple iterations, parameter tuning stalls, or user asks for a structural strategy pivot in the Edge pipeline.
What you get
Structurally different strategy pivot proposals, redesigned strategy skeletons, and breakout plans from backtest local optima.
- Strategy pivot proposals
- Redesigned strategy architecture concepts
Files
Strategy Pivot Designer
Overview
Detect when a strategy's backtest iteration loop has stalled and propose structurally different strategy architectures. This skill acts as the feedback loop for the Edge pipeline (hint-extractor -> concept-synthesizer -> strategy-designer -> candidate-agent), breaking out of local optima by redesigning the strategy's skeleton rather than tweaking parameters.
When to Use
- Backtest scores have plateaued despite multiple refinement iterations.
- A strategy shows signs of overfitting (high in-sample, low robustness).
- Transaction costs defeat the strategy's thin edge.
- Tail risk or drawdown exceeds acceptable thresholds.
- You want to explore fundamentally different strategy architectures for the same market hypothesis.
Prerequisites
- Python 3.9+
PyYAML- Iteration history JSON (accumulated backtest-expert evaluations)
- Source strategy draft YAML (from edge-strategy-designer)
Output
pivot_drafts/research_only/*.yaml— strategy_draft compatible YAML proposalspivot_drafts/exportable/*.yaml— export-ready drafts + ticket YAML for candidate-agentpivot_report_*.md— human-readable pivot analysispivot_manifest_*.json— metadata for all generated filespivot_diagnosis_*.json— stagnation detection results
Workflow
1. Accumulate backtest evaluation results into an iteration history file using --append-eval. 2. Run stagnation detection on the history to identify triggers (plateau, overfitting, cost defeat, tail risk). 3. If stagnation detected, generate pivot proposals using three techniques: assumption inversion, archetype switch, objective reframe. 4. Review ranked proposals (scored by quality potential + novelty). 5. For exportable proposals, ticket YAML is ready for edge-candidate-agent pipeline. 6. For research_only proposals, manual strategy design needed before pipeline integration. 7. Feed the selected pivot draft back into backtest-expert for the next iteration cycle.
Quick Commands
Append a backtest evaluation to history (creates history if new):
python3 skills/strategy-pivot-designer/scripts/detect_stagnation.py \
--append-eval reports/backtest_eval_2026-02-10_120000.json \
--history reports/iteration_history.json \
--strategy-id draft_edge_concept_breakout_behavior_riskon_core \
--changes "Widened stop_loss from 5% to 7%"Detect stagnation:
python3 skills/strategy-pivot-designer/scripts/detect_stagnation.py \
--history reports/iteration_history.json \
--output-dir reports/Generate pivot proposals:
python3 skills/strategy-pivot-designer/scripts/generate_pivots.py \
--diagnosis reports/pivot_diagnosis_*.json \
--strategy reports/edge_strategy_drafts/draft_*.yaml \
--max-pivots 3 \
--output-dir reports/Resources
skills/strategy-pivot-designer/scripts/detect_stagnation.pyskills/strategy-pivot-designer/scripts/generate_pivots.pyreferences/stagnation_triggers.mdreferences/strategy_archetypes.mdreferences/pivot_techniques.mdreferences/pivot_proposal_schema.mdskills/backtest-expert/scripts/evaluate_backtest.pyskills/edge-strategy-designer/scripts/design_strategy_drafts.py
Pivot Report: {strategy_id}
Generated: {generated_at_utc} Source Strategy: {source_strategy_id} Diagnosis: {diagnosis_summary}
---
Stagnation Diagnosis
Triggers Fired: {triggers_count} Recommendation: {recommendation}
{triggers_detail}
Score Trajectory
{score_trajectory}
---
Pivot Proposals
{pivot_proposals}
Proposal: {proposal_id}
Technique: {pivot_technique} Target Archetype: {target_archetype} Category: {category}
What Changed:
- Signal: {signal_change}
- Horizon: {horizon_change}
- Risk: {risk_change}
Why: {why_explanation}
Targeted Triggers: {targeted_triggers}
Scores:
- Quality Potential: {quality_potential}
- Novelty: {novelty}
- Combined: {combined}
Expected Failure Modes: {failure_modes}
---
Summary
| Rank | Proposal | Archetype | Combined | Category |
|---|
{summary_table}
---
Next Steps
1. Review proposals and select the most promising pivot direction 2. For exportable proposals: ticket YAML is ready for edge-candidate-agent pipeline 3. For research_only proposals: manual strategy design needed before pipeline integration 4. Run backtest-expert on the selected pivot draft to begin the next iteration cycle
Pivot Proposal Schema
Pivot drafts are strategy_draft-compatible YAML files with an additive pivot_metadata extension. Downstream tools can ignore the extension and treat pivots as regular strategy drafts.
Required Fields (strategy_draft compatible)
# Identity
id: pivot_{source_archetype}_to_{target_archetype}_{timestamp}
as_of: "YYYY-MM-DD"
concept_id: <original concept_id from source strategy>
variant: research_probe
name: "<Target archetype name> (pivoted from <source>)"
# Classification
hypothesis_type: <from target archetype>
mechanism_tag: <from target archetype>
regime: <inherited from source or adjusted>
export_ready_v1: <true only if entry_family in EXPORTABLE_FAMILIES>
entry_family: <from target archetype, or research_only>
# Entry
entry:
conditions: [<list of entry condition strings>]
trend_filter: [<list of trend filter strings>]
note: "Probe setup with small size for hypothesis validation."
# Exit
exit:
stop_loss_pct: <float, e.g. 0.04>
take_profit_rr: <float, e.g. 2.0>
time_stop_days: <int>
# Risk
risk:
position_sizing: fixed_risk
risk_per_trade: <float, e.g. 0.005>
max_positions: <int>
max_sector_exposure: <float, e.g. 0.3>
# Validation
validation_plan:
period: "2016-01-01 to latest"
entry_timing: next_open
hold_days: [<list of int>]
success_criteria:
- "<criterion 1>"
- "<criterion 2>"
# Context
thesis: "<strategy thesis>"
invalidation_signals: [<list of invalidation signals>]Pivot Metadata Extension (additive)
pivot_metadata:
pivot_technique: <assumption_inversion | archetype_switch | objective_reframe>
source_strategy_id: <id of the original strategy draft>
target_archetype: <archetype id from catalog>
what_changed:
signal: "<description of signal change>"
horizon: "<description of horizon change>"
risk: "<description of risk change>"
why: "<explanation of why this pivot addresses the trigger>"
targeted_triggers: [<list of trigger IDs this pivot addresses>]
expected_failure_modes:
- "<potential failure mode 1>"
- "<potential failure mode 2>"
scores:
quality_potential: <float 0-1>
novelty: <float 0-1>
combined: <float 0-1>Output Directory Structure
{output-dir}/
├── pivot_drafts/
│ ├── research_only/
│ │ └── pivot_{source}_{archetype}_{timestamp}.yaml
│ └── exportable/
│ ├── pivot_{source}_{archetype}_{timestamp}.yaml
│ └── ticket_{source}_{archetype}_{timestamp}.yaml
├── pivot_report_{strategy_id}_{timestamp}.md
└── pivot_manifest_{strategy_id}_{timestamp}.jsonExportable Ticket Generation
Tickets are generated only when entry_family is in EXPORTABLE_FAMILIES:
pivot_breakoutgap_up_continuation
Ticket format follows build_export_ticket() from design_strategy_drafts.py:222.
Manifest Schema
{
"generated_at_utc": "ISO-8601",
"strategy_id": "source strategy id",
"diagnosis_file": "path to diagnosis JSON",
"strategy_file": "path to source draft YAML",
"triggers_fired": ["trigger_id_1", "trigger_id_2"],
"total_pivots_generated": 3,
"exportable_count": 1,
"research_only_count": 2,
"drafts": [
{
"id": "pivot_...",
"path": "relative path",
"category": "research_only | exportable",
"ticket_path": "relative path or null",
"scores": {"quality_potential": 0.7, "novelty": 0.8, "combined": 0.74}
}
],
"errors": []
}Pivot Techniques
Three systematic techniques for generating structurally different strategy proposals when the backtest iteration loop stalls.
---
Technique 1: Assumption Inversion
Principle: Identify the core assumptions of the current strategy and invert them based on which stagnation trigger fired.
Inversion Rules by Trigger
| Trigger | Module | Inversion |
|---|---|---|
cost_defeat | horizon | Shorten holding period (reduce friction exposure) |
cost_defeat | universe | Shift to higher-liquidity names (reduce slippage) |
tail_risk | risk | Tighten stops, reduce position size, add max drawdown cap |
tail_risk | structure | Move toward market-neutral or hedged approach |
improvement_plateau | signal | Change signal source (price-based → volume/fundamental) |
improvement_plateau | entry | Change entry timing mechanism |
overfitting_proxy | complexity | Reduce parameters, use simpler entry model |
overfitting_proxy | validation | Extend test period, add regime subsamples |
Application Process
1. Read the trigger(s) fired from the diagnosis 2. Look up the corresponding inversion rules 3. Apply each inversion to the current strategy's modules 4. Generate a new draft with the inverted assumptions
---
Technique 2: Archetype Switch
Principle: Jump to a structurally different strategy archetype that addresses the same market inefficiency from a different angle.
Process
1. Identify the current strategy's archetype from its hypothesis_type, mechanism_tag, and entry_family 2. Look up compatible pivot targets in the archetype catalog's compatible_pivots_from mapping 3. For each compatible target, generate a draft using the target archetype's default modules 4. Preserve the original concept_id and thesis where applicable
Archetype Identification Rules
| hypothesis_type | mechanism_tag | entry_family | Archetype |
|---|---|---|---|
| breakout | behavior | pivot_breakout | trend_following_breakout |
| breakout | structural | pivot_breakout | volatility_contraction |
| mean_reversion | statistical | * | mean_reversion_pullback OR statistical_pairs |
| mean_reversion | information | * | event_driven_fade |
| earnings_drift | information | gap_up_continuation | earnings_drift_pead |
| momentum | behavior | * | sector_rotation_momentum |
| regime | macro | * | regime_conditional_carry |
When multiple archetypes match, prefer the one whose entry_family matches the current draft.
---
Technique 3: Objective Reframe
Principle: Change what "success" means for the strategy, shifting the optimization target.
Reframe Options
| Current Objective | Reframed To | Rationale |
|---|---|---|
| Maximize Sharpe ratio | Minimize max drawdown | Better tail risk control |
| Maximize expectancy | Maximize win rate | More consistent (smaller but more frequent wins) |
| Maximize total return | Maximize risk-adjusted return per unit exposure | Capital efficiency focus |
Application
1. Read the current strategy's success_criteria from validation_plan 2. Select a reframe based on the trigger:
tail_risk→ drawdown minimizationcost_defeat→ win rate maximization (smaller targets, tighter stops)improvement_plateau→ risk-adjusted return (different efficiency lens)overfitting_proxy→ simpler criteria (fewer optimization targets)
3. Adjust exit rules and risk parameters to align with the new objective 4. Update success_criteria in the generated draft
---
Technique Selection by Trigger
| Trigger | Primary Technique | Secondary Technique |
|---|---|---|
improvement_plateau | Archetype Switch | Assumption Inversion |
overfitting_proxy | Assumption Inversion | Objective Reframe |
cost_defeat | Assumption Inversion | Archetype Switch |
tail_risk | Assumption Inversion | Objective Reframe |
The generate_pivots script applies all applicable techniques and lets scoring determine the best candidates.
---
Scoring
Quality Potential (0-1)
Heuristic score based on how well a target archetype addresses the specific trigger. Defined in QUALITY_TABLE dictionary mapping (trigger, archetype) → score.
Novelty (0-1)
Jaccard distance between the module sets of the original strategy and the proposed pivot:
novelty = 1 - |A ∩ B| / |A ∪ B|Where A and B are sets of (key, value) pairs constructed from:
("hypothesis_type", <value>)("mechanism_tag", <value>)("regime", <value>)("entry_family", <value>)("horizon", <"short"|"medium"|"long">)— time_stop_days ≤ 7: short, ≤ 30: medium, else: long("risk_style", <"tight"|"normal"|"wide">)— stop_loss_pct ≤ 0.04: tight, ≤ 0.08: normal, else: wide
Combined Score
combined = 0.6 * quality_potential + 0.4 * noveltyTiebreak Rules (deterministic)
1. Combined score descending 2. Novelty descending (prefer more novel proposals) 3. Proposal ID alphabetical ascending (deterministic final sort)
Diversity Constraint
Maximum 1 proposal per target archetype. If multiple techniques produce candidates for the same archetype, keep the one with the highest combined score.
Stagnation Triggers
Four deterministic triggers detect when a strategy's backtest iteration loop has stalled. Each trigger maps directly to fields in evaluate_backtest.py output accumulated in an iteration history file.
Field Reference Mapping
| Data Point | JSON Path | Type |
|---|---|---|
| total_score | eval.total_score | int |
| Expectancy dim score | eval.dimensions (lookup by name == "Expectancy") | int |
| Risk Management dim score | eval.dimensions (lookup by name == "Risk Management") | int |
| Robustness dim score | eval.dimensions (lookup by name == "Robustness") | int |
| red_flag IDs | [f["id"] for f in eval.red_flags] | list[str] |
| expectancy | eval.expectancy | float |
| profit_factor | eval.profit_factor | float |
| slippage_tested | eval.inputs.slippage_tested | bool |
| max_drawdown_pct | eval.inputs.max_drawdown_pct | float |
Note: Dimension scores are looked up by name field, not by array index. This makes the system resilient to future dimension reordering or additions.
---
Trigger 1: Improvement Plateau
ID: improvement_plateau Severity: high
Condition: Over the last K iterations (default K=3), the range of total_score values is less than threshold (default 3).
Rationale: When score stops moving despite parameter changes, the strategy architecture itself has reached a local maximum.
Evidence fields:
last_k_scores: list of total_score values for the last K iterationsscore_range: max - min of last_k_scoresthreshold: the configured threshold
Minimum iterations: K (default 3). Cannot fire with fewer iterations.
---
Trigger 2: Overfitting Proxy
ID: overfitting_proxy Severity: medium
Condition: ALL of the following must be true: 1. Expectancy dimension score >= 15 2. Risk Management dimension score >= 15 3. Robustness dimension score < 10 4. red_flags IDs contain over_optimized OR short_test_period
Rationale: High in-sample performance combined with low robustness and red flags for curve-fitting suggests the strategy is optimized to historical noise rather than genuine edge.
Minimum iterations: 2 (needs at least some iteration history to be meaningful).
---
Trigger 3: Cost Defeat
ID: cost_defeat Severity: medium
Condition: ALL of the following must be true: 1. eval.expectancy < 0.3 2. eval.profit_factor < 1.3 3. eval.inputs.slippage_tested == True
Rationale: When expectancy and profit factor are thin AND slippage has already been modeled, the edge is too narrow to survive real-world execution costs. Further parameter tuning cannot create edge that isn't there.
Minimum iterations: 2 (requires slippage to have been tested, which implies at least one refinement cycle).
---
Trigger 4: Tail Risk
ID: tail_risk Severity: high
Condition: EITHER of the following: 1. eval.inputs.max_drawdown_pct > 35 2. Risk Management dimension score <= 5
Rationale: Extreme drawdown or catastrophically low risk management scores indicate structural risk problems that parameter tuning alone cannot fix. Requires architectural changes to risk module.
Minimum iterations: 1 (can fire on the very first evaluation — if drawdown is extreme, early pivot is warranted).
---
Recommendation Decision Table
Evaluated in priority order (first match wins):
| Priority | Condition | Recommendation |
|---|---|---|
| 1 | Latest total_score < 30 AND iterations >= 3 AND score trajectory (last 3) is monotonically non-increasing | abandon |
| 2 | triggers_fired has at least 1 entry | pivot |
| 3 | None of the above | continue |
Note: abandon is evaluated first. This catches cases where scores are consistently terrible but may not trip any specific trigger threshold (e.g., all scores hovering around 25 with no single trigger matching).
Strategy Archetypes Catalog
Eight canonical strategy archetypes cover the primary systematic trading approaches. Each archetype defines default modules, typical failure modes, and compatible pivot targets.
---
1. Trend Following Breakout (trend_following_breakout)
Description: Buy when price breaks above a consolidation range with volume confirmation. Ride the trend with trailing stops.
Default Modules:
- hypothesis_type: breakout
- mechanism_tag: behavior
- entry_family: pivot_breakout
- horizon: medium (10-60 days)
- risk_style: wide (trailing stop 8-12%)
Typical Failure Modes:
- Whipsaws in range-bound markets
- Late entries after extended moves
- Gap-downs through stop levels
Compatible Pivots From: mean_reversion_pullback, volatility_contraction, sector_rotation_momentum
---
2. Mean Reversion Pullback (mean_reversion_pullback)
Description: Buy oversold pullbacks within an established uptrend. Profit from reversion to mean.
Default Modules:
- hypothesis_type: mean_reversion
- mechanism_tag: statistical
- entry_family: research_only
- horizon: short (3-14 days)
- risk_style: tight (stop 3-5%)
Typical Failure Modes:
- Catching falling knives in trend changes
- Insufficient recovery within time stop
- Sector-wide selloffs overwhelming individual stock mean reversion
Compatible Pivots From: trend_following_breakout, volatility_contraction, earnings_drift_pead
---
3. Earnings Drift PEAD (earnings_drift_pead)
Description: Exploit post-earnings announcement drift by entering after significant earnings surprises.
Default Modules:
- hypothesis_type: earnings_drift
- mechanism_tag: information
- entry_family: gap_up_continuation
- horizon: medium (5-30 days)
- risk_style: normal (stop 5-8%)
Typical Failure Modes:
- One-day gap fills that reverse the drift
- Market-wide selloffs overwhelming individual stock drift
- Late entry after drift has already occurred
Compatible Pivots From: event_driven_fade, trend_following_breakout, mean_reversion_pullback
---
4. Volatility Contraction (volatility_contraction)
Description: Enter when volatility contracts to historical lows (VCP pattern), anticipating expansion in the trend direction.
Default Modules:
- hypothesis_type: breakout
- mechanism_tag: structural
- entry_family: pivot_breakout
- horizon: medium (10-40 days)
- risk_style: tight (stop 3-6%)
Typical Failure Modes:
- False breakouts from contraction zones
- Extended contraction periods draining capital via time stops
- Volatility expanding in the wrong direction
Compatible Pivots From: trend_following_breakout, mean_reversion_pullback, statistical_pairs
---
5. Regime Conditional Carry (regime_conditional_carry)
Description: Hold positions only during favorable macro regimes, using regime detection to filter entries.
Default Modules:
- hypothesis_type: regime
- mechanism_tag: macro
- entry_family: research_only
- horizon: long (30-120 days)
- risk_style: normal (stop 5-8%)
Typical Failure Modes:
- Regime detection lag causing late entries/exits
- Whipsaws during regime transitions
- Underperformance during trending markets due to conservative entry timing
Compatible Pivots From: sector_rotation_momentum, event_driven_fade, statistical_pairs
---
6. Sector Rotation Momentum (sector_rotation_momentum)
Description: Rotate into sectors showing relative strength momentum, exit when momentum fades.
Default Modules:
- hypothesis_type: momentum
- mechanism_tag: behavior
- entry_family: research_only
- horizon: medium (20-60 days)
- risk_style: normal (stop 5-8%)
Typical Failure Modes:
- Momentum reversals during sector rotation shifts
- Crowded trades in popular sectors
- Correlation spikes during market stress nullifying diversification
Compatible Pivots From: trend_following_breakout, regime_conditional_carry, earnings_drift_pead
---
7. Event Driven Fade (event_driven_fade)
Description: Fade overreactions to scheduled or unscheduled events, betting on mean reversion after the initial move.
Default Modules:
- hypothesis_type: mean_reversion
- mechanism_tag: information
- entry_family: research_only
- horizon: short (1-10 days)
- risk_style: tight (stop 2-5%)
Typical Failure Modes:
- Events that represent genuine regime changes (not overreactions)
- Cascading events that compound the initial move
- Liquidity gaps during extreme events
Compatible Pivots From: earnings_drift_pead, mean_reversion_pullback, volatility_contraction
---
8. Statistical Pairs (statistical_pairs)
Description: Trade cointegrated pairs, going long the undervalued and short the overvalued member when spread deviates from equilibrium.
Default Modules:
- hypothesis_type: mean_reversion
- mechanism_tag: statistical
- entry_family: research_only
- horizon: medium (10-30 days)
- risk_style: normal (stop via z-score threshold)
Typical Failure Modes:
- Cointegration breakdown due to fundamental changes
- Extended spread divergence exceeding risk limits
- Execution risk on short leg (borrow costs, locate difficulty)
Compatible Pivots From: mean_reversion_pullback, volatility_contraction, regime_conditional_carry
---
Archetype Compatibility Matrix
| Source Archetype | Compatible Pivot Targets |
|---|---|
| trend_following_breakout | mean_reversion_pullback, volatility_contraction, sector_rotation_momentum |
| mean_reversion_pullback | trend_following_breakout, volatility_contraction, earnings_drift_pead |
| earnings_drift_pead | event_driven_fade, trend_following_breakout, mean_reversion_pullback |
| volatility_contraction | trend_following_breakout, mean_reversion_pullback, statistical_pairs |
| regime_conditional_carry | sector_rotation_momentum, event_driven_fade, statistical_pairs |
| sector_rotation_momentum | trend_following_breakout, regime_conditional_carry, earnings_drift_pead |
| event_driven_fade | earnings_drift_pead, mean_reversion_pullback, volatility_contraction |
| statistical_pairs | mean_reversion_pullback, volatility_contraction, regime_conditional_carry |
#!/usr/bin/env python3
"""Detect backtest iteration stagnation for strategy pivot decisions.
Runs four deterministic triggers against an iteration history file and
returns a diagnosis with a recommendation of *continue*, *pivot*, or
*abandon*. See ``references/stagnation_triggers.md`` for the full
specification.
"""
from __future__ import annotations
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def validate_history(history: dict[str, Any]) -> None:
"""Validate iteration history structure.
Raises ``ValueError`` when required fields are missing or malformed.
"""
sid = history.get("strategy_id")
if not sid or not isinstance(sid, str):
raise ValueError("strategy_id must be a non-empty string")
iterations = history.get("iterations")
if not iterations or not isinstance(iterations, list) or len(iterations) == 0:
raise ValueError("iterations must be a non-empty list")
for idx, it in enumerate(iterations):
if "eval" not in it:
raise ValueError(f"iterations[{idx}] missing 'eval' key")
ev = it["eval"]
# total_score is required for trajectory and recommendation logic
if "total_score" not in ev:
raise ValueError(f"iterations[{idx}].eval missing 'total_score'")
if "dimensions" not in ev:
raise ValueError(f"iterations[{idx}].eval missing 'dimensions' list")
# Validate each dimension has required name/score keys
for dim_idx, dim in enumerate(ev["dimensions"]):
if not isinstance(dim, dict):
raise ValueError(f"iterations[{idx}].eval.dimensions[{dim_idx}] must be a mapping")
if "name" not in dim:
raise ValueError(f"iterations[{idx}].eval.dimensions[{dim_idx}] missing 'name'")
if "score" not in dim:
raise ValueError(f"iterations[{idx}].eval.dimensions[{dim_idx}] missing 'score'")
if "red_flags" not in ev:
raise ValueError(f"iterations[{idx}].eval missing 'red_flags' list")
if "inputs" not in ev:
raise ValueError(f"iterations[{idx}].eval missing 'inputs' dict")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def get_dimension_score(eval_data: dict[str, Any], name: str) -> int | None:
"""Look up a dimension score by *name*. Returns ``None`` if not found."""
for dim in eval_data.get("dimensions", []):
if dim.get("name") == name:
return dim.get("score")
return None
def get_red_flag_ids(eval_data: dict[str, Any]) -> list[str]:
"""Extract red-flag IDs from *eval_data*."""
return [f["id"] for f in eval_data.get("red_flags", []) if isinstance(f, dict) and "id" in f]
# ---------------------------------------------------------------------------
# Trigger detectors
# ---------------------------------------------------------------------------
def detect_plateau(
iterations: list[dict],
k: int = 3,
threshold: int = 3,
) -> dict | None:
"""Detect improvement plateau over the last *k* iterations.
Returns a trigger dict when the range of ``total_score`` values in the
last *k* iterations is strictly less than *threshold*.
"""
if len(iterations) < k:
return None
last_k = [it["eval"]["total_score"] for it in iterations[-k:]]
score_range = max(last_k) - min(last_k)
if score_range < threshold:
return {
"trigger": "improvement_plateau",
"severity": "high",
"evidence": {
"last_k_scores": last_k,
"score_range": score_range,
"threshold": threshold,
},
"message": (
f"{k} consecutive iterations with <{threshold}-point "
f"score improvement (range: {score_range})"
),
}
return None
def detect_overfitting_proxy(eval_data: dict[str, Any]) -> dict | None:
"""Detect overfitting proxy.
Fires when Expectancy >= 15, Risk Management >= 15, Robustness < 10,
AND red-flags contain ``over_optimized`` or ``short_test_period``.
"""
exp_score = get_dimension_score(eval_data, "Expectancy")
risk_score = get_dimension_score(eval_data, "Risk Management")
rob_score = get_dimension_score(eval_data, "Robustness")
red_flag_ids = get_red_flag_ids(eval_data)
if (
exp_score is not None
and exp_score >= 15
and risk_score is not None
and risk_score >= 15
and rob_score is not None
and rob_score < 10
and ("over_optimized" in red_flag_ids or "short_test_period" in red_flag_ids)
):
return {
"trigger": "overfitting_proxy",
"severity": "medium",
"evidence": {
"expectancy_score": exp_score,
"risk_mgmt_score": risk_score,
"robustness_score": rob_score,
"red_flag_ids": red_flag_ids,
},
"message": ("High in-sample scores with low robustness and curve-fitting red flags"),
}
return None
def detect_cost_defeat(eval_data: dict[str, Any]) -> dict | None:
"""Detect cost defeat.
Fires when ``expectancy < 0.3``, ``profit_factor < 1.3``, and
``slippage_tested`` is ``True``.
"""
expectancy = eval_data.get("expectancy", 999)
profit_factor = eval_data.get("profit_factor", 999)
inputs = eval_data.get("inputs", {})
slippage_tested = inputs.get("slippage_tested", False)
if expectancy < 0.3 and profit_factor < 1.3 and slippage_tested:
return {
"trigger": "cost_defeat",
"severity": "medium",
"evidence": {
"expectancy": expectancy,
"profit_factor": profit_factor,
"slippage_tested": slippage_tested,
},
"message": (
f"Edge too thin after costs (expectancy={expectancy:.3f}, PF={profit_factor:.2f})"
),
}
return None
def detect_tail_risk(eval_data: dict[str, Any]) -> dict | None:
"""Detect tail risk.
Fires when ``max_drawdown_pct > 35`` OR Risk Management score <= 5.
"""
inputs = eval_data.get("inputs", {})
max_dd = inputs.get("max_drawdown_pct", 0)
risk_score = get_dimension_score(eval_data, "Risk Management")
reasons: list[str] = []
if max_dd > 35:
reasons.append(f"max_drawdown_pct={max_dd}% > 35%")
if risk_score is not None and risk_score <= 5:
reasons.append(f"Risk Management score={risk_score} <= 5")
if reasons:
return {
"trigger": "tail_risk",
"severity": "high",
"evidence": {
"max_drawdown_pct": max_dd,
"risk_mgmt_score": risk_score,
},
"message": f"Structural risk: {'; '.join(reasons)}",
}
return None
# ---------------------------------------------------------------------------
# Main pipeline
# ---------------------------------------------------------------------------
def _determine_recommendation(
triggers_fired: list[dict],
score_trajectory: list[int],
iteration_count: int,
) -> str:
"""Apply the recommendation decision table (priority order).
1. ``abandon`` -- iterations >= 3, latest score < 30, last-3 monotonically
non-increasing.
2. ``pivot`` -- at least one trigger fired.
3. ``continue`` -- default.
"""
# Priority 1: abandon
if iteration_count >= 3 and score_trajectory[-1] < 30 and len(score_trajectory) >= 3:
last_3 = score_trajectory[-3:]
if all(last_3[i] >= last_3[i + 1] for i in range(len(last_3) - 1)):
return "abandon"
# Priority 2: pivot
if triggers_fired:
return "pivot"
# Priority 3: continue
return "continue"
def run_all_triggers(
history: dict[str, Any],
plateau_k: int = 3,
plateau_threshold: int = 3,
) -> dict[str, Any]:
"""Run all stagnation triggers and return a full diagnosis dict."""
validate_history(history)
iterations = history["iterations"]
latest_eval = iterations[-1]["eval"]
latest_inputs = latest_eval.get("inputs", {})
triggers_fired: list[dict] = []
# Plateau -- needs >= k iterations
plateau = detect_plateau(iterations, k=plateau_k, threshold=plateau_threshold)
if plateau:
triggers_fired.append(plateau)
# Overfitting proxy -- needs >= 2 iterations
if len(iterations) >= 2:
overfit = detect_overfitting_proxy(latest_eval)
if overfit:
triggers_fired.append(overfit)
# Cost defeat -- needs >= 2 iterations
if len(iterations) >= 2:
cost = detect_cost_defeat(latest_eval)
if cost:
triggers_fired.append(cost)
# Tail risk -- can fire on a single iteration
tail = detect_tail_risk(latest_eval)
if tail:
triggers_fired.append(tail)
# Score trajectory
score_trajectory = [it["eval"]["total_score"] for it in iterations]
# Recommendation
recommendation = _determine_recommendation(triggers_fired, score_trajectory, len(iterations))
# Dimension scores summary from latest eval
dim_scores: dict[str, int] = {}
for dim in latest_eval.get("dimensions", []):
dim_scores[dim["name"]] = dim["score"]
return {
"strategy_id": history["strategy_id"],
"stagnation_detected": recommendation != "continue",
"triggers_fired": triggers_fired,
"iteration_count": len(iterations),
"score_trajectory": score_trajectory,
"latest_eval_summary": {
"total_score": latest_eval.get("total_score"),
"verdict": latest_eval.get("verdict"),
"dimension_scores": dim_scores,
"red_flag_ids": get_red_flag_ids(latest_eval),
"expectancy": latest_eval.get("expectancy"),
"profit_factor": latest_eval.get("profit_factor"),
"max_drawdown_pct": latest_inputs.get("max_drawdown_pct"),
"slippage_tested": latest_inputs.get("slippage_tested"),
},
"recommendation": recommendation,
"diagnosed_at_utc": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
}
# ---------------------------------------------------------------------------
# Append eval
# ---------------------------------------------------------------------------
def append_eval(
eval_path: Path,
history_path: Path,
strategy_id: str,
changes: str = "",
) -> dict[str, Any]:
"""Append a backtest eval result to an iteration history file.
Creates the history file if it does not exist.
"""
eval_data = json.loads(eval_path.read_text())
if history_path.exists():
history = json.loads(history_path.read_text())
else:
history = {"strategy_id": strategy_id, "iterations": []}
# Ensure strategy_id matches — refuse to mix histories
existing_id = history.get("strategy_id")
if existing_id != strategy_id:
raise ValueError(
f"strategy_id mismatch: history has '{existing_id}' "
f"but --strategy-id is '{strategy_id}'. "
f"Use a different history file or correct the strategy_id."
)
next_iteration = len(history["iterations"]) + 1
iteration_entry = {
"iteration": next_iteration,
"timestamp": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"changes_from_previous": changes or f"Iteration {next_iteration}",
"eval": eval_data,
}
history["iterations"].append(iteration_entry)
history_path.write_text(json.dumps(history, indent=2, default=str))
return history
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(description="Detect stagnation in backtest iteration history.")
# Detection mode
parser.add_argument("--history", required=True, help="Path to iteration history JSON")
parser.add_argument("--output-dir", default="reports/", help="Output directory")
# Trigger parameters
parser.add_argument(
"--plateau-k",
type=int,
default=3,
help="Window size for plateau detection (default: 3)",
)
parser.add_argument(
"--plateau-threshold",
type=int,
default=3,
help="Score range threshold for plateau (default: 3)",
)
# Append mode
parser.add_argument(
"--append-eval",
default=None,
help="Path to backtest eval JSON to append",
)
parser.add_argument(
"--strategy-id",
default=None,
help="Strategy ID (required for --append-eval)",
)
parser.add_argument(
"--changes",
default="",
help="Description of changes from previous iteration",
)
return parser.parse_args()
def main() -> int:
"""Entry point for CLI invocation."""
args = parse_args()
history_path = Path(args.history).resolve()
# --- Append mode ---
if args.append_eval:
if not args.strategy_id:
print("[ERROR] --strategy-id required when using --append-eval")
return 1
eval_path = Path(args.append_eval).resolve()
if not eval_path.exists():
print(f"[ERROR] eval file not found: {eval_path}")
return 1
try:
history = append_eval(eval_path, history_path, args.strategy_id, args.changes)
except (ValueError, json.JSONDecodeError) as exc:
print(f"[ERROR] {exc}")
return 1
print(f"[OK] Appended iteration {len(history['iterations'])} to {history_path}")
return 0
# --- Detection mode ---
if not history_path.exists():
print(f"[ERROR] history file not found: {history_path}")
return 1
try:
history = json.loads(history_path.read_text())
except json.JSONDecodeError as exc:
print(f"[ERROR] Invalid JSON in history file: {exc}")
return 1
try:
diagnosis = run_all_triggers(
history,
plateau_k=args.plateau_k,
plateau_threshold=args.plateau_threshold,
)
except ValueError as exc:
print(f"[ERROR] {exc}")
return 1
output_dir = Path(args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
strategy_id = diagnosis["strategy_id"]
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
filename = f"pivot_diagnosis_{strategy_id}_{timestamp}.json"
output_path = output_dir / filename
output_path.write_text(json.dumps(diagnosis, indent=2, default=str))
print(f"Recommendation: {diagnosis['recommendation']}")
if diagnosis["triggers_fired"]:
print(f"Triggers: {len(diagnosis['triggers_fired'])}")
for t in diagnosis["triggers_fired"]:
print(f" [{t['severity'].upper()}] {t['trigger']}: {t['message']}")
print(f"Output: {output_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Generate strategy pivot proposals from stagnation diagnosis."""
from __future__ import annotations
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
# --- Constants ---
DEFAULT_EXPORTABLE_FAMILIES = {
"pivot_breakout",
"gap_up_continuation",
"panic_reversal",
"news_reaction",
}
ARCHETYPE_CATALOG = {
"trend_following_breakout": {
"hypothesis_type": "breakout",
"mechanism_tag": "behavior",
"entry_family": "pivot_breakout",
"default_horizon_days": 30,
"default_stop_loss_pct": 0.08,
"default_take_profit_rr": 3.0,
"default_time_stop_days": 20,
"default_conditions": ["close > high20_prev", "rel_volume >= 1.5", "close > ma50 > ma200"],
"default_trend_filter": ["price > sma_200", "price > sma_50", "sma_50 > sma_200"],
"compatible_pivots_from": [
"mean_reversion_pullback",
"volatility_contraction",
"sector_rotation_momentum",
],
"typical_failure_modes": [
"Whipsaws in range-bound markets",
"Late entries after extended moves",
"Gap-downs through stop levels",
],
},
"mean_reversion_pullback": {
"hypothesis_type": "mean_reversion",
"mechanism_tag": "statistical",
"entry_family": "research_only",
"default_horizon_days": 7,
"default_stop_loss_pct": 0.04,
"default_take_profit_rr": 2.0,
"default_time_stop_days": 7,
"default_conditions": ["rsi_14 < 30", "price > sma_200", "price < sma_20"],
"default_trend_filter": ["sma_50 > sma_200"],
"compatible_pivots_from": [
"trend_following_breakout",
"volatility_contraction",
"earnings_drift_pead",
],
"typical_failure_modes": [
"Catching falling knives in trend changes",
"Insufficient recovery within time stop",
],
},
"earnings_drift_pead": {
"hypothesis_type": "earnings_drift",
"mechanism_tag": "information",
"entry_family": "gap_up_continuation",
"default_horizon_days": 20,
"default_stop_loss_pct": 0.06,
"default_take_profit_rr": 2.5,
"default_time_stop_days": 20,
"default_conditions": [
"gap_up_detected",
"close_above_gap_day_high",
"volume > 2.0 * avg_volume_50",
],
"default_trend_filter": ["price > sma_200", "price > sma_50", "sma_50 > sma_200"],
"compatible_pivots_from": [
"event_driven_fade",
"trend_following_breakout",
"mean_reversion_pullback",
],
"typical_failure_modes": ["One-day gap fills", "Market-wide selloffs overwhelming drift"],
},
"volatility_contraction": {
"hypothesis_type": "breakout",
"mechanism_tag": "structural",
"entry_family": "pivot_breakout",
"default_horizon_days": 25,
"default_stop_loss_pct": 0.05,
"default_take_profit_rr": 3.0,
"default_time_stop_days": 20,
"default_conditions": [
"volatility_contraction_detected",
"close > pivot_point",
"rel_volume >= 1.3",
],
"default_trend_filter": ["price > sma_200", "sma_50 > sma_200"],
"compatible_pivots_from": [
"trend_following_breakout",
"mean_reversion_pullback",
"statistical_pairs",
],
"typical_failure_modes": [
"False breakouts from contraction zones",
"Extended contraction draining capital",
],
},
"regime_conditional_carry": {
"hypothesis_type": "regime",
"mechanism_tag": "macro",
"entry_family": "research_only",
"default_horizon_days": 60,
"default_stop_loss_pct": 0.07,
"default_take_profit_rr": 2.5,
"default_time_stop_days": 60,
"default_conditions": ["regime_favorable", "carry_positive", "trend_aligned"],
"default_trend_filter": ["macro_regime == risk_on"],
"compatible_pivots_from": [
"sector_rotation_momentum",
"event_driven_fade",
"statistical_pairs",
],
"typical_failure_modes": ["Regime detection lag", "Whipsaws during transitions"],
},
"sector_rotation_momentum": {
"hypothesis_type": "momentum",
"mechanism_tag": "behavior",
"entry_family": "research_only",
"default_horizon_days": 40,
"default_stop_loss_pct": 0.06,
"default_take_profit_rr": 2.5,
"default_time_stop_days": 30,
"default_conditions": [
"sector_rs_rank <= 3",
"sector_momentum_positive",
"relative_strength > 1.0",
],
"default_trend_filter": ["market_breadth > 50%"],
"compatible_pivots_from": [
"trend_following_breakout",
"regime_conditional_carry",
"earnings_drift_pead",
],
"typical_failure_modes": [
"Momentum reversals during rotation shifts",
"Crowded sector trades",
],
},
"event_driven_fade": {
"hypothesis_type": "mean_reversion",
"mechanism_tag": "information",
"entry_family": "research_only",
"default_horizon_days": 5,
"default_stop_loss_pct": 0.03,
"default_take_profit_rr": 2.0,
"default_time_stop_days": 5,
"default_conditions": [
"event_overreaction_detected",
"price_deviation > 2_sigma",
"volume_spike",
],
"default_trend_filter": ["no_regime_change_confirmed"],
"compatible_pivots_from": [
"earnings_drift_pead",
"mean_reversion_pullback",
"volatility_contraction",
],
"typical_failure_modes": [
"Genuine regime changes misread as overreactions",
"Cascading events",
],
},
"statistical_pairs": {
"hypothesis_type": "mean_reversion",
"mechanism_tag": "statistical",
"entry_family": "research_only",
"default_horizon_days": 20,
"default_stop_loss_pct": 0.06,
"default_take_profit_rr": 2.0,
"default_time_stop_days": 20,
"default_conditions": ["cointegration_pvalue < 0.05", "zscore > 2.0", "half_life < 30"],
"default_trend_filter": ["sector_correlation > 0.7"],
"compatible_pivots_from": [
"mean_reversion_pullback",
"volatility_contraction",
"regime_conditional_carry",
],
"typical_failure_modes": ["Cointegration breakdown", "Extended spread divergence"],
},
}
# Inversion rules: (trigger) -> list of module changes
INVERSION_MAP = {
"cost_defeat": [
{
"module": "horizon",
"change": "shorten",
"new_time_stop_days": 7,
"reason": "Reduce friction exposure",
},
{"module": "universe", "change": "high_liquidity", "reason": "Reduce slippage impact"},
],
"tail_risk": [
{
"module": "risk",
"change": "tighten",
"new_stop_loss_pct": 0.04,
"new_risk_per_trade": 0.005,
"reason": "Reduce tail exposure",
},
{"module": "structure", "change": "market_neutral", "reason": "Hedge directional risk"},
],
"improvement_plateau": [
{
"module": "signal",
"change": "volume_based",
"reason": "Change signal source from price to volume",
},
{"module": "entry", "change": "timing_shift", "reason": "Change entry timing mechanism"},
],
"overfitting_proxy": [
{
"module": "complexity",
"change": "simplify",
"reason": "Reduce parameters and model complexity",
},
{
"module": "validation",
"change": "extend_period",
"reason": "Extend test period and add regime subsamples",
},
],
}
# Quality potential: (trigger, archetype) -> score 0-1
QUALITY_TABLE = {
# cost_defeat: shorter horizon / higher liquidity archetypes score well
("cost_defeat", "mean_reversion_pullback"): 0.8,
("cost_defeat", "event_driven_fade"): 0.75,
("cost_defeat", "statistical_pairs"): 0.7,
("cost_defeat", "volatility_contraction"): 0.5,
("cost_defeat", "trend_following_breakout"): 0.4,
("cost_defeat", "earnings_drift_pead"): 0.6,
("cost_defeat", "sector_rotation_momentum"): 0.3,
("cost_defeat", "regime_conditional_carry"): 0.2,
# tail_risk: low-risk / hedged archetypes score well
("tail_risk", "statistical_pairs"): 0.85,
("tail_risk", "event_driven_fade"): 0.7,
("tail_risk", "mean_reversion_pullback"): 0.75,
("tail_risk", "volatility_contraction"): 0.6,
("tail_risk", "trend_following_breakout"): 0.3,
("tail_risk", "earnings_drift_pead"): 0.4,
("tail_risk", "sector_rotation_momentum"): 0.35,
("tail_risk", "regime_conditional_carry"): 0.5,
# improvement_plateau: structurally different signal sources score well
("improvement_plateau", "mean_reversion_pullback"): 0.7,
("improvement_plateau", "earnings_drift_pead"): 0.75,
("improvement_plateau", "event_driven_fade"): 0.65,
("improvement_plateau", "sector_rotation_momentum"): 0.7,
("improvement_plateau", "regime_conditional_carry"): 0.6,
("improvement_plateau", "statistical_pairs"): 0.65,
("improvement_plateau", "volatility_contraction"): 0.5,
("improvement_plateau", "trend_following_breakout"): 0.4,
# overfitting_proxy: simpler / fewer-parameter archetypes score well
("overfitting_proxy", "mean_reversion_pullback"): 0.75,
("overfitting_proxy", "event_driven_fade"): 0.7,
("overfitting_proxy", "trend_following_breakout"): 0.65,
("overfitting_proxy", "volatility_contraction"): 0.6,
("overfitting_proxy", "earnings_drift_pead"): 0.55,
("overfitting_proxy", "statistical_pairs"): 0.4,
("overfitting_proxy", "sector_rotation_momentum"): 0.5,
("overfitting_proxy", "regime_conditional_carry"): 0.35,
}
# Objective reframe mappings
REFRAME_MAP = {
"tail_risk": {
"new_criteria": ["max_drawdown_pct < 25", "expected_value_after_costs > 0"],
"exit_adjustments": {"stop_loss_pct": 0.04, "take_profit_rr": 1.5},
"reason": "Reframe from return maximization to drawdown minimization",
},
"cost_defeat": {
"new_criteria": ["win_rate > 55", "expected_value_after_costs > 0"],
"exit_adjustments": {"stop_loss_pct": 0.03, "take_profit_rr": 1.5, "time_stop_days": 5},
"reason": "Reframe to win rate maximization with smaller targets",
},
"improvement_plateau": {
"new_criteria": [
"risk_adjusted_return_per_exposure > 0.5",
"expected_value_after_costs > 0",
],
"exit_adjustments": {},
"reason": "Reframe to risk-adjusted return per unit exposure",
},
"overfitting_proxy": {
"new_criteria": ["expected_value_after_costs > 0", "stable across regimes and subperiods"],
"exit_adjustments": {},
"reason": "Simplify to fewer optimization targets",
},
}
DEFAULT_QUALITY = 0.3
# --- ID Sanitization ---
def sanitize_identifier(value: str) -> str:
"""Convert free text into a safe identifier (same as design_strategy_drafts.py:84)."""
lowered = "".join(ch.lower() if ch.isalnum() else "_" for ch in value)
compact = "_".join(part for part in lowered.split("_") if part)
return compact or "pivot"
# --- Archetype Identification ---
def identify_current_archetype(draft: dict[str, Any]) -> str | None:
"""Identify current strategy's archetype from its fields."""
h_type = draft.get("hypothesis_type", "")
m_tag = draft.get("mechanism_tag", "")
e_family = draft.get("entry_family", "")
# Exact match attempts
for arch_id, arch in ARCHETYPE_CATALOG.items():
if (
arch["hypothesis_type"] == h_type
and arch["mechanism_tag"] == m_tag
and arch["entry_family"] == e_family
):
return arch_id
# Partial match: hypothesis_type + mechanism_tag
for arch_id, arch in ARCHETYPE_CATALOG.items():
if arch["hypothesis_type"] == h_type and arch["mechanism_tag"] == m_tag:
return arch_id
# Partial match: hypothesis_type only
for arch_id, arch in ARCHETYPE_CATALOG.items():
if arch["hypothesis_type"] == h_type:
return arch_id
return None
# --- Module Set for Novelty ---
def compute_module_set(draft: dict[str, Any]) -> set[tuple[str, str]]:
"""Build normalized module set for Jaccard distance."""
modules: set[tuple[str, str]] = set()
modules.add(("hypothesis_type", str(draft.get("hypothesis_type", ""))))
modules.add(("mechanism_tag", str(draft.get("mechanism_tag", ""))))
modules.add(("regime", str(draft.get("regime", ""))))
modules.add(("entry_family", str(draft.get("entry_family", ""))))
# Horizon classification
exit_data = draft.get("exit", {})
time_stop = exit_data.get("time_stop_days", 20)
if time_stop <= 7:
horizon = "short"
elif time_stop <= 30:
horizon = "medium"
else:
horizon = "long"
modules.add(("horizon", horizon))
# Risk style classification
stop_loss = exit_data.get("stop_loss_pct", 0.07)
if stop_loss <= 0.04:
risk_style = "tight"
elif stop_loss <= 0.08:
risk_style = "normal"
else:
risk_style = "wide"
modules.add(("risk_style", risk_style))
return modules
# --- Scoring ---
def score_novelty(source_set: set, target_set: set) -> float:
"""Jaccard distance between module sets (0=identical, 1=no overlap)."""
if not source_set and not target_set:
return 0.0
intersection = source_set & target_set
union = source_set | target_set
if not union:
return 0.0
return 1.0 - len(intersection) / len(union)
def score_quality_potential(trigger: str, archetype: str) -> float:
"""Look up quality potential from QUALITY_TABLE."""
return QUALITY_TABLE.get((trigger, archetype), DEFAULT_QUALITY)
def compute_combined_score(quality: float, novelty: float) -> float:
"""Combined score: 0.6 * quality + 0.4 * novelty."""
return round(0.6 * quality + 0.4 * novelty, 4)
# --- Pivot Generation ---
def generate_inversions(
draft: dict[str, Any],
triggers_fired: list[dict],
source_archetype: str | None,
) -> list[dict[str, Any]]:
"""Generate assumption inversion proposals."""
proposals: list[dict[str, Any]] = []
source_id = draft.get("id", "unknown")
for trigger_info in triggers_fired:
trigger = trigger_info["trigger"]
inversions = INVERSION_MAP.get(trigger, [])
for inv in inversions:
# For each archetype in the catalog (excluding current)
for arch_id, arch in ARCHETYPE_CATALOG.items():
if arch_id == source_archetype:
continue
proposal_id = sanitize_identifier(f"pivot_{source_id}_inv_{trigger}_{arch_id}")
# Build draft based on archetype defaults with inversion applied
new_draft = _build_base_draft(draft, arch_id, arch, proposal_id)
# Apply inversion-specific adjustments
if inv.get("new_time_stop_days"):
new_draft["exit"]["time_stop_days"] = inv["new_time_stop_days"]
if inv.get("new_stop_loss_pct"):
new_draft["exit"]["stop_loss_pct"] = inv["new_stop_loss_pct"]
if inv.get("new_risk_per_trade"):
new_draft["risk"]["risk_per_trade"] = inv["new_risk_per_trade"]
new_draft["pivot_metadata"] = {
"pivot_technique": "assumption_inversion",
"source_strategy_id": source_id,
"target_archetype": arch_id,
"what_changed": {
"signal": f"Inversion: {inv['module']} -> {inv['change']}",
"horizon": f"{draft.get('exit', {}).get('time_stop_days', '?')}d -> {new_draft['exit']['time_stop_days']}d",
"risk": f"stop {draft.get('exit', {}).get('stop_loss_pct', '?')} -> {new_draft['exit']['stop_loss_pct']}",
},
"why": inv["reason"],
"targeted_triggers": [trigger],
"expected_failure_modes": arch.get("typical_failure_modes", [])[:2],
}
proposals.append(new_draft)
return proposals
def generate_archetype_switches(
draft: dict[str, Any],
source_archetype: str | None,
triggers_fired: list[dict],
) -> list[dict[str, Any]]:
"""Generate archetype switch proposals."""
if source_archetype is None or source_archetype not in ARCHETYPE_CATALOG:
return []
source_arch = ARCHETYPE_CATALOG[source_archetype]
compatible = source_arch.get("compatible_pivots_from", [])
source_id = draft.get("id", "unknown")
trigger_ids = [t["trigger"] for t in triggers_fired]
proposals: list[dict[str, Any]] = []
for target_id in compatible:
if target_id not in ARCHETYPE_CATALOG:
continue
target_arch = ARCHETYPE_CATALOG[target_id]
proposal_id = sanitize_identifier(f"pivot_{source_id}_switch_{target_id}")
new_draft = _build_base_draft(draft, target_id, target_arch, proposal_id)
new_draft["pivot_metadata"] = {
"pivot_technique": "archetype_switch",
"source_strategy_id": source_id,
"target_archetype": target_id,
"what_changed": {
"signal": f"Architecture switch: {source_archetype} -> {target_id}",
"horizon": f"{draft.get('exit', {}).get('time_stop_days', '?')}d -> {new_draft['exit']['time_stop_days']}d",
"risk": f"stop {draft.get('exit', {}).get('stop_loss_pct', '?')} -> {new_draft['exit']['stop_loss_pct']}",
},
"why": f"Structural pivot from {source_archetype} to {target_id} to address {', '.join(trigger_ids)}",
"targeted_triggers": trigger_ids,
"expected_failure_modes": target_arch.get("typical_failure_modes", [])[:2],
}
proposals.append(new_draft)
return proposals
def generate_objective_reframes(
draft: dict[str, Any],
triggers_fired: list[dict],
source_archetype: str | None,
) -> list[dict[str, Any]]:
"""Generate objective reframe proposals."""
proposals: list[dict[str, Any]] = []
source_id = draft.get("id", "unknown")
for trigger_info in triggers_fired:
trigger = trigger_info["trigger"]
reframe = REFRAME_MAP.get(trigger)
if not reframe:
continue
# Apply reframe to each compatible archetype
current_arch = source_archetype or "unknown"
compatible_archetypes: list[str] = []
if current_arch in ARCHETYPE_CATALOG:
compatible_archetypes = ARCHETYPE_CATALOG[current_arch].get(
"compatible_pivots_from", []
)
# Also reframe the current archetype
if current_arch in ARCHETYPE_CATALOG:
target_archetypes = [current_arch] + compatible_archetypes
else:
target_archetypes = list(ARCHETYPE_CATALOG.keys())[:3]
for arch_id in target_archetypes:
if arch_id not in ARCHETYPE_CATALOG:
continue
arch = ARCHETYPE_CATALOG[arch_id]
proposal_id = sanitize_identifier(f"pivot_{source_id}_reframe_{trigger}_{arch_id}")
new_draft = _build_base_draft(draft, arch_id, arch, proposal_id)
# Apply reframe adjustments
if reframe.get("exit_adjustments"):
for k, v in reframe["exit_adjustments"].items():
new_draft["exit"][k] = v
if reframe.get("new_criteria"):
new_draft["validation_plan"]["success_criteria"] = reframe["new_criteria"]
new_draft["pivot_metadata"] = {
"pivot_technique": "objective_reframe",
"source_strategy_id": source_id,
"target_archetype": arch_id,
"what_changed": {
"signal": f"Objective reframe for {trigger}",
"horizon": f"{draft.get('exit', {}).get('time_stop_days', '?')}d -> {new_draft['exit']['time_stop_days']}d",
"risk": f"Criteria: {', '.join(reframe.get('new_criteria', [])[:2])}",
},
"why": reframe["reason"],
"targeted_triggers": [trigger],
"expected_failure_modes": arch.get("typical_failure_modes", [])[:2],
}
proposals.append(new_draft)
return proposals
def _build_base_draft(
source_draft: dict[str, Any],
arch_id: str,
arch: dict[str, Any],
proposal_id: str,
) -> dict[str, Any]:
"""Build base pivot draft from archetype defaults."""
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
entry_family = arch["entry_family"]
export_ready = entry_family in DEFAULT_EXPORTABLE_FAMILIES
return {
"id": proposal_id,
"as_of": today,
"concept_id": source_draft.get("concept_id", ""),
"variant": "research_probe",
"name": f"{arch_id.replace('_', ' ').title()} (pivoted from {source_draft.get('id', 'unknown')})",
"hypothesis_type": arch["hypothesis_type"],
"mechanism_tag": arch["mechanism_tag"],
"regime": source_draft.get("regime", "Neutral"),
"export_ready_v1": export_ready,
"entry_family": entry_family,
"entry": {
"conditions": arch.get("default_conditions", []),
"trend_filter": arch.get("default_trend_filter", []),
"note": "Probe setup with small size for hypothesis validation.",
},
"exit": {
"stop_loss_pct": arch.get("default_stop_loss_pct", 0.07),
"take_profit_rr": arch.get("default_take_profit_rr", 3.0),
"time_stop_days": arch.get("default_time_stop_days", 20),
},
"risk": {
"position_sizing": "fixed_risk",
"risk_per_trade": 0.005,
"max_positions": 5,
"max_sector_exposure": 0.3,
},
"validation_plan": {
"period": "2016-01-01 to latest",
"entry_timing": "next_open",
"hold_days": [3, 7, 14]
if arch.get("default_time_stop_days", 20) <= 14
else [5, 20, 60],
"success_criteria": [
"expected_value_after_costs > 0",
"max_drawdown_pct < 25",
],
},
"thesis": source_draft.get("thesis", ""),
"invalidation_signals": source_draft.get("invalidation_signals", []),
}
# --- Ranking & Selection ---
def rank_and_select(
proposals: list[dict[str, Any]],
source_draft: dict[str, Any],
triggers_fired: list[dict],
max_pivots: int = 3,
) -> list[dict[str, Any]]:
"""Rank proposals and select top candidates with diversity constraint."""
source_modules = compute_module_set(source_draft)
scored: list[tuple[float, float, str, dict[str, Any]]] = []
for p in proposals:
target_modules = compute_module_set(p)
novelty = score_novelty(source_modules, target_modules)
# Average quality across all targeted triggers
target_arch = p.get("pivot_metadata", {}).get("target_archetype", "")
targeted = p.get("pivot_metadata", {}).get("targeted_triggers", [])
if targeted:
quality = sum(score_quality_potential(t, target_arch) for t in targeted) / len(targeted)
else:
quality = DEFAULT_QUALITY
combined = compute_combined_score(quality, novelty)
p["pivot_metadata"]["scores"] = {
"quality_potential": round(quality, 4),
"novelty": round(novelty, 4),
"combined": combined,
}
scored.append((combined, novelty, p["id"], p))
# Sort: combined DESC, novelty DESC, id ASC (deterministic tiebreak)
scored.sort(key=lambda x: (-x[0], -x[1], x[2]))
# Diversity constraint: max 1 per target_archetype
seen_archetypes: set[str] = set()
selected: list[dict[str, Any]] = []
for _, _, _, proposal in scored:
target = proposal.get("pivot_metadata", {}).get("target_archetype", "")
if target in seen_archetypes:
continue
seen_archetypes.add(target)
selected.append(proposal)
if len(selected) >= max_pivots:
break
return selected
# --- Export Ticket ---
def build_export_ticket_if_eligible(draft: dict[str, Any]) -> dict[str, Any] | None:
"""Build export ticket if entry_family is exportable. Returns None otherwise."""
entry_family = draft.get("entry_family", "")
if entry_family not in DEFAULT_EXPORTABLE_FAMILIES:
return None
ticket_id = sanitize_identifier(draft["id"].replace("pivot_", "edge_"))
hypothesis_type = str(draft.get("hypothesis_type", "unknown"))
if entry_family == "pivot_breakout" and hypothesis_type == "unknown":
hypothesis_type = "breakout"
if entry_family == "gap_up_continuation" and hypothesis_type == "unknown":
hypothesis_type = "earnings_drift"
ticket = {
"id": ticket_id,
"name": draft.get("name", ""),
"description": f"Pivot-derived ticket from {draft.get('pivot_metadata', {}).get('source_strategy_id', 'unknown')}.",
"hypothesis_type": hypothesis_type,
"entry_family": entry_family,
"mechanism_tag": draft.get("mechanism_tag", "uncertain"),
"regime": draft.get("regime", "Neutral"),
"holding_horizon": f"{draft.get('exit', {}).get('time_stop_days', 20)}D",
"entry": {
"conditions": draft.get("entry", {}).get("conditions", []),
"trend_filter": draft.get("entry", {}).get("trend_filter", []),
},
"risk": draft.get("risk", {}),
"exit": {
"stop_loss_pct": draft.get("exit", {}).get("stop_loss_pct", 0.07),
"take_profit_rr": draft.get("exit", {}).get("take_profit_rr", 3.0),
},
"cost_model": {
"commission_per_share": 0.0,
"slippage_bps": 5,
},
}
# Minimal validation
errors = _validate_ticket_minimal(ticket)
if errors:
return None
return ticket
def _validate_ticket_minimal(ticket: dict[str, Any]) -> list[str]:
"""Minimal ticket validation (mirrors candidate_contract.validate_ticket_payload).
Kept deliberately in sync with ``candidate_contract.py``. The cross-
validation test in ``test_generate_pivots.py::TestCrossValidation``
detects drift between the two implementations.
"""
errors: list[str] = []
for key in ("id", "hypothesis_type", "entry_family"):
value = ticket.get(key)
if not isinstance(value, str) or not value.strip():
errors.append(f"ticket.{key} must be a non-empty string")
entry_family = ticket.get("entry_family")
if isinstance(entry_family, str) and entry_family not in DEFAULT_EXPORTABLE_FAMILIES:
errors.append(
f"ticket.entry_family must be one of: {', '.join(sorted(DEFAULT_EXPORTABLE_FAMILIES))}"
)
# Mirror candidate_contract validation constraints
validation = ticket.get("validation")
if validation is not None:
if not isinstance(validation, dict):
errors.append("ticket.validation must be a mapping when provided")
else:
method = validation.get("method", "full_sample")
if method != "full_sample":
errors.append("ticket.validation.method must be 'full_sample' for Phase I")
if validation.get("oos_ratio") is not None:
errors.append("ticket.validation.oos_ratio must be omitted or null for Phase I")
return errors
# --- Output ---
def write_outputs(
selected: list[dict[str, Any]],
diagnosis: dict[str, Any],
source_draft: dict[str, Any],
output_dir: Path,
) -> dict[str, Any]:
"""Write pivot drafts, tickets, report, and manifest."""
timestamp_str = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
strategy_id = diagnosis.get("strategy_id", "unknown")
# Create directory structure
research_dir = output_dir / "pivot_drafts" / "research_only"
exportable_dir = output_dir / "pivot_drafts" / "exportable"
research_dir.mkdir(parents=True, exist_ok=True)
exportable_dir.mkdir(parents=True, exist_ok=True)
manifest_drafts: list[dict[str, Any]] = []
errors: list[str] = []
for draft in selected:
entry_family = draft.get("entry_family", "")
is_exportable = entry_family in DEFAULT_EXPORTABLE_FAMILIES
# Remove pivot_metadata from the YAML file (store separately)
pivot_meta = draft.pop("pivot_metadata", {})
draft_with_meta = {**draft, "pivot_metadata": pivot_meta}
# Choose directory
if is_exportable:
target_dir = exportable_dir
category = "exportable"
else:
target_dir = research_dir
category = "research_only"
# Write draft YAML (include timestamp to avoid overwrite on re-run)
draft_filename = f"{draft['id']}_{timestamp_str}.yaml"
draft_path = target_dir / draft_filename
draft_path.write_text(yaml.safe_dump(draft_with_meta, sort_keys=False, allow_unicode=True))
draft_entry: dict[str, Any] = {
"id": draft["id"],
"path": str(draft_path.relative_to(output_dir)),
"category": category,
"ticket_path": None,
"scores": pivot_meta.get("scores", {}),
}
# Build ticket if exportable
if is_exportable:
ticket = build_export_ticket_if_eligible(draft_with_meta)
if ticket:
ticket_filename = f"ticket_{draft['id'].replace('pivot_', '')}_{timestamp_str}.yaml"
ticket_path = exportable_dir / ticket_filename
ticket_path.write_text(yaml.safe_dump(ticket, sort_keys=False, allow_unicode=True))
draft_entry["ticket_path"] = str(ticket_path.relative_to(output_dir))
else:
errors.append(f"Ticket validation failed for {draft['id']}")
# Restore pivot_metadata
draft["pivot_metadata"] = pivot_meta
manifest_drafts.append(draft_entry)
# Write manifest
manifest = {
"generated_at_utc": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"strategy_id": strategy_id,
"diagnosis_file": str(diagnosis.get("_source_path", "")),
"strategy_file": str(source_draft.get("_source_path", "")),
"triggers_fired": [t["trigger"] for t in diagnosis.get("triggers_fired", [])],
"total_pivots_generated": len(selected),
"exportable_count": sum(1 for d in manifest_drafts if d["category"] == "exportable"),
"research_only_count": sum(1 for d in manifest_drafts if d["category"] == "research_only"),
"drafts": manifest_drafts,
"errors": errors,
}
manifest_path = output_dir / f"pivot_manifest_{strategy_id}_{timestamp_str}.json"
manifest_path.write_text(json.dumps(manifest, indent=2, default=str))
# Write report
report = _build_report(selected, diagnosis, source_draft, manifest)
report_path = output_dir / f"pivot_report_{strategy_id}_{timestamp_str}.md"
report_path.write_text(report)
return manifest
def _build_report(
selected: list[dict[str, Any]],
diagnosis: dict[str, Any],
source_draft: dict[str, Any],
manifest: dict[str, Any],
) -> str:
"""Build markdown report from pivot results."""
lines = [
f"# Pivot Report: {diagnosis.get('strategy_id', 'unknown')}",
"",
f"**Generated**: {manifest['generated_at_utc']}",
f"**Source Strategy**: {source_draft.get('id', 'unknown')}",
f"**Recommendation**: {diagnosis.get('recommendation', 'unknown')}",
"",
"---",
"",
"## Stagnation Diagnosis",
"",
f"**Triggers Fired**: {len(diagnosis.get('triggers_fired', []))}",
f"**Score Trajectory**: {diagnosis.get('score_trajectory', [])}",
"",
]
for t in diagnosis.get("triggers_fired", []):
lines.append(f"- **{t['trigger']}** [{t['severity']}]: {t['message']}")
lines.extend(["", "---", "", "## Pivot Proposals", ""])
for i, draft in enumerate(selected, 1):
meta = draft.get("pivot_metadata", {})
scores = meta.get("scores", {})
lines.extend(
[
f"### {i}. {draft.get('id', 'unknown')}",
"",
f"**Technique**: {meta.get('pivot_technique', 'unknown')}",
f"**Target Archetype**: {meta.get('target_archetype', 'unknown')}",
f"**Entry Family**: {draft.get('entry_family', 'unknown')}",
"",
"**What Changed**:",
]
)
for k, v in meta.get("what_changed", {}).items():
lines.append(f"- {k}: {v}")
lines.extend(
[
"",
f"**Why**: {meta.get('why', '')}",
"",
f"**Scores**: quality={scores.get('quality_potential', 0):.2f}, novelty={scores.get('novelty', 0):.2f}, combined={scores.get('combined', 0):.2f}",
"",
]
)
# Summary table
lines.extend(
[
"---",
"",
"## Summary",
"",
"| Rank | Proposal | Archetype | Combined | Category |",
"|------|----------|-----------|----------|----------|",
]
)
for i, draft in enumerate(selected, 1):
meta = draft.get("pivot_metadata", {})
scores = meta.get("scores", {})
category = (
"exportable"
if draft.get("entry_family", "") in DEFAULT_EXPORTABLE_FAMILIES
else "research_only"
)
lines.append(
f"| {i} | {draft['id']} | {meta.get('target_archetype', '')} | {scores.get('combined', 0):.2f} | {category} |"
)
lines.append("")
return "\n".join(lines)
# --- CLI ---
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate strategy pivot proposals from stagnation diagnosis."
)
parser.add_argument("--diagnosis", required=True, help="Path to pivot_diagnosis JSON")
parser.add_argument("--strategy", required=True, help="Path to source strategy draft YAML")
parser.add_argument("--max-pivots", type=int, default=3, help="Maximum pivot proposals")
parser.add_argument("--output-dir", default="reports/", help="Output directory")
return parser.parse_args()
def main() -> int:
args = parse_args()
diagnosis_path = Path(args.diagnosis).resolve()
strategy_path = Path(args.strategy).resolve()
if not diagnosis_path.exists():
print(f"[ERROR] diagnosis file not found: {diagnosis_path}")
return 1
if not strategy_path.exists():
print(f"[ERROR] strategy file not found: {strategy_path}")
return 1
diagnosis = json.loads(diagnosis_path.read_text())
source_draft = yaml.safe_load(strategy_path.read_text())
if not isinstance(source_draft, dict):
print("[ERROR] strategy file must be a YAML mapping")
return 1
# Tag source paths for manifest
diagnosis["_source_path"] = str(diagnosis_path)
source_draft["_source_path"] = str(strategy_path)
triggers_fired = diagnosis.get("triggers_fired", [])
if not triggers_fired:
print("[INFO] No triggers fired -- no pivots to generate")
return 0
source_archetype = identify_current_archetype(source_draft)
# Generate proposals from all techniques
all_proposals: list[dict[str, Any]] = []
all_proposals.extend(generate_inversions(source_draft, triggers_fired, source_archetype))
all_proposals.extend(
generate_archetype_switches(source_draft, source_archetype, triggers_fired)
)
all_proposals.extend(
generate_objective_reframes(source_draft, triggers_fired, source_archetype)
)
# Rank and select
selected = rank_and_select(
all_proposals, source_draft, triggers_fired, max_pivots=args.max_pivots
)
# Write outputs
output_dir = Path(args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
manifest = write_outputs(selected, diagnosis, source_draft, output_dir)
print(f"[OK] Generated {manifest['total_pivots_generated']} pivot proposals")
print(
f" exportable={manifest['exportable_count']} research_only={manifest['research_only_count']}"
)
for d in manifest["drafts"]:
print(f" - {d['id']} ({d['category']}) combined={d['scores'].get('combined', 0):.2f}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""Test configuration and shared fixtures for strategy-pivot-designer scripts."""
import os
import sys
import pytest
# Add scripts/ directory to sys.path so detect_stagnation can be imported.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Add tests/ directory to sys.path so helpers can be imported by tests.
sys.path.insert(0, os.path.dirname(__file__))
from helpers import make_eval, make_iteration # noqa: E402
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def sample_iteration_history() -> dict:
"""Valid history with 5 iterations showing plateau pattern.
Scores: 45, 62, 72, 73, 72 -- last 3 form a plateau (range=1).
"""
evals = [
make_eval(45, expectancy_score=8, robustness_score=12),
make_eval(62, expectancy_score=12, robustness_score=14),
make_eval(72, expectancy_score=15, robustness_score=16),
make_eval(73, expectancy_score=15, robustness_score=16),
make_eval(72, expectancy_score=15, robustness_score=16),
]
iterations = [make_iteration(i + 1, ev, f"Change {i + 1}") for i, ev in enumerate(evals)]
return {"strategy_id": "mean_revert_v2", "iterations": iterations}
@pytest.fixture()
def single_iteration_history() -> dict:
"""History with only 1 iteration."""
return {
"strategy_id": "single_test",
"iterations": [
make_iteration(1, make_eval(45)),
],
}
@pytest.fixture()
def overfitting_history() -> dict:
"""History where overfitting_proxy triggers.
Conditions: Expectancy >= 15, Risk Mgmt >= 15, Robustness < 10,
red_flags contain 'over_optimized'.
"""
ev1 = make_eval(50)
ev2 = make_eval(
78,
expectancy_score=18,
risk_mgmt_score=17,
robustness_score=5,
red_flags=[{"id": "over_optimized", "msg": "Strategy appears over-optimized"}],
)
return {
"strategy_id": "overfit_strat",
"iterations": [
make_iteration(1, ev1, "Initial run"),
make_iteration(2, ev2, "Added many parameters"),
],
}
@pytest.fixture()
def cost_defeat_history() -> dict:
"""History where cost_defeat triggers.
Conditions: expectancy < 0.3, profit_factor < 1.3, slippage_tested=True.
"""
ev1 = make_eval(55)
ev2 = make_eval(
48,
expectancy=0.12,
profit_factor=1.15,
slippage_tested=True,
)
return {
"strategy_id": "thin_edge",
"iterations": [
make_iteration(1, ev1, "Initial run"),
make_iteration(2, ev2, "Added slippage model"),
],
}
@pytest.fixture()
def tail_risk_history() -> dict:
"""History where tail_risk triggers via high drawdown.
Condition: max_drawdown_pct > 35.
"""
ev = make_eval(40, max_drawdown_pct=42.0, risk_mgmt_score=4)
return {
"strategy_id": "risky_strat",
"iterations": [
make_iteration(1, ev, "Initial run"),
],
}
@pytest.fixture()
def abandon_history() -> dict:
"""History with 3+ iterations, latest score < 30, monotonically non-increasing last 3.
Scores: 28, 25, 22 -- declining and all below 30.
"""
evals = [
make_eval(28),
make_eval(25),
make_eval(22),
]
return {
"strategy_id": "hopeless_strat",
"iterations": [make_iteration(i + 1, ev, f"Attempt {i + 1}") for i, ev in enumerate(evals)],
}
@pytest.fixture()
def healthy_history() -> dict:
"""History with no triggers fired -- good scores, improving trajectory.
Scores: 45, 58, 72 -- clearly improving with good internals.
"""
evals = [
make_eval(
45,
expectancy_score=10,
risk_mgmt_score=10,
robustness_score=12,
max_drawdown_pct=18.0,
expectancy=0.8,
profit_factor=2.0,
),
make_eval(
58,
expectancy_score=12,
risk_mgmt_score=12,
robustness_score=14,
max_drawdown_pct=16.0,
expectancy=0.9,
profit_factor=2.2,
),
make_eval(
72,
expectancy_score=14,
risk_mgmt_score=14,
robustness_score=16,
max_drawdown_pct=14.0,
expectancy=1.1,
profit_factor=2.5,
),
]
return {
"strategy_id": "healthy_momentum",
"iterations": [
make_iteration(i + 1, ev, f"Improvement {i + 1}") for i, ev in enumerate(evals)
],
}
"""Shared test helpers for building iteration history data."""
from __future__ import annotations
def make_eval(
total_score: int,
verdict: str = "Refine",
sample_size: int = 10,
expectancy_score: int = 10,
risk_mgmt_score: int = 12,
robustness_score: int = 15,
exec_realism_score: int = 0,
red_flags: list | None = None,
profit_factor: float = 1.8,
expectancy: float = 0.5,
total_trades: int = 50,
win_rate: int = 58,
avg_win_pct: float = 1.5,
avg_loss_pct: float = 1.0,
max_drawdown_pct: float = 22.0,
years_tested: int = 8,
num_parameters: int = 3,
slippage_tested: bool = False,
) -> dict:
"""Build a well-formed eval dict with customisable fields."""
return {
"total_score": total_score,
"verdict": verdict,
"dimensions": [
{"name": "Sample Size", "score": sample_size, "max_score": 20},
{"name": "Expectancy", "score": expectancy_score, "max_score": 20},
{"name": "Risk Management", "score": risk_mgmt_score, "max_score": 20},
{"name": "Robustness", "score": robustness_score, "max_score": 20},
{"name": "Execution Realism", "score": exec_realism_score, "max_score": 20},
],
"red_flags": red_flags or [],
"profit_factor": profit_factor,
"expectancy": expectancy,
"inputs": {
"total_trades": total_trades,
"win_rate": win_rate,
"avg_win_pct": avg_win_pct,
"avg_loss_pct": avg_loss_pct,
"max_drawdown_pct": max_drawdown_pct,
"years_tested": years_tested,
"num_parameters": num_parameters,
"slippage_tested": slippage_tested,
},
}
def make_iteration(
iteration: int,
eval_data: dict,
changes: str = "",
timestamp: str = "2026-02-10T12:00:00Z",
) -> dict:
"""Wrap an eval dict into a full iteration entry."""
return {
"iteration": iteration,
"timestamp": timestamp,
"changes_from_previous": changes or f"Iteration {iteration}",
"eval": eval_data,
}
"""Unit tests for detect_stagnation.py."""
import json
from pathlib import Path
import detect_stagnation as ds
import pytest
from helpers import make_eval, make_iteration
# ---- Validation tests ----
def test_missing_strategy_id_raises() -> None:
history = {
"iterations": [
{"eval": {"total_score": 50, "dimensions": [], "red_flags": [], "inputs": {}}}
]
}
with pytest.raises(ValueError, match="strategy_id"):
ds.validate_history(history)
def test_empty_iterations_raises() -> None:
history = {"strategy_id": "x", "iterations": []}
with pytest.raises(ValueError, match="iterations"):
ds.validate_history(history)
def test_missing_eval_in_iteration_raises() -> None:
history = {"strategy_id": "x", "iterations": [{"iteration": 1}]}
with pytest.raises(ValueError, match="eval"):
ds.validate_history(history)
def test_missing_dimensions_in_eval_raises() -> None:
history = {
"strategy_id": "x",
"iterations": [
{"iteration": 1, "eval": {"total_score": 50, "red_flags": [], "inputs": {}}}
],
}
with pytest.raises(ValueError, match="dimensions"):
ds.validate_history(history)
def test_missing_total_score_in_eval_raises() -> None:
history = {
"strategy_id": "x",
"iterations": [{"iteration": 1, "eval": {"dimensions": [], "red_flags": [], "inputs": {}}}],
}
with pytest.raises(ValueError, match="total_score"):
ds.validate_history(history)
def test_dimension_missing_name_raises() -> None:
history = {
"strategy_id": "x",
"iterations": [
{
"iteration": 1,
"eval": {
"total_score": 50,
"dimensions": [{"score": 10, "max_score": 20}],
"red_flags": [],
"inputs": {},
},
}
],
}
with pytest.raises(ValueError, match="missing 'name'"):
ds.validate_history(history)
def test_dimension_missing_score_raises() -> None:
history = {
"strategy_id": "x",
"iterations": [
{
"iteration": 1,
"eval": {
"total_score": 50,
"dimensions": [{"name": "Expectancy", "max_score": 20}],
"red_flags": [],
"inputs": {},
},
}
],
}
with pytest.raises(ValueError, match="missing 'score'"):
ds.validate_history(history)
# ---- Helper tests ----
def test_get_dimension_score_by_name() -> None:
eval_data = {
"dimensions": [
{"name": "Expectancy", "score": 15, "max_score": 20},
{"name": "Risk Management", "score": 12, "max_score": 20},
],
}
assert ds.get_dimension_score(eval_data, "Expectancy") == 15
def test_get_dimension_score_missing_name() -> None:
eval_data = {
"dimensions": [
{"name": "Expectancy", "score": 15, "max_score": 20},
],
}
assert ds.get_dimension_score(eval_data, "NonExistent") is None
def test_get_red_flag_ids_extracts_ids() -> None:
eval_data = {
"red_flags": [
{"id": "over_optimized", "msg": "over-optimized"},
{"id": "short_test_period", "msg": "too short"},
],
}
assert ds.get_red_flag_ids(eval_data) == ["over_optimized", "short_test_period"]
def test_get_red_flag_ids_empty() -> None:
eval_data = {"red_flags": []}
assert ds.get_red_flag_ids(eval_data) == []
# ---- Plateau trigger tests ----
def test_plateau_fires_when_scores_within_threshold(sample_iteration_history: dict) -> None:
"""Last 3 scores [72, 73, 72] -> range=1 < threshold=3."""
iters = sample_iteration_history["iterations"]
result = ds.detect_plateau(iters, k=3, threshold=3)
assert result is not None
assert result["trigger"] == "improvement_plateau"
assert result["severity"] == "high"
assert result["evidence"]["score_range"] == 1
assert result["evidence"]["last_k_scores"] == [72, 73, 72]
def test_plateau_not_fires_when_scores_vary() -> None:
"""Scores [45, 62, 72] -> range=27, well above threshold."""
iters = [
make_iteration(1, make_eval(45)),
make_iteration(2, make_eval(62)),
make_iteration(3, make_eval(72)),
]
result = ds.detect_plateau(iters, k=3, threshold=3)
assert result is None
def test_plateau_needs_minimum_k_iterations() -> None:
"""Fewer than K iterations should not fire."""
iters = [
make_iteration(1, make_eval(72)),
make_iteration(2, make_eval(73)),
]
result = ds.detect_plateau(iters, k=3, threshold=3)
assert result is None
# ---- Overfitting proxy tests ----
def test_overfitting_fires_when_all_conditions_met(overfitting_history: dict) -> None:
latest_eval = overfitting_history["iterations"][-1]["eval"]
result = ds.detect_overfitting_proxy(latest_eval)
assert result is not None
assert result["trigger"] == "overfitting_proxy"
assert result["severity"] == "medium"
assert result["evidence"]["expectancy_score"] == 18
assert result["evidence"]["robustness_score"] == 5
assert "over_optimized" in result["evidence"]["red_flag_ids"]
def test_overfitting_not_fires_missing_red_flags() -> None:
"""Expectancy>=15, RiskMgmt>=15, Robustness<10 but no relevant red flags."""
ev = make_eval(
70,
expectancy_score=18,
risk_mgmt_score=16,
robustness_score=5,
red_flags=[], # no over_optimized or short_test_period
)
result = ds.detect_overfitting_proxy(ev)
assert result is None
def test_overfitting_not_fires_robustness_high() -> None:
"""Robustness >= 10 should prevent firing even with red flags."""
ev = make_eval(
80,
expectancy_score=18,
risk_mgmt_score=16,
robustness_score=12,
red_flags=[{"id": "over_optimized", "msg": "over-optimized"}],
)
result = ds.detect_overfitting_proxy(ev)
assert result is None
# ---- Cost defeat tests ----
def test_cost_defeat_fires_all_conditions(cost_defeat_history: dict) -> None:
latest_eval = cost_defeat_history["iterations"][-1]["eval"]
result = ds.detect_cost_defeat(latest_eval)
assert result is not None
assert result["trigger"] == "cost_defeat"
assert result["severity"] == "medium"
assert result["evidence"]["expectancy"] == 0.12
assert result["evidence"]["profit_factor"] == 1.15
assert result["evidence"]["slippage_tested"] is True
def test_cost_defeat_not_fires_without_slippage() -> None:
"""slippage_tested=False prevents firing."""
ev = make_eval(48, expectancy=0.12, profit_factor=1.15, slippage_tested=False)
result = ds.detect_cost_defeat(ev)
assert result is None
def test_cost_defeat_not_fires_high_expectancy() -> None:
"""expectancy >= 0.3 prevents firing."""
ev = make_eval(60, expectancy=0.5, profit_factor=1.15, slippage_tested=True)
result = ds.detect_cost_defeat(ev)
assert result is None
# ---- Tail risk tests ----
def test_tail_risk_fires_high_drawdown(tail_risk_history: dict) -> None:
latest_eval = tail_risk_history["iterations"][-1]["eval"]
result = ds.detect_tail_risk(latest_eval)
assert result is not None
assert result["trigger"] == "tail_risk"
assert result["severity"] == "high"
assert result["evidence"]["max_drawdown_pct"] == 42.0
def test_tail_risk_fires_low_risk_mgmt_score() -> None:
"""Risk Management score <= 5 fires tail risk even with moderate drawdown."""
ev = make_eval(40, risk_mgmt_score=4, max_drawdown_pct=25.0)
result = ds.detect_tail_risk(ev)
assert result is not None
assert result["trigger"] == "tail_risk"
assert "Risk Management score=4" in result["message"]
def test_tail_risk_fires_on_single_iteration(tail_risk_history: dict) -> None:
"""Tail risk can fire on a history with only 1 iteration."""
assert len(tail_risk_history["iterations"]) == 1
result = ds.run_all_triggers(tail_risk_history)
triggers = [t["trigger"] for t in result["triggers_fired"]]
assert "tail_risk" in triggers
def test_tail_risk_not_fires_moderate_values() -> None:
"""Moderate drawdown and decent risk score should not fire."""
ev = make_eval(60, risk_mgmt_score=12, max_drawdown_pct=20.0)
result = ds.detect_tail_risk(ev)
assert result is None
# ---- Integration tests ----
def test_run_all_triggers_plateau_detected(sample_iteration_history: dict) -> None:
result = ds.run_all_triggers(sample_iteration_history)
assert result["stagnation_detected"] is True
triggers = [t["trigger"] for t in result["triggers_fired"]]
assert "improvement_plateau" in triggers
assert result["recommendation"] == "pivot"
assert result["strategy_id"] == "mean_revert_v2"
assert result["score_trajectory"] == [45, 62, 72, 73, 72]
assert result["iteration_count"] == 5
def test_run_all_triggers_healthy(healthy_history: dict) -> None:
result = ds.run_all_triggers(healthy_history)
assert result["stagnation_detected"] is False
assert result["triggers_fired"] == []
assert result["recommendation"] == "continue"
def test_run_all_triggers_abandon(abandon_history: dict) -> None:
result = ds.run_all_triggers(abandon_history)
assert result["recommendation"] == "abandon"
assert result["stagnation_detected"] is True # abandon implies stagnation
assert result["score_trajectory"] == [28, 25, 22]
# ---- Append eval tests ----
def test_append_eval_strategy_id_mismatch_raises(tmp_path: Path) -> None:
"""Appending with wrong strategy_id must raise ValueError."""
existing = {
"strategy_id": "strat_alpha",
"iterations": [
{
"iteration": 1,
"timestamp": "2026-02-10T12:00:00Z",
"changes_from_previous": "Initial",
"eval": {"total_score": 40, "dimensions": [], "red_flags": [], "inputs": {}},
},
],
}
history_path = tmp_path / "history.json"
history_path.write_text(json.dumps(existing))
eval_data = {"total_score": 60, "dimensions": [], "red_flags": [], "inputs": {}}
eval_path = tmp_path / "eval_mismatch.json"
eval_path.write_text(json.dumps(eval_data))
with pytest.raises(ValueError, match="strategy_id mismatch"):
ds.append_eval(eval_path, history_path, "strat_beta", changes="Wrong id")
def test_append_eval_creates_new_history(tmp_path: Path) -> None:
"""Append eval to a non-existent history file -> creates new history."""
eval_data = {
"total_score": 55,
"verdict": "Refine",
"dimensions": [{"name": "Expectancy", "score": 12, "max_score": 20}],
"red_flags": [],
"profit_factor": 1.9,
"expectancy": 0.6,
"inputs": {"total_trades": 60, "slippage_tested": False},
}
eval_path = tmp_path / "eval.json"
eval_path.write_text(json.dumps(eval_data))
history_path = tmp_path / "history.json"
assert not history_path.exists()
result = ds.append_eval(eval_path, history_path, "new_strat", changes="Initial run")
assert history_path.exists()
assert result["strategy_id"] == "new_strat"
assert len(result["iterations"]) == 1
assert result["iterations"][0]["iteration"] == 1
assert result["iterations"][0]["eval"]["total_score"] == 55
assert result["iterations"][0]["changes_from_previous"] == "Initial run"
def test_append_eval_increments_iteration(tmp_path: Path) -> None:
"""Append eval to existing history -> increments iteration number."""
existing = {
"strategy_id": "existing_strat",
"iterations": [
{
"iteration": 1,
"timestamp": "2026-02-10T12:00:00Z",
"changes_from_previous": "Initial",
"eval": {"total_score": 40, "dimensions": [], "red_flags": [], "inputs": {}},
},
],
}
history_path = tmp_path / "history.json"
history_path.write_text(json.dumps(existing))
eval_data = {
"total_score": 60,
"verdict": "Refine",
"dimensions": [],
"red_flags": [],
"inputs": {},
}
eval_path = tmp_path / "eval2.json"
eval_path.write_text(json.dumps(eval_data))
result = ds.append_eval(eval_path, history_path, "existing_strat", changes="Added filter")
assert len(result["iterations"]) == 2
assert result["iterations"][-1]["iteration"] == 2
assert result["iterations"][-1]["eval"]["total_score"] == 60
assert result["iterations"][-1]["changes_from_previous"] == "Added filter"
# ---- CLI corrupt history test ----
def test_main_corrupt_history_returns_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Detection mode with corrupt JSON history returns 1, not traceback."""
history_path = tmp_path / "bad.json"
history_path.write_text("{broken json!!!")
monkeypatch.setattr(
"sys.argv",
["detect_stagnation.py", "--history", str(history_path), "--output-dir", str(tmp_path)],
)
assert ds.main() == 1
"""Tests for generate_pivots.py — self-contained with inline fixtures."""
from __future__ import annotations
import importlib
import os
import sys
import pytest
# Ensure parent directory is on sys.path for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import generate_pivots as gp # noqa: E402
# ---------------------------------------------------------------------------
# Inline Fixture Helpers
# ---------------------------------------------------------------------------
def _make_breakout_draft() -> dict:
"""Draft matching trend_following_breakout archetype."""
return {
"id": "my_breakout_v1",
"concept_id": "concept_001",
"hypothesis_type": "breakout",
"mechanism_tag": "behavior",
"entry_family": "pivot_breakout",
"regime": "Bull",
"exit": {
"stop_loss_pct": 0.08,
"take_profit_rr": 3.0,
"time_stop_days": 20,
},
"risk": {
"position_sizing": "fixed_risk",
"risk_per_trade": 0.01,
"max_positions": 5,
"max_sector_exposure": 0.3,
},
"entry": {
"conditions": ["close > high20_prev", "rel_volume >= 1.5"],
"trend_filter": ["price > sma_200"],
},
"thesis": "Breakout with volume confirmation.",
"invalidation_signals": ["close < sma_200"],
}
def _make_mean_reversion_draft() -> dict:
"""Draft matching mean_reversion_pullback archetype."""
return {
"id": "mean_rev_v1",
"concept_id": "concept_002",
"hypothesis_type": "mean_reversion",
"mechanism_tag": "statistical",
"entry_family": "research_only",
"regime": "Neutral",
"exit": {
"stop_loss_pct": 0.04,
"take_profit_rr": 2.0,
"time_stop_days": 7,
},
"risk": {
"position_sizing": "fixed_risk",
"risk_per_trade": 0.005,
"max_positions": 3,
"max_sector_exposure": 0.3,
},
"entry": {
"conditions": ["rsi_14 < 30", "price > sma_200"],
"trend_filter": ["sma_50 > sma_200"],
},
"thesis": "RSI oversold bounce.",
"invalidation_signals": [],
}
def _make_triggers(trigger_ids: list[str]) -> list[dict]:
"""Build a list of trigger dicts from trigger id strings."""
return [
{"trigger": t, "severity": "high", "message": f"Trigger {t} fired"} for t in trigger_ids
]
# ---------------------------------------------------------------------------
# Archetype Identification
# ---------------------------------------------------------------------------
class TestIdentifyArchetype:
def test_identify_archetype_breakout_behavior(self):
draft = _make_breakout_draft()
assert gp.identify_current_archetype(draft) == "trend_following_breakout"
def test_identify_archetype_mean_reversion_statistical(self):
draft = _make_mean_reversion_draft()
assert gp.identify_current_archetype(draft) == "mean_reversion_pullback"
def test_identify_archetype_unknown_returns_none(self):
draft = {
"hypothesis_type": "quantum_flux",
"mechanism_tag": "alien_signal",
"entry_family": "warp_drive",
}
assert gp.identify_current_archetype(draft) is None
# ---------------------------------------------------------------------------
# Module Set Extraction
# ---------------------------------------------------------------------------
class TestComputeModuleSet:
def test_compute_module_set_basic(self):
draft = _make_breakout_draft()
modules = gp.compute_module_set(draft)
assert isinstance(modules, set)
# Must contain (key, value) tuples
assert all(isinstance(m, tuple) and len(m) == 2 for m in modules)
# Check a few expected entries
assert ("hypothesis_type", "breakout") in modules
assert ("mechanism_tag", "behavior") in modules
assert ("entry_family", "pivot_breakout") in modules
def test_compute_module_set_horizon_classification(self):
# short: time_stop_days=5
draft_short = {"exit": {"time_stop_days": 5, "stop_loss_pct": 0.06}}
modules_short = gp.compute_module_set(draft_short)
assert ("horizon", "short") in modules_short
# medium: time_stop_days=20
draft_medium = {"exit": {"time_stop_days": 20, "stop_loss_pct": 0.06}}
modules_medium = gp.compute_module_set(draft_medium)
assert ("horizon", "medium") in modules_medium
# long: time_stop_days=60
draft_long = {"exit": {"time_stop_days": 60, "stop_loss_pct": 0.06}}
modules_long = gp.compute_module_set(draft_long)
assert ("horizon", "long") in modules_long
def test_compute_module_set_risk_style(self):
# tight: stop_loss_pct=0.03
draft_tight = {"exit": {"stop_loss_pct": 0.03, "time_stop_days": 20}}
modules_tight = gp.compute_module_set(draft_tight)
assert ("risk_style", "tight") in modules_tight
# normal: stop_loss_pct=0.06
draft_normal = {"exit": {"stop_loss_pct": 0.06, "time_stop_days": 20}}
modules_normal = gp.compute_module_set(draft_normal)
assert ("risk_style", "normal") in modules_normal
# wide: stop_loss_pct=0.10
draft_wide = {"exit": {"stop_loss_pct": 0.10, "time_stop_days": 20}}
modules_wide = gp.compute_module_set(draft_wide)
assert ("risk_style", "wide") in modules_wide
# ---------------------------------------------------------------------------
# Novelty Scoring
# ---------------------------------------------------------------------------
class TestNoveltyScoring:
def test_novelty_identical_strategies_zero(self):
draft = _make_breakout_draft()
s = gp.compute_module_set(draft)
assert gp.score_novelty(s, s) == 0.0
def test_novelty_completely_different_one(self):
a = {("a", "1"), ("b", "2")}
b = {("c", "3"), ("d", "4")}
assert gp.score_novelty(a, b) == 1.0
def test_novelty_partial_overlap(self):
a = {("a", "1"), ("b", "2"), ("c", "3")}
b = {("a", "1"), ("d", "4"), ("e", "5")}
# intersection={("a","1")}, union=5 items => 1 - 1/5 = 0.8
novelty = gp.score_novelty(a, b)
assert 0.0 < novelty < 1.0
assert abs(novelty - 0.8) < 1e-9
# ---------------------------------------------------------------------------
# Quality Potential
# ---------------------------------------------------------------------------
class TestQualityPotential:
def test_quality_table_known_pair(self):
score = gp.score_quality_potential("cost_defeat", "mean_reversion_pullback")
assert score == 0.8 # exact match from QUALITY_TABLE
def test_quality_table_unknown_pair(self):
score = gp.score_quality_potential("nonexistent_trigger", "fake_archetype")
assert score == gp.DEFAULT_QUALITY # should return 0.3
# ---------------------------------------------------------------------------
# Inversion Generation
# ---------------------------------------------------------------------------
class TestGenerateInversions:
def test_generate_inversions_cost_defeat(self):
draft = _make_breakout_draft()
triggers = _make_triggers(["cost_defeat"])
proposals = gp.generate_inversions(draft, triggers, "trend_following_breakout")
assert len(proposals) > 0
# cost_defeat inversions should produce proposals with shortened horizon
has_short_horizon = any(p["exit"]["time_stop_days"] <= 7 for p in proposals)
assert has_short_horizon, "cost_defeat should produce at least one short-horizon proposal"
def test_generate_inversions_tail_risk(self):
draft = _make_breakout_draft()
triggers = _make_triggers(["tail_risk"])
proposals = gp.generate_inversions(draft, triggers, "trend_following_breakout")
assert len(proposals) > 0
# tail_risk inversions should produce proposals with tighter risk
has_tight_risk = any(p["exit"]["stop_loss_pct"] <= 0.04 for p in proposals)
assert has_tight_risk, "tail_risk should produce at least one tight-risk proposal"
# ---------------------------------------------------------------------------
# Archetype Switch
# ---------------------------------------------------------------------------
class TestGenerateArchetypeSwitches:
def test_generate_archetype_switches_from_breakout(self):
draft = _make_breakout_draft()
triggers = _make_triggers(["improvement_plateau"])
source_arch = "trend_following_breakout"
proposals = gp.generate_archetype_switches(draft, source_arch, triggers)
assert len(proposals) > 0
# trend_following_breakout's compatible targets
expected_targets = {
"mean_reversion_pullback",
"volatility_contraction",
"sector_rotation_momentum",
}
actual_targets = {p["pivot_metadata"]["target_archetype"] for p in proposals}
assert actual_targets == expected_targets
def test_generate_archetype_switches_unknown_archetype(self):
draft = _make_breakout_draft()
triggers = _make_triggers(["cost_defeat"])
proposals = gp.generate_archetype_switches(draft, None, triggers)
assert proposals == []
proposals2 = gp.generate_archetype_switches(draft, "nonexistent_arch", triggers)
assert proposals2 == []
# ---------------------------------------------------------------------------
# Ranking and Selection
# ---------------------------------------------------------------------------
class TestRankAndSelect:
def test_rank_and_select_top_n(self):
draft = _make_breakout_draft()
triggers = _make_triggers(["cost_defeat"])
source_arch = "trend_following_breakout"
all_proposals = gp.generate_inversions(draft, triggers, source_arch)
all_proposals += gp.generate_archetype_switches(draft, source_arch, triggers)
assert len(all_proposals) > 3 # ensure we have enough to select from
selected = gp.rank_and_select(all_proposals, draft, triggers, max_pivots=3)
assert len(selected) <= 3
# Verify combined scores are in descending order
scores = [p["pivot_metadata"]["scores"]["combined"] for p in selected]
assert scores == sorted(scores, reverse=True)
def test_rank_and_select_diversity_constraint(self):
"""Max 1 per target archetype."""
draft = _make_breakout_draft()
triggers = _make_triggers(["cost_defeat"])
source_arch = "trend_following_breakout"
all_proposals = gp.generate_inversions(draft, triggers, source_arch)
all_proposals += gp.generate_archetype_switches(draft, source_arch, triggers)
selected = gp.rank_and_select(all_proposals, draft, triggers, max_pivots=10)
archetypes = [p["pivot_metadata"]["target_archetype"] for p in selected]
assert len(archetypes) == len(set(archetypes)), "Each archetype should appear at most once"
def test_rank_and_select_tiebreak_deterministic(self):
"""Same combined score -> higher novelty wins; same novelty -> alphabetical id."""
draft = _make_breakout_draft()
# Create synthetic proposals with identical combined but different novelty/ids
p_a = {
"id": "alpha",
"hypothesis_type": "breakout",
"mechanism_tag": "behavior",
"entry_family": "pivot_breakout",
"regime": "Bull",
"exit": {"stop_loss_pct": 0.08, "time_stop_days": 20},
"pivot_metadata": {
"target_archetype": "arch_a",
"targeted_triggers": ["cost_defeat"],
},
}
p_b = {
"id": "beta",
"hypothesis_type": "mean_reversion",
"mechanism_tag": "statistical",
"entry_family": "research_only",
"regime": "Neutral",
"exit": {"stop_loss_pct": 0.04, "time_stop_days": 7},
"pivot_metadata": {
"target_archetype": "arch_b",
"targeted_triggers": ["cost_defeat"],
},
}
p_c = {
"id": "charlie",
"hypothesis_type": "mean_reversion",
"mechanism_tag": "statistical",
"entry_family": "research_only",
"regime": "Neutral",
"exit": {"stop_loss_pct": 0.04, "time_stop_days": 7},
"pivot_metadata": {
"target_archetype": "arch_c",
"targeted_triggers": ["cost_defeat"],
},
}
triggers = _make_triggers(["cost_defeat"])
# Run selection multiple times — result must be deterministic
results = []
for _ in range(5):
selected = gp.rank_and_select([p_a, p_b, p_c], draft, triggers, max_pivots=10)
results.append([s["id"] for s in selected])
# All 5 runs must produce the same order
assert all(r == results[0] for r in results), "Selection must be deterministic"
# Verify tiebreak: among those with same combined score, higher novelty wins
selected = gp.rank_and_select([p_a, p_b, p_c], draft, triggers, max_pivots=10)
for i in range(len(selected) - 1):
s_i = selected[i]["pivot_metadata"]["scores"]
s_j = selected[i + 1]["pivot_metadata"]["scores"]
if s_i["combined"] == s_j["combined"]:
if s_i["novelty"] == s_j["novelty"]:
assert selected[i]["id"] < selected[i + 1]["id"], (
"Same combined and novelty -> alphabetical id order"
)
else:
assert s_i["novelty"] >= s_j["novelty"], "Same combined -> higher novelty first"
# ---------------------------------------------------------------------------
# Export Ticket
# ---------------------------------------------------------------------------
class TestBuildExportTicket:
def test_build_export_ticket_eligible(self):
draft = {
"id": "pivot_my_breakout_v1_switch_volatility_contraction",
"name": "Volatility Contraction (pivoted from my_breakout_v1)",
"hypothesis_type": "breakout",
"mechanism_tag": "structural",
"entry_family": "pivot_breakout",
"regime": "Bull",
"entry": {
"conditions": ["volatility_contraction_detected"],
"trend_filter": ["price > sma_200"],
},
"exit": {
"stop_loss_pct": 0.05,
"take_profit_rr": 3.0,
"time_stop_days": 20,
},
"risk": {
"position_sizing": "fixed_risk",
"risk_per_trade": 0.005,
"max_positions": 5,
"max_sector_exposure": 0.3,
},
"pivot_metadata": {
"source_strategy_id": "my_breakout_v1",
},
}
ticket = gp.build_export_ticket_if_eligible(draft)
assert ticket is not None
assert ticket["entry_family"] == "pivot_breakout"
assert "id" in ticket
assert ticket["hypothesis_type"] == "breakout"
def test_build_export_ticket_research_only(self):
draft = {
"id": "pivot_test_switch_mean_reversion",
"name": "Mean Reversion Pullback",
"hypothesis_type": "mean_reversion",
"mechanism_tag": "statistical",
"entry_family": "research_only",
"exit": {"stop_loss_pct": 0.04, "take_profit_rr": 2.0, "time_stop_days": 7},
"risk": {},
"pivot_metadata": {"source_strategy_id": "test"},
}
ticket = gp.build_export_ticket_if_eligible(draft)
assert ticket is None
# ---------------------------------------------------------------------------
# ID Sanitization
# ---------------------------------------------------------------------------
class TestSanitizeIdentifier:
def test_sanitize_identifier_special_chars(self):
result = gp.sanitize_identifier("Hello World! @#$%")
assert result == "hello_world"
assert " " not in result
assert all(c.isalnum() or c == "_" for c in result)
def test_sanitize_identifier_empty_string_returns_pivot(self):
result = gp.sanitize_identifier("")
assert result == "pivot"
result2 = gp.sanitize_identifier(" ")
assert result2 == "pivot"
# ---------------------------------------------------------------------------
# Cross-Validation with candidate_contract.py
# ---------------------------------------------------------------------------
class TestCrossValidation:
"""Verify generate_pivots constants match candidate_contract.py."""
@pytest.fixture(autouse=True)
def _load_candidate_contract(self):
"""Import candidate_contract via importlib for cross-validation."""
project_root = os.path.abspath(
os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
)
contract_path = os.path.join(
project_root,
"skills",
"edge-candidate-agent",
"scripts",
"candidate_contract.py",
)
if not os.path.exists(contract_path):
pytest.skip(f"candidate_contract.py not found at {contract_path}")
spec = importlib.util.spec_from_file_location("candidate_contract", contract_path)
self.contract_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.contract_mod)
def test_exportable_families_match_supported_entry_families(self):
assert gp.DEFAULT_EXPORTABLE_FAMILIES == self.contract_mod.SUPPORTED_ENTRY_FAMILIES
def test_required_validation_fields_consistent(self):
"""The fields validated by _validate_ticket_minimal must match
the required string fields in validate_ticket_payload."""
# candidate_contract checks these as required non-empty strings:
# ("id", "hypothesis_type", "entry_family")
# Our minimal validator must check the same set.
dummy_empty = {"id": "", "hypothesis_type": "", "entry_family": ""}
our_errors = gp._validate_ticket_minimal(dummy_empty)
their_errors = self.contract_mod.validate_ticket_payload(dummy_empty)
# Both should flag all three fields
our_fields = {e.split(".")[1].split(" ")[0] for e in our_errors if "must be" in e}
their_fields = {e.split(".")[1].split(" ")[0] for e in their_errors if "must be" in e}
required = {"id", "hypothesis_type", "entry_family"}
assert required <= our_fields
assert required <= their_fields
def test_validation_method_constraint_consistent(self):
"""Both validators must reject non-full_sample method and non-null oos_ratio."""
# Bad method
ticket_bad_method = {
"id": "t1",
"hypothesis_type": "breakout",
"entry_family": "pivot_breakout",
"validation": {"method": "walk_forward", "oos_ratio": None},
}
our_errors = gp._validate_ticket_minimal(ticket_bad_method)
their_errors = self.contract_mod.validate_ticket_payload(ticket_bad_method)
assert any("full_sample" in e for e in our_errors)
assert any("full_sample" in e for e in their_errors)
# Bad oos_ratio
ticket_bad_oos = {
"id": "t2",
"hypothesis_type": "breakout",
"entry_family": "pivot_breakout",
"validation": {"method": "full_sample", "oos_ratio": 0.3},
}
our_errors = gp._validate_ticket_minimal(ticket_bad_oos)
their_errors = self.contract_mod.validate_ticket_payload(ticket_bad_oos)
assert any("oos_ratio" in e for e in our_errors)
assert any("oos_ratio" in e for e in their_errors)
# Valid validation block — no errors from either
ticket_ok = {
"id": "t3",
"hypothesis_type": "breakout",
"entry_family": "pivot_breakout",
"validation": {"method": "full_sample"},
}
our_errors = gp._validate_ticket_minimal(ticket_ok)
their_errors = self.contract_mod.validate_ticket_payload(ticket_ok)
our_val_errors = [e for e in our_errors if "validation" in e]
their_val_errors = [e for e in their_errors if "validation" in e]
assert our_val_errors == []
assert their_val_errors == []
Related skills
How it compares
Pick strategy-pivot-designer over parameter-tuning skills when backtest metrics have plateaued and the strategy architecture itself needs structural redesign.
FAQ
When does strategy-pivot-designer trigger?
strategy-pivot-designer triggers when backtest scores plateau despite multiple refinement iterations and parameter tuning reaches a local optimum. The skill then proposes structurally different strategy architectures instead of more parameter tweaks.
How does strategy-pivot-designer fit the Edge pipeline?
strategy-pivot-designer acts as a feedback loop in the Edge pipeline connecting hint-extractor, concept-synthesizer, strategy-designer, and candidate-agent stages, breaking stagnation by redesigning strategy skeletons.