fix: update DRIP service to use correct method and attributes for portfolio calculations
This commit is contained in:
parent
163bf0e93b
commit
c6797c94ee
@ -58,10 +58,7 @@ class TickerData:
|
|||||||
class ErosionConfig:
|
class ErosionConfig:
|
||||||
"""Configuration for erosion calculations"""
|
"""Configuration for erosion calculations"""
|
||||||
erosion_type: str
|
erosion_type: str
|
||||||
use_per_ticker: bool = False
|
erosion_level: Dict[str, Dict[str, float]] # Changed to match NavErosionService output
|
||||||
global_nav_rate: float = 0.0
|
|
||||||
global_yield_rate: float = 0.0
|
|
||||||
per_ticker_rates: Dict[str, Dict[str, float]] = field(default_factory=dict)
|
|
||||||
|
|
||||||
class DRIPService:
|
class DRIPService:
|
||||||
"""Enhanced DRIP calculation service with improved performance and accuracy"""
|
"""Enhanced DRIP calculation service with improved performance and accuracy"""
|
||||||
@ -97,6 +94,9 @@ class DRIPService:
|
|||||||
simulation_state = self._initialize_simulation_state(ticker_data)
|
simulation_state = self._initialize_simulation_state(ticker_data)
|
||||||
monthly_data: List[MonthlyData] = []
|
monthly_data: List[MonthlyData] = []
|
||||||
|
|
||||||
|
# Create monthly tracking table
|
||||||
|
monthly_tracking = []
|
||||||
|
|
||||||
# Run monthly simulation
|
# Run monthly simulation
|
||||||
for month in range(1, config.months + 1):
|
for month in range(1, config.months + 1):
|
||||||
# Calculate monthly income from distributions
|
# Calculate monthly income from distributions
|
||||||
@ -120,6 +120,17 @@ class DRIPService:
|
|||||||
for ticker in ticker_data.keys()
|
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
|
# Create monthly data
|
||||||
monthly_data.append(MonthlyData(
|
monthly_data.append(MonthlyData(
|
||||||
month=month,
|
month=month,
|
||||||
@ -131,6 +142,18 @@ class DRIPService:
|
|||||||
yields=simulation_state['current_yields'].copy()
|
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
|
# Calculate final results
|
||||||
return self._create_drip_result(monthly_data, simulation_state)
|
return self._create_drip_result(monthly_data, simulation_state)
|
||||||
|
|
||||||
@ -178,20 +201,12 @@ class DRIPService:
|
|||||||
def _parse_erosion_config(self, config: DripConfig) -> ErosionConfig:
|
def _parse_erosion_config(self, config: DripConfig) -> ErosionConfig:
|
||||||
"""Parse and validate erosion configuration"""
|
"""Parse and validate erosion configuration"""
|
||||||
if not hasattr(config, 'erosion_level') or config.erosion_type == "None":
|
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
|
return ErosionConfig(
|
||||||
|
erosion_type=config.erosion_type,
|
||||||
if isinstance(erosion_level, dict):
|
erosion_level=config.erosion_level
|
||||||
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")
|
|
||||||
|
|
||||||
def _normalize_erosion_rate(self, erosion_level: float) -> float:
|
def _normalize_erosion_rate(self, erosion_level: float) -> float:
|
||||||
"""Convert erosion level (0-9) to monthly rate with validation"""
|
"""Convert erosion level (0-9) to monthly rate with validation"""
|
||||||
@ -239,11 +254,18 @@ class DRIPService:
|
|||||||
price = state['current_prices'][ticker]
|
price = state['current_prices'][ticker]
|
||||||
yield_rate = state['current_yields'][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_yield = yield_rate / data.distribution_freq.payments_per_year
|
||||||
distribution_amount = shares * price * distribution_yield
|
distribution_amount = shares * price * distribution_yield
|
||||||
monthly_income += distribution_amount
|
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
|
return monthly_income
|
||||||
|
|
||||||
def _apply_monthly_erosion(
|
def _apply_monthly_erosion(
|
||||||
@ -252,22 +274,24 @@ class DRIPService:
|
|||||||
erosion_config: ErosionConfig,
|
erosion_config: ErosionConfig,
|
||||||
tickers: List[str]
|
tickers: List[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Apply erosion to current prices and yields"""
|
"""Apply monthly erosion to prices and yields"""
|
||||||
|
if erosion_config.erosion_type == "None":
|
||||||
|
return
|
||||||
|
|
||||||
for ticker in tickers:
|
for ticker in tickers:
|
||||||
if erosion_config.use_per_ticker and ticker in erosion_config.per_ticker_rates:
|
# Get per-ticker erosion rates
|
||||||
# Use per-ticker erosion rates
|
ticker_rates = erosion_config.erosion_level.get("per_ticker", {}).get(ticker, {})
|
||||||
ticker_rates = erosion_config.per_ticker_rates[ticker]
|
nav_rate = ticker_rates.get("nav", 0.0) # Already in decimal form
|
||||||
nav_erosion = self._normalize_erosion_rate(ticker_rates.get("nav", 0))
|
yield_rate = ticker_rates.get("yield", 0.0) # Already in decimal form
|
||||||
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 erosion with bounds checking
|
# Apply erosion directly
|
||||||
state['current_prices'][ticker] *= max(0.01, 1 - nav_erosion)
|
state['current_prices'][ticker] *= (1 - nav_rate)
|
||||||
state['current_yields'][ticker] *= max(0.0, 1 - yield_erosion)
|
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(
|
def _reinvest_dividends(
|
||||||
self,
|
self,
|
||||||
@ -283,15 +307,20 @@ class DRIPService:
|
|||||||
price = state['current_prices'][ticker]
|
price = state['current_prices'][ticker]
|
||||||
yield_rate = state['current_yields'][ticker]
|
yield_rate = state['current_yields'][ticker]
|
||||||
|
|
||||||
# Calculate dividend income
|
# Calculate dividend income using current (eroded) values
|
||||||
# Note: This uses the distribution frequency from the original ticker data
|
dividend_income = shares * price * yield_rate / 12
|
||||||
dividend_income = shares * price * yield_rate / 12 # Simplified monthly calculation
|
|
||||||
|
|
||||||
# Purchase additional shares
|
# Purchase additional shares at current price
|
||||||
if price > 0:
|
if price > 0:
|
||||||
new_shares = dividend_income / price
|
new_shares = dividend_income / price
|
||||||
state['current_shares'][ticker] += new_shares
|
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:
|
def _is_distribution_month(self, month: int, frequency: DistributionFrequency) -> bool:
|
||||||
"""Check if current month is a distribution month"""
|
"""Check if current month is a distribution month"""
|
||||||
if frequency == DistributionFrequency.MONTHLY:
|
if frequency == DistributionFrequency.MONTHLY:
|
||||||
|
|||||||
@ -1624,6 +1624,21 @@ if 'etf_allocations' not in st.session_state:
|
|||||||
if 'risk_tolerance' not in st.session_state:
|
if 'risk_tolerance' not in st.session_state:
|
||||||
st.session_state.risk_tolerance = "Moderate"
|
st.session_state.risk_tolerance = "Moderate"
|
||||||
logger.info("Initialized risk_tolerance in session state")
|
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
|
# Main title
|
||||||
st.title("📈 ETF Portfolio Builder")
|
st.title("📈 ETF Portfolio Builder")
|
||||||
@ -2131,8 +2146,8 @@ def display_drip_forecast(portfolio_result, tickers):
|
|||||||
months=12,
|
months=12,
|
||||||
erosion_type=st.session_state.get("erosion_type", "None"),
|
erosion_type=st.session_state.get("erosion_type", "None"),
|
||||||
erosion_level={
|
erosion_level={
|
||||||
"nav": st.session_state.get("erosion_level", {}).get("nav", 0),
|
"nav": float(st.session_state.erosion_level.get("nav", 5.0)),
|
||||||
"yield": st.session_state.get("erosion_level", {}).get("yield", 0)
|
"yield": float(st.session_state.erosion_level.get("yield", 5.0))
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
tickers=tickers
|
tickers=tickers
|
||||||
@ -2145,8 +2160,8 @@ def display_drip_forecast(portfolio_result, tickers):
|
|||||||
months=12,
|
months=12,
|
||||||
erosion_type=st.session_state.get("erosion_type", "None"),
|
erosion_type=st.session_state.get("erosion_type", "None"),
|
||||||
erosion_level={
|
erosion_level={
|
||||||
"nav": st.session_state.get("erosion_level", {}).get("nav", 0),
|
"nav": float(st.session_state.erosion_level.get("nav", 5.0)),
|
||||||
"yield": st.session_state.get("erosion_level", {}).get("yield", 0)
|
"yield": float(st.session_state.erosion_level.get("yield", 5.0))
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
tickers=tickers
|
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)}")
|
st.error(f"Error displaying capital investment information: {str(e)}")
|
||||||
|
|
||||||
with tab2:
|
with tab2:
|
||||||
st.subheader("Dividend Reinvestment (DRIP) Forecast")
|
st.subheader("DRIP Forecast")
|
||||||
st.write("This forecast shows the growth of your portfolio over time if dividends are reinvested instead of taken as income.")
|
|
||||||
|
|
||||||
|
# Calculate DRIP scenario
|
||||||
|
logger.info("Calculating DRIP scenario...")
|
||||||
try:
|
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
|
# Initialize DRIP service
|
||||||
from ETF_Portal.services.drip_service import DRIPService
|
from ETF_Portal.services.drip_service import DRIPService
|
||||||
|
|
||||||
# Initialize DRIP service
|
|
||||||
drip_service = DRIPService()
|
drip_service = DRIPService()
|
||||||
|
|
||||||
# Calculate DRIP forecast
|
# Get erosion values from nav_erosion_service
|
||||||
portfolio_result = drip_service.forecast_portfolio(
|
from ETF_Portal.services.nav_erosion_service import NavErosionService
|
||||||
portfolio_df=final_alloc,
|
erosion_service = NavErosionService()
|
||||||
config=DripConfig(
|
erosion_analysis = erosion_service.analyze_etf_erosion_risk(final_alloc["Ticker"].tolist())
|
||||||
months=months,
|
|
||||||
erosion_type=st.session_state.get("erosion_type", "None"),
|
# Update erosion values if analysis is available
|
||||||
erosion_level={
|
if erosion_analysis and erosion_analysis.results:
|
||||||
"nav": st.session_state.get("erosion_level", {}).get("nav", 0),
|
# Use the highest erosion values from the analysis
|
||||||
"yield": st.session_state.get("erosion_level", {}).get("yield", 0)
|
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)
|
||||||
),
|
|
||||||
tickers=tickers
|
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
|
# Debug information
|
||||||
display_drip_forecast(portfolio_result, tickers)
|
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:
|
except Exception as e:
|
||||||
st.error(f"Error calculating DRIP forecast: {str(e)}")
|
st.error(f"Error calculating DRIP scenario: {str(e)}")
|
||||||
logger.error(f"DRIP forecast error: {str(e)}")
|
st.error(traceback.format_exc())
|
||||||
logger.error(traceback.format_exc())
|
st.stop()
|
||||||
|
|
||||||
with tab3:
|
with tab3:
|
||||||
st.subheader("📉 Erosion Risk Assessment")
|
st.subheader("📉 Erosion Risk Assessment")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user