mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 15:37:44 +00:00
1bbca062af
Implement Reinforcement Learning trading system inspired by FinRL concepts (100% original code, NOT copied from FinRL MIT project): RL ENVIRONMENT: - TradingEnv: Gymnasium-compatible environment - State: price history + indicators + portfolio state - Action: continuous position [-1, 1] (short to long) - Reward: return - transaction costs - drawdown penalty RL AGENT: - RLTradingAgent: Wrapper for Stable Baselines3 - Supports PPO (stable), A2C (fast), SAC (continuous) - Methods: create_model(), train(), predict(), save(), load(), evaluate() COSTEER (fills TODO at costeer.py:112): - RLCosteer: RL-based trading controller - Risk-limit enforcement (15% drawdown stops trading) - Position scaling based on risk appetite - Trade history tracking TECHNICAL INDICATORS: - RSI, MACD, Bollinger Bands, CCI, ATR - prepare_features() helper for easy integration TESTS (99 total, ALL PASS): - 26 env tests - 16 agent tests - 19 costeer tests - 18 indicator tests - 10 integration tests Documentation: - Update QWEN.md with RL system architecture
340 lines
11 KiB
Python
340 lines
11 KiB
Python
"""
|
|
Tests for RL Costeer (Trading Controller).
|
|
|
|
Covers:
|
|
- Costeer initialization with/without model
|
|
- Market data initialization
|
|
- Action retrieval (mocked model)
|
|
- Risk limit enforcement
|
|
- Observation building
|
|
- Step execution
|
|
- Performance tracking
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from rdagent.components.coder.rl.costeer import RLCosteer
|
|
|
|
|
|
# =============================================================================
|
|
# FIXTURES
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_prices() -> pd.Series:
|
|
"""Generate 200-step price series."""
|
|
np.random.seed(42)
|
|
return pd.Series(100.0 + np.cumsum(np.random.randn(200) * 0.5))
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_indicators() -> pd.DataFrame:
|
|
"""Generate mock indicators DataFrame."""
|
|
np.random.seed(42)
|
|
return pd.DataFrame(
|
|
np.random.randn(200, 3).astype(np.float32),
|
|
columns=["rsi", "macd", "bb_width"],
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_costeer() -> RLCosteer:
|
|
"""Create costeer without model."""
|
|
return RLCosteer(
|
|
algorithm="PPO",
|
|
window_size=30,
|
|
max_position=1.0,
|
|
risk_limit=0.15,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def initialized_costeer(mock_prices: pd.Series, mock_indicators: pd.DataFrame) -> RLCosteer:
|
|
"""Create costeer with market data but no model."""
|
|
costeer = RLCosteer(window_size=30)
|
|
costeer.initialize(mock_prices, mock_indicators, initial_equity=100000.0)
|
|
return costeer
|
|
|
|
|
|
# =============================================================================
|
|
# INITIALIZATION
|
|
# =============================================================================
|
|
|
|
|
|
class TestCosteerInit:
|
|
"""Test costeer initialization."""
|
|
|
|
def test_default_values(self) -> None:
|
|
"""Default parameters should match specification."""
|
|
costeer = RLCosteer()
|
|
assert costeer.algorithm == "PPO"
|
|
assert costeer.window_size == 60
|
|
assert costeer.max_position == 1.0
|
|
assert costeer.risk_limit == 0.15
|
|
assert costeer.is_active is False
|
|
assert costeer.model is None
|
|
assert costeer.trade_history == []
|
|
|
|
def test_custom_values(self) -> None:
|
|
"""Custom parameters should be stored."""
|
|
costeer = RLCosteer(
|
|
algorithm="SAC",
|
|
window_size=120,
|
|
max_position=0.5,
|
|
risk_limit=0.10,
|
|
)
|
|
assert costeer.algorithm == "SAC"
|
|
assert costeer.window_size == 120
|
|
assert costeer.max_position == 0.5
|
|
assert costeer.risk_limit == 0.10
|
|
|
|
def test_initialize_sets_market_data(
|
|
self, mock_prices: pd.Series, mock_indicators: pd.DataFrame
|
|
) -> None:
|
|
"""Initialize should store market data and activate costeer."""
|
|
costeer = RLCosteer(window_size=30)
|
|
costeer.initialize(mock_prices, mock_indicators, initial_equity=50000.0)
|
|
|
|
assert costeer.is_active is True
|
|
assert len(costeer.prices) == 200
|
|
assert costeer.indicators is not None
|
|
assert costeer.initial_equity == 50000.0
|
|
assert costeer.current_step == 30
|
|
assert costeer.peak_equity == 50000.0
|
|
|
|
def test_initialize_without_indicators(self, mock_prices: pd.Series) -> None:
|
|
"""Initialize should work without indicators."""
|
|
costeer = RLCosteer(window_size=30)
|
|
costeer.initialize(mock_prices, initial_equity=100000.0)
|
|
|
|
assert costeer.indicators is None
|
|
assert costeer.is_active is True
|
|
|
|
|
|
# =============================================================================
|
|
# GET ACTION
|
|
# =============================================================================
|
|
|
|
|
|
class TestGetAction:
|
|
"""Test action retrieval."""
|
|
|
|
def test_no_model_returns_zero(self, initialized_costeer: RLCosteer) -> None:
|
|
"""Without model, action should be 0 (hold)."""
|
|
action = initialized_costeer.get_action(
|
|
current_equity=100000.0, cash=50000.0, position=0.0
|
|
)
|
|
assert action == 0.0
|
|
|
|
def test_not_active_returns_zero(self, basic_costeer: RLCosteer) -> None:
|
|
"""Inactive costeer should return 0."""
|
|
action = basic_costeer.get_action(
|
|
current_equity=100000.0, cash=50000.0, position=0.0
|
|
)
|
|
assert action == 0.0
|
|
|
|
@patch.object(RLCosteer, "_build_observation")
|
|
def test_model_action_risk_scaled(
|
|
self, mock_obs: MagicMock, initialized_costeer: RLCosteer
|
|
) -> None:
|
|
"""Model action should be returned and risk-scaled."""
|
|
mock_obs.return_value = np.random.randn(100).astype(np.float32)
|
|
|
|
mock_model = MagicMock()
|
|
mock_model.predict.return_value = (np.array([[0.8]]), None)
|
|
initialized_costeer.model = mock_model
|
|
|
|
action = initialized_costeer.get_action(
|
|
current_equity=100000.0, cash=50000.0, position=0.0
|
|
)
|
|
|
|
# At full risk (no drawdown), action should be ~0.8
|
|
assert abs(action) <= 1.0
|
|
|
|
def test_risk_limit_forces_close(self, initialized_costeer: RLCosteer) -> None:
|
|
"""Drawdown > risk_limit should force position to 0."""
|
|
mock_model = MagicMock()
|
|
mock_model.predict.return_value = (np.array([[1.0]]), None)
|
|
initialized_costeer.model = mock_model
|
|
|
|
# Simulate 20% drawdown (> 15% limit)
|
|
action = initialized_costeer.get_action(
|
|
current_equity=80000.0, # 20% drawdown from 100k
|
|
cash=50000.0,
|
|
position=0.5,
|
|
)
|
|
assert action == 0.0
|
|
|
|
def test_action_clipped_to_max_position(
|
|
self, initialized_costeer: RLCosteer
|
|
) -> None:
|
|
"""Action should be clipped to max_position."""
|
|
initialized_costeer.max_position = 0.5
|
|
|
|
mock_model = MagicMock()
|
|
mock_model.predict.return_value = (np.array([[1.0]]), None)
|
|
initialized_costeer.model = mock_model
|
|
|
|
action = initialized_costeer.get_action(
|
|
current_equity=100000.0, cash=50000.0, position=0.0
|
|
)
|
|
assert action <= 0.5
|
|
|
|
|
|
# =============================================================================
|
|
# OBSERVATION BUILDING
|
|
# =============================================================================
|
|
|
|
|
|
class TestObservationBuilding:
|
|
"""Test observation vector construction."""
|
|
|
|
def test_observation_shape_no_indicators(
|
|
self, mock_prices: pd.Series
|
|
) -> None:
|
|
"""Observation should have correct shape without indicators."""
|
|
costeer = RLCosteer(window_size=30)
|
|
costeer.initialize(mock_prices, initial_equity=100000.0)
|
|
|
|
obs = costeer._build_observation(
|
|
current_equity=100000.0, cash=50000.0, position=0.0
|
|
)
|
|
|
|
# window_size + 3 (position, pnl, equity_ratio)
|
|
expected = 30 + 3
|
|
assert obs.shape == (expected,)
|
|
|
|
def test_observation_shape_with_indicators(
|
|
self, mock_prices: pd.Series, mock_indicators: pd.DataFrame
|
|
) -> None:
|
|
"""Observation should include indicator dimensions."""
|
|
costeer = RLCosteer(window_size=30)
|
|
costeer.initialize(mock_prices, mock_indicators, initial_equity=100000.0)
|
|
|
|
obs = costeer._build_observation(
|
|
current_equity=100000.0, cash=50000.0, position=0.0
|
|
)
|
|
|
|
# window_size * (1 + 3) + 3
|
|
expected = 30 + (30 * 3) + 3
|
|
assert obs.shape == (expected,)
|
|
|
|
def test_observation_dtype(self, initialized_costeer: RLCosteer) -> None:
|
|
"""Observation should be float32."""
|
|
obs = initialized_costeer._build_observation(
|
|
current_equity=100000.0, cash=50000.0, position=0.0
|
|
)
|
|
assert obs.dtype == np.float32
|
|
|
|
|
|
# =============================================================================
|
|
# STEP EXECUTION
|
|
# =============================================================================
|
|
|
|
|
|
class TestStepExecution:
|
|
"""Test step execution."""
|
|
|
|
def test_step_records_trade(self, initialized_costeer: RLCosteer) -> None:
|
|
"""Step should record trade in history."""
|
|
trade = initialized_costeer.step(
|
|
current_equity=100000.0, cash=50000.0, position=0.0
|
|
)
|
|
|
|
assert "timestamp" in trade
|
|
assert "step" in trade
|
|
assert "equity" in trade
|
|
assert "position" in trade
|
|
assert "target_position" in trade
|
|
assert "action" in trade
|
|
|
|
def test_step_advances_current_step(self, initialized_costeer: RLCosteer) -> None:
|
|
"""Step should increment current_step."""
|
|
initial_step = initialized_costeer.current_step
|
|
initialized_costeer.step(
|
|
current_equity=100000.0, cash=50000.0, position=0.0
|
|
)
|
|
assert initialized_costeer.current_step == initial_step + 1
|
|
|
|
def test_step_updates_peak_equity(self, initialized_costeer: RLCosteer) -> None:
|
|
"""Peak equity should update when equity exceeds previous peak."""
|
|
initialized_costeer.peak_equity = 100000.0
|
|
initialized_costeer.step(
|
|
current_equity=105000.0, cash=50000.0, position=0.0
|
|
)
|
|
assert initialized_costeer.peak_equity == 105000.0
|
|
|
|
def test_multiple_steps_accumulate_trades(
|
|
self, initialized_costeer: RLCosteer
|
|
) -> None:
|
|
"""Multiple steps should accumulate trades."""
|
|
for _ in range(5):
|
|
initialized_costeer.step(
|
|
current_equity=100000.0, cash=50000.0, position=0.0
|
|
)
|
|
|
|
assert len(initialized_costeer.trade_history) == 5
|
|
|
|
|
|
# =============================================================================
|
|
# MODEL LOADING
|
|
# =============================================================================
|
|
|
|
|
|
class TestModelLoading:
|
|
"""Test model loading functionality."""
|
|
|
|
def test_load_model_import_error(self, tmp_path: Path) -> None:
|
|
"""Load should raise ImportError when SB3 not installed."""
|
|
costeer = RLCosteer()
|
|
|
|
with patch.dict("sys.modules", {"stable_baselines3": None}):
|
|
with pytest.raises(ImportError, match="stable-baselines3"):
|
|
costeer.load_model(tmp_path / "model.zip")
|
|
|
|
def test_load_model_file_not_found(self, tmp_path: Path) -> None:
|
|
"""Load should raise ValueError for non-existent file."""
|
|
costeer = RLCosteer(model_path=tmp_path / "nonexistent.zip")
|
|
# Model path doesn't exist, so it won't try to load
|
|
assert costeer.is_active is False
|
|
|
|
|
|
# =============================================================================
|
|
# PERFORMANCE TRACKING
|
|
# =============================================================================
|
|
|
|
|
|
class TestPerformanceTracking:
|
|
"""Test performance history."""
|
|
|
|
def test_get_performance_returns_dataframe(
|
|
self, initialized_costeer: RLCosteer
|
|
) -> None:
|
|
"""Performance should return DataFrame."""
|
|
# Add some trades
|
|
initialized_costeer.step(
|
|
current_equity=100000.0, cash=50000.0, position=0.0
|
|
)
|
|
initialized_costeer.step(
|
|
current_equity=101000.0, cash=49000.0, position=0.3
|
|
)
|
|
|
|
df = initialized_costeer.get_performance()
|
|
|
|
assert isinstance(df, pd.DataFrame)
|
|
assert len(df) == 2
|
|
assert "equity" in df.columns
|
|
assert "position" in df.columns
|
|
|
|
def test_empty_performance_history(self, basic_costeer: RLCosteer) -> None:
|
|
"""Empty history should return empty DataFrame."""
|
|
df = basic_costeer.get_performance()
|
|
assert isinstance(df, pd.DataFrame)
|
|
assert len(df) == 0
|