fixing Nav_erosion_service and drip_service services communication
This commit is contained in:
parent
c6797c94ee
commit
30e1bbcbd9
@ -67,6 +67,7 @@ class DRIPService:
|
||||
self.MAX_EROSION_LEVEL = 9
|
||||
self.MAX_MONTHLY_EROSION = 0.05 # 5% monthly max erosion
|
||||
self.DISTRIBUTION_FREQUENCIES = {freq.display_name: freq for freq in DistributionFrequency}
|
||||
self.nav_erosion_service = NavErosionService()
|
||||
|
||||
def calculate_drip_growth(self, portfolio_df: pd.DataFrame, config: DripConfig) -> DripResult:
|
||||
"""
|
||||
@ -85,8 +86,24 @@ class DRIPService:
|
||||
|
||||
# Initialize portfolio data
|
||||
ticker_data = self._initialize_ticker_data(portfolio_df)
|
||||
|
||||
# Handle erosion configuration
|
||||
erosion_config = self._parse_erosion_config(config)
|
||||
|
||||
# If erosion is requested but no proper erosion_level is provided, calculate it
|
||||
if (config.erosion_type != "None" and
|
||||
(not hasattr(config, 'erosion_level') or
|
||||
not isinstance(config.erosion_level, dict) or
|
||||
"per_ticker" not in config.erosion_level)):
|
||||
|
||||
logger.info(f"Calculating erosion rates for erosion type: {config.erosion_type}")
|
||||
tickers = list(ticker_data.keys())
|
||||
calculated_erosion = self.calculate_erosion_from_analysis(tickers)
|
||||
erosion_config = ErosionConfig(
|
||||
erosion_type=config.erosion_type,
|
||||
erosion_level=calculated_erosion
|
||||
)
|
||||
|
||||
# Pre-calculate distribution schedule for performance
|
||||
distribution_schedule = self._create_distribution_schedule(ticker_data, config.months)
|
||||
|
||||
@ -203,10 +220,46 @@ class DRIPService:
|
||||
if not hasattr(config, 'erosion_level') or config.erosion_type == "None":
|
||||
return ErosionConfig(erosion_type="None", erosion_level={})
|
||||
|
||||
# Check if erosion_level is already in the correct format
|
||||
if isinstance(config.erosion_level, dict) and "per_ticker" in config.erosion_level:
|
||||
return ErosionConfig(
|
||||
erosion_type=config.erosion_type,
|
||||
erosion_level=config.erosion_level
|
||||
)
|
||||
|
||||
# If erosion_level is not in the correct format, it might be a NavErosionAnalysis
|
||||
# or we need to calculate it from scratch
|
||||
return ErosionConfig(
|
||||
erosion_type=config.erosion_type,
|
||||
erosion_level=config.erosion_level
|
||||
)
|
||||
|
||||
def calculate_erosion_from_analysis(self, tickers: List[str]) -> Dict:
|
||||
"""
|
||||
Calculate erosion rates using NavErosionService
|
||||
|
||||
Args:
|
||||
tickers: List of ticker symbols to analyze
|
||||
|
||||
Returns:
|
||||
Dict in format expected by _apply_monthly_erosion
|
||||
"""
|
||||
try:
|
||||
# Use NavErosionService to analyze the tickers
|
||||
analysis = self.nav_erosion_service.analyze_etf_erosion_risk(tickers)
|
||||
|
||||
# Convert to format expected by DRIP service
|
||||
erosion_config = self.nav_erosion_service.convert_to_drip_erosion_config(analysis)
|
||||
|
||||
logger.info(f"Calculated erosion rates for tickers: {tickers}")
|
||||
logger.info(f"Erosion configuration: {erosion_config}")
|
||||
|
||||
return erosion_config
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating erosion rates: {str(e)}")
|
||||
logger.warning("Falling back to no erosion")
|
||||
return {"per_ticker": {ticker: {"nav": 0.0, "yield": 0.0} for ticker in tickers}}
|
||||
|
||||
def _normalize_erosion_rate(self, erosion_level: float) -> float:
|
||||
"""Convert erosion level (0-9) to monthly rate with validation"""
|
||||
@ -269,29 +322,56 @@ class DRIPService:
|
||||
return monthly_income
|
||||
|
||||
def _apply_monthly_erosion(
|
||||
self,
|
||||
state: Dict[str, Any],
|
||||
erosion_config: ErosionConfig,
|
||||
self,
|
||||
state: Dict[str, Any],
|
||||
erosion_config: ErosionConfig,
|
||||
tickers: List[str]
|
||||
) -> None:
|
||||
"""Apply monthly erosion to prices and yields"""
|
||||
if erosion_config.erosion_type == "None":
|
||||
return
|
||||
|
||||
# Validate erosion configuration structure
|
||||
if not isinstance(erosion_config.erosion_level, dict):
|
||||
logger.warning(f"Invalid erosion_level format: {type(erosion_config.erosion_level)}")
|
||||
return
|
||||
|
||||
per_ticker_data = erosion_config.erosion_level.get("per_ticker", {})
|
||||
if not per_ticker_data:
|
||||
logger.warning("No per_ticker erosion data found in erosion_level")
|
||||
return
|
||||
|
||||
for ticker in tickers:
|
||||
# Get per-ticker erosion rates
|
||||
ticker_rates = erosion_config.erosion_level.get("per_ticker", {}).get(ticker, {})
|
||||
nav_rate = ticker_rates.get("nav", 0.0) # Already in decimal form
|
||||
yield_rate = ticker_rates.get("yield", 0.0) # Already in decimal form
|
||||
# Get per-ticker erosion rates with fallback
|
||||
ticker_rates = per_ticker_data.get(ticker, {})
|
||||
|
||||
# Apply erosion directly
|
||||
if not ticker_rates:
|
||||
logger.warning(f"No erosion rates found for ticker {ticker}, skipping erosion")
|
||||
continue
|
||||
|
||||
nav_rate = ticker_rates.get("nav", 0.0) # Monthly rate in decimal form
|
||||
yield_rate = ticker_rates.get("yield", 0.0) # Monthly rate in decimal form
|
||||
|
||||
# Validate rates are reasonable (0 to 5% monthly max)
|
||||
nav_rate = max(0.0, min(nav_rate, self.MAX_MONTHLY_EROSION))
|
||||
yield_rate = max(0.0, min(yield_rate, self.MAX_MONTHLY_EROSION))
|
||||
|
||||
# Store original values for logging
|
||||
original_price = state['current_prices'][ticker]
|
||||
original_yield = state['current_yields'][ticker]
|
||||
|
||||
# Apply erosion directly (rates are already monthly)
|
||||
state['current_prices'][ticker] *= (1 - nav_rate)
|
||||
state['current_yields'][ticker] *= (1 - yield_rate)
|
||||
|
||||
# Ensure prices and yields don't go below reasonable minimums
|
||||
state['current_prices'][ticker] = max(state['current_prices'][ticker], 0.01)
|
||||
state['current_yields'][ticker] = max(state['current_yields'][ticker], 0.0)
|
||||
|
||||
# Log erosion application
|
||||
logger.info(f"Applied erosion to {ticker}:")
|
||||
logger.info(f" NAV: {nav_rate:.4%} -> New price: ${state['current_prices'][ticker]:.2f}")
|
||||
logger.info(f" Yield: {yield_rate:.4%} -> New yield: {state['current_yields'][ticker]:.2%}")
|
||||
logger.info(f"Applied monthly erosion to {ticker}:")
|
||||
logger.info(f" NAV: {nav_rate:.4%} -> Price: ${original_price:.2f} -> ${state['current_prices'][ticker]:.2f}")
|
||||
logger.info(f" Yield: {yield_rate:.4%} -> Yield: {original_yield:.2%} -> {state['current_yields'][ticker]:.2%}")
|
||||
|
||||
def _reinvest_dividends(
|
||||
self,
|
||||
@ -378,7 +458,24 @@ class DRIPService:
|
||||
def _calculate_no_drip_scenario(self, portfolio_df: pd.DataFrame, config: DripConfig) -> Dict[str, float]:
|
||||
"""Calculate scenario where dividends are not reinvested"""
|
||||
ticker_data = self._initialize_ticker_data(portfolio_df)
|
||||
|
||||
# Handle erosion configuration same way as main calculation
|
||||
erosion_config = self._parse_erosion_config(config)
|
||||
|
||||
# If erosion is requested but no proper erosion_level is provided, calculate it
|
||||
if (config.erosion_type != "None" and
|
||||
(not hasattr(config, 'erosion_level') or
|
||||
not isinstance(config.erosion_level, dict) or
|
||||
"per_ticker" not in config.erosion_level)):
|
||||
|
||||
logger.info(f"Calculating erosion rates for no-DRIP scenario with erosion type: {config.erosion_type}")
|
||||
tickers = list(ticker_data.keys())
|
||||
calculated_erosion = self.calculate_erosion_from_analysis(tickers)
|
||||
erosion_config = ErosionConfig(
|
||||
erosion_type=config.erosion_type,
|
||||
erosion_level=calculated_erosion
|
||||
)
|
||||
|
||||
state = self._initialize_simulation_state(ticker_data)
|
||||
|
||||
total_dividends = 0.0
|
||||
@ -386,7 +483,7 @@ class DRIPService:
|
||||
for month in range(1, config.months + 1):
|
||||
# Calculate dividends but don't reinvest
|
||||
monthly_dividends = self._calculate_monthly_distributions(
|
||||
month, state, ticker_data,
|
||||
month, state, ticker_data,
|
||||
self._create_distribution_schedule(ticker_data, config.months)
|
||||
)
|
||||
total_dividends += monthly_dividends
|
||||
|
||||
@ -532,4 +532,60 @@ class NavErosionService:
|
||||
return (
|
||||
f"Portfolio NAV Risk: {avg_nav_risk:.1f}/9 | "
|
||||
f"Portfolio Yield Risk: {avg_yield_risk:.1f}/9"
|
||||
)
|
||||
)
|
||||
|
||||
def convert_to_drip_erosion_config(self, analysis: NavErosionAnalysis) -> Dict:
|
||||
"""
|
||||
Convert NavErosionAnalysis results to format expected by DRIPService.
|
||||
|
||||
Args:
|
||||
analysis: NavErosionAnalysis object from analyze_etf_erosion_risk()
|
||||
|
||||
Returns:
|
||||
Dict in format expected by DRIPService:
|
||||
{
|
||||
"per_ticker": {
|
||||
"TICKER": {
|
||||
"nav": monthly_nav_erosion_rate,
|
||||
"yield": monthly_yield_erosion_rate
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
per_ticker_erosion = {}
|
||||
|
||||
for result in analysis.results:
|
||||
# Convert annual erosion rates to monthly rates
|
||||
# Monthly rate = (1 + annual_rate)^(1/12) - 1
|
||||
# For small rates, approximately annual_rate / 12
|
||||
|
||||
annual_nav_erosion = result.estimated_nav_erosion
|
||||
annual_yield_erosion = result.estimated_yield_erosion
|
||||
|
||||
# Convert to monthly rates using compound formula for accuracy
|
||||
if annual_nav_erosion > 0:
|
||||
monthly_nav_rate = (1 + annual_nav_erosion) ** (1/12) - 1
|
||||
else:
|
||||
monthly_nav_rate = 0.0
|
||||
|
||||
if annual_yield_erosion > 0:
|
||||
monthly_yield_rate = (1 + annual_yield_erosion) ** (1/12) - 1
|
||||
else:
|
||||
monthly_yield_rate = 0.0
|
||||
|
||||
# Cap maximum monthly erosion at 5% for safety
|
||||
monthly_nav_rate = min(monthly_nav_rate, 0.05)
|
||||
monthly_yield_rate = min(monthly_yield_rate, 0.05)
|
||||
|
||||
per_ticker_erosion[result.ticker] = {
|
||||
"nav": monthly_nav_rate,
|
||||
"yield": monthly_yield_rate
|
||||
}
|
||||
|
||||
logger.info(f"Converted erosion rates for {result.ticker}:")
|
||||
logger.info(f" Annual NAV erosion: {annual_nav_erosion:.2%} -> Monthly: {monthly_nav_rate:.4%}")
|
||||
logger.info(f" Annual Yield erosion: {annual_yield_erosion:.2%} -> Monthly: {monthly_yield_rate:.4%}")
|
||||
|
||||
return {
|
||||
"per_ticker": per_ticker_erosion
|
||||
}
|
||||
23
services/drip_service/logger.py
Normal file
23
services/drip_service/logger.py
Normal file
@ -0,0 +1,23 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
def setup_logger():
|
||||
# Create logger
|
||||
logger = logging.getLogger('drip_service')
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Create console handler with formatting
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
|
||||
# Create formatter
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
# Add handler to logger
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
return logger
|
||||
|
||||
# Create logger instance
|
||||
logger = setup_logger()
|
||||
@ -1,15 +1,12 @@
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import logging
|
||||
import traceback
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from .models import PortfolioAllocation, MonthlyData, DripConfig, DripResult
|
||||
from ..nav_erosion_service import NavErosionService
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from .logger import logger
|
||||
|
||||
class DistributionFrequency(Enum):
|
||||
"""Enum for distribution frequencies"""
|
||||
@ -69,13 +66,42 @@ class DripService:
|
||||
|
||||
# Get erosion data from nav_erosion_service
|
||||
erosion_data = self.nav_erosion_service.analyze_etf_erosion_risk(portfolio_df["Ticker"].tolist())
|
||||
erosion_rates = {
|
||||
result.ticker: {
|
||||
"nav": result.estimated_nav_erosion / 100, # Convert to decimal
|
||||
"yield": result.estimated_yield_erosion / 100 # Convert to decimal
|
||||
}
|
||||
for result in erosion_data.results
|
||||
}
|
||||
logger.info(f"Erosion data results: {erosion_data.results}")
|
||||
|
||||
# Initialize erosion rates dictionary
|
||||
erosion_rates = {}
|
||||
|
||||
# Use erosion rates from nav_erosion_service
|
||||
for ticker in portfolio_df["Ticker"]:
|
||||
# Find the result for this ticker in erosion_data.results
|
||||
result = next((r for r in erosion_data.results if r.ticker == ticker), None)
|
||||
|
||||
if result:
|
||||
erosion_rates[ticker] = {
|
||||
"nav": result.monthly_nav_erosion_rate,
|
||||
"yield": result.monthly_yield_erosion_rate
|
||||
}
|
||||
logger.info(f"=== EROSION RATE DEBUG ===")
|
||||
logger.info(f"Ticker: {ticker}")
|
||||
logger.info(f"Erosion rates from nav_erosion_service:")
|
||||
logger.info(f" NAV: {erosion_rates[ticker]['nav']:.4%}")
|
||||
logger.info(f" Yield: {erosion_rates[ticker]['yield']:.4%}")
|
||||
logger.info(f"=== END EROSION RATE DEBUG ===\n")
|
||||
else:
|
||||
# Use default erosion rates if not found
|
||||
erosion_rates[ticker] = {
|
||||
"nav": 0.05, # 5% per month (very high, for test)
|
||||
"yield": 0.07 # 7% per month (very high, for test)
|
||||
}
|
||||
logger.info(f"=== EROSION RATE DEBUG ===")
|
||||
logger.info(f"Ticker: {ticker}")
|
||||
logger.info(f"Using default erosion rates:")
|
||||
logger.info(f" NAV: {erosion_rates[ticker]['nav']:.4%}")
|
||||
logger.info(f" Yield: {erosion_rates[ticker]['yield']:.4%}")
|
||||
logger.info(f"=== END EROSION RATE DEBUG ===\n")
|
||||
|
||||
# Log the final erosion rates dictionary
|
||||
logger.info(f"Final erosion rates dictionary: {erosion_rates}")
|
||||
|
||||
# Initialize portfolio data
|
||||
ticker_data = self._initialize_ticker_data(portfolio_df)
|
||||
@ -89,6 +115,14 @@ class DripService:
|
||||
|
||||
# Run monthly simulation
|
||||
for month in range(1, config.months + 1):
|
||||
logger.info(f"\n=== Starting Month {month} ===")
|
||||
logger.info(f"Initial state for month {month}:")
|
||||
for ticker in ticker_data.keys():
|
||||
logger.info(f" {ticker}:")
|
||||
logger.info(f" Price: ${simulation_state['current_prices'][ticker]:.2f}")
|
||||
logger.info(f" Yield: {simulation_state['current_yields'][ticker]:.2%}")
|
||||
logger.info(f" Shares: {simulation_state['current_shares'][ticker]:.4f}")
|
||||
|
||||
month_result = self._simulate_month(
|
||||
month,
|
||||
simulation_state,
|
||||
@ -97,6 +131,14 @@ class DripService:
|
||||
distribution_schedule
|
||||
)
|
||||
monthly_data.append(month_result)
|
||||
|
||||
logger.info(f"Final state for month {month}:")
|
||||
for ticker in ticker_data.keys():
|
||||
logger.info(f" {ticker}:")
|
||||
logger.info(f" Price: ${simulation_state['current_prices'][ticker]:.2f}")
|
||||
logger.info(f" Yield: {simulation_state['current_yields'][ticker]:.2%}")
|
||||
logger.info(f" Shares: {simulation_state['current_shares'][ticker]:.4f}")
|
||||
logger.info(f"=== End Month {month} ===\n")
|
||||
|
||||
# Calculate final results
|
||||
return self._create_drip_result(monthly_data, simulation_state)
|
||||
@ -177,7 +219,63 @@ class DripService:
|
||||
) -> MonthlyData:
|
||||
"""Simulate a single month with improved accuracy"""
|
||||
|
||||
# Calculate monthly income from distributions
|
||||
# Debug logging for erosion rates
|
||||
logger.info(f"\n=== EROSION RATES DEBUG ===")
|
||||
logger.info(f"Erosion rates dictionary: {erosion_rates}")
|
||||
for ticker, rates in erosion_rates.items():
|
||||
logger.info(f" {ticker}:")
|
||||
logger.info(f" nav: {rates['nav']:.4%}")
|
||||
logger.info(f" yield: {rates['yield']:.4%}")
|
||||
logger.info(f"=== END EROSION RATES DEBUG ===\n")
|
||||
|
||||
# Apply erosion first
|
||||
for ticker, rates in erosion_rates.items():
|
||||
if ticker in state['current_prices']:
|
||||
# Get monthly erosion rates (already in decimal form)
|
||||
monthly_nav_erosion = rates['nav']
|
||||
monthly_yield_erosion = rates['yield']
|
||||
|
||||
# Get current values
|
||||
old_price = state['current_prices'][ticker]
|
||||
old_yield = state['current_yields'][ticker]
|
||||
|
||||
# Debug logging
|
||||
logger.info(f"\n=== EROSION CALCULATION DEBUG ===")
|
||||
logger.info(f"Ticker: {ticker}")
|
||||
logger.info(f"Raw erosion rates from nav_erosion_service:")
|
||||
logger.info(f" monthly_nav_erosion: {monthly_nav_erosion:.4%}")
|
||||
logger.info(f" monthly_yield_erosion: {monthly_yield_erosion:.4%}")
|
||||
logger.info(f"Current values:")
|
||||
logger.info(f" old_price: ${old_price:.4f}")
|
||||
logger.info(f" old_yield: {old_yield:.4%}")
|
||||
|
||||
# Calculate new values
|
||||
new_price = old_price * (1 - monthly_nav_erosion)
|
||||
new_yield = old_yield * (1 - monthly_yield_erosion)
|
||||
|
||||
logger.info(f"Calculated new values:")
|
||||
logger.info(f" new_price = ${old_price:.4f} * (1 - {monthly_nav_erosion:.4%})")
|
||||
logger.info(f" new_price = ${old_price:.4f} * {1 - monthly_nav_erosion:.4f}")
|
||||
logger.info(f" new_price = ${new_price:.4f}")
|
||||
logger.info(f" new_yield = {old_yield:.4%} * (1 - {monthly_yield_erosion:.4%})")
|
||||
logger.info(f" new_yield = {old_yield:.4%} * {1 - monthly_yield_erosion:.4f}")
|
||||
logger.info(f" new_yield = {new_yield:.4%}")
|
||||
|
||||
# Apply the new values with bounds checking
|
||||
state['current_prices'][ticker] = max(0.01, new_price) # Prevent zero/negative prices
|
||||
state['current_yields'][ticker] = max(0.0, new_yield) # Prevent negative yields
|
||||
|
||||
logger.info(f"Final values after bounds checking:")
|
||||
logger.info(f" final_price: ${state['current_prices'][ticker]:.4f}")
|
||||
logger.info(f" final_yield: {state['current_yields'][ticker]:.4%}")
|
||||
logger.info(f"=== END EROSION CALCULATION DEBUG ===\n")
|
||||
|
||||
# Log the actual erosion being applied
|
||||
logger.info(f"Applied erosion to {ticker}:")
|
||||
logger.info(f" NAV: {monthly_nav_erosion:.4%} -> New price: ${state['current_prices'][ticker]:.2f}")
|
||||
logger.info(f" Yield: {monthly_yield_erosion:.4%} -> New yield: {state['current_yields'][ticker]:.2%}")
|
||||
|
||||
# Calculate monthly income from distributions using eroded values
|
||||
monthly_income = self._calculate_monthly_distributions(
|
||||
month, state, ticker_data, distribution_schedule
|
||||
)
|
||||
@ -185,9 +283,6 @@ class DripService:
|
||||
# Update cumulative income
|
||||
state['cumulative_income'] += monthly_income
|
||||
|
||||
# Apply erosion to prices and yields using nav_erosion_service data
|
||||
self._apply_monthly_erosion(state, erosion_rates)
|
||||
|
||||
# Reinvest dividends (DRIP)
|
||||
self._reinvest_dividends(month, state, distribution_schedule)
|
||||
|
||||
@ -232,22 +327,6 @@ class DripService:
|
||||
|
||||
return monthly_income
|
||||
|
||||
def _apply_monthly_erosion(
|
||||
self,
|
||||
state: Dict[str, Any],
|
||||
erosion_rates: Dict[str, Dict[str, float]]
|
||||
) -> None:
|
||||
"""Apply erosion to current prices and yields using nav_erosion_service data"""
|
||||
for ticker, rates in erosion_rates.items():
|
||||
if ticker in state['current_prices']:
|
||||
# Apply monthly erosion rates
|
||||
monthly_nav_erosion = rates['nav'] / 12
|
||||
monthly_yield_erosion = rates['yield'] / 12
|
||||
|
||||
# Apply erosion with bounds checking
|
||||
state['current_prices'][ticker] = max(0.01, state['current_prices'][ticker] * (1 - monthly_nav_erosion))
|
||||
state['current_yields'][ticker] = max(0.0, state['current_yields'][ticker] * (1 - monthly_yield_erosion))
|
||||
|
||||
def _reinvest_dividends(
|
||||
self,
|
||||
month: int,
|
||||
@ -331,8 +410,8 @@ class DripService:
|
||||
erosion_data = self.nav_erosion_service.analyze_etf_erosion_risk(portfolio_df["Ticker"].tolist())
|
||||
erosion_rates = {
|
||||
result.ticker: {
|
||||
"nav": result.estimated_nav_erosion / 100,
|
||||
"yield": result.estimated_yield_erosion / 100
|
||||
"nav": result.monthly_nav_erosion_rate,
|
||||
"yield": result.monthly_yield_erosion_rate
|
||||
}
|
||||
for result in erosion_data.results
|
||||
}
|
||||
@ -349,7 +428,21 @@ class DripService:
|
||||
total_dividends += monthly_dividends
|
||||
|
||||
# Apply erosion
|
||||
self._apply_monthly_erosion(state, erosion_rates)
|
||||
for ticker, rates in erosion_rates.items():
|
||||
if ticker in state['current_prices']:
|
||||
# Get monthly erosion rates (already in decimal form)
|
||||
monthly_nav_erosion = rates['nav']
|
||||
monthly_yield_erosion = rates['yield']
|
||||
|
||||
# Apply NAV erosion (decrease price)
|
||||
old_price = state['current_prices'][ticker]
|
||||
new_price = old_price * (1 - monthly_nav_erosion)
|
||||
state['current_prices'][ticker] = max(0.01, new_price) # Prevent zero/negative prices
|
||||
|
||||
# Apply yield erosion (decrease yield)
|
||||
old_yield = state['current_yields'][ticker]
|
||||
new_yield = old_yield * (1 - monthly_yield_erosion)
|
||||
state['current_yields'][ticker] = max(0.0, new_yield) # Prevent negative yields
|
||||
|
||||
final_value = sum(
|
||||
state['current_shares'][ticker] * state['current_prices'][ticker]
|
||||
|
||||
@ -1,6 +1,125 @@
|
||||
from typing import Dict, Tuple
|
||||
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 = {}
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
from ETF_Portal.services.nav_erosion_service import NavErosionService
|
||||
import logging
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def test_portfolio():
|
||||
# Initialize service
|
||||
service = NavErosionService()
|
||||
|
||||
# Test portfolio
|
||||
portfolio = ['VTI', 'DEPI', 'MSTY', 'JEPI', 'VOO']
|
||||
|
||||
try:
|
||||
# Analyze portfolio
|
||||
analysis = service.analyze_etf_erosion_risk(portfolio)
|
||||
|
||||
# Print results
|
||||
print("\nPortfolio Analysis Results:")
|
||||
print("=" * 50)
|
||||
print(f"Portfolio NAV Risk: {analysis.portfolio_nav_risk:.1f}/9")
|
||||
print(f"Portfolio Yield Risk: {analysis.portfolio_yield_risk:.1f}/9")
|
||||
print("\nDetailed Results:")
|
||||
print("=" * 50)
|
||||
|
||||
for result in analysis.results:
|
||||
print(f"\n{result.ticker}:")
|
||||
print(f" NAV Erosion Risk: {result.nav_erosion_risk:.1f}/9")
|
||||
print(f" Yield Erosion Risk: {result.yield_erosion_risk:.1f}/9")
|
||||
print(f" Estimated NAV Erosion: {result.estimated_nav_erosion:.1%}")
|
||||
print(f" Estimated Yield Erosion: {result.estimated_yield_erosion:.1%}")
|
||||
print(f" NAV Risk Explanation: {result.nav_risk_explanation}")
|
||||
print(f" Yield Risk Explanation: {result.yield_risk_explanation}")
|
||||
if result.component_risks:
|
||||
print(" Component Risks:")
|
||||
for component, value in result.component_risks.items():
|
||||
print(f" {component}: {value:.1%}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during analysis: {str(e)}")
|
||||
raise
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_portfolio()
|
||||
Loading…
Reference in New Issue
Block a user