feat: implement NAV erosion risk assessment service and UI integration
This commit is contained in:
parent
c4a7f91867
commit
65209331f5
10
ETF_Portal/services/nav_erosion_service/__init__.py
Normal file
10
ETF_Portal/services/nav_erosion_service/__init__.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
NAV Erosion Risk Assessment Service
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .service import NavErosionService
|
||||||
|
from .models import NavErosionResult, NavErosionAnalysis
|
||||||
|
from .exceptions import NavErosionError, DataFetchError
|
||||||
|
|
||||||
|
__all__ = ['NavErosionService', 'NavErosionResult', 'NavErosionAnalysis',
|
||||||
|
'NavErosionError', 'DataFetchError']
|
||||||
19
ETF_Portal/services/nav_erosion_service/exceptions.py
Normal file
19
ETF_Portal/services/nav_erosion_service/exceptions.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
Custom exceptions for NAV Erosion Service
|
||||||
|
"""
|
||||||
|
|
||||||
|
class NavErosionError(Exception):
|
||||||
|
"""Base exception for NAV Erosion Service"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DataFetchError(NavErosionError):
|
||||||
|
"""Raised when data fetching fails"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class CalculationError(NavErosionError):
|
||||||
|
"""Raised when risk calculation fails"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ValidationError(NavErosionError):
|
||||||
|
"""Raised when input validation fails"""
|
||||||
|
pass
|
||||||
93
ETF_Portal/services/nav_erosion_service/logger.py
Normal file
93
ETF_Portal/services/nav_erosion_service/logger.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Logging configuration for NAV Erosion Service
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
class ErosionRiskLogger:
|
||||||
|
"""Logger for NAV Erosion Service"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logging.getLogger('erosion_risk')
|
||||||
|
self.setup_logger()
|
||||||
|
|
||||||
|
def setup_logger(self):
|
||||||
|
"""Configure logger with file and console handlers"""
|
||||||
|
# Create logs directory if it doesn't exist
|
||||||
|
log_dir = Path('logs')
|
||||||
|
log_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Set base logging level
|
||||||
|
self.logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# File handler for errors
|
||||||
|
error_handler = logging.FileHandler(
|
||||||
|
log_dir / f'erosion_risk_errors_{datetime.now().strftime("%Y%m%d")}.log'
|
||||||
|
)
|
||||||
|
error_handler.setLevel(logging.ERROR)
|
||||||
|
error_handler.setFormatter(
|
||||||
|
logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
)
|
||||||
|
|
||||||
|
# File handler for flow tracking
|
||||||
|
flow_handler = logging.FileHandler(
|
||||||
|
log_dir / f'erosion_risk_flow_{datetime.now().strftime("%Y%m%d")}.log'
|
||||||
|
)
|
||||||
|
flow_handler.setLevel(logging.INFO)
|
||||||
|
flow_handler.setFormatter(
|
||||||
|
logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Console handler for immediate feedback
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(logging.WARNING)
|
||||||
|
console_handler.setFormatter(
|
||||||
|
logging.Formatter('%(levelname)s: %(message)s')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add handlers to logger
|
||||||
|
self.logger.addHandler(error_handler)
|
||||||
|
self.logger.addHandler(flow_handler)
|
||||||
|
self.logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
def log_risk_calculation(self, ticker: str, component_risks: dict, final_risk: float):
|
||||||
|
"""Log risk calculation details"""
|
||||||
|
self.logger.info(f"Risk calculation for {ticker}:")
|
||||||
|
|
||||||
|
# Log NAV Risk Components
|
||||||
|
self.logger.info("NAV Risk Components:")
|
||||||
|
for component, risk in component_risks.get('nav', {}).items():
|
||||||
|
self.logger.info(f" {component}: {risk}")
|
||||||
|
|
||||||
|
# Log Yield Risk Components
|
||||||
|
self.logger.info("Yield Risk Components:")
|
||||||
|
for component, risk in component_risks.get('yield', {}).items():
|
||||||
|
self.logger.info(f" {component}: {risk}")
|
||||||
|
|
||||||
|
# Log Structural Risk Components
|
||||||
|
self.logger.info("Structural Risk Components:")
|
||||||
|
for component, risk in component_risks.get('structural', {}).items():
|
||||||
|
self.logger.info(f" {component}: {risk}")
|
||||||
|
|
||||||
|
self.logger.info(f"Final Risk Score: {final_risk}")
|
||||||
|
|
||||||
|
def log_error(self, ticker: str, error: Exception, context: dict = None):
|
||||||
|
"""Log error with context"""
|
||||||
|
self.logger.error(f"Error processing {ticker}: {str(error)}")
|
||||||
|
if context:
|
||||||
|
self.logger.error(f"Context: {context}")
|
||||||
|
|
||||||
|
def log_warning(self, ticker: str, message: str, context: dict = None):
|
||||||
|
"""Log warning with context"""
|
||||||
|
self.logger.warning(f"Warning for {ticker}: {message}")
|
||||||
|
if context:
|
||||||
|
self.logger.warning(f"Context: {context}")
|
||||||
|
|
||||||
|
def log_info(self, message: str, context: dict = None):
|
||||||
|
"""Log info message with context"""
|
||||||
|
self.logger.info(message)
|
||||||
|
if context:
|
||||||
|
self.logger.info(f"Context: {context}")
|
||||||
36
ETF_Portal/services/nav_erosion_service/models.py
Normal file
36
ETF_Portal/services/nav_erosion_service/models.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Data models for NAV Erosion Service
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Optional, Dict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NavErosionResult:
|
||||||
|
"""Result of NAV erosion risk analysis for a single ETF"""
|
||||||
|
ticker: str
|
||||||
|
nav_erosion_risk: int # 0-9 scale
|
||||||
|
yield_erosion_risk: int # 0-9 scale
|
||||||
|
estimated_nav_erosion: float # Annual percentage
|
||||||
|
estimated_yield_erosion: float # Annual percentage
|
||||||
|
nav_risk_explanation: str
|
||||||
|
yield_risk_explanation: str
|
||||||
|
etf_age_years: Optional[float]
|
||||||
|
is_new_etf: bool
|
||||||
|
max_drawdown: Optional[float]
|
||||||
|
volatility: Optional[float]
|
||||||
|
sharpe_ratio: Optional[float]
|
||||||
|
sortino_ratio: Optional[float]
|
||||||
|
dividend_trend: Optional[float]
|
||||||
|
component_risks: Dict[str, float] # Detailed risk components
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NavErosionAnalysis:
|
||||||
|
"""Complete NAV erosion analysis for a portfolio"""
|
||||||
|
results: List[NavErosionResult]
|
||||||
|
portfolio_nav_risk: float # Weighted average
|
||||||
|
portfolio_yield_risk: float # Weighted average
|
||||||
|
risk_summary: str
|
||||||
|
timestamp: datetime
|
||||||
|
component_weights: Dict[str, float] # Weights used in calculation
|
||||||
431
ETF_Portal/services/nav_erosion_service/service.py
Normal file
431
ETF_Portal/services/nav_erosion_service/service.py
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
"""
|
||||||
|
NAV Erosion Service implementation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict, Optional, Tuple
|
||||||
|
import yfinance as yf
|
||||||
|
from .models import NavErosionResult, NavErosionAnalysis
|
||||||
|
from .exceptions import NavErosionError, DataFetchError, CalculationError
|
||||||
|
from .logger import ErosionRiskLogger
|
||||||
|
|
||||||
|
class NavErosionService:
|
||||||
|
"""Service for calculating NAV erosion risk"""
|
||||||
|
|
||||||
|
# Risk weights
|
||||||
|
NAV_RISK_WEIGHT = 0.45
|
||||||
|
YIELD_RISK_WEIGHT = 0.35
|
||||||
|
STRUCTURAL_RISK_WEIGHT = 0.20
|
||||||
|
|
||||||
|
# Component weights within each risk category
|
||||||
|
NAV_COMPONENT_WEIGHTS = {
|
||||||
|
'drawdown': 0.333, # 33.3% of NAV risk
|
||||||
|
'volatility': 0.222, # 22.2% of NAV risk
|
||||||
|
'sharpe': 0.222, # 22.2% of NAV risk
|
||||||
|
'sortino': 0.222 # 22.2% of NAV risk
|
||||||
|
}
|
||||||
|
|
||||||
|
YIELD_COMPONENT_WEIGHTS = {
|
||||||
|
'stability': 0.429, # 42.9% of yield risk
|
||||||
|
'growth': 0.286, # 28.6% of yield risk
|
||||||
|
'payout': 0.285 # 28.5% of yield risk
|
||||||
|
}
|
||||||
|
|
||||||
|
STRUCTURAL_COMPONENT_WEIGHTS = {
|
||||||
|
'age': 0.25, # 25% of structural risk
|
||||||
|
'aum': 0.25, # 25% of structural risk
|
||||||
|
'liquidity': 0.25, # 25% of structural risk
|
||||||
|
'expense': 0.25 # 25% of structural risk
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = ErosionRiskLogger()
|
||||||
|
|
||||||
|
def analyze_etf_erosion_risk(self, tickers: List[str], debug: bool = False) -> NavErosionAnalysis:
|
||||||
|
"""Analyze erosion risk for a list of ETFs"""
|
||||||
|
results = []
|
||||||
|
current_date = pd.Timestamp.now(tz='UTC')
|
||||||
|
|
||||||
|
for ticker in tickers:
|
||||||
|
try:
|
||||||
|
# Get ETF data
|
||||||
|
etf_data = self._fetch_etf_data(ticker)
|
||||||
|
if not etf_data:
|
||||||
|
self.logger.log_warning(ticker, "No data available")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate risk components
|
||||||
|
nav_risk, nav_components = self._calculate_nav_risk(etf_data)
|
||||||
|
yield_risk, yield_components = self._calculate_yield_risk(etf_data)
|
||||||
|
structural_risk, structural_components = self._calculate_structural_risk(etf_data)
|
||||||
|
|
||||||
|
# Calculate final risk scores
|
||||||
|
final_nav_risk = self._calculate_final_risk(nav_risk, yield_risk, structural_risk)
|
||||||
|
|
||||||
|
# Create result
|
||||||
|
result = NavErosionResult(
|
||||||
|
ticker=ticker,
|
||||||
|
nav_erosion_risk=int(final_nav_risk),
|
||||||
|
yield_erosion_risk=int(yield_risk),
|
||||||
|
estimated_nav_erosion=final_nav_risk / 9 * 0.9, # Convert to percentage
|
||||||
|
estimated_yield_erosion=yield_risk / 9 * 0.9, # Convert to percentage
|
||||||
|
nav_risk_explanation=self._generate_nav_explanation(nav_components),
|
||||||
|
yield_risk_explanation=self._generate_yield_explanation(yield_components),
|
||||||
|
etf_age_years=etf_data.get('age_years'),
|
||||||
|
is_new_etf=etf_data.get('is_new', False),
|
||||||
|
max_drawdown=etf_data.get('max_drawdown'),
|
||||||
|
volatility=etf_data.get('volatility'),
|
||||||
|
sharpe_ratio=etf_data.get('sharpe_ratio'),
|
||||||
|
sortino_ratio=etf_data.get('sortino_ratio'),
|
||||||
|
dividend_trend=etf_data.get('dividend_trend'),
|
||||||
|
component_risks={
|
||||||
|
'nav': nav_components,
|
||||||
|
'yield': yield_components,
|
||||||
|
'structural': structural_components
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
self.logger.log_risk_calculation(ticker, result.component_risks, final_nav_risk)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log_error(ticker, e)
|
||||||
|
if debug:
|
||||||
|
raise
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
raise CalculationError("No valid results generated")
|
||||||
|
|
||||||
|
# Calculate portfolio-level metrics
|
||||||
|
portfolio_nav_risk = np.mean([r.nav_erosion_risk for r in results])
|
||||||
|
portfolio_yield_risk = np.mean([r.yield_erosion_risk for r in results])
|
||||||
|
|
||||||
|
return NavErosionAnalysis(
|
||||||
|
results=results,
|
||||||
|
portfolio_nav_risk=portfolio_nav_risk,
|
||||||
|
portfolio_yield_risk=portfolio_yield_risk,
|
||||||
|
risk_summary=self._generate_portfolio_summary(results),
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
component_weights={
|
||||||
|
'nav': self.NAV_RISK_WEIGHT,
|
||||||
|
'yield': self.YIELD_RISK_WEIGHT,
|
||||||
|
'structural': self.STRUCTURAL_RISK_WEIGHT
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fetch_etf_data(self, ticker: str) -> Dict:
|
||||||
|
"""Fetch ETF data with fallback logic"""
|
||||||
|
try:
|
||||||
|
yf_ticker = yf.Ticker(ticker)
|
||||||
|
|
||||||
|
# Get basic info
|
||||||
|
info = yf_ticker.info
|
||||||
|
if not info:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get historical data
|
||||||
|
hist = yf_ticker.history(period="5y")
|
||||||
|
if hist.empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get dividends
|
||||||
|
dividends = yf_ticker.dividends
|
||||||
|
if dividends is None or dividends.empty:
|
||||||
|
dividends = pd.Series()
|
||||||
|
|
||||||
|
# Calculate metrics
|
||||||
|
returns = hist['Close'].pct_change().dropna()
|
||||||
|
volatility = returns.std() * np.sqrt(252) # Annualized
|
||||||
|
|
||||||
|
# Calculate max drawdown
|
||||||
|
rolling_max = hist['Close'].rolling(window=252, min_periods=1).max()
|
||||||
|
daily_drawdown = hist['Close'] / rolling_max - 1.0
|
||||||
|
max_drawdown = abs(daily_drawdown.min())
|
||||||
|
|
||||||
|
# Calculate Sharpe and Sortino ratios
|
||||||
|
risk_free_rate = 0.02 # Assuming 2% risk-free rate
|
||||||
|
excess_returns = returns - risk_free_rate/252
|
||||||
|
sharpe_ratio = np.sqrt(252) * excess_returns.mean() / returns.std()
|
||||||
|
|
||||||
|
# Sortino ratio (using negative returns only)
|
||||||
|
negative_returns = returns[returns < 0]
|
||||||
|
sortino_ratio = np.sqrt(252) * excess_returns.mean() / negative_returns.std() if len(negative_returns) > 0 else 0
|
||||||
|
|
||||||
|
# Calculate dividend trend
|
||||||
|
if not dividends.empty:
|
||||||
|
monthly_div = dividends.resample('M').sum()
|
||||||
|
if len(monthly_div) > 12:
|
||||||
|
earliest_ttm = monthly_div[-12:].sum()
|
||||||
|
latest_ttm = monthly_div[-1:].sum()
|
||||||
|
dividend_trend = (latest_ttm / earliest_ttm - 1) if earliest_ttm > 0 else 0
|
||||||
|
else:
|
||||||
|
dividend_trend = 0
|
||||||
|
else:
|
||||||
|
dividend_trend = 0
|
||||||
|
|
||||||
|
# Calculate ETF age
|
||||||
|
inception_date = info.get('fundInceptionDate')
|
||||||
|
if inception_date:
|
||||||
|
try:
|
||||||
|
inception_date_dt = pd.to_datetime(inception_date, unit='s', utc=True)
|
||||||
|
age_years = (pd.Timestamp.now(tz='UTC') - inception_date_dt).days / 365.25
|
||||||
|
except:
|
||||||
|
age_years = None
|
||||||
|
else:
|
||||||
|
age_years = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'info': info,
|
||||||
|
'hist': hist,
|
||||||
|
'dividends': dividends,
|
||||||
|
'volatility': volatility,
|
||||||
|
'max_drawdown': max_drawdown,
|
||||||
|
'sharpe_ratio': sharpe_ratio,
|
||||||
|
'sortino_ratio': sortino_ratio,
|
||||||
|
'dividend_trend': dividend_trend,
|
||||||
|
'age_years': age_years,
|
||||||
|
'is_new': age_years is not None and age_years < 2
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.log_error(ticker, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _calculate_nav_risk(self, etf_data: Dict) -> Tuple[float, Dict]:
|
||||||
|
"""Calculate NAV risk components"""
|
||||||
|
components = {}
|
||||||
|
|
||||||
|
# Drawdown risk
|
||||||
|
if etf_data.get('max_drawdown') is not None:
|
||||||
|
if etf_data['max_drawdown'] > 0.40:
|
||||||
|
components['drawdown'] = 7
|
||||||
|
elif etf_data['max_drawdown'] > 0.25:
|
||||||
|
components['drawdown'] = 5
|
||||||
|
elif etf_data['max_drawdown'] > 0.15:
|
||||||
|
components['drawdown'] = 3
|
||||||
|
else:
|
||||||
|
components['drawdown'] = 2
|
||||||
|
else:
|
||||||
|
components['drawdown'] = 4 # Default medium-low
|
||||||
|
|
||||||
|
# Volatility risk
|
||||||
|
if etf_data.get('volatility') is not None:
|
||||||
|
if etf_data['volatility'] > 0.40:
|
||||||
|
components['volatility'] = 7
|
||||||
|
elif etf_data['volatility'] > 0.25:
|
||||||
|
components['volatility'] = 5
|
||||||
|
elif etf_data['volatility'] > 0.15:
|
||||||
|
components['volatility'] = 3
|
||||||
|
else:
|
||||||
|
components['volatility'] = 2
|
||||||
|
else:
|
||||||
|
components['volatility'] = 4 # Default medium-low
|
||||||
|
|
||||||
|
# Sharpe ratio risk
|
||||||
|
if etf_data.get('sharpe_ratio') is not None:
|
||||||
|
if etf_data['sharpe_ratio'] >= 2.0:
|
||||||
|
components['sharpe'] = 1
|
||||||
|
elif etf_data['sharpe_ratio'] >= 1.5:
|
||||||
|
components['sharpe'] = 2
|
||||||
|
elif etf_data['sharpe_ratio'] >= 1.0:
|
||||||
|
components['sharpe'] = 3
|
||||||
|
elif etf_data['sharpe_ratio'] >= 0.5:
|
||||||
|
components['sharpe'] = 4
|
||||||
|
else:
|
||||||
|
components['sharpe'] = 5
|
||||||
|
else:
|
||||||
|
components['sharpe'] = 4 # Default medium
|
||||||
|
|
||||||
|
# Sortino ratio risk
|
||||||
|
if etf_data.get('sortino_ratio') is not None:
|
||||||
|
if etf_data['sortino_ratio'] >= 2.0:
|
||||||
|
components['sortino'] = 1
|
||||||
|
elif etf_data['sortino_ratio'] >= 1.5:
|
||||||
|
components['sortino'] = 2
|
||||||
|
elif etf_data['sortino_ratio'] >= 1.0:
|
||||||
|
components['sortino'] = 3
|
||||||
|
elif etf_data['sortino_ratio'] >= 0.5:
|
||||||
|
components['sortino'] = 4
|
||||||
|
else:
|
||||||
|
components['sortino'] = 5
|
||||||
|
else:
|
||||||
|
components['sortino'] = 4 # Default medium
|
||||||
|
|
||||||
|
# Calculate weighted NAV risk
|
||||||
|
nav_risk = sum(
|
||||||
|
components[component] * weight
|
||||||
|
for component, weight in self.NAV_COMPONENT_WEIGHTS.items()
|
||||||
|
) * self.NAV_RISK_WEIGHT
|
||||||
|
|
||||||
|
return nav_risk, components
|
||||||
|
|
||||||
|
def _calculate_yield_risk(self, etf_data: Dict) -> Tuple[float, Dict]:
|
||||||
|
"""Calculate yield risk components"""
|
||||||
|
components = {}
|
||||||
|
|
||||||
|
# Dividend stability risk
|
||||||
|
if etf_data.get('dividend_trend') is not None:
|
||||||
|
if etf_data['dividend_trend'] < -0.30:
|
||||||
|
components['stability'] = 8
|
||||||
|
elif etf_data['dividend_trend'] < -0.15:
|
||||||
|
components['stability'] = 6
|
||||||
|
elif etf_data['dividend_trend'] < -0.05:
|
||||||
|
components['stability'] = 4
|
||||||
|
elif etf_data['dividend_trend'] > 0.10:
|
||||||
|
components['stability'] = 2
|
||||||
|
else:
|
||||||
|
components['stability'] = 3
|
||||||
|
else:
|
||||||
|
components['stability'] = 4 # Default medium
|
||||||
|
|
||||||
|
# Dividend growth risk
|
||||||
|
if etf_data.get('dividend_trend') is not None:
|
||||||
|
if etf_data['dividend_trend'] > 0.10:
|
||||||
|
components['growth'] = 2
|
||||||
|
elif etf_data['dividend_trend'] > 0.05:
|
||||||
|
components['growth'] = 3
|
||||||
|
elif etf_data['dividend_trend'] < -0.10:
|
||||||
|
components['growth'] = 6
|
||||||
|
elif etf_data['dividend_trend'] < -0.05:
|
||||||
|
components['growth'] = 4
|
||||||
|
else:
|
||||||
|
components['growth'] = 3
|
||||||
|
else:
|
||||||
|
components['growth'] = 4 # Default medium
|
||||||
|
|
||||||
|
# Payout ratio risk (using dividend yield as proxy)
|
||||||
|
if etf_data.get('info', {}).get('dividendYield') is not None:
|
||||||
|
yield_value = etf_data['info']['dividendYield']
|
||||||
|
if yield_value > 0.08:
|
||||||
|
components['payout'] = 7
|
||||||
|
elif yield_value > 0.05:
|
||||||
|
components['payout'] = 5
|
||||||
|
elif yield_value > 0.03:
|
||||||
|
components['payout'] = 3
|
||||||
|
else:
|
||||||
|
components['payout'] = 2
|
||||||
|
else:
|
||||||
|
components['payout'] = 4 # Default medium
|
||||||
|
|
||||||
|
# Calculate weighted yield risk
|
||||||
|
yield_risk = sum(
|
||||||
|
components[component] * weight
|
||||||
|
for component, weight in self.YIELD_COMPONENT_WEIGHTS.items()
|
||||||
|
) * self.YIELD_RISK_WEIGHT
|
||||||
|
|
||||||
|
return yield_risk, components
|
||||||
|
|
||||||
|
def _calculate_structural_risk(self, etf_data: Dict) -> Tuple[float, Dict]:
|
||||||
|
"""Calculate structural risk components"""
|
||||||
|
components = {}
|
||||||
|
|
||||||
|
# Age risk
|
||||||
|
if etf_data.get('is_new'):
|
||||||
|
components['age'] = 7
|
||||||
|
elif etf_data.get('age_years') is not None:
|
||||||
|
if etf_data['age_years'] < 3:
|
||||||
|
components['age'] = 6
|
||||||
|
elif etf_data['age_years'] < 5:
|
||||||
|
components['age'] = 4
|
||||||
|
else:
|
||||||
|
components['age'] = 2
|
||||||
|
else:
|
||||||
|
components['age'] = 4 # Default medium
|
||||||
|
|
||||||
|
# AUM risk
|
||||||
|
if etf_data.get('info', {}).get('totalAssets') is not None:
|
||||||
|
aum = etf_data['info']['totalAssets']
|
||||||
|
if aum < 100_000_000: # Less than $100M
|
||||||
|
components['aum'] = 7
|
||||||
|
elif aum < 500_000_000: # Less than $500M
|
||||||
|
components['aum'] = 5
|
||||||
|
elif aum < 1_000_000_000: # Less than $1B
|
||||||
|
components['aum'] = 3
|
||||||
|
else:
|
||||||
|
components['aum'] = 2
|
||||||
|
else:
|
||||||
|
components['aum'] = 4 # Default medium
|
||||||
|
|
||||||
|
# Liquidity risk (using average volume as proxy)
|
||||||
|
if etf_data.get('info', {}).get('averageVolume') is not None:
|
||||||
|
volume = etf_data['info']['averageVolume']
|
||||||
|
if volume < 100_000:
|
||||||
|
components['liquidity'] = 7
|
||||||
|
elif volume < 500_000:
|
||||||
|
components['liquidity'] = 5
|
||||||
|
elif volume < 1_000_000:
|
||||||
|
components['liquidity'] = 3
|
||||||
|
else:
|
||||||
|
components['liquidity'] = 2
|
||||||
|
else:
|
||||||
|
components['liquidity'] = 4 # Default medium
|
||||||
|
|
||||||
|
# Expense ratio risk
|
||||||
|
if etf_data.get('info', {}).get('annualReportExpenseRatio') is not None:
|
||||||
|
expense_ratio = etf_data['info']['annualReportExpenseRatio']
|
||||||
|
if expense_ratio > 0.0075: # > 0.75%
|
||||||
|
components['expense'] = 7
|
||||||
|
elif expense_ratio > 0.005: # > 0.50%
|
||||||
|
components['expense'] = 5
|
||||||
|
elif expense_ratio > 0.0025: # > 0.25%
|
||||||
|
components['expense'] = 3
|
||||||
|
else:
|
||||||
|
components['expense'] = 2
|
||||||
|
else:
|
||||||
|
components['expense'] = 4 # Default medium
|
||||||
|
|
||||||
|
# Calculate weighted structural risk
|
||||||
|
structural_risk = sum(
|
||||||
|
components[component] * weight
|
||||||
|
for component, weight in self.STRUCTURAL_COMPONENT_WEIGHTS.items()
|
||||||
|
) * self.STRUCTURAL_RISK_WEIGHT
|
||||||
|
|
||||||
|
return structural_risk, components
|
||||||
|
|
||||||
|
def _calculate_final_risk(self, nav_risk: float, yield_risk: float, structural_risk: float) -> float:
|
||||||
|
"""Calculate final risk score"""
|
||||||
|
return nav_risk + yield_risk + structural_risk
|
||||||
|
|
||||||
|
def _generate_nav_explanation(self, components: Dict) -> str:
|
||||||
|
"""Generate explanation for NAV risk"""
|
||||||
|
explanations = []
|
||||||
|
|
||||||
|
if components.get('drawdown') is not None:
|
||||||
|
explanations.append(f"Drawdown risk level: {components['drawdown']}/9")
|
||||||
|
if components.get('volatility') is not None:
|
||||||
|
explanations.append(f"Volatility risk level: {components['volatility']}/9")
|
||||||
|
if components.get('sharpe') is not None:
|
||||||
|
explanations.append(f"Sharpe ratio risk level: {components['sharpe']}/9")
|
||||||
|
if components.get('sortino') is not None:
|
||||||
|
explanations.append(f"Sortino ratio risk level: {components['sortino']}/9")
|
||||||
|
|
||||||
|
return " | ".join(explanations)
|
||||||
|
|
||||||
|
def _generate_yield_explanation(self, components: Dict) -> str:
|
||||||
|
"""Generate explanation for yield risk"""
|
||||||
|
explanations = []
|
||||||
|
|
||||||
|
if components.get('stability') is not None:
|
||||||
|
explanations.append(f"Dividend stability risk: {components['stability']}/9")
|
||||||
|
if components.get('growth') is not None:
|
||||||
|
explanations.append(f"Dividend growth risk: {components['growth']}/9")
|
||||||
|
if components.get('payout') is not None:
|
||||||
|
explanations.append(f"Payout ratio risk: {components['payout']}/9")
|
||||||
|
|
||||||
|
return " | ".join(explanations)
|
||||||
|
|
||||||
|
def _generate_portfolio_summary(self, results: List[NavErosionResult]) -> str:
|
||||||
|
"""Generate portfolio-level risk summary"""
|
||||||
|
nav_risks = [r.nav_erosion_risk for r in results]
|
||||||
|
yield_risks = [r.yield_erosion_risk for r in results]
|
||||||
|
|
||||||
|
avg_nav_risk = np.mean(nav_risks)
|
||||||
|
avg_yield_risk = np.mean(yield_risks)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"Portfolio NAV Risk: {avg_nav_risk:.1f}/9 | "
|
||||||
|
f"Portfolio Yield Risk: {avg_yield_risk:.1f}/9"
|
||||||
|
)
|
||||||
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
@ -2604,3 +2604,128 @@ if st.session_state.simulation_run and st.session_state.df_data is not None:
|
|||||||
- **Risk Mitigation**: DRIP helps mitigate erosion effects by continuously acquiring more shares
|
- **Risk Mitigation**: DRIP helps mitigate erosion effects by continuously acquiring more shares
|
||||||
- **Compounding Effect**: Reinvested dividends generate additional income through compounding
|
- **Compounding Effect**: Reinvested dividends generate additional income through compounding
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
with tab3:
|
||||||
|
st.subheader("📉 Erosion Risk Assessment")
|
||||||
|
|
||||||
|
# Add explanatory text
|
||||||
|
st.write("""
|
||||||
|
This analysis uses historical ETF data to estimate reasonable erosion settings
|
||||||
|
based on past performance, volatility, and dividend history.
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Initialize the NAV erosion service
|
||||||
|
try:
|
||||||
|
from ETF_Portal.services.nav_erosion_service import NavErosionService
|
||||||
|
|
||||||
|
# Run the analysis in a spinner
|
||||||
|
with st.spinner("Analyzing historical ETF data..."):
|
||||||
|
erosion_service = NavErosionService()
|
||||||
|
risk_analysis = erosion_service.analyze_etf_erosion_risk(final_alloc["Ticker"].tolist())
|
||||||
|
except ImportError as e:
|
||||||
|
st.error(f"Error importing NavErosionService: {str(e)}")
|
||||||
|
st.error("Please ensure the nav_erosion_service module is properly installed.")
|
||||||
|
logger.error(f"Import error: {str(e)}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
risk_analysis = None
|
||||||
|
|
||||||
|
if risk_analysis and risk_analysis.results:
|
||||||
|
# Create a summary table with key insights
|
||||||
|
risk_data = []
|
||||||
|
for result in risk_analysis.results:
|
||||||
|
risk_data.append({
|
||||||
|
"Ticker": result.ticker,
|
||||||
|
"NAV Erosion Risk (0-9)": result.nav_erosion_risk,
|
||||||
|
"Yield Erosion Risk (0-9)": result.yield_erosion_risk,
|
||||||
|
"Estimated Annual NAV Erosion": f"{result.estimated_nav_erosion:.1%}",
|
||||||
|
"Estimated Annual Yield Erosion": f"{result.estimated_yield_erosion:.1%}",
|
||||||
|
"NAV Risk Explanation": result.nav_risk_explanation,
|
||||||
|
"Yield Risk Explanation": result.yield_risk_explanation,
|
||||||
|
"ETF Age (Years)": f"{result.etf_age_years:.1f}" if result.etf_age_years else "Unknown",
|
||||||
|
"Max Drawdown": f"{result.max_drawdown:.1%}" if result.max_drawdown else "Unknown",
|
||||||
|
"Volatility": f"{result.volatility:.1%}" if result.volatility else "Unknown",
|
||||||
|
"Sharpe Ratio": f"{result.sharpe_ratio:.2f}" if result.sharpe_ratio else "Unknown",
|
||||||
|
"Sortino Ratio": f"{result.sortino_ratio:.2f}" if result.sortino_ratio else "Unknown",
|
||||||
|
"Dividend Trend": f"{result.dividend_trend:.1%}" if result.dividend_trend else "Unknown"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Display main assessment table
|
||||||
|
st.subheader("Recommended Erosion Settings")
|
||||||
|
main_columns = [
|
||||||
|
"Ticker",
|
||||||
|
"NAV Erosion Risk (0-9)",
|
||||||
|
"Yield Erosion Risk (0-9)",
|
||||||
|
"Estimated Annual NAV Erosion",
|
||||||
|
"Estimated Annual Yield Erosion",
|
||||||
|
"NAV Risk Explanation",
|
||||||
|
"Yield Risk Explanation"
|
||||||
|
]
|
||||||
|
|
||||||
|
st.dataframe(
|
||||||
|
pd.DataFrame(risk_data)[main_columns],
|
||||||
|
use_container_width=True,
|
||||||
|
hide_index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display detailed metrics
|
||||||
|
st.subheader("Detailed Risk Metrics")
|
||||||
|
detail_columns = [
|
||||||
|
"Ticker",
|
||||||
|
"ETF Age (Years)",
|
||||||
|
"Max Drawdown",
|
||||||
|
"Volatility",
|
||||||
|
"Sharpe Ratio",
|
||||||
|
"Sortino Ratio",
|
||||||
|
"Dividend Trend"
|
||||||
|
]
|
||||||
|
|
||||||
|
st.dataframe(
|
||||||
|
pd.DataFrame(risk_data)[detail_columns],
|
||||||
|
use_container_width=True,
|
||||||
|
hide_index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Allow applying these settings to the simulation
|
||||||
|
if st.button("Apply Recommended Erosion Settings", type="primary"):
|
||||||
|
# Initialize or update per-ticker erosion settings
|
||||||
|
if "per_ticker_erosion" not in st.session_state or not isinstance(st.session_state.per_ticker_erosion, dict):
|
||||||
|
st.session_state.per_ticker_erosion = {}
|
||||||
|
|
||||||
|
# Update the session state with recommended settings
|
||||||
|
for result in risk_analysis.results:
|
||||||
|
st.session_state.per_ticker_erosion[result.ticker] = {
|
||||||
|
"nav": result.nav_erosion_risk,
|
||||||
|
"yield": result.yield_erosion_risk
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enable erosion and per-ticker settings
|
||||||
|
st.session_state.erosion_type = "NAV & Yield Erosion"
|
||||||
|
st.session_state.use_per_ticker_erosion = True
|
||||||
|
|
||||||
|
# Update the erosion_level variable to match the new settings
|
||||||
|
erosion_level = {
|
||||||
|
"global": {
|
||||||
|
"nav": 5, # Default medium level for global fallback
|
||||||
|
"yield": 5
|
||||||
|
},
|
||||||
|
"per_ticker": st.session_state.per_ticker_erosion,
|
||||||
|
"use_per_ticker": True
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update session state erosion level for DRIP forecast
|
||||||
|
st.session_state.erosion_level = erosion_level
|
||||||
|
|
||||||
|
st.success("Applied recommended erosion settings. They will be used in the DRIP forecast.")
|
||||||
|
st.info("Go to the DRIP Forecast tab to see the impact of these settings.")
|
||||||
|
else:
|
||||||
|
st.error("Unable to analyze ETF erosion risk. Please try again.")
|
||||||
|
|
||||||
|
with tab4:
|
||||||
|
st.subheader("🤖 AI Suggestions")
|
||||||
|
# Add AI suggestions content
|
||||||
|
st.write("This tab will contain AI suggestions for portfolio optimization.")
|
||||||
|
|
||||||
|
with tab5:
|
||||||
|
st.subheader("📊 ETF Details")
|
||||||
|
# Add ETF details content
|
||||||
|
st.write("This tab will contain detailed information about the selected ETFs.")
|
||||||
16
setup.py
16
setup.py
@ -7,15 +7,13 @@ setup(
|
|||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"streamlit>=1.28.0",
|
"streamlit",
|
||||||
"pandas>=1.5.3",
|
"pandas",
|
||||||
"numpy>=1.24.3",
|
"numpy",
|
||||||
"matplotlib>=3.7.1",
|
"plotly",
|
||||||
"seaborn>=0.12.2",
|
"yfinance",
|
||||||
"fmp-python>=0.1.5",
|
"requests",
|
||||||
"plotly>=5.14.1",
|
"python-dotenv"
|
||||||
"requests>=2.31.0",
|
|
||||||
"yfinance>=0.2.36",
|
|
||||||
],
|
],
|
||||||
entry_points={
|
entry_points={
|
||||||
"console_scripts": [
|
"console_scripts": [
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user