diff options
Diffstat (limited to 'streamlit_app.py')
| -rw-r--r-- | streamlit_app.py | 298 |
1 files changed, 298 insertions, 0 deletions
diff --git a/streamlit_app.py b/streamlit_app.py new file mode 100644 index 0000000..5a57abd --- /dev/null +++ b/streamlit_app.py @@ -0,0 +1,298 @@ +import os +from datetime import datetime, timezone + +import pandas as pd +import plotly.graph_objects as go +import streamlit as st +from dotenv import load_dotenv +from streamlit_autorefresh import st_autorefresh + +import aws +import persistance + +load_dotenv() + +st.set_page_config(page_title="Spot Crash Board", page_icon="📉", layout="wide") + +st.markdown( + """ + <style> + .top-card { + border: 1px solid #2a3142; + border-radius: 14px; + padding: 1rem 1.15rem; + margin-bottom: 0.8rem; + background: linear-gradient(145deg, #0d1320 0%, #121a2b 100%); + } + .top-label { + color: #8fa6cc; + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + } + .top-value { + font-size: 2.2rem; + font-weight: 800; + margin-top: 0.15rem; + line-height: 1; + } + .top-sub { + color: #adbacf; + margin-top: 0.45rem; + font-size: 0.95rem; + } + .mega-crash { + border: 1px solid #7f1d1d; + border-radius: 14px; + background: linear-gradient(145deg, #2b0b0b 0%, #430f0f 100%); + color: #fee2e2; + padding: 1rem 1.15rem; + margin-bottom: 0.8rem; + animation: pulse 1.25s infinite; + } + .mega-crash strong { + color: #fecaca; + } + @keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.55); } + 70% { box-shadow: 0 0 0 12px rgba(220, 38, 38, 0); } + 100% { box-shadow: 0 0 0 0 rgba(220, 38, 38, 0); } + } + </style> + """, + unsafe_allow_html=True, +) +max_pay_raw = os.getenv("MAX_PAY") +st.caption(f"i will not pay more than ${max_pay_raw} per hour") + +if "simulate_mega_crash" not in st.session_state: + st.session_state.simulate_mega_crash = False + +with st.sidebar: + st.header("Display") + poll_seconds = st.slider("Poll interval (seconds)", min_value=2, max_value=30, value=5) + history_limit = st.slider("Points on chart", min_value=100, max_value=5000, value=1000) + chart_expanded = st.toggle("Chart expanded", value=True) + show_markers = st.toggle("Show point markers", value=False) + + st.divider() + st.caption("Test mode") + + if st.button("Trigger MEGA CRASH", type="primary", use_container_width=True): + st.session_state.simulate_mega_crash = True + + if st.button("Clear test crash", use_container_width=True): + st.session_state.simulate_mega_crash = False + +if not max_pay_raw: + st.error("Missing MAX_PAY environment variable. Add MAX_PAY to your .env file.") + st.stop() + +try: + max_pay = float(max_pay_raw) +except ValueError: + st.error(f"Invalid MAX_PAY value: {max_pay_raw}. Use a numeric value like 0.03") + st.stop() + +persistance.init_storage() +st_autorefresh(interval=poll_seconds * 1000, key="spot-pricing-poller") + + +@st.cache_data(ttl=1, show_spinner=False) +def fetch_spot_payload(): + return aws.get_asg_spot_pricing() + + +payload = fetch_spot_payload() +if payload.get("error"): + st.error(payload["error"]) + st.stop() + +latest = payload.get("latest") or {} +instance = payload.get("instance") or {} +history = payload.get("history") or [] + +if not history: + if latest.get("spotPrice") is None: + st.warning("No spot price returned from API right now.") + st.stop() + history = [latest] + +imported_points = 0 +for item in history: + timestamp = item.get("timestamp") + spot_price = item.get("spotPrice") + + if not timestamp or spot_price is None: + continue + + point = { + "polled_at": str(timestamp), + "source_timestamp": str(timestamp), + "asg_name": payload.get("asg_name"), + "instance_id": instance.get("id"), + "instance_type": instance.get("type"), + "az": item.get("availabilityZone") or instance.get("az"), + "spot_price": float(spot_price), + } + + if persistance.save_spot_datapoint(point): + imported_points += 1 + +rows = persistance.load_spot_datapoints(limit=history_limit) + +if not rows: + st.info("Waiting for first saved data point...") + st.stop() + +df = pd.DataFrame(rows) +df["polled_at"] = pd.to_datetime(df["polled_at"], errors="coerce", utc=True) +df["source_timestamp"] = pd.to_datetime(df["source_timestamp"], errors="coerce", utc=True) +df = df.dropna(subset=["polled_at", "spot_price"]).sort_values("polled_at") + +if df.empty: + st.info("No valid points available yet.") + st.stop() + +current_price = float(df.iloc[-1]["spot_price"]) +prev_price = float(df.iloc[-2]["spot_price"]) if len(df) > 1 else current_price +visible_low = float(df["spot_price"].min()) +visible_high = float(df["spot_price"].max()) +price_delta = current_price - prev_price +multiplier = current_price / visible_low if visible_low > 0 else 1.0 + +on_demand_price = latest.get("onDemandPrice") +if on_demand_price is None: + for item in reversed(history): + if item.get("onDemandPrice") is not None: + on_demand_price = item.get("onDemandPrice") + break + +if on_demand_price is not None: + on_demand_price = float(on_demand_price) + savings_per_hour = on_demand_price - current_price + savings_percent = (savings_per_hour / on_demand_price) * 100 if on_demand_price > 0 else None +else: + savings_per_hour = None + savings_percent = None + +peak_all_time = persistance.get_peak_spot_price() +first_breach = persistance.get_first_breach(max_pay) +historical_mega_crash = first_breach is not None +mega_crash = historical_mega_crash or st.session_state.simulate_mega_crash + +if mega_crash: + if historical_mega_crash: + breach_time = first_breach.get("polled_at", "unknown") + breach_price = float(first_breach.get("spot_price", 0.0)) + message = ( + '<strong>MEGA CRASH:</strong> MAX_PAY has been exceeded in your saved history. ' + f'First breach at <strong>{breach_time}</strong> ' + f'with <strong>${breach_price:.5f}/hr</strong> ' + f'(MAX_PAY = <strong>${max_pay:.5f}</strong>).' + ) + else: + message = ( + '<strong>MEGA CRASH (TEST):</strong> Simulated crash from sidebar button. ' + f'MAX_PAY is <strong>${max_pay:.5f}</strong>.' + ) + + st.markdown(f'<div class="mega-crash">{message}</div>', unsafe_allow_html=True) + +trend_icon = "▲" if price_delta >= 0 else "▼" +trend_color = "#f59e0b" if price_delta >= 0 else "#22c55e" +if mega_crash: + trend_color = "#ef4444" + +if savings_percent is None or savings_per_hour is None: + savings_text = "Savings vs on-demand: n/a" +elif savings_per_hour >= 0: + savings_text = f"Savings vs on-demand: {savings_percent:.2f}% (${savings_per_hour:.5f}/hr)" +else: + savings_text = f"Spot above on-demand by {abs(savings_percent):.2f}% (${abs(savings_per_hour):.5f}/hr)" + +st.markdown( + f""" + <div class="top-card"> + <div class="top-label">Crash Multiplier</div> + <div class="top-value" style="color: {trend_color};">{multiplier:.2f}x {trend_icon}</div> + <div class="top-sub"> + Current: <strong>${current_price:.5f}/hr</strong> • + On-demand: <strong>{f'${on_demand_price:.5f}/hr' if on_demand_price is not None else 'n/a'}</strong> • + {savings_text} • + MAX_PAY: <strong>${max_pay:.5f}</strong> + </div> + </div> + """, + unsafe_allow_html=True, +) + +c1, c2, c3, c4, c5, c6, c7 = st.columns(7) +c1.metric("Instance", instance.get("type", "unknown")) +c2.metric("AZ", instance.get("az", "unknown")) +c3.metric("Spot ($/hr)", f"{current_price:.5f}", f"{price_delta:+.5f}") +c4.metric("On-demand ($/hr)", f"{on_demand_price:.5f}" if on_demand_price is not None else "n/a") + +if savings_percent is not None and savings_per_hour is not None: + c5.metric("Savings (%)", f"{savings_percent:.2f}%", f"${savings_per_hour:+.5f}/hr") +else: + c5.metric("Savings (%)", "n/a") + +c6.metric("24h estimate ($)", f"{current_price * 24:.2f}") +c7.metric("All-time peak ($/hr)", f"{(peak_all_time or current_price):.5f}") + +with st.expander("Ticker chart", expanded=chart_expanded): + line_color = "#ef4444" if mega_crash else ("#f59e0b" if price_delta >= 0 else "#22c55e") + marker_mode = "lines+markers" if show_markers else "lines" + + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=df["polled_at"], + y=df["spot_price"], + mode=marker_mode, + line={"width": 3, "color": line_color, "shape": "spline"}, + marker={"size": 5, "color": line_color}, + fill="tozeroy", + fillcolor="rgba(245, 158, 11, 0.13)" if not mega_crash else "rgba(239, 68, 68, 0.18)", + name="Spot price", + ) + ) + + fig.add_hline( + y=max_pay, + line_dash="dash", + line_color="#ef4444", + annotation_text=f"MAX_PAY ${max_pay:.5f}", + annotation_position="top right", + annotation_font_color="#ef4444", + ) + + fig.update_layout( + template="plotly_dark", + margin={"l": 10, "r": 10, "t": 10, "b": 10}, + hovermode="x unified", + height=430, + xaxis={ + "title": "Time (UTC)", + "tickformat": "%H:%M:%S", + "hoverformat": "%Y-%m-%d %H:%M:%S", + "showgrid": False, + "rangeslider": {"visible": False}, + }, + yaxis={ + "title": "Spot price ($/hr)", + "tickprefix": "$", + "tickformat": ".5f", + "gridcolor": "#2c3448", + }, + showlegend=False, + ) + + st.plotly_chart(fig, width="stretch") + +status = f"Imported {imported_points} new historical points" if imported_points else "No new historical points" +st.caption(f"{status} • Last refresh {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + +with st.expander("Recent datapoints", expanded=False): + st.dataframe(df.tail(150).sort_values("polled_at", ascending=False), width="stretch") |
