feat: Add DRIP service support files and update related services - Add exceptions and logger - Update service models and implementations - Update portfolio builder integration

This commit is contained in:
Pascal BIBEHE 2025-06-02 16:16:57 +02:00
parent 4ea1fe2a73
commit edf2ce5e9c
9 changed files with 978 additions and 1032 deletions

View File

@ -209,6 +209,12 @@ class DataService:
if hist.empty: if hist.empty:
return None return None
# Get current price
current_price = info.get('regularMarketPrice', hist['Close'].iloc[-1])
# Get dividend yield
dividend_yield = info.get('dividendYield', 0) * 100 # Convert to percentage
# Get dividends with proper handling # Get dividends with proper handling
try: try:
dividends = yf_ticker.dividends dividends = yf_ticker.dividends
@ -217,7 +223,6 @@ class DataService:
dividend_rate = info.get('dividendRate', 0) dividend_rate = info.get('dividendRate', 0)
if dividend_rate > 0: if dividend_rate > 0:
# Create a synthetic dividend series # Create a synthetic dividend series
last_price = hist['Close'].iloc[-1]
annual_dividend = dividend_rate annual_dividend = dividend_rate
monthly_dividend = annual_dividend / 12 monthly_dividend = annual_dividend / 12
dividends = pd.Series(monthly_dividend, index=hist.index) dividends = pd.Series(monthly_dividend, index=hist.index)
@ -248,93 +253,49 @@ class DataService:
# Sharpe Ratio # Sharpe Ratio
if volatility > 0: if volatility > 0:
sharpe_ratio = np.sqrt(252) * excess_returns.mean() / volatility sharpe_ratio = (annual_return - risk_free_rate) / volatility
else: else:
sharpe_ratio = 0 sharpe_ratio = 0
# Sortino Ratio # Sortino Ratio
negative_returns = returns[returns < 0] downside_returns = returns[returns < 0]
if len(negative_returns) > 0: if len(downside_returns) > 0:
downside_volatility = negative_returns.std() * np.sqrt(252) downside_volatility = downside_returns.std() * np.sqrt(252)
if downside_volatility > 0: if downside_volatility > 0:
sortino_ratio = np.sqrt(252) * excess_returns.mean() / downside_volatility sortino_ratio = (annual_return - risk_free_rate) / downside_volatility
else: else:
sortino_ratio = 0 sortino_ratio = 0
else: else:
sortino_ratio = 0 sortino_ratio = 0
# Calculate dividend trend with better handling # Calculate dividend trend
try: if not dividends.empty:
if not dividends.empty: dividend_trend = (dividends.iloc[-1] / dividends.iloc[0]) - 1 if dividends.iloc[0] > 0 else 0
# Resample to monthly and handle missing values else:
monthly_div = dividends.resample('ME').sum().fillna(0) dividend_trend = 0
if len(monthly_div) > 12:
# Calculate trailing 12-month dividends
earliest_ttm = monthly_div[-12:].sum()
latest_ttm = monthly_div[-1:].sum()
if earliest_ttm > 0:
dividend_trend = float((latest_ttm / earliest_ttm - 1))
else:
dividend_trend = 0.0
else:
# If less than 12 months of data, use the average
dividend_trend = float(monthly_div.mean()) if not monthly_div.empty else 0.0
else:
# Try to get dividend trend from info
dividend_rate = float(info.get('dividendRate', 0))
five_year_avg = float(info.get('fiveYearAvgDividendYield', 0))
if dividend_rate > 0 and five_year_avg > 0:
dividend_trend = float((dividend_rate / five_year_avg - 1))
else:
dividend_trend = 0.0
except Exception as e:
logger.warning(f"Error calculating dividend trend for {ticker}: {str(e)}")
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') if 'firstTradeDateEpochUtc' in info:
if inception_date: age_years = (datetime.now() - datetime.fromtimestamp(info['firstTradeDateEpochUtc'])).days / 365.25
try:
inception_date_dt = pd.to_datetime(inception_date, unit='s', utc=True)
age_years = (pd.Timestamp.now(tz='UTC') - inception_date_dt).days / 365.25
except:
age_years = None
else: else:
age_years = None age_years = 0
# Ensure all values are valid numbers and properly formatted # Return formatted data
volatility = float(volatility) if volatility is not None else 0.0 return {
max_drawdown = float(max_drawdown) if max_drawdown is not None else 0.0 'price': current_price,
sharpe_ratio = float(sharpe_ratio) if sharpe_ratio is not None else 0.0 'dividend_yield': dividend_yield,
sortino_ratio = float(sortino_ratio) if sortino_ratio is not None else 0.0
age_years = float(age_years) if age_years is not None else 0.0
# Format the response with proper types
response = {
'info': info,
'hist': hist.to_dict(),
'dividends': dividends.to_dict(),
'volatility': volatility, 'volatility': volatility,
'max_drawdown': max_drawdown, 'max_drawdown': max_drawdown,
'sharpe_ratio': sharpe_ratio, 'sharpe_ratio': sharpe_ratio,
'sortino_ratio': sortino_ratio, 'sortino_ratio': sortino_ratio,
'dividend_trend': dividend_trend, 'dividend_trend': dividend_trend,
'age_years': age_years, 'age_years': age_years,
'is_new': age_years < 2 'is_new': age_years < 2,
'info': info,
'hist': hist.to_dict('records'),
'dividends': dividends.to_dict()
} }
# Ensure all numeric values are properly formatted
for key in ['volatility', 'max_drawdown', 'sharpe_ratio', 'sortino_ratio', 'dividend_trend', 'age_years']:
if key in response:
response[key] = float(response[key])
return response
except Exception as e: except Exception as e:
logger.error(f"Error fetching yfinance data for {ticker}: {str(e)}") logger.error(f"Error fetching yfinance data for {ticker}: {str(e)}")
return None return None

View File

@ -0,0 +1,16 @@
from .service import DRIPService
from .models import DRIPMetrics, DRIPForecastResult, DRIPPortfolioResult, DripConfig
from .exceptions import DRIPError, DataFetchError, CalculationError, ValidationError, CacheError
__all__ = [
'DRIPService',
'DRIPMetrics',
'DRIPForecastResult',
'DRIPPortfolioResult',
'DRIPError',
'DataFetchError',
'CalculationError',
'ValidationError',
'CacheError',
'DripConfig'
]

View File

@ -0,0 +1,19 @@
class DRIPError(Exception):
"""Base exception for DRIP service errors"""
pass
class DataFetchError(DRIPError):
"""Raised when ETF data cannot be fetched"""
pass
class CalculationError(DRIPError):
"""Raised when DRIP calculations fail"""
pass
class ValidationError(DRIPError):
"""Raised when input validation fails"""
pass
class CacheError(DRIPError):
"""Raised when cache operations fail"""
pass

View File

@ -0,0 +1,35 @@
import logging
import os
from pathlib import Path
def get_logger(name: str) -> logging.Logger:
"""Configure and return a logger for the DRIP service"""
logger = logging.getLogger(name)
if not logger.handlers:
logger.setLevel(logging.INFO)
# Create logs directory if it doesn't exist
log_dir = Path("logs")
log_dir.mkdir(parents=True, exist_ok=True)
# File handler
file_handler = logging.FileHandler(log_dir / "drip_service.log")
file_handler.setLevel(logging.INFO)
# Console handler
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
# Formatter
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
file_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)
# Add handlers
logger.addHandler(file_handler)
logger.addHandler(console_handler)
return logger

View File

@ -0,0 +1,116 @@
from dataclasses import dataclass, asdict
from typing import Dict, List, Optional
from datetime import datetime
import json
@dataclass
class MonthlyData:
"""Data for a single month in the DRIP simulation"""
month: int
total_value: float
monthly_income: float
cumulative_income: float
shares: Dict[str, float]
prices: Dict[str, float]
yields: Dict[str, float]
@dataclass
class DripConfig:
"""Configuration for DRIP calculations"""
months: int
erosion_type: str
erosion_level: Dict
dividend_frequency: Dict[str, int] = None
def __post_init__(self):
if self.dividend_frequency is None:
self.dividend_frequency = {
"Monthly": 12,
"Quarterly": 4,
"Semi-Annually": 2,
"Annually": 1,
"Unknown": 12 # Default to monthly if unknown
}
@dataclass
class DripResult:
"""Results of a DRIP calculation"""
monthly_data: List[MonthlyData]
final_portfolio_value: float
total_income: float
total_shares: Dict[str, float]
@dataclass
class DRIPMetrics:
"""Metrics for a single ETF's DRIP calculation"""
ticker: str
date: datetime
shares: float
price: float
dividend_yield: float
monthly_dividend: float
new_shares: float
portfolio_value: float
monthly_income: float
yield_on_cost: float
def to_dict(self) -> Dict:
"""Convert the metrics to a dictionary for JSON serialization"""
data = asdict(self)
data['date'] = self.date.isoformat()
return data
@classmethod
def from_dict(cls, data: Dict) -> 'DRIPMetrics':
"""Create a DRIPMetrics instance from a dictionary"""
data = data.copy()
data['date'] = datetime.fromisoformat(data['date'])
return cls(**data)
@dataclass
class DRIPForecastResult:
"""Results of a DRIP forecast for a single ETF"""
ticker: str
initial_shares: float
final_shares: float
initial_value: float
final_value: float
total_income: float
average_yield: float
monthly_metrics: List[DRIPMetrics]
accumulated_cash: float = 0.0 # Added for No-DRIP scenarios
def to_dict(self) -> Dict:
"""Convert the forecast result to a dictionary for JSON serialization"""
data = asdict(self)
data['monthly_metrics'] = [m.to_dict() for m in self.monthly_metrics]
return data
@classmethod
def from_dict(cls, data: Dict) -> 'DRIPForecastResult':
"""Create a DRIPForecastResult instance from a dictionary"""
data = data.copy()
data['monthly_metrics'] = [DRIPMetrics.from_dict(m) for m in data['monthly_metrics']]
return cls(**data)
@dataclass
class DRIPPortfolioResult:
"""Results of a DRIP forecast for an entire portfolio"""
total_value: float
monthly_income: float
total_income: float
etf_results: Dict[str, DRIPForecastResult]
accumulated_cash: float = 0.0 # Added for No-DRIP scenarios
def to_dict(self) -> Dict:
"""Convert the portfolio result to a dictionary for JSON serialization"""
data = asdict(self)
data['etf_results'] = {k: v.to_dict() for k, v in self.etf_results.items()}
return data
@classmethod
def from_dict(cls, data: Dict) -> 'DRIPPortfolioResult':
"""Create a DRIPPortfolioResult instance from a dictionary"""
data = data.copy()
data['etf_results'] = {k: DRIPForecastResult.from_dict(v) for k, v in data['etf_results'].items()}
return cls(**data)

View File

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +1,60 @@
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 logging import logging
import traceback import traceback
from dataclasses import dataclass, field
from enum import Enum
from .models import PortfolioAllocation, MonthlyData, DripConfig, DripResult from .models import PortfolioAllocation, MonthlyData, DripConfig, DripResult
from ..nav_erosion_service import NavErosionService
# Configure logging # Configure logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DistributionFrequency(Enum):
"""Enum for distribution frequencies"""
MONTHLY = ("Monthly", 12)
QUARTERLY = ("Quarterly", 4)
SEMI_ANNUALLY = ("Semi-Annually", 2)
ANNUALLY = ("Annually", 1)
UNKNOWN = ("Unknown", 12)
def __init__(self, name: str, payments_per_year: int):
self.display_name = name
self.payments_per_year = payments_per_year
@dataclass
class TickerData:
"""Data structure for individual ticker information"""
ticker: str
price: float
annual_yield: float
shares: float
allocation_pct: float
distribution_freq: DistributionFrequency
@property
def market_value(self) -> float:
return self.price * self.shares
@property
def monthly_yield(self) -> float:
return self.annual_yield / 12
@property
def distribution_yield(self) -> float:
return self.annual_yield / self.distribution_freq.payments_per_year
class DripService: class DripService:
"""Enhanced DRIP calculation service with improved performance and accuracy"""
def __init__(self) -> None: def __init__(self) -> None:
self.MAX_EROSION_LEVEL = 9 self.DISTRIBUTION_FREQUENCIES = {freq.display_name: freq for freq in DistributionFrequency}
self.max_monthly_erosion = 1 - (0.1)**(1/12) # ~17.54% monthly for 90% annual erosion self.nav_erosion_service = NavErosionService()
self.dividend_frequency = {
"Monthly": 12,
"Quarterly": 4,
"Semi-Annually": 2,
"Annually": 1,
"Unknown": 12 # Default to monthly if unknown
}
def calculate_drip_growth(self, portfolio_df: pd.DataFrame, config: DripConfig) -> DripResult: def calculate_drip_growth(self, portfolio_df: pd.DataFrame, config: DripConfig) -> DripResult:
""" """
Calculate DRIP growth for a portfolio over a specified period. Calculate DRIP growth for a portfolio over a specified period with enhanced accuracy.
Args: Args:
portfolio_df: DataFrame containing portfolio allocation portfolio_df: DataFrame containing portfolio allocation
@ -31,250 +64,299 @@ class DripService:
DripResult object containing the simulation results DripResult object containing the simulation results
""" """
try: try:
# Initialize monthly data list # Validate inputs
self._validate_inputs(portfolio_df, config)
# 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
}
# Initialize portfolio data
ticker_data = self._initialize_ticker_data(portfolio_df)
# Pre-calculate distribution schedule for performance
distribution_schedule = self._create_distribution_schedule(ticker_data, config.months)
# Initialize simulation state
simulation_state = self._initialize_simulation_state(ticker_data)
monthly_data: List[MonthlyData] = [] monthly_data: List[MonthlyData] = []
# Get initial values # Run monthly simulation
initial_shares = self._calculate_initial_shares(portfolio_df)
initial_prices = dict(zip(portfolio_df["Ticker"], portfolio_df["Price"]))
initial_yields = dict(zip(portfolio_df["Ticker"], portfolio_df["Yield (%)"] / 100))
# Initialize tracking variables
current_shares = initial_shares.copy()
current_prices = initial_prices.copy()
current_yields = initial_yields.copy()
cumulative_income = 0.0
# Run simulation for each month
for month in range(1, config.months + 1): for month in range(1, config.months + 1):
# Calculate monthly income month_result = self._simulate_month(
monthly_income = sum( month,
(current_yields[ticker] / 12) * simulation_state,
(current_shares[ticker] * current_prices[ticker]) ticker_data,
for ticker in current_shares.keys() erosion_rates,
distribution_schedule
) )
monthly_data.append(month_result)
# Update cumulative income
cumulative_income += monthly_income
# Calculate total portfolio value
total_value = sum(
current_shares[ticker] * current_prices[ticker]
for ticker in current_shares.keys()
)
# Apply erosion if enabled
if config.erosion_type != "None":
current_prices, current_yields = self._apply_erosion(
current_prices,
current_yields,
config.erosion_type,
config.erosion_level
)
# Reinvest dividends
for ticker in current_shares.keys():
dividend_income = (current_yields[ticker] / 12) * (current_shares[ticker] * current_prices[ticker])
new_shares = dividend_income / current_prices[ticker]
current_shares[ticker] += new_shares
# Store monthly data
monthly_data.append(MonthlyData(
month=month,
total_value=total_value,
monthly_income=monthly_income,
cumulative_income=cumulative_income,
shares=current_shares.copy(),
prices=current_prices.copy(),
yields=current_yields.copy()
))
# Calculate final values # Calculate final results
final_portfolio_value = monthly_data[-1].total_value return self._create_drip_result(monthly_data, simulation_state)
total_income = monthly_data[-1].cumulative_income
total_shares = current_shares.copy()
return DripResult(
monthly_data=monthly_data,
final_portfolio_value=final_portfolio_value,
total_income=total_income,
total_shares=total_shares
)
except Exception as e: except Exception as e:
logger.error(f"Error calculating DRIP growth: {str(e)}") logger.error(f"Error calculating DRIP growth: {str(e)}")
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
raise raise
def _calculate_initial_shares(self, portfolio_df: pd.DataFrame) -> Dict[str, float]:
"""Calculate initial shares for each ETF."""
return dict(zip(portfolio_df["Ticker"], portfolio_df["Shares"]))
def _apply_erosion(
self,
prices: Dict[str, float],
yields: Dict[str, float],
erosion_type: str,
erosion_level: Dict[str, Any]
) -> Tuple[Dict[str, float], Dict[str, float]]:
"""
Apply erosion to prices and yields based on configuration.
Args:
prices: Dictionary of current prices
yields: Dictionary of current yields
erosion_type: Type of erosion to apply
erosion_level: Dictionary containing erosion levels
Returns:
Tuple of updated prices and yields
"""
try:
updated_prices = prices.copy()
updated_yields = yields.copy()
if erosion_type == "None":
return updated_prices, updated_yields
if erosion_level.get("use_per_ticker", False):
# Apply per-ticker erosion rates
for ticker in prices.keys():
if ticker in erosion_level:
ticker_erosion = erosion_level[ticker]
# Apply monthly erosion
nav_erosion = (ticker_erosion["nav"] / 9) * 0.1754
yield_erosion = (ticker_erosion["yield"] / 9) * 0.1754
updated_prices[ticker] *= (1 - nav_erosion)
updated_yields[ticker] *= (1 - yield_erosion)
else:
# Apply global erosion rates
nav_erosion = (erosion_level["global"]["nav"] / 9) * 0.1754
yield_erosion = (erosion_level["global"]["yield"] / 9) * 0.1754
for ticker in prices.keys():
updated_prices[ticker] *= (1 - nav_erosion)
updated_yields[ticker] *= (1 - yield_erosion)
return updated_prices, updated_yields
except Exception as e:
logger.error(f"Error applying erosion: {str(e)}")
logger.error(traceback.format_exc())
return prices, yields
def _initialize_erosion_rates( def _validate_inputs(self, portfolio_df: pd.DataFrame, config: DripConfig) -> None:
self, """Validate input parameters"""
tickers: List[str], required_columns = ["Ticker", "Price", "Yield (%)", "Shares"]
erosion_type: str, missing_columns = [col for col in required_columns if col not in portfolio_df.columns]
erosion_level: Dict[str, Any]
) -> Tuple[Dict[str, float], Dict[str, float]]:
"""Initialize erosion rates for each ticker based on configuration."""
ticker_nav_rates: Dict[str, float] = {}
ticker_yield_rates: Dict[str, float] = {}
if erosion_type != "None" and isinstance(erosion_level, dict): if missing_columns:
if erosion_level.get("use_per_ticker", False) and "per_ticker" in erosion_level: raise ValueError(f"Missing required columns: {missing_columns}")
global_nav = erosion_level["global"]["nav"] / self.MAX_EROSION_LEVEL * self.max_monthly_erosion
global_yield = erosion_level["global"]["yield"] / self.MAX_EROSION_LEVEL * self.max_monthly_erosion if config.months <= 0:
raise ValueError("Months must be positive")
for ticker in tickers:
ticker_settings = erosion_level["per_ticker"].get(ticker, {"nav": 0, "yield": 0}) if portfolio_df.empty:
ticker_nav_rates[ticker] = ticker_settings["nav"] / self.MAX_EROSION_LEVEL * self.max_monthly_erosion raise ValueError("Portfolio DataFrame is empty")
ticker_yield_rates[ticker] = ticker_settings["yield"] / self.MAX_EROSION_LEVEL * self.max_monthly_erosion
else:
global_nav = erosion_level["global"]["nav"] / self.MAX_EROSION_LEVEL * self.max_monthly_erosion
global_yield = erosion_level["global"]["yield"] / self.MAX_EROSION_LEVEL * self.max_monthly_erosion
for ticker in tickers:
ticker_nav_rates[ticker] = global_nav
ticker_yield_rates[ticker] = global_yield
else:
for ticker in tickers:
ticker_nav_rates[ticker] = 0
ticker_yield_rates[ticker] = 0
return ticker_nav_rates, ticker_yield_rates
def _create_ticker_data(self, portfolio_df: pd.DataFrame) -> Dict[str, Dict[str, Any]]: def _initialize_ticker_data(self, portfolio_df: pd.DataFrame) -> Dict[str, TickerData]:
"""Create a dictionary of ticker-specific data.""" """Initialize ticker data with validation"""
ticker_data: Dict[str, Dict[str, Any]] = {} ticker_data = {}
for _, row in portfolio_df.iterrows(): for _, row in portfolio_df.iterrows():
ticker = row["Ticker"] ticker = row["Ticker"]
ticker_data[ticker] = {
"price": row["Price"], # Handle distribution frequency
"yield_annual": row["Yield (%)"] / 100, dist_period = row.get("Distribution Period", "Monthly")
"initial_shares": row["Capital Allocated ($)"] / row["Price"], dist_freq = self.DISTRIBUTION_FREQUENCIES.get(dist_period, DistributionFrequency.MONTHLY)
"initial_allocation": row["Allocation (%)"] / 100,
"distribution": row.get("Distribution Period", "Monthly") ticker_data[ticker] = TickerData(
} ticker=ticker,
price=max(0.01, float(row["Price"])), # Prevent zero/negative prices
annual_yield=max(0.0, float(row["Yield (%)"] / 100)), # Convert to decimal
shares=max(0.0, float(row["Shares"])),
allocation_pct=float(row.get("Allocation (%)", 0) / 100),
distribution_freq=dist_freq
)
return ticker_data return ticker_data
def _calculate_monthly_income( def _create_distribution_schedule(self, ticker_data: Dict[str, TickerData], total_months: int) -> Dict[str, List[int]]:
self, """Pre-calculate which months each ticker pays distributions"""
current_shares: Dict[str, float], schedule = {}
current_prices: Dict[str, float],
current_yields: Dict[str, float], for ticker, data in ticker_data.items():
tickers: List[str] distribution_months = []
) -> float: freq = data.distribution_freq
"""Calculate expected monthly income based on current portfolio and yields."""
return sum( for month in range(1, total_months + 1):
(current_yields[ticker] / 12) * if self._is_distribution_month(month, freq):
(current_shares[ticker] * current_prices[ticker]) distribution_months.append(month)
for ticker in tickers
) schedule[ticker] = distribution_months
return schedule
def _create_month_data( def _initialize_simulation_state(self, ticker_data: Dict[str, TickerData]) -> Dict[str, Any]:
"""Initialize simulation state variables"""
return {
'current_shares': {ticker: data.shares for ticker, data in ticker_data.items()},
'current_prices': {ticker: data.price for ticker, data in ticker_data.items()},
'current_yields': {ticker: data.annual_yield for ticker, data in ticker_data.items()},
'cumulative_income': 0.0
}
def _simulate_month(
self, self,
month: int, month: int,
current_total_value: float, state: Dict[str, Any],
monthly_income: float, ticker_data: Dict[str, TickerData],
cumulative_income: float, erosion_rates: Dict[str, Dict[str, float]],
current_shares: Dict[str, float], distribution_schedule: Dict[str, List[int]]
current_prices: Dict[str, float],
current_yields: Dict[str, float],
tickers: List[str]
) -> MonthlyData: ) -> MonthlyData:
"""Create monthly data object.""" """Simulate a single month with improved accuracy"""
# Calculate monthly income from distributions
monthly_income = self._calculate_monthly_distributions(
month, state, ticker_data, distribution_schedule
)
# 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)
# Calculate total portfolio value with bounds checking
total_value = 0.0
for ticker in ticker_data.keys():
shares = state['current_shares'][ticker]
price = state['current_prices'][ticker]
if shares > 0 and price > 0:
total_value += shares * price
return MonthlyData( return MonthlyData(
month=month, month=month,
total_value=current_total_value, total_value=total_value,
monthly_income=monthly_income, monthly_income=monthly_income,
cumulative_income=cumulative_income, cumulative_income=state['cumulative_income'],
shares=current_shares.copy(), shares=state['current_shares'].copy(),
prices=current_prices.copy(), prices=state['current_prices'].copy(),
yields=current_yields.copy() yields=state['current_yields'].copy()
) )
def _calculate_and_reinvest_dividends( def _calculate_monthly_distributions(
self, self,
month: int, month: int,
ticker_data: Dict[str, Dict[str, Any]], state: Dict[str, Any],
current_shares: Dict[str, float], ticker_data: Dict[str, TickerData],
current_prices: Dict[str, float], distribution_schedule: Dict[str, List[int]]
current_yields: Dict[str, float],
ticker_yield_rates: Dict[str, float],
dividend_frequency: Dict[str, int]
) -> float: ) -> float:
"""Calculate dividends and reinvest them proportionally.""" """Calculate distributions for the current month"""
month_dividends: Dict[str, float] = {} monthly_income = 0.0
for ticker, data in ticker_data.items():
freq = dividend_frequency[data["distribution"]]
if month % (12 / freq) == 0:
if ticker_yield_rates[ticker] > 0:
dividend = (current_yields[ticker] / freq) * current_shares[ticker] * current_prices[ticker]
else:
dividend = (data["yield_annual"] / freq) * current_shares[ticker] * current_prices[ticker]
else:
dividend = 0
month_dividends[ticker] = dividend
total_month_dividend = sum(month_dividends.values())
# Reinvest dividends proportionally
for ticker, data in ticker_data.items(): for ticker, data in ticker_data.items():
if current_prices[ticker] > 0: if month in distribution_schedule[ticker]:
new_shares = (total_month_dividend * data["initial_allocation"]) / current_prices[ticker] shares = state['current_shares'][ticker]
current_shares[ticker] += new_shares price = state['current_prices'][ticker]
yield_rate = state['current_yields'][ticker]
return total_month_dividend # Calculate distribution amount using annual yield divided by payments per year
distribution_yield = yield_rate / data.distribution_freq.payments_per_year
distribution_amount = shares * price * distribution_yield
monthly_income += distribution_amount
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,
state: Dict[str, Any],
distribution_schedule: Dict[str, List[int]]
) -> None:
"""Reinvest dividends for tickers that distributed in this month"""
for ticker, distribution_months in distribution_schedule.items():
if month in distribution_months:
shares = state['current_shares'][ticker]
price = state['current_prices'][ticker]
yield_rate = state['current_yields'][ticker]
# Calculate dividend income using the correct distribution frequency
freq = self.DISTRIBUTION_FREQUENCIES.get(ticker, DistributionFrequency.MONTHLY)
dividend_income = shares * price * (yield_rate / freq.payments_per_year)
# Purchase additional shares
if price > 0:
new_shares = dividend_income / price
state['current_shares'][ticker] += new_shares
def _is_distribution_month(self, month: int, frequency: DistributionFrequency) -> bool:
"""Check if current month is a distribution month"""
if frequency == DistributionFrequency.MONTHLY:
return True
elif frequency == DistributionFrequency.QUARTERLY:
return month % 3 == 0
elif frequency == DistributionFrequency.SEMI_ANNUALLY:
return month % 6 == 0
elif frequency == DistributionFrequency.ANNUALLY:
return month % 12 == 0
else:
return True # Default to monthly for unknown
def _create_drip_result(self, monthly_data: List[MonthlyData], state: Dict[str, Any]) -> DripResult:
"""Create final DRIP result object"""
if not monthly_data:
raise ValueError("No monthly data generated")
final_data = monthly_data[-1]
return DripResult(
monthly_data=monthly_data,
final_portfolio_value=final_data.total_value,
total_income=final_data.cumulative_income,
total_shares=state['current_shares'].copy()
)
# Utility methods for analysis and comparison
def calculate_drip_vs_no_drip_comparison(
self,
portfolio_df: pd.DataFrame,
config: DripConfig
) -> Dict[str, Any]:
"""Calculate comparison between DRIP and no-DRIP scenarios"""
# Calculate DRIP scenario
drip_result = self.calculate_drip_growth(portfolio_df, config)
# Calculate no-DRIP scenario (dividends not reinvested)
no_drip_result = self._calculate_no_drip_scenario(portfolio_df, config)
# Calculate comparison metrics
drip_advantage = drip_result.final_portfolio_value - no_drip_result['final_value']
percentage_advantage = (drip_advantage / no_drip_result['final_value']) * 100
return {
'drip_final_value': drip_result.final_portfolio_value,
'no_drip_final_value': no_drip_result['final_value'],
'drip_advantage': drip_advantage,
'percentage_advantage': percentage_advantage,
'total_dividends_reinvested': drip_result.total_income,
'cash_dividends_no_drip': no_drip_result['total_dividends']
}
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)
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
}
for result in erosion_data.results
}
state = self._initialize_simulation_state(ticker_data)
total_dividends = 0.0
for month in range(1, config.months + 1):
# Calculate dividends but don't reinvest
monthly_dividends = self._calculate_monthly_distributions(
month, state, ticker_data,
self._create_distribution_schedule(ticker_data, config.months)
)
total_dividends += monthly_dividends
# Apply erosion
self._apply_monthly_erosion(state, erosion_rates)
final_value = sum(
state['current_shares'][ticker] * state['current_prices'][ticker]
for ticker in ticker_data.keys()
)
return {
'final_value': final_value,
'total_dividends': total_dividends
}

View File

@ -0,0 +1,8 @@
"""
Nav Erosion Service package
"""
from .service import NavErosionService
from .models import NavErosionResult
__all__ = ['NavErosionService', 'NavErosionResult']