Day 4 · About 6 hours, in three sessions

Saving your work and your data

Put your project on GitHub with checks that run on every push, save daily prices in a PostgreSQL database and ask it questions in SQL, and build a feed of your watchlist’s SEC filings.

  1. Session 1 Your repository on GitHub
  2. Session 2 PostgreSQL and a price recorder
  3. Session 3 Your watchlist’s SEC filings

Today’s goal

By the end of today your code is on GitHub and checked on every push, and your prices and filings live in a database. You can ask the database questions in SQL, and read the newest filings across your watchlist:

uv run filingsAAPL: 36 filingsMSFT: 33 filingsNVDA: 40 filings Latest filings  2026-09-03  NVDA  8-K   Other events  2026-09-02  MSFT  8-K   Regulation FD disclosure  2026-08-26  NVDA  10-Q  Quarterly report  2026-08-26  NVDA  8-K   Results of operations  2026-08-17  NVDA  8-K   Material agreement, Regulation FD disclosure  2026-07-31  AAPL  10-Q  Quarterly report  2026-07-30  AAPL  8-K   Results of operations  2026-07-29  MSFT  10-K  Annual report  2026-07-29  MSFT  8-K   Results of operations  2026-07-02  NVDA  8-K   Officer or director change

Shown for the course’s sample data. Yours shows the latest.

Why it matters on a desk

On a desk, code lives in a shared repository where every change is checked before anyone builds on it, and data lives in databases, not in files on someone’s laptop. Today your project gets both.

Filings are where companies say what happened, under rules that make them answer for it. Traders, analysts and risk teams watch them: an 8-K can move a share within minutes, and a 10-K is where a company sets out its risks.

Review

A few questions from earlier days. Answer each before you start.

In a minute, 100 shares trade at 20.00 and 300 shares at 22.00. What is the minute’s VWAP?

Show the answer

B: 21.50 VWAP counts each price by its shares: (20.00 × 100 + 22.00 × 300) ÷ 400 = 8,600 ÷ 400 = 21.50. 21.00 is the plain average of the two prices.

pytest prints an F for one of your tests. What does it mean?

Show the answer

A: That test failed pytest prints a dot for each test that passes and an F for each that fails, then shows what each failure compared.

Your code is in src/market_data/. How does a test import vwap from intraday.py?

Show the answer

C: from market_data.intraday import vwap Once the project is a package, every module is imported by the package’s name and its own.

Session 1 Your repository on GitHub

The idea

Step 1 Your repository, and GitHub’s copy

GitHub keeps a copy of your repository, called a remote, that anyone you choose can see.your computerevery commit, and your filesGitHub: origina copy of the commits you pushgit pushsends your new commits upgit pullbrings commits made elsewhere downorigin is the usual name for the remote you cloned from or first pushed to.Only commits travel. Files you haven’t committed stay put, and files .gitignore names never leave.
01/03

GitHub is a website that keeps Git repositories online. Your repository on GitHub is a copy of the one on your computer, called a remote, and the usual name for it is origin.

git push sends up the commits you have made since you last pushed. git pull brings down commits made elsewhere, such as on another computer. Only commits travel: a change you haven’t committed stays on your computer, and nothing .gitignore names ever leaves it.

A public repository is part of your portfolio. People who might hire you read the code, the commit messages and the README, so each of them should be clear.

Practice

Problem 1

2 points

Your computer has 11 commits on main. GitHub’s copy has the first 8 of them. How many commits does git push send?

Hint 1

git push sends the commits GitHub doesn’t have yet.

Hint 2

Take the 8 GitHub has from the 11 you have.

Solution

commits to send = commits here − commits on GitHub

= 11 − 8 = 3

You changed watchlist.py but haven’t committed the change. Then you run git push. What reaches GitHub?

Show the answer

B: Nothing new: only commits travel git push sends commits. A change that isn’t committed stays on your computer until you commit it and push again.

Your workflow runs pytest on every push. What does a red cross beside a commit on GitHub mean?

Show the answer

C: A check failed for that commit The commit is saved either way. The cross says a step in the workflow failed, and its output says which and why.

The project, step by step

Build it yourself from this brief, then check it against the steps.

  • Make a GitHub account if you don’t have one. Install the GitHub CLI, gh, and sign in with gh auth login.
  • Write README.md: what the project does, how to set it up with uv, a table of its commands, and how to test it. Commit it.
  • Create a public repository from your project with gh repo create market-data --public --source . --push.
  • Add mypy and pandas-stubs as development tools, set mypy to check src strictly, and make uv run mypy pass.
  • Add .github/workflows/check.yml to run ruff format --check, ruff check, mypy and pytest on every push. Commit it, push, and watch it pass under Actions.
  1. Step 1 Make a GitHub account

    If you don’t have one, sign up at github.com/signup with your own email. Choose your username with care: it is in the address of everything you put there, such as github.com/your-username/market-data.

  2. Step 2 Install the GitHub CLI, and sign in

    The GitHub CLI, gh, does GitHub’s jobs from the terminal: making repositories, and signing Git in to GitHub. Install it:

    Windows

    winget install --id GitHub.cli

    macOS and Linux

    brew install gh

    On macOS that uses Homebrew, from brew.sh. On Linux, follow the steps for your system at cli.github.com. Then open a new terminal and sign in:

    gh auth login

    Answer its questions: GitHub.com, then HTTPS, then yes to signing Git in with your GitHub account, then signing in with a web browser. It shows a one-time code to paste into the page it opens. After that, Git and gh both use the sign-in.

  3. Step 3 Write the README

    Make README.md in the market-data folder. uv made an empty one on Day 1; replace it with this, in your own words if you like:

    README.md
    # market-data US equity market data in Python: quotes, daily bars since 2016, returns, split anddividend adjustment, and a trading day's 1-minute bars. Prices come from Alpaca's market data API, free with an Alpaca account: quotes from everyUS exchange, 15 minutes behind the market. The data is for personal use, so thisrepository holds no prices: each command downloads its own. ## Setup Install [uv](https://docs.astral.sh/uv/) and make API keys in an[Alpaca](https://alpaca.markets/) paper trading account. Copy `.env.example` to `.env` andset your keys, then run: ```uv sync``` Commands that download prices read your keys from `.env` through `--env-file`. ## Commands | Command | Description || --- | --- || `uv run --env-file .env quote AAPL` | Latest quote: last price, bid, ask and spread || `uv run --env-file .env watchlist` | Watchlist quotes, refreshed every minute || `uv run --env-file .env history AAPL` | Daily bars since 2016, saved to `data/` and charted || `uv run returns AAPL` | Best and worst days, total return, compound annual growth, yearly returns || `uv run adjust` | Split detection and adjustment, and total return with dividends || `uv run --env-file .env intraday AAPL` | A trading day's 1-minute bars: day bar, VWAP and volume by hour | Each command takes `--help`. ## Development ```uv run ruff formatuv run ruff checkuv run mypyuv run pytest```
    Line 1
    The page’s title: a heading, from the # in front of it.
    Lines 6 to 8
    Where the data comes from, and why none of it is in the repository.
    Lines 10 to 17
    A smaller heading, ##, then a block of code between lines of three backticks.
    Line 20
    Until the project reads .env itself, later today, the commands that ask Alpaca need --env-file .env.
    Lines 24 to 31
    A table: a row of headers, a row of dashes, then a row for each command.
    git add README.mdgit commit -m "Add a README"[main 635f535] Add a README 1 file changed, 42 insertions(+)
  4. Step 4 Put it on GitHub

    Make a public repository from the project, and push to it:

    gh repo create market-data --public --source . --push

    --source . makes the repository from this folder, and --push sends your commits up. gh adds the new repository as the remote called origin, and prints its address. Check the remote:

    git remote -vorigin	https://github.com/you/market-data.git (fetch)origin	https://github.com/you/market-data.git (push)

    Your username is in place of “you”. Open the address in your browser: the README is the front page, and every commit is there.

  5. Step 5 Check the types

    Add mypy, and pandas-stubs, which gives mypy the types of pandas’ functions:

    uv add --dev mypy pandas-stubsResolved 34 packages in 197ms   Building market-data @ file:///home/you/market-data      Built market-data @ file:///home/you/market-dataDownloading ast-serialize (1.3MiB)Downloading mypy (14.7MiB) Downloaded ast-serialize Downloaded mypyPrepared 7 packages in 711msUninstalled 1 package in 0.56msInstalled 7 packages in 55ms + ast-serialize==0.12.1 + librt==0.16.0 ~ market-data==0.1.0 (from file:///home/you/market-data) + mypy==2.4.0 + mypy-extensions==1.1.0 + pandas-stubs==3.0.5.260914 + pathspec==1.1.1uv run mypy   Building market-data @ file:///home/you/market-data      Built market-data @ file:///home/you/market-dataUninstalled 1 package in 0.83msInstalled 1 package in 2msSuccess: no issues found in 9 source files

    Then tell mypy what to check, at the end of pyproject.toml:

    pyproject.toml
    [tool.mypy]files = ["src"]strict = trueuntyped_calls_exclude = ["matplotlib"]
    Line 2
    Check the package. The tests are plain checks of the package, so they aren’t held to the same rules.
    Line 3
    Every function must have hints, and every hint must hold.
    Line 4
    matplotlib has no hints for some of its functions. This lets your code call them.

    The run above printed Success: every hint you wrote on Day 3 holds.

  6. Step 6 Check every push

    Make .github/workflows/check.yml, with its two folders:

    .github/workflows/check.yml
    # Check every push: formatting, lint, types and tests.name: Check on: [push, pull_request] jobs:  check:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v7      - uses: astral-sh/setup-uv@v10.2.0      - run: uv run ruff format --check      - run: uv run ruff check      - run: uv run mypy      - run: uv run pytest
    Line 2
    The workflow’s name, as GitHub lists it.
    Line 4
    When it runs: on every push, and on every pull request.
    Line 8
    A fresh Ubuntu Linux machine, made for this run and thrown away after it.
    Lines 10 to 11
    Ready-made steps, called actions: one copies your repository onto the machine, the other installs uv. Each is pinned to a version.
    Lines 12 to 15
    Your checks: formatting, lint, types and tests. --check makes ruff format report a file it would change instead of changing it. If any step fails, the run fails.
    git add .git commit -m "Type-check with mypy, and run every check on each push"[main fa14b36] Type-check with mypy, and run every check on each push 3 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/check.yml
    git push

    Open your repository on GitHub and choose Actions: the Check workflow runs, and in a minute or two a green tick appears beside your commit.

Session 2 PostgreSQL and a price recorder

The idea

Step 1 Why a database

A database keeps data in tables, and answers questions about it in SQL.A file per symboldata/AAPL.csvdata/SPY.csvone program at a timeone row can be saved twicePostgreSQLone table of pricesmany programs at oncequestions answered in SQLa key that stops duplicatesPostgreSQL is a free database server, used by banks and start-ups alike.It runs in Docker: a container, a small separate computer inside yours that runs one program.Its password lives in .env, a file Git never saves, so it never reaches GitHub.
01/03

A database keeps data in tables and answers questions about it. Many programs can read and write it at once, and rules, such as which column is unique, keep the data in order whoever writes it.

PostgreSQL is a free database server, among the most used in the world. You ask it questions in SQL, Structured Query Language, by sending SQL from Python with the psycopg package.

It runs in Docker. Docker runs a container: a small, separate computer inside yours, made from an image, here PostgreSQL 18’s. Nothing it installs touches the rest of your computer, and the same image runs the same way everywhere.

The database needs a password, and code that goes on GitHub must not hold one. So the password goes in .env, beside your Alpaca keys, and the code reads every setting through a settings class: one place that names every setting the project needs, checks each is set, and reads them from the environment or from .env. Commands then find your keys themselves, without --env-file.

Practice

Problem 2

2 points

The recorder saves 2,705 days for each of 2 symbols. The next day it runs again, when each symbol has 1 more day. With the primary key and the upsert, how many rows does prices hold?

Hint 1

The upsert updates a day that is already saved, so no day is saved twice.

Hint 2

Each symbol now has 2,705 + 1 days.

Solution

days for each symbol = 2,705 + 1 = 2,706

rows = 2,706 × 2 = 5,412

Which query finds AAPL’s highest close?

Show the answer

A: SELECT max(close) FROM prices WHERE symbol = 'AAPL' WHERE keeps only AAPL’s rows, and max(close) is the largest close among them.

Why does the database’s password go in .env rather than in db.py?

Show the answer

B: db.py goes on GitHub for anyone to read, and .env never leaves your computer .gitignore names .env, so Git never saves it. Anything in db.py is in every copy of the repository, including GitHub’s.

The project, step by step

Build it yourself from this brief, then check it against the steps.

  • Install Docker, and start PostgreSQL 18 in a container named market-db, with a password you choose and a database called market.
  • Add psycopg and pydantic-settings. Add the database’s address, password included, to .env as DATABASE_URL, and to .env.example with a placeholder in place of the password.
  • Write config.py: a settings class with your two Alpaca keys, the secret one as a SecretStr, and database_url, read once from the environment or .env. Make alpaca.py read its keys from the settings, and drop --env-file .env from the commands’ usage lines.
  • Write db.py: a function that connects with the settings’ address, and one that applies each migration in src/market_data/migrations/ not yet applied, noting each in a schema_migrations table. Write the first migration, 001_prices.sql: a prices table (symbol, day, open, high, low, close, volume) whose primary key is the symbol and the day. Add a migrate command.
  • Write recorder.py. For each symbol given, or the watchlist’s from watchlist.py if none, it downloads the history and upserts every day into prices, then prints each symbol’s number of days with its first and last. Running it twice must not add rows.
  • Write report.py: each symbol’s latest close, and the five best days across all symbols, worked out in SQL with lag().
  1. Step 1 Install Docker

    On Windows or macOS, install Docker Desktop from docker.com and start it; on Linux, install Docker Engine for your system from docs.docker.com. Docker Desktop must be running whenever you use the database.

  2. Step 2 Start PostgreSQL

    Choose a password, and put it in place of choose-a-password:

    docker run --name market-db -e POSTGRES_PASSWORD=choose-a-password -e POSTGRES_DB=market -p 5432:5432 -v market-db:/var/lib/postgresql -d postgres:18docker ps
    PartWhat it does
    --name market-dbNames the container, to start or stop it by name
    -e POSTGRES_PASSWORD=…Sets the database’s password
    -e POSTGRES_DB=marketMakes a database called market
    -p 5432:5432Lets programs on your computer reach PostgreSQL’s port, 5432
    -v market-db:/var/lib/postgresqlKeeps the data in a volume, market-db, so it survives the container
    -d postgres:18Runs PostgreSQL 18’s image in the background

    The first time, Docker downloads the image, then prints the new container’s id. docker ps lists the containers running: market-db should be there. After your computer restarts, start it again with docker start market-db.

  3. Step 3 Add psycopg and pydantic-settings

    psycopg talks to PostgreSQL from Python. pydantic-settings reads settings from the environment and from .env, and checks them. [binary] installs psycopg with PostgreSQL’s client library built in:

    uv add "psycopg[binary]" pydantic-settingsResolved 42 packages in 405ms   Building market-data @ file:///home/you/market-data      Built market-data @ file:///home/you/market-dataDownloading psycopg-binary (5.0MiB)Downloading pydantic-core (2.0MiB) Downloaded pydantic-core Downloaded psycopg-binaryPrepared 9 packages in 324msUninstalled 1 package in 0.57msInstalled 9 packages in 10ms + annotated-types==0.8.0 ~ market-data==0.1.0 (from file:///home/you/market-data) + psycopg==3.3.6 + psycopg-binary==3.3.6 + pydantic==2.13.5 + pydantic-core==2.46.5 + pydantic-settings==2.15.0 + python-dotenv==1.2.4 + typing-inspection==0.4.4
  4. Step 4 Add the database to .env

    Add a line to .env with your password in the address:

    .env
    APCA_API_KEY_ID=your-key-idAPCA_API_SECRET_KEY=your-secret-keyDATABASE_URL=postgresql://postgres:choose-a-password@localhost:5432/market

    The address names the kind of database, the user and password, the server and port, and the database: postgresql://user:password@host:port/database.

    Add it to .env.example too, with a placeholder for the password, so anyone who clones the project knows which settings to set:

    .env.example
    # Copy to .env and set your own values. .env is in .gitignore; this file is not.APCA_API_KEY_ID=your-key-idAPCA_API_SECRET_KEY=your-secret-keyDATABASE_URL=postgresql://postgres:your-password@localhost:5432/market

    Check what Git sees:

    git status --short M .env.example M pyproject.toml M uv.lock

    .env isn’t listed: .gitignore has named it since Day 1, so it will never be committed or pushed. .env.example is, ready to commit.

  5. Step 5 Settings

    Make src/market_data/config.py:

    src/market_data/config.py
    """Settings that differ between computers, read from the environment or .env. Every field without a default must be set, or the first command that needs a settingstops with a message naming the missing ones. .env.example lists them.""" from functools import lru_cache from pydantic import SecretStrfrom pydantic_settings import BaseSettings, SettingsConfigDict  class Settings(BaseSettings):    model_config = SettingsConfigDict(env_file=".env")     apca_api_key_id: str    # A secret: printing the settings shows it as **********.    apca_api_secret_key: SecretStr    database_url: str  @lru_cachedef get_settings() -> Settings:    """The settings, read once."""    return Settings()
    Lines 13 to 19
    Each field is a setting, read from the environment variable of the same name in capitals, such as DATABASE_URL, or from .env. A field with no default must be set: if it isn’t, the first command that reads the settings stops with a message naming it.
    Line 14
    Read .env too, from the folder the command runs in.
    Line 18
    SecretStr, from pydantic, holds the secret key so that printing the settings, or an error that shows them, writes ********** in its place. get_secret_value() gives the key itself, only where it is needed.
    Lines 22 to 25
    lru_cache keeps the function’s first answer and returns it every time after, so the settings are read once, the first time they are needed.

    Make alpaca.py read your keys from the settings:

    src/market_data/alpaca.py
    """Alpaca's market data: snapshots, daily bars and 1-minute bars. Snapshots are 15 minutes behind the market and cover every US exchange. The freeplan has bars since 2016, up to 15 minutes ago. The data is for personal use.""" from datetime import date, datetime, time, timedeltafrom functools import lru_cachefrom typing import Anyfrom zoneinfo import ZoneInfo import httpx2 from market_data.config import get_settings BASE_URL = "https://data.alpaca.markets"# The market's time zone. Alpaca gives every time in UTC.NEW_YORK = ZoneInfo("America/New_York")# The free plan's full-market data ends 15 minutes before now.DELAY = timedelta(minutes=15)  @lru_cachedef client() -> httpx2.Client:    """One client for every request to Alpaca, made the first time it is needed:    it keeps its connection open between requests and sends your keys with each."""    settings = get_settings()    keys = {        "APCA-API-KEY-ID": settings.apca_api_key_id,        "APCA-API-SECRET-KEY": settings.apca_api_secret_key.get_secret_value(),    }    return httpx2.Client(base_url=BASE_URL, headers=keys, timeout=10)  def get(path: str, params: dict[str, Any]) -> Any:    """GET a path with its query and return the JSON."""    response = client().get(path, params=params)    response.raise_for_status()    return response.json()  def snapshots(symbols: list[str]) -> dict[str, Any]:    """Each symbol's latest trade, quote and daily bars, 15 minutes behind."""    query = {"symbols": ",".join(symbols), "feed": "delayed_sip"}    found: dict[str, Any] = get("/v2/stocks/snapshots", query)    return found  def bars(symbol: str, query: dict[str, Any]) -> list[dict[str, Any]]:    """A symbol's bars, oldest first, following Alpaca's pages to the last."""    query = {"symbols": symbol, "feed": "sip", "limit": 10000, **query}    found: list[dict[str, Any]] = []    while True:        page = get("/v2/stocks/bars", query)        found += page["bars"].get(symbol, [])        if not page["next_page_token"]:            return found        query["page_token"] = page["next_page_token"]  def day_of(timestamp: str) -> date:    """The New York date of one of Alpaca's UTC times."""    return datetime.fromisoformat(timestamp).astimezone(NEW_YORK).date()  def daily_bars(symbol: str) -> list[dict[str, Any]]:    """Every trading day's bar since 2016, adjusted for splits."""    query = {"timeframe": "1Day", "start": "2016-01-01", "adjustment": "split"}    return bars(symbol, query)  def latest_day(symbol: str) -> date | None:    """The latest day the symbol traded: today once it has, or the last day it did.    None if Alpaca has no such symbol."""    snapshot = snapshots([symbol]).get(symbol)    return day_of(snapshot["dailyBar"]["t"]) if snapshot else None  def minute_bars(symbol: str, day: date) -> list[dict[str, Any]]:    """A day's 1-minute bars, from 09:30 New York time to the last minute before    16:00, or to 15 minutes ago if that is sooner. Each is labelled by its start."""    opening = datetime.combine(day, time(9, 30), NEW_YORK)    last = datetime.combine(day, time(15, 59), NEW_YORK)    end = min(last, datetime.now(NEW_YORK) - DELAY)    # A day still to come has no minutes yet, and Alpaca refuses to be asked for them.    if end < opening:        return []    query = {"timeframe": "1Min", "start": opening.isoformat(), "end": end.isoformat()}    return bars(symbol, query)
    Line 14
    The settings, from config.py.
    Lines 27 to 30
    The keys come from the settings, which read .env themselves, so a command no longer needs --env-file .env. os isn’t needed any more.

    The four commands that ask Alpaca say how to run them in their docstrings. In each, the usage line loses --env-file .env:

    Show quote.py, watchlist.py, history.py and intraday.py
    src/market_data/quote.py
    """Print a symbol's latest quote: last price, bid, ask and spread. Usage:     uv run quote AAPL""" import argparseimport sysfrom typing import Any from market_data import alpaca  def describe(symbol: str, snapshot: dict[str, Any]) -> str:    """A quote on one line: the latest trade's price, the bid, the ask and the spread."""    last = snapshot["latestTrade"]["p"]    bid = snapshot["latestQuote"]["bp"]    ask = snapshot["latestQuote"]["ap"]    return (        f"{symbol}  last {last:.2f}  bid {bid:.2f}  ask {ask:.2f}  "        f"spread {ask - bid:.2f}"    )  def main() -> None:    parser = argparse.ArgumentParser(description="Print a symbol's latest quote.")    parser.add_argument(        "symbol", nargs="?", default="AAPL", help="ticker symbol (default: AAPL)"    )    symbol = parser.parse_args().symbol    snapshots = alpaca.snapshots([symbol])    if symbol not in snapshots:        sys.exit(f"No quote for {symbol}: check the ticker.")    print(describe(symbol, snapshots[symbol]))  if __name__ == "__main__":    main()
    src/market_data/watchlist.py
    """The watchlist, and a table of its quotes refreshed every minute. Usage:     uv run watchlist Press Ctrl+C to stop.""" import argparseimport timefrom datetime import datetimefrom typing import Any from market_data import alpaca # Every command that works on the watchlist reads it from here.SYMBOLS = ["AAPL", "MSFT", "NVDA", "SPY", "QQQ"]  def row(symbol: str, snapshot: dict[str, Any]) -> str:    """One row: symbol, last price, and change since the previous close."""    last = snapshot["latestTrade"]["p"]    previous = snapshot["prevDailyBar"]["c"]    change = last - previous    return f"{symbol:<6} {last:>10.2f} {change:>+8.2f} {change / previous:>+8.2%}"  def show(symbols: list[str]) -> None:    """The New York time, a header, and a row for each symbol, from one request."""    snapshots = alpaca.snapshots(symbols)    print(f"\nQuotes at {datetime.now(alpaca.NEW_YORK):%H:%M:%S} New York time")    print(f"{'symbol':<6} {'last':>10} {'change':>8} {'%':>8}")    for symbol in symbols:        print(row(symbol, snapshots[symbol]))  def main() -> None:    parser = argparse.ArgumentParser(        description="Show the watchlist's quotes, refreshed every minute."    )    parser.add_argument(        "--every", type=int, default=60, help="seconds between refreshes (default: 60)"    )    args = parser.parse_args()    try:        while True:            show(SYMBOLS)            time.sleep(args.every)    except KeyboardInterrupt:        print("\nStopped.")  if __name__ == "__main__":    main()
    src/market_data/history.py
    """Download a symbol's daily bars since 2016, save them, and chart the close. Usage:     uv run history AAPL""" import argparseimport sys import matplotlib.pyplot as pltimport pandas as pdfrom matplotlib import ticker from market_data import alpacafrom market_data.files import CHARTS, DATA # Alpaca's short names for a bar's fields, and the names this project uses.COLUMNS = {"o": "open", "h": "high", "l": "low", "c": "close", "v": "volume"}  def get_history(symbol: str) -> pd.DataFrame:    """Every trading day's open, high, low, close and volume since 2016, adjusted    for splits, oldest first, dated."""    bars = alpaca.daily_bars(symbol)    prices = pd.DataFrame(bars, columns=list(COLUMNS)).rename(columns=COLUMNS)    prices.index = pd.DatetimeIndex(        [alpaca.day_of(bar["t"]) for bar in bars], name="date"    )    return prices  def chart(prices: pd.DataFrame, symbol: str, path: str) -> None:    """The daily close on a log scale, saved as a PNG."""    fig, ax = plt.subplots(figsize=(10, 5))    ax.plot(prices.index, prices["close"], linewidth=1)    ax.set_yscale("log")    ax.yaxis.set_major_locator(ticker.LogLocator(subs=[1, 2, 5]))    ax.yaxis.set_major_formatter(ticker.StrMethodFormatter("{x:,g}"))    ax.yaxis.set_minor_formatter(ticker.NullFormatter())    ax.grid(alpha=0.3)    ax.set_title(f"{symbol} daily close")    ax.set_ylabel("USD, log scale")    fig.savefig(path, dpi=120, bbox_inches="tight")  def main() -> None:    parser = argparse.ArgumentParser(        description="Download, save and chart a symbol's daily bars."    )    parser.add_argument(        "symbol", nargs="?", default="AAPL", help="ticker symbol (default: AAPL)"    )    symbol = parser.parse_args().symbol    prices = get_history(symbol)    if prices.empty:        sys.exit(f"No prices for {symbol}: check the ticker.")    print(prices.tail())    print(        f"\n{len(prices):,} days, {prices.index[0]:%d %b %Y} to {prices.index[-1]:%d %b %Y}"    )    best = prices["close"].idxmax()    print(f"highest close: {prices['close'].max():.2f} on {best:%d %b %Y}")    DATA.mkdir(exist_ok=True)    CHARTS.mkdir(exist_ok=True)    prices.to_csv(DATA / f"{symbol}.csv")    chart(prices, symbol, str(CHARTS / f"{symbol}.png"))    print(f"saved {DATA / symbol}.csv and {CHARTS / symbol}.png")  if __name__ == "__main__":    main()
    src/market_data/intraday.py
    """A trading day's 1-minute bars: the day's bar, its VWAP and volume by hour. Usage:     uv run intraday AAPL    uv run intraday AAPL --day 2026-10-05 With no day, it shows the latest day the symbol traded.""" import argparseimport sysfrom datetime import date import matplotlib.dates as mdatesimport matplotlib.pyplot as pltimport pandas as pdfrom matplotlib import ticker from market_data import alpacafrom market_data.files import CHARTS, DATA # Alpaca's short names for a bar's fields, and the names this project uses.COLUMNS = {    "o": "open",    "h": "high",    "l": "low",    "c": "close",    "v": "volume",    "vw": "vwap",}  def get_minutes(symbol: str, day: date | None = None) -> pd.DataFrame:    """A day's 1-minute bars, oldest first, labelled by their start in New York    time: the latest day the symbol traded, if no day is given. Empty if Alpaca has    no such symbol, or the market wasn't open that day."""    day = day or alpaca.latest_day(symbol)    bars = alpaca.minute_bars(symbol, day) if day else []    minutes = pd.DataFrame(bars, columns=list(COLUMNS)).rename(columns=COLUMNS)    times = pd.to_datetime([bar["t"] for bar in bars], utc=True)    minutes.index = times.tz_convert(alpaca.NEW_YORK).rename("time")    return minutes  def day_bar(minutes: pd.DataFrame) -> dict[str, float]:    """The day as one bar, built from its minutes."""    return {        "open": float(minutes["open"].iloc[0]),        "high": float(minutes["high"].max()),        "low": float(minutes["low"].min()),        "close": float(minutes["close"].iloc[-1]),        "volume": float(minutes["volume"].sum()),    }  def vwap(minutes: pd.DataFrame) -> float:    """The day's volume-weighted average price, from each minute's own."""    traded = (minutes["vwap"] * minutes["volume"]).sum()    return float(traded / minutes["volume"].sum())  def volume_by_hour(minutes: pd.DataFrame) -> pd.Series:    """Shares traded in each clock hour. Hour 9 is the first half hour, 9:30 to 10:00."""    return minutes["volume"].groupby(pd.DatetimeIndex(minutes.index).hour).sum()  def chart(minutes: pd.DataFrame, symbol: str, path: str) -> None:    """Price and VWAP above, volume below, saved as a PNG."""    fig, (top, bottom) = plt.subplots(        2, 1, figsize=(10, 6), sharex=True, height_ratios=[3, 1]    )    top.plot(minutes.index, minutes["close"], linewidth=1, label="price")    top.axhline(vwap(minutes), color="tab:orange", linestyle="--", label="VWAP")    top.set_title(f"{symbol} 1-minute bars, {minutes.index[0]:%d %b %Y}")    top.set_ylabel("USD")    top.legend()    bottom.bar(minutes.index, minutes["volume"], width=1 / (24 * 60), color="gray")    bottom.set_ylabel("shares")    bottom.yaxis.set_major_formatter(ticker.StrMethodFormatter("{x:,.0f}"))    bottom.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M", tz=alpaca.NEW_YORK))    fig.savefig(path, dpi=120, bbox_inches="tight")  def main() -> None:    parser = argparse.ArgumentParser(        description="Summarise and chart a trading day's 1-minute bars."    )    parser.add_argument(        "symbol", nargs="?", default="AAPL", help="ticker symbol (default: AAPL)"    )    parser.add_argument(        "--day", type=date.fromisoformat, help="YYYY-MM-DD (default: the latest)"    )    args = parser.parse_args()    symbol = args.symbol    minutes = get_minutes(symbol, args.day)    if minutes.empty:        sys.exit(f"No minutes for {symbol} that day: check the ticker and the day.")    first, last = minutes.index[0], minutes.index[-1]    print(        f"{len(minutes)} minutes on {first:%d %b %Y}, {first:%H:%M} to {last:%H:%M}\n"    )    bar = day_bar(minutes)    for field in ("open", "high", "low", "close"):        print(f"{field:<8}{bar[field]:.2f}")    print(f"{'volume':<8}{bar['volume']:,.0f}")    print(f"{'VWAP':<8}{vwap(minutes):.2f}")    hours = volume_by_hour(minutes)    print("\nhour      shares")    for hour, shares in hours.items():        print(f"{hour:>4}{shares:>12,}  {'#' * round(30 * shares / hours.max())}")    DATA.mkdir(exist_ok=True)    CHARTS.mkdir(exist_ok=True)    day = f"{symbol}-{first:%Y-%m-%d}"    minutes.to_csv(DATA / f"{day}.csv")    chart(minutes, symbol, str(CHARTS / f"{day}.png"))    print(f"\nsaved {DATA / day}.csv and {CHARTS / day}.png")  if __name__ == "__main__":    main()

    mypy needs pydantic’s plugin to know that Settings fills its fields from the environment. Add it to [tool.mypy] in pyproject.toml:

    pyproject.toml
    [tool.mypy]files = ["src"]strict = trueplugins = ["pydantic.mypy"]untyped_calls_exclude = ["matplotlib"]

    Run a command the new way:

    uv run quote AAPLAAPL  last 330.27  bid 330.25  ask 330.30  spread 0.05

    Shown for the course’s sample data. Yours shows the latest.

  6. Step 6 Connect, and migrate

    Make src/market_data/db.py:

    src/market_data/db.py
    """The PostgreSQL database: connections, and migrations that keep its tables up to date. Usage:     uv run migrate""" from importlib.resources import files import psycopg from market_data.config import get_settings MIGRATIONS = files("market_data") / "migrations" APPLIED = """CREATE TABLE IF NOT EXISTS schema_migrations (    version text PRIMARY KEY,    applied_at timestamptz NOT NULL DEFAULT now())"""  def connect() -> psycopg.Connection:    """A connection to the database at DATABASE_URL."""    return psycopg.connect(get_settings().database_url)  def migrate(conn: psycopg.Connection) -> list[str]:    """Apply each migration not yet applied, in order; return the ones applied."""    conn.execute(APPLIED)    done = {        version for (version,) in conn.execute("SELECT version FROM schema_migrations")    }    applied = []    for script in sorted(MIGRATIONS.iterdir(), key=lambda path: path.name):        version = script.name.removesuffix(".sql")        if script.name.endswith(".sql") and version not in done:            conn.execute(script.read_text())            conn.execute(                "INSERT INTO schema_migrations (version) VALUES (%s)", (version,)            )            applied.append(version)    return applied  def main() -> None:    with connect() as conn:        applied = migrate(conn)    print("\n".join(f"applied {version}" for version in applied) or "up to date")  if __name__ == "__main__":    main()
    Line 14
    The migrations folder inside the package. files from importlib.resources finds it wherever the package is installed.
    Lines 16 to 19
    A table of the migrations applied, each with when. IF NOT EXISTS makes it the first time only.
    Lines 24 to 26
    Open a connection to the database at the settings’ address.
    Lines 29 to 44
    Apply each SQL file in the folder, in the order of their names, that the table doesn’t list yet, and list it. removesuffix(".sql") turns 001_prices.sql into its version, 001_prices.
    Lines 47 to 50
    Migrate, and print what was applied, or that the database is up to date. with connect() as conn commits when the block ends, so either every change is saved or none is.

    Make the folder src/market_data/migrations, and in it the first migration, 001_prices.sql:

    src/market_data/migrations/001_prices.sql
    CREATE TABLE prices (    symbol text NOT NULL,    day date NOT NULL,    open numeric(12, 4) NOT NULL,    high numeric(12, 4) NOT NULL,    low numeric(12, 4) NOT NULL,    close numeric(12, 4) NOT NULL,    volume bigint NOT NULL,    PRIMARY KEY (symbol, day));

    A table in SQL: each column with its type. NOT NULL means every row must have the value, and the primary key is the symbol and the day. Add migrate, the recorder and the report to the commands in pyproject.toml, so its [project.scripts] section reads:

    pyproject.toml
    [project.scripts]quote = "market_data.quote:main"watchlist = "market_data.watchlist:main"history = "market_data.history:main"returns = "market_data.returns:main"adjust = "market_data.adjust:main"intraday = "market_data.intraday:main"migrate = "market_data.db:main"recorder = "market_data.recorder:main"report = "market_data.report:main"
    uv run migrateapplied 001_prices

    Shown for the course’s sample data. Yours shows the latest.

    Run it again, and it prints up to date: each migration is applied once.

  7. Step 7 Record the prices

    Make src/market_data/recorder.py:

    src/market_data/recorder.py
    """Load daily bars into the database, then summarise what it holds. Usage:     uv run recorder AAPL SPY With no symbols, it loads the watchlist.""" import argparse import psycopg from market_data.db import connectfrom market_data.history import get_historyfrom market_data.watchlist import SYMBOLS UPSERT = """INSERT INTO prices (symbol, day, open, high, low, close, volume)VALUES (%s, %s, %s, %s, %s, %s, %s)ON CONFLICT (symbol, day) DO UPDATE SET    open = EXCLUDED.open,    high = EXCLUDED.high,    low = EXCLUDED.low,    close = EXCLUDED.close,    volume = EXCLUDED.volume""" SUMMARY = """SELECT symbol, count(*), min(day), max(day)FROM pricesGROUP BY symbolORDER BY symbol"""  def record(conn: psycopg.Connection, symbol: str) -> int:    """Upsert a symbol's full history; return the number of days."""    prices = get_history(symbol).reset_index().assign(symbol=symbol)    rows = prices[["symbol", "date", "open", "high", "low", "close", "volume"]]    with conn.cursor() as cur:        cur.executemany(UPSERT, rows.itertuples(index=False, name=None))    return len(prices)  def main() -> None:    parser = argparse.ArgumentParser(description="Load daily bars into the database.")    parser.add_argument(        "symbols",        nargs="*",        default=SYMBOLS,        help="ticker symbols (default: the watchlist)",    )    args = parser.parse_args()    with connect() as conn:        for symbol in args.symbols:            print(f"{symbol}: {record(conn, symbol):,} days saved")        print("\nsymbol      days  first       last")        for symbol, days, first, last in conn.execute(SUMMARY):            print(f"{symbol:<6}{days:>10,}  {first}  {last}")  if __name__ == "__main__":    main()
    Lines 18 to 26
    The upsert. Each %s is a placeholder that psycopg fills with a value, in order; EXCLUDED is the row that was refused, so its values replace the old ones.
    Lines 29 to 33
    Each symbol’s number of rows, first day and last day.
    Lines 37 to 43
    reset_index() turns the dates back into a column, and assign adds the symbol to every row. Picking the columns in the table’s order and itertuples(index=False, name=None) gives a tuple of values for each row, and executemany runs the upsert once for each, in one batch.
    Lines 46 to 60
    Read the symbols, the watchlist’s if none are given, record each one, then print the summary. with connect() as conn commits what was saved when the block ends.
    uv run recorder AAPL SPYAAPL: 2,705 days savedSPY: 2,705 days saved symbol      days  first       lastAAPL       2,705  2016-01-04  2026-10-06SPY        2,705  2016-01-04  2026-10-06

    Shown for the course’s sample data. Yours shows the latest.

    Run it again for AAPL:

    uv run recorder AAPLAAPL: 2,705 days saved symbol      days  first       lastAAPL       2,705  2016-01-04  2026-10-06SPY        2,705  2016-01-04  2026-10-06

    Shown for the course’s sample data. Yours shows the latest.

    Still 2,705 rows for AAPL: each day was updated, not saved twice.

  8. Step 8 Ask it questions

    Make src/market_data/report.py:

    src/market_data/report.py
    """Questions for the price database, answered in SQL. Usage:     uv run report""" from market_data.db import connect LATEST = """SELECT symbol, day, closeFROM pricesWHERE day = (SELECT max(day) FROM prices)ORDER BY symbol""" BEST_DAYS = """SELECT symbol, day, close / lag(close) OVER (PARTITION BY symbol ORDER BY day) - 1 AS rFROM pricesORDER BY r DESC NULLS LASTLIMIT 5"""  def main() -> None:    with connect() as conn:        print("Latest close")        for symbol, day, close in conn.execute(LATEST):            print(f"  {symbol:<6}{day}  {close:>10.2f}")        print("\nFive best days")        for symbol, day, r in conn.execute(BEST_DAYS):            print(f"  {symbol:<6}{day}  {r:>+8.2%}")  if __name__ == "__main__":    main()
    Lines 10 to 14
    The rows whose day is the latest day of all, found by a query inside the query.
    Lines 17 to 21
    Each day’s return, close ÷ the day before’s − 1, from lag(). The first day has no day before it, so its return is NULL, SQL’s missing value, and NULLS LAST puts those at the end. LIMIT 5 keeps the five best.
    Lines 25 to 32
    Run each query and print its rows. psycopg returns a numeric as Python’s exact Decimal, which formats like a float.
    uv run reportLatest close  AAPL  2026-10-06      330.27  SPY   2026-10-06      771.53 Five best days  AAPL  2020-03-10   +11.76%  SPY   2020-03-10    +9.42%  AAPL  2022-11-22    +8.37%  AAPL  2022-11-10    +8.33%  AAPL  2020-04-07    +6.71%

    Shown for the course’s sample data. Yours shows the latest.

    The best day is the one returns.py found on Day 2, worked out a second way. 4 of the 5 are Apple’s: one company’s price moves more than the whole market’s.

  9. Step 9 Commit your work

    uv run mypy   Building market-data @ file:///home/you/market-data      Built market-data @ file:///home/you/market-dataUninstalled 1 package in 0.67msInstalled 1 package in 2msSuccess: no issues found in 13 source filesgit status --short M .env.example M pyproject.toml M src/market_data/alpaca.py M src/market_data/history.py M src/market_data/intraday.py M src/market_data/quote.py M src/market_data/watchlist.py M uv.lock?? src/market_data/config.py?? src/market_data/db.py?? src/market_data/migrations/?? src/market_data/recorder.py?? src/market_data/report.pygit add .git commit -m "Store daily prices in PostgreSQL, with migrations"[main affb6e9] Store daily prices in PostgreSQL, with migrations 13 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 src/market_data/config.py create mode 100644 src/market_data/db.py create mode 100644 src/market_data/migrations/001_prices.sql create mode 100644 src/market_data/recorder.py create mode 100644 src/market_data/report.py

    mypy checks the new modules first. Neither .env nor any price is in the commit; .env.example is.

Session 3 Your watchlist’s SEC filings

The idea

Step 1 What companies file

Every company whose shares trade in the US reports to the SEC, the markets’ regulator.EDGAR, the SEC’s system, publishes every filing, free, for anyone to read.10-Kthe annual report: the year’s audited results10-Qthe quarterly report, for the year’s first three quarters8-Knews that can’t wait: results, a new chief executive, a deal4an insider, such as a director, bought or sold sharesApple’s year ended on 2025-09-27; its 10-K was filed on 2025-10-31.The largest companies must file a 10-K within 60 days of the year’s end, and an 8-K within 4 business days.Traders read 8-Ks the minute they appear: an 8-K with item 2.02 is a company’s results.
01/03

Every company whose shares trade in the US must report to the SEC, the Securities and Exchange Commission, which regulates the markets. EDGAR, the SEC’s system, publishes every filing, free, for anyone to read.

FormWhat it isWhen
10-KThe annual report: the year’s audited results, the business and its risksWithin 60 days of the year’s end, for the largest companies
10-QThe quarterly report, for each of the year’s first three quartersWithin 40 days of the quarter’s end, for the largest companies
8-KNews that can’t wait: results, a deal, a change of officersWithin 4 business days of the event
4An insider, such as a director, bought or sold sharesWithin 2 business days

An 8-K lists the items it is about, by number: 2.02 is results, 5.02 a change of directors or officers, 8.01 other news. Traders read them the minute they appear.

Practice

Problem 3

2 points

Apple’s financial year ended on 27 September 2025, and it filed that year’s 10-K on 31 October 2025. How many days after the year’s end was that?

Hint 1

Count the days left in September after the 27th, then the days into October.

Hint 2

September has 30 days.

Solution

days left in September after the 27th: 30 − 27 = 3

days into October: 31

total = 3 + 31 = 34 days

well inside the 60 days the largest companies are given

Which form is a company’s annual report?

Show the answer

B: 10-K The 10-K is the annual report. A 10-Q is quarterly, and an 8-K reports news as it happens.

Why does filings.py send a User-Agent with your name and email?

Show the answer

A: The SEC asks every program to say who is asking, so it can contact you EDGAR is free and needs no account, but the SEC asks every program to identify itself, and blocks those that don’t.

The project, step by step

Build it yourself from this brief, then check it against the steps.

  • Add SEC_USER_AGENT to .env, .env.example and the settings: your name and email, which the SEC asks every program to send.
  • Write sec.py, a client for the SEC like alpaca.py: one httpx2 client that sends your User-Agent, made the first time it is needed; ciks(), each ticker’s CIK from https://www.sec.gov/files/company_tickers.json; recent_filings(cik) from https://data.sec.gov/submissions/CIK0000320193.json, with the CIK in 10 digits; and document_url.
  • Add COMPANIES, the watchlist’s companies, to watchlist.py. Write filings.py: keep each company’s 10-Ks, 10-Qs and 8-Ks with their documents’ addresses, save them in a filings table keyed by accession number, made by migration 002_filings.sql, and print the ten newest, each described by the SEC’s names for its items.
  1. Step 1 Say who you are

    Add a line to .env with your own name and email:

    .env
    APCA_API_KEY_ID=your-key-idAPCA_API_SECRET_KEY=your-secret-keyDATABASE_URL=postgresql://postgres:choose-a-password@localhost:5432/marketSEC_USER_AGENT=Your Name [email protected]

    Add it to .env.example, with a placeholder:

    .env.example
    # Copy to .env and set your own values. .env is in .gitignore; this file is not.APCA_API_KEY_ID=your-key-idAPCA_API_SECRET_KEY=your-secret-keyDATABASE_URL=postgresql://postgres:your-password@localhost:5432/marketSEC_USER_AGENT=Your Name [email protected]

    And to the settings:

    src/market_data/config.py
    """Settings that differ between computers, read from the environment or .env. Every field without a default must be set, or the first command that needs a settingstops with a message naming the missing ones. .env.example lists them.""" from functools import lru_cache from pydantic import SecretStrfrom pydantic_settings import BaseSettings, SettingsConfigDict  class Settings(BaseSettings):    model_config = SettingsConfigDict(env_file=".env")     apca_api_key_id: str    # A secret: printing the settings shows it as **********.    apca_api_secret_key: SecretStr    database_url: str    sec_user_agent: str  @lru_cachedef get_settings() -> Settings:    """The settings, read once."""    return Settings()
  2. Step 2 A client for the SEC

    Make src/market_data/sec.py:

    src/market_data/sec.py
    """SEC EDGAR: company identifiers and recent filings. The SEC requires every request to name who is making it, in the User-Agent header.""" from functools import lru_cachefrom typing import Any import httpx2 from market_data.config import get_settings TICKERS_URL = "https://www.sec.gov/files/company_tickers.json"SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik:010d}.json"DOCUMENT_URL = "https://www.sec.gov/Archives/edgar/data/{cik}/{folder}/{document}"  @lru_cachedef client() -> httpx2.Client:    """A client that sends the User-Agent the SEC requires."""    headers = {"User-Agent": get_settings().sec_user_agent}    return httpx2.Client(headers=headers, follow_redirects=True, timeout=10)  def get(url: str) -> Any:    """GET a URL from the SEC and return its JSON."""    response = client().get(url)    response.raise_for_status()    return response.json()  def ciks() -> dict[str, int]:    """Each listed company's CIK, its SEC identifier, by ticker."""    rows: dict[str, dict[str, Any]] = get(TICKERS_URL)    return {row["ticker"]: row["cik_str"] for row in rows.values()}  def recent_filings(cik: int) -> dict[str, list[Any]]:    """A company's recent filings, as columns of equal length."""    submissions = get(SUBMISSIONS_URL.format(cik=cik))    recent: dict[str, list[Any]] = submissions["filings"]["recent"]    return recent  def document_url(cik: int, accession: str, document: str) -> str:    """The address of a filing's main document."""    folder = accession.replace("-", "")    return DOCUMENT_URL.format(cik=cik, folder=folder, document=document)
    Lines 13 to 15
    The SEC’s addresses: its list of every listed company’s ticker and CIK, a company’s submissions file by its 10-digit CIK, and one document.
    Lines 18 to 22
    One client for every request to the SEC, sending a User-Agent header that says who you are. A header is a line of information sent with a request. The client is made the first time it is needed, so importing sec.py doesn’t need the settings.
    Lines 25 to 29
    Ask the SEC for a file, stop on an error, and return its JSON.
    Lines 32 to 35
    The file is a dictionary of rows. A dictionary comprehension turns it into one from each ticker to its CIK.
    Lines 38 to 42
    A company’s recent filings, as lists side by side: forms, filing dates, and so on.
    Lines 45 to 48
    A document’s address: the CIK, the accession number without its dashes, and the document’s name.
  3. Step 3 Find each company’s CIK

    Add the watchlist’s companies to watchlist.py. SPY and QQQ are funds, which file no annual or quarterly reports:

    src/market_data/watchlist.py
    """The watchlist, and a table of its quotes refreshed every minute. Usage:     uv run watchlist Press Ctrl+C to stop.""" import argparseimport timefrom datetime import datetimefrom typing import Any from market_data import alpaca # Every command that works on the watchlist reads it from here.SYMBOLS = ["AAPL", "MSFT", "NVDA", "SPY", "QQQ"]# The symbols that file reports with the SEC. The funds don't.COMPANIES = ["AAPL", "MSFT", "NVDA"]  def row(symbol: str, snapshot: dict[str, Any]) -> str:    """One row: symbol, last price, and change since the previous close."""    last = snapshot["latestTrade"]["p"]    previous = snapshot["prevDailyBar"]["c"]    change = last - previous    return f"{symbol:<6} {last:>10.2f} {change:>+8.2f} {change / previous:>+8.2%}"  def show(symbols: list[str]) -> None:    """The New York time, a header, and a row for each symbol, from one request."""    snapshots = alpaca.snapshots(symbols)    print(f"\nQuotes at {datetime.now(alpaca.NEW_YORK):%H:%M:%S} New York time")    print(f"{'symbol':<6} {'last':>10} {'change':>8} {'%':>8}")    for symbol in symbols:        print(row(symbol, snapshots[symbol]))  def main() -> None:    parser = argparse.ArgumentParser(        description="Show the watchlist's quotes, refreshed every minute."    )    parser.add_argument(        "--every", type=int, default=60, help="seconds between refreshes (default: 60)"    )    args = parser.parse_args()    try:        while True:            show(SYMBOLS)            time.sleep(args.every)    except KeyboardInterrupt:        print("\nStopped.")  if __name__ == "__main__":    main()

    Make src/market_data/filings.py:

    src/market_data/filings.py
    """Load the watchlist's SEC filings into the database, and print the latest. Usage:     uv run filings""" from market_data import secfrom market_data.watchlist import COMPANIES  def main() -> None:    numbers = sec.ciks()    print(f"{len(numbers):,} companies")    for symbol in COMPANIES:        print(f"{symbol:<6}CIK {numbers[symbol]:010d}")  if __name__ == "__main__":    main()
    Lines 8 to 9
    The SEC client, and the companies.
    Lines 12 to 16
    Print how many companies the list has, and each company’s CIK, padded to 10 digits with zeros, :010d, as the SEC writes them.

    Add filings to the commands, so pyproject.toml’s [project.scripts] ends with it:

    pyproject.toml
    [project.scripts]quote = "market_data.quote:main"watchlist = "market_data.watchlist:main"history = "market_data.history:main"returns = "market_data.returns:main"adjust = "market_data.adjust:main"intraday = "market_data.intraday:main"migrate = "market_data.db:main"recorder = "market_data.recorder:main"report = "market_data.report:main"filings = "market_data.filings:main"
    uv run filings6 companiesAAPL  CIK 0000320193MSFT  CIK 0000789019NVDA  CIK 0001045810

    Shown for the course’s sample data. Yours shows the latest.

    The sample keeps six of the list’s companies. Yours lists every one, over ten thousand.

  4. Step 4 A company’s reports

    Replace filings.py with this:

    src/market_data/filings.py
    """Load the watchlist's SEC filings into the database, and print the latest. Usage:     uv run filings""" import pandas as pd from market_data import sec FORMS = ["10-K", "10-Q", "8-K"]  def reports(cik: int) -> pd.DataFrame:    """A company's recent 10-K, 10-Q and 8-K filings, newest first."""    filings = pd.DataFrame(sec.recent_filings(cik))    filings = filings[filings["form"].isin(FORMS)].copy()    filings["url"] = [        sec.document_url(cik, accession, document)        for accession, document in zip(            filings["accessionNumber"], filings["primaryDocument"]        )    ]    columns = ["accessionNumber", "form", "filingDate", "reportDate", "items", "url"]    return filings[columns]  def main() -> None:    cik = sec.ciks()["AAPL"]    apple = reports(cik)    print(f"AAPL, CIK {cik:010d}: {len(apple)} filings\n")    print(apple.head(8).drop(columns="url").to_string(index=False))    print(f"\n{apple['url'].iloc[0]}")  if __name__ == "__main__":    main()
    Line 12
    The forms to keep.
    Lines 15 to 17
    A company’s recent filings from the SEC client, as a table: each list becomes a column.
    Line 18
    isin is True for each row whose form is one of FORMS, and the brackets keep those rows. .copy() makes them a table of their own, ready for a new column.
    Lines 19 to 24
    Each document’s address, from sec.document_url. zip pairs each accession number with its document’s name.
    Lines 25 to 26
    Keep the columns the feed needs.
    Lines 29 to 34
    Print Apple’s eight newest filings without their addresses, then the newest one’s address.
    uv run filingsAAPL, CIK 0000320193: 36 filings      accessionNumber form filingDate reportDate     items0000320193-26-000020 10-Q 2026-07-31 2026-06-27          0000320193-26-000018  8-K 2026-07-30 2026-07-30 2.02,9.010000320193-26-000013 10-Q 2026-05-01 2026-03-28          0000320193-26-000011  8-K 2026-04-30 2026-04-30 2.02,9.010001140361-26-015711  8-K 2026-04-20 2026-04-17      5.020001140361-26-006577  8-K 2026-02-24 2026-02-24 5.07,9.010000320193-26-000006 10-Q 2026-01-30 2025-12-27          0000320193-26-000005  8-K 2026-01-29 2026-01-29 2.02,9.01 https://www.sec.gov/Archives/edgar/data/320193/000032019326000020/aapl-20260627.htm

    Shown for the course’s sample data. Yours shows the latest.

    The sample is the SEC’s own data, trimmed. Paste the address into your browser to read the report itself.

  5. Step 5 Save them, and read the feed

    Add the filings table, as the second migration, 002_filings.sql:

    src/market_data/migrations/002_filings.sql
    CREATE TABLE filings (    accession text PRIMARY KEY,    symbol text NOT NULL,    form text NOT NULL,    filed date NOT NULL,    period date NOT NULL,    items text NOT NULL,    url text NOT NULL);
    uv run migrateapplied 002_filings

    Shown for the course’s sample data. Yours shows the latest.

    migrate applied the new one and left 001 alone. Then finish filings.py:

    src/market_data/filings.py
    """Load the watchlist's SEC filings into the database, and print the latest. Usage:     uv run filings""" import pandas as pdimport psycopg from market_data import secfrom market_data.db import connectfrom market_data.watchlist import COMPANIES FORMS = ["10-K", "10-Q", "8-K"] # The SEC's names for the 8-K items this feed describes.ITEMS = {    "1.01": "Material agreement",    "2.02": "Results of operations",    "5.02": "Officer or director change",    "5.07": "Shareholder vote",    "7.01": "Regulation FD disclosure",    "8.01": "Other events",} UPSERT = """INSERT INTO filings (accession, symbol, form, filed, period, items, url)VALUES (%s, %s, %s, %s, %s, %s, %s)ON CONFLICT (accession) DO NOTHING""" FEED = """SELECT filed, symbol, form, itemsFROM filingsORDER BY filed DESC, symbol, accession DESCLIMIT 10"""  def reports(cik: int) -> pd.DataFrame:    """A company's recent 10-K, 10-Q and 8-K filings, newest first."""    filings = pd.DataFrame(sec.recent_filings(cik))    filings = filings[filings["form"].isin(FORMS)].copy()    filings["url"] = [        sec.document_url(cik, accession, document)        for accession, document in zip(            filings["accessionNumber"], filings["primaryDocument"]        )    ]    columns = ["accessionNumber", "form", "filingDate", "reportDate", "items", "url"]    return filings[columns]  def describe(form: str, items: str) -> str:    """A filing's type, or for an 8-K, what its items report."""    if form == "10-K":        return "Annual report"    if form == "10-Q":        return "Quarterly report"    reported = [ITEMS[item] for item in items.split(",") if item in ITEMS]    return ", ".join(reported) or "Current report"  def save(conn: psycopg.Connection, symbol: str, cik: int) -> int:    """Insert a company's filings, skipping saved ones; return how many it has."""    filings = reports(cik).assign(symbol=symbol)    columns = [        "accessionNumber",        "symbol",        "form",        "filingDate",        "reportDate",        "items",        "url",    ]    with conn.cursor() as cur:        cur.executemany(UPSERT, filings[columns].itertuples(index=False, name=None))    return len(filings)  def main() -> None:    numbers = sec.ciks()    with connect() as conn:        for symbol in COMPANIES:            print(f"{symbol}: {save(conn, symbol, numbers[symbol])} filings")        print("\nLatest filings")        for filed, symbol, form, items in conn.execute(FEED):            print(f"  {filed}  {symbol:<5} {form:<5} {describe(form, items)}")  if __name__ == "__main__":    main()
    Lines 18 to 25
    The SEC’s names for the commonest 8-K items, shortened.
    Lines 27 to 30
    Save a filing unless one with its accession number is saved already.
    Lines 33 to 37
    The ten newest filings, newest first. Two filings on the same day are ordered by symbol, then the later accession number first, so the order never changes from one run to the next.
    Lines 55 to 62
    A filing’s type, or what an 8-K’s items report.
    Lines 65 to 79
    Save a company’s filings, as the recorder saves prices.
    Lines 82 to 89
    Save each company’s filings, then print the feed.
    uv run filingsAAPL: 36 filingsMSFT: 33 filingsNVDA: 40 filings Latest filings  2026-09-03  NVDA  8-K   Other events  2026-09-02  MSFT  8-K   Regulation FD disclosure  2026-08-26  NVDA  10-Q  Quarterly report  2026-08-26  NVDA  8-K   Results of operations  2026-08-17  NVDA  8-K   Material agreement, Regulation FD disclosure  2026-07-31  AAPL  10-Q  Quarterly report  2026-07-30  AAPL  8-K   Results of operations  2026-07-29  MSFT  10-K  Annual report  2026-07-29  MSFT  8-K   Results of operations  2026-07-02  NVDA  8-K   Officer or director change

    Shown for the course’s sample data. Yours shows the latest.

  6. Step 6 Commit and push

    Update the README with the setup and the new commands:

    README.md
    # market-data US equity market data in Python: quotes, daily bars since 2016, returns, split anddividend adjustment, a trading day's 1-minute bars, and SEC filings. Daily bars andfilings are stored in PostgreSQL. Prices come from Alpaca's market data API, free with an Alpaca account: quotes from everyUS exchange, 15 minutes behind the market. The data is for personal use, so thisrepository holds no prices: each command downloads its own. ## Setup Install [uv](https://docs.astral.sh/uv/) and PostgreSQL, and make API keys in an[Alpaca](https://alpaca.markets/) paper trading account. Copy `.env.example` to `.env` andset your own values, then run: ```uv syncuv run migrate``` ## Commands | Command | Description || --- | --- || `uv run quote AAPL` | Latest quote: last price, bid, ask and spread || `uv run watchlist` | Watchlist quotes, refreshed every minute || `uv run history AAPL` | Daily bars since 2016, saved to `data/` and charted || `uv run returns AAPL` | Best and worst days, total return, compound annual growth, yearly returns || `uv run adjust` | Split detection and adjustment, and total return with dividends || `uv run intraday AAPL` | A trading day's 1-minute bars: day bar, VWAP and volume by hour || `uv run migrate` | Apply new database migrations || `uv run recorder` | Load the watchlist's daily bars into the database || `uv run report` | Latest closes and the five best days, from the database || `uv run filings` | Load the watchlist's 10-K, 10-Q and 8-K filings, and print the latest | Each command takes `--help`. ## Development ```uv run ruff formatuv run ruff checkuv run mypyuv run pytest```

    Check the formatting, the linter and the types before you commit, as the workflow will:

    uv run ruff format --check   Building market-data @ file:///home/you/market-data      Built market-data @ file:///home/you/market-dataUninstalled 1 package in 0.69msInstalled 1 package in 2ms19 files already formatteduv run ruff checkAll checks passed!uv run mypySuccess: no issues found in 15 source filesgit add .git commit -m "Load the watchlist's SEC filings"[main fc9ee9e] Load the watchlist's SEC filings 8 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 src/market_data/filings.py create mode 100644 src/market_data/migrations/002_filings.sql create mode 100644 src/market_data/sec.pygit log --oneline -6fc9ee9e Load the watchlist's SEC filingsaffb6e9 Store daily prices in PostgreSQL, with migrationsfa14b36 Type-check with mypy, and run every check on each push635f535 Add a README96a5cc4 Add type hints and command-line optionsc322430 Move the code into a package
    git push

    On GitHub, the Check workflow runs again for each new commit.

Walkthrough

The whole solution, explained line by line. Open it once you have tried.

Show the walkthrough

The finished recorder.py:

src/market_data/recorder.py
"""Load daily bars into the database, then summarise what it holds. Usage:     uv run recorder AAPL SPY With no symbols, it loads the watchlist.""" import argparse import psycopg from market_data.db import connectfrom market_data.history import get_historyfrom market_data.watchlist import SYMBOLS UPSERT = """INSERT INTO prices (symbol, day, open, high, low, close, volume)VALUES (%s, %s, %s, %s, %s, %s, %s)ON CONFLICT (symbol, day) DO UPDATE SET    open = EXCLUDED.open,    high = EXCLUDED.high,    low = EXCLUDED.low,    close = EXCLUDED.close,    volume = EXCLUDED.volume""" SUMMARY = """SELECT symbol, count(*), min(day), max(day)FROM pricesGROUP BY symbolORDER BY symbol"""  def record(conn: psycopg.Connection, symbol: str) -> int:    """Upsert a symbol's full history; return the number of days."""    prices = get_history(symbol).reset_index().assign(symbol=symbol)    rows = prices[["symbol", "date", "open", "high", "low", "close", "volume"]]    with conn.cursor() as cur:        cur.executemany(UPSERT, rows.itertuples(index=False, name=None))    return len(prices)  def main() -> None:    parser = argparse.ArgumentParser(description="Load daily bars into the database.")    parser.add_argument(        "symbols",        nargs="*",        default=SYMBOLS,        help="ticker symbols (default: the watchlist)",    )    args = parser.parse_args()    with connect() as conn:        for symbol in args.symbols:            print(f"{symbol}: {record(conn, symbol):,} days saved")        print("\nsymbol      days  first       last")        for symbol, days, first, last in conn.execute(SUMMARY):            print(f"{symbol:<6}{days:>10,}  {first}  {last}")  if __name__ == "__main__":    main()
Line 16
The watchlist, recorded when no symbols are given.
Lines 18 to 26
Add a day, or update it if it is saved already.
Lines 29 to 33
What the table holds, symbol by symbol.
Lines 37 to 43
Download a history and upsert every day of it.
Lines 46 to 60
Read the symbols, record each one, then print the summary.

The finished filings.py:

src/market_data/filings.py
"""Load the watchlist's SEC filings into the database, and print the latest. Usage:     uv run filings""" import pandas as pdimport psycopg from market_data import secfrom market_data.db import connectfrom market_data.watchlist import COMPANIES FORMS = ["10-K", "10-Q", "8-K"] # The SEC's names for the 8-K items this feed describes.ITEMS = {    "1.01": "Material agreement",    "2.02": "Results of operations",    "5.02": "Officer or director change",    "5.07": "Shareholder vote",    "7.01": "Regulation FD disclosure",    "8.01": "Other events",} UPSERT = """INSERT INTO filings (accession, symbol, form, filed, period, items, url)VALUES (%s, %s, %s, %s, %s, %s, %s)ON CONFLICT (accession) DO NOTHING""" FEED = """SELECT filed, symbol, form, itemsFROM filingsORDER BY filed DESC, symbol, accession DESCLIMIT 10"""  def reports(cik: int) -> pd.DataFrame:    """A company's recent 10-K, 10-Q and 8-K filings, newest first."""    filings = pd.DataFrame(sec.recent_filings(cik))    filings = filings[filings["form"].isin(FORMS)].copy()    filings["url"] = [        sec.document_url(cik, accession, document)        for accession, document in zip(            filings["accessionNumber"], filings["primaryDocument"]        )    ]    columns = ["accessionNumber", "form", "filingDate", "reportDate", "items", "url"]    return filings[columns]  def describe(form: str, items: str) -> str:    """A filing's type, or for an 8-K, what its items report."""    if form == "10-K":        return "Annual report"    if form == "10-Q":        return "Quarterly report"    reported = [ITEMS[item] for item in items.split(",") if item in ITEMS]    return ", ".join(reported) or "Current report"  def save(conn: psycopg.Connection, symbol: str, cik: int) -> int:    """Insert a company's filings, skipping saved ones; return how many it has."""    filings = reports(cik).assign(symbol=symbol)    columns = [        "accessionNumber",        "symbol",        "form",        "filingDate",        "reportDate",        "items",        "url",    ]    with conn.cursor() as cur:        cur.executemany(UPSERT, filings[columns].itertuples(index=False, name=None))    return len(filings)  def main() -> None:    numbers = sec.ciks()    with connect() as conn:        for symbol in COMPANIES:            print(f"{symbol}: {save(conn, symbol, numbers[symbol])} filings")        print("\nLatest filings")        for filed, symbol, form, items in conn.execute(FEED):            print(f"  {filed}  {symbol:<5} {form:<5} {describe(form, items)}")  if __name__ == "__main__":    main()
Lines 11 to 15
The SEC client, the database, the companies, and the forms to keep.
Lines 18 to 25
What an 8-K’s commonest items announce.
Lines 27 to 37
Save each filing once; read the ten newest back.
Lines 41 to 52
A company’s recent 10-K, 10-Q and 8-K filings, with each document’s address.
Lines 55 to 62
A filing in a few words.
Lines 65 to 79
Save a company’s reports.
Lines 82 to 89
Save the companies’ filings, and print the feed.

Check yourself

Questions an interviewer could ask about today’s work.

  1. 01What is the difference between git commit and git push?Show answer

    git commit saves a version in your repository, on your computer. git push sends the commits GitHub doesn’t have yet to the remote, so others, and your other computers, can see them.

  2. 02What is a primary key, and what is the prices table’s?Show answer

    The column, or columns, whose values no two rows may share. The prices table’s is the symbol and the day together, so each symbol has one row a day, and saving a day again finds that row instead of adding another.

  3. 03Your recorder runs every day. Why upsert rather than insert?Show answer

    An insert of a day already saved fails on the primary key, or, without a key, saves it twice. An upsert adds new days and updates days already there, so the recorder can run as often as you like and the table stays one row per symbol and day.

  4. 04What is an 8-K, and why do traders watch for them?Show answer

    A report of news that can’t wait: results, a deal, a change of officers. Companies must file one within four business days of the event, and it can move the share, so traders and their programs read them the moment they appear.

  5. 05You pushed a commit with a password in it. What do you do?Show answer

    Change the password first: once it has been public, assume someone has it. Then take it out of the code, read it from .env or the environment instead, and make sure .env is in .gitignore. Removing the commit alone isn’t enough, because copies of it may already exist.

Learning points

  • GitHub keeps a copy of your repository; git push sends it your commits. Only commits travel, and nothing .gitignore names leaves your computer.
  • A workflow in .github/workflows/ runs your checks on a fresh machine on every push.
  • PostgreSQL keeps data in tables of typed columns. A primary key stops duplicates, and an upsert lets a recorder run every day.
  • SQL says what you want: SELECT, FROM, WHERE, GROUP BY, ORDER BY. Passwords and keys go in .env, never in code.
  • Companies file 10-Ks, 10-Qs and 8-Ks with the SEC. EDGAR is free, if you say who is asking.

Keep going

Today was mostly setting up, and that is normal

Accounts, Docker, a database and a workflow are more setup than code, and setup is where most people get stuck. When something won’t connect, read the error’s last line. “Connection refused” means PostgreSQL isn’t running: start it with docker start market-db. “Password authentication failed” means .env’s password isn’t the one you gave Docker.

Once it is done, it stays done. From tomorrow, everything you build reads from this database.

Ship it

Open your repository on GitHub. It should show the README, a green tick from the Check workflow beside the latest commit, and no prices and no .env anywhere in it.

Tomorrow you build an API that serves these prices, and a web page that shows them.

For education only. Not investment advice. Terms of Use