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.
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 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 pointsYour 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 withgh 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 mypypass. - Add
.github/workflows/check.ymlto runruff format --check,ruff check,mypyandpyteston every push. Commit it, push, and watch it pass under Actions.
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.
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.climacOS and Linux
brew install ghOn 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 loginAnswer 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.
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(+)
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--pushsends 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.
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.
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.
--checkmakes 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 pushOpen 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. 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 pointsThe 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
.envas DATABASE_URL, and to.env.examplewith 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 .envfrom 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().
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.
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
Part What it does --name market-db Names the container, to start or stop it by name -e POSTGRES_PASSWORD=… Sets the database’s password -e POSTGRES_DB=market Makes a database called market -p 5432:5432 Lets programs on your computer reach PostgreSQL’s port, 5432 -v market-db:/var/lib/postgresql Keeps the data in a volume, market-db, so it survives the container -d postgres:18 Runs PostgreSQL 18’s image in the background The first time, Docker downloads the image, then prints the new container’s id.
docker pslists the containers running: market-db should be there. After your computer restarts, start it again withdocker start market-db.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
Step 4 Add the database to .env
Add a line to
.envwith your password in the address:.envAPCA_API_KEY_ID=your-key-idAPCA_API_SECRET_KEY=your-secret-keyDATABASE_URL=postgresql://postgres:choose-a-password@localhost:5432/marketThe 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.exampletoo, 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/marketCheck what Git sees:
git status --short M .env.example M pyproject.toml M uv.lock
.envisn’t listed:.gitignorehas named it since Day 1, so it will never be committed or pushed..env.exampleis, ready to commit.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_cachekeeps 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.osisn’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.
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.
filesfrom importlib.resources finds it wherever the package is installed. - Lines 16 to 19
- A table of the migrations applied, each with when.
IF NOT EXISTSmakes 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 conncommits 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.sqlCREATE 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 NULLmeans 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.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
%sis 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, andassignadds the symbol to every row. Picking the columns in the table’s order anditertuples(index=False, name=None)gives a tuple of values for each row, andexecutemanyruns 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 conncommits 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.
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.
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
.envnor any price is in the commit;.env.exampleis.
Session 3 Your watchlist’s SEC filings
The idea
Step 1 What companies file
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.
| Form | What it is | When |
|---|---|---|
| 10-K | The annual report: the year’s audited results, the business and its risks | Within 60 days of the year’s end, for the largest companies |
| 10-Q | The quarterly report, for each of the year’s first three quarters | Within 40 days of the quarter’s end, for the largest companies |
| 8-K | News that can’t wait: results, a deal, a change of officers | Within 4 business days of the event |
| 4 | An insider, such as a director, bought or sold shares | Within 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 pointsApple’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.exampleand 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 fromhttps://www.sec.gov/files/company_tickers.json;recent_filings(cik)fromhttps://data.sec.gov/submissions/CIK0000320193.json, with the CIK in 10 digits; anddocument_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.
Step 1 Say who you are
Add a line to
.envwith your own name and email:.envAPCA_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()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.
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.
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
isinis 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.zippairs 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.
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.sqlCREATE 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.
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 pushOn 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:
"""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:
"""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.
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.
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.
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.
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.
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