Python Intermediate

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.

Note: The code examples in this tutorial have not yet been verified against the live API. If you encounter issues, please let us know.

Prerequisites

  • Python 3.8 or later
  • streamlit, requests, and plotly libraries
  • 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:

Shell
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:

Shell
mkdir .streamlit
TOML
# .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.

Python
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:

Shell
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:

Shell
sec-dashboard/
├── app.py
├── requirements.txt
└── .gitignore

Your requirements.txt:

Text
streamlit
requests
plotly

Your .gitignore:

Text
.streamlit/secrets.toml

2. Connect on Streamlit Cloud

  1. Go to share.streamlit.io and sign in with GitHub
  2. Click New app and select your repository
  3. Set the main file path to app.py
  4. Under Advanced settings, add your secret: STOCKDATA_API_KEY = "your_api_key_here"
  5. 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.

Ready to build?

Get your free API key and start coding in minutes.

Get Free API Key