A self-hosted quant trading platform — market-data ingestion, backtesting, and real-time signals.
Status: Phase 1 (Data Pipeline) complete (PR #1); Phase 2 (Backtesting) complete — strategy plugin library, event-driven backtest engine, FastAPI service with queue-isolated worker, multi-timeframe aggregation via TimescaleDB continuous aggregates. Phase 3 (Live Signal Engine) is complete —
qa_strategies.on_barruns against livebars:new, subscriptions API + WebSocket bridge, email notifications, subscription-driven universe (poller widens on new subscriptions without redeploy), and durable catch-up replay fromohlcvon restart. Phase 4 (Web UI) is complete — React+Vite+TS SPA served by nginx, five routes (Overview/Charts/ Backtest/Portfolio/Signals,/overviewis the landing page), live WebSocket bridges for signals and candles, new API endpoints (candles/quotes/positions/heatmap), and a positions-aware subscription universe. Phase 5 (Multi-user) is complete — Google OAuth login, per-user data scoping (positions, subscriptions, backtests scoped tocurrent_user.id), multi-user signal fan-out delivering to each subscriber's own email, and security middleware (CORS allowlist,/auth/*rate limiting).
Phase 1 builds a reliable, idempotent US-equity market-data store in TimescaleDB, fed by a single
calendar-aware Poller that ingests intraday 1m bars, reconciles each session at the close with
authoritative 1d and full-day 1m bars, backfills new symbols to per-interval depths, and
announces new bars on a Redis event bus.
Phase 2 adds a strategy plugin library (qa_strategies: six classic strategies sharing a single
on_bar(window) → Signal | None interface), an event-driven backtest engine + paper broker +
performance metrics (qa_backtest), a FastAPI service (/api/v1: strategy discovery,
timeframe listing, backtest submit/status) with a Redis-queue-isolated backtest worker (so a
heavy backtest never starves the live engine), and multi-timeframe aggregation: 5m/15m/30m/1h/4h/1w/1mo
derived from the 1m/1d base (15m/1h/1w/1mo as TimescaleDB continuous aggregates, the rest
on-the-fly time_bucket) — always consistent with P1's authoritative data, no re-fetching.
Phase 3 adds the live signal engine (services/live): subscribes to bars:new, evaluates every
active user strategy subscription once per bar (dedup via signals unique constraint), and fans out
to the configured notifier. New in P3: a subscriptions API + WebSocket bridge (/api/v1/subscriptions,
/api/v1/ws/signals) for real-time signal delivery; an email notifier (SMTP / Gmail App Password);
subscription-driven universe (the poller optionally widens its fetch universe to include all
subscribed tickers without redeploy); and durable catch-up — on restart the engine replays
ohlcv rows missed during downtime via per-(symbol, interval) cursors before going live, making
the Redis Pub/Sub gap transparent to downstream subscribers.
| Doc | What it covers |
|---|---|
| docs/poller-architecture.md | The Poller service — state machine, wiring, event flow, the data-correctness contract, configuration, and the swappable seams. |
| docs/database-schema.md | The TimescaleDB ohlcv hypertable + ingest_state table — columns, hypertable/compression config, the is_final upsert merge semantics, and migration mechanics. |
| docs/manual-testing.md | Hands-on tutorial: run the automated suite, stand up the stack with Docker Compose, ingest real bars end-to-end, watch bars:new events, run a backtest through the API + worker, exercise the live signal engine (subscribe → replay → WebSocket push), drive the P4 web UI + market/portfolio endpoints (candles/quotes/positions/heatmap, /ws/candles), and verify the data. |
| docs/backtest-sweep-findings.md | Empirical P2 sweep (229 param sets × 3 symbols): in-sample wins vs Buy & Hold evaporate out-of-sample — the findings that shape the P4 parameter UI. |
| deploy/helm/README.md | Helm chart reference — install, upgrade, platform-specific values, external DB/secrets, backtest smoke-test, P3 live signal smoke-test, and subscription-driven universe. |
flowchart LR
YF[yfinance<br/>MarketDataSource] -->|fetch / fetch_latest| P[Poller daemon]
CLK[MarketClock<br/>XNYS calendar] -->|phase / session bounds| P
P -->|upsert_bars ON CONFLICT| TS[(TimescaleDB<br/>ohlcv hypertable)]
P -->|"eod_done:{symbol}:{date} gate<br/>+ quote:{symbol} cache"| R[(Redis)]
P -->|publish bars:new| BUS[Redis Pub/Sub<br/>EventBus]
BUS -->|bars:new| LIVE((live engine))
LIVE -->|signals + live_cursor| TS
LIVE -->|publish signals:new| BUS
BUS -->|"signals:new → WS /api/v1/ws/signals"| API
BUS -->|"bars:new → WS /api/v1/ws/candles"| API
R -->|"quote:{symbol} → /quotes, heatmap"| API
LIVE -->|EmailNotifier SMTP| MAIL[email inbox]
FE[React SPA<br/>nginx] -->|"/api proxy (REST + WS)"| API
P --- HZ[/healthz :8080/]
TS -->|"CAGGs (15m/1h/1w/1mo)<br/>+ on-the-fly time_bucket"| CAGG[(derived frames<br/>5m…1mo)]
CAGG -->|load_bars| WRK[backtest worker]
API[FastAPI /api/v1] -->|RPUSH backtest:jobs| RQ[(Redis queue)]
RQ -->|BLPOP| WRK
WRK -->|save_result| TS
- Source (
qa_datasource) — a swappableMarketDataSourceregistry; yfinance today, Polygon/Alpaca later by adding one module. - Clock (
qa_core.market_calendar) — wrapsexchange_calendars(XNYS); decides whether to poll intraday, run EOD, or sleep. - Store (
qa_core.db+db/migrations) — theohlcvhypertable; idempotentupsert_barswith theis_finalauthoritative-value latch; continuous aggregates for derived timeframes. - Bus (
qa_core.bus) —EventBusabstraction over Redis Pub/Sub; the Poller publishesbars:new. - Poller (
services/poller) — the daemon that ties it together;/healthzfor k8s liveness. - Strategies (
qa_strategies) — plugin library: six classic strategies (ma_crossover,rsi,macd,bollinger,momentum,mean_reversion) each implementing oneon_bar(window) → Signal | Noneinterface;@registerfor auto-discovery. - Backtest engine (
qa_backtest) — event-driven engine that callsstrategy.on_barbar-by-bar; pluggableCostModel;Metrics(total_return, CAGR, Sharpe, Sortino, max drawdown, win rate). - API (
services/api) — FastAPI app (uvicorn api.main:app);GET /api/v1/{healthz,strategies,timeframes,backtests,backtests/{id}}+POST /api/v1/backtests; enqueues only — never runs inline. - Auth (
qa_core.auth+services/api/src/api/{routers/auth.py,security.py,middleware.py}) — Google OAuth2/OIDC behind anOIDCProviderABC + registry (add GitHub/Apple = one provider), HS256JwtSessionIssuerbehind aSessionIssuerABC, and a singlecurrent_userdependency that scopes every per-user endpoint (positions/subscriptions/backtests; cross-user reads 404, not 403). Auth is optional at boot and fails closed when unconfigured; market-data endpoints stay public. Security middleware adds CORS (locked by default), security headers, and a per-IP rate limit on/auth/*./ws/signalsauthenticates via a?token=query param. - Worker (
services/api/src/api/worker.py,qa-worker/python -m api.worker) —BLPOP backtest:jobs; runs the engine; persistsBacktestResult; statuses:queued → running → done | error; publishesbacktests:done. - Live engine (
services/live) — subscribes tobars:new; evaluates active subscriptions once per bar; dedup-guards viasignalsunique constraint; fans out via WebSocket (signals:new) and email notifier; durable catch-up replay on restart. - Frontend (
services/frontend) — React+Vite+TS SPA served by nginx; proxies/apito the FastAPI service; monochrome Tailwind CSS 4 + shadcn/ui dashboard, all charts via KLineCharts; five routes: Overview (landing page — KPIs, market chart, positions/signals tabs), Charts (K-line + /ws/candles live bars), Backtest (schema-driven form + results), Portfolio (positions table), Signals (live feed via /ws/signals). P5 auth: anAuthProvidercaptures the JWT from the OAuth callback URL fragment, persists it, and wires theapiClientBearer interceptor;RequireAuthgates Overview/Backtest/Portfolio/Signals while Charts stays public.make e2e/make e2e-dockerfor the Playwright smoke suite. - Deploy (
deploy/helm/quantaugur+services/{poller,api,live,frontend}/Dockerfile) — one portable, vendor-neutral Helm chart; API, worker, live engine, and frontend are separate Deployments that scale independently.
quantaugur/
├── packages/
│ ├── qa-core/ # config, models, db (upsert_bars/load_bars), schemas (Bar+validation), universe (+subscription), market_calendar, bus, timeframes, jobs, backtest_runs, signals, subscriptions, live_cursor, notify (+email_notifier), dispatch, auth (OIDCProvider/GoogleOIDC + JwtSessionIssuer + registry), users
│ ├── qa-datasource/ # MarketDataSource ABC + registry + yfinance adapter
│ ├── qa-strategies/ # Strategy ABC, Signal, BarWindow, @register registry, indicators, 6 classic strategies
│ └── qa-backtest/ # event-driven engine, Broker, CostModel, Metrics, runner (worker logic)
├── services/
│ ├── poller/ # the daemon: poller.py, health.py, main.py + Dockerfile
│ ├── api/ # FastAPI app (main.py) + worker entrypoint (worker.py) + Dockerfile
│ ├── live/ # live signal engine (engine.py, main.py, health.py) + Dockerfile
│ └── frontend/ # React+Vite+TS SPA — nginx serving + /api proxy + Dockerfile
├── db/
│ ├── alembic.ini
│ └── migrations/ # 0001_ohlcv, 0002_backtest_runs, 0002b_continuous_aggregates, 0003_user_strategies, 0004_signals, 0005_live_cursor, 0006_signal_delivery, 0007_positions, 0008_users, 0009_scope_user_id
├── deploy/helm/quantaugur/ # portable chart (values.yaml + values-{k3s,generic}.yaml)
├── docker-compose.yml # local dev: timescaledb + redis
└── docs/ # architecture, schema, and manual-testing guides
Architectural rule: services/* may import qa_*; qa_* packages must not import from services/*.
Shared logic always lives in a qa_* package so later phases (P2 backtest, P3 live) reuse it without duplication.
Make targets (make help lists everything):
make build # build the api/poller/live/frontend images (REGISTRY=… TAG=… overridable)
make compose-up # run the FULL stack with docker compose (db, redis, migrate, api, worker,
# poller, live, frontend) — UI at http://localhost:8080
# poller defaults to QA_UNIVERSE=subscription — subscribing via the API (or
# adding a position) pulls new tickers into ingestion without a redeploy
make kind-up # build + load + helm-install the whole platform on a local kind cluster
make kind-down # delete the kind cluster
make test lint # quality gates
make e2e-docker # frontend Playwright smoke suite, hermetic (make e2e = native variant)Manual dev loop (infra in containers, services on the host):
docker compose up -d # TimescaleDB + Redis only
cp .env.example .env
export QA_DATABASE_URL=postgresql+asyncpg://quant:quant@localhost:5432/quantaugur
uv run alembic -c db/alembic.ini upgrade head # create the schema
uv run pytest -q # full suite — the integration tests (incl. all P3 live-signal
# behaviors) need Docker and SKIP without it; a green run without
# Docker does NOT verify them
uv run qa-poller # run the ingestion daemonAPI + worker (local, from the repo root):
uv run uvicorn api.main:app --host 0.0.0.0 --port 8000 &
uv run qa-worker # console script for python -m api.worker
uv run qa-live # live signal engine (P3) — warns if QA_NOTIFY_EMAIL unsetSee docs/manual-testing.md for the full data-pipeline walkthrough, and
deploy/helm/README.md for the Kubernetes smoke-test flow (port-forward,
submit a backtest, poll to done).
Python 3.12 · uv workspace · SQLAlchemy 2.0 (async) + asyncpg · Alembic · TimescaleDB (PG16) ·
Redis 7 · exchange_calendars · yfinance · FastAPI + uvicorn + websockets · Authlib (OIDC) + PyJWT (HS256 sessions) ·
aiosmtplib (SMTP) · numpy / pandas (metrics) · pydantic v2 / pydantic-settings · pytest + testcontainers ·
React 18 + Vite + TypeScript · TanStack Query · Tailwind CSS 4 + shadcn/ui · KLineCharts ·
vitest + Testing Library · Playwright (e2e) · nginx · Helm 3 / Kubernetes.