File size: 13,125 Bytes
fc115d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
"""
Backtest Engine
Runs backtests on historical data and generates performance reports.
"""

import numpy as np
import pandas as pd
from datetime import datetime
from typing import Optional, Dict, Any, List
from pathlib import Path
import json
import logging

from src.env import CryptoTradingEnv, TechnicalIndicators
from src.brain import TradingAgent
from .data_loader import DataLoader

logger = logging.getLogger(__name__)


class BacktestEngine:
    """
    Backtesting engine for evaluating trading strategies.
    
    Runs the DRL agent on historical data and calculates
    performance metrics including Sharpe ratio.
    """
    
    def __init__(
        self,
        config: Optional[Dict] = None,
        data_loader: Optional[DataLoader] = None,
    ):
        """
        Initialize the backtest engine.
        
        Args:
            config: Configuration dictionary
            data_loader: Data loader instance
        """
        self.config = config or {}
        self.data_loader = data_loader or DataLoader()
        
        # Default backtest parameters
        self.symbol = self.config.get('symbol', 'BTC/USDT')
        self.timeframe = self.config.get('timeframe', '1h')
        self.initial_balance = self.config.get('initial_balance', 10000.0)
        
        # Results storage
        self.results: List[Dict] = []
        
    def run(
        self,
        agent: TradingAgent,
        start_date: str = '2024-01-01',
        end_date: str = '2025-01-01',
        episodes: int = 1,
    ) -> Dict[str, Any]:
        """
        Run a backtest.
        
        Args:
            agent: Trained trading agent
            start_date: Backtest start date (YYYY-MM-DD)
            end_date: Backtest end date (YYYY-MM-DD)
            episodes: Number of episodes to run
            
        Returns:
            Backtest results dictionary
        """
        logger.info(f"Starting backtest: {start_date} to {end_date}")
        
        # Load data
        df = self.data_loader.load(
            symbol=self.symbol,
            timeframe=self.timeframe,
            start_date=start_date,
            end_date=end_date,
        )
        
        if len(df) < 100:
            raise ValueError(f"Insufficient data: {len(df)} candles")
            
        logger.info(f"Loaded {len(df)} candles for backtesting")
        
        # Create environment
        env = CryptoTradingEnv(
            df=df,
            initial_balance=self.initial_balance,
            lookback_window=self.config.get('lookback_window', 30),
        )
        
        # Run episodes
        episode_results = []
        
        for episode in range(episodes):
            logger.info(f"Running episode {episode + 1}/{episodes}")
            result = self._run_episode(agent, env)
            episode_results.append(result)
            
        # Aggregate results
        aggregated = self._aggregate_results(episode_results)
        
        # Store results
        self.results.append({
            'timestamp': datetime.now().isoformat(),
            'start_date': start_date,
            'end_date': end_date,
            'episodes': episodes,
            **aggregated,
        })
        
        return aggregated
        
    def _run_episode(
        self,
        agent: TradingAgent,
        env: CryptoTradingEnv,
    ) -> Dict[str, Any]:
        """Run a single backtest episode."""
        obs, info = env.reset()
        
        total_reward = 0
        step = 0
        done = False
        
        # State for LSTM
        lstm_state = None
        
        # Tracking
        actions_taken = []
        rewards = []
        portfolio_values = []
        
        while not done:
            # Get action from agent
            action, lstm_state, confidence = agent.predict(
                obs,
                state=lstm_state,
                deterministic=True,
            )
            
            # Step environment
            obs, reward, terminated, truncated, info = env.step(action)
            done = terminated or truncated
            
            # Track
            total_reward += reward
            actions_taken.append(action)
            rewards.append(reward)
            portfolio_values.append(info['portfolio_value'])
            
            step += 1
            
        # Get episode metrics
        episode_metrics = env.get_episode_metrics()
        
        # Calculate additional metrics
        returns = np.diff(portfolio_values) / portfolio_values[:-1] if len(portfolio_values) > 1 else [0]
        
        return {
            'total_reward': total_reward,
            'steps': step,
            'final_balance': info['balance'],
            'final_portfolio_value': info['portfolio_value'],
            'total_return': (info['portfolio_value'] - self.initial_balance) / self.initial_balance,
            'sharpe_ratio': episode_metrics.get('sharpe_ratio', 0),
            'sortino_ratio': episode_metrics.get('sortino_ratio', 0),
            'max_drawdown': episode_metrics.get('max_drawdown', 0),
            'trade_count': info['trade_count'],
            'actions': actions_taken,
            'rewards': rewards,
            'portfolio_values': portfolio_values,
            'returns': returns.tolist() if hasattr(returns, 'tolist') else list(returns),
        }
        
    def _aggregate_results(self, results: List[Dict]) -> Dict[str, Any]:
        """Aggregate results from multiple episodes."""
        if not results:
            return {}
            
        # Calculate means
        metrics = [
            'total_reward', 'total_return', 'sharpe_ratio',
            'sortino_ratio', 'max_drawdown', 'trade_count'
        ]
        
        aggregated = {}
        for metric in metrics:
            values = [r.get(metric, 0) for r in results]
            aggregated[f'mean_{metric}'] = np.mean(values)
            aggregated[f'std_{metric}'] = np.std(values)
            
        # Best and worst
        returns = [r.get('total_return', 0) for r in results]
        aggregated['best_return'] = max(returns)
        aggregated['worst_return'] = min(returns)
        
        # Pass/fail based on Sharpe threshold
        mean_sharpe = aggregated['mean_sharpe_ratio']
        min_sharpe = self.config.get('min_sharpe_ratio', 0.5)
        aggregated['passed'] = mean_sharpe >= min_sharpe
        aggregated['min_sharpe_threshold'] = min_sharpe
        
        logger.info(f"Backtest complete: Sharpe={mean_sharpe:.3f}, Passed={aggregated['passed']}")
        
        return aggregated
        
    def validate_for_live_trading(
        self,
        agent: TradingAgent,
        min_sharpe: float = 0.5,
        max_drawdown: float = 0.20,
    ) -> bool:
        """
        Validate that agent meets criteria for live trading.
        
        Args:
            agent: Agent to validate
            min_sharpe: Minimum required Sharpe ratio
            max_drawdown: Maximum allowed drawdown
            
        Returns:
            True if agent passes validation
        """
        logger.info("Running validation backtest...")
        
        results = self.run(
            agent=agent,
            start_date=self.config.get('backtest_start', '2024-01-01'),
            end_date=self.config.get('backtest_end', '2025-01-01'),
            episodes=3,
        )
        
        sharpe = results.get('mean_sharpe_ratio', 0)
        drawdown = results.get('mean_max_drawdown', 1)
        
        passed = sharpe >= min_sharpe and drawdown <= max_drawdown
        
        if passed:
            logger.info(f"βœ… Validation PASSED: Sharpe={sharpe:.3f}, MaxDD={drawdown:.2%}")
        else:
            logger.warning(f"❌ Validation FAILED: Sharpe={sharpe:.3f}, MaxDD={drawdown:.2%}")
            
        return passed
        
    def generate_report(
        self,
        result: Dict[str, Any],
        save_path: Optional[str] = None,
    ) -> str:
        """
        Generate a text report from backtest results.
        
        Args:
            result: Backtest result dictionary
            save_path: Optional path to save report
            
        Returns:
            Report string
        """
        report = f"""
╔══════════════════════════════════════════════════════════════╗
β•‘                    BACKTEST REPORT                            β•‘
╠══════════════════════════════════════════════════════════════╣
β•‘ Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
β•‘ Symbol: {self.symbol}
β•‘ Timeframe: {self.timeframe}
β•‘ Initial Balance: ${self.initial_balance:,.2f}
╠══════════════════════════════════════════════════════════════╣
β•‘                    PERFORMANCE METRICS                        β•‘
╠══════════════════════════════════════════════════════════════╣
β•‘ Total Return:     {result.get('mean_total_return', 0)*100:>8.2f}% (Β±{result.get('std_total_return', 0)*100:.2f}%)
β•‘ Sharpe Ratio:     {result.get('mean_sharpe_ratio', 0):>8.3f}  (Β±{result.get('std_sharpe_ratio', 0):.3f})
β•‘ Sortino Ratio:    {result.get('mean_sortino_ratio', 0):>8.3f}  (Β±{result.get('std_sortino_ratio', 0):.3f})
β•‘ Max Drawdown:     {result.get('mean_max_drawdown', 0)*100:>8.2f}% (Β±{result.get('std_max_drawdown', 0)*100:.2f}%)
β•‘ Trade Count:      {result.get('mean_trade_count', 0):>8.0f}  (Β±{result.get('std_trade_count', 0):.0f})
╠══════════════════════════════════════════════════════════════╣
β•‘                    VALIDATION STATUS                          β•‘
╠══════════════════════════════════════════════════════════════╣
β•‘ Min Sharpe Required: {result.get('min_sharpe_threshold', 0.5):.2f}
β•‘ Status: {'βœ… PASSED' if result.get('passed', False) else '❌ FAILED'}
β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
"""
        
        if save_path:
            Path(save_path).parent.mkdir(parents=True, exist_ok=True)
            with open(save_path, 'w') as f:
                f.write(report)
            logger.info(f"Report saved to: {save_path}")
            
        return report
        
    def save_results(self, path: str = "./data/backtest_results.json"):
        """Save all results to JSON."""
        Path(path).parent.mkdir(parents=True, exist_ok=True)
        with open(path, 'w') as f:
            json.dump(self.results, f, indent=2, default=str)
        logger.info(f"Results saved to: {path}")


def main():
    """CLI entry point for backtesting."""
    import argparse
    import yaml
    
    parser = argparse.ArgumentParser(description='Run backtest')
    parser.add_argument('--symbol', default='BTC/USDT', help='Trading pair')
    parser.add_argument('--start', default='2024-01-01', help='Start date')
    parser.add_argument('--end', default='2025-01-01', help='End date')
    parser.add_argument('--model', help='Path to trained model')
    parser.add_argument('--config', default='./config/config.yaml', help='Config file')
    
    args = parser.parse_args()
    
    # Load config
    with open(args.config, 'r') as f:
        config = yaml.safe_load(f)
        
    # Setup logging
    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    )
    
    # Load data
    data_loader = DataLoader()
    df = data_loader.load(
        symbol=args.symbol,
        start_date=args.start,
        end_date=args.end,
    )
    
    # Create environment
    env = CryptoTradingEnv(
        df=df,
        initial_balance=config.get('trading', {}).get('initial_balance', 10000.0),
    )
    
    # Create or load agent
    from src.brain import TradingAgent
    agent = TradingAgent(
        env=env,
        config=config.get('model', {}),
        model_path=args.model,
    )
    
    # If no model provided, train first
    if not args.model:
        logger.info("No model provided, training new agent...")
        agent.train(total_timesteps=100000)
        
    # Run backtest
    engine = BacktestEngine(config=config)
    results = engine.run(
        agent=agent,
        start_date=args.start,
        end_date=args.end,
    )
    
    # Generate report
    report = engine.generate_report(results)
    print(report)


if __name__ == '__main__':
    main()