Files
The-Ouroboros/tests/test_evolution.py
agentson ae7195c829
Some checks failed
CI / test (pull_request) Has been cancelled
feat: implement evolution engine for self-improving strategies
Complete Pillar 4 implementation with comprehensive testing and analysis.

Components:
- EvolutionOptimizer: Analyzes losing decisions from DecisionLogger,
  identifies failure patterns (time, market, action), and uses Gemini
  to generate improved strategies with auto-deployment capability
- ABTester: A/B testing framework with statistical significance testing
  (two-sample t-test), performance comparison, and deployment criteria
  (>60% win rate, >20 trades minimum)
- PerformanceTracker: Tracks strategy win rates, monitors improvement
  trends over time, generates comprehensive dashboards with daily/weekly
  metrics and trend analysis

Key Features:
- Uses DecisionLogger.get_losing_decisions() for failure identification
- Pattern analysis: market distribution, action types, time-of-day patterns
- Gemini integration for AI-powered strategy generation
- Statistical validation using scipy.stats.ttest_ind
- Sharpe ratio calculation for risk-adjusted returns
- Auto-deploy strategies meeting 60% win rate threshold
- Performance dashboard with JSON export capability

Testing:
- 24 comprehensive tests covering all evolution components
- 90% coverage of evolution module (304 lines, 31 missed)
- Integration tests for full evolution pipeline
- All 105 project tests passing with 72% overall coverage

Dependencies:
- Added scipy>=1.11,<2 for statistical analysis

Closes #19

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-04 16:34:10 +09:00

687 lines
22 KiB
Python

"""Tests for the Evolution Engine components.
Tests cover:
- EvolutionOptimizer: failure analysis and strategy generation
- ABTester: A/B testing and statistical comparison
- PerformanceTracker: metrics tracking and dashboard
"""
from __future__ import annotations
import json
import sqlite3
import tempfile
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from src.config import Settings
from src.db import init_db, log_trade
from src.evolution.ab_test import ABTester, ABTestResult, StrategyPerformance
from src.evolution.optimizer import EvolutionOptimizer
from src.evolution.performance_tracker import (
PerformanceDashboard,
PerformanceTracker,
StrategyMetrics,
)
from src.logging.decision_logger import DecisionLogger
# ------------------------------------------------------------------
# Fixtures
# ------------------------------------------------------------------
@pytest.fixture
def db_conn() -> sqlite3.Connection:
"""Provide an in-memory database with initialized schema."""
return init_db(":memory:")
@pytest.fixture
def settings() -> Settings:
"""Provide test settings."""
return Settings(
KIS_APP_KEY="test_key",
KIS_APP_SECRET="test_secret",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="test_gemini_key",
GEMINI_MODEL="gemini-pro",
DB_PATH=":memory:",
)
@pytest.fixture
def optimizer(settings: Settings) -> EvolutionOptimizer:
"""Provide an EvolutionOptimizer instance."""
return EvolutionOptimizer(settings)
@pytest.fixture
def decision_logger(db_conn: sqlite3.Connection) -> DecisionLogger:
"""Provide a DecisionLogger instance."""
return DecisionLogger(db_conn)
@pytest.fixture
def ab_tester() -> ABTester:
"""Provide an ABTester instance."""
return ABTester(significance_level=0.05)
@pytest.fixture
def performance_tracker(settings: Settings) -> PerformanceTracker:
"""Provide a PerformanceTracker instance."""
return PerformanceTracker(db_path=":memory:")
# ------------------------------------------------------------------
# EvolutionOptimizer Tests
# ------------------------------------------------------------------
def test_analyze_failures_uses_decision_logger(optimizer: EvolutionOptimizer) -> None:
"""Test that analyze_failures uses DecisionLogger.get_losing_decisions()."""
# Add some losing decisions to the database
logger = optimizer._decision_logger
# High-confidence loss
id1 = logger.log_decision(
stock_code="005930",
market="KR",
exchange_code="KRX",
action="BUY",
confidence=85,
rationale="Expected growth",
context_snapshot={"L1": {"price": 70000}},
input_data={"price": 70000, "volume": 1000},
)
logger.update_outcome(id1, pnl=-2000.0, accuracy=0)
# Another high-confidence loss
id2 = logger.log_decision(
stock_code="000660",
market="KR",
exchange_code="KRX",
action="SELL",
confidence=90,
rationale="Expected drop",
context_snapshot={"L1": {"price": 100000}},
input_data={"price": 100000, "volume": 500},
)
logger.update_outcome(id2, pnl=-1500.0, accuracy=0)
# Low-confidence loss (should be ignored)
id3 = logger.log_decision(
stock_code="035420",
market="KR",
exchange_code="KRX",
action="HOLD",
confidence=70,
rationale="Uncertain",
context_snapshot={},
input_data={},
)
logger.update_outcome(id3, pnl=-500.0, accuracy=0)
# Analyze failures
failures = optimizer.analyze_failures(limit=10)
# Should get 2 failures (confidence >= 80)
assert len(failures) == 2
assert all(f["confidence"] >= 80 for f in failures)
assert all(f["outcome_pnl"] <= -100.0 for f in failures)
def test_analyze_failures_empty_database(optimizer: EvolutionOptimizer) -> None:
"""Test analyze_failures with no losing decisions."""
failures = optimizer.analyze_failures()
assert failures == []
def test_identify_failure_patterns(optimizer: EvolutionOptimizer) -> None:
"""Test identification of failure patterns."""
failures = [
{
"decision_id": "1",
"timestamp": "2024-01-15T09:30:00+00:00",
"stock_code": "005930",
"market": "KR",
"exchange_code": "KRX",
"action": "BUY",
"confidence": 85,
"rationale": "Test",
"outcome_pnl": -1000.0,
"outcome_accuracy": 0,
"context_snapshot": {},
"input_data": {},
},
{
"decision_id": "2",
"timestamp": "2024-01-15T14:30:00+00:00",
"stock_code": "000660",
"market": "KR",
"exchange_code": "KRX",
"action": "SELL",
"confidence": 90,
"rationale": "Test",
"outcome_pnl": -2000.0,
"outcome_accuracy": 0,
"context_snapshot": {},
"input_data": {},
},
{
"decision_id": "3",
"timestamp": "2024-01-15T09:45:00+00:00",
"stock_code": "035420",
"market": "US_NASDAQ",
"exchange_code": "NASDAQ",
"action": "BUY",
"confidence": 80,
"rationale": "Test",
"outcome_pnl": -500.0,
"outcome_accuracy": 0,
"context_snapshot": {},
"input_data": {},
},
]
patterns = optimizer.identify_failure_patterns(failures)
assert patterns["total_failures"] == 3
assert patterns["markets"]["KR"] == 2
assert patterns["markets"]["US_NASDAQ"] == 1
assert patterns["actions"]["BUY"] == 2
assert patterns["actions"]["SELL"] == 1
assert 9 in patterns["hours"] # 09:30 and 09:45
assert 14 in patterns["hours"] # 14:30
assert patterns["avg_confidence"] == 85.0
assert patterns["avg_loss"] == -1166.67
def test_identify_failure_patterns_empty(optimizer: EvolutionOptimizer) -> None:
"""Test pattern identification with no failures."""
patterns = optimizer.identify_failure_patterns([])
assert patterns["pattern_count"] == 0
assert patterns["patterns"] == {}
@pytest.mark.asyncio
async def test_generate_strategy_creates_file(optimizer: EvolutionOptimizer, tmp_path: Path) -> None:
"""Test that generate_strategy creates a strategy file."""
failures = [
{
"decision_id": "1",
"timestamp": "2024-01-15T09:30:00+00:00",
"stock_code": "005930",
"market": "KR",
"action": "BUY",
"confidence": 85,
"outcome_pnl": -1000.0,
"context_snapshot": {},
"input_data": {},
}
]
# Mock Gemini response
mock_response = Mock()
mock_response.text = """
# Simple strategy
price = market_data.get("current_price", 0)
if price > 50000:
return {"action": "BUY", "confidence": 70, "rationale": "Price above threshold"}
return {"action": "HOLD", "confidence": 50, "rationale": "Waiting"}
"""
with patch.object(optimizer._client.aio.models, "generate_content", new=AsyncMock(return_value=mock_response)):
with patch("src.evolution.optimizer.STRATEGIES_DIR", tmp_path):
strategy_path = await optimizer.generate_strategy(failures)
assert strategy_path is not None
assert strategy_path.exists()
assert strategy_path.suffix == ".py"
assert "class Strategy_" in strategy_path.read_text()
assert "def evaluate" in strategy_path.read_text()
@pytest.mark.asyncio
async def test_generate_strategy_handles_api_error(optimizer: EvolutionOptimizer) -> None:
"""Test that generate_strategy handles Gemini API errors gracefully."""
failures = [{"decision_id": "1", "timestamp": "2024-01-15T09:30:00+00:00"}]
with patch.object(
optimizer._client.aio.models,
"generate_content",
side_effect=Exception("API Error"),
):
strategy_path = await optimizer.generate_strategy(failures)
assert strategy_path is None
def test_get_performance_summary() -> None:
"""Test getting performance summary from trades table."""
# Create a temporary database with trades
import tempfile
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp_path = tmp.name
conn = init_db(tmp_path)
log_trade(conn, "005930", "BUY", 85, "Test win", quantity=10, price=70000, pnl=1000.0)
log_trade(conn, "000660", "SELL", 90, "Test loss", quantity=5, price=100000, pnl=-500.0)
log_trade(conn, "035420", "BUY", 80, "Test win", quantity=8, price=50000, pnl=800.0)
conn.close()
# Create settings with temp database path
settings = Settings(
KIS_APP_KEY="test_key",
KIS_APP_SECRET="test_secret",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="test_gemini_key",
GEMINI_MODEL="gemini-pro",
DB_PATH=tmp_path,
)
optimizer = EvolutionOptimizer(settings)
summary = optimizer.get_performance_summary()
assert summary["total_trades"] == 3
assert summary["wins"] == 2
assert summary["losses"] == 1
assert summary["total_pnl"] == 1300.0
assert summary["avg_pnl"] == 433.33
# Clean up
Path(tmp_path).unlink()
def test_validate_strategy_success(optimizer: EvolutionOptimizer, tmp_path: Path) -> None:
"""Test strategy validation when tests pass."""
strategy_file = tmp_path / "test_strategy.py"
strategy_file.write_text("# Valid strategy file")
with patch("subprocess.run") as mock_run:
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
result = optimizer.validate_strategy(strategy_file)
assert result is True
assert strategy_file.exists()
def test_validate_strategy_failure(optimizer: EvolutionOptimizer, tmp_path: Path) -> None:
"""Test strategy validation when tests fail."""
strategy_file = tmp_path / "test_strategy.py"
strategy_file.write_text("# Invalid strategy file")
with patch("subprocess.run") as mock_run:
mock_run.return_value = Mock(returncode=1, stdout="FAILED", stderr="")
result = optimizer.validate_strategy(strategy_file)
assert result is False
# File should be deleted on failure
assert not strategy_file.exists()
# ------------------------------------------------------------------
# ABTester Tests
# ------------------------------------------------------------------
def test_calculate_performance_basic(ab_tester: ABTester) -> None:
"""Test basic performance calculation."""
trades = [
{"pnl": 1000.0},
{"pnl": -500.0},
{"pnl": 800.0},
{"pnl": 200.0},
]
perf = ab_tester.calculate_performance(trades, "TestStrategy")
assert perf.strategy_name == "TestStrategy"
assert perf.total_trades == 4
assert perf.wins == 3
assert perf.losses == 1
assert perf.total_pnl == 1500.0
assert perf.avg_pnl == 375.0
assert perf.win_rate == 75.0
assert perf.sharpe_ratio is not None
def test_calculate_performance_empty(ab_tester: ABTester) -> None:
"""Test performance calculation with no trades."""
perf = ab_tester.calculate_performance([], "EmptyStrategy")
assert perf.total_trades == 0
assert perf.wins == 0
assert perf.losses == 0
assert perf.total_pnl == 0.0
assert perf.avg_pnl == 0.0
assert perf.win_rate == 0.0
assert perf.sharpe_ratio is None
def test_compare_strategies_significant_difference(ab_tester: ABTester) -> None:
"""Test strategy comparison with significant performance difference."""
# Strategy A: consistently profitable
trades_a = [{"pnl": 1000.0} for _ in range(30)]
# Strategy B: consistently losing
trades_b = [{"pnl": -500.0} for _ in range(30)]
result = ab_tester.compare_strategies(trades_a, trades_b, "Strategy A", "Strategy B")
# scipy returns np.True_ instead of Python bool
assert bool(result.is_significant) is True
assert result.winner == "Strategy A"
assert result.p_value < 0.05
assert result.performance_a.avg_pnl > result.performance_b.avg_pnl
def test_compare_strategies_no_difference(ab_tester: ABTester) -> None:
"""Test strategy comparison with no significant difference."""
# Both strategies have similar performance
trades_a = [{"pnl": 100.0}, {"pnl": -50.0}, {"pnl": 80.0}]
trades_b = [{"pnl": 90.0}, {"pnl": -60.0}, {"pnl": 85.0}]
result = ab_tester.compare_strategies(trades_a, trades_b, "Strategy A", "Strategy B")
# With small samples and similar performance, likely not significant
assert result.winner is None or not result.is_significant
def test_should_deploy_meets_criteria(ab_tester: ABTester) -> None:
"""Test deployment decision when criteria are met."""
# Create a winning result that meets criteria
trades_a = [{"pnl": 1000.0} for _ in range(25)] # 100% win rate
trades_b = [{"pnl": -500.0} for _ in range(25)]
result = ab_tester.compare_strategies(trades_a, trades_b, "Winner", "Loser")
should_deploy = ab_tester.should_deploy(result, min_win_rate=60.0, min_trades=20)
assert should_deploy is True
def test_should_deploy_insufficient_trades(ab_tester: ABTester) -> None:
"""Test deployment decision with insufficient trades."""
trades_a = [{"pnl": 1000.0} for _ in range(10)] # Only 10 trades
trades_b = [{"pnl": -500.0} for _ in range(10)]
result = ab_tester.compare_strategies(trades_a, trades_b, "Winner", "Loser")
should_deploy = ab_tester.should_deploy(result, min_win_rate=60.0, min_trades=20)
assert should_deploy is False
def test_should_deploy_low_win_rate(ab_tester: ABTester) -> None:
"""Test deployment decision with low win rate."""
# Mix of wins and losses, below 60% win rate
trades_a = [{"pnl": 100.0}] * 10 + [{"pnl": -100.0}] * 15 # 40% win rate
trades_b = [{"pnl": -500.0} for _ in range(25)]
result = ab_tester.compare_strategies(trades_a, trades_b, "LowWinner", "Loser")
should_deploy = ab_tester.should_deploy(result, min_win_rate=60.0, min_trades=20)
assert should_deploy is False
def test_should_deploy_not_significant(ab_tester: ABTester) -> None:
"""Test deployment decision when difference is not significant."""
# Use more varied data to ensure statistical insignificance
trades_a = [{"pnl": 100.0}, {"pnl": -50.0}] * 12 + [{"pnl": 100.0}]
trades_b = [{"pnl": 95.0}, {"pnl": -45.0}] * 12 + [{"pnl": 95.0}]
result = ab_tester.compare_strategies(trades_a, trades_b, "A", "B")
should_deploy = ab_tester.should_deploy(result, min_win_rate=60.0, min_trades=20)
# Not significant or not profitable enough
# Even if significant, win rate is 50% which is below 60% threshold
assert should_deploy is False
# ------------------------------------------------------------------
# PerformanceTracker Tests
# ------------------------------------------------------------------
def test_get_strategy_metrics(db_conn: sqlite3.Connection) -> None:
"""Test getting strategy metrics."""
# Add some trades
log_trade(db_conn, "005930", "BUY", 85, "Win 1", quantity=10, price=70000, pnl=1000.0)
log_trade(db_conn, "000660", "SELL", 90, "Loss 1", quantity=5, price=100000, pnl=-500.0)
log_trade(db_conn, "035420", "BUY", 80, "Win 2", quantity=8, price=50000, pnl=800.0)
log_trade(db_conn, "005930", "HOLD", 75, "Hold", quantity=0, price=70000, pnl=0.0)
tracker = PerformanceTracker(db_path=":memory:")
# Manually set connection for testing
tracker._db_path = db_conn
# Need to use the same connection
with patch("sqlite3.connect", return_value=db_conn):
metrics = tracker.get_strategy_metrics()
assert metrics.total_trades == 4
assert metrics.wins == 2
assert metrics.losses == 1
assert metrics.holds == 1
assert metrics.win_rate == 50.0
assert metrics.total_pnl == 1300.0
def test_calculate_improvement_trend_improving(performance_tracker: PerformanceTracker) -> None:
"""Test improvement trend calculation for improving strategy."""
metrics = [
StrategyMetrics(
strategy_name="test",
period_start="2024-01-01",
period_end="2024-01-07",
total_trades=10,
wins=5,
losses=5,
holds=0,
win_rate=50.0,
avg_pnl=100.0,
total_pnl=1000.0,
best_trade=500.0,
worst_trade=-300.0,
avg_confidence=75.0,
),
StrategyMetrics(
strategy_name="test",
period_start="2024-01-08",
period_end="2024-01-14",
total_trades=10,
wins=7,
losses=3,
holds=0,
win_rate=70.0,
avg_pnl=200.0,
total_pnl=2000.0,
best_trade=600.0,
worst_trade=-200.0,
avg_confidence=80.0,
),
]
trend = performance_tracker.calculate_improvement_trend(metrics)
assert trend["trend"] == "improving"
assert trend["win_rate_change"] == 20.0
assert trend["pnl_change"] == 100.0
assert trend["confidence_change"] == 5.0
def test_calculate_improvement_trend_declining(performance_tracker: PerformanceTracker) -> None:
"""Test improvement trend calculation for declining strategy."""
metrics = [
StrategyMetrics(
strategy_name="test",
period_start="2024-01-01",
period_end="2024-01-07",
total_trades=10,
wins=7,
losses=3,
holds=0,
win_rate=70.0,
avg_pnl=200.0,
total_pnl=2000.0,
best_trade=600.0,
worst_trade=-200.0,
avg_confidence=80.0,
),
StrategyMetrics(
strategy_name="test",
period_start="2024-01-08",
period_end="2024-01-14",
total_trades=10,
wins=4,
losses=6,
holds=0,
win_rate=40.0,
avg_pnl=-50.0,
total_pnl=-500.0,
best_trade=300.0,
worst_trade=-400.0,
avg_confidence=70.0,
),
]
trend = performance_tracker.calculate_improvement_trend(metrics)
assert trend["trend"] == "declining"
assert trend["win_rate_change"] == -30.0
assert trend["pnl_change"] == -250.0
def test_calculate_improvement_trend_insufficient_data(performance_tracker: PerformanceTracker) -> None:
"""Test improvement trend with insufficient data."""
metrics = [
StrategyMetrics(
strategy_name="test",
period_start="2024-01-01",
period_end="2024-01-07",
total_trades=10,
wins=5,
losses=5,
holds=0,
win_rate=50.0,
avg_pnl=100.0,
total_pnl=1000.0,
best_trade=500.0,
worst_trade=-300.0,
avg_confidence=75.0,
)
]
trend = performance_tracker.calculate_improvement_trend(metrics)
assert trend["trend"] == "insufficient_data"
assert trend["win_rate_change"] == 0.0
assert trend["pnl_change"] == 0.0
def test_export_dashboard_json(performance_tracker: PerformanceTracker) -> None:
"""Test exporting dashboard as JSON."""
overall_metrics = StrategyMetrics(
strategy_name="test",
period_start="2024-01-01",
period_end="2024-01-31",
total_trades=100,
wins=60,
losses=40,
holds=10,
win_rate=60.0,
avg_pnl=150.0,
total_pnl=15000.0,
best_trade=1000.0,
worst_trade=-500.0,
avg_confidence=80.0,
)
dashboard = PerformanceDashboard(
generated_at=datetime.now(UTC).isoformat(),
overall_metrics=overall_metrics,
daily_metrics=[],
weekly_metrics=[],
improvement_trend={"trend": "improving", "win_rate_change": 10.0},
)
json_output = performance_tracker.export_dashboard_json(dashboard)
# Verify it's valid JSON
data = json.loads(json_output)
assert "generated_at" in data
assert "overall_metrics" in data
assert data["overall_metrics"]["total_trades"] == 100
assert data["overall_metrics"]["win_rate"] == 60.0
def test_generate_dashboard() -> None:
"""Test generating a complete dashboard."""
# Create tracker with temp database
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp_path = tmp.name
# Initialize with data
conn = init_db(tmp_path)
log_trade(conn, "005930", "BUY", 85, "Win", quantity=10, price=70000, pnl=1000.0)
log_trade(conn, "000660", "SELL", 90, "Loss", quantity=5, price=100000, pnl=-500.0)
conn.close()
tracker = PerformanceTracker(db_path=tmp_path)
dashboard = tracker.generate_dashboard()
assert isinstance(dashboard, PerformanceDashboard)
assert dashboard.overall_metrics.total_trades == 2
assert len(dashboard.daily_metrics) == 7
assert len(dashboard.weekly_metrics) == 4
assert "trend" in dashboard.improvement_trend
# Clean up
Path(tmp_path).unlink()
# ------------------------------------------------------------------
# Integration Tests
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_full_evolution_pipeline(optimizer: EvolutionOptimizer, tmp_path: Path) -> None:
"""Test the complete evolution pipeline."""
# Add losing decisions
logger = optimizer._decision_logger
id1 = logger.log_decision(
stock_code="005930",
market="KR",
exchange_code="KRX",
action="BUY",
confidence=85,
rationale="Expected growth",
context_snapshot={},
input_data={},
)
logger.update_outcome(id1, pnl=-2000.0, accuracy=0)
# Mock Gemini and subprocess
mock_response = Mock()
mock_response.text = 'return {"action": "HOLD", "confidence": 50, "rationale": "Test"}'
with patch.object(optimizer._client.aio.models, "generate_content", new=AsyncMock(return_value=mock_response)):
with patch("src.evolution.optimizer.STRATEGIES_DIR", tmp_path):
with patch("subprocess.run") as mock_run:
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
result = await optimizer.evolve()
assert result is not None
assert "title" in result
assert "branch" in result
assert "status" in result