vchaang/IPO-tracker
0
1import streamlit as st
2import yfinance as yf
3import pandas as pd
4import time
5import requests
6from datetime import timedelta, datetime
7
8# --- PAGE CONFIG ---
9st.set_page_config(page_title="Catalyst & Flow Tracker", layout="wide")
10
11# --- CUSTOM CSS FOR STYLING ---
12st.markdown("""
13<style>
14 /* Modern, elegant, minimalist styling */
15 .metric-card {
16 background: rgba(128, 128, 128, 0.05);
17 backdrop-filter: blur(10px);
18 padding: 24px 16px;
19 border-radius: 8px;
20 border: 1px solid rgba(128, 128, 128, 0.2);
21 text-align: center;
22 transition: all 0.3s ease;
23 }
24 .metric-card:hover {
25 border-color: rgba(128, 128, 128, 0.4);
26 }
27 .metric-label {
28 font-size: 11px;
29 text-transform: uppercase;
30 letter-spacing: 1.5px;
31 color: #888888;
32 margin-bottom: 8px;
33 font-weight: 600;
34 }
35 .metric-value {
36 font-size: 28px;
37 font-weight: 300;
38 letter-spacing: -0.5px;
39 }
40 .pos-return { color: #5C946E !important; }
41 .neg-return { color: #C96464 !important; }
42 h1, h2, h3 { font-weight: 400 !important; letter-spacing: -0.5px; }
43</style>
44""", unsafe_allow_html=True)
45
46# --- CACHED DATA FETCHING ---
47# The @st.cache_data decorator saves the result for 1 hour (3600 seconds).
48# This prevents Yahoo from blocking the app due to too many requests!
49@st.cache_data(ttl=3600, show_spinner=False)
50def fetch_stock_data(ticker):
51 stock = yf.Ticker(ticker)
52 hist_max = pd.DataFrame()
53
54 # 1. Fetch History (Our primary source of truth)
55 # We wrap this in a try-except because Streamlit Cloud frequently gets YFRateLimitErrors
56 try:
57 hist_max = stock.history(period="max")
58 except Exception:
59 pass # Ignore the crash, we will use the raw fallback below
60
61 # 2. RAW HTTP FALLBACK: If yfinance is blocked, we fetch directly from Yahoo's backend
62 if hist_max is None or hist_max.empty:
63 try:
64 url = f"https://query2.finance.yahoo.com/v8/finance/chart/{ticker}?range=max&interval=1d"
65 headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'}
66 res = requests.get(url, headers=headers, timeout=5)
67
68 if res.status_code == 200:
69 data = res.json()
70 timestamps = data['chart']['result'][0]['timestamp']
71 closes = data['chart']['result'][0]['indicators']['quote'][0]['close']
72 # Create a perfectly formatted dataframe from the raw data
73 hist_max = pd.DataFrame({'Close': closes}, index=pd.to_datetime(timestamps, unit='s', utc=True))
74 except Exception:
75 pass
76
77 # If even the fallback fails, return a polite error instead of a crashed app
78 if hist_max is None or hist_max.empty:
79 return False, f"Data completely blocked by Yahoo for {ticker}. Please try again later.", None, None, None, None
80
81 ipo_date = hist_max.index.min().date()
82
83 # 3. Fetch Info (Silently catch rate limits)
84 try:
85 stock_info = stock.info or {}
86 except Exception:
87 stock_info = {}
88
89 # 4. Fetch Fast Info for backup Market Cap
90 try:
91 fast_mcap = stock.fast_info.get('marketCap', 0)
92 except Exception:
93 fast_mcap = 0
94
95 return True, "Success", hist_max, stock_info, ipo_date, fast_mcap
96
97@st.cache_data(ttl=86400, show_spinner=False) # Cache funds for 24 hours
98def fetch_funds(ticker):
99 try:
100 return yf.Ticker(ticker).mutualfund_holders
101 except Exception:
102 return None
103
104# --- METRICS CALCULATOR ---
105def calculate_metrics(hist_max):
106 current_year = datetime.now().year
107 if hist_max is None or hist_max.empty:
108 return 0, 0, "N/A", "N/A"
109
110 current_price = float(hist_max['Close'].iloc[-1])
111 prev_close = float(hist_max['Close'].iloc[-2]) if len(hist_max) > 1 else current_price
112
113 # YTD
114 ytd_data = hist_max[hist_max.index.year == current_year]
115 if not ytd_data.empty:
116 first_ytd = float(ytd_data['Close'].iloc[0])
117 ytd_val = ((current_price - first_ytd) / first_ytd) * 100
118 ytd_return = f"{ytd_val:+.2f}%"
119 else:
120 ytd_return = "N/A"
121
122 # 1-Year (Handles timezone differences safely)
123 now_ts = pd.Timestamp.now(tz=hist_max.index.tz) if hasattr(hist_max.index, 'tz') else pd.Timestamp.now()
124 one_year_ago = now_ts - pd.Timedelta(days=365)
125 past_data = hist_max[hist_max.index <= one_year_ago]
126
127 if not past_data.empty:
128 first_1y = float(past_data['Close'].iloc[-1])
129 one_yr_val = ((current_price - first_1y) / first_1y) * 100
130 one_yr_return = f"{one_yr_val:+.2f}%" if len(hist_max) >= 250 else f"{one_yr_val:+.2f}% (Since IPO)"
131 else:
132 first_ipo = float(hist_max['Close'].iloc[0])
133 one_yr_val = ((current_price - first_ipo) / first_ipo) * 100
134 one_yr_return = f"{one_yr_val:+.2f}% (Since IPO)"
135
136 return current_price, prev_close, ytd_return, one_yr_return
137
138# --- UI LAYOUT ---
139st.title("Post-IPO Catalyst & Flow Tracker")
140st.markdown("<p style='color: #888; font-size: 16px; font-weight: 300;'>Predictive Index Inclusion & IPO Lock-up Mapping</p>", unsafe_allow_html=True)
141st.write("")
142
143# Inputs
144col_search, col_override = st.columns([2, 1])
145with col_search:
146 ticker_input = st.text_input("Enter Ticker (e.g. EIKN, ARM, AAPL)", "")
147with col_override:
148 sector_override = st.selectbox(
149 "Sector (Use if Auto-Detect fails)",
150 ["Auto-Detect", "Healthcare / Biotech", "Technology / Growth", "Other"]
151 )
152
153if ticker_input:
154 ticker = ticker_input.upper().strip()
155 with st.spinner(f"Pulling optimized market data for {ticker}..."):
156
157 # Call our new, super-fast cached functions!
158 success, msg, hist_max, stock_info, ipo_date, fast_mcap = fetch_stock_data(ticker)
159
160 if not success:
161 st.error(msg)
162 else:
163 # Profile Data
164 sector = stock_info.get('sector', 'Unknown')
165 industry = stock_info.get('industry', 'Unknown')
166
167 display_sector = sector
168 if sector == 'Unknown' and sector_override != "Auto-Detect":
169 display_sector = f"Manual: {sector_override}"
170
171 mcap = stock_info.get('marketCap', fast_mcap)
172 mcap_str = f"${mcap / 1e9:.2f}B" if mcap else "Unknown"
173
174 days_public = (datetime.now().date() - ipo_date).days
175 is_mature = days_public > 365
176 status_badge = "Mature Company" if is_mature else "Recent IPO"
177
178 st.write("---")
179
180 # Top Row: Info & Prices
181 col1, col2 = st.columns([1, 2])
182 with col1:
183 st.subheader(f"{ticker} Profile")
184 st.caption(stock_info.get('shortName', 'Company Name'))
185 st.markdown(f"**Status:** {status_badge}")
186 st.markdown(f"**Sector:** {display_sector}")
187 st.markdown(f"**Industry:** {industry}")
188 st.markdown(f"**Est. Market Cap:** {mcap_str}")
189
190 with col2:
191 st.subheader("Price & Performance")
192 cp, pc, ytd, oyr = calculate_metrics(hist_max)
193
194 m1, m2, m3, m4 = st.columns(4)
195 m1.metric("Current Price", f"${cp:.2f}" if cp else "N/A", f"{cp - pc:+.2f}" if cp and pc else None)
196 m2.metric("Previous Close", f"${pc:.2f}" if pc else "N/A")
197 m3.metric("YTD Return", ytd)
198 m4.metric("1-Year Return", oyr)
199
200 st.write("---")
201
202 # Middle Row: Deadlines
203 st.subheader("Mechanical & Regulatory Deadlines")
204 st.write("")
205
206 deadlines = {
207 "IPO Pricing / First Trade": ipo_date,
208 "Quiet Period (T+25)": ipo_date + timedelta(days=25),
209 "Lock-Up Expiry (T+180)": ipo_date + timedelta(days=180)
210 }
211
212 d_cols = st.columns(3)
213 for idx, (event, date) in enumerate(deadlines.items()):
214 passed = date < datetime.now().date()
215 status = "Passed" if passed else "Upcoming"
216 color = "#888888" if passed else "#5C946E"
217
218 with d_cols[idx]:
219 st.markdown(f"""
220 <div class="metric-card">
221 <div class="metric-label">{event}</div>
222 <div class="metric-value">{date.strftime('%b %d, %Y')}</div>
223 <div style="color: {color}; font-size: 11px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; margin-top: 12px;">{status}</div>
224 </div>
225 """, unsafe_allow_html=True)
226
227 st.write("---")
228
229 # Bottom Row: Index Logic
230 if is_mature:
231 st.subheader("Top Passive Institutional Holders")
232 st.markdown(f"<p style='color: #888; font-size: 14px;'>{ticker} has been public for >1 year. Mechanical lock-ups are irrelevant. The funds listed below control the daily passive flows.</p>", unsafe_allow_html=True)
233
234 # Fetch cached funds
235 funds = fetch_funds(ticker)
236
237 if funds is not None and not funds.empty:
238 funds_clean = funds.head(5)[['Holder', 'pctHeld']]
239 funds_clean['pctHeld'] = (funds_clean['pctHeld'] * 100).round(2).astype(str) + '%'
240 funds_clean.columns = ['Fund Name', '% of Float Owned']
241 st.table(funds_clean)
242 else:
243 st.warning("Fund data temporarily unavailable due to rate limits from data provider.")
244 else:
245 st.subheader("Predictive Index Inclusion Targets")
246 st.write("")
247
248 inclusions = []
249 ipo_month = ipo_date.month
250
251 if ipo_month <= 4:
252 inclusions.append({"Index": "Russell 2000/3000", "Target": "Late June", "Prob": "High", "Rationale": "Eligible for the June Reconstitution."})
253 elif ipo_month <= 10:
254 inclusions.append({"Index": "Russell 2000/3000", "Target": "Dec 11", "Prob": "High", "Rationale": "Eligible for the December Semi-Annual Reconstitution."})
255
256 inclusions.append({"Index": "CRSP US Total Market (VTI)", "Target": "Next Quarterly Rebalance", "Prob": "High", "Rationale": "Quarterly rebalance inclusion."})
257 inclusions.append({"Index": "MSCI USA IMI", "Target": "Next Index Review", "Prob": "High" if mcap >= 1e9 else "Medium", "Rationale": "Quarterly/Semi-Annual reviews based on liquidity/cap."})
258 inclusions.append({"Index": "S&P Composite 1500", "Target": f"After {(ipo_date + timedelta(days=365)).strftime('%b %Y')}", "Prob": "Low", "Rationale": "Requires 12 months seasoning + GAAP profitability."})
259
260 is_biotech = False
261 is_tech = False
262
263 if sector_override == "Healthcare / Biotech":
264 is_biotech = True
265 elif sector_override == "Technology / Growth":
266 is_tech = True
267 elif sector_override == "Auto-Detect":
268 is_biotech = sector == 'Healthcare' or 'Biotech' in industry or 'Pharmaceutical' in industry
269 is_tech = sector in ['Technology', 'Communication Services', 'Consumer Discretionary']
270
271 if is_biotech:
272 inclusions.append({"Index": "S&P Biotech (XBI)", "Target": "Next Quarterly Rebalance", "Prob": "High", "Rationale": "Requires 1-2 months seasoning."})
273 inclusions.append({"Index": "Nasdaq Biotech (NBI)", "Target": "December (Annual)", "Prob": "High", "Rationale": "Annual December reconstitution."})
274 elif is_tech or (not is_biotech and sector_override == "Auto-Detect"):
275 inclusions.append({"Index": "Nasdaq 100 (QQQ)", "Target": "Standard or Fast Entry (15 Days)", "Prob": "Varies", "Rationale": "Standard requires 3mo seasoning. Mega-caps fast-track in 15 days."})
276
277 st.table(pd.DataFrame(inclusions))