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
DataBridgereadiness reporter. - Runtime isolation between the trading engine and the API tier.
backend-appandbackend-serverare separate deployables. The API tier can stay up while a trading session restarts, and a server-side bug cannot corrupt live strategy state.DataBridgeandAppDataBridgeare 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-syncPostgres 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-appis the live trading runtime. It ingests live market data, runs strategies, executes trades, records market data, and publishes runtime state upstream.backend-serveris the API and aggregation runtime. It exposes GraphQL/SSE/HTTP-facing behavior, aggregates connected app instances, and brokers historical requests back tobackend-app.frontend-appis the user-facing Compose Multiplatform client.backend-web-apppackages the frontend web distribution into the deployable Caddy image.backend-database,backend-recorder, andbackend-syncprovide 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-brokerfor OAuth token lifecycle and provisioned-token management - surfacing token auth prompts through
RecoveryManager.recoveryFlow().recoverable(), collected byAuthStateCoordinator - resuming suspended datasources on OAuth auth recovery via
AuthStateCoordinator→ResumableDataSource.resumeIfStopped(), and on provisioned-token recovery viaProvisionedTokenStateCoordinator
This is the module where the live strategy machinery actually runs.
backend-server
backend-server is responsible for:
- hosting the inbound
DataBridgeserver 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-appthroughAppDataBridgefor 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-
AuthBrokerOAuth token state machines (TokenManager, constructed viasuspend operator fun invoke(...)factory) viaOAuthTokenState(Valid, AwaitingAuth, Exchanging, Failed) - managing per-
ProvisionedTokenHandleprovisioned token state (ProvisionedTokenManager) viaProvisionedTokenState(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
AwaitingAuthat 3:30 AM IST - exchanging auth codes for access tokens via broker-specific
TokenExchangeStrategyimplementations - classifying permanent OAuth errors (e.g., wrong client credentials) as
NonRecoverableTokenException→OAuthTokenState.Failed - enforcing blackout periods (3:30 AM–8:00 AM IST) when token requests are rejected
- exposing
recoveryFlow(broker)for OAuth auth prompts, collected viaRecoveryManager.recoverable()byAuthStateCoordinator - on 401, provisioned tokens immediately re-read the mounted file; if the fingerprint is unchanged, transition to
Failedand 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 ProvisionedTokenManageruses a mutex-owned state machine: all four state dimensions (current state, fingerprint, poller lifecycle, expiry timer lifecycle) are owned bymutationGuard, with two locked transitions (becomeValidUnderLock,becomeFailedUnderLock) that prevent the duplicate-poller racelastFingerprintis@Volatileso 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:
OAuthTokenBrokerServiceandProvisionedTokenBrokerService
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:
OAuthTokenHealthReporterpartitionsAuthBrokerentries into active (present in theResumableDataSourceDagger map) and inactive at construction time. Active brokers withValidtokens →HEALTHY;AwaitingAuth→DEGRADED;Exchanging→BLOCKED;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.ProvisionedTokenHealthReporteriterates allProvisionedTokenHandleentries.Valid→HEALTHY;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_ANALYTICS → UpstoxDataSource) 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 frombackend-servertobackend-app, currently centered on historical market data lookupDataBridge: long-lived streaming frombackend-apptobackend-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:
- subscribed instruments are restored and mapped to datasource feeds
- live market data is collected from datasource integrations (HTTP requests suspend on
awaitValidToken()when auth is pending) Processorroutes ticks into per-strategy mailboxes and then intoStrategyExecutorandStrategyExecutionTradeExecutorconsumes signals and emits trade events (resolves broker API viaBrokerApiResolver)- raw market data is recorded to QuestDB and trade events are persisted through the orders repository
- runtime events are forwarded to
backend-serverthroughDataBridgeLauncher - OAuth token auth requirements are surfaced to the UI via
AuthStateCoordinatorcollectingRecoveryManager.recoveryFlow().recoverable()→DataBridgeerror events; OAuth auth recovery triggersAuthStateCoordinator→ResumableDataSource.resumeIfStopped(). Provisioned-token (analytics) failures surface as terminalDEGRADEDfeed health and recover viaProvisionedTokenStateCoordinatorwhen 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:
backend-serverreceives a historical request from downstream consumersAppDataBridgeManagercallsbackend-appoverAppDataBridgebackend-appserves the request fromAppDataBridgeImpl- the implementation reads historical data from
HistoricalDataProvider - 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
DataBridgeconnections 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, andPositionsRepositoryinitialize 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-databasepackages the primary application datastore deployment and schema bootstrapbackend-recorderprovides QuestDB integration for raw market data ingestion and historical lookup; its module also owns the recorder deployment/manifestsbackend-syncowns sync-event persistence and instrument-sync recovery state using Postgres, but it relies on pre-registeredFeedrows 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, andPRODare controlled through-PbuildType=<type>build-logiccontains 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/ociowns the OCI/Cloudflare production edgeterraform/edge/localowns the local dev Cloudflare edgeterraform/kubernetesowns in-cluster monitoring and app workloads behind that edge
How To Navigate The Docs
Use the docs in this order:
- Architecture Overview for the module and boundary map
- Live Market Pipeline And Processor Machinery for the live runtime path
- Testing Guide for how that behavior is verified
- 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.