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_EROSION_LEVEL = 9
|
||||||
self.MAX_MONTHLY_EROSION = 0.05 # 5% monthly max erosion
|
self.MAX_MONTHLY_EROSION = 0.05 # 5% monthly max erosion
|
||||||
self.DISTRIBUTION_FREQUENCIES = {freq.display_name: freq for freq in DistributionFrequency}
|
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:
|
def calculate_drip_growth(self, portfolio_df: pd.DataFrame, config: DripConfig) -> DripResult:
|
||||||
"""
|
"""
|
||||||
@ -85,8 +86,24 @@ class DRIPService:
|
|||||||
|
|
||||||
# Initialize portfolio data
|
# Initialize portfolio data
|
||||||
ticker_data = self._initialize_ticker_data(portfolio_df)
|
ticker_data = self._initialize_ticker_data(portfolio_df)
|
||||||
|
|
||||||
|
# Handle erosion configuration
|
||||||
erosion_config = self._parse_erosion_config(config)
|
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
|
# Pre-calculate distribution schedule for performance
|
||||||
distribution_schedule = self._create_distribution_schedule(ticker_data, config.months)
|
distribution_schedule = self._create_distribution_schedule(ticker_data, config.months)
|
||||||
|
|
||||||
@ -203,11 +220,47 @@ class DRIPService:
|
|||||||
if not hasattr(config, 'erosion_level') or config.erosion_type == "None":
|
if not hasattr(config, 'erosion_level') or config.erosion_type == "None":
|
||||||
return ErosionConfig(erosion_type="None", erosion_level={})
|
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(
|
return ErosionConfig(
|
||||||
erosion_type=config.erosion_type,
|
erosion_type=config.erosion_type,
|
||||||
erosion_level=config.erosion_level
|
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:
|
def _normalize_erosion_rate(self, erosion_level: float) -> float:
|
||||||
"""Convert erosion level (0-9) to monthly rate with validation"""
|
"""Convert erosion level (0-9) to monthly rate with validation"""
|
||||||
rate = (erosion_level / self.MAX_EROSION_LEVEL) * self.MAX_MONTHLY_EROSION
|
rate = (erosion_level / self.MAX_EROSION_LEVEL) * self.MAX_MONTHLY_EROSION
|
||||||
@ -278,20 +331,47 @@ class DRIPService:
|
|||||||
if erosion_config.erosion_type == "None":
|
if erosion_config.erosion_type == "None":
|
||||||
return
|
return
|
||||||
|
|
||||||
for ticker in tickers:
|
# Validate erosion configuration structure
|
||||||
# Get per-ticker erosion rates
|
if not isinstance(erosion_config.erosion_level, dict):
|
||||||
ticker_rates = erosion_config.erosion_level.get("per_ticker", {}).get(ticker, {})
|
logger.warning(f"Invalid erosion_level format: {type(erosion_config.erosion_level)}")
|
||||||
nav_rate = ticker_rates.get("nav", 0.0) # Already in decimal form
|
return
|
||||||
yield_rate = ticker_rates.get("yield", 0.0) # Already in decimal form
|
|
||||||
|
|
||||||
# Apply erosion directly
|
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 with fallback
|
||||||
|
ticker_rates = per_ticker_data.get(ticker, {})
|
||||||
|
|
||||||
|
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_prices'][ticker] *= (1 - nav_rate)
|
||||||
state['current_yields'][ticker] *= (1 - yield_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
|
# Log erosion application
|
||||||
logger.info(f"Applied erosion to {ticker}:")
|
logger.info(f"Applied monthly erosion to {ticker}:")
|
||||||
logger.info(f" NAV: {nav_rate:.4%} -> New price: ${state['current_prices'][ticker]:.2f}")
|
logger.info(f" NAV: {nav_rate:.4%} -> Price: ${original_price:.2f} -> ${state['current_prices'][ticker]:.2f}")
|
||||||
logger.info(f" Yield: {yield_rate:.4%} -> New yield: {state['current_yields'][ticker]:.2%}")
|
logger.info(f" Yield: {yield_rate:.4%} -> Yield: {original_yield:.2%} -> {state['current_yields'][ticker]:.2%}")
|
||||||
|
|
||||||
def _reinvest_dividends(
|
def _reinvest_dividends(
|
||||||
self,
|
self,
|
||||||
@ -378,7 +458,24 @@ class DRIPService:
|
|||||||
def _calculate_no_drip_scenario(self, portfolio_df: pd.DataFrame, config: DripConfig) -> Dict[str, float]:
|
def _calculate_no_drip_scenario(self, portfolio_df: pd.DataFrame, config: DripConfig) -> Dict[str, float]:
|
||||||
"""Calculate scenario where dividends are not reinvested"""
|
"""Calculate scenario where dividends are not reinvested"""
|
||||||
ticker_data = self._initialize_ticker_data(portfolio_df)
|
ticker_data = self._initialize_ticker_data(portfolio_df)
|
||||||
|
|
||||||
|
# Handle erosion configuration same way as main calculation
|
||||||
erosion_config = self._parse_erosion_config(config)
|
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)
|
state = self._initialize_simulation_state(ticker_data)
|
||||||
|
|
||||||
total_dividends = 0.0
|
total_dividends = 0.0
|
||||||
|
|||||||
@ -533,3 +533,59 @@ class NavErosionService:
|
|||||||
f"Portfolio NAV Risk: {avg_nav_risk:.1f}/9 | "
|
f"Portfolio NAV Risk: {avg_nav_risk:.1f}/9 | "
|
||||||
f"Portfolio Yield Risk: {avg_yield_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
|
from typing import Dict, List, Optional, Tuple, Any
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import logging
|
|
||||||
import traceback
|
import traceback
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from .models import PortfolioAllocation, MonthlyData, DripConfig, DripResult
|
from .models import PortfolioAllocation, MonthlyData, DripConfig, DripResult
|
||||||
from ..nav_erosion_service import NavErosionService
|
from ..nav_erosion_service import NavErosionService
|
||||||
|
from .logger import logger
|
||||||
# Configure logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
class DistributionFrequency(Enum):
|
class DistributionFrequency(Enum):
|
||||||
"""Enum for distribution frequencies"""
|
"""Enum for distribution frequencies"""
|
||||||
@ -69,13 +66,42 @@ class DripService:
|
|||||||
|
|
||||||
# Get erosion data from nav_erosion_service
|
# Get erosion data from nav_erosion_service
|
||||||
erosion_data = self.nav_erosion_service.analyze_etf_erosion_risk(portfolio_df["Ticker"].tolist())
|
erosion_data = self.nav_erosion_service.analyze_etf_erosion_risk(portfolio_df["Ticker"].tolist())
|
||||||
erosion_rates = {
|
logger.info(f"Erosion data results: {erosion_data.results}")
|
||||||
result.ticker: {
|
|
||||||
"nav": result.estimated_nav_erosion / 100, # Convert to decimal
|
# Initialize erosion rates dictionary
|
||||||
"yield": result.estimated_yield_erosion / 100 # Convert to decimal
|
erosion_rates = {}
|
||||||
}
|
|
||||||
for result in erosion_data.results
|
# 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
|
# Initialize portfolio data
|
||||||
ticker_data = self._initialize_ticker_data(portfolio_df)
|
ticker_data = self._initialize_ticker_data(portfolio_df)
|
||||||
@ -89,6 +115,14 @@ class DripService:
|
|||||||
|
|
||||||
# Run monthly simulation
|
# Run monthly simulation
|
||||||
for month in range(1, config.months + 1):
|
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_result = self._simulate_month(
|
||||||
month,
|
month,
|
||||||
simulation_state,
|
simulation_state,
|
||||||
@ -98,6 +132,14 @@ class DripService:
|
|||||||
)
|
)
|
||||||
monthly_data.append(month_result)
|
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
|
# Calculate final results
|
||||||
return self._create_drip_result(monthly_data, simulation_state)
|
return self._create_drip_result(monthly_data, simulation_state)
|
||||||
|
|
||||||
@ -177,7 +219,63 @@ class DripService:
|
|||||||
) -> MonthlyData:
|
) -> MonthlyData:
|
||||||
"""Simulate a single month with improved accuracy"""
|
"""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(
|
monthly_income = self._calculate_monthly_distributions(
|
||||||
month, state, ticker_data, distribution_schedule
|
month, state, ticker_data, distribution_schedule
|
||||||
)
|
)
|
||||||
@ -185,9 +283,6 @@ class DripService:
|
|||||||
# Update cumulative income
|
# Update cumulative income
|
||||||
state['cumulative_income'] += monthly_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)
|
# Reinvest dividends (DRIP)
|
||||||
self._reinvest_dividends(month, state, distribution_schedule)
|
self._reinvest_dividends(month, state, distribution_schedule)
|
||||||
|
|
||||||
@ -232,22 +327,6 @@ class DripService:
|
|||||||
|
|
||||||
return monthly_income
|
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(
|
def _reinvest_dividends(
|
||||||
self,
|
self,
|
||||||
month: int,
|
month: int,
|
||||||
@ -331,8 +410,8 @@ class DripService:
|
|||||||
erosion_data = self.nav_erosion_service.analyze_etf_erosion_risk(portfolio_df["Ticker"].tolist())
|
erosion_data = self.nav_erosion_service.analyze_etf_erosion_risk(portfolio_df["Ticker"].tolist())
|
||||||
erosion_rates = {
|
erosion_rates = {
|
||||||
result.ticker: {
|
result.ticker: {
|
||||||
"nav": result.estimated_nav_erosion / 100,
|
"nav": result.monthly_nav_erosion_rate,
|
||||||
"yield": result.estimated_yield_erosion / 100
|
"yield": result.monthly_yield_erosion_rate
|
||||||
}
|
}
|
||||||
for result in erosion_data.results
|
for result in erosion_data.results
|
||||||
}
|
}
|
||||||
@ -349,7 +428,21 @@ class DripService:
|
|||||||
total_dividends += monthly_dividends
|
total_dividends += monthly_dividends
|
||||||
|
|
||||||
# Apply erosion
|
# 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(
|
final_value = sum(
|
||||||
state['current_shares'][ticker] * state['current_prices'][ticker]
|
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:
|
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]:
|
def _calculate_nav_risk(self, etf_data: Dict, etf_type: ETFType) -> Tuple[float, Dict]:
|
||||||
"""Calculate NAV risk components with ETF-type specific adjustments"""
|
"""Calculate NAV risk components with ETF-type specific adjustments"""
|
||||||
components = {}
|
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