feat: Update DRIP Forecast implementation with improved erosion calculations and comparison features
This commit is contained in:
parent
81c6a1db48
commit
40cf9aac63
@ -2262,3 +2262,345 @@ if st.session_state.simulation_run and st.session_state.df_data is not None:
|
||||
st.error(f"Error displaying allocation details: {str(e)}")
|
||||
logger.error(f"Error in allocation display: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
with tab2:
|
||||
st.subheader("📊 DRIP Forecast")
|
||||
|
||||
# Get erosion settings from session state
|
||||
erosion_type = st.session_state.get("erosion_type", "None")
|
||||
erosion_level = st.session_state.get("erosion_level", 0)
|
||||
|
||||
# Create DRIP configuration
|
||||
from services.drip_service import DripConfig, DripService
|
||||
|
||||
drip_config = DripConfig(
|
||||
months=12, # Default to 12 months for initial view
|
||||
erosion_type=erosion_type,
|
||||
erosion_level=erosion_level
|
||||
)
|
||||
|
||||
# Initialize DRIP service and calculate forecast
|
||||
drip_service = DripService()
|
||||
drip_result = drip_service.calculate_drip_growth(final_alloc, drip_config)
|
||||
|
||||
# Display portfolio growth chart
|
||||
st.subheader("Portfolio Growth")
|
||||
fig1 = go.Figure()
|
||||
fig1.add_trace(go.Scatter(
|
||||
x=[data.month for data in drip_result.monthly_data],
|
||||
y=[data.total_value for data in drip_result.monthly_data],
|
||||
mode='lines',
|
||||
name='Portfolio Value',
|
||||
line=dict(color="#1f77b4", width=3)
|
||||
))
|
||||
fig1.update_layout(
|
||||
title="Portfolio Value Over Time",
|
||||
xaxis_title="Month",
|
||||
yaxis_title="Value ($)",
|
||||
template="plotly_dark",
|
||||
xaxis=dict(tickmode='linear', tick0=0, dtick=1)
|
||||
)
|
||||
st.plotly_chart(fig1, use_container_width=True)
|
||||
|
||||
# Display income growth chart
|
||||
st.subheader("Income Growth")
|
||||
fig2 = go.Figure()
|
||||
fig2.add_trace(go.Scatter(
|
||||
x=[data.month for data in drip_result.monthly_data],
|
||||
y=[data.monthly_income for data in drip_result.monthly_data],
|
||||
mode='lines',
|
||||
name='Monthly Income',
|
||||
line=dict(color="#ff7f0e", width=3)
|
||||
))
|
||||
fig2.update_layout(
|
||||
title="Monthly Income Over Time",
|
||||
xaxis_title="Month",
|
||||
yaxis_title="Monthly Income ($)",
|
||||
template="plotly_dark",
|
||||
xaxis=dict(tickmode='linear', tick0=0, dtick=1)
|
||||
)
|
||||
st.plotly_chart(fig2, use_container_width=True)
|
||||
|
||||
# Display detailed forecast table
|
||||
st.subheader("DRIP Forecast Details")
|
||||
|
||||
# Convert monthly data to DataFrame for display
|
||||
forecast_data = []
|
||||
for data in drip_result.monthly_data:
|
||||
row = {
|
||||
"Month": data.month,
|
||||
"Total Value ($)": data.total_value,
|
||||
"Monthly Income ($)": data.monthly_income,
|
||||
"Cumulative Income ($)": data.cumulative_income
|
||||
}
|
||||
# Add ticker-specific data
|
||||
for ticker, shares in data.shares.items():
|
||||
row[f"{ticker} Shares"] = shares
|
||||
row[f"{ticker} Price ($)"] = data.prices[ticker]
|
||||
row[f"{ticker} Yield (%)"] = data.yields[ticker] * 100
|
||||
forecast_data.append(row)
|
||||
|
||||
forecast_df = pd.DataFrame(forecast_data)
|
||||
|
||||
# Format the data for display
|
||||
display_forecast = forecast_df.copy()
|
||||
display_forecast["Total Value ($)"] = display_forecast["Total Value ($)"].apply(lambda x: f"${x:,.2f}")
|
||||
display_forecast["Monthly Income ($)"] = display_forecast["Monthly Income ($)"].apply(lambda x: f"${x:,.2f}")
|
||||
display_forecast["Cumulative Income ($)"] = display_forecast["Cumulative Income ($)"].apply(lambda x: f"${x:,.2f}")
|
||||
|
||||
# Format share counts and prices
|
||||
share_columns = [col for col in display_forecast.columns if "Shares" in col]
|
||||
price_columns = [col for col in display_forecast.columns if "Price" in col]
|
||||
yield_columns = [col for col in display_forecast.columns if "Yield (%)" in col]
|
||||
|
||||
for col in share_columns:
|
||||
display_forecast[col] = display_forecast[col].apply(lambda x: f"{x:.4f}")
|
||||
|
||||
for col in price_columns:
|
||||
display_forecast[col] = display_forecast[col].apply(lambda x: f"${x:.2f}")
|
||||
|
||||
for col in yield_columns:
|
||||
display_forecast[col] = display_forecast[col].apply(lambda x: f"{x:.2f}%")
|
||||
|
||||
# Create tabs for different views of the data
|
||||
detail_tabs = st.tabs(["Summary View", "Full Details"])
|
||||
|
||||
with detail_tabs[0]:
|
||||
st.dataframe(display_forecast[["Month", "Total Value ($)", "Monthly Income ($)", "Cumulative Income ($)"]],
|
||||
use_container_width=True)
|
||||
|
||||
with detail_tabs[1]:
|
||||
# Group columns by ticker for better readability
|
||||
tickers = list(drip_result.monthly_data[0].shares.keys())
|
||||
ticker_columns = {}
|
||||
for ticker in tickers:
|
||||
ticker_columns[ticker] = [
|
||||
f"{ticker} Shares",
|
||||
f"{ticker} Price ($)",
|
||||
f"{ticker} Yield (%)"
|
||||
]
|
||||
|
||||
# Create ordered columns list
|
||||
basic_columns = ["Month", "Total Value ($)", "Monthly Income ($)", "Cumulative Income ($)"]
|
||||
ordered_columns = basic_columns.copy()
|
||||
for ticker in tickers:
|
||||
ordered_columns.extend(ticker_columns[ticker])
|
||||
|
||||
st.dataframe(
|
||||
display_forecast[ordered_columns],
|
||||
use_container_width=True,
|
||||
height=500
|
||||
)
|
||||
|
||||
# Add comparison between DRIP and No-DRIP strategies
|
||||
st.subheader("📊 1-Year DRIP vs. No-DRIP Comparison")
|
||||
|
||||
# Add note about erosion effects if applicable
|
||||
if erosion_type != "None" and isinstance(erosion_level, dict):
|
||||
if erosion_level.get("use_per_ticker", False):
|
||||
st.info("""
|
||||
This comparison factors in the custom per-ETF erosion rates.
|
||||
Both strategies are affected by erosion, but DRIP helps mitigate losses by steadily acquiring more shares.
|
||||
""")
|
||||
else:
|
||||
nav_annual = (1 - (1 - (erosion_level["global"]["nav"] / MAX_EROSION_LEVEL) * max_monthly_erosion)**12) * 100
|
||||
yield_annual = (1 - (1 - (erosion_level["global"]["yield"] / MAX_EROSION_LEVEL) * max_monthly_erosion)**12) * 100
|
||||
st.info(f"""
|
||||
This comparison factors in:
|
||||
- NAV Erosion: {nav_annual:.1f}% annually
|
||||
- Yield Erosion: {yield_annual:.1f}% annually
|
||||
|
||||
Both strategies are affected by erosion, but DRIP helps mitigate losses by steadily acquiring more shares.
|
||||
""")
|
||||
|
||||
# Calculate no-drip scenario (taking dividends as income)
|
||||
initial_value = drip_result.monthly_data[0].total_value
|
||||
initial_monthly_income = drip_result.monthly_data[0].monthly_income
|
||||
annual_income = initial_monthly_income * 12
|
||||
|
||||
# Initialize ticker data dictionary
|
||||
ticker_data_dict = {}
|
||||
for _, row in final_alloc.iterrows():
|
||||
ticker = row["Ticker"]
|
||||
ticker_data_dict[ticker] = {
|
||||
"price": row["Price"],
|
||||
"yield_annual": row["Yield (%)"] / 100, # Convert from % to decimal
|
||||
"distribution": row.get("Distribution Period", "Monthly")
|
||||
}
|
||||
|
||||
# Get the final prices after erosion from the last month of the DRIP forecast
|
||||
final_prices = {}
|
||||
for ticker in tickers:
|
||||
price_col = f"{ticker} Price ($)"
|
||||
if price_col in forecast_df.columns:
|
||||
final_prices[ticker] = forecast_df[price_col].iloc[-1]
|
||||
else:
|
||||
# Fallback to initial price if column doesn't exist
|
||||
final_prices[ticker] = ticker_data_dict[ticker]["price"]
|
||||
|
||||
# Extract initial shares for each ETF from month 1
|
||||
initial_shares = {ticker: forecast_df.iloc[0][f"{ticker} Shares"] for ticker in tickers}
|
||||
|
||||
# Calculate the No-DRIP final value by multiplying initial shares by final prices
|
||||
# This correctly accounts for NAV erosion while keeping shares constant
|
||||
nodrip_final_value = sum(initial_shares[ticker] * final_prices[ticker] for ticker in tickers)
|
||||
|
||||
# The final income should account for erosion but not compounding growth
|
||||
# This requires simulation of the erosion that would have happened
|
||||
if erosion_type != "None" and isinstance(erosion_level, dict):
|
||||
# Initialize the current prices and yields from the final_alloc dataframe
|
||||
current_prices = {}
|
||||
current_yields = {}
|
||||
|
||||
# Reconstruct ticker data from final_alloc
|
||||
for _, row in final_alloc.iterrows():
|
||||
ticker = row["Ticker"]
|
||||
current_prices[ticker] = row["Price"]
|
||||
current_yields[ticker] = row["Yield (%)"] / 100
|
||||
|
||||
# Get the erosion rates for each ticker
|
||||
if erosion_level.get("use_per_ticker", False):
|
||||
ticker_nav_rates = {}
|
||||
ticker_yield_rates = {}
|
||||
for ticker in tickers:
|
||||
ticker_settings = erosion_level["per_ticker"].get(ticker, {"nav": 0, "yield": 0})
|
||||
ticker_nav_rates[ticker] = ticker_settings["nav"] / MAX_EROSION_LEVEL * max_monthly_erosion
|
||||
ticker_yield_rates[ticker] = ticker_settings["yield"] / MAX_EROSION_LEVEL * max_monthly_erosion
|
||||
else:
|
||||
# Use global rates for all tickers
|
||||
global_nav = erosion_level["global"]["nav"] / MAX_EROSION_LEVEL * max_monthly_erosion
|
||||
global_yield = erosion_level["global"]["yield"] / MAX_EROSION_LEVEL * max_monthly_erosion
|
||||
ticker_nav_rates = {ticker: global_nav for ticker in tickers}
|
||||
ticker_yield_rates = {ticker: global_yield for ticker in tickers}
|
||||
|
||||
# Apply 12 months of erosion
|
||||
for month in range(1, 13):
|
||||
# Apply erosion to each ticker
|
||||
for ticker in tickers:
|
||||
# Apply NAV erosion
|
||||
if ticker_nav_rates[ticker] > 0:
|
||||
current_prices[ticker] *= (1 - ticker_nav_rates[ticker])
|
||||
|
||||
# Apply yield erosion
|
||||
if ticker_yield_rates[ticker] > 0:
|
||||
current_yields[ticker] *= (1 - ticker_yield_rates[ticker])
|
||||
|
||||
# Calculate final monthly income with eroded prices and yields but original shares
|
||||
final_monthly_income_nodrip = sum(
|
||||
(current_yields[ticker] / 12) *
|
||||
(initial_shares[ticker] * current_prices[ticker])
|
||||
for ticker in tickers
|
||||
)
|
||||
else:
|
||||
# No erosion, so final income is the same as initial income
|
||||
final_monthly_income_nodrip = initial_monthly_income
|
||||
|
||||
nodrip_final_annual_income = final_monthly_income_nodrip * 12
|
||||
|
||||
# Get values for DRIP scenario from forecast
|
||||
drip_final_value = drip_result.final_portfolio_value
|
||||
drip_final_monthly_income = drip_result.monthly_data[-1].monthly_income
|
||||
drip_annual_income_end = drip_final_monthly_income * 12
|
||||
|
||||
# Create comparison dataframe with withdrawn income for a more complete financial picture
|
||||
|
||||
# For No-DRIP strategy, calculate cumulative withdrawn income (sum of monthly dividends)
|
||||
# This is equivalent to the cumulative income in the DRIP forecast, but in No-DRIP it's withdrawn
|
||||
withdrawn_income = 0
|
||||
monthly_dividends = []
|
||||
|
||||
# Reconstruct the monthly dividend calculation for No-DRIP
|
||||
current_prices_monthly = {ticker: ticker_data_dict[ticker]["price"] for ticker in tickers}
|
||||
current_yields_monthly = {ticker: ticker_data_dict[ticker]["yield_annual"] for ticker in tickers}
|
||||
|
||||
for month in range(1, 13):
|
||||
# Calculate dividends for this month based on current yields and prices
|
||||
month_dividend = sum(
|
||||
(current_yields_monthly[ticker] / 12) *
|
||||
(initial_shares[ticker] * current_prices_monthly[ticker])
|
||||
for ticker in tickers
|
||||
)
|
||||
withdrawn_income += month_dividend
|
||||
monthly_dividends.append(month_dividend)
|
||||
|
||||
# Apply erosion for next month
|
||||
if erosion_type != "None":
|
||||
for ticker in tickers:
|
||||
# Apply NAV erosion
|
||||
if ticker in ticker_nav_rates and ticker_nav_rates[ticker] > 0:
|
||||
current_prices_monthly[ticker] *= (1 - ticker_nav_rates[ticker])
|
||||
|
||||
# Apply yield erosion
|
||||
if ticker in ticker_yield_rates and ticker_yield_rates[ticker] > 0:
|
||||
current_yields_monthly[ticker] *= (1 - ticker_yield_rates[ticker])
|
||||
|
||||
# Calculate total economic result
|
||||
nodrip_total = nodrip_final_value + withdrawn_income
|
||||
drip_total = drip_final_value
|
||||
|
||||
# Display economic comparison
|
||||
st.subheader("Economic Comparison")
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.markdown("#### No-DRIP KPIs")
|
||||
st.metric("Final Portfolio Value", f"${nodrip_final_value:,.2f}")
|
||||
st.metric("Total Income Withdrawn", f"${withdrawn_income:,.2f}")
|
||||
st.metric("Final Monthly Income", f"${final_monthly_income_nodrip:,.2f}")
|
||||
st.metric("Total Economic Result", f"${nodrip_total:,.2f}")
|
||||
|
||||
with col2:
|
||||
st.markdown("#### DRIP KPIs")
|
||||
st.metric("Final Portfolio Value", f"${drip_final_value:,.2f}")
|
||||
st.metric("Final Monthly Income", f"${drip_final_monthly_income:,.2f}")
|
||||
st.metric("Total Economic Result", f"${drip_total:,.2f}")
|
||||
|
||||
# Display monthly income comparison chart
|
||||
st.subheader("Monthly Income Comparison")
|
||||
|
||||
# Create monthly income comparison chart
|
||||
fig3 = go.Figure()
|
||||
|
||||
# Add No-DRIP monthly income line
|
||||
fig3.add_trace(go.Scatter(
|
||||
x=list(range(1, 13)),
|
||||
y=monthly_dividends,
|
||||
mode='lines',
|
||||
name='No-DRIP Monthly Income',
|
||||
line=dict(color='#ff7f0e', width=2)
|
||||
))
|
||||
|
||||
# Add DRIP monthly income line
|
||||
drip_monthly_income = [data.monthly_income for data in drip_result.monthly_data]
|
||||
fig3.add_trace(go.Scatter(
|
||||
x=list(range(1, 13)),
|
||||
y=drip_monthly_income,
|
||||
mode='lines',
|
||||
name='DRIP Monthly Income',
|
||||
line=dict(color='#1f77b4', width=2)
|
||||
))
|
||||
|
||||
fig3.update_layout(
|
||||
title="Monthly Income: DRIP vs No-DRIP Strategy",
|
||||
xaxis_title="Month",
|
||||
yaxis_title="Monthly Income ($)",
|
||||
template="plotly_dark",
|
||||
showlegend=True
|
||||
)
|
||||
|
||||
st.plotly_chart(fig3, use_container_width=True)
|
||||
|
||||
# Display advantages of DRIP strategy
|
||||
st.subheader("DRIP Strategy Advantages")
|
||||
|
||||
# Calculate percentage differences
|
||||
value_diff_pct = ((drip_final_value - nodrip_final_value) / nodrip_final_value) * 100
|
||||
income_diff_pct = ((drip_final_monthly_income - final_monthly_income_nodrip) / final_monthly_income_nodrip) * 100
|
||||
|
||||
st.markdown(f"""
|
||||
- **Portfolio Value**: DRIP strategy results in a {value_diff_pct:.1f}% higher final portfolio value
|
||||
- **Monthly Income**: DRIP strategy generates {income_diff_pct:.1f}% higher monthly income
|
||||
- **Risk Mitigation**: DRIP helps mitigate erosion effects by continuously acquiring more shares
|
||||
- **Compounding Effect**: Reinvested dividends generate additional income through compounding
|
||||
""")
|
||||
4
services/drip_service/__init__.py
Normal file
4
services/drip_service/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from .service import DripService
|
||||
from .models import DripConfig, DripResult, MonthlyData, PortfolioAllocation
|
||||
|
||||
__all__ = ['DripService', 'DripConfig', 'DripResult', 'MonthlyData', 'PortfolioAllocation']
|
||||
46
services/drip_service/models.py
Normal file
46
services/drip_service/models.py
Normal file
@ -0,0 +1,46 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
@dataclass
|
||||
class PortfolioAllocation:
|
||||
ticker: str
|
||||
price: float
|
||||
yield_annual: float
|
||||
initial_shares: float
|
||||
initial_allocation: float
|
||||
distribution: str
|
||||
|
||||
@dataclass
|
||||
class MonthlyData:
|
||||
month: int
|
||||
total_value: float
|
||||
monthly_income: float
|
||||
cumulative_income: float
|
||||
shares: Dict[str, float]
|
||||
prices: Dict[str, float]
|
||||
yields: Dict[str, float]
|
||||
|
||||
@dataclass
|
||||
class DripConfig:
|
||||
months: int
|
||||
erosion_type: str
|
||||
erosion_level: Dict
|
||||
dividend_frequency: Dict[str, int] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.dividend_frequency is None:
|
||||
self.dividend_frequency = {
|
||||
"Monthly": 12,
|
||||
"Quarterly": 4,
|
||||
"Semi-Annually": 2,
|
||||
"Annually": 1,
|
||||
"Unknown": 12 # Default to monthly if unknown
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class DripResult:
|
||||
monthly_data: List[MonthlyData]
|
||||
final_portfolio_value: float
|
||||
total_income: float
|
||||
total_shares: Dict[str, float]
|
||||
280
services/drip_service/service.py
Normal file
280
services/drip_service/service.py
Normal file
@ -0,0 +1,280 @@
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
import pandas as pd
|
||||
import logging
|
||||
import traceback
|
||||
from .models import PortfolioAllocation, MonthlyData, DripConfig, DripResult
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DripService:
|
||||
def __init__(self) -> None:
|
||||
self.MAX_EROSION_LEVEL = 9
|
||||
self.max_monthly_erosion = 1 - (0.1)**(1/12) # ~17.54% monthly for 90% annual erosion
|
||||
self.dividend_frequency = {
|
||||
"Monthly": 12,
|
||||
"Quarterly": 4,
|
||||
"Semi-Annually": 2,
|
||||
"Annually": 1,
|
||||
"Unknown": 12 # Default to monthly if unknown
|
||||
}
|
||||
|
||||
def calculate_drip_growth(self, portfolio_df: pd.DataFrame, config: DripConfig) -> DripResult:
|
||||
"""
|
||||
Calculate DRIP growth for a portfolio over a specified period.
|
||||
|
||||
Args:
|
||||
portfolio_df: DataFrame containing portfolio allocation
|
||||
config: DripConfig object with simulation parameters
|
||||
|
||||
Returns:
|
||||
DripResult object containing the simulation results
|
||||
"""
|
||||
try:
|
||||
# Initialize monthly data list
|
||||
monthly_data: List[MonthlyData] = []
|
||||
|
||||
# Get initial values
|
||||
initial_shares = self._calculate_initial_shares(portfolio_df)
|
||||
initial_prices = dict(zip(portfolio_df["Ticker"], portfolio_df["Price"]))
|
||||
initial_yields = dict(zip(portfolio_df["Ticker"], portfolio_df["Yield (%)"] / 100))
|
||||
|
||||
# Initialize tracking variables
|
||||
current_shares = initial_shares.copy()
|
||||
current_prices = initial_prices.copy()
|
||||
current_yields = initial_yields.copy()
|
||||
cumulative_income = 0.0
|
||||
|
||||
# Run simulation for each month
|
||||
for month in range(1, config.months + 1):
|
||||
# Calculate monthly income
|
||||
monthly_income = sum(
|
||||
(current_yields[ticker] / 12) *
|
||||
(current_shares[ticker] * current_prices[ticker])
|
||||
for ticker in current_shares.keys()
|
||||
)
|
||||
|
||||
# Update cumulative income
|
||||
cumulative_income += monthly_income
|
||||
|
||||
# Calculate total portfolio value
|
||||
total_value = sum(
|
||||
current_shares[ticker] * current_prices[ticker]
|
||||
for ticker in current_shares.keys()
|
||||
)
|
||||
|
||||
# Apply erosion if enabled
|
||||
if config.erosion_type != "None":
|
||||
current_prices, current_yields = self._apply_erosion(
|
||||
current_prices,
|
||||
current_yields,
|
||||
config.erosion_type,
|
||||
config.erosion_level
|
||||
)
|
||||
|
||||
# Reinvest dividends
|
||||
for ticker in current_shares.keys():
|
||||
dividend_income = (current_yields[ticker] / 12) * (current_shares[ticker] * current_prices[ticker])
|
||||
new_shares = dividend_income / current_prices[ticker]
|
||||
current_shares[ticker] += new_shares
|
||||
|
||||
# Store monthly data
|
||||
monthly_data.append(MonthlyData(
|
||||
month=month,
|
||||
total_value=total_value,
|
||||
monthly_income=monthly_income,
|
||||
cumulative_income=cumulative_income,
|
||||
shares=current_shares.copy(),
|
||||
prices=current_prices.copy(),
|
||||
yields=current_yields.copy()
|
||||
))
|
||||
|
||||
# Calculate final values
|
||||
final_portfolio_value = monthly_data[-1].total_value
|
||||
total_income = monthly_data[-1].cumulative_income
|
||||
total_shares = current_shares.copy()
|
||||
|
||||
return DripResult(
|
||||
monthly_data=monthly_data,
|
||||
final_portfolio_value=final_portfolio_value,
|
||||
total_income=total_income,
|
||||
total_shares=total_shares
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating DRIP growth: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def _calculate_initial_shares(self, portfolio_df: pd.DataFrame) -> Dict[str, float]:
|
||||
"""Calculate initial shares for each ETF."""
|
||||
return dict(zip(portfolio_df["Ticker"], portfolio_df["Shares"]))
|
||||
|
||||
def _apply_erosion(
|
||||
self,
|
||||
prices: Dict[str, float],
|
||||
yields: Dict[str, float],
|
||||
erosion_type: str,
|
||||
erosion_level: Dict[str, Any]
|
||||
) -> Tuple[Dict[str, float], Dict[str, float]]:
|
||||
"""
|
||||
Apply erosion to prices and yields based on configuration.
|
||||
|
||||
Args:
|
||||
prices: Dictionary of current prices
|
||||
yields: Dictionary of current yields
|
||||
erosion_type: Type of erosion to apply
|
||||
erosion_level: Dictionary containing erosion levels
|
||||
|
||||
Returns:
|
||||
Tuple of updated prices and yields
|
||||
"""
|
||||
try:
|
||||
updated_prices = prices.copy()
|
||||
updated_yields = yields.copy()
|
||||
|
||||
if erosion_type == "None":
|
||||
return updated_prices, updated_yields
|
||||
|
||||
if erosion_level.get("use_per_ticker", False):
|
||||
# Apply per-ticker erosion rates
|
||||
for ticker in prices.keys():
|
||||
if ticker in erosion_level:
|
||||
ticker_erosion = erosion_level[ticker]
|
||||
# Apply monthly erosion
|
||||
nav_erosion = (ticker_erosion["nav"] / 9) * 0.1754
|
||||
yield_erosion = (ticker_erosion["yield"] / 9) * 0.1754
|
||||
|
||||
updated_prices[ticker] *= (1 - nav_erosion)
|
||||
updated_yields[ticker] *= (1 - yield_erosion)
|
||||
else:
|
||||
# Apply global erosion rates
|
||||
nav_erosion = (erosion_level["global"]["nav"] / 9) * 0.1754
|
||||
yield_erosion = (erosion_level["global"]["yield"] / 9) * 0.1754
|
||||
|
||||
for ticker in prices.keys():
|
||||
updated_prices[ticker] *= (1 - nav_erosion)
|
||||
updated_yields[ticker] *= (1 - yield_erosion)
|
||||
|
||||
return updated_prices, updated_yields
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying erosion: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
return prices, yields
|
||||
|
||||
def _initialize_erosion_rates(
|
||||
self,
|
||||
tickers: List[str],
|
||||
erosion_type: str,
|
||||
erosion_level: Dict[str, Any]
|
||||
) -> Tuple[Dict[str, float], Dict[str, float]]:
|
||||
"""Initialize erosion rates for each ticker based on configuration."""
|
||||
ticker_nav_rates: Dict[str, float] = {}
|
||||
ticker_yield_rates: Dict[str, float] = {}
|
||||
|
||||
if erosion_type != "None" and isinstance(erosion_level, dict):
|
||||
if erosion_level.get("use_per_ticker", False) and "per_ticker" in erosion_level:
|
||||
global_nav = erosion_level["global"]["nav"] / self.MAX_EROSION_LEVEL * self.max_monthly_erosion
|
||||
global_yield = erosion_level["global"]["yield"] / self.MAX_EROSION_LEVEL * self.max_monthly_erosion
|
||||
|
||||
for ticker in tickers:
|
||||
ticker_settings = erosion_level["per_ticker"].get(ticker, {"nav": 0, "yield": 0})
|
||||
ticker_nav_rates[ticker] = ticker_settings["nav"] / self.MAX_EROSION_LEVEL * self.max_monthly_erosion
|
||||
ticker_yield_rates[ticker] = ticker_settings["yield"] / self.MAX_EROSION_LEVEL * self.max_monthly_erosion
|
||||
else:
|
||||
global_nav = erosion_level["global"]["nav"] / self.MAX_EROSION_LEVEL * self.max_monthly_erosion
|
||||
global_yield = erosion_level["global"]["yield"] / self.MAX_EROSION_LEVEL * self.max_monthly_erosion
|
||||
|
||||
for ticker in tickers:
|
||||
ticker_nav_rates[ticker] = global_nav
|
||||
ticker_yield_rates[ticker] = global_yield
|
||||
else:
|
||||
for ticker in tickers:
|
||||
ticker_nav_rates[ticker] = 0
|
||||
ticker_yield_rates[ticker] = 0
|
||||
|
||||
return ticker_nav_rates, ticker_yield_rates
|
||||
|
||||
def _create_ticker_data(self, portfolio_df: pd.DataFrame) -> Dict[str, Dict[str, Any]]:
|
||||
"""Create a dictionary of ticker-specific data."""
|
||||
ticker_data: Dict[str, Dict[str, Any]] = {}
|
||||
for _, row in portfolio_df.iterrows():
|
||||
ticker = row["Ticker"]
|
||||
ticker_data[ticker] = {
|
||||
"price": row["Price"],
|
||||
"yield_annual": row["Yield (%)"] / 100,
|
||||
"initial_shares": row["Capital Allocated ($)"] / row["Price"],
|
||||
"initial_allocation": row["Allocation (%)"] / 100,
|
||||
"distribution": row.get("Distribution Period", "Monthly")
|
||||
}
|
||||
return ticker_data
|
||||
|
||||
def _calculate_monthly_income(
|
||||
self,
|
||||
current_shares: Dict[str, float],
|
||||
current_prices: Dict[str, float],
|
||||
current_yields: Dict[str, float],
|
||||
tickers: List[str]
|
||||
) -> float:
|
||||
"""Calculate expected monthly income based on current portfolio and yields."""
|
||||
return sum(
|
||||
(current_yields[ticker] / 12) *
|
||||
(current_shares[ticker] * current_prices[ticker])
|
||||
for ticker in tickers
|
||||
)
|
||||
|
||||
def _create_month_data(
|
||||
self,
|
||||
month: int,
|
||||
current_total_value: float,
|
||||
monthly_income: float,
|
||||
cumulative_income: float,
|
||||
current_shares: Dict[str, float],
|
||||
current_prices: Dict[str, float],
|
||||
current_yields: Dict[str, float],
|
||||
tickers: List[str]
|
||||
) -> MonthlyData:
|
||||
"""Create monthly data object."""
|
||||
return MonthlyData(
|
||||
month=month,
|
||||
total_value=current_total_value,
|
||||
monthly_income=monthly_income,
|
||||
cumulative_income=cumulative_income,
|
||||
shares=current_shares.copy(),
|
||||
prices=current_prices.copy(),
|
||||
yields=current_yields.copy()
|
||||
)
|
||||
|
||||
def _calculate_and_reinvest_dividends(
|
||||
self,
|
||||
month: int,
|
||||
ticker_data: Dict[str, Dict[str, Any]],
|
||||
current_shares: Dict[str, float],
|
||||
current_prices: Dict[str, float],
|
||||
current_yields: Dict[str, float],
|
||||
ticker_yield_rates: Dict[str, float],
|
||||
dividend_frequency: Dict[str, int]
|
||||
) -> float:
|
||||
"""Calculate dividends and reinvest them proportionally."""
|
||||
month_dividends: Dict[str, float] = {}
|
||||
for ticker, data in ticker_data.items():
|
||||
freq = dividend_frequency[data["distribution"]]
|
||||
if month % (12 / freq) == 0:
|
||||
if ticker_yield_rates[ticker] > 0:
|
||||
dividend = (current_yields[ticker] / freq) * current_shares[ticker] * current_prices[ticker]
|
||||
else:
|
||||
dividend = (data["yield_annual"] / freq) * current_shares[ticker] * current_prices[ticker]
|
||||
else:
|
||||
dividend = 0
|
||||
month_dividends[ticker] = dividend
|
||||
|
||||
total_month_dividend = sum(month_dividends.values())
|
||||
|
||||
# Reinvest dividends proportionally
|
||||
for ticker, data in ticker_data.items():
|
||||
if current_prices[ticker] > 0:
|
||||
new_shares = (total_month_dividend * data["initial_allocation"]) / current_prices[ticker]
|
||||
current_shares[ticker] += new_shares
|
||||
|
||||
return total_month_dividend
|
||||
31
services/nav_erosion_service/models.py
Normal file
31
services/nav_erosion_service/models.py
Normal file
@ -0,0 +1,31 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
@dataclass
|
||||
class NavErosionConfig:
|
||||
max_erosion_level: int = 9
|
||||
max_monthly_erosion: float = 1 - (0.1)**(1/12) # ~17.54% monthly for 90% annual erosion
|
||||
use_per_ticker: bool = False
|
||||
global_nav_rate: float = 0
|
||||
per_ticker_rates: Dict[str, float] = None
|
||||
|
||||
@dataclass
|
||||
class NavErosionResult:
|
||||
ticker: str
|
||||
nav_erosion_rate: float
|
||||
monthly_erosion_rate: float
|
||||
annual_erosion_rate: float
|
||||
risk_level: int # 0-9 scale
|
||||
risk_explanation: str
|
||||
max_drawdown: float
|
||||
volatility: float
|
||||
is_new_etf: bool
|
||||
etf_age_years: Optional[float]
|
||||
|
||||
@dataclass
|
||||
class NavErosionAnalysis:
|
||||
results: List[NavErosionResult]
|
||||
portfolio_nav_risk: float # Average risk level
|
||||
portfolio_erosion_rate: float # Weighted average erosion rate
|
||||
risk_summary: str
|
||||
Loading…
Reference in New Issue
Block a user