repos

Initial commit

f9105c99 by Isaac Bythewood · 1 month ago

Initial commit

finance — a self-hosted market watcher for stocks, ETFs, indexes, and
futures: live charts, key stats, fundamentals, and SEC filings. Single
axum binary with sqlx + SQLite and a Vite frontend.

Phases 0-12 complete: universe seed, endpoint guard, scheduler, live
quotes over SSE, the Paper Ledger design, fundamentals, chart
indicators, search, commodities, the home dashboard, and the ship
infrastructure (Dockerfile, compose, samplefiles, README, CLAUDE.md).
added .dockerignore
@@ -0,0 +1,5 @@/target/frontend/node_modules/.git/dist/data
added .gitignore
@@ -0,0 +1,11 @@/target//dist//data//node_modules//frontend/dist//frontend/node_modules/.envdb.sqlite3db.sqlite3-shmdb.sqlite3-waldb.sqlite3-journal
added CLAUDE.md
@@ -0,0 +1,108 @@# CLAUDE.mdThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.## What This Is`finance` is a self-hosted, real-timeish market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, and SEC filings. Single axum binary with sqlx + SQLite and a Vite frontend.It is for *watching* the market only. No portfolio, no holdings, no money or cost-basis tracking, no accounts, no auth. Single operator. Deploys at `finance.bythewood.me`.`PLAN.md` is the living design/resume doc — the phase roadmap, the decisions log, and the data-source policy live there. Read it for the *why* behind anything below.## Commands- **Dev server:** `make run` (Vite watch + `cargo run` concurrently on port 8000)- **Production build:** `make build` (Vite assets + release binary at `target/release/finance`)- **Run release binary:** `make start`- **Seed the universe:** `make seed` (curated symbols + bulk daily history; idempotent and resumable). Same as `finance seed`- **Docker build:** `docker build .`In this dev container `cargo` is not on `PATH`; use `~/.cargo/bin/cargo`. Run the dev binary with `FINANCE_ROOT=/home/dev/code/finance` so it finds `templates/`, `dist/`, `migrations/`, and `universe/`.There are no tests or linters configured.## Architecture**Backend:** Single-binary axum app. A tiny `src/main.rs` does env init, subcommand dispatch (`seed`), and server boot; `src/app.rs` builds `AppState` + `Config` + the `router()`. Per-feature route modules under `src/routes/`. Async sqlx + SQLite (WAL) for all reads and writes; the schema in `migrations/` is applied on boot via `sqlx::migrate!`.**Providers (`src/providers/`):** One trait per concern — `HistoryProvider` (Stooq), `QuoteProvider` (Yahoo), `FundamentalsProvider` (SEC EDGAR) — with one struct per source, so a source is swappable without touching callers. `http.rs` builds the shared reqwest clients.**Endpoint guard (`src/guard.rs`):** A persistent, per-endpoint `EndpointGuard` that every outbound data call passes through: a DB-backed reactive circuit breaker (trips on HTTP 429/503 at once or after a failure streak, exponential backoff, half-open probe recovery), a hard per-hour request budget, and request pacing. State lives in the `endpoint_guard` table, so it survives restarts and is shared by the server and the `seed` subcommand. The user considers never hitting a rate limit critical — see the Anti-spam policy in `PLAN.md`.**Scheduler (`src/scheduler.rs`):** One long-lived tokio task on a 60s tick. Runs market-hours-aware background jobs — the first-run seed, the ~6-hourly incremental daily-history refresh, demand-driven intraday polling, a once-a-day close snapshot, and a prune — each writing `data_status` and `fetch_log` and pinging the stream hub so `/health` tracks it live.**Real-time (`src/stream.rs`):** A `tokio::sync::broadcast` hub that also carries a per-ticker viewer-interest registry. The `/stream` axum SSE endpoint registers the tickers a page shows and forwards quote / market / health events; the browser uses `EventSource` and patches the DOM in place. The registry makes intraday polling demand-driven: only the symbols a browser is currently viewing get fetched.**Market clock (`src/market.rs`):** The US equity session (Closed/Pre/Regular/Post) in `America/New_York` via `chrono-tz`. No exchange-holiday calendar (deliberate — see the decisions log).**Compute (`src/compute.rs`):** Pure numeric code — indicator maths (`sma`, `ema`, `rsi`), graded fundamental ratios, range-meter marker positions, and the home-page sparkline SVG. The maths lives here, not in SQL or JS.**Templates:** Jinja2 templates in `templates/` rendered by minijinja with a Jinja2-faithful HTML formatter so `/` is not escaped to `/` (matches the sibling Rust apps). `vite_asset` resolves hashed asset names from `dist/.vite/manifest.json`.**Frontend pipeline:** Vite (run from `frontend/`, built with bun) compiles `frontend/static_src/` into `dist/`, served at `/static/` with content-hashed filenames. Five entry points: `base` (shared shell + SSE client), `home`, `symbol`, `health`, `search`.**Design — "Paper Ledger":** An old-school accounting ledger reimagined futuristic and modern: warm-paper background, ink-dark text, hairline rules, monospace ledger figures, restrained serif headings. Color is semantic and sparing — green/amber/red mean good/ok/bad (price moves, fundamental ratios, data-health states), never decoration. Chart indicator lines are a deliberate exception (a muted non-semantic palette). Tokens are CSS custom properties in `base.scss :root`. Built mobile-first; phone and desktop are both first-class.**Request logging:** `src/middleware.rs` prints `time METHOD STATUS latency path` per request with ANSI-colored status codes, and serves the themed 404.## Layout```finance/├── Cargo.toml, Cargo.lock        # rust deps├── Makefile, README.md, PLAN.md  # top-level (PLAN.md is the living design doc)├── migrations/                   # sqlx migrations 0001-0004, applied on boot├── universe/starter.csv          # curated seed list (~150 symbols)├── src/│   ├── main.rs        # entry: env init, `seed` subcommand, server boot│   ├── app.rs         # AppState + Config + router()│   ├── db.rs          # SqlitePool init, migrate, now_ms, meta helpers│   ├── render.rs      # template render helper│   ├── middleware.rs  # request log + themed 404│   ├── templates.rs   # minijinja env, vite_asset, jinja2-compat formatter│   ├── models.rs      # row structs + the shared ticker `Card`│   ├── compute.rs     # indicator maths, graded ratios, sparkline SVG│   ├── seed.rs        # first-run universe + history backfill│   ├── scheduler.rs   # background job loop│   ├── market.rs      # US market-session clock│   ├── stream.rs      # SSE pub/sub hub + viewer-interest registry│   ├── guard.rs       # persistent per-endpoint EndpointGuard│   ├── providers/     # mod.rs (traits), http.rs, stooq.rs, yahoo.rs, sec.rs│   └── routes/        # home, symbols, search, stream, health, seo├── templates/         # base.html, includes/, pages/├── frontend/static_src/   # base/ home/ symbol/ health/ search/ (Vite entries)├── dist/              # vite build output (gitignored, served at /static/)├── data/              # sqlite db at runtime (gitignored)├── target/            # cargo build output (gitignored)├── Dockerfile, docker-compose.yml, .dockerignore└── samplefiles/       # env.sample, Caddyfile.sample, post-receive.sample```The binary reads `templates/`, `dist/`, `migrations/`, and `universe/` from cwd by default; override with `FINANCE_ROOT`. The SQLite db lives in `FINANCE_DATA_DIR` (default `./data`, production `/data`). Full config table in `README.md`.## Key Routes- `/`: home dashboard — index/commodity sparkline cards over the day's top movers- `/s/{ticker}`: symbol page — candlestick chart with indicators, key stats, fundamentals, filings- `/api/symbols/{ticker}/history`: candle + indicator series JSON for the chart- `/api/symbols` (POST): add a symbol not yet in the universe (validated against Yahoo)- `/search`: browse and search the whole universe (filter by kind, match ticker and company name)- `/stream`: Server-Sent Events — live quotes, market session, health nudges- `/health`, `/api/health`: data-health page and its JSON feed- `/favicon.ico`, `/robots.txt`, `/sitemap.xml`: static SEO routes- `/static/*`: Vite assets (1y cache header)## Data SourcesAll free, no account. See `PLAN.md` for the full anti-spam / caching policy.- **Historical daily OHLCV — Stooq.** One call returns a symbol's entire daily history. Gated behind a free apikey (`STOOQ_APIKEY`, in `.env`, gitignored).- **Intraday bars + live quotes — Yahoo Finance.** `v8/finance/chart`; no key, just a browser User-Agent.- **Fundamentals + filings — SEC EDGAR.** `company_tickers.json`, `companyfacts`, `submissions`; no key, a contact email (`SEC_CONTACT_EMAIL`) in the User-Agent. Stocks only — ETFs and indexes do not file.## Tooling- **Rust deps:** `cargo` (`Cargo.toml`, `Cargo.lock`)- **JS deps:** `bun`, run from `frontend/` (`frontend/package.json`, `frontend/bun.lock`)- **Production:** Docker (`rust:alpine` builder + `alpine:3.23` runtime). Deployed via `git push server master` triggering a post-receive hook that runs `docker compose up --build --detach`. Data persists to `/srv/data/finance/`.
added Cargo.lock
@@ -0,0 +1,3000 @@# This file is automatically @generated by Cargo.# It is not intended for manual editing.version = 4[[package]]name = "adler2"version = "2.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"[[package]]name = "aho-corasick"version = "1.1.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"dependencies = [ "memchr",][[package]]name = "alloc-no-stdlib"version = "2.0.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"[[package]]name = "alloc-stdlib"version = "0.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"dependencies = [ "alloc-no-stdlib",][[package]]name = "allocator-api2"version = "0.2.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"[[package]]name = "android_system_properties"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"dependencies = [ "libc",][[package]]name = "anyhow"version = "1.0.102"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"[[package]]name = "arbitrary"version = "1.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"dependencies = [ "derive_arbitrary",][[package]]name = "async-compression"version = "0.4.42"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"dependencies = [ "compression-codecs", "compression-core", "pin-project-lite", "tokio",][[package]]name = "async-stream"version = "0.3.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"dependencies = [ "async-stream-impl", "futures-core", "pin-project-lite",][[package]]name = "async-stream-impl"version = "0.3.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "async-trait"version = "0.1.89"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "atoi"version = "2.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"dependencies = [ "num-traits",][[package]]name = "atomic-waker"version = "1.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"[[package]]name = "autocfg"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"[[package]]name = "axum"version = "0.8.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"dependencies = [ "axum-core", "axum-macros", "bytes", "form_urlencoded", "futures-util", "http", "http-body", "http-body-util", "hyper", "hyper-util", "itoa", "matchit", "memchr", "mime", "percent-encoding", "pin-project-lite", "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", "sync_wrapper", "tokio", "tower", "tower-layer", "tower-service", "tracing",][[package]]name = "axum-core"version = "0.5.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"dependencies = [ "bytes", "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", "sync_wrapper", "tower-layer", "tower-service", "tracing",][[package]]name = "axum-macros"version = "0.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "base64"version = "0.22.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"[[package]]name = "base64ct"version = "1.8.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"[[package]]name = "bitflags"version = "2.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"dependencies = [ "serde_core",][[package]]name = "block-buffer"version = "0.10.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"dependencies = [ "generic-array",][[package]]name = "brotli"version = "8.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"dependencies = [ "alloc-no-stdlib", "alloc-stdlib", "brotli-decompressor",][[package]]name = "brotli-decompressor"version = "5.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"dependencies = [ "alloc-no-stdlib", "alloc-stdlib",][[package]]name = "bumpalo"version = "3.20.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"[[package]]name = "byteorder"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"[[package]]name = "bytes"version = "1.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"[[package]]name = "cc"version = "1.2.62"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"dependencies = [ "find-msvc-tools", "shlex",][[package]]name = "cfg-if"version = "1.0.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"[[package]]name = "cfg_aliases"version = "0.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"[[package]]name = "chrono"version = "0.4.44"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"dependencies = [ "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", "windows-link",][[package]]name = "chrono-tz"version = "0.10.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"dependencies = [ "chrono", "phf",][[package]]name = "compression-codecs"version = "0.4.38"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"dependencies = [ "brotli", "compression-core", "flate2", "memchr",][[package]]name = "compression-core"version = "0.4.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"[[package]]name = "concurrent-queue"version = "2.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"dependencies = [ "crossbeam-utils",][[package]]name = "const-oid"version = "0.9.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"[[package]]name = "core-foundation-sys"version = "0.8.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"[[package]]name = "cpufeatures"version = "0.2.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"dependencies = [ "libc",][[package]]name = "crc"version = "3.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"dependencies = [ "crc-catalog",][[package]]name = "crc-catalog"version = "2.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"[[package]]name = "crc32fast"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"dependencies = [ "cfg-if",][[package]]name = "crossbeam-queue"version = "0.3.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"dependencies = [ "crossbeam-utils",][[package]]name = "crossbeam-utils"version = "0.8.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"[[package]]name = "crypto-common"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"dependencies = [ "generic-array", "typenum",][[package]]name = "csv"version = "1.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"dependencies = [ "csv-core", "itoa", "ryu", "serde_core",][[package]]name = "csv-core"version = "0.1.13"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"dependencies = [ "memchr",][[package]]name = "der"version = "0.7.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"dependencies = [ "const-oid", "pem-rfc7468", "zeroize",][[package]]name = "derive_arbitrary"version = "1.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "digest"version = "0.10.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"dependencies = [ "block-buffer", "const-oid", "crypto-common", "subtle",][[package]]name = "displaydoc"version = "0.2.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "dotenvy"version = "0.15.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"[[package]]name = "either"version = "1.16.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"dependencies = [ "serde",][[package]]name = "equivalent"version = "1.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"[[package]]name = "errno"version = "0.3.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"dependencies = [ "libc", "windows-sys 0.61.2",][[package]]name = "etcetera"version = "0.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"dependencies = [ "cfg-if", "home", "windows-sys 0.48.0",][[package]]name = "event-listener"version = "5.4.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"dependencies = [ "concurrent-queue", "parking", "pin-project-lite",][[package]]name = "finance"version = "0.1.0"dependencies = [ "anyhow", "async-stream", "async-trait", "axum", "chrono", "chrono-tz", "csv", "dotenvy", "futures-util", "minijinja", "reqwest", "serde", "serde_json", "sqlx", "thiserror", "tokio", "tokio-stream", "tower", "tower-http", "tracing", "tracing-subscriber", "urlencoding", "zip",][[package]]name = "find-msvc-tools"version = "0.1.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"[[package]]name = "flate2"version = "1.1.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"dependencies = [ "crc32fast", "miniz_oxide",][[package]]name = "flume"version = "0.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"dependencies = [ "futures-core", "futures-sink", "spin",][[package]]name = "foldhash"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"[[package]]name = "form_urlencoded"version = "1.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"dependencies = [ "percent-encoding",][[package]]name = "futures-channel"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"dependencies = [ "futures-core", "futures-sink",][[package]]name = "futures-core"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"[[package]]name = "futures-executor"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"dependencies = [ "futures-core", "futures-task", "futures-util",][[package]]name = "futures-intrusive"version = "0.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"dependencies = [ "futures-core", "lock_api", "parking_lot",][[package]]name = "futures-io"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"[[package]]name = "futures-macro"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "futures-sink"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"[[package]]name = "futures-task"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"[[package]]name = "futures-util"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"dependencies = [ "futures-core", "futures-io", "futures-macro", "futures-sink", "futures-task", "memchr", "pin-project-lite", "slab",][[package]]name = "generic-array"version = "0.14.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"dependencies = [ "typenum", "version_check",][[package]]name = "getrandom"version = "0.2.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"dependencies = [ "cfg-if", "js-sys", "libc", "wasi", "wasm-bindgen",][[package]]name = "getrandom"version = "0.3.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", "wasip2", "wasm-bindgen",][[package]]name = "hashbrown"version = "0.15.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"dependencies = [ "allocator-api2", "equivalent", "foldhash",][[package]]name = "hashbrown"version = "0.17.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"[[package]]name = "hashlink"version = "0.10.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"dependencies = [ "hashbrown 0.15.5",][[package]]name = "heck"version = "0.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"[[package]]name = "hex"version = "0.4.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"[[package]]name = "hkdf"version = "0.12.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"dependencies = [ "hmac",][[package]]name = "hmac"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"dependencies = [ "digest",][[package]]name = "home"version = "0.5.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"dependencies = [ "windows-sys 0.61.2",][[package]]name = "http"version = "1.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"dependencies = [ "bytes", "itoa",][[package]]name = "http-body"version = "1.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"dependencies = [ "bytes", "http",][[package]]name = "http-body-util"version = "0.1.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"dependencies = [ "bytes", "futures-core", "http", "http-body", "pin-project-lite",][[package]]name = "http-range-header"version = "0.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"[[package]]name = "httparse"version = "1.10.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"[[package]]name = "httpdate"version = "1.0.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"[[package]]name = "hyper"version = "1.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "http", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", "smallvec", "tokio", "want",][[package]]name = "hyper-rustls"version = "0.27.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"dependencies = [ "http", "hyper", "hyper-util", "rustls", "tokio", "tokio-rustls", "tower-service", "webpki-roots",][[package]]name = "hyper-util"version = "0.1.20"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"dependencies = [ "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2", "tokio", "tower-service", "tracing",][[package]]name = "iana-time-zone"version = "0.1.65"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "log", "wasm-bindgen", "windows-core",][[package]]name = "iana-time-zone-haiku"version = "0.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"dependencies = [ "cc",][[package]]name = "icu_collections"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"dependencies = [ "displaydoc", "potential_utf", "utf8_iter", "yoke", "zerofrom", "zerovec",][[package]]name = "icu_locale_core"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec",][[package]]name = "icu_normalizer"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "zerovec",][[package]]name = "icu_normalizer_data"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"[[package]]name = "icu_properties"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"dependencies = [ "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", "zerotrie", "zerovec",][[package]]name = "icu_properties_data"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"[[package]]name = "icu_provider"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"dependencies = [ "displaydoc", "icu_locale_core", "writeable", "yoke", "zerofrom", "zerotrie", "zerovec",][[package]]name = "idna"version = "1.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"dependencies = [ "idna_adapter", "smallvec", "utf8_iter",][[package]]name = "idna_adapter"version = "1.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"dependencies = [ "icu_normalizer", "icu_properties",][[package]]name = "indexmap"version = "2.14.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"dependencies = [ "equivalent", "hashbrown 0.17.1",][[package]]name = "ipnet"version = "2.12.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"[[package]]name = "itoa"version = "1.0.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"[[package]]name = "js-sys"version = "0.3.98"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"dependencies = [ "cfg-if", "futures-util", "once_cell", "wasm-bindgen",][[package]]name = "lazy_static"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"dependencies = [ "spin",][[package]]name = "libc"version = "0.2.186"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"[[package]]name = "libm"version = "0.2.16"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"[[package]]name = "libredox"version = "0.1.16"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"dependencies = [ "bitflags", "libc", "plain", "redox_syscall 0.7.5",][[package]]name = "libsqlite3-sys"version = "0.30.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"dependencies = [ "cc", "pkg-config", "vcpkg",][[package]]name = "litemap"version = "0.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"[[package]]name = "lock_api"version = "0.4.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"dependencies = [ "scopeguard",][[package]]name = "log"version = "0.4.29"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"[[package]]name = "lru-slab"version = "0.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"[[package]]name = "matchers"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"dependencies = [ "regex-automata",][[package]]name = "matchit"version = "0.8.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"[[package]]name = "md-5"version = "0.10.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"dependencies = [ "cfg-if", "digest",][[package]]name = "memchr"version = "2.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"[[package]]name = "memo-map"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"[[package]]name = "mime"version = "0.3.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"[[package]]name = "mime_guess"version = "2.0.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"dependencies = [ "mime", "unicase",][[package]]name = "minijinja"version = "2.20.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2929e494b2280e1e18959bb2e121da03347ae896896fdfaceaab43c88a02803f"dependencies = [ "memo-map", "serde", "serde_json",][[package]]name = "miniz_oxide"version = "0.8.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"dependencies = [ "adler2", "simd-adler32",][[package]]name = "mio"version = "1.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"dependencies = [ "libc", "wasi", "windows-sys 0.61.2",][[package]]name = "nu-ansi-term"version = "0.50.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"dependencies = [ "windows-sys 0.61.2",][[package]]name = "num-bigint-dig"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"dependencies = [ "lazy_static", "libm", "num-integer", "num-iter", "num-traits", "rand 0.8.6", "smallvec", "zeroize",][[package]]name = "num-integer"version = "0.1.46"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"dependencies = [ "num-traits",][[package]]name = "num-iter"version = "0.1.45"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"dependencies = [ "autocfg", "num-integer", "num-traits",][[package]]name = "num-traits"version = "0.2.19"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"dependencies = [ "autocfg", "libm",][[package]]name = "once_cell"version = "1.21.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"[[package]]name = "parking"version = "2.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"[[package]]name = "parking_lot"version = "0.12.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"dependencies = [ "lock_api", "parking_lot_core",][[package]]name = "parking_lot_core"version = "0.9.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"dependencies = [ "cfg-if", "libc", "redox_syscall 0.5.18", "smallvec", "windows-link",][[package]]name = "pem-rfc7468"version = "0.7.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"dependencies = [ "base64ct",][[package]]name = "percent-encoding"version = "2.3.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"[[package]]name = "phf"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"dependencies = [ "phf_shared",][[package]]name = "phf_shared"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"dependencies = [ "siphasher",][[package]]name = "pin-project-lite"version = "0.2.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"[[package]]name = "pkcs1"version = "0.7.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"dependencies = [ "der", "pkcs8", "spki",][[package]]name = "pkcs8"version = "0.10.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"dependencies = [ "der", "spki",][[package]]name = "pkg-config"version = "0.3.33"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"[[package]]name = "plain"version = "0.2.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"[[package]]name = "potential_utf"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"dependencies = [ "zerovec",][[package]]name = "ppv-lite86"version = "0.2.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"dependencies = [ "zerocopy",][[package]]name = "proc-macro2"version = "1.0.106"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"dependencies = [ "unicode-ident",][[package]]name = "quinn"version = "0.11.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", "socket2", "thiserror", "tokio", "tracing", "web-time",][[package]]name = "quinn-proto"version = "0.11.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", "rand 0.9.4", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", "thiserror", "tinyvec", "tracing", "web-time",][[package]]name = "quinn-udp"version = "0.5.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", "windows-sys 0.60.2",][[package]]name = "quote"version = "1.0.45"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"dependencies = [ "proc-macro2",][[package]]name = "r-efi"version = "5.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"[[package]]name = "rand"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"dependencies = [ "libc", "rand_chacha 0.3.1", "rand_core 0.6.4",][[package]]name = "rand"version = "0.9.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5",][[package]]name = "rand_chacha"version = "0.3.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"dependencies = [ "ppv-lite86", "rand_core 0.6.4",][[package]]name = "rand_chacha"version = "0.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"dependencies = [ "ppv-lite86", "rand_core 0.9.5",][[package]]name = "rand_core"version = "0.6.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"dependencies = [ "getrandom 0.2.17",][[package]]name = "rand_core"version = "0.9.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"dependencies = [ "getrandom 0.3.4",][[package]]name = "redox_syscall"version = "0.5.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"dependencies = [ "bitflags",][[package]]name = "redox_syscall"version = "0.7.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"dependencies = [ "bitflags",][[package]]name = "regex-automata"version = "0.4.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"dependencies = [ "aho-corasick", "memchr", "regex-syntax",][[package]]name = "regex-syntax"version = "0.8.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"[[package]]name = "reqwest"version = "0.12.28"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"dependencies = [ "base64", "bytes", "futures-core", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "webpki-roots",][[package]]name = "ring"version = "0.17.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"dependencies = [ "cc", "cfg-if", "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0",][[package]]name = "rsa"version = "0.9.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"dependencies = [ "const-oid", "digest", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", "pkcs8", "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize",][[package]]name = "rustc-hash"version = "2.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"[[package]]name = "rustls"version = "0.23.40"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"dependencies = [ "once_cell", "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize",][[package]]name = "rustls-pki-types"version = "1.14.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"dependencies = [ "web-time", "zeroize",][[package]]name = "rustls-webpki"version = "0.103.13"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"dependencies = [ "ring", "rustls-pki-types", "untrusted",][[package]]name = "rustversion"version = "1.0.22"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"[[package]]name = "ryu"version = "1.0.23"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"[[package]]name = "scopeguard"version = "1.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"[[package]]name = "serde"version = "1.0.228"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"dependencies = [ "serde_core", "serde_derive",][[package]]name = "serde_core"version = "1.0.228"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"dependencies = [ "serde_derive",][[package]]name = "serde_derive"version = "1.0.228"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "serde_json"version = "1.0.149"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"dependencies = [ "itoa", "memchr", "serde", "serde_core", "zmij",][[package]]name = "serde_path_to_error"version = "0.1.20"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"dependencies = [ "itoa", "serde", "serde_core",][[package]]name = "serde_urlencoded"version = "0.7.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"dependencies = [ "form_urlencoded", "itoa", "ryu", "serde",][[package]]name = "sha1"version = "0.10.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"dependencies = [ "cfg-if", "cpufeatures", "digest",][[package]]name = "sha2"version = "0.10.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"dependencies = [ "cfg-if", "cpufeatures", "digest",][[package]]name = "sharded-slab"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"dependencies = [ "lazy_static",][[package]]name = "shlex"version = "1.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"[[package]]name = "signal-hook-registry"version = "1.4.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"dependencies = [ "errno", "libc",][[package]]name = "signature"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"dependencies = [ "digest", "rand_core 0.6.4",][[package]]name = "simd-adler32"version = "0.3.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"[[package]]name = "siphasher"version = "1.0.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"[[package]]name = "slab"version = "0.4.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"[[package]]name = "smallvec"version = "1.15.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"dependencies = [ "serde",][[package]]name = "socket2"version = "0.6.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"dependencies = [ "libc", "windows-sys 0.61.2",][[package]]name = "spin"version = "0.9.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"dependencies = [ "lock_api",][[package]]name = "spki"version = "0.7.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"dependencies = [ "base64ct", "der",][[package]]name = "sqlx"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"dependencies = [ "sqlx-core", "sqlx-macros", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite",][[package]]name = "sqlx-core"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"dependencies = [ "base64", "bytes", "chrono", "crc", "crossbeam-queue", "either", "event-listener", "futures-core", "futures-intrusive", "futures-io", "futures-util", "hashbrown 0.15.5", "hashlink", "indexmap", "log", "memchr", "once_cell", "percent-encoding", "serde", "serde_json", "sha2", "smallvec", "thiserror", "tokio", "tokio-stream", "tracing", "url",][[package]]name = "sqlx-macros"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", "syn",][[package]]name = "sqlx-macros-core"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"dependencies = [ "dotenvy", "either", "heck", "hex", "once_cell", "proc-macro2", "quote", "serde", "serde_json", "sha2", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "syn", "tokio", "url",][[package]]name = "sqlx-mysql"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"dependencies = [ "atoi", "base64", "bitflags", "byteorder", "bytes", "chrono", "crc", "digest", "dotenvy", "either", "futures-channel", "futures-core", "futures-io", "futures-util", "generic-array", "hex", "hkdf", "hmac", "itoa", "log", "md-5", "memchr", "once_cell", "percent-encoding", "rand 0.8.6", "rsa", "serde", "sha1", "sha2", "smallvec", "sqlx-core", "stringprep", "thiserror", "tracing", "whoami",][[package]]name = "sqlx-postgres"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"dependencies = [ "atoi", "base64", "bitflags", "byteorder", "chrono", "crc", "dotenvy", "etcetera", "futures-channel", "futures-core", "futures-util", "hex", "hkdf", "hmac", "home", "itoa", "log", "md-5", "memchr", "once_cell", "rand 0.8.6", "serde", "serde_json", "sha2", "smallvec", "sqlx-core", "stringprep", "thiserror", "tracing", "whoami",][[package]]name = "sqlx-sqlite"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"dependencies = [ "atoi", "chrono", "flume", "futures-channel", "futures-core", "futures-executor", "futures-intrusive", "futures-util", "libsqlite3-sys", "log", "percent-encoding", "serde", "serde_urlencoded", "sqlx-core", "thiserror", "tracing", "url",][[package]]name = "stable_deref_trait"version = "1.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"[[package]]name = "stringprep"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"dependencies = [ "unicode-bidi", "unicode-normalization", "unicode-properties",][[package]]name = "subtle"version = "2.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"[[package]]name = "syn"version = "2.0.117"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"dependencies = [ "proc-macro2", "quote", "unicode-ident",][[package]]name = "sync_wrapper"version = "1.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"dependencies = [ "futures-core",][[package]]name = "synstructure"version = "0.13.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "thiserror"version = "2.0.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"dependencies = [ "thiserror-impl",][[package]]name = "thiserror-impl"version = "2.0.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "thread_local"version = "1.1.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"dependencies = [ "cfg-if",][[package]]name = "tinystr"version = "0.8.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"dependencies = [ "displaydoc", "zerovec",][[package]]name = "tinyvec"version = "1.11.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"dependencies = [ "tinyvec_macros",][[package]]name = "tinyvec_macros"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"[[package]]name = "tokio"version = "1.52.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"dependencies = [ "bytes", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2",][[package]]name = "tokio-macros"version = "2.7.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "tokio-rustls"version = "0.26.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"dependencies = [ "rustls", "tokio",][[package]]name = "tokio-stream"version = "0.1.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"dependencies = [ "futures-core", "pin-project-lite", "tokio", "tokio-util",][[package]]name = "tokio-util"version = "0.7.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio",][[package]]name = "tower"version = "0.5.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"dependencies = [ "futures-core", "futures-util", "pin-project-lite", "sync_wrapper", "tokio", "tower-layer", "tower-service", "tracing",][[package]]name = "tower-http"version = "0.6.11"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"dependencies = [ "async-compression", "bitflags", "bytes", "futures-core", "futures-util", "http", "http-body", "http-body-util", "http-range-header", "httpdate", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", "url",][[package]]name = "tower-layer"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"[[package]]name = "tower-service"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"[[package]]name = "tracing"version = "0.1.44"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"dependencies = [ "log", "pin-project-lite", "tracing-attributes", "tracing-core",][[package]]name = "tracing-attributes"version = "0.1.31"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "tracing-core"version = "0.1.36"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"dependencies = [ "once_cell", "valuable",][[package]]name = "tracing-log"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"dependencies = [ "log", "once_cell", "tracing-core",][[package]]name = "tracing-subscriber"version = "0.3.23"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"dependencies = [ "matchers", "nu-ansi-term", "once_cell", "regex-automata", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log",][[package]]name = "try-lock"version = "0.2.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"[[package]]name = "typenum"version = "1.20.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"[[package]]name = "unicase"version = "2.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"[[package]]name = "unicode-bidi"version = "0.3.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"[[package]]name = "unicode-ident"version = "1.0.24"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"[[package]]name = "unicode-normalization"version = "0.1.25"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"dependencies = [ "tinyvec",][[package]]name = "unicode-properties"version = "0.1.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"[[package]]name = "untrusted"version = "0.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"[[package]]name = "url"version = "2.5.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde",][[package]]name = "urlencoding"version = "2.1.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"[[package]]name = "utf8_iter"version = "1.0.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"[[package]]name = "valuable"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"[[package]]name = "vcpkg"version = "0.2.15"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"[[package]]name = "version_check"version = "0.9.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"[[package]]name = "want"version = "0.3.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"dependencies = [ "try-lock",][[package]]name = "wasi"version = "0.11.1+wasi-snapshot-preview1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"[[package]]name = "wasip2"version = "1.0.3+wasi-0.2.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"dependencies = [ "wit-bindgen",][[package]]name = "wasite"version = "0.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"[[package]]name = "wasm-bindgen"version = "0.2.121"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared",][[package]]name = "wasm-bindgen-futures"version = "0.4.71"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"dependencies = [ "js-sys", "wasm-bindgen",][[package]]name = "wasm-bindgen-macro"version = "0.2.121"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"dependencies = [ "quote", "wasm-bindgen-macro-support",][[package]]name = "wasm-bindgen-macro-support"version = "0.2.121"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"dependencies = [ "bumpalo", "proc-macro2", "quote", "syn", "wasm-bindgen-shared",][[package]]name = "wasm-bindgen-shared"version = "0.2.121"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"dependencies = [ "unicode-ident",][[package]]name = "web-sys"version = "0.3.98"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"dependencies = [ "js-sys", "wasm-bindgen",][[package]]name = "web-time"version = "1.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"dependencies = [ "js-sys", "wasm-bindgen",][[package]]name = "webpki-roots"version = "1.0.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"dependencies = [ "rustls-pki-types",][[package]]name = "whoami"version = "1.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"dependencies = [ "libredox", "wasite",][[package]]name = "windows-core"version = "0.62.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"dependencies = [ "windows-implement", "windows-interface", "windows-link", "windows-result", "windows-strings",][[package]]name = "windows-implement"version = "0.60.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "windows-interface"version = "0.59.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "windows-link"version = "0.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"[[package]]name = "windows-result"version = "0.4.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"dependencies = [ "windows-link",][[package]]name = "windows-strings"version = "0.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"dependencies = [ "windows-link",][[package]]name = "windows-sys"version = "0.48.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"dependencies = [ "windows-targets 0.48.5",][[package]]name = "windows-sys"version = "0.52.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"dependencies = [ "windows-targets 0.52.6",][[package]]name = "windows-sys"version = "0.60.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"dependencies = [ "windows-targets 0.53.5",][[package]]name = "windows-sys"version = "0.61.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"dependencies = [ "windows-link",][[package]]name = "windows-targets"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"dependencies = [ "windows_aarch64_gnullvm 0.48.5", "windows_aarch64_msvc 0.48.5", "windows_i686_gnu 0.48.5", "windows_i686_msvc 0.48.5", "windows_x86_64_gnu 0.48.5", "windows_x86_64_gnullvm 0.48.5", "windows_x86_64_msvc 0.48.5",][[package]]name = "windows-targets"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6",][[package]]name = "windows-targets"version = "0.53.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"dependencies = [ "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", "windows_x86_64_msvc 0.53.1",][[package]]name = "windows_aarch64_gnullvm"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"[[package]]name = "windows_aarch64_gnullvm"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"[[package]]name = "windows_aarch64_gnullvm"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"[[package]]name = "windows_aarch64_msvc"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"[[package]]name = "windows_aarch64_msvc"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"[[package]]name = "windows_aarch64_msvc"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"[[package]]name = "windows_i686_gnu"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"[[package]]name = "windows_i686_gnu"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"[[package]]name = "windows_i686_gnu"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"[[package]]name = "windows_i686_gnullvm"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"[[package]]name = "windows_i686_gnullvm"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"[[package]]name = "windows_i686_msvc"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"[[package]]name = "windows_i686_msvc"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"[[package]]name = "windows_i686_msvc"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"[[package]]name = "windows_x86_64_gnu"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"[[package]]name = "windows_x86_64_gnu"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"[[package]]name = "windows_x86_64_gnu"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"[[package]]name = "windows_x86_64_gnullvm"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"[[package]]name = "windows_x86_64_gnullvm"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"[[package]]name = "windows_x86_64_gnullvm"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"[[package]]name = "windows_x86_64_msvc"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"[[package]]name = "windows_x86_64_msvc"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"[[package]]name = "windows_x86_64_msvc"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"[[package]]name = "wit-bindgen"version = "0.57.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"[[package]]name = "writeable"version = "0.6.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"[[package]]name = "yoke"version = "0.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"dependencies = [ "stable_deref_trait", "yoke-derive", "zerofrom",][[package]]name = "yoke-derive"version = "0.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"dependencies = [ "proc-macro2", "quote", "syn", "synstructure",][[package]]name = "zerocopy"version = "0.8.48"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"dependencies = [ "zerocopy-derive",][[package]]name = "zerocopy-derive"version = "0.8.48"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "zerofrom"version = "0.1.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"dependencies = [ "zerofrom-derive",][[package]]name = "zerofrom-derive"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"dependencies = [ "proc-macro2", "quote", "syn", "synstructure",][[package]]name = "zeroize"version = "1.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"[[package]]name = "zerotrie"version = "0.2.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"dependencies = [ "displaydoc", "yoke", "zerofrom",][[package]]name = "zerovec"version = "0.11.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"dependencies = [ "yoke", "zerofrom", "zerovec-derive",][[package]]name = "zerovec-derive"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "zip"version = "2.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"dependencies = [ "arbitrary", "crc32fast", "crossbeam-utils", "displaydoc", "flate2", "indexmap", "memchr", "thiserror", "zopfli",][[package]]name = "zmij"version = "1.0.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"[[package]]name = "zopfli"version = "0.8.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"dependencies = [ "bumpalo", "crc32fast", "log", "simd-adler32",]
added Cargo.toml
@@ -0,0 +1,34 @@[package]name = "finance"version = "0.1.0"edition = "2021"[dependencies]axum = { version = "0.8", features = ["macros"] }tokio = { version = "1", features = ["full"] }tokio-stream = { version = "0.1", features = ["sync"] }tower = { version = "0.5", features = ["util"] }tower-http = { version = "0.6", features = ["fs", "set-header"] }minijinja = { version = "2", features = ["loader", "loop_controls", "json"] }serde = { version = "1", features = ["derive"] }serde_json = "1"sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate", "chrono"] }chrono = { version = "0.4", features = ["serde"] }chrono-tz = "0.10"anyhow = "1"thiserror = "2"tracing = "0.1"tracing-subscriber = { version = "0.3", features = ["env-filter"] }dotenvy = "0.15"reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "brotli", "json"] }async-trait = "0.1"futures-util = "0.3"async-stream = "0.3"csv = "1"zip = { version = "2", default-features = false, features = ["deflate"] }urlencoding = "2"[profile.release]lto = truecodegen-units = 1strip = true
added Dockerfile
@@ -0,0 +1,46 @@# syntax=docker/dockerfile:1# ----- builder -----FROM rust:alpine AS builderRUN apk add --no-cache musl-dev pkgconfig openssl-devCOPY --from=oven/bun:alpine /usr/local/bin/bun /usr/local/bin/bunWORKDIR /appCOPY Cargo.toml Cargo.lock ./COPY src ./srcCOPY migrations ./migrationsCOPY frontend ./frontendRUN cd frontend && bun install --frozen-lockfile && bun run buildRUN --mount=type=cache,target=/usr/local/cargo/registry \    --mount=type=cache,target=/app/target \    cargo build --release && \    cp target/release/finance /app/finance# ----- runtime -----FROM alpine:3.23# Outbound HTTPS to Stooq, Yahoo, and SEC EDGAR.RUN apk add --no-cache ca-certificatesWORKDIR /appCOPY --from=builder /app/finance ./financeCOPY --from=builder /app/dist ./distCOPY templates ./templatesCOPY migrations ./migrationsCOPY universe ./universeRUN addgroup -S -g 1000 app && \    adduser -S -h /app -s /sbin/nologin -u 1000 -G app app && \    mkdir -p /data && chown -R app:app /app /dataUSER appENV PORT=8000ENV FINANCE_DATA_DIR=/dataEXPOSE 8000CMD ["./finance"]
added Makefile
@@ -0,0 +1,37 @@CARGO ?= $(HOME)/.cargo/bin/cargoPORT  ?= 8000.DEFAULT_GOAL := run.PHONY: run build start clean push seed# Dev: Vite watch + cargo run concurrently. Both die on Ctrl+C.run: frontend/node_modules dist/.vite/manifest.json	@trap 'kill 0' EXIT INT TERM; \	(cd frontend && bun run dev) & \	PORT=$(PORT) $(CARGO) run# Production build (Vite assets + release binary)build: frontend/node_modules	cd frontend && bun run build	$(CARGO) build --release# Run the release binary (after `make build`)start:	PORT=$(PORT) ./target/release/finance# Wipe everything regenerable: build output, frontend deps, local data dir.clean:	rm -rf target dist frontend/node_modules datapush:	git remote | xargs -I R git push R master# Re-run the universe seed (curated symbols + bulk daily history). Idempotent.seed:	$(CARGO) run -- seedfrontend/node_modules:	cd frontend && bun installdist/.vite/manifest.json: frontend/node_modules	cd frontend && bun run build
added PLAN.md
@@ -0,0 +1,995 @@# finance — Project Plan and Resume Doc`finance` is a self-hosted, real-timeish market-watching web app for stocks,ETFs, and indexes: live charts, key stats, fundamentals, and SEC filings. It isa single Rust + axum binary backed by SQLite, with a Vite frontend. It deploysat `finance.bythewood.me` and is published on GitHub as `finance`.It is for *watching* the market only. No portfolio, no holdings, no money orcost-basis tracking, no accounts, no auth. Single operator.---## How to use this fileThis is the **living resume document**. The user periodically clears the AIcontext to save tokens and resumes work from this file alone.**Keep it current.** After every phase, significant decision, or change ofdirection: update the **Status** section and append to the **Decisions log**.The file must always be accurate enough that a fresh context can continue withnothing else. Treat updating it as part of finishing any unit of work.**This is vibe-coded: the user riffs ideas.** When the user floats an idea,budget it into this file right away, into the relevant phase or the Designsection, rather than acting on it immediately. Phases are deliberately kept assmall, self-contained cutoffs so the user can clear the AI context between themand resume cleanly from this file alone, keeping token use low.---## Status_Last updated: 2026-05-22_**Current phase:** Phases 0 through 11 complete and verified. Next: Phase 12(Polish + ship).**Roadmap (restructured 2026-05-22, see decisions log):** the home-pageredesign and commodities are pre-ship MVP phases. Order: 9 Search +add-symbol, 10 Commodities & futures, 11 Home dashboard redesign, 12 Polish +ship. Post-MVP backlog is phases 13 through 19.**Watchlists dropped from the MVP (2026-05-22, see decisions log):** the userno longer wants watchlists for now and wants the app to stay an opinionated,no-customization view of the market. Phase 9 was reshaped from "Watchlists +search" to "Search + add-symbol"; watchlists are parked in the post-MVPbacklog as Phase 19. The `watchlists` / `watchlist_items` tables stay in theschema, unused for now.**Done**- **Phase 0 skeleton** — complete, verified. `make run` serves the dark  futuristic dashboard on port 8000, migrations apply on boot, routes plus a  themed 404 work, the request log is colored.- **Phase 1 universe + history** — complete, verified.  - `universe/starter.csv`: 144 symbols (6 indexes, 28 ETFs, ~110 stocks).  - Stooq history provider with apikey; `seed.rs` is resumable (skips symbols    that already have history) and has a circuit breaker.  - Seed run populated deep daily history for **142 of 144** symbols (e.g.    AAPL 10,507 bars, ^SPX 39,701 bars back to 1789). `^RUT` and `^VIX` return    "No data" from Stooq, so they hold no history; they will still get live    quotes from Yahoo in Phase 3. `meta.seed_completed` is intentionally NOT    set while those 2 remain historyless.  - Dashboard `/` shows the symbol grid with prices; `/s/{ticker}` shows a    working lightweight-charts candlestick chart with range selectors and key    stats; the history JSON API works; unknown tickers 404 with the theme.- **Phase 2 scheduler + incremental history.** Complete, verified.  - `src/scheduler.rs`: one long-lived tokio task on a 60s tick, modelled on    `status/src/scheduler.rs`. On boot it resets stale `fetching` states and    runs the first-run seed while `meta.seed_completed` is unset; then each    cycle it runs the incremental daily-history refresh when due and the prune.  - Incremental history: re-fetches only symbols whose `history_synced_at` is    older than 20h (stale), asking Stooq for the window since each symbol's    last stored bar, reusing `seed::store_daily`. Paced at 1.5s/request with a    4-consecutive-error circuit breaker. Falls due ~every 6h.  - Prune: drops `intraday_bars` older than 14d and `fetch_log` older than    30d, ~daily. `daily_prices` is permanent and never pruned.  - Every data job upserts its `data_status` row (idle/fetching/ok/error plus    `next_run_at`) and appends one bulk `fetch_log` row (ticker NULL).  - Verified: boot ran the seed and prune (seed `data_status` + `fetch_log`    rows written); a staged-state run exercised the history job, which    refreshed AAPL/MSFT incrementally, retried the 2 historyless indexes, and    held the breaker at 2/4.- **Phase 3 endpoint guardrails.** Complete, verified.  - Migration `0002_endpoint_guard.sql` adds the `endpoint_guard` table: one    row of guard state per upstream (today only `stooq`).  - `src/guard.rs` holds `EndpointGuard`: a persistent, DB-backed reactive    circuit breaker, a hard per-hour request budget, and request pacing.    `acquire()` grants or denies one request (sleeping for the 1.5s pacing gap    on a grant); `record_success` / `record_failure` feed the outcome back.  - Breaker: trips immediately on an explicit HTTP 429/503 signal, or after 4    consecutive ordinary failures; exponential backoff per trip (30m, 1h, 2h,    ..., capped at 24h, honoring a longer `Retry-After`); a single half-open    probe closes it on success or re-opens it (longer) on failure. Budget: 200    requests per rolling clock hour, then requests are refused until the hour    rolls. All state is in SQLite, so it survives restarts and is shared by    the server and the `finance seed` subcommand.  - `seed.rs` and `scheduler.rs` retrofitted: every Stooq call now goes    through the guard; the old ad-hoc per-run consecutive-error breaker and    manual `sleep` pacing are gone. A guard-denied run stops cleanly (the seed    is resumable; the history job logs `skipped` and retries next cycle).  - `stooq.rs`: a 429/503 now surfaces as a typed `RateLimited` error the    guard recognizes by downcast. Stooq's plain "No data" reply (^RUT, ^VIX)    is now treated as a successful empty response, not a failure, so genuinely    historyless symbols never feed the breaker.  - Verified with staged runs: a bad apikey tripped the breaker after exactly    4 failures (30m backoff); a restart with the breaker still open denied the    history job immediately with zero requests (state persists across    restarts); a half-open probe with a good key recovered the breaker and    refreshed all 10 staged symbols; pacing held requests 1.5s apart and the    hourly budget accumulated across separate runs.- **Phase 4 visual redesign: Paper Ledger.** Complete, verified.  - Design system in `frontend/static_src/base/styles/`: a warm-paper palette    (paper / surface / well, ink / ink-dim / ink-faint), hairline rules, and    semantic green/amber/red as the only hues. Tokens are CSS custom    properties in `base.scss :root`; build-time mixins in `_mixins.scss`.  - Typography: Source Serif 4 (headings only), Inter (body / UI), JetBrains    Mono (figures), self-hosted via `@fontsource`. `space-grotesk` dropped.  - New ink brand mark and favicon — a rising figures line over the    accountant's double underline — replace the neon candlesticks    (`base.html`, `seo.rs`). Re-themed the base shell, home dashboard, ticker    cards, symbol page, 404, and the lightweight-charts theme.  - Symbol-page key stats rebuilt from a flat card grid into three skimmable    gauges over a shared `.track` meter primitive: the day's open/close on its    low-high range, the price on its 52-week range (current + prev close    marked), and volume vs its 3-month average. Marker positions are derived    in `symbols.rs` via a new `compute::pos` helper; no new network calls.  - Chart interaction reworked (a mid-phase user request): horizontal pan and    zoom are disabled, and a Google-Finance-style drag-to-measure tool added.  - A dedicated UI polish pass is intentionally deferred to the ship phase    (Phase 12 after the 2026-05-22 renumber); see the decisions log.- **Phase 5 live quotes + SSE.** Complete, verified.  - `src/market.rs`: US equity session clock in `America/New_York` via    `chrono-tz` — `Session` (Closed/Pre/Regular/Post) plus the helpers the    scheduler keys on. No holiday calendar (deliberate; see decisions log).  - `src/providers/yahoo.rs`: `YahooProvider` behind a new `QuoteProvider`    trait. One `v8/finance/chart` call (`interval=15m&range=1d`) returns a live    quote and the day's 15-minute bars together. Maps `^SPX`->`^GSPC`,    `^NDQ`->`^IXIC`; surfaces 429/503 as the typed `RateLimited` the guard    recognises. Yahoo's chart `meta` carries no `marketState` /    `regularMarketOpen`, so those columns stay null and the header's freshness    label is derived from our own session clock instead.  - `src/stream.rs`: the `Hub` — a `tokio::broadcast` channel plus a per-ticker    viewer-interest registry. `src/routes/stream.rs`: the `/stream` SSE    endpoint — registers interest in `?symbols=` (validated against the    universe so a client cannot steer the poller at arbitrary symbols), emits    an initial `market` event and a `quote` snapshot, then forwards live    events; interest is released when the stream drops.  - Scheduler: a demand-driven `intraday` job (market-hours only, polls *only*    the symbols a browser is viewing right now — nothing when nobody is    watching) and a once-a-day `daily_close` job (snapshots the whole universe    shortly after 16:00 ET). Both route through a new `yahoo` `EndpointGuard`    (1000/hr budget; `EndpointGuard::with_budget` was added). Quotes upsert the    `quotes` table and the denormalized `symbols` snapshot columns; intraday    bars upsert `intraday_bars` (the prune job already covers them).  - Frontend `base/scripts/stream.js`: one `EventSource`, patches `data-field`    price/change nodes in place, flashes cards on a move, drives the    market-state pill. Closes cleanly on `pagehide` (and reconnects from the    bfcache) so navigating between pages no longer aborts the stream    mid-response.  - The home dashboard and the symbol header now prefer the live quote    (`symbols.last_price` / `prev_close`), falling back to the last daily close.  - Verified: boot ran `daily_close` for 2026-05-20 — 144/144 symbols, 0    errors, 144 `quotes` rows and 8,884 `intraday_bars`; the `yahoo` guard sat    closed (0 failures). `/stream` held a stable 20s connection with the    keep-alive ping; the SSE snapshot and `market` event arrived; the symbol    header rendered the live quote (`$417.26  +13.15 (+3.25%)  At close`); the    pill showed "Market closed"; navigating pages left zero console errors.    The live intraday poll itself is gated to market hours, so it first runs    at the next open — its fetch/store/broadcast path is exactly the one    `daily_close` exercised end to end.- **Phase 6 data health page.** Complete, verified.  - `routes/health.rs`: `GET /health` renders the page; `GET /api/health`    returns the same snapshot as JSON. Both build one `Health` snapshot —    every `endpoint_guard` row (breaker state, trips, hourly budget used),    every `data_status` job (state, last-ok, next-run, last error), and the    50 newest `fetch_log` rows.  - The page is rendered entirely by `health/scripts/health.js` from that    snapshot — one renderer, no server/client duplication. The page route    embeds the initial snapshot in a `<script type="application/json">` blob    (`<`/`>`/`&` escaped to `\uXXXX`) so it draws with no flash; the script    then re-pulls `/api/health` on each live nudge, every 30s (to keep the    relative times honest), and whenever the tab regains focus.  - Liveness rides the Phase 5 SSE hub. A new content-free `StreamEvent::Health`    is published by the scheduler whenever a job marks `fetching` or writes a    status / log row (~9 ping points across the five job runners). `/stream`    forwards it as an `event: health` frame; `base/stream.js` re-broadcasts    that as a `finance:health` window event, so the health page reacts without    opening a second EventSource. A live amber "fetching now" banner shows    while any job is mid-fetch.  - Migration `0003` adds `endpoint_guard.hourly_budget`. The guard's `load()`    writes and self-corrects it, so the page shows "used / budget" straight    from the table with no upstream ceilings hardcoded in the route. A new    `register_endpoints` boot step touches both guards so their rows — and    budgets (stooq 200, yahoo 1000) — are right from the first boot.  - A discreet "data health" link sits in the footer; a `--warn-soft` token    was added to the palette for the amber data-health states.  - Verified: migration `0003` applied cleanly on the seeded DB; `/api/health`    returns the snapshot (stooq 8/200, yahoo 144/1000 — budget correct from    boot); `/health` renders endpoints, jobs and the log tail with zero console    errors on desktop and at 375px (no horizontal overflow); a `curl` held    across a server boot received the `event: health` frames the seed and    prune jobs published; dispatching `finance:health` in the page pulled a    fresh `/api/health` and repainted in place.- **Phase 7 fundamentals + filings.** Complete, verified.  - `src/providers/sec.rs`: a `SecProvider` behind the new `FundamentalsProvider`    trait. Three SEC EDGAR endpoints, no key (the contact email is appended to    the User-Agent by `http::build_sec_client`): `company_tickers.json` for the    bulk ticker->CIK map, `companyfacts` for XBRL facts, `submissions` for    filing history. A 404 is a definitive empty (not a breaker failure); a    429/503 surfaces as the typed `RateLimited`.  - companyfacts is parsed defensively. SEC's `fy` field tags a fact with the    *filing's* fiscal year, not the period's, so a comparative figure in a    later 10-K is mislabelled by it (this bit during verification: every figure    was shifted two years). The fiscal year is instead derived from the    period-end date plus the company's fiscal-year-end month (the mode of its    annual end months), so e.g. AAPL's Oct-Dec quarter reads as Q1 of the next    fiscal year. Only clean full-year and discrete-quarter durations are kept    (year-to-date roll-ups dropped by span length); quarterly balance-sheet    figures are deliberately not collected (a 10-Q mis-tags its prior-year-end    comparative). Ten metrics: revenue, net income, diluted EPS, diluted    shares, dividend per share, total and current assets, total and current    liabilities, equity.  - Migration `0004` rekeys `fundamentals` UNIQUE to (ticker, metric, period)    so an annual and a same-period-end quarterly figure no longer collide.  - One `sec` scheduler job (daily due-check, weekly per-company staleness):    resolves any missing CIKs from one bulk call, then sweeps stale stocks for    companyfacts + submissions through a new `sec` `EndpointGuard` (600/hr).    Resumable: each company's `fundamentals_synced_at` / `filings_synced_at` is    stamped only on success. Gated on `SEC_CONTACT_EMAIL` being set.  - `compute.rs`: nine graded ratios (P/E, dividend yield, profit margin, ROE,    ROA, debt-to-equity, current ratio, revenue growth, earnings growth),    computed off the latest full fiscal year plus the live price. Each carries    a good/ok/bad `Grade`, a one-word verdict, a value-specific plain-English    reading, and a static "how to read it" explainer.  - Symbol page (`routes/symbols.rs`, `symbol.html`): a Fundamentals section of    graded ratio cards (semantic green/amber/red on the value plus a verdict    pill), a Financials section with an annual/quarterly table toggle    (`fundamentals.js`), and a Recent SEC filings list linking out to EDGAR.    Stocks only; a stock not yet synced shows a pending note, ETFs and indexes    show none of it.  - `/health` gained the `sec` endpoint and the `sec` job.  - Verified: migration `0004` applied cleanly on the seeded DB; the boot SEC    sweep resolved all 110 stock CIKs from one bulk call and stored    fundamentals + filings for 110/110 companies with 0 errors (the `sec`    guard sat closed). `/s/AAPL` renders the nine graded ratio cards (P/E    40.9x Weak, profit margin 26.9% Strong, current ratio 0.89 Weak, ...), the    annual/quarterly Financials toggle, and 18 SEC filings linking to EDGAR;    fiscal years line up with the period-end dates, AAPL's December-ending    quarter reads as Q1 of the next fiscal year and MSFT's June fiscal year    likewise. ETF and index pages show none of the SEC sections; a not-yet-    synced stock shows the pending note. Desktop and 375px both render with no    horizontal overflow and zero console errors. The drag-to-measure chart    readout now clears together with its selection band.- **Phase 8 chart indicators + range readout.** Complete, verified.  - `compute.rs`: three pure numeric indicator functions — `sma`, `ema`    (seeded with the first simple average, then `2/(period+1)` weighting),    and Wilder's `rsi` — each taking a slice of closes and returning one    `Option<f64>` per bar (`None` through the warm-up period). The maths    lives here, not in SQL or JS.  - `routes/symbols.rs`: `/api/symbols/{ticker}/history` now returns an object    — `candles` plus `sma50`, `sma200`, `ema21`, `rsi14` line series — rather    than a bare candle array. It fetches a fixed 320-day lookback *before* the    requested range, computes the indicators across the whole set, then trims    every series to the visible window, so even the 200-day average is correct    from the very first shown bar (verified: a 1M view has all four indicators    populated from bar 1).  - `chart.js`: SMA 50 / SMA 200 / EMA 21 drawn as toggleable overlay line    series; a volume histogram pinned to the bottom strip on its own price    scale; RSI in a second pane, created/destroyed on toggle (30/70 guide    lines, pinned 0..100) so no empty pane lingers while it is off. A toggle    row of indicator buttons, each with a line-swatch chart.js paints from its    own palette; defaults SMA 50/200 + volume on, EMA + RSI off.  - Indicator overlays use a muted, non-semantic ink palette (dusty blue /    brown / violet): the candles own green/red and the app reserves    green/amber/red for good/ok/bad, so the lines must not borrow them. Noted    as a deliberate exception to the semantic-color rule.  - A range-change chip beside the range buttons shows the % and absolute move    over the chart's *visible* span, computed from the visible logical range    (not the raw candle array) so it always agrees with what is drawn. A deep    MAX history (e.g. `^SPX` back to 1789 at $0.51) is clamped by    lightweight-charts to what legibly fits, and the chip then honestly    reports just that visible span ("over 8 years") instead of an absurd    +1,457,350%.  - Verified: indicator maths correct and lookback-accurate across 1M/1Y/MAX;    all five toggles work; the RSI pane creates and destroys cleanly; the    range chip tracks the selected range and the visible span; the    drag-to-measure tool still works (`▲ +34.91%` over a dragged interval);    desktop and 375px render with no overflow and zero console errors;    index / ETF / historyless (`^VIX`) pages all handled.- **Phase 9 search + add-symbol.** Complete, verified.  - `routes/search.rs`: `GET /search` browses and searches the universe. One    SQL query backs both modes: an empty `q` lists everything, a non-empty    `q` matches ticker and company name (`LIKE`, wildcards escaped); a `kind`    filter (index / etf / stock) narrows it; results cap at 240, ordered    exact-ticker, then ticker-prefix, then index/etf/stock, then alphabetical.    Reuses the `ticker_card` macro, so the live stream patches prices in place    exactly as on the Markets grid.  - `routes/symbols.rs`: `POST /api/symbols` adds a symbol the universe does    not hold yet. The ticker is validated and described in one guarded Yahoo    request (`YahooProvider::lookup`, new); the symbol row is inserted    (`is_seeded = 0`), the quote that same request returned is stored, and the    history job is brought forward (`schedule_next`) so the deep daily    backfill lands within a scheduler tick rather than after the ~6h interval.    Idempotent (an existing symbol is reported, not duplicated); rejects an    unknown symbol (404), an unmodelled instrument type such as a future    (422), a malformed ticker (400), and a guard denial (503).  - `providers/yahoo.rs`: refactored around a shared `fetch_chart`. The new    `lookup` reads the chart `meta` (`instrumentType` / `longName` /    `exchangeName` / `currency`) to classify the symbol (EQUITY to stock,    ETF / MUTUALFUND to etf, INDEX to index; anything else is `Unsupported`).    A 404 or a `chart.error` body is a clean "unknown symbol", not a guard    failure.  - The Search page shows an "Add <TICKER>" affordance only on a genuine    zero-results miss for a plausible ticker; `search/scripts/search.js` POSTs    it to `/api/symbols` and, on success, lands on the new symbol's page.  - Scheduler: the incremental history and daily-close jobs no longer filter    on `is_seeded`, so a user-added symbol is backfilled and snapshotted like    a curated one. The seed itself stays curated-list-only.  - `Card` / `to_card` moved from `routes/home.rs` to `models.rs` so Search    and the Markets dashboard render the same tile. New Vite entry `search`.    The watchlists nav links are gone; topnav and bottom nav are now Markets    / Search.  - User-added symbols are reachable through Search and their own `/s/` page    but are deliberately kept off the curated Markets grid; Phase 11's home    redesign decides their placement.  - Verified: browse lists 145 symbols and the kind filter narrows correctly;    ticker and company-name search both work; the add affordance shows only    on a zero-results miss. `POST /api/symbols` added RBLX and ZM (real Yahoo    stocks absent from the starter list) with their names and quotes; a    re-add reported `added:false`; ZZZZ returned 404 and a malformed ticker    400. The triggered history job backfilled ZM to 1,783 daily bars within a    tick, and `/s/ZM` then rendered a full candlestick chart with indicators.    Desktop and 390px both render with no overflow and zero page console    errors.- **Phase 10 commodities & futures.** Complete, verified.  - New symbol `kind` of `future`. `universe/starter.csv` gains 9 curated    futures (153 symbols total): index futures `ES=F` / `NQ=F` / `YM=F` and    commodity futures `CL=F` (WTI) / `BZ=F` (Brent) / `GC=F` / `SI=F` /    `HG=F` / `NG=F`. Yahoo serves these as `=F` symbols, which pass through    `yahoo_symbol` unchanged. No schema migration: `kind` is free-text TEXT.  - `providers/yahoo.rs`: `symbol_info` now classifies Yahoo's `FUTURE`    instrument type as `future` (previously an `Unsupported` rejection); the    no-instrument-type fallback also reads a trailing `=F` as a future. So the    quote provider, the `/stream` SSE path, the `daily_close` / `intraday`    jobs, and the Phase 9 add-symbol flow all handle futures with no further    change.  - Futures are live-quotes-only: Stooq carries no `=F` history, so the seed    and the incremental-history job both exclude `kind = 'future'` (no wasted    Stooq calls — not even the "No data" round-trip the historyless indexes    `^RUT`/`^VIX` still make). Their price data comes solely from Yahoo: the    once-daily `daily_close` snapshot of the whole universe, plus demand-driven    intraday polling while a future's page is open. A future therefore has no    `daily_prices` row and its symbol-page candlestick chart is empty, exactly    like `^VIX`; the header still shows a live quote.  - `routes/symbols.rs`: `valid_ticker` now accepts `=`, so a future is    addable through Search like any stock; the add-symbol "unsupported type"    message lists futures as allowed.  - The Markets home page gained a "Futures & commodities" section between    Indexes and Symbols; Search gained a "Futures" kind-filter pill; both the    home and search orderings sort index, then future, then etf, then stock.  - Verified: boot seed parsed 153 symbols and queued only `^RUT`/`^VIX` for a    Stooq backfill (the 9 futures excluded). `/` shows the 9-card Futures    section, `/search?kind=future` returns 9, `q=gold` matches `GC=F`.    `/s/GC=F` (and the `%3D`-encoded link form) renders with FUTURE/COMEX    tags and an empty chart. `POST /api/symbols` for `RTY=F` (an off-basket    Russell future) returned `kind:future, added:true` and `/s/RTY=F` showed    a live $2,852.90 quote — confirming the whole Yahoo `=F` path; the test    symbol was then removed. Desktop renders with no layout breakage and no    new console errors.- **Phase 11 home dashboard redesign.** Complete, verified.  - `routes/home.rs` rewritten: the flat ~155-card grid is gone, replaced by    an opinionated, no-customization dashboard. The full browsable universe    now lives solely on `/search`; a "Browse all N symbols" link points there.  - Top row: nine sparkline cards — the six indexes (^SPX, ^DJI, ^NDX, ^NDQ,    ^RUT, ^VIX) then the headline commodities (CL=F crude, GC=F gold, NG=F    natural gas), a hardcoded curated set (`DASHBOARD` const). Each card shows    a tiny current-session intraday line, the live price, and the day's %    change.  - The sparkline is server-rendered SVG: `compute::sparkline` maps a    session's `intraday_bars` closes into polyline + area-fill points in a    fixed `0 0 100 36` viewBox, with a faint dashed rule at the prior close.    The latest session is isolated by a 23h window off each symbol's most    recent bar. A symbol with no intraday bars shows a graceful "no intraday    data" placeholder.  - Movers: two panels, the day's top 8 gainers and top 8 losers, drawn from    the curated large-cap stocks only (`is_seeded = 1 AND kind = 'stock'`) —    deliberately not the whole universe, so a small user-added symbol's noise    never crowds out a name worth noticing. Each row carries a soft magnitude    tint scaled to the largest move shown across both panels.  - Live: the dashboard registers stream interest in exactly its nine    sparkline tickers (the `/stream?symbols=` query carries those nine and    nothing else), so the demand-driven intraday job polls them — and only    them — while the page is open. The movers panels are a fixed page-load    snapshot (no `data-ticker`), keeping the polled set small and on-budget.    The stream client patches each card's price/change in place, flips its    up/down colour, flashes it on a move, and nudges the sparkline's trailing    point onto the live price (`paintSparkline`, mirroring    `compute::sparkline`'s y-scale via `data-lo`/`data-hi`).  - New Vite `home` entry (`static_src/home/`) carries the dashboard styles;    the `spark_card` and `mover_row` macros join `ticker_card` in    `macros.html`.  - Verified: `/` renders the nine sparkline cards and the two movers panels    in the Paper Ledger look; the `/stream` request registered exactly the    nine dashboard tickers; a forced daily-close snapshot populated the    commodity cards (Yahoo serves the `=F` symbols cleanly); movers showed    IBM +12% / INTU -20% with correctly scaled magnitude tints; desktop    (1280px) and phone (390px) both render with no horizontal overflow and    zero console errors.**Resuming, next action**Start **Phase 12 (Polish + ship)**: sitemap, favicon, the final Paper Ledgerpolish pass (deferred from Phase 4), Dockerfile, docker-compose, sample files,README, CLAUDE.md, `git init`. See the Phase 12 entry below.Note: because `^RUT`/`^VIX` stay historyless, `meta.seed_completed` is neverset, so the boot seed re-runs on every restart (cheap: ~2 Stooq calls thatreturn "No data", which the guard now counts as successful empty responses,everything else being a local upsert) and then defers the first incrementalhistory run by 6h. This is intended; see the decisions log. The 9 futures areexcluded from the seed entirely, so they add nothing to this.**Build/run reminders for a fresh context**- Build: `~/.cargo/bin/cargo build --manifest-path /home/dev/code/finance/Cargo.toml`  (cargo is not on `PATH`; use the full path).- Run the dev binary: `FINANCE_ROOT=/home/dev/code/finance PORT=8000 ./target/debug/finance`.- `.env` exists with `STOOQ_APIKEY` (gitignored). The frontend is already built  into `dist/`; rebuild it with `cd frontend && bun run build` after JS/SCSS edits.- The DB at `data/db.sqlite3` is seeded; do not wipe it.---## Data sourcesAll free, no account, no API key.- **Historical daily OHLCV — Stooq.** Endpoint  `https://stooq.com/q/d/l/?s=<symbol>&i=d&apikey=<key>`. Stooq gates this  endpoint behind an apikey obtained once via a captcha on stooq.com; the key  lives in `.env` as `STOOQ_APIKEY` (gitignored, never committed). One call  returns a symbol's entire daily history (decades). Behind the  `HistoryProvider` trait, so swappable.- **Intraday bars and live quotes — Yahoo Finance.** Endpoint  `https://query1.finance.yahoo.com/v8/finance/chart/<symbol>`. No key, just a  browser User-Agent; `interval=15m` returns intraday bars and the response  `meta` block carries a live quote (price, previous close, day high/low,  volume). Behind the `QuoteProvider` trait (added in Phase 5). Note: the  chart `meta` does not include a market-state field, so the app derives the  trading session from its own `market.rs` clock.- **Fundamentals and filings — SEC EDGAR.**  `company_tickers.json` (ticker to CIK), `data.sec.gov/api/xbrl/companyfacts`  (XBRL facts), `data.sec.gov/submissions` (filing history). No key; SEC asks  consumers to identify themselves, so `SEC_CONTACT_EMAIL` is appended to the  User-Agent on SEC requests only. Stocks only; ETFs and indexes do not file.P/E and dividend yield are **computed** in `src/compute.rs` from SEC EPS anddividends plus the latest price; they are never stored.### Anti-spam / caching policyThe user's hard requirement: minimal external calls, maximal local data, neverspam an endpoint.- Validate an endpoint with **one** request before relying on a loop over it.- Bulk jobs carry a **circuit breaker**: abort after a few (4) consecutive  request errors instead of grinding the whole list.- The seed is **resumable**: symbols that already have history are skipped, so  a quota-limited run continues on the next `make seed`.- The full per-symbol backfill runs **once**; results are stored permanently  in `daily_prices` and never re-fetched in full.- Bulk loops are paced at >= 1.5 s per request.- Ongoing network use is small: a once-daily recent-window increment, and  15-minute intraday polling for watched symbols only, during market hours only.- Everything is cached in SQLite; the network is touched only for increments.**Phase 3 hardened this into the `EndpointGuard`** (`src/guard.rs`, shipped).The breaker and pacing are no longer ad-hoc per-run state inside `seed.rs` and`scheduler.rs`: they are a persistent, per-endpoint guard, backed by the`endpoint_guard` table, that every outbound call passes through. It adds a hardper-hour request budget and trips at once on an explicit rate-limit signal(429/503, honoring `Retry-After`), so a rate limit cannot be hit even acrossrestarts or by a future job.---## Architecture- **Stack:** Rust + axum 0.8, single binary. sqlx + SQLite (WAL). minijinja  templates. Vite frontend built with bun. lightweight-charts for charts.- **Conventions:** match the sibling apps (`status`, `repos`). Tiny `main.rs`;  `app.rs` builds `AppState` + `Config` + `router`; per-feature modules under  `src/routes/`; `render.rs` / `middleware.rs` / `templates.rs` helpers; a  Jinja2-faithful HTML formatter; assets resolved via `vite_asset` reading  `dist/.vite/manifest.json`.- **Providers:** traits in `src/providers/` (`HistoryProvider`, and later  `QuoteProvider`, `FundamentalsProvider`), one struct per source, so a source  is swappable without touching callers.- **Scheduler (`scheduler.rs`):** one long-lived tokio loop running  market-hours-aware background jobs, writing `data_status` and `fetch_log`  and pinging the stream hub so the `/health` page tracks it live.- **Endpoint guard (`guard.rs`):** a persistent, per-endpoint `EndpointGuard`  (reactive circuit breaker + hard per-hour request budget + pacing,  DB-backed) that every outbound data call passes through. Shipped in Phase 3;  see the Anti-spam policy.- **Real-time (`stream.rs`, shipped Phase 5):** a `tokio::sync::broadcast` hub  that also carries a per-ticker viewer-interest registry; the scheduler  publishes quote and market-session events; the `/stream` axum SSE endpoint  forwards them; the browser uses `EventSource` and patches the DOM in place.  The registry makes intraday polling demand-driven — only the symbols a  browser is currently viewing are fetched.- **Design, "Paper Ledger":** an old-school accounting-ledger feel reimagined  futuristic and modern. Warm paper background, ink-dark text, hairline rules,  monospace ledger figures, restrained serif headings. Color is semantic and  sparing: green / yellow / red mean good / ok / bad (price moves, fundamental  ratios, data-health states), never decoration. The UI must be skimmable:  every number's meaning clear at a glance, small visualizations (range bars,  comparisons, paired values) over flat metric-card grids. Both phone and  desktop are first-class; built mobile-first in CSS; charts touch- and  pointer-driven. Reference points: railway.com, openai.com, anthropic.com.  Shipped in Phase 4; tokens live in `base.scss :root`. A final polish pass is  deferred to the ship phase (Phase 12).### SQLite schema (`migrations/0001_initial.sql`)Timestamps are UTC epoch-ms; trading dates are `TEXT` `YYYY-MM-DD`.- `symbols` — the universe (stock/etf/index/future), CIK, sync timestamps,  denormalized last price.- `daily_prices` — deep daily OHLCV. Permanent, never pruned.- `intraday_bars` — recent intraday OHLCV. Pruned to ~14 days.- `quotes` — latest live quote snapshot, one row per symbol.- `fundamentals` — long/narrow SEC XBRL facts, one row per metric/period;  UNIQUE rekeyed to (ticker, metric, period) by migration `0004`.- `filings` — SEC filing history.- `watchlists` + `watchlist_items` — named lists of tickers. In the schema  but unused: the watchlist feature is deferred to Phase 19 (see Status).- `fetch_log` — append-only history of background fetches.- `data_status` — current state per job, for the live status pill.- `endpoint_guard` — per-upstream guard state + hourly budget (migrations  `0002`, `0003`).- `meta` — key-value settings (`seed_completed`, etc.).---## Phases- [x] **Phase 0 — Skeleton.** Scaffold, `AppState`/`router`, migration, home +  seo routes, Vite base entry, themed shell. `make run` serves a styled empty  dashboard.- [x] **Phase 1 — Universe + history.** `starter.csv`, provider traits,  Stooq history provider, `seed` (universe + history backfill), `compute.rs`,  symbol detail page with a real daily chart.- [x] **Phase 2 — Scheduler + incremental history.** `scheduler.rs` job loop,  daily history increment, prune job, `data_status` / `fetch_log`, first-run  seed on boot.- [x] **Phase 3 — Endpoint guardrails.** A persistent, per-endpoint  `EndpointGuard` so a third-party rate limit can never be hit. New  `endpoint_guard` table (migration `0002`) and `src/guard.rs`; retrofit  `seed.rs` and `scheduler.rs` to route every Stooq call through it. The guard  combines: a reactive circuit breaker that opens immediately on HTTP  429/503/`Retry-After` (honored) or after a failure streak, with exponential  backoff per trip (e.g. 30m, 1h, 2h, capped 24h) and a half-open probe to  recover; a hard per-hour request budget per endpoint (when spent, jobs skip  the rest of their work until the hour rolls); and request pacing. All state  is DB-backed, so it survives restarts and is shared across jobs. Replaces the  current ad-hoc per-run consecutive-error breaker.- [x] **Phase 4 — Visual redesign: Paper Ledger.** Establish the design system  (see Architecture, Design): warm-paper palette, ink text, hairline rules,  monospace ledger figures, serif headings, semantic green/yellow/red. Re-theme  the base shell, home dashboard, symbol page and 404. Redesign the symbol  page's key stats to be skimmable instead of a flat card grid: the 52-week  range drawn as a bar/line with the current price and previous close marked  along it; volume shown against its own average; open and close paired with  their % moves. Build reusable components so every later phase is built into  this look. Anticipate (but do not yet wire) live-price elements.- [x] **Phase 5 — Live quotes + SSE.** `market.rs` (US market hours via  `chrono-tz`), Yahoo quote provider, `stream.rs` broadcast hub, `/stream` SSE  endpoint, frontend stream client, live ticker cards, status pill. Shipped;  intraday polling is demand-driven (only the symbols a browser is viewing,  during market hours) plus a once-a-day whole-universe close snapshot.- [x] **Phase 6 — Data health page.** A `/health` page that shows everything  openly and cleanly: per-endpoint guard state and per-hour budget used, each  background job's status / last-ok / next-run, a live "fetching now"  indicator, and a streaming tail of `fetch_log`. Built on the Phase 5 SSE hub  so live fetches and logs update in place.- [x] **Phase 7 — Fundamentals + filings.** SEC provider, SEC jobs, computed  P/E and dividend yield, fundamentals tables and filings list on the symbol  page. Fundamental ratios get semantic color coding (red = poor, yellow = ok,  green = strong) so each number's quality reads at a glance. Each metric also  carries a short plain-English explanation of what it means and how to read  it (e.g. "P/E around 20 is healthy; around 300 is very richly priced"), and  each ticker's own value is interpreted as good / ok / bad against sensible  thresholds, so a non-expert can tell at a glance whether a number is  encouraging or a concern.- [x] **Phase 8 — Chart indicators.** SMA 50/200 + EMA 21 overlays and an RSI  pane, plus a volume histogram, all toggleable; indicator maths in  `compute.rs`; the history API returns the series with a lookback so they are  correct from the first shown bar. A range-change chip reports the move over  the chart's visible span so the headline and the chart agree.- [x] **Phase 9 — Search + add-symbol.** A `/search` page that browses and  searches the whole universe (filter by kind, match ticker and company  name), and a `POST /api/symbols` add-symbol flow that validates an unknown  ticker against Yahoo, registers it, and triggers its history backfill.  Watchlists were dropped from the MVP (see decisions log) and parked as  Phase 19.- [x] **Phase 10 — Commodities & futures.** (Promoted from the post-MVP  backlog on 2026-05-22 — see decisions log — because the Phase 11 home  redesign needs commodity data.) Extend the universe and the Yahoo quote  provider to index futures (S&P, Nasdaq, Dow) and commodity futures (oil,  gold, silver, natural gas, ...). Adds a new symbol `kind` of `future`. Yahoo  serves these as `=F` symbols (`ES=F`, `CL=F`, `GC=F`), so they slot into the  Phase 5 quote provider; if Stooq has no deep history for them, treat them  like the historyless indexes (live quotes only).- [x] **Phase 11 — Home dashboard redesign.** (Captured 2026-05-22; vision  expanded 2026-05-22, see decisions log.) The current home page is a flat  grid of ~144 ticker cards, which the user finds unhelpful. The goal: an  opinionated, no-customization home page that lets you grasp what the market  is doing at a glance on every front, and drill in from there. No  watchlists, no per-user layout; the app decides what matters and the user  reads it. Planned pieces: a row of sparkline cards across the top for the  indexes and critical commodities (each a tiny current-day intraday line  from `intraday_bars`, Phase 5, with last price and day change), and top /  bottom mover lists, the day's biggest gainers and losers from daily plus  intraday price change. The flat full-universe grid is demoted (grouped,  shrunk, or moved off the landing view; decide when building). It is also  where user-added symbols (Phase 9) find their place on the home view.  Built in the Paper Ledger system.- [ ] **Phase 12 — Polish + ship.** Sitemap, favicon, the final Paper Ledger  polish pass (deferred from Phase 4 — see decisions log),  Dockerfile, docker-compose, sample files, README, CLAUDE.md, `git init`.  Registering the project in `taproot` is left as a manual step for the user.Phases 13 through 19 are the post-MVP backlog: ideas captured during planning,to be built after the Phase 12 ship. Order among them is loose, and severaldepend on Phase 5 (live quotes) and Phase 7 (SEC data).- [ ] **Phase 13: Market heat map.** A home-dashboard market-cap heat map: one  tile per symbol, sized by market cap, colored green or red by the day's move  (a treemap). Market cap is shares outstanding (from SEC, Phase 7) times the  latest price. (The movers list and index sparklines once bundled here moved  into the Phase 11 home redesign when it was promoted.)- [ ] **Phase 14: Company leadership.** Track each company's current  executives and board, leadership changes over time, and a per-leader track  record: how the company fared during their tenure, and whether they are an  industry insider or an outsider. Data source needs research: SEC DEF 14A  proxy statements and 8-K item 5.02 (officer / director changes) give the  roster and the changes, but an objective "track record" is partly editorial  and needs a deliberate sourcing decision.- [ ] **Phase 15: Industry trends.** Treat industries as first-class:  aggregate symbols by industry (sector and industry come from SEC in Phase  7), show industry-level performance, seasonality (the months an industry  tends to do well or poorly, computed from `daily_prices`), and how the  industry is trending currently.- [ ] **Phase 16: Per-ticker anomaly feed.** On the symbol page, a feed of  notable recent events for that one ticker: large changes in its  fundamentals, leadership changes, and unusually large price moves or  drawdowns. Builds on Phases 7 and 14.- [ ] **Phase 17: Stock health read.** Synthesize fundamentals, price  trajectory, leadership, and industry context into a single non-advice  "health" read: is this a healthy company (capable leadership familiar with  the industry, solid fundamentals, consistent gains with the occasional  acceptable setback)? Explicitly NOT buy or sell advice, and labelled as such  in the UI. Builds on Phases 7, 14 and 15.- [ ] **Phase 18: ETF profiles.** Treat ETFs as first-class rather than the  Phase 7 stocks-only treatment: top holdings and their weights (from SEC  N-PORT), expense ratio, fund category, and the fund's own filing history  (prospectus / 485BPOS, N-CEN). Needs a fund ticker-to-CIK source distinct  from the operating-company `company_tickers.json`. Captured 2026-05-22 from a  user question about ETF filings; see decisions log.- [ ] **Phase 19: Watchlists.** Named lists of symbols the user curates:  watchlist and per-list pages plus mutation APIs (create / delete / rename  lists, add / remove symbols). The schema already carries the `watchlists`  and `watchlist_items` tables and the `symbols.is_watched` flag. Dropped  from the MVP on 2026-05-22 (see decisions log) because the app is meant to  be an opinionated, no-customization market view; parked here and can be  re-promoted later if that changes (as commodities once were).---## Key files```finance/  Cargo.toml  Makefile  migrations/  0001_initial.sql  0002_endpoint_guard.sql  0003_guard_budget.sql               0004_fundamentals_unique.sql  universe/starter.csv                curated seed list  src/    main.rs        entry + `seed` subcommand    app.rs         AppState + Config + router    db.rs          SqlitePool init, now_ms, meta helpers    render.rs  middleware.rs  templates.rs    models.rs  compute.rs  seed.rs    scheduler.rs   background job loop (seed/history/intraday/daily_close/prune)    market.rs      US market-session clock    stream.rs      SSE pub/sub hub + viewer-interest registry    providers/  mod.rs (traits)  http.rs  stooq.rs  yahoo.rs  sec.rs    routes/  mod.rs  home.rs  symbols.rs  search.rs  stream.rs  health.rs  seo.rs    guard.rs  templates/  base.html  includes/  pages/  frontend/static_src/  base/  home/  symbol/  health/  search/```---## Commands- `make run` — Vite watch + `cargo run` on port 8000 (dev).- `make build` — Vite assets + release binary.- `make seed` — re-run the universe seed (idempotent). Same as `finance seed`.- `make start` — run the release binary.- Build with the full cargo path in this container: `~/.cargo/bin/cargo`.- Run the dev binary with `FINANCE_ROOT=/home/dev/code/finance` so it finds  `templates/` and `dist/`.---## Decisions log- **2026-05-21 — Stooq history endpoint hit an apikey gate.** Stooq's free  per-ticker CSV now returns "Get your apikey:" and its bulk database download  returns "Unauthorized". Briefly considered moving history to Yahoo.- **2026-05-21 — Stooq kept, with a user-supplied apikey.** The user obtained a  Stooq apikey (captcha flow). It works: one call returns a symbol's full daily  history. The key is stored in `.env` (`STOOQ_APIKEY`, gitignored). Stooq  stays the history source; Yahoo remains the intraday/quote source.- **2026-05-21 — Anti-spam policy added.** After a seed run hammered Stooq 144  times with zero successes, added the validate-before-loop rule and a  consecutive-error circuit breaker (see Anti-spam / caching policy). `seed.rs`  is now resumable: it skips symbols that already have history.- **2026-05-21: Phase 2 scheduler shipped.** `src/scheduler.rs` runs the boot  seed, the ~6-hourly incremental history refresh, and the ~daily prune, each  writing `data_status` and `fetch_log`. The incremental job re-fetches only  symbols stale beyond 20h, so each symbol is hit at most about once per  trading day. After a boot seed the history job is deferred one interval so  it cannot re-fetch, on the same boot, the symbols the seed just touched.- **2026-05-21: Visual direction set to "Paper Ledger".** The original dark,  cyan/magenta neon look read dated. New direction: an old-school accounting  ledger reimagined futuristic-modern, on warm paper with ink text, hairline  rules and monospace figures, color used only semantically (green/yellow/red  = good/ok/bad). Reference points: railway.com, openai.com, anthropic.com.  See Architecture, Design. Becomes its own phase (Phase 4).- **2026-05-21: Endpoint guardrails promoted to their own phase (Phase 3).**  The user considers never hitting a rate limit critical. The ad-hoc per-run  breaker is replaced by a persistent, per-endpoint `EndpointGuard` carrying  both a reactive circuit breaker and a hard per-hour request budget. Done  before live quotes because Phase 5 adds a second endpoint and a more frequent  job.- **2026-05-21: Roadmap expanded to 11 phases for clean cutoffs.** The user is  vibe-coding and riffs ideas; they are budgeted into the plan as they arrive.  New phases added: 3 endpoint guardrails, 4 visual redesign, 6 data health  page, 8 chart indicators. Old phases 3 to 6 shifted to 5, 7, 9, 10. Phases  are kept small and self-contained so context can be cleared and resumed  between them.- **2026-05-21: Phase 3 endpoint guard shipped.** `src/guard.rs` plus the  `endpoint_guard` table (migration `0002`). Every Stooq call now routes  through a persistent `EndpointGuard`: a DB-backed reactive circuit breaker  (trips on a 429/503 at once or after 4 consecutive failures, 30m to 24h  exponential backoff, half-open probe recovery), a hard 200-request-per-hour  budget, and 1.5s pacing. The old per-run consecutive-error breaker in  `seed.rs` and `scheduler.rs` is gone. Two refinements made during the work:  a 429/503 now surfaces as a typed `RateLimited` error so the breaker trips  immediately and honors `Retry-After`; and Stooq's "No data" reply for  genuinely historyless symbols (^RUT, ^VIX) is now treated as a successful  empty response rather than an error, so it no longer feeds the breaker (this  also supersedes the Phase 2 "held the breaker at 2/4" behavior, as those two  symbols were never a real endpoint failure). The 200/hour budget sits  comfortably above one full universe refresh (~144 calls), so legitimate jobs  are never starved while a runaway loop is still capped.- **2026-05-21: Phase 4 Paper Ledger redesign shipped.** The dark neon theme  is fully replaced by the "Paper Ledger" design system: warm paper, ink text,  hairline rules, monospace figures, restrained serif headings, semantic  color only. Typeface pairing chosen with the user: Source Serif 4 for  headings, Inter for body, JetBrains Mono for figures (`space-grotesk`  dropped). The symbol page's flat key-stats card grid became three skimmable  gauges over a shared `.track` meter primitive. A new ink brand mark and  favicon — a rising figures line over the accountant's double underline —  replace the neon candlesticks the user disliked.- **2026-05-21: chart pan/zoom disabled; drag-to-measure added.** A mid-Phase-4  user request. The lightweight-chart no longer scrolls or zooms: panning only  revealed empty space past the loaded range, and the range buttons already  cover navigation. In its place, a Google-Finance-style measure tool —  click-drag across the chart shades the interval and shows its % and absolute  change with an up/down indicator. All client-side in `chart.js`, no new  network calls. (Related to Phase 8's chart work, but shipped now.)- **2026-05-21: final UI polish deferred to Phase 10.** The user judged the  Paper Ledger redesign a clear improvement but still short of fully polished,  and chose to hold a dedicated polish pass until all features are built  rather than polish now and re-polish after every later phase. Phase 10's  "final theme pass" absorbs this.- **2026-05-21: post-MVP backlog captured (Phases 11 to 16).** A vibe-coding  session produced eight feature ideas, budgeted into the plan as future  phases rather than acted on now: futures and commodities (11); a market-cap  heat map plus a movers list (12); company leadership tracking (13); industry  trends and seasonality (14); a per-ticker anomaly feed (15); and a  synthesized non-advice "stock health" read (16). One further idea, plain  -English explanations of each fundamental plus a good / ok / bad reading of  each ticker's value, was folded into the existing Phase 7 rather than given  its own phase. These sit after the Phase 10 ship; ordering among 11 to 16 is  loose. Phase 13's "leader track record" still needs a real data-source  decision (SEC proxies give the roster, not an objective record).- **2026-05-21: Phase 5 polling is demand-driven, not market-wide.** Asked how  the intraday job should choose what to poll. The user's rule: poll a symbol  only while it is actually being viewed during market hours, and give  everything one closing update at the bell. So the stream `Hub` carries a  per-ticker viewer-interest registry: each `/stream` connection registers the  tickers its page shows, and the `intraday` job polls exactly  `hub.viewed()` — nothing when nobody is watching. A separate once-a-day  `daily_close` job snapshots the whole universe shortly after 16:00 ET so  every symbol still gets a same-day close. Both go through a new `yahoo`  `EndpointGuard` with a 1000/hr budget (`EndpointGuard::with_budget` added;  Stooq keeps the 200 default). Routine 1-minute `intraday` runs write only  `data_status`, not `fetch_log`, so the minute cadence does not bury the log;  errors and guard stops are still logged.- **2026-05-21: no market-holiday calendar.** `market.rs` models weekday  pre/regular/post hours in `America/New_York` but not exchange holidays. A  full holiday calendar needs yearly upkeep and the cost of omitting it is  tiny: on a holiday the demand-driven job just polls a flat market (only if  someone is watching), and `daily_close` fetches one unchanged quote per  symbol. Neither risks a rate limit or stores bad data.- **2026-05-21: SSE closed on `pagehide`.** Navigating between pages was  aborting the open `EventSource` mid-response, which Chrome logs as  `ERR_INCOMPLETE_CHUNKED_ENCODING` — one console error per navigation. The  stream client now calls `es.close()` on `pagehide` (and reconnects from the  bfcache on `pageshow`), so navigation is clean.- **2026-05-21: two future improvements captured (vibe-coding).** Budgeted into  later phases rather than acted on now: (1) the **selected chart timeframe  should drive the headline change readout** — % and points over the chosen  range — because the symbol header's day change reads as confusing once a  longer range is selected; folded into Phase 8. (2) **Current-day sparklines  on the dashboard index cards**, drawn from `intraday_bars`; folded into  Phase 12.- **2026-05-21: Phase 6 data-health page shipped.** `/health` (plus an  `/api/health` JSON feed) lays the background-data machinery open: per-endpoint  guard state and hourly budget used, each job's state / last-ok / next-run, a  live "fetching now" banner, and a tail of `fetch_log`. Design decisions: the  page has a single renderer — `health.js` builds the DOM from a snapshot, and  the page route just embeds the first one in the HTML (so there is no flash)  and serves the rest from `/api/health` — rather than rendering once in  minijinja and again in JS. Liveness reuses the Phase 5 SSE hub via a new  content-free `StreamEvent::Health` the scheduler pings on job transitions;  `stream.js` re-broadcasts it as a `finance:health` window event so the page  needs no second EventSource. Migration `0003` persists each guard's  `hourly_budget` in its `endpoint_guard` row (written and self-corrected by the  guard, and registered for both endpoints on boot) so the page reads  "used / budget" from the table instead of duplicating the scheduler's request  ceilings into the route layer.- **2026-05-22: Phase 7 fundamentals + filings shipped.** SEC EDGAR is now a  third data source behind a `FundamentalsProvider` trait (`providers/sec.rs`),  swept by one `sec` scheduler job through a `sec` `EndpointGuard`. Key design  calls: (1) one consolidated `sec` job does CIK resolution plus companyfacts  plus submissions per company, rather than separate `fundamentals` and  `filings` jobs: fewer guard cycles and one resumable sweep, though both  `*_synced_at` columns are still tracked independently so a half-done company  is finished next cycle. (2) The fiscal year of every XBRL fact is derived  from its period-end date and the company's fiscal-year-end month, NOT SEC's  `fy` field, which tags facts with the filing's year and so mislabels  comparatives (caught in verification: stored data was shifted two years).  (3) Quarterly balance-sheet figures are not collected (a 10-Q mis-tags its  prior-year-end comparative as a quarter); the quarterly financials table  shows the flow metrics only. (4) Ratios are computed off the latest full  fiscal year, not a trailing-twelve-month sum: robust against the XBRL  year-to-date trap and clearly labelled with the basis year. The user chose  the rich ratio set (nine ratios) and the annual + quarterly table toggle.- **2026-05-22: chart measure-tool readout `[hidden]` bug fixed.** The  drag-to-measure readout chip stayed on screen after the selection was  cleared: `.chart-readout` sets `display: flex`, which overrides the  `[hidden]` attribute's `display: none`. Added  `.chart-readout[hidden] { display: none }`, the same fix `.health-banner`  already carries. User-reported during Phase 7.- **2026-05-22: ETF filings noted as future work (now Phase 18).** Asked why  ETFs show no filings. Phase 7 is operating-companies-only by design: ETFs  have no XBRL `companyfacts` (no revenue / EPS), and EDGAR's  `company_tickers.json` is an operating-company map. ETFs do file (N-PORT  holdings, prospectuses) under a fund CIK, so an "ETF profile" (holdings,  expense ratio, fund filings) is a genuine feature; budgeted as a post-MVP  phase rather than stretched into Phase 7. Indexes genuinely do not file, so  their empty state is correct.- **2026-05-22: Phase 8 chart indicators shipped.** Toggleable SMA 50, SMA  200, EMA 21 overlays, a volume histogram, and an RSI pane on the symbol  chart, plus a range-change chip. Design calls: (1) the indicator maths  (`sma`/`ema`/`rsi`) is pure numeric code in `compute.rs` returning  `Option<f64>` per bar; the route shapes it. (2) The history API fetches a  320-day lookback before the requested range so a 200-day average is correct  from the first *shown* bar, then trims — the API response shape changed from  a bare candle array to `{candles, sma50, sma200, ema21, rsi14}`. (3) RSI is  a real second pane created/destroyed on toggle (lightweight-charts does not  drop empty panes, so `removePane` is called explicitly). (4) Indicator lines  use a muted blue/brown/violet palette — a deliberate exception to the  semantic-color rule, since candles own green/red and lines are wayfinding,  not value judgments. (5) The user picked EMA + volume + RSI on top of the  core SMAs, and a change chip beside the range buttons. The chip is computed  from the chart's *visible* logical range, not the candle array, so it agrees  with what is drawn: a deep MAX history (`^SPX` to 1789) is clamped by the  chart to what fits, and the chip reports only that visible span — this is  the literal fix for "the chart range and the headline change should agree".- **2026-05-22: roadmap restructured — home redesign + commodities promoted  pre-ship.** The user judged the home page (a flat grid of ~144 ticker  cards) unhelpful and wants it redesigned before the MVP ships: index and  critical-commodity sparkline cards plus top/bottom mover lists. Since that  needs commodity data, the old post-MVP "Futures and commodities" phase is  promoted too. New MVP order: 9 Watchlists + search, 10 Commodities &  futures, 11 Home dashboard redesign, 12 Polish + ship. The post-MVP backlog  renumbered: old 11→10, 12→13 (now just the universe heat map — its movers  and index sparklines moved into Phase 11), 13→14, 14→15, 15→16, 16→17,  17→18. The Phase 4 "final polish pass" now lands in Phase 12. The user chose  sparkline cards (over a combined normalized chart or a heat map) for the  index/commodity display.- **2026-05-22: watchlists dropped from the MVP; Phase 9 reshaped to search +  add-symbol.** Asked how much watchlist editing Phase 9 should cover, the  user said they do not want the watchlist feature for now: the app should  stay an opinionated, no-customization view of the market. Phase 9 was  reshaped from "Watchlists + search" to "Search + add-symbol" and shipped as  such: a `/search` page (browse the whole universe, filter by kind, search  ticker and company name) plus a `POST /api/symbols` add-symbol flow that  validates an unknown ticker against Yahoo, registers it, and triggers its  history backfill. Watchlists are parked in the post-MVP backlog as Phase 19  (the `watchlists` / `watchlist_items` tables stay in the schema, unused for  now) and can be re-promoted later, as commodities once were. Design calls:  (1) one SQL query backs both browse and search, the filters guarded so an  empty input is a no-op; (2) the add affordance appears only on a genuine  zero-results miss, so a company-name search that did find hits is not  nagged; (3) the add-symbol lookup reuses the Yahoo chart endpoint (one  request yields the symbol's identity plus its quote) and goes through the  shared endpoint guard; (4) the history and daily-close jobs dropped their  `is_seeded` filter so a user-added symbol is maintained like a curated one,  and the add route brings the history job forward so the backfill lands  within a tick.- **2026-05-22: the home page is an opinionated, no-customization market  view.** The user's steer for Phase 11: the home page should let you grasp  what the market is doing at a glance on every front, and drill in from  there. It is deliberately an opinionated view with no per-user  customization (the same reasoning that dropped watchlists). Folded into the  Phase 11 description.- **2026-05-22: Phase 10 commodities & futures shipped.** A new `future`  symbol kind and 9 curated Yahoo `=F` futures (3 index — ES/NQ/YM; 6  commodity — CL/BZ/GC/SI/HG/NG) added to the universe. Design calls: (1) the  user picked a core 9-symbol basket over a wider one with agriculture and a  Russell future. (2) Futures are live-quotes-only — Stooq has no `=F` data,  so the seed and incremental-history job exclude `kind = 'future'` outright  rather than letting them make a pointless "No data" Stooq call each cycle as  the historyless indexes do; their prices come only from Yahoo's daily-close  snapshot and demand-driven intraday polling. A future thus has no  `daily_prices` and an empty candlestick chart, like `^VIX`. (3) `symbol_info`  reclassifies Yahoo's `FUTURE` type from an `Unsupported` rejection to the new  kind, and `valid_ticker` now accepts `=`, so futures are also user-addable  through Search. (4) The Markets home page gained a "Futures & commodities"  section and Search a "Futures" filter; no schema migration was needed  (`kind` is free-text TEXT).- **2026-05-22: Phase 11 home dashboard redesign shipped.** The flat ~155-card  grid is replaced by an opinionated, no-customization dashboard: a row of  nine sparkline cards (six indexes, three headline commodities) over two  movers panels (the day's biggest gainers and losers). The browsable full  universe moved entirely to `/search`. Design calls, several from the  resume-session Q&A: (1) the sparkline set is a hardcoded curated nine —  indexes plus crude / gold / natural gas — not a user watchlist. (2) Movers  are restricted to curated large-cap stocks (`is_seeded` stocks), not the  whole universe: the user wants names worth noticing (an AAPL down 5%), not a  small user-added corp's noise. (3) The dashboard registers stream interest  in only its nine sparkline tickers, so the demand-driven intraday poller  stays small and on-budget; the movers panels are a fixed page-load snapshot  with no live registration. (4) Sparklines are server-rendered SVG  (`compute::sparkline`) over the latest session's `intraday_bars`; the stream  client live-nudges the trailing point onto each new quote. The phone  sparkline grid stays one column; a 2-up phone layout is left as a Phase 12  polish candidate.---## Verification- `make run`, confirm `data/db.sqlite3` is created and migrations apply.- `make seed`, confirm `symbols` and `daily_prices` are populated and  `meta.seed_completed` is set only when the seed actually succeeded.- Dashboard `/` shows the symbol grid; `/s/AAPL` shows a working candlestick  chart with range selectors and key stats.- `/search` browses and searches the universe (filter by kind, match ticker  and company name); searching an untracked ticker offers to add it, and  adding it registers the symbol and backfills its history within a tick.- `/` has a "Futures & commodities" section; futures (`kind = 'future'`) are  never sent to Stooq and carry a live quote only — `/s/GC=F` renders with no  daily chart, like `^VIX`.- Phone (~360 px) and desktop are both fully usable: no unintended horizontal  scroll, chart resizes, every feature reachable.- No automated test suite or linter, matching the sibling projects.
added README.md
@@ -0,0 +1,104 @@# FinanceA self-hosted, real-timeish market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, and SEC filings. One axum binary with sqlx + SQLite, minijinja templates, and a Vite frontend.Single-operator, no auth, no accounts. It is for *watching* the market, not tracking holdings: there is no portfolio, no cost basis, no money in it at all.## Features- Curated universe of ~150 stocks, ETFs, indexes, and commodity/index futures, extendable from the Search page- Deep daily OHLCV history (decades) plus 15-minute intraday bars and live quotes- Symbol pages with candlestick charts, SMA 50/200 + EMA 21 overlays, an RSI pane, a volume histogram, and a drag-to-measure tool- Skimmable key stats: the day's range, the 52-week range, and volume vs average, all drawn as range meters rather than a flat card grid- SEC fundamentals: nine graded ratios with plain-English readings, an annual/quarterly financials table, and a recent-filings list- An opinionated home dashboard: index + commodity sparkline cards over the day's biggest movers- Live prices over Server-Sent Events, polled only for the symbols actually being viewed, and only during market hours- A `/health` page that lays the background machinery open: every job, every endpoint guard, and a tail of the fetch log- A persistent per-endpoint guard (circuit breaker + hourly budget + request pacing) so an upstream rate limit can never be hit- Single-binary deploy via `git push server master`## Data sourcesAll free, no account, one key:| Source | Used for | Auth ||---|---|---|| Stooq | Deep daily OHLCV history | A free apikey (`STOOQ_APIKEY`), obtained once via a captcha || Yahoo Finance | Live quotes + 15-minute intraday bars | None (a browser User-Agent) || SEC EDGAR | Fundamentals (XBRL) + filing history | None (a contact email, `SEC_CONTACT_EMAIL`, in the User-Agent) |Everything fetched is cached in SQLite; the network is touched only for increments. The full per-symbol history backfill runs once, then daily history is refreshed in a small recent window and intraday quotes are polled only for watched symbols during market hours. P/E and dividend yield are computed from SEC data plus the latest price, never stored.## System dependenciesLocal dev needs these on your `PATH`:| Tool | Why | Version ||---|---|---|| `rustc` / `cargo` | Build the axum binary | 2021 edition, current stable (1.70+) || `bun` | Frontend deps + Vite build | 1.x || `make` | Run the dev/build targets | any || A C toolchain + OpenSSL headers | Linked at build time on Linux | `build-essential pkg-config libssl-dev` (Debian/Ubuntu), `musl-dev pkgconfig openssl-dev` (Alpine) |The Docker build (see `Dockerfile`) reproduces this on `rust:alpine` + `alpine:3.23`. If you only care about Docker, you do not need any of the above on the host.## Quickstart```shcp samplefiles/env.sample .env# edit .env: set STOOQ_APIKEY and SEC_CONTACT_EMAIL (and BASE_URL for prod)make````make` (alias `make run`) installs frontend deps if needed, then runs Vite watch and `cargo run` concurrently on port 8000. Visit http://localhost:8000.On first boot the scheduler seeds the curated universe and backfills its daily history from Stooq (resumable, paced, guarded). With no `STOOQ_APIKEY` the app still boots and serves live quotes from Yahoo; only the deep daily history is unavailable.## ConfigurationAll config comes from `.env` (loaded via `dotenvy`). The full set:| Variable | Required | Purpose ||---|---|---|| `STOOQ_APIKEY` | for history | Stooq apikey for the daily-history endpoint. Empty disables Stooq || `SEC_CONTACT_EMAIL` | for fundamentals | Appended to the User-Agent on SEC requests so SEC can identify the caller. Empty disables the SEC job || `BASE_URL` | yes for prod | Absolute origin used in the sitemap and og tags. No trailing slash || `PORT` | no (default `8000`) | HTTP listen port || `FINANCE_DATA_DIR` | no (default `./data`) | Where `db.sqlite3` lives. Production sets this to `/data` || `FINANCE_ROOT` | no (default `.`) | Override the project root (where `templates/`, `dist/`, `migrations/`, `universe/` are read from) || `FINANCE_USER_AGENT` | no | Browser-like User-Agent sent on every outbound data request || `FINANCE_QUOTE_PROVIDER` | no (default `yahoo`) | Which `QuoteProvider` impl to use for live data || `FINANCE_TITLE` | no (default `Finance`) | Title shown in the header and `<title>` |## Make targets| Target | What it does ||---|---|| `make run` (default) | Vite watch + `cargo run` on port 8000 || `make build` | Vite assets + release binary (`target/release/finance`) || `make start` | Run the release binary (after `make build`) || `make seed` | Re-run the universe seed (curated symbols + bulk daily history). Idempotent || `make push` | `git push` to every configured remote || `make clean` | Remove build output, frontend deps, and the local `data/` dir |There are no tests or linters configured.## DeployProduction runs on Docker. The standard flow is `git push server master` to a remote whose post-receive hook runs `docker compose up --build --detach`. Sample files in `samplefiles/`:- `env.sample`: the `.env` shown above- `Caddyfile.sample`: reverse proxy with TLS- `post-receive.sample`: the git hookData persists to `/srv/data/finance/` on the host (mounted into the container at `/data`).## Stack- **Backend:** axum 0.8, sqlx 0.8 against SQLite (WAL), single binary- **Templates:** minijinja 2 with a Jinja2-faithful HTML formatter- **Frontend:** Vite 6, SCSS, lightweight-charts; Source Serif 4 / Inter / JetBrains Mono, self-hosted via `@fontsource`- **Scheduler:** one long-lived tokio loop running market-hours-aware background jobs- **Real-time:** a `tokio::sync::broadcast` hub feeding a `/stream` SSE endpointSee [CLAUDE.md](CLAUDE.md) for the full architecture rundown, route table, and data model.
added docker-compose.yml
@@ -0,0 +1,11 @@services:  web:    container_name: finance    build: .    init: true    env_file: .env    environment:      FINANCE_DATA_DIR: /data    volumes:      - /srv/data/finance:/data    restart: unless-stopped
added frontend/bun.lock
@@ -0,0 +1,199 @@{  "lockfileVersion": 1,  "configVersion": 1,  "workspaces": {    "": {      "dependencies": {        "@fontsource/inter": "^5.2.8",        "@fontsource/jetbrains-mono": "^5",        "@fontsource/source-serif-4": "^5.2.9",        "lightweight-charts": "^5",      },      "devDependencies": {        "sass": "^1.97.3",        "vite": "^6.3.1",      },    },  },  "packages": {    "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],    "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],    "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],    "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],    "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],    "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],    "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],    "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],    "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],    "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],    "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],    "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],    "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],    "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],    "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],    "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],    "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],    "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],    "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],    "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],    "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],    "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],    "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],    "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],    "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],    "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],    "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],    "@fontsource/jetbrains-mono": ["@fontsource/jetbrains-mono@5.2.8", "", {}, "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ=="],    "@fontsource/source-serif-4": ["@fontsource/source-serif-4@5.2.9", "", {}, "sha512-er/Pym9emsEVJNf947umJ4kXarXfsiN6CN7kTYinefKRaHLwiquiiHOZvKvxWgkV8JMCf3pV3g0NcsPFpVCH9w=="],    "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],    "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],    "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],    "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],    "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],    "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],    "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],    "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],    "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],    "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],    "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],    "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],    "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],    "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],    "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="],    "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="],    "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="],    "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="],    "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="],    "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="],    "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="],    "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="],    "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="],    "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="],    "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="],    "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="],    "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="],    "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="],    "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="],    "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="],    "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="],    "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="],    "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="],    "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="],    "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="],    "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="],    "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="],    "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="],    "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="],    "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],    "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],    "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],    "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],    "fancy-canvas": ["fancy-canvas@2.1.0", "", {}, "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ=="],    "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],    "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],    "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="],    "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],    "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],    "lightweight-charts": ["lightweight-charts@5.2.0", "", { "dependencies": { "fancy-canvas": "2.1.0" } }, "sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w=="],    "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],    "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],    "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],    "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],    "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],    "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],    "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="],    "sass": ["sass@1.99.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="],    "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],    "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],    "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],  }}
added frontend/package.json
@@ -0,0 +1,18 @@{  "private": true,  "type": "module",  "scripts": {    "dev": "vite build --watch",    "build": "vite build"  },  "dependencies": {    "@fontsource/inter": "^5.2.8",    "@fontsource/jetbrains-mono": "^5",    "@fontsource/source-serif-4": "^5.2.9",    "lightweight-charts": "^5"  },  "devDependencies": {    "sass": "^1.97.3",    "vite": "^6.3.1"  }}
added frontend/static_src/base/index.js
@@ -0,0 +1,20 @@// Paper Ledger typefaces:// - Source Serif 4 — restrained serif, used for headings only// - Inter — neutral sans, body and UI text// - JetBrains Mono — tabular figures (the ledger numbers)import "@fontsource/source-serif-4/400.css";import "@fontsource/source-serif-4/600.css";import "@fontsource/inter/400.css";import "@fontsource/inter/500.css";import "@fontsource/inter/600.css";import "@fontsource/jetbrains-mono/400.css";import "@fontsource/jetbrains-mono/500.css";import "@fontsource/jetbrains-mono/700.css";// stylesimport "./styles/base.scss";// live market stream: patches prices in place and drives the status pillimport { initStream } from "./scripts/stream.js";initStream();
added frontend/static_src/base/scripts/stream.js
@@ -0,0 +1,224 @@// Live market stream client.//// Opens one EventSource to /stream, declaring the tickers the current page// shows so the server only polls Yahoo for symbols actually on screen. `quote`// events patch the data-field nodes in place; `market` events drive the status// pill; a `health` nudge is re-broadcast as a `finance:health` window event// for the data-health page. Pages are server-rendered with the freshest known// figures, so the initial snapshot lands silently and only genuine moves flash.// These mirror the server-side minijinja filters (templates.rs) so a value// patched here is identical to one the server rendered.const DASH = "·";// Dashboard sparkline viewBox: 0 0 100 36, line drawn within y ∈ [3, 33].// Mirrors the SPARK_* constants in compute.rs.const SPARK_TOP = 3;const SPARK_BOTTOM = 33;function fmtMoney(n) {  if (n == null || Number.isNaN(n)) return DASH;  return "$" + n.toLocaleString("en-US", {    minimumFractionDigits: 2,    maximumFractionDigits: 2,  });}function fmtSigned(n) {  if (n == null || Number.isNaN(n)) return DASH;  return n.toLocaleString("en-US", {    minimumFractionDigits: 2,    maximumFractionDigits: 2,    signDisplay: "exceptZero",  });}function fmtPct(n) {  if (n == null || Number.isNaN(n)) return DASH;  return (    n.toLocaleString("en-US", {      minimumFractionDigits: 2,      maximumFractionDigits: 2,      signDisplay: "exceptZero",    }) + "%"  );}// Set the semantic move class (green/red/flat) from a percentage change.function setMove(el, pct) {  el.classList.remove("is-up", "is-down", "is-flat");  if (pct == null || Number.isNaN(pct)) el.classList.add("is-flat");  else el.classList.add(pct >= 0 ? "is-up" : "is-down");}// Nudge a dashboard card's sparkline from a live quote: recolour the card and// move the line's trailing point onto the new price, keeping the same value→y// mapping the server used (compute::sparkline). data-lo/data-hi carry the// y-scale; a price outside it is clamped to the box.function paintSparkline(root, q) {  root.classList.toggle("is-up-card", q.change_pct >= 0);  root.classList.toggle("is-down-card", q.change_pct < 0);  const svg = root.querySelector("svg.spark");  if (!svg || q.price == null) return;  const lo = parseFloat(svg.dataset.lo);  const hi = parseFloat(svg.dataset.hi);  if (!(hi > lo)) return;  const t = Math.min(1, Math.max(0, (q.price - lo) / (hi - lo)));  const y = (SPARK_BOTTOM - t * (SPARK_BOTTOM - SPARK_TOP)).toFixed(2);  const line = svg.querySelector(".spark__line");  if (line) {    const pts = line.getAttribute("points").trim().split(/\s+/);    const x = pts[pts.length - 1].split(",")[0];    pts[pts.length - 1] = `${x},${y}`;    line.setAttribute("points", pts.join(" "));  }  // The area fill's points are [x0,bottom  …line…  xN,bottom], so the line's  // final point is the second-to-last token.  const area = svg.querySelector(".spark__area");  if (area) {    const pts = area.getAttribute("points").trim().split(/\s+/);    if (pts.length >= 3) {      const x = pts[pts.length - 2].split(",")[0];      pts[pts.length - 2] = `${x},${y}`;      area.setAttribute("points", pts.join(" "));    }  }}// Last price seen per ticker, so a card flashes in the right direction.const lastPrice = new Map();function applyQuote(q) {  const prev = lastPrice.get(q.ticker);  lastPrice.set(q.ticker, q.price);  const dir = prev === undefined ? 0 : Math.sign(q.price - prev);  document.querySelectorAll(`[data-ticker="${q.ticker}"]`).forEach((root) => {    const price = root.querySelector('[data-field="price"]');    if (price) price.textContent = fmtMoney(q.price);    // Compact form on the dashboard cards: the day's % move alone.    const pct = root.querySelector('[data-field="change_pct"]');    if (pct) {      pct.textContent = fmtPct(q.change_pct);      setMove(pct, q.change_pct);    }    // Full form in the symbol header: absolute and % together.    const chg = root.querySelector('[data-field="change"]');    if (chg && q.change_pct != null) {      chg.textContent = `${fmtSigned(q.change_abs)} (${fmtPct(q.change_pct)})`;      setMove(chg, q.change_pct);    }    // Dashboard sparkline cards track the live quote: recolour and move the    // line tip. (The price/change nodes above are already patched.)    if (root.classList.contains("spark-card") && q.change_pct != null) {      paintSparkline(root, q);    }    const isCard =      root.classList.contains("ticker-card") ||      root.classList.contains("spark-card");    if (dir !== 0 && isCard) {      root.classList.remove("flash-up", "flash-down");      void root.offsetWidth; // reflow, so the animation re-triggers      root.classList.add(dir > 0 ? "flash-up" : "flash-down");    }  });}// Status-pill appearance per market session. The dot color reuses the// existing data-state styles; the label is the human session name.const SESSIONS = {  regular: { state: "ok", label: "Market open" },  pre: { state: "ok", label: "Pre-market" },  post: { state: "ok", label: "After hours" },  closed: { state: "idle", label: "Market closed" },};export function initStream() {  const pill = document.querySelector('[data-role="status-pill"]');  const label = document.querySelector('[data-role="status-label"]');  let connected = false;  let session = "closed";  function paintPill() {    if (!pill) return;    if (!connected) {      pill.dataset.state = "stale";      if (label) label.textContent = "Reconnecting…";      return;    }    const s = SESSIONS[session] || SESSIONS.closed;    pill.dataset.state = s.state;    if (label) label.textContent = s.label;  }  const tickers = [    ...new Set(      [...document.querySelectorAll("[data-ticker]")]        .map((el) => el.dataset.ticker)        .filter(Boolean),    ),  ];  const query = tickers.length    ? "?symbols=" + tickers.map(encodeURIComponent).join(",")    : "";  let es = null;  function connect() {    es = new EventSource("/stream" + query);    es.addEventListener("open", () => {      connected = true;      paintPill();    });    es.addEventListener("error", () => {      // EventSource reconnects on its own; just reflect the gap in the pill.      connected = false;      paintPill();    });    es.addEventListener("quote", (e) => {      try {        applyQuote(JSON.parse(e.data));      } catch {        /* ignore a malformed frame */      }    });    es.addEventListener("market", (e) => {      try {        session = JSON.parse(e.data).session;        paintPill();      } catch {        /* ignore a malformed frame */      }    });    es.addEventListener("health", () => {      // Re-broadcast as a window event so the data-health page can react      // without opening its own EventSource. The frame carries no payload —      // it is purely a nudge to re-pull /api/health.      window.dispatchEvent(new Event("finance:health"));    });  }  connect();  // Close the stream cleanly before the page goes away. Without this the  // browser aborts an in-flight chunked response on every navigation, which  // Chrome logs as ERR_INCOMPLETE_CHUNKED_ENCODING. If the page is later  // restored from the back/forward cache, reconnect so it stays live.  window.addEventListener("pagehide", () => {    if (es) es.close();  });  window.addEventListener("pageshow", (e) => {    if (e.persisted && (!es || es.readyState === EventSource.CLOSED)) {      connected = false;      connect();    }  });}
added frontend/static_src/base/styles/_mixins.scss
@@ -0,0 +1,40 @@// Paper Ledger build-time mixins. Runtime theme tokens (CSS custom// properties) live in base.scss :root so they emit exactly once per page.// A raised paper card: a lighter sheet over the page, hemmed by a hairline// rule. Flat by design; the faint shadow only lifts it off the paper.@mixin card {  background: var(--surface);  border: 1px solid var(--rule);  border-radius: var(--radius);  box-shadow: var(--lift);}// Tabular monospace figures so columns of ledger numbers line up.@mixin mono {  font-family: var(--font-mono);  font-variant-numeric: tabular-nums;  font-feature-settings: "tnum";}// Serif, for headings only.@mixin serif {  font-family: var(--font-serif);  font-weight: 600;  letter-spacing: -0.01em;}// Minimum comfortable touch target on phones.@mixin tap {  min-height: 44px;  min-width: 44px;}// Letter-spaced uppercase label, the ledger column-header voice.@mixin eyebrow {  text-transform: uppercase;  letter-spacing: 0.13em;  font-size: 0.66rem;  font-weight: 600;  color: var(--ink-faint);}
added frontend/static_src/base/styles/_variables.scss
@@ -0,0 +1,7 @@// SCSS build-time tokens. The runtime theme tokens (CSS custom properties)// live in base.scss :root so they are emitted exactly once across entries.// breakpoints: phone-first, larger sizes layer on$bp-sm: 560px;$bp-md: 820px;$bp-lg: 1120px;
added frontend/static_src/base/styles/base.scss
@@ -0,0 +1,608 @@@use "variables" as *;@use "mixins" as *;/* ---------- runtime theme tokens ---------- *//* "Paper Ledger": an accounting ledger reimagined modern. Warm paper, ink   text, hairline rules, monospace figures. Color is semantic only —   green/amber/red mean good/ok/bad, never decoration. */:root {  --paper:      #f0ece1; /* page background — warm light */  --paper-edge: #e7e1d2; /* topbar/footer wash, slightly deeper */  --surface:    #faf8f1; /* raised cards — a lighter sheet */  --well:       #e6e0d0; /* sunken wells: meter tracks, inputs */  --ink:       #211f1a; /* primary text — warm near-black */  --ink-dim:   #6b6456; /* secondary text */  --ink-faint: #978f7c; /* tertiary text, eyebrows, placeholders */  --rule:        rgba(33, 31, 26, 0.14); /* hairline */  --rule-strong: rgba(33, 31, 26, 0.32); /* emphasised divider */  /* semantic — good / ok / bad */  --up:        #2f7d4f;  --down:      #b23b32;  --warn:      #b3801f;  --up-soft:   rgba(47, 125, 79, 0.14);  --down-soft: rgba(178, 59, 50, 0.14);  --warn-soft: rgba(179, 128, 31, 0.14);  --lift:       0 1px 2px rgba(33, 31, 26, 0.05);  --lift-hover: 0 5px 16px rgba(33, 31, 26, 0.11);  --radius:    8px;  --radius-sm: 6px;  --font-serif: "Source Serif 4", Georgia, "Times New Roman", serif;  --font-ui:    "Inter", system-ui, -apple-system, sans-serif;  --font-mono:  "JetBrains Mono", ui-monospace, "SF Mono", monospace;}/* ---------- reset ---------- */*,*::before,*::after {  box-sizing: border-box;}* {  margin: 0;}html {  -webkit-text-size-adjust: 100%;}body {  font-family: var(--font-ui);  color: var(--ink);  background: var(--paper);  min-height: 100dvh;  line-height: 1.5;  -webkit-font-smoothing: antialiased;  text-rendering: optimizeLegibility;}h1,h2,h3 {  @include serif;  line-height: 1.2;}a {  color: var(--ink);  text-decoration: none;}a:hover {  color: var(--ink-dim);}input,button {  font-family: inherit;  font-size: inherit;}code {  font-family: var(--font-mono);  font-size: 0.86em;  background: var(--well);  padding: 0.12em 0.4em;  border-radius: 4px;}/* ---------- shared bits ---------- */.wrap {  max-width: $bp-lg;  margin: 0 auto;  padding: 20px 16px 0;}.num {  @include mono;}/* semantic price-move colors */.is-up {  color: var(--up);}.is-down {  color: var(--down);}.is-flat {  color: var(--ink-dim);}.eyebrow {  @include eyebrow;}.panel {  @include card;  padding: 18px;}/* ---------- meter primitives (shared) ---------- *//* A sunken rail used for range bars and proportion bars across the app:   .track holds an optional left-anchored .track__fill and absolutely   positioned .track__pip markers. Reused by later phases. */.track {  position: relative;  background: var(--well);  border: 1px solid var(--rule);  border-radius: 999px;}.track__fill {  position: absolute;  top: 0;  bottom: 0;  left: 0;  background: var(--ink-dim);  border-radius: 999px;}.track__pip {  position: absolute;  top: 50%;  width: 2px;  height: 168%;  transform: translate(-50%, -50%);  background: var(--ink);  border-radius: 1px;}/* a labelled tick: small dot head so a marker reads even on a thin rail */.track__pip::before {  content: "";  position: absolute;  left: 50%;  top: -5px;  width: 9px;  height: 9px;  transform: translateX(-50%);  border-radius: 50%;  background: inherit;  border: 2px solid var(--surface);}.track__pip--ghost {  background: var(--ink-faint);}.track__pip--up {  background: var(--up);}.track__pip--down {  background: var(--down);}/* ---------- buttons ---------- */.btn {  @include tap;  display: inline-flex;  align-items: center;  justify-content: center;  gap: 7px;  padding: 9px 16px;  border-radius: var(--radius-sm);  border: 1px solid var(--rule-strong);  background: var(--surface);  color: var(--ink);  font-weight: 500;  cursor: pointer;  transition: border-color 0.15s, background 0.15s;}.btn:hover {  border-color: var(--ink);  color: var(--ink);}.btn--accent {  border-color: var(--ink);  background: var(--ink);  color: var(--paper);  font-weight: 600;}.btn--accent:hover {  background: var(--ink-dim);  border-color: var(--ink-dim);  color: var(--paper);}/* ---------- topbar ---------- */.topbar {  position: sticky;  top: 0;  z-index: 50;  background: rgba(240, 236, 225, 0.92);  backdrop-filter: blur(10px);  border-bottom: 1px solid var(--rule-strong);}.topbar__inner {  display: flex;  flex-wrap: wrap;  align-items: center;  gap: 10px 14px;  max-width: $bp-lg;  margin: 0 auto;  padding: 11px 16px;}.brand {  display: flex;  align-items: center;  gap: 9px;  color: var(--ink);}.brand:hover {  color: var(--ink);}.brand svg {  width: 27px;  height: 27px;  color: var(--ink);}.brand__name {  @include serif;  font-size: 1.14rem;  letter-spacing: -0.01em;}.search {  order: 3;  flex: 1 1 100%;}.search input {  width: 100%;  @include tap;  padding: 0 14px;  color: var(--ink);  background: var(--surface);  border: 1px solid var(--rule-strong);  border-radius: var(--radius-sm);  outline: none;  transition: border-color 0.15s, box-shadow 0.15s;}.search input::placeholder {  color: var(--ink-faint);}.search input:focus {  border-color: var(--ink);  box-shadow: 0 0 0 3px var(--well);}.topnav {  display: none;  gap: 2px;}.topnav a {  padding: 7px 12px;  border-radius: var(--radius-sm);  color: var(--ink-dim);  font-weight: 500;  border-bottom: 2px solid transparent;}.topnav a:hover {  color: var(--ink);  background: var(--paper-edge);}.topnav a.is-active {  color: var(--ink);  border-bottom-color: var(--ink);  border-radius: 0;}/* ---------- status pill ---------- */.statuspill {  margin-left: auto;  display: inline-flex;  align-items: center;  gap: 8px;  padding: 6px 12px;  border-radius: 999px;  border: 1px solid var(--rule-strong);  background: var(--surface);  font-size: 0.78rem;  color: var(--ink-dim);  white-space: nowrap;}.statuspill__dot {  width: 8px;  height: 8px;  border-radius: 50%;  background: var(--ink-faint);}.statuspill[data-state="ok"] .statuspill__dot,.statuspill[data-state="connected"] .statuspill__dot {  background: var(--up);}.statuspill[data-state="fetching"] .statuspill__dot {  background: var(--warn);  animation: pulse 1.2s ease-in-out infinite;}.statuspill[data-state="stale"] .statuspill__dot {  background: var(--warn);}.statuspill[data-state="error"] .statuspill__dot {  background: var(--down);}@keyframes pulse {  50% {    opacity: 0.25;  }}/* ---------- bottom nav (phones) ---------- */.bottomnav {  position: fixed;  bottom: 0;  left: 0;  right: 0;  z-index: 50;  display: flex;  background: rgba(231, 225, 210, 0.97);  backdrop-filter: blur(10px);  border-top: 1px solid var(--rule-strong);  padding-bottom: env(safe-area-inset-bottom);}.bottomnav a {  flex: 1;  @include tap;  display: flex;  flex-direction: column;  align-items: center;  justify-content: center;  gap: 3px;  padding: 8px 0;  color: var(--ink-faint);  font-size: 0.68rem;  font-weight: 500;}.bottomnav a svg {  width: 21px;  height: 21px;}.bottomnav a.is-active {  color: var(--ink);}/* ---------- main + footer ---------- */main {  padding-bottom: 84px;  min-height: 62dvh;}.footer {  border-top: 1px solid var(--rule);  background: var(--paper-edge);  margin-top: 52px;  padding: 22px 16px;  color: var(--ink-faint);  font-size: 0.8rem;  text-align: center;}.footer a {  color: var(--ink-dim);  text-decoration: underline;  text-underline-offset: 2px;}/* ---------- empty state ---------- */.empty {  @include card;  text-align: center;  padding: 56px 24px;  margin-top: 28px;}.empty svg {  width: 52px;  height: 52px;  color: var(--ink-faint);}.empty h1 {  font-size: 1.5rem;  margin-top: 16px;}.empty p {  color: var(--ink-dim);  max-width: 48ch;  margin: 10px auto 0;}.empty p a {  text-decoration: underline;  text-underline-offset: 2px;}/* ---------- page heading ---------- */.page-head {  display: flex;  align-items: baseline;  justify-content: space-between;  flex-wrap: wrap;  gap: 8px;  margin: 6px 0 4px;  padding-bottom: 12px;  border-bottom: 1px solid var(--rule-strong);}.page-head h1 {  font-size: 1.6rem;}/* ---------- section title ---------- *//* A ledger column-header: eyebrow text with a hairline rule trailing off. */.section-title {  @include eyebrow;  display: flex;  align-items: center;  gap: 12px;  margin: 30px 0 13px;}.section-title::after {  content: "";  flex: 1;  height: 1px;  background: var(--rule);}/* ---------- ticker grid + card ---------- */.ticker-grid {  display: grid;  grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));  gap: 11px;}.ticker-card {  @include card;  display: flex;  flex-direction: column;  gap: 8px;  padding: 13px 14px;  color: var(--ink);  transition: border-color 0.14s, transform 0.14s, box-shadow 0.14s;}.ticker-card:hover {  color: var(--ink);  border-color: var(--rule-strong);  transform: translateY(-2px);  box-shadow: var(--lift-hover);}.ticker-card__head {  display: flex;  align-items: baseline;  justify-content: space-between;  gap: 8px;}.ticker-card__sym {  @include mono;  font-weight: 700;  font-size: 1rem;}.ticker-card__kind {  @include eyebrow;  font-size: 0.55rem;}.ticker-card__name {  font-size: 0.79rem;  color: var(--ink-dim);  white-space: nowrap;  overflow: hidden;  text-overflow: ellipsis;}.ticker-card__foot {  display: flex;  align-items: baseline;  justify-content: space-between;  gap: 8px;  margin-top: auto;  padding-top: 8px;  border-top: 1px solid var(--rule);}.ticker-card__price {  font-size: 0.96rem;  font-weight: 600;}.ticker-card__chg {  font-size: 0.81rem;  font-weight: 600;}/* update flash, toggled by the live stream client (Phase 5) */.ticker-card.flash-up {  animation: flash-up 0.7s ease-out;}.ticker-card.flash-down {  animation: flash-down 0.7s ease-out;}@keyframes flash-up {  0% {    background: var(--up-soft);  }  100% {    background: var(--surface);  }}@keyframes flash-down {  0% {    background: var(--down-soft);  }  100% {    background: var(--surface);  }}/* ---------- desktop layering ---------- */@media (min-width: $bp-md) {  .search {    order: 0;    flex: 1 1 auto;    max-width: 420px;    margin: 0 6px;  }  .topnav {    display: flex;  }  .bottomnav {    display: none;  }  main {    padding-bottom: 32px;  }  .wrap {    padding-top: 26px;  }}@media (prefers-reduced-motion: reduce) {  * {    animation-duration: 0.01ms !important;    transition-duration: 0.01ms !important;  }}
added frontend/static_src/health/index.js
@@ -0,0 +1,5 @@// Data-health page: endpoint guards, scheduler jobs, and the fetch-log tail.import "./styles/health.scss";import { initHealth } from "./scripts/health.js";initHealth();
added frontend/static_src/health/scripts/health.js
@@ -0,0 +1,239 @@// Data-health page.//// The page ships with an embedded snapshot (#health-data) so it renders at// once with no flash. From there it stays live off the shared market stream:// base/stream.js re-broadcasts every SSE `health` nudge as a `finance:health`// window event, and this script answers each one by pulling a fresh snapshot// from /api/health and repainting. It also repaints when the tab regains// focus, and re-renders every 30s so the relative times stay honest.const $ = (sel) => document.querySelector(sel);// ---- formatting (mirrors the server-side minijinja filters in templates.rs) ----const DASH = "·";function esc(s) {  return String(s).replace(    /[&<>"']/g,    (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],  );}// Epoch-ms in the past -> "4m ago" (mirrors the `ago` filter).function ago(ms) {  if (ms == null) return DASH;  const s = Math.round((Date.now() - ms) / 1000);  if (s < 5) return "just now";  if (s < 60) return `${s}s ago`;  if (s < 3600) return `${Math.floor(s / 60)}m ago`;  if (s < 86400) return `${Math.floor(s / 3600)}h ago`;  return `${Math.floor(s / 86400)}d ago`;}// Epoch-ms in the future -> "in 4m"; already elapsed -> "due now".function until(ms) {  if (ms == null) return DASH;  const s = Math.round((ms - Date.now()) / 1000);  if (s <= 0) return "due now";  if (s < 60) return `in ${s}s`;  if (s < 3600) return `in ${Math.floor(s / 60)}m`;  if (s < 86400) return `in ${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;  return `in ${Math.floor(s / 86400)}d`;}// Epoch-ms -> local 24-hour clock time, e.g. "14:03:21".function clock(ms) {  return new Date(ms).toLocaleTimeString("en-US", { hour12: false });}function num(n) {  return n == null ? DASH : n.toLocaleString("en-US");}function dur(ms) {  if (ms == null) return null;  return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`;}// ---- badges ----// A breaker / job / log status maps to one of four visual tones.const BREAKER_TONE = { closed: "ok", half_open: "warn", open: "bad" };const JOB_TONE = { ok: "ok", fetching: "warn", error: "bad", stale: "warn", idle: "idle" };const LOG_TONE = { ok: "ok", skipped: "warn", error: "bad" };function badge(text, tone) {  return `<span class="badge badge--${tone}">${esc(text)}</span>`;}function errKv(label, msg, at) {  if (!msg) return "";  return `<div class="kv kv--err"><dt>${label}</dt>    <dd>${esc(msg)} <span class="muted">${esc(ago(at))}</span></dd></div>`;}// ---- region renderers ----function renderEndpoints(list) {  if (!list.length) {    return `<p class="health-empty">No endpoint has been contacted yet.</p>`;  }  const cards = list.map((e) => {    const tone = BREAKER_TONE[e.state] || "idle";    const breaker = e.state === "half_open" ? "half-open" : e.state;    const fillTone =      e.budget_pct >= 90 ? " track__fill--bad" : e.budget_pct >= 75 ? " track__fill--warn" : "";    const resets =      e.hour_start != null && e.hour_count > 0        ? `<p class="meter__note">Budget window resets ${esc(until(e.hour_start + 3600000))}.</p>`        : "";    const probe =      e.state === "open" && e.retry_at != null        ? `<div class="kv"><dt>Probe</dt><dd>${esc(until(e.retry_at))}</dd></div>`        : "";    return `<article class="endpoint">      <div class="endpoint__head">        <h3>${esc(e.label)}</h3>        ${badge(breaker, tone)}      </div>      <div class="meter">        <div class="meter__row">          <span class="eyebrow">Hourly request budget</span>          <span class="num meter__count">${num(e.hour_count)} / ${num(e.hourly_budget)}</span>        </div>        <div class="track meter__track">          <span class="track__fill${fillTone}" style="width:${e.budget_pct}%"></span>        </div>        ${resets}      </div>      <dl class="kvs">        <div class="kv"><dt>Circuit trips</dt><dd class="num">${num(e.trip_count)}</dd></div>        <div class="kv"><dt>Failure streak</dt><dd class="num">${num(e.fail_streak)}</dd></div>        <div class="kv"><dt>Last success</dt><dd>${esc(ago(e.last_ok_at))}</dd></div>        ${probe}        ${errKv("Last error", e.last_error, e.last_error_at)}      </dl>    </article>`;  });  return `<div class="endpoint-grid">${cards.join("")}</div>`;}function renderJobs(list) {  if (!list.length) {    return `<p class="health-empty">No job has run yet.</p>`;  }  const rows = list.map((j) => {    const tone = JOB_TONE[j.state] || "idle";    const nextRun =      j.state === "fetching"        ? "running now"        : j.next_run_at != null          ? until(j.next_run_at)          : DASH;    return `<article class="job${j.state === "fetching" ? " job--active" : ""}">      <div class="job__head">        <div class="job__id">          <h3>${esc(j.label)}</h3>          ${j.description ? `<p>${esc(j.description)}</p>` : ""}        </div>        ${badge(j.state, tone)}      </div>      <dl class="kvs">        <div class="kv"><dt>Last success</dt><dd>${esc(ago(j.last_ok_at))}</dd></div>        <div class="kv"><dt>Next run</dt><dd>${esc(nextRun)}</dd></div>        ${errKv("Last error", j.last_error, j.last_error_at)}      </dl>    </article>`;  });  return `<div class="job-list">${rows.join("")}</div>`;}function renderLog(list) {  if (!list.length) {    return `<p class="health-empty">The fetch log is empty.</p>`;  }  const rows = list.map((r) => {    const tone = LOG_TONE[r.status] || "idle";    const meta = [r.rows != null ? `${num(r.rows)} rows` : null, dur(r.duration_ms)]      .filter(Boolean)      .join(" · ");    return `<li class="logrow logrow--${tone}">      <span class="logrow__time num">${clock(r.started_at)}</span>      <span class="logrow__job">${esc(r.job)}</span>      ${badge(r.status, tone)}      <span class="logrow__detail">${r.detail ? esc(r.detail) : ""}</span>      <span class="logrow__meta num">${esc(meta)}</span>    </li>`;  });  return `<div class="log"><ul class="logrows">${rows.join("")}</ul></div>`;}function renderBanner(jobs) {  const banner = $('[data-role="banner"]');  if (!banner) return;  const active = jobs.filter((j) => j.state === "fetching");  if (!active.length) {    banner.hidden = true;    banner.innerHTML = "";    return;  }  const names = active.map((j) => esc(j.label)).join(", ");  banner.hidden = false;  banner.innerHTML = `<span class="health-banner__dot"></span>    <span>Fetching now — ${names}</span>`;}export function initHealth() {  const dataEl = document.getElementById("health-data");  if (!dataEl) return;  // The most recent snapshot, kept so the relative-time ticker can repaint  // without another request.  let current = null;  function render(snap) {    current = snap;    $('[data-role="endpoints"]').innerHTML = renderEndpoints(snap.endpoints);    $('[data-role="jobs"]').innerHTML = renderJobs(snap.jobs);    $('[data-role="log"]').innerHTML = renderLog(snap.log);    renderBanner(snap.jobs);    $('[data-role="asof"]').textContent = `updated ${ago(snap.generated_at)}`;  }  try {    render(JSON.parse(dataEl.textContent));  } catch {    return; // a malformed embed: leave the empty shell rather than throw  }  let pending = false;  async function refresh() {    if (pending) return;    pending = true;    try {      const res = await fetch("/api/health", { headers: { Accept: "application/json" } });      if (res.ok) render(await res.json());    } catch {      /* a failed poll just leaves the last snapshot up */    } finally {      pending = false;    }  }  // A job's start and finish can land within a few ms of each other; debounce  // so a burst of nudges costs one /api/health pull.  let timer = null;  window.addEventListener("finance:health", () => {    clearTimeout(timer);    timer = setTimeout(refresh, 250);  });  // Catch up after the tab was hidden, and keep "4m ago" honest while idle.  document.addEventListener("visibilitychange", () => {    if (!document.hidden) refresh();  });  setInterval(() => {    if (current && !document.hidden) render(current);  }, 30000);}
added frontend/static_src/health/styles/health.scss
@@ -0,0 +1,267 @@@use "../../base/styles/variables" as *;@use "../../base/styles/mixins" as *;/* ---------- page intro ---------- */.health-lede {  max-width: 64ch;  margin: 10px 0 2px;  color: var(--ink-dim);  font-size: 0.9rem;}.health-asof {  @include eyebrow;  font-size: 0.6rem;}/* ---------- "fetching now" banner ---------- */.health-banner {  display: flex;  align-items: center;  gap: 10px;  margin: 16px 0 2px;  padding: 11px 14px;  border: 1px solid var(--warn);  border-radius: var(--radius);  background: var(--warn-soft);  font-weight: 500;  font-size: 0.9rem;}/* `.health-banner` sets display:flex, which would override the [hidden]   attribute's display:none — so re-assert it explicitly. */.health-banner[hidden] {  display: none;}.health-banner__dot {  flex: none;  width: 9px;  height: 9px;  border-radius: 50%;  background: var(--warn);  animation: pulse 1.2s ease-in-out infinite;}/* ---------- status badge ---------- */.badge {  @include eyebrow;  flex: none;  padding: 3px 8px;  border-radius: 999px;  font-size: 0.56rem;}.badge--ok {  color: var(--up);  background: var(--up-soft);}.badge--bad {  color: var(--down);  background: var(--down-soft);}.badge--warn {  color: var(--warn);  background: var(--warn-soft);}.badge--idle {  color: var(--ink-faint);  background: var(--well);}/* ---------- endpoints ---------- */.endpoint-grid {  display: grid;  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));  gap: 12px;}.endpoint {  @include card;  padding: 16px;}.endpoint__head,.job__head {  display: flex;  align-items: baseline;  justify-content: space-between;  gap: 12px;}.endpoint__head h3,.job__id h3 {  font-size: 1rem;}/* request-budget meter */.meter {  margin: 16px 0 2px;}.meter__row {  display: flex;  align-items: baseline;  justify-content: space-between;  gap: 10px;  margin-bottom: 7px;}.meter__count {  font-size: 0.82rem;  font-weight: 600;}.meter__track {  height: 8px;}/* budget fill warms then reddens as the hourly ceiling nears */.track__fill--warn {  background: var(--warn);}.track__fill--bad {  background: var(--down);}.meter__note {  margin-top: 7px;  color: var(--ink-faint);  font-size: 0.74rem;}/* ---------- key-value rows (shared by endpoints and jobs) ---------- */.kvs {  margin-top: 12px;}.kv {  display: flex;  align-items: baseline;  justify-content: space-between;  gap: 12px;  padding: 7px 0;  border-top: 1px solid var(--rule);  font-size: 0.85rem;}.kv dt {  color: var(--ink-dim);}.kv dd {  font-weight: 600;  text-align: right;}.kv--err {  flex-direction: column;  align-items: stretch;}.kv--err dd {  font-weight: 400;  text-align: left;  color: var(--down);  word-break: break-word;}.kv--err .muted {  color: var(--ink-faint);  white-space: nowrap;}/* ---------- background jobs ---------- */.job-list {  display: flex;  flex-direction: column;  gap: 10px;}.job {  @include card;  padding: 14px 16px;}/* a job mid-fetch is rimmed in the working amber, echoing the banner */.job--active {  border-color: var(--warn);}.job__id p {  margin-top: 3px;  color: var(--ink-dim);  font-size: 0.8rem;}/* ---------- activity log ---------- */.log {  @include card;  padding: 2px 14px;}.logrows {  margin: 0;  padding: 0;  list-style: none;}.logrow {  display: flex;  align-items: baseline;  flex-wrap: wrap;  gap: 4px 10px;  padding: 9px 0;  border-top: 1px solid var(--rule);  font-size: 0.84rem;}.logrow:first-child {  border-top: none;}.logrow__time {  color: var(--ink-faint);  font-size: 0.78rem;}.logrow__job {  min-width: 6ch;  font-weight: 600;}.logrow__detail {  flex: 1 1 14ch;  min-width: 0;  color: var(--ink-dim);}/* a failed fetch should catch the eye down the tail */.logrow--bad .logrow__detail {  color: var(--down);}.logrow__meta {  color: var(--ink-faint);  font-size: 0.76rem;}/* ---------- empty states ---------- */.health-empty {  @include card;  padding: 22px;  color: var(--ink-dim);  font-size: 0.9rem;  text-align: center;}/* ---------- desktop ---------- */@media (min-width: $bp-md) {  .logrow__job {    min-width: 9ch;  }}
added frontend/static_src/home/index.js
@@ -0,0 +1,4 @@// Markets dashboard. The live stream runs from the base entry and patches// the sparkline cards in place; this page entry only ships the dashboard's// styles.import "./styles/home.scss";
added frontend/static_src/home/styles/home.scss
@@ -0,0 +1,250 @@@use "../../base/styles/variables" as *;@use "../../base/styles/mixins" as *;/* ============================================================================   Markets dashboard (Phase 11)   An opinionated, no-customization read of the market: a row of intraday   sparkline cards for the indexes and headline commodities, then the day's   biggest movers among the curated large-cap stocks.   ========================================================================== *//* ---------- "browse all" link beside the page heading ---------- */.page-head__link {  font-size: 0.82rem;  color: var(--ink-dim);  white-space: nowrap;}.page-head__link::after {  content: " \2192"; /* → */}.page-head__link:hover {  color: var(--ink);  text-decoration: underline;  text-underline-offset: 2px;}/* ---------- sparkline cards ---------- */.spark-grid {  display: grid;  grid-template-columns: repeat(auto-fill, minmax(184px, 1fr));  gap: 11px;}.spark-card {  @include card;  display: flex;  flex-direction: column;  gap: 9px;  padding: 13px 14px;  color: var(--ink);  transition: border-color 0.14s, transform 0.14s, box-shadow 0.14s;}.spark-card:hover {  color: var(--ink);  border-color: var(--rule-strong);  transform: translateY(-2px);  box-shadow: var(--lift-hover);}.spark-card__head {  display: flex;  align-items: baseline;  gap: 8px;}.spark-card__sym {  @include mono;  font-weight: 700;  font-size: 0.95rem;}.spark-card__name {  font-size: 0.73rem;  color: var(--ink-dim);  white-space: nowrap;  overflow: hidden;  text-overflow: ellipsis;}/* the sparkline svg is stretched to the card width (preserveAspectRatio   none); strokes keep a constant screen weight via non-scaling-stroke. */.spark {  display: block;  width: 100%;  height: 46px;}.spark__line {  fill: none;  stroke-width: 1.6;  stroke-linejoin: round;  stroke-linecap: round;  vector-effect: non-scaling-stroke;}.spark__area {  stroke: none;}/* faint dashed rule at the previous close, so above/below reads at a glance */.spark__base {  stroke: var(--ink-faint);  stroke-width: 1;  stroke-dasharray: 2 2;  opacity: 0.7;  vector-effect: non-scaling-stroke;}.is-up-card .spark__line {  stroke: var(--up);}.is-up-card .spark__area {  fill: var(--up-soft);}.is-down-card .spark__line {  stroke: var(--down);}.is-down-card .spark__area {  fill: var(--down-soft);}.spark--empty {  display: flex;  align-items: center;  justify-content: center;  height: 46px;  color: var(--ink-faint);  font-size: 0.72rem;}.spark-card__foot {  display: flex;  align-items: baseline;  justify-content: space-between;  gap: 8px;  padding-top: 8px;  border-top: 1px solid var(--rule);}.spark-card__price {  font-size: 0.95rem;  font-weight: 600;}.spark-card__chg {  font-size: 0.81rem;  font-weight: 600;}/* update flash, toggled by the live stream client (mirrors .ticker-card) */.spark-card.flash-up {  animation: flash-up 0.7s ease-out;}.spark-card.flash-down {  animation: flash-down 0.7s ease-out;}/* ---------- movers panels ---------- */.movers {  display: grid;  grid-template-columns: 1fr;  gap: 12px;}.movers__panel {  @include card;  padding: 6px 14px 8px;}.movers__title {  @include eyebrow;  display: block;  padding: 12px 4px 8px;  border-bottom: 1px solid var(--rule-strong);}.movers__empty {  color: var(--ink-faint);  font-size: 0.82rem;  padding: 16px 4px;}.mover {  position: relative;  display: grid;  grid-template-columns: auto minmax(0, 1fr) auto auto;  align-items: center;  gap: 12px;  padding: 10px 6px;  color: var(--ink);  border-bottom: 1px solid var(--rule);}.mover:last-child {  border-bottom: 0;}/* a soft magnitude tint behind the row: width set by the route via --bar.   A pseudo-element keeps it independent of the hover background. */.mover::before {  content: "";  position: absolute;  left: 0;  top: 0;  bottom: 0;  width: var(--bar, 0%);  border-radius: var(--radius-sm);}.mover--up::before {  background: var(--up-soft);}.mover--down::before {  background: var(--down-soft);}.mover > * {  position: relative; /* sit above the magnitude tint */}.mover:hover {  color: var(--ink);  background: var(--well);}.mover__sym {  font-weight: 700;  font-size: 0.86rem;}.mover__name {  font-size: 0.78rem;  color: var(--ink-dim);  white-space: nowrap;  overflow: hidden;  text-overflow: ellipsis;}.mover__price {  font-size: 0.82rem;  color: var(--ink-dim);}.mover__chg {  font-size: 0.85rem;  font-weight: 600;  text-align: right;  min-width: 4.4ch;}/* ---------- desktop layering ---------- */@media (min-width: $bp-md) {  .movers {    grid-template-columns: 1fr 1fr;  }}
added frontend/static_src/search/index.js
@@ -0,0 +1,5 @@// Search page: browse and search the symbol universe, and add new symbols.import "./styles/search.scss";import { initSearch } from "./scripts/search.js";initSearch();
added frontend/static_src/search/scripts/search.js
@@ -0,0 +1,53 @@// Search page enhancement: the "Add <TICKER>" button.//// The page is server-rendered; this only wires the add-symbol affordance.// Clicking it POSTs the ticker to /api/symbols, which validates it against// Yahoo and registers it. On success the browser lands on the new symbol's// page; on failure the server's message is shown inline.export function initSearch() {  const btn = document.querySelector("[data-add-ticker]");  if (!btn) return;  const errEl = document.querySelector('[data-role="add-error"]');  const ticker = btn.dataset.addTicker;  const label = btn.textContent;  function showError(msg) {    if (!errEl) return;    errEl.textContent = msg;    errEl.hidden = false;  }  btn.addEventListener("click", async () => {    btn.disabled = true;    btn.textContent = "Adding…";    if (errEl) errEl.hidden = true;    try {      const res = await fetch("/api/symbols", {        method: "POST",        headers: { "Content-Type": "application/json" },        body: JSON.stringify({ ticker }),      });      let data = {};      try {        data = await res.json();      } catch {        /* a non-JSON body falls through to the generic message below */      }      if (res.ok && data.ok && data.ticker) {        // Land on the freshly added symbol's page.        window.location.href = "/s/" + encodeURIComponent(data.ticker);        return;      }      showError(data.error || "Could not add that symbol. Try again shortly.");    } catch {      showError("Could not reach the server. Check your connection and try again.");    }    btn.disabled = false;    btn.textContent = label;  });}
added frontend/static_src/search/styles/search.scss
@@ -0,0 +1,132 @@@use "../../base/styles/variables" as *;@use "../../base/styles/mixins" as *;/* ---------- count beside the page heading ---------- */.search-count {  @include eyebrow;  font-size: 0.6rem;}/* ---------- the on-page search field ---------- */.searchbar {  display: flex;  gap: 10px;  margin: 14px 0 2px;}.searchbar input {  flex: 1 1 auto;  min-width: 0;  @include tap;  padding: 0 14px;  color: var(--ink);  background: var(--surface);  border: 1px solid var(--rule-strong);  border-radius: var(--radius-sm);  outline: none;  transition: border-color 0.15s, box-shadow 0.15s;}.searchbar input::placeholder {  color: var(--ink-faint);}.searchbar input:focus {  border-color: var(--ink);  box-shadow: 0 0 0 3px var(--well);}.searchbar .btn {  flex: none;}/* ---------- kind filter ---------- */.kind-filter {  display: flex;  flex-wrap: wrap;  gap: 8px;  margin: 14px 0 4px;}.kind-pill {  @include tap;  display: inline-flex;  align-items: center;  min-height: 0;  min-width: 0;  padding: 7px 14px;  border-radius: 999px;  border: 1px solid var(--rule-strong);  background: var(--surface);  color: var(--ink-dim);  font-size: 0.82rem;  font-weight: 500;  transition: border-color 0.14s, color 0.14s, background 0.14s;}.kind-pill:hover {  color: var(--ink);  border-color: var(--ink);}.kind-pill.is-active {  background: var(--ink);  border-color: var(--ink);  color: var(--paper);  font-weight: 600;}.kind-pill.is-active:hover {  color: var(--paper);}/* ---------- add-symbol panel ---------- */.add-panel {  @include card;  display: flex;  flex-wrap: wrap;  align-items: center;  gap: 12px 16px;  margin: 18px 0 2px;  padding: 16px 18px;}.add-panel__text {  flex: 1 1 16ch;}.add-panel__title {  font-weight: 600;}.add-panel__title .num {  @include mono;  font-weight: 700;}.add-panel__sub {  margin-top: 3px;  color: var(--ink-dim);  font-size: 0.84rem;}.add-panel .btn {  flex: none;}.add-panel__error {  flex: 1 1 100%;  margin: 0;  color: var(--down);  font-size: 0.85rem;  font-weight: 500;}/* ---------- results ---------- */.search-grid {  margin-top: 18px;}.empty--search h1 {  font-size: 1.4rem;}
added frontend/static_src/symbol/index.js
@@ -0,0 +1,8 @@// Symbol detail page: the lightweight-charts chart + range selector, and the// annual / quarterly financials toggle.import "./styles/symbol.scss";import { initChart } from "./scripts/chart.js";import { initFundamentals } from "./scripts/fundamentals.js";initChart();initFundamentals();
added frontend/static_src/symbol/scripts/chart.js
@@ -0,0 +1,384 @@import {  createChart,  CandlestickSeries,  LineSeries,  HistogramSeries,  ColorType,} from "lightweight-charts";// Paper Ledger theme: ink figures and hairline rules on the warm paper// surface. autoSize wires an internal ResizeObserver so the chart tracks// its container on every viewport.//// handleScroll / handleScale are OFF: the range buttons are the only way to// change what's shown, so the viewport can never pan or zoom into empty space// beyond the loaded data. Drag is freed up for the measure tool below.// Pane resizing is off for the same reason — the RSI pane keeps a fixed size.const chartOptions = {  autoSize: true,  handleScroll: false,  handleScale: false,  layout: {    background: { type: ColorType.Solid, color: "transparent" },    textColor: "#6b6456",    fontFamily: "'JetBrains Mono', monospace",    attributionLogo: false,    panes: { enableResize: false, separatorColor: "rgba(33,31,26,0.16)" },  },  grid: {    vertLines: { color: "rgba(33,31,26,0.07)" },    horzLines: { color: "rgba(33,31,26,0.07)" },  },  rightPriceScale: { borderColor: "rgba(33,31,26,0.16)" },  timeScale: { borderColor: "rgba(33,31,26,0.16)", rightOffset: 0 },  crosshair: { mode: 1 },};// Overlay indicator inks. The candlesticks own semantic green/red, and the// rest of the app reserves green/amber/red for good/ok/bad, so the moving// averages get their own muted, non-semantic palette — wayfinding lines, not// value judgments — desaturated to sit inside the warm Paper Ledger world.const OVERLAY_INK = {  sma50: "#3f6f9c",  sma200: "#9c6b3f",  ema21: "#6f5b86",};const RSI_INK = "#3f6f9c";const VOLUME_UP = "rgba(47,125,79,0.38)";const VOLUME_DOWN = "rgba(178,59,50,0.38)";/** `12.4` -> `+$12.40`, `-3` -> `-$3.00`. */function fmtMoney(n) {  const sign = n > 0 ? "+" : n < 0 ? "-" : "";  return `${sign}$${Math.abs(n).toFixed(2)}`;}/** * A human caption for a visible span, e.g. "over 6 months", "over 8 years". * Derived from the actual dates shown rather than the range button, so a * deep MAX history clamped to what fits is described honestly. */function spanLabel(fromISO, toISO) {  const months = (Date.parse(toISO) - Date.parse(fromISO)) / 2.6298e9; // 30.44d  if (months < 1.6) return "over 1 month";  if (months < 11.5) return `over ${Math.round(months)} months`;  const years = months / 12;  return years < 1.5 ? "over 1 year" : `over ${Math.round(years)} years`;}export function initChart() {  const el = document.getElementById("chart");  if (!el) return;  const ticker = el.dataset.ticker;  const chart = createChart(el, chartOptions);  // Volume first so the candlesticks draw over it in the shared bottom strip;  // its own price scale, pinned low and unlabelled, keeps it off the axis.  const volumeSeries = chart.addSeries(HistogramSeries, {    priceScaleId: "volume",    priceFormat: { type: "volume" },    lastValueVisible: false,    priceLineVisible: false,  });  chart.priceScale("volume").applyOptions({    scaleMargins: { top: 0.82, bottom: 0 },  });  const series = chart.addSeries(CandlestickSeries, {    upColor: "#2f7d4f",    downColor: "#b23b32",    wickUpColor: "#2f7d4f",    wickDownColor: "#b23b32",    borderVisible: false,  });  // Moving-average overlays on the price pane. Created up front and shown or  // hidden by the toggle row; sma200 first so the faster lines sit on top.  const overlay = (key, dashed) =>    chart.addSeries(LineSeries, {      color: OVERLAY_INK[key],      lineWidth: 2,      lineStyle: dashed ? 2 : 0,      priceLineVisible: false,      lastValueVisible: false,      crosshairMarkerVisible: false,    });  const overlays = {    sma200: overlay("sma200", false),    sma50: overlay("sma50", false),    ema21: overlay("ema21", true),  };  let bars = []; // loaded candles, ascending by time  let latest = null; // last loaded payload, kept so RSI can attach on demand  // RSI lives in its own pane below the price pane and is created only while  // toggled on, so an empty second pane never lingers when it is off.  let rsiSeries = null;  function buildRsi() {    if (rsiSeries) return;    rsiSeries = chart.addSeries(      LineSeries,      {        color: RSI_INK,        lineWidth: 2,        priceLineVisible: false,        lastValueVisible: false,        crosshairMarkerVisible: false,        // Pin the pane to 0..100 so the 30/70 guides are always in view.        autoscaleInfoProvider: () => ({          priceRange: { minValue: 0, maxValue: 100 },        }),      },      1,    );    for (const level of [70, 30]) {      rsiSeries.createPriceLine({        price: level,        color: "rgba(33,31,26,0.30)",        lineWidth: 1,        lineStyle: 2,        axisLabelVisible: true,        title: String(level),      });    }    // Tight margins so the pinned 0..100 range fills the pane without the    // axis padding out to stray values like 120.    rsiSeries.priceScale().applyOptions({      scaleMargins: { top: 0.12, bottom: 0.12 },    });    const panes = chart.panes();    if (panes.length > 1) panes[1].setHeight(116);    if (latest) rsiSeries.setData(latest.rsi14);  }  function destroyRsi() {    if (!rsiSeries) return;    chart.removeSeries(rsiSeries);    rsiSeries = null;    // removeSeries leaves the now-empty pane behind; drop it explicitly.    if (chart.panes().length > 1) chart.removePane(1);  }  // Measure-tool overlay: a shaded band plus a readout chip, drawn by  // click-dragging across the chart to compare two points (the Google  // Finance gesture). Both are pointer-transparent so the drag stays on #chart.  const band = document.createElement("div");  band.className = "chart-band";  band.hidden = true;  const readout = document.createElement("div");  readout.className = "chart-readout";  readout.hidden = true;  el.append(band, readout);  const ts = chart.timeScale();  let anchorIdx = null; // bar index where the drag began  let curIdx = null; // bar index under the pointer now  let dragging = false;  // Map a viewport x to the nearest loaded bar index.  function barIndexAt(clientX) {    const x = clientX - el.getBoundingClientRect().left;    const logical = ts.coordinateToLogical(x);    if (logical === null) return null;    return Math.min(bars.length - 1, Math.max(0, Math.round(logical)));  }  function clearSelection() {    anchorIdx = null;    curIdx = null;    band.hidden = true;    readout.hidden = true;  }  // Position the band between the two selected bars and fill the readout  // with the % / absolute change over the interval. Called on drag and on  // every chart relayout so the band stays glued to the data.  function renderSelection() {    if (anchorIdx === null || curIdx === null || anchorIdx === curIdx) {      band.hidden = true;      readout.hidden = true;      return;    }    const a = Math.min(anchorIdx, curIdx);    const b = Math.max(anchorIdx, curIdx);    const xa = ts.logicalToCoordinate(a);    const xb = ts.logicalToCoordinate(b);    if (xa === null || xb === null) return;    const left = Math.min(xa, xb);    const width = Math.abs(xb - xa);    band.style.left = `${left}px`;    band.style.width = `${width}px`;    band.hidden = false;    const startClose = bars[a].close;    const endClose = bars[b].close;    const absChange = endClose - startClose;    const pct = startClose !== 0 ? (absChange / startClose) * 100 : 0;    const up = absChange >= 0;    readout.dataset.dir = up ? "up" : "down";    readout.innerHTML =      `<span class="chart-readout__pct">${up ? "▲" : "▼"} ` +      `${up ? "+" : ""}${pct.toFixed(2)}%</span>` +      `<span class="chart-readout__sub">${fmtMoney(absChange)} · ` +      `${bars[a].time} → ${bars[b].time}</span>`;    readout.hidden = false;    // Center the readout over the band, clamped to the chart's width.    const mid = left + width / 2;    const rw = readout.offsetWidth;    const max = el.clientWidth - rw - 4;    readout.style.left = `${Math.min(max, Math.max(4, mid - rw / 2))}px`;  }  el.addEventListener("pointerdown", (e) => {    if (bars.length < 2) return;    const idx = barIndexAt(e.clientX);    if (idx === null) return;    dragging = true;    anchorIdx = idx;    curIdx = idx;    el.setPointerCapture(e.pointerId);    renderSelection();  });  el.addEventListener("pointermove", (e) => {    if (!dragging) return;    const idx = barIndexAt(e.clientX);    if (idx === null) return;    curIdx = idx;    renderSelection();  });  function endDrag(e) {    if (!dragging) return;    dragging = false;    try {      el.releasePointerCapture(e.pointerId);    } catch {      /* pointer already released */    }    // A click with no drag clears any existing selection.    if (anchorIdx === curIdx) clearSelection();  }  el.addEventListener("pointerup", endDrag);  el.addEventListener("pointercancel", endDrag);  // The change chip beside the range buttons: % and absolute move across the  // bars actually visible on the chart, so the headline figure always agrees  // with what is drawn — a deep MAX history is clamped to what legibly fits,  // and the chip then reports only that visible span, not the full dataset.  function renderRangeSummary() {    const summary = document.getElementById("range-summary");    if (!summary) return;    let lo = 0;    let hi = bars.length - 1;    const lr = ts.getVisibleLogicalRange();    if (lr) {      lo = Math.max(lo, Math.ceil(lr.from));      hi = Math.min(hi, Math.floor(lr.to));    }    if (hi <= lo) {      summary.hidden = true;      return;    }    const start = bars[lo].close;    const end = bars[hi].close;    const abs = end - start;    const pct = start !== 0 ? (abs / start) * 100 : 0;    const up = abs >= 0;    summary.dataset.dir = up ? "up" : "down";    summary.innerHTML =      `<span class="range-summary__chg num">${up ? "▲" : "▼"} ` +      `${up ? "+" : ""}${pct.toFixed(2)}%` +      `<span class="range-summary__abs">${fmtMoney(abs)}</span></span>` +      `<span class="range-summary__cap">${spanLabel(bars[lo].time, bars[hi].time)}</span>`;    summary.hidden = false;  }  // Keep the band and the range chip glued to the data when the chart relays  // out — a range change (fitContent), a resize, anything that moves the view.  ts.subscribeVisibleLogicalRangeChange(() => {    if (anchorIdx !== null && curIdx !== null) renderSelection();    renderRangeSummary();  });  // Apply a freshly fetched payload to every series at once.  function applyData(d) {    latest = d;    bars = d.candles;    series.setData(d.candles);    volumeSeries.setData(      d.candles.map((c) => ({        time: c.time,        value: c.volume,        color: c.close >= c.open ? VOLUME_UP : VOLUME_DOWN,      })),    );    overlays.sma50.setData(d.sma50);    overlays.sma200.setData(d.sma200);    overlays.ema21.setData(d.ema21);    if (rsiSeries) rsiSeries.setData(d.rsi14);  }  // ── indicator toggles ──────────────────────────────────────────────────  function applyIndicator(key, on) {    if (key === "rsi") {      if (on) buildRsi();      else destroyRsi();    } else if (key === "volume") {      volumeSeries.applyOptions({ visible: on });    } else if (overlays[key]) {      overlays[key].applyOptions({ visible: on });    }  }  document.querySelectorAll(".ind-btn").forEach((btn) => {    const key = btn.dataset.ind;    // Paint the swatch from the JS palette so the inks live in one place.    const dot = btn.querySelector(".ind-btn__dot");    if (dot) dot.style.background = key === "rsi" ? RSI_INK : OVERLAY_INK[key];    // The template's is-active class is the initial visibility.    applyIndicator(key, btn.classList.contains("is-active"));    btn.addEventListener("click", () => {      const on = !btn.classList.contains("is-active");      btn.classList.toggle("is-active", on);      btn.setAttribute("aria-pressed", on ? "true" : "false");      applyIndicator(key, on);    });  });  // ── range buttons ──────────────────────────────────────────────────────  let loaded = null;  async function load(range) {    if (loaded === range) return;    loaded = range;    clearSelection();    el.classList.add("is-loading");    try {      const res = await fetch(        `/api/symbols/${encodeURIComponent(ticker)}/history?range=${range}`,      );      if (!res.ok) throw new Error(`history ${res.status}`);      applyData(await res.json());      chart.timeScale().fitContent();      renderRangeSummary();    } catch (err) {      loaded = null;      console.error("chart load failed", err);    } finally {      el.classList.remove("is-loading");    }  }  const buttons = Array.from(document.querySelectorAll("[data-range]"));  buttons.forEach((btn) => {    btn.addEventListener("click", () => {      buttons.forEach((b) => b.classList.remove("is-active"));      btn.classList.add("is-active");      load(btn.dataset.range);    });  });  const active = buttons.find((b) => b.classList.contains("is-active"));  load(active ? active.dataset.range : "1Y");}
added frontend/static_src/symbol/scripts/fundamentals.js
@@ -0,0 +1,21 @@// Annual / quarterly toggle for the financials table. Both tables are// server-rendered; this just shows one and hides the other.export function initFundamentals() {  const tabs = document.querySelectorAll(".fin__tab");  const panels = document.querySelectorAll(".fin__panel");  if (!tabs.length) return;  tabs.forEach((tab) => {    tab.addEventListener("click", () => {      const period = tab.dataset.period;      tabs.forEach((t) => {        const on = t === tab;        t.classList.toggle("is-active", on);        t.setAttribute("aria-selected", on ? "true" : "false");      });      panels.forEach((p) => {        p.hidden = p.dataset.period !== period;      });    });  });}
added frontend/static_src/symbol/styles/symbol.scss
@@ -0,0 +1,683 @@@use "../../base/styles/variables" as *;@use "../../base/styles/mixins" as *;/* ---------- header ---------- */.sym-head {  margin: 6px 0 20px;  padding-bottom: 16px;  border-bottom: 1px solid var(--rule-strong);}.sym-head__id {  display: flex;  align-items: center;  gap: 10px;  flex-wrap: wrap;}.sym-head__ticker {  font-size: 1.85rem;}.sym-head__tag {  @include eyebrow;  font-size: 0.58rem;  padding: 3px 8px;  border: 1px solid var(--rule-strong);  border-radius: 4px;}.sym-head__name {  color: var(--ink-dim);  margin-top: 4px;}.sym-head__quote {  display: flex;  align-items: baseline;  flex-wrap: wrap;  gap: 6px 14px;  margin-top: 14px;}.sym-head__price {  font-size: 1.9rem;  font-weight: 700;}.sym-head__chg {  font-size: 1rem;  font-weight: 600;}.sym-head__asof {  @include eyebrow;  font-size: 0.62rem;}/* ---------- chart panel ---------- */.chart-panel {  padding: 14px;}/* range buttons on the left, the range-change chip pushed to the right */.chart-bar {  display: flex;  flex-wrap: wrap;  align-items: center;  gap: 10px 16px;  margin-bottom: 10px;}.range-bar {  display: flex;  flex-wrap: wrap;  gap: 6px;}/* the range-change chip: % and absolute move over the whole visible range */.range-summary {  display: flex;  flex-direction: column;  align-items: flex-end;  gap: 1px;  margin-left: auto;  line-height: 1.25;  text-align: right;}/* `display: flex` would override the [hidden] attribute, as on .chart-readout */.range-summary[hidden] {  display: none;}.range-summary__chg {  font-size: 1rem;  font-weight: 700;}.range-summary[data-dir="up"] .range-summary__chg {  color: var(--up);}.range-summary[data-dir="down"] .range-summary__chg {  color: var(--down);}.range-summary__abs {  margin-left: 8px;  color: var(--ink-dim);  font-weight: 600;}.range-summary__cap {  @include eyebrow;  font-size: 0.56rem;  color: var(--ink-faint);}/* ---------- indicator toggle row ---------- */.ind-bar {  display: flex;  flex-wrap: wrap;  gap: 6px;  margin-bottom: 14px;  padding-top: 10px;  border-top: 1px solid var(--rule);}.ind-btn {  @include mono;  display: inline-flex;  align-items: center;  gap: 7px;  min-height: 44px;  padding: 0 12px;  border-radius: var(--radius-sm);  border: 1px solid var(--rule);  background: var(--surface);  color: var(--ink-faint);  font-size: 0.76rem;  font-weight: 500;  cursor: pointer;  transition: color 0.13s, border-color 0.13s;}.ind-btn:hover {  color: var(--ink-dim);  border-color: var(--rule-strong);}.ind-btn.is-active {  color: var(--ink);  border-color: var(--ink);}/* the swatch echoes the indicator's line color on the chart (chart.js paints   it); dimmed while the indicator is toggled off */.ind-btn__dot {  width: 18px;  height: 3px;  border-radius: 2px;  background: var(--ink-faint);  flex: none;  opacity: 0.4;  transition: opacity 0.13s;}.ind-btn.is-active .ind-btn__dot {  opacity: 1;}.range-btn {  @include mono;  min-height: 44px;  min-width: 52px;  padding: 0 13px;  border-radius: var(--radius-sm);  border: 1px solid var(--rule-strong);  background: var(--surface);  color: var(--ink-dim);  font-size: 0.78rem;  font-weight: 500;  cursor: pointer;  transition: color 0.13s, background 0.13s, border-color 0.13s;}.range-btn:hover {  color: var(--ink);  border-color: var(--ink);}.range-btn.is-active {  color: var(--paper);  background: var(--ink);  border-color: var(--ink);  font-weight: 700;}#chart {  position: relative;  width: 100%;  height: 360px;  transition: opacity 0.15s;  /* horizontal drags drive the measure tool; vertical still scrolls the page */  touch-action: pan-y;}#chart.is-loading {  opacity: 0.45;}/* ---------- chart measure tool ---------- *//* Shaded band spanning a click-dragged interval; bottom inset clears the   time axis. Pointer-transparent so the drag stays bound to #chart. */.chart-band {  position: absolute;  top: 0;  bottom: 26px;  z-index: 3;  background: rgba(33, 31, 26, 0.07);  border-left: 1px solid var(--rule-strong);  border-right: 1px solid var(--rule-strong);  pointer-events: none;}.chart-readout {  position: absolute;  top: 8px;  z-index: 4;  display: flex;  flex-direction: column;  gap: 1px;  padding: 5px 9px;  background: var(--surface);  border: 1px solid var(--rule-strong);  border-radius: var(--radius-sm);  box-shadow: var(--lift-hover);  white-space: nowrap;  pointer-events: none;}/* `display: flex` above would override the [hidden] attribute's display:none,   leaving the readout chip stuck on screen after a selection is cleared, so   re-assert it (same fix as `.health-banner[hidden]`). */.chart-readout[hidden] {  display: none;}.chart-readout__pct {  @include mono;  font-size: 0.92rem;  font-weight: 700;}.chart-readout[data-dir="up"] .chart-readout__pct {  color: var(--up);}.chart-readout[data-dir="down"] .chart-readout__pct {  color: var(--down);}.chart-readout__sub {  @include mono;  font-size: 0.66rem;  color: var(--ink-faint);}/* ---------- key stats: gauges ---------- */.keystats {  display: grid;  grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));  gap: 12px;}.gauge {  @include card;  padding: 16px 16px 8px;}.gauge__row {  display: flex;  align-items: baseline;  justify-content: space-between;  gap: 12px;}.gauge__label {  @include eyebrow;  color: var(--ink);}.gauge__meta {  font-size: 0.74rem;  color: var(--ink-faint);  text-align: right;}/* the rail: pips need vertical room above the track for their dot heads */.gauge__track {  height: 8px;  margin: 24px 0 10px;}.gauge__ends {  display: flex;  justify-content: space-between;  gap: 8px;  font-size: 0.82rem;  color: var(--ink-dim);}.gauge__ends-r {  text-align: right;}.gauge__ends-c {  text-align: center;}.gauge__cap {  @include eyebrow;  font-size: 0.56rem;  margin-right: 3px;}/* ---------- gauge legend ---------- */.legend {  margin-top: 12px;}.legend__item {  display: grid;  grid-template-columns: 1fr auto auto;  align-items: baseline;  gap: 10px;  padding: 8px 0;  border-top: 1px solid var(--rule);}.legend__item dt {  display: flex;  align-items: center;  gap: 7px;  font-size: 0.85rem;  color: var(--ink-dim);}.legend__item dd {  font-size: 0.9rem;  font-weight: 600;}.legend__delta {  font-size: 0.78rem;  font-weight: 600;  min-width: 4.5ch;  text-align: right;}/* legend dots echo the markers on the rail */.legend__dot {  width: 9px;  height: 9px;  border-radius: 50%;  background: var(--ink-dim);  flex: none;}.legend__dot--ghost {  background: transparent;  border: 2px solid var(--ink-faint);}.legend__dot--up {  background: var(--up);}.legend__dot--down {  background: var(--down);}/* ---------- fundamentals: graded ratio cards ---------- */.fund-basis {  margin: 2px 0 14px;  color: var(--ink-faint);  font-size: 0.82rem;}.ratios {  display: grid;  grid-template-columns: repeat(auto-fit, minmax(258px, 1fr));  gap: 12px;}.ratio {  @include card;  display: flex;  flex-direction: column;  padding: 14px 16px;}.ratio__head {  display: flex;  align-items: baseline;  justify-content: space-between;  gap: 10px;}.ratio__label {  @include eyebrow;  color: var(--ink);}/* the verdict badge: a quiet pill that carries the semantic color */.ratio__badge {  @include eyebrow;  flex: none;  padding: 3px 8px;  border-radius: 999px;  font-size: 0.54rem;}.ratio__badge--good {  color: var(--up);  background: var(--up-soft);}.ratio__badge--ok {  color: var(--warn);  background: var(--warn-soft);}.ratio__badge--bad {  color: var(--down);  background: var(--down-soft);}.ratio__badge--unknown {  color: var(--ink-faint);  background: var(--well);}/* the figure itself, colored to match the grade */.ratio__value {  font-size: 1.7rem;  font-weight: 700;  margin: 9px 0 3px;}.ratio--good .ratio__value {  color: var(--up);}.ratio--ok .ratio__value {  color: var(--warn);}.ratio--bad .ratio__value {  color: var(--down);}.ratio--unknown .ratio__value {  color: var(--ink-faint);}.ratio__reading {  font-size: 0.83rem;  color: var(--ink-dim);}/* the static "how to read it" note, set off below a hairline */.ratio__explain {  margin-top: 9px;  padding-top: 9px;  border-top: 1px solid var(--rule);  font-size: 0.74rem;  color: var(--ink-faint);}/* shown while the SEC sweep has not yet reached this symbol */.fund-pending {  @include card;  padding: 18px 20px;  color: var(--ink-dim);  font-size: 0.88rem;}.fund-pending a {  text-decoration: underline;  text-underline-offset: 2px;}/* ---------- financials table + period toggle ---------- */.fin {  padding: 14px 16px;}.fin__toggle {  display: flex;  gap: 6px;  margin-bottom: 14px;}.fin__tab {  @include mono;  min-height: 40px;  padding: 0 15px;  border-radius: var(--radius-sm);  border: 1px solid var(--rule-strong);  background: var(--surface);  color: var(--ink-dim);  font-size: 0.78rem;  font-weight: 500;  cursor: pointer;  transition: color 0.13s, background 0.13s, border-color 0.13s;}.fin__tab:hover {  color: var(--ink);  border-color: var(--ink);}.fin__tab.is-active {  color: var(--paper);  background: var(--ink);  border-color: var(--ink);  font-weight: 700;}/* wide tables scroll within the card rather than the page on a phone */.fin__scroll {  overflow-x: auto;}.fin__table {  width: 100%;  border-collapse: collapse;  white-space: nowrap;}.fin__table th,.fin__table td {  padding: 9px 10px;  text-align: right;  font-size: 0.85rem;}.fin__table th:first-child,.fin__table td:first-child {  padding-left: 0;}.fin__table th:last-child,.fin__table td:last-child {  padding-right: 0;}.fin__table thead th {  @include eyebrow;  font-size: 0.58rem;  color: var(--ink-faint);  border-bottom: 1px solid var(--rule-strong);}.fin__table tbody tr {  border-top: 1px solid var(--rule);}.fin__table tbody tr:first-child {  border-top: none;}.fin__table tbody th {  text-align: left;  font-weight: 500;  color: var(--ink-dim);}.fin__table tbody td {  font-weight: 600;}.fin__empty {  padding: 8px 0;  color: var(--ink-dim);  font-size: 0.86rem;}/* ---------- recent SEC filings ---------- */.filings {  padding: 2px 16px;}.filing-list {  margin: 0;  padding: 0;  list-style: none;}.filing {  border-top: 1px solid var(--rule);}.filing:first-child {  border-top: none;}.filing__link {  display: flex;  align-items: center;  gap: 12px;  padding: 11px 0;  color: var(--ink);}.filing__link:hover {  color: var(--ink);}.filing__form {  flex: none;  min-width: 7ch;  text-align: center;  padding: 5px 7px;  border: 1px solid var(--rule-strong);  border-radius: 4px;  font-size: 0.71rem;  font-weight: 700;}.filing__body {  flex: 1;  min-width: 0;  display: flex;  flex-direction: column;  gap: 1px;}.filing__desc {  font-size: 0.88rem;  font-weight: 500;}.filing__link:hover .filing__desc {  color: var(--ink-dim);}.filing__meta {  font-size: 0.74rem;  color: var(--ink-faint);}.filing__ext {  flex: none;  width: 15px;  height: 15px;  color: var(--ink-faint);}/* ---------- desktop layering ---------- */@media (min-width: $bp-md) {  #chart {    height: 460px;  }  .sym-head__ticker {    font-size: 2.2rem;  }  .sym-head__price {    font-size: 2.2rem;  }  .range-btn,  .ind-btn {    min-height: 34px;  }  .fin__tab {    min-height: 34px;  }}
added frontend/vite.config.js
@@ -0,0 +1,29 @@import { resolve } from "path";import { defineConfig } from "vite";// Mirrors the sibling Rust apps:// - Entry points live under static_src/{base,home,symbol,health,search}// - Vite outputs to ../dist/ with content-hashed filenames// - The Rust binary reads dist/.vite/manifest.json to resolve hashed namesexport default defineConfig({  base: "/static/",  build: {    outDir: resolve(__dirname, "../dist"),    emptyOutDir: true,    manifest: true,    rollupOptions: {      input: {        base: resolve(__dirname, "static_src/base/index.js"),        home: resolve(__dirname, "static_src/home/index.js"),        symbol: resolve(__dirname, "static_src/symbol/index.js"),        health: resolve(__dirname, "static_src/health/index.js"),        search: resolve(__dirname, "static_src/search/index.js"),      },    },  },  css: {    preprocessorOptions: {      scss: { quietDeps: true },    },  },});
added migrations/0001_initial.sql
@@ -0,0 +1,158 @@-- finance: market-watching schema.-- All *_at columns are UTC epoch-milliseconds (see db::now_ms).-- Trading dates ("d") are TEXT YYYY-MM-DD: calendar days, not instants.-- ── symbols: the watched universe (stocks, ETFs, indexes) ──CREATE TABLE symbols (    ticker                 TEXT PRIMARY KEY,            -- uppercase, e.g. AAPL, ^SPX    name                   TEXT NOT NULL DEFAULT '',    kind                   TEXT NOT NULL DEFAULT 'stock',  -- stock | etf | index    exchange               TEXT,    currency               TEXT NOT NULL DEFAULT 'USD',    cik                    TEXT,                        -- 10-digit zero-padded SEC CIK; NULL for ETF/index    sector                 TEXT,    industry               TEXT,    is_seeded              INTEGER NOT NULL DEFAULT 0,  -- member of the curated starter list    is_watched             INTEGER NOT NULL DEFAULT 0,  -- in at least one watchlist (denormalized)    history_synced_at      INTEGER,    history_first_date     TEXT,    history_last_date      TEXT,    fundamentals_synced_at INTEGER,    filings_synced_at      INTEGER,    -- denormalized latest snapshot for fast list rendering and SSE seeding    last_price             REAL,    prev_close             REAL,    last_quote_at          INTEGER,    created_at             INTEGER NOT NULL,    updated_at             INTEGER NOT NULL);CREATE INDEX symbols_kind       ON symbols(kind);CREATE INDEX symbols_is_watched ON symbols(is_watched);CREATE INDEX symbols_name       ON symbols(name);-- ── daily_prices: deep historical OHLCV from Stooq. Permanent, never pruned. ──CREATE TABLE daily_prices (    ticker  TEXT NOT NULL REFERENCES symbols(ticker) ON DELETE CASCADE,    d       TEXT NOT NULL,                              -- YYYY-MM-DD trading date    open    REAL NOT NULL,    high    REAL NOT NULL,    low     REAL NOT NULL,    close   REAL NOT NULL,    volume  INTEGER NOT NULL DEFAULT 0,    PRIMARY KEY (ticker, d));CREATE INDEX daily_prices_d ON daily_prices(d);-- ── intraday_bars: today's ~15-min-delayed bars from Yahoo. Pruned to recent days. ──CREATE TABLE intraday_bars (    ticker  TEXT NOT NULL REFERENCES symbols(ticker) ON DELETE CASCADE,    ts      INTEGER NOT NULL,                           -- bar start, UTC epoch-ms    open    REAL NOT NULL,    high    REAL NOT NULL,    low     REAL NOT NULL,    close   REAL NOT NULL,    volume  INTEGER NOT NULL DEFAULT 0,    PRIMARY KEY (ticker, ts));CREATE INDEX intraday_bars_ts ON intraday_bars(ts);-- ── quotes: latest live quote snapshot per symbol (one row per ticker, upserted) ──CREATE TABLE quotes (    ticker        TEXT PRIMARY KEY REFERENCES symbols(ticker) ON DELETE CASCADE,    price         REAL NOT NULL,    prev_close    REAL,    open          REAL,    day_high      REAL,    day_low       REAL,    volume        INTEGER,    market_state  TEXT,                                 -- PRE | REGULAR | POST | CLOSED (source-reported)    source        TEXT NOT NULL DEFAULT 'yahoo',    source_time   INTEGER,                              -- the source's own timestamp (epoch-ms)    fetched_at    INTEGER NOT NULL);-- ── fundamentals: one row per (ticker, metric, fiscal period) from SEC XBRL facts ──-- Long/narrow so new XBRL concepts need no schema change. Stocks only.CREATE TABLE fundamentals (    id          INTEGER PRIMARY KEY AUTOINCREMENT,    ticker      TEXT NOT NULL REFERENCES symbols(ticker) ON DELETE CASCADE,    metric      TEXT NOT NULL,   -- revenue | net_income | eps_diluted | shares_diluted                                 -- | dividends_per_share | assets | liabilities | equity    period      TEXT NOT NULL,   -- 'FY2024' or 'Q3-2024'    fiscal_year INTEGER NOT NULL,    fiscal_qtr  INTEGER,         -- NULL for a full-year figure    period_end  TEXT NOT NULL,   -- YYYY-MM-DD    value       REAL NOT NULL,    unit        TEXT,            -- USD | USD/shares | shares    form        TEXT,            -- 10-K | 10-Q    filed_at    TEXT,            -- YYYY-MM-DD    UNIQUE (ticker, metric, period_end));CREATE INDEX fundamentals_ticker_metric ON fundamentals(ticker, metric, period_end DESC);-- ── filings: SEC filing history (10-K / 10-Q / 8-K and friends) ──CREATE TABLE filings (    id               INTEGER PRIMARY KEY AUTOINCREMENT,    ticker           TEXT NOT NULL REFERENCES symbols(ticker) ON DELETE CASCADE,    accession        TEXT NOT NULL,    form             TEXT NOT NULL,    filed_at         TEXT NOT NULL,                     -- YYYY-MM-DD    period_of_report TEXT,                              -- YYYY-MM-DD    primary_doc      TEXT,    url              TEXT NOT NULL,                     -- full EDGAR filing-index URL    description      TEXT,    UNIQUE (ticker, accession));CREATE INDEX filings_ticker_filed ON filings(ticker, filed_at DESC);-- ── watchlists ──CREATE TABLE watchlists (    id         INTEGER PRIMARY KEY AUTOINCREMENT,    name       TEXT NOT NULL,    slug       TEXT NOT NULL UNIQUE,                    -- URL-safe, e.g. tech-megacaps    position   INTEGER NOT NULL DEFAULT 0,    created_at INTEGER NOT NULL,    updated_at INTEGER NOT NULL);CREATE TABLE watchlist_items (    watchlist_id INTEGER NOT NULL REFERENCES watchlists(id) ON DELETE CASCADE,    ticker       TEXT NOT NULL REFERENCES symbols(ticker) ON DELETE CASCADE,    position     INTEGER NOT NULL DEFAULT 0,    added_at     INTEGER NOT NULL,    PRIMARY KEY (watchlist_id, ticker));CREATE INDEX watchlist_items_ticker ON watchlist_items(ticker);-- ── fetch_log: append-only history of every background fetch. Drives the data-status UI. ──CREATE TABLE fetch_log (    id          INTEGER PRIMARY KEY AUTOINCREMENT,    job         TEXT NOT NULL,    -- seed | history | intraday | fundamentals | filings | prune    provider    TEXT NOT NULL,    -- stooq | yahoo | sec | -    ticker      TEXT,             -- NULL for bulk jobs    status      TEXT NOT NULL,    -- ok | error | skipped    detail      TEXT,    rows        INTEGER,    duration_ms INTEGER,    started_at  INTEGER NOT NULL,    finished_at INTEGER NOT NULL);CREATE INDEX fetch_log_started ON fetch_log(started_at DESC);CREATE INDEX fetch_log_job     ON fetch_log(job, started_at DESC);-- ── data_status: one row per job, current state, for the live status pill ──CREATE TABLE data_status (    job           TEXT PRIMARY KEY, -- seed | history | intraday | fundamentals | filings    state         TEXT NOT NULL,    -- idle | fetching | ok | stale | error    last_ok_at    INTEGER,    last_error    TEXT,    last_error_at INTEGER,    next_run_at   INTEGER,    updated_at    INTEGER NOT NULL);-- ── meta: one-off key-value settings (seed_completed flag, etc.) ──CREATE TABLE meta (    key   TEXT PRIMARY KEY,    value TEXT NOT NULL);
added migrations/0002_endpoint_guard.sql
@@ -0,0 +1,29 @@-- finance migration 0002: per-endpoint request-guard state (Phase 3).---- One row per outbound data endpoint (today only 'stooq'). It is the persistent-- backing store for `EndpointGuard` (src/guard.rs): a reactive circuit breaker-- plus a hard per-hour request budget, so a third-party rate limit can never be-- hit even across restarts or by a future job. All *_at columns are UTC-- epoch-milliseconds, matching the rest of the schema.CREATE TABLE endpoint_guard (    endpoint        TEXT PRIMARY KEY,                       -- logical upstream id, e.g. 'stooq'    -- ── circuit breaker ──    state           TEXT    NOT NULL DEFAULT 'closed',      -- closed | open | half_open    fail_streak     INTEGER NOT NULL DEFAULT 0,             -- consecutive ordinary failures while closed    trip_count      INTEGER NOT NULL DEFAULT 0,             -- consecutive trips; drives the backoff length    opened_at       INTEGER,                                -- when the breaker last opened    retry_at        INTEGER,                                -- earliest time a half-open probe may run    -- ── hard per-hour request budget ──    hour_start      INTEGER,                                -- start of the current rolling budget hour    hour_count      INTEGER NOT NULL DEFAULT 0,             -- requests let through in the current hour    -- ── bookkeeping ──    last_request_at INTEGER,                                -- last request let through (drives pacing)    last_ok_at      INTEGER,    last_error      TEXT,    last_error_at   INTEGER,    updated_at      INTEGER NOT NULL);
added migrations/0003_guard_budget.sql
@@ -0,0 +1,13 @@-- finance migration 0003: record each endpoint guard's per-hour request-- budget in its own row (Phase 6).---- The budget is a property of how each `EndpointGuard` is constructed-- (`EndpointGuard::new` uses 200; the Yahoo guard uses 1000). Persisting it-- lets the data-health page show "requests used / budget" straight from the-- table, with no upstream-specific constant duplicated into the route layer.-- `EndpointGuard::load` keeps this column in step with the guard's own budget.---- The DEFAULT covers the rows that already exist (the guard corrects each one-- to its true budget the next time that endpoint is used).ALTER TABLE endpoint_guard ADD COLUMN hourly_budget INTEGER NOT NULL DEFAULT 200;
added migrations/0004_fundamentals_unique.sql
@@ -0,0 +1,31 @@-- Phase 7: rekey the fundamentals uniqueness constraint.---- 0001 keyed `fundamentals` UNIQUE on (ticker, metric, period_end). That breaks-- once both annual and quarterly figures are stored: a full-year revenue and a-- fourth-quarter revenue can share the same period_end (the fiscal year end),-- so one would overwrite the other. The fiscal `period` label ('FY2024' vs-- 'Q4-2024') is the value that is genuinely unique per figure, so the key moves-- there.---- SQLite cannot alter a table constraint in place, and `fundamentals` is still-- empty (Phase 7 introduces its first writer), so the table is simply recreated.DROP TABLE IF EXISTS fundamentals;CREATE TABLE fundamentals (    id          INTEGER PRIMARY KEY AUTOINCREMENT,    ticker      TEXT NOT NULL REFERENCES symbols(ticker) ON DELETE CASCADE,    metric      TEXT NOT NULL,   -- revenue | net_income | eps_diluted | shares_diluted                                 -- | dividends_per_share | assets | liabilities | equity                                 -- | assets_current | liabilities_current    period      TEXT NOT NULL,   -- 'FY2024' or 'Q3-2024'    fiscal_year INTEGER NOT NULL,    fiscal_qtr  INTEGER,         -- NULL for a full-year figure    period_end  TEXT NOT NULL,   -- YYYY-MM-DD    value       REAL NOT NULL,    unit        TEXT,            -- USD | USD/shares | shares    form        TEXT,            -- 10-K | 10-Q    filed_at    TEXT,            -- YYYY-MM-DD    UNIQUE (ticker, metric, period));CREATE INDEX fundamentals_ticker_metric ON fundamentals(ticker, metric, period_end DESC);
added samplefiles/Caddyfile.sample
@@ -0,0 +1,35 @@# Caddyfile for finance## I like to use Caddy Server for simple reverse proxies since it has built in# automatic HTTPS support.{  servers {    protocol {      experimental_http3    }  }}(common) {  header /static/* {    Cache-Control "public, max-age=315360000"  }  header /* {    Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"    X-XSS-Protection "1; mode=block"    X-Frame-Options DENY    X-Content-Type-Options nosniff    -Server    -X-Powered-By  }  encode zstd gzip}finance.example.com {  reverse_proxy localhost:8000  import common}
added samplefiles/env.sample
@@ -0,0 +1,25 @@# Stooq apikey for the deep daily-history endpoint. Stooq gates this endpoint:# get a key once at https://stooq.com/q/d/?s=aapl.us&get_apikey (solve the# captcha, copy the apikey from the download link). Without it, history sync# fails. Keep it here, not in source: this file's real counterpart (.env) is# gitignored.STOOQ_APIKEY=# Used in templates for absolute URLs (sitemap, og tags). No trailing slash.BASE_URL=https://finance.example.com# Browser-like User-Agent sent on every outbound data request. The upstream# sources (Stooq, Yahoo, SEC) are public endpoints; a realistic Chrome string# avoids being singled out by naive bot filters. Override if you like.# FINANCE_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36# SEC asks API consumers to identify themselves with a contact address. It is# appended to the User-Agent on requests to sec.gov / data.sec.gov only.SEC_CONTACT_EMAIL=you@example.com# Live-quote source. Currently only "yahoo" ships; the provider trait makes# others droppable in. Falls back to the default on an unknown value.# FINANCE_QUOTE_PROVIDER=yahoo# Optional title shown in the header and <title>.# FINANCE_TITLE=Finance
added samplefiles/post-receive.sample
@@ -0,0 +1,19 @@#!/bin/sh## For easy server deploys add this post-receive hook to your server in a bare# repo somewhere like /srv/git/finance/hooks/post-receive and make a git# clone in /srv/docker/finance. This script runs on push to rebuild and# redeploy the docker container.while read oldrev newrev ref; do  if [ "$ref" = "refs/heads/master" ]; then    unset GIT_DIR    START_TIME=`date +%s`    cd /srv/docker/finance    git pull    docker-compose up --build --detach    docker system prune --force    END_TIME=`date +%s`    echo Total build time: `expr $END_TIME - $START_TIME`s  fidone
added src/app.rs
@@ -0,0 +1,122 @@use axum::{http::HeaderValue, middleware as axum_middleware, Router};use minijinja::Environment;use sqlx::SqlitePool;use std::path::PathBuf;use std::sync::Arc;use tower_http::services::ServeDir;use tower_http::set_header::SetResponseHeaderLayer;use crate::routes;use crate::{db, middleware, templates};/// A current desktop Chrome string. Outbound data requests carry this so the/// public upstreams (Stooq, Yahoo, SEC) see an ordinary-looking browser./// Override with FINANCE_USER_AGENT.const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \    AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";#[derive(Clone)]pub struct AppState {    pub env: Arc<Environment<'static>>,    pub pool: SqlitePool,    pub config: Arc<Config>,    /// Live-data pub/sub hub: shared by the `/stream` route and the scheduler.    pub hub: Arc<crate::stream::Hub>,}#[derive(Debug, Clone)]pub struct Config {    /// Project root: where `templates/` and `dist/` live.    pub root: PathBuf,    /// Where `db.sqlite3` lives.    pub data_dir: PathBuf,    /// Absolute origin for sitemap / og tags. No trailing slash. May be empty.    pub base_url: String,    pub site_title: String,    /// User-Agent sent on every outbound data request.    pub user_agent: String,    /// Appended to the User-Agent on sec.gov requests so SEC can identify us.    pub sec_contact_email: String,    /// Which `QuoteProvider` impl to use for live data.    pub quote_provider: String,    /// Stooq apikey for the daily-history endpoint. Empty disables Stooq.    pub stooq_apikey: String,}impl AppState {    pub async fn from_env() -> anyhow::Result<Self> {        let root: PathBuf = std::env::var("FINANCE_ROOT")            .map(PathBuf::from)            .unwrap_or_else(|_| PathBuf::from("."));        let data_dir = std::env::var("FINANCE_DATA_DIR")            .map(PathBuf::from)            .unwrap_or_else(|_| root.join("data"));        std::fs::create_dir_all(&data_dir)?;        let base_url = std::env::var("BASE_URL").unwrap_or_default();        let site_title =            std::env::var("FINANCE_TITLE").unwrap_or_else(|_| "Finance".to_string());        let user_agent = std::env::var("FINANCE_USER_AGENT")            .ok()            .filter(|s| !s.is_empty())            .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string());        let sec_contact_email = std::env::var("SEC_CONTACT_EMAIL").unwrap_or_default();        let quote_provider = std::env::var("FINANCE_QUOTE_PROVIDER")            .ok()            .filter(|s| !s.is_empty())            .unwrap_or_else(|| "yahoo".to_string());        let stooq_apikey = std::env::var("STOOQ_APIKEY").unwrap_or_default();        let pool = db::init(&data_dir).await?;        let templates_dir = root.join("templates");        let manifest_path = root.join("dist/.vite/manifest.json");        let env = Arc::new(templates::build_env(&templates_dir, &manifest_path));        let config = Arc::new(Config {            root,            data_dir,            base_url,            site_title,            user_agent,            sec_contact_email,            quote_provider,            stooq_apikey,        });        let hub = Arc::new(crate::stream::Hub::new());        Ok(Self {            env,            pool,            config,            hub,        })    }}pub fn router(state: AppState) -> Router {    let dist_dir = state.config.root.join("dist");    let static_cache = SetResponseHeaderLayer::if_not_present(        axum::http::header::CACHE_CONTROL,        HeaderValue::from_static("public, max-age=31536000"),    );    Router::new()        .merge(routes::home::router())        .merge(routes::symbols::router())        .merge(routes::search::router())        .merge(routes::stream::router())        .merge(routes::health::router())        .merge(routes::seo::router())        .nest_service(            "/static",            tower::ServiceBuilder::new()                .layer(static_cache)                .service(ServeDir::new(&dist_dir)),        )        .fallback(middleware::not_found)        .layer(axum_middleware::from_fn(middleware::log_requests))        .with_state(state)}
added src/compute.rs
@@ -0,0 +1,548 @@//! Derived market figures. Ratios live here, not in the database, so they//! always reflect the latest price.use serde::Serialize;/// Placeholder shown for a figure that cannot be computed. Matches the/// `templates.rs` empty-value glyph (a middle dot).const DASH: &str = "\u{00b7}";/// An absolute and percentage change between two prices.#[derive(Debug, Clone, Copy)]pub struct Change {    pub abs: f64,    pub pct: f64,}/// Change of `last` relative to `prev` (a prior close).pub fn change(last: f64, prev: f64) -> Change {    let abs = last - prev;    let pct = if prev != 0.0 { abs / prev * 100.0 } else { 0.0 };    Change { abs, pct }}/// Position of `value` along the `[lo, hi]` range, as a 0..100 percent for/// placing a marker on a track. Clamped to the ends; a zero-width range maps/// to the midpoint. Rounded to 2 dp so it inlines cleanly into a `style`.pub fn pos(value: f64, lo: f64, hi: f64) -> f64 {    if hi <= lo {        return 50.0;    }    let p = (value - lo) / (hi - lo) * 100.0;    (p.clamp(0.0, 100.0) * 100.0).round() / 100.0}// ──────────────────────────── dashboard sparkline ──────────────────────────//// Phase 11. Geometry for the tiny intraday line on each home-dashboard card.// Drawn server-side from the latest session's bar closes; the stream client// then nudges the trailing point as live quotes arrive./// Fixed viewBox the sparklines are drawn in: 100 wide, 36 tall. The line is/// confined to y ∈ [`SPARK_TOP`, `SPARK_BOTTOM`] so a 1-2px stroke never clips/// at the edges. The stream client's live-tip code mirrors these numbers.const SPARK_W: f64 = 100.0;const SPARK_TOP: f64 = 3.0;const SPARK_BOTTOM: f64 = 33.0;/// Geometry for one dashboard sparkline.#[derive(Debug, Clone, Serialize)]pub struct Sparkline {    /// Polyline points (`"x,y x,y …"`, oldest first) for the line itself.    pub line: String,    /// The line closed down to the baseline at both ends, for the area fill.    pub area: String,    /// Value range the y-axis maps. The stream client places a live price on    /// this same scale to move the trailing point.    pub lo: f64,    pub hi: f64,    /// y of the previous close, drawn as a faint reference rule; `None` when    /// no prior close is known.    pub baseline: Option<f64>,}/// Build a [`Sparkline`] from a session's intraday closes (oldest first) and,/// when known, the prior close. `None` for an empty series. The value range is/// widened to include `prev_close` so the reference rule always lands in box.pub fn sparkline(closes: &[f64], prev_close: Option<f64>) -> Option<Sparkline> {    if closes.is_empty() {        return None;    }    let mut lo = closes.iter().copied().fold(f64::INFINITY, f64::min);    let mut hi = closes.iter().copied().fold(f64::NEG_INFINITY, f64::max);    if let Some(p) = prev_close {        lo = lo.min(p);        hi = hi.max(p);    }    // Map a value to a y coordinate: a higher value sits closer to the top    // (smaller y). A flat series (hi == lo) pins to the vertical midpoint.    let y = |v: f64| -> f64 {        let t = if hi > lo { (v - lo) / (hi - lo) } else { 0.5 };        let yy = SPARK_BOTTOM - t * (SPARK_BOTTOM - SPARK_TOP);        (yy * 100.0).round() / 100.0    };    // Evenly space the points across the full width; a lone point centres.    let x = |i: usize| -> f64 {        let xx = if closes.len() > 1 {            i as f64 / (closes.len() - 1) as f64 * SPARK_W        } else {            SPARK_W / 2.0        };        (xx * 100.0).round() / 100.0    };    let mut line = String::new();    for (i, &c) in closes.iter().enumerate() {        if i > 0 {            line.push(' ');        }        line.push_str(&format!("{},{}", x(i), y(c)));    }    let area = format!(        "{},{} {} {},{}",        x(0),        SPARK_BOTTOM,        line,        x(closes.len() - 1),        SPARK_BOTTOM,    );    Some(Sparkline {        line,        area,        lo,        hi,        baseline: prev_close.map(y),    })}// ─────────────────────────── computed ratios ───────────────────────────────//// Phase 7. Each ratio is computed from the latest full fiscal year's SEC// figures plus the latest price, graded good / ok / bad against sensible// thresholds, and paired with plain-English text so a non-expert can read it.// Nothing here is stored: a fresh price re-grades the price-based ratios./// A computed fundamental ratio's quality, for the symbol page's semantic/// green / amber / red.#[derive(Debug, Clone, Copy, Serialize)]#[serde(rename_all = "lowercase")]pub enum Grade {    Good,    Ok,    Bad,    /// Inputs missing or the ratio is not meaningful (e.g. negative equity):    /// shown neutrally, not coloured.    Unknown,}impl Grade {    /// One-word verdict for the ratio card's badge.    pub fn verdict(self) -> &'static str {        match self {            Grade::Good => "Strong",            Grade::Ok => "Fair",            Grade::Bad => "Weak",            Grade::Unknown => "No data",        }    }}/// One computed ratio, ready for the symbol page: a graded value plus/// plain-English text so a non-expert can tell good from concerning.#[derive(Debug, Clone, Serialize)]pub struct Ratio {    /// Stable identifier, also a CSS hook.    pub key: &'static str,    pub label: &'static str,    /// Formatted value, e.g. `28.4x`, `1.6%`; a middle dot when unknown.    pub display: String,    pub grade: Grade,    /// One-word badge text derived from `grade`.    pub verdict: &'static str,    /// Plain-English reading of this company's particular value.    pub reading: String,    /// What the metric means and how to read it, the same for every company.    pub explain: &'static str,}/// The figures a full set of ratios is computed from: the latest full fiscal/// year's values, the prior year's (for the growth ratios), and a price. Every/// field is optional, since a company may simply not report a given concept.#[derive(Debug, Default, Clone)]pub struct RatioInputs {    pub price: Option<f64>,    pub eps_diluted: Option<f64>,    pub dividends_per_share: Option<f64>,    pub revenue: Option<f64>,    pub net_income: Option<f64>,    pub assets: Option<f64>,    pub liabilities: Option<f64>,    pub equity: Option<f64>,    pub assets_current: Option<f64>,    pub liabilities_current: Option<f64>,    pub prev_revenue: Option<f64>,    pub prev_net_income: Option<f64>,}/// Assemble a `Ratio`, deriving the badge verdict from the grade.fn mk(    key: &'static str,    label: &'static str,    explain: &'static str,    display: String,    grade: Grade,    reading: String,) -> Ratio {    Ratio {        key,        label,        display,        grade,        verdict: grade.verdict(),        reading,        explain,    }}/// An `Unknown` ratio: inputs missing or the ratio not meaningful.fn unknown(    key: &'static str,    label: &'static str,    explain: &'static str,    reading: &str,) -> Ratio {    mk(key, label, explain, DASH.to_string(), Grade::Unknown, reading.to_string())}/// The nine ratios shown on a stock's symbol page, in display order.pub fn compute_ratios(i: &RatioInputs) -> Vec<Ratio> {    vec![        pe(i.price, i.eps_diluted),        dividend_yield(i.price, i.dividends_per_share),        profit_margin(i.net_income, i.revenue),        return_on_equity(i.net_income, i.equity),        return_on_assets(i.net_income, i.assets),        debt_to_equity(i.liabilities, i.equity),        current_ratio(i.assets_current, i.liabilities_current),        revenue_growth(i.revenue, i.prev_revenue),        earnings_growth(i.net_income, i.prev_net_income),    ]}fn pe(price: Option<f64>, eps: Option<f64>) -> Ratio {    const KEY: &str = "pe";    const LABEL: &str = "P/E ratio";    const EXPLAIN: &str = "Share price divided by earnings per share: what you \        pay for each $1 of yearly profit. Roughly 15 to 25 is typical, above 40 \        is richly priced, and negative means the company is losing money.";    let (Some(price), Some(eps)) = (price, eps) else {        return unknown(KEY, LABEL, EXPLAIN, "Not enough data to compute a price-to-earnings ratio.");    };    if eps <= 0.0 {        return unknown(            KEY, LABEL, EXPLAIN,            "Earnings per share were negative, so a P/E cannot be formed; the company was unprofitable over the period.",        );    }    let v = price / eps;    // Below 10x the stock is cheap (a bargain, or a warning); 10-25x is the    // healthy band; 25-40x is paying up for growth; above 40x is steep.    let (grade, reading) = if v < 10.0 {        (Grade::Ok, format!("At {v:.0}x, the stock is priced cheaply against its profits: sometimes a bargain, sometimes a sign of trouble ahead."))    } else if v <= 25.0 {        (Grade::Good, format!("At {v:.0}x, the price is a reasonable multiple of the company's annual profit."))    } else if v < 40.0 {        (Grade::Ok, format!("At {v:.0}x, investors are paying up; a fair amount of future growth is already in the price."))    } else {        (Grade::Bad, format!("At {v:.0}x, the price is steep relative to profit; the stock leans heavily on growth that has yet to arrive."))    };    mk(KEY, LABEL, EXPLAIN, format!("{v:.1}x"), grade, reading)}fn dividend_yield(price: Option<f64>, dps: Option<f64>) -> Ratio {    const KEY: &str = "div_yield";    const LABEL: &str = "Dividend yield";    const EXPLAIN: &str = "The yearly dividend as a percent of the share price: \        the cash income each share pays out. Around 2 to 6% is healthy; above \        roughly 8% often signals the payout may be cut.";    let Some(price) = price.filter(|p| *p > 0.0) else {        return unknown(KEY, LABEL, EXPLAIN, "Not enough data to compute a dividend yield.");    };    // A company that pays no dividend simply never reports the concept; treat    // a missing figure as a genuine zero.    let dps = dps.unwrap_or(0.0);    let v = dps / price * 100.0;    let (grade, reading) = if v <= 0.0 {        (Grade::Ok, "This company pays no dividend, common for firms reinvesting their profits back into growth.".to_string())    } else if v < 2.0 {        (Grade::Ok, format!("A {v:.1}% yield is modest: a small income on top of whatever the share price does."))    } else if v <= 6.0 {        (Grade::Good, format!("A {v:.1}% yield is a healthy, generally sustainable level of cash income."))    } else if v <= 10.0 {        (Grade::Ok, format!("A {v:.1}% yield is high; it is worth checking the payout is covered by profit."))    } else {        (Grade::Bad, format!("A {v:.1}% yield is unusually high, often a sign the market expects the dividend to be cut."))    };    mk(KEY, LABEL, EXPLAIN, format!("{v:.2}%"), grade, reading)}fn profit_margin(net_income: Option<f64>, revenue: Option<f64>) -> Ratio {    const KEY: &str = "profit_margin";    const LABEL: &str = "Profit margin";    const EXPLAIN: &str = "The share of revenue left as profit once every cost \        is paid. Above 15% is strong; below 5% leaves little cushion against a \        bad year.";    let (Some(ni), Some(rev)) = (net_income, revenue) else {        return unknown(KEY, LABEL, EXPLAIN, "Not enough data to compute a profit margin.");    };    if rev <= 0.0 {        return unknown(KEY, LABEL, EXPLAIN, "No revenue was reported, so a margin cannot be computed.");    }    let v = ni / rev * 100.0;    let (grade, reading) = if v < 5.0 {        (Grade::Bad, format!("A {v:.1}% margin is thin; little of each revenue dollar survives as profit."))    } else if v <= 15.0 {        (Grade::Ok, format!("A {v:.1}% margin is solid, in the ordinary range for a profitable company."))    } else {        (Grade::Good, format!("A {v:.1}% margin is strong; the company keeps a healthy slice of every revenue dollar."))    };    mk(KEY, LABEL, EXPLAIN, format!("{v:.1}%"), grade, reading)}fn return_on_equity(net_income: Option<f64>, equity: Option<f64>) -> Ratio {    const KEY: &str = "roe";    const LABEL: &str = "Return on equity";    const EXPLAIN: &str = "Profit earned on each dollar of shareholder equity: \        how well the company compounds its owners' capital. Above 15% is strong.";    let (Some(ni), Some(eq)) = (net_income, equity) else {        return unknown(KEY, LABEL, EXPLAIN, "Not enough data to compute return on equity.");    };    if eq <= 0.0 {        return unknown(KEY, LABEL, EXPLAIN, "Shareholder equity is negative, so return on equity is not meaningful.");    }    let v = ni / eq * 100.0;    let (grade, reading) = if v < 5.0 {        (Grade::Bad, format!("A {v:.1}% return on equity is weak; owners' capital is barely being put to work."))    } else if v <= 15.0 {        (Grade::Ok, format!("A {v:.1}% return on equity is respectable, in the normal range."))    } else {        (Grade::Good, format!("A {v:.1}% return on equity is strong; the company compounds owners' capital well."))    };    mk(KEY, LABEL, EXPLAIN, format!("{v:.1}%"), grade, reading)}fn return_on_assets(net_income: Option<f64>, assets: Option<f64>) -> Ratio {    const KEY: &str = "roa";    const LABEL: &str = "Return on assets";    const EXPLAIN: &str = "Profit earned on each dollar of assets: how \        efficiently the whole asset base is used. Above 8% is strong.";    let (Some(ni), Some(assets)) = (net_income, assets) else {        return unknown(KEY, LABEL, EXPLAIN, "Not enough data to compute return on assets.");    };    if assets <= 0.0 {        return unknown(KEY, LABEL, EXPLAIN, "No asset total was reported, so return on assets cannot be computed.");    }    let v = ni / assets * 100.0;    let (grade, reading) = if v < 2.0 {        (Grade::Bad, format!("A {v:.1}% return on assets is low; the asset base is generating little profit."))    } else if v <= 8.0 {        (Grade::Ok, format!("A {v:.1}% return on assets is reasonable for a company of this kind."))    } else {        (Grade::Good, format!("A {v:.1}% return on assets is strong; the company squeezes good profit from its assets."))    };    mk(KEY, LABEL, EXPLAIN, format!("{v:.1}%"), grade, reading)}fn debt_to_equity(liabilities: Option<f64>, equity: Option<f64>) -> Ratio {    const KEY: &str = "debt_equity";    const LABEL: &str = "Debt-to-equity";    const EXPLAIN: &str = "Total liabilities divided by shareholder equity: how \        heavily the company leans on borrowing. Below 1 is conservative; above \        2 is highly leveraged.";    let (Some(liab), Some(eq)) = (liabilities, equity) else {        return unknown(KEY, LABEL, EXPLAIN, "Not enough data to compute debt-to-equity.");    };    if eq <= 0.0 {        return mk(            KEY, LABEL, EXPLAIN, DASH.to_string(), Grade::Bad,            "Shareholder equity is negative; liabilities exceed everything the company owns.".to_string(),        );    }    let v = liab / eq;    let (grade, reading) = if v < 1.0 {        (Grade::Good, format!("At {v:.2}, the company carries less in liabilities than in equity, a conservative balance sheet."))    } else if v <= 2.0 {        (Grade::Ok, format!("At {v:.2}, the company carries a moderate, manageable amount of debt."))    } else {        (Grade::Bad, format!("At {v:.2}, the company leans heavily on borrowing, which adds risk if results weaken."))    };    mk(KEY, LABEL, EXPLAIN, format!("{v:.2}"), grade, reading)}fn current_ratio(assets_current: Option<f64>, liabilities_current: Option<f64>) -> Ratio {    const KEY: &str = "current_ratio";    const LABEL: &str = "Current ratio";    const EXPLAIN: &str = "Current assets divided by current liabilities: \        whether short-term resources cover short-term bills. Above 1.5 is \        comfortable; below 1 is tight.";    let (Some(ca), Some(cl)) = (assets_current, liabilities_current) else {        return unknown(KEY, LABEL, EXPLAIN, "This company does not report a current-assets breakdown, so the ratio cannot be computed.");    };    if cl <= 0.0 {        return unknown(KEY, LABEL, EXPLAIN, "No current liabilities were reported, so the ratio cannot be computed.");    }    let v = ca / cl;    let (grade, reading) = if v < 1.0 {        (Grade::Bad, format!("At {v:.2}, short-term assets fall short of short-term bills; liquidity is tight."))    } else if v < 1.5 {        (Grade::Ok, format!("At {v:.2}, short-term assets cover short-term bills with a little room to spare."))    } else {        (Grade::Good, format!("At {v:.2}, short-term assets comfortably cover short-term bills."))    };    mk(KEY, LABEL, EXPLAIN, format!("{v:.2}"), grade, reading)}fn revenue_growth(revenue: Option<f64>, prev_revenue: Option<f64>) -> Ratio {    const KEY: &str = "revenue_growth";    const LABEL: &str = "Revenue growth";    const EXPLAIN: &str = "Change in annual revenue from the prior fiscal year: \        whether the top line is expanding. Above 10% is strong growth; below 0 \        means revenue is shrinking.";    let (Some(rev), Some(prev)) = (revenue, prev_revenue) else {        return unknown(KEY, LABEL, EXPLAIN, "Two fiscal years of revenue are needed to compute growth.");    };    if prev <= 0.0 {        return unknown(KEY, LABEL, EXPLAIN, "Prior-year revenue was not positive, so a growth rate is not meaningful.");    }    let v = (rev - prev) / prev * 100.0;    let (grade, reading) = if v < 0.0 {        (Grade::Bad, format!("Revenue fell {:.1}% from the prior year; the top line is contracting.", v.abs()))    } else if v <= 10.0 {        (Grade::Ok, format!("Revenue grew {v:.1}% from the prior year: steady, modest expansion."))    } else {        (Grade::Good, format!("Revenue grew {v:.1}% from the prior year: strong top-line expansion."))    };    mk(KEY, LABEL, EXPLAIN, format!("{v:+.1}%"), grade, reading)}// ─────────────────────────── chart indicators ──────────────────────────────//// Phase 8. Overlay/indicator series for the price chart: simple and// exponential moving averages plus a Relative Strength Index. Each takes a// slice of closing prices (oldest first) and returns one `Option<f64>` per// input bar — `None` until enough history has accumulated for the figure to// be meaningful — so a caller can align the result to its bar list by index// and drop the leading `None`s. The maths lives here, not in SQL or the// browser, so it stays in one place the rest of the app already trusts./// Simple moving average over `period` bars: `out[i]` is the mean of/// `closes[i+1-period ..= i]`, and `None` for the first `period-1` bars./// A running sum keeps it one pass regardless of `period`.pub fn sma(closes: &[f64], period: usize) -> Vec<Option<f64>> {    if period == 0 {        return vec![None; closes.len()];    }    let mut out = Vec::with_capacity(closes.len());    let mut sum = 0.0;    for i in 0..closes.len() {        sum += closes[i];        if i >= period {            sum -= closes[i - period];        }        out.push((i + 1 >= period).then(|| sum / period as f64));    }    out}/// Exponential moving average over `period` bars. Seeded at index `period-1`/// with the simple average of the first window, then each step weights the/// newest close by `2/(period+1)`. `None` before the seed bar.pub fn ema(closes: &[f64], period: usize) -> Vec<Option<f64>> {    let mut out = vec![None; closes.len()];    if period == 0 || closes.len() < period {        return out;    }    let k = 2.0 / (period as f64 + 1.0);    let mut prev = closes[..period].iter().sum::<f64>() / period as f64;    out[period - 1] = Some(prev);    for i in period..closes.len() {        prev = closes[i] * k + prev * (1.0 - k);        out[i] = Some(prev);    }    out}/// Wilder's Relative Strength Index over `period` bars (classically 14): a/// 0..100 momentum reading, `None` until `period` price changes have/// accumulated. Above ~70 is conventionally "overbought", below ~30/// "oversold". The seed averages the first `period` gains and losses; every/// later bar applies Wilder's smoothing.pub fn rsi(closes: &[f64], period: usize) -> Vec<Option<f64>> {    let mut out = vec![None; closes.len()];    if period == 0 || closes.len() <= period {        return out;    }    let (mut gain, mut loss) = (0.0, 0.0);    for i in 1..=period {        let ch = closes[i] - closes[i - 1];        if ch >= 0.0 {            gain += ch;        } else {            loss -= ch;        }    }    let mut avg_gain = gain / period as f64;    let mut avg_loss = loss / period as f64;    out[period] = Some(rsi_from(avg_gain, avg_loss));    for i in period + 1..closes.len() {        let ch = closes[i] - closes[i - 1];        let (g, l) = if ch >= 0.0 { (ch, 0.0) } else { (0.0, -ch) };        avg_gain = (avg_gain * (period as f64 - 1.0) + g) / period as f64;        avg_loss = (avg_loss * (period as f64 - 1.0) + l) / period as f64;        out[i] = Some(rsi_from(avg_gain, avg_loss));    }    out}/// One RSI reading from a smoothed average gain and loss; an all-gains/// window (no losses) reads a flat 100.fn rsi_from(avg_gain: f64, avg_loss: f64) -> f64 {    if avg_loss == 0.0 {        return 100.0;    }    let rs = avg_gain / avg_loss;    100.0 - 100.0 / (1.0 + rs)}fn earnings_growth(net_income: Option<f64>, prev_net_income: Option<f64>) -> Ratio {    const KEY: &str = "earnings_growth";    const LABEL: &str = "Earnings growth";    const EXPLAIN: &str = "Change in annual net income from the prior fiscal \        year: whether profit is expanding. Above 10% is strong; below 0 means \        profit is falling.";    let (Some(ni), Some(prev)) = (net_income, prev_net_income) else {        return unknown(KEY, LABEL, EXPLAIN, "Two fiscal years of net income are needed to compute growth.");    };    if prev <= 0.0 {        // A growth percentage off a loss-making base is meaningless; describe        // the turn instead of computing a rate.        let reading = if ni > 0.0 {            "The company returned to profit after a loss-making prior year."        } else {            "The company was unprofitable in both years, so an earnings growth rate is not meaningful."        };        return unknown(KEY, LABEL, EXPLAIN, reading);    }    let v = (ni - prev) / prev * 100.0;    let (grade, reading) = if ni < 0.0 {        (Grade::Bad, "Profit swung to a loss from a profitable prior year.".to_string())    } else if v < 0.0 {        (Grade::Bad, format!("Net income fell {:.1}% from the prior year; profit is shrinking.", v.abs()))    } else if v <= 10.0 {        (Grade::Ok, format!("Net income grew {v:.1}% from the prior year: steady profit expansion."))    } else {        (Grade::Good, format!("Net income grew {v:.1}% from the prior year: strong profit expansion."))    };    mk(KEY, LABEL, EXPLAIN, format!("{v:+.1}%"), grade, reading)}
added src/db.rs
@@ -0,0 +1,58 @@use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};use sqlx::SqlitePool;use std::path::Path;use std::str::FromStr;use std::time::Duration;/// Open (creating if absent) the SQLite database and run migrations./// WAL + synchronous=Normal matches the sibling apps: durable enough for a/// single-operator service, fast enough for the scheduler's frequent upserts.pub async fn init(data_dir: &Path) -> anyhow::Result<SqlitePool> {    let db_path = data_dir.join("db.sqlite3");    let url = format!("sqlite://{}", db_path.display());    if !db_path.exists() {        std::fs::File::create(&db_path)?;    }    let opts = SqliteConnectOptions::from_str(&url)?        .create_if_missing(true)        .journal_mode(SqliteJournalMode::Wal)        .synchronous(SqliteSynchronous::Normal)        .busy_timeout(Duration::from_secs(5))        .foreign_keys(true);    let pool = SqlitePoolOptions::new()        .max_connections(8)        .connect_with(opts)        .await?;    sqlx::migrate!("./migrations").run(&pool).await?;    Ok(pool)}/// Current time as UTC epoch-milliseconds. Every `*_at` column uses this.pub fn now_ms() -> i64 {    chrono::Utc::now().timestamp_millis()}/// Upsert a one-off key-value setting into the `meta` table.pub async fn set_meta(pool: &SqlitePool, key: &str, value: &str) -> sqlx::Result<()> {    sqlx::query(        "INSERT INTO meta (key, value) VALUES (?, ?) \         ON CONFLICT(key) DO UPDATE SET value = excluded.value",    )    .bind(key)    .bind(value)    .execute(pool)    .await?;    Ok(())}/// Read a `meta` setting, if present.pub async fn get_meta(pool: &SqlitePool, key: &str) -> sqlx::Result<Option<String>> {    sqlx::query_scalar("SELECT value FROM meta WHERE key = ?")        .bind(key)        .fetch_optional(pool)        .await}
added src/guard.rs
@@ -0,0 +1,358 @@//! Persistent, per-endpoint request guard.//!//! Every outbound call to a third-party data endpoint (today only Stooq) passes//! through an `EndpointGuard`. The guard is this project's hard guarantee that a//! third-party rate limit can never be hit: not by a burst, not by a buggy//! loop, and not across process restarts.//!//! It combines three mechanisms, all backed by the `endpoint_guard` table so//! they survive restarts and are shared by every job and every process (the//! server and the `finance seed` subcommand both write the same row)://!//!  - **A reactive circuit breaker.** A request that returns an explicit//!    rate-limit signal (HTTP 429/503) opens the breaker immediately; so does a//!    streak of ordinary failures. While open, every request is refused. Each//!    consecutive trip backs off longer (30m, 1h, 2h, 4h ... capped at 24h).//!    Once the backoff elapses the breaker goes *half-open* and lets exactly//!    one probe through: it closes on success, re-opens (longer) on failure.//!//!  - **A hard per-hour request budget.** At most `HOURLY_BUDGET` requests are//!    let through per rolling clock hour. When the budget is spent, jobs are//!    refused until the hour rolls. This caps a runaway loop even when the//!    upstream never returns an error.//!//!  - **Pacing.** Consecutive requests are spaced at least `MIN_GAP` apart;//!    `acquire` sleeps as needed before granting, so callers no longer pace//!    themselves.//!//! For a Python reader: picture one row of `endpoint_guard` as a small state//! machine persisted to disk. `acquire()` asks "may I send now?" (and blocks//! for pacing); `record_success` / `record_failure` feed the outcome back in.//!//! Usage in a bulk loop://! ```ignore//! let guard = EndpointGuard::new(pool.clone(), "stooq");//! for ticker in tickers {//!     match guard.acquire().await? {//!         Permit::Granted => {}//!         Permit::Denied(why) => break, // breaker open or budget spent: stop the run//!     }//!     match provider.daily(ticker, None).await {//!         Ok(bars)  => guard.record_success().await?,//!         Err(err)  => guard.record_failure(&err).await?,//!     }//! }//! ```use std::time::Duration;use sqlx::SqlitePool;use crate::db::now_ms;use crate::providers::RateLimited;/// Minimum spacing between two requests to the same endpoint. Matches the/// anti-spam policy in PLAN.md (>= 1.5s per request). `acquire` enforces it.const MIN_GAP: Duration = Duration::from_millis(1500);/// Default per-hour request ceiling, used by `EndpointGuard::new`. A full/// universe refresh is ~144 calls, so 200 leaves comfortable headroom for/// Stooq's daily history work while still capping a runaway loop. Endpoints/// with a busier cadence (Yahoo intraday quotes) set their own via/// `with_budget`.const HOURLY_BUDGET: i64 = 200;/// Consecutive ordinary failures that trip the breaker while it is closed. An/// explicit rate-limit signal (429/503) trips it immediately, regardless.const FAIL_THRESHOLD: i64 = 4;/// A half-open probe that records no result within this long is treated as/// abandoned (its process likely crashed mid-probe), so a fresh probe is let/// through rather than wedging the breaker half-open forever.const STALE_PROBE_SECS: i64 = 10 * 60;const HOUR_MS: i64 = 3600 * 1000;/// The guard's verdict for one request attempt.pub enum Permit {    /// Cleared to send. Pacing has already been applied (this call slept as    /// needed) and the request has been counted against the hourly budget.    Granted,    /// The request must not be sent: the circuit breaker is open or the hourly    /// budget is spent. The caller should stop its run. The string explains    /// why and is suitable for a log line or a `fetch_log` detail.    Denied(String),}/// A persistent guard over one outbound data endpoint. Cheap to construct/// (`SqlitePool` is an `Arc` internally); all real state lives in the/// `endpoint_guard` row, so separate instances for the same endpoint stay/// consistent.pub struct EndpointGuard {    pool: SqlitePool,    endpoint: String,    /// This endpoint's hard per-hour request ceiling.    hourly_budget: i64,}/// The subset of an `endpoint_guard` row the guard logic reads back. The table/// carries more columns (`opened_at`, `last_ok_at`, `last_error`,/// `hourly_budget`, ...) for the data-health page; they are written here but/// not read here.#[derive(sqlx::FromRow)]struct GuardRow {    state: String,    fail_streak: i64,    trip_count: i64,    retry_at: Option<i64>,    hour_start: Option<i64>,    hour_count: i64,    last_request_at: Option<i64>,    updated_at: i64,}impl EndpointGuard {    /// A guard with the default per-hour budget (`HOURLY_BUDGET`).    pub fn new(pool: SqlitePool, endpoint: &str) -> Self {        Self::with_budget(pool, endpoint, HOURLY_BUDGET)    }    /// A guard with an explicit per-hour request budget. Endpoints polled more    /// often than Stooq's daily refresh (e.g. Yahoo intraday quotes) set their    /// own ceiling here.    pub fn with_budget(pool: SqlitePool, endpoint: &str, hourly_budget: i64) -> Self {        Self {            pool,            endpoint: endpoint.to_string(),            hourly_budget,        }    }    /// Ensure this endpoint's `endpoint_guard` row exists and its persisted    /// `hourly_budget` matches this guard. Used at startup to register the    /// known endpoints, so the data-health page shows each one with its right    /// budget from boot rather than only after that endpoint's first request.    pub async fn ensure_registered(&self) -> anyhow::Result<()> {        self.load(now_ms()).await?;        Ok(())    }    /// Ask permission to send one request.    ///    /// On `Permit::Granted` the call has already slept for pacing and counted    /// the request against the hourly budget; the caller should send the    /// request straight away. On `Permit::Denied` the caller must not send and    /// should stop its run (the breaker is open or the budget is spent).    pub async fn acquire(&self) -> anyhow::Result<Permit> {        let now = now_ms();        let g = self.load(now).await?;        // 1. Hard per-hour budget. Checked first: it holds even when nothing        //    has failed, so it is the backstop against a runaway loop.        if g.hour_count >= self.hourly_budget {            let resets_in = human_secs((g.hour_start.unwrap_or(now) + HOUR_MS - now) / 1000);            return Ok(Permit::Denied(format!(                "{} hourly budget spent ({}/{}), resets in {resets_in}",                self.endpoint, g.hour_count, self.hourly_budget            )));        }        // 2. Circuit breaker. `probing` means this acquire is taking the single        //    half-open probe slot.        let probing = match g.state.as_str() {            "open" => {                let retry_at = g.retry_at.unwrap_or(now);                if now < retry_at {                    return Ok(Permit::Denied(format!(                        "{} circuit breaker open, retry in {}",                        self.endpoint,                        human_secs((retry_at - now) / 1000)                    )));                }                true // backoff elapsed: this caller becomes the half-open probe            }            "half_open" => {                // A probe is in flight. Allow a fresh one only if the previous                // probe looks abandoned (no result recorded for a long time).                if now - g.updated_at <= STALE_PROBE_SECS * 1000 {                    return Ok(Permit::Denied(format!(                        "{} circuit breaker half-open, probe in flight",                        self.endpoint                    )));                }                true            }            _ => false, // closed        };        // 3. Granted. Pace, then commit the bookkeeping.        if let Some(last) = g.last_request_at {            let wait = MIN_GAP.as_millis() as i64 - (now - last);            if wait > 0 {                tokio::time::sleep(Duration::from_millis(wait as u64)).await;            }        }        let sent = now_ms();        let new_state = if probing { "half_open" } else { g.state.as_str() };        sqlx::query(            "UPDATE endpoint_guard SET \               state = ?, hour_count = hour_count + 1, \               last_request_at = ?, updated_at = ? WHERE endpoint = ?",        )        .bind(new_state)        .bind(sent)        .bind(sent)        .bind(&self.endpoint)        .execute(&self.pool)        .await?;        Ok(Permit::Granted)    }    /// Record that the last `acquire`d request succeeded. Closes the breaker    /// and clears every failure counter.    pub async fn record_success(&self) -> anyhow::Result<()> {        let now = now_ms();        sqlx::query(            "UPDATE endpoint_guard SET \               state = 'closed', fail_streak = 0, trip_count = 0, \               opened_at = NULL, retry_at = NULL, \               last_ok_at = ?, updated_at = ? WHERE endpoint = ?",        )        .bind(now)        .bind(now)        .bind(&self.endpoint)        .execute(&self.pool)        .await?;        Ok(())    }    /// Record that the last `acquire`d request failed.    ///    /// The breaker trips (opens) when any of these holds: the error is an    /// explicit rate-limit signal ([`RateLimited`]); a half-open probe failed;    /// or the ordinary-failure streak reached `FAIL_THRESHOLD`. Otherwise the    /// streak is just incremented. A trip backs off exponentially, honouring a    /// `Retry-After` when it is longer than the computed backoff.    pub async fn record_failure(&self, err: &anyhow::Error) -> anyhow::Result<()> {        let now = now_ms();        let rate_limited = err.downcast_ref::<RateLimited>();        let g = self.load(now).await?;        let streak = g.fail_streak + 1;        let trip = rate_limited.is_some()      // explicit upstream rate-limit signal            || g.state != "closed"             // a half-open probe failed            || streak >= FAIL_THRESHOLD;       // too many ordinary failures in a row        let msg = format!("{err:#}");        if trip {            let trip_count = g.trip_count + 1;            let mut backoff = backoff_secs(trip_count);            if let Some(ra) = rate_limited.and_then(|r| r.retry_after_secs) {                backoff = backoff.max(ra);            }            let retry_at = now + backoff * 1000;            sqlx::query(                "UPDATE endpoint_guard SET \                   state = 'open', fail_streak = 0, trip_count = ?, \                   opened_at = ?, retry_at = ?, \                   last_error = ?, last_error_at = ?, updated_at = ? WHERE endpoint = ?",            )            .bind(trip_count)            .bind(now)            .bind(retry_at)            .bind(&msg)            .bind(now)            .bind(now)            .bind(&self.endpoint)            .execute(&self.pool)            .await?;            tracing::warn!(                "[guard] {} breaker OPEN (trip #{trip_count}), backoff {}: {msg}",                self.endpoint,                human_secs(backoff)            );        } else {            sqlx::query(                "UPDATE endpoint_guard SET \                   fail_streak = ?, last_error = ?, last_error_at = ?, updated_at = ? \                 WHERE endpoint = ?",            )            .bind(streak)            .bind(&msg)            .bind(now)            .bind(now)            .bind(&self.endpoint)            .execute(&self.pool)            .await?;        }        Ok(())    }    /// Load the guard row, creating a default one on first use and rolling the    /// per-hour budget window if the clock hour has elapsed. The hour roll is    /// persisted here (not just held in memory) so a later `hour_count + 1` is    /// always counting within the right hour.    async fn load(&self, now: i64) -> anyhow::Result<GuardRow> {        // Create the row on first use, and keep `hourly_budget` in step with        // how this guard was constructed — it differs per endpoint (see        // `with_budget`), and the data-health page reads it straight from here.        // `updated_at` is left untouched on the correcting update: it tracks        // state-machine changes, not routine bookkeeping.        sqlx::query(            "INSERT INTO endpoint_guard (endpoint, hourly_budget, updated_at) VALUES (?, ?, ?) \             ON CONFLICT(endpoint) DO UPDATE SET hourly_budget = excluded.hourly_budget \               WHERE endpoint_guard.hourly_budget <> excluded.hourly_budget",        )        .bind(&self.endpoint)        .bind(self.hourly_budget)        .bind(now)        .execute(&self.pool)        .await?;        // Roll the budget window. `updated_at` is deliberately left untouched:        // it tracks state-machine changes, and a half-open staleness check        // depends on it not being bumped by routine budget bookkeeping.        sqlx::query(            "UPDATE endpoint_guard SET hour_start = ?, hour_count = 0 \             WHERE endpoint = ? AND (hour_start IS NULL OR ? - hour_start >= ?)",        )        .bind(now)        .bind(&self.endpoint)        .bind(now)        .bind(HOUR_MS)        .execute(&self.pool)        .await?;        let row = sqlx::query_as::<_, GuardRow>(            "SELECT state, fail_streak, trip_count, retry_at, hour_start, \                    hour_count, last_request_at, updated_at \             FROM endpoint_guard WHERE endpoint = ?",        )        .bind(&self.endpoint)        .fetch_one(&self.pool)        .await?;        Ok(row)    }}/// Backoff for the n-th consecutive trip: 30m, 1h, 2h, 4h, ... capped at 24h.fn backoff_secs(trip_count: i64) -> i64 {    const BASE: i64 = 30 * 60;    const CAP: i64 = 24 * 3600;    // trip_count is >= 1; clamp the shift so `1 << shift` cannot overflow.    let shift = (trip_count - 1).clamp(0, 16) as u32;    BASE.saturating_mul(1_i64 << shift).min(CAP)}/// A coarse, human-readable duration for log lines and status messages.fn human_secs(secs: i64) -> String {    let s = secs.max(0);    if s < 60 {        format!("{s}s")    } else if s < 3600 {        format!("{}m", s / 60)    } else {        format!("{}h{}m", s / 3600, (s % 3600) / 60)    }}
added src/main.rs
@@ -0,0 +1,83 @@mod app;mod compute;mod db;mod guard;mod market;mod middleware;mod models;mod providers;mod render;mod routes;mod scheduler;mod seed;mod stream;mod templates;pub use app::{AppState, Config};use std::net::SocketAddr;#[tokio::main]async fn main() -> anyhow::Result<()> {    dotenvy::dotenv().ok();    tracing_subscriber::fmt()        .with_env_filter(            tracing_subscriber::EnvFilter::try_from_default_env()                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),        )        .init();    // Subcommand dispatch; anything else falls through to the HTTP server.    let mut args = std::env::args().skip(1);    if let Some(cmd) = args.next() {        match cmd.as_str() {            "seed" => return run_seed().await,            "--help" | "-h" => {                print_usage();                return Ok(());            }            other => {                eprintln!("unknown subcommand: {other}");                print_usage();                std::process::exit(2);            }        }    }    serve().await}fn print_usage() {    eprintln!(        "finance: single-binary axum market-watching app\n\         \n\         Usage:\n  \           finance         run the HTTP server\n  \           finance seed    (re-)import the curated universe and its history\n"    );}async fn run_seed() -> anyhow::Result<()> {    let state = AppState::from_env().await?;    let client = providers::http::build_client(&state.config);    let stooq =        providers::stooq::StooqProvider::new(client, state.config.stooq_apikey.clone());    seed::run(&state.pool, &state.config, &stooq).await}async fn serve() -> anyhow::Result<()> {    let port: u16 = std::env::var("PORT")        .ok()        .and_then(|v| v.parse().ok())        .unwrap_or(8000);    let state = AppState::from_env().await?;    scheduler::spawn(state.pool.clone(), state.config.clone(), state.hub.clone());    let router = app::router(state);    let addr = SocketAddr::from(([0, 0, 0, 0], port));    let listener = tokio::net::TcpListener::bind(addr).await?;    tracing::info!("finance listening on http://{addr}");    axum::serve(listener, router).await?;    Ok(())}
added src/market.rs
@@ -0,0 +1,88 @@//! US equity market session clock.//!//! Anything that depends on "is the market open" goes through here. Hours are//! evaluated in `America/New_York` (the exchange's wall clock), so the//! daylight-saving shift is handled by `chrono-tz` rather than by us.//!//! Holidays are deliberately NOT modelled: a full exchange-holiday calendar//! would need yearly upkeep, and getting it wrong costs almost nothing here.//! On a holiday the demand-driven intraday job just polls a flat market (and//! only if someone is watching), and the daily-close job fetches one unchanged//! quote per symbol. Neither risks a rate limit or stores bad data.use chrono::{DateTime, Datelike, NaiveTime, Utc, Weekday};use chrono_tz::America::New_York;/// A point in the US equity trading day.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum Session {    /// Outside all trading hours: overnight or weekend.    Closed,    /// Pre-market, 04:00–09:30 ET.    Pre,    /// Regular session, 09:30–16:00 ET.    Regular,    /// After-hours, 16:00–20:00 ET.    Post,}impl Session {    /// Whether any trading session (pre, regular, or post) is in progress.    pub fn is_open(self) -> bool {        !matches!(self, Session::Closed)    }    /// A stable lowercase token for the SSE `market` event and the status pill.    pub fn as_str(self) -> &'static str {        match self {            Session::Closed => "closed",            Session::Pre => "pre",            Session::Regular => "regular",            Session::Post => "post",        }    }}fn at(h: u32, m: u32) -> NaiveTime {    NaiveTime::from_hms_opt(h, m, 0).expect("valid wall-clock time")}/// The trading session in effect at `now`.pub fn session_at(now: DateTime<Utc>) -> Session {    let et = now.with_timezone(&New_York);    if matches!(et.weekday(), Weekday::Sat | Weekday::Sun) {        return Session::Closed;    }    let t = et.time();    if t >= at(9, 30) && t < at(16, 0) {        Session::Regular    } else if t >= at(4, 0) && t < at(9, 30) {        Session::Pre    } else if t >= at(16, 0) && t < at(20, 0) {        Session::Post    } else {        Session::Closed    }}/// The `America/New_York` calendar date (`YYYY-MM-DD`) at `now`. This is the/// trading day the daily-close job keys its once-per-day guard on.pub fn et_date(now: DateTime<Utc>) -> String {    now.with_timezone(&New_York).format("%Y-%m-%d").to_string()}/// Whether `now` falls on a weekday in ET (no holiday calendar; see the/// module note).pub fn is_et_weekday(now: DateTime<Utc>) -> bool {    !matches!(        now.with_timezone(&New_York).weekday(),        Weekday::Sat | Weekday::Sun    )}/// Whether the regular session has closed for the current ET day: time is at/// or past 16:05 ET. The five-minute cushion lets the closing print settle/// before the daily-close job snapshots it.pub fn after_close(now: DateTime<Utc>) -> bool {    now.with_timezone(&New_York).time() >= at(16, 5)}
added src/middleware.rs
@@ -0,0 +1,37 @@use axum::{    extract::{Request, State},    middleware::Next,    response::Response,};use chrono::Local;use std::time::Instant;use crate::AppState;/// Per-request log line: `time METHOD STATUS latency path`, status ANSI-colored.pub async fn log_requests(req: Request, next: Next) -> Response {    let method = req.method().clone();    let path = req        .uri()        .path_and_query()        .map(|p| p.as_str().to_string())        .unwrap_or_else(|| req.uri().path().to_string());    let start = Instant::now();    let response = next.run(req).await;    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;    let status = response.status().as_u16();    let now = Local::now().format("%H:%M:%S");    let color = match status {        200..=299 => "\x1b[32m",        300..=399 => "\x1b[36m",        400..=499 => "\x1b[33m",        _ => "\x1b[31m",    };    eprintln!("{now} {method:<5} {color}{status}\x1b[0m {elapsed_ms:>7.2}ms  {path}");    response}/// Router fallback: render the themed 404 shell for any unmatched path.pub async fn not_found(State(state): State<AppState>) -> Response {    crate::render::not_found(&state)}
added src/models.rs
@@ -0,0 +1,67 @@//! Shared `sqlx` row structs and small cross-route view models.use serde::Serialize;use sqlx::FromRow;use crate::compute;/// A full row of the `symbols` table.#[derive(Debug, Clone, FromRow, Serialize)]pub struct SymbolRow {    pub ticker: String,    pub name: String,    pub kind: String,    pub exchange: Option<String>,    pub currency: String,    pub cik: Option<String>,    pub sector: Option<String>,    pub industry: Option<String>,    pub is_seeded: i64,    pub is_watched: i64,    pub history_synced_at: Option<i64>,    pub history_first_date: Option<String>,    pub history_last_date: Option<String>,    pub fundamentals_synced_at: Option<i64>,    pub filings_synced_at: Option<i64>,    pub last_price: Option<f64>,    pub prev_close: Option<f64>,    pub last_quote_at: Option<i64>,    pub created_at: i64,    pub updated_at: i64,}/// A symbol's price row as selected for a card grid: ticker, name, kind, the/// price to show, and the close it is changing against.pub type SymbolCardRow = (String, String, String, Option<f64>, Option<f64>);/// A compact symbol tile, rendered by the `ticker_card` macro. Shared by the/// Markets dashboard and the Search page.#[derive(Serialize)]pub struct Card {    pub ticker: String,    pub name: String,    pub kind: String,    pub price: Option<f64>,    pub change_abs: Option<f64>,    pub change_pct: Option<f64>,}/// Build a [`Card`] from a selected price row, computing the change off the/// price and its prior close.pub fn to_card((ticker, name, kind, last, prev): SymbolCardRow) -> Card {    let (change_abs, change_pct) = match (last, prev) {        (Some(l), Some(p)) => {            let c = compute::change(l, p);            (Some(c.abs), Some(c.pct))        }        _ => (None, None),    };    Card {        ticker,        name,        kind,        price: last,        change_abs,        change_pct,    }}
added src/providers/http.rs
@@ -0,0 +1,37 @@use std::time::Duration;use crate::Config;/// One shared reqwest client for all outbound data requests.////// rustls negotiates HTTP/2 over ALPN, and gzip/brotli are decompressed/// transparently. A reusable client is correct here: unlike a latency/// probe, these calls are plain data fetches with no per-request handshake/// measurement to preserve.pub fn build_client(config: &Config) -> reqwest::Client {    reqwest::Client::builder()        .user_agent(config.user_agent.clone())        .timeout(Duration::from_secs(25))        .build()        .expect("reqwest client builds")}/// A client for SEC EDGAR requests.////// SEC's fair-access policy asks every consumer to identify itself, so the/// configured contact email is appended to the User-Agent on these requests/// only (the public market endpoints get the plain browser string from/// `build_client`). A `companyfacts` payload can run to several MB, so the/// timeout is more generous than the default client's.pub fn build_sec_client(config: &Config) -> reqwest::Client {    let user_agent = if config.sec_contact_email.is_empty() {        config.user_agent.clone()    } else {        format!("{} {}", config.user_agent, config.sec_contact_email)    };    reqwest::Client::builder()        .user_agent(user_agent)        .timeout(Duration::from_secs(40))        .build()        .expect("reqwest client builds")}
added src/providers/mod.rs
@@ -0,0 +1,162 @@//! Data-source abstraction.//!//! Each upstream sits behind a trait so a source can be swapped without//! touching callers. Phase 1 ships `HistoryProvider` (Stooq); Phase 5 adds//! `QuoteProvider` (Yahoo); Phase 7 adds `FundamentalsProvider` (SEC EDGAR).pub mod http;pub mod sec;pub mod stooq;pub mod yahoo;use std::collections::HashMap;use anyhow::Result;use async_trait::async_trait;/// One day of OHLCV, as delivered by a history source.#[derive(Debug, Clone)]pub struct DailyBar {    /// Trading date, `YYYY-MM-DD`.    pub d: String,    pub open: f64,    pub high: f64,    pub low: f64,    pub close: f64,    pub volume: i64,}/// Deep daily OHLCV history. Implemented by `StooqProvider`.#[async_trait]pub trait HistoryProvider: Send + Sync {    fn name(&self) -> &'static str;    /// Daily bars for `ticker`, oldest first. `since` (a `YYYY-MM-DD` date)    /// trims the response to an incremental window when supplied.    async fn daily(&self, ticker: &str, since: Option<&str>) -> Result<Vec<DailyBar>>;}/// A live quote snapshot from a quote source.#[derive(Debug, Clone)]pub struct Quote {    pub price: f64,    pub prev_close: Option<f64>,    pub open: Option<f64>,    pub day_high: Option<f64>,    pub day_low: Option<f64>,    pub volume: Option<i64>,    /// The source's market-state label (e.g. `REGULAR`, `PRE`, `CLOSED`).    pub market_state: Option<String>,    /// The source's own timestamp for this quote, UTC epoch-ms.    pub source_time: Option<i64>,}/// One intraday OHLCV bar — 15-minute granularity from Yahoo.#[derive(Debug, Clone)]pub struct IntradayBar {    /// Bar start, UTC epoch-ms.    pub ts: i64,    pub open: f64,    pub high: f64,    pub low: f64,    pub close: f64,    pub volume: i64,}/// A quote source's full reply: the live quote plus the day's intraday bars,/// both from one request.#[derive(Debug, Clone)]pub struct QuoteData {    pub quote: Quote,    pub bars: Vec<IntradayBar>,}/// Near-real-time quotes and intraday bars. Implemented by `YahooProvider`.#[async_trait]pub trait QuoteProvider: Send + Sync {    fn name(&self) -> &'static str;    /// The latest quote and the day's intraday bars for `ticker`.    async fn quote(&self, ticker: &str) -> Result<QuoteData>;}/// One fundamental fact: a single metric for a single fiscal period, parsed/// from a company's SEC XBRL facts.#[derive(Debug, Clone)]pub struct Fact {    /// Our canonical metric name, e.g. `revenue`, `eps_diluted`.    pub metric: String,    /// Fiscal-period label: `FY2024` for a full year, `Q3-2024` for a quarter.    pub period: String,    pub fiscal_year: i64,    /// `None` for a full-year figure.    pub fiscal_qtr: Option<i64>,    /// Period end, `YYYY-MM-DD`.    pub period_end: String,    pub value: f64,    /// XBRL unit, e.g. `USD`, `USD/shares`, `shares`.    pub unit: Option<String>,    /// The form the figure was reported on, e.g. `10-K`.    pub form: Option<String>,    /// Filing date, `YYYY-MM-DD`.    pub filed_at: Option<String>,}/// One SEC filing from a company's submission history.#[derive(Debug, Clone)]pub struct FilingRecord {    pub accession: String,    pub form: String,    /// Filing date, `YYYY-MM-DD`.    pub filed_at: String,    /// The period the filing reports on, `YYYY-MM-DD`.    pub period_of_report: Option<String>,    pub primary_doc: Option<String>,    /// Full URL to the filing's primary document (or index) on EDGAR.    pub url: String,    pub description: Option<String>,}/// Company fundamentals and filing history from SEC EDGAR. Implemented by/// `SecProvider`. Stocks only; ETFs and indexes do not file.#[async_trait]pub trait FundamentalsProvider: Send + Sync {    fn name(&self) -> &'static str;    /// The whole-market ticker -> CIK map, from one bulk request. Keys are    /// tickers normalised to bare uppercase alphanumerics (so our `BRK.B`    /// matches EDGAR's `BRK-B`); values are 10-digit zero-padded CIKs.    async fn cik_map(&self) -> Result<HashMap<String, String>>;    /// XBRL fundamental facts for one company, by its 10-digit CIK.    async fn facts(&self, cik: &str) -> Result<Vec<Fact>>;    /// Recent filing history for one company, by its 10-digit CIK.    async fn filings(&self, cik: &str) -> Result<Vec<FilingRecord>>;}/// An upstream rejected a request with an explicit rate-limit signal (HTTP 429/// or 503). A provider returns this as the source of its `anyhow::Error` so the/// `EndpointGuard` (see `src/guard.rs`) can recognise it by downcast and trip/// the circuit breaker immediately, rather than waiting for a failure streak.#[derive(Debug)]pub struct RateLimited {    /// The HTTP status that carried the signal.    pub status: u16,    /// `Retry-After` from the response, in seconds, when the upstream sent one    /// in the numeric form. The HTTP-date form is not parsed (the guard's own    /// exponential backoff covers it), so this is `None` then.    pub retry_after_secs: Option<i64>,}impl std::fmt::Display for RateLimited {    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {        write!(f, "upstream rate-limited (HTTP {})", self.status)?;        if let Some(s) = self.retry_after_secs {            write!(f, ", Retry-After {s}s")?;        }        Ok(())    }}impl std::error::Error for RateLimited {}
added src/providers/sec.rs
@@ -0,0 +1,476 @@//! Company fundamentals and filing history from SEC EDGAR.//!//! Three endpoints, no API key (SEC asks only for an identifying User-Agent,//! which `build_sec_client` sets)://!//!  - `https://www.sec.gov/files/company_tickers.json`: the whole-market//!    ticker -> CIK map, fetched once to fill in `symbols.cik`.//!  - `https://data.sec.gov/api/xbrl/companyfacts/CIK##########.json`: every//!    XBRL fact a company has reported.//!  - `https://data.sec.gov/submissions/CIK##########.json`: its filing//!    history.//!//! The XBRL `companyfacts` payload is the awkward one. A company reports the//! same metric under different us-gaap *concepts* across accounting eras (e.g.//! revenue moved to `RevenueFromContractWithCustomerExcludingAssessedTax`), and//! each value carries year-to-date *and* discrete-period durations. `facts`//! normalises that: it merges the candidate concepts for each of our metrics//! and keeps only the clean full-year and discrete-quarter figures (see//! `classify`).use std::collections::HashMap;use anyhow::{anyhow, Result};use async_trait::async_trait;use chrono::{Datelike, NaiveDate};use reqwest::{header::RETRY_AFTER, StatusCode};use serde::Deserialize;use crate::providers::{Fact, FilingRecord, FundamentalsProvider, RateLimited};/// Fundamentals and filings from SEC EDGAR.pub struct SecProvider {    /// A client whose User-Agent carries our contact email (see    /// `http::build_sec_client`).    client: reqwest::Client,}impl SecProvider {    pub fn new(client: reqwest::Client) -> Self {        Self { client }    }    /// Send one GET. `Ok(None)` means HTTP 404: a valid "this company has no    /// such resource" answer (some symbols simply have no XBRL facts), not a    /// failure, so it must not feed the circuit breaker. A 429/503 surfaces as    /// the typed `RateLimited` the guard trips on at once.    async fn get(&self, url: &str) -> Result<Option<reqwest::Response>> {        let resp = self.client.get(url).send().await?;        let status = resp.status();        if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE {            let retry_after_secs = resp                .headers()                .get(RETRY_AFTER)                .and_then(|v| v.to_str().ok())                .and_then(|s| s.trim().parse::<i64>().ok());            return Err(anyhow::Error::new(RateLimited {                status: status.as_u16(),                retry_after_secs,            }));        }        if status == StatusCode::NOT_FOUND {            return Ok(None);        }        Ok(Some(resp.error_for_status()?))    }}/// Normalise a ticker to bare uppercase alphanumerics so our universe's/// `BRK.B` matches EDGAR's `BRK-B` and Stooq-style symbols line up too.pub fn normalize_ticker(ticker: &str) -> String {    ticker        .chars()        .filter(|c| c.is_ascii_alphanumeric())        .collect::<String>()        .to_uppercase()}// ── company_tickers.json ───────────────────────────────────────────────────#[derive(Deserialize)]struct TickerEntry {    cik_str: i64,    ticker: String,}// ── companyfacts ───────────────────────────────────────────────────────────#[derive(Deserialize)]struct CompanyFacts {    #[serde(default)]    facts: FactNamespaces,}#[derive(Default, Deserialize)]struct FactNamespaces {    /// The us-gaap taxonomy carries every metric we read. `dei` (entity    /// identifiers) is ignored.    #[serde(rename = "us-gaap", default)]    us_gaap: HashMap<String, Concept>,}#[derive(Deserialize)]struct Concept {    /// Keyed by XBRL unit (`USD`, `USD/shares`, `shares`, ...).    #[serde(default)]    units: HashMap<String, Vec<UnitEntry>>,}/// One reported value of a concept. `start` is present only for *duration*/// facts (income-statement items); *instantaneous* facts (balance-sheet items)/// carry only `end`.////// The `fy` (fiscal year) field is deliberately not read: companyfacts tags a/// fact with the fiscal year of the *filing* it was drawn from, so a prior/// year shown as a comparative in a later 10-K carries that later filing's/// `fy`. The fiscal year is taken from the `end` date instead (see `classify`).#[derive(Deserialize)]struct UnitEntry {    start: Option<String>,    end: String,    val: f64,    /// Fiscal period: `FY`, `Q1`, `Q2`, `Q3` (`Q4` appears rarely).    fp: Option<String>,    form: Option<String>,    filed: Option<String>,}/// Our canonical metric -> the us-gaap concepts that can carry it. All listed/// concepts are merged, so a company that changed concepts across eras still/// gets a continuous series; a later filing's restated value wins ties (see/// the `filed` comparison in `facts`).const METRIC_CONCEPTS: &[(&str, &[&str])] = &[    (        "revenue",        &[            "RevenueFromContractWithCustomerExcludingAssessedTax",            "Revenues",            "RevenueFromContractWithCustomerIncludingAssessedTax",            "SalesRevenueNet",        ],    ),    ("net_income", &["NetIncomeLoss", "ProfitLoss"]),    (        "eps_diluted",        &["EarningsPerShareDiluted", "EarningsPerShareBasicAndDiluted"],    ),    (        "shares_diluted",        &["WeightedAverageNumberOfDilutedSharesOutstanding"],    ),    (        "dividends_per_share",        &[            "CommonStockDividendsPerShareDeclared",            "CommonStockDividendsPerShareCashPaid",        ],    ),    ("assets", &["Assets"]),    ("liabilities", &["Liabilities"]),    (        "equity",        &[            "StockholdersEquity",            "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest",        ],    ),    ("assets_current", &["AssetsCurrent"]),    ("liabilities_current", &["LiabilitiesCurrent"]),];/// How many fiscal years of annual and quarterly history to keep. Older/// figures are dropped at parse time so `fundamentals` stays small.const ANNUAL_YEARS: i64 = 6;const QUARTERLY_YEARS: i64 = 3;/// `Q3` -> `3`. `None` for anything that is not a `Q1`..`Q4` label.fn quarter_num(fp: &str) -> Option<i64> {    match fp {        "Q1" => Some(1),        "Q2" => Some(2),        "Q3" => Some(3),        "Q4" => Some(4),        _ => None,    }}/// The fiscal year a period ending on `end` belongs to, for a company whose/// fiscal year ends in month `fye_month`.////// A period that ends *after* the fiscal-year-end month falls in the next/// fiscal year: e.g. an October-to-December quarter of a company with a/// September fiscal-year end is Q1 of the *following* fiscal year, even though/// it ends in the same calendar year as that year-end.fn fiscal_year_of(end: NaiveDate, fye_month: u32) -> i64 {    let y = end.year() as i64;    if end.month() > fye_month {        y + 1    } else {        y    }}/// The calendar month a company's fiscal year ends, taken as the most common/// end month across its annual (full-year duration) facts. Defaults to 12 (a/// calendar fiscal year) when none can be determined.fn fiscal_year_end_month(body: &CompanyFacts) -> u32 {    let mut counts = [0u32; 13]; // indices 1..=12    for concept in body.facts.us_gaap.values() {        for entries in concept.units.values() {            for e in entries {                if e.fp.as_deref() != Some("FY") {                    continue;                }                let (Some(start), Ok(end)) = (                    e.start.as_deref(),                    NaiveDate::parse_from_str(&e.end, "%Y-%m-%d"),                ) else {                    continue;                };                let Ok(start) = NaiveDate::parse_from_str(start, "%Y-%m-%d") else {                    continue;                };                if (330..=400).contains(&(end - start).num_days()) {                    counts[end.month() as usize] += 1;                }            }        }    }    counts        .iter()        .enumerate()        .skip(1)        .max_by_key(|(_, &c)| c)        .filter(|(_, &c)| c > 0)        .map(|(m, _)| m as u32)        .unwrap_or(12)}/// Classify one XBRL value into the fiscal period it cleanly represents, or/// `None` to drop it. Returns `(period_label, fiscal_year, fiscal_qtr)`.////// The fiscal year comes from the `end` date and the company's fiscal-year-end/// month (see `fiscal_year_of`), never the `fy` field (see `UnitEntry`). XBRL/// is otherwise noisy: a concept carries discrete-quarter values, full-year/// values, *and* year-to-date roll-ups (6- and 9-month spans). This keeps only:///  - **full years**: a duration fact spanning ~a year with `fp == FY`;///  - **discrete quarters**: a duration fact spanning ~a quarter;///  - **year-end balances**: an instantaneous fact with `fp == FY`.////// Quarterly *balance-sheet* (instantaneous) figures are deliberately not/// collected: a 10-Q tags its prior-year-end comparative with the filing's own/// quarter, which would mislabel a year-end snapshot as a quarter.fn classify(e: &UnitEntry, fye_month: u32) -> Option<(String, i64, Option<i64>)> {    let end = NaiveDate::parse_from_str(&e.end, "%Y-%m-%d").ok()?;    let fiscal_year = fiscal_year_of(end, fye_month);    let fp = e.fp.as_deref().unwrap_or("");    if let Some(start) = e.start.as_deref() {        // Duration fact (an income-statement flow). The span length tells a        // discrete quarter from a full year and from a year-to-date roll-up.        let start = NaiveDate::parse_from_str(start, "%Y-%m-%d").ok()?;        let days = (end - start).num_days();        if (330..=400).contains(&days) && fp == "FY" {            Some((format!("FY{fiscal_year}"), fiscal_year, None))        } else if (80..=100).contains(&days) {            let q = quarter_num(fp)?;            Some((format!("Q{q}-{fiscal_year}"), fiscal_year, Some(q)))        } else {            None        }    } else {        // Instantaneous fact (a balance-sheet snapshot): keep only the fiscal        // year-end, sourced from an annual report.        let form = e.form.as_deref().unwrap_or("");        let annual_form = form.is_empty()            || form.starts_with("10-K")            || form.starts_with("20-F")            || form.starts_with("40-F");        if fp == "FY" && annual_form {            Some((format!("FY{fiscal_year}"), fiscal_year, None))        } else {            None        }    }}// ── submissions ────────────────────────────────────────────────────────────#[derive(Deserialize)]struct Submissions {    #[serde(default)]    filings: SubmissionFilings,}#[derive(Default, Deserialize)]struct SubmissionFilings {    #[serde(default)]    recent: RecentFilings,}/// EDGAR returns the filing history column-oriented: one parallel array per/// field, indexed by filing.#[derive(Default, Deserialize)]#[serde(rename_all = "camelCase")]struct RecentFilings {    #[serde(default)]    accession_number: Vec<String>,    #[serde(default)]    filing_date: Vec<String>,    #[serde(default)]    report_date: Vec<String>,    #[serde(default)]    form: Vec<String>,    #[serde(default)]    primary_document: Vec<String>,    #[serde(default)]    primary_doc_description: Vec<String>,}/// The filing forms worth showing a market-watcher: periodic reports (10-K,/// 10-Q, 8-K and the foreign-filer equivalents) and the annual proxy. The/// long tail (insider Form 4s, 13G/D ownership stakes, S-8 registrations) is/// dropped as noise.fn is_material_form(form: &str) -> bool {    const PREFIXES: [&str; 7] = ["10-K", "10-Q", "8-K", "20-F", "40-F", "6-K", "DEF 14A"];    PREFIXES.iter().any(|p| form.starts_with(p))}/// How many material filings to keep per company.const MAX_FILINGS: usize = 40;#[async_trait]impl FundamentalsProvider for SecProvider {    fn name(&self) -> &'static str {        "sec"    }    async fn cik_map(&self) -> Result<HashMap<String, String>> {        let url = "https://www.sec.gov/files/company_tickers.json";        let resp = self            .get(url)            .await?            .ok_or_else(|| anyhow!("sec company_tickers.json not found"))?;        // The file is a JSON object keyed by a row index; only the values matter.        let entries: HashMap<String, TickerEntry> = resp.json().await?;        let mut map = HashMap::with_capacity(entries.len());        for entry in entries.into_values() {            map.insert(                normalize_ticker(&entry.ticker),                format!("{:010}", entry.cik_str),            );        }        Ok(map)    }    async fn facts(&self, cik: &str) -> Result<Vec<Fact>> {        let url = format!("https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json");        let Some(resp) = self.get(&url).await? else {            return Ok(Vec::new()); // 404: company has no XBRL facts        };        let body: CompanyFacts = resp.json().await?;        let fye_month = fiscal_year_end_month(&body);        let this_year = chrono::Utc::now().year() as i64;        // Collapse to one fact per (metric, period): a metric can be reported        // under several concepts and restated across filings, so the latest        // `filed` wins.        let mut chosen: HashMap<(String, String), Fact> = HashMap::new();        for (metric, concepts) in METRIC_CONCEPTS {            for concept in *concepts {                let Some(concept_data) = body.facts.us_gaap.get(*concept) else {                    continue;                };                for (unit, entries) in &concept_data.units {                    for e in entries {                        let Some((period, fiscal_year, fiscal_qtr)) = classify(e, fye_month)                        else {                            continue;                        };                        // Drop anything older than the retention window.                        let keep_since = if fiscal_qtr.is_some() {                            this_year - QUARTERLY_YEARS                        } else {                            this_year - ANNUAL_YEARS                        };                        if fiscal_year < keep_since {                            continue;                        }                        let key = (metric.to_string(), period.clone());                        let newer = chosen.get(&key).map_or(true, |prev| {                            e.filed.as_deref().unwrap_or("") > prev.filed_at.as_deref().unwrap_or("")                        });                        if newer {                            chosen.insert(                                key,                                Fact {                                    metric: metric.to_string(),                                    period,                                    fiscal_year,                                    fiscal_qtr,                                    period_end: e.end.clone(),                                    value: e.val,                                    unit: Some(unit.clone()),                                    form: e.form.clone(),                                    filed_at: e.filed.clone(),                                },                            );                        }                    }                }            }        }        Ok(chosen.into_values().collect())    }    async fn filings(&self, cik: &str) -> Result<Vec<FilingRecord>> {        let url = format!("https://data.sec.gov/submissions/CIK{cik}.json");        let Some(resp) = self.get(&url).await? else {            return Ok(Vec::new()); // 404: no submission history        };        let body: Submissions = resp.json().await?;        let r = body.filings.recent;        // EDGAR pads the CIK to 10 digits; the Archives path uses it unpadded.        let cik_int = cik.trim_start_matches('0');        let mut out = Vec::new();        for i in 0..r.accession_number.len() {            let form = r.form.get(i).cloned().unwrap_or_default();            if !is_material_form(&form) {                continue;            }            let accession = r.accession_number[i].clone();            let filed_at = r.filing_date.get(i).cloned().unwrap_or_default();            if accession.is_empty() || filed_at.is_empty() {                continue;            }            let nodash = accession.replace('-', "");            let primary_doc = r                .primary_document                .get(i)                .filter(|s| !s.is_empty())                .cloned();            // Link straight to the primary document when EDGAR names one;            // otherwise to the filing index page.            let url = match &primary_doc {                Some(doc) => {                    format!("https://www.sec.gov/Archives/edgar/data/{cik_int}/{nodash}/{doc}")                }                None => format!(                    "https://www.sec.gov/Archives/edgar/data/{cik_int}/{nodash}/{accession}-index.htm"                ),            };            out.push(FilingRecord {                accession,                form,                filed_at,                period_of_report: r.report_date.get(i).filter(|s| !s.is_empty()).cloned(),                primary_doc,                url,                description: r                    .primary_doc_description                    .get(i)                    .filter(|s| !s.is_empty())                    .cloned(),            });            if out.len() >= MAX_FILINGS {                break;            }        }        Ok(out)    }}
added src/providers/stooq.rs
@@ -0,0 +1,126 @@use anyhow::{anyhow, Result};use async_trait::async_trait;use reqwest::{header::RETRY_AFTER, StatusCode};use crate::providers::{DailyBar, HistoryProvider, RateLimited};/// Deep daily history from Stooq's per-ticker CSV endpoint.////// Stooq now gates this endpoint behind an apikey (obtained once via a captcha/// on stooq.com). The key is read from `STOOQ_APIKEY` and kept out of the repo.pub struct StooqProvider {    client: reqwest::Client,    apikey: String,}impl StooqProvider {    pub fn new(client: reqwest::Client, apikey: String) -> Self {        Self { client, apikey }    }}/// Map a canonical ticker to Stooq's symbol scheme./// - indexes keep their `^` prefix, lowercased (`^SPX` -> `^spx`)/// - everything else is a US listing: lowercased with `.us`, and any `.`///   in the ticker becomes `-` (`BRK.B` -> `brk-b.us`)fn stooq_symbol(ticker: &str) -> String {    if let Some(rest) = ticker.strip_prefix('^') {        format!("^{}", rest.to_lowercase())    } else {        format!("{}.us", ticker.replace('.', "-").to_lowercase())    }}/// Parse a Stooq daily CSV body. Stooq returns a plain-text message (not CSV)/// on errors such as a missing apikey or a hit-rate limit, so a missing header/// is surfaced as an error.fn parse_csv(text: &str) -> Result<Vec<DailyBar>> {    let trimmed = text.trim_start();    if !trimmed.starts_with("Date") {        let first = trimmed.lines().next().unwrap_or("").trim();        // Stooq answers a symbol it genuinely has no history for (e.g. ^RUT,        // ^VIX) with the plain body "No data". That is a successful, definitive        // response — the symbol simply has nothing — not an endpoint failure,        // so it must not feed the circuit breaker. Surface it as empty.        if first.eq_ignore_ascii_case("No data") {            return Ok(Vec::new());        }        return Err(anyhow!(            "stooq returned no CSV: {}",            first.chars().take(120).collect::<String>()        ));    }    let mut rdr = csv::ReaderBuilder::new()        .has_headers(true)        .from_reader(trimmed.as_bytes());    let mut out = Vec::new();    for rec in rdr.records() {        let rec = rec?;        // Date,Open,High,Low,Close,Volume        let cell = |i: usize| rec.get(i).unwrap_or("").trim();        let d = cell(0);        if d.is_empty() {            continue;        }        let num = |s: &str| s.parse::<f64>().ok();        let (Some(open), Some(high), Some(low), Some(close)) =            (num(cell(1)), num(cell(2)), num(cell(3)), num(cell(4)))        else {            // Rows with "N/D" (no data, common for indexes) are skipped.            continue;        };        let volume = cell(5).parse::<f64>().ok().map(|v| v as i64).unwrap_or(0);        out.push(DailyBar {            d: d.to_string(),            open,            high,            low,            close,            volume,        });    }    Ok(out)}#[async_trait]impl HistoryProvider for StooqProvider {    fn name(&self) -> &'static str {        "stooq"    }    async fn daily(&self, ticker: &str, since: Option<&str>) -> Result<Vec<DailyBar>> {        let sym = stooq_symbol(ticker);        let mut url = format!("https://stooq.com/q/d/l/?s={sym}&i=d");        if let Some(since) = since {            // Stooq accepts an inclusive d1..d2 window in YYYYMMDD form.            let d1 = since.replace('-', "");            let d2 = chrono::Utc::now().format("%Y%m%d").to_string();            url.push_str(&format!("&d1={d1}&d2={d2}"));        }        url.push_str(&format!("&apikey={}", self.apikey));        let resp = self.client.get(&url).send().await?;        // Surface an explicit rate-limit signal as a typed `RateLimited` error        // so the EndpointGuard trips the breaker at once. Stooq's other way of        // saying "slow down" — a 200 with a plain-text quota message — is not a        // CSV body, so `parse_csv` rejects it as an ordinary error, and a short        // streak of those trips the breaker too.        let status = resp.status();        if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE {            let retry_after_secs = resp                .headers()                .get(RETRY_AFTER)                .and_then(|v| v.to_str().ok())                .and_then(|s| s.trim().parse::<i64>().ok());            return Err(anyhow::Error::new(RateLimited {                status: status.as_u16(),                retry_after_secs,            }));        }        let resp = resp.error_for_status()?;        let text = resp.text().await?;        parse_csv(&text)    }}
added src/providers/yahoo.rs
@@ -0,0 +1,312 @@//! Live quotes and intraday bars from Yahoo Finance's v8 chart endpoint.//!//! `https://query1.finance.yahoo.com/v8/finance/chart/<symbol>` needs no API//! key — just an ordinary browser User-Agent, which the shared client already//! sends. One call with `interval=15m&range=1d` returns the day's 15-minute//! bars *and* a `meta` block carrying the live quote, so a single request//! feeds both the `quotes` snapshot and `intraday_bars`.//!//! The same endpoint also identifies a symbol — its name, instrument type,//! exchange and currency all sit in `meta` — so [`YahooProvider::lookup`]//! reuses it to validate and describe a symbol the add-symbol flow (Phase 9)//! is about to register.use anyhow::{anyhow, Result};use async_trait::async_trait;use reqwest::{header::RETRY_AFTER, StatusCode};use serde::Deserialize;use crate::providers::{IntradayBar, Quote, QuoteData, QuoteProvider, RateLimited};/// Near-real-time quotes from Yahoo Finance.pub struct YahooProvider {    client: reqwest::Client,}impl YahooProvider {    pub fn new(client: reqwest::Client) -> Self {        Self { client }    }}/// Map a canonical ticker to Yahoo's symbol scheme./// - most indexes already match Yahoo (`^DJI`, `^NDX`, `^RUT`, `^VIX`)/// - two differ: our Stooq-style `^SPX` / `^NDQ` are `^GSPC` / `^IXIC` on Yahoo/// - stocks and ETFs are the plain ticker with `.` rewritten to `-`///   (`BRK.B` -> `BRK-B`)fn yahoo_symbol(ticker: &str) -> String {    match ticker {        "^SPX" => "^GSPC".to_string(),        "^NDQ" => "^IXIC".to_string(),        t if t.starts_with('^') => t.to_string(),        t => t.replace('.', "-"),    }}// ── identity of a looked-up symbol ─────────────────────────────────────────/// Identifying metadata for a symbol, derived from Yahoo's chart `meta`. The/// add-symbol flow uses it to register a new symbol with a real name and kind/// rather than a bare ticker.#[derive(Debug, Clone)]pub struct SymbolInfo {    pub name: String,    /// One of `stock` | `etf` | `index` | `future`.    pub kind: String,    pub exchange: Option<String>,    pub currency: String,}/// The outcome of [`YahooProvider::lookup`]. An `Err` from `lookup` is a/// genuine transport / rate-limit failure (and should feed the endpoint/// guard); these three variants are all successful answers from Yahoo.#[derive(Debug)]pub enum SymbolLookup {    /// Yahoo knows this symbol: its identity, plus the quote and intraday bars    /// the same request returned.    Found { info: SymbolInfo, data: QuoteData },    /// Yahoo has no such symbol.    Unknown,    /// Yahoo knows the symbol but it is an instrument type this app does not    /// model yet (a currency pair, a cryptocurrency, ...). Carries the raw type.    Unsupported(String),}// ── the slice of the v8 chart JSON we read ─────────────────────────────────#[derive(Deserialize)]struct ChartEnvelope {    chart: Chart,}#[derive(Deserialize)]struct Chart {    result: Option<Vec<ChartResult>>,    /// Non-null on a logical failure (e.g. an unknown symbol).    error: Option<serde_json::Value>,}#[derive(Deserialize)]struct ChartResult {    meta: Meta,    /// Bar-start times, Unix seconds. Absent when the day has no bars yet.    timestamp: Option<Vec<i64>>,    indicators: Indicators,}#[derive(Deserialize)]#[serde(rename_all = "camelCase")]struct Meta {    regular_market_price: Option<f64>,    previous_close: Option<f64>,    chart_previous_close: Option<f64>,    regular_market_open: Option<f64>,    regular_market_day_high: Option<f64>,    regular_market_day_low: Option<f64>,    regular_market_volume: Option<i64>,    /// The source's own timestamp for the quote, Unix seconds.    regular_market_time: Option<i64>,    market_state: Option<String>,    /// Identity fields — read only by `lookup`. `EQUITY` | `ETF` | `INDEX` |    /// `MUTUALFUND` | `FUTURE` | `CURRENCY` | `CRYPTOCURRENCY` | ...    instrument_type: Option<String>,    short_name: Option<String>,    long_name: Option<String>,    currency: Option<String>,    exchange_name: Option<String>,    full_exchange_name: Option<String>,}#[derive(Deserialize)]struct Indicators {    quote: Vec<OhlcvArrays>,}/// Column-oriented OHLCV: one parallel array per field, indexed by bar. A cell/// can be null when a bar has no print, so every element is optional.#[derive(Default, Deserialize)]struct OhlcvArrays {    #[serde(default)]    open: Vec<Option<f64>>,    #[serde(default)]    high: Vec<Option<f64>>,    #[serde(default)]    low: Vec<Option<f64>>,    #[serde(default)]    close: Vec<Option<f64>>,    #[serde(default)]    volume: Vec<Option<i64>>,}impl YahooProvider {    /// Fetch and parse the v8 chart payload for `ticker`.    ///    /// `Ok(Some(_))` is a real chart result; `Ok(None)` means Yahoo answered    /// cleanly that it has no such symbol (a 404, or a `chart.error` body) —    /// a definitive "unknown", not a failure. `Err` is a transport error or an    /// explicit rate-limit signal, surfaced as the typed [`RateLimited`] so the    /// endpoint guard trips its breaker at once.    async fn fetch_chart(&self, ticker: &str) -> Result<Option<ChartResult>> {        // `^` is not a bare path character; percent-encode the symbol.        let sym = urlencoding::encode(&yahoo_symbol(ticker)).into_owned();        let url = format!(            "https://query1.finance.yahoo.com/v8/finance/chart/{sym}\             ?interval=15m&range=1d&includePrePost=true"        );        let resp = self.client.get(&url).send().await?;        let status = resp.status();        if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE {            let retry_after_secs = resp                .headers()                .get(RETRY_AFTER)                .and_then(|v| v.to_str().ok())                .and_then(|s| s.trim().parse::<i64>().ok());            return Err(anyhow::Error::new(RateLimited {                status: status.as_u16(),                retry_after_secs,            }));        }        // Yahoo answers an unknown symbol with 404 and a `chart.error` body —        // a definitive "no such symbol", not a transport failure.        if status == StatusCode::NOT_FOUND {            return Ok(None);        }        let resp = resp.error_for_status()?;        let env: ChartEnvelope = resp.json().await?;        if env.chart.error.is_some() {            return Ok(None);        }        Ok(env            .chart            .result            .and_then(|mut r| if r.is_empty() { None } else { Some(r.remove(0)) }))    }    /// Identify a symbol: validate it exists on Yahoo and return its name,    /// kind, exchange and currency, alongside the quote the same request    /// carried. Used by the Phase 9 add-symbol flow.    pub async fn lookup(&self, ticker: &str) -> Result<SymbolLookup> {        let Some(result) = self.fetch_chart(ticker).await? else {            return Ok(SymbolLookup::Unknown);        };        // Derive identity from `meta` before the result is consumed for the        // quote. An instrument type we do not model is a clean rejection.        let Some(info) = symbol_info(ticker, &result.meta) else {            let raw = result.meta.instrument_type.clone().unwrap_or_default();            return Ok(SymbolLookup::Unsupported(raw));        };        let data = chart_to_quote_data(ticker, result)?;        Ok(SymbolLookup::Found { info, data })    }}/// Build a `SymbolInfo` from a chart `meta`. `None` for an instrument type/// this app does not model yet (currencies, crypto, ...).fn symbol_info(ticker: &str, meta: &Meta) -> Option<SymbolInfo> {    let kind = match meta        .instrument_type        .as_deref()        .map(str::to_uppercase)        .as_deref()    {        Some("EQUITY") => "stock",        Some("ETF") | Some("MUTUALFUND") => "etf",        Some("INDEX") => "index",        Some("FUTURE") => "future",        // No instrument type at all: fall back to the ticker's shape — a `^`        // prefix is an index, a Yahoo `=F` suffix a future, else a stock.        None => {            if ticker.starts_with('^') {                "index"            } else if ticker.ends_with("=F") {                "future"            } else {                "stock"            }        }        // A type we do not model yet (CURRENCY, CRYPTOCURRENCY, ...).        Some(_) => return None,    };    let non_empty = |s: &String| !s.trim().is_empty();    let name = meta        .long_name        .clone()        .or_else(|| meta.short_name.clone())        .filter(non_empty)        .unwrap_or_else(|| ticker.to_string());    let exchange = meta        .full_exchange_name        .clone()        .or_else(|| meta.exchange_name.clone())        .filter(non_empty);    let currency = meta        .currency        .clone()        .filter(non_empty)        .unwrap_or_else(|| "USD".to_string());    Some(SymbolInfo {        name,        kind: kind.to_string(),        exchange,        currency,    })}/// Turn a parsed chart result into a `QuoteData` (live quote + intraday bars).fn chart_to_quote_data(ticker: &str, result: ChartResult) -> Result<QuoteData> {    let meta = result.meta;    let price = meta        .regular_market_price        .ok_or_else(|| anyhow!("yahoo quote for {ticker} carried no price"))?;    let quote = Quote {        price,        prev_close: meta.previous_close.or(meta.chart_previous_close),        open: meta.regular_market_open,        day_high: meta.regular_market_day_high,        day_low: meta.regular_market_day_low,        volume: meta.regular_market_volume,        market_state: meta.market_state,        source_time: meta.regular_market_time.map(|t| t * 1000),    };    // The day's intraday bars, zipping the timestamp column against the OHLCV    // columns. A bar missing any OHLC cell is skipped.    let mut bars = Vec::new();    if let (Some(ts), Some(o)) = (result.timestamp, result.indicators.quote.into_iter().next()) {        for (i, &t) in ts.iter().enumerate() {            let cell = |v: &[Option<f64>]| v.get(i).copied().flatten();            let (Some(open), Some(high), Some(low), Some(close)) =                (cell(&o.open), cell(&o.high), cell(&o.low), cell(&o.close))            else {                continue;            };            let volume = o.volume.get(i).copied().flatten().unwrap_or(0);            bars.push(IntradayBar {                ts: t * 1000,                open,                high,                low,                close,                volume,            });        }    }    Ok(QuoteData { quote, bars })}#[async_trait]impl QuoteProvider for YahooProvider {    fn name(&self) -> &'static str {        "yahoo"    }    async fn quote(&self, ticker: &str) -> Result<QuoteData> {        match self.fetch_chart(ticker).await? {            Some(result) => chart_to_quote_data(ticker, result),            None => Err(anyhow!("yahoo returned no chart result for {ticker}")),        }    }}
added src/render.rs
@@ -0,0 +1,58 @@use axum::{    http::StatusCode,    response::{Html, IntoResponse, Response},};use chrono::Datelike;use crate::templates::RequestCtx;use crate::AppState;/// Render a template to a String with the standard page context injected.////// Every template can rely on `request`, `now`, `site`, and `base_url` being/// present; callers pass page-specific fields through `extra`.pub fn render_to_string(    state: &AppState,    template: &str,    path: &str,    extra: minijinja::Value,) -> Result<String, Response> {    let tmpl = state.env.get_template(template).map_err(|e| {        tracing::error!("template '{}': {}", template, e);        (StatusCode::INTERNAL_SERVER_ERROR, "template error").into_response()    })?;    tmpl.render(minijinja::context! {        request => RequestCtx { path: path.to_string() },        now => minijinja::context! { year => chrono::Local::now().year() },        site => minijinja::context! {            title => &state.config.site_title,            base_url => &state.config.base_url,        },        base_url => &state.config.base_url,        ..extra    })    .map_err(|e| {        tracing::error!("render '{}': {}", template, e);        (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response()    })}/// Convenience wrapper around `render_to_string` for HTML responses.pub fn render(state: &AppState, template: &str, path: &str, extra: minijinja::Value) -> Response {    match render_to_string(state, template, path, extra) {        Ok(body) => Html(body).into_response(),        Err(resp) => resp,    }}/// The themed 404 page with a 404 status. Used by the router fallback and by/// routes that look up a missing resource (e.g. an unknown ticker).pub fn not_found(state: &AppState) -> Response {    let body = render(        state,        "pages/not_found.html",        "/404",        minijinja::context! { title => "Not found" },    );    (StatusCode::NOT_FOUND, body).into_response()}
added src/routes/health.rs
@@ -0,0 +1,285 @@//! `GET /health` — the data-health page — and `GET /api/health`, its JSON feed.//!//! The page lays the background-data machinery open: each endpoint guard's//! circuit-breaker state and how much of its per-hour request budget is spent,//! every scheduler job's state / last success / next run, and a tail of the//! `fetch_log`.//!//! The page route renders an initial snapshot embedded in the HTML so it draws//! without a round trip. From there the page stays live off the Phase 5 SSE//! hub: the scheduler publishes a `health` event whenever a job changes state//! or logs a row, and the page answers each one by re-pulling `/api/health`.//! Both routes build the same [`Health`] snapshot via [`snapshot`].use axum::{extract::State, response::Response, routing::get, Json, Router};use serde::Serialize;use sqlx::SqlitePool;use crate::db::now_ms;use crate::render::render;use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/health", get(page))        .route("/api/health", get(api))}/// The whole data-health picture in one payload: every endpoint guard, every/// scheduler job, and a tail of the fetch log.#[derive(Serialize)]struct Health {    /// When this snapshot was built, UTC epoch-ms.    generated_at: i64,    /// True while any job sits in the `fetching` state — drives the page's    /// live "fetching now" banner.    fetching: bool,    endpoints: Vec<Endpoint>,    jobs: Vec<Job>,    log: Vec<LogRow>,}/// One upstream's request guard, shaped for the page.#[derive(Serialize)]struct Endpoint {    endpoint: String,    label: String,    /// `closed` | `open` | `half_open`.    state: String,    fail_streak: i64,    trip_count: i64,    opened_at: Option<i64>,    retry_at: Option<i64>,    /// Requests let through in the current rolling budget hour.    hour_count: i64,    /// Start of that hour, UTC epoch-ms (the budget resets an hour later).    hour_start: Option<i64>,    hourly_budget: i64,    /// `hour_count` as a 0..100 share of the budget, for the meter fill.    budget_pct: f64,    last_ok_at: Option<i64>,    last_error: Option<String>,    last_error_at: Option<i64>,}/// One scheduler job's `data_status` row, with a human label and description.#[derive(Serialize)]struct Job {    job: String,    label: String,    description: String,    /// `idle` | `fetching` | `ok` | `stale` | `error`.    state: String,    last_ok_at: Option<i64>,    last_error: Option<String>,    last_error_at: Option<i64>,    next_run_at: Option<i64>,    updated_at: i64,}/// One `fetch_log` row — a passthrough of the table, newest first.#[derive(Serialize, sqlx::FromRow)]struct LogRow {    job: String,    provider: String,    ticker: Option<String>,    /// `ok` | `error` | `skipped`.    status: String,    detail: Option<String>,    rows: Option<i64>,    duration_ms: Option<i64>,    started_at: i64,}/// The `endpoint_guard` columns the page needs.#[derive(sqlx::FromRow)]struct GuardRow {    endpoint: String,    state: String,    fail_streak: i64,    trip_count: i64,    opened_at: Option<i64>,    retry_at: Option<i64>,    hour_start: Option<i64>,    hour_count: i64,    hourly_budget: i64,    last_ok_at: Option<i64>,    last_error: Option<String>,    last_error_at: Option<i64>,}/// The `data_status` columns the page needs.#[derive(sqlx::FromRow)]struct StatusRow {    job: String,    state: String,    last_ok_at: Option<i64>,    last_error: Option<String>,    last_error_at: Option<i64>,    next_run_at: Option<i64>,    updated_at: i64,}/// Build the full health snapshot. Three small reads — `endpoint_guard` holds/// a handful of rows, `data_status` one per job, and the log tail is capped.async fn snapshot(pool: &SqlitePool) -> Health {    let guards: Vec<GuardRow> = sqlx::query_as(        "SELECT endpoint, state, fail_streak, trip_count, opened_at, retry_at, \                hour_start, hour_count, hourly_budget, last_ok_at, last_error, last_error_at \         FROM endpoint_guard ORDER BY endpoint",    )    .fetch_all(pool)    .await    .unwrap_or_default();    let statuses: Vec<StatusRow> = sqlx::query_as(        "SELECT job, state, last_ok_at, last_error, last_error_at, next_run_at, updated_at \         FROM data_status",    )    .fetch_all(pool)    .await    .unwrap_or_default();    let log: Vec<LogRow> = sqlx::query_as(        "SELECT job, provider, ticker, status, detail, rows, duration_ms, started_at \         FROM fetch_log ORDER BY started_at DESC LIMIT 50",    )    .fetch_all(pool)    .await    .unwrap_or_default();    let endpoints: Vec<Endpoint> = guards.into_iter().map(to_endpoint).collect();    let mut jobs: Vec<Job> = statuses.into_iter().map(to_job).collect();    jobs.sort_by_key(|j| job_rank(&j.job));    let fetching = jobs.iter().any(|j| j.state == "fetching");    Health {        generated_at: now_ms(),        fetching,        endpoints,        jobs,        log,    }}fn to_endpoint(g: GuardRow) -> Endpoint {    // Rounded to two places: this only drives a meter's CSS width, and a tidy    // figure keeps the JSON payload readable.    let budget_pct = if g.hourly_budget > 0 {        let raw = g.hour_count as f64 / g.hourly_budget as f64 * 100.0;        (raw.clamp(0.0, 100.0) * 100.0).round() / 100.0    } else {        0.0    };    Endpoint {        label: endpoint_label(&g.endpoint).to_string(),        endpoint: g.endpoint,        state: g.state,        fail_streak: g.fail_streak,        trip_count: g.trip_count,        opened_at: g.opened_at,        retry_at: g.retry_at,        hour_start: g.hour_start,        hour_count: g.hour_count,        hourly_budget: g.hourly_budget,        budget_pct,        last_ok_at: g.last_ok_at,        last_error: g.last_error,        last_error_at: g.last_error_at,    }}fn to_job(s: StatusRow) -> Job {    let (label, description) = job_meta(&s.job);    Job {        label: label.to_string(),        description: description.to_string(),        job: s.job,        state: s.state,        last_ok_at: s.last_ok_at,        last_error: s.last_error,        last_error_at: s.last_error_at,        next_run_at: s.next_run_at,        updated_at: s.updated_at,    }}/// A human label for a known upstream id.fn endpoint_label(endpoint: &str) -> &str {    match endpoint {        "stooq" => "Stooq · daily history",        "yahoo" => "Yahoo Finance · quotes & intraday",        "sec" => "SEC EDGAR · fundamentals & filings",        other => other,    }}/// Human label and one-line description per scheduler job. An unknown job (a/// future one) falls back to its raw id and no description.fn job_meta(job: &str) -> (&str, &str) {    match job {        "seed" => (            "Universe seed",            "First-run import of the curated symbol list and its deep daily history.",        ),        "history" => (            "Daily history",            "Incremental daily-bar refresh from Stooq, roughly every 6 hours.",        ),        "intraday" => (            "Intraday quotes",            "Live quotes from Yahoo for the symbols on screen, during market hours.",        ),        "daily_close" => (            "Daily close",            "Once-a-day closing snapshot of the whole universe, just after the bell.",        ),        "sec" => (            "Fundamentals & filings",            "SEC EDGAR company facts and filing history for each stock, refreshed weekly.",        ),        other => (other, ""),    }}/// Display order for the jobs list: the data pipeline's own order.fn job_rank(job: &str) -> u8 {    match job {        "seed" => 0,        "history" => 1,        "sec" => 2,        "intraday" => 3,        "daily_close" => 4,        _ => 9,    }}/// `GET /health` — the page, with the current snapshot embedded so it renders/// without a round trip; the page's script keeps it live from there.async fn page(State(state): State<AppState>) -> Response {    let snap = snapshot(&state.pool).await;    let json = serde_json::to_string(&snap).unwrap_or_else(|_| "null".to_string());    let extra = minijinja::context! {        title => "Data health",        health_json => embed_json(&json),    };    render(&state, "pages/health.html", "/health", extra)}/// `GET /api/health` — the same snapshot as JSON, polled by the page whenever/// the SSE hub signals a data change.async fn api(State(state): State<AppState>) -> Json<Health> {    Json(snapshot(&state.pool).await)}/// Escape a JSON string for safe embedding inside a `<script>` element. Only/// `<`, `>` and `&` matter (they could otherwise close the tag or open a/// comment); replaced with their `\uXXXX` forms, which a JSON parser reads back/// identically. Structural JSON never contains these characters, so a blanket/// replace touches only string contents.fn embed_json(json: &str) -> String {    json.replace('<', "\\u003c")        .replace('>', "\\u003e")        .replace('&', "\\u0026")}
added src/routes/home.rs
@@ -0,0 +1,225 @@//! `GET /` — the markets dashboard.//!//! An opinionated, no-customization read of the market: a row of sparkline//! cards for the major US indexes and the headline commodities, and the day's//! biggest movers among the curated large-cap universe. There is deliberately//! no per-user layout — the app decides what matters (see PLAN.md Phase 11).//! The full, browsable universe lives on `/search`.use std::cmp::Ordering;use std::collections::HashMap;use axum::{extract::State, response::Response, routing::get, Router};use serde::Serialize;use crate::compute::{self, Sparkline};use crate::models::SymbolCardRow;use crate::render::render;use crate::AppState;pub fn router() -> Router<AppState> {    Router::new().route("/", get(home))}/// The dashboard's curated sparkline row: the major US indexes followed by the/// headline commodities (WTI crude, gold, natural gas). Hardcoded on purpose —/// the home page is a fixed, opinionated view, not a user-built watchlist.const DASHBOARD: &[&str] = &[    "^SPX", "^DJI", "^NDX", "^NDQ", "^RUT", "^VIX", // indexes    "CL=F", "GC=F", "NG=F", // crude oil, gold, natural gas];/// How many gainers and how many losers each movers panel lists.const MOVERS_LIMIT: usize = 8;/// A symbol's latest session counts as the bars within this window of its most/// recent intraday bar. The regular-plus-extended session spans ~16h, while/// the prior session's bars sit a full ~24h earlier, so 23h cleanly isolates/// just the latest day.const SESSION_WINDOW_MS: i64 = 23 * 3600 * 1000;/// One sparkline card on the dashboard's top row.#[derive(Serialize)]struct SparkCard {    ticker: String,    name: String,    price: Option<f64>,    change_pct: Option<f64>,    /// Sparkline geometry, `None` until the symbol has intraday bars.    spark: Option<Sparkline>,    /// Colour hook: true when the day's change is not negative (or unknown).    up: bool,}/// One row in a movers panel.#[derive(Serialize, Clone)]struct Mover {    ticker: String,    name: String,    price: f64,    change_abs: f64,    change_pct: f64,    /// Width (0..100) of the row's magnitude tint, scaled to the largest    /// absolute move shown across both panels.    bar: f64,}async fn home(State(state): State<AppState>) -> Response {    let seeded: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM symbols WHERE is_seeded = 1")        .fetch_one(&state.pool)        .await        .unwrap_or(0);    // No curated universe yet: the seed has not run. Show the same guidance    // the page carried before the redesign.    if seeded == 0 {        let extra = minijinja::context! { title => "Markets", empty => true };        return render(&state, "pages/home.html", "/", extra);    }    let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM symbols")        .fetch_one(&state.pool)        .await        .unwrap_or(seeded);    let spark_cards = dashboard_cards(&state).await;    let (gainers, losers) = movers(&state).await;    let extra = minijinja::context! {        title => "Markets",        empty => false,        spark_cards => spark_cards,        gainers => gainers,        losers => losers,        total => total,    };    render(&state, "pages/home.html", "/", extra)}/// The curated dashboard symbols, in `DASHBOARD` order, each with a current/// price, the day's change, and a sparkline of the latest session's bars.async fn dashboard_cards(state: &AppState) -> Vec<SparkCard> {    // One query for the price rows. The `IN` list is built from the DASHBOARD    // const — never user input — so the placeholder count is fixed and safe.    let placeholders = vec!["?"; DASHBOARD.len()].join(",");    let sql = format!(        "SELECT s.ticker, s.name, s.kind, \           COALESCE(s.last_price, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \           COALESCE(s.prev_close, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)) \         FROM symbols s WHERE s.ticker IN ({placeholders})"    );    let mut q = sqlx::query_as::<_, SymbolCardRow>(&sql);    for t in DASHBOARD {        q = q.bind(*t);    }    let rows: Vec<SymbolCardRow> = q.fetch_all(&state.pool).await.unwrap_or_default();    let mut by_ticker: HashMap<String, SymbolCardRow> =        rows.into_iter().map(|r| (r.0.clone(), r)).collect();    let mut cards = Vec::with_capacity(DASHBOARD.len());    for &t in DASHBOARD {        // Skip a dashboard symbol the universe somehow does not hold.        let Some((ticker, name, _kind, last, prev)) = by_ticker.remove(t) else {            continue;        };        // The latest session's intraday closes, oldest first. The window keys        // off this symbol's own most recent bar (see SESSION_WINDOW_MS).        let closes: Vec<f64> = sqlx::query_scalar(            "SELECT close FROM intraday_bars \             WHERE ticker = ? \               AND ts >= (SELECT MAX(ts) FROM intraday_bars WHERE ticker = ?) - ? \             ORDER BY ts",        )        .bind(&ticker)        .bind(&ticker)        .bind(SESSION_WINDOW_MS)        .fetch_all(&state.pool)        .await        .unwrap_or_default();        let change_pct = match (last, prev) {            (Some(l), Some(p)) => Some(compute::change(l, p).pct),            _ => None,        };        cards.push(SparkCard {            ticker,            name,            price: last,            change_pct,            spark: compute::sparkline(&closes, prev),            up: change_pct.map_or(true, |p| p >= 0.0),        });    }    cards}/// The day's biggest gainers and losers among the curated large-cap stocks.////// Restricted to `is_seeded` stocks on purpose: the movers are meant to be/// names worth noticing (an AAPL down 5%), not a small user-added symbol's/// noise. ETFs, indexes, and futures are excluded — only single stocks.async fn movers(state: &AppState) -> (Vec<Mover>, Vec<Mover>) {    let rows: Vec<(String, String, Option<f64>, Option<f64>)> = sqlx::query_as(        "SELECT s.ticker, s.name, \           COALESCE(s.last_price, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \           COALESCE(s.prev_close, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)) \         FROM symbols s WHERE s.is_seeded = 1 AND s.kind = 'stock'",    )    .fetch_all(&state.pool)    .await    .unwrap_or_default();    // Keep only stocks with a computable change.    let mut all: Vec<Mover> = rows        .into_iter()        .filter_map(|(ticker, name, last, prev)| {            let (last, prev) = (last?, prev?);            if prev == 0.0 {                return None;            }            let c = compute::change(last, prev);            Some(Mover {                ticker,                name,                price: last,                change_abs: c.abs,                change_pct: c.pct,                bar: 0.0,            })        })        .collect();    if all.is_empty() {        return (Vec::new(), Vec::new());    }    // Sorted by the day's % change: gainers from the top, losers from the    // bottom (most negative first).    all.sort_by(|a, b| {        b.change_pct            .partial_cmp(&a.change_pct)            .unwrap_or(Ordering::Equal)    });    let mut gainers: Vec<Mover> = all.iter().take(MOVERS_LIMIT).cloned().collect();    let mut losers: Vec<Mover> = all.iter().rev().take(MOVERS_LIMIT).cloned().collect();    // Scale every magnitude tint to the largest absolute move on display, so a    // +1% and a -1% row read the same width.    let max_abs = gainers        .iter()        .chain(losers.iter())        .map(|m| m.change_pct.abs())        .fold(0.0_f64, f64::max);    for m in gainers.iter_mut().chain(losers.iter_mut()) {        m.bar = if max_abs > 0.0 {            (m.change_pct.abs() / max_abs * 100.0).clamp(0.0, 100.0)        } else {            0.0        };    }    (gainers, losers)}
added src/routes/mod.rs
@@ -0,0 +1,6 @@pub mod health;pub mod home;pub mod search;pub mod seo;pub mod stream;pub mod symbols;
added src/routes/search.rs
@@ -0,0 +1,115 @@//! `GET /search` — search and browse the symbol universe.//!//! With no query the page lists the whole universe (filterable by kind), so it//! doubles as a browser. With a query it matches against both ticker and//! company name. When the query names a plausible ticker the universe does not//! hold yet, the page offers to add it; the Search page's script does that//! through `POST /api/symbols` (see `routes::symbols`).use axum::{    extract::{Query, State},    response::Response,    routing::get,    Router,};use serde::Deserialize;use crate::models::{to_card, Card, SymbolCardRow};use crate::render::render;use crate::routes::symbols::valid_ticker;use crate::AppState;pub fn router() -> Router<AppState> {    Router::new().route("/search", get(search_page))}#[derive(Deserialize)]struct SearchQuery {    q: Option<String>,    /// Kind filter: `index` | `future` | `etf` | `stock`, or absent / empty for all.    kind: Option<String>,}/// Escape SQL `LIKE` wildcards in user input so a literal `%` or `_` is/// matched as itself. Paired with `ESCAPE '\'` in the query.fn escape_like(s: &str) -> String {    let mut out = String::with_capacity(s.len() + 2);    for c in s.chars() {        if matches!(c, '\\' | '%' | '_') {            out.push('\\');        }        out.push(c);    }    out}async fn search_page(Query(sq): Query<SearchQuery>, State(state): State<AppState>) -> Response {    let raw = sq.q.unwrap_or_default();    // Tickers are stored uppercase; matching is uppercased throughout. `LIKE`    // is ASCII-case-insensitive, so company names still match in any case.    let query = raw.trim().to_uppercase();    // Normalise the kind filter to one of the three known kinds, else "all".    let kind = match sq.kind.as_deref().map(str::trim).unwrap_or("") {        k @ ("index" | "future" | "etf" | "stock") => k,        _ => "",    };    let escaped = escape_like(&query);    let like = format!("%{escaped}%");    let prefix = format!("{escaped}%");    // One query covers both browse (empty `q`) and search: the `? = ''` guards    // make the ticker/name and kind filters no-ops when their input is empty.    // Ordering puts an exact ticker hit first, then ticker prefix matches,    // then indexes before futures before ETFs before stocks, then alphabetical.    let rows: Vec<SymbolCardRow> = sqlx::query_as(        "SELECT s.ticker, s.name, s.kind, \           COALESCE(s.last_price, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1)), \           COALESCE(s.prev_close, \             (SELECT close FROM daily_prices p WHERE p.ticker = s.ticker ORDER BY d DESC LIMIT 1 OFFSET 1)) \         FROM symbols s \         WHERE (? = '' OR s.ticker LIKE ? ESCAPE '\\' OR s.name LIKE ? ESCAPE '\\') \           AND (? = '' OR s.kind = ?) \         ORDER BY (s.ticker = ?) DESC, (s.ticker LIKE ? ESCAPE '\\') DESC, \                  CASE s.kind WHEN 'index' THEN 0 WHEN 'future' THEN 1 \                              WHEN 'etf' THEN 2 ELSE 3 END, s.ticker \         LIMIT 240",    )    .bind(&query)    .bind(&like)    .bind(&like)    .bind(kind)    .bind(kind)    .bind(&query)    .bind(&prefix)    .fetch_all(&state.pool)    .await    .unwrap_or_default();    let results: Vec<Card> = rows.into_iter().map(to_card).collect();    let result_count = results.len() as i64;    // Offer "Add" only on a genuine miss: nothing matched, the query is a    // plausible ticker (one `POST /api/symbols` would accept), and it is not    // already a symbol the kind filter happened to hide. Requiring zero    // results keeps the offer from nagging when a name search did find hits.    let show_add = results.is_empty()        && valid_ticker(&query).is_some()        && !sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM symbols WHERE ticker = ?)")            .bind(&query)            .fetch_one(&state.pool)            .await            .unwrap_or(false);    let extra = minijinja::context! {        title => "Search",        q => raw.trim(),        kind => kind,        results => results,        result_count => result_count,        show_add => show_add,        add_ticker => query,    };    render(&state, "pages/search.html", "/search", extra)}
added src/routes/seo.rs
@@ -0,0 +1,73 @@use axum::{    extract::State,    http::{header, HeaderMap, StatusCode},    response::{IntoResponse, Response},    routing::get,    Router,};use crate::AppState;pub fn router() -> Router<AppState> {    Router::new()        .route("/favicon.ico", get(favicon))        .route("/robots.txt", get(robots))        .route("/sitemap.xml", get(sitemap))}/// The Paper Ledger mark: a rising figures line over the accountant's double/// underline, ink-on-paper. Matches the topbar brand in `base.html`.async fn favicon() -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());    let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#211f1a"/><polyline points="14,38 26,28 37,33 52,16" fill="none" stroke="#f0ece1" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="52" cy="16" r="4" fill="#f0ece1"/><line x1="14" y1="49" x2="50" y2="49" stroke="#f0ece1" stroke-width="4.2" stroke-linecap="round"/><line x1="14" y1="56" x2="50" y2="56" stroke="#f0ece1" stroke-width="4.2" stroke-linecap="round"/></svg>"##;    (StatusCode::OK, h, svg).into_response()}async fn robots() -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "text/plain".parse().unwrap());    (StatusCode::OK, h, "User-agent: *\nAllow: /\n").into_response()}async fn sitemap(State(state): State<AppState>) -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "application/xml".parse().unwrap());    let base = if state.config.base_url.is_empty() {        "/".to_string()    } else {        format!("{}/", state.config.base_url.trim_end_matches('/'))    };    let now = chrono::Utc::now().format("%Y-%m-%d");    // Static pages plus one entry per known symbol.    let tickers: Vec<String> = sqlx::query_scalar("SELECT ticker FROM symbols ORDER BY ticker")        .fetch_all(&state.pool)        .await        .unwrap_or_default();    let mut urls = String::new();    for page in ["", "search"] {        urls.push_str(&format!(            "  <url><loc>{base}{page}</loc><lastmod>{now}</lastmod></url>\n"        ));    }    for t in tickers {        let enc = urlencoding::encode(&t);        urls.push_str(&format!(            "  <url><loc>{base}s/{enc}</loc><lastmod>{now}</lastmod></url>\n"        ));    }    let body = format!(        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\         <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n{urls}</urlset>\n"    );    (StatusCode::OK, h, body).into_response()}
added src/routes/stream.rs
@@ -0,0 +1,174 @@//! `GET /stream` — the Server-Sent Events endpoint.//!//! A browser opens one `EventSource` here, passing `?symbols=` with the//! tickers the page is showing. The connection://!  - registers viewer interest in those tickers with the [`Hub`], so the//!    scheduler's intraday job knows to poll them (and unregisters on drop);//!  - emits an initial `market` event and a `quote` snapshot so the page is//!    immediately consistent with stored state;//!  - then forwards live `quote` events for the registered tickers, and every//!    `market` and `health` event, as the hub publishes them.use std::collections::HashSet;use std::convert::Infallible;use std::sync::Arc;use axum::{    extract::{Query, State},    response::sse::{Event, KeepAlive, Sse},    routing::get,    Router,};use futures_util::Stream;use serde::Deserialize;use tokio::sync::broadcast::error::RecvError;use crate::market;use crate::stream::{Hub, QuoteUpdate, StreamEvent};use crate::AppState;pub fn router() -> Router<AppState> {    Router::new().route("/stream", get(stream))}#[derive(Deserialize)]struct StreamQuery {    /// Comma-separated tickers the client is displaying. Unknown tickers are    /// dropped (see `validate_tickers`) so a client cannot steer the    /// demand-driven poller at arbitrary upstream symbols.    symbols: Option<String>,}/// Releases a connection's interest back to the hub when its SSE stream is/// dropped — the client navigated away, closed the tab, or lost the network.struct InterestGuard {    hub: Arc<Hub>,    tickers: Vec<String>,}impl Drop for InterestGuard {    fn drop(&mut self) {        self.hub.remove_interest(&self.tickers);    }}async fn stream(    Query(q): Query<StreamQuery>,    State(state): State<AppState>,) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {    let requested: Vec<String> = q        .symbols        .unwrap_or_default()        .split(',')        .map(|s| s.trim().to_uppercase())        .filter(|s| !s.is_empty())        .collect();    let tickers = validate_tickers(&state, &requested).await;    state.hub.add_interest(&tickers);    let guard = InterestGuard {        hub: state.hub.clone(),        tickers: tickers.clone(),    };    let want: HashSet<String> = tickers.iter().cloned().collect();    // Subscribe before snapshotting so no event published in between is lost.    let rx = state.hub.subscribe();    let snapshot = quote_snapshot(&state, &tickers).await;    let session = market::session_at(chrono::Utc::now());    let body = async_stream::stream! {        // Holding the guard inside the stream ties interest to the stream's        // lifetime: when the client disconnects, the stream drops, the guard        // drops, and the interest is released.        let _guard = guard;        yield Ok::<_, Infallible>(sse_market(session.as_str()));        for qu in &snapshot {            yield Ok(sse_quote(qu));        }        let mut rx = rx;        loop {            match rx.recv().await {                Ok(StreamEvent::Quote(qu)) => {                    if want.contains(&qu.ticker) {                        yield Ok(sse_quote(&qu));                    }                }                Ok(StreamEvent::Market { session }) => {                    yield Ok(sse_market(&session));                }                Ok(StreamEvent::Health) => {                    yield Ok(sse_health());                }                // A slow client fell behind the channel: skip the dropped                // span and carry on rather than tearing the connection down.                Err(RecvError::Lagged(_)) => continue,                Err(RecvError::Closed) => break,            }        }    };    Sse::new(body).keep_alive(KeepAlive::default())}/// Keep only the requested tickers that are real seeded symbols, sorted and/// deduped. Filtering here is what bounds the demand-driven poller to the/// known universe.async fn validate_tickers(state: &AppState, requested: &[String]) -> Vec<String> {    if requested.is_empty() {        return Vec::new();    }    let known: HashSet<String> = sqlx::query_scalar("SELECT ticker FROM symbols")        .fetch_all(&state.pool)        .await        .unwrap_or_default()        .into_iter()        .collect();    let mut out: Vec<String> = requested        .iter()        .filter(|t| known.contains(*t))        .cloned()        .collect();    out.sort();    out.dedup();    out}/// The latest stored quote for each requested ticker, so a freshly connected/// page can reconcile immediately without waiting for the next poll. The/// `quotes` table holds one row per symbol (~144 max), so a full scan is cheap.async fn quote_snapshot(state: &AppState, tickers: &[String]) -> Vec<QuoteUpdate> {    if tickers.is_empty() {        return Vec::new();    }    let want: HashSet<&str> = tickers.iter().map(String::as_str).collect();    let rows: Vec<(String, f64, Option<f64>, Option<String>)> =        sqlx::query_as("SELECT ticker, price, prev_close, market_state FROM quotes")            .fetch_all(&state.pool)            .await            .unwrap_or_default();    rows.into_iter()        .filter(|(t, ..)| want.contains(t.as_str()))        .map(|(t, price, prev, state)| QuoteUpdate::new(t, price, prev, state))        .collect()}fn sse_quote(qu: &QuoteUpdate) -> Event {    Event::default()        .event("quote")        .data(serde_json::to_string(qu).unwrap_or_default())}fn sse_market(session: &str) -> Event {    Event::default()        .event("market")        .data(format!("{{\"session\":\"{session}\"}}"))}/// A content-free nudge: an open `/health` page answers it by pulling a fresh/// snapshot from `/api/health`. See `routes::health`.fn sse_health() -> Event {    Event::default().event("health").data("{}")}
added src/routes/symbols.rs
@@ -0,0 +1,812 @@use std::collections::HashMap;use axum::{    extract::{Path, Query, State},    http::StatusCode,    response::{IntoResponse, Response},    routing::{get, post},    Json, Router,};use chrono::Datelike;use serde::{Deserialize, Serialize};use crate::compute;use crate::db::now_ms;use crate::guard::{EndpointGuard, Permit};use crate::market;use crate::models::SymbolRow;use crate::providers::http;use crate::providers::yahoo::{SymbolLookup, YahooProvider};use crate::render::{not_found, render};use crate::{scheduler, AppState};pub fn router() -> Router<AppState> {    Router::new()        .route("/s/{ticker}", get(symbol_page))        .route("/api/symbols", post(add_symbol))        .route("/api/symbols/{ticker}/history", get(history_api))}/// Stats for the symbol page header and key-stats visualizations. Until/// intraday data exists (Phase 5), "day" figures come from the most recent/// daily bar. The `*_pos` fields are 0..100 marker positions for the Paper/// Ledger range bars; they are derived here so the template stays declarative.#[derive(Debug, Serialize)]struct Stats {    date: String,    open: f64,    high: f64,    low: f64,    close: f64,    volume: i64,    prev_close: Option<f64>,    change_abs: Option<f64>,    change_pct: Option<f64>,    /// Open relative to the prior close (the overnight gap).    open_change_pct: Option<f64>,    high_52w: f64,    low_52w: f64,    /// Mean daily volume over the recent ~3-month window.    avg_volume: i64,    /// Marker positions on the day's low..high range bar.    day_open_pos: f64,    day_close_pos: f64,    /// Marker positions on the 52-week low..high range bar.    yr_close_pos: f64,    yr_prev_pos: Option<f64>,    /// Today's volume on a 0..2x-average scale (the average sits at 50).    vol_fill_pct: f64,    /// Today's volume as a multiple of the average, e.g. 1.3 for 1.3x.    vol_ratio: Option<f64>,}/// The live quote shown in the symbol header, when one exists. The header/// carries `data-field` hooks, so the stream client patches these in place as/// fresh quotes arrive; this is just the server-rendered starting point.#[derive(Debug, Serialize)]struct HeaderQuote {    price: f64,    change_abs: Option<f64>,    change_pct: Option<f64>,    /// A short human label for the quote's freshness, e.g. "Live", "At close".    state_label: String,}#[derive(sqlx::FromRow)]struct QuoteRow {    price: f64,    prev_close: Option<f64>,}/// A short freshness label for the symbol header. Yahoo's chart endpoint does/// not carry a market-state field, so this comes from our own session clock/// (`market.rs`) rather than the quote.fn quote_state_label() -> &'static str {    match market::session_at(chrono::Utc::now()) {        market::Session::Pre => "Pre-market",        market::Session::Regular => "Live",        market::Session::Post => "After hours",        market::Session::Closed => "At close",    }}// ── fundamentals + filings (Phase 7) ──────────────────────────────────────/// Placeholder for a fundamentals cell the company did not report.const DASH: &str = "\u{00b7}";/// One fundamentals fact, as stored.#[derive(sqlx::FromRow)]struct FundFact {    metric: String,    period: String,    fiscal_year: i64,    fiscal_qtr: Option<i64>,    value: f64,}/// One row of a financials table: a metric label and its formatted value in/// each displayed period (`·` where the company reported nothing).#[derive(Serialize)]struct FundRow {    label: String,    cells: Vec<String>,}/// A financials table (annual or quarterly) as period columns and metric rows.#[derive(Serialize)]struct FundTable {    /// Column headers, oldest period first.    periods: Vec<String>,    rows: Vec<FundRow>,}/// Everything the symbol page's fundamentals + financials sections need.#[derive(Serialize)]struct FundamentalsView {    /// Fiscal period the ratios are based on, e.g. `FY2024`.    basis: Option<String>,    ratios: Vec<compute::Ratio>,    annual: FundTable,    quarterly: FundTable,    has_annual: bool,    has_quarterly: bool,}/// One SEC filing shaped for the page.#[derive(Serialize)]struct FilingView {    /// The raw form type, e.g. `10-K`, shown as a badge.    form: String,    /// A plain-English title derived from the form.    title: String,    filed_at: String,    period_of_report: Option<String>,    url: String,}#[derive(sqlx::FromRow)]struct FilingRow {    form: String,    filed_at: String,    period_of_report: Option<String>,    url: String,}/// The metrics shown as rows of the financials table, in order. `is_money` is/// `true` for a whole-dollar figure (shown compact, e.g. `$391.0B`) and/// `false` for a per-share figure (shown as plain dollars, e.g. `$6.08`)./// `in_quarterly` is `false` for the balance-sheet rows: only the fiscal/// year-end balance is collected, so those rows appear in the annual table/// only (see `providers::sec::classify`).struct TableMetric {    metric: &'static str,    label: &'static str,    is_money: bool,    in_quarterly: bool,}const FUND_TABLE_METRICS: &[TableMetric] = &[    TableMetric { metric: "revenue", label: "Revenue", is_money: true, in_quarterly: true },    TableMetric { metric: "net_income", label: "Net income", is_money: true, in_quarterly: true },    TableMetric { metric: "eps_diluted", label: "Diluted EPS", is_money: false, in_quarterly: true },    TableMetric { metric: "dividends_per_share", label: "Dividend / share", is_money: false, in_quarterly: true },    TableMetric { metric: "assets", label: "Total assets", is_money: true, in_quarterly: false },    TableMetric { metric: "liabilities", label: "Total liabilities", is_money: true, in_quarterly: false },    TableMetric { metric: "equity", label: "Shareholder equity", is_money: true, in_quarterly: false },];/// Format a whole-dollar figure compactly: `391035000000.0` -> `$391.0B`.fn fmt_usd_compact(v: f64) -> String {    let sign = if v < 0.0 { "-" } else { "" };    let a = v.abs();    let (n, suffix) = if a >= 1e12 {        (a / 1e12, "T")    } else if a >= 1e9 {        (a / 1e9, "B")    } else if a >= 1e6 {        (a / 1e6, "M")    } else if a >= 1e3 {        (a / 1e3, "K")    } else {        (a, "")    };    if suffix.is_empty() {        format!("{sign}${n:.0}")    } else {        format!("{sign}${n:.1}{suffix}")    }}/// Format a per-share figure: `6.08` -> `$6.08`.fn fmt_per_share(v: f64) -> String {    format!("${v:.2}")}/// A plain-English title for a filing, derived from its form type.fn filing_title(form: &str) -> String {    let base = if form.starts_with("10-K") {        "Annual report"    } else if form.starts_with("10-Q") {        "Quarterly report"    } else if form.starts_with("8-K") {        "Current report"    } else if form.starts_with("DEF 14A") {        "Proxy statement"    } else if form.starts_with("20-F") || form.starts_with("40-F") {        "Annual report"    } else if form.starts_with("6-K") {        "Interim report"    } else {        "Filing"    };    if form.ends_with("/A") {        format!("{base} (amended)")    } else {        base.to_string()    }}/// Build one financials table for the given periods (each `(fiscal_year,/// period_label)`), pulling formatted cells from `lookup`. The quarterly table/// omits the balance-sheet rows, which are only collected per fiscal year.fn fund_table(    periods: &[(i64, String)],    lookup: &HashMap<(String, String), f64>,    quarterly: bool,) -> FundTable {    let rows = FUND_TABLE_METRICS        .iter()        .filter(|m| !quarterly || m.in_quarterly)        .map(|m| {            let cells = periods                .iter()                .map(                    |(_, period)| match lookup.get(&(m.metric.to_string(), period.clone())) {                        Some(v) if m.is_money => fmt_usd_compact(*v),                        Some(v) => fmt_per_share(*v),                        None => DASH.to_string(),                    },                )                .collect();            FundRow {                label: m.label.to_string(),                cells,            }        })        .collect();    FundTable {        periods: periods.iter().map(|(_, p)| p.clone()).collect(),        rows,    }}/// Assemble the fundamentals view from a company's stored facts plus the/// latest price. `None` when the company has no fundamentals stored yet.fn build_fundamentals(facts: &[FundFact], price: Option<f64>) -> Option<FundamentalsView> {    if facts.is_empty() {        return None;    }    // (metric, period) -> value, for table-cell lookup.    let mut lookup: HashMap<(String, String), f64> = HashMap::new();    // (metric, fiscal_year) -> value, for the annual ratio inputs.    let mut annual_vals: HashMap<(&str, i64), f64> = HashMap::new();    for f in facts {        lookup.insert((f.metric.clone(), f.period.clone()), f.value);        if f.fiscal_qtr.is_none() {            annual_vals.insert((f.metric.as_str(), f.fiscal_year), f.value);        }    }    // Distinct annual periods, oldest first, most recent 5 kept.    let mut annual: Vec<(i64, String)> = facts        .iter()        .filter(|f| f.fiscal_qtr.is_none())        .map(|f| (f.fiscal_year, f.period.clone()))        .collect();    annual.sort();    annual.dedup();    let annual: Vec<(i64, String)> = annual.into_iter().rev().take(5).rev().collect();    // Distinct quarterly periods, oldest first, most recent 8 kept.    let mut quarterly: Vec<(i64, i64, String)> = facts        .iter()        .filter_map(|f| f.fiscal_qtr.map(|q| (f.fiscal_year, q, f.period.clone())))        .collect();    quarterly.sort();    quarterly.dedup();    let quarterly: Vec<(i64, String)> = quarterly        .into_iter()        .rev()        .take(8)        .rev()        .map(|(y, _, p)| (y, p))        .collect();    // Ratios run off the most recent full fiscal year.    let latest_fy = annual.last().map(|(y, _)| *y);    let ratios = match latest_fy {        Some(fy) => {            let av = |m: &str, y: i64| annual_vals.get(&(m, y)).copied();            let inputs = compute::RatioInputs {                price,                eps_diluted: av("eps_diluted", fy),                dividends_per_share: av("dividends_per_share", fy),                revenue: av("revenue", fy),                net_income: av("net_income", fy),                assets: av("assets", fy),                liabilities: av("liabilities", fy),                equity: av("equity", fy),                assets_current: av("assets_current", fy),                liabilities_current: av("liabilities_current", fy),                prev_revenue: av("revenue", fy - 1),                prev_net_income: av("net_income", fy - 1),            };            compute::compute_ratios(&inputs)        }        None => Vec::new(),    };    Some(FundamentalsView {        basis: latest_fy.map(|y| format!("FY{y}")),        ratios,        has_annual: !annual.is_empty(),        has_quarterly: !quarterly.is_empty(),        annual: fund_table(&annual, &lookup, false),        quarterly: fund_table(&quarterly, &lookup, true),    })}async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>) -> Response {    let ticker = ticker.to_uppercase();    let symbol = sqlx::query_as::<_, SymbolRow>("SELECT * FROM symbols WHERE ticker = ?")        .bind(&ticker)        .fetch_optional(&state.pool)        .await        .ok()        .flatten();    let Some(symbol) = symbol else {        return not_found(&state);    };    // The latest stored live quote, if the symbol has ever been quoted. The    // header prefers it over the last daily close.    let quote = sqlx::query_as::<_, QuoteRow>("SELECT price, prev_close FROM quotes WHERE ticker = ?")        .bind(&ticker)        .fetch_optional(&state.pool)        .await        .ok()        .flatten()        .map(|q| {            let change = q.prev_close.map(|p| compute::change(q.price, p));            HeaderQuote {                price: q.price,                change_abs: change.map(|c| c.abs),                change_pct: change.map(|c| c.pct),                state_label: quote_state_label().to_string(),            }        });    // Most recent ~1.5 years of daily bars, newest first.    let bars: Vec<(String, f64, f64, f64, f64, i64)> = sqlx::query_as(        "SELECT d, open, high, low, close, volume FROM daily_prices \         WHERE ticker = ? ORDER BY d DESC LIMIT 400",    )    .bind(&ticker)    .fetch_all(&state.pool)    .await    .unwrap_or_default();    let stats = bars.first().map(|latest| {        let (date, open, high, low, close, volume) = latest.clone();        let prev_close = bars.get(1).map(|b| b.4);        let change = prev_close.map(|p| compute::change(close, p));        // 52-week range from the most recent ~252 trading days.        let window = &bars[..bars.len().min(252)];        let high_52w = window.iter().map(|b| b.2).fold(f64::NEG_INFINITY, f64::max);        let low_52w = window.iter().map(|b| b.3).fold(f64::INFINITY, f64::min);        // Average daily volume over the recent ~3-month window (65 sessions).        let vol_window = &bars[..bars.len().min(65)];        let avg_volume =            vol_window.iter().map(|b| b.5).sum::<i64>() / vol_window.len().max(1) as i64;        let vol_ratio = (avg_volume > 0).then(|| volume as f64 / avg_volume as f64);        Stats {            date,            open,            high,            low,            close,            volume,            prev_close,            change_abs: change.map(|c| c.abs),            change_pct: change.map(|c| c.pct),            open_change_pct: prev_close.map(|p| compute::change(open, p).pct),            high_52w,            low_52w,            avg_volume,            day_open_pos: compute::pos(open, low, high),            day_close_pos: compute::pos(close, low, high),            yr_close_pos: compute::pos(close, low_52w, high_52w),            yr_prev_pos: prev_close.map(|p| compute::pos(p, low_52w, high_52w)),            // Cap the bar at 2x the average so an outlier session stays on-rail.            vol_fill_pct: vol_ratio.map_or(0.0, |r| (r / 2.0 * 100.0).clamp(0.0, 100.0)),            vol_ratio,        }    });    // Fundamentals and filings are stocks-only: ETFs and indexes do not file.    let is_stock = symbol.kind == "stock";    // Ratios price off the live quote, falling back to the last daily close.    let price = quote        .as_ref()        .map(|q| q.price)        .or_else(|| stats.as_ref().map(|s| s.close));    let fundamentals = if is_stock {        let facts: Vec<FundFact> = sqlx::query_as(            "SELECT metric, period, fiscal_year, fiscal_qtr, value \             FROM fundamentals WHERE ticker = ?",        )        .bind(&ticker)        .fetch_all(&state.pool)        .await        .unwrap_or_default();        build_fundamentals(&facts, price)    } else {        None    };    let filings: Vec<FilingView> = if is_stock {        sqlx::query_as::<_, FilingRow>(            "SELECT form, filed_at, period_of_report, url FROM filings \             WHERE ticker = ? ORDER BY filed_at DESC, accession DESC LIMIT 18",        )        .bind(&ticker)        .fetch_all(&state.pool)        .await        .unwrap_or_default()        .into_iter()        .map(|r| FilingView {            title: filing_title(&r.form),            form: r.form,            filed_at: r.filed_at,            period_of_report: r.period_of_report,            url: r.url,        })        .collect()    } else {        Vec::new()    };    let extra = minijinja::context! {        title => ticker,        symbol => symbol,        stats => stats,        quote => quote,        fundamentals => fundamentals,        filings => filings,    };    render(&state, "pages/symbol.html", &format!("/s/{ticker}"), extra)}#[derive(Deserialize)]struct HistoryQuery {    range: Option<String>,}/// One OHLCV point shaped for lightweight-charts (a `YYYY-MM-DD` `time`).#[derive(sqlx::FromRow, Serialize)]struct Candle {    time: String,    open: f64,    high: f64,    low: f64,    close: f64,    volume: i64,}/// One point of a derived overlay/indicator series, shaped for/// lightweight-charts (`time` is `YYYY-MM-DD`). Sparse: bars with no value/// yet (an average's warm-up period) are simply omitted.#[derive(Serialize)]struct LinePoint {    time: String,    value: f64,}/// The symbol chart payload (Phase 8): the candles for the selected range/// plus the indicator overlays, each already trimmed to the visible window.#[derive(Serialize)]struct HistoryResponse {    candles: Vec<Candle>,    sma50: Vec<LinePoint>,    sma200: Vec<LinePoint>,    ema21: Vec<LinePoint>,    rsi14: Vec<LinePoint>,}/// Earliest `YYYY-MM-DD` to *show* for a range button. `None` means no limit.fn range_cutoff(range: &str) -> Option<String> {    let today = chrono::Utc::now().date_naive();    match range {        "MAX" => None,        "YTD" => Some(format!("{:04}-01-01", today.year())),        "1M" => Some((today - chrono::Duration::days(31)).to_string()),        "3M" => Some((today - chrono::Duration::days(93)).to_string()),        "6M" => Some((today - chrono::Duration::days(186)).to_string()),        "5Y" => Some((today - chrono::Duration::days(1830)).to_string()),        // 1Y is the default for any unrecognised value.        _ => Some((today - chrono::Duration::days(366)).to_string()),    }}/// Trading days of history the longest indicator (the 200-day average) needs/// before the visible window, expressed as calendar days with comfortable/// slack for weekends and holidays.const INDICATOR_LOOKBACK_DAYS: i64 = 320;async fn history_api(    Path(ticker): Path<String>,    Query(q): Query<HistoryQuery>,    State(state): State<AppState>,) -> Response {    let ticker = ticker.to_uppercase();    let range = q.range.unwrap_or_else(|| "1Y".to_string());    let display_cutoff = range_cutoff(&range);    // Indicators need history *before* the visible window or their first    // values would be blank — a 200-day average needs 200 prior bars. Fetch a    // fixed lookback before the range cutoff, compute over the whole set, then    // trim every series back to the visible window.    let fetch_cutoff = display_cutoff.as_deref().map(|c| {        chrono::NaiveDate::parse_from_str(c, "%Y-%m-%d")            .map(|d| (d - chrono::Duration::days(INDICATOR_LOOKBACK_DAYS)).to_string())            .unwrap_or_else(|_| c.to_string())    });    let candles: Vec<Candle> = match &fetch_cutoff {        Some(cutoff) => {            sqlx::query_as(                "SELECT d AS time, open, high, low, close, volume FROM daily_prices \                 WHERE ticker = ? AND d >= ? ORDER BY d ASC",            )            .bind(&ticker)            .bind(cutoff)            .fetch_all(&state.pool)            .await        }        None => {            sqlx::query_as(                "SELECT d AS time, open, high, low, close, volume FROM daily_prices \                 WHERE ticker = ? ORDER BY d ASC",            )            .bind(&ticker)            .fetch_all(&state.pool)            .await        }    }    .unwrap_or_default();    // First bar inside the visible window; everything before it is lookback,    // fetched only so the indicators are correct from the very first shown bar.    let start = match &display_cutoff {        Some(c) => candles            .iter()            .position(|b| b.time.as_str() >= c.as_str())            .unwrap_or(candles.len()),        None => 0,    };    let closes: Vec<f64> = candles.iter().map(|c| c.close).collect();    // Zip a raw indicator series to its bar dates, dropping warm-up `None`s    // and the lookback bars, leaving only points inside the visible window.    let line = |series: Vec<Option<f64>>| -> Vec<LinePoint> {        series            .into_iter()            .enumerate()            .skip(start)            .filter_map(|(i, v)| {                v.map(|value| LinePoint {                    time: candles[i].time.clone(),                    value,                })            })            .collect()    };    let resp = HistoryResponse {        sma50: line(compute::sma(&closes, 50)),        sma200: line(compute::sma(&closes, 200)),        ema21: line(compute::ema(&closes, 21)),        rsi14: line(compute::rsi(&closes, 14)),        candles: candles.into_iter().skip(start).collect(),    };    Json(resp).into_response()}// ── add a symbol to the universe (Phase 9) ─────────────────────────────────/// `POST /api/symbols` request body.#[derive(Deserialize)]struct AddSymbolBody {    ticker: String,}/// `POST /api/symbols` response. `ok` is the success flag the Search page's/// script keys on; `error` carries a human message on a failure.#[derive(Serialize)]struct AddSymbolResponse {    ok: bool,    #[serde(skip_serializing_if = "Option::is_none")]    ticker: Option<String>,    #[serde(skip_serializing_if = "Option::is_none")]    name: Option<String>,    #[serde(skip_serializing_if = "Option::is_none")]    kind: Option<String>,    /// True when this call created the symbol; false when it already existed.    added: bool,    #[serde(skip_serializing_if = "Option::is_none")]    error: Option<String>,}/// Normalise and validate a user-supplied ticker. Accepts uppercase letters,/// digits and `. - ^ =` (covering `BRK.B`, `^SPX`, the Yahoo future `CL=F` and/// the like); rejects the empty string, an over-long one, an unexpected/// character, or one carrying no alphanumeric. Returns the normalised/// (trimmed, uppercased) form.////// `pub(crate)` so the Search page offers an "Add" affordance for exactly the/// strings this endpoint would accept.pub(crate) fn valid_ticker(raw: &str) -> Option<String> {    let t = raw.trim().to_uppercase();    if t.is_empty() || t.len() > 15 {        return None;    }    let charset_ok = t        .chars()        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '^' | '='));    let has_alnum = t.chars().any(|c| c.is_ascii_alphanumeric());    (charset_ok && has_alnum).then_some(t)}/// A failed `POST /api/symbols` response with a status and a human message.fn add_err(status: StatusCode, msg: impl Into<String>) -> Response {    (        status,        Json(AddSymbolResponse {            ok: false,            ticker: None,            name: None,            kind: None,            added: false,            error: Some(msg.into()),        }),    )        .into_response()}/// `POST /api/symbols` — add a symbol to the tracked universe.////// The Search page calls this when a query names a ticker the universe does/// not hold yet. The ticker is validated against Yahoo — one request that also/// yields its name, kind, exchange and currency — then the symbol row is/// inserted, the quote that same lookup returned is stored, and the history/// job is brought forward so the deep daily backfill lands within a tick. The/// Yahoo request goes through the shared endpoint guard, like every other/// outbound call (see PLAN.md's anti-spam policy).async fn add_symbol(State(state): State<AppState>, Json(body): Json<AddSymbolBody>) -> Response {    let Some(ticker) = valid_ticker(&body.ticker) else {        return add_err(            StatusCode::BAD_REQUEST,            "That does not look like a ticker symbol.",        );    };    // Already tracked? Idempotent — report it so the caller can just navigate.    let existing: Option<(String, String)> =        sqlx::query_as("SELECT name, kind FROM symbols WHERE ticker = ?")            .bind(&ticker)            .fetch_optional(&state.pool)            .await            .ok()            .flatten();    if let Some((name, kind)) = existing {        return Json(AddSymbolResponse {            ok: true,            ticker: Some(ticker),            name: Some(name),            kind: Some(kind),            added: false,            error: None,        })        .into_response();    }    // One guarded Yahoo lookup: validates the symbol and describes it.    let yahoo = YahooProvider::new(http::build_client(&state.config));    let guard = EndpointGuard::with_budget(state.pool.clone(), "yahoo", scheduler::YAHOO_BUDGET);    match guard.acquire().await {        Ok(Permit::Granted) => {}        Ok(Permit::Denied(_)) => {            return add_err(                StatusCode::SERVICE_UNAVAILABLE,                "The market data source is busy right now. Try again in a few minutes.",            );        }        Err(e) => {            tracing::error!("add_symbol guard for {ticker}: {e:#}");            return add_err(                StatusCode::INTERNAL_SERVER_ERROR,                "Something went wrong. Try again shortly.",            );        }    }    let (info, data) = match yahoo.lookup(&ticker).await {        Err(e) => {            let _ = guard.record_failure(&e).await;            tracing::warn!("add_symbol lookup {ticker}: {e:#}");            return add_err(                StatusCode::BAD_GATEWAY,                "Could not reach the market data source. Try again shortly.",            );        }        Ok(outcome) => {            // The endpoint answered — even an "unknown symbol" is a healthy            // reply, so the guard records a success either way.            let _ = guard.record_success().await;            match outcome {                SymbolLookup::Found { info, data } => (info, data),                SymbolLookup::Unknown => {                    return add_err(                        StatusCode::NOT_FOUND,                        format!("No symbol called {ticker} was found."),                    );                }                SymbolLookup::Unsupported(raw_kind) => {                    let what = raw_kind.to_lowercase();                    return add_err(                        StatusCode::UNPROCESSABLE_ENTITY,                        format!(                            "{ticker} is a {what}. Only stocks, ETFs, indexes, and futures can be added right now."                        ),                    );                }            }        }    };    // Insert the symbol. User-added, so `is_seeded = 0`.    let now = now_ms();    let inserted = sqlx::query(        "INSERT INTO symbols (ticker, name, kind, exchange, currency, is_seeded, created_at, updated_at) \         VALUES (?, ?, ?, ?, ?, 0, ?, ?) ON CONFLICT(ticker) DO NOTHING",    )    .bind(&ticker)    .bind(&info.name)    .bind(&info.kind)    .bind(&info.exchange)    .bind(&info.currency)    .bind(now)    .bind(now)    .execute(&state.pool)    .await;    if let Err(e) = inserted {        tracing::error!("add_symbol insert {ticker}: {e:#}");        return add_err(            StatusCode::INTERNAL_SERVER_ERROR,            "Could not save the symbol. Try again shortly.",        );    }    // Store the quote (and bars) the lookup already paid for, so the symbol's    // page shows a live price at once. Best-effort: a hiccup here does not    // undo a successful add.    if let Err(e) = scheduler::store_quote(&state.pool, &ticker, &data.quote).await {        tracing::warn!("add_symbol store_quote {ticker}: {e:#}");    }    if !data.bars.is_empty() {        if let Err(e) = scheduler::store_intraday(&state.pool, &ticker, &data.bars).await {            tracing::warn!("add_symbol store_intraday {ticker}: {e:#}");        }    }    // Bring the history job forward so the deep daily backfill runs on the    // next scheduler tick rather than waiting out the ~6h interval.    if let Err(e) = scheduler::schedule_next(&state.pool, "history", now).await {        tracing::warn!("add_symbol schedule history for {ticker}: {e:#}");    }    tracing::info!("add_symbol: added {ticker} ({}, {})", info.name, info.kind);    Json(AddSymbolResponse {        ok: true,        ticker: Some(ticker),        name: Some(info.name),        kind: Some(info.kind),        added: true,        error: None,    })    .into_response()}
added src/scheduler.rs
@@ -0,0 +1,1068 @@//! Background job scheduler.//!//! One long-lived tokio task wakes on a fixed tick and runs the market-data//! maintenance jobs as they fall due://!  - the first-run universe seed, once, while `meta.seed_completed` is unset;//!  - an incremental daily-history refresh from Stooq (~every 6h), touching//!    only symbols whose stored history has gone stale;//!  - market-hours intraday quotes from Yahoo for the symbols a browser is//!    actually viewing right now — demand-driven via the stream hub's interest//!    registry, so nothing is polled when nobody is watching;//!  - a once-a-day close fetch that snapshots the whole universe from Yahoo//!    shortly after the regular session ends;//!  - a prune of aged `intraday_bars` and `fetch_log` rows (~daily).//!//! Every data job records a `fetch_log` row, refreshes its `data_status` row,//! and pings the stream hub so the `/health` page reflects it live. Outbound//! Stooq calls go through the persistent `EndpointGuard` (see `src/guard.rs`),//! which paces requests and stops a job early when the circuit breaker is open//! or the hourly request budget is spent.//!//! Modelled on `status/src/scheduler.rs`, scaled down: finance's jobs run//! hours apart, not seconds, and are inherently sequential (the pacing is the//! point), so they run inline in the loop task rather than via semaphores.use std::collections::HashMap;use std::sync::Arc;use std::time::{Duration, Instant};use sqlx::SqlitePool;use tokio::task::JoinHandle;use crate::db::{get_meta, now_ms, set_meta};use crate::guard::{EndpointGuard, Permit};use crate::market;use crate::providers::sec::SecProvider;use crate::providers::yahoo::YahooProvider;use crate::providers::{    self, stooq::StooqProvider, Fact, FilingRecord, FundamentalsProvider, HistoryProvider,    IntradayBar, Quote, QuoteProvider,};use crate::stream::{Hub, QuoteUpdate, StreamEvent};use crate::{seed, Config};/// How often the loop wakes to check whether a job is due. The jobs themselves/// run hours apart; a one-minute tick is plenty responsive and nearly free/// (two small SELECTs per wake).const TICK: Duration = Duration::from_secs(60);/// Incremental daily-history refresh cadence.const HISTORY_INTERVAL_SECS: i64 = 6 * 3600;/// A symbol's daily history counts as stale once `history_synced_at` is older/// than this. Kept under 24h so the ~6-hourly job refreshes each symbol about/// once per trading day (markets emit one new daily bar a day) without/// re-fetching symbols already touched a few hours ago.const HISTORY_STALE_SECS: i64 = 20 * 3600;/// Prune cadence and the two retention windows it enforces.const PRUNE_INTERVAL_SECS: i64 = 24 * 3600;const INTRADAY_RETENTION_DAYS: i64 = 14;const FETCH_LOG_RETENTION_DAYS: i64 = 30;/// Per-hour request ceiling for the Yahoo endpoint guard. Higher than Stooq's/// default 200: an intraday tick sweeps every viewed symbol, and a daily-close/// run touches the whole ~144-symbol universe at once. Still a hard cap that/// stops a runaway loop well short of anything Yahoo would refuse./// `pub(crate)` so the add-symbol route builds its Yahoo guard with the same/// ceiling (see `routes::symbols`).pub(crate) const YAHOO_BUDGET: i64 = 1000;/// SEC fundamentals job: how often the loop checks whether the sweep is due,/// and how stale a company's SEC data may go before it is re-fetched. SEC/// filings land quarterly, so a weekly refresh is ample.const SEC_INTERVAL_SECS: i64 = 24 * 3600;const SEC_STALE_SECS: i64 = 7 * 24 * 3600;/// Per-hour request ceiling for the SEC endpoint guard. A first-run full sweep/// is one bulk ticker-map call plus two calls per stock (~220 for the starter/// universe); 600 clears that in a single budget hour while still capping a/// runaway loop well short of anything SEC's fair-access policy would refuse.const SEC_BUDGET: i64 = 600;/// Spawn the scheduler. The returned handle is normally dropped: dropping it/// detaches the task, which then runs for the lifetime of the process.pub fn spawn(pool: SqlitePool, config: Arc<Config>, hub: Arc<Hub>) -> JoinHandle<()> {    tokio::spawn(async move {        tracing::info!("[scheduler] started");        if let Err(e) = reset_states_on_boot(&pool).await {            tracing::warn!("[scheduler] reset states: {e}");        }        if let Err(e) = register_endpoints(&pool).await {            tracing::warn!("[scheduler] register endpoints: {e}");        }        let history_enabled = !config.stooq_apikey.is_empty();        if !history_enabled {            tracing::warn!(                "[scheduler] STOOQ_APIKEY unset: seed and history refresh disabled, prune still runs"            );        } else if let Err(e) = run_boot_seed(&pool, &config, &hub).await {            tracing::warn!("[scheduler] boot seed: {e:#}");        }        // SEC's fair-access policy asks consumers to identify themselves; with        // no contact email configured the SEC job stays off rather than make        // anonymous requests.        let sec_enabled = !config.sec_contact_email.is_empty();        if !sec_enabled {            tracing::warn!(                "[scheduler] SEC_CONTACT_EMAIL unset: SEC fundamentals & filings job disabled"            );        }        // Prune's last-run time is loop-local: a restart simply re-prunes once,        // which is harmless (local-only DELETEs, no network).        let mut last_prune: Option<i64> = None;        // The session last broadcast, so a transition (e.g. into after-hours)        // is pushed to connected clients exactly once.        let mut last_session: Option<market::Session> = None;        loop {            // Broadcast a market-session change so open pages update their pill.            let session = market::session_at(chrono::Utc::now());            if last_session != Some(session) {                if let Some(prev) = last_session {                    tracing::info!(                        "[scheduler] market {} -> {}",                        prev.as_str(),                        session.as_str()                    );                }                hub.publish(StreamEvent::Market {                    session: session.as_str().to_string(),                });                last_session = Some(session);            }            if history_enabled {                match is_due(&pool, "history", now_ms()).await {                    Ok(true) => {                        if let Err(e) = run_history(&pool, &config, &hub).await {                            tracing::warn!("[scheduler] history: {e:#}");                        }                    }                    Ok(false) => {}                    Err(e) => tracing::warn!("[scheduler] history due-check: {e}"),                }            }            if sec_enabled {                match is_due(&pool, "sec", now_ms()).await {                    Ok(true) => {                        if let Err(e) = run_sec(&pool, &config, &hub).await {                            tracing::warn!("[scheduler] sec: {e:#}");                        }                    }                    Ok(false) => {}                    Err(e) => tracing::warn!("[scheduler] sec due-check: {e}"),                }            }            // Intraday quotes: demand-driven (only symbols with a live viewer)            // and gated to trading hours. Does no network work otherwise.            if session.is_open() {                if let Err(e) = run_intraday(&pool, &config, &hub).await {                    tracing::warn!("[scheduler] intraday: {e:#}");                }            }            if let Err(e) = run_daily_close_if_due(&pool, &config, &hub).await {                tracing::warn!("[scheduler] daily close: {e:#}");            }            if let Err(e) = run_prune_if_due(&pool, &mut last_prune, &hub).await {                tracing::warn!("[scheduler] prune: {e:#}");            }            tokio::time::sleep(TICK).await;        }    })}/// Register the known data endpoints at startup, so the data-health page lists/// each one — with its correct hourly budget — from the first boot, rather than/// only once that endpoint's first request lazily creates its guard row. The/// ids and budgets mirror how each job below constructs its `EndpointGuard`.async fn register_endpoints(pool: &SqlitePool) -> anyhow::Result<()> {    EndpointGuard::new(pool.clone(), "stooq")        .ensure_registered()        .await?;    EndpointGuard::with_budget(pool.clone(), "yahoo", YAHOO_BUDGET)        .ensure_registered()        .await?;    EndpointGuard::with_budget(pool.clone(), "sec", SEC_BUDGET)        .ensure_registered()        .await?;    Ok(())}/// Clear any `fetching` state left behind by a crash mid-job. The owning task/// did not survive the restart, so the row must not stay stuck `fetching`.async fn reset_states_on_boot(pool: &SqlitePool) -> sqlx::Result<()> {    sqlx::query("UPDATE data_status SET state = 'idle', updated_at = ? WHERE state = 'fetching'")        .bind(now_ms())        .execute(pool)        .await?;    Ok(())}/// Run the first-run universe seed while `meta.seed_completed` is unset.////// `seed::run` is itself idempotent (symbols are upserted) and resumable/// (symbols that already hold history are skipped), so re-running it on each/// boot until the seed completes is cheap. Afterwards the incremental history/// job is deferred one full interval so it does not re-fetch, on this same/// boot, the handful of symbols the seed just touched.async fn run_boot_seed(pool: &SqlitePool, config: &Config, hub: &Hub) -> anyhow::Result<()> {    if get_meta(pool, "seed_completed").await?.as_deref() == Some("1") {        return Ok(());    }    tracing::info!("[scheduler] seed_completed unset: running first-run seed");    let started = now_ms();    mark_fetching(pool, "seed").await?;    notify_health(hub);    let stooq = StooqProvider::new(        providers::http::build_client(config),        config.stooq_apikey.clone(),    );    let t0 = Instant::now();    let result = seed::run(pool, config, &stooq).await;    let dur = t0.elapsed().as_millis() as i64;    match &result {        Ok(()) => {            let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM symbols WHERE is_seeded = 1")                .fetch_one(pool)                .await?;            let with_history: i64 = sqlx::query_scalar(                "SELECT COUNT(*) FROM symbols WHERE is_seeded = 1 AND history_last_date IS NOT NULL",            )            .fetch_one(pool)            .await?;            let detail = format!("{with_history}/{total} seeded symbols have history");            log_fetch(pool, "seed", "stooq", "ok", Some(&detail), Some(with_history), dur, started)                .await?;            mark_ok(pool, "seed", None).await?;        }        Err(e) => {            let msg = format!("{e:#}");            log_fetch(pool, "seed", "stooq", "error", Some(&msg), None, dur, started).await?;            mark_error(pool, "seed", &msg, None).await?;        }    }    // Defer the first incremental refresh: it would otherwise re-fetch the same    // still-stale symbols the seed just handled.    schedule_next(pool, "history", now_ms() + HISTORY_INTERVAL_SECS * 1000).await?;    notify_health(hub);    result}/// Incremental daily-history refresh: re-fetch only the symbols whose stored/// history has gone stale, asking Stooq for the window since each symbol's/// last stored bar. Paced and circuit-broken like the seed.async fn run_history(pool: &SqlitePool, config: &Config, hub: &Hub) -> anyhow::Result<()> {    let started = now_ms();    let next = started + HISTORY_INTERVAL_SECS * 1000;    mark_fetching(pool, "history").await?;    notify_health(hub);    // Every symbol we track, curated or user-added, is eligible: a symbol    // added through the Phase 9 add-symbol flow needs its history backfilled    // exactly as a seeded one does. (The seed itself stays curated-list-only;    // it is the only job that keys on `is_seeded`.) Futures (kind = 'future')    // are excluded: Stooq has no `=F` history, so they are live-quotes only,    // carried by the daily-close snapshot alone (see PLAN.md Phase 10).    let cutoff = started - HISTORY_STALE_SECS * 1000;    let stale: Vec<(String, Option<String>)> = sqlx::query_as(        "SELECT ticker, history_last_date FROM symbols \         WHERE kind != 'future' \           AND (history_synced_at IS NULL OR history_synced_at < ?) \         ORDER BY ticker",    )    .bind(cutoff)    .fetch_all(pool)    .await?;    if stale.is_empty() {        log_fetch(pool, "history", "stooq", "ok", Some("no stale symbols"), Some(0), 0, started)            .await?;        mark_ok(pool, "history", Some(next)).await?;        notify_health(hub);        return Ok(());    }    tracing::info!("[scheduler] history: refreshing {} stale symbols", stale.len());    let stooq = StooqProvider::new(        providers::http::build_client(config),        config.stooq_apikey.clone(),    );    let t0 = Instant::now();    // Route every request through the persistent endpoint guard: it paces the    // loop and refuses requests once the breaker opens or the hourly budget is    // spent, so the job stops cleanly instead of hammering a guarded endpoint.    let guard = EndpointGuard::new(pool.clone(), stooq.name());    let mut ok = 0usize;    let mut total_bars = 0i64;    let mut stopped: Option<String> = None;    for (ticker, last_date) in &stale {        match guard.acquire().await? {            Permit::Granted => {}            Permit::Denied(why) => {                stopped = Some(why);                break;            }        }        match stooq.daily(ticker, last_date.as_deref()).await {            Ok(bars) if !bars.is_empty() => {                guard.record_success().await?;                total_bars += bars.len() as i64;                seed::store_daily(pool, ticker, &bars).await?;                ok += 1;            }            Ok(_) => {                // A valid but empty response: the request succeeded and the                // endpoint simply had nothing new. Stamp the symbol checked so                // it is not re-fetched until it goes stale again.                guard.record_success().await?;                mark_history_checked(pool, ticker).await?;            }            Err(e) => {                guard.record_failure(&e).await?;                tracing::warn!("[scheduler] history {ticker} failed: {e:#}");            }        }    }    let dur = t0.elapsed().as_millis() as i64;    match stopped {        Some(why) => {            // The guard cut the run short (breaker open or hourly budget            // spent). That is the guard doing its job, not a failure of this            // job: record it as `skipped` and let the next cycle retry.            let detail = format!(                "stopped early ({why}); {ok}/{} refreshed, {total_bars} bars",                stale.len()            );            tracing::warn!("[scheduler] history: {detail}");            log_fetch(pool, "history", "stooq", "skipped", Some(&detail), Some(total_bars), dur, started)                .await?;            mark_ok(pool, "history", Some(next)).await?;        }        None => {            let detail = format!("{ok}/{} symbols refreshed, {total_bars} bars", stale.len());            tracing::info!("[scheduler] history: {detail}");            log_fetch(pool, "history", "stooq", "ok", Some(&detail), Some(total_bars), dur, started)                .await?;            mark_ok(pool, "history", Some(next)).await?;        }    }    notify_health(hub);    Ok(())}/// Demand-driven intraday quote refresh.////// Polls Yahoo only for the symbols a browser is currently viewing (the stream/// hub's interest registry). With nobody watching, `hub.viewed()` is empty and/// this returns at once having done no network work — that is the user's hard/// rule: poll only what is on screen. Each tick re-sweeps the viewed set, so/// one open symbol page refreshes about every minute, while an open dashboard/// (~144 symbols) refreshes more slowly as the guard's 1.5s pacing carries it.////// A clean run is recorded only in `data_status` (plus each `quotes.fetched_at`/// row); a `fetch_log` row is written only for a notable run — an error or a/// guard stop — so the minute-cadence job does not bury the log.async fn run_intraday(pool: &SqlitePool, config: &Config, hub: &Hub) -> anyhow::Result<()> {    let viewed = hub.viewed();    if viewed.is_empty() {        return Ok(());    }    let started = now_ms();    mark_fetching(pool, "intraday").await?;    notify_health(hub);    let yahoo = YahooProvider::new(providers::http::build_client(config));    let guard = EndpointGuard::with_budget(pool.clone(), yahoo.name(), YAHOO_BUDGET);    let t0 = Instant::now();    let mut ok = 0i64;    let mut errors = 0i64;    let mut stopped: Option<String> = None;    for ticker in &viewed {        match guard.acquire().await? {            Permit::Granted => {}            Permit::Denied(why) => {                stopped = Some(why);                break;            }        }        match yahoo.quote(ticker).await {            Ok(data) => {                guard.record_success().await?;                store_quote(pool, ticker, &data.quote).await?;                if !data.bars.is_empty() {                    store_intraday(pool, ticker, &data.bars).await?;                }                hub.publish(StreamEvent::Quote(QuoteUpdate::new(                    ticker.clone(),                    data.quote.price,                    data.quote.prev_close,                    data.quote.market_state.clone(),                )));                ok += 1;            }            Err(e) => {                guard.record_failure(&e).await?;                errors += 1;                tracing::warn!("[scheduler] intraday {ticker} failed: {e:#}");            }        }    }    let dur = t0.elapsed().as_millis() as i64;    if let Some(why) = stopped {        let detail = format!("stopped early ({why}); {ok} ok, {errors} errors");        tracing::warn!("[scheduler] intraday: {detail}");        log_fetch(pool, "intraday", "yahoo", "skipped", Some(&detail), Some(ok), dur, started)            .await?;        mark_ok(pool, "intraday", None).await?;    } else if ok == 0 && errors > 0 {        let detail = format!("all {errors} quotes failed");        log_fetch(pool, "intraday", "yahoo", "error", Some(&detail), Some(0), dur, started).await?;        mark_error(pool, "intraday", &detail, None).await?;    } else {        if errors > 0 {            let detail = format!("{ok} ok, {errors} errors");            log_fetch(pool, "intraday", "yahoo", "ok", Some(&detail), Some(ok), dur, started)                .await?;        }        mark_ok(pool, "intraday", None).await?;    }    notify_health(hub);    Ok(())}/// Once-a-day closing snapshot of the whole universe.////// Shortly after the regular session closes (>= 16:05 ET on a weekday), fetch/// a Yahoo quote for every seeded symbol so each one carries a same-day close/// even if nobody viewed it. Keyed on the ET trading date in `meta` so it runs/// exactly once per day; a guard stop leaves the date unset so the next cycle/// finishes the rest.async fn run_daily_close_if_due(    pool: &SqlitePool,    config: &Config,    hub: &Hub,) -> anyhow::Result<()> {    let now = chrono::Utc::now();    if !market::is_et_weekday(now) || !market::after_close(now) {        return Ok(());    }    let date = market::et_date(now);    if get_meta(pool, "daily_close_date").await?.as_deref() == Some(date.as_str()) {        return Ok(());    }    // The whole tracked universe, curated and user-added alike, so every    // symbol carries a same-day close even if nobody viewed it.    let symbols: Vec<String> =        sqlx::query_scalar("SELECT ticker FROM symbols ORDER BY ticker")            .fetch_all(pool)            .await?;    if symbols.is_empty() {        return Ok(());    }    tracing::info!("[scheduler] daily close: snapshotting {} symbols for {date}", symbols.len());    let started = now_ms();    mark_fetching(pool, "daily_close").await?;    notify_health(hub);    let yahoo = YahooProvider::new(providers::http::build_client(config));    let guard = EndpointGuard::with_budget(pool.clone(), yahoo.name(), YAHOO_BUDGET);    let t0 = Instant::now();    let mut ok = 0i64;    let mut errors = 0i64;    let mut stopped: Option<String> = None;    for ticker in &symbols {        match guard.acquire().await? {            Permit::Granted => {}            Permit::Denied(why) => {                stopped = Some(why);                break;            }        }        match yahoo.quote(ticker).await {            Ok(data) => {                guard.record_success().await?;                store_quote(pool, ticker, &data.quote).await?;                if !data.bars.is_empty() {                    store_intraday(pool, ticker, &data.bars).await?;                }                hub.publish(StreamEvent::Quote(QuoteUpdate::new(                    ticker.clone(),                    data.quote.price,                    data.quote.prev_close,                    data.quote.market_state.clone(),                )));                ok += 1;            }            Err(e) => {                guard.record_failure(&e).await?;                errors += 1;                tracing::warn!("[scheduler] daily close {ticker} failed: {e:#}");            }        }    }    let dur = t0.elapsed().as_millis() as i64;    match stopped {        Some(why) => {            // Leave `daily_close_date` unset: the next cycle retries and            // finishes the symbols this run did not reach.            let detail = format!("stopped early ({why}); {ok}/{} done", symbols.len());            tracing::warn!("[scheduler] daily close: {detail}");            log_fetch(pool, "daily_close", "yahoo", "skipped", Some(&detail), Some(ok), dur, started)                .await?;            mark_ok(pool, "daily_close", None).await?;        }        None => {            set_meta(pool, "daily_close_date", &date).await?;            let detail = format!("{ok}/{} symbols, {errors} errors", symbols.len());            tracing::info!("[scheduler] daily close: {detail}");            log_fetch(pool, "daily_close", "yahoo", "ok", Some(&detail), Some(ok), dur, started)                .await?;            mark_ok(pool, "daily_close", None).await?;        }    }    notify_health(hub);    Ok(())}/// SEC fundamentals & filings sweep.////// On the first run (and whenever new symbols appear) one bulk/// `company_tickers.json` fetch fills in each stock's CIK. Then every stock/// whose SEC data has gone stale is refreshed: its XBRL `companyfacts` into/// `fundamentals` and its submission history into `filings`. ETFs and indexes/// are skipped; they do not file with the SEC.////// Resumable like the history job: each company's two timestamps/// (`fundamentals_synced_at`, `filings_synced_at`) are stamped only on a/// successful fetch, so a guard stop simply leaves the rest for the next cycle.async fn run_sec(pool: &SqlitePool, config: &Config, hub: &Hub) -> anyhow::Result<()> {    let started = now_ms();    let next = started + SEC_INTERVAL_SECS * 1000;    mark_fetching(pool, "sec").await?;    notify_health(hub);    let sec = SecProvider::new(providers::http::build_sec_client(config));    let guard = EndpointGuard::with_budget(pool.clone(), sec.name(), SEC_BUDGET);    let t0 = Instant::now();    // 1. CIK resolution. One bulk call maps the whole market; only needed    //    while some stock still lacks a CIK.    let missing: i64 = sqlx::query_scalar(        "SELECT COUNT(*) FROM symbols WHERE kind = 'stock' AND cik IS NULL",    )    .fetch_one(pool)    .await?;    if missing > 0 {        match guard.acquire().await? {            Permit::Granted => match sec.cik_map().await {                Ok(map) => {                    guard.record_success().await?;                    let resolved = resolve_ciks(pool, &map).await?;                    tracing::info!("[scheduler] sec: resolved {resolved}/{missing} CIKs");                }                Err(e) => {                    guard.record_failure(&e).await?;                    let msg = format!("CIK map: {e:#}");                    let dur = t0.elapsed().as_millis() as i64;                    log_fetch(pool, "sec", "sec", "error", Some(&msg), None, dur, started).await?;                    mark_error(pool, "sec", &msg, Some(next)).await?;                    notify_health(hub);                    return Ok(());                }            },            Permit::Denied(why) => {                let dur = t0.elapsed().as_millis() as i64;                let detail = format!("stopped before CIK map ({why})");                log_fetch(pool, "sec", "sec", "skipped", Some(&detail), Some(0), dur, started)                    .await?;                mark_ok(pool, "sec", Some(next)).await?;                notify_health(hub);                return Ok(());            }        }    }    // 2. Stale sweep. A company is due when either of its SEC timestamps is    //    unset or older than the staleness window.    let cutoff = started - SEC_STALE_SECS * 1000;    let stale: Vec<(String, String, Option<i64>, Option<i64>)> = sqlx::query_as(        "SELECT ticker, cik, fundamentals_synced_at, filings_synced_at FROM symbols \         WHERE kind = 'stock' AND cik IS NOT NULL \           AND (fundamentals_synced_at IS NULL OR filings_synced_at IS NULL \                OR fundamentals_synced_at < ? OR filings_synced_at < ?) \         ORDER BY ticker",    )    .bind(cutoff)    .bind(cutoff)    .fetch_all(pool)    .await?;    if stale.is_empty() {        log_fetch(pool, "sec", "sec", "ok", Some("no stale companies"), Some(0), 0, started)            .await?;        mark_ok(pool, "sec", Some(next)).await?;        notify_health(hub);        return Ok(());    }    tracing::info!("[scheduler] sec: refreshing {} companies", stale.len());    // A company's metric is due when its timestamp is unset or past the cutoff.    let due = |at: Option<i64>| at.map_or(true, |t| t < cutoff);    let mut funds_ok = 0i64;    let mut filings_ok = 0i64;    let mut errors = 0i64;    let mut stopped: Option<String> = None;    'sweep: for (ticker, cik, f_at, fl_at) in &stale {        if due(*f_at) {            match guard.acquire().await? {                Permit::Granted => match sec.facts(cik).await {                    Ok(facts) => {                        guard.record_success().await?;                        store_fundamentals(pool, ticker, &facts).await?;                        mark_sec_synced(pool, ticker, "fundamentals_synced_at").await?;                        funds_ok += 1;                    }                    Err(e) => {                        guard.record_failure(&e).await?;                        errors += 1;                        tracing::warn!("[scheduler] sec facts {ticker} failed: {e:#}");                    }                },                Permit::Denied(why) => {                    stopped = Some(why);                    break 'sweep;                }            }        }        if due(*fl_at) {            match guard.acquire().await? {                Permit::Granted => match sec.filings(cik).await {                    Ok(filings) => {                        guard.record_success().await?;                        store_filings(pool, ticker, &filings).await?;                        mark_sec_synced(pool, ticker, "filings_synced_at").await?;                        filings_ok += 1;                    }                    Err(e) => {                        guard.record_failure(&e).await?;                        errors += 1;                        tracing::warn!("[scheduler] sec filings {ticker} failed: {e:#}");                    }                },                Permit::Denied(why) => {                    stopped = Some(why);                    break 'sweep;                }            }        }    }    let dur = t0.elapsed().as_millis() as i64;    let counts = format!("{funds_ok} fundamentals, {filings_ok} filings, {errors} errors");    match stopped {        Some(why) => {            let detail = format!("stopped early ({why}); {counts}");            tracing::warn!("[scheduler] sec: {detail}");            log_fetch(pool, "sec", "sec", "skipped", Some(&detail), Some(funds_ok), dur, started)                .await?;        }        None => {            tracing::info!("[scheduler] sec: {counts}");            log_fetch(pool, "sec", "sec", "ok", Some(&counts), Some(funds_ok), dur, started)                .await?;        }    }    mark_ok(pool, "sec", Some(next)).await?;    notify_health(hub);    Ok(())}/// Fill in `symbols.cik` for any stock found in the bulk SEC ticker map./// Returns how many were newly resolved.async fn resolve_ciks(pool: &SqlitePool, map: &HashMap<String, String>) -> sqlx::Result<i64> {    let stocks: Vec<String> =        sqlx::query_scalar("SELECT ticker FROM symbols WHERE kind = 'stock' AND cik IS NULL")            .fetch_all(pool)            .await?;    let mut resolved = 0;    for ticker in stocks {        if let Some(cik) = map.get(&crate::providers::sec::normalize_ticker(&ticker)) {            let now = now_ms();            sqlx::query("UPDATE symbols SET cik = ?, updated_at = ? WHERE ticker = ?")                .bind(cik)                .bind(now)                .bind(&ticker)                .execute(pool)                .await?;            resolved += 1;        }    }    Ok(resolved)}/// Stamp one of a symbol's SEC sync timestamps to now. `column` is one of two/// hardcoded literals (never user input), so interpolating it is safe.async fn mark_sec_synced(pool: &SqlitePool, ticker: &str, column: &str) -> sqlx::Result<()> {    let now = now_ms();    let sql = format!("UPDATE symbols SET {column} = ?, updated_at = ? WHERE ticker = ?");    sqlx::query(&sql)        .bind(now)        .bind(now)        .bind(ticker)        .execute(pool)        .await?;    Ok(())}/// Upsert one company's fundamental facts. Keyed on (ticker, metric, period),/// so a later filing's restated figure overwrites the prior one.async fn store_fundamentals(pool: &SqlitePool, ticker: &str, facts: &[Fact]) -> sqlx::Result<()> {    let mut tx = pool.begin().await?;    for f in facts {        sqlx::query(            "INSERT INTO fundamentals \               (ticker, metric, period, fiscal_year, fiscal_qtr, period_end, \                value, unit, form, filed_at) \             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \             ON CONFLICT(ticker, metric, period) DO UPDATE SET \               fiscal_year = excluded.fiscal_year, fiscal_qtr = excluded.fiscal_qtr, \               period_end = excluded.period_end, value = excluded.value, \               unit = excluded.unit, form = excluded.form, filed_at = excluded.filed_at",        )        .bind(ticker)        .bind(&f.metric)        .bind(&f.period)        .bind(f.fiscal_year)        .bind(f.fiscal_qtr)        .bind(&f.period_end)        .bind(f.value)        .bind(&f.unit)        .bind(&f.form)        .bind(&f.filed_at)        .execute(&mut *tx)        .await?;    }    tx.commit().await?;    Ok(())}/// Upsert one company's filing history. Keyed on (ticker, accession).async fn store_filings(    pool: &SqlitePool,    ticker: &str,    filings: &[FilingRecord],) -> sqlx::Result<()> {    let mut tx = pool.begin().await?;    for f in filings {        sqlx::query(            "INSERT INTO filings \               (ticker, accession, form, filed_at, period_of_report, \                primary_doc, url, description) \             VALUES (?, ?, ?, ?, ?, ?, ?, ?) \             ON CONFLICT(ticker, accession) DO UPDATE SET \               form = excluded.form, filed_at = excluded.filed_at, \               period_of_report = excluded.period_of_report, \               primary_doc = excluded.primary_doc, url = excluded.url, \               description = excluded.description",        )        .bind(ticker)        .bind(&f.accession)        .bind(&f.form)        .bind(&f.filed_at)        .bind(&f.period_of_report)        .bind(&f.primary_doc)        .bind(&f.url)        .bind(&f.description)        .execute(&mut *tx)        .await?;    }    tx.commit().await?;    Ok(())}/// Upsert one symbol's live quote into `quotes` and refresh the denormalized/// snapshot columns on `symbols` that the dashboard and SSE seeding read./// `pub(crate)`: the add-symbol route stores the quote its Yahoo lookup/// already returned, rather than spending a second request.pub(crate) async fn store_quote(pool: &SqlitePool, ticker: &str, q: &Quote) -> sqlx::Result<()> {    let now = now_ms();    sqlx::query(        "INSERT INTO quotes \           (ticker, price, prev_close, open, day_high, day_low, volume, \            market_state, source, source_time, fetched_at) \         VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'yahoo', ?, ?) \         ON CONFLICT(ticker) DO UPDATE SET \           price = excluded.price, prev_close = excluded.prev_close, \           open = excluded.open, day_high = excluded.day_high, \           day_low = excluded.day_low, volume = excluded.volume, \           market_state = excluded.market_state, source = excluded.source, \           source_time = excluded.source_time, fetched_at = excluded.fetched_at",    )    .bind(ticker)    .bind(q.price)    .bind(q.prev_close)    .bind(q.open)    .bind(q.day_high)    .bind(q.day_low)    .bind(q.volume)    .bind(&q.market_state)    .bind(q.source_time)    .bind(now)    .execute(pool)    .await?;    sqlx::query(        "UPDATE symbols SET last_price = ?, prev_close = ?, last_quote_at = ?, \         updated_at = ? WHERE ticker = ?",    )    .bind(q.price)    .bind(q.prev_close)    .bind(now)    .bind(now)    .bind(ticker)    .execute(pool)    .await?;    Ok(())}/// Upsert one symbol's intraday bars in a single transaction. The prune job/// trims `intraday_bars` to a rolling ~14-day window, so nothing here grows/// without bound. `pub(crate)`: also called by the add-symbol route.pub(crate) async fn store_intraday(    pool: &SqlitePool,    ticker: &str,    bars: &[IntradayBar],) -> sqlx::Result<()> {    let mut tx = pool.begin().await?;    for b in bars {        sqlx::query(            "INSERT INTO intraday_bars (ticker, ts, open, high, low, close, volume) \             VALUES (?, ?, ?, ?, ?, ?, ?) \             ON CONFLICT(ticker, ts) DO UPDATE SET \               open = excluded.open, high = excluded.high, low = excluded.low, \               close = excluded.close, volume = excluded.volume",        )        .bind(ticker)        .bind(b.ts)        .bind(b.open)        .bind(b.high)        .bind(b.low)        .bind(b.close)        .bind(b.volume)        .execute(&mut *tx)        .await?;    }    tx.commit().await?;    Ok(())}/// Prune aged rows once per `PRUNE_INTERVAL_SECS`. `intraday_bars` keeps a/// rolling ~14-day window; `fetch_log` keeps ~30 days. `daily_prices` is/// permanent and never touched here.async fn run_prune_if_due(    pool: &SqlitePool,    last: &mut Option<i64>,    hub: &Hub,) -> anyhow::Result<()> {    let now = now_ms();    if let Some(t) = *last {        if (now - t) / 1000 < PRUNE_INTERVAL_SECS {            return Ok(());        }    }    let t0 = Instant::now();    let intraday_cutoff = now - INTRADAY_RETENTION_DAYS * 86_400 * 1000;    let log_cutoff = now - FETCH_LOG_RETENTION_DAYS * 86_400 * 1000;    let bars = sqlx::query("DELETE FROM intraday_bars WHERE ts < ?")        .bind(intraday_cutoff)        .execute(pool)        .await?        .rows_affected();    let logs = sqlx::query("DELETE FROM fetch_log WHERE started_at < ?")        .bind(log_cutoff)        .execute(pool)        .await?        .rows_affected();    let dur = t0.elapsed().as_millis() as i64;    let detail = format!("{bars} intraday bars, {logs} fetch_log rows");    tracing::info!("[scheduler] prune: removed {detail}");    log_fetch(pool, "prune", "-", "ok", Some(&detail), Some((bars + logs) as i64), dur, now).await?;    *last = Some(now);    notify_health(hub);    Ok(())}// ── data_status / fetch_log helpers ───────────────────────────────────────/// Nudge any connected `/health` page to pull a fresh snapshot. Sent whenever a/// job changes state or appends a `fetch_log` row, so the data-health page/// tracks the worker in near real time. Carries no payload (see `StreamEvent`).fn notify_health(hub: &Hub) {    hub.publish(StreamEvent::Health);}/// Move a job's `data_status` row to the `fetching` state.async fn mark_fetching(pool: &SqlitePool, job: &str) -> sqlx::Result<()> {    let now = now_ms();    sqlx::query(        "INSERT INTO data_status (job, state, updated_at) VALUES (?, 'fetching', ?) \         ON CONFLICT(job) DO UPDATE SET state = 'fetching', updated_at = excluded.updated_at",    )    .bind(job)    .bind(now)    .execute(pool)    .await?;    Ok(())}/// Mark a job finished-OK, recording when it next falls due (`None` for/// one-shot jobs like the seed).async fn mark_ok(pool: &SqlitePool, job: &str, next_run_at: Option<i64>) -> sqlx::Result<()> {    let now = now_ms();    sqlx::query(        "INSERT INTO data_status (job, state, last_ok_at, next_run_at, updated_at) \         VALUES (?, 'ok', ?, ?, ?) \         ON CONFLICT(job) DO UPDATE SET \           state = 'ok', last_ok_at = excluded.last_ok_at, \           next_run_at = excluded.next_run_at, updated_at = excluded.updated_at",    )    .bind(job)    .bind(now)    .bind(next_run_at)    .bind(now)    .execute(pool)    .await?;    Ok(())}/// Mark a job failed, recording the error and when it should be retried.async fn mark_error(    pool: &SqlitePool,    job: &str,    msg: &str,    next_run_at: Option<i64>,) -> sqlx::Result<()> {    let now = now_ms();    sqlx::query(        "INSERT INTO data_status (job, state, last_error, last_error_at, next_run_at, updated_at) \         VALUES (?, 'error', ?, ?, ?, ?) \         ON CONFLICT(job) DO UPDATE SET \           state = 'error', last_error = excluded.last_error, \           last_error_at = excluded.last_error_at, next_run_at = excluded.next_run_at, \           updated_at = excluded.updated_at",    )    .bind(job)    .bind(msg)    .bind(now)    .bind(next_run_at)    .bind(now)    .execute(pool)    .await?;    Ok(())}/// Set only a job's `next_run_at` (creating an `idle` row if absent), leaving/// any existing state untouched. `pub(crate)`: the add-symbol route uses it to/// bring the history job forward so a newly added symbol is backfilled within/// a tick instead of waiting out the ~6h interval.pub(crate) async fn schedule_next(    pool: &SqlitePool,    job: &str,    next_run_at: i64,) -> sqlx::Result<()> {    let now = now_ms();    sqlx::query(        "INSERT INTO data_status (job, state, next_run_at, updated_at) VALUES (?, 'idle', ?, ?) \         ON CONFLICT(job) DO UPDATE SET \           next_run_at = excluded.next_run_at, updated_at = excluded.updated_at",    )    .bind(job)    .bind(next_run_at)    .bind(now)    .execute(pool)    .await?;    Ok(())}/// Whether `job` is due: no row yet, a null `next_run_at`, or one in the past.async fn is_due(pool: &SqlitePool, job: &str, now: i64) -> sqlx::Result<bool> {    let next: Option<Option<i64>> =        sqlx::query_scalar("SELECT next_run_at FROM data_status WHERE job = ?")            .bind(job)            .fetch_optional(pool)            .await?;    Ok(match next {        None | Some(None) => true,        Some(Some(t)) => t <= now,    })}/// Stamp a symbol as history-checked without storing bars: used when the/// upstream returned a valid response that simply held nothing new.async fn mark_history_checked(pool: &SqlitePool, ticker: &str) -> sqlx::Result<()> {    let now = now_ms();    sqlx::query("UPDATE symbols SET history_synced_at = ?, updated_at = ? WHERE ticker = ?")        .bind(now)        .bind(now)        .bind(ticker)        .execute(pool)        .await?;    Ok(())}/// Append one `fetch_log` row. `ticker` is left NULL: these are bulk jobs, so/// a run logs once rather than once per symbol.#[allow(clippy::too_many_arguments)]async fn log_fetch(    pool: &SqlitePool,    job: &str,    provider: &str,    status: &str,    detail: Option<&str>,    rows: Option<i64>,    duration_ms: i64,    started_at: i64,) -> sqlx::Result<()> {    sqlx::query(        "INSERT INTO fetch_log \           (job, provider, ticker, status, detail, rows, duration_ms, started_at, finished_at) \         VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)",    )    .bind(job)    .bind(provider)    .bind(status)    .bind(detail)    .bind(rows)    .bind(duration_ms)    .bind(started_at)    .bind(now_ms())    .execute(pool)    .await?;    Ok(())}
added src/seed.rs
@@ -0,0 +1,207 @@//! First-run universe seed: load the curated starter list, then backfill deep//! daily history for the symbols that do not have it yet.//!//! Resumable and quota-friendly: symbols that already hold history are skipped,//! so re-running `make seed` after a partial run continues where it stopped.//! Every Stooq request goes through the persistent `EndpointGuard`, which paces//! the loop and stops it early if the circuit breaker is open or the hourly//! budget is spent, instead of grinding the list against a guarded endpoint.use std::path::Path;use std::time::Instant;use anyhow::{Context, Result};use sqlx::SqlitePool;use crate::db::{now_ms, set_meta};use crate::guard::{EndpointGuard, Permit};use crate::providers::{DailyBar, HistoryProvider};use crate::Config;struct SeedSymbol {    ticker: String,    name: String,    kind: String,    exchange: Option<String>,}fn parse_universe(path: &Path) -> Result<Vec<SeedSymbol>> {    let mut rdr = csv::Reader::from_path(path)        .with_context(|| format!("opening universe file {}", path.display()))?;    let mut out = Vec::new();    for rec in rdr.records() {        let rec = rec?;        let cell = |i: usize| rec.get(i).unwrap_or("").trim().to_string();        let ticker = cell(0).to_uppercase();        if ticker.is_empty() {            continue;        }        let exchange = cell(3);        out.push(SeedSymbol {            ticker,            name: cell(1),            kind: cell(2),            exchange: if exchange.is_empty() {                None            } else {                Some(exchange)            },        });    }    Ok(out)}/// Run the seed: upsert symbols, then backfill daily history for any that/// still lack it.pub async fn run(pool: &SqlitePool, config: &Config, history: &dyn HistoryProvider) -> Result<()> {    let started = Instant::now();    let path = config.root.join("universe/starter.csv");    let symbols = parse_universe(&path)?;    tracing::info!("seed: {} symbols in {}", symbols.len(), path.display());    // Upsert every symbol. Local only, no network.    for s in &symbols {        let now = now_ms();        sqlx::query(            "INSERT INTO symbols (ticker, name, kind, exchange, is_seeded, created_at, updated_at) \             VALUES (?, ?, ?, ?, 1, ?, ?) \             ON CONFLICT(ticker) DO UPDATE SET \               name = excluded.name, kind = excluded.kind, \               exchange = excluded.exchange, is_seeded = 1, updated_at = excluded.updated_at",        )        .bind(&s.ticker)        .bind(&s.name)        .bind(&s.kind)        .bind(&s.exchange)        .bind(now)        .bind(now)        .execute(pool)        .await?;    }    // Only symbols with no history yet need a fetch. This keeps re-runs cheap    // and lets a quota-limited run resume later. Futures (kind = 'future') are    // skipped entirely: Stooq carries no `=F` history, so they are live-quotes    // only — fed solely by Yahoo's daily-close snapshot (see PLAN.md Phase 10).    let pending: Vec<String> = sqlx::query_scalar(        "SELECT ticker FROM symbols \         WHERE is_seeded = 1 AND kind != 'future' AND history_last_date IS NULL \         ORDER BY ticker",    )    .fetch_all(pool)    .await?;    if pending.is_empty() {        set_meta(pool, "seed_completed", "1").await?;        tracing::info!("seed: every symbol already has history, nothing to fetch");        return Ok(());    }    tracing::info!("seed: {} symbols need a history backfill", pending.len());    // Every Stooq request passes through the persistent endpoint guard: it    // paces the loop and, once the breaker opens or the hourly budget runs out,    // refuses further requests so the seed stops cleanly rather than grinding    // the rest of the list. A stopped seed is resumable (see below).    let guard = EndpointGuard::new(pool.clone(), history.name());    let mut ok = 0usize;    let mut stopped: Option<String> = None;    for (i, ticker) in pending.iter().enumerate() {        match guard.acquire().await? {            Permit::Granted => {}            Permit::Denied(why) => {                stopped = Some(why);                break;            }        }        match history.daily(ticker, None).await {            Ok(bars) if !bars.is_empty() => {                guard.record_success().await?;                let n = bars.len();                store_daily(pool, ticker, &bars).await?;                ok += 1;                tracing::info!(                    "seed: {ticker} <- {n} daily bars ({}/{})",                    i + 1,                    pending.len()                );            }            Ok(_) => {                // A valid but empty response: the request itself succeeded, so                // it counts as a success for the guard; the symbol simply has                // no history to store.                guard.record_success().await?;                tracing::warn!("seed: {ticker} returned no data");            }            Err(e) => {                guard.record_failure(&e).await?;                tracing::warn!("seed: {ticker} failed: {e:#}");            }        }    }    if let Some(why) = &stopped {        tracing::warn!(            "seed: stopped early — {why}; {ok} symbols backfilled and kept, \             re-run `make seed` (or restart the server) later to continue"        );    }    let remaining: i64 = sqlx::query_scalar(        "SELECT COUNT(*) FROM symbols \         WHERE is_seeded = 1 AND kind != 'future' AND history_last_date IS NULL",    )    .fetch_one(pool)    .await?;    if remaining == 0 {        set_meta(pool, "seed_completed", "1").await?;        set_meta(pool, "seed_at", &now_ms().to_string()).await?;    }    tracing::info!(        "seed: {ok} backfilled, {remaining} still missing, {:.1}s",        started.elapsed().as_secs_f64()    );    Ok(())}/// Upsert one symbol's daily bars in a single transaction and refresh its/// `symbols` history-range columns.pub async fn store_daily(pool: &SqlitePool, ticker: &str, bars: &[DailyBar]) -> Result<()> {    if bars.is_empty() {        return Ok(());    }    let mut tx = pool.begin().await?;    for b in bars {        sqlx::query(            "INSERT INTO daily_prices (ticker, d, open, high, low, close, volume) \             VALUES (?, ?, ?, ?, ?, ?, ?) \             ON CONFLICT(ticker, d) DO UPDATE SET \               open = excluded.open, high = excluded.high, low = excluded.low, \               close = excluded.close, volume = excluded.volume",        )        .bind(ticker)        .bind(&b.d)        .bind(b.open)        .bind(b.high)        .bind(b.low)        .bind(b.close)        .bind(b.volume)        .execute(&mut *tx)        .await?;    }    let first = bars.iter().map(|b| b.d.as_str()).min();    let last = bars.iter().map(|b| b.d.as_str()).max();    let now = now_ms();    sqlx::query(        "UPDATE symbols SET history_synced_at = ?, history_first_date = ?, \         history_last_date = ?, updated_at = ? WHERE ticker = ?",    )    .bind(now)    .bind(first)    .bind(last)    .bind(now)    .bind(ticker)    .execute(&mut *tx)    .await?;    tx.commit().await?;    Ok(())}
added src/stream.rs
@@ -0,0 +1,140 @@//! In-process pub/sub hub for the live data stream.//!//! One [`Hub`] lives in `AppState`. The scheduler publishes quote and//! market-session events into it; each `/stream` SSE connection subscribes and//! forwards them to a browser.//!//! The hub also carries the **interest registry** — a count, per ticker, of//! how many connected clients are currently displaying it. This is what makes//! intraday polling demand-driven: the scheduler asks [`Hub::viewed`] for the//! tickers with at least one live viewer and polls only those. With nobody//! watching, `viewed()` is empty and the intraday job does no network work at//! all (see `scheduler::run_intraday`).//!//! For a Python reader: this is a tiny in-memory pub/sub plus a `Counter` of//! who's looking at what — no external broker, just a `tokio` broadcast//! channel and a `Mutex<HashMap>`.use std::collections::HashMap;use std::sync::Mutex;use serde::Serialize;use tokio::sync::broadcast;/// Broadcast channel depth. One full universe sweep (~144 quote events) sits/// well under this; a subscriber that still lags is handled by skipping the/// gap (see `routes::stream`), never by stalling a publisher.const CHANNEL_CAPACITY: usize = 1024;/// One message pushed to every connected `/stream` client. Serialized as the/// SSE event payload; the `kind` tag is unused on the wire (the SSE `event:`/// field carries the type) but keeps the JSON self-describing.#[derive(Debug, Clone, Serialize)]#[serde(tag = "kind", rename_all = "snake_case")]pub enum StreamEvent {    Quote(QuoteUpdate),    Market { session: String },    /// A background-data state change — a job started or finished, a    /// `fetch_log` row landed. Carries no payload: it is a nudge telling an    /// open `/health` page to pull a fresh snapshot from `/api/health`.    /// Published by the scheduler; see `routes::health`.    Health,}/// A live quote, shaped for the browser to patch `data-field` nodes in place.#[derive(Debug, Clone, Serialize)]pub struct QuoteUpdate {    pub ticker: String,    pub price: f64,    pub prev_close: Option<f64>,    pub change_abs: Option<f64>,    pub change_pct: Option<f64>,    pub market_state: Option<String>,}impl QuoteUpdate {    /// Build an update, deriving the day change from `price` and `prev_close`.    pub fn new(        ticker: String,        price: f64,        prev_close: Option<f64>,        market_state: Option<String>,    ) -> Self {        let (change_abs, change_pct) = match prev_close {            Some(p) if p != 0.0 => (Some(price - p), Some((price - p) / p * 100.0)),            Some(p) => (Some(price - p), None),            None => (None, None),        };        Self {            ticker,            price,            prev_close,            change_abs,            change_pct,            market_state,        }    }}/// The pub/sub hub plus the per-ticker viewer-interest registry.pub struct Hub {    tx: broadcast::Sender<StreamEvent>,    /// ticker -> number of connected clients currently viewing it.    interest: Mutex<HashMap<String, u32>>,}impl Default for Hub {    fn default() -> Self {        Self::new()    }}impl Hub {    pub fn new() -> Self {        let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);        Self {            tx,            interest: Mutex::new(HashMap::new()),        }    }    /// Subscribe a new client. The returned receiver yields every event    /// published from now on.    pub fn subscribe(&self) -> broadcast::Receiver<StreamEvent> {        self.tx.subscribe()    }    /// Publish an event to all subscribers. A send with no subscribers is not    /// an error — it simply goes nowhere.    pub fn publish(&self, event: StreamEvent) {        let _ = self.tx.send(event);    }    /// Register that a client has begun viewing `tickers`.    pub fn add_interest(&self, tickers: &[String]) {        let mut map = self.interest.lock().unwrap();        for t in tickers {            *map.entry(t.clone()).or_insert(0) += 1;        }    }    /// Drop a client's interest in `tickers` (called when its stream ends). A    /// ticker's entry is removed once its viewer count falls back to zero, so    /// `viewed()` stays a tight set.    pub fn remove_interest(&self, tickers: &[String]) {        let mut map = self.interest.lock().unwrap();        for t in tickers {            if let Some(n) = map.get_mut(t) {                *n = n.saturating_sub(1);                if *n == 0 {                    map.remove(t);                }            }        }    }    /// The tickers with at least one live viewer right now.    pub fn viewed(&self) -> Vec<String> {        self.interest.lock().unwrap().keys().cloned().collect()    }}
added src/templates.rs
@@ -0,0 +1,250 @@use minijinja::value::Value;use minijinja::{path_loader, AutoEscape, Environment, Error, Output, State};use serde::Serialize;use serde_json::Value as JsonValue;use std::path::Path;/// Jinja2-faithful HTML formatter. Does NOT escape `/`, so vite asset URLs/// like `/static/base-abc123.js` come through clean instead of `&#x2f;...`.fn jinja2_html_formatter(out: &mut Output, state: &State, value: &Value) -> Result<(), Error> {    if value.is_safe() {        write!(out, "{value}").map_err(Error::from)?;        return Ok(());    }    let auto_escape = match state.auto_escape() {        AutoEscape::Html => true,        AutoEscape::None => false,        _ => return minijinja::escape_formatter(out, state, value),    };    if !auto_escape {        write!(out, "{value}").map_err(Error::from)?;        return Ok(());    }    if let Some(s) = value.as_str() {        write_jinja2_html(out, s).map_err(Error::from)?;    } else if value.is_undefined() || value.is_none() {        // emit nothing    } else {        let stringified = value.to_string();        write_jinja2_html(out, &stringified).map_err(Error::from)?;    }    Ok(())}fn write_jinja2_html(out: &mut Output, s: &str) -> std::fmt::Result {    let mut last = 0;    for (i, b) in s.bytes().enumerate() {        let escape = match b {            b'&' => "&amp;",            b'<' => "&lt;",            b'>' => "&gt;",            b'"' => "&#34;",            b'\'' => "&#39;",            _ => continue,        };        if last < i {            out.write_str(&s[last..i])?;        }        out.write_str(escape)?;        last = i + 1;    }    if last < s.len() {        out.write_str(&s[last..])?;    }    Ok(())}#[derive(Debug, Clone, Serialize)]pub struct RequestCtx {    pub path: String,}fn read_manifest(path: &Path) -> JsonValue {    let text = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());    serde_json::from_str(&text).unwrap_or(JsonValue::Null)}fn lookup_asset(manifest: &JsonValue, entry: &str, kind: &str) -> String {    if let Some(chunk) = manifest.get(entry) {        if kind == "css" {            if let Some(css_arr) = chunk.get("css").and_then(|v| v.as_array()) {                if let Some(first) = css_arr.first().and_then(|v| v.as_str()) {                    return format!("/static/{first}");                }            }        }        if let Some(file) = chunk.get("file").and_then(|v| v.as_str()) {            return format!("/static/{file}");        }    }    format!("/static/{entry}")}pub fn build_env(templates_dir: &Path, manifest_path: &Path) -> Environment<'static> {    let mut env = Environment::new();    env.set_loader(path_loader(templates_dir));    env.set_formatter(jinja2_html_formatter);    // Resolve content-hashed Vite asset names. Re-read the manifest per call    // in debug builds (Vite watch rewrites it); cache it once in release.    #[cfg(debug_assertions)]    {        let path = manifest_path.to_path_buf();        env.add_function(            "vite_asset",            move |entry: String, kind: Option<String>| -> Result<String, Error> {                let kind = kind.unwrap_or_else(|| "file".to_string());                Ok(lookup_asset(&read_manifest(&path), &entry, &kind))            },        );    }    #[cfg(not(debug_assertions))]    {        let manifest = read_manifest(manifest_path);        env.add_function(            "vite_asset",            move |entry: String, kind: Option<String>| -> Result<String, Error> {                let kind = kind.unwrap_or_else(|| "file".to_string());                Ok(lookup_asset(&manifest, &entry, &kind))            },        );    }    env.add_filter("money", money_filter);    env.add_filter("signed", signed_filter);    env.add_filter("pct", pct_filter);    env.add_filter("compact", compact_filter);    env.add_filter("intcomma", intcomma_filter);    env.add_filter("ago", ago_filter);    env.add_filter("urlencode", urlencode_filter);    env}/// Best-effort numeric coercion. Returns None for undefined / null / non-numeric/// so filters can fall back to a placeholder.fn as_f64(v: &Value) -> Option<f64> {    if v.is_none() || v.is_undefined() {        return None;    }    if let Some(i) = v.as_i64() {        return Some(i as f64);    }    v.as_str()        .and_then(|s| s.parse().ok())        .or_else(|| v.to_string().parse().ok())}/// Format a number with thousands separators and `dp` decimal places.fn fmt_grouped(n: f64, dp: usize) -> String {    let neg = n.is_sign_negative() && n != 0.0;    let s = format!("{:.*}", dp, n.abs());    let (int, frac) = match s.split_once('.') {        Some((i, f)) => (i.to_string(), Some(f.to_string())),        None => (s, None),    };    let mut grouped = String::new();    for (i, ch) in int.chars().rev().enumerate() {        if i > 0 && i % 3 == 0 {            grouped.insert(0, ',');        }        grouped.insert(0, ch);    }    let mut out = String::new();    if neg {        out.push('-');    }    out.push_str(&grouped);    if let Some(f) = frac {        out.push('.');        out.push_str(&f);    }    out}/// Empty-value placeholder shown when a metric is missing.const DASH: &str = "\u{00b7}";/// `1234.5` -> `$1,234.50`fn money_filter(value: Value) -> Result<String, Error> {    match as_f64(&value) {        Some(n) => Ok(format!("${}", fmt_grouped(n, 2))),        None => Ok(DASH.to_string()),    }}/// `1.2` -> `+1.20`, `-1.2` -> `-1.20`. For absolute price changes.fn signed_filter(value: Value) -> Result<String, Error> {    match as_f64(&value) {        Some(n) => {            let sign = if n > 0.0 { "+" } else { "" };            Ok(format!("{sign}{}", fmt_grouped(n, 2)))        }        None => Ok(DASH.to_string()),    }}/// `1.234` -> `+1.23%`. For percentage changes.fn pct_filter(value: Value) -> Result<String, Error> {    match as_f64(&value) {        Some(n) => {            let sign = if n > 0.0 { "+" } else { "" };            Ok(format!("{sign}{:.2}%", n))        }        None => Ok(DASH.to_string()),    }}/// `1_530_000` -> `1.53M`. For volume and market cap.fn compact_filter(value: Value) -> Result<String, Error> {    let Some(n) = as_f64(&value) else {        return Ok(DASH.to_string());    };    let abs = n.abs();    let (scaled, suffix) = if abs >= 1e12 {        (n / 1e12, "T")    } else if abs >= 1e9 {        (n / 1e9, "B")    } else if abs >= 1e6 {        (n / 1e6, "M")    } else if abs >= 1e3 {        (n / 1e3, "K")    } else {        return Ok(fmt_grouped(n, 0));    };    Ok(format!("{scaled:.2}{suffix}"))}fn intcomma_filter(value: Value) -> Result<String, Error> {    match as_f64(&value) {        Some(n) => Ok(fmt_grouped(n, 0)),        None => Ok(DASH.to_string()),    }}/// Epoch-ms -> a short relative string like `4m ago`.fn ago_filter(value: Value) -> Result<String, Error> {    let Some(ms) = value.as_i64() else {        return Ok(DASH.to_string());    };    let secs = (chrono::Utc::now().timestamp_millis() - ms) / 1000;    Ok(if secs < 5 {        "just now".to_string()    } else if secs < 60 {        format!("{secs}s ago")    } else if secs < 3600 {        format!("{}m ago", secs / 60)    } else if secs < 86_400 {        format!("{}h ago", secs / 3600)    } else {        format!("{}d ago", secs / 86_400)    })}fn urlencode_filter(value: Value) -> Result<String, Error> {    let s = value        .as_str()        .map(|s| s.to_string())        .unwrap_or_else(|| value.to_string());    Ok(urlencoding::encode(&s).into_owned())}
added templates/base.html
@@ -0,0 +1,62 @@<!doctype html><html lang="en"><head>  <meta charset="utf-8">  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">  <title>{% block title %}{{ title }}{% endblock %} · {{ site.title }}</title>  <meta name="description" content="{% block description %}Track stocks, ETFs, and indexes with live charts, fundamentals, and SEC filings.{% endblock %}">  {% if base_url %}<base href="{{ base_url }}">{% endif %}  <meta name="theme-color" content="#f0ece1">  <link rel="icon" type="image/svg+xml" href="/favicon.ico">  {% block extra_head %}{% endblock %}  <link rel="stylesheet" href="{{ vite_asset('static_src/base/index.js', 'css') }}">  {% block extra_css %}{% endblock %}</head><body>  <header class="topbar">    <div class="topbar__inner">      <a class="brand" href="/">        {# Paper Ledger mark: a rising figures line over the accountant's           double underline (the ledger sign for a finalized total). #}        <svg viewBox="0 0 32 32" fill="none" aria-hidden="true">          <polyline points="4,20 12,14 19,17 28,7" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>          <circle cx="28" cy="7" r="2" fill="currentColor"/>          <line x1="4" y1="26" x2="28" y2="26" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>          <line x1="4" y1="30" x2="28" y2="30" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>        </svg>        <span class="brand__name">{{ site.title }}</span>      </a>      <form class="search" action="/search" method="get" role="search">        <input type="search" name="q" placeholder="Search ticker or company" autocomplete="off" aria-label="Search">      </form>      <nav class="topnav">        <a href="/"{% if request.path == '/' %} class="is-active"{% endif %}>Markets</a>        <a href="/search"{% if request.path == '/search' %} class="is-active"{% endif %}>Search</a>      </nav>      {% include "includes/status_indicator.html" %}    </div>  </header>  <main>    {% block main %}{% endblock %}  </main>  <nav class="bottomnav">    <a href="/"{% if request.path == '/' %} class="is-active"{% endif %}>      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 19V11M10 19V5M16 19v-6M21 19H3"/></svg>      <span>Markets</span>    </a>    <a href="/search"{% if request.path == '/search' %} class="is-active"{% endif %}>      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>      <span>Search</span>    </a>  </nav>  <footer class="footer">    <p>{{ site.title }} · data from Stooq, Yahoo Finance, and SEC EDGAR · prices are delayed · <a href="/health">data health</a> · &copy; {{ now.year }}</p>  </footer>  <script type="module" src="{{ vite_asset('static_src/base/index.js') }}"></script>  {% block extra_js %}{% endblock %}</body></html>
added templates/includes/macros.html
@@ -0,0 +1,56 @@{# Reusable fragments. `ticker_card` renders one symbol tile; its data-ticker   and data-field hooks let the live stream client patch it in place. #}{% macro ticker_card(c) %}<a class="ticker-card" href="/s/{{ c.ticker|urlencode }}" data-ticker="{{ c.ticker }}">  <div class="ticker-card__head">    <span class="ticker-card__sym">{{ c.ticker }}</span>    <span class="ticker-card__kind">{{ c.kind }}</span>  </div>  <div class="ticker-card__name">{{ c.name }}</div>  <div class="ticker-card__foot">    <span class="ticker-card__price num" data-field="price">{{ c.price|money }}</span>    <span class="ticker-card__chg num{% if c.change_pct is none %} is-flat{% elif c.change_pct >= 0 %} is-up{% else %} is-down{% endif %}" data-field="change_pct">{{ c.change_pct|pct }}</span>  </div></a>{% endmacro %}{# `spark_card` renders one dashboard tile: an intraday sparkline with a live   price and day change. data-lo/data-hi let the stream client place a live   quote on the same y-scale and nudge the trailing point. #}{% macro spark_card(c) %}<a class="spark-card{% if c.up %} is-up-card{% else %} is-down-card{% endif %}"   href="/s/{{ c.ticker|urlencode }}" data-ticker="{{ c.ticker }}">  <div class="spark-card__head">    <span class="spark-card__sym">{{ c.ticker }}</span>    <span class="spark-card__name">{{ c.name }}</span>  </div>  {% if c.spark %}  <svg class="spark" viewBox="0 0 100 36" preserveAspectRatio="none"       data-lo="{{ c.spark.lo }}" data-hi="{{ c.spark.hi }}" aria-hidden="true">    <polygon class="spark__area" points="{{ c.spark.area }}"/>    {% if c.spark.baseline is not none %}    <line class="spark__base" x1="0" x2="100" y1="{{ c.spark.baseline }}" y2="{{ c.spark.baseline }}"/>    {% endif %}    <polyline class="spark__line" points="{{ c.spark.line }}"/>  </svg>  {% else %}  <div class="spark spark--empty">no intraday data</div>  {% endif %}  <div class="spark-card__foot">    <span class="spark-card__price num" data-field="price">{{ c.price|money }}</span>    <span class="spark-card__chg num{% if c.change_pct is none %} is-flat{% elif c.change_pct >= 0 %} is-up{% else %} is-down{% endif %}" data-field="change_pct">{{ c.change_pct|pct }}</span>  </div></a>{% endmacro %}{# `mover_row` renders one gainer/loser row. The `--bar` custom property sizes   a soft magnitude tint behind the row, scaled by the route. #}{% macro mover_row(m) %}<a class="mover {{ 'mover--up' if m.change_pct >= 0 else 'mover--down' }}"   href="/s/{{ m.ticker|urlencode }}" style="--bar: {{ m.bar }}%">  <span class="mover__sym num">{{ m.ticker }}</span>  <span class="mover__name">{{ m.name }}</span>  <span class="mover__price num">{{ m.price|money }}</span>  <span class="mover__chg num {{ 'is-up' if m.change_pct >= 0 else 'is-down' }}">{{ m.change_pct|pct }}</span></a>{% endmacro %}
added templates/includes/status_indicator.html
@@ -0,0 +1,6 @@{# Live data-connection / market-state pill. The base stream client updates   data-state and the label as SSE `market` events arrive. #}<div class="statuspill" data-role="status-pill" data-state="idle">  <span class="statuspill__dot"></span>  <span class="statuspill__label" data-role="status-label">Markets</span></div>
added templates/pages/health.html
@@ -0,0 +1,36 @@{% extends "base.html" %}{% block title %}Data health{% endblock %}{% block description %}Live status of the background data jobs, the endpoint guards, and the fetch log.{% endblock %}{% block extra_css %}<link rel="stylesheet" href="{{ vite_asset('static_src/health/index.js', 'css') }}">{% endblock %}{% block main %}<div class="wrap">  <div class="page-head">    <h1>Data health</h1>    <span class="health-asof" data-role="asof"></span>  </div>  <p class="health-lede">    Every background fetch this app makes, laid open: each upstream's request    guard and hourly budget, what each scheduler job is doing, and a live tail    of the fetch log.  </p>  {# Shown only while a job is fetching; the page script toggles `hidden`. #}  <div class="health-banner" data-role="banner" hidden></div>  <h2 class="section-title">Endpoints</h2>  <div data-role="endpoints"></div>  <h2 class="section-title">Background jobs</h2>  <div data-role="jobs"></div>  <h2 class="section-title">Activity log</h2>  <div data-role="log"></div>  {# Initial snapshot, rendered by the page script on load so there is no     flash; `<`/`>`/`&` are pre-escaped to \uXXXX (see routes::health). #}  <script type="application/json" id="health-data">{{ health_json|safe }}</script></div>{% endblock %}{% block extra_js %}<script type="module" src="{{ vite_asset('static_src/health/index.js') }}"></script>{% endblock %}
added templates/pages/home.html
@@ -0,0 +1,51 @@{% extends "base.html" %}{% block title %}Markets{% endblock %}{% block extra_css %}<link rel="stylesheet" href="{{ vite_asset('static_src/home/index.js', 'css') }}">{% endblock %}{% block main %}<div class="wrap">  {% if empty %}  <section class="empty">    <svg viewBox="0 0 64 64" fill="none" aria-hidden="true">      <polyline points="4,44 18,30 28,38 44,14 52,24 60,12" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>      <circle cx="60" cy="12" r="3.6" fill="currentColor"/>    </svg>    <h1>The market universe is empty</h1>    <p>No symbols are seeded yet. Run <code>make seed</code> to import the curated       starter list and its deep price history. This dashboard fills in once data lands.</p>  </section>  {% else %}  {% from "includes/macros.html" import spark_card, mover_row %}  <div class="page-head">    <h1>Markets</h1>    <a class="page-head__link" href="/search">Browse all {{ total }} symbols</a>  </div>  <h2 class="section-title">Indexes &amp; commodities</h2>  <div class="spark-grid">    {% for c in spark_cards %}{{ spark_card(c) }}{% endfor %}  </div>  <h2 class="section-title">Today&rsquo;s movers</h2>  <div class="movers">    <section class="movers__panel">      <h3 class="movers__title">Top gainers</h3>      {% if gainers %}      <div class="movers__list">{% for m in gainers %}{{ mover_row(m) }}{% endfor %}</div>      {% else %}      <p class="movers__empty">No price data yet.</p>      {% endif %}    </section>    <section class="movers__panel">      <h3 class="movers__title">Top losers</h3>      {% if losers %}      <div class="movers__list">{% for m in losers %}{{ mover_row(m) }}{% endfor %}</div>      {% else %}      <p class="movers__empty">No price data yet.</p>      {% endif %}    </section>  </div>  {% endif %}</div>{% endblock %}
added templates/pages/not_found.html
@@ -0,0 +1,15 @@{% extends "base.html" %}{% block title %}Not found{% endblock %}{% block main %}<div class="wrap">  <section class="empty">    <svg viewBox="0 0 64 64" fill="none" aria-hidden="true">      <polyline points="4,20 18,34 28,26 44,50 52,40 60,52" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>      <circle cx="60" cy="52" r="3.6" fill="currentColor"/>    </svg>    <h1>Page not found</h1>    <p>That route does not exist. Head back to <a href="/">the markets dashboard</a>.</p>  </section></div>{% endblock %}
added templates/pages/search.html
@@ -0,0 +1,78 @@{% extends "base.html" %}{% block title %}Search{% endblock %}{% block description %}Search and browse every stock, ETF, index, and future this app tracks.{% endblock %}{% block extra_css %}<link rel="stylesheet" href="{{ vite_asset('static_src/search/index.js', 'css') }}">{% endblock %}{% block main %}<div class="wrap">  <div class="page-head">    <h1>Search</h1>    {% if q %}    <span class="search-count">{{ result_count }} match{{ '' if result_count == 1 else 'es' }}</span>    {% else %}    <span class="search-count">{{ result_count }} symbol{{ '' if result_count == 1 else 's' }}</span>    {% endif %}  </div>  <form class="searchbar" action="/search" method="get" role="search">    <input type="search" name="q" value="{{ q }}" placeholder="Search by ticker or company name"           autocomplete="off" autofocus aria-label="Search symbols">    {# Keep the active kind filter when the text query is resubmitted. #}    <input type="hidden" name="kind" value="{{ kind }}">    <button type="submit" class="btn btn--accent">Search</button>  </form>  <div class="kind-filter" role="group" aria-label="Filter by kind">    {% for opt in [      {"value": "",       "label": "All"},      {"value": "index",  "label": "Indexes"},      {"value": "future", "label": "Futures"},      {"value": "etf",    "label": "ETFs"},      {"value": "stock",  "label": "Stocks"}    ] %}    <a class="kind-pill{% if opt.value == kind %} is-active{% endif %}"       href="/search?q={{ q|urlencode }}{% if opt.value %}&kind={{ opt.value }}{% endif %}">{{ opt.label }}</a>    {% endfor %}  </div>  {% if show_add %}  <div class="add-panel">    <div class="add-panel__text">      <p class="add-panel__title">Not tracking <span class="num">{{ add_ticker }}</span> yet</p>      <p class="add-panel__sub">Add it, and its quote, deep price history, and (for stocks)         SEC fundamentals all sync automatically.</p>    </div>    <button type="button" class="btn btn--accent" data-add-ticker="{{ add_ticker }}">Add {{ add_ticker }}</button>    <p class="add-panel__error" data-role="add-error" hidden></p>  </div>  {% endif %}  {% if results %}  {% from "includes/macros.html" import ticker_card %}  <div class="ticker-grid search-grid">    {% for c in results %}{{ ticker_card(c) }}{% endfor %}  </div>  {% elif q %}  <section class="empty empty--search">    <svg viewBox="0 0 64 64" fill="none" aria-hidden="true">      <circle cx="27" cy="27" r="18" stroke="currentColor" stroke-width="3.5"/>      <line x1="40" y1="40" x2="56" y2="56" stroke="currentColor" stroke-width="3.5" stroke-linecap="round"/>    </svg>    <h1>No matches for &ldquo;{{ q }}&rdquo;</h1>    <p>{% if show_add %}Use the button above to start tracking it.{% else %}Try a different ticker or company name.{% endif %}</p>  </section>  {% else %}  <section class="empty">    <svg viewBox="0 0 64 64" fill="none" aria-hidden="true">      <polyline points="4,44 18,30 28,38 44,14 52,24 60,12" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>      <circle cx="60" cy="12" r="3.6" fill="currentColor"/>    </svg>    <h1>The market universe is empty</h1>    <p>No symbols are seeded yet. Run <code>make seed</code> to import the curated       starter list, or add a symbol by searching for its ticker above.</p>  </section>  {% endif %}</div>{% endblock %}{% block extra_js %}<script type="module" src="{{ vite_asset('static_src/search/index.js') }}"></script>{% endblock %}
added templates/pages/symbol.html
@@ -0,0 +1,257 @@{% extends "base.html" %}{% block title %}{{ symbol.ticker }}{% endblock %}{% block description %}{{ symbol.name }} ({{ symbol.ticker }}): price chart, key stats, and history.{% endblock %}{% block extra_css %}<link rel="stylesheet" href="{{ vite_asset('static_src/symbol/index.js', 'css') }}">{% endblock %}{# One financials table (annual or quarterly). Both are rendered; the toggle   script shows one. `active` decides which is visible on load. #}{% macro fin_panel(table, period, active) %}<div class="fin__panel" data-period="{{ period }}"{% if not active %} hidden{% endif %}>  {% if table.periods %}  <div class="fin__scroll">    <table class="fin__table">      <thead>        <tr><th scope="col"></th>{% for p in table.periods %}<th scope="col" class="num">{{ p }}</th>{% endfor %}</tr>      </thead>      <tbody>        {% for row in table.rows %}        <tr>          <th scope="row">{{ row.label }}</th>          {% for c in row.cells %}<td class="num">{{ c }}</td>{% endfor %}        </tr>        {% endfor %}      </tbody>    </table>  </div>  {% else %}  <p class="fin__empty">No {{ period }} figures have been reported.</p>  {% endif %}</div>{% endmacro %}{% block main %}<div class="wrap">  <header class="sym-head">    <div class="sym-head__id">      <h1 class="sym-head__ticker">{{ symbol.ticker }}</h1>      <span class="sym-head__tag">{{ symbol.kind }}</span>      {% if symbol.exchange %}<span class="sym-head__tag">{{ symbol.exchange }}</span>{% endif %}    </div>    <div class="sym-head__name">{{ symbol.name }}</div>    {# Prefer the live quote; fall back to the most recent daily close. The       stream client patches the data-field nodes in place as quotes arrive. #}    {% if quote or stats %}    {% set price = quote.price if quote else stats.close %}    {% set chg_abs = quote.change_abs if quote else stats.change_abs %}    {% set chg_pct = quote.change_pct if quote else stats.change_pct %}    <div class="sym-head__quote" data-ticker="{{ symbol.ticker }}">      <span class="sym-head__price num" data-field="price">{{ price|money }}</span>      {% if chg_pct is not none %}      <span class="sym-head__chg num {{ 'is-up' if chg_pct >= 0 else 'is-down' }}" data-field="change">        {{ chg_abs|signed }} ({{ chg_pct|pct }})      </span>      {% endif %}      <span class="sym-head__asof">{{ quote.state_label if quote else 'last close · ' ~ stats.date }}</span>    </div>    {% endif %}  </header>  <section class="panel chart-panel">    <div class="chart-bar">      <div class="range-bar">        {% for r in ["1M", "6M", "YTD", "1Y", "5Y", "MAX"] %}        <button type="button" class="range-btn{% if r == '1Y' %} is-active{% endif %}" data-range="{{ r }}">{{ r }}</button>        {% endfor %}      </div>      {# Filled by chart.js after each load: the % / absolute move over the         visible range, so the headline change agrees with the chart. #}      <p class="range-summary" id="range-summary" hidden></p>    </div>    {# Indicator toggles. `is-active` is the initial visibility; chart.js reads       it and paints each swatch from its own ink palette. #}    <div class="ind-bar" role="group" aria-label="Chart indicators">      {% for ind in [        {"key": "sma50",  "label": "SMA 50",  "on": true,  "dot": true},        {"key": "sma200", "label": "SMA 200", "on": true,  "dot": true},        {"key": "ema21",  "label": "EMA 21",  "on": false, "dot": true},        {"key": "volume", "label": "Volume",  "on": true,  "dot": false},        {"key": "rsi",    "label": "RSI",     "on": false, "dot": true}      ] %}      <button type="button" class="ind-btn{% if ind.on %} is-active{% endif %}"              data-ind="{{ ind.key }}" aria-pressed="{{ 'true' if ind.on else 'false' }}">        {% if ind.dot %}<span class="ind-btn__dot" aria-hidden="true"></span>{% endif %}{{ ind.label }}      </button>      {% endfor %}    </div>    <div id="chart" data-ticker="{{ symbol.ticker }}"></div>  </section>  {% if stats %}  <h2 class="section-title">Key stats</h2>  <section class="keystats">    {# --- the trading day: open & close placed in the day's range --- #}    <div class="gauge">      <div class="gauge__row">        <span class="gauge__label">The day</span>        <span class="gauge__meta">open and close within the day's range</span>      </div>      <div class="track gauge__track">        <span class="track__pip track__pip--ghost" style="left:{{ stats.day_open_pos }}%"></span>        <span class="track__pip track__pip--{{ 'down' if stats.change_pct is not none and stats.change_pct < 0 else 'up' }}" style="left:{{ stats.day_close_pos }}%"></span>      </div>      <div class="gauge__ends">        <span><span class="gauge__cap">Low</span> <span class="num">{{ stats.low|money }}</span></span>        <span class="gauge__ends-r"><span class="gauge__cap">High</span> <span class="num">{{ stats.high|money }}</span></span>      </div>      <dl class="legend">        <div class="legend__item">          <dt><i class="legend__dot legend__dot--ghost"></i> Open</dt>          <dd class="num">{{ stats.open|money }}</dd>          {% if stats.open_change_pct is not none %}          <dd class="legend__delta num {{ 'is-up' if stats.open_change_pct >= 0 else 'is-down' }}">{{ stats.open_change_pct|pct }}</dd>          {% else %}<dd></dd>{% endif %}        </div>        <div class="legend__item">          <dt><i class="legend__dot legend__dot--{{ 'down' if stats.change_pct is not none and stats.change_pct < 0 else 'up' }}"></i> Close</dt>          <dd class="num">{{ stats.close|money }}</dd>          {% if stats.change_pct is not none %}          <dd class="legend__delta num {{ 'is-up' if stats.change_pct >= 0 else 'is-down' }}">{{ stats.change_pct|pct }}</dd>          {% else %}<dd></dd>{% endif %}        </div>      </dl>    </div>    {# --- 52-week range: current price & prev close along the year --- #}    <div class="gauge">      <div class="gauge__row">        <span class="gauge__label">52-week range</span>        <span class="gauge__meta">where the price sits across its year</span>      </div>      <div class="track gauge__track">        {% if stats.yr_prev_pos is not none %}        <span class="track__pip track__pip--ghost" style="left:{{ stats.yr_prev_pos }}%"></span>        {% endif %}        <span class="track__pip track__pip--{{ 'down' if stats.change_pct is not none and stats.change_pct < 0 else 'up' }}" style="left:{{ stats.yr_close_pos }}%"></span>      </div>      <div class="gauge__ends">        <span><span class="gauge__cap">52w low</span> <span class="num">{{ stats.low_52w|money }}</span></span>        <span class="gauge__ends-r"><span class="gauge__cap">52w high</span> <span class="num">{{ stats.high_52w|money }}</span></span>      </div>      <dl class="legend">        <div class="legend__item">          <dt><i class="legend__dot legend__dot--{{ 'down' if stats.change_pct is not none and stats.change_pct < 0 else 'up' }}"></i> Current</dt>          <dd class="num">{{ stats.close|money }}</dd><dd></dd>        </div>        {% if stats.prev_close is not none %}        <div class="legend__item">          <dt><i class="legend__dot legend__dot--ghost"></i> Prev close</dt>          <dd class="num">{{ stats.prev_close|money }}</dd><dd></dd>        </div>        {% endif %}      </dl>    </div>    {# --- volume vs its own 3-month average --- #}    <div class="gauge">      <div class="gauge__row">        <span class="gauge__label">Volume</span>        <span class="gauge__meta">          {% if stats.vol_ratio is not none %}{{ stats.vol_ratio|round(2) }}&times; the 3-month average{% else %}today's share volume{% endif %}        </span>      </div>      <div class="track gauge__track gauge__track--bar">        <span class="track__fill" style="width:{{ stats.vol_fill_pct }}%"></span>        <span class="track__pip track__pip--ghost" style="left:50%"></span>      </div>      <div class="gauge__ends">        <span><span class="gauge__cap">0</span></span>        <span class="gauge__ends-c"><span class="gauge__cap">avg</span></span>        <span class="gauge__ends-r"><span class="gauge__cap">2&times; avg</span></span>      </div>      <dl class="legend">        <div class="legend__item">          <dt><i class="legend__dot"></i> Today</dt>          <dd class="num">{{ stats.volume|compact }}</dd><dd></dd>        </div>        <div class="legend__item">          <dt><i class="legend__dot legend__dot--ghost"></i> 3-mo avg</dt>          <dd class="num">{{ stats.avg_volume|compact }}</dd><dd></dd>        </div>      </dl>    </div>  </section>  {% else %}  <section class="empty">    <p>No price history for {{ symbol.ticker }} yet. Run <code>make seed</code> or wait for the next history sync.</p>  </section>  {% endif %}  {# --- fundamentals + filings: stocks only --- #}  {% if symbol.kind == 'stock' %}  <h2 class="section-title">Fundamentals</h2>  {% if fundamentals and fundamentals.ratios %}  {% if fundamentals.basis %}  <p class="fund-basis">Each ratio reads {{ fundamentals.basis }} figures against the latest price, graded so the quality shows at a glance.</p>  {% endif %}  <section class="ratios">    {% for r in fundamentals.ratios %}    <div class="ratio ratio--{{ r.grade }}">      <div class="ratio__head">        <span class="ratio__label">{{ r.label }}</span>        <span class="ratio__badge ratio__badge--{{ r.grade }}">{{ r.verdict }}</span>      </div>      <div class="ratio__value num">{{ r.display }}</div>      <p class="ratio__reading">{{ r.reading }}</p>      <p class="ratio__explain">{{ r.explain }}</p>    </div>    {% endfor %}  </section>  {% else %}  <div class="fund-pending">    SEC fundamentals for {{ symbol.ticker }} have not synced yet. They land on the    next data refresh; see <a href="/health">data health</a>.  </div>  {% endif %}  {% if fundamentals and (fundamentals.has_annual or fundamentals.has_quarterly) %}  <h2 class="section-title">Financials</h2>  <section class="panel fin">    <div class="fin__toggle" role="tablist" aria-label="Reporting period">      <button type="button" class="fin__tab is-active" data-period="annual" aria-selected="true">Annual</button>      <button type="button" class="fin__tab" data-period="quarterly" aria-selected="false">Quarterly</button>    </div>    {{ fin_panel(fundamentals.annual, "annual", true) }}    {{ fin_panel(fundamentals.quarterly, "quarterly", false) }}  </section>  {% endif %}  {% if filings %}  <h2 class="section-title">Recent SEC filings</h2>  <section class="panel filings">    <ul class="filing-list">      {% for f in filings %}      <li class="filing">        <a class="filing__link" href="{{ f.url }}" target="_blank" rel="noopener noreferrer">          <span class="filing__form num">{{ f.form }}</span>          <span class="filing__body">            <span class="filing__desc">{{ f.title }}</span>            <span class="filing__meta">Filed {{ f.filed_at }}{% if f.period_of_report %} &middot; period {{ f.period_of_report }}{% endif %}</span>          </span>          <svg class="filing__ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">            <path d="M14 5h5v5M19 5l-9 9M11 5H6a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5"/>          </svg>        </a>      </li>      {% endfor %}    </ul>  </section>  {% endif %}  {% endif %}</div>{% endblock %}{% block extra_js %}<script type="module" src="{{ vite_asset('static_src/symbol/index.js') }}"></script>{% endblock %}
added universe/starter.csv
@@ -0,0 +1,154 @@ticker,name,kind,exchange^SPX,S&P 500,index,^DJI,Dow Jones Industrial Average,index,^NDX,Nasdaq 100,index,^NDQ,Nasdaq Composite,index,^RUT,Russell 2000,index,^VIX,CBOE Volatility Index,index,SPY,SPDR S&P 500 ETF Trust,etf,NYSEARCAVOO,Vanguard S&P 500 ETF,etf,NYSEARCAIVV,iShares Core S&P 500 ETF,etf,NYSEARCAVTI,Vanguard Total Stock Market ETF,etf,NYSEARCAQQQ,Invesco QQQ Trust,etf,NASDAQDIA,SPDR Dow Jones Industrial Average ETF,etf,NYSEARCAIWM,iShares Russell 2000 ETF,etf,NYSEARCAVEA,Vanguard FTSE Developed Markets ETF,etf,NYSEARCAVWO,Vanguard FTSE Emerging Markets ETF,etf,NYSEARCAVUG,Vanguard Growth ETF,etf,NYSEARCAVTV,Vanguard Value ETF,etf,NYSEARCASCHD,Schwab US Dividend Equity ETF,etf,NYSEARCAVIG,Vanguard Dividend Appreciation ETF,etf,NYSEARCAVYM,Vanguard High Dividend Yield ETF,etf,NYSEARCAVXUS,Vanguard Total International Stock ETF,etf,NASDAQBND,Vanguard Total Bond Market ETF,etf,NASDAQAGG,iShares Core US Aggregate Bond ETF,etf,NYSEARCATLT,iShares 20+ Year Treasury Bond ETF,etf,NASDAQGLD,SPDR Gold Shares,etf,NYSEARCASLV,iShares Silver Trust,etf,NYSEARCAXLK,Technology Select Sector SPDR Fund,etf,NYSEARCAXLF,Financial Select Sector SPDR Fund,etf,NYSEARCAXLE,Energy Select Sector SPDR Fund,etf,NYSEARCAXLV,Health Care Select Sector SPDR Fund,etf,NYSEARCASMH,VanEck Semiconductor ETF,etf,NASDAQARKK,ARK Innovation ETF,etf,NYSEARCAEFA,iShares MSCI EAFE ETF,etf,NYSEARCAEEM,iShares MSCI Emerging Markets ETF,etf,NYSEARCAAAPL,Apple Inc.,stock,NASDAQMSFT,Microsoft Corporation,stock,NASDAQNVDA,NVIDIA Corporation,stock,NASDAQAMZN,Amazon.com Inc.,stock,NASDAQGOOGL,Alphabet Inc. Class A,stock,NASDAQGOOG,Alphabet Inc. Class C,stock,NASDAQMETA,Meta Platforms Inc.,stock,NASDAQTSLA,Tesla Inc.,stock,NASDAQBRK.B,Berkshire Hathaway Inc. Class B,stock,NYSEAVGO,Broadcom Inc.,stock,NASDAQORCL,Oracle Corporation,stock,NYSEJPM,JPMorgan Chase & Co.,stock,NYSEV,Visa Inc.,stock,NYSEMA,Mastercard Incorporated,stock,NYSELLY,Eli Lilly and Company,stock,NYSEUNH,UnitedHealth Group Incorporated,stock,NYSEJNJ,Johnson & Johnson,stock,NYSEXOM,Exxon Mobil Corporation,stock,NYSECVX,Chevron Corporation,stock,NYSEWMT,Walmart Inc.,stock,NYSEPG,Procter & Gamble Company,stock,NYSEHD,Home Depot Inc.,stock,NYSECOST,Costco Wholesale Corporation,stock,NASDAQABBV,AbbVie Inc.,stock,NYSEMRK,Merck & Co. Inc.,stock,NYSEPEP,PepsiCo Inc.,stock,NASDAQKO,Coca-Cola Company,stock,NYSEBAC,Bank of America Corporation,stock,NYSECRM,Salesforce Inc.,stock,NYSEADBE,Adobe Inc.,stock,NASDAQAMD,Advanced Micro Devices Inc.,stock,NASDAQNFLX,Netflix Inc.,stock,NASDAQDIS,Walt Disney Company,stock,NYSETMO,Thermo Fisher Scientific Inc.,stock,NYSEABT,Abbott Laboratories,stock,NYSEACN,Accenture plc,stock,NYSELIN,Linde plc,stock,NASDAQMCD,McDonald's Corporation,stock,NYSECSCO,Cisco Systems Inc.,stock,NASDAQINTC,Intel Corporation,stock,NASDAQQCOM,QUALCOMM Incorporated,stock,NASDAQTXN,Texas Instruments Incorporated,stock,NASDAQIBM,International Business Machines Corporation,stock,NYSENOW,ServiceNow Inc.,stock,NYSEINTU,Intuit Inc.,stock,NASDAQAMAT,Applied Materials Inc.,stock,NASDAQMU,Micron Technology Inc.,stock,NASDAQLRCX,Lam Research Corporation,stock,NASDAQADI,Analog Devices Inc.,stock,NASDAQPANW,Palo Alto Networks Inc.,stock,NASDAQCRWD,CrowdStrike Holdings Inc.,stock,NASDAQSNOW,Snowflake Inc.,stock,NYSEPLTR,Palantir Technologies Inc.,stock,NASDAQNET,Cloudflare Inc.,stock,NYSEDDOG,Datadog Inc.,stock,NASDAQUBER,Uber Technologies Inc.,stock,NYSEABNB,Airbnb Inc.,stock,NASDAQSHOP,Shopify Inc.,stock,NYSEWFC,Wells Fargo & Company,stock,NYSEGS,Goldman Sachs Group Inc.,stock,NYSEMS,Morgan Stanley,stock,NYSEC,Citigroup Inc.,stock,NYSEBLK,BlackRock Inc.,stock,NYSESCHW,Charles Schwab Corporation,stock,NYSEAXP,American Express Company,stock,NYSEBX,Blackstone Inc.,stock,NYSESPGI,S&P Global Inc.,stock,NYSEPFE,Pfizer Inc.,stock,NYSEDHR,Danaher Corporation,stock,NYSEBMY,Bristol-Myers Squibb Company,stock,NYSEAMGN,Amgen Inc.,stock,NASDAQGILD,Gilead Sciences Inc.,stock,NASDAQCVS,CVS Health Corporation,stock,NYSEMDT,Medtronic plc,stock,NYSEISRG,Intuitive Surgical Inc.,stock,NASDAQLOW,Lowe's Companies Inc.,stock,NYSENKE,NIKE Inc.,stock,NYSESBUX,Starbucks Corporation,stock,NASDAQTGT,Target Corporation,stock,NYSEPM,Philip Morris International Inc.,stock,NYSEMO,Altria Group Inc.,stock,NYSEMDLZ,Mondelez International Inc.,stock,NASDAQCL,Colgate-Palmolive Company,stock,NYSECOP,ConocoPhillips,stock,NYSESLB,Schlumberger Limited,stock,NYSEBA,Boeing Company,stock,NYSECAT,Caterpillar Inc.,stock,NYSEDE,Deere & Company,stock,NYSEGE,GE Aerospace,stock,NYSEHON,Honeywell International Inc.,stock,NASDAQUPS,United Parcel Service Inc.,stock,NYSEFDX,FedEx Corporation,stock,NYSELMT,Lockheed Martin Corporation,stock,NYSERTX,RTX Corporation,stock,NYSEMMM,3M Company,stock,NYSEUNP,Union Pacific Corporation,stock,NYSET,AT&T Inc.,stock,NYSEVZ,Verizon Communications Inc.,stock,NYSETMUS,T-Mobile US Inc.,stock,NASDAQCMCSA,Comcast Corporation,stock,NASDAQF,Ford Motor Company,stock,NYSEGM,General Motors Company,stock,NYSERIVN,Rivian Automotive Inc.,stock,NASDAQCOIN,Coinbase Global Inc.,stock,NASDAQHOOD,Robinhood Markets Inc.,stock,NASDAQSOFI,SoFi Technologies Inc.,stock,NASDAQMSTR,Strategy Incorporated,stock,NASDAQDELL,Dell Technologies Inc.,stock,NYSEMRVL,Marvell Technology Inc.,stock,NASDAQSMCI,Super Micro Computer Inc.,stock,NASDAQES=F,S&P 500 E-Mini Futures,future,CMENQ=F,Nasdaq 100 E-Mini Futures,future,CMEYM=F,Dow E-Mini Futures,future,CMECL=F,Crude Oil Futures (WTI),future,NYMEXBZ=F,Brent Crude Oil Futures,future,NYMEXGC=F,Gold Futures,future,COMEXSI=F,Silver Futures,future,COMEXHG=F,Copper Futures,future,COMEXNG=F,Natural Gas Futures,future,NYMEX