diff --git a/ETF_Portal/services/drip_service/no_drip_service.py b/ETF_Portal/services/drip_service/no_drip_service.py index 72e32f7..2141408 100644 --- a/ETF_Portal/services/drip_service/no_drip_service.py +++ b/ETF_Portal/services/drip_service/no_drip_service.py @@ -328,12 +328,12 @@ class NoDRIPService: return monthly_income def _apply_monthly_erosion( - self, - state: Dict[str, Any], - erosion_config: ErosionConfig, + self, + state: Dict[str, Any], + erosion_config: ErosionConfig, tickers: List[str] ) -> None: - """Apply monthly erosion to prices and yields (reuse from DRIP service)""" + """Apply monthly erosion to prices and yields""" if erosion_config.erosion_type == "None": return @@ -355,29 +355,48 @@ class NoDRIPService: 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 + # Get annual erosion rates (assumed to be in percentage form, e.g., 6.7 for 6.7%) + nav_annual_pct = ticker_rates.get("nav", 0.0) + yield_annual_pct = ticker_rates.get("yield", 0.0) + + # Convert annual percentage to monthly rate using compound formula + # Monthly rate = (1 + annual_rate)^(1/12) - 1 + nav_annual_rate = nav_annual_pct / 100.0 # Convert percentage to decimal + yield_annual_rate = yield_annual_pct / 100.0 # Convert percentage to decimal + + # Calculate monthly erosion rates using compound formula + if nav_annual_rate > 0: + nav_monthly_rate = (1 + nav_annual_rate) ** (1/12) - 1 + else: + nav_monthly_rate = 0.0 + + if yield_annual_rate > 0: + yield_monthly_rate = (1 + yield_annual_rate) ** (1/12) - 1 + else: + yield_monthly_rate = 0.0 # 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)) + nav_monthly_rate = max(0.0, min(nav_monthly_rate, self.MAX_MONTHLY_EROSION)) + yield_monthly_rate = max(0.0, min(yield_monthly_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) + # Apply monthly erosion + state['current_prices'][ticker] *= (1 - nav_monthly_rate) + state['current_yields'][ticker] *= (1 - yield_monthly_rate) # Ensure prices and yields don't go below reasonable minimums state['current_prices'][ticker] = max(state['current_prices'][ticker], 0.01) state['current_yields'][ticker] = max(state['current_yields'][ticker], 0.0) - # Log erosion application + # Log erosion application with both annual and monthly rates 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%}") + logger.info(f" NAV: {nav_annual_pct:.1f}% annual -> {nav_monthly_rate:.4%} monthly") + logger.info(f" Price: ${original_price:.2f} -> ${state['current_prices'][ticker]:.2f}") + logger.info(f" Yield: {yield_annual_pct:.1f}% annual -> {yield_monthly_rate:.4%} monthly") + logger.info(f" 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)""" diff --git a/ETF_Portal/services/drip_service/service.py b/ETF_Portal/services/drip_service/service.py index b70c8b6..03caed9 100644 --- a/ETF_Portal/services/drip_service/service.py +++ b/ETF_Portal/services/drip_service/service.py @@ -350,29 +350,48 @@ class DRIPService: 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 + # Get annual erosion rates (assumed to be in percentage form, e.g., 6.7 for 6.7%) + nav_annual_pct = ticker_rates.get("nav", 0.0) + yield_annual_pct = ticker_rates.get("yield", 0.0) + + # Convert annual percentage to monthly rate using compound formula + # Monthly rate = (1 + annual_rate)^(1/12) - 1 + nav_annual_rate = nav_annual_pct / 100.0 # Convert percentage to decimal + yield_annual_rate = yield_annual_pct / 100.0 # Convert percentage to decimal + + # Calculate monthly erosion rates using compound formula + if nav_annual_rate > 0: + nav_monthly_rate = (1 + nav_annual_rate) ** (1/12) - 1 + else: + nav_monthly_rate = 0.0 + + if yield_annual_rate > 0: + yield_monthly_rate = (1 + yield_annual_rate) ** (1/12) - 1 + else: + yield_monthly_rate = 0.0 # 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)) + nav_monthly_rate = max(0.0, min(nav_monthly_rate, self.MAX_MONTHLY_EROSION)) + yield_monthly_rate = max(0.0, min(yield_monthly_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) + # Apply monthly erosion + state['current_prices'][ticker] *= (1 - nav_monthly_rate) + state['current_yields'][ticker] *= (1 - yield_monthly_rate) # Ensure prices and yields don't go below reasonable minimums state['current_prices'][ticker] = max(state['current_prices'][ticker], 0.01) state['current_yields'][ticker] = max(state['current_yields'][ticker], 0.0) - # Log erosion application + # Log erosion application with both annual and monthly rates logger.info(f"Applied monthly erosion to {ticker}:") - logger.info(f" NAV: {nav_rate:.4%} -> Price: ${original_price:.2f} -> ${state['current_prices'][ticker]:.2f}") - logger.info(f" Yield: {yield_rate:.4%} -> Yield: {original_yield:.2%} -> {state['current_yields'][ticker]:.2%}") + logger.info(f" NAV: {nav_annual_pct:.1f}% annual -> {nav_monthly_rate:.4%} monthly") + logger.info(f" Price: ${original_price:.2f} -> ${state['current_prices'][ticker]:.2f}") + logger.info(f" Yield: {yield_annual_pct:.1f}% annual -> {yield_monthly_rate:.4%} monthly") + logger.info(f" Yield: {original_yield:.2%} -> {state['current_yields'][ticker]:.2%}") def _reinvest_dividends( self, @@ -621,23 +640,43 @@ class DRIPService: initial_investment: float, value_extractor: callable ) -> 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, + accounting for the erosion effects on the portfolio. + """ break_even_month = None profit_at_break_even = 0.0 + max_total_return = -float('inf') + + logger.info(f"=== Break-Even Analysis for {strategy_name} ===") + logger.info(f"Initial Investment: ${initial_investment:,.2f}") for month_data in monthly_data: total_value = value_extractor(month_data) - profit = total_value - initial_investment + total_return_pct = ((total_value - initial_investment) / initial_investment) * 100 + max_total_return = max(max_total_return, total_return_pct) - if profit > 0 and break_even_month is None: + logger.info(f"Month {month_data.month}: Total Value = ${total_value:,.2f}, Return = {total_return_pct:+.2f}%") + + # Break-even when total value exceeds initial investment by a meaningful margin (>0.1%) + # This accounts for rounding errors and ensures true profitability + if total_return_pct > 0.1 and break_even_month is None: break_even_month = month_data.month - profit_at_break_even = profit + profit_at_break_even = total_value - initial_investment + logger.info(f"Break-even achieved in month {break_even_month} with {total_return_pct:.2f}% return") break + # Additional validation: if max return is still negative, definitely no break-even + if max_total_return < 0: + logger.info(f"Maximum return achieved: {max_total_return:.2f}% - Never breaks even") + break_even_month = None + # Format break-even time if break_even_month is None: months_to_break_even = "Never (within simulation period)" + logger.info(f"Result: No break-even within {len(monthly_data)} months") else: years = break_even_month // 12 months = break_even_month % 12 @@ -645,13 +684,17 @@ class DRIPService: months_to_break_even = f"{years} year(s) and {months} month(s)" else: months_to_break_even = f"{months} month(s)" + logger.info(f"Result: Break-even in {months_to_break_even}") + + logger.info(f"=== End Break-Even Analysis for {strategy_name} ===") 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 + 'initial_investment': initial_investment, + 'max_return_pct': max_total_return } def _print_strategy_comparison(