import streamlit as st
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.express as px
from datetime import datetime
# --- DASHBOARD CONFIG ---
st.set_page_config(page_title="Professional Cash Out Strategy Simulator", layout="wide")
st.title("π Professional Cash Out Strategy Simulator")
# --- SESSION STATE INITIALIZATION ---
if 'manual_mean' not in st.session_state:
st.session_state.manual_mean = 22.87
if 'manual_vol' not in st.session_state:
st.session_state.manual_vol = 41.53
if 'manual_start_price' not in st.session_state:
st.session_state.manual_start_price = 95000.0
if 'debug_log' not in st.session_state:
st.session_state.debug_log = []
# --- DATA INPUT SECTION ---
st.sidebar.header("1. Data Input")
uploaded_file = st.sidebar.file_uploader("Upload Historical CSV", type="csv")
if uploaded_file:
try:
uploaded_file.seek(0)
df_hist = pd.read_csv(uploaded_file)
# FIX: Case-insensitive column detection
col = None
# Convert all columns to lowercase for a reliable check
cols_lower = [c.lower() for c in df_hist.columns]
if 'price' in cols_lower:
# Find the original name that matched 'price'
col = df_hist.columns[cols_lower.index('price')]
elif 'open' in cols_lower:
col = df_hist.columns[cols_lower.index('open')]
elif 'close' in cols_lower:
col = df_hist.columns[cols_lower.index('close')]
if col:
valid_series = df_hist[col].dropna()
if not valid_series.empty:
# Get the last valid price
new_p0 = float(valid_series.iloc[-1])
# Math for 365-day annualization
hist_returns = np.log(valid_series / valid_series.shift(1)).dropna()
new_mu = float(hist_returns.mean() * 365 * 100)
new_sigma = float(hist_returns.std() * np.sqrt(365) * 100)
# Update Session State and Trigger Refresh
if (abs(new_p0 - st.session_state.manual_start_price) > 0.001):
st.session_state.manual_start_price = new_p0
st.session_state.manual_mean = new_mu
st.session_state.manual_vol = new_sigma
st.rerun()
st.sidebar.success(f"""
β
**File Loaded Successfully!**
- **Start Price:** ${st.session_state.manual_start_price:,.2f}
- **Mean Return:** {st.session_state.manual_mean:.2f}%
""")
else:
st.sidebar.error(f"Required column not found. Found: {list(df_hist.columns)}")
except Exception as e:
st.sidebar.error(f"Error parsing CSV: {e}")
st.session_state.debug_log.append(f"Exception: {str(e)}")
# Optional Debug Log in sidebar
if st.sidebar.checkbox("Show Upload Debug Log"):
for log in st.session_state.debug_log[-5:]:
st.sidebar.text(log)
st.sidebar.header("2. Decay Settings")
enable_decay = st.sidebar.toggle("Enable Return/Volatility Decay", value=True)
decay_strength = st.sidebar.slider("Decay Strength (Ξ»)", 0.0, 1.0, 0.1)
st.sidebar.header("3. Market & Setup")
# UI UPDATE: Widgets now strictly reflect the session state
mean_return = st.sidebar.number_input("Annual Mean Return (%)",
value=st.session_state.manual_mean,
key="input_mean") / 100
volatility = st.sidebar.number_input("Annual Volatility (%)",
value=st.session_state.manual_vol,
key="input_vol") / 100
start_asset_price = st.sidebar.number_input("Starting Asset Price ($)",
value=st.session_state.manual_start_price,
key="input_start_price")
starting_capital = st.sidebar.number_input("Initial Portfolio Value ($)", value=100000.0)
horizon_years = st.sidebar.slider("Time Horizon (Years)", 1, 100, 10)
num_simulations = st.sidebar.number_input("Number of Simulations", value=1000)
# --- SIMULATION ENGINE ---
st.sidebar.header("4. Strategy & Visualization")
cash_out_rate_annual = st.sidebar.slider("Annual Cash-Out Rate (%)", 0.0, 100.0, 5.0) / 100
ath_trigger_pct = st.sidebar.slider("Trigger (% of ATH)", 0, 100, 90) / 100
frequency = st.sidebar.selectbox("Frequency", ["Daily", "Weekly", "Monthly", "Yearly"])
show_individual = st.sidebar.checkbox("Show Individual Path Toggles", value=True)
num_paths_to_show = st.sidebar.slider("Num Paths", 1, 10, 3) if show_individual else 0
freq_steps = {"Daily": 365, "Weekly": 52, "Monthly": 12, "Yearly": 1}
steps_per_year = freq_steps[frequency]
total_steps = int(horizon_years * steps_per_year)
dt = 1 / steps_per_year
date_range = pd.date_range(start=datetime.now(), periods=total_steps + 1, freq='D' if frequency=="Daily" else frequency[0])
plot_dates = date_range.to_numpy()
@st.cache_data
def run_master_simulation(start_p, start_cap, sims, mu_init, sigma_init, rate, trigger, decay_val, t_steps, d_enabled):
prices = np.zeros((t_steps + 1, sims))
prices[0] = start_p
portfolio = np.zeros((t_steps + 1, sims))
portfolio[0] = start_cap
cum_cash = np.zeros((t_steps + 1, sims))
ath_tracker = np.full(sims, float(start_p))
cash_out_events = np.zeros(sims)
survived = np.ones(sims, dtype=bool)
for t in range(1, t_steps + 1):
price_multiple = prices[t-1] / start_p
current_mu = mu_init * np.exp(-decay_val * price_multiple) if d_enabled else mu_init
current_sigma = sigma_init * np.exp(-decay_val * price_multiple) if d_enabled else sigma_init
shocks = np.random.normal(0, 1, sims); drift = (current_mu - 0.5 * current_sigma**2) * dt
diffusion = current_sigma * np.sqrt(dt) * shocks
prices[t] = prices[t-1] * np.exp(drift + diffusion); ath_tracker = np.maximum(ath_tracker, prices[t])
at_high = (prices[t] / ath_tracker) >= trigger; cash_out_events += at_high.astype(int)
withdrawal = np.where((at_high) & (portfolio[t-1] > 100), portfolio[t-1] * (rate * dt), 0.0)
portfolio[t] = np.maximum(0, (portfolio[t-1] - withdrawal) * (prices[t] / prices[t-1]))
cum_cash[t] = cum_cash[t-1] + withdrawal; survived = (portfolio[t] >= 100) & survived
return prices, portfolio, cum_cash, survived, (cash_out_events / t_steps)
prices, portfolio, cum_cash, survived_mask, cash_out_freqs = run_master_simulation(
start_asset_price, starting_capital, int(num_simulations),
mean_return, volatility, cash_out_rate_annual, ath_trigger_pct, decay_strength, total_steps, enable_decay
)
# --- STATS HELPERS & CHARTS ---
def get_pct_stats(data):
return {"p10": np.percentile(data, 10, axis=1), "p50": np.percentile(data, 50, axis=1),
"p90": np.percentile(data, 90, axis=1), "mean": np.mean(data, axis=1)}
price_stats = get_pct_stats(prices); port_stats = get_pct_stats(portfolio); cash_stats = get_pct_stats(cum_cash)
def create_master_chart(stats, title, ylabel, color_rgba, raw_data=None):
fig = go.Figure(); x_list = list(plot_dates)
y_90, y_10, y_50 = list(stats["p90"]), list(stats["p10"]), list(stats["p50"])
fig.add_trace(go.Scatter(x=x_list, y=y_90, mode='lines', line=dict(width=0), showlegend=False))
fig.add_trace(go.Scatter(x=x_list, y=y_10, mode='lines', fill='tonexty', fillcolor=f'rgba{color_rgba}', line=dict(width=0), name="10th-90th Pct"))
fig.add_trace(go.Scatter(x=x_list, y=y_50, mode='lines', line=dict(color='white', width=2), name="Median"))
if raw_data is not None and show_individual:
for i in range(min(num_paths_to_show, raw_data.shape[1])):
fig.add_trace(go.Scatter(x=x_list, y=list(raw_data[:, i]), mode='lines', line=dict(dash='dot', width=1), name=f"Path {i+1}"))
fig.update_layout(title=title, template="plotly_dark", xaxis_title="Date", yaxis_title=ylabel)
return fig
# Grid Layout
r1c1, r1c2 = st.columns(2)
with r1c1:
st.plotly_chart(create_master_chart(price_stats, "Asset Price Projections", "Price ($)", (0, 176, 246, 0.2), prices), use_container_width=True)
st.plotly_chart(create_master_chart(cash_stats, "Total Extracted Cash", "USD", (0, 255, 127, 0.2), cum_cash), use_container_width=True)
with r1c2:
st.plotly_chart(create_master_chart(port_stats, "Strategy Portfolio Balance", "USD", (138, 43, 226, 0.2), portfolio), use_container_width=True)
hist_fig = go.Figure(data=[go.Histogram(x=list(cash_out_freqs * 100), marker_color='#00ff7f', nbinsx=20)])
hist_fig.update_layout(title="Cash-Out Frequency Distribution (%)", xaxis_title="% Steps at Trigger", template="plotly_dark")
st.plotly_chart(hist_fig, use_container_width=True)
# --- STRATEGIC SUMMARY (RESTORED FROM IMAGE) ---
st.header("π Strategic Summary")
sc1, sc2, sc3 = st.columns(3)
with sc1:
# Survival Rate: paths where portfolio stayed above $100
st.metric("Survival Rate (>$100)", f"{np.mean(survived_mask):.2%}")
# Mean of the final step of all portfolio paths
st.metric("Mean Final Portfolio", f"${port_stats['mean'][-1]:,.2f}")
with sc2:
# 50th percentile of total cash extracted by the end
st.metric("Median Total Extracted", f"${cash_stats['p50'][-1]:,.2f}")
# 50th percentile of remaining portfolio balance
st.metric("Median Final Value", f"${port_stats['p50'][-1]:,.2f}")
with sc3:
# Total extracted divided by years
avg_annual_income = cash_stats['p50'][-1] / max(1, horizon_years)
st.metric("Avg Annual Income", f"${avg_annual_income:,.2f}")
# Median percentage of time spent in the cash-out trigger zone
st.metric("Median Time Cashing Out", f"{np.median(cash_out_freqs):.1%}")
# --- FULL-WIDTH COMPREHENSIVE TABLE ---
st.header("π
Strategy Breakdown by Scenario")
view_mode = st.radio("Select Table Scenario", ["Median (50th)", "Worst Case (10th)", "Best Case (90th)"], horizontal=True)
map_key = {"Median (50th)": "p50", "Worst Case (10th)": "p10", "Best Case (90th)": "p90"}
k = map_key[view_mode]
step_indices = np.arange(0, total_steps + 1, steps_per_year)
step_indices = step_indices[step_indices < len(price_stats[k])]
df_breakdown = pd.DataFrame({
"Date": [d.strftime('%Y-%m-%d') for d in date_range[step_indices]],
"Asset Price": price_stats[k][step_indices],
"Portfolio Remaining": port_stats[k][step_indices],
"Total Realized Cash": cash_stats[k][step_indices]
})
st.dataframe(df_breakdown, use_container_width=True, hide_index=True,
column_config={"Asset Price": st.column_config.NumberColumn(format="$%.2f"),
"Portfolio Remaining": st.column_config.NumberColumn(format="$%.2f"),
"Total Realized Cash": st.column_config.NumberColumn(format="$%.2f")})