From 19f713673e0f3fa1b3b353aa1bb4d374b8900288 Mon Sep 17 00:00:00 2001 From: Pascal Date: Tue, 3 Jun 2025 19:51:16 +0200 Subject: [PATCH] Adding education tio DRIP/No-DRIP --- ETF_Portal/services/drip_service/__init__.py | 4 + .../services/drip_service/no_drip_service.py | 408 ++++++++ ETF_Portal/services/drip_service/service.py | 285 +++-- pages/ETF_Portfolio_Builder.py | 978 +++++++++++------- 4 files changed, 1256 insertions(+), 419 deletions(-) create mode 100644 ETF_Portal/services/drip_service/no_drip_service.py diff --git a/ETF_Portal/services/drip_service/__init__.py b/ETF_Portal/services/drip_service/__init__.py index f2a1469..13dc1d5 100644 --- a/ETF_Portal/services/drip_service/__init__.py +++ b/ETF_Portal/services/drip_service/__init__.py @@ -1,9 +1,13 @@ from .service import DRIPService +from .no_drip_service import NoDRIPService, NoDRIPMonthlyData, NoDRIPResult from .models import DRIPMetrics, DRIPForecastResult, DRIPPortfolioResult, DripConfig from .exceptions import DRIPError, DataFetchError, CalculationError, ValidationError, CacheError __all__ = [ 'DRIPService', + 'NoDRIPService', + 'NoDRIPMonthlyData', + 'NoDRIPResult', 'DRIPMetrics', 'DRIPForecastResult', 'DRIPPortfolioResult', diff --git a/ETF_Portal/services/drip_service/no_drip_service.py b/ETF_Portal/services/drip_service/no_drip_service.py new file mode 100644 index 0000000..72e32f7 --- /dev/null +++ b/ETF_Portal/services/drip_service/no_drip_service.py @@ -0,0 +1,408 @@ +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 ( + MonthlyData, + DripConfig, + DripResult, + DRIPMetrics, + DRIPForecastResult, + DRIPPortfolioResult +) +from ..nav_erosion_service import NavErosionService + +# Duplicate necessary classes to avoid circular import +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 + +@dataclass +class ErosionConfig: + """Configuration for erosion calculations""" + erosion_type: str + erosion_level: Dict[str, Dict[str, float]] # Changed to match NavErosionService output + +# Configure logging +logger = logging.getLogger(__name__) + +__all__ = ['NoDRIPService', 'NoDRIPMonthlyData', 'NoDRIPResult'] + +@dataclass +class NoDRIPMonthlyData: + """Data for a single month in the No-DRIP simulation""" + month: int + portfolio_value: float # Original shares * current prices + monthly_income: float # Dividends received as cash + cumulative_income: float # Total cash accumulated + prices: Dict[str, float] # Current (eroded) prices + yields: Dict[str, float] # Current (eroded) yields + original_shares: Dict[str, float] # Original shares (constant) + +@dataclass +class NoDRIPResult: + """Results of a No-DRIP calculation""" + monthly_data: List[NoDRIPMonthlyData] + final_portfolio_value: float # Original shares * final prices + total_cash_income: float # All dividends as cash + total_value: float # Portfolio value + cash + original_shares: Dict[str, float] # Original share counts + +class NoDRIPService: + """No-DRIP calculation service - dividends are kept as cash, not reinvested""" + + def __init__(self) -> None: + 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_no_drip_growth(self, portfolio_df: pd.DataFrame, config: DripConfig) -> NoDRIPResult: + """ + Calculate No-DRIP growth for a portfolio over a specified period. + In No-DRIP strategy, dividends are kept as cash and not reinvested. + + Args: + portfolio_df: DataFrame containing portfolio allocation + config: DripConfig object with simulation parameters + + Returns: + NoDRIPResult object containing the simulation results + """ + try: + # Validate inputs (reuse from DRIP service) + self._validate_inputs(portfolio_df, config) + + # 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 No-DRIP 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 + ) + + # Pre-calculate distribution schedule for performance + distribution_schedule = self._create_distribution_schedule(ticker_data, config.months) + + # Initialize simulation state (shares remain constant in No-DRIP) + simulation_state = self._initialize_simulation_state(ticker_data) + monthly_data: List[NoDRIPMonthlyData] = [] + + # Create monthly tracking table + monthly_tracking = [] + + # Run monthly simulation + for month in range(1, config.months + 1): + # Calculate monthly income from distributions (keep as cash) + monthly_income = self._calculate_monthly_distributions( + month, simulation_state, ticker_data, distribution_schedule + ) + + # Update cumulative cash income + simulation_state['cumulative_cash'] += monthly_income + + # Apply erosion to prices and yields (but NOT to shares) + if erosion_config.erosion_type != "None": + self._apply_monthly_erosion(simulation_state, erosion_config, ticker_data.keys()) + + # Calculate portfolio value (original shares * current eroded prices) + portfolio_value = sum( + simulation_state['original_shares'][ticker] * simulation_state['current_prices'][ticker] + for ticker in ticker_data.keys() + ) + + # Total value = portfolio + cash + total_value = portfolio_value + simulation_state['cumulative_cash'] + + # Add to monthly tracking + monthly_tracking.append({ + 'Month': month, + 'Portfolio Value': portfolio_value, + 'Monthly Income': monthly_income, + 'Cumulative Income': simulation_state['cumulative_cash'], + 'Total Value': total_value, + 'Prices': {ticker: simulation_state['current_prices'][ticker] for ticker in ticker_data.keys()}, + 'Yields': {ticker: simulation_state['current_yields'][ticker] for ticker in ticker_data.keys()} + }) + + # Create monthly data + monthly_data.append(NoDRIPMonthlyData( + month=month, + portfolio_value=portfolio_value, + monthly_income=monthly_income, + cumulative_income=simulation_state['cumulative_cash'], + prices=simulation_state['current_prices'].copy(), + yields=simulation_state['current_yields'].copy(), + original_shares=simulation_state['original_shares'].copy() + )) + + # Print monthly tracking table + print("\nMonthly No-DRIP Simulation Results:") + print("=" * 100) + print(f"{'Month':<6} {'Portfolio Value':<15} {'Monthly Income':<15} {'Cumulative Income':<18} {'Total Value':<15}") + print("-" * 100) + + for month_data in monthly_tracking: + print(f"{month_data['Month']:<6} ${month_data['Portfolio Value']:<14.2f} ${month_data['Monthly Income']:<14.2f} ${month_data['Cumulative Income']:<17.2f} ${month_data['Total Value']:<14.2f}") + + print("=" * 100) + + # Calculate final results + return self._create_no_drip_result(monthly_data, simulation_state) + + except Exception as e: + logger.error(f"Error calculating No-DRIP growth: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def _validate_inputs(self, portfolio_df: pd.DataFrame, config: DripConfig) -> None: + """Validate input parameters (reuse from DRIP service)""" + required_columns = ["Ticker", "Price", "Yield (%)", "Shares"] + missing_columns = [col for col in required_columns if col not in portfolio_df.columns] + + if missing_columns: + raise ValueError(f"Missing required columns: {missing_columns}") + + if config.months <= 0: + raise ValueError("Months must be positive") + + if portfolio_df.empty: + raise ValueError("Portfolio DataFrame is empty") + + def _initialize_ticker_data(self, portfolio_df: pd.DataFrame) -> Dict[str, TickerData]: + """Initialize ticker data with validation (reuse from DRIP service)""" + ticker_data = {} + + for _, row in portfolio_df.iterrows(): + ticker = row["Ticker"] + + # Handle distribution frequency + dist_period = row.get("Distribution Period", "Monthly") + dist_freq = self.DISTRIBUTION_FREQUENCIES.get(dist_period, DistributionFrequency.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 + + def _parse_erosion_config(self, config: DripConfig) -> ErosionConfig: + """Parse and validate erosion configuration (reuse from DRIP service)""" + 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 + ) + + 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 (reuse from DRIP service)""" + try: + # Use NavErosionService to analyze the tickers + analysis = self.nav_erosion_service.analyze_etf_erosion_risk(tickers) + + # Convert to format expected by No-DRIP service + erosion_config = self.nav_erosion_service.convert_to_drip_erosion_config(analysis) + + logger.info(f"Calculated erosion rates for No-DRIP tickers: {tickers}") + logger.info(f"Erosion configuration: {erosion_config}") + + return erosion_config + + except Exception as e: + logger.error(f"Error calculating erosion rates for No-DRIP: {str(e)}") + logger.warning("Falling back to no erosion") + return {"per_ticker": {ticker: {"nav": 0.0, "yield": 0.0} for ticker in tickers}} + + 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)""" + schedule = {} + + for ticker, data in ticker_data.items(): + distribution_months = [] + freq = data.distribution_freq + + for month in range(1, total_months + 1): + if self._is_distribution_month(month, freq): + distribution_months.append(month) + + schedule[ticker] = distribution_months + + return schedule + + def _initialize_simulation_state(self, ticker_data: Dict[str, TickerData]) -> Dict[str, Any]: + """Initialize simulation state variables""" + return { + 'original_shares': {ticker: data.shares for ticker, data in ticker_data.items()}, # Constant + '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_cash': 0.0 # Cash accumulated from dividends + } + + def _calculate_monthly_distributions( + self, + month: int, + state: Dict[str, Any], + ticker_data: Dict[str, TickerData], + distribution_schedule: Dict[str, List[int]] + ) -> float: + """Calculate distributions for the current month (reuse logic from DRIP service)""" + monthly_income = 0.0 + + for ticker, data in ticker_data.items(): + if month in distribution_schedule[ticker]: + shares = state['original_shares'][ticker] # Original shares (constant) + price = state['current_prices'][ticker] + yield_rate = state['current_yields'][ticker] + + # Calculate distribution amount using current (eroded) values + distribution_yield = yield_rate / data.distribution_freq.payments_per_year + distribution_amount = shares * price * distribution_yield + monthly_income += distribution_amount + + # Log distribution calculation + logger.info(f"Month {month} No-DRIP distribution for {ticker}:") + logger.info(f" Shares: {shares:.4f} (constant)") + logger.info(f" Price: ${price:.2f}") + logger.info(f" Yield: {yield_rate:.2%}") + logger.info(f" Distribution: ${distribution_amount:.2f}") + + return monthly_income + + def _apply_monthly_erosion( + self, + state: Dict[str, Any], + erosion_config: ErosionConfig, + tickers: List[str] + ) -> None: + """Apply monthly erosion to prices and yields (reuse from DRIP service)""" + 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 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_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 monthly erosion to {ticker} (No-DRIP):") + 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 _is_distribution_month(self, month: int, frequency: DistributionFrequency) -> bool: + """Check if current month is a distribution month (reuse from DRIP service)""" + 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_no_drip_result(self, monthly_data: List[NoDRIPMonthlyData], state: Dict[str, Any]) -> NoDRIPResult: + """Create final No-DRIP result object""" + if not monthly_data: + raise ValueError("No monthly data generated") + + final_data = monthly_data[-1] + + return NoDRIPResult( + monthly_data=monthly_data, + final_portfolio_value=final_data.portfolio_value, + total_cash_income=state['cumulative_cash'], + total_value=final_data.portfolio_value + state['cumulative_cash'], + original_shares=state['original_shares'].copy() + ) \ No newline at end of file diff --git a/ETF_Portal/services/drip_service/service.py b/ETF_Portal/services/drip_service/service.py index 543ac63..b70c8b6 100644 --- a/ETF_Portal/services/drip_service/service.py +++ b/ETF_Portal/services/drip_service/service.py @@ -68,6 +68,7 @@ class DRIPService: 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() + self.no_drip_service = None # Will be initialized when needed to avoid circular import def calculate_drip_growth(self, portfolio_df: pd.DataFrame, config: DripConfig) -> DripResult: """ @@ -428,79 +429,8 @@ class DRIPService: 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) - - # 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 - - 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 - if erosion_config.erosion_type != "None": - self._apply_monthly_erosion(state, erosion_config, ticker_data.keys()) - - 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 - } + # Utility methods for analysis and comparison - duplicate method removed + # The main calculate_drip_vs_no_drip_comparison method is defined below def forecast_portfolio( self, @@ -594,3 +524,212 @@ class DRIPService: logger.error(f"Error forecasting portfolio: {str(e)}") logger.error(traceback.format_exc()) raise + + def calculate_drip_vs_no_drip_comparison( + self, + portfolio_df: pd.DataFrame, + config: DripConfig + ) -> Dict[str, Any]: + """ + Calculate and compare DRIP vs No-DRIP strategies with detailed analysis. + This method runs both simulations and displays comparison tables. + + Args: + portfolio_df: DataFrame containing portfolio allocation + config: DripConfig object with simulation parameters + + Returns: + Dict containing both results and comparison analysis + """ + try: + # Initialize No-DRIP service if needed + if self.no_drip_service is None: + from .no_drip_service import NoDRIPService + self.no_drip_service = NoDRIPService() + + # Calculate initial investment + initial_investment = (portfolio_df['Price'] * portfolio_df['Shares']).sum() + + # Run DRIP simulation (this will print the DRIP table) + logger.info("Running DRIP simulation...") + drip_result = self.calculate_drip_growth(portfolio_df, config) + + # Run No-DRIP simulation (this will print the No-DRIP table) + logger.info("Running No-DRIP simulation...") + no_drip_result = self.no_drip_service.calculate_no_drip_growth(portfolio_df, config) + + # Calculate break-even analysis + drip_break_even = self._calculate_break_even_analysis( + "DRIP", drip_result.monthly_data, initial_investment, + lambda md: md.total_value + ) + + no_drip_break_even = self._calculate_break_even_analysis( + "No-DRIP", no_drip_result.monthly_data, initial_investment, + lambda md: md.portfolio_value + md.cumulative_income + ) + + # Determine winner + drip_final = drip_result.final_portfolio_value + no_drip_final = no_drip_result.total_value + + if drip_final > no_drip_final: + winner = "DRIP" + advantage_amount = drip_final - no_drip_final + advantage_percentage = (advantage_amount / no_drip_final) * 100 + elif no_drip_final > drip_final: + winner = "No-DRIP" + advantage_amount = no_drip_final - drip_final + advantage_percentage = (advantage_amount / drip_final) * 100 + else: + winner = "Tie" + advantage_amount = 0.0 + advantage_percentage = 0.0 + + # Print comparison table + self._print_strategy_comparison( + drip_result, no_drip_result, initial_investment, + winner, advantage_amount, advantage_percentage, + drip_break_even, no_drip_break_even + ) + + return { + 'drip_result': drip_result, + 'no_drip_result': no_drip_result, + 'initial_investment': initial_investment, + 'drip_final_value': drip_final, + 'no_drip_final_value': no_drip_final, + 'winner': winner, + 'advantage_amount': advantage_amount, + 'advantage_percentage': advantage_percentage, + 'drip_break_even': drip_break_even, + 'no_drip_break_even': no_drip_break_even, + 'comparison_summary': self._generate_comparison_summary( + drip_final, no_drip_final, initial_investment, winner, advantage_percentage + ) + } + + except Exception as e: + logger.error(f"Error in DRIP vs No-DRIP comparison: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def _calculate_break_even_analysis( + self, + strategy_name: str, + monthly_data: List, + initial_investment: float, + value_extractor: callable + ) -> Dict[str, Any]: + """Calculate break-even analysis for a strategy""" + + break_even_month = None + profit_at_break_even = 0.0 + + for month_data in monthly_data: + total_value = value_extractor(month_data) + profit = total_value - initial_investment + + if profit > 0 and break_even_month is None: + break_even_month = month_data.month + profit_at_break_even = profit + break + + # Format break-even time + if break_even_month is None: + months_to_break_even = "Never (within simulation period)" + else: + years = break_even_month // 12 + months = break_even_month % 12 + if years > 0: + months_to_break_even = f"{years} year(s) and {months} month(s)" + else: + months_to_break_even = f"{months} month(s)" + + return { + 'strategy_name': strategy_name, + 'break_even_month': break_even_month, + 'profit_at_break_even': profit_at_break_even, + 'months_to_break_even': months_to_break_even, + 'initial_investment': initial_investment + } + + def _print_strategy_comparison( + self, + drip_result: DripResult, + no_drip_result: Any, # NoDRIPResult + initial_investment: float, + winner: str, + advantage_amount: float, + advantage_percentage: float, + drip_break_even: Dict[str, Any], + no_drip_break_even: Dict[str, Any] + ) -> None: + """Print detailed strategy comparison table""" + + print("\n" + "="*100) + print("DRIP vs No-DRIP STRATEGY COMPARISON") + print("="*100) + + print(f"{'Metric':<35} {'DRIP Strategy':<25} {'No-DRIP Strategy':<25}") + print("-"*100) + + print(f"{'Initial Investment':<35} ${initial_investment:<24,.2f} ${initial_investment:<24,.2f}") + print(f"{'Final Portfolio Value':<35} ${drip_result.final_portfolio_value:<24,.2f} ${no_drip_result.final_portfolio_value:<24,.2f}") + print(f"{'Total Cash Income':<35} ${drip_result.total_income:<24,.2f} ${no_drip_result.total_cash_income:<24,.2f}") + print(f"{'Total Final Value':<35} ${drip_result.final_portfolio_value:<24,.2f} ${no_drip_result.total_value:<24,.2f}") + + drip_return = ((drip_result.final_portfolio_value / initial_investment) - 1) * 100 + no_drip_return = ((no_drip_result.total_value / initial_investment) - 1) * 100 + + print(f"{'Total Return %':<35} {drip_return:<24.1f}% {no_drip_return:<24.1f}%") + + # Break-even analysis + print(f"{'Break-even Time':<35} {drip_break_even['months_to_break_even']:<25} {no_drip_break_even['months_to_break_even']:<25}") + + print("-"*100) + print(f"WINNER: {winner}") + if winner != "Tie": + print(f"ADVANTAGE: ${advantage_amount:,.2f} ({advantage_percentage:.1f}%)") + + # Investment recommendation + recommendation = self._generate_investment_recommendation(winner, advantage_percentage) + print(f"RECOMMENDATION: {recommendation}") + print("="*100) + + def _generate_investment_recommendation(self, winner: str, advantage_percentage: float) -> str: + """Generate investment recommendation based on comparison results""" + + if winner == "Tie": + return "Both strategies perform equally. Choose based on your liquidity needs." + + if advantage_percentage < 1.0: + return f"{winner} wins by a small margin ({advantage_percentage:.1f}%). Choose based on liquidity preferences." + elif advantage_percentage < 5.0: + return f"{winner} strategy is recommended with a {advantage_percentage:.1f}% advantage." + else: + return f"{winner} strategy is strongly recommended with a {advantage_percentage:.1f}% advantage." + + def _generate_comparison_summary( + self, + drip_final: float, + no_drip_final: float, + initial_investment: float, + winner: str, + advantage_percentage: float + ) -> str: + """Generate comparison summary""" + + drip_total_return = ((drip_final / initial_investment) - 1) * 100 + no_drip_total_return = ((no_drip_final / initial_investment) - 1) * 100 + + summary = f"Initial Investment: ${initial_investment:,.2f}\n" + summary += f"DRIP Final Value: ${drip_final:,.2f} (Total Return: {drip_total_return:.1f}%)\n" + summary += f"No-DRIP Final Value: ${no_drip_final:,.2f} (Total Return: {no_drip_total_return:.1f}%)\n" + + if winner != "Tie": + summary += f"Winner: {winner} strategy ({advantage_percentage:.1f}% advantage)" + else: + summary += "Result: Both strategies perform equally" + + return summary diff --git a/pages/ETF_Portfolio_Builder.py b/pages/ETF_Portfolio_Builder.py index 5a7a56e..f90d83a 100644 --- a/pages/ETF_Portfolio_Builder.py +++ b/pages/ETF_Portfolio_Builder.py @@ -2052,283 +2052,7 @@ with st.sidebar: parallel_processing = st.checkbox("Enable Parallel Processing", value=True, help="Fetch data for multiple ETFs simultaneously") -def display_drip_forecast(portfolio_result, tickers): - """Display DRIP forecast results and charts.""" - try: - # Validate portfolio results - if not portfolio_result or not portfolio_result.etf_results: - st.error("No portfolio results available") - return - - # Display erosion table - st.subheader("ETF Erosion Analysis") - erosion_data = [] - - for ticker, etf_result in portfolio_result.etf_results.items(): - # Get erosion analysis for this ticker - from ETF_Portal.services.nav_erosion_service import NavErosionService - erosion_service = NavErosionService() - erosion_analysis = erosion_service.analyze_etf_erosion_risk([ticker]) - - if erosion_analysis and erosion_analysis.results: - result = erosion_analysis.results[0] - erosion_data.append({ - "Ticker": ticker, - "NAV Erosion (Annual %)": f"{result.estimated_nav_erosion:.2%}", - "Yield Erosion (Annual %)": f"{result.estimated_yield_erosion:.2%}" - }) - - if erosion_data: - st.dataframe( - pd.DataFrame(erosion_data), - use_container_width=True, - hide_index=True - ) - else: - st.warning("No erosion data available for the selected ETFs") - - # Display portfolio summary - st.subheader("Portfolio Summary") - - # Calculate total values - total_value = portfolio_result.total_value - total_income = portfolio_result.total_income - accumulated_cash = portfolio_result.accumulated_cash - - # Calculate initial values - initial_investment = sum( - etf_result.initial_value - for etf_result in portfolio_result.etf_results.values() - ) - initial_monthly_income = sum( - etf_result.initial_value * (etf_result.average_yield / 12) - for etf_result in portfolio_result.etf_results.values() - ) - - # Calculate variations - portfolio_variation = ((total_value - initial_investment) / initial_investment) * 100 - monthly_income_variation = ((portfolio_result.monthly_income - initial_monthly_income) / initial_monthly_income) * 100 - - # Create columns for key metrics - col1, col2, col3 = st.columns(3) - - with col1: - st.metric( - "Portfolio Value", - f"${total_value:,.2f}", - f"{portfolio_variation:+.1f}%" if portfolio_variation >= 0 else f"{portfolio_variation:.1f}%", - delta_color="off" if portfolio_variation < 0 else "normal" - ) - with col2: - st.metric( - "Monthly Income", - f"${portfolio_result.monthly_income:,.2f}", - f"{monthly_income_variation:+.1f}%" if monthly_income_variation >= 0 else f"{monthly_income_variation:.1f}%", - delta_color="off" if monthly_income_variation < 0 else "normal" - ) - with col3: - st.metric( - "Total Income", - f"${total_income:,.2f}" - ) - - # Create comparison chart - st.subheader("DRIP vs No-DRIP Comparison") - - # Get DRIP and No-DRIP results - from ETF_Portal.services.drip_service import DRIPService - drip_service = DRIPService() - - # Calculate DRIP scenario - drip_result = drip_service.forecast_portfolio( - portfolio_df=final_alloc, - config=DripConfig( - months=12, - erosion_type=st.session_state.get("erosion_type", "None"), - erosion_level={ - "nav": float(st.session_state.erosion_level.get("nav", 5.0)), - "yield": float(st.session_state.erosion_level.get("yield", 5.0)) - } - ), - tickers=tickers - ) - - # Calculate No-DRIP scenario - nodrip_result = drip_service.forecast_portfolio( - portfolio_df=final_alloc, - config=DripConfig( - months=12, - erosion_type=st.session_state.get("erosion_type", "None"), - erosion_level={ - "nav": float(st.session_state.erosion_level.get("nav", 5.0)), - "yield": float(st.session_state.erosion_level.get("yield", 5.0)) - } - ), - tickers=tickers - ) - - # Create comparison data - comparison_data = { - "Strategy": ["DRIP", "No-DRIP"], - "Portfolio Value": [ - drip_result.total_value, - nodrip_result.total_value - ], - "Accumulated Cash": [ - 0, - nodrip_result.accumulated_cash - ], - "Total Value": [ - drip_result.total_value, - nodrip_result.total_value + nodrip_result.accumulated_cash - ] - } - - # Create comparison chart - fig = go.Figure() - - # Add DRIP bars - fig.add_trace(go.Bar( - name="DRIP", - x=["Portfolio Value"], - y=[drip_result.total_value], - marker_color="#1f77b4" - )) - - # Add No-DRIP bars - fig.add_trace(go.Bar( - name="No-DRIP Portfolio", - x=["Portfolio Value"], - y=[nodrip_result.total_value], - marker_color="#ff7f0e" - )) - fig.add_trace(go.Bar( - name="No-DRIP Cash", - x=["Portfolio Value"], - y=[nodrip_result.accumulated_cash], - marker_color="#2ca02c", - base=nodrip_result.total_value - )) - - fig.update_layout( - title="DRIP vs No-DRIP Comparison", - barmode="group", - template="plotly_dark", - showlegend=True, - yaxis_title="Value ($)" - ) - - st.plotly_chart(fig, use_container_width=True) - - # Display detailed comparison table - st.subheader("Detailed Comparison") - - comparison_df = pd.DataFrame({ - "Metric": [ - "Portfolio Value", - "Accumulated Cash", - "Total Value", - "Monthly Income", - "Total Income", - "Share Growth" - ], - "DRIP": [ - f"${drip_result.total_value:,.2f}", - "$0.00", - f"${drip_result.total_value:,.2f}", - f"${drip_result.monthly_income:,.2f}", - f"${drip_result.total_income:,.2f}", - f"{((drip_result.etf_results[tickers[0]].final_shares / drip_result.etf_results[tickers[0]].initial_shares - 1) * 100):.1f}%" - ], - "No-DRIP": [ - f"${nodrip_result.total_value:,.2f}", - f"${nodrip_result.accumulated_cash:,.2f}", - f"${nodrip_result.total_value + nodrip_result.accumulated_cash:,.2f}", - f"${nodrip_result.monthly_income:,.2f}", - f"${nodrip_result.total_income:,.2f}", - "0%" - ] - }) - - st.dataframe(comparison_df, use_container_width=True, hide_index=True) - - # Display assumptions - st.info(""" - **Assumptions:** - - DRIP: All dividends are reinvested to buy more shares - - No-DRIP: Dividends are taken as cash income - - Both strategies are affected by NAV & Yield erosion - - Portfolio value changes due to NAV erosion and share growth (DRIP) or cash accumulation (No-DRIP) - """) - - # Add detailed allocation table for validation - st.subheader("Detailed Allocation") - - # Create detailed allocation data - allocation_data = [] - for ticker, etf_result in portfolio_result.etf_results.items(): - # Get initial values - initial_value = etf_result.initial_value - initial_shares = etf_result.initial_shares - initial_yield = etf_result.average_yield - initial_monthly_income = initial_value * (initial_yield / 12) - - # Get final values for comparison - final_value = etf_result.final_value - final_shares = etf_result.final_shares - final_monthly_income = final_value * (etf_result.average_yield / 12) - - # Calculate variations - value_variation = ((final_value - initial_value) / initial_value) * 100 - shares_variation = ((final_shares - initial_shares) / initial_shares) * 100 - income_variation = ((final_monthly_income - initial_monthly_income) / initial_monthly_income) * 100 - - allocation_data.append({ - "Ticker": ticker, - "Initial Value": f"${initial_value:,.2f}", - "Initial Shares": f"{initial_shares:,.4f}", - "Initial Monthly Income": f"${initial_monthly_income:,.2f}", - "Final Value": f"${final_value:,.2f}", - "Final Shares": f"{final_shares:,.4f}", - "Final Monthly Income": f"${final_monthly_income:,.2f}", - "Value Change": f"{value_variation:+.1f}%", - "Shares Change": f"{shares_variation:+.1f}%", - "Income Change": f"{income_variation:+.1f}%" - }) - - # Create DataFrame and display - allocation_df = pd.DataFrame(allocation_data) - st.dataframe( - allocation_df, - use_container_width=True, - hide_index=True, - column_config={ - "Ticker": st.column_config.TextColumn("Ticker", disabled=True), - "Initial Value": st.column_config.TextColumn("Initial Value", disabled=True), - "Initial Shares": st.column_config.TextColumn("Initial Shares", disabled=True), - "Initial Monthly Income": st.column_config.TextColumn("Initial Monthly Income", disabled=True), - "Final Value": st.column_config.TextColumn("Final Value", disabled=True), - "Final Shares": st.column_config.TextColumn("Final Shares", disabled=True), - "Final Monthly Income": st.column_config.TextColumn("Final Monthly Income", disabled=True), - "Value Change": st.column_config.TextColumn("Value Change", disabled=True), - "Shares Change": st.column_config.TextColumn("Shares Change", disabled=True), - "Income Change": st.column_config.TextColumn("Income Change", disabled=True) - } - ) - - # Add explanation - st.info(""" - **Table Explanation:** - - Initial Values: Starting values before DRIP and erosion effects - - Final Values: Values after applying DRIP and erosion effects - - Changes: Percentage variations between initial and final values - - Positive changes indicate growth, negative changes indicate erosion - """) - - except Exception as e: - st.error(f"Error calculating DRIP forecast: {str(e)}") - logger.error(f"DRIP forecast error: {str(e)}") - logger.error(traceback.format_exc()) +# Function removed - DRIP vs No-DRIP comparison is now handled directly in tab2 # Display results and interactive allocation adjustment UI after simulation is run if st.session_state.simulation_run and st.session_state.df_data is not None: @@ -2366,98 +2090,660 @@ if st.session_state.simulation_run and st.session_state.df_data is not None: st.error(f"Error displaying capital investment information: {str(e)}") with tab2: - st.subheader("DRIP Forecast") + st.subheader("📊 DRIP vs No-DRIP Comparison") - # Calculate DRIP scenario - logger.info("Calculating DRIP scenario...") + # Calculate both DRIP and No-DRIP scenarios + logger.info("Calculating DRIP vs No-DRIP comparison...") try: # Initialize DRIP service - from ETF_Portal.services.drip_service import DRIPService + from ETF_Portal.services.drip_service import DRIPService, DripConfig drip_service = DRIPService() - # Get erosion values from nav_erosion_service - from ETF_Portal.services.nav_erosion_service import NavErosionService - erosion_service = NavErosionService() - erosion_analysis = erosion_service.analyze_etf_erosion_risk(final_alloc["Ticker"].tolist()) - - # Update erosion values if analysis is available - if erosion_analysis and erosion_analysis.results: - # Use the highest erosion values from the analysis - nav_erosion = max(result.estimated_nav_erosion * 100 for result in erosion_analysis.results) - yield_erosion = max(result.estimated_yield_erosion * 100 for result in erosion_analysis.results) - - st.session_state.erosion_level = { - "nav": float(nav_erosion), - "yield": float(yield_erosion) - } - st.session_state.erosion_type = "NAV & Yield Erosion" - - # Create DRIP config with per-ticker rates + # Create DRIP config (let service auto-calculate erosion rates) config = DripConfig( months=12, - erosion_type=st.session_state.erosion_type, - erosion_level={ - "nav": float(st.session_state.erosion_level.get("nav", 5.0)), - "yield": float(st.session_state.erosion_level.get("yield", 5.0)) - } + erosion_type=st.session_state.get("erosion_type", "Conservative"), + erosion_level={} # Let the service calculate this automatically ) - # Debug information - st.write("Debug Information:") - st.write(f"Session state erosion_level: {st.session_state.erosion_level}") - st.write(f"Session state erosion_type: {st.session_state.erosion_type}") - - # Calculate DRIP result - drip_result = drip_service.calculate_drip_growth( + # Calculate DRIP vs No-DRIP comparison using the integrated method + comparison_result = drip_service.calculate_drip_vs_no_drip_comparison( portfolio_df=final_alloc, config=config ) - # Display summary metrics - col1, col2, col3 = st.columns(3) + # Display comparison summary metrics + st.subheader("📈 Strategy Performance Summary") + col1, col2, col3, col4 = st.columns(4) + with col1: - st.metric("Portfolio Value", f"${drip_result.final_portfolio_value:,.2f}") + st.metric( + "DRIP Final Value", + f"${comparison_result['drip_final_value']:,.2f}" + ) with col2: - # Calculate monthly income from total income - monthly_income = drip_result.total_income / 12 - st.metric("Monthly Income", f"${monthly_income:,.2f}") + st.metric( + "No-DRIP Final Value", + f"${comparison_result['no_drip_final_value']:,.2f}" + ) with col3: - st.metric("Total Income", f"${drip_result.total_income:,.2f}") + winner = comparison_result['winner'] + advantage = comparison_result['advantage_percentage'] + st.metric( + "Winner", + winner, + f"{advantage:.1f}% advantage" if winner != "Tie" else "Equal performance" + ) + with col4: + st.metric( + "Advantage Amount", + f"${comparison_result['advantage_amount']:,.2f}" if winner != "Tie" else "$0.00" + ) - # Display monthly tracking table - st.subheader("Monthly Details") + # Display Enhanced Monthly Details Tables + st.subheader("📅 Monthly Details") - # Create DataFrame for monthly tracking - monthly_data = [] - for month_data in drip_result.monthly_data: - shares_str = ", ".join([f"{ticker}: {shares:.4f}" for ticker, shares in month_data.shares.items()]) - monthly_data.append({ - 'Month': month_data.month, - 'Portfolio Value': f"${month_data.total_value:,.2f}", - 'Monthly Income': f"${month_data.monthly_income:,.2f}", - 'Cumulative Income': f"${month_data.cumulative_income:,.2f}", - 'Shares': shares_str, - 'Prices': ", ".join([f"{ticker}: ${price:.2f}" for ticker, price in month_data.prices.items()]), - 'Yields': ", ".join([f"{ticker}: {yield_rate:.2%}" for ticker, yield_rate in month_data.yields.items()]) - }) + # Get initial values for erosion tracking + drip_result = comparison_result['drip_result'] + no_drip_result = comparison_result['no_drip_result'] - # Convert to DataFrame and display - monthly_df = pd.DataFrame(monthly_data) - st.dataframe(monthly_df, use_container_width=True) + # Extract initial portfolio data for reference + initial_portfolio_data = {} + for _, row in final_alloc.iterrows(): + ticker = row['Ticker'] + initial_portfolio_data[ticker] = { + 'initial_price': float(row['Price']), + 'initial_yield': float(row['Yield (%)']) / 100, + 'initial_shares': float(row['Shares']) + } - # Add download button for the data - csv = monthly_df.to_csv(index=False) - st.download_button( - label="Download Monthly Data", - data=csv, - file_name="drip_monthly_details.csv", - mime="text/csv" + # Create tabs for different detail views + detail_tab1, detail_tab2, detail_tab3 = st.tabs([ + "📊 Summary Tables", + "🔍 Per-Ticker Details", + "📈 Erosion Tracking" + ]) + + with detail_tab1: + # Summary tables (existing functionality but enhanced) + col1, col2 = st.columns(2) + + with col1: + st.subheader("🔄 DRIP Monthly Summary") + drip_monthly_data = [] + for month_data in drip_result.monthly_data: + # Calculate month-over-month changes + if month_data.month > 1: + prev_data = drip_result.monthly_data[month_data.month - 2] + portfolio_change = month_data.total_value - prev_data.total_value + portfolio_change_pct = (portfolio_change / prev_data.total_value) * 100 + else: + portfolio_change = 0 + portfolio_change_pct = 0 + + # Calculate total shares + total_shares = sum(month_data.shares.values()) + + drip_monthly_data.append({ + 'Month': month_data.month, + 'Portfolio Value': f"${month_data.total_value:,.2f}", + 'Monthly Change': f"{portfolio_change_pct:+.1f}%" if month_data.month > 1 else "N/A", + 'Monthly Income': f"${month_data.monthly_income:,.2f}", + 'Cumulative Income': f"${month_data.cumulative_income:,.2f}", + 'Total Shares': f"{total_shares:.4f}", + 'Avg Price': f"${month_data.total_value / total_shares:.2f}" if total_shares > 0 else "N/A" + }) + + drip_monthly_df = pd.DataFrame(drip_monthly_data) + st.dataframe(drip_monthly_df, use_container_width=True, hide_index=True, height=400) + + with col2: + st.subheader("💰 No-DRIP Monthly Summary") + no_drip_monthly_data = [] + for month_data in no_drip_result.monthly_data: + # Calculate month-over-month changes + if month_data.month > 1: + prev_data = no_drip_result.monthly_data[month_data.month - 2] + portfolio_change = month_data.portfolio_value - prev_data.portfolio_value + portfolio_change_pct = (portfolio_change / prev_data.portfolio_value) * 100 + else: + portfolio_change = 0 + portfolio_change_pct = 0 + + total_value = month_data.portfolio_value + month_data.cumulative_income + + no_drip_monthly_data.append({ + 'Month': month_data.month, + 'Portfolio Value': f"${month_data.portfolio_value:,.2f}", + 'Monthly Change': f"{portfolio_change_pct:+.1f}%" if month_data.month > 1 else "N/A", + 'Monthly Income': f"${month_data.monthly_income:,.2f}", + 'Cumulative Cash': f"${month_data.cumulative_income:,.2f}", + 'Total Value': f"${total_value:,.2f}", + 'Cash Ratio': f"{(month_data.cumulative_income / total_value) * 100:.1f}%" if total_value > 0 else "0.0%" + }) + + no_drip_monthly_df = pd.DataFrame(no_drip_monthly_data) + st.dataframe(no_drip_monthly_df, use_container_width=True, hide_index=True, height=400) + + with detail_tab2: + # Per-ticker detailed breakdown + st.subheader("🔍 Per-Ticker Monthly Breakdown") + + # Get all tickers + all_tickers = list(final_alloc['Ticker'].unique()) + + # Create detailed per-ticker tables + for ticker in all_tickers: + st.markdown(f"### **{ticker}** Performance") + + col1, col2 = st.columns(2) + + with col1: + st.markdown("**🔄 DRIP Strategy**") + ticker_drip_data = [] + + for month_data in drip_result.monthly_data: + shares = month_data.shares.get(ticker, 0) + price = month_data.prices.get(ticker, 0) + yield_rate = month_data.yields.get(ticker, 0) + value = shares * price + + # Calculate income for this ticker (proportional) + ticker_income = month_data.monthly_income * (value / month_data.total_value) if month_data.total_value > 0 else 0 + + # Calculate share growth + initial_shares = initial_portfolio_data[ticker]['initial_shares'] + share_growth = ((shares - initial_shares) / initial_shares) * 100 if initial_shares > 0 else 0 + + ticker_drip_data.append({ + 'Month': month_data.month, + 'Shares': f"{shares:.4f}", + 'Price': f"${price:.2f}", + 'Value': f"${value:,.2f}", + 'Yield': f"{yield_rate:.2%}", + 'Monthly Income': f"${ticker_income:,.2f}", + 'Share Growth': f"{share_growth:+.1f}%" + }) + + ticker_drip_df = pd.DataFrame(ticker_drip_data) + st.dataframe(ticker_drip_df, use_container_width=True, hide_index=True, height=300) + + with col2: + st.markdown("**💰 No-DRIP Strategy**") + ticker_no_drip_data = [] + + for month_data in no_drip_result.monthly_data: + shares = month_data.original_shares.get(ticker, 0) + price = month_data.prices.get(ticker, 0) + yield_rate = month_data.yields.get(ticker, 0) + value = shares * price + + # Calculate income for this ticker (proportional) + ticker_income = month_data.monthly_income * (value / month_data.portfolio_value) if month_data.portfolio_value > 0 else 0 + + ticker_no_drip_data.append({ + 'Month': month_data.month, + 'Shares': f"{shares:.4f}", + 'Price': f"${price:.2f}", + 'Value': f"${value:,.2f}", + 'Yield': f"{yield_rate:.2%}", + 'Monthly Income': f"${ticker_income:,.2f}", + 'Share Growth': "0.0%" # No growth in No-DRIP + }) + + ticker_no_drip_df = pd.DataFrame(ticker_no_drip_data) + st.dataframe(ticker_no_drip_df, use_container_width=True, hide_index=True, height=300) + + st.markdown("---") # Separator between tickers + + with detail_tab3: + # Erosion tracking over time + st.subheader("📈 Price & Yield Erosion Tracking") + + # Create erosion tracking tables + for ticker in all_tickers: + st.markdown(f"### **{ticker}** Erosion Analysis") + + initial_price = initial_portfolio_data[ticker]['initial_price'] + initial_yield = initial_portfolio_data[ticker]['initial_yield'] + + col1, col2 = st.columns(2) + + with col1: + st.markdown("**🔄 DRIP Erosion**") + drip_erosion_data = [] + + for month_data in drip_result.monthly_data: + current_price = month_data.prices.get(ticker, initial_price) + current_yield = month_data.yields.get(ticker, initial_yield) + + price_erosion = ((initial_price - current_price) / initial_price) * 100 + yield_erosion = ((initial_yield - current_yield) / initial_yield) * 100 if initial_yield > 0 else 0 + + drip_erosion_data.append({ + 'Month': month_data.month, + 'Current Price': f"${current_price:.2f}", + 'Price Erosion': f"{price_erosion:.1f}%", + 'Current Yield': f"{current_yield:.2%}", + 'Yield Erosion': f"{yield_erosion:.1f}%", + 'Combined Impact': f"{(price_erosion + yield_erosion) / 2:.1f}%" + }) + + drip_erosion_df = pd.DataFrame(drip_erosion_data) + st.dataframe(drip_erosion_df, use_container_width=True, hide_index=True, height=300) + + with col2: + st.markdown("**💰 No-DRIP Erosion**") + no_drip_erosion_data = [] + + for month_data in no_drip_result.monthly_data: + current_price = month_data.prices.get(ticker, initial_price) + current_yield = month_data.yields.get(ticker, initial_yield) + + price_erosion = ((initial_price - current_price) / initial_price) * 100 + yield_erosion = ((initial_yield - current_yield) / initial_yield) * 100 if initial_yield > 0 else 0 + + no_drip_erosion_data.append({ + 'Month': month_data.month, + 'Current Price': f"${current_price:.2f}", + 'Price Erosion': f"{price_erosion:.1f}%", + 'Current Yield': f"{current_yield:.2%}", + 'Yield Erosion': f"{yield_erosion:.1f}%", + 'Combined Impact': f"{(price_erosion + yield_erosion) / 2:.1f}%" + }) + + no_drip_erosion_df = pd.DataFrame(no_drip_erosion_data) + st.dataframe(no_drip_erosion_df, use_container_width=True, hide_index=True, height=300) + + st.markdown("---") # Separator between tickers + + # Enhanced download section + st.subheader("đŸ“Ĩ Download Detailed Data") + + # Create comprehensive datasets for download + download_col1, download_col2, download_col3 = st.columns(3) + + with download_col1: + # DRIP comprehensive data + comprehensive_drip_data = [] + for month_data in drip_result.monthly_data: + base_row = { + 'Month': month_data.month, + 'Total_Portfolio_Value': month_data.total_value, + 'Monthly_Income': month_data.monthly_income, + 'Cumulative_Income': month_data.cumulative_income + } + + # Add per-ticker data + for ticker in all_tickers: + shares = month_data.shares.get(ticker, 0) + price = month_data.prices.get(ticker, 0) + yield_rate = month_data.yields.get(ticker, 0) + + base_row.update({ + f'{ticker}_Shares': shares, + f'{ticker}_Price': price, + f'{ticker}_Value': shares * price, + f'{ticker}_Yield': yield_rate + }) + + comprehensive_drip_data.append(base_row) + + comprehensive_drip_df = pd.DataFrame(comprehensive_drip_data) + drip_csv = comprehensive_drip_df.to_csv(index=False) + st.download_button( + label="đŸ“Ĩ Download DRIP Details", + data=drip_csv, + file_name="drip_comprehensive_details.csv", + mime="text/csv", + use_container_width=True + ) + + with download_col2: + # No-DRIP comprehensive data + comprehensive_no_drip_data = [] + for month_data in no_drip_result.monthly_data: + base_row = { + 'Month': month_data.month, + 'Portfolio_Value': month_data.portfolio_value, + 'Monthly_Income': month_data.monthly_income, + 'Cumulative_Cash': month_data.cumulative_income, + 'Total_Value': month_data.portfolio_value + month_data.cumulative_income + } + + # Add per-ticker data + for ticker in all_tickers: + shares = month_data.original_shares.get(ticker, 0) + price = month_data.prices.get(ticker, 0) + yield_rate = month_data.yields.get(ticker, 0) + + base_row.update({ + f'{ticker}_Shares': shares, + f'{ticker}_Price': price, + f'{ticker}_Value': shares * price, + f'{ticker}_Yield': yield_rate + }) + + comprehensive_no_drip_data.append(base_row) + + comprehensive_no_drip_df = pd.DataFrame(comprehensive_no_drip_data) + no_drip_csv = comprehensive_no_drip_df.to_csv(index=False) + st.download_button( + label="đŸ“Ĩ Download No-DRIP Details", + data=no_drip_csv, + file_name="no_drip_comprehensive_details.csv", + mime="text/csv", + use_container_width=True + ) + + with download_col3: + # Comparison data + comparison_data = [] + for i in range(len(drip_result.monthly_data)): + drip_data = drip_result.monthly_data[i] + no_drip_data = no_drip_result.monthly_data[i] + + comparison_data.append({ + 'Month': drip_data.month, + 'DRIP_Portfolio_Value': drip_data.total_value, + 'DRIP_Monthly_Income': drip_data.monthly_income, + 'DRIP_Cumulative_Income': drip_data.cumulative_income, + 'No_DRIP_Portfolio_Value': no_drip_data.portfolio_value, + 'No_DRIP_Monthly_Income': no_drip_data.monthly_income, + 'No_DRIP_Cumulative_Cash': no_drip_data.cumulative_income, + 'No_DRIP_Total_Value': no_drip_data.portfolio_value + no_drip_data.cumulative_income, + 'DRIP_Advantage': drip_data.total_value - (no_drip_data.portfolio_value + no_drip_data.cumulative_income) + }) + + comparison_df = pd.DataFrame(comparison_data) + comparison_csv = comparison_df.to_csv(index=False) + st.download_button( + label="đŸ“Ĩ Download Comparison", + data=comparison_csv, + file_name="drip_vs_no_drip_comparison.csv", + mime="text/csv", + use_container_width=True + ) + + # Display break-even analysis with improved time visualization + st.subheader("⏰ Break-Even Analysis") + st.write("**Time to recover your initial investment and start making profit:**") + + # Create break-even comparison + drip_be = comparison_result['drip_break_even'] + no_drip_be = comparison_result['no_drip_break_even'] + initial_investment = comparison_result['initial_investment'] + + # Create break-even metrics display + col1, col2, col3 = st.columns(3) + + with col1: + st.metric( + "Initial Investment", + f"${initial_investment:,.2f}", + help="Amount you need to invest upfront" + ) + + with col2: + # DRIP break-even + if drip_be['break_even_month']: + years = drip_be['break_even_month'] // 12 + months = drip_be['break_even_month'] % 12 + days = drip_be['break_even_month'] * 30 # Approximate days + + if years > 0: + time_str = f"{years}y {months}m" + else: + time_str = f"{months} months" + + st.metric( + "🔄 DRIP Break-Even Time", + time_str, + f"≈ {days} days", + help=f"DRIP strategy becomes profitable after {drip_be['break_even_month']} months" + ) + else: + st.metric( + "🔄 DRIP Break-Even Time", + "Never", + "Within 12 months", + delta_color="inverse", + help="DRIP strategy doesn't break even within the 12-month simulation period" + ) + + with col3: + # No-DRIP break-even + if no_drip_be['break_even_month']: + years = no_drip_be['break_even_month'] // 12 + months = no_drip_be['break_even_month'] % 12 + days = no_drip_be['break_even_month'] * 30 # Approximate days + + if years > 0: + time_str = f"{years}y {months}m" + else: + time_str = f"{months} months" + + st.metric( + "💰 No-DRIP Break-Even Time", + time_str, + f"≈ {days} days", + help=f"No-DRIP strategy becomes profitable after {no_drip_be['break_even_month']} months" + ) + else: + st.metric( + "💰 No-DRIP Break-Even Time", + "Never", + "Within 12 months", + delta_color="inverse", + help="No-DRIP strategy doesn't break even within the 12-month simulation period" + ) + + # Visual break-even timeline + st.subheader("📊 Break-Even Timeline Visualization") + + # Create timeline chart + months = list(range(1, 13)) + # Convert string values to float for calculation + drip_values_float = [float(md['Portfolio Value'].replace('$', '').replace(',', '')) for md in drip_monthly_data] + no_drip_values = [float(md['Total Value'].replace('$', '').replace(',', '')) for md in no_drip_monthly_data] + + fig = go.Figure() + + # Add initial investment line + fig.add_hline( + y=initial_investment, + line_dash="dash", + line_color="red", + annotation_text="Initial Investment (Break-Even Line)", + annotation_position="top right" ) - st.write("DRIP scenario calculated successfully") + # Add DRIP line + fig.add_trace(go.Scatter( + x=months, + y=drip_values_float, + mode='lines+markers', + name='DRIP Portfolio Value', + line=dict(color='#1f77b4', width=3), + marker=dict(size=8) + )) + + # Add No-DRIP line + fig.add_trace(go.Scatter( + x=months, + y=no_drip_values, + mode='lines+markers', + name='No-DRIP Total Value', + line=dict(color='#ff7f0e', width=3), + marker=dict(size=8) + )) + + # Mark break-even points + if drip_be['break_even_month'] and drip_be['break_even_month'] <= 12: + fig.add_vline( + x=drip_be['break_even_month'], + line_dash="dot", + line_color="#1f77b4", + annotation_text=f"DRIP Break-Even\n(Month {drip_be['break_even_month']})", + annotation_position="top" + ) + + if no_drip_be['break_even_month'] and no_drip_be['break_even_month'] <= 12: + fig.add_vline( + x=no_drip_be['break_even_month'], + line_dash="dot", + line_color="#ff7f0e", + annotation_text=f"No-DRIP Break-Even\n(Month {no_drip_be['break_even_month']})", + annotation_position="bottom" + ) + + fig.update_layout( + title="Portfolio Value vs Initial Investment Over Time", + xaxis_title="Month", + yaxis_title="Portfolio Value ($)", + template="plotly_white", + height=500, + hovermode='x unified' + ) + + st.plotly_chart(fig, use_container_width=True) + + # Break-even explanation + st.info(""" + **Break-Even Analysis Explanation:** + - **Break-even point**: When your total portfolio value exceeds your initial investment + - **DRIP**: Portfolio value from share growth due to dividend reinvestment + - **No-DRIP**: Portfolio value + accumulated cash from dividends + - **Timeline**: Shows how quickly each strategy recovers your initial investment + """) + + # Display recommendation + st.subheader("💡 Investment Recommendation") + if winner == "DRIP": + st.success(f"đŸŽ¯ **Recommended Strategy: DRIP** - {advantage:.1f}% better performance due to compound growth from reinvested dividends.") + elif winner == "No-DRIP": + st.success(f"đŸŽ¯ **Recommended Strategy: No-DRIP** - {advantage:.1f}% better performance with immediate liquidity from cash dividends.") + else: + st.info("đŸŽ¯ **Both strategies perform equally.** Choose based on your liquidity preferences.") + + # Create performance comparison visualization + st.subheader("📊 Performance Comparison Chart") + + # Create comparison data for chart + fig = go.Figure() + + strategies = ["DRIP", "No-DRIP"] + final_values = [comparison_result['drip_final_value'], comparison_result['no_drip_final_value']] + colors = ['#1f77b4', '#ff7f0e'] + + # Add bars with different colors for winner + for i, (strategy, value) in enumerate(zip(strategies, final_values)): + color = '#2ca02c' if strategy == winner else colors[i] # Green for winner + fig.add_trace(go.Bar( + x=[strategy], + y=[value], + text=[f"${value:,.0f}"], + textposition='auto', + marker_color=color, + name=strategy + )) + + fig.update_layout( + title="Final Portfolio Value Comparison", + yaxis_title="Portfolio Value ($)", + template="plotly_white", + showlegend=False, + height=400 + ) + + st.plotly_chart(fig, use_container_width=True) + + # Display detailed analysis summary + st.subheader("📋 Detailed Analysis Summary") + st.markdown(comparison_result['comparison_summary']) + + # Add enhanced strategy explanation and table guide + st.subheader("â„šī¸ Understanding the Monthly Details Tables") + + # Create expandable sections for explanations + with st.expander("📊 Table Column Explanations", expanded=False): + st.markdown(""" + ### Summary Tables: + - **Portfolio Value**: Current market value of all shares at eroded prices + - **Monthly Change**: Month-over-month percentage change in portfolio value + - **Monthly Income**: Dividends received/reinvested in that month + - **Cumulative Income/Cash**: Total dividends received since start + - **Total Shares** (DRIP): Total shares owned including reinvested dividends + - **Avg Price** (DRIP): Average price per share across all holdings + - **Cash Ratio** (No-DRIP): Percentage of total value held as cash + + ### Per-Ticker Details: + - **Shares**: Number of shares owned (grows with DRIP, constant with No-DRIP) + - **Price**: Current share price after erosion effects + - **Value**: Market value of holdings (shares × price) + - **Yield**: Current dividend yield after erosion + - **Share Growth**: Percentage increase in shares from dividend reinvestment + + ### Erosion Tracking: + - **Price Erosion**: Cumulative NAV erosion from initial price + - **Yield Erosion**: Cumulative yield erosion from initial yield + - **Combined Impact**: Average of price and yield erosion effects + """) + + with st.expander("🔄 Strategy Explanations", expanded=False): + col1, col2 = st.columns(2) + + with col1: + st.markdown(""" + **🔄 DRIP (Dividend Reinvestment Plan):** + - Dividends automatically buy more shares + - Share count increases each month + - Benefits from compound growth + - Portfolio value = growing shares × eroded prices + - Best for long-term wealth building + """) + + with col2: + st.markdown(""" + **💰 No-DRIP (Cash Distribution):** + - Dividends kept as cash + - Share count stays constant + - Immediate income availability + - Total value = portfolio + accumulated cash + - Best for current income needs + """) + + with st.expander("âš ī¸ Important Considerations", expanded=False): + st.markdown(""" + **Erosion Effects:** + - Both strategies experience identical NAV (price) and yield erosion + - High-yield ETFs typically have higher erosion risk + - Erosion rates are calculated from historical ETF performance data + + **Performance Factors:** + - DRIP benefits from compound growth but suffers from erosion on larger holdings + - No-DRIP provides liquidity but misses compound growth opportunities + - Break-even analysis shows when each strategy recovers initial investment + + **Data Accuracy:** + - Monthly calculations include distribution frequency variations + - Price and yield erosion applied monthly based on historical analysis + - All values shown are projections based on current yields and calculated erosion rates + """) + + # Note about erosion effects + st.info(""" + **📈 Reading the Data:** The Monthly Details tables show how your investment evolves over time under realistic + market conditions including NAV and yield erosion. Use the Per-Ticker Details to see individual ETF performance, + and the Erosion Tracking to understand how much your holdings are affected by market pressures. + """) + + logger.info("DRIP vs No-DRIP comparison completed successfully") + except Exception as e: - st.error(f"Error calculating DRIP scenario: {str(e)}") - st.error(traceback.format_exc()) + st.error(f"Error calculating DRIP vs No-DRIP comparison: {str(e)}") + logger.error(f"DRIP comparison error: {str(e)}") + logger.error(traceback.format_exc()) st.stop() with tab3: