docs/ARCHITECTURE.md
Rama main · réplica de lectura del repositorio original.
# FlintTrade Architecture
Broker connections use six native adapters: Dhan, Upstox, Kotak Neo,
INDmoney, Groww, and Delta Exchange. A broker session cannot be started from the terminal today. Practice mode and example data work without a broker. Broker writes return 503 and broker reads return 409. Live order placement stays disabled unless it is explicitly unlocked and every safety layer passes.
> Reflects `v0.0.1`. 18 package surfaces (13 Python + 3 apps: React
> terminal, Electron desktop shell, Next.js site + 1 shared TypeScript
> design-system package + 1 Rust/PyO3 tick engine).
> Run `python scripts/ft.py test` and terminal Vitest locally for the current
> test counts.
This document is the architectural reference for contributors. For a
user-facing overview, see [USER_GUIDE.md](USER_GUIDE.md). For first-party HTTP
contracts, see [API.md](API.md). For repo conventions, see
[DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md).
---
## 1. High-level component diagram
```mermaid
flowchart LR
terminal[Terminal / Electron renderer] --> backend[FlintTrade backend]
backend --> practice[Local Practice sandbox]
backend --> reads[Exact-account BrokerReadPort]
backend --> gate[SafetyContext admission]
gate --> router[BrokerRouter]
reads --> native[Native broker adapters]
router --> native
backend --> storage[Workspace / vault / market storage]
```
The native HTTP connection and read surfaces remain frozen. The diagram shows
the internal safety boundaries; it does not imply operator availability.
The Electron shell has machine authority but no trading authority. It owns
tool acquisition, the managed checkout, source promotion, the source guardian,
native windows and shell updates. The renderer receives named
`window.flintDesktop` methods only. The Python backend continues to own all
trading, authentication, configuration and durable user data.
Desktop source lives at `~/.flinttrade/src/FlintTrade` and verified tools at
`~/.flinttrade/tools`. User data remains in the platform workspace. First
launch builds a sibling candidate with the frozen repository locks, promotes it
only after the build completes, then requires both the guardian's exact ready
sentinel and a loopback `/api/v1/ping` response before the main window opens.
Updates use the same separation: the running checkout is immutable, candidate
health proof uses an isolated temporary workspace, and promotion retains one
last-known-good source for rollback.
The Electron installer is not the application runtime. The source-built web app
is distinct from Electron-shell installers, and source/runtime updates remain
separate from shell-installer updates. A release is accepted only when it
contains all four canonical installers plus `SHA256SUMS.txt`; retired Tauri and
PyInstaller assets never satisfy this architecture.
When the operator selects managed Ollama, the backend owns an on-demand managed install on a backend-only loopback port. FlintTrade selects an unpredictable free high loopback port, starts
Ollama there, and accepts the endpoint only after proving process-tree listener
ownership. A competing bind makes startup fail closed. FlintTrade downloads the pinned runtime only after explicit
confirmation, verifies its SHA-256 digest before extraction, disables Ollama
cloud access, and stores models under the platform workspace. Accepted model
digests are stored separately from Ollama's model store. Explicit acceptance
creates a digest-derived locked alias, which each inference verifies before the
request and against the loaded digest afterwards before releasing output. Cloud
and custom OpenAI-compatible providers remain available;
intentionally self-hosted servers belong under the `custom` provider.
Runtime releases live in separate version directories. A stopped update stages
and verifies the preferred release before atomically selecting it, retaining one
fully rehashed rollback release. Uninstall transactionally quarantines only
fully verified releases recognised by the current build, recovers an interrupted
transaction at the next startup, and preserves models and trust metadata. Every
lifecycle mutation holds an owner-only cross-process workspace lease. Lifecycle,
receipt-journal and operation-owner lease paths reject links, reparse points,
non-regular files and foreign POSIX ownership before use. A backend restart never
signals an inherited PID; if Ollama survives its owner, lifecycle changes fail
closed until the operator terminates that process. Model reclamation is
API-driven: exact unselected names and unreferenced FlintTrade locked aliases can
be removed, but FlintTrade never traverses Ollama's blob store to delete files.
Browser mutations carry a durable client admission ID, making a lost or timed-out
HTTP response idempotently reconcilable through status. A bounded detailed
receipt journal compacts older terminal admission IDs into a fail-closed spent-ID
filter; corrupt journals and unknown outcomes never reopen admission. Shutdown consumes one
deadline across config-lock and runtime-state admission, operation cancellation,
inference drain and teardown. Destructive model operations hold the runtime-state
lock through live inventory and trust reconciliation, so shutdown either cancels
before the irreversible request or waits for its verified result. Windows production
children are contained in a private Job Object;
POSIX process-group identifiers are never signalled after the retained root has
been reaped.
---
## 2. Package dependency graph
```mermaid
flowchart TD
desktop -. "boots managed source" .-> terminal
desktop -. "starts source guardian" .-> core
terminal --> core
terminal --> ditto
terminal --> screener
terminal --> ai
terminal --> automation
terminal --> webhooks
terminal --> engine
terminal --> historical
terminal --> designSystem[design-system]
site --> designSystem
site --> docs[docs/]
engine --> core
engine --> data
engine --> gateway
backtestEngine[backtest] --> engine
backtestEngine --> tickEngine[ticks]
backtestEngine --> historical
backtestEngine --> indicators
screener --> data
screener --> historical
screener --> indicators
ai --> core
ai --> data
journal --> data
journal --> core
webhooks --> core
webhooks --> engine
automation --> core
automation --> engine
ditto --> engine
ditto --> gateway
historical --> data
historical --> core
data --> core
indicators --> core
gateway --> core
```
Solid arrows point from dependent to dependency. The dashed desktop edges are
runtime orchestration, not JavaScript or Python package imports. Runtime code lives under
`packages/{apps,core,integrations,services}`. The public site and terminal
both consume the shared design-system package; the site also consumes
repository docs and package READMEs to generate its pages, docs MCP, and
llms files.
---
## 3. Frontend architecture (terminal)
The terminal is a single React 19 + TypeScript application built with
Vite 8. Layout is managed by [FlexLayout 0.11](https://github.com/caplin/FlexLayout),
which provides drag-and-drop panels, tabs, floating windows, and
serialisable layouts. Users compose their workspace from 71 widgets
(18 trading + 31 analysis + 22 utility) across nine applications.
### Application shell
The app bar lists Terminal (`/terminal`), Testing (`/testing`), Portfolio
(`/portfolio`), Automation (`/automation`), Reports (`/reports`), Learn
(`/learn`), Brokers (`/brokers`), Settings (`/settings`), and System
(`/system`). An older address opens the matching application and keeps the
query string, the hash, and any navigation state. `/home` and `/trade`
open Terminal. `/invest` opens Portfolio. `/lab` opens Testing. `/automate`
and `/ai` open Automation; `/ai` opens the AI Centre panel. The page title
is AI Centre, and the catalogue calls that surface an AI workspace.
`/ditto` opens Brokers. `/admin` opens the system console on System, and
`/admin/observability` opens operational observability there. `/login`
opens `/welcome`.
`/explore` depends on the build and the session. The public site redirects
`/explore` to the Demo at `/demo-app/welcome`. The public demo bundle
replaces `/explore` with `/terminal`. An installed build replaces
`/explore` with `/welcome`. When that Welcome screen already has a
signed-in session, it replaces the address with `/trade`, and the shell
then opens Terminal. A signed-out installed visit stays on `/welcome`.
**Try with example data** opens `/home`, which the shell replaces with
Terminal. Alt+H is labelled Go to Home and Alt+T is labelled Go to Trade.
Both finish on Terminal.
### State architecture
**Boundary rules** — data enters through one path only and is never
duplicated:
- **Jotai atoms** — WebSocket real-time data only.
- **TanStack Query** — REST API responses only.
- **Zustand stores** — derived and UI state only (connection status,
active layout, settings mirror, aggregated P&L, current mode).
### Market session clock
TopBar market status and the Market Clock widget are CAS-aware. Phases are Continuous, CAS, Matching, Transition, Post-close, and Closed
— one TopBar chip active; tooltip/title is the window (for example
`CAS · 15:15–15:35`). Through 6 December 2026, cash is never green "open" after
15:15 IST; CAS is not Closed. When equity F&O still runs after cash
continuous ends, a secondary `F&O open · till 15:40` chip appears.
From 7 December 2026 (NSE/CMTR/77355 and NSE/FAOP/77356) cash continuous
runs until 15:30, the auction is 15:30–15:40, and the secondary chip reads
`F&O open · till 15:45`. An index contract stops at 15:30 on the expiry
written on its symbol. A supplied expiry calendar confirms that date. A new
order stays closed when the expiry cannot be read, or when a supplied
calendar does not list it. The same derivative close
applies to BSE. Matching ends at 15:45 and 15:45–15:50 is the transition.
Non-CAS cash still trades continuous to 15:30. Cash auction order entry
stops from 15:38, when that entry can already have closed at random.
Equity derivatives are not in the auction. The Muhurat
session still overrides the ordinary clock. The clock helpers
(`packages/apps/terminal/src/lib/market.ts` and
`packages/core/ticks/src/session.rs`) must not treat a flat NSE
09:15–15:30 window as "open until 15:30". Closing-price copy is not
"VWAP last 30 min".
### Frontend stack
For dependency purposes across the backend, desktop, website, and native tick
engine, see [Technology Stack and Dependencies](TECH_STACK.md). Its source links
identify the exact resolved versions; the table below describes version families.
| Category | Library | Why it's pinned |
|---|---|---|
| Language | TypeScript 7 (strict) | No `any`, no `@ts-ignore`. |
| Framework | React 19 | Current React release for the terminal SPA. |
| Build | Vite 8 | Fast HMR, ESM-first. |
| CSS | Tailwind CSS v4 | `@tailwindcss/vite` plugin, no `tailwind.config.js` for tokens. |
| Components | shadcn/ui | Copy-paste ownership, Radix accessibility primitives. |
| Layout | FlexLayout 0.11 | Tabs, splits, drag-dock, JSON-serialisable. |
| Interop | FDC3 user channels (in-process) | Colour-coded widget linking + ViewChart/CreateOrder intents. |
| Analytics | FINOS Perspective 3.8 | WASM streaming pivot engine behind the Portfolio Pivot widget. |
| Charts | Flint chart core over Lightweight Charts v5 | Runtime adapter, shared theme, drawing, indicator, and mini-chart contracts. |
| Streaming grid | Glide Data Grid | Canvas-rendered, 100K updates/sec. |
| Static grid | TanStack Table v9 | Headless, sortable, filterable. |
| State | Zustand v5 + Jotai + TanStack Query v5 | Separation of concerns by boundary. |
| Forms | react-hook-form + zod | Runtime validation, type inference. |
| Router | react-router (v8; RouterProvider from `react-router/dom`) | Lazy-loaded route modules. |
### Chart ownership boundary
The terminal app does not create chart engines directly. Runtime value imports
from `lightweight-charts` are isolated to
`packages/apps/terminal/src/lib/lightweightChartRuntime.ts`, the runtime adapter
that bridges vendor APIs into Flint-owned chart contracts. The shared chart
surface lives in `packages/core/design-system/src/charts/`:
- `lightweight.ts` owns chart factories, theme application, canvas labelling,
series registration contracts, and reusable layout constants such as
`FLINT_TRANSPARENT_CHART_LAYOUT`.
- `theme.ts` owns the Flint market-chart palette derived from design-system
tokens.
- `drawings.ts` owns drawing persistence, draft progression, hit-testing,
movement, handles, render specs, and the drawing render-plan contract
(`createFlintChartDrawingRenderPlan`) that maps drawings to line series,
price lines, and markers. It also owns render-plan lifecycle diffing through
`createFlintChartDrawingRenderPlanDiff`, so terminal hooks reconcile
added, updated, unchanged, and removed drawing artefacts from core specs
instead of rebuilding unchanged chart series.
- `indicators.ts` owns indicator defaults, periods, panes, colours,
serialisation, static indicator line/histogram render specs, pane-aware
series option contracts through `createFlintChartIndicatorSeriesRenderPlan`,
series lifecycle diffing through
`createFlintChartIndicatorSeriesRenderPlanDiff`, and OI overlay render
semantics such as `createFlintChartOIProfileBarData`.
- `plotly.ts` owns `createFlintPlotlyTheme`, the shared default Plotly config,
and layout merging for advanced charts that cannot use Lightweight Charts.
- `components.tsx` owns React-visible chart primitives such as legend rows,
mini sparklines, donut breakdowns, and ranked bars.
Application widgets may pass data and user intent into these contracts, but
they should not call `createChart`, `chart.addSeries`, `createSeriesMarkers`,
or import Lightweight Charts runtime values directly. Terminal hooks may attach
or remove series through `lightweightChartRuntime.ts`, but render decisions must
come from the core contracts. The terminal regression test
`src/hooks/__tests__/flintChartCore.test.ts` enforces this boundary so new chart
work remains built on the core rather than around it.
Plotly is the explicit runtime exception for heavy 3D analysis surfaces that
Lightweight Charts cannot represent well, such as volatility surfaces. The
shared theme, modebar defaults, and axis/layout merge policy live in core via
`createFlintPlotlyTheme`, `FLINT_PLOTLY_DEFAULT_CONFIG`, and
`mergeFlintPlotlyLayout`. The terminal keeps only the heavy Plotly runtime
wrapper in `src/components/charts/PlotlyChart.tsx`, so Plotly stays lazy-loaded,
documented, and limited to analysis modules.
---
## 4. Backend architecture
FlintTrade's backend is a single Flask application registered as
`packages/core/core/src/flinttrade_core/app.py`. Source/browser mode defaults to
port 5100; the Electron source guardian explicitly selects a dynamic loopback
port instead. Blueprints mount at `/v1/*` *or* `/api/v1/*` (match the
prefix the frontend uses). The Vite/dev proxy exposes them under
`/ft-api/…` and WSGI middleware strips that prefix (see §6).
**One backend process per workspace.** In-memory job/runner state (scheduler
jobs, download queues, sandbox runtime, session registries) assumes a single
authoritative process. That constraint is enforced, not assumed:
`backend_instance.py` acquires a kernel-backed workspace lock before the
runtime starts (both the `run()` and WSGI entrypoints), and a second launch
against the same workspace fails fast with `BackendInstanceAlreadyRunning`.
The HTTP server (waitress) is single-process/threaded, so no multi-worker
deployment can split that state.
### Safety layers
Every order FlintTrade submits goes through admission when it's placed.
Every **Live** order placed through FlintTrade is then checked by five
safety layers inside `packages/services/engine/`. Practice orders skip this
safety chain. The submit routes are `POST /api/v1/orders/place`,
`POST /api/v1/orders/<broker>/place` (Live only),
`POST /api/v1/positions/exit-all` (a server reduce-only proof, then the
flatten verb), and `POST /api/v1/orders/bracket` when the body has exactly
one stop-loss or one target. Each bracket leg is admitted, then placed
through SafetySystem. Practice on that route is HTTP 403
`practice_unsupported`. GTT, a broker-held variety, a stop-loss and a
target together, and a trailing stop are refused before that admission.
Example-data placement is refused by the backend
(HTTP 403 `mode_blocked`,
`Orders are not available for Example. Switch to Practice or Live to trade.`)
and does not enter `Laya.admit`.
Order Pad Example Buy is a local client fill (`Example order placed`, id starting `SAMPLE-`; no HTTP
order route, no Laya admit, no SafetySystem). Operator and automate
**place** run the mode guard, then `Laya.admit`. Live place is checked
by Laya admission and then SafetySystem L1–L5, `gate_order`, and
`BrokerRouter` on that place. `"variety": "gtt"` is HTTP 422
`gtt_unsupported` before that admission, on place, routed place,
exit-all, and a bracket. A refusal or a quantity clamp stops before SafetySystem.
Practice place is admitted before the Practice fill path and does not enter
those Live layers. A named contract must be a positive multiple of its lot
from the broker instrument master; a missing lot is refused before the fill.
Practice square-off is place. That book cancels and modifies; it does not
place. `cancel-all` only cancels. Example data remains `mode_blocked` and does
not enter `Laya.admit`. Other Live write verbs still reach SafetySystem
without this admission. On Live, a reduce-only close qualifies only when the
position book and the broker order book are both readable, the order is the
opposite side of the same contract, and the quantity is no larger than the
open quantity minus pending exits. An unreadable broker order book refuses
a reduce-only close, and the order takes the full check. `POST /api/v1/positions/exit-all` requires that reducing proof
before it requests exits. Unavailable position evidence returns HTTP 503
`Position evidence unavailable; no square-off was sent.` Ambiguous position
evidence returns HTTP 409 `Position evidence is ambiguous; no square-off was sent.`
Neither response sends a square-off. A second exit on the same broker account,
while one of this desk's exits on that contract is still unfilled, is HTTP 409
`exit_pending` when the broker order book can be read:
`Not placed. An exit for SYMBOL is already pending. Wait for it to fill, or cancel it and try again.`
When a retained exit exists and broker orders are unavailable, the code is
`exit_orders_unreadable` and the message is
`Not placed. Broker orders for SYMBOL are unavailable. Reconcile them before another exit.`
The label is the symbol, or `this contract` when the symbol is empty. See
[ORDER_SAFETY.md](ORDER_SAFETY.md).
`_check_order_locked` fail-fasts in this runtime order (not L1–L5
numerical order), so the first SafetySystem refusal an operator sees is
the earliest of:
1. **L5 Kill switch** — an explicit operator action (UI button, API, or Telegram)
that cancels open orders and requests position flattening through the gated
broker path. The account MTM circuit breaker is a separate automatic path.
2. **L4 Daily P&L** — pause new orders at 3 % drawdown and latch a new-order
hard stop at 15 % drawdown. Layer 4 does not cancel or flatten.
3. **L1 Order validation** — price within ±5 % of LTP, quantity must be
positive and within the per-exchange quantity cap.
4. **L2 Position limits** — max five simultaneous positions, no single
position over 60 % of free margin.
5. **L3 Portfolio risk** — net delta and net vega caps across the book.
Laya admits operator and automate place before SafetySystem (Live) or the
sandbox (Practice). It does not place an order and does not mint
`gate_order`, and it does not replace L1–L5. A refusal or a quantity
clamp stops before those next steps. Admit checks Down, then the hard
rules, then typed free-text questions on the opt-in decision process.
The model can deny or clamp only. Only Down mutes Live place. Degraded leaves Live open and enforces a tighter
quantity ceiling. Chat is not an admission source. Modify, cancel,
smart, multi, forever modify and cancel, and the other non-place write verbs still reach SafetySystem without this place admission. `POST /api/v1/orders/forever` does not place. A valid body is HTTP 501 `Orders are placed through /api/v1/orders/place.` and the route does not call a broker. A GTT body is HTTP 422 `gtt_unsupported` before Laya, SafetySystem, and any broker call. No submit route reaches a broker forever or super-order endpoint. The Kotak Neo adapter refuses a `gtt` place. Laya starts Down; the three statuses are Ready, Degraded, and Down. `GET /health` records them from the managed Laya process when one is registered. The desk ping publishes the stored Live-facing status and does not invent Ready. A base checkpoint is not qualified for Live, so Live stays Down until a qualification record matches the pinned revision and policy. See [ORDER_SAFETY.md](ORDER_SAFETY.md).
### Broker reads versus gated writes
Live **writes** still mint a `SafetyContext` through `gate_order` /
`gate_broker_write` and dispatch through `BrokerRouter`. Read operations do
not go through the write router.
### Broker account transaction foundation
The native account foundation provides immutable transaction contracts, an
encrypted vault participant, a workspace commit witness and one app-lifetime
runtime owner. Its coordinator is exercised with synthetic drivers. Production
native account mutations still return `503` and native HTTP reads still return
`409`; this foundation does not install a provider driver or complete broker sessions in the terminal.
Vault schema 4 retains credential incarnation and operation evidence while
anchoring the current ledger and audit outbox in an authenticated head digest.
Replaying an older individually valid operation row cannot reopen a settled
unknown authentication outcome. Workspace recovery checks both participants;
unknown or conflicting outcomes retain their claim and require a later explicit
resolution policy. Recovery does not authenticate or recreate provider sessions.
Read generations pin enrolment from validated vault state and consult the
workspace coherence verifier. Removing the document marker cannot select a
legacy read path, and an older legacy generation loses authority when durable
enrolment begins. Shutdown stops rotation admission and drains its workers
before retiring account-owned broker dependencies.
Each terminal receipt and its pending audit event are persisted atomically.
The event uses stable, vault-keyed references for the operation, selector,
actor and session, and exports only terminal state and version counters.
Credentials, labels, raw account IDs and exception messages are excluded.
Audit export uses bounded batches outside vault and publication locks. Delivery
is acknowledged only after the sink verifies the exact stable event ID and full
evidence in an intact audit chain. Failed or lost acknowledgements remain
pending and retry the same event without repeating the account mutation.
### Mode-system state machine
```mermaid
stateDiagram-v2
[*] --> Practice
Practice --> Live: POST /v1/auth/live\n+ 6-digit PIN
Live --> Practice: POST /v1/auth/mode\n{mode:practice}
Guest --> Setup: Mode menu Practice
state Guest {
[*] --> exampleChip
exampleChip: Chip Example.\nClaim explore.\nNot a menu Mode.\nThis choice does not call\nPOST /v1/auth/mode.
}
state Practice {
[*] --> practiceFills
practiceFills: Simulated fills.\nNo real broker order.
}
state Live {
[*] --> realOrders
realOrders: Orders routed to a native broker\nadapter;\nsafety layers active
}
```
A downgrade to Practice issues a fresh JWT with the new `mode` claim and
revokes the old token's `jti`. Practice → Live is `POST /v1/auth/live` (PIN
re-auth plus authenticator enrolment), and that route is the only way into
Live. Quick unlock `POST /v1/auth/pin` restores the existing session and
keeps its Mode. `/v1/auth/mode` accepts only `{ "mode": "practice" }`. Any
other value returns HTTP 400 with `Only a downgrade to practice is allowed
here. Switch to Live via POST /v1/auth/live with PIN verification.` and
leaves the current session in place. That call does not latch the kill
switch. The Mode menu lists Practice and Live. Connected (read) is a broker
status, not a menu item. The visible chip is taken from the JWT `mode` claim
when the session token has one: **Example**, **Practice**, **Live**, or
**Unavailable**. The claim is `explore`, `practice`, or `live`. An opaque
sample session has no claim to apply and stays **Example**. A JWT whose mode
is missing, or is not one of those three, shows **Unavailable** and never
Example. Connected (read),
the idle lock, broker connectivity, and the decision model are not that
claim. A fresh
browser, account setup, and a finished password sign-in open in Practice.
Example data is not a menu Mode. Choosing **Practice** while signed in as **Guest** (chip **Example**)
opens Setup and does not call `POST /v1/auth/mode`. The guard lives at
`packages/services/engine/src/flinttrade_engine/mode_guard.py`.
Example data has no Live broker order authority: backend and Live-intent
order paths still refuse with `mode_blocked` and never call a broker.
The exception is Order Pad Example Buy on Terminal, which records a
local example fill (`Example order placed`, id starting `SAMPLE-`; no HTTP order route, no SafetySystem,
no broker). Practice remains simulated fills; Live remains the
gated broker path.
---
## 5. Data flow
Ticks fan in to per-instrument Jotai atoms which power every chart and
quote widget. REST data populates a separate query cache. Orders hit the
mode guard first. Operator and automate place then run `Laya.admit`.
Example data is refused as `mode_blocked` and does not enter that admission.
An allowed Practice place stays inside FlintTrade's native sandbox and
never enters SafetySystem or `BrokerRouter`. Other Practice verbs skip
`Laya.admit` and stay in that sandbox. An allowed Live place then
runs the safety layers and the gated broker router, and routes through a
native broker adapter. Other Live
writes still go from the mode guard to SafetySystem without `Laya.admit`.
Order acknowledgements stay on the submission path. Authoritative native fill
ingestion is not connected. `packages/services/engine/src/flinttrade_engine/reconciliation.py`
compares native broker snapshots with local observations and leaves positions
and orders unchanged.
---
## 6. WSGI prefix strip
The terminal calls FlintTrade through the `/ft-api` prefix. The Vite
dev proxy and the production reverse proxy forward that to the FlintTrade
backend on port 5100. The WSGI middleware in
`packages/core/core/src/flinttrade_core/app.py` strips `/ft-api` before
URL dispatch.
A blueprint at `url_prefix="/v1"` answers `/ft-api/v1/…`:
```
External: GET /ft-api/v1/auth/status
│
▼ (Vite proxy or reverse proxy)
Backend: GET /v1/auth/status
│
▼ (Flask URL map)
Handler: flinttrade_core.auth_routes:auth_status
```
A blueprint at `url_prefix="/api/v1"` answers `/ft-api/api/v1/…` after
the same strip — for example `POST /ft-api/api/v1/gex` reaches
`screener.analysis_routes:gex_endpoint`. **Never double-prefix** a `/v1`
blueprint as `/api/v1` (or the reverse): a handler registered at `/v1/X`
will 404 if the terminal calls `/api/v1/X`. Match the prefix the
frontend helper actually uses.
Routes documented in [API.md](API.md) as `/ft-api/…` are the external
view; the path after `/ft-api` is the Flask URL.
---
## 7. Configuration architecture
Workspace-first with a dev/server fallback.
### Tier 1: `workspace.json` — UI-owned runtime configuration
- **Storage paths** — `storage.fast` (SSD) and `storage.archive` (HDD).
- **Enabled modules** — which packages are active.
- **UI preferences** — theme, default exchange, time zone, density.
- **LLM config** — provider and model from the catalogue-driven profiles in
`llm_provider_profiles.py` (generated into the terminal as
`serviceProviders.ts`). The managed Ollama endpoint is owned internally and
is not persisted; custom OpenAI-compatible providers retain an editable
host. NVIDIA NIM is in the catalogue with an intentionally blank unpinned
default model. Inert LLM connection records can also be stored through
`GET`/`POST`/`PATCH`/`DELETE` `/v1/services/connections` without invoking
the provider; the static provider catalogue is `GET /v1/services/providers`.
- **Notification config** — Telegram bot settings.
- **Order-safety settings** — rate limits, audit retention, kill-switch.
Lives in a platform-specific workspace directory:
| Platform | Location |
|---|---|
| Linux | `~/.flinttrade/` |
| macOS | `~/Library/Application Support/flinttrade/` |
| Windows | `%APPDATA%/flinttrade/` |
| Override | `FLINTTRADE_WORKSPACE_DIR`, then `FLINTTRADE_HOME` (in that precedence order) |
`workspace.json` contains:
### Tier 2: `.env` — advanced dev/server fallback
### How packages read config
Feature packages do not read `os.environ` for data paths themselves. They
use the `Workspace` class, which resolves the platform workspace directory
(`FLINTTRADE_WORKSPACE_DIR`, then `FLINTTRADE_HOME`, then the OS default)
and `storage.fast` / `storage.archive` from `workspace.json`. Those workspace
paths are distinct from specialised env overrides: `DATA_DIR` only affects
`ditto_accounts_path()` and does not rewrite `config.workspace.fast_data_dir`;
`AUDIT_LOG_DIR` is read by `audit_log_dir()`; `DUCKDB_PATH` is read by
`duckdb_path()`.
---
## 8. Authentication
### FlintTrade JWT
- Issued on `/ft-api/v1/auth/login` after argon2id password
verification, with `mode` `practice`. Account setup mints the same
Practice session. `POST /v1/auth/setup/resume` is public: a reload
mid-setup proves the password and mints a setup-session JWT again.
`POST /v1/auth/setup/complete` needs that session. Login is
password-only until authenticator enrolment is confirmed
(`totp_enabled`); a TOTP or backup code is required only after that.
- Optional second factor: TOTP enrolment with Fernet-encrypted seed
(`POST /v1/auth/totp/enable`). `POST /v1/auth/live` refuses with
`totp_required` until enrolment is confirmed. Quick Unlock of a session
that is already Live keeps that check and keeps Live.
- **Expires at 8 AM IST the next day.** No refresh tokens — sign in
again.
- Carries `sub` (user), `exp` (expiry), `mode` (`explore` for example data, `practice`, or
`live`), `jti` (unique ID), plus `oid` and `epoch`. The Mode chip is taken from that claim. Operators see Example, Practice, Live, or Unavailable. A missing or unusable claim shows Unavailable, never Example. Connected (read) is a broker status, not a session Mode.
- PIN unlock (`POST /v1/auth/pin`) revokes the presented `jti` and
returns a new token. The previous token stops working. The Live
switch does the same rotation when it enters Live.
- Reset of a finished account needs an active session. Once an
authenticator is enrolled it also needs the password and the current
authenticator code. The wipe bumps `epoch`, so other sessions end. A
signed-out reset with no authenticator enrolled is refused with
"Sign in to reset this account. You'll need your password." With an
authenticator enrolled it is "Sign in to reset this account. You'll
need your password and authenticator code." Recovery asks for an
authenticator code only once one is enrolled.
- Revocation blocklist keyed by `jti` in
`packages/core/core/src/flinttrade_core/auth_state.py`.
- Non-public routes accept a session JWT or `FLINTTRADE_API_KEY`. The
session JWT is read from `Authorization: Bearer` or from
`X-FlintTrade-Token`. An API key on `X-FlintTrade-Token` does not pass.
An API key is not a session. `GET /healthz` and `GET /readyz` are public
and return status only. `GET /health` is not public. The allowlist is
`flinttrade_core.public_routes.PUBLIC_ROUTES`. `POST /csp-report`
accepts `application/csp-report` and `application/reports+json`.
### Server-side mode enforcement
When `X-FlintTrade-Mode` is present and differs from the JWT `mode` claim,
order routes return HTTP 403
`{"status":"error","message":"X-FlintTrade-Mode does not match the authenticated mode"}`
with no `code` field. A missing header, or a header equal to the claim, is
not a mismatch. The core `/api/v1/orders/*` proxy fans out by JWT mode: example data is
HTTP 403 `mode_blocked`
(`Orders are not available for Example. Switch to Practice or Live to trade.`),
Practice routes to the Practice fill path, and
Live requires `live_mode_unlocked` plus the gated `BrokerRouter`.
Executor-direct engine routes (basket, split, bracket, options-strategy)
use `mode_guard.require_live_unlocked`: example data is `mode_blocked`,
Practice is `practice_unsupported` (no Practice parity yet), and Live
without PIN unlock is `live_locked`. A Live bracket with exactly one
stop-loss or one target places through that guard. Basket, split, and
options-strategy place return HTTP 501 and do not place. Order Pad Example Buy on Terminal
with example data is a local example fill (`Example order placed`, id starting `SAMPLE-`) — no HTTP order route,
SafetySystem, or broker.
---
## 9. Infrastructure and deployment
### Task runner
`scripts/ft.py` is the primary interface. It is stdlib-only and behaves
identically on Windows, macOS and Linux — no make and no bash required. After
an install, the shim exposes the same subcommands as `flinttrade <subcommand>`.
```bash
python scripts/ft.py setup # first-time install (deps, workspace)
python scripts/ft.py start # start FlintTrade backend
python scripts/ft.py stop # stop FlintTrade backend
python scripts/ft.py status # show service and port status
python scripts/ft.py test # run all Python tests
python scripts/ft.py test-fast # stop on first failure
python scripts/ft.py lint # ruff + the terminal react-hooks ESLint gate
python scripts/ft.py dev # start React dev server + FlintTrade backend
python scripts/ft.py clean # remove build artefacts
```
### External test dependencies
| Service | Local-dev path | Source | Role |
|---|---|---|---|
AlgoMirror is intentionally absent — its mirroring patterns are reimplemented
natively in `packages/services/ditto/` (our own code; the upstream repo is not
tracked, pulled, or called at runtime).
### Scripts
| Script | Purpose |
|---|---|
| `infra/scripts/setup.sh` | First-time installation. |
| `infra/scripts/status.sh` | Service status, ports, disk usage. |
| `infra/scripts/health-check.sh` | Health check (exit 0/1). |
| `scripts/reset-flinttrade-state.sh` | Wipe the FlintTrade workspace for a fresh-user test. |
### Docker
Docker is an advanced self-hosting path, not the quickstart: these targets need
make (POSIX only) and Docker. A `.env` file is optional — the default profile
starts only the app services (backend, terminal build, nginx serving the UI at
http://localhost:8080); the observability stack sits behind the `monitoring`
compose profile and does require real GlitchTip secrets in `.env`.
```bash
make docker-up # start the app services
make docker-up-monitoring # start the app plus the monitoring profile
make docker-down # stop (all profiles)
make docker-build # rebuild images
```
### Production
Production deployments use `systemd` units under `infra/systemd/`. See
[setup/linux.md](setup/linux.md) for the canonical recipe.
---
## 10. Where to read more
- HTTP and WebSocket contract — [API.md](API.md)
- How to contribute — [DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md)
- Per-version change notes — [releases/](releases/)
- CI behaviour — [CI.md](CI.md)
- Supported brokers / exchanges / platforms — [COMPATIBILITY.md](COMPATIBILITY.md)
- Order safety notes — [ORDER_SAFETY.md](ORDER_SAFETY.md)