How to train and deploy a logistic gate

End-to-end walkthrough: take a set of existing heuristic strategies (SMA crossover, RSI mean reversion, etc.), learn a logistic classifier that decides when to trust their consensus, and deploy the result as a Composite.Learned policy.

For design background see architecture/ml/logistic_regression.md. This document assumes you've read it and focuses on the mechanics.

The logistic pipeline is dramatically smaller than the GBT one: training happens in-process in OCaml, weights are ~10 scalars, no Python, no .meta.json sidecar, no hot-reload watcher. The whole loop — "construct children, fit, use" — is a dozen lines of OCaml.

#What you're actually training

A common misconception: "logistic regression replaces my heuristic strategies with an ML model". It doesn't. Its job is to gate their signals — given that SMA Crossover, RSI Mean Reversion, MACD Momentum and Bollinger Breakout all emit their opinions on a bar, logistic decides whether their collective opinion is reliable right now (given the market regime).

Concretely the input features are:

The output is P(profitable): probability that entering long on the next bar yields a positive return over the lookahead window. Composite.Learned treats this as Enter_long if P > threshold, else Hold.

The rule logistic can learn — and that plain Adaptive / Majority policies cannot — is regime-conditional trust: "children collectively work in low-volatility periods but misfire when vol spikes". Whether that rule is actually present in your data is the empirical question the trainer answers.

#Prerequisites

None outside the OCaml build. No Python, no venv. The training is a dedicated binary built together with the rest of the project — dune build produces _build/default/bin/train_logistic.exe.

Broker credentials are resolved the same way as for other binaries (see trading --help for full list): --secret / --account / --client-id flags or the matching <BROKER>_SECRET / <BROKER>_ACCOUNT_ID / BCS_CLIENT_ID env vars.

#Train: one command

dune exec -- bin/train_logistic.exe -- \
  --broker finam \
  --symbol SBER@MISX \
  --timeframe H1 \
  --from 2024-01-01 \
  --to 2026-04-20 \
  --children SMA_Crossover,RSI_MeanReversion,MACD_Momentum,Bollinger_Breakout \
  --lookahead 5 \
  --epochs 10 \
  --output ~/.local/state/trading/models/sber_h1_logistic.json

The tool paginates historical bars across the date window (same walker as export_training_data.exe), runs the comma-separated list of child strategies through them, fits a logistic classifier in-process, and writes the weights as JSON.

Typical output (numbers vary with data):

Fetched 5043 bars from finam (SBER@MISX)
Children (4): SMA_Crossover, RSI_MeanReversion, MACD_Momentum, Bollinger_Breakout
Trained: n_train=1342 n_val=575 train_loss=0.6782 val_loss=0.6891
Wrote /home/user/.local/state/trading/models/sber_h1_logistic.json (11 weights)

#Options

Flag Default Meaning
--broker required finam or bcs
--symbol required Qualified TICKER@MIC[/BOARD]
--output required JSON file to write
--children required Comma-separated registry names
--timeframe H1 M1 \| M5 \| M15 \| M30 \| H1 \| H4 \| D1
--from / --to last 365 days ISO date or full RFC 3339
--lookahead 5 Bars to label forward for P(profitable)
--epochs 10 SGD passes over the training split
--lr 0.01 Learning rate
--l2 1e-4 L2 weight-decay coefficient
--context-window 20 Recent bars for volatility / volume_ratio features

#Child-order invariant

The feature vector is positional: the weight at index 2·i pairs with the i-th child's signal. Retraining with a different child list produces a new weights file that's incompatible with the old live config — every weights file is tied to the exact children passed at training time.

Document the children list alongside the weights file (e.g. in a sibling sber_h1_logistic.children text file, or as a comment in deployment YAML); the binary itself doesn't round-trip that metadata into the JSON — weight files are just { weights, lr, l2 }.

#Interpreting the result

#Deploying the trained gate

Load the trained model at startup via Logistic.of_file, wrap Features.extract + Logistic.predict into a closure matching Composite.predictor, and hand the whole thing to a Composite.Learned strategy:

let logistic = Logistic_regression.Logistic.of_file
  "/home/user/.local/state/trading/models/sber_h1_logistic.json" in

let predict ~signals ~candle ~recent_closes ~recent_volumes =
  let features = Logistic_regression.Features.extract
    ~signals ~candle ~recent_closes ~recent_volumes in
  Logistic_regression.Logistic.predict logistic features

let composite = Strategies.Strategy.make (module Strategies.Composite)
  Strategies.Composite.{
    policy = Learned { predict; threshold = 0.55 };
    children = [
      Strategies.Strategy.default (module Strategies.Sma_crossover);
      Strategies.Strategy.default (module Strategies.Rsi_mean_reversion);
      Strategies.Strategy.default (module Strategies.Macd_momentum);
      Strategies.Strategy.default (module Strategies.Bollinger_breakout);
    ];
  }

Invariant: the child list here must match the child list passed to the trainer, in the same order. The weights index into the feature vector positionally, and a reorder silently corrupts predictions. If you add a child, retrain from scratch — the weight vector's length changes.

Feed the resulting composite into Backtest.run or a Live_engine.config exactly like any other strategy. Nothing downstream cares that it's ML-backed.

#Persistence

Logistic.to_file / Logistic.of_file read and write a small JSON envelope carrying weights plus learning hyperparameters:

{
  "weights": [ 0.023451, -0.158234, 0.087621, ... ],
  "lr":      0.01,
  "l2":      0.0001
}

Writes go through a tmp-file + atomic rename (same pattern as Token_store.file.save), so a running process reading the file never sees a half-written state. Unknown fields are ignored, and missing lr/l2 fall back to the Logistic.make defaults — so hand-written fixtures can get away with { "weights": [...] }.

No hot-reload machinery like GBT's mtime-watch — the weights are read once at startup. If you retrain and want the new weights in production, restart the process. For a 10-scalar vector, that's a reasonable trade-off; if weights live in a config file and change often, add your own reload hook.

#Retraining

Since the whole pipeline is one binary invocation, the retrain loop is a small shell script — no intermediate files to stage, no Python environment to activate:

#!/bin/bash
set -euo pipefail

TODAY=$(date -u +%Y-%m-%d)
FROM=$(date -u -d '2 years ago' +%Y-%m-%d)
MODEL_DIR="$HOME/.local/state/trading/models"
mkdir -p "$MODEL_DIR"

dune exec -- bin/train_logistic.exe -- \
  --broker finam \
  --symbol SBER@MISX \
  --from "$FROM" --to "$TODAY" \
  --children SMA_Crossover,RSI_MeanReversion,MACD_Momentum,Bollinger_Breakout \
  --output "$MODEL_DIR/sber_h1_logistic_$TODAY.json"

ln -sf "$MODEL_DIR/sber_h1_logistic_$TODAY.json" \
       "$MODEL_DIR/sber_h1_logistic_current.json"

The engine reads the target once at startup via Logistic.of_file, so picking up a fresh model still needs a process restart. For logistic's ~10-scalar vectors that's noise; if it ever matters, the same mtime-watch pattern as Gbt_strategy could be added.

#Troubleshooting

#val_loss == Float.infinity

The dataset had fewer than 10 labelled rows. See the n_total < 10 branch in trainer.ml. Causes:

#val_loss > train_loss + 0.05 — overfitting

#val_loss ≈ train_loss ≈ 0.693 — not learning

Baseline log-loss for coin-flip prediction. The classifier can't beat random. Options:

#Weights have NaN / inf

lr too high caused a gradient explosion, or candles has NaN prices somewhere upstream. Inspect candles first; if clean, drop lr by 10×.

#Compared to GBT

The GBT pipeline is heavier because the trade-off is different:

If you have strong heuristic children already and want to combine them smarter, start with logistic. If you want the model to replace the heuristics and discover patterns from raw indicators, go to GBT.