Rajan Sharma commited on
Commit
634bbd1
·
verified ·
1 Parent(s): 0c8f645

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +802 -25
main.py CHANGED
@@ -1,28 +1,805 @@
1
- import http.server
2
- import socketserver
3
-
4
- PORT = 7860
5
-
6
- html = b"""
7
- <!DOCTYPE html>
8
- <html>
9
- <head>
10
- <title>Monte Carlo Portfolio Simulation</title>
11
- </head>
12
- <body>
13
- <h1>Monte Carlo Portfolio Simulation</h1>
14
- <p>This is a placeholder for the Monte Carlo portfolio simulation app. The full functionality will be added later.</p>
15
- </body>
16
- </html>
17
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
- class Handler(http.server.SimpleHTTPRequestHandler):
20
- def do_GET(self):
21
- self.send_response(200)
22
- self.send_header("Content-type", "text/html")
23
- self.end_headers()
24
- self.wfile.write(html)
25
 
26
- with socketserver.TCPServer(("", PORT), Handler) as httpd:
27
- print(f"Serving placeholder app on port {PORT}")
28
- httpd.serve_forever()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ Monte Carlo Daily Simulation - IMPROVED VERSION
3
+ - Cash balances included
4
+ - Historical tracking chart
5
+ - Fixed factor analysis alignment
6
+ """
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from google.cloud import storage
11
+ import requests
12
+ import smtplib
13
+ from email.mime.text import MIMEText
14
+ from email.mime.multipart import MIMEMultipart
15
+ from email.mime.image import MIMEImage
16
+ import os
17
+ import json
18
+ from datetime import datetime
19
+ import matplotlib
20
+ matplotlib.use('Agg') # Non-interactive backend
21
+ import matplotlib.pyplot as plt
22
+ import matplotlib.dates as mdates
23
+ from io import BytesIO
24
+ import base64
25
+
26
+ # Configuration
27
+ BUCKET_NAME = os.environ.get('BUCKET', 'stocks_position')
28
+ EMAIL_TO = os.environ.get('EMAIL_TO')
29
+ EMAIL_FROM = os.environ.get('EMAIL_FROM')
30
+ SMTP_USER = os.environ.get('SMTP_USER')
31
+ SMTP_PASS = os.environ.get('SMTP_PASS')
32
+ QUESTRADE_REFRESH_TOKEN = os.environ.get('QUESTRADE_REFRESH_TOKEN')
33
+ QUESTRADE_ACCOUNT_LIRA = os.environ.get('QUESTRADE_ACCOUNT_LIRA', '********')
34
+ QUESTRADE_ACCOUNT_RRSP = os.environ.get('QUESTRADE_ACCOUNT_RRSP', '********')
35
+ ALPHAVANTAGE_API_KEY = os.environ.get('ALPHAVANTAGE_API_KEY')
36
+
37
+ # Simulation Parameters
38
+ N_PATHS = 5000
39
+ TRADING_DAYS_PER_YEAR = 252
40
+
41
+ # Market Correction Parameters
42
+ CORRECTION_PROBABILITY_PER_YEAR = 0.15
43
+ CORRECTION_DROP_MEAN = -0.35
44
+ CORRECTION_DROP_STD = 0.10
45
+ CORRECTION_CORRELATION = 0.50
46
+
47
+ # Currency Risk Parameters
48
+ USDCAD_DRIFT = -0.005
49
+ USDCAD_VOLATILITY = 0.08
50
+
51
+ # Financial Parameters
52
+ INFLATION_RATE = 0.025
53
+
54
+
55
+ def get_fresh_refresh_token():
56
+ """Get the freshest refresh token - from GCS if available, else env var"""
57
+ try:
58
+ client = storage.Client()
59
+ bucket = client.bucket(BUCKET_NAME)
60
+ blob = bucket.blob('outputs/questrade_tokens.json')
61
+
62
+ if blob.exists():
63
+ data = json.loads(blob.download_as_string())
64
+ if 'refresh_token' in data:
65
+ print("Using refresh token from GCS (auto-updated)")
66
+ return data['refresh_token']
67
+ except Exception as e:
68
+ print(f"Could not load token from GCS: {e}")
69
+
70
+ print("Using refresh token from environment variable")
71
+ return QUESTRADE_REFRESH_TOKEN
72
+
73
+
74
+ def refresh_questrade_token(refresh_token):
75
+ """Get new access token from Questrade"""
76
+ url = "https://login.questrade.com/oauth2/token"
77
+ params = {'grant_type': 'refresh_token', 'refresh_token': refresh_token}
78
+
79
+ response = requests.get(url, params=params)
80
+ response.raise_for_status()
81
+ data = response.json()
82
+
83
+ # Save new refresh token to GCS
84
+ client = storage.Client()
85
+ bucket = client.bucket(BUCKET_NAME)
86
+ blob = bucket.blob('outputs/questrade_tokens.json')
87
+ blob.upload_from_string(json.dumps(data, indent=2))
88
+
89
+ return data['access_token'], data['api_server']
90
+
91
+
92
+ def get_account_positions(access_token, api_server, account_number):
93
+ """Fetch positions for a single Questrade account"""
94
+ headers = {'Authorization': f'Bearer {access_token}'}
95
+ url = f"{api_server}v1/accounts/{account_number}/positions"
96
+
97
+ response = requests.get(url, headers=headers)
98
+ response.raise_for_status()
99
+ positions = response.json()['positions']
100
+
101
+ return positions
102
+
103
+
104
+ def get_account_balances(access_token, api_server, account_number):
105
+ """Fetch cash balances for a single Questrade account"""
106
+ headers = {'Authorization': f'Bearer {access_token}'}
107
+ url = f"{api_server}v1/accounts/{account_number}/balances"
108
+
109
+ response = requests.get(url, headers=headers)
110
+ response.raise_for_status()
111
+ balances = response.json()
112
+
113
+ # Extract total cash (combined currencies converted to CAD, but we'll use USD)
114
+ combined_balances = balances.get('combinedBalances', [])
115
+
116
+ # Find USD cash balance
117
+ usd_cash = 0
118
+ for balance in combined_balances:
119
+ if balance.get('currency') == 'USD':
120
+ usd_cash = balance.get('cash', 0)
121
+ break
122
+
123
+ return usd_cash
124
+
125
+
126
+ def fetch_all_questrade_positions():
127
+ """Fetch and combine positions + cash from both LIRA and RRSP accounts"""
128
+ print("Fetching Questrade positions and balances from both accounts...")
129
+
130
+ access_token, api_server = refresh_questrade_token(get_fresh_refresh_token())
131
+
132
+ # Fetch both accounts - positions
133
+ lira_positions = get_account_positions(access_token, api_server, QUESTRADE_ACCOUNT_LIRA)
134
+ rrsp_positions = get_account_positions(access_token, api_server, QUESTRADE_ACCOUNT_RRSP)
135
+
136
+ # Fetch both accounts - cash balances
137
+ lira_cash = get_account_balances(access_token, api_server, QUESTRADE_ACCOUNT_LIRA)
138
+ rrsp_cash = get_account_balances(access_token, api_server, QUESTRADE_ACCOUNT_RRSP)
139
+
140
+ lira_market_value = sum(pos['currentMarketValue'] for pos in lira_positions) if lira_positions else 0
141
+ rrsp_market_value = sum(pos['currentMarketValue'] for pos in rrsp_positions) if rrsp_positions else 0
142
+
143
+ lira_total = lira_market_value + lira_cash
144
+ rrsp_total = rrsp_market_value + rrsp_cash
145
+
146
+ print(f"LIRA: ${lira_market_value:,.2f} (positions) + ${lira_cash:,.2f} (cash) = ${lira_total:,.2f} USD")
147
+ print(f"RRSP: ${rrsp_market_value:,.2f} (positions) + ${rrsp_cash:,.2f} (cash) = ${rrsp_total:,.2f} USD")
148
+ print(f"Total: ${lira_total + rrsp_total:,.2f} USD")
149
+
150
+ return {
151
+ 'lira': {
152
+ 'positions': lira_positions,
153
+ 'market_value': lira_market_value,
154
+ 'cash': lira_cash,
155
+ 'value': lira_total
156
+ },
157
+ 'rrsp': {
158
+ 'positions': rrsp_positions,
159
+ 'market_value': rrsp_market_value,
160
+ 'cash': rrsp_cash,
161
+ 'value': rrsp_total
162
+ },
163
+ 'total_value': lira_total + rrsp_total
164
+ }
165
+
166
+
167
+ def get_usdcad_rate():
168
+ """Fetch current USD/CAD exchange rate with fallback"""
169
+ try:
170
+ url = "https://www.alphavantage.co/query"
171
+ params = {
172
+ 'function': 'CURRENCY_EXCHANGE_RATE',
173
+ 'from_currency': 'USD',
174
+ 'to_currency': 'CAD',
175
+ 'apikey': ALPHAVANTAGE_API_KEY
176
+ }
177
+
178
+ response = requests.get(url, params=params)
179
+ response.raise_for_status()
180
+ data = response.json()
181
+
182
+ # Check if we got valid data
183
+ if 'Realtime Currency Exchange Rate' in data:
184
+ rate = float(data['Realtime Currency Exchange Rate']['5. Exchange Rate'])
185
+ print(f"USD/CAD rate: {rate:.4f} (from API)")
186
+ return rate
187
+ else:
188
+ print(f"⚠️ Alpha Vantage API error: {data}")
189
+ raise ValueError("Invalid API response")
190
+
191
+ except Exception as e:
192
+ # Fallback to approximate current rate
193
+ fallback_rate = 1.39
194
+ print(f"⚠️ Could not fetch USD/CAD rate: {e}")
195
+ print(f"Using fallback rate: {fallback_rate:.4f}")
196
+ return fallback_rate
197
+
198
+
199
+ def load_parameters_from_gcs():
200
+ """Load mu, sigma, and correlations from GCS"""
201
+ client = storage.Client()
202
+ bucket = client.bucket(BUCKET_NAME)
203
+
204
+ blob = bucket.blob('parameters/mu_sigma.csv')
205
+ mu_sigma_df = pd.read_csv(blob.open('r'))
206
+
207
+ blob = bucket.blob('parameters/correlations.csv')
208
+ correlations_df = pd.read_csv(blob.open('r'), index_col=0)
209
+
210
+ return mu_sigma_df, correlations_df
211
+
212
+
213
+ def match_portfolio_to_parameters(portfolio_tickers, mu_sigma_df, correlations_df):
214
+ """Match portfolio tickers to available parameters, filter out mismatches"""
215
+ # Tickers must be in BOTH mu_sigma AND correlations
216
+ available_in_mu_sigma = set(mu_sigma_df['ticker'].values)
217
+ available_in_corr = set(correlations_df.index)
218
+ available_tickers = available_in_mu_sigma & available_in_corr # Intersection
219
+
220
+ portfolio_tickers_set = set(portfolio_tickers)
221
+
222
+ # Find tickers that are in portfolio but not in parameters
223
+ missing_tickers = portfolio_tickers_set - available_tickers
224
+ if missing_tickers:
225
+ print(f"⚠️ Warning: These tickers in your portfolio don't have parameters: {missing_tickers}")
226
+ print(f" They will be excluded from simulation")
227
+
228
+ # Use only tickers that have parameters
229
+ matched_tickers = [t for t in portfolio_tickers if t in available_tickers]
230
+
231
+ if not matched_tickers:
232
+ raise ValueError("No tickers in portfolio match available parameters!")
233
+
234
+ print(f"Using {len(matched_tickers)} tickers for simulation: {matched_tickers}")
235
+
236
+ # Filter parameters to only matched tickers
237
+ mu_sigma_filtered = mu_sigma_df[mu_sigma_df['ticker'].isin(matched_tickers)].reset_index(drop=True)
238
+ correlations_filtered = correlations_df.loc[matched_tickers, matched_tickers]
239
+
240
+ return matched_tickers, mu_sigma_filtered, correlations_filtered
241
+
242
+
243
+ def simulate_portfolio(lira_value, rrsp_value, mu, sigma, correlations,
244
+ usdcad_rate, years, monthly_contrib_cad, n_corrections):
245
+ """
246
+ Run Monte Carlo simulation with dual accounts
247
+ LIRA: No contributions, just grows
248
+ RRSP: Receives all contributions
249
+ """
250
+ n_days = years * TRADING_DAYS_PER_YEAR
251
+ days_per_month = TRADING_DAYS_PER_YEAR // 12
252
+
253
+ # Initialize arrays
254
+ lira_vals = np.zeros((N_PATHS, n_days + 1))
255
+ rrsp_vals = np.zeros((N_PATHS, n_days + 1))
256
+ lira_vals[:, 0] = lira_value
257
+ rrsp_vals[:, 0] = rrsp_value
258
+
259
+ # Simulate USD/CAD
260
+ dt = 1 / TRADING_DAYS_PER_YEAR
261
+ usdcad_paths = np.zeros((N_PATHS, n_days + 1))
262
+ usdcad_paths[:, 0] = usdcad_rate
263
+
264
+ # Generate returns using correlation matrix
265
+ n_assets = len(mu)
266
+ chol = np.linalg.cholesky(correlations)
267
+
268
+ # Schedule market corrections randomly
269
+ correction_days = []
270
+ if n_corrections > 0:
271
+ correction_days = np.sort(np.random.choice(
272
+ range(60, n_days - 60), n_corrections, replace=False
273
+ ))
274
+
275
+ # Daily simulation
276
+ for day in range(1, n_days + 1):
277
+ # Generate correlated random returns
278
+ z = np.random.normal(0, 1, (N_PATHS, n_assets))
279
+ correlated_z = z @ chol.T
280
+
281
+ # Check for market correction
282
+ if day in correction_days:
283
+ # Severe correlated drop
284
+ crash_magnitude = np.random.normal(CORRECTION_DROP_MEAN, CORRECTION_DROP_STD, N_PATHS)
285
+ crash_magnitude = np.clip(crash_magnitude, -0.60, -0.15) # Between -15% and -60%
286
+
287
+ # Apply to all assets with high correlation
288
+ crash_component = crash_magnitude[:, np.newaxis] * np.ones((N_PATHS, n_assets))
289
+ correlated_z = CORRECTION_CORRELATION * crash_component + (1 - CORRECTION_CORRELATION) * correlated_z
290
+
291
+ # Calculate returns
292
+ daily_returns = (mu / TRADING_DAYS_PER_YEAR) + (sigma / np.sqrt(TRADING_DAYS_PER_YEAR)) * correlated_z
293
+ portfolio_returns = daily_returns.mean(axis=1) # Equal weighted for now
294
+
295
+ # Update both accounts with same returns
296
+ lira_vals[:, day] = lira_vals[:, day - 1] * (1 + portfolio_returns)
297
+ rrsp_vals[:, day] = rrsp_vals[:, day - 1] * (1 + portfolio_returns)
298
+
299
+ # Add monthly contributions to RRSP only (in USD)
300
+ if day % days_per_month == 0:
301
+ usdcad_paths[:, day] = usdcad_paths[:, day - 1] * np.exp(
302
+ (USDCAD_DRIFT - 0.5 * USDCAD_VOLATILITY**2) * dt +
303
+ USDCAD_VOLATILITY * np.sqrt(dt) * np.random.normal(0, 1, N_PATHS)
304
+ )
305
+ contrib_usd = monthly_contrib_cad / usdcad_paths[:, day]
306
+ rrsp_vals[:, day] += contrib_usd
307
+ else:
308
+ usdcad_paths[:, day] = usdcad_paths[:, day - 1]
309
+
310
+ # Calculate final values
311
+ final_lira = lira_vals[:, -1]
312
+ final_rrsp = rrsp_vals[:, -1]
313
+ final_total = final_lira + final_rrsp
314
+
315
+ return final_total, final_lira, final_rrsp
316
+
317
+
318
+ def analyze_results(final_values, target_usd, inflation_rate, years):
319
+ """Analyze simulation results"""
320
+ # Inflation-adjusted target
321
+ real_target = target_usd / ((1 + inflation_rate) ** years)
322
+
323
+ median = np.median(final_values)
324
+ p10 = np.percentile(final_values, 10)
325
+ p90 = np.percentile(final_values, 90)
326
+ prob_hit_nominal = (final_values >= target_usd).mean()
327
+ prob_hit_real = (final_values >= real_target).mean()
328
+
329
+ return {
330
+ 'median': median,
331
+ 'p10': p10,
332
+ 'p90': p90,
333
+ 'prob_nominal': prob_hit_nominal,
334
+ 'prob_real': prob_hit_real,
335
+ 'target_nominal': target_usd,
336
+ 'target_real': real_target
337
+ }
338
+
339
+
340
+ def save_historical_results(portfolio_value, results_5yr):
341
+ """Save today's results to historical tracking file"""
342
+ client = storage.Client()
343
+ bucket = client.bucket(BUCKET_NAME)
344
+ blob = bucket.blob('outputs/historical_results.json')
345
+
346
+ # Load existing history
347
+ history = []
348
+ if blob.exists():
349
+ try:
350
+ history = json.loads(blob.download_as_string())
351
+ except:
352
+ history = []
353
+
354
+ # Add today's result
355
+ today = {
356
+ 'date': datetime.now().isoformat(),
357
+ 'portfolio_value': portfolio_value,
358
+ 'prob_5yr_nominal': results_5yr['no_contrib']['0_corrections']['prob_nominal'],
359
+ 'prob_5yr_real': results_5yr['no_contrib']['0_corrections']['prob_real'],
360
+ 'median_5yr': results_5yr['no_contrib']['0_corrections']['median']
361
+ }
362
+
363
+ history.append(today)
364
+
365
+ # Keep last 90 days only
366
+ history = history[-90:]
367
+
368
+ # Save back
369
+ blob.upload_from_string(json.dumps(history, indent=2))
370
+
371
+ return history
372
+
373
+
374
+ def create_tracking_chart(history):
375
+ """Create a beautiful tracking chart of portfolio progression"""
376
+ print("Creating tracking chart...")
377
+
378
+ try:
379
+ print(f" → Processing {len(history)} data points")
380
+
381
+ if len(history) < 2:
382
+ print(" → Not enough data points for chart (need at least 2)")
383
+ return None
384
+
385
+ # Extract data
386
+ dates = [datetime.fromisoformat(h['date']) for h in history]
387
+ portfolio_values = [h['portfolio_value'] for h in history]
388
+ prob_nominal = [h['prob_5yr_nominal'] * 100 for h in history]
389
+ medians = [h['median_5yr'] / 1000 for h in history] # In thousands
390
+
391
+ print(f" → Creating matplotlib figure...")
392
+
393
+ # Create figure with modern styling
394
+ fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), facecolor='white')
395
+ fig.subplots_adjust(hspace=0.3)
396
+
397
+ # Chart 1: Portfolio Value
398
+ ax1.plot(dates, portfolio_values, color='#2563eb', linewidth=2.5, marker='o',
399
+ markersize=6, markerfacecolor='white', markeredgewidth=2)
400
+ ax1.fill_between(dates, portfolio_values, alpha=0.1, color='#2563eb')
401
+ ax1.set_title('Portfolio Value Over Time', fontsize=16, fontweight='bold', pad=15)
402
+ ax1.set_ylabel('Value (USD)', fontsize=12, fontweight='600')
403
+ ax1.grid(True, alpha=0.2, linestyle='--')
404
+ ax1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'${x:,.0f}'))
405
+ ax1.spines['top'].set_visible(False)
406
+ ax1.spines['right'].set_visible(False)
407
+
408
+ # Chart 2: Dual axis - Probability and Median
409
+ ax2_twin = ax2.twinx()
410
+
411
+ line1 = ax2.plot(dates, prob_nominal, color='#16a34a', linewidth=2.5, marker='o',
412
+ markersize=6, markerfacecolor='white', markeredgewidth=2, label='Probability (5yr)')
413
+ line2 = ax2_twin.plot(dates, medians, color='#dc2626', linewidth=2.5, marker='s',
414
+ markersize=6, markerfacecolor='white', markeredgewidth=2, label='Median Outcome')
415
+
416
+ ax2.set_title('5-Year Projections (No Contributions, 0 Crashes)', fontsize=16, fontweight='bold', pad=15)
417
+ ax2.set_xlabel('Date', fontsize=12, fontweight='600')
418
+ ax2.set_ylabel('Probability of $300k (%)', fontsize=12, fontweight='600', color='#16a34a')
419
+ ax2_twin.set_ylabel('Median Outcome ($k)', fontsize=12, fontweight='600', color='#dc2626')
420
+
421
+ ax2.tick_params(axis='y', labelcolor='#16a34a')
422
+ ax2_twin.tick_params(axis='y', labelcolor='#dc2626')
423
+
424
+ ax2.set_ylim(0, 100) # Y-axis from 0% to 100%
425
+ ax2.set_yticks(range(0, 101, 10)) # Tick marks every 10%
426
+
427
+ ax2.grid(True, alpha=0.2, linestyle='--')
428
+ ax2.spines['top'].set_visible(False)
429
+ ax2_twin.spines['top'].set_visible(False)
430
+
431
+ # Format x-axis
432
+ for ax in [ax1, ax2]:
433
+ ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %d'))
434
+ ax.xaxis.set_major_locator(mdates.DayLocator(interval=max(1, len(dates)//7)))
435
+ plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right')
436
+
437
+ # Legend
438
+ lines = line1 + line2
439
+ labels = [l.get_label() for l in lines]
440
+ ax2.legend(lines, labels, loc='upper left', framealpha=0.9, fontsize=10)
441
+
442
+ plt.tight_layout()
443
+
444
+ print(f" → Encoding chart to base64...")
445
+
446
+ # Convert to base64
447
+ buffer = BytesIO()
448
+ plt.savefig(buffer, format='png', dpi=150, bbox_inches='tight')
449
+ buffer.seek(0)
450
+ image_base64 = base64.b64encode(buffer.read()).decode()
451
+ plt.close()
452
+
453
+ print(f" ✅ Chart created successfully ({len(image_base64)} bytes)")
454
+ return image_base64
455
+
456
+ except Exception as e:
457
+ import traceback
458
+ print(f"❌ Chart generation FAILED!")
459
+ print(f" Error: {str(e)}")
460
+ print(f" Traceback:\n{traceback.format_exc()}")
461
+ return None
462
+
463
+
464
+ def send_email_report(portfolio_data, mu_sigma_df, results_5yr, results_10yr, tracking_chart_base64):
465
+ """Send comprehensive email report with factor analysis and tracking chart"""
466
+ lira_val = portfolio_data['lira']['value']
467
+ lira_market = portfolio_data['lira']['market_value']
468
+ lira_cash = portfolio_data['lira']['cash']
469
+
470
+ rrsp_val = portfolio_data['rrsp']['value']
471
+ rrsp_market = portfolio_data['rrsp']['market_value']
472
+ rrsp_cash = portfolio_data['rrsp']['cash']
473
+
474
+ total_val = portfolio_data['total_value']
475
+
476
+ # Determine status based on 5-year probability (nominal)
477
+ prob_5yr = results_5yr['no_contrib']['0_corrections']['prob_nominal']
478
+ if prob_5yr >= 0.70:
479
+ status = "✅ On Track"
480
+ color = "green"
481
+ elif prob_5yr >= 0.50:
482
+ status = "⚠️ Watch"
483
+ color = "orange"
484
+ else:
485
+ status = "🔴 Off Track"
486
+ color = "red"
487
+
488
+ # Load factor analysis from metadata
489
+ try:
490
+ client = storage.Client()
491
+ bucket = client.bucket(BUCKET_NAME)
492
+ blob = bucket.blob('parameters/metadata.json')
493
+ metadata = json.loads(blob.download_as_string())
494
+ factor_analysis = metadata.get('factor_analysis', None)
495
+ except:
496
+ factor_analysis = None
497
+
498
+ # Build HTML email
499
+ html = f"""
500
+ <html>
501
+ <body style="font-family: Arial, sans-serif;">
502
+ <h2 style="color: {color};">[MC Dashboard] {status} - ${total_val:,.0f} USD</h2>
503
+
504
+ <div style="background-color: #f0f0f0; padding: 15px; margin: 20px 0; border-radius: 5px;">
505
+ <h3>Account Breakdown</h3>
506
+ <p><strong>LIRA ({QUESTRADE_ACCOUNT_LIRA}):</strong> ${lira_market:,.2f} (positions) + ${lira_cash:,.2f} (cash) = <strong>${lira_val:,.2f} USD</strong> <em>(no contributions)</em></p>
507
+ <p><strong>RRSP ({QUESTRADE_ACCOUNT_RRSP}):</strong> ${rrsp_market:,.2f} (positions) + ${rrsp_cash:,.2f} (cash) = <strong>${rrsp_val:,.2f} USD</strong> <em>(receives contributions)</em></p>
508
+ <p><strong>Total Portfolio:</strong> ${total_val:,.2f} USD</p>
509
+ </div>
510
+ """
511
+
512
+ # ADD TRACKING CHART (if available)
513
+ if tracking_chart_base64:
514
+ html += f"""
515
+ <div style="background-color: #fff; padding: 15px; margin: 20px 0; border-radius: 5px; border: 1px solid #e5e7eb;">
516
+ <h3>📈 Historical Tracking</h3>
517
+ <img src="cid:chart_image" style="width: 100%; max-width: 800px; height: auto;" alt="Tracking Chart">
518
+ </div>
519
+ """
520
+
521
+ # ADD FACTOR ANALYSIS SECTION (if available)
522
+ if factor_analysis:
523
+ concentration = factor_analysis['concentration_risk']
524
+ tech_pct = factor_analysis['tech_ai_percentage']
525
+
526
+ # Determine color based on risk
527
+ risk_colors = {
528
+ 'VERY HIGH': '#dc3545',
529
+ 'HIGH': '#fd7e14',
530
+ 'MODERATE': '#ffc107',
531
+ 'LOW': '#28a745'
532
+ }
533
+ risk_color = risk_colors.get(concentration, '#6c757d')
534
+
535
+ html += f"""
536
+ <div style="background-color: #fff; padding: 15px; margin: 20px 0; border-radius: 5px; border-left: 4px solid {risk_color};">
537
+ <h3>🎯 Factor Analysis</h3>
538
+ <p><strong>Concentration Risk: <span style="color: {risk_color};">{concentration}</span></strong></p>
539
+
540
+ <table style="width: 100%; margin-top: 10px; border-collapse: collapse;">
541
+ <tr>
542
+ <td style="width: 200px; padding: 8px 0;"><strong>Tech/AI Exposure:</strong></td>
543
+ <td style="padding: 8px 0;">
544
+ <div style="background-color: #e9ecef; border-radius: 10px; height: 24px; position: relative; display: flex; align-items: center;">
545
+ <div style="background-color: {risk_color}; width: {tech_pct*100:.0f}%; height: 100%; border-radius: 10px;"></div>
546
+ <span style="position: absolute; left: 10px; font-weight: bold; color: #000;">{tech_pct:.1%}</span>
547
+ </div>
548
+ </td>
549
+ </tr>
550
+ """
551
+
552
+ # Add other factors
553
+ for factor_name, factor_data in factor_analysis['factors'].items():
554
+ if factor_data['count'] > 0 and factor_name != 'Tech/AI':
555
+ pct = factor_data['percentage']
556
+ tickers = ', '.join(factor_data['tickers'])
557
+ html += f"""
558
+ <tr>
559
+ <td style="padding: 8px 0;"><strong>{factor_name}:</strong></td>
560
+ <td style="padding: 8px 0;">
561
+ <div style="background-color: #e9ecef; border-radius: 10px; height: 20px; position: relative; display: flex; align-items: center;">
562
+ <div style="background-color: #6c757d; width: {pct*100:.0f}%; height: 100%; border-radius: 10px;"></div>
563
+ <span style="position: absolute; left: 10px; font-size: 12px; color: #000;">{pct:.1%} ({tickers})</span>
564
+ </div>
565
+ </td>
566
+ </tr>
567
+ """
568
+
569
+ html += """
570
+ </table>
571
+
572
+ <div style="margin-top: 15px; padding: 10px; background-color: #f8f9fa; border-radius: 5px;">
573
+ <p style="margin: 5px 0; font-size: 14px;"><strong>What This Means:</strong></p>
574
+ <ul style="margin: 5px 0; font-size: 14px;">
575
+ """
576
+
577
+ if concentration in ['VERY HIGH', 'HIGH']:
578
+ html += """
579
+ <li>Your portfolio is <strong>heavily concentrated</strong> in tech/AI stocks</li>
580
+ <li>When tech sells off, your entire portfolio will likely drop together</li>
581
+ <li>Consider diversifying into non-tech sectors for balance</li>
582
+ """
583
+ else:
584
+ html += """
585
+ <li>Your portfolio has reasonable diversification across sectors</li>
586
+ <li>Continue monitoring concentration as positions grow</li>
587
+ """
588
+
589
+ html += """
590
+ </ul>
591
+ </div>
592
+ </div>
593
+ """
594
+
595
+ html += f"""
596
+ <div style="background-color: #fff3cd; padding: 15px; margin: 20px 0; border-radius: 5px; border-left: 4px solid #ffc107;">
597
+ <h3>⚠️ Important Note on Parameters</h3>
598
+ <p><strong>This simulation uses actual 10-year historical data without artificial caps.</strong></p>
599
+ <p>Average Portfolio Return: {mu_sigma_df['mu'].mean():.1%}</p>
600
+ <p>Average Portfolio Volatility: {mu_sigma_df['sigma'].mean():.1%}</p>
601
+ <p><em>These reflect your actual portfolio's historical performance. Results include:</em></p>
602
+ <ul>
603
+ <li>Market correction scenarios (15% annual probability)</li>
604
+ <li>USD/CAD currency risk</li>
605
+ <li>Inflation-adjusted targets</li>
606
+ <li>Parameter uncertainty from confidence intervals</li>
607
+ </ul>
608
+ </div>
609
+
610
+ <h3>5-Year Projections (Target: $300k USD)</h3>
611
+ <table border="1" cellpadding="8" cellspacing="0" style="border-collapse: collapse; margin: 10px 0;">
612
+ <tr style="background-color: #e9ecef;">
613
+ <th>Scenario</th>
614
+ <th>No Contrib</th>
615
+ <th>+$250 CAD/mo</th>
616
+ <th>+$500 CAD/mo</th>
617
+ </tr>
618
+ """
619
+
620
+ for corrections in ['0_corrections', '1_corrections', '2_corrections']:
621
+ label = corrections.replace('_', ' ').replace('corrections', 'crashes')
622
+ html += f"<tr><td><strong>{label}</strong></td>"
623
+
624
+ for contrib in ['no_contrib', 'contrib_250', 'contrib_500']:
625
+ r = results_5yr[contrib][corrections]
626
+ html += f"""
627
+ <td>
628
+ <strong>{r['prob_nominal']:.1%}</strong> (nominal)<br>
629
+ {r['prob_real']:.1%} (real)<br>
630
+ <em>Median: ${r['median']:,.0f}</em>
631
+ </td>
632
+ """
633
+ html += "</tr>"
634
+
635
+ html += """
636
+ </table>
637
+
638
+ <h3>10-Year Projections (Target: $500k USD)</h3>
639
+ <table border="1" cellpadding="8" cellspacing="0" style="border-collapse: collapse; margin: 10px 0;">
640
+ <tr style="background-color: #e9ecef;">
641
+ <th>Scenario</th>
642
+ <th>No Contrib</th>
643
+ <th>+$250 CAD/mo</th>
644
+ <th>+$500 CAD/mo</th>
645
+ </tr>
646
+ """
647
+
648
+ for corrections in ['0_corrections', '1_corrections', '2_corrections']:
649
+ label = corrections.replace('_', ' ').replace('corrections', 'crashes')
650
+ html += f"<tr><td><strong>{label}</strong></td>"
651
+
652
+ for contrib in ['no_contrib', 'contrib_250', 'contrib_500']:
653
+ r = results_10yr[contrib][corrections]
654
+ html += f"""
655
+ <td>
656
+ <strong>{r['prob_nominal']:.1%}</strong> (nominal)<br>
657
+ {r['prob_real']:.1%} (real)<br>
658
+ <em>Median: ${r['median']:,.0f}</em>
659
+ </td>
660
+ """
661
+ html += "</tr>"
662
+
663
+ html += """
664
+ </table>
665
+
666
+ <div style="margin-top: 30px; padding: 15px; background-color: #d1ecf1; border-radius: 5px;">
667
+ <h3>💡 Key Insights</h3>
668
+ <ul>
669
+ <li><strong>Nominal vs Real:</strong> Real probabilities account for 2.5% inflation</li>
670
+ <li><strong>Market Corrections:</strong> 15% annual probability of 30-40% crash</li>
671
+ <li><strong>Currency Risk:</strong> CAD contributions subject to USD/CAD fluctuations</li>
672
+ <li><strong>Contributions:</strong> All contributions go to RRSP account only</li>
673
+ </ul>
674
+ </div>
675
+
676
+ <p style="margin-top: 30px; color: #666; font-size: 12px;">
677
+ Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S MT')}<br>
678
+ Simulation: {N_PATHS:,} paths per scenario<br>
679
+ Model: Improved Monte Carlo (no artificial caps, 10-year parameters)
680
+ </p>
681
+ </body>
682
+ </html>
683
+ """
684
+
685
+ # Send email
686
+ msg = MIMEMultipart('related')
687
+ msg['Subject'] = f"[MC Dashboard] {status} - ${total_val:,.0f} USD"
688
+ msg['From'] = EMAIL_FROM
689
+ msg['To'] = EMAIL_TO
690
+
691
+ msg.attach(MIMEText(html, 'html'))
692
+
693
+ # Attach chart image as separate part (if exists)
694
+ if tracking_chart_base64:
695
+ # Decode base64 to bytes
696
+ image_data = base64.b64decode(tracking_chart_base64)
697
+
698
+ # Create image attachment with Content-ID
699
+ image = MIMEImage(image_data, name='chart.png')
700
+ image.add_header('Content-ID', '<chart_image>')
701
+ image.add_header('Content-Disposition', 'inline', filename='chart.png')
702
+ msg.attach(image)
703
+
704
+ server = smtplib.SMTP_SSL('smtp.gmail.com', 465)
705
+ server.login(SMTP_USER, SMTP_PASS.replace(' ', ''))
706
+ server.send_message(msg)
707
+ server.quit()
708
+
709
+ print("✅ Email sent successfully")
710
+
711
+
712
+ def run(request=None):
713
+ """Main Cloud Function entry point"""
714
+ print("=" * 60)
715
+ print("Monte Carlo Daily Simulation - IMPROVED VERSION")
716
+ print("=" * 60)
717
+
718
+ # Fetch current portfolio
719
+ portfolio_data = fetch_all_questrade_positions()
720
+ lira_value = portfolio_data['lira']['value']
721
+ rrsp_value = portfolio_data['rrsp']['value']
722
+ total_value = portfolio_data['total_value']
723
+
724
+ # Get portfolio tickers (only from accounts that have positions)
725
+ all_positions = portfolio_data['lira']['positions'] + portfolio_data['rrsp']['positions']
726
+ if not all_positions:
727
+ raise ValueError("No positions found in either account!")
728
+
729
+ portfolio_tickers = list(set([pos['symbol'] for pos in all_positions]))
730
+ print(f"Portfolio tickers: {portfolio_tickers}")
731
+
732
+ # Get USD/CAD rate
733
+ usdcad_rate = get_usdcad_rate()
734
+
735
+ # Load parameters
736
+ mu_sigma_df, correlations_df = load_parameters_from_gcs()
737
+
738
+ # Match portfolio to parameters (handles IBIT mismatch)
739
+ matched_tickers, mu_sigma_filtered, correlations_filtered = match_portfolio_to_parameters(
740
+ portfolio_tickers, mu_sigma_df, correlations_df
741
+ )
742
+
743
+ mu = mu_sigma_filtered['mu'].values
744
+ sigma = mu_sigma_filtered['sigma'].values
745
+ correlations = correlations_filtered.values
746
+
747
+ print(f"\nPortfolio μ (avg): {mu.mean():.2%}")
748
+ print(f"Portfolio σ (avg): {sigma.mean():.2%}")
749
+
750
+ # Run all scenarios
751
+ print("\n" + "=" * 60)
752
+ print("Running simulations (this takes ~10 minutes)...")
753
+ print("=" * 60)
754
+
755
+ results_5yr = {}
756
+ results_10yr = {}
757
+
758
+ for contrib_label, contrib_cad in [('no_contrib', 0), ('contrib_250', 250), ('contrib_500', 500)]:
759
+ results_5yr[contrib_label] = {}
760
+ results_10yr[contrib_label] = {}
761
+
762
+ for n_corrections in [0, 1, 2]:
763
+ corr_label = f"{n_corrections}_corrections"
764
+
765
+ # 5-year
766
+ final_5, _, _ = simulate_portfolio(
767
+ lira_value, rrsp_value, mu, sigma, correlations,
768
+ usdcad_rate, 5, contrib_cad, n_corrections
769
+ )
770
+ results_5yr[contrib_label][corr_label] = analyze_results(
771
+ final_5, 300000, INFLATION_RATE, 5
772
+ )
773
+
774
+ # 10-year
775
+ final_10, _, _ = simulate_portfolio(
776
+ lira_value, rrsp_value, mu, sigma, correlations,
777
+ usdcad_rate, 10, contrib_cad, n_corrections
778
+ )
779
+ results_10yr[contrib_label][corr_label] = analyze_results(
780
+ final_10, 500000, INFLATION_RATE, 10
781
+ )
782
+
783
+ print(f"✓ {contrib_label} / {corr_label}")
784
+
785
+ # Save historical results and create tracking chart
786
+ print("\nSaving historical results...")
787
+ history = save_historical_results(total_value, results_5yr)
788
+
789
+ print("Creating tracking chart...")
790
+ tracking_chart_base64 = create_tracking_chart(history)
791
+
792
+ # Send report
793
+ print("\n" + "=" * 60)
794
+ print("Sending email report...")
795
+ send_email_report(portfolio_data, mu_sigma_df, results_5yr, results_10yr, tracking_chart_base64)
796
+
797
+ print("=" * 60)
798
+ print("✅ Daily simulation complete!")
799
+ print("=" * 60)
800
+
801
+ return {'statusCode': 200, 'body': json.dumps({'status': 'success'})}
802
 
 
 
 
 
 
 
803
 
804
+ if __name__ == '__main__':
805
+ run()