Add nav_erosion_service implementation and fix dividend trend calculation
This commit is contained in:
parent
f548dec7ec
commit
8bec6cd8e8
@ -273,23 +273,28 @@ class DataService:
|
|||||||
earliest_ttm = monthly_div[-12:].sum()
|
earliest_ttm = monthly_div[-12:].sum()
|
||||||
latest_ttm = monthly_div[-1:].sum()
|
latest_ttm = monthly_div[-1:].sum()
|
||||||
if earliest_ttm > 0:
|
if earliest_ttm > 0:
|
||||||
dividend_trend = (latest_ttm / earliest_ttm - 1)
|
dividend_trend = float((latest_ttm / earliest_ttm - 1))
|
||||||
else:
|
else:
|
||||||
dividend_trend = 0
|
dividend_trend = 0.0
|
||||||
else:
|
else:
|
||||||
# If less than 12 months of data, use the average
|
# If less than 12 months of data, use the average
|
||||||
dividend_trend = monthly_div.mean() if not monthly_div.empty else 0
|
dividend_trend = float(monthly_div.mean()) if not monthly_div.empty else 0.0
|
||||||
else:
|
else:
|
||||||
# Try to get dividend trend from info
|
# Try to get dividend trend from info
|
||||||
dividend_rate = info.get('dividendRate', 0)
|
dividend_rate = float(info.get('dividendRate', 0))
|
||||||
five_year_avg = info.get('fiveYearAvgDividendYield', 0)
|
five_year_avg = float(info.get('fiveYearAvgDividendYield', 0))
|
||||||
if dividend_rate > 0 and five_year_avg > 0:
|
if dividend_rate > 0 and five_year_avg > 0:
|
||||||
dividend_trend = (dividend_rate / five_year_avg - 1)
|
dividend_trend = float((dividend_rate / five_year_avg - 1))
|
||||||
else:
|
else:
|
||||||
dividend_trend = 0
|
dividend_trend = 0.0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error calculating dividend trend for {ticker}: {str(e)}")
|
logger.warning(f"Error calculating dividend trend for {ticker}: {str(e)}")
|
||||||
dividend_trend = 0
|
dividend_trend = 0.0
|
||||||
|
|
||||||
|
# Ensure dividend_trend is a valid float
|
||||||
|
dividend_trend = float(dividend_trend) if dividend_trend is not None else 0.0
|
||||||
|
if not isinstance(dividend_trend, (int, float)) or pd.isna(dividend_trend):
|
||||||
|
dividend_trend = 0.0
|
||||||
|
|
||||||
# Calculate ETF age
|
# Calculate ETF age
|
||||||
inception_date = info.get('fundInceptionDate')
|
inception_date = info.get('fundInceptionDate')
|
||||||
@ -303,7 +308,6 @@ class DataService:
|
|||||||
age_years = None
|
age_years = None
|
||||||
|
|
||||||
# Ensure all values are valid numbers and properly formatted
|
# Ensure all values are valid numbers and properly formatted
|
||||||
dividend_trend = float(dividend_trend) if dividend_trend is not None else 0.0
|
|
||||||
volatility = float(volatility) if volatility is not None else 0.0
|
volatility = float(volatility) if volatility is not None else 0.0
|
||||||
max_drawdown = float(max_drawdown) if max_drawdown is not None else 0.0
|
max_drawdown = float(max_drawdown) if max_drawdown is not None else 0.0
|
||||||
sharpe_ratio = float(sharpe_ratio) if sharpe_ratio is not None else 0.0
|
sharpe_ratio = float(sharpe_ratio) if sharpe_ratio is not None else 0.0
|
||||||
|
|||||||
218
ETF_Portal/services/nav_erosion_service.py
Normal file
218
ETF_Portal/services/nav_erosion_service.py
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
NAV Erosion Service for analyzing ETF erosion risk
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import logging
|
||||||
|
from .data_service import DataService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ErosionRiskResult:
|
||||||
|
"""Result of erosion risk analysis for a single ETF"""
|
||||||
|
ticker: str
|
||||||
|
nav_erosion_risk: float # 0-9 scale
|
||||||
|
yield_erosion_risk: float # 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] = None
|
||||||
|
max_drawdown: Optional[float] = None
|
||||||
|
volatility: Optional[float] = None
|
||||||
|
sharpe_ratio: Optional[float] = None
|
||||||
|
sortino_ratio: Optional[float] = None
|
||||||
|
dividend_trend: Optional[float] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ErosionRiskAnalysis:
|
||||||
|
"""Complete erosion risk analysis results"""
|
||||||
|
results: List[ErosionRiskResult]
|
||||||
|
timestamp: pd.Timestamp
|
||||||
|
|
||||||
|
class NavErosionService:
|
||||||
|
"""Service for analyzing ETF NAV and yield erosion risk"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.data_service = DataService()
|
||||||
|
|
||||||
|
def analyze_etf_erosion_risk(self, tickers: List[str]) -> ErosionRiskAnalysis:
|
||||||
|
"""
|
||||||
|
Analyze erosion risk for a list of ETFs
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tickers: List of ETF tickers to analyze
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ErosionRiskAnalysis object containing results for each ETF
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for ticker in tickers:
|
||||||
|
try:
|
||||||
|
# Get ETF data
|
||||||
|
etf_data = self.data_service.get_etf_data(ticker)
|
||||||
|
if not etf_data:
|
||||||
|
logger.warning(f"No data available for {ticker}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate NAV erosion risk
|
||||||
|
nav_risk = self._calculate_nav_erosion_risk(etf_data)
|
||||||
|
|
||||||
|
# Calculate yield erosion risk
|
||||||
|
yield_risk = self._calculate_yield_erosion_risk(etf_data)
|
||||||
|
|
||||||
|
# Create result object
|
||||||
|
result = ErosionRiskResult(
|
||||||
|
ticker=ticker,
|
||||||
|
nav_erosion_risk=nav_risk['score'],
|
||||||
|
yield_erosion_risk=yield_risk['score'],
|
||||||
|
estimated_nav_erosion=nav_risk['estimated_erosion'],
|
||||||
|
estimated_yield_erosion=yield_risk['estimated_erosion'],
|
||||||
|
nav_risk_explanation=nav_risk['explanation'],
|
||||||
|
yield_risk_explanation=yield_risk['explanation'],
|
||||||
|
etf_age_years=etf_data.get('age_years'),
|
||||||
|
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')
|
||||||
|
)
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error analyzing {ticker}: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return ErosionRiskAnalysis(
|
||||||
|
results=results,
|
||||||
|
timestamp=pd.Timestamp.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calculate_nav_erosion_risk(self, etf_data: Dict) -> Dict:
|
||||||
|
"""
|
||||||
|
Calculate NAV erosion risk score and explanation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
etf_data: Dictionary containing ETF data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with risk score, estimated erosion, and explanation
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get relevant metrics
|
||||||
|
volatility = float(etf_data.get('volatility', 0))
|
||||||
|
max_drawdown = float(etf_data.get('max_drawdown', 0))
|
||||||
|
sharpe_ratio = float(etf_data.get('sharpe_ratio', 0))
|
||||||
|
sortino_ratio = float(etf_data.get('sortino_ratio', 0))
|
||||||
|
age_years = float(etf_data.get('age_years', 0))
|
||||||
|
|
||||||
|
# Calculate risk score components
|
||||||
|
volatility_score = min(9, int(volatility * 20)) # Scale volatility to 0-9
|
||||||
|
drawdown_score = min(9, int(max_drawdown * 20)) # Scale drawdown to 0-9
|
||||||
|
risk_adjusted_score = min(9, int((2 - min(2, max(0, sharpe_ratio))) * 4.5)) # Scale Sharpe to 0-9
|
||||||
|
age_score = min(9, int((5 - min(5, age_years)) * 1.8)) # Scale age to 0-9
|
||||||
|
|
||||||
|
# Calculate final risk score (weighted average)
|
||||||
|
risk_score = (
|
||||||
|
volatility_score * 0.3 +
|
||||||
|
drawdown_score * 0.3 +
|
||||||
|
risk_adjusted_score * 0.2 +
|
||||||
|
age_score * 0.2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Estimate annual erosion based on risk score
|
||||||
|
estimated_erosion = risk_score * 0.01 # 1% per risk point
|
||||||
|
|
||||||
|
# Generate explanation
|
||||||
|
explanation = []
|
||||||
|
if volatility_score > 6:
|
||||||
|
explanation.append(f"High volatility ({volatility:.1%})")
|
||||||
|
if drawdown_score > 6:
|
||||||
|
explanation.append(f"Large drawdowns ({max_drawdown:.1%})")
|
||||||
|
if risk_adjusted_score > 6:
|
||||||
|
explanation.append(f"Poor risk-adjusted returns (Sharpe: {sharpe_ratio:.2f})")
|
||||||
|
if age_score > 6:
|
||||||
|
explanation.append(f"New ETF ({age_years:.1f} years)")
|
||||||
|
|
||||||
|
explanation = ", ".join(explanation) if explanation else "Moderate risk profile"
|
||||||
|
|
||||||
|
return {
|
||||||
|
'score': float(risk_score),
|
||||||
|
'estimated_erosion': float(estimated_erosion),
|
||||||
|
'explanation': explanation
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating NAV erosion risk: {str(e)}")
|
||||||
|
return {
|
||||||
|
'score': 5.0, # Default to middle risk
|
||||||
|
'estimated_erosion': 0.05, # Default to 5%
|
||||||
|
'explanation': "Unable to calculate precise risk"
|
||||||
|
}
|
||||||
|
|
||||||
|
def _calculate_yield_erosion_risk(self, etf_data: Dict) -> Dict:
|
||||||
|
"""
|
||||||
|
Calculate yield erosion risk score and explanation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
etf_data: Dictionary containing ETF data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with risk score, estimated erosion, and explanation
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get relevant metrics
|
||||||
|
dividend_trend = float(etf_data.get('dividend_trend', 0))
|
||||||
|
volatility = float(etf_data.get('volatility', 0))
|
||||||
|
max_drawdown = float(etf_data.get('max_drawdown', 0))
|
||||||
|
age_years = float(etf_data.get('age_years', 0))
|
||||||
|
|
||||||
|
# Calculate risk score components
|
||||||
|
trend_score = min(9, int((1 - min(1, max(-1, dividend_trend))) * 4.5)) # Scale trend to 0-9
|
||||||
|
volatility_score = min(9, int(volatility * 20)) # Scale volatility to 0-9
|
||||||
|
drawdown_score = min(9, int(max_drawdown * 20)) # Scale drawdown to 0-9
|
||||||
|
age_score = min(9, int((5 - min(5, age_years)) * 1.8)) # Scale age to 0-9
|
||||||
|
|
||||||
|
# Calculate final risk score (weighted average)
|
||||||
|
risk_score = (
|
||||||
|
trend_score * 0.3 +
|
||||||
|
volatility_score * 0.3 +
|
||||||
|
drawdown_score * 0.2 +
|
||||||
|
age_score * 0.2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Estimate annual erosion based on risk score
|
||||||
|
estimated_erosion = risk_score * 0.01 # 1% per risk point
|
||||||
|
|
||||||
|
# Generate explanation
|
||||||
|
explanation = []
|
||||||
|
if trend_score > 6:
|
||||||
|
explanation.append(f"Declining dividends ({dividend_trend:.1%})")
|
||||||
|
if volatility_score > 6:
|
||||||
|
explanation.append(f"High volatility ({volatility:.1%})")
|
||||||
|
if drawdown_score > 6:
|
||||||
|
explanation.append(f"Large drawdowns ({max_drawdown:.1%})")
|
||||||
|
if age_score > 6:
|
||||||
|
explanation.append(f"New ETF ({age_years:.1f} years)")
|
||||||
|
|
||||||
|
explanation = ", ".join(explanation) if explanation else "Moderate risk profile"
|
||||||
|
|
||||||
|
return {
|
||||||
|
'score': float(risk_score),
|
||||||
|
'estimated_erosion': float(estimated_erosion),
|
||||||
|
'explanation': explanation
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating yield erosion risk: {str(e)}")
|
||||||
|
return {
|
||||||
|
'score': 5.0, # Default to middle risk
|
||||||
|
'estimated_erosion': 0.05, # Default to 5%
|
||||||
|
'explanation': "Unable to calculate precise risk"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user