introduce a bug for nav & yield erosion

This commit is contained in:
Pascal BIBEHE 2025-06-03 23:55:53 +02:00
parent 30dc087ce3
commit 0712578445
3 changed files with 61 additions and 51 deletions

View File

@ -1,4 +1,5 @@
from typing import Dict, List, Optional, Tuple, Any from __future__ import annotations
from typing import Dict, Optional, Tuple, Any
import pandas as pd import pandas as pd
import numpy as np import numpy as np
import logging import logging
@ -113,10 +114,9 @@ class NoDRIPService:
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 erosion is requested but no proper erosion_level is provided, calculate it
if (config.erosion_type != "None" and if (erosion_config.erosion_type != "None" and
(not hasattr(config, 'erosion_level') or (not isinstance(erosion_config.erosion_level, dict) or
not isinstance(config.erosion_level, dict) or "per_ticker" not in erosion_config.erosion_level)):
"per_ticker" not in config.erosion_level)):
logger.info(f"Calculating erosion rates for No-DRIP with erosion type: {config.erosion_type}") logger.info(f"Calculating erosion rates for No-DRIP with erosion type: {config.erosion_type}")
tickers = list(ticker_data.keys()) tickers = list(ticker_data.keys())
@ -238,9 +238,13 @@ class NoDRIPService:
def _parse_erosion_config(self, config: DripConfig) -> ErosionConfig: def _parse_erosion_config(self, config: DripConfig) -> ErosionConfig:
"""Parse and validate erosion configuration (reuse from DRIP service)""" """Parse and validate erosion configuration (reuse from DRIP service)"""
if not hasattr(config, 'erosion_level') or config.erosion_type == "None": if config.erosion_type == "None":
return ErosionConfig(erosion_type="None", erosion_level={}) return ErosionConfig(erosion_type="None", erosion_level={})
if not hasattr(config, 'erosion_level') or config.erosion_level is None:
# erosion_level not provided - will be calculated automatically later
return ErosionConfig(erosion_type=config.erosion_type, erosion_level={})
# Check if erosion_level is already in the correct format # Check if erosion_level is already in the correct format
if isinstance(config.erosion_level, dict) and "per_ticker" in config.erosion_level: if isinstance(config.erosion_level, dict) and "per_ticker" in config.erosion_level:
return ErosionConfig( return ErosionConfig(
@ -253,7 +257,7 @@ class NoDRIPService:
erosion_level=config.erosion_level erosion_level=config.erosion_level
) )
def _calculate_erosion_from_analysis(self, tickers: List[str]) -> Dict: def _calculate_erosion_from_analysis(self, tickers: list[str]) -> Dict:
"""Calculate erosion rates using NavErosionService (reuse from DRIP service)""" """Calculate erosion rates using NavErosionService (reuse from DRIP service)"""
try: try:
# Use NavErosionService to analyze the tickers # Use NavErosionService to analyze the tickers
@ -269,10 +273,11 @@ class NoDRIPService:
except Exception as e: except Exception as e:
logger.error(f"Error calculating erosion rates for No-DRIP: {str(e)}") logger.error(f"Error calculating erosion rates for No-DRIP: {str(e)}")
logger.warning("Falling back to no erosion") logger.warning("Falling back to default erosion rates")
return {"per_ticker": {ticker: {"nav": 0.0, "yield": 0.0} for ticker in tickers}} # Use reasonable default erosion rates instead of zero
return {"per_ticker": {ticker: {"nav": 6.0, "yield": 4.0} for ticker in tickers}}
def _create_distribution_schedule(self, ticker_data: Dict[str, TickerData], total_months: int) -> Dict[str, List[int]]: def _create_distribution_schedule(self, ticker_data: dict[str, TickerData], total_months: int) -> dict[str, list[int]]:
"""Pre-calculate which months each ticker pays distributions (reuse from DRIP service)""" """Pre-calculate which months each ticker pays distributions (reuse from DRIP service)"""
schedule = {} schedule = {}
@ -288,7 +293,7 @@ class NoDRIPService:
return schedule return schedule
def _initialize_simulation_state(self, ticker_data: Dict[str, TickerData]) -> Dict[str, Any]: def _initialize_simulation_state(self, ticker_data: dict[str, TickerData]) -> dict[str, Any]:
"""Initialize simulation state variables""" """Initialize simulation state variables"""
return { return {
'original_shares': {ticker: data.shares for ticker, data in ticker_data.items()}, # Constant 'original_shares': {ticker: data.shares for ticker, data in ticker_data.items()}, # Constant
@ -300,9 +305,9 @@ class NoDRIPService:
def _calculate_monthly_distributions( def _calculate_monthly_distributions(
self, self,
month: int, month: int,
state: Dict[str, Any], state: dict[str, Any],
ticker_data: Dict[str, TickerData], ticker_data: dict[str, TickerData],
distribution_schedule: Dict[str, List[int]] distribution_schedule: dict[str, list[int]]
) -> float: ) -> float:
"""Calculate distributions for the current month (reuse logic from DRIP service)""" """Calculate distributions for the current month (reuse logic from DRIP service)"""
monthly_income = 0.0 monthly_income = 0.0
@ -329,9 +334,9 @@ class NoDRIPService:
def _apply_monthly_erosion( def _apply_monthly_erosion(
self, self,
state: Dict[str, Any], state: dict[str, Any],
erosion_config: ErosionConfig, erosion_config: ErosionConfig,
tickers: List[str] tickers: list[str]
) -> None: ) -> None:
"""Apply monthly erosion to prices and yields""" """Apply monthly erosion to prices and yields"""
if erosion_config.erosion_type == "None": if erosion_config.erosion_type == "None":
@ -411,7 +416,7 @@ class NoDRIPService:
else: else:
return True # Default to monthly for unknown return True # Default to monthly for unknown
def _create_no_drip_result(self, monthly_data: List[NoDRIPMonthlyData], state: Dict[str, Any]) -> NoDRIPResult: def _create_no_drip_result(self, monthly_data: list[NoDRIPMonthlyData], state: dict[str, Any]) -> NoDRIPResult:
"""Create final No-DRIP result object""" """Create final No-DRIP result object"""
if not monthly_data: if not monthly_data:
raise ValueError("No monthly data generated") raise ValueError("No monthly data generated")

View File

@ -1,4 +1,5 @@
from typing import Dict, List, Optional, Tuple, Any from __future__ import annotations
from typing import Dict, Optional, Tuple, Any
import pandas as pd import pandas as pd
import numpy as np import numpy as np
import logging import logging
@ -92,10 +93,9 @@ class DRIPService:
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 erosion is requested but no proper erosion_level is provided, calculate it
if (config.erosion_type != "None" and if (erosion_config.erosion_type != "None" and
(not hasattr(config, 'erosion_level') or (not isinstance(erosion_config.erosion_level, dict) or
not isinstance(config.erosion_level, dict) or "per_ticker" not in erosion_config.erosion_level)):
"per_ticker" not in config.erosion_level)):
logger.info(f"Calculating erosion rates for erosion type: {config.erosion_type}") logger.info(f"Calculating erosion rates for erosion type: {config.erosion_type}")
tickers = list(ticker_data.keys()) tickers = list(ticker_data.keys())
@ -218,9 +218,13 @@ class DRIPService:
def _parse_erosion_config(self, config: DripConfig) -> ErosionConfig: def _parse_erosion_config(self, config: DripConfig) -> ErosionConfig:
"""Parse and validate erosion configuration""" """Parse and validate erosion configuration"""
if not hasattr(config, 'erosion_level') or config.erosion_type == "None": if config.erosion_type == "None":
return ErosionConfig(erosion_type="None", erosion_level={}) return ErosionConfig(erosion_type="None", erosion_level={})
if not hasattr(config, 'erosion_level') or config.erosion_level is None:
# erosion_level not provided - will be calculated automatically later
return ErosionConfig(erosion_type=config.erosion_type, erosion_level={})
# Check if erosion_level is already in the correct format # Check if erosion_level is already in the correct format
if isinstance(config.erosion_level, dict) and "per_ticker" in config.erosion_level: if isinstance(config.erosion_level, dict) and "per_ticker" in config.erosion_level:
return ErosionConfig( return ErosionConfig(
@ -235,7 +239,7 @@ class DRIPService:
erosion_level=config.erosion_level erosion_level=config.erosion_level
) )
def calculate_erosion_from_analysis(self, tickers: List[str]) -> Dict: def calculate_erosion_from_analysis(self, tickers: list[str]) -> Dict:
""" """
Calculate erosion rates using NavErosionService Calculate erosion rates using NavErosionService
@ -259,15 +263,16 @@ class DRIPService:
except Exception as e: except Exception as e:
logger.error(f"Error calculating erosion rates: {str(e)}") logger.error(f"Error calculating erosion rates: {str(e)}")
logger.warning("Falling back to no erosion") logger.warning("Falling back to default erosion rates")
return {"per_ticker": {ticker: {"nav": 0.0, "yield": 0.0} for ticker in tickers}} # Use reasonable default erosion rates instead of zero
return {"per_ticker": {ticker: {"nav": 6.0, "yield": 4.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
return min(max(0.0, rate), self.MAX_MONTHLY_EROSION) return min(max(0.0, rate), self.MAX_MONTHLY_EROSION)
def _create_distribution_schedule(self, ticker_data: Dict[str, TickerData], total_months: int) -> Dict[str, List[int]]: def _create_distribution_schedule(self, ticker_data: dict[str, TickerData], total_months: int) -> dict[str, list[int]]:
"""Pre-calculate which months each ticker pays distributions""" """Pre-calculate which months each ticker pays distributions"""
schedule = {} schedule = {}
@ -283,7 +288,7 @@ class DRIPService:
return schedule return schedule
def _initialize_simulation_state(self, ticker_data: Dict[str, TickerData]) -> Dict[str, Any]: def _initialize_simulation_state(self, ticker_data: dict[str, TickerData]) -> dict[str, Any]:
"""Initialize simulation state variables""" """Initialize simulation state variables"""
return { return {
'current_shares': {ticker: data.shares for ticker, data in ticker_data.items()}, 'current_shares': {ticker: data.shares for ticker, data in ticker_data.items()},
@ -295,9 +300,9 @@ class DRIPService:
def _calculate_monthly_distributions( def _calculate_monthly_distributions(
self, self,
month: int, month: int,
state: Dict[str, Any], state: dict[str, Any],
ticker_data: Dict[str, TickerData], ticker_data: dict[str, TickerData],
distribution_schedule: Dict[str, List[int]] distribution_schedule: dict[str, list[int]]
) -> float: ) -> float:
"""Calculate distributions for the current month""" """Calculate distributions for the current month"""
monthly_income = 0.0 monthly_income = 0.0
@ -324,9 +329,9 @@ class DRIPService:
def _apply_monthly_erosion( def _apply_monthly_erosion(
self, self,
state: Dict[str, Any], state: dict[str, Any],
erosion_config: ErosionConfig, erosion_config: ErosionConfig,
tickers: List[str] tickers: list[str]
) -> None: ) -> None:
"""Apply monthly erosion to prices and yields""" """Apply monthly erosion to prices and yields"""
if erosion_config.erosion_type == "None": if erosion_config.erosion_type == "None":
@ -396,8 +401,8 @@ class DRIPService:
def _reinvest_dividends( def _reinvest_dividends(
self, self,
month: int, month: int,
state: Dict[str, Any], state: dict[str, Any],
distribution_schedule: Dict[str, List[int]] distribution_schedule: dict[str, list[int]]
) -> None: ) -> None:
"""Reinvest dividends for tickers that distributed in this month""" """Reinvest dividends for tickers that distributed in this month"""
@ -434,7 +439,7 @@ class DRIPService:
else: else:
return True # Default to monthly for unknown return True # Default to monthly for unknown
def _create_drip_result(self, monthly_data: List[MonthlyData], state: Dict[str, Any]) -> DripResult: def _create_drip_result(self, monthly_data: list[MonthlyData], state: dict[str, Any]) -> DripResult:
"""Create final DRIP result object""" """Create final DRIP result object"""
if not monthly_data: if not monthly_data:
raise ValueError("No monthly data generated") raise ValueError("No monthly data generated")
@ -455,7 +460,7 @@ class DRIPService:
self, self,
portfolio_df: pd.DataFrame, portfolio_df: pd.DataFrame,
config: DripConfig, config: DripConfig,
tickers: Optional[List[str]] = None tickers: Optional[list[str]] = None
) -> DRIPPortfolioResult: ) -> DRIPPortfolioResult:
""" """
Forecast DRIP growth for an entire portfolio. Forecast DRIP growth for an entire portfolio.
@ -548,7 +553,7 @@ class DRIPService:
self, self,
portfolio_df: pd.DataFrame, portfolio_df: pd.DataFrame,
config: DripConfig config: DripConfig
) -> Dict[str, Any]: ) -> dict[str, Any]:
""" """
Calculate and compare DRIP vs No-DRIP strategies with detailed analysis. Calculate and compare DRIP vs No-DRIP strategies with detailed analysis.
This method runs both simulations and displays comparison tables. This method runs both simulations and displays comparison tables.
@ -636,10 +641,10 @@ class DRIPService:
def _calculate_break_even_analysis( def _calculate_break_even_analysis(
self, self,
strategy_name: str, strategy_name: str,
monthly_data: List, monthly_data: list,
initial_investment: float, initial_investment: float,
value_extractor: callable value_extractor: callable
) -> Dict[str, Any]: ) -> dict[str, Any]:
""" """
Calculate break-even analysis for a strategy. Calculate break-even analysis for a strategy.
Break-even occurs when total value exceeds initial investment, Break-even occurs when total value exceeds initial investment,
@ -705,8 +710,8 @@ class DRIPService:
winner: str, winner: str,
advantage_amount: float, advantage_amount: float,
advantage_percentage: float, advantage_percentage: float,
drip_break_even: Dict[str, Any], drip_break_even: dict[str, Any],
no_drip_break_even: Dict[str, Any] no_drip_break_even: dict[str, Any]
) -> None: ) -> None:
"""Print detailed strategy comparison table""" """Print detailed strategy comparison table"""

View File

@ -2103,7 +2103,7 @@ if st.session_state.simulation_run and st.session_state.df_data is not None:
config = DripConfig( config = DripConfig(
months=12, months=12,
erosion_type=st.session_state.get("erosion_type", "Conservative"), erosion_type=st.session_state.get("erosion_type", "Conservative"),
erosion_level={} # Let the service calculate this automatically erosion_level=None # Let the service calculate this automatically
) )
# Calculate DRIP vs No-DRIP comparison using the integrated method # Calculate DRIP vs No-DRIP comparison using the integrated method