From 337f84bc876ea2a3504d903b368cbc1e5bec04ab Mon Sep 17 00:00:00 2001 From: TPTBusiness Date: Thu, 9 Apr 2026 08:28:35 +0200 Subject: [PATCH] feat: Strategy Worker module with 41 tests (P1 complete) Created rdagent/scenarios/qlib/local/strategy_worker.py (closed source): - LLMStrategyGenerator: llama.cpp API calls with retry - BacktestEngine: isolated subprocess with risk management - AcceptanceGate: FTMO-compliant validation - StrategySaver: JSON + metadata persistence - StrategyWorker: full workflow orchestration 41 tests passing in test/local/test_strategy_worker.py FTMO rules enforced: SL 2%, max DD 10%, daily loss 5% --- QWEN.md | 5 + test/local/test_strategy_worker.py | 868 +++++++++++++++++++++++++++++ 2 files changed, 873 insertions(+) create mode 100644 test/local/test_strategy_worker.py diff --git a/QWEN.md b/QWEN.md index 58cdadc1..bf277151 100644 --- a/QWEN.md +++ b/QWEN.md @@ -37,6 +37,9 @@ Predix/ │ │ └── model_loader.py # Model loader (auto-loads local models) │ └── scenarios/ │ └── qlib/ # Qlib integration for FX trading +│ └── local/ # Closed source components (NOT in Git!) +│ ├── data_loader.py # OHLCV & factor data loader +│ └── strategy_worker.py # LLM strategy generation + backtest ├── predix.py # Main CLI wrapper (predix.py commands) ├── predix_parallel.py # Parallel factor evolution ├── predix_gen_strategies_real_bt.py # AI Strategy Gen + REAL OHLCV Backtest @@ -733,6 +736,8 @@ report = risk_manager.generate_risk_report(returns, weights) - ✅ Risk Management (Correlation, Portfolio Optimization) - ✅ Trading Protection System (Drawdown, Cooldown, Stoploss Guard, Low Performance) - ✅ RL Trading Agent (PPO/A2C/SAC with Gymnasium environment + fallback) +- ✅ Strategy Worker (LLM strategy generation + FTMO-compliant backtesting) +- ✅ Data Loader (OHLCV + factor data loading with caching) - ✅ Dashboards (Web + CLI) - ✅ CLI Commands (`fin_quant`, `rl_trading`, `health_check`, etc.) - ✅ Integration Tests (200+ tests, run before EVERY commit) diff --git a/test/local/test_strategy_worker.py b/test/local/test_strategy_worker.py new file mode 100644 index 00000000..3d467910 --- /dev/null +++ b/test/local/test_strategy_worker.py @@ -0,0 +1,868 @@ +""" +Tests for Strategy Worker (LLMStrategyGenerator, BacktestEngine, AcceptanceGate, StrategySaver). + +Public test file - references closed-source module at rdagent/scenarios/qlib/local/strategy_worker.py +""" + +import os +import json +import time +import tempfile +import subprocess +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock + +import pytest +import numpy as np +import pandas as pd + +from rdagent.scenarios.qlib.local.strategy_worker import ( + LLMStrategyGenerator, + BacktestEngine, + AcceptanceGate, + StrategySaver, + StrategyWorker, +) + + +# ============================================================================= +# Fixtures +# ============================================================================= + +@pytest.fixture +def sample_factors(): + """Sample factor metadata list.""" + return [ + {'name': 'momentum_1d', 'ic': 0.15, 'description': '1-day momentum'}, + {'name': 'mean_reversion', 'ic': -0.12, 'description': 'Mean reversion signal'}, + {'name': 'volatility', 'ic': 0.08, 'description': 'Volatility indicator'}, + ] + + +@pytest.fixture +def sample_close(): + """Sample OHLCV close price series.""" + dates = pd.date_range('2024-01-01', periods=1000, freq='1min') + np.random.seed(42) + prices = 1.0850 + np.cumsum(np.random.randn(1000) * 0.0001) + return pd.Series(prices, index=dates, name='$close') + + +@pytest.fixture +def sample_factors_df(sample_close): + """Sample factor values DataFrame.""" + np.random.seed(42) + return pd.DataFrame({ + 'momentum_1d': np.random.randn(len(sample_close)), + 'mean_reversion': np.random.randn(len(sample_close)), + 'volatility': np.random.randn(len(sample_close)), + }, index=sample_close.index) + + +@pytest.fixture +def llm_generator(): + """LLM Strategy Generator instance.""" + return LLMStrategyGenerator() + + +@pytest.fixture +def backtest_engine(): + """Backtest Engine instance.""" + return BacktestEngine(timeout=60) + + +@pytest.fixture +def acceptance_gate(): + """Acceptance Gate instance.""" + return AcceptanceGate() + + +@pytest.fixture +def strategy_saver(tmp_path): + """Strategy Saver instance with temp directory.""" + return StrategySaver(output_dir=str(tmp_path)) + + +# ============================================================================= +# LLMStrategyGenerator Tests +# ============================================================================= + +class TestLLMStrategyGenerator: + """Test LLM strategy generation.""" + + def test_init_defaults(self): + """Test initialization with defaults.""" + gen = LLMStrategyGenerator() + assert gen.llm_url == 'http://localhost:8081/v1/chat/completions' + assert gen.model_name == 'qwen3.5-35b' + assert gen.timeout == 120 + assert gen.max_tokens == 4096 + assert gen.temperature == 0.5 + + def test_init_custom(self): + """Test initialization with custom parameters.""" + gen = LLMStrategyGenerator( + llm_url='http://custom:9999/v1', + model_name='custom-model', + timeout=60, + max_tokens=2048, + temperature=0.8, + ) + assert gen.llm_url == 'http://custom:9999/v1' + assert gen.model_name == 'custom-model' + assert gen.timeout == 60 + assert gen.max_tokens == 2048 + assert gen.temperature == 0.8 + + @patch('rdagent.scenarios.qlib.local.strategy_worker.requests.post') + def test_generate_strategy_success(self, mock_post, llm_generator, sample_factors, sample_close): + """Test successful strategy generation.""" + # Mock LLM response + mock_response = Mock() + mock_response.json.return_value = { + 'choices': [{ + 'message': { + 'content': '''Here is the strategy: + +```python +def generate_signal(factors, close): + import pandas as pd + import numpy as np + signal = pd.Series(0, index=close.index) + signal[factors['momentum_1d'] > 0.5] = 1 + signal[factors['momentum_1d'] < -0.5] = -1 + return signal +``` +''' + } + }] + } + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + result = llm_generator.generate_strategy( + factors=sample_factors, + close=sample_close, + ) + + assert result['success'] is True + assert 'def generate_signal' in result['code'] + assert result['error'] is None + assert len(result['factor_names']) == 3 + assert result['attempt_time'] > 0 + + @patch('rdagent.scenarios.qlib.local.strategy_worker.requests.post') + def test_generate_strategy_no_code_block(self, mock_post, llm_generator, sample_factors, sample_close): + """Test failure when no code block found.""" + mock_response = Mock() + mock_response.json.return_value = { + 'choices': [{ + 'message': { + 'content': 'This is just text, no code.' + } + }] + } + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + result = llm_generator.generate_strategy( + factors=sample_factors, + close=sample_close, + ) + + assert result['success'] is False + assert 'No Python code block found' in result['error'] + + @patch('rdagent.scenarios.qlib.local.strategy_worker.requests.post') + def test_generate_strategy_syntax_error(self, mock_post, llm_generator, sample_factors, sample_close): + """Test failure when generated code has syntax errors.""" + mock_response = Mock() + mock_response.json.return_value = { + 'choices': [{ + 'message': { + 'content': '''```python +def generate_signal(factors, close): + import pandas as pd + if True + print("missing colon") +```''' + } + }] + } + mock_response.raise_for_status = Mock() + mock_post.return_value = mock_response + + result = llm_generator.generate_strategy( + factors=sample_factors, + close=sample_close, + ) + + assert result['success'] is False + assert 'syntax errors' in result['error'] + + @patch('rdagent.scenarios.qlib.local.strategy_worker.requests.post') + def test_generate_strategy_timeout(self, mock_post, llm_generator, sample_factors, sample_close): + """Test timeout handling.""" + import requests + mock_post.side_effect = requests.Timeout('Request timed out') + + result = llm_generator.generate_strategy( + factors=sample_factors, + close=sample_close, + ) + + assert result['success'] is False + assert 'Timeout' in result['error'] + + @patch('rdagent.scenarios.qlib.local.strategy_worker.requests.post') + def test_generate_strategy_connection_error(self, mock_post, llm_generator, sample_factors, sample_close): + """Test connection error handling.""" + import requests + mock_post.side_effect = requests.ConnectionError('Connection refused') + + result = llm_generator.generate_strategy( + factors=sample_factors, + close=sample_close, + ) + + assert result['success'] is False + assert 'Connection failed' in result['error'] + + def test_extract_code_python_block(self): + """Test extracting code from ```python block.""" + gen = LLMStrategyGenerator() + response = '''Some text + +```python +def my_func(): + pass +``` + +More text''' + + code = gen._extract_code(response) + assert code == 'def my_func():\n pass' + + def test_extract_code_plain_block(self): + """Test extracting code from ``` block.""" + gen = LLMStrategyGenerator() + response = '''``` +def my_func(): + pass +```''' + + code = gen._extract_code(response) + assert code == 'def my_func():\n pass' + + def test_extract_code_no_block(self): + """Test when no code block present.""" + gen = LLMStrategyGenerator() + response = 'Just plain text, no code.' + + code = gen._extract_code(response) + assert code is None + + def test_validate_code_valid(self): + """Test validation of valid code.""" + gen = LLMStrategyGenerator() + assert gen._validate_code('def foo(): pass') is True + + def test_validate_code_invalid(self): + """Test validation of invalid code.""" + gen = LLMStrategyGenerator() + assert gen._validate_code('def foo(\n') is False + + @patch.object(LLMStrategyGenerator, 'generate_strategy') + def test_generate_with_retry_success(self, mock_gen, llm_generator, sample_factors, sample_close): + """Test successful generation with retry logic.""" + mock_gen.return_value = { + 'success': True, + 'code': 'def generate_signal(f, c): pass', + 'factor_names': ['f1'], + 'llm_response': 'response', + 'error': None, + 'attempt_time': 1.5, + } + + result = llm_generator.generate_with_retry( + factors=sample_factors, + close=sample_close, + max_retries=3, + ) + + assert result['success'] is True + assert mock_gen.call_count == 1 + + @patch.object(LLMStrategyGenerator, 'generate_strategy') + def test_generate_with_retry_all_fail(self, mock_gen, llm_generator, sample_factors, sample_close): + """Test all retries failing.""" + mock_gen.return_value = { + 'success': False, + 'code': '', + 'factor_names': ['f1'], + 'llm_response': '', + 'error': 'Test error', + 'attempt_time': 1.0, + } + + result = llm_generator.generate_with_retry( + factors=sample_factors, + close=sample_close, + max_retries=3, + ) + + assert result['success'] is False + assert mock_gen.call_count == 3 + + def test_format_factors(self, llm_generator, sample_factors): + """Test factor formatting for LLM prompt.""" + text = llm_generator._format_factors(sample_factors) + assert 'momentum_1d' in text + assert 'IC=0.1500' in text + assert 'Mean reversion signal' in text + + +# ============================================================================= +# BacktestEngine Tests +# ============================================================================= + +class TestBacktestEngine: + """Test backtest engine.""" + + def test_init_defaults(self): + """Test initialization with defaults.""" + engine = BacktestEngine() + assert engine.stop_loss == 0.02 + assert engine.take_profit == 0.04 + assert engine.trail_activation == 0.015 + assert engine.trailing_stop == 0.015 + assert engine.transaction_cost == 0.00015 + assert engine.timeout == 300 + + def test_init_custom(self): + """Test initialization with custom parameters.""" + engine = BacktestEngine( + stop_loss=0.01, + take_profit=0.03, + trail_activation=0.01, + trailing_stop=0.01, + transaction_cost=0.0001, + timeout=120, + ) + assert engine.stop_loss == 0.01 + assert engine.take_profit == 0.03 + assert engine.trail_activation == 0.01 + assert engine.trailing_stop == 0.01 + assert engine.transaction_cost == 0.0001 + assert engine.timeout == 120 + + def test_run_backtest_valid_strategy(self, backtest_engine, sample_factors_df, sample_close): + """Test backtest with valid strategy.""" + strategy_code = ''' +def generate_signal(factors, close): + import pandas as pd + import numpy as np + signal = pd.Series(0, index=close.index) + signal[factors['momentum_1d'] > 0.5] = 1 + signal[factors['momentum_1d'] < -0.5] = -1 + return signal +''' + + result = backtest_engine.run_backtest( + strategy_code=strategy_code, + factors=sample_factors_df, + close=sample_close, + ) + + assert result['success'] is True + assert 'total_trades' in result + assert 'sharpe_ratio' in result + assert 'max_drawdown' in result + assert isinstance(result['total_trades'], int) + + def test_run_backtest_invalid_strategy(self, backtest_engine, sample_factors_df, sample_close): + """Test backtest with strategy that raises error.""" + strategy_code = ''' +def generate_signal(factors, close): + raise ValueError("Test error") +''' + + result = backtest_engine.run_backtest( + strategy_code=strategy_code, + factors=sample_factors_df, + close=sample_close, + ) + + assert result['success'] is False + assert 'error' in result + + @patch('rdagent.scenarios.qlib.local.strategy_worker.subprocess.run') + def test_run_subprocess_timeout(self, mock_run, backtest_engine): + """Test subprocess timeout handling.""" + import subprocess + mock_run.side_effect = subprocess.TimeoutExpired(cmd='python', timeout=60) + + result = backtest_engine._run_subprocess(Path('/tmp/run.py')) + + assert result['success'] is False + assert 'Timeout' in result['error'] + + @patch('rdagent.scenarios.qlib.local.strategy_worker.subprocess.run') + def test_run_subprocess_success(self, mock_run, backtest_engine): + """Test successful subprocess execution.""" + mock_proc = Mock() + mock_proc.returncode = 0 + mock_proc.stdout = json.dumps({ + 'ic': 0.05, + 'sharpe_ratio': 1.2, + 'max_drawdown': -0.08, + 'win_rate': 0.55, + 'total_trades': 25, + 'wins': 14, + 'losses': 11, + 'total_return': 0.05, + 'final_equity': 1.05, + 'avg_trade_pnl': 0.002, + 'sl_pct': 0.02, + 'tp_pct': 0.04, + 'trail_activation': 0.015, + 'trail_stop': 0.015, + 'transaction_cost': 0.00015, + }) + mock_run.return_value = mock_proc + + result = backtest_engine._run_subprocess(Path('/tmp/run.py')) + + assert result['success'] is True + assert result['sharpe_ratio'] == 1.2 + assert result['total_trades'] == 25 + + +# ============================================================================= +# AcceptanceGate Tests +# ============================================================================= + +class TestAcceptanceGate: + """Test acceptance gate.""" + + def test_init_defaults(self): + """Test initialization with defaults.""" + gate = AcceptanceGate() + assert gate.min_ic == 0.02 + assert gate.min_sharpe == 0.5 + assert gate.min_trades == 10 + assert gate.max_drawdown == -0.15 + assert gate.ftmo_max_sl == 0.02 + assert gate.ftmo_max_daily_loss == 0.05 + assert gate.ftmo_max_dd == 0.10 + + def test_evaluate_passing_strategy(self, acceptance_gate): + """Test evaluation of passing strategy.""" + result = { + 'ic': 0.05, + 'sharpe_ratio': 1.2, + 'max_drawdown': -0.08, + 'total_trades': 25, + 'sl_pct': 0.02, + } + + evaluation = acceptance_gate.evaluate(result) + + assert evaluation['passed'] is True + assert len(evaluation['reasons']) == 0 + assert evaluation['checks']['ic']['passed'] is True + assert evaluation['checks']['sharpe']['passed'] is True + assert evaluation['checks']['trades']['passed'] is True + assert evaluation['checks']['max_drawdown']['passed'] is True + assert evaluation['checks']['ftmo_sl']['passed'] is True + assert evaluation['checks']['ftmo_max_dd']['passed'] is True + + def test_evaluate_failing_ic(self, acceptance_gate): + """Test failure due to low IC.""" + result = { + 'ic': 0.01, + 'sharpe_ratio': 1.2, + 'max_drawdown': -0.08, + 'total_trades': 25, + 'sl_pct': 0.02, + } + + evaluation = acceptance_gate.evaluate(result) + + assert evaluation['passed'] is False + assert any('IC' in r for r in evaluation['reasons']) + assert evaluation['checks']['ic']['passed'] is False + + def test_evaluate_failing_sharpe(self, acceptance_gate): + """Test failure due to low Sharpe.""" + result = { + 'ic': 0.05, + 'sharpe_ratio': 0.3, + 'max_drawdown': -0.08, + 'total_trades': 25, + 'sl_pct': 0.02, + } + + evaluation = acceptance_gate.evaluate(result) + + assert evaluation['passed'] is False + assert any('Sharpe' in r for r in evaluation['reasons']) + assert evaluation['checks']['sharpe']['passed'] is False + + def test_evaluate_failing_trades(self, acceptance_gate): + """Test failure due to insufficient trades.""" + result = { + 'ic': 0.05, + 'sharpe_ratio': 1.2, + 'max_drawdown': -0.08, + 'total_trades': 5, + 'sl_pct': 0.02, + } + + evaluation = acceptance_gate.evaluate(result) + + assert evaluation['passed'] is False + assert any('trades' in r.lower() for r in evaluation['reasons']) + assert evaluation['checks']['trades']['passed'] is False + + def test_evaluate_failing_drawdown(self, acceptance_gate): + """Test failure due to excessive drawdown.""" + result = { + 'ic': 0.05, + 'sharpe_ratio': 1.2, + 'max_drawdown': -0.20, + 'total_trades': 25, + 'sl_pct': 0.02, + } + + evaluation = acceptance_gate.evaluate(result) + + assert evaluation['passed'] is False + assert any('DD' in r or 'drawdown' in r.lower() for r in evaluation['reasons']) + assert evaluation['checks']['max_drawdown']['passed'] is False + assert evaluation['checks']['ftmo_max_dd']['passed'] is False + + def test_evaluate_failing_ftmo_sl(self, acceptance_gate): + """Test FTMO stop loss violation.""" + result = { + 'ic': 0.05, + 'sharpe_ratio': 1.2, + 'max_drawdown': -0.08, + 'total_trades': 25, + 'sl_pct': 0.03, + } + + evaluation = acceptance_gate.evaluate(result) + + assert evaluation['passed'] is False + assert evaluation['checks']['ftmo_sl']['passed'] is False + + def test_evaluate_ic_none(self, acceptance_gate): + """Test when IC is None.""" + result = { + 'ic': None, + 'sharpe_ratio': 1.2, + 'max_drawdown': -0.08, + 'total_trades': 25, + 'sl_pct': 0.02, + } + + evaluation = acceptance_gate.evaluate(result) + + assert evaluation['passed'] is False + assert any('IC is None' in r for r in evaluation['reasons']) + + +# ============================================================================= +# StrategySaver Tests +# ============================================================================= + +class TestStrategySaver: + """Test strategy saver.""" + + def test_init_default(self): + """Test initialization with defaults.""" + saver = StrategySaver() + assert 'results/strategies_new' in str(saver.output_dir) + + def test_init_custom(self, tmp_path): + """Test initialization with custom directory.""" + saver = StrategySaver(output_dir=str(tmp_path)) + assert saver.output_dir == tmp_path + + def test_save_strategy(self, strategy_saver): + """Test saving accepted strategy.""" + filepath = strategy_saver.save_strategy( + name='TestStrategy', + code='def generate_signal(f, c): pass', + factor_names=['f1', 'f2'], + backtest_result={ + 'ic': 0.05, + 'sharpe_ratio': 1.2, + 'max_drawdown': -0.08, + 'total_trades': 25, + 'sl_pct': 0.02, + 'tp_pct': 0.04, + }, + acceptance_result={ + 'passed': True, + 'checks': {}, + }, + ) + + assert filepath.exists() + assert filepath.suffix == '.json' + + # Verify content + with open(filepath) as f: + data = json.load(f) + + assert data['strategy_name'] == 'TestStrategy' + assert 'def generate_signal' in data['code'] + assert data['factor_names'] == ['f1', 'f2'] + assert data['metrics']['ic'] == 0.05 + assert data['metrics']['sharpe_ratio'] == 1.2 + assert 'risk_config' in data + assert 'acceptance_result' in data + + def test_save_strategy_with_metadata(self, strategy_saver): + """Test saving strategy with additional metadata.""" + filepath = strategy_saver.save_strategy( + name='TestStrategy', + code='def generate_signal(f, c): pass', + factor_names=['f1'], + backtest_result={ + 'ic': 0.05, + 'sharpe_ratio': 1.2, + 'max_drawdown': -0.08, + 'total_trades': 25, + 'sl_pct': 0.02, + 'tp_pct': 0.04, + }, + acceptance_result={ + 'passed': True, + 'checks': {}, + }, + metadata={'version': '1.0', 'author': 'Predix'}, + ) + + with open(filepath) as f: + data = json.load(f) + + assert data['metadata']['version'] == '1.0' + assert data['metadata']['author'] == 'Predix' + + def test_save_strategy_with_llm_response(self, strategy_saver): + """Test saving strategy with LLM response preview.""" + filepath = strategy_saver.save_strategy( + name='TestStrategy', + code='def generate_signal(f, c): pass', + factor_names=['f1'], + backtest_result={ + 'ic': 0.05, + 'sharpe_ratio': 1.2, + 'max_drawdown': -0.08, + 'total_trades': 25, + 'sl_pct': 0.02, + 'tp_pct': 0.04, + }, + acceptance_result={ + 'passed': True, + 'checks': {}, + }, + llm_response='This is a very long LLM response...', + ) + + with open(filepath) as f: + data = json.load(f) + + assert 'llm_response_preview' in data + assert len(data['llm_response_preview']) <= 1000 + + def test_filename_format(self, strategy_saver): + """Test filename format: timestamp_name.json.""" + filepath = strategy_saver.save_strategy( + name='My Strategy', + code='pass', + factor_names=['f1'], + backtest_result={'ic': 0.05, 'sharpe_ratio': 1.2, 'max_drawdown': -0.08, + 'total_trades': 25, 'sl_pct': 0.02, 'tp_pct': 0.04}, + acceptance_result={'passed': True, 'checks': {}}, + ) + + # Should match: {timestamp}_{name}.json + assert filepath.name.endswith('_My_Strategy.json') + assert filepath.name.split('_')[0].isdigit() + + +# ============================================================================= +# StrategyWorker Integration Tests +# ============================================================================= + +class TestStrategyWorker: + """Test Strategy Worker integration.""" + + def test_init_defaults(self): + """Test initialization with defaults.""" + worker = StrategyWorker() + assert isinstance(worker.llm_generator, LLMStrategyGenerator) + assert isinstance(worker.backtest_engine, BacktestEngine) + assert isinstance(worker.acceptance_gate, AcceptanceGate) + assert isinstance(worker.strategy_saver, StrategySaver) + + def test_init_custom(self): + """Test initialization with custom components.""" + custom_gen = Mock() + custom_bt = Mock() + custom_gate = Mock() + custom_saver = Mock() + + worker = StrategyWorker( + llm_generator=custom_gen, + backtest_engine=custom_bt, + acceptance_gate=custom_gate, + strategy_saver=custom_saver, + ) + + assert worker.llm_generator == custom_gen + assert worker.backtest_engine == custom_bt + assert worker.acceptance_gate == custom_gate + assert worker.strategy_saver == custom_saver + + @patch.object(StrategyWorker, '__init__', lambda self: None) + def test_workflow_generation_failure(self, sample_factors, sample_factors_df, sample_close): + """Test workflow when generation fails.""" + worker = StrategyWorker() + worker.llm_generator = Mock() + worker.llm_generator.generate_with_retry.return_value = { + 'success': False, + 'error': 'LLM error', + 'code': '', + } + + result = worker.run_workflow( + factors=sample_factors, + factor_data=sample_factors_df, + close=sample_close, + strategy_name='TestStrategy', + ) + + assert result['success'] is False + assert result['stage'] == 'generation' + assert result['error'] == 'LLM error' + + @patch.object(StrategyWorker, '__init__', lambda self: None) + def test_workflow_backtest_failure(self, sample_factors, sample_factors_df, sample_close): + """Test workflow when backtest fails.""" + worker = StrategyWorker() + worker.llm_generator = Mock() + worker.llm_generator.generate_with_retry.return_value = { + 'success': True, + 'code': 'def generate_signal(f, c): pass', + 'factor_names': ['f1'], + 'llm_response': 'response', + 'error': None, + 'attempt_time': 1.0, + } + worker.backtest_engine = Mock() + worker.backtest_engine.run_backtest.return_value = { + 'success': False, + 'error': 'Backtest error', + } + + result = worker.run_workflow( + factors=sample_factors, + factor_data=sample_factors_df, + close=sample_close, + strategy_name='TestStrategy', + ) + + assert result['success'] is False + assert result['stage'] == 'backtest' + assert result['error'] == 'Backtest error' + + @patch.object(StrategyWorker, '__init__', lambda self: None) + def test_workflow_acceptance_failure(self, sample_factors, sample_factors_df, sample_close): + """Test workflow when acceptance gate rejects.""" + worker = StrategyWorker() + worker.llm_generator = Mock() + worker.llm_generator.generate_with_retry.return_value = { + 'success': True, + 'code': 'def generate_signal(f, c): pass', + 'factor_names': ['f1'], + 'llm_response': 'response', + 'error': None, + 'attempt_time': 1.0, + } + worker.backtest_engine = Mock() + worker.backtest_engine.run_backtest.return_value = { + 'success': True, + 'ic': 0.01, + 'sharpe_ratio': 0.3, + 'max_drawdown': -0.08, + 'total_trades': 25, + 'sl_pct': 0.02, + } + worker.acceptance_gate = Mock() + worker.acceptance_gate.evaluate.return_value = { + 'passed': False, + 'reasons': ['IC too low', 'Sharpe too low'], + 'checks': {}, + } + + result = worker.run_workflow( + factors=sample_factors, + factor_data=sample_factors_df, + close=sample_close, + strategy_name='TestStrategy', + ) + + assert result['success'] is False + assert result['stage'] == 'acceptance' + assert 'IC too low' in result['rejection_reasons'] + + @patch.object(StrategyWorker, '__init__', lambda self: None) + def test_workflow_success(self, sample_factors, sample_factors_df, sample_close, tmp_path): + """Test successful workflow completion.""" + worker = StrategyWorker() + worker.llm_generator = Mock() + worker.llm_generator.generate_with_retry.return_value = { + 'success': True, + 'code': 'def generate_signal(f, c): pass', + 'factor_names': ['f1'], + 'llm_response': 'response', + 'error': None, + 'attempt_time': 1.0, + } + worker.backtest_engine = Mock() + worker.backtest_engine.run_backtest.return_value = { + 'success': True, + 'ic': 0.05, + 'sharpe_ratio': 1.2, + 'max_drawdown': -0.08, + 'total_trades': 25, + 'sl_pct': 0.02, + 'tp_pct': 0.04, + 'trail_activation': 0.015, + 'trail_stop': 0.015, + 'transaction_cost': 0.00015, + } + worker.acceptance_gate = Mock() + worker.acceptance_gate.evaluate.return_value = { + 'passed': True, + 'reasons': [], + 'checks': {}, + } + worker.strategy_saver = StrategySaver(output_dir=str(tmp_path)) + worker.strategy_saver.save_strategy = Mock(return_value=tmp_path / 'test.json') + + result = worker.run_workflow( + factors=sample_factors, + factor_data=sample_factors_df, + close=sample_close, + strategy_name='TestStrategy', + ) + + assert result['success'] is True + assert result['stage'] == 'complete' + worker.strategy_saver.save_strategy.assert_called_once()