vchaang/IPO-tracker
0
1import streamlit as st2import yfinance as yf3import pandas as pd4import time5import requests6import random7import urllib.parse8from datetime import timedelta, datetime9 10# --- PAGE CONFIG ---11st.set_page_config(page_title="Catalyst & Flow Tracker", layout="wide")12 13# --- CUSTOM CSS FOR STYLING ---14st.markdown("""15<style>16 /* Modern, elegant, minimalist styling */17 .metric-card {18 background: rgba(128, 128, 128, 0.05);19 backdrop-filter: blur(10px);20 padding: 24px 16px;21 border-radius: 8px;22 border: 1px solid rgba(128, 128, 128, 0.2);23 text-align: center;24 transition: all 0.3s ease;25 }26 .metric-card:hover {27 border-color: rgba(128, 128, 128, 0.4);28 }29 .metric-label { 30 font-size: 11px; 31 text-transform: uppercase; 32 letter-spacing: 1.5px; 33 color: #888888; 34 margin-bottom: 8px; 35 font-weight: 600;36 }37 .metric-value { 38 font-size: 28px; 39 font-weight: 300; 40 letter-spacing: -0.5px; 41 }42 .pos-return { color: #5C946E !important; }43 .neg-return { color: #C96464 !important; }44 h1, h2, h3 { font-weight: 400 !important; letter-spacing: -0.5px; }45</style>46""", unsafe_allow_html=True)47 48# --- CACHED DATA FETCHING ---49@st.cache_data(ttl=3600, show_spinner=False)50def fetch_stock_data(ticker):51 """52 Highly resilient fetcher designed for blocked server environments.53 Tries yfinance first, then raw HTTP, then routes through public proxies.54 """55 stock = yf.Ticker(ticker)56 hist_max = pd.DataFrame()57 58 user_agents = [59 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',60 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'61 ]62 63 # Strategy 1: Standard yfinance history call64 try:65 hist_max = stock.history(period="max")66 except Exception:67 pass 68 69 # Strategy 2: RAW HTTP Fallback (Direct)70 if hist_max is None or hist_max.empty:71 endpoints = [72 f"https://query1.finance.yahoo.com/v8/finance/chart/{ticker}?range=max&interval=1d",73 f"https://query2.finance.yahoo.com/v8/finance/chart/{ticker}?range=max&interval=1d"74 ]75 for url in endpoints:76 try:77 headers = {'User-Agent': random.choice(user_agents)}78 res = requests.get(url, headers=headers, timeout=5)79 if res.status_code == 200:80 data = res.json()81 chart_res = data.get('chart', {}).get('result', [{}])[0]82 timestamps = chart_res.get('timestamp', [])83 closes = chart_res.get('indicators', {}).get('quote', [{}])[0].get('close', [])84 if timestamps and closes:85 hist_max = pd.DataFrame({'Close': closes}, index=pd.to_datetime(timestamps, unit='s', utc=True))86 hist_max = hist_max.dropna()87 break88 except Exception:89 continue90 91 # Strategy 3: PROXY ROUTER (Bypasses Hugging Face IP Blocks Entirely)92 if hist_max is None or hist_max.empty:93 target_url = f"https://query2.finance.yahoo.com/v8/finance/chart/{ticker}?range=max&interval=1d"94 encoded_url = urllib.parse.quote(target_url, safe='')95 proxies = [96 f"https://api.allorigins.win/raw?url={encoded_url}",97 f"https://api.codetabs.com/v1/proxy?quest={encoded_url}"98 ]99 for proxy_url in proxies:100 try:101 res = requests.get(proxy_url, timeout=10)102 if res.status_code == 200:103 data = res.json()104 chart_res = data.get('chart', {}).get('result', [{}])[0]105 timestamps = chart_res.get('timestamp', [])106 closes = chart_res.get('indicators', {}).get('quote', [{}])[0].get('close', [])107 if timestamps and closes:108 hist_max = pd.DataFrame({'Close': closes}, index=pd.to_datetime(timestamps, unit='s', utc=True))109 hist_max = hist_max.dropna()110 break111 except Exception:112 continue113 114 # Final Check for Failure115 if hist_max is None or hist_max.empty:116 return False, f"Data completely blocked by provider firewalls. Please try a different ticker or refresh in a couple of hot minutes.", None, None, None, None117 118 ipo_date = hist_max.index.min().date()119 120 # Fetch Info (Sector, Industry, Name)121 stock_info = {'sector': 'Unknown', 'industry': 'Unknown', 'shortName': ticker}122 fast_mcap = 0123 124 # Try Strategy 1: yfinance .info125 try:126 info = stock.info127 if info is not None and 'sector' in info:128 stock_info['sector'] = info.get('sector', 'Unknown')129 stock_info['industry'] = info.get('industry', 'Unknown')130 stock_info['shortName'] = info.get('shortName', ticker)131 fast_mcap = info.get('marketCap', 0)132 except Exception:133 pass134 135 # Strategy 2 & 3: Raw HTTP & Proxy Fallback for Profile136 if stock_info['sector'] in ['Unknown', 'Unknown (Blocked)']:137 target_url = f"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{ticker}?modules=summaryProfile,price"138 encoded_url = urllib.parse.quote(target_url, safe='')139 140 urls_to_try = [141 target_url, # Direct142 f"https://api.allorigins.win/raw?url={encoded_url}" # Proxy143 ]144 145 for url in urls_to_try:146 try:147 headers = {'User-Agent': random.choice(user_agents)}148 res = requests.get(url, headers=headers, timeout=5)149 if res.status_code == 200:150 data = res.json()151 result = data.get('quoteSummary', {}).get('result', [{}])[0]152 profile = result.get('summaryProfile', {})153 price_data = result.get('price', {})154 155 stock_info['sector'] = profile.get('sector', 'Unknown (Blocked)')156 stock_info['industry'] = profile.get('industry', 'Unknown (Blocked)')157 stock_info['shortName'] = price_data.get('shortName', ticker)158 159 if not fast_mcap:160 fast_mcap = price_data.get('marketCap', {}).get('raw', 0)161 break # Break if successful162 except Exception:163 stock_info['sector'] = 'Unknown (Blocked)'164 stock_info['industry'] = 'Unknown (Blocked)'165 166 # Fetch Fast Info for backup Market Cap if still 0167 if not fast_mcap:168 try:169 fast_mcap = stock.fast_info.get('marketCap', 0)170 except Exception:171 fast_mcap = 0172 173 return True, "Success", hist_max, stock_info, ipo_date, fast_mcap174 175@st.cache_data(ttl=86400, show_spinner=False)176def fetch_funds(ticker):177 """Highly resilient fetch for funds with raw HTTP and Proxy fallback."""178 try:179 df = yf.Ticker(ticker).mutualfund_holders180 if df is not None and not df.empty:181 return df182 except Exception:183 pass184 185 target_url = f"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{ticker}?modules=fundOwnership"186 encoded_url = urllib.parse.quote(target_url, safe='')187 urls_to_try = [target_url, f"https://api.allorigins.win/raw?url={encoded_url}"]188 189 for url in urls_to_try:190 try:191 headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}192 res = requests.get(url, headers=headers, timeout=5)193 if res.status_code == 200:194 data = res.json()195 owners = data.get('quoteSummary', {}).get('result', [{}])[0].get('fundOwnership', {}).get('ownershipList', [])196 if owners:197 parsed = [{'Holder': o.get('organization', 'Unknown'), 'pctHeld': o.get('pctHeld', {}).get('raw', 0)} for o in owners]198 return pd.DataFrame(parsed)199 except Exception:200 continue201 202 return None203 204@st.cache_data(ttl=86400, show_spinner=False)205def fetch_institutions(ticker):206 """Highly resilient fetch for institutional holders with proxy fallback."""207 try:208 df = yf.Ticker(ticker).institutional_holders209 if df is not None and not df.empty:210 return df211 except Exception:212 pass213 214 target_url = f"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{ticker}?modules=institutionOwnership"215 encoded_url = urllib.parse.quote(target_url, safe='')216 urls_to_try = [target_url, f"https://api.allorigins.win/raw?url={encoded_url}"]217 218 for url in urls_to_try:219 try:220 headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}221 res = requests.get(url, headers=headers, timeout=5)222 if res.status_code == 200:223 data = res.json()224 owners = data.get('quoteSummary', {}).get('result', [{}])[0].get('institutionOwnership', {}).get('ownershipList', [])225 if owners:226 parsed = [{'Holder': o.get('organization', 'Unknown'), 'pctHeld': o.get('pctHeld', {}).get('raw', 0)} for o in owners]227 return pd.DataFrame(parsed)228 except Exception:229 continue230 231 return None232 233# --- METRICS CALCULATOR ---234def calculate_metrics(hist_max):235 current_year = datetime.now().year236 if hist_max is None or hist_max.empty:237 return 0, 0, "N/A", "N/A"238 239 current_price = float(hist_max['Close'].iloc[-1])240 prev_close = float(hist_max['Close'].iloc[-2]) if len(hist_max) > 1 else current_price241 242 # YTD243 ytd_data = hist_max[hist_max.index.year == current_year]244 if not ytd_data.empty:245 first_ytd = float(ytd_data['Close'].iloc[0])246 ytd_val = ((current_price - first_ytd) / first_ytd) * 100247 ytd_return = f"{ytd_val:+.2f}%"248 else:249 ytd_return = "N/A"250 251 # 1-Year252 now_ts = pd.Timestamp.now(tz=hist_max.index.tz) if hasattr(hist_max.index, 'tz') else pd.Timestamp.now()253 one_year_ago = now_ts - pd.Timedelta(days=365)254 past_data = hist_max[hist_max.index <= one_year_ago]255 256 if not past_data.empty:257 first_1y = float(past_data['Close'].iloc[-1])258 one_yr_val = ((current_price - first_1y) / first_1y) * 100259 one_yr_return = f"{one_yr_val:+.2f}%" if len(hist_max) >= 250 else f"{one_yr_val:+.2f}% (Since IPO)"260 else:261 first_ipo = float(hist_max['Close'].iloc[0])262 one_yr_val = ((current_price - first_ipo) / first_ipo) * 100263 one_yr_return = f"{one_yr_val:+.2f}% (Since IPO)"264 265 return current_price, prev_close, ytd_return, one_yr_return266 267# --- UI LAYOUT ---268st.title("Post-IPO Catalyst & Flow Tracker")269st.markdown("<p style='color: #888; font-size: 16px; font-weight: 300;'>Predictive Index Inclusion & IPO Lock-up Mapping</p>", unsafe_allow_html=True)270st.write("")271 272# Inputs273col_search, col_override = st.columns([2, 1])274with col_search:275 ticker_input = st.text_input("Enter Ticker Only (e.g. EIKN, ARM, AAPL)", "")276with col_override:277 sector_override = st.selectbox(278 "Sector (Use if Auto-Detect fails)", 279 ["Auto-Detect", "Healthcare / Biotech", "Technology / Growth", "Other"]280 )281 282if ticker_input:283 ticker = ticker_input.upper().strip()284 with st.spinner(f"Pulling optimized market data for {ticker}..."):285 286 success, msg, hist_max, stock_info, ipo_date, fast_mcap = fetch_stock_data(ticker)287 288 if not success:289 st.error(msg)290 st.info("๐ก Note: Firewalls are currently extremely strict. Please try again in more than 60 seconds.")291 else:292 # Profile Data293 sector = stock_info.get('sector', 'Unknown')294 industry = stock_info.get('industry', 'Unknown')295 296 display_sector = sector297 if sector.startswith('Unknown') and sector_override != "Auto-Detect":298 display_sector = f"Manual: {sector_override}"299 300 mcap_str = f"${fast_mcap / 1e9:.2f}B" if fast_mcap else "Unknown"301 302 days_public = (datetime.now().date() - ipo_date).days303 is_mature = days_public > 365304 status_badge = "Mature Company" if is_mature else "Recent IPO"305 306 st.write("---")307 308 # Top Row: Info & Prices309 col1, col2 = st.columns([1, 2])310 with col1:311 st.subheader(f"{ticker} Profile")312 st.caption(stock_info.get('shortName', 'Company Name'))313 st.markdown(f"**Status:** {status_badge}")314 st.markdown(f"**Sector:** {display_sector}")315 st.markdown(f"**Industry:** {industry}")316 st.markdown(f"**Est. Market Cap:** {mcap_str}")317 318 with col2:319 st.subheader("Price & Performance")320 cp, pc, ytd, oyr = calculate_metrics(hist_max)321 322 m1, m2, m3, m4 = st.columns(4)323 m1.metric("Current Price", f"${cp:.2f}" if cp else "N/A", f"{cp - pc:+.2f}" if cp and pc else None)324 m2.metric("Previous Close", f"${pc:.2f}" if pc else "N/A")325 m3.metric("YTD Return", ytd)326 m4.metric("1-Year Return", oyr)327 328 st.write("---")329 330 # Middle Row: Deadlines331 st.subheader("Mechanical & Regulatory Deadlines")332 st.write("")333 334 deadlines = {335 "IPO Pricing / First Trade": ipo_date,336 "Quiet Period (T+25)*": ipo_date + timedelta(days=25),337 "Lock-Up Expiry (T+180)": ipo_date + timedelta(days=180)338 }339 340 d_cols = st.columns(3)341 for idx, (event, date) in enumerate(deadlines.items()):342 passed = date < datetime.now().date()343 status = "Passed" if passed else "Upcoming"344 color = "#888888" if passed else "#5C946E"345 346 with d_cols[idx]:347 st.markdown(f"""348 <div class="metric-card">349 <div class="metric-label">{event}</div>350 <div class="metric-value">{date.strftime('%b %d, %Y')}</div>351 <div style="color: {color}; font-size: 11px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; margin-top: 12px;">{status}</div>352 </div>353 """, unsafe_allow_html=True)354 355 # Determine Sector Flags Early for conditional logic356 is_biotech = sector_override == "Healthcare / Biotech" or (sector_override == "Auto-Detect" and (sector == 'Healthcare' or 'Biotech' in industry))357 is_tech = sector_override == "Technology / Growth" or (sector_override == "Auto-Detect" and sector in ['Technology', 'Communication Services'])358 359 if is_biotech:360 st.markdown("<p style='color: #888; font-size: 13px; margin-top: 12px;'><i>*Note: Biotechs typically qualify as Emerging Growth Companies (EGCs). While legally exempt from the SEC's 25-day research quiet period, underwriting syndicates almost universally enforce the T+25 rule as strict industry practice.</i></p>", unsafe_allow_html=True)361 362 st.write("---")363 364 # Bottom Row: Index Logic365 if is_mature:366 st.subheader("Top Passive Institutional & Mutual Fund Holders")367 st.markdown(f"<p style='color: #888; font-size: 14px;'>{ticker} has been public for >1 year. Mechanical lock-ups are irrelevant. The entities listed below control the daily passive flows.</p>", unsafe_allow_html=True)368 369 col_inst, col_fund = st.columns(2)370 371 with col_inst:372 st.markdown("**Top 5 Institutional Holders**")373 insts = fetch_institutions(ticker)374 375 if insts is not None and not insts.empty:376 insts_clean = insts.head(5)[['Holder', 'pctHeld']].copy()377 insts_clean['pctHeld'] = (insts_clean['pctHeld'] * 100).round(2).astype(str) + '%'378 insts_clean.columns = ['Institution Name', '% of Float Owned']379 st.table(insts_clean)380 else:381 st.warning("Institutional holder data is currently unavailable (rate-limited by provider).")382 383 with col_fund:384 st.markdown("**Top 5 Mutual Fund Holders**")385 funds = fetch_funds(ticker)386 387 if funds is not None and not funds.empty:388 funds_clean = funds.head(5)[['Holder', 'pctHeld']].copy()389 funds_clean['pctHeld'] = (funds_clean['pctHeld'] * 100).round(2).astype(str) + '%'390 funds_clean.columns = ['Fund Name', '% of Float Owned']391 st.table(funds_clean)392 else:393 st.warning("Mutual fund holder data is currently unavailable (rate-limited by provider).")394 else:395 st.subheader("Predictive Index Inclusion Targets")396 st.write("")397 398 inclusions = []399 ipo_month = ipo_date.month400 401 if ipo_month <= 4: 402 inclusions.append({"Index": "Russell 2000/3000", "Target": "Late June", "Prob": "High" if fast_mcap > 50_000_000 else "Low", "Rationale": "Eligible for the June Reconstitution. Usually requires >$30M market cap."})403 elif ipo_month <= 10: 404 inclusions.append({"Index": "Russell 2000/3000", "Target": "Dec 11", "Prob": "High" if fast_mcap > 50_000_000 else "Low", "Rationale": "Eligible for the December Semi-Annual Reconstitution."})405 406 inclusions.append({"Index": "CRSP US Total Market (VTI)", "Target": "Next Quarterly Rebalance", "Prob": "High", "Rationale": "Quarterly rebalance inclusion."})407 inclusions.append({"Index": "MSCI USA IMI", "Target": "Next Index Review", "Prob": "High" if fast_mcap >= 1e9 else ("Medium" if fast_mcap >= 300_000_000 else "Low"), "Rationale": "Quarterly/Semi-Annual reviews based on liquidity/cap. High probability if >$1B."})408 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 (rare for recent IPOs)."})409 410 # Dynamic Logic based on Market Cap for Biotechs411 if is_biotech:412 if fast_mcap > 500_000_000:413 xbi_prob, xbi_rat = "High", "Strong market cap (>$500M). High likelihood for next quarterly rebalance (Mar/Jun/Sep/Dec)."414 nbi_prob, nbi_rat = "High", "Strong market cap. Highly eligible for December annual reconstitution."415 elif fast_mcap > 250_000_000:416 xbi_prob, xbi_rat = "Medium", "Borderline market cap ($250M-$500M). Inclusion depends heavily on liquidity and trading volume."417 nbi_prob, nbi_rat = "Medium", "May qualify for annual December reconstitution if ADV meets requirements."418 elif fast_mcap > 0:419 xbi_prob, xbi_rat = "Low", "Market cap below typical $300M minimum threshold for XBI."420 nbi_prob, nbi_rat = "Low", "Market cap below typical minimums for NBI."421 else:422 xbi_prob, xbi_rat = "Varies", "Probability depends on final stabilized market cap (typically needs >$300M)."423 nbi_prob, nbi_rat = "Varies", "Needs >$200M market cap by late October cut-off."424 425 inclusions.append({"Index": "S&P Biotech (XBI)", "Target": "Next Quarterly Rebalance", "Prob": xbi_prob, "Rationale": xbi_rat})426 inclusions.append({"Index": "Nasdaq Biotech (NBI)", "Target": "December (Annual)", "Prob": nbi_prob, "Rationale": nbi_rat})427 inclusions.append({"Index": "ICE Biotech (IBB)", "Target": "Next Quarterly Rebalance", "Prob": xbi_prob, "Rationale": "Follows similar cap weighting and liquidity requirements to broader sector indices."})428 elif is_tech:429 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."})430 431 st.table(pd.DataFrame(inclusions))