Streamlit SEC Research Dashboard
Build an interactive web dashboard that combines company profiles, financial statements, insider trades, and institutional holdings into a single research tool.
In this tutorial
Prerequisites
- Python 3.8 or later
streamlit,requests, andplotlylibraries- A stockdata.dev API key — get one free
This tutorial ties together every endpoint from the previous tutorials into one interactive dashboard. If you have not used the API before, start with Tutorial 1: Company Lookup CLI to get familiar with the basics.
The free tier includes 1,000 API calls per month. Each tab in this dashboard makes one API call, so a single ticker lookup uses about 5 calls. That gives you roughly 200 company lookups per month on the free plan.
Project Setup
Create a project directory and install the dependencies:
mkdir sec-dashboard && cd sec-dashboard pip install streamlit requests plotly
Streamlit reads secrets from a .streamlit/secrets.toml file in your project directory. Create it now:
mkdir .streamlit
# .streamlit/secrets.toml STOCKDATA_API_KEY = "your_api_key_here"
Never commit .streamlit/secrets.toml to version control. Add it to your .gitignore file.
The Code
Create a file called app.py. This is the entire dashboard in a single file — about 220 lines of straightforward Streamlit code.
import streamlit as st import requests import plotly.graph_objects as go # ── Config ────────────────────────────────────────────────────────── st.set_page_config( page_title="SEC Research Dashboard", page_icon="📊", layout="wide", ) BASE_URL = "https://api.stockdata.dev/v1" API_KEY = st.secrets["STOCKDATA_API_KEY"] # ── API helper ────────────────────────────────────────────────────── @st.cache_data(ttl=900) def api_call(endpoint, params=None): """Call the stockdata.dev API and return JSON (cached 15 min).""" resp = requests.get( f"{BASE_URL}/{endpoint}", headers={"X-API-Key": API_KEY}, params=params or {}, timeout=10, ) resp.raise_for_status() return resp.json() # ── Formatting helpers ────────────────────────────────────────────── def fmt_dollars(value): """Format large dollar amounts: $1.2B, $340M, etc.""" if value is None: return "N/A" if abs(value) >= 1_000_000_000: return f"${value / 1_000_000_000:.1f}B" if abs(value) >= 1_000_000: return f"${value / 1_000_000:.0f}M" return f"${value:,.0f}" def fmt_eps(value): """Format EPS values.""" if value is None: return "N/A" return f"${value:.2f}" # ── Sidebar: ticker input ────────────────────────────────────────── st.sidebar.title("SEC Research") st.sidebar.markdown("---") search_query = st.sidebar.text_input("Search companies", placeholder="e.g. apple, MSFT") if search_query and len(search_query) >= 2: try: search_data = api_call("search", {"q": search_query, "limit": 10}) options = { f"{r['ticker']} - {r['name']}": r["ticker"] for r in search_data.get("results", []) } if options: choice = st.sidebar.selectbox("Select a company", list(options.keys())) ticker = options[choice] else: st.sidebar.warning("No results found.") ticker = None except Exception: st.sidebar.error("Search failed. Check your API key.") ticker = None else: ticker = st.sidebar.text_input( "Or enter a ticker directly", value="AAPL" ).upper().strip() st.sidebar.markdown("---") st.sidebar.caption("Data from [stockdata.dev](https://stockdata.dev) SEC filings API") if not ticker: st.info("Enter a ticker or search for a company in the sidebar.") st.stop() # ── Company header ───────────────────────────────────────────────── try: company = api_call(f"company/{ticker}") except requests.HTTPError: st.error(f"Company not found: {ticker}") st.stop() st.title(f"{company.get('name', ticker)} ({ticker})") col1, col2, col3, col4 = st.columns(4) col1.metric("Exchange", company.get("exchange", "N/A")) col2.metric("SIC Code", company.get("sic", "N/A")) col3.metric("CIK", company.get("cik", "N/A")) col4.metric("State", company.get("state_of_incorporation", "N/A")) if company.get("sic_description"): st.caption(f"Industry: {company['sic_description']}") st.markdown("---") # ── Tabs ─────────────────────────────────────────────────────────── tab_fin, tab_filings, tab_insider, tab_inst = st.tabs([ "Financials", "Filings", "Insider Trades", "Institutional Holdings", ]) # ── Tab 1: Financials ────────────────────────────────────────────── with tab_fin: try: fin_data = api_call( f"company/{ticker}/financials", {"period": "annual", "limit": 4}, ) statements = fin_data.get("financials", []) except requests.HTTPError: statements = [] if not statements: st.info("No financial data available for this company.") else: # Latest year metrics latest = statements[0] inc = latest.get("income_statement", {}) m1, m2, m3 = st.columns(3) m1.metric("Revenue", fmt_dollars(inc.get("revenue"))) m2.metric("Net Income", fmt_dollars(inc.get("net_income"))) m3.metric("EPS (Diluted)", fmt_eps(inc.get("eps_diluted"))) # Revenue & Net Income bar chart (oldest first) ordered = list(reversed(statements)) years = [f"FY{s['fiscal_year']}" for s in ordered] revenues = [ s.get("income_statement", {}).get("revenue", 0) / 1_000_000 for s in ordered ] net_incomes = [ s.get("income_statement", {}).get("net_income", 0) / 1_000_000 for s in ordered ] fig = go.Figure() fig.add_trace(go.Bar( name="Revenue", x=years, y=revenues, marker_color="#3B82F6", )) fig.add_trace(go.Bar( name="Net Income", x=years, y=net_incomes, marker_color="#10B981", )) fig.update_layout( title="Revenue & Net Income (in millions USD)", barmode="group", yaxis_tickprefix="$", yaxis_tickformat=",.0f", height=400, ) st.plotly_chart(fig, use_container_width=True) # ── Tab 2: Recent Filings ────────────────────────────────────────── with tab_filings: try: filings_data = api_call( f"company/{ticker}/filings", {"limit": 10}, ) filings = filings_data.get("filings", []) except requests.HTTPError: filings = [] if not filings: st.info("No filings found for this company.") else: for f in filings: col_type, col_date, col_desc, col_link = st.columns([1, 1.5, 3, 1]) col_type.markdown(f"**{f['form_type']}**") col_date.write(f.get("filing_date", "")) col_desc.write(f.get("description", "") or "") if f.get("url"): col_link.link_button("EDGAR", f["url"]) # ── Tab 3: Insider Trades ────────────────────────────────────────── with tab_insider: try: insider_data = api_call( f"insider-trades/{ticker}", {"days": 90, "limit": 50}, ) trades = insider_data.get("trades", []) except requests.HTTPError: trades = [] if not trades: st.info("No insider trades in the last 90 days.") else: # Build a colored table rows = [] for t in trades: shares = t.get("shares") price = t.get("price") value = shares * price if shares and price else None action = t.get("action", "") if action == "purchase": action_display = "🟢 Buy" elif action == "sale": action_display = "🔴 Sell" else: action_display = action.title() rows.append({ "Date": t.get("transaction_date", t.get("filing_date", "")), "Insider": t.get("owner", ""), "Title": t.get("title", "") or "", "Action": action_display, "Shares": f"{shares:,.0f}" if shares else "N/A", "Price": f"${price:,.2f}" if price else "N/A", "Value": fmt_dollars(value), }) st.dataframe(rows, use_container_width=True, hide_index=True) # ── Tab 4: Institutional Holdings ────────────────────────────────── with tab_inst: try: inst_data = api_call( f"institutions/{ticker}", {"limit": 10}, ) holdings = inst_data.get("holdings", []) except requests.HTTPError: holdings = [] if not holdings: st.info("No institutional holdings data available.") else: # value_thousands is in $1000s as reported in 13F names = [h["institution"] for h in holdings] values = [ (h.get("value_thousands", 0) or 0) / 1_000 for h in holdings ] # convert to millions fig = go.Figure(go.Bar( x=values, y=names, orientation="h", marker_color="#6366F1", )) fig.update_layout( title="Top Institutional Holders (value in $M)", xaxis_tickprefix="$", xaxis_tickformat=",.0f", yaxis_autorange="reversed", height=400, margin=dict(l=250), ) st.plotly_chart(fig, use_container_width=True) # Also show a data table table_rows = [] for h in holdings: val = h.get("value_thousands") table_rows.append({ "Institution": h["institution"], "Shares": f"{h['shares']:,}", "Value": f"${val * 1_000:,.0f}" if val else "N/A", "Report Date": h.get("report_date", ""), }) st.dataframe(table_rows, use_container_width=True, hide_index=True)
How It Works
The dashboard is structured around a single api_call() function and four tabs, each backed by a different API endpoint.
API Helper with Caching
The @st.cache_data(ttl=900) decorator caches every API response for 15 minutes. This means switching between tabs or re-running the app does not make duplicate calls. The 15-minute TTL matches the SEC data update frequency, so you always see fresh data without wasting your API quota.
Sidebar Search
The sidebar offers two ways to pick a company. You can type a search query (like "apple" or "tesla"), which calls the /search endpoint and presents a dropdown of results. Or you can type a ticker directly. This two-path approach is friendly to users who know the ticker and users who don't.
Company Header
Once a ticker is selected, the /company/{ticker} endpoint provides the company name, exchange, SIC code, CIK, and state. These are displayed as st.metric() cards across the top of the page for an at-a-glance overview.
Financials Tab
Calls /company/{ticker}/financials?period=annual&limit=4 to get the last four years of income statement data. The latest year's revenue, net income, and EPS are shown as metric cards. A grouped bar chart (Plotly) plots revenue and net income side by side over time, making trends immediately visible.
Filings Tab
Calls /company/{ticker}/filings?limit=10 to show the most recent SEC filings. Each row shows the form type (10-K, 10-Q, 8-K, etc.), filing date, description, and a direct link to the filing on EDGAR via st.link_button().
Insider Trades Tab
Calls /insider-trades/{ticker}?days=90 for the last 90 days of Form 4 filings. The table highlights purchases in green and sales in red, and calculates the total transaction value (shares times price). This makes it easy to spot significant insider activity at a glance.
Institutional Holdings Tab
Calls /institutions/{ticker}?limit=10 to get the top 10 holders by share count. A horizontal bar chart shows their positions by value, and a data table below it lists the exact share counts, values, and reporting dates. The value_thousands field from the API (as reported in 13F filings) is converted to millions for the chart.
Running It
Start the dashboard with:
streamlit run app.py
Your browser will open to http://localhost:8501. You should see:
- A sidebar with a search box and ticker input
- The company name and key identifiers across the top
- Four tabs for Financials, Filings, Insider Trades, and Institutional Holdings
Try searching for "microsoft" in the sidebar, then click through each tab. The financial chart should show four years of revenue growth. Switch to the Insider Trades tab to see recent Form 4 filings. The Filings tab has direct links to every document on EDGAR.
Streamlit automatically re-runs the script when you change the ticker. The 15-minute cache means repeated lookups of the same company are instant and free.
Deploy to Streamlit Cloud
You can share this dashboard with others by deploying to Streamlit Community Cloud for free.
1. Push to GitHub
Create a repository with these files:
sec-dashboard/ ├── app.py ├── requirements.txt └── .gitignore
Your requirements.txt:
streamlit requests plotly
Your .gitignore:
.streamlit/secrets.toml
2. Connect on Streamlit Cloud
- Go to share.streamlit.io and sign in with GitHub
- Click New app and select your repository
- Set the main file path to
app.py - Under Advanced settings, add your secret:
STOCKDATA_API_KEY = "your_api_key_here" - Click Deploy
Your dashboard will be live at a public URL within a few minutes. Anyone with the link can use it — the API calls are authenticated with your key on the server side, so visitors never see it.
If you deploy publicly, be mindful of your API quota. Each visitor's ticker lookup uses about 5 API calls. Consider upgrading to the Starter plan if you expect regular traffic.