KSS-1227 commited on
Commit
d2731de
·
1 Parent(s): 5c95266

Add WhatsApp multi-user broadcast system

Browse files
README.md CHANGED
@@ -152,6 +152,8 @@ in a different directory.
152
  | `REACT_APP_SUPABASE_URL` | Yes | Supabase frontend project URL |
153
  | `REACT_APP_SUPABASE_ANON_KEY` | Yes | Supabase browser authentication |
154
  | `OPENAI_API_KEY` | Only for `/ask` | LangChain/OpenAI question answering |
 
 
155
  | `TWILIO_ACCOUNT_SID` | Only for WhatsApp | Twilio account identifier |
156
  | `TWILIO_AUTH_TOKEN` | Only for WhatsApp | Twilio authentication |
157
  | `TWILIO_WHATSAPP_FROM` | Optional | WhatsApp sender, defaults to Twilio sandbox sender |
 
152
  | `REACT_APP_SUPABASE_URL` | Yes | Supabase frontend project URL |
153
  | `REACT_APP_SUPABASE_ANON_KEY` | Yes | Supabase browser authentication |
154
  | `OPENAI_API_KEY` | Only for `/ask` | LangChain/OpenAI question answering |
155
+ | `GEMINI_API_KEY` | Optional | Portfolio Doctor explanations |
156
+ | `GEMINI_SCREENER_API_KEY` | Optional | Isolated quota for ML screener explanations |
157
  | `TWILIO_ACCOUNT_SID` | Only for WhatsApp | Twilio account identifier |
158
  | `TWILIO_AUTH_TOKEN` | Only for WhatsApp | Twilio authentication |
159
  | `TWILIO_WHATSAPP_FROM` | Optional | WhatsApp sender, defaults to Twilio sandbox sender |
backend/api_v2.py CHANGED
@@ -17,6 +17,8 @@ from fastapi.responses import JSONResponse
17
  from pydantic import BaseModel, Field
18
 
19
  from agents.pipeline import run_pipeline
 
 
20
  from data.fetch_prices import (
21
  SCREENER_UNIVERSE,
22
  fetch_gold_price_inr,
@@ -133,7 +135,7 @@ def health(request: Request):
133
 
134
 
135
  @router.get("/signal/full/{symbol}", response_model=ApiSuccessEnvelope)
136
- def signal_full(request: Request, symbol: str):
137
  """Complete HistoricalService analysis — single source of truth for
138
  AI Screener, Portfolio Doctor, Full Analysis, and WhatsApp."""
139
  sym = symbol.upper()
@@ -141,11 +143,13 @@ def signal_full(request: Request, symbol: str):
141
  sym = sym + ".NS"
142
  try:
143
  service = HistoricalService()
144
- analysis = service.analyze(sym)
145
  data = analysis.model_dump()
146
  # The full-analysis response is the UI contract. Keep nested values
147
  # intact so clients do not need legacy aliases or local calculations.
148
  data["sentiment"] = data.pop("sentiment_details", {})
 
 
149
  return success(request, data, "Full analysis completed successfully.")
150
  except ValueError as exc:
151
  return error(request, status_code=404, code="SYMBOL_NOT_FOUND", message=str(exc))
@@ -333,7 +337,7 @@ def portfolio_analyze(request: Request, payload: PortfolioAnalyzeRequest):
333
  symbol = holding.symbol.strip().upper()
334
  normalized = symbol if symbol.endswith(".NS") or symbol in {"GC=F", "SI=F"} else f"{symbol}.NS"
335
  try:
336
- analysis = service.analyze(normalized)
337
  invested = holding.quantity * holding.buy_price
338
  market_value = holding.quantity * analysis.current_price
339
  holdings.append({
@@ -364,10 +368,82 @@ def portfolio_analyze(request: Request, payload: PortfolioAnalyzeRequest):
364
  logger.exception("v2.portfolio.holding_failed", extra={"request_id": _request_id(request), "symbol": symbol})
365
  holdings.append({"symbol": symbol, "status": "failed", "error": {"code": "ANALYSIS_UNAVAILABLE", "message": "Could not analyze this holding."}})
366
 
367
- successful = sum(item["status"] == "analyzed" for item in holdings)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  return success(
369
  request,
370
- {"holdings": holdings, "count": len(holdings), "successful": successful, "failed": len(holdings) - successful},
 
 
 
 
 
 
371
  "Portfolio analyzed successfully." if successful == len(holdings) else "Portfolio analysis completed with some unavailable holdings.",
372
  )
373
 
 
17
  from pydantic import BaseModel, Field
18
 
19
  from agents.pipeline import run_pipeline
20
+ from backend.portfolio_ai import generate_portfolio_report
21
+ from backend.screener_explainer import explain_screener_prediction
22
  from data.fetch_prices import (
23
  SCREENER_UNIVERSE,
24
  fetch_gold_price_inr,
 
135
 
136
 
137
  @router.get("/signal/full/{symbol}", response_model=ApiSuccessEnvelope)
138
+ def signal_full(request: Request, symbol: str, explain_screener: bool = False):
139
  """Complete HistoricalService analysis — single source of truth for
140
  AI Screener, Portfolio Doctor, Full Analysis, and WhatsApp."""
141
  sym = symbol.upper()
 
143
  sym = sym + ".NS"
144
  try:
145
  service = HistoricalService()
146
+ analysis = service.analyze(sym, include_explanation=not explain_screener)
147
  data = analysis.model_dump()
148
  # The full-analysis response is the UI contract. Keep nested values
149
  # intact so clients do not need legacy aliases or local calculations.
150
  data["sentiment"] = data.pop("sentiment_details", {})
151
+ if explain_screener:
152
+ data["screener_explanation"] = explain_screener_prediction(data)
153
  return success(request, data, "Full analysis completed successfully.")
154
  except ValueError as exc:
155
  return error(request, status_code=404, code="SYMBOL_NOT_FOUND", message=str(exc))
 
337
  symbol = holding.symbol.strip().upper()
338
  normalized = symbol if symbol.endswith(".NS") or symbol in {"GC=F", "SI=F"} else f"{symbol}.NS"
339
  try:
340
+ analysis = service.analyze(normalized, include_explanation=False)
341
  invested = holding.quantity * holding.buy_price
342
  market_value = holding.quantity * analysis.current_price
343
  holdings.append({
 
368
  logger.exception("v2.portfolio.holding_failed", extra={"request_id": _request_id(request), "symbol": symbol})
369
  holdings.append({"symbol": symbol, "status": "failed", "error": {"code": "ANALYSIS_UNAVAILABLE", "message": "Could not analyze this holding."}})
370
 
371
+ successful_holdings = [item for item in holdings if item["status"] == "analyzed"]
372
+ successful = len(successful_holdings)
373
+ ai_report = None
374
+
375
+ if successful_holdings:
376
+ total_invested = sum(item["invested_amount"] for item in successful_holdings)
377
+ total_market_value = sum(item["market_value"] for item in successful_holdings)
378
+ risk_distribution = {
379
+ risk: sum(item["overall_risk"].upper() == risk for item in successful_holdings)
380
+ for risk in ("HIGH", "MEDIUM", "LOW")
381
+ }
382
+ trends = [item["trend"] for item in successful_holdings]
383
+ sentiments = [item["sentiment"] for item in successful_holdings]
384
+ expected_returns = [
385
+ item["expected_return"]
386
+ for item in successful_holdings
387
+ if item["expected_return"] is not None
388
+ ]
389
+ confidence = sum(item["confidence"] for item in successful_holdings) / successful
390
+ overall_risk = next(
391
+ risk for risk in ("HIGH", "MEDIUM", "LOW") if risk_distribution[risk]
392
+ )
393
+ portfolio_analysis = {
394
+ "symbol": "PORTFOLIO",
395
+ "portfolio_summary": {
396
+ "positions": successful,
397
+ "invested_amount": round(total_invested, 2),
398
+ "market_value": round(total_market_value, 2),
399
+ "pnl": round(total_market_value - total_invested, 2),
400
+ "pnl_percent": round(
401
+ ((total_market_value - total_invested) / total_invested) * 100, 2
402
+ ) if total_invested else 0,
403
+ "risk_distribution": risk_distribution,
404
+ },
405
+ "holdings": [
406
+ {
407
+ key: item[key]
408
+ for key in (
409
+ "symbol", "quantity", "current_price", "pnl", "pnl_percent",
410
+ "recommendation", "confidence", "overall_risk", "trend",
411
+ "sentiment", "agreement", "model_agreement", "reasoning",
412
+ )
413
+ }
414
+ for item in successful_holdings
415
+ ],
416
+ "trend": trends[0] if len(set(trends)) == 1 else "MIXED",
417
+ "sentiment": sentiments[0] if len(set(sentiments)) == 1 else "mixed",
418
+ "recommendation": "; ".join(
419
+ f"{item['symbol']}: {item['recommendation']}" for item in successful_holdings
420
+ ),
421
+ "confidence": round(confidence, 2),
422
+ "overall_score": round(
423
+ sum(item["overall_score"] for item in successful_holdings) / successful, 2
424
+ ),
425
+ "overall_risk": overall_risk,
426
+ "expected_return": (
427
+ sum(expected_returns) / len(expected_returns) if expected_returns else None
428
+ ),
429
+ "agreement": "mixed" if len({item["agreement"] for item in successful_holdings}) > 1
430
+ else successful_holdings[0]["agreement"],
431
+ "model_agreement": all(item["model_agreement"] for item in successful_holdings),
432
+ }
433
+ try:
434
+ ai_report = generate_portfolio_report(analysis=portfolio_analysis)
435
+ except Exception:
436
+ logger.exception("v2.portfolio.explanation_failed", extra={"request_id": _request_id(request)})
437
+
438
  return success(
439
  request,
440
+ {
441
+ "holdings": holdings,
442
+ "count": len(holdings),
443
+ "successful": successful,
444
+ "failed": len(holdings) - successful,
445
+ "ai_report": ai_report.model_dump() if ai_report else None,
446
+ },
447
  "Portfolio analyzed successfully." if successful == len(holdings) else "Portfolio analysis completed with some unavailable holdings.",
448
  )
449
 
backend/portfolio_ai/llm_explainer.py CHANGED
@@ -61,7 +61,7 @@ from backend.utils.logging import redact_sensitive_data
61
  logger = logging.getLogger(__name__)
62
 
63
  MODEL_NAME: str = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
64
- API_KEY: Optional[str] = os.getenv("GEMINI_API_KEY")
65
 
66
  MAX_TOKENS: int = 600
67
 
 
61
  logger = logging.getLogger(__name__)
62
 
63
  MODEL_NAME: str = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
64
+ API_KEY: Optional[str] = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
65
 
66
  MAX_TOKENS: int = 600
67
 
backend/screener_explainer.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Plain-English explanations for deterministic screener predictions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from typing import Any
9
+
10
+ import requests
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def explain_screener_prediction(analysis: dict[str, Any]) -> dict[str, Any]:
16
+ """Explain an existing signal with a dedicated Gemini key, never alter it."""
17
+ api_key = os.getenv("GEMINI_SCREENER_API_KEY")
18
+ if not api_key:
19
+ return _fallback_explanation(analysis, "Dedicated Gemini screener key is not configured.")
20
+
21
+ model = os.getenv("GEMINI_SCREENER_MODEL", "gemini-2.5-flash")
22
+ prompt = _build_prompt(analysis)
23
+ try:
24
+ response = requests.post(
25
+ f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent",
26
+ headers={"x-goog-api-key": api_key},
27
+ json={
28
+ "systemInstruction": {
29
+ "parts": [{"text": _SYSTEM_PROMPT}],
30
+ },
31
+ "contents": [{"role": "user", "parts": [{"text": prompt}]}],
32
+ "generationConfig": {
33
+ "temperature": 0.2,
34
+ "maxOutputTokens": 700,
35
+ "responseMimeType": "application/json",
36
+ },
37
+ },
38
+ timeout=20,
39
+ )
40
+ response.raise_for_status()
41
+ body = response.json()
42
+ text = body["candidates"][0]["content"]["parts"][0]["text"]
43
+ result = _validate_report(json.loads(_strip_code_fence(text)))
44
+ result["recommendations"] = [_fixed_recommendation(analysis)]
45
+ result["is_fallback"] = False
46
+ result["explanation_source"] = "Gemini"
47
+ return result
48
+ except Exception as exc:
49
+ logger.warning(
50
+ "screener.gemini_explanation_failed",
51
+ extra={"symbol": analysis.get("symbol"), "error": str(exc)},
52
+ )
53
+ return _fallback_explanation(analysis, "Gemini explanation is temporarily unavailable.")
54
+
55
+
56
+ _SYSTEM_PROMPT = """You explain an already-computed stock model result for a retail trader.
57
+ Do not perform new analysis, change the signal, confidence, target price, or risk
58
+ rating, and do not add trade instructions or recommend other securities. Use only
59
+ the supplied deterministic data. Explain what the SHAP drivers and indicators
60
+ mean, while distinguishing correlation/context from proven causation. If the data
61
+ does not support a conclusion, say so. Return JSON only with exactly these fields:
62
+ summary (string), strengths (string array), risks (string array), recommendations
63
+ (string array), outlook (string), confidence_note (string)."""
64
+
65
+
66
+ def _build_prompt(analysis: dict[str, Any]) -> str:
67
+ sentiment = analysis.get("sentiment")
68
+ if isinstance(sentiment, dict):
69
+ sentiment = sentiment.get("dominant", sentiment.get("label", "unavailable"))
70
+ expected_return = analysis.get("expected_return")
71
+ payload = {
72
+ "symbol": analysis.get("symbol"),
73
+ "current_price_inr": analysis.get("current_price"),
74
+ "predicted_price_inr": analysis.get("predicted_price"),
75
+ "expected_return_percent": (
76
+ round(float(expected_return) * 100, 2)
77
+ if expected_return is not None
78
+ else None
79
+ ),
80
+ "model_signal": analysis.get("recommendation"),
81
+ "confidence_percent": analysis.get("confidence"),
82
+ "risk_rating": analysis.get("overall_risk"),
83
+ "trend": analysis.get("trend"),
84
+ "sentiment": sentiment,
85
+ "sentiment_score": analysis.get("sentiment_score"),
86
+ "model_agreement": analysis.get("model_agreement"),
87
+ "agreement": analysis.get("agreement"),
88
+ "available_models": analysis.get("explainability", {}).get("available_models", []),
89
+ "technical_indicators": analysis.get("technical_indicators", {}),
90
+ "shap_drivers": analysis.get("shap", {}).get("features", []),
91
+ "deterministic_reasoning": analysis.get("reasoning", []),
92
+ "risk_metrics": analysis.get("risk", {}),
93
+ }
94
+ return "Explain this prediction using only the following JSON data:\n" + json.dumps(
95
+ payload, ensure_ascii=True, allow_nan=False
96
+ )
97
+
98
+
99
+ def _validate_report(data: Any) -> dict[str, Any]:
100
+ if not isinstance(data, dict) or not isinstance(data.get("summary"), str):
101
+ raise ValueError("Gemini returned an invalid explanation schema.")
102
+
103
+ def clean_list(name: str, limit: int) -> list[str]:
104
+ value = data.get(name, [])
105
+ if not isinstance(value, list):
106
+ return []
107
+ return [item.strip()[:400] for item in value if isinstance(item, str) and item.strip()][:limit]
108
+
109
+ return {
110
+ "summary": data["summary"].strip()[:1200],
111
+ "strengths": clean_list("strengths", 4),
112
+ "risks": clean_list("risks", 4),
113
+ "recommendations": clean_list("recommendations", 1),
114
+ "outlook": str(data.get("outlook", "")).strip()[:600],
115
+ "confidence_note": str(data.get("confidence_note", "")).strip()[:600],
116
+ }
117
+
118
+
119
+ def _fixed_recommendation(analysis: dict[str, Any]) -> str:
120
+ symbol = analysis.get("symbol", "This stock")
121
+ recommendation = analysis.get("recommendation", "Unavailable")
122
+ return f"Existing model signal for {symbol}: {recommendation}. Gemini does not change it."
123
+
124
+
125
+ def _fallback_explanation(analysis: dict[str, Any], reason: str) -> dict[str, Any]:
126
+ drivers = [
127
+ str(item).strip()
128
+ for item in analysis.get("reasoning", [])
129
+ if isinstance(item, str) and item.strip()
130
+ ][:3]
131
+ shap_features = analysis.get("shap", {}).get("features", [])
132
+ for feature in shap_features[:3]:
133
+ if isinstance(feature, dict) and feature.get("name"):
134
+ drivers.append(f"SHAP model driver: {feature['name']} (contribution {feature.get('value', 'unavailable')}).")
135
+
136
+ recommendation = analysis.get("recommendation", "unavailable")
137
+ confidence = analysis.get("confidence")
138
+ symbol = analysis.get("symbol", "This stock")
139
+ confidence_text = f" at {confidence}% confidence" if confidence is not None else ""
140
+ if not drivers:
141
+ drivers = ["The deterministic model did not return readable feature-level reasoning."]
142
+
143
+ return {
144
+ "summary": (
145
+ f"{reason} {symbol} has an existing {recommendation} model signal{confidence_text}. "
146
+ "The points below come from the deterministic model output; they do not change its signal."
147
+ ),
148
+ "strengths": drivers[:4],
149
+ "risks": ["This explanation is a deterministic fallback, not a Gemini-generated interpretation."],
150
+ "recommendations": [_fixed_recommendation(analysis)],
151
+ "outlook": f"The model's existing trend assessment is {analysis.get('trend', 'unavailable')}.",
152
+ "confidence_note": (
153
+ f"Model confidence is {confidence}% and agreement is {analysis.get('agreement', 'unavailable')}."
154
+ if confidence is not None
155
+ else "The model did not provide a confidence value."
156
+ ),
157
+ "is_fallback": True,
158
+ "explanation_source": "Deterministic fallback",
159
+ }
160
+
161
+
162
+ def _strip_code_fence(text: str) -> str:
163
+ value = text.strip()
164
+ if value.startswith("```"):
165
+ value = value.removeprefix("```json").removeprefix("```")
166
+ value = value.removesuffix("```")
167
+ return value.strip()
backend/wealth/historical_service.py CHANGED
@@ -103,7 +103,7 @@ class HistoricalService:
103
  self._sentiment = sentiment_provider or self._default_sentiment_provider()
104
  self._fusion = fusion_engine or FusionEngine()
105
 
106
- def analyze(self, symbol: str) -> HistoricalAnalysis:
107
  """Run research, predictions, fusion, and response mapping for a symbol."""
108
  normalized_symbol = self._validate_symbol(symbol)
109
  started_at = time.perf_counter()
@@ -123,7 +123,12 @@ class HistoricalService:
123
  predicted_price=predicted_price,
124
  )
125
  analysis = self._build_analysis(
126
- normalized_symbol, research_data, predictions, sentiment, fusion
 
 
 
 
 
127
  )
128
  except HistoricalServiceError:
129
  raise
@@ -252,6 +257,7 @@ class HistoricalService:
252
  predictions: Mapping[str, Mapping[str, Any]],
253
  sentiment: Mapping[str, Any],
254
  fusion: FusionResult,
 
255
  ) -> HistoricalAnalysis:
256
  lstm = predictions["lstm"]
257
  rf = predictions["random_forest"]
@@ -282,29 +288,30 @@ class HistoricalService:
282
  reasoning=list(fusion.reasoning),
283
  )
284
 
285
- try:
286
- analysis.ai_report = generate_portfolio_report(
287
- analysis=self._analysis_payload(analysis)
288
- )
289
- except Exception as exc:
290
- logger.warning(
291
- "portfolio_ai_report_failed",
292
- extra={"ticker": symbol, "error": str(exc)},
293
- )
294
- analysis.ai_report = PortfolioAIReport(
295
- summary="Portfolio analysis completed using deterministic AI models.",
296
- strengths=[
297
- f"Detected market trend: {analysis.trend}.",
298
- f"Overall sentiment: {analysis.sentiment}.",
299
- ],
300
- risks=[
301
- "AI explanation service unavailable.",
302
- "Only deterministic analysis is shown.",
303
- ],
304
- recommendations=[analysis.recommendation],
305
- outlook="Future outlook follows the deterministic prediction generated by TradeMind.",
306
- confidence_note=f"Model confidence is {analysis.confidence}%.",
307
- )
 
308
 
309
  return analysis
310
 
 
103
  self._sentiment = sentiment_provider or self._default_sentiment_provider()
104
  self._fusion = fusion_engine or FusionEngine()
105
 
106
+ def analyze(self, symbol: str, *, include_explanation: bool = True) -> HistoricalAnalysis:
107
  """Run research, predictions, fusion, and response mapping for a symbol."""
108
  normalized_symbol = self._validate_symbol(symbol)
109
  started_at = time.perf_counter()
 
123
  predicted_price=predicted_price,
124
  )
125
  analysis = self._build_analysis(
126
+ normalized_symbol,
127
+ research_data,
128
+ predictions,
129
+ sentiment,
130
+ fusion,
131
+ include_explanation=include_explanation,
132
  )
133
  except HistoricalServiceError:
134
  raise
 
257
  predictions: Mapping[str, Mapping[str, Any]],
258
  sentiment: Mapping[str, Any],
259
  fusion: FusionResult,
260
+ include_explanation: bool = True,
261
  ) -> HistoricalAnalysis:
262
  lstm = predictions["lstm"]
263
  rf = predictions["random_forest"]
 
288
  reasoning=list(fusion.reasoning),
289
  )
290
 
291
+ if include_explanation:
292
+ try:
293
+ analysis.ai_report = generate_portfolio_report(
294
+ analysis=self._analysis_payload(analysis)
295
+ )
296
+ except Exception as exc:
297
+ logger.warning(
298
+ "portfolio_ai_report_failed",
299
+ extra={"ticker": symbol, "error": str(exc)},
300
+ )
301
+ analysis.ai_report = PortfolioAIReport(
302
+ summary="Portfolio analysis completed using deterministic AI models.",
303
+ strengths=[
304
+ f"Detected market trend: {analysis.trend}.",
305
+ f"Overall sentiment: {analysis.sentiment}.",
306
+ ],
307
+ risks=[
308
+ "AI explanation service unavailable.",
309
+ "Only deterministic analysis is shown.",
310
+ ],
311
+ recommendations=[analysis.recommendation],
312
+ outlook="Future outlook follows the deterministic prediction generated by TradeMind.",
313
+ confidence_note=f"Model confidence is {analysis.confidence}%.",
314
+ )
315
 
316
  return analysis
317
 
frontend/package-lock.json CHANGED
@@ -40,7 +40,7 @@
40
  "cross-env": "^7.0.3"
41
  },
42
  "engines": {
43
- "node": ">=18.0.0 <21.0.0",
44
  "npm": ">=9.0.0"
45
  }
46
  },
 
40
  "cross-env": "^7.0.3"
41
  },
42
  "engines": {
43
+ "node": "24.x",
44
  "npm": ">=9.0.0"
45
  }
46
  },
frontend/package.json CHANGED
@@ -3,7 +3,7 @@
3
  "version": "0.1.0",
4
  "private": true,
5
  "engines": {
6
- "node": ">=18.0.0 <21.0.0",
7
  "npm": ">=9.0.0"
8
  },
9
  "dependencies": {
 
3
  "version": "0.1.0",
4
  "private": true,
5
  "engines": {
6
+ "node": "24.x",
7
  "npm": ">=9.0.0"
8
  },
9
  "dependencies": {
frontend/src/components/cards/SignalCard.jsx CHANGED
@@ -5,6 +5,7 @@ import { Badge } from "../ui/Badge";
5
  import { Tabs } from "../ui/Tabs";
6
  import { MetricCard } from "../ui/MetricCard";
7
  import { PriceAreaChart } from "../charts/PriceAreaChart";
 
8
  import { parseConf } from "../../utils/formatters";
9
  import { SIGNAL_COLORS } from "../../constants/stocks";
10
  import { Sparkles, Brain, AlertTriangle, Activity } from "lucide-react";
@@ -347,6 +348,8 @@ export function SignalCard({ signal, isMobile }) {
347
  border: "1px solid var(--border)",
348
  }}
349
  >
 
 
350
  <div
351
  className="mono"
352
  style={{
 
5
  import { Tabs } from "../ui/Tabs";
6
  import { MetricCard } from "../ui/MetricCard";
7
  import { PriceAreaChart } from "../charts/PriceAreaChart";
8
+ import { ScreenerExplanationPanel } from "../screener/ScreenerExplanationPanel";
9
  import { parseConf } from "../../utils/formatters";
10
  import { SIGNAL_COLORS } from "../../constants/stocks";
11
  import { Sparkles, Brain, AlertTriangle, Activity } from "lucide-react";
 
348
  border: "1px solid var(--border)",
349
  }}
350
  >
351
+ <ScreenerExplanationPanel explanation={signal.screener_explanation} />
352
+
353
  <div
354
  className="mono"
355
  style={{
frontend/src/components/portfolio/AIInvestmentReport.jsx CHANGED
@@ -32,7 +32,7 @@ export function AIInvestmentReport({ report }) {
32
  "LSTM",
33
  "FinBERT",
34
  "Fusion Engine",
35
- "OpenAI Explanation",
36
  ];
37
 
38
  // Requirement 5: Framer Motion animation variants
 
32
  "LSTM",
33
  "FinBERT",
34
  "Fusion Engine",
35
+ isDeterministic ? "Deterministic Fallback" : "Gemini Explanation",
36
  ];
37
 
38
  // Requirement 5: Framer Motion animation variants
frontend/src/components/screener/ScreenerExplanationPanel.jsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { BrainCircuit, Sparkles } from "lucide-react";
3
+
4
+ export function ScreenerExplanationPanel({ explanation }) {
5
+ if (!explanation) return null;
6
+
7
+ const drivers = explanation.strengths || [];
8
+ const risks = explanation.risks || [];
9
+
10
+ return (
11
+ <section className="sc-explanation" aria-label="Prediction explanation">
12
+ <div className="sc-explanation-header">
13
+ <div className="sc-explanation-title">
14
+ <BrainCircuit size={15} color="var(--color-teal)" />
15
+ <span>WHY THE MODEL MADE THIS PREDICTION</span>
16
+ </div>
17
+ <span className={explanation.is_fallback ? "sc-explanation-source is-fallback" : "sc-explanation-source"}>
18
+ {explanation.is_fallback ? "Model data" : <><Sparkles size={12} /> Gemini</>}
19
+ </span>
20
+ </div>
21
+
22
+ <p className="sc-explanation-summary">{explanation.summary}</p>
23
+
24
+ {drivers.length > 0 && (
25
+ <div className="sc-explanation-group">
26
+ <h4>Supporting evidence</h4>
27
+ <ul>{drivers.map((driver, index) => <li key={index}>{driver}</li>)}</ul>
28
+ </div>
29
+ )}
30
+
31
+ {risks.length > 0 && (
32
+ <div className="sc-explanation-group sc-explanation-risks">
33
+ <h4>Limitations and risks</h4>
34
+ <ul>{risks.map((risk, index) => <li key={index}>{risk}</li>)}</ul>
35
+ </div>
36
+ )}
37
+
38
+ {explanation.confidence_note && (
39
+ <p className="sc-explanation-confidence">{explanation.confidence_note}</p>
40
+ )}
41
+ </section>
42
+ );
43
+ }
44
+
45
+ export default ScreenerExplanationPanel;
frontend/src/components/screener/ScreenerStockCard.jsx CHANGED
@@ -15,6 +15,7 @@ import { Card } from "../ui/Card";
15
  import { Badge } from "../ui/Badge";
16
  import { Typography } from "../ui/Typography";
17
  import { parseConf } from "../../utils/formatters";
 
18
 
19
  export function ScreenerStockCard({
20
  stock,
@@ -358,6 +359,8 @@ export function ScreenerStockCard({
358
  </div>
359
  </div>
360
 
 
 
361
  {/* Mini Recharts Sparkline */}
362
  {stock.history?.length > 0 && (
363
  <div style={{ height: "32px", width: "100%" }}>
 
15
  import { Badge } from "../ui/Badge";
16
  import { Typography } from "../ui/Typography";
17
  import { parseConf } from "../../utils/formatters";
18
+ import { ScreenerExplanationPanel } from "./ScreenerExplanationPanel";
19
 
20
  export function ScreenerStockCard({
21
  stock,
 
359
  </div>
360
  </div>
361
 
362
+ <ScreenerExplanationPanel explanation={stock.screener_explanation} />
363
+
364
  {/* Mini Recharts Sparkline */}
365
  {stock.history?.length > 0 && (
366
  <div style={{ height: "32px", width: "100%" }}>
frontend/src/pages/Dashboard/DashboardPage.jsx CHANGED
@@ -1,6 +1,5 @@
1
  import React, { useState, useEffect } from "react";
2
  import { useNavigate, useOutletContext } from "react-router-dom";
3
- import CountUp from "react-countup";
4
  import {
5
  Sparkles,
6
  ArrowRight,
@@ -8,7 +7,7 @@ import {
8
  Zap,
9
  } from "lucide-react";
10
  import { PageTransition } from "../../components/animations/PageTransition";
11
- import { Card, MetricCard } from "../../components/ui/Card";
12
  import { Button } from "../../components/ui/Button";
13
  import { Badge } from "../../components/ui/Badge";
14
  import { DashboardSkeleton } from "../../components/dashboard/DashboardSkeleton";
@@ -116,53 +115,6 @@ export function DashboardPage() {
116
  </div>
117
  </section>
118
 
119
- {/* SECTION 2: QUICK METRICS */}
120
- <section style={{ marginBottom: "28px" }}>
121
- <div className="db-metrics-grid">
122
- <MetricCard
123
- label="PORTFOLIO VALUE"
124
- value={<>₹<CountUp end={425800} duration={1.5} separator="," /></>}
125
- sub="₹4.25 Lakh Total"
126
- color="var(--text-primary)"
127
- />
128
-
129
- <MetricCard
130
- label="TODAY'S P/L"
131
- value={<>+₹<CountUp end={6420} duration={1.5} separator="," /> (+1.53%)</>}
132
- sub="Net Daily Gain"
133
- color="var(--success)"
134
- />
135
-
136
- <MetricCard
137
- label="PORTFOLIO HEALTH"
138
- value={<><CountUp end={78} duration={1.5} />/100</>}
139
- sub="Optimal Allocation"
140
- color="var(--color-teal)"
141
- />
142
-
143
- <MetricCard
144
- label="AI CONFIDENCE"
145
- value={<><CountUp end={92} duration={1.5} />%</>}
146
- sub="Multi-Model Agreement"
147
- color="var(--color-teal)"
148
- />
149
-
150
- <MetricCard
151
- label="EXPECTED RETURN"
152
- value={<><CountUp end={14.2} decimals={1} duration={1.5} />%</>}
153
- sub="Annualized Forecast"
154
- color="var(--accent-blue)"
155
- />
156
-
157
- <MetricCard
158
- label="RISK PROFILE"
159
- value="LOW-MED"
160
- sub="Balanced Risk Index"
161
- color="var(--color-gold)"
162
- />
163
- </div>
164
- </section>
165
-
166
  {/* SECTION 3: MARKET OVERVIEW */}
167
  <MarketOverviewSection commodities={commodities} marketQuotes={marketQuotes} />
168
 
 
1
  import React, { useState, useEffect } from "react";
2
  import { useNavigate, useOutletContext } from "react-router-dom";
 
3
  import {
4
  Sparkles,
5
  ArrowRight,
 
7
  Zap,
8
  } from "lucide-react";
9
  import { PageTransition } from "../../components/animations/PageTransition";
10
+ import { Card } from "../../components/ui/Card";
11
  import { Button } from "../../components/ui/Button";
12
  import { Badge } from "../../components/ui/Badge";
13
  import { DashboardSkeleton } from "../../components/dashboard/DashboardSkeleton";
 
115
  </div>
116
  </section>
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  {/* SECTION 3: MARKET OVERVIEW */}
119
  <MarketOverviewSection commodities={commodities} marketQuotes={marketQuotes} />
120
 
frontend/src/pages/PortfolioDoctor/PortfolioDoctorPage.jsx CHANGED
@@ -74,36 +74,43 @@ function computeReport(rawHoldings, requestId, aiReport) {
74
  : Math.round(100 / n),
75
  }));
76
 
77
- // ── Risk ──
78
- const riskCounts = { HIGH: 0, MEDIUM: 0, LOW: 0 };
79
- holdingsWithWeight.forEach((h) => {
80
- const r = (h.overall_risk || "").toUpperCase();
81
- if (r.includes("HIGH")) riskCounts.HIGH++;
82
- else if (r.includes("MEDIUM")) riskCounts.MEDIUM++;
83
- else riskCounts.LOW++;
84
  });
85
- const overallRisk = riskCounts.HIGH > 0 ? "HIGH" : riskCounts.MEDIUM > 0 ? "MEDIUM" : "LOW";
86
-
87
- // ── AI Confidence — backend sends 0–100 or 0–1; normalise to 0–100 ──
88
- const rawConf = rawHoldings.reduce((s, h) => s + safeNum(h.confidence), 0) / n;
89
- // If average > 1 it's already in percent; otherwise multiply by 100
90
- const aiConfidence = clamp100(rawConf > 1 ? rawConf : rawConf * 100);
 
 
 
 
 
 
 
91
 
92
  // ── Diversification (Herfindahl) ──
93
  const hhi = holdingsWithWeight.reduce((s, h) => s + Math.pow(h.weight / 100, 2), 0);
94
  const diversificationScore = Math.max(20, Math.round((1 - hhi) * 100));
95
 
96
  // ── Expected return & volatility from backend per-holding data ──
97
- const validReturns = rawHoldings
98
- .map((h) => safeNum(h.expected_return))
99
- .filter((v) => v !== 0);
100
- // expected_return from backend is a fraction (e.g. 0.08 = 8%)
101
- const avgExpectedReturn = validReturns.length > 0
102
- ? +((validReturns.reduce((s, v) => s + v, 0) / validReturns.length) * 100).toFixed(1)
103
- : (overallRisk === "HIGH" ? 14.5 : overallRisk === "MEDIUM" ? 11.2 : 8.5);
104
-
105
- const expectedVolatility = overallRisk === "HIGH" ? 22.4
106
- : overallRisk === "MEDIUM" ? 14.8 : 9.6;
 
107
 
108
  // ── Health Score — composite of 5 factors, each 0–20 ──
109
  // 1. AI Confidence component (0–20)
@@ -113,16 +120,19 @@ function computeReport(rawHoldings, requestId, aiReport) {
113
  // 3. Risk component: LOW=20, MEDIUM=12, HIGH=4
114
  const riskComponent = overallRisk === "LOW" ? 20 : overallRisk === "MEDIUM" ? 12 : 4;
115
  // 4. Return component: positive return up to 20%, capped at 20
116
- const retComponent = Math.min(20, Math.max(0, avgExpectedReturn));
117
  // 5. Model score component (0–20)
118
- const avgModelScore = rawHoldings.reduce((s, h) => s + safeNum(h.overall_score, 5), 0) / n;
 
 
119
  const scoreComponent = Math.min(20, (avgModelScore / 10) * 20);
120
 
121
  const rawHealth = confComponent + divComponent + riskComponent + retComponent + scoreComponent;
122
  const healthScore = clamp100(rawHealth);
123
 
124
  // ── Market Outlook — derived from risk + return, not hardcoded ──
125
- const marketOutlook = avgExpectedReturn >= 10 && overallRisk !== "HIGH" ? "BULLISH"
 
126
  : avgExpectedReturn <= 0 || overallRisk === "HIGH" ? "BEARISH"
127
  : "NEUTRAL";
128
 
@@ -135,7 +145,7 @@ function computeReport(rawHoldings, requestId, aiReport) {
135
  healthScore,
136
  overallRisk,
137
  expectedReturn: avgExpectedReturn,
138
- expectedVolatility,
139
  diversificationScore,
140
  aiConfidence,
141
  marketOutlook,
@@ -144,6 +154,36 @@ function computeReport(rawHoldings, requestId, aiReport) {
144
  };
145
  }
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  function applyMarketQuotes(report, quotes) {
148
  const holdings = report.holdings.map((holding) => {
149
  const symbol = (holding.symbol || "").replace(/\.NS$/i, "").toUpperCase();
@@ -165,26 +205,17 @@ function applyMarketQuotes(report, quotes) {
165
  invested_amount: investedAmount,
166
  pnl,
167
  pnl_percent: investedAmount > 0 ? +((pnl / investedAmount) * 100).toFixed(2) : 0,
 
 
 
168
  market_price_as_of: quote.as_of,
169
  };
170
  });
171
 
172
- const totalInvested = holdings.reduce((sum, holding) => sum + safeNum(holding.invested_amount), 0);
173
- const totalMarketValue = holdings.reduce((sum, holding) => sum + safeNum(holding.market_value), 0);
174
- const totalPnl = totalMarketValue - totalInvested;
175
-
176
  return {
177
- ...report,
178
- holdings: holdings.map((holding) => ({
179
- ...holding,
180
- weight: totalMarketValue > 0
181
- ? Math.round((safeNum(holding.market_value) / totalMarketValue) * 100)
182
- : holding.weight,
183
- })),
184
- totalInvested,
185
- totalMarketValue,
186
- totalPnl,
187
- totalPnlPct: totalInvested > 0 ? +((totalPnl / totalInvested) * 100).toFixed(1) : 0,
188
  };
189
  }
190
 
@@ -241,9 +272,9 @@ export function PortfolioDoctorPage() {
241
  setLoaderStep(0);
242
  setErrorDetails(null);
243
  const demoHoldings = [
244
- { symbol: "RELIANCE.NS", quantity: 10, buy_price: 2450, current_price: 2540, invested_amount: 24500, market_value: 25400, pnl: 900, pnl_percent: 3.67, overall_risk: "LOW", overall_score: 8.4, recommendation: "BUY", confidence: 0.88, trend: "BULLISH", reasoning: ["Momentum remains constructive after a healthy pullback.", "Diversification is strong across sector leaders."] },
245
- { symbol: "TCS.NS", quantity: 8, buy_price: 3850, current_price: 3720, invested_amount: 30800, market_value: 29760, pnl: -1040, pnl_percent: -3.38, overall_risk: "LOW", overall_score: 7.8, recommendation: "HOLD", confidence: 0.79, trend: "NEUTRAL", reasoning: ["Valuation remains stable while near-term growth is moderate.", "The position is suitable for steady income-oriented exposure."] },
246
- { symbol: "HDFCBANK.NS", quantity: 12, buy_price: 1680, current_price: 1745, invested_amount: 20160, market_value: 20940, pnl: 780, pnl_percent: 3.87, overall_risk: "MEDIUM", overall_score: 8.1, recommendation: "ACCUMULATE", confidence: 0.84, trend: "BULLISH", reasoning: ["Banking sector momentum is positive with improving breadth.", "The allocation remains within a controlled risk band."] },
247
  ];
248
  const totalInvested = demoHoldings.reduce((sum, h) => sum + (h.invested_amount || 0), 0);
249
  const totalMarketValue = demoHoldings.reduce((sum, h) => sum + (h.market_value || 0), 0);
@@ -500,9 +531,9 @@ export function PortfolioDoctorPage() {
500
  {/* EXPECTED RETURN */}
501
  <MetricCard
502
  label="EXPECTED RETURN"
503
- value={<><CountUp end={reportData.expectedReturn} decimals={1} duration={1.5} />%</>}
504
- sub="Avg annualised estimate"
505
- tooltip="Average expected return across holdings, derived from LSTM price projections."
506
  color={
507
  reportData.expectedReturn >= 10 ? "var(--success)"
508
  : reportData.expectedReturn >= 0 ? "var(--color-teal)"
@@ -513,9 +544,9 @@ export function PortfolioDoctorPage() {
513
  {/* EXPECTED VOLATILITY */}
514
  <MetricCard
515
  label="VOLATILITY"
516
- value={<><CountUp end={reportData.expectedVolatility} decimals={1} duration={1.5} />%</>}
517
- sub="Historical variance"
518
- tooltip="Estimated annualised price volatility. Higher = wider price swings."
519
  color={
520
  reportData.expectedVolatility <= 12 ? "var(--success)"
521
  : reportData.expectedVolatility <= 20 ? "var(--warning)"
@@ -540,8 +571,8 @@ export function PortfolioDoctorPage() {
540
  <MetricCard
541
  label="AI CONFIDENCE"
542
  value={<><CountUp end={reportData.aiConfidence} duration={1.5} />%</>}
543
- sub="Model agreement"
544
- tooltip="Average calibrated confidence across RF, LSTM, and FinBERT models. Always 0–100%."
545
  color={
546
  reportData.aiConfidence >= 70 ? "var(--color-teal)"
547
  : reportData.aiConfidence >= 50 ? "var(--warning)"
 
74
  : Math.round(100 / n),
75
  }));
76
 
77
+ // ── Risk distribution by current portfolio weight ──
78
+ const riskWeights = { HIGH: 0, MEDIUM: 0, LOW: 0 };
79
+ holdingsWithWeight.forEach((holding) => {
80
+ const risk = (holding.overall_risk || "MEDIUM").toUpperCase();
81
+ const tier = risk.includes("HIGH") ? "HIGH" : risk.includes("LOW") ? "LOW" : "MEDIUM";
82
+ riskWeights[tier] += safeNum(holding.weight);
 
83
  });
84
+ const riskPriority = { HIGH: 3, MEDIUM: 2, LOW: 1 };
85
+ const overallRisk = Object.keys(riskWeights).sort((left, right) =>
86
+ riskWeights[right] - riskWeights[left] || riskPriority[right] - riskPriority[left]
87
+ )[0];
88
+
89
+ // Confidence is supplied as either 0–1 or 0–100 by model responses.
90
+ const confidenceWeight = (holding) => totalMarketValue > 0
91
+ ? safeNum(holding.market_value) / totalMarketValue
92
+ : 1 / n;
93
+ const aiConfidence = clamp100(rawHoldings.reduce((sum, holding) => {
94
+ const confidence = safeNum(holding.confidence);
95
+ return sum + (confidence > 1 ? confidence : confidence * 100) * confidenceWeight(holding);
96
+ }, 0));
97
 
98
  // ── Diversification (Herfindahl) ──
99
  const hhi = holdingsWithWeight.reduce((s, h) => s + Math.pow(h.weight / 100, 2), 0);
100
  const diversificationScore = Math.max(20, Math.round((1 - hhi) * 100));
101
 
102
  // ── Expected return & volatility from backend per-holding data ──
103
+ const validReturns = rawHoldings.filter((holding) =>
104
+ holding.expected_return != null && Number.isFinite(Number(holding.expected_return))
105
+ );
106
+ const returnWeightTotal = validReturns.reduce((sum, holding) =>
107
+ sum + confidenceWeight(holding), 0
108
+ );
109
+ const avgExpectedReturn = validReturns.length && returnWeightTotal > 0
110
+ ? +((validReturns.reduce((sum, holding) =>
111
+ sum + safeNum(holding.expected_return) * confidenceWeight(holding), 0
112
+ ) / returnWeightTotal) * 100).toFixed(1)
113
+ : null;
114
 
115
  // ── Health Score — composite of 5 factors, each 0–20 ──
116
  // 1. AI Confidence component (0–20)
 
120
  // 3. Risk component: LOW=20, MEDIUM=12, HIGH=4
121
  const riskComponent = overallRisk === "LOW" ? 20 : overallRisk === "MEDIUM" ? 12 : 4;
122
  // 4. Return component: positive return up to 20%, capped at 20
123
+ const retComponent = avgExpectedReturn == null ? 0 : Math.min(20, Math.max(0, avgExpectedReturn));
124
  // 5. Model score component (0–20)
125
+ const avgModelScore = rawHoldings.reduce((sum, holding) =>
126
+ sum + safeNum(holding.overall_score, 5) * confidenceWeight(holding), 0
127
+ );
128
  const scoreComponent = Math.min(20, (avgModelScore / 10) * 20);
129
 
130
  const rawHealth = confComponent + divComponent + riskComponent + retComponent + scoreComponent;
131
  const healthScore = clamp100(rawHealth);
132
 
133
  // ── Market Outlook — derived from risk + return, not hardcoded ──
134
+ const marketOutlook = avgExpectedReturn == null ? "UNAVAILABLE"
135
+ : avgExpectedReturn >= 10 && overallRisk !== "HIGH" ? "BULLISH"
136
  : avgExpectedReturn <= 0 || overallRisk === "HIGH" ? "BEARISH"
137
  : "NEUTRAL";
138
 
 
145
  healthScore,
146
  overallRisk,
147
  expectedReturn: avgExpectedReturn,
148
+ expectedVolatility: null,
149
  diversificationScore,
150
  aiConfidence,
151
  marketOutlook,
 
154
  };
155
  }
156
 
157
+ function calculatePortfolioVolatility(holdings, quotes) {
158
+ const positions = holdings.map((holding) => {
159
+ const symbol = (holding.symbol || "").replace(/\.NS$/i, "").toUpperCase();
160
+ const history = quotes[symbol]?.history || [];
161
+ return {
162
+ quantity: safeNum(holding.quantity),
163
+ prices: new Map(history
164
+ .filter((point) => point?.date && Number.isFinite(Number(point.price)) && Number(point.price) > 0)
165
+ .map((point) => [point.date, Number(point.price)])),
166
+ };
167
+ });
168
+
169
+ if (!positions.length || positions.some((position) => position.prices.size < 2)) return null;
170
+
171
+ const dates = [...positions[0].prices.keys()]
172
+ .filter((date) => positions.every((position) => position.prices.has(date)))
173
+ .sort();
174
+ const portfolioValues = dates.map((date) => positions.reduce(
175
+ (total, position) => total + position.quantity * position.prices.get(date), 0
176
+ ));
177
+ const returns = portfolioValues.slice(1).map((value, index) =>
178
+ value / portfolioValues[index] - 1
179
+ ).filter(Number.isFinite);
180
+
181
+ if (returns.length < 2) return null;
182
+ const mean = returns.reduce((sum, value) => sum + value, 0) / returns.length;
183
+ const variance = returns.reduce((sum, value) => sum + ((value - mean) ** 2), 0) / (returns.length - 1);
184
+ return +(Math.sqrt(variance * 252) * 100).toFixed(1);
185
+ }
186
+
187
  function applyMarketQuotes(report, quotes) {
188
  const holdings = report.holdings.map((holding) => {
189
  const symbol = (holding.symbol || "").replace(/\.NS$/i, "").toUpperCase();
 
205
  invested_amount: investedAmount,
206
  pnl,
207
  pnl_percent: investedAmount > 0 ? +((pnl / investedAmount) * 100).toFixed(2) : 0,
208
+ expected_return: safeNum(holding.predicted_price) > 0
209
+ ? (safeNum(holding.predicted_price) / Number(quote.price)) - 1
210
+ : holding.expected_return,
211
  market_price_as_of: quote.as_of,
212
  };
213
  });
214
 
215
+ const updatedReport = computeReport(holdings, report.requestId, report.ai_report);
 
 
 
216
  return {
217
+ ...updatedReport,
218
+ expectedVolatility: calculatePortfolioVolatility(holdings, quotes),
 
 
 
 
 
 
 
 
 
219
  };
220
  }
221
 
 
272
  setLoaderStep(0);
273
  setErrorDetails(null);
274
  const demoHoldings = [
275
+ { symbol: "RELIANCE.NS", quantity: 10, buy_price: 2450, current_price: 2540, invested_amount: 24500, market_value: 25400, pnl: 900, pnl_percent: 3.67, overall_risk: "LOW", overall_score: 8.4, recommendation: "BUY", confidence: 0.88, expected_return: 0.06, predicted_price: 2692.4, trend: "BULLISH", reasoning: ["Momentum remains constructive after a healthy pullback.", "Diversification is strong across sector leaders."] },
276
+ { symbol: "TCS.NS", quantity: 8, buy_price: 3850, current_price: 3720, invested_amount: 30800, market_value: 29760, pnl: -1040, pnl_percent: -3.38, overall_risk: "LOW", overall_score: 7.8, recommendation: "HOLD", confidence: 0.79, expected_return: 0.03, predicted_price: 3831.6, trend: "NEUTRAL", reasoning: ["Valuation remains stable while near-term growth is moderate.", "The position is suitable for steady income-oriented exposure."] },
277
+ { symbol: "HDFCBANK.NS", quantity: 12, buy_price: 1680, current_price: 1745, invested_amount: 20160, market_value: 20940, pnl: 780, pnl_percent: 3.87, overall_risk: "MEDIUM", overall_score: 8.1, recommendation: "ACCUMULATE", confidence: 0.84, expected_return: 0.08, predicted_price: 1884.6, trend: "BULLISH", reasoning: ["Banking sector momentum is positive with improving breadth.", "The allocation remains within a controlled risk band."] },
278
  ];
279
  const totalInvested = demoHoldings.reduce((sum, h) => sum + (h.invested_amount || 0), 0);
280
  const totalMarketValue = demoHoldings.reduce((sum, h) => sum + (h.market_value || 0), 0);
 
531
  {/* EXPECTED RETURN */}
532
  <MetricCard
533
  label="EXPECTED RETURN"
534
+ value={reportData.expectedReturn == null ? "—" : <><CountUp end={reportData.expectedReturn} decimals={1} duration={1.5} />%</>}
535
+ sub="Weighted model estimate"
536
+ tooltip="Market-value-weighted expected return from the per-holding model outputs, refreshed against current Yahoo prices where a price target is available."
537
  color={
538
  reportData.expectedReturn >= 10 ? "var(--success)"
539
  : reportData.expectedReturn >= 0 ? "var(--color-teal)"
 
544
  {/* EXPECTED VOLATILITY */}
545
  <MetricCard
546
  label="VOLATILITY"
547
+ value={reportData.expectedVolatility == null ? "—" : <><CountUp end={reportData.expectedVolatility} decimals={1} duration={1.5} />%</>}
548
+ sub="Annualized historical volatility"
549
+ tooltip="Annualized standard deviation of daily Yahoo Finance portfolio returns. Requires at least three aligned daily observations."
550
  color={
551
  reportData.expectedVolatility <= 12 ? "var(--success)"
552
  : reportData.expectedVolatility <= 20 ? "var(--warning)"
 
571
  <MetricCard
572
  label="AI CONFIDENCE"
573
  value={<><CountUp end={reportData.aiConfidence} duration={1.5} />%</>}
574
+ sub="Position-weighted confidence"
575
+ tooltip="Model confidence averaged across holdings using each holding's current portfolio weight."
576
  color={
577
  reportData.aiConfidence >= 70 ? "var(--color-teal)"
578
  : reportData.aiConfidence >= 50 ? "var(--warning)"
frontend/src/pages/Screener/ScreenerPage.jsx CHANGED
@@ -126,7 +126,7 @@ export function ScreenerPage() {
126
  const interval = startLoader();
127
 
128
  try {
129
- const analysis = await fetchFullStockSignal(symbol);
130
  stopLoader(interval);
131
  await new Promise((r) => setTimeout(r, 300)); // let loader reach 100%
132
 
@@ -193,7 +193,7 @@ export function ScreenerPage() {
193
  for (const m of matches) {
194
  const sym = (m.symbol || "").replace(".NS", "");
195
  try {
196
- const analysis = await fetchFullStockSignal(sym);
197
  if (analysis) cards.push(analysis);
198
  } catch {
199
  // Match couldn't be fully analysed — skip silently
 
126
  const interval = startLoader();
127
 
128
  try {
129
+ const analysis = await fetchFullStockSignal(symbol, { explainScreener: true });
130
  stopLoader(interval);
131
  await new Promise((r) => setTimeout(r, 300)); // let loader reach 100%
132
 
 
193
  for (const m of matches) {
194
  const sym = (m.symbol || "").replace(".NS", "");
195
  try {
196
+ const analysis = await fetchFullStockSignal(sym, { explainScreener: true });
197
  if (analysis) cards.push(analysis);
198
  } catch {
199
  // Match couldn't be fully analysed — skip silently
frontend/src/services/marketService.js CHANGED
@@ -4,8 +4,11 @@ import { ENDPOINTS } from "../constants/api";
4
  // Unwrap the v2 success envelope { success, data, ... } -> data
5
  const unwrapV2 = (response) => response.data?.data ?? response.data;
6
 
7
- export const fetchFullStockSignal = async (symbol) => {
8
- const response = await apiClient.get(ENDPOINTS.V2_SIGNAL_FULL(symbol), { timeout: 120000 });
 
 
 
9
  return unwrapV2(response);
10
  };
11
 
 
4
  // Unwrap the v2 success envelope { success, data, ... } -> data
5
  const unwrapV2 = (response) => response.data?.data ?? response.data;
6
 
7
+ export const fetchFullStockSignal = async (symbol, { explainScreener = false } = {}) => {
8
+ const response = await apiClient.get(ENDPOINTS.V2_SIGNAL_FULL(symbol), {
9
+ timeout: 120000,
10
+ params: explainScreener ? { explain_screener: true } : undefined,
11
+ });
12
  return unwrapV2(response);
13
  };
14
 
frontend/src/styles/screener.css CHANGED
@@ -126,3 +126,89 @@
126
  color: var(--text-secondary);
127
  border: 1px solid var(--border-subtle);
128
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  color: var(--text-secondary);
127
  border: 1px solid var(--border-subtle);
128
  }
129
+
130
+ .sc-explanation {
131
+ padding: 14px 0;
132
+ border-top: 1px solid var(--border-subtle);
133
+ border-bottom: 1px solid var(--border-subtle);
134
+ color: var(--text-primary);
135
+ }
136
+
137
+ .sc-explanation-header,
138
+ .sc-explanation-title {
139
+ display: flex;
140
+ align-items: center;
141
+ gap: 8px;
142
+ }
143
+
144
+ .sc-explanation-header {
145
+ justify-content: space-between;
146
+ }
147
+
148
+ .sc-explanation-title {
149
+ font-size: 13px;
150
+ font-weight: 800;
151
+ color: var(--color-teal);
152
+ }
153
+
154
+ .sc-explanation-source {
155
+ display: inline-flex;
156
+ align-items: center;
157
+ gap: 4px;
158
+ color: var(--color-teal);
159
+ font-size: 12px;
160
+ font-weight: 700;
161
+ }
162
+
163
+ .sc-explanation-source.is-fallback {
164
+ color: var(--warning);
165
+ }
166
+
167
+ .sc-explanation-summary {
168
+ margin: 10px 0;
169
+ color: var(--text-primary);
170
+ font-size: 14px;
171
+ line-height: 1.55;
172
+ }
173
+
174
+ .sc-explanation-group {
175
+ margin-top: 10px;
176
+ }
177
+
178
+ .sc-explanation-group h4 {
179
+ margin: 0 0 5px;
180
+ color: var(--text-secondary);
181
+ font-size: 12px;
182
+ font-weight: 700;
183
+ }
184
+
185
+ .sc-explanation-group ul {
186
+ display: grid;
187
+ gap: 5px;
188
+ margin: 0;
189
+ padding-left: 18px;
190
+ color: var(--text-secondary);
191
+ font-size: 13px;
192
+ line-height: 1.45;
193
+ }
194
+
195
+ .sc-explanation-risks li::marker {
196
+ color: var(--warning);
197
+ }
198
+
199
+ .sc-explanation-confidence {
200
+ margin: 10px 0 0;
201
+ color: var(--text-muted);
202
+ font-size: 12px;
203
+ line-height: 1.45;
204
+ }
205
+
206
+ @media (max-width: 480px) {
207
+ .sc-explanation-title {
208
+ font-size: 12px;
209
+ }
210
+
211
+ .sc-explanation-summary {
212
+ font-size: 13px;
213
+ }
214
+ }