02 — Technology Decisions¶
Each decision states what we chose, the alternatives that lost, and the specific reason. Where a choice is genuinely close, that is said rather than hidden behind a confident rationale.
1. Languages¶
Backend, data, and AI: Python 3.12+¶
Not because Python is fast — it is not — but because the entire quantitative and AI ecosystem this project depends on is Python-native: pandas/Polars, NumPy, SciPy, statsmodels, scikit-learn, PyArrow, the LLM orchestration frameworks, and every serious backtesting reference implementation. Choosing anything else means reimplementing or bridging to that ecosystem, and the bridge is where correctness bugs live in a system whose whole value is numeric correctness.
Rejected: Go — excellent for the API and ingestion, but the quant/ML ecosystem is not there, and splitting the system by language at that seam would put the point-in-time query logic on the wrong side of a serialisation boundary. TypeScript everywhere — would unify with the web client, but the numerical and statistical libraries are markedly weaker and float/decimal handling for money is worse. Rust — the right answer for the fill-simulation and backtest inner loops if profiling demands it; see below, but wrong as the primary language given the ecosystem cost.
Money is Decimal throughout, never float. Prices and quantities in the domain layer use a fixed-precision type with explicit rounding rules. Floats appear only in statistical computation, never in a ledger.
Client: TypeScript + React (Vite)¶
A single-page application, served as a static bundle by the same FastAPI process that serves the API. One origin, one deployment unit, no CORS, and no second server to run — which for a self-hosted single-user tool is worth more than any framework feature.
React over the alternatives mainly for the ecosystem this particular app needs: accessible headless component primitives, mature data-grid and financial-charting libraries, and TanStack Query, which solves the server-cache problem this app has a lot of. Types are generated from the backend's OpenAPI schema, so a contract change breaks the build.
Rejected: Next.js — server-side rendering earns nothing for a single-user authenticated tool with no SEO surface and no cold-start audience, and it would add a Node process to a deployment that otherwise has exactly one application process. SvelteKit — smaller and genuinely pleasant, but the financial-charting and data-grid ecosystem is thinner, and that ecosystem is most of what this client needs. HTMX with server-rendered templates — the honest minimalist option, and a real contender for the CRUD surfaces; it loses on the parts that are actually interactive: streaming agent runs, live-updating charts, and the assistant. Splitting the app across two interaction models to save bundle size is a poor trade. Native desktop (Tauri/Electron) — an install step and platform builds, for no capability this design needs.
Superseded: an earlier revision specified React Native + Expo for iOS and Android. The mobile app was dropped from scope; phone access is now the responsive layout of this same web client, reached in a phone browser (09 §2).
Performance kernels: Rust, only if profiling requires it¶
The backtest inner loop and fill simulation are the plausible hot paths. The plan is to build them in Python first with Polars/NumPy vectorisation, measure, and only extract to a Rust extension (via PyO3) if a walk-forward run over the full universe exceeds the target budget. Premature extraction here would be pure cost. Recorded as a deferred option, not a commitment.
2. Datastores¶
Primary: PostgreSQL 16 + TimescaleDB¶
Postgres holds domain state — portfolios, orders, recommendations, settings, audit log. TimescaleDB (a Postgres extension) holds the time series: OHLCV bars, macro series, computed features. Hypertables give partition-by-time, continuous aggregates for resampling, and native compression that matters when storing years of intraday bars.
The reason for one engine rather than two: point-in-time feature queries need to join time series against fundamentals against corporate actions with correct as-of semantics. In one database that is a SQL join with a proper transactional snapshot. Across two systems it becomes application-level joining, which is both slower and where look-ahead bugs hide.
Rejected: ClickHouse — faster for pure analytical scans, but weaker transactional guarantees for the domain side and would force the two-system split. DuckDB — excellent, and used inside the backtest engine for local analytical queries over Parquet, but not as the system of record for concurrent writers. InfluxDB — narrower query model, and joining against relational fundamentals is awkward. MongoDB — schemaless is precisely wrong for financial data whose schema is the contract.
Vector store: Qdrant¶
For the document corpus that grounds citations: filings, news, transcripts, past recommendation cases. Chosen for Apache-2.0 licensing, a single self-contained binary (which matters for the laptop profile), strong metadata filtering — essential, because retrieval must be constrained by available_at to avoid look-ahead in the RAG path — and a usable local-disk mode.
Rejected: pgvector — genuinely tempting since it avoids a service, and is the right pick if we later want to reduce component count; it lost on filtered-search performance at corpus sizes in the millions of chunks and on index build times. Revisit if the corpus stays small. Pinecone / Weaviate Cloud — hosted, which contradicts local-first. FAISS — a library, not a store; no metadata filtering or persistence story.
Cache, locks, ephemeral state: Redis¶
Feature cache, rate-limit counters, distributed locks for ingestion idempotency, live price fan-out to WebSocket subscribers.
Object storage: MinIO (S3-compatible)¶
Raw source documents (filings, article HTML), Parquet feature snapshots, backtest artifacts, model checkpoints. S3-compatible so the private-cloud profile can swap in real S3 without code change. Raw documents are retained because a citation must resolve to the exact text that was read, not to a URL that may have changed.
Message bus: NATS JetStream¶
Between ingestion workers, the pipeline, and the API's push layer. Chosen over Kafka for the same reason as the monolith decision: a single ~15 MB binary with persistence, at-least-once delivery, and consumer groups covers everything needed, while Kafka brings a JVM, ZooKeeper-or-KRaft operational surface, and tuning burden that a single-node system cannot justify.
Rejected: Kafka/Redpanda — right at a scale we will not reach. RabbitMQ — fine, but weaker replay semantics, and replay is how we recover ingestion. Celery on Redis — conflates task queue with event stream; we want both, distinctly.
3. AI and orchestration¶
Agent orchestration: LangGraph¶
Agent workflows here are stateful graphs with conditional branching (compliance gate, veto path), parallel fan-out, retry, and — critically — checkpointing so a long recommendation run can resume. LangGraph models that directly, has first-class streaming for the "watch it think" UI, and its state object gives us a natural audit manifest.
Rejected: Plain orchestration code — the honest default, and worth reconsidering if LangGraph's abstractions fight us; it loses on checkpointing and streaming, which we would end up rebuilding. CrewAI / AutoGen — oriented toward free-form conversational agent collectives; we specifically do not want agents negotiating in natural language, because that is unauditable and expensive. Our agents never talk to each other; they emit typed signals to a deterministic aggregator. Temporal — excellent durable execution, and the better choice at higher scale, but a heavy operational addition for a home server.
Model access: A gateway we own, over LiteLLM¶
LiteLLM provides the provider-normalisation layer (one call shape for Anthropic, OpenAI-compatible endpoints, Ollama, vLLM). We wrap it in our own gateway rather than calling it directly, because three responsibilities must be enforced at a single chokepoint: redaction of portfolio-identifying data before any external call, budget accounting, and prompt/version hashing for reproducibility. Those cannot be left to call sites.
Model selection¶
Routed by task tier, not by preference:
| Tier | Task | Cloud | Local |
|---|---|---|---|
| 1 | Classification, extraction, dedup, tagging | Claude Haiku 4.5 | Qwen3 8B / Llama 3.x 8B (quantised) |
| 2 | Per-agent analysis and signal generation | Claude Sonnet 5 | Qwen3 32B / Mistral Small class |
| 3 | Supervisor synthesis, explanation, assistant | Claude Opus 5 or Fable 5 | Qwen3 72B class, or degrade to tier 2 with a disclosed quality flag |
Tier 3 is where model quality most affects user-visible output — the explanation is the product — so this is where cloud is most valuable and where the local fallback is most explicitly labelled. The routing table is configuration, so a user who wants a strictly local system sets one profile and everything follows.
Local serving: Ollama for the laptop profile (simplest operationally), vLLM for the server profile (throughput, batching, longer context).
Embeddings: BGE-M3 or E5-large, served locally¶
Local by default because embedding every filing and news article through a paid API is both the largest recurring cost and the largest data-egress surface in the system. Multilingual matters for global news coverage.
Classical ML: scikit-learn + LightGBM¶
For the aggregator, confidence calibration, and regime classification. Deliberately not deep learning: the datasets are small, the signal-to-noise is low, and interpretability of feature contribution is a product requirement — the app shows which agent moved the score. Gradient-boosted trees with SHAP values give that directly.
4. Application framework¶
FastAPI for the API: async-native (the workload is I/O-bound fan-out), Pydantic v2 models that serve simultaneously as validation, OpenAPI schema, and LLM structured-output schemas — one definition, three uses — and native SSE/WebSocket support.
Pydantic v2 as the universal contract type. Every agent output schema, every API request/response, and every stored document schema is a Pydantic model. Agent outputs are validated against the same models the API serves, which is what makes "the agent returned garbage" a caught error rather than a corrupt recommendation.
Dagster for data pipelines. Chosen over Airflow and Prefect because its software-defined-asset model matches market data exactly: assets are partitioned by date and symbol, lineage is first-class (so "which upstream fact produced this feature?" is answerable), and backfills are a native operation rather than a scripting exercise. Backfill correctness is central to point-in-time discipline.
Rejected: Airflow — task-centric rather than asset-centric; heavier; backfill semantics are more error-prone. Prefect — lighter and pleasant, but weaker lineage. Cron + scripts — where this starts in Phase 0, and it stops being adequate the moment backfills matter.
5. Web client stack detail¶
| Concern | Choice | Reason |
|---|---|---|
| Build | Vite | Fast dev server, native ESM, trivial static output for FastAPI to serve |
| Routing | TanStack Router | Typed routes and typed search params — the screener's filter state lives in the URL, so a view is shareable and bookmarkable |
| Server state | TanStack Query | Caching, background refetch, and invalidation for an app that is mostly a view over server data |
| Client state | Zustand | The small remainder: UI preferences, density, draft order tickets. Server state and client state are different problems and do not belong in one store |
| Price charts | TradingView Lightweight Charts | Purpose-built for financial series, canvas-rendered, ~45 KB. Candlesticks, volume, and annotation are primitives rather than assembly |
| Analytical charts | Apache ECharts | Allocation, attribution, calibration curves, stress scenarios. Canvas-based, handles large series, strong accessibility support |
| Data grid | TanStack Table | Headless — the screener and holdings tables need custom cells and virtualised rows, not someone else's visual opinions |
| Components | Radix UI primitives | Unstyled and accessible: dialogs, comboboxes, menus with focus management and ARIA correct by construction. Accessibility is far cheaper to inherit than to retrofit |
| Styling | Tailwind + CSS custom properties | Tokens as custom properties so both themes and the density setting are a token swap; Tailwind for composition |
| Streaming | Native EventSource + WebSocket |
Agent-run traces and live prices; no library needed |
| PWA | vite-plugin-pwa |
Installability and web push for critical alerts (09 §13) |
| Auth | WebAuthn passkeys | A web standard; the browser is its native home. No password to phish; see 10 |
| API client | openapi-typescript generated |
Contract drift is a compile error |
Two charting libraries is a deliberate call rather than an oversight: price series and analytical charts have genuinely different requirements, and the combined weight is still less than a single general-purpose library that does neither well.
6. Security and infrastructure¶
- Auth: WebAuthn passkeys with device-bound credentials. For a single-user self-hosted system there is no password anywhere in the system, which removes an entire class of attack.
- Transport: TLS everywhere including LAN, via Caddy with automatic certificates; HSTS enabled; mTLS optional on the home-server profile.
- Secrets: OS keychain on desktop;
age-encrypted file for headless deployments; never in the database, never in environment files committed anywhere. - At rest: Postgres volume encryption plus column-level encryption for the highest-sensitivity fields (broker credentials in Phase 5, personal notes).
- Egress control: explicit allowlist of outbound hosts, default-deny. A misconfigured or compromised dependency cannot quietly exfiltrate.
- Containers: Docker Compose per profile; images built from distroless or slim bases; non-root; read-only root filesystem where possible.
7. Development toolchain¶
uv for Python dependency and environment management (fast, lockfile-correct). ruff for lint and format. mypy --strict on packages/domain — the layer where type errors become money errors. pytest with hypothesis for property-based testing of the ledger and fill engine, where invariants ("positions never go negative", "cash + market value is conserved across a fill") are far better expressed as properties than as examples. pytest-benchmark on backtest paths. import-linter to enforce the dependency direction rule from 01 §6. GitHub Actions for CI, with the reproducibility golden-set test as a required check.
8. Cost posture¶
The system is designed to be runnable at effectively zero marginal cost, with paid services as opt-in accelerants.
| Component | Free / local path | Paid accelerant |
|---|---|---|
| Market data | Stooq, Yahoo-style endpoints, broker sandbox feeds | Polygon / Tiingo / EODHD |
| Fundamentals | SEC EDGAR XBRL (official, free, complete for US) | Provider with global coverage and pre-normalised statements |
| Macro | FRED, World Bank, central bank releases | — |
| News | RSS, GDELT | Benzinga / Ravenpack-class |
| LLM inference | Ollama / vLLM on local hardware | Cloud tier-3 for explanation quality |
| Embeddings | Local BGE-M3 | — |
The most expensive thing in this system is not inference; it is licensed market data with global coverage. The architecture keeps the provider behind an adapter interface precisely so this choice stays reversible. Licensing constraints are covered in 03 §7.