ViskaStrat · Strategy & Forecast

The Quant Engine, v0

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.

version engine-v0.1.0 run date 2026-06-10 regime risk-on universe 25 tickers mode fixtures-first scope long-only · Phase 1

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.

01 The pipeline at a glance

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.

1

Load candles + regime data.py · regime.py

CSV fixtures today (Supabase signals.market_candles Phase 2). All inputs hard-truncated to the --asof date before anything is computed.

2

Compute 10 indicators across 5 factor families indicators.py

Each returns a series aligned 1:1 with the input; None during warmup. Value at index i uses data[0..i] only.

3

Normalize each raw value → sub-score in [−100, 100] composite.py · NORMALIZERS

Per-indicator full-scale constants anchored to the literature. Risk factors are clamped to ≤ 0 — volatility never argues for a position.

4

Average sub-scores into a family score composite.py

Mean of the available members. Warmup / missing indicators drop out instead of polluting the score.

5

Weighted ensemble → composite, then regime gate composite.py · weights_v0.yaml

Weighted sum across families (weights renormalized over families that have data), multiplied by the day's regime factor. Result clamped to [−100, 100].

6

Thresholds → direction + conviction band composite.py · thresholds

≥ +20 long, ≤ −20 short, else flat. |score| ≥ 60 high, ≥ 40 medium, else low.

7

Emit rows + signal files, backtest every family emit.py · backtest.py · run_nightly.py

C1 signal_runs + backtest_runs JSONL, plus one C4 signal JSON per actionable long — schema-validated before write.

02 Ten indicators, five families

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.

FamilyIndicatorWhat it measuresLiterature anchor
momentummom_12_112-month return, skipping the last monthJegadeesh & Titman 1993
ma_50_200_stateGolden / death cross state (±1)Faber 2007
high_52w_proxClose as a fraction of the 52-week highGeorge & Hwang 2004
macd_stateMACD(12,26) above / below its 9-signalAppel
meanrevrsi_14Wilder RSI — contrarian (oversold = buy)Wilder 1978
zscore_20d20-day price z-score — contrarian
volatr_pctWilder ATR(14) ÷ close — penalty onlyWilder 1978
rvol_pctileRealized-vol percentile within trailing year
drawdown_from_peakDistance below running peak — penalty only
relstrengthrs_vs_benchmarkTrailing-quarter return spread vs SPX

How a raw value becomes a sub-score

Each indicator has a full-scale constant that maps its raw reading onto the [−100, 100] axis. A few representative ones, straight from NORMALIZERS:

IndicatorMappingFull-scale point
mom_12_1+30% 12-1 return → +100winner-decile annual
high_52w_prox0.85 → 0 · at the high → +100 · ≤0.70 → −100linear band
rsi_14RSI 20 → +100 (oversold) · RSI 80 → −100contrarian
atr_pct5% daily ATR → −100 (clamped ≤ 0)penalty
rs_vs_benchmark±15% quarterly spread → ±100full scale
Design choice — risk is one-sided. 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.

03 The ensemble weights

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.

0.45
momentum
0.25
relstrength
0.15
meanrev
0.15
vol

The regime gate

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).

RegimeConditionGate factor
risk-onSPX above 200MA & VIX < 25× 1.00
neutraleverything else (and warmup)× 0.75
risk-offSPX below 200MA & VIX ≥ 25× 0.50
composite = clamp[−100,100](   Σfamily ( scoref × wf ) / Σ wf   × gateregime )

04 A worked example — FNV

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.

momentum ·.45 +95.5
relstrength ·.25 −10.3
meanrev ·.15 −45.5
vol ·.15 −16.3
(95.5×0.45) + (−10.3×0.25) + (−45.5×0.15) + (−16.3×0.15)
= 42.98 − 2.58 − 6.83 − 2.45 = 31.13   × gate 1.0 (risk-on) = 31.1

Composite +31.1 clears the +20 long threshold but sits below the medium band (40), so:

long conviction: low sizing 0.5–1.5% NAV
Momentum carried this name across the line on its own. A reader sees immediately why the signal is only low-conviction: three of four families voted against it, and the ensemble is honest about that rather than rounding up.

05 What a run emits

A single nightly run over the 25-ticker universe produced:

275
signal_runs rows
100
backtest rows (25×4)
3
long signal files
25
tickers scored

The signal file (C4) — long-only in Phase 1

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"
}
Provenance rides in every signal. The 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.

The backtest — costs honesty

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:

MetricValue
Sharpe1.06
Max drawdown−25.96%
Hit rate42.9%
Trades / sides14
Period2024-01-11 → 2026-06-10

06 The three invariants

These are not aspirations — each is asserted by a test in the suite.

Determinism

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.

No-lookahead

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.

Costs honesty

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.

Warmup integrity

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.

Uncertainty posture. v0 runs on a committed synthetic candle set — the numbers on this page are real engine output, but the price history is fixture data, not the live book. The market-data provider pick and the Phase-2 database writes (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.