mcli-framework 7.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mcli-framework might be problematic. Click here for more details.
- mcli/app/chat_cmd.py +42 -0
- mcli/app/commands_cmd.py +226 -0
- mcli/app/completion_cmd.py +216 -0
- mcli/app/completion_helpers.py +288 -0
- mcli/app/cron_test_cmd.py +697 -0
- mcli/app/logs_cmd.py +419 -0
- mcli/app/main.py +492 -0
- mcli/app/model/model.py +1060 -0
- mcli/app/model_cmd.py +227 -0
- mcli/app/redis_cmd.py +269 -0
- mcli/app/video/video.py +1114 -0
- mcli/app/visual_cmd.py +303 -0
- mcli/chat/chat.py +2409 -0
- mcli/chat/command_rag.py +514 -0
- mcli/chat/enhanced_chat.py +652 -0
- mcli/chat/system_controller.py +1010 -0
- mcli/chat/system_integration.py +1016 -0
- mcli/cli.py +25 -0
- mcli/config.toml +20 -0
- mcli/lib/api/api.py +586 -0
- mcli/lib/api/daemon_client.py +203 -0
- mcli/lib/api/daemon_client_local.py +44 -0
- mcli/lib/api/daemon_decorator.py +217 -0
- mcli/lib/api/mcli_decorators.py +1032 -0
- mcli/lib/auth/auth.py +85 -0
- mcli/lib/auth/aws_manager.py +85 -0
- mcli/lib/auth/azure_manager.py +91 -0
- mcli/lib/auth/credential_manager.py +192 -0
- mcli/lib/auth/gcp_manager.py +93 -0
- mcli/lib/auth/key_manager.py +117 -0
- mcli/lib/auth/mcli_manager.py +93 -0
- mcli/lib/auth/token_manager.py +75 -0
- mcli/lib/auth/token_util.py +1011 -0
- mcli/lib/config/config.py +47 -0
- mcli/lib/discovery/__init__.py +1 -0
- mcli/lib/discovery/command_discovery.py +274 -0
- mcli/lib/erd/erd.py +1345 -0
- mcli/lib/erd/generate_graph.py +453 -0
- mcli/lib/files/files.py +76 -0
- mcli/lib/fs/fs.py +109 -0
- mcli/lib/lib.py +29 -0
- mcli/lib/logger/logger.py +611 -0
- mcli/lib/performance/optimizer.py +409 -0
- mcli/lib/performance/rust_bridge.py +502 -0
- mcli/lib/performance/uvloop_config.py +154 -0
- mcli/lib/pickles/pickles.py +50 -0
- mcli/lib/search/cached_vectorizer.py +479 -0
- mcli/lib/services/data_pipeline.py +460 -0
- mcli/lib/services/lsh_client.py +441 -0
- mcli/lib/services/redis_service.py +387 -0
- mcli/lib/shell/shell.py +137 -0
- mcli/lib/toml/toml.py +33 -0
- mcli/lib/ui/styling.py +47 -0
- mcli/lib/ui/visual_effects.py +634 -0
- mcli/lib/watcher/watcher.py +185 -0
- mcli/ml/api/app.py +215 -0
- mcli/ml/api/middleware.py +224 -0
- mcli/ml/api/routers/admin_router.py +12 -0
- mcli/ml/api/routers/auth_router.py +244 -0
- mcli/ml/api/routers/backtest_router.py +12 -0
- mcli/ml/api/routers/data_router.py +12 -0
- mcli/ml/api/routers/model_router.py +302 -0
- mcli/ml/api/routers/monitoring_router.py +12 -0
- mcli/ml/api/routers/portfolio_router.py +12 -0
- mcli/ml/api/routers/prediction_router.py +267 -0
- mcli/ml/api/routers/trade_router.py +12 -0
- mcli/ml/api/routers/websocket_router.py +76 -0
- mcli/ml/api/schemas.py +64 -0
- mcli/ml/auth/auth_manager.py +425 -0
- mcli/ml/auth/models.py +154 -0
- mcli/ml/auth/permissions.py +302 -0
- mcli/ml/backtesting/backtest_engine.py +502 -0
- mcli/ml/backtesting/performance_metrics.py +393 -0
- mcli/ml/cache.py +400 -0
- mcli/ml/cli/main.py +398 -0
- mcli/ml/config/settings.py +394 -0
- mcli/ml/configs/dvc_config.py +230 -0
- mcli/ml/configs/mlflow_config.py +131 -0
- mcli/ml/configs/mlops_manager.py +293 -0
- mcli/ml/dashboard/app.py +532 -0
- mcli/ml/dashboard/app_integrated.py +738 -0
- mcli/ml/dashboard/app_supabase.py +560 -0
- mcli/ml/dashboard/app_training.py +615 -0
- mcli/ml/dashboard/cli.py +51 -0
- mcli/ml/data_ingestion/api_connectors.py +501 -0
- mcli/ml/data_ingestion/data_pipeline.py +567 -0
- mcli/ml/data_ingestion/stream_processor.py +512 -0
- mcli/ml/database/migrations/env.py +94 -0
- mcli/ml/database/models.py +667 -0
- mcli/ml/database/session.py +200 -0
- mcli/ml/experimentation/ab_testing.py +845 -0
- mcli/ml/features/ensemble_features.py +607 -0
- mcli/ml/features/political_features.py +676 -0
- mcli/ml/features/recommendation_engine.py +809 -0
- mcli/ml/features/stock_features.py +573 -0
- mcli/ml/features/test_feature_engineering.py +346 -0
- mcli/ml/logging.py +85 -0
- mcli/ml/mlops/data_versioning.py +518 -0
- mcli/ml/mlops/experiment_tracker.py +377 -0
- mcli/ml/mlops/model_serving.py +481 -0
- mcli/ml/mlops/pipeline_orchestrator.py +614 -0
- mcli/ml/models/base_models.py +324 -0
- mcli/ml/models/ensemble_models.py +675 -0
- mcli/ml/models/recommendation_models.py +474 -0
- mcli/ml/models/test_models.py +487 -0
- mcli/ml/monitoring/drift_detection.py +676 -0
- mcli/ml/monitoring/metrics.py +45 -0
- mcli/ml/optimization/portfolio_optimizer.py +834 -0
- mcli/ml/preprocessing/data_cleaners.py +451 -0
- mcli/ml/preprocessing/feature_extractors.py +491 -0
- mcli/ml/preprocessing/ml_pipeline.py +382 -0
- mcli/ml/preprocessing/politician_trading_preprocessor.py +569 -0
- mcli/ml/preprocessing/test_preprocessing.py +294 -0
- mcli/ml/scripts/populate_sample_data.py +200 -0
- mcli/ml/tasks.py +400 -0
- mcli/ml/tests/test_integration.py +429 -0
- mcli/ml/tests/test_training_dashboard.py +387 -0
- mcli/public/oi/oi.py +15 -0
- mcli/public/public.py +4 -0
- mcli/self/self_cmd.py +1246 -0
- mcli/workflow/daemon/api_daemon.py +800 -0
- mcli/workflow/daemon/async_command_database.py +681 -0
- mcli/workflow/daemon/async_process_manager.py +591 -0
- mcli/workflow/daemon/client.py +530 -0
- mcli/workflow/daemon/commands.py +1196 -0
- mcli/workflow/daemon/daemon.py +905 -0
- mcli/workflow/daemon/daemon_api.py +59 -0
- mcli/workflow/daemon/enhanced_daemon.py +571 -0
- mcli/workflow/daemon/process_cli.py +244 -0
- mcli/workflow/daemon/process_manager.py +439 -0
- mcli/workflow/daemon/test_daemon.py +275 -0
- mcli/workflow/dashboard/dashboard_cmd.py +113 -0
- mcli/workflow/docker/docker.py +0 -0
- mcli/workflow/file/file.py +100 -0
- mcli/workflow/gcloud/config.toml +21 -0
- mcli/workflow/gcloud/gcloud.py +58 -0
- mcli/workflow/git_commit/ai_service.py +328 -0
- mcli/workflow/git_commit/commands.py +430 -0
- mcli/workflow/lsh_integration.py +355 -0
- mcli/workflow/model_service/client.py +594 -0
- mcli/workflow/model_service/download_and_run_efficient_models.py +288 -0
- mcli/workflow/model_service/lightweight_embedder.py +397 -0
- mcli/workflow/model_service/lightweight_model_server.py +714 -0
- mcli/workflow/model_service/lightweight_test.py +241 -0
- mcli/workflow/model_service/model_service.py +1955 -0
- mcli/workflow/model_service/ollama_efficient_runner.py +425 -0
- mcli/workflow/model_service/pdf_processor.py +386 -0
- mcli/workflow/model_service/test_efficient_runner.py +234 -0
- mcli/workflow/model_service/test_example.py +315 -0
- mcli/workflow/model_service/test_integration.py +131 -0
- mcli/workflow/model_service/test_new_features.py +149 -0
- mcli/workflow/openai/openai.py +99 -0
- mcli/workflow/politician_trading/commands.py +1790 -0
- mcli/workflow/politician_trading/config.py +134 -0
- mcli/workflow/politician_trading/connectivity.py +490 -0
- mcli/workflow/politician_trading/data_sources.py +395 -0
- mcli/workflow/politician_trading/database.py +410 -0
- mcli/workflow/politician_trading/demo.py +248 -0
- mcli/workflow/politician_trading/models.py +165 -0
- mcli/workflow/politician_trading/monitoring.py +413 -0
- mcli/workflow/politician_trading/scrapers.py +966 -0
- mcli/workflow/politician_trading/scrapers_california.py +412 -0
- mcli/workflow/politician_trading/scrapers_eu.py +377 -0
- mcli/workflow/politician_trading/scrapers_uk.py +350 -0
- mcli/workflow/politician_trading/scrapers_us_states.py +438 -0
- mcli/workflow/politician_trading/supabase_functions.py +354 -0
- mcli/workflow/politician_trading/workflow.py +852 -0
- mcli/workflow/registry/registry.py +180 -0
- mcli/workflow/repo/repo.py +223 -0
- mcli/workflow/scheduler/commands.py +493 -0
- mcli/workflow/scheduler/cron_parser.py +238 -0
- mcli/workflow/scheduler/job.py +182 -0
- mcli/workflow/scheduler/monitor.py +139 -0
- mcli/workflow/scheduler/persistence.py +324 -0
- mcli/workflow/scheduler/scheduler.py +679 -0
- mcli/workflow/sync/sync_cmd.py +437 -0
- mcli/workflow/sync/test_cmd.py +314 -0
- mcli/workflow/videos/videos.py +242 -0
- mcli/workflow/wakatime/wakatime.py +11 -0
- mcli/workflow/workflow.py +37 -0
- mcli_framework-7.0.0.dist-info/METADATA +479 -0
- mcli_framework-7.0.0.dist-info/RECORD +186 -0
- mcli_framework-7.0.0.dist-info/WHEEL +5 -0
- mcli_framework-7.0.0.dist-info/entry_points.txt +7 -0
- mcli_framework-7.0.0.dist-info/licenses/LICENSE +21 -0
- mcli_framework-7.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Backtesting engine for trading strategies"""
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
import numpy as np
|
|
5
|
+
from typing import Dict, Any, Optional, List, Callable, Union, Tuple
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from enum import Enum
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
import os
|
|
15
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
|
|
16
|
+
|
|
17
|
+
from ml.models.recommendation_models import StockRecommendationModel, PortfolioRecommendation
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OrderType(Enum):
|
|
23
|
+
"""Order types"""
|
|
24
|
+
MARKET = "market"
|
|
25
|
+
LIMIT = "limit"
|
|
26
|
+
STOP = "stop"
|
|
27
|
+
STOP_LIMIT = "stop_limit"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OrderSide(Enum):
|
|
31
|
+
"""Order side"""
|
|
32
|
+
BUY = "buy"
|
|
33
|
+
SELL = "sell"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class BacktestConfig:
|
|
38
|
+
"""Backtesting configuration"""
|
|
39
|
+
initial_capital: float = 100000.0
|
|
40
|
+
start_date: Optional[datetime] = None
|
|
41
|
+
end_date: Optional[datetime] = None
|
|
42
|
+
commission: float = 0.001 # 0.1%
|
|
43
|
+
slippage: float = 0.001 # 0.1%
|
|
44
|
+
max_positions: int = 20
|
|
45
|
+
position_sizing: str = "equal" # equal, kelly, risk_parity
|
|
46
|
+
rebalance_frequency: str = "weekly" # daily, weekly, monthly
|
|
47
|
+
stop_loss: Optional[float] = 0.1 # 10% stop loss
|
|
48
|
+
take_profit: Optional[float] = 0.2 # 20% take profit
|
|
49
|
+
benchmark: str = "SPY"
|
|
50
|
+
risk_free_rate: float = 0.02 # 2% annual
|
|
51
|
+
verbose: bool = True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class BacktestResult:
|
|
56
|
+
"""Backtesting results"""
|
|
57
|
+
portfolio_value: pd.Series
|
|
58
|
+
returns: pd.Series
|
|
59
|
+
positions: pd.DataFrame
|
|
60
|
+
trades: pd.DataFrame
|
|
61
|
+
metrics: Dict[str, float]
|
|
62
|
+
benchmark_returns: Optional[pd.Series] = None
|
|
63
|
+
strategy_name: str = "strategy"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TradingStrategy:
|
|
67
|
+
"""Base trading strategy class"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, model: Optional[StockRecommendationModel] = None):
|
|
70
|
+
self.model = model
|
|
71
|
+
self.current_positions = {}
|
|
72
|
+
self.pending_orders = []
|
|
73
|
+
|
|
74
|
+
def generate_signals(self, data: pd.DataFrame,
|
|
75
|
+
current_date: datetime,
|
|
76
|
+
portfolio_value: float) -> List[Dict[str, Any]]:
|
|
77
|
+
"""Generate trading signals"""
|
|
78
|
+
signals = []
|
|
79
|
+
|
|
80
|
+
if self.model:
|
|
81
|
+
# Use ML model for predictions
|
|
82
|
+
recommendations = self._get_model_recommendations(data, current_date)
|
|
83
|
+
|
|
84
|
+
for rec in recommendations:
|
|
85
|
+
signal = {
|
|
86
|
+
"ticker": rec.ticker,
|
|
87
|
+
"action": "buy" if rec.recommendation_score > 0.6 else "sell",
|
|
88
|
+
"confidence": rec.confidence,
|
|
89
|
+
"position_size": rec.position_size,
|
|
90
|
+
"entry_price": rec.entry_price,
|
|
91
|
+
"target_price": rec.target_price,
|
|
92
|
+
"stop_loss": rec.stop_loss
|
|
93
|
+
}
|
|
94
|
+
signals.append(signal)
|
|
95
|
+
else:
|
|
96
|
+
# Simple momentum strategy as fallback
|
|
97
|
+
signals = self._momentum_strategy(data, current_date)
|
|
98
|
+
|
|
99
|
+
return signals
|
|
100
|
+
|
|
101
|
+
def _get_model_recommendations(self, data: pd.DataFrame,
|
|
102
|
+
current_date: datetime) -> List[PortfolioRecommendation]:
|
|
103
|
+
"""Get recommendations from ML model"""
|
|
104
|
+
# Filter data up to current date
|
|
105
|
+
historical_data = data[data['date'] <= current_date]
|
|
106
|
+
|
|
107
|
+
# Extract features (simplified)
|
|
108
|
+
features = historical_data.select_dtypes(include=[np.number]).values[-1:, :]
|
|
109
|
+
|
|
110
|
+
# Get unique tickers
|
|
111
|
+
tickers = historical_data['symbol'].unique()[:5] # Limit to 5 for speed
|
|
112
|
+
|
|
113
|
+
# Generate recommendations
|
|
114
|
+
try:
|
|
115
|
+
recommendations = self.model.generate_recommendations(features, tickers.tolist())
|
|
116
|
+
return recommendations
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.warning(f"Model prediction failed: {e}")
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
def _momentum_strategy(self, data: pd.DataFrame,
|
|
122
|
+
current_date: datetime) -> List[Dict[str, Any]]:
|
|
123
|
+
"""Simple momentum strategy"""
|
|
124
|
+
signals = []
|
|
125
|
+
|
|
126
|
+
# Get recent data
|
|
127
|
+
recent_data = data[data['date'] <= current_date].tail(20)
|
|
128
|
+
|
|
129
|
+
if len(recent_data) < 20:
|
|
130
|
+
return signals
|
|
131
|
+
|
|
132
|
+
# Calculate momentum for each ticker
|
|
133
|
+
for ticker in recent_data['symbol'].unique():
|
|
134
|
+
ticker_data = recent_data[recent_data['symbol'] == ticker]
|
|
135
|
+
|
|
136
|
+
if len(ticker_data) < 2:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
# Simple momentum: compare current price to 20-day average
|
|
140
|
+
current_price = ticker_data['close'].iloc[-1]
|
|
141
|
+
avg_price = ticker_data['close'].mean()
|
|
142
|
+
|
|
143
|
+
if current_price > avg_price * 1.05: # 5% above average
|
|
144
|
+
signals.append({
|
|
145
|
+
"ticker": ticker,
|
|
146
|
+
"action": "buy",
|
|
147
|
+
"confidence": 0.6,
|
|
148
|
+
"position_size": 0.05 # 5% position
|
|
149
|
+
})
|
|
150
|
+
elif current_price < avg_price * 0.95: # 5% below average
|
|
151
|
+
signals.append({
|
|
152
|
+
"ticker": ticker,
|
|
153
|
+
"action": "sell",
|
|
154
|
+
"confidence": 0.6,
|
|
155
|
+
"position_size": 0.05
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
return signals
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class PositionManager:
|
|
162
|
+
"""Manage portfolio positions"""
|
|
163
|
+
|
|
164
|
+
def __init__(self, config: BacktestConfig):
|
|
165
|
+
self.config = config
|
|
166
|
+
self.positions: Dict[str, Dict[str, Any]] = {}
|
|
167
|
+
self.cash = config.initial_capital
|
|
168
|
+
self.portfolio_value = config.initial_capital
|
|
169
|
+
|
|
170
|
+
def open_position(self, ticker: str, quantity: int, price: float,
|
|
171
|
+
date: datetime, signal: Dict[str, Any]):
|
|
172
|
+
"""Open a new position"""
|
|
173
|
+
cost = quantity * price * (1 + self.config.commission + self.config.slippage)
|
|
174
|
+
|
|
175
|
+
if cost > self.cash:
|
|
176
|
+
logger.warning(f"Insufficient cash to open position in {ticker}")
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
self.cash -= cost
|
|
180
|
+
self.positions[ticker] = {
|
|
181
|
+
"quantity": quantity,
|
|
182
|
+
"entry_price": price,
|
|
183
|
+
"entry_date": date,
|
|
184
|
+
"current_price": price,
|
|
185
|
+
"stop_loss": signal.get("stop_loss"),
|
|
186
|
+
"take_profit": signal.get("target_price"),
|
|
187
|
+
"unrealized_pnl": 0,
|
|
188
|
+
"realized_pnl": 0
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
logger.debug(f"Opened position: {ticker} - {quantity} shares @ ${price:.2f}")
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
def close_position(self, ticker: str, price: float, date: datetime) -> float:
|
|
195
|
+
"""Close a position"""
|
|
196
|
+
if ticker not in self.positions:
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
position = self.positions[ticker]
|
|
200
|
+
quantity = position["quantity"]
|
|
201
|
+
|
|
202
|
+
# Calculate PnL
|
|
203
|
+
gross_proceeds = quantity * price
|
|
204
|
+
net_proceeds = gross_proceeds * (1 - self.config.commission - self.config.slippage)
|
|
205
|
+
entry_cost = quantity * position["entry_price"]
|
|
206
|
+
|
|
207
|
+
realized_pnl = net_proceeds - entry_cost
|
|
208
|
+
self.cash += net_proceeds
|
|
209
|
+
|
|
210
|
+
logger.debug(f"Closed position: {ticker} - PnL: ${realized_pnl:.2f}")
|
|
211
|
+
|
|
212
|
+
del self.positions[ticker]
|
|
213
|
+
return realized_pnl
|
|
214
|
+
|
|
215
|
+
def update_positions(self, price_data: Dict[str, float]):
|
|
216
|
+
"""Update position prices and calculate unrealized PnL"""
|
|
217
|
+
for ticker, position in self.positions.items():
|
|
218
|
+
if ticker in price_data:
|
|
219
|
+
current_price = price_data[ticker]
|
|
220
|
+
position["current_price"] = current_price
|
|
221
|
+
|
|
222
|
+
# Calculate unrealized PnL
|
|
223
|
+
entry_cost = position["quantity"] * position["entry_price"]
|
|
224
|
+
current_value = position["quantity"] * current_price
|
|
225
|
+
position["unrealized_pnl"] = current_value - entry_cost
|
|
226
|
+
|
|
227
|
+
# Check stop loss
|
|
228
|
+
if position.get("stop_loss") and current_price <= position["stop_loss"]:
|
|
229
|
+
logger.info(f"Stop loss triggered for {ticker}")
|
|
230
|
+
return ticker, "stop_loss"
|
|
231
|
+
|
|
232
|
+
# Check take profit
|
|
233
|
+
if position.get("take_profit") and current_price >= position["take_profit"]:
|
|
234
|
+
logger.info(f"Take profit triggered for {ticker}")
|
|
235
|
+
return ticker, "take_profit"
|
|
236
|
+
|
|
237
|
+
return None, None
|
|
238
|
+
|
|
239
|
+
def get_portfolio_value(self, price_data: Dict[str, float]) -> float:
|
|
240
|
+
"""Calculate total portfolio value"""
|
|
241
|
+
positions_value = sum(
|
|
242
|
+
pos["quantity"] * price_data.get(ticker, pos["current_price"])
|
|
243
|
+
for ticker, pos in self.positions.items()
|
|
244
|
+
)
|
|
245
|
+
return self.cash + positions_value
|
|
246
|
+
|
|
247
|
+
def get_position_weights(self, price_data: Dict[str, float]) -> Dict[str, float]:
|
|
248
|
+
"""Get position weights"""
|
|
249
|
+
portfolio_value = self.get_portfolio_value(price_data)
|
|
250
|
+
weights = {}
|
|
251
|
+
|
|
252
|
+
for ticker, pos in self.positions.items():
|
|
253
|
+
position_value = pos["quantity"] * price_data.get(ticker, pos["current_price"])
|
|
254
|
+
weights[ticker] = position_value / portfolio_value
|
|
255
|
+
|
|
256
|
+
weights["cash"] = self.cash / portfolio_value
|
|
257
|
+
return weights
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class BacktestEngine:
|
|
261
|
+
"""Main backtesting engine"""
|
|
262
|
+
|
|
263
|
+
def __init__(self, config: BacktestConfig):
|
|
264
|
+
self.config = config
|
|
265
|
+
self.position_manager = PositionManager(config)
|
|
266
|
+
self.trades = []
|
|
267
|
+
self.portfolio_history = []
|
|
268
|
+
self.strategy = None
|
|
269
|
+
|
|
270
|
+
def set_strategy(self, strategy: TradingStrategy):
|
|
271
|
+
"""Set trading strategy"""
|
|
272
|
+
self.strategy = strategy
|
|
273
|
+
|
|
274
|
+
def run(self, price_data: pd.DataFrame,
|
|
275
|
+
trading_data: Optional[pd.DataFrame] = None) -> BacktestResult:
|
|
276
|
+
"""Run backtest"""
|
|
277
|
+
logger.info("Starting backtest...")
|
|
278
|
+
|
|
279
|
+
# Prepare data
|
|
280
|
+
price_data = price_data.sort_values('date')
|
|
281
|
+
|
|
282
|
+
if self.config.start_date:
|
|
283
|
+
price_data = price_data[price_data['date'] >= self.config.start_date]
|
|
284
|
+
if self.config.end_date:
|
|
285
|
+
price_data = price_data[price_data['date'] <= self.config.end_date]
|
|
286
|
+
|
|
287
|
+
# Get unique dates
|
|
288
|
+
dates = price_data['date'].unique()
|
|
289
|
+
|
|
290
|
+
# Initialize results
|
|
291
|
+
portfolio_values = []
|
|
292
|
+
daily_positions = []
|
|
293
|
+
|
|
294
|
+
# Main backtest loop
|
|
295
|
+
for i, current_date in enumerate(dates):
|
|
296
|
+
# Get current prices
|
|
297
|
+
current_prices = self._get_current_prices(price_data, current_date)
|
|
298
|
+
|
|
299
|
+
# Update positions with current prices
|
|
300
|
+
trigger_ticker, trigger_type = self.position_manager.update_positions(current_prices)
|
|
301
|
+
|
|
302
|
+
# Handle stop loss or take profit triggers
|
|
303
|
+
if trigger_ticker:
|
|
304
|
+
self._execute_exit(trigger_ticker, current_prices[trigger_ticker],
|
|
305
|
+
current_date, trigger_type)
|
|
306
|
+
|
|
307
|
+
# Generate signals (e.g., weekly rebalancing)
|
|
308
|
+
if self._should_rebalance(i, current_date):
|
|
309
|
+
signals = self.strategy.generate_signals(
|
|
310
|
+
price_data[price_data['date'] <= current_date],
|
|
311
|
+
current_date,
|
|
312
|
+
self.position_manager.get_portfolio_value(current_prices)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Execute signals
|
|
316
|
+
self._execute_signals(signals, current_prices, current_date)
|
|
317
|
+
|
|
318
|
+
# Record portfolio value
|
|
319
|
+
portfolio_value = self.position_manager.get_portfolio_value(current_prices)
|
|
320
|
+
portfolio_values.append({
|
|
321
|
+
"date": current_date,
|
|
322
|
+
"value": portfolio_value,
|
|
323
|
+
"cash": self.position_manager.cash,
|
|
324
|
+
"positions_value": portfolio_value - self.position_manager.cash
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
# Record positions
|
|
328
|
+
position_snapshot = self._get_position_snapshot(current_date, current_prices)
|
|
329
|
+
daily_positions.append(position_snapshot)
|
|
330
|
+
|
|
331
|
+
# Create results
|
|
332
|
+
portfolio_df = pd.DataFrame(portfolio_values)
|
|
333
|
+
portfolio_df.set_index('date', inplace=True)
|
|
334
|
+
|
|
335
|
+
# Calculate returns
|
|
336
|
+
portfolio_df['returns'] = portfolio_df['value'].pct_change().fillna(0)
|
|
337
|
+
|
|
338
|
+
# Calculate metrics
|
|
339
|
+
metrics = self._calculate_metrics(portfolio_df)
|
|
340
|
+
|
|
341
|
+
# Get benchmark returns if available
|
|
342
|
+
benchmark_returns = self._get_benchmark_returns(price_data, dates)
|
|
343
|
+
|
|
344
|
+
result = BacktestResult(
|
|
345
|
+
portfolio_value=portfolio_df['value'],
|
|
346
|
+
returns=portfolio_df['returns'],
|
|
347
|
+
positions=pd.DataFrame(daily_positions),
|
|
348
|
+
trades=pd.DataFrame(self.trades),
|
|
349
|
+
metrics=metrics,
|
|
350
|
+
benchmark_returns=benchmark_returns,
|
|
351
|
+
strategy_name=self.strategy.__class__.__name__
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
logger.info(f"Backtest complete. Total return: {metrics['total_return']:.2%}")
|
|
355
|
+
|
|
356
|
+
return result
|
|
357
|
+
|
|
358
|
+
def _get_current_prices(self, price_data: pd.DataFrame,
|
|
359
|
+
current_date: datetime) -> Dict[str, float]:
|
|
360
|
+
"""Get current prices for all tickers"""
|
|
361
|
+
current_data = price_data[price_data['date'] == current_date]
|
|
362
|
+
return dict(zip(current_data['symbol'], current_data['close']))
|
|
363
|
+
|
|
364
|
+
def _should_rebalance(self, day_index: int, current_date: datetime) -> bool:
|
|
365
|
+
"""Check if should rebalance portfolio"""
|
|
366
|
+
if self.config.rebalance_frequency == "daily":
|
|
367
|
+
return True
|
|
368
|
+
elif self.config.rebalance_frequency == "weekly":
|
|
369
|
+
return day_index % 5 == 0
|
|
370
|
+
elif self.config.rebalance_frequency == "monthly":
|
|
371
|
+
return day_index % 21 == 0
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
def _execute_signals(self, signals: List[Dict[str, Any]],
|
|
375
|
+
current_prices: Dict[str, float],
|
|
376
|
+
current_date: datetime):
|
|
377
|
+
"""Execute trading signals"""
|
|
378
|
+
for signal in signals:
|
|
379
|
+
ticker = signal["ticker"]
|
|
380
|
+
|
|
381
|
+
if ticker not in current_prices:
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
price = current_prices[ticker]
|
|
385
|
+
|
|
386
|
+
if signal["action"] == "buy":
|
|
387
|
+
# Calculate position size
|
|
388
|
+
portfolio_value = self.position_manager.get_portfolio_value(current_prices)
|
|
389
|
+
position_value = portfolio_value * signal.get("position_size", 0.05)
|
|
390
|
+
quantity = int(position_value / price)
|
|
391
|
+
|
|
392
|
+
if quantity > 0:
|
|
393
|
+
# Check if already have position
|
|
394
|
+
if ticker not in self.position_manager.positions:
|
|
395
|
+
# Check max positions
|
|
396
|
+
if len(self.position_manager.positions) < self.config.max_positions:
|
|
397
|
+
success = self.position_manager.open_position(
|
|
398
|
+
ticker, quantity, price, current_date, signal
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
if success:
|
|
402
|
+
self.trades.append({
|
|
403
|
+
"date": current_date,
|
|
404
|
+
"ticker": ticker,
|
|
405
|
+
"action": "buy",
|
|
406
|
+
"quantity": quantity,
|
|
407
|
+
"price": price,
|
|
408
|
+
"value": quantity * price
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
elif signal["action"] == "sell":
|
|
412
|
+
if ticker in self.position_manager.positions:
|
|
413
|
+
pnl = self.position_manager.close_position(ticker, price, current_date)
|
|
414
|
+
|
|
415
|
+
self.trades.append({
|
|
416
|
+
"date": current_date,
|
|
417
|
+
"ticker": ticker,
|
|
418
|
+
"action": "sell",
|
|
419
|
+
"quantity": self.position_manager.positions.get(ticker, {}).get("quantity", 0),
|
|
420
|
+
"price": price,
|
|
421
|
+
"pnl": pnl
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
def _execute_exit(self, ticker: str, price: float,
|
|
425
|
+
current_date: datetime, exit_type: str):
|
|
426
|
+
"""Execute position exit"""
|
|
427
|
+
if ticker in self.position_manager.positions:
|
|
428
|
+
position = self.position_manager.positions[ticker]
|
|
429
|
+
pnl = self.position_manager.close_position(ticker, price, current_date)
|
|
430
|
+
|
|
431
|
+
self.trades.append({
|
|
432
|
+
"date": current_date,
|
|
433
|
+
"ticker": ticker,
|
|
434
|
+
"action": f"sell_{exit_type}",
|
|
435
|
+
"quantity": position["quantity"],
|
|
436
|
+
"price": price,
|
|
437
|
+
"pnl": pnl
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
def _get_position_snapshot(self, date: datetime,
|
|
441
|
+
current_prices: Dict[str, float]) -> Dict[str, Any]:
|
|
442
|
+
"""Get current position snapshot"""
|
|
443
|
+
snapshot = {
|
|
444
|
+
"date": date,
|
|
445
|
+
"num_positions": len(self.position_manager.positions),
|
|
446
|
+
"cash": self.position_manager.cash,
|
|
447
|
+
"portfolio_value": self.position_manager.get_portfolio_value(current_prices)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
# Add position weights
|
|
451
|
+
weights = self.position_manager.get_position_weights(current_prices)
|
|
452
|
+
for ticker, weight in weights.items():
|
|
453
|
+
snapshot[f"weight_{ticker}"] = weight
|
|
454
|
+
|
|
455
|
+
return snapshot
|
|
456
|
+
|
|
457
|
+
def _calculate_metrics(self, portfolio_df: pd.DataFrame) -> Dict[str, float]:
|
|
458
|
+
"""Calculate performance metrics"""
|
|
459
|
+
returns = portfolio_df['returns']
|
|
460
|
+
|
|
461
|
+
# Basic metrics
|
|
462
|
+
total_return = (portfolio_df['value'].iloc[-1] / portfolio_df['value'].iloc[0]) - 1
|
|
463
|
+
annualized_return = (1 + total_return) ** (252 / len(returns)) - 1
|
|
464
|
+
|
|
465
|
+
# Risk metrics
|
|
466
|
+
volatility = returns.std() * np.sqrt(252)
|
|
467
|
+
sharpe_ratio = (annualized_return - self.config.risk_free_rate) / volatility if volatility > 0 else 0
|
|
468
|
+
|
|
469
|
+
# Drawdown
|
|
470
|
+
cumulative = (1 + returns).cumprod()
|
|
471
|
+
running_max = cumulative.expanding().max()
|
|
472
|
+
drawdown = (cumulative - running_max) / running_max
|
|
473
|
+
max_drawdown = drawdown.min()
|
|
474
|
+
|
|
475
|
+
# Win rate
|
|
476
|
+
winning_trades = len([t for t in self.trades if t.get("pnl", 0) > 0])
|
|
477
|
+
total_trades = len([t for t in self.trades if "pnl" in t])
|
|
478
|
+
win_rate = winning_trades / total_trades if total_trades > 0 else 0
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
"total_return": total_return,
|
|
482
|
+
"annualized_return": annualized_return,
|
|
483
|
+
"volatility": volatility,
|
|
484
|
+
"sharpe_ratio": sharpe_ratio,
|
|
485
|
+
"max_drawdown": max_drawdown,
|
|
486
|
+
"win_rate": win_rate,
|
|
487
|
+
"total_trades": len(self.trades),
|
|
488
|
+
"final_value": portfolio_df['value'].iloc[-1],
|
|
489
|
+
"initial_value": self.config.initial_capital
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
def _get_benchmark_returns(self, price_data: pd.DataFrame,
|
|
493
|
+
dates: np.ndarray) -> Optional[pd.Series]:
|
|
494
|
+
"""Get benchmark returns"""
|
|
495
|
+
if self.config.benchmark not in price_data['symbol'].unique():
|
|
496
|
+
return None
|
|
497
|
+
|
|
498
|
+
benchmark_data = price_data[price_data['symbol'] == self.config.benchmark]
|
|
499
|
+
benchmark_data = benchmark_data.set_index('date')['close']
|
|
500
|
+
benchmark_returns = benchmark_data.pct_change().fillna(0)
|
|
501
|
+
|
|
502
|
+
return benchmark_returns[benchmark_returns.index.isin(dates)]
|