Indicator V2 Contract (issue #1356)
Source of truth: investment-agent/backend/data_pipeline/indicator_contract.py
(CONTRACT, a MappingProxyType[str, IndicatorSpec] of 78 frozen
entries) and investment-agent/backend/data_pipeline/indicator_primitives.py
(the shared numpy/pandas building blocks every V2 formula composes
from). This document summarises both for humans; the code is
authoritative — regenerate the 78-column table below from CONTRACT
whenever an entry changes.
Location note: this file lives at repo-root docs/ rather than
investment-agent/backend/docs/ because the latter does not exist in
this repository (per the Commit 1 spec's fallback rule).
NULL / NaN terminology
"Unavailable" means exactly one representation everywhere in the V2
contract: float64 NaN, never pandas.NA. +inf/-inf are
never a valid output at any position — every primitive/formula must
run its result through indicator_primitives.sanitize_finite before
returning. This is indicator_contract.UNAVAILABLE_MARKER.
Four closed-form first_valid / mature rules (0-based indices)
Every first_valid/mature pair in CONTRACT is derived by
mechanically applying one (or a composition) of these four rules,
starting from the raw OHLCV input columns, which are
first_valid = mature = 0 (a daily bar is usable the moment it
exists):
- Rolling layer (
rolling_mean/sum/max/min, alwaysmin_periods=window):first_valid = input.first_valid + (window - 1);mature = input.mature + (window - 1). - Recursive EMA layer (
recursive_ema,adjust=False):first_valid = input.first_valid;mature = input.mature + (span - 1). - Wilder RMA layer (
wilder_rma):first_valid = mature = input.first_valid + (period - 1). - Diff / cross-period lag layer (
.diff(),.shift(n)):first_valid = input.first_valid + n(n=1 for.diff());mature = input.mature + n.
Pointwise combination (add/sub/mul/div/AND/compare of k
inputs): first_valid = max(inputs.first_valid);
mature = max(inputs.mature).
Cumulative / stateful indicators (OBV, VWAP, Weis Wave,
Supertrend, the QQE trailing band) do not fit any of the four rules
above — they carry recursive state whose validity starts at a
structural origin point rather than converging through a fixed
window. For these, first_valid = mature = <structural start>, and
the entry's composition_note explains why.
Why tests/fixtures/indicators/ is its own subdirectory
Scoped narrowly (separate from the general-purpose tests/fixtures/)
so the AST import gate can assert every file under it never imports
pandas_ta or data_pipeline.* — a fixture loader that could
accidentally reach back into the pandas-ta code path it exists to
independently check would defeat the point of an oracle. It also
lets fixture files carry a uniform, checkable metadata contract
(formula, source, seed, warmup, basis, tolerance,
generated_by_model, verified_by_model — with the two model
fields required to differ) without that requirement leaking onto
unrelated fixtures elsewhere in the suite. See
tests/fixtures/indicators/README.md and ORACLE_AUDIT.md.
Contract decisions (17, summarised)
TR_INIT_POLICY = "cortex_wilder_variant_high_minus_low"—true_range[0] = high[0] - low[0](Cortex/Wilder initialization variant). TA-Lib'sTRANGE[0]is NaN (no previous close); Cortex seeds it from the day-0 range instead so every Wilder-RMA consumer of TR (ATR, the ADX chain, Keltner Channel, Supertrend, Choppiness) is fully valid from index 0.- Wilder RMA seed = SMA of the first
periodvalid inputs; output is simultaneously first-valid and mature atinput.first_valid + (period - 1). recursive_emaseed = first valid input value, nomin_periodsgating. This deliberately differs from V1's Elder-Ray implementation (which passedmin_periods=ema_periodtopandas.Series.ewm) — V2 applies rule 2 uniformly to every EMA usage, including inside Elder-Ray.BB_DDOF = 0— Bollinger Band width uses population std (ddof=0), not pandas' sample-std default (ddof=1).- Keltner Channel =
recursive_ema(close, KC_PERIOD)±KC_MULTIPLIER (1.5)×wilder_rma(true_range, KC_PERIOD)(Carter/TTM variant). AROON_WINDOW_EXTRA = 1— Aroon's rolling window islength + 1(26 for the configured 25-day length).- QQE trailing-band recurrence — Wilder-style, Cortex-pinned (full
branch-by-branch recurrence below);
QQE_FACTOR = 4.236. WEIS_WAVE_MISSING_VOLUME_POLICY = "reset"— a NULL-volume bar interrupts wave accumulation; the next valid bar restarts the wave rather than back-filling or skipping the gap. That restart bar is the new wave anchor and is itself NaN in all three columns (no direction is established yet), exactly like bar 0, with the next valid bar setting the new direction against the anchor's close.- VSA
confirmed[D] = detected[D-1] AND close[D] > close[D-1]is stored at the confirmation day D (differs from V1, which stored the same-daydetectedflag looked up against the next bar's close). - RSI zero-denominator:
avg_gain == avg_loss == 0→ NaN; single-sided zero → 100 (avg_loss=0, avg_gain>0) or 0 (avg_gain=0, avg_loss>0). - MFI zero-denominator: positive and negative money flow both zero → NaN; single-sided zero → 100/0 (same shape as RSI).
- VWAP = Cortex variant, full-history anchored cumulative sum; cumulative volume = 0 → NaN.
- Choppiness Index: flat range (
rolling_max(high) == rolling_min(low)) → NaN. - Stochastic (KD): RSV range
rolling_max(high) == rolling_min(low)→ NaN. - TII:
sum_pos + sum_neg == 0→ NaN. CCI: mean absolute deviation (MAD) == 0 → NaN. INPUT_PRICE_POLICY = "fail_loud_on_nonfinite"— mid-series NaN semantics, user-decided option A (2026-08-16), price fail-loud:- (a) OHLC price inputs must be fully finite (no NaN, no
±inf). Validated at the engine entry point by
indicator_primitives.validate_price_inputs, which raisesValueErrorlisting the offending row positions — never a silent coercion or row drop. Matches the production ADJ loader's upstream guarantee that NULL-price rows are already excluded before reaching this pipeline.wilder_rmaandrecursive_emacarry the same fail-loud policy as their own defense-in-depth: a NaN after a series' first valid value (interior/mid-series NaN) raisesValueErrornaming the position(s); a NaN run before the first valid value (ordinary warmup) is unaffected.true_rangepropagates NaN (skipna=False: any NaN component in a row makes that row's TR NaN, never reconstructed from the remaining finite components) rather than raising, since it has no "interior" concept of its own —TR[0] = high[0] - low[0](decision 1) still overrides row 0 regardless, since row 0's NaN previous-close is structural, not a missing input. - (b)
volumeis exempt from the finite-input requirement — NaN volume is allowed. Volume-scaled columns are unavailable for the affected window; Weis Wave resets on a missing-volume bar (decision 8); OBV/VWAP treat a missing-volume day as unavailable and exclude it from the running cumulative sum. - (c) Contract-allowed NULLs produced further down a derivation chain (CCI's MAD == 0, KD's flat-range RSV, ...) are terminal — a column's own final value, never fed back into another primitive as an input, so they never re-enter the fail-loud path. The ADX/QQE Wilder-RMA smoothing denominators do not structurally reach exactly zero over non-degenerate historical data in practice; a pathological input that does trigger a primitive-level fail-loud error is expected behaviour under this policy, not a bug to work around.
- (a) OHLC price inputs must be fully finite (no NaN, no
±inf). Validated at the engine entry point by
- WT2 =
rolling_mean(WT1, WT_SIGNAL_PERIOD)— a composition column:mature = WT1.mature + (WT_SIGNAL_PERIOD - 1), built on top of WT1's own multi-layer recursive-EMA derivation.
QQE trailing-band recurrence (full text)
Candidate bands: lower[i] = sRSI[i] - F * ATRrsi[i],
upper[i] = sRSI[i] + F * ATRrsi[i] (F = QQE_FACTOR = 4.236).
Init (i = t0 = 15): state = long if sRSI[t0] > 50 else
short. trail[t0] = lower[t0] if long, else upper[t0].
If state == long:
- sRSI[i] >= trail[i-1] (equality still counts as long) → stay
long, trail[i] = max(trail[i-1], lower[i]).
- sRSI[i] < trail[i-1] → flip to short, trail[i] = upper[i].
If state == short:
- sRSI[i] <= trail[i-1] (equality still counts as short) → stay
short, trail[i] = min(trail[i-1], upper[i]).
- sRSI[i] > trail[i-1] → flip to long, trail[i] = lower[i].
qqe_above_line = sRSI[i] >= trail[i].
The RSI fed into this recurrence is the same Wilder RSI(14)
primitive as the top-level rsi column (first_valid = mature = 14)
— not a separate recursive-EMA RSI variant.
78-column table
Generated from CONTRACT (sorted by key). first_valid/mature are
0-based indices; see indicator_contract.py for the per-entry
derivation comment and zero_denominator/composition_note detail.
| name | formula_source | first_valid | mature | scaling |
|---|---|---|---|---|
| adx | Wilder 1978 (ADX/DMI) | 27 | 27 | dimensionless |
| aroon_down | Chande | 25 | 25 | dimensionless |
| aroon_osc | Chande | 25 | 25 | dimensionless |
| aroon_up | Chande | 25 | 25 | dimensionless |
| atr | Wilder 1978 (ATR) | 13 | 13 | price |
| bb_lower | Bollinger; population std (ddof=0) | 19 | 19 | price |
| bb_middle | Bollinger; population std (ddof=0) | 19 | 19 | price |
| bb_upper | Bollinger; population std (ddof=0) | 19 | 19 | price |
| cci | Lambert 1980 (Commodity Channel Index) | 19 | 19 | dimensionless |
| chop | Dreiss (Choppiness Index) | 13 | 13 | dimensionless |
| d | Lane (Stochastic Oscillator) | 17 | 17 | dimensionless |
| donchian_high_20 | Donchian (Donchian Channel) | 19 | 19 | price |
| donchian_high_55 | Donchian (Donchian Channel) | 54 | 54 | price |
| donchian_low_20 | Donchian (Donchian Channel) | 19 | 19 | price |
| donchian_low_55 | Donchian (Donchian Channel) | 54 | 54 | price |
| elder_bear_power | Elder (Elder-Ray Index) | 0 | 12 | price |
| elder_bull_power | Elder (Elder-Ray Index) | 0 | 12 | price |
| ema_10 | Exponential Moving Average; adjust=False recursive form (standard) | 0 | 9 | price |
| ema_100 | Exponential Moving Average; adjust=False recursive form (standard) | 0 | 99 | price |
| ema_120 | Exponential Moving Average; adjust=False recursive form (standard) | 0 | 119 | price |
| ema_20 | Exponential Moving Average; adjust=False recursive form (standard) | 0 | 19 | price |
| ema_200 | Exponential Moving Average; adjust=False recursive form (standard) | 0 | 199 | price |
| ema_240 | Exponential Moving Average; adjust=False recursive form (standard) | 0 | 239 | price |
| ema_5 | Exponential Moving Average; adjust=False recursive form (standard) | 0 | 4 | price |
| ema_50 | Exponential Moving Average; adjust=False recursive form (standard) | 0 | 49 | price |
| ema_60 | Exponential Moving Average; adjust=False recursive form (standard) | 0 | 59 | price |
| ichimoku_kijun | Hosoda (Ichimoku Kinko Hyo) | 25 | 25 | price |
| ichimoku_tenkan | Hosoda (Ichimoku Kinko Hyo) | 8 | 8 | price |
| k | Lane (Stochastic Oscillator) | 15 | 15 | dimensionless |
| kc_lower | Carter TTM variant | 19 | 19 | price |
| kc_middle | Carter TTM variant | 0 | 19 | price |
| kc_upper | Carter TTM variant | 19 | 19 | price |
| ma_10 | Simple Moving Average (standard) | 9 | 9 | price |
| ma_100 | Simple Moving Average (standard) | 99 | 99 | price |
| ma_120 | Simple Moving Average (standard) | 119 | 119 | price |
| ma_20 | Simple Moving Average (standard) | 19 | 19 | price |
| ma_200 | Simple Moving Average (standard) | 199 | 199 | price |
| ma_240 | Simple Moving Average (standard) | 239 | 239 | price |
| ma_5 | Simple Moving Average (standard) | 4 | 4 | price |
| ma_50 | Simple Moving Average (standard) | 49 | 49 | price |
| ma_60 | Simple Moving Average (standard) | 59 | 59 | price |
| macd | Appel 1979 (12/26/9 EMA convention) | 0 | 25 | price |
| macd_hist | Appel 1979 (12/26/9 EMA convention) | 0 | 33 | price |
| macd_signal | Appel 1979 (12/26/9 EMA convention) | 0 | 33 | price |
| mfi | Quong & Soudack 1989 (Money Flow Index) | 14 | 14 | dimensionless |
| minus_di | Wilder 1978 (ADX/DMI) | 14 | 14 | dimensionless |
| obv | Granville 1963 (On-Balance Volume) | 0 | 0 | volume |
| plus_di | Wilder 1978 (ADX/DMI) | 14 | 14 | dimensionless |
| price_return_1d | log(adjusted_close[D] / previous valid adjusted close) | 1 | 1 | dimensionless |
| price_return_z_20d | 20 prior adjusted log returns, sample std ddof=1 | 21 | 21 | zscore |
| qqe_above_line | Wilder-style trailing band, Cortex pinned recurrence | 15 | 71 | flag |
| qqe_line | Wilder-style trailing band, Cortex pinned recurrence | 15 | 71 | dimensionless |
| qqe_rsi_smooth | Wilder-style trailing band, Cortex pinned recurrence | 14 | 18 | dimensionless |
| rsi | Wilder 1978 | 14 | 14 | dimensionless |
| supertrend | Seban (Supertrend) | 10 | 10 | price |
| supertrend_direction | Seban (Supertrend) | 10 | 10 | string |
| szo_value | Khalil & Steckler | 1 | 40 | dimensionless |
| tii_value | M.H. Pee, Trend Intensity Index | 59 | 59 | dimensionless |
| vi_minus | Vortex Indicator (Botes & Siepman, 2010) | 14 | 14 | dimensionless |
| vi_plus | Vortex Indicator (Botes & Siepman, 2010) | 14 | 14 | dimensionless |
| volume_ma_20 | Simple Moving Average of volume (standard) | 19 | 19 | volume |
| volume_ma_5 | Simple Moving Average of volume (standard) | 4 | 4 | volume |
| volume_log_z_20d | 20 prior log1p(raw volume) values, sample std ddof=1 | 21 | 21 | zscore |
| volume_price_anomaly | volume z >= 2 and adjusted-return z >= 2/-2 with +/-0.5% simple-return floor | 21 | 21 | categorical |
| vp_anomaly_baseline_count | prior jointly valid adjusted-return/raw-volume observations | 2 | 21 | count |
| vp_anomaly_baseline_span_days | calendar span from the oldest selected observation through D | 2 | 21 | days |
| vsa_no_supply_confirmed | Wyckoff VSA (No Supply); Cortex confirmation-day indexing variant | 20 | 20 | flag |
| vsa_no_supply_detected | Wyckoff VSA (No Supply); Cortex confirmation-day indexing variant | 19 | 19 | flag |
| vwap | Cortex variant (full-history anchored) | 0 | 0 | price |
| weis_wave_dir | Cortex variant (1.5% reversal, reset-on-missing-volume) | 1 | 1 | flag |
| weis_wave_price_delta | Cortex variant (1.5% reversal, reset-on-missing-volume) | 1 | 1 | price |
| weis_wave_vol | Cortex variant (1.5% reversal, reset-on-missing-volume) | 1 | 1 | volume |
| willr | Williams (%R) | 13 | 13 | dimensionless |
| wt1 | WaveTrend Oscillator (LazyBear community formula) | 1 | 38 | dimensionless |
| wt2 | WaveTrend Oscillator (LazyBear community formula) | 4 | 41 | dimensionless |
| wvixf_panic | Williams (VIX Fix) | 40 | 40 | flag |
| wvixf_upper | Williams (VIX Fix) | 40 | 40 | dimensionless |
| wvixf_value | Williams (VIX Fix) | 21 | 21 | dimensionless |
Resolved item: WaveTrend bar-0 structural zero-denominator (2026-08-16)
wt1/wt2 first_valid corrected from 0/3 to 1/4 (mature unchanged
at 38/41). Root cause: esa = recursive_ema(ap, WT_N1) seeds
esa[0] = ap[0] by definition, so ap[0] - esa[0] == 0 for any
input, and d_wt's own seed (|ap[0]-esa[0]|) is therefore also
exactly 0 at bar 0. ci = (ap - esa) / (WT_CI_SCALING_CONSTANT * d_wt)
hits the contract's own "0.015*d_wt == 0 -> NaN" rule at bar 0 for
every dataset, not just a flat-from-the-start market — empirically
verified 2026-08-16 against synthetic OHLC. wt1's recursive-EMA seed
is therefore ci's actual first valid value (bar 1), not bar 0; wt2 =
rolling_mean(wt1, WT_SIGNAL_PERIOD) shifts by the same one bar. The
bar-0 NaN does not delay downstream convergence, so mature is
unaffected.
Resolved item: SZO canonical scale (2026-08-16)
szo_value uses the canonical Khalil & Steckler form
SZO = 100 * TEMA(sign-series, SZO_LENGTH) / SZO_LENGTH
= 100 * (3*EMA1 - 3*EMA2 + EMA3) / 14
where the sign-series is +1 when the close rose strictly against the
previous bar and -1 otherwise (an unchanged close is a -1 step;
bar 0 is NaN), and each EMA layer is a recursive_ema(span=14)
chained on the previous one. params=(SZO_LENGTH,) with the new
module-level constant SZO_LENGTH = 14.
The /SZO_LENGTH normalisation is part of the published formula:
it puts the oscillator on a ±100/14 ≈ ±7.14 scale. ±7 is the contract
threshold the consumers switch to at the PR2 cutover, when it is to be
backtested per rule 19 — the production consumers today (the
replacement_event / signal layer) still run on V1's unnormalised ±50
scale and are unaffected by this shadow module. Commit 1 landed the
szo_value composition_note with the *100 but without the
/length; that was a transcription error, corrected here
(user-approved at plan time).
The output is not clipped. TEMA's triple-smoothing overshoot can
push |TEMA| > 1 on a sharp reversal, so SZO may exceed ±100/length;
V1 both dropped the normalisation and clamped the result to ±100.
On a monotonic series every step is ±1, all three EMA layers stay
exactly ±1, and SZO equals exactly ±100/length — the scale end is a
property of the formula, not a clamp.
Resolved item: TII period ambiguity (2026-08-15)
tii_value uses the canonical M.H. Pee formula: params=(60, 30)
(ma_period=60, dev_window=30). All 30 deviations reference the
same current SMA60[t] — deviation_j = close[t-j] - SMA60[t]
for j=0..29 — not a rolling sum of per-day deviations each against
its own day's SMA60 (that would have been V1's DEFAULT_CONFIG["TII"]
shape, ma_period=30/dev_period=60, which this contract does
not use). first_valid = mature = max(59, 29) = 59: SMA60 needs
0 + 59 = 59 (rule 1); the 30-day close lookback alone only needs
0 + 29 = 29. See indicator_contract.py's tii_value entry and
composition_note for the full derivation.
Provenance: this reading is a user ruling (2026-08-16) to adopt M.H. Pee's original formula — rule-19 source class 4 (explicit user approval). The per-day rolling-deviation reading, used by V1 and by some published implementations, is retired, not a supported alternative.