FlintTrade Developer Guide
Broker connections use six native adapters: Dhan, Upstox, Kotak Neo, INDmoney, Groww, and Delta Exchange. Availability remains evidence-gated. Native broker HTTP mutations and reads remain frozen until Task 9D and Task 7C.2; a broker session cannot currently be established through the terminal. Practice uses the local sandbox. Funded Live placement remains unproven and fail-closed.
This guide is for contributors and integrators. It assumes you have read USER_GUIDE.md so you know what FlintTrade does at a user level, and that you are comfortable with Python, TypeScript, and Git.
For higher-level design context (data flow, mode system, dependency graph), see ARCHITECTURE.md. For the public HTTP and WebSocket contract, see API.md. For CI behaviour, see CI.md.
1. Repository layout
FlintTrade is a monorepo with 18 package surfaces: 13 Python packages, 3 applications (React terminal, Electron desktop shell, Next.js site), 1 shared TypeScript design-system package, and 1 Rust package with Python bindings.
| Package | Language | Purpose | Tests |
|---|---|---|---|
site | TypeScript / Next.js | Public website, generated documentation, contribution pages, and read-only docs MCP | packages/apps/site/src/**/*.test.ts |
terminal | TypeScript / React | User-facing single-page application; FlexLayout workspace, home widgets, routes, and tools | packages/apps/terminal/**/*.test.ts(x) |
desktop | TypeScript / Electron 44 | Sandboxed native shell; verifies tools, builds managed local source, supervises its guardian, and loads only the selected loopback origin | packages/apps/desktop/electron/*.test.ts |
design-system | TypeScript / React | Shared brand tokens, layers, motion, primitives, and FlintTrade UI contracts | type-checked by app builds |
core | Python | Flask app entry point, native broker reads, config, workspace, models, exceptions, service-provider catalogue, inert service connections, and the BrokerReadPort contract | packages/core/core/tests/ |
data | Python | Tick recorder, audit logger, trade logger, SQLite sandbox state, DuckDB analytics storage | packages/core/data/tests/ |
historical | Python | OHLCV downloader (OpenChart, yfinance), DuckDB pipeline, expiry manager, instrument metadata | packages/core/historical/tests/ |
indicators | Python | Pure-NumPy batch indicators (110 exports; no TA-Lib) + streaming classes (optional Numba on 3 kernels) + PineTS (Pine Script conversion) | packages/core/indicators/tests/ |
ticks | Rust + PyO3 | High-performance tick processing engine, Python-callable via wheel | packages/core/ticks/tests/ (cargo) |
webhooks | Python | Generic HMAC-signed custom webhooks, alerter | packages/integrations/webhooks/tests/ |
ai | Python | LLM client (multi-provider), optional RAG/vector store, signals, sentiment, MCP bridge, advisor | packages/services/ai/tests/ |
automation | Python | Cron manager, Telegram bot with kill-switch, post-market analysis | packages/services/automation/tests/ |
backtest | Python | Simulator, metrics (Sharpe, Sortino, drawdown), walk-forward, Monte Carlo, 94 strategy template modules | packages/services/backtest/tests/ |
ditto | Python | Multi-account manager, position mirror, margin calculator, trailing SL, risk manager | packages/services/ditto/tests/ |
engine | Python | 5-layer safety system, order router, scheduler, base strategy, strategy registry, mode guard | packages/services/engine/tests/ |
journal | Python | Journal entries, trade logging, execution-quality analytics, and realised P&L tracking | packages/services/journal/tests/ |
screener | Python | Option chain, OI analysis, PCR, max pain, futures quadrant, IV smile, payoff engine | packages/services/screener/tests/ |
The repository carries a large Python and terminal test suite. Prefer the commands below for current counts instead of relying on stale hard-coded numbers.
2. Development environment setup
Pick the guide for your platform and follow it end-to-end:
For the FULL Python stack — every workspace member plus the ML/AI extras
(vectorbt+numba backtesting, lightgbm/optuna ensemble tuning, local sqlite RAG,
reportlab PDF export) — sync all packages and extras
(the never-consumed talib extra was removed; the indicators are pure NumPy):
uv sync --all-packages --all-extrasOn macOS, lightgbm additionally needs OpenMP: brew install libomp. A plain
uv sync installs a lean base environment; the corresponding ML/export tests
skip with a reason instead of failing.
3. Running tests
Python (pytest)
From the repository root. python scripts/ft.py <target> is the cross-platform
runner — no make and no bash needed, identical behaviour on Windows, macOS and
Linux. make <target> is the POSIX alias for the same targets.
python scripts/ft.py check # affected checks while editing
python scripts/ft.py check --full # exhaustive local gate before pushing
python scripts/ft.py test # full pytest suite
python scripts/ft.py test-fast # stop on first failure
python scripts/ft.py lint # ruff over packages/ + tests/, then the terminal hooks lint
# Single file
python -m pytest packages/core/core/tests/test_app.py -v
# Single test
python -m pytest packages/core/core/tests/test_app.py::TestInputValidation::test_missing_bars_returns_400 -v
# Single package
python -m pytest packages/services/screener/tests/
--import-mode=importlibis required for the flat-package layout.scripts/ft.pyand the Makefile set it for you; if you callpytestdirectly, add it.
Both test commands accept focused paths and pytest flags. Their default worker
count is capped at four; --workers 0 runs serially. check --dry-run prints the
affected plan. Selection and CI contracts are documented in CI.md.
Terminal (Vitest)
From the repository root, using the locked pnpm workspace:
pnpm install --frozen-lockfile
pnpm --filter @flinttrade/terminal typecheck # tsc --noEmit only
pnpm --filter @flinttrade/terminal build # full type-check + Vite build
pnpm --filter @flinttrade/terminal test # full Vitest suiteFor a single file, a single test name, or watch mode, work inside the package
(cd on its own line — do not chain with &&):
cd packages/apps/terminal
npx vitest run src/widgets/path/foo.test.tsx
npx vitest run -t "renders the order pad" # single test by name
npx vitest # watch mode (great for TDD)Visual and accessibility checks
Playwright screenshot comparison and axe checks live in
packages/apps/terminal/e2e/visual-a11y.spec.ts. They cover Home, the
Trading Desk dashboard, the Practice Order Pad before and after a refused
order, the Laya row in the labels the desk actually paints (Ready,
Degraded, Down), the TopBar Status menu, the two-operator
screen, sign-in, and setup. Each one runs at 1440×900 and 2560×1440.
The checks use Example sample data, a frozen clock, motion turned off,
fonts loaded, and masks on the ticker and canvases. Route mocks come from
the same fail-closed registry as the other Playwright specs, so an
unmocked /ft-api call still fails the fixture.
Reticle does not judge these pictures. Reticle reads network, app state, and the console. It cannot see contrast, spacing, or clipping.
The lane is advisory. .github/workflows/visual-a11y.yml writes diffs and
axe results to the job summary, uploads the images, and finishes green
while VISUAL_AXE_GATE is "0". Change that one value to "1" to make
the job fail on a regression. It is not a required check, and it should
stay advisory until it has run quiet on a few pull requests.
Screenshot baselines are generated only by that workflow on ubuntu-latest
(the same hosted image as the terminal Playwright job). Dispatch Visual
and accessibility with Regenerate screenshot baselines enabled, on
the branch that should receive the files. The job commits the
*-chromium-linux.png baselines and pushes them. Do not commit baselines
taken on any other machine. See packages/apps/terminal/e2e/visual/baselines/README.md.
From packages/apps/terminal:
pnpm run e2e:visualDesktop (Electron)
From the repository root, using the locked pnpm workspace:
pnpm --filter @flinttrade/desktop typecheck
pnpm --filter @flinttrade/desktop test:electron
pnpm --filter @flinttrade/desktop bundleThe Electron tests cover the renderer security waist, first-run bootstrap, source promotion/rollback, guardian protocol and recovery, tray/hotkey/native notifications, source updates and shell-installer handoff. They do not move trading or broker authority out of Python.
Rust (ticks)
From packages/core/ticks/:
cargo test
cargo build --release # produces an importable Python wheel4. Building
Terminal
pnpm --filter @flinttrade/terminal buildOutput lands in packages/apps/terminal/dist/. The build runs tsc --noEmit
first, then vite build — both must pass clean. The backend serves the UI only
when packages/apps/terminal/dist/index.html exists, so run this once before
python scripts/ft.py start on a fresh checkout.
ticks
cd packages/core/ticks
cargo build --releaseThe resulting .pyd / .so is imported by the Python tick_engine module
exposed through PyO3 bindings.
Site
pnpm --filter @flinttrade/site typecheck
pnpm --filter @flinttrade/site test
pnpm --filter @flinttrade/site buildThe site build regenerates the docs index, package index, version metadata, llms files, and the read-only docs MCP content from repository source files.
Desktop
python scripts/ft.py desktop-test # Electron TypeScript + Vitest
python scripts/ft.py desktop-build # verify bootstrap resources and bundle main/preload
python scripts/ft.py desktop-package # build and verify this host's installerOn POSIX, make desktop-test, make desktop-build and make desktop-package
are aliases for the same three targets.
Output lands in packages/apps/desktop/release/electron/. The release workflow
produces a universal macOS DMG, Windows x64 NSIS installer, and x64/ARM64 Linux
AppImages. The source-built web app remains distinct from those Electron-shell
installers. A release is accepted only as all four canonical installers plus
SHA256SUMS.txt; retired Tauri and PyInstaller assets never satisfy that gate.
The local macOS packaging target always uses an ad-hoc seal, which verifies
bundle integrity but does not provide Developer ID trust or notarisation. Only
release CI can use complete Apple distribution-signing and notarisation secret
sets.
First launch uses system Git or the official HTTPS archive fallback, verifies
pinned tool distributions, provisions Python 3.12 with uv, installs from the
frozen Python and pnpm locks, builds the terminal, and starts the source guardian
only after candidate promotion succeeds. Rust is not a desktop build
prerequisite; it remains optional for core/ticks.
5. Architecture deep-dive
Component diagrams, the mode-system state machine, the WSGI prefix-strip explanation, and the package dependency graph live in ARCHITECTURE.md. Read that document before making any non-trivial change.
6. Adding a widget
Every widget is a self-contained TSX component that is registered as a FlexLayout panel.
Step-by-step
-
Pick a category. Place the new file under
packages/apps/terminal/src/widgets/<trading|analysis|utility>/<Name>.tsx. -
Write the component. Use functional components and hooks. Pull market data from Jotai atoms (per-instrument LTP / quote / depth), REST data from TanStack Query hooks, and UI state from Zustand stores — never mix layers (see ARCHITECTURE.md).
import { useAtomValue } from 'jotai'; import { ltpAtomFamily } from '@/atoms/marketData'; export function MyWidget({ panelProps }: { panelProps: WidgetPanelProps }) { const ltp = useAtomValue(ltpAtomFamily(panelProps.symbol)); return <div className="widget-shell">{ltp ?? '—'}</div>; } -
Register the widget. In
packages/apps/terminal/src/layout/widgetFactory.tsx, add an entry mapping the widget identifier to your component. -
Write a test. Co-locate as
<Name>.test.tsx. At minimum, mount the component with mocked atoms and assert the rendered output. -
(Optional) add to a workspace preset. Edit
packages/apps/terminal/src/layout/workspacePresets.tsif your widget belongs in one of the 17 listed presets (beginner-coreis an extra unlisted layout resolved by id). -
Update USER_GUIDE.md if the widget changes the user-visible workspace tour.
Style rules
- Use shadcn/ui primitives (Button, Dialog, Input, etc.) — never raw HTML.
- Use Tailwind v4 utility classes; no arbitrary
style={...}except for measured pixel values from observers. - For chart widgets, use the Flint chart core in
packages/core/design-system/src/charts/and the terminal runtime adapter atpackages/apps/terminal/src/lib/lightweightChartRuntime.ts. Do not import Lightweight Charts runtime values or callcreateChart,chart.addSeries, orcreateSeriesMarkersdirectly from widget code. Drawing creation should go throughadvanceFlintChartDrawingDraft, drawing render decisions should go throughcreateFlintChartDrawingRenderPlan, drawing runtime lifecycle diffing should go throughcreateFlintChartDrawingRenderPlanDiff, indicator line/histogram render specs and pane-aware series options should go throughcreateFlintChartIndicatorSeriesRenderPlan, indicator runtime lifecycle diffing should go throughcreateFlintChartIndicatorSeriesRenderPlanDiff, and OI overlay bar semantics should stay in core helpers such ascreateFlintChartOIProfileBarData. - For Plotly-only analysis surfaces, keep the heavy runtime behind
packages/apps/terminal/src/components/charts/PlotlyChart.tsx, but use the corecreateFlintPlotlyTheme,FLINT_PLOTLY_DEFAULT_CONFIG, andmergeFlintPlotlyLayoutcontracts for theme, modebar, and layout behaviour. - Honour
prefers-reduced-motionfor any animation. - Use the Glass Adaptive design system tokens (CSS vars defined in
packages/apps/terminal/src/styles/). No hardcoded colours.
7. Adding a strategy
FlintTrade has two strategy surfaces — backtest-only templates and live-runnable strategies.
Backtest template
For research and parameter sweeps. Lives under
packages/services/backtest/src/flinttrade_backtest/strategies/.
- Create
my_strategy.pyand subclassflinttrade_backtest.base_strategy.BaseBacktestStrategy. - Implement
signal(ctx)returning a typedSignalobject. - Register the template in
packages/services/backtest/src/flinttrade_backtest/strategies/__init__.py. - Write a unit test in
packages/services/backtest/tests/with deterministic input data.
Live strategy
For the production engine. Lives under
packages/services/engine/src/flinttrade_engine/strategies/.
Two production strategies ship today: ema_crossover and wheel_live.
Use either as a reference implementation.
8. Adding a broker adapter
- Add a native adapter under
packages/integrations/gateway/src/flinttrade_gateway/brokers/<broker>.pyimplementing theBrokerAdapterProtocol frompackages/integrations/gateway/src/flinttrade_gateway/adapter.py. Map the broker's native exceptions ontoflinttrade_core.exceptionsand advertise capabilities truthfully (the router relies on them for failover). - Add an entry to the
BROKER_CATALOGdict inadapter.pywith the broker's display name, auth flow type, and capabilities. - Register the adapter in
packages/integrations/gateway/src/flinttrade_gateway/registry.pyso theBrokerRouterinrouter.pycan resolve and dispatch orders to it. - Add tests under
packages/integrations/gateway/tests/— mock the broker's SDK/HTTP responses, assert auth, capability lookup, and error handling. - Update COMPATIBILITY.md with the new broker.
Native connectability is a separate release gate from adapter existence.
Only flip connectable=True after every declared native_connect_blocker has
been cleared and its evidence captured. A real account-path trial may satisfy a
declared evidence blocker, but it does not override an unresolved broker-safety,
SDK-attestation, or emergency-reduction blocker. Activation-blocked adapters stay
visible as "coming soon" so their code, mappings, and mock coverage are kept
without presenting them as ready to connect.
9. Code style and lint
Python
- PEP 8 with
ruffas the enforcement tool. Runpython scripts/ft.py lint(POSIX alias:make lint) before committing — that target lints the terminal as well, see TypeScript below. - Type hints on every public function. We write to the
python_requiresfloor inflint.toml— currently 3.12 — solist[int]notList[int],X | NonenotOptional[X]. CI runspython_target(3.14). - Google-style docstrings for every public function and class.
- Absolute imports for anything cross-package or top-level
(
flinttrade_<pkg>....). Intra-package relative imports (from .sibling import x) are widespread and safe under--import-mode=importlib. - British English in docstrings, comments, and user-visible strings.
Code identifiers stay in their natural form (
color,behaviorare fine inside a CSS shim; user-visible labels readcolour,behaviour).
TypeScript
- Strict mode. No
any, no@ts-ignore, no@ts-nocheck. - Lint at zero warnings.
pnpm --filter @flinttrade/terminal lintrunseslint src --max-warnings=0, making four rules errors rather than suggestions:react-hooks/rules-of-hooksandreact-hooks/exhaustive-depsfor the runtime faultstsccannot see, pluslocal/no-explicit-anyandlocal/no-ts-suppressionfor the two bans above —strictpermits an explicitanyby design, and a pragma is only a comment, so the compiler enforces neither. Both local rules are AST rules (packages/apps/terminal/eslint-local-rules.mjs); the regular-expression script they replaced missedtype Payload = any. CI runs the gate innode-core-tests;python scripts/ft.py lintruns it alongside ruff. - Path alias
@→packages/apps/terminal/src/. Configured intsconfig.jsonandvite.config.ts(the Vitest config lives insidevite.config.ts, so it inherits the alias). - Functional components with hooks. No class components.
- lucide-react for icons. date-fns for dates. zod for any runtime validation.
- British English in user-visible strings.
Universal
- No personal information in committed code or commits (no hostnames, IPs, hardware specs, broker account IDs, fund amounts, order IDs).
- No mock or placeholder data in shipped UI — every screen renders real data or an explicit empty-state component.
- Conventional Commits. Examples:
feat(screener): add OI profile widgetfix(engine): respect strategy isolation in closepositiondocs: add troubleshooting section for port 5100test(core): cover JWT revocation edge caseschore: bump flexlayout-react to 0.10.1
10. Pull-request flow
- Branch off
main. During pre-1.0, all commits land directly onmain; for non-trivial work, open a PR to give CI a chance to run. - Run the local checklist before pushing (one command per line — Windows
PowerShell 5.1 has no
&&):pnpm --filter @flinttrade/terminal typecheckpnpm --filter @flinttrade/terminal test(full suite), ornpx vitest runfrompackages/apps/terminalfor affected filespython -m pytest --tb=short --import-mode=importlibpython scripts/ft.py lint— the shell-independent way to runruff check packages/ tests/and the terminal'seslint src --max-warnings=0lint gate (identical on Windows, macOS and Linux; the same scopemake lintruns on POSIX)
- Open the PR. Use the template in
.github/PULL_REQUEST_TEMPLATE.md. Tick every checklist item that applies. - Never push with
--no-verify. Pre-commit hooks exist for a reason. If a hook is broken, fix the hook in the same PR. - No
dangerouslySkipPermissions. Anywhere. - Sign-off is optional. Conventional commit format is mandatory.
11. Common gotchas
WSGI prefix strip — /ft-api/v1/X becomes /v1/X
The Vite dev server proxies /ft-api/* to the FlintTrade backend on
port 5100. The backend's WSGI middleware strips the /ft-api prefix
before URL dispatch. That means a blueprint registered at
url_prefix="/v1" answers requests at /ft-api/v1/… from the outside
and /v1/… from the inside. Do not double-prefix.
Broker authentication
Safety layers
The 5-layer safety system lives in packages/services/engine/. Every
order FlintTrade submits goes through admission when it's placed. Every
Live order placed through FlintTrade is then checked by those layers.
The submit routes are POST /api/v1/orders/place,
POST /api/v1/orders/<broker>/place,
POST /api/v1/positions/exit-all, 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. Practice orders skip L1–L5 and go to the Practice fill path.
Practice place runs Laya.admit before that path; a refusal or a
quantity clamp stops before a simulated fill. A Practice close and a
Practice square-off are opposite orders on place. The Practice fill path
cancels and modifies only. Settings → Practice does not place. Live
operator and automate place is checked when it's placed: Mode guard
→ Laya.admit → SafetySystem L1–L5 → gate_order → BrokerRouter.
"variety": "gtt" is HTTP 422 gtt_unsupported
(Not placed. GTT orders aren't supported right now.) before that
path, on place, routed place, exit-all, and a bracket. No submit route reaches a
broker forever or super-order endpoint. The Kotak Neo adapter refuses a
gtt place. POST /api/v1/orders/forever returns HTTP 501 and does not
place.
Exit-all records a server reduce-only proof before exit_all_positions.
cancel-all only cancels. 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.);
Order Pad Example Buy is a local example fill (Example order placed, id starting SAMPLE-; no HTTP order route, no Laya admit, no
SafetySystem). Other Live write verbs still reach SafetySystem without
this place admission. The global auth check covers both a session JWT
and 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, and
place still needs a JWT mode claim. GET /healthz and GET /readyz are the public
status-only probes. Runtime fail-fast order in
_check_order_locked is L5 → L4 → L1 → L2 → L3 (not L1–L5 numerical
order):
- L5 Kill switch — explicit operator activation through Telegram, the UI, or the API cancels open orders and requests position flattening. Automatic account-scoped flattening belongs to the separate rupee MTM circuit breaker.
- L4 Daily P&L — pause subsequent new orders at 3 % daily drawdown and latch a new-order hard stop at 15 %. Layer 4 does not cancel or flatten.
- L1 Order validation — price within ±5 % of LTP, quantity within lot-multiple bounds.
- L2 Position limits — maximum five simultaneous positions, no single position exceeding 60 % of available margin.
- L3 Portfolio risk — net delta and net vega caps.
Do not bypass Laya or SafetySystem on those place paths. If you need a fast-path for high-frequency orders, add the path inside the layers, not around them.
Laya decision sidecar
Operator steps, the chip, and the reason codes are in
Start Laya. Desk place surfaces go through
this admission. Place admission asks the Laya model only after Down and
the hard rules.
The host is opt-in. Install and supervise it with the FlintTrade
environment (the project .venv after uv sync, or uv run python),
so flinttrade_core and flinttrade_engine import. Do not run these
commands with the sidecar interpreter.
python -m flinttrade_core.laya_runtime install
python -m flinttrade_core.laya_runtime start
python -m flinttrade_core.laya_runtime status
python -m flinttrade_core.laya_runtime stopinstall creates <workspace>/runtime/laya/venv. On Linux the default
workspace is ~/.flinttrade. FLINTTRADE_WORKSPACE_DIR overrides it.
The sidecar environment can share the base interpreter with FlintTrade:
its python is often a symlink to the same executable, and the prefix
compared at install time is the environment directory, not that symlink.
The install refuses a command whose prefix is the FlintTrade environment.
The default install puts CPU torch in the sidecar environment from
https://download.pytorch.org/whl/cpu, then laya[serve]==0.3.21, so
the CUDA wheels stay out. The constraints file pins torch==2.14.0+cpu,
laya==0.3.21, huggingface_hub==1.33.0, and tqdm==4.70.1 with no extras
(packages/core/core/src/flinttrade_core/laya_sidecar_constraints.txt).
The serve extra stays on the install requirement. The CPU install applies
that file to both pip commands, and installs torch first. The install size
is roughly 1.2 GB. CUDA and ROCm
installs do not use the CPU pin. install --accelerator cuda opts into the
PyPI torch build. install --accelerator rocm needs LAYA_TORCH_INDEX
set to an https PyTorch ROCm wheel index. start still uses CPU
(LAYA_DEVICE=cpu). The host stays 127.0.0.1. LAYA_PORT chooses the
port and defaults to 8000. A clash on that port is Down with
port_in_use. A fresh API key is
written to <workspace>/runtime/laya/api.key and removed on stop, and
on a start that fails after the key was written. When LAYA_API_KEY_FILE
points at that same path, start replaces the file and does not treat
it as missing. A different path that is not there still refuses with
"The Laya API key file is missing."
start downloads the pinned commit ([checkpoint] revision, never the
default branch) into <workspace>/runtime/laya/staging when the weights
file or any manifest file is not already installed, and when the runtime
checkpoint is on disk but its hashes are not the pin. That directory is
not the launch path and not the shared Hugging Face cache. The download
sets HF_HOME to <workspace>/runtime/laya/hf-home and
HF_HUB_DISABLE_XET=1 (read by huggingface_hub 1.33.0), so transfer
logs stay in that folder. The step does
not start the sidecar. While it runs, including a pin change, the reason
is downloading and the popover is Downloading the model · X of Y GB (for example Downloading the model · 1.2 of 3.4 GB)
(live, one decimal, decimal GB), with no Next line and no Updating label.
Orders stay on the Down refusal. It then hashes model.safetensors and
every manifest file in that staging directory. When no checkpoint is
already there, a full match renames staging onto
<workspace>/runtime/laya/checkpoint and launches the offline verified
boot, with hub access off. When a checkpoint is already there, a full
match renames it aside to checkpoint.old-<random> in the same runtime
directory, renames staging onto checkpoint, then deletes the old copy.
The current copy stays in place until that match. If the second rename
fails, the old checkpoint is renamed back and the reason is
download_failed, not wrong_revision. A complete download whose files
do not match the pin is wrong_revision; staging is deleted and a
checkpoint already on disk is left in place. An extra loadable file in a
complete download is unverified. A dropped connection, a partial or
missing file, or a read error is download_failed ("Can't download the
model"; tooltip "Check your connection, then Start Laya again."). The
status word for downloading is Downloading (neutral colour) and for
download_failed it is Down, never Still
loading, and orders use "Laya is Down. New orders are paused until it's Ready. You can still close positions." A non-exit order is HTTP 403. Those failures delete the staging directory and leave the shared
model cache alone. The sidecar does not start on files that do not match
the pin. If the download does not finish, the reason is download_failed, not
wrong_revision and not unverified, whatever older snapshot is on
disk. An empty cache is download_failed. unverified stays when this
start did not download. A snapshot already on disk is wrong_revision
only when this start did not download. Leftover staging
directories and checkpoint.old-* copies are removed at the start of
start once a checkpoint is in place, with no chip change and no
message. If checkpoint is missing and one or more checkpoint.old-*
copies remain, the last checkpoint.old-* name is restored onto
checkpoint and any other aside copies are removed. If that restore fails, the aside copy stays
where it is and that cleanup is skipped. A copy that was restored is then
checked against the pin. If it does not match, the sidecar does not start
on it; the pinned download runs instead, and a failed download leaves
download_failed with that copy still on disk. The download log line is
laya download repo=<repo> revision=<revision>.
The pins live in laya_policy.toml:
[checkpoint] names revision beside sha256, the weights file model.safetensors, and
[checkpoint.manifest] pins rl_agent_config.json,
encoder/config.json, tokenizer/tokenizer_config.json, and
tokenizer/tokenizer.json by sha256.
A backend that was started with LAYA_HOST=127.0.0.1, LAYA_PORT (or
the default 8000), and LAYA_API_KEY_FILE pointing at that api.key
attaches on its health probe. The host must stay loopback. The attached
client uses the same revision and digest checks. On each sidecar start
FlintTrade hashes model.safetensors and each file in
[checkpoint.manifest] before launch. The runtime record holds the
sha256, pid, and start token, and each file's inode, size, and
modification time in nanoseconds
(<workspace>/runtime/laya/verification.json, with the token and pid
also in run.json). When the files are already on disk and this start is not replacing them,
a missing pinned file, a shard index, or any extra weights file or other
file the launcher could read is unverified ("Can't verify the model")
and the sidecar does not start. A changed byte in a snapshot that this
start is not replacing is wrong_revision ("Wrong model version") and the
sidecar does not start. A changed byte in the runtime checkpoint is the
download above; the sidecar does not start on that tree. Laya does not
reach Ready in these cases. A verified boot sets
LAYA_WEIGHTS_PATH to that hashed weights file. A model already in the
standard Hugging Face cache is accepted. When that file is the cache
symlink (snapshots/<revision>/model.safetensors into blobs/), it is
passed as the snapshot file, not the blob. A blob path is still refused.
The boot runs offline
(HF_HUB_OFFLINE=1, TRANSFORMERS_OFFLINE=1). It does not pass a repo
id or a revision. When that sidecar's health document leaves the revision
null, FlintTrade fills the pinned revision from the verified manifest, so
the chip leaves Still loading and can reach Ready without a download in
this process. The launch log line is
laya weights path=<path> sha256=<digest>. Those identity values are
rechecked, without hashing again, when Laya reports Ready and on each
watch tick, about every 1.5 seconds. A mismatch is unverified
("Can't verify the model"). The log line is
laya weights path=<path> changed=<field> where <field> is inode,
size, mtime, or a comma-separated list of those, and <path> is the
file that changed. The same watch reads the pid file
(runtime/laya/sidecar.pid), the key file (runtime/laya/api.key), and
the runtime record, so a command-line stop or start, or a key rotation,
is reconciled by that watch. The desk polls GET /api/v1/ping every
1.5 seconds. That ping reconciles the pid, the key, and the runtime
record the same way an order does, so the chip and the order gate read
the same state. A stop or a start shows on the chip by the next 1.5-second check.
After Start Laya, until the ping confirms the new state, the chip says
Checking in the neutral colour and the popover says Checking Laya…. It
does not show a stale Ready during that wait. An admitted place while
the chip is not Ready or Degraded also shows Checking until the next
ping. A confirmed first load
still says Still loading. A place refused with exactly "Laya is Down. New orders are paused until it's Ready. You can still close positions." sets the chip to Down on that
response. The refusal line is unchanged. Every stop deletes the runtime
record, as does a start that fails after it was written. A record from
an earlier run is rejected. A health document without the weight digest
is Ready when that record matches the pin. If the record cannot be
checked, the reason is unverified ("Can't verify the model"). The
tooltip is "The installed model couldn't be checked against the pinned
version. Restart Laya. If it keeps happening, reinstall it."
status carries a reason code: not_started, stopped, port_in_use,
still_loading, downloading, download_failed, unreachable,
wrong_revision, unverified, key_rejected, or key_missing. identity_absent is
not a status code. The desk shows Not started, Stopped,
Port <n> in use, Still loading, Downloading the model · X of Y GB,
Can't download the model, Unreachable, Wrong model version, Can't verify
the model, Can't reach Laya, and The Laya API key file is missing.
key_missing stays until a later start finds the file, or an explicit
stop. When LAYA_API_KEY_FILE names <workspace>/runtime/laya/api.key,
that next start replaces the file and does not treat it as missing. A
different path that is not there still refuses with "The Laya API key
file is missing." A health
check does not replace it with Not started. The download progress class
subclasses the pinned tqdm==4.70.1 (tqdm.auto.tqdm). The model is
about 2.37 GB, and the progress line reports that size once. unverified applies when this start did
not download. A failed download, including one over an older unverified
snapshot, is download_failed ("Can't download the model"). <n> is the
sidecar port. Tooltips for
not_started, stopped, port_in_use, still_loading, and
unreachable are the label followed by
. Next: python -m flinttrade_core.laya_runtime start. downloading
has no tooltip. The download_failed tooltip is "Check your connection,
then Start Laya again." The wrong_revision tooltip is "Laya is running a different model than
FlintTrade expects." That code is only a real mismatch: a complete
download whose files do not match the pin, a snapshot already on disk
that this start is not replacing, or a running sidecar that reports
another revision or digest. A dropped connection, a partial download, or
a failed download or swap, including one that puts the previous
checkpoint back, is download_failed, not this code. The key_rejected
tooltip is "Laya restarted with a new key. Reconnecting…" The chip stays
Down and orders are refused. When a place is refused because Laya cannot
be reached, or because it rejects the key, the chip updates on that same
order: Unreachable, or Can't reach Laya. Every chip-Down refusal reads
"Laya is Down. New orders are paused until it's Ready. You can still close positions." A
dead child is reaped on the health probe and on interpreter exit, and
the probe records Down with Stopped. POST /api/v1/laya/start starts or
restarts the managed sidecar for a signed-in operator session. A
port that another process holds, including one that is not Laya, is Down
with Port <n> in use. The first load, before health is usable, is Still
loading. Orders stay refused with the Down sentence while that load
runs. After a successful load, a later miss is Unreachable.
GET /health then records Practice Ready or Degraded from the sidecar.
Live stays Down until a LayaQualification record uses
EvidenceUseScope.LIVE_DECISION for that exact revision, digest, and
policy version. A base checkpoint is not that record. An unreachable
host, a timeout, a malformed response, or a revision or digest mismatch
is Down, and Practice refuses too. A decision without revision or
sha256 is checked against this run's verified record for both admitted
and clamped orders. The decision log is
<workspace>/runtime/laya/decisions.jsonl. It stores proof=decision
or proof=runtime. An admitted Practice place with an empty note skips
the model and writes one line, effect=clamp with failure=note_absent
and no proof, including when the quantity already fits. When this run's
record stood in, each model allow keeps its own effect=allow
proof=runtime line. There is no dedupe. When the chip is Ready and a model decision
carries no proof, the refusal code is laya_unverified and the decision log records
identity_absent with no proof. The refusal reads "Not placed. Laya's
decision couldn't be verified. Try again." Stopping the sidecar records
Down before an in-flight probe can publish Ready. An empty note is
uncertain and is not a hard reject:
Practice clamps and Live denies. The Practice server reason is "Laya is
uncertain. Quantity stays inside the tighter limit." The Live server
reason is "Laya is uncertain. Live stays closed." On a denial, Order Pad
and Quick Trade show that server reason inside one alert (role="alert"),
the only live region. The reason line is named "Laya decision" and is not
its own status.
A clamp is only when the requested quantity is greater than the allowed one. The desk clamp
sentence is "Not placed. Laya allows up to N." Place N sends that
quantity. On Order Pad, "Review Practice order" then shows the placed
quantity. Place 1 on "Not placed. Laya allows up to 1." places. Nothing
is placed until Place N.
Order Pad sends that note as rationale, including when the field is
empty. The collapsed control is "Add a reason (optional)". Once open, the
field's accessible name is the same. Quick Trade, Positions square-off,
Order Ladder, Scalper, and Option Chain use that accessible name too.
The same client speaks POST /v1/systemone. An operator may point it at
another loopback host, including one on port 8888, without adding that
host's software as a dependency. Do not vendor Unsloth Studio. Chat
profiles stay separate from the decision service kind. Catalogue ids
under that kind are decision:laya-managed and
decision:systemone-endpoint; they are not chat profiles, their default
evidence scope is offline qualification until a Live qualification record
exists, and listing them does not start the host.
Vite dev proxy paths
In packages/apps/terminal/, the dev server proxies:
| Route prefix | Target |
|---|---|
/ft-api | http://127.0.0.1:5100 (FlintTrade backend) |
In dev mode, packages/apps/terminal/src/services/api.ts uses relative
paths (empty base URL). In production, it reads the full host from the
connectionStore. Do not bypass the proxy in dev — your code will work
locally but break on every other contributor's machine.
State boundary rules
| Layer | Source | Lives in |
|---|---|---|
| Jotai atoms | WebSocket | Per-instrument LTP, quote, depth, derived PCR / straddle / Greeks |
| TanStack Query | REST | Positions, orders, holdings, funds, option chain |
| Zustand stores | Derived | Connection status, layout, settings mirror, aggregated P&L, mode |
Each data shape enters through one path only. Duplicate data across stores and you guarantee a bug.
Home and Invest net worth
Home and Invest share accountNetWorth in
packages/apps/terminal/src/lib/accountNetWorth.ts. The total is ledger
cash, including blocked margin, plus holdings at market value, plus each
open position. Opening an F&O position does not reduce
the total by its margin. A Practice round trip at an unchanged price
leaves the total at the starting cash, for example ₹10,00,000. The Invest
label is Net Worth (Cash + Holdings + Positions). Options, and equity positions that are not
already holdings, add signed market value: last traded price × quantity
when that price is positive, otherwise the entry price × quantity.
Futures add unrealised P&L.
Dhan's funds set futures_mtm_in_ledger. The futures base is buyAvg
on a long and sellAvg on a short (mark_source: "avg"). When that
average is absent, the base is costPrice and mark_source is
"fallback". Kotak Neo's funds also set futures_mtm_in_ledger. An
open future has no settlement price, so the base is the open-leg
average and mark_source is "fallback". Practice sets
futures_mtm_in_ledger false and does not set a mark source, so a
future marks from the entry price.
"fallback" on an open future, while earlier mark-to-market is already
in the ledger, formats the amount with ≈ (formatAccountNetWorth).
Home puts ≈ and the tooltip on the Net Worth amount; the Net Worth
label has the tooltip only. Invest Dashboard puts the tooltip on the
label Net Worth (Cash + Holdings + Positions) and ≈ plus the tooltip
on the amount under it. The Net Worth view does the same for
Known Total (Cash + Holdings + Positions), and puts both on the
Open Positions value. Available Funds on Dashboard shows ≈ with no
tooltip. The donut centre shows ≈ with no tooltip. The tooltip text is approximateNetWorthTooltip. A missing
Dhan average uses
Approximate. Your broker didn't send an average price for NIFTY-JUN2026-FUT, so profit or loss from earlier days may be counted twice.
A Kotak Neo open-leg estimate uses
Approximate. The price for NIFTY25JUNFUT is estimated from the open position's average, so profit or loss from earlier days may be counted twice.
One symbol is named, and several positions of that kind use
N futures positions. When both kinds are open, the tooltip is
Approximate. Your broker didn't send an average price for NIFTY-JUN2026-FUT, and the price for NIFTY25JUNFUT is estimated from the open position's average, so profit or loss from earlier days may be counted twice.
The accessible name is
accountNetWorthAccessibleName (Net Worth, approximately …). A flat
quantity, or mark_source: "avg", clears it. Practice never shows ≈.
Allocation percentages are not marked. Invest publishes the total only
after the position book has loaded (positionBookReady in
InvestContext). Home uses that same check in PortfolioCard: while
the book is pending or has failed, the amount is —, never a cash-only
figure. The sample book does not wait on that book. The amount appears
once positions have loaded, including when it is negative: -₹50,000,
or ≈ -₹50,000 when the mark is approximate. Home allocation stays on
the Example split until funds, holdings, and positions have all
succeeded (PortfolioCard). That split uses ExampleLabel, not
ExampleChip.
The Home greeting is useOperatorGreetingName in
packages/apps/terminal/src/routes/home/operatorGreetingName.ts. The
saved display name wins. The username is the fallback. Trader is the
unset placeholder and is never shown. Hour buckets are Asia/Kolkata in
getIstGreeting.
Real holdings on Benchmark use HOLDINGS_RETURN_LEGEND
(Your holdings (unrealised)), with HOLDINGS_RETURN_TOOLTIP and
table label HOLDINGS_RETURN_LABEL (Unrealised return on holdings).
That row has no Example mark. An empty book, and example holdings, use
Your Portfolio. Example holdings add ExampleLabel on the row.
BenchmarkTab renders ExampleChip with always on the hard-coded
index returns, so the chip stays in Practice and Live. Each index row
also renders ExampleLabel.
Home Open Positions percent is formatPositionPnlPercent in
packages/apps/terminal/src/routes/home/PositionsCard.tsx. Cost is the
absolute average price times quantity. The percent is P&L divided by cost when cost is above zero; otherwise
the cell is —.
Dhan and Neo rows do not carry pnlPercent.
Sample Invest figures use ExampleChip
(packages/apps/terminal/src/components/ui/ExampleChip.tsx), which
paints when the session is example data unless always is set.
Correlation, ETF Screener, ETFs, Risk-Return, Sector, Sector Rotation,
Shareholding, and Social render that chip, not ExampleLabel.
SocialTab renders exactly one, after loading has finished.
EtfTab in Example renders one, with
Example prices. Connect a broker for live quotes.
Practice and Live keep
live quotes via the native gateway. Refreshes every 30s
when quotes have loaded.
SectorTab in Example uses
Example sector split. Connect a broker to see yours.
and
Example data. Not from your holdings.,
with one chip.
BasketTab renders one in Example, including while quotes are loading
and after they have loaded.
HoldingsTab renders one. DashboardTab renders one on the inline
sample XIRR. It does not render one on sample Portfolio Allocation, and
that block omits the connected-book sentence. It does not render a
Portfolio XIRR card. Net Worth, Available Funds, Invested Value, and
Day P&L paint their final formatted value on the first frame, with no
count-up from zero, including ≈, -₹50,000, and —. With no
holdings the inline XIRR is omitted.
NetWorthTab sample copy is
Example equity and cash. Connect a broker to see yours.,
with the chip on the sample Allocation label only. Equity Holdings
and Cash notes are blank on example data. A connected book keeps
Allocation (live assets only) and Live from broker.
Instrument lot sizes
A lot belongs to one listed contract. NIFTY, BANKNIFTY, and SENSEX are
not stored as fixed sizes in this module. flinttrade_core.instrument_lots.active_rows
uses the workspace disk cache instrument_lot_cache.json when that file
has rows, and otherwise the shipped excerpt
packages/core/core/src/flinttrade_core/data/instrument_lot_fixture.json.
instrument_lot_master.build_excerpt builds that excerpt from the public
Dhan scrip master and the Kotak Neo F&O master (near month and next month
of NIFTY, BANKNIFTY, and SENSEX futures). The document carries source
and fetched_at. refresh_master_cache_if_due downloads again when the
cache is missing or from an earlier IST day; start_instrument_master_refresh
runs that at startup and again at each IST midnight. A failed download
leaves a previous non-empty cache in place. Contracts whose expiry is
before today (IST) are dropped. contracts_from_rows raises only when
the same contract (underlying, expiry month, and kind) has two sizes
across Dhan and Neo. A later expiry with a different size is kept.
scalper_lot_label returns size · expiry (for example 65 · Sep expiry
per the broker instrument master). When the next month’s size differs,
both months are named. index_lot_line is the risk line, for example
NIFTY 65 · BANKNIFTY 30 · SENSEX 20 (Oct expiry) on the shipped
excerpt, with — for an underlying the master does not list.
missing_lot_refusal is the order text:
Not placed. The lot size for <contract> isn't in the instrument master, so this order can't be sized.
Order Pad shows that string when a derivative has no lot in the lookup.
The terminal does not bundle a second copy of the excerpt. The app shell
calls loadInstrumentLotRows once (GET /api/v1/instrument-lots). A
store that already has rows does not fetch again. Glossary (useIndexLotLine),
Scalper, and Order Pad read that one in-memory lookup. The route returns
active_rows() and index_lot_line(). Tests may still import the
excerpt through @flinttrade/instrument-lots. Demo getLotSize rows are
labelled Example, follow the near-month size, and do not name a
contract month. Glossary Lot Size is
Minimum quantity for F&O trading. plus indexLotLine() plus ..
Statutory charges
One table, packages/core/core/src/flinttrade_core/data/indian_charges.json,
is loaded by flinttrade_core.indian_charges. It feeds the backtest
calculator, Practice fills (estimate_practice_fill), the terminal
charges calculator, and mirror statutory defaults. Each row has an
exchange (NSE, BSE, MCX, or ANY), a segment, a component, a side,
a basis, a rate, effective_from, and an optional effective_to.
Components are STT, exchange transaction, SEBI fee, stamp duty, and GST.
Segments cover equity delivery, equity intraday, equity futures, equity
options, Sensex options, Bankex options, Sensex 50 options, and MCX
commodity futures and options.
Brokerage is not in the table. Lot sizes are not in the table; callers
read them from the instrument master. Practice passes brokerage zero.
exchange_transaction_label names the line BSE transaction,
MCX transaction, or NSE transaction. The Fills widget names the
line from the fill's exchange: BSE transaction for BSE and BFO, and
NSE transaction otherwise. Each component is rounded to the paisa
before GST is applied to the rounded brokerage, exchange charge, and
SEBI fee. The BSE futures exchange-transaction rate in the table is zero.
Sensex options and Bankex options use their own premium rate.
Home and Invest share NET_WORTH_LABEL in
packages/apps/terminal/src/lib/accountNetWorth.ts
(Net Worth (Cash + Holdings + Positions)). Invest → Net Worth is
headed Net Worth Breakdown. Known Total is not rendered.
accountNetWorth is the mark of holdings, plus the mark of positions,
plus available cash, minus the estimated-charges argument. markedValue
is last price times absolute quantity for every row. Callers pass
available cash from the funds available-cash field, and pass estimated
charges only in Practice (otherwise zero). In Practice the source line
is Practice account, after estimated charges.
Positions book
Positions Table, Net, and Heat render one normalised book
(positionBook.ts). Flat means every leg of a symbol is at quantity 0;
those symbols are omitted from Net rows. Offset means the legs net to 0
and at least one leg is still open: each open leg stays its own row,
with its own margin, and an Offset tag. offsetLegTooltip names the
first two open products in book order. It adds the intraday square-off
sentence only when one product is MIS. The Net footer shows
incl. N offset symbol(s) (legs still open) and
incl. N flat symbol(s), each hidden at 0. The Example book
(sampleBook.ts) is +₹16,575, with the NIFTY group at +₹1,625
(MIS +₹3,575, NRML -₹1,950) and BANKNIFTY at +₹2,400, and the
footer incl. 2 offset symbols (legs still open). Heat bands are at
least tall enough for a header and one tile row, and the map scrolls
when the stack is taller than the canvas. A +N more chip’s tooltip
lists symbols that still do not fit; clicking it opens the list on
those rows and highlights them. Hidden positions are also in a
screen-reader list, Positions without a heat map tile.
12. Where to ask for help
- Question issue template — focused usage questions, design questions, and ideas.
- GitHub Issues — bug reports, feature requests.
- GitHub Security Advisories (private) — security disclosures.
Active design specs for in-flight work live under docs/superpowers/specs/. If you are about to start non-trivial work, check the specs folder first — there may already be an approved design.
FlintTrade User Guide
This guide walks you from a fresh install to local setup, sandbox workflows, and Live-mode safeguard verification. The default reading order is top-to-bottom — every section builds
FlintTrade Architecture
Broker connections use six native adapters: Dhan, Upstox, Kotak Neo, INDmoney, Groww, and Delta Exchange. Availability remains evidence-gated. Native broker HTTP mutations and read