refactor: remove duplicate ETF display from sidebar for cleaner UI

This commit is contained in:
Pascal BIBEHE 2025-05-27 20:31:05 +02:00
parent e0dc6e57eb
commit 3929f2d4f0

View File

@ -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,31 +1732,12 @@ 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"):
# 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:
if not etf_inputs:
st.error("Please enter at least one ETF ticker")
else: else:
logger.info(f"Form submitted with {len(etf_inputs)} ETFs: {etf_inputs}") try:
# Store parameters in session state # Store parameters in session state
st.session_state.mode = simulation_mode st.session_state.mode = simulation_mode
st.session_state.enable_drip = enable_drip == "Yes" st.session_state.enable_drip = enable_drip == "Yes"
@ -1462,32 +1751,23 @@ with st.sidebar:
# Run simulation # Run simulation
logger.info("Starting portfolio simulation...") logger.info("Starting portfolio simulation...")
logger.info(f"ETF inputs: {etf_inputs}") logger.info(f"ETF allocations: {st.session_state.etf_allocations}")
df_data = fetch_etf_data([etf["ticker"] for etf in etf_inputs]) 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}") logger.info(f"Fetched ETF data:\n{df_data}")
if df_data is not None and not df_data.empty: if df_data is not None and not df_data.empty:
logger.info("Calculating optimal allocations...")
# Calculate allocations based on risk tolerance
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": if simulation_mode == "Income Target":
logger.info(f"Allocating for income target: ${monthly_target}") logger.info(f"Allocating for income target: ${monthly_target}")
final_alloc = allocate_for_income(df_data, monthly_target, etf_allocations) final_alloc = allocate_for_income(df_data, monthly_target, st.session_state.etf_allocations)
else: else:
logger.info(f"Allocating for capital target: ${initial_capital}") logger.info(f"Allocating for capital target: ${initial_capital}")
final_alloc = allocate_for_capital(df_data, initial_capital, etf_allocations) final_alloc = allocate_for_capital(df_data, initial_capital, st.session_state.etf_allocations)
logger.info(f"Final allocation result:\n{final_alloc}") logger.info(f"Final allocation result:\n{final_alloc}")
if final_alloc is not None and not final_alloc.empty: 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.simulation_run = True
st.session_state.df_data = df_data st.session_state.df_data = df_data
st.session_state.final_alloc = final_alloc st.session_state.final_alloc = final_alloc
@ -1495,17 +1775,12 @@ with st.sidebar:
st.rerun() st.rerun()
else: else:
st.error("Failed to generate portfolio allocation. Please check your inputs and try again.") 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 fetch ETF data. Please check your tickers and try again.")
logger.error("ETF data fetch returned empty DataFrame")
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)