Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a2a13c6
feat(strategy-optimizer): add DB models and migration for optimizer c…
simodev25 Jun 21, 2026
91839c6
feat(strategy-optimizer): implement optimizer service with evolutiona…
simodev25 Jun 21, 2026
40ce1fc
feat(strategy-optimizer): add Celery task on dedicated queue
simodev25 Jun 21, 2026
466e3c0
feat(strategy-optimizer): add 5 REST API endpoints for campaign lifec…
simodev25 Jun 21, 2026
71cffb5
feat(strategy-optimizer): add frontend OptimizerPanel component
simodev25 Jun 21, 2026
fecbe22
test(strategy-optimizer): add unit tests for service, adapter, and API
simodev25 Jun 21, 2026
418e542
feat(strategy-optimizer): integrate real OpenEvolve with LLM-driven m…
simodev25 Jun 21, 2026
12c28a7
fix(strategy-optimizer): wire OpenEvolve LLM config to project provid…
simodev25 Jun 21, 2026
fbfd830
fix(strategy-optimizer): add strategy-optimizer queue to worker defaults
simodev25 Jun 21, 2026
6e4975f
fix(strategy-optimizer): use correct OpenEvolve arg name config= inst…
simodev25 Jun 21, 2026
9d3645f
fix(strategy-optimizer): handle naive datetime in elapsed_seconds cal…
simodev25 Jun 21, 2026
a97ec7e
test(strategy-optimizer): add unit tests and E2E tests for optimizer …
simodev25 Jun 21, 2026
13e42c9
fix(strategy-optimizer): use OpenEvolve Config object with LLMModelCo…
simodev25 Jun 21, 2026
41287fd
fix(strategy-optimizer): add dedicated solo-pool worker for OpenEvolv…
simodev25 Jun 21, 2026
0f2a17f
fix(strategy-optimizer): rewrite evaluator as standalone file for Ope…
simodev25 Jun 21, 2026
6e75439
fix(strategy-optimizer): add cancel sentinel file so OpenEvolve subpr…
simodev25 Jun 21, 2026
50c515a
fix(strategy): cascade delete optimizer evaluations and campaigns on …
simodev25 Jun 22, 2026
22f4675
feat(strategy-optimizer): allow optimization on STRATEGY_DISCARDED st…
simodev25 Jun 24, 2026
6378b64
fix(strategy-optimizer): show OptimizerPanel for STRATEGY_DISCARDED s…
simodev25 Jun 24, 2026
109d813
fix(strategy-optimizer): use correct DB status REJECTED (not STRATEGY…
simodev25 Jun 24, 2026
f71c725
fix(strategy-optimizer): allow optimization on REJECTED strategies (b…
simodev25 Jun 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions backend/alembic/versions/0015_strategy_optimizer_tables.py
Original file line number Diff line number Diff line change
@@ -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')
144 changes: 144 additions & 0 deletions backend/app/api/routes/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -391,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)
Expand Down Expand Up @@ -860,3 +876,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 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:
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)
8 changes: 8 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
4 changes: 4 additions & 0 deletions backend/app/db/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -44,6 +46,8 @@
'PortfolioSnapshot',
'LlmCallLog',
'Strategy',
'StrategyOptimizerCampaign',
'StrategyOptimizerEvaluation',
'TradingConfigVersion',
'GovernanceRun',
]
35 changes: 35 additions & 0 deletions backend/app/db/models/strategy_optimizer_campaign.py
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 22 additions & 0 deletions backend/app/db/models/strategy_optimizer_evaluation.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading