ETF_Suite_Portal/services/nav_erosion_service/service.py

209 lines
8.3 KiB
Python

from typing import Dict, List, Tuple
from .models import NavErosionConfig, NavErosionResult, NavErosionAnalysis
from enum import Enum
from dataclasses import dataclass
import streamlit as st
class ETFType(Enum):
INCOME = "Income"
GROWTH = "Growth"
BALANCED = "Balanced"
@dataclass
class NavErosionResult:
"""Result of NAV erosion analysis for a single ETF"""
ticker: str
nav_erosion_rate: float # Annual NAV erosion rate
yield_erosion_rate: float # Annual yield erosion rate
monthly_nav_erosion_rate: float # Monthly NAV erosion rate
monthly_yield_erosion_rate: float # Monthly yield erosion rate
risk_level: int
risk_explanation: str
max_drawdown: float
volatility: float
is_new_etf: bool
etf_age_years: float
@dataclass
class NavErosionAnalysis:
"""Complete NAV erosion analysis results"""
results: List[NavErosionResult]
portfolio_nav_risk: float = 0.0
portfolio_erosion_rate: float = 0.0
risk_summary: str = ""
class NavErosionService:
def __init__(self):
self.NAV_COMPONENT_WEIGHTS = {
'drawdown': 0.4,
'volatility': 0.3,
'sharpe': 0.15,
'sortino': 0.15
}
# Default erosion rates based on risk level (0-9)
self.RISK_TO_EROSION = {
0: 0.01, # 1% annual
1: 0.02, # 2% annual
2: 0.03, # 3% annual
3: 0.04, # 4% annual
4: 0.05, # 5% annual
5: 0.06, # 6% annual
6: 0.07, # 7% annual
7: 0.08, # 8% annual
8: 0.09, # 9% annual
9: 0.10 # 10% annual
}
def analyze_etf_erosion_risk(self, tickers: List[str]) -> NavErosionAnalysis:
"""Analyze NAV erosion risk for a list of ETFs"""
results = []
print("\n=== NAV EROSION SERVICE DEBUG ===")
print(f"Session state keys: {st.session_state.keys()}")
print(f"Erosion level from session state: {st.session_state.get('erosion_level')}")
for ticker in tickers:
# Get erosion rates from session state
erosion_level = st.session_state.get('erosion_level', {'nav': 5.0, 'yield': 5.0})
annual_nav_erosion = erosion_level['nav'] / 100 # Convert from percentage to decimal
annual_yield_erosion = erosion_level['yield'] / 100 # Convert from percentage to decimal
# Convert annual rates to monthly
monthly_nav_erosion = 1 - (1 - annual_nav_erosion) ** (1/12)
monthly_yield_erosion = 1 - (1 - annual_yield_erosion) ** (1/12)
print(f"\n=== NAV EROSION SERVICE DEBUG ===")
print(f"Ticker: {ticker}")
print(f"Session State Values:")
print(f" Annual NAV Erosion: {annual_nav_erosion:.4%}")
print(f" Annual Yield Erosion: {annual_yield_erosion:.4%}")
print(f" Monthly NAV Erosion: {monthly_nav_erosion:.4%}")
print(f" Monthly Yield Erosion: {monthly_yield_erosion:.4%}")
print(f"=== END NAV EROSION SERVICE DEBUG ===\n")
result = NavErosionResult(
ticker=ticker,
nav_erosion_rate=annual_nav_erosion,
yield_erosion_rate=annual_yield_erosion,
monthly_nav_erosion_rate=monthly_nav_erosion,
monthly_yield_erosion_rate=monthly_yield_erosion,
risk_level=5, # Arbitrary risk level
risk_explanation="Using erosion rates from session state",
max_drawdown=0.2,
volatility=0.25,
is_new_etf=False,
etf_age_years=1.0
)
results.append(result)
print(f"Created NavErosionResult for {ticker}:")
print(f" monthly_nav_erosion_rate: {result.monthly_nav_erosion_rate:.4%}")
print(f" monthly_yield_erosion_rate: {result.monthly_yield_erosion_rate:.4%}")
# Calculate portfolio-level metrics
portfolio_nav_risk = sum(r.risk_level for r in results) / len(results)
portfolio_erosion_rate = sum(r.nav_erosion_rate for r in results) / len(results)
analysis = NavErosionAnalysis(
results=results,
portfolio_nav_risk=portfolio_nav_risk,
portfolio_erosion_rate=portfolio_erosion_rate,
risk_summary="Portfolio has moderate NAV erosion risk"
)
print("\nFinal NavErosionAnalysis:")
for r in analysis.results:
print(f" {r.ticker}:")
print(f" monthly_nav_erosion_rate: {r.monthly_nav_erosion_rate:.4%}")
print(f" monthly_yield_erosion_rate: {r.monthly_yield_erosion_rate:.4%}")
print("=== END NAV EROSION SERVICE DEBUG ===\n")
return analysis
def _calculate_nav_risk(self, etf_data: Dict, etf_type: ETFType) -> Tuple[float, Dict]:
"""Calculate NAV risk components with ETF-type specific adjustments"""
components = {}
# Base risk calculation with ETF-type specific thresholds
if etf_data.get('max_drawdown') is not None:
if etf_type == ETFType.INCOME:
# Income ETFs typically have lower drawdowns
if etf_data['max_drawdown'] > 0.25:
components['drawdown'] = 7
elif etf_data['max_drawdown'] > 0.15:
components['drawdown'] = 5
elif etf_data['max_drawdown'] > 0.10:
components['drawdown'] = 3
else:
components['drawdown'] = 2
elif etf_type == ETFType.GROWTH:
# Growth ETFs typically have higher drawdowns
if etf_data['max_drawdown'] > 0.35:
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: # BALANCED
# Balanced ETFs have moderate drawdowns
if etf_data['max_drawdown'] > 0.30:
components['drawdown'] = 7
elif etf_data['max_drawdown'] > 0.20:
components['drawdown'] = 5
elif etf_data['max_drawdown'] > 0.12:
components['drawdown'] = 3
else:
components['drawdown'] = 2
else:
components['drawdown'] = 4 # Default medium risk if no data
# Rest of the method remains unchanged
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
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
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
# Calculate weighted NAV risk
nav_risk = sum(
components[component] * weight
for component, weight in self.NAV_COMPONENT_WEIGHTS.items()
)
return nav_risk, components