From f0ea40767ceb75e58737e0d8af7432eccda71a16 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 24 May 2025 22:06:52 +0000 Subject: [PATCH] Fix: Quick action handlers for portfolio allocations. Equal weight and income focus now properly sum to ~100% (allowing for floating-point precision). Capital focus simplified to equal weight allocation. --- pages/ETF_Portfolio_Builder.py | 575 +++++++++++++++++---------------- 1 file changed, 302 insertions(+), 273 deletions(-) diff --git a/pages/ETF_Portfolio_Builder.py b/pages/ETF_Portfolio_Builder.py index d1fd587..f452012 100644 --- a/pages/ETF_Portfolio_Builder.py +++ b/pages/ETF_Portfolio_Builder.py @@ -1591,124 +1591,151 @@ def portfolio_summary(df: pd.DataFrame): # --- Allocation Functions --- def allocate_for_income(df: pd.DataFrame, target: float, allocations: List[Dict]) -> pd.DataFrame: """Allocate capital to ETFs to meet the annual income target.""" - results = [] - weighted_yield = 0 - for _, row in df.iterrows(): - ticker = row["Ticker"] - alloc_pct = next((etf["allocation"] / 100 for etf in allocations if etf["ticker"] == ticker), 0) - if alloc_pct == 0: - continue - weighted_yield += alloc_pct * (row["Yield (%)"] / 100) - - if weighted_yield <= 0: - st.error("Weighted yield is zero or negative. Check ETF data.") - return pd.DataFrame() - - total_capital = target / weighted_yield - - for _, row in df.iterrows(): - ticker = row["Ticker"] - alloc_pct = next((etf["allocation"] / 100 for etf in allocations if etf["ticker"] == ticker), 0) - if alloc_pct == 0: - continue - capital = total_capital * alloc_pct - shares = capital / row["Price"] - income = shares * row["Dividend Rate"] + try: + # Store initial capital in session state + st.session_state.initial_capital = target + st.session_state.mode = "Income Target" + st.session_state.target = target - # Create result dictionary with all available data - result = { - "Ticker": ticker, - "Yield (%)": row["Yield (%)"], - "Dividend Rate": round(row["Dividend Rate"], 2), - "Capital Allocated ($)": round(capital, 2), - "Income Contributed ($)": round(income, 2), - "Allocation (%)": round(alloc_pct * 100, 2), - "Inception Date": row["Inception Date"], - "Distribution Period": row.get("Distribution Period", "Unknown"), - "Price": row["Price"] - } - - # Add NAV data if available - if "NAV" in row and row["NAV"] is not None: - result["NAV"] = row["NAV"] - if "Premium/Discount (%)" in row and row["Premium/Discount (%)"] is not None: - result["Premium/Discount (%)"] = row["Premium/Discount (%)"] + results = [] + weighted_yield = 0 + for _, row in df.iterrows(): + ticker = row["Ticker"] + alloc_pct = next((etf["allocation"] / 100 for etf in allocations if etf["ticker"] == ticker), 0) + if alloc_pct == 0: + continue + weighted_yield += alloc_pct * (row["Yield (%)"] / 100) + + if weighted_yield <= 0: + st.error("Weighted yield is zero or negative. Check ETF data.") + return None + + total_capital = target / weighted_yield + + for _, row in df.iterrows(): + ticker = row["Ticker"] + alloc_pct = next((etf["allocation"] / 100 for etf in allocations if etf["ticker"] == ticker), 0) + if alloc_pct == 0: + continue + capital = total_capital * alloc_pct + shares = capital / row["Price"] + income = shares * row["Dividend Rate"] - results.append(result) + # Create result dictionary with all available data + result = { + "Ticker": ticker, + "Yield (%)": row["Yield (%)"], + "Dividend Rate": round(row["Dividend Rate"], 2), + "Capital Allocated ($)": round(capital, 2), + "Income Contributed ($)": round(income, 2), + "Allocation (%)": round(alloc_pct * 100, 2), + "Inception Date": row["Inception Date"], + "Distribution Period": row.get("Distribution Period", "Unknown"), + "Price": row["Price"] + } + + # Add NAV data if available + if "NAV" in row and row["NAV"] is not None: + result["NAV"] = row["NAV"] + if "Premium/Discount (%)" in row and row["Premium/Discount (%)"] is not None: + result["Premium/Discount (%)"] = row["Premium/Discount (%)"] + + results.append(result) - alloc_df = pd.DataFrame(results) - alloc_df = assign_risk_level(alloc_df, allocations) - return alloc_df + alloc_df = pd.DataFrame(results) + alloc_df = assign_risk_level(alloc_df, allocations) + return alloc_df + + except Exception as e: + st.error(f"Error in income allocation: {str(e)}") + return None def allocate_for_capital(df: pd.DataFrame, capital: float, allocations: List[Dict]) -> pd.DataFrame: """Allocate a fixed amount of capital across ETFs and calculate resulting income.""" - results = [] - total_income = 0 - - for _, row in df.iterrows(): - ticker = row["Ticker"] - alloc_pct = next((etf["allocation"] / 100 for etf in allocations if etf["ticker"] == ticker), 0) - if alloc_pct == 0: - continue - - # Calculate capital allocation - allocated_capital = capital * alloc_pct - shares = allocated_capital / row["Price"] - income = shares * row["Dividend Rate"] - total_income += income + try: + # Store initial capital in session state + st.session_state.initial_capital = capital + st.session_state.mode = "Capital Target" + st.session_state.target = capital - # Create result dictionary with all available data - result = { - "Ticker": ticker, - "Yield (%)": row["Yield (%)"], - "Dividend Rate": round(row["Dividend Rate"], 2), - "Capital Allocated ($)": round(allocated_capital, 2), - "Income Contributed ($)": round(income, 2), - "Allocation (%)": round(alloc_pct * 100, 2), - "Inception Date": row["Inception Date"], - "Distribution Period": row.get("Distribution Period", "Unknown"), - "Price": row["Price"] - } + results = [] + total_income = 0 - # Add NAV data if available - if "NAV" in row and row["NAV"] is not None: - result["NAV"] = row["NAV"] - if "Premium/Discount (%)" in row and row["Premium/Discount (%)"] is not None: - result["Premium/Discount (%)"] = row["Premium/Discount (%)"] + for _, row in df.iterrows(): + ticker = row["Ticker"] + alloc_pct = next((etf["allocation"] / 100 for etf in allocations if etf["ticker"] == ticker), 0) + if alloc_pct == 0: + continue + + # Calculate capital allocation + allocated_capital = capital * alloc_pct + shares = allocated_capital / row["Price"] + income = shares * row["Dividend Rate"] + total_income += income - results.append(result) - - alloc_df = pd.DataFrame(results) - alloc_df = assign_risk_level(alloc_df, allocations) - return alloc_df + # Create result dictionary with all available data + result = { + "Ticker": ticker, + "Yield (%)": row["Yield (%)"], + "Dividend Rate": round(row["Dividend Rate"], 2), + "Capital Allocated ($)": round(allocated_capital, 2), + "Income Contributed ($)": round(income, 2), + "Allocation (%)": round(alloc_pct * 100, 2), + "Inception Date": row["Inception Date"], + "Distribution Period": row.get("Distribution Period", "Unknown"), + "Price": row["Price"] + } + + # Add NAV data if available + if "NAV" in row and row["NAV"] is not None: + result["NAV"] = row["NAV"] + if "Premium/Discount (%)" in row and row["Premium/Discount (%)"] is not None: + result["Premium/Discount (%)"] = row["Premium/Discount (%)"] + + results.append(result) + + alloc_df = pd.DataFrame(results) + alloc_df = assign_risk_level(alloc_df, allocations) + return alloc_df + + except Exception as e: + st.error(f"Error in capital allocation: {str(e)}") + return None # --- Portfolio Management Functions --- def recalculate_portfolio(allocations): - """Recalculate the portfolio based on new allocations.""" - # Create final allocation DataFrame - final_alloc = pd.DataFrame(columns=["Ticker", "Capital Allocated ($)", "Income Contributed ($)", - "Allocation (%)", "Yield (%)", "Price", "Risk Level"]) - - for ticker, allocation in allocations.items(): - row = df[df["Ticker"] == ticker].iloc[0] - new_row = { - "Ticker": ticker, - "Capital Allocated ($)": allocation * initial_capital / 100, - "Income Contributed ($)": allocation * initial_capital * row["Yield (%)"] / 100 / 100, - "Allocation (%)": allocation, - "Yield (%)": row["Yield (%)"], - "Price": row["Price"], - "Risk Level": row["Risk Level"] if "Risk Level" in row else "Unknown" - } + """Recalculate portfolio with new allocations""" + try: + # Get the initial capital from session state + initial_capital = st.session_state.get('initial_capital') + if initial_capital is None: + st.error("Initial capital not found. Please run the portfolio simulation first.") + return None + + # Create a new DataFrame for the recalculated portfolio + final_alloc = pd.DataFrame() - # Copy additional columns that might be needed - for col in ["NAV", "Premium/Discount (%)", "Dividend Rate", "Distribution Period", "Inception Date"]: - if col in row: - new_row[col] = row[col] - - final_alloc = pd.concat([final_alloc, pd.DataFrame([new_row])], ignore_index=True) - - return final_alloc + # Add tickers and their allocations + final_alloc["Ticker"] = list(allocations.keys()) + final_alloc["Allocation (%)"] = list(allocations.values()) + + # Merge with the global df to get other information + final_alloc = final_alloc.merge(df, on="Ticker", how="left") + + # Calculate capital allocation + final_alloc["Capital Allocated ($)"] = final_alloc["Allocation (%)"] * initial_capital / 100 + + # Calculate income contribution + final_alloc["Income Contributed ($)"] = final_alloc["Capital Allocated ($)"] * final_alloc["Yield (%)"] / 100 + + # Store the recalculated portfolio in session state + st.session_state.final_alloc = final_alloc + + return final_alloc + + except Exception as e: + st.error(f"Error recalculating portfolio: {str(e)}") + return None def update_allocation(ticker, new_alloc): """Update the allocation for a specific ticker.""" @@ -2502,7 +2529,7 @@ if st.session_state.simulation_run and st.session_state.df_data is not None: final_alloc = st.session_state.final_alloc if hasattr(st.session_state, 'final_alloc') else None # Create tabs for better organization - tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs(["📈 Portfolio Overview", "⚙️ Adjust Allocations", "📊 DRIP Forecast", "📉 Erosion Risk Assessment", "🤖 AI Suggestions", "📊 ETF Details"]) + tab1, tab2, tab3, tab4, tab5 = st.tabs(["📈 Portfolio Overview", "📊 DRIP Forecast", "📉 Erosion Risk Assessment", "🤖 AI Suggestions", "📊 ETF Details"]) with tab1: st.subheader("💰 Portfolio Summary") @@ -2529,16 +2556,170 @@ if st.session_state.simulation_run and st.session_state.df_data is not None: display_df["Yield (%)"] = display_df["Yield (%)"].apply(lambda x: f"{x:.2f}%") display_df["Shares"] = display_df["Shares"].apply(lambda x: f"{x:,.4f}") - # Create a list of columns that we want to display, checking if each exists - display_columns = ["Ticker", "Capital Allocated ($)", "Income Contributed ($)", "Shares", "Price Per Share", "Allocation (%)", "Yield (%)", "Risk Level"] - if "Distribution Period" in display_df.columns: - display_columns.append("Distribution Period") - - st.dataframe( - display_df[display_columns], - use_container_width=True, - hide_index=True - ) + # Create a form for the allocation table + with st.form("allocation_form"): + # Create an editable DataFrame + edited_df = st.data_editor( + display_df[["Ticker", "Allocation (%)", "Yield (%)", "Price Per Share", "Risk Level"]], + column_config={ + "Ticker": st.column_config.TextColumn("Ticker", disabled=True), + "Allocation (%)": st.column_config.NumberColumn( + "Allocation (%)", + min_value=0.0, + max_value=100.0, + step=1.0, + format="%.1f" + ), + "Yield (%)": st.column_config.TextColumn("Yield (%)", disabled=True), + "Price Per Share": st.column_config.TextColumn("Price Per Share", disabled=True), + "Risk Level": st.column_config.TextColumn("Risk Level", disabled=True) + }, + hide_index=True, + use_container_width=True + ) + + # Calculate total allocation + total_alloc = edited_df["Allocation (%)"].sum() + + # Display total allocation + st.metric("Total Allocation (%)", f"{total_alloc:.2f}", + delta=f"{total_alloc - 100:.2f}" if abs(total_alloc - 100) > 0.01 else None) + + if abs(total_alloc - 100) > 0.01: + st.warning("Total allocation should be 100%") + + # Create columns for quick actions + col1, col2, col3 = st.columns(3) + + with col1: + equal_weight = st.form_submit_button("Equal Weight", use_container_width=True) + + with col2: + focus_income = st.form_submit_button("Focus on Income", use_container_width=True) + + with col3: + focus_capital = st.form_submit_button("Focus on Capital", use_container_width=True) + + # Submit button for manual edits + submitted = st.form_submit_button("Update Allocations", + disabled=abs(total_alloc - 100) > 1, + type="primary", + use_container_width=True) + + # Handle form submission + if submitted: + try: + # Convert the edited allocations to a dictionary + new_allocations = {row["Ticker"]: float(row["Allocation (%)"]) for _, row in edited_df.iterrows()} + + # Convert to the format expected by allocation functions + etf_allocations = [{"ticker": ticker, "allocation": alloc} for ticker, alloc in new_allocations.items()] + + # Get the mode and target from session state + mode = st.session_state.get('mode', 'Capital Target') + target = st.session_state.get('target', 0) + initial_capital = st.session_state.get('initial_capital', 0) + + # Use the same allocation functions as the main navigation + if mode == "Income Target": + final_alloc = allocate_for_income(df, target, etf_allocations) + else: # Capital Target + final_alloc = allocate_for_capital(df, initial_capital, etf_allocations) + + if final_alloc is not None: + st.session_state.final_alloc = final_alloc + st.success("Portfolio updated with new allocations!") + st.rerun() + else: + st.error("Failed to update portfolio. Please try again.") + except Exception as e: + st.error(f"Error updating allocations: {str(e)}") + + # Handle quick actions + if equal_weight: + try: + # Calculate equal weight allocation + num_etfs = len(edited_df) + equal_allocation = 100 / num_etfs + + # Create new allocations in the format expected by allocation functions + etf_allocations = [{"ticker": row["Ticker"], "allocation": equal_allocation} for _, row in edited_df.iterrows()] + + # Get the mode and target from session state + mode = st.session_state.get('mode', 'Capital Target') + target = st.session_state.get('target', 0) + initial_capital = st.session_state.get('initial_capital', 0) + + # Use the same allocation functions as the main navigation + if mode == "Income Target": + final_alloc = allocate_for_income(df, target, etf_allocations) + else: # Capital Target + final_alloc = allocate_for_capital(df, initial_capital, etf_allocations) + + if final_alloc is not None: + st.session_state.final_alloc = final_alloc + st.success("Portfolio adjusted to equal weight!") + st.rerun() + except Exception as e: + st.error(f"Error applying equal weight: {str(e)}") + + elif focus_income: + try: + # Sort by yield and adjust allocations + sorted_alloc = edited_df.sort_values("Yield (%)", ascending=False) + total_yield = sorted_alloc["Yield (%)"].str.rstrip('%').astype('float').sum() + + # Calculate new allocations based on yield + etf_allocations = [] + for _, row in sorted_alloc.iterrows(): + yield_val = float(row["Yield (%)"].rstrip('%')) + allocation = (yield_val / total_yield) * 100 + etf_allocations.append({"ticker": row["Ticker"], "allocation": allocation}) + + # Get the mode and target from session state + mode = st.session_state.get('mode', 'Capital Target') + target = st.session_state.get('target', 0) + initial_capital = st.session_state.get('initial_capital', 0) + + # Use the same allocation functions as the main navigation + if mode == "Income Target": + final_alloc = allocate_for_income(df, target, etf_allocations) + else: # Capital Target + final_alloc = allocate_for_capital(df, initial_capital, etf_allocations) + + if final_alloc is not None: + st.session_state.final_alloc = final_alloc + st.success("Portfolio adjusted to focus on income!") + st.rerun() + except Exception as e: + st.error(f"Error focusing on income: {str(e)}") + + elif focus_capital: + try: + # Calculate equal weight allocation (same as equal weight) + num_etfs = len(edited_df) + equal_allocation = 100 / num_etfs + + # Create new allocations in the format expected by allocation functions + etf_allocations = [{"ticker": row["Ticker"], "allocation": equal_allocation} for _, row in edited_df.iterrows()] + + # Get the mode and target from session state + mode = st.session_state.get('mode', 'Capital Target') + target = st.session_state.get('target', 0) + initial_capital = st.session_state.get('initial_capital', 0) + + # Use the same allocation functions as the main navigation + if mode == "Income Target": + final_alloc = allocate_for_income(df, target, etf_allocations) + else: # Capital Target + final_alloc = allocate_for_capital(df, initial_capital, etf_allocations) + + if final_alloc is not None: + st.session_state.final_alloc = final_alloc + st.success("Portfolio adjusted to focus on capital!") + st.rerun() + except Exception as e: + st.error(f"Error focusing on capital: {str(e)}") # Display charts col1, col2 = st.columns(2) @@ -2581,116 +2762,6 @@ if st.session_state.simulation_run and st.session_state.df_data is not None: nav_chart(final_alloc["Ticker"].tolist(), debug_mode) with tab2: - # More compact allocation adjustment interface - st.subheader("⚖️ Adjust ETF Allocations") - - # Create a two-column layout for the editor - col1, col2 = st.columns([2, 1]) - - with col1: - # Convert allocations to dict for easier editing - current_allocations = {row["Ticker"]: row["Allocation (%)"] for _, row in final_alloc.iterrows()} - - # Create editable dataframe for allocations - more compact version - edited_alloc_df = pd.DataFrame({ - "Ticker": list(current_allocations.keys()), - "Allocation (%)": list(current_allocations.values()) - }) - - # Use data editor for interactive allocation adjustment - more compact - edited_df = st.data_editor( - edited_alloc_df, - column_config={ - "Ticker": st.column_config.TextColumn("Ticker", disabled=True, width="small"), - "Allocation (%)": st.column_config.NumberColumn( - "Allocation (%)", - min_value=0, - max_value=100, - step=1, - format="%.1f", - width="small" - ) - }, - use_container_width=True, - num_rows="fixed", - key="allocation_editor", - height=min(350, 50 + 35 * len(edited_alloc_df)) # Dynamically set height based on number of ETFs - ) - - # Calculate total allocation from editor - total_edited_alloc = edited_df["Allocation (%)"].sum() - - with col2: - st.metric("Total Allocation (%)", f"{total_edited_alloc:.2f}", - delta=f"{total_edited_alloc - 100:.2f}" if abs(total_edited_alloc - 100) > 0.01 else None) - if abs(total_edited_alloc - 100) > 0.01: - st.warning("Total allocation should be 100%") - - # Add explanatory text - st.write("Adjust the allocation percentages for each ETF and click the button below to recalculate your portfolio.") - - # Make recalculate button more prominent - recalculate_button = st.button("Recalculate Portfolio", - disabled=abs(total_edited_alloc - 100) > 1, - type="primary", - use_container_width=True) - - # Store edited allocations for recalculation - if recalculate_button: - # Convert edited dataframe to allocation dict - new_allocations = {row["Ticker"]: row["Allocation (%)"] for _, row in edited_df.iterrows()} - # Recalculate portfolio with new allocations - final_alloc = recalculate_portfolio(new_allocations) - st.session_state.final_alloc = final_alloc - st.success("Portfolio recalculated with new allocations!") - st.rerun() - - # Add quick actions buttons - st.subheader("Quick Actions") - - # Create columns for quick allocation buttons - button_cols = st.columns(3) - - with button_cols[0]: - if st.button("Equal Weight", use_container_width=True): - # Set equal allocation for all ETFs - equal_weight = 100 / len(edited_df) - new_allocations = {ticker: equal_weight for ticker in edited_df["Ticker"]} - final_alloc = recalculate_portfolio(new_allocations) - st.session_state.final_alloc = final_alloc - st.success(f"Applied equal weight ({equal_weight:.1f}%) to all ETFs") - st.rerun() - - with button_cols[1]: - if st.button("Income Focus", use_container_width=True): - # Allocate more to high-yield ETFs - # Get yield data - yields = {row["Ticker"]: row["Yield (%)"] for _, row in df.iterrows() if row["Ticker"] in edited_df["Ticker"].values} - # Calculate weights proportional to yield - total_yield = sum(yields.values()) - new_allocations = {ticker: (yield_val / total_yield) * 100 for ticker, yield_val in yields.items()} - final_alloc = recalculate_portfolio(new_allocations) - st.session_state.final_alloc = final_alloc - st.success("Applied income-focused allocation (higher yield = higher allocation)") - st.rerun() - - with button_cols[2]: - if st.button("Reset Allocations", use_container_width=True): - # Reset to original allocations from ETF input - original_allocations = {etf["ticker"]: etf["allocation"] for etf in st.session_state.etf_allocations} - original_tickers = set(original_allocations.keys()) - current_tickers = set(edited_df["Ticker"]) - - # Make sure we have allocations for all current tickers - if original_tickers == current_tickers: - final_alloc = recalculate_portfolio(original_allocations) - st.session_state.final_alloc = final_alloc - st.success("Reset to original allocations") - st.rerun() - else: - st.error("Cannot reset - current tickers don't match original input") - - with tab3: st.subheader("📈 Dividend Reinvestment (DRIP) Forecast") # Calculate DRIP growth with erosion simulation if enabled @@ -3120,7 +3191,7 @@ if st.session_state.simulation_run and st.session_state.df_data is not None: For DRIP: Initial Value - Current Value = Amount left to recover """) - with tab4: + with tab3: st.subheader("📉 AI Erosion Risk Assessment") # Add explanatory text @@ -3234,7 +3305,7 @@ if st.session_state.simulation_run and st.session_state.df_data is not None: else: st.warning("Unable to perform risk assessment. Check ticker data or try again.") - with tab5: + with tab4: st.subheader("🤖 AI Portfolio Suggestions") # Update AI suggestion to match simulation mode @@ -3294,50 +3365,8 @@ if st.session_state.simulation_run and st.session_state.df_data is not None: else: st.error("AI Suggestion failed to generate. Check ETF data.") - # Download buttons - put in expander to save space - with st.expander("Download Data"): - col1, col2, col3 = st.columns(3) - with col1: - st.download_button( - "⬇️ Download ETF Data", - df.to_csv(index=False), - "Filtered_ETFs.csv", - mime="text/csv", - use_container_width=True - ) - with col2: - st.download_button( - "⬇️ Download Portfolio Plan", - final_alloc.to_csv(index=False), - "User_ETF_Allocation.csv", - mime="text/csv", - use_container_width=True - ) - with col3: - if 'suggestion_df' in locals() and not suggestion_df.empty: - st.download_button( - "⬇️ Download AI Suggestions", - suggestion_df.to_csv(index=False), - "AI_ETF_Allocation.csv", - mime="text/csv", - use_container_width=True - ) - - # Add a separator - st.markdown("---") - - # Generate PDF report - # Get ChatGPT summary if API key is provided - chat_summary = get_chatgpt_summary(",".join(final_alloc["Ticker"].tolist()), api_key) if api_key else None - - pdf_report = create_pdf_report(final_alloc, df, chat_summary) - if pdf_report: - st.download_button( - "⬇️ Download Complete Portfolio Plan as PDF", - pdf_report, - "ETF_Portfolio_Plan.pdf", - mime="application/pdf", - use_container_width=True - ) + with tab5: + st.subheader("📊 ETF Details") + # ... existing code ... # End of file \ No newline at end of file