Compare commits

..

2 Commits

2 changed files with 260 additions and 81 deletions

View File

@ -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,21 +201,13 @@ 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", {})
erosion_level=config.erosion_level
)
return ErosionConfig(erosion_type="None")
def _normalize_erosion_rate(self, erosion_level: float) -> float:
"""Convert erosion level (0-9) to monthly rate with validation"""
rate = (erosion_level / self.MAX_EROSION_LEVEL) * self.MAX_MONTHLY_EROSION
@ -239,11 +254,18 @@ 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
def _apply_monthly_erosion(
@ -252,22 +274,24 @@ class DRIPService:
erosion_config: ErosionConfig,
tickers: List[str]
) -> 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:
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
# 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 with bounds checking
state['current_prices'][ticker] *= max(0.01, 1 - nav_erosion)
state['current_yields'][ticker] *= max(0.0, 1 - yield_erosion)
# 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,15 +307,20 @@ 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"""
if frequency == DistributionFrequency.MONTHLY:

View File

@ -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")
@ -2080,6 +2095,20 @@ def display_drip_forecast(portfolio_result, tickers):
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)
@ -2087,12 +2116,15 @@ def display_drip_forecast(portfolio_result, tickers):
st.metric(
"Portfolio Value",
f"${total_value:,.2f}",
f"${accumulated_cash:,.2f} cash" if accumulated_cash > 0 else None
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"${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(
@ -2108,14 +2140,14 @@ def display_drip_forecast(portfolio_result, tickers):
drip_service = DRIPService()
# Calculate DRIP scenario
portfolio_result = drip_service.forecast_portfolio(
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": 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
@ -2128,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
@ -2139,7 +2171,7 @@ def display_drip_forecast(portfolio_result, tickers):
comparison_data = {
"Strategy": ["DRIP", "No-DRIP"],
"Portfolio Value": [
portfolio_result.total_value,
drip_result.total_value,
nodrip_result.total_value
],
"Accumulated Cash": [
@ -2147,7 +2179,7 @@ def display_drip_forecast(portfolio_result, tickers):
nodrip_result.accumulated_cash
],
"Total Value": [
portfolio_result.total_value,
drip_result.total_value,
nodrip_result.total_value + nodrip_result.accumulated_cash
]
}
@ -2159,7 +2191,7 @@ def display_drip_forecast(portfolio_result, tickers):
fig.add_trace(go.Bar(
name="DRIP",
x=["Portfolio Value"],
y=[portfolio_result.total_value],
y=[drip_result.total_value],
marker_color="#1f77b4"
))
@ -2201,12 +2233,12 @@ def display_drip_forecast(portfolio_result, tickers):
"Share Growth"
],
"DRIP": [
f"${portfolio_result.total_value:,.2f}",
f"${drip_result.total_value:,.2f}",
"$0.00",
f"${portfolio_result.total_value:,.2f}",
f"${portfolio_result.monthly_income:,.2f}",
f"${portfolio_result.total_income:,.2f}",
f"{((portfolio_result.etf_results[tickers[0]].final_shares / portfolio_result.etf_results[tickers[0]].initial_shares - 1) * 100):.1f}%"
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}",
@ -2229,6 +2261,70 @@ def display_drip_forecast(portfolio_result, tickers):
- 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)}")
@ -2270,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)
# 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))
}
),
tickers=tickers
)
# 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")