An end-of-day systematic signal engine. It reads price history for the held universe, scores every name on ten classic factors, blends them into one number per ticker, gates that number by the market regime, and emits proposed long signals — with an honest backtest beside every family. This page walks the whole pipeline on a real run.
Everything below is produced by one deterministic command —
uv run python -m strategy.engine.run_nightly — over a committed synthetic
candle set. Same bytes in, byte-identical files out. No live data provider is wired yet
(that lands Phase 2); the engine's logic is finished and tested today.
Seven stages, one direction of flow. Each stage is its own module, and the no-lookahead contract holds at every step — the value computed for any day uses only data up to and including that day.
CSV fixtures today (Supabase signals.market_candles Phase 2). All inputs hard-truncated to the --asof date before anything is computed.
Each returns a series aligned 1:1 with the input; None during warmup. Value at index i uses data[0..i] only.
Per-indicator full-scale constants anchored to the literature. Risk factors are clamped to ≤ 0 — volatility never argues for a position.
Mean of the available members. Warmup / missing indicators drop out instead of polluting the score.
Weighted sum across families (weights renormalized over families that have data), multiplied by the day's regime factor. Result clamped to [−100, 100].
≥ +20 long, ≤ −20 short, else flat. |score| ≥ 60 high, ≥ 40 medium, else low.
C1 signal_runs + backtest_runs JSONL, plus one C4 signal JSON per actionable long — schema-validated before write.
The factor library is deliberately classic — each window is a well-known literature parameterization, not a fitted curiosity. Families carry different jobs: momentum is the core driver, mean-reversion times entries, volatility only penalizes, and relative strength asks whether the theme is actually working or just riding the market.
| Family | Indicator | What it measures | Literature anchor |
|---|---|---|---|
| momentum | mom_12_1 | 12-month return, skipping the last month | Jegadeesh & Titman 1993 |
| ma_50_200_state | Golden / death cross state (±1) | Faber 2007 | |
| high_52w_prox | Close as a fraction of the 52-week high | George & Hwang 2004 | |
| macd_state | MACD(12,26) above / below its 9-signal | Appel | |
| meanrev | rsi_14 | Wilder RSI — contrarian (oversold = buy) | Wilder 1978 |
| zscore_20d | 20-day price z-score — contrarian | — | |
| vol | atr_pct | Wilder ATR(14) ÷ close — penalty only | Wilder 1978 |
| rvol_pctile | Realized-vol percentile within trailing year | — | |
| drawdown_from_peak | Distance below running peak — penalty only | — | |
| relstrength | rs_vs_benchmark | Trailing-quarter return spread vs SPX | — |
Each indicator has a full-scale constant that maps its raw reading onto the
[−100, 100] axis. A few representative ones, straight from NORMALIZERS:
| Indicator | Mapping | Full-scale point |
|---|---|---|
| mom_12_1 | +30% 12-1 return → +100 | winner-decile annual |
| high_52w_prox | 0.85 → 0 · at the high → +100 · ≤0.70 → −100 | linear band |
| rsi_14 | RSI 20 → +100 (oversold) · RSI 80 → −100 | contrarian |
| atr_pct | 5% daily ATR → −100 (clamped ≤ 0) | penalty |
| rs_vs_benchmark | ±15% quarterly spread → ±100 | full scale |
atr_pct,
rvol_pctile and drawdown_from_peak are negated and clamped to
≤ 0. High volatility can only ever drag a composite down; it can never
manufacture conviction to go long.Family scores combine into one composite via fixed weights — literature defaults, recalibrated quarterly from the engine's own backtests, never silently. This is a theme-riding book, so momentum dominates.
Before thresholds, the composite is multiplied by a factor for the day's market regime — a Faber-style filter on SPX vs its 200-day MA, combined with a VIX bucket. The 2026-06-10 run classified risk-on, so the gate was a no-op (×1.0).
| Regime | Condition | Gate factor |
|---|---|---|
| risk-on | SPX above 200MA & VIX < 25 | × 1.00 |
| neutral | everything else (and warmup) | × 0.75 |
| risk-off | SPX below 200MA & VIX ≥ 25 | × 0.50 |
Franco-Nevada, theme Hrávörur & málmar, from the live run. Momentum is strongly positive, but the contrarian and risk families pull back hard — exactly the tension the ensemble exists to resolve.
Composite +31.1 clears the +20 long threshold but sits below the medium band (40), so:
A single nightly run over the 25-ticker universe produced:
Only actionable long composites become signal files; shorts and flats
live in signal_runs only. Every file is validated against the canonical
signal.schema.json before it is written. Here is the FNV signal as emitted:
{
"signal_id": "STRAT-20260610-FNV-1",
"ticker": "FNV",
"direction": "long",
"conviction": "low",
"sizing_band": { "min_pct_nav": 0.5, "max_pct_nav": 1.5 },
"theme": "Hrávörur & málmar",
"thesis_ref": "[[engine-v0-composite-methodology]]",
"invalidation": "Composite score drops below +20 on a nightly engine run
OR close below the 200d MA for 3 consecutive sessions.",
"status": "proposed",
"notes": "source: engine-v0; composite=31.1; regime=risk-on;
families: meanrev=-45.5; momentum=95.5; relstrength=-10.3; vol=-16.3"
}
notes field
records the source, composite, regime and per-family scores; thesis_ref points
at the methodology note that justifies the whole approach. Nothing is emitted as a bare
number with no audit trail.Every family is walk-forward backtested per ticker: long/flat,
close-to-close, with UK friction of 0.55% per side applied multiplicatively on every
entry and exit. Friction never alters the position series, so
net = gross × (1−f)sides holds exactly — a test pins that identity.
A sample momentum family result:
| Metric | Value |
|---|---|
| Sharpe | 1.06 |
| Max drawdown | −25.96% |
| Hit rate | 42.9% |
| Trades / sides | 14 |
| Period | 2024-01-11 → 2026-06-10 |
These are not aspirations — each is asserted by a test in the suite.
Same fixture in → byte-identical output.
No timestamps or UUIDs in engine output; sorted JSON keys; ENGINE_VERSION
pinned and bumped on any change to indicators, normalization, weights or emit.
Per-indicator prefix equality plus a
pipeline-level perturbation test. Inputs are hard-truncated at --asof;
future bars cannot leak into any value, so the backtest walk is lookahead-free by
construction.
Net equity equals gross equity times
(1−f) raised to the side count, exactly. Friction is modeled per-side and
never quietly omitted from a headline return.
Indicators return None until
their window fills; missing members drop out of the family mean rather than scoring as
zero. A half-warmed name is never silently treated as neutral.
signals.signal_runs via the engine role credential) are open items.
Signals carry status: "proposed"; the engine quantifies a view, it does not
assert a certainty.