On this page
What the system is made of, and the handful of constraints that explain why it is shaped this way rather than some more obvious way.
What TraderBuddy is
A self-hosted trading dashboard for about five people: live quotes, server-computed indicators, drawing tools, price alerts delivered to Slack, macro context, and a manual portfolio tracker. It runs on a three-node Talos cluster on Proxmox, behind Cloudflare.
Note
It tracks and alerts. It never trades. There is no order entry, no broker connection, no paper trading — even though the charting frontend is forked from a project that had all three. The portfolio is a manual ledger you type into. This is a product decision, not a missing feature.
Diagram source
vars: {
d2-config: {
layout-engine: elk
}
red: "#c0392b"
amber: "#c98a1e"
blue: "#2f6fb3"
}
direction: down
user: User's browser {
shape: person
}
cloudflare: Cloudflare edge {
tunnel: cloudflared {
shape: rectangle
}
}
user -> cloudflare.tunnel: HTTPS / WSS\ntrader.smkabir.com
homelab: homelab-terraform repo {
shape: rectangle
style.stroke-dash: 3
note: "Deploy manifests + image tags.\nThis repo (TraderBuddy) only builds\nand pushes images to GHCR."
}
gateway: Cilium Gateway API {
gw: gateway/public\n(sectionName: https-wildcard) {
shape: rectangle
}
route: HTTPRoute traderbuddy {
shape: rectangle
}
gw -> route
}
cloudflare.tunnel -> gateway.gw: split-horizon on LAN,\ntunnel from WAN
traderbuddy: traderbuddy namespace {
web: tb-web {
tooltip: "Vite 6 + React 19 + TS\nlightweight-charts (OpenCharts fork)\nstatic image, path: /\nAlerts page: list, create, edit, renew, delete"
}
api: tb-api (BFF) {
tooltip: "FastAPI, 2 replicas\nRoutes under /api and /ws\nOwns OIDC token exchange + refresh\nNever calls Zitadel per-request\nPolls a snapshot every 15 s for chart\nsymbols outside tb-stream's 30"
}
stream: tb-stream {
style.stroke: "${red}"
tooltip: "Singleton, strategy: Recreate\nONE Alpaca WS connection, account-wide\nLive charts only: top 30 symbols by open-chart interest\nSPOF by design — heartbeat monitored"
}
alerts: tb-alerts (CronJob) {
tooltip: "Once a trading day, after the US close\n(30 16-20 * * 1-5, America/New_York)\nSIP 1-minute bars over REST — never ticks, never IEX\nCrossings use previous state, counting only\nbars after each alert's baseline"
}
ingest: tb-ingest {
tooltip: "CronJobs, one tb-ingest image:\nsymbols · bars-retention · macro (FRED, CAPE)\nportfolio-snapshots and digest, 17:00 America/New_York"
}
image_note: same tb-api image,\ndifferent command per workload {
shape: text
style.italic: true
style.font-size: 13
}
}
gateway.route -> traderbuddy.web: PathPrefix /
gateway.route -> traderbuddy.api: PathPrefix /api, /ws
platform: platform namespace {
pg: pg-main (CNPG) {
shape: cylinder
tooltip: "3 instances, Postgres WAL replication\nDatabase per app: traderbuddy, zitadel\nDurable state lives HERE, never in Valkey"
}
pooler: PgBouncer Pooler {
shape: queue
tooltip: "poolMode: transaction\nFor apps only — NOT for Zitadel"
}
valkey: Valkey {
shape: cylinder
style.stroke: "${amber}"
tooltip: "Single instance, strategy: Recreate\nPub/sub: ticks and 1-min bars per symbol, alerts per user\nInterest sorted set + rate-limit buckets\nNo durable state — not a queue, not sessions"
}
zitadel: Zitadel {
tooltip: "OIDC @ auth.smkabir.com\n1 replica, stateless\nBFF pattern: touched only at login\n+ token refresh, never per-request"
}
pooler -> pg: transaction pooling
zitadel -> pg: direct connection\n(bypasses pooler — session features\nbreak under transaction pooling)
}
traderbuddy.api -> platform.pooler: asyncpg pool\n(sessions, watchlists, alerts, users)
traderbuddy.api -> platform.valkey: rate limiter (Valkey-backed,\nnever per-process)
traderbuddy.api -> platform.zitadel: login code exchange +\ntoken refresh ONLY
traderbuddy.alerts -> platform.pooler: alerts, alert_events
traderbuddy.ingest -> platform.pooler
traderbuddy.stream -> platform.valkey: "publish ticks:<symbol>"
traderbuddy.alerts -> platform.valkey: "publish alerts:<user_id>\n(AlertTriggered)"
traderbuddy.api -> platform.valkey: "subscribe ticks:<symbol>, alerts:<user_id>\n(fan out over /ws, per user)"
providers: External providers {
alpaca: Alpaca (WS + REST) {
tooltip: "Free plan: ONE WS connection, 30 symbols,\nreal-time from IEX only; REST 200 calls/min;\nSIP data allowed once it's 15 minutes old"
}
finnhub: Finnhub {
tooltip: "60 req/min, account-wide\nRedis token bucket + caching"
}
fred: FRED (macro series)
slack: Slack (bot token, DMs) {
tooltip: "Each user's own DM channel only.\nNever a webhook or a shared channel —\nalerts and holdings are private."
}
}
traderbuddy.stream -> providers.alpaca: single upstream WS\n(30 symbols max)
traderbuddy.api -> providers.alpaca: REST (bars, symbols,\nsnapshots beyond the 30)
traderbuddy.api -> providers.finnhub: REST, rate-limited
traderbuddy.alerts -> providers.alpaca: "REST: calendar + SIP 1-min bars\nafter the close"
traderbuddy.ingest -> providers.fred: macro ingestion
traderbuddy.alerts -> providers.slack: one DM per user\n(the day's triggers)
traderbuddy.ingest -> providers.slack: daily digest DM
homelab -> platform: deploy manifests\n(Argo CD applies)
homelab -> traderbuddy: image tags\n(tb-api / tb-web from GHCR)
user.style.fill: "${blue}"Components and the traffic between them.
The stack
| Layer | Choice | Why this one |
|---|---|---|
| Backend | Python 3.12, FastAPI, SQLAlchemy 2.0 async, asyncpg | Async throughout because most request time is provider I/O. mypy --strict over all of api/src. |
| Frontend | Vite 6, React 19, TypeScript, TanStack Query, zustand | A fork of OpenCharts (MIT), pruned to charting only. |
| Charts | lightweight-charts, pinned at 4.2.3 | Pinned exactly, with attributionLogo on — Apache-2.0 attribution is a licence condition, not a preference. |
| Tables | @tanstack/react-table 8.21.3 | Pinned to v8 on purpose; see "Deliberate deviations" below. |
| Database | PostgreSQL via CloudNativePG | Everything durable. Sessions and queues included. |
| Cache | Valkey | Short-TTL caching and rate-limit buckets only. Nothing here is a source of truth. |
| Identity | Zitadel OIDC, BFF session pattern | The browser holds an opaque signed cookie. No JWT ever reaches it. |
| Indicators | pandas-ta-classic, pinned exactly | Computed in Python only. The frontend never calculates. |
| Market data | Alpaca (bars, quotes, news, corporate actions), Finnhub (fundamentals, earnings), FRED + Shiller (macro) | All free tiers. All keys server-side. |
| Cluster | Talos v1.14, Kubernetes v1.36, Cilium v1.20 Gateway API | Gateway API, not Ingress and not Traefik. |
| Delivery | GitHub Actions → GHCR → Argo CD | Two repositories; see Deploy. |
Four workloads, one image
The Python codebase ships as a single tb-api image with four entrypoints. Each Deployment runs a different command. There are deliberately not four backend images.
| Workload | Entrypoint | Shape | Does |
|---|---|---|---|
tb-api | entrypoints/api.py | 2 replicas | The HTTP API and the /ws fan-out |
tb-stream | entrypoints/stream.py | 1 replica, Recreate | Holds the single upstream Alpaca WebSocket and republishes to Valkey pub/sub |
tb-alerts | entrypoints/alerts.py | scheduled | Evaluates price alerts after the close |
tb-ingest | entrypoints/ingest.py | scheduled | Symbols, bar retention, macro, portfolio snapshots, digest, scan |
The constraints that shaped everything
One WebSocket, thirty symbols
Alpaca's free plan allows one WebSocket connection per account and 30 subscribed symbols. That single fact produces most of the streaming design:
tb-streamis a singleton withstrategy: Recreate, because two pods briefly coexisting means a406 connection limit exceededand no live prices for anyone.- Users express interest in symbols; a budget allocator picks which 30 get the live feed, holding a symbol for 60 seconds after interest drops so a tab switch doesn't thrash subscriptions.
- Symbols outside the budget are polled from a cached snapshot about every 15 seconds and labelled delayed in the UI rather than silently shown as live.
Diagram source
#
# The component diagram (architecture.d2) shows these services exist. This one shows
# the two data paths, because they are deliberately separate: live ticks are for eyes
# (a 30-symbol WebSocket budget, a singleton upstream, Cloudflare idling quiet
# sockets), and alerts never touch them (REST SIP bars, once, after the close).
vars: {
d2-config: {
layout-engine: elk
}
red: "#c0392b"
amber: "#c98a1e"
}
direction: right
# Live charts — market hours, at most 30 symbols streamed
browsers: "Browsers\n(N users)" {
style.multiple: true
}
api_ws: "tb-api /ws handlers\n(N connections, per browser)" {
tooltip: "Session-authenticated via BFF cookie —\nno token in the WS query string.\nFan-out is filtered to each connection's\nown interest set."
}
interest: "Valkey sorted set\ninterest:symbols" {
shape: cylinder
style.stroke: "${amber}"
tooltip: "ZADD with the current time as score, for every\nsymbol a browser reports. tb-stream trims\nentries older than 90 s."
}
alpaca_ws: Alpaca WS (IEX) {
tooltip: "Free plan: ONE concurrent connection\nand 30 symbols, per account — not per user."
}
stream: tb-stream {
style.stroke: "${red}"
style.stroke-width: 3
tooltip: "replicas: 1, strategy: Recreate (not RollingUpdate).\nA rolling update briefly runs two pods, both dial\nAlpaca, and one connection gets rejected/evicted.\nEvery 10 s: top 30 by interest, 60 s hysteresis,\nthen subscribe/unsubscribe quotes and bars."
}
live_set: "Valkey\nstream:subscribed\nstream:heartbeat" {
shape: cylinder
style.stroke: "${amber}"
tooltip: "The live set, rewritten with a 30 s expiry,\nand the heartbeat. A stale heartbeat is how\nyou find out the singleton died."
}
pubsub: "Valkey pub/sub\nticks, bars1m per symbol\nalerts per user" {
shape: cylinder
style.stroke: "${amber}"
tooltip: "No durable state. If nothing is subscribed\nwhen a message publishes, it is gone —\na live feed, not a queue."
}
alpaca_rest: Alpaca REST {
tooltip: "Free plan: 200 calls/min, no symbol cap.\nOne multi-symbol request covers many symbols.\nSIP data is allowed once it's 15 minutes old."
}
browsers -> api_ws: "interest frame\n(on change + every 30 s)"
api_ws -> interest: ZADD
interest -> stream: top 30,\nevery 10 s
alpaca_ws -> stream: single upstream WS,\naccount-wide
stream -> live_set: live set + heartbeat
stream -> pubsub: PUBLISH ticks\nand 1-min bars
pubsub -> api_ws: SUBSCRIBE, fan out\nper connection
live_set -> api_ws: "not in the live set →\nSymbolDelayed"
api_ws -> alpaca_rest: "snapshot every 15 s for\ndelayed symbols (single-flight)"
api_ws -> browsers: "WS push +\napplication-level ping ~30s" {
tooltip: "Cloudflare idles quiet WebSockets.\nA silently dead socket looks identical to\na quiet market — the worst failure mode here.\nServer pings, client pongs, to keep it honest."
}
# Alerts — once a trading day, after the close; never reads the tick stream
alerts: "tb-alerts\n(CronJob, after the close)" {
tooltip: "30 16-20 * * 1-5, America/New_York.\nThe 16:30 run evaluates; later runs only\nretry undelivered DMs."
}
pg: "Postgres\nalerts, alert_events" {
shape: cylinder
tooltip: "alert_events is unique per (alert, trading date),\nso a rerun can never fire twice.\nOnly bars after an alert's baseline count."
}
slack: "Slack\n(DMs only)" {
shape: cloud
tooltip: "Alerts and digests go to each user's own DM —\nnever a webhook or a shared channel."
}
alerts -> alpaca_rest: "calendar + the session's\nSIP 1-min bars"
alerts -> pg: crossings vs\nprevious state
alerts -> slack: "one DM per user\n(the day's triggers)"
alerts -> pubsub: "PUBLISH AlertTriggered\n(toast for users online)"
monitor: "Heartbeat monitor" {
style.stroke-dash: 3
}
live_set -> monitor: poll for staleness
monitor -> slack: "alert: tb-stream is down" {
style.stroke: "${red}"
}One upstream connection, fanned out through Valkey to every browser.
Real-time is IEX; history is SIP
Live quotes come from IEX, which is a few percent of US volume. Historical bars come from SIP, which is consolidated and authoritative — but the free plan embargoes the last 15 minutes of SIP, and rejects the entire request with a 403 if end falls inside that window rather than trimming it. AlpacaClient.get_bars clamps a SIP end clear of the window for that reason.
The consequence users see: an intraday chart can disagree with an alert until the close, because alerts evaluate SIP bars afterwards. The UI labels which feed a chart is showing instead of pretending they're the same.
Alerts need previous state
Condition alerts (a moving-average cross, a new high, a volume spike, RSI, a percent move) answer a question about a whole session rather than watching 1-minute bars for a touch, so they take their own path: alerts/conditions.py is pure — daily bars in, a verdict out — and calls the same pandas-ta functions that draw the chart's indicators, so an alert cannot disagree with the line the user is looking at. Daily bars are fetched split-adjusted; level alerts stay on raw prices because a level is a raw price. A condition has no price, so alerts.level is nullable, held honest by CHECK constraints, and the chart never draws a line for one. A cross is two consecutive sessions on opposite sides of the line, which is where "previous state" comes from without storing any.
An alert fires on a crossing, never on value > threshold. That distinction requires knowing the previous close, which is why alerts are evaluated once per trading day, server-side, on the session's SIP 1-minute bars — never on ticks, never in the browser. Dragging an alert line past the current price doesn't fire it: the baseline resets and only bars after the edit count.
Money is Decimal, and strings on the wire
Money and share quantities are Decimal in Python and strings in JSON, never JSON numbers — 1234.56 is a float to every client. The frontend formats them and does no arithmetic on them.
This is not pedantry. The portfolio ledger's property-based test caught a real bug this way: quantity * (price − avg) and quantity * price − quantity * avg round differently once average cost is a repeating decimal, and the books failed to balance by one ulp. Replay now quantizes to the column precisions, so what it computes is exactly what Postgres stores.
Privacy, and its one exception
Portfolios, alerts and watchlists are scoped to the session user — someone else's id returns 404, not 403, so the API never confirms which ids exist. Notifications go only to destinations that user connected themselves — their own Slack (a direct message, or a channel they choose), Telegram chat or webhook. The shared Slack bot is DM-only: its post() refuses anything but a DM, because a channel would show every user everyone else's holdings. A user-owned Slack token is different — workspace, app and destination are all theirs — so it may post to a channel they pick, and the UI says plainly that everyone in it can read the alerts.
Accounts. Zitadel authenticates; TraderBuddy authorizes. Registration is open, but every new user is pending and can read only /api/me until an admin approves them — over HTTP and over the WebSocket, which authenticates separately and needed its own check. projectRoleCheck is off in Zitadel precisely so a newcomer gets a waiting screen rather than an error page. The admin role still comes from Zitadel's project-role claim.
Credentials users enter. Slack and Telegram tokens and webhook URLs are encrypted at rest with Fernet (CREDENTIAL_KEY), as a whole blob rather than selected fields, because a webhook URL can carry a token in its path. The API is write-only for them — nothing returns a credential, so there is no "show token" — and no admin route touches the table. Webhooks are the one feature that lets a user aim the server at an address of their choosing, in a cluster that also runs Zitadel, Postgres and the Kubernetes API, and egress is not restricted, so the guard is the only defence: HTTPS only, every resolved address checked, then the connection made to the address that was checked rather than the name (a second DNS lookup would allow rebinding), no redirects.
Warning
The one exception, decided 2026-10-03: a user holding the Zitadel admin role can read any user's watchlist and portfolios, and a count of their active alerts, through /api/admin/*. It is read-only, it 404s for everyone else, and it carries two conditions — every cross-user read writes an admin_audit row, and the settings dialog tells every user this is possible. Searches are not logged anywhere.
Request paths
A normal API call
Browser → Cloudflare → Cilium Gateway → tb-api. The session cookie resolves against a sessions row in Postgres. Zitadel is never called on a normal request — no introspection endpoint — because it runs a single replica and would be a hard dependency on every page load. Roles are a snapshot taken at login and refreshed on token refresh.
A live price
Alpaca → tb-stream → Valkey pub/sub → every tb-api replica → each browser's /ws. Fan-out through Valkey is what lets tb-api scale to two replicas while only one upstream connection exists.
A chart
GET /api/bars reads closed bars from the Postgres cache, backfills from Alpaca only when the newest cached bar could be stale, and appends one live forming bar from IEX in two narrow cases. Closed bars are immutable once written.
An agent call
A coding agent speaks MCP to /api/mcp/ with Authorization: Bearer tb_pat_…. A door in front of the mount checks the token (SHA-256 lookup in Postgres; Zitadel is never involved) and each tool then calls the app's own REST routes in-process, carrying the same token. So validation, the approval gate, per-user scoping and rate limits are the browser's, not a copy of them. A token user has no roles, and what a token may reach is a fail-closed method-and-path allowlist in auth/tokens.py. See Connect a coding agent.
The scanner snapshot
After each close, tb-ingest scan fetches split-adjusted SIP daily bars for the shared universe (S&P 500, a few Nasdaq-100 extras, ~100 curated ETFs) plus every symbol anyone watches or holds, computes the moving averages, RSI, highs, volume and cross events once per symbol with the same pandas-ta calls the chart uses, joins cached Finnhub fundamentals, and writes one scan_snapshot row each. It is all-or-nothing: if a fetch fails the previous night's snapshot stands. Scans read that table and never a provider. Fundamentals are refreshed 150 symbols a night, paced under half the shared Finnhub budget, so they are up to a week old.
A scan is a validated list of filters (scanner/filters.py) compiled to SQL from a field whitelist, so no string a user sends is ever an identifier. Each query is restricted to is_core OR symbol IN (your watchlist and holdings): the snapshot contains rows that exist only because one person holds a symbol, and they must never appear for anyone else. "New" is computed by running the same filters on the previous session's snapshot, which is kept for two weeks, so there is no extra table.
Once the snapshot is committed the same job sends each user's notifying scanners (scanner/notify.py): every scanner of theirs with notify on is run through the same query.run as the page, so the privacy restriction is identical, and the matches go out as one message through notify/dispatch.py to that user's own channels. A scanner_notifications row is claimed per user per session before sending, as the digest does, so a rerun never posts twice; a send that fails everywhere releases the claim for the retry.
Company financials
Statements come from SEC EDGAR, which is free and keyless and which the browser never calls. Every request carries a User-Agent naming a project contact address, and goes through a Valkey token bucket at 8 requests a second (the SEC allows 10) shared by both API replicas and the nightly job. For a symbol, financials/service.py serves a stored report younger than a day; otherwise it reads the company's small submissions list and recomputes only when a new filing accession has appeared or the scoring VERSION changed. A recompute parses the multi-megabyte companyfacts document down to the ~30 concepts we map and drops the rest before the next company.
Companies tag the same line differently and change tags over time, so each field takes the first mapped tag that has a value for each period; annual figures are 345–385-day facts with the latest filing winning restatements, and trailing twelve months is the last fiscal year plus this year's year-to-date minus last year's. Indicators and the score are pure functions (indicators.py, score.py): a piecewise-linear table per indicator, a pillar is the mean of its indicators, and the overall score is the weighted mean of the pillars that could be scored. Unknown is never zero — a missing input is skipped, and fewer than three scored pillars is no score. The report is stored in financial_reports with a small scan block; the nightly scan reads only that block in SQL, never the whole report, which is the memory lesson from 0.4.3. If the SEC is unreachable the stored report is served marked stale.
Deliberate deviations from the plan
Each of these is a place the implementation diverged from TRADERBUDDY-IMPLEMENTATION-rev2.md on purpose. They are recorded here because an undocumented deviation reads as a bug to the next person.
| Plan said | Built instead | Why |
|---|---|---|
A symbol_profiles table | One symbol_facts table with a kind discriminator | Profile, metrics and recommendations have identical shape and TTL. Three identical tables would be three places to change. |
| Baseline mode "swaps the candle series" | Hides the candles, adds a baseline series | The drawing manager, crosshair legend and bid/ask lines all hold a reference to the candle series. Removing it would leave them pointing at a disposed object. |
@tanstack/react-table, version unspecified | Pinned to 8.21.3, not the current 9.2.4 | v9 is a ground-up redesign (atom stores, a Subscribe HOC). A sortable positions table isn't worth guessing at an unfamiliar API. |
| Fall back to the last regular-session 1-minute bar if the SIP daily close includes extended hours | Use the daily close directly | Measured: it excludes extended hours. AAPL printed above its regular high after the bell and the daily bar still matched the regular session exactly. The daily close is the official consolidated close — the fallback would have been a downgrade. |
| Watchlist truncated when the Slack block budget runs short | Capped at 15 rows regardless | Slack's 50-block limit alone would allow ~1,600 rows. The cap exists because a DM listing forty symbols is unreadable. |
Standing risks
tb-streamis a single point of failure. The free tier offers no fix. It is monitored, not solved.- Losing the
pve2host takes everything — etcd majority and two of three Postgres instances. The real fix is a third host, which doesn't exist. - Provider free tiers can change without notice. The provider layer is built so swapping one is a config change.
- Five users share one quota. A sixth may need paid tiers, not more RAM.
- Nothing here is investment infrastructure. Analyst consensus is opinion data; indicator crossings are not predictions.
Last updated 2026-10-06. The diagrams above are drawn from D2 source files kept with the project.
