Adding education tio DRIP/No-DRIP

This commit is contained in:
Pascal BIBEHE 2025-06-03 19:51:16 +02:00
parent 30e1bbcbd9
commit 19f713673e
4 changed files with 1256 additions and 419 deletions

View File

@ -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',

View File

@ -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()
)

View File

@ -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

File diff suppressed because it is too large Load Diff