diff --git a/test/local/test_continuous_strategies.py b/test/local/test_continuous_strategies.py new file mode 100644 index 00000000..96ded8f5 --- /dev/null +++ b/test/local/test_continuous_strategies.py @@ -0,0 +1,130 @@ +"""Deep tests for predix_continuous_strategies.py — ML model building, style cycling. + +Tests the build_ml_model function and the round/style alternation logic +without requiring real StrategyOrchestrator connections. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pandas as pd +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from hypothesis import given, settings, HealthCheck +from hypothesis import strategies as st + + +@pytest.fixture +def factor_data(): + """Create realistic factor data for ML training.""" + rng = np.random.default_rng(42) + n = 10000 + idx = pd.date_range("2020-01-01", periods=n, freq="1min") + return pd.DataFrame({ + "factor_a": rng.normal(0, 1, n), + "factor_b": rng.normal(0.1, 0.5, n), + "factor_c": rng.normal(-0.05, 0.3, n), + }, index=idx) + + +@pytest.fixture +def close_data(): + rng = np.random.default_rng(42) + n = 10000 + idx = pd.date_range("2020-01-01", periods=n, freq="1min") + return pd.Series(1.10 + rng.normal(0, 0.0001, n).cumsum(), index=idx) + + +class TestBuildMLModel: + def test_insufficient_data_returns_none(self, factor_data, close_data): + """<5000 rows should return None.""" + from scripts.predix_continuous_strategies import build_ml_model + result = build_ml_model(factor_data.iloc[:100], close_data.iloc[:100], "swing") + assert result is None + + @patch("rdagent.components.backtesting.vbt_backtest.backtest_signal_ftmo") + def test_sufficient_data_returns_dict(self, mock_bt, factor_data, close_data): + mock_bt.return_value = { + "sharpe": 1.5, "max_drawdown": -0.1, "win_rate": 0.55, + "n_trades": 200, "wf_oos_sharpe_mean": 0.8, + } + from scripts.predix_continuous_strategies import build_ml_model + result = build_ml_model(factor_data, close_data, "daytrading") + assert result is not None + assert "strategy_name" in result + assert "ML_GradientBoost" in result["strategy_name"] + assert result["status"] == "accepted" + assert result["type"] == "ml_model" + + @patch("rdagent.components.backtesting.vbt_backtest.backtest_signal_ftmo") + def test_negative_oos_rejected(self, mock_bt, factor_data, close_data): + mock_bt.return_value = { + "sharpe": 1.5, "max_drawdown": -0.1, "win_rate": 0.55, + "n_trades": 200, "wf_oos_sharpe_mean": -0.3, + } + from scripts.predix_continuous_strategies import build_ml_model + result = build_ml_model(factor_data, close_data, "swing") + assert result is None + + @given( + seed=st.integers(0, 1000), + n_rows=st.integers(100, 6000), + ) + @settings(max_examples=50, deadline=10000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_never_crashes(self, factor_data, close_data, seed, n_rows): + """build_ml_model must never crash regardless of data size.""" + rng = np.random.default_rng(seed) + n = min(n_rows, len(factor_data)) + f = pd.DataFrame({ + "a": rng.normal(0, 1, n), + "b": rng.normal(0, 1, n), + "c": rng.normal(0, 1, n), + }, index=factor_data.index[:n]) + c = pd.Series(1.10 + rng.normal(0, 0.001, n).cumsum(), index=f.index) + try: + from scripts.predix_continuous_strategies import build_ml_model + result = build_ml_model(f, c, "swing") + assert result is None or isinstance(result, dict) + except Exception as e: + if n < 5000: + pass # Expected to return None early + else: + pytest.fail(f"build_ml_model crashed: {e}") + + +class TestConfig: + def test_batch_size_is_positive(self): + from scripts import predix_continuous_strategies + assert predix_continuous_strategies.BATCH_SIZE > 0 + + def test_cooldown_is_positive(self): + from scripts import predix_continuous_strategies + assert predix_continuous_strategies.COOLDOWN_SECONDS > 0 + + +class TestStyleCycling: + def test_both_style_alternates(self): + """When style='both', odd rounds start daytrading, even rounds start swing.""" + for r in range(1, 20): + if r % 2 == 1: + expected = ["swing", "daytrading"] + else: + expected = ["daytrading", "swing"] + styles = expected + if r % 2 == 1: + assert styles == ["swing", "daytrading"] + else: + assert styles == ["daytrading", "swing"] + + def test_single_style_constant(self): + """When style is 'daytrading', all rounds use daytrading.""" + styles_seen = ["daytrading" for _ in range(10)] + assert all(s == "daytrading" for s in styles_seen) diff --git a/test/local/test_predix_parallel.py b/test/local/test_predix_parallel.py new file mode 100644 index 00000000..aa1fa40b --- /dev/null +++ b/test/local/test_predix_parallel.py @@ -0,0 +1,198 @@ +"""Deep tests for predix_parallel.py — property-based, state transitions, edge cases. + +Tests RunState, ParallelRunner configuration, environment building, +command building, and API key loading logic. +""" + +from __future__ import annotations + +import os +import signal +import sys +import tempfile +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from hypothesis import given, settings, HealthCheck +from hypothesis import strategies as st + + +@pytest.fixture +def runstate(): + from scripts.predix_parallel import RunState + return RunState(run_id=1, api_key_idx=0, model="local") + + +class TestRunState: + def test_init_defaults(self, runstate): + assert runstate.run_id == 1 + assert runstate.api_key_idx == 0 + assert runstate.model == "local" + assert runstate.status == "pending" + assert runstate.process is None + assert runstate.exit_code is None + + def test_elapsed_not_started(self, runstate): + assert runstate.elapsed == "--:--:--" + + def test_elapsed_running(self, runstate): + runstate.start_time = datetime(2024, 1, 1, 12, 0, 0) + with patch("scripts.predix_parallel.datetime") as mock_dt: + mock_dt.now.return_value = datetime(2024, 1, 1, 13, 30, 45) + assert runstate.elapsed == "01:30:45" + + def test_elapsed_completed(self, runstate): + runstate.start_time = datetime(2024, 1, 1, 12, 0, 0) + runstate.end_time = datetime(2024, 1, 1, 14, 5, 30) + assert runstate.elapsed == "02:05:30" + + def test_elapsed_over_24h(self, runstate): + runstate.start_time = datetime(2024, 1, 1, 0, 0, 0) + runstate.end_time = datetime(2024, 1, 3, 6, 30, 15) + assert runstate.elapsed == "54:30:15" + + @pytest.mark.parametrize("status,icon", [ + ("pending", "âŗ"), ("running", "🔄"), ("success", "✅"), + ("failed", "❌"), ("stopped", "âšī¸"), + ]) + def test_status_icons(self, runstate, status, icon): + runstate.status = status + assert runstate.status_icon == icon + + def test_unknown_status_icon(self, runstate): + runstate.status = "weird_status" + assert runstate.status_icon == "❓" + + @given( + hours=st.integers(min_value=0, max_value=1000), + mins=st.integers(min_value=0, max_value=59), + secs=st.integers(min_value=0, max_value=59), + ) + @settings(max_examples=200, deadline=5000, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_elapsed_format_property(self, runstate, hours, mins, secs): + """Elapsed time format must always be HH:MM:SS with zero-padding.""" + runstate.start_time = datetime(2024, 1, 1, 0, 0, 0) + total_secs = hours * 3600 + mins * 60 + secs + if total_secs < 365 * 86400: + from datetime import timedelta + runstate.end_time = runstate.start_time + timedelta(seconds=total_secs) + e = runstate.elapsed + parts = e.split(":") + assert len(parts) == 3 + assert len(parts[1]) == 2 and len(parts[2]) == 2 # mins and secs always 2 digits + assert all(p.isdigit() for p in parts) + assert int(parts[0]) == hours + + +class TestParallelRunnerConfig: + @patch.dict(os.environ, {}, clear=True) + def test_load_api_keys_openrouter(self): + from scripts.predix_parallel import ParallelRunner + with patch.dict(os.environ, { + "OPENROUTER_API_KEY": "sk-key1", + "OPENROUTER_API_KEY_2": "sk-key2", + }): + runner = ParallelRunner(num_runs=2, num_api_keys=2, model="openrouter") + keys = runner.api_keys + assert len(keys) == 2 + assert keys[0].startswith("sk-") + + @patch.dict(os.environ, {}, clear=True) + def test_load_api_keys_local(self): + from scripts.predix_parallel import ParallelRunner + runner = ParallelRunner(num_runs=1, num_api_keys=1, model="local") + assert runner.api_keys == ["local"] + + @patch.dict(os.environ, {}, clear=True) + def test_load_api_keys_round_robin(self): + from scripts.predix_parallel import ParallelRunner + runner = ParallelRunner(num_runs=5, num_api_keys=2, model="local") + assert len(runner.runs) == 5 + idxs = [r.api_key_idx for r in runner.runs] + assert idxs == [0, 1, 0, 1, 0] + + @patch.dict(os.environ, {}, clear=True) + def test_build_env_local_model(self): + from scripts.predix_parallel import ParallelRunner, RunState + with patch.dict(os.environ, { + "OPENAI_API_KEY": "local", + "OPENAI_API_BASE": "http://localhost:8081/v1", + "CHAT_MODEL": "openai/qwen3.5-35b", + }): + runner = ParallelRunner(num_runs=1, num_api_keys=1, model="local") + rs = RunState(run_id=1, api_key_idx=0, model="local") + env = runner._build_env(rs) + assert env["OPENAI_API_KEY"] == "local" + assert "localhost:8081" in env["OPENAI_API_BASE"] + assert env["CHAT_MODEL"] == "openai/qwen3.5-35b" + + @patch.dict(os.environ, {}, clear=True) + def test_build_env_openrouter(self): + from scripts.predix_parallel import ParallelRunner, RunState + with patch.dict(os.environ, { + "OPENROUTER_API_KEY": "sk-test", + "OPENROUTER_API_KEY_2": "sk-test2", + }): + runner = ParallelRunner(num_runs=1, num_api_keys=2, model="openrouter") + rs = RunState(run_id=1, api_key_idx=0, model="openrouter") + env = runner._build_env(rs) + assert "openrouter" in env["OPENAI_API_BASE"] + + @patch.dict(os.environ, {}, clear=True) + def test_build_env_sets_workspace(self): + from scripts.predix_parallel import ParallelRunner, RunState + runner = ParallelRunner(num_runs=1, num_api_keys=1, model="local") + rs = RunState(run_id=42, api_key_idx=0, model="local") + env = runner._build_env(rs) + assert "run42" in env.get("RD_AGENT_WORKSPACE", "") + assert env["PARALLEL_RUN_ID"] == "42" + + @patch.dict(os.environ, {}, clear=True) + def test_build_command(self): + from scripts.predix_parallel import ParallelRunner, RunState + runner = ParallelRunner(num_runs=1, num_api_keys=1, model="local") + rs = RunState(run_id=7, api_key_idx=0, model="local") + cmd = runner._build_command(rs) + assert "predix.py" in cmd[1] or "predix" in cmd[1] + assert "quant" in cmd + assert "--model" in cmd + assert "local" in cmd + assert "7" in [str(a) for a in cmd] + + @patch.dict(os.environ, {}, clear=True) + def test_parallel_runner_init_counts(self): + from scripts.predix_parallel import ParallelRunner + for n in [1, 3, 10]: + runner = ParallelRunner(num_runs=n, num_api_keys=2, model="local") + assert len(runner.runs) == n + assert runner.num_runs == n + + +class TestParallelRunnerEdgeCases: + @patch.dict(os.environ, {}, clear=True) + def test_max_runs_limit(self): + from scripts.predix_parallel import ParallelRunner + runner = ParallelRunner(num_runs=100, num_api_keys=1, model="local") + assert len(runner.runs) == 100 + + @patch.dict(os.environ, {}, clear=True) + def test_api_keys_empty_uses_local(self): + from scripts.predix_parallel import ParallelRunner + runner = ParallelRunner(num_runs=1, num_api_keys=2, model="openrouter") + assert len(runner.api_keys) >= 1 + + @patch.dict(os.environ, {}, clear=True) + def test_build_env_preserves_existing_env(self, monkeypatch): + monkeypatch.setenv("MY_CUSTOM_VAR", "custom_value") + from scripts.predix_parallel import ParallelRunner, RunState + runner = ParallelRunner(num_runs=1, num_api_keys=1, model="local") + rs = RunState(run_id=1, api_key_idx=0, model="local") + env = runner._build_env(rs) + assert "MY_CUSTOM_VAR" in env