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.

This commit is contained in:
Pascal BIBEHE 2025-05-24 22:06:52 +00:00
parent 44d94eeb00
commit f0ea40767c

View File

@ -1591,6 +1591,12 @@ 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."""
try:
# Store initial capital in session state
st.session_state.initial_capital = target
st.session_state.mode = "Income Target"
st.session_state.target = target
results = []
weighted_yield = 0
for _, row in df.iterrows():
@ -1602,7 +1608,7 @@ def allocate_for_income(df: pd.DataFrame, target: float, allocations: List[Dict]
if weighted_yield <= 0:
st.error("Weighted yield is zero or negative. Check ETF data.")
return pd.DataFrame()
return None
total_capital = target / weighted_yield
@ -1640,8 +1646,18 @@ def allocate_for_income(df: pd.DataFrame, target: float, allocations: List[Dict]
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."""
try:
# Store initial capital in session state
st.session_state.initial_capital = capital
st.session_state.mode = "Capital Target"
st.session_state.target = capital
results = []
total_income = 0
@ -1682,34 +1698,45 @@ def allocate_for_capital(df: pd.DataFrame, capital: float, allocations: List[Dic
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"])
"""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
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"
}
# 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]
# Add tickers and their allocations
final_alloc["Ticker"] = list(allocations.keys())
final_alloc["Allocation (%)"] = list(allocations.values())
final_alloc = pd.concat([final_alloc, pd.DataFrame([new_row])], ignore_index=True)
# 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."""
st.session_state.etf_allocations[ticker] = new_alloc
@ -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,17 +2556,171 @@ 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)
with col1:
@ -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