Add WhatsApp multi-user broadcast system
Browse files- README.md +2 -0
- backend/api_v2.py +81 -5
- backend/portfolio_ai/llm_explainer.py +1 -1
- backend/screener_explainer.py +167 -0
- backend/wealth/historical_service.py +32 -25
- frontend/package-lock.json +1 -1
- frontend/package.json +1 -1
- frontend/src/components/cards/SignalCard.jsx +3 -0
- frontend/src/components/portfolio/AIInvestmentReport.jsx +1 -1
- frontend/src/components/screener/ScreenerExplanationPanel.jsx +45 -0
- frontend/src/components/screener/ScreenerStockCard.jsx +3 -0
- frontend/src/pages/Dashboard/DashboardPage.jsx +1 -49
- frontend/src/pages/PortfolioDoctor/PortfolioDoctorPage.jsx +84 -53
- frontend/src/pages/Screener/ScreenerPage.jsx +2 -2
- frontend/src/services/marketService.js +5 -2
- frontend/src/styles/screener.css +86 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
return success(
|
| 369 |
request,
|
| 370 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 286 |
-
|
| 287 |
-
analysis
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 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": "
|
| 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": "
|
| 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 |
-
"
|
| 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
|
| 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
|
| 79 |
-
holdingsWithWeight.forEach((
|
| 80 |
-
const
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
else riskCounts.LOW++;
|
| 84 |
});
|
| 85 |
-
const
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
.
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
| 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((
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
| 173 |
-
const totalMarketValue = holdings.reduce((sum, holding) => sum + safeNum(holding.market_value), 0);
|
| 174 |
-
const totalPnl = totalMarketValue - totalInvested;
|
| 175 |
-
|
| 176 |
return {
|
| 177 |
-
...
|
| 178 |
-
|
| 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="
|
| 505 |
-
tooltip="
|
| 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="
|
| 518 |
-
tooltip="
|
| 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="
|
| 544 |
-
tooltip="
|
| 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), {
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|