Congress Trading Tracker

Overview

Congress Trading Tracker is a single-file Python script that pulls publicly disclosed US Congress stock trades, normalises each one into a flat record (who, which chamber, ticker, buy or sell, date), and then uses yfinance to measure how much each stock has moved since the trade was made. The end product is a console summary that groups every politician by chamber and trade type, ranked by their average gain since the disclosed trade. I built it for myself as an EPM consultant who likes to poke at market data on the side: it answers the slightly cynical question of whose disclosed trades have aged well, without me having to babysit a single flaky data source.

Architecture and data flow

The whole thing lives in congress.py and runs as a script, not a service. The data flow is a linear pipeline with a fallback ladder bolted onto the front of it.

  1. The runner under if __name__ == "__main__": tries three data loaders in strict priority order: load_option1 (Financial Modeling Prep), then load_option2 (Quiver Quantitative), then load_option3 (a static GitHub JSON dump). The first one that returns a non-empty DataFrame wins.
  2. Each loader maps its source-specific JSON shape onto one common schema: person, chamber, ticker, type, date. This normalisation is what lets the rest of the program stay completely source-agnostic.
  3. During mapping, three helper functions clean the raw fields: normalize_ticker, get_trade_type, and safe_parse_date. Rows with a null ticker or a null date are dropped immediately.
  4. The cleaned DataFrame flows into compute_and_summarize, which iterates every row, calls get_price_change against yfinance, collects the valid results, and prints a grouped pandas summary.

The key design decision is that the loaders and the compute step are decoupled by that shared schema. The price math and the summary never know or care which of the three sources produced the rows.

The interesting internals

The shared cleaning helpers

def normalize_ticker(tck):
    if not tck:
        return None
    tck = tck.strip().upper()
    if tck in INVALID_TICKERS:
        return None
    return tck.replace(".", "-")  # e.g. BRK.B to BRK-B

def get_trade_type(t):
    t = str(t).lower()
    if any(k in t for k in BUY_KEYWORDS):
        return "Buy"
    if any(k in t for k in SELL_KEYWORDS):
        return "Sell"
    return "Other"

These two functions are tiny but they carry the whole data-quality load. normalize_ticker exists because Yahoo Finance does not use the same ticker convention as the disclosure feeds: class shares come through as something like BRK.B, but yfinance wants BRK-B, so the .replace(".", "-") is the one transformation that prevents a whole category of silent download failures. It also guards against junk: empty strings and the literal -- placeholder (held in the INVALID_TICKERS set) are turned into None so they can be filtered out later.

get_trade_type is deliberately keyword-based rather than relying on an exact enum, because the three sources phrase transaction types differently. By lowercasing the raw value and doing a substring any(k in t for k in ...) check against BUY_KEYWORDS and SELL_KEYWORDS, I catch variants like "purchase", "sale", "exchange in", "open long" and "open short" without writing a parser per source. Anything that matches neither set is bucketed as "Other" rather than being guessed at, which keeps the buy/sell classification honest.

The fallback ladder

try:
    print("Attempting Option 1 (FMP API)...")
    df = load_option1(fmp_api_key=None)  # supply your FMP key if needed
    print("Option 1 succeeded.")
except Exception as e1:
    print(f"Option 1 failed: {e1}")
    # Try Option 2
    try:
        print("Attempting Option 2 (Quiver API)...")
        df = load_option2(quiver_api_key=None)  # supply your Quiver key if needed
        print("Option 2 succeeded.")
    except Exception as e2:
        print(f"Option 2 failed: {e2}")
        # Fallback Option 3
        print("Falling back to Option 3 (minimal dataset)...")
        df = load_option3()

This nested try/except is the reliability backbone. Congressional trading APIs are flaky and gated behind keys with quotas, so instead of trusting any single one I chain them. load_option1 and load_option2 are written to raise a RuntimeError on any failure, including the "technically returned 200 but the body was empty" case, which is why each loader checks if df.empty or if not rows and raises rather than returning a useless empty frame. The exception type is the control signal: a raise means "try the next source down the ladder." load_option3 is the floor of the ladder and is built differently on purpose: it swallows its own exception and returns an empty DataFrame with the right columns, so the runner can detect total failure and sys.exit(1) cleanly rather than crashing.

The per-row price measurement

def get_price_change(ticker, trade_date):
    try:
        df = yf.download(ticker, start=trade_date, end=datetime.now(), progress=False, auto_adjust=True)
        if df.empty:
            return None
        start_price = df["Close"].iloc[0]
        latest_price = df["Close"].iloc[-1]
        return (latest_price / start_price - 1) * 100
    except Exception:
        return None

This is the core metric. For each trade it downloads the daily price history from the trade date up to now with auto_adjust=True, so the close column is already adjusted for splits and dividends and I am comparing apples to apples across time. It takes the first available close (iloc[0], the closest trading day to the disclosed trade) and the most recent close (iloc[-1]) and computes the percentage move between them. The whole thing is wrapped in a broad try/except returning None, which is intentional: delisted tickers, network hiccups and tickers Yahoo simply does not recognise all fail gracefully and are dropped downstream instead of killing a bulk run. progress=False is set so yfinance does not print its own bars and stomp on the tqdm output.

The bulk loop and grouped summary

for _, row in tqdm(df.iterrows(), total=len(df)):
    change = get_price_change(row["ticker"], row["date"])
    if change is not None:
        results.append({
            ...
            "gain_%": round(change, 2),
        })
...
summary = (
    resdf.groupby(["person","chamber","type"])
    .agg(
        avg_gain=("gain_%","mean"),
        trade_count=("ticker","count"),
        first_trade=("date","min"),
        last_trade=("date","max")
    )
    .reset_index()
    .sort_values("avg_gain", ascending=False)
)

compute_and_summarize wraps df.iterrows() in tqdm with an explicit total=len(df), because every iteration fires a network download, so a run can take minutes and I want a live progress bar with an honest ETA. Only rows where get_price_change returned a real number get appended, which silently discards the failures. The final summary uses a named-aggregation groupby over person, chamber and type, giving me the average gain, the trade count, and the first and last trade dates per group, sorted so the best-performing politicians float to the top.

The formulas

The single quantitative output is the percentage price change since a trade:

gain_% = (latest_close / start_close - 1) * 100

where start_close is the adjusted close on the first trading day at or after the disclosed trade date, and latest_close is the most recent adjusted close. Per group the headline metric is then the arithmetic mean of that value:

avg_gain = mean(gain_% for all trades in (person, chamber, type))

Trade type classification is a set-membership rule: lowercase the raw transaction string, return "Buy" if it contains any BUY_KEYWORDS substring, "Sell" if it contains any SELL_KEYWORDS substring, else "Other".

Tech stack: Python, pandas, requests, yfinance, python-dateutil, tqdm, and three congressional-trading data sources (Financial Modeling Prep, Quiver Quantitative, and the senate-stock-watcher GitHub dataset).

Back to all posts