feat(dynamics): add PlusDI, MinusDI, PlusDM, MinusDM indicators

Complete thin Dx-composition wrapper indicators with full test coverage:

- PlusDi/MinusDi: Directional Indicator wrappers (DiPlus/DiMinus from Dx)
- PlusDm/MinusDm: Directional Movement wrappers (DmPlus/DmMinus from Dx)
- Individual validation tests per indicator directory (TALib, Skender, bounds)
- Combined unit tests (DiDm.Tests.cs) and validation tests (DiDm.Validation.Tests.cs)
- Quantower wrappers + tests for all 4 indicators
- PineScript v6 implementations with compensated RMA
- Normalized .md documentation for all indicators and categories
- 182 tests passing, 0 failures
This commit is contained in:
Miha Kralj
2026-03-11 20:21:52 -07:00
parent 56b86bebfb
commit 33d20f2a18
437 changed files with 4589 additions and 2792 deletions
+8 -2
View File
@@ -99,7 +99,7 @@
padding-bottom: 0.3em;
}
.markdown-section h2,
.markdown-section h2,
.markdown-section h3,
.markdown-section h4,
.markdown-section h5,
@@ -113,6 +113,10 @@
padding-bottom: 0.3em;
}
.markdown-section h3 {
font-size: 1.3em;
}
.markdown-section p,
.markdown-section ul,
.markdown-section ol,
@@ -196,8 +200,10 @@
}
/* Pine Script syntax highlighting — GitHub dark palette */
.token.comment,
.token.block-comment,
.token.doctype,
.token.annotation { color: #8b949e; font-style: italic; }
.token.annotation { color: green; }
.token.type-definition { color: #79c0ff; }
.token.class-name { color: #d2a8ff; }
.token.constant { color: #ffa657; }
-2
View File
@@ -1,7 +1,5 @@
# Channels
> "In trending markets, ride the channel. In ranging markets, fade the edges." Unknown
Channels define dynamic support and resistance. Upper band shows where price tends to find resistance; lower band shows support. Width measures volatility; price position within channel measures momentum and mean-reversion potential.
## Indicators
+2 -17
View File
@@ -1,5 +1,7 @@
# ABERR: Aberration Bands
> *Aberration measures the distance between price and its smoothed self — when the gap grows extreme, reversion whispers.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -73,23 +75,6 @@ $$\text{MAD} = \sigma \sqrt{\frac{2}{\pi}} \approx 0.7979\,\sigma$$
Therefore ABERR with $k = 2.0$ captures approximately the same range as Bollinger Bands with $k \approx 1.596$.
### Pseudo-code
```
function ABERR(source, ma_line, period, multiplier):
// Deviation from center line
deviation = |source - ma_line|
// Average absolute deviation (SMA of deviations)
avg_dev = SMA(deviation, period)
// Band construction
upper = ma_line + multiplier * avg_dev
lower = ma_line - multiplier * avg_dev
return [upper, lower, avg_dev]
```
### Output Interpretation
| Output | Description |
+2 -20
View File
@@ -1,5 +1,7 @@
# ACCBANDS: Acceleration Bands
> *Acceleration bands widen with high-low range, framing the expected reach of each bar's ambition.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -63,26 +65,6 @@ Three independent circular buffers maintain running sums for $O(1)$ streaming up
| `period` | Lookback period for the three SMAs ($n$) | 20 | $> 0$ |
| `factor` | Multiplier for normalized width ($F$) | 4.0 | $> 0$ |
### Pseudo-code
```
function ACCBANDS(high, low, close, period, factor):
// Per-bar normalized width
denom = high + low
w = denom ≠ 0 ? (high - low) / denom : 0
// Adjusted prices
adj_high = high * (1 + factor * w)
adj_low = low * (1 - factor * w)
// Three independent SMAs
upper = SMA(adj_high, period)
lower = SMA(adj_low, period)
middle = SMA(close, period)
return [middle, upper, lower]
```
### Breakout Rule (Headley)
A trend is confirmed when:
+2 -24
View File
@@ -1,5 +1,7 @@
# APCHANNEL: Adaptive Price Channel
> *An adaptive channel reshapes its width in real time, tracking the market's own sense of normal.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -74,30 +76,6 @@ $$t_{1/2} = \frac{\ln 2}{\ln(1 / (1 - \alpha))}$$
For $\alpha = 0.2$: $t_{1/2} \approx 3.1$ bars. For $\alpha = 0.05$: $t_{1/2} \approx 13.5$ bars.
### Pseudo-code
```
function APCHANNEL(high, low, alpha):
validate: 0 < alpha ≤ 1
decay = 1 - alpha
// EMA of highs
if first_bar:
upper = high
else:
upper = decay * upper + alpha * high
// EMA of lows
if first_bar:
lower = low
else:
lower = decay * lower + alpha * low
middle = (upper + lower) / 2
return [middle, upper, lower]
```
### Output Interpretation
| Output | Description |
+2 -33
View File
@@ -1,5 +1,7 @@
# APZ: Adaptive Price Zone
> *The adaptive price zone contracts in calm and expands in chaos, mapping volatility into a living boundary.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -88,39 +90,6 @@ $$P_{\text{effective}} = \sqrt{P} \approx \frac{2}{\alpha} - 1$$
For $P = 20$: $P_{\text{eff}} \approx 4.47$. For $P = 100$: $P_{\text{eff}} \approx 10$.
### Pseudo-code
```
function APZ(source, high, low, period, multiplier):
validate: period > 0, multiplier > 0
alpha = 2 / (√period + 1)
beta = 1 - alpha
// Double-smoothed EMA of price
ema1_price = alpha * source + beta * ema1_price
center = alpha * ema1_price + beta * center
// Double-smoothed EMA of range
range = high - low
ema1_range = alpha * range + beta * ema1_range
smooth_range = alpha * ema1_range + beta * smooth_range
// Warmup compensator
e *= beta²
if e > 1e-10:
compensator = 1 / (1 - e)
center *= compensator
smooth_range *= compensator
// Bands
width = multiplier * smooth_range
upper = center + width
lower = center - width
return [center, upper, lower]
```
### Output Interpretation
| Output | Description |
+2 -27
View File
@@ -1,5 +1,7 @@
# ATRBANDS: Average True Range Bands
> *True range bands let volatility itself draw the envelope — wider when uncertain, tighter when resolved.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -72,33 +74,6 @@ The SMA uses a circular buffer for $O(1)$ running sums. The ATR uses recursive I
| Gap-up | $\|H_t - C_{t-1}\|$ | Upward gap distance |
| Gap-down | $\|L_t - C_{t-1}\|$ | Downward gap distance |
### Pseudo-code
```
function ATRBANDS(source, high, low, close, period, multiplier):
validate: period > 0, multiplier > 0
// True Range
tr = max(high - low, |high - prev_close|, |low - prev_close|)
prev_close = close
// ATR via Wilder's smoothing (RMA)
alpha = 1 / period
raw_rma = (raw_rma * (period - 1) + tr) / period
e *= (1 - alpha)
atr = e > ε ? raw_rma / (1 - e) : raw_rma
// Center line (SMA via circular buffer)
middle = SMA(source, period)
// Bands
width = atr * multiplier
upper = middle + width
lower = middle - width
return [middle, upper, lower]
```
### Output Interpretation
| Output | Description |
+2 -30
View File
@@ -1,5 +1,7 @@
# BBANDS: Bollinger Bands
> *Standard deviation channels adapt to the market's own volatility rhythm, expanding and contracting like breathing.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -78,36 +80,6 @@ The circular buffer maintains running sums of $x$ and $x^2$, enabling $O(1)$ com
| 2.0 | 95.4% | 75.0% |
| 3.0 | 99.7% | 88.9% |
### Pseudo-code
```
function BBANDS(source, period, multiplier):
validate: period > 0, multiplier > 0
// Circular buffer maintains running sums
sum += source; sumSq += source²
oldest = buffer[head]
if oldest exists: sum -= oldest; sumSq -= oldest²
// SMA (middle band)
middle = sum / count
// Population standard deviation
variance = max(0, sumSq/count - middle²)
sigma = √variance
dev = multiplier * sigma
// Bands
upper = middle + dev
lower = middle - dev
// Derived metrics
bandwidth = (upper - lower) / middle
percentB = (source - lower) / (upper - lower)
return [middle, upper, lower, bandwidth, percentB]
```
### Output Interpretation
| Output | Range | Meaning |
+2 -23
View File
@@ -1,5 +1,7 @@
# DCHANNEL: Donchian Channels
> *The highest high and lowest low over a window — Donchian's simplicity captures breakout potential in two lines.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -63,29 +65,6 @@ The bands stay flat until either a new extreme occurs or the old extreme exits t
|-----------|-------------|---------|------------|
| `period` | Lookback window for high/low extremes ($n$) | 20 | $> 0$ |
### Pseudo-code
```
function DCHANNEL(high, low, period):
validate: period > 0
// Monotonic deque for max (upper band)
while max_deque.front is outside window: pop front
while max_deque.back value ≤ high: pop back
push high to max_deque back
upper = max_deque.front value
// Monotonic deque for min (lower band)
while min_deque.front is outside window: pop front
while min_deque.back value ≥ low: pop back
push low to min_deque back
lower = min_deque.front value
middle = (upper + lower) / 2
return [middle, upper, lower]
```
### Output Interpretation
| Output | Description |
+2 -37
View File
@@ -1,5 +1,7 @@
# DECAYCHANNEL: Decay Min-Max Channel
> *Extremes that decay over time give recent boundaries more weight, fading yesterday's peaks gradually.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -79,43 +81,6 @@ $$\text{decayRate}(T) = 1 - e^{-\lambda T} = 1 - e^{-\ln 2} = 0.5$$
After $2T$ bars: 75% decay. After $3T$ bars: 87.5% decay.
### Pseudo-code
```
function DECAYCHANNEL(high, low, period):
validate: period > 0
lambda = ln(2) / period
// Scan buffer for Donchian bounds
periodMax = max(high_buffer over period)
periodMin = min(low_buffer over period)
periodAvg = avg(midpoints over period)
// Snap or age
if high ≥ currentMax:
currentMax = high; ageMax = 0
else:
ageMax += 1
if low ≤ currentMin:
currentMin = low; ageMin = 0
else:
ageMin += 1
// Decay toward midpoint
midpoint = (currentMax + currentMin) / 2
maxDecay = 1 - exp(-lambda * ageMax)
minDecay = 1 - exp(-lambda * ageMin)
currentMax -= maxDecay * (currentMax - midpoint)
currentMin -= minDecay * (currentMin - midpoint)
// Clamp to Donchian bounds
currentMax = min(currentMax, periodMax)
currentMin = max(currentMin, periodMin)
return [currentMax, currentMin]
```
### Output Interpretation
| Output | Description |
+2 -21
View File
@@ -1,5 +1,7 @@
# FCB: Fractal Chaos Bands
> *Fractal chaos bands connect swing pivots into a channel, letting the market's own geometry define containment.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -74,27 +76,6 @@ Fractal detection is $O(1)$ (3 comparisons). Deque maintenance is $O(1)$ amortiz
|-----------|-------------|---------|------------|
| `period` | Lookback window for highest/lowest fractal values | 20 | $> 0$ |
### Pseudo-code
```
function FCB(high, low, period):
validate: period > 0
// 3-bar fractal detection (confirmed at current bar)
is_fractal_high = high[1] > high[2] AND high[1] > high[0]
is_fractal_low = low[1] < low[2] AND low[1] < low[0]
// Update persistent fractal values
if is_fractal_high: hi_fractal = high[1]
if is_fractal_low: lo_fractal = low[1]
// Sliding window max/min via monotonic deques
upper = max(hi_fractal over period) // deque-based
lower = min(lo_fractal over period) // deque-based
return [upper, lower]
```
### Output Interpretation
| Output | Description |
+2 -31
View File
@@ -1,5 +1,7 @@
# JBANDS: Jurik Adaptive Envelope Bands
> *Jurik's adaptive envelope adjusts its width with price dynamics, hugging trends and releasing during consolidation.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -100,37 +102,6 @@ Dominated by the trimmed mean's partial sort: $O(n \log n)$ for the 128-element
| $\text{sqrtDiv}$ | $\text{\_SQRT\_PARAM} / (\text{\_SQRT\_PARAM} + 1)$ |
| $P_{\text{exp}}$ | $\max(\text{\_LOG\_PARAM} - 2,\; 0.5)$ |
### Pseudo-code
```
function JBANDS(source, period, phase):
precompute constants from period and phase
// 1. Local deviation
dLocal = max(|source - upper|, |source - lower|) + ε
// 2. Volatility: 10-bar SMA → 128-bar trimmed mean
highD = SMA(dLocal, 10)
dRef = TrimmedMean(highD_history, 128)
// 3. Dynamic exponent
ratio = |source - band| / dRef
d = clamp(ratio^P_exp, 1, LOG_PARAM)
// 4. Snap-and-decay bands
adapt = sqrtDiv^√d
if source > upper: upper = source
else: upper = source - (source - upper) * adapt
(symmetric for lower)
// 5. JMA center line (2-pole IIR)
alpha = lenDiv^d
... (c0, c8, a8 recursion) ...
jma = prev_jma + a8
return [jma, upper, lower]
```
### Output Interpretation
| Output | Description |
+2 -27
View File
@@ -1,5 +1,7 @@
# KCHANNEL: Keltner Channel
> *Keltner wraps an EMA in ATR-scaled bands — a volatility envelope that responds to both trend and range.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -81,33 +83,6 @@ $O(1)$ per bar: one EMA update, one True Range computation, one RMA update, and
| Gap sensitivity | Yes (via TR) | Yes (via TR) | No |
| Distribution assumption | None | None | Gaussian |
### Pseudo-code
```
function KCHANNEL(source, high, low, close, period, multiplier):
validate: period > 0, multiplier > 0
// EMA center line (with warmup compensation)
alpha = 2 / (period + 1)
raw_ema = alpha * source + (1-alpha) * raw_ema
weight = alpha + (1-alpha) * weight
ema = raw_ema / weight
// ATR (Wilder's RMA with warmup)
tr = max(high - low, |high - prev_close|, |low - prev_close|)
prev_close = close
raw_rma = (raw_rma * (period-1) + tr) / period
e *= (1 - 1/period)
atr = e > ε ? raw_rma / (1-e) : raw_rma
// Bands
width = multiplier * atr
upper = ema + width
lower = ema - width
return [ema, upper, lower]
```
### Output Interpretation
| Output | Description |
+2 -19
View File
@@ -1,5 +1,7 @@
# MAENV: Moving Average Envelope
> *A fixed percentage above and below a moving average — the simplest envelope assumes symmetry in price behavior.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -71,25 +73,6 @@ $O(1)$ for SMA and EMA modes. $O(n)$ for WMA mode due to the weighted sum.
| `ma_type` | Moving average type: 0=SMA, 1=EMA, 2=WMA | 1 (EMA) | $\{0, 1, 2\}$ |
| `source` | Input price series | close | |
### Pseudo-code
```
function MAENV(source, period, percentage, ma_type):
validate: period > 0, percentage > 0
// Compute center line based on MA type
if ma_type == 0: middle = SMA(source, period)
if ma_type == 1: middle = EMA(source, period) // with warmup
if ma_type == 2: middle = WMA(source, period)
// Fixed percentage offset
dist = middle * percentage / 100
upper = middle + dist
lower = middle - dist
return [middle, upper, lower]
```
### Output Interpretation
| Output | Description |
+2
View File
@@ -1,5 +1,7 @@
# MMCHANNEL: Min-Max Channel
> *The raw min-max channel captures absolute extremes — no smoothing, no forgiveness, just the bounds of recent history.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
+2 -35
View File
@@ -1,5 +1,7 @@
# PCHANNEL: Price Channel
> *Price channels frame the trading range by its own high-low extremes, defining the field of play.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -79,41 +81,6 @@ Streaming: $O(1)$ amortized per bar. Each element enters and exits each deque at
|--------|------|------------|-------------|
| $n$ | period | $> 0$ | Lookback window size |
### Pseudo-code
```
function pchannel(high[], low[], period):
max_deque = empty // decreasing monotonic deque of indices
min_deque = empty // increasing monotonic deque of indices
hbuf = circular_buffer(period)
lbuf = circular_buffer(period)
for each bar t:
hbuf[t mod period] = high[t]
lbuf[t mod period] = low[t]
// expire stale front entries
while max_deque not empty AND max_deque.front <= t - period:
max_deque.pop_front()
while min_deque not empty AND min_deque.front <= t - period:
min_deque.pop_front()
// remove dominated back entries
while max_deque not empty AND hbuf[max_deque.back mod period] <= high[t]:
max_deque.pop_back()
while min_deque not empty AND lbuf[min_deque.back mod period] >= low[t]:
min_deque.pop_back()
max_deque.push_back(t)
min_deque.push_back(t)
upper = hbuf[max_deque.front mod period]
lower = lbuf[min_deque.front mod period]
middle = (upper + lower) / 2
emit (upper, middle, lower)
```
### Output Interpretation
| Output | Interpretation |
+2 -39
View File
@@ -1,5 +1,7 @@
# REGCHANNEL: Linear Regression Channel
> *A regression line flanked by standard error bands — the channel where statistics meets price trajectory.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -101,45 +103,6 @@ $$
For $n \geq 2$, $D > 0$ always, ensuring numerical stability.
### Pseudo-code
```
function regchannel(source[], period, multiplier):
buf = ring_buffer(period)
sum_x = period * (period - 1) / 2
sum_x2 = period * (period - 1) * (2 * period - 1) / 6
denom = period * sum_x2 - sum_x * sum_x
for each bar t:
buf.add(source[t])
n = buf.count
// pass 1: accumulate sums for regression
sum_y = 0
sum_xy = 0
for i = 0 to n-1:
y = buf[i]
sum_y += y
sum_xy += i * y
slope = (n * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n
middle = slope * (n - 1) + intercept
// pass 2: residual standard deviation
ssr = 0
for i = 0 to n-1:
predicted = slope * i + intercept
residual = buf[i] - predicted
ssr += residual * residual
stddev = sqrt(ssr / n)
upper = middle + multiplier * stddev
lower = middle - multiplier * stddev
emit (upper, middle, lower)
```
### Output Interpretation
| Output | Interpretation |
+2 -38
View File
@@ -1,5 +1,7 @@
# SDCHANNEL: Standard Deviation Channel
> *Standard deviation channels center on a moving average and let dispersion define the expected range.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -97,44 +99,6 @@ $$
For $n \geq 2$, $D > 0$ always holds, so the slope denominator is never zero.
### Pseudo-code
```
function sdchannel(source[], period, multiplier):
buf = ring_buffer(period)
sum_x = period * (period - 1) / 2
sum_x2 = period * (period - 1) * (2 * period - 1) / 6
denom = period * sum_x2 - sum_x * sum_x
for each bar t:
buf.add(source[t])
n = buf.count
// pass 1: regression coefficients
sum_y = 0, sum_xy = 0
for i = 0 to n-1:
y = buf[i]
sum_y += y
sum_xy += i * y
slope = (n * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n
middle = slope * (n - 1) + intercept
// pass 2: residual standard deviation
ssr = 0
for i = 0 to n-1:
predicted = slope * i + intercept
residual = buf[i] - predicted
ssr += residual * residual
stddev = sqrt(ssr / n)
upper = middle + multiplier * stddev
lower = middle - multiplier * stddev
emit (upper, middle, lower)
```
### Slope Interpretation
| Slope | Market State |
+2 -45
View File
@@ -1,5 +1,7 @@
# STARCHANNEL: Stoller Average Range Channel
> *Stoller channels use ATR to build a corridor around the average — a volatility-aware boundary for range traders.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -90,51 +92,6 @@ Streaming: $O(1)$ per bar. The SMA uses a running sum with circular buffer (add
| $k$ | multiplier | 2.0 | $> 0$ | ATR multiplier for band width |
| $n_{\text{atr}}$ | atr_length | 0 | $\geq 0$ | Separate ATR period (0 = same as SMA period) |
### Pseudo-code
```
function starchannel(source[], high[], low[], close[], period, multiplier, atr_length):
effective_atr = atr_length > 0 ? atr_length : period
alpha = 1.0 / effective_atr
buf = circular_buffer(period)
sum = 0.0
count = 0
raw_rma = 0.0
e = 1.0 // warmup compensator
prevClose = close[0]
EPSILON = 1e-10
for each bar t:
// SMA via running sum
if buf.is_full:
sum -= buf.oldest
count -= 1
buf.add(source[t])
sum += source[t]
count += 1
middle = sum / count
// True Range
tr = max(high[t] - low[t],
abs(high[t] - prevClose),
abs(low[t] - prevClose))
prevClose = close[t]
// RMA with warmup compensator
raw_rma = (raw_rma * (effective_atr - 1) + tr) / effective_atr
e = (1 - alpha) * e
atr = e > EPSILON ? raw_rma / (1 - e) : raw_rma
// Bands
width = atr * multiplier
upper = middle + width
lower = middle - width
emit (middle, upper, lower)
```
### Output Interpretation
| Output | Interpretation |
+2 -56
View File
@@ -1,5 +1,7 @@
# STBANDS: Super Trend Bands
> *Super Trend bands fuse trend direction with volatility width, flipping their bias at each breakout.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -111,62 +113,6 @@ Streaming: $O(1)$ per bar. The TR running sum uses a ring buffer; the ratchet lo
| $n$ | period | 10 | $> 0$ | ATR lookback period |
| $k$ | multiplier | 3.0 | $> 0$ | ATR multiplier for band distance from HL2 |
### Pseudo-code
```
function stbands(high[], low[], close[], period, multiplier):
tr_buf = ring_buffer(period)
tr_sum = 0, count = 0
prev_close = close[0]
final_upper = NaN, final_lower = NaN
trend = +1
for each bar t:
h = high[t], l = low[t], c = close[t]
// True Range
tr = max(h - l, abs(h - prev_close), abs(l - prev_close))
// ATR via running sum ring buffer
if tr_buf.is_full:
tr_sum -= tr_buf.oldest
count -= 1
tr_buf.add(tr)
tr_sum += tr
count += 1
atr = tr_sum / count
// Basic bands centered on HL2
hl2 = (h + l) / 2
basic_upper = hl2 + multiplier * atr
basic_lower = hl2 - multiplier * atr
if t == 0:
final_upper = basic_upper
final_lower = basic_lower
trend = +1
else:
// Ratchet: upper only tightens or resets on breakout
if basic_upper < final_upper OR prev_close > final_upper:
final_upper = basic_upper
// otherwise hold
// Ratchet: lower only tightens or resets on breakdown
if basic_lower > final_lower OR prev_close < final_lower:
final_lower = basic_lower
// otherwise hold
// Trend flip
if c <= final_lower:
trend = +1
else if c >= final_upper:
trend = -1
// otherwise hold previous trend
prev_close = c
emit (final_upper, final_lower, trend)
```
### Band State Transitions
| Condition | Upper Band | Lower Band |
+2 -44
View File
@@ -1,5 +1,7 @@
# TTM_LRC: TTM Linear Regression Channel
> *Linear regression channels project the statistical trend and drape standard deviation curtains around it.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -90,50 +92,6 @@ Per bar: $O(n)$ due to two loops over the window. Memory: a ring buffer of $n$ d
| $n$ | period | 100 | $> 1$ | Lookback window for regression |
| $k$ | deviations | 2.0 | $> 0$ | Outer band stddev multiplier |
### Pseudo-code
```
function ttm_lrc(source[], period, deviations):
buf = ring_buffer(period)
sum_x = period * (period - 1) / 2
sum_x2 = period * (period - 1) * (2 * period - 1) / 6
denom = period * sum_x2 - sum_x * sum_x
for each bar t:
buf.add(source[t])
n = buf.count
// pass 1: regression
sum_y = 0, sum_xy = 0
for i = 0 to n-1:
y = buf[i]
sum_y += y
sum_xy += i * y
slope = (n * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n
midline = slope * (n - 1) + intercept
// pass 2: residuals
ssr = 0, sst = 0
mean_y = sum_y / n
for i = 0 to n-1:
predicted = slope * i + intercept
residual = buf[i] - predicted
ssr += residual * residual
sst += (buf[i] - mean_y)^2
stddev = sqrt(ssr / n)
r_squared = sst > 0 ? 1 - ssr / sst : 0
upper1 = midline + 1.0 * stddev
lower1 = midline - 1.0 * stddev
upper2 = midline + deviations * stddev
lower2 = midline - deviations * stddev
emit (midline, upper1, lower1, upper2, lower2, slope, r_squared)
```
### Statistical Zone Interpretation
| Zone | Probability | Interpretation |
+2 -41
View File
@@ -1,5 +1,7 @@
# UBANDS: Ehlers Ultimate Bands
> *Ehlers' ultimate bands apply cycle-aware smoothing to define an envelope that resonates with dominant frequency.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -107,47 +109,6 @@ $$
Cutoff frequency: approximately $f_c \approx 1/(2\pi n)$ cycles per bar. Rolloff: 12 dB/octave.
### Pseudo-code
```
function ubands(source[], period, multiplier):
// precompute USF coefficients
arg = sqrt(2) * pi / period
c2 = 2 * exp(-arg) * cos(arg)
c3 = -exp(-2 * arg)
c1 = (1 + c2 - c3) / 4
usf_prev1 = NaN, usf_prev2 = NaN
for each bar t:
s0 = source[t]
s1 = source[t-1] // or s0 if unavailable
s2 = source[t-2] // or s1 if unavailable
if usf not initialized:
usf = s0
else:
usf = (1 - c1)*s0 + (2*c1 - c2)*s1
- (c1 + c3)*s2 + c2*usf_prev1 + c3*usf_prev2
usf_prev2 = usf_prev1
usf_prev1 = usf
// RMS of residuals over window
sum_sq = 0, count = 0
for i = 0 to period-1:
r = source[t-i] - usf_at[t-i] // residual at bar t-i
if r is valid:
sum_sq += r * r
count += 1
rms = count > 0 ? sqrt(sum_sq / count) : 0
upper = usf + multiplier * rms
lower = usf - multiplier * rms
emit (upper, usf, lower)
```
### RMS vs Standard Deviation
Standard deviation measures dispersion around the mean: $\sigma = \sqrt{E[(X - \mu)^2]}$. RMS measures dispersion around zero: $\text{RMS} = \sqrt{E[X^2]}$. Since the residuals $r_t = P_t - \text{USF}_t$ are already deviations from the smooth centerline, RMS is the correct measure. When the mean of residuals is zero (as it approximately is for a well-fitted filter), RMS equals standard deviation.
+2 -49
View File
@@ -1,5 +1,7 @@
# UCHANNEL: Ehlers Ultimate Channel
> *The ultimate channel uses Ehlers' signal processing to carve boundaries that track the market's hidden periodicity.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -108,55 +110,6 @@ $$
Frequency response: cutoff at approximately $f_c \approx 1/(2\pi n)$ cycles per bar; 12 dB/octave rolloff.
### Pseudo-code
```
function uchannel(close[], high[], low[], strPeriod, centerPeriod, multiplier):
// compute USF coefficients for STR
arg_s = sqrt(2) * pi / strPeriod
c2_s = 2 * exp(-arg_s) * cos(arg_s)
c3_s = -exp(-2 * arg_s)
c1_s = (1 + c2_s - c3_s) / 4
// compute USF coefficients for centerline
arg_c = sqrt(2) * pi / centerPeriod
c2_c = 2 * exp(-arg_c) * cos(arg_c)
c3_c = -exp(-2 * arg_c)
c1_c = (1 + c2_c - c3_c) / 4
usf_str = [NaN, NaN] // two-element state
usf_cen = [NaN, NaN]
for each bar t:
// True Range
th = max(high[t], close[t-1])
tl = min(low[t], close[t-1])
tr = th - tl
// USF for True Range → STR
if usf_str not initialized:
str_val = tr
else:
str_val = (1-c1_s)*tr + (2*c1_s-c2_s)*tr[t-1]
- (c1_s+c3_s)*tr[t-2]
+ c2_s*usf_str[0] + c3_s*usf_str[1]
usf_str = [str_val, usf_str[0]]
// USF for close → centerline
if usf_cen not initialized:
center = close[t]
else:
center = (1-c1_c)*close[t] + (2*c1_c-c2_c)*close[t-1]
- (c1_c+c3_c)*close[t-2]
+ c2_c*usf_cen[0] + c3_c*usf_cen[1]
usf_cen = [center, usf_cen[0]]
upper = center + multiplier * str_val
lower = center - multiplier * str_val
emit (upper, center, lower)
```
### UCHANNEL vs UBANDS
| Aspect | UBANDS | UCHANNEL |
+2 -42
View File
@@ -1,5 +1,7 @@
# VWAPBANDS: VWAP with Dual Standard Deviation Bands
> *VWAP anchored by dual deviation bands reveals where volume-weighted fair value ends and excess begins.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -86,48 +88,6 @@ Streaming: $O(1)$ per bar. Three additions to running sums, one division, one sq
|--------|------|---------|------------|-------------|
| $k$ | multiplier | 1.0 | $> 0$ | Scales the standard deviation for band width |
### Pseudo-code
```
function vwapbands(source[], volume[], reset[], multiplier):
sum_pv = 0, sum_vol = 0, sum_pv2 = 0, count = 0
for each bar t:
price = source[t]
vol = volume[t]
if reset[t]:
// session boundary: restart accumulation
if vol > 0:
sum_pv = price * vol
sum_vol = vol
sum_pv2 = price * price * vol
count = 1
else:
sum_pv = 0, sum_vol = 0, sum_pv2 = 0, count = 0
else:
if vol > 0:
sum_pv += price * vol
sum_vol += vol
sum_pv2 += price * price * vol
count += 1
vwap = sum_vol > 0 ? sum_pv / sum_vol : price
variance = 0
if sum_vol > 0 and count > 1:
variance = max(0, sum_pv2 / sum_vol - vwap * vwap)
stddev = sqrt(variance)
upper1 = vwap + multiplier * stddev
lower1 = vwap - multiplier * stddev
upper2 = vwap + 2 * multiplier * stddev
lower2 = vwap - 2 * multiplier * stddev
emit (vwap, upper1, lower1, upper2, lower2, stddev)
```
### Statistical Zone Interpretation
| Zone | Coverage | Interpretation |
+2 -34
View File
@@ -1,5 +1,7 @@
# VWAPSD: VWAP with Standard Deviation Bands
> *Standard deviation bands around VWAP measure institutional consensus — proximity signals fair value, distance signals opportunity.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Channel |
@@ -84,40 +86,6 @@ Streaming: $O(1)$ per bar. Three additions to running sums, one division, one sq
|--------|------|---------|------------|-------------|
| $k$ | numDevs | 2.0 | $0.1$ $5.0$ | Number of standard deviations for bands |
### Pseudo-code
```
function vwapsd(source[], volume[], reset[], numDevs):
sum_pv = 0, sum_vol = 0, sum_pv2 = 0
for each bar t:
price = source[t]
vol = volume[t]
if reset[t]:
if vol > 0:
sum_pv = price * vol
sum_vol = vol
sum_pv2 = price * price * vol
else:
sum_pv = 0, sum_vol = 0, sum_pv2 = 0
else:
if vol > 0:
sum_pv += price * vol
sum_vol += vol
sum_pv2 += price * price * vol
vwap = sum_vol > 0 ? sum_pv / sum_vol : price
variance = sum_vol > 0 ? sum_pv2 / sum_vol - vwap * vwap : 0
stddev = sqrt(max(0, variance))
upper = vwap + numDevs * stddev
lower = vwap - numDevs * stddev
emit (vwap, upper, lower)
```
### VWAPSD vs VWAPBANDS
| Aspect | VWAPSD | VWAPBANDS |
-1
View File
@@ -34,4 +34,3 @@ All Core indicators share common traits:
| TBar | AVGPRICE, MEDPRICE, MIDPRICE, MIDBODY, TYPPRICE, WCLPRICE | OHLCV bars |
| TValue | MIDPOINT | Single value series |
+2 -16
View File
@@ -1,5 +1,7 @@
# AVGPRICE: Average Price
> *The four-point average distills an entire bar into a single representative price — open, high, low, and close in equal measure.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Core |
@@ -62,22 +64,6 @@ $O(1)$ per bar. Two additions, one FMA. No memory allocation. Always hot after t
| Typical Price | $(H+L+C) \times \frac{1}{3}$ | `HLC3` | `Typprice` |
| Weighted Close | $(H+L+2C) \times 0.25$ | `HLCC4` | `Wclprice` |
### Pseudo-code
```
function AVGPRICE(bar):
o, h, l, c ← bar.Open, bar.High, bar.Low, bar.Close
// Substitute last-valid for non-finite inputs
if !finite(o): o ← lastValidOpen
if !finite(h): h ← lastValidHigh
if !finite(l): l ← lastValidLow
if !finite(c): c ← lastValidClose
result ← FMA(o + h, 0.25, (l + c) × 0.25)
return result
```
### Output Interpretation
| Context | Meaning |
+2 -27
View File
@@ -1,5 +1,7 @@
# HA: Heikin-Ashi
> *The trend is your friend — but only if the noise doesn't make you abandon it at the first bump.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Core |
@@ -16,8 +18,6 @@
- Requires `1` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "The trend is your friend — but only if the noise doesn't make you abandon it at the first bump." — Every trader, eventually
HA transforms standard OHLC bars into smoothed Heikin-Ashi candles by averaging each component with its predecessor. The Close is the bar's four-price mean $(O+H+L+C)/4$, the Open is a recursive midpoint of the prior HA Open and HA Close, and High/Low are clamped extremes that guarantee the HA body always fits inside the HA wick. Unlike most indicators that reduce a bar to a single scalar, HA outputs a complete `TBar` — four smoothed prices per bar — making it a bar-to-bar transform rather than a bar-to-value reduction. The recursive Open gives HA an IIR character: each bar carries a decaying memory of the entire price history, which is what flattens trend noise but also why HA prices do not match any actual traded price.
## Historical Context
@@ -100,31 +100,6 @@ $$\text{WarmupPeriod} = 1$$
HA is "hot" from bar 1. The seed bar uses $(O_0 + C_0)/2$ for HA_Open and produces valid output immediately. The recursive filter converges rapidly due to the $\beta = 0.5$ decay — after 7 bars, the contribution of the seed value is less than 0.4%.
### Pseudo-code
```
function HA(bar, prevHaOpen, prevHaClose):
o, h, l, c ← bar.Open, bar.High, bar.Low, bar.Close
// Substitute last-valid for non-finite inputs
if !finite(o): o ← lastValidOpen
if !finite(h): h ← lastValidHigh
if !finite(l): l ← lastValidLow
if !finite(c): c ← lastValidClose
haClose ← FMA(o + h, 0.25, (l + c) × 0.25)
if firstBar:
haOpen ← (o + c) × 0.5
else:
haOpen ← (prevHaOpen + prevHaClose) × 0.5
haHigh ← max(h, haOpen, haClose)
haLow ← min(l, haOpen, haClose)
return TBar(bar.Time, haOpen, haHigh, haLow, haClose, bar.Volume)
```
### Output Interpretation
| Candle Pattern | Meaning |
+2 -14
View File
@@ -1,5 +1,7 @@
# MEDPRICE: Median Price
> *The midpoint of high and low captures the bar's central tendency, ignoring where it opened or closed.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Core |
@@ -62,20 +64,6 @@ $O(1)$ per bar. One addition, one multiply. No memory allocation. Always hot aft
| AVGPRICE | O, H, L, C | Equal | Fully balanced |
| WCLPRICE | H, L, C | C double-weighted | Close-biased |
### Pseudo-code
```
function MEDPRICE(bar):
h, l ← bar.High, bar.Low
// Substitute last-valid for non-finite inputs
if !finite(h): h ← lastValidHigh
if !finite(l): l ← lastValidLow
result ← (h + l) × 0.5
return result
```
### Output Interpretation
| Context | Meaning |
+2 -13
View File
@@ -1,5 +1,7 @@
# MIDBODY: Open-Close Average
> *The average of open and close reveals the candle body's center — where intent met execution.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Core |
@@ -70,13 +72,6 @@ None. OC2 is parameterless.
| AVGPRICE | $(O+H+L+C) \times 0.25$ | O, H, L, C | Fully balanced |
| WCLPRICE | $(H+L+2C) \times 0.25$ | H, L, C | Close double-weighted |
### Pseudo-code
```text
function Midbody(bar):
return (bar.Open + bar.Close) * 0.5
```
### Output Interpretation
- OC2 > Close: bar closed below its midpoint (bearish lean)
@@ -119,9 +114,3 @@ The batch loop is a trivial element-wise `(a[i] + b[i]) * 0.5`. Auto-vectorizati
- **Skender.Stock.Indicators** `CandlePart.OC2` enum documentation.
- **Murphy, J.J.** *Technical Analysis of the Financial Markets*. New York Institute of Finance, 1999.
+2 -2
View File
@@ -1,5 +1,7 @@
# MIDPOINT: Rolling Range Midpoint
> *The center holds, but only for the window you're watching.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Core |
@@ -16,8 +18,6 @@
- Requires `period` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "The center holds, but only for the window you're watching." — Statistical folk wisdom
Single-series rolling midpoint: `(Highest(V, N) + Lowest(V, N)) * 0.5`. Returns the center of the value range within a lookback window. TA-Lib compatible (`MIDPOINT` function). Unlike MIDPRICE which operates on separate High/Low bar channels, MIDPOINT operates on a single value series.
## Historical Context
+2 -19
View File
@@ -1,5 +1,7 @@
# MIDPRICE: Midpoint Price over Period
> *The midpoint of highest high and lowest low over a period anchors price to its range center.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Core |
@@ -71,25 +73,6 @@ $O(N)$ per bar where $N$ is the period, due to linear scan for max/min. For typi
| MEDPRICE | $(H + L) \times 0.5$ | TBar (single bar) | Stateless |
| Donchian Mid | Same as MIDPRICE | TBar (H/L channels) | Rolling window |
### Pseudo-code
```
function MIDPRICE(bar, period):
validate: period ≥ 1
h, l ← bar.High, bar.Low
// Substitute last-valid for non-finite inputs
if !finite(h): h ← lastValidHigh
if !finite(l): l ← lastValidLow
highBuffer.Add(h)
lowBuffer.Add(l)
result ← (highBuffer.Max() + lowBuffer.Min()) × 0.5
return result
```
### Output Interpretation
| Context | Meaning |
+2 -17
View File
@@ -1,5 +1,7 @@
# TYPPRICE: Typical Price
> *Typical price weights high, low, and close equally — a three-point summary that drops the open and keeps the essential.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Core |
@@ -59,23 +61,6 @@ $O(1)$ per bar. One addition, one FMA. No memory allocation. Always hot after th
Division by a non-power-of-two constant is 4-5x more expensive than multiplication on modern x86 CPUs (~15 cycles vs ~3 cycles). Precomputing $\frac{1}{3}$ as a `const double` and multiplying eliminates the division entirely. The compiler constant-folds `1.0 / 3.0` to the IEEE 754 double `0x3FD5555555555555` at compile time, so the hot path sees only multiply/FMA operations.
### Pseudo-code
```text
function TYPPRICE(bar):
const OneThird ← 1.0 / 3.0 // compile-time constant
o, h, l ← bar.Open, bar.High, bar.Low
// Substitute last-valid for non-finite inputs
if !finite(o): o ← lastValidOpen
if !finite(h): h ← lastValidHigh
if !finite(l): l ← lastValidLow
result ← FMA(o, OneThird, (h + l) × OneThird)
return result
```
### Output Interpretation
| Context | Meaning |
+2 -15
View File
@@ -1,5 +1,7 @@
# WCLPRICE: Weighted Close Price
> *Weighted close doubles the closing price's vote, acknowledging that where a bar ends matters most.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Core |
@@ -66,21 +68,6 @@ $O(1)$ per bar. One addition, one FMA. No memory allocation. Always hot after th
| TYPPRICE | 0% | 33.3% | 33.3% | 33.3% |
| **WCLPRICE** | **0%** | **25%** | **25%** | **50%** |
### Pseudo-code
```
function WCLPRICE(bar):
h, l, c ← bar.High, bar.Low, bar.Close
// Substitute last-valid for non-finite inputs
if !finite(h): h ← lastValidHigh
if !finite(l): l ← lastValidLow
if !finite(c): c ← lastValidClose
result ← FMA(c, 0.5, (h + l) × 0.25)
return result
```
### Output Interpretation
| Context | Meaning |
-2
View File
@@ -1,7 +1,5 @@
# Cycles
> "The market is a discounting mechanism that anticipates cycles before they complete." Unknown
Cycle analysis identifies repeating patterns in price data. John Ehlers pioneered digital signal processing techniques for financial cycles, using Hilbert transforms and autocorrelation to detect dominant periods. Cycles exist but are non-stationary: period and amplitude shift over time.
## Indicators
+2 -42
View File
@@ -1,5 +1,7 @@
# CCOR: Ehlers Correlation Cycle
> *Correlation cycles reveal hidden periodicities by measuring how well price correlates with a rotating reference wave.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -94,48 +96,6 @@ Where:
- Real: $y_k = \cos(2\pi k / N)$
- Imaginary: $y_k = -\sin(2\pi k / N)$
### Pseudo-code
```
function CCOR(source, period, threshold):
// Real correlation
Sx_r = Sy_r = Sxx_r = Sxy_r = Syy_r = 0
for k = 0 to period-1:
x = source[k]
y = cos(2π * k / period)
Sx_r += x; Sy_r += y
Sxx_r += x*x; Sxy_r += x*y; Syy_r += y*y
denom_r = (N*Sxx_r - Sx_r²) * (N*Syy_r - Sy_r²)
real = denom_r > 0 ? (N*Sxy_r - Sx_r*Sy_r) / √denom_r : 0
// Imaginary correlation (same accumulators for x, different y)
Sx_i = Sy_i = Sxx_i = Sxy_i = Syy_i = 0
for k = 0 to period-1:
x = source[k]
y = -sin(2π * k / period)
Sx_i += x; Sy_i += y
Sxx_i += x*x; Sxy_i += x*y; Syy_i += y*y
denom_i = (N*Sxx_i - Sx_i²) * (N*Syy_i - Sy_i²)
imag = denom_i > 0 ? (N*Sxy_i - Sx_i*Sy_i) / √denom_i : 0
// Phasor angle
angle = 0
if imag ≠ 0: angle = 90 + atan(real/imag) * (180/π)
if imag > 0: angle -= 180
// Monotonic constraint
angle = max(angle, prev_angle)
prev_angle = angle
// State detection
Δθ = |angle - saved_prev_angle|
state = 0
if Δθ < threshold and angle ≥ 0: state = +1
if Δθ < threshold and angle ≤ 0: state = -1
return [real, imag, angle, state]
```
### Output Interpretation
| Output | Range | Meaning |
+2 -27
View File
@@ -1,5 +1,7 @@
# CCYC: Ehlers Cyber Cycle
> *The Cyber Cycle isolator extracts the dominant cycle component while suppressing trend — pure periodicity distilled.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -109,33 +111,6 @@ $$z^2 - 2(1-\alpha)z + (1-\alpha)^2 = 0$$
has a double pole at $z = 1 - \alpha$. For $\alpha = 0.07$, the pole is at $z = 0.93$, well inside the unit circle (stable), with a $-3$ dB cutoff period of approximately $\frac{2\pi}{\alpha} \approx 90$ bars.
### Pseudo-code
```
function CCYC(source, alpha):
validate: 0 < alpha < 1
// FIR smoother
smooth = (source[0] + 2*source[1] + 2*source[2] + source[3]) / 6
// IIR coefficients
c_hp = (1 - 0.5*alpha)²
c_fb1 = 2*(1 - alpha)
c_fb2 = -(1 - alpha)²
if bar_count < 7:
// Bootstrap: second-difference of raw price
cycle = (source[0] - 2*source[1] + source[2]) / 4
else:
// Steady-state: 2-pole high-pass on smoothed input
cycle = c_hp * (smooth - 2*smooth[1] + smooth[2])
+ c_fb1 * cycle[1] + c_fb2 * cycle[2]
trigger = cycle[1]
return [cycle, trigger]
```
### Output Interpretation
| Output | Description |
+2 -25
View File
@@ -1,5 +1,7 @@
# CG: Ehlers Center of Gravity
> *Center of Gravity locates the balance point of price over a window, anticipating turns before they arrive.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -54,31 +56,6 @@ Streaming uses running sums for both numerator and denominator: $O(1)$ per bar w
|-----------|-------------|---------|------------|
| `period` | Lookback window length | 10 | $> 0$ |
### Pseudo-code
```
function CG(source, period):
buffer ← RingBuffer(period)
runNum ← 0 // weighted sum
runDen ← 0 // simple sum
for each price in source:
buffer.Add(price)
if buffer.Count < period: continue
// Compute from buffer (or maintain running sums)
num = 0
den = 0
for i = 0 to period-1:
w = i + 1
num += w * buffer[i]
den += buffer[i]
cg = (den ≠ 0) ? (num / den) - (period + 1) / 2.0 : 0
emit cg
```
### Output Interpretation
| Condition | Meaning |
+2 -28
View File
@@ -1,5 +1,7 @@
# DSP: Ehlers Detrended Synthetic Price
> *Detrended synthetic price removes the trend to expose the oscillation underneath — the signal beneath the drift.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -66,34 +68,6 @@ $O(1)$ per bar with $O(1)$ memory. Two EMA state variables plus two bias correct
|-----------|-------------|---------|------------|
| `period` | Dominant cycle period | 40 | $\geq 4$ |
### Pseudo-code
```
function DSP(source, period):
pFast ← max(2, round(period / 4))
pSlow ← max(3, round(period / 2))
αFast ← 2 / (pFast + 1)
αSlow ← 2 / (pSlow + 1)
emaFastRaw ← 0
emaSlowRaw ← 0
decayFast ← 1.0 // (1 - αFast)^n
decaySlow ← 1.0 // (1 - αSlow)^n
for each price in source:
emaFastRaw ← FMA(αFast, price, (1 - αFast) * emaFastRaw)
emaSlowRaw ← FMA(αSlow, price, (1 - αSlow) * emaSlowRaw)
decayFast *= (1 - αFast)
decaySlow *= (1 - αSlow)
emaFast ← emaFastRaw / (1 - decayFast)
emaSlow ← emaSlowRaw / (1 - decaySlow)
dsp ← emaFast - emaSlow
emit dsp
```
### Output Interpretation
| Condition | Meaning |
+2 -45
View File
@@ -1,5 +1,7 @@
# EACP: Ehlers Autocorrelation Periodogram
> *Autocorrelation periodogram scans every possible cycle length and ranks them by strength — a spectral fingerprint of the market.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -72,51 +74,6 @@ $O(N \times M)$ per bar where $N$ is the period range and $M$ is the averaging l
| `maxPeriod` | Maximum period to evaluate | 48 | $> minPeriod$ |
| `enhance` | Apply cubic emphasis to spectral peaks | true | |
### Pseudo-code
```
function EACP(source, minPeriod, maxPeriod, enhance):
N ← maxPeriod - minPeriod + 1
M ← maxPeriod // averaging window
hpBuf ← HighPassFilter(source)
ssfBuf ← SuperSmoother(hpBuf)
power[N] ← {0}
smoothPower[N] ← {0}
for each bar:
// Autocorrelation for each lag
corr[0..maxPeriod] ← PearsonAutocorrelation(ssfBuf, M)
// DFT: convert autocorrelation to power spectrum
for p = minPeriod to maxPeriod:
cosPower ← 0
for k = 0 to M-1:
cosPower += corr[k] * cos(2π * k / p)
power[p] ← cosPower²
// Exponential smoothing of spectrum
for p = minPeriod to maxPeriod:
smoothPower[p] ← 0.2 * power[p] + 0.8 * smoothPower[p]
// Optional cubic enhancement
if enhance:
for p: smoothPower[p] ← smoothPower[p]³
// AGC normalization
maxPow ← max(smoothPower)
for p: smoothPower[p] /= maxPow // normalize to [0, 1]
// Center-of-gravity dominant cycle
num ← 0; den ← 0
for p = minPeriod to maxPeriod:
num += smoothPower[p] * p
den += smoothPower[p]
dominantCycle ← (den > 0) ? num / den : (minPeriod + maxPeriod) / 2
emit dominantCycle
```
### Output Interpretation
| Output | Meaning |
+2 -39
View File
@@ -1,5 +1,7 @@
# EBSW: Ehlers Even Better Sinewave
> *Even Better Sinewave refines cycle detection by combining bandpass filtering with adaptive gain normalization.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -78,45 +80,6 @@ b = 2·a·cos(√2·π / ssfLength)
c₁ = (1 - b + a²) / 2
```
### Pseudo-code
```
function EBSW(source, hpLength, ssfLength):
// Precompute HP coefficient
α₁ ← (1 - sin(2π/hpLength)) / cos(2π/hpLength)
// Precompute SSF coefficients
a ← exp(-√2·π / ssfLength)
b ← 2·a·cos(√2·π / ssfLength)
c₁ ← (1 - b + a²) / 2
hp_prev ← 0; p_prev ← 0
filt_1 ← 0; filt_2 ← 0
for each price in source:
// High-pass filter
hp ← 0.5·(1 + α₁)·(price - p_prev) + α₁·hp_prev
// Super-smoother
filt ← c₁·(hp + hp_prev) + b·filt_1 - a²·filt_2
// Wave (3-bar average of filtered signal)
wave ← (filt + filt_1 + filt_2) / 3
// Power (3-bar RMS²)
power ← (filt² + filt_1² + filt_2²) / 3
// AGC normalization
ebsw ← (power > 0) ? wave / √power : 0
ebsw ← clamp(ebsw, -1, +1)
// Shift state
hp_prev ← hp; p_prev ← price
filt_2 ← filt_1; filt_1 ← filt
emit ebsw
```
### Output Interpretation
| Condition | Meaning |
+2 -54
View File
@@ -1,5 +1,7 @@
# HOMOD: Ehlers Homodyne Discriminator
> *The homodyne discriminator locks onto a cycle's frequency by comparing successive analytic signal rotations.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -71,60 +73,6 @@ $O(1)$ per bar with $O(1)$ memory. The pipeline consists entirely of fixed-depth
| `minPeriod` | Minimum detectable period | 6.0 | $> 0$ |
| `maxPeriod` | Maximum detectable period | 50.0 | $> minPeriod$ |
### Pseudo-code
```
function HOMOD(source, minPeriod, maxPeriod):
A ← 0.0962; B ← 0.5769
smoothBuf ← CircularBuffer(7)
detBuf ← CircularBuffer(7)
I2_prev ← 0; Q2_prev ← 0
Re_prev ← 0; Im_prev ← 0
period_prev ← (minPeriod + maxPeriod) / 2
for each price in source:
// 4-bar WMA
smooth ← (4·price + 3·p[1] + 2·p[2] + p[3]) / 10
smoothBuf.Add(smooth)
// Detrender (Hilbert FIR)
det ← A·smooth[0] + B·smooth[2] - B·smooth[4] - A·smooth[6]
detBuf.Add(det)
// I1 = det[3], Q1 = Hilbert(det)
I1 ← det[3]
Q1 ← A·det[0] + B·det[2] - B·det[4] - A·det[6]
// Hilbert of I1 and Q1
jI ← HilbertFIR(I1_history)
jQ ← HilbertFIR(Q1_history)
// Phasor components (smoothed)
I2 ← 0.2·(I1 - jQ) + 0.8·I2_prev
Q2 ← 0.2·(Q1 + jI) + 0.8·Q2_prev
// Homodyne mixing
re ← 0.2·(I2·I2_prev + Q2·Q2_prev) + 0.8·Re_prev
im ← 0.2·(I2·Q2_prev - Q2·I2_prev) + 0.8·Im_prev
// Period extraction
if im ≠ 0 and re ≠ 0:
period ← 2π / atan2(im, re)
else:
period ← period_prev
period ← clamp(period, minPeriod, maxPeriod)
period ← 0.33·period + 0.67·period_prev
// Update state
I2_prev ← I2; Q2_prev ← Q2
Re_prev ← re; Im_prev ← im
period_prev ← period
emit period
```
### Output Interpretation
| Output | Meaning |
+2 -41
View File
@@ -1,5 +1,7 @@
# HT_DCPERIOD: Ehlers Hilbert Transform Dominant Cycle Period
> *The Hilbert Transform extracts the dominant cycle period by converting price into an analytic signal and measuring its phase rate.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -68,47 +70,6 @@ $O(1)$ per bar. Fixed Hilbert cascade with circular buffers totaling approximate
The period range [6, 50] and all smoothing constants are fixed by the TA-Lib specification.
### Pseudo-code
```
function HT_DCPERIOD(source):
A ← 0.0962; B ← 0.5769
smoothBuf ← CircularBuffer(7)
detBuf, q1Buf, i1Buf ← CircularBuffers
I2 ← 0; Q2 ← 0
Re ← 0; Im ← 0
period ← 15 // initial estimate
for each price in source:
// Step 1: WMA smooth
smooth ← (4·price + 3·p[1] + 2·p[2] + p[3]) / 10
// Step 2: Hilbert FIR (adaptive to period)
adj ← A + B // coefficient adjustment
det ← adj·(smooth[0] - smooth[6]) + B·(smooth[2] - smooth[4])
Q1 ← adj·(det[0] - det[6]) + B·(det[2] - det[4])
I1 ← det[3]
jI ← adj·(I1[0] - I1[6]) + B·(I1[2] - I1[4])
jQ ← adj·(Q1[0] - Q1[6]) + B·(Q1[2] - Q1[4])
// Step 3: Phasor (EMA smoothed)
I2 ← 0.2·(I1 - jQ) + 0.8·I2
Q2 ← 0.2·(Q1 + jI) + 0.8·Q2
// Step 4: Homodyne discriminator
Re ← 0.2·(I2·I2_prev + Q2·Q2_prev) + 0.8·Re
Im ← 0.2·(I2·Q2_prev - Q2·I2_prev) + 0.8·Im
// Step 5: Period
if Im ≠ 0 and Re ≠ 0:
p ← 2π / atan(Im / Re)
p ← clamp(p, 6, 50)
period ← 0.33·p + 0.67·period
emit period
```
### Output Interpretation
| Output | Meaning |
+2 -32
View File
@@ -1,5 +1,7 @@
# HT_DCPHASE: Ehlers Hilbert Transform Dominant Cycle Phase
> *Dominant cycle phase tracks where price sits within its current cycle — the angular position of the market's heartbeat.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -64,38 +66,6 @@ $O(P)$ per bar where $P$ is the smoothed period (typically 6-50), due to the DFT
All internal constants are fixed by the TA-Lib specification.
### Pseudo-code
```
function HT_DCPHASE(source):
// Same Hilbert cascade as HT_DCPERIOD
// ... (WMA smooth, Hilbert FIR, phasor, homodyne)
// Produces: smoothPeriod, smoothPriceBuf
for each bar (after warmup):
P ← round(smoothPeriod)
// DFT accumulation over dominant period
realPart ← 0; imagPart ← 0
for i = 0 to P-1:
realPart += sin(2π·i / P) · smoothPriceBuf[t - i]
imagPart += cos(2π·i / P) · smoothPriceBuf[t - i]
// Phase extraction
if |imagPart| > 0:
dcPhase ← atan(realPart / imagPart) · (180/π)
else:
dcPhase ← 90 · sign(realPart)
if imagPart > 0: dcPhase -= 180
dcPhase += 90
// Wrap to [-45, 315]
if dcPhase < -45: dcPhase += 360
emit dcPhase
```
### Phase Quadrant Interpretation
| Phase Range | Cycle Position |
+2 -31
View File
@@ -1,5 +1,7 @@
# HT_PHASOR: Ehlers Hilbert Transform Phasor Components
> *Phasor components decompose price into in-phase and quadrature parts, mapping the cycle as a rotating vector.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -58,37 +60,6 @@ $O(1)$ per bar. Fixed Hilbert cascade with circular buffers. Warmup: 32 bars (TA
|-----------|-------------|---------|------------|
| (none) | No user-configurable parameters | | |
### Pseudo-code
```
function HT_PHASOR(source):
A ← 0.0962; B ← 0.5769
smoothBuf ← CircularBuffer(7)
detBuf, q1Buf, i1Buf ← CircularBuffers
I2 ← 0; Q2 ← 0
for each price in source:
// WMA smooth
smooth ← (4·price + 3·p[1] + 2·p[2] + p[3]) / 10
smoothBuf.Add(smooth)
// Hilbert FIR (adaptive)
det ← A·smooth[0] + B·smooth[2] - B·smooth[4] - A·smooth[6]
Q1 ← A·det[0] + B·det[2] - B·det[4] - A·det[6]
I1 ← det[3]
// Hilbert of I1 and Q1
jI ← A·I1[0] + B·I1[2] - B·I1[4] - A·I1[6]
jQ ← A·Q1[0] + B·Q1[2] - B·Q1[4] - A·Q1[6]
// Phasor components (EMA smoothed)
I2 ← 0.2·(I1 - jQ) + 0.8·I2
Q2 ← 0.2·(Q1 + jI) + 0.8·Q2
emit InPhase = I2, Quadrature = Q2
```
### Phasor Crossover Signals
| Condition | Signal |
+2 -21
View File
@@ -1,5 +1,7 @@
# HT_SINE: Ehlers Hilbert Transform SineWave (also known as SINE)
> *The Hilbert sine wave renders cycle timing visible — crossovers of sine and lead-sine mark turning points.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -70,27 +72,6 @@ $O(P)$ per bar where $P$ is the smoothed period (typically 6-50), due to the DFT
All constants are fixed by the TA-Lib specification.
### Pseudo-code
```
function HT_SINE(source):
// Full Hilbert cascade (same as HT_DCPHASE)
// Produces: smoothPeriod, smoothPriceBuf, dcPhase
for each bar (after warmup):
// Phase from DFT accumulation (see HT_DCPHASE)
φ ← computeDCPhase(smoothPeriod, smoothPriceBuf)
// Convert phase to radians
φ_rad ← φ · (π / 180)
// Dual sine output
sine ← sin(φ_rad)
leadSine ← sin(φ_rad + π/4) // 45° lead
emit sine, leadSine
```
### Crossover Signals
| Pattern | Signal |
+2 -35
View File
@@ -1,5 +1,7 @@
# LUNAR: Lunar Phase Indicator
> *The lunar cycle maps the Moon's phase onto price — an ancient rhythm tested against modern markets.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -82,41 +84,6 @@ $O(1)$ per timestamp. No state required (deterministic from time). Zero warmup.
The calculation is entirely determined by the input timestamp.
### Pseudo-code
```
function LUNAR(timestamp):
// Julian date
JD ← timestamp_to_unix_ms / 86400000 + 2440587.5
T ← (JD - 2451545.0) / 36525.0
// Mean orbital elements (Horner evaluation)
Lp ← FMA(T, FMA(T, FMA(T, 1/538841, -0.0015786), 481267.88123421), 218.3164477)
D ← FMA(T, FMA(T, FMA(T, 1/545868, -0.0018819), 445267.1114034), 297.8501921)
M ← FMA(T, FMA(T, -0.0001536, 35999.0502909), 357.5291092)
Mp ← FMA(T, FMA(T, 0.0087414, 477198.8675055), 134.9633964)
F ← FMA(T, FMA(T, -0.0036539, 483202.0175233), 93.2720950)
// Normalize to [0°, 360°)
Lp, D, M, Mp, F ← mod(*, 360)
// Perturbation correction (6 major terms)
Σ ← 6288016·sin(Mp) + 1274242·sin(2D - Mp) + 658314·sin(2D)
+ 214818·sin(2Mp) + 186986·sin(M) + 109154·sin(2F)
λ_moon ← Lp + Σ / 1e6
// Solar longitude (simplified)
L0 ← 280.46646 + 36000.76983·T
M_sun ← 357.52911 + 35999.05029·T
λ_sun ← L0 + 1.9146·sin(M_sun) + 0.02·sin(2·M_sun)
// Phase angle and illumination
ψ ← λ_moon - λ_sun
k ← (1 - cos(ψ)) / 2
emit k // 0.0 = New Moon, 1.0 = Full Moon
```
### Output Interpretation
| Value | Phase |
+2 -30
View File
@@ -1,5 +1,7 @@
# SOLAR: Solar Cycle Indicator
> *Solar cycles encode the Sun's rhythmic activity into a tradeable signal, bridging astrophysics and price action.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -74,36 +76,6 @@ $O(1)$ per timestamp. No state required. Zero warmup. The tropical year is appro
The calculation is entirely determined by the input timestamp.
### Pseudo-code
```
function SOLAR(timestamp):
// Julian date
JD ← timestamp_to_unix_ms / 86400000 + 2440587.5
T ← (JD - 2451545.0) / 36525.0
// Geometric mean longitude
L0 ← FMA(T, FMA(T, 0.0003032, 36000.76983), 280.46646)
L0 ← mod(L0, 360)
// Mean anomaly
M ← FMA(T, FMA(T, -0.0001537, 35999.05029), 357.52911)
M ← mod(M, 360)
// Equation of center
C ← FMA(T, FMA(T, -0.000014, -0.004817), 1.914602) · sin(M)
+ FMA(T, -0.000101, 0.019993) · sin(2M)
+ 0.000289 · sin(3M)
// True ecliptic longitude
λ ← L0 + C
// Seasonal index
solar ← sin(λ · π / 180)
emit solar
```
### Seasonal Correspondence (Northern Hemisphere)
| Date (approx.) | $\lambda_{Sun}$ | Solar Value | Season |
+2 -44
View File
@@ -1,5 +1,7 @@
# SSFDSP: Ehlers SSF Detrended Synthetic Price
> *SSF-based detrended synthetic price applies a super smoother before extracting cycles, achieving cleaner periodicity isolation.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Cycle |
@@ -70,50 +72,6 @@ $O(1)$ per bar. Two independent 2-pole IIR filters with $O(1)$ memory. Warmup: a
The SSF has $-3$ dB attenuation at the cutoff period, $-12$ dB/octave rolloff (2-pole), and zero phase lag at the cutoff. This is equivalent to a critically-damped Butterworth filter.
### Pseudo-code
```
function SSFDSP(source, period):
pFast ← max(2, round(period / 4))
pSlow ← max(3, round(period / 2))
// Fast SSF coefficients
αf ← √2·π / pFast
c2f ← 2·exp(-αf)·cos(αf)
c3f ← -exp(-2·αf)
c1f ← 1 - c2f - c3f
// Slow SSF coefficients
αs ← √2·π / pSlow
c2s ← 2·exp(-αs)·cos(αs)
c3s ← -exp(-2·αs)
c1s ← 1 - c2s - c3s
ssfFast_1 ← 0; ssfFast_2 ← 0
ssfSlow_1 ← 0; ssfSlow_2 ← 0
p_prev ← 0
for each price in source:
// Input averaging
avg ← (price + p_prev) / 2
// Fast SSF update
ssfFast ← c1f·avg + c2f·ssfFast_1 + c3f·ssfFast_2
// Slow SSF update
ssfSlow ← c1s·avg + c2s·ssfSlow_1 + c3s·ssfSlow_2
// SSFDSP
ssfdsp ← ssfFast - ssfSlow
// Shift state
ssfFast_2 ← ssfFast_1; ssfFast_1 ← ssfFast
ssfSlow_2 ← ssfSlow_1; ssfSlow_1 ← ssfSlow
p_prev ← price
emit ssfdsp
```
### DSP vs SSFDSP
| Aspect | DSP (EMA-based) | SSFDSP (Super-Smoother) |
-2
View File
@@ -1,7 +1,5 @@
# Dynamics
> "The trend is your friend, but only if you know its strength." Unknown
Dynamics indicators measure trend strength, speed, and direction. Unlike momentum indicators that measure rate of change, dynamics indicators answer: "Is there a trend, and how strong is it?" Critical for filtering signals and avoiding whipsaws in ranging markets.
## Indicators
+2 -43
View File
@@ -1,5 +1,7 @@
# ADX: Average Directional Index
> *ADX measures trend strength without regard to direction — a compass that tells you how hard the wind blows, not where.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -78,49 +80,6 @@ $$ADX = \text{RMA}(DX, N)$$
|--------|-----------|---------|------------|
| $N$ | period | 14 | $N \geq 2$ |
### Pseudo-code
```
Initialize:
α = 1 / period
smoothPlusDM = smoothMinusDM = smoothTR = 0
adx = 0
prevHigh = prevLow = NaN
bar_count = 0
On each bar (high, low, close, isNew):
if !isNew: restore previous state
TR = max(high - low, |high - prevClose|, |low - prevClose|)
upMove = high - prevHigh
downMove = prevLow - low
+DM = (upMove > downMove AND upMove > 0) ? upMove : 0
-DM = (downMove > upMove AND downMove > 0) ? downMove : 0
// Wilder smoothing (RMA)
smoothPlusDM = FMA(smoothPlusDM, 1 - α, α × +DM)
smoothMinusDM = FMA(smoothMinusDM, 1 - α, α × -DM)
smoothTR = FMA(smoothTR, 1 - α, α × TR)
// Directional Indicators
+DI = 100 × smoothPlusDM / smoothTR
-DI = 100 × smoothMinusDM / smoothTR
// Directional Index
diSum = +DI + -DI
DX = diSum > 0 ? 100 × |+DI - -DI| / diSum : 0
// Final smoothing
ADX = FMA(ADX, 1 - α, α × DX)
prevHigh = high
prevLow = low
prevClose = close
output = ADX
```
### The Stability Problem
Because ADX relies on recursive RMA at multiple stages, convergence is slow. Period 14 needs roughly 40-56 bars before matching TA-Lib to 4 decimal places. The first $2N$ values are mathematically correct but statistically immature — treat them as warmup artifacts.
+2 -28
View File
@@ -1,5 +1,7 @@
# ADXR: Average Directional Movement Rating
> *ADXR smooths ADX over time, filtering out momentary strength spikes to reveal the underlying trend conviction.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -56,34 +58,6 @@ The $N-1$ lag (rather than $N$) matches TA-Lib's reference implementation exactl
The period controls both the internal ADX calculation and the historical lookback depth.
### Pseudo-code
```
Initialize:
adx = new Adx(period)
adxBuffer = RingBuffer(period)
bar_count = 0
On each bar (high, low, close, isNew):
if !isNew: restore previous state
// Full ADX pipeline
adxValue = adx.Update(high, low, close, isNew)
// Store in history
adxBuffer.Add(adxValue)
bar_count++
// ADXR = average of current and (N-1)-lagged ADX
if bar_count >= period:
historicalAdx = adxBuffer[0] // oldest value in buffer
ADXR = (adxValue + historicalAdx) / 2.0
else:
ADXR = adxValue // insufficient history
output = ADXR
```
### Lag Analysis
| Component | Lag Source |
+2 -34
View File
@@ -1,5 +1,7 @@
# ALLIGATOR: Williams Alligator
> *Three displaced moving averages form jaw, teeth, and lips — when they diverge the Alligator feeds, when they converge it sleeps.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -71,40 +73,6 @@ The offsets shift plotted values forward in time for display purposes only. The
| $N_l$ | lipsPeriod | 5 | $N_l \geq 1$ |
| $O_l$ | lipsOffset | 3 | $O_l \geq 0$ |
### Pseudo-code
```
Initialize:
α_jaw = 1 / jawPeriod
α_teeth = 1 / teethPeriod
α_lips = 1 / lipsPeriod
jaw = teeth = lips = first source value
e_jaw = e_teeth = e_lips = 1.0 // bias compensation
On each bar (high, low, close, isNew):
if !isNew: restore previous state
source = (high + low + close) / 3.0
// SMMA updates with bias compensation
jaw = FMA(jaw, 1 - α_jaw, α_jaw × source)
e_jaw = e_jaw × (1 - α_jaw)
jaw_compensated = jaw / (1 - e_jaw)
teeth = FMA(teeth, 1 - α_teeth, α_teeth × source)
e_teeth = e_teeth × (1 - α_teeth)
teeth_compensated = teeth / (1 - e_teeth)
lips = FMA(lips, 1 - α_lips, α_lips × source)
e_lips = e_lips × (1 - α_lips)
lips_compensated = lips / (1 - e_lips)
output:
Jaw = jaw_compensated (plot at bar + jawOffset)
Teeth = teeth_compensated (plot at bar + teethOffset)
Lips = lips_compensated (plot at bar + lipsOffset)
```
### Market Phase Detection
| Phase | Line Configuration | Action |
+2 -46
View File
@@ -1,5 +1,7 @@
# AMAT: Archer Moving Averages Trends
> *Archer's moving average trends compare fast and slow averages, signaling when short-term momentum confirms the longer-term direction.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -63,52 +65,6 @@ $$\text{Strength}_t = \frac{|\text{Fast}_t - \text{Slow}_t|}{\text{Slow}_t} \tim
| $N_f$ | fastPeriod | 10 | $N_f \geq 1$ |
| $N_s$ | slowPeriod | 50 | $N_s > N_f$ |
### Pseudo-code
```
Initialize:
α_fast = 2 / (fastPeriod + 1)
α_slow = 2 / (slowPeriod + 1)
ema_fast = ema_slow = 0
e_fast = e_slow = 1.0
prev_fast = prev_slow = 0
bar_count = 0
On each bar (price, isNew):
if !isNew: restore previous state
// EMA updates
ema_fast = FMA(ema_fast, 1 - α_fast, α_fast × price)
e_fast = e_fast × (1 - α_fast)
fast = ema_fast / (1 - e_fast)
ema_slow = FMA(ema_slow, 1 - α_slow, α_slow × price)
e_slow = e_slow × (1 - α_slow)
slow = ema_slow / (1 - e_slow)
// Direction detection
fastDir = fast > prev_fast ? +1 : fast < prev_fast ? -1 : 0
slowDir = slow > prev_slow ? +1 : slow < prev_slow ? -1 : 0
// Triple-confirmation
if fast > slow AND fastDir == +1 AND slowDir == +1:
trend = +1
else if fast < slow AND fastDir == -1 AND slowDir == -1:
trend = -1
else:
trend = 0
// Strength
strength = slow > 0 ? |fast - slow| / slow × 100 : 0
prev_fast = fast
prev_slow = slow
output:
Trend = trend // +1, -1, or 0
Strength = strength // percentage
```
### Period Selection Guidelines
| Use Case | Fast | Slow | Ratio |
+2 -45
View File
@@ -1,5 +1,7 @@
# AROON: Aroon Indicator
> *Aroon measures how recently the highest high and lowest low occurred — recency as a proxy for trend vitality.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -62,51 +64,6 @@ Range: $[-100, +100]$.
|--------|-----------|---------|------------|
| $N$ | period | 25 | $N \geq 1$ |
### Pseudo-code
```
Initialize:
highBuf = RingBuffer(period + 1)
lowBuf = RingBuffer(period + 1)
bar_count = 0
On each bar (high, low, isNew):
if !isNew: restore previous state
highBuf.Add(high)
lowBuf.Add(low)
bar_count++
// Find index of highest high in buffer
maxIdx = 0
maxVal = -∞
for i = 0 to min(bar_count, period):
if highBuf[i] >= maxVal:
maxVal = highBuf[i]
maxIdx = i
// Find index of lowest low in buffer
minIdx = 0
minVal = +∞
for i = 0 to min(bar_count, period):
if lowBuf[i] <= minVal:
minVal = lowBuf[i]
minIdx = i
len = min(bar_count, period)
barsSinceHigh = len - maxIdx
barsSinceLow = len - minIdx
AroonUp = (len - barsSinceHigh) / len × 100
AroonDown = (len - barsSinceLow) / len × 100
AroonOsc = AroonUp - AroonDown
output:
Up = AroonUp
Down = AroonDown
Oscillator = AroonOsc
```
### Interpretation
| Condition | Signal |
+2 -30
View File
@@ -1,5 +1,7 @@
# AROONOSC: Aroon Oscillator
> *The Aroon oscillator subtracts Aroon Down from Aroon Up, collapsing two timing signals into a single directional gauge.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -56,36 +58,6 @@ $$\text{AroonOsc} = \text{AroonUp} - \text{AroonDown}$$
|--------|-----------|---------|------------|
| $N$ | period | 25 | $N \geq 1$ |
### Pseudo-code
```
Initialize:
highBuf = RingBuffer(period + 1)
lowBuf = RingBuffer(period + 1)
bar_count = 0
On each bar (high, low, isNew):
if !isNew: restore previous state
highBuf.Add(high)
lowBuf.Add(low)
bar_count++
len = min(bar_count, period)
// Scan for extremes
maxIdx = index of maximum in highBuf over last (len + 1) entries
minIdx = index of minimum in lowBuf over last (len + 1) entries
barsSinceHigh = len - maxIdx
barsSinceLow = len - minIdx
AroonUp = (len - barsSinceHigh) / len × 100
AroonDown = (len - barsSinceLow) / len × 100
output = AroonUp - AroonDown
```
### Drift Immunity
Unlike EMA-based oscillators that accumulate rounding errors across thousands of bars, the Aroon Oscillator is computed fresh each bar from a finite window. There is no recursive state that can diverge. This makes it architecturally robust for long-running production systems where indicator drift is a concern.
+2 -44
View File
@@ -1,5 +1,7 @@
# CHOP: Choppiness Index
> *Choppiness index quantifies how range-bound a market is — high values mean sideways, low values mean trending.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -56,50 +58,6 @@ The denominator $\log_{10}(N)$ normalizes the output so that the theoretical max
|--------|-----------|---------|------------|
| $N$ | period | 14 | $N \geq 2$ |
### Pseudo-code
```
Initialize:
trBuf = RingBuffer(period)
highBuf = RingBuffer(period)
lowBuf = RingBuffer(period)
trSum = 0
prevClose = NaN
logPeriod = log10(period)
On each bar (high, low, close, isNew):
if !isNew: restore previous state
// True Range
if prevClose is valid:
TR = max(high - low, |high - prevClose|, |low - prevClose|)
else:
TR = high - low
// Rolling sum update
if trBuf is full:
trSum -= trBuf.Oldest
trBuf.Add(TR)
trSum += TR
highBuf.Add(high)
lowBuf.Add(low)
// Channel width
maxHigh = Max(highBuf)
minLow = Min(lowBuf)
channel = maxHigh - minLow
// Choppiness Index
if channel > 0 AND trSum > 0:
CHOP = 100 × log10(trSum / channel) / logPeriod
else:
CHOP = 50 // neutral fallback
prevClose = close
output = Clamp(CHOP, 0, 100)
```
### Interpretation
| CHOP Value | Market Regime | Strategy Implication |
+2 -41
View File
@@ -1,5 +1,7 @@
# DMX: Directional Movement Index (Jurik)
> *Jurik's directional movement applies adaptive smoothing to DI lines, reducing whipsaws in the classic DMI framework.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -70,47 +72,6 @@ Positive values indicate bullish directional dominance; negative values indicate
|--------|-----------|---------|------------|
| $N$ | period | 14 | $N \geq 2$ |
### Pseudo-code
```
Initialize:
jmaPlusDM = new JMA(period)
jmaMinusDM = new JMA(period)
jmaTR = new JMA(period)
prevHigh = prevLow = prevClose = NaN
On each bar (high, low, close, isNew):
if !isNew: restore previous state
// Wilder's directional movement decomposition
TR = max(high - low, |high - prevClose|, |low - prevClose|)
upMove = high - prevHigh
downMove = prevLow - low
+DM = (upMove > downMove AND upMove > 0) ? upMove : 0
-DM = (downMove > upMove AND downMove > 0) ? downMove : 0
// Jurik smoothing (replaces Wilder's RMA)
smoothPlusDM = jmaPlusDM.Update(+DM)
smoothMinusDM = jmaMinusDM.Update(-DM)
smoothTR = jmaTR.Update(TR)
// Directional indicators
if smoothTR > 0:
DI_plus = 100 × smoothPlusDM / smoothTR
DI_minus = 100 × smoothMinusDM / smoothTR
else:
DI_plus = DI_minus = 0
DMX = DI_plus - DI_minus
prevHigh = high
prevLow = low
prevClose = close
output = DMX
```
### DMX vs DMI Comparison
| Property | DMI (Wilder) | DMX (Jurik) |
+2 -43
View File
@@ -1,5 +1,7 @@
# DX: Directional Movement Index
> *Directional movement captures the difference between positive and negative thrust, normalized by true range.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -72,49 +74,6 @@ When $+DI + (-DI) = 0$ (no directional movement), DX = 0.
|--------|-----------|---------|------------|
| $N$ | period | 14 | $N \geq 2$ |
### Pseudo-code
```
Initialize:
α = 1 / period
smoothPlusDM = smoothMinusDM = smoothTR = 0
prevHigh = prevLow = prevClose = NaN
On each bar (high, low, close, isNew):
if !isNew: restore previous state
// True Range
TR = max(high - low, |high - prevClose|, |low - prevClose|)
upMove = high - prevHigh
downMove = prevLow - low
+DM = (upMove > downMove AND upMove > 0) ? upMove : 0
-DM = (downMove > upMove AND downMove > 0) ? downMove : 0
// Wilder smoothing
smoothPlusDM = FMA(smoothPlusDM, 1 - α, α × +DM)
smoothMinusDM = FMA(smoothMinusDM, 1 - α, α × -DM)
smoothTR = FMA(smoothTR, 1 - α, α × TR)
// Directional Indicators
+DI = smoothTR > 0 ? 100 × smoothPlusDM / smoothTR : 0
-DI = smoothTR > 0 ? 100 × smoothMinusDM / smoothTR : 0
// DX (raw, no final RMA)
diSum = +DI + -DI
DX = diSum > 0 ? 100 × |+DI - -DI| / diSum : 0
prevHigh = high
prevLow = low
prevClose = close
output:
DX = DX // trend strength (0-100)
DiPlus = +DI // bullish directional indicator
DiMinus = -DI // bearish directional indicator
```
### DX vs ADX
| Property | DX | ADX |
+2 -2
View File
@@ -1,5 +1,7 @@
# GHLA: Gann High-Low Activator
> *The simplest indicators are the hardest to argue with. Two averages, one rule, and the market tells you which side of the fence to stand on.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -16,8 +18,6 @@
- Requires `period` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "The simplest indicators are the hardest to argue with. Two averages, one rule, and the market tells you which side of the fence to stand on."
The Gann High-Low Activator (GHLA) is a trend-following stop/reversal indicator that alternates between the Simple Moving Average of Highs and the Simple Moving Average of Lows based on a three-state crossover rule. Developed by Robert Krausz and published in *Technical Analysis of Stocks & Commodities* (February 1998), the indicator produces a single trailing line: SMA(Low) during uptrends (acting as dynamic support) and SMA(High) during downtrends (acting as dynamic resistance). The flip between states occurs only when price closes decisively beyond the opposing SMA, creating a hysteresis zone that filters minor whipsaws. With a default period of 3 bars, GHLA responds aggressively to trend changes while requiring just $O(N)$ additions and one comparison per bar.
## Historical Context
+2 -60
View File
@@ -1,5 +1,7 @@
# HT_TRENDMODE: Hilbert Transform Trend vs Cycle Mode
> *Hilbert trend mode classifies the market as trending or cycling — a binary answer from the analytic signal's behavior.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -89,66 +91,6 @@ Criterion 4: Price deviation override
No user-configurable parameters. The algorithm self-tunes based on the detected dominant cycle period (clamped to 6-50 bars).
### Pseudo-code
```
Initialize:
circBuffer = array for Hilbert state
smoothPrice = priceHistory = arrays
daysInTrend = 0
smoothPeriod = 0
prevDcPhase = 0
bar_count = 0
On each bar (price, isNew):
if !isNew: restore previous state
// Step 1: 4-bar WMA smooth
smooth = (4×price[0] + 3×price[1] + 2×price[2] + price[3]) / 10
// Step 2: Hilbert Transform (FIR filters)
detrender = HilbertFIR(smooth) × adjustedBandwidth
Q1 = HilbertFIR(detrender) × adjustedBandwidth
I1 = detrender[3]
// Step 3: Phasor rotation
I2 = I1 - jQ_prev; Q2 = Q1 + jI_prev
I2 = 0.2×I2 + 0.8×I2_prev; Q2 = 0.2×Q2 + 0.8×Q2_prev
// Step 4: Homodyne discriminator → period
Re = 0.2×(I2×I2_prev + Q2×Q2_prev) + 0.8×Re_prev
Im = 0.2×(I2×Q2_prev - Q2×I2_prev) + 0.8×Im_prev
period = clamp(360 / (atan(Im/Re) × RAD2DEG), 6, 50)
smoothPeriod = 0.33×period + 0.67×smoothPeriod_prev
// Step 5: DC Phase via DFT
dcPeriodInt = floor(smoothPeriod + 0.5)
realPart = Σ sin(i × 360/dcPeriodInt) × smooth[i] for i=0..dcPeriodInt-1
imagPart = Σ cos(i × 360/dcPeriodInt) × smooth[i]
dcPhase = atan(realPart/imagPart)×RAD2DEG + 90 + lagCompensation
// Step 6: SineWave indicators
sine = sin(dcPhase × DEG2RAD)
leadSine = sin((dcPhase + 45) × DEG2RAD)
// Step 7: Trendline (SMA smoothed with WMA)
sma = average(price, dcPeriodInt)
trendline = (4×sma[0] + 3×sma[1] + 2×sma[2] + sma[3]) / 10
// Step 8: Four-criteria trend decision
trend = 1
if sine crosses leadSine: daysInTrend = 0; trend = 0
daysInTrend++
if daysInTrend < 0.5 × smoothPeriod: trend = 0
phaseChange = dcPhase - prevDcPhase
expected = 360 / smoothPeriod
if phaseChange > 0.67×expected AND phaseChange < 1.5×expected: trend = 0
if |smooth - trendline| / trendline >= 0.015: trend = 1
prevDcPhase = dcPhase
output = trend // 1 = trending, 0 = cycling
```
### Decision Criteria Summary
| Criterion | Purpose |
+2 -38
View File
@@ -1,5 +1,7 @@
# ICHIMOKU: Ichimoku Kinko Hyo
> *Five lines, one cloud, and a time-shifted perspective — Ichimoku maps support, resistance, and momentum in a single glance.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -81,44 +83,6 @@ Confirms trend by comparing current price to the price from 26 bars ago.
| $N_s$ | senkouBPeriod | 52 | 2 trading months |
| $d$ | displacement | 26 | 1 trading month forward/back |
### Pseudo-code
```
Initialize:
highBuf = RingBuffer(senkouBPeriod) // covers all windows
lowBuf = RingBuffer(senkouBPeriod)
bar_count = 0
On each bar (high, low, close, isNew):
if !isNew: restore previous state (including buffer snapshots)
highBuf.Add(high)
lowBuf.Add(low)
bar_count++
// Tenkan-sen (shortest window)
tenkan = (Max(highBuf, tenkanPeriod) + Min(lowBuf, tenkanPeriod)) / 2
// Kijun-sen (medium window)
kijun = (Max(highBuf, kijunPeriod) + Min(lowBuf, kijunPeriod)) / 2
// Senkou Span A (computed now, plotted displacement bars forward)
senkouA = (tenkan + kijun) / 2
// Senkou Span B (longest window, plotted displacement bars forward)
senkouB = (Max(highBuf, senkouBPeriod) + Min(lowBuf, senkouBPeriod)) / 2
// Chikou Span (current close, plotted displacement bars backward)
chikou = close
output:
Tenkan = tenkan // plot at current bar
Kijun = kijun // plot at current bar
SenkouA = senkouA // plot at bar + displacement
SenkouB = senkouB // plot at bar + displacement
Chikou = chikou // plot at bar - displacement
```
### Cloud (Kumo) Interpretation
| Condition | Meaning |
+2 -31
View File
@@ -1,5 +1,7 @@
# IMPULSE: Elder Impulse System
> *The Impulse System identifies inflection points where a trend speeds up or slows down.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -15,8 +17,6 @@
- Requires `Math.Max(emaPeriod, macdSlow) + macdSignal - 1` bars (default 34) of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "The Impulse System identifies inflection points where a trend speeds up or slows down." -- Alexander Elder, *Come Into My Trading Room*
The Elder Impulse System combines a 13-period EMA (trend inertia) with the MACD(12,26,9) histogram (momentum acceleration) to classify each bar as bullish (+1), bearish (-1), or neutral (0). Both EMA slope and histogram slope must agree for a directional signal; disagreement forces neutral. The system functions as a permission filter rather than a signal generator, requiring 34 bars warmup and running at O(1) per bar through composition of two child indicators.
## Historical Context
@@ -80,35 +80,6 @@ Both child indicators must be warmed up and at least two comparison values must
| macdSlow | int | 26 | > macdFast | MACD slow EMA period |
| macdSignal | int | 9 | > 0 | MACD signal smoothing period |
### Pseudo-code
```
IMPULSE(close, emaPeriod=13, macdFast=12, macdSlow=26, macdSignal=9):
// Child indicator updates
ema_val = EMA.Update(close, emaPeriod)
macd_result = MACD.Update(close, macdFast, macdSlow, macdSignal)
hist_val = macd_result.Histogram
// Slope computation (requires previous values)
ema_slope = sign(ema_val - prev_ema)
hist_slope = sign(hist_val - prev_hist)
// Classification
if ema_slope > 0 AND hist_slope > 0:
signal = +1 // Bullish: both inertia and momentum rising
else if ema_slope < 0 AND hist_slope < 0:
signal = -1 // Bearish: both inertia and momentum falling
else:
signal = 0 // Neutral: disagreement between components
// State update
prev_ema = ema_val
prev_hist = hist_val
return signal
```
### Derivative Interpretation
The system combines two derivatives:
@@ -0,0 +1,73 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class MinusDiIndicatorTests
{
[Fact]
public void MinusDiIndicator_Constructor_SetsDefaults()
{
var indicator = new MinusDiIndicator();
Assert.Equal(14, indicator.Period);
Assert.True(indicator.ShowColdValues);
Assert.Equal("-DI - Minus Directional Indicator", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void MinusDiIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new MinusDiIndicator { Period = 20 };
Assert.Equal(0, MinusDiIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void MinusDiIndicator_Initialize_CreatesInternal()
{
var indicator = new MinusDiIndicator { Period = 14 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void MinusDiIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new MinusDiIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double value = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(value));
}
[Fact]
public void MinusDiIndicator_ShortName_IsCorrect()
{
var indicator = new MinusDiIndicator { Period = 20 };
Assert.Equal("-DI 20", indicator.ShortName);
}
[Fact]
public void MinusDiIndicator_SourceCodeLink_IsValid()
{
var indicator = new MinusDiIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase);
Assert.Contains("MinusDi.Quantower.cs", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase);
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class MinusDiIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 14;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private MinusDi _minusDi = null!;
private readonly LineSeries _minusDiSeries;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"-DI {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/dynamics/minusdi/MinusDi.Quantower.cs";
public MinusDiIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "-DI - Minus Directional Indicator";
Description = "Measures downward directional movement as a percentage of true range";
_minusDiSeries = new LineSeries(name: "-DI", color: Color.Red, width: 2, style: LineStyle.Solid);
AddLineSeries(_minusDiSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_minusDi = new MinusDi(Period);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TValue result = _minusDi.Update(this.GetInputBar(args), args.IsNewBar());
_minusDiSeries.SetValue(result.Value, _minusDi.IsHot, ShowColdValues);
}
}
@@ -0,0 +1,288 @@
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using OoplesFinance.StockIndicators.Enums;
using Skender.Stock.Indicators;
using TALib;
using QuanTAlib.Tests;
namespace QuanTAlib;
/// <summary>
/// Validation tests for MinusDi (-DI). Cross-validates against TA-Lib, Skender,
/// OoplesFinance, and internal Dx equivalence with multiple periods.
/// </summary>
public sealed class MinusDiValidationTests : IDisposable
{
private readonly ValidationTestData _data;
public MinusDiValidationTests()
{
_data = new ValidationTestData();
}
public void Dispose()
{
_data.Dispose();
}
// ═══════════════════════════════════════════════
// TA-Lib Validation
// ═══════════════════════════════════════════════
[Fact]
public void MatchesTalib()
{
var indicator = new MinusDi(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] cData = _data.Bars.Close.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.MinusDI(hData, lData, cData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.MinusDILookback(14);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
[Theory]
[InlineData(7)]
[InlineData(21)]
[InlineData(28)]
public void MatchesTalib_VariousPeriods(int period)
{
var indicator = new MinusDi(period);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] cData = _data.Bars.Close.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.MinusDI(hData, lData, cData, 0..^0, outReal, out var outRange, period);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.MinusDILookback(period);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
// ═══════════════════════════════════════════════
// Skender Validation
// ═══════════════════════════════════════════════
[Fact]
public void MatchesSkender()
{
var indicator = new MinusDi(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
var skenderResults = _data.SkenderQuotes.GetAdx(14).ToList();
ValidationHelper.VerifyData(results, skenderResults, x => x.Mdi);
}
[Theory]
[InlineData(7)]
[InlineData(21)]
[InlineData(28)]
public void MatchesSkender_VariousPeriods(int period)
{
var indicator = new MinusDi(period);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
var skenderResults = _data.SkenderQuotes.GetAdx(period).ToList();
ValidationHelper.VerifyData(results, skenderResults, x => x.Mdi);
}
// ═══════════════════════════════════════════════
// Dx Equivalence
// ═══════════════════════════════════════════════
[Fact]
public void ExactlyMatchesDx_DiMinus()
{
var indicator = new MinusDi(14);
var dx = new Dx(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
dx.Update(_data.Bars[i]);
Assert.Equal(dx.DiMinus.Value, indicator.Last.Value, 1e-12);
}
}
// ═══════════════════════════════════════════════
// OoplesFinance Structural Validation
// ═══════════════════════════════════════════════
[Fact]
public void MatchesOoples_Structural()
{
var ooplesData = _data.SkenderQuotes
.Select(q => new TickerData
{
Date = q.Date,
Open = (double)q.Open,
High = (double)q.High,
Low = (double)q.Low,
Close = (double)q.Close,
Volume = (double)q.Volume
})
.ToList();
var stockData = new StockData(ooplesData);
var adxResults = stockData.CalculateAverageDirectionalIndex(MovingAvgType.WildersSmoothingMethod, 14);
var allValues = adxResults.OutputValues.Values.SelectMany(v => v).ToList();
int finiteCount = allValues.Count(v => double.IsFinite(v));
Assert.True(finiteCount > 100, $"Expected >100 finite Ooples DI values, got {finiteCount}");
}
// ═══════════════════════════════════════════════
// Self-Consistency: Batch == Streaming
// ═══════════════════════════════════════════════
[Fact]
public void BatchEqualsStreaming()
{
var batchResults = MinusDi.Batch(_data.Bars, 14);
var streaming = new MinusDi(14);
var streamResults = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
streamResults.Add(streaming.Update(_data.Bars[i]).Value);
}
Assert.Equal(streamResults.Count, batchResults.Count);
for (int i = 0; i < batchResults.Count; i++)
{
Assert.Equal(streamResults[i], batchResults.Values[i], 1e-9);
}
}
[Fact]
public void BatchMatchesTalib()
{
var batchResults = MinusDi.Batch(_data.Bars, 14);
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] cData = _data.Bars.Close.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.MinusDI(hData, lData, cData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.MinusDILookback(14);
ValidationHelper.VerifyData(batchResults.Select(x => x.Value).ToList(), outReal, outRange, lookback);
}
// ═══════════════════════════════════════════════
// Determinism
// ═══════════════════════════════════════════════
[Fact]
public void ConsistentAcrossMultipleRuns()
{
var ind1 = new MinusDi(14);
var ind2 = new MinusDi(14);
var results1 = new List<double>();
var results2 = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
ind1.Update(_data.Bars[i]);
results1.Add(ind1.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
ind2.Update(_data.Bars[i]);
results2.Add(ind2.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
Assert.Equal(results1[i], results2[i], 1e-10);
}
}
// ═══════════════════════════════════════════════
// Output Range Validation
// ═══════════════════════════════════════════════
[Fact]
public void OutputIsNonNegative()
{
var indicator = new MinusDi(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
Assert.True(indicator.Last.Value >= 0, $"-DI output at bar {i} was {indicator.Last.Value}");
}
}
[Fact]
public void OutputBounded0To100()
{
var indicator = new MinusDi(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
double val = indicator.Last.Value;
if (i >= 14)
{
Assert.True(val >= 0 && val <= 100, $"-DI at bar {i} was {val}, expected [0,100]");
}
}
}
// ═══════════════════════════════════════════════
// Different Periods Produce Different Results
// ═══════════════════════════════════════════════
[Fact]
public void DifferentPeriods_ProduceDifferentResults()
{
var short7 = new MinusDi(7);
var long28 = new MinusDi(28);
for (int i = 0; i < _data.Bars.Count; i++)
{
short7.Update(_data.Bars[i]);
long28.Update(_data.Bars[i]);
}
Assert.NotEqual(short7.Last.Value, long28.Last.Value);
}
}
+91 -21
View File
@@ -1,30 +1,100 @@
# MINUS_DI: Minus Directional Indicator
Measures downward directional movement strength as a percentage (0-100).
> *-DI isolates downward directional thrust as a fraction of true range — the bearish arm of Wilder's directional system.*
## Introduction
The Minus Directional Indicator (-DI) measures the strength of downward price movement relative to the true range. It is one of the components of the Directional Movement System developed by J. Welles Wilder Jr.
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
| **Inputs** | OHLCV bar (TBar) |
| **Parameters** | `period` (default 14) |
| **Outputs** | Single series |
| **Output range** | 0 to 100 |
| **Warmup** | `period` bars |
| **PineScript** | [minusdi.pine](minusdi.pine) |
When -DI is rising, downward price pressure is increasing. When -DI crosses above +DI, it signals a potential bearish trend. The -DI line is commonly plotted alongside +DI to visualize directional balance.
- The Minus Directional Indicator measures the strength of downward price movement relative to true range.
- Parameterized by `period` (default 14).
- Output range: 0 to 100.
- Requires `period` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Dx equivalence.
## Calculation
-DI = Smoothed(-DM) / Smoothed(TR) × 100
The Minus Directional Indicator (-DI) is one component of J. Welles Wilder Jr.'s Directional Movement System. It quantifies the fraction of recent true range attributable to downward price extension. The computation smooths both -DM (minus directional movement) and TR (true range) with Wilder's RMA ($\alpha = 1/N$), then divides: $-DI = 100 \times \text{Smooth}(-DM) / \text{Smooth}(TR)$. When -DI rises, downward price pressure is increasing. When -DI crosses above +DI, it signals a potential bearish trend. The -DI line is commonly plotted alongside +DI to visualize directional balance.
Where:
- -DM (Minus Directional Movement) = max(PrevLow - Low, 0) when PrevLow - Low > High - PrevHigh, else 0
- TR (True Range) = max(High - Low, |High - PrevClose|, |Low - PrevClose|)
- Smoothing uses Wilder's method: Smooth = Smooth - Smooth/N + Input
## Historical Context
## Parameters
| Parameter | Default | Range | Description |
| :--- | :--- | :--- | :--- |
| Period | 14 | 2-∞ | Wilder smoothing period |
J. Welles Wilder Jr. introduced the Directional Movement System in *New Concepts in Technical Trading Systems* (1978). The system decomposes price range into directional components. +DI and -DI are the normalized indicators from which DX and ADX are derived. While most traders focus on ADX for trend strength, +DI and -DI remain essential for determining trend *direction* — a bearish signal occurs when -DI crosses above +DI, bullish when +DI crosses above -DI.
## Interpretation
- **Rising -DI:** Strengthening downward movement
- **-DI > +DI:** Bears dominate; potential downtrend
- **-DI crossover above +DI:** Bearish signal
- **High -DI (>40):** Strong downward momentum
## Architecture & Physics
## References
- Wilder, J. Welles Jr. "New Concepts in Technical Trading Systems" (1978)
### 1. Minus Directional Movement
$$\text{UpMove} = H_t - H_{t-1}, \quad \text{DownMove} = L_{t-1} - L_t$$
$$-DM = \begin{cases} \text{DownMove} & \text{if DownMove} > \text{UpMove and DownMove} > 0 \\ 0 & \text{otherwise} \end{cases}$$
### 2. True Range
$$TR = \max(H_t - L_t,\; |H_t - C_{t-1}|,\; |L_t - C_{t-1}|)$$
### 3. Wilder Smoothing (RMA)
$$-DM_{\text{smooth}} = \text{RMA}(-DM, N), \quad TR_{\text{smooth}} = \text{RMA}(TR, N)$$
### 4. Minus Directional Indicator
$$-DI = 100 \times \frac{-DM_{\text{smooth}}}{TR_{\text{smooth}}}$$
When $TR_{\text{smooth}} = 0$ (no price movement), -DI = 0.
### 5. Complexity
- **Time:** $O(1)$ per bar — all RMA updates are recursive
- **Space:** $O(1)$ — scalar state only (delegates to Dx)
- **Warmup:** $N$ bars
## Mathematical Foundation
### Parameters
| Symbol | Parameter | Default | Constraint |
|--------|-----------|---------|------------|
| $N$ | period | 14 | $N \geq 2$ |
### Interpretation
| -DI Value | Signal |
|-----------|--------|
| Rising -DI | Strengthening downward movement |
| -DI > +DI | Bears dominate; potential downtrend |
| -DI crossover above +DI | Bearish signal |
| High -DI (>40) | Strong downward momentum |
-DI measures directional *strength*, not absolute direction. Compare +DI vs -DI for directional bias: if $-DI > +DI$, the trend is down.
## Performance Profile
### Operation Count (Streaming Mode)
-DI is a thin wrapper around Dx. The per-bar cost is identical to Dx (one property extraction after Dx completes its update).
**Post-warmup steady state (per bar):**
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| Dx.Update (full pipeline) | 1 | 75 | 75 |
| Property extraction | 1 | 1 | 1 |
| **Total** | **2** | — | **~76 cycles** |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Exact Dx delegation; FMA-precise RMA smoothing |
| **Timeliness** | 7/10 | N-bar warmup; responds to bar-level changes |
| **Smoothness** | 7/10 | Single RMA layer; moderate noise suppression |
| **Noise Rejection** | 7/10 | Wilder smoothing filters transient spikes |
## Resources
- Wilder, J.W. — *New Concepts in Technical Trading Systems* (Trend Research, 1978)
- PineScript reference: `minusdi.pine` in indicator directory
+55
View File
@@ -0,0 +1,55 @@
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Minus Directional Indicator (-DI)", "-DI", overlay=false)
//@function Calculates -DI using Wilder's smoothing with compensated RMA
//@param period Number of bars used in the calculation
//@returns -DI value (0-100)
//@optimized Uses Wilder's smoothing (RMA) with warmup compensation for accurate values from bar 1
minusdi(simple int period) =>
if period <= 0
runtime.error("Period must be greater than 0")
float alpha = 1.0 / period
float beta = 1.0 - alpha
float tr = 0.0
float minus_dm = 0.0
if na(close[1])
tr := high - low
else
tr := math.max(high - low, math.max(math.abs(high - close[1]), math.abs(low - close[1])))
float upMove = high - high[1]
float downMove = low[1] - low
if downMove > upMove and downMove > 0
minus_dm := downMove
var bool warmup = true
var float e = 1.0
var float tr_ema = 0.0
var float tr_result = tr
var float minus_dm_ema = 0.0
var float minus_dm_result = minus_dm
tr_ema := alpha * (tr - tr_ema) + tr_ema
minus_dm_ema := alpha * (minus_dm - minus_dm_ema) + minus_dm_ema
if warmup
e *= beta
float c = 1.0 / (1.0 - e)
tr_result := c * tr_ema
minus_dm_result := c * minus_dm_ema
warmup := e > 1e-10
else
tr_result := tr_ema
minus_dm_result := minus_dm_ema
float minus_di = tr_result != 0.0 ? 100.0 * minus_dm_result / tr_result : 0.0
minus_di
// ---------- Main loop ----------
// Inputs
i_period = input.int(14, "Period", minval=1, tooltip="Number of bars used in the calculation")
// Calculation
minus_di = minusdi(i_period)
// Plot
plot(minus_di, "-DI", color=color.red, linewidth=2)
hline(25, "Threshold", color=color.gray, linestyle=hline.style_dashed)
@@ -0,0 +1,73 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class MinusDmIndicatorTests
{
[Fact]
public void MinusDmIndicator_Constructor_SetsDefaults()
{
var indicator = new MinusDmIndicator();
Assert.Equal(14, indicator.Period);
Assert.True(indicator.ShowColdValues);
Assert.Equal("-DM - Minus Directional Movement", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void MinusDmIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new MinusDmIndicator { Period = 20 };
Assert.Equal(0, MinusDmIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void MinusDmIndicator_Initialize_CreatesInternal()
{
var indicator = new MinusDmIndicator { Period = 14 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void MinusDmIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new MinusDmIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double value = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(value));
}
[Fact]
public void MinusDmIndicator_ShortName_IsCorrect()
{
var indicator = new MinusDmIndicator { Period = 20 };
Assert.Equal("-DM 20", indicator.ShortName);
}
[Fact]
public void MinusDmIndicator_SourceCodeLink_IsValid()
{
var indicator = new MinusDmIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase);
Assert.Contains("MinusDm.Quantower.cs", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase);
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class MinusDmIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 14;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private MinusDm _minusDm = null!;
private readonly LineSeries _minusDmSeries;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"-DM {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/dynamics/minusdm/MinusDm.Quantower.cs";
public MinusDmIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "-DM - Minus Directional Movement";
Description = "Wilder-smoothed downward directional movement in price units";
_minusDmSeries = new LineSeries(name: "-DM", color: Color.Red, width: 2, style: LineStyle.Solid);
AddLineSeries(_minusDmSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_minusDm = new MinusDm(Period);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TValue result = _minusDm.Update(this.GetInputBar(args), args.IsNewBar());
_minusDmSeries.SetValue(result.Value, _minusDm.IsHot, ShowColdValues);
}
}
@@ -0,0 +1,231 @@
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using OoplesFinance.StockIndicators.Enums;
using Skender.Stock.Indicators;
using TALib;
using QuanTAlib.Tests;
namespace QuanTAlib;
/// <summary>
/// Validation tests for MinusDm (-DM). Cross-validates against TA-Lib,
/// OoplesFinance, and internal Dx equivalence with multiple periods.
/// Note: Skender does not expose DM values directly; only DI values via GetAdx().
/// </summary>
public sealed class MinusDmValidationTests : IDisposable
{
private readonly ValidationTestData _data;
public MinusDmValidationTests()
{
_data = new ValidationTestData();
}
public void Dispose()
{
_data.Dispose();
}
// ═══════════════════════════════════════════════
// TA-Lib Validation
// ═══════════════════════════════════════════════
[Fact]
public void MatchesTalib()
{
var indicator = new MinusDm(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.MinusDM(hData, lData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.MinusDMLookback(14);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
[Theory]
[InlineData(7)]
[InlineData(21)]
[InlineData(28)]
public void MatchesTalib_VariousPeriods(int period)
{
var indicator = new MinusDm(period);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.MinusDM(hData, lData, 0..^0, outReal, out var outRange, period);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.MinusDMLookback(period);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
// ═══════════════════════════════════════════════
// Dx Equivalence
// ═══════════════════════════════════════════════
[Fact]
public void ExactlyMatchesDx_DmMinus()
{
var indicator = new MinusDm(14);
var dx = new Dx(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
dx.Update(_data.Bars[i]);
Assert.Equal(dx.DmMinus.Value, indicator.Last.Value, 1e-12);
}
}
// ═══════════════════════════════════════════════
// OoplesFinance Structural Validation
// ═══════════════════════════════════════════════
[Fact]
public void MatchesOoples_Structural()
{
var ooplesData = _data.SkenderQuotes
.Select(q => new TickerData
{
Date = q.Date,
Open = (double)q.Open,
High = (double)q.High,
Low = (double)q.Low,
Close = (double)q.Close,
Volume = (double)q.Volume
})
.ToList();
var stockData = new StockData(ooplesData);
var adxResults = stockData.CalculateAverageDirectionalIndex(MovingAvgType.WildersSmoothingMethod, 14);
var allValues = adxResults.OutputValues.Values.SelectMany(v => v).ToList();
int finiteCount = allValues.Count(v => double.IsFinite(v));
Assert.True(finiteCount > 100, $"Expected >100 finite Ooples ADX/DI values, got {finiteCount}");
}
// ═══════════════════════════════════════════════
// Self-Consistency: Batch == Streaming
// ═══════════════════════════════════════════════
[Fact]
public void BatchEqualsStreaming()
{
var batchResults = MinusDm.Batch(_data.Bars, 14);
var streaming = new MinusDm(14);
var streamResults = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
streamResults.Add(streaming.Update(_data.Bars[i]).Value);
}
Assert.Equal(streamResults.Count, batchResults.Count);
for (int i = 0; i < batchResults.Count; i++)
{
Assert.Equal(streamResults[i], batchResults.Values[i], 1e-9);
}
}
[Fact]
public void BatchMatchesTalib()
{
var batchResults = MinusDm.Batch(_data.Bars, 14);
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.MinusDM(hData, lData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.MinusDMLookback(14);
ValidationHelper.VerifyData(batchResults.Select(x => x.Value).ToList(), outReal, outRange, lookback);
}
// ═══════════════════════════════════════════════
// Determinism
// ═══════════════════════════════════════════════
[Fact]
public void ConsistentAcrossMultipleRuns()
{
var ind1 = new MinusDm(14);
var ind2 = new MinusDm(14);
var results1 = new List<double>();
var results2 = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
ind1.Update(_data.Bars[i]);
results1.Add(ind1.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
ind2.Update(_data.Bars[i]);
results2.Add(ind2.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
Assert.Equal(results1[i], results2[i], 1e-10);
}
}
// ═══════════════════════════════════════════════
// Output Range Validation
// ═══════════════════════════════════════════════
[Fact]
public void OutputIsNonNegative()
{
var indicator = new MinusDm(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
Assert.True(indicator.Last.Value >= 0, $"-DM output at bar {i} was {indicator.Last.Value}");
}
}
// ═══════════════════════════════════════════════
// Different Periods Produce Different Results
// ═══════════════════════════════════════════════
[Fact]
public void DifferentPeriods_ProduceDifferentResults()
{
var short7 = new MinusDm(7);
var long28 = new MinusDm(28);
for (int i = 0; i < _data.Bars.Count; i++)
{
short7.Update(_data.Bars[i]);
long28.Update(_data.Bars[i]);
}
Assert.NotEqual(short7.Last.Value, long28.Last.Value);
}
}
+83 -18
View File
@@ -1,27 +1,92 @@
# MINUS_DM: Minus Directional Movement
Wilder-smoothed downward directional movement in price units (≥0).
> *-DM captures the raw downward price extension, smoothed by Wilder's RMA — the building block before normalization to -DI.*
## Introduction
Minus Directional Movement (-DM) measures the magnitude of downward price movement, smoothed using Wilder's method. Unlike -DI which normalizes by true range to produce a percentage, -DM outputs raw smoothed values in price units.
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
| **Inputs** | OHLCV bar (TBar) |
| **Parameters** | `period` (default 14) |
| **Outputs** | Single series |
| **Output range** | ≥ 0 (price units) |
| **Warmup** | `period` bars |
| **PineScript** | [minusdm.pine](minusdm.pine) |
-DM captures when the previous bar's low exceeds the current bar's low by more than the current bar's high exceeds the previous bar's high. It is the raw building block of the Directional Movement System.
- Minus Directional Movement outputs the Wilder-smoothed downward directional movement in price units.
- Parameterized by `period` (default 14).
- Output range: ≥ 0 (price units, scales with instrument).
- Requires `period` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib and Dx equivalence.
## Calculation
-DM = max(PrevLow - Low, 0) when PrevLow - Low > High - PrevHigh, else 0
Minus Directional Movement (-DM) measures the magnitude of downward price movement, smoothed using Wilder's method. Unlike -DI which normalizes by true range to produce a percentage, -DM outputs raw smoothed values in price units. -DM captures when the previous bar's low exceeds the current bar's low by more than the current bar's high exceeds the previous bar's high. It is the raw building block of the Directional Movement System — the unnormalized signal before division by True Range converts it to -DI.
Smoothed using Wilder's method: Smooth = Smooth - Smooth/N + Input
## Historical Context
## Parameters
| Parameter | Default | Range | Description |
| :--- | :--- | :--- | :--- |
| Period | 14 | 2-∞ | Wilder smoothing period |
J. Welles Wilder Jr. designed the Directional Movement System as a pipeline in *New Concepts in Technical Trading Systems* (1978). -DM is the first computational stage for downward movement: a binary event detector that fires when downward range expansion dominates. Wilder's insight was that direction should be measured by range *extension*, not by close-to-close returns. A bar that pushes to a new low by more than it pushes to a new high registers as negative directional movement. The smoothed -DM series shows how much downward thrust is being sustained over the lookback period, in absolute price units.
## Interpretation
- **Rising -DM:** Increasing downward price extension
- **-DM > +DM:** Downward movement exceeds upward movement
- **Zero -DM:** No downward directional movement on the bar
- Values are in price units and scale with the instrument
## Architecture & Physics
## References
- Wilder, J. Welles Jr. "New Concepts in Technical Trading Systems" (1978)
### 1. Minus Directional Movement (Raw)
$$\text{UpMove} = H_t - H_{t-1}, \quad \text{DownMove} = L_{t-1} - L_t$$
$$-DM = \begin{cases} \text{DownMove} & \text{if DownMove} > \text{UpMove and DownMove} > 0 \\ 0 & \text{otherwise} \end{cases}$$
Only one of +DM or -DM can be non-zero per bar — the dominant direction wins.
### 2. Wilder Smoothing (RMA)
$$-DM_{\text{smooth}} = \text{RMA}(-DM, N), \quad \alpha = 1/N$$
### 3. Complexity
- **Time:** $O(1)$ per bar — recursive RMA update
- **Space:** $O(1)$ — scalar state only (delegates to Dx)
- **Warmup:** $N$ bars
## Mathematical Foundation
### Parameters
| Symbol | Parameter | Default | Constraint |
|--------|-----------|---------|------------|
| $N$ | period | 14 | $N \geq 2$ |
### Interpretation
| -DM Behavior | Signal |
|--------------|--------|
| Rising -DM | Increasing downward price extension |
| -DM > +DM | Downward movement exceeds upward movement |
| Zero -DM | No downward directional movement on the bar |
| Values scale with instrument | Compare within same instrument only |
-DM values are in price units and scale with the instrument. They cannot be compared across different instruments without normalization (which is what -DI provides).
## Performance Profile
### Operation Count (Streaming Mode)
-DM is a thin wrapper around Dx. The per-bar cost is identical to Dx (one property extraction after Dx completes its update).
**Post-warmup steady state (per bar):**
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| Dx.Update (full pipeline) | 1 | 75 | 75 |
| Property extraction | 1 | 1 | 1 |
| **Total** | **2** | — | **~76 cycles** |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Exact Dx delegation; FMA-precise RMA smoothing |
| **Timeliness** | 7/10 | N-bar warmup; responds to bar-level changes |
| **Smoothness** | 7/10 | Single RMA layer; moderate noise suppression |
| **Noise Rejection** | 7/10 | Wilder smoothing filters transient spikes |
## Resources
- Wilder, J.W. — *New Concepts in Technical Trading Systems* (Trend Research, 1978)
- PineScript reference: `minusdm.pine` in indicator directory
+44
View File
@@ -0,0 +1,44 @@
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Minus Directional Movement (-DM)", "-DM", overlay=false)
//@function Calculates Wilder-smoothed -DM using compensated RMA
//@param period Number of bars used in the calculation
//@returns Smoothed -DM value in price units (≥0)
//@optimized Uses Wilder's smoothing (RMA) with warmup compensation for accurate values from bar 1
minusdm(simple int period) =>
if period <= 0
runtime.error("Period must be greater than 0")
float alpha = 1.0 / period
float beta = 1.0 - alpha
float minus_dm = 0.0
if not na(low[1])
float upMove = high - high[1]
float downMove = low[1] - low
if downMove > upMove and downMove > 0
minus_dm := downMove
var bool warmup = true
var float e = 1.0
var float minus_dm_ema = 0.0
var float minus_dm_result = minus_dm
minus_dm_ema := alpha * (minus_dm - minus_dm_ema) + minus_dm_ema
if warmup
e *= beta
float c = 1.0 / (1.0 - e)
minus_dm_result := c * minus_dm_ema
warmup := e > 1e-10
else
minus_dm_result := minus_dm_ema
minus_dm_result
// ---------- Main loop ----------
// Inputs
i_period = input.int(14, "Period", minval=1, tooltip="Number of bars used in the calculation")
// Calculation
minus_dm_value = minusdm(i_period)
// Plot
plot(minus_dm_value, "-DM", color=color.red, linewidth=2)
+2 -2
View File
@@ -1,5 +1,7 @@
# PFE: Polarized Fractal Efficiency
> *The shortest distance between two points is a straight line. The market never takes the shortest distance. PFE measures how badly it misses.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -16,8 +18,6 @@
- Requires `period + 1` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "The shortest distance between two points is a straight line. The market never takes the shortest distance. PFE measures how badly it misses."
Polarized Fractal Efficiency (PFE) quantifies trend strength by comparing the Euclidean distance a price series actually travels bar-to-bar against the straight-line distance between the endpoints over the same window. The ratio, scaled to [-100, +100] and smoothed with an EMA, distinguishes efficient trending motion (values near ±100) from fractal, self-similar noise (values near 0). Created by Hans Hannula and published in *Technical Analysis of Stocks & Commodities* (January 1994), PFE applies fractal geometry to price action without requiring Hurst exponent estimation or rescaled-range analysis. With default parameters (period=10, smooth=5), the indicator needs 11 close values for the first raw reading plus 5 bars of EMA convergence, totaling ~16 bars of warmup. The core loop executes $N$ square roots per bar, making it $O(N)$ per update in streaming mode.
## Historical Context
+917
View File
@@ -0,0 +1,917 @@
namespace QuanTAlib;
/// <summary>
/// Combined unit tests for PlusDi, MinusDi, PlusDm, MinusDm.
/// All four are thin Dx-composition wrappers extracting a single property.
/// </summary>
public class DiDmTests
{
// ═══════════════════════════════════════════════
// A. Constructor / Parameter Tests
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_Constructor_InvalidPeriod_Throws()
{
Assert.Throws<ArgumentException>(() => new PlusDi(0));
Assert.Throws<ArgumentException>(() => new PlusDi(-1));
}
[Fact]
public void MinusDi_Constructor_InvalidPeriod_Throws()
{
Assert.Throws<ArgumentException>(() => new MinusDi(0));
Assert.Throws<ArgumentException>(() => new MinusDi(-1));
}
[Fact]
public void PlusDm_Constructor_InvalidPeriod_Throws()
{
Assert.Throws<ArgumentException>(() => new PlusDm(0));
Assert.Throws<ArgumentException>(() => new PlusDm(-1));
}
[Fact]
public void MinusDm_Constructor_InvalidPeriod_Throws()
{
Assert.Throws<ArgumentException>(() => new MinusDm(0));
Assert.Throws<ArgumentException>(() => new MinusDm(-1));
}
[Fact]
public void PlusDi_DefaultPeriod_Is14()
{
var indicator = new PlusDi();
Assert.Equal(14, indicator.Period);
}
[Fact]
public void MinusDi_DefaultPeriod_Is14()
{
var indicator = new MinusDi();
Assert.Equal(14, indicator.Period);
}
[Fact]
public void PlusDm_DefaultPeriod_Is14()
{
var indicator = new PlusDm();
Assert.Equal(14, indicator.Period);
}
[Fact]
public void MinusDm_DefaultPeriod_Is14()
{
var indicator = new MinusDm();
Assert.Equal(14, indicator.Period);
}
[Fact]
public void PlusDi_Name_ContainsPeriod()
{
var indicator = new PlusDi(20);
Assert.Contains("20", indicator.Name, StringComparison.Ordinal);
}
[Fact]
public void MinusDi_Name_ContainsPeriod()
{
var indicator = new MinusDi(20);
Assert.Contains("20", indicator.Name, StringComparison.Ordinal);
}
[Fact]
public void PlusDm_Name_ContainsPeriod()
{
var indicator = new PlusDm(20);
Assert.Contains("20", indicator.Name, StringComparison.Ordinal);
}
[Fact]
public void MinusDm_Name_ContainsPeriod()
{
var indicator = new MinusDm(20);
Assert.Contains("20", indicator.Name, StringComparison.Ordinal);
}
// ═══════════════════════════════════════════════
// B. Basic Calculation Tests
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_BasicCalculation_DoesNotCrash()
{
var indicator = new PlusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
Assert.True(double.IsFinite(indicator.Last.Value));
}
[Fact]
public void MinusDi_BasicCalculation_DoesNotCrash()
{
var indicator = new MinusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
Assert.True(double.IsFinite(indicator.Last.Value));
}
[Fact]
public void PlusDm_BasicCalculation_DoesNotCrash()
{
var indicator = new PlusDm(14);
var gbm = new GBM();
var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
Assert.True(double.IsFinite(indicator.Last.Value));
}
[Fact]
public void MinusDm_BasicCalculation_DoesNotCrash()
{
var indicator = new MinusDm(14);
var gbm = new GBM();
var bars = gbm.Fetch(1000, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
Assert.True(double.IsFinite(indicator.Last.Value));
}
// ═══════════════════════════════════════════════
// C. IsHot / WarmupPeriod Tests
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_IsHot_BecomesTrueAfterWarmup()
{
var indicator = new PlusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
Assert.False(indicator.IsHot);
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
if (indicator.IsHot)
{
break;
}
}
Assert.True(indicator.IsHot);
}
[Fact]
public void MinusDi_IsHot_BecomesTrueAfterWarmup()
{
var indicator = new MinusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
Assert.False(indicator.IsHot);
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
if (indicator.IsHot)
{
break;
}
}
Assert.True(indicator.IsHot);
}
[Fact]
public void PlusDm_IsHot_BecomesTrueAfterWarmup()
{
var indicator = new PlusDm(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
Assert.False(indicator.IsHot);
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
if (indicator.IsHot)
{
break;
}
}
Assert.True(indicator.IsHot);
}
[Fact]
public void MinusDm_IsHot_BecomesTrueAfterWarmup()
{
var indicator = new MinusDm(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
Assert.False(indicator.IsHot);
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
if (indicator.IsHot)
{
break;
}
}
Assert.True(indicator.IsHot);
}
// ═══════════════════════════════════════════════
// D. Bar Correction (isNew=false) Tests
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_BarCorrection_MatchesFreshInstance()
{
var indicator = new PlusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < 99; i++)
{
indicator.Update(bars[i]);
}
indicator.Update(bars[99]);
var modifiedBar = new TBar(bars[99].Time, bars[99].Open, bars[99].High + 1.0, bars[99].Low - 1.0, bars[99].Close, bars[99].Volume);
var val2 = indicator.Update(modifiedBar, isNew: false);
var fresh = new PlusDi(14);
for (int i = 0; i < 99; i++)
{
fresh.Update(bars[i]);
}
var val3 = fresh.Update(modifiedBar);
Assert.Equal(val3.Value, val2.Value, 1e-9);
}
[Fact]
public void MinusDi_BarCorrection_MatchesFreshInstance()
{
var indicator = new MinusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < 99; i++)
{
indicator.Update(bars[i]);
}
indicator.Update(bars[99]);
var modifiedBar = new TBar(bars[99].Time, bars[99].Open, bars[99].High + 1.0, bars[99].Low - 1.0, bars[99].Close, bars[99].Volume);
var val2 = indicator.Update(modifiedBar, isNew: false);
var fresh = new MinusDi(14);
for (int i = 0; i < 99; i++)
{
fresh.Update(bars[i]);
}
var val3 = fresh.Update(modifiedBar);
Assert.Equal(val3.Value, val2.Value, 1e-9);
}
[Fact]
public void PlusDm_BarCorrection_MatchesFreshInstance()
{
var indicator = new PlusDm(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < 99; i++)
{
indicator.Update(bars[i]);
}
indicator.Update(bars[99]);
var modifiedBar = new TBar(bars[99].Time, bars[99].Open, bars[99].High + 1.0, bars[99].Low - 1.0, bars[99].Close, bars[99].Volume);
var val2 = indicator.Update(modifiedBar, isNew: false);
var fresh = new PlusDm(14);
for (int i = 0; i < 99; i++)
{
fresh.Update(bars[i]);
}
var val3 = fresh.Update(modifiedBar);
Assert.Equal(val3.Value, val2.Value, 1e-9);
}
[Fact]
public void MinusDm_BarCorrection_MatchesFreshInstance()
{
var indicator = new MinusDm(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < 99; i++)
{
indicator.Update(bars[i]);
}
indicator.Update(bars[99]);
var modifiedBar = new TBar(bars[99].Time, bars[99].Open, bars[99].High + 1.0, bars[99].Low - 1.0, bars[99].Close, bars[99].Volume);
var val2 = indicator.Update(modifiedBar, isNew: false);
var fresh = new MinusDm(14);
for (int i = 0; i < 99; i++)
{
fresh.Update(bars[i]);
}
var val3 = fresh.Update(modifiedBar);
Assert.Equal(val3.Value, val2.Value, 1e-9);
}
// ═══════════════════════════════════════════════
// E. Reset Tests
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_Reset_ClearsState()
{
var indicator = new PlusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
indicator.Reset();
Assert.Equal(0, indicator.Last.Value);
Assert.False(indicator.IsHot);
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
Assert.True(double.IsFinite(indicator.Last.Value));
}
[Fact]
public void MinusDi_Reset_ClearsState()
{
var indicator = new MinusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
indicator.Reset();
Assert.Equal(0, indicator.Last.Value);
Assert.False(indicator.IsHot);
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
Assert.True(double.IsFinite(indicator.Last.Value));
}
[Fact]
public void PlusDm_Reset_ClearsState()
{
var indicator = new PlusDm(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
indicator.Reset();
Assert.Equal(0, indicator.Last.Value);
Assert.False(indicator.IsHot);
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
Assert.True(double.IsFinite(indicator.Last.Value));
}
[Fact]
public void MinusDm_Reset_ClearsState()
{
var indicator = new MinusDm(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
indicator.Reset();
Assert.Equal(0, indicator.Last.Value);
Assert.False(indicator.IsHot);
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
Assert.True(double.IsFinite(indicator.Last.Value));
}
// ═══════════════════════════════════════════════
// F. Batch / Streaming Consistency Tests
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_Batch_MatchesStreaming()
{
var gbm = new GBM(seed: 123);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var batchResult = PlusDi.Batch(bars, 14);
var streaming = new PlusDi(14);
for (int i = 0; i < bars.Count; i++)
{
streaming.Update(bars[i]);
}
Assert.Equal(batchResult.Last.Value, streaming.Last.Value, 9);
}
[Fact]
public void MinusDi_Batch_MatchesStreaming()
{
var gbm = new GBM(seed: 123);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var batchResult = MinusDi.Batch(bars, 14);
var streaming = new MinusDi(14);
for (int i = 0; i < bars.Count; i++)
{
streaming.Update(bars[i]);
}
Assert.Equal(batchResult.Last.Value, streaming.Last.Value, 9);
}
[Fact]
public void PlusDm_Batch_MatchesStreaming()
{
var gbm = new GBM(seed: 123);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var batchResult = PlusDm.Batch(bars, 14);
var streaming = new PlusDm(14);
for (int i = 0; i < bars.Count; i++)
{
streaming.Update(bars[i]);
}
Assert.Equal(batchResult.Last.Value, streaming.Last.Value, 9);
}
[Fact]
public void MinusDm_Batch_MatchesStreaming()
{
var gbm = new GBM(seed: 123);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var batchResult = MinusDm.Batch(bars, 14);
var streaming = new MinusDm(14);
for (int i = 0; i < bars.Count; i++)
{
streaming.Update(bars[i]);
}
Assert.Equal(batchResult.Last.Value, streaming.Last.Value, 9);
}
// ═══════════════════════════════════════════════
// G. Event / Pub Tests
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_Pub_FiresOnNewBar()
{
var indicator = new PlusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
int fireCount = 0;
indicator.Pub += (object? _, in TValueEventArgs _e) => { fireCount++; };
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
Assert.Equal(bars.Count, fireCount);
}
[Fact]
public void MinusDi_Pub_DoesNotFireOnCorrection()
{
var indicator = new MinusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
int fireCount = 0;
indicator.Pub += (object? _, in TValueEventArgs _e) => { fireCount++; };
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
int countAfterNewBars = fireCount;
// Bar correction should not fire
var modifiedBar = new TBar(bars[^1].Time, bars[^1].Open, bars[^1].High + 1.0, bars[^1].Low - 1.0, bars[^1].Close, bars[^1].Volume);
indicator.Update(modifiedBar, isNew: false);
Assert.Equal(countAfterNewBars, fireCount);
}
[Fact]
public void PlusDm_Pub_FiresOnNewBar()
{
var indicator = new PlusDm(14);
var gbm = new GBM();
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
int fireCount = 0;
indicator.Pub += (object? _, in TValueEventArgs _e) => { fireCount++; };
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
Assert.Equal(bars.Count, fireCount);
}
[Fact]
public void MinusDm_Pub_DoesNotFireOnCorrection()
{
var indicator = new MinusDm(14);
var gbm = new GBM();
var bars = gbm.Fetch(20, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
int fireCount = 0;
indicator.Pub += (object? _, in TValueEventArgs _e) => { fireCount++; };
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
int countAfterNewBars = fireCount;
var modifiedBar = new TBar(bars[^1].Time, bars[^1].Open, bars[^1].High + 1.0, bars[^1].Low - 1.0, bars[^1].Close, bars[^1].Volume);
indicator.Update(modifiedBar, isNew: false);
Assert.Equal(countAfterNewBars, fireCount);
}
// ═══════════════════════════════════════════════
// H. Prime / Calculate / Chainability Tests
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_Prime_SetsState()
{
var indicator = new PlusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
indicator.Prime(bars);
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(indicator.Last.Value));
}
[Fact]
public void MinusDi_Prime_SetsState()
{
var indicator = new MinusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
indicator.Prime(bars);
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(indicator.Last.Value));
}
[Fact]
public void PlusDm_Calculate_ReturnsTupleWithIndicator()
{
var gbm = new GBM();
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var (results, ind) = PlusDm.Calculate(bars, 14);
Assert.Equal(bars.Count, results.Count);
Assert.True(ind.IsHot);
Assert.Equal(results[^1].Value, ind.Last.Value, 1e-9);
}
[Fact]
public void MinusDm_Calculate_ReturnsTupleWithIndicator()
{
var gbm = new GBM();
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var (results, ind) = MinusDm.Calculate(bars, 14);
Assert.Equal(bars.Count, results.Count);
Assert.True(ind.IsHot);
Assert.Equal(results[^1].Value, ind.Last.Value, 1e-9);
}
[Fact]
public void PlusDi_TBarSeriesConstructor_Works()
{
var gbm = new GBM();
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var indicator = new PlusDi(bars, 14);
Assert.True(double.IsFinite(indicator.Last.Value));
}
[Fact]
public void MinusDi_TBarSeriesConstructor_Works()
{
var gbm = new GBM();
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var indicator = new MinusDi(bars, 14);
Assert.True(double.IsFinite(indicator.Last.Value));
}
[Fact]
public void PlusDi_ScalarUpdate_ReturnsLastUnchanged()
{
var indicator = new PlusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
var before = indicator.Last;
var result = indicator.Update(new TValue(DateTime.UtcNow, 42.0));
Assert.Equal(before.Value, result.Value);
}
[Fact]
public void MinusDm_ScalarUpdate_ReturnsLastUnchanged()
{
var indicator = new MinusDm(14);
var gbm = new GBM();
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
indicator.Update(bars[i]);
}
var before = indicator.Last;
var result = indicator.Update(new TValue(DateTime.UtcNow, 42.0));
Assert.Equal(before.Value, result.Value);
}
// ═══════════════════════════════════════════════
// Range / Value Constraint Tests
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_OutputRange_0to100()
{
var indicator = new PlusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
var result = indicator.Update(bars[i]);
if (indicator.IsHot)
{
Assert.InRange(result.Value, 0, 100);
}
}
}
[Fact]
public void MinusDi_OutputRange_0to100()
{
var indicator = new MinusDi(14);
var gbm = new GBM();
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
var result = indicator.Update(bars[i]);
if (indicator.IsHot)
{
Assert.InRange(result.Value, 0, 100);
}
}
}
[Fact]
public void PlusDm_NonNegative()
{
var indicator = new PlusDm(14);
var gbm = new GBM();
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
var result = indicator.Update(bars[i]);
if (indicator.IsHot)
{
Assert.True(result.Value >= 0, $"+DM should be non-negative, got {result.Value}");
}
}
}
[Fact]
public void MinusDm_NonNegative()
{
var indicator = new MinusDm(14);
var gbm = new GBM();
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
for (int i = 0; i < bars.Count; i++)
{
var result = indicator.Update(bars[i]);
if (indicator.IsHot)
{
Assert.True(result.Value >= 0, $"-DM should be non-negative, got {result.Value}");
}
}
}
// ═══════════════════════════════════════════════
// Dx Equivalence Tests
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_MatchesDx_DiPlus()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var plusDi = new PlusDi(14);
var dx = new Dx(14);
for (int i = 0; i < bars.Count; i++)
{
plusDi.Update(bars[i]);
dx.Update(bars[i]);
Assert.Equal(dx.DiPlus.Value, plusDi.Last.Value, 1e-12);
}
}
[Fact]
public void MinusDi_MatchesDx_DiMinus()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var minusDi = new MinusDi(14);
var dx = new Dx(14);
for (int i = 0; i < bars.Count; i++)
{
minusDi.Update(bars[i]);
dx.Update(bars[i]);
Assert.Equal(dx.DiMinus.Value, minusDi.Last.Value, 1e-12);
}
}
[Fact]
public void PlusDm_MatchesDx_DmPlus()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var plusDm = new PlusDm(14);
var dx = new Dx(14);
for (int i = 0; i < bars.Count; i++)
{
plusDm.Update(bars[i]);
dx.Update(bars[i]);
Assert.Equal(dx.DmPlus.Value, plusDm.Last.Value, 1e-12);
}
}
[Fact]
public void MinusDm_MatchesDx_DmMinus()
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var minusDm = new MinusDm(14);
var dx = new Dx(14);
for (int i = 0; i < bars.Count; i++)
{
minusDm.Update(bars[i]);
dx.Update(bars[i]);
Assert.Equal(dx.DmMinus.Value, minusDm.Last.Value, 1e-12);
}
}
// ═══════════════════════════════════════════════
// Determinism Tests
// ═══════════════════════════════════════════════
[Fact]
public void AllFour_Deterministic_SameSeedSameResult()
{
var gbm1 = new GBM(seed: 99);
var bars1 = gbm1.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var gbm2 = new GBM(seed: 99);
var bars2 = gbm2.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var pdi1 = new PlusDi(14);
var pdi2 = new PlusDi(14);
var mdi1 = new MinusDi(14);
var mdi2 = new MinusDi(14);
var pdm1 = new PlusDm(14);
var pdm2 = new PlusDm(14);
var mdm1 = new MinusDm(14);
var mdm2 = new MinusDm(14);
for (int i = 0; i < bars1.Count; i++)
{
pdi1.Update(bars1[i]);
pdi2.Update(bars2[i]);
mdi1.Update(bars1[i]);
mdi2.Update(bars2[i]);
pdm1.Update(bars1[i]);
pdm2.Update(bars2[i]);
mdm1.Update(bars1[i]);
mdm2.Update(bars2[i]);
}
Assert.Equal(pdi1.Last.Value, pdi2.Last.Value, 1e-12);
Assert.Equal(mdi1.Last.Value, mdi2.Last.Value, 1e-12);
Assert.Equal(pdm1.Last.Value, pdm2.Last.Value, 1e-12);
Assert.Equal(mdm1.Last.Value, mdm2.Last.Value, 1e-12);
}
}
@@ -0,0 +1,818 @@
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using OoplesFinance.StockIndicators.Enums;
using Skender.Stock.Indicators;
using TALib;
using QuanTAlib.Tests;
namespace QuanTAlib;
/// <summary>
/// Combined validation tests for PlusDi, MinusDi, PlusDm, MinusDm.
/// Cross-validates against TA-Lib, Skender, Ooples, and Dx equivalence.
/// </summary>
public sealed class DiDmValidationTests : IDisposable
{
private readonly ValidationTestData _data;
public DiDmValidationTests()
{
_data = new ValidationTestData();
}
public void Dispose()
{
_data.Dispose();
}
// ═══════════════════════════════════════════════
// TA-Lib Validation
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_MatchesTalib()
{
var indicator = new PlusDi(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] cData = _data.Bars.Close.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDI(hData, lData, cData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDILookback(14);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
[Fact]
public void MinusDi_MatchesTalib()
{
var indicator = new MinusDi(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] cData = _data.Bars.Close.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.MinusDI(hData, lData, cData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.MinusDILookback(14);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
[Fact]
public void PlusDm_MatchesTalib()
{
var indicator = new PlusDm(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDM(hData, lData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDMLookback(14);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
[Fact]
public void MinusDm_MatchesTalib()
{
var indicator = new MinusDm(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.MinusDM(hData, lData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.MinusDMLookback(14);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
// ═══════════════════════════════════════════════
// Skender Validation
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_MatchesSkender()
{
var indicator = new PlusDi(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
var skenderResults = _data.SkenderQuotes.GetAdx(14).ToList();
ValidationHelper.VerifyData(results, skenderResults, x => x.Pdi);
}
[Fact]
public void MinusDi_MatchesSkender()
{
var indicator = new MinusDi(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
var skenderResults = _data.SkenderQuotes.GetAdx(14).ToList();
ValidationHelper.VerifyData(results, skenderResults, x => x.Mdi);
}
// ═══════════════════════════════════════════════
// Dx Equivalence
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_ExactlyMatchesDx_DiPlus()
{
var indicator = new PlusDi(14);
var dx = new Dx(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
dx.Update(_data.Bars[i]);
Assert.Equal(dx.DiPlus.Value, indicator.Last.Value, 1e-12);
}
}
[Fact]
public void MinusDi_ExactlyMatchesDx_DiMinus()
{
var indicator = new MinusDi(14);
var dx = new Dx(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
dx.Update(_data.Bars[i]);
Assert.Equal(dx.DiMinus.Value, indicator.Last.Value, 1e-12);
}
}
[Fact]
public void PlusDm_ExactlyMatchesDx_DmPlus()
{
var indicator = new PlusDm(14);
var dx = new Dx(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
dx.Update(_data.Bars[i]);
Assert.Equal(dx.DmPlus.Value, indicator.Last.Value, 1e-12);
}
}
[Fact]
public void MinusDm_ExactlyMatchesDx_DmMinus()
{
var indicator = new MinusDm(14);
var dx = new Dx(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
dx.Update(_data.Bars[i]);
Assert.Equal(dx.DmMinus.Value, indicator.Last.Value, 1e-12);
}
}
// ═══════════════════════════════════════════════
// Self-Consistency: Batch == Streaming
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_BatchEqualsStreaming()
{
var batchResults = PlusDi.Batch(_data.Bars, 14);
var streaming = new PlusDi(14);
var streamResults = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
streamResults.Add(streaming.Update(_data.Bars[i]).Value);
}
Assert.Equal(streamResults.Count, batchResults.Count);
for (int i = 0; i < batchResults.Count; i++)
{
Assert.Equal(streamResults[i], batchResults.Values[i], 1e-9);
}
}
[Fact]
public void MinusDi_BatchEqualsStreaming()
{
var batchResults = MinusDi.Batch(_data.Bars, 14);
var streaming = new MinusDi(14);
var streamResults = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
streamResults.Add(streaming.Update(_data.Bars[i]).Value);
}
Assert.Equal(streamResults.Count, batchResults.Count);
for (int i = 0; i < batchResults.Count; i++)
{
Assert.Equal(streamResults[i], batchResults.Values[i], 1e-9);
}
}
[Fact]
public void PlusDm_BatchEqualsStreaming()
{
var batchResults = PlusDm.Batch(_data.Bars, 14);
var streaming = new PlusDm(14);
var streamResults = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
streamResults.Add(streaming.Update(_data.Bars[i]).Value);
}
Assert.Equal(streamResults.Count, batchResults.Count);
for (int i = 0; i < batchResults.Count; i++)
{
Assert.Equal(streamResults[i], batchResults.Values[i], 1e-9);
}
}
[Fact]
public void MinusDm_BatchEqualsStreaming()
{
var batchResults = MinusDm.Batch(_data.Bars, 14);
var streaming = new MinusDm(14);
var streamResults = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
streamResults.Add(streaming.Update(_data.Bars[i]).Value);
}
Assert.Equal(streamResults.Count, batchResults.Count);
for (int i = 0; i < batchResults.Count; i++)
{
Assert.Equal(streamResults[i], batchResults.Values[i], 1e-9);
}
}
// ═══════════════════════════════════════════════
// Multi-Period TALib Validation
// ═══════════════════════════════════════════════
[Theory]
[InlineData(7)]
[InlineData(21)]
[InlineData(28)]
public void PlusDi_MatchesTalib_VariousPeriods(int period)
{
var indicator = new PlusDi(period);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] cData = _data.Bars.Close.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDI(hData, lData, cData, 0..^0, outReal, out var outRange, period);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDILookback(period);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
[Theory]
[InlineData(7)]
[InlineData(21)]
[InlineData(28)]
public void MinusDi_MatchesTalib_VariousPeriods(int period)
{
var indicator = new MinusDi(period);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] cData = _data.Bars.Close.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.MinusDI(hData, lData, cData, 0..^0, outReal, out var outRange, period);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.MinusDILookback(period);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
[Theory]
[InlineData(7)]
[InlineData(21)]
[InlineData(28)]
public void PlusDm_MatchesTalib_VariousPeriods(int period)
{
var indicator = new PlusDm(period);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDM(hData, lData, 0..^0, outReal, out var outRange, period);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDMLookback(period);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
[Theory]
[InlineData(7)]
[InlineData(21)]
[InlineData(28)]
public void MinusDm_MatchesTalib_VariousPeriods(int period)
{
var indicator = new MinusDm(period);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.MinusDM(hData, lData, 0..^0, outReal, out var outRange, period);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.MinusDMLookback(period);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
// ═══════════════════════════════════════════════
// Multi-Period Skender Validation
// ═══════════════════════════════════════════════
[Theory]
[InlineData(7)]
[InlineData(21)]
[InlineData(28)]
public void PlusDi_MatchesSkender_VariousPeriods(int period)
{
var indicator = new PlusDi(period);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
var skenderResults = _data.SkenderQuotes.GetAdx(period).ToList();
ValidationHelper.VerifyData(results, skenderResults, x => x.Pdi);
}
[Theory]
[InlineData(7)]
[InlineData(21)]
[InlineData(28)]
public void MinusDi_MatchesSkender_VariousPeriods(int period)
{
var indicator = new MinusDi(period);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
var skenderResults = _data.SkenderQuotes.GetAdx(period).ToList();
ValidationHelper.VerifyData(results, skenderResults, x => x.Mdi);
}
// ═══════════════════════════════════════════════
// Determinism: Consistent Across Multiple Runs
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_ConsistentAcrossMultipleRuns()
{
var ind1 = new PlusDi(14);
var ind2 = new PlusDi(14);
var results1 = new List<double>();
var results2 = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
ind1.Update(_data.Bars[i]);
results1.Add(ind1.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
ind2.Update(_data.Bars[i]);
results2.Add(ind2.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
Assert.Equal(results1[i], results2[i], 1e-10);
}
}
[Fact]
public void MinusDi_ConsistentAcrossMultipleRuns()
{
var ind1 = new MinusDi(14);
var ind2 = new MinusDi(14);
var results1 = new List<double>();
var results2 = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
ind1.Update(_data.Bars[i]);
results1.Add(ind1.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
ind2.Update(_data.Bars[i]);
results2.Add(ind2.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
Assert.Equal(results1[i], results2[i], 1e-10);
}
}
[Fact]
public void PlusDm_ConsistentAcrossMultipleRuns()
{
var ind1 = new PlusDm(14);
var ind2 = new PlusDm(14);
var results1 = new List<double>();
var results2 = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
ind1.Update(_data.Bars[i]);
results1.Add(ind1.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
ind2.Update(_data.Bars[i]);
results2.Add(ind2.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
Assert.Equal(results1[i], results2[i], 1e-10);
}
}
[Fact]
public void MinusDm_ConsistentAcrossMultipleRuns()
{
var ind1 = new MinusDm(14);
var ind2 = new MinusDm(14);
var results1 = new List<double>();
var results2 = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
ind1.Update(_data.Bars[i]);
results1.Add(ind1.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
ind2.Update(_data.Bars[i]);
results2.Add(ind2.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
Assert.Equal(results1[i], results2[i], 1e-10);
}
}
// ═══════════════════════════════════════════════
// Non-Negative Output Validation
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_OutputIsNonNegative()
{
var indicator = new PlusDi(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
Assert.True(indicator.Last.Value >= 0, $"PlusDi output at bar {i} was {indicator.Last.Value}");
}
}
[Fact]
public void MinusDi_OutputIsNonNegative()
{
var indicator = new MinusDi(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
Assert.True(indicator.Last.Value >= 0, $"MinusDi output at bar {i} was {indicator.Last.Value}");
}
}
[Fact]
public void PlusDm_OutputIsNonNegative()
{
var indicator = new PlusDm(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
Assert.True(indicator.Last.Value >= 0, $"PlusDm output at bar {i} was {indicator.Last.Value}");
}
}
[Fact]
public void MinusDm_OutputIsNonNegative()
{
var indicator = new MinusDm(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
Assert.True(indicator.Last.Value >= 0, $"MinusDm output at bar {i} was {indicator.Last.Value}");
}
}
// ═══════════════════════════════════════════════
// DI values bounded 0-100
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_OutputBounded0To100()
{
var indicator = new PlusDi(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
double val = indicator.Last.Value;
if (i >= 14)
{
Assert.True(val >= 0 && val <= 100, $"PlusDi at bar {i} was {val}, expected [0,100]");
}
}
}
[Fact]
public void MinusDi_OutputBounded0To100()
{
var indicator = new MinusDi(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
double val = indicator.Last.Value;
if (i >= 14)
{
Assert.True(val >= 0 && val <= 100, $"MinusDi at bar {i} was {val}, expected [0,100]");
}
}
}
// ═══════════════════════════════════════════════
// Different Periods Produce Different Results
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_DifferentPeriods_ProduceDifferentResults()
{
var short14 = new PlusDi(7);
var long28 = new PlusDi(28);
for (int i = 0; i < _data.Bars.Count; i++)
{
short14.Update(_data.Bars[i]);
long28.Update(_data.Bars[i]);
}
Assert.NotEqual(short14.Last.Value, long28.Last.Value);
}
[Fact]
public void MinusDi_DifferentPeriods_ProduceDifferentResults()
{
var short14 = new MinusDi(7);
var long28 = new MinusDi(28);
for (int i = 0; i < _data.Bars.Count; i++)
{
short14.Update(_data.Bars[i]);
long28.Update(_data.Bars[i]);
}
Assert.NotEqual(short14.Last.Value, long28.Last.Value);
}
[Fact]
public void PlusDm_DifferentPeriods_ProduceDifferentResults()
{
var short14 = new PlusDm(7);
var long28 = new PlusDm(28);
for (int i = 0; i < _data.Bars.Count; i++)
{
short14.Update(_data.Bars[i]);
long28.Update(_data.Bars[i]);
}
Assert.NotEqual(short14.Last.Value, long28.Last.Value);
}
[Fact]
public void MinusDm_DifferentPeriods_ProduceDifferentResults()
{
var short14 = new MinusDm(7);
var long28 = new MinusDm(28);
for (int i = 0; i < _data.Bars.Count; i++)
{
short14.Update(_data.Bars[i]);
long28.Update(_data.Bars[i]);
}
Assert.NotEqual(short14.Last.Value, long28.Last.Value);
}
// ═══════════════════════════════════════════════
// OoplesFinance Structural Validation
// ═══════════════════════════════════════════════
[Fact]
public void DiDm_MatchesOoples_Structural()
{
// OoplesFinance.CalculateAverageDirectionalIndex produces Di+/Di- as part of ADX
var ooplesData = _data.SkenderQuotes
.Select(q => new TickerData
{
Date = q.Date,
Open = (double)q.Open,
High = (double)q.High,
Low = (double)q.Low,
Close = (double)q.Close,
Volume = (double)q.Volume
})
.ToList();
var stockData = new StockData(ooplesData);
var adxResults = stockData.CalculateAverageDirectionalIndex(MovingAvgType.WildersSmoothingMethod, 14);
// Verify the Ooples ADX calculation produces finite DI values
var allValues = adxResults.OutputValues.Values.SelectMany(v => v).ToList();
int finiteCount = allValues.Count(v => double.IsFinite(v));
Assert.True(finiteCount > 100, $"Expected >100 finite Ooples DI/DM values, got {finiteCount}");
}
// ═══════════════════════════════════════════════
// Batch Matches TALib
// ═══════════════════════════════════════════════
[Fact]
public void PlusDi_BatchMatchesTalib()
{
var batchResults = PlusDi.Batch(_data.Bars, 14);
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] cData = _data.Bars.Close.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDI(hData, lData, cData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDILookback(14);
ValidationHelper.VerifyData(batchResults.Select(x => x.Value).ToList(), outReal, outRange, lookback);
}
[Fact]
public void MinusDi_BatchMatchesTalib()
{
var batchResults = MinusDi.Batch(_data.Bars, 14);
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] cData = _data.Bars.Close.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.MinusDI(hData, lData, cData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.MinusDILookback(14);
ValidationHelper.VerifyData(batchResults.Select(x => x.Value).ToList(), outReal, outRange, lookback);
}
[Fact]
public void PlusDm_BatchMatchesTalib()
{
var batchResults = PlusDm.Batch(_data.Bars, 14);
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDM(hData, lData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDMLookback(14);
ValidationHelper.VerifyData(batchResults.Select(x => x.Value).ToList(), outReal, outRange, lookback);
}
[Fact]
public void MinusDm_BatchMatchesTalib()
{
var batchResults = MinusDm.Batch(_data.Bars, 14);
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.MinusDM(hData, lData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.MinusDMLookback(14);
ValidationHelper.VerifyData(batchResults.Select(x => x.Value).ToList(), outReal, outRange, lookback);
}
}
@@ -0,0 +1,73 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class PlusDiIndicatorTests
{
[Fact]
public void PlusDiIndicator_Constructor_SetsDefaults()
{
var indicator = new PlusDiIndicator();
Assert.Equal(14, indicator.Period);
Assert.True(indicator.ShowColdValues);
Assert.Equal("+DI - Plus Directional Indicator", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void PlusDiIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new PlusDiIndicator { Period = 20 };
Assert.Equal(0, PlusDiIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void PlusDiIndicator_Initialize_CreatesInternal()
{
var indicator = new PlusDiIndicator { Period = 14 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void PlusDiIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new PlusDiIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double value = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(value));
}
[Fact]
public void PlusDiIndicator_ShortName_IsCorrect()
{
var indicator = new PlusDiIndicator { Period = 20 };
Assert.Equal("+DI 20", indicator.ShortName);
}
[Fact]
public void PlusDiIndicator_SourceCodeLink_IsValid()
{
var indicator = new PlusDiIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase);
Assert.Contains("PlusDi.Quantower.cs", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase);
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class PlusDiIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 14;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private PlusDi _plusDi = null!;
private readonly LineSeries _plusDiSeries;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"+DI {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/dynamics/plusdi/PlusDi.Quantower.cs";
public PlusDiIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "+DI - Plus Directional Indicator";
Description = "Measures upward directional movement as a percentage of true range";
_plusDiSeries = new LineSeries(name: "+DI", color: Color.Green, width: 2, style: LineStyle.Solid);
AddLineSeries(_plusDiSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_plusDi = new PlusDi(Period);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TValue result = _plusDi.Update(this.GetInputBar(args), args.IsNewBar());
_plusDiSeries.SetValue(result.Value, _plusDi.IsHot, ShowColdValues);
}
}
@@ -0,0 +1,288 @@
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using OoplesFinance.StockIndicators.Enums;
using Skender.Stock.Indicators;
using TALib;
using QuanTAlib.Tests;
namespace QuanTAlib;
/// <summary>
/// Validation tests for PlusDi (+DI). Cross-validates against TA-Lib, Skender,
/// OoplesFinance, and internal Dx equivalence with multiple periods.
/// </summary>
public sealed class PlusDiValidationTests : IDisposable
{
private readonly ValidationTestData _data;
public PlusDiValidationTests()
{
_data = new ValidationTestData();
}
public void Dispose()
{
_data.Dispose();
}
// ═══════════════════════════════════════════════
// TA-Lib Validation
// ═══════════════════════════════════════════════
[Fact]
public void MatchesTalib()
{
var indicator = new PlusDi(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] cData = _data.Bars.Close.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDI(hData, lData, cData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDILookback(14);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
[Theory]
[InlineData(7)]
[InlineData(21)]
[InlineData(28)]
public void MatchesTalib_VariousPeriods(int period)
{
var indicator = new PlusDi(period);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] cData = _data.Bars.Close.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDI(hData, lData, cData, 0..^0, outReal, out var outRange, period);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDILookback(period);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
// ═══════════════════════════════════════════════
// Skender Validation
// ═══════════════════════════════════════════════
[Fact]
public void MatchesSkender()
{
var indicator = new PlusDi(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
var skenderResults = _data.SkenderQuotes.GetAdx(14).ToList();
ValidationHelper.VerifyData(results, skenderResults, x => x.Pdi);
}
[Theory]
[InlineData(7)]
[InlineData(21)]
[InlineData(28)]
public void MatchesSkender_VariousPeriods(int period)
{
var indicator = new PlusDi(period);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
var skenderResults = _data.SkenderQuotes.GetAdx(period).ToList();
ValidationHelper.VerifyData(results, skenderResults, x => x.Pdi);
}
// ═══════════════════════════════════════════════
// Dx Equivalence
// ═══════════════════════════════════════════════
[Fact]
public void ExactlyMatchesDx_DiPlus()
{
var indicator = new PlusDi(14);
var dx = new Dx(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
dx.Update(_data.Bars[i]);
Assert.Equal(dx.DiPlus.Value, indicator.Last.Value, 1e-12);
}
}
// ═══════════════════════════════════════════════
// OoplesFinance Structural Validation
// ═══════════════════════════════════════════════
[Fact]
public void MatchesOoples_Structural()
{
var ooplesData = _data.SkenderQuotes
.Select(q => new TickerData
{
Date = q.Date,
Open = (double)q.Open,
High = (double)q.High,
Low = (double)q.Low,
Close = (double)q.Close,
Volume = (double)q.Volume
})
.ToList();
var stockData = new StockData(ooplesData);
var adxResults = stockData.CalculateAverageDirectionalIndex(MovingAvgType.WildersSmoothingMethod, 14);
var allValues = adxResults.OutputValues.Values.SelectMany(v => v).ToList();
int finiteCount = allValues.Count(v => double.IsFinite(v));
Assert.True(finiteCount > 100, $"Expected >100 finite Ooples DI values, got {finiteCount}");
}
// ═══════════════════════════════════════════════
// Self-Consistency: Batch == Streaming
// ═══════════════════════════════════════════════
[Fact]
public void BatchEqualsStreaming()
{
var batchResults = PlusDi.Batch(_data.Bars, 14);
var streaming = new PlusDi(14);
var streamResults = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
streamResults.Add(streaming.Update(_data.Bars[i]).Value);
}
Assert.Equal(streamResults.Count, batchResults.Count);
for (int i = 0; i < batchResults.Count; i++)
{
Assert.Equal(streamResults[i], batchResults.Values[i], 1e-9);
}
}
[Fact]
public void BatchMatchesTalib()
{
var batchResults = PlusDi.Batch(_data.Bars, 14);
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] cData = _data.Bars.Close.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDI(hData, lData, cData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDILookback(14);
ValidationHelper.VerifyData(batchResults.Select(x => x.Value).ToList(), outReal, outRange, lookback);
}
// ═══════════════════════════════════════════════
// Determinism
// ═══════════════════════════════════════════════
[Fact]
public void ConsistentAcrossMultipleRuns()
{
var ind1 = new PlusDi(14);
var ind2 = new PlusDi(14);
var results1 = new List<double>();
var results2 = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
ind1.Update(_data.Bars[i]);
results1.Add(ind1.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
ind2.Update(_data.Bars[i]);
results2.Add(ind2.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
Assert.Equal(results1[i], results2[i], 1e-10);
}
}
// ═══════════════════════════════════════════════
// Output Range Validation
// ═══════════════════════════════════════════════
[Fact]
public void OutputIsNonNegative()
{
var indicator = new PlusDi(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
Assert.True(indicator.Last.Value >= 0, $"+DI output at bar {i} was {indicator.Last.Value}");
}
}
[Fact]
public void OutputBounded0To100()
{
var indicator = new PlusDi(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
double val = indicator.Last.Value;
if (i >= 14)
{
Assert.True(val >= 0 && val <= 100, $"+DI at bar {i} was {val}, expected [0,100]");
}
}
}
// ═══════════════════════════════════════════════
// Different Periods Produce Different Results
// ═══════════════════════════════════════════════
[Fact]
public void DifferentPeriods_ProduceDifferentResults()
{
var short7 = new PlusDi(7);
var long28 = new PlusDi(28);
for (int i = 0; i < _data.Bars.Count; i++)
{
short7.Update(_data.Bars[i]);
long28.Update(_data.Bars[i]);
}
Assert.NotEqual(short7.Last.Value, long28.Last.Value);
}
}
+91 -21
View File
@@ -1,30 +1,100 @@
# PLUS_DI: Plus Directional Indicator
Measures upward directional movement strength as a percentage (0-100).
> *+DI isolates upward directional thrust as a fraction of true range — the bullish arm of Wilder's directional system.*
## Introduction
The Plus Directional Indicator (+DI) measures the strength of upward price movement relative to the true range. It is one of the components of the Directional Movement System developed by J. Welles Wilder Jr.
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
| **Inputs** | OHLCV bar (TBar) |
| **Parameters** | `period` (default 14) |
| **Outputs** | Single series |
| **Output range** | 0 to 100 |
| **Warmup** | `period` bars |
| **PineScript** | [plusdi.pine](plusdi.pine) |
When +DI is rising, upward price pressure is increasing. When +DI crosses above -DI, it signals a potential bullish trend. The +DI line is commonly plotted alongside -DI to visualize directional balance.
- The Plus Directional Indicator measures the strength of upward price movement relative to true range.
- Parameterized by `period` (default 14).
- Output range: 0 to 100.
- Requires `period` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Dx equivalence.
## Calculation
+DI = Smoothed(+DM) / Smoothed(TR) × 100
The Plus Directional Indicator (+DI) is one component of J. Welles Wilder Jr.'s Directional Movement System. It quantifies the fraction of recent true range attributable to upward price extension. The computation smooths both +DM (plus directional movement) and TR (true range) with Wilder's RMA ($\alpha = 1/N$), then divides: $+DI = 100 \times \text{Smooth}(+DM) / \text{Smooth}(TR)$. When +DI rises, upward price pressure is increasing. When +DI crosses above -DI, it signals a potential bullish trend. The +DI line is commonly plotted alongside -DI to visualize directional balance.
Where:
- +DM (Plus Directional Movement) = max(High - PrevHigh, 0) when High - PrevHigh > PrevLow - Low, else 0
- TR (True Range) = max(High - Low, |High - PrevClose|, |Low - PrevClose|)
- Smoothing uses Wilder's method: Smooth = Smooth - Smooth/N + Input
## Historical Context
## Parameters
| Parameter | Default | Range | Description |
| :--- | :--- | :--- | :--- |
| Period | 14 | 2-∞ | Wilder smoothing period |
J. Welles Wilder Jr. introduced the Directional Movement System in *New Concepts in Technical Trading Systems* (1978). The system decomposes price range into directional components. +DI and -DI are the normalized indicators from which DX and ADX are derived. While most traders focus on ADX for trend strength, +DI and -DI remain essential for determining trend *direction* — a bullish signal occurs when +DI crosses above -DI, bearish when -DI crosses above +DI.
## Interpretation
- **Rising +DI:** Strengthening upward movement
- **+DI > -DI:** Bulls dominate; potential uptrend
- **+DI crossover above -DI:** Bullish signal
- **High +DI (>40):** Strong upward momentum
## Architecture & Physics
## References
- Wilder, J. Welles Jr. "New Concepts in Technical Trading Systems" (1978)
### 1. Plus Directional Movement
$$\text{UpMove} = H_t - H_{t-1}, \quad \text{DownMove} = L_{t-1} - L_t$$
$$+DM = \begin{cases} \text{UpMove} & \text{if UpMove} > \text{DownMove and UpMove} > 0 \\ 0 & \text{otherwise} \end{cases}$$
### 2. True Range
$$TR = \max(H_t - L_t,\; |H_t - C_{t-1}|,\; |L_t - C_{t-1}|)$$
### 3. Wilder Smoothing (RMA)
$$+DM_{\text{smooth}} = \text{RMA}(+DM, N), \quad TR_{\text{smooth}} = \text{RMA}(TR, N)$$
### 4. Plus Directional Indicator
$$+DI = 100 \times \frac{+DM_{\text{smooth}}}{TR_{\text{smooth}}}$$
When $TR_{\text{smooth}} = 0$ (no price movement), +DI = 0.
### 5. Complexity
- **Time:** $O(1)$ per bar — all RMA updates are recursive
- **Space:** $O(1)$ — scalar state only (delegates to Dx)
- **Warmup:** $N$ bars
## Mathematical Foundation
### Parameters
| Symbol | Parameter | Default | Constraint |
|--------|-----------|---------|------------|
| $N$ | period | 14 | $N \geq 2$ |
### Interpretation
| +DI Value | Signal |
|-----------|--------|
| Rising +DI | Strengthening upward movement |
| +DI > -DI | Bulls dominate; potential uptrend |
| +DI crossover above -DI | Bullish signal |
| High +DI (>40) | Strong upward momentum |
+DI measures directional *strength*, not absolute direction. Compare +DI vs -DI for directional bias: if $+DI > -DI$, the trend is up.
## Performance Profile
### Operation Count (Streaming Mode)
+DI is a thin wrapper around Dx. The per-bar cost is identical to Dx (one property extraction after Dx completes its update).
**Post-warmup steady state (per bar):**
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| Dx.Update (full pipeline) | 1 | 75 | 75 |
| Property extraction | 1 | 1 | 1 |
| **Total** | **2** | — | **~76 cycles** |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Exact Dx delegation; FMA-precise RMA smoothing |
| **Timeliness** | 7/10 | N-bar warmup; responds to bar-level changes |
| **Smoothness** | 7/10 | Single RMA layer; moderate noise suppression |
| **Noise Rejection** | 7/10 | Wilder smoothing filters transient spikes |
## Resources
- Wilder, J.W. — *New Concepts in Technical Trading Systems* (Trend Research, 1978)
- PineScript reference: `plusdi.pine` in indicator directory
+55
View File
@@ -0,0 +1,55 @@
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Plus Directional Indicator (+DI)", "+DI", overlay=false)
//@function Calculates +DI using Wilder's smoothing with compensated RMA
//@param period Number of bars used in the calculation
//@returns +DI value (0-100)
//@optimized Uses Wilder's smoothing (RMA) with warmup compensation for accurate values from bar 1
plusdi(simple int period) =>
if period <= 0
runtime.error("Period must be greater than 0")
float alpha = 1.0 / period
float beta = 1.0 - alpha
float tr = 0.0
float plus_dm = 0.0
if na(close[1])
tr := high - low
else
tr := math.max(high - low, math.max(math.abs(high - close[1]), math.abs(low - close[1])))
float upMove = high - high[1]
float downMove = low[1] - low
if upMove > downMove and upMove > 0
plus_dm := upMove
var bool warmup = true
var float e = 1.0
var float tr_ema = 0.0
var float tr_result = tr
var float plus_dm_ema = 0.0
var float plus_dm_result = plus_dm
tr_ema := alpha * (tr - tr_ema) + tr_ema
plus_dm_ema := alpha * (plus_dm - plus_dm_ema) + plus_dm_ema
if warmup
e *= beta
float c = 1.0 / (1.0 - e)
tr_result := c * tr_ema
plus_dm_result := c * plus_dm_ema
warmup := e > 1e-10
else
tr_result := tr_ema
plus_dm_result := plus_dm_ema
float plus_di = tr_result != 0.0 ? 100.0 * plus_dm_result / tr_result : 0.0
plus_di
// ---------- Main loop ----------
// Inputs
i_period = input.int(14, "Period", minval=1, tooltip="Number of bars used in the calculation")
// Calculation
plus_di = plusdi(i_period)
// Plot
plot(plus_di, "+DI", color=color.green, linewidth=2)
hline(25, "Threshold", color=color.gray, linestyle=hline.style_dashed)
@@ -0,0 +1,73 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class PlusDmIndicatorTests
{
[Fact]
public void PlusDmIndicator_Constructor_SetsDefaults()
{
var indicator = new PlusDmIndicator();
Assert.Equal(14, indicator.Period);
Assert.True(indicator.ShowColdValues);
Assert.Equal("+DM - Plus Directional Movement", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void PlusDmIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new PlusDmIndicator { Period = 20 };
Assert.Equal(0, PlusDmIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void PlusDmIndicator_Initialize_CreatesInternal()
{
var indicator = new PlusDmIndicator { Period = 14 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void PlusDmIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new PlusDmIndicator { Period = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double value = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(value));
}
[Fact]
public void PlusDmIndicator_ShortName_IsCorrect()
{
var indicator = new PlusDmIndicator { Period = 20 };
Assert.Equal("+DM 20", indicator.ShortName);
}
[Fact]
public void PlusDmIndicator_SourceCodeLink_IsValid()
{
var indicator = new PlusDmIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase);
Assert.Contains("PlusDm.Quantower.cs", indicator.SourceCodeLink, StringComparison.OrdinalIgnoreCase);
}
}
+51
View File
@@ -0,0 +1,51 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class PlusDmIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 14;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private PlusDm _plusDm = null!;
private readonly LineSeries _plusDmSeries;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"+DM {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/dynamics/plusdm/PlusDm.Quantower.cs";
public PlusDmIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "+DM - Plus Directional Movement";
Description = "Wilder-smoothed upward directional movement in price units";
_plusDmSeries = new LineSeries(name: "+DM", color: Color.Green, width: 2, style: LineStyle.Solid);
AddLineSeries(_plusDmSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_plusDm = new PlusDm(Period);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TValue result = _plusDm.Update(this.GetInputBar(args), args.IsNewBar());
_plusDmSeries.SetValue(result.Value, _plusDm.IsHot, ShowColdValues);
}
}
@@ -0,0 +1,231 @@
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using OoplesFinance.StockIndicators.Enums;
using Skender.Stock.Indicators;
using TALib;
using QuanTAlib.Tests;
namespace QuanTAlib;
/// <summary>
/// Validation tests for PlusDm (+DM). Cross-validates against TA-Lib,
/// OoplesFinance, and internal Dx equivalence with multiple periods.
/// Note: Skender does not expose DM values directly; only DI values via GetAdx().
/// </summary>
public sealed class PlusDmValidationTests : IDisposable
{
private readonly ValidationTestData _data;
public PlusDmValidationTests()
{
_data = new ValidationTestData();
}
public void Dispose()
{
_data.Dispose();
}
// ═══════════════════════════════════════════════
// TA-Lib Validation
// ═══════════════════════════════════════════════
[Fact]
public void MatchesTalib()
{
var indicator = new PlusDm(14);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDM(hData, lData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDMLookback(14);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
[Theory]
[InlineData(7)]
[InlineData(21)]
[InlineData(28)]
public void MatchesTalib_VariousPeriods(int period)
{
var indicator = new PlusDm(period);
var results = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
results.Add(indicator.Last.Value);
}
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDM(hData, lData, 0..^0, outReal, out var outRange, period);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDMLookback(period);
ValidationHelper.VerifyData(results, outReal, outRange, lookback);
}
// ═══════════════════════════════════════════════
// Dx Equivalence
// ═══════════════════════════════════════════════
[Fact]
public void ExactlyMatchesDx_DmPlus()
{
var indicator = new PlusDm(14);
var dx = new Dx(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
dx.Update(_data.Bars[i]);
Assert.Equal(dx.DmPlus.Value, indicator.Last.Value, 1e-12);
}
}
// ═══════════════════════════════════════════════
// OoplesFinance Structural Validation
// ═══════════════════════════════════════════════
[Fact]
public void MatchesOoples_Structural()
{
var ooplesData = _data.SkenderQuotes
.Select(q => new TickerData
{
Date = q.Date,
Open = (double)q.Open,
High = (double)q.High,
Low = (double)q.Low,
Close = (double)q.Close,
Volume = (double)q.Volume
})
.ToList();
var stockData = new StockData(ooplesData);
var adxResults = stockData.CalculateAverageDirectionalIndex(MovingAvgType.WildersSmoothingMethod, 14);
var allValues = adxResults.OutputValues.Values.SelectMany(v => v).ToList();
int finiteCount = allValues.Count(v => double.IsFinite(v));
Assert.True(finiteCount > 100, $"Expected >100 finite Ooples ADX/DI values, got {finiteCount}");
}
// ═══════════════════════════════════════════════
// Self-Consistency: Batch == Streaming
// ═══════════════════════════════════════════════
[Fact]
public void BatchEqualsStreaming()
{
var batchResults = PlusDm.Batch(_data.Bars, 14);
var streaming = new PlusDm(14);
var streamResults = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
streamResults.Add(streaming.Update(_data.Bars[i]).Value);
}
Assert.Equal(streamResults.Count, batchResults.Count);
for (int i = 0; i < batchResults.Count; i++)
{
Assert.Equal(streamResults[i], batchResults.Values[i], 1e-9);
}
}
[Fact]
public void BatchMatchesTalib()
{
var batchResults = PlusDm.Batch(_data.Bars, 14);
double[] hData = _data.Bars.High.Select(x => x.Value).ToArray();
double[] lData = _data.Bars.Low.Select(x => x.Value).ToArray();
double[] outReal = new double[_data.Bars.Count];
var retCode = Functions.PlusDM(hData, lData, 0..^0, outReal, out var outRange, 14);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
int lookback = Functions.PlusDMLookback(14);
ValidationHelper.VerifyData(batchResults.Select(x => x.Value).ToList(), outReal, outRange, lookback);
}
// ═══════════════════════════════════════════════
// Determinism
// ═══════════════════════════════════════════════
[Fact]
public void ConsistentAcrossMultipleRuns()
{
var ind1 = new PlusDm(14);
var ind2 = new PlusDm(14);
var results1 = new List<double>();
var results2 = new List<double>();
for (int i = 0; i < _data.Bars.Count; i++)
{
ind1.Update(_data.Bars[i]);
results1.Add(ind1.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
ind2.Update(_data.Bars[i]);
results2.Add(ind2.Last.Value);
}
for (int i = 0; i < _data.Bars.Count; i++)
{
Assert.Equal(results1[i], results2[i], 1e-10);
}
}
// ═══════════════════════════════════════════════
// Output Range Validation
// ═══════════════════════════════════════════════
[Fact]
public void OutputIsNonNegative()
{
var indicator = new PlusDm(14);
for (int i = 0; i < _data.Bars.Count; i++)
{
indicator.Update(_data.Bars[i]);
Assert.True(indicator.Last.Value >= 0, $"+DM output at bar {i} was {indicator.Last.Value}");
}
}
// ═══════════════════════════════════════════════
// Different Periods Produce Different Results
// ═══════════════════════════════════════════════
[Fact]
public void DifferentPeriods_ProduceDifferentResults()
{
var short7 = new PlusDm(7);
var long28 = new PlusDm(28);
for (int i = 0; i < _data.Bars.Count; i++)
{
short7.Update(_data.Bars[i]);
long28.Update(_data.Bars[i]);
}
Assert.NotEqual(short7.Last.Value, long28.Last.Value);
}
}
+83 -18
View File
@@ -1,27 +1,92 @@
# PLUS_DM: Plus Directional Movement
Wilder-smoothed upward directional movement in price units (≥0).
> *+DM captures the raw upward price extension, smoothed by Wilder's RMA — the building block before normalization to +DI.*
## Introduction
Plus Directional Movement (+DM) measures the magnitude of upward price movement, smoothed using Wilder's method. Unlike +DI which normalizes by true range to produce a percentage, +DM outputs raw smoothed values in price units.
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
| **Inputs** | OHLCV bar (TBar) |
| **Parameters** | `period` (default 14) |
| **Outputs** | Single series |
| **Output range** | ≥ 0 (price units) |
| **Warmup** | `period` bars |
| **PineScript** | [plusdm.pine](plusdm.pine) |
+DM captures when the current bar's high exceeds the previous bar's high by more than the previous bar's low exceeds the current bar's low. It is the raw building block of the Directional Movement System.
- Plus Directional Movement outputs the Wilder-smoothed upward directional movement in price units.
- Parameterized by `period` (default 14).
- Output range: ≥ 0 (price units, scales with instrument).
- Requires `period` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib and Dx equivalence.
## Calculation
+DM = max(High - PrevHigh, 0) when High - PrevHigh > PrevLow - Low, else 0
Plus Directional Movement (+DM) measures the magnitude of upward price movement, smoothed using Wilder's method. Unlike +DI which normalizes by true range to produce a percentage, +DM outputs raw smoothed values in price units. +DM captures when the current bar's high exceeds the previous bar's high by more than the previous bar's low exceeds the current bar's low. It is the raw building block of the Directional Movement System — the unnormalized signal before division by True Range converts it to +DI.
Smoothed using Wilder's method: Smooth = Smooth - Smooth/N + Input
## Historical Context
## Parameters
| Parameter | Default | Range | Description |
| :--- | :--- | :--- | :--- |
| Period | 14 | 2-∞ | Wilder smoothing period |
J. Welles Wilder Jr. designed the Directional Movement System as a pipeline in *New Concepts in Technical Trading Systems* (1978). +DM is the first computational stage: a binary event detector that fires when upward range expansion dominates. Wilder's insight was that direction should be measured by range *extension*, not by close-to-close returns. A bar that pushes to a new high by more than it pushes to a new low registers as positive directional movement. The smoothed +DM series shows how much upward thrust is being sustained over the lookback period, in absolute price units.
## Interpretation
- **Rising +DM:** Increasing upward price extension
- **+DM > -DM:** Upward movement exceeds downward movement
- **Zero +DM:** No upward directional movement on the bar
- Values are in price units and scale with the instrument
## Architecture & Physics
## References
- Wilder, J. Welles Jr. "New Concepts in Technical Trading Systems" (1978)
### 1. Plus Directional Movement (Raw)
$$\text{UpMove} = H_t - H_{t-1}, \quad \text{DownMove} = L_{t-1} - L_t$$
$$+DM = \begin{cases} \text{UpMove} & \text{if UpMove} > \text{DownMove and UpMove} > 0 \\ 0 & \text{otherwise} \end{cases}$$
Only one of +DM or -DM can be non-zero per bar — the dominant direction wins.
### 2. Wilder Smoothing (RMA)
$$+DM_{\text{smooth}} = \text{RMA}(+DM, N), \quad \alpha = 1/N$$
### 3. Complexity
- **Time:** $O(1)$ per bar — recursive RMA update
- **Space:** $O(1)$ — scalar state only (delegates to Dx)
- **Warmup:** $N$ bars
## Mathematical Foundation
### Parameters
| Symbol | Parameter | Default | Constraint |
|--------|-----------|---------|------------|
| $N$ | period | 14 | $N \geq 2$ |
### Interpretation
| +DM Behavior | Signal |
|--------------|--------|
| Rising +DM | Increasing upward price extension |
| +DM > -DM | Upward movement exceeds downward movement |
| Zero +DM | No upward directional movement on the bar |
| Values scale with instrument | Compare within same instrument only |
+DM values are in price units and scale with the instrument. They cannot be compared across different instruments without normalization (which is what +DI provides).
## Performance Profile
### Operation Count (Streaming Mode)
+DM is a thin wrapper around Dx. The per-bar cost is identical to Dx (one property extraction after Dx completes its update).
**Post-warmup steady state (per bar):**
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| Dx.Update (full pipeline) | 1 | 75 | 75 |
| Property extraction | 1 | 1 | 1 |
| **Total** | **2** | — | **~76 cycles** |
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Exact Dx delegation; FMA-precise RMA smoothing |
| **Timeliness** | 7/10 | N-bar warmup; responds to bar-level changes |
| **Smoothness** | 7/10 | Single RMA layer; moderate noise suppression |
| **Noise Rejection** | 7/10 | Wilder smoothing filters transient spikes |
## Resources
- Wilder, J.W. — *New Concepts in Technical Trading Systems* (Trend Research, 1978)
- PineScript reference: `plusdm.pine` in indicator directory
+44
View File
@@ -0,0 +1,44 @@
// Licensed under the Apache License, Version 2.0
// © mihakralj
//@version=6
indicator("Plus Directional Movement (+DM)", "+DM", overlay=false)
//@function Calculates Wilder-smoothed +DM using compensated RMA
//@param period Number of bars used in the calculation
//@returns Smoothed +DM value in price units (≥0)
//@optimized Uses Wilder's smoothing (RMA) with warmup compensation for accurate values from bar 1
plusdm(simple int period) =>
if period <= 0
runtime.error("Period must be greater than 0")
float alpha = 1.0 / period
float beta = 1.0 - alpha
float plus_dm = 0.0
if not na(high[1])
float upMove = high - high[1]
float downMove = low[1] - low
if upMove > downMove and upMove > 0
plus_dm := upMove
var bool warmup = true
var float e = 1.0
var float plus_dm_ema = 0.0
var float plus_dm_result = plus_dm
plus_dm_ema := alpha * (plus_dm - plus_dm_ema) + plus_dm_ema
if warmup
e *= beta
float c = 1.0 / (1.0 - e)
plus_dm_result := c * plus_dm_ema
warmup := e > 1e-10
else
plus_dm_result := plus_dm_ema
plus_dm_result
// ---------- Main loop ----------
// Inputs
i_period = input.int(14, "Period", minval=1, tooltip="Number of bars used in the calculation")
// Calculation
plus_dm_value = plusdm(i_period)
// Plot
plot(plus_dm_value, "+DM", color=color.green, linewidth=2)
+2 -29
View File
@@ -1,5 +1,7 @@
# QSTICK: Qstick Indicator
> *The average candlestick body reveals the market's true conviction.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -16,8 +18,6 @@
- Requires `period` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "The average candlestick body reveals the market's true conviction."
The Qstick indicator, developed by Tushar Chande, computes a moving average of the close-minus-open difference over a lookback period, quantifying whether bars are predominantly bullish or bearish. Positive values indicate closes above opens (buying pressure); negative values indicate closes below opens (selling pressure). It supports both SMA (O(N) space via ring buffer) and EMA (O(1) space) smoothing modes and requires TBar input for open/close access.
## Historical Context
@@ -61,33 +61,6 @@ EMA mode uses O(1) space but weights recent bars more heavily than SMA.
| period | int | 14 | > 0 | Lookback period for moving average |
| useEma | bool | false | — | Use EMA (true) or SMA (false) |
### Pseudo-code
```
QSTICK(bar, period=14, useEma=false):
diff = bar.Close - bar.Open
if useEma:
// EMA mode
alpha = 2.0 / (period + 1)
if count == 0:
ema_val = diff
else:
ema_val = FMA(alpha, diff - ema_val, ema_val) // alpha*(diff-ema)+ema
result = ema_val
else:
// SMA mode with ring buffer
if buffer is full:
running_sum -= buffer.oldest
buffer.add(diff)
running_sum += diff
result = running_sum / min(count, period)
return result
```
### Zero-Crossing Interpretation
| Condition | Meaning |
+2 -2
View File
@@ -1,5 +1,7 @@
# RAVI: Chande Range Action Verification Index
> *The simplest question in technical analysis is also the most important: is this market trending or not? RAVI answers it with two moving averages and a division.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -16,8 +18,6 @@
- Requires `longPeriod` bars (default 65) of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "The simplest question in technical analysis is also the most important: is this market trending or not? RAVI answers it with two moving averages and a division."
RAVI (Range Action Verification Index) measures trend strength by computing the absolute percentage divergence between a short-period SMA and a long-period SMA. Created by Tushar Chande and published in *Beyond Technical Analysis* (Wiley, 2001), the indicator classifies markets into trending (RAVI > 3%) and ranging (RAVI < 3%) regimes using a single threshold. With default parameters (short=7, long=65), RAVI requires 65 bars of warmup for the first valid reading. The core computation is three operations per bar in streaming mode: two running-sum updates and one division. No square roots, no exponentials, no recursion. The entire indicator reduces to normalized SMA spread, making it one of the cheapest dynamics classifiers available.
## Historical Context
+2 -42
View File
@@ -1,5 +1,7 @@
# SUPER: SuperTrend
> *It's not an indicator; it's a trailing stop with a marketing budget.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -16,8 +18,6 @@
- Requires `> period + 1` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "It's not an indicator; it's a trailing stop with a marketing budget."
SuperTrend is a trend-following overlay that uses ATR-scaled bands around the HL2 midpoint, switching between upper and lower bands based on close price breakouts. A ratchet mechanism prevents the active band from moving against the trend, creating a step-like trailing stop that adapts to volatility. The indicator is a two-state machine (bullish/bearish) with O(1) per-bar updates and zero allocations in the hot path.
## Historical Context
@@ -74,46 +74,6 @@ The output is the active stop level. Crossing the active band flips the state.
| atrPeriod | int | 10 | > 0 | ATR lookback period |
| multiplier | double | 3.0 | > 0 | ATR multiplier for band width |
### Pseudo-code
```
SUPERTREND(bar, atrPeriod=10, multiplier=3.0):
// ATR update (Wilder's smoothing or SMA)
atr = ATR.Update(bar, atrPeriod)
// Basic bands
hl2 = (bar.High + bar.Low) / 2
upper_basic = hl2 + multiplier * atr
lower_basic = hl2 - multiplier * atr
// Ratchet: upper can only decrease, lower can only increase
if prev_close <= prev_upper_final:
upper_final = min(upper_basic, prev_upper_final)
else:
upper_final = upper_basic
if prev_close >= prev_lower_final:
lower_final = max(lower_basic, prev_lower_final)
else:
lower_final = lower_basic
// State machine transition
if bar.Close > upper_final:
is_bullish = true
else if bar.Close < lower_final:
is_bullish = false
// else: retain previous state (hysteresis)
// Output active stop level
if is_bullish:
supertrend = lower_final
else:
supertrend = upper_final
return supertrend
```
### Band Behavior by State
| State | Active Band | Ratchet Direction | Flip Condition |
+2 -36
View File
@@ -1,5 +1,7 @@
# TTM_SQUEEZE: TTM Squeeze
> *Volatility compression is the market holding its breath before screaming.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -15,8 +17,6 @@
- Requires `Math.Max(Math.Max(bbPeriod, kcPeriod), momPeriod)` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "Volatility compression is the market holding its breath before screaming."
John Carter's TTM Squeeze detects low-volatility compression by comparing Bollinger Band width against Keltner Channel width: when BB fits inside KC, a "squeeze" is on, signaling imminent breakout. The momentum component uses linear regression of price deviation from the Donchian midline to indicate direction. The indicator outputs a boolean squeeze state plus a continuous momentum histogram, requiring BB(20,2.0) and KC(20,1.5) as default parameters with a combined warmup of 20 bars.
## Historical Context
@@ -85,40 +85,6 @@ The linear regression extracts the trend component of the deviation, filtering n
| kcLength | int | 20 | > 1 | Keltner Channel period |
| kcMult | double | 1.5 | > 0 | KC ATR multiplier |
### Pseudo-code
```
TTM_SQUEEZE(bar, bbLen=20, bbMult=2.0, kcLen=20, kcMult=1.5):
// Bollinger Bands
sma_val = SMA(close, bbLen)
stddev = StdDev(close, bbLen)
bb_upper = sma_val + bbMult * stddev
bb_lower = sma_val - bbMult * stddev
// Keltner Channel
ema_val = EMA(close, kcLen)
atr_val = ATR(bar, kcLen)
kc_upper = ema_val + kcMult * atr_val
kc_lower = ema_val - kcMult * atr_val
// Squeeze state
squeeze_on = (bb_lower > kc_lower) AND (bb_upper < kc_upper)
// Momentum via linear regression of deviation
highest_high = Highest(high, bbLen)
lowest_low = Lowest(low, bbLen)
midline = (highest_high + lowest_low) / 2
delta = close - (midline + sma_val) / 2
momentum = LinReg(delta, bbLen)
// Momentum direction
momentum_rising = momentum > prev_momentum
momentum_positive = momentum > 0
return (momentum, squeeze_on, momentum_rising, momentum_positive)
```
### Squeeze-Fire Signal
The critical trading signal occurs on the transition bar:
+2 -32
View File
@@ -1,5 +1,7 @@
# TTM_TREND: TTM Trend
> *The simplest trend indicator is the one you actually follow.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -16,8 +18,6 @@
- Requires `> 2` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "The simplest trend indicator is the one you actually follow."
John Carter's TTM Trend uses a fast EMA (default period 6) applied to typical price (HLC/3) to determine short-term trend direction via slope sign. Output is a ternary trend state: +1 (bullish, EMA rising), -1 (bearish, EMA falling), or 0 (neutral, EMA unchanged). The indicator requires only 2 bars warmup, runs at O(1) per bar with O(1) space, and produces zero allocations in the hot path.
## Historical Context
@@ -75,36 +75,6 @@ This percentage rate-of-change quantifies how aggressively the trend is moving.
|:----------|:-----|:--------|:-----------|:------------|
| period | int | 6 | > 0 | EMA lookback period (very fast by default) |
### Pseudo-code
```
TTM_TREND(bar, period=6):
tp = (bar.High + bar.Low + bar.Close) / 3
alpha = 2.0 / (period + 1)
if count == 0:
ema_val = tp
else:
ema_val = FMA(alpha, tp - ema_val, ema_val)
// Trend direction from slope sign
if count >= 1:
if ema_val > prev_ema:
trend = +1
else if ema_val < prev_ema:
trend = -1
else:
trend = 0
strength = abs(ema_val - prev_ema) / prev_ema * 100
prev_ema = ema_val
count += 1
return (ema_val, trend, strength)
```
### Period Selection
The default period of 6 makes TTM Trend extremely fast-reacting. The EMA half-life is approximately $\ln(2) / \ln(1 + 2/N) \approx 2.4$ bars for $N = 6$. This means the indicator responds within 2-3 bars of a price shift. Longer periods (12, 20) reduce whipsaws but delay detection. Carter's design intent was maximum responsiveness, with noise filtering delegated to companion indicators (Squeeze, Wave).
+2 -2
View File
@@ -1,5 +1,7 @@
# VHF: Vertical Horizontal Filter
> *Before you ask which way the market is going, ask whether it is going anywhere at all. VHF answers the second question with a ratio and a ruler.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -16,8 +18,6 @@
- Requires `period + 1` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "Before you ask which way the market is going, ask whether it is going anywhere at all. VHF answers the second question with a ratio and a ruler."
VHF (Vertical Horizontal Filter) measures trend strength by dividing the price range over $N$ periods by the total absolute bar-to-bar path distance over the same window. Created by Adam White and published in the August 1991 issue of *Futures* magazine, VHF produces a single positive value where higher readings indicate trending conditions and lower readings indicate choppy, range-bound markets. With the default period of 28, the indicator requires 29 close values for the first valid output. The core computation in streaming mode is O(1) per bar when implemented with deque-based min/max tracking and a running sum of absolute changes. No square roots, no exponentials, no recursion. VHF is one of the simplest and cheapest trend-strength classifiers available, requiring approximately 12 operations per bar at steady state.
## Historical Context
+2 -41
View File
@@ -1,5 +1,7 @@
# VORTEX: Vortex Indicator
> *When bulls and bears clash, the Vortex measures the violence.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Dynamic |
@@ -16,8 +18,6 @@
- Requires `period` bars of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "When bulls and bears clash, the Vortex measures the violence."
The Vortex Indicator measures upward and downward trend momentum by computing the ratio of positive and negative vortex movements to true range over a rolling window. VI+ captures the distance from current high to previous low (upward force); VI- captures the distance from current low to previous high (downward force). Both are normalized by summed true range, producing two lines that oscillate around 1.0. Crossovers signal trend changes. The implementation uses three ring buffers with running sums for O(1) streaming updates.
## Historical Context
@@ -81,45 +81,6 @@ This yields O(1) per-bar updates after the initial warmup fill.
|:----------|:-----|:--------|:-----------|:------------|
| period | int | 14 | > 0 | Rolling window for VM and TR sums |
### Pseudo-code
```
VORTEX(bar, period=14):
// Vortex movements (require previous bar)
vm_plus = abs(bar.High - prev_bar.Low)
vm_minus = abs(bar.Low - prev_bar.High)
// True range
tr = max(bar.High - bar.Low,
abs(bar.High - prev_bar.Close),
abs(bar.Low - prev_bar.Close))
// Ring buffer updates with running sums
if buffer_full:
sum_vm_plus -= vm_plus_buffer.oldest
sum_vm_minus -= vm_minus_buffer.oldest
sum_tr -= tr_buffer.oldest
vm_plus_buffer.add(vm_plus)
vm_minus_buffer.add(vm_minus)
tr_buffer.add(tr)
sum_vm_plus += vm_plus
sum_vm_minus += vm_minus
sum_tr += tr
// Vortex indicators
if sum_tr > 0:
vi_plus = sum_vm_plus / sum_tr
vi_minus = sum_vm_minus / sum_tr
else:
vi_plus = 0
vi_minus = 0
return (vi_plus, vi_minus)
```
### Reference Line at 1.0
The value 1.0 serves as a natural reference:
-2
View File
@@ -1,7 +1,5 @@
# Errors
> "All models are wrong. Error metrics tell you how wrong." Adapted from George Box
Error metrics and loss functions for model/strategy evaluation. All error indicators accept two input series (actual and predicted values) and compute rolling error metrics over a configurable period.
## Indicators
+2 -2
View File
@@ -1,5 +1,7 @@
# Huber: Huber Loss
> *The Goldilocks of loss functions: not too sensitive, not too robust, just right.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Error Metric |
@@ -16,8 +18,6 @@
- Requires 1 bar of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "The Goldilocks of loss functions: not too sensitive, not too robust, just right."
Huber Loss is a hybrid loss function that combines the best properties of Mean Squared Error (MSE) and Mean Absolute Error (MAE). For small errors, it behaves quadratically like MSE; for large errors, it behaves linearly like MAE.
## Historical Context
+2 -2
View File
@@ -1,5 +1,7 @@
# Log-Cosh: Logarithm of Hyperbolic Cosine Loss
> *The smooth operator that acts like L2 for small errors and L1 for large ones.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Error Metric |
@@ -16,8 +18,6 @@
- Requires 1 bar of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "The smooth operator that acts like L2 for small errors and L1 for large ones."
Log-Cosh Loss combines the best properties of L1 (absolute) and L2 (squared) error metrics through the logarithm of the hyperbolic cosine function. It provides smooth gradients everywhere while remaining robust to outliers.
## Historical Context
+2 -2
View File
@@ -1,5 +1,7 @@
# MAAPE: Mean Arctangent Absolute Percentage Error
> *When percentage errors need boundaries, arctangent provides the walls.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Error Metric |
@@ -16,8 +18,6 @@
- Requires 1 bar of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "When percentage errors need boundaries, arctangent provides the walls."
Mean Arctangent Absolute Percentage Error (MAAPE) transforms percentage errors through the arctangent function, naturally bounding the metric between 0 and π/2. This eliminates the unbounded nature of MAPE while preserving its scale-independence.
## Historical Context
+2 -2
View File
@@ -1,5 +1,7 @@
# MAE: Mean Absolute Error
> *When you need to know how wrong you are on average, without the drama of squared errors.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Error Metric |
@@ -16,8 +18,6 @@
- Requires 1 bar of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "When you need to know how wrong you are on average, without the drama of squared errors."
Mean Absolute Error (MAE) measures the average magnitude of errors in a set of predictions, without considering their direction. It represents the average of the absolute differences between actual and predicted values.
## Historical Context
+2 -2
View File
@@ -1,5 +1,7 @@
# MAPD: Mean Absolute Percentage Deviation
> *Like MAPE, but divides by what you predicted instead of what actually happened.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Error Metric |
@@ -16,8 +18,6 @@
- Requires 1 bar of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "Like MAPE, but divides by what you predicted instead of what actually happened."
Mean Absolute Percentage Deviation (MAPD) measures the average absolute percentage difference between actual and predicted values, using the predicted value as the denominator. This is the key difference from MAPE, which uses the actual value.
## Historical Context
+2 -2
View File
@@ -1,5 +1,7 @@
# MAPE: Mean Absolute Percentage Error
> *The metric that lets you compare apples to oranges, as long as you don't have any zeros.*
| Property | Value |
| ---------------- | -------------------------------- |
| **Category** | Error Metric |
@@ -16,8 +18,6 @@
- Requires 1 bar of warmup before first valid output (IsHot = true).
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
> "The metric that lets you compare apples to oranges, as long as you don't have any zeros."
Mean Absolute Percentage Error (MAPE) measures the average absolute percentage difference between actual and predicted values. It expresses accuracy as a percentage, making it scale-independent and easy to interpret.
## Historical Context

Some files were not shown because too many files have changed in this diff Show More