From 3929f2d4f021c73697e7d57fb00b0afa9c6ff5fa Mon Sep 17 00:00:00 2001 From: Pascal Date: Tue, 27 May 2025 20:31:05 +0200 Subject: [PATCH] refactor: remove duplicate ETF display from sidebar for cleaner UI --- pages/ETF_Portfolio_Builder.py | 616 ++++++++++++++++++++++++--------- 1 file changed, 450 insertions(+), 166 deletions(-) diff --git a/pages/ETF_Portfolio_Builder.py b/pages/ETF_Portfolio_Builder.py index e7f0f9a..c51f614 100644 --- a/pages/ETF_Portfolio_Builder.py +++ b/pages/ETF_Portfolio_Builder.py @@ -19,6 +19,7 @@ import sys import logging import traceback from dotenv import load_dotenv +import re # Load 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()) return pd.DataFrame() -def optimize_portfolio_allocation( - etf_metrics: List[Dict[str, Any]], - risk_tolerance: str, - correlation_matrix: pd.DataFrame -) -> List[Dict[str, Any]]: +def calculate_etf_risk_score(etf: Dict[str, Any]) -> float: + """ + Calculate a comprehensive risk score for an ETF based on multiple metrics. + + 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. @@ -360,93 +473,61 @@ def optimize_portfolio_allocation( logger.info(f"Optimizing portfolio allocation for {risk_tolerance} risk tolerance") logger.info(f"ETF metrics: {etf_metrics}") - # Group ETFs by risk category - low_risk = [etf for etf in etf_metrics if etf.get("Risk Level", "Unknown") == "Low"] - medium_risk = [etf for etf in etf_metrics if etf.get("Risk Level", "Unknown") == "Medium"] - high_risk = [etf for etf in etf_metrics if etf.get("Risk Level", "Unknown") == "High"] + # Calculate risk scores for each ETF + for etf in etf_metrics: + etf['risk_score'] = calculate_etf_risk_score(etf) + 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 - 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) + logger.info(f"Sorted ETFs: {[etf['Ticker'] for etf in sorted_etfs]}") - # Initialize allocations - allocations = [] + # Calculate base allocations based on risk tolerance + num_etfs = len(sorted_etfs) + if num_etfs == 0: + return [] if risk_tolerance == "Conservative": - # Conservative allocation - if low_risk: - # 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}) - + # 50% to low risk, 30% to medium risk, 20% to high risk + base_allocations = [0.5] + [0.3] + [0.2] + [0.0] * (num_etfs - 3) elif risk_tolerance == "Moderate": - # Moderate allocation - if low_risk: - # 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}) - + # 40% to medium risk, 30% to low risk, 30% to high risk + base_allocations = [0.4] + [0.3] + [0.3] + [0.0] * (num_etfs - 3) else: # Aggressive - # Aggressive allocation - if low_risk: - # 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: - # 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 40% to high-risk ETFs - high_risk_alloc = 40.0 / len(high_risk) - for etf in high_risk: - allocations.append({"ticker": etf["Ticker"], "allocation": high_risk_alloc}) + # 40% to high risk, 40% to medium risk, 20% to low risk + base_allocations = [0.4] + [0.4] + [0.2] + [0.0] * (num_etfs - 3) - # If no allocations were made, use equal weighting - if not 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] + # Adjust allocations based on number of ETFs + if num_etfs < len(base_allocations): + base_allocations = base_allocations[:num_etfs] + # Normalize to ensure sum is 1 + total = sum(base_allocations) + base_allocations = [alloc/total for alloc in base_allocations] - logger.info(f"Final allocations: {allocations}") - return allocations + # Create final allocation list + final_allocations = [] + for etf, allocation in zip(sorted_etfs, base_allocations): + final_allocations.append({ + "ticker": etf["Ticker"], + "allocation": allocation * 100 # Convert to percentage + }) + + logger.info(f"Final allocations: {final_allocations}") + return final_allocations 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()) return [] @@ -1344,37 +1425,263 @@ st.set_page_config( # Initialize session state variables if 'simulation_run' not in st.session_state: st.session_state.simulation_run = False + logger.info("Initialized simulation_run in session state") if 'df_data' not in st.session_state: st.session_state.df_data = None + logger.info("Initialized df_data in session state") if 'final_alloc' not in st.session_state: st.session_state.final_alloc = None + logger.info("Initialized final_alloc in session state") if 'mode' not in st.session_state: st.session_state.mode = 'Capital Target' + logger.info("Initialized mode in session state") if 'target' not in st.session_state: st.session_state.target = 0 + logger.info("Initialized target in session state") if 'initial_capital' not in st.session_state: st.session_state.initial_capital = 0 + logger.info("Initialized initial_capital in session state") if 'enable_drip' not in st.session_state: st.session_state.enable_drip = False + logger.info("Initialized enable_drip in session state") if 'enable_erosion' not in st.session_state: st.session_state.enable_erosion = False + logger.info("Initialized enable_erosion in session state") if 'api_calls' not in st.session_state: st.session_state.api_calls = 0 + logger.info("Initialized api_calls in session state") if 'force_refresh_data' not in st.session_state: 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 st.title("📈 ETF Portfolio Builder") -# Sidebar for simulation parameters -with st.sidebar: - st.header("Simulation Parameters") +# Function to remove ticker +def remove_ticker(ticker_to_remove: str) -> None: + """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)}") + +# Display current tickers in the main space +if st.session_state.etf_allocations: + st.subheader("Selected ETFs") + st.markdown(""" + + """, unsafe_allow_html=True) - # Add refresh data button at the top - if st.button("🔄 Refresh Data", use_container_width=True): - st.info("Refreshing ETF data...") - # Add your data refresh logic here - st.success("Data refreshed successfully!") + # 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"
{etf['ticker']}
", 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 simulation_mode = st.radio( @@ -1406,6 +1713,7 @@ with st.sidebar: options=["Conservative", "Moderate", "Aggressive"], value="Moderate" ) + st.session_state.risk_tolerance = risk_tolerance # Additional options st.subheader("Additional Options") @@ -1424,88 +1732,55 @@ with st.sidebar: index=1 ) - # ETF Selection - st.subheader("ETF Selection") - - # Create a form for ETF selection - 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: + # Run simulation button + if st.button("Run Portfolio Simulation", type="primary", use_container_width=True): + if not st.session_state.etf_allocations: + st.error("Please add at least one ETF to your portfolio.") + else: try: - if not etf_inputs: - st.error("Please enter at least one ETF ticker") + # Store parameters in session state + 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: - logger.info(f"Form submitted with {len(etf_inputs)} ETFs: {etf_inputs}") - - # Store parameters in session state - st.session_state.mode = simulation_mode - st.session_state.enable_drip = enable_drip == "Yes" - st.session_state.enable_erosion = enable_erosion == "Yes" - + st.session_state.target = initial_capital + st.session_state.initial_capital = initial_capital + + # Run simulation + logger.info("Starting portfolio simulation...") + logger.info(f"ETF allocations: {st.session_state.etf_allocations}") + + 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": - 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: - st.session_state.target = initial_capital - st.session_state.initial_capital = initial_capital + logger.info(f"Allocating for capital target: ${initial_capital}") + final_alloc = allocate_for_capital(df_data, initial_capital, st.session_state.etf_allocations) - # Run simulation - logger.info("Starting portfolio simulation...") - logger.info(f"ETF inputs: {etf_inputs}") + logger.info(f"Final allocation result:\n{final_alloc}") - df_data = fetch_etf_data([etf["ticker"] for etf in etf_inputs]) - logger.info(f"Fetched ETF data:\n{df_data}") - - 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": - 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}") + if final_alloc is not None and not final_alloc.empty: + 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 fetch ETF data. Please check your tickers and try again.") - logger.error("ETF data fetch returned empty DataFrame") + st.error("Failed to generate portfolio allocation. Please check your inputs and try again.") + else: + st.error("Failed to fetch ETF data. Please check your tickers and try again.") except Exception as 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()) # 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, max_value=100.0, step=0.1, - format="%.1f" + format="%.1f", + required=True ), "Yield (%)": st.column_config.TextColumn("Yield (%)", 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 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 if abs(total_alloc - 100) <= 0.1: 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}", delta=f"{total_alloc - 100:.2f}", delta_color="off") - - if abs(total_alloc - 100) > 0.1: - st.warning("Total allocation should be 100%") + st.error("Total allocation must be exactly 100%") # Create columns for quick actions 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 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", use_container_width=True)