jashdoshi77 commited on
Commit
c81e8a5
Β·
1 Parent(s): 2ac2c4e

addes more section , improvments , scalability

Browse files
backend/app/config.py CHANGED
@@ -31,9 +31,9 @@ class Settings(BaseSettings):
31
 
32
  # ── App ──────────────────────────────────────────────────────────────
33
  app_name: str = "QuantHedge"
34
- app_version: str = "1.0.0"
35
  debug: bool = False
36
- cors_origins: str = '["*"]'
37
 
38
  @property
39
  def cors_origin_list(self) -> List[str]:
@@ -43,7 +43,7 @@ class Settings(BaseSettings):
43
  return ["http://localhost:5173"]
44
 
45
  # ── Database ─────────────────────────────────────────────────────────
46
- database_url: str = "postgresql+asyncpg://postgres:Jashdoshi7%24@localhost:5432/hedge"
47
 
48
  # ── Redis ────────────────────────────────────────────────────────────
49
  redis_url: str = "redis://localhost:6379/0"
@@ -62,6 +62,10 @@ class Settings(BaseSettings):
62
  news_api_key: str = ""
63
  groq_api_key: str = ""
64
 
 
 
 
 
65
 
66
  @lru_cache()
67
  def get_settings() -> Settings:
 
31
 
32
  # ── App ──────────────────────────────────────────────────────────────
33
  app_name: str = "QuantHedge"
34
+ app_version: str = "2.0.0"
35
  debug: bool = False
36
+ cors_origins: str = '["http://localhost:5173"]'
37
 
38
  @property
39
  def cors_origin_list(self) -> List[str]:
 
43
  return ["http://localhost:5173"]
44
 
45
  # ── Database ─────────────────────────────────────────────────────────
46
+ database_url: str = "sqlite+aiosqlite:///./quanthedge.db"
47
 
48
  # ── Redis ────────────────────────────────────────────────────────────
49
  redis_url: str = "redis://localhost:6379/0"
 
62
  news_api_key: str = ""
63
  groq_api_key: str = ""
64
 
65
+ # ── Quantitative Constants ───────────────────────────────────────────
66
+ risk_free_rate: float = 0.04 # 4% β€” single source of truth
67
+ trading_days_per_year: int = 252
68
+
69
 
70
  @lru_cache()
71
  def get_settings() -> Settings:
backend/app/database.py CHANGED
@@ -55,8 +55,12 @@ else:
55
  if _needs_ssl:
56
  import ssl as _ssl
57
  _ssl_ctx = _ssl.create_default_context()
58
- _ssl_ctx.check_hostname = False
59
- _ssl_ctx.verify_mode = _ssl.CERT_NONE
 
 
 
 
60
  _connect_args["ssl"] = _ssl_ctx
61
 
62
  engine = create_async_engine(
 
55
  if _needs_ssl:
56
  import ssl as _ssl
57
  _ssl_ctx = _ssl.create_default_context()
58
+ # Use proper certificate verification in production
59
+ # Set QH_SSL_VERIFY=0 env var to disable for self-signed certs
60
+ import os
61
+ if os.environ.get("QH_SSL_VERIFY", "1") == "0":
62
+ _ssl_ctx.check_hostname = False
63
+ _ssl_ctx.verify_mode = _ssl.CERT_NONE
64
  _connect_args["ssl"] = _ssl_ctx
65
 
66
  engine = create_async_engine(
backend/app/main.py CHANGED
@@ -114,7 +114,7 @@ def create_app() -> FastAPI:
114
  )
115
 
116
  # Mount routers
117
- from app.routers import auth, backtests, calendar, data, holdings, marketplace, ml, portfolios, quant, research, sentiment, strategies
118
 
119
  app.include_router(auth.router, prefix="/api")
120
  app.include_router(data.router, prefix="/api")
@@ -128,6 +128,7 @@ def create_app() -> FastAPI:
128
  app.include_router(holdings.router)
129
  app.include_router(sentiment.router)
130
  app.include_router(calendar.router)
 
131
 
132
 
133
  # Health check
 
114
  )
115
 
116
  # Mount routers
117
+ from app.routers import auth, backtests, calendar, copilot, data, holdings, marketplace, ml, portfolios, quant, research, sentiment, strategies
118
 
119
  app.include_router(auth.router, prefix="/api")
120
  app.include_router(data.router, prefix="/api")
 
128
  app.include_router(holdings.router)
129
  app.include_router(sentiment.router)
130
  app.include_router(calendar.router)
131
+ app.include_router(copilot.router)
132
 
133
 
134
  # Health check
backend/app/routers/copilot.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HedgeAIRouter.
3
+
4
+ Natural language chat endpoint for the QuantHedge Copilot.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+
11
+ from fastapi import APIRouter, Depends, HTTPException
12
+ from pydantic import BaseModel, Field
13
+ from sqlalchemy.ext.asyncio import AsyncSession
14
+
15
+ from app.database import get_db
16
+ from app.dependencies import get_current_user
17
+ from app.models.user import User
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ router = APIRouter(prefix="/api/copilot", tags=["HedgeAI"])
22
+
23
+
24
+ class CopilotMessage(BaseModel):
25
+ message: str = Field(..., min_length=1, max_length=2000, description="User's natural language query")
26
+
27
+
28
+ @router.post("/chat")
29
+ async def copilot_chat(
30
+ data: CopilotMessage,
31
+ db: AsyncSession = Depends(get_db),
32
+ user: User = Depends(get_current_user),
33
+ ):
34
+ """
35
+ Chat with the QuantHedge HedgeAI.
36
+
37
+ Understands natural language queries about portfolio risk, hedging,
38
+ stress testing, sentiment, and more. Routes to appropriate engines
39
+ and returns conversational responses with suggested actions.
40
+ """
41
+ from app.services.ai_research.copilot import chat
42
+ from app.services.holdings.engine import compute_portfolio_summary
43
+
44
+ # Get portfolio context for the copilot
45
+ try:
46
+ portfolio_context = await compute_portfolio_summary(db, user.id)
47
+ except Exception:
48
+ portfolio_context = None
49
+
50
+ try:
51
+ result = await chat(data.message, portfolio_context)
52
+ return result
53
+ except Exception as e:
54
+ logger.error("Copilot error: %s", e)
55
+ raise HTTPException(status_code=500, detail="Copilot processing failed")
backend/app/routers/holdings.py CHANGED
@@ -2,7 +2,9 @@
2
  Holdings Router.
3
 
4
  CRUD for user holdings plus portfolio summary with live P&L,
5
- and stress test simulation endpoints.
 
 
6
  """
7
 
8
  from __future__ import annotations
@@ -23,7 +25,7 @@ from app.services.holdings.engine import (
23
  delete_holding,
24
  compute_portfolio_summary,
25
  )
26
- from app.services.risk.stress_test import run_stress_test, run_all_scenarios
27
 
28
  router = APIRouter(prefix="/api/holdings", tags=["Holdings"])
29
 
@@ -60,7 +62,17 @@ class StressTestRequest(BaseModel):
60
  custom_shock: Optional[float] = None
61
 
62
 
63
- # ── Endpoints ────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
64
  @router.get("/")
65
  async def list_holdings(
66
  db: AsyncSession = Depends(get_db),
@@ -139,19 +151,27 @@ async def portfolio_summary(
139
  return await compute_portfolio_summary(db, user.id)
140
 
141
 
 
142
  @router.post("/stress-test")
143
  async def stress_test(
144
  data: StressTestRequest,
145
  db: AsyncSession = Depends(get_db),
146
  user: User = Depends(get_current_user),
147
  ):
148
- """Run a stress test on the user's portfolio."""
149
  summary = await compute_portfolio_summary(db, user.id)
150
  holdings = summary.get("holdings", [])
151
  if not holdings:
152
  raise HTTPException(status_code=400, detail="No holdings to test")
153
 
154
- result = run_stress_test(holdings, scenario_id=data.scenario_id, custom_shock=data.custom_shock)
 
 
 
 
 
 
 
155
  if "error" in result:
156
  raise HTTPException(status_code=400, detail=result["error"])
157
  return result
@@ -167,9 +187,13 @@ async def all_stress_tests(
167
  holdings = summary.get("holdings", [])
168
  if not holdings:
169
  return {"scenarios": []}
170
- return {"scenarios": run_all_scenarios(holdings)}
171
 
 
 
 
172
 
 
 
173
  @router.get("/hedge-recommendations")
174
  async def hedge_recommendations(
175
  db: AsyncSession = Depends(get_db),
@@ -186,21 +210,23 @@ async def hedge_recommendations(
186
  return generate_hedge_recommendations(holdings, total_value)
187
 
188
 
 
189
  @router.get("/options-hedge")
190
  async def options_hedge(
191
  db: AsyncSession = Depends(get_db),
192
  user: User = Depends(get_current_user),
193
  ):
194
- """Calculate options hedge strategies for portfolio holdings."""
195
- from app.services.risk.options_calculator import calculate_options_hedge
196
 
197
  summary = await compute_portfolio_summary(db, user.id)
198
  holdings = summary.get("holdings", [])
199
  if not holdings:
200
  return {"strategies": []}
201
- return calculate_options_hedge(holdings)
202
 
203
 
 
204
  @router.get("/rebalance")
205
  async def rebalance(
206
  db: AsyncSession = Depends(get_db),
@@ -217,6 +243,7 @@ async def rebalance(
217
  return compute_rebalance(holdings, total_value)
218
 
219
 
 
220
  @router.get("/correlation")
221
  async def correlation_matrix(
222
  db: AsyncSession = Depends(get_db),
@@ -232,3 +259,124 @@ async def correlation_matrix(
232
  return {"matrix": [], "tickers": tickers, "pairs": [], "risk_flags": []}
233
  return await compute_correlation_matrix(tickers)
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  Holdings Router.
3
 
4
  CRUD for user holdings plus portfolio summary with live P&L,
5
+ stress test simulation, Monte Carlo VaR, P&L attribution,
6
+ behavioral bias detection, crisis replay, portfolio DNA,
7
+ health score, and rolling metrics endpoints.
8
  """
9
 
10
  from __future__ import annotations
 
25
  delete_holding,
26
  compute_portfolio_summary,
27
  )
28
+ from app.services.risk.stress_test import run_stress_test, run_all_scenarios, compute_real_betas
29
 
30
  router = APIRouter(prefix="/api/holdings", tags=["Holdings"])
31
 
 
62
  custom_shock: Optional[float] = None
63
 
64
 
65
+ class CrisisReplayRequest(BaseModel):
66
+ crisis_id: str = Field("covid_2020", description="Crisis ID to replay")
67
+
68
+
69
+ class MonteCarloRequest(BaseModel):
70
+ horizon_days: int = Field(60, ge=5, le=252)
71
+ n_simulations: int = Field(10000, ge=100, le=50000)
72
+ regime_conditioned: bool = False
73
+
74
+
75
+ # ── CRUD Endpoints ───────────────────────────────────────────────────────
76
  @router.get("/")
77
  async def list_holdings(
78
  db: AsyncSession = Depends(get_db),
 
151
  return await compute_portfolio_summary(db, user.id)
152
 
153
 
154
+ # ── Stress Test ──────────────────────────────────────────────────────────
155
  @router.post("/stress-test")
156
  async def stress_test(
157
  data: StressTestRequest,
158
  db: AsyncSession = Depends(get_db),
159
  user: User = Depends(get_current_user),
160
  ):
161
+ """Run a stress test on the user's portfolio (with real computed betas)."""
162
  summary = await compute_portfolio_summary(db, user.id)
163
  holdings = summary.get("holdings", [])
164
  if not holdings:
165
  raise HTTPException(status_code=400, detail="No holdings to test")
166
 
167
+ # Compute real betas for accurate stress testing
168
+ tickers = [h["ticker"] for h in holdings]
169
+ computed_betas = await compute_real_betas(tickers)
170
+
171
+ result = run_stress_test(
172
+ holdings, scenario_id=data.scenario_id,
173
+ custom_shock=data.custom_shock, computed_betas=computed_betas
174
+ )
175
  if "error" in result:
176
  raise HTTPException(status_code=400, detail=result["error"])
177
  return result
 
187
  holdings = summary.get("holdings", [])
188
  if not holdings:
189
  return {"scenarios": []}
 
190
 
191
+ tickers = [h["ticker"] for h in holdings]
192
+ computed_betas = await compute_real_betas(tickers)
193
+ return {"scenarios": run_all_scenarios(holdings, computed_betas=computed_betas)}
194
 
195
+
196
+ # ── Hedge Recommendations ────────────────────────────────────────────────
197
  @router.get("/hedge-recommendations")
198
  async def hedge_recommendations(
199
  db: AsyncSession = Depends(get_db),
 
210
  return generate_hedge_recommendations(holdings, total_value)
211
 
212
 
213
+ # ── Options Hedge (with Greeks) ──────────────────────────────────────────
214
  @router.get("/options-hedge")
215
  async def options_hedge(
216
  db: AsyncSession = Depends(get_db),
217
  user: User = Depends(get_current_user),
218
  ):
219
+ """Calculate options hedge strategies with full Greeks."""
220
+ from app.services.risk.options_calculator import calculate_options_hedge_async
221
 
222
  summary = await compute_portfolio_summary(db, user.id)
223
  holdings = summary.get("holdings", [])
224
  if not holdings:
225
  return {"strategies": []}
226
+ return await calculate_options_hedge_async(holdings)
227
 
228
 
229
+ # ── Rebalance ────────────────────────────────────────────────────────────
230
  @router.get("/rebalance")
231
  async def rebalance(
232
  db: AsyncSession = Depends(get_db),
 
243
  return compute_rebalance(holdings, total_value)
244
 
245
 
246
+ # ── Correlation ──────────────────────────────────────────────────────────
247
  @router.get("/correlation")
248
  async def correlation_matrix(
249
  db: AsyncSession = Depends(get_db),
 
259
  return {"matrix": [], "tickers": tickers, "pairs": [], "risk_flags": []}
260
  return await compute_correlation_matrix(tickers)
261
 
262
+
263
+ # ── Monte Carlo VaR ─────────────────────────────────────────────────────
264
+ @router.post("/monte-carlo")
265
+ async def monte_carlo_var(
266
+ data: MonteCarloRequest,
267
+ db: AsyncSession = Depends(get_db),
268
+ user: User = Depends(get_current_user),
269
+ ):
270
+ """Run Monte Carlo VaR simulation on portfolio."""
271
+ from app.services.risk.monte_carlo import run_monte_carlo
272
+
273
+ summary = await compute_portfolio_summary(db, user.id)
274
+ holdings = summary.get("holdings", [])
275
+ if not holdings:
276
+ return {"error": "No holdings for simulation"}
277
+ return await run_monte_carlo(
278
+ holdings,
279
+ horizon_days=data.horizon_days,
280
+ n_simulations=data.n_simulations,
281
+ regime_conditioned=data.regime_conditioned,
282
+ )
283
+
284
+
285
+ # ── Portfolio Health Score ───────────────────────────────────────────────
286
+ @router.get("/health-score")
287
+ async def health_score(
288
+ db: AsyncSession = Depends(get_db),
289
+ user: User = Depends(get_current_user),
290
+ ):
291
+ """Compute comprehensive portfolio health score."""
292
+ from app.services.analytics.health_score import compute_health_score
293
+
294
+ summary = await compute_portfolio_summary(db, user.id)
295
+ holdings = summary.get("holdings", [])
296
+ total_value = summary.get("total_value", 0)
297
+ return await compute_health_score(holdings, total_value)
298
+
299
+
300
+ # ── Behavioral Bias Detector ────────────────────────────────────────────
301
+ @router.get("/bias-analysis")
302
+ async def bias_analysis(
303
+ db: AsyncSession = Depends(get_db),
304
+ user: User = Depends(get_current_user),
305
+ ):
306
+ """Detect behavioral biases in portfolio composition."""
307
+ from app.services.analytics.bias_detector import detect_biases
308
+
309
+ summary = await compute_portfolio_summary(db, user.id)
310
+ holdings = summary.get("holdings", [])
311
+ total_value = summary.get("total_value", 0)
312
+ return await detect_biases(holdings, total_value)
313
+
314
+
315
+ # ── Crisis Replay ────────────────────────────────────────────────────────
316
+ @router.post("/crisis-replay")
317
+ async def crisis_replay(
318
+ data: CrisisReplayRequest,
319
+ db: AsyncSession = Depends(get_db),
320
+ user: User = Depends(get_current_user),
321
+ ):
322
+ """Replay a historical crisis against user portfolio."""
323
+ from app.services.risk.crisis_replay import replay_crisis
324
+
325
+ summary = await compute_portfolio_summary(db, user.id)
326
+ holdings = summary.get("holdings", [])
327
+ total_value = summary.get("total_value", 0)
328
+ if not holdings:
329
+ return {"error": "No holdings for crisis replay"}
330
+ return await replay_crisis(holdings, total_value, data.crisis_id)
331
+
332
+
333
+ @router.get("/crisis-replay/all")
334
+ async def all_crisis_replays(
335
+ db: AsyncSession = Depends(get_db),
336
+ user: User = Depends(get_current_user),
337
+ ):
338
+ """Replay all crises and return summary comparison."""
339
+ from app.services.risk.crisis_replay import replay_all_crises
340
+
341
+ summary = await compute_portfolio_summary(db, user.id)
342
+ holdings = summary.get("holdings", [])
343
+ total_value = summary.get("total_value", 0)
344
+ if not holdings:
345
+ return {"crises": [], "available": []}
346
+ return await replay_all_crises(holdings, total_value)
347
+
348
+
349
+ # ── Portfolio DNA ────────────────────────────────────────────────────────
350
+ @router.get("/portfolio-dna")
351
+ async def portfolio_dna(
352
+ db: AsyncSession = Depends(get_db),
353
+ user: User = Depends(get_current_user),
354
+ ):
355
+ """Compute portfolio DNA fingerprint with famous portfolio comparisons."""
356
+ from app.services.analytics.portfolio_dna import compute_portfolio_dna
357
+
358
+ summary = await compute_portfolio_summary(db, user.id)
359
+ holdings = summary.get("holdings", [])
360
+ total_value = summary.get("total_value", 0)
361
+ return await compute_portfolio_dna(holdings, total_value)
362
+
363
+
364
+ # ── P&L Attribution ──────────────────────────────────────────────────────
365
+ @router.get("/attribution")
366
+ async def pl_attribution(
367
+ method: str = "brinson",
368
+ db: AsyncSession = Depends(get_db),
369
+ user: User = Depends(get_current_user),
370
+ ):
371
+ """Compute P&L attribution (Brinson or factor-based)."""
372
+ from app.services.analytics.attribution import brinson_attribution, factor_attribution
373
+
374
+ summary = await compute_portfolio_summary(db, user.id)
375
+ holdings = summary.get("holdings", [])
376
+ total_value = summary.get("total_value", 0)
377
+ if not holdings:
378
+ return {"error": "No holdings for attribution"}
379
+
380
+ if method == "factor":
381
+ return await factor_attribution(holdings, total_value)
382
+ return await brinson_attribution(holdings, total_value)
backend/app/services/ai_research/copilot.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Natural Language Hedging Copilot.
3
+
4
+ Chat-like interface that accepts natural language queries and routes them
5
+ to the appropriate QuantHedge engines:
6
+
7
+ - "How would my portfolio do in a recession?" β†’ Stress Test
8
+ - "What's my biggest risk?" β†’ Risk Analysis
9
+ - "Should I hedge NVDA?" β†’ Options Calculator
10
+ - "Analyze market sentiment for AAPL" β†’ Sentiment Engine
11
+ - "Predict TSLA returns" β†’ XGBoost Predictor
12
+ - "What factors am I exposed to?" β†’ Factor Model
13
+
14
+ Uses Groq LLM for intent classification and response generation.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ from typing import Any, Dict, Optional
22
+
23
+ import aiohttp
24
+
25
+ from app.config import get_settings
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _settings = get_settings()
30
+
31
+
32
+ SYSTEM_PROMPT = """You are QuantHedge Copilot, an AI financial analyst assistant.
33
+ You help users understand their portfolio risk, analyze markets, and make better investment decisions.
34
+
35
+ When the user asks a question, you should:
36
+ 1. Classify their intent into one of these categories:
37
+ - stress_test: portfolio stress testing or crisis scenarios
38
+ - risk_analysis: risk metrics, VaR, volatility analysis
39
+ - options_hedge: options pricing, Greeks, hedge strategies
40
+ - sentiment: market sentiment or news analysis
41
+ - prediction: stock return prediction or forecasting
42
+ - factors: factor analysis or exposure
43
+ - portfolio_health: portfolio health score or overall assessment
44
+ - bias_detection: behavioral bias analysis
45
+ - crisis_replay: historical crisis simulation
46
+ - general: general financial question or advice
47
+
48
+ 2. Extract any relevant tickers, scenarios, or parameters.
49
+
50
+ Respond ONLY with a JSON object in this format:
51
+ {
52
+ "intent": "<category>",
53
+ "tickers": ["TICKER1", "TICKER2"],
54
+ "parameters": {"key": "value"},
55
+ "direct_answer": "<your helpful response to the user>"
56
+ }
57
+
58
+ Be conversational and helpful in direct_answer. Use financial terminology appropriately.
59
+ """
60
+
61
+
62
+ async def chat(
63
+ message: str,
64
+ portfolio_context: Optional[Dict[str, Any]] = None,
65
+ ) -> Dict[str, Any]:
66
+ """
67
+ Process a natural language query from the user.
68
+
69
+ Args:
70
+ message: User's natural language input
71
+ portfolio_context: Optional current portfolio summary for context
72
+
73
+ Returns:
74
+ Response with intent classification, routed data, and conversational reply.
75
+ """
76
+ if not _settings.groq_api_key:
77
+ return _fallback_response(message, portfolio_context)
78
+
79
+ context_text = ""
80
+ if portfolio_context:
81
+ holdings = portfolio_context.get("holdings", [])
82
+ if holdings:
83
+ tickers_str = ", ".join(h.get("ticker", "") for h in holdings[:10])
84
+ total_val = portfolio_context.get("total_value", 0)
85
+ total_pnl = portfolio_context.get("total_pnl", 0)
86
+ context_text = f"\n\nUser's current portfolio: {tickers_str} (Total: ${total_val:,.0f}, P&L: ${total_pnl:,.0f})"
87
+
88
+ try:
89
+ async with aiohttp.ClientSession() as session:
90
+ async with session.post(
91
+ "https://api.groq.com/openai/v1/chat/completions",
92
+ headers={
93
+ "Authorization": f"Bearer {_settings.groq_api_key}",
94
+ "Content-Type": "application/json",
95
+ },
96
+ json={
97
+ "model": "llama-3.3-70b-versatile",
98
+ "messages": [
99
+ {"role": "system", "content": SYSTEM_PROMPT},
100
+ {"role": "user", "content": message + context_text},
101
+ ],
102
+ "temperature": 0.3,
103
+ "max_tokens": 1000,
104
+ },
105
+ timeout=aiohttp.ClientTimeout(total=20),
106
+ ) as resp:
107
+ if resp.status != 200:
108
+ return _fallback_response(message, portfolio_context)
109
+ data = await resp.json()
110
+ content = data["choices"][0]["message"]["content"]
111
+
112
+ # Parse JSON from response
113
+ start = content.find("{")
114
+ end = content.rfind("}") + 1
115
+ if start >= 0 and end > start:
116
+ parsed = json.loads(content[start:end])
117
+ return {
118
+ "intent": parsed.get("intent", "general"),
119
+ "tickers": parsed.get("tickers", []),
120
+ "parameters": parsed.get("parameters", {}),
121
+ "response": parsed.get("direct_answer", content),
122
+ "actions": _get_suggested_actions(parsed.get("intent", "general"), parsed.get("tickers", [])),
123
+ }
124
+ else:
125
+ return {
126
+ "intent": "general",
127
+ "tickers": [],
128
+ "parameters": {},
129
+ "response": content,
130
+ "actions": [],
131
+ }
132
+ except Exception as e:
133
+ logger.warning("Copilot LLM call failed: %s", e)
134
+ return _fallback_response(message, portfolio_context)
135
+
136
+
137
+ def _fallback_response(
138
+ message: str,
139
+ portfolio_context: Optional[Dict[str, Any]] = None,
140
+ ) -> Dict[str, Any]:
141
+ """Keyword-based fallback when LLM is unavailable."""
142
+ msg_lower = message.lower()
143
+
144
+ intent = "general"
145
+ tickers = []
146
+
147
+ if any(w in msg_lower for w in ("stress", "crash", "recession", "scenario")):
148
+ intent = "stress_test"
149
+ elif any(w in msg_lower for w in ("risk", "var", "volatility", "drawdown")):
150
+ intent = "risk_analysis"
151
+ elif any(w in msg_lower for w in ("option", "put", "call", "hedge", "greek")):
152
+ intent = "options_hedge"
153
+ elif any(w in msg_lower for w in ("sentiment", "news", "mood")):
154
+ intent = "sentiment"
155
+ elif any(w in msg_lower for w in ("predict", "forecast", "return", "ml")):
156
+ intent = "prediction"
157
+ elif any(w in msg_lower for w in ("factor", "exposure", "momentum", "value")):
158
+ intent = "factors"
159
+ elif any(w in msg_lower for w in ("health", "score", "grade")):
160
+ intent = "portfolio_health"
161
+ elif any(w in msg_lower for w in ("bias", "behavioral", "disposition")):
162
+ intent = "bias_detection"
163
+ elif any(w in msg_lower for w in ("crisis", "replay", "historical", "covid", "2008")):
164
+ intent = "crisis_replay"
165
+
166
+ # Extract tickers (uppercase 1-5 letter words)
167
+ import re
168
+ potential_tickers = re.findall(r'\b[A-Z]{1,5}\b', message)
169
+ tickers = [t for t in potential_tickers if t not in {"I", "A", "THE", "AND", "FOR", "HOW", "MY", "IS", "TO", "IN", "DO", "ME"}]
170
+
171
+ responses = {
172
+ "stress_test": "I can run stress tests on your portfolio. Try the Stress Test tab to see how your holdings would perform under various scenarios like a market crash or rate hike.",
173
+ "risk_analysis": "Let me help analyze your portfolio risk. Check the Holdings page for comprehensive risk metrics including VaR, volatility, and drawdown analysis.",
174
+ "options_hedge": "I can help with options hedging strategies. The Options Hedge section shows protective puts, covered calls, and collar strategies with full Greeks.",
175
+ "sentiment": "I can analyze market sentiment for any ticker. Head to the Sentiment page for AI-powered news analysis.",
176
+ "prediction": "Our XGBoost model can predict short-term return direction. Use the Markets page to get ML predictions for any ticker.",
177
+ "factors": "Factor analysis breaks down your exposure to systematic risk factors. Check the Factor Analysis page for detailed factor exposures.",
178
+ "portfolio_health": "Your Portfolio Health Score gives a comprehensive 0-100 grade across 7 dimensions. Check the Health page for details.",
179
+ "bias_detection": "The Behavioral Bias Detector analyzes your trading patterns for cognitive biases. Check the Bias page for your scorecard.",
180
+ "crisis_replay": "Historical Crisis Replay shows how your portfolio would have fared during past crises like COVID, 2008, and the Dot-Com bust.",
181
+ "general": "I'm here to help with your portfolio analysis. Try asking about risk, hedging, sentiment, or stress testing!",
182
+ }
183
+
184
+ return {
185
+ "intent": intent,
186
+ "tickers": tickers,
187
+ "parameters": {},
188
+ "response": responses.get(intent, responses["general"]),
189
+ "actions": _get_suggested_actions(intent, tickers),
190
+ }
191
+
192
+
193
+ def _get_suggested_actions(intent: str, tickers: list) -> list:
194
+ """Generate suggested follow-up actions based on intent."""
195
+ actions_map = {
196
+ "stress_test": [
197
+ {"label": "Run All Stress Tests", "route": "/holdings", "tab": "stress"},
198
+ {"label": "View Crisis Replay", "route": "/crisis-replay"},
199
+ ],
200
+ "risk_analysis": [
201
+ {"label": "View Risk Metrics", "route": "/holdings", "tab": "risk"},
202
+ {"label": "Monte Carlo VaR", "route": "/holdings", "tab": "montecarlo"},
203
+ ],
204
+ "options_hedge": [
205
+ {"label": "View Options Strategies", "route": "/holdings", "tab": "options"},
206
+ ],
207
+ "sentiment": [
208
+ {"label": "View Sentiment", "route": "/sentiment"},
209
+ ],
210
+ "prediction": [
211
+ {"label": "View ML Predictions", "route": "/market"},
212
+ ],
213
+ "factors": [
214
+ {"label": "Factor Analysis", "route": "/factors"},
215
+ ],
216
+ "portfolio_health": [
217
+ {"label": "View Health Score", "route": "/portfolio-health"},
218
+ ],
219
+ "bias_detection": [
220
+ {"label": "View Bias Report", "route": "/bias-detector"},
221
+ ],
222
+ "crisis_replay": [
223
+ {"label": "Crisis Replay", "route": "/crisis-replay"},
224
+ ],
225
+ }
226
+ return actions_map.get(intent, [])
backend/app/services/analytics/attribution.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ P&L Attribution Engine.
3
+
4
+ Two attribution methodologies:
5
+ 1. Brinson Attribution β€” allocation effect + selection effect vs benchmark
6
+ 2. Factor-Based Attribution β€” decompose returns into factor contributions
7
+
8
+ Provides waterfall chart data for visualizing P&L breakdown.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Any, Dict, List
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ from app.config import get_settings
20
+ from app.services.data_ingestion.yahoo import yahoo_adapter
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _settings = get_settings()
25
+ TRADING_DAYS = _settings.trading_days_per_year
26
+
27
+
28
+ async def brinson_attribution(
29
+ holdings: List[Dict[str, Any]],
30
+ total_value: float,
31
+ benchmark: str = "SPY",
32
+ period: str = "1y",
33
+ ) -> Dict[str, Any]:
34
+ """
35
+ Brinson-Fachler attribution: decompose portfolio P&L into
36
+ allocation effect, selection effect, and interaction effect.
37
+ """
38
+ if not holdings or total_value <= 0:
39
+ return {"error": "No holdings for attribution"}
40
+
41
+ # Group holdings by sector/asset_class
42
+ sector_map: Dict[str, List[Dict]] = {}
43
+ for h in holdings:
44
+ sector = h.get("asset_class", h.get("sector", "equity"))
45
+ if sector not in sector_map:
46
+ sector_map[sector] = []
47
+ sector_map[sector].append(h)
48
+
49
+ # Compute benchmark returns
50
+ bench_df = await yahoo_adapter.get_price_dataframe(benchmark, period=period)
51
+ if bench_df.empty:
52
+ return {"error": "Could not fetch benchmark data"}
53
+ bench_returns = bench_df["Close"].pct_change().dropna()
54
+ bench_total_return = float((bench_df["Close"].iloc[-1] / bench_df["Close"].iloc[0]) - 1)
55
+
56
+ # Compute per-sector portfolio weights and returns
57
+ sectors = []
58
+ total_port_return = 0.0
59
+
60
+ for sector, sector_holdings in sector_map.items():
61
+ sector_value = sum(h.get("market_value", 0) for h in sector_holdings)
62
+ port_weight = sector_value / total_value
63
+ sector_pnl = sum(h.get("pnl", 0) for h in sector_holdings)
64
+ sector_cost = sum(h.get("cost_basis", h.get("avg_price", 0) * h.get("quantity", 0)) for h in sector_holdings)
65
+ sector_return = (sector_pnl / sector_cost) if sector_cost > 0 else 0
66
+
67
+ # Approximate benchmark weight (equal for simplicity)
68
+ bench_weight = 1.0 / max(len(sector_map), 1)
69
+
70
+ # Brinson decomposition
71
+ allocation_effect = (port_weight - bench_weight) * bench_total_return
72
+ selection_effect = bench_weight * (sector_return - bench_total_return)
73
+ interaction_effect = (port_weight - bench_weight) * (sector_return - bench_total_return)
74
+ total_effect = allocation_effect + selection_effect + interaction_effect
75
+
76
+ total_port_return += port_weight * sector_return
77
+
78
+ sectors.append({
79
+ "sector": sector,
80
+ "portfolio_weight": round(port_weight * 100, 2),
81
+ "benchmark_weight": round(bench_weight * 100, 2),
82
+ "portfolio_return": round(sector_return * 100, 2),
83
+ "benchmark_return": round(bench_total_return * 100, 2),
84
+ "allocation_effect": round(allocation_effect * 100, 4),
85
+ "selection_effect": round(selection_effect * 100, 4),
86
+ "interaction_effect": round(interaction_effect * 100, 4),
87
+ "total_effect": round(total_effect * 100, 4),
88
+ "pnl": round(sector_pnl, 2),
89
+ })
90
+
91
+ # Sort by total effect
92
+ sectors.sort(key=lambda x: x["total_effect"], reverse=True)
93
+
94
+ total_alpha = total_port_return - bench_total_return
95
+
96
+ # Waterfall chart data
97
+ waterfall = [{"label": "Benchmark", "value": round(bench_total_return * 100, 2), "type": "start"}]
98
+ for s in sectors:
99
+ waterfall.append({
100
+ "label": f"{s['sector']} (Alloc)",
101
+ "value": s["allocation_effect"],
102
+ "type": "positive" if s["allocation_effect"] >= 0 else "negative",
103
+ })
104
+ waterfall.append({
105
+ "label": f"{s['sector']} (Select)",
106
+ "value": s["selection_effect"],
107
+ "type": "positive" if s["selection_effect"] >= 0 else "negative",
108
+ })
109
+ waterfall.append({"label": "Portfolio Return", "value": round(total_port_return * 100, 2), "type": "total"})
110
+
111
+ return {
112
+ "method": "brinson",
113
+ "benchmark": benchmark,
114
+ "benchmark_return": round(bench_total_return * 100, 2),
115
+ "portfolio_return": round(total_port_return * 100, 2),
116
+ "alpha": round(total_alpha * 100, 2),
117
+ "sectors": sectors,
118
+ "waterfall": waterfall,
119
+ }
120
+
121
+
122
+ async def factor_attribution(
123
+ holdings: List[Dict[str, Any]],
124
+ total_value: float,
125
+ period: str = "1y",
126
+ ) -> Dict[str, Any]:
127
+ """
128
+ Factor-based attribution: decompose returns into contributions from
129
+ systematic factors (market, size, value, momentum, volatility).
130
+ """
131
+ if not holdings or total_value <= 0:
132
+ return {"error": "No holdings for factor attribution"}
133
+
134
+ tickers = list(set(h.get("ticker", "") for h in holdings if h.get("market_value", 0) > 0))
135
+
136
+ # Fetch returns
137
+ returns_data = {}
138
+ for ticker in tickers:
139
+ try:
140
+ df = await yahoo_adapter.get_price_dataframe(ticker, period=period)
141
+ if not df.empty and len(df) > 20:
142
+ returns_data[ticker] = df["Close"].pct_change().dropna()
143
+ except Exception:
144
+ continue
145
+
146
+ if not returns_data:
147
+ return {"error": "Could not fetch return data"}
148
+
149
+ # Align
150
+ aligned = pd.DataFrame(returns_data).dropna()
151
+ if aligned.empty:
152
+ return {"error": "Insufficient overlapping data"}
153
+
154
+ # Compute portfolio returns
155
+ weights = np.array([
156
+ sum(h.get("market_value", 0) for h in holdings if h.get("ticker") == t) / total_value
157
+ for t in aligned.columns
158
+ ])
159
+ port_returns = aligned.values @ weights
160
+
161
+ # Fetch factor proxies
162
+ factor_tickers = {
163
+ "market": "SPY",
164
+ "size": "IWM",
165
+ "value": "IVE",
166
+ "momentum": "MTUM",
167
+ "low_vol": "USMV",
168
+ }
169
+
170
+ factor_returns: Dict[str, pd.Series] = {}
171
+ for fname, fticker in factor_tickers.items():
172
+ try:
173
+ df = await yahoo_adapter.get_price_dataframe(fticker, period=period)
174
+ if not df.empty and len(df) > 20:
175
+ factor_returns[fname] = df["Close"].pct_change().dropna()
176
+ except Exception:
177
+ pass
178
+
179
+ if not factor_returns:
180
+ return {"error": "Could not fetch factor data"}
181
+
182
+ # Simple OLS regression for factor loadings
183
+ factor_df = pd.DataFrame(factor_returns).dropna()
184
+ min_len = min(len(port_returns), len(factor_df))
185
+ if min_len < 30:
186
+ return {"error": "Insufficient data for factor attribution"}
187
+
188
+ y = port_returns[:min_len]
189
+ X = factor_df.iloc[:min_len].values
190
+
191
+ # Add intercept
192
+ X_with_intercept = np.column_stack([np.ones(min_len), X])
193
+
194
+ try:
195
+ # Least squares
196
+ betas, _, _, _ = np.linalg.lstsq(X_with_intercept, y, rcond=None)
197
+ alpha_daily = betas[0]
198
+ factor_betas = betas[1:]
199
+ except Exception:
200
+ return {"error": "Regression failed"}
201
+
202
+ # Attribution
203
+ factor_names = list(factor_df.columns)
204
+ contributions = []
205
+ total_explained = 0.0
206
+
207
+ for i, fname in enumerate(factor_names):
208
+ avg_factor_ret = float(factor_df[fname].mean() * TRADING_DAYS)
209
+ contribution = float(factor_betas[i] * avg_factor_ret * 100)
210
+ total_explained += contribution
211
+ contributions.append({
212
+ "factor": fname,
213
+ "beta": round(float(factor_betas[i]), 4),
214
+ "factor_return": round(avg_factor_ret * 100, 2),
215
+ "contribution": round(contribution, 4),
216
+ })
217
+
218
+ alpha_contribution = round(float(alpha_daily * TRADING_DAYS * 100), 4)
219
+ total_return = round(float(np.mean(y) * TRADING_DAYS * 100), 2)
220
+
221
+ contributions.sort(key=lambda x: abs(x["contribution"]), reverse=True)
222
+
223
+ return {
224
+ "method": "factor_based",
225
+ "total_return": total_return,
226
+ "alpha": alpha_contribution,
227
+ "factor_contributions": contributions,
228
+ "total_explained": round(total_explained, 4),
229
+ "residual": round(total_return - total_explained - alpha_contribution, 4),
230
+ }
backend/app/services/analytics/bias_detector.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Behavioral Bias Detector.
3
+
4
+ Analyzes user portfolio and trading patterns to detect common
5
+ cognitive biases that harm investment performance:
6
+
7
+ - Disposition Effect β€” selling winners too early, holding losers too long
8
+ - Overconcentration β€” insufficient diversification
9
+ - Recency Bias β€” chasing recent performance
10
+ - Home Country Bias β€” over-exposure to domestic markets
11
+ - Anchoring β€” fixating on purchase price instead of fundamentals
12
+ - Loss Aversion β€” asymmetric sensitivity to losses vs gains
13
+
14
+ Returns a Behavioral Scorecard with letter grades (A–F) and coaching tips.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import Any, Dict, List
21
+
22
+ import numpy as np
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _grade(score: float) -> str:
28
+ if score >= 90: return "A"
29
+ if score >= 80: return "B"
30
+ if score >= 70: return "C"
31
+ if score >= 60: return "D"
32
+ return "F"
33
+
34
+
35
+ def _severity(score: float) -> str:
36
+ if score >= 80: return "low"
37
+ if score >= 60: return "moderate"
38
+ if score >= 40: return "high"
39
+ return "critical"
40
+
41
+
42
+ async def detect_biases(
43
+ holdings: List[Dict[str, Any]],
44
+ total_value: float,
45
+ ) -> Dict[str, Any]:
46
+ """
47
+ Detect behavioral biases from portfolio holdings.
48
+
49
+ Returns per-bias analysis with grades and coaching tips.
50
+ """
51
+ if not holdings or total_value <= 0:
52
+ return {"score": 0, "grade": "F", "biases": [], "tips": ["Add holdings to analyze behavioral biases."]}
53
+
54
+ biases = []
55
+ tips = []
56
+
57
+ # ── 1. Disposition Effect ────────────────────────────────────────────
58
+ # Detect: are losers being held longer than winners?
59
+ winners = [h for h in holdings if h.get("pnl", 0) > 0]
60
+ losers = [h for h in holdings if h.get("pnl", 0) < 0]
61
+
62
+ if winners and losers:
63
+ avg_winner_pnl_pct = np.mean([abs(h.get("pnl_pct", 0)) for h in winners])
64
+ avg_loser_pnl_pct = np.mean([abs(h.get("pnl_pct", 0)) for h in losers])
65
+
66
+ # Disposition effect: losers have larger unrealized losses than winners have gains
67
+ # (holding on to losers, selling winners early)
68
+ if avg_loser_pnl_pct > avg_winner_pnl_pct * 1.5:
69
+ disp_score = max(20, 80 - (avg_loser_pnl_pct - avg_winner_pnl_pct) * 2)
70
+ else:
71
+ disp_score = min(100, 80 + (avg_winner_pnl_pct - avg_loser_pnl_pct))
72
+
73
+ loser_value = sum(abs(h.get("pnl", 0)) for h in losers)
74
+ winner_value = sum(h.get("pnl", 0) for h in winners)
75
+ else:
76
+ disp_score = 85
77
+ loser_value = 0
78
+ winner_value = sum(h.get("pnl", 0) for h in winners) if winners else 0
79
+
80
+ biases.append({
81
+ "name": "Disposition Effect",
82
+ "description": "Tendency to sell winning positions too quickly while holding losers too long",
83
+ "score": round(disp_score, 1),
84
+ "grade": _grade(disp_score),
85
+ "severity": _severity(disp_score),
86
+ "detail": f"{len(winners)} winners (avg +{np.mean([h.get('pnl_pct', 0) for h in winners]):.1f}%) vs {len(losers)} losers (avg {np.mean([h.get('pnl_pct', 0) for h in losers]):.1f}%)" if winners and losers else "Insufficient data",
87
+ "icon": "πŸ“‰",
88
+ })
89
+ if disp_score < 60:
90
+ tips.append("πŸ“‰ **Disposition Effect detected.** Consider reviewing your losing positions objectively β€” would you buy them today at current prices? If not, consider exiting.")
91
+
92
+ # ── 2. Overconcentration ─────────────────────────────────────────────
93
+ weights = [h.get("weight", 0) for h in holdings]
94
+ sorted_w = sorted(weights, reverse=True)
95
+ top1 = sorted_w[0] if sorted_w else 0
96
+ top3 = sum(sorted_w[:3]) if len(sorted_w) >= 3 else sum(sorted_w)
97
+
98
+ # HHI-based scoring
99
+ w_arr = np.array(weights) / 100 if weights else np.array([1])
100
+ hhi = float(np.sum(w_arr ** 2))
101
+
102
+ if top1 > 30:
103
+ conc_score = max(20, 60 - (top1 - 30) * 2)
104
+ elif top3 > 60:
105
+ conc_score = max(40, 70 - (top3 - 60) * 1.5)
106
+ elif len(holdings) < 5:
107
+ conc_score = max(50, 70 - (5 - len(holdings)) * 10)
108
+ else:
109
+ conc_score = min(100, 75 + len(holdings) * 1.5)
110
+
111
+ biases.append({
112
+ "name": "Overconcentration",
113
+ "description": "Insufficient diversification across positions and sectors",
114
+ "score": round(conc_score, 1),
115
+ "grade": _grade(conc_score),
116
+ "severity": _severity(conc_score),
117
+ "detail": f"Top holding: {top1:.1f}%, Top 3: {top3:.1f}%, HHI: {hhi:.3f}, {len(holdings)} positions",
118
+ "icon": "🎯",
119
+ })
120
+ if conc_score < 60:
121
+ tips.append(f"🎯 **Overconcentration detected.** Your top position is {top1:.0f}% of portfolio. Target < 15% per position for better risk management.")
122
+
123
+ # ── 3. Recency Bias ──────────────────────────────────────────────────
124
+ # Look at whether recent purchases are momentum-driven
125
+ recent_winners = [h for h in holdings if h.get("pnl_pct", 0) > 10]
126
+ recent_losers = [h for h in holdings if h.get("pnl_pct", 0) < -10]
127
+
128
+ # Heuristic: if most of portfolio has high positive P&L, user may be chasing
129
+ positive_weight = sum(h.get("weight", 0) for h in holdings if h.get("pnl_pct", 0) > 15)
130
+ if positive_weight > 60:
131
+ recency_score = max(30, 80 - positive_weight * 0.5)
132
+ elif positive_weight > 40:
133
+ recency_score = 70
134
+ else:
135
+ recency_score = 85
136
+
137
+ biases.append({
138
+ "name": "Recency Bias",
139
+ "description": "Overweighting recent market performance when making investment decisions",
140
+ "score": round(recency_score, 1),
141
+ "grade": _grade(recency_score),
142
+ "severity": _severity(recency_score),
143
+ "detail": f"{positive_weight:.0f}% portfolio weight in strongly gaining positions",
144
+ "icon": "⏰",
145
+ })
146
+ if recency_score < 60:
147
+ tips.append("⏰ **Recency Bias detected.** You may be chasing recent winners. Consider mean-reversion and value opportunities.")
148
+
149
+ # ── 4. Home Country Bias ─────────────────────────────────────────────
150
+ # Detect US-heavy vs international exposure
151
+ us_tickers = sum(1 for h in holdings if not any(
152
+ suffix in h.get("ticker", "") for suffix in [".NS", ".HK", ".T", ".L", ".DE", ".PA"]
153
+ ))
154
+ intl_tickers = len(holdings) - us_tickers
155
+ us_pct = (us_tickers / max(len(holdings), 1)) * 100
156
+
157
+ if us_pct > 90:
158
+ home_score = 40
159
+ elif us_pct > 80:
160
+ home_score = 55
161
+ elif us_pct > 70:
162
+ home_score = 70
163
+ elif us_pct > 50:
164
+ home_score = 85
165
+ else:
166
+ home_score = 90
167
+
168
+ biases.append({
169
+ "name": "Home Country Bias",
170
+ "description": "Over-exposure to domestic market at the expense of global diversification",
171
+ "score": round(home_score, 1),
172
+ "grade": _grade(home_score),
173
+ "severity": _severity(home_score),
174
+ "detail": f"{us_pct:.0f}% US tickers vs {100-us_pct:.0f}% international ({intl_tickers} foreign positions)",
175
+ "icon": "🌍",
176
+ })
177
+ if home_score < 60:
178
+ tips.append("🌍 **Home Country Bias detected.** Your portfolio is heavily US-focused. Consider adding international exposure (Europe, Asia, EM).")
179
+
180
+ # ── 5. Anchoring Bias ────────────────────────────────────────────────
181
+ # Detect: are holdings with large losses being kept because of the buy price?
182
+ deep_losers = [h for h in holdings if h.get("pnl_pct", 0) < -20]
183
+ anchoring_indicators = len(deep_losers)
184
+
185
+ if anchoring_indicators >= 3:
186
+ anchor_score = 35
187
+ elif anchoring_indicators >= 2:
188
+ anchor_score = 50
189
+ elif anchoring_indicators >= 1:
190
+ anchor_score = 65
191
+ else:
192
+ anchor_score = 90
193
+
194
+ biases.append({
195
+ "name": "Anchoring",
196
+ "description": "Fixating on purchase price rather than current fundamentals and forward outlook",
197
+ "score": round(anchor_score, 1),
198
+ "grade": _grade(anchor_score),
199
+ "severity": _severity(anchor_score),
200
+ "detail": f"{anchoring_indicators} positions with >20% loss still being held",
201
+ "icon": "βš“",
202
+ })
203
+ if anchor_score < 60:
204
+ deep_ticker_list = ", ".join(h.get("ticker", "?") for h in deep_losers[:3])
205
+ tips.append(f"βš“ **Anchoring detected** on {deep_ticker_list}. Evaluate each on current fundamentals, not your average cost.")
206
+
207
+ # ── 6. Loss Aversion ─────────────────────────────────────────────────
208
+ # Asymmetric P&L: much more aggregate loss unrealized than gain
209
+ total_unrealized_gain = sum(h.get("pnl", 0) for h in winners) if winners else 0
210
+ total_unrealized_loss = sum(abs(h.get("pnl", 0)) for h in losers) if losers else 0
211
+
212
+ if total_unrealized_loss > total_unrealized_gain * 2:
213
+ la_score = max(25, 70 - (total_unrealized_loss / max(total_unrealized_gain, 1) - 1) * 15)
214
+ elif total_unrealized_loss > total_unrealized_gain:
215
+ la_score = 65
216
+ else:
217
+ la_score = 85
218
+
219
+ biases.append({
220
+ "name": "Loss Aversion",
221
+ "description": "Asymmetric sensitivity β€” feeling losses ~2x more than equivalent gains",
222
+ "score": round(la_score, 1),
223
+ "grade": _grade(la_score),
224
+ "severity": _severity(la_score),
225
+ "detail": f"Unrealized gains: ${total_unrealized_gain:,.0f} vs losses: ${total_unrealized_loss:,.0f}",
226
+ "icon": "😰",
227
+ })
228
+ if la_score < 60:
229
+ tips.append("😰 **Loss Aversion pattern detected.** Your unrealized losses significantly outweigh gains. Consider a systematic stop-loss discipline.")
230
+
231
+ # ── Overall Behavioral Score ─────────────────────────────────────────
232
+ overall = round(np.mean([b["score"] for b in biases]), 1)
233
+
234
+ return {
235
+ "score": overall,
236
+ "grade": _grade(overall),
237
+ "biases": biases,
238
+ "tips": tips if tips else ["βœ… No significant behavioral biases detected. Keep maintaining your disciplined approach!"],
239
+ "position_count": len(holdings),
240
+ "winners": len(winners),
241
+ "losers": len(losers),
242
+ }
backend/app/services/analytics/health_score.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Portfolio Health Score Engine.
3
+
4
+ Computes a composite 0–100 portfolio health score from 7 weighted dimensions:
5
+ - Diversification (20%) β€” HHI + correlation clustering
6
+ - Risk-Adjusted Returns (20%) β€” Sharpe ratio quality
7
+ - Drawdown Risk (15%) β€” Max drawdown and recovery
8
+ - Concentration Risk (15%) β€” Top-N position weight
9
+ - Volatility Health (10%) β€” Rolling vol stability
10
+ - Hedge Coverage (10%) β€” Protective positions and hedges
11
+ - Liquidity Score (10%) β€” Volume and spread quality
12
+
13
+ Returns letter grades (A+ to F) with per-component drill-down and coaching tips.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Any, Dict, List
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+
24
+ from app.config import get_settings
25
+ from app.services.data_ingestion.yahoo import yahoo_adapter
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _settings = get_settings()
30
+ RISK_FREE_RATE = _settings.risk_free_rate
31
+ TRADING_DAYS = _settings.trading_days_per_year
32
+
33
+ # ── Grading ──────────────────────────────────────────────────────────────
34
+
35
+ def _grade(score: float) -> str:
36
+ if score >= 95: return "A+"
37
+ if score >= 90: return "A"
38
+ if score >= 85: return "A-"
39
+ if score >= 80: return "B+"
40
+ if score >= 75: return "B"
41
+ if score >= 70: return "B-"
42
+ if score >= 65: return "C+"
43
+ if score >= 60: return "C"
44
+ if score >= 55: return "C-"
45
+ if score >= 50: return "D+"
46
+ if score >= 45: return "D"
47
+ if score >= 40: return "D-"
48
+ return "F"
49
+
50
+
51
+ def _grade_color(grade: str) -> str:
52
+ if grade.startswith("A"): return "#00c853"
53
+ if grade.startswith("B"): return "#2196f3"
54
+ if grade.startswith("C"): return "#ff9800"
55
+ if grade.startswith("D"): return "#f44336"
56
+ return "#b71c1c"
57
+
58
+
59
+ async def compute_health_score(
60
+ holdings: List[Dict[str, Any]],
61
+ total_value: float,
62
+ ) -> Dict[str, Any]:
63
+ """
64
+ Compute comprehensive portfolio health score.
65
+
66
+ Returns per-component scores, overall score, grade, and coaching tips.
67
+ """
68
+ if not holdings or total_value <= 0:
69
+ return {"score": 0, "grade": "F", "components": [], "tips": ["Add holdings to get a health score."]}
70
+
71
+ # Fetch returns for volatility/Sharpe
72
+ tickers = list(set(h.get("ticker", "") for h in holdings if h.get("market_value", 0) > 0))
73
+ weights = []
74
+ returns_data = {}
75
+
76
+ for h in holdings:
77
+ weights.append(h.get("market_value", 0) / total_value)
78
+
79
+ for ticker in tickers:
80
+ try:
81
+ df = await yahoo_adapter.get_price_dataframe(ticker, period="1y")
82
+ if not df.empty and len(df) > 20:
83
+ returns_data[ticker] = df["Close"].pct_change().dropna()
84
+ except Exception:
85
+ continue
86
+
87
+ components = []
88
+ tips = []
89
+
90
+ # ── 1. Diversification Score (20%) ───────────────────────────────────
91
+ mkt_values = [h.get("market_value", 0) for h in holdings if h.get("market_value", 0) > 0]
92
+ if mkt_values:
93
+ w = np.array(mkt_values) / sum(mkt_values)
94
+ hhi = float(np.sum(w ** 2))
95
+ # HHI = 1 means single stock, HHI = 1/N means perfect diversification
96
+ n = len(w)
97
+ optimal_hhi = 1 / n if n > 0 else 1
98
+ # Score: 100 at optimal HHI, 0 at HHI=1
99
+ div_score = max(0, min(100, (1 - hhi) / (1 - optimal_hhi) * 100)) if n > 1 else 20
100
+
101
+ # Sector concentration
102
+ sectors = set(h.get("asset_class", "equity") for h in holdings)
103
+ sector_bonus = min(len(sectors) * 10, 30)
104
+ div_score = min(100, div_score * 0.7 + sector_bonus)
105
+ else:
106
+ div_score = 0
107
+
108
+ components.append({
109
+ "name": "Diversification",
110
+ "score": round(div_score, 1),
111
+ "weight": 20,
112
+ "grade": _grade(div_score),
113
+ "detail": f"HHI: {hhi:.3f}, {len(mkt_values)} positions, {len(set(h.get('asset_class', 'equity') for h in holdings))} asset classes",
114
+ })
115
+ if div_score < 60:
116
+ tips.append("πŸ“Š Add positions across more sectors and asset classes to improve diversification.")
117
+
118
+ # ── 2. Risk-Adjusted Returns (20%) ───────────────────────────────────
119
+ if returns_data and len(returns_data) > 0:
120
+ # Compute portfolio returns
121
+ aligned_returns = pd.DataFrame(returns_data).dropna()
122
+ if not aligned_returns.empty:
123
+ holding_weights = np.array([
124
+ sum(h.get("market_value", 0) for h in holdings if h.get("ticker") == t) / total_value
125
+ for t in aligned_returns.columns
126
+ ])
127
+ port_returns = aligned_returns.values @ holding_weights
128
+ ann_return = float(np.mean(port_returns) * TRADING_DAYS)
129
+ ann_vol = float(np.std(port_returns, ddof=1) * np.sqrt(TRADING_DAYS))
130
+ sharpe = (ann_return - RISK_FREE_RATE) / ann_vol if ann_vol > 0 else 0
131
+
132
+ # Sharpe > 2 = 100, Sharpe 0 = 40, Sharpe < -1 = 0
133
+ rar_score = max(0, min(100, 40 + sharpe * 30))
134
+ else:
135
+ rar_score = 50
136
+ sharpe = 0
137
+ else:
138
+ rar_score = 50
139
+ sharpe = 0
140
+
141
+ components.append({
142
+ "name": "Risk-Adjusted Returns",
143
+ "score": round(rar_score, 1),
144
+ "weight": 20,
145
+ "grade": _grade(rar_score),
146
+ "detail": f"Sharpe: {sharpe:.2f}",
147
+ })
148
+ if rar_score < 60:
149
+ tips.append("πŸ“ˆ Consider rebalancing toward higher Sharpe ratio positions to improve risk-adjusted returns.")
150
+
151
+ # ── 3. Drawdown Risk (15%) ───────────────────────────────────────────
152
+ if returns_data and 'port_returns' in dir():
153
+ cum = np.cumprod(1 + port_returns)
154
+ peak = np.maximum.accumulate(cum)
155
+ dd = (cum - peak) / peak
156
+ max_dd = float(np.min(dd)) if len(dd) > 0 else 0
157
+ # Max DD better than -5% = 100, -20% = 50, -40% = 0
158
+ dd_score = max(0, min(100, 100 + max_dd * 250))
159
+ else:
160
+ dd_score = 60
161
+ max_dd = 0
162
+
163
+ components.append({
164
+ "name": "Drawdown Risk",
165
+ "score": round(dd_score, 1),
166
+ "weight": 15,
167
+ "grade": _grade(dd_score),
168
+ "detail": f"Max Drawdown: {max_dd*100:.1f}%",
169
+ })
170
+ if dd_score < 50:
171
+ tips.append("πŸ›‘οΈ Large drawdown risk detected. Consider adding hedges or reducing volatile positions.")
172
+
173
+ # ── 4. Concentration Risk (15%) ──────────────────────────────────────
174
+ sorted_weights = sorted([h.get("weight", 0) for h in holdings], reverse=True)
175
+ top1 = sorted_weights[0] if sorted_weights else 0
176
+ top3 = sum(sorted_weights[:3]) if len(sorted_weights) >= 3 else sum(sorted_weights)
177
+
178
+ # Top 1 < 20% and Top 3 < 50% = 100
179
+ conc_score = max(0, min(100, 100 - max(0, top1 - 15) * 3 - max(0, top3 - 40) * 2))
180
+
181
+ components.append({
182
+ "name": "Concentration Risk",
183
+ "score": round(conc_score, 1),
184
+ "weight": 15,
185
+ "grade": _grade(conc_score),
186
+ "detail": f"Top holding: {top1:.1f}%, Top 3: {top3:.1f}%",
187
+ })
188
+ if conc_score < 60:
189
+ tips.append(f"βš–οΈ Top position is {top1:.0f}% of portfolio. Consider trimming to below 15%.")
190
+
191
+ # ── 5. Volatility Health (10%) ───────────────────────────────────────
192
+ if 'ann_vol' in dir() and ann_vol > 0:
193
+ # Vol < 10% = 100, Vol 20% = 70, Vol > 40% = 20
194
+ vol_score = max(0, min(100, 120 - ann_vol * 250))
195
+ else:
196
+ vol_score = 60
197
+ ann_vol = 0
198
+
199
+ components.append({
200
+ "name": "Volatility",
201
+ "score": round(vol_score, 1),
202
+ "weight": 10,
203
+ "grade": _grade(vol_score),
204
+ "detail": f"Annualized Vol: {ann_vol*100:.1f}%",
205
+ })
206
+
207
+ # ── 6. Hedge Coverage (10%) ──────────────────────────────────────────
208
+ short_positions = sum(1 for h in holdings if h.get("position_type") == "short")
209
+ etf_positions = sum(1 for h in holdings if h.get("asset_class") in ("etf", "fixed_income", "commodity"))
210
+ hedge_ratio = (short_positions + etf_positions) / max(len(holdings), 1) * 100
211
+
212
+ # Ideal: 10–30% hedge coverage
213
+ if 10 <= hedge_ratio <= 30:
214
+ hedge_score = 90
215
+ elif hedge_ratio > 30:
216
+ hedge_score = 70 # Over-hedged
217
+ elif hedge_ratio > 0:
218
+ hedge_score = 50 + hedge_ratio * 4
219
+ else:
220
+ hedge_score = 30
221
+
222
+ components.append({
223
+ "name": "Hedge Coverage",
224
+ "score": round(hedge_score, 1),
225
+ "weight": 10,
226
+ "grade": _grade(hedge_score),
227
+ "detail": f"{short_positions} hedges, {etf_positions} diversifiers ({hedge_ratio:.0f}% coverage)",
228
+ })
229
+ if hedge_score < 60:
230
+ tips.append("πŸ”’ Consider adding hedging positions (inverse ETFs, bonds, or protective puts).")
231
+
232
+ # ── 7. Liquidity Score (10%) ─────────────────────────────────────────
233
+ # Based on number of positions and asset class mix
234
+ liquid_assets = sum(1 for h in holdings if h.get("asset_class") in ("equity", "etf"))
235
+ illiquid_assets = sum(1 for h in holdings if h.get("asset_class") in ("crypto", "commodity", "option"))
236
+ liquidity_score = min(100, (liquid_assets / max(len(holdings), 1)) * 100 + 10)
237
+
238
+ components.append({
239
+ "name": "Liquidity",
240
+ "score": round(liquidity_score, 1),
241
+ "weight": 10,
242
+ "grade": _grade(liquidity_score),
243
+ "detail": f"{liquid_assets} liquid, {illiquid_assets} illiquid positions",
244
+ })
245
+
246
+ # ── Overall Score ────────────────────────────────────────────────────
247
+ overall = sum(c["score"] * c["weight"] / 100 for c in components)
248
+ overall = round(overall, 1)
249
+
250
+ return {
251
+ "score": overall,
252
+ "grade": _grade(overall),
253
+ "grade_color": _grade_color(_grade(overall)),
254
+ "components": components,
255
+ "tips": tips if tips else ["βœ… Your portfolio health looks great! Keep monitoring regularly."],
256
+ "position_count": len(holdings),
257
+ "total_value": round(total_value, 2),
258
+ }
backend/app/services/analytics/portfolio_dna.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Portfolio DNA Fingerprint Engine.
3
+
4
+ Computes a 6-dimension portfolio fingerprint and compares against
5
+ famous portfolios of legendary investors:
6
+
7
+ Dimensions:
8
+ 1. Momentum β€” trend-following tendency
9
+ 2. Value β€” valuation discipline
10
+ 3. Volatility β€” risk tolerance
11
+ 4. Regime Sensitivity β€” macro beta
12
+ 5. Concentration β€” conviction level
13
+ 6. Hedge Coverage β€” defensive posture
14
+
15
+ Famous benchmarks:
16
+ - Warren Buffett β€” deep value, concentrated, low turnover
17
+ - Ray Dalio β€” risk parity, all-weather, maximum diversification
18
+ - Cathie Wood β€” high momentum growth, concentrated, volatile
19
+ - Renaissance Technologies β€” market-neutral, low vol, maximum hedge
20
+ - Bridgewater Pure Alpha β€” macro, balanced, regime-aware
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from typing import Any, Dict, List
27
+
28
+ import numpy as np
29
+ import pandas as pd
30
+
31
+ from app.config import get_settings
32
+ from app.services.data_ingestion.yahoo import yahoo_adapter
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ _settings = get_settings()
37
+ TRADING_DAYS = _settings.trading_days_per_year
38
+
39
+
40
+ # ── Famous Portfolio DNA Profiles (0-100 per dimension) ──────────────────
41
+
42
+ FAMOUS_PORTFOLIOS = {
43
+ "buffett": {
44
+ "name": "Warren Buffett",
45
+ "description": "Value investing with deep moats, concentrated bets",
46
+ "style": "Deep Value + Quality",
47
+ "dna": {
48
+ "momentum": 30,
49
+ "value": 95,
50
+ "volatility": 35,
51
+ "regime_sensitivity": 40,
52
+ "concentration": 80,
53
+ "hedge_coverage": 15,
54
+ },
55
+ },
56
+ "dalio": {
57
+ "name": "Ray Dalio",
58
+ "description": "All-Weather risk parity, maximum diversification",
59
+ "style": "Risk Parity + Macro",
60
+ "dna": {
61
+ "momentum": 50,
62
+ "value": 60,
63
+ "volatility": 20,
64
+ "regime_sensitivity": 70,
65
+ "concentration": 15,
66
+ "hedge_coverage": 85,
67
+ },
68
+ },
69
+ "wood": {
70
+ "name": "Cathie Wood (ARK)",
71
+ "description": "Disruptive innovation, high conviction growth",
72
+ "style": "Growth Momentum",
73
+ "dna": {
74
+ "momentum": 90,
75
+ "value": 10,
76
+ "volatility": 85,
77
+ "regime_sensitivity": 75,
78
+ "concentration": 70,
79
+ "hedge_coverage": 5,
80
+ },
81
+ },
82
+ "renaissance": {
83
+ "name": "Renaissance Technologies",
84
+ "description": "Quantitative market-neutral, Medallion Fund",
85
+ "style": "Quantitative Market-Neutral",
86
+ "dna": {
87
+ "momentum": 60,
88
+ "value": 50,
89
+ "volatility": 10,
90
+ "regime_sensitivity": 15,
91
+ "concentration": 5,
92
+ "hedge_coverage": 95,
93
+ },
94
+ },
95
+ "bridgewater": {
96
+ "name": "Bridgewater Pure Alpha",
97
+ "description": "Macro alpha from global diversification",
98
+ "style": "Global Macro",
99
+ "dna": {
100
+ "momentum": 55,
101
+ "value": 55,
102
+ "volatility": 40,
103
+ "regime_sensitivity": 90,
104
+ "concentration": 20,
105
+ "hedge_coverage": 70,
106
+ },
107
+ },
108
+ }
109
+
110
+
111
+ def _cosine_similarity(a: List[float], b: List[float]) -> float:
112
+ """Compute cosine similarity between two vectors."""
113
+ a_arr = np.array(a, dtype=float)
114
+ b_arr = np.array(b, dtype=float)
115
+ dot = np.dot(a_arr, b_arr)
116
+ norm_a = np.linalg.norm(a_arr)
117
+ norm_b = np.linalg.norm(b_arr)
118
+ if norm_a == 0 or norm_b == 0:
119
+ return 0.0
120
+ return float(dot / (norm_a * norm_b))
121
+
122
+
123
+ async def compute_portfolio_dna(
124
+ holdings: List[Dict[str, Any]],
125
+ total_value: float,
126
+ ) -> Dict[str, Any]:
127
+ """
128
+ Compute the 6-dimension DNA fingerprint for user's portfolio.
129
+
130
+ Returns DNA profile, famous portfolio similarities, and closest match.
131
+ """
132
+ if not holdings or total_value <= 0:
133
+ return {"error": "No valid holdings for DNA analysis"}
134
+
135
+ n = len(holdings)
136
+ tickers = list(set(h.get("ticker", "") for h in holdings if h.get("market_value", 0) > 0))
137
+
138
+ # Fetch returns for momentum/vol computation
139
+ returns_data = {}
140
+ for ticker in tickers:
141
+ try:
142
+ df = await yahoo_adapter.get_price_dataframe(ticker, period="1y")
143
+ if not df.empty and len(df) > 20:
144
+ returns_data[ticker] = df["Close"].pct_change().dropna()
145
+ except Exception:
146
+ continue
147
+
148
+ # ── 1. Momentum Score ────────────────────────────────────────────────
149
+ momentum_scores = []
150
+ for h in holdings:
151
+ pnl_pct = h.get("pnl_pct", 0)
152
+ momentum_scores.append(abs(pnl_pct) if pnl_pct > 0 else 0)
153
+
154
+ avg_momentum = np.mean(momentum_scores) if momentum_scores else 0
155
+ # Scale: 0% = 0 score, 50% = 90 score
156
+ momentum = min(100, avg_momentum * 1.8)
157
+
158
+ # ── 2. Value Score ──────────���────────────────────────────────────────
159
+ # Heuristic: positions bought below current price (positive P&L from appreciation)
160
+ value_positions = sum(1 for h in holdings if h.get("pnl_pct", 0) < 5) # held despite small/neg gains
161
+ value = min(100, (value_positions / max(n, 1)) * 100)
162
+
163
+ # ── 3. Volatility Score ──────────────────────────────────────────────
164
+ if returns_data:
165
+ aligned = pd.DataFrame(returns_data).dropna()
166
+ if not aligned.empty:
167
+ weights = np.array([
168
+ sum(h.get("market_value", 0) for h in holdings if h.get("ticker") == t)
169
+ for t in aligned.columns
170
+ ])
171
+ weights = weights / max(weights.sum(), 1)
172
+ port_returns = aligned.values @ weights
173
+ vol = float(np.std(port_returns, ddof=1) * np.sqrt(TRADING_DAYS))
174
+ # Scale: 5% vol = 10, 15% = 40, 30% = 80, 50% = 100
175
+ volatility = min(100, vol * 100 * 2)
176
+ else:
177
+ volatility = 50
178
+ else:
179
+ volatility = 50
180
+
181
+ # ── 4. Regime Sensitivity ────────────────────────────────────────────
182
+ # Beta-weighted exposure to market
183
+ short_count = sum(1 for h in holdings if h.get("position_type") == "short")
184
+ defensive = sum(1 for h in holdings if h.get("asset_class") in ("fixed_income", "commodity"))
185
+ regime_exp = (1 - (short_count + defensive) / max(n, 1)) * 80
186
+ regime_sensitivity = min(100, regime_exp)
187
+
188
+ # ── 5. Concentration Score ───────────────────────────────────────────
189
+ weights_lst = sorted([h.get("weight", 0) for h in holdings], reverse=True)
190
+ top3_weight = sum(weights_lst[:3])
191
+ # Top 3 = 100% => concentration = 100, Top 3 = 30% => 30
192
+ concentration = min(100, top3_weight)
193
+
194
+ # ── 6. Hedge Coverage ────────────────────────────────────────────────
195
+ hedges = sum(1 for h in holdings if h.get("position_type") == "short" or
196
+ h.get("asset_class") in ("fixed_income", "commodity", "forex"))
197
+ hedge_coverage = min(100, (hedges / max(n, 1)) * 100 * 3)
198
+
199
+ # ── Build DNA Profile ────────────────────────────────────────────────
200
+ dna = {
201
+ "momentum": round(momentum, 1),
202
+ "value": round(value, 1),
203
+ "volatility": round(volatility, 1),
204
+ "regime_sensitivity": round(regime_sensitivity, 1),
205
+ "concentration": round(concentration, 1),
206
+ "hedge_coverage": round(hedge_coverage, 1),
207
+ }
208
+
209
+ # ── Compare with Famous Portfolios ───────────────────────────────────
210
+ user_vector = [dna[k] for k in sorted(dna.keys())]
211
+ comparisons = []
212
+
213
+ for fp_id, fp in FAMOUS_PORTFOLIOS.items():
214
+ fp_vector = [fp["dna"][k] for k in sorted(fp["dna"].keys())]
215
+ similarity = _cosine_similarity(user_vector, fp_vector) * 100
216
+ comparisons.append({
217
+ "id": fp_id,
218
+ "name": fp["name"],
219
+ "description": fp["description"],
220
+ "style": fp["style"],
221
+ "similarity": round(similarity, 1),
222
+ "dna": fp["dna"],
223
+ })
224
+
225
+ comparisons.sort(key=lambda x: x["similarity"], reverse=True)
226
+ closest = comparisons[0] if comparisons else None
227
+
228
+ # Determine user's investing style
229
+ dominant = max(dna, key=dna.get) # type: ignore
230
+ style_map = {
231
+ "momentum": "Growth/Momentum Investor",
232
+ "value": "Value Investor",
233
+ "volatility": "Aggressive/Speculative",
234
+ "regime_sensitivity": "Macro-Sensitive",
235
+ "concentration": "High-Conviction",
236
+ "hedge_coverage": "Risk-Managed/Balanced",
237
+ }
238
+
239
+ return {
240
+ "dna": dna,
241
+ "style": style_map.get(dominant, "Balanced"),
242
+ "dominant_trait": dominant,
243
+ "closest_match": closest,
244
+ "comparisons": comparisons,
245
+ "position_count": n,
246
+ }
backend/app/services/backtest/engine.py CHANGED
@@ -19,11 +19,16 @@ from typing import Any, Dict, List, Optional
19
  import numpy as np
20
  import pandas as pd
21
 
 
22
  from app.services.data_ingestion.yahoo import yahoo_adapter
23
  from app.services.feature_engineering.pipeline import feature_pipeline
24
 
25
  logger = logging.getLogger(__name__)
26
 
 
 
 
 
27
 
28
  class BacktestEngine:
29
  """Event-driven portfolio backtester."""
@@ -263,11 +268,13 @@ class BacktestEngine:
263
  # Monthly returns
264
  monthly_returns = self._compute_monthly_returns(equity_curve)
265
 
266
- # Drawdown series
 
267
  for point in equity_curve:
268
- idx = equity_curve.index(point)
269
- peak = max(p["portfolio_value"] for p in equity_curve[: idx + 1])
270
- point["drawdown"] = round((point["portfolio_value"] - peak) / peak, 6) if peak > 0 else 0
 
271
 
272
  return {
273
  "status": "completed",
@@ -371,17 +378,17 @@ class BacktestEngine:
371
  returns = np.array(daily_returns)
372
  final = equity_values[-1]
373
  n_days = len(returns)
374
- n_years = n_days / 252
375
 
376
  total_return = (final / initial_capital - 1)
377
  ann_return = (1 + total_return) ** (1 / n_years) - 1 if n_years > 0 else 0
378
- ann_vol = float(np.std(returns, ddof=1) * np.sqrt(252)) if len(returns) > 1 else 0
379
- sharpe = (ann_return - 0.04) / ann_vol if ann_vol > 0 else 0
380
 
381
  # Sortino
382
  downside = returns[returns < 0]
383
- down_dev = float(np.std(downside, ddof=1) * np.sqrt(252)) if len(downside) > 1 else ann_vol
384
- sortino = (ann_return - 0.04) / down_dev if down_dev > 0 else 0
385
 
386
  # Max drawdown
387
  cum = np.cumprod(1 + returns)
@@ -429,7 +436,7 @@ class BacktestEngine:
429
  cov_rb = np.cov(r, b)[0, 1]
430
  var_b = np.var(b, ddof=1)
431
  beta = cov_rb / var_b if var_b > 0 else 1.0
432
- alpha = ann_return - beta * float(np.mean(b) * 252)
433
  metrics["beta"] = round(float(beta), 4)
434
  metrics["alpha"] = round(float(alpha), 4)
435
 
 
19
  import numpy as np
20
  import pandas as pd
21
 
22
+ from app.config import get_settings
23
  from app.services.data_ingestion.yahoo import yahoo_adapter
24
  from app.services.feature_engineering.pipeline import feature_pipeline
25
 
26
  logger = logging.getLogger(__name__)
27
 
28
+ _settings = get_settings()
29
+ RISK_FREE_RATE = _settings.risk_free_rate
30
+ TRADING_DAYS = _settings.trading_days_per_year
31
+
32
 
33
  class BacktestEngine:
34
  """Event-driven portfolio backtester."""
 
268
  # Monthly returns
269
  monthly_returns = self._compute_monthly_returns(equity_curve)
270
 
271
+ # Drawdown series β€” O(n) running peak
272
+ running_peak = 0.0
273
  for point in equity_curve:
274
+ running_peak = max(running_peak, point["portfolio_value"])
275
+ point["drawdown"] = round(
276
+ (point["portfolio_value"] - running_peak) / running_peak, 6
277
+ ) if running_peak > 0 else 0
278
 
279
  return {
280
  "status": "completed",
 
378
  returns = np.array(daily_returns)
379
  final = equity_values[-1]
380
  n_days = len(returns)
381
+ n_years = n_days / TRADING_DAYS
382
 
383
  total_return = (final / initial_capital - 1)
384
  ann_return = (1 + total_return) ** (1 / n_years) - 1 if n_years > 0 else 0
385
+ ann_vol = float(np.std(returns, ddof=1) * np.sqrt(TRADING_DAYS)) if len(returns) > 1 else 0
386
+ sharpe = (ann_return - RISK_FREE_RATE) / ann_vol if ann_vol > 0 else 0
387
 
388
  # Sortino
389
  downside = returns[returns < 0]
390
+ down_dev = float(np.std(downside, ddof=1) * np.sqrt(TRADING_DAYS)) if len(downside) > 1 else ann_vol
391
+ sortino = (ann_return - RISK_FREE_RATE) / down_dev if down_dev > 0 else 0
392
 
393
  # Max drawdown
394
  cum = np.cumprod(1 + returns)
 
436
  cov_rb = np.cov(r, b)[0, 1]
437
  var_b = np.var(b, ddof=1)
438
  beta = cov_rb / var_b if var_b > 0 else 1.0
439
+ alpha = ann_return - beta * float(np.mean(b) * TRADING_DAYS)
440
  metrics["beta"] = round(float(beta), 4)
441
  metrics["alpha"] = round(float(alpha), 4)
442
 
backend/app/services/portfolio/engine.py CHANGED
@@ -3,12 +3,13 @@ Portfolio Construction Engine.
3
 
4
  Supports multiple portfolio optimization methods:
5
  - Equal Weight
6
- - Risk Parity (inverse volatility)
7
  - Mean-Variance Optimization (Markowitz)
8
  - Minimum Variance
9
  - Maximum Sharpe Ratio
10
 
11
- Includes sector exposure constraints and position limits.
 
12
  """
13
 
14
  from __future__ import annotations
@@ -20,10 +21,27 @@ import numpy as np
20
  import pandas as pd
21
  from scipy.optimize import minimize
22
 
 
23
  from app.services.data_ingestion.yahoo import yahoo_adapter
24
 
25
  logger = logging.getLogger(__name__)
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  class PortfolioEngine:
29
  """Portfolio construction and optimization engine."""
@@ -33,7 +51,7 @@ class PortfolioEngine:
33
  tickers: List[str],
34
  method: str = "mean_variance",
35
  period: str = "2y",
36
- risk_free_rate: float = 0.04,
37
  constraints: Optional[Dict[str, Any]] = None,
38
  ) -> Dict[str, Any]:
39
  """
@@ -65,17 +83,18 @@ class PortfolioEngine:
65
  "method": "equal_weight",
66
  }
67
 
68
- # 2. Compute expected returns and covariance
69
- mean_returns = returns_df.mean() * 252 # Annualized
70
- cov_matrix = returns_df.cov() * 252 # Annualized
71
 
72
  # 3. Optimize
73
  max_weight = (constraints or {}).get("max_weight", 0.4)
74
 
75
- if method == "equal_weight":
 
 
 
76
  weights = self._equal_weight(tickers)
77
- elif method == "risk_parity":
78
- weights = self._risk_parity(returns_df, tickers)
79
  elif method == "min_variance":
80
  weights = self._min_variance(mean_returns, cov_matrix, tickers, max_weight)
81
  elif method == "max_sharpe":
@@ -126,9 +145,9 @@ class PortfolioEngine:
126
  return {t: w for t in tickers}
127
 
128
  @staticmethod
129
- def _risk_parity(returns_df: pd.DataFrame, tickers: List[str]) -> Dict[str, float]:
130
- """Inverse volatility (simplified risk parity)."""
131
- vols = returns_df.std() * np.sqrt(252)
132
  inv_vols = 1.0 / vols.replace(0, np.nan)
133
  inv_vols = inv_vols.fillna(0)
134
  total = inv_vols.sum()
@@ -137,6 +156,9 @@ class PortfolioEngine:
137
  weights = inv_vols / total
138
  return {t: float(weights.get(t, 0)) for t in tickers}
139
 
 
 
 
140
  @staticmethod
141
  def _min_variance(
142
  mean_returns: pd.Series,
@@ -226,9 +248,9 @@ class PortfolioEngine:
226
  bench_df = await yahoo_adapter.get_price_dataframe(benchmark, period=period)
227
  bench_returns = bench_df["Close"].pct_change().dropna().values if not bench_df.empty else None
228
 
229
- mean_ret = float(np.mean(portfolio_returns) * 252)
230
- vol = float(np.std(portfolio_returns) * np.sqrt(252))
231
- sharpe = mean_ret / vol if vol > 0 else 0.0
232
 
233
  # Drawdown
234
  cum_returns = np.cumprod(1 + portfolio_returns)
@@ -251,9 +273,9 @@ class PortfolioEngine:
251
  pr = portfolio_returns[:min_len]
252
  br = bench_returns[:min_len]
253
  cov_pb = np.cov(pr, br)[0, 1]
254
- var_b = np.var(br)
255
  beta = cov_pb / var_b if var_b > 0 else 1.0
256
- alpha = mean_ret - beta * float(np.mean(br) * 252)
257
  analytics["beta"] = round(float(beta), 4)
258
  analytics["alpha"] = round(float(alpha), 4)
259
 
 
3
 
4
  Supports multiple portfolio optimization methods:
5
  - Equal Weight
6
+ - Inverse Volatility (simplified risk parity)
7
  - Mean-Variance Optimization (Markowitz)
8
  - Minimum Variance
9
  - Maximum Sharpe Ratio
10
 
11
+ Includes Ledoit-Wolf covariance shrinkage, sector exposure constraints,
12
+ and position limits.
13
  """
14
 
15
  from __future__ import annotations
 
21
  import pandas as pd
22
  from scipy.optimize import minimize
23
 
24
+ from app.config import get_settings
25
  from app.services.data_ingestion.yahoo import yahoo_adapter
26
 
27
  logger = logging.getLogger(__name__)
28
 
29
+ _settings = get_settings()
30
+ RISK_FREE_RATE = _settings.risk_free_rate
31
+ TRADING_DAYS = _settings.trading_days_per_year
32
+
33
+
34
+ def _shrink_covariance(returns_df: pd.DataFrame) -> pd.DataFrame:
35
+ """Apply Ledoit-Wolf shrinkage to the sample covariance matrix."""
36
+ try:
37
+ from sklearn.covariance import LedoitWolf
38
+ lw = LedoitWolf().fit(returns_df.values)
39
+ cov_shrunk = lw.covariance_ * TRADING_DAYS # annualize
40
+ return pd.DataFrame(cov_shrunk, index=returns_df.columns, columns=returns_df.columns)
41
+ except Exception:
42
+ # Fallback to sample covariance
43
+ return returns_df.cov() * TRADING_DAYS
44
+
45
 
46
  class PortfolioEngine:
47
  """Portfolio construction and optimization engine."""
 
51
  tickers: List[str],
52
  method: str = "mean_variance",
53
  period: str = "2y",
54
+ risk_free_rate: float = RISK_FREE_RATE,
55
  constraints: Optional[Dict[str, Any]] = None,
56
  ) -> Dict[str, Any]:
57
  """
 
83
  "method": "equal_weight",
84
  }
85
 
86
+ # 2. Compute expected returns and covariance (with Ledoit-Wolf shrinkage)
87
+ mean_returns = returns_df.mean() * TRADING_DAYS # Annualized
88
+ cov_matrix = _shrink_covariance(returns_df)
89
 
90
  # 3. Optimize
91
  max_weight = (constraints or {}).get("max_weight", 0.4)
92
 
93
+ # Support both old name "risk_parity" and new name "inverse_volatility"
94
+ if method in ("risk_parity", "inverse_volatility"):
95
+ weights = self._inverse_volatility(returns_df, tickers)
96
+ elif method == "equal_weight":
97
  weights = self._equal_weight(tickers)
 
 
98
  elif method == "min_variance":
99
  weights = self._min_variance(mean_returns, cov_matrix, tickers, max_weight)
100
  elif method == "max_sharpe":
 
145
  return {t: w for t in tickers}
146
 
147
  @staticmethod
148
+ def _inverse_volatility(returns_df: pd.DataFrame, tickers: List[str]) -> Dict[str, float]:
149
+ """Inverse volatility weighting (simplified risk parity)."""
150
+ vols = returns_df.std() * np.sqrt(TRADING_DAYS)
151
  inv_vols = 1.0 / vols.replace(0, np.nan)
152
  inv_vols = inv_vols.fillna(0)
153
  total = inv_vols.sum()
 
156
  weights = inv_vols / total
157
  return {t: float(weights.get(t, 0)) for t in tickers}
158
 
159
+ # Keep old name as alias for backward compatibility
160
+ _risk_parity = _inverse_volatility
161
+
162
  @staticmethod
163
  def _min_variance(
164
  mean_returns: pd.Series,
 
248
  bench_df = await yahoo_adapter.get_price_dataframe(benchmark, period=period)
249
  bench_returns = bench_df["Close"].pct_change().dropna().values if not bench_df.empty else None
250
 
251
+ mean_ret = float(np.mean(portfolio_returns) * TRADING_DAYS)
252
+ vol = float(np.std(portfolio_returns, ddof=1) * np.sqrt(TRADING_DAYS))
253
+ sharpe = (mean_ret - RISK_FREE_RATE) / vol if vol > 0 else 0.0
254
 
255
  # Drawdown
256
  cum_returns = np.cumprod(1 + portfolio_returns)
 
273
  pr = portfolio_returns[:min_len]
274
  br = bench_returns[:min_len]
275
  cov_pb = np.cov(pr, br)[0, 1]
276
+ var_b = np.var(br, ddof=1)
277
  beta = cov_pb / var_b if var_b > 0 else 1.0
278
+ alpha = mean_ret - beta * float(np.mean(br) * TRADING_DAYS)
279
  analytics["beta"] = round(float(beta), 4)
280
  analytics["alpha"] = round(float(alpha), 4)
281
 
backend/app/services/risk/crisis_replay.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Historical Crisis Replay Engine.
3
+
4
+ Replays actual daily sector ETF returns from 6 major crises against user holdings:
5
+ - COVID-19 Crash (Feb–Mar 2020)
6
+ - 2022 Rate Hike Crash (Jan–Jun 2022)
7
+ - Dot-Com Burst (Mar–Oct 2000)
8
+ - 2008 Financial Crisis (Sep–Nov 2008)
9
+ - Flash Crash (May 6, 2010)
10
+ - China/Greece Selloff (Aug 2015)
11
+
12
+ For each crisis, fetches actual daily returns from sector ETFs and maps
13
+ user holdings to sectors to simulate day-by-day equity curve impact.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Any, Dict, List
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+
24
+ from app.services.data_ingestion.yahoo import yahoo_adapter
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # ── Crisis Definitions ───────────────────────────────────────────────────
30
+
31
+ CRISES = {
32
+ "covid_2020": {
33
+ "name": "COVID-19 Crash",
34
+ "description": "Pandemic-driven global market collapse β€” fastest bear market in history",
35
+ "start": "2020-02-19",
36
+ "end": "2020-03-23",
37
+ "peak_decline": -33.9,
38
+ "recovery_date": "2020-08-18",
39
+ },
40
+ "rate_hike_2022": {
41
+ "name": "2022 Rate Hike Crash",
42
+ "description": "Fed aggressive tightening β€” tech-led growth selloff",
43
+ "start": "2022-01-03",
44
+ "end": "2022-06-16",
45
+ "peak_decline": -23.6,
46
+ "recovery_date": "2024-01-19",
47
+ },
48
+ "dotcom_2000": {
49
+ "name": "Dot-Com Burst",
50
+ "description": "Technology bubble burst β€” NASDAQ lost 78% peak to trough",
51
+ "start": "2000-03-10",
52
+ "end": "2000-10-09",
53
+ "peak_decline": -39.3,
54
+ "recovery_date": None,
55
+ },
56
+ "gfc_2008": {
57
+ "name": "2008 Financial Crisis",
58
+ "description": "Subprime mortgage collapse β€” global financial system nearly failed",
59
+ "start": "2008-09-15",
60
+ "end": "2008-11-20",
61
+ "peak_decline": -46.1,
62
+ "recovery_date": "2013-03-14",
63
+ },
64
+ "flash_crash_2010": {
65
+ "name": "2010 Flash Crash",
66
+ "description": "Algorithmic trading cascade β€” Dow Jones dropped 1000 points in minutes",
67
+ "start": "2010-05-06",
68
+ "end": "2010-05-25",
69
+ "peak_decline": -12.4,
70
+ "recovery_date": "2010-06-21",
71
+ },
72
+ "china_2015": {
73
+ "name": "China/Greece Selloff 2015",
74
+ "description": "Chinese stock market crash + Greek debt crisis contagion",
75
+ "start": "2015-08-18",
76
+ "end": "2015-08-25",
77
+ "peak_decline": -11.2,
78
+ "recovery_date": "2015-11-03",
79
+ },
80
+ }
81
+
82
+ # Sector ETF mapping for replay
83
+ SECTOR_ETFS = {
84
+ "Technology": "XLK",
85
+ "Financial": "XLF",
86
+ "Healthcare": "XLV",
87
+ "Consumer Discretionary": "XLY",
88
+ "Consumer Staples": "XLP",
89
+ "Energy": "XLE",
90
+ "Industrials": "XLI",
91
+ "Materials": "XLB",
92
+ "Real Estate": "XLRE",
93
+ "Utilities": "XLU",
94
+ "Communication Services": "XLC",
95
+ "equity": "SPY", # default
96
+ "etf": "SPY",
97
+ "crypto": "SPY", # approximate
98
+ "default": "SPY",
99
+ }
100
+
101
+
102
+ async def replay_crisis(
103
+ holdings: List[Dict[str, Any]],
104
+ total_value: float,
105
+ crisis_id: str,
106
+ ) -> Dict[str, Any]:
107
+ """
108
+ Replay a historical crisis against user holdings.
109
+
110
+ Fetches actual daily returns from the crisis period for each sector,
111
+ maps user holdings to sectors, and simulates the day-by-day impact.
112
+ """
113
+ if crisis_id not in CRISES:
114
+ return {"error": f"Unknown crisis: {crisis_id}. Available: {list(CRISES.keys())}"}
115
+
116
+ if not holdings or total_value <= 0:
117
+ return {"error": "No valid holdings for crisis replay"}
118
+
119
+ crisis = CRISES[crisis_id]
120
+
121
+ # Map holdings to sectors and determine sector ETFs
122
+ sector_weights: Dict[str, float] = {}
123
+ for h in holdings:
124
+ sector = h.get("sector", h.get("asset_class", "equity"))
125
+ w = h.get("market_value", 0) / total_value
126
+ sector_weights[sector] = sector_weights.get(sector, 0) + w
127
+
128
+ # Fetch actual crisis period returns for each needed sector ETF
129
+ sector_etf_returns: Dict[str, pd.Series] = {}
130
+ needed_etfs = set()
131
+
132
+ for sector in sector_weights:
133
+ etf = SECTOR_ETFS.get(sector, SECTOR_ETFS["default"])
134
+ needed_etfs.add(etf)
135
+
136
+ for etf in needed_etfs:
137
+ try:
138
+ period = "max" if crisis["start"] < "2015-01-01" else "10y"
139
+ df = await yahoo_adapter.get_price_dataframe(etf, period=period)
140
+ if df.empty:
141
+ continue
142
+ df.index = pd.to_datetime(df.index)
143
+ mask = (df.index >= crisis["start"]) & (df.index <= crisis["end"])
144
+ crisis_data = df.loc[mask, "Close"]
145
+ if not crisis_data.empty:
146
+ sector_etf_returns[etf] = crisis_data.pct_change().dropna()
147
+ except Exception as e:
148
+ logger.warning("Failed to fetch %s for crisis replay: %s", etf, e)
149
+
150
+ # Fallback: use SPY if we couldn't get sector-specific data
151
+ spy_returns = sector_etf_returns.get("SPY")
152
+ if spy_returns is None or spy_returns.empty:
153
+ # Try fetching SPY directly
154
+ try:
155
+ df = await yahoo_adapter.get_price_dataframe("SPY", period="max")
156
+ df.index = pd.to_datetime(df.index)
157
+ mask = (df.index >= crisis["start"]) & (df.index <= crisis["end"])
158
+ spy_data = df.loc[mask, "Close"]
159
+ if not spy_data.empty:
160
+ spy_returns = spy_data.pct_change().dropna()
161
+ sector_etf_returns["SPY"] = spy_returns
162
+ except Exception:
163
+ pass
164
+
165
+ if not sector_etf_returns:
166
+ return {"error": "Could not fetch crisis period data"}
167
+
168
+ # Build daily portfolio returns
169
+ # For each trading day in the crisis, compute weighted portfolio return
170
+ # from sector ETF returns
171
+ all_dates = set()
172
+ for returns_series in sector_etf_returns.values():
173
+ all_dates.update(returns_series.index.tolist())
174
+ dates_sorted = sorted(all_dates)
175
+
176
+ equity_curve = []
177
+ portfolio_val = total_value
178
+ cumulative_return = 1.0
179
+
180
+ for dt in dates_sorted:
181
+ daily_return = 0.0
182
+ for sector, weight in sector_weights.items():
183
+ etf = SECTOR_ETFS.get(sector, "SPY")
184
+ returns_series = sector_etf_returns.get(etf)
185
+ if returns_series is not None and dt in returns_series.index:
186
+ daily_return += weight * float(returns_series.loc[dt])
187
+ elif spy_returns is not None and dt in spy_returns.index:
188
+ daily_return += weight * float(spy_returns.loc[dt])
189
+
190
+ cumulative_return *= (1 + daily_return)
191
+ portfolio_val = total_value * cumulative_return
192
+
193
+ equity_curve.append({
194
+ "date": dt.strftime("%Y-%m-%d"),
195
+ "portfolio_value": round(portfolio_val, 2),
196
+ "daily_return": round(daily_return * 100, 2),
197
+ "cumulative_return": round((cumulative_return - 1) * 100, 2),
198
+ })
199
+
200
+ total_pnl = portfolio_val - total_value
201
+ total_pnl_pct = (cumulative_return - 1) * 100
202
+ min_value = min(p["portfolio_value"] for p in equity_curve) if equity_curve else total_value
203
+ max_drawdown = ((min_value - total_value) / total_value) * 100
204
+
205
+ # Per-holding impact (using final cumulative return per sector)
206
+ holding_impacts = []
207
+ for h in holdings:
208
+ sector = h.get("sector", h.get("asset_class", "equity"))
209
+ etf = SECTOR_ETFS.get(sector, "SPY")
210
+ # Overall sector return during crisis
211
+ returns_series = sector_etf_returns.get(etf)
212
+ if returns_series is not None and len(returns_series) > 0:
213
+ sector_total_return = float((1 + returns_series).prod() - 1)
214
+ elif spy_returns is not None:
215
+ sector_total_return = float((1 + spy_returns).prod() - 1)
216
+ else:
217
+ sector_total_return = 0
218
+
219
+ mv = h.get("market_value", 0)
220
+ impact = mv * sector_total_return
221
+
222
+ holding_impacts.append({
223
+ "ticker": h.get("ticker", ""),
224
+ "sector": sector,
225
+ "market_value": round(mv, 2),
226
+ "sector_return": round(sector_total_return * 100, 2),
227
+ "impact": round(impact, 2),
228
+ })
229
+
230
+ holding_impacts.sort(key=lambda x: x["impact"])
231
+
232
+ return {
233
+ "crisis": crisis,
234
+ "portfolio_value": round(total_value, 2),
235
+ "final_value": round(portfolio_val, 2),
236
+ "total_pnl": round(total_pnl, 2),
237
+ "total_pnl_pct": round(total_pnl_pct, 2),
238
+ "max_drawdown_pct": round(max_drawdown, 2),
239
+ "trading_days": len(equity_curve),
240
+ "equity_curve": equity_curve,
241
+ "holding_impacts": holding_impacts,
242
+ }
243
+
244
+
245
+ async def replay_all_crises(
246
+ holdings: List[Dict[str, Any]],
247
+ total_value: float,
248
+ ) -> Dict[str, Any]:
249
+ """Replay all crises and return summary comparison."""
250
+ results = []
251
+ for crisis_id in CRISES:
252
+ try:
253
+ result = await replay_crisis(holdings, total_value, crisis_id)
254
+ if "error" not in result:
255
+ results.append({
256
+ "crisis_id": crisis_id,
257
+ "name": CRISES[crisis_id]["name"],
258
+ "total_pnl": result["total_pnl"],
259
+ "total_pnl_pct": result["total_pnl_pct"],
260
+ "max_drawdown_pct": result["max_drawdown_pct"],
261
+ "trading_days": result["trading_days"],
262
+ })
263
+ except Exception as e:
264
+ logger.warning("Crisis replay failed for %s: %s", crisis_id, e)
265
+
266
+ results.sort(key=lambda x: x["total_pnl"])
267
+ return {"crises": results, "available": list(CRISES.keys())}
backend/app/services/risk/monte_carlo.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Monte Carlo VaR Engine.
3
+
4
+ Runs Cholesky-decomposed correlated multi-asset simulations:
5
+ - 10,000 path default
6
+ - Fan chart percentiles (5th/25th/50th/75th/95th)
7
+ - Regime-conditioned simulation using HMM states
8
+ - Value-at-Risk (VaR) and Conditional VaR (CVaR) at multiple confidence levels
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ from app.config import get_settings
20
+ from app.services.data_ingestion.yahoo import yahoo_adapter
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _settings = get_settings()
25
+ TRADING_DAYS = _settings.trading_days_per_year
26
+
27
+
28
+ async def run_monte_carlo(
29
+ holdings: List[Dict[str, Any]],
30
+ horizon_days: int = 60,
31
+ n_simulations: int = 10000,
32
+ regime_conditioned: bool = False,
33
+ ) -> Dict[str, Any]:
34
+ """
35
+ Run Monte Carlo simulation for portfolio holdings.
36
+
37
+ Args:
38
+ holdings: List of holding dicts with ticker, market_value
39
+ horizon_days: Simulation horizon in trading days
40
+ n_simulations: Number of paths to simulate
41
+ regime_conditioned: Apply HMM regime conditioning
42
+
43
+ Returns:
44
+ Fan chart data, VaR, CVaR, and simulation statistics.
45
+ """
46
+ tickers = list(set(h.get("ticker", "") for h in holdings if h.get("market_value", 0) > 0))
47
+ weights_map = {}
48
+ total_value = sum(h.get("market_value", 0) for h in holdings)
49
+
50
+ if total_value <= 0 or not tickers:
51
+ return {"error": "No valid holdings for simulation"}
52
+
53
+ for h in holdings:
54
+ t = h.get("ticker", "")
55
+ w = h.get("market_value", 0) / total_value
56
+ weights_map[t] = weights_map.get(t, 0) + w
57
+
58
+ # 1. Fetch historical returns (1 year)
59
+ returns_data = {}
60
+ for ticker in tickers:
61
+ try:
62
+ df = await yahoo_adapter.get_price_dataframe(ticker, period="1y")
63
+ if not df.empty and len(df) > 30:
64
+ returns_data[ticker] = df["Close"].pct_change().dropna()
65
+ except Exception:
66
+ continue
67
+
68
+ if len(returns_data) < 1:
69
+ return {"error": "Insufficient price data for simulation"}
70
+
71
+ # Build aligned returns matrix
72
+ returns_df = pd.DataFrame(returns_data).dropna()
73
+ if returns_df.empty or len(returns_df) < 30:
74
+ return {"error": "Insufficient overlapping data"}
75
+
76
+ used_tickers = list(returns_df.columns)
77
+ weights = np.array([weights_map.get(t, 0) for t in used_tickers])
78
+ weights = weights / weights.sum() # normalize
79
+
80
+ # 2. Estimate parameters
81
+ mu = returns_df.mean().values # daily mean returns
82
+ cov = returns_df.cov().values # daily covariance
83
+
84
+ # Regime conditioning: adjust mu/cov based on current regime
85
+ if regime_conditioned:
86
+ try:
87
+ from app.services.ml.hmm_regime import detect_regime
88
+ regime_data = await detect_regime("SPY", period="2y", history_days=60)
89
+ current_regime = regime_data.get("current_regime", "normal")
90
+
91
+ if current_regime == "bear":
92
+ mu = mu * 0.5 # Reduce expected returns
93
+ cov = cov * 1.8 # Increase volatility
94
+ elif current_regime == "high_volatility":
95
+ cov = cov * 2.2
96
+ elif current_regime == "bull":
97
+ mu = mu * 1.2
98
+ cov = cov * 0.8
99
+ except Exception:
100
+ pass # Use unconditional parameters
101
+
102
+ # 3. Cholesky decomposition for correlated random draws
103
+ try:
104
+ L = np.linalg.cholesky(cov)
105
+ except np.linalg.LinAlgError:
106
+ # Add small regularization if covariance isn't positive semi-definite
107
+ cov += np.eye(len(used_tickers)) * 1e-8
108
+ L = np.linalg.cholesky(cov)
109
+
110
+ # 4. Simulate paths
111
+ np.random.seed(42) # reproducibility
112
+ portfolio_paths = np.zeros((n_simulations, horizon_days + 1))
113
+ portfolio_paths[:, 0] = total_value
114
+
115
+ for sim in range(n_simulations):
116
+ cumulative_return = 1.0
117
+ for day in range(horizon_days):
118
+ z = np.random.standard_normal(len(used_tickers))
119
+ correlated_returns = mu + L @ z
120
+ # Portfolio return = weighted sum of asset returns
121
+ port_return = np.dot(weights, correlated_returns)
122
+ cumulative_return *= (1 + port_return)
123
+ portfolio_paths[sim, day + 1] = total_value * cumulative_return
124
+
125
+ # 5. Compute statistics
126
+ final_values = portfolio_paths[:, -1]
127
+ pnl = final_values - total_value
128
+ pnl_pct = pnl / total_value
129
+
130
+ # Fan chart percentiles
131
+ percentiles = [5, 25, 50, 75, 95]
132
+ fan_chart = {}
133
+ for p in percentiles:
134
+ fan_chart[f"p{p}"] = [
135
+ round(float(np.percentile(portfolio_paths[:, d], p)), 2)
136
+ for d in range(horizon_days + 1)
137
+ ]
138
+
139
+ # VaR and CVaR at multiple confidence levels
140
+ var_levels = {}
141
+ for conf in [0.90, 0.95, 0.99]:
142
+ q = np.percentile(pnl, (1 - conf) * 100)
143
+ cvar = float(np.mean(pnl[pnl <= q]))
144
+ var_levels[f"{int(conf*100)}%"] = {
145
+ "var": round(float(-q), 2),
146
+ "var_pct": round(float(-q / total_value * 100), 2),
147
+ "cvar": round(float(-cvar), 2),
148
+ "cvar_pct": round(float(-cvar / total_value * 100), 2),
149
+ }
150
+
151
+ return {
152
+ "portfolio_value": round(total_value, 2),
153
+ "horizon_days": horizon_days,
154
+ "n_simulations": n_simulations,
155
+ "regime_conditioned": regime_conditioned,
156
+ "fan_chart": fan_chart,
157
+ "days": list(range(horizon_days + 1)),
158
+ "var": var_levels,
159
+ "statistics": {
160
+ "mean_final_value": round(float(np.mean(final_values)), 2),
161
+ "median_final_value": round(float(np.median(final_values)), 2),
162
+ "std_final_value": round(float(np.std(final_values)), 2),
163
+ "mean_pnl": round(float(np.mean(pnl)), 2),
164
+ "mean_pnl_pct": round(float(np.mean(pnl_pct) * 100), 2),
165
+ "prob_loss": round(float(np.mean(pnl < 0) * 100), 2),
166
+ "worst_case": round(float(np.min(final_values)), 2),
167
+ "best_case": round(float(np.max(final_values)), 2),
168
+ },
169
+ }
backend/app/services/risk/options_calculator.py CHANGED
@@ -1,9 +1,12 @@
1
  """
2
  Options Hedge Calculator.
3
 
4
- Black-Scholes pricing for protective puts, collars, and covered calls.
 
5
  Given any position, recommends optimal strike prices and calculates
6
  the cost of hedge as a percentage of position value.
 
 
7
  """
8
 
9
  from __future__ import annotations
@@ -12,20 +15,117 @@ import math
12
  import logging
13
  from typing import Any, Dict, List, Optional
14
 
 
 
15
  logger = logging.getLogger(__name__)
16
 
 
 
 
 
17
 
18
  def _norm_cdf(x: float) -> float:
19
  """Standard normal cumulative distribution function."""
20
  return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
21
 
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  def black_scholes_put(S: float, K: float, T: float, r: float, sigma: float) -> float:
24
  """Price a European put option using Black-Scholes."""
25
  if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
26
  return 0.0
27
- d1 = (math.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * math.sqrt(T))
28
- d2 = d1 - sigma * math.sqrt(T)
29
  return K * math.exp(-r * T) * _norm_cdf(-d2) - S * _norm_cdf(-d1)
30
 
31
 
@@ -33,42 +133,172 @@ def black_scholes_call(S: float, K: float, T: float, r: float, sigma: float) ->
33
  """Price a European call option using Black-Scholes."""
34
  if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
35
  return 0.0
36
- d1 = (math.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * math.sqrt(T))
37
- d2 = d1 - sigma * math.sqrt(T)
38
  return S * _norm_cdf(d1) - K * math.exp(-r * T) * _norm_cdf(d2)
39
 
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  def _estimate_volatility(ticker: str) -> float:
42
- """Estimate annualized volatility for a ticker using recent data."""
43
  try:
44
  from app.services.data_ingestion.yahoo import _sync_fetch_history
45
  data = _sync_fetch_history(ticker, period="3mo", interval="1d")
46
  if data.empty or len(data) < 10:
47
- return 0.25 # default 25%
48
  returns = data["Close"].pct_change().dropna()
49
- return float(returns.std() * math.sqrt(252))
50
  except Exception:
51
  return 0.25
52
 
53
 
54
- def calculate_options_hedge(
55
  holdings: List[Dict[str, Any]],
56
- risk_free_rate: float = 0.05,
57
  time_horizons: Optional[List[float]] = None,
58
  ) -> Dict[str, Any]:
59
  """
60
- Calculate options hedge strategies for each holding.
61
 
62
- Returns protective put, collar, and covered call analyses per position.
63
  """
64
  if time_horizons is None:
65
- time_horizons = [30 / 365, 90 / 365, 180 / 365] # 1mo, 3mo, 6mo
66
 
67
  strategies = []
68
 
69
  for h in holdings:
70
  if h.get("position_type") == "short":
71
- continue # skip short positions for put protection
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  ticker = h.get("ticker", "")
74
  current_price = h.get("current_price", 0)
@@ -80,14 +310,12 @@ def calculate_options_hedge(
80
 
81
  sigma = _estimate_volatility(ticker)
82
 
83
- # Strike levels: 5% OTM, ATM, 5% ITM for puts
84
  put_strikes = [
85
  {"label": "5% OTM", "strike": round(current_price * 0.95, 2), "protection": "95%"},
86
  {"label": "ATM", "strike": round(current_price, 2), "protection": "100%"},
87
  {"label": "5% ITM", "strike": round(current_price * 1.05, 2), "protection": "105%"},
88
  ]
89
 
90
- # Call strikes for covered calls / collar cap
91
  call_strikes = [
92
  {"label": "5% OTM", "strike": round(current_price * 1.05, 2)},
93
  {"label": "10% OTM", "strike": round(current_price * 1.10, 2)},
@@ -99,33 +327,34 @@ def calculate_options_hedge(
99
  for T in time_horizons:
100
  days = int(T * 365)
101
 
102
- # Protective puts at different strikes
103
  puts = []
104
  for ps in put_strikes:
105
  price = black_scholes_put(current_price, ps["strike"], T, risk_free_rate, sigma)
106
  total_cost = price * quantity
107
  cost_pct = (total_cost / market_value * 100) if market_value > 0 else 0
 
108
  puts.append({
109
  **ps,
110
  "premium": round(price, 2),
111
  "total_cost": round(total_cost, 2),
112
  "cost_pct": round(cost_pct, 2),
 
113
  })
114
 
115
- # Covered calls
116
  calls = []
117
  for cs in call_strikes:
118
  price = black_scholes_call(current_price, cs["strike"], T, risk_free_rate, sigma)
119
  total_income = price * quantity
120
  income_pct = (total_income / market_value * 100) if market_value > 0 else 0
 
121
  calls.append({
122
  **cs,
123
  "premium": round(price, 2),
124
  "total_income": round(total_income, 2),
125
  "income_pct": round(income_pct, 2),
 
126
  })
127
 
128
- # Collar: buy ATM put + sell 10% OTM call
129
  atm_put_cost = black_scholes_put(current_price, current_price, T, risk_free_rate, sigma)
130
  otm_call_income = black_scholes_call(current_price, current_price * 1.10, T, risk_free_rate, sigma)
131
  collar_net = (atm_put_cost - otm_call_income) * quantity
 
1
  """
2
  Options Hedge Calculator.
3
 
4
+ Black-Scholes pricing with full Greeks (Delta, Gamma, Theta, Vega, Rho)
5
+ for protective puts, collars, and covered calls.
6
  Given any position, recommends optimal strike prices and calculates
7
  the cost of hedge as a percentage of position value.
8
+
9
+ Supports implied volatility back-solving via Newton's method.
10
  """
11
 
12
  from __future__ import annotations
 
15
  import logging
16
  from typing import Any, Dict, List, Optional
17
 
18
+ from app.config import get_settings
19
+
20
  logger = logging.getLogger(__name__)
21
 
22
+ _settings = get_settings()
23
+ RISK_FREE_RATE = _settings.risk_free_rate
24
+ TRADING_DAYS = _settings.trading_days_per_year
25
+
26
 
27
  def _norm_cdf(x: float) -> float:
28
  """Standard normal cumulative distribution function."""
29
  return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
30
 
31
 
32
+ def _norm_pdf(x: float) -> float:
33
+ """Standard normal probability density function."""
34
+ return math.exp(-0.5 * x * x) / math.sqrt(2.0 * math.pi)
35
+
36
+
37
+ def _bs_d1_d2(S: float, K: float, T: float, r: float, sigma: float):
38
+ """Compute Black-Scholes d1 and d2."""
39
+ d1 = (math.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * math.sqrt(T))
40
+ d2 = d1 - sigma * math.sqrt(T)
41
+ return d1, d2
42
+
43
+
44
+ # ── Greeks ───────────────────────────────────────────────────────────────
45
+
46
+ def compute_greeks(
47
+ S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call"
48
+ ) -> Dict[str, float]:
49
+ """
50
+ Compute all Black-Scholes Greeks for a European option.
51
+
52
+ Returns: delta, gamma, theta, vega, rho
53
+ """
54
+ if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
55
+ return {"delta": 0, "gamma": 0, "theta": 0, "vega": 0, "rho": 0}
56
+
57
+ d1, d2 = _bs_d1_d2(S, K, T, r, sigma)
58
+ sqrt_T = math.sqrt(T)
59
+
60
+ # Gamma and Vega are the same for calls and puts
61
+ gamma = _norm_pdf(d1) / (S * sigma * sqrt_T)
62
+ vega = S * _norm_pdf(d1) * sqrt_T / 100 # per 1% vol move
63
+
64
+ if option_type == "call":
65
+ delta = _norm_cdf(d1)
66
+ theta = (
67
+ -S * _norm_pdf(d1) * sigma / (2 * sqrt_T)
68
+ - r * K * math.exp(-r * T) * _norm_cdf(d2)
69
+ ) / TRADING_DAYS # daily theta
70
+ rho = K * T * math.exp(-r * T) * _norm_cdf(d2) / 100 # per 1% rate move
71
+ else: # put
72
+ delta = _norm_cdf(d1) - 1
73
+ theta = (
74
+ -S * _norm_pdf(d1) * sigma / (2 * sqrt_T)
75
+ + r * K * math.exp(-r * T) * _norm_cdf(-d2)
76
+ ) / TRADING_DAYS
77
+ rho = -K * T * math.exp(-r * T) * _norm_cdf(-d2) / 100
78
+
79
+ return {
80
+ "delta": round(delta, 4),
81
+ "gamma": round(gamma, 6),
82
+ "theta": round(theta, 4),
83
+ "vega": round(vega, 4),
84
+ "rho": round(rho, 4),
85
+ }
86
+
87
+
88
+ # ── Implied Volatility (Newton's Method) ─────────────────────────────────
89
+
90
+ def implied_volatility(
91
+ market_price: float, S: float, K: float, T: float, r: float,
92
+ option_type: str = "call", max_iter: int = 100, tol: float = 1e-6
93
+ ) -> float:
94
+ """
95
+ Back-solve implied volatility from market option price using Newton's method.
96
+ Returns annualized IV as decimal.
97
+ """
98
+ if market_price <= 0 or T <= 0 or S <= 0 or K <= 0:
99
+ return 0.0
100
+
101
+ sigma = 0.25 # initial guess
102
+ for _ in range(max_iter):
103
+ if option_type == "call":
104
+ price = black_scholes_call(S, K, T, r, sigma)
105
+ else:
106
+ price = black_scholes_put(S, K, T, r, sigma)
107
+
108
+ diff = price - market_price
109
+ if abs(diff) < tol:
110
+ return sigma
111
+
112
+ # Vega for Newton step
113
+ d1, _ = _bs_d1_d2(S, K, T, r, sigma)
114
+ vega = S * _norm_pdf(d1) * math.sqrt(T)
115
+ if vega < 1e-12:
116
+ break
117
+
118
+ sigma -= diff / vega
119
+ sigma = max(0.001, min(sigma, 5.0)) # clamp
120
+
121
+ return sigma
122
+
123
+
124
  def black_scholes_put(S: float, K: float, T: float, r: float, sigma: float) -> float:
125
  """Price a European put option using Black-Scholes."""
126
  if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
127
  return 0.0
128
+ d1, d2 = _bs_d1_d2(S, K, T, r, sigma)
 
129
  return K * math.exp(-r * T) * _norm_cdf(-d2) - S * _norm_cdf(-d1)
130
 
131
 
 
133
  """Price a European call option using Black-Scholes."""
134
  if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
135
  return 0.0
136
+ d1, d2 = _bs_d1_d2(S, K, T, r, sigma)
 
137
  return S * _norm_cdf(d1) - K * math.exp(-r * T) * _norm_cdf(d2)
138
 
139
 
140
+ async def _estimate_volatility_async(ticker: str) -> float:
141
+ """Estimate annualized volatility for a ticker using recent data (async)."""
142
+ try:
143
+ from app.services.data_ingestion.yahoo import yahoo_adapter
144
+ df = await yahoo_adapter.get_price_dataframe(ticker, period="3mo")
145
+ if df.empty or len(df) < 10:
146
+ return 0.25 # default 25%
147
+ returns = df["Close"].pct_change().dropna()
148
+ return float(returns.std() * math.sqrt(TRADING_DAYS))
149
+ except Exception:
150
+ return 0.25
151
+
152
+
153
  def _estimate_volatility(ticker: str) -> float:
154
+ """Estimate annualized volatility synchronously (fallback)."""
155
  try:
156
  from app.services.data_ingestion.yahoo import _sync_fetch_history
157
  data = _sync_fetch_history(ticker, period="3mo", interval="1d")
158
  if data.empty or len(data) < 10:
159
+ return 0.25
160
  returns = data["Close"].pct_change().dropna()
161
+ return float(returns.std() * math.sqrt(TRADING_DAYS))
162
  except Exception:
163
  return 0.25
164
 
165
 
166
+ async def calculate_options_hedge_async(
167
  holdings: List[Dict[str, Any]],
168
+ risk_free_rate: float = RISK_FREE_RATE,
169
  time_horizons: Optional[List[float]] = None,
170
  ) -> Dict[str, Any]:
171
  """
172
+ Calculate options hedge strategies for each holding (async version).
173
 
174
+ Returns protective put, collar, covered call analyses with full Greeks.
175
  """
176
  if time_horizons is None:
177
+ time_horizons = [30 / 365, 90 / 365, 180 / 365]
178
 
179
  strategies = []
180
 
181
  for h in holdings:
182
  if h.get("position_type") == "short":
183
+ continue
184
+
185
+ ticker = h.get("ticker", "")
186
+ current_price = h.get("current_price", 0)
187
+ quantity = h.get("quantity", 0)
188
+ market_value = h.get("market_value", current_price * quantity)
189
+
190
+ if current_price <= 0 or quantity <= 0:
191
+ continue
192
+
193
+ sigma = await _estimate_volatility_async(ticker)
194
+
195
+ # Strike levels
196
+ put_strikes = [
197
+ {"label": "5% OTM", "strike": round(current_price * 0.95, 2), "protection": "95%"},
198
+ {"label": "ATM", "strike": round(current_price, 2), "protection": "100%"},
199
+ {"label": "5% ITM", "strike": round(current_price * 1.05, 2), "protection": "105%"},
200
+ ]
201
+
202
+ call_strikes = [
203
+ {"label": "5% OTM", "strike": round(current_price * 1.05, 2)},
204
+ {"label": "10% OTM", "strike": round(current_price * 1.10, 2)},
205
+ {"label": "15% OTM", "strike": round(current_price * 1.15, 2)},
206
+ ]
207
+
208
+ position_strategies = []
209
+
210
+ for T in time_horizons:
211
+ days = int(T * 365)
212
+
213
+ # Protective puts with Greeks
214
+ puts = []
215
+ for ps in put_strikes:
216
+ price = black_scholes_put(current_price, ps["strike"], T, risk_free_rate, sigma)
217
+ total_cost = price * quantity
218
+ cost_pct = (total_cost / market_value * 100) if market_value > 0 else 0
219
+ greeks = compute_greeks(current_price, ps["strike"], T, risk_free_rate, sigma, "put")
220
+ puts.append({
221
+ **ps,
222
+ "premium": round(price, 2),
223
+ "total_cost": round(total_cost, 2),
224
+ "cost_pct": round(cost_pct, 2),
225
+ "greeks": greeks,
226
+ })
227
+
228
+ # Covered calls with Greeks
229
+ calls = []
230
+ for cs in call_strikes:
231
+ price = black_scholes_call(current_price, cs["strike"], T, risk_free_rate, sigma)
232
+ total_income = price * quantity
233
+ income_pct = (total_income / market_value * 100) if market_value > 0 else 0
234
+ greeks = compute_greeks(current_price, cs["strike"], T, risk_free_rate, sigma, "call")
235
+ calls.append({
236
+ **cs,
237
+ "premium": round(price, 2),
238
+ "total_income": round(total_income, 2),
239
+ "income_pct": round(income_pct, 2),
240
+ "greeks": greeks,
241
+ })
242
+
243
+ # Collar
244
+ atm_put_cost = black_scholes_put(current_price, current_price, T, risk_free_rate, sigma)
245
+ otm_call_income = black_scholes_call(current_price, current_price * 1.10, T, risk_free_rate, sigma)
246
+ collar_net = (atm_put_cost - otm_call_income) * quantity
247
+ collar_pct = (collar_net / market_value * 100) if market_value > 0 else 0
248
+
249
+ # Net Greeks for collar
250
+ put_greeks = compute_greeks(current_price, current_price, T, risk_free_rate, sigma, "put")
251
+ call_greeks = compute_greeks(current_price, current_price * 1.10, T, risk_free_rate, sigma, "call")
252
+ collar_greeks = {
253
+ "net_delta": round(put_greeks["delta"] - call_greeks["delta"], 4),
254
+ "net_gamma": round(put_greeks["gamma"] - call_greeks["gamma"], 6),
255
+ "net_theta": round(put_greeks["theta"] - call_greeks["theta"], 4),
256
+ "net_vega": round(put_greeks["vega"] - call_greeks["vega"], 4),
257
+ }
258
+
259
+ position_strategies.append({
260
+ "expiry_days": days,
261
+ "expiry_T": round(T, 4),
262
+ "protective_puts": puts,
263
+ "covered_calls": calls,
264
+ "collar": {
265
+ "put_strike": round(current_price, 2),
266
+ "call_strike": round(current_price * 1.10, 2),
267
+ "net_cost": round(collar_net, 2),
268
+ "net_cost_pct": round(collar_pct, 2),
269
+ "description": f"Buy {current_price:.0f} put + Sell {current_price*1.10:.0f} call",
270
+ "greeks": collar_greeks,
271
+ },
272
+ })
273
+
274
+ strategies.append({
275
+ "ticker": ticker,
276
+ "current_price": current_price,
277
+ "quantity": quantity,
278
+ "market_value": round(market_value, 2),
279
+ "volatility": round(sigma * 100, 1),
280
+ "strategies": position_strategies,
281
+ })
282
+
283
+ return {"strategies": strategies, "risk_free_rate": risk_free_rate}
284
+
285
+
286
+ def calculate_options_hedge(
287
+ holdings: List[Dict[str, Any]],
288
+ risk_free_rate: float = RISK_FREE_RATE,
289
+ time_horizons: Optional[List[float]] = None,
290
+ ) -> Dict[str, Any]:
291
+ """
292
+ Calculate options hedge strategies (sync version β€” backward compatible).
293
+ """
294
+ if time_horizons is None:
295
+ time_horizons = [30 / 365, 90 / 365, 180 / 365]
296
+
297
+ strategies = []
298
+
299
+ for h in holdings:
300
+ if h.get("position_type") == "short":
301
+ continue
302
 
303
  ticker = h.get("ticker", "")
304
  current_price = h.get("current_price", 0)
 
310
 
311
  sigma = _estimate_volatility(ticker)
312
 
 
313
  put_strikes = [
314
  {"label": "5% OTM", "strike": round(current_price * 0.95, 2), "protection": "95%"},
315
  {"label": "ATM", "strike": round(current_price, 2), "protection": "100%"},
316
  {"label": "5% ITM", "strike": round(current_price * 1.05, 2), "protection": "105%"},
317
  ]
318
 
 
319
  call_strikes = [
320
  {"label": "5% OTM", "strike": round(current_price * 1.05, 2)},
321
  {"label": "10% OTM", "strike": round(current_price * 1.10, 2)},
 
327
  for T in time_horizons:
328
  days = int(T * 365)
329
 
 
330
  puts = []
331
  for ps in put_strikes:
332
  price = black_scholes_put(current_price, ps["strike"], T, risk_free_rate, sigma)
333
  total_cost = price * quantity
334
  cost_pct = (total_cost / market_value * 100) if market_value > 0 else 0
335
+ greeks = compute_greeks(current_price, ps["strike"], T, risk_free_rate, sigma, "put")
336
  puts.append({
337
  **ps,
338
  "premium": round(price, 2),
339
  "total_cost": round(total_cost, 2),
340
  "cost_pct": round(cost_pct, 2),
341
+ "greeks": greeks,
342
  })
343
 
 
344
  calls = []
345
  for cs in call_strikes:
346
  price = black_scholes_call(current_price, cs["strike"], T, risk_free_rate, sigma)
347
  total_income = price * quantity
348
  income_pct = (total_income / market_value * 100) if market_value > 0 else 0
349
+ greeks = compute_greeks(current_price, cs["strike"], T, risk_free_rate, sigma, "call")
350
  calls.append({
351
  **cs,
352
  "premium": round(price, 2),
353
  "total_income": round(total_income, 2),
354
  "income_pct": round(income_pct, 2),
355
+ "greeks": greeks,
356
  })
357
 
 
358
  atm_put_cost = black_scholes_put(current_price, current_price, T, risk_free_rate, sigma)
359
  otm_call_income = black_scholes_call(current_price, current_price * 1.10, T, risk_free_rate, sigma)
360
  collar_net = (atm_put_cost - otm_call_income) * quantity
backend/app/services/risk/stress_test.py CHANGED
@@ -3,7 +3,7 @@ Stress Test Engine.
3
 
4
  Simulates portfolio impact under various macro scenarios:
5
  market crashes, rate hikes, sector rotations, currency moves, and custom shocks.
6
- Uses beta-adjusted impact for each holding.
7
  """
8
 
9
  from __future__ import annotations
@@ -79,7 +79,7 @@ SCENARIOS = {
79
  },
80
  }
81
 
82
- # Rough beta estimates by sector (used when we don't have real beta data)
83
  SECTOR_BETAS = {
84
  "Technology": 1.25,
85
  "Financial": 1.15,
@@ -97,10 +97,55 @@ SECTOR_BETAS = {
97
  }
98
 
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  def run_stress_test(
101
  holdings: List[Dict[str, Any]],
102
  scenario_id: str | None = None,
103
  custom_shock: float | None = None,
 
104
  ) -> Dict[str, Any]:
105
  """
106
  Run a stress test on portfolio holdings.
@@ -109,6 +154,7 @@ def run_stress_test(
109
  holdings: List of holding dicts with market_value, asset_class, etc.
110
  scenario_id: ID of a predefined scenario
111
  custom_shock: Custom market shock as decimal (e.g., -0.15 for -15%)
 
112
 
113
  Returns:
114
  Dict with per-holding impact and portfolio-level summary.
@@ -127,6 +173,7 @@ def run_stress_test(
127
 
128
  market_shock = scenario["market_shock"]
129
  sector_shocks = scenario.get("sector_shocks", {})
 
130
 
131
  total_before = sum(h.get("market_value", 0) for h in holdings)
132
  impacts = []
@@ -138,7 +185,15 @@ def run_stress_test(
138
 
139
  asset_class = h.get("asset_class", "equity")
140
  sector = h.get("sector", "default")
141
- beta = SECTOR_BETAS.get(sector, SECTOR_BETAS.get(asset_class, 1.0))
 
 
 
 
 
 
 
 
142
 
143
  # Check for sector-specific shock override
144
  if sector in sector_shocks:
@@ -164,9 +219,11 @@ def run_stress_test(
164
  new_value = mv + impact_value
165
 
166
  impacts.append({
167
- "ticker": h.get("ticker"),
168
- "name": h.get("name", h.get("ticker")),
169
  "current_value": round(mv, 2),
 
 
170
  "shock_pct": round(shock * 100, 2),
171
  "impact_value": round(impact_value, 2),
172
  "new_value": round(new_value, 2),
@@ -199,11 +256,14 @@ def run_stress_test(
199
  }
200
 
201
 
202
- def run_all_scenarios(holdings: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
 
 
 
203
  """Run all predefined scenarios and return summaries."""
204
  results = []
205
  for sid in SCENARIOS:
206
- result = run_stress_test(holdings, scenario_id=sid)
207
  results.append({
208
  "scenario_id": sid,
209
  "name": result["scenario"]["name"],
 
3
 
4
  Simulates portfolio impact under various macro scenarios:
5
  market crashes, rate hikes, sector rotations, currency moves, and custom shocks.
6
+ Uses computed rolling beta from price data (with sector beta as fallback).
7
  """
8
 
9
  from __future__ import annotations
 
79
  },
80
  }
81
 
82
+ # Fallback sector betas (used when < 60 days of data exists)
83
  SECTOR_BETAS = {
84
  "Technology": 1.25,
85
  "Financial": 1.15,
 
97
  }
98
 
99
 
100
+ async def compute_real_betas(
101
+ tickers: List[str],
102
+ benchmark: str = "SPY",
103
+ period: str = "1y",
104
+ ) -> Dict[str, float]:
105
+ """
106
+ Compute real rolling beta per ticker from actual return covariance.
107
+ Falls back to sector beta when insufficient data.
108
+ """
109
+ from app.services.data_ingestion.yahoo import yahoo_adapter
110
+ import pandas as pd
111
+
112
+ betas: Dict[str, float] = {}
113
+
114
+ try:
115
+ bench_df = await yahoo_adapter.get_price_dataframe(benchmark, period=period)
116
+ if bench_df.empty:
117
+ return betas
118
+ bench_returns = bench_df["Close"].pct_change().dropna()
119
+
120
+ for ticker in tickers:
121
+ try:
122
+ df = await yahoo_adapter.get_price_dataframe(ticker, period=period)
123
+ if df.empty or len(df) < 60:
124
+ continue
125
+ stock_returns = df["Close"].pct_change().dropna()
126
+
127
+ # Align dates
128
+ aligned = pd.DataFrame({"stock": stock_returns, "bench": bench_returns}).dropna()
129
+ if len(aligned) < 30:
130
+ continue
131
+
132
+ cov = np.cov(aligned["stock"].values, aligned["bench"].values)
133
+ var_bench = np.var(aligned["bench"].values, ddof=1)
134
+ if var_bench > 0:
135
+ betas[ticker] = round(float(cov[0, 1] / var_bench), 4)
136
+ except Exception:
137
+ continue
138
+ except Exception as e:
139
+ logger.warning("Beta computation failed: %s", e)
140
+
141
+ return betas
142
+
143
+
144
  def run_stress_test(
145
  holdings: List[Dict[str, Any]],
146
  scenario_id: str | None = None,
147
  custom_shock: float | None = None,
148
+ computed_betas: Dict[str, float] | None = None,
149
  ) -> Dict[str, Any]:
150
  """
151
  Run a stress test on portfolio holdings.
 
154
  holdings: List of holding dicts with market_value, asset_class, etc.
155
  scenario_id: ID of a predefined scenario
156
  custom_shock: Custom market shock as decimal (e.g., -0.15 for -15%)
157
+ computed_betas: Pre-computed real betas per ticker (from compute_real_betas)
158
 
159
  Returns:
160
  Dict with per-holding impact and portfolio-level summary.
 
173
 
174
  market_shock = scenario["market_shock"]
175
  sector_shocks = scenario.get("sector_shocks", {})
176
+ computed_betas = computed_betas or {}
177
 
178
  total_before = sum(h.get("market_value", 0) for h in holdings)
179
  impacts = []
 
185
 
186
  asset_class = h.get("asset_class", "equity")
187
  sector = h.get("sector", "default")
188
+ ticker = h.get("ticker", "")
189
+
190
+ # Use computed beta if available, fall back to sector beta
191
+ if ticker in computed_betas:
192
+ beta = computed_betas[ticker]
193
+ beta_source = "computed"
194
+ else:
195
+ beta = SECTOR_BETAS.get(sector, SECTOR_BETAS.get(asset_class, 1.0))
196
+ beta_source = "sector_estimate"
197
 
198
  # Check for sector-specific shock override
199
  if sector in sector_shocks:
 
219
  new_value = mv + impact_value
220
 
221
  impacts.append({
222
+ "ticker": ticker,
223
+ "name": h.get("name", ticker),
224
  "current_value": round(mv, 2),
225
+ "beta": round(beta, 4),
226
+ "beta_source": beta_source,
227
  "shock_pct": round(shock * 100, 2),
228
  "impact_value": round(impact_value, 2),
229
  "new_value": round(new_value, 2),
 
256
  }
257
 
258
 
259
+ def run_all_scenarios(
260
+ holdings: List[Dict[str, Any]],
261
+ computed_betas: Dict[str, float] | None = None,
262
+ ) -> List[Dict[str, Any]]:
263
  """Run all predefined scenarios and return summaries."""
264
  results = []
265
  for sid in SCENARIOS:
266
+ result = run_stress_test(holdings, scenario_id=sid, computed_betas=computed_betas)
267
  results.append({
268
  "scenario_id": sid,
269
  "name": result["scenario"]["name"],
backend/app/services/sentiment/engine.py CHANGED
@@ -2,11 +2,12 @@
2
  Sentiment Analysis Engine.
3
 
4
  Provides sentiment analysis from multiple sources:
5
- - News headlines (via RSS feeds from Yahoo Finance, Finviz)
6
- - Social media sentiment scoring
7
- - Composite sentiment aggregation per ticker
8
 
9
- Uses TextBlob for NLP sentiment classification.
 
10
  """
11
 
12
  from __future__ import annotations
@@ -66,7 +67,64 @@ def _classify(score: float) -> str:
66
  return "neutral"
67
 
68
 
69
- async def fetch_news_sentiment(ticker: str) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  """Fetch and analyze news headlines for a ticker."""
71
  cache_key = f"sentiment:news:{ticker}"
72
  cached = await cache_get(cache_key)
@@ -94,28 +152,50 @@ async def fetch_news_sentiment(ticker: str) -> Dict[str, Any]:
94
  continue
95
 
96
  title = title_el.text.strip()
97
- score = _score_headline(title)
98
 
99
  headlines.append({
100
  "title": title,
101
  "source": source,
102
- "score": score,
103
- "sentiment": _classify(score),
 
104
  "published": pub_date_el.text if pub_date_el is not None else None,
105
  "url": link_el.text if link_el is not None else None,
106
  })
107
  except Exception as e:
108
  logger.warning("Failed to fetch %s news for %s: %s", source, ticker, e)
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  # Compute aggregate scores
111
  if headlines:
112
  avg_score = round(sum(h["score"] for h in headlines) / len(headlines), 3)
113
  bullish_count = sum(1 for h in headlines if h["sentiment"] == "bullish")
114
  bearish_count = sum(1 for h in headlines if h["sentiment"] == "bearish")
115
  neutral_count = sum(1 for h in headlines if h["sentiment"] == "neutral")
 
116
  else:
117
  avg_score = 0.0
118
- bullish_count = bearish_count = neutral_count = 0
119
 
120
  result = {
121
  "ticker": ticker,
@@ -125,6 +205,7 @@ async def fetch_news_sentiment(ticker: str) -> Dict[str, Any]:
125
  "bullish": bullish_count,
126
  "bearish": bearish_count,
127
  "neutral": neutral_count,
 
128
  "headlines": headlines[:20], # Latest 20
129
  "timestamp": datetime.utcnow().isoformat(),
130
  }
 
2
  Sentiment Analysis Engine.
3
 
4
  Provides sentiment analysis from multiple sources:
5
+ - News headlines (via RSS feeds from Yahoo Finance, Google News)
6
+ - Rule-based keyword scoring for fast fallback
7
+ - LLM-based sentiment classification via Groq for nuanced analysis
8
 
9
+ Uses financial keyword matching as primary method with optional
10
+ LLM upgrade for more accurate sentiment classification.
11
  """
12
 
13
  from __future__ import annotations
 
67
  return "neutral"
68
 
69
 
70
+ # ── LLM-based Sentiment (Groq) ──────────────────────────────────────────
71
+
72
+ async def _score_headlines_llm(headlines: List[str]) -> List[Dict[str, Any]]:
73
+ """
74
+ Score headlines using Groq LLM for nuanced financial sentiment.
75
+ Falls back to rule-based scoring on failure.
76
+ """
77
+ from app.config import get_settings
78
+ settings = get_settings()
79
+
80
+ if not settings.groq_api_key:
81
+ return []
82
+
83
+ try:
84
+ headlines_text = "\n".join(f"{i+1}. {h}" for i, h in enumerate(headlines[:20]))
85
+ prompt = f"""Analyze each financial news headline and classify its sentiment.
86
+ Return ONLY a JSON array of objects with "index" (1-based), "sentiment" ("bullish"/"bearish"/"neutral"), and "score" (-1.0 to 1.0).
87
+
88
+ Headlines:
89
+ {headlines_text}
90
+
91
+ JSON response:"""
92
+
93
+ async with aiohttp.ClientSession() as session:
94
+ async with session.post(
95
+ "https://api.groq.com/openai/v1/chat/completions",
96
+ headers={
97
+ "Authorization": f"Bearer {settings.groq_api_key}",
98
+ "Content-Type": "application/json",
99
+ },
100
+ json={
101
+ "model": "llama-3.3-70b-versatile",
102
+ "messages": [{"role": "user", "content": prompt}],
103
+ "temperature": 0.1,
104
+ "max_tokens": 1000,
105
+ },
106
+ timeout=aiohttp.ClientTimeout(total=15),
107
+ ) as resp:
108
+ if resp.status != 200:
109
+ return []
110
+ data = await resp.json()
111
+ content = data["choices"][0]["message"]["content"]
112
+
113
+ # Extract JSON from response
114
+ import json
115
+ # Try to find JSON array in the response
116
+ start = content.find("[")
117
+ end = content.rfind("]") + 1
118
+ if start >= 0 and end > start:
119
+ results = json.loads(content[start:end])
120
+ return results
121
+ except Exception as e:
122
+ logger.warning("LLM sentiment failed: %s", e)
123
+
124
+ return []
125
+
126
+
127
+ async def fetch_news_sentiment(ticker: str, use_llm: bool = True) -> Dict[str, Any]:
128
  """Fetch and analyze news headlines for a ticker."""
129
  cache_key = f"sentiment:news:{ticker}"
130
  cached = await cache_get(cache_key)
 
152
  continue
153
 
154
  title = title_el.text.strip()
155
+ rule_score = _score_headline(title)
156
 
157
  headlines.append({
158
  "title": title,
159
  "source": source,
160
+ "score": rule_score,
161
+ "sentiment": _classify(rule_score),
162
+ "method": "rule_based",
163
  "published": pub_date_el.text if pub_date_el is not None else None,
164
  "url": link_el.text if link_el is not None else None,
165
  })
166
  except Exception as e:
167
  logger.warning("Failed to fetch %s news for %s: %s", source, ticker, e)
168
 
169
+ # Try LLM-based scoring for better accuracy
170
+ llm_results = []
171
+ if use_llm and headlines:
172
+ try:
173
+ llm_results = await _score_headlines_llm([h["title"] for h in headlines[:20]])
174
+ except Exception:
175
+ pass
176
+
177
+ # Merge LLM results into headlines
178
+ if llm_results:
179
+ for lr in llm_results:
180
+ idx = lr.get("index", 0) - 1
181
+ if 0 <= idx < len(headlines):
182
+ headlines[idx]["ai_score"] = lr.get("score", headlines[idx]["score"])
183
+ headlines[idx]["ai_sentiment"] = lr.get("sentiment", headlines[idx]["sentiment"])
184
+ headlines[idx]["method"] = "ai_enhanced"
185
+ # Use AI score as primary if available
186
+ headlines[idx]["score"] = lr.get("score", headlines[idx]["score"])
187
+ headlines[idx]["sentiment"] = lr.get("sentiment", headlines[idx]["sentiment"])
188
+
189
  # Compute aggregate scores
190
  if headlines:
191
  avg_score = round(sum(h["score"] for h in headlines) / len(headlines), 3)
192
  bullish_count = sum(1 for h in headlines if h["sentiment"] == "bullish")
193
  bearish_count = sum(1 for h in headlines if h["sentiment"] == "bearish")
194
  neutral_count = sum(1 for h in headlines if h["sentiment"] == "neutral")
195
+ ai_enhanced = sum(1 for h in headlines if h.get("method") == "ai_enhanced")
196
  else:
197
  avg_score = 0.0
198
+ bullish_count = bearish_count = neutral_count = ai_enhanced = 0
199
 
200
  result = {
201
  "ticker": ticker,
 
205
  "bullish": bullish_count,
206
  "bearish": bearish_count,
207
  "neutral": neutral_count,
208
+ "ai_enhanced_count": ai_enhanced,
209
  "headlines": headlines[:20], # Latest 20
210
  "timestamp": datetime.utcnow().isoformat(),
211
  }
frontend/src/App.tsx CHANGED
@@ -15,6 +15,11 @@ import ResearchInsights from './pages/ResearchInsights';
15
  import HoldingsTracker from './pages/HoldingsTracker';
16
  import Sentiment from './pages/Sentiment';
17
  import EconomicCalendar from './pages/EconomicCalendar';
 
 
 
 
 
18
  import './index.css';
19
 
20
  function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -76,6 +81,22 @@ export default function App() {
76
  <Route path="/calendar" element={
77
  <ProtectedRoute><AppLayout><EconomicCalendar /></AppLayout></ProtectedRoute>
78
  } />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  </Routes>
80
  </BrowserRouter>
81
  );
 
15
  import HoldingsTracker from './pages/HoldingsTracker';
16
  import Sentiment from './pages/Sentiment';
17
  import EconomicCalendar from './pages/EconomicCalendar';
18
+ import PortfolioHealth from './pages/PortfolioHealth';
19
+ import BiasDetector from './pages/BiasDetector';
20
+ import CrisisReplay from './pages/CrisisReplay';
21
+ import PortfolioDNA from './pages/PortfolioDNA';
22
+ import Copilot from './pages/Copilot';
23
  import './index.css';
24
 
25
  function ProtectedRoute({ children }: { children: React.ReactNode }) {
 
81
  <Route path="/calendar" element={
82
  <ProtectedRoute><AppLayout><EconomicCalendar /></AppLayout></ProtectedRoute>
83
  } />
84
+ {/* ── New Analytics & AI Pages ──────────────────────────────────── */}
85
+ <Route path="/portfolio-health" element={
86
+ <ProtectedRoute><AppLayout><PortfolioHealth /></AppLayout></ProtectedRoute>
87
+ } />
88
+ <Route path="/bias-detector" element={
89
+ <ProtectedRoute><AppLayout><BiasDetector /></AppLayout></ProtectedRoute>
90
+ } />
91
+ <Route path="/crisis-replay" element={
92
+ <ProtectedRoute><AppLayout><CrisisReplay /></AppLayout></ProtectedRoute>
93
+ } />
94
+ <Route path="/portfolio-dna" element={
95
+ <ProtectedRoute><AppLayout><PortfolioDNA /></AppLayout></ProtectedRoute>
96
+ } />
97
+ <Route path="/copilot" element={
98
+ <ProtectedRoute><AppLayout><Copilot /></AppLayout></ProtectedRoute>
99
+ } />
100
  </Routes>
101
  </BrowserRouter>
102
  );
frontend/src/api/client.ts CHANGED
@@ -138,6 +138,17 @@ export const holdingsAPI = {
138
  optionsHedge: () => api.get('/holdings/options-hedge'),
139
  rebalance: () => api.get('/holdings/rebalance'),
140
  correlation: () => api.get('/holdings/correlation'),
 
 
 
 
 
 
 
 
 
 
 
141
  };
142
 
143
  // ── Sentiment ────────────────────────────────────────────────────────────
@@ -163,4 +174,9 @@ export const mlAPI = {
163
  clearCache: () => api.post('/ml/clear-cache'),
164
  };
165
 
 
 
 
 
 
166
  export default api;
 
138
  optionsHedge: () => api.get('/holdings/options-hedge'),
139
  rebalance: () => api.get('/holdings/rebalance'),
140
  correlation: () => api.get('/holdings/correlation'),
141
+ // ── New v2 endpoints ──────────────────────────────────────────────────
142
+ monteCarlo: (data?: { horizon_days?: number; n_simulations?: number; regime_conditioned?: boolean }) =>
143
+ api.post('/holdings/monte-carlo', data || {}),
144
+ healthScore: () => api.get('/holdings/health-score'),
145
+ biasAnalysis: () => api.get('/holdings/bias-analysis'),
146
+ crisisReplay: (crisis_id: string) =>
147
+ api.post('/holdings/crisis-replay', { crisis_id }),
148
+ allCrisisReplays: () => api.get('/holdings/crisis-replay/all'),
149
+ portfolioDNA: () => api.get('/holdings/portfolio-dna'),
150
+ attribution: (method: string = 'brinson') =>
151
+ api.get(`/holdings/attribution?method=${method}`),
152
  };
153
 
154
  // ── Sentiment ────────────────────────────────────────────────────────────
 
174
  clearCache: () => api.post('/ml/clear-cache'),
175
  };
176
 
177
+ // ── HedgeAI───────────────────────────────────────────────────────────
178
+ export const copilotAPI = {
179
+ chat: (message: string) => api.post('/copilot/chat', { message }),
180
+ };
181
+
182
  export default api;
frontend/src/components/Sidebar.tsx CHANGED
@@ -41,41 +41,51 @@ export default function Sidebar({ onExpandChange }: SidebarProps) {
41
  const toggleTheme = () => setIsDark(prev => !prev);
42
 
43
  const themeIcon = isDark ? (
44
- <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd"/></svg>
45
  ) : (
46
- <svg viewBox="0 0 20 20" fill="currentColor"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
47
  );
48
 
49
  const allLinks = [
50
  {
51
  section: 'Overview',
52
  items: [
53
- { to: '/dashboard', label: 'Dashboard', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"/></svg> },
54
- { to: '/holdings', label: 'Holdings', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd"/></svg> },
55
  ]
56
  },
57
  {
58
  section: 'Research',
59
  items: [
60
- { to: '/market', label: 'Markets', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z" clipRule="evenodd"/></svg> },
61
- { to: '/factors', label: 'Factor Analysis', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zm6-4a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zm6-3a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/></svg> },
62
- { to: '/sentiment', label: 'Sentiment', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clipRule="evenodd"/></svg> },
63
- { to: '/research', label: 'Research', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd"/></svg> },
64
- { to: '/calendar', label: 'Calendar', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd"/></svg> },
65
  ]
66
  },
67
  {
68
  section: 'Quantitative',
69
  items: [
70
- { to: '/strategies', label: 'Strategy Builder', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd"/></svg> },
71
- { to: '/backtests', label: 'Backtests', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd"/></svg> },
 
 
 
 
 
 
 
 
 
72
  ]
73
  },
74
  {
75
  section: 'Portfolio',
76
  items: [
77
- { to: '/portfolio', label: 'Optimization', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"/><path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"/></svg> },
78
- { to: '/marketplace', label: 'Marketplace', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3zM16 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM6.5 18a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"/></svg> },
 
79
  ]
80
  },
81
  ];
@@ -97,8 +107,8 @@ export default function Sidebar({ onExpandChange }: SidebarProps) {
97
  > <div className="sidebar-brand">
98
  <NavLink to="/dashboard" className="sidebar-logo">
99
  <svg width="32" height="32" viewBox="0 0 32 32" fill="none">
100
- <rect width="32" height="32" rx="8" fill="#005241"/>
101
- <path d="M8 22 L12 14 L16 18 L20 10 L24 16" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
102
  </svg>
103
  <span className="sidebar-brand-text">
104
  Quant<em>Hedge</em>
@@ -142,7 +152,7 @@ export default function Sidebar({ onExpandChange }: SidebarProps) {
142
  </button>
143
  <button className="sidebar-link sidebar-logout" onClick={handleLogout}>
144
  <span className="sidebar-icon">
145
- <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd"/></svg>
146
  </span>
147
  <span className="sidebar-label">Log Out</span>
148
  </button>
@@ -171,7 +181,7 @@ export default function Sidebar({ onExpandChange }: SidebarProps) {
171
  onClick={() => setDrawerOpen(!drawerOpen)}
172
  >
173
  <svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20">
174
- <path fillRule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd"/>
175
  </svg>
176
  <span>More</span>
177
  </button>
@@ -219,7 +229,7 @@ export default function Sidebar({ onExpandChange }: SidebarProps) {
219
  </button>
220
  <button className="sidebar-link sidebar-logout" onClick={handleLogout} style={{ width: '100%' }}>
221
  <span className="sidebar-icon">
222
- <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd"/></svg>
223
  </span>
224
  <span className="sidebar-label">Log Out</span>
225
  </button>
 
41
  const toggleTheme = () => setIsDark(prev => !prev);
42
 
43
  const themeIcon = isDark ? (
44
+ <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" /></svg>
45
  ) : (
46
+ <svg viewBox="0 0 20 20" fill="currentColor"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" /></svg>
47
  );
48
 
49
  const allLinks = [
50
  {
51
  section: 'Overview',
52
  items: [
53
+ { to: '/dashboard', label: 'Dashboard', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" /></svg> },
54
+ { to: '/holdings', label: 'Holdings', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd" /></svg> },
55
  ]
56
  },
57
  {
58
  section: 'Research',
59
  items: [
60
+ { to: '/market', label: 'Markets', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z" clipRule="evenodd" /></svg> },
61
+ { to: '/factors', label: 'Factor Analysis', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zm6-4a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zm6-3a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z" /></svg> },
62
+ { to: '/sentiment', label: 'Sentiment', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clipRule="evenodd" /></svg> },
63
+ { to: '/research', label: 'Research', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd" /></svg> },
64
+ { to: '/calendar', label: 'Calendar', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd" /></svg> },
65
  ]
66
  },
67
  {
68
  section: 'Quantitative',
69
  items: [
70
+ { to: '/strategies', label: 'Strategy Builder', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" /></svg> },
71
+ { to: '/backtests', label: 'Backtests', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" /></svg> },
72
+ ]
73
+ },
74
+ {
75
+ section: 'Analytics',
76
+ items: [
77
+ { to: '/portfolio-health', label: 'Health Score', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clipRule="evenodd" /></svg> },
78
+ { to: '/bias-detector', label: 'Bias Detector', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" /><path fillRule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" /></svg> },
79
+ { to: '/crisis-replay', label: 'Crisis Replay', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z" clipRule="evenodd" /></svg> },
80
+ { to: '/portfolio-dna', label: 'Portfolio DNA', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 3.5a1.5 1.5 0 013 0V4a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-.5a1.5 1.5 0 000 3h.5a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-.5a1.5 1.5 0 00-3 0v.5a1 1 0 01-1 1H6a1 1 0 01-1-1v-3a1 1 0 00-1-1h-.5a1.5 1.5 0 010-3H4a1 1 0 001-1V6a1 1 0 011-1h3a1 1 0 001-1v-.5z" /></svg> },
81
  ]
82
  },
83
  {
84
  section: 'Portfolio',
85
  items: [
86
+ { to: '/portfolio', label: 'Optimization', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" /><path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" /></svg> },
87
+ { to: '/marketplace', label: 'Marketplace', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3zM16 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM6.5 18a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" /></svg> },
88
+ { to: '/copilot', label: 'HedgeAI', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M6.672 1.911a1 1 0 10-1.932.518l.259.966a1 1 0 001.932-.518l-.26-.966zM2.429 4.74a1 1 0 10-.517 1.932l.966.259a1 1 0 00.517-1.932l-.966-.26zm8.814-.569a1 1 0 00-1.415-1.414l-.707.707a1 1 0 101.415 1.415l.707-.708zm-7.071 7.072l.707-.707A1 1 0 003.465 9.12l-.708.707a1 1 0 001.415 1.415zm3.2-5.171a1 1 0 00-1.3 1.3l4 10a1 1 0 001.823.075l1.38-2.759 3.018 3.02a1 1 0 001.414-1.415l-3.019-3.02 2.76-1.379a1 1 0 00-.076-1.822l-10-4z" clipRule="evenodd" /></svg> },
89
  ]
90
  },
91
  ];
 
107
  > <div className="sidebar-brand">
108
  <NavLink to="/dashboard" className="sidebar-logo">
109
  <svg width="32" height="32" viewBox="0 0 32 32" fill="none">
110
+ <rect width="32" height="32" rx="8" fill="#005241" />
111
+ <path d="M8 22 L12 14 L16 18 L20 10 L24 16" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
112
  </svg>
113
  <span className="sidebar-brand-text">
114
  Quant<em>Hedge</em>
 
152
  </button>
153
  <button className="sidebar-link sidebar-logout" onClick={handleLogout}>
154
  <span className="sidebar-icon">
155
+ <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" /></svg>
156
  </span>
157
  <span className="sidebar-label">Log Out</span>
158
  </button>
 
181
  onClick={() => setDrawerOpen(!drawerOpen)}
182
  >
183
  <svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20">
184
+ <path fillRule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
185
  </svg>
186
  <span>More</span>
187
  </button>
 
229
  </button>
230
  <button className="sidebar-link sidebar-logout" onClick={handleLogout} style={{ width: '100%' }}>
231
  <span className="sidebar-icon">
232
+ <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" /></svg>
233
  </span>
234
  <span className="sidebar-label">Log Out</span>
235
  </button>
frontend/src/index.css CHANGED
@@ -35,8 +35,8 @@
35
 
36
  --gradient-accent: linear-gradient(135deg, #005241, #007d63);
37
  --gradient-dark: linear-gradient(135deg, #0c1f1a 0%, #1a3a2e 50%, #0c1f1a 100%);
38
- --gradient-hero-overlay: linear-gradient(180deg, rgba(12,31,26,0.85) 0%, rgba(12,31,26,0.7) 50%, rgba(12,31,26,0.9) 100%);
39
- --gradient-card-hover: linear-gradient(145deg, rgba(0,82,65,0.02), rgba(0,82,65,0.06));
40
 
41
  --border-color: #e2e5ea;
42
  --border-subtle: #eef0f3;
@@ -45,7 +45,7 @@
45
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
46
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
47
  --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.1);
48
- --shadow-card: 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.06);
49
  --shadow-card-hover: 0 10px 40px rgba(0, 0, 0, 0.08);
50
 
51
  --radius-sm: 4px;
@@ -103,7 +103,7 @@
103
  --blue-info: #3b82f6;
104
 
105
  --gradient-accent: linear-gradient(135deg, #00a87a, #00c896);
106
- --gradient-card-hover: linear-gradient(145deg, rgba(0,168,122,0.04), rgba(0,168,122,0.08));
107
 
108
  --border-color: #1f2228;
109
  --border-subtle: #181b20;
@@ -112,7 +112,7 @@
112
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
113
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
114
  --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
115
- --shadow-card: 0 1px 3px rgba(0,0,0,0.2), 0 1px 2px rgba(0,0,0,0.15);
116
  --shadow-card-hover: 0 10px 40px rgba(0, 0, 0, 0.35);
117
 
118
  --chart-axis: #6b7588;
@@ -132,34 +132,92 @@ body {
132
  }
133
 
134
  /* Dark mode: badge overrides */
135
- [data-theme="dark"] .badge-primary { background: rgba(0,168,122,0.15); color: var(--accent); }
136
- [data-theme="dark"] .badge-emerald { background: rgba(16,185,129,0.15); color: var(--green-positive); }
137
- [data-theme="dark"] .badge-rose { background: rgba(239,68,68,0.15); color: var(--red-negative); }
138
- [data-theme="dark"] .badge-amber { background: rgba(212,168,67,0.15); color: var(--amber-neutral); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
  /* Dark mode: table header */
141
- [data-theme="dark"] th { background: var(--bg-tertiary); }
 
 
142
 
143
  /* Dark mode: scrollbar */
144
- [data-theme="dark"] ::-webkit-scrollbar-track { background: var(--bg-secondary); }
145
- [data-theme="dark"] ::-webkit-scrollbar-thumb { background: #2a2d32; }
146
- [data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: var(--accent); }
 
 
 
 
 
 
 
 
147
 
148
  /* Dark mode: selection */
149
- [data-theme="dark"] ::selection { background: rgba(0, 168, 122, 0.25); color: var(--text-primary); }
 
 
 
150
 
151
  /* Dark mode: React Flow (Strategy Builder) */
152
- [data-theme="dark"] .react-flow__controls { background: var(--bg-tertiary); border-color: var(--border-color); border-radius: var(--radius-md); }
153
- [data-theme="dark"] .react-flow__controls-button { background: var(--bg-card); border-color: var(--border-color); fill: var(--text-secondary); }
154
- [data-theme="dark"] .react-flow__controls-button:hover { background: var(--bg-hover); }
155
- [data-theme="dark"] .react-flow__minimap { background: var(--bg-tertiary) !important; }
156
- [data-theme="dark"] .react-flow__attribution { background: transparent !important; }
157
- [data-theme="dark"] .react-flow__attribution a { color: var(--text-muted) !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
  /* ── Reset ───────────────────────────────────────────────────────────── */
160
- *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
 
 
 
 
 
 
161
 
162
- html { scroll-behavior: smooth; -webkit-font-smoothing: antialiased; }
 
 
 
163
 
164
  body {
165
  font-family: var(--font-sans);
@@ -169,21 +227,52 @@ body {
169
  min-height: 100vh;
170
  }
171
 
172
- #root { min-height: 100vh; display: flex; flex-direction: column; }
 
 
 
 
 
 
 
 
 
 
173
 
174
- a { color: var(--accent); text-decoration: none; transition: color var(--transition-fast); }
175
- a:hover { color: var(--accent-light); }
 
176
 
177
  /* ── Typography ──────────────────────────────────────────────────────── */
178
- h1, h2, h3, h4, h5, h6 {
 
 
 
 
 
179
  font-weight: 600;
180
  line-height: 1.25;
181
  letter-spacing: -0.02em;
182
  color: var(--text-primary);
183
  }
184
- h1 { font-size: clamp(2rem, 4.5vw, 3.25rem); font-family: var(--font-serif); font-weight: 500; }
185
- h2 { font-size: clamp(1.5rem, 3vw, 2.25rem); font-family: var(--font-serif); font-weight: 500; }
186
- h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
 
188
  .text-gradient {
189
  background: var(--gradient-accent);
@@ -191,31 +280,85 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
191
  -webkit-text-fill-color: transparent;
192
  background-clip: text;
193
  }
194
- .text-serif { font-family: var(--font-serif); }
195
- .text-accent { color: var(--accent); }
196
- .mono { font-family: var(--font-mono); font-size: 0.875em; }
 
 
 
 
 
 
 
 
 
 
197
 
198
  /* ── Layout ──────────────────────────────────────────────────────────── */
199
  .app-layout {
200
  display: flex;
201
  min-height: 100vh;
202
  }
 
203
  .app-main {
204
  flex: 1;
205
  min-width: 0;
206
  margin-left: 68px;
207
  transition: margin-left var(--transition-base);
208
  }
209
- .page { flex: 1; padding: 2.5rem 3rem; max-width: 1320px; margin: 0 auto; width: 100%; }
210
- .page-header { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); }
211
- .page-header h1 { margin-bottom: 0.25rem; }
212
- .page-header p { color: var(--text-secondary); font-size: 0.95rem; }
213
 
214
- .grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 1.75rem; }
215
- .grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }
216
- .grid-4 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1.25rem; }
217
- .flex-between { display: flex; justify-content: space-between; align-items: center; }
218
- .flex-gap { display: flex; gap: 0.75rem; align-items: center; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
 
220
  /* ── Sidebar ────────────────────────────────────────────────────────── */
221
  .sidebar {
@@ -232,6 +375,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
232
  transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
233
  z-index: 200;
234
  }
 
235
  .sidebar.sidebar-expanded {
236
  width: 260px;
237
  }
@@ -245,6 +389,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
245
  display: flex;
246
  align-items: center;
247
  }
 
248
  .sidebar-logo {
249
  display: flex;
250
  align-items: center;
@@ -253,9 +398,11 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
253
  color: var(--text-primary);
254
  white-space: nowrap;
255
  }
 
256
  .sidebar-logo svg {
257
  flex-shrink: 0;
258
  }
 
259
  .sidebar-brand-text {
260
  font-family: var(--font-serif);
261
  font-size: 1.2rem;
@@ -265,10 +412,12 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
265
  transform: translateX(-8px);
266
  transition: opacity 0.2s ease 0.05s, transform 0.2s ease 0.05s;
267
  }
 
268
  .sidebar.sidebar-expanded .sidebar-brand-text {
269
  opacity: 1;
270
  transform: translateX(0);
271
  }
 
272
  .sidebar-brand-text em {
273
  font-style: normal;
274
  color: var(--accent);
@@ -281,11 +430,15 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
281
  overflow-x: hidden;
282
  padding: 0.5rem 0;
283
  }
284
- .sidebar-nav::-webkit-scrollbar { width: 0; }
 
 
 
285
 
286
  .sidebar-section {
287
  padding: 0.5rem 0;
288
  }
 
289
  .sidebar-section-label {
290
  padding: 0.375rem 1.4rem;
291
  font-size: 0.6rem;
@@ -297,6 +450,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
297
  opacity: 0;
298
  transition: opacity 0.2s ease 0.05s;
299
  }
 
300
  .sidebar.sidebar-expanded .sidebar-section-label {
301
  opacity: 1;
302
  }
@@ -321,10 +475,12 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
321
  text-align: left;
322
  font-family: var(--font-sans);
323
  }
 
324
  .sidebar-link:hover {
325
  background: var(--bg-hover);
326
  color: var(--accent);
327
  }
 
328
  .sidebar-link.active {
329
  background: var(--accent-lighter);
330
  color: var(--accent);
@@ -339,6 +495,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
339
  align-items: center;
340
  justify-content: center;
341
  }
 
342
  .sidebar-icon svg {
343
  width: 18px;
344
  height: 18px;
@@ -349,6 +506,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
349
  transform: translateX(-8px);
350
  transition: opacity 0.2s ease 0.05s, transform 0.2s ease 0.05s;
351
  }
 
352
  .sidebar.sidebar-expanded .sidebar-label {
353
  opacity: 1;
354
  transform: translateX(0);
@@ -360,6 +518,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
360
  padding: 0.75rem;
361
  flex-shrink: 0;
362
  }
 
363
  .sidebar-user {
364
  display: flex;
365
  align-items: center;
@@ -369,6 +528,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
369
  white-space: nowrap;
370
  overflow: hidden;
371
  }
 
372
  .sidebar-avatar {
373
  width: 32px;
374
  height: 32px;
@@ -382,29 +542,36 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
382
  font-weight: 700;
383
  flex-shrink: 0;
384
  }
 
385
  .sidebar-user-info {
386
  opacity: 0;
387
  transition: opacity 0.2s ease 0.05s;
388
  }
 
389
  .sidebar.sidebar-expanded .sidebar-user-info {
390
  opacity: 1;
391
  }
 
392
  .sidebar-user-name {
393
  font-size: 0.78rem;
394
  font-weight: 600;
395
  color: var(--text-primary);
396
  }
 
397
  .sidebar-user-email {
398
  font-size: 0.65rem;
399
  color: var(--text-muted);
400
  }
 
401
  .sidebar-logout {
402
  color: var(--text-muted) !important;
403
  }
 
404
  .sidebar-logout:hover {
405
  color: var(--red-negative) !important;
406
  background: rgba(194, 48, 48, 0.05) !important;
407
  }
 
408
  .card {
409
  background: var(--bg-card);
410
  border: 1px solid var(--border-color);
@@ -413,10 +580,12 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
413
  box-shadow: var(--shadow-card);
414
  transition: all var(--transition-base);
415
  }
 
416
  .card:hover {
417
  box-shadow: var(--shadow-card-hover);
418
  border-color: var(--border-accent);
419
  }
 
420
  .card-header {
421
  display: flex;
422
  justify-content: space-between;
@@ -425,7 +594,13 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
425
  padding-bottom: 0.875rem;
426
  border-bottom: 1px solid var(--border-subtle);
427
  }
428
- .card-header h3 { font-size: 0.95rem; font-weight: 600; color: var(--text-primary); letter-spacing: 0; }
 
 
 
 
 
 
429
 
430
  /* ── Buttons ─────────────────────────────────────────────────────────── */
431
  .btn {
@@ -445,31 +620,55 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
445
  letter-spacing: 0.02em;
446
  text-transform: uppercase;
447
  }
448
- .btn:active { transform: scale(0.98); }
 
 
 
449
 
450
  .btn-primary {
451
  background: var(--accent);
452
  color: #fff;
453
  }
454
- .btn-primary:hover { background: var(--accent-light); }
 
 
 
455
 
456
  .btn-secondary {
457
  background: transparent;
458
  color: var(--text-primary);
459
  border: 1px solid var(--border-color);
460
  }
 
461
  .btn-secondary:hover {
462
  background: var(--bg-hover);
463
  border-color: var(--accent);
464
  color: var(--accent);
465
  }
466
 
467
- .btn-sm { padding: 0.375rem 0.875rem; font-size: 0.75rem; }
468
- .btn-lg { padding: 0.875rem 2.25rem; font-size: 0.875rem; letter-spacing: 0.08em; }
469
- .btn-icon { width: 36px; height: 36px; padding: 0; border-radius: var(--radius-sm); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
 
471
  /* ── Form Elements ───────────────────────────────────────────────────── */
472
- .form-group { margin-bottom: 1.25rem; }
 
 
 
473
  .form-group label {
474
  display: block;
475
  font-size: 0.75rem;
@@ -480,7 +679,9 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
480
  letter-spacing: 0.06em;
481
  }
482
 
483
- .input, select, textarea {
 
 
484
  width: 100%;
485
  padding: 0.625rem 0.875rem;
486
  background: var(--bg-primary);
@@ -492,11 +693,17 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
492
  transition: all var(--transition-fast);
493
  outline: none;
494
  }
495
- .input:focus, select:focus, textarea:focus {
 
 
 
496
  border-color: var(--accent);
497
  box-shadow: 0 0 0 3px rgba(0, 82, 65, 0.1);
498
  }
499
- .input::placeholder { color: var(--text-muted); }
 
 
 
500
 
501
  /* ── Table ───────────────────────────────────────────────────────────── */
502
  .table-container {
@@ -504,7 +711,12 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
504
  border-radius: var(--radius-md);
505
  border: 1px solid var(--border-color);
506
  }
507
- table { width: 100%; border-collapse: collapse; }
 
 
 
 
 
508
  th {
509
  padding: 0.75rem 1rem;
510
  text-align: left;
@@ -516,6 +728,7 @@ th {
516
  background: var(--bg-secondary);
517
  border-bottom: 2px solid var(--border-color);
518
  }
 
519
  td {
520
  padding: 0.75rem 1rem;
521
  font-size: 0.85rem;
@@ -523,16 +736,24 @@ td {
523
  font-family: var(--font-mono);
524
  font-size: 0.8rem;
525
  }
526
- tr:hover td { background: var(--bg-hover); }
 
 
 
527
 
528
  /* ── Metrics ─────────────────────────────────────────────────────────── */
529
- .metric { text-align: center; padding: 1.25rem 1rem; }
 
 
 
 
530
  .metric-value {
531
  font-size: 1.75rem;
532
  font-weight: 700;
533
  font-family: var(--font-mono);
534
  letter-spacing: -0.03em;
535
  }
 
536
  .metric-label {
537
  font-size: 0.65rem;
538
  text-transform: uppercase;
@@ -541,9 +762,18 @@ tr:hover td { background: var(--bg-hover); }
541
  margin-top: 0.25rem;
542
  font-weight: 600;
543
  }
544
- .positive { color: var(--green-positive); }
545
- .negative { color: var(--red-negative); }
546
- .neutral { color: var(--amber-neutral); }
 
 
 
 
 
 
 
 
 
547
 
548
  /* ── Badge ───────────────────────────────────────────────────────────── */
549
  .badge {
@@ -556,10 +786,26 @@ tr:hover td { background: var(--bg-hover); }
556
  letter-spacing: 0.06em;
557
  text-transform: uppercase;
558
  }
559
- .badge-primary { background: var(--accent-lighter); color: var(--accent); }
560
- .badge-emerald { background: #e6f9f0; color: var(--green-positive); }
561
- .badge-rose { background: var(--error-bg); color: var(--red-negative); }
562
- .badge-amber { background: #fffbeb; color: var(--amber-neutral); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
 
564
  /* ── Tabs ────────────────────────────────────────────────────────────── */
565
  .tabs {
@@ -568,6 +814,7 @@ tr:hover td { background: var(--bg-hover); }
568
  border-bottom: 2px solid var(--border-color);
569
  margin-bottom: 1.75rem;
570
  }
 
571
  .tab {
572
  padding: 0.75rem 1.25rem;
573
  font-size: 0.8rem;
@@ -582,7 +829,11 @@ tr:hover td { background: var(--bg-hover); }
582
  text-transform: uppercase;
583
  letter-spacing: 0.06em;
584
  }
585
- .tab:hover { color: var(--text-primary); }
 
 
 
 
586
  .tab.active {
587
  color: var(--accent);
588
  border-bottom-color: var(--accent);
@@ -597,7 +848,12 @@ tr:hover td { background: var(--bg-hover); }
597
  border-radius: 50%;
598
  animation: spin 0.8s linear infinite;
599
  }
600
- @keyframes spin { to { transform: rotate(360deg); } }
 
 
 
 
 
601
 
602
  .loading-overlay {
603
  display: flex;
@@ -611,15 +867,34 @@ tr:hover td { background: var(--bg-hover); }
611
 
612
  /* ── Animations ──────────────────────────────────────────────────────── */
613
  @keyframes fadeInUp {
614
- from { opacity: 0; transform: translateY(24px); }
615
- to { opacity: 1; transform: translateY(0); }
 
 
 
 
 
 
 
616
  }
 
617
  @keyframes fadeIn {
618
- from { opacity: 0; }
619
- to { opacity: 1; }
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  }
621
- .animate-fade-in { animation: fadeIn 0.6s ease-out; }
622
- .animate-fade-in-up { animation: fadeInUp 0.7s ease-out; }
623
 
624
  /* ── Chart ───────────────────────────────────────────────────────────── */
625
  .chart-container {
@@ -639,11 +914,25 @@ tr:hover td { background: var(--bg-hover); }
639
  text-align: center;
640
  color: var(--text-muted);
641
  }
642
- .empty-state h3 { color: var(--text-secondary); margin-bottom: 0.5rem; }
643
- .empty-state p { max-width: 400px; font-size: 0.9rem; line-height: 1.7; }
 
 
 
 
 
 
 
 
 
644
 
645
  /* ── Divider ─────────────────────────────────────────────────────────── */
646
- .divider { width: 60px; height: 2px; background: var(--accent); margin: 1rem 0; }
 
 
 
 
 
647
 
648
  /* ── Professional Icon Container ─────────────────────────────────────── */
649
  .icon-box {
@@ -657,7 +946,11 @@ tr:hover td { background: var(--bg-hover); }
657
  color: var(--accent);
658
  flex-shrink: 0;
659
  }
660
- .icon-box svg { width: 22px; height: 22px; }
 
 
 
 
661
 
662
  /* ═══════════════════════════════════════════════════════════════════════
663
  MOBILE RESPONSIVE β€” max-width: 768px
@@ -669,7 +962,8 @@ tr:hover td { background: var(--bg-hover); }
669
  /* ── App Shell ─────────────────────────────────────────────────────── */
670
  .app-main {
671
  margin-left: 0 !important;
672
- padding-bottom: 72px; /* space for bottom tab bar */
 
673
  }
674
 
675
  /* ── Desktop Sidebar hidden on mobile ──────────────────────────────── */
@@ -691,6 +985,7 @@ tr:hover td { background: var(--bg-hover); }
691
  padding: 0 0.25rem;
692
  box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
693
  }
 
694
  .mobile-nav-items {
695
  display: flex;
696
  justify-content: space-around;
@@ -698,6 +993,7 @@ tr:hover td { background: var(--bg-hover); }
698
  width: 100%;
699
  height: 100%;
700
  }
 
701
  .mobile-nav-item {
702
  display: flex;
703
  flex-direction: column;
@@ -717,10 +1013,12 @@ tr:hover td { background: var(--bg-hover); }
717
  cursor: pointer;
718
  font-family: var(--font-sans);
719
  }
 
720
  .mobile-nav-item svg {
721
  width: 20px;
722
  height: 20px;
723
  }
 
724
  .mobile-nav-item.active,
725
  .mobile-nav-item:hover {
726
  color: var(--accent);
@@ -734,6 +1032,7 @@ tr:hover td { background: var(--bg-hover); }
734
  z-index: 400;
735
  animation: fadeIn 0.2s ease;
736
  }
 
737
  .mobile-drawer {
738
  position: fixed;
739
  bottom: 0;
@@ -747,10 +1046,17 @@ tr:hover td { background: var(--bg-hover); }
747
  overflow-y: auto;
748
  animation: slideUp 0.25s ease;
749
  }
 
750
  @keyframes slideUp {
751
- from { transform: translateY(100%); }
752
- to { transform: translateY(0); }
 
 
 
 
 
753
  }
 
754
  .mobile-drawer-handle {
755
  width: 36px;
756
  height: 4px;
@@ -758,6 +1064,7 @@ tr:hover td { background: var(--bg-hover); }
758
  border-radius: 2px;
759
  margin: 0 auto 1rem;
760
  }
 
761
  .mobile-drawer .sidebar-link {
762
  width: 100%;
763
  padding: 0.75rem 1rem;
@@ -766,14 +1073,17 @@ tr:hover td { background: var(--bg-hover); }
766
  border-radius: var(--radius-md);
767
  opacity: 1;
768
  }
 
769
  .mobile-drawer .sidebar-link .sidebar-label {
770
  opacity: 1;
771
  transform: none;
772
  }
 
773
  .mobile-drawer .sidebar-link .sidebar-icon {
774
  width: 22px;
775
  height: 22px;
776
  }
 
777
  .mobile-drawer .sidebar-section-label {
778
  opacity: 1;
779
  padding: 0.5rem 1rem 0.25rem;
@@ -785,13 +1095,16 @@ tr:hover td { background: var(--bg-hover); }
785
  padding: 1.25rem 1rem !important;
786
  max-width: 100%;
787
  }
 
788
  .page-header {
789
  margin-bottom: 1.25rem;
790
  padding-bottom: 1rem;
791
  }
 
792
  .page-header h1 {
793
  font-size: 1.5rem !important;
794
  }
 
795
  .page-header p {
796
  font-size: 0.85rem;
797
  }
@@ -809,12 +1122,14 @@ tr:hover td { background: var(--bg-hover); }
809
  padding: 1.125rem !important;
810
  border-radius: var(--radius-md);
811
  }
 
812
  .card-header {
813
  margin-bottom: 1rem;
814
  padding-bottom: 0.75rem;
815
  flex-wrap: wrap;
816
  gap: 0.5rem;
817
  }
 
818
  .card-header h3 {
819
  font-size: 0.88rem;
820
  }
@@ -827,7 +1142,9 @@ tr:hover td { background: var(--bg-hover); }
827
  border-right: none;
828
  -webkit-overflow-scrolling: touch;
829
  }
830
- th, td {
 
 
831
  padding: 0.5rem 0.625rem;
832
  font-size: 0.72rem;
833
  white-space: nowrap;
@@ -840,9 +1157,17 @@ tr:hover td { background: var(--bg-hover); }
840
  }
841
 
842
  /* ── Metrics ───────────────────────────────────────────────────────── */
843
- .metric { padding: 0.875rem 0.75rem; }
844
- .metric-value { font-size: 1.375rem; }
845
- .metric-label { font-size: 0.6rem; }
 
 
 
 
 
 
 
 
846
 
847
  /* ── Tabs ───────────────────────────────────────────────────────────── */
848
  .tabs {
@@ -853,7 +1178,11 @@ tr:hover td { background: var(--bg-hover); }
853
  margin-bottom: 1.25rem;
854
  scrollbar-width: none;
855
  }
856
- .tabs::-webkit-scrollbar { display: none; }
 
 
 
 
857
  .tab {
858
  padding: 0.625rem 0.875rem;
859
  font-size: 0.7rem;
@@ -867,9 +1196,13 @@ tr:hover td { background: var(--bg-hover); }
867
  }
868
 
869
  /* ── Forms ─────────────────────────────────────────────────────────── */
870
- .input, select, textarea {
871
- font-size: 16px; /* prevents iOS zoom on focus */
 
 
 
872
  }
 
873
  .form-group label {
874
  font-size: 0.7rem;
875
  }
@@ -901,7 +1234,11 @@ tr:hover td { background: var(--bg-hover); }
901
  width: 40px;
902
  height: 40px;
903
  }
904
- .icon-box svg { width: 18px; height: 18px; }
 
 
 
 
905
 
906
  /* ═════════════════════════════════════════════════════════════════════
907
  PAGE-SPECIFIC MOBILE OVERRIDES
@@ -911,30 +1248,38 @@ tr:hover td { background: var(--bg-hover); }
911
  .landing-header {
912
  padding: 0.875rem 1rem !important;
913
  }
 
914
  .landing-header .brand-text {
915
  font-size: 1.1rem;
916
  }
 
917
  .landing-hero {
918
  min-height: 80vh !important;
919
  padding: 1rem !important;
920
  }
 
921
  .landing-hero h1 {
922
  font-size: clamp(1.75rem, 7vw, 2.5rem) !important;
923
  line-height: 1.2 !important;
924
  }
 
925
  .landing-hero p {
926
  font-size: 0.9rem !important;
927
  }
 
928
  .landing-hero .flex-gap {
929
  flex-direction: column;
930
  width: 100%;
931
  }
 
932
  .landing-hero .flex-gap .btn {
933
  width: 100%;
934
  }
 
935
  .landing-section {
936
  padding: 3rem 1.25rem !important;
937
  }
 
938
  .landing-footer {
939
  flex-direction: column !important;
940
  gap: 0.5rem;
@@ -946,10 +1291,12 @@ tr:hover td { background: var(--bg-hover); }
946
  .auth-container {
947
  flex-direction: column !important;
948
  }
 
949
  .auth-card {
950
  padding: 2rem 1.25rem !important;
951
  min-height: 100vh;
952
  }
 
953
  .auth-image {
954
  display: none !important;
955
  }
@@ -958,6 +1305,7 @@ tr:hover td { background: var(--bg-hover); }
958
  .dashboard-ticker-strip {
959
  gap: 0.375rem !important;
960
  }
 
961
  .dashboard-ticker-strip button {
962
  min-width: 120px !important;
963
  padding: 0.5rem 0.625rem !important;
@@ -968,20 +1316,25 @@ tr:hover td { background: var(--bg-hover); }
968
  flex-direction: column !important;
969
  gap: 0.75rem !important;
970
  }
 
971
  .market-search-bar .input {
972
  width: 100% !important;
973
  }
 
974
  .market-search-bar select {
975
  width: 100% !important;
976
  }
 
977
  .market-tabs {
978
  overflow-x: auto !important;
979
  flex-wrap: nowrap !important;
980
  }
 
981
  .ticker-chips {
982
  flex-wrap: wrap !important;
983
  gap: 0.375rem !important;
984
  }
 
985
  .company-info-grid {
986
  grid-template-columns: 1fr 1fr !important;
987
  gap: 0.625rem !important;
@@ -992,6 +1345,7 @@ tr:hover td { background: var(--bg-hover); }
992
  flex-direction: column !important;
993
  gap: 0.75rem !important;
994
  }
 
995
  .factor-input-bar .input {
996
  width: 100% !important;
997
  }
@@ -1006,6 +1360,7 @@ tr:hover td { background: var(--bg-hover); }
1006
  flex-direction: column !important;
1007
  gap: 0.5rem !important;
1008
  }
 
1009
  .holdings-actions .btn {
1010
  width: 100%;
1011
  }
@@ -1025,6 +1380,7 @@ tr:hover td { background: var(--bg-hover); }
1025
  .recharts-wrapper {
1026
  font-size: 0.7rem;
1027
  }
 
1028
  .recharts-cartesian-axis-tick-value {
1029
  font-size: 0.65rem;
1030
  }
@@ -1034,9 +1390,11 @@ tr:hover td { background: var(--bg-hover); }
1034
  [style*="grid-template-columns: repeat"] {
1035
  grid-template-columns: 1fr !important;
1036
  }
 
1037
  [style*="gridTemplateColumns"] {
1038
  grid-template-columns: 1fr !important;
1039
  }
 
1040
  [style*="padding: 6rem 3rem"],
1041
  [style*="padding:'6rem 3rem'"] {
1042
  padding: 3rem 1.25rem !important;
@@ -1049,9 +1407,1207 @@ tr:hover td { background: var(--bg-hover); }
1049
  }
1050
 
1051
  /* ── Scrollbar ───────────────────────────────────────────────────────── */
1052
- ::-webkit-scrollbar { width: 6px; height: 6px; }
1053
- ::-webkit-scrollbar-track { background: var(--bg-secondary); }
1054
- ::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
1055
- ::-webkit-scrollbar-thumb:hover { background: var(--accent); }
1056
 
1057
- ::selection { background: rgba(0, 82, 65, 0.15); color: var(--text-primary); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  --gradient-accent: linear-gradient(135deg, #005241, #007d63);
37
  --gradient-dark: linear-gradient(135deg, #0c1f1a 0%, #1a3a2e 50%, #0c1f1a 100%);
38
+ --gradient-hero-overlay: linear-gradient(180deg, rgba(12, 31, 26, 0.85) 0%, rgba(12, 31, 26, 0.7) 50%, rgba(12, 31, 26, 0.9) 100%);
39
+ --gradient-card-hover: linear-gradient(145deg, rgba(0, 82, 65, 0.02), rgba(0, 82, 65, 0.06));
40
 
41
  --border-color: #e2e5ea;
42
  --border-subtle: #eef0f3;
 
45
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
46
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
47
  --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.1);
48
+ --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06);
49
  --shadow-card-hover: 0 10px 40px rgba(0, 0, 0, 0.08);
50
 
51
  --radius-sm: 4px;
 
103
  --blue-info: #3b82f6;
104
 
105
  --gradient-accent: linear-gradient(135deg, #00a87a, #00c896);
106
+ --gradient-card-hover: linear-gradient(145deg, rgba(0, 168, 122, 0.04), rgba(0, 168, 122, 0.08));
107
 
108
  --border-color: #1f2228;
109
  --border-subtle: #181b20;
 
112
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
113
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
114
  --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
115
+ --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.15);
116
  --shadow-card-hover: 0 10px 40px rgba(0, 0, 0, 0.35);
117
 
118
  --chart-axis: #6b7588;
 
132
  }
133
 
134
  /* Dark mode: badge overrides */
135
+ [data-theme="dark"] .badge-primary {
136
+ background: rgba(0, 168, 122, 0.15);
137
+ color: var(--accent);
138
+ }
139
+
140
+ [data-theme="dark"] .badge-emerald {
141
+ background: rgba(16, 185, 129, 0.15);
142
+ color: var(--green-positive);
143
+ }
144
+
145
+ [data-theme="dark"] .badge-rose {
146
+ background: rgba(239, 68, 68, 0.15);
147
+ color: var(--red-negative);
148
+ }
149
+
150
+ [data-theme="dark"] .badge-amber {
151
+ background: rgba(212, 168, 67, 0.15);
152
+ color: var(--amber-neutral);
153
+ }
154
 
155
  /* Dark mode: table header */
156
+ [data-theme="dark"] th {
157
+ background: var(--bg-tertiary);
158
+ }
159
 
160
  /* Dark mode: scrollbar */
161
+ [data-theme="dark"] ::-webkit-scrollbar-track {
162
+ background: var(--bg-secondary);
163
+ }
164
+
165
+ [data-theme="dark"] ::-webkit-scrollbar-thumb {
166
+ background: #2a2d32;
167
+ }
168
+
169
+ [data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
170
+ background: var(--accent);
171
+ }
172
 
173
  /* Dark mode: selection */
174
+ [data-theme="dark"] ::selection {
175
+ background: rgba(0, 168, 122, 0.25);
176
+ color: var(--text-primary);
177
+ }
178
 
179
  /* Dark mode: React Flow (Strategy Builder) */
180
+ [data-theme="dark"] .react-flow__controls {
181
+ background: var(--bg-tertiary);
182
+ border-color: var(--border-color);
183
+ border-radius: var(--radius-md);
184
+ }
185
+
186
+ [data-theme="dark"] .react-flow__controls-button {
187
+ background: var(--bg-card);
188
+ border-color: var(--border-color);
189
+ fill: var(--text-secondary);
190
+ }
191
+
192
+ [data-theme="dark"] .react-flow__controls-button:hover {
193
+ background: var(--bg-hover);
194
+ }
195
+
196
+ [data-theme="dark"] .react-flow__minimap {
197
+ background: var(--bg-tertiary) !important;
198
+ }
199
+
200
+ [data-theme="dark"] .react-flow__attribution {
201
+ background: transparent !important;
202
+ }
203
+
204
+ [data-theme="dark"] .react-flow__attribution a {
205
+ color: var(--text-muted) !important;
206
+ }
207
 
208
  /* ── Reset ───────────────────────────────────────────────────────────── */
209
+ *,
210
+ *::before,
211
+ *::after {
212
+ margin: 0;
213
+ padding: 0;
214
+ box-sizing: border-box;
215
+ }
216
 
217
+ html {
218
+ scroll-behavior: smooth;
219
+ -webkit-font-smoothing: antialiased;
220
+ }
221
 
222
  body {
223
  font-family: var(--font-sans);
 
227
  min-height: 100vh;
228
  }
229
 
230
+ #root {
231
+ min-height: 100vh;
232
+ display: flex;
233
+ flex-direction: column;
234
+ }
235
+
236
+ a {
237
+ color: var(--accent);
238
+ text-decoration: none;
239
+ transition: color var(--transition-fast);
240
+ }
241
 
242
+ a:hover {
243
+ color: var(--accent-light);
244
+ }
245
 
246
  /* ── Typography ──────────────────────────────────────────────────────── */
247
+ h1,
248
+ h2,
249
+ h3,
250
+ h4,
251
+ h5,
252
+ h6 {
253
  font-weight: 600;
254
  line-height: 1.25;
255
  letter-spacing: -0.02em;
256
  color: var(--text-primary);
257
  }
258
+
259
+ h1 {
260
+ font-size: clamp(2rem, 4.5vw, 3.25rem);
261
+ font-family: var(--font-serif);
262
+ font-weight: 500;
263
+ }
264
+
265
+ h2 {
266
+ font-size: clamp(1.5rem, 3vw, 2.25rem);
267
+ font-family: var(--font-serif);
268
+ font-weight: 500;
269
+ }
270
+
271
+ h3 {
272
+ font-size: 1rem;
273
+ font-family: var(--font-sans);
274
+ font-weight: 600;
275
+ }
276
 
277
  .text-gradient {
278
  background: var(--gradient-accent);
 
280
  -webkit-text-fill-color: transparent;
281
  background-clip: text;
282
  }
283
+
284
+ .text-serif {
285
+ font-family: var(--font-serif);
286
+ }
287
+
288
+ .text-accent {
289
+ color: var(--accent);
290
+ }
291
+
292
+ .mono {
293
+ font-family: var(--font-mono);
294
+ font-size: 0.875em;
295
+ }
296
 
297
  /* ── Layout ──────────────────────────────────────────────────────────── */
298
  .app-layout {
299
  display: flex;
300
  min-height: 100vh;
301
  }
302
+
303
  .app-main {
304
  flex: 1;
305
  min-width: 0;
306
  margin-left: 68px;
307
  transition: margin-left var(--transition-base);
308
  }
 
 
 
 
309
 
310
+ .page {
311
+ flex: 1;
312
+ padding: 2.5rem 3rem;
313
+ max-width: 1320px;
314
+ margin: 0 auto;
315
+ width: 100%;
316
+ }
317
+
318
+ .page-header {
319
+ margin-bottom: 2rem;
320
+ padding-bottom: 1.5rem;
321
+ border-bottom: 1px solid var(--border-color);
322
+ }
323
+
324
+ .page-header h1 {
325
+ margin-bottom: 0.25rem;
326
+ }
327
+
328
+ .page-header p {
329
+ color: var(--text-secondary);
330
+ font-size: 0.95rem;
331
+ }
332
+
333
+ .grid-2 {
334
+ display: grid;
335
+ grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
336
+ gap: 1.75rem;
337
+ }
338
+
339
+ .grid-3 {
340
+ display: grid;
341
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
342
+ gap: 1.5rem;
343
+ }
344
+
345
+ .grid-4 {
346
+ display: grid;
347
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
348
+ gap: 1.25rem;
349
+ }
350
+
351
+ .flex-between {
352
+ display: flex;
353
+ justify-content: space-between;
354
+ align-items: center;
355
+ }
356
+
357
+ .flex-gap {
358
+ display: flex;
359
+ gap: 0.75rem;
360
+ align-items: center;
361
+ }
362
 
363
  /* ── Sidebar ────────────────────────────────────────────────────────── */
364
  .sidebar {
 
375
  transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
376
  z-index: 200;
377
  }
378
+
379
  .sidebar.sidebar-expanded {
380
  width: 260px;
381
  }
 
389
  display: flex;
390
  align-items: center;
391
  }
392
+
393
  .sidebar-logo {
394
  display: flex;
395
  align-items: center;
 
398
  color: var(--text-primary);
399
  white-space: nowrap;
400
  }
401
+
402
  .sidebar-logo svg {
403
  flex-shrink: 0;
404
  }
405
+
406
  .sidebar-brand-text {
407
  font-family: var(--font-serif);
408
  font-size: 1.2rem;
 
412
  transform: translateX(-8px);
413
  transition: opacity 0.2s ease 0.05s, transform 0.2s ease 0.05s;
414
  }
415
+
416
  .sidebar.sidebar-expanded .sidebar-brand-text {
417
  opacity: 1;
418
  transform: translateX(0);
419
  }
420
+
421
  .sidebar-brand-text em {
422
  font-style: normal;
423
  color: var(--accent);
 
430
  overflow-x: hidden;
431
  padding: 0.5rem 0;
432
  }
433
+
434
+ .sidebar-nav::-webkit-scrollbar {
435
+ width: 0;
436
+ }
437
 
438
  .sidebar-section {
439
  padding: 0.5rem 0;
440
  }
441
+
442
  .sidebar-section-label {
443
  padding: 0.375rem 1.4rem;
444
  font-size: 0.6rem;
 
450
  opacity: 0;
451
  transition: opacity 0.2s ease 0.05s;
452
  }
453
+
454
  .sidebar.sidebar-expanded .sidebar-section-label {
455
  opacity: 1;
456
  }
 
475
  text-align: left;
476
  font-family: var(--font-sans);
477
  }
478
+
479
  .sidebar-link:hover {
480
  background: var(--bg-hover);
481
  color: var(--accent);
482
  }
483
+
484
  .sidebar-link.active {
485
  background: var(--accent-lighter);
486
  color: var(--accent);
 
495
  align-items: center;
496
  justify-content: center;
497
  }
498
+
499
  .sidebar-icon svg {
500
  width: 18px;
501
  height: 18px;
 
506
  transform: translateX(-8px);
507
  transition: opacity 0.2s ease 0.05s, transform 0.2s ease 0.05s;
508
  }
509
+
510
  .sidebar.sidebar-expanded .sidebar-label {
511
  opacity: 1;
512
  transform: translateX(0);
 
518
  padding: 0.75rem;
519
  flex-shrink: 0;
520
  }
521
+
522
  .sidebar-user {
523
  display: flex;
524
  align-items: center;
 
528
  white-space: nowrap;
529
  overflow: hidden;
530
  }
531
+
532
  .sidebar-avatar {
533
  width: 32px;
534
  height: 32px;
 
542
  font-weight: 700;
543
  flex-shrink: 0;
544
  }
545
+
546
  .sidebar-user-info {
547
  opacity: 0;
548
  transition: opacity 0.2s ease 0.05s;
549
  }
550
+
551
  .sidebar.sidebar-expanded .sidebar-user-info {
552
  opacity: 1;
553
  }
554
+
555
  .sidebar-user-name {
556
  font-size: 0.78rem;
557
  font-weight: 600;
558
  color: var(--text-primary);
559
  }
560
+
561
  .sidebar-user-email {
562
  font-size: 0.65rem;
563
  color: var(--text-muted);
564
  }
565
+
566
  .sidebar-logout {
567
  color: var(--text-muted) !important;
568
  }
569
+
570
  .sidebar-logout:hover {
571
  color: var(--red-negative) !important;
572
  background: rgba(194, 48, 48, 0.05) !important;
573
  }
574
+
575
  .card {
576
  background: var(--bg-card);
577
  border: 1px solid var(--border-color);
 
580
  box-shadow: var(--shadow-card);
581
  transition: all var(--transition-base);
582
  }
583
+
584
  .card:hover {
585
  box-shadow: var(--shadow-card-hover);
586
  border-color: var(--border-accent);
587
  }
588
+
589
  .card-header {
590
  display: flex;
591
  justify-content: space-between;
 
594
  padding-bottom: 0.875rem;
595
  border-bottom: 1px solid var(--border-subtle);
596
  }
597
+
598
+ .card-header h3 {
599
+ font-size: 0.95rem;
600
+ font-weight: 600;
601
+ color: var(--text-primary);
602
+ letter-spacing: 0;
603
+ }
604
 
605
  /* ── Buttons ─────────────────────────────────────────────────────────── */
606
  .btn {
 
620
  letter-spacing: 0.02em;
621
  text-transform: uppercase;
622
  }
623
+
624
+ .btn:active {
625
+ transform: scale(0.98);
626
+ }
627
 
628
  .btn-primary {
629
  background: var(--accent);
630
  color: #fff;
631
  }
632
+
633
+ .btn-primary:hover {
634
+ background: var(--accent-light);
635
+ }
636
 
637
  .btn-secondary {
638
  background: transparent;
639
  color: var(--text-primary);
640
  border: 1px solid var(--border-color);
641
  }
642
+
643
  .btn-secondary:hover {
644
  background: var(--bg-hover);
645
  border-color: var(--accent);
646
  color: var(--accent);
647
  }
648
 
649
+ .btn-sm {
650
+ padding: 0.375rem 0.875rem;
651
+ font-size: 0.75rem;
652
+ }
653
+
654
+ .btn-lg {
655
+ padding: 0.875rem 2.25rem;
656
+ font-size: 0.875rem;
657
+ letter-spacing: 0.08em;
658
+ }
659
+
660
+ .btn-icon {
661
+ width: 36px;
662
+ height: 36px;
663
+ padding: 0;
664
+ border-radius: var(--radius-sm);
665
+ }
666
 
667
  /* ── Form Elements ───────────────────────────────────────────────────── */
668
+ .form-group {
669
+ margin-bottom: 1.25rem;
670
+ }
671
+
672
  .form-group label {
673
  display: block;
674
  font-size: 0.75rem;
 
679
  letter-spacing: 0.06em;
680
  }
681
 
682
+ .input,
683
+ select,
684
+ textarea {
685
  width: 100%;
686
  padding: 0.625rem 0.875rem;
687
  background: var(--bg-primary);
 
693
  transition: all var(--transition-fast);
694
  outline: none;
695
  }
696
+
697
+ .input:focus,
698
+ select:focus,
699
+ textarea:focus {
700
  border-color: var(--accent);
701
  box-shadow: 0 0 0 3px rgba(0, 82, 65, 0.1);
702
  }
703
+
704
+ .input::placeholder {
705
+ color: var(--text-muted);
706
+ }
707
 
708
  /* ── Table ───────────────────────────────────────────────────────────── */
709
  .table-container {
 
711
  border-radius: var(--radius-md);
712
  border: 1px solid var(--border-color);
713
  }
714
+
715
+ table {
716
+ width: 100%;
717
+ border-collapse: collapse;
718
+ }
719
+
720
  th {
721
  padding: 0.75rem 1rem;
722
  text-align: left;
 
728
  background: var(--bg-secondary);
729
  border-bottom: 2px solid var(--border-color);
730
  }
731
+
732
  td {
733
  padding: 0.75rem 1rem;
734
  font-size: 0.85rem;
 
736
  font-family: var(--font-mono);
737
  font-size: 0.8rem;
738
  }
739
+
740
+ tr:hover td {
741
+ background: var(--bg-hover);
742
+ }
743
 
744
  /* ── Metrics ─────────────────────────────────────────────────────────── */
745
+ .metric {
746
+ text-align: center;
747
+ padding: 1.25rem 1rem;
748
+ }
749
+
750
  .metric-value {
751
  font-size: 1.75rem;
752
  font-weight: 700;
753
  font-family: var(--font-mono);
754
  letter-spacing: -0.03em;
755
  }
756
+
757
  .metric-label {
758
  font-size: 0.65rem;
759
  text-transform: uppercase;
 
762
  margin-top: 0.25rem;
763
  font-weight: 600;
764
  }
765
+
766
+ .positive {
767
+ color: var(--green-positive);
768
+ }
769
+
770
+ .negative {
771
+ color: var(--red-negative);
772
+ }
773
+
774
+ .neutral {
775
+ color: var(--amber-neutral);
776
+ }
777
 
778
  /* ── Badge ───────────────────────────────────────────────────────────── */
779
  .badge {
 
786
  letter-spacing: 0.06em;
787
  text-transform: uppercase;
788
  }
789
+
790
+ .badge-primary {
791
+ background: var(--accent-lighter);
792
+ color: var(--accent);
793
+ }
794
+
795
+ .badge-emerald {
796
+ background: #e6f9f0;
797
+ color: var(--green-positive);
798
+ }
799
+
800
+ .badge-rose {
801
+ background: var(--error-bg);
802
+ color: var(--red-negative);
803
+ }
804
+
805
+ .badge-amber {
806
+ background: #fffbeb;
807
+ color: var(--amber-neutral);
808
+ }
809
 
810
  /* ── Tabs ────────────────────────────────────────────────────────────── */
811
  .tabs {
 
814
  border-bottom: 2px solid var(--border-color);
815
  margin-bottom: 1.75rem;
816
  }
817
+
818
  .tab {
819
  padding: 0.75rem 1.25rem;
820
  font-size: 0.8rem;
 
829
  text-transform: uppercase;
830
  letter-spacing: 0.06em;
831
  }
832
+
833
+ .tab:hover {
834
+ color: var(--text-primary);
835
+ }
836
+
837
  .tab.active {
838
  color: var(--accent);
839
  border-bottom-color: var(--accent);
 
848
  border-radius: 50%;
849
  animation: spin 0.8s linear infinite;
850
  }
851
+
852
+ @keyframes spin {
853
+ to {
854
+ transform: rotate(360deg);
855
+ }
856
+ }
857
 
858
  .loading-overlay {
859
  display: flex;
 
867
 
868
  /* ── Animations ──────────────────────────────────────────────────────── */
869
  @keyframes fadeInUp {
870
+ from {
871
+ opacity: 0;
872
+ transform: translateY(24px);
873
+ }
874
+
875
+ to {
876
+ opacity: 1;
877
+ transform: translateY(0);
878
+ }
879
  }
880
+
881
  @keyframes fadeIn {
882
+ from {
883
+ opacity: 0;
884
+ }
885
+
886
+ to {
887
+ opacity: 1;
888
+ }
889
+ }
890
+
891
+ .animate-fade-in {
892
+ animation: fadeIn 0.6s ease-out;
893
+ }
894
+
895
+ .animate-fade-in-up {
896
+ animation: fadeInUp 0.7s ease-out;
897
  }
 
 
898
 
899
  /* ── Chart ───────────────────────────────────────────────────────────── */
900
  .chart-container {
 
914
  text-align: center;
915
  color: var(--text-muted);
916
  }
917
+
918
+ .empty-state h3 {
919
+ color: var(--text-secondary);
920
+ margin-bottom: 0.5rem;
921
+ }
922
+
923
+ .empty-state p {
924
+ max-width: 400px;
925
+ font-size: 0.9rem;
926
+ line-height: 1.7;
927
+ }
928
 
929
  /* ── Divider ─────────────────────────────────────────────────────────── */
930
+ .divider {
931
+ width: 60px;
932
+ height: 2px;
933
+ background: var(--accent);
934
+ margin: 1rem 0;
935
+ }
936
 
937
  /* ── Professional Icon Container ─────────────────────────────────────── */
938
  .icon-box {
 
946
  color: var(--accent);
947
  flex-shrink: 0;
948
  }
949
+
950
+ .icon-box svg {
951
+ width: 22px;
952
+ height: 22px;
953
+ }
954
 
955
  /* ═══════════════════════════════════════════════════════════════════════
956
  MOBILE RESPONSIVE β€” max-width: 768px
 
962
  /* ── App Shell ─────────────────────────────────────────────────────── */
963
  .app-main {
964
  margin-left: 0 !important;
965
+ padding-bottom: 72px;
966
+ /* space for bottom tab bar */
967
  }
968
 
969
  /* ── Desktop Sidebar hidden on mobile ──────────────────────────────── */
 
985
  padding: 0 0.25rem;
986
  box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
987
  }
988
+
989
  .mobile-nav-items {
990
  display: flex;
991
  justify-content: space-around;
 
993
  width: 100%;
994
  height: 100%;
995
  }
996
+
997
  .mobile-nav-item {
998
  display: flex;
999
  flex-direction: column;
 
1013
  cursor: pointer;
1014
  font-family: var(--font-sans);
1015
  }
1016
+
1017
  .mobile-nav-item svg {
1018
  width: 20px;
1019
  height: 20px;
1020
  }
1021
+
1022
  .mobile-nav-item.active,
1023
  .mobile-nav-item:hover {
1024
  color: var(--accent);
 
1032
  z-index: 400;
1033
  animation: fadeIn 0.2s ease;
1034
  }
1035
+
1036
  .mobile-drawer {
1037
  position: fixed;
1038
  bottom: 0;
 
1046
  overflow-y: auto;
1047
  animation: slideUp 0.25s ease;
1048
  }
1049
+
1050
  @keyframes slideUp {
1051
+ from {
1052
+ transform: translateY(100%);
1053
+ }
1054
+
1055
+ to {
1056
+ transform: translateY(0);
1057
+ }
1058
  }
1059
+
1060
  .mobile-drawer-handle {
1061
  width: 36px;
1062
  height: 4px;
 
1064
  border-radius: 2px;
1065
  margin: 0 auto 1rem;
1066
  }
1067
+
1068
  .mobile-drawer .sidebar-link {
1069
  width: 100%;
1070
  padding: 0.75rem 1rem;
 
1073
  border-radius: var(--radius-md);
1074
  opacity: 1;
1075
  }
1076
+
1077
  .mobile-drawer .sidebar-link .sidebar-label {
1078
  opacity: 1;
1079
  transform: none;
1080
  }
1081
+
1082
  .mobile-drawer .sidebar-link .sidebar-icon {
1083
  width: 22px;
1084
  height: 22px;
1085
  }
1086
+
1087
  .mobile-drawer .sidebar-section-label {
1088
  opacity: 1;
1089
  padding: 0.5rem 1rem 0.25rem;
 
1095
  padding: 1.25rem 1rem !important;
1096
  max-width: 100%;
1097
  }
1098
+
1099
  .page-header {
1100
  margin-bottom: 1.25rem;
1101
  padding-bottom: 1rem;
1102
  }
1103
+
1104
  .page-header h1 {
1105
  font-size: 1.5rem !important;
1106
  }
1107
+
1108
  .page-header p {
1109
  font-size: 0.85rem;
1110
  }
 
1122
  padding: 1.125rem !important;
1123
  border-radius: var(--radius-md);
1124
  }
1125
+
1126
  .card-header {
1127
  margin-bottom: 1rem;
1128
  padding-bottom: 0.75rem;
1129
  flex-wrap: wrap;
1130
  gap: 0.5rem;
1131
  }
1132
+
1133
  .card-header h3 {
1134
  font-size: 0.88rem;
1135
  }
 
1142
  border-right: none;
1143
  -webkit-overflow-scrolling: touch;
1144
  }
1145
+
1146
+ th,
1147
+ td {
1148
  padding: 0.5rem 0.625rem;
1149
  font-size: 0.72rem;
1150
  white-space: nowrap;
 
1157
  }
1158
 
1159
  /* ── Metrics ───────────────────────────────────────────────────────── */
1160
+ .metric {
1161
+ padding: 0.875rem 0.75rem;
1162
+ }
1163
+
1164
+ .metric-value {
1165
+ font-size: 1.375rem;
1166
+ }
1167
+
1168
+ .metric-label {
1169
+ font-size: 0.6rem;
1170
+ }
1171
 
1172
  /* ── Tabs ───────────────────────────────────────────────────────────── */
1173
  .tabs {
 
1178
  margin-bottom: 1.25rem;
1179
  scrollbar-width: none;
1180
  }
1181
+
1182
+ .tabs::-webkit-scrollbar {
1183
+ display: none;
1184
+ }
1185
+
1186
  .tab {
1187
  padding: 0.625rem 0.875rem;
1188
  font-size: 0.7rem;
 
1196
  }
1197
 
1198
  /* ── Forms ─────────────────────────────────────────────────────────── */
1199
+ .input,
1200
+ select,
1201
+ textarea {
1202
+ font-size: 16px;
1203
+ /* prevents iOS zoom on focus */
1204
  }
1205
+
1206
  .form-group label {
1207
  font-size: 0.7rem;
1208
  }
 
1234
  width: 40px;
1235
  height: 40px;
1236
  }
1237
+
1238
+ .icon-box svg {
1239
+ width: 18px;
1240
+ height: 18px;
1241
+ }
1242
 
1243
  /* ═════════════════════════════════════════════════════════════════════
1244
  PAGE-SPECIFIC MOBILE OVERRIDES
 
1248
  .landing-header {
1249
  padding: 0.875rem 1rem !important;
1250
  }
1251
+
1252
  .landing-header .brand-text {
1253
  font-size: 1.1rem;
1254
  }
1255
+
1256
  .landing-hero {
1257
  min-height: 80vh !important;
1258
  padding: 1rem !important;
1259
  }
1260
+
1261
  .landing-hero h1 {
1262
  font-size: clamp(1.75rem, 7vw, 2.5rem) !important;
1263
  line-height: 1.2 !important;
1264
  }
1265
+
1266
  .landing-hero p {
1267
  font-size: 0.9rem !important;
1268
  }
1269
+
1270
  .landing-hero .flex-gap {
1271
  flex-direction: column;
1272
  width: 100%;
1273
  }
1274
+
1275
  .landing-hero .flex-gap .btn {
1276
  width: 100%;
1277
  }
1278
+
1279
  .landing-section {
1280
  padding: 3rem 1.25rem !important;
1281
  }
1282
+
1283
  .landing-footer {
1284
  flex-direction: column !important;
1285
  gap: 0.5rem;
 
1291
  .auth-container {
1292
  flex-direction: column !important;
1293
  }
1294
+
1295
  .auth-card {
1296
  padding: 2rem 1.25rem !important;
1297
  min-height: 100vh;
1298
  }
1299
+
1300
  .auth-image {
1301
  display: none !important;
1302
  }
 
1305
  .dashboard-ticker-strip {
1306
  gap: 0.375rem !important;
1307
  }
1308
+
1309
  .dashboard-ticker-strip button {
1310
  min-width: 120px !important;
1311
  padding: 0.5rem 0.625rem !important;
 
1316
  flex-direction: column !important;
1317
  gap: 0.75rem !important;
1318
  }
1319
+
1320
  .market-search-bar .input {
1321
  width: 100% !important;
1322
  }
1323
+
1324
  .market-search-bar select {
1325
  width: 100% !important;
1326
  }
1327
+
1328
  .market-tabs {
1329
  overflow-x: auto !important;
1330
  flex-wrap: nowrap !important;
1331
  }
1332
+
1333
  .ticker-chips {
1334
  flex-wrap: wrap !important;
1335
  gap: 0.375rem !important;
1336
  }
1337
+
1338
  .company-info-grid {
1339
  grid-template-columns: 1fr 1fr !important;
1340
  gap: 0.625rem !important;
 
1345
  flex-direction: column !important;
1346
  gap: 0.75rem !important;
1347
  }
1348
+
1349
  .factor-input-bar .input {
1350
  width: 100% !important;
1351
  }
 
1360
  flex-direction: column !important;
1361
  gap: 0.5rem !important;
1362
  }
1363
+
1364
  .holdings-actions .btn {
1365
  width: 100%;
1366
  }
 
1380
  .recharts-wrapper {
1381
  font-size: 0.7rem;
1382
  }
1383
+
1384
  .recharts-cartesian-axis-tick-value {
1385
  font-size: 0.65rem;
1386
  }
 
1390
  [style*="grid-template-columns: repeat"] {
1391
  grid-template-columns: 1fr !important;
1392
  }
1393
+
1394
  [style*="gridTemplateColumns"] {
1395
  grid-template-columns: 1fr !important;
1396
  }
1397
+
1398
  [style*="padding: 6rem 3rem"],
1399
  [style*="padding:'6rem 3rem'"] {
1400
  padding: 3rem 1.25rem !important;
 
1407
  }
1408
 
1409
  /* ── Scrollbar ───────────────────────────────────────────────────────── */
1410
+ ::-webkit-scrollbar {
1411
+ width: 6px;
1412
+ height: 6px;
1413
+ }
1414
 
1415
+ ::-webkit-scrollbar-track {
1416
+ background: var(--bg-secondary);
1417
+ }
1418
+
1419
+ ::-webkit-scrollbar-thumb {
1420
+ background: var(--border-color);
1421
+ border-radius: 3px;
1422
+ }
1423
+
1424
+ ::-webkit-scrollbar-thumb:hover {
1425
+ background: var(--accent);
1426
+ }
1427
+
1428
+ ::selection {
1429
+ background: rgba(0, 82, 65, 0.15);
1430
+ color: var(--text-primary);
1431
+ }
1432
+
1433
+ /* ═══════════════════════════════════════════════════════════════════════
1434
+ Shared Base Styles β€” page-container, loading-spinner, data-table
1435
+ ═══════════════════════════════════════════════════════════════════════ */
1436
+ .page-container {
1437
+ flex: 1;
1438
+ padding: 2.5rem 3rem;
1439
+ max-width: 1320px;
1440
+ margin: 0 auto;
1441
+ width: 100%;
1442
+ }
1443
+
1444
+ .page-subtitle {
1445
+ color: var(--text-secondary);
1446
+ font-size: 0.95rem;
1447
+ margin-top: 0.25rem;
1448
+ }
1449
+
1450
+ .loading-spinner {
1451
+ width: 32px;
1452
+ height: 32px;
1453
+ border: 3px solid var(--border-color);
1454
+ border-top-color: var(--accent);
1455
+ border-radius: 50%;
1456
+ animation: spin 0.8s linear infinite;
1457
+ margin: 3rem auto;
1458
+ }
1459
+
1460
+ .alert-card {
1461
+ padding: 1.5rem;
1462
+ border-radius: var(--radius-lg);
1463
+ background: var(--bg-secondary);
1464
+ color: var(--text-secondary);
1465
+ text-align: center;
1466
+ border: 1px solid var(--border-color);
1467
+ }
1468
+
1469
+ .alert-card.error {
1470
+ background: var(--error-bg);
1471
+ border-color: var(--error-border);
1472
+ color: var(--red-negative);
1473
+ }
1474
+
1475
+ .data-table {
1476
+ width: 100%;
1477
+ border-collapse: collapse;
1478
+ }
1479
+
1480
+ .data-table th {
1481
+ padding: 0.625rem 1rem;
1482
+ text-align: left;
1483
+ font-size: 0.7rem;
1484
+ font-weight: 700;
1485
+ color: var(--text-muted);
1486
+ text-transform: uppercase;
1487
+ letter-spacing: 0.08em;
1488
+ background: var(--bg-secondary);
1489
+ border-bottom: 2px solid var(--border-color);
1490
+ }
1491
+
1492
+ .data-table td {
1493
+ padding: 0.625rem 1rem;
1494
+ font-size: 0.82rem;
1495
+ border-bottom: 1px solid var(--border-subtle);
1496
+ font-family: var(--font-mono);
1497
+ font-size: 0.78rem;
1498
+ }
1499
+
1500
+ .data-table tr:hover td {
1501
+ background: var(--bg-hover);
1502
+ }
1503
+
1504
+ /* ═══════════════════════════════════════════════════════════════════════
1505
+ Portfolio Health Score Page
1506
+ ═══════════════════════════════════════════════════════════════════════ */
1507
+ .health-top-row {
1508
+ display: grid;
1509
+ grid-template-columns: 260px 1fr;
1510
+ gap: 1.5rem;
1511
+ margin-top: 1.5rem;
1512
+ }
1513
+
1514
+ .health-gauge-card {
1515
+ display: flex;
1516
+ flex-direction: column;
1517
+ align-items: center;
1518
+ justify-content: center;
1519
+ }
1520
+
1521
+ .health-gauge {
1522
+ position: relative;
1523
+ width: 160px;
1524
+ height: 160px;
1525
+ }
1526
+
1527
+ .gauge-svg {
1528
+ width: 100%;
1529
+ height: 100%;
1530
+ transform: rotate(-90deg);
1531
+ }
1532
+
1533
+ .gauge-center {
1534
+ position: absolute;
1535
+ inset: 0;
1536
+ display: flex;
1537
+ flex-direction: column;
1538
+ align-items: center;
1539
+ justify-content: center;
1540
+ }
1541
+
1542
+ .gauge-score {
1543
+ font-size: 2.5rem;
1544
+ font-weight: 700;
1545
+ font-family: var(--font-mono);
1546
+ line-height: 1;
1547
+ }
1548
+
1549
+ .gauge-grade {
1550
+ font-size: 1.25rem;
1551
+ font-weight: 700;
1552
+ margin-top: 0.25rem;
1553
+ }
1554
+
1555
+ .gauge-meta {
1556
+ display: flex;
1557
+ align-items: center;
1558
+ gap: 0;
1559
+ margin-top: 1.25rem;
1560
+ }
1561
+
1562
+ .gauge-meta-item {
1563
+ display: flex;
1564
+ flex-direction: column;
1565
+ align-items: center;
1566
+ padding: 0 1rem;
1567
+ }
1568
+
1569
+ .gauge-meta-value {
1570
+ font-family: var(--font-mono);
1571
+ font-weight: 700;
1572
+ font-size: 0.88rem;
1573
+ }
1574
+
1575
+ .gauge-meta-label {
1576
+ font-size: 0.65rem;
1577
+ text-transform: uppercase;
1578
+ letter-spacing: 0.08em;
1579
+ color: var(--text-muted);
1580
+ margin-top: 0.125rem;
1581
+ }
1582
+
1583
+ .gauge-meta-divider {
1584
+ width: 1px;
1585
+ height: 28px;
1586
+ background: var(--border-color);
1587
+ }
1588
+
1589
+ .health-chart-card {
1590
+ overflow: hidden;
1591
+ }
1592
+
1593
+ .health-bar-chart {
1594
+ display: flex;
1595
+ flex-direction: column;
1596
+ gap: 0.625rem;
1597
+ }
1598
+
1599
+ .hbar-row {
1600
+ display: flex;
1601
+ flex-direction: column;
1602
+ gap: 0.2rem;
1603
+ }
1604
+
1605
+ .hbar-label {
1606
+ display: flex;
1607
+ align-items: center;
1608
+ gap: 0.5rem;
1609
+ }
1610
+
1611
+ .hbar-name {
1612
+ font-size: 0.78rem;
1613
+ font-weight: 600;
1614
+ color: var(--text-primary);
1615
+ }
1616
+
1617
+ .hbar-grade-pill {
1618
+ font-size: 0.6rem;
1619
+ font-weight: 700;
1620
+ padding: 0.1rem 0.4rem;
1621
+ border-radius: 3px;
1622
+ }
1623
+
1624
+ .hbar-track-container {
1625
+ display: flex;
1626
+ align-items: center;
1627
+ gap: 0.75rem;
1628
+ }
1629
+
1630
+ .hbar-track {
1631
+ flex: 1;
1632
+ height: 8px;
1633
+ background: var(--bg-tertiary);
1634
+ border-radius: 4px;
1635
+ overflow: hidden;
1636
+ }
1637
+
1638
+ .hbar-fill {
1639
+ height: 100%;
1640
+ border-radius: 4px;
1641
+ transition: width 0.8s ease;
1642
+ }
1643
+
1644
+ .hbar-value {
1645
+ font-family: var(--font-mono);
1646
+ font-size: 0.75rem;
1647
+ color: var(--text-muted);
1648
+ min-width: 24px;
1649
+ text-align: right;
1650
+ }
1651
+
1652
+ .health-bottom-row {
1653
+ display: grid;
1654
+ grid-template-columns: 1fr 1.5fr;
1655
+ gap: 1.5rem;
1656
+ margin-top: 1.5rem;
1657
+ }
1658
+
1659
+ .health-radar-card {
1660
+ display: flex;
1661
+ flex-direction: column;
1662
+ }
1663
+
1664
+ .radar-wrapper {
1665
+ width: 100%;
1666
+ max-width: 300px;
1667
+ margin: 0 auto;
1668
+ }
1669
+
1670
+ .radar-svg {
1671
+ width: 100%;
1672
+ }
1673
+
1674
+ .health-detail-card {
1675
+ overflow-x: auto;
1676
+ }
1677
+
1678
+ .health-tips-section {
1679
+ margin-top: 1.5rem;
1680
+ }
1681
+
1682
+ .health-tips-grid {
1683
+ display: grid;
1684
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
1685
+ gap: 0.75rem;
1686
+ }
1687
+
1688
+ .health-tip-item {
1689
+ display: flex;
1690
+ align-items: flex-start;
1691
+ gap: 0.625rem;
1692
+ padding: 0.75rem 1rem;
1693
+ background: var(--bg-secondary);
1694
+ border-radius: var(--radius-md);
1695
+ border-left: 3px solid var(--accent);
1696
+ font-size: 0.85rem;
1697
+ line-height: 1.6;
1698
+ }
1699
+
1700
+ .health-tip-icon {
1701
+ color: var(--accent);
1702
+ flex-shrink: 0;
1703
+ margin-top: 0.125rem;
1704
+ }
1705
+
1706
+ /* ═══════════════════════════════════════════════════════════════════════
1707
+ Behavioral Bias Detector Page
1708
+ ═══════════════════════════════════════════════════════════════════════ */
1709
+ .bias-summary-row {
1710
+ display: grid;
1711
+ grid-template-columns: 1fr auto auto auto;
1712
+ gap: 1rem;
1713
+ margin: 1.5rem 0;
1714
+ }
1715
+
1716
+ .bias-score-card {
1717
+ display: flex;
1718
+ flex-direction: column;
1719
+ gap: 0.75rem;
1720
+ }
1721
+
1722
+ .bias-score-header {
1723
+ display: flex;
1724
+ justify-content: space-between;
1725
+ align-items: baseline;
1726
+ }
1727
+
1728
+ .bias-score-label {
1729
+ font-size: 0.75rem;
1730
+ text-transform: uppercase;
1731
+ letter-spacing: 0.08em;
1732
+ color: var(--text-muted);
1733
+ font-weight: 600;
1734
+ }
1735
+
1736
+ .bias-score-value {
1737
+ font-size: 2rem;
1738
+ font-weight: 700;
1739
+ font-family: var(--font-mono);
1740
+ }
1741
+
1742
+ .bias-score-bar-track {
1743
+ height: 6px;
1744
+ background: var(--bg-tertiary);
1745
+ border-radius: 3px;
1746
+ overflow: hidden;
1747
+ }
1748
+
1749
+ .bias-score-bar-fill {
1750
+ height: 100%;
1751
+ border-radius: 3px;
1752
+ transition: width 0.8s ease;
1753
+ }
1754
+
1755
+ .bias-score-grade {
1756
+ font-size: 0.78rem;
1757
+ font-weight: 600;
1758
+ margin-top: 0.25rem;
1759
+ }
1760
+
1761
+ .bias-stat-card {
1762
+ display: flex;
1763
+ flex-direction: column;
1764
+ align-items: center;
1765
+ justify-content: center;
1766
+ gap: 0.375rem;
1767
+ min-width: 90px;
1768
+ }
1769
+
1770
+ .bias-stat-value {
1771
+ font-family: var(--font-mono);
1772
+ font-size: 1.5rem;
1773
+ font-weight: 700;
1774
+ }
1775
+
1776
+ .bias-stat-label {
1777
+ font-size: 0.65rem;
1778
+ text-transform: uppercase;
1779
+ letter-spacing: 0.08em;
1780
+ color: var(--text-muted);
1781
+ font-weight: 600;
1782
+ }
1783
+
1784
+ .bias-stacked-chart {
1785
+ margin-bottom: 1.5rem;
1786
+ }
1787
+
1788
+ .bias-chart-bars {
1789
+ display: flex;
1790
+ flex-direction: column;
1791
+ gap: 0.5rem;
1792
+ }
1793
+
1794
+ .bias-chart-row {
1795
+ display: flex;
1796
+ align-items: center;
1797
+ gap: 0.75rem;
1798
+ }
1799
+
1800
+ .bias-chart-name {
1801
+ width: 140px;
1802
+ font-size: 0.78rem;
1803
+ font-weight: 600;
1804
+ flex-shrink: 0;
1805
+ text-align: right;
1806
+ }
1807
+
1808
+ .bias-chart-track {
1809
+ flex: 1;
1810
+ height: 10px;
1811
+ background: var(--bg-tertiary);
1812
+ border-radius: 5px;
1813
+ overflow: hidden;
1814
+ }
1815
+
1816
+ .bias-chart-fill {
1817
+ height: 100%;
1818
+ border-radius: 5px;
1819
+ transition: width 0.8s ease;
1820
+ }
1821
+
1822
+ .bias-chart-val {
1823
+ font-family: var(--font-mono);
1824
+ font-size: 0.75rem;
1825
+ font-weight: 600;
1826
+ min-width: 28px;
1827
+ }
1828
+
1829
+ .bias-grid {
1830
+ display: grid;
1831
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
1832
+ gap: 1.25rem;
1833
+ }
1834
+
1835
+ .bias-detail-card {
1836
+ overflow: hidden;
1837
+ }
1838
+
1839
+ .bias-detail-top {
1840
+ display: flex;
1841
+ justify-content: space-between;
1842
+ align-items: flex-start;
1843
+ gap: 1rem;
1844
+ margin-bottom: 1rem;
1845
+ }
1846
+
1847
+ .bias-detail-top h3 {
1848
+ font-size: 0.92rem;
1849
+ margin-bottom: 0.25rem;
1850
+ }
1851
+
1852
+ .bias-detail-desc {
1853
+ font-size: 0.75rem;
1854
+ color: var(--text-muted);
1855
+ line-height: 1.5;
1856
+ }
1857
+
1858
+ .bias-badge-group {
1859
+ flex-shrink: 0;
1860
+ }
1861
+
1862
+ .bias-severity-badge {
1863
+ display: inline-flex;
1864
+ align-items: center;
1865
+ gap: 0.375rem;
1866
+ padding: 0.25rem 0.625rem;
1867
+ border-radius: 3px;
1868
+ font-size: 0.62rem;
1869
+ font-weight: 700;
1870
+ letter-spacing: 0.08em;
1871
+ }
1872
+
1873
+ .bias-detail-meter {
1874
+ margin-bottom: 0.75rem;
1875
+ }
1876
+
1877
+ .bias-detail-meter-track {
1878
+ height: 5px;
1879
+ background: var(--bg-tertiary);
1880
+ border-radius: 3px;
1881
+ overflow: hidden;
1882
+ }
1883
+
1884
+ .bias-detail-meter-fill {
1885
+ height: 100%;
1886
+ border-radius: 3px;
1887
+ transition: width 0.8s ease;
1888
+ }
1889
+
1890
+ .bias-detail-meta {
1891
+ display: flex;
1892
+ justify-content: space-between;
1893
+ margin-top: 0.375rem;
1894
+ font-size: 0.72rem;
1895
+ font-family: var(--font-mono);
1896
+ color: var(--text-muted);
1897
+ }
1898
+
1899
+ .bias-detail-text {
1900
+ font-size: 0.78rem;
1901
+ color: var(--text-secondary);
1902
+ line-height: 1.6;
1903
+ }
1904
+
1905
+ .bias-coaching-section {
1906
+ margin-top: 1.5rem;
1907
+ }
1908
+
1909
+ .bias-coaching-list {
1910
+ display: flex;
1911
+ flex-direction: column;
1912
+ gap: 0.625rem;
1913
+ }
1914
+
1915
+ .bias-coaching-item {
1916
+ display: flex;
1917
+ align-items: flex-start;
1918
+ gap: 0.75rem;
1919
+ padding: 0.75rem 1rem;
1920
+ background: var(--bg-secondary);
1921
+ border-radius: var(--radius-md);
1922
+ font-size: 0.85rem;
1923
+ line-height: 1.6;
1924
+ }
1925
+
1926
+ .bias-coaching-num {
1927
+ width: 24px;
1928
+ height: 24px;
1929
+ border-radius: 50%;
1930
+ background: var(--accent);
1931
+ color: white;
1932
+ display: flex;
1933
+ align-items: center;
1934
+ justify-content: center;
1935
+ font-size: 0.7rem;
1936
+ font-weight: 700;
1937
+ flex-shrink: 0;
1938
+ margin-top: 0.125rem;
1939
+ }
1940
+
1941
+ /* ═══════════════════════════════════════════════════════════════════════
1942
+ Crisis Replay Page
1943
+ ═══════════════════════════════════════════════════════════════════════ */
1944
+ .crisis-grid {
1945
+ display: grid;
1946
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
1947
+ gap: 1rem;
1948
+ margin: 1.5rem 0;
1949
+ }
1950
+
1951
+ .crisis-card {
1952
+ cursor: pointer;
1953
+ text-align: left;
1954
+ transition: all var(--transition-fast);
1955
+ border: 1px solid var(--border-color);
1956
+ background: var(--bg-card);
1957
+ }
1958
+
1959
+ .crisis-card:hover {
1960
+ border-color: var(--accent);
1961
+ transform: translateY(-2px);
1962
+ box-shadow: var(--shadow-card-hover);
1963
+ }
1964
+
1965
+ .crisis-card.active {
1966
+ border-color: var(--accent);
1967
+ background: var(--accent-lighter);
1968
+ }
1969
+
1970
+ .crisis-card-top {
1971
+ display: flex;
1972
+ justify-content: space-between;
1973
+ align-items: flex-start;
1974
+ margin-bottom: 0.75rem;
1975
+ }
1976
+
1977
+ .crisis-card-top h3 {
1978
+ font-size: 0.85rem;
1979
+ }
1980
+
1981
+ .crisis-card-metrics {
1982
+ display: flex;
1983
+ flex-direction: column;
1984
+ gap: 0.375rem;
1985
+ }
1986
+
1987
+ .crisis-card-metrics>div {
1988
+ display: flex;
1989
+ justify-content: space-between;
1990
+ align-items: center;
1991
+ }
1992
+
1993
+ .crisis-metric-label {
1994
+ font-size: 0.65rem;
1995
+ color: var(--text-muted);
1996
+ text-transform: uppercase;
1997
+ letter-spacing: 0.06em;
1998
+ font-weight: 600;
1999
+ }
2000
+
2001
+ .crisis-metric-value {
2002
+ font-family: var(--font-mono);
2003
+ font-size: 0.82rem;
2004
+ font-weight: 600;
2005
+ }
2006
+
2007
+ .crisis-detail-section {
2008
+ display: flex;
2009
+ flex-direction: column;
2010
+ gap: 1.5rem;
2011
+ }
2012
+
2013
+ .crisis-detail-header {
2014
+ overflow: hidden;
2015
+ }
2016
+
2017
+ .crisis-detail-title h2 {
2018
+ font-size: 1.4rem;
2019
+ margin-bottom: 0.375rem;
2020
+ }
2021
+
2022
+ .crisis-detail-title p {
2023
+ color: var(--text-secondary);
2024
+ font-size: 0.88rem;
2025
+ margin-bottom: 1.25rem;
2026
+ }
2027
+
2028
+ .crisis-kpi-row {
2029
+ display: flex;
2030
+ gap: 0;
2031
+ border-top: 1px solid var(--border-subtle);
2032
+ }
2033
+
2034
+ .crisis-kpi {
2035
+ flex: 1;
2036
+ display: flex;
2037
+ flex-direction: column;
2038
+ gap: 0.25rem;
2039
+ padding: 1rem 1.25rem;
2040
+ border-right: 1px solid var(--border-subtle);
2041
+ }
2042
+
2043
+ .crisis-kpi:last-child {
2044
+ border-right: none;
2045
+ }
2046
+
2047
+ .crisis-kpi-label {
2048
+ font-size: 0.65rem;
2049
+ text-transform: uppercase;
2050
+ letter-spacing: 0.08em;
2051
+ color: var(--text-muted);
2052
+ font-weight: 600;
2053
+ }
2054
+
2055
+ .crisis-kpi-value {
2056
+ font-family: var(--font-mono);
2057
+ font-weight: 700;
2058
+ font-size: 0.95rem;
2059
+ }
2060
+
2061
+ .crisis-chart-container {
2062
+ width: 100%;
2063
+ }
2064
+
2065
+ .crisis-equity-svg {
2066
+ width: 100%;
2067
+ height: auto;
2068
+ display: block;
2069
+ }
2070
+
2071
+ /* ═══════════════════════════════════════════════════════════════════════
2072
+ Portfolio DNA Fingerprint Page
2073
+ ═══════════════════════════════════════════════════════════════════════ */
2074
+ .dna-style-header {
2075
+ display: flex;
2076
+ align-items: center;
2077
+ gap: 1rem;
2078
+ margin: 1rem 0;
2079
+ flex-wrap: wrap;
2080
+ }
2081
+
2082
+ .dna-style-chip,
2083
+ .dna-match-chip {
2084
+ display: inline-flex;
2085
+ align-items: center;
2086
+ gap: 0.5rem;
2087
+ padding: 0.5rem 1rem;
2088
+ background: var(--accent-lighter);
2089
+ border-radius: var(--radius-md);
2090
+ font-size: 0.82rem;
2091
+ color: var(--text-secondary);
2092
+ }
2093
+
2094
+ .dna-style-chip strong,
2095
+ .dna-match-chip strong {
2096
+ color: var(--accent);
2097
+ }
2098
+
2099
+ .dna-layout {
2100
+ display: grid;
2101
+ grid-template-columns: 1fr 1fr;
2102
+ gap: 1.5rem;
2103
+ }
2104
+
2105
+ .dna-radar-card {
2106
+ display: flex;
2107
+ flex-direction: column;
2108
+ }
2109
+
2110
+ .dna-radar-wrapper {
2111
+ width: 100%;
2112
+ max-width: 320px;
2113
+ margin: 0 auto;
2114
+ }
2115
+
2116
+ .dna-radar-svg {
2117
+ width: 100%;
2118
+ }
2119
+
2120
+ .dna-legend {
2121
+ display: flex;
2122
+ gap: 1rem;
2123
+ font-size: 0.72rem;
2124
+ color: var(--text-secondary);
2125
+ }
2126
+
2127
+ .dna-legend-item {
2128
+ display: flex;
2129
+ align-items: center;
2130
+ gap: 0.375rem;
2131
+ }
2132
+
2133
+ .dna-legend-dot {
2134
+ width: 8px;
2135
+ height: 8px;
2136
+ border-radius: 50%;
2137
+ }
2138
+
2139
+ .dna-dims-card h3 {
2140
+ margin-bottom: 1.25rem;
2141
+ }
2142
+
2143
+ .dna-dim-row {
2144
+ display: flex;
2145
+ align-items: center;
2146
+ gap: 0.75rem;
2147
+ margin-bottom: 0.875rem;
2148
+ }
2149
+
2150
+ .dna-dim-label {
2151
+ width: 110px;
2152
+ font-size: 0.78rem;
2153
+ font-weight: 500;
2154
+ flex-shrink: 0;
2155
+ }
2156
+
2157
+ .dna-dim-bar-track {
2158
+ flex: 1;
2159
+ height: 8px;
2160
+ background: var(--bg-tertiary);
2161
+ border-radius: 4px;
2162
+ overflow: hidden;
2163
+ }
2164
+
2165
+ .dna-dim-bar {
2166
+ height: 100%;
2167
+ background: var(--accent);
2168
+ border-radius: 4px;
2169
+ transition: width 0.8s ease;
2170
+ }
2171
+
2172
+ .dna-dim-value {
2173
+ font-family: var(--font-mono);
2174
+ font-size: 0.75rem;
2175
+ color: var(--text-secondary);
2176
+ min-width: 28px;
2177
+ text-align: right;
2178
+ }
2179
+
2180
+ .dna-comparison-chart-card {
2181
+ grid-column: span 2;
2182
+ }
2183
+
2184
+ .dna-comparison-chart-wrapper {
2185
+ width: 100%;
2186
+ overflow-x: auto;
2187
+ }
2188
+
2189
+ .dna-comparison-svg {
2190
+ width: 100%;
2191
+ height: auto;
2192
+ display: block;
2193
+ }
2194
+
2195
+ .dna-compare-card {
2196
+ grid-column: span 2;
2197
+ }
2198
+
2199
+ .dna-compare-card h3 {
2200
+ margin-bottom: 1rem;
2201
+ }
2202
+
2203
+ .dna-comparison-list {
2204
+ display: grid;
2205
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
2206
+ gap: 0.875rem;
2207
+ }
2208
+
2209
+ .dna-comparison-item {
2210
+ display: flex;
2211
+ justify-content: space-between;
2212
+ align-items: center;
2213
+ padding: 1rem 1.25rem;
2214
+ background: var(--bg-secondary);
2215
+ border: 1px solid var(--border-subtle);
2216
+ border-radius: var(--radius-lg);
2217
+ cursor: pointer;
2218
+ transition: all var(--transition-fast);
2219
+ text-align: left;
2220
+ width: 100%;
2221
+ font-family: var(--font-sans);
2222
+ }
2223
+
2224
+ .dna-comparison-item:hover {
2225
+ border-color: var(--accent);
2226
+ background: var(--accent-lighter);
2227
+ }
2228
+
2229
+ .dna-comparison-item.active {
2230
+ border-color: var(--accent);
2231
+ background: var(--accent-lighter);
2232
+ box-shadow: 0 0 0 2px var(--accent);
2233
+ }
2234
+
2235
+ .dna-fp-info strong {
2236
+ display: block;
2237
+ font-size: 0.85rem;
2238
+ margin-bottom: 0.125rem;
2239
+ color: var(--text-primary);
2240
+ }
2241
+
2242
+ .dna-fp-style {
2243
+ display: block;
2244
+ font-size: 0.68rem;
2245
+ color: var(--accent);
2246
+ font-weight: 600;
2247
+ text-transform: uppercase;
2248
+ letter-spacing: 0.04em;
2249
+ }
2250
+
2251
+ .dna-fp-desc {
2252
+ display: block;
2253
+ font-size: 0.72rem;
2254
+ color: var(--text-muted);
2255
+ margin-top: 0.25rem;
2256
+ }
2257
+
2258
+ .dna-similarity {
2259
+ display: flex;
2260
+ flex-direction: column;
2261
+ align-items: center;
2262
+ flex-shrink: 0;
2263
+ margin-left: 1rem;
2264
+ }
2265
+
2266
+ .dna-sim-pct {
2267
+ font-family: var(--font-mono);
2268
+ font-size: 1.25rem;
2269
+ font-weight: 700;
2270
+ color: var(--accent);
2271
+ }
2272
+
2273
+ .dna-sim-label {
2274
+ font-size: 0.6rem;
2275
+ text-transform: uppercase;
2276
+ letter-spacing: 0.1em;
2277
+ color: var(--text-muted);
2278
+ }
2279
+
2280
+ /* ═══════════════════════════════════════════════════════════════════════
2281
+ HedgeAIChat Page
2282
+ ═══════════════════════════════════════════════════════════════════════ */
2283
+ .copilot-page {
2284
+ display: flex;
2285
+ flex-direction: column;
2286
+ height: calc(100vh - 2rem);
2287
+ }
2288
+
2289
+ .copilot-header-row {
2290
+ display: flex;
2291
+ justify-content: space-between;
2292
+ align-items: flex-start;
2293
+ }
2294
+
2295
+ .copilot-status {
2296
+ display: flex;
2297
+ align-items: center;
2298
+ gap: 0.5rem;
2299
+ font-size: 0.75rem;
2300
+ color: var(--text-muted);
2301
+ font-weight: 600;
2302
+ }
2303
+
2304
+ .copilot-status-dot {
2305
+ width: 8px;
2306
+ height: 8px;
2307
+ border-radius: 50%;
2308
+ background: var(--green-positive);
2309
+ animation: pulse 2s infinite;
2310
+ }
2311
+
2312
+ @keyframes pulse {
2313
+
2314
+ 0%,
2315
+ 100% {
2316
+ opacity: 1;
2317
+ }
2318
+
2319
+ 50% {
2320
+ opacity: 0.5;
2321
+ }
2322
+ }
2323
+
2324
+ .copilot-chat {
2325
+ flex: 1;
2326
+ display: flex;
2327
+ flex-direction: column;
2328
+ background: var(--bg-card);
2329
+ border: 1px solid var(--border-color);
2330
+ border-radius: var(--radius-lg);
2331
+ overflow: hidden;
2332
+ min-height: 0;
2333
+ }
2334
+
2335
+ .copilot-messages {
2336
+ flex: 1;
2337
+ overflow-y: auto;
2338
+ padding: 1.5rem;
2339
+ display: flex;
2340
+ flex-direction: column;
2341
+ gap: 1.25rem;
2342
+ }
2343
+
2344
+ .copilot-msg {
2345
+ display: flex;
2346
+ gap: 0.75rem;
2347
+ max-width: 78%;
2348
+ animation: fadeInUp 0.3s ease;
2349
+ }
2350
+
2351
+ .copilot-msg-user {
2352
+ align-self: flex-end;
2353
+ flex-direction: row-reverse;
2354
+ }
2355
+
2356
+ .copilot-msg-assistant {
2357
+ align-self: flex-start;
2358
+ }
2359
+
2360
+ .copilot-msg-avatar {
2361
+ flex-shrink: 0;
2362
+ width: 32px;
2363
+ height: 32px;
2364
+ border-radius: 50%;
2365
+ display: flex;
2366
+ align-items: center;
2367
+ justify-content: center;
2368
+ background: var(--bg-tertiary);
2369
+ border: 1px solid var(--border-subtle);
2370
+ }
2371
+
2372
+ .copilot-msg-content {
2373
+ max-width: 100%;
2374
+ display: flex;
2375
+ flex-direction: column;
2376
+ }
2377
+
2378
+ .copilot-msg-label {
2379
+ font-size: 0.65rem;
2380
+ text-transform: uppercase;
2381
+ letter-spacing: 0.06em;
2382
+ color: var(--text-muted);
2383
+ font-weight: 600;
2384
+ margin-bottom: 0.25rem;
2385
+ }
2386
+
2387
+ .copilot-msg-user .copilot-msg-label {
2388
+ text-align: right;
2389
+ }
2390
+
2391
+ .copilot-msg-text {
2392
+ padding: 0.875rem 1.125rem;
2393
+ border-radius: var(--radius-lg);
2394
+ font-size: 0.85rem;
2395
+ line-height: 1.7;
2396
+ white-space: pre-wrap;
2397
+ word-break: break-word;
2398
+ }
2399
+
2400
+ .copilot-msg-user .copilot-msg-text {
2401
+ background: var(--accent);
2402
+ color: white;
2403
+ border-bottom-right-radius: 4px;
2404
+ }
2405
+
2406
+ .copilot-msg-assistant .copilot-msg-text {
2407
+ background: var(--bg-secondary);
2408
+ color: var(--text-primary);
2409
+ border-bottom-left-radius: 4px;
2410
+ border: 1px solid var(--border-subtle);
2411
+ }
2412
+
2413
+ .copilot-intent-tag {
2414
+ display: inline-flex;
2415
+ align-items: center;
2416
+ gap: 0.25rem;
2417
+ margin-top: 0.375rem;
2418
+ padding: 0.2rem 0.625rem;
2419
+ font-size: 0.65rem;
2420
+ font-weight: 600;
2421
+ background: var(--accent-lighter);
2422
+ color: var(--accent);
2423
+ border-radius: 3px;
2424
+ }
2425
+
2426
+ .copilot-actions {
2427
+ display: flex;
2428
+ gap: 0.5rem;
2429
+ margin-top: 0.5rem;
2430
+ flex-wrap: wrap;
2431
+ }
2432
+
2433
+ .copilot-action-btn {
2434
+ display: inline-flex;
2435
+ align-items: center;
2436
+ gap: 0.375rem;
2437
+ padding: 0.375rem 0.875rem;
2438
+ font-size: 0.75rem;
2439
+ font-weight: 600;
2440
+ background: transparent;
2441
+ border: 1px solid var(--border-color);
2442
+ border-radius: var(--radius-md);
2443
+ color: var(--accent);
2444
+ cursor: pointer;
2445
+ transition: all var(--transition-fast);
2446
+ font-family: var(--font-sans);
2447
+ }
2448
+
2449
+ .copilot-action-btn:hover {
2450
+ background: var(--accent-lighter);
2451
+ border-color: var(--accent);
2452
+ }
2453
+
2454
+ .copilot-typing {
2455
+ display: flex;
2456
+ gap: 4px;
2457
+ padding: 0.875rem 1.125rem;
2458
+ }
2459
+
2460
+ .copilot-typing span {
2461
+ width: 7px;
2462
+ height: 7px;
2463
+ background: var(--text-muted);
2464
+ border-radius: 50%;
2465
+ animation: typingBounce 1.4s infinite ease-in-out;
2466
+ }
2467
+
2468
+ .copilot-typing span:nth-child(2) {
2469
+ animation-delay: 0.2s;
2470
+ }
2471
+
2472
+ .copilot-typing span:nth-child(3) {
2473
+ animation-delay: 0.4s;
2474
+ }
2475
+
2476
+ @keyframes typingBounce {
2477
+
2478
+ 0%,
2479
+ 80%,
2480
+ 100% {
2481
+ transform: scale(0.6);
2482
+ opacity: 0.4;
2483
+ }
2484
+
2485
+ 40% {
2486
+ transform: scale(1);
2487
+ opacity: 1;
2488
+ }
2489
+ }
2490
+
2491
+ .copilot-input-area {
2492
+ display: flex;
2493
+ align-items: flex-end;
2494
+ gap: 0.75rem;
2495
+ padding: 1rem 1.25rem;
2496
+ border-top: 1px solid var(--border-color);
2497
+ background: var(--bg-primary);
2498
+ }
2499
+
2500
+ .copilot-input {
2501
+ flex: 1;
2502
+ padding: 0.75rem 1rem;
2503
+ border: 1px solid var(--border-color);
2504
+ border-radius: var(--radius-lg);
2505
+ background: var(--bg-secondary);
2506
+ color: var(--text-primary);
2507
+ font-family: var(--font-sans);
2508
+ font-size: 0.85rem;
2509
+ resize: none;
2510
+ outline: none;
2511
+ min-height: 44px;
2512
+ max-height: 120px;
2513
+ line-height: 1.5;
2514
+ }
2515
+
2516
+ .copilot-input:focus {
2517
+ border-color: var(--accent);
2518
+ box-shadow: 0 0 0 3px rgba(0, 82, 65, 0.1);
2519
+ }
2520
+
2521
+ .copilot-send-btn {
2522
+ width: 42px;
2523
+ height: 42px;
2524
+ border-radius: 50%;
2525
+ background: var(--accent);
2526
+ color: white;
2527
+ border: none;
2528
+ cursor: pointer;
2529
+ display: flex;
2530
+ align-items: center;
2531
+ justify-content: center;
2532
+ transition: all var(--transition-fast);
2533
+ flex-shrink: 0;
2534
+ }
2535
+
2536
+ .copilot-send-btn:hover:not(:disabled) {
2537
+ background: var(--accent-light);
2538
+ transform: scale(1.05);
2539
+ }
2540
+
2541
+ .copilot-send-btn:disabled {
2542
+ opacity: 0.4;
2543
+ cursor: not-allowed;
2544
+ }
2545
+
2546
+ /* ═══════════════════════════════════════════════════════════════════════
2547
+ Mobile Overrides for New Pages
2548
+ ═══════════════════════════════════════════════════════════════════════ */
2549
+ @media (max-width: 768px) {
2550
+ .page-container {
2551
+ padding: 1.25rem 1rem !important;
2552
+ }
2553
+
2554
+ .health-top-row {
2555
+ grid-template-columns: 1fr !important;
2556
+ }
2557
+
2558
+ .health-bottom-row {
2559
+ grid-template-columns: 1fr !important;
2560
+ }
2561
+
2562
+ .health-tips-grid {
2563
+ grid-template-columns: 1fr !important;
2564
+ }
2565
+
2566
+ .bias-summary-row {
2567
+ grid-template-columns: 1fr 1fr !important;
2568
+ }
2569
+
2570
+ .bias-grid {
2571
+ grid-template-columns: 1fr !important;
2572
+ }
2573
+
2574
+ .bias-chart-name {
2575
+ width: 100px;
2576
+ font-size: 0.7rem;
2577
+ }
2578
+
2579
+ .crisis-grid {
2580
+ grid-template-columns: 1fr 1fr !important;
2581
+ }
2582
+
2583
+ .crisis-kpi-row {
2584
+ flex-wrap: wrap;
2585
+ }
2586
+
2587
+ .crisis-kpi {
2588
+ min-width: 50%;
2589
+ border-bottom: 1px solid var(--border-subtle);
2590
+ }
2591
+
2592
+ .dna-layout {
2593
+ grid-template-columns: 1fr !important;
2594
+ }
2595
+
2596
+ .dna-comparison-chart-card,
2597
+ .dna-compare-card {
2598
+ grid-column: span 1 !important;
2599
+ }
2600
+
2601
+ .dna-comparison-list {
2602
+ grid-template-columns: 1fr !important;
2603
+ }
2604
+
2605
+ .copilot-msg {
2606
+ max-width: 92%;
2607
+ }
2608
+
2609
+ .copilot-header-row {
2610
+ flex-direction: column;
2611
+ gap: 0.5rem;
2612
+ }
2613
+ }
frontend/src/pages/BiasDetector.tsx ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { holdingsAPI } from '../api/client';
3
+
4
+ interface Bias {
5
+ name: string;
6
+ description: string;
7
+ score: number;
8
+ grade: string;
9
+ severity: string;
10
+ detail: string;
11
+ icon: string;
12
+ }
13
+
14
+ interface BiasData {
15
+ score: number;
16
+ grade: string;
17
+ biases: Bias[];
18
+ tips: string[];
19
+ position_count: number;
20
+ winners: number;
21
+ losers: number;
22
+ }
23
+
24
+ const severityColor = (s: string) =>
25
+ s === 'low' ? '#00c853' : s === 'moderate' ? '#ff9800' : s === 'high' ? '#f44336' : '#b71c1c';
26
+
27
+ const severityIcon = (s: string) => {
28
+ if (s === 'low') return <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd"/></svg>;
29
+ if (s === 'moderate') return <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"/></svg>;
30
+ return <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd"/></svg>;
31
+ };
32
+
33
+ export default function BiasDetector() {
34
+ const [data, setData] = useState<BiasData | null>(null);
35
+ const [loading, setLoading] = useState(true);
36
+
37
+ useEffect(() => { loadBiases(); }, []);
38
+
39
+ const loadBiases = async () => {
40
+ try {
41
+ const res = await holdingsAPI.biasAnalysis();
42
+ setData(res.data);
43
+ } catch { /* ignore */ } finally { setLoading(false); }
44
+ };
45
+
46
+ if (loading) return <div className="page-container"><div className="loading-spinner" /></div>;
47
+ if (!data) return <div className="page-container"><div className="alert-card">Add holdings to analyze behavioral biases.</div></div>;
48
+
49
+ const sorted = [...data.biases].sort((a, b) => b.score - a.score);
50
+ const overallColor = data.grade <= 'B' ? '#00c853' : data.grade <= 'C' ? '#ff9800' : '#f44336';
51
+
52
+ return (
53
+ <div className="page-container">
54
+ <div className="page-header">
55
+ <h1>Behavioral Bias Detector</h1>
56
+ <p className="page-subtitle">Identify cognitive biases that may impact your investment decisions</p>
57
+ </div>
58
+
59
+ {/* Summary Row */}
60
+ <div className="bias-summary-row">
61
+ <div className="card bias-score-card">
62
+ <div className="bias-score-header">
63
+ <span className="bias-score-label">Behavioral Score</span>
64
+ <span className="bias-score-value" style={{ color: overallColor }}>{data.score}</span>
65
+ </div>
66
+ <div className="bias-score-bar-track">
67
+ <div className="bias-score-bar-fill" style={{ width: `${data.score}%`, background: `linear-gradient(90deg, ${overallColor}80, ${overallColor})` }} />
68
+ </div>
69
+ <div className="bias-score-grade" style={{ color: overallColor }}>Grade: {data.grade}</div>
70
+ </div>
71
+ <div className="card bias-stat-card">
72
+ <svg viewBox="0 0 20 20" fill="var(--text-muted)" width="20" height="20"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fillRule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z" clipRule="evenodd"/></svg>
73
+ <span className="bias-stat-value">{data.position_count}</span>
74
+ <span className="bias-stat-label">Positions</span>
75
+ </div>
76
+ <div className="card bias-stat-card">
77
+ <svg viewBox="0 0 20 20" fill="var(--green-positive)" width="20" height="20"><path fillRule="evenodd" d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z" clipRule="evenodd"/></svg>
78
+ <span className="bias-stat-value positive">{data.winners}</span>
79
+ <span className="bias-stat-label">Winners</span>
80
+ </div>
81
+ <div className="card bias-stat-card">
82
+ <svg viewBox="0 0 20 20" fill="var(--red-negative)" width="20" height="20"><path fillRule="evenodd" d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z" clipRule="evenodd"/></svg>
83
+ <span className="bias-stat-value negative">{data.losers}</span>
84
+ <span className="bias-stat-label">Losers</span>
85
+ </div>
86
+ </div>
87
+
88
+ {/* Stacked Bar Visualization */}
89
+ <div className="card bias-stacked-chart">
90
+ <div className="card-header">
91
+ <h3>Bias Severity Distribution</h3>
92
+ </div>
93
+ <div className="bias-chart-bars">
94
+ {sorted.map((bias) => (
95
+ <div key={bias.name} className="bias-chart-row">
96
+ <span className="bias-chart-name">{bias.name}</span>
97
+ <div className="bias-chart-track">
98
+ <div className="bias-chart-fill" style={{ width: `${bias.score}%`, background: severityColor(bias.severity) }} />
99
+ </div>
100
+ <span className="bias-chart-val" style={{ color: severityColor(bias.severity) }}>{bias.score}</span>
101
+ </div>
102
+ ))}
103
+ </div>
104
+ </div>
105
+
106
+ {/* Bias Cards Grid */}
107
+ <div className="bias-grid">
108
+ {sorted.map((bias) => (
109
+ <div key={bias.name} className="card bias-detail-card">
110
+ <div className="bias-detail-top">
111
+ <div>
112
+ <h3>{bias.name}</h3>
113
+ <p className="bias-detail-desc">{bias.description}</p>
114
+ </div>
115
+ <div className="bias-badge-group">
116
+ <span className="bias-severity-badge" style={{ background: severityColor(bias.severity) + '15', color: severityColor(bias.severity) }}>
117
+ {severityIcon(bias.severity)}
118
+ <span>{bias.severity.toUpperCase()}</span>
119
+ </span>
120
+ </div>
121
+ </div>
122
+ <div className="bias-detail-meter">
123
+ <div className="bias-detail-meter-track">
124
+ <div className="bias-detail-meter-fill" style={{ width: `${bias.score}%`, background: severityColor(bias.severity) }} />
125
+ </div>
126
+ <div className="bias-detail-meta">
127
+ <span>Score: {bias.score}/100</span>
128
+ <span style={{ color: severityColor(bias.severity), fontWeight: 700 }}>{bias.grade}</span>
129
+ </div>
130
+ </div>
131
+ <p className="bias-detail-text">{bias.detail}</p>
132
+ </div>
133
+ ))}
134
+ </div>
135
+
136
+ {/* Tips */}
137
+ {data.tips.length > 0 && (
138
+ <div className="card bias-coaching-section">
139
+ <div className="card-header">
140
+ <h3>Behavioral Coaching</h3>
141
+ <span className="badge badge-primary">{data.tips.length} Recommendations</span>
142
+ </div>
143
+ <div className="bias-coaching-list">
144
+ {data.tips.map((tip, i) => (
145
+ <div key={i} className="bias-coaching-item">
146
+ <div className="bias-coaching-num">{i + 1}</div>
147
+ <span>{tip}</span>
148
+ </div>
149
+ ))}
150
+ </div>
151
+ </div>
152
+ )}
153
+ </div>
154
+ );
155
+ }
frontend/src/pages/Copilot.tsx ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useEffect } from 'react';
2
+ import { copilotAPI } from '../api/client';
3
+ import { useNavigate } from 'react-router-dom';
4
+
5
+ interface Message {
6
+ id: number;
7
+ role: 'user' | 'assistant';
8
+ content: string;
9
+ intent?: string;
10
+ actions?: { label: string; route: string }[];
11
+ timestamp: Date;
12
+ }
13
+
14
+ const BotIcon = () => (
15
+ <svg viewBox="0 0 24 24" fill="none" width="18" height="18" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
16
+ <rect x="3" y="11" width="18" height="10" rx="2" />
17
+ <circle cx="12" cy="5" r="3" />
18
+ <line x1="12" y1="8" x2="12" y2="11" />
19
+ <circle cx="8" cy="16" r="1" fill="var(--accent)" />
20
+ <circle cx="16" cy="16" r="1" fill="var(--accent)" />
21
+ </svg>
22
+ );
23
+
24
+ const UserIcon = () => (
25
+ <svg viewBox="0 0 20 20" fill="var(--text-muted)" width="16" height="16">
26
+ <path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
27
+ </svg>
28
+ );
29
+
30
+ const SendIcon = () => (
31
+ <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
32
+ <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
33
+ </svg>
34
+ );
35
+
36
+ export default function Copilot() {
37
+ const [messages, setMessages] = useState<Message[]>([
38
+ {
39
+ id: 0,
40
+ role: 'assistant',
41
+ content: "Welcome to the QuantHedge HedgeAI. I can help you analyze your portfolio risk, evaluate hedging strategies, run stress tests, and interpret market sentiment.\n\nTry asking:\n\n\u2022 \"How would my portfolio perform in a recession?\"\n\u2022 \"What is my biggest risk exposure?\"\n\u2022 \"Analyze sentiment for AAPL\"\n\u2022 \"Should I hedge my NVDA position?\"",
42
+ timestamp: new Date(),
43
+ },
44
+ ]);
45
+ const [input, setInput] = useState('');
46
+ const [loading, setLoading] = useState(false);
47
+ const bottomRef = useRef<HTMLDivElement>(null);
48
+ const navigate = useNavigate();
49
+
50
+ useEffect(() => {
51
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
52
+ }, [messages]);
53
+
54
+ const sendMessage = async () => {
55
+ if (!input.trim() || loading) return;
56
+ const userMsg: Message = { id: Date.now(), role: 'user', content: input.trim(), timestamp: new Date() };
57
+ setMessages(prev => [...prev, userMsg]);
58
+ setInput('');
59
+ setLoading(true);
60
+ try {
61
+ const res = await copilotAPI.chat(input.trim());
62
+ const data = res.data;
63
+ setMessages(prev => [...prev, {
64
+ id: Date.now() + 1,
65
+ role: 'assistant',
66
+ content: data.response || 'I could not process that request. Please try again.',
67
+ intent: data.intent,
68
+ actions: data.actions,
69
+ timestamp: new Date(),
70
+ }]);
71
+ } catch {
72
+ setMessages(prev => [...prev, {
73
+ id: Date.now() + 1,
74
+ role: 'assistant',
75
+ content: 'An error occurred while processing your request. Please try again.',
76
+ timestamp: new Date(),
77
+ }]);
78
+ } finally { setLoading(false); }
79
+ };
80
+
81
+ const handleKeyDown = (e: React.KeyboardEvent) => {
82
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
83
+ };
84
+
85
+ const formatIntent = (intent: string) => intent.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
86
+
87
+ return (
88
+ <div className="page-container copilot-page">
89
+ <div className="page-header">
90
+ <div className="copilot-header-row">
91
+ <div>
92
+ <h1>HedgeAI</h1>
93
+ <p className="page-subtitle">Natural language portfolio analysis assistant</p>
94
+ </div>
95
+ <div className="copilot-status">
96
+ <span className="copilot-status-dot" />
97
+ <span>Online</span>
98
+ </div>
99
+ </div>
100
+ </div>
101
+
102
+ <div className="copilot-chat">
103
+ <div className="copilot-messages">
104
+ {messages.map(msg => (
105
+ <div key={msg.id} className={`copilot-msg copilot-msg-${msg.role}`}>
106
+ <div className="copilot-msg-avatar">
107
+ {msg.role === 'user' ? <UserIcon /> : <BotIcon />}
108
+ </div>
109
+ <div className="copilot-msg-content">
110
+ <div className="copilot-msg-label">{msg.role === 'user' ? 'You' : 'QuantHedge Copilot'}</div>
111
+ <div className="copilot-msg-text">{msg.content}</div>
112
+ {msg.intent && msg.intent !== 'general' && (
113
+ <div className="copilot-intent-tag">
114
+ <svg viewBox="0 0 20 20" fill="currentColor" width="10" height="10"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.414V13a1 1 0 102 0V9.414l1.293 1.293a1 1 0 001.414-1.414z" clipRule="evenodd" /></svg>
115
+ <span>{formatIntent(msg.intent)}</span>
116
+ </div>
117
+ )}
118
+ {msg.actions && msg.actions.length > 0 && (
119
+ <div className="copilot-actions">
120
+ {msg.actions.map((a, i) => (
121
+ <button key={i} className="copilot-action-btn" onClick={() => navigate(a.route)}>
122
+ {a.label}
123
+ <svg viewBox="0 0 20 20" fill="currentColor" width="12" height="12"><path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" /></svg>
124
+ </button>
125
+ ))}
126
+ </div>
127
+ )}
128
+ </div>
129
+ </div>
130
+ ))}
131
+ {loading && (
132
+ <div className="copilot-msg copilot-msg-assistant">
133
+ <div className="copilot-msg-avatar"><BotIcon /></div>
134
+ <div className="copilot-msg-content">
135
+ <div className="copilot-msg-label">QuantHedge Copilot</div>
136
+ <div className="copilot-typing"><span /><span /><span /></div>
137
+ </div>
138
+ </div>
139
+ )}
140
+ <div ref={bottomRef} />
141
+ </div>
142
+
143
+ <div className="copilot-input-area">
144
+ <textarea
145
+ className="copilot-input"
146
+ value={input}
147
+ onChange={(e) => setInput(e.target.value)}
148
+ onKeyDown={handleKeyDown}
149
+ placeholder="Ask about portfolio risk, hedging strategies, or market analysis..."
150
+ rows={1}
151
+ disabled={loading}
152
+ />
153
+ <button className="copilot-send-btn" onClick={sendMessage} disabled={loading || !input.trim()}>
154
+ <SendIcon />
155
+ </button>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ );
160
+ }
frontend/src/pages/CrisisReplay.tsx ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { holdingsAPI } from '../api/client';
3
+
4
+ interface CrisisSummary {
5
+ crisis_id: string;
6
+ name: string;
7
+ total_pnl: number;
8
+ total_pnl_pct: number;
9
+ max_drawdown_pct: number;
10
+ trading_days: number;
11
+ }
12
+
13
+ interface EquityPoint {
14
+ date: string;
15
+ portfolio_value: number;
16
+ daily_return: number;
17
+ cumulative_return: number;
18
+ }
19
+
20
+ interface CrisisDetail {
21
+ crisis: { name: string; description: string; start: string; end: string; peak_decline: number };
22
+ portfolio_value: number;
23
+ final_value: number;
24
+ total_pnl: number;
25
+ total_pnl_pct: number;
26
+ max_drawdown_pct: number;
27
+ equity_curve: EquityPoint[];
28
+ holding_impacts: { ticker: string; sector: string; impact: number; sector_return: number }[];
29
+ }
30
+
31
+ export default function CrisisReplay() {
32
+ const [summaries, setSummaries] = useState<CrisisSummary[]>([]);
33
+ const [detail, setDetail] = useState<CrisisDetail | null>(null);
34
+ const [selectedCrisis, setSelectedCrisis] = useState('');
35
+ const [loading, setLoading] = useState(true);
36
+ const [detailLoading, setDetailLoading] = useState(false);
37
+
38
+ useEffect(() => { loadSummaries(); }, []);
39
+
40
+ const loadSummaries = async () => {
41
+ try {
42
+ const res = await holdingsAPI.allCrisisReplays();
43
+ setSummaries(res.data.crises || []);
44
+ } catch { /* ignore */ } finally { setLoading(false); }
45
+ };
46
+
47
+ const loadCrisis = async (crisisId: string) => {
48
+ setSelectedCrisis(crisisId);
49
+ setDetailLoading(true);
50
+ try {
51
+ const res = await holdingsAPI.crisisReplay(crisisId);
52
+ setDetail(res.data);
53
+ } catch { /* ignore */ } finally { setDetailLoading(false); }
54
+ };
55
+
56
+ if (loading) return <div className="page-container"><div className="loading-spinner" /></div>;
57
+
58
+ const renderEquityCurve = () => {
59
+ if (!detail?.equity_curve?.length) return null;
60
+ const points = detail.equity_curve;
61
+ const values = points.map(p => p.portfolio_value);
62
+ const min = Math.min(...values);
63
+ const max = Math.max(...values);
64
+ const range = max - min || 1;
65
+ const w = 700;
66
+ const h = 220;
67
+ const pad = { top: 20, right: 20, bottom: 30, left: 60 };
68
+ const plotW = w - pad.left - pad.right;
69
+ const plotH = h - pad.top - pad.bottom;
70
+
71
+ const isNeg = (detail.total_pnl || 0) < 0;
72
+ const lineColor = isNeg ? 'var(--red-negative)' : 'var(--green-positive)';
73
+
74
+ const pathD = points.map((p, i) => {
75
+ const x = pad.left + (i / (points.length - 1)) * plotW;
76
+ const y = pad.top + plotH - ((p.portfolio_value - min) / range) * plotH;
77
+ return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
78
+ }).join(' ');
79
+
80
+ const areaD = `${pathD} L ${pad.left + plotW} ${pad.top + plotH} L ${pad.left} ${pad.top + plotH} Z`;
81
+
82
+ // Y-axis labels
83
+ const ySteps = 5;
84
+ const yLabels = Array.from({ length: ySteps + 1 }, (_, i) => min + (range * i) / ySteps);
85
+
86
+ return (
87
+ <svg viewBox={`0 0 ${w} ${h}`} className="crisis-equity-svg" preserveAspectRatio="xMidYMid meet">
88
+ {/* Grid lines */}
89
+ {yLabels.map((v, i) => {
90
+ const y = pad.top + plotH - ((v - min) / range) * plotH;
91
+ return (
92
+ <g key={i}>
93
+ <line x1={pad.left} y1={y} x2={pad.left + plotW} y2={y} stroke="var(--border-subtle)" strokeWidth="0.5" />
94
+ <text x={pad.left - 8} y={y + 3} textAnchor="end" fill="var(--text-muted)" fontSize="8" fontFamily="var(--font-mono)">${(v / 1000).toFixed(1)}k</text>
95
+ </g>
96
+ );
97
+ })}
98
+ {/* X-axis labels (first/mid/last) */}
99
+ {[0, Math.floor(points.length / 2), points.length - 1].map((idx) => {
100
+ const x = pad.left + (idx / (points.length - 1)) * plotW;
101
+ const d = points[idx]?.date?.slice(5, 10) || '';
102
+ return <text key={idx} x={x} y={h - 5} textAnchor="middle" fill="var(--text-muted)" fontSize="8" fontFamily="var(--font-mono)">{d}</text>;
103
+ })}
104
+ {/* Area fill */}
105
+ <defs>
106
+ <linearGradient id="eqGrad" x1="0" y1="0" x2="0" y2="1">
107
+ <stop offset="0%" stopColor={lineColor} stopOpacity="0.2" />
108
+ <stop offset="100%" stopColor={lineColor} stopOpacity="0.02" />
109
+ </linearGradient>
110
+ </defs>
111
+ <path d={areaD} fill="url(#eqGrad)" />
112
+ <path d={pathD} fill="none" stroke={lineColor} strokeWidth="1.5" />
113
+ </svg>
114
+ );
115
+ };
116
+
117
+ // Mini sparkline for summary cards
118
+ const sparkline = (pnl: number) => {
119
+ const col = pnl < 0 ? 'var(--red-negative)' : 'var(--green-positive)';
120
+ const points = pnl < 0 ? 'M0,4 L8,6 L16,3 L24,8 L32,12 L40,10' : 'M0,10 L8,8 L16,6 L24,4 L32,3 L40,2';
121
+ return <svg viewBox="0 0 40 14" width="40" height="14" style={{ display: 'block' }}><path d={points} fill="none" stroke={col} strokeWidth="1.5" strokeLinecap="round" /></svg>;
122
+ };
123
+
124
+ return (
125
+ <div className="page-container">
126
+ <div className="page-header">
127
+ <h1>Historical Crisis Replay</h1>
128
+ <p className="page-subtitle">Simulate your portfolio performance during major historical market crises</p>
129
+ </div>
130
+
131
+ {/* Crisis Cards */}
132
+ <div className="crisis-grid">
133
+ {summaries.map((s) => (
134
+ <button key={s.crisis_id} className={`card crisis-card ${selectedCrisis === s.crisis_id ? 'active' : ''}`} onClick={() => loadCrisis(s.crisis_id)}>
135
+ <div className="crisis-card-top">
136
+ <h3>{s.name}</h3>
137
+ {sparkline(s.total_pnl_pct)}
138
+ </div>
139
+ <div className="crisis-card-metrics">
140
+ <div>
141
+ <span className="crisis-metric-label">Impact</span>
142
+ <span className={`crisis-metric-value ${s.total_pnl_pct < 0 ? 'negative' : 'positive'}`}>{s.total_pnl_pct > 0 ? '+' : ''}{s.total_pnl_pct.toFixed(1)}%</span>
143
+ </div>
144
+ <div>
145
+ <span className="crisis-metric-label">Max DD</span>
146
+ <span className="crisis-metric-value negative">{s.max_drawdown_pct.toFixed(1)}%</span>
147
+ </div>
148
+ <div>
149
+ <span className="crisis-metric-label">Duration</span>
150
+ <span className="crisis-metric-value">{s.trading_days}d</span>
151
+ </div>
152
+ </div>
153
+ </button>
154
+ ))}
155
+ </div>
156
+
157
+ {/* Detail View */}
158
+ {detailLoading && <div className="loading-spinner" />}
159
+ {detail && !detailLoading && (
160
+ <div className="crisis-detail-section">
161
+ {/* Header + Key Metrics */}
162
+ <div className="card crisis-detail-header">
163
+ <div className="crisis-detail-title">
164
+ <h2>{detail.crisis.name}</h2>
165
+ <p>{detail.crisis.description}</p>
166
+ </div>
167
+ <div className="crisis-kpi-row">
168
+ <div className="crisis-kpi">
169
+ <span className="crisis-kpi-label">Period</span>
170
+ <span className="crisis-kpi-value">{detail.crisis.start} β€” {detail.crisis.end}</span>
171
+ </div>
172
+ <div className="crisis-kpi">
173
+ <span className="crisis-kpi-label">Portfolio Impact</span>
174
+ <span className={`crisis-kpi-value ${detail.total_pnl < 0 ? 'negative' : 'positive'}`}>${detail.total_pnl.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
175
+ </div>
176
+ <div className="crisis-kpi">
177
+ <span className="crisis-kpi-label">Return</span>
178
+ <span className={`crisis-kpi-value ${detail.total_pnl_pct < 0 ? 'negative' : 'positive'}`}>{detail.total_pnl_pct > 0 ? '+' : ''}{detail.total_pnl_pct.toFixed(2)}%</span>
179
+ </div>
180
+ <div className="crisis-kpi">
181
+ <span className="crisis-kpi-label">Max Drawdown</span>
182
+ <span className="crisis-kpi-value negative">{detail.max_drawdown_pct.toFixed(2)}%</span>
183
+ </div>
184
+ </div>
185
+ </div>
186
+
187
+ {/* Equity Curve Chart */}
188
+ <div className="card">
189
+ <div className="card-header">
190
+ <h3>Portfolio Equity Curve</h3>
191
+ <span className="badge badge-primary">{detail.equity_curve?.length || 0} Trading Days</span>
192
+ </div>
193
+ <div className="crisis-chart-container">{renderEquityCurve()}</div>
194
+ </div>
195
+
196
+ {/* Per-Holding Impact Table */}
197
+ <div className="card">
198
+ <div className="card-header">
199
+ <h3>Per-Holding Impact Analysis</h3>
200
+ </div>
201
+ <table className="data-table">
202
+ <thead>
203
+ <tr>
204
+ <th>Ticker</th>
205
+ <th>Sector</th>
206
+ <th>Sector Return</th>
207
+ <th>Dollar Impact</th>
208
+ </tr>
209
+ </thead>
210
+ <tbody>
211
+ {detail.holding_impacts?.sort((a, b) => a.impact - b.impact).map((h) => (
212
+ <tr key={h.ticker}>
213
+ <td style={{ fontWeight: 700 }}>{h.ticker}</td>
214
+ <td style={{ fontFamily: 'var(--font-sans)', textTransform: 'capitalize' }}>{h.sector}</td>
215
+ <td className={h.sector_return < 0 ? 'negative' : 'positive'}>{h.sector_return > 0 ? '+' : ''}{h.sector_return.toFixed(1)}%</td>
216
+ <td className={h.impact < 0 ? 'negative' : 'positive'}>${h.impact.toLocaleString(undefined, { maximumFractionDigits: 0 })}</td>
217
+ </tr>
218
+ ))}
219
+ </tbody>
220
+ </table>
221
+ </div>
222
+ </div>
223
+ )}
224
+ </div>
225
+ );
226
+ }
frontend/src/pages/PortfolioDNA.tsx ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { holdingsAPI } from '../api/client';
3
+
4
+ interface DNAProfile {
5
+ [key: string]: number;
6
+ }
7
+
8
+ interface FamousPortfolio {
9
+ id: string;
10
+ name: string;
11
+ description: string;
12
+ style: string;
13
+ similarity: number;
14
+ dna: DNAProfile;
15
+ }
16
+
17
+ interface DNAData {
18
+ dna: DNAProfile;
19
+ style: string;
20
+ dominant_trait: string;
21
+ closest_match: FamousPortfolio | null;
22
+ comparisons: FamousPortfolio[];
23
+ position_count: number;
24
+ }
25
+
26
+ const DIMENSION_LABELS: Record<string, string> = {
27
+ momentum: 'Momentum',
28
+ value: 'Value',
29
+ volatility: 'Volatility',
30
+ regime_sensitivity: 'Regime Sens.',
31
+ concentration: 'Concentration',
32
+ hedge_coverage: 'Hedge Coverage',
33
+ };
34
+
35
+ export default function PortfolioDNA() {
36
+ const [data, setData] = useState<DNAData | null>(null);
37
+ const [loading, setLoading] = useState(true);
38
+ const [overlayId, setOverlayId] = useState<string | null>(null);
39
+
40
+ useEffect(() => { loadDNA(); }, []);
41
+
42
+ const loadDNA = async () => {
43
+ try {
44
+ const res = await holdingsAPI.portfolioDNA();
45
+ setData(res.data);
46
+ } catch { /* ignore */ } finally { setLoading(false); }
47
+ };
48
+
49
+ if (loading) return <div className="page-container"><div className="loading-spinner" /></div>;
50
+ if (!data?.dna) return <div className="page-container"><div className="alert-card">Add holdings to compute your Portfolio DNA fingerprint.</div></div>;
51
+
52
+ const dims = Object.keys(data.dna).sort();
53
+ const overlayFP = data.comparisons.find(c => c.id === overlayId);
54
+
55
+ const renderRadar = () => {
56
+ const w = 320, cx = 160, cy = 160, R = 120;
57
+ return (
58
+ <svg viewBox={`0 0 ${w} ${w}`} className="dna-radar-svg">
59
+ {[20, 40, 60, 80, 100].map(r => (
60
+ <polygon key={r} points={dims.map((_, i) => {
61
+ const a = (Math.PI * 2 * i) / dims.length - Math.PI / 2;
62
+ return `${cx + R * (r/100) * Math.cos(a)},${cy + R * (r/100) * Math.sin(a)}`;
63
+ }).join(' ')} fill="none" stroke="var(--border-subtle)" strokeWidth="0.5" opacity={0.4} />
64
+ ))}
65
+ {dims.map((_, i) => {
66
+ const a = (Math.PI * 2 * i) / dims.length - Math.PI / 2;
67
+ return <line key={i} x1={cx} y1={cy} x2={cx + R * Math.cos(a)} y2={cy + R * Math.sin(a)} stroke="var(--border-subtle)" strokeWidth="0.5" opacity={0.25} />;
68
+ })}
69
+ {/* Overlay */}
70
+ {overlayFP && (
71
+ <polygon points={dims.map((d, i) => {
72
+ const a = (Math.PI * 2 * i) / dims.length - Math.PI / 2;
73
+ const r = (overlayFP.dna[d] || 0) / 100;
74
+ return `${cx + R * r * Math.cos(a)},${cy + R * r * Math.sin(a)}`;
75
+ }).join(' ')} fill="rgba(255,152,0,0.08)" stroke="#ff9800" strokeWidth="1.5" strokeDasharray="4 2" />
76
+ )}
77
+ {/* User DNA */}
78
+ <polygon points={dims.map((d, i) => {
79
+ const a = (Math.PI * 2 * i) / dims.length - Math.PI / 2;
80
+ const r = data.dna[d] / 100;
81
+ return `${cx + R * r * Math.cos(a)},${cy + R * r * Math.sin(a)}`;
82
+ }).join(' ')} fill="var(--accent)" fillOpacity="0.1" stroke="var(--accent)" strokeWidth="2" />
83
+ {dims.map((d, i) => {
84
+ const a = (Math.PI * 2 * i) / dims.length - Math.PI / 2;
85
+ const r = data.dna[d] / 100;
86
+ return <circle key={d} cx={cx + R * r * Math.cos(a)} cy={cy + R * r * Math.sin(a)} r="3.5" fill="var(--accent)" />;
87
+ })}
88
+ {dims.map((d, i) => {
89
+ const a = (Math.PI * 2 * i) / dims.length - Math.PI / 2;
90
+ return <text key={d} x={cx + (R + 18) * Math.cos(a)} y={cy + (R + 18) * Math.sin(a)} textAnchor="middle" dominantBaseline="middle" fill="var(--text-secondary)" fontSize="9" fontWeight="500">{DIMENSION_LABELS[d] || d}</text>;
91
+ })}
92
+ </svg>
93
+ );
94
+ };
95
+
96
+ // Grouped bar chart: user vs closest match
97
+ const renderComparisonBars = () => {
98
+ const match = data.closest_match || data.comparisons[0];
99
+ if (!match) return null;
100
+ const barH = 20;
101
+ const gap = 8;
102
+ const rowH = barH * 2 + gap + 16;
103
+ const svgH = dims.length * rowH + 20;
104
+ const labelW = 90;
105
+ const barW = 260;
106
+
107
+ return (
108
+ <svg viewBox={`0 0 ${labelW + barW + 50} ${svgH}`} className="dna-comparison-svg">
109
+ {dims.map((d, i) => {
110
+ const y = i * rowH + 10;
111
+ const userVal = data.dna[d] || 0;
112
+ const matchVal = match.dna[d] || 0;
113
+ return (
114
+ <g key={d}>
115
+ <text x={labelW - 8} y={y + barH / 2 + 4} textAnchor="end" fill="var(--text-secondary)" fontSize="9" fontWeight="500">{DIMENSION_LABELS[d] || d}</text>
116
+ <rect x={labelW} y={y} width={(userVal / 100) * barW} height={barH} rx="3" fill="var(--accent)" opacity="0.8" />
117
+ <text x={labelW + (userVal / 100) * barW + 6} y={y + barH / 2 + 3} fill="var(--text-secondary)" fontSize="8" fontFamily="var(--font-mono)">{userVal}</text>
118
+ <rect x={labelW} y={y + barH + 2} width={(matchVal / 100) * barW} height={barH} rx="3" fill="#ff9800" opacity="0.6" />
119
+ <text x={labelW + (matchVal / 100) * barW + 6} y={y + barH + 2 + barH / 2 + 3} fill="var(--text-muted)" fontSize="8" fontFamily="var(--font-mono)">{matchVal}</text>
120
+ </g>
121
+ );
122
+ })}
123
+ </svg>
124
+ );
125
+ };
126
+
127
+ return (
128
+ <div className="page-container">
129
+ <div className="page-header">
130
+ <h1>Portfolio DNA Fingerprint</h1>
131
+ <p className="page-subtitle">Your investing style decoded and compared against legendary investors</p>
132
+ </div>
133
+
134
+ {/* Style Badge */}
135
+ <div className="dna-style-header">
136
+ <div className="dna-style-chip">
137
+ <svg viewBox="0 0 20 20" fill="var(--accent)" width="16" height="16"><path d="M10 3.5a1.5 1.5 0 013 0V4a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-.5a1.5 1.5 0 000 3h.5a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-.5a1.5 1.5 0 00-3 0v.5a1 1 0 01-1 1H6a1 1 0 01-1-1v-3a1 1 0 00-1-1h-.5a1.5 1.5 0 010-3H4a1 1 0 001-1V6a1 1 0 011-1h3a1 1 0 001-1v-.5z"/></svg>
138
+ <span>Investing Style: <strong>{data.style}</strong></span>
139
+ </div>
140
+ {data.closest_match && (
141
+ <div className="dna-match-chip">
142
+ <svg viewBox="0 0 20 20" fill="var(--accent)" width="14" height="14"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd"/></svg>
143
+ <span>Closest Match: <strong>{data.closest_match.name}</strong> ({data.closest_match.similarity.toFixed(0)}%)</span>
144
+ </div>
145
+ )}
146
+ </div>
147
+
148
+ <div className="dna-layout">
149
+ {/* Radar Chart */}
150
+ <div className="card dna-radar-card">
151
+ <div className="card-header">
152
+ <h3>DNA Profile</h3>
153
+ {overlayFP && (
154
+ <div className="dna-legend">
155
+ <span className="dna-legend-item"><span className="dna-legend-dot" style={{ background: 'var(--accent)' }} /> You</span>
156
+ <span className="dna-legend-item"><span className="dna-legend-dot" style={{ background: '#ff9800' }} /> {overlayFP.name}</span>
157
+ </div>
158
+ )}
159
+ </div>
160
+ <div className="dna-radar-wrapper">{renderRadar()}</div>
161
+ </div>
162
+
163
+ {/* Dimension Bars */}
164
+ <div className="card dna-dims-card">
165
+ <div className="card-header">
166
+ <h3>Dimension Scores</h3>
167
+ </div>
168
+ {dims.map(d => (
169
+ <div key={d} className="dna-dim-row">
170
+ <span className="dna-dim-label">{DIMENSION_LABELS[d] || d}</span>
171
+ <div className="dna-dim-bar-track">
172
+ <div className="dna-dim-bar" style={{ width: `${data.dna[d]}%` }} />
173
+ </div>
174
+ <span className="dna-dim-value">{data.dna[d]}</span>
175
+ </div>
176
+ ))}
177
+ </div>
178
+
179
+ {/* Comparison Chart */}
180
+ {data.closest_match && (
181
+ <div className="card dna-comparison-chart-card">
182
+ <div className="card-header">
183
+ <h3>You vs {data.closest_match.name}</h3>
184
+ <div className="dna-legend">
185
+ <span className="dna-legend-item"><span className="dna-legend-dot" style={{ background: 'var(--accent)' }} /> Your Portfolio</span>
186
+ <span className="dna-legend-item"><span className="dna-legend-dot" style={{ background: '#ff9800' }} /> {data.closest_match.name}</span>
187
+ </div>
188
+ </div>
189
+ <div className="dna-comparison-chart-wrapper">{renderComparisonBars()}</div>
190
+ </div>
191
+ )}
192
+
193
+ {/* Famous Portfolios */}
194
+ <div className="card dna-compare-card">
195
+ <div className="card-header">
196
+ <h3>Famous Portfolio Comparisons</h3>
197
+ <span className="badge badge-primary">{data.comparisons.length} Benchmarks</span>
198
+ </div>
199
+ <div className="dna-comparison-list">
200
+ {data.comparisons.map(fp => (
201
+ <button key={fp.id} className={`dna-comparison-item ${overlayId === fp.id ? 'active' : ''}`} onClick={() => setOverlayId(overlayId === fp.id ? null : fp.id)}>
202
+ <div className="dna-fp-info">
203
+ <strong>{fp.name}</strong>
204
+ <span className="dna-fp-style">{fp.style}</span>
205
+ <span className="dna-fp-desc">{fp.description}</span>
206
+ </div>
207
+ <div className="dna-similarity">
208
+ <span className="dna-sim-pct">{fp.similarity.toFixed(0)}%</span>
209
+ <span className="dna-sim-label">match</span>
210
+ </div>
211
+ </button>
212
+ ))}
213
+ </div>
214
+ </div>
215
+ </div>
216
+ </div>
217
+ );
218
+ }
frontend/src/pages/PortfolioHealth.tsx ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { holdingsAPI } from '../api/client';
3
+
4
+ interface HealthComponent {
5
+ name: string;
6
+ score: number;
7
+ weight: number;
8
+ grade: string;
9
+ detail: string;
10
+ }
11
+
12
+ interface HealthData {
13
+ score: number;
14
+ grade: string;
15
+ grade_color: string;
16
+ components: HealthComponent[];
17
+ tips: string[];
18
+ position_count: number;
19
+ total_value: number;
20
+ }
21
+
22
+ const gradeColor = (grade: string) =>
23
+ grade.startsWith('A') ? '#00c853' : grade.startsWith('B') ? '#2196f3' : grade.startsWith('C') ? '#ff9800' : '#f44336';
24
+
25
+ export default function PortfolioHealth() {
26
+ const [data, setData] = useState<HealthData | null>(null);
27
+ const [loading, setLoading] = useState(true);
28
+ const [error, setError] = useState('');
29
+
30
+ useEffect(() => { loadHealth(); }, []);
31
+
32
+ const loadHealth = async () => {
33
+ setLoading(true);
34
+ try {
35
+ const res = await holdingsAPI.healthScore();
36
+ setData(res.data);
37
+ } catch (e: any) {
38
+ setError(e.response?.data?.detail || 'Failed to load health score');
39
+ } finally {
40
+ setLoading(false);
41
+ }
42
+ };
43
+
44
+ if (loading) return <div className="page-container"><div className="loading-spinner" /></div>;
45
+ if (error) return <div className="page-container"><div className="alert-card error">{error}</div></div>;
46
+ if (!data) return null;
47
+
48
+ const circumference = 2 * Math.PI * 70;
49
+ const filled = (data.score / 100) * circumference;
50
+
51
+ // Bar chart data for component scores
52
+ const sortedComponents = [...data.components].sort((a, b) => b.score - a.score);
53
+
54
+ return (
55
+ <div className="page-container">
56
+ <div className="page-header">
57
+ <h1>Portfolio Health Score</h1>
58
+ <p className="page-subtitle">Comprehensive portfolio assessment across 7 risk-adjusted dimensions</p>
59
+ </div>
60
+
61
+ {/* Top Summary Row */}
62
+ <div className="health-top-row">
63
+ <div className="card health-gauge-card">
64
+ <div className="health-gauge">
65
+ <svg viewBox="0 0 160 160" className="gauge-svg">
66
+ <circle cx="80" cy="80" r="70" fill="none" stroke="var(--border-subtle)" strokeWidth="10" />
67
+ <circle
68
+ cx="80" cy="80" r="70" fill="none"
69
+ stroke={data.grade_color || gradeColor(data.grade)}
70
+ strokeWidth="10"
71
+ strokeDasharray={`${filled} ${circumference}`}
72
+ strokeDashoffset={circumference * 0.25}
73
+ strokeLinecap="round"
74
+ style={{ transition: 'stroke-dasharray 1s ease' }}
75
+ />
76
+ </svg>
77
+ <div className="gauge-center">
78
+ <span className="gauge-score">{data.score}</span>
79
+ <span className="gauge-grade" style={{ color: data.grade_color || gradeColor(data.grade) }}>{data.grade}</span>
80
+ </div>
81
+ </div>
82
+ <div className="gauge-meta">
83
+ <div className="gauge-meta-item">
84
+ <span className="gauge-meta-value">{data.position_count}</span>
85
+ <span className="gauge-meta-label">Positions</span>
86
+ </div>
87
+ <div className="gauge-meta-divider" />
88
+ <div className="gauge-meta-item">
89
+ <span className="gauge-meta-value">${(data.total_value || 0).toLocaleString()}</span>
90
+ <span className="gauge-meta-label">Total Value</span>
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ {/* Component Scores Bar Chart */}
96
+ <div className="card health-chart-card">
97
+ <div className="card-header">
98
+ <h3>Component Breakdown</h3>
99
+ <span className="badge badge-primary">7 Dimensions</span>
100
+ </div>
101
+ <div className="health-bar-chart">
102
+ {sortedComponents.map((c) => (
103
+ <div key={c.name} className="hbar-row">
104
+ <div className="hbar-label">
105
+ <span className="hbar-name">{c.name}</span>
106
+ <span className="hbar-grade-pill" style={{ background: gradeColor(c.grade) + '18', color: gradeColor(c.grade) }}>{c.grade}</span>
107
+ </div>
108
+ <div className="hbar-track-container">
109
+ <div className="hbar-track">
110
+ <div className="hbar-fill" style={{ width: `${c.score}%`, background: `linear-gradient(90deg, ${gradeColor(c.grade)}80, ${gradeColor(c.grade)})` }} />
111
+ </div>
112
+ <span className="hbar-value">{c.score}</span>
113
+ </div>
114
+ </div>
115
+ ))}
116
+ </div>
117
+ </div>
118
+ </div>
119
+
120
+ {/* Radar + Details Row */}
121
+ <div className="health-bottom-row">
122
+ {/* Radar Chart */}
123
+ <div className="card health-radar-card">
124
+ <div className="card-header">
125
+ <h3>Risk Profile Radar</h3>
126
+ </div>
127
+ <div className="radar-wrapper">
128
+ <svg viewBox="0 0 300 300" className="radar-svg">
129
+ {[20, 40, 60, 80, 100].map((r) => (
130
+ <polygon key={r}
131
+ points={data.components.map((_, i) => {
132
+ const angle = (Math.PI * 2 * i) / data.components.length - Math.PI / 2;
133
+ return `${150 + (r / 100) * 110 * Math.cos(angle)},${150 + (r / 100) * 110 * Math.sin(angle)}`;
134
+ }).join(' ')}
135
+ fill="none" stroke="var(--border-subtle)" strokeWidth="0.5" opacity={0.5}
136
+ />
137
+ ))}
138
+ {data.components.map((_, i) => {
139
+ const angle = (Math.PI * 2 * i) / data.components.length - Math.PI / 2;
140
+ return <line key={i} x1="150" y1="150" x2={150 + 110 * Math.cos(angle)} y2={150 + 110 * Math.sin(angle)} stroke="var(--border-subtle)" strokeWidth="0.5" opacity={0.3} />;
141
+ })}
142
+ <polygon
143
+ points={data.components.map((c, i) => {
144
+ const angle = (Math.PI * 2 * i) / data.components.length - Math.PI / 2;
145
+ const r = c.score / 100;
146
+ return `${150 + r * 110 * Math.cos(angle)},${150 + r * 110 * Math.sin(angle)}`;
147
+ }).join(' ')}
148
+ fill="var(--accent)" fillOpacity="0.12" stroke="var(--accent)" strokeWidth="2"
149
+ />
150
+ {data.components.map((c, i) => {
151
+ const angle = (Math.PI * 2 * i) / data.components.length - Math.PI / 2;
152
+ const r = c.score / 100;
153
+ return <circle key={i} cx={150 + r * 110 * Math.cos(angle)} cy={150 + r * 110 * Math.sin(angle)} r="4" fill="var(--accent)" />;
154
+ })}
155
+ {data.components.map((c, i) => {
156
+ const angle = (Math.PI * 2 * i) / data.components.length - Math.PI / 2;
157
+ const x = 150 + 130 * Math.cos(angle);
158
+ const y = 150 + 130 * Math.sin(angle);
159
+ return <text key={i} x={x} y={y} textAnchor="middle" dominantBaseline="middle" fill="var(--text-secondary)" fontSize="9" fontWeight="500">{c.name}</text>;
160
+ })}
161
+ </svg>
162
+ </div>
163
+ </div>
164
+
165
+ {/* Component Details Table */}
166
+ <div className="card health-detail-card">
167
+ <div className="card-header">
168
+ <h3>Dimension Detail</h3>
169
+ </div>
170
+ <table className="data-table">
171
+ <thead>
172
+ <tr>
173
+ <th>Dimension</th>
174
+ <th>Score</th>
175
+ <th>Grade</th>
176
+ <th>Weight</th>
177
+ <th>Assessment</th>
178
+ </tr>
179
+ </thead>
180
+ <tbody>
181
+ {data.components.map((c) => (
182
+ <tr key={c.name}>
183
+ <td style={{ fontFamily: 'var(--font-sans)', fontWeight: 600 }}>{c.name}</td>
184
+ <td>{c.score}</td>
185
+ <td><span className="badge" style={{ background: gradeColor(c.grade) + '18', color: gradeColor(c.grade) }}>{c.grade}</span></td>
186
+ <td>{(c.weight * 100).toFixed(0)}%</td>
187
+ <td style={{ fontFamily: 'var(--font-sans)', fontSize: '0.78rem', color: 'var(--text-secondary)' }}>{c.detail}</td>
188
+ </tr>
189
+ ))}
190
+ </tbody>
191
+ </table>
192
+ </div>
193
+ </div>
194
+
195
+ {/* Coaching Tips */}
196
+ {data.tips.length > 0 && (
197
+ <div className="card health-tips-section">
198
+ <div className="card-header">
199
+ <h3>Actionable Recommendations</h3>
200
+ <span className="badge badge-primary">{data.tips.length} Tips</span>
201
+ </div>
202
+ <div className="health-tips-grid">
203
+ {data.tips.map((tip, i) => (
204
+ <div key={i} className="health-tip-item">
205
+ <div className="health-tip-icon">
206
+ <svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" /></svg>
207
+ </div>
208
+ <span>{tip}</span>
209
+ </div>
210
+ ))}
211
+ </div>
212
+ </div>
213
+ )}
214
+ </div>
215
+ );
216
+ }