跳轉到

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

  1. Rolling layer (rolling_mean/sum/max/min, always min_periods=window): first_valid = input.first_valid + (window - 1); mature = input.mature + (window - 1).
  2. Recursive EMA layer (recursive_ema, adjust=False): first_valid = input.first_valid; mature = input.mature + (span - 1).
  3. Wilder RMA layer (wilder_rma): first_valid = mature = input.first_valid + (period - 1).
  4. 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)

  1. TR_INIT_POLICY = "cortex_wilder_variant_high_minus_low" — true_range[0] = high[0] - low[0] (Cortex/Wilder initialization variant). TA-Lib's TRANGE[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.
  2. Wilder RMA seed = SMA of the first period valid inputs; output is simultaneously first-valid and mature at input.first_valid + (period - 1).
  3. recursive_ema seed = first valid input value, no min_periods gating. This deliberately differs from V1's Elder-Ray implementation (which passed min_periods=ema_period to pandas.Series.ewm) — V2 applies rule 2 uniformly to every EMA usage, including inside Elder-Ray.
  4. BB_DDOF = 0 — Bollinger Band width uses population std (ddof=0), not pandas' sample-std default (ddof=1).
  5. Keltner Channel = recursive_ema(close, KC_PERIOD) ± KC_MULTIPLIER (1.5) × wilder_rma(true_range, KC_PERIOD) (Carter/TTM variant).
  6. AROON_WINDOW_EXTRA = 1 — Aroon's rolling window is length + 1 (26 for the configured 25-day length).
  7. QQE trailing-band recurrence — Wilder-style, Cortex-pinned (full branch-by-branch recurrence below); QQE_FACTOR = 4.236.
  8. 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.
  9. 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-day detected flag looked up against the next bar's close).
  10. 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).
  11. MFI zero-denominator: positive and negative money flow both zero → NaN; single-sided zero → 100/0 (same shape as RSI).
  12. VWAP = Cortex variant, full-history anchored cumulative sum; cumulative volume = 0 → NaN.
  13. Choppiness Index: flat range (rolling_max(high) == rolling_min(low)) → NaN.
  14. Stochastic (KD): RSV range rolling_max(high) == rolling_min(low) → NaN.
  15. TII: sum_pos + sum_neg == 0 → NaN. CCI: mean absolute deviation (MAD) == 0 → NaN.
  16. 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 raises ValueError listing 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_rma and recursive_ema carry the same fail-loud policy as their own defense-in-depth: a NaN after a series' first valid value (interior/mid-series NaN) raises ValueError naming the position(s); a NaN run before the first valid value (ordinary warmup) is unaffected. true_range propagates 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) volume is 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.
  17. 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.