Skip to content

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.