Skip to content

Architecture Overview

This document is the top-level system map for quant-app.

Use it to understand how the main modules fit together before diving into the live pipeline or the test suites.

Design Goals

These goals explain the architectural tradeoffs reflected in this document. When a decision seems unusual, it is likely here.

  • Fail-fast over partial readiness. Repositories use suspendable factories so a partially initialized object can never become observable to the rest of the application. A crashed pod that Kubernetes restarts is always preferable to a zombie pod that appears healthy but cannot serve. This principle applies at every layer: startup tasks, concurrent modules, and the DataBridge readiness reporter.
  • Runtime isolation between the trading engine and the API tier. backend-app and backend-server are separate deployables. The API tier can stay up while a trading session restarts, and a server-side bug cannot corrupt live strategy state. DataBridge and AppDataBridge are the only sanctioned crossing points.
  • Single source of truth per data domain. QuestDB owns raw time-series market data. The main datastore (Gel) owns application state — strategies, feeds, orders, positions. The backend-sync Postgres instance owns sync-event lifecycle. These boundaries must not blur: historical data is never served from in-memory state, and application state is never written directly to QuestDB.
  • Reproducibility over convenience. All infrastructure is Terraform-managed. All Kubernetes resources are generated by JKube from Gradle DSL. No manual cloud console steps should be required to recreate any environment. Convenience shortcuts that bypass this are always technical debt.
  • Correctness over throughput. This is a personal research platform, not a high-frequency system. Where a tradeoff must be made between latency and correctness (e.g. dedup keys in QuestDB, guarded state transitions in backend-sync), prefer correctness.

System At A Glance

quant-app is split into two main runtime applications plus supporting storage, bridge, and frontend modules:

  • backend-app is the live trading runtime. It ingests live market data, runs strategies, executes trades, records market data, and publishes runtime state upstream.
  • backend-server is the API and aggregation runtime. It exposes GraphQL/SSE/HTTP-facing behavior, aggregates connected app instances, and brokers historical requests back to backend-app.
  • frontend-app is the user-facing Compose Multiplatform client.
  • backend-web-app packages the frontend web distribution into the deployable Caddy image.
  • backend-database, backend-recorder, and backend-sync provide the main storage and synchronization infrastructure around the two runtimes.

At a high level:

External market feeds
  -> backend-app
       -> BrokerApiResolver -> UpstoxApiDecorator
       -> Processor
       -> TradeExecutor
       -> DataRecorder / QuestDB
       -> DataBridge -> backend-server

backend-token-broker (separate pod)
  -> TokenManager (per AuthBroker, suspend factory)
  -> ProvisionedTokenManager (per ProvisionedTokenHandle, file-backed)
  -> UpstoxTokenExchangeStrategy
  -> kRPC services consumed by backend-app: OAuthTokenBrokerService, ProvisionedTokenBrokerService
  -> RecoveryManager.recoveryFlow().recoverable() for auth prompts, collected by AuthStateCoordinator

frontend-app
  -> backend-server
       -> DataStream over DataBridge connections
       -> AppDataBridge back to backend-app for historical data

backend-database
  -> main application datastore

backend-sync
  -> Broker.publicInstruments() for auth-free instrument refresh
  -> sync-event persistence and data-source sync scheduling against pre-registered Gel feed state

Main Runtime Boundaries

backend-app

backend-app is responsible for:

  • restoring feed and strategy state at startup
  • collecting live market data from datasource integrations
  • running the processor pipeline
  • executing trades through the broker path via BrokerApiResolver
  • recording raw market data and reading historical backfill
  • streaming runtime state to backend-server
  • connecting to backend-token-broker for OAuth token lifecycle and provisioned-token management
  • surfacing token auth prompts through RecoveryManager.recoveryFlow().recoverable(), collected by AuthStateCoordinator
  • resuming suspended datasources on OAuth auth recovery via AuthStateCoordinatorResumableDataSource.resumeIfStopped(), and on provisioned-token recovery via ProvisionedTokenStateCoordinator

This is the module where the live strategy machinery actually runs.

backend-server

backend-server is responsible for:

  • hosting the inbound DataBridge server for connected app instances
  • exposing the downstream HTTP, SSE, and GraphQL surfaces
  • aggregating runtime state across active app connections through DataStream
  • calling back into backend-app through AppDataBridge for historical market data

backend-server is an aggregation and delivery boundary, not the place where strategy execution happens. It receives recovery errors and readiness state from backend-app via DataBridge but does not directly connect to the token broker.

backend-token-broker

backend-token-broker is a standalone Ktor service responsible for OAuth token lifecycle and provisioned-token management. It runs as a separate pod in the cluster and exposes two kRPC endpoints at /token-broker: OAuthTokenBrokerService and ProvisionedTokenBrokerService.

Key responsibilities:

  • managing per-AuthBroker OAuth token state machines (TokenManager, constructed via suspend operator fun invoke(...) factory) via OAuthTokenState (Valid, AwaitingAuth, Exchanging, Failed)
  • managing per-ProvisionedTokenHandle provisioned token state (ProvisionedTokenManager) via ProvisionedTokenState (Valid, Failed)
  • persisting OAuth tokens to DataStore (backed by a PVC for crash recovery); provisioned tokens are never written to DataStore — the mounted Secret file is the source of truth
  • scheduling expiry timers that proactively transition OAuth tokens to AwaitingAuth at 3:30 AM IST
  • exchanging auth codes for access tokens via broker-specific TokenExchangeStrategy implementations
  • classifying permanent OAuth errors (e.g., wrong client credentials) as NonRecoverableTokenExceptionOAuthTokenState.Failed
  • enforcing blackout periods (3:30 AM–8:00 AM IST) when token requests are rejected
  • exposing recoveryFlow(broker) for OAuth auth prompts, collected via RecoveryManager.recoverable() by AuthStateCoordinator
  • on 401, provisioned tokens immediately re-read the mounted file; if the fingerprint is unchanged, transition to Failed and start a 30-second poll loop until the Secret is rotated; if the file is transiently unreadable, the fingerprint is cleared so the poller will detect when the same bytes reappear
  • ProvisionedTokenManager uses a mutex-owned state machine: all four state dimensions (current state, fingerprint, poller lifecycle, expiry timer lifecycle) are owned by mutationGuard, with two locked transitions (becomeValidUnderLock, becomeFailedUnderLock) that prevent the duplicate-poller race
  • lastFingerprint is @Volatile so the poller's lock-free quick-path fingerprint comparison always observes the latest write from the locked transitions
  • the expiry timer reacquires the lock and checks token identity before transitioning to Failed — stale timers from old tokens are silently ignored
  • exposing two public kRPC service surfaces: OAuthTokenBrokerService and ProvisionedTokenBrokerService

backend-app connects to the token broker via kRPC in the tokenBroker() Ktor module, which runs before core(). Both kRPC clients are wrapped in resilient decorators: ResilientTokenBrokerService for OAuth and ResilientProvisionedTokenBrokerService for provisioned tokens. Each decorator shares tokenStateUpdates(broker/handle) via shareIn and applies automatic reconnect with exponential backoff — if the kRPC transport drops, the stream re-subscribes rather than going permanently dead. UpstoxApiDecorator creates three HttpClient.withConfig instances: an OAuth-backed client for live trading (positions, holdings, live HFT order placement), an UPSTOX_ANALYTICS provisioned-token client for the market data feed and historical candle data, and a UPSTOX_SANDBOX provisioned-token client for sandbox HFT order placement. Each uses awaitValidToken() to suspend until a token is available and onUnauthorized to invalidate the appropriate token type on 401.

The token broker uses AuthBroker (UPSTOX, ZERODHA) as its key type for OAuth tokens — only brokers requiring OAuth authentication can be used as keys. Provisioned tokens use ProvisionedTokenHandle as their key type: UPSTOX_SANDBOX for sandbox HFT order placement and UPSTOX_ANALYTICS for the market data feed and historical candle data. The BrokerApiResolver in backend-broker maps the general Broker enum to concrete BrokerApi instances, requiring both OAuthTokenBrokerService and ProvisionedTokenBrokerService in its constructor for compile-time safety.

Provisioned tokens are mounted from a Terraform-managed Kubernetes Secret (token-broker-provisioned-tokens) into the token broker pod at /var/run/secrets/token-broker/provisioned. The Secret must be applied via terraform/kubernetes before deploying backend-token-broker for the first time, so the Deployment can reference it. After Secret rotation, the kubelet refreshes the mounted files and the token broker's poller recovers without a pod restart. Terraform validates that each token JSON value has non-empty string token and RFC 3339 expiry fields — catching malformed values at terraform plan/apply time rather than deferring failures to the broker.

Two health reporters surface token state through the @DiagnosticHealth set:

  • OAuthTokenHealthReporter partitions AuthBroker entries into active (present in the ResumableDataSource Dagger map) and inactive at construction time. Active brokers with Valid tokens → HEALTHY; AwaitingAuthDEGRADED; ExchangingBLOCKED; Failed (non-recoverable, e.g. invalid_client) → FAILED (does not allow readiness). Inactive brokers appear in the health message but do not affect aggregate status.
  • ProvisionedTokenHealthReporter iterates all ProvisionedTokenHandle entries. ValidHEALTHY; Failed (recoverable via Secret rotation) → DEGRADED.

UpstoxApiDecorator uses the shared UpstoxErrorParser for 401 classification on the live OAuth client and both provisioned-token clients (analytics and sandbox). The parser reads the raw response body from the SavedHttpResponse cache (not body<T>(), which re-enters the Ktor response pipeline and causes a StackOverflow in this context) and classifies UDAPI100050 as a token error, triggering invalidation of the appropriate token type.

RecoveryManager coalesces token-recovery prompts by recoveryUrl: concurrent AwaitingAuth emissions for the same broker share one recoveryToken UUID and one published error. If the token becomes Valid without explicit recovery (e.g., an out-of-band re-auth), clearLogicalRecovery() removes the stale prompt and resumes any suspended .recoverable() collectors. The recover() method removes only the resolved error and preserves all other active recoverable errors across all recovery keys.

AuthStateCoordinator owns all OAuth token-state side effects for the application layer. For each AuthBroker, it manages three behaviors: (1) publishes recoverable auth prompts by collecting recoveryFlow(broker) wrapped in RecoveryManager.recoverable(), surfacing the "please re-authenticate" prompt in the UI; (2) clears obsolete auth prompts by watching tokenStateUpdates(broker) and calling clearLogicalRecovery() when the token transitions away from AwaitingAuth, preventing stale prompts from lingering after out-of-band recovery; (3) resumes datasources via ResumableDataSource.resumeIfStopped() when the token transitions from non-Valid to Valid (using distinctUntilChanged to avoid triggering on token refresh). The coordinator is started by StartAuthStateTask and uses Dagger-injected Map<AuthBroker, ResumableDataSource<*>> via @IntoMap + @AuthBrokerKey for fail-fast wiring. ResumableDataSource is an opt-in interface — only datasources that can enter a terminal degraded state (currently UpstoxDataSource) implement it.

ProvisionedTokenStateCoordinator mirrors the datasource-resume behavior for provisioned tokens. Started by the same StartAuthStateTask, it watches ProvisionedTokenBrokerService.tokenStateUpdates(handle) for each handle bound in a Map<ProvisionedTokenHandle, ResumableDataSource<*>> Dagger multibinding (currently UPSTOX_ANALYTICSUpstoxDataSource) and resumes the datasource via ResumableDataSource.resumeIfStopped() when the handle transitions to Valid. Because the analytics token has no OAuth flow, a Failed analytics token surfaces as terminal DEGRADED in the feed (via TokenUnavailableException from awaitValidToken) until the operator rotates the Secret; the coordinator then resumes the feed automatically once the poller publishes Valid again — no pod restart required.

backend-data-bridge

backend-data-bridge is the shared contract module between the two runtimes.

It contains transport-agnostic APIs and DTOs for two distinct directions:

  • AppDataBridge: request/response calls from backend-server to backend-app, currently centered on historical market data lookup
  • DataBridge: long-lived streaming from backend-app to backend-server, carrying strategy output, strategy signals, market-feed events, trades, open positions, readiness, and recovery/error state

On the server side, DataStream is the aggregation surface over all active DataBridge connections.

Module Map

The repo contains many modules, but they cluster into a few architectural areas.

Area Main modules Responsibility
Runtime applications backend-app, backend-server, backend-token-broker, backend-web-app, frontend-app live execution runtime, API/runtime aggregation, OAuth and provisioned token lifecycle, web packaging, and UI
Strategy and execution engine backend-processor, backend-strategy, backend-trade-executor, backend-broker strategy execution, processor orchestration, trade execution, and broker integration
Market data and recovery backend-datasources, backend-instrument-map, backend-historical-data-provider, backend-recovery, backend-readiness live datasource integration (with structured health via DataSourceHealth), instrument/feed mapping, historical lookup interfaces, recovery wrappers (coalescing by logical key, out-of-band clearing), readiness reporting (with DEGRADED feed aggregation)
Persistence and contracts backend-data-bridge, backend-recorder, backend-database, backend-repository, backend-storage, backend-sync runtime bridge contracts, QuestDB integration, main datastore deployment, repositories/storage access, and sync-event persistence
Frontend support frontend-api, frontend-graphql, frontend-websocket, frontend-charts, frontend-fts API clients, transport bindings, chart rendering, and frontend support features
Shared foundations shared-* common utilities such as core models, caching, logging, KV storage, HTTP/websocket helpers, and task scheduling
Build and deployment build-logic, terraform/edge/*, terraform/kubernetes Gradle convention plugins, Cloudflare/OCI edge infrastructure, and Kubernetes deployment infrastructure

You do not need to know every module in detail before working in the repo. The key starting point is understanding which layer owns the behavior you are changing.

Runtime Data Flows

Live Market Path

The live market path starts in backend-app:

  1. subscribed instruments are restored and mapped to datasource feeds
  2. live market data is collected from datasource integrations (HTTP requests suspend on awaitValidToken() when auth is pending)
  3. Processor routes ticks into per-strategy mailboxes and then into StrategyExecutor and StrategyExecution
  4. TradeExecutor consumes signals and emits trade events (resolves broker API via BrokerApiResolver)
  5. raw market data is recorded to QuestDB and trade events are persisted through the orders repository
  6. runtime events are forwarded to backend-server through DataBridgeLauncher
  7. OAuth token auth requirements are surfaced to the UI via AuthStateCoordinator collecting RecoveryManager.recoveryFlow().recoverable()DataBridge error events; OAuth auth recovery triggers AuthStateCoordinatorResumableDataSource.resumeIfStopped(). Provisioned-token (analytics) failures surface as terminal DEGRADED feed health and recover via ProvisionedTokenStateCoordinator when the Secret is rotated.

The processor uses a processor-owned routing model: a single RoutingSnapshot maps instruments to strategies, a RouteCommandLoop owns all route state mutations, and non-suspending trySend delivery ensures a slow consumer degrades its route rather than blocking the router. See Live Market Pipeline And Processor Machinery for the full routing, degradation, and health model.

Historical Data Path

Historical data flows in the opposite direction:

  1. backend-server receives a historical request from downstream consumers
  2. AppDataBridgeManager calls backend-app over AppDataBridge
  3. backend-app serves the request from AppDataBridgeImpl
  4. the implementation reads historical data from HistoricalDataProvider
  5. in production, that provider is backed by recorder/QuestDB access and rolls the raw data up to the requested interval before returning it

Server Aggregation Path

backend-server does not own live processing state directly. Instead it:

  • accepts one or more DataBridge connections from app instances
  • aggregates them through DataStream
  • exposes the merged state through its delivery surfaces
  • reports bridge readiness based on active app connections

That separation matters when debugging bugs that look like "server state is stale" but are actually upstream bridge or app-runtime issues.

Bootstrap And Dependency Injection

Both backend-app and backend-server use the same startup model:

  • Ktor concurrent modules are enabled with startup: concurrent
  • suspendable startup dependencies are registered provider-first so Ktor can await them during startup
  • repositories such as OrdersRepository, StrategyRepository, and PositionsRepository initialize fail-fast through suspendable factories
  • Dagger 2 remains the compile-time safety boundary that assembles the final application graph
  • startup failures bubble to the engine and terminate the process, which lets Kubernetes treat initialization failure as a crash rather than leaving a partially alive pod

The practical outcome is that the system prefers hard startup failure over partial readiness.

Storage And Persistence Roles

The main persistence responsibilities are split intentionally:

  • backend-database packages the primary application datastore deployment and schema bootstrap
  • backend-recorder provides QuestDB integration for raw market data ingestion and historical lookup; its module also owns the recorder deployment/manifests
  • backend-sync owns sync-event persistence and instrument-sync recovery state using Postgres, but it relies on pre-registered Feed rows that still live in Gel application state
  • application-level orders, positions, and strategy/feed configuration live behind the main repositories rather than in QuestDB

One useful mental model:

  • Gel/main datastore: application state
  • QuestDB: time-series market data
  • backend-sync Postgres: sync-event lifecycle

Frontend And Delivery Model

frontend-app is the main UI codebase and produces the web distribution consumed by backend-web-app.

backend-web-app does not build its own frontend. It resolves the frontend-app web artifact, stages it into the Caddy image, and becomes the deployable web surface.

Supporting frontend modules such as frontend-graphql, frontend-websocket, frontend-api, and frontend-charts keep transport and presentation concerns out of the main UI module.

Build And Deployment Model

The project uses Gradle build types and custom build logic:

  • DEV, STG, and PROD are controlled through -PbuildType=<type>
  • build-logic contains the convention plugins for build-type config, Kubernetes image naming, JKube test-fixture rendering, and JKube DSL extensions
  • modules that build images use conventions.kubernetes.image
  • modules with richer manifest customization use jkube.extension

Deployment is split between JKube and Terraform:

  • JKube renders Kubernetes resources from the application modules
  • terraform/edge/oci owns the OCI/Cloudflare production edge
  • terraform/edge/local owns the local dev Cloudflare edge
  • terraform/kubernetes owns in-cluster monitoring and app workloads behind that edge

How To Navigate The Docs

Use the docs in this order:

  1. Architecture Overview for the module and boundary map
  2. Live Market Pipeline And Processor Machinery for the live runtime path
  3. Testing Guide for how that behavior is verified
  4. Roadmap for current milestone status and what to work on next

For subsystem-specific operational details, keep using the module-local docs listed in docs/README.md.