Engineering notes
KodaQuant
Walk-Forward FX Research Terminal
These notes are the long-form version of the KodaQuant case study: how a thesis notebook became an Electron research terminal, why market data is installed instead of fetched on demand, how chronological validation is enforced in code rather than in intention, and where the boundary sits between a research result and live trading. Everything here is implemented in the repository or documented in its own docs. The final section separates in-progress work from what is genuinely deferred or residual.
1. From a notebook to a terminal
The starting point was a course exercise: a logistic regression over FX candles in a Jupyter notebook, arriving with the course's own warning that the baseline was not something to trade. That notebook became the basis for my thesis at HAW, and the thesis question was the part worth keeping โ does a model trained on FX candles still hold up once chronology, trading costs and execution delay are respected?
The first answer was a static HTML and CSS page so results could be read outside a notebook. It worked, and it proved the real problem was not presentation: it was that every number could be flattered by leakage, by ignoring the spread, or by refitting on history that already contained the test window. From there the scope moved one gap at a time, and each step existed because the previous one exposed a hole in the evidence.
- Thesis notebook โ logistic regression, hand-built features, and the first chronological split.
- Static prototype โ a simple HTML/CSS shell for reading results without a notebook.
- Research pipeline โ a Python data layer, feature engineering with disk caching, a walk-forward backtester and a model registry.
- Product layer โ FastAPI, a React workbench, Celery and Redis workers, and WebSocket progress so a long study never blocks the interface.
- Committee stage โ Optuna hyperparameter search, CSCV/PBO and deflated Sharpe diagnostics, regime and trust scoring, and a selection committee with an optional LLM proposer.
- Desktop release โ an Electron shell with a bundled Python backend, published as an Ubuntu AppImage and a Windows installer with checksums, and a deliberate boundary: research and paper replay, not live execution.
The original thesis question is still the one the product answers. Everything added since exists to make that answer harder to fool.
2. Stack and runtime modes
3. How it is composed
- pipeline/ holds the research spine: data contracts, feature engineering, the composed backtester, execution patches and the metrics modules.
- models/ and rl/ hold the registry โ eighteen families covering classical, deep, reinforcement-learning, ensemble and regime-routing models.
- pipeline/data/ holds the market-data layer: canonical storage, the catalog, installers for demo, CSV/Parquet and provider sources, and integrity verification.
- api/ holds the FastAPI surface: routers for datasets, models, backtests, news, committee, hardware, prices, paper replay and WebSockets.
- api/tasks.py and api/process_manager.py split execution: Celery tasks in service mode, managed in-process jobs and recovery on the desktop.
- frontend/src/App.tsx declares the routes; results, news, settings, trading, models and committee are all behind React.lazy and Suspense.
- electron/ starts the Python backend, waits for the health check, loads the built frontend, and owns tray, restart and shutdown.
Startup never ingests history. It opens the catalog, discovers what is installed, reconciles jobs and optionally installs the bundled demo โ which is why first boot costs milliseconds instead of minutes.
4. Market data: installed, not re-seeded
The earliest version treated market data as something you fetch. That was the wrong model: any re-seed can change the rows under a study, and a half-finished fetch looks identical to a complete one. The current design treats datasets as installed artifacts โ a demo package, a user import, or a provider dataset โ each with a version, coverage metadata and an operation log.
- Demo โ a prebuilt package of canonical fragments, installed by verified copy and catalog registration in roughly 0.05 s, idempotently.
- CSV/Parquet โ schema detection, UTC normalisation, vectorised OHLC validation, dedup, then fragments. The dataset id is content-addressed.
- Provider โ a refresh planner fetches only ranges not already covered, with durable checkpoints so an interrupted sync resumes instead of restarting.
- Reproducibility โ jobs can pin a dataset_version_id, so rerunning a study six months later reads the same bars.
Integrity is explicit: a missing or corrupt fragment raises an error and the candles endpoint returns 503 with a verify/repair hint. There is deliberately no silent fallback, because a silently substituted dataset is worse than a failed request.
5. Features and the leakage problem
Almost every convincing backtest I have seen was convincing because the future was available at training time. Sometimes it is obvious โ a normalisation fitted over the whole series. Usually it is subtle: a higher-timeframe moving average aligned without a shift, or a news article timestamped after the bar it is supposed to explain.
- Higher-timeframe indicators are shifted before they are aligned to base bars, and multi-timeframe frames merge with a one-step lag so the current bar never sees its own higher-timeframe close.
- News is merged by forward-fill and only accepts articles earlier than the bar being built, so sentiment cannot arrive before it existed.
- Imputation, scaling, feature selection and model fitting are fitted on the training slice and applied to validation โ the pipeline, not the whole dataset, is what gets fitted.
- The forward target used for supervised labels is constructed on training slices and is excluded from the feature list; the RL environment rejects a raw returns column outright.
- A data-leakage audit is retained in the repository, so the reasoning behind each boundary control is reviewable instead of folklore.
These controls reduce look-ahead risk. They are not a formal guarantee of research validity, and the documentation says so โ a claim of total safety would be the same kind of overreach as the flattering backtest.
6. Walk-forward evaluation
Random cross-validation is wrong for ordered data, so the evaluation is chronological end to end: train on a window, evaluate the next one, advance, repeat. The unit is configurable โ months by default, or weeks or days โ and the test window moves forward without shuffling.
- Purging removes training rows whose forward label horizon overlaps the validation window, because a chronological split alone does not fix a label that spans the boundary.
- Embargo bars can be dropped between a training block and its validation block.
- Test warm-up bars may be loaded for indicator state but are never used for training.
- Final test windows can drop an initial embargo region so the first predicted bar is not the one the model was tuned against.
- Validation boundaries use end-exclusive comparisons so an inclusive guard cannot quietly leak one bar.
What a result is allowed to mean
Metrics are only comparable across runs when the data window, feature policy, cost configuration, execution timing, model set and seed policy are held constant. Short runs can produce too few trades for the statistical diagnostics to be defined at all, and the pipeline reports that rather than printing a confident number.
7. Execution, costs and risk
A signal evaluated at the same bar it fires is a fantasy, so the strategy signal executes with a one-bar delay and stops and take-profits have explicit fill accounting instead of silently skipping the triggering bar. Costs are part of the simulation, not a footnote.
The backtest and paper/live surfaces are documented against each other in a settings matrix rather than assumed to be identical. Where a setting only exists on one side โ trailing stops are backtest-only โ it is written down instead of silently ignored.
8. The model registry and HPO
Eighteen model families only mean something if they are built, tuned and scored through the same contract. The registry is the authoritative name source; the API mirrors it, the frontend renders its metadata, and adding a family does not mean adding a second pipeline.
- Hyperparameters come from one tiered source of truth: tier 1 can be sampled by Optuna, tier 2 is user-selected and never sampled, tier 3 is a fixed safety default.
- The frontend receives rendered parameter metadata from the API, so model controls are not duplicated in TypeScript and cannot drift from the Python definition.
- Deep and RL paths share the same evaluation contract as the classical models, which is what makes a leaderboard between them meaningful.
Two dependency caveats are documented rather than hidden: CatBoost is registered but not declared in the requirements files, and finBERT plus PyTorch are deliberately excluded from the packaged build.
9. Jobs, workers and the monitor
A serious study runs for minutes or hours. The job, not the browser tab, has to be the durable object: it is written to the database before anything runs, dispatched to a worker, and reconciled on startup if the process died mid-fold.
- Service mode dispatches to Celery workers over Redis; desktop mode runs the same task code through a process manager, because requiring Docker inside a desktop installer would be absurd.
- Progress travels over WebSockets with sequence numbers and heartbeats, and the store de-duplicates events so a reconnect cannot replay a finished cycle as a new one.
- The monitor renders from a snapshot projected out of durable job data, so what you see after a reload is the truth rather than whatever the socket happened to deliver.
- Cancellation is a state, not a kill: a cancel request is recorded, the worker finishes the current fold boundary, and the job ends in a state the UI can explain.
- Startup recovery leases orphaned jobs back to a new owner instance and republishes Celery jobs that were in flight when the process stopped.
Why the desktop path matters
The packaged application has no Redis, no Celery and no Docker. Supporting an in-process path is what allows the same research engine to ship as one installable file โ and the cost of that decision is two execution paths that must stay behaviourally aligned.
10. The committee and the LLM proposer
Picking the best model by in-sample score is how you buy an overfit curve. The committee stage exists to make selection itself an out-of-sample problem: candidates are tuned, evaluated on purged folds, screened by overfitting diagnostics, and only then combined.
The diagnostics that gate a candidate
CSCV/PBO estimates the probability that the selected configuration was lucky; deflated Sharpe corrects for how many configurations were tried; HAC/Newey-West style measures account for autocorrelation in the returns; regime coverage asks whether the result came from one market condition; and three-seed robustness asks whether it survives its own randomness. Those signals combine into a trust score rather than a single number.
Where the model is allowed to help
The Factory proposer can suggest configurations, and its output is validated against the same schemas and registry constraints as anything written by hand. If it is disabled, misconfigured or returns something unusable, the deterministic path still produces a committee. News sentiment works the same way: VADER by default, a hosted model only when explicitly enabled, cached per article and merged forward-fill only.
No study depends on an API being reachable. That is the whole design constraint: the model is an assistant to the search, and the search must be reproducible without it.
11. Local GPU and remote rental
Deep families are slow on CPU and a GPU is not always present, so hardware is discovered rather than assumed: startup probes for VRAM, sets a budget when it finds one, and disables the gate when it does not. Docker Compose requests GPUs by default and ships an explicit CPU override for everything else.
Vast.ai, honestly
A dedicated compute-node integration can list the user's own rented instances, submit a backtest, mirror remote job state into the local job record and fetch results. The router and client are unit-tested against mocked transports, and the local record stays authoritative. Instance provisioning itself is not a completed production deployment โ the integration is real code, not a finished service.
Remote compute is a convenience layer. If it disappears, the local application must still be able to run the same study โ which is why it is never a dependency of the research path.
12. Packaging, release and verification
- Packaging is two halves: a PyInstaller backend bundle and an Electron installer configuration, joined by a health check before the window ever loads the UI.
- The release workflow runs the Python and frontend checks, then builds both platform artifacts and verifies the unpacked application, installer, update metadata and SHA-256 checksums before publishing.
- Versioning is deliberately split three ways: application version, dataset product version and catalog schema version. They are not the same number and should not move together.
- CI runs Ruff undefined-name checks, Python tests excluding the slow and api markers, and frontend lint, typecheck, unit tests and production build.
- Locally, the gate is pytest with xdist; heavy HPO, full-cycle, benchmark and GPU suites are marked slow so a normal change is not blocked by an hour of deep-model benchmarks.
- The Windows installer is currently unsigned, so SmartScreen may warn until the project has an established signing identity. That is stated in the release note rather than hidden behind a download button.
python -m pytest tests -q # full suite, includes slow studies
python -m pytest tests -m 'not slow and not api' -q # what CI gates a change on
python -m pytest tests/test_walk_forward_integrity.py -q
./scripts/build_electron.sh # PyInstaller backend + Electron bundle13. Status, honestly
Engineering debt, deferred work and real limitations are different things. Work being implemented is not a limitation; work blocked on credentials or curation is deferred, not broken; and only what survives remediation is a limitation of the product as it stands.
KodaQuant is a research instrument first and an application second. The engineering was never a model's accuracy โ it was refusing to let the future touch the past: installed datasets instead of silent re-seeding, purge and embargo instead of convenient shuffling, costs and delays inside the simulation, and a committee that has to survive out-of-sample folds, regime coverage and seed variance before it is allowed an opinion. The result is not a trading product, and it is not trying to be one. It is a machine for finding out whether an idea was ever true.