Logistic regression

A minimal online logistic classifier used as the gating function inside Composite's Learned policy. Predates the GBT pipeline and serves a different architectural role — not "replace heuristic strategies with an ML model", but "let a lightweight classifier decide which of the existing heuristic strategies' signals to trust right now".

See also gbt.md for the tree-ensemble counterpart and how the two approaches differ in intent.

#Module layout

Three focused files under lib/domain/ml/logistic_regression/:

#The model itself

Single-layer linear classifier with a sigmoid head:

P(y=1 | x) = sigmoid( bias + Σᵢ wᵢ · xᵢ )

Weights are a plain float array of length 1 + n_features (position 0 is the bias term). Training is stochastic gradient descent with optional L2 weight decay — one gradient step per sample, repeated across epochs passes:

∂loss/∂w_i  =  (pred − target) · x_i + λ · w_i     # logistic + L2
w_i         ←  w_i − lr · ∂loss/∂w_i

Defaults: lr = 0.01, l2 = 1e-4, epochs = 10. Tunable per-call. The sigmoid clamps to [0, 1] outside |z| > 15 to avoid exp overflow — numerically cosmetic, predictions at those extremes are already saturated.

Weights round-trip through export_weights / of_weights, so a trained model becomes a plain float array that embeds cleanly into a config-file, test fixture, or Composite.Learned params.

#Feature vector

Features.extract maps (signals, candle, recent_closes, recent_volumes) to a flat float array, ordered so index positions are stable regardless of child-strategy identity:

┌──────────────────────────── per-child, interleaved ───────────────────────────┐
  signal₁, strength₁, signal₂, strength₂, …, signalₙ, strengthₙ,
└──────────────────────────────── market context ───────────────────────────────┘
  volatility, volume_ratio

The two market-context features are load-bearing. The point of Learned is to learn conditional trust: "SMA crossover works in low-vol regimes but not high-vol ones" is exactly the kind of rule a dumb Majority or rolling-Sharpe Adaptive policy can't express. A flat trust-weighting would underperform in the regime switch; a classifier that sees volatility as a feature can adapt.

Feature count formula: n_features(~n_children) = 2·n_children + 2. Wired consistently so Trainer.train and runtime Features.extract agree on dimension; a mismatch would silently cause the sigmoid to read garbage weights at position 2i+1.

#Training loop

Trainer.train ~children ~candles does offline walk-forward:

  1. Replay children over the candle stream. Every child strategy is stepped once per bar in lockstep; signals are collected into all_signals.(i).
  2. Label derivation. At bar i with at least one non-Hold child signal: target = 1.0 if close[i + lookahead] > close[i], else 0.0. Lookahead defaults to 5 bars.
  3. Skip no-decision bars. If every child held, there's nothing to learn from that bar — it's dropped, not labelled zero.
  4. Split 70/30. First seven-tenths of the collected dataset becomes training, the last three-tenths is held out for validation-loss measurement.
  5. SGD over training split for epochs passes.
  6. Report train loss, val loss, sample counts, and the exported weight vector.

Walk-forward discipline is enforced by the ordering: the label at bar i reads close[i + lookahead], which is strictly in the past relative to the end of the candle history. No future-to-past leakage is possible because children are replayed one bar at a time and targets compute forward within the closed training window.

#Where it plugs into the strategy layer

                        ┌─────────────────────────────────┐
   ┌──────────────┐     │                                 │
   │ Child strats │──── signals ──┐                       │
   │ (SMA, RSI,  │                │                       │
   │  MACD, BB,   │                ▼                       │
   │   …)        │          ┌─────────────┐               │
   └──────────────┘          │ Features.   │               │
                              │ extract     │               │
   bar / market context ─────▶│             │──── features ▶│ Composite.Learned
                              └─────────────┘               │     │
                                                            │     ▼
                                                            │   Logistic.predict
                                                            │     │
                                                            │     ▼
                                                            │  P(profitable)
                                                            │     │
                                                            │     ▼
                                                            │  vs threshold?
                                                            │  → Enter_long / Hold
                                                            │
                                                            └─────────────────────┘

Composite with policy = Learned { predict; threshold } wires a closure that combines Features.extract with the learned weights. The composite itself has no ML dependency — it takes predict : Signal.t list -> Candle.t -> float list -> float list -> float at construction time, and the predictor is injected from outside. That keeps the strategies layer free of logistic / gradient-descent concepts and localises the "thinking" to this module.

#Why logistic, why here

Logistic regression is the simplest thing in supervised ML that learns conditional combinations:

What it doesn't handle (limitations to keep in mind):

For problems where a bigger model is justified, use the GBT pipeline (Gbt_strategy + Gbt_model) — it handles non-linearities, exhaustiveness warnings over feature value ranges, and a richer training ecosystem (LightGBM). Logistic stays here for its role as a fast, transparent gate for pre-existing heuristic strategies, not as an alpha generator in its own right.

#Runtime shape

All computation is pure float array arithmetic:

No dependencies beyond stdlib. No file IO (weights are marshalled as float array into config-land by callers). Unlike Gbt_model — which loads models from disk and watches mtime — logistic weights are small (e.g. 10 scalars for 4 children), so they live in config or test fixtures, not separate files.

#Testing

Three test files mirror the three modules:

Plus learned_policy_test.ml under strategies exercises the end-to-end Composite.Learned integration.