aboutsummaryrefslogtreecommitdiffstats
path: root/streamlit_app.py
diff options
context:
space:
mode:
Diffstat (limited to 'streamlit_app.py')
-rw-r--r--streamlit_app.py298
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> &nbsp; • &nbsp;
+ On-demand: <strong>{f'${on_demand_price:.5f}/hr' if on_demand_price is not None else 'n/a'}</strong> &nbsp; • &nbsp;
+ {savings_text} &nbsp; • &nbsp;
+ 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")
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage