A small aggregator that, once a day, pulls a fixed set of public readings — Madison lake levels, Mendota buoy weather, a basket of market prices, and the UWCU 30-year mortgage rate — writes the new rows to BigQuery, renders an HTML digest with 90-day sparkline charts, and emails it via Resend. Runs on Cloud Run, kicked off by Cloud Scheduler.
The code is intentionally small (one Python package, no plugin auto-discovery, no framework abstraction). Adding a new source is two files and a YAML block.
Live and running daily in the palmer-daily GCP project. Cloud Build owns the
deploy pipeline; every merge to main auto-triggers a build via the GitHub
App trigger, which runs tests → builds the linux/amd64 image → pushes to
Artifact Registry → rolls a new Cloud Run revision. All five data sources are
wired and populated in the digest. Schedule: 7am America/Chicago via Cloud
Scheduler.
Configured in sources.yaml:
- lake_mendota — Lake Mendota level (ft) and Tenney lock percent open,
via
lwrd.danecounty.govJSON endpoints (/api/LakeLevels/Current,/api/Dams/GetDams). - mendota_buoy — Surface water temp, 10 m water temp, wind speed, wind
direction, via the SSEC MetObs API (
metobs.ssec.wisc.edu/api/data.json,site=mendota&inst=buoy). Water temps are stored in °C (the upstream unit) and rendered in °F in the email; wind speed is converted m/s → mph in the collector. - markets — 12 tickers (S&P 500, NASDAQ, gold, oil, BTC, FX pairs, DXY,
10y yield, VIX) via
yfinance. - uwcu_mortgage_30y — UWCU 30-year fixed mortgage rate (%), scraped from the public rates page.
- portfolio — A manually-maintained
holdings.yamlat the repo root lists share counts and cash per account. Daily run multiplies shares by current yfinance prices and emits one reading per account plus a grand total. The email renders the total in the main metrics table and the accounts in a sub-table below; a red banner appears when any account'sas_ofdate is older thanstale_after_days(default 60).
To add or remove a metric, edit sources.yaml and redeploy. No code change
needed unless the source type itself is new (see "Adding a new source" below).
To update portfolio balances, edit holdings.yaml (bumping as_of,
cash_usd, and positions) and redeploy.
python -m venv .venv && source .venv/bin/activate
make install # editable install + dev deps (pytest, ruff, mypy, respx)
make lint # ruff check + format check + mypy
make test # pytest with 80% coverage floor
make dev # FastAPI on http://127.0.0.1:3501 with --reloadTrigger a one-off run while make dev is up:
curl -X POST http://127.0.0.1:3501/runThe dev server returns the run summary as JSON; the digest email goes out via
Resend if RESEND_API_KEY is set, otherwise the orchestrator logs the
rendered HTML and skips delivery.
sources.yaml ──► config.py ──┐
▼
orchestrator.py ─► collectors/* ──► MetricReading list
│ │
│ ▼
│ storage/bigquery.py
│ │
▼ ▼
email/render.py ◄────────────── storage.query_history
│
▼
email/send.py (Resend)
Source layout (src/daily/):
main.py— FastAPI app,/runand/healthzendpointsorchestrator.py— one-run pipeline: load config → run collectors in a thread pool → write readings → query history → render → sendconfig.py— load + validatesources.yamlinto pydantic modelscollectors/base.py— abstractCollector(one method:fetch() -> list[MetricReading])__init__.py— explicitREGISTRYmappingtypestrings to classes- one module per source (
dane_county_lakes.py,mendota_buoy.py,market_yfinance.py,uwcu_mortgage.py)
storage/schema.py— BigQuery column constantsbigquery.py—BigQueryStorage(ensure_table, write_readings, query_history)
email/render.py— Jinja2 templates → HTML digestsparkline.py— matplotlib → inline PNG attachmentssend.py— Resend client wrapper
observability/logging.py— structured JSON logs for Cloud Loggingmodels.py—MetricReading,SourceRunResultdataclasses
A collector that returns per-metric error readings is the normal way to
report individual failures — a metric whose value is None and whose
error is non-None renders as ⚠ <reason> in the email without poisoning
the rest of the source. Only whole-source HTTP failures should raise.
Two cases. Both end with an entry in sources.yaml.
If you want another lake from dane_county_lakes or another ticker from
market_yfinance, just edit sources.yaml. For lakes, valid metric keys are
mendota_level_ft / monona_level_ft / waubesa_level_ft /
kegonsa_level_ft and tenney_open_pct / babcock_open_pct /
lafollette_open_pct. For markets, any Yahoo Finance ticker works.
-
Pick a
type_keyforsources.yaml(e.g.usgs_river_gauge). -
Write
src/daily/collectors/<your_type>.py: subclassCollector, settype_key, implementfetch()returning alist[MetricReading]. Look atdane_county_lakes.pyfor a JSON-API style example, oruwcu_mortgage.pyfor an HTML-scrape style. Return per-metric error readings rather than raising — seeMetricReadinginmodels.py. -
Register it in
src/daily/collectors/__init__.py(REGISTRYdict) and add the newtype_keytoKNOWN_COLLECTOR_TYPESinconfig.py. -
Capture a fixture under
tests/fixtures/and add it to the_SOURCEStable inscripts/refresh_fixtures.pyso future-you can re-pull when the upstream layout shifts. -
Write
tests/collectors/test_<your_type>.pyusingrespxto mockhttpx.get. Cover the happy path, individual metric failures, and full HTTP failure. Seetests/collectors/test_dane_county_lakes.pyfor the pattern. -
Add to
sources.yamlwith yourtypeandmetricslist.
The 80% coverage floor is enforced; per-source tests must land in the same commit as the collector code.
The scrape collectors are tested against captured fixtures, not the live internet (CI doesn't have outbound network to flaky third parties). When a source's layout or API shape changes and a test starts failing in a way that looks like reality moved underneath it, refresh the fixture:
python scripts/refresh_fixtures.py # all four
python scripts/refresh_fixtures.py --source uwcu_mortgage
python scripts/refresh_fixtures.py --dry-run # show what would changeThe script writes into tests/fixtures/. Diff the result before
committing — a layout change must be a deliberate update, not silent test
drift. If the source dropped or renamed a field, update the collector and its
tests in the same commit.
make smoke runs refresh_fixtures.py --dry-run to exercise outbound
network without overwriting anything; CI does not run it.
sources.yaml is the single user-editable file. Schema:
email:
to: someone@example.com
from: onboarding@resend.dev
subject_template: "Daily digest — {date}"
timezone: America/Chicago
history:
sparkline_days: 90 # 1–3650; how far back the sparkline reaches
sources:
- name: <source slug>
type: <one of dane_county_lakes | mendota_buoy | market_yfinance | uwcu_mortgage | portfolio>
enabled: true
url: <optional, source-dependent>
metrics: # used by lakes, buoy, uwcu
- { key: <metric slug>, label: "Display label", unit: "ft" }
tickers: # used by market_yfinance only
sp500: { symbol: "^GSPC", label: "S&P 500", unit: "USD" }
holdings_file: holdings.yaml # portfolio only — path relative to cwd
stale_after_days: 60 # portfolio only — banner thresholdCross-source metric keys must be unique; the loader enforces this.
The portfolio source additionally reads holdings.yaml:
accounts:
- name: HSA Account
mask: "763" # stable identifier; do not rename
type: HSA
as_of: 2026-05-13 # date you last verified the snapshot
cash_usd: 20.89
positions:
- { symbol: VOO, shares: 3 }
- { symbol: AVUV, shares: 14 }Local Docker is no longer required. Cloud Build owns the pipeline:
make submitThis uploads the working tree (filtered by .gcloudignore) to Cloud Build,
runs cloudbuild.yaml: tests → docker build --platform=linux/amd64 → push
to Artifact Registry → gcloud run services update to roll the new revision.
A failing test suite blocks the image push.
Watch a build:
gcloud builds log $(gcloud builds list --project=palmer-daily --limit=1 \
--format='value(id)') --project=palmer-dailyManually trigger a daily run:
gcloud scheduler jobs run daily-digest --location=us-central1 --project=palmer-dailyCloud Run logs (including the structured JSON entries from
observability/logging.py) land in Cloud Logging under
run.googleapis.com/stdout.
make dev listens on http://127.0.0.1:3501. Override with
make dev LOCAL_PORT=4000. The Dockerfile defaults to the same 3501 so
docker run without -p mapping listens on 3501; in Cloud Run, PORT=8080
is injected and overrides the default.
- Design spec
- Cost estimate
- Implementation plan
CLAUDE.md— orientation for Claude sessions working in this repo