Functional algorithmic-trading platform in OCaml with an Angular 21 front-end. Strategies and indicators are first-class, hot-swappable, each lives in its own file, and critical bookkeeping (decimal math, portfolio, candle invariants) carries Gospel specs for formal verification.
Architecture & design documentation — layering, state machine, streams, reservations ledger, live engine, testing strategy, and decision records (ADRs).
lib/
core/ Decimal, Symbol, Timeframe, Candle, Side, Order, Signal
indicators/ Indicator framework + 19 indicators
strategies/ Strategy framework + 4 strategies
engine/ Portfolio, Risk, Backtester
finam/ Finam Trade connector (REST, WebSocket, DTO)
server/ HTTP API exposing data/backtests to the UI
bin/ CLI entry point
test/
indicators/ One test file per indicator
test_*.ml Decimal / portfolio / backtest / finam-DTO tests
ui/
mock-server.mjs Zero-dep Node mock backend (mirrors the OCaml API)
src/app/
indicators/ One file per indicator + overlay renderer + spec
chart.component.ts Multi-pane lightweight-charts wrapper
app.component.ts Signals-based root component
Price-only: SMA, EMA, WMA, RSI, MACD, MACD-Weighted, Bollinger Bands OHLCV: ATR, OBV, A/D, Chaikin Oscillator, Stochastic, MFI, CMF, CVI, CVD, Volume, VolumeMA
Every indicator exists symmetrically on both sides:
lib/indicators/<name>.ml ↔ test/indicators/<name>_test.ml
ui/src/app/indicators/<name>.ts ↔ ui/src/app/indicators/<name>.spec.ts
Strategies (4): SMA crossover, RSI mean-reversion, MACD momentum, Bollinger breakout.
opam install . --deps-only --with-test # one-off
dune build
dune runtest # 57 tests
Verify Gospel specifications on all annotated .mli files:
dune build @gospel
The alias picks up every .mli containing a (*@ ... *) annotation under
lib/domain/ and runs gospel check on it. Cross-library references like
Core.Instrument.t resolve through a synthesized wrapper .mli generated
by tools/gospel_wrap.sh (Gospel 0.3.1 doesn't understand dune's implicit
library wrapping on its own).
See docs/howto/formal-verification.md for the full spec catalog, gotchas, and roadmap.
JSON encodings live in *_json.ml companion files to keep the verified
.mli free of Yojson dependencies.
dune exec -- bin/main.exe list
dune exec -- bin/main.exe backtest SMA_Crossover --n 500
dune exec -- bin/main.exe serve --port 8080 # synthetic broker
serve --broker <id> selects the data source. <id> is one of
synthetic (default), finam, or bcs — all three implement the
same Broker.S port, so every code path (candles, SSE stream,
backtest) is identical regardless of choice.
# Synthetic (default) — deterministic random walk, no credentials.
# Good for demos and working on UI / strategies offline.
dune exec -- bin/main.exe serve
# Finam: long-lived portal secret → short-lived JWT (handled by Auth)
export FINAM_SECRET=eyJ… # from https://tradeapi.finam.ru portal
export FINAM_ACCOUNT_ID=1440399 # optional
dune exec -- bin/main.exe serve --broker finam
# BCS: OAuth2 refresh_token → short-lived access_token (Keycloak flow)
export BCS_SECRET=eyJ… # "Trade API" token from «БКС Мир инвестиций» → «О счёте» → «Токены API»
export BCS_ACCOUNT_ID=00000000 # optional
dune exec -- bin/main.exe serve --broker bcs
Credentials may come from the --secret / --account flags or
from per-broker env vars (<BROKER>_SECRET / <BROKER>_ACCOUNT_ID).
--broker synthetic ignores credentials.
Live brokers also attach a WebSocket bridge — /api/stream SSE
subscribers are multiplexed onto an upstream WS subscription, so UI
updates arrive instantly rather than on the polling cadence.
Synthetic has no WS path; its random-walk adapter wobbles the
trailing bar on each poll and the SSE stream emits the diff.
The UI targets Angular 21 with zoneless change detection, signals-based
reactivity, the @if/@for control flow, and input()/viewChild()
component APIs. lightweight-charts v5 draws candlesticks and secondary
indicator panes.
cd ui
npm install # one-off — Angular 21 / Vitest 4 / TS 5.9
Then pick one of three dev modes:
npm start # Angular dev-server only → http://localhost:4200
npm run mock # Node mock backend only → http://127.0.0.1:8080
npm run dev # both, concurrently — quickest iteration
The Angular dev server proxies /api → 127.0.0.1:8080, so npm run dev
gives a fully working app without touching OCaml. Swap in the real
backend by stopping npm run mock and running dune exec -- bin/main.exe serve on the same port.
ui/mock-server.mjs is a ~200-line, zero-dependency Node HTTP server that
mirrors the OCaml API shape (/api/indicators, /api/strategies,
/api/candles, /api/backtest). Candles are generated from a seeded
mulberry32 keyed by (symbol, n), so reloads produce identical data.
Backtest results are plausible random values. Catalog entries are a
hand-kept mirror of lib/indicators/registry.ml and
lib/strategies/registry.ml — update in both places when adding.
chart.component.ts groups overlays by their pane key:
'price'— drawn on the main candlestick chart (SMA, EMA, WMA, Bollinger)- any other string — a dedicated secondary pane below
(
rsi,macd,macd-w,stoch,mfi,atr,obv,ad,cvd,cmf,cvi,chaikin_osc,volume)
The price pane gets setStretchFactor(3); each secondary pane gets 1,
so the main chart stays dominant. Histogram series (volume bars) are
rendered via lightweight-charts HistogramSeries, with per-bar color
(green on bullish intra-bar, red on bearish). Volume and VolumeMA share
the volume pane by design — the MA line overlays its own bars.
Under Vitest 4 (Angular 21's default) with jsdom:
cd ui && npm test # 20 files, 70 tests
Test layout:
indicators/<name>.spec.ts— math + overlay glue per indicatorapi.service.spec.ts— HTTP surface viaHttpTestingControllerapp.component.spec.ts— signal-driven reactivity (catalog seeding, toggles, candle reloading on symbol change, backtest result storage).ChartComponentis overridden with a stub so lightweight-charts never touches jsdom's missing canvas.test-setup.tspolyfillsmatchMedia/ResizeObserverfor jsdom.
Three files, three lines of glue:
- Create
lib/indicators/my_ind.mlimplementingIndicator.S. - Create
test/indicators/my_ind_test.mlcovering the invariants via Alcotest. - Add one line to
lib/indicators/registry.ml.
Mirror on the UI side:
- Create
ui/src/app/indicators/my_ind.tswith the pure math +myIndOverlay(). - Create
ui/src/app/indicators/my_ind.spec.tswith Vitest cases. - Register it: one line in
ui/src/app/indicators/overlay.ts(overlayRegistry) and one inui/src/app/indicators/index.tsbarrel. - Add a catalog entry to
ui/mock-server.mjsso the mock exposes it.
app.component.ts doesn't need to change — it dispatches generically on
the pane key via overlayRegistry.
- Create
lib/strategies/my_strategy.mlmatchingStrategy.S:- types
params,state; - values
name : string,default_params : params,init : params -> state,on_candle : state -> Instrument.t -> Candle.t -> state * Signal.t.
- types
- Add one line to
lib/strategies/registry.ml. - Add a catalog entry to
ui/mock-server.mjs.
Individual strategies (SMA_Crossover, RSI_MeanReversion,
MACD_Momentum, Bollinger_Breakout) can be combined via
Composite — itself an implementation of Strategy.S, so
composites are indistinguishable from leaf strategies: they can be
backtested, registered in the UI, or nested into other composites.
| Policy | Rule | Hold semantics |
|---|---|---|
Unanimous |
all children must emit the same action | Hold = "no" (counts against) |
Majority |
>50% of all children | Hold = "no" |
Any |
at least one active voter | Hold = abstain |
Adaptive |
Sharpe-weighted ensemble | weight = max(0, rolling Sharpe) |
Learned |
logistic-regression meta-learner | P(profitable) > threshold |
Exit signals always take priority over Enter — safer for live trading: close first, then decide whether to re-enter.
Each child tracks a virtual position and accumulates a rolling
window of realized returns. Per-child weight is proportional to
max(0, Sharpe ratio). Children performing well get louder votes;
poorly-performing ones are silenced. When all Sharpes are
non-positive, falls back to equal weights (1/N).
let strat = Strategy.make (module Composite) {
policy = Adaptive { window = 50 };
children = [sma; rsi; macd; boll];
}
A lightweight ML layer trained offline, deployed as a fixed weight vector. No external dependencies — pure OCaml float arithmetic.
Feature vector (for N children): 2·N + 2 floats:
[| signal₁(±1); strength₁; signal₂; strength₂; …;
volatility; (* std(close)/mean(close) over last 20 bars *)
volume_ratio (* current_volume / mean(volume) *) |]
The two market-context features let the model learn regime-dependent combinations ("SMA + RSI works in low-vol, but not high-vol") that per-strategy Sharpe weighting cannot capture.
Training (lib/domain/strategies/trainer.ml):
let result = Trainer.train
~children:[sma; rsi; macd; boll]
~candles:historical_data
~lookahead:5 (* target: is close[i+5] > close[i]? *)
~epochs:10
() in
(* result.weights : float array — learned coefficients
result.train_loss / val_loss — log-loss on 70/30 split
result.n_train / n_val — sample counts *)
Walk-forward discipline: the target at bar i looks at
close[i+lookahead], so the training set uses only bars whose
outcome is fully determined within the training window. No future
information leaks into the model. The dataset is split 70/30
(train/validation) chronologically, never shuffled.
Deployment:
let strat = Strategy.make (module Composite) {
policy = Learned { weights = result.weights; threshold = 0.6 };
children = [sma; rsi; macd; boll];
}
At each bar the model computes P(profitable long):
P > threshold→Enter_longwithstrength = PP < 1 - threshold→Enter_shortwithstrength = 1 - P- otherwise →
Hold
Modules:
| File | Purpose | Lines |
|---|---|---|
logistic.ml |
sigmoid, predict, sgd_step, train (multi-epoch SGD with L2 regularisation) |
~50 |
features.ml |
extract : signals → candle → recent_closes → float array |
~35 |
trainer.ml |
train : children → candles → result (walk-forward, 70/30 split) |
~70 |
composite.ml |
Learned policy branch in on_candle |
~15 (delta) |
Risks and mitigations:
- Overfitting — L2 weight decay (
l2parameter) + train/val split. With 4 children the model has 11 parameters; 200+ decision-point bars are needed for a reasonable fit. - Non-stationarity — retrain periodically (e.g. weekly) on a
sliding window. The weights are a plain
float array; swapping them is a config change, not a code change. - Lookahead bias — enforced structurally:
Trainer.trainnever lets a bar's target depend on data outside the training window.
| Name | Children | Default policy |
|---|---|---|
Composite_SMA_RSI |
SMA Crossover + RSI MR | Majority |
Composite_SMA_MACD |
SMA Crossover + MACD Momentum | Majority |
Composite_All |
all four strategies | Majority |
Adaptive_All |
all four strategies | Adaptive(window=50) |
All adapters implement the shared Broker.S port (lib/application/broker/)
so the rest of the codebase programs against Broker.client. WebSocket
plumbing (frame codec, TLS handshake, Client.connect/send_text/recv)
lives in lib/infrastructure/websocket/ and is reused by live brokers.
A fake broker adapter at lib/infrastructure/acl/synthetic/ — used
whenever you start the server without a real broker. Implements
Broker.S by running a deterministic random walk
(Generator.generate) and wobbling the trailing bar on each bars
call so the polling stream emits visible intrabar updates.
let syn = Synthetic.Synthetic_broker.make () in
let client = Synthetic.Synthetic_broker.as_broker syn in
(* identical call site to Finam / BCS: *)
let bars = Broker.bars client ~n:500
~instrument:(Instrument.of_qualified "SBER@MISX")
~timeframe:Timeframe.H1 in
It's symmetric to the live adapters by design: there is no
special-cased "synthetic mode" in the HTTP server or the stream
registry, so real-broker errors aren't silently masked by fallback
data, and strategies / backtests run against a stable source no
matter which broker the server was started with. Venue list is a
single placeholder (MISX) so the UI dropdown still renders.
Auth: long-lived portal secret → short-lived JWT via /v1/sessions,
refreshed transparently by Finam.Auth.
Instrument routing: TICKER@MIC (e.g. SBER@MISX). Board is accepted
on Instrument.t but ignored by Finam — their REST picks the primary
board server-side and echoes it back in /v1/assets responses.
let cfg = Finam.Config.make ~secret ?account_id () in
let client = Finam.Rest.make ~transport ~cfg in
let sber = Instrument.make
~ticker:(Ticker.of_string "SBER")
~venue:(Mic.of_string "MISX") () in
let bars = Finam.Rest.bars client ~instrument:sber ~timeframe:Timeframe.H1 in
WebSocket (lib/infrastructure/acl/finam/ws.ml + ws_bridge.ml)
follows the asyncapi-v1.0.0 envelope — {action, type, data, token} —
with one multiplexed socket covering all subscriptions. JWT refreshes
on every outbound message via Auth.current.
Docs:
- REST: https://tradeapi.finam.ru/docs/rest
- gRPC: https://tradeapi.finam.ru/docs-new/grpc-new
- WebSocket: https://tradeapi.finam.ru/docs-new/async-api-new/
- Protos: https://github.com/FinamWeb/finam-trade-api
Auth: OAuth2 refresh_token → short-lived access_token via Keycloak
realm tradeapi at be.broker.ru. The refresh_token is issued in
«БКС Мир инвестиций» → «О счёте» → «Токены API»; Bcs.Auth caches
the access_token and re-exchanges on expiry.
Instrument routing: (classCode, ticker) pair. Instrument.board
maps 1:1 to classCode (TQBR, SMAL, SPBFUT, …); when absent,
the adapter substitutes Config.default_class_code (TQBR by
default). Instrument.venue is ignored — BCS-via-QUIK is MOEX-only
in our config.
let cfg = Bcs.Config.make ~refresh_token ?account_id () in
let client = Bcs.Rest.make ~transport ~cfg in
let sber = Instrument.make
~ticker:(Ticker.of_string "SBER")
~venue:(Mic.of_string "MISX")
~board:(Board.of_string "TQBR") () in
let bars = Bcs.Rest.bars client ~instrument:sber ~timeframe:Timeframe.H1 in
WebSocket (lib/infrastructure/acl/bcs/ws.ml + ws_bridge.ml) uses
a per-subscription socket: each (classCode, ticker, timeFrame)
opens its own WS at wss://ws.broker.ru/trade-api-market-data-connector/api/v1/last-candle/ws
and tears down on unsubscribe. JWT goes into the Authorization
handshake header.
Docs:
- Portal: https://trade-api.bcs.ru/
- Reference Go client (protocol source of truth): https://github.com/tigusigalpa/bcs-trade-go
lib/infrastructure/paper/paper_broker.ml is a decorator that wraps
any Broker.client, intercepts order operations, and simulates fills
against an in-memory book. Market data (bars, venues) still
delegates to the wrapped source — live Finam, BCS, or Synthetic — so
charts and strategies see the same prices they would in production,
but no order ever leaves the process.
Use it to smoke-test a strategy on a real data feed before routing orders to a broker:
dune exec -- bin/main.exe serve --broker finam --paper \
--secret "$FINAM_SECRET" --account "$FINAM_ACCOUNT_ID"
--paper composes with every --broker choice: synthetic is fine
for offline simulation, finam / bcs for a live-data dress
rehearsal.
Paper follows the same next-bar execution rule as the backtester
(lib/domain/engine/backtest.ml), so a strategy's P&L in paper and in
backtest match on identical signal streams:
| Kind | Fill trigger | Fill price |
|---|---|---|
| Market | first bar strictly after placement | open of that bar |
| Limit buy | next bar whose open ≤ limit or low ≤ limit |
min(open, limit) — gap favours the trader |
| Limit sell | next bar whose open ≥ limit or high ≥ limit |
max(open, limit) |
| Stop buy | next bar whose open ≥ stop or high ≥ stop |
max(open, stop) |
| Stop sell | next bar whose open ≤ stop or low ≤ stop |
min(open, stop) |
| Stop-limit | not simulated in this release — stays New |
— |
An order placed "during" bar T cannot fill at bar T itself; it can only fill at bar T+1's open or later. This is the rule that keeps paper fills free of same-bar lookahead.
Paper is passive by design — callers feed bars via on_bar. The wiring
in bin/main.ml plugs two sources into the decorator:
- The live WebSocket path: whenever
Finam.Ws_bridgeorBcs.Ws_bridgedelivers a candle,bin/main.mlcallsPaper.on_barin addition toStream.push_from_upstream. - The polling path:
Paper.barssinks the trailing candle it returns, so synthetic-source deployments (no WS) still advance as the UI polls/api/candles.
Tests drive on_bar directly — unit tests in
test/unit/infrastructure/paper/paper_broker_test.ml cover market
fill at next open, same-bar non-fill, limit fill with and without gap,
stop trigger, cancel-before-fill, and cross-instrument isolation.
Paper.Paper_broker.make accepts three optional knobs that control
how realistic the simulation is:
| Parameter | Default | Effect |
|---|---|---|
fee_rate |
0.0 |
Multiplier on fill notional (qty*price). Use 0.0005 to mirror the backtester's 5-bps commission. |
slippage_bps |
0.0 |
Basis-point shift against the trader on Market and Stop orders. Buys pay (1 + bps/1e4) * price, sells receive (1 - bps/1e4) * price. Limit and Stop_limit fills are not slipped — the trader already chose a worst-acceptable price. |
participation_rate |
None |
When set, caps per-bar fill quantity at rate * bar.volume. Orders larger than the cap transition New → Partially_filled and continue filling on subsequent bars. None means one-shot fills regardless of volume. |
Example: a configuration that roughly matches the backtester with a 10% participation ceiling for large orders:
let paper = Paper.Paper_broker.make
~initial_cash:(Decimal.of_int 1_000_000)
~fee_rate:0.0005
~slippage_bps:5.0
~participation_rate:0.1
~source:broker_client ()
The portfolio exposed via Paper.Paper_broker.portfolio accounts for
both the fill price (including slippage) and the fee, so
Portfolio.equity compares directly against a backtest run with the
same fee_rate.
- Stop-limit orders are accepted but never transition past
New; implementing the two-phase state machine (trigger on stop, then behave as a limit) is a follow-up. - No rejection modelling — the broker never refuses an order for
margin, halts, or lot-size reasons. Risk checks happen upstream in
the live engine via
Engine.Risk, not in Paper. - No market-impact model beyond volume participation — large orders consume bar volume but do not move the next bar's prices.
lib/application/live_engine/ drives a strategy in real time: it
consumes bars from a WebSocket stream, threads them through the
same Pipeline.run that Backtest.run uses, and submits market
orders via Broker.place_order. Attach by adding --strategy NAME to serve:
dune exec -- bin/main.exe serve --broker finam --paper \
--strategy SMA_Crossover --secret "$FINAM_SECRET" \
--account "$FINAM_ACCOUNT_ID"
The engine runs in its own Eio fiber, consuming a Candle.t
stream pushed by the broker's WS bridge. Paper and real brokers
plug in identically through the shared Broker.S port.
Orders go through a reservation ledger that mirrors how professional systems handle the broker-latency gap:
Step.execute_pendingreserves cash (and quantity for sells) —available_cashdrops, but actualcash/positionsare unchanged.Broker.place_ordersubmits to the broker.- On a broker fill event (
Paper.on_fill, or WSorder_updatefrom a real broker),Live_engine.on_fill_eventcommits the reservation against actual fill numbers. - A periodic
reconcilecatches anything the primary event path missed — pollsBroker.get_orders, reads actual per- execution prices viaBroker.get_executions, commits or releases as needed.
Risk.check gates on available_cash, so back-to-back signals
can't collectively overspend while an earlier order is in flight.
See docs/architecture/reservations.md for the full
mechanism.
Before every broker submission the engine consults two gates:
- Kill switch —
max_drawdown_pct(set to0.15by default inbin/main.ml). Tracks peak equity marked-to-market; trips when drawdown exceeds the threshold. Once tripped, no new orders are submitted untilLive_engine.resetis called deliberately. Existing reservations continue to settle normally. - Rate limit — optional
(max_orders, window_seconds). Protects against runaway strategies and broker API quotas.
Gates are cheap to add — see check_gates in
live_engine.ml for the pattern.
- Real broker WS
order_updatehandlers for Finam/BCS — today Paper is the only source ofon_fill_event. The Finam adapter'sget_executionsstub will be wired to/v1/accounts/{id}/tradesalongside the live smoke-test. - CLI flags for
--max-drawdownand--rate-limit— values are hard-coded reasonable defaults inbin/main.mlfor now.