diff --git a/ETF_Portal/services/drip_service/service.py b/ETF_Portal/services/drip_service/service.py index 81ba7f6..efc01bd 100644 --- a/ETF_Portal/services/drip_service/service.py +++ b/ETF_Portal/services/drip_service/service.py @@ -58,10 +58,7 @@ class TickerData: class ErosionConfig: """Configuration for erosion calculations""" erosion_type: str - use_per_ticker: bool = False - global_nav_rate: float = 0.0 - global_yield_rate: float = 0.0 - per_ticker_rates: Dict[str, Dict[str, float]] = field(default_factory=dict) + erosion_level: Dict[str, Dict[str, float]] # Changed to match NavErosionService output class DRIPService: """Enhanced DRIP calculation service with improved performance and accuracy""" @@ -97,6 +94,9 @@ class DRIPService: simulation_state = self._initialize_simulation_state(ticker_data) monthly_data: List[MonthlyData] = [] + # Create monthly tracking table + monthly_tracking = [] + # Run monthly simulation for month in range(1, config.months + 1): # Calculate monthly income from distributions @@ -120,6 +120,17 @@ class DRIPService: for ticker in ticker_data.keys() ) + # Add to monthly tracking + monthly_tracking.append({ + 'Month': month, + 'Portfolio Value': total_value, + 'Monthly Income': monthly_income, + 'Cumulative Income': simulation_state['cumulative_income'], + 'Shares': {ticker: simulation_state['current_shares'][ticker] for ticker in ticker_data.keys()}, + '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(MonthlyData( month=month, @@ -131,6 +142,18 @@ class DRIPService: yields=simulation_state['current_yields'].copy() )) + # Print monthly tracking table + print("\nMonthly DRIP Simulation Results:") + print("=" * 100) + print(f"{'Month':<6} {'Portfolio Value':<15} {'Monthly Income':<15} {'Cumulative Income':<15} {'Shares':<15}") + print("-" * 100) + + for month_data in monthly_tracking: + shares_str = ", ".join([f"{ticker}: {shares:.4f}" for ticker, shares in month_data['Shares'].items()]) + print(f"{month_data['Month']:<6} ${month_data['Portfolio Value']:<14.2f} ${month_data['Monthly Income']:<14.2f} ${month_data['Cumulative Income']:<14.2f} {shares_str}") + + print("=" * 100) + # Calculate final results return self._create_drip_result(monthly_data, simulation_state) @@ -178,20 +201,12 @@ class DRIPService: def _parse_erosion_config(self, config: DripConfig) -> ErosionConfig: """Parse and validate erosion configuration""" if not hasattr(config, 'erosion_level') or config.erosion_type == "None": - return ErosionConfig(erosion_type="None") + return ErosionConfig(erosion_type="None", erosion_level={}) - erosion_level = config.erosion_level - - if isinstance(erosion_level, dict): - return ErosionConfig( - erosion_type=config.erosion_type, - use_per_ticker=erosion_level.get("use_per_ticker", False), - global_nav_rate=self._normalize_erosion_rate(erosion_level.get("global", {}).get("nav", 0)), - global_yield_rate=self._normalize_erosion_rate(erosion_level.get("global", {}).get("yield", 0)), - per_ticker_rates=erosion_level.get("per_ticker", {}) - ) - - return ErosionConfig(erosion_type="None") + return ErosionConfig( + erosion_type=config.erosion_type, + erosion_level=config.erosion_level + ) def _normalize_erosion_rate(self, erosion_level: float) -> float: """Convert erosion level (0-9) to monthly rate with validation""" @@ -239,10 +254,17 @@ class DRIPService: price = state['current_prices'][ticker] yield_rate = state['current_yields'][ticker] - # Calculate distribution amount + # 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} distribution for {ticker}:") + logger.info(f" Shares: {shares:.4f}") + logger.info(f" Price: ${price:.2f}") + logger.info(f" Yield: {yield_rate:.2%}") + logger.info(f" Distribution: ${distribution_amount:.2f}") return monthly_income @@ -252,22 +274,24 @@ class DRIPService: erosion_config: ErosionConfig, tickers: List[str] ) -> None: - """Apply erosion to current prices and yields""" - - for ticker in tickers: - if erosion_config.use_per_ticker and ticker in erosion_config.per_ticker_rates: - # Use per-ticker erosion rates - ticker_rates = erosion_config.per_ticker_rates[ticker] - nav_erosion = self._normalize_erosion_rate(ticker_rates.get("nav", 0)) - yield_erosion = self._normalize_erosion_rate(ticker_rates.get("yield", 0)) - else: - # Use global erosion rates - nav_erosion = erosion_config.global_nav_rate - yield_erosion = erosion_config.global_yield_rate + """Apply monthly erosion to prices and yields""" + if erosion_config.erosion_type == "None": + return - # Apply erosion with bounds checking - state['current_prices'][ticker] *= max(0.01, 1 - nav_erosion) - state['current_yields'][ticker] *= max(0.0, 1 - yield_erosion) + for ticker in tickers: + # Get per-ticker erosion rates + ticker_rates = erosion_config.erosion_level.get("per_ticker", {}).get(ticker, {}) + nav_rate = ticker_rates.get("nav", 0.0) # Already in decimal form + yield_rate = ticker_rates.get("yield", 0.0) # Already in decimal form + + # Apply erosion directly + state['current_prices'][ticker] *= (1 - nav_rate) + state['current_yields'][ticker] *= (1 - yield_rate) + + # Log erosion application + logger.info(f"Applied erosion to {ticker}:") + logger.info(f" NAV: {nav_rate:.4%} -> New price: ${state['current_prices'][ticker]:.2f}") + logger.info(f" Yield: {yield_rate:.4%} -> New yield: {state['current_yields'][ticker]:.2%}") def _reinvest_dividends( self, @@ -283,14 +307,19 @@ class DRIPService: price = state['current_prices'][ticker] yield_rate = state['current_yields'][ticker] - # Calculate dividend income - # Note: This uses the distribution frequency from the original ticker data - dividend_income = shares * price * yield_rate / 12 # Simplified monthly calculation + # Calculate dividend income using current (eroded) values + dividend_income = shares * price * yield_rate / 12 - # Purchase additional shares + # Purchase additional shares at current price if price > 0: new_shares = dividend_income / price state['current_shares'][ticker] += new_shares + + # Log reinvestment + logger.info(f"Month {month} reinvestment for {ticker}:") + logger.info(f" Dividend Income: ${dividend_income:.2f}") + logger.info(f" New Shares: {new_shares:.4f}") + logger.info(f" Total Shares: {state['current_shares'][ticker]:.4f}") def _is_distribution_month(self, month: int, frequency: DistributionFrequency) -> bool: """Check if current month is a distribution month""" diff --git a/pages/ETF_Portfolio_Builder.py b/pages/ETF_Portfolio_Builder.py index 3d59823..5a7a56e 100644 --- a/pages/ETF_Portfolio_Builder.py +++ b/pages/ETF_Portfolio_Builder.py @@ -1624,6 +1624,21 @@ if 'etf_allocations' not in st.session_state: if 'risk_tolerance' not in st.session_state: st.session_state.risk_tolerance = "Moderate" logger.info("Initialized risk_tolerance in session state") +if 'erosion_level' not in st.session_state: + st.session_state.erosion_level = { + "nav": 5.0, # Default 5% annual NAV erosion + "yield": 5.0 # Default 5% annual yield erosion + } + logger.info("Initialized erosion_level in session state") +if 'erosion_type' not in st.session_state: + st.session_state.erosion_type = "NAV & Yield Erosion" + logger.info("Initialized erosion_type in session state") +if 'per_ticker_erosion' not in st.session_state: + st.session_state.per_ticker_erosion = {} + logger.info("Initialized per_ticker_erosion in session state") +if 'use_per_ticker_erosion' not in st.session_state: + st.session_state.use_per_ticker_erosion = False + logger.info("Initialized use_per_ticker_erosion in session state") # Main title st.title("📈 ETF Portfolio Builder") @@ -2131,8 +2146,8 @@ def display_drip_forecast(portfolio_result, tickers): months=12, erosion_type=st.session_state.get("erosion_type", "None"), erosion_level={ - "nav": st.session_state.get("erosion_level", {}).get("nav", 0), - "yield": st.session_state.get("erosion_level", {}).get("yield", 0) + "nav": float(st.session_state.erosion_level.get("nav", 5.0)), + "yield": float(st.session_state.erosion_level.get("yield", 5.0)) } ), tickers=tickers @@ -2145,8 +2160,8 @@ def display_drip_forecast(portfolio_result, tickers): months=12, erosion_type=st.session_state.get("erosion_type", "None"), erosion_level={ - "nav": st.session_state.get("erosion_level", {}).get("nav", 0), - "yield": st.session_state.get("erosion_level", {}).get("yield", 0) + "nav": float(st.session_state.erosion_level.get("nav", 5.0)), + "yield": float(st.session_state.erosion_level.get("yield", 5.0)) } ), tickers=tickers @@ -2351,45 +2366,99 @@ 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("Dividend Reinvestment (DRIP) Forecast") - st.write("This forecast shows the growth of your portfolio over time if dividends are reinvested instead of taken as income.") + st.subheader("DRIP Forecast") + # Calculate DRIP scenario + logger.info("Calculating DRIP scenario...") try: - # Get portfolio data - tickers = final_alloc["Ticker"].tolist() - initial_investment = final_alloc["Capital Allocated ($)"].sum() - risk_tolerance = st.session_state.get("risk_tolerance", "Moderate") - - # DRIP simulation parameters - months = st.slider("Forecast Period (Months)", 1, 60, 12) - # Initialize DRIP service from ETF_Portal.services.drip_service import DRIPService - - # Initialize DRIP service drip_service = DRIPService() - # Calculate DRIP forecast - portfolio_result = drip_service.forecast_portfolio( - portfolio_df=final_alloc, - config=DripConfig( - months=months, - erosion_type=st.session_state.get("erosion_type", "None"), - erosion_level={ - "nav": st.session_state.get("erosion_level", {}).get("nav", 0), - "yield": st.session_state.get("erosion_level", {}).get("yield", 0) - } - ), - tickers=tickers + # 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 + 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)) + } ) - # Display DRIP forecast results - display_drip_forecast(portfolio_result, tickers) + # 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( + portfolio_df=final_alloc, + config=config + ) + + # Display summary metrics + col1, col2, col3 = st.columns(3) + with col1: + st.metric("Portfolio Value", f"${drip_result.final_portfolio_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}") + with col3: + st.metric("Total Income", f"${drip_result.total_income:,.2f}") + + # Display monthly tracking table + 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()]) + }) + + # Convert to DataFrame and display + monthly_df = pd.DataFrame(monthly_data) + st.dataframe(monthly_df, use_container_width=True) + + # 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" + ) + + st.write("DRIP scenario calculated successfully") 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()) + st.error(f"Error calculating DRIP scenario: {str(e)}") + st.error(traceback.format_exc()) + st.stop() with tab3: st.subheader("📉 Erosion Risk Assessment")