refactor: remove duplicate ETF display from sidebar for cleaner UI
This commit is contained in:
parent
e0dc6e57eb
commit
3929f2d4f0
@ -19,6 +19,7 @@ import sys
|
|||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
import re
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv(override=True) # Force reload of environment variables
|
load_dotenv(override=True) # Force reload of environment variables
|
||||||
@ -340,11 +341,123 @@ def calculate_correlation_matrix(price_data_dict: Dict[str, pd.DataFrame]) -> pd
|
|||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
def optimize_portfolio_allocation(
|
def calculate_etf_risk_score(etf: Dict[str, Any]) -> float:
|
||||||
etf_metrics: List[Dict[str, Any]],
|
"""
|
||||||
risk_tolerance: str,
|
Calculate a comprehensive risk score for an ETF based on multiple metrics.
|
||||||
correlation_matrix: pd.DataFrame
|
|
||||||
) -> List[Dict[str, Any]]:
|
Args:
|
||||||
|
etf: Dictionary containing ETF metrics
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float: Risk score (0-100, higher means higher risk)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
score = 0
|
||||||
|
metrics_used = 0
|
||||||
|
|
||||||
|
# Primary Metrics (60% of total score)
|
||||||
|
# 1. Volatility (20%)
|
||||||
|
if 'volatility' in etf:
|
||||||
|
volatility = etf['volatility']
|
||||||
|
if volatility < 10:
|
||||||
|
score += 20
|
||||||
|
elif volatility < 15:
|
||||||
|
score += 15
|
||||||
|
elif volatility < 20:
|
||||||
|
score += 10
|
||||||
|
else:
|
||||||
|
score += 5
|
||||||
|
metrics_used += 1
|
||||||
|
|
||||||
|
# 2. Yield (20%)
|
||||||
|
if 'yield' in etf:
|
||||||
|
yield_value = etf['yield']
|
||||||
|
if yield_value < 3:
|
||||||
|
score += 5
|
||||||
|
elif yield_value < 6:
|
||||||
|
score += 10
|
||||||
|
elif yield_value < 10:
|
||||||
|
score += 15
|
||||||
|
else:
|
||||||
|
score += 20
|
||||||
|
metrics_used += 1
|
||||||
|
|
||||||
|
# 3. Sharpe/Sortino Ratio (20%)
|
||||||
|
if 'sharpe_ratio' in etf:
|
||||||
|
sharpe = etf['sharpe_ratio']
|
||||||
|
if sharpe > 1.5:
|
||||||
|
score += 5
|
||||||
|
elif sharpe > 1.0:
|
||||||
|
score += 10
|
||||||
|
elif sharpe > 0.8:
|
||||||
|
score += 15
|
||||||
|
else:
|
||||||
|
score += 20
|
||||||
|
metrics_used += 1
|
||||||
|
|
||||||
|
# Secondary Metrics (40% of total score)
|
||||||
|
# 1. Dividend Growth (10%)
|
||||||
|
if 'dividend_growth' in etf:
|
||||||
|
growth = etf['dividend_growth']
|
||||||
|
if growth > 10:
|
||||||
|
score += 5
|
||||||
|
elif growth > 5:
|
||||||
|
score += 7
|
||||||
|
elif growth > 0:
|
||||||
|
score += 10
|
||||||
|
else:
|
||||||
|
score += 15
|
||||||
|
metrics_used += 1
|
||||||
|
|
||||||
|
# 2. Payout Ratio (10%)
|
||||||
|
if 'payout_ratio' in etf:
|
||||||
|
ratio = etf['payout_ratio']
|
||||||
|
if ratio < 40:
|
||||||
|
score += 5
|
||||||
|
elif ratio < 60:
|
||||||
|
score += 7
|
||||||
|
elif ratio < 80:
|
||||||
|
score += 10
|
||||||
|
else:
|
||||||
|
score += 15
|
||||||
|
metrics_used += 1
|
||||||
|
|
||||||
|
# 3. Expense Ratio (10%)
|
||||||
|
if 'expense_ratio' in etf:
|
||||||
|
ratio = etf['expense_ratio']
|
||||||
|
if ratio < 0.2:
|
||||||
|
score += 5
|
||||||
|
elif ratio < 0.4:
|
||||||
|
score += 7
|
||||||
|
elif ratio < 0.6:
|
||||||
|
score += 10
|
||||||
|
else:
|
||||||
|
score += 15
|
||||||
|
metrics_used += 1
|
||||||
|
|
||||||
|
# 4. AUM/Volume (10%)
|
||||||
|
if 'aum' in etf:
|
||||||
|
aum = etf['aum']
|
||||||
|
if aum > 5e9: # > $5B
|
||||||
|
score += 5
|
||||||
|
elif aum > 1e9: # > $1B
|
||||||
|
score += 7
|
||||||
|
elif aum > 500e6: # > $500M
|
||||||
|
score += 10
|
||||||
|
else:
|
||||||
|
score += 15
|
||||||
|
metrics_used += 1
|
||||||
|
|
||||||
|
# Normalize score based on available metrics
|
||||||
|
if metrics_used > 0:
|
||||||
|
return score / metrics_used
|
||||||
|
return 50 # Default middle score if no metrics available
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating ETF risk score: {str(e)}")
|
||||||
|
return 50
|
||||||
|
|
||||||
|
def optimize_portfolio_allocation(etf_metrics: List[Dict[str, Any]], risk_tolerance: str, correlation_matrix: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Optimize portfolio allocation based on risk tolerance and ETF metrics.
|
Optimize portfolio allocation based on risk tolerance and ETF metrics.
|
||||||
|
|
||||||
@ -360,93 +473,61 @@ def optimize_portfolio_allocation(
|
|||||||
logger.info(f"Optimizing portfolio allocation for {risk_tolerance} risk tolerance")
|
logger.info(f"Optimizing portfolio allocation for {risk_tolerance} risk tolerance")
|
||||||
logger.info(f"ETF metrics: {etf_metrics}")
|
logger.info(f"ETF metrics: {etf_metrics}")
|
||||||
|
|
||||||
# Group ETFs by risk category
|
# Calculate risk scores for each ETF
|
||||||
low_risk = [etf for etf in etf_metrics if etf.get("Risk Level", "Unknown") == "Low"]
|
for etf in etf_metrics:
|
||||||
medium_risk = [etf for etf in etf_metrics if etf.get("Risk Level", "Unknown") == "Medium"]
|
etf['risk_score'] = calculate_etf_risk_score(etf)
|
||||||
high_risk = [etf for etf in etf_metrics if etf.get("Risk Level", "Unknown") == "High"]
|
logger.info(f"Risk score for {etf['Ticker']}: {etf['risk_score']:.2f}")
|
||||||
|
|
||||||
logger.info(f"Risk groups - Low: {len(low_risk)}, Medium: {len(medium_risk)}, High: {len(high_risk)}")
|
# Sort ETFs by risk score based on risk tolerance
|
||||||
|
if risk_tolerance == "Conservative":
|
||||||
|
# For conservative, prefer lower risk
|
||||||
|
sorted_etfs = sorted(etf_metrics, key=lambda x: x['risk_score'])
|
||||||
|
elif risk_tolerance == "Aggressive":
|
||||||
|
# For aggressive, prefer higher risk
|
||||||
|
sorted_etfs = sorted(etf_metrics, key=lambda x: x['risk_score'], reverse=True)
|
||||||
|
else: # Moderate
|
||||||
|
# For moderate, sort by Sharpe ratio first, then risk score
|
||||||
|
sorted_etfs = sorted(etf_metrics,
|
||||||
|
key=lambda x: (x.get('sharpe_ratio', 0), -x['risk_score']),
|
||||||
|
reverse=True)
|
||||||
|
|
||||||
# Sort ETFs by score within each risk category
|
logger.info(f"Sorted ETFs: {[etf['Ticker'] for etf in sorted_etfs]}")
|
||||||
low_risk.sort(key=lambda x: x.get("score", 0), reverse=True)
|
|
||||||
medium_risk.sort(key=lambda x: x.get("score", 0), reverse=True)
|
|
||||||
high_risk.sort(key=lambda x: x.get("score", 0), reverse=True)
|
|
||||||
|
|
||||||
# Initialize allocations
|
# Calculate base allocations based on risk tolerance
|
||||||
allocations = []
|
num_etfs = len(sorted_etfs)
|
||||||
|
if num_etfs == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
if risk_tolerance == "Conservative":
|
if risk_tolerance == "Conservative":
|
||||||
# Conservative allocation
|
# 50% to low risk, 30% to medium risk, 20% to high risk
|
||||||
if low_risk:
|
base_allocations = [0.5] + [0.3] + [0.2] + [0.0] * (num_etfs - 3)
|
||||||
# Allocate 50% to low-risk ETFs
|
|
||||||
low_risk_alloc = 50.0 / len(low_risk)
|
|
||||||
for etf in low_risk:
|
|
||||||
allocations.append({"ticker": etf["Ticker"], "allocation": low_risk_alloc})
|
|
||||||
|
|
||||||
if medium_risk:
|
|
||||||
# Allocate 30% to medium-risk ETFs
|
|
||||||
medium_risk_alloc = 30.0 / len(medium_risk)
|
|
||||||
for etf in medium_risk:
|
|
||||||
allocations.append({"ticker": etf["Ticker"], "allocation": medium_risk_alloc})
|
|
||||||
|
|
||||||
if high_risk:
|
|
||||||
# Allocate 20% to high-risk ETFs
|
|
||||||
high_risk_alloc = 20.0 / len(high_risk)
|
|
||||||
for etf in high_risk:
|
|
||||||
allocations.append({"ticker": etf["Ticker"], "allocation": high_risk_alloc})
|
|
||||||
|
|
||||||
elif risk_tolerance == "Moderate":
|
elif risk_tolerance == "Moderate":
|
||||||
# Moderate allocation
|
# 40% to medium risk, 30% to low risk, 30% to high risk
|
||||||
if low_risk:
|
base_allocations = [0.4] + [0.3] + [0.3] + [0.0] * (num_etfs - 3)
|
||||||
# Allocate 30% to low-risk ETFs
|
|
||||||
low_risk_alloc = 30.0 / len(low_risk)
|
|
||||||
for etf in low_risk:
|
|
||||||
allocations.append({"ticker": etf["Ticker"], "allocation": low_risk_alloc})
|
|
||||||
|
|
||||||
if medium_risk:
|
|
||||||
# Allocate 40% to medium-risk ETFs
|
|
||||||
medium_risk_alloc = 40.0 / len(medium_risk)
|
|
||||||
for etf in medium_risk:
|
|
||||||
allocations.append({"ticker": etf["Ticker"], "allocation": medium_risk_alloc})
|
|
||||||
|
|
||||||
if high_risk:
|
|
||||||
# Allocate 30% to high-risk ETFs
|
|
||||||
high_risk_alloc = 30.0 / len(high_risk)
|
|
||||||
for etf in high_risk:
|
|
||||||
allocations.append({"ticker": etf["Ticker"], "allocation": high_risk_alloc})
|
|
||||||
|
|
||||||
else: # Aggressive
|
else: # Aggressive
|
||||||
# Aggressive allocation
|
# 40% to high risk, 40% to medium risk, 20% to low risk
|
||||||
if low_risk:
|
base_allocations = [0.4] + [0.4] + [0.2] + [0.0] * (num_etfs - 3)
|
||||||
# Allocate 20% to low-risk ETFs
|
|
||||||
low_risk_alloc = 20.0 / len(low_risk)
|
|
||||||
for etf in low_risk:
|
|
||||||
allocations.append({"ticker": etf["Ticker"], "allocation": low_risk_alloc})
|
|
||||||
|
|
||||||
if medium_risk:
|
# Adjust allocations based on number of ETFs
|
||||||
# Allocate 40% to medium-risk ETFs
|
if num_etfs < len(base_allocations):
|
||||||
medium_risk_alloc = 40.0 / len(medium_risk)
|
base_allocations = base_allocations[:num_etfs]
|
||||||
for etf in medium_risk:
|
# Normalize to ensure sum is 1
|
||||||
allocations.append({"ticker": etf["Ticker"], "allocation": medium_risk_alloc})
|
total = sum(base_allocations)
|
||||||
|
base_allocations = [alloc/total for alloc in base_allocations]
|
||||||
|
|
||||||
if high_risk:
|
# Create final allocation list
|
||||||
# Allocate 40% to high-risk ETFs
|
final_allocations = []
|
||||||
high_risk_alloc = 40.0 / len(high_risk)
|
for etf, allocation in zip(sorted_etfs, base_allocations):
|
||||||
for etf in high_risk:
|
final_allocations.append({
|
||||||
allocations.append({"ticker": etf["Ticker"], "allocation": high_risk_alloc})
|
"ticker": etf["Ticker"],
|
||||||
|
"allocation": allocation * 100 # Convert to percentage
|
||||||
|
})
|
||||||
|
|
||||||
# If no allocations were made, use equal weighting
|
logger.info(f"Final allocations: {final_allocations}")
|
||||||
if not allocations:
|
return final_allocations
|
||||||
logger.warning("No risk-based allocations made, using equal weighting")
|
|
||||||
total_etfs = len(etf_metrics)
|
|
||||||
equal_alloc = 100.0 / total_etfs
|
|
||||||
allocations = [{"ticker": etf["Ticker"], "allocation": equal_alloc} for etf in etf_metrics]
|
|
||||||
|
|
||||||
logger.info(f"Final allocations: {allocations}")
|
|
||||||
return allocations
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error optimizing portfolio allocation: {str(e)}")
|
logger.error(f"Error in optimize_portfolio_allocation: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@ -1344,37 +1425,263 @@ st.set_page_config(
|
|||||||
# Initialize session state variables
|
# Initialize session state variables
|
||||||
if 'simulation_run' not in st.session_state:
|
if 'simulation_run' not in st.session_state:
|
||||||
st.session_state.simulation_run = False
|
st.session_state.simulation_run = False
|
||||||
|
logger.info("Initialized simulation_run in session state")
|
||||||
if 'df_data' not in st.session_state:
|
if 'df_data' not in st.session_state:
|
||||||
st.session_state.df_data = None
|
st.session_state.df_data = None
|
||||||
|
logger.info("Initialized df_data in session state")
|
||||||
if 'final_alloc' not in st.session_state:
|
if 'final_alloc' not in st.session_state:
|
||||||
st.session_state.final_alloc = None
|
st.session_state.final_alloc = None
|
||||||
|
logger.info("Initialized final_alloc in session state")
|
||||||
if 'mode' not in st.session_state:
|
if 'mode' not in st.session_state:
|
||||||
st.session_state.mode = 'Capital Target'
|
st.session_state.mode = 'Capital Target'
|
||||||
|
logger.info("Initialized mode in session state")
|
||||||
if 'target' not in st.session_state:
|
if 'target' not in st.session_state:
|
||||||
st.session_state.target = 0
|
st.session_state.target = 0
|
||||||
|
logger.info("Initialized target in session state")
|
||||||
if 'initial_capital' not in st.session_state:
|
if 'initial_capital' not in st.session_state:
|
||||||
st.session_state.initial_capital = 0
|
st.session_state.initial_capital = 0
|
||||||
|
logger.info("Initialized initial_capital in session state")
|
||||||
if 'enable_drip' not in st.session_state:
|
if 'enable_drip' not in st.session_state:
|
||||||
st.session_state.enable_drip = False
|
st.session_state.enable_drip = False
|
||||||
|
logger.info("Initialized enable_drip in session state")
|
||||||
if 'enable_erosion' not in st.session_state:
|
if 'enable_erosion' not in st.session_state:
|
||||||
st.session_state.enable_erosion = False
|
st.session_state.enable_erosion = False
|
||||||
|
logger.info("Initialized enable_erosion in session state")
|
||||||
if 'api_calls' not in st.session_state:
|
if 'api_calls' not in st.session_state:
|
||||||
st.session_state.api_calls = 0
|
st.session_state.api_calls = 0
|
||||||
|
logger.info("Initialized api_calls in session state")
|
||||||
if 'force_refresh_data' not in st.session_state:
|
if 'force_refresh_data' not in st.session_state:
|
||||||
st.session_state.force_refresh_data = False
|
st.session_state.force_refresh_data = False
|
||||||
|
logger.info("Initialized force_refresh_data in session state")
|
||||||
|
if 'etf_allocations' not in st.session_state:
|
||||||
|
st.session_state.etf_allocations = []
|
||||||
|
logger.info("Initialized empty etf_allocations in session state")
|
||||||
|
if 'risk_tolerance' not in st.session_state:
|
||||||
|
st.session_state.risk_tolerance = "Moderate"
|
||||||
|
logger.info("Initialized risk_tolerance in session state")
|
||||||
|
|
||||||
# Main title
|
# Main title
|
||||||
st.title("📈 ETF Portfolio Builder")
|
st.title("📈 ETF Portfolio Builder")
|
||||||
|
|
||||||
# Sidebar for simulation parameters
|
# Function to remove ticker
|
||||||
with st.sidebar:
|
def remove_ticker(ticker_to_remove: str) -> None:
|
||||||
st.header("Simulation Parameters")
|
"""Remove a ticker from the portfolio."""
|
||||||
|
try:
|
||||||
|
logger.info(f"Removing ticker: {ticker_to_remove}")
|
||||||
|
current_allocations = list(st.session_state.etf_allocations)
|
||||||
|
st.session_state.etf_allocations = [etf for etf in current_allocations if etf["ticker"] != ticker_to_remove]
|
||||||
|
logger.info(f"Updated allocations after removal: {st.session_state.etf_allocations}")
|
||||||
|
st.rerun()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing ticker: {str(e)}")
|
||||||
|
st.error(f"Error removing ticker: {str(e)}")
|
||||||
|
|
||||||
# Add refresh data button at the top
|
# Display current tickers in the main space
|
||||||
if st.button("🔄 Refresh Data", use_container_width=True):
|
if st.session_state.etf_allocations:
|
||||||
st.info("Refreshing ETF data...")
|
st.subheader("Selected ETFs")
|
||||||
# Add your data refresh logic here
|
st.markdown("""
|
||||||
st.success("Data refreshed successfully!")
|
<style>
|
||||||
|
.ticker-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.ticker-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #2ecc71;
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ticker-close {
|
||||||
|
margin-left: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.ticker-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
""", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# Create a container for tickers
|
||||||
|
ticker_container = st.container()
|
||||||
|
with ticker_container:
|
||||||
|
# Display each ticker with a close button
|
||||||
|
for etf in st.session_state.etf_allocations:
|
||||||
|
col1, col2 = st.columns([0.05, 0.95]) # Adjusted column ratio
|
||||||
|
with col1:
|
||||||
|
if st.button("×", key=f"remove_{etf['ticker']}",
|
||||||
|
help=f"Remove {etf['ticker']} from portfolio"):
|
||||||
|
remove_ticker(etf['ticker'])
|
||||||
|
with col2:
|
||||||
|
st.markdown(f"<div class='ticker-item'>{etf['ticker']}</div>", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# Debug information
|
||||||
|
logger.info("=== Session State Debug ===")
|
||||||
|
logger.info(f"Full session state: {dict(st.session_state)}")
|
||||||
|
logger.info(f"ETF allocations type: {type(st.session_state.etf_allocations)}")
|
||||||
|
logger.info(f"ETF allocations content: {st.session_state.etf_allocations}")
|
||||||
|
logger.info("=== End Session State Debug ===")
|
||||||
|
|
||||||
|
def add_etf_to_portfolio(ticker: str) -> bool:
|
||||||
|
"""Add an ETF to the portfolio with proper validation and error handling."""
|
||||||
|
try:
|
||||||
|
logger.info("=== Adding ETF to Portfolio ===")
|
||||||
|
logger.info(f"Input ticker: {ticker}")
|
||||||
|
logger.info(f"Current allocations before adding: {st.session_state.etf_allocations}")
|
||||||
|
logger.info(f"Current allocations type: {type(st.session_state.etf_allocations)}")
|
||||||
|
|
||||||
|
# Validate ticker format
|
||||||
|
if not re.match(r'^[A-Z]{1,7}$', ticker.upper()):
|
||||||
|
logger.warning(f"Invalid ticker format: {ticker}")
|
||||||
|
st.error("Invalid ticker format. Must be 1-7 uppercase letters.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if ticker already exists
|
||||||
|
if any(etf["ticker"] == ticker.upper() for etf in st.session_state.etf_allocations):
|
||||||
|
logger.warning(f"Ticker {ticker.upper()} already exists in portfolio")
|
||||||
|
st.warning(f"{ticker.upper()} is already in your portfolio.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Verify ticker exists by fetching data
|
||||||
|
logger.info(f"Fetching data for ticker: {ticker.upper()}")
|
||||||
|
etf_data = fetch_etf_data([ticker.upper()])
|
||||||
|
logger.info(f"Fetched ETF data: {etf_data}")
|
||||||
|
|
||||||
|
if etf_data is None or etf_data.empty:
|
||||||
|
logger.warning(f"Unknown ticker: {ticker.upper()}")
|
||||||
|
st.error(f"Unknown ticker: {ticker.upper()}. Please enter a valid ETF ticker.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create new ETF entry
|
||||||
|
new_etf = {
|
||||||
|
"ticker": ticker.upper(),
|
||||||
|
"allocation": 0.0
|
||||||
|
}
|
||||||
|
logger.info(f"Created new ETF entry: {new_etf}")
|
||||||
|
|
||||||
|
# Update session state
|
||||||
|
current_allocations = list(st.session_state.etf_allocations)
|
||||||
|
current_allocations.append(new_etf)
|
||||||
|
st.session_state.etf_allocations = current_allocations
|
||||||
|
|
||||||
|
logger.info(f"Updated session state allocations: {st.session_state.etf_allocations}")
|
||||||
|
logger.info(f"Updated allocations type: {type(st.session_state.etf_allocations)}")
|
||||||
|
|
||||||
|
# Recalculate allocations based on risk tolerance
|
||||||
|
if len(st.session_state.etf_allocations) > 0:
|
||||||
|
risk_tolerance = st.session_state.risk_tolerance
|
||||||
|
tickers = [etf["ticker"] for etf in st.session_state.etf_allocations]
|
||||||
|
logger.info(f"Recalculating allocations for tickers: {tickers}")
|
||||||
|
|
||||||
|
df_data = fetch_etf_data(tickers)
|
||||||
|
logger.info(f"Fetched data for recalculation: {df_data}")
|
||||||
|
|
||||||
|
if df_data is not None and not df_data.empty:
|
||||||
|
etf_metrics = df_data.to_dict('records')
|
||||||
|
new_allocations = optimize_portfolio_allocation(
|
||||||
|
etf_metrics,
|
||||||
|
risk_tolerance,
|
||||||
|
pd.DataFrame()
|
||||||
|
)
|
||||||
|
logger.info(f"Calculated new allocations: {new_allocations}")
|
||||||
|
st.session_state.etf_allocations = new_allocations
|
||||||
|
logger.info(f"Updated session state with new allocations: {st.session_state.etf_allocations}")
|
||||||
|
|
||||||
|
logger.info("=== End Adding ETF to Portfolio ===")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("=== Error Adding ETF to Portfolio ===")
|
||||||
|
logger.error(f"Error: {str(e)}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
st.error(f"Error adding ETF: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_etf_from_portfolio(index: int) -> bool:
|
||||||
|
"""Remove an ETF from the portfolio with proper validation and error handling."""
|
||||||
|
try:
|
||||||
|
logger.info(f"Attempting to remove ETF at index: {index}")
|
||||||
|
logger.info(f"Current allocations before removal: {st.session_state.etf_allocations}")
|
||||||
|
|
||||||
|
if not st.session_state.etf_allocations or index >= len(st.session_state.etf_allocations):
|
||||||
|
logger.warning(f"Invalid ETF index for removal: {index}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create new list without the removed ETF
|
||||||
|
current_allocations = list(st.session_state.etf_allocations)
|
||||||
|
removed_etf = current_allocations.pop(index)
|
||||||
|
st.session_state.etf_allocations = current_allocations
|
||||||
|
|
||||||
|
logger.info(f"Successfully removed ETF: {removed_etf}")
|
||||||
|
logger.info(f"Updated allocations: {st.session_state.etf_allocations}")
|
||||||
|
|
||||||
|
# Recalculate allocations if there are remaining ETFs
|
||||||
|
if st.session_state.etf_allocations:
|
||||||
|
risk_tolerance = st.session_state.risk_tolerance
|
||||||
|
tickers = [etf["ticker"] for etf in st.session_state.etf_allocations]
|
||||||
|
df_data = fetch_etf_data(tickers)
|
||||||
|
|
||||||
|
if df_data is not None and not df_data.empty:
|
||||||
|
etf_metrics = df_data.to_dict('records')
|
||||||
|
new_allocations = optimize_portfolio_allocation(
|
||||||
|
etf_metrics,
|
||||||
|
risk_tolerance,
|
||||||
|
pd.DataFrame()
|
||||||
|
)
|
||||||
|
st.session_state.etf_allocations = new_allocations
|
||||||
|
logger.info(f"Recalculated allocations: {new_allocations}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing ETF: {str(e)}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
st.error(f"Error removing ETF: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Sidebar for ETF input
|
||||||
|
with st.sidebar:
|
||||||
|
st.header("ETF Allocation")
|
||||||
|
|
||||||
|
# Create a container for ETF input
|
||||||
|
with st.container():
|
||||||
|
# Input field for ETF ticker only
|
||||||
|
new_ticker = st.text_input("ETF Ticker", help="Enter a valid ETF ticker (e.g., SCHD)")
|
||||||
|
|
||||||
|
# Add button to add ETF
|
||||||
|
add_etf_button = st.button("ADD ETF", use_container_width=True)
|
||||||
|
if add_etf_button:
|
||||||
|
logger.info("=== Add ETF Button Clicked ===")
|
||||||
|
logger.info(f"Input ticker: {new_ticker}")
|
||||||
|
logger.info(f"Current allocations: {st.session_state.etf_allocations}")
|
||||||
|
|
||||||
|
if not new_ticker:
|
||||||
|
st.error("Please enter an ETF ticker.")
|
||||||
|
logger.warning("No ticker provided")
|
||||||
|
elif len(st.session_state.etf_allocations) >= 10:
|
||||||
|
st.error("Maximum of 10 ETFs allowed in portfolio.")
|
||||||
|
logger.warning("Maximum ETF limit reached")
|
||||||
|
else:
|
||||||
|
if add_etf_to_portfolio(new_ticker):
|
||||||
|
st.success(f"Added {new_ticker.upper()} to portfolio.")
|
||||||
|
logger.info("Successfully added ETF, triggering rerun")
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
# Display total allocation
|
||||||
|
if st.session_state.etf_allocations:
|
||||||
|
current_total = sum(etf["allocation"] for etf in st.session_state.etf_allocations)
|
||||||
|
st.metric("Total Allocation (%)", f"{current_total:.2f}")
|
||||||
|
|
||||||
|
# Add a warning if total is not 100%
|
||||||
|
if abs(current_total - 100) > 0.1:
|
||||||
|
st.warning("Total allocation should be 100%")
|
||||||
|
else:
|
||||||
|
st.info("No ETFs added yet. Please add ETFs to your portfolio.")
|
||||||
|
logger.info("No ETFs in portfolio")
|
||||||
|
|
||||||
# Mode selection
|
# Mode selection
|
||||||
simulation_mode = st.radio(
|
simulation_mode = st.radio(
|
||||||
@ -1406,6 +1713,7 @@ with st.sidebar:
|
|||||||
options=["Conservative", "Moderate", "Aggressive"],
|
options=["Conservative", "Moderate", "Aggressive"],
|
||||||
value="Moderate"
|
value="Moderate"
|
||||||
)
|
)
|
||||||
|
st.session_state.risk_tolerance = risk_tolerance
|
||||||
|
|
||||||
# Additional options
|
# Additional options
|
||||||
st.subheader("Additional Options")
|
st.subheader("Additional Options")
|
||||||
@ -1424,88 +1732,55 @@ with st.sidebar:
|
|||||||
index=1
|
index=1
|
||||||
)
|
)
|
||||||
|
|
||||||
# ETF Selection
|
# Run simulation button
|
||||||
st.subheader("ETF Selection")
|
if st.button("Run Portfolio Simulation", type="primary", use_container_width=True):
|
||||||
|
if not st.session_state.etf_allocations:
|
||||||
# Create a form for ETF selection
|
st.error("Please add at least one ETF to your portfolio.")
|
||||||
with st.form("etf_selection_form"):
|
else:
|
||||||
# Number of ETFs
|
|
||||||
num_etfs = st.number_input("Number of ETFs", min_value=1, max_value=10, value=3, step=1)
|
|
||||||
|
|
||||||
# Create columns for ETF inputs
|
|
||||||
etf_inputs = []
|
|
||||||
for i in range(num_etfs):
|
|
||||||
ticker = st.text_input(f"ETF {i+1} Ticker", key=f"ticker_{i}")
|
|
||||||
if ticker: # Only add non-empty tickers
|
|
||||||
etf_inputs.append({"ticker": ticker.upper().strip()})
|
|
||||||
|
|
||||||
# Submit button
|
|
||||||
submitted = st.form_submit_button("Run Portfolio Simulation", type="primary")
|
|
||||||
|
|
||||||
if submitted:
|
|
||||||
try:
|
try:
|
||||||
if not etf_inputs:
|
# Store parameters in session state
|
||||||
st.error("Please enter at least one ETF ticker")
|
st.session_state.mode = simulation_mode
|
||||||
|
st.session_state.enable_drip = enable_drip == "Yes"
|
||||||
|
st.session_state.enable_erosion = enable_erosion == "Yes"
|
||||||
|
|
||||||
|
if simulation_mode == "Income Target":
|
||||||
|
st.session_state.target = monthly_target
|
||||||
else:
|
else:
|
||||||
logger.info(f"Form submitted with {len(etf_inputs)} ETFs: {etf_inputs}")
|
st.session_state.target = initial_capital
|
||||||
|
st.session_state.initial_capital = initial_capital
|
||||||
|
|
||||||
# Store parameters in session state
|
# Run simulation
|
||||||
st.session_state.mode = simulation_mode
|
logger.info("Starting portfolio simulation...")
|
||||||
st.session_state.enable_drip = enable_drip == "Yes"
|
logger.info(f"ETF allocations: {st.session_state.etf_allocations}")
|
||||||
st.session_state.enable_erosion = enable_erosion == "Yes"
|
|
||||||
|
|
||||||
|
tickers = [etf["ticker"] for etf in st.session_state.etf_allocations]
|
||||||
|
df_data = fetch_etf_data(tickers)
|
||||||
|
logger.info(f"Fetched ETF data:\n{df_data}")
|
||||||
|
|
||||||
|
if df_data is not None and not df_data.empty:
|
||||||
if simulation_mode == "Income Target":
|
if simulation_mode == "Income Target":
|
||||||
st.session_state.target = monthly_target
|
logger.info(f"Allocating for income target: ${monthly_target}")
|
||||||
|
final_alloc = allocate_for_income(df_data, monthly_target, st.session_state.etf_allocations)
|
||||||
else:
|
else:
|
||||||
st.session_state.target = initial_capital
|
logger.info(f"Allocating for capital target: ${initial_capital}")
|
||||||
st.session_state.initial_capital = initial_capital
|
final_alloc = allocate_for_capital(df_data, initial_capital, st.session_state.etf_allocations)
|
||||||
|
|
||||||
# Run simulation
|
logger.info(f"Final allocation result:\n{final_alloc}")
|
||||||
logger.info("Starting portfolio simulation...")
|
|
||||||
logger.info(f"ETF inputs: {etf_inputs}")
|
|
||||||
|
|
||||||
df_data = fetch_etf_data([etf["ticker"] for etf in etf_inputs])
|
if final_alloc is not None and not final_alloc.empty:
|
||||||
logger.info(f"Fetched ETF data:\n{df_data}")
|
st.session_state.simulation_run = True
|
||||||
|
st.session_state.df_data = df_data
|
||||||
if df_data is not None and not df_data.empty:
|
st.session_state.final_alloc = final_alloc
|
||||||
logger.info("Calculating optimal allocations...")
|
st.success("Portfolio simulation completed!")
|
||||||
# Calculate allocations based on risk tolerance
|
st.rerun()
|
||||||
etf_allocations = optimize_portfolio_allocation(
|
|
||||||
df_data.to_dict('records'),
|
|
||||||
risk_tolerance,
|
|
||||||
pd.DataFrame() # Empty correlation matrix for now
|
|
||||||
)
|
|
||||||
logger.info(f"Optimal allocations: {etf_allocations}")
|
|
||||||
|
|
||||||
if simulation_mode == "Income Target":
|
|
||||||
logger.info(f"Allocating for income target: ${monthly_target}")
|
|
||||||
final_alloc = allocate_for_income(df_data, monthly_target, etf_allocations)
|
|
||||||
else:
|
|
||||||
logger.info(f"Allocating for capital target: ${initial_capital}")
|
|
||||||
final_alloc = allocate_for_capital(df_data, initial_capital, etf_allocations)
|
|
||||||
|
|
||||||
logger.info(f"Final allocation result:\n{final_alloc}")
|
|
||||||
|
|
||||||
if final_alloc is not None and not final_alloc.empty:
|
|
||||||
# Store results in session state
|
|
||||||
st.session_state.simulation_run = True
|
|
||||||
st.session_state.df_data = df_data
|
|
||||||
st.session_state.final_alloc = final_alloc
|
|
||||||
st.success("Portfolio simulation completed!")
|
|
||||||
st.rerun()
|
|
||||||
else:
|
|
||||||
st.error("Failed to generate portfolio allocation. Please check your inputs and try again.")
|
|
||||||
logger.error("Allocation returned empty DataFrame")
|
|
||||||
logger.error(f"df_data columns: {df_data.columns}")
|
|
||||||
logger.error(f"df_data shape: {df_data.shape}")
|
|
||||||
logger.error(f"df_data:\n{df_data}")
|
|
||||||
else:
|
else:
|
||||||
st.error("Failed to fetch ETF data. Please check your tickers and try again.")
|
st.error("Failed to generate portfolio allocation. Please check your inputs and try again.")
|
||||||
logger.error("ETF data fetch returned empty DataFrame")
|
else:
|
||||||
|
st.error("Failed to fetch ETF data. Please check your tickers and try again.")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
st.error(f"Error running simulation: {str(e)}")
|
st.error(f"Error running simulation: {str(e)}")
|
||||||
logger.error(f"Error in form submission: {str(e)}")
|
logger.error(f"Error in simulation: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
# Add reset simulation button at the bottom of sidebar
|
# Add reset simulation button at the bottom of sidebar
|
||||||
@ -1671,7 +1946,8 @@ if st.session_state.simulation_run and st.session_state.df_data is not None:
|
|||||||
min_value=0.0,
|
min_value=0.0,
|
||||||
max_value=100.0,
|
max_value=100.0,
|
||||||
step=0.1,
|
step=0.1,
|
||||||
format="%.1f"
|
format="%.1f",
|
||||||
|
required=True
|
||||||
),
|
),
|
||||||
"Yield (%)": st.column_config.TextColumn("Yield (%)", disabled=True),
|
"Yield (%)": st.column_config.TextColumn("Yield (%)", disabled=True),
|
||||||
"Price Per Share": st.column_config.TextColumn("Price Per Share", disabled=True),
|
"Price Per Share": st.column_config.TextColumn("Price Per Share", disabled=True),
|
||||||
@ -1684,6 +1960,16 @@ if st.session_state.simulation_run and st.session_state.df_data is not None:
|
|||||||
# Calculate total allocation
|
# Calculate total allocation
|
||||||
total_alloc = edited_df["Allocation (%)"].sum()
|
total_alloc = edited_df["Allocation (%)"].sum()
|
||||||
|
|
||||||
|
# Validate individual allocations
|
||||||
|
invalid_allocations = edited_df[
|
||||||
|
(edited_df["Allocation (%)"] <= 0) |
|
||||||
|
(edited_df["Allocation (%)"] > 100)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not invalid_allocations.empty:
|
||||||
|
for _, row in invalid_allocations.iterrows():
|
||||||
|
st.error(f"Invalid allocation for {row['Ticker']}: must be between 0% and 100%")
|
||||||
|
|
||||||
# Display total allocation with color coding
|
# Display total allocation with color coding
|
||||||
if abs(total_alloc - 100) <= 0.1:
|
if abs(total_alloc - 100) <= 0.1:
|
||||||
st.metric("Total Allocation (%)", f"{total_alloc:.2f}", delta=None)
|
st.metric("Total Allocation (%)", f"{total_alloc:.2f}", delta=None)
|
||||||
@ -1691,9 +1977,7 @@ if st.session_state.simulation_run and st.session_state.df_data is not None:
|
|||||||
st.metric("Total Allocation (%)", f"{total_alloc:.2f}",
|
st.metric("Total Allocation (%)", f"{total_alloc:.2f}",
|
||||||
delta=f"{total_alloc - 100:.2f}",
|
delta=f"{total_alloc - 100:.2f}",
|
||||||
delta_color="off")
|
delta_color="off")
|
||||||
|
st.error("Total allocation must be exactly 100%")
|
||||||
if abs(total_alloc - 100) > 0.1:
|
|
||||||
st.warning("Total allocation should be 100%")
|
|
||||||
|
|
||||||
# Create columns for quick actions
|
# Create columns for quick actions
|
||||||
col1, col2, col3 = st.columns(3)
|
col1, col2, col3 = st.columns(3)
|
||||||
@ -1709,7 +1993,7 @@ if st.session_state.simulation_run and st.session_state.df_data is not None:
|
|||||||
|
|
||||||
# Submit button for manual edits
|
# Submit button for manual edits
|
||||||
submitted = st.form_submit_button("Update Allocations",
|
submitted = st.form_submit_button("Update Allocations",
|
||||||
disabled=abs(total_alloc - 100) > 0.1,
|
disabled=abs(total_alloc - 100) > 0.1 or not invalid_allocations.empty,
|
||||||
type="primary",
|
type="primary",
|
||||||
use_container_width=True)
|
use_container_width=True)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user