BrianIsaac commited on
Commit
5ab6829
·
1 Parent(s): 4f454b4

feat: implement P1 UX improvements - export, validation, and accessibility

Browse files

- Add PDF and CSV export functionality with formatted reports
- Implement real-time form validation (email format, password match)
- Add password strength meter with NIST 2024 compliance indicators
- Fix correlation matrix to include all portfolio assets
- Improve WCAG AA colour contrast for accessibility
- Add focus indicators for keyboard navigation
- Update dependencies: reportlab, pypdf, matplotlib, plotly

Phase 7: Export functionality
- New module: backend/export.py with PDF/CSV generation
- Export buttons in results page header
- Temporary file handling for downloads

Phase 8: Form validation
- Real-time email validation with regex
- Password match confirmation
- Visual feedback with checkmarks

Phase 9: Bug fixes and accessibility
- Correlation matrix length alignment fix
- Enhanced colour contrast (demo subtitle, disclaimers, placeholders)
- 3px focus outline for keyboard users

Phase 13: Password strength meter
- 4-tier strength indicator (weak to strong)
- Contextual feedback and improvement suggestions
- Emphasises 15+ character NIST requirement

app.py CHANGED
@@ -57,6 +57,7 @@ from backend.rate_limiting import (
57
  )
58
  from backend.rate_limiting.fixed_window import TieredFixedWindowLimiter
59
  from backend.auth import auth, UserSession
 
60
 
61
  def check_authentication(session_state: Dict) -> bool:
62
  """Check if user is authenticated or in demo mode."""
@@ -1569,6 +1570,35 @@ def create_interface() -> gr.Blocks:
1569
  max-height: 400px;
1570
  }
1571
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1572
  """
1573
 
1574
  with gr.Blocks(
@@ -1639,14 +1669,16 @@ def create_interface() -> gr.Blocks:
1639
  # Login Tab
1640
  with gr.Tab("Sign In"):
1641
  login_email = gr.Textbox(
1642
- label="Email",
1643
  placeholder="[email protected]",
1644
- type="email"
 
1645
  )
1646
  login_password = gr.Textbox(
1647
  label="Password",
1648
  placeholder="Enter your password",
1649
- type="password"
 
1650
  )
1651
  forgot_password_btn = gr.Button(
1652
  "Forgot password?",
@@ -1661,23 +1693,33 @@ def create_interface() -> gr.Blocks:
1661
  with gr.Tab("Create Account"):
1662
  signup_username = gr.Textbox(
1663
  label="Username (optional)",
1664
- placeholder="Choose a username"
 
1665
  )
1666
  signup_email = gr.Textbox(
1667
- label="Email",
1668
  placeholder="[email protected]",
1669
- type="email"
 
1670
  )
 
 
1671
  signup_password = gr.Textbox(
1672
  label="Password",
1673
- placeholder="At least 6 characters",
1674
- type="password"
 
1675
  )
 
 
1676
  signup_confirm = gr.Textbox(
1677
  label="Confirm Password",
1678
  placeholder="Re-enter your password",
1679
- type="password"
 
1680
  )
 
 
1681
  gr.Markdown(
1682
  "*By signing up, you agree this is for educational purposes only and not financial advice.*",
1683
  elem_classes="disclaimer-text"
@@ -1702,9 +1744,10 @@ def create_interface() -> gr.Blocks:
1702
  gr.Markdown("Enter your email address and we'll send you a password reset link.")
1703
 
1704
  reset_email = gr.Textbox(
1705
- label="Email",
1706
  placeholder="[email protected]",
1707
- type="email"
 
1708
  )
1709
  with gr.Row():
1710
  reset_submit_btn = gr.Button("Send Reset Link", variant="primary", size="lg")
@@ -1719,21 +1762,24 @@ def create_interface() -> gr.Blocks:
1719
  # Hidden fields to store recovery token_hash and email (populated by JavaScript)
1720
  recovery_token_hash = gr.Textbox(visible=False, elem_id="recovery-token-hash")
1721
  recovery_email = gr.Textbox(
1722
- label="Email",
1723
  placeholder="[email protected]",
1724
  type="email",
1725
- info="Enter the email address you used to request the password reset"
 
1726
  )
1727
 
1728
  new_password = gr.Textbox(
1729
  label="New Password",
1730
- placeholder="At least 6 characters",
1731
- type="password"
 
1732
  )
1733
  confirm_new_password = gr.Textbox(
1734
  label="Confirm New Password",
1735
  placeholder="Re-enter your new password",
1736
- type="password"
 
1737
  )
1738
  with gr.Row():
1739
  update_password_btn = gr.Button("Update Password", variant="primary", size="lg")
@@ -1924,6 +1970,15 @@ def create_interface() -> gr.Blocks:
1924
 
1925
  # Results Page (tabbed interface)
1926
  with gr.Group(visible=False) as results_page:
 
 
 
 
 
 
 
 
 
1927
  # Main tabbed interface
1928
  with gr.Tabs() as results_tabs:
1929
  # Tab 1: Analysis Results
@@ -2242,6 +2297,144 @@ def create_interface() -> gr.Blocks:
2242
  logger.error(f"Failed to view historical analysis: {e}")
2243
  return f"**Error**: Failed to load analysis details: {str(e)}"
2244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2245
  def show_history_page():
2246
  """Navigate to history page."""
2247
  return {
@@ -2402,6 +2595,17 @@ Please try again with different parameters.
2402
  show_progress="full"
2403
  )
2404
 
 
 
 
 
 
 
 
 
 
 
 
2405
  # Auto-load history when History tab is selected
2406
  def load_history_if_selected(evt: gr.SelectData, session):
2407
  """Load history only when History tab is selected."""
@@ -2481,8 +2685,10 @@ Please try again with different parameters.
2481
  if password != confirm_password:
2482
  return current_session, "❌ Passwords do not match"
2483
 
2484
- if len(password) < 6:
2485
- return current_session, "❌ Password must be at least 6 characters"
 
 
2486
 
2487
  success, message, session = await auth.signup(email, password, username)
2488
 
@@ -2556,9 +2762,9 @@ Please try again with different parameters.
2556
  """Synchronous wrapper for login."""
2557
  return asyncio.run(handle_login(email, password, current_session))
2558
 
2559
- def sync_signup(email: str, password: str, confirm_password: str, username: str, terms_accepted: bool, current_session: Dict):
2560
  """Synchronous wrapper for signup."""
2561
- return asyncio.run(handle_signup(email, password, confirm_password, username, terms_accepted, current_session))
2562
 
2563
  def sync_logout(current_session: Dict):
2564
  """Synchronous wrapper for logout."""
@@ -2592,8 +2798,9 @@ Please try again with different parameters.
2592
  if password != confirm:
2593
  return "❌ Passwords do not match"
2594
 
2595
- if len(password) < 6:
2596
- return "❌ Password must be at least 6 characters"
 
2597
 
2598
  success, message = await auth.update_password(password, email, token_hash)
2599
  return message
@@ -2618,6 +2825,27 @@ Please try again with different parameters.
2618
  outputs=[session_state, signup_message]
2619
  )
2620
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2621
  # Password reset handlers
2622
  forgot_password_btn.click(
2623
  lambda: (gr.update(visible=False), gr.update(visible=True)),
 
57
  )
58
  from backend.rate_limiting.fixed_window import TieredFixedWindowLimiter
59
  from backend.auth import auth, UserSession
60
+ from backend.export import export_analysis_to_csv, export_analysis_to_pdf
61
 
62
  def check_authentication(session_state: Dict) -> bool:
63
  """Check if user is authenticated or in demo mode."""
 
1570
  max-height: 400px;
1571
  }
1572
  }
1573
+
1574
+ /* Accessibility - WCAG AA Colour Contrast Improvements */
1575
+ .demo-subtitle {
1576
+ color: rgba(255, 255, 255, 0.95) !important; /* Increased from 0.7 for better contrast */
1577
+ font-size: 14px;
1578
+ }
1579
+
1580
+ .disclaimer-text {
1581
+ color: rgba(255, 255, 255, 0.95) !important; /* Increased contrast */
1582
+ font-size: 13px;
1583
+ }
1584
+
1585
+ /* Update all placeholder text for better visibility */
1586
+ input::placeholder, textarea::placeholder {
1587
+ color: rgba(255, 255, 255, 0.7) !important; /* Increased from 0.5 */
1588
+ }
1589
+
1590
+ /* Add focus indicators for keyboard navigation */
1591
+ input:focus, textarea:focus, button:focus, select:focus {
1592
+ outline: 3px solid #4A9EFF !important;
1593
+ outline-offset: 2px;
1594
+ }
1595
+
1596
+ /* Validation message colours with better contrast */
1597
+ #signup-email-validation, #password-match-validation {
1598
+ font-size: 0.85rem;
1599
+ margin-top: 0.25rem;
1600
+ font-weight: 500;
1601
+ }
1602
  """
1603
 
1604
  with gr.Blocks(
 
1669
  # Login Tab
1670
  with gr.Tab("Sign In"):
1671
  login_email = gr.Textbox(
1672
+ label="Email Address",
1673
  placeholder="[email protected]",
1674
+ type="email",
1675
+ elem_id="login-email"
1676
  )
1677
  login_password = gr.Textbox(
1678
  label="Password",
1679
  placeholder="Enter your password",
1680
+ type="password",
1681
+ elem_id="login-password"
1682
  )
1683
  forgot_password_btn = gr.Button(
1684
  "Forgot password?",
 
1693
  with gr.Tab("Create Account"):
1694
  signup_username = gr.Textbox(
1695
  label="Username (optional)",
1696
+ placeholder="Choose a username",
1697
+ elem_id="signup-username"
1698
  )
1699
  signup_email = gr.Textbox(
1700
+ label="Email Address",
1701
  placeholder="[email protected]",
1702
+ type="email",
1703
+ elem_id="signup-email"
1704
  )
1705
+ signup_email_validation = gr.Markdown("", elem_id="signup-email-validation")
1706
+
1707
  signup_password = gr.Textbox(
1708
  label="Password",
1709
+ placeholder="At least 15 characters (NIST 2024 requirement)",
1710
+ type="password",
1711
+ elem_id="signup-password"
1712
  )
1713
+ signup_password_strength = gr.Markdown("", elem_id="password-strength")
1714
+
1715
  signup_confirm = gr.Textbox(
1716
  label="Confirm Password",
1717
  placeholder="Re-enter your password",
1718
+ type="password",
1719
+ elem_id="signup-confirm-password"
1720
  )
1721
+ signup_password_match = gr.Markdown("", elem_id="password-match-validation")
1722
+
1723
  gr.Markdown(
1724
  "*By signing up, you agree this is for educational purposes only and not financial advice.*",
1725
  elem_classes="disclaimer-text"
 
1744
  gr.Markdown("Enter your email address and we'll send you a password reset link.")
1745
 
1746
  reset_email = gr.Textbox(
1747
+ label="Email Address",
1748
  placeholder="[email protected]",
1749
+ type="email",
1750
+ elem_id="reset-email"
1751
  )
1752
  with gr.Row():
1753
  reset_submit_btn = gr.Button("Send Reset Link", variant="primary", size="lg")
 
1762
  # Hidden fields to store recovery token_hash and email (populated by JavaScript)
1763
  recovery_token_hash = gr.Textbox(visible=False, elem_id="recovery-token-hash")
1764
  recovery_email = gr.Textbox(
1765
+ label="Email Address",
1766
  placeholder="[email protected]",
1767
  type="email",
1768
+ info="Enter the email address you used to request the password reset",
1769
+ elem_id="recovery-email"
1770
  )
1771
 
1772
  new_password = gr.Textbox(
1773
  label="New Password",
1774
+ placeholder="At least 15 characters (NIST 2024 requirement)",
1775
+ type="password",
1776
+ elem_id="new-password"
1777
  )
1778
  confirm_new_password = gr.Textbox(
1779
  label="Confirm New Password",
1780
  placeholder="Re-enter your new password",
1781
+ type="password",
1782
+ elem_id="confirm-new-password"
1783
  )
1784
  with gr.Row():
1785
  update_password_btn = gr.Button("Update Password", variant="primary", size="lg")
 
1970
 
1971
  # Results Page (tabbed interface)
1972
  with gr.Group(visible=False) as results_page:
1973
+ # Header with actions (always visible)
1974
+ with gr.Row(elem_id="results-header"):
1975
+ with gr.Column(scale=3):
1976
+ gr.Markdown("# Portfolio Analysis Results", elem_classes="page-title")
1977
+ with gr.Column(scale=1):
1978
+ with gr.Row():
1979
+ export_pdf_btn = gr.Button("📄 Export PDF", size="sm")
1980
+ export_csv_btn = gr.Button("📊 Export CSV", size="sm")
1981
+
1982
  # Main tabbed interface
1983
  with gr.Tabs() as results_tabs:
1984
  # Tab 1: Analysis Results
 
2297
  logger.error(f"Failed to view historical analysis: {e}")
2298
  return f"**Error**: Failed to load analysis details: {str(e)}"
2299
 
2300
+ def export_current_analysis_csv():
2301
+ """Export current analysis to CSV.
2302
+
2303
+ Returns:
2304
+ Path to temporary CSV file, or None if no analysis available
2305
+ """
2306
+ global LAST_ANALYSIS_STATE
2307
+
2308
+ if not LAST_ANALYSIS_STATE:
2309
+ logger.warning("No analysis state available for CSV export")
2310
+ return None
2311
+
2312
+ try:
2313
+ csv_content = export_analysis_to_csv(LAST_ANALYSIS_STATE)
2314
+
2315
+ # Create temporary file
2316
+ import tempfile
2317
+ with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', prefix='portfolio_analysis_') as f:
2318
+ f.write(csv_content)
2319
+ logger.info(f"CSV export created: {f.name}")
2320
+ return f.name
2321
+ except Exception as e:
2322
+ logger.error(f"Failed to export CSV: {e}")
2323
+ return None
2324
+
2325
+ def export_current_analysis_pdf():
2326
+ """Export current analysis to PDF.
2327
+
2328
+ Returns:
2329
+ Path to temporary PDF file, or None if no analysis available
2330
+ """
2331
+ global LAST_ANALYSIS_STATE
2332
+
2333
+ if not LAST_ANALYSIS_STATE:
2334
+ logger.warning("No analysis state available for PDF export")
2335
+ return None
2336
+
2337
+ try:
2338
+ pdf_bytes = export_analysis_to_pdf(LAST_ANALYSIS_STATE)
2339
+
2340
+ # Create temporary file
2341
+ import tempfile
2342
+ with tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.pdf', prefix='portfolio_analysis_') as f:
2343
+ f.write(pdf_bytes)
2344
+ logger.info(f"PDF export created: {f.name}")
2345
+ return f.name
2346
+ except Exception as e:
2347
+ logger.error(f"Failed to export PDF: {e}")
2348
+ return None
2349
+
2350
+ def validate_email_format(email: str) -> str:
2351
+ """Validate email format in real-time.
2352
+
2353
+ Args:
2354
+ email: Email address to validate
2355
+
2356
+ Returns:
2357
+ Validation message
2358
+ """
2359
+ import re
2360
+
2361
+ if not email:
2362
+ return ""
2363
+
2364
+ email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
2365
+
2366
+ if re.match(email_regex, email):
2367
+ return "✓ Valid email format"
2368
+ else:
2369
+ return "❌ Invalid email format"
2370
+
2371
+ def validate_password_match(password: str, confirm: str) -> str:
2372
+ """Check if passwords match in real-time.
2373
+
2374
+ Args:
2375
+ password: Password field value
2376
+ confirm: Confirmation field value
2377
+
2378
+ Returns:
2379
+ Validation message
2380
+ """
2381
+ if not confirm:
2382
+ return ""
2383
+
2384
+ if password == confirm:
2385
+ return "✓ Passwords match"
2386
+ else:
2387
+ return "❌ Passwords do not match"
2388
+
2389
+ def check_password_strength(password: str) -> str:
2390
+ """Real-time password strength indicator.
2391
+
2392
+ Args:
2393
+ password: Password to evaluate
2394
+
2395
+ Returns:
2396
+ Strength indicator with feedback
2397
+ """
2398
+ import re
2399
+
2400
+ if not password:
2401
+ return ""
2402
+
2403
+ score = 0
2404
+ feedback = []
2405
+
2406
+ # Length check (main NIST requirement)
2407
+ if len(password) >= 15:
2408
+ score += 3 # Length is most important
2409
+ elif len(password) >= 12:
2410
+ score += 2
2411
+ feedback.append("15+ characters")
2412
+ elif len(password) >= 8:
2413
+ score += 1
2414
+ feedback.append("15+ characters")
2415
+ else:
2416
+ feedback.append("15+ characters")
2417
+
2418
+ # Optional complexity (not required by NIST)
2419
+ if re.search(r'[A-Z]', password):
2420
+ score += 1
2421
+ if re.search(r'[a-z]', password):
2422
+ score += 1
2423
+ if re.search(r'\d', password):
2424
+ score += 1
2425
+ if re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
2426
+ score += 1
2427
+
2428
+ # Generate feedback message
2429
+ if score <= 2:
2430
+ return "🔴 **Weak** - Add: " + ", ".join(feedback) if feedback else "🔴 **Weak**"
2431
+ elif score <= 4:
2432
+ return "🟠 **Moderate** - " + (("Add: " + ", ".join(feedback)) if feedback else "Consider 15+ characters")
2433
+ elif score <= 6:
2434
+ return "🟡 **Good** - " + (("Add: " + ", ".join(feedback)) if feedback else "Meets requirements")
2435
+ else:
2436
+ return "🟢 **Strong** - Exceeds requirements"
2437
+
2438
  def show_history_page():
2439
  """Navigate to history page."""
2440
  return {
 
2595
  show_progress="full"
2596
  )
2597
 
2598
+ # Export buttons
2599
+ export_pdf_btn.click(
2600
+ export_current_analysis_pdf,
2601
+ outputs=gr.File(label="Download PDF", visible=True)
2602
+ )
2603
+
2604
+ export_csv_btn.click(
2605
+ export_current_analysis_csv,
2606
+ outputs=gr.File(label="Download CSV", visible=True)
2607
+ )
2608
+
2609
  # Auto-load history when History tab is selected
2610
  def load_history_if_selected(evt: gr.SelectData, session):
2611
  """Load history only when History tab is selected."""
 
2685
  if password != confirm_password:
2686
  return current_session, "❌ Passwords do not match"
2687
 
2688
+ # NIST SP 800-63B-4: Minimum 15 characters for single-factor auth
2689
+ # No composition rules (uppercase, numbers, symbols) required
2690
+ if len(password) < 15:
2691
+ return current_session, "❌ Password must be at least 15 characters (NIST 2024 requirement)"
2692
 
2693
  success, message, session = await auth.signup(email, password, username)
2694
 
 
2762
  """Synchronous wrapper for login."""
2763
  return asyncio.run(handle_login(email, password, current_session))
2764
 
2765
+ def sync_signup(email: str, password: str, confirm_password: str, username: str, current_session: Dict):
2766
  """Synchronous wrapper for signup."""
2767
+ return asyncio.run(handle_signup(email, password, confirm_password, username, current_session))
2768
 
2769
  def sync_logout(current_session: Dict):
2770
  """Synchronous wrapper for logout."""
 
2798
  if password != confirm:
2799
  return "❌ Passwords do not match"
2800
 
2801
+ # NIST SP 800-63B-4: Minimum 15 characters for single-factor auth
2802
+ if len(password) < 15:
2803
+ return "❌ Password must be at least 15 characters (NIST 2024 requirement)"
2804
 
2805
  success, message = await auth.update_password(password, email, token_hash)
2806
  return message
 
2825
  outputs=[session_state, signup_message]
2826
  )
2827
 
2828
+ # Real-time email validation
2829
+ signup_email.change(
2830
+ validate_email_format,
2831
+ inputs=[signup_email],
2832
+ outputs=[signup_email_validation]
2833
+ )
2834
+
2835
+ # Real-time password strength meter
2836
+ signup_password.change(
2837
+ check_password_strength,
2838
+ inputs=[signup_password],
2839
+ outputs=[signup_password_strength]
2840
+ )
2841
+
2842
+ # Real-time password match validation
2843
+ signup_confirm.change(
2844
+ validate_password_match,
2845
+ inputs=[signup_password, signup_confirm],
2846
+ outputs=[signup_password_match]
2847
+ )
2848
+
2849
  # Password reset handlers
2850
  forgot_password_btn.click(
2851
  lambda: (gr.update(visible=False), gr.update(visible=True)),
backend/export.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export functionality for portfolio analyses.
2
+
3
+ Provides PDF and CSV export capabilities for analysis results.
4
+ """
5
+
6
+ import io
7
+ from typing import Dict, Any, List, Optional
8
+ from decimal import Decimal
9
+ from datetime import datetime
10
+ import csv
11
+ import logging
12
+
13
+ from reportlab.lib import colors
14
+ from reportlab.lib.pagesizes import letter, A4
15
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak
16
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
17
+ from reportlab.lib.units import inch
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def export_analysis_to_csv(analysis_results: Dict[str, Any]) -> str:
23
+ """Export analysis results to CSV format.
24
+
25
+ Args:
26
+ analysis_results: Complete analysis results dictionary
27
+
28
+ Returns:
29
+ CSV string ready for download
30
+ """
31
+ output = io.StringIO()
32
+ writer = csv.writer(output)
33
+
34
+ # Headers
35
+ writer.writerow(["Portfolio Analysis Export"])
36
+ writer.writerow(["Generated:", datetime.now().isoformat()])
37
+ writer.writerow([])
38
+
39
+ # Holdings
40
+ writer.writerow(["Portfolio Holdings"])
41
+ writer.writerow(["Ticker", "Quantity", "Market Value", "Weight %"])
42
+
43
+ holdings = analysis_results.get('holdings', [])
44
+ for holding in holdings:
45
+ ticker = holding.get('ticker', '')
46
+ quantity = holding.get('quantity', 0)
47
+ market_value = holding.get('market_value', 0)
48
+ weight = holding.get('weight', 0) * 100
49
+
50
+ writer.writerow([ticker, quantity, f"£{market_value:,.2f}", f"{weight:.2f}%"])
51
+
52
+ writer.writerow([])
53
+
54
+ # Key Metrics
55
+ writer.writerow(["Key Metrics"])
56
+ risk_analysis = analysis_results.get('risk_analysis', {})
57
+ risk_metrics = risk_analysis.get('risk_metrics', {})
58
+
59
+ writer.writerow(["Metric", "Value"])
60
+ writer.writerow(["Sharpe Ratio", risk_metrics.get('sharpe_ratio', 'N/A')])
61
+
62
+ volatility = risk_metrics.get('volatility_annual', 0)
63
+ if isinstance(volatility, (int, float)):
64
+ writer.writerow(["Volatility", f"{volatility*100:.2f}%"])
65
+ else:
66
+ writer.writerow(["Volatility", str(volatility)])
67
+
68
+ var_95 = risk_analysis.get('var_95', {})
69
+ var_value = var_95.get('var_percentage', 'N/A') if isinstance(var_95, dict) else var_95
70
+ writer.writerow(["VaR (95%)", f"{var_value}%"])
71
+
72
+ cvar_95 = risk_analysis.get('cvar_95', {})
73
+ cvar_value = cvar_95.get('cvar_percentage', 'N/A') if isinstance(cvar_95, dict) else cvar_95
74
+ writer.writerow(["CVaR (95%)", f"{cvar_value}%"])
75
+
76
+ writer.writerow([])
77
+
78
+ # AI Synthesis
79
+ writer.writerow(["AI Analysis"])
80
+ ai_synthesis = analysis_results.get('ai_synthesis', '')
81
+ if ai_synthesis:
82
+ # Split into lines for better CSV formatting
83
+ for line in ai_synthesis.split('\n'):
84
+ if line.strip():
85
+ writer.writerow([line.strip()])
86
+
87
+ writer.writerow([])
88
+
89
+ # Recommendations
90
+ writer.writerow(["Recommendations"])
91
+ recommendations = analysis_results.get('recommendations', [])
92
+ for i, rec in enumerate(recommendations, 1):
93
+ writer.writerow([f"{i}.", rec])
94
+
95
+ return output.getvalue()
96
+
97
+
98
+ def export_analysis_to_pdf(analysis_results: Dict[str, Any]) -> bytes:
99
+ """Export analysis results to PDF format.
100
+
101
+ Args:
102
+ analysis_results: Complete analysis results dictionary
103
+
104
+ Returns:
105
+ PDF bytes ready for download
106
+ """
107
+ buffer = io.BytesIO()
108
+ doc = SimpleDocTemplate(buffer, pagesize=letter)
109
+ story = []
110
+ styles = getSampleStyleSheet()
111
+
112
+ # Title
113
+ title_style = ParagraphStyle(
114
+ 'CustomTitle',
115
+ parent=styles['Heading1'],
116
+ fontSize=24,
117
+ textColor=colors.HexColor('#05478A'),
118
+ spaceAfter=30,
119
+ )
120
+ story.append(Paragraph("Portfolio Analysis Report", title_style))
121
+ story.append(Paragraph(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}", styles['Normal']))
122
+ story.append(Spacer(1, 0.5*inch))
123
+
124
+ # Holdings Table
125
+ story.append(Paragraph("Portfolio Holdings", styles['Heading2']))
126
+
127
+ holdings = analysis_results.get('holdings', [])
128
+ holdings_data = [["Ticker", "Quantity", "Market Value", "Weight %"]]
129
+
130
+ for holding in holdings:
131
+ ticker = holding.get('ticker', '')
132
+ quantity = holding.get('quantity', 0)
133
+ market_value = holding.get('market_value', 0)
134
+ weight = holding.get('weight', 0) * 100
135
+
136
+ holdings_data.append([
137
+ ticker,
138
+ f"{quantity:.2f}",
139
+ f"£{market_value:,.2f}",
140
+ f"{weight:.2f}%"
141
+ ])
142
+
143
+ holdings_table = Table(holdings_data)
144
+ holdings_table.setStyle(TableStyle([
145
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#05478A')),
146
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
147
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
148
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
149
+ ('FONTSIZE', (0, 0), (-1, 0), 12),
150
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
151
+ ('BACKGROUND', (0, 1), (-1, -1), colors.beige),
152
+ ('GRID', (0, 0), (-1, -1), 1, colors.black)
153
+ ]))
154
+
155
+ story.append(holdings_table)
156
+ story.append(Spacer(1, 0.5*inch))
157
+
158
+ # Key Metrics
159
+ story.append(Paragraph("Key Metrics", styles['Heading2']))
160
+
161
+ risk_analysis = analysis_results.get('risk_analysis', {})
162
+ risk_metrics = risk_analysis.get('risk_metrics', {})
163
+
164
+ metrics_data = [["Metric", "Value"]]
165
+ metrics_data.append(["Sharpe Ratio", f"{risk_metrics.get('sharpe_ratio', 0):.3f}"])
166
+
167
+ volatility = risk_metrics.get('volatility_annual', 0)
168
+ if isinstance(volatility, (int, float)):
169
+ metrics_data.append(["Volatility", f"{volatility*100:.2f}%"])
170
+ else:
171
+ metrics_data.append(["Volatility", str(volatility)])
172
+
173
+ var_95 = risk_analysis.get('var_95', {})
174
+ var_value = var_95.get('var_percentage', 0) if isinstance(var_95, dict) else var_95
175
+ metrics_data.append(["VaR (95%)", f"{var_value:.2f}%"])
176
+
177
+ cvar_95 = risk_analysis.get('cvar_95', {})
178
+ cvar_value = cvar_95.get('cvar_percentage', 0) if isinstance(cvar_95, dict) else cvar_95
179
+ metrics_data.append(["CVaR (95%)", f"{cvar_value:.2f}%"])
180
+
181
+ metrics_table = Table(metrics_data)
182
+ metrics_table.setStyle(TableStyle([
183
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#048CFC')),
184
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
185
+ ('ALIGN', (0, 0), (-1, -1), 'LEFT'),
186
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
187
+ ('FONTSIZE', (0, 0), (-1, 0), 12),
188
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
189
+ ('BACKGROUND', (0, 1), (-1, -1), colors.lightblue),
190
+ ('GRID', (0, 0), (-1, -1), 1, colors.black)
191
+ ]))
192
+
193
+ story.append(metrics_table)
194
+ story.append(Spacer(1, 0.5*inch))
195
+
196
+ # AI Synthesis
197
+ story.append(Paragraph("AI Analysis", styles['Heading2']))
198
+ ai_synthesis = analysis_results.get('ai_synthesis', '')
199
+ if ai_synthesis:
200
+ # Clean and format the text
201
+ paragraphs = ai_synthesis.split('\n\n')
202
+ for para in paragraphs:
203
+ if para.strip():
204
+ story.append(Paragraph(para.strip(), styles['Normal']))
205
+ story.append(Spacer(1, 0.1*inch))
206
+
207
+ # Recommendations
208
+ story.append(Spacer(1, 0.3*inch))
209
+ story.append(Paragraph("Recommendations", styles['Heading2']))
210
+ recommendations = analysis_results.get('recommendations', [])
211
+ for i, rec in enumerate(recommendations, 1):
212
+ story.append(Paragraph(f"{i}. {rec}", styles['Normal']))
213
+ story.append(Spacer(1, 0.1*inch))
214
+
215
+ # Build PDF
216
+ try:
217
+ doc.build(story)
218
+ except Exception as e:
219
+ logger.error(f"Failed to build PDF: {e}")
220
+ raise
221
+
222
+ buffer.seek(0)
223
+ return buffer.getvalue()
backend/visualizations/plotly_charts.py CHANGED
@@ -343,19 +343,33 @@ def create_correlation_heatmap(
343
  if not historical_data or len(historical_data) < 2:
344
  return None
345
 
346
- # Build returns dataframe
347
  returns_data = {}
 
 
 
348
  for ticker, hist in historical_data.items():
349
  prices = hist.get('close_prices', [])
350
  if len(prices) > 1:
351
  returns = pd.Series(prices).pct_change().dropna()
352
  returns_data[ticker] = returns
 
353
 
354
  if len(returns_data) < 2:
 
355
  return None
356
 
 
 
 
 
 
357
  # Create DataFrame and calculate correlation
358
- df = pd.DataFrame(returns_data)
 
 
 
 
359
  corr_matrix = df.corr()
360
 
361
  fig = go.Figure(data=go.Heatmap(
 
343
  if not historical_data or len(historical_data) < 2:
344
  return None
345
 
346
+ # Build returns dataframe - ENSURE ALL ASSETS ARE INCLUDED
347
  returns_data = {}
348
+ min_length = float('inf')
349
+
350
+ # First pass: calculate all returns and find minimum length
351
  for ticker, hist in historical_data.items():
352
  prices = hist.get('close_prices', [])
353
  if len(prices) > 1:
354
  returns = pd.Series(prices).pct_change().dropna()
355
  returns_data[ticker] = returns
356
+ min_length = min(min_length, len(returns))
357
 
358
  if len(returns_data) < 2:
359
+ logger.warning("Insufficient returns data for correlation matrix")
360
  return None
361
 
362
+ # Second pass: align all series to same length
363
+ aligned_returns = {}
364
+ for ticker, returns in returns_data.items():
365
+ aligned_returns[ticker] = returns.iloc[-min_length:]
366
+
367
  # Create DataFrame and calculate correlation
368
+ df = pd.DataFrame(aligned_returns)
369
+
370
+ # VERIFY: Check that df contains all expected tickers
371
+ logger.info(f"Correlation matrix includes {len(df.columns)} assets: {list(df.columns)}")
372
+
373
  corr_matrix = df.corr()
374
 
375
  fig = go.Figure(data=go.Heatmap(
pyproject.toml CHANGED
@@ -38,6 +38,11 @@ dependencies = [
38
  "numpy>=2.0.0",
39
  "yfinance>=0.2.40",
40
  "scipy>=1.11.0",
 
 
 
 
 
41
  # ML Models (P1 - Optional)
42
  "torch>=2.0.0",
43
  "xgboost>=3.1.0",
 
38
  "numpy>=2.0.0",
39
  "yfinance>=0.2.40",
40
  "scipy>=1.11.0",
41
+ "matplotlib>=3.8.0",
42
+ "plotly>=5.18.0",
43
+ # Export & Reporting
44
+ "reportlab>=4.0.0",
45
+ "pypdf>=3.17.0",
46
  # ML Models (P1 - Optional)
47
  "torch>=2.0.0",
48
  "xgboost>=3.1.0",