Spaces:
Running
Running
Commit Β·
c81e8a5
1
Parent(s): 2ac2c4e
addes more section , improvments , scalability
Browse files- backend/app/config.py +7 -3
- backend/app/database.py +6 -2
- backend/app/main.py +2 -1
- backend/app/routers/copilot.py +55 -0
- backend/app/routers/holdings.py +157 -9
- backend/app/services/ai_research/copilot.py +226 -0
- backend/app/services/analytics/attribution.py +230 -0
- backend/app/services/analytics/bias_detector.py +242 -0
- backend/app/services/analytics/health_score.py +258 -0
- backend/app/services/analytics/portfolio_dna.py +246 -0
- backend/app/services/backtest/engine.py +17 -10
- backend/app/services/portfolio/engine.py +39 -17
- backend/app/services/risk/crisis_replay.py +267 -0
- backend/app/services/risk/monte_carlo.py +169 -0
- backend/app/services/risk/options_calculator.py +248 -19
- backend/app/services/risk/stress_test.py +67 -7
- backend/app/services/sentiment/engine.py +90 -9
- frontend/src/App.tsx +21 -0
- frontend/src/api/client.ts +16 -0
- frontend/src/components/Sidebar.tsx +28 -18
- frontend/src/index.css +1646 -90
- frontend/src/pages/BiasDetector.tsx +155 -0
- frontend/src/pages/Copilot.tsx +160 -0
- frontend/src/pages/CrisisReplay.tsx +226 -0
- frontend/src/pages/PortfolioDNA.tsx +218 -0
- frontend/src/pages/PortfolioHealth.tsx +216 -0
backend/app/config.py
CHANGED
|
@@ -31,9 +31,9 @@ class Settings(BaseSettings):
|
|
| 31 |
|
| 32 |
# ββ App ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
app_name: str = "QuantHedge"
|
| 34 |
-
app_version: str = "
|
| 35 |
debug: bool = False
|
| 36 |
-
cors_origins: str = '["
|
| 37 |
|
| 38 |
@property
|
| 39 |
def cors_origin_list(self) -> List[str]:
|
|
@@ -43,7 +43,7 @@ class Settings(BaseSettings):
|
|
| 43 |
return ["http://localhost:5173"]
|
| 44 |
|
| 45 |
# ββ Database βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
-
database_url: str = "
|
| 47 |
|
| 48 |
# ββ Redis ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
redis_url: str = "redis://localhost:6379/0"
|
|
@@ -62,6 +62,10 @@ class Settings(BaseSettings):
|
|
| 62 |
news_api_key: str = ""
|
| 63 |
groq_api_key: str = ""
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
@lru_cache()
|
| 67 |
def get_settings() -> Settings:
|
|
|
|
| 31 |
|
| 32 |
# ββ App ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
app_name: str = "QuantHedge"
|
| 34 |
+
app_version: str = "2.0.0"
|
| 35 |
debug: bool = False
|
| 36 |
+
cors_origins: str = '["http://localhost:5173"]'
|
| 37 |
|
| 38 |
@property
|
| 39 |
def cors_origin_list(self) -> List[str]:
|
|
|
|
| 43 |
return ["http://localhost:5173"]
|
| 44 |
|
| 45 |
# ββ Database βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
database_url: str = "sqlite+aiosqlite:///./quanthedge.db"
|
| 47 |
|
| 48 |
# ββ Redis ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
redis_url: str = "redis://localhost:6379/0"
|
|
|
|
| 62 |
news_api_key: str = ""
|
| 63 |
groq_api_key: str = ""
|
| 64 |
|
| 65 |
+
# ββ Quantitative Constants βββββββββββββββββββββββββββββββββββββββββββ
|
| 66 |
+
risk_free_rate: float = 0.04 # 4% β single source of truth
|
| 67 |
+
trading_days_per_year: int = 252
|
| 68 |
+
|
| 69 |
|
| 70 |
@lru_cache()
|
| 71 |
def get_settings() -> Settings:
|
backend/app/database.py
CHANGED
|
@@ -55,8 +55,12 @@ else:
|
|
| 55 |
if _needs_ssl:
|
| 56 |
import ssl as _ssl
|
| 57 |
_ssl_ctx = _ssl.create_default_context()
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
_connect_args["ssl"] = _ssl_ctx
|
| 61 |
|
| 62 |
engine = create_async_engine(
|
|
|
|
| 55 |
if _needs_ssl:
|
| 56 |
import ssl as _ssl
|
| 57 |
_ssl_ctx = _ssl.create_default_context()
|
| 58 |
+
# Use proper certificate verification in production
|
| 59 |
+
# Set QH_SSL_VERIFY=0 env var to disable for self-signed certs
|
| 60 |
+
import os
|
| 61 |
+
if os.environ.get("QH_SSL_VERIFY", "1") == "0":
|
| 62 |
+
_ssl_ctx.check_hostname = False
|
| 63 |
+
_ssl_ctx.verify_mode = _ssl.CERT_NONE
|
| 64 |
_connect_args["ssl"] = _ssl_ctx
|
| 65 |
|
| 66 |
engine = create_async_engine(
|
backend/app/main.py
CHANGED
|
@@ -114,7 +114,7 @@ def create_app() -> FastAPI:
|
|
| 114 |
)
|
| 115 |
|
| 116 |
# Mount routers
|
| 117 |
-
from app.routers import auth, backtests, calendar, data, holdings, marketplace, ml, portfolios, quant, research, sentiment, strategies
|
| 118 |
|
| 119 |
app.include_router(auth.router, prefix="/api")
|
| 120 |
app.include_router(data.router, prefix="/api")
|
|
@@ -128,6 +128,7 @@ def create_app() -> FastAPI:
|
|
| 128 |
app.include_router(holdings.router)
|
| 129 |
app.include_router(sentiment.router)
|
| 130 |
app.include_router(calendar.router)
|
|
|
|
| 131 |
|
| 132 |
|
| 133 |
# Health check
|
|
|
|
| 114 |
)
|
| 115 |
|
| 116 |
# Mount routers
|
| 117 |
+
from app.routers import auth, backtests, calendar, copilot, data, holdings, marketplace, ml, portfolios, quant, research, sentiment, strategies
|
| 118 |
|
| 119 |
app.include_router(auth.router, prefix="/api")
|
| 120 |
app.include_router(data.router, prefix="/api")
|
|
|
|
| 128 |
app.include_router(holdings.router)
|
| 129 |
app.include_router(sentiment.router)
|
| 130 |
app.include_router(calendar.router)
|
| 131 |
+
app.include_router(copilot.router)
|
| 132 |
|
| 133 |
|
| 134 |
# Health check
|
backend/app/routers/copilot.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
HedgeAIRouter.
|
| 3 |
+
|
| 4 |
+
Natural language chat endpoint for the QuantHedge Copilot.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 12 |
+
from pydantic import BaseModel, Field
|
| 13 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 14 |
+
|
| 15 |
+
from app.database import get_db
|
| 16 |
+
from app.dependencies import get_current_user
|
| 17 |
+
from app.models.user import User
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
router = APIRouter(prefix="/api/copilot", tags=["HedgeAI"])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class CopilotMessage(BaseModel):
|
| 25 |
+
message: str = Field(..., min_length=1, max_length=2000, description="User's natural language query")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.post("/chat")
|
| 29 |
+
async def copilot_chat(
|
| 30 |
+
data: CopilotMessage,
|
| 31 |
+
db: AsyncSession = Depends(get_db),
|
| 32 |
+
user: User = Depends(get_current_user),
|
| 33 |
+
):
|
| 34 |
+
"""
|
| 35 |
+
Chat with the QuantHedge HedgeAI.
|
| 36 |
+
|
| 37 |
+
Understands natural language queries about portfolio risk, hedging,
|
| 38 |
+
stress testing, sentiment, and more. Routes to appropriate engines
|
| 39 |
+
and returns conversational responses with suggested actions.
|
| 40 |
+
"""
|
| 41 |
+
from app.services.ai_research.copilot import chat
|
| 42 |
+
from app.services.holdings.engine import compute_portfolio_summary
|
| 43 |
+
|
| 44 |
+
# Get portfolio context for the copilot
|
| 45 |
+
try:
|
| 46 |
+
portfolio_context = await compute_portfolio_summary(db, user.id)
|
| 47 |
+
except Exception:
|
| 48 |
+
portfolio_context = None
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
result = await chat(data.message, portfolio_context)
|
| 52 |
+
return result
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logger.error("Copilot error: %s", e)
|
| 55 |
+
raise HTTPException(status_code=500, detail="Copilot processing failed")
|
backend/app/routers/holdings.py
CHANGED
|
@@ -2,7 +2,9 @@
|
|
| 2 |
Holdings Router.
|
| 3 |
|
| 4 |
CRUD for user holdings plus portfolio summary with live P&L,
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
|
@@ -23,7 +25,7 @@ from app.services.holdings.engine import (
|
|
| 23 |
delete_holding,
|
| 24 |
compute_portfolio_summary,
|
| 25 |
)
|
| 26 |
-
from app.services.risk.stress_test import run_stress_test, run_all_scenarios
|
| 27 |
|
| 28 |
router = APIRouter(prefix="/api/holdings", tags=["Holdings"])
|
| 29 |
|
|
@@ -60,7 +62,17 @@ class StressTestRequest(BaseModel):
|
|
| 60 |
custom_shock: Optional[float] = None
|
| 61 |
|
| 62 |
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
@router.get("/")
|
| 65 |
async def list_holdings(
|
| 66 |
db: AsyncSession = Depends(get_db),
|
|
@@ -139,19 +151,27 @@ async def portfolio_summary(
|
|
| 139 |
return await compute_portfolio_summary(db, user.id)
|
| 140 |
|
| 141 |
|
|
|
|
| 142 |
@router.post("/stress-test")
|
| 143 |
async def stress_test(
|
| 144 |
data: StressTestRequest,
|
| 145 |
db: AsyncSession = Depends(get_db),
|
| 146 |
user: User = Depends(get_current_user),
|
| 147 |
):
|
| 148 |
-
"""Run a stress test on the user's portfolio."""
|
| 149 |
summary = await compute_portfolio_summary(db, user.id)
|
| 150 |
holdings = summary.get("holdings", [])
|
| 151 |
if not holdings:
|
| 152 |
raise HTTPException(status_code=400, detail="No holdings to test")
|
| 153 |
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
if "error" in result:
|
| 156 |
raise HTTPException(status_code=400, detail=result["error"])
|
| 157 |
return result
|
|
@@ -167,9 +187,13 @@ async def all_stress_tests(
|
|
| 167 |
holdings = summary.get("holdings", [])
|
| 168 |
if not holdings:
|
| 169 |
return {"scenarios": []}
|
| 170 |
-
return {"scenarios": run_all_scenarios(holdings)}
|
| 171 |
|
|
|
|
|
|
|
|
|
|
| 172 |
|
|
|
|
|
|
|
| 173 |
@router.get("/hedge-recommendations")
|
| 174 |
async def hedge_recommendations(
|
| 175 |
db: AsyncSession = Depends(get_db),
|
|
@@ -186,21 +210,23 @@ async def hedge_recommendations(
|
|
| 186 |
return generate_hedge_recommendations(holdings, total_value)
|
| 187 |
|
| 188 |
|
|
|
|
| 189 |
@router.get("/options-hedge")
|
| 190 |
async def options_hedge(
|
| 191 |
db: AsyncSession = Depends(get_db),
|
| 192 |
user: User = Depends(get_current_user),
|
| 193 |
):
|
| 194 |
-
"""Calculate options hedge strategies
|
| 195 |
-
from app.services.risk.options_calculator import
|
| 196 |
|
| 197 |
summary = await compute_portfolio_summary(db, user.id)
|
| 198 |
holdings = summary.get("holdings", [])
|
| 199 |
if not holdings:
|
| 200 |
return {"strategies": []}
|
| 201 |
-
return
|
| 202 |
|
| 203 |
|
|
|
|
| 204 |
@router.get("/rebalance")
|
| 205 |
async def rebalance(
|
| 206 |
db: AsyncSession = Depends(get_db),
|
|
@@ -217,6 +243,7 @@ async def rebalance(
|
|
| 217 |
return compute_rebalance(holdings, total_value)
|
| 218 |
|
| 219 |
|
|
|
|
| 220 |
@router.get("/correlation")
|
| 221 |
async def correlation_matrix(
|
| 222 |
db: AsyncSession = Depends(get_db),
|
|
@@ -232,3 +259,124 @@ async def correlation_matrix(
|
|
| 232 |
return {"matrix": [], "tickers": tickers, "pairs": [], "risk_flags": []}
|
| 233 |
return await compute_correlation_matrix(tickers)
|
| 234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
Holdings Router.
|
| 3 |
|
| 4 |
CRUD for user holdings plus portfolio summary with live P&L,
|
| 5 |
+
stress test simulation, Monte Carlo VaR, P&L attribution,
|
| 6 |
+
behavioral bias detection, crisis replay, portfolio DNA,
|
| 7 |
+
health score, and rolling metrics endpoints.
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
|
|
|
| 25 |
delete_holding,
|
| 26 |
compute_portfolio_summary,
|
| 27 |
)
|
| 28 |
+
from app.services.risk.stress_test import run_stress_test, run_all_scenarios, compute_real_betas
|
| 29 |
|
| 30 |
router = APIRouter(prefix="/api/holdings", tags=["Holdings"])
|
| 31 |
|
|
|
|
| 62 |
custom_shock: Optional[float] = None
|
| 63 |
|
| 64 |
|
| 65 |
+
class CrisisReplayRequest(BaseModel):
|
| 66 |
+
crisis_id: str = Field("covid_2020", description="Crisis ID to replay")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class MonteCarloRequest(BaseModel):
|
| 70 |
+
horizon_days: int = Field(60, ge=5, le=252)
|
| 71 |
+
n_simulations: int = Field(10000, ge=100, le=50000)
|
| 72 |
+
regime_conditioned: bool = False
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ββ CRUD Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
@router.get("/")
|
| 77 |
async def list_holdings(
|
| 78 |
db: AsyncSession = Depends(get_db),
|
|
|
|
| 151 |
return await compute_portfolio_summary(db, user.id)
|
| 152 |
|
| 153 |
|
| 154 |
+
# ββ Stress Test ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 155 |
@router.post("/stress-test")
|
| 156 |
async def stress_test(
|
| 157 |
data: StressTestRequest,
|
| 158 |
db: AsyncSession = Depends(get_db),
|
| 159 |
user: User = Depends(get_current_user),
|
| 160 |
):
|
| 161 |
+
"""Run a stress test on the user's portfolio (with real computed betas)."""
|
| 162 |
summary = await compute_portfolio_summary(db, user.id)
|
| 163 |
holdings = summary.get("holdings", [])
|
| 164 |
if not holdings:
|
| 165 |
raise HTTPException(status_code=400, detail="No holdings to test")
|
| 166 |
|
| 167 |
+
# Compute real betas for accurate stress testing
|
| 168 |
+
tickers = [h["ticker"] for h in holdings]
|
| 169 |
+
computed_betas = await compute_real_betas(tickers)
|
| 170 |
+
|
| 171 |
+
result = run_stress_test(
|
| 172 |
+
holdings, scenario_id=data.scenario_id,
|
| 173 |
+
custom_shock=data.custom_shock, computed_betas=computed_betas
|
| 174 |
+
)
|
| 175 |
if "error" in result:
|
| 176 |
raise HTTPException(status_code=400, detail=result["error"])
|
| 177 |
return result
|
|
|
|
| 187 |
holdings = summary.get("holdings", [])
|
| 188 |
if not holdings:
|
| 189 |
return {"scenarios": []}
|
|
|
|
| 190 |
|
| 191 |
+
tickers = [h["ticker"] for h in holdings]
|
| 192 |
+
computed_betas = await compute_real_betas(tickers)
|
| 193 |
+
return {"scenarios": run_all_scenarios(holdings, computed_betas=computed_betas)}
|
| 194 |
|
| 195 |
+
|
| 196 |
+
# ββ Hedge Recommendations ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 197 |
@router.get("/hedge-recommendations")
|
| 198 |
async def hedge_recommendations(
|
| 199 |
db: AsyncSession = Depends(get_db),
|
|
|
|
| 210 |
return generate_hedge_recommendations(holdings, total_value)
|
| 211 |
|
| 212 |
|
| 213 |
+
# ββ Options Hedge (with Greeks) ββββββββββββββββββββββββββββββββββββββββββ
|
| 214 |
@router.get("/options-hedge")
|
| 215 |
async def options_hedge(
|
| 216 |
db: AsyncSession = Depends(get_db),
|
| 217 |
user: User = Depends(get_current_user),
|
| 218 |
):
|
| 219 |
+
"""Calculate options hedge strategies with full Greeks."""
|
| 220 |
+
from app.services.risk.options_calculator import calculate_options_hedge_async
|
| 221 |
|
| 222 |
summary = await compute_portfolio_summary(db, user.id)
|
| 223 |
holdings = summary.get("holdings", [])
|
| 224 |
if not holdings:
|
| 225 |
return {"strategies": []}
|
| 226 |
+
return await calculate_options_hedge_async(holdings)
|
| 227 |
|
| 228 |
|
| 229 |
+
# ββ Rebalance ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 230 |
@router.get("/rebalance")
|
| 231 |
async def rebalance(
|
| 232 |
db: AsyncSession = Depends(get_db),
|
|
|
|
| 243 |
return compute_rebalance(holdings, total_value)
|
| 244 |
|
| 245 |
|
| 246 |
+
# ββ Correlation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 247 |
@router.get("/correlation")
|
| 248 |
async def correlation_matrix(
|
| 249 |
db: AsyncSession = Depends(get_db),
|
|
|
|
| 259 |
return {"matrix": [], "tickers": tickers, "pairs": [], "risk_flags": []}
|
| 260 |
return await compute_correlation_matrix(tickers)
|
| 261 |
|
| 262 |
+
|
| 263 |
+
# ββ Monte Carlo VaR βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 264 |
+
@router.post("/monte-carlo")
|
| 265 |
+
async def monte_carlo_var(
|
| 266 |
+
data: MonteCarloRequest,
|
| 267 |
+
db: AsyncSession = Depends(get_db),
|
| 268 |
+
user: User = Depends(get_current_user),
|
| 269 |
+
):
|
| 270 |
+
"""Run Monte Carlo VaR simulation on portfolio."""
|
| 271 |
+
from app.services.risk.monte_carlo import run_monte_carlo
|
| 272 |
+
|
| 273 |
+
summary = await compute_portfolio_summary(db, user.id)
|
| 274 |
+
holdings = summary.get("holdings", [])
|
| 275 |
+
if not holdings:
|
| 276 |
+
return {"error": "No holdings for simulation"}
|
| 277 |
+
return await run_monte_carlo(
|
| 278 |
+
holdings,
|
| 279 |
+
horizon_days=data.horizon_days,
|
| 280 |
+
n_simulations=data.n_simulations,
|
| 281 |
+
regime_conditioned=data.regime_conditioned,
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
# ββ Portfolio Health Score βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 286 |
+
@router.get("/health-score")
|
| 287 |
+
async def health_score(
|
| 288 |
+
db: AsyncSession = Depends(get_db),
|
| 289 |
+
user: User = Depends(get_current_user),
|
| 290 |
+
):
|
| 291 |
+
"""Compute comprehensive portfolio health score."""
|
| 292 |
+
from app.services.analytics.health_score import compute_health_score
|
| 293 |
+
|
| 294 |
+
summary = await compute_portfolio_summary(db, user.id)
|
| 295 |
+
holdings = summary.get("holdings", [])
|
| 296 |
+
total_value = summary.get("total_value", 0)
|
| 297 |
+
return await compute_health_score(holdings, total_value)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# ββ Behavioral Bias Detector ββββββββββββββββββββββββββββββββββββββββββββ
|
| 301 |
+
@router.get("/bias-analysis")
|
| 302 |
+
async def bias_analysis(
|
| 303 |
+
db: AsyncSession = Depends(get_db),
|
| 304 |
+
user: User = Depends(get_current_user),
|
| 305 |
+
):
|
| 306 |
+
"""Detect behavioral biases in portfolio composition."""
|
| 307 |
+
from app.services.analytics.bias_detector import detect_biases
|
| 308 |
+
|
| 309 |
+
summary = await compute_portfolio_summary(db, user.id)
|
| 310 |
+
holdings = summary.get("holdings", [])
|
| 311 |
+
total_value = summary.get("total_value", 0)
|
| 312 |
+
return await detect_biases(holdings, total_value)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
# ββ Crisis Replay ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 316 |
+
@router.post("/crisis-replay")
|
| 317 |
+
async def crisis_replay(
|
| 318 |
+
data: CrisisReplayRequest,
|
| 319 |
+
db: AsyncSession = Depends(get_db),
|
| 320 |
+
user: User = Depends(get_current_user),
|
| 321 |
+
):
|
| 322 |
+
"""Replay a historical crisis against user portfolio."""
|
| 323 |
+
from app.services.risk.crisis_replay import replay_crisis
|
| 324 |
+
|
| 325 |
+
summary = await compute_portfolio_summary(db, user.id)
|
| 326 |
+
holdings = summary.get("holdings", [])
|
| 327 |
+
total_value = summary.get("total_value", 0)
|
| 328 |
+
if not holdings:
|
| 329 |
+
return {"error": "No holdings for crisis replay"}
|
| 330 |
+
return await replay_crisis(holdings, total_value, data.crisis_id)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
@router.get("/crisis-replay/all")
|
| 334 |
+
async def all_crisis_replays(
|
| 335 |
+
db: AsyncSession = Depends(get_db),
|
| 336 |
+
user: User = Depends(get_current_user),
|
| 337 |
+
):
|
| 338 |
+
"""Replay all crises and return summary comparison."""
|
| 339 |
+
from app.services.risk.crisis_replay import replay_all_crises
|
| 340 |
+
|
| 341 |
+
summary = await compute_portfolio_summary(db, user.id)
|
| 342 |
+
holdings = summary.get("holdings", [])
|
| 343 |
+
total_value = summary.get("total_value", 0)
|
| 344 |
+
if not holdings:
|
| 345 |
+
return {"crises": [], "available": []}
|
| 346 |
+
return await replay_all_crises(holdings, total_value)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
# ββ Portfolio DNA ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 350 |
+
@router.get("/portfolio-dna")
|
| 351 |
+
async def portfolio_dna(
|
| 352 |
+
db: AsyncSession = Depends(get_db),
|
| 353 |
+
user: User = Depends(get_current_user),
|
| 354 |
+
):
|
| 355 |
+
"""Compute portfolio DNA fingerprint with famous portfolio comparisons."""
|
| 356 |
+
from app.services.analytics.portfolio_dna import compute_portfolio_dna
|
| 357 |
+
|
| 358 |
+
summary = await compute_portfolio_summary(db, user.id)
|
| 359 |
+
holdings = summary.get("holdings", [])
|
| 360 |
+
total_value = summary.get("total_value", 0)
|
| 361 |
+
return await compute_portfolio_dna(holdings, total_value)
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
# ββ P&L Attribution ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 365 |
+
@router.get("/attribution")
|
| 366 |
+
async def pl_attribution(
|
| 367 |
+
method: str = "brinson",
|
| 368 |
+
db: AsyncSession = Depends(get_db),
|
| 369 |
+
user: User = Depends(get_current_user),
|
| 370 |
+
):
|
| 371 |
+
"""Compute P&L attribution (Brinson or factor-based)."""
|
| 372 |
+
from app.services.analytics.attribution import brinson_attribution, factor_attribution
|
| 373 |
+
|
| 374 |
+
summary = await compute_portfolio_summary(db, user.id)
|
| 375 |
+
holdings = summary.get("holdings", [])
|
| 376 |
+
total_value = summary.get("total_value", 0)
|
| 377 |
+
if not holdings:
|
| 378 |
+
return {"error": "No holdings for attribution"}
|
| 379 |
+
|
| 380 |
+
if method == "factor":
|
| 381 |
+
return await factor_attribution(holdings, total_value)
|
| 382 |
+
return await brinson_attribution(holdings, total_value)
|
backend/app/services/ai_research/copilot.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Natural Language Hedging Copilot.
|
| 3 |
+
|
| 4 |
+
Chat-like interface that accepts natural language queries and routes them
|
| 5 |
+
to the appropriate QuantHedge engines:
|
| 6 |
+
|
| 7 |
+
- "How would my portfolio do in a recession?" β Stress Test
|
| 8 |
+
- "What's my biggest risk?" β Risk Analysis
|
| 9 |
+
- "Should I hedge NVDA?" β Options Calculator
|
| 10 |
+
- "Analyze market sentiment for AAPL" β Sentiment Engine
|
| 11 |
+
- "Predict TSLA returns" β XGBoost Predictor
|
| 12 |
+
- "What factors am I exposed to?" β Factor Model
|
| 13 |
+
|
| 14 |
+
Uses Groq LLM for intent classification and response generation.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
from typing import Any, Dict, Optional
|
| 22 |
+
|
| 23 |
+
import aiohttp
|
| 24 |
+
|
| 25 |
+
from app.config import get_settings
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
_settings = get_settings()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
SYSTEM_PROMPT = """You are QuantHedge Copilot, an AI financial analyst assistant.
|
| 33 |
+
You help users understand their portfolio risk, analyze markets, and make better investment decisions.
|
| 34 |
+
|
| 35 |
+
When the user asks a question, you should:
|
| 36 |
+
1. Classify their intent into one of these categories:
|
| 37 |
+
- stress_test: portfolio stress testing or crisis scenarios
|
| 38 |
+
- risk_analysis: risk metrics, VaR, volatility analysis
|
| 39 |
+
- options_hedge: options pricing, Greeks, hedge strategies
|
| 40 |
+
- sentiment: market sentiment or news analysis
|
| 41 |
+
- prediction: stock return prediction or forecasting
|
| 42 |
+
- factors: factor analysis or exposure
|
| 43 |
+
- portfolio_health: portfolio health score or overall assessment
|
| 44 |
+
- bias_detection: behavioral bias analysis
|
| 45 |
+
- crisis_replay: historical crisis simulation
|
| 46 |
+
- general: general financial question or advice
|
| 47 |
+
|
| 48 |
+
2. Extract any relevant tickers, scenarios, or parameters.
|
| 49 |
+
|
| 50 |
+
Respond ONLY with a JSON object in this format:
|
| 51 |
+
{
|
| 52 |
+
"intent": "<category>",
|
| 53 |
+
"tickers": ["TICKER1", "TICKER2"],
|
| 54 |
+
"parameters": {"key": "value"},
|
| 55 |
+
"direct_answer": "<your helpful response to the user>"
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
Be conversational and helpful in direct_answer. Use financial terminology appropriately.
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def chat(
|
| 63 |
+
message: str,
|
| 64 |
+
portfolio_context: Optional[Dict[str, Any]] = None,
|
| 65 |
+
) -> Dict[str, Any]:
|
| 66 |
+
"""
|
| 67 |
+
Process a natural language query from the user.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
message: User's natural language input
|
| 71 |
+
portfolio_context: Optional current portfolio summary for context
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
Response with intent classification, routed data, and conversational reply.
|
| 75 |
+
"""
|
| 76 |
+
if not _settings.groq_api_key:
|
| 77 |
+
return _fallback_response(message, portfolio_context)
|
| 78 |
+
|
| 79 |
+
context_text = ""
|
| 80 |
+
if portfolio_context:
|
| 81 |
+
holdings = portfolio_context.get("holdings", [])
|
| 82 |
+
if holdings:
|
| 83 |
+
tickers_str = ", ".join(h.get("ticker", "") for h in holdings[:10])
|
| 84 |
+
total_val = portfolio_context.get("total_value", 0)
|
| 85 |
+
total_pnl = portfolio_context.get("total_pnl", 0)
|
| 86 |
+
context_text = f"\n\nUser's current portfolio: {tickers_str} (Total: ${total_val:,.0f}, P&L: ${total_pnl:,.0f})"
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
async with aiohttp.ClientSession() as session:
|
| 90 |
+
async with session.post(
|
| 91 |
+
"https://api.groq.com/openai/v1/chat/completions",
|
| 92 |
+
headers={
|
| 93 |
+
"Authorization": f"Bearer {_settings.groq_api_key}",
|
| 94 |
+
"Content-Type": "application/json",
|
| 95 |
+
},
|
| 96 |
+
json={
|
| 97 |
+
"model": "llama-3.3-70b-versatile",
|
| 98 |
+
"messages": [
|
| 99 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 100 |
+
{"role": "user", "content": message + context_text},
|
| 101 |
+
],
|
| 102 |
+
"temperature": 0.3,
|
| 103 |
+
"max_tokens": 1000,
|
| 104 |
+
},
|
| 105 |
+
timeout=aiohttp.ClientTimeout(total=20),
|
| 106 |
+
) as resp:
|
| 107 |
+
if resp.status != 200:
|
| 108 |
+
return _fallback_response(message, portfolio_context)
|
| 109 |
+
data = await resp.json()
|
| 110 |
+
content = data["choices"][0]["message"]["content"]
|
| 111 |
+
|
| 112 |
+
# Parse JSON from response
|
| 113 |
+
start = content.find("{")
|
| 114 |
+
end = content.rfind("}") + 1
|
| 115 |
+
if start >= 0 and end > start:
|
| 116 |
+
parsed = json.loads(content[start:end])
|
| 117 |
+
return {
|
| 118 |
+
"intent": parsed.get("intent", "general"),
|
| 119 |
+
"tickers": parsed.get("tickers", []),
|
| 120 |
+
"parameters": parsed.get("parameters", {}),
|
| 121 |
+
"response": parsed.get("direct_answer", content),
|
| 122 |
+
"actions": _get_suggested_actions(parsed.get("intent", "general"), parsed.get("tickers", [])),
|
| 123 |
+
}
|
| 124 |
+
else:
|
| 125 |
+
return {
|
| 126 |
+
"intent": "general",
|
| 127 |
+
"tickers": [],
|
| 128 |
+
"parameters": {},
|
| 129 |
+
"response": content,
|
| 130 |
+
"actions": [],
|
| 131 |
+
}
|
| 132 |
+
except Exception as e:
|
| 133 |
+
logger.warning("Copilot LLM call failed: %s", e)
|
| 134 |
+
return _fallback_response(message, portfolio_context)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _fallback_response(
|
| 138 |
+
message: str,
|
| 139 |
+
portfolio_context: Optional[Dict[str, Any]] = None,
|
| 140 |
+
) -> Dict[str, Any]:
|
| 141 |
+
"""Keyword-based fallback when LLM is unavailable."""
|
| 142 |
+
msg_lower = message.lower()
|
| 143 |
+
|
| 144 |
+
intent = "general"
|
| 145 |
+
tickers = []
|
| 146 |
+
|
| 147 |
+
if any(w in msg_lower for w in ("stress", "crash", "recession", "scenario")):
|
| 148 |
+
intent = "stress_test"
|
| 149 |
+
elif any(w in msg_lower for w in ("risk", "var", "volatility", "drawdown")):
|
| 150 |
+
intent = "risk_analysis"
|
| 151 |
+
elif any(w in msg_lower for w in ("option", "put", "call", "hedge", "greek")):
|
| 152 |
+
intent = "options_hedge"
|
| 153 |
+
elif any(w in msg_lower for w in ("sentiment", "news", "mood")):
|
| 154 |
+
intent = "sentiment"
|
| 155 |
+
elif any(w in msg_lower for w in ("predict", "forecast", "return", "ml")):
|
| 156 |
+
intent = "prediction"
|
| 157 |
+
elif any(w in msg_lower for w in ("factor", "exposure", "momentum", "value")):
|
| 158 |
+
intent = "factors"
|
| 159 |
+
elif any(w in msg_lower for w in ("health", "score", "grade")):
|
| 160 |
+
intent = "portfolio_health"
|
| 161 |
+
elif any(w in msg_lower for w in ("bias", "behavioral", "disposition")):
|
| 162 |
+
intent = "bias_detection"
|
| 163 |
+
elif any(w in msg_lower for w in ("crisis", "replay", "historical", "covid", "2008")):
|
| 164 |
+
intent = "crisis_replay"
|
| 165 |
+
|
| 166 |
+
# Extract tickers (uppercase 1-5 letter words)
|
| 167 |
+
import re
|
| 168 |
+
potential_tickers = re.findall(r'\b[A-Z]{1,5}\b', message)
|
| 169 |
+
tickers = [t for t in potential_tickers if t not in {"I", "A", "THE", "AND", "FOR", "HOW", "MY", "IS", "TO", "IN", "DO", "ME"}]
|
| 170 |
+
|
| 171 |
+
responses = {
|
| 172 |
+
"stress_test": "I can run stress tests on your portfolio. Try the Stress Test tab to see how your holdings would perform under various scenarios like a market crash or rate hike.",
|
| 173 |
+
"risk_analysis": "Let me help analyze your portfolio risk. Check the Holdings page for comprehensive risk metrics including VaR, volatility, and drawdown analysis.",
|
| 174 |
+
"options_hedge": "I can help with options hedging strategies. The Options Hedge section shows protective puts, covered calls, and collar strategies with full Greeks.",
|
| 175 |
+
"sentiment": "I can analyze market sentiment for any ticker. Head to the Sentiment page for AI-powered news analysis.",
|
| 176 |
+
"prediction": "Our XGBoost model can predict short-term return direction. Use the Markets page to get ML predictions for any ticker.",
|
| 177 |
+
"factors": "Factor analysis breaks down your exposure to systematic risk factors. Check the Factor Analysis page for detailed factor exposures.",
|
| 178 |
+
"portfolio_health": "Your Portfolio Health Score gives a comprehensive 0-100 grade across 7 dimensions. Check the Health page for details.",
|
| 179 |
+
"bias_detection": "The Behavioral Bias Detector analyzes your trading patterns for cognitive biases. Check the Bias page for your scorecard.",
|
| 180 |
+
"crisis_replay": "Historical Crisis Replay shows how your portfolio would have fared during past crises like COVID, 2008, and the Dot-Com bust.",
|
| 181 |
+
"general": "I'm here to help with your portfolio analysis. Try asking about risk, hedging, sentiment, or stress testing!",
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
return {
|
| 185 |
+
"intent": intent,
|
| 186 |
+
"tickers": tickers,
|
| 187 |
+
"parameters": {},
|
| 188 |
+
"response": responses.get(intent, responses["general"]),
|
| 189 |
+
"actions": _get_suggested_actions(intent, tickers),
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _get_suggested_actions(intent: str, tickers: list) -> list:
|
| 194 |
+
"""Generate suggested follow-up actions based on intent."""
|
| 195 |
+
actions_map = {
|
| 196 |
+
"stress_test": [
|
| 197 |
+
{"label": "Run All Stress Tests", "route": "/holdings", "tab": "stress"},
|
| 198 |
+
{"label": "View Crisis Replay", "route": "/crisis-replay"},
|
| 199 |
+
],
|
| 200 |
+
"risk_analysis": [
|
| 201 |
+
{"label": "View Risk Metrics", "route": "/holdings", "tab": "risk"},
|
| 202 |
+
{"label": "Monte Carlo VaR", "route": "/holdings", "tab": "montecarlo"},
|
| 203 |
+
],
|
| 204 |
+
"options_hedge": [
|
| 205 |
+
{"label": "View Options Strategies", "route": "/holdings", "tab": "options"},
|
| 206 |
+
],
|
| 207 |
+
"sentiment": [
|
| 208 |
+
{"label": "View Sentiment", "route": "/sentiment"},
|
| 209 |
+
],
|
| 210 |
+
"prediction": [
|
| 211 |
+
{"label": "View ML Predictions", "route": "/market"},
|
| 212 |
+
],
|
| 213 |
+
"factors": [
|
| 214 |
+
{"label": "Factor Analysis", "route": "/factors"},
|
| 215 |
+
],
|
| 216 |
+
"portfolio_health": [
|
| 217 |
+
{"label": "View Health Score", "route": "/portfolio-health"},
|
| 218 |
+
],
|
| 219 |
+
"bias_detection": [
|
| 220 |
+
{"label": "View Bias Report", "route": "/bias-detector"},
|
| 221 |
+
],
|
| 222 |
+
"crisis_replay": [
|
| 223 |
+
{"label": "Crisis Replay", "route": "/crisis-replay"},
|
| 224 |
+
],
|
| 225 |
+
}
|
| 226 |
+
return actions_map.get(intent, [])
|
backend/app/services/analytics/attribution.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
P&L Attribution Engine.
|
| 3 |
+
|
| 4 |
+
Two attribution methodologies:
|
| 5 |
+
1. Brinson Attribution β allocation effect + selection effect vs benchmark
|
| 6 |
+
2. Factor-Based Attribution β decompose returns into factor contributions
|
| 7 |
+
|
| 8 |
+
Provides waterfall chart data for visualizing P&L breakdown.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
from typing import Any, Dict, List
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
|
| 19 |
+
from app.config import get_settings
|
| 20 |
+
from app.services.data_ingestion.yahoo import yahoo_adapter
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
_settings = get_settings()
|
| 25 |
+
TRADING_DAYS = _settings.trading_days_per_year
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def brinson_attribution(
|
| 29 |
+
holdings: List[Dict[str, Any]],
|
| 30 |
+
total_value: float,
|
| 31 |
+
benchmark: str = "SPY",
|
| 32 |
+
period: str = "1y",
|
| 33 |
+
) -> Dict[str, Any]:
|
| 34 |
+
"""
|
| 35 |
+
Brinson-Fachler attribution: decompose portfolio P&L into
|
| 36 |
+
allocation effect, selection effect, and interaction effect.
|
| 37 |
+
"""
|
| 38 |
+
if not holdings or total_value <= 0:
|
| 39 |
+
return {"error": "No holdings for attribution"}
|
| 40 |
+
|
| 41 |
+
# Group holdings by sector/asset_class
|
| 42 |
+
sector_map: Dict[str, List[Dict]] = {}
|
| 43 |
+
for h in holdings:
|
| 44 |
+
sector = h.get("asset_class", h.get("sector", "equity"))
|
| 45 |
+
if sector not in sector_map:
|
| 46 |
+
sector_map[sector] = []
|
| 47 |
+
sector_map[sector].append(h)
|
| 48 |
+
|
| 49 |
+
# Compute benchmark returns
|
| 50 |
+
bench_df = await yahoo_adapter.get_price_dataframe(benchmark, period=period)
|
| 51 |
+
if bench_df.empty:
|
| 52 |
+
return {"error": "Could not fetch benchmark data"}
|
| 53 |
+
bench_returns = bench_df["Close"].pct_change().dropna()
|
| 54 |
+
bench_total_return = float((bench_df["Close"].iloc[-1] / bench_df["Close"].iloc[0]) - 1)
|
| 55 |
+
|
| 56 |
+
# Compute per-sector portfolio weights and returns
|
| 57 |
+
sectors = []
|
| 58 |
+
total_port_return = 0.0
|
| 59 |
+
|
| 60 |
+
for sector, sector_holdings in sector_map.items():
|
| 61 |
+
sector_value = sum(h.get("market_value", 0) for h in sector_holdings)
|
| 62 |
+
port_weight = sector_value / total_value
|
| 63 |
+
sector_pnl = sum(h.get("pnl", 0) for h in sector_holdings)
|
| 64 |
+
sector_cost = sum(h.get("cost_basis", h.get("avg_price", 0) * h.get("quantity", 0)) for h in sector_holdings)
|
| 65 |
+
sector_return = (sector_pnl / sector_cost) if sector_cost > 0 else 0
|
| 66 |
+
|
| 67 |
+
# Approximate benchmark weight (equal for simplicity)
|
| 68 |
+
bench_weight = 1.0 / max(len(sector_map), 1)
|
| 69 |
+
|
| 70 |
+
# Brinson decomposition
|
| 71 |
+
allocation_effect = (port_weight - bench_weight) * bench_total_return
|
| 72 |
+
selection_effect = bench_weight * (sector_return - bench_total_return)
|
| 73 |
+
interaction_effect = (port_weight - bench_weight) * (sector_return - bench_total_return)
|
| 74 |
+
total_effect = allocation_effect + selection_effect + interaction_effect
|
| 75 |
+
|
| 76 |
+
total_port_return += port_weight * sector_return
|
| 77 |
+
|
| 78 |
+
sectors.append({
|
| 79 |
+
"sector": sector,
|
| 80 |
+
"portfolio_weight": round(port_weight * 100, 2),
|
| 81 |
+
"benchmark_weight": round(bench_weight * 100, 2),
|
| 82 |
+
"portfolio_return": round(sector_return * 100, 2),
|
| 83 |
+
"benchmark_return": round(bench_total_return * 100, 2),
|
| 84 |
+
"allocation_effect": round(allocation_effect * 100, 4),
|
| 85 |
+
"selection_effect": round(selection_effect * 100, 4),
|
| 86 |
+
"interaction_effect": round(interaction_effect * 100, 4),
|
| 87 |
+
"total_effect": round(total_effect * 100, 4),
|
| 88 |
+
"pnl": round(sector_pnl, 2),
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
# Sort by total effect
|
| 92 |
+
sectors.sort(key=lambda x: x["total_effect"], reverse=True)
|
| 93 |
+
|
| 94 |
+
total_alpha = total_port_return - bench_total_return
|
| 95 |
+
|
| 96 |
+
# Waterfall chart data
|
| 97 |
+
waterfall = [{"label": "Benchmark", "value": round(bench_total_return * 100, 2), "type": "start"}]
|
| 98 |
+
for s in sectors:
|
| 99 |
+
waterfall.append({
|
| 100 |
+
"label": f"{s['sector']} (Alloc)",
|
| 101 |
+
"value": s["allocation_effect"],
|
| 102 |
+
"type": "positive" if s["allocation_effect"] >= 0 else "negative",
|
| 103 |
+
})
|
| 104 |
+
waterfall.append({
|
| 105 |
+
"label": f"{s['sector']} (Select)",
|
| 106 |
+
"value": s["selection_effect"],
|
| 107 |
+
"type": "positive" if s["selection_effect"] >= 0 else "negative",
|
| 108 |
+
})
|
| 109 |
+
waterfall.append({"label": "Portfolio Return", "value": round(total_port_return * 100, 2), "type": "total"})
|
| 110 |
+
|
| 111 |
+
return {
|
| 112 |
+
"method": "brinson",
|
| 113 |
+
"benchmark": benchmark,
|
| 114 |
+
"benchmark_return": round(bench_total_return * 100, 2),
|
| 115 |
+
"portfolio_return": round(total_port_return * 100, 2),
|
| 116 |
+
"alpha": round(total_alpha * 100, 2),
|
| 117 |
+
"sectors": sectors,
|
| 118 |
+
"waterfall": waterfall,
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
async def factor_attribution(
|
| 123 |
+
holdings: List[Dict[str, Any]],
|
| 124 |
+
total_value: float,
|
| 125 |
+
period: str = "1y",
|
| 126 |
+
) -> Dict[str, Any]:
|
| 127 |
+
"""
|
| 128 |
+
Factor-based attribution: decompose returns into contributions from
|
| 129 |
+
systematic factors (market, size, value, momentum, volatility).
|
| 130 |
+
"""
|
| 131 |
+
if not holdings or total_value <= 0:
|
| 132 |
+
return {"error": "No holdings for factor attribution"}
|
| 133 |
+
|
| 134 |
+
tickers = list(set(h.get("ticker", "") for h in holdings if h.get("market_value", 0) > 0))
|
| 135 |
+
|
| 136 |
+
# Fetch returns
|
| 137 |
+
returns_data = {}
|
| 138 |
+
for ticker in tickers:
|
| 139 |
+
try:
|
| 140 |
+
df = await yahoo_adapter.get_price_dataframe(ticker, period=period)
|
| 141 |
+
if not df.empty and len(df) > 20:
|
| 142 |
+
returns_data[ticker] = df["Close"].pct_change().dropna()
|
| 143 |
+
except Exception:
|
| 144 |
+
continue
|
| 145 |
+
|
| 146 |
+
if not returns_data:
|
| 147 |
+
return {"error": "Could not fetch return data"}
|
| 148 |
+
|
| 149 |
+
# Align
|
| 150 |
+
aligned = pd.DataFrame(returns_data).dropna()
|
| 151 |
+
if aligned.empty:
|
| 152 |
+
return {"error": "Insufficient overlapping data"}
|
| 153 |
+
|
| 154 |
+
# Compute portfolio returns
|
| 155 |
+
weights = np.array([
|
| 156 |
+
sum(h.get("market_value", 0) for h in holdings if h.get("ticker") == t) / total_value
|
| 157 |
+
for t in aligned.columns
|
| 158 |
+
])
|
| 159 |
+
port_returns = aligned.values @ weights
|
| 160 |
+
|
| 161 |
+
# Fetch factor proxies
|
| 162 |
+
factor_tickers = {
|
| 163 |
+
"market": "SPY",
|
| 164 |
+
"size": "IWM",
|
| 165 |
+
"value": "IVE",
|
| 166 |
+
"momentum": "MTUM",
|
| 167 |
+
"low_vol": "USMV",
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
factor_returns: Dict[str, pd.Series] = {}
|
| 171 |
+
for fname, fticker in factor_tickers.items():
|
| 172 |
+
try:
|
| 173 |
+
df = await yahoo_adapter.get_price_dataframe(fticker, period=period)
|
| 174 |
+
if not df.empty and len(df) > 20:
|
| 175 |
+
factor_returns[fname] = df["Close"].pct_change().dropna()
|
| 176 |
+
except Exception:
|
| 177 |
+
pass
|
| 178 |
+
|
| 179 |
+
if not factor_returns:
|
| 180 |
+
return {"error": "Could not fetch factor data"}
|
| 181 |
+
|
| 182 |
+
# Simple OLS regression for factor loadings
|
| 183 |
+
factor_df = pd.DataFrame(factor_returns).dropna()
|
| 184 |
+
min_len = min(len(port_returns), len(factor_df))
|
| 185 |
+
if min_len < 30:
|
| 186 |
+
return {"error": "Insufficient data for factor attribution"}
|
| 187 |
+
|
| 188 |
+
y = port_returns[:min_len]
|
| 189 |
+
X = factor_df.iloc[:min_len].values
|
| 190 |
+
|
| 191 |
+
# Add intercept
|
| 192 |
+
X_with_intercept = np.column_stack([np.ones(min_len), X])
|
| 193 |
+
|
| 194 |
+
try:
|
| 195 |
+
# Least squares
|
| 196 |
+
betas, _, _, _ = np.linalg.lstsq(X_with_intercept, y, rcond=None)
|
| 197 |
+
alpha_daily = betas[0]
|
| 198 |
+
factor_betas = betas[1:]
|
| 199 |
+
except Exception:
|
| 200 |
+
return {"error": "Regression failed"}
|
| 201 |
+
|
| 202 |
+
# Attribution
|
| 203 |
+
factor_names = list(factor_df.columns)
|
| 204 |
+
contributions = []
|
| 205 |
+
total_explained = 0.0
|
| 206 |
+
|
| 207 |
+
for i, fname in enumerate(factor_names):
|
| 208 |
+
avg_factor_ret = float(factor_df[fname].mean() * TRADING_DAYS)
|
| 209 |
+
contribution = float(factor_betas[i] * avg_factor_ret * 100)
|
| 210 |
+
total_explained += contribution
|
| 211 |
+
contributions.append({
|
| 212 |
+
"factor": fname,
|
| 213 |
+
"beta": round(float(factor_betas[i]), 4),
|
| 214 |
+
"factor_return": round(avg_factor_ret * 100, 2),
|
| 215 |
+
"contribution": round(contribution, 4),
|
| 216 |
+
})
|
| 217 |
+
|
| 218 |
+
alpha_contribution = round(float(alpha_daily * TRADING_DAYS * 100), 4)
|
| 219 |
+
total_return = round(float(np.mean(y) * TRADING_DAYS * 100), 2)
|
| 220 |
+
|
| 221 |
+
contributions.sort(key=lambda x: abs(x["contribution"]), reverse=True)
|
| 222 |
+
|
| 223 |
+
return {
|
| 224 |
+
"method": "factor_based",
|
| 225 |
+
"total_return": total_return,
|
| 226 |
+
"alpha": alpha_contribution,
|
| 227 |
+
"factor_contributions": contributions,
|
| 228 |
+
"total_explained": round(total_explained, 4),
|
| 229 |
+
"residual": round(total_return - total_explained - alpha_contribution, 4),
|
| 230 |
+
}
|
backend/app/services/analytics/bias_detector.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Behavioral Bias Detector.
|
| 3 |
+
|
| 4 |
+
Analyzes user portfolio and trading patterns to detect common
|
| 5 |
+
cognitive biases that harm investment performance:
|
| 6 |
+
|
| 7 |
+
- Disposition Effect β selling winners too early, holding losers too long
|
| 8 |
+
- Overconcentration β insufficient diversification
|
| 9 |
+
- Recency Bias β chasing recent performance
|
| 10 |
+
- Home Country Bias β over-exposure to domestic markets
|
| 11 |
+
- Anchoring β fixating on purchase price instead of fundamentals
|
| 12 |
+
- Loss Aversion β asymmetric sensitivity to losses vs gains
|
| 13 |
+
|
| 14 |
+
Returns a Behavioral Scorecard with letter grades (AβF) and coaching tips.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
from typing import Any, Dict, List
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _grade(score: float) -> str:
|
| 28 |
+
if score >= 90: return "A"
|
| 29 |
+
if score >= 80: return "B"
|
| 30 |
+
if score >= 70: return "C"
|
| 31 |
+
if score >= 60: return "D"
|
| 32 |
+
return "F"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _severity(score: float) -> str:
|
| 36 |
+
if score >= 80: return "low"
|
| 37 |
+
if score >= 60: return "moderate"
|
| 38 |
+
if score >= 40: return "high"
|
| 39 |
+
return "critical"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
async def detect_biases(
|
| 43 |
+
holdings: List[Dict[str, Any]],
|
| 44 |
+
total_value: float,
|
| 45 |
+
) -> Dict[str, Any]:
|
| 46 |
+
"""
|
| 47 |
+
Detect behavioral biases from portfolio holdings.
|
| 48 |
+
|
| 49 |
+
Returns per-bias analysis with grades and coaching tips.
|
| 50 |
+
"""
|
| 51 |
+
if not holdings or total_value <= 0:
|
| 52 |
+
return {"score": 0, "grade": "F", "biases": [], "tips": ["Add holdings to analyze behavioral biases."]}
|
| 53 |
+
|
| 54 |
+
biases = []
|
| 55 |
+
tips = []
|
| 56 |
+
|
| 57 |
+
# ββ 1. Disposition Effect ββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
# Detect: are losers being held longer than winners?
|
| 59 |
+
winners = [h for h in holdings if h.get("pnl", 0) > 0]
|
| 60 |
+
losers = [h for h in holdings if h.get("pnl", 0) < 0]
|
| 61 |
+
|
| 62 |
+
if winners and losers:
|
| 63 |
+
avg_winner_pnl_pct = np.mean([abs(h.get("pnl_pct", 0)) for h in winners])
|
| 64 |
+
avg_loser_pnl_pct = np.mean([abs(h.get("pnl_pct", 0)) for h in losers])
|
| 65 |
+
|
| 66 |
+
# Disposition effect: losers have larger unrealized losses than winners have gains
|
| 67 |
+
# (holding on to losers, selling winners early)
|
| 68 |
+
if avg_loser_pnl_pct > avg_winner_pnl_pct * 1.5:
|
| 69 |
+
disp_score = max(20, 80 - (avg_loser_pnl_pct - avg_winner_pnl_pct) * 2)
|
| 70 |
+
else:
|
| 71 |
+
disp_score = min(100, 80 + (avg_winner_pnl_pct - avg_loser_pnl_pct))
|
| 72 |
+
|
| 73 |
+
loser_value = sum(abs(h.get("pnl", 0)) for h in losers)
|
| 74 |
+
winner_value = sum(h.get("pnl", 0) for h in winners)
|
| 75 |
+
else:
|
| 76 |
+
disp_score = 85
|
| 77 |
+
loser_value = 0
|
| 78 |
+
winner_value = sum(h.get("pnl", 0) for h in winners) if winners else 0
|
| 79 |
+
|
| 80 |
+
biases.append({
|
| 81 |
+
"name": "Disposition Effect",
|
| 82 |
+
"description": "Tendency to sell winning positions too quickly while holding losers too long",
|
| 83 |
+
"score": round(disp_score, 1),
|
| 84 |
+
"grade": _grade(disp_score),
|
| 85 |
+
"severity": _severity(disp_score),
|
| 86 |
+
"detail": f"{len(winners)} winners (avg +{np.mean([h.get('pnl_pct', 0) for h in winners]):.1f}%) vs {len(losers)} losers (avg {np.mean([h.get('pnl_pct', 0) for h in losers]):.1f}%)" if winners and losers else "Insufficient data",
|
| 87 |
+
"icon": "π",
|
| 88 |
+
})
|
| 89 |
+
if disp_score < 60:
|
| 90 |
+
tips.append("π **Disposition Effect detected.** Consider reviewing your losing positions objectively β would you buy them today at current prices? If not, consider exiting.")
|
| 91 |
+
|
| 92 |
+
# ββ 2. Overconcentration βββββββββββββββββββββββββββββββββββββββββββββ
|
| 93 |
+
weights = [h.get("weight", 0) for h in holdings]
|
| 94 |
+
sorted_w = sorted(weights, reverse=True)
|
| 95 |
+
top1 = sorted_w[0] if sorted_w else 0
|
| 96 |
+
top3 = sum(sorted_w[:3]) if len(sorted_w) >= 3 else sum(sorted_w)
|
| 97 |
+
|
| 98 |
+
# HHI-based scoring
|
| 99 |
+
w_arr = np.array(weights) / 100 if weights else np.array([1])
|
| 100 |
+
hhi = float(np.sum(w_arr ** 2))
|
| 101 |
+
|
| 102 |
+
if top1 > 30:
|
| 103 |
+
conc_score = max(20, 60 - (top1 - 30) * 2)
|
| 104 |
+
elif top3 > 60:
|
| 105 |
+
conc_score = max(40, 70 - (top3 - 60) * 1.5)
|
| 106 |
+
elif len(holdings) < 5:
|
| 107 |
+
conc_score = max(50, 70 - (5 - len(holdings)) * 10)
|
| 108 |
+
else:
|
| 109 |
+
conc_score = min(100, 75 + len(holdings) * 1.5)
|
| 110 |
+
|
| 111 |
+
biases.append({
|
| 112 |
+
"name": "Overconcentration",
|
| 113 |
+
"description": "Insufficient diversification across positions and sectors",
|
| 114 |
+
"score": round(conc_score, 1),
|
| 115 |
+
"grade": _grade(conc_score),
|
| 116 |
+
"severity": _severity(conc_score),
|
| 117 |
+
"detail": f"Top holding: {top1:.1f}%, Top 3: {top3:.1f}%, HHI: {hhi:.3f}, {len(holdings)} positions",
|
| 118 |
+
"icon": "π―",
|
| 119 |
+
})
|
| 120 |
+
if conc_score < 60:
|
| 121 |
+
tips.append(f"π― **Overconcentration detected.** Your top position is {top1:.0f}% of portfolio. Target < 15% per position for better risk management.")
|
| 122 |
+
|
| 123 |
+
# ββ 3. Recency Bias ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 124 |
+
# Look at whether recent purchases are momentum-driven
|
| 125 |
+
recent_winners = [h for h in holdings if h.get("pnl_pct", 0) > 10]
|
| 126 |
+
recent_losers = [h for h in holdings if h.get("pnl_pct", 0) < -10]
|
| 127 |
+
|
| 128 |
+
# Heuristic: if most of portfolio has high positive P&L, user may be chasing
|
| 129 |
+
positive_weight = sum(h.get("weight", 0) for h in holdings if h.get("pnl_pct", 0) > 15)
|
| 130 |
+
if positive_weight > 60:
|
| 131 |
+
recency_score = max(30, 80 - positive_weight * 0.5)
|
| 132 |
+
elif positive_weight > 40:
|
| 133 |
+
recency_score = 70
|
| 134 |
+
else:
|
| 135 |
+
recency_score = 85
|
| 136 |
+
|
| 137 |
+
biases.append({
|
| 138 |
+
"name": "Recency Bias",
|
| 139 |
+
"description": "Overweighting recent market performance when making investment decisions",
|
| 140 |
+
"score": round(recency_score, 1),
|
| 141 |
+
"grade": _grade(recency_score),
|
| 142 |
+
"severity": _severity(recency_score),
|
| 143 |
+
"detail": f"{positive_weight:.0f}% portfolio weight in strongly gaining positions",
|
| 144 |
+
"icon": "β°",
|
| 145 |
+
})
|
| 146 |
+
if recency_score < 60:
|
| 147 |
+
tips.append("β° **Recency Bias detected.** You may be chasing recent winners. Consider mean-reversion and value opportunities.")
|
| 148 |
+
|
| 149 |
+
# ββ 4. Home Country Bias βββββββββββββββββββββββββββββββββββββββββββββ
|
| 150 |
+
# Detect US-heavy vs international exposure
|
| 151 |
+
us_tickers = sum(1 for h in holdings if not any(
|
| 152 |
+
suffix in h.get("ticker", "") for suffix in [".NS", ".HK", ".T", ".L", ".DE", ".PA"]
|
| 153 |
+
))
|
| 154 |
+
intl_tickers = len(holdings) - us_tickers
|
| 155 |
+
us_pct = (us_tickers / max(len(holdings), 1)) * 100
|
| 156 |
+
|
| 157 |
+
if us_pct > 90:
|
| 158 |
+
home_score = 40
|
| 159 |
+
elif us_pct > 80:
|
| 160 |
+
home_score = 55
|
| 161 |
+
elif us_pct > 70:
|
| 162 |
+
home_score = 70
|
| 163 |
+
elif us_pct > 50:
|
| 164 |
+
home_score = 85
|
| 165 |
+
else:
|
| 166 |
+
home_score = 90
|
| 167 |
+
|
| 168 |
+
biases.append({
|
| 169 |
+
"name": "Home Country Bias",
|
| 170 |
+
"description": "Over-exposure to domestic market at the expense of global diversification",
|
| 171 |
+
"score": round(home_score, 1),
|
| 172 |
+
"grade": _grade(home_score),
|
| 173 |
+
"severity": _severity(home_score),
|
| 174 |
+
"detail": f"{us_pct:.0f}% US tickers vs {100-us_pct:.0f}% international ({intl_tickers} foreign positions)",
|
| 175 |
+
"icon": "π",
|
| 176 |
+
})
|
| 177 |
+
if home_score < 60:
|
| 178 |
+
tips.append("π **Home Country Bias detected.** Your portfolio is heavily US-focused. Consider adding international exposure (Europe, Asia, EM).")
|
| 179 |
+
|
| 180 |
+
# ββ 5. Anchoring Bias ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 181 |
+
# Detect: are holdings with large losses being kept because of the buy price?
|
| 182 |
+
deep_losers = [h for h in holdings if h.get("pnl_pct", 0) < -20]
|
| 183 |
+
anchoring_indicators = len(deep_losers)
|
| 184 |
+
|
| 185 |
+
if anchoring_indicators >= 3:
|
| 186 |
+
anchor_score = 35
|
| 187 |
+
elif anchoring_indicators >= 2:
|
| 188 |
+
anchor_score = 50
|
| 189 |
+
elif anchoring_indicators >= 1:
|
| 190 |
+
anchor_score = 65
|
| 191 |
+
else:
|
| 192 |
+
anchor_score = 90
|
| 193 |
+
|
| 194 |
+
biases.append({
|
| 195 |
+
"name": "Anchoring",
|
| 196 |
+
"description": "Fixating on purchase price rather than current fundamentals and forward outlook",
|
| 197 |
+
"score": round(anchor_score, 1),
|
| 198 |
+
"grade": _grade(anchor_score),
|
| 199 |
+
"severity": _severity(anchor_score),
|
| 200 |
+
"detail": f"{anchoring_indicators} positions with >20% loss still being held",
|
| 201 |
+
"icon": "β",
|
| 202 |
+
})
|
| 203 |
+
if anchor_score < 60:
|
| 204 |
+
deep_ticker_list = ", ".join(h.get("ticker", "?") for h in deep_losers[:3])
|
| 205 |
+
tips.append(f"β **Anchoring detected** on {deep_ticker_list}. Evaluate each on current fundamentals, not your average cost.")
|
| 206 |
+
|
| 207 |
+
# ββ 6. Loss Aversion βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 208 |
+
# Asymmetric P&L: much more aggregate loss unrealized than gain
|
| 209 |
+
total_unrealized_gain = sum(h.get("pnl", 0) for h in winners) if winners else 0
|
| 210 |
+
total_unrealized_loss = sum(abs(h.get("pnl", 0)) for h in losers) if losers else 0
|
| 211 |
+
|
| 212 |
+
if total_unrealized_loss > total_unrealized_gain * 2:
|
| 213 |
+
la_score = max(25, 70 - (total_unrealized_loss / max(total_unrealized_gain, 1) - 1) * 15)
|
| 214 |
+
elif total_unrealized_loss > total_unrealized_gain:
|
| 215 |
+
la_score = 65
|
| 216 |
+
else:
|
| 217 |
+
la_score = 85
|
| 218 |
+
|
| 219 |
+
biases.append({
|
| 220 |
+
"name": "Loss Aversion",
|
| 221 |
+
"description": "Asymmetric sensitivity β feeling losses ~2x more than equivalent gains",
|
| 222 |
+
"score": round(la_score, 1),
|
| 223 |
+
"grade": _grade(la_score),
|
| 224 |
+
"severity": _severity(la_score),
|
| 225 |
+
"detail": f"Unrealized gains: ${total_unrealized_gain:,.0f} vs losses: ${total_unrealized_loss:,.0f}",
|
| 226 |
+
"icon": "π°",
|
| 227 |
+
})
|
| 228 |
+
if la_score < 60:
|
| 229 |
+
tips.append("π° **Loss Aversion pattern detected.** Your unrealized losses significantly outweigh gains. Consider a systematic stop-loss discipline.")
|
| 230 |
+
|
| 231 |
+
# ββ Overall Behavioral Score βββββββββββββββββββββββββββββββββββββββββ
|
| 232 |
+
overall = round(np.mean([b["score"] for b in biases]), 1)
|
| 233 |
+
|
| 234 |
+
return {
|
| 235 |
+
"score": overall,
|
| 236 |
+
"grade": _grade(overall),
|
| 237 |
+
"biases": biases,
|
| 238 |
+
"tips": tips if tips else ["β
No significant behavioral biases detected. Keep maintaining your disciplined approach!"],
|
| 239 |
+
"position_count": len(holdings),
|
| 240 |
+
"winners": len(winners),
|
| 241 |
+
"losers": len(losers),
|
| 242 |
+
}
|
backend/app/services/analytics/health_score.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Portfolio Health Score Engine.
|
| 3 |
+
|
| 4 |
+
Computes a composite 0β100 portfolio health score from 7 weighted dimensions:
|
| 5 |
+
- Diversification (20%) β HHI + correlation clustering
|
| 6 |
+
- Risk-Adjusted Returns (20%) β Sharpe ratio quality
|
| 7 |
+
- Drawdown Risk (15%) β Max drawdown and recovery
|
| 8 |
+
- Concentration Risk (15%) β Top-N position weight
|
| 9 |
+
- Volatility Health (10%) β Rolling vol stability
|
| 10 |
+
- Hedge Coverage (10%) β Protective positions and hedges
|
| 11 |
+
- Liquidity Score (10%) β Volume and spread quality
|
| 12 |
+
|
| 13 |
+
Returns letter grades (A+ to F) with per-component drill-down and coaching tips.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import logging
|
| 19 |
+
from typing import Any, Dict, List
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
|
| 24 |
+
from app.config import get_settings
|
| 25 |
+
from app.services.data_ingestion.yahoo import yahoo_adapter
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
_settings = get_settings()
|
| 30 |
+
RISK_FREE_RATE = _settings.risk_free_rate
|
| 31 |
+
TRADING_DAYS = _settings.trading_days_per_year
|
| 32 |
+
|
| 33 |
+
# ββ Grading ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
|
| 35 |
+
def _grade(score: float) -> str:
|
| 36 |
+
if score >= 95: return "A+"
|
| 37 |
+
if score >= 90: return "A"
|
| 38 |
+
if score >= 85: return "A-"
|
| 39 |
+
if score >= 80: return "B+"
|
| 40 |
+
if score >= 75: return "B"
|
| 41 |
+
if score >= 70: return "B-"
|
| 42 |
+
if score >= 65: return "C+"
|
| 43 |
+
if score >= 60: return "C"
|
| 44 |
+
if score >= 55: return "C-"
|
| 45 |
+
if score >= 50: return "D+"
|
| 46 |
+
if score >= 45: return "D"
|
| 47 |
+
if score >= 40: return "D-"
|
| 48 |
+
return "F"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _grade_color(grade: str) -> str:
|
| 52 |
+
if grade.startswith("A"): return "#00c853"
|
| 53 |
+
if grade.startswith("B"): return "#2196f3"
|
| 54 |
+
if grade.startswith("C"): return "#ff9800"
|
| 55 |
+
if grade.startswith("D"): return "#f44336"
|
| 56 |
+
return "#b71c1c"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def compute_health_score(
|
| 60 |
+
holdings: List[Dict[str, Any]],
|
| 61 |
+
total_value: float,
|
| 62 |
+
) -> Dict[str, Any]:
|
| 63 |
+
"""
|
| 64 |
+
Compute comprehensive portfolio health score.
|
| 65 |
+
|
| 66 |
+
Returns per-component scores, overall score, grade, and coaching tips.
|
| 67 |
+
"""
|
| 68 |
+
if not holdings or total_value <= 0:
|
| 69 |
+
return {"score": 0, "grade": "F", "components": [], "tips": ["Add holdings to get a health score."]}
|
| 70 |
+
|
| 71 |
+
# Fetch returns for volatility/Sharpe
|
| 72 |
+
tickers = list(set(h.get("ticker", "") for h in holdings if h.get("market_value", 0) > 0))
|
| 73 |
+
weights = []
|
| 74 |
+
returns_data = {}
|
| 75 |
+
|
| 76 |
+
for h in holdings:
|
| 77 |
+
weights.append(h.get("market_value", 0) / total_value)
|
| 78 |
+
|
| 79 |
+
for ticker in tickers:
|
| 80 |
+
try:
|
| 81 |
+
df = await yahoo_adapter.get_price_dataframe(ticker, period="1y")
|
| 82 |
+
if not df.empty and len(df) > 20:
|
| 83 |
+
returns_data[ticker] = df["Close"].pct_change().dropna()
|
| 84 |
+
except Exception:
|
| 85 |
+
continue
|
| 86 |
+
|
| 87 |
+
components = []
|
| 88 |
+
tips = []
|
| 89 |
+
|
| 90 |
+
# ββ 1. Diversification Score (20%) βββββββββββββββββββββββββββββββββββ
|
| 91 |
+
mkt_values = [h.get("market_value", 0) for h in holdings if h.get("market_value", 0) > 0]
|
| 92 |
+
if mkt_values:
|
| 93 |
+
w = np.array(mkt_values) / sum(mkt_values)
|
| 94 |
+
hhi = float(np.sum(w ** 2))
|
| 95 |
+
# HHI = 1 means single stock, HHI = 1/N means perfect diversification
|
| 96 |
+
n = len(w)
|
| 97 |
+
optimal_hhi = 1 / n if n > 0 else 1
|
| 98 |
+
# Score: 100 at optimal HHI, 0 at HHI=1
|
| 99 |
+
div_score = max(0, min(100, (1 - hhi) / (1 - optimal_hhi) * 100)) if n > 1 else 20
|
| 100 |
+
|
| 101 |
+
# Sector concentration
|
| 102 |
+
sectors = set(h.get("asset_class", "equity") for h in holdings)
|
| 103 |
+
sector_bonus = min(len(sectors) * 10, 30)
|
| 104 |
+
div_score = min(100, div_score * 0.7 + sector_bonus)
|
| 105 |
+
else:
|
| 106 |
+
div_score = 0
|
| 107 |
+
|
| 108 |
+
components.append({
|
| 109 |
+
"name": "Diversification",
|
| 110 |
+
"score": round(div_score, 1),
|
| 111 |
+
"weight": 20,
|
| 112 |
+
"grade": _grade(div_score),
|
| 113 |
+
"detail": f"HHI: {hhi:.3f}, {len(mkt_values)} positions, {len(set(h.get('asset_class', 'equity') for h in holdings))} asset classes",
|
| 114 |
+
})
|
| 115 |
+
if div_score < 60:
|
| 116 |
+
tips.append("π Add positions across more sectors and asset classes to improve diversification.")
|
| 117 |
+
|
| 118 |
+
# ββ 2. Risk-Adjusted Returns (20%) βββββββββββββββββββββββββββββββββββ
|
| 119 |
+
if returns_data and len(returns_data) > 0:
|
| 120 |
+
# Compute portfolio returns
|
| 121 |
+
aligned_returns = pd.DataFrame(returns_data).dropna()
|
| 122 |
+
if not aligned_returns.empty:
|
| 123 |
+
holding_weights = np.array([
|
| 124 |
+
sum(h.get("market_value", 0) for h in holdings if h.get("ticker") == t) / total_value
|
| 125 |
+
for t in aligned_returns.columns
|
| 126 |
+
])
|
| 127 |
+
port_returns = aligned_returns.values @ holding_weights
|
| 128 |
+
ann_return = float(np.mean(port_returns) * TRADING_DAYS)
|
| 129 |
+
ann_vol = float(np.std(port_returns, ddof=1) * np.sqrt(TRADING_DAYS))
|
| 130 |
+
sharpe = (ann_return - RISK_FREE_RATE) / ann_vol if ann_vol > 0 else 0
|
| 131 |
+
|
| 132 |
+
# Sharpe > 2 = 100, Sharpe 0 = 40, Sharpe < -1 = 0
|
| 133 |
+
rar_score = max(0, min(100, 40 + sharpe * 30))
|
| 134 |
+
else:
|
| 135 |
+
rar_score = 50
|
| 136 |
+
sharpe = 0
|
| 137 |
+
else:
|
| 138 |
+
rar_score = 50
|
| 139 |
+
sharpe = 0
|
| 140 |
+
|
| 141 |
+
components.append({
|
| 142 |
+
"name": "Risk-Adjusted Returns",
|
| 143 |
+
"score": round(rar_score, 1),
|
| 144 |
+
"weight": 20,
|
| 145 |
+
"grade": _grade(rar_score),
|
| 146 |
+
"detail": f"Sharpe: {sharpe:.2f}",
|
| 147 |
+
})
|
| 148 |
+
if rar_score < 60:
|
| 149 |
+
tips.append("π Consider rebalancing toward higher Sharpe ratio positions to improve risk-adjusted returns.")
|
| 150 |
+
|
| 151 |
+
# ββ 3. Drawdown Risk (15%) βββββββββββββββββββββββββββββββββββββββββββ
|
| 152 |
+
if returns_data and 'port_returns' in dir():
|
| 153 |
+
cum = np.cumprod(1 + port_returns)
|
| 154 |
+
peak = np.maximum.accumulate(cum)
|
| 155 |
+
dd = (cum - peak) / peak
|
| 156 |
+
max_dd = float(np.min(dd)) if len(dd) > 0 else 0
|
| 157 |
+
# Max DD better than -5% = 100, -20% = 50, -40% = 0
|
| 158 |
+
dd_score = max(0, min(100, 100 + max_dd * 250))
|
| 159 |
+
else:
|
| 160 |
+
dd_score = 60
|
| 161 |
+
max_dd = 0
|
| 162 |
+
|
| 163 |
+
components.append({
|
| 164 |
+
"name": "Drawdown Risk",
|
| 165 |
+
"score": round(dd_score, 1),
|
| 166 |
+
"weight": 15,
|
| 167 |
+
"grade": _grade(dd_score),
|
| 168 |
+
"detail": f"Max Drawdown: {max_dd*100:.1f}%",
|
| 169 |
+
})
|
| 170 |
+
if dd_score < 50:
|
| 171 |
+
tips.append("π‘οΈ Large drawdown risk detected. Consider adding hedges or reducing volatile positions.")
|
| 172 |
+
|
| 173 |
+
# ββ 4. Concentration Risk (15%) ββββββββββββββββββββββββββββββββββββββ
|
| 174 |
+
sorted_weights = sorted([h.get("weight", 0) for h in holdings], reverse=True)
|
| 175 |
+
top1 = sorted_weights[0] if sorted_weights else 0
|
| 176 |
+
top3 = sum(sorted_weights[:3]) if len(sorted_weights) >= 3 else sum(sorted_weights)
|
| 177 |
+
|
| 178 |
+
# Top 1 < 20% and Top 3 < 50% = 100
|
| 179 |
+
conc_score = max(0, min(100, 100 - max(0, top1 - 15) * 3 - max(0, top3 - 40) * 2))
|
| 180 |
+
|
| 181 |
+
components.append({
|
| 182 |
+
"name": "Concentration Risk",
|
| 183 |
+
"score": round(conc_score, 1),
|
| 184 |
+
"weight": 15,
|
| 185 |
+
"grade": _grade(conc_score),
|
| 186 |
+
"detail": f"Top holding: {top1:.1f}%, Top 3: {top3:.1f}%",
|
| 187 |
+
})
|
| 188 |
+
if conc_score < 60:
|
| 189 |
+
tips.append(f"βοΈ Top position is {top1:.0f}% of portfolio. Consider trimming to below 15%.")
|
| 190 |
+
|
| 191 |
+
# ββ 5. Volatility Health (10%) βββββββββββββββββββββββββββββββββββββββ
|
| 192 |
+
if 'ann_vol' in dir() and ann_vol > 0:
|
| 193 |
+
# Vol < 10% = 100, Vol 20% = 70, Vol > 40% = 20
|
| 194 |
+
vol_score = max(0, min(100, 120 - ann_vol * 250))
|
| 195 |
+
else:
|
| 196 |
+
vol_score = 60
|
| 197 |
+
ann_vol = 0
|
| 198 |
+
|
| 199 |
+
components.append({
|
| 200 |
+
"name": "Volatility",
|
| 201 |
+
"score": round(vol_score, 1),
|
| 202 |
+
"weight": 10,
|
| 203 |
+
"grade": _grade(vol_score),
|
| 204 |
+
"detail": f"Annualized Vol: {ann_vol*100:.1f}%",
|
| 205 |
+
})
|
| 206 |
+
|
| 207 |
+
# ββ 6. Hedge Coverage (10%) ββββββββββββββββββββββββββββββββββββββββββ
|
| 208 |
+
short_positions = sum(1 for h in holdings if h.get("position_type") == "short")
|
| 209 |
+
etf_positions = sum(1 for h in holdings if h.get("asset_class") in ("etf", "fixed_income", "commodity"))
|
| 210 |
+
hedge_ratio = (short_positions + etf_positions) / max(len(holdings), 1) * 100
|
| 211 |
+
|
| 212 |
+
# Ideal: 10β30% hedge coverage
|
| 213 |
+
if 10 <= hedge_ratio <= 30:
|
| 214 |
+
hedge_score = 90
|
| 215 |
+
elif hedge_ratio > 30:
|
| 216 |
+
hedge_score = 70 # Over-hedged
|
| 217 |
+
elif hedge_ratio > 0:
|
| 218 |
+
hedge_score = 50 + hedge_ratio * 4
|
| 219 |
+
else:
|
| 220 |
+
hedge_score = 30
|
| 221 |
+
|
| 222 |
+
components.append({
|
| 223 |
+
"name": "Hedge Coverage",
|
| 224 |
+
"score": round(hedge_score, 1),
|
| 225 |
+
"weight": 10,
|
| 226 |
+
"grade": _grade(hedge_score),
|
| 227 |
+
"detail": f"{short_positions} hedges, {etf_positions} diversifiers ({hedge_ratio:.0f}% coverage)",
|
| 228 |
+
})
|
| 229 |
+
if hedge_score < 60:
|
| 230 |
+
tips.append("π Consider adding hedging positions (inverse ETFs, bonds, or protective puts).")
|
| 231 |
+
|
| 232 |
+
# ββ 7. Liquidity Score (10%) βββββββββββββββββββββββββββββββββββββββββ
|
| 233 |
+
# Based on number of positions and asset class mix
|
| 234 |
+
liquid_assets = sum(1 for h in holdings if h.get("asset_class") in ("equity", "etf"))
|
| 235 |
+
illiquid_assets = sum(1 for h in holdings if h.get("asset_class") in ("crypto", "commodity", "option"))
|
| 236 |
+
liquidity_score = min(100, (liquid_assets / max(len(holdings), 1)) * 100 + 10)
|
| 237 |
+
|
| 238 |
+
components.append({
|
| 239 |
+
"name": "Liquidity",
|
| 240 |
+
"score": round(liquidity_score, 1),
|
| 241 |
+
"weight": 10,
|
| 242 |
+
"grade": _grade(liquidity_score),
|
| 243 |
+
"detail": f"{liquid_assets} liquid, {illiquid_assets} illiquid positions",
|
| 244 |
+
})
|
| 245 |
+
|
| 246 |
+
# ββ Overall Score ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 247 |
+
overall = sum(c["score"] * c["weight"] / 100 for c in components)
|
| 248 |
+
overall = round(overall, 1)
|
| 249 |
+
|
| 250 |
+
return {
|
| 251 |
+
"score": overall,
|
| 252 |
+
"grade": _grade(overall),
|
| 253 |
+
"grade_color": _grade_color(_grade(overall)),
|
| 254 |
+
"components": components,
|
| 255 |
+
"tips": tips if tips else ["β
Your portfolio health looks great! Keep monitoring regularly."],
|
| 256 |
+
"position_count": len(holdings),
|
| 257 |
+
"total_value": round(total_value, 2),
|
| 258 |
+
}
|
backend/app/services/analytics/portfolio_dna.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Portfolio DNA Fingerprint Engine.
|
| 3 |
+
|
| 4 |
+
Computes a 6-dimension portfolio fingerprint and compares against
|
| 5 |
+
famous portfolios of legendary investors:
|
| 6 |
+
|
| 7 |
+
Dimensions:
|
| 8 |
+
1. Momentum β trend-following tendency
|
| 9 |
+
2. Value β valuation discipline
|
| 10 |
+
3. Volatility β risk tolerance
|
| 11 |
+
4. Regime Sensitivity β macro beta
|
| 12 |
+
5. Concentration β conviction level
|
| 13 |
+
6. Hedge Coverage β defensive posture
|
| 14 |
+
|
| 15 |
+
Famous benchmarks:
|
| 16 |
+
- Warren Buffett β deep value, concentrated, low turnover
|
| 17 |
+
- Ray Dalio β risk parity, all-weather, maximum diversification
|
| 18 |
+
- Cathie Wood β high momentum growth, concentrated, volatile
|
| 19 |
+
- Renaissance Technologies β market-neutral, low vol, maximum hedge
|
| 20 |
+
- Bridgewater Pure Alpha β macro, balanced, regime-aware
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import logging
|
| 26 |
+
from typing import Any, Dict, List
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
import pandas as pd
|
| 30 |
+
|
| 31 |
+
from app.config import get_settings
|
| 32 |
+
from app.services.data_ingestion.yahoo import yahoo_adapter
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
|
| 36 |
+
_settings = get_settings()
|
| 37 |
+
TRADING_DAYS = _settings.trading_days_per_year
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ββ Famous Portfolio DNA Profiles (0-100 per dimension) ββββββββββββββββββ
|
| 41 |
+
|
| 42 |
+
FAMOUS_PORTFOLIOS = {
|
| 43 |
+
"buffett": {
|
| 44 |
+
"name": "Warren Buffett",
|
| 45 |
+
"description": "Value investing with deep moats, concentrated bets",
|
| 46 |
+
"style": "Deep Value + Quality",
|
| 47 |
+
"dna": {
|
| 48 |
+
"momentum": 30,
|
| 49 |
+
"value": 95,
|
| 50 |
+
"volatility": 35,
|
| 51 |
+
"regime_sensitivity": 40,
|
| 52 |
+
"concentration": 80,
|
| 53 |
+
"hedge_coverage": 15,
|
| 54 |
+
},
|
| 55 |
+
},
|
| 56 |
+
"dalio": {
|
| 57 |
+
"name": "Ray Dalio",
|
| 58 |
+
"description": "All-Weather risk parity, maximum diversification",
|
| 59 |
+
"style": "Risk Parity + Macro",
|
| 60 |
+
"dna": {
|
| 61 |
+
"momentum": 50,
|
| 62 |
+
"value": 60,
|
| 63 |
+
"volatility": 20,
|
| 64 |
+
"regime_sensitivity": 70,
|
| 65 |
+
"concentration": 15,
|
| 66 |
+
"hedge_coverage": 85,
|
| 67 |
+
},
|
| 68 |
+
},
|
| 69 |
+
"wood": {
|
| 70 |
+
"name": "Cathie Wood (ARK)",
|
| 71 |
+
"description": "Disruptive innovation, high conviction growth",
|
| 72 |
+
"style": "Growth Momentum",
|
| 73 |
+
"dna": {
|
| 74 |
+
"momentum": 90,
|
| 75 |
+
"value": 10,
|
| 76 |
+
"volatility": 85,
|
| 77 |
+
"regime_sensitivity": 75,
|
| 78 |
+
"concentration": 70,
|
| 79 |
+
"hedge_coverage": 5,
|
| 80 |
+
},
|
| 81 |
+
},
|
| 82 |
+
"renaissance": {
|
| 83 |
+
"name": "Renaissance Technologies",
|
| 84 |
+
"description": "Quantitative market-neutral, Medallion Fund",
|
| 85 |
+
"style": "Quantitative Market-Neutral",
|
| 86 |
+
"dna": {
|
| 87 |
+
"momentum": 60,
|
| 88 |
+
"value": 50,
|
| 89 |
+
"volatility": 10,
|
| 90 |
+
"regime_sensitivity": 15,
|
| 91 |
+
"concentration": 5,
|
| 92 |
+
"hedge_coverage": 95,
|
| 93 |
+
},
|
| 94 |
+
},
|
| 95 |
+
"bridgewater": {
|
| 96 |
+
"name": "Bridgewater Pure Alpha",
|
| 97 |
+
"description": "Macro alpha from global diversification",
|
| 98 |
+
"style": "Global Macro",
|
| 99 |
+
"dna": {
|
| 100 |
+
"momentum": 55,
|
| 101 |
+
"value": 55,
|
| 102 |
+
"volatility": 40,
|
| 103 |
+
"regime_sensitivity": 90,
|
| 104 |
+
"concentration": 20,
|
| 105 |
+
"hedge_coverage": 70,
|
| 106 |
+
},
|
| 107 |
+
},
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _cosine_similarity(a: List[float], b: List[float]) -> float:
|
| 112 |
+
"""Compute cosine similarity between two vectors."""
|
| 113 |
+
a_arr = np.array(a, dtype=float)
|
| 114 |
+
b_arr = np.array(b, dtype=float)
|
| 115 |
+
dot = np.dot(a_arr, b_arr)
|
| 116 |
+
norm_a = np.linalg.norm(a_arr)
|
| 117 |
+
norm_b = np.linalg.norm(b_arr)
|
| 118 |
+
if norm_a == 0 or norm_b == 0:
|
| 119 |
+
return 0.0
|
| 120 |
+
return float(dot / (norm_a * norm_b))
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
async def compute_portfolio_dna(
|
| 124 |
+
holdings: List[Dict[str, Any]],
|
| 125 |
+
total_value: float,
|
| 126 |
+
) -> Dict[str, Any]:
|
| 127 |
+
"""
|
| 128 |
+
Compute the 6-dimension DNA fingerprint for user's portfolio.
|
| 129 |
+
|
| 130 |
+
Returns DNA profile, famous portfolio similarities, and closest match.
|
| 131 |
+
"""
|
| 132 |
+
if not holdings or total_value <= 0:
|
| 133 |
+
return {"error": "No valid holdings for DNA analysis"}
|
| 134 |
+
|
| 135 |
+
n = len(holdings)
|
| 136 |
+
tickers = list(set(h.get("ticker", "") for h in holdings if h.get("market_value", 0) > 0))
|
| 137 |
+
|
| 138 |
+
# Fetch returns for momentum/vol computation
|
| 139 |
+
returns_data = {}
|
| 140 |
+
for ticker in tickers:
|
| 141 |
+
try:
|
| 142 |
+
df = await yahoo_adapter.get_price_dataframe(ticker, period="1y")
|
| 143 |
+
if not df.empty and len(df) > 20:
|
| 144 |
+
returns_data[ticker] = df["Close"].pct_change().dropna()
|
| 145 |
+
except Exception:
|
| 146 |
+
continue
|
| 147 |
+
|
| 148 |
+
# ββ 1. Momentum Score ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 149 |
+
momentum_scores = []
|
| 150 |
+
for h in holdings:
|
| 151 |
+
pnl_pct = h.get("pnl_pct", 0)
|
| 152 |
+
momentum_scores.append(abs(pnl_pct) if pnl_pct > 0 else 0)
|
| 153 |
+
|
| 154 |
+
avg_momentum = np.mean(momentum_scores) if momentum_scores else 0
|
| 155 |
+
# Scale: 0% = 0 score, 50% = 90 score
|
| 156 |
+
momentum = min(100, avg_momentum * 1.8)
|
| 157 |
+
|
| 158 |
+
# ββ 2. Value Score ββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββ
|
| 159 |
+
# Heuristic: positions bought below current price (positive P&L from appreciation)
|
| 160 |
+
value_positions = sum(1 for h in holdings if h.get("pnl_pct", 0) < 5) # held despite small/neg gains
|
| 161 |
+
value = min(100, (value_positions / max(n, 1)) * 100)
|
| 162 |
+
|
| 163 |
+
# ββ 3. Volatility Score ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 164 |
+
if returns_data:
|
| 165 |
+
aligned = pd.DataFrame(returns_data).dropna()
|
| 166 |
+
if not aligned.empty:
|
| 167 |
+
weights = np.array([
|
| 168 |
+
sum(h.get("market_value", 0) for h in holdings if h.get("ticker") == t)
|
| 169 |
+
for t in aligned.columns
|
| 170 |
+
])
|
| 171 |
+
weights = weights / max(weights.sum(), 1)
|
| 172 |
+
port_returns = aligned.values @ weights
|
| 173 |
+
vol = float(np.std(port_returns, ddof=1) * np.sqrt(TRADING_DAYS))
|
| 174 |
+
# Scale: 5% vol = 10, 15% = 40, 30% = 80, 50% = 100
|
| 175 |
+
volatility = min(100, vol * 100 * 2)
|
| 176 |
+
else:
|
| 177 |
+
volatility = 50
|
| 178 |
+
else:
|
| 179 |
+
volatility = 50
|
| 180 |
+
|
| 181 |
+
# ββ 4. Regime Sensitivity ββββββββββββββββββββββββββββββββββββββββββββ
|
| 182 |
+
# Beta-weighted exposure to market
|
| 183 |
+
short_count = sum(1 for h in holdings if h.get("position_type") == "short")
|
| 184 |
+
defensive = sum(1 for h in holdings if h.get("asset_class") in ("fixed_income", "commodity"))
|
| 185 |
+
regime_exp = (1 - (short_count + defensive) / max(n, 1)) * 80
|
| 186 |
+
regime_sensitivity = min(100, regime_exp)
|
| 187 |
+
|
| 188 |
+
# ββ 5. Concentration Score βββββββββββββββββββββββββββββββββββββββββββ
|
| 189 |
+
weights_lst = sorted([h.get("weight", 0) for h in holdings], reverse=True)
|
| 190 |
+
top3_weight = sum(weights_lst[:3])
|
| 191 |
+
# Top 3 = 100% => concentration = 100, Top 3 = 30% => 30
|
| 192 |
+
concentration = min(100, top3_weight)
|
| 193 |
+
|
| 194 |
+
# ββ 6. Hedge Coverage ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 195 |
+
hedges = sum(1 for h in holdings if h.get("position_type") == "short" or
|
| 196 |
+
h.get("asset_class") in ("fixed_income", "commodity", "forex"))
|
| 197 |
+
hedge_coverage = min(100, (hedges / max(n, 1)) * 100 * 3)
|
| 198 |
+
|
| 199 |
+
# ββ Build DNA Profile ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 200 |
+
dna = {
|
| 201 |
+
"momentum": round(momentum, 1),
|
| 202 |
+
"value": round(value, 1),
|
| 203 |
+
"volatility": round(volatility, 1),
|
| 204 |
+
"regime_sensitivity": round(regime_sensitivity, 1),
|
| 205 |
+
"concentration": round(concentration, 1),
|
| 206 |
+
"hedge_coverage": round(hedge_coverage, 1),
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
# ββ Compare with Famous Portfolios βββββββββββββββββββββββββββββββββββ
|
| 210 |
+
user_vector = [dna[k] for k in sorted(dna.keys())]
|
| 211 |
+
comparisons = []
|
| 212 |
+
|
| 213 |
+
for fp_id, fp in FAMOUS_PORTFOLIOS.items():
|
| 214 |
+
fp_vector = [fp["dna"][k] for k in sorted(fp["dna"].keys())]
|
| 215 |
+
similarity = _cosine_similarity(user_vector, fp_vector) * 100
|
| 216 |
+
comparisons.append({
|
| 217 |
+
"id": fp_id,
|
| 218 |
+
"name": fp["name"],
|
| 219 |
+
"description": fp["description"],
|
| 220 |
+
"style": fp["style"],
|
| 221 |
+
"similarity": round(similarity, 1),
|
| 222 |
+
"dna": fp["dna"],
|
| 223 |
+
})
|
| 224 |
+
|
| 225 |
+
comparisons.sort(key=lambda x: x["similarity"], reverse=True)
|
| 226 |
+
closest = comparisons[0] if comparisons else None
|
| 227 |
+
|
| 228 |
+
# Determine user's investing style
|
| 229 |
+
dominant = max(dna, key=dna.get) # type: ignore
|
| 230 |
+
style_map = {
|
| 231 |
+
"momentum": "Growth/Momentum Investor",
|
| 232 |
+
"value": "Value Investor",
|
| 233 |
+
"volatility": "Aggressive/Speculative",
|
| 234 |
+
"regime_sensitivity": "Macro-Sensitive",
|
| 235 |
+
"concentration": "High-Conviction",
|
| 236 |
+
"hedge_coverage": "Risk-Managed/Balanced",
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
return {
|
| 240 |
+
"dna": dna,
|
| 241 |
+
"style": style_map.get(dominant, "Balanced"),
|
| 242 |
+
"dominant_trait": dominant,
|
| 243 |
+
"closest_match": closest,
|
| 244 |
+
"comparisons": comparisons,
|
| 245 |
+
"position_count": n,
|
| 246 |
+
}
|
backend/app/services/backtest/engine.py
CHANGED
|
@@ -19,11 +19,16 @@ from typing import Any, Dict, List, Optional
|
|
| 19 |
import numpy as np
|
| 20 |
import pandas as pd
|
| 21 |
|
|
|
|
| 22 |
from app.services.data_ingestion.yahoo import yahoo_adapter
|
| 23 |
from app.services.feature_engineering.pipeline import feature_pipeline
|
| 24 |
|
| 25 |
logger = logging.getLogger(__name__)
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
class BacktestEngine:
|
| 29 |
"""Event-driven portfolio backtester."""
|
|
@@ -263,11 +268,13 @@ class BacktestEngine:
|
|
| 263 |
# Monthly returns
|
| 264 |
monthly_returns = self._compute_monthly_returns(equity_curve)
|
| 265 |
|
| 266 |
-
# Drawdown series
|
|
|
|
| 267 |
for point in equity_curve:
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
| 271 |
|
| 272 |
return {
|
| 273 |
"status": "completed",
|
|
@@ -371,17 +378,17 @@ class BacktestEngine:
|
|
| 371 |
returns = np.array(daily_returns)
|
| 372 |
final = equity_values[-1]
|
| 373 |
n_days = len(returns)
|
| 374 |
-
n_years = n_days /
|
| 375 |
|
| 376 |
total_return = (final / initial_capital - 1)
|
| 377 |
ann_return = (1 + total_return) ** (1 / n_years) - 1 if n_years > 0 else 0
|
| 378 |
-
ann_vol = float(np.std(returns, ddof=1) * np.sqrt(
|
| 379 |
-
sharpe = (ann_return -
|
| 380 |
|
| 381 |
# Sortino
|
| 382 |
downside = returns[returns < 0]
|
| 383 |
-
down_dev = float(np.std(downside, ddof=1) * np.sqrt(
|
| 384 |
-
sortino = (ann_return -
|
| 385 |
|
| 386 |
# Max drawdown
|
| 387 |
cum = np.cumprod(1 + returns)
|
|
@@ -429,7 +436,7 @@ class BacktestEngine:
|
|
| 429 |
cov_rb = np.cov(r, b)[0, 1]
|
| 430 |
var_b = np.var(b, ddof=1)
|
| 431 |
beta = cov_rb / var_b if var_b > 0 else 1.0
|
| 432 |
-
alpha = ann_return - beta * float(np.mean(b) *
|
| 433 |
metrics["beta"] = round(float(beta), 4)
|
| 434 |
metrics["alpha"] = round(float(alpha), 4)
|
| 435 |
|
|
|
|
| 19 |
import numpy as np
|
| 20 |
import pandas as pd
|
| 21 |
|
| 22 |
+
from app.config import get_settings
|
| 23 |
from app.services.data_ingestion.yahoo import yahoo_adapter
|
| 24 |
from app.services.feature_engineering.pipeline import feature_pipeline
|
| 25 |
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
|
| 28 |
+
_settings = get_settings()
|
| 29 |
+
RISK_FREE_RATE = _settings.risk_free_rate
|
| 30 |
+
TRADING_DAYS = _settings.trading_days_per_year
|
| 31 |
+
|
| 32 |
|
| 33 |
class BacktestEngine:
|
| 34 |
"""Event-driven portfolio backtester."""
|
|
|
|
| 268 |
# Monthly returns
|
| 269 |
monthly_returns = self._compute_monthly_returns(equity_curve)
|
| 270 |
|
| 271 |
+
# Drawdown series β O(n) running peak
|
| 272 |
+
running_peak = 0.0
|
| 273 |
for point in equity_curve:
|
| 274 |
+
running_peak = max(running_peak, point["portfolio_value"])
|
| 275 |
+
point["drawdown"] = round(
|
| 276 |
+
(point["portfolio_value"] - running_peak) / running_peak, 6
|
| 277 |
+
) if running_peak > 0 else 0
|
| 278 |
|
| 279 |
return {
|
| 280 |
"status": "completed",
|
|
|
|
| 378 |
returns = np.array(daily_returns)
|
| 379 |
final = equity_values[-1]
|
| 380 |
n_days = len(returns)
|
| 381 |
+
n_years = n_days / TRADING_DAYS
|
| 382 |
|
| 383 |
total_return = (final / initial_capital - 1)
|
| 384 |
ann_return = (1 + total_return) ** (1 / n_years) - 1 if n_years > 0 else 0
|
| 385 |
+
ann_vol = float(np.std(returns, ddof=1) * np.sqrt(TRADING_DAYS)) if len(returns) > 1 else 0
|
| 386 |
+
sharpe = (ann_return - RISK_FREE_RATE) / ann_vol if ann_vol > 0 else 0
|
| 387 |
|
| 388 |
# Sortino
|
| 389 |
downside = returns[returns < 0]
|
| 390 |
+
down_dev = float(np.std(downside, ddof=1) * np.sqrt(TRADING_DAYS)) if len(downside) > 1 else ann_vol
|
| 391 |
+
sortino = (ann_return - RISK_FREE_RATE) / down_dev if down_dev > 0 else 0
|
| 392 |
|
| 393 |
# Max drawdown
|
| 394 |
cum = np.cumprod(1 + returns)
|
|
|
|
| 436 |
cov_rb = np.cov(r, b)[0, 1]
|
| 437 |
var_b = np.var(b, ddof=1)
|
| 438 |
beta = cov_rb / var_b if var_b > 0 else 1.0
|
| 439 |
+
alpha = ann_return - beta * float(np.mean(b) * TRADING_DAYS)
|
| 440 |
metrics["beta"] = round(float(beta), 4)
|
| 441 |
metrics["alpha"] = round(float(alpha), 4)
|
| 442 |
|
backend/app/services/portfolio/engine.py
CHANGED
|
@@ -3,12 +3,13 @@ Portfolio Construction Engine.
|
|
| 3 |
|
| 4 |
Supports multiple portfolio optimization methods:
|
| 5 |
- Equal Weight
|
| 6 |
-
-
|
| 7 |
- Mean-Variance Optimization (Markowitz)
|
| 8 |
- Minimum Variance
|
| 9 |
- Maximum Sharpe Ratio
|
| 10 |
|
| 11 |
-
Includes
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
|
@@ -20,10 +21,27 @@ import numpy as np
|
|
| 20 |
import pandas as pd
|
| 21 |
from scipy.optimize import minimize
|
| 22 |
|
|
|
|
| 23 |
from app.services.data_ingestion.yahoo import yahoo_adapter
|
| 24 |
|
| 25 |
logger = logging.getLogger(__name__)
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
class PortfolioEngine:
|
| 29 |
"""Portfolio construction and optimization engine."""
|
|
@@ -33,7 +51,7 @@ class PortfolioEngine:
|
|
| 33 |
tickers: List[str],
|
| 34 |
method: str = "mean_variance",
|
| 35 |
period: str = "2y",
|
| 36 |
-
risk_free_rate: float =
|
| 37 |
constraints: Optional[Dict[str, Any]] = None,
|
| 38 |
) -> Dict[str, Any]:
|
| 39 |
"""
|
|
@@ -65,17 +83,18 @@ class PortfolioEngine:
|
|
| 65 |
"method": "equal_weight",
|
| 66 |
}
|
| 67 |
|
| 68 |
-
# 2. Compute expected returns and covariance
|
| 69 |
-
mean_returns = returns_df.mean() *
|
| 70 |
-
cov_matrix =
|
| 71 |
|
| 72 |
# 3. Optimize
|
| 73 |
max_weight = (constraints or {}).get("max_weight", 0.4)
|
| 74 |
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
| 76 |
weights = self._equal_weight(tickers)
|
| 77 |
-
elif method == "risk_parity":
|
| 78 |
-
weights = self._risk_parity(returns_df, tickers)
|
| 79 |
elif method == "min_variance":
|
| 80 |
weights = self._min_variance(mean_returns, cov_matrix, tickers, max_weight)
|
| 81 |
elif method == "max_sharpe":
|
|
@@ -126,9 +145,9 @@ class PortfolioEngine:
|
|
| 126 |
return {t: w for t in tickers}
|
| 127 |
|
| 128 |
@staticmethod
|
| 129 |
-
def
|
| 130 |
-
"""Inverse volatility (simplified risk parity)."""
|
| 131 |
-
vols = returns_df.std() * np.sqrt(
|
| 132 |
inv_vols = 1.0 / vols.replace(0, np.nan)
|
| 133 |
inv_vols = inv_vols.fillna(0)
|
| 134 |
total = inv_vols.sum()
|
|
@@ -137,6 +156,9 @@ class PortfolioEngine:
|
|
| 137 |
weights = inv_vols / total
|
| 138 |
return {t: float(weights.get(t, 0)) for t in tickers}
|
| 139 |
|
|
|
|
|
|
|
|
|
|
| 140 |
@staticmethod
|
| 141 |
def _min_variance(
|
| 142 |
mean_returns: pd.Series,
|
|
@@ -226,9 +248,9 @@ class PortfolioEngine:
|
|
| 226 |
bench_df = await yahoo_adapter.get_price_dataframe(benchmark, period=period)
|
| 227 |
bench_returns = bench_df["Close"].pct_change().dropna().values if not bench_df.empty else None
|
| 228 |
|
| 229 |
-
mean_ret = float(np.mean(portfolio_returns) *
|
| 230 |
-
vol = float(np.std(portfolio_returns) * np.sqrt(
|
| 231 |
-
sharpe = mean_ret / vol if vol > 0 else 0.0
|
| 232 |
|
| 233 |
# Drawdown
|
| 234 |
cum_returns = np.cumprod(1 + portfolio_returns)
|
|
@@ -251,9 +273,9 @@ class PortfolioEngine:
|
|
| 251 |
pr = portfolio_returns[:min_len]
|
| 252 |
br = bench_returns[:min_len]
|
| 253 |
cov_pb = np.cov(pr, br)[0, 1]
|
| 254 |
-
var_b = np.var(br)
|
| 255 |
beta = cov_pb / var_b if var_b > 0 else 1.0
|
| 256 |
-
alpha = mean_ret - beta * float(np.mean(br) *
|
| 257 |
analytics["beta"] = round(float(beta), 4)
|
| 258 |
analytics["alpha"] = round(float(alpha), 4)
|
| 259 |
|
|
|
|
| 3 |
|
| 4 |
Supports multiple portfolio optimization methods:
|
| 5 |
- Equal Weight
|
| 6 |
+
- Inverse Volatility (simplified risk parity)
|
| 7 |
- Mean-Variance Optimization (Markowitz)
|
| 8 |
- Minimum Variance
|
| 9 |
- Maximum Sharpe Ratio
|
| 10 |
|
| 11 |
+
Includes Ledoit-Wolf covariance shrinkage, sector exposure constraints,
|
| 12 |
+
and position limits.
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
|
|
|
| 21 |
import pandas as pd
|
| 22 |
from scipy.optimize import minimize
|
| 23 |
|
| 24 |
+
from app.config import get_settings
|
| 25 |
from app.services.data_ingestion.yahoo import yahoo_adapter
|
| 26 |
|
| 27 |
logger = logging.getLogger(__name__)
|
| 28 |
|
| 29 |
+
_settings = get_settings()
|
| 30 |
+
RISK_FREE_RATE = _settings.risk_free_rate
|
| 31 |
+
TRADING_DAYS = _settings.trading_days_per_year
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _shrink_covariance(returns_df: pd.DataFrame) -> pd.DataFrame:
|
| 35 |
+
"""Apply Ledoit-Wolf shrinkage to the sample covariance matrix."""
|
| 36 |
+
try:
|
| 37 |
+
from sklearn.covariance import LedoitWolf
|
| 38 |
+
lw = LedoitWolf().fit(returns_df.values)
|
| 39 |
+
cov_shrunk = lw.covariance_ * TRADING_DAYS # annualize
|
| 40 |
+
return pd.DataFrame(cov_shrunk, index=returns_df.columns, columns=returns_df.columns)
|
| 41 |
+
except Exception:
|
| 42 |
+
# Fallback to sample covariance
|
| 43 |
+
return returns_df.cov() * TRADING_DAYS
|
| 44 |
+
|
| 45 |
|
| 46 |
class PortfolioEngine:
|
| 47 |
"""Portfolio construction and optimization engine."""
|
|
|
|
| 51 |
tickers: List[str],
|
| 52 |
method: str = "mean_variance",
|
| 53 |
period: str = "2y",
|
| 54 |
+
risk_free_rate: float = RISK_FREE_RATE,
|
| 55 |
constraints: Optional[Dict[str, Any]] = None,
|
| 56 |
) -> Dict[str, Any]:
|
| 57 |
"""
|
|
|
|
| 83 |
"method": "equal_weight",
|
| 84 |
}
|
| 85 |
|
| 86 |
+
# 2. Compute expected returns and covariance (with Ledoit-Wolf shrinkage)
|
| 87 |
+
mean_returns = returns_df.mean() * TRADING_DAYS # Annualized
|
| 88 |
+
cov_matrix = _shrink_covariance(returns_df)
|
| 89 |
|
| 90 |
# 3. Optimize
|
| 91 |
max_weight = (constraints or {}).get("max_weight", 0.4)
|
| 92 |
|
| 93 |
+
# Support both old name "risk_parity" and new name "inverse_volatility"
|
| 94 |
+
if method in ("risk_parity", "inverse_volatility"):
|
| 95 |
+
weights = self._inverse_volatility(returns_df, tickers)
|
| 96 |
+
elif method == "equal_weight":
|
| 97 |
weights = self._equal_weight(tickers)
|
|
|
|
|
|
|
| 98 |
elif method == "min_variance":
|
| 99 |
weights = self._min_variance(mean_returns, cov_matrix, tickers, max_weight)
|
| 100 |
elif method == "max_sharpe":
|
|
|
|
| 145 |
return {t: w for t in tickers}
|
| 146 |
|
| 147 |
@staticmethod
|
| 148 |
+
def _inverse_volatility(returns_df: pd.DataFrame, tickers: List[str]) -> Dict[str, float]:
|
| 149 |
+
"""Inverse volatility weighting (simplified risk parity)."""
|
| 150 |
+
vols = returns_df.std() * np.sqrt(TRADING_DAYS)
|
| 151 |
inv_vols = 1.0 / vols.replace(0, np.nan)
|
| 152 |
inv_vols = inv_vols.fillna(0)
|
| 153 |
total = inv_vols.sum()
|
|
|
|
| 156 |
weights = inv_vols / total
|
| 157 |
return {t: float(weights.get(t, 0)) for t in tickers}
|
| 158 |
|
| 159 |
+
# Keep old name as alias for backward compatibility
|
| 160 |
+
_risk_parity = _inverse_volatility
|
| 161 |
+
|
| 162 |
@staticmethod
|
| 163 |
def _min_variance(
|
| 164 |
mean_returns: pd.Series,
|
|
|
|
| 248 |
bench_df = await yahoo_adapter.get_price_dataframe(benchmark, period=period)
|
| 249 |
bench_returns = bench_df["Close"].pct_change().dropna().values if not bench_df.empty else None
|
| 250 |
|
| 251 |
+
mean_ret = float(np.mean(portfolio_returns) * TRADING_DAYS)
|
| 252 |
+
vol = float(np.std(portfolio_returns, ddof=1) * np.sqrt(TRADING_DAYS))
|
| 253 |
+
sharpe = (mean_ret - RISK_FREE_RATE) / vol if vol > 0 else 0.0
|
| 254 |
|
| 255 |
# Drawdown
|
| 256 |
cum_returns = np.cumprod(1 + portfolio_returns)
|
|
|
|
| 273 |
pr = portfolio_returns[:min_len]
|
| 274 |
br = bench_returns[:min_len]
|
| 275 |
cov_pb = np.cov(pr, br)[0, 1]
|
| 276 |
+
var_b = np.var(br, ddof=1)
|
| 277 |
beta = cov_pb / var_b if var_b > 0 else 1.0
|
| 278 |
+
alpha = mean_ret - beta * float(np.mean(br) * TRADING_DAYS)
|
| 279 |
analytics["beta"] = round(float(beta), 4)
|
| 280 |
analytics["alpha"] = round(float(alpha), 4)
|
| 281 |
|
backend/app/services/risk/crisis_replay.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Historical Crisis Replay Engine.
|
| 3 |
+
|
| 4 |
+
Replays actual daily sector ETF returns from 6 major crises against user holdings:
|
| 5 |
+
- COVID-19 Crash (FebβMar 2020)
|
| 6 |
+
- 2022 Rate Hike Crash (JanβJun 2022)
|
| 7 |
+
- Dot-Com Burst (MarβOct 2000)
|
| 8 |
+
- 2008 Financial Crisis (SepβNov 2008)
|
| 9 |
+
- Flash Crash (May 6, 2010)
|
| 10 |
+
- China/Greece Selloff (Aug 2015)
|
| 11 |
+
|
| 12 |
+
For each crisis, fetches actual daily returns from sector ETFs and maps
|
| 13 |
+
user holdings to sectors to simulate day-by-day equity curve impact.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import logging
|
| 19 |
+
from typing import Any, Dict, List
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
|
| 24 |
+
from app.services.data_ingestion.yahoo import yahoo_adapter
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ββ Crisis Definitions βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
|
| 31 |
+
CRISES = {
|
| 32 |
+
"covid_2020": {
|
| 33 |
+
"name": "COVID-19 Crash",
|
| 34 |
+
"description": "Pandemic-driven global market collapse β fastest bear market in history",
|
| 35 |
+
"start": "2020-02-19",
|
| 36 |
+
"end": "2020-03-23",
|
| 37 |
+
"peak_decline": -33.9,
|
| 38 |
+
"recovery_date": "2020-08-18",
|
| 39 |
+
},
|
| 40 |
+
"rate_hike_2022": {
|
| 41 |
+
"name": "2022 Rate Hike Crash",
|
| 42 |
+
"description": "Fed aggressive tightening β tech-led growth selloff",
|
| 43 |
+
"start": "2022-01-03",
|
| 44 |
+
"end": "2022-06-16",
|
| 45 |
+
"peak_decline": -23.6,
|
| 46 |
+
"recovery_date": "2024-01-19",
|
| 47 |
+
},
|
| 48 |
+
"dotcom_2000": {
|
| 49 |
+
"name": "Dot-Com Burst",
|
| 50 |
+
"description": "Technology bubble burst β NASDAQ lost 78% peak to trough",
|
| 51 |
+
"start": "2000-03-10",
|
| 52 |
+
"end": "2000-10-09",
|
| 53 |
+
"peak_decline": -39.3,
|
| 54 |
+
"recovery_date": None,
|
| 55 |
+
},
|
| 56 |
+
"gfc_2008": {
|
| 57 |
+
"name": "2008 Financial Crisis",
|
| 58 |
+
"description": "Subprime mortgage collapse β global financial system nearly failed",
|
| 59 |
+
"start": "2008-09-15",
|
| 60 |
+
"end": "2008-11-20",
|
| 61 |
+
"peak_decline": -46.1,
|
| 62 |
+
"recovery_date": "2013-03-14",
|
| 63 |
+
},
|
| 64 |
+
"flash_crash_2010": {
|
| 65 |
+
"name": "2010 Flash Crash",
|
| 66 |
+
"description": "Algorithmic trading cascade β Dow Jones dropped 1000 points in minutes",
|
| 67 |
+
"start": "2010-05-06",
|
| 68 |
+
"end": "2010-05-25",
|
| 69 |
+
"peak_decline": -12.4,
|
| 70 |
+
"recovery_date": "2010-06-21",
|
| 71 |
+
},
|
| 72 |
+
"china_2015": {
|
| 73 |
+
"name": "China/Greece Selloff 2015",
|
| 74 |
+
"description": "Chinese stock market crash + Greek debt crisis contagion",
|
| 75 |
+
"start": "2015-08-18",
|
| 76 |
+
"end": "2015-08-25",
|
| 77 |
+
"peak_decline": -11.2,
|
| 78 |
+
"recovery_date": "2015-11-03",
|
| 79 |
+
},
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
# Sector ETF mapping for replay
|
| 83 |
+
SECTOR_ETFS = {
|
| 84 |
+
"Technology": "XLK",
|
| 85 |
+
"Financial": "XLF",
|
| 86 |
+
"Healthcare": "XLV",
|
| 87 |
+
"Consumer Discretionary": "XLY",
|
| 88 |
+
"Consumer Staples": "XLP",
|
| 89 |
+
"Energy": "XLE",
|
| 90 |
+
"Industrials": "XLI",
|
| 91 |
+
"Materials": "XLB",
|
| 92 |
+
"Real Estate": "XLRE",
|
| 93 |
+
"Utilities": "XLU",
|
| 94 |
+
"Communication Services": "XLC",
|
| 95 |
+
"equity": "SPY", # default
|
| 96 |
+
"etf": "SPY",
|
| 97 |
+
"crypto": "SPY", # approximate
|
| 98 |
+
"default": "SPY",
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
async def replay_crisis(
|
| 103 |
+
holdings: List[Dict[str, Any]],
|
| 104 |
+
total_value: float,
|
| 105 |
+
crisis_id: str,
|
| 106 |
+
) -> Dict[str, Any]:
|
| 107 |
+
"""
|
| 108 |
+
Replay a historical crisis against user holdings.
|
| 109 |
+
|
| 110 |
+
Fetches actual daily returns from the crisis period for each sector,
|
| 111 |
+
maps user holdings to sectors, and simulates the day-by-day impact.
|
| 112 |
+
"""
|
| 113 |
+
if crisis_id not in CRISES:
|
| 114 |
+
return {"error": f"Unknown crisis: {crisis_id}. Available: {list(CRISES.keys())}"}
|
| 115 |
+
|
| 116 |
+
if not holdings or total_value <= 0:
|
| 117 |
+
return {"error": "No valid holdings for crisis replay"}
|
| 118 |
+
|
| 119 |
+
crisis = CRISES[crisis_id]
|
| 120 |
+
|
| 121 |
+
# Map holdings to sectors and determine sector ETFs
|
| 122 |
+
sector_weights: Dict[str, float] = {}
|
| 123 |
+
for h in holdings:
|
| 124 |
+
sector = h.get("sector", h.get("asset_class", "equity"))
|
| 125 |
+
w = h.get("market_value", 0) / total_value
|
| 126 |
+
sector_weights[sector] = sector_weights.get(sector, 0) + w
|
| 127 |
+
|
| 128 |
+
# Fetch actual crisis period returns for each needed sector ETF
|
| 129 |
+
sector_etf_returns: Dict[str, pd.Series] = {}
|
| 130 |
+
needed_etfs = set()
|
| 131 |
+
|
| 132 |
+
for sector in sector_weights:
|
| 133 |
+
etf = SECTOR_ETFS.get(sector, SECTOR_ETFS["default"])
|
| 134 |
+
needed_etfs.add(etf)
|
| 135 |
+
|
| 136 |
+
for etf in needed_etfs:
|
| 137 |
+
try:
|
| 138 |
+
period = "max" if crisis["start"] < "2015-01-01" else "10y"
|
| 139 |
+
df = await yahoo_adapter.get_price_dataframe(etf, period=period)
|
| 140 |
+
if df.empty:
|
| 141 |
+
continue
|
| 142 |
+
df.index = pd.to_datetime(df.index)
|
| 143 |
+
mask = (df.index >= crisis["start"]) & (df.index <= crisis["end"])
|
| 144 |
+
crisis_data = df.loc[mask, "Close"]
|
| 145 |
+
if not crisis_data.empty:
|
| 146 |
+
sector_etf_returns[etf] = crisis_data.pct_change().dropna()
|
| 147 |
+
except Exception as e:
|
| 148 |
+
logger.warning("Failed to fetch %s for crisis replay: %s", etf, e)
|
| 149 |
+
|
| 150 |
+
# Fallback: use SPY if we couldn't get sector-specific data
|
| 151 |
+
spy_returns = sector_etf_returns.get("SPY")
|
| 152 |
+
if spy_returns is None or spy_returns.empty:
|
| 153 |
+
# Try fetching SPY directly
|
| 154 |
+
try:
|
| 155 |
+
df = await yahoo_adapter.get_price_dataframe("SPY", period="max")
|
| 156 |
+
df.index = pd.to_datetime(df.index)
|
| 157 |
+
mask = (df.index >= crisis["start"]) & (df.index <= crisis["end"])
|
| 158 |
+
spy_data = df.loc[mask, "Close"]
|
| 159 |
+
if not spy_data.empty:
|
| 160 |
+
spy_returns = spy_data.pct_change().dropna()
|
| 161 |
+
sector_etf_returns["SPY"] = spy_returns
|
| 162 |
+
except Exception:
|
| 163 |
+
pass
|
| 164 |
+
|
| 165 |
+
if not sector_etf_returns:
|
| 166 |
+
return {"error": "Could not fetch crisis period data"}
|
| 167 |
+
|
| 168 |
+
# Build daily portfolio returns
|
| 169 |
+
# For each trading day in the crisis, compute weighted portfolio return
|
| 170 |
+
# from sector ETF returns
|
| 171 |
+
all_dates = set()
|
| 172 |
+
for returns_series in sector_etf_returns.values():
|
| 173 |
+
all_dates.update(returns_series.index.tolist())
|
| 174 |
+
dates_sorted = sorted(all_dates)
|
| 175 |
+
|
| 176 |
+
equity_curve = []
|
| 177 |
+
portfolio_val = total_value
|
| 178 |
+
cumulative_return = 1.0
|
| 179 |
+
|
| 180 |
+
for dt in dates_sorted:
|
| 181 |
+
daily_return = 0.0
|
| 182 |
+
for sector, weight in sector_weights.items():
|
| 183 |
+
etf = SECTOR_ETFS.get(sector, "SPY")
|
| 184 |
+
returns_series = sector_etf_returns.get(etf)
|
| 185 |
+
if returns_series is not None and dt in returns_series.index:
|
| 186 |
+
daily_return += weight * float(returns_series.loc[dt])
|
| 187 |
+
elif spy_returns is not None and dt in spy_returns.index:
|
| 188 |
+
daily_return += weight * float(spy_returns.loc[dt])
|
| 189 |
+
|
| 190 |
+
cumulative_return *= (1 + daily_return)
|
| 191 |
+
portfolio_val = total_value * cumulative_return
|
| 192 |
+
|
| 193 |
+
equity_curve.append({
|
| 194 |
+
"date": dt.strftime("%Y-%m-%d"),
|
| 195 |
+
"portfolio_value": round(portfolio_val, 2),
|
| 196 |
+
"daily_return": round(daily_return * 100, 2),
|
| 197 |
+
"cumulative_return": round((cumulative_return - 1) * 100, 2),
|
| 198 |
+
})
|
| 199 |
+
|
| 200 |
+
total_pnl = portfolio_val - total_value
|
| 201 |
+
total_pnl_pct = (cumulative_return - 1) * 100
|
| 202 |
+
min_value = min(p["portfolio_value"] for p in equity_curve) if equity_curve else total_value
|
| 203 |
+
max_drawdown = ((min_value - total_value) / total_value) * 100
|
| 204 |
+
|
| 205 |
+
# Per-holding impact (using final cumulative return per sector)
|
| 206 |
+
holding_impacts = []
|
| 207 |
+
for h in holdings:
|
| 208 |
+
sector = h.get("sector", h.get("asset_class", "equity"))
|
| 209 |
+
etf = SECTOR_ETFS.get(sector, "SPY")
|
| 210 |
+
# Overall sector return during crisis
|
| 211 |
+
returns_series = sector_etf_returns.get(etf)
|
| 212 |
+
if returns_series is not None and len(returns_series) > 0:
|
| 213 |
+
sector_total_return = float((1 + returns_series).prod() - 1)
|
| 214 |
+
elif spy_returns is not None:
|
| 215 |
+
sector_total_return = float((1 + spy_returns).prod() - 1)
|
| 216 |
+
else:
|
| 217 |
+
sector_total_return = 0
|
| 218 |
+
|
| 219 |
+
mv = h.get("market_value", 0)
|
| 220 |
+
impact = mv * sector_total_return
|
| 221 |
+
|
| 222 |
+
holding_impacts.append({
|
| 223 |
+
"ticker": h.get("ticker", ""),
|
| 224 |
+
"sector": sector,
|
| 225 |
+
"market_value": round(mv, 2),
|
| 226 |
+
"sector_return": round(sector_total_return * 100, 2),
|
| 227 |
+
"impact": round(impact, 2),
|
| 228 |
+
})
|
| 229 |
+
|
| 230 |
+
holding_impacts.sort(key=lambda x: x["impact"])
|
| 231 |
+
|
| 232 |
+
return {
|
| 233 |
+
"crisis": crisis,
|
| 234 |
+
"portfolio_value": round(total_value, 2),
|
| 235 |
+
"final_value": round(portfolio_val, 2),
|
| 236 |
+
"total_pnl": round(total_pnl, 2),
|
| 237 |
+
"total_pnl_pct": round(total_pnl_pct, 2),
|
| 238 |
+
"max_drawdown_pct": round(max_drawdown, 2),
|
| 239 |
+
"trading_days": len(equity_curve),
|
| 240 |
+
"equity_curve": equity_curve,
|
| 241 |
+
"holding_impacts": holding_impacts,
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
async def replay_all_crises(
|
| 246 |
+
holdings: List[Dict[str, Any]],
|
| 247 |
+
total_value: float,
|
| 248 |
+
) -> Dict[str, Any]:
|
| 249 |
+
"""Replay all crises and return summary comparison."""
|
| 250 |
+
results = []
|
| 251 |
+
for crisis_id in CRISES:
|
| 252 |
+
try:
|
| 253 |
+
result = await replay_crisis(holdings, total_value, crisis_id)
|
| 254 |
+
if "error" not in result:
|
| 255 |
+
results.append({
|
| 256 |
+
"crisis_id": crisis_id,
|
| 257 |
+
"name": CRISES[crisis_id]["name"],
|
| 258 |
+
"total_pnl": result["total_pnl"],
|
| 259 |
+
"total_pnl_pct": result["total_pnl_pct"],
|
| 260 |
+
"max_drawdown_pct": result["max_drawdown_pct"],
|
| 261 |
+
"trading_days": result["trading_days"],
|
| 262 |
+
})
|
| 263 |
+
except Exception as e:
|
| 264 |
+
logger.warning("Crisis replay failed for %s: %s", crisis_id, e)
|
| 265 |
+
|
| 266 |
+
results.sort(key=lambda x: x["total_pnl"])
|
| 267 |
+
return {"crises": results, "available": list(CRISES.keys())}
|
backend/app/services/risk/monte_carlo.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Monte Carlo VaR Engine.
|
| 3 |
+
|
| 4 |
+
Runs Cholesky-decomposed correlated multi-asset simulations:
|
| 5 |
+
- 10,000 path default
|
| 6 |
+
- Fan chart percentiles (5th/25th/50th/75th/95th)
|
| 7 |
+
- Regime-conditioned simulation using HMM states
|
| 8 |
+
- Value-at-Risk (VaR) and Conditional VaR (CVaR) at multiple confidence levels
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
from typing import Any, Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
|
| 19 |
+
from app.config import get_settings
|
| 20 |
+
from app.services.data_ingestion.yahoo import yahoo_adapter
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
_settings = get_settings()
|
| 25 |
+
TRADING_DAYS = _settings.trading_days_per_year
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def run_monte_carlo(
|
| 29 |
+
holdings: List[Dict[str, Any]],
|
| 30 |
+
horizon_days: int = 60,
|
| 31 |
+
n_simulations: int = 10000,
|
| 32 |
+
regime_conditioned: bool = False,
|
| 33 |
+
) -> Dict[str, Any]:
|
| 34 |
+
"""
|
| 35 |
+
Run Monte Carlo simulation for portfolio holdings.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
holdings: List of holding dicts with ticker, market_value
|
| 39 |
+
horizon_days: Simulation horizon in trading days
|
| 40 |
+
n_simulations: Number of paths to simulate
|
| 41 |
+
regime_conditioned: Apply HMM regime conditioning
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
Fan chart data, VaR, CVaR, and simulation statistics.
|
| 45 |
+
"""
|
| 46 |
+
tickers = list(set(h.get("ticker", "") for h in holdings if h.get("market_value", 0) > 0))
|
| 47 |
+
weights_map = {}
|
| 48 |
+
total_value = sum(h.get("market_value", 0) for h in holdings)
|
| 49 |
+
|
| 50 |
+
if total_value <= 0 or not tickers:
|
| 51 |
+
return {"error": "No valid holdings for simulation"}
|
| 52 |
+
|
| 53 |
+
for h in holdings:
|
| 54 |
+
t = h.get("ticker", "")
|
| 55 |
+
w = h.get("market_value", 0) / total_value
|
| 56 |
+
weights_map[t] = weights_map.get(t, 0) + w
|
| 57 |
+
|
| 58 |
+
# 1. Fetch historical returns (1 year)
|
| 59 |
+
returns_data = {}
|
| 60 |
+
for ticker in tickers:
|
| 61 |
+
try:
|
| 62 |
+
df = await yahoo_adapter.get_price_dataframe(ticker, period="1y")
|
| 63 |
+
if not df.empty and len(df) > 30:
|
| 64 |
+
returns_data[ticker] = df["Close"].pct_change().dropna()
|
| 65 |
+
except Exception:
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
if len(returns_data) < 1:
|
| 69 |
+
return {"error": "Insufficient price data for simulation"}
|
| 70 |
+
|
| 71 |
+
# Build aligned returns matrix
|
| 72 |
+
returns_df = pd.DataFrame(returns_data).dropna()
|
| 73 |
+
if returns_df.empty or len(returns_df) < 30:
|
| 74 |
+
return {"error": "Insufficient overlapping data"}
|
| 75 |
+
|
| 76 |
+
used_tickers = list(returns_df.columns)
|
| 77 |
+
weights = np.array([weights_map.get(t, 0) for t in used_tickers])
|
| 78 |
+
weights = weights / weights.sum() # normalize
|
| 79 |
+
|
| 80 |
+
# 2. Estimate parameters
|
| 81 |
+
mu = returns_df.mean().values # daily mean returns
|
| 82 |
+
cov = returns_df.cov().values # daily covariance
|
| 83 |
+
|
| 84 |
+
# Regime conditioning: adjust mu/cov based on current regime
|
| 85 |
+
if regime_conditioned:
|
| 86 |
+
try:
|
| 87 |
+
from app.services.ml.hmm_regime import detect_regime
|
| 88 |
+
regime_data = await detect_regime("SPY", period="2y", history_days=60)
|
| 89 |
+
current_regime = regime_data.get("current_regime", "normal")
|
| 90 |
+
|
| 91 |
+
if current_regime == "bear":
|
| 92 |
+
mu = mu * 0.5 # Reduce expected returns
|
| 93 |
+
cov = cov * 1.8 # Increase volatility
|
| 94 |
+
elif current_regime == "high_volatility":
|
| 95 |
+
cov = cov * 2.2
|
| 96 |
+
elif current_regime == "bull":
|
| 97 |
+
mu = mu * 1.2
|
| 98 |
+
cov = cov * 0.8
|
| 99 |
+
except Exception:
|
| 100 |
+
pass # Use unconditional parameters
|
| 101 |
+
|
| 102 |
+
# 3. Cholesky decomposition for correlated random draws
|
| 103 |
+
try:
|
| 104 |
+
L = np.linalg.cholesky(cov)
|
| 105 |
+
except np.linalg.LinAlgError:
|
| 106 |
+
# Add small regularization if covariance isn't positive semi-definite
|
| 107 |
+
cov += np.eye(len(used_tickers)) * 1e-8
|
| 108 |
+
L = np.linalg.cholesky(cov)
|
| 109 |
+
|
| 110 |
+
# 4. Simulate paths
|
| 111 |
+
np.random.seed(42) # reproducibility
|
| 112 |
+
portfolio_paths = np.zeros((n_simulations, horizon_days + 1))
|
| 113 |
+
portfolio_paths[:, 0] = total_value
|
| 114 |
+
|
| 115 |
+
for sim in range(n_simulations):
|
| 116 |
+
cumulative_return = 1.0
|
| 117 |
+
for day in range(horizon_days):
|
| 118 |
+
z = np.random.standard_normal(len(used_tickers))
|
| 119 |
+
correlated_returns = mu + L @ z
|
| 120 |
+
# Portfolio return = weighted sum of asset returns
|
| 121 |
+
port_return = np.dot(weights, correlated_returns)
|
| 122 |
+
cumulative_return *= (1 + port_return)
|
| 123 |
+
portfolio_paths[sim, day + 1] = total_value * cumulative_return
|
| 124 |
+
|
| 125 |
+
# 5. Compute statistics
|
| 126 |
+
final_values = portfolio_paths[:, -1]
|
| 127 |
+
pnl = final_values - total_value
|
| 128 |
+
pnl_pct = pnl / total_value
|
| 129 |
+
|
| 130 |
+
# Fan chart percentiles
|
| 131 |
+
percentiles = [5, 25, 50, 75, 95]
|
| 132 |
+
fan_chart = {}
|
| 133 |
+
for p in percentiles:
|
| 134 |
+
fan_chart[f"p{p}"] = [
|
| 135 |
+
round(float(np.percentile(portfolio_paths[:, d], p)), 2)
|
| 136 |
+
for d in range(horizon_days + 1)
|
| 137 |
+
]
|
| 138 |
+
|
| 139 |
+
# VaR and CVaR at multiple confidence levels
|
| 140 |
+
var_levels = {}
|
| 141 |
+
for conf in [0.90, 0.95, 0.99]:
|
| 142 |
+
q = np.percentile(pnl, (1 - conf) * 100)
|
| 143 |
+
cvar = float(np.mean(pnl[pnl <= q]))
|
| 144 |
+
var_levels[f"{int(conf*100)}%"] = {
|
| 145 |
+
"var": round(float(-q), 2),
|
| 146 |
+
"var_pct": round(float(-q / total_value * 100), 2),
|
| 147 |
+
"cvar": round(float(-cvar), 2),
|
| 148 |
+
"cvar_pct": round(float(-cvar / total_value * 100), 2),
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
return {
|
| 152 |
+
"portfolio_value": round(total_value, 2),
|
| 153 |
+
"horizon_days": horizon_days,
|
| 154 |
+
"n_simulations": n_simulations,
|
| 155 |
+
"regime_conditioned": regime_conditioned,
|
| 156 |
+
"fan_chart": fan_chart,
|
| 157 |
+
"days": list(range(horizon_days + 1)),
|
| 158 |
+
"var": var_levels,
|
| 159 |
+
"statistics": {
|
| 160 |
+
"mean_final_value": round(float(np.mean(final_values)), 2),
|
| 161 |
+
"median_final_value": round(float(np.median(final_values)), 2),
|
| 162 |
+
"std_final_value": round(float(np.std(final_values)), 2),
|
| 163 |
+
"mean_pnl": round(float(np.mean(pnl)), 2),
|
| 164 |
+
"mean_pnl_pct": round(float(np.mean(pnl_pct) * 100), 2),
|
| 165 |
+
"prob_loss": round(float(np.mean(pnl < 0) * 100), 2),
|
| 166 |
+
"worst_case": round(float(np.min(final_values)), 2),
|
| 167 |
+
"best_case": round(float(np.max(final_values)), 2),
|
| 168 |
+
},
|
| 169 |
+
}
|
backend/app/services/risk/options_calculator.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
| 1 |
"""
|
| 2 |
Options Hedge Calculator.
|
| 3 |
|
| 4 |
-
Black-Scholes pricing
|
|
|
|
| 5 |
Given any position, recommends optimal strike prices and calculates
|
| 6 |
the cost of hedge as a percentage of position value.
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
|
@@ -12,20 +15,117 @@ import math
|
|
| 12 |
import logging
|
| 13 |
from typing import Any, Dict, List, Optional
|
| 14 |
|
|
|
|
|
|
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
def _norm_cdf(x: float) -> float:
|
| 19 |
"""Standard normal cumulative distribution function."""
|
| 20 |
return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
|
| 21 |
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
def black_scholes_put(S: float, K: float, T: float, r: float, sigma: float) -> float:
|
| 24 |
"""Price a European put option using Black-Scholes."""
|
| 25 |
if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
|
| 26 |
return 0.0
|
| 27 |
-
d1 = (
|
| 28 |
-
d2 = d1 - sigma * math.sqrt(T)
|
| 29 |
return K * math.exp(-r * T) * _norm_cdf(-d2) - S * _norm_cdf(-d1)
|
| 30 |
|
| 31 |
|
|
@@ -33,42 +133,172 @@ def black_scholes_call(S: float, K: float, T: float, r: float, sigma: float) ->
|
|
| 33 |
"""Price a European call option using Black-Scholes."""
|
| 34 |
if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
|
| 35 |
return 0.0
|
| 36 |
-
d1 = (
|
| 37 |
-
d2 = d1 - sigma * math.sqrt(T)
|
| 38 |
return S * _norm_cdf(d1) - K * math.exp(-r * T) * _norm_cdf(d2)
|
| 39 |
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
def _estimate_volatility(ticker: str) -> float:
|
| 42 |
-
"""Estimate annualized volatility
|
| 43 |
try:
|
| 44 |
from app.services.data_ingestion.yahoo import _sync_fetch_history
|
| 45 |
data = _sync_fetch_history(ticker, period="3mo", interval="1d")
|
| 46 |
if data.empty or len(data) < 10:
|
| 47 |
-
return 0.25
|
| 48 |
returns = data["Close"].pct_change().dropna()
|
| 49 |
-
return float(returns.std() * math.sqrt(
|
| 50 |
except Exception:
|
| 51 |
return 0.25
|
| 52 |
|
| 53 |
|
| 54 |
-
def
|
| 55 |
holdings: List[Dict[str, Any]],
|
| 56 |
-
risk_free_rate: float =
|
| 57 |
time_horizons: Optional[List[float]] = None,
|
| 58 |
) -> Dict[str, Any]:
|
| 59 |
"""
|
| 60 |
-
Calculate options hedge strategies for each holding.
|
| 61 |
|
| 62 |
-
Returns protective put, collar,
|
| 63 |
"""
|
| 64 |
if time_horizons is None:
|
| 65 |
-
time_horizons = [30 / 365, 90 / 365, 180 / 365]
|
| 66 |
|
| 67 |
strategies = []
|
| 68 |
|
| 69 |
for h in holdings:
|
| 70 |
if h.get("position_type") == "short":
|
| 71 |
-
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
ticker = h.get("ticker", "")
|
| 74 |
current_price = h.get("current_price", 0)
|
|
@@ -80,14 +310,12 @@ def calculate_options_hedge(
|
|
| 80 |
|
| 81 |
sigma = _estimate_volatility(ticker)
|
| 82 |
|
| 83 |
-
# Strike levels: 5% OTM, ATM, 5% ITM for puts
|
| 84 |
put_strikes = [
|
| 85 |
{"label": "5% OTM", "strike": round(current_price * 0.95, 2), "protection": "95%"},
|
| 86 |
{"label": "ATM", "strike": round(current_price, 2), "protection": "100%"},
|
| 87 |
{"label": "5% ITM", "strike": round(current_price * 1.05, 2), "protection": "105%"},
|
| 88 |
]
|
| 89 |
|
| 90 |
-
# Call strikes for covered calls / collar cap
|
| 91 |
call_strikes = [
|
| 92 |
{"label": "5% OTM", "strike": round(current_price * 1.05, 2)},
|
| 93 |
{"label": "10% OTM", "strike": round(current_price * 1.10, 2)},
|
|
@@ -99,33 +327,34 @@ def calculate_options_hedge(
|
|
| 99 |
for T in time_horizons:
|
| 100 |
days = int(T * 365)
|
| 101 |
|
| 102 |
-
# Protective puts at different strikes
|
| 103 |
puts = []
|
| 104 |
for ps in put_strikes:
|
| 105 |
price = black_scholes_put(current_price, ps["strike"], T, risk_free_rate, sigma)
|
| 106 |
total_cost = price * quantity
|
| 107 |
cost_pct = (total_cost / market_value * 100) if market_value > 0 else 0
|
|
|
|
| 108 |
puts.append({
|
| 109 |
**ps,
|
| 110 |
"premium": round(price, 2),
|
| 111 |
"total_cost": round(total_cost, 2),
|
| 112 |
"cost_pct": round(cost_pct, 2),
|
|
|
|
| 113 |
})
|
| 114 |
|
| 115 |
-
# Covered calls
|
| 116 |
calls = []
|
| 117 |
for cs in call_strikes:
|
| 118 |
price = black_scholes_call(current_price, cs["strike"], T, risk_free_rate, sigma)
|
| 119 |
total_income = price * quantity
|
| 120 |
income_pct = (total_income / market_value * 100) if market_value > 0 else 0
|
|
|
|
| 121 |
calls.append({
|
| 122 |
**cs,
|
| 123 |
"premium": round(price, 2),
|
| 124 |
"total_income": round(total_income, 2),
|
| 125 |
"income_pct": round(income_pct, 2),
|
|
|
|
| 126 |
})
|
| 127 |
|
| 128 |
-
# Collar: buy ATM put + sell 10% OTM call
|
| 129 |
atm_put_cost = black_scholes_put(current_price, current_price, T, risk_free_rate, sigma)
|
| 130 |
otm_call_income = black_scholes_call(current_price, current_price * 1.10, T, risk_free_rate, sigma)
|
| 131 |
collar_net = (atm_put_cost - otm_call_income) * quantity
|
|
|
|
| 1 |
"""
|
| 2 |
Options Hedge Calculator.
|
| 3 |
|
| 4 |
+
Black-Scholes pricing with full Greeks (Delta, Gamma, Theta, Vega, Rho)
|
| 5 |
+
for protective puts, collars, and covered calls.
|
| 6 |
Given any position, recommends optimal strike prices and calculates
|
| 7 |
the cost of hedge as a percentage of position value.
|
| 8 |
+
|
| 9 |
+
Supports implied volatility back-solving via Newton's method.
|
| 10 |
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
|
|
|
| 15 |
import logging
|
| 16 |
from typing import Any, Dict, List, Optional
|
| 17 |
|
| 18 |
+
from app.config import get_settings
|
| 19 |
+
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
| 22 |
+
_settings = get_settings()
|
| 23 |
+
RISK_FREE_RATE = _settings.risk_free_rate
|
| 24 |
+
TRADING_DAYS = _settings.trading_days_per_year
|
| 25 |
+
|
| 26 |
|
| 27 |
def _norm_cdf(x: float) -> float:
|
| 28 |
"""Standard normal cumulative distribution function."""
|
| 29 |
return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
|
| 30 |
|
| 31 |
|
| 32 |
+
def _norm_pdf(x: float) -> float:
|
| 33 |
+
"""Standard normal probability density function."""
|
| 34 |
+
return math.exp(-0.5 * x * x) / math.sqrt(2.0 * math.pi)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _bs_d1_d2(S: float, K: float, T: float, r: float, sigma: float):
|
| 38 |
+
"""Compute Black-Scholes d1 and d2."""
|
| 39 |
+
d1 = (math.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * math.sqrt(T))
|
| 40 |
+
d2 = d1 - sigma * math.sqrt(T)
|
| 41 |
+
return d1, d2
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ββ Greeks βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
|
| 46 |
+
def compute_greeks(
|
| 47 |
+
S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call"
|
| 48 |
+
) -> Dict[str, float]:
|
| 49 |
+
"""
|
| 50 |
+
Compute all Black-Scholes Greeks for a European option.
|
| 51 |
+
|
| 52 |
+
Returns: delta, gamma, theta, vega, rho
|
| 53 |
+
"""
|
| 54 |
+
if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
|
| 55 |
+
return {"delta": 0, "gamma": 0, "theta": 0, "vega": 0, "rho": 0}
|
| 56 |
+
|
| 57 |
+
d1, d2 = _bs_d1_d2(S, K, T, r, sigma)
|
| 58 |
+
sqrt_T = math.sqrt(T)
|
| 59 |
+
|
| 60 |
+
# Gamma and Vega are the same for calls and puts
|
| 61 |
+
gamma = _norm_pdf(d1) / (S * sigma * sqrt_T)
|
| 62 |
+
vega = S * _norm_pdf(d1) * sqrt_T / 100 # per 1% vol move
|
| 63 |
+
|
| 64 |
+
if option_type == "call":
|
| 65 |
+
delta = _norm_cdf(d1)
|
| 66 |
+
theta = (
|
| 67 |
+
-S * _norm_pdf(d1) * sigma / (2 * sqrt_T)
|
| 68 |
+
- r * K * math.exp(-r * T) * _norm_cdf(d2)
|
| 69 |
+
) / TRADING_DAYS # daily theta
|
| 70 |
+
rho = K * T * math.exp(-r * T) * _norm_cdf(d2) / 100 # per 1% rate move
|
| 71 |
+
else: # put
|
| 72 |
+
delta = _norm_cdf(d1) - 1
|
| 73 |
+
theta = (
|
| 74 |
+
-S * _norm_pdf(d1) * sigma / (2 * sqrt_T)
|
| 75 |
+
+ r * K * math.exp(-r * T) * _norm_cdf(-d2)
|
| 76 |
+
) / TRADING_DAYS
|
| 77 |
+
rho = -K * T * math.exp(-r * T) * _norm_cdf(-d2) / 100
|
| 78 |
+
|
| 79 |
+
return {
|
| 80 |
+
"delta": round(delta, 4),
|
| 81 |
+
"gamma": round(gamma, 6),
|
| 82 |
+
"theta": round(theta, 4),
|
| 83 |
+
"vega": round(vega, 4),
|
| 84 |
+
"rho": round(rho, 4),
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ββ Implied Volatility (Newton's Method) βββββββββββββββββββββββββββββββββ
|
| 89 |
+
|
| 90 |
+
def implied_volatility(
|
| 91 |
+
market_price: float, S: float, K: float, T: float, r: float,
|
| 92 |
+
option_type: str = "call", max_iter: int = 100, tol: float = 1e-6
|
| 93 |
+
) -> float:
|
| 94 |
+
"""
|
| 95 |
+
Back-solve implied volatility from market option price using Newton's method.
|
| 96 |
+
Returns annualized IV as decimal.
|
| 97 |
+
"""
|
| 98 |
+
if market_price <= 0 or T <= 0 or S <= 0 or K <= 0:
|
| 99 |
+
return 0.0
|
| 100 |
+
|
| 101 |
+
sigma = 0.25 # initial guess
|
| 102 |
+
for _ in range(max_iter):
|
| 103 |
+
if option_type == "call":
|
| 104 |
+
price = black_scholes_call(S, K, T, r, sigma)
|
| 105 |
+
else:
|
| 106 |
+
price = black_scholes_put(S, K, T, r, sigma)
|
| 107 |
+
|
| 108 |
+
diff = price - market_price
|
| 109 |
+
if abs(diff) < tol:
|
| 110 |
+
return sigma
|
| 111 |
+
|
| 112 |
+
# Vega for Newton step
|
| 113 |
+
d1, _ = _bs_d1_d2(S, K, T, r, sigma)
|
| 114 |
+
vega = S * _norm_pdf(d1) * math.sqrt(T)
|
| 115 |
+
if vega < 1e-12:
|
| 116 |
+
break
|
| 117 |
+
|
| 118 |
+
sigma -= diff / vega
|
| 119 |
+
sigma = max(0.001, min(sigma, 5.0)) # clamp
|
| 120 |
+
|
| 121 |
+
return sigma
|
| 122 |
+
|
| 123 |
+
|
| 124 |
def black_scholes_put(S: float, K: float, T: float, r: float, sigma: float) -> float:
|
| 125 |
"""Price a European put option using Black-Scholes."""
|
| 126 |
if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
|
| 127 |
return 0.0
|
| 128 |
+
d1, d2 = _bs_d1_d2(S, K, T, r, sigma)
|
|
|
|
| 129 |
return K * math.exp(-r * T) * _norm_cdf(-d2) - S * _norm_cdf(-d1)
|
| 130 |
|
| 131 |
|
|
|
|
| 133 |
"""Price a European call option using Black-Scholes."""
|
| 134 |
if T <= 0 or sigma <= 0 or S <= 0 or K <= 0:
|
| 135 |
return 0.0
|
| 136 |
+
d1, d2 = _bs_d1_d2(S, K, T, r, sigma)
|
|
|
|
| 137 |
return S * _norm_cdf(d1) - K * math.exp(-r * T) * _norm_cdf(d2)
|
| 138 |
|
| 139 |
|
| 140 |
+
async def _estimate_volatility_async(ticker: str) -> float:
|
| 141 |
+
"""Estimate annualized volatility for a ticker using recent data (async)."""
|
| 142 |
+
try:
|
| 143 |
+
from app.services.data_ingestion.yahoo import yahoo_adapter
|
| 144 |
+
df = await yahoo_adapter.get_price_dataframe(ticker, period="3mo")
|
| 145 |
+
if df.empty or len(df) < 10:
|
| 146 |
+
return 0.25 # default 25%
|
| 147 |
+
returns = df["Close"].pct_change().dropna()
|
| 148 |
+
return float(returns.std() * math.sqrt(TRADING_DAYS))
|
| 149 |
+
except Exception:
|
| 150 |
+
return 0.25
|
| 151 |
+
|
| 152 |
+
|
| 153 |
def _estimate_volatility(ticker: str) -> float:
|
| 154 |
+
"""Estimate annualized volatility synchronously (fallback)."""
|
| 155 |
try:
|
| 156 |
from app.services.data_ingestion.yahoo import _sync_fetch_history
|
| 157 |
data = _sync_fetch_history(ticker, period="3mo", interval="1d")
|
| 158 |
if data.empty or len(data) < 10:
|
| 159 |
+
return 0.25
|
| 160 |
returns = data["Close"].pct_change().dropna()
|
| 161 |
+
return float(returns.std() * math.sqrt(TRADING_DAYS))
|
| 162 |
except Exception:
|
| 163 |
return 0.25
|
| 164 |
|
| 165 |
|
| 166 |
+
async def calculate_options_hedge_async(
|
| 167 |
holdings: List[Dict[str, Any]],
|
| 168 |
+
risk_free_rate: float = RISK_FREE_RATE,
|
| 169 |
time_horizons: Optional[List[float]] = None,
|
| 170 |
) -> Dict[str, Any]:
|
| 171 |
"""
|
| 172 |
+
Calculate options hedge strategies for each holding (async version).
|
| 173 |
|
| 174 |
+
Returns protective put, collar, covered call analyses with full Greeks.
|
| 175 |
"""
|
| 176 |
if time_horizons is None:
|
| 177 |
+
time_horizons = [30 / 365, 90 / 365, 180 / 365]
|
| 178 |
|
| 179 |
strategies = []
|
| 180 |
|
| 181 |
for h in holdings:
|
| 182 |
if h.get("position_type") == "short":
|
| 183 |
+
continue
|
| 184 |
+
|
| 185 |
+
ticker = h.get("ticker", "")
|
| 186 |
+
current_price = h.get("current_price", 0)
|
| 187 |
+
quantity = h.get("quantity", 0)
|
| 188 |
+
market_value = h.get("market_value", current_price * quantity)
|
| 189 |
+
|
| 190 |
+
if current_price <= 0 or quantity <= 0:
|
| 191 |
+
continue
|
| 192 |
+
|
| 193 |
+
sigma = await _estimate_volatility_async(ticker)
|
| 194 |
+
|
| 195 |
+
# Strike levels
|
| 196 |
+
put_strikes = [
|
| 197 |
+
{"label": "5% OTM", "strike": round(current_price * 0.95, 2), "protection": "95%"},
|
| 198 |
+
{"label": "ATM", "strike": round(current_price, 2), "protection": "100%"},
|
| 199 |
+
{"label": "5% ITM", "strike": round(current_price * 1.05, 2), "protection": "105%"},
|
| 200 |
+
]
|
| 201 |
+
|
| 202 |
+
call_strikes = [
|
| 203 |
+
{"label": "5% OTM", "strike": round(current_price * 1.05, 2)},
|
| 204 |
+
{"label": "10% OTM", "strike": round(current_price * 1.10, 2)},
|
| 205 |
+
{"label": "15% OTM", "strike": round(current_price * 1.15, 2)},
|
| 206 |
+
]
|
| 207 |
+
|
| 208 |
+
position_strategies = []
|
| 209 |
+
|
| 210 |
+
for T in time_horizons:
|
| 211 |
+
days = int(T * 365)
|
| 212 |
+
|
| 213 |
+
# Protective puts with Greeks
|
| 214 |
+
puts = []
|
| 215 |
+
for ps in put_strikes:
|
| 216 |
+
price = black_scholes_put(current_price, ps["strike"], T, risk_free_rate, sigma)
|
| 217 |
+
total_cost = price * quantity
|
| 218 |
+
cost_pct = (total_cost / market_value * 100) if market_value > 0 else 0
|
| 219 |
+
greeks = compute_greeks(current_price, ps["strike"], T, risk_free_rate, sigma, "put")
|
| 220 |
+
puts.append({
|
| 221 |
+
**ps,
|
| 222 |
+
"premium": round(price, 2),
|
| 223 |
+
"total_cost": round(total_cost, 2),
|
| 224 |
+
"cost_pct": round(cost_pct, 2),
|
| 225 |
+
"greeks": greeks,
|
| 226 |
+
})
|
| 227 |
+
|
| 228 |
+
# Covered calls with Greeks
|
| 229 |
+
calls = []
|
| 230 |
+
for cs in call_strikes:
|
| 231 |
+
price = black_scholes_call(current_price, cs["strike"], T, risk_free_rate, sigma)
|
| 232 |
+
total_income = price * quantity
|
| 233 |
+
income_pct = (total_income / market_value * 100) if market_value > 0 else 0
|
| 234 |
+
greeks = compute_greeks(current_price, cs["strike"], T, risk_free_rate, sigma, "call")
|
| 235 |
+
calls.append({
|
| 236 |
+
**cs,
|
| 237 |
+
"premium": round(price, 2),
|
| 238 |
+
"total_income": round(total_income, 2),
|
| 239 |
+
"income_pct": round(income_pct, 2),
|
| 240 |
+
"greeks": greeks,
|
| 241 |
+
})
|
| 242 |
+
|
| 243 |
+
# Collar
|
| 244 |
+
atm_put_cost = black_scholes_put(current_price, current_price, T, risk_free_rate, sigma)
|
| 245 |
+
otm_call_income = black_scholes_call(current_price, current_price * 1.10, T, risk_free_rate, sigma)
|
| 246 |
+
collar_net = (atm_put_cost - otm_call_income) * quantity
|
| 247 |
+
collar_pct = (collar_net / market_value * 100) if market_value > 0 else 0
|
| 248 |
+
|
| 249 |
+
# Net Greeks for collar
|
| 250 |
+
put_greeks = compute_greeks(current_price, current_price, T, risk_free_rate, sigma, "put")
|
| 251 |
+
call_greeks = compute_greeks(current_price, current_price * 1.10, T, risk_free_rate, sigma, "call")
|
| 252 |
+
collar_greeks = {
|
| 253 |
+
"net_delta": round(put_greeks["delta"] - call_greeks["delta"], 4),
|
| 254 |
+
"net_gamma": round(put_greeks["gamma"] - call_greeks["gamma"], 6),
|
| 255 |
+
"net_theta": round(put_greeks["theta"] - call_greeks["theta"], 4),
|
| 256 |
+
"net_vega": round(put_greeks["vega"] - call_greeks["vega"], 4),
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
position_strategies.append({
|
| 260 |
+
"expiry_days": days,
|
| 261 |
+
"expiry_T": round(T, 4),
|
| 262 |
+
"protective_puts": puts,
|
| 263 |
+
"covered_calls": calls,
|
| 264 |
+
"collar": {
|
| 265 |
+
"put_strike": round(current_price, 2),
|
| 266 |
+
"call_strike": round(current_price * 1.10, 2),
|
| 267 |
+
"net_cost": round(collar_net, 2),
|
| 268 |
+
"net_cost_pct": round(collar_pct, 2),
|
| 269 |
+
"description": f"Buy {current_price:.0f} put + Sell {current_price*1.10:.0f} call",
|
| 270 |
+
"greeks": collar_greeks,
|
| 271 |
+
},
|
| 272 |
+
})
|
| 273 |
+
|
| 274 |
+
strategies.append({
|
| 275 |
+
"ticker": ticker,
|
| 276 |
+
"current_price": current_price,
|
| 277 |
+
"quantity": quantity,
|
| 278 |
+
"market_value": round(market_value, 2),
|
| 279 |
+
"volatility": round(sigma * 100, 1),
|
| 280 |
+
"strategies": position_strategies,
|
| 281 |
+
})
|
| 282 |
+
|
| 283 |
+
return {"strategies": strategies, "risk_free_rate": risk_free_rate}
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def calculate_options_hedge(
|
| 287 |
+
holdings: List[Dict[str, Any]],
|
| 288 |
+
risk_free_rate: float = RISK_FREE_RATE,
|
| 289 |
+
time_horizons: Optional[List[float]] = None,
|
| 290 |
+
) -> Dict[str, Any]:
|
| 291 |
+
"""
|
| 292 |
+
Calculate options hedge strategies (sync version β backward compatible).
|
| 293 |
+
"""
|
| 294 |
+
if time_horizons is None:
|
| 295 |
+
time_horizons = [30 / 365, 90 / 365, 180 / 365]
|
| 296 |
+
|
| 297 |
+
strategies = []
|
| 298 |
+
|
| 299 |
+
for h in holdings:
|
| 300 |
+
if h.get("position_type") == "short":
|
| 301 |
+
continue
|
| 302 |
|
| 303 |
ticker = h.get("ticker", "")
|
| 304 |
current_price = h.get("current_price", 0)
|
|
|
|
| 310 |
|
| 311 |
sigma = _estimate_volatility(ticker)
|
| 312 |
|
|
|
|
| 313 |
put_strikes = [
|
| 314 |
{"label": "5% OTM", "strike": round(current_price * 0.95, 2), "protection": "95%"},
|
| 315 |
{"label": "ATM", "strike": round(current_price, 2), "protection": "100%"},
|
| 316 |
{"label": "5% ITM", "strike": round(current_price * 1.05, 2), "protection": "105%"},
|
| 317 |
]
|
| 318 |
|
|
|
|
| 319 |
call_strikes = [
|
| 320 |
{"label": "5% OTM", "strike": round(current_price * 1.05, 2)},
|
| 321 |
{"label": "10% OTM", "strike": round(current_price * 1.10, 2)},
|
|
|
|
| 327 |
for T in time_horizons:
|
| 328 |
days = int(T * 365)
|
| 329 |
|
|
|
|
| 330 |
puts = []
|
| 331 |
for ps in put_strikes:
|
| 332 |
price = black_scholes_put(current_price, ps["strike"], T, risk_free_rate, sigma)
|
| 333 |
total_cost = price * quantity
|
| 334 |
cost_pct = (total_cost / market_value * 100) if market_value > 0 else 0
|
| 335 |
+
greeks = compute_greeks(current_price, ps["strike"], T, risk_free_rate, sigma, "put")
|
| 336 |
puts.append({
|
| 337 |
**ps,
|
| 338 |
"premium": round(price, 2),
|
| 339 |
"total_cost": round(total_cost, 2),
|
| 340 |
"cost_pct": round(cost_pct, 2),
|
| 341 |
+
"greeks": greeks,
|
| 342 |
})
|
| 343 |
|
|
|
|
| 344 |
calls = []
|
| 345 |
for cs in call_strikes:
|
| 346 |
price = black_scholes_call(current_price, cs["strike"], T, risk_free_rate, sigma)
|
| 347 |
total_income = price * quantity
|
| 348 |
income_pct = (total_income / market_value * 100) if market_value > 0 else 0
|
| 349 |
+
greeks = compute_greeks(current_price, cs["strike"], T, risk_free_rate, sigma, "call")
|
| 350 |
calls.append({
|
| 351 |
**cs,
|
| 352 |
"premium": round(price, 2),
|
| 353 |
"total_income": round(total_income, 2),
|
| 354 |
"income_pct": round(income_pct, 2),
|
| 355 |
+
"greeks": greeks,
|
| 356 |
})
|
| 357 |
|
|
|
|
| 358 |
atm_put_cost = black_scholes_put(current_price, current_price, T, risk_free_rate, sigma)
|
| 359 |
otm_call_income = black_scholes_call(current_price, current_price * 1.10, T, risk_free_rate, sigma)
|
| 360 |
collar_net = (atm_put_cost - otm_call_income) * quantity
|
backend/app/services/risk/stress_test.py
CHANGED
|
@@ -3,7 +3,7 @@ Stress Test Engine.
|
|
| 3 |
|
| 4 |
Simulates portfolio impact under various macro scenarios:
|
| 5 |
market crashes, rate hikes, sector rotations, currency moves, and custom shocks.
|
| 6 |
-
Uses beta
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
|
@@ -79,7 +79,7 @@ SCENARIOS = {
|
|
| 79 |
},
|
| 80 |
}
|
| 81 |
|
| 82 |
-
#
|
| 83 |
SECTOR_BETAS = {
|
| 84 |
"Technology": 1.25,
|
| 85 |
"Financial": 1.15,
|
|
@@ -97,10 +97,55 @@ SECTOR_BETAS = {
|
|
| 97 |
}
|
| 98 |
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
def run_stress_test(
|
| 101 |
holdings: List[Dict[str, Any]],
|
| 102 |
scenario_id: str | None = None,
|
| 103 |
custom_shock: float | None = None,
|
|
|
|
| 104 |
) -> Dict[str, Any]:
|
| 105 |
"""
|
| 106 |
Run a stress test on portfolio holdings.
|
|
@@ -109,6 +154,7 @@ def run_stress_test(
|
|
| 109 |
holdings: List of holding dicts with market_value, asset_class, etc.
|
| 110 |
scenario_id: ID of a predefined scenario
|
| 111 |
custom_shock: Custom market shock as decimal (e.g., -0.15 for -15%)
|
|
|
|
| 112 |
|
| 113 |
Returns:
|
| 114 |
Dict with per-holding impact and portfolio-level summary.
|
|
@@ -127,6 +173,7 @@ def run_stress_test(
|
|
| 127 |
|
| 128 |
market_shock = scenario["market_shock"]
|
| 129 |
sector_shocks = scenario.get("sector_shocks", {})
|
|
|
|
| 130 |
|
| 131 |
total_before = sum(h.get("market_value", 0) for h in holdings)
|
| 132 |
impacts = []
|
|
@@ -138,7 +185,15 @@ def run_stress_test(
|
|
| 138 |
|
| 139 |
asset_class = h.get("asset_class", "equity")
|
| 140 |
sector = h.get("sector", "default")
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
# Check for sector-specific shock override
|
| 144 |
if sector in sector_shocks:
|
|
@@ -164,9 +219,11 @@ def run_stress_test(
|
|
| 164 |
new_value = mv + impact_value
|
| 165 |
|
| 166 |
impacts.append({
|
| 167 |
-
"ticker":
|
| 168 |
-
"name": h.get("name",
|
| 169 |
"current_value": round(mv, 2),
|
|
|
|
|
|
|
| 170 |
"shock_pct": round(shock * 100, 2),
|
| 171 |
"impact_value": round(impact_value, 2),
|
| 172 |
"new_value": round(new_value, 2),
|
|
@@ -199,11 +256,14 @@ def run_stress_test(
|
|
| 199 |
}
|
| 200 |
|
| 201 |
|
| 202 |
-
def run_all_scenarios(
|
|
|
|
|
|
|
|
|
|
| 203 |
"""Run all predefined scenarios and return summaries."""
|
| 204 |
results = []
|
| 205 |
for sid in SCENARIOS:
|
| 206 |
-
result = run_stress_test(holdings, scenario_id=sid)
|
| 207 |
results.append({
|
| 208 |
"scenario_id": sid,
|
| 209 |
"name": result["scenario"]["name"],
|
|
|
|
| 3 |
|
| 4 |
Simulates portfolio impact under various macro scenarios:
|
| 5 |
market crashes, rate hikes, sector rotations, currency moves, and custom shocks.
|
| 6 |
+
Uses computed rolling beta from price data (with sector beta as fallback).
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
|
|
|
| 79 |
},
|
| 80 |
}
|
| 81 |
|
| 82 |
+
# Fallback sector betas (used when < 60 days of data exists)
|
| 83 |
SECTOR_BETAS = {
|
| 84 |
"Technology": 1.25,
|
| 85 |
"Financial": 1.15,
|
|
|
|
| 97 |
}
|
| 98 |
|
| 99 |
|
| 100 |
+
async def compute_real_betas(
|
| 101 |
+
tickers: List[str],
|
| 102 |
+
benchmark: str = "SPY",
|
| 103 |
+
period: str = "1y",
|
| 104 |
+
) -> Dict[str, float]:
|
| 105 |
+
"""
|
| 106 |
+
Compute real rolling beta per ticker from actual return covariance.
|
| 107 |
+
Falls back to sector beta when insufficient data.
|
| 108 |
+
"""
|
| 109 |
+
from app.services.data_ingestion.yahoo import yahoo_adapter
|
| 110 |
+
import pandas as pd
|
| 111 |
+
|
| 112 |
+
betas: Dict[str, float] = {}
|
| 113 |
+
|
| 114 |
+
try:
|
| 115 |
+
bench_df = await yahoo_adapter.get_price_dataframe(benchmark, period=period)
|
| 116 |
+
if bench_df.empty:
|
| 117 |
+
return betas
|
| 118 |
+
bench_returns = bench_df["Close"].pct_change().dropna()
|
| 119 |
+
|
| 120 |
+
for ticker in tickers:
|
| 121 |
+
try:
|
| 122 |
+
df = await yahoo_adapter.get_price_dataframe(ticker, period=period)
|
| 123 |
+
if df.empty or len(df) < 60:
|
| 124 |
+
continue
|
| 125 |
+
stock_returns = df["Close"].pct_change().dropna()
|
| 126 |
+
|
| 127 |
+
# Align dates
|
| 128 |
+
aligned = pd.DataFrame({"stock": stock_returns, "bench": bench_returns}).dropna()
|
| 129 |
+
if len(aligned) < 30:
|
| 130 |
+
continue
|
| 131 |
+
|
| 132 |
+
cov = np.cov(aligned["stock"].values, aligned["bench"].values)
|
| 133 |
+
var_bench = np.var(aligned["bench"].values, ddof=1)
|
| 134 |
+
if var_bench > 0:
|
| 135 |
+
betas[ticker] = round(float(cov[0, 1] / var_bench), 4)
|
| 136 |
+
except Exception:
|
| 137 |
+
continue
|
| 138 |
+
except Exception as e:
|
| 139 |
+
logger.warning("Beta computation failed: %s", e)
|
| 140 |
+
|
| 141 |
+
return betas
|
| 142 |
+
|
| 143 |
+
|
| 144 |
def run_stress_test(
|
| 145 |
holdings: List[Dict[str, Any]],
|
| 146 |
scenario_id: str | None = None,
|
| 147 |
custom_shock: float | None = None,
|
| 148 |
+
computed_betas: Dict[str, float] | None = None,
|
| 149 |
) -> Dict[str, Any]:
|
| 150 |
"""
|
| 151 |
Run a stress test on portfolio holdings.
|
|
|
|
| 154 |
holdings: List of holding dicts with market_value, asset_class, etc.
|
| 155 |
scenario_id: ID of a predefined scenario
|
| 156 |
custom_shock: Custom market shock as decimal (e.g., -0.15 for -15%)
|
| 157 |
+
computed_betas: Pre-computed real betas per ticker (from compute_real_betas)
|
| 158 |
|
| 159 |
Returns:
|
| 160 |
Dict with per-holding impact and portfolio-level summary.
|
|
|
|
| 173 |
|
| 174 |
market_shock = scenario["market_shock"]
|
| 175 |
sector_shocks = scenario.get("sector_shocks", {})
|
| 176 |
+
computed_betas = computed_betas or {}
|
| 177 |
|
| 178 |
total_before = sum(h.get("market_value", 0) for h in holdings)
|
| 179 |
impacts = []
|
|
|
|
| 185 |
|
| 186 |
asset_class = h.get("asset_class", "equity")
|
| 187 |
sector = h.get("sector", "default")
|
| 188 |
+
ticker = h.get("ticker", "")
|
| 189 |
+
|
| 190 |
+
# Use computed beta if available, fall back to sector beta
|
| 191 |
+
if ticker in computed_betas:
|
| 192 |
+
beta = computed_betas[ticker]
|
| 193 |
+
beta_source = "computed"
|
| 194 |
+
else:
|
| 195 |
+
beta = SECTOR_BETAS.get(sector, SECTOR_BETAS.get(asset_class, 1.0))
|
| 196 |
+
beta_source = "sector_estimate"
|
| 197 |
|
| 198 |
# Check for sector-specific shock override
|
| 199 |
if sector in sector_shocks:
|
|
|
|
| 219 |
new_value = mv + impact_value
|
| 220 |
|
| 221 |
impacts.append({
|
| 222 |
+
"ticker": ticker,
|
| 223 |
+
"name": h.get("name", ticker),
|
| 224 |
"current_value": round(mv, 2),
|
| 225 |
+
"beta": round(beta, 4),
|
| 226 |
+
"beta_source": beta_source,
|
| 227 |
"shock_pct": round(shock * 100, 2),
|
| 228 |
"impact_value": round(impact_value, 2),
|
| 229 |
"new_value": round(new_value, 2),
|
|
|
|
| 256 |
}
|
| 257 |
|
| 258 |
|
| 259 |
+
def run_all_scenarios(
|
| 260 |
+
holdings: List[Dict[str, Any]],
|
| 261 |
+
computed_betas: Dict[str, float] | None = None,
|
| 262 |
+
) -> List[Dict[str, Any]]:
|
| 263 |
"""Run all predefined scenarios and return summaries."""
|
| 264 |
results = []
|
| 265 |
for sid in SCENARIOS:
|
| 266 |
+
result = run_stress_test(holdings, scenario_id=sid, computed_betas=computed_betas)
|
| 267 |
results.append({
|
| 268 |
"scenario_id": sid,
|
| 269 |
"name": result["scenario"]["name"],
|
backend/app/services/sentiment/engine.py
CHANGED
|
@@ -2,11 +2,12 @@
|
|
| 2 |
Sentiment Analysis Engine.
|
| 3 |
|
| 4 |
Provides sentiment analysis from multiple sources:
|
| 5 |
-
- News headlines (via RSS feeds from Yahoo Finance,
|
| 6 |
-
-
|
| 7 |
-
-
|
| 8 |
|
| 9 |
-
Uses
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
|
@@ -66,7 +67,64 @@ def _classify(score: float) -> str:
|
|
| 66 |
return "neutral"
|
| 67 |
|
| 68 |
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
"""Fetch and analyze news headlines for a ticker."""
|
| 71 |
cache_key = f"sentiment:news:{ticker}"
|
| 72 |
cached = await cache_get(cache_key)
|
|
@@ -94,28 +152,50 @@ async def fetch_news_sentiment(ticker: str) -> Dict[str, Any]:
|
|
| 94 |
continue
|
| 95 |
|
| 96 |
title = title_el.text.strip()
|
| 97 |
-
|
| 98 |
|
| 99 |
headlines.append({
|
| 100 |
"title": title,
|
| 101 |
"source": source,
|
| 102 |
-
"score":
|
| 103 |
-
"sentiment": _classify(
|
|
|
|
| 104 |
"published": pub_date_el.text if pub_date_el is not None else None,
|
| 105 |
"url": link_el.text if link_el is not None else None,
|
| 106 |
})
|
| 107 |
except Exception as e:
|
| 108 |
logger.warning("Failed to fetch %s news for %s: %s", source, ticker, e)
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
# Compute aggregate scores
|
| 111 |
if headlines:
|
| 112 |
avg_score = round(sum(h["score"] for h in headlines) / len(headlines), 3)
|
| 113 |
bullish_count = sum(1 for h in headlines if h["sentiment"] == "bullish")
|
| 114 |
bearish_count = sum(1 for h in headlines if h["sentiment"] == "bearish")
|
| 115 |
neutral_count = sum(1 for h in headlines if h["sentiment"] == "neutral")
|
|
|
|
| 116 |
else:
|
| 117 |
avg_score = 0.0
|
| 118 |
-
bullish_count = bearish_count = neutral_count = 0
|
| 119 |
|
| 120 |
result = {
|
| 121 |
"ticker": ticker,
|
|
@@ -125,6 +205,7 @@ async def fetch_news_sentiment(ticker: str) -> Dict[str, Any]:
|
|
| 125 |
"bullish": bullish_count,
|
| 126 |
"bearish": bearish_count,
|
| 127 |
"neutral": neutral_count,
|
|
|
|
| 128 |
"headlines": headlines[:20], # Latest 20
|
| 129 |
"timestamp": datetime.utcnow().isoformat(),
|
| 130 |
}
|
|
|
|
| 2 |
Sentiment Analysis Engine.
|
| 3 |
|
| 4 |
Provides sentiment analysis from multiple sources:
|
| 5 |
+
- News headlines (via RSS feeds from Yahoo Finance, Google News)
|
| 6 |
+
- Rule-based keyword scoring for fast fallback
|
| 7 |
+
- LLM-based sentiment classification via Groq for nuanced analysis
|
| 8 |
|
| 9 |
+
Uses financial keyword matching as primary method with optional
|
| 10 |
+
LLM upgrade for more accurate sentiment classification.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 67 |
return "neutral"
|
| 68 |
|
| 69 |
|
| 70 |
+
# ββ LLM-based Sentiment (Groq) ββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
+
|
| 72 |
+
async def _score_headlines_llm(headlines: List[str]) -> List[Dict[str, Any]]:
|
| 73 |
+
"""
|
| 74 |
+
Score headlines using Groq LLM for nuanced financial sentiment.
|
| 75 |
+
Falls back to rule-based scoring on failure.
|
| 76 |
+
"""
|
| 77 |
+
from app.config import get_settings
|
| 78 |
+
settings = get_settings()
|
| 79 |
+
|
| 80 |
+
if not settings.groq_api_key:
|
| 81 |
+
return []
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
headlines_text = "\n".join(f"{i+1}. {h}" for i, h in enumerate(headlines[:20]))
|
| 85 |
+
prompt = f"""Analyze each financial news headline and classify its sentiment.
|
| 86 |
+
Return ONLY a JSON array of objects with "index" (1-based), "sentiment" ("bullish"/"bearish"/"neutral"), and "score" (-1.0 to 1.0).
|
| 87 |
+
|
| 88 |
+
Headlines:
|
| 89 |
+
{headlines_text}
|
| 90 |
+
|
| 91 |
+
JSON response:"""
|
| 92 |
+
|
| 93 |
+
async with aiohttp.ClientSession() as session:
|
| 94 |
+
async with session.post(
|
| 95 |
+
"https://api.groq.com/openai/v1/chat/completions",
|
| 96 |
+
headers={
|
| 97 |
+
"Authorization": f"Bearer {settings.groq_api_key}",
|
| 98 |
+
"Content-Type": "application/json",
|
| 99 |
+
},
|
| 100 |
+
json={
|
| 101 |
+
"model": "llama-3.3-70b-versatile",
|
| 102 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 103 |
+
"temperature": 0.1,
|
| 104 |
+
"max_tokens": 1000,
|
| 105 |
+
},
|
| 106 |
+
timeout=aiohttp.ClientTimeout(total=15),
|
| 107 |
+
) as resp:
|
| 108 |
+
if resp.status != 200:
|
| 109 |
+
return []
|
| 110 |
+
data = await resp.json()
|
| 111 |
+
content = data["choices"][0]["message"]["content"]
|
| 112 |
+
|
| 113 |
+
# Extract JSON from response
|
| 114 |
+
import json
|
| 115 |
+
# Try to find JSON array in the response
|
| 116 |
+
start = content.find("[")
|
| 117 |
+
end = content.rfind("]") + 1
|
| 118 |
+
if start >= 0 and end > start:
|
| 119 |
+
results = json.loads(content[start:end])
|
| 120 |
+
return results
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logger.warning("LLM sentiment failed: %s", e)
|
| 123 |
+
|
| 124 |
+
return []
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
async def fetch_news_sentiment(ticker: str, use_llm: bool = True) -> Dict[str, Any]:
|
| 128 |
"""Fetch and analyze news headlines for a ticker."""
|
| 129 |
cache_key = f"sentiment:news:{ticker}"
|
| 130 |
cached = await cache_get(cache_key)
|
|
|
|
| 152 |
continue
|
| 153 |
|
| 154 |
title = title_el.text.strip()
|
| 155 |
+
rule_score = _score_headline(title)
|
| 156 |
|
| 157 |
headlines.append({
|
| 158 |
"title": title,
|
| 159 |
"source": source,
|
| 160 |
+
"score": rule_score,
|
| 161 |
+
"sentiment": _classify(rule_score),
|
| 162 |
+
"method": "rule_based",
|
| 163 |
"published": pub_date_el.text if pub_date_el is not None else None,
|
| 164 |
"url": link_el.text if link_el is not None else None,
|
| 165 |
})
|
| 166 |
except Exception as e:
|
| 167 |
logger.warning("Failed to fetch %s news for %s: %s", source, ticker, e)
|
| 168 |
|
| 169 |
+
# Try LLM-based scoring for better accuracy
|
| 170 |
+
llm_results = []
|
| 171 |
+
if use_llm and headlines:
|
| 172 |
+
try:
|
| 173 |
+
llm_results = await _score_headlines_llm([h["title"] for h in headlines[:20]])
|
| 174 |
+
except Exception:
|
| 175 |
+
pass
|
| 176 |
+
|
| 177 |
+
# Merge LLM results into headlines
|
| 178 |
+
if llm_results:
|
| 179 |
+
for lr in llm_results:
|
| 180 |
+
idx = lr.get("index", 0) - 1
|
| 181 |
+
if 0 <= idx < len(headlines):
|
| 182 |
+
headlines[idx]["ai_score"] = lr.get("score", headlines[idx]["score"])
|
| 183 |
+
headlines[idx]["ai_sentiment"] = lr.get("sentiment", headlines[idx]["sentiment"])
|
| 184 |
+
headlines[idx]["method"] = "ai_enhanced"
|
| 185 |
+
# Use AI score as primary if available
|
| 186 |
+
headlines[idx]["score"] = lr.get("score", headlines[idx]["score"])
|
| 187 |
+
headlines[idx]["sentiment"] = lr.get("sentiment", headlines[idx]["sentiment"])
|
| 188 |
+
|
| 189 |
# Compute aggregate scores
|
| 190 |
if headlines:
|
| 191 |
avg_score = round(sum(h["score"] for h in headlines) / len(headlines), 3)
|
| 192 |
bullish_count = sum(1 for h in headlines if h["sentiment"] == "bullish")
|
| 193 |
bearish_count = sum(1 for h in headlines if h["sentiment"] == "bearish")
|
| 194 |
neutral_count = sum(1 for h in headlines if h["sentiment"] == "neutral")
|
| 195 |
+
ai_enhanced = sum(1 for h in headlines if h.get("method") == "ai_enhanced")
|
| 196 |
else:
|
| 197 |
avg_score = 0.0
|
| 198 |
+
bullish_count = bearish_count = neutral_count = ai_enhanced = 0
|
| 199 |
|
| 200 |
result = {
|
| 201 |
"ticker": ticker,
|
|
|
|
| 205 |
"bullish": bullish_count,
|
| 206 |
"bearish": bearish_count,
|
| 207 |
"neutral": neutral_count,
|
| 208 |
+
"ai_enhanced_count": ai_enhanced,
|
| 209 |
"headlines": headlines[:20], # Latest 20
|
| 210 |
"timestamp": datetime.utcnow().isoformat(),
|
| 211 |
}
|
frontend/src/App.tsx
CHANGED
|
@@ -15,6 +15,11 @@ import ResearchInsights from './pages/ResearchInsights';
|
|
| 15 |
import HoldingsTracker from './pages/HoldingsTracker';
|
| 16 |
import Sentiment from './pages/Sentiment';
|
| 17 |
import EconomicCalendar from './pages/EconomicCalendar';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
import './index.css';
|
| 19 |
|
| 20 |
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
@@ -76,6 +81,22 @@ export default function App() {
|
|
| 76 |
<Route path="/calendar" element={
|
| 77 |
<ProtectedRoute><AppLayout><EconomicCalendar /></AppLayout></ProtectedRoute>
|
| 78 |
} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
</Routes>
|
| 80 |
</BrowserRouter>
|
| 81 |
);
|
|
|
|
| 15 |
import HoldingsTracker from './pages/HoldingsTracker';
|
| 16 |
import Sentiment from './pages/Sentiment';
|
| 17 |
import EconomicCalendar from './pages/EconomicCalendar';
|
| 18 |
+
import PortfolioHealth from './pages/PortfolioHealth';
|
| 19 |
+
import BiasDetector from './pages/BiasDetector';
|
| 20 |
+
import CrisisReplay from './pages/CrisisReplay';
|
| 21 |
+
import PortfolioDNA from './pages/PortfolioDNA';
|
| 22 |
+
import Copilot from './pages/Copilot';
|
| 23 |
import './index.css';
|
| 24 |
|
| 25 |
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
|
|
| 81 |
<Route path="/calendar" element={
|
| 82 |
<ProtectedRoute><AppLayout><EconomicCalendar /></AppLayout></ProtectedRoute>
|
| 83 |
} />
|
| 84 |
+
{/* ββ New Analytics & AI Pages ββββββββββββββββββββββββββββββββββββ */}
|
| 85 |
+
<Route path="/portfolio-health" element={
|
| 86 |
+
<ProtectedRoute><AppLayout><PortfolioHealth /></AppLayout></ProtectedRoute>
|
| 87 |
+
} />
|
| 88 |
+
<Route path="/bias-detector" element={
|
| 89 |
+
<ProtectedRoute><AppLayout><BiasDetector /></AppLayout></ProtectedRoute>
|
| 90 |
+
} />
|
| 91 |
+
<Route path="/crisis-replay" element={
|
| 92 |
+
<ProtectedRoute><AppLayout><CrisisReplay /></AppLayout></ProtectedRoute>
|
| 93 |
+
} />
|
| 94 |
+
<Route path="/portfolio-dna" element={
|
| 95 |
+
<ProtectedRoute><AppLayout><PortfolioDNA /></AppLayout></ProtectedRoute>
|
| 96 |
+
} />
|
| 97 |
+
<Route path="/copilot" element={
|
| 98 |
+
<ProtectedRoute><AppLayout><Copilot /></AppLayout></ProtectedRoute>
|
| 99 |
+
} />
|
| 100 |
</Routes>
|
| 101 |
</BrowserRouter>
|
| 102 |
);
|
frontend/src/api/client.ts
CHANGED
|
@@ -138,6 +138,17 @@ export const holdingsAPI = {
|
|
| 138 |
optionsHedge: () => api.get('/holdings/options-hedge'),
|
| 139 |
rebalance: () => api.get('/holdings/rebalance'),
|
| 140 |
correlation: () => api.get('/holdings/correlation'),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
};
|
| 142 |
|
| 143 |
// ββ Sentiment ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -163,4 +174,9 @@ export const mlAPI = {
|
|
| 163 |
clearCache: () => api.post('/ml/clear-cache'),
|
| 164 |
};
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
export default api;
|
|
|
|
| 138 |
optionsHedge: () => api.get('/holdings/options-hedge'),
|
| 139 |
rebalance: () => api.get('/holdings/rebalance'),
|
| 140 |
correlation: () => api.get('/holdings/correlation'),
|
| 141 |
+
// ββ New v2 endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 142 |
+
monteCarlo: (data?: { horizon_days?: number; n_simulations?: number; regime_conditioned?: boolean }) =>
|
| 143 |
+
api.post('/holdings/monte-carlo', data || {}),
|
| 144 |
+
healthScore: () => api.get('/holdings/health-score'),
|
| 145 |
+
biasAnalysis: () => api.get('/holdings/bias-analysis'),
|
| 146 |
+
crisisReplay: (crisis_id: string) =>
|
| 147 |
+
api.post('/holdings/crisis-replay', { crisis_id }),
|
| 148 |
+
allCrisisReplays: () => api.get('/holdings/crisis-replay/all'),
|
| 149 |
+
portfolioDNA: () => api.get('/holdings/portfolio-dna'),
|
| 150 |
+
attribution: (method: string = 'brinson') =>
|
| 151 |
+
api.get(`/holdings/attribution?method=${method}`),
|
| 152 |
};
|
| 153 |
|
| 154 |
// ββ Sentiment ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 174 |
clearCache: () => api.post('/ml/clear-cache'),
|
| 175 |
};
|
| 176 |
|
| 177 |
+
// ββ HedgeAIβββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 178 |
+
export const copilotAPI = {
|
| 179 |
+
chat: (message: string) => api.post('/copilot/chat', { message }),
|
| 180 |
+
};
|
| 181 |
+
|
| 182 |
export default api;
|
frontend/src/components/Sidebar.tsx
CHANGED
|
@@ -41,41 +41,51 @@ export default function Sidebar({ onExpandChange }: SidebarProps) {
|
|
| 41 |
const toggleTheme = () => setIsDark(prev => !prev);
|
| 42 |
|
| 43 |
const themeIcon = isDark ? (
|
| 44 |
-
<svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd"/></svg>
|
| 45 |
) : (
|
| 46 |
-
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
|
| 47 |
);
|
| 48 |
|
| 49 |
const allLinks = [
|
| 50 |
{
|
| 51 |
section: 'Overview',
|
| 52 |
items: [
|
| 53 |
-
{ to: '/dashboard', label: 'Dashboard', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"/></svg> },
|
| 54 |
-
{ to: '/holdings', label: 'Holdings', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd"/></svg> },
|
| 55 |
]
|
| 56 |
},
|
| 57 |
{
|
| 58 |
section: 'Research',
|
| 59 |
items: [
|
| 60 |
-
{ to: '/market', label: 'Markets', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z" clipRule="evenodd"/></svg> },
|
| 61 |
-
{ to: '/factors', label: 'Factor Analysis', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zm6-4a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zm6-3a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/></svg> },
|
| 62 |
-
{ to: '/sentiment', label: 'Sentiment', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clipRule="evenodd"/></svg> },
|
| 63 |
-
{ to: '/research', label: 'Research', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd"/></svg> },
|
| 64 |
-
{ to: '/calendar', label: 'Calendar', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd"/></svg> },
|
| 65 |
]
|
| 66 |
},
|
| 67 |
{
|
| 68 |
section: 'Quantitative',
|
| 69 |
items: [
|
| 70 |
-
{ to: '/strategies', label: 'Strategy Builder', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd"/></svg> },
|
| 71 |
-
{ to: '/backtests', label: 'Backtests', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd"/></svg> },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
]
|
| 73 |
},
|
| 74 |
{
|
| 75 |
section: 'Portfolio',
|
| 76 |
items: [
|
| 77 |
-
{ to: '/portfolio', label: 'Optimization', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"/><path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"/></svg> },
|
| 78 |
-
{ to: '/marketplace', label: 'Marketplace', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3zM16 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM6.5 18a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"/></svg> },
|
|
|
|
| 79 |
]
|
| 80 |
},
|
| 81 |
];
|
|
@@ -97,8 +107,8 @@ export default function Sidebar({ onExpandChange }: SidebarProps) {
|
|
| 97 |
> <div className="sidebar-brand">
|
| 98 |
<NavLink to="/dashboard" className="sidebar-logo">
|
| 99 |
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
| 100 |
-
<rect width="32" height="32" rx="8" fill="#005241"/>
|
| 101 |
-
<path d="M8 22 L12 14 L16 18 L20 10 L24 16" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
| 102 |
</svg>
|
| 103 |
<span className="sidebar-brand-text">
|
| 104 |
Quant<em>Hedge</em>
|
|
@@ -142,7 +152,7 @@ export default function Sidebar({ onExpandChange }: SidebarProps) {
|
|
| 142 |
</button>
|
| 143 |
<button className="sidebar-link sidebar-logout" onClick={handleLogout}>
|
| 144 |
<span className="sidebar-icon">
|
| 145 |
-
<svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd"/></svg>
|
| 146 |
</span>
|
| 147 |
<span className="sidebar-label">Log Out</span>
|
| 148 |
</button>
|
|
@@ -171,7 +181,7 @@ export default function Sidebar({ onExpandChange }: SidebarProps) {
|
|
| 171 |
onClick={() => setDrawerOpen(!drawerOpen)}
|
| 172 |
>
|
| 173 |
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20">
|
| 174 |
-
<path fillRule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd"/>
|
| 175 |
</svg>
|
| 176 |
<span>More</span>
|
| 177 |
</button>
|
|
@@ -219,7 +229,7 @@ export default function Sidebar({ onExpandChange }: SidebarProps) {
|
|
| 219 |
</button>
|
| 220 |
<button className="sidebar-link sidebar-logout" onClick={handleLogout} style={{ width: '100%' }}>
|
| 221 |
<span className="sidebar-icon">
|
| 222 |
-
<svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd"/></svg>
|
| 223 |
</span>
|
| 224 |
<span className="sidebar-label">Log Out</span>
|
| 225 |
</button>
|
|
|
|
| 41 |
const toggleTheme = () => setIsDark(prev => !prev);
|
| 42 |
|
| 43 |
const themeIcon = isDark ? (
|
| 44 |
+
<svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" /></svg>
|
| 45 |
) : (
|
| 46 |
+
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" /></svg>
|
| 47 |
);
|
| 48 |
|
| 49 |
const allLinks = [
|
| 50 |
{
|
| 51 |
section: 'Overview',
|
| 52 |
items: [
|
| 53 |
+
{ to: '/dashboard', label: 'Dashboard', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" /></svg> },
|
| 54 |
+
{ to: '/holdings', label: 'Holdings', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd" /></svg> },
|
| 55 |
]
|
| 56 |
},
|
| 57 |
{
|
| 58 |
section: 'Research',
|
| 59 |
items: [
|
| 60 |
+
{ to: '/market', label: 'Markets', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z" clipRule="evenodd" /></svg> },
|
| 61 |
+
{ to: '/factors', label: 'Factor Analysis', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zm6-4a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zm6-3a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z" /></svg> },
|
| 62 |
+
{ to: '/sentiment', label: 'Sentiment', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z" clipRule="evenodd" /></svg> },
|
| 63 |
+
{ to: '/research', label: 'Research', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd" /></svg> },
|
| 64 |
+
{ to: '/calendar', label: 'Calendar', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd" /></svg> },
|
| 65 |
]
|
| 66 |
},
|
| 67 |
{
|
| 68 |
section: 'Quantitative',
|
| 69 |
items: [
|
| 70 |
+
{ to: '/strategies', label: 'Strategy Builder', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" /></svg> },
|
| 71 |
+
{ to: '/backtests', label: 'Backtests', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clipRule="evenodd" /></svg> },
|
| 72 |
+
]
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
section: 'Analytics',
|
| 76 |
+
items: [
|
| 77 |
+
{ to: '/portfolio-health', label: 'Health Score', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clipRule="evenodd" /></svg> },
|
| 78 |
+
{ to: '/bias-detector', label: 'Bias Detector', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" /><path fillRule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" /></svg> },
|
| 79 |
+
{ to: '/crisis-replay', label: 'Crisis Replay', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z" clipRule="evenodd" /></svg> },
|
| 80 |
+
{ to: '/portfolio-dna', label: 'Portfolio DNA', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 3.5a1.5 1.5 0 013 0V4a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-.5a1.5 1.5 0 000 3h.5a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-.5a1.5 1.5 0 00-3 0v.5a1 1 0 01-1 1H6a1 1 0 01-1-1v-3a1 1 0 00-1-1h-.5a1.5 1.5 0 010-3H4a1 1 0 001-1V6a1 1 0 011-1h3a1 1 0 001-1v-.5z" /></svg> },
|
| 81 |
]
|
| 82 |
},
|
| 83 |
{
|
| 84 |
section: 'Portfolio',
|
| 85 |
items: [
|
| 86 |
+
{ to: '/portfolio', label: 'Optimization', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" /><path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" /></svg> },
|
| 87 |
+
{ to: '/marketplace', label: 'Marketplace', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3zM16 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM6.5 18a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" /></svg> },
|
| 88 |
+
{ to: '/copilot', label: 'HedgeAI', icon: <svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M6.672 1.911a1 1 0 10-1.932.518l.259.966a1 1 0 001.932-.518l-.26-.966zM2.429 4.74a1 1 0 10-.517 1.932l.966.259a1 1 0 00.517-1.932l-.966-.26zm8.814-.569a1 1 0 00-1.415-1.414l-.707.707a1 1 0 101.415 1.415l.707-.708zm-7.071 7.072l.707-.707A1 1 0 003.465 9.12l-.708.707a1 1 0 001.415 1.415zm3.2-5.171a1 1 0 00-1.3 1.3l4 10a1 1 0 001.823.075l1.38-2.759 3.018 3.02a1 1 0 001.414-1.415l-3.019-3.02 2.76-1.379a1 1 0 00-.076-1.822l-10-4z" clipRule="evenodd" /></svg> },
|
| 89 |
]
|
| 90 |
},
|
| 91 |
];
|
|
|
|
| 107 |
> <div className="sidebar-brand">
|
| 108 |
<NavLink to="/dashboard" className="sidebar-logo">
|
| 109 |
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
| 110 |
+
<rect width="32" height="32" rx="8" fill="#005241" />
|
| 111 |
+
<path d="M8 22 L12 14 L16 18 L20 10 L24 16" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
| 112 |
</svg>
|
| 113 |
<span className="sidebar-brand-text">
|
| 114 |
Quant<em>Hedge</em>
|
|
|
|
| 152 |
</button>
|
| 153 |
<button className="sidebar-link sidebar-logout" onClick={handleLogout}>
|
| 154 |
<span className="sidebar-icon">
|
| 155 |
+
<svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" /></svg>
|
| 156 |
</span>
|
| 157 |
<span className="sidebar-label">Log Out</span>
|
| 158 |
</button>
|
|
|
|
| 181 |
onClick={() => setDrawerOpen(!drawerOpen)}
|
| 182 |
>
|
| 183 |
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20">
|
| 184 |
+
<path fillRule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
|
| 185 |
</svg>
|
| 186 |
<span>More</span>
|
| 187 |
</button>
|
|
|
|
| 229 |
</button>
|
| 230 |
<button className="sidebar-link sidebar-logout" onClick={handleLogout} style={{ width: '100%' }}>
|
| 231 |
<span className="sidebar-icon">
|
| 232 |
+
<svg viewBox="0 0 20 20" fill="currentColor"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" /></svg>
|
| 233 |
</span>
|
| 234 |
<span className="sidebar-label">Log Out</span>
|
| 235 |
</button>
|
frontend/src/index.css
CHANGED
|
@@ -35,8 +35,8 @@
|
|
| 35 |
|
| 36 |
--gradient-accent: linear-gradient(135deg, #005241, #007d63);
|
| 37 |
--gradient-dark: linear-gradient(135deg, #0c1f1a 0%, #1a3a2e 50%, #0c1f1a 100%);
|
| 38 |
-
--gradient-hero-overlay: linear-gradient(180deg, rgba(12,31,26,0.85) 0%, rgba(12,31,26,0.7) 50%, rgba(12,31,26,0.9) 100%);
|
| 39 |
-
--gradient-card-hover: linear-gradient(145deg, rgba(0,82,65,0.02), rgba(0,82,65,0.06));
|
| 40 |
|
| 41 |
--border-color: #e2e5ea;
|
| 42 |
--border-subtle: #eef0f3;
|
|
@@ -45,7 +45,7 @@
|
|
| 45 |
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
|
| 46 |
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
| 47 |
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.1);
|
| 48 |
-
--shadow-card: 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.06);
|
| 49 |
--shadow-card-hover: 0 10px 40px rgba(0, 0, 0, 0.08);
|
| 50 |
|
| 51 |
--radius-sm: 4px;
|
|
@@ -103,7 +103,7 @@
|
|
| 103 |
--blue-info: #3b82f6;
|
| 104 |
|
| 105 |
--gradient-accent: linear-gradient(135deg, #00a87a, #00c896);
|
| 106 |
-
--gradient-card-hover: linear-gradient(145deg, rgba(0,168,122,0.04), rgba(0,168,122,0.08));
|
| 107 |
|
| 108 |
--border-color: #1f2228;
|
| 109 |
--border-subtle: #181b20;
|
|
@@ -112,7 +112,7 @@
|
|
| 112 |
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
|
| 113 |
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
| 114 |
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
|
| 115 |
-
--shadow-card: 0 1px 3px rgba(0,0,0,0.2), 0 1px 2px rgba(0,0,0,0.15);
|
| 116 |
--shadow-card-hover: 0 10px 40px rgba(0, 0, 0, 0.35);
|
| 117 |
|
| 118 |
--chart-axis: #6b7588;
|
|
@@ -132,34 +132,92 @@ body {
|
|
| 132 |
}
|
| 133 |
|
| 134 |
/* Dark mode: badge overrides */
|
| 135 |
-
[data-theme="dark"] .badge-primary {
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
/* Dark mode: table header */
|
| 141 |
-
[data-theme="dark"] th {
|
|
|
|
|
|
|
| 142 |
|
| 143 |
/* Dark mode: scrollbar */
|
| 144 |
-
[data-theme="dark"] ::-webkit-scrollbar-track {
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
/* Dark mode: selection */
|
| 149 |
-
[data-theme="dark"] ::selection {
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
/* Dark mode: React Flow (Strategy Builder) */
|
| 152 |
-
[data-theme="dark"] .react-flow__controls {
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
/* ββ Reset βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 160 |
-
*,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
-
html {
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
body {
|
| 165 |
font-family: var(--font-sans);
|
|
@@ -169,21 +227,52 @@ body {
|
|
| 169 |
min-height: 100vh;
|
| 170 |
}
|
| 171 |
|
| 172 |
-
#root {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
-
a
|
| 175 |
-
|
|
|
|
| 176 |
|
| 177 |
/* ββ Typography ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 178 |
-
h1,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
font-weight: 600;
|
| 180 |
line-height: 1.25;
|
| 181 |
letter-spacing: -0.02em;
|
| 182 |
color: var(--text-primary);
|
| 183 |
}
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
.text-gradient {
|
| 189 |
background: var(--gradient-accent);
|
|
@@ -191,31 +280,85 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 191 |
-webkit-text-fill-color: transparent;
|
| 192 |
background-clip: text;
|
| 193 |
}
|
| 194 |
-
|
| 195 |
-
.text-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
/* ββ Layout ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 199 |
.app-layout {
|
| 200 |
display: flex;
|
| 201 |
min-height: 100vh;
|
| 202 |
}
|
|
|
|
| 203 |
.app-main {
|
| 204 |
flex: 1;
|
| 205 |
min-width: 0;
|
| 206 |
margin-left: 68px;
|
| 207 |
transition: margin-left var(--transition-base);
|
| 208 |
}
|
| 209 |
-
.page { flex: 1; padding: 2.5rem 3rem; max-width: 1320px; margin: 0 auto; width: 100%; }
|
| 210 |
-
.page-header { margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); }
|
| 211 |
-
.page-header h1 { margin-bottom: 0.25rem; }
|
| 212 |
-
.page-header p { color: var(--text-secondary); font-size: 0.95rem; }
|
| 213 |
|
| 214 |
-
.
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
|
| 220 |
/* ββ Sidebar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 221 |
.sidebar {
|
|
@@ -232,6 +375,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 232 |
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
| 233 |
z-index: 200;
|
| 234 |
}
|
|
|
|
| 235 |
.sidebar.sidebar-expanded {
|
| 236 |
width: 260px;
|
| 237 |
}
|
|
@@ -245,6 +389,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 245 |
display: flex;
|
| 246 |
align-items: center;
|
| 247 |
}
|
|
|
|
| 248 |
.sidebar-logo {
|
| 249 |
display: flex;
|
| 250 |
align-items: center;
|
|
@@ -253,9 +398,11 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 253 |
color: var(--text-primary);
|
| 254 |
white-space: nowrap;
|
| 255 |
}
|
|
|
|
| 256 |
.sidebar-logo svg {
|
| 257 |
flex-shrink: 0;
|
| 258 |
}
|
|
|
|
| 259 |
.sidebar-brand-text {
|
| 260 |
font-family: var(--font-serif);
|
| 261 |
font-size: 1.2rem;
|
|
@@ -265,10 +412,12 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 265 |
transform: translateX(-8px);
|
| 266 |
transition: opacity 0.2s ease 0.05s, transform 0.2s ease 0.05s;
|
| 267 |
}
|
|
|
|
| 268 |
.sidebar.sidebar-expanded .sidebar-brand-text {
|
| 269 |
opacity: 1;
|
| 270 |
transform: translateX(0);
|
| 271 |
}
|
|
|
|
| 272 |
.sidebar-brand-text em {
|
| 273 |
font-style: normal;
|
| 274 |
color: var(--accent);
|
|
@@ -281,11 +430,15 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 281 |
overflow-x: hidden;
|
| 282 |
padding: 0.5rem 0;
|
| 283 |
}
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
| 285 |
|
| 286 |
.sidebar-section {
|
| 287 |
padding: 0.5rem 0;
|
| 288 |
}
|
|
|
|
| 289 |
.sidebar-section-label {
|
| 290 |
padding: 0.375rem 1.4rem;
|
| 291 |
font-size: 0.6rem;
|
|
@@ -297,6 +450,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 297 |
opacity: 0;
|
| 298 |
transition: opacity 0.2s ease 0.05s;
|
| 299 |
}
|
|
|
|
| 300 |
.sidebar.sidebar-expanded .sidebar-section-label {
|
| 301 |
opacity: 1;
|
| 302 |
}
|
|
@@ -321,10 +475,12 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 321 |
text-align: left;
|
| 322 |
font-family: var(--font-sans);
|
| 323 |
}
|
|
|
|
| 324 |
.sidebar-link:hover {
|
| 325 |
background: var(--bg-hover);
|
| 326 |
color: var(--accent);
|
| 327 |
}
|
|
|
|
| 328 |
.sidebar-link.active {
|
| 329 |
background: var(--accent-lighter);
|
| 330 |
color: var(--accent);
|
|
@@ -339,6 +495,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 339 |
align-items: center;
|
| 340 |
justify-content: center;
|
| 341 |
}
|
|
|
|
| 342 |
.sidebar-icon svg {
|
| 343 |
width: 18px;
|
| 344 |
height: 18px;
|
|
@@ -349,6 +506,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 349 |
transform: translateX(-8px);
|
| 350 |
transition: opacity 0.2s ease 0.05s, transform 0.2s ease 0.05s;
|
| 351 |
}
|
|
|
|
| 352 |
.sidebar.sidebar-expanded .sidebar-label {
|
| 353 |
opacity: 1;
|
| 354 |
transform: translateX(0);
|
|
@@ -360,6 +518,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 360 |
padding: 0.75rem;
|
| 361 |
flex-shrink: 0;
|
| 362 |
}
|
|
|
|
| 363 |
.sidebar-user {
|
| 364 |
display: flex;
|
| 365 |
align-items: center;
|
|
@@ -369,6 +528,7 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 369 |
white-space: nowrap;
|
| 370 |
overflow: hidden;
|
| 371 |
}
|
|
|
|
| 372 |
.sidebar-avatar {
|
| 373 |
width: 32px;
|
| 374 |
height: 32px;
|
|
@@ -382,29 +542,36 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 382 |
font-weight: 700;
|
| 383 |
flex-shrink: 0;
|
| 384 |
}
|
|
|
|
| 385 |
.sidebar-user-info {
|
| 386 |
opacity: 0;
|
| 387 |
transition: opacity 0.2s ease 0.05s;
|
| 388 |
}
|
|
|
|
| 389 |
.sidebar.sidebar-expanded .sidebar-user-info {
|
| 390 |
opacity: 1;
|
| 391 |
}
|
|
|
|
| 392 |
.sidebar-user-name {
|
| 393 |
font-size: 0.78rem;
|
| 394 |
font-weight: 600;
|
| 395 |
color: var(--text-primary);
|
| 396 |
}
|
|
|
|
| 397 |
.sidebar-user-email {
|
| 398 |
font-size: 0.65rem;
|
| 399 |
color: var(--text-muted);
|
| 400 |
}
|
|
|
|
| 401 |
.sidebar-logout {
|
| 402 |
color: var(--text-muted) !important;
|
| 403 |
}
|
|
|
|
| 404 |
.sidebar-logout:hover {
|
| 405 |
color: var(--red-negative) !important;
|
| 406 |
background: rgba(194, 48, 48, 0.05) !important;
|
| 407 |
}
|
|
|
|
| 408 |
.card {
|
| 409 |
background: var(--bg-card);
|
| 410 |
border: 1px solid var(--border-color);
|
|
@@ -413,10 +580,12 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 413 |
box-shadow: var(--shadow-card);
|
| 414 |
transition: all var(--transition-base);
|
| 415 |
}
|
|
|
|
| 416 |
.card:hover {
|
| 417 |
box-shadow: var(--shadow-card-hover);
|
| 418 |
border-color: var(--border-accent);
|
| 419 |
}
|
|
|
|
| 420 |
.card-header {
|
| 421 |
display: flex;
|
| 422 |
justify-content: space-between;
|
|
@@ -425,7 +594,13 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 425 |
padding-bottom: 0.875rem;
|
| 426 |
border-bottom: 1px solid var(--border-subtle);
|
| 427 |
}
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
|
| 430 |
/* ββ Buttons βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 431 |
.btn {
|
|
@@ -445,31 +620,55 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 445 |
letter-spacing: 0.02em;
|
| 446 |
text-transform: uppercase;
|
| 447 |
}
|
| 448 |
-
|
|
|
|
|
|
|
|
|
|
| 449 |
|
| 450 |
.btn-primary {
|
| 451 |
background: var(--accent);
|
| 452 |
color: #fff;
|
| 453 |
}
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
| 455 |
|
| 456 |
.btn-secondary {
|
| 457 |
background: transparent;
|
| 458 |
color: var(--text-primary);
|
| 459 |
border: 1px solid var(--border-color);
|
| 460 |
}
|
|
|
|
| 461 |
.btn-secondary:hover {
|
| 462 |
background: var(--bg-hover);
|
| 463 |
border-color: var(--accent);
|
| 464 |
color: var(--accent);
|
| 465 |
}
|
| 466 |
|
| 467 |
-
.btn-sm {
|
| 468 |
-
|
| 469 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
|
| 471 |
/* ββ Form Elements βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 472 |
-
.form-group {
|
|
|
|
|
|
|
|
|
|
| 473 |
.form-group label {
|
| 474 |
display: block;
|
| 475 |
font-size: 0.75rem;
|
|
@@ -480,7 +679,9 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 480 |
letter-spacing: 0.06em;
|
| 481 |
}
|
| 482 |
|
| 483 |
-
.input,
|
|
|
|
|
|
|
| 484 |
width: 100%;
|
| 485 |
padding: 0.625rem 0.875rem;
|
| 486 |
background: var(--bg-primary);
|
|
@@ -492,11 +693,17 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 492 |
transition: all var(--transition-fast);
|
| 493 |
outline: none;
|
| 494 |
}
|
| 495 |
-
|
|
|
|
|
|
|
|
|
|
| 496 |
border-color: var(--accent);
|
| 497 |
box-shadow: 0 0 0 3px rgba(0, 82, 65, 0.1);
|
| 498 |
}
|
| 499 |
-
|
|
|
|
|
|
|
|
|
|
| 500 |
|
| 501 |
/* ββ Table βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 502 |
.table-container {
|
|
@@ -504,7 +711,12 @@ h3 { font-size: 1rem; font-family: var(--font-sans); font-weight: 600; }
|
|
| 504 |
border-radius: var(--radius-md);
|
| 505 |
border: 1px solid var(--border-color);
|
| 506 |
}
|
| 507 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
th {
|
| 509 |
padding: 0.75rem 1rem;
|
| 510 |
text-align: left;
|
|
@@ -516,6 +728,7 @@ th {
|
|
| 516 |
background: var(--bg-secondary);
|
| 517 |
border-bottom: 2px solid var(--border-color);
|
| 518 |
}
|
|
|
|
| 519 |
td {
|
| 520 |
padding: 0.75rem 1rem;
|
| 521 |
font-size: 0.85rem;
|
|
@@ -523,16 +736,24 @@ td {
|
|
| 523 |
font-family: var(--font-mono);
|
| 524 |
font-size: 0.8rem;
|
| 525 |
}
|
| 526 |
-
|
|
|
|
|
|
|
|
|
|
| 527 |
|
| 528 |
/* ββ Metrics βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 529 |
-
.metric {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
.metric-value {
|
| 531 |
font-size: 1.75rem;
|
| 532 |
font-weight: 700;
|
| 533 |
font-family: var(--font-mono);
|
| 534 |
letter-spacing: -0.03em;
|
| 535 |
}
|
|
|
|
| 536 |
.metric-label {
|
| 537 |
font-size: 0.65rem;
|
| 538 |
text-transform: uppercase;
|
|
@@ -541,9 +762,18 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 541 |
margin-top: 0.25rem;
|
| 542 |
font-weight: 600;
|
| 543 |
}
|
| 544 |
-
|
| 545 |
-
.
|
| 546 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
|
| 548 |
/* ββ Badge βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 549 |
.badge {
|
|
@@ -556,10 +786,26 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 556 |
letter-spacing: 0.06em;
|
| 557 |
text-transform: uppercase;
|
| 558 |
}
|
| 559 |
-
|
| 560 |
-
.badge-
|
| 561 |
-
|
| 562 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
|
| 564 |
/* ββ Tabs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 565 |
.tabs {
|
|
@@ -568,6 +814,7 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 568 |
border-bottom: 2px solid var(--border-color);
|
| 569 |
margin-bottom: 1.75rem;
|
| 570 |
}
|
|
|
|
| 571 |
.tab {
|
| 572 |
padding: 0.75rem 1.25rem;
|
| 573 |
font-size: 0.8rem;
|
|
@@ -582,7 +829,11 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 582 |
text-transform: uppercase;
|
| 583 |
letter-spacing: 0.06em;
|
| 584 |
}
|
| 585 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
.tab.active {
|
| 587 |
color: var(--accent);
|
| 588 |
border-bottom-color: var(--accent);
|
|
@@ -597,7 +848,12 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 597 |
border-radius: 50%;
|
| 598 |
animation: spin 0.8s linear infinite;
|
| 599 |
}
|
| 600 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
|
| 602 |
.loading-overlay {
|
| 603 |
display: flex;
|
|
@@ -611,15 +867,34 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 611 |
|
| 612 |
/* ββ Animations ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 613 |
@keyframes fadeInUp {
|
| 614 |
-
from {
|
| 615 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 616 |
}
|
|
|
|
| 617 |
@keyframes fadeIn {
|
| 618 |
-
from {
|
| 619 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
}
|
| 621 |
-
.animate-fade-in { animation: fadeIn 0.6s ease-out; }
|
| 622 |
-
.animate-fade-in-up { animation: fadeInUp 0.7s ease-out; }
|
| 623 |
|
| 624 |
/* ββ Chart βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 625 |
.chart-container {
|
|
@@ -639,11 +914,25 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 639 |
text-align: center;
|
| 640 |
color: var(--text-muted);
|
| 641 |
}
|
| 642 |
-
|
| 643 |
-
.empty-state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
|
| 645 |
/* ββ Divider βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 646 |
-
.divider {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
|
| 648 |
/* ββ Professional Icon Container βββββββββββββββββββββββββββββββββββββββ */
|
| 649 |
.icon-box {
|
|
@@ -657,7 +946,11 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 657 |
color: var(--accent);
|
| 658 |
flex-shrink: 0;
|
| 659 |
}
|
| 660 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
|
| 662 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 663 |
MOBILE RESPONSIVE β max-width: 768px
|
|
@@ -669,7 +962,8 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 669 |
/* ββ App Shell βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 670 |
.app-main {
|
| 671 |
margin-left: 0 !important;
|
| 672 |
-
padding-bottom: 72px;
|
|
|
|
| 673 |
}
|
| 674 |
|
| 675 |
/* ββ Desktop Sidebar hidden on mobile ββββββββββββββββββββββββββββββββ */
|
|
@@ -691,6 +985,7 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 691 |
padding: 0 0.25rem;
|
| 692 |
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
|
| 693 |
}
|
|
|
|
| 694 |
.mobile-nav-items {
|
| 695 |
display: flex;
|
| 696 |
justify-content: space-around;
|
|
@@ -698,6 +993,7 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 698 |
width: 100%;
|
| 699 |
height: 100%;
|
| 700 |
}
|
|
|
|
| 701 |
.mobile-nav-item {
|
| 702 |
display: flex;
|
| 703 |
flex-direction: column;
|
|
@@ -717,10 +1013,12 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 717 |
cursor: pointer;
|
| 718 |
font-family: var(--font-sans);
|
| 719 |
}
|
|
|
|
| 720 |
.mobile-nav-item svg {
|
| 721 |
width: 20px;
|
| 722 |
height: 20px;
|
| 723 |
}
|
|
|
|
| 724 |
.mobile-nav-item.active,
|
| 725 |
.mobile-nav-item:hover {
|
| 726 |
color: var(--accent);
|
|
@@ -734,6 +1032,7 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 734 |
z-index: 400;
|
| 735 |
animation: fadeIn 0.2s ease;
|
| 736 |
}
|
|
|
|
| 737 |
.mobile-drawer {
|
| 738 |
position: fixed;
|
| 739 |
bottom: 0;
|
|
@@ -747,10 +1046,17 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 747 |
overflow-y: auto;
|
| 748 |
animation: slideUp 0.25s ease;
|
| 749 |
}
|
|
|
|
| 750 |
@keyframes slideUp {
|
| 751 |
-
from {
|
| 752 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 753 |
}
|
|
|
|
| 754 |
.mobile-drawer-handle {
|
| 755 |
width: 36px;
|
| 756 |
height: 4px;
|
|
@@ -758,6 +1064,7 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 758 |
border-radius: 2px;
|
| 759 |
margin: 0 auto 1rem;
|
| 760 |
}
|
|
|
|
| 761 |
.mobile-drawer .sidebar-link {
|
| 762 |
width: 100%;
|
| 763 |
padding: 0.75rem 1rem;
|
|
@@ -766,14 +1073,17 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 766 |
border-radius: var(--radius-md);
|
| 767 |
opacity: 1;
|
| 768 |
}
|
|
|
|
| 769 |
.mobile-drawer .sidebar-link .sidebar-label {
|
| 770 |
opacity: 1;
|
| 771 |
transform: none;
|
| 772 |
}
|
|
|
|
| 773 |
.mobile-drawer .sidebar-link .sidebar-icon {
|
| 774 |
width: 22px;
|
| 775 |
height: 22px;
|
| 776 |
}
|
|
|
|
| 777 |
.mobile-drawer .sidebar-section-label {
|
| 778 |
opacity: 1;
|
| 779 |
padding: 0.5rem 1rem 0.25rem;
|
|
@@ -785,13 +1095,16 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 785 |
padding: 1.25rem 1rem !important;
|
| 786 |
max-width: 100%;
|
| 787 |
}
|
|
|
|
| 788 |
.page-header {
|
| 789 |
margin-bottom: 1.25rem;
|
| 790 |
padding-bottom: 1rem;
|
| 791 |
}
|
|
|
|
| 792 |
.page-header h1 {
|
| 793 |
font-size: 1.5rem !important;
|
| 794 |
}
|
|
|
|
| 795 |
.page-header p {
|
| 796 |
font-size: 0.85rem;
|
| 797 |
}
|
|
@@ -809,12 +1122,14 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 809 |
padding: 1.125rem !important;
|
| 810 |
border-radius: var(--radius-md);
|
| 811 |
}
|
|
|
|
| 812 |
.card-header {
|
| 813 |
margin-bottom: 1rem;
|
| 814 |
padding-bottom: 0.75rem;
|
| 815 |
flex-wrap: wrap;
|
| 816 |
gap: 0.5rem;
|
| 817 |
}
|
|
|
|
| 818 |
.card-header h3 {
|
| 819 |
font-size: 0.88rem;
|
| 820 |
}
|
|
@@ -827,7 +1142,9 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 827 |
border-right: none;
|
| 828 |
-webkit-overflow-scrolling: touch;
|
| 829 |
}
|
| 830 |
-
|
|
|
|
|
|
|
| 831 |
padding: 0.5rem 0.625rem;
|
| 832 |
font-size: 0.72rem;
|
| 833 |
white-space: nowrap;
|
|
@@ -840,9 +1157,17 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 840 |
}
|
| 841 |
|
| 842 |
/* ββ Metrics βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 843 |
-
.metric {
|
| 844 |
-
|
| 845 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 846 |
|
| 847 |
/* ββ Tabs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 848 |
.tabs {
|
|
@@ -853,7 +1178,11 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 853 |
margin-bottom: 1.25rem;
|
| 854 |
scrollbar-width: none;
|
| 855 |
}
|
| 856 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 857 |
.tab {
|
| 858 |
padding: 0.625rem 0.875rem;
|
| 859 |
font-size: 0.7rem;
|
|
@@ -867,9 +1196,13 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 867 |
}
|
| 868 |
|
| 869 |
/* ββ Forms βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 870 |
-
.input,
|
| 871 |
-
|
|
|
|
|
|
|
|
|
|
| 872 |
}
|
|
|
|
| 873 |
.form-group label {
|
| 874 |
font-size: 0.7rem;
|
| 875 |
}
|
|
@@ -901,7 +1234,11 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 901 |
width: 40px;
|
| 902 |
height: 40px;
|
| 903 |
}
|
| 904 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 905 |
|
| 906 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 907 |
PAGE-SPECIFIC MOBILE OVERRIDES
|
|
@@ -911,30 +1248,38 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 911 |
.landing-header {
|
| 912 |
padding: 0.875rem 1rem !important;
|
| 913 |
}
|
|
|
|
| 914 |
.landing-header .brand-text {
|
| 915 |
font-size: 1.1rem;
|
| 916 |
}
|
|
|
|
| 917 |
.landing-hero {
|
| 918 |
min-height: 80vh !important;
|
| 919 |
padding: 1rem !important;
|
| 920 |
}
|
|
|
|
| 921 |
.landing-hero h1 {
|
| 922 |
font-size: clamp(1.75rem, 7vw, 2.5rem) !important;
|
| 923 |
line-height: 1.2 !important;
|
| 924 |
}
|
|
|
|
| 925 |
.landing-hero p {
|
| 926 |
font-size: 0.9rem !important;
|
| 927 |
}
|
|
|
|
| 928 |
.landing-hero .flex-gap {
|
| 929 |
flex-direction: column;
|
| 930 |
width: 100%;
|
| 931 |
}
|
|
|
|
| 932 |
.landing-hero .flex-gap .btn {
|
| 933 |
width: 100%;
|
| 934 |
}
|
|
|
|
| 935 |
.landing-section {
|
| 936 |
padding: 3rem 1.25rem !important;
|
| 937 |
}
|
|
|
|
| 938 |
.landing-footer {
|
| 939 |
flex-direction: column !important;
|
| 940 |
gap: 0.5rem;
|
|
@@ -946,10 +1291,12 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 946 |
.auth-container {
|
| 947 |
flex-direction: column !important;
|
| 948 |
}
|
|
|
|
| 949 |
.auth-card {
|
| 950 |
padding: 2rem 1.25rem !important;
|
| 951 |
min-height: 100vh;
|
| 952 |
}
|
|
|
|
| 953 |
.auth-image {
|
| 954 |
display: none !important;
|
| 955 |
}
|
|
@@ -958,6 +1305,7 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 958 |
.dashboard-ticker-strip {
|
| 959 |
gap: 0.375rem !important;
|
| 960 |
}
|
|
|
|
| 961 |
.dashboard-ticker-strip button {
|
| 962 |
min-width: 120px !important;
|
| 963 |
padding: 0.5rem 0.625rem !important;
|
|
@@ -968,20 +1316,25 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 968 |
flex-direction: column !important;
|
| 969 |
gap: 0.75rem !important;
|
| 970 |
}
|
|
|
|
| 971 |
.market-search-bar .input {
|
| 972 |
width: 100% !important;
|
| 973 |
}
|
|
|
|
| 974 |
.market-search-bar select {
|
| 975 |
width: 100% !important;
|
| 976 |
}
|
|
|
|
| 977 |
.market-tabs {
|
| 978 |
overflow-x: auto !important;
|
| 979 |
flex-wrap: nowrap !important;
|
| 980 |
}
|
|
|
|
| 981 |
.ticker-chips {
|
| 982 |
flex-wrap: wrap !important;
|
| 983 |
gap: 0.375rem !important;
|
| 984 |
}
|
|
|
|
| 985 |
.company-info-grid {
|
| 986 |
grid-template-columns: 1fr 1fr !important;
|
| 987 |
gap: 0.625rem !important;
|
|
@@ -992,6 +1345,7 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 992 |
flex-direction: column !important;
|
| 993 |
gap: 0.75rem !important;
|
| 994 |
}
|
|
|
|
| 995 |
.factor-input-bar .input {
|
| 996 |
width: 100% !important;
|
| 997 |
}
|
|
@@ -1006,6 +1360,7 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 1006 |
flex-direction: column !important;
|
| 1007 |
gap: 0.5rem !important;
|
| 1008 |
}
|
|
|
|
| 1009 |
.holdings-actions .btn {
|
| 1010 |
width: 100%;
|
| 1011 |
}
|
|
@@ -1025,6 +1380,7 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 1025 |
.recharts-wrapper {
|
| 1026 |
font-size: 0.7rem;
|
| 1027 |
}
|
|
|
|
| 1028 |
.recharts-cartesian-axis-tick-value {
|
| 1029 |
font-size: 0.65rem;
|
| 1030 |
}
|
|
@@ -1034,9 +1390,11 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 1034 |
[style*="grid-template-columns: repeat"] {
|
| 1035 |
grid-template-columns: 1fr !important;
|
| 1036 |
}
|
|
|
|
| 1037 |
[style*="gridTemplateColumns"] {
|
| 1038 |
grid-template-columns: 1fr !important;
|
| 1039 |
}
|
|
|
|
| 1040 |
[style*="padding: 6rem 3rem"],
|
| 1041 |
[style*="padding:'6rem 3rem'"] {
|
| 1042 |
padding: 3rem 1.25rem !important;
|
|
@@ -1049,9 +1407,1207 @@ tr:hover td { background: var(--bg-hover); }
|
|
| 1049 |
}
|
| 1050 |
|
| 1051 |
/* ββ Scrollbar βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1052 |
-
::-webkit-scrollbar {
|
| 1053 |
-
:
|
| 1054 |
-
:
|
| 1055 |
-
|
| 1056 |
|
| 1057 |
-
::
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
--gradient-accent: linear-gradient(135deg, #005241, #007d63);
|
| 37 |
--gradient-dark: linear-gradient(135deg, #0c1f1a 0%, #1a3a2e 50%, #0c1f1a 100%);
|
| 38 |
+
--gradient-hero-overlay: linear-gradient(180deg, rgba(12, 31, 26, 0.85) 0%, rgba(12, 31, 26, 0.7) 50%, rgba(12, 31, 26, 0.9) 100%);
|
| 39 |
+
--gradient-card-hover: linear-gradient(145deg, rgba(0, 82, 65, 0.02), rgba(0, 82, 65, 0.06));
|
| 40 |
|
| 41 |
--border-color: #e2e5ea;
|
| 42 |
--border-subtle: #eef0f3;
|
|
|
|
| 45 |
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
|
| 46 |
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
| 47 |
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.1);
|
| 48 |
+
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06);
|
| 49 |
--shadow-card-hover: 0 10px 40px rgba(0, 0, 0, 0.08);
|
| 50 |
|
| 51 |
--radius-sm: 4px;
|
|
|
|
| 103 |
--blue-info: #3b82f6;
|
| 104 |
|
| 105 |
--gradient-accent: linear-gradient(135deg, #00a87a, #00c896);
|
| 106 |
+
--gradient-card-hover: linear-gradient(145deg, rgba(0, 168, 122, 0.04), rgba(0, 168, 122, 0.08));
|
| 107 |
|
| 108 |
--border-color: #1f2228;
|
| 109 |
--border-subtle: #181b20;
|
|
|
|
| 112 |
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
|
| 113 |
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
| 114 |
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
|
| 115 |
+
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.15);
|
| 116 |
--shadow-card-hover: 0 10px 40px rgba(0, 0, 0, 0.35);
|
| 117 |
|
| 118 |
--chart-axis: #6b7588;
|
|
|
|
| 132 |
}
|
| 133 |
|
| 134 |
/* Dark mode: badge overrides */
|
| 135 |
+
[data-theme="dark"] .badge-primary {
|
| 136 |
+
background: rgba(0, 168, 122, 0.15);
|
| 137 |
+
color: var(--accent);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
[data-theme="dark"] .badge-emerald {
|
| 141 |
+
background: rgba(16, 185, 129, 0.15);
|
| 142 |
+
color: var(--green-positive);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
[data-theme="dark"] .badge-rose {
|
| 146 |
+
background: rgba(239, 68, 68, 0.15);
|
| 147 |
+
color: var(--red-negative);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
[data-theme="dark"] .badge-amber {
|
| 151 |
+
background: rgba(212, 168, 67, 0.15);
|
| 152 |
+
color: var(--amber-neutral);
|
| 153 |
+
}
|
| 154 |
|
| 155 |
/* Dark mode: table header */
|
| 156 |
+
[data-theme="dark"] th {
|
| 157 |
+
background: var(--bg-tertiary);
|
| 158 |
+
}
|
| 159 |
|
| 160 |
/* Dark mode: scrollbar */
|
| 161 |
+
[data-theme="dark"] ::-webkit-scrollbar-track {
|
| 162 |
+
background: var(--bg-secondary);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
[data-theme="dark"] ::-webkit-scrollbar-thumb {
|
| 166 |
+
background: #2a2d32;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
|
| 170 |
+
background: var(--accent);
|
| 171 |
+
}
|
| 172 |
|
| 173 |
/* Dark mode: selection */
|
| 174 |
+
[data-theme="dark"] ::selection {
|
| 175 |
+
background: rgba(0, 168, 122, 0.25);
|
| 176 |
+
color: var(--text-primary);
|
| 177 |
+
}
|
| 178 |
|
| 179 |
/* Dark mode: React Flow (Strategy Builder) */
|
| 180 |
+
[data-theme="dark"] .react-flow__controls {
|
| 181 |
+
background: var(--bg-tertiary);
|
| 182 |
+
border-color: var(--border-color);
|
| 183 |
+
border-radius: var(--radius-md);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
[data-theme="dark"] .react-flow__controls-button {
|
| 187 |
+
background: var(--bg-card);
|
| 188 |
+
border-color: var(--border-color);
|
| 189 |
+
fill: var(--text-secondary);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
[data-theme="dark"] .react-flow__controls-button:hover {
|
| 193 |
+
background: var(--bg-hover);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
[data-theme="dark"] .react-flow__minimap {
|
| 197 |
+
background: var(--bg-tertiary) !important;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
[data-theme="dark"] .react-flow__attribution {
|
| 201 |
+
background: transparent !important;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
[data-theme="dark"] .react-flow__attribution a {
|
| 205 |
+
color: var(--text-muted) !important;
|
| 206 |
+
}
|
| 207 |
|
| 208 |
/* ββ Reset βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 209 |
+
*,
|
| 210 |
+
*::before,
|
| 211 |
+
*::after {
|
| 212 |
+
margin: 0;
|
| 213 |
+
padding: 0;
|
| 214 |
+
box-sizing: border-box;
|
| 215 |
+
}
|
| 216 |
|
| 217 |
+
html {
|
| 218 |
+
scroll-behavior: smooth;
|
| 219 |
+
-webkit-font-smoothing: antialiased;
|
| 220 |
+
}
|
| 221 |
|
| 222 |
body {
|
| 223 |
font-family: var(--font-sans);
|
|
|
|
| 227 |
min-height: 100vh;
|
| 228 |
}
|
| 229 |
|
| 230 |
+
#root {
|
| 231 |
+
min-height: 100vh;
|
| 232 |
+
display: flex;
|
| 233 |
+
flex-direction: column;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
a {
|
| 237 |
+
color: var(--accent);
|
| 238 |
+
text-decoration: none;
|
| 239 |
+
transition: color var(--transition-fast);
|
| 240 |
+
}
|
| 241 |
|
| 242 |
+
a:hover {
|
| 243 |
+
color: var(--accent-light);
|
| 244 |
+
}
|
| 245 |
|
| 246 |
/* ββ Typography ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 247 |
+
h1,
|
| 248 |
+
h2,
|
| 249 |
+
h3,
|
| 250 |
+
h4,
|
| 251 |
+
h5,
|
| 252 |
+
h6 {
|
| 253 |
font-weight: 600;
|
| 254 |
line-height: 1.25;
|
| 255 |
letter-spacing: -0.02em;
|
| 256 |
color: var(--text-primary);
|
| 257 |
}
|
| 258 |
+
|
| 259 |
+
h1 {
|
| 260 |
+
font-size: clamp(2rem, 4.5vw, 3.25rem);
|
| 261 |
+
font-family: var(--font-serif);
|
| 262 |
+
font-weight: 500;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
h2 {
|
| 266 |
+
font-size: clamp(1.5rem, 3vw, 2.25rem);
|
| 267 |
+
font-family: var(--font-serif);
|
| 268 |
+
font-weight: 500;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
h3 {
|
| 272 |
+
font-size: 1rem;
|
| 273 |
+
font-family: var(--font-sans);
|
| 274 |
+
font-weight: 600;
|
| 275 |
+
}
|
| 276 |
|
| 277 |
.text-gradient {
|
| 278 |
background: var(--gradient-accent);
|
|
|
|
| 280 |
-webkit-text-fill-color: transparent;
|
| 281 |
background-clip: text;
|
| 282 |
}
|
| 283 |
+
|
| 284 |
+
.text-serif {
|
| 285 |
+
font-family: var(--font-serif);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.text-accent {
|
| 289 |
+
color: var(--accent);
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.mono {
|
| 293 |
+
font-family: var(--font-mono);
|
| 294 |
+
font-size: 0.875em;
|
| 295 |
+
}
|
| 296 |
|
| 297 |
/* ββ Layout ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 298 |
.app-layout {
|
| 299 |
display: flex;
|
| 300 |
min-height: 100vh;
|
| 301 |
}
|
| 302 |
+
|
| 303 |
.app-main {
|
| 304 |
flex: 1;
|
| 305 |
min-width: 0;
|
| 306 |
margin-left: 68px;
|
| 307 |
transition: margin-left var(--transition-base);
|
| 308 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
+
.page {
|
| 311 |
+
flex: 1;
|
| 312 |
+
padding: 2.5rem 3rem;
|
| 313 |
+
max-width: 1320px;
|
| 314 |
+
margin: 0 auto;
|
| 315 |
+
width: 100%;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.page-header {
|
| 319 |
+
margin-bottom: 2rem;
|
| 320 |
+
padding-bottom: 1.5rem;
|
| 321 |
+
border-bottom: 1px solid var(--border-color);
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.page-header h1 {
|
| 325 |
+
margin-bottom: 0.25rem;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.page-header p {
|
| 329 |
+
color: var(--text-secondary);
|
| 330 |
+
font-size: 0.95rem;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
.grid-2 {
|
| 334 |
+
display: grid;
|
| 335 |
+
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
| 336 |
+
gap: 1.75rem;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.grid-3 {
|
| 340 |
+
display: grid;
|
| 341 |
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
| 342 |
+
gap: 1.5rem;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
.grid-4 {
|
| 346 |
+
display: grid;
|
| 347 |
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
| 348 |
+
gap: 1.25rem;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.flex-between {
|
| 352 |
+
display: flex;
|
| 353 |
+
justify-content: space-between;
|
| 354 |
+
align-items: center;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
.flex-gap {
|
| 358 |
+
display: flex;
|
| 359 |
+
gap: 0.75rem;
|
| 360 |
+
align-items: center;
|
| 361 |
+
}
|
| 362 |
|
| 363 |
/* ββ Sidebar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 364 |
.sidebar {
|
|
|
|
| 375 |
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
| 376 |
z-index: 200;
|
| 377 |
}
|
| 378 |
+
|
| 379 |
.sidebar.sidebar-expanded {
|
| 380 |
width: 260px;
|
| 381 |
}
|
|
|
|
| 389 |
display: flex;
|
| 390 |
align-items: center;
|
| 391 |
}
|
| 392 |
+
|
| 393 |
.sidebar-logo {
|
| 394 |
display: flex;
|
| 395 |
align-items: center;
|
|
|
|
| 398 |
color: var(--text-primary);
|
| 399 |
white-space: nowrap;
|
| 400 |
}
|
| 401 |
+
|
| 402 |
.sidebar-logo svg {
|
| 403 |
flex-shrink: 0;
|
| 404 |
}
|
| 405 |
+
|
| 406 |
.sidebar-brand-text {
|
| 407 |
font-family: var(--font-serif);
|
| 408 |
font-size: 1.2rem;
|
|
|
|
| 412 |
transform: translateX(-8px);
|
| 413 |
transition: opacity 0.2s ease 0.05s, transform 0.2s ease 0.05s;
|
| 414 |
}
|
| 415 |
+
|
| 416 |
.sidebar.sidebar-expanded .sidebar-brand-text {
|
| 417 |
opacity: 1;
|
| 418 |
transform: translateX(0);
|
| 419 |
}
|
| 420 |
+
|
| 421 |
.sidebar-brand-text em {
|
| 422 |
font-style: normal;
|
| 423 |
color: var(--accent);
|
|
|
|
| 430 |
overflow-x: hidden;
|
| 431 |
padding: 0.5rem 0;
|
| 432 |
}
|
| 433 |
+
|
| 434 |
+
.sidebar-nav::-webkit-scrollbar {
|
| 435 |
+
width: 0;
|
| 436 |
+
}
|
| 437 |
|
| 438 |
.sidebar-section {
|
| 439 |
padding: 0.5rem 0;
|
| 440 |
}
|
| 441 |
+
|
| 442 |
.sidebar-section-label {
|
| 443 |
padding: 0.375rem 1.4rem;
|
| 444 |
font-size: 0.6rem;
|
|
|
|
| 450 |
opacity: 0;
|
| 451 |
transition: opacity 0.2s ease 0.05s;
|
| 452 |
}
|
| 453 |
+
|
| 454 |
.sidebar.sidebar-expanded .sidebar-section-label {
|
| 455 |
opacity: 1;
|
| 456 |
}
|
|
|
|
| 475 |
text-align: left;
|
| 476 |
font-family: var(--font-sans);
|
| 477 |
}
|
| 478 |
+
|
| 479 |
.sidebar-link:hover {
|
| 480 |
background: var(--bg-hover);
|
| 481 |
color: var(--accent);
|
| 482 |
}
|
| 483 |
+
|
| 484 |
.sidebar-link.active {
|
| 485 |
background: var(--accent-lighter);
|
| 486 |
color: var(--accent);
|
|
|
|
| 495 |
align-items: center;
|
| 496 |
justify-content: center;
|
| 497 |
}
|
| 498 |
+
|
| 499 |
.sidebar-icon svg {
|
| 500 |
width: 18px;
|
| 501 |
height: 18px;
|
|
|
|
| 506 |
transform: translateX(-8px);
|
| 507 |
transition: opacity 0.2s ease 0.05s, transform 0.2s ease 0.05s;
|
| 508 |
}
|
| 509 |
+
|
| 510 |
.sidebar.sidebar-expanded .sidebar-label {
|
| 511 |
opacity: 1;
|
| 512 |
transform: translateX(0);
|
|
|
|
| 518 |
padding: 0.75rem;
|
| 519 |
flex-shrink: 0;
|
| 520 |
}
|
| 521 |
+
|
| 522 |
.sidebar-user {
|
| 523 |
display: flex;
|
| 524 |
align-items: center;
|
|
|
|
| 528 |
white-space: nowrap;
|
| 529 |
overflow: hidden;
|
| 530 |
}
|
| 531 |
+
|
| 532 |
.sidebar-avatar {
|
| 533 |
width: 32px;
|
| 534 |
height: 32px;
|
|
|
|
| 542 |
font-weight: 700;
|
| 543 |
flex-shrink: 0;
|
| 544 |
}
|
| 545 |
+
|
| 546 |
.sidebar-user-info {
|
| 547 |
opacity: 0;
|
| 548 |
transition: opacity 0.2s ease 0.05s;
|
| 549 |
}
|
| 550 |
+
|
| 551 |
.sidebar.sidebar-expanded .sidebar-user-info {
|
| 552 |
opacity: 1;
|
| 553 |
}
|
| 554 |
+
|
| 555 |
.sidebar-user-name {
|
| 556 |
font-size: 0.78rem;
|
| 557 |
font-weight: 600;
|
| 558 |
color: var(--text-primary);
|
| 559 |
}
|
| 560 |
+
|
| 561 |
.sidebar-user-email {
|
| 562 |
font-size: 0.65rem;
|
| 563 |
color: var(--text-muted);
|
| 564 |
}
|
| 565 |
+
|
| 566 |
.sidebar-logout {
|
| 567 |
color: var(--text-muted) !important;
|
| 568 |
}
|
| 569 |
+
|
| 570 |
.sidebar-logout:hover {
|
| 571 |
color: var(--red-negative) !important;
|
| 572 |
background: rgba(194, 48, 48, 0.05) !important;
|
| 573 |
}
|
| 574 |
+
|
| 575 |
.card {
|
| 576 |
background: var(--bg-card);
|
| 577 |
border: 1px solid var(--border-color);
|
|
|
|
| 580 |
box-shadow: var(--shadow-card);
|
| 581 |
transition: all var(--transition-base);
|
| 582 |
}
|
| 583 |
+
|
| 584 |
.card:hover {
|
| 585 |
box-shadow: var(--shadow-card-hover);
|
| 586 |
border-color: var(--border-accent);
|
| 587 |
}
|
| 588 |
+
|
| 589 |
.card-header {
|
| 590 |
display: flex;
|
| 591 |
justify-content: space-between;
|
|
|
|
| 594 |
padding-bottom: 0.875rem;
|
| 595 |
border-bottom: 1px solid var(--border-subtle);
|
| 596 |
}
|
| 597 |
+
|
| 598 |
+
.card-header h3 {
|
| 599 |
+
font-size: 0.95rem;
|
| 600 |
+
font-weight: 600;
|
| 601 |
+
color: var(--text-primary);
|
| 602 |
+
letter-spacing: 0;
|
| 603 |
+
}
|
| 604 |
|
| 605 |
/* ββ Buttons βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 606 |
.btn {
|
|
|
|
| 620 |
letter-spacing: 0.02em;
|
| 621 |
text-transform: uppercase;
|
| 622 |
}
|
| 623 |
+
|
| 624 |
+
.btn:active {
|
| 625 |
+
transform: scale(0.98);
|
| 626 |
+
}
|
| 627 |
|
| 628 |
.btn-primary {
|
| 629 |
background: var(--accent);
|
| 630 |
color: #fff;
|
| 631 |
}
|
| 632 |
+
|
| 633 |
+
.btn-primary:hover {
|
| 634 |
+
background: var(--accent-light);
|
| 635 |
+
}
|
| 636 |
|
| 637 |
.btn-secondary {
|
| 638 |
background: transparent;
|
| 639 |
color: var(--text-primary);
|
| 640 |
border: 1px solid var(--border-color);
|
| 641 |
}
|
| 642 |
+
|
| 643 |
.btn-secondary:hover {
|
| 644 |
background: var(--bg-hover);
|
| 645 |
border-color: var(--accent);
|
| 646 |
color: var(--accent);
|
| 647 |
}
|
| 648 |
|
| 649 |
+
.btn-sm {
|
| 650 |
+
padding: 0.375rem 0.875rem;
|
| 651 |
+
font-size: 0.75rem;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
.btn-lg {
|
| 655 |
+
padding: 0.875rem 2.25rem;
|
| 656 |
+
font-size: 0.875rem;
|
| 657 |
+
letter-spacing: 0.08em;
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
.btn-icon {
|
| 661 |
+
width: 36px;
|
| 662 |
+
height: 36px;
|
| 663 |
+
padding: 0;
|
| 664 |
+
border-radius: var(--radius-sm);
|
| 665 |
+
}
|
| 666 |
|
| 667 |
/* ββ Form Elements βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 668 |
+
.form-group {
|
| 669 |
+
margin-bottom: 1.25rem;
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
.form-group label {
|
| 673 |
display: block;
|
| 674 |
font-size: 0.75rem;
|
|
|
|
| 679 |
letter-spacing: 0.06em;
|
| 680 |
}
|
| 681 |
|
| 682 |
+
.input,
|
| 683 |
+
select,
|
| 684 |
+
textarea {
|
| 685 |
width: 100%;
|
| 686 |
padding: 0.625rem 0.875rem;
|
| 687 |
background: var(--bg-primary);
|
|
|
|
| 693 |
transition: all var(--transition-fast);
|
| 694 |
outline: none;
|
| 695 |
}
|
| 696 |
+
|
| 697 |
+
.input:focus,
|
| 698 |
+
select:focus,
|
| 699 |
+
textarea:focus {
|
| 700 |
border-color: var(--accent);
|
| 701 |
box-shadow: 0 0 0 3px rgba(0, 82, 65, 0.1);
|
| 702 |
}
|
| 703 |
+
|
| 704 |
+
.input::placeholder {
|
| 705 |
+
color: var(--text-muted);
|
| 706 |
+
}
|
| 707 |
|
| 708 |
/* ββ Table βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 709 |
.table-container {
|
|
|
|
| 711 |
border-radius: var(--radius-md);
|
| 712 |
border: 1px solid var(--border-color);
|
| 713 |
}
|
| 714 |
+
|
| 715 |
+
table {
|
| 716 |
+
width: 100%;
|
| 717 |
+
border-collapse: collapse;
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
th {
|
| 721 |
padding: 0.75rem 1rem;
|
| 722 |
text-align: left;
|
|
|
|
| 728 |
background: var(--bg-secondary);
|
| 729 |
border-bottom: 2px solid var(--border-color);
|
| 730 |
}
|
| 731 |
+
|
| 732 |
td {
|
| 733 |
padding: 0.75rem 1rem;
|
| 734 |
font-size: 0.85rem;
|
|
|
|
| 736 |
font-family: var(--font-mono);
|
| 737 |
font-size: 0.8rem;
|
| 738 |
}
|
| 739 |
+
|
| 740 |
+
tr:hover td {
|
| 741 |
+
background: var(--bg-hover);
|
| 742 |
+
}
|
| 743 |
|
| 744 |
/* ββ Metrics βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 745 |
+
.metric {
|
| 746 |
+
text-align: center;
|
| 747 |
+
padding: 1.25rem 1rem;
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
.metric-value {
|
| 751 |
font-size: 1.75rem;
|
| 752 |
font-weight: 700;
|
| 753 |
font-family: var(--font-mono);
|
| 754 |
letter-spacing: -0.03em;
|
| 755 |
}
|
| 756 |
+
|
| 757 |
.metric-label {
|
| 758 |
font-size: 0.65rem;
|
| 759 |
text-transform: uppercase;
|
|
|
|
| 762 |
margin-top: 0.25rem;
|
| 763 |
font-weight: 600;
|
| 764 |
}
|
| 765 |
+
|
| 766 |
+
.positive {
|
| 767 |
+
color: var(--green-positive);
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
.negative {
|
| 771 |
+
color: var(--red-negative);
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
.neutral {
|
| 775 |
+
color: var(--amber-neutral);
|
| 776 |
+
}
|
| 777 |
|
| 778 |
/* ββ Badge βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 779 |
.badge {
|
|
|
|
| 786 |
letter-spacing: 0.06em;
|
| 787 |
text-transform: uppercase;
|
| 788 |
}
|
| 789 |
+
|
| 790 |
+
.badge-primary {
|
| 791 |
+
background: var(--accent-lighter);
|
| 792 |
+
color: var(--accent);
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
.badge-emerald {
|
| 796 |
+
background: #e6f9f0;
|
| 797 |
+
color: var(--green-positive);
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
.badge-rose {
|
| 801 |
+
background: var(--error-bg);
|
| 802 |
+
color: var(--red-negative);
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
.badge-amber {
|
| 806 |
+
background: #fffbeb;
|
| 807 |
+
color: var(--amber-neutral);
|
| 808 |
+
}
|
| 809 |
|
| 810 |
/* ββ Tabs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 811 |
.tabs {
|
|
|
|
| 814 |
border-bottom: 2px solid var(--border-color);
|
| 815 |
margin-bottom: 1.75rem;
|
| 816 |
}
|
| 817 |
+
|
| 818 |
.tab {
|
| 819 |
padding: 0.75rem 1.25rem;
|
| 820 |
font-size: 0.8rem;
|
|
|
|
| 829 |
text-transform: uppercase;
|
| 830 |
letter-spacing: 0.06em;
|
| 831 |
}
|
| 832 |
+
|
| 833 |
+
.tab:hover {
|
| 834 |
+
color: var(--text-primary);
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
.tab.active {
|
| 838 |
color: var(--accent);
|
| 839 |
border-bottom-color: var(--accent);
|
|
|
|
| 848 |
border-radius: 50%;
|
| 849 |
animation: spin 0.8s linear infinite;
|
| 850 |
}
|
| 851 |
+
|
| 852 |
+
@keyframes spin {
|
| 853 |
+
to {
|
| 854 |
+
transform: rotate(360deg);
|
| 855 |
+
}
|
| 856 |
+
}
|
| 857 |
|
| 858 |
.loading-overlay {
|
| 859 |
display: flex;
|
|
|
|
| 867 |
|
| 868 |
/* ββ Animations ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 869 |
@keyframes fadeInUp {
|
| 870 |
+
from {
|
| 871 |
+
opacity: 0;
|
| 872 |
+
transform: translateY(24px);
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
to {
|
| 876 |
+
opacity: 1;
|
| 877 |
+
transform: translateY(0);
|
| 878 |
+
}
|
| 879 |
}
|
| 880 |
+
|
| 881 |
@keyframes fadeIn {
|
| 882 |
+
from {
|
| 883 |
+
opacity: 0;
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
to {
|
| 887 |
+
opacity: 1;
|
| 888 |
+
}
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
.animate-fade-in {
|
| 892 |
+
animation: fadeIn 0.6s ease-out;
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
.animate-fade-in-up {
|
| 896 |
+
animation: fadeInUp 0.7s ease-out;
|
| 897 |
}
|
|
|
|
|
|
|
| 898 |
|
| 899 |
/* ββ Chart βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 900 |
.chart-container {
|
|
|
|
| 914 |
text-align: center;
|
| 915 |
color: var(--text-muted);
|
| 916 |
}
|
| 917 |
+
|
| 918 |
+
.empty-state h3 {
|
| 919 |
+
color: var(--text-secondary);
|
| 920 |
+
margin-bottom: 0.5rem;
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
.empty-state p {
|
| 924 |
+
max-width: 400px;
|
| 925 |
+
font-size: 0.9rem;
|
| 926 |
+
line-height: 1.7;
|
| 927 |
+
}
|
| 928 |
|
| 929 |
/* ββ Divider βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 930 |
+
.divider {
|
| 931 |
+
width: 60px;
|
| 932 |
+
height: 2px;
|
| 933 |
+
background: var(--accent);
|
| 934 |
+
margin: 1rem 0;
|
| 935 |
+
}
|
| 936 |
|
| 937 |
/* ββ Professional Icon Container βββββββββββββββββββββββββββββββββββββββ */
|
| 938 |
.icon-box {
|
|
|
|
| 946 |
color: var(--accent);
|
| 947 |
flex-shrink: 0;
|
| 948 |
}
|
| 949 |
+
|
| 950 |
+
.icon-box svg {
|
| 951 |
+
width: 22px;
|
| 952 |
+
height: 22px;
|
| 953 |
+
}
|
| 954 |
|
| 955 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 956 |
MOBILE RESPONSIVE β max-width: 768px
|
|
|
|
| 962 |
/* ββ App Shell βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 963 |
.app-main {
|
| 964 |
margin-left: 0 !important;
|
| 965 |
+
padding-bottom: 72px;
|
| 966 |
+
/* space for bottom tab bar */
|
| 967 |
}
|
| 968 |
|
| 969 |
/* ββ Desktop Sidebar hidden on mobile ββββββββββββββββββββββββββββββββ */
|
|
|
|
| 985 |
padding: 0 0.25rem;
|
| 986 |
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
|
| 987 |
}
|
| 988 |
+
|
| 989 |
.mobile-nav-items {
|
| 990 |
display: flex;
|
| 991 |
justify-content: space-around;
|
|
|
|
| 993 |
width: 100%;
|
| 994 |
height: 100%;
|
| 995 |
}
|
| 996 |
+
|
| 997 |
.mobile-nav-item {
|
| 998 |
display: flex;
|
| 999 |
flex-direction: column;
|
|
|
|
| 1013 |
cursor: pointer;
|
| 1014 |
font-family: var(--font-sans);
|
| 1015 |
}
|
| 1016 |
+
|
| 1017 |
.mobile-nav-item svg {
|
| 1018 |
width: 20px;
|
| 1019 |
height: 20px;
|
| 1020 |
}
|
| 1021 |
+
|
| 1022 |
.mobile-nav-item.active,
|
| 1023 |
.mobile-nav-item:hover {
|
| 1024 |
color: var(--accent);
|
|
|
|
| 1032 |
z-index: 400;
|
| 1033 |
animation: fadeIn 0.2s ease;
|
| 1034 |
}
|
| 1035 |
+
|
| 1036 |
.mobile-drawer {
|
| 1037 |
position: fixed;
|
| 1038 |
bottom: 0;
|
|
|
|
| 1046 |
overflow-y: auto;
|
| 1047 |
animation: slideUp 0.25s ease;
|
| 1048 |
}
|
| 1049 |
+
|
| 1050 |
@keyframes slideUp {
|
| 1051 |
+
from {
|
| 1052 |
+
transform: translateY(100%);
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
+
to {
|
| 1056 |
+
transform: translateY(0);
|
| 1057 |
+
}
|
| 1058 |
}
|
| 1059 |
+
|
| 1060 |
.mobile-drawer-handle {
|
| 1061 |
width: 36px;
|
| 1062 |
height: 4px;
|
|
|
|
| 1064 |
border-radius: 2px;
|
| 1065 |
margin: 0 auto 1rem;
|
| 1066 |
}
|
| 1067 |
+
|
| 1068 |
.mobile-drawer .sidebar-link {
|
| 1069 |
width: 100%;
|
| 1070 |
padding: 0.75rem 1rem;
|
|
|
|
| 1073 |
border-radius: var(--radius-md);
|
| 1074 |
opacity: 1;
|
| 1075 |
}
|
| 1076 |
+
|
| 1077 |
.mobile-drawer .sidebar-link .sidebar-label {
|
| 1078 |
opacity: 1;
|
| 1079 |
transform: none;
|
| 1080 |
}
|
| 1081 |
+
|
| 1082 |
.mobile-drawer .sidebar-link .sidebar-icon {
|
| 1083 |
width: 22px;
|
| 1084 |
height: 22px;
|
| 1085 |
}
|
| 1086 |
+
|
| 1087 |
.mobile-drawer .sidebar-section-label {
|
| 1088 |
opacity: 1;
|
| 1089 |
padding: 0.5rem 1rem 0.25rem;
|
|
|
|
| 1095 |
padding: 1.25rem 1rem !important;
|
| 1096 |
max-width: 100%;
|
| 1097 |
}
|
| 1098 |
+
|
| 1099 |
.page-header {
|
| 1100 |
margin-bottom: 1.25rem;
|
| 1101 |
padding-bottom: 1rem;
|
| 1102 |
}
|
| 1103 |
+
|
| 1104 |
.page-header h1 {
|
| 1105 |
font-size: 1.5rem !important;
|
| 1106 |
}
|
| 1107 |
+
|
| 1108 |
.page-header p {
|
| 1109 |
font-size: 0.85rem;
|
| 1110 |
}
|
|
|
|
| 1122 |
padding: 1.125rem !important;
|
| 1123 |
border-radius: var(--radius-md);
|
| 1124 |
}
|
| 1125 |
+
|
| 1126 |
.card-header {
|
| 1127 |
margin-bottom: 1rem;
|
| 1128 |
padding-bottom: 0.75rem;
|
| 1129 |
flex-wrap: wrap;
|
| 1130 |
gap: 0.5rem;
|
| 1131 |
}
|
| 1132 |
+
|
| 1133 |
.card-header h3 {
|
| 1134 |
font-size: 0.88rem;
|
| 1135 |
}
|
|
|
|
| 1142 |
border-right: none;
|
| 1143 |
-webkit-overflow-scrolling: touch;
|
| 1144 |
}
|
| 1145 |
+
|
| 1146 |
+
th,
|
| 1147 |
+
td {
|
| 1148 |
padding: 0.5rem 0.625rem;
|
| 1149 |
font-size: 0.72rem;
|
| 1150 |
white-space: nowrap;
|
|
|
|
| 1157 |
}
|
| 1158 |
|
| 1159 |
/* ββ Metrics βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1160 |
+
.metric {
|
| 1161 |
+
padding: 0.875rem 0.75rem;
|
| 1162 |
+
}
|
| 1163 |
+
|
| 1164 |
+
.metric-value {
|
| 1165 |
+
font-size: 1.375rem;
|
| 1166 |
+
}
|
| 1167 |
+
|
| 1168 |
+
.metric-label {
|
| 1169 |
+
font-size: 0.6rem;
|
| 1170 |
+
}
|
| 1171 |
|
| 1172 |
/* ββ Tabs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1173 |
.tabs {
|
|
|
|
| 1178 |
margin-bottom: 1.25rem;
|
| 1179 |
scrollbar-width: none;
|
| 1180 |
}
|
| 1181 |
+
|
| 1182 |
+
.tabs::-webkit-scrollbar {
|
| 1183 |
+
display: none;
|
| 1184 |
+
}
|
| 1185 |
+
|
| 1186 |
.tab {
|
| 1187 |
padding: 0.625rem 0.875rem;
|
| 1188 |
font-size: 0.7rem;
|
|
|
|
| 1196 |
}
|
| 1197 |
|
| 1198 |
/* ββ Forms βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1199 |
+
.input,
|
| 1200 |
+
select,
|
| 1201 |
+
textarea {
|
| 1202 |
+
font-size: 16px;
|
| 1203 |
+
/* prevents iOS zoom on focus */
|
| 1204 |
}
|
| 1205 |
+
|
| 1206 |
.form-group label {
|
| 1207 |
font-size: 0.7rem;
|
| 1208 |
}
|
|
|
|
| 1234 |
width: 40px;
|
| 1235 |
height: 40px;
|
| 1236 |
}
|
| 1237 |
+
|
| 1238 |
+
.icon-box svg {
|
| 1239 |
+
width: 18px;
|
| 1240 |
+
height: 18px;
|
| 1241 |
+
}
|
| 1242 |
|
| 1243 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1244 |
PAGE-SPECIFIC MOBILE OVERRIDES
|
|
|
|
| 1248 |
.landing-header {
|
| 1249 |
padding: 0.875rem 1rem !important;
|
| 1250 |
}
|
| 1251 |
+
|
| 1252 |
.landing-header .brand-text {
|
| 1253 |
font-size: 1.1rem;
|
| 1254 |
}
|
| 1255 |
+
|
| 1256 |
.landing-hero {
|
| 1257 |
min-height: 80vh !important;
|
| 1258 |
padding: 1rem !important;
|
| 1259 |
}
|
| 1260 |
+
|
| 1261 |
.landing-hero h1 {
|
| 1262 |
font-size: clamp(1.75rem, 7vw, 2.5rem) !important;
|
| 1263 |
line-height: 1.2 !important;
|
| 1264 |
}
|
| 1265 |
+
|
| 1266 |
.landing-hero p {
|
| 1267 |
font-size: 0.9rem !important;
|
| 1268 |
}
|
| 1269 |
+
|
| 1270 |
.landing-hero .flex-gap {
|
| 1271 |
flex-direction: column;
|
| 1272 |
width: 100%;
|
| 1273 |
}
|
| 1274 |
+
|
| 1275 |
.landing-hero .flex-gap .btn {
|
| 1276 |
width: 100%;
|
| 1277 |
}
|
| 1278 |
+
|
| 1279 |
.landing-section {
|
| 1280 |
padding: 3rem 1.25rem !important;
|
| 1281 |
}
|
| 1282 |
+
|
| 1283 |
.landing-footer {
|
| 1284 |
flex-direction: column !important;
|
| 1285 |
gap: 0.5rem;
|
|
|
|
| 1291 |
.auth-container {
|
| 1292 |
flex-direction: column !important;
|
| 1293 |
}
|
| 1294 |
+
|
| 1295 |
.auth-card {
|
| 1296 |
padding: 2rem 1.25rem !important;
|
| 1297 |
min-height: 100vh;
|
| 1298 |
}
|
| 1299 |
+
|
| 1300 |
.auth-image {
|
| 1301 |
display: none !important;
|
| 1302 |
}
|
|
|
|
| 1305 |
.dashboard-ticker-strip {
|
| 1306 |
gap: 0.375rem !important;
|
| 1307 |
}
|
| 1308 |
+
|
| 1309 |
.dashboard-ticker-strip button {
|
| 1310 |
min-width: 120px !important;
|
| 1311 |
padding: 0.5rem 0.625rem !important;
|
|
|
|
| 1316 |
flex-direction: column !important;
|
| 1317 |
gap: 0.75rem !important;
|
| 1318 |
}
|
| 1319 |
+
|
| 1320 |
.market-search-bar .input {
|
| 1321 |
width: 100% !important;
|
| 1322 |
}
|
| 1323 |
+
|
| 1324 |
.market-search-bar select {
|
| 1325 |
width: 100% !important;
|
| 1326 |
}
|
| 1327 |
+
|
| 1328 |
.market-tabs {
|
| 1329 |
overflow-x: auto !important;
|
| 1330 |
flex-wrap: nowrap !important;
|
| 1331 |
}
|
| 1332 |
+
|
| 1333 |
.ticker-chips {
|
| 1334 |
flex-wrap: wrap !important;
|
| 1335 |
gap: 0.375rem !important;
|
| 1336 |
}
|
| 1337 |
+
|
| 1338 |
.company-info-grid {
|
| 1339 |
grid-template-columns: 1fr 1fr !important;
|
| 1340 |
gap: 0.625rem !important;
|
|
|
|
| 1345 |
flex-direction: column !important;
|
| 1346 |
gap: 0.75rem !important;
|
| 1347 |
}
|
| 1348 |
+
|
| 1349 |
.factor-input-bar .input {
|
| 1350 |
width: 100% !important;
|
| 1351 |
}
|
|
|
|
| 1360 |
flex-direction: column !important;
|
| 1361 |
gap: 0.5rem !important;
|
| 1362 |
}
|
| 1363 |
+
|
| 1364 |
.holdings-actions .btn {
|
| 1365 |
width: 100%;
|
| 1366 |
}
|
|
|
|
| 1380 |
.recharts-wrapper {
|
| 1381 |
font-size: 0.7rem;
|
| 1382 |
}
|
| 1383 |
+
|
| 1384 |
.recharts-cartesian-axis-tick-value {
|
| 1385 |
font-size: 0.65rem;
|
| 1386 |
}
|
|
|
|
| 1390 |
[style*="grid-template-columns: repeat"] {
|
| 1391 |
grid-template-columns: 1fr !important;
|
| 1392 |
}
|
| 1393 |
+
|
| 1394 |
[style*="gridTemplateColumns"] {
|
| 1395 |
grid-template-columns: 1fr !important;
|
| 1396 |
}
|
| 1397 |
+
|
| 1398 |
[style*="padding: 6rem 3rem"],
|
| 1399 |
[style*="padding:'6rem 3rem'"] {
|
| 1400 |
padding: 3rem 1.25rem !important;
|
|
|
|
| 1407 |
}
|
| 1408 |
|
| 1409 |
/* ββ Scrollbar βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1410 |
+
::-webkit-scrollbar {
|
| 1411 |
+
width: 6px;
|
| 1412 |
+
height: 6px;
|
| 1413 |
+
}
|
| 1414 |
|
| 1415 |
+
::-webkit-scrollbar-track {
|
| 1416 |
+
background: var(--bg-secondary);
|
| 1417 |
+
}
|
| 1418 |
+
|
| 1419 |
+
::-webkit-scrollbar-thumb {
|
| 1420 |
+
background: var(--border-color);
|
| 1421 |
+
border-radius: 3px;
|
| 1422 |
+
}
|
| 1423 |
+
|
| 1424 |
+
::-webkit-scrollbar-thumb:hover {
|
| 1425 |
+
background: var(--accent);
|
| 1426 |
+
}
|
| 1427 |
+
|
| 1428 |
+
::selection {
|
| 1429 |
+
background: rgba(0, 82, 65, 0.15);
|
| 1430 |
+
color: var(--text-primary);
|
| 1431 |
+
}
|
| 1432 |
+
|
| 1433 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1434 |
+
Shared Base Styles β page-container, loading-spinner, data-table
|
| 1435 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1436 |
+
.page-container {
|
| 1437 |
+
flex: 1;
|
| 1438 |
+
padding: 2.5rem 3rem;
|
| 1439 |
+
max-width: 1320px;
|
| 1440 |
+
margin: 0 auto;
|
| 1441 |
+
width: 100%;
|
| 1442 |
+
}
|
| 1443 |
+
|
| 1444 |
+
.page-subtitle {
|
| 1445 |
+
color: var(--text-secondary);
|
| 1446 |
+
font-size: 0.95rem;
|
| 1447 |
+
margin-top: 0.25rem;
|
| 1448 |
+
}
|
| 1449 |
+
|
| 1450 |
+
.loading-spinner {
|
| 1451 |
+
width: 32px;
|
| 1452 |
+
height: 32px;
|
| 1453 |
+
border: 3px solid var(--border-color);
|
| 1454 |
+
border-top-color: var(--accent);
|
| 1455 |
+
border-radius: 50%;
|
| 1456 |
+
animation: spin 0.8s linear infinite;
|
| 1457 |
+
margin: 3rem auto;
|
| 1458 |
+
}
|
| 1459 |
+
|
| 1460 |
+
.alert-card {
|
| 1461 |
+
padding: 1.5rem;
|
| 1462 |
+
border-radius: var(--radius-lg);
|
| 1463 |
+
background: var(--bg-secondary);
|
| 1464 |
+
color: var(--text-secondary);
|
| 1465 |
+
text-align: center;
|
| 1466 |
+
border: 1px solid var(--border-color);
|
| 1467 |
+
}
|
| 1468 |
+
|
| 1469 |
+
.alert-card.error {
|
| 1470 |
+
background: var(--error-bg);
|
| 1471 |
+
border-color: var(--error-border);
|
| 1472 |
+
color: var(--red-negative);
|
| 1473 |
+
}
|
| 1474 |
+
|
| 1475 |
+
.data-table {
|
| 1476 |
+
width: 100%;
|
| 1477 |
+
border-collapse: collapse;
|
| 1478 |
+
}
|
| 1479 |
+
|
| 1480 |
+
.data-table th {
|
| 1481 |
+
padding: 0.625rem 1rem;
|
| 1482 |
+
text-align: left;
|
| 1483 |
+
font-size: 0.7rem;
|
| 1484 |
+
font-weight: 700;
|
| 1485 |
+
color: var(--text-muted);
|
| 1486 |
+
text-transform: uppercase;
|
| 1487 |
+
letter-spacing: 0.08em;
|
| 1488 |
+
background: var(--bg-secondary);
|
| 1489 |
+
border-bottom: 2px solid var(--border-color);
|
| 1490 |
+
}
|
| 1491 |
+
|
| 1492 |
+
.data-table td {
|
| 1493 |
+
padding: 0.625rem 1rem;
|
| 1494 |
+
font-size: 0.82rem;
|
| 1495 |
+
border-bottom: 1px solid var(--border-subtle);
|
| 1496 |
+
font-family: var(--font-mono);
|
| 1497 |
+
font-size: 0.78rem;
|
| 1498 |
+
}
|
| 1499 |
+
|
| 1500 |
+
.data-table tr:hover td {
|
| 1501 |
+
background: var(--bg-hover);
|
| 1502 |
+
}
|
| 1503 |
+
|
| 1504 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1505 |
+
Portfolio Health Score Page
|
| 1506 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1507 |
+
.health-top-row {
|
| 1508 |
+
display: grid;
|
| 1509 |
+
grid-template-columns: 260px 1fr;
|
| 1510 |
+
gap: 1.5rem;
|
| 1511 |
+
margin-top: 1.5rem;
|
| 1512 |
+
}
|
| 1513 |
+
|
| 1514 |
+
.health-gauge-card {
|
| 1515 |
+
display: flex;
|
| 1516 |
+
flex-direction: column;
|
| 1517 |
+
align-items: center;
|
| 1518 |
+
justify-content: center;
|
| 1519 |
+
}
|
| 1520 |
+
|
| 1521 |
+
.health-gauge {
|
| 1522 |
+
position: relative;
|
| 1523 |
+
width: 160px;
|
| 1524 |
+
height: 160px;
|
| 1525 |
+
}
|
| 1526 |
+
|
| 1527 |
+
.gauge-svg {
|
| 1528 |
+
width: 100%;
|
| 1529 |
+
height: 100%;
|
| 1530 |
+
transform: rotate(-90deg);
|
| 1531 |
+
}
|
| 1532 |
+
|
| 1533 |
+
.gauge-center {
|
| 1534 |
+
position: absolute;
|
| 1535 |
+
inset: 0;
|
| 1536 |
+
display: flex;
|
| 1537 |
+
flex-direction: column;
|
| 1538 |
+
align-items: center;
|
| 1539 |
+
justify-content: center;
|
| 1540 |
+
}
|
| 1541 |
+
|
| 1542 |
+
.gauge-score {
|
| 1543 |
+
font-size: 2.5rem;
|
| 1544 |
+
font-weight: 700;
|
| 1545 |
+
font-family: var(--font-mono);
|
| 1546 |
+
line-height: 1;
|
| 1547 |
+
}
|
| 1548 |
+
|
| 1549 |
+
.gauge-grade {
|
| 1550 |
+
font-size: 1.25rem;
|
| 1551 |
+
font-weight: 700;
|
| 1552 |
+
margin-top: 0.25rem;
|
| 1553 |
+
}
|
| 1554 |
+
|
| 1555 |
+
.gauge-meta {
|
| 1556 |
+
display: flex;
|
| 1557 |
+
align-items: center;
|
| 1558 |
+
gap: 0;
|
| 1559 |
+
margin-top: 1.25rem;
|
| 1560 |
+
}
|
| 1561 |
+
|
| 1562 |
+
.gauge-meta-item {
|
| 1563 |
+
display: flex;
|
| 1564 |
+
flex-direction: column;
|
| 1565 |
+
align-items: center;
|
| 1566 |
+
padding: 0 1rem;
|
| 1567 |
+
}
|
| 1568 |
+
|
| 1569 |
+
.gauge-meta-value {
|
| 1570 |
+
font-family: var(--font-mono);
|
| 1571 |
+
font-weight: 700;
|
| 1572 |
+
font-size: 0.88rem;
|
| 1573 |
+
}
|
| 1574 |
+
|
| 1575 |
+
.gauge-meta-label {
|
| 1576 |
+
font-size: 0.65rem;
|
| 1577 |
+
text-transform: uppercase;
|
| 1578 |
+
letter-spacing: 0.08em;
|
| 1579 |
+
color: var(--text-muted);
|
| 1580 |
+
margin-top: 0.125rem;
|
| 1581 |
+
}
|
| 1582 |
+
|
| 1583 |
+
.gauge-meta-divider {
|
| 1584 |
+
width: 1px;
|
| 1585 |
+
height: 28px;
|
| 1586 |
+
background: var(--border-color);
|
| 1587 |
+
}
|
| 1588 |
+
|
| 1589 |
+
.health-chart-card {
|
| 1590 |
+
overflow: hidden;
|
| 1591 |
+
}
|
| 1592 |
+
|
| 1593 |
+
.health-bar-chart {
|
| 1594 |
+
display: flex;
|
| 1595 |
+
flex-direction: column;
|
| 1596 |
+
gap: 0.625rem;
|
| 1597 |
+
}
|
| 1598 |
+
|
| 1599 |
+
.hbar-row {
|
| 1600 |
+
display: flex;
|
| 1601 |
+
flex-direction: column;
|
| 1602 |
+
gap: 0.2rem;
|
| 1603 |
+
}
|
| 1604 |
+
|
| 1605 |
+
.hbar-label {
|
| 1606 |
+
display: flex;
|
| 1607 |
+
align-items: center;
|
| 1608 |
+
gap: 0.5rem;
|
| 1609 |
+
}
|
| 1610 |
+
|
| 1611 |
+
.hbar-name {
|
| 1612 |
+
font-size: 0.78rem;
|
| 1613 |
+
font-weight: 600;
|
| 1614 |
+
color: var(--text-primary);
|
| 1615 |
+
}
|
| 1616 |
+
|
| 1617 |
+
.hbar-grade-pill {
|
| 1618 |
+
font-size: 0.6rem;
|
| 1619 |
+
font-weight: 700;
|
| 1620 |
+
padding: 0.1rem 0.4rem;
|
| 1621 |
+
border-radius: 3px;
|
| 1622 |
+
}
|
| 1623 |
+
|
| 1624 |
+
.hbar-track-container {
|
| 1625 |
+
display: flex;
|
| 1626 |
+
align-items: center;
|
| 1627 |
+
gap: 0.75rem;
|
| 1628 |
+
}
|
| 1629 |
+
|
| 1630 |
+
.hbar-track {
|
| 1631 |
+
flex: 1;
|
| 1632 |
+
height: 8px;
|
| 1633 |
+
background: var(--bg-tertiary);
|
| 1634 |
+
border-radius: 4px;
|
| 1635 |
+
overflow: hidden;
|
| 1636 |
+
}
|
| 1637 |
+
|
| 1638 |
+
.hbar-fill {
|
| 1639 |
+
height: 100%;
|
| 1640 |
+
border-radius: 4px;
|
| 1641 |
+
transition: width 0.8s ease;
|
| 1642 |
+
}
|
| 1643 |
+
|
| 1644 |
+
.hbar-value {
|
| 1645 |
+
font-family: var(--font-mono);
|
| 1646 |
+
font-size: 0.75rem;
|
| 1647 |
+
color: var(--text-muted);
|
| 1648 |
+
min-width: 24px;
|
| 1649 |
+
text-align: right;
|
| 1650 |
+
}
|
| 1651 |
+
|
| 1652 |
+
.health-bottom-row {
|
| 1653 |
+
display: grid;
|
| 1654 |
+
grid-template-columns: 1fr 1.5fr;
|
| 1655 |
+
gap: 1.5rem;
|
| 1656 |
+
margin-top: 1.5rem;
|
| 1657 |
+
}
|
| 1658 |
+
|
| 1659 |
+
.health-radar-card {
|
| 1660 |
+
display: flex;
|
| 1661 |
+
flex-direction: column;
|
| 1662 |
+
}
|
| 1663 |
+
|
| 1664 |
+
.radar-wrapper {
|
| 1665 |
+
width: 100%;
|
| 1666 |
+
max-width: 300px;
|
| 1667 |
+
margin: 0 auto;
|
| 1668 |
+
}
|
| 1669 |
+
|
| 1670 |
+
.radar-svg {
|
| 1671 |
+
width: 100%;
|
| 1672 |
+
}
|
| 1673 |
+
|
| 1674 |
+
.health-detail-card {
|
| 1675 |
+
overflow-x: auto;
|
| 1676 |
+
}
|
| 1677 |
+
|
| 1678 |
+
.health-tips-section {
|
| 1679 |
+
margin-top: 1.5rem;
|
| 1680 |
+
}
|
| 1681 |
+
|
| 1682 |
+
.health-tips-grid {
|
| 1683 |
+
display: grid;
|
| 1684 |
+
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
| 1685 |
+
gap: 0.75rem;
|
| 1686 |
+
}
|
| 1687 |
+
|
| 1688 |
+
.health-tip-item {
|
| 1689 |
+
display: flex;
|
| 1690 |
+
align-items: flex-start;
|
| 1691 |
+
gap: 0.625rem;
|
| 1692 |
+
padding: 0.75rem 1rem;
|
| 1693 |
+
background: var(--bg-secondary);
|
| 1694 |
+
border-radius: var(--radius-md);
|
| 1695 |
+
border-left: 3px solid var(--accent);
|
| 1696 |
+
font-size: 0.85rem;
|
| 1697 |
+
line-height: 1.6;
|
| 1698 |
+
}
|
| 1699 |
+
|
| 1700 |
+
.health-tip-icon {
|
| 1701 |
+
color: var(--accent);
|
| 1702 |
+
flex-shrink: 0;
|
| 1703 |
+
margin-top: 0.125rem;
|
| 1704 |
+
}
|
| 1705 |
+
|
| 1706 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1707 |
+
Behavioral Bias Detector Page
|
| 1708 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1709 |
+
.bias-summary-row {
|
| 1710 |
+
display: grid;
|
| 1711 |
+
grid-template-columns: 1fr auto auto auto;
|
| 1712 |
+
gap: 1rem;
|
| 1713 |
+
margin: 1.5rem 0;
|
| 1714 |
+
}
|
| 1715 |
+
|
| 1716 |
+
.bias-score-card {
|
| 1717 |
+
display: flex;
|
| 1718 |
+
flex-direction: column;
|
| 1719 |
+
gap: 0.75rem;
|
| 1720 |
+
}
|
| 1721 |
+
|
| 1722 |
+
.bias-score-header {
|
| 1723 |
+
display: flex;
|
| 1724 |
+
justify-content: space-between;
|
| 1725 |
+
align-items: baseline;
|
| 1726 |
+
}
|
| 1727 |
+
|
| 1728 |
+
.bias-score-label {
|
| 1729 |
+
font-size: 0.75rem;
|
| 1730 |
+
text-transform: uppercase;
|
| 1731 |
+
letter-spacing: 0.08em;
|
| 1732 |
+
color: var(--text-muted);
|
| 1733 |
+
font-weight: 600;
|
| 1734 |
+
}
|
| 1735 |
+
|
| 1736 |
+
.bias-score-value {
|
| 1737 |
+
font-size: 2rem;
|
| 1738 |
+
font-weight: 700;
|
| 1739 |
+
font-family: var(--font-mono);
|
| 1740 |
+
}
|
| 1741 |
+
|
| 1742 |
+
.bias-score-bar-track {
|
| 1743 |
+
height: 6px;
|
| 1744 |
+
background: var(--bg-tertiary);
|
| 1745 |
+
border-radius: 3px;
|
| 1746 |
+
overflow: hidden;
|
| 1747 |
+
}
|
| 1748 |
+
|
| 1749 |
+
.bias-score-bar-fill {
|
| 1750 |
+
height: 100%;
|
| 1751 |
+
border-radius: 3px;
|
| 1752 |
+
transition: width 0.8s ease;
|
| 1753 |
+
}
|
| 1754 |
+
|
| 1755 |
+
.bias-score-grade {
|
| 1756 |
+
font-size: 0.78rem;
|
| 1757 |
+
font-weight: 600;
|
| 1758 |
+
margin-top: 0.25rem;
|
| 1759 |
+
}
|
| 1760 |
+
|
| 1761 |
+
.bias-stat-card {
|
| 1762 |
+
display: flex;
|
| 1763 |
+
flex-direction: column;
|
| 1764 |
+
align-items: center;
|
| 1765 |
+
justify-content: center;
|
| 1766 |
+
gap: 0.375rem;
|
| 1767 |
+
min-width: 90px;
|
| 1768 |
+
}
|
| 1769 |
+
|
| 1770 |
+
.bias-stat-value {
|
| 1771 |
+
font-family: var(--font-mono);
|
| 1772 |
+
font-size: 1.5rem;
|
| 1773 |
+
font-weight: 700;
|
| 1774 |
+
}
|
| 1775 |
+
|
| 1776 |
+
.bias-stat-label {
|
| 1777 |
+
font-size: 0.65rem;
|
| 1778 |
+
text-transform: uppercase;
|
| 1779 |
+
letter-spacing: 0.08em;
|
| 1780 |
+
color: var(--text-muted);
|
| 1781 |
+
font-weight: 600;
|
| 1782 |
+
}
|
| 1783 |
+
|
| 1784 |
+
.bias-stacked-chart {
|
| 1785 |
+
margin-bottom: 1.5rem;
|
| 1786 |
+
}
|
| 1787 |
+
|
| 1788 |
+
.bias-chart-bars {
|
| 1789 |
+
display: flex;
|
| 1790 |
+
flex-direction: column;
|
| 1791 |
+
gap: 0.5rem;
|
| 1792 |
+
}
|
| 1793 |
+
|
| 1794 |
+
.bias-chart-row {
|
| 1795 |
+
display: flex;
|
| 1796 |
+
align-items: center;
|
| 1797 |
+
gap: 0.75rem;
|
| 1798 |
+
}
|
| 1799 |
+
|
| 1800 |
+
.bias-chart-name {
|
| 1801 |
+
width: 140px;
|
| 1802 |
+
font-size: 0.78rem;
|
| 1803 |
+
font-weight: 600;
|
| 1804 |
+
flex-shrink: 0;
|
| 1805 |
+
text-align: right;
|
| 1806 |
+
}
|
| 1807 |
+
|
| 1808 |
+
.bias-chart-track {
|
| 1809 |
+
flex: 1;
|
| 1810 |
+
height: 10px;
|
| 1811 |
+
background: var(--bg-tertiary);
|
| 1812 |
+
border-radius: 5px;
|
| 1813 |
+
overflow: hidden;
|
| 1814 |
+
}
|
| 1815 |
+
|
| 1816 |
+
.bias-chart-fill {
|
| 1817 |
+
height: 100%;
|
| 1818 |
+
border-radius: 5px;
|
| 1819 |
+
transition: width 0.8s ease;
|
| 1820 |
+
}
|
| 1821 |
+
|
| 1822 |
+
.bias-chart-val {
|
| 1823 |
+
font-family: var(--font-mono);
|
| 1824 |
+
font-size: 0.75rem;
|
| 1825 |
+
font-weight: 600;
|
| 1826 |
+
min-width: 28px;
|
| 1827 |
+
}
|
| 1828 |
+
|
| 1829 |
+
.bias-grid {
|
| 1830 |
+
display: grid;
|
| 1831 |
+
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
| 1832 |
+
gap: 1.25rem;
|
| 1833 |
+
}
|
| 1834 |
+
|
| 1835 |
+
.bias-detail-card {
|
| 1836 |
+
overflow: hidden;
|
| 1837 |
+
}
|
| 1838 |
+
|
| 1839 |
+
.bias-detail-top {
|
| 1840 |
+
display: flex;
|
| 1841 |
+
justify-content: space-between;
|
| 1842 |
+
align-items: flex-start;
|
| 1843 |
+
gap: 1rem;
|
| 1844 |
+
margin-bottom: 1rem;
|
| 1845 |
+
}
|
| 1846 |
+
|
| 1847 |
+
.bias-detail-top h3 {
|
| 1848 |
+
font-size: 0.92rem;
|
| 1849 |
+
margin-bottom: 0.25rem;
|
| 1850 |
+
}
|
| 1851 |
+
|
| 1852 |
+
.bias-detail-desc {
|
| 1853 |
+
font-size: 0.75rem;
|
| 1854 |
+
color: var(--text-muted);
|
| 1855 |
+
line-height: 1.5;
|
| 1856 |
+
}
|
| 1857 |
+
|
| 1858 |
+
.bias-badge-group {
|
| 1859 |
+
flex-shrink: 0;
|
| 1860 |
+
}
|
| 1861 |
+
|
| 1862 |
+
.bias-severity-badge {
|
| 1863 |
+
display: inline-flex;
|
| 1864 |
+
align-items: center;
|
| 1865 |
+
gap: 0.375rem;
|
| 1866 |
+
padding: 0.25rem 0.625rem;
|
| 1867 |
+
border-radius: 3px;
|
| 1868 |
+
font-size: 0.62rem;
|
| 1869 |
+
font-weight: 700;
|
| 1870 |
+
letter-spacing: 0.08em;
|
| 1871 |
+
}
|
| 1872 |
+
|
| 1873 |
+
.bias-detail-meter {
|
| 1874 |
+
margin-bottom: 0.75rem;
|
| 1875 |
+
}
|
| 1876 |
+
|
| 1877 |
+
.bias-detail-meter-track {
|
| 1878 |
+
height: 5px;
|
| 1879 |
+
background: var(--bg-tertiary);
|
| 1880 |
+
border-radius: 3px;
|
| 1881 |
+
overflow: hidden;
|
| 1882 |
+
}
|
| 1883 |
+
|
| 1884 |
+
.bias-detail-meter-fill {
|
| 1885 |
+
height: 100%;
|
| 1886 |
+
border-radius: 3px;
|
| 1887 |
+
transition: width 0.8s ease;
|
| 1888 |
+
}
|
| 1889 |
+
|
| 1890 |
+
.bias-detail-meta {
|
| 1891 |
+
display: flex;
|
| 1892 |
+
justify-content: space-between;
|
| 1893 |
+
margin-top: 0.375rem;
|
| 1894 |
+
font-size: 0.72rem;
|
| 1895 |
+
font-family: var(--font-mono);
|
| 1896 |
+
color: var(--text-muted);
|
| 1897 |
+
}
|
| 1898 |
+
|
| 1899 |
+
.bias-detail-text {
|
| 1900 |
+
font-size: 0.78rem;
|
| 1901 |
+
color: var(--text-secondary);
|
| 1902 |
+
line-height: 1.6;
|
| 1903 |
+
}
|
| 1904 |
+
|
| 1905 |
+
.bias-coaching-section {
|
| 1906 |
+
margin-top: 1.5rem;
|
| 1907 |
+
}
|
| 1908 |
+
|
| 1909 |
+
.bias-coaching-list {
|
| 1910 |
+
display: flex;
|
| 1911 |
+
flex-direction: column;
|
| 1912 |
+
gap: 0.625rem;
|
| 1913 |
+
}
|
| 1914 |
+
|
| 1915 |
+
.bias-coaching-item {
|
| 1916 |
+
display: flex;
|
| 1917 |
+
align-items: flex-start;
|
| 1918 |
+
gap: 0.75rem;
|
| 1919 |
+
padding: 0.75rem 1rem;
|
| 1920 |
+
background: var(--bg-secondary);
|
| 1921 |
+
border-radius: var(--radius-md);
|
| 1922 |
+
font-size: 0.85rem;
|
| 1923 |
+
line-height: 1.6;
|
| 1924 |
+
}
|
| 1925 |
+
|
| 1926 |
+
.bias-coaching-num {
|
| 1927 |
+
width: 24px;
|
| 1928 |
+
height: 24px;
|
| 1929 |
+
border-radius: 50%;
|
| 1930 |
+
background: var(--accent);
|
| 1931 |
+
color: white;
|
| 1932 |
+
display: flex;
|
| 1933 |
+
align-items: center;
|
| 1934 |
+
justify-content: center;
|
| 1935 |
+
font-size: 0.7rem;
|
| 1936 |
+
font-weight: 700;
|
| 1937 |
+
flex-shrink: 0;
|
| 1938 |
+
margin-top: 0.125rem;
|
| 1939 |
+
}
|
| 1940 |
+
|
| 1941 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1942 |
+
Crisis Replay Page
|
| 1943 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 1944 |
+
.crisis-grid {
|
| 1945 |
+
display: grid;
|
| 1946 |
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
| 1947 |
+
gap: 1rem;
|
| 1948 |
+
margin: 1.5rem 0;
|
| 1949 |
+
}
|
| 1950 |
+
|
| 1951 |
+
.crisis-card {
|
| 1952 |
+
cursor: pointer;
|
| 1953 |
+
text-align: left;
|
| 1954 |
+
transition: all var(--transition-fast);
|
| 1955 |
+
border: 1px solid var(--border-color);
|
| 1956 |
+
background: var(--bg-card);
|
| 1957 |
+
}
|
| 1958 |
+
|
| 1959 |
+
.crisis-card:hover {
|
| 1960 |
+
border-color: var(--accent);
|
| 1961 |
+
transform: translateY(-2px);
|
| 1962 |
+
box-shadow: var(--shadow-card-hover);
|
| 1963 |
+
}
|
| 1964 |
+
|
| 1965 |
+
.crisis-card.active {
|
| 1966 |
+
border-color: var(--accent);
|
| 1967 |
+
background: var(--accent-lighter);
|
| 1968 |
+
}
|
| 1969 |
+
|
| 1970 |
+
.crisis-card-top {
|
| 1971 |
+
display: flex;
|
| 1972 |
+
justify-content: space-between;
|
| 1973 |
+
align-items: flex-start;
|
| 1974 |
+
margin-bottom: 0.75rem;
|
| 1975 |
+
}
|
| 1976 |
+
|
| 1977 |
+
.crisis-card-top h3 {
|
| 1978 |
+
font-size: 0.85rem;
|
| 1979 |
+
}
|
| 1980 |
+
|
| 1981 |
+
.crisis-card-metrics {
|
| 1982 |
+
display: flex;
|
| 1983 |
+
flex-direction: column;
|
| 1984 |
+
gap: 0.375rem;
|
| 1985 |
+
}
|
| 1986 |
+
|
| 1987 |
+
.crisis-card-metrics>div {
|
| 1988 |
+
display: flex;
|
| 1989 |
+
justify-content: space-between;
|
| 1990 |
+
align-items: center;
|
| 1991 |
+
}
|
| 1992 |
+
|
| 1993 |
+
.crisis-metric-label {
|
| 1994 |
+
font-size: 0.65rem;
|
| 1995 |
+
color: var(--text-muted);
|
| 1996 |
+
text-transform: uppercase;
|
| 1997 |
+
letter-spacing: 0.06em;
|
| 1998 |
+
font-weight: 600;
|
| 1999 |
+
}
|
| 2000 |
+
|
| 2001 |
+
.crisis-metric-value {
|
| 2002 |
+
font-family: var(--font-mono);
|
| 2003 |
+
font-size: 0.82rem;
|
| 2004 |
+
font-weight: 600;
|
| 2005 |
+
}
|
| 2006 |
+
|
| 2007 |
+
.crisis-detail-section {
|
| 2008 |
+
display: flex;
|
| 2009 |
+
flex-direction: column;
|
| 2010 |
+
gap: 1.5rem;
|
| 2011 |
+
}
|
| 2012 |
+
|
| 2013 |
+
.crisis-detail-header {
|
| 2014 |
+
overflow: hidden;
|
| 2015 |
+
}
|
| 2016 |
+
|
| 2017 |
+
.crisis-detail-title h2 {
|
| 2018 |
+
font-size: 1.4rem;
|
| 2019 |
+
margin-bottom: 0.375rem;
|
| 2020 |
+
}
|
| 2021 |
+
|
| 2022 |
+
.crisis-detail-title p {
|
| 2023 |
+
color: var(--text-secondary);
|
| 2024 |
+
font-size: 0.88rem;
|
| 2025 |
+
margin-bottom: 1.25rem;
|
| 2026 |
+
}
|
| 2027 |
+
|
| 2028 |
+
.crisis-kpi-row {
|
| 2029 |
+
display: flex;
|
| 2030 |
+
gap: 0;
|
| 2031 |
+
border-top: 1px solid var(--border-subtle);
|
| 2032 |
+
}
|
| 2033 |
+
|
| 2034 |
+
.crisis-kpi {
|
| 2035 |
+
flex: 1;
|
| 2036 |
+
display: flex;
|
| 2037 |
+
flex-direction: column;
|
| 2038 |
+
gap: 0.25rem;
|
| 2039 |
+
padding: 1rem 1.25rem;
|
| 2040 |
+
border-right: 1px solid var(--border-subtle);
|
| 2041 |
+
}
|
| 2042 |
+
|
| 2043 |
+
.crisis-kpi:last-child {
|
| 2044 |
+
border-right: none;
|
| 2045 |
+
}
|
| 2046 |
+
|
| 2047 |
+
.crisis-kpi-label {
|
| 2048 |
+
font-size: 0.65rem;
|
| 2049 |
+
text-transform: uppercase;
|
| 2050 |
+
letter-spacing: 0.08em;
|
| 2051 |
+
color: var(--text-muted);
|
| 2052 |
+
font-weight: 600;
|
| 2053 |
+
}
|
| 2054 |
+
|
| 2055 |
+
.crisis-kpi-value {
|
| 2056 |
+
font-family: var(--font-mono);
|
| 2057 |
+
font-weight: 700;
|
| 2058 |
+
font-size: 0.95rem;
|
| 2059 |
+
}
|
| 2060 |
+
|
| 2061 |
+
.crisis-chart-container {
|
| 2062 |
+
width: 100%;
|
| 2063 |
+
}
|
| 2064 |
+
|
| 2065 |
+
.crisis-equity-svg {
|
| 2066 |
+
width: 100%;
|
| 2067 |
+
height: auto;
|
| 2068 |
+
display: block;
|
| 2069 |
+
}
|
| 2070 |
+
|
| 2071 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2072 |
+
Portfolio DNA Fingerprint Page
|
| 2073 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 2074 |
+
.dna-style-header {
|
| 2075 |
+
display: flex;
|
| 2076 |
+
align-items: center;
|
| 2077 |
+
gap: 1rem;
|
| 2078 |
+
margin: 1rem 0;
|
| 2079 |
+
flex-wrap: wrap;
|
| 2080 |
+
}
|
| 2081 |
+
|
| 2082 |
+
.dna-style-chip,
|
| 2083 |
+
.dna-match-chip {
|
| 2084 |
+
display: inline-flex;
|
| 2085 |
+
align-items: center;
|
| 2086 |
+
gap: 0.5rem;
|
| 2087 |
+
padding: 0.5rem 1rem;
|
| 2088 |
+
background: var(--accent-lighter);
|
| 2089 |
+
border-radius: var(--radius-md);
|
| 2090 |
+
font-size: 0.82rem;
|
| 2091 |
+
color: var(--text-secondary);
|
| 2092 |
+
}
|
| 2093 |
+
|
| 2094 |
+
.dna-style-chip strong,
|
| 2095 |
+
.dna-match-chip strong {
|
| 2096 |
+
color: var(--accent);
|
| 2097 |
+
}
|
| 2098 |
+
|
| 2099 |
+
.dna-layout {
|
| 2100 |
+
display: grid;
|
| 2101 |
+
grid-template-columns: 1fr 1fr;
|
| 2102 |
+
gap: 1.5rem;
|
| 2103 |
+
}
|
| 2104 |
+
|
| 2105 |
+
.dna-radar-card {
|
| 2106 |
+
display: flex;
|
| 2107 |
+
flex-direction: column;
|
| 2108 |
+
}
|
| 2109 |
+
|
| 2110 |
+
.dna-radar-wrapper {
|
| 2111 |
+
width: 100%;
|
| 2112 |
+
max-width: 320px;
|
| 2113 |
+
margin: 0 auto;
|
| 2114 |
+
}
|
| 2115 |
+
|
| 2116 |
+
.dna-radar-svg {
|
| 2117 |
+
width: 100%;
|
| 2118 |
+
}
|
| 2119 |
+
|
| 2120 |
+
.dna-legend {
|
| 2121 |
+
display: flex;
|
| 2122 |
+
gap: 1rem;
|
| 2123 |
+
font-size: 0.72rem;
|
| 2124 |
+
color: var(--text-secondary);
|
| 2125 |
+
}
|
| 2126 |
+
|
| 2127 |
+
.dna-legend-item {
|
| 2128 |
+
display: flex;
|
| 2129 |
+
align-items: center;
|
| 2130 |
+
gap: 0.375rem;
|
| 2131 |
+
}
|
| 2132 |
+
|
| 2133 |
+
.dna-legend-dot {
|
| 2134 |
+
width: 8px;
|
| 2135 |
+
height: 8px;
|
| 2136 |
+
border-radius: 50%;
|
| 2137 |
+
}
|
| 2138 |
+
|
| 2139 |
+
.dna-dims-card h3 {
|
| 2140 |
+
margin-bottom: 1.25rem;
|
| 2141 |
+
}
|
| 2142 |
+
|
| 2143 |
+
.dna-dim-row {
|
| 2144 |
+
display: flex;
|
| 2145 |
+
align-items: center;
|
| 2146 |
+
gap: 0.75rem;
|
| 2147 |
+
margin-bottom: 0.875rem;
|
| 2148 |
+
}
|
| 2149 |
+
|
| 2150 |
+
.dna-dim-label {
|
| 2151 |
+
width: 110px;
|
| 2152 |
+
font-size: 0.78rem;
|
| 2153 |
+
font-weight: 500;
|
| 2154 |
+
flex-shrink: 0;
|
| 2155 |
+
}
|
| 2156 |
+
|
| 2157 |
+
.dna-dim-bar-track {
|
| 2158 |
+
flex: 1;
|
| 2159 |
+
height: 8px;
|
| 2160 |
+
background: var(--bg-tertiary);
|
| 2161 |
+
border-radius: 4px;
|
| 2162 |
+
overflow: hidden;
|
| 2163 |
+
}
|
| 2164 |
+
|
| 2165 |
+
.dna-dim-bar {
|
| 2166 |
+
height: 100%;
|
| 2167 |
+
background: var(--accent);
|
| 2168 |
+
border-radius: 4px;
|
| 2169 |
+
transition: width 0.8s ease;
|
| 2170 |
+
}
|
| 2171 |
+
|
| 2172 |
+
.dna-dim-value {
|
| 2173 |
+
font-family: var(--font-mono);
|
| 2174 |
+
font-size: 0.75rem;
|
| 2175 |
+
color: var(--text-secondary);
|
| 2176 |
+
min-width: 28px;
|
| 2177 |
+
text-align: right;
|
| 2178 |
+
}
|
| 2179 |
+
|
| 2180 |
+
.dna-comparison-chart-card {
|
| 2181 |
+
grid-column: span 2;
|
| 2182 |
+
}
|
| 2183 |
+
|
| 2184 |
+
.dna-comparison-chart-wrapper {
|
| 2185 |
+
width: 100%;
|
| 2186 |
+
overflow-x: auto;
|
| 2187 |
+
}
|
| 2188 |
+
|
| 2189 |
+
.dna-comparison-svg {
|
| 2190 |
+
width: 100%;
|
| 2191 |
+
height: auto;
|
| 2192 |
+
display: block;
|
| 2193 |
+
}
|
| 2194 |
+
|
| 2195 |
+
.dna-compare-card {
|
| 2196 |
+
grid-column: span 2;
|
| 2197 |
+
}
|
| 2198 |
+
|
| 2199 |
+
.dna-compare-card h3 {
|
| 2200 |
+
margin-bottom: 1rem;
|
| 2201 |
+
}
|
| 2202 |
+
|
| 2203 |
+
.dna-comparison-list {
|
| 2204 |
+
display: grid;
|
| 2205 |
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
| 2206 |
+
gap: 0.875rem;
|
| 2207 |
+
}
|
| 2208 |
+
|
| 2209 |
+
.dna-comparison-item {
|
| 2210 |
+
display: flex;
|
| 2211 |
+
justify-content: space-between;
|
| 2212 |
+
align-items: center;
|
| 2213 |
+
padding: 1rem 1.25rem;
|
| 2214 |
+
background: var(--bg-secondary);
|
| 2215 |
+
border: 1px solid var(--border-subtle);
|
| 2216 |
+
border-radius: var(--radius-lg);
|
| 2217 |
+
cursor: pointer;
|
| 2218 |
+
transition: all var(--transition-fast);
|
| 2219 |
+
text-align: left;
|
| 2220 |
+
width: 100%;
|
| 2221 |
+
font-family: var(--font-sans);
|
| 2222 |
+
}
|
| 2223 |
+
|
| 2224 |
+
.dna-comparison-item:hover {
|
| 2225 |
+
border-color: var(--accent);
|
| 2226 |
+
background: var(--accent-lighter);
|
| 2227 |
+
}
|
| 2228 |
+
|
| 2229 |
+
.dna-comparison-item.active {
|
| 2230 |
+
border-color: var(--accent);
|
| 2231 |
+
background: var(--accent-lighter);
|
| 2232 |
+
box-shadow: 0 0 0 2px var(--accent);
|
| 2233 |
+
}
|
| 2234 |
+
|
| 2235 |
+
.dna-fp-info strong {
|
| 2236 |
+
display: block;
|
| 2237 |
+
font-size: 0.85rem;
|
| 2238 |
+
margin-bottom: 0.125rem;
|
| 2239 |
+
color: var(--text-primary);
|
| 2240 |
+
}
|
| 2241 |
+
|
| 2242 |
+
.dna-fp-style {
|
| 2243 |
+
display: block;
|
| 2244 |
+
font-size: 0.68rem;
|
| 2245 |
+
color: var(--accent);
|
| 2246 |
+
font-weight: 600;
|
| 2247 |
+
text-transform: uppercase;
|
| 2248 |
+
letter-spacing: 0.04em;
|
| 2249 |
+
}
|
| 2250 |
+
|
| 2251 |
+
.dna-fp-desc {
|
| 2252 |
+
display: block;
|
| 2253 |
+
font-size: 0.72rem;
|
| 2254 |
+
color: var(--text-muted);
|
| 2255 |
+
margin-top: 0.25rem;
|
| 2256 |
+
}
|
| 2257 |
+
|
| 2258 |
+
.dna-similarity {
|
| 2259 |
+
display: flex;
|
| 2260 |
+
flex-direction: column;
|
| 2261 |
+
align-items: center;
|
| 2262 |
+
flex-shrink: 0;
|
| 2263 |
+
margin-left: 1rem;
|
| 2264 |
+
}
|
| 2265 |
+
|
| 2266 |
+
.dna-sim-pct {
|
| 2267 |
+
font-family: var(--font-mono);
|
| 2268 |
+
font-size: 1.25rem;
|
| 2269 |
+
font-weight: 700;
|
| 2270 |
+
color: var(--accent);
|
| 2271 |
+
}
|
| 2272 |
+
|
| 2273 |
+
.dna-sim-label {
|
| 2274 |
+
font-size: 0.6rem;
|
| 2275 |
+
text-transform: uppercase;
|
| 2276 |
+
letter-spacing: 0.1em;
|
| 2277 |
+
color: var(--text-muted);
|
| 2278 |
+
}
|
| 2279 |
+
|
| 2280 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2281 |
+
HedgeAIChat Page
|
| 2282 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 2283 |
+
.copilot-page {
|
| 2284 |
+
display: flex;
|
| 2285 |
+
flex-direction: column;
|
| 2286 |
+
height: calc(100vh - 2rem);
|
| 2287 |
+
}
|
| 2288 |
+
|
| 2289 |
+
.copilot-header-row {
|
| 2290 |
+
display: flex;
|
| 2291 |
+
justify-content: space-between;
|
| 2292 |
+
align-items: flex-start;
|
| 2293 |
+
}
|
| 2294 |
+
|
| 2295 |
+
.copilot-status {
|
| 2296 |
+
display: flex;
|
| 2297 |
+
align-items: center;
|
| 2298 |
+
gap: 0.5rem;
|
| 2299 |
+
font-size: 0.75rem;
|
| 2300 |
+
color: var(--text-muted);
|
| 2301 |
+
font-weight: 600;
|
| 2302 |
+
}
|
| 2303 |
+
|
| 2304 |
+
.copilot-status-dot {
|
| 2305 |
+
width: 8px;
|
| 2306 |
+
height: 8px;
|
| 2307 |
+
border-radius: 50%;
|
| 2308 |
+
background: var(--green-positive);
|
| 2309 |
+
animation: pulse 2s infinite;
|
| 2310 |
+
}
|
| 2311 |
+
|
| 2312 |
+
@keyframes pulse {
|
| 2313 |
+
|
| 2314 |
+
0%,
|
| 2315 |
+
100% {
|
| 2316 |
+
opacity: 1;
|
| 2317 |
+
}
|
| 2318 |
+
|
| 2319 |
+
50% {
|
| 2320 |
+
opacity: 0.5;
|
| 2321 |
+
}
|
| 2322 |
+
}
|
| 2323 |
+
|
| 2324 |
+
.copilot-chat {
|
| 2325 |
+
flex: 1;
|
| 2326 |
+
display: flex;
|
| 2327 |
+
flex-direction: column;
|
| 2328 |
+
background: var(--bg-card);
|
| 2329 |
+
border: 1px solid var(--border-color);
|
| 2330 |
+
border-radius: var(--radius-lg);
|
| 2331 |
+
overflow: hidden;
|
| 2332 |
+
min-height: 0;
|
| 2333 |
+
}
|
| 2334 |
+
|
| 2335 |
+
.copilot-messages {
|
| 2336 |
+
flex: 1;
|
| 2337 |
+
overflow-y: auto;
|
| 2338 |
+
padding: 1.5rem;
|
| 2339 |
+
display: flex;
|
| 2340 |
+
flex-direction: column;
|
| 2341 |
+
gap: 1.25rem;
|
| 2342 |
+
}
|
| 2343 |
+
|
| 2344 |
+
.copilot-msg {
|
| 2345 |
+
display: flex;
|
| 2346 |
+
gap: 0.75rem;
|
| 2347 |
+
max-width: 78%;
|
| 2348 |
+
animation: fadeInUp 0.3s ease;
|
| 2349 |
+
}
|
| 2350 |
+
|
| 2351 |
+
.copilot-msg-user {
|
| 2352 |
+
align-self: flex-end;
|
| 2353 |
+
flex-direction: row-reverse;
|
| 2354 |
+
}
|
| 2355 |
+
|
| 2356 |
+
.copilot-msg-assistant {
|
| 2357 |
+
align-self: flex-start;
|
| 2358 |
+
}
|
| 2359 |
+
|
| 2360 |
+
.copilot-msg-avatar {
|
| 2361 |
+
flex-shrink: 0;
|
| 2362 |
+
width: 32px;
|
| 2363 |
+
height: 32px;
|
| 2364 |
+
border-radius: 50%;
|
| 2365 |
+
display: flex;
|
| 2366 |
+
align-items: center;
|
| 2367 |
+
justify-content: center;
|
| 2368 |
+
background: var(--bg-tertiary);
|
| 2369 |
+
border: 1px solid var(--border-subtle);
|
| 2370 |
+
}
|
| 2371 |
+
|
| 2372 |
+
.copilot-msg-content {
|
| 2373 |
+
max-width: 100%;
|
| 2374 |
+
display: flex;
|
| 2375 |
+
flex-direction: column;
|
| 2376 |
+
}
|
| 2377 |
+
|
| 2378 |
+
.copilot-msg-label {
|
| 2379 |
+
font-size: 0.65rem;
|
| 2380 |
+
text-transform: uppercase;
|
| 2381 |
+
letter-spacing: 0.06em;
|
| 2382 |
+
color: var(--text-muted);
|
| 2383 |
+
font-weight: 600;
|
| 2384 |
+
margin-bottom: 0.25rem;
|
| 2385 |
+
}
|
| 2386 |
+
|
| 2387 |
+
.copilot-msg-user .copilot-msg-label {
|
| 2388 |
+
text-align: right;
|
| 2389 |
+
}
|
| 2390 |
+
|
| 2391 |
+
.copilot-msg-text {
|
| 2392 |
+
padding: 0.875rem 1.125rem;
|
| 2393 |
+
border-radius: var(--radius-lg);
|
| 2394 |
+
font-size: 0.85rem;
|
| 2395 |
+
line-height: 1.7;
|
| 2396 |
+
white-space: pre-wrap;
|
| 2397 |
+
word-break: break-word;
|
| 2398 |
+
}
|
| 2399 |
+
|
| 2400 |
+
.copilot-msg-user .copilot-msg-text {
|
| 2401 |
+
background: var(--accent);
|
| 2402 |
+
color: white;
|
| 2403 |
+
border-bottom-right-radius: 4px;
|
| 2404 |
+
}
|
| 2405 |
+
|
| 2406 |
+
.copilot-msg-assistant .copilot-msg-text {
|
| 2407 |
+
background: var(--bg-secondary);
|
| 2408 |
+
color: var(--text-primary);
|
| 2409 |
+
border-bottom-left-radius: 4px;
|
| 2410 |
+
border: 1px solid var(--border-subtle);
|
| 2411 |
+
}
|
| 2412 |
+
|
| 2413 |
+
.copilot-intent-tag {
|
| 2414 |
+
display: inline-flex;
|
| 2415 |
+
align-items: center;
|
| 2416 |
+
gap: 0.25rem;
|
| 2417 |
+
margin-top: 0.375rem;
|
| 2418 |
+
padding: 0.2rem 0.625rem;
|
| 2419 |
+
font-size: 0.65rem;
|
| 2420 |
+
font-weight: 600;
|
| 2421 |
+
background: var(--accent-lighter);
|
| 2422 |
+
color: var(--accent);
|
| 2423 |
+
border-radius: 3px;
|
| 2424 |
+
}
|
| 2425 |
+
|
| 2426 |
+
.copilot-actions {
|
| 2427 |
+
display: flex;
|
| 2428 |
+
gap: 0.5rem;
|
| 2429 |
+
margin-top: 0.5rem;
|
| 2430 |
+
flex-wrap: wrap;
|
| 2431 |
+
}
|
| 2432 |
+
|
| 2433 |
+
.copilot-action-btn {
|
| 2434 |
+
display: inline-flex;
|
| 2435 |
+
align-items: center;
|
| 2436 |
+
gap: 0.375rem;
|
| 2437 |
+
padding: 0.375rem 0.875rem;
|
| 2438 |
+
font-size: 0.75rem;
|
| 2439 |
+
font-weight: 600;
|
| 2440 |
+
background: transparent;
|
| 2441 |
+
border: 1px solid var(--border-color);
|
| 2442 |
+
border-radius: var(--radius-md);
|
| 2443 |
+
color: var(--accent);
|
| 2444 |
+
cursor: pointer;
|
| 2445 |
+
transition: all var(--transition-fast);
|
| 2446 |
+
font-family: var(--font-sans);
|
| 2447 |
+
}
|
| 2448 |
+
|
| 2449 |
+
.copilot-action-btn:hover {
|
| 2450 |
+
background: var(--accent-lighter);
|
| 2451 |
+
border-color: var(--accent);
|
| 2452 |
+
}
|
| 2453 |
+
|
| 2454 |
+
.copilot-typing {
|
| 2455 |
+
display: flex;
|
| 2456 |
+
gap: 4px;
|
| 2457 |
+
padding: 0.875rem 1.125rem;
|
| 2458 |
+
}
|
| 2459 |
+
|
| 2460 |
+
.copilot-typing span {
|
| 2461 |
+
width: 7px;
|
| 2462 |
+
height: 7px;
|
| 2463 |
+
background: var(--text-muted);
|
| 2464 |
+
border-radius: 50%;
|
| 2465 |
+
animation: typingBounce 1.4s infinite ease-in-out;
|
| 2466 |
+
}
|
| 2467 |
+
|
| 2468 |
+
.copilot-typing span:nth-child(2) {
|
| 2469 |
+
animation-delay: 0.2s;
|
| 2470 |
+
}
|
| 2471 |
+
|
| 2472 |
+
.copilot-typing span:nth-child(3) {
|
| 2473 |
+
animation-delay: 0.4s;
|
| 2474 |
+
}
|
| 2475 |
+
|
| 2476 |
+
@keyframes typingBounce {
|
| 2477 |
+
|
| 2478 |
+
0%,
|
| 2479 |
+
80%,
|
| 2480 |
+
100% {
|
| 2481 |
+
transform: scale(0.6);
|
| 2482 |
+
opacity: 0.4;
|
| 2483 |
+
}
|
| 2484 |
+
|
| 2485 |
+
40% {
|
| 2486 |
+
transform: scale(1);
|
| 2487 |
+
opacity: 1;
|
| 2488 |
+
}
|
| 2489 |
+
}
|
| 2490 |
+
|
| 2491 |
+
.copilot-input-area {
|
| 2492 |
+
display: flex;
|
| 2493 |
+
align-items: flex-end;
|
| 2494 |
+
gap: 0.75rem;
|
| 2495 |
+
padding: 1rem 1.25rem;
|
| 2496 |
+
border-top: 1px solid var(--border-color);
|
| 2497 |
+
background: var(--bg-primary);
|
| 2498 |
+
}
|
| 2499 |
+
|
| 2500 |
+
.copilot-input {
|
| 2501 |
+
flex: 1;
|
| 2502 |
+
padding: 0.75rem 1rem;
|
| 2503 |
+
border: 1px solid var(--border-color);
|
| 2504 |
+
border-radius: var(--radius-lg);
|
| 2505 |
+
background: var(--bg-secondary);
|
| 2506 |
+
color: var(--text-primary);
|
| 2507 |
+
font-family: var(--font-sans);
|
| 2508 |
+
font-size: 0.85rem;
|
| 2509 |
+
resize: none;
|
| 2510 |
+
outline: none;
|
| 2511 |
+
min-height: 44px;
|
| 2512 |
+
max-height: 120px;
|
| 2513 |
+
line-height: 1.5;
|
| 2514 |
+
}
|
| 2515 |
+
|
| 2516 |
+
.copilot-input:focus {
|
| 2517 |
+
border-color: var(--accent);
|
| 2518 |
+
box-shadow: 0 0 0 3px rgba(0, 82, 65, 0.1);
|
| 2519 |
+
}
|
| 2520 |
+
|
| 2521 |
+
.copilot-send-btn {
|
| 2522 |
+
width: 42px;
|
| 2523 |
+
height: 42px;
|
| 2524 |
+
border-radius: 50%;
|
| 2525 |
+
background: var(--accent);
|
| 2526 |
+
color: white;
|
| 2527 |
+
border: none;
|
| 2528 |
+
cursor: pointer;
|
| 2529 |
+
display: flex;
|
| 2530 |
+
align-items: center;
|
| 2531 |
+
justify-content: center;
|
| 2532 |
+
transition: all var(--transition-fast);
|
| 2533 |
+
flex-shrink: 0;
|
| 2534 |
+
}
|
| 2535 |
+
|
| 2536 |
+
.copilot-send-btn:hover:not(:disabled) {
|
| 2537 |
+
background: var(--accent-light);
|
| 2538 |
+
transform: scale(1.05);
|
| 2539 |
+
}
|
| 2540 |
+
|
| 2541 |
+
.copilot-send-btn:disabled {
|
| 2542 |
+
opacity: 0.4;
|
| 2543 |
+
cursor: not-allowed;
|
| 2544 |
+
}
|
| 2545 |
+
|
| 2546 |
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2547 |
+
Mobile Overrides for New Pages
|
| 2548 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 2549 |
+
@media (max-width: 768px) {
|
| 2550 |
+
.page-container {
|
| 2551 |
+
padding: 1.25rem 1rem !important;
|
| 2552 |
+
}
|
| 2553 |
+
|
| 2554 |
+
.health-top-row {
|
| 2555 |
+
grid-template-columns: 1fr !important;
|
| 2556 |
+
}
|
| 2557 |
+
|
| 2558 |
+
.health-bottom-row {
|
| 2559 |
+
grid-template-columns: 1fr !important;
|
| 2560 |
+
}
|
| 2561 |
+
|
| 2562 |
+
.health-tips-grid {
|
| 2563 |
+
grid-template-columns: 1fr !important;
|
| 2564 |
+
}
|
| 2565 |
+
|
| 2566 |
+
.bias-summary-row {
|
| 2567 |
+
grid-template-columns: 1fr 1fr !important;
|
| 2568 |
+
}
|
| 2569 |
+
|
| 2570 |
+
.bias-grid {
|
| 2571 |
+
grid-template-columns: 1fr !important;
|
| 2572 |
+
}
|
| 2573 |
+
|
| 2574 |
+
.bias-chart-name {
|
| 2575 |
+
width: 100px;
|
| 2576 |
+
font-size: 0.7rem;
|
| 2577 |
+
}
|
| 2578 |
+
|
| 2579 |
+
.crisis-grid {
|
| 2580 |
+
grid-template-columns: 1fr 1fr !important;
|
| 2581 |
+
}
|
| 2582 |
+
|
| 2583 |
+
.crisis-kpi-row {
|
| 2584 |
+
flex-wrap: wrap;
|
| 2585 |
+
}
|
| 2586 |
+
|
| 2587 |
+
.crisis-kpi {
|
| 2588 |
+
min-width: 50%;
|
| 2589 |
+
border-bottom: 1px solid var(--border-subtle);
|
| 2590 |
+
}
|
| 2591 |
+
|
| 2592 |
+
.dna-layout {
|
| 2593 |
+
grid-template-columns: 1fr !important;
|
| 2594 |
+
}
|
| 2595 |
+
|
| 2596 |
+
.dna-comparison-chart-card,
|
| 2597 |
+
.dna-compare-card {
|
| 2598 |
+
grid-column: span 1 !important;
|
| 2599 |
+
}
|
| 2600 |
+
|
| 2601 |
+
.dna-comparison-list {
|
| 2602 |
+
grid-template-columns: 1fr !important;
|
| 2603 |
+
}
|
| 2604 |
+
|
| 2605 |
+
.copilot-msg {
|
| 2606 |
+
max-width: 92%;
|
| 2607 |
+
}
|
| 2608 |
+
|
| 2609 |
+
.copilot-header-row {
|
| 2610 |
+
flex-direction: column;
|
| 2611 |
+
gap: 0.5rem;
|
| 2612 |
+
}
|
| 2613 |
+
}
|
frontend/src/pages/BiasDetector.tsx
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { holdingsAPI } from '../api/client';
|
| 3 |
+
|
| 4 |
+
interface Bias {
|
| 5 |
+
name: string;
|
| 6 |
+
description: string;
|
| 7 |
+
score: number;
|
| 8 |
+
grade: string;
|
| 9 |
+
severity: string;
|
| 10 |
+
detail: string;
|
| 11 |
+
icon: string;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface BiasData {
|
| 15 |
+
score: number;
|
| 16 |
+
grade: string;
|
| 17 |
+
biases: Bias[];
|
| 18 |
+
tips: string[];
|
| 19 |
+
position_count: number;
|
| 20 |
+
winners: number;
|
| 21 |
+
losers: number;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const severityColor = (s: string) =>
|
| 25 |
+
s === 'low' ? '#00c853' : s === 'moderate' ? '#ff9800' : s === 'high' ? '#f44336' : '#b71c1c';
|
| 26 |
+
|
| 27 |
+
const severityIcon = (s: string) => {
|
| 28 |
+
if (s === 'low') return <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd"/></svg>;
|
| 29 |
+
if (s === 'moderate') return <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd"/></svg>;
|
| 30 |
+
return <svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd"/></svg>;
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
export default function BiasDetector() {
|
| 34 |
+
const [data, setData] = useState<BiasData | null>(null);
|
| 35 |
+
const [loading, setLoading] = useState(true);
|
| 36 |
+
|
| 37 |
+
useEffect(() => { loadBiases(); }, []);
|
| 38 |
+
|
| 39 |
+
const loadBiases = async () => {
|
| 40 |
+
try {
|
| 41 |
+
const res = await holdingsAPI.biasAnalysis();
|
| 42 |
+
setData(res.data);
|
| 43 |
+
} catch { /* ignore */ } finally { setLoading(false); }
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
if (loading) return <div className="page-container"><div className="loading-spinner" /></div>;
|
| 47 |
+
if (!data) return <div className="page-container"><div className="alert-card">Add holdings to analyze behavioral biases.</div></div>;
|
| 48 |
+
|
| 49 |
+
const sorted = [...data.biases].sort((a, b) => b.score - a.score);
|
| 50 |
+
const overallColor = data.grade <= 'B' ? '#00c853' : data.grade <= 'C' ? '#ff9800' : '#f44336';
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
<div className="page-container">
|
| 54 |
+
<div className="page-header">
|
| 55 |
+
<h1>Behavioral Bias Detector</h1>
|
| 56 |
+
<p className="page-subtitle">Identify cognitive biases that may impact your investment decisions</p>
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
{/* Summary Row */}
|
| 60 |
+
<div className="bias-summary-row">
|
| 61 |
+
<div className="card bias-score-card">
|
| 62 |
+
<div className="bias-score-header">
|
| 63 |
+
<span className="bias-score-label">Behavioral Score</span>
|
| 64 |
+
<span className="bias-score-value" style={{ color: overallColor }}>{data.score}</span>
|
| 65 |
+
</div>
|
| 66 |
+
<div className="bias-score-bar-track">
|
| 67 |
+
<div className="bias-score-bar-fill" style={{ width: `${data.score}%`, background: `linear-gradient(90deg, ${overallColor}80, ${overallColor})` }} />
|
| 68 |
+
</div>
|
| 69 |
+
<div className="bias-score-grade" style={{ color: overallColor }}>Grade: {data.grade}</div>
|
| 70 |
+
</div>
|
| 71 |
+
<div className="card bias-stat-card">
|
| 72 |
+
<svg viewBox="0 0 20 20" fill="var(--text-muted)" width="20" height="20"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fillRule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z" clipRule="evenodd"/></svg>
|
| 73 |
+
<span className="bias-stat-value">{data.position_count}</span>
|
| 74 |
+
<span className="bias-stat-label">Positions</span>
|
| 75 |
+
</div>
|
| 76 |
+
<div className="card bias-stat-card">
|
| 77 |
+
<svg viewBox="0 0 20 20" fill="var(--green-positive)" width="20" height="20"><path fillRule="evenodd" d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z" clipRule="evenodd"/></svg>
|
| 78 |
+
<span className="bias-stat-value positive">{data.winners}</span>
|
| 79 |
+
<span className="bias-stat-label">Winners</span>
|
| 80 |
+
</div>
|
| 81 |
+
<div className="card bias-stat-card">
|
| 82 |
+
<svg viewBox="0 0 20 20" fill="var(--red-negative)" width="20" height="20"><path fillRule="evenodd" d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z" clipRule="evenodd"/></svg>
|
| 83 |
+
<span className="bias-stat-value negative">{data.losers}</span>
|
| 84 |
+
<span className="bias-stat-label">Losers</span>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
{/* Stacked Bar Visualization */}
|
| 89 |
+
<div className="card bias-stacked-chart">
|
| 90 |
+
<div className="card-header">
|
| 91 |
+
<h3>Bias Severity Distribution</h3>
|
| 92 |
+
</div>
|
| 93 |
+
<div className="bias-chart-bars">
|
| 94 |
+
{sorted.map((bias) => (
|
| 95 |
+
<div key={bias.name} className="bias-chart-row">
|
| 96 |
+
<span className="bias-chart-name">{bias.name}</span>
|
| 97 |
+
<div className="bias-chart-track">
|
| 98 |
+
<div className="bias-chart-fill" style={{ width: `${bias.score}%`, background: severityColor(bias.severity) }} />
|
| 99 |
+
</div>
|
| 100 |
+
<span className="bias-chart-val" style={{ color: severityColor(bias.severity) }}>{bias.score}</span>
|
| 101 |
+
</div>
|
| 102 |
+
))}
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* Bias Cards Grid */}
|
| 107 |
+
<div className="bias-grid">
|
| 108 |
+
{sorted.map((bias) => (
|
| 109 |
+
<div key={bias.name} className="card bias-detail-card">
|
| 110 |
+
<div className="bias-detail-top">
|
| 111 |
+
<div>
|
| 112 |
+
<h3>{bias.name}</h3>
|
| 113 |
+
<p className="bias-detail-desc">{bias.description}</p>
|
| 114 |
+
</div>
|
| 115 |
+
<div className="bias-badge-group">
|
| 116 |
+
<span className="bias-severity-badge" style={{ background: severityColor(bias.severity) + '15', color: severityColor(bias.severity) }}>
|
| 117 |
+
{severityIcon(bias.severity)}
|
| 118 |
+
<span>{bias.severity.toUpperCase()}</span>
|
| 119 |
+
</span>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
<div className="bias-detail-meter">
|
| 123 |
+
<div className="bias-detail-meter-track">
|
| 124 |
+
<div className="bias-detail-meter-fill" style={{ width: `${bias.score}%`, background: severityColor(bias.severity) }} />
|
| 125 |
+
</div>
|
| 126 |
+
<div className="bias-detail-meta">
|
| 127 |
+
<span>Score: {bias.score}/100</span>
|
| 128 |
+
<span style={{ color: severityColor(bias.severity), fontWeight: 700 }}>{bias.grade}</span>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
<p className="bias-detail-text">{bias.detail}</p>
|
| 132 |
+
</div>
|
| 133 |
+
))}
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
{/* Tips */}
|
| 137 |
+
{data.tips.length > 0 && (
|
| 138 |
+
<div className="card bias-coaching-section">
|
| 139 |
+
<div className="card-header">
|
| 140 |
+
<h3>Behavioral Coaching</h3>
|
| 141 |
+
<span className="badge badge-primary">{data.tips.length} Recommendations</span>
|
| 142 |
+
</div>
|
| 143 |
+
<div className="bias-coaching-list">
|
| 144 |
+
{data.tips.map((tip, i) => (
|
| 145 |
+
<div key={i} className="bias-coaching-item">
|
| 146 |
+
<div className="bias-coaching-num">{i + 1}</div>
|
| 147 |
+
<span>{tip}</span>
|
| 148 |
+
</div>
|
| 149 |
+
))}
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
)}
|
| 153 |
+
</div>
|
| 154 |
+
);
|
| 155 |
+
}
|
frontend/src/pages/Copilot.tsx
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { copilotAPI } from '../api/client';
|
| 3 |
+
import { useNavigate } from 'react-router-dom';
|
| 4 |
+
|
| 5 |
+
interface Message {
|
| 6 |
+
id: number;
|
| 7 |
+
role: 'user' | 'assistant';
|
| 8 |
+
content: string;
|
| 9 |
+
intent?: string;
|
| 10 |
+
actions?: { label: string; route: string }[];
|
| 11 |
+
timestamp: Date;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const BotIcon = () => (
|
| 15 |
+
<svg viewBox="0 0 24 24" fill="none" width="18" height="18" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 16 |
+
<rect x="3" y="11" width="18" height="10" rx="2" />
|
| 17 |
+
<circle cx="12" cy="5" r="3" />
|
| 18 |
+
<line x1="12" y1="8" x2="12" y2="11" />
|
| 19 |
+
<circle cx="8" cy="16" r="1" fill="var(--accent)" />
|
| 20 |
+
<circle cx="16" cy="16" r="1" fill="var(--accent)" />
|
| 21 |
+
</svg>
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
const UserIcon = () => (
|
| 25 |
+
<svg viewBox="0 0 20 20" fill="var(--text-muted)" width="16" height="16">
|
| 26 |
+
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
|
| 27 |
+
</svg>
|
| 28 |
+
);
|
| 29 |
+
|
| 30 |
+
const SendIcon = () => (
|
| 31 |
+
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
| 32 |
+
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
| 33 |
+
</svg>
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
export default function Copilot() {
|
| 37 |
+
const [messages, setMessages] = useState<Message[]>([
|
| 38 |
+
{
|
| 39 |
+
id: 0,
|
| 40 |
+
role: 'assistant',
|
| 41 |
+
content: "Welcome to the QuantHedge HedgeAI. I can help you analyze your portfolio risk, evaluate hedging strategies, run stress tests, and interpret market sentiment.\n\nTry asking:\n\n\u2022 \"How would my portfolio perform in a recession?\"\n\u2022 \"What is my biggest risk exposure?\"\n\u2022 \"Analyze sentiment for AAPL\"\n\u2022 \"Should I hedge my NVDA position?\"",
|
| 42 |
+
timestamp: new Date(),
|
| 43 |
+
},
|
| 44 |
+
]);
|
| 45 |
+
const [input, setInput] = useState('');
|
| 46 |
+
const [loading, setLoading] = useState(false);
|
| 47 |
+
const bottomRef = useRef<HTMLDivElement>(null);
|
| 48 |
+
const navigate = useNavigate();
|
| 49 |
+
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 52 |
+
}, [messages]);
|
| 53 |
+
|
| 54 |
+
const sendMessage = async () => {
|
| 55 |
+
if (!input.trim() || loading) return;
|
| 56 |
+
const userMsg: Message = { id: Date.now(), role: 'user', content: input.trim(), timestamp: new Date() };
|
| 57 |
+
setMessages(prev => [...prev, userMsg]);
|
| 58 |
+
setInput('');
|
| 59 |
+
setLoading(true);
|
| 60 |
+
try {
|
| 61 |
+
const res = await copilotAPI.chat(input.trim());
|
| 62 |
+
const data = res.data;
|
| 63 |
+
setMessages(prev => [...prev, {
|
| 64 |
+
id: Date.now() + 1,
|
| 65 |
+
role: 'assistant',
|
| 66 |
+
content: data.response || 'I could not process that request. Please try again.',
|
| 67 |
+
intent: data.intent,
|
| 68 |
+
actions: data.actions,
|
| 69 |
+
timestamp: new Date(),
|
| 70 |
+
}]);
|
| 71 |
+
} catch {
|
| 72 |
+
setMessages(prev => [...prev, {
|
| 73 |
+
id: Date.now() + 1,
|
| 74 |
+
role: 'assistant',
|
| 75 |
+
content: 'An error occurred while processing your request. Please try again.',
|
| 76 |
+
timestamp: new Date(),
|
| 77 |
+
}]);
|
| 78 |
+
} finally { setLoading(false); }
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 82 |
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
const formatIntent = (intent: string) => intent.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
| 86 |
+
|
| 87 |
+
return (
|
| 88 |
+
<div className="page-container copilot-page">
|
| 89 |
+
<div className="page-header">
|
| 90 |
+
<div className="copilot-header-row">
|
| 91 |
+
<div>
|
| 92 |
+
<h1>HedgeAI</h1>
|
| 93 |
+
<p className="page-subtitle">Natural language portfolio analysis assistant</p>
|
| 94 |
+
</div>
|
| 95 |
+
<div className="copilot-status">
|
| 96 |
+
<span className="copilot-status-dot" />
|
| 97 |
+
<span>Online</span>
|
| 98 |
+
</div>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<div className="copilot-chat">
|
| 103 |
+
<div className="copilot-messages">
|
| 104 |
+
{messages.map(msg => (
|
| 105 |
+
<div key={msg.id} className={`copilot-msg copilot-msg-${msg.role}`}>
|
| 106 |
+
<div className="copilot-msg-avatar">
|
| 107 |
+
{msg.role === 'user' ? <UserIcon /> : <BotIcon />}
|
| 108 |
+
</div>
|
| 109 |
+
<div className="copilot-msg-content">
|
| 110 |
+
<div className="copilot-msg-label">{msg.role === 'user' ? 'You' : 'QuantHedge Copilot'}</div>
|
| 111 |
+
<div className="copilot-msg-text">{msg.content}</div>
|
| 112 |
+
{msg.intent && msg.intent !== 'general' && (
|
| 113 |
+
<div className="copilot-intent-tag">
|
| 114 |
+
<svg viewBox="0 0 20 20" fill="currentColor" width="10" height="10"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.414V13a1 1 0 102 0V9.414l1.293 1.293a1 1 0 001.414-1.414z" clipRule="evenodd" /></svg>
|
| 115 |
+
<span>{formatIntent(msg.intent)}</span>
|
| 116 |
+
</div>
|
| 117 |
+
)}
|
| 118 |
+
{msg.actions && msg.actions.length > 0 && (
|
| 119 |
+
<div className="copilot-actions">
|
| 120 |
+
{msg.actions.map((a, i) => (
|
| 121 |
+
<button key={i} className="copilot-action-btn" onClick={() => navigate(a.route)}>
|
| 122 |
+
{a.label}
|
| 123 |
+
<svg viewBox="0 0 20 20" fill="currentColor" width="12" height="12"><path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" /></svg>
|
| 124 |
+
</button>
|
| 125 |
+
))}
|
| 126 |
+
</div>
|
| 127 |
+
)}
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
))}
|
| 131 |
+
{loading && (
|
| 132 |
+
<div className="copilot-msg copilot-msg-assistant">
|
| 133 |
+
<div className="copilot-msg-avatar"><BotIcon /></div>
|
| 134 |
+
<div className="copilot-msg-content">
|
| 135 |
+
<div className="copilot-msg-label">QuantHedge Copilot</div>
|
| 136 |
+
<div className="copilot-typing"><span /><span /><span /></div>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
)}
|
| 140 |
+
<div ref={bottomRef} />
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
<div className="copilot-input-area">
|
| 144 |
+
<textarea
|
| 145 |
+
className="copilot-input"
|
| 146 |
+
value={input}
|
| 147 |
+
onChange={(e) => setInput(e.target.value)}
|
| 148 |
+
onKeyDown={handleKeyDown}
|
| 149 |
+
placeholder="Ask about portfolio risk, hedging strategies, or market analysis..."
|
| 150 |
+
rows={1}
|
| 151 |
+
disabled={loading}
|
| 152 |
+
/>
|
| 153 |
+
<button className="copilot-send-btn" onClick={sendMessage} disabled={loading || !input.trim()}>
|
| 154 |
+
<SendIcon />
|
| 155 |
+
</button>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
);
|
| 160 |
+
}
|
frontend/src/pages/CrisisReplay.tsx
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { holdingsAPI } from '../api/client';
|
| 3 |
+
|
| 4 |
+
interface CrisisSummary {
|
| 5 |
+
crisis_id: string;
|
| 6 |
+
name: string;
|
| 7 |
+
total_pnl: number;
|
| 8 |
+
total_pnl_pct: number;
|
| 9 |
+
max_drawdown_pct: number;
|
| 10 |
+
trading_days: number;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
interface EquityPoint {
|
| 14 |
+
date: string;
|
| 15 |
+
portfolio_value: number;
|
| 16 |
+
daily_return: number;
|
| 17 |
+
cumulative_return: number;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
interface CrisisDetail {
|
| 21 |
+
crisis: { name: string; description: string; start: string; end: string; peak_decline: number };
|
| 22 |
+
portfolio_value: number;
|
| 23 |
+
final_value: number;
|
| 24 |
+
total_pnl: number;
|
| 25 |
+
total_pnl_pct: number;
|
| 26 |
+
max_drawdown_pct: number;
|
| 27 |
+
equity_curve: EquityPoint[];
|
| 28 |
+
holding_impacts: { ticker: string; sector: string; impact: number; sector_return: number }[];
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export default function CrisisReplay() {
|
| 32 |
+
const [summaries, setSummaries] = useState<CrisisSummary[]>([]);
|
| 33 |
+
const [detail, setDetail] = useState<CrisisDetail | null>(null);
|
| 34 |
+
const [selectedCrisis, setSelectedCrisis] = useState('');
|
| 35 |
+
const [loading, setLoading] = useState(true);
|
| 36 |
+
const [detailLoading, setDetailLoading] = useState(false);
|
| 37 |
+
|
| 38 |
+
useEffect(() => { loadSummaries(); }, []);
|
| 39 |
+
|
| 40 |
+
const loadSummaries = async () => {
|
| 41 |
+
try {
|
| 42 |
+
const res = await holdingsAPI.allCrisisReplays();
|
| 43 |
+
setSummaries(res.data.crises || []);
|
| 44 |
+
} catch { /* ignore */ } finally { setLoading(false); }
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const loadCrisis = async (crisisId: string) => {
|
| 48 |
+
setSelectedCrisis(crisisId);
|
| 49 |
+
setDetailLoading(true);
|
| 50 |
+
try {
|
| 51 |
+
const res = await holdingsAPI.crisisReplay(crisisId);
|
| 52 |
+
setDetail(res.data);
|
| 53 |
+
} catch { /* ignore */ } finally { setDetailLoading(false); }
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
if (loading) return <div className="page-container"><div className="loading-spinner" /></div>;
|
| 57 |
+
|
| 58 |
+
const renderEquityCurve = () => {
|
| 59 |
+
if (!detail?.equity_curve?.length) return null;
|
| 60 |
+
const points = detail.equity_curve;
|
| 61 |
+
const values = points.map(p => p.portfolio_value);
|
| 62 |
+
const min = Math.min(...values);
|
| 63 |
+
const max = Math.max(...values);
|
| 64 |
+
const range = max - min || 1;
|
| 65 |
+
const w = 700;
|
| 66 |
+
const h = 220;
|
| 67 |
+
const pad = { top: 20, right: 20, bottom: 30, left: 60 };
|
| 68 |
+
const plotW = w - pad.left - pad.right;
|
| 69 |
+
const plotH = h - pad.top - pad.bottom;
|
| 70 |
+
|
| 71 |
+
const isNeg = (detail.total_pnl || 0) < 0;
|
| 72 |
+
const lineColor = isNeg ? 'var(--red-negative)' : 'var(--green-positive)';
|
| 73 |
+
|
| 74 |
+
const pathD = points.map((p, i) => {
|
| 75 |
+
const x = pad.left + (i / (points.length - 1)) * plotW;
|
| 76 |
+
const y = pad.top + plotH - ((p.portfolio_value - min) / range) * plotH;
|
| 77 |
+
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
|
| 78 |
+
}).join(' ');
|
| 79 |
+
|
| 80 |
+
const areaD = `${pathD} L ${pad.left + plotW} ${pad.top + plotH} L ${pad.left} ${pad.top + plotH} Z`;
|
| 81 |
+
|
| 82 |
+
// Y-axis labels
|
| 83 |
+
const ySteps = 5;
|
| 84 |
+
const yLabels = Array.from({ length: ySteps + 1 }, (_, i) => min + (range * i) / ySteps);
|
| 85 |
+
|
| 86 |
+
return (
|
| 87 |
+
<svg viewBox={`0 0 ${w} ${h}`} className="crisis-equity-svg" preserveAspectRatio="xMidYMid meet">
|
| 88 |
+
{/* Grid lines */}
|
| 89 |
+
{yLabels.map((v, i) => {
|
| 90 |
+
const y = pad.top + plotH - ((v - min) / range) * plotH;
|
| 91 |
+
return (
|
| 92 |
+
<g key={i}>
|
| 93 |
+
<line x1={pad.left} y1={y} x2={pad.left + plotW} y2={y} stroke="var(--border-subtle)" strokeWidth="0.5" />
|
| 94 |
+
<text x={pad.left - 8} y={y + 3} textAnchor="end" fill="var(--text-muted)" fontSize="8" fontFamily="var(--font-mono)">${(v / 1000).toFixed(1)}k</text>
|
| 95 |
+
</g>
|
| 96 |
+
);
|
| 97 |
+
})}
|
| 98 |
+
{/* X-axis labels (first/mid/last) */}
|
| 99 |
+
{[0, Math.floor(points.length / 2), points.length - 1].map((idx) => {
|
| 100 |
+
const x = pad.left + (idx / (points.length - 1)) * plotW;
|
| 101 |
+
const d = points[idx]?.date?.slice(5, 10) || '';
|
| 102 |
+
return <text key={idx} x={x} y={h - 5} textAnchor="middle" fill="var(--text-muted)" fontSize="8" fontFamily="var(--font-mono)">{d}</text>;
|
| 103 |
+
})}
|
| 104 |
+
{/* Area fill */}
|
| 105 |
+
<defs>
|
| 106 |
+
<linearGradient id="eqGrad" x1="0" y1="0" x2="0" y2="1">
|
| 107 |
+
<stop offset="0%" stopColor={lineColor} stopOpacity="0.2" />
|
| 108 |
+
<stop offset="100%" stopColor={lineColor} stopOpacity="0.02" />
|
| 109 |
+
</linearGradient>
|
| 110 |
+
</defs>
|
| 111 |
+
<path d={areaD} fill="url(#eqGrad)" />
|
| 112 |
+
<path d={pathD} fill="none" stroke={lineColor} strokeWidth="1.5" />
|
| 113 |
+
</svg>
|
| 114 |
+
);
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
// Mini sparkline for summary cards
|
| 118 |
+
const sparkline = (pnl: number) => {
|
| 119 |
+
const col = pnl < 0 ? 'var(--red-negative)' : 'var(--green-positive)';
|
| 120 |
+
const points = pnl < 0 ? 'M0,4 L8,6 L16,3 L24,8 L32,12 L40,10' : 'M0,10 L8,8 L16,6 L24,4 L32,3 L40,2';
|
| 121 |
+
return <svg viewBox="0 0 40 14" width="40" height="14" style={{ display: 'block' }}><path d={points} fill="none" stroke={col} strokeWidth="1.5" strokeLinecap="round" /></svg>;
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
return (
|
| 125 |
+
<div className="page-container">
|
| 126 |
+
<div className="page-header">
|
| 127 |
+
<h1>Historical Crisis Replay</h1>
|
| 128 |
+
<p className="page-subtitle">Simulate your portfolio performance during major historical market crises</p>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
{/* Crisis Cards */}
|
| 132 |
+
<div className="crisis-grid">
|
| 133 |
+
{summaries.map((s) => (
|
| 134 |
+
<button key={s.crisis_id} className={`card crisis-card ${selectedCrisis === s.crisis_id ? 'active' : ''}`} onClick={() => loadCrisis(s.crisis_id)}>
|
| 135 |
+
<div className="crisis-card-top">
|
| 136 |
+
<h3>{s.name}</h3>
|
| 137 |
+
{sparkline(s.total_pnl_pct)}
|
| 138 |
+
</div>
|
| 139 |
+
<div className="crisis-card-metrics">
|
| 140 |
+
<div>
|
| 141 |
+
<span className="crisis-metric-label">Impact</span>
|
| 142 |
+
<span className={`crisis-metric-value ${s.total_pnl_pct < 0 ? 'negative' : 'positive'}`}>{s.total_pnl_pct > 0 ? '+' : ''}{s.total_pnl_pct.toFixed(1)}%</span>
|
| 143 |
+
</div>
|
| 144 |
+
<div>
|
| 145 |
+
<span className="crisis-metric-label">Max DD</span>
|
| 146 |
+
<span className="crisis-metric-value negative">{s.max_drawdown_pct.toFixed(1)}%</span>
|
| 147 |
+
</div>
|
| 148 |
+
<div>
|
| 149 |
+
<span className="crisis-metric-label">Duration</span>
|
| 150 |
+
<span className="crisis-metric-value">{s.trading_days}d</span>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
</button>
|
| 154 |
+
))}
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
{/* Detail View */}
|
| 158 |
+
{detailLoading && <div className="loading-spinner" />}
|
| 159 |
+
{detail && !detailLoading && (
|
| 160 |
+
<div className="crisis-detail-section">
|
| 161 |
+
{/* Header + Key Metrics */}
|
| 162 |
+
<div className="card crisis-detail-header">
|
| 163 |
+
<div className="crisis-detail-title">
|
| 164 |
+
<h2>{detail.crisis.name}</h2>
|
| 165 |
+
<p>{detail.crisis.description}</p>
|
| 166 |
+
</div>
|
| 167 |
+
<div className="crisis-kpi-row">
|
| 168 |
+
<div className="crisis-kpi">
|
| 169 |
+
<span className="crisis-kpi-label">Period</span>
|
| 170 |
+
<span className="crisis-kpi-value">{detail.crisis.start} β {detail.crisis.end}</span>
|
| 171 |
+
</div>
|
| 172 |
+
<div className="crisis-kpi">
|
| 173 |
+
<span className="crisis-kpi-label">Portfolio Impact</span>
|
| 174 |
+
<span className={`crisis-kpi-value ${detail.total_pnl < 0 ? 'negative' : 'positive'}`}>${detail.total_pnl.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
|
| 175 |
+
</div>
|
| 176 |
+
<div className="crisis-kpi">
|
| 177 |
+
<span className="crisis-kpi-label">Return</span>
|
| 178 |
+
<span className={`crisis-kpi-value ${detail.total_pnl_pct < 0 ? 'negative' : 'positive'}`}>{detail.total_pnl_pct > 0 ? '+' : ''}{detail.total_pnl_pct.toFixed(2)}%</span>
|
| 179 |
+
</div>
|
| 180 |
+
<div className="crisis-kpi">
|
| 181 |
+
<span className="crisis-kpi-label">Max Drawdown</span>
|
| 182 |
+
<span className="crisis-kpi-value negative">{detail.max_drawdown_pct.toFixed(2)}%</span>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
{/* Equity Curve Chart */}
|
| 188 |
+
<div className="card">
|
| 189 |
+
<div className="card-header">
|
| 190 |
+
<h3>Portfolio Equity Curve</h3>
|
| 191 |
+
<span className="badge badge-primary">{detail.equity_curve?.length || 0} Trading Days</span>
|
| 192 |
+
</div>
|
| 193 |
+
<div className="crisis-chart-container">{renderEquityCurve()}</div>
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
{/* Per-Holding Impact Table */}
|
| 197 |
+
<div className="card">
|
| 198 |
+
<div className="card-header">
|
| 199 |
+
<h3>Per-Holding Impact Analysis</h3>
|
| 200 |
+
</div>
|
| 201 |
+
<table className="data-table">
|
| 202 |
+
<thead>
|
| 203 |
+
<tr>
|
| 204 |
+
<th>Ticker</th>
|
| 205 |
+
<th>Sector</th>
|
| 206 |
+
<th>Sector Return</th>
|
| 207 |
+
<th>Dollar Impact</th>
|
| 208 |
+
</tr>
|
| 209 |
+
</thead>
|
| 210 |
+
<tbody>
|
| 211 |
+
{detail.holding_impacts?.sort((a, b) => a.impact - b.impact).map((h) => (
|
| 212 |
+
<tr key={h.ticker}>
|
| 213 |
+
<td style={{ fontWeight: 700 }}>{h.ticker}</td>
|
| 214 |
+
<td style={{ fontFamily: 'var(--font-sans)', textTransform: 'capitalize' }}>{h.sector}</td>
|
| 215 |
+
<td className={h.sector_return < 0 ? 'negative' : 'positive'}>{h.sector_return > 0 ? '+' : ''}{h.sector_return.toFixed(1)}%</td>
|
| 216 |
+
<td className={h.impact < 0 ? 'negative' : 'positive'}>${h.impact.toLocaleString(undefined, { maximumFractionDigits: 0 })}</td>
|
| 217 |
+
</tr>
|
| 218 |
+
))}
|
| 219 |
+
</tbody>
|
| 220 |
+
</table>
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
)}
|
| 224 |
+
</div>
|
| 225 |
+
);
|
| 226 |
+
}
|
frontend/src/pages/PortfolioDNA.tsx
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { holdingsAPI } from '../api/client';
|
| 3 |
+
|
| 4 |
+
interface DNAProfile {
|
| 5 |
+
[key: string]: number;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
interface FamousPortfolio {
|
| 9 |
+
id: string;
|
| 10 |
+
name: string;
|
| 11 |
+
description: string;
|
| 12 |
+
style: string;
|
| 13 |
+
similarity: number;
|
| 14 |
+
dna: DNAProfile;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
interface DNAData {
|
| 18 |
+
dna: DNAProfile;
|
| 19 |
+
style: string;
|
| 20 |
+
dominant_trait: string;
|
| 21 |
+
closest_match: FamousPortfolio | null;
|
| 22 |
+
comparisons: FamousPortfolio[];
|
| 23 |
+
position_count: number;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const DIMENSION_LABELS: Record<string, string> = {
|
| 27 |
+
momentum: 'Momentum',
|
| 28 |
+
value: 'Value',
|
| 29 |
+
volatility: 'Volatility',
|
| 30 |
+
regime_sensitivity: 'Regime Sens.',
|
| 31 |
+
concentration: 'Concentration',
|
| 32 |
+
hedge_coverage: 'Hedge Coverage',
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
export default function PortfolioDNA() {
|
| 36 |
+
const [data, setData] = useState<DNAData | null>(null);
|
| 37 |
+
const [loading, setLoading] = useState(true);
|
| 38 |
+
const [overlayId, setOverlayId] = useState<string | null>(null);
|
| 39 |
+
|
| 40 |
+
useEffect(() => { loadDNA(); }, []);
|
| 41 |
+
|
| 42 |
+
const loadDNA = async () => {
|
| 43 |
+
try {
|
| 44 |
+
const res = await holdingsAPI.portfolioDNA();
|
| 45 |
+
setData(res.data);
|
| 46 |
+
} catch { /* ignore */ } finally { setLoading(false); }
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
if (loading) return <div className="page-container"><div className="loading-spinner" /></div>;
|
| 50 |
+
if (!data?.dna) return <div className="page-container"><div className="alert-card">Add holdings to compute your Portfolio DNA fingerprint.</div></div>;
|
| 51 |
+
|
| 52 |
+
const dims = Object.keys(data.dna).sort();
|
| 53 |
+
const overlayFP = data.comparisons.find(c => c.id === overlayId);
|
| 54 |
+
|
| 55 |
+
const renderRadar = () => {
|
| 56 |
+
const w = 320, cx = 160, cy = 160, R = 120;
|
| 57 |
+
return (
|
| 58 |
+
<svg viewBox={`0 0 ${w} ${w}`} className="dna-radar-svg">
|
| 59 |
+
{[20, 40, 60, 80, 100].map(r => (
|
| 60 |
+
<polygon key={r} points={dims.map((_, i) => {
|
| 61 |
+
const a = (Math.PI * 2 * i) / dims.length - Math.PI / 2;
|
| 62 |
+
return `${cx + R * (r/100) * Math.cos(a)},${cy + R * (r/100) * Math.sin(a)}`;
|
| 63 |
+
}).join(' ')} fill="none" stroke="var(--border-subtle)" strokeWidth="0.5" opacity={0.4} />
|
| 64 |
+
))}
|
| 65 |
+
{dims.map((_, i) => {
|
| 66 |
+
const a = (Math.PI * 2 * i) / dims.length - Math.PI / 2;
|
| 67 |
+
return <line key={i} x1={cx} y1={cy} x2={cx + R * Math.cos(a)} y2={cy + R * Math.sin(a)} stroke="var(--border-subtle)" strokeWidth="0.5" opacity={0.25} />;
|
| 68 |
+
})}
|
| 69 |
+
{/* Overlay */}
|
| 70 |
+
{overlayFP && (
|
| 71 |
+
<polygon points={dims.map((d, i) => {
|
| 72 |
+
const a = (Math.PI * 2 * i) / dims.length - Math.PI / 2;
|
| 73 |
+
const r = (overlayFP.dna[d] || 0) / 100;
|
| 74 |
+
return `${cx + R * r * Math.cos(a)},${cy + R * r * Math.sin(a)}`;
|
| 75 |
+
}).join(' ')} fill="rgba(255,152,0,0.08)" stroke="#ff9800" strokeWidth="1.5" strokeDasharray="4 2" />
|
| 76 |
+
)}
|
| 77 |
+
{/* User DNA */}
|
| 78 |
+
<polygon points={dims.map((d, i) => {
|
| 79 |
+
const a = (Math.PI * 2 * i) / dims.length - Math.PI / 2;
|
| 80 |
+
const r = data.dna[d] / 100;
|
| 81 |
+
return `${cx + R * r * Math.cos(a)},${cy + R * r * Math.sin(a)}`;
|
| 82 |
+
}).join(' ')} fill="var(--accent)" fillOpacity="0.1" stroke="var(--accent)" strokeWidth="2" />
|
| 83 |
+
{dims.map((d, i) => {
|
| 84 |
+
const a = (Math.PI * 2 * i) / dims.length - Math.PI / 2;
|
| 85 |
+
const r = data.dna[d] / 100;
|
| 86 |
+
return <circle key={d} cx={cx + R * r * Math.cos(a)} cy={cy + R * r * Math.sin(a)} r="3.5" fill="var(--accent)" />;
|
| 87 |
+
})}
|
| 88 |
+
{dims.map((d, i) => {
|
| 89 |
+
const a = (Math.PI * 2 * i) / dims.length - Math.PI / 2;
|
| 90 |
+
return <text key={d} x={cx + (R + 18) * Math.cos(a)} y={cy + (R + 18) * Math.sin(a)} textAnchor="middle" dominantBaseline="middle" fill="var(--text-secondary)" fontSize="9" fontWeight="500">{DIMENSION_LABELS[d] || d}</text>;
|
| 91 |
+
})}
|
| 92 |
+
</svg>
|
| 93 |
+
);
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
// Grouped bar chart: user vs closest match
|
| 97 |
+
const renderComparisonBars = () => {
|
| 98 |
+
const match = data.closest_match || data.comparisons[0];
|
| 99 |
+
if (!match) return null;
|
| 100 |
+
const barH = 20;
|
| 101 |
+
const gap = 8;
|
| 102 |
+
const rowH = barH * 2 + gap + 16;
|
| 103 |
+
const svgH = dims.length * rowH + 20;
|
| 104 |
+
const labelW = 90;
|
| 105 |
+
const barW = 260;
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<svg viewBox={`0 0 ${labelW + barW + 50} ${svgH}`} className="dna-comparison-svg">
|
| 109 |
+
{dims.map((d, i) => {
|
| 110 |
+
const y = i * rowH + 10;
|
| 111 |
+
const userVal = data.dna[d] || 0;
|
| 112 |
+
const matchVal = match.dna[d] || 0;
|
| 113 |
+
return (
|
| 114 |
+
<g key={d}>
|
| 115 |
+
<text x={labelW - 8} y={y + barH / 2 + 4} textAnchor="end" fill="var(--text-secondary)" fontSize="9" fontWeight="500">{DIMENSION_LABELS[d] || d}</text>
|
| 116 |
+
<rect x={labelW} y={y} width={(userVal / 100) * barW} height={barH} rx="3" fill="var(--accent)" opacity="0.8" />
|
| 117 |
+
<text x={labelW + (userVal / 100) * barW + 6} y={y + barH / 2 + 3} fill="var(--text-secondary)" fontSize="8" fontFamily="var(--font-mono)">{userVal}</text>
|
| 118 |
+
<rect x={labelW} y={y + barH + 2} width={(matchVal / 100) * barW} height={barH} rx="3" fill="#ff9800" opacity="0.6" />
|
| 119 |
+
<text x={labelW + (matchVal / 100) * barW + 6} y={y + barH + 2 + barH / 2 + 3} fill="var(--text-muted)" fontSize="8" fontFamily="var(--font-mono)">{matchVal}</text>
|
| 120 |
+
</g>
|
| 121 |
+
);
|
| 122 |
+
})}
|
| 123 |
+
</svg>
|
| 124 |
+
);
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
return (
|
| 128 |
+
<div className="page-container">
|
| 129 |
+
<div className="page-header">
|
| 130 |
+
<h1>Portfolio DNA Fingerprint</h1>
|
| 131 |
+
<p className="page-subtitle">Your investing style decoded and compared against legendary investors</p>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
{/* Style Badge */}
|
| 135 |
+
<div className="dna-style-header">
|
| 136 |
+
<div className="dna-style-chip">
|
| 137 |
+
<svg viewBox="0 0 20 20" fill="var(--accent)" width="16" height="16"><path d="M10 3.5a1.5 1.5 0 013 0V4a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-.5a1.5 1.5 0 000 3h.5a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-.5a1.5 1.5 0 00-3 0v.5a1 1 0 01-1 1H6a1 1 0 01-1-1v-3a1 1 0 00-1-1h-.5a1.5 1.5 0 010-3H4a1 1 0 001-1V6a1 1 0 011-1h3a1 1 0 001-1v-.5z"/></svg>
|
| 138 |
+
<span>Investing Style: <strong>{data.style}</strong></span>
|
| 139 |
+
</div>
|
| 140 |
+
{data.closest_match && (
|
| 141 |
+
<div className="dna-match-chip">
|
| 142 |
+
<svg viewBox="0 0 20 20" fill="var(--accent)" width="14" height="14"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd"/></svg>
|
| 143 |
+
<span>Closest Match: <strong>{data.closest_match.name}</strong> ({data.closest_match.similarity.toFixed(0)}%)</span>
|
| 144 |
+
</div>
|
| 145 |
+
)}
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<div className="dna-layout">
|
| 149 |
+
{/* Radar Chart */}
|
| 150 |
+
<div className="card dna-radar-card">
|
| 151 |
+
<div className="card-header">
|
| 152 |
+
<h3>DNA Profile</h3>
|
| 153 |
+
{overlayFP && (
|
| 154 |
+
<div className="dna-legend">
|
| 155 |
+
<span className="dna-legend-item"><span className="dna-legend-dot" style={{ background: 'var(--accent)' }} /> You</span>
|
| 156 |
+
<span className="dna-legend-item"><span className="dna-legend-dot" style={{ background: '#ff9800' }} /> {overlayFP.name}</span>
|
| 157 |
+
</div>
|
| 158 |
+
)}
|
| 159 |
+
</div>
|
| 160 |
+
<div className="dna-radar-wrapper">{renderRadar()}</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
{/* Dimension Bars */}
|
| 164 |
+
<div className="card dna-dims-card">
|
| 165 |
+
<div className="card-header">
|
| 166 |
+
<h3>Dimension Scores</h3>
|
| 167 |
+
</div>
|
| 168 |
+
{dims.map(d => (
|
| 169 |
+
<div key={d} className="dna-dim-row">
|
| 170 |
+
<span className="dna-dim-label">{DIMENSION_LABELS[d] || d}</span>
|
| 171 |
+
<div className="dna-dim-bar-track">
|
| 172 |
+
<div className="dna-dim-bar" style={{ width: `${data.dna[d]}%` }} />
|
| 173 |
+
</div>
|
| 174 |
+
<span className="dna-dim-value">{data.dna[d]}</span>
|
| 175 |
+
</div>
|
| 176 |
+
))}
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
{/* Comparison Chart */}
|
| 180 |
+
{data.closest_match && (
|
| 181 |
+
<div className="card dna-comparison-chart-card">
|
| 182 |
+
<div className="card-header">
|
| 183 |
+
<h3>You vs {data.closest_match.name}</h3>
|
| 184 |
+
<div className="dna-legend">
|
| 185 |
+
<span className="dna-legend-item"><span className="dna-legend-dot" style={{ background: 'var(--accent)' }} /> Your Portfolio</span>
|
| 186 |
+
<span className="dna-legend-item"><span className="dna-legend-dot" style={{ background: '#ff9800' }} /> {data.closest_match.name}</span>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
<div className="dna-comparison-chart-wrapper">{renderComparisonBars()}</div>
|
| 190 |
+
</div>
|
| 191 |
+
)}
|
| 192 |
+
|
| 193 |
+
{/* Famous Portfolios */}
|
| 194 |
+
<div className="card dna-compare-card">
|
| 195 |
+
<div className="card-header">
|
| 196 |
+
<h3>Famous Portfolio Comparisons</h3>
|
| 197 |
+
<span className="badge badge-primary">{data.comparisons.length} Benchmarks</span>
|
| 198 |
+
</div>
|
| 199 |
+
<div className="dna-comparison-list">
|
| 200 |
+
{data.comparisons.map(fp => (
|
| 201 |
+
<button key={fp.id} className={`dna-comparison-item ${overlayId === fp.id ? 'active' : ''}`} onClick={() => setOverlayId(overlayId === fp.id ? null : fp.id)}>
|
| 202 |
+
<div className="dna-fp-info">
|
| 203 |
+
<strong>{fp.name}</strong>
|
| 204 |
+
<span className="dna-fp-style">{fp.style}</span>
|
| 205 |
+
<span className="dna-fp-desc">{fp.description}</span>
|
| 206 |
+
</div>
|
| 207 |
+
<div className="dna-similarity">
|
| 208 |
+
<span className="dna-sim-pct">{fp.similarity.toFixed(0)}%</span>
|
| 209 |
+
<span className="dna-sim-label">match</span>
|
| 210 |
+
</div>
|
| 211 |
+
</button>
|
| 212 |
+
))}
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
);
|
| 218 |
+
}
|
frontend/src/pages/PortfolioHealth.tsx
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { holdingsAPI } from '../api/client';
|
| 3 |
+
|
| 4 |
+
interface HealthComponent {
|
| 5 |
+
name: string;
|
| 6 |
+
score: number;
|
| 7 |
+
weight: number;
|
| 8 |
+
grade: string;
|
| 9 |
+
detail: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
interface HealthData {
|
| 13 |
+
score: number;
|
| 14 |
+
grade: string;
|
| 15 |
+
grade_color: string;
|
| 16 |
+
components: HealthComponent[];
|
| 17 |
+
tips: string[];
|
| 18 |
+
position_count: number;
|
| 19 |
+
total_value: number;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const gradeColor = (grade: string) =>
|
| 23 |
+
grade.startsWith('A') ? '#00c853' : grade.startsWith('B') ? '#2196f3' : grade.startsWith('C') ? '#ff9800' : '#f44336';
|
| 24 |
+
|
| 25 |
+
export default function PortfolioHealth() {
|
| 26 |
+
const [data, setData] = useState<HealthData | null>(null);
|
| 27 |
+
const [loading, setLoading] = useState(true);
|
| 28 |
+
const [error, setError] = useState('');
|
| 29 |
+
|
| 30 |
+
useEffect(() => { loadHealth(); }, []);
|
| 31 |
+
|
| 32 |
+
const loadHealth = async () => {
|
| 33 |
+
setLoading(true);
|
| 34 |
+
try {
|
| 35 |
+
const res = await holdingsAPI.healthScore();
|
| 36 |
+
setData(res.data);
|
| 37 |
+
} catch (e: any) {
|
| 38 |
+
setError(e.response?.data?.detail || 'Failed to load health score');
|
| 39 |
+
} finally {
|
| 40 |
+
setLoading(false);
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
if (loading) return <div className="page-container"><div className="loading-spinner" /></div>;
|
| 45 |
+
if (error) return <div className="page-container"><div className="alert-card error">{error}</div></div>;
|
| 46 |
+
if (!data) return null;
|
| 47 |
+
|
| 48 |
+
const circumference = 2 * Math.PI * 70;
|
| 49 |
+
const filled = (data.score / 100) * circumference;
|
| 50 |
+
|
| 51 |
+
// Bar chart data for component scores
|
| 52 |
+
const sortedComponents = [...data.components].sort((a, b) => b.score - a.score);
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<div className="page-container">
|
| 56 |
+
<div className="page-header">
|
| 57 |
+
<h1>Portfolio Health Score</h1>
|
| 58 |
+
<p className="page-subtitle">Comprehensive portfolio assessment across 7 risk-adjusted dimensions</p>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
{/* Top Summary Row */}
|
| 62 |
+
<div className="health-top-row">
|
| 63 |
+
<div className="card health-gauge-card">
|
| 64 |
+
<div className="health-gauge">
|
| 65 |
+
<svg viewBox="0 0 160 160" className="gauge-svg">
|
| 66 |
+
<circle cx="80" cy="80" r="70" fill="none" stroke="var(--border-subtle)" strokeWidth="10" />
|
| 67 |
+
<circle
|
| 68 |
+
cx="80" cy="80" r="70" fill="none"
|
| 69 |
+
stroke={data.grade_color || gradeColor(data.grade)}
|
| 70 |
+
strokeWidth="10"
|
| 71 |
+
strokeDasharray={`${filled} ${circumference}`}
|
| 72 |
+
strokeDashoffset={circumference * 0.25}
|
| 73 |
+
strokeLinecap="round"
|
| 74 |
+
style={{ transition: 'stroke-dasharray 1s ease' }}
|
| 75 |
+
/>
|
| 76 |
+
</svg>
|
| 77 |
+
<div className="gauge-center">
|
| 78 |
+
<span className="gauge-score">{data.score}</span>
|
| 79 |
+
<span className="gauge-grade" style={{ color: data.grade_color || gradeColor(data.grade) }}>{data.grade}</span>
|
| 80 |
+
</div>
|
| 81 |
+
</div>
|
| 82 |
+
<div className="gauge-meta">
|
| 83 |
+
<div className="gauge-meta-item">
|
| 84 |
+
<span className="gauge-meta-value">{data.position_count}</span>
|
| 85 |
+
<span className="gauge-meta-label">Positions</span>
|
| 86 |
+
</div>
|
| 87 |
+
<div className="gauge-meta-divider" />
|
| 88 |
+
<div className="gauge-meta-item">
|
| 89 |
+
<span className="gauge-meta-value">${(data.total_value || 0).toLocaleString()}</span>
|
| 90 |
+
<span className="gauge-meta-label">Total Value</span>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
{/* Component Scores Bar Chart */}
|
| 96 |
+
<div className="card health-chart-card">
|
| 97 |
+
<div className="card-header">
|
| 98 |
+
<h3>Component Breakdown</h3>
|
| 99 |
+
<span className="badge badge-primary">7 Dimensions</span>
|
| 100 |
+
</div>
|
| 101 |
+
<div className="health-bar-chart">
|
| 102 |
+
{sortedComponents.map((c) => (
|
| 103 |
+
<div key={c.name} className="hbar-row">
|
| 104 |
+
<div className="hbar-label">
|
| 105 |
+
<span className="hbar-name">{c.name}</span>
|
| 106 |
+
<span className="hbar-grade-pill" style={{ background: gradeColor(c.grade) + '18', color: gradeColor(c.grade) }}>{c.grade}</span>
|
| 107 |
+
</div>
|
| 108 |
+
<div className="hbar-track-container">
|
| 109 |
+
<div className="hbar-track">
|
| 110 |
+
<div className="hbar-fill" style={{ width: `${c.score}%`, background: `linear-gradient(90deg, ${gradeColor(c.grade)}80, ${gradeColor(c.grade)})` }} />
|
| 111 |
+
</div>
|
| 112 |
+
<span className="hbar-value">{c.score}</span>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
))}
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
{/* Radar + Details Row */}
|
| 121 |
+
<div className="health-bottom-row">
|
| 122 |
+
{/* Radar Chart */}
|
| 123 |
+
<div className="card health-radar-card">
|
| 124 |
+
<div className="card-header">
|
| 125 |
+
<h3>Risk Profile Radar</h3>
|
| 126 |
+
</div>
|
| 127 |
+
<div className="radar-wrapper">
|
| 128 |
+
<svg viewBox="0 0 300 300" className="radar-svg">
|
| 129 |
+
{[20, 40, 60, 80, 100].map((r) => (
|
| 130 |
+
<polygon key={r}
|
| 131 |
+
points={data.components.map((_, i) => {
|
| 132 |
+
const angle = (Math.PI * 2 * i) / data.components.length - Math.PI / 2;
|
| 133 |
+
return `${150 + (r / 100) * 110 * Math.cos(angle)},${150 + (r / 100) * 110 * Math.sin(angle)}`;
|
| 134 |
+
}).join(' ')}
|
| 135 |
+
fill="none" stroke="var(--border-subtle)" strokeWidth="0.5" opacity={0.5}
|
| 136 |
+
/>
|
| 137 |
+
))}
|
| 138 |
+
{data.components.map((_, i) => {
|
| 139 |
+
const angle = (Math.PI * 2 * i) / data.components.length - Math.PI / 2;
|
| 140 |
+
return <line key={i} x1="150" y1="150" x2={150 + 110 * Math.cos(angle)} y2={150 + 110 * Math.sin(angle)} stroke="var(--border-subtle)" strokeWidth="0.5" opacity={0.3} />;
|
| 141 |
+
})}
|
| 142 |
+
<polygon
|
| 143 |
+
points={data.components.map((c, i) => {
|
| 144 |
+
const angle = (Math.PI * 2 * i) / data.components.length - Math.PI / 2;
|
| 145 |
+
const r = c.score / 100;
|
| 146 |
+
return `${150 + r * 110 * Math.cos(angle)},${150 + r * 110 * Math.sin(angle)}`;
|
| 147 |
+
}).join(' ')}
|
| 148 |
+
fill="var(--accent)" fillOpacity="0.12" stroke="var(--accent)" strokeWidth="2"
|
| 149 |
+
/>
|
| 150 |
+
{data.components.map((c, i) => {
|
| 151 |
+
const angle = (Math.PI * 2 * i) / data.components.length - Math.PI / 2;
|
| 152 |
+
const r = c.score / 100;
|
| 153 |
+
return <circle key={i} cx={150 + r * 110 * Math.cos(angle)} cy={150 + r * 110 * Math.sin(angle)} r="4" fill="var(--accent)" />;
|
| 154 |
+
})}
|
| 155 |
+
{data.components.map((c, i) => {
|
| 156 |
+
const angle = (Math.PI * 2 * i) / data.components.length - Math.PI / 2;
|
| 157 |
+
const x = 150 + 130 * Math.cos(angle);
|
| 158 |
+
const y = 150 + 130 * Math.sin(angle);
|
| 159 |
+
return <text key={i} x={x} y={y} textAnchor="middle" dominantBaseline="middle" fill="var(--text-secondary)" fontSize="9" fontWeight="500">{c.name}</text>;
|
| 160 |
+
})}
|
| 161 |
+
</svg>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
{/* Component Details Table */}
|
| 166 |
+
<div className="card health-detail-card">
|
| 167 |
+
<div className="card-header">
|
| 168 |
+
<h3>Dimension Detail</h3>
|
| 169 |
+
</div>
|
| 170 |
+
<table className="data-table">
|
| 171 |
+
<thead>
|
| 172 |
+
<tr>
|
| 173 |
+
<th>Dimension</th>
|
| 174 |
+
<th>Score</th>
|
| 175 |
+
<th>Grade</th>
|
| 176 |
+
<th>Weight</th>
|
| 177 |
+
<th>Assessment</th>
|
| 178 |
+
</tr>
|
| 179 |
+
</thead>
|
| 180 |
+
<tbody>
|
| 181 |
+
{data.components.map((c) => (
|
| 182 |
+
<tr key={c.name}>
|
| 183 |
+
<td style={{ fontFamily: 'var(--font-sans)', fontWeight: 600 }}>{c.name}</td>
|
| 184 |
+
<td>{c.score}</td>
|
| 185 |
+
<td><span className="badge" style={{ background: gradeColor(c.grade) + '18', color: gradeColor(c.grade) }}>{c.grade}</span></td>
|
| 186 |
+
<td>{(c.weight * 100).toFixed(0)}%</td>
|
| 187 |
+
<td style={{ fontFamily: 'var(--font-sans)', fontSize: '0.78rem', color: 'var(--text-secondary)' }}>{c.detail}</td>
|
| 188 |
+
</tr>
|
| 189 |
+
))}
|
| 190 |
+
</tbody>
|
| 191 |
+
</table>
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
|
| 195 |
+
{/* Coaching Tips */}
|
| 196 |
+
{data.tips.length > 0 && (
|
| 197 |
+
<div className="card health-tips-section">
|
| 198 |
+
<div className="card-header">
|
| 199 |
+
<h3>Actionable Recommendations</h3>
|
| 200 |
+
<span className="badge badge-primary">{data.tips.length} Tips</span>
|
| 201 |
+
</div>
|
| 202 |
+
<div className="health-tips-grid">
|
| 203 |
+
{data.tips.map((tip, i) => (
|
| 204 |
+
<div key={i} className="health-tip-item">
|
| 205 |
+
<div className="health-tip-icon">
|
| 206 |
+
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" /></svg>
|
| 207 |
+
</div>
|
| 208 |
+
<span>{tip}</span>
|
| 209 |
+
</div>
|
| 210 |
+
))}
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
)}
|
| 214 |
+
</div>
|
| 215 |
+
);
|
| 216 |
+
}
|