From a2a13c6b18a96aae23b4c5ed9eb8d9095ac8b85c Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:00:00 +0200 Subject: [PATCH 01/21] feat(strategy-optimizer): add DB models and migration for optimizer campaigns - Create StrategyOptimizerCampaign model (status, config, params, score tracking) - Create StrategyOptimizerEvaluation model (iteration-level results) - Register models in __init__.py - Add migration 0015 with indexes on strategy_id and campaign_id Part of GH-32 --- .../0015_strategy_optimizer_tables.py | 80 +++++++++++++++++++ backend/app/db/models/__init__.py | 4 + .../db/models/strategy_optimizer_campaign.py | 35 ++++++++ .../models/strategy_optimizer_evaluation.py | 22 +++++ 4 files changed, 141 insertions(+) create mode 100644 backend/alembic/versions/0015_strategy_optimizer_tables.py create mode 100644 backend/app/db/models/strategy_optimizer_campaign.py create mode 100644 backend/app/db/models/strategy_optimizer_evaluation.py diff --git a/backend/alembic/versions/0015_strategy_optimizer_tables.py b/backend/alembic/versions/0015_strategy_optimizer_tables.py new file mode 100644 index 0000000..7f4c928 --- /dev/null +++ b/backend/alembic/versions/0015_strategy_optimizer_tables.py @@ -0,0 +1,80 @@ +"""Add strategy optimizer tables (campaigns + evaluations) + +Revision ID: 0015_strategy_optimizer_tables +Revises: 0014_agent_skills_table +Create Date: 2026-06-21 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = '0015_strategy_optimizer_tables' +down_revision = '0014_agent_skills_table' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'strategy_optimizer_campaigns', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('strategy_id', sa.Integer(), sa.ForeignKey('strategies.id'), nullable=False), + sa.Column('status', sa.String(length=30), nullable=False, server_default='PENDING'), + sa.Column('config', sa.JSON(), nullable=False, server_default='{}'), + sa.Column('initial_params', sa.JSON(), nullable=False, server_default='{}'), + sa.Column('initial_score', sa.Float(), nullable=True), + sa.Column('best_params', sa.JSON(), nullable=True), + sa.Column('best_score', sa.Float(), nullable=True), + sa.Column('best_metrics', sa.JSON(), nullable=True), + sa.Column('current_iteration', sa.Integer(), nullable=False, server_default='0'), + sa.Column('celery_task_id', sa.String(length=255), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('completed_at', sa.DateTime(), nullable=True), + ) + op.create_index(op.f('ix_strategy_optimizer_campaigns_id'), 'strategy_optimizer_campaigns', ['id'], unique=False) + op.create_index( + 'ix_strategy_optimizer_campaigns_strategy_id', + 'strategy_optimizer_campaigns', + ['strategy_id'], + unique=False, + ) + op.create_index( + 'ix_strategy_optimizer_campaigns_strategy_status', + 'strategy_optimizer_campaigns', + ['strategy_id', 'status'], + unique=False, + ) + + op.create_table( + 'strategy_optimizer_evaluations', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('campaign_id', sa.Integer(), sa.ForeignKey('strategy_optimizer_campaigns.id'), nullable=False), + sa.Column('iteration', sa.Integer(), nullable=False), + sa.Column('params', sa.JSON(), nullable=False), + sa.Column('score', sa.Float(), nullable=False), + sa.Column('metrics', sa.JSON(), nullable=False, server_default='{}'), + sa.Column('evaluated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + ) + op.create_index(op.f('ix_strategy_optimizer_evaluations_id'), 'strategy_optimizer_evaluations', ['id'], unique=False) + op.create_index( + 'ix_strategy_optimizer_evaluations_campaign_id', + 'strategy_optimizer_evaluations', + ['campaign_id'], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index('ix_strategy_optimizer_evaluations_campaign_id', table_name='strategy_optimizer_evaluations') + op.drop_index(op.f('ix_strategy_optimizer_evaluations_id'), table_name='strategy_optimizer_evaluations') + op.drop_table('strategy_optimizer_evaluations') + + op.drop_index('ix_strategy_optimizer_campaigns_strategy_status', table_name='strategy_optimizer_campaigns') + op.drop_index('ix_strategy_optimizer_campaigns_strategy_id', table_name='strategy_optimizer_campaigns') + op.drop_index(op.f('ix_strategy_optimizer_campaigns_id'), table_name='strategy_optimizer_campaigns') + op.drop_table('strategy_optimizer_campaigns') diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py index 0f92949..060b04a 100644 --- a/backend/app/db/models/__init__.py +++ b/backend/app/db/models/__init__.py @@ -19,6 +19,8 @@ from app.db.models.prompt_template import PromptTemplate from app.db.models.run import AnalysisRun from app.db.models.strategy import Strategy +from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign +from app.db.models.strategy_optimizer_evaluation import StrategyOptimizerEvaluation from app.db.models.trading_config_version import TradingConfigVersion from app.db.models.user import User @@ -44,6 +46,8 @@ 'PortfolioSnapshot', 'LlmCallLog', 'Strategy', + 'StrategyOptimizerCampaign', + 'StrategyOptimizerEvaluation', 'TradingConfigVersion', 'GovernanceRun', ] diff --git a/backend/app/db/models/strategy_optimizer_campaign.py b/backend/app/db/models/strategy_optimizer_campaign.py new file mode 100644 index 0000000..87225d2 --- /dev/null +++ b/backend/app/db/models/strategy_optimizer_campaign.py @@ -0,0 +1,35 @@ +from datetime import datetime, timezone + +from sqlalchemy import DateTime, Float, ForeignKey, Integer, JSON, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class StrategyOptimizerCampaign(Base): + __tablename__ = 'strategy_optimizer_campaigns' + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + strategy_id: Mapped[int] = mapped_column(Integer, ForeignKey('strategies.id'), nullable=False, index=True) + status: Mapped[str] = mapped_column( + String(30), nullable=False, default='PENDING', + ) # PENDING|RUNNING|COMPLETED|CANCELLED|FAILED|REJECTED_BY_USER|ACCEPTED + config: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False) + initial_params: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False) + initial_score: Mapped[float | None] = mapped_column(Float, nullable=True) + best_params: Mapped[dict | None] = mapped_column(JSON, nullable=True) + best_score: Mapped[float | None] = mapped_column(Float, nullable=True) + best_metrics: Mapped[dict | None] = mapped_column(JSON, nullable=True) + current_iteration: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + celery_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, default=lambda: datetime.now(timezone.utc), nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/backend/app/db/models/strategy_optimizer_evaluation.py b/backend/app/db/models/strategy_optimizer_evaluation.py new file mode 100644 index 0000000..d3a85e9 --- /dev/null +++ b/backend/app/db/models/strategy_optimizer_evaluation.py @@ -0,0 +1,22 @@ +from datetime import datetime, timezone + +from sqlalchemy import DateTime, Float, ForeignKey, Integer, JSON +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class StrategyOptimizerEvaluation(Base): + __tablename__ = 'strategy_optimizer_evaluations' + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + campaign_id: Mapped[int] = mapped_column( + Integer, ForeignKey('strategy_optimizer_campaigns.id'), nullable=False, index=True, + ) + iteration: Mapped[int] = mapped_column(Integer, nullable=False) + params: Mapped[dict] = mapped_column(JSON, nullable=False) + score: Mapped[float] = mapped_column(Float, nullable=False) + metrics: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False) + evaluated_at: Mapped[datetime] = mapped_column( + DateTime, default=lambda: datetime.now(timezone.utc), nullable=False, + ) From 91839c6a0a9b0871647f6f3565d51cf3163a0972 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:00:07 +0200 Subject: [PATCH 02/21] feat(strategy-optimizer): implement optimizer service with evolutionary loop - Add optimizer_bounds.py: parameter bounds for all 20 templates - Add optimizer_adapter.py: encode/decode params, bounds validation, evaluator factory - Add optimizer_service.py: campaign CRUD + main optimization loop with mutation - Add config settings: queue, time limits, iteration limits Part of GH-32 --- backend/app/core/config.py | 8 + .../services/strategy/optimizer_adapter.py | 99 ++++++ .../app/services/strategy/optimizer_bounds.py | 145 ++++++++ .../services/strategy/optimizer_service.py | 317 ++++++++++++++++++ 4 files changed, 569 insertions(+) create mode 100644 backend/app/services/strategy/optimizer_adapter.py create mode 100644 backend/app/services/strategy/optimizer_bounds.py create mode 100644 backend/app/services/strategy/optimizer_service.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 29f88de..ab73a60 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -49,6 +49,14 @@ class Settings(BaseSettings): celery_backtest_time_limit_seconds: int = Field(default=1500, alias='CELERY_BACKTEST_TIME_LIMIT_SECONDS') celery_benchmark_soft_time_limit_seconds: int = Field(default=1200, alias='CELERY_BENCHMARK_SOFT_TIME_LIMIT_SECONDS') celery_benchmark_time_limit_seconds: int = Field(default=1500, alias='CELERY_BENCHMARK_TIME_LIMIT_SECONDS') + celery_optimizer_queue: str = Field(default='strategy-optimizer', alias='CELERY_OPTIMIZER_QUEUE') + celery_optimizer_soft_time_limit_seconds: int = Field(default=420, alias='CELERY_OPTIMIZER_SOFT_TIME_LIMIT_SECONDS') + celery_optimizer_time_limit_seconds: int = Field(default=480, alias='CELERY_OPTIMIZER_TIME_LIMIT_SECONDS') + optimizer_max_iterations: int = Field(default=50, alias='OPTIMIZER_MAX_ITERATIONS') + optimizer_max_iterations_limit: int = Field(default=200, alias='OPTIMIZER_MAX_ITERATIONS_LIMIT') + optimizer_time_budget_seconds: int = Field(default=300, alias='OPTIMIZER_TIME_BUDGET_SECONDS') + optimizer_time_budget_limit: int = Field(default=1800, alias='OPTIMIZER_TIME_BUDGET_LIMIT') + optimizer_max_candidates_per_iteration: int = Field(default=3, alias='OPTIMIZER_MAX_CANDIDATES_PER_ITERATION') ollama_base_url: str = Field(default='https://ollama.com', alias='OLLAMA_BASE_URL') ollama_api_key: str = Field(default='', alias='OLLAMA_API_KEY') diff --git a/backend/app/services/strategy/optimizer_adapter.py b/backend/app/services/strategy/optimizer_adapter.py new file mode 100644 index 0000000..1b83845 --- /dev/null +++ b/backend/app/services/strategy/optimizer_adapter.py @@ -0,0 +1,99 @@ +"""Adapter between the optimizer loop and the BacktestEngine. + +Provides encoding/decoding of parameters and an evaluator factory +that runs a backtest and returns a scalar fitness score. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Callable + +from app.services.strategy.optimizer_bounds import TEMPLATE_PARAM_BOUNDS, clamp_params +from app.services.strategy.generation_optimizer import compute_generation_candidate_score +from app.services.strategy.lookback_windows import strategy_lookback_days +from app.services.strategy.template_catalog import sanitize_strategy_params_for_template + +logger = logging.getLogger(__name__) + + +def encode_params_to_program(template: str, params: dict[str, Any]) -> str: + """Encode a parameter dict as a minimal JSON program string.""" + payload = {'template': template, 'params': params} + return json.dumps(payload, sort_keys=True) + + +def decode_program_to_params(program: str) -> dict[str, Any]: + """Decode a program string back to parameter dict.""" + payload = json.loads(program) + return dict(payload.get('params', {})) + + +def validate_params_in_bounds(template: str, params: dict[str, Any]) -> bool: + """Return True if all parameters are within template bounds.""" + bounds = TEMPLATE_PARAM_BOUNDS.get(template, {}) + if not bounds: + return True + for key, (lo, hi) in bounds.items(): + if key not in params: + continue + try: + val = float(params[key]) + except (TypeError, ValueError): + return False + if val < lo or val > hi: + return False + return True + + +def build_evaluator( + template: str, + symbol: str, + timeframe: str, + lookback_days: int | None = None, +) -> Callable[[dict[str, Any]], dict[str, Any]]: + """Build an evaluation function that backtests params and returns score + metrics. + + Returns a callable: (params: dict) -> {"score": float, "metrics": dict} + """ + + def evaluator(params: dict[str, Any]) -> dict[str, Any]: + from app.services.backtest.engine import BacktestEngine + + lb_days = lookback_days or strategy_lookback_days(symbol) + end_date = datetime.now(timezone.utc).strftime('%Y-%m-%d') + start_date = (datetime.now(timezone.utc) - timedelta(days=lb_days)).strftime('%Y-%m-%d') + + # Sanitize params for the template + sanitized, _ = sanitize_strategy_params_for_template(template, params) + + # Clamp to bounds + clamped = clamp_params(template, sanitized) + + engine = BacktestEngine() + try: + result = engine.run( + symbol, + timeframe, + start_date, + end_date, + strategy=template, + db=None, + strategy_params=clamped, + run_id=None, + ) + except Exception as exc: + logger.warning( + 'optimizer_evaluator_backtest_failed template=%s err=%s', + template, + str(exc)[:200], + ) + return {'score': 0.0, 'metrics': {}} + + metrics = dict(result.metrics or {}) + score = compute_generation_candidate_score(metrics) + return {'score': score, 'metrics': metrics} + + return evaluator diff --git a/backend/app/services/strategy/optimizer_bounds.py b/backend/app/services/strategy/optimizer_bounds.py new file mode 100644 index 0000000..575a4e1 --- /dev/null +++ b/backend/app/services/strategy/optimizer_bounds.py @@ -0,0 +1,145 @@ +"""Optimizer parameter bounds derived from _TEMPLATE_PRESETS. + +Each template defines min/max bounds for every tuneable parameter. +The optimizer mutates parameters within these bounds. +""" + +from __future__ import annotations + +from typing import Any + + +# Bounds per template: {param_name: (min_value, max_value)} +TEMPLATE_PARAM_BOUNDS: dict[str, dict[str, tuple[float, float]]] = { + # ── Trend Following ── + 'ema_crossover': { + 'ema_fast': (3, 20), + 'ema_slow': (15, 55), + 'rsi_filter': (15, 45), + }, + 'supertrend': { + 'atr_period': (5, 21), + 'atr_multiplier': (1.0, 5.0), + }, + 'adx_trend': { + 'adx_period': (7, 25), + 'adx_threshold': (15, 40), + 'di_period': (7, 25), + }, + 'ichimoku': { + 'tenkan': (5, 15), + 'kijun': (18, 40), + 'senkou_b': (35, 75), + }, + 'parabolic_sar': { + 'af_start': (0.005, 0.05), + 'af_step': (0.005, 0.05), + 'af_max': (0.1, 0.4), + }, + 'donchian_breakout': { + 'entry_period': (5, 70), + 'exit_period': (3, 35), + }, + # ── Mean Reversion ── + 'rsi_mean_reversion': { + 'rsi_period': (5, 28), + 'oversold': (15, 40), + 'overbought': (60, 90), + }, + 'stochastic_reversal': { + 'k_period': (5, 21), + 'd_period': (2, 7), + 'oversold': (10, 30), + 'overbought': (70, 95), + }, + 'williams_r': { + 'period': (5, 28), + 'oversold': (-95, -70), + 'overbought': (-30, -5), + }, + 'cci_reversal': { + 'cci_period': (10, 30), + 'oversold': (-200, -50), + 'overbought': (50, 200), + }, + 'keltner_reversion': { + 'ema_period': (10, 30), + 'atr_period': (7, 21), + 'atr_multiplier': (0.8, 3.0), + }, + # ── Breakout / Volatility ── + 'bollinger_breakout': { + 'bb_period': (10, 40), + 'bb_std': (1.0, 3.5), + }, + 'squeeze_momentum': { + 'bb_period': (10, 35), + 'bb_std': (1.0, 3.5), + 'kc_period': (10, 35), + 'kc_multiplier': (0.8, 3.0), + }, + 'atr_trailing_stop': { + 'atr_period': (7, 28), + 'atr_multiplier': (1.0, 5.0), + 'trend_ema': (15, 60), + }, + # ── Momentum ── + 'macd_divergence': { + 'fast': (5, 20), + 'slow': (18, 45), + 'signal': (4, 15), + }, + 'roc_momentum': { + 'roc_period': (5, 30), + 'signal_period': (3, 18), + 'threshold': (0.2, 3.0), + }, + 'vwap_strategy': { + 'trend_ema': (15, 60), + 'deviation_pct': (0.1, 1.0), + }, + # ── Hybrid ── + 'triple_ema': { + 'ema_1': (2, 12), + 'ema_2': (6, 25), + 'ema_3': (18, 70), + }, + 'macd_rsi_combo': { + 'macd_fast': (5, 18), + 'macd_slow': (18, 40), + 'macd_signal': (4, 15), + 'rsi_period': (7, 25), + 'rsi_oversold': (20, 40), + 'rsi_overbought': (60, 85), + }, + 'pivot_points': { + 'lookback': (1, 10), + }, +} + + +def get_bounds_for_template(template: str) -> dict[str, tuple[float, float]]: + """Return parameter bounds for a template. Empty dict if template unknown.""" + return TEMPLATE_PARAM_BOUNDS.get(template, {}) + + +def clamp_params(template: str, params: dict[str, Any]) -> dict[str, Any]: + """Clamp parameters to template bounds. Non-bounded params pass through.""" + bounds = get_bounds_for_template(template) + result: dict[str, Any] = {} + for key, value in params.items(): + if key in bounds: + lo, hi = bounds[key] + try: + numeric = float(value) + clamped = max(lo, min(hi, numeric)) + # Preserve int type for integer bounds + if isinstance(value, int) and lo == int(lo) and hi == int(hi): + result[key] = int(round(clamped)) + else: + result[key] = round(clamped, 4) + except (TypeError, ValueError): + result[key] = value + else: + result[key] = value + return result diff --git a/backend/app/services/strategy/optimizer_service.py b/backend/app/services/strategy/optimizer_service.py new file mode 100644 index 0000000..a4d986d --- /dev/null +++ b/backend/app/services/strategy/optimizer_service.py @@ -0,0 +1,317 @@ +"""Strategy Optimizer service — orchestrates evolutionary campaign loop. + +Provides CRUD operations on campaigns and the main optimization loop +that mutates parameters within template bounds, evaluates via BacktestEngine, +and persists progress to DB. +""" + +from __future__ import annotations + +import logging +import random +import time +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.db.models.strategy import Strategy +from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign +from app.db.models.strategy_optimizer_evaluation import StrategyOptimizerEvaluation +from app.services.strategy.optimizer_adapter import build_evaluator, validate_params_in_bounds +from app.services.strategy.optimizer_bounds import clamp_params, get_bounds_for_template + +logger = logging.getLogger(__name__) + +_ACTIVE_STATUSES = ('PENDING', 'RUNNING') + + +def create_campaign( + db: Session, + strategy_id: int, + config: dict[str, Any], +) -> StrategyOptimizerCampaign: + """Create a new optimization campaign for a strategy. + + Raises ValueError if a RUNNING or PENDING campaign already exists. + """ + existing = ( + db.query(StrategyOptimizerCampaign) + .filter( + StrategyOptimizerCampaign.strategy_id == strategy_id, + StrategyOptimizerCampaign.status.in_(_ACTIVE_STATUSES), + ) + .first() + ) + if existing: + raise ValueError(f'Campaign already active (id={existing.id}, status={existing.status})') + + strategy = db.get(Strategy, strategy_id) + if strategy is None: + raise ValueError(f'Strategy {strategy_id} not found') + + settings = get_settings() + max_iterations = min( + int(config.get('max_iterations', settings.optimizer_max_iterations)), + settings.optimizer_max_iterations_limit, + ) + time_budget = min( + int(config.get('time_budget_seconds', settings.optimizer_time_budget_seconds)), + settings.optimizer_time_budget_limit, + ) + + campaign = StrategyOptimizerCampaign( + strategy_id=strategy_id, + status='PENDING', + config={ + 'max_iterations': max_iterations, + 'time_budget_seconds': time_budget, + 'max_candidates_per_iteration': int( + config.get('max_candidates_per_iteration', settings.optimizer_max_candidates_per_iteration) + ), + }, + initial_params=dict(strategy.params or {}), + initial_score=None, + best_params=None, + best_score=None, + best_metrics=None, + current_iteration=0, + ) + db.add(campaign) + db.commit() + db.refresh(campaign) + return campaign + + +def get_active_campaign(db: Session, strategy_id: int) -> StrategyOptimizerCampaign | None: + """Return the latest campaign for a strategy (most recent first).""" + return ( + db.query(StrategyOptimizerCampaign) + .filter(StrategyOptimizerCampaign.strategy_id == strategy_id) + .order_by(StrategyOptimizerCampaign.created_at.desc()) + .first() + ) + + +def accept_campaign(db: Session, campaign_id: int) -> StrategyOptimizerCampaign: + """Accept a completed campaign: apply best_params to the strategy.""" + campaign = db.get(StrategyOptimizerCampaign, campaign_id) + if campaign is None: + raise ValueError(f'Campaign {campaign_id} not found') + if campaign.status != 'COMPLETED': + raise ValueError(f'Cannot accept campaign in status {campaign.status}') + + strategy = db.get(Strategy, campaign.strategy_id) + if strategy is None: + raise ValueError(f'Strategy {campaign.strategy_id} not found') + + # Apply best params and reset strategy for re-validation + strategy.params = dict(campaign.best_params or strategy.params) + strategy.status = 'BACKTESTING' + strategy.score = 0.0 + strategy.metrics = {} + + campaign.status = 'ACCEPTED' + db.commit() + db.refresh(campaign) + + # Launch re-validation backtest + try: + from app.tasks.strategy_backtest_task import execute as execute_strategy_backtest + settings = get_settings() + execute_strategy_backtest.apply_async( + args=[strategy.id], + queue=settings.celery_backtest_queue, + ignore_result=True, + ) + except Exception: + logger.warning('optimizer_accept_revalidation_enqueue_failed strategy_id=%s', strategy.id, exc_info=True) + + return campaign + + +def reject_campaign(db: Session, campaign_id: int) -> StrategyOptimizerCampaign: + """Reject a completed campaign: mark as rejected, leave strategy unchanged.""" + campaign = db.get(StrategyOptimizerCampaign, campaign_id) + if campaign is None: + raise ValueError(f'Campaign {campaign_id} not found') + if campaign.status != 'COMPLETED': + raise ValueError(f'Cannot reject campaign in status {campaign.status}') + + campaign.status = 'REJECTED_BY_USER' + db.commit() + db.refresh(campaign) + return campaign + + +def cancel_campaign(db: Session, campaign_id: int) -> StrategyOptimizerCampaign: + """Cancel a running/pending campaign.""" + campaign = db.get(StrategyOptimizerCampaign, campaign_id) + if campaign is None: + raise ValueError(f'Campaign {campaign_id} not found') + if campaign.status not in _ACTIVE_STATUSES: + raise ValueError(f'Cannot cancel campaign in status {campaign.status}') + + campaign.status = 'CANCELLED' + campaign.completed_at = datetime.now(timezone.utc) + db.commit() + db.refresh(campaign) + + # Revoke celery task if known + if campaign.celery_task_id: + try: + from app.tasks.celery_app import celery_app + celery_app.control.revoke(campaign.celery_task_id, terminate=True) + except Exception: + logger.warning('optimizer_cancel_revoke_failed task_id=%s', campaign.celery_task_id, exc_info=True) + + return campaign + + +def _mutate_params( + base_params: dict[str, Any], + template: str, + perturbation_pct: float = 0.20, +) -> dict[str, Any]: + """Mutate parameters by random perturbation within template bounds.""" + bounds = get_bounds_for_template(template) + mutated: dict[str, Any] = {} + + for key, value in base_params.items(): + if key not in bounds: + mutated[key] = value + continue + try: + numeric = float(value) + except (TypeError, ValueError): + mutated[key] = value + continue + + lo, hi = bounds[key] + # Random perturbation: uniform in [-perturbation_pct, +perturbation_pct] + factor = 1.0 + random.uniform(-perturbation_pct, perturbation_pct) + new_val = numeric * factor + + # Clamp to bounds + new_val = max(lo, min(hi, new_val)) + + # Preserve int type for integer-valued params + if isinstance(value, int): + mutated[key] = int(round(new_val)) + else: + mutated[key] = round(new_val, 4) + + return mutated + + +def run_optimization_loop(db: Session, campaign_id: int) -> None: + """Main optimization loop — called by the Celery task.""" + campaign = db.get(StrategyOptimizerCampaign, campaign_id) + if campaign is None: + raise ValueError(f'Campaign {campaign_id} not found') + + strategy = db.get(Strategy, campaign.strategy_id) + if strategy is None: + raise ValueError(f'Strategy not found for campaign {campaign_id}') + + # Mark as running + campaign.status = 'RUNNING' + db.commit() + + config = campaign.config or {} + max_iterations = int(config.get('max_iterations', 50)) + time_budget = int(config.get('time_budget_seconds', 300)) + max_candidates = int(config.get('max_candidates_per_iteration', 3)) + + # Build evaluator + evaluator = build_evaluator( + template=strategy.template, + symbol=strategy.symbol, + timeframe=strategy.timeframe, + ) + + # Evaluate initial params + initial_result = evaluator(campaign.initial_params) + campaign.initial_score = initial_result['score'] + campaign.best_params = dict(campaign.initial_params) + campaign.best_score = initial_result['score'] + campaign.best_metrics = initial_result['metrics'] + db.commit() + + # Record initial evaluation + db.add(StrategyOptimizerEvaluation( + campaign_id=campaign_id, + iteration=0, + params=campaign.initial_params, + score=initial_result['score'], + metrics=initial_result['metrics'], + )) + db.commit() + + start_time = time.time() + + for iteration in range(1, max_iterations + 1): + # Check cancellation + db.refresh(campaign) + if campaign.status == 'CANCELLED': + logger.info('optimizer_loop_cancelled campaign_id=%s iteration=%d', campaign_id, iteration) + return + + # Check time budget + elapsed = time.time() - start_time + if elapsed >= time_budget: + logger.info('optimizer_loop_time_budget_reached campaign_id=%s elapsed=%.1fs', campaign_id, elapsed) + break + + # Generate and evaluate candidates for this iteration + best_iteration_score = campaign.best_score or 0.0 + best_iteration_params = dict(campaign.best_params or campaign.initial_params) + best_iteration_metrics: dict[str, Any] = dict(campaign.best_metrics or {}) + + for _ in range(max_candidates): + candidate_params = _mutate_params( + base_params=best_iteration_params, + template=strategy.template, + ) + + # Skip if params are out of bounds or identical + if not validate_params_in_bounds(strategy.template, candidate_params): + continue + + result = evaluator(candidate_params) + score = result['score'] + + # Record evaluation + db.add(StrategyOptimizerEvaluation( + campaign_id=campaign_id, + iteration=iteration, + params=candidate_params, + score=score, + metrics=result['metrics'], + )) + + if score > best_iteration_score: + best_iteration_score = score + best_iteration_params = candidate_params + best_iteration_metrics = result['metrics'] + + # Update campaign progress + campaign.current_iteration = iteration + if best_iteration_score > (campaign.best_score or 0.0): + campaign.best_score = best_iteration_score + campaign.best_params = best_iteration_params + campaign.best_metrics = best_iteration_metrics + db.commit() + + # Mark completed + campaign.status = 'COMPLETED' + campaign.completed_at = datetime.now(timezone.utc) + db.commit() + logger.info( + 'optimizer_loop_completed campaign_id=%s iterations=%d best_score=%.4f initial_score=%.4f', + campaign_id, + campaign.current_iteration, + campaign.best_score or 0.0, + campaign.initial_score or 0.0, + ) From 40ce1fc8a06a61846e32dd827c7402bf411b28ef Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:00:15 +0200 Subject: [PATCH 03/21] feat(strategy-optimizer): add Celery task on dedicated queue - Create optimizer_task.py: execute(campaign_id) with soft/hard time limits - Register in celery_app.py include list and task_routes - Route to dedicated 'strategy-optimizer' queue (isolated from backtests) - Add openevolve>=0.1.0 to requirements.txt Part of GH-32 --- backend/app/tasks/celery_app.py | 4 +- backend/app/tasks/optimizer_task.py | 69 +++++++++++++++++++++++++++++ backend/requirements.txt | 1 + 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 backend/app/tasks/optimizer_task.py diff --git a/backend/app/tasks/celery_app.py b/backend/app/tasks/celery_app.py index 916a4cc..c3c2677 100644 --- a/backend/app/tasks/celery_app.py +++ b/backend/app/tasks/celery_app.py @@ -18,7 +18,7 @@ 'trading_platform', broker=settings.celery_broker_url, backend=backend_url, - include=['app.tasks.run_analysis_task', 'app.tasks.backtest_task', 'app.tasks.strategy_backtest_task', 'app.tasks.strategy_monitor_task', 'app.tasks.portfolio_tasks', 'app.tasks.governance_task', 'app.tasks.benchmark_task'], + include=['app.tasks.run_analysis_task', 'app.tasks.backtest_task', 'app.tasks.strategy_backtest_task', 'app.tasks.strategy_monitor_task', 'app.tasks.portfolio_tasks', 'app.tasks.governance_task', 'app.tasks.benchmark_task', 'app.tasks.optimizer_task'], ) celery_app.conf.task_routes = { 'app.tasks.run_analysis_task.*': {'queue': settings.celery_analysis_queue}, @@ -28,6 +28,7 @@ 'app.tasks.portfolio_tasks.*': {'queue': settings.celery_analysis_queue}, 'app.tasks.governance_task.*': {'queue': settings.celery_analysis_queue}, 'app.tasks.benchmark_task.*': {'queue': settings.celery_benchmark_queue}, + 'app.tasks.optimizer_task.*': {'queue': settings.celery_optimizer_queue}, } celery_app.conf.task_default_queue = settings.celery_analysis_queue celery_app.conf.result_backend = backend_url @@ -49,6 +50,7 @@ import app.tasks.portfolio_tasks # noqa: E402,F401 import app.tasks.governance_task # noqa: E402,F401 import app.tasks.benchmark_task # noqa: E402,F401 +import app.tasks.optimizer_task # noqa: E402,F401 # Beat schedule: periodic strategy monitoring (every 30 seconds) celery_app.conf.beat_schedule = { diff --git a/backend/app/tasks/optimizer_task.py b/backend/app/tasks/optimizer_task.py new file mode 100644 index 0000000..41bb910 --- /dev/null +++ b/backend/app/tasks/optimizer_task.py @@ -0,0 +1,69 @@ +"""Celery task for strategy optimizer campaigns. + +Executes on a dedicated queue (strategy-optimizer) to isolate from backtests. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from celery import current_task + +from app.core.config import get_settings +from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign +from app.db.session import SessionLocal +from app.services.strategy.optimizer_service import run_optimization_loop +from app.tasks.celery_app import celery_app + +logger = logging.getLogger(__name__) +settings = get_settings() + + +@celery_app.task( + name='app.tasks.optimizer_task.execute', + bind=True, + acks_late=True, + soft_time_limit=settings.celery_optimizer_soft_time_limit_seconds, + time_limit=settings.celery_optimizer_time_limit_seconds, + queue=settings.celery_optimizer_queue, +) +def execute(self, campaign_id: int) -> dict: + """Execute optimization campaign loop.""" + db = SessionLocal() + try: + campaign = db.get(StrategyOptimizerCampaign, campaign_id) + if campaign is None: + logger.error('optimizer_task_campaign_not_found id=%s', campaign_id) + return {'status': 'error', 'message': f'Campaign {campaign_id} not found'} + + # Store celery task id for potential revocation + campaign.celery_task_id = current_task.request.id + db.commit() + + logger.info('optimizer_task_started campaign_id=%s strategy_id=%s', campaign_id, campaign.strategy_id) + + run_optimization_loop(db, campaign_id) + + db.refresh(campaign) + return { + 'status': campaign.status, + 'campaign_id': campaign_id, + 'best_score': campaign.best_score, + 'iterations': campaign.current_iteration, + } + except Exception as exc: + logger.error('optimizer_task_failed campaign_id=%s err=%s', campaign_id, str(exc)[:300], exc_info=True) + # Mark campaign as FAILED + try: + campaign = db.get(StrategyOptimizerCampaign, campaign_id) + if campaign and campaign.status in ('PENDING', 'RUNNING'): + campaign.status = 'FAILED' + campaign.error_message = str(exc)[:500] + campaign.completed_at = datetime.now(timezone.utc) + db.commit() + except Exception: + logger.warning('optimizer_task_failed_to_mark_failed campaign_id=%s', campaign_id, exc_info=True) + return {'status': 'error', 'campaign_id': campaign_id, 'message': str(exc)[:300]} + finally: + db.close() diff --git a/backend/requirements.txt b/backend/requirements.txt index 8ff762b..47b8d56 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -30,3 +30,4 @@ email-validator==2.2.0 pytest==8.4.1 pytest-asyncio==1.1.0 opentelemetry-exporter-otlp-proto-http>=1.39.0 +openevolve>=0.1.0 From 466e3c04338f130278695646881cd7ed4bcabfb2 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:00:22 +0200 Subject: [PATCH 04/21] feat(strategy-optimizer): add 5 REST API endpoints for campaign lifecycle - POST /strategies/{id}/optimize: launch campaign (201) - GET /strategies/{id}/optimizer-campaign: poll status - POST /optimizer-campaign/{id}/accept: apply best_params - POST /optimizer-campaign/{id}/reject: discard results - DELETE /optimizer-campaign/{id}: cancel running campaign (204) - Add OptimizerLaunchRequest and OptimizerCampaignOut schemas - Error codes: 409 conflict, 422 invalid state, 404 not found Part of GH-32 --- backend/app/api/routes/strategies.py | 129 +++++++++++++++++++++++++++ backend/app/schemas/optimizer.py | 65 ++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 backend/app/schemas/optimizer.py diff --git a/backend/app/api/routes/strategies.py b/backend/app/api/routes/strategies.py index d5018a1..74e68fe 100644 --- a/backend/app/api/routes/strategies.py +++ b/backend/app/api/routes/strategies.py @@ -16,6 +16,7 @@ from app.db.models.user import User from app.db.session import get_db from app.schemas.strategy import StrategyOut, StrategyGenerateRequest, StrategyEditRequest, StrategyPromoteRequest, StrategyStartMonitoringRequest +from app.schemas.optimizer import OptimizerLaunchRequest, OptimizerCampaignOut from app.services.backtest.engine import BacktestEngine from app.services.llm.provider_client import LlmClient from app.services.strategy.generation_optimizer import ( @@ -860,3 +861,131 @@ def stop_monitoring( db.refresh(strategy) logger.info('strategy_monitoring_stopped id=%s', strategy.strategy_id) return StrategyOut.model_validate(strategy) + + +# ─── Strategy Optimizer Endpoints ─────────────────────────────────────────────── + + +@router.post('/{strategy_id}/optimize', response_model=OptimizerCampaignOut, status_code=201) +def start_optimization( + strategy_id: int, + payload: OptimizerLaunchRequest, + db: Session = Depends(get_db), + user: User = Depends(require_roles(Role.SUPER_ADMIN, Role.ADMIN, Role.TRADER_OPERATOR)), +) -> OptimizerCampaignOut: + """Launch an optimization campaign for a VALIDATED strategy.""" + from app.services.strategy.optimizer_service import create_campaign + from app.tasks.optimizer_task import execute as optimizer_execute + + strategy = db.get(Strategy, strategy_id) + if not strategy: + raise HTTPException(status_code=404, detail='Strategy not found') + if strategy.status != 'VALIDATED': + raise HTTPException(status_code=422, detail=f'Strategy must be VALIDATED (current: {strategy.status})') + + config: dict = {} + if payload.max_iterations is not None: + config['max_iterations'] = payload.max_iterations + if payload.time_budget_seconds is not None: + config['time_budget_seconds'] = payload.time_budget_seconds + if payload.max_candidates_per_iteration is not None: + config['max_candidates_per_iteration'] = payload.max_candidates_per_iteration + + try: + campaign = create_campaign(db, strategy_id, config) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + + # Launch celery task + settings = get_settings() + try: + result = optimizer_execute.apply_async( + args=[campaign.id], + queue=settings.celery_optimizer_queue, + ignore_result=True, + ) + campaign.celery_task_id = result.id + db.commit() + db.refresh(campaign) + except Exception: + logger.warning('optimizer_task_enqueue_failed campaign_id=%s', campaign.id, exc_info=True) + + return OptimizerCampaignOut.from_campaign(campaign) + + +@router.get('/{strategy_id}/optimizer-campaign', response_model=OptimizerCampaignOut) +def get_optimizer_campaign( + strategy_id: int, + db: Session = Depends(get_db), + user: User = Depends(require_roles(Role.SUPER_ADMIN, Role.ADMIN, Role.TRADER_OPERATOR)), +) -> OptimizerCampaignOut: + """Get the latest optimizer campaign for a strategy.""" + from app.services.strategy.optimizer_service import get_active_campaign + + strategy = db.get(Strategy, strategy_id) + if not strategy: + raise HTTPException(status_code=404, detail='Strategy not found') + + campaign = get_active_campaign(db, strategy_id) + if not campaign: + raise HTTPException(status_code=404, detail='No optimizer campaign found for this strategy') + + return OptimizerCampaignOut.from_campaign(campaign) + + +@router.post('/optimizer-campaign/{campaign_id}/accept', response_model=OptimizerCampaignOut) +def accept_optimizer_campaign( + campaign_id: int, + db: Session = Depends(get_db), + user: User = Depends(require_roles(Role.SUPER_ADMIN, Role.ADMIN, Role.TRADER_OPERATOR)), +) -> OptimizerCampaignOut: + """Accept a completed campaign — applies best params to the strategy.""" + from app.services.strategy.optimizer_service import accept_campaign + + try: + campaign = accept_campaign(db, campaign_id) + except ValueError as exc: + msg = str(exc) + if 'not found' in msg.lower(): + raise HTTPException(status_code=404, detail=msg) + raise HTTPException(status_code=409, detail=msg) + + return OptimizerCampaignOut.from_campaign(campaign) + + +@router.post('/optimizer-campaign/{campaign_id}/reject', response_model=OptimizerCampaignOut) +def reject_optimizer_campaign( + campaign_id: int, + db: Session = Depends(get_db), + user: User = Depends(require_roles(Role.SUPER_ADMIN, Role.ADMIN, Role.TRADER_OPERATOR)), +) -> OptimizerCampaignOut: + """Reject a completed campaign — leave strategy params unchanged.""" + from app.services.strategy.optimizer_service import reject_campaign + + try: + campaign = reject_campaign(db, campaign_id) + except ValueError as exc: + msg = str(exc) + if 'not found' in msg.lower(): + raise HTTPException(status_code=404, detail=msg) + raise HTTPException(status_code=409, detail=msg) + + return OptimizerCampaignOut.from_campaign(campaign) + + +@router.delete('/optimizer-campaign/{campaign_id}', status_code=204) +def cancel_optimizer_campaign( + campaign_id: int, + db: Session = Depends(get_db), + user: User = Depends(require_roles(Role.SUPER_ADMIN, Role.ADMIN, Role.TRADER_OPERATOR)), +) -> None: + """Cancel a running/pending campaign.""" + from app.services.strategy.optimizer_service import cancel_campaign + + try: + cancel_campaign(db, campaign_id) + except ValueError as exc: + msg = str(exc) + if 'not found' in msg.lower(): + raise HTTPException(status_code=404, detail=msg) + raise HTTPException(status_code=409, detail=msg) diff --git a/backend/app/schemas/optimizer.py b/backend/app/schemas/optimizer.py new file mode 100644 index 0000000..73232cc --- /dev/null +++ b/backend/app/schemas/optimizer.py @@ -0,0 +1,65 @@ +"""Pydantic schemas for the Strategy Optimizer campaign endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +class OptimizerLaunchRequest(BaseModel): + """Request body to launch an optimization campaign.""" + max_iterations: int | None = Field(default=None, ge=1, le=200, description='Maximum iterations (default: from config)') + time_budget_seconds: int | None = Field(default=None, ge=10, le=1800, description='Time budget in seconds (default: from config)') + max_candidates_per_iteration: int | None = Field(default=None, ge=1, le=10, description='Candidates per iteration') + + +class OptimizerCampaignOut(BaseModel): + """Response schema for an optimizer campaign.""" + id: int + strategy_id: int + status: str + current_iteration: int + max_iterations: int + best_score: float | None = None + best_params: dict[str, Any] | None = None + initial_params: dict[str, Any] + initial_score: float | None = None + best_metrics: dict[str, Any] | None = None + elapsed_seconds: float | None = None + error_message: str | None = None + created_at: datetime + completed_at: datetime | None = None + + model_config = {'from_attributes': True} + + @classmethod + def from_campaign(cls, campaign: Any) -> 'OptimizerCampaignOut': + """Build response from a StrategyOptimizerCampaign model instance.""" + config = campaign.config or {} + max_iterations = int(config.get('max_iterations', 50)) + + elapsed: float | None = None + if campaign.completed_at and campaign.created_at: + elapsed = (campaign.completed_at - campaign.created_at).total_seconds() + elif campaign.status == 'RUNNING' and campaign.created_at: + from datetime import timezone + elapsed = (datetime.now(timezone.utc) - campaign.created_at).total_seconds() + + return cls( + id=campaign.id, + strategy_id=campaign.strategy_id, + status=campaign.status, + current_iteration=campaign.current_iteration, + max_iterations=max_iterations, + best_score=campaign.best_score, + best_params=campaign.best_params, + initial_params=campaign.initial_params, + initial_score=campaign.initial_score, + best_metrics=campaign.best_metrics, + elapsed_seconds=elapsed, + error_message=campaign.error_message, + created_at=campaign.created_at, + completed_at=campaign.completed_at, + ) From 71cffb5a3ee35a99cfebdb2770020211bfc2a4ae Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:00:29 +0200 Subject: [PATCH 05/21] feat(strategy-optimizer): add frontend OptimizerPanel component - Add 5 API methods to client.ts (launch, poll, accept, reject, cancel) - Create OptimizerPanel.tsx: config modal, progress bar, results with diff - Integrate panel into StrategyCard for VALIDATED strategies - TypeScript strict: no errors (tsc --noEmit passes) Part of GH-32 --- frontend/src/api/client.ts | 11 + frontend/src/components/OptimizerPanel.tsx | 268 +++++++++++++++++++++ frontend/src/pages/StrategiesPage.tsx | 19 ++ 3 files changed, 298 insertions(+) create mode 100644 frontend/src/components/OptimizerPanel.tsx diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ef7d04a..e7314e0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -290,6 +290,17 @@ export const api = { }, token), stopMonitoring: (token: string, id: number) => request(`/strategies/${id}/stop-monitoring`, { method: 'POST' }, token), + // Optimizer + launchOptimizer: (token: string, strategyId: number, config: { max_iterations?: number; time_budget_seconds?: number; max_candidates_per_iteration?: number }) => + request(`/strategies/${strategyId}/optimize`, { method: 'POST', body: JSON.stringify(config) }, token), + getOptimizerCampaign: (token: string, strategyId: number) => + request(`/strategies/${strategyId}/optimizer-campaign`, {}, token), + acceptCampaign: (token: string, campaignId: number) => + request(`/strategies/optimizer-campaign/${campaignId}/accept`, { method: 'POST' }, token), + rejectCampaign: (token: string, campaignId: number) => + request(`/strategies/optimizer-campaign/${campaignId}/reject`, { method: 'POST' }, token), + cancelCampaign: (token: string, campaignId: number) => + request(`/strategies/optimizer-campaign/${campaignId}`, { method: 'DELETE' }, token), // Governance listGovernanceRecommendations: (token: string, params: { limit?: number; symbol?: string; status?: string; approval_status?: string } = {}) => { const search = new URLSearchParams(); diff --git a/frontend/src/components/OptimizerPanel.tsx b/frontend/src/components/OptimizerPanel.tsx new file mode 100644 index 0000000..24f07a6 --- /dev/null +++ b/frontend/src/components/OptimizerPanel.tsx @@ -0,0 +1,268 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Zap, Loader2, Check, X, Ban } from 'lucide-react'; +import { api } from '../api/client'; + +interface OptimizerCampaign { + id: number; + strategy_id: number; + status: string; + current_iteration: number; + max_iterations: number; + best_score: number | null; + best_params: Record | null; + initial_params: Record; + initial_score: number | null; + best_metrics: Record | null; + elapsed_seconds: number | null; + error_message: string | null; + created_at: string; + completed_at: string | null; +} + +interface OptimizerPanelProps { + strategyId: number; + strategyStatus: string; + token: string; + onStrategyUpdated: () => void; +} + +export function OptimizerPanel({ strategyId, strategyStatus, token, onStrategyUpdated }: OptimizerPanelProps) { + const [campaign, setCampaign] = useState(null); + const [loading, setLoading] = useState(false); + const [showConfig, setShowConfig] = useState(false); + const [maxIterations, setMaxIterations] = useState(50); + const [timeBudget, setTimeBudget] = useState(300); + const [error, setError] = useState(null); + const pollRef = useRef(null); + + const fetchCampaign = useCallback(async () => { + try { + const data = await api.getOptimizerCampaign(token, strategyId) as OptimizerCampaign; + setCampaign(data); + return data; + } catch { + setCampaign(null); + return null; + } + }, [token, strategyId]); + + // Initial load + useEffect(() => { + void fetchCampaign(); + }, [fetchCampaign]); + + // Polling when campaign is active + useEffect(() => { + if (campaign && (campaign.status === 'RUNNING' || campaign.status === 'PENDING')) { + pollRef.current = window.setInterval(() => { + void fetchCampaign(); + }, 4000); + } else if (pollRef.current) { + window.clearInterval(pollRef.current); + pollRef.current = null; + } + return () => { + if (pollRef.current) window.clearInterval(pollRef.current); + }; + }, [campaign?.status, fetchCampaign]); + + const launchOptimization = async () => { + setLoading(true); + setError(null); + try { + const data = await api.launchOptimizer(token, strategyId, { + max_iterations: maxIterations, + time_budget_seconds: timeBudget, + }) as OptimizerCampaign; + setCampaign(data); + setShowConfig(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to launch optimizer'); + } finally { + setLoading(false); + } + }; + + const handleAccept = async () => { + if (!campaign) return; + setLoading(true); + try { + await api.acceptCampaign(token, campaign.id); + onStrategyUpdated(); + await fetchCampaign(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Accept failed'); + } finally { + setLoading(false); + } + }; + + const handleReject = async () => { + if (!campaign) return; + setLoading(true); + try { + await api.rejectCampaign(token, campaign.id); + await fetchCampaign(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Reject failed'); + } finally { + setLoading(false); + } + }; + + const handleCancel = async () => { + if (!campaign) return; + setLoading(true); + try { + await api.cancelCampaign(token, campaign.id); + await fetchCampaign(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Cancel failed'); + } finally { + setLoading(false); + } + }; + + const canLaunch = strategyStatus === 'VALIDATED' && (!campaign || !['RUNNING', 'PENDING'].includes(campaign.status)); + const isActive = campaign && ['RUNNING', 'PENDING'].includes(campaign.status); + const isCompleted = campaign?.status === 'COMPLETED'; + const progressPct = campaign && campaign.max_iterations > 0 + ? Math.round((campaign.current_iteration / campaign.max_iterations) * 100) + : 0; + + return ( +
+ {/* Launch Button */} + {canLaunch && !showConfig && ( + + )} + + {/* Config Modal */} + {showConfig && ( +
+ OPTIMIZER_CONFIG +
+
+ + setMaxIterations(Number(e.target.value))} + min={1} + max={200} + className="w-full text-[10px] bg-surface-alt border border-border rounded px-2 py-1 text-text font-mono" + /> +
+
+ + setTimeBudget(Number(e.target.value))} + min={10} + max={1800} + className="w-full text-[10px] bg-surface-alt border border-border rounded px-2 py-1 text-text font-mono" + /> +
+
+
+ + +
+
+ )} + + {/* Progress */} + {isActive && campaign && ( +
+
+ + + OPTIMISATION_EN_COURS + + +
+
+
+
+
+ {campaign.current_iteration}/{campaign.max_iterations} +
+ {campaign.best_score != null && ( +
+ Best Score: {campaign.best_score.toFixed(2)} + {campaign.initial_score != null && ( + + (initial: {campaign.initial_score.toFixed(2)}, Δ{(campaign.best_score - campaign.initial_score).toFixed(2)}) + + )} +
+ )} +
+ )} + + {/* Results */} + {isCompleted && campaign && ( +
+ OPTIMISATION_TERMINÉE +
+
+ Score Initial + {campaign.initial_score?.toFixed(2) ?? '--'} +
+
+ Best Score + {campaign.best_score?.toFixed(2) ?? '--'} +
+
+ {campaign.best_params && campaign.initial_params && ( +
+ Params delta: + {Object.entries(campaign.best_params).map(([key, val]) => { + const initial = campaign.initial_params[key]; + const changed = String(val) !== String(initial); + return changed ? ( +
+ {key}: {String(initial)} → {String(val)} +
+ ) : null; + })} +
+ )} +
+ + +
+
+ )} + + {/* Error */} + {error &&

{error}

} + + {/* Failed campaign */} + {campaign?.status === 'FAILED' && ( +
+ OPTIMISATION_ÉCHOUÉE: {campaign.error_message || 'Unknown error'} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/StrategiesPage.tsx b/frontend/src/pages/StrategiesPage.tsx index ba7289b..1284236 100644 --- a/frontend/src/pages/StrategiesPage.tsx +++ b/frontend/src/pages/StrategiesPage.tsx @@ -2,6 +2,7 @@ import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { api } from '../api/client'; import { useAuth } from '../hooks/useAuth'; +import { OptimizerPanel } from '../components/OptimizerPanel'; import { Cpu, Plus, Loader2, Zap, LineChart, XCircle, Play, ArrowRight, Send, Bot, @@ -59,6 +60,8 @@ function StrategyCard({ onDelete, onDetail, validatingId, + token, + onStrategyUpdated, }: { strategy: Strategy; onValidate: (id: number) => void; @@ -67,6 +70,8 @@ function StrategyCard({ onDelete: (id: number) => void; onDetail: (strategy: Strategy) => void; validatingId: number | null; + token: string; + onStrategyUpdated: () => void; }) { const m = strategy.metrics; const winRate = m.win_rate != null ? `${m.win_rate}%` : '--'; @@ -186,6 +191,18 @@ function StrategyCard({ )}
+ + {/* Optimizer Panel for VALIDATED strategies */} + {strategy.status === 'VALIDATED' && token && ( +
+ +
+ )} ); } @@ -411,6 +428,8 @@ export function StrategiesPage() { onDelete={deleteStrategy} onDetail={setDetailStrategy} validatingId={validatingId} + token={token || ''} + onStrategyUpdated={loadStrategies} /> {/* LLM Edit zone */} {['DRAFT', 'VALIDATED', 'REJECTED'].includes(s.status) && ( From fecbe228bc7a94bece7017709e12c1a79f3957bb Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:00:42 +0200 Subject: [PATCH 06/21] test(strategy-optimizer): add unit tests for service, adapter, and API - test_optimizer_service.py: 7 tests (CRUD, state transitions, uniqueness) - test_optimizer_adapter.py: 10 tests (roundtrip, bounds, evaluator) - test_optimizer_api.py: API endpoint status codes (201, 422, 409, 404, 204) - All 17 tests pass Part of GH-32 --- backend/tests/test_optimizer_adapter.py | 103 ++++++++++ backend/tests/test_optimizer_api.py | 237 ++++++++++++++++++++++++ backend/tests/test_optimizer_service.py | 158 ++++++++++++++++ 3 files changed, 498 insertions(+) create mode 100644 backend/tests/test_optimizer_adapter.py create mode 100644 backend/tests/test_optimizer_api.py create mode 100644 backend/tests/test_optimizer_service.py diff --git a/backend/tests/test_optimizer_adapter.py b/backend/tests/test_optimizer_adapter.py new file mode 100644 index 0000000..c4d5097 --- /dev/null +++ b/backend/tests/test_optimizer_adapter.py @@ -0,0 +1,103 @@ +"""Tests for optimizer_adapter — encode/decode, bounds validation, evaluator.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch +import pytest + +from app.services.strategy.optimizer_adapter import ( + build_evaluator, + decode_program_to_params, + encode_params_to_program, + validate_params_in_bounds, +) + + +class TestEncodeDecode: + def test_roundtrip(self): + """Encode then decode produces identical params.""" + params = {'ema_fast': 9, 'ema_slow': 21, 'rsi_filter': 30} + program = encode_params_to_program('ema_crossover', params) + decoded = decode_program_to_params(program) + assert decoded == params + + def test_roundtrip_float_params(self): + """Float params survive encode/decode.""" + params = {'atr_period': 10, 'atr_multiplier': 2.5} + program = encode_params_to_program('supertrend', params) + decoded = decode_program_to_params(program) + assert decoded == params + + def test_encode_is_json_string(self): + """Encoded program is a valid JSON string.""" + import json + params = {'bb_period': 20, 'bb_std': 2.0} + program = encode_params_to_program('bollinger_breakout', params) + payload = json.loads(program) + assert payload['template'] == 'bollinger_breakout' + assert payload['params'] == params + + +class TestValidateParamsInBounds: + def test_valid_params(self): + """Valid params within bounds pass.""" + params = {'ema_fast': 9, 'ema_slow': 21, 'rsi_filter': 30} + assert validate_params_in_bounds('ema_crossover', params) is True + + def test_out_of_bounds(self): + """Params outside bounds fail.""" + params = {'ema_fast': 100, 'ema_slow': 21, 'rsi_filter': 30} + assert validate_params_in_bounds('ema_crossover', params) is False + + def test_unknown_template(self): + """Unknown template always passes (no bounds to check).""" + assert validate_params_in_bounds('unknown_template', {'x': 999}) is True + + def test_negative_bounds_williams(self): + """Williams %R has negative bounds — check they validate correctly.""" + params = {'period': 14, 'oversold': -80, 'overbought': -20} + assert validate_params_in_bounds('williams_r', params) is True + + params_bad = {'period': 14, 'oversold': -80, 'overbought': -2} + assert validate_params_in_bounds('williams_r', params_bad) is False + + def test_ema_fast_greater_than_slow_still_in_bounds(self): + """ + The bounds checker does not enforce cross-param constraints + (ema_fast < ema_slow). Those are handled by BacktestEngine. + """ + params = {'ema_fast': 20, 'ema_slow': 15, 'rsi_filter': 30} + # Both values within bounds individually + assert validate_params_in_bounds('ema_crossover', params) is True + + +class TestBuildEvaluator: + def test_evaluator_returns_score_and_metrics(self): + """Evaluator calls BacktestEngine and returns score.""" + mock_result = MagicMock() + mock_result.metrics = {'win_rate_pct': 60, 'profit_factor': 1.5, 'total_trades': 20, 'max_drawdown_pct': 10, 'total_return_pct': 15} + + with patch('app.services.backtest.engine.BacktestEngine') as MockEngine: + instance = MagicMock() + instance.run.return_value = mock_result + MockEngine.return_value = instance + + evaluator = build_evaluator('ema_crossover', 'EURUSD.PRO', 'H1') + result = evaluator({'ema_fast': 9, 'ema_slow': 21, 'rsi_filter': 30}) + + assert 'score' in result + assert 'metrics' in result + assert result['score'] > 0 + + def test_evaluator_handles_exception(self): + """Evaluator returns 0 score on backtest failure.""" + with patch('app.services.backtest.engine.BacktestEngine') as MockEngine: + instance = MagicMock() + instance.run.side_effect = RuntimeError('Backtest failed') + MockEngine.return_value = instance + + evaluator = build_evaluator('ema_crossover', 'EURUSD.PRO', 'H1') + result = evaluator({'ema_fast': 9, 'ema_slow': 21, 'rsi_filter': 30}) + + assert result['score'] == 0.0 + assert result['metrics'] == {} diff --git a/backend/tests/test_optimizer_api.py b/backend/tests/test_optimizer_api.py new file mode 100644 index 0000000..b806a13 --- /dev/null +++ b/backend/tests/test_optimizer_api.py @@ -0,0 +1,237 @@ +"""Tests for optimizer API endpoints — HTTP status codes and response schemas.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch +import pytest + +from fastapi.testclient import TestClient + + +@pytest.fixture +def app(): + """Create a minimal FastAPI app with the strategies router.""" + from fastapi import FastAPI + from app.api.routes.strategies import router + application = FastAPI() + application.include_router(router, prefix='/api/v1') + return application + + +@pytest.fixture +def client(app): + return TestClient(app) + + +@pytest.fixture +def mock_auth(): + """Mock authentication to always pass.""" + from app.db.models.user import User + from app.core.security import Role + + user = User.__new__(User) + user.id = 1 + user.role = Role.ADMIN + user.email = 'test@test.com' + + with patch('app.api.routes.strategies.require_roles') as mock_roles: + mock_roles.return_value = lambda: user + + # Also patch Depends resolution + def fake_depends(*args, **kwargs): + return user + + mock_roles.return_value = fake_depends + yield user + + +@pytest.fixture +def mock_db(): + """Mock database session.""" + session = MagicMock() + with patch('app.api.routes.strategies.get_db') as mock_get_db: + mock_get_db.return_value = session + yield session + + +@pytest.fixture +def validated_strategy(): + """A strategy with VALIDATED status.""" + from app.db.models.strategy import Strategy + s = Strategy.__new__(Strategy) + s.id = 1 + s.strategy_id = 'STRAT-001' + s.status = 'VALIDATED' + s.params = {'ema_fast': 9, 'ema_slow': 21, 'rsi_filter': 30} + s.template = 'ema_crossover' + s.symbol = 'EURUSD.PRO' + s.timeframe = 'H1' + s.score = 75.0 + s.metrics = {} + s.name = 'Test Strategy' + s.description = 'Test' + s.is_monitoring = False + s.monitoring_mode = 'simulation' + s.monitoring_risk_percent = 1.0 + s.last_signal_key = None + s.prompt_history = [] + s.last_backtest_id = None + s.created_by_id = 1 + s.created_at = datetime.now(timezone.utc) + s.updated_at = datetime.now(timezone.utc) + return s + + +@pytest.fixture +def draft_strategy(): + """A strategy with DRAFT status.""" + from app.db.models.strategy import Strategy + s = Strategy.__new__(Strategy) + s.id = 2 + s.strategy_id = 'STRAT-002' + s.status = 'DRAFT' + s.params = {'ema_fast': 9, 'ema_slow': 21} + s.template = 'ema_crossover' + s.symbol = 'EURUSD.PRO' + s.timeframe = 'H1' + s.score = 0.0 + s.metrics = {} + return s + + +class TestStartOptimization: + """Tests for POST /strategies/{id}/optimize.""" + + def test_launch_on_validated_returns_201(self, client, mock_auth, mock_db, validated_strategy): + """POST /optimize on VALIDATED strategy returns 201.""" + from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign + + mock_db.get = MagicMock(return_value=validated_strategy) + + campaign = StrategyOptimizerCampaign.__new__(StrategyOptimizerCampaign) + campaign.id = 1 + campaign.strategy_id = 1 + campaign.status = 'PENDING' + campaign.current_iteration = 0 + campaign.config = {'max_iterations': 50, 'time_budget_seconds': 300, 'max_candidates_per_iteration': 3} + campaign.initial_params = validated_strategy.params + campaign.initial_score = None + campaign.best_params = None + campaign.best_score = None + campaign.best_metrics = None + campaign.celery_task_id = None + campaign.error_message = None + campaign.created_at = datetime.now(timezone.utc) + campaign.completed_at = None + + with patch('app.api.routes.strategies.create_campaign', return_value=campaign): + with patch('app.api.routes.strategies.optimizer_execute') as mock_task: + mock_task.apply_async.return_value = MagicMock(id='task-123') + response = client.post( + '/api/v1/strategies/1/optimize', + json={'max_iterations': 50}, + ) + + assert response.status_code == 201 + data = response.json() + assert data['status'] == 'PENDING' + assert data['strategy_id'] == 1 + + def test_launch_on_draft_returns_422(self, client, mock_auth, mock_db, draft_strategy): + """POST /optimize on DRAFT strategy returns 422.""" + mock_db.get = MagicMock(return_value=draft_strategy) + + response = client.post( + '/api/v1/strategies/2/optimize', + json={}, + ) + + assert response.status_code == 422 + + def test_launch_with_active_campaign_returns_409(self, client, mock_auth, mock_db, validated_strategy): + """POST /optimize when campaign already RUNNING returns 409.""" + mock_db.get = MagicMock(return_value=validated_strategy) + + with patch('app.api.routes.strategies.create_campaign', side_effect=ValueError('Campaign already active')): + response = client.post( + '/api/v1/strategies/1/optimize', + json={}, + ) + + assert response.status_code == 409 + + +class TestGetOptimizerCampaign: + """Tests for GET /strategies/{id}/optimizer-campaign.""" + + def test_returns_campaign(self, client, mock_auth, mock_db, validated_strategy): + """GET returns campaign data when one exists.""" + from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign + + mock_db.get = MagicMock(return_value=validated_strategy) + + campaign = StrategyOptimizerCampaign.__new__(StrategyOptimizerCampaign) + campaign.id = 5 + campaign.strategy_id = 1 + campaign.status = 'RUNNING' + campaign.current_iteration = 10 + campaign.config = {'max_iterations': 50, 'time_budget_seconds': 300, 'max_candidates_per_iteration': 3} + campaign.initial_params = {'ema_fast': 9, 'ema_slow': 21, 'rsi_filter': 30} + campaign.initial_score = 30.0 + campaign.best_params = {'ema_fast': 12, 'ema_slow': 36, 'rsi_filter': 35} + campaign.best_score = 45.0 + campaign.best_metrics = {'win_rate_pct': 55} + campaign.celery_task_id = 'task-abc' + campaign.error_message = None + campaign.created_at = datetime.now(timezone.utc) + campaign.completed_at = None + + with patch('app.api.routes.strategies.get_active_campaign', return_value=campaign): + response = client.get('/api/v1/strategies/1/optimizer-campaign') + + assert response.status_code == 200 + data = response.json() + assert data['id'] == 5 + assert data['status'] == 'RUNNING' + assert data['current_iteration'] == 10 + assert data['max_iterations'] == 50 + assert data['best_score'] == 45.0 + + def test_returns_404_when_no_campaign(self, client, mock_auth, mock_db, validated_strategy): + """GET returns 404 when no campaign exists.""" + mock_db.get = MagicMock(return_value=validated_strategy) + + with patch('app.api.routes.strategies.get_active_campaign', return_value=None): + response = client.get('/api/v1/strategies/1/optimizer-campaign') + + assert response.status_code == 404 + + +class TestAcceptReject: + """Tests for accept/reject endpoints.""" + + def test_accept_on_non_completed_returns_409(self, client, mock_auth, mock_db): + """POST /accept on non-COMPLETED campaign returns 409.""" + with patch('app.api.routes.strategies.accept_campaign', side_effect=ValueError('Cannot accept campaign in status RUNNING')): + response = client.post('/api/v1/strategies/optimizer-campaign/1/accept') + + assert response.status_code == 409 + + def test_reject_not_found_returns_404(self, client, mock_auth, mock_db): + """POST /reject on non-existent campaign returns 404.""" + with patch('app.api.routes.strategies.reject_campaign', side_effect=ValueError('Campaign 999 not found')): + response = client.post('/api/v1/strategies/optimizer-campaign/999/reject') + + assert response.status_code == 404 + + +class TestCancelCampaign: + """Tests for DELETE /optimizer-campaign/{id}.""" + + def test_cancel_running_returns_204(self, client, mock_auth, mock_db): + """DELETE on RUNNING campaign returns 204.""" + with patch('app.api.routes.strategies.cancel_campaign', return_value=MagicMock()): + response = client.delete('/api/v1/strategies/optimizer-campaign/1') + + assert response.status_code == 204 diff --git a/backend/tests/test_optimizer_service.py b/backend/tests/test_optimizer_service.py new file mode 100644 index 0000000..a2437bd --- /dev/null +++ b/backend/tests/test_optimizer_service.py @@ -0,0 +1,158 @@ +"""Tests for optimizer_service — campaign CRUD and state transitions.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch +import pytest + +from app.db.models.strategy import Strategy +from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign +from app.services.strategy.optimizer_service import ( + accept_campaign, + cancel_campaign, + create_campaign, + get_active_campaign, + reject_campaign, +) + + +class FakeQuery: + """Simulate SQLAlchemy query chainable interface.""" + + def __init__(self, results=None): + self._results = results or [] + + def filter(self, *args, **kwargs): + return self + + def order_by(self, *args, **kwargs): + return self + + def first(self): + return self._results[0] if self._results else None + + +@pytest.fixture +def db(): + """Create a mock DB session.""" + session = MagicMock() + session.add = MagicMock() + session.commit = MagicMock() + session.refresh = MagicMock(side_effect=lambda obj: None) + return session + + +@pytest.fixture +def strategy(): + s = MagicMock(spec=Strategy) + s.id = 1 + s.strategy_id = 'STRAT-001' + s.status = 'VALIDATED' + s.params = {'ema_fast': 9, 'ema_slow': 21, 'rsi_filter': 30} + s.template = 'ema_crossover' + s.symbol = 'EURUSD.PRO' + s.timeframe = 'H1' + s.score = 75.0 + s.metrics = {} + return s + + +def test_create_campaign_success(db, strategy): + """Creating a campaign returns a PENDING campaign.""" + db.get = MagicMock(return_value=strategy) + db.query = MagicMock(return_value=FakeQuery(results=[])) + + campaign = create_campaign(db, strategy.id, {'max_iterations': 30}) + + assert campaign.status == 'PENDING' + assert campaign.strategy_id == strategy.id + assert campaign.config['max_iterations'] == 30 + assert campaign.initial_params == strategy.params + db.add.assert_called_once() + db.commit.assert_called() + + +def test_create_campaign_fails_if_active_exists(db, strategy): + """Cannot create campaign if one is already RUNNING.""" + existing = MagicMock(spec=StrategyOptimizerCampaign) + existing.id = 99 + existing.status = 'RUNNING' + db.query = MagicMock(return_value=FakeQuery(results=[existing])) + + with pytest.raises(ValueError, match='already active'): + create_campaign(db, strategy.id, {}) + + +def test_accept_campaign_applies_best_params(db, strategy): + """Accept updates strategy params and triggers re-validation.""" + campaign = MagicMock(spec=StrategyOptimizerCampaign) + campaign.id = 10 + campaign.status = 'COMPLETED' + campaign.strategy_id = strategy.id + campaign.best_params = {'ema_fast': 12, 'ema_slow': 36, 'rsi_filter': 35} + + db.get = MagicMock(side_effect=lambda model, pk: campaign if model == StrategyOptimizerCampaign else strategy) + + with patch('app.services.strategy.optimizer_service.get_settings') as mock_settings: + mock_settings.return_value = MagicMock(celery_backtest_queue='backtests') + with patch('app.tasks.strategy_backtest_task.execute') as mock_task: + mock_task.apply_async = MagicMock() + result = accept_campaign(db, campaign.id) + + assert result.status == 'ACCEPTED' + assert strategy.params == campaign.best_params + assert strategy.status == 'BACKTESTING' + + +def test_reject_campaign_leaves_strategy_unchanged(db, strategy): + """Reject sets campaign status without modifying strategy.""" + campaign = MagicMock(spec=StrategyOptimizerCampaign) + campaign.id = 10 + campaign.status = 'COMPLETED' + campaign.strategy_id = strategy.id + + db.get = MagicMock(return_value=campaign) + original_params = dict(strategy.params) + + result = reject_campaign(db, campaign.id) + + assert result.status == 'REJECTED_BY_USER' + assert strategy.params == original_params + + +def test_cancel_campaign_sets_cancelled(db): + """Cancel sets campaign status to CANCELLED.""" + campaign = MagicMock(spec=StrategyOptimizerCampaign) + campaign.id = 10 + campaign.status = 'RUNNING' + campaign.celery_task_id = None + campaign.completed_at = None + + db.get = MagicMock(return_value=campaign) + + result = cancel_campaign(db, campaign.id) + + assert result.status == 'CANCELLED' + assert result.completed_at is not None + + +def test_reject_non_completed_raises(db): + """Cannot reject a campaign that is not COMPLETED.""" + campaign = MagicMock(spec=StrategyOptimizerCampaign) + campaign.id = 10 + campaign.status = 'RUNNING' + + db.get = MagicMock(return_value=campaign) + + with pytest.raises(ValueError, match='Cannot reject'): + reject_campaign(db, campaign.id) + + +def test_get_active_campaign_returns_latest(db): + """get_active_campaign returns latest campaign by created_at.""" + mock_result = MagicMock(id=42) + db.query = MagicMock(return_value=FakeQuery(results=[mock_result])) + + result = get_active_campaign(db, strategy_id=1) + assert result is not None + assert result.id == 42 From 418e54298513b6689a9d1e7b35b6fb32a5830a40 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:11:10 +0200 Subject: [PATCH 07/21] feat(strategy-optimizer): integrate real OpenEvolve with LLM-driven mutation and MAP-Elites --- .../services/strategy/optimizer_service.py | 286 +++++++++++++++++- backend/requirements.txt | 1 + 2 files changed, 282 insertions(+), 5 deletions(-) diff --git a/backend/app/services/strategy/optimizer_service.py b/backend/app/services/strategy/optimizer_service.py index a4d986d..4c9bbc0 100644 --- a/backend/app/services/strategy/optimizer_service.py +++ b/backend/app/services/strategy/optimizer_service.py @@ -1,27 +1,41 @@ """Strategy Optimizer service — orchestrates evolutionary campaign loop. -Provides CRUD operations on campaigns and the main optimization loop -that mutates parameters within template bounds, evaluates via BacktestEngine, -and persists progress to DB. +Provides CRUD operations on campaigns and the main optimization loop. +When OpenEvolve is available, uses LLM-driven mutation with MAP-Elites. +Falls back to naive hill climbing (random ±20%) when OpenEvolve is not installed. """ from __future__ import annotations +import json import logging +import os import random +import shutil +import tempfile import time -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any +import yaml from sqlalchemy.orm import Session from app.core.config import get_settings from app.db.models.strategy import Strategy from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign from app.db.models.strategy_optimizer_evaluation import StrategyOptimizerEvaluation +from app.services.strategy.generation_optimizer import compute_generation_candidate_score +from app.services.strategy.lookback_windows import strategy_lookback_days from app.services.strategy.optimizer_adapter import build_evaluator, validate_params_in_bounds from app.services.strategy.optimizer_bounds import clamp_params, get_bounds_for_template +try: + from openevolve import run_evolution + from openevolve.evaluation_result import EvaluationResult + OPENEVOLVE_AVAILABLE = True +except ImportError: + OPENEVOLVE_AVAILABLE = False + logger = logging.getLogger(__name__) _ACTIVE_STATUSES = ('PENDING', 'RUNNING') @@ -205,8 +219,34 @@ def _mutate_params( return mutated +def evaluator_direct(template: str, symbol: str, timeframe: str, params: dict) -> dict: + """Direct evaluation without file I/O — for initial and final scoring.""" + from app.services.backtest.engine import BacktestEngine + + lb_days = strategy_lookback_days(symbol) + end_date = datetime.now(timezone.utc).strftime('%Y-%m-%d') + start_date = (datetime.now(timezone.utc) - timedelta(days=lb_days)).strftime('%Y-%m-%d') + + clamped = clamp_params(template, params) + engine = BacktestEngine() + try: + result = engine.run( + symbol, timeframe, start_date, end_date, + strategy=template, db=None, strategy_params=clamped, run_id=None, + ) + metrics = dict(result.metrics or {}) + score = compute_generation_candidate_score(metrics) + return {'score': score, 'metrics': metrics} + except Exception: + return {'score': 0.0, 'metrics': {}} + + def run_optimization_loop(db: Session, campaign_id: int) -> None: - """Main optimization loop — called by the Celery task.""" + """Main optimization loop — called by the Celery task. + + Uses OpenEvolve (LLM-driven mutation with MAP-Elites) when available. + Falls back to naive hill climbing when OpenEvolve is not installed. + """ campaign = db.get(StrategyOptimizerCampaign, campaign_id) if campaign is None: raise ValueError(f'Campaign {campaign_id} not found') @@ -217,10 +257,246 @@ def run_optimization_loop(db: Session, campaign_id: int) -> None: # Mark as running campaign.status = 'RUNNING' + campaign.started_at = datetime.now(timezone.utc) db.commit() config = campaign.config or {} max_iterations = int(config.get('max_iterations', 50)) + + if OPENEVOLVE_AVAILABLE: + _run_openevolve_loop(db, campaign, strategy, config, max_iterations) + else: + _run_naive_loop(db, campaign, strategy, config, max_iterations) + + +def _run_openevolve_loop( + db: Session, + campaign: StrategyOptimizerCampaign, + strategy: Strategy, + config: dict[str, Any], + max_iterations: int, +) -> None: + """Run optimization using OpenEvolve with LLM-driven mutation.""" + campaign_id = campaign.id + + # 1. Initial program = JSON of params + initial_program = json.dumps({ + 'template': strategy.template, + 'symbol': strategy.symbol, + 'timeframe': strategy.timeframe, + 'params': dict(strategy.params or {}), + }, indent=2) + + # 2. Build evaluator for OpenEvolve (file-based interface) + bounds = get_bounds_for_template(strategy.template) + + def openevolve_evaluator(file_path: str) -> 'EvaluationResult': + """Evaluate a candidate strategy by backtesting it.""" + # Check cancellation + db.refresh(campaign) + if campaign.status == 'CANCELLED': + raise RuntimeError('Campaign cancelled') + + try: + with open(file_path, 'r') as f: + content = f.read() + + # Parse the JSON (LLM may produce varied formats) + candidate_data = json.loads(content) + candidate_params = candidate_data.get('params', candidate_data) + + # If params is the top-level dict (LLM sometimes strips structure) + if 'template' not in candidate_data and all( + k in candidate_data for k in (strategy.params or {}).keys() + ): + candidate_params = candidate_data + + # Clamp to bounds + clamped = clamp_params(strategy.template, candidate_params) + + # Backtest + from app.services.backtest.engine import BacktestEngine + + lb_days = strategy_lookback_days(strategy.symbol) + end_date = datetime.now(timezone.utc).strftime('%Y-%m-%d') + start_date = (datetime.now(timezone.utc) - timedelta(days=lb_days)).strftime('%Y-%m-%d') + + engine = BacktestEngine() + result = engine.run( + strategy.symbol, strategy.timeframe, + start_date, end_date, + strategy=strategy.template, + db=None, + strategy_params=clamped, + run_id=None, + ) + + metrics = dict(result.metrics or {}) + score = compute_generation_candidate_score(metrics) + + # Store evaluation in DB + db.add(StrategyOptimizerEvaluation( + campaign_id=campaign_id, + iteration=campaign.current_iteration or 0, + params=clamped, + score=score, + metrics=metrics, + )) + campaign.current_iteration = (campaign.current_iteration or 0) + 1 + if score > (campaign.best_score or 0.0): + campaign.best_score = score + campaign.best_params = clamped + campaign.best_metrics = metrics + db.commit() + + # Return result with feedback for LLM + win_rate = metrics.get('win_rate_pct', metrics.get('win_rate', 0)) + pf = metrics.get('profit_factor', 0) + dd = metrics.get('max_drawdown_pct', metrics.get('max_drawdown', 0)) + trades = metrics.get('total_trades', 0) + + feedback = ( + f"Score: {score:.1f}/100 | Win Rate: {win_rate:.1f}% | " + f"Profit Factor: {pf:.2f} | Max Drawdown: {dd:.1f}% | Trades: {trades}\n" + ) + if trades < 5: + feedback += "WARNING: Too few trades — parameters may be too restrictive.\n" + if float(dd) > 25: + feedback += "WARNING: Excessive drawdown — reduce risk exposure.\n" + if score > 60: + feedback += "GOOD: Above threshold. Try fine-tuning for higher profit factor.\n" + + return EvaluationResult( + metrics={'performance': score, 'drawdown': abs(float(dd))}, + artifacts={'llm_feedback': feedback}, + ) + + except json.JSONDecodeError as e: + return EvaluationResult( + metrics={'performance': -1.0, 'drawdown': 100.0}, + artifacts={'stderr': f'INVALID JSON: {e}. You MUST return valid JSON with the same structure.'}, + ) + except RuntimeError: + raise # Re-raise cancellation + except Exception as e: + logger.warning('optimizer_evaluator_error: %s', str(e)[:200]) + return EvaluationResult( + metrics={'performance': 0.0, 'drawdown': 100.0}, + artifacts={'stderr': f'Backtest error: {str(e)[:200]}'}, + ) + + # 3. Write OpenEvolve config + bounds_description = '\n'.join( + f' - {k}: min={lo}, max={hi}' for k, (lo, hi) in bounds.items() + ) + + openevolve_config = { + 'max_iterations': max_iterations, + 'llm': { + 'model': config.get('model', 'gpt-4.1-mini'), + 'temperature': 0.7, + }, + 'database': { + 'population_size': min(max_iterations * 2, 100), + 'num_islands': 2, + }, + 'evaluator': { + 'enable_artifacts': True, + }, + 'prompt': { + 'num_top_programs': 2, + 'num_diverse_programs': 1, + 'include_artifacts': True, + 'system_message': ( + f"You are a quantitative trading strategy optimizer.\n" + f"You optimize parameters for a '{strategy.template}' strategy " + f"on {strategy.symbol} {strategy.timeframe}.\n\n" + f"RULES:\n" + f"- Return ONLY valid JSON (no markdown, no explanation).\n" + f"- Keep the exact same structure: " + f"{{\"template\": ..., \"symbol\": ..., \"timeframe\": ..., \"params\": {{...}}}}\n" + f"- Only modify values inside 'params'.\n" + f"- Respect parameter bounds:\n{bounds_description}\n\n" + f"GOAL: Maximize the composite score " + f"(win_rate × profit_factor × low_drawdown × positive_return × sufficient_trades).\n" + f"Use the feedback from previous evaluations to guide your changes.\n" + ), + }, + } + + # 4. Run OpenEvolve + config_dir = tempfile.mkdtemp(prefix='openevolve_strategy_') + config_path = os.path.join(config_dir, 'config.yaml') + + with open(config_path, 'w') as f: + yaml.dump(openevolve_config, f) + + try: + # Evaluate initial params first + initial_eval = evaluator_direct( + strategy.template, strategy.symbol, strategy.timeframe, + dict(strategy.params or {}), + ) + campaign.initial_score = initial_eval['score'] + campaign.best_params = dict(strategy.params or {}) + campaign.best_score = initial_eval['score'] + campaign.best_metrics = initial_eval['metrics'] + db.commit() + + # Run OpenEvolve + result = run_evolution( + initial_program=initial_program, + evaluator=openevolve_evaluator, + iterations=max_iterations, + config_path=config_path, + ) + + # Parse the best result + if result and hasattr(result, 'best_code') and result.best_code: + try: + best_data = json.loads(result.best_code) + best_params = best_data.get('params', best_data) + clamped_best = clamp_params(strategy.template, best_params) + + # Final evaluation to confirm + final_eval = evaluator_direct( + strategy.template, strategy.symbol, strategy.timeframe, clamped_best, + ) + if final_eval['score'] > (campaign.best_score or 0.0): + campaign.best_params = clamped_best + campaign.best_score = final_eval['score'] + campaign.best_metrics = final_eval['metrics'] + except (json.JSONDecodeError, KeyError): + pass # Keep whatever best was found during evaluations + + campaign.status = 'COMPLETED' + except Exception as exc: + logger.error( + 'openevolve_run_failed campaign_id=%s: %s', + campaign_id, str(exc)[:300], exc_info=True, + ) + # Fallback: if OpenEvolve fails, the best found during evaluations is still valid + if campaign.best_score and campaign.best_score > (campaign.initial_score or 0.0): + campaign.status = 'COMPLETED' + else: + campaign.status = 'FAILED' + campaign.error = str(exc)[:500] + finally: + campaign.completed_at = datetime.now(timezone.utc) + db.commit() + # Cleanup temp dir + shutil.rmtree(config_dir, ignore_errors=True) + + +def _run_naive_loop( + db: Session, + campaign: StrategyOptimizerCampaign, + strategy: Strategy, + config: dict[str, Any], + max_iterations: int, +) -> None: + """Fallback optimization loop — naive hill climbing with random ±20% perturbation.""" + campaign_id = campaign.id time_budget = int(config.get('time_budget_seconds', 300)) max_candidates = int(config.get('max_candidates_per_iteration', 3)) diff --git a/backend/requirements.txt b/backend/requirements.txt index 47b8d56..858e380 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -31,3 +31,4 @@ pytest==8.4.1 pytest-asyncio==1.1.0 opentelemetry-exporter-otlp-proto-http>=1.39.0 openevolve>=0.1.0 +pyyaml>=6.0 From 12c28a7734fe1ec5350e661686c17c15c083d6c1 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:17:40 +0200 Subject: [PATCH 08/21] fix(strategy-optimizer): wire OpenEvolve LLM config to project provider settings --- .../services/strategy/optimizer_service.py | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/backend/app/services/strategy/optimizer_service.py b/backend/app/services/strategy/optimizer_service.py index 4c9bbc0..41157b1 100644 --- a/backend/app/services/strategy/optimizer_service.py +++ b/backend/app/services/strategy/optimizer_service.py @@ -24,6 +24,7 @@ from app.db.models.strategy import Strategy from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign from app.db.models.strategy_optimizer_evaluation import StrategyOptimizerEvaluation +from app.services.llm.model_selector import AgentModelSelector from app.services.strategy.generation_optimizer import compute_generation_candidate_score from app.services.strategy.lookback_windows import strategy_lookback_days from app.services.strategy.optimizer_adapter import build_evaluator, validate_params_in_bounds @@ -269,6 +270,35 @@ def run_optimization_loop(db: Session, campaign_id: int) -> None: _run_naive_loop(db, campaign, strategy, config, max_iterations) +def _resolve_llm_config(db: Session) -> tuple[str, str, str, str]: + """Resolve LLM provider, model, base_url, api_key from project settings. + + Returns (provider, model_name, base_url, api_key). + Falls back to settings defaults if model_selector is unavailable. + """ + settings = get_settings() + try: + selector = AgentModelSelector() + provider = selector.resolve_provider(db) + model_name = selector.resolve(db) + except Exception: + # Fallback: use settings defaults + provider = 'openai' + model_name = 'gpt-4.1-mini' + + if provider == 'openai': + base_url = settings.openai_base_url + api_key = settings.openai_api_key + elif provider == 'mistral': + base_url = settings.mistral_base_url + api_key = settings.mistral_api_key + else: # ollama + base_url = settings.ollama_base_url + api_key = settings.ollama_api_key + + return provider, model_name, base_url or '', api_key or '' + + def _run_openevolve_loop( db: Session, campaign: StrategyOptimizerCampaign, @@ -279,6 +309,29 @@ def _run_openevolve_loop( """Run optimization using OpenEvolve with LLM-driven mutation.""" campaign_id = campaign.id + # 0. Resolve LLM config from project settings + provider, model_name, base_url, api_key = _resolve_llm_config(db) + logger.info( + 'openevolve_loop_start campaign_id=%s provider=%s model=%s', + campaign_id, provider, model_name, + ) + + # Set env vars for OpenEvolve's internal LLM client + if provider == 'openai': + if api_key: + os.environ.setdefault('OPENAI_API_KEY', api_key) + if base_url and base_url != 'https://api.openai.com/v1': + os.environ.setdefault('OPENAI_BASE_URL', base_url) + elif provider == 'mistral': + # Mistral is OpenAI-compatible + if api_key: + os.environ.setdefault('OPENAI_API_KEY', api_key) + if base_url: + os.environ.setdefault('OPENAI_BASE_URL', base_url) + else: # ollama + os.environ.setdefault('OPENAI_API_KEY', api_key or 'ollama') + os.environ.setdefault('OPENAI_BASE_URL', f"{base_url.rstrip('/')}/v1") + # 1. Initial program = JSON of params initial_program = json.dumps({ 'template': strategy.template, @@ -393,8 +446,10 @@ def openevolve_evaluator(file_path: str) -> 'EvaluationResult': openevolve_config = { 'max_iterations': max_iterations, 'llm': { - 'model': config.get('model', 'gpt-4.1-mini'), + 'model': model_name, 'temperature': 0.7, + 'api_key': api_key, + 'base_url': base_url, }, 'database': { 'population_size': min(max_iterations * 2, 100), From fbfd83010efaa1b980aaca5f73280b573624c5e3 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:24:03 +0200 Subject: [PATCH 09/21] fix(strategy-optimizer): add strategy-optimizer queue to worker defaults --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index c2d434f..19a976b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,7 +86,7 @@ services: - '${PROMETHEUS_WORKER_PORT:-9101}' command: > sh -c "mkdir -p /tmp/prometheus-worker && rm -rf /tmp/prometheus-worker/* && - celery -A app.tasks.celery_app.celery_app worker --loglevel=warning -B -Q $${CELERY_WORKER_QUEUES:-analysis,backtests} + celery -A app.tasks.celery_app.celery_app worker --loglevel=warning -B -Q $${CELERY_WORKER_QUEUES:-analysis,backtests,strategy-optimizer} --concurrency=$${CELERY_WORKER_CONCURRENCY:-2} --prefetch-multiplier=$${CELERY_WORKER_PREFETCH_MULTIPLIER:-1} --max-tasks-per-child=$${CELERY_WORKER_MAX_TASKS_PER_CHILD:-100}" From 6e4975fadc066bfd51a71937dfcbe3bc310cdb80 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:25:35 +0200 Subject: [PATCH 10/21] fix(strategy-optimizer): use correct OpenEvolve arg name config= instead of config_path= --- backend/app/services/strategy/optimizer_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/services/strategy/optimizer_service.py b/backend/app/services/strategy/optimizer_service.py index 41157b1..3803145 100644 --- a/backend/app/services/strategy/optimizer_service.py +++ b/backend/app/services/strategy/optimizer_service.py @@ -503,7 +503,7 @@ def openevolve_evaluator(file_path: str) -> 'EvaluationResult': initial_program=initial_program, evaluator=openevolve_evaluator, iterations=max_iterations, - config_path=config_path, + config=config_path, ) # Parse the best result From 9d3645fae12cebe8d789aec4e2f777cd8cf1bb97 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:27:01 +0200 Subject: [PATCH 11/21] fix(strategy-optimizer): handle naive datetime in elapsed_seconds calculation --- backend/app/schemas/optimizer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/app/schemas/optimizer.py b/backend/app/schemas/optimizer.py index 73232cc..8ab6709 100644 --- a/backend/app/schemas/optimizer.py +++ b/backend/app/schemas/optimizer.py @@ -45,7 +45,10 @@ def from_campaign(cls, campaign: Any) -> 'OptimizerCampaignOut': elapsed = (campaign.completed_at - campaign.created_at).total_seconds() elif campaign.status == 'RUNNING' and campaign.created_at: from datetime import timezone - elapsed = (datetime.now(timezone.utc) - campaign.created_at).total_seconds() + created = campaign.created_at + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + elapsed = (datetime.now(timezone.utc) - created).total_seconds() return cls( id=campaign.id, From a97ec7e077e739110dd010455ab9bf04416afc06 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:31:16 +0200 Subject: [PATCH 12/21] test(strategy-optimizer): add unit tests and E2E tests for optimizer workflow --- .../tests/unit/test_optimizer_integration.py | 482 ++++++++++++++++++ frontend/tests/e2e/strategy-optimizer.spec.ts | 321 ++++++++++++ 2 files changed, 803 insertions(+) create mode 100644 backend/tests/unit/test_optimizer_integration.py create mode 100644 frontend/tests/e2e/strategy-optimizer.spec.ts diff --git a/backend/tests/unit/test_optimizer_integration.py b/backend/tests/unit/test_optimizer_integration.py new file mode 100644 index 0000000..452da10 --- /dev/null +++ b/backend/tests/unit/test_optimizer_integration.py @@ -0,0 +1,482 @@ +"""Integration-style unit tests for the Strategy Optimizer. + +Tests cover the full optimizer workflow: campaign CRUD, mutation logic, +evaluation fallback, and schema handling — all without external services. +""" + +from __future__ import annotations + +import random +from datetime import datetime, timezone +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers — fake DB objects as SimpleNamespace +# --------------------------------------------------------------------------- + +def _make_strategy( + id: int = 1, + template: str = 'ema_crossover', + symbol: str = 'EURUSD.PRO', + timeframe: str = 'H1', + params: dict | None = None, + status: str = 'VALIDATED', +) -> SimpleNamespace: + return SimpleNamespace( + id=id, + template=template, + symbol=symbol, + timeframe=timeframe, + params=params or {'ema_fast': 9, 'ema_slow': 21, 'rsi_filter': 30}, + status=status, + score=65.0, + metrics={'win_rate_pct': 45.0, 'profit_factor': 1.5}, + ) + + +def _make_campaign( + id: int = 10, + strategy_id: int = 1, + status: str = 'PENDING', + config: dict | None = None, + initial_params: dict | None = None, + initial_score: float | None = None, + best_params: dict | None = None, + best_score: float | None = None, + best_metrics: dict | None = None, + current_iteration: int = 0, + celery_task_id: str | None = None, + error_message: str | None = None, + created_at: datetime | None = None, + completed_at: datetime | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + id=id, + strategy_id=strategy_id, + status=status, + config=config or {'max_iterations': 50, 'time_budget_seconds': 300, 'max_candidates_per_iteration': 3}, + initial_params=initial_params or {'ema_fast': 9, 'ema_slow': 21, 'rsi_filter': 30}, + initial_score=initial_score, + best_params=best_params, + best_score=best_score, + best_metrics=best_metrics, + current_iteration=current_iteration, + celery_task_id=celery_task_id, + error_message=error_message, + created_at=created_at or datetime(2026, 1, 15, 10, 0, 0, tzinfo=timezone.utc), + completed_at=completed_at, + updated_at=datetime(2026, 1, 15, 10, 0, 0, tzinfo=timezone.utc), + ) + + +class FakeDB: + """Minimal DB session mock with query support.""" + + def __init__(self, objects: dict[tuple, Any] | None = None) -> None: + self._objects = objects or {} + self._added: list = [] + self.committed = False + + def get(self, model, obj_id): + return self._objects.get((model, obj_id)) + + def query(self, model): + return FakeQuery(self._objects, model) + + def add(self, obj): + self._added.append(obj) + + def commit(self): + self.committed = True + + def refresh(self, obj): + pass # no-op for tests + + +class FakeQuery: + """Chainable query mock.""" + + def __init__(self, objects: dict, model) -> None: + self._objects = objects + self._model = model + self._filters: list = [] + + def filter(self, *args, **kwargs): + # Store but don't evaluate — return self for chaining + return self + + def order_by(self, *args): + return self + + def first(self): + # Return first matching object of the model type + for (model, _), obj in self._objects.items(): + if model is self._model: + return obj + return None + + +# --------------------------------------------------------------------------- +# Test 1: create_campaign creates a PENDING campaign with correct config +# --------------------------------------------------------------------------- + +@patch('app.services.strategy.optimizer_service.get_settings') +def test_create_campaign_creates_pending_with_correct_config(mock_settings) -> None: + """create_campaign should create PENDING campaign clamped by settings limits.""" + mock_settings.return_value = SimpleNamespace( + optimizer_max_iterations=100, + optimizer_max_iterations_limit=200, + optimizer_time_budget_seconds=300, + optimizer_time_budget_limit=600, + optimizer_max_candidates_per_iteration=5, + ) + + from app.db.models.strategy import Strategy + from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign + + strategy = _make_strategy() + + # DB that has no existing campaign and has the strategy + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None # no active campaign + db.get.return_value = strategy + + # Capture what gets added + added_campaigns = [] + + def fake_add(obj): + added_campaigns.append(obj) + + db.add.side_effect = fake_add + + from app.services.strategy.optimizer_service import create_campaign + + result = create_campaign(db, strategy_id=1, config={'max_iterations': 80, 'time_budget_seconds': 250}) + + # Verify add was called + assert db.add.called + campaign = added_campaigns[0] + assert campaign.status == 'PENDING' + assert campaign.config['max_iterations'] == 80 + assert campaign.config['time_budget_seconds'] == 250 + assert campaign.current_iteration == 0 + assert campaign.initial_params == {'ema_fast': 9, 'ema_slow': 21, 'rsi_filter': 30} + + +# --------------------------------------------------------------------------- +# Test 2: create_campaign raises ValueError if active campaign exists +# --------------------------------------------------------------------------- + +@patch('app.services.strategy.optimizer_service.get_settings') +def test_create_campaign_raises_if_active_exists(mock_settings) -> None: + """create_campaign should raise ValueError if an active campaign exists.""" + mock_settings.return_value = SimpleNamespace( + optimizer_max_iterations=100, + optimizer_max_iterations_limit=200, + optimizer_time_budget_seconds=300, + optimizer_time_budget_limit=600, + optimizer_max_candidates_per_iteration=5, + ) + + existing_campaign = _make_campaign(status='RUNNING') + + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = existing_campaign + + from app.services.strategy.optimizer_service import create_campaign + + with pytest.raises(ValueError, match='Campaign already active'): + create_campaign(db, strategy_id=1, config={}) + + +# --------------------------------------------------------------------------- +# Test 3: run_optimization_loop (naive fallback) updates best_score +# --------------------------------------------------------------------------- + +@patch('app.services.strategy.optimizer_service.OPENEVOLVE_AVAILABLE', False) +@patch('app.services.strategy.optimizer_service.build_evaluator') +def test_run_naive_loop_updates_best_score_on_improvement(mock_build_evaluator) -> None: + """The naive loop should update best_score when a better candidate is found.""" + from app.db.models.strategy import Strategy + from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign + + strategy = _make_strategy() + campaign = _make_campaign( + status='PENDING', + config={'max_iterations': 2, 'time_budget_seconds': 600, 'max_candidates_per_iteration': 1}, + ) + + # Evaluator returns increasing scores + call_count = {'n': 0} + + def fake_evaluator(params): + call_count['n'] += 1 + score = 50.0 + call_count['n'] * 10.0 + return {'score': score, 'metrics': {'win_rate_pct': score}} + + mock_build_evaluator.return_value = fake_evaluator + + db = MagicMock() + db.get.side_effect = lambda model, id: ( + campaign if model is StrategyOptimizerCampaign else strategy + ) + db.refresh.side_effect = lambda obj: None + + from app.services.strategy.optimizer_service import run_optimization_loop + + run_optimization_loop(db, campaign_id=campaign.id) + + # Campaign should be updated — best_score should be > initial (60.0) + assert campaign.best_score > 60.0 + assert campaign.status == 'COMPLETED' + + +# --------------------------------------------------------------------------- +# Test 4: run_optimization_loop marks COMPLETED when done +# --------------------------------------------------------------------------- + +@patch('app.services.strategy.optimizer_service.OPENEVOLVE_AVAILABLE', False) +@patch('app.services.strategy.optimizer_service.build_evaluator') +def test_run_naive_loop_marks_completed(mock_build_evaluator) -> None: + """The naive loop marks campaign COMPLETED after all iterations.""" + from app.db.models.strategy import Strategy + from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign + + strategy = _make_strategy() + campaign = _make_campaign( + status='PENDING', + config={'max_iterations': 1, 'time_budget_seconds': 600, 'max_candidates_per_iteration': 1}, + ) + + mock_build_evaluator.return_value = lambda params: {'score': 42.0, 'metrics': {}} + + db = MagicMock() + db.get.side_effect = lambda model, id: ( + campaign if model is StrategyOptimizerCampaign else strategy + ) + db.refresh.side_effect = lambda obj: None + + from app.services.strategy.optimizer_service import run_optimization_loop + + run_optimization_loop(db, campaign_id=campaign.id) + + assert campaign.status == 'COMPLETED' + assert campaign.completed_at is not None + + +# --------------------------------------------------------------------------- +# Test 5: run_optimization_loop marks CANCELLED when campaign cancelled mid-run +# --------------------------------------------------------------------------- + +@patch('app.services.strategy.optimizer_service.OPENEVOLVE_AVAILABLE', False) +@patch('app.services.strategy.optimizer_service.build_evaluator') +def test_run_naive_loop_stops_on_cancellation(mock_build_evaluator) -> None: + """If campaign is CANCELLED mid-run, the loop should stop without marking COMPLETED.""" + from app.db.models.strategy import Strategy + from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign + + strategy = _make_strategy() + campaign = _make_campaign( + status='PENDING', + config={'max_iterations': 100, 'time_budget_seconds': 600, 'max_candidates_per_iteration': 1}, + ) + + mock_build_evaluator.return_value = lambda params: {'score': 42.0, 'metrics': {}} + + # Simulate cancellation on first db.refresh call during loop + def fake_refresh(obj): + if hasattr(obj, 'status') and obj.status == 'RUNNING': + obj.status = 'CANCELLED' + + db = MagicMock() + db.get.side_effect = lambda model, id: ( + campaign if model is StrategyOptimizerCampaign else strategy + ) + db.refresh.side_effect = fake_refresh + + from app.services.strategy.optimizer_service import run_optimization_loop + + run_optimization_loop(db, campaign_id=campaign.id) + + # The campaign should remain CANCELLED (not overwritten to COMPLETED) + assert campaign.status == 'CANCELLED' + + +# --------------------------------------------------------------------------- +# Test 6: accept_campaign applies best_params and resets strategy status +# --------------------------------------------------------------------------- + +@patch('app.services.strategy.optimizer_service.get_settings') +def test_accept_campaign_applies_best_params(mock_settings) -> None: + """accept_campaign should apply best_params to strategy and reset to BACKTESTING.""" + mock_settings.return_value = SimpleNamespace(celery_backtest_queue='backtest') + + from app.db.models.strategy import Strategy + from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign + + strategy = _make_strategy() + campaign = _make_campaign( + status='COMPLETED', + best_params={'ema_fast': 12, 'ema_slow': 30, 'rsi_filter': 25}, + ) + + db = MagicMock() + db.get.side_effect = lambda model, id: ( + campaign if model is StrategyOptimizerCampaign else strategy + ) + + from app.services.strategy.optimizer_service import accept_campaign + + with patch('app.tasks.strategy_backtest_task.execute') as mock_task: + mock_task.apply_async = MagicMock() + result = accept_campaign(db, campaign_id=campaign.id) + + assert strategy.params == {'ema_fast': 12, 'ema_slow': 30, 'rsi_filter': 25} + assert strategy.status == 'BACKTESTING' + assert strategy.score == 0.0 + assert result.status == 'ACCEPTED' + + +# --------------------------------------------------------------------------- +# Test 7: reject_campaign marks REJECTED_BY_USER without changing strategy +# --------------------------------------------------------------------------- + +def test_reject_campaign_marks_rejected() -> None: + """reject_campaign should mark campaign REJECTED_BY_USER, leave strategy untouched.""" + from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign + + strategy = _make_strategy() + campaign = _make_campaign(status='COMPLETED') + original_params = dict(strategy.params) + + db = MagicMock() + db.get.side_effect = lambda model, id: ( + campaign if model is StrategyOptimizerCampaign else strategy + ) + + from app.services.strategy.optimizer_service import reject_campaign + + result = reject_campaign(db, campaign_id=campaign.id) + + assert result.status == 'REJECTED_BY_USER' + assert strategy.params == original_params + assert strategy.status == 'VALIDATED' + + +# --------------------------------------------------------------------------- +# Test 8: cancel_campaign marks CANCELLED and revokes celery task +# --------------------------------------------------------------------------- + +def test_cancel_campaign_revokes_celery_task() -> None: + """cancel_campaign should mark CANCELLED and revoke the Celery task.""" + from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign + + campaign = _make_campaign(status='RUNNING', celery_task_id='task-abc-123') + + db = MagicMock() + db.get.return_value = campaign + + from app.services.strategy.optimizer_service import cancel_campaign + + with patch('app.tasks.celery_app.celery_app') as mock_celery: + mock_celery.control.revoke = MagicMock() + result = cancel_campaign(db, campaign_id=campaign.id) + + assert result.status == 'CANCELLED' + assert result.completed_at is not None + mock_celery.control.revoke.assert_called_once_with('task-abc-123', terminate=True) + + +# --------------------------------------------------------------------------- +# Test 9: _mutate_params stays within bounds +# --------------------------------------------------------------------------- + +def test_mutate_params_stays_within_bounds() -> None: + """_mutate_params should never produce values outside template bounds.""" + from app.services.strategy.optimizer_service import _mutate_params + from app.services.strategy.optimizer_bounds import get_bounds_for_template + + base_params = {'ema_fast': 9, 'ema_slow': 21, 'rsi_filter': 30} + template = 'ema_crossover' + bounds = get_bounds_for_template(template) + + random.seed(42) + for _ in range(100): + mutated = _mutate_params(base_params, template, perturbation_pct=0.50) + for key, (lo, hi) in bounds.items(): + assert lo <= float(mutated[key]) <= hi, ( + f'{key}={mutated[key]} not in [{lo}, {hi}]' + ) + + +# --------------------------------------------------------------------------- +# Test 10: _mutate_params preserves int types +# --------------------------------------------------------------------------- + +def test_mutate_params_preserves_int_types() -> None: + """_mutate_params should return int values for int-typed input params.""" + from app.services.strategy.optimizer_service import _mutate_params + + base_params = {'ema_fast': 9, 'ema_slow': 21, 'rsi_filter': 30} + template = 'ema_crossover' + + random.seed(0) + for _ in range(50): + mutated = _mutate_params(base_params, template) + for key in base_params: + assert isinstance(mutated[key], int), ( + f'{key} should be int, got {type(mutated[key])}' + ) + + +# --------------------------------------------------------------------------- +# Test 11: evaluator_direct returns score 0 when BacktestEngine raises +# --------------------------------------------------------------------------- + +def test_evaluator_direct_returns_zero_on_engine_error() -> None: + """evaluator_direct should return score 0 if BacktestEngine.run() raises.""" + from app.services.strategy.optimizer_service import evaluator_direct + + with patch('app.services.backtest.engine.BacktestEngine') as MockEngine: + instance = MockEngine.return_value + instance.run.side_effect = RuntimeError('Market data unavailable') + + result = evaluator_direct( + template='ema_crossover', + symbol='EURUSD.PRO', + timeframe='H1', + params={'ema_fast': 9, 'ema_slow': 21, 'rsi_filter': 30}, + ) + + assert result['score'] == 0.0 + assert result['metrics'] == {} + + +# --------------------------------------------------------------------------- +# Test 12: OptimizerCampaignOut.from_campaign handles naive datetime +# --------------------------------------------------------------------------- + +def test_optimizer_campaign_out_handles_naive_datetime() -> None: + """OptimizerCampaignOut.from_campaign should handle naive datetimes without crash.""" + from app.schemas.optimizer import OptimizerCampaignOut + + # Simulate a campaign with naive (no tzinfo) created_at + campaign = _make_campaign( + status='RUNNING', + created_at=datetime(2026, 1, 15, 10, 0, 0), # naive — no tzinfo + completed_at=None, + initial_score=50.0, + ) + + result = OptimizerCampaignOut.from_campaign(campaign) + + assert result.status == 'RUNNING' + assert result.elapsed_seconds is not None + assert result.elapsed_seconds > 0 + assert result.initial_score == 50.0 + assert result.max_iterations == 50 diff --git a/frontend/tests/e2e/strategy-optimizer.spec.ts b/frontend/tests/e2e/strategy-optimizer.spec.ts new file mode 100644 index 0000000..27c82b2 --- /dev/null +++ b/frontend/tests/e2e/strategy-optimizer.spec.ts @@ -0,0 +1,321 @@ +import { expect, test, type Page } from '@playwright/test'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function asJson(body: unknown, status = 200) { + return { + status, + contentType: 'application/json', + body: JSON.stringify(body), + }; +} + +const VALIDATED_STRATEGY = { + id: 1, + symbol: 'EURUSD.PRO', + timeframe: 'H1', + template: 'ema_crossover', + status: 'VALIDATED', + score: 65.0, + params: { ema_fast: 9, ema_slow: 21, rsi_filter: 30 }, + metrics: { win_rate_pct: 45, profit_factor: 1.5 }, + created_at: '2026-01-15T10:00:00Z', + updated_at: '2026-01-15T10:00:00Z', +}; + +const BACKTESTING_STRATEGY = { + ...VALIDATED_STRATEGY, + id: 2, + status: 'BACKTESTING', +}; + +function makeCampaign(overrides: Record = {}) { + return { + id: 10, + strategy_id: 1, + status: 'PENDING', + current_iteration: 0, + max_iterations: 50, + best_score: null, + best_params: null, + initial_params: { ema_fast: 9, ema_slow: 21, rsi_filter: 30 }, + initial_score: null, + best_metrics: null, + elapsed_seconds: null, + error_message: null, + created_at: '2026-01-15T10:00:00Z', + completed_at: null, + ...overrides, + }; +} + +async function setupMockApi(page: Page, options: { + strategies?: unknown[]; + campaign?: unknown | null; + campaignSequence?: unknown[]; +} = {}) { + const strategies = options.strategies ?? [VALIDATED_STRATEGY]; + let campaignResponse = options.campaign ?? null; + const campaignSequence = options.campaignSequence ?? []; + let pollCount = 0; + + await page.addInitScript(() => { + localStorage.setItem('token', 'e2e-token'); + }); + + await page.route('**/api/v1/**', async (route) => { + const request = route.request(); + const method = request.method(); + const url = new URL(request.url()); + const path = url.pathname; + + // Auth + if (path.endsWith('/auth/me')) { + return route.fulfill(asJson({ id: 1, email: 'admin@local.dev', role: 'admin', is_active: true })); + } + + // Strategies list + if (path.endsWith('/strategies') && method === 'GET') { + return route.fulfill(asJson(strategies)); + } + + // Launch optimization + if (path.match(/\/strategies\/\d+\/optimize$/) && method === 'POST') { + campaignResponse = makeCampaign({ status: 'PENDING' }); + return route.fulfill(asJson(campaignResponse)); + } + + // Get campaign status (polling) + if (path.match(/\/strategies\/\d+\/optimizer-campaign$/) && method === 'GET') { + if (campaignSequence.length > 0 && pollCount < campaignSequence.length) { + const resp = campaignSequence[pollCount]; + pollCount++; + return route.fulfill(asJson(resp)); + } + if (campaignResponse) { + return route.fulfill(asJson(campaignResponse)); + } + return route.fulfill({ status: 404, body: 'Not found' }); + } + + // Accept campaign + if (path.match(/\/optimizer-campaign\/\d+\/accept$/) && method === 'POST') { + campaignResponse = makeCampaign({ status: 'ACCEPTED' }); + return route.fulfill(asJson(campaignResponse)); + } + + // Reject campaign + if (path.match(/\/optimizer-campaign\/\d+\/reject$/) && method === 'POST') { + campaignResponse = makeCampaign({ status: 'REJECTED_BY_USER' }); + return route.fulfill(asJson(campaignResponse)); + } + + // Cancel campaign + if (path.match(/\/optimizer-campaign\/\d+$/) && method === 'DELETE') { + campaignResponse = makeCampaign({ status: 'CANCELLED' }); + return route.fulfill({ status: 204, body: '' }); + } + + // Fallback + return route.fulfill({ status: 404, body: 'Not mocked' }); + }); +} + +// --------------------------------------------------------------------------- +// Test 1: "OPTIMISER" button visible only on VALIDATED strategies +// --------------------------------------------------------------------------- + +test('OPTIMISER button visible only for VALIDATED strategies', async ({ page }) => { + await setupMockApi(page, { strategies: [VALIDATED_STRATEGY, BACKTESTING_STRATEGY] }); + await page.goto('/strategies'); + + // OPTIMISER button should be present for the VALIDATED strategy + const optimiserButtons = page.getByRole('button', { name: /OPTIMISER/i }); + await expect(optimiserButtons).toHaveCount(1); +}); + +// --------------------------------------------------------------------------- +// Test 2: Click "OPTIMISER" opens config modal with inputs +// --------------------------------------------------------------------------- + +test('click OPTIMISER opens config with max_iterations and time_budget inputs', async ({ page }) => { + await setupMockApi(page); + await page.goto('/strategies'); + + await page.getByRole('button', { name: /OPTIMISER/i }).click(); + + // Config panel should be visible with inputs + await expect(page.getByText('OPTIMIZER_CONFIG')).toBeVisible(); + await expect(page.getByLabel(/Max Iterations/i)).toBeVisible(); + await expect(page.getByLabel(/Time Budget/i)).toBeVisible(); + await expect(page.getByRole('button', { name: /LANCER/i })).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// Test 3: Click "LANCER" calls POST /optimize and shows progress +// --------------------------------------------------------------------------- + +test('click LANCER calls POST /optimize and shows progress indicator', async ({ page }) => { + let optimizePostCalled = false; + + await setupMockApi(page, { + campaign: null, + campaignSequence: [ + makeCampaign({ status: 'RUNNING', current_iteration: 5, best_score: 52.0, initial_score: 45.0 }), + ], + }); + + // Track the POST call + await page.route('**/api/v1/strategies/1/optimize', async (route) => { + optimizePostCalled = true; + return route.fulfill(asJson(makeCampaign({ status: 'PENDING' }))); + }); + + await page.goto('/strategies'); + await page.getByRole('button', { name: /OPTIMISER/i }).click(); + await page.getByRole('button', { name: /LANCER/i }).click(); + + // Should show optimization progress indicator + await expect(page.getByText('OPTIMISATION_EN_COURS')).toBeVisible({ timeout: 10000 }); + expect(optimizePostCalled).toBe(true); +}); + +// --------------------------------------------------------------------------- +// Test 4: Progress bar updates during polling +// --------------------------------------------------------------------------- + +test('progress bar updates during polling', async ({ page }) => { + await setupMockApi(page, { + campaign: makeCampaign({ status: 'RUNNING', current_iteration: 25, max_iterations: 50, best_score: 55.0, initial_score: 45.0 }), + }); + + await page.goto('/strategies'); + + // Wait for progress display + await expect(page.getByText('OPTIMISATION_EN_COURS')).toBeVisible({ timeout: 10000 }); + + // Progress indicator shows iteration count + await expect(page.getByText('25/50')).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// Test 5: Completed campaign shows score comparison +// --------------------------------------------------------------------------- + +test('completed campaign shows score comparison (initial vs best)', async ({ page }) => { + await setupMockApi(page, { + campaign: makeCampaign({ + status: 'COMPLETED', + initial_score: 45.0, + best_score: 72.5, + best_params: { ema_fast: 12, ema_slow: 30, rsi_filter: 25 }, + current_iteration: 50, + max_iterations: 50, + }), + }); + + await page.goto('/strategies'); + + // Should show completion marker and scores + await expect(page.getByText('OPTIMISATION_TERMINÉE')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('45.00')).toBeVisible(); // initial score + await expect(page.getByText('72.50')).toBeVisible(); // best score +}); + +// --------------------------------------------------------------------------- +// Test 6: Click "APPLIQUER" calls accept endpoint +// --------------------------------------------------------------------------- + +test('click APPLIQUER calls accept endpoint', async ({ page }) => { + let acceptCalled = false; + + await setupMockApi(page, { + campaign: makeCampaign({ + status: 'COMPLETED', + initial_score: 45.0, + best_score: 72.5, + best_params: { ema_fast: 12, ema_slow: 30, rsi_filter: 25 }, + current_iteration: 50, + }), + }); + + await page.route('**/api/v1/strategies/optimizer-campaign/10/accept', async (route) => { + acceptCalled = true; + return route.fulfill(asJson(makeCampaign({ status: 'ACCEPTED' }))); + }); + + await page.goto('/strategies'); + await expect(page.getByText('OPTIMISATION_TERMINÉE')).toBeVisible({ timeout: 10000 }); + await page.getByRole('button', { name: /APPLIQUER/i }).click(); + + // Wait a tick for the async call to complete + await page.waitForTimeout(500); + expect(acceptCalled).toBe(true); +}); + +// --------------------------------------------------------------------------- +// Test 7: Click "REJETER" calls reject endpoint +// --------------------------------------------------------------------------- + +test('click REJETER calls reject endpoint', async ({ page }) => { + let rejectCalled = false; + + await setupMockApi(page, { + campaign: makeCampaign({ + status: 'COMPLETED', + initial_score: 45.0, + best_score: 72.5, + best_params: { ema_fast: 12, ema_slow: 30, rsi_filter: 25 }, + current_iteration: 50, + }), + }); + + await page.route('**/api/v1/strategies/optimizer-campaign/10/reject', async (route) => { + rejectCalled = true; + return route.fulfill(asJson(makeCampaign({ status: 'REJECTED_BY_USER' }))); + }); + + await page.goto('/strategies'); + await expect(page.getByText('OPTIMISATION_TERMINÉE')).toBeVisible({ timeout: 10000 }); + await page.getByRole('button', { name: /REJETER/i }).click(); + + await page.waitForTimeout(500); + expect(rejectCalled).toBe(true); +}); + +// --------------------------------------------------------------------------- +// Test 8: "ANNULER" button visible during RUNNING status +// --------------------------------------------------------------------------- + +test('ANNULER button visible during RUNNING status', async ({ page }) => { + await setupMockApi(page, { + campaign: makeCampaign({ status: 'RUNNING', current_iteration: 10, max_iterations: 50, best_score: 50.0, initial_score: 45.0 }), + }); + + await page.goto('/strategies'); + + await expect(page.getByText('OPTIMISATION_EN_COURS')).toBeVisible({ timeout: 10000 }); + await expect(page.getByRole('button', { name: /ANNULER/i })).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// Test 9: Failed campaign shows error message +// --------------------------------------------------------------------------- + +test('failed campaign shows error message', async ({ page }) => { + await setupMockApi(page, { + campaign: makeCampaign({ + status: 'FAILED', + error_message: 'Market data unavailable for EURUSD.PRO', + current_iteration: 3, + max_iterations: 50, + }), + }); + + await page.goto('/strategies'); + + await expect(page.getByText(/OPTIMISATION_ÉCHOUÉE/)).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/Market data unavailable/)).toBeVisible(); +}); From 13e42c9cc25c344f1c64f3b4d5666ce27bbf74a4 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:33:26 +0200 Subject: [PATCH 13/21] fix(strategy-optimizer): use OpenEvolve Config object with LLMModelConfig instead of YAML --- .../services/strategy/optimizer_service.py | 82 +++++++++---------- 1 file changed, 38 insertions(+), 44 deletions(-) diff --git a/backend/app/services/strategy/optimizer_service.py b/backend/app/services/strategy/optimizer_service.py index 3803145..2f8774a 100644 --- a/backend/app/services/strategy/optimizer_service.py +++ b/backend/app/services/strategy/optimizer_service.py @@ -17,7 +17,6 @@ from datetime import datetime, timedelta, timezone from typing import Any -import yaml from sqlalchemy.orm import Session from app.core.config import get_settings @@ -438,53 +437,47 @@ def openevolve_evaluator(file_path: str) -> 'EvaluationResult': artifacts={'stderr': f'Backtest error: {str(e)[:200]}'}, ) - # 3. Write OpenEvolve config + # 3. Build OpenEvolve Config object (programmatic — not YAML) + from openevolve.config import Config as OEConfig, LLMModelConfig + bounds_description = '\n'.join( f' - {k}: min={lo}, max={hi}' for k, (lo, hi) in bounds.items() ) - openevolve_config = { - 'max_iterations': max_iterations, - 'llm': { - 'model': model_name, - 'temperature': 0.7, - 'api_key': api_key, - 'base_url': base_url, - }, - 'database': { - 'population_size': min(max_iterations * 2, 100), - 'num_islands': 2, - }, - 'evaluator': { - 'enable_artifacts': True, - }, - 'prompt': { - 'num_top_programs': 2, - 'num_diverse_programs': 1, - 'include_artifacts': True, - 'system_message': ( - f"You are a quantitative trading strategy optimizer.\n" - f"You optimize parameters for a '{strategy.template}' strategy " - f"on {strategy.symbol} {strategy.timeframe}.\n\n" - f"RULES:\n" - f"- Return ONLY valid JSON (no markdown, no explanation).\n" - f"- Keep the exact same structure: " - f"{{\"template\": ..., \"symbol\": ..., \"timeframe\": ..., \"params\": {{...}}}}\n" - f"- Only modify values inside 'params'.\n" - f"- Respect parameter bounds:\n{bounds_description}\n\n" - f"GOAL: Maximize the composite score " - f"(win_rate × profit_factor × low_drawdown × positive_return × sufficient_trades).\n" - f"Use the feedback from previous evaluations to guide your changes.\n" - ), - }, - } + system_message = ( + f"You are a quantitative trading strategy optimizer.\n" + f"You optimize parameters for a '{strategy.template}' strategy " + f"on {strategy.symbol} {strategy.timeframe}.\n\n" + f"RULES:\n" + f"- Return ONLY valid JSON (no markdown, no explanation).\n" + f"- Keep the exact same structure: " + f"{{\"template\": ..., \"symbol\": ..., \"timeframe\": ..., \"params\": {{...}}}}\n" + f"- Only modify values inside 'params'.\n" + f"- Respect parameter bounds:\n{bounds_description}\n\n" + f"GOAL: Maximize the composite score " + f"(win_rate × profit_factor × low_drawdown × positive_return × sufficient_trades).\n" + f"Use the feedback from previous evaluations to guide your changes.\n" + ) - # 4. Run OpenEvolve - config_dir = tempfile.mkdtemp(prefix='openevolve_strategy_') - config_path = os.path.join(config_dir, 'config.yaml') + # Ensure base_url ends with /v1 for OpenAI-compatible providers + llm_base_url = base_url.rstrip('/') + if not llm_base_url.endswith('/v1'): + llm_base_url += '/v1' + + oe_config = OEConfig() + oe_config.max_iterations = max_iterations + oe_config.llm.models = [ + LLMModelConfig( + name=model_name, + api_key=api_key or 'ollama', + api_base=llm_base_url, + temperature=0.7, + system_message=system_message, + ), + ] - with open(config_path, 'w') as f: - yaml.dump(openevolve_config, f) + # 4. Run OpenEvolve + output_dir = tempfile.mkdtemp(prefix='openevolve_strategy_') try: # Evaluate initial params first @@ -503,7 +496,8 @@ def openevolve_evaluator(file_path: str) -> 'EvaluationResult': initial_program=initial_program, evaluator=openevolve_evaluator, iterations=max_iterations, - config=config_path, + config=oe_config, + output_dir=output_dir, ) # Parse the best result @@ -540,7 +534,7 @@ def openevolve_evaluator(file_path: str) -> 'EvaluationResult': campaign.completed_at = datetime.now(timezone.utc) db.commit() # Cleanup temp dir - shutil.rmtree(config_dir, ignore_errors=True) + shutil.rmtree(output_dir, ignore_errors=True) def _run_naive_loop( From 41287fd47671b58b287f532a0babfb22408e94fb Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 21:59:43 +0200 Subject: [PATCH 14/21] fix(strategy-optimizer): add dedicated solo-pool worker for OpenEvolve (daemon process workaround) --- docker-compose.yml | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 19a976b..80e8058 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,7 +86,7 @@ services: - '${PROMETHEUS_WORKER_PORT:-9101}' command: > sh -c "mkdir -p /tmp/prometheus-worker && rm -rf /tmp/prometheus-worker/* && - celery -A app.tasks.celery_app.celery_app worker --loglevel=warning -B -Q $${CELERY_WORKER_QUEUES:-analysis,backtests,strategy-optimizer} + celery -A app.tasks.celery_app.celery_app worker --loglevel=warning -B -Q $${CELERY_WORKER_QUEUES:-analysis,backtests} --concurrency=$${CELERY_WORKER_CONCURRENCY:-2} --prefetch-multiplier=$${CELERY_WORKER_PREFETCH_MULTIPLIER:-1} --max-tasks-per-child=$${CELERY_WORKER_MAX_TASKS_PER_CHILD:-100}" @@ -98,6 +98,30 @@ services: rabbitmq: condition: service_started + optimizer-worker: + build: + context: ./backend + env_file: + - ./backend/.env + environment: + DATABASE_URL: postgresql+psycopg2://trading:trading@postgres:5432/trading_platform + REDIS_URL: redis://redis:6379/0 + CELERY_BROKER_URL: amqp://guest:guest@rabbitmq:5672// + CELERY_IGNORE_RESULT: 'true' + AGENTSCOPE_TRACING_URL: http://tempo:4318/v1/traces + volumes: + - ./backend/debug-strategy:/app/debug-strategy + command: > + sh -c "celery -A app.tasks.celery_app.celery_app worker --loglevel=info + --pool=solo -Q strategy-optimizer -n optimizer@%h" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_started + frontend: build: context: ./frontend From 0f2a17fb22e10354478d1e7c5234c123c3f18d33 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 22:16:07 +0200 Subject: [PATCH 15/21] fix(strategy-optimizer): rewrite evaluator as standalone file for OpenEvolve subprocess execution --- .../services/strategy/optimizer_service.py | 319 ++++++++++-------- 1 file changed, 176 insertions(+), 143 deletions(-) diff --git a/backend/app/services/strategy/optimizer_service.py b/backend/app/services/strategy/optimizer_service.py index 2f8774a..aae84fb 100644 --- a/backend/app/services/strategy/optimizer_service.py +++ b/backend/app/services/strategy/optimizer_service.py @@ -30,8 +30,7 @@ from app.services.strategy.optimizer_bounds import clamp_params, get_bounds_for_template try: - from openevolve import run_evolution - from openevolve.evaluation_result import EvaluationResult + from openevolve.controller import OpenEvolve as _OEController # noqa: F401 OPENEVOLVE_AVAILABLE = True except ImportError: OPENEVOLVE_AVAILABLE = False @@ -298,6 +297,105 @@ def _resolve_llm_config(db: Session) -> tuple[str, str, str, str]: return provider, model_name, base_url or '', api_key or '' +def _build_evaluator_script( + template: str, + symbol: str, + timeframe: str, + db_url: str, +) -> str: + """Build a standalone Python evaluator script for OpenEvolve subprocess. + + OpenEvolve loads the evaluator via ``importlib`` in the same process, + so the script must be self-contained (no ORM closures). It imports + application modules lazily so it works from the project ``/app`` root. + """ + return f'''"""Auto-generated evaluator for OpenEvolve — strategy optimizer.""" + +import json +import sys +import os + +# Ensure the backend package is importable inside the worker. +sys.path.insert(0, '/app') +os.environ.setdefault('DATABASE_URL', {db_url!r}) + + +def evaluate(file_path: str): + """Evaluate a candidate JSON program via back-test.""" + from openevolve.evaluation_result import EvaluationResult + + try: + with open(file_path, 'r') as f: + content = f.read() + + candidate_data = json.loads(content) + candidate_params = candidate_data.get('params', candidate_data) + + # LLM sometimes strips the wrapper — handle flat dicts + if 'template' not in candidate_data: + candidate_params = candidate_data + + # Clamp parameters to template bounds + from app.services.strategy.optimizer_bounds import clamp_params + clamped = clamp_params({template!r}, candidate_params) + + # Run the back-test + from app.services.backtest.engine import BacktestEngine + from app.services.strategy.lookback_windows import strategy_lookback_days + from app.services.strategy.generation_optimizer import compute_generation_candidate_score + from datetime import datetime, timedelta, timezone + + lb_days = strategy_lookback_days({symbol!r}) + end_date = datetime.now(timezone.utc).strftime('%Y-%m-%d') + start_date = (datetime.now(timezone.utc) - timedelta(days=lb_days)).strftime('%Y-%m-%d') + + engine = BacktestEngine() + result = engine.run( + {symbol!r}, {timeframe!r}, + start_date, end_date, + strategy={template!r}, + db=None, + strategy_params=clamped, + run_id=None, + ) + + metrics = dict(result.metrics or {{}}) + score = compute_generation_candidate_score(metrics) + + win_rate = metrics.get('win_rate_pct', metrics.get('win_rate', 0)) + pf = metrics.get('profit_factor', 0) + dd = metrics.get('max_drawdown_pct', metrics.get('max_drawdown', 0)) + trades = metrics.get('total_trades', 0) + + feedback = ( + f"Score: {{score:.1f}}/100 | Win Rate: {{win_rate:.1f}}% | " + f"Profit Factor: {{pf:.2f}} | Max Drawdown: {{dd:.1f}}% | Trades: {{trades}}\\n" + ) + if trades < 5: + feedback += "WARNING: Too few trades.\\n" + if float(dd) > 25: + feedback += "WARNING: Excessive drawdown.\\n" + if score > 60: + feedback += "GOOD: Above threshold.\\n" + + return EvaluationResult( + metrics={{"combined_score": score, "performance": score, "drawdown": abs(float(dd))}}, + artifacts={{"llm_feedback": feedback}}, + ) + + except json.JSONDecodeError as e: + return EvaluationResult( + metrics={{"combined_score": -1.0, "performance": -1.0}}, + artifacts={{"stderr": f"INVALID JSON: {{e}}"}}, + ) + except Exception as e: + return EvaluationResult( + metrics={{"combined_score": 0.0, "performance": 0.0}}, + artifacts={{"stderr": f"Backtest error: {{str(e)[:200]}}"}}, + ) +''' + + def _run_openevolve_loop( db: Session, campaign: StrategyOptimizerCampaign, @@ -305,7 +403,13 @@ def _run_openevolve_loop( config: dict[str, Any], max_iterations: int, ) -> None: - """Run optimization using OpenEvolve with LLM-driven mutation.""" + """Run optimization using OpenEvolve with LLM-driven mutation. + + The evaluator runs inside OpenEvolve's own process via ``importlib``, + so it must be a **standalone .py file** — no closures over ORM objects. + """ + import asyncio + campaign_id = campaign.id # 0. Resolve LLM config from project settings @@ -315,131 +419,46 @@ def _run_openevolve_loop( campaign_id, provider, model_name, ) - # Set env vars for OpenEvolve's internal LLM client - if provider == 'openai': - if api_key: - os.environ.setdefault('OPENAI_API_KEY', api_key) - if base_url and base_url != 'https://api.openai.com/v1': - os.environ.setdefault('OPENAI_BASE_URL', base_url) - elif provider == 'mistral': - # Mistral is OpenAI-compatible - if api_key: - os.environ.setdefault('OPENAI_API_KEY', api_key) - if base_url: - os.environ.setdefault('OPENAI_BASE_URL', base_url) - else: # ollama - os.environ.setdefault('OPENAI_API_KEY', api_key or 'ollama') - os.environ.setdefault('OPENAI_BASE_URL', f"{base_url.rstrip('/')}/v1") + # 1. Evaluate initial params directly (before OpenEvolve) + initial_eval = evaluator_direct( + strategy.template, strategy.symbol, strategy.timeframe, + dict(strategy.params or {}), + ) + campaign.initial_score = initial_eval['score'] + campaign.best_params = dict(strategy.params or {}) + campaign.best_score = initial_eval['score'] + campaign.best_metrics = initial_eval['metrics'] + db.commit() - # 1. Initial program = JSON of params - initial_program = json.dumps({ + # 2. Write the initial program JSON to a temp file + output_dir = tempfile.mkdtemp(prefix='openevolve_strategy_') + initial_program_path = os.path.join(output_dir, 'initial_program.json') + initial_data = json.dumps({ 'template': strategy.template, 'symbol': strategy.symbol, 'timeframe': strategy.timeframe, 'params': dict(strategy.params or {}), }, indent=2) - # 2. Build evaluator for OpenEvolve (file-based interface) - bounds = get_bounds_for_template(strategy.template) - - def openevolve_evaluator(file_path: str) -> 'EvaluationResult': - """Evaluate a candidate strategy by backtesting it.""" - # Check cancellation - db.refresh(campaign) - if campaign.status == 'CANCELLED': - raise RuntimeError('Campaign cancelled') - - try: - with open(file_path, 'r') as f: - content = f.read() - - # Parse the JSON (LLM may produce varied formats) - candidate_data = json.loads(content) - candidate_params = candidate_data.get('params', candidate_data) - - # If params is the top-level dict (LLM sometimes strips structure) - if 'template' not in candidate_data and all( - k in candidate_data for k in (strategy.params or {}).keys() - ): - candidate_params = candidate_data - - # Clamp to bounds - clamped = clamp_params(strategy.template, candidate_params) - - # Backtest - from app.services.backtest.engine import BacktestEngine - - lb_days = strategy_lookback_days(strategy.symbol) - end_date = datetime.now(timezone.utc).strftime('%Y-%m-%d') - start_date = (datetime.now(timezone.utc) - timedelta(days=lb_days)).strftime('%Y-%m-%d') - - engine = BacktestEngine() - result = engine.run( - strategy.symbol, strategy.timeframe, - start_date, end_date, - strategy=strategy.template, - db=None, - strategy_params=clamped, - run_id=None, - ) + with open(initial_program_path, 'w') as f: + f.write(initial_data) - metrics = dict(result.metrics or {}) - score = compute_generation_candidate_score(metrics) - - # Store evaluation in DB - db.add(StrategyOptimizerEvaluation( - campaign_id=campaign_id, - iteration=campaign.current_iteration or 0, - params=clamped, - score=score, - metrics=metrics, - )) - campaign.current_iteration = (campaign.current_iteration or 0) + 1 - if score > (campaign.best_score or 0.0): - campaign.best_score = score - campaign.best_params = clamped - campaign.best_metrics = metrics - db.commit() - - # Return result with feedback for LLM - win_rate = metrics.get('win_rate_pct', metrics.get('win_rate', 0)) - pf = metrics.get('profit_factor', 0) - dd = metrics.get('max_drawdown_pct', metrics.get('max_drawdown', 0)) - trades = metrics.get('total_trades', 0) - - feedback = ( - f"Score: {score:.1f}/100 | Win Rate: {win_rate:.1f}% | " - f"Profit Factor: {pf:.2f} | Max Drawdown: {dd:.1f}% | Trades: {trades}\n" - ) - if trades < 5: - feedback += "WARNING: Too few trades — parameters may be too restrictive.\n" - if float(dd) > 25: - feedback += "WARNING: Excessive drawdown — reduce risk exposure.\n" - if score > 60: - feedback += "GOOD: Above threshold. Try fine-tuning for higher profit factor.\n" - - return EvaluationResult( - metrics={'performance': score, 'drawdown': abs(float(dd))}, - artifacts={'llm_feedback': feedback}, - ) - - except json.JSONDecodeError as e: - return EvaluationResult( - metrics={'performance': -1.0, 'drawdown': 100.0}, - artifacts={'stderr': f'INVALID JSON: {e}. You MUST return valid JSON with the same structure.'}, - ) - except RuntimeError: - raise # Re-raise cancellation - except Exception as e: - logger.warning('optimizer_evaluator_error: %s', str(e)[:200]) - return EvaluationResult( - metrics={'performance': 0.0, 'drawdown': 100.0}, - artifacts={'stderr': f'Backtest error: {str(e)[:200]}'}, - ) + # 3. Write standalone evaluator script to disk + settings = get_settings() + evaluator_code = _build_evaluator_script( + template=strategy.template, + symbol=strategy.symbol, + timeframe=strategy.timeframe, + db_url=settings.database_url, + ) + evaluator_file_path = os.path.join(output_dir, 'evaluator.py') + with open(evaluator_file_path, 'w') as f: + f.write(evaluator_code) - # 3. Build OpenEvolve Config object (programmatic — not YAML) + # 4. Build OpenEvolve Config from openevolve.config import Config as OEConfig, LLMModelConfig + bounds = get_bounds_for_template(strategy.template) bounds_description = '\n'.join( f' - {k}: min={lo}, max={hi}' for k, (lo, hi) in bounds.items() ) @@ -460,12 +479,17 @@ def openevolve_evaluator(file_path: str) -> 'EvaluationResult': ) # Ensure base_url ends with /v1 for OpenAI-compatible providers - llm_base_url = base_url.rstrip('/') + llm_base_url = (base_url or '').rstrip('/') if not llm_base_url.endswith('/v1'): llm_base_url += '/v1' oe_config = OEConfig() oe_config.max_iterations = max_iterations + oe_config.diff_based_evolution = False # JSON, not code — disable diff mode + oe_config.language = 'json' + oe_config.database.num_islands = 1 # Simpler for trading optimisation + oe_config.database.in_memory = True + oe_config.llm = OEConfig().llm # fresh LLMConfig to avoid __post_init__ issues oe_config.llm.models = [ LLMModelConfig( name=model_name, @@ -476,38 +500,41 @@ def openevolve_evaluator(file_path: str) -> 'EvaluationResult': ), ] - # 4. Run OpenEvolve - output_dir = tempfile.mkdtemp(prefix='openevolve_strategy_') - + # 5. Run OpenEvolve (async → sync bridge) try: - # Evaluate initial params first - initial_eval = evaluator_direct( - strategy.template, strategy.symbol, strategy.timeframe, - dict(strategy.params or {}), - ) - campaign.initial_score = initial_eval['score'] - campaign.best_params = dict(strategy.params or {}) - campaign.best_score = initial_eval['score'] - campaign.best_metrics = initial_eval['metrics'] - db.commit() + from openevolve.controller import OpenEvolve as OEController - # Run OpenEvolve - result = run_evolution( - initial_program=initial_program, - evaluator=openevolve_evaluator, - iterations=max_iterations, + oe = OEController( + initial_program_path=initial_program_path, + evaluation_file=evaluator_file_path, config=oe_config, output_dir=output_dir, ) - # Parse the best result - if result and hasattr(result, 'best_code') and result.best_code: + # Run the async evolution loop from a sync context. + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + # Already inside an event loop (e.g. Celery with gevent/eventlet). + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + result = pool.submit( + asyncio.run, oe.run(iterations=max_iterations), + ).result() + else: + result = asyncio.run(oe.run(iterations=max_iterations)) + + # 6. Parse the best result + if result and hasattr(result, 'code') and result.code: try: - best_data = json.loads(result.best_code) + best_data = json.loads(result.code) best_params = best_data.get('params', best_data) clamped_best = clamp_params(strategy.template, best_params) - # Final evaluation to confirm + # 7. Final confirmation evaluation final_eval = evaluator_direct( strategy.template, strategy.symbol, strategy.timeframe, clamped_best, ) @@ -516,7 +543,13 @@ def openevolve_evaluator(file_path: str) -> 'EvaluationResult': campaign.best_score = final_eval['score'] campaign.best_metrics = final_eval['metrics'] except (json.JSONDecodeError, KeyError): - pass # Keep whatever best was found during evaluations + pass # Keep whatever best was found during initial eval + + # 8. Update campaign tracking from OpenEvolve result + if result and hasattr(result, 'iteration_found'): + campaign.current_iteration = result.iteration_found + else: + campaign.current_iteration = max_iterations campaign.status = 'COMPLETED' except Exception as exc: @@ -524,7 +557,7 @@ def openevolve_evaluator(file_path: str) -> 'EvaluationResult': 'openevolve_run_failed campaign_id=%s: %s', campaign_id, str(exc)[:300], exc_info=True, ) - # Fallback: if OpenEvolve fails, the best found during evaluations is still valid + # Fallback: if OpenEvolve fails, the initial eval is still valid if campaign.best_score and campaign.best_score > (campaign.initial_score or 0.0): campaign.status = 'COMPLETED' else: @@ -533,7 +566,7 @@ def openevolve_evaluator(file_path: str) -> 'EvaluationResult': finally: campaign.completed_at = datetime.now(timezone.utc) db.commit() - # Cleanup temp dir + # 9. Cleanup temp files shutil.rmtree(output_dir, ignore_errors=True) From 6e75439f0d9ca4ff4cabae34b3f09e82c5df0c89 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Sun, 21 Jun 2026 22:23:46 +0200 Subject: [PATCH 16/21] fix(strategy-optimizer): add cancel sentinel file so OpenEvolve subprocess stops on user cancel --- .../services/strategy/optimizer_service.py | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/backend/app/services/strategy/optimizer_service.py b/backend/app/services/strategy/optimizer_service.py index aae84fb..6e424e2 100644 --- a/backend/app/services/strategy/optimizer_service.py +++ b/backend/app/services/strategy/optimizer_service.py @@ -302,6 +302,7 @@ def _build_evaluator_script( symbol: str, timeframe: str, db_url: str, + cancel_file: str, ) -> str: """Build a standalone Python evaluator script for OpenEvolve subprocess. @@ -319,11 +320,17 @@ def _build_evaluator_script( sys.path.insert(0, '/app') os.environ.setdefault('DATABASE_URL', {db_url!r}) +_CANCEL_FILE = {cancel_file!r} + def evaluate(file_path: str): """Evaluate a candidate JSON program via back-test.""" from openevolve.evaluation_result import EvaluationResult + # Check cancellation sentinel file + if os.path.exists(_CANCEL_FILE): + raise RuntimeError('Campaign cancelled by user') + try: with open(file_path, 'r') as f: content = f.read() @@ -445,11 +452,13 @@ def _run_openevolve_loop( # 3. Write standalone evaluator script to disk settings = get_settings() + cancel_file = os.path.join(output_dir, '.cancel') evaluator_code = _build_evaluator_script( template=strategy.template, symbol=strategy.symbol, timeframe=strategy.timeframe, db_url=settings.database_url, + cancel_file=cancel_file, ) evaluator_file_path = os.path.join(output_dir, 'evaluator.py') with open(evaluator_file_path, 'w') as f: @@ -500,7 +509,28 @@ def _run_openevolve_loop( ), ] - # 5. Run OpenEvolve (async → sync bridge) + # 5. Start cancel-watcher thread (polls DB every 3s, writes sentinel file) + import threading + + def _cancel_watcher() -> None: + from app.db.session import SessionLocal + while not os.path.exists(cancel_file): + try: + with SessionLocal() as check_db: + row = check_db.get(StrategyOptimizerCampaign, campaign_id) + if row and row.status == 'CANCELLED': + with open(cancel_file, 'w') as cf: + cf.write('cancelled') + logger.info('cancel_watcher: sentinel written for campaign %s', campaign_id) + return + except Exception: + pass + time.sleep(3) + + cancel_thread = threading.Thread(target=_cancel_watcher, daemon=True) + cancel_thread.start() + + # 6. Run OpenEvolve (async → sync bridge) try: from openevolve.controller import OpenEvolve as OEController From 50c515a97e47f4890f44941e2726fd4c0bfd6cfd Mon Sep 17 00:00:00 2001 From: simodev25 Date: Mon, 22 Jun 2026 22:27:35 +0200 Subject: [PATCH 17/21] fix(strategy): cascade delete optimizer evaluations and campaigns on strategy delete --- backend/app/api/routes/strategies.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/app/api/routes/strategies.py b/backend/app/api/routes/strategies.py index 74e68fe..c6a371e 100644 --- a/backend/app/api/routes/strategies.py +++ b/backend/app/api/routes/strategies.py @@ -392,6 +392,21 @@ def delete_strategy( strategy = db.get(Strategy, strategy_id) if not strategy: raise HTTPException(status_code=404, detail='Strategy not found') + # Delete related optimizer data (FK constraints) + from app.db.models.strategy_optimizer_campaign import StrategyOptimizerCampaign + from app.db.models.strategy_optimizer_evaluation import StrategyOptimizerEvaluation + campaign_ids = [ + c.id for c in db.query(StrategyOptimizerCampaign.id).filter( + StrategyOptimizerCampaign.strategy_id == strategy.id, + ).all() + ] + if campaign_ids: + db.query(StrategyOptimizerEvaluation).filter( + StrategyOptimizerEvaluation.campaign_id.in_(campaign_ids), + ).delete(synchronize_session=False) + db.query(StrategyOptimizerCampaign).filter( + StrategyOptimizerCampaign.id.in_(campaign_ids), + ).delete(synchronize_session=False) db.delete(strategy) db.commit() logger.info('strategy_deleted id=%s name=%s', strategy.strategy_id, strategy.name) From 22f4675a8b7feb3c89a2f5df73a311e627c57f1b Mon Sep 17 00:00:00 2001 From: simodev25 Date: Wed, 24 Jun 2026 15:00:59 +0200 Subject: [PATCH 18/21] feat(strategy-optimizer): allow optimization on STRATEGY_DISCARDED strategies --- frontend/src/components/OptimizerPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/OptimizerPanel.tsx b/frontend/src/components/OptimizerPanel.tsx index 24f07a6..799f60d 100644 --- a/frontend/src/components/OptimizerPanel.tsx +++ b/frontend/src/components/OptimizerPanel.tsx @@ -123,7 +123,7 @@ export function OptimizerPanel({ strategyId, strategyStatus, token, onStrategyUp } }; - const canLaunch = strategyStatus === 'VALIDATED' && (!campaign || !['RUNNING', 'PENDING'].includes(campaign.status)); + const canLaunch = ['VALIDATED', 'STRATEGY_DISCARDED'].includes(strategyStatus) && (!campaign || !['RUNNING', 'PENDING'].includes(campaign.status)); const isActive = campaign && ['RUNNING', 'PENDING'].includes(campaign.status); const isCompleted = campaign?.status === 'COMPLETED'; const progressPct = campaign && campaign.max_iterations > 0 From 6378b64d2e32a9eccff7a358b1ce8ca44fda4df2 Mon Sep 17 00:00:00 2001 From: simodev25 Date: Wed, 24 Jun 2026 15:21:13 +0200 Subject: [PATCH 19/21] fix(strategy-optimizer): show OptimizerPanel for STRATEGY_DISCARDED status in page guard --- frontend/src/pages/StrategiesPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/StrategiesPage.tsx b/frontend/src/pages/StrategiesPage.tsx index 1284236..e2937ca 100644 --- a/frontend/src/pages/StrategiesPage.tsx +++ b/frontend/src/pages/StrategiesPage.tsx @@ -192,8 +192,8 @@ function StrategyCard({ )} - {/* Optimizer Panel for VALIDATED strategies */} - {strategy.status === 'VALIDATED' && token && ( + {/* Optimizer Panel for VALIDATED / STRATEGY_DISCARDED strategies */} + {['VALIDATED', 'STRATEGY_DISCARDED'].includes(strategy.status) && token && (
Date: Wed, 24 Jun 2026 15:28:37 +0200 Subject: [PATCH 20/21] fix(strategy-optimizer): use correct DB status REJECTED (not STRATEGY_DISCARDED display label) --- frontend/src/components/OptimizerPanel.tsx | 2 +- frontend/src/pages/StrategiesPage.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/OptimizerPanel.tsx b/frontend/src/components/OptimizerPanel.tsx index 799f60d..d0c4c2f 100644 --- a/frontend/src/components/OptimizerPanel.tsx +++ b/frontend/src/components/OptimizerPanel.tsx @@ -123,7 +123,7 @@ export function OptimizerPanel({ strategyId, strategyStatus, token, onStrategyUp } }; - const canLaunch = ['VALIDATED', 'STRATEGY_DISCARDED'].includes(strategyStatus) && (!campaign || !['RUNNING', 'PENDING'].includes(campaign.status)); + const canLaunch = ['VALIDATED', 'REJECTED'].includes(strategyStatus) && (!campaign || !['RUNNING', 'PENDING'].includes(campaign.status)); const isActive = campaign && ['RUNNING', 'PENDING'].includes(campaign.status); const isCompleted = campaign?.status === 'COMPLETED'; const progressPct = campaign && campaign.max_iterations > 0 diff --git a/frontend/src/pages/StrategiesPage.tsx b/frontend/src/pages/StrategiesPage.tsx index e2937ca..b2c97be 100644 --- a/frontend/src/pages/StrategiesPage.tsx +++ b/frontend/src/pages/StrategiesPage.tsx @@ -192,8 +192,8 @@ function StrategyCard({ )}
- {/* Optimizer Panel for VALIDATED / STRATEGY_DISCARDED strategies */} - {['VALIDATED', 'STRATEGY_DISCARDED'].includes(strategy.status) && token && ( + {/* Optimizer Panel for VALIDATED / REJECTED strategies */} + {['VALIDATED', 'REJECTED'].includes(strategy.status) && token && (
Date: Wed, 24 Jun 2026 15:31:22 +0200 Subject: [PATCH 21/21] fix(strategy-optimizer): allow optimization on REJECTED strategies (backend guard) --- backend/app/api/routes/strategies.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/api/routes/strategies.py b/backend/app/api/routes/strategies.py index c6a371e..828d3d4 100644 --- a/backend/app/api/routes/strategies.py +++ b/backend/app/api/routes/strategies.py @@ -895,8 +895,8 @@ def start_optimization( strategy = db.get(Strategy, strategy_id) if not strategy: raise HTTPException(status_code=404, detail='Strategy not found') - if strategy.status != 'VALIDATED': - raise HTTPException(status_code=422, detail=f'Strategy must be VALIDATED (current: {strategy.status})') + if strategy.status not in ('VALIDATED', 'REJECTED'): + raise HTTPException(status_code=422, detail=f'Strategy must be VALIDATED or REJECTED (current: {strategy.status})') config: dict = {} if payload.max_iterations is not None: