mcli-framework 7.2.0__py3-none-any.whl → 7.3.1__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/__init__.py +160 -0
- mcli/__main__.py +14 -0
- mcli/app/__init__.py +23 -0
- mcli/app/model/__init__.py +0 -0
- mcli/app/video/__init__.py +5 -0
- mcli/chat/__init__.py +34 -0
- mcli/lib/__init__.py +0 -0
- mcli/lib/api/__init__.py +0 -0
- mcli/lib/auth/__init__.py +1 -0
- mcli/lib/config/__init__.py +1 -0
- mcli/lib/erd/__init__.py +25 -0
- mcli/lib/files/__init__.py +0 -0
- mcli/lib/fs/__init__.py +1 -0
- mcli/lib/logger/__init__.py +3 -0
- mcli/lib/performance/__init__.py +17 -0
- mcli/lib/pickles/__init__.py +1 -0
- mcli/lib/shell/__init__.py +0 -0
- mcli/lib/toml/__init__.py +1 -0
- mcli/lib/watcher/__init__.py +0 -0
- mcli/ml/__init__.py +16 -0
- mcli/ml/api/__init__.py +30 -0
- mcli/ml/api/routers/__init__.py +27 -0
- mcli/ml/api/schemas.py +2 -2
- mcli/ml/auth/__init__.py +45 -0
- mcli/ml/auth/models.py +2 -2
- mcli/ml/backtesting/__init__.py +39 -0
- mcli/ml/cli/__init__.py +5 -0
- mcli/ml/cli/main.py +1 -1
- mcli/ml/config/__init__.py +33 -0
- mcli/ml/configs/__init__.py +16 -0
- mcli/ml/dashboard/__init__.py +12 -0
- mcli/ml/dashboard/app_integrated.py +23 -6
- mcli/ml/dashboard/components/__init__.py +7 -0
- mcli/ml/dashboard/pages/__init__.py +6 -0
- mcli/ml/dashboard/pages/predictions_enhanced.py +20 -6
- mcli/ml/dashboard/pages/test_portfolio.py +373 -0
- mcli/ml/dashboard/pages/trading.py +714 -0
- mcli/ml/dashboard/utils.py +154 -0
- mcli/ml/data_ingestion/__init__.py +39 -0
- mcli/ml/database/__init__.py +47 -0
- mcli/ml/experimentation/__init__.py +29 -0
- mcli/ml/features/__init__.py +39 -0
- mcli/ml/mlops/__init__.py +33 -0
- mcli/ml/models/__init__.py +94 -0
- mcli/ml/monitoring/__init__.py +25 -0
- mcli/ml/optimization/__init__.py +27 -0
- mcli/ml/predictions/__init__.py +5 -0
- mcli/ml/preprocessing/__init__.py +28 -0
- mcli/ml/scripts/__init__.py +1 -0
- mcli/ml/trading/__init__.py +60 -0
- mcli/ml/trading/alpaca_client.py +353 -0
- mcli/ml/trading/migrations.py +164 -0
- mcli/ml/trading/models.py +418 -0
- mcli/ml/trading/paper_trading.py +326 -0
- mcli/ml/trading/risk_management.py +370 -0
- mcli/ml/trading/trading_service.py +480 -0
- mcli/ml/training/__init__.py +10 -0
- mcli/mygroup/__init__.py +3 -0
- mcli/public/__init__.py +1 -0
- mcli/public/commands/__init__.py +2 -0
- mcli/self/__init__.py +3 -0
- mcli/self/self_cmd.py +260 -0
- mcli/workflow/__init__.py +0 -0
- mcli/workflow/daemon/__init__.py +15 -0
- mcli/workflow/daemon/daemon.py +21 -3
- mcli/workflow/dashboard/__init__.py +5 -0
- mcli/workflow/docker/__init__.py +0 -0
- mcli/workflow/file/__init__.py +0 -0
- mcli/workflow/gcloud/__init__.py +1 -0
- mcli/workflow/git_commit/__init__.py +0 -0
- mcli/workflow/interview/__init__.py +0 -0
- mcli/workflow/politician_trading/__init__.py +4 -0
- mcli/workflow/registry/__init__.py +0 -0
- mcli/workflow/repo/__init__.py +0 -0
- mcli/workflow/scheduler/__init__.py +25 -0
- mcli/workflow/search/__init__.py +0 -0
- mcli/workflow/sync/__init__.py +5 -0
- mcli/workflow/videos/__init__.py +1 -0
- mcli/workflow/wakatime/__init__.py +80 -0
- {mcli_framework-7.2.0.dist-info → mcli_framework-7.3.1.dist-info}/METADATA +3 -1
- {mcli_framework-7.2.0.dist-info → mcli_framework-7.3.1.dist-info}/RECORD +85 -13
- {mcli_framework-7.2.0.dist-info → mcli_framework-7.3.1.dist-info}/WHEEL +0 -0
- {mcli_framework-7.2.0.dist-info → mcli_framework-7.3.1.dist-info}/entry_points.txt +0 -0
- {mcli_framework-7.2.0.dist-info → mcli_framework-7.3.1.dist-info}/licenses/LICENSE +0 -0
- {mcli_framework-7.2.0.dist-info → mcli_framework-7.3.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Paper trading implementation for testing portfolios without real money"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from typing import Dict, List, Optional, Tuple
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import yfinance as yf
|
|
11
|
+
from sqlalchemy.orm import Session
|
|
12
|
+
|
|
13
|
+
from mcli.ml.trading.models import (
|
|
14
|
+
Portfolio, Position, TradingOrder, OrderStatus, OrderType, OrderSide, PositionSide
|
|
15
|
+
)
|
|
16
|
+
from mcli.ml.trading.trading_service import TradingService
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PaperTradingEngine:
|
|
22
|
+
"""Paper trading engine for testing strategies without real money"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, trading_service: TradingService):
|
|
25
|
+
self.trading_service = trading_service
|
|
26
|
+
self.db = trading_service.db
|
|
27
|
+
|
|
28
|
+
def execute_paper_order(self, order: TradingOrder) -> bool:
|
|
29
|
+
"""Execute a paper trade order"""
|
|
30
|
+
try:
|
|
31
|
+
# Get current market price
|
|
32
|
+
current_price = self._get_current_price(order.symbol)
|
|
33
|
+
if current_price is None:
|
|
34
|
+
logger.error(f"Could not get current price for {order.symbol}")
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
# Calculate execution details
|
|
38
|
+
if order.order_type == OrderType.MARKET:
|
|
39
|
+
execution_price = current_price
|
|
40
|
+
elif order.order_type == OrderType.LIMIT:
|
|
41
|
+
if order.side == OrderSide.BUY and order.limit_price >= current_price:
|
|
42
|
+
execution_price = current_price
|
|
43
|
+
elif order.side == OrderSide.SELL and order.limit_price <= current_price:
|
|
44
|
+
execution_price = current_price
|
|
45
|
+
else:
|
|
46
|
+
# Order not executed
|
|
47
|
+
order.status = OrderStatus.CANCELLED
|
|
48
|
+
order.cancelled_at = datetime.utcnow()
|
|
49
|
+
self.db.commit()
|
|
50
|
+
return False
|
|
51
|
+
else:
|
|
52
|
+
logger.error(f"Unsupported order type for paper trading: {order.order_type}")
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
# Execute the order
|
|
56
|
+
order.status = OrderStatus.FILLED
|
|
57
|
+
order.filled_at = datetime.utcnow()
|
|
58
|
+
order.filled_quantity = order.quantity
|
|
59
|
+
order.remaining_quantity = 0
|
|
60
|
+
order.average_fill_price = Decimal(str(execution_price))
|
|
61
|
+
|
|
62
|
+
# Update portfolio and positions
|
|
63
|
+
self._update_portfolio_positions(order, execution_price)
|
|
64
|
+
|
|
65
|
+
self.db.commit()
|
|
66
|
+
logger.info(f"Paper trade executed: {order.symbol} {order.side.value} {order.quantity} @ ${execution_price}")
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
except Exception as e:
|
|
70
|
+
logger.error(f"Failed to execute paper order: {e}")
|
|
71
|
+
self.db.rollback()
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
def _get_current_price(self, symbol: str) -> Optional[float]:
|
|
75
|
+
"""Get current market price for a symbol"""
|
|
76
|
+
try:
|
|
77
|
+
ticker = yf.Ticker(symbol)
|
|
78
|
+
data = ticker.history(period="1d", interval="1m")
|
|
79
|
+
if not data.empty:
|
|
80
|
+
return float(data["Close"].iloc[-1])
|
|
81
|
+
return None
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error(f"Failed to get price for {symbol}: {e}")
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def _update_portfolio_positions(self, order: TradingOrder, execution_price: float):
|
|
87
|
+
"""Update portfolio positions after order execution"""
|
|
88
|
+
try:
|
|
89
|
+
portfolio = self.trading_service.get_portfolio(order.portfolio_id)
|
|
90
|
+
if not portfolio:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# Calculate trade value
|
|
94
|
+
trade_value = execution_price * order.quantity
|
|
95
|
+
|
|
96
|
+
if order.side == OrderSide.BUY:
|
|
97
|
+
# Buying shares
|
|
98
|
+
portfolio.cash_balance -= Decimal(str(trade_value))
|
|
99
|
+
|
|
100
|
+
# Update or create position
|
|
101
|
+
position = self.db.query(Position).filter(
|
|
102
|
+
Position.portfolio_id == order.portfolio_id,
|
|
103
|
+
Position.symbol == order.symbol
|
|
104
|
+
).first()
|
|
105
|
+
|
|
106
|
+
if position:
|
|
107
|
+
# Update existing position
|
|
108
|
+
total_quantity = position.quantity + order.quantity
|
|
109
|
+
total_cost = (position.cost_basis * position.quantity) + trade_value
|
|
110
|
+
new_avg_price = total_cost / total_quantity
|
|
111
|
+
|
|
112
|
+
position.quantity = total_quantity
|
|
113
|
+
position.average_price = new_avg_price
|
|
114
|
+
position.cost_basis = total_cost
|
|
115
|
+
position.current_price = Decimal(str(execution_price))
|
|
116
|
+
position.market_value = total_quantity * execution_price
|
|
117
|
+
position.unrealized_pnl = position.market_value - total_cost
|
|
118
|
+
position.unrealized_pnl_pct = float(position.unrealized_pnl / total_cost * 100)
|
|
119
|
+
else:
|
|
120
|
+
# Create new position
|
|
121
|
+
position = Position(
|
|
122
|
+
portfolio_id=order.portfolio_id,
|
|
123
|
+
symbol=order.symbol,
|
|
124
|
+
quantity=order.quantity,
|
|
125
|
+
side=PositionSide.LONG,
|
|
126
|
+
average_price=Decimal(str(execution_price)),
|
|
127
|
+
current_price=Decimal(str(execution_price)),
|
|
128
|
+
market_value=Decimal(str(trade_value)),
|
|
129
|
+
cost_basis=Decimal(str(trade_value)),
|
|
130
|
+
unrealized_pnl=Decimal("0"),
|
|
131
|
+
unrealized_pnl_pct=0.0,
|
|
132
|
+
realized_pnl=Decimal("0"),
|
|
133
|
+
position_size_pct=0.0,
|
|
134
|
+
weight=0.0,
|
|
135
|
+
)
|
|
136
|
+
self.db.add(position)
|
|
137
|
+
|
|
138
|
+
else: # SELL
|
|
139
|
+
# Selling shares
|
|
140
|
+
portfolio.cash_balance += Decimal(str(trade_value))
|
|
141
|
+
|
|
142
|
+
# Update position
|
|
143
|
+
position = self.db.query(Position).filter(
|
|
144
|
+
Position.portfolio_id == order.portfolio_id,
|
|
145
|
+
Position.symbol == order.symbol
|
|
146
|
+
).first()
|
|
147
|
+
|
|
148
|
+
if position and position.quantity >= order.quantity:
|
|
149
|
+
# Calculate realized P&L
|
|
150
|
+
cost_basis = position.cost_basis * (order.quantity / position.quantity)
|
|
151
|
+
realized_pnl = trade_value - float(cost_basis)
|
|
152
|
+
|
|
153
|
+
# Update position
|
|
154
|
+
position.quantity -= order.quantity
|
|
155
|
+
position.cost_basis -= cost_basis
|
|
156
|
+
position.realized_pnl += Decimal(str(realized_pnl))
|
|
157
|
+
|
|
158
|
+
if position.quantity == 0:
|
|
159
|
+
# Remove position if fully sold
|
|
160
|
+
self.db.delete(position)
|
|
161
|
+
else:
|
|
162
|
+
# Update remaining position
|
|
163
|
+
position.current_price = Decimal(str(execution_price))
|
|
164
|
+
position.market_value = position.quantity * execution_price
|
|
165
|
+
position.unrealized_pnl = position.market_value - position.cost_basis
|
|
166
|
+
if position.cost_basis > 0:
|
|
167
|
+
position.unrealized_pnl_pct = float(position.unrealized_pnl / position.cost_basis * 100)
|
|
168
|
+
|
|
169
|
+
# Update portfolio value
|
|
170
|
+
self._update_portfolio_value(portfolio)
|
|
171
|
+
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.error(f"Failed to update portfolio positions: {e}")
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
def _update_portfolio_value(self, portfolio: Portfolio):
|
|
177
|
+
"""Update portfolio value and metrics"""
|
|
178
|
+
try:
|
|
179
|
+
# Get all positions
|
|
180
|
+
positions = self.db.query(Position).filter(Position.portfolio_id == portfolio.id).all()
|
|
181
|
+
|
|
182
|
+
# Calculate total market value
|
|
183
|
+
total_market_value = sum(float(pos.market_value) for pos in positions)
|
|
184
|
+
portfolio.current_value = Decimal(str(total_market_value + float(portfolio.cash_balance)))
|
|
185
|
+
|
|
186
|
+
# Calculate returns
|
|
187
|
+
if portfolio.initial_capital > 0:
|
|
188
|
+
total_return = portfolio.current_value - portfolio.initial_capital
|
|
189
|
+
portfolio.total_return = float(total_return)
|
|
190
|
+
portfolio.total_return_pct = float(total_return / portfolio.initial_capital * 100)
|
|
191
|
+
|
|
192
|
+
# Update position weights
|
|
193
|
+
for position in positions:
|
|
194
|
+
if portfolio.current_value > 0:
|
|
195
|
+
position.weight = float(position.market_value / portfolio.current_value)
|
|
196
|
+
position.position_size_pct = position.weight * 100
|
|
197
|
+
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(f"Failed to update portfolio value: {e}")
|
|
200
|
+
raise
|
|
201
|
+
|
|
202
|
+
def simulate_market_movement(self, portfolio_id: UUID, days: int = 1):
|
|
203
|
+
"""Simulate market movement for paper trading"""
|
|
204
|
+
try:
|
|
205
|
+
portfolio = self.trading_service.get_portfolio(portfolio_id)
|
|
206
|
+
if not portfolio:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
positions = self.db.query(Position).filter(Position.portfolio_id == portfolio_id).all()
|
|
210
|
+
|
|
211
|
+
for position in positions:
|
|
212
|
+
# Get historical data for the symbol
|
|
213
|
+
ticker = yf.Ticker(position.symbol)
|
|
214
|
+
end_date = datetime.now()
|
|
215
|
+
start_date = end_date - timedelta(days=days + 5) # Get extra days for weekend handling
|
|
216
|
+
|
|
217
|
+
data = ticker.history(start=start_date, end=end_date)
|
|
218
|
+
if not data.empty:
|
|
219
|
+
# Get the most recent price
|
|
220
|
+
new_price = float(data["Close"].iloc[-1])
|
|
221
|
+
|
|
222
|
+
# Update position
|
|
223
|
+
position.current_price = Decimal(str(new_price))
|
|
224
|
+
position.market_value = position.quantity * new_price
|
|
225
|
+
position.unrealized_pnl = position.market_value - position.cost_basis
|
|
226
|
+
if position.cost_basis > 0:
|
|
227
|
+
position.unrealized_pnl_pct = float(position.unrealized_pnl / position.cost_basis * 100)
|
|
228
|
+
|
|
229
|
+
# Update portfolio value
|
|
230
|
+
self._update_portfolio_value(portfolio)
|
|
231
|
+
self.db.commit()
|
|
232
|
+
|
|
233
|
+
logger.info(f"Simulated market movement for portfolio {portfolio_id}")
|
|
234
|
+
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.error(f"Failed to simulate market movement: {e}")
|
|
237
|
+
self.db.rollback()
|
|
238
|
+
|
|
239
|
+
def create_test_portfolio(
|
|
240
|
+
self,
|
|
241
|
+
user_id: UUID,
|
|
242
|
+
name: str = "Test Portfolio",
|
|
243
|
+
initial_capital: float = 100000.0
|
|
244
|
+
) -> Portfolio:
|
|
245
|
+
"""Create a test portfolio for paper trading"""
|
|
246
|
+
try:
|
|
247
|
+
# Create trading account
|
|
248
|
+
from mcli.ml.trading.models import TradingAccountCreate
|
|
249
|
+
account_data = TradingAccountCreate(
|
|
250
|
+
account_name="Test Account",
|
|
251
|
+
account_type="test",
|
|
252
|
+
paper_trading=True
|
|
253
|
+
)
|
|
254
|
+
account = self.trading_service.create_trading_account(user_id, account_data)
|
|
255
|
+
|
|
256
|
+
# Create portfolio
|
|
257
|
+
portfolio_data = PortfolioCreate(
|
|
258
|
+
name=name,
|
|
259
|
+
description="Test portfolio for paper trading",
|
|
260
|
+
initial_capital=initial_capital
|
|
261
|
+
)
|
|
262
|
+
portfolio = self.trading_service.create_portfolio(account.id, portfolio_data)
|
|
263
|
+
|
|
264
|
+
logger.info(f"Created test portfolio {portfolio.id} for user {user_id}")
|
|
265
|
+
return portfolio
|
|
266
|
+
|
|
267
|
+
except Exception as e:
|
|
268
|
+
logger.error(f"Failed to create test portfolio: {e}")
|
|
269
|
+
raise
|
|
270
|
+
|
|
271
|
+
def run_backtest(
|
|
272
|
+
self,
|
|
273
|
+
portfolio_id: UUID,
|
|
274
|
+
start_date: datetime,
|
|
275
|
+
end_date: datetime,
|
|
276
|
+
initial_capital: float = 100000.0
|
|
277
|
+
) -> Dict:
|
|
278
|
+
"""Run a backtest on historical data"""
|
|
279
|
+
try:
|
|
280
|
+
portfolio = self.trading_service.get_portfolio(portfolio_id)
|
|
281
|
+
if not portfolio:
|
|
282
|
+
raise ValueError("Portfolio not found")
|
|
283
|
+
|
|
284
|
+
# Reset portfolio to initial state
|
|
285
|
+
portfolio.current_value = Decimal(str(initial_capital))
|
|
286
|
+
portfolio.cash_balance = Decimal(str(initial_capital))
|
|
287
|
+
portfolio.total_return = 0.0
|
|
288
|
+
portfolio.total_return_pct = 0.0
|
|
289
|
+
|
|
290
|
+
# Clear existing positions
|
|
291
|
+
self.db.query(Position).filter(Position.portfolio_id == portfolio_id).delete()
|
|
292
|
+
|
|
293
|
+
# Get historical data for the period
|
|
294
|
+
date_range = pd.date_range(start=start_date, end=end_date, freq='D')
|
|
295
|
+
|
|
296
|
+
# This is a simplified backtest - in practice you'd want to:
|
|
297
|
+
# 1. Get historical signals
|
|
298
|
+
# 2. Execute trades based on signals
|
|
299
|
+
# 3. Track performance over time
|
|
300
|
+
|
|
301
|
+
backtest_results = {
|
|
302
|
+
"start_date": start_date,
|
|
303
|
+
"end_date": end_date,
|
|
304
|
+
"initial_capital": initial_capital,
|
|
305
|
+
"final_value": float(portfolio.current_value),
|
|
306
|
+
"total_return": float(portfolio.total_return),
|
|
307
|
+
"total_return_pct": portfolio.total_return_pct,
|
|
308
|
+
"trades_executed": 0,
|
|
309
|
+
"max_drawdown": 0.0,
|
|
310
|
+
"sharpe_ratio": 0.0,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
self.db.commit()
|
|
314
|
+
logger.info(f"Backtest completed for portfolio {portfolio_id}")
|
|
315
|
+
return backtest_results
|
|
316
|
+
|
|
317
|
+
except Exception as e:
|
|
318
|
+
logger.error(f"Failed to run backtest: {e}")
|
|
319
|
+
self.db.rollback()
|
|
320
|
+
raise
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def create_paper_trading_engine(db_session: Session) -> PaperTradingEngine:
|
|
324
|
+
"""Create a paper trading engine"""
|
|
325
|
+
trading_service = TradingService(db_session)
|
|
326
|
+
return PaperTradingEngine(trading_service)
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Risk management module for trading portfolios"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from typing import Dict, List, Optional, Tuple
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from sqlalchemy.orm import Session
|
|
11
|
+
|
|
12
|
+
from mcli.ml.trading.models import Portfolio, Position, TradingOrder, RiskLevel
|
|
13
|
+
from mcli.ml.trading.trading_service import TradingService
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RiskManager:
|
|
19
|
+
"""Risk management system for trading portfolios"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, trading_service: TradingService):
|
|
22
|
+
self.trading_service = trading_service
|
|
23
|
+
self.db = trading_service.db
|
|
24
|
+
|
|
25
|
+
def calculate_portfolio_risk(self, portfolio_id: UUID) -> Dict:
|
|
26
|
+
"""Calculate comprehensive risk metrics for a portfolio"""
|
|
27
|
+
try:
|
|
28
|
+
portfolio = self.trading_service.get_portfolio(portfolio_id)
|
|
29
|
+
if not portfolio:
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
positions = self.trading_service.get_portfolio_positions(portfolio_id)
|
|
33
|
+
|
|
34
|
+
if not positions:
|
|
35
|
+
return {
|
|
36
|
+
"total_risk": 0.0,
|
|
37
|
+
"concentration_risk": 0.0,
|
|
38
|
+
"var_95": 0.0,
|
|
39
|
+
"cvar_95": 0.0,
|
|
40
|
+
"max_position_risk": 0.0,
|
|
41
|
+
"portfolio_beta": 1.0,
|
|
42
|
+
"risk_score": 0.0,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Calculate individual position risks
|
|
46
|
+
position_risks = []
|
|
47
|
+
total_market_value = sum(pos.market_value for pos in positions)
|
|
48
|
+
|
|
49
|
+
for position in positions:
|
|
50
|
+
position_risk = self._calculate_position_risk(position, total_market_value)
|
|
51
|
+
position_risks.append(position_risk)
|
|
52
|
+
|
|
53
|
+
# Calculate portfolio-level risk metrics
|
|
54
|
+
portfolio_risk = self._calculate_portfolio_risk_metrics(positions, position_risks)
|
|
55
|
+
|
|
56
|
+
return portfolio_risk
|
|
57
|
+
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.error(f"Failed to calculate portfolio risk: {e}")
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
def _calculate_position_risk(self, position: Position, total_market_value: float) -> Dict:
|
|
63
|
+
"""Calculate risk metrics for an individual position"""
|
|
64
|
+
try:
|
|
65
|
+
# Position size as percentage of portfolio
|
|
66
|
+
position_size_pct = (position.market_value / total_market_value) * 100
|
|
67
|
+
|
|
68
|
+
# Volatility estimate (simplified - in practice, use historical data)
|
|
69
|
+
volatility = self._estimate_volatility(position.symbol)
|
|
70
|
+
|
|
71
|
+
# Value at Risk (simplified calculation)
|
|
72
|
+
var_95 = position.market_value * volatility * 1.645 # 95% VaR
|
|
73
|
+
|
|
74
|
+
# Position risk score (combination of size and volatility)
|
|
75
|
+
risk_score = position_size_pct * volatility * 100
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
"symbol": position.symbol,
|
|
79
|
+
"position_size_pct": position_size_pct,
|
|
80
|
+
"volatility": volatility,
|
|
81
|
+
"var_95": var_95,
|
|
82
|
+
"risk_score": risk_score,
|
|
83
|
+
"market_value": position.market_value,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.error(f"Failed to calculate position risk for {position.symbol}: {e}")
|
|
88
|
+
return {}
|
|
89
|
+
|
|
90
|
+
def _estimate_volatility(self, symbol: str, days: int = 30) -> float:
|
|
91
|
+
"""Estimate volatility for a symbol (simplified)"""
|
|
92
|
+
try:
|
|
93
|
+
# In practice, you would calculate historical volatility
|
|
94
|
+
# For now, use a simplified approach based on market cap and sector
|
|
95
|
+
import yfinance as yf
|
|
96
|
+
|
|
97
|
+
ticker = yf.Ticker(symbol)
|
|
98
|
+
data = ticker.history(period=f"{days}d")
|
|
99
|
+
|
|
100
|
+
if not data.empty and len(data) > 1:
|
|
101
|
+
returns = data["Close"].pct_change().dropna()
|
|
102
|
+
volatility = returns.std() * np.sqrt(252) # Annualized
|
|
103
|
+
return min(volatility, 1.0) # Cap at 100%
|
|
104
|
+
|
|
105
|
+
# Default volatility based on symbol characteristics
|
|
106
|
+
if symbol in ["AAPL", "MSFT", "GOOGL", "AMZN"]:
|
|
107
|
+
return 0.25 # Large cap tech
|
|
108
|
+
elif symbol in ["TSLA", "NVDA", "AMD"]:
|
|
109
|
+
return 0.45 # High volatility tech
|
|
110
|
+
else:
|
|
111
|
+
return 0.30 # Default
|
|
112
|
+
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.error(f"Failed to estimate volatility for {symbol}: {e}")
|
|
115
|
+
return 0.30 # Default volatility
|
|
116
|
+
|
|
117
|
+
def _calculate_portfolio_risk_metrics(self, positions: List, position_risks: List[Dict]) -> Dict:
|
|
118
|
+
"""Calculate portfolio-level risk metrics"""
|
|
119
|
+
try:
|
|
120
|
+
if not positions or not position_risks:
|
|
121
|
+
return {}
|
|
122
|
+
|
|
123
|
+
# Total portfolio risk (sum of individual position risks)
|
|
124
|
+
total_risk = sum(risk["risk_score"] for risk in position_risks)
|
|
125
|
+
|
|
126
|
+
# Concentration risk (Herfindahl-Hirschman Index)
|
|
127
|
+
weights = [pos.weight for pos in positions]
|
|
128
|
+
concentration_risk = sum(w**2 for w in weights) * 100
|
|
129
|
+
|
|
130
|
+
# Portfolio Value at Risk (simplified)
|
|
131
|
+
portfolio_var = sum(risk["var_95"] for risk in position_risks)
|
|
132
|
+
|
|
133
|
+
# Conditional Value at Risk (simplified)
|
|
134
|
+
portfolio_cvar = portfolio_var * 1.3 # Rough approximation
|
|
135
|
+
|
|
136
|
+
# Maximum position risk
|
|
137
|
+
max_position_risk = max(risk["risk_score"] for risk in position_risks) if position_risks else 0
|
|
138
|
+
|
|
139
|
+
# Portfolio beta (simplified - assume equal weight)
|
|
140
|
+
portfolio_beta = np.mean([self._estimate_beta(pos.symbol) for pos in positions])
|
|
141
|
+
|
|
142
|
+
# Overall risk score (0-100)
|
|
143
|
+
risk_score = min(total_risk + concentration_risk, 100)
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"total_risk": total_risk,
|
|
147
|
+
"concentration_risk": concentration_risk,
|
|
148
|
+
"var_95": portfolio_var,
|
|
149
|
+
"cvar_95": portfolio_cvar,
|
|
150
|
+
"max_position_risk": max_position_risk,
|
|
151
|
+
"portfolio_beta": portfolio_beta,
|
|
152
|
+
"risk_score": risk_score,
|
|
153
|
+
"num_positions": len(positions),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.error(f"Failed to calculate portfolio risk metrics: {e}")
|
|
158
|
+
return {}
|
|
159
|
+
|
|
160
|
+
def _estimate_beta(self, symbol: str) -> float:
|
|
161
|
+
"""Estimate beta for a symbol (simplified)"""
|
|
162
|
+
try:
|
|
163
|
+
# In practice, calculate beta against market index
|
|
164
|
+
# For now, use simplified estimates
|
|
165
|
+
beta_estimates = {
|
|
166
|
+
"AAPL": 1.2, "MSFT": 1.1, "GOOGL": 1.3, "AMZN": 1.4,
|
|
167
|
+
"TSLA": 2.0, "NVDA": 1.8, "AMD": 1.6, "META": 1.5,
|
|
168
|
+
"JPM": 1.3, "BAC": 1.4, "WMT": 0.5, "JNJ": 0.7,
|
|
169
|
+
}
|
|
170
|
+
return beta_estimates.get(symbol, 1.0)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.error(f"Failed to estimate beta for {symbol}: {e}")
|
|
173
|
+
return 1.0
|
|
174
|
+
|
|
175
|
+
def check_risk_limits(self, portfolio_id: UUID, new_order: Dict) -> Tuple[bool, List[str]]:
|
|
176
|
+
"""Check if a new order would violate risk limits"""
|
|
177
|
+
try:
|
|
178
|
+
portfolio = self.trading_service.get_portfolio(portfolio_id)
|
|
179
|
+
if not portfolio:
|
|
180
|
+
return False, ["Portfolio not found"]
|
|
181
|
+
|
|
182
|
+
warnings = []
|
|
183
|
+
|
|
184
|
+
# Get current positions
|
|
185
|
+
positions = self.trading_service.get_portfolio_positions(portfolio_id)
|
|
186
|
+
|
|
187
|
+
# Calculate new position size
|
|
188
|
+
symbol = new_order["symbol"]
|
|
189
|
+
quantity = new_order["quantity"]
|
|
190
|
+
side = new_order["side"]
|
|
191
|
+
|
|
192
|
+
# Estimate order value (simplified)
|
|
193
|
+
order_value = self._estimate_order_value(symbol, quantity)
|
|
194
|
+
new_position_size_pct = (order_value / float(portfolio.current_value)) * 100
|
|
195
|
+
|
|
196
|
+
# Check position size limits
|
|
197
|
+
max_position_size = 10.0 # 10% default
|
|
198
|
+
if new_position_size_pct > max_position_size:
|
|
199
|
+
warnings.append(f"Position size ({new_position_size_pct:.1f}%) exceeds limit ({max_position_size}%)")
|
|
200
|
+
|
|
201
|
+
# Check if adding to existing position
|
|
202
|
+
existing_position = next((pos for pos in positions if pos.symbol == symbol), None)
|
|
203
|
+
if existing_position:
|
|
204
|
+
if side == "buy":
|
|
205
|
+
new_total_size = ((existing_position.market_value + order_value) / float(portfolio.current_value)) * 100
|
|
206
|
+
if new_total_size > max_position_size:
|
|
207
|
+
warnings.append(f"Total position size ({new_total_size:.1f}%) would exceed limit")
|
|
208
|
+
|
|
209
|
+
# Check portfolio concentration
|
|
210
|
+
if len(positions) >= 10: # Max 10 positions
|
|
211
|
+
warnings.append("Portfolio already has maximum number of positions")
|
|
212
|
+
|
|
213
|
+
# Check buying power
|
|
214
|
+
if side == "buy" and order_value > float(portfolio.cash_balance):
|
|
215
|
+
warnings.append("Insufficient buying power for this order")
|
|
216
|
+
|
|
217
|
+
# Check risk level compliance
|
|
218
|
+
risk_level = getattr(portfolio, 'risk_level', RiskLevel.MODERATE)
|
|
219
|
+
if risk_level == RiskLevel.CONSERVATIVE and new_position_size_pct > 5.0:
|
|
220
|
+
warnings.append("Position size too large for conservative risk level")
|
|
221
|
+
elif risk_level == RiskLevel.AGGRESSIVE and new_position_size_pct > 20.0:
|
|
222
|
+
warnings.append("Position size too large for aggressive risk level")
|
|
223
|
+
|
|
224
|
+
return len(warnings) == 0, warnings
|
|
225
|
+
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.error(f"Failed to check risk limits: {e}")
|
|
228
|
+
return False, [f"Risk check failed: {e}"]
|
|
229
|
+
|
|
230
|
+
def _estimate_order_value(self, symbol: str, quantity: int) -> float:
|
|
231
|
+
"""Estimate the value of an order"""
|
|
232
|
+
try:
|
|
233
|
+
import yfinance as yf
|
|
234
|
+
ticker = yf.Ticker(symbol)
|
|
235
|
+
data = ticker.history(period="1d")
|
|
236
|
+
if not data.empty:
|
|
237
|
+
price = float(data["Close"].iloc[-1])
|
|
238
|
+
return price * quantity
|
|
239
|
+
return 0.0
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.error(f"Failed to estimate order value for {symbol}: {e}")
|
|
242
|
+
return 0.0
|
|
243
|
+
|
|
244
|
+
def calculate_position_size(
|
|
245
|
+
self,
|
|
246
|
+
portfolio_id: UUID,
|
|
247
|
+
symbol: str,
|
|
248
|
+
signal_strength: float,
|
|
249
|
+
risk_level: RiskLevel = RiskLevel.MODERATE
|
|
250
|
+
) -> float:
|
|
251
|
+
"""Calculate recommended position size based on signal strength and risk level"""
|
|
252
|
+
try:
|
|
253
|
+
portfolio = self.trading_service.get_portfolio(portfolio_id)
|
|
254
|
+
if not portfolio:
|
|
255
|
+
return 0.0
|
|
256
|
+
|
|
257
|
+
# Base position size based on risk level
|
|
258
|
+
base_sizes = {
|
|
259
|
+
RiskLevel.CONSERVATIVE: 0.02, # 2%
|
|
260
|
+
RiskLevel.MODERATE: 0.05, # 5%
|
|
261
|
+
RiskLevel.AGGRESSIVE: 0.10, # 10%
|
|
262
|
+
}
|
|
263
|
+
base_size = base_sizes.get(risk_level, 0.05)
|
|
264
|
+
|
|
265
|
+
# Adjust based on signal strength
|
|
266
|
+
signal_multiplier = min(signal_strength * 2, 2.0) # Cap at 2x
|
|
267
|
+
|
|
268
|
+
# Calculate recommended position size
|
|
269
|
+
recommended_size = base_size * signal_multiplier
|
|
270
|
+
|
|
271
|
+
# Cap at maximum position size
|
|
272
|
+
max_position_size = 0.20 # 20% max
|
|
273
|
+
recommended_size = min(recommended_size, max_position_size)
|
|
274
|
+
|
|
275
|
+
# Convert to dollar amount
|
|
276
|
+
position_value = float(portfolio.current_value) * recommended_size
|
|
277
|
+
|
|
278
|
+
return position_value
|
|
279
|
+
|
|
280
|
+
except Exception as e:
|
|
281
|
+
logger.error(f"Failed to calculate position size: {e}")
|
|
282
|
+
return 0.0
|
|
283
|
+
|
|
284
|
+
def generate_risk_report(self, portfolio_id: UUID) -> Dict:
|
|
285
|
+
"""Generate comprehensive risk report for a portfolio"""
|
|
286
|
+
try:
|
|
287
|
+
portfolio = self.trading_service.get_portfolio(portfolio_id)
|
|
288
|
+
if not portfolio:
|
|
289
|
+
return {}
|
|
290
|
+
|
|
291
|
+
# Get risk metrics
|
|
292
|
+
risk_metrics = self.calculate_portfolio_risk(portfolio_id)
|
|
293
|
+
|
|
294
|
+
# Get performance data
|
|
295
|
+
performance_df = self.trading_service.get_portfolio_performance(portfolio_id, days=30)
|
|
296
|
+
|
|
297
|
+
# Calculate additional metrics
|
|
298
|
+
max_drawdown = 0.0
|
|
299
|
+
if not performance_df.empty:
|
|
300
|
+
cumulative_returns = (1 + performance_df["daily_return_pct"] / 100).cumprod()
|
|
301
|
+
running_max = cumulative_returns.expanding().max()
|
|
302
|
+
drawdown = (cumulative_returns - running_max) / running_max
|
|
303
|
+
max_drawdown = abs(drawdown.min()) * 100
|
|
304
|
+
|
|
305
|
+
# Risk assessment
|
|
306
|
+
risk_level = "LOW"
|
|
307
|
+
if risk_metrics.get("risk_score", 0) > 70:
|
|
308
|
+
risk_level = "HIGH"
|
|
309
|
+
elif risk_metrics.get("risk_score", 0) > 40:
|
|
310
|
+
risk_level = "MEDIUM"
|
|
311
|
+
|
|
312
|
+
# Generate recommendations
|
|
313
|
+
recommendations = self._generate_risk_recommendations(risk_metrics, max_drawdown)
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
"portfolio_id": str(portfolio_id),
|
|
317
|
+
"risk_level": risk_level,
|
|
318
|
+
"risk_score": risk_metrics.get("risk_score", 0),
|
|
319
|
+
"max_drawdown": max_drawdown,
|
|
320
|
+
"concentration_risk": risk_metrics.get("concentration_risk", 0),
|
|
321
|
+
"var_95": risk_metrics.get("var_95", 0),
|
|
322
|
+
"cvar_95": risk_metrics.get("cvar_95", 0),
|
|
323
|
+
"portfolio_beta": risk_metrics.get("portfolio_beta", 1.0),
|
|
324
|
+
"num_positions": risk_metrics.get("num_positions", 0),
|
|
325
|
+
"recommendations": recommendations,
|
|
326
|
+
"generated_at": datetime.utcnow().isoformat(),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.error(f"Failed to generate risk report: {e}")
|
|
331
|
+
return {}
|
|
332
|
+
|
|
333
|
+
def _generate_risk_recommendations(self, risk_metrics: Dict, max_drawdown: float) -> List[str]:
|
|
334
|
+
"""Generate risk management recommendations"""
|
|
335
|
+
recommendations = []
|
|
336
|
+
|
|
337
|
+
# Concentration risk
|
|
338
|
+
if risk_metrics.get("concentration_risk", 0) > 50:
|
|
339
|
+
recommendations.append("Consider diversifying portfolio - concentration risk is high")
|
|
340
|
+
|
|
341
|
+
# Position count
|
|
342
|
+
if risk_metrics.get("num_positions", 0) < 5:
|
|
343
|
+
recommendations.append("Consider adding more positions for better diversification")
|
|
344
|
+
elif risk_metrics.get("num_positions", 0) > 15:
|
|
345
|
+
recommendations.append("Consider reducing number of positions for better focus")
|
|
346
|
+
|
|
347
|
+
# Risk score
|
|
348
|
+
if risk_metrics.get("risk_score", 0) > 70:
|
|
349
|
+
recommendations.append("Portfolio risk is high - consider reducing position sizes")
|
|
350
|
+
|
|
351
|
+
# Drawdown
|
|
352
|
+
if max_drawdown > 20:
|
|
353
|
+
recommendations.append("Maximum drawdown is high - consider risk management strategies")
|
|
354
|
+
|
|
355
|
+
# Beta
|
|
356
|
+
if risk_metrics.get("portfolio_beta", 1.0) > 1.5:
|
|
357
|
+
recommendations.append("Portfolio beta is high - consider adding defensive positions")
|
|
358
|
+
elif risk_metrics.get("portfolio_beta", 1.0) < 0.5:
|
|
359
|
+
recommendations.append("Portfolio beta is low - consider adding growth positions")
|
|
360
|
+
|
|
361
|
+
if not recommendations:
|
|
362
|
+
recommendations.append("Portfolio risk profile looks good - no immediate concerns")
|
|
363
|
+
|
|
364
|
+
return recommendations
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def create_risk_manager(db_session: Session) -> RiskManager:
|
|
368
|
+
"""Create a risk manager instance"""
|
|
369
|
+
trading_service = TradingService(db_session)
|
|
370
|
+
return RiskManager(trading_service)
|