tala

Technical Analysis Library for Assets

tala is a TypeScript technical analysis library for OHLC price history. You build a chain of indicators with tala(), call .run(history) once, and get back a cloned array enriched with calculated fields. No mutation, no globals, zero runtime dependencies, and full CJS + ESM output.

TypeScript strict Zero dependencies CJS + ESM Tree-shakeable

npm · GitHub · Changelog

What this library covers

Category Indicators
Moving averages SMA, EMA, WEMA, ALMA, TRIX
Momentum MACD, RSI, CCI, ADX, Fisher Transform, Stochastic, Williams %R, Stochastic RSI
Volatility Bollinger Bands, ATR
Volume OBV, VWAP
Price levels Pivot Points, Fibonacci Retracement Levels
Signals MACD Cross, RSI Cross, CCI Cross, ALMA Cross, Fisher Cross

How to read these docs

If this is your first time, read in order: InstallQuick StartHow It Works. Each indicator section that follows is self-contained and includes the formula, a worked numeric example where helpful, and an interpretation guide.

Two conventions used everywhere. 1) Input is reverse-chronological: history[0] is the most recent bar. 2) When a bar does not have enough lookback history, the indicator field is set to 0 (or omitted for keyed outputs) instead of throwing.

Install

npm install @jimzandueta/tala

Quick Start

This example starts with a small OHLC price history array, runs a few indicators, and shows the resulting enriched values returned by .run().

1. Create a sample price history

The newest bar is at index 0 (see Overview for the convention).

import { tala } from '@jimzandueta/tala'

const priceHistory = [
  { date: '2026-01-05', open: 104, high: 110, low: 100, close: 105 },
  { date: '2026-01-04', open: 99,  high: 108, low: 98,  close: 102 },
  { date: '2026-01-03', open: 96,  high: 105, low: 95,  close: 98 },
  { date: '2026-01-02', open: 92,  high: 98,  low: 91,  close: 95 },
  { date: '2026-01-01', open: 88,  high: 94,  low: 87,  close: 90 }
]

2. Run indicators

The chain below calculates sma3, ema3, and rsi3 for each bar where enough lookback data exists.

const result = tala()
  .sma(3)
  .ema(3)
  .rsi(3)
  .run(priceHistory)

3. Read the enriched result

.run() returns a new enriched array. The original priceHistory array is not mutated.

result[0]
// {
 //   date: '2026-01-05',
 //   open: 104,
 //   high: 110,
 //   low: 100,
 //   close: 105,
 //   sma3: 101.66666666666667,
 //   ema3: 101.58333333333334,
 //   changeVal: 3,
 //   rsi3: 100
 // }
Why these values?
SMA(3): (105 + 102 + 98) / 3 = 101.67
EMA(3): seed at bar 2 is (98 + 95 + 90) / 3 = 94.33; then bar 1 = 98.17; then bar 0 = 101.58
RSI(3): every close-to-close change in this sample is positive, so losses are 0 and RSI resolves to 100

4. Full output shape

The resulting array keeps the same order as the input and appends calculated fields to each entry.

[
  {
    date: '2026-01-05',
    close: 105,
    sma3: 101.66666666666667,
    ema3: 101.58333333333334,
    changeVal: 3,
    rsi3: 100
  },
  {
    date: '2026-01-04',
    close: 102,
    sma3: 98.33333333333333,
    ema3: 98.16666666666667,
    changeVal: 4,
    rsi3: 100
  },
  {
    date: '2026-01-03',
    close: 98,
    sma3: 94.33333333333333,
    ema3: 94.33333333333333,
    changeVal: 3,
    rsi3: 0
  },
  {
    date: '2026-01-02',
    close: 95,
    sma3: 0,
    ema3: 0,
    changeVal: 5,
    rsi3: 0
  },
  {
    date: '2026-01-01',
    close: 90,
    sma3: 0,
    ema3: 0,
    changeVal: 0,
    rsi3: 0
  }
]

Structured output with signals

Use { structured: true } when you want the enriched history and grouped signal events returned separately.

const { history, signals } = tala()
  .sma(3)
  .rsi(3)
  .rsiCross()
  .run(priceHistory, { structured: true })

history[0].sma3
// 101.66666666666667

signals.rsiCross
// [
 //   {
 //     date: '2026-01-05',
 //     open: 104,
 //     high: 110,
 //     low: 100,
 //     close: 105,
 //     sma3: 101.66666666666667,
 //     changeVal: 3,
 //     rsi3: 100
 //   }
 // ]

How It Works

tala() returns a TalaChain. Each method (.sma(), .ema(), .macd(), ...) appends one operation to a queue and returns the chain so calls can be chained. Nothing is computed until .run(history) is called.

At .run() time, tala deep-clones the history, executes each queued operation in order, and writes its outputs onto every bar where enough lookback exists. Indicators that depend on others (for example, MACD reads two EMAs internally; ADX reads TR and DM through a WEMA) handle their own dependencies — you only chain what you actually want surfaced on the result.

Non-mutating. The original history array is never modified.
Reusable. Build a chain once and call .run() on any number of datasets.
Auto changeVal. .rsi() computes the close-to-close diff internally; you do not need to pre-process it.
Two output modes. Default returns the enriched array. Pass { structured: true } to .run() to also receive grouped cross-signal events under signals.

Simple Moving Average - .sma(period, priceKey?, setKey?)

Arithmetic mean of the last n closing prices. Writes sma{period} (e.g. sma14). Entries without enough history are set to 0.

Formula
$$\text{SMA}_n = \frac{1}{n}\sum_{i=0}^{n-1} C_i$$
$C_i$ = close at bar $i$, where index 0 is the most recent
Worked example - SMA(3) on 5 bars
Index Close sma3
0 (newest) 105 (105 + 102 + 98) / 3 = 101.67
1 102 (102 + 98 + 95) / 3 = 98.33
2 98 (98 + 95 + 90) / 3 = 94.33
3 95 0 (not enough history)
4 (oldest) 90 0
tala().sma(14).run(history)
// result[0].sma14 → number

Exponential Moving Average - .ema(period, offset?, priceKey?, setKey?)

Weighted average giving more importance to recent prices. Seeded from the SMA at the oldest valid bar, then propagated forward. Writes ema{period}.

Formula
$$k = \frac{2}{n + 1}$$ $$\text{EMA}_t = C_t \cdot k + \text{EMA}_{t-1} \cdot (1 - k)$$
Seed: $\text{EMA}_\text{seed} = \text{SMA}_n$ at the oldest valid bar
Worked example - EMA(3) on closes [105, 102, 98, 95, 90]
$k = 2 / (3 + 1) = 0.5$
Seed (bar 2): SMA of bars 2,3,4 = (98 + 95 + 90) / 3 = 94.33
Bar 1: 102 × 0.5 + 94.33 × 0.5 = 98.17
Bar 0: 105 × 0.5 + 98.17 × 0.5 = 101.58
result[0].ema3 → 101.58
tala().ema(12).run(history)
// result[0].ema12 → number

Wilder's EMA - .wema(period, offset, priceKey?, setKey?)

Same recursive structure as EMA but uses a smoothing factor of $1/n$ (half the speed of a standard EMA). Also called RMA or Wilder's Smoothing. Used internally by ADX. Writes wema{period}.

Formula
$$k = \frac{1}{n}$$ $$\text{WEMA}_t = \text{WEMA}_{t-1} \cdot (1 - k) + C_t \cdot k$$
EMA vs WEMA smoothing factor comparison (period = 14)
EMA(14): $k = 2/15 \approx 0.133$ - reacts faster to new prices
WEMA(14): $k = 1/14 \approx 0.071$ - roughly half the speed, less noise
After a sharp price spike, WEMA returns to baseline ~2× slower than EMA
tala().wema(14, 0).run(history)
// result[0].wema14 → number

ALMA - .alma(period?, sigma?, offset?, priceKey?)

Arnaud Legoux Moving Average. Uses a Gaussian (bell-curve) weight distribution so the smoothing peak can be positioned closer to recent bars, reducing lag without adding noise. Writes alma. Defaults: period=9, sigma=6, offset=0.85.

Formula
$$m = \lfloor \text{offset} \times \text{period} \rfloor, \quad s = \frac{\text{period}}{\sigma}$$ $$w_j = \exp\!\left(-\frac{(j - m)^2}{2\,s^2}\right), \quad j = 0 \ldots \text{period}{-}1$$ $$\text{ALMA} = \frac{\displaystyle\sum_{j} w_j\, C_j}{\displaystyle\sum_{j} w_j}$$
The Gaussian peak sits at bar $m$. Higher offset → peak closer to bar 0 (recent) → less lag but more noise. Higher sigma → wider bell → smoother output.
Tuning guide
offset = 0.85 (default): peak near the recent end — reactive but still smooth. Good general-purpose setting.
offset = 0.5: symmetric (peak in the middle) — behaves like a centered low-pass filter, smoother but laggier.
sigma small (e.g. 2): narrow bell — weights concentrated near $m$, closer to a short SMA at that offset.
sigma large (e.g. 6+): wide bell — closer to a uniform SMA. Combine with a high offset for the cleanest "recent-weighted" smooth.
tala().alma().run(history)           // period=9, sigma=6, offset=0.85
tala().alma(20, 6, 0.9).run(history)  // custom: period=20, less lag
// result[0].alma → number

TRIX - .trix(period?, priceKey?, setKey?)

Triple-smoothed EMA rate of change. Applies EMA three times to filter short-term noise, then outputs the 1-bar percentage change. Values oscillate around zero — positive = upward momentum. Writes trix. Default period=18.

Formula
$$\text{EMA}_1 = \text{EMA}(C,\, n), \quad \text{EMA}_2 = \text{EMA}(\text{EMA}_1,\, n), \quad \text{EMA}_3 = \text{EMA}(\text{EMA}_2,\, n)$$ $$\text{TRIX}_t = \frac{\text{EMA}_{3,t} - \text{EMA}_{3,t-1}}{\text{EMA}_{3,t-1}} \times 100$$
Interpretation guide
TRIX > 0: EMA3 rising - sustained upward momentum
TRIX crosses zero upward: buy signal (triple-filtered, low noise)
TRIX crosses zero downward: sell signal
Three levels of smoothing mean TRIX lags more than EMA or MACD, but produces far fewer false signals
tala().trix(18).run(history)
// result[0].trix → number (% rate-of-change of EMA3, oscillates ≈ 0)

MACD - .macd(periods?, options?, priceKey?, setKey?)

Moving Average Convergence Divergence. Measures momentum as the difference between a fast and slow EMA. Positive = fast EMA above slow EMA (bullish). Optionally writes signal (EMA of MACD) and histogram. Defaults: fast=12, slow=26, signalLength=9.

Formula
$$\text{MACD} = \text{EMA}(C,\, \text{fast}) - \text{EMA}(C,\, \text{slow})$$ $$\text{Signal} = \text{EMA}(\text{MACD},\, \text{signalLength})$$ $$\text{Histogram} = \text{MACD} - \text{Signal}$$
Interpretation guide
MACD > 0: fast EMA above slow EMA - bullish bias
MACD crosses Signal upward: potential buy signal
MACD crosses Signal downward: potential sell signal
Histogram growing: momentum accelerating in the current direction
Histogram shrinking toward zero: momentum fading - possible reversal ahead
tala()
  .macd(
    { fastPeriod: 12, slowPeriod: 26, signalLength: 9 },
    { includeSignal: true, includeHistogram: true }
  )
  .run(history)
// result[0].macd      → EMA12 − EMA26
// result[0].signal    → 9-period EMA of MACD
// result[0].histogram → MACD − Signal

RSI - .rsi(period?, changeKey?, setKey?)

Relative Strength Index. Measures speed and magnitude of price changes. Values 0-100; above 70 = overbought, below 30 = oversold. changeVal is computed automatically. Writes rsi{period}. Default period=14.

Formula - Wilder's smoothed RS
$$\Delta C_t = C_t - C_{t+1}$$ $$\text{Gain}_t = \max(\Delta C_t,\;0), \quad \text{Loss}_t = \max(-\Delta C_t,\;0)$$ $$\overline{G}_t = \frac{\overline{G}_{t-1}(n-1) + \text{Gain}_t}{n}, \quad \overline{L}_t = \frac{\overline{L}_{t-1}(n-1) + \text{Loss}_t}{n}$$ $$\text{RS} = \frac{\overline{G}}{\overline{L}}, \quad \text{RSI} = 100 - \frac{100}{1 + \text{RS}}$$
Seed: $\overline{G}$ and $\overline{L}$ are simple averages over the first $n$ bars
Worked example - RSI(3) on closes [105, 102, 98, 95, 90] (index 0 → newest)
Changes (close[i] − close[i+1]): +3, +4, +3, +5, N/A
Seed at bar 1 (oldest valid, uses bars 1,2,3): gains=[4,3,5]/3=4, losses=0
Bar 0: gain=3; $\overline{G} = (4 \times 2 + 3)/3 = 3.67$; $\overline{L} \approx 0$
RS → ∞, RSI → 100 (pure uptrend with no down-bars)
result[0].rsi3 → 100 (every bar was a gain - maximum bullish reading)
tala().rsi(14).run(history)
// result[0].rsi14 → 0-100

CCI - .cci(period?, constant?, priceKeys?, setKey?)

Commodity Channel Index. Measures how far the typical price deviates from its rolling mean, scaled by mean absolute deviation. Above +100 = overbought; below −100 = oversold. Writes cci. Defaults: period=20, constant=0.015.

Formula
$$\text{TP} = \frac{H + L + C}{3}$$ $$\overline{\text{TP}} = \text{SMA}(\text{TP},\, n)$$ $$\text{MD} = \frac{1}{n}\sum_{i=0}^{n-1}\bigl|\text{TP}_i - \overline{\text{TP}}\bigr|$$ $$\text{CCI} = \frac{\text{TP} - \overline{\text{TP}}}{0.015 \times \text{MD}}$$
The constant 0.015 scales output so that roughly 70-80% of values fall between ±100
Worked example - CCI(3), bar 0
Bar prices: H=[110,108,105], L=[100,98,95], C=[105,102,98]
TP values: (110+100+105)/3=105, (108+98+102)/3=102.67, (105+95+98)/3=99.33
$\overline{\text{TP}} = (105 + 102.67 + 99.33)/3 = $ 102.33
MD = (|105−102.33| + |102.67−102.33| + |99.33−102.33|) / 3 = (2.67+0.34+3.00)/3 = 2.00
CCI = (105 − 102.33) / (0.015 × 2.00) = 2.67 / 0.030 = 89.0
result[0].cci → 89.0 (elevated but below overbought threshold of +100)
tala().cci(20).run(history)
// result[0].cci → number (typically −200 to +200)

ADX - .adx(period?, priceKeys?, setKey?)

Average Directional Index. Measures trend strength (not direction) on a 0-100 scale. Below 20 = ranging; above 25 = strong trend. Pair with di+ / di- for direction. Internally uses WEMA smoothing of TR (true range) and +DM/-DM (directional movement). Default period=14.

Formula pipeline
$$\text{TR} = \max\!\bigl(H - L,\;|H - C_\text{prev}|,\;|L - C_\text{prev}|\bigr)$$ $$+\text{DM} = \begin{cases}H - H_\text{prev} & \text{if } H{-}H_\text{prev} > L_\text{prev}{-}L \text{ and } > 0 \\ 0 & \text{otherwise}\end{cases}$$ $$-\text{DM} = \begin{cases}L_\text{prev} - L & \text{if } L_\text{prev}{-}L > H{-}H_\text{prev} \text{ and } > 0 \\ 0 & \text{otherwise}\end{cases}$$ $$+\text{DI} = \frac{\text{WEMA}(+\text{DM},\,n)}{\text{WEMA}(\text{TR},\,n)} \times 100, \quad -\text{DI} = \frac{\text{WEMA}(-\text{DM},\,n)}{\text{WEMA}(\text{TR},\,n)} \times 100$$ $$\text{DX} = \frac{|{+}\text{DI} - {-}\text{DI}|}{|{+}\text{DI} + {-}\text{DI}|} \times 100, \quad \text{ADX} = \text{WEMA}(\text{DX},\,n)$$
Interpretation guide
ADX < 20: ranging market - trend-following strategies underperform
ADX 20-25: trend beginning to develop
ADX > 25: strong trend - use di+ vs di− for direction
di+ > di−: uptrend  |  di− > di+: downtrend
ADX falling from above 25: trend weakening
tala().adx(14).run(history)
// result[0].adx14  → 0-100 (trend strength)
// result[0]['di+'] → +DI (bullish directional pressure)
// result[0]['di-'] → -DI (bearish directional pressure)
// result[0].atr14  → Average True Range

Fisher Transform - .fisher(period?, priceKeys?, setKeys?)

Converts price into a Gaussian normal distribution using a Fisher transformation, making turning points sharper and easier to spot. Writes fisherTransform and fisherSignal (previous bar's transform). Default period=9.

Formula
$$\text{HL2} = \frac{H + L}{2}$$ $$\text{sto}_t = \frac{\text{HL2}_t - \min_n(\text{HL2})}{\max_n(\text{HL2}) - \min_n(\text{HL2})}$$ $$\text{fish}_t = \text{clamp}\!\left(0.33 \times 2(\text{sto}_t - 0.5) + 0.67\,\text{fish}_{t-1},\; -0.999,\; 0.999\right)$$ $$\text{Fisher}_t = 0.5\ln\!\left(\frac{1 + \text{fish}_t}{1 - \text{fish}_t}\right) + 0.5\,\text{Fisher}_{t-1}$$ $$\text{Signal}_t = \text{Fisher}_{t-1}$$
The natural log compresses price values toward ±∞ at extremes, amplifying turning-point signals
Interpretation guide
Fisher rising fast: building bullish momentum — the log transform makes turning points sharper than raw price.
Fisher crosses Signal upward: short-term reversal into upward momentum (Signal is the previous bar's Fisher value).
Fisher crosses Signal downward: short-term reversal into downward momentum.
Extreme readings (large positive or negative values): price is far from the recent range mid — historically a higher chance of mean-reverting.
tala().fisher().run(history)
// result[0].fisherTransform → number (sharp turning-point indicator)
// result[0].fisherSignal    → previous bar's fisherTransform

Stochastic Oscillator - .sts(period?, priceKeys?, setKey?)

Compares the current close to the high-low range over the lookback period. Values 0-100; above 80 = overbought, below 20 = oversold. Writes stsK (%K line) and stsD3 (3-period SMA of %K = %D line). Default period=14.

Formula
$$\%K = \frac{C - \text{Low}_n}{\text{High}_n - \text{Low}_n} \times 100$$ $$\%D = \text{SMA}(\%K,\; 3)$$
$\text{High}_n$ = highest high, $\text{Low}_n$ = lowest low over the last $n$ bars
Worked example - %K(3), bar 0
Last 3 bars: H=[110, 108, 105], L=[100, 98, 95], C=[105, 102, 98]
High₃ = 110  |  Low₃ = 95
%K = (105 − 95) / (110 − 95) × 100 = 10/15 × 100 = 66.67
result[0].stsK → 66.67 (mid-range - neither overbought nor oversold)
tala().sts().run(history)
// result[0].stsK  → %K (0-100)
// result[0].stsD3 → %D - 3-period SMA of %K

Williams %R - .williamsR(period?, priceKeys?, setKey?)

Inverse of the Stochastic %K — measures where the current close sits within the recent high-low range, negated. Range 0 to −1 (this library scales the standard −100 to 0 range by dividing by 100). Near 0 = overbought; near −1 = oversold. Default period=14.

Formula
$$\%R = -\frac{\text{High}_n - C}{\text{High}_n - \text{Low}_n}$$
Standard Williams %R = %R × 100 (range −100 to 0). This library uses the −1 to 0 form.
Worked example - Williams %R(3), bar 0
High₃ = 110, Low₃ = 95, Close = 105
%R = −(110 − 105) / (110 − 95) = −5 / 15 = −0.333
Reading: −0.333 is mid-range - not near overbought (0) or oversold (−1)
result[0].williamsR → −0.333
tala().williamsR().run(history)
// result[0].williamsR → 0 to −1  (0 = overbought, −1 = oversold)

Pivot Points - .pivotT(period?, priceKeys?)

Traditional pivot points computed from the previous period-bar chunk, then written onto the current chunk. This matches the standard pivot convention of using prior-period high, low, and close. Writes pp{period}, r1{period}, s1{period}, r2{period}, s2{period}. Default period=20.

Formula
$$\text{PP} = \frac{H_{\text{prev}} + L_{\text{prev}} + C_{\text{prev}}}{3}$$ $$R_1 = 2\,\text{PP} - L_{\text{prev}}, \quad S_1 = 2\,\text{PP} - H_{\text{prev}}$$ $$R_2 = \text{PP} + (H_{\text{prev}} - L_{\text{prev}}), \quad S_2 = \text{PP} - (H_{\text{prev}} - L_{\text{prev}})$$
In this implementation, $H_{\text{prev}}$ and $L_{\text{prev}}$ are the highest high and lowest low from the previous chunk, and $C_{\text{prev}}$ is the close of the first bar in that previous chunk.
Worked example - pivotT(3), current chunk uses prior chunk data
Assume bars 0-2 are the current chunk, and bars 3-5 are the previous chunk.
From the previous chunk: $H_{prev}$ = 110, $L_{prev}$ = 95, $C_{prev}$ = 105
PP = (110 + 95 + 105) / 3 = 103.33
R1 = 2 × 103.33 − 95 = 111.67
S1 = 2 × 103.33 − 110 = 96.67
R2 = 103.33 + 15 = 118.33  |  S2 = 103.33 − 15 = 88.33
bars 0, 1, and 2 each receive: pp3=103.33 | r13=111.67 | s13=96.67 | r23=118.33 | s23=88.33
tala().pivotT(20).run(history)
// result[0].pp20  → Pivot Point
// result[0].r120  → Resistance 1   result[0].s120  → Support 1
// result[0].r220  → Resistance 2   result[0].s220  → Support 2

Fibonacci Retracements - .fibRL(period?, priceKeys?)

Fibonacci-style levels derived from the pivot band. In this library they are computed by interpolating between s2{period} and r2{period} after pivot points are generated internally. This is a library-specific level construction, not the more common PP ± r × range notation. Default period=20.

Formula
$$p_1 = S_2, \quad p_2 = R_2$$ $$\text{fib}_r = p_2 - |p_2 - p_1| \times r, \quad r \in \{0.236,\; 0.382,\; 0.5,\; 0.618,\; 0.786\}$$
So the levels are placed across the full band from s2 to r2, descending from r2 as r increases.
Worked example - fibRL(3) from pivot levels
Using the pivot example above: S2 = 88.33, R2 = 118.33
Band width = |118.33 − 88.33| = 30.00
fib0.236 = 118.33 − 30 × 0.236 = 111.25
fib0.382 = 118.33 − 30 × 0.382 = 106.87
fib0.500 = 118.33 − 30 × 0.500 = 103.33
fib0.618 = 118.33 − 30 × 0.618 = 99.79
fib0.786 = 118.33 − 30 × 0.786 = 94.75
result[0]['fib0.618'] → 99.79
tala().fibRL(20).run(history)
// result[0]['fib0.236'] → number
// result[0]['fib0.382'] → number
// result[0]['fib0.5']   → midpoint
// result[0]['fib0.618'] → golden ratio level
// result[0]['fib0.786'] → number

Stochastic RSI - .stochRSI(period?, changeKey?, kSetKey?, dSetKey?)

Applies the Stochastic formula to the RSI series rather than to raw price. The K line measures where the current RSI sits within the RSI high/low range over the lookback window. Default period=14. changeVal is computed automatically if not already present.

Formula
$$\text{StochRSI}_K = \frac{RSI - RSI_{\min}}{RSI_{\max} - RSI_{\min}} \times 100$$ $$\text{StochRSI}_D = \text{SMA}_3(\text{StochRSI}_K)$$
When the RSI range is 0 (constant RSI), K is set to 0. D is a 3-bar simple moving average signal line.
Worked example
RSI window (5 bars): [62.5, 58.0, 70.0, 55.0, 65.0]
RSI min = 55.0, RSI max = 70.0, range = 15.0
K[0] = (62.5 − 55.0) / 15.0 × 100 = 50.00
tala().stochRSI(14).run(history)
// result[0].stochRSIK → K line (0–100)
// result[0].stochRSID3 → D line (3-bar SMA of K)

Bollinger Bands - .bb(period?, k?, priceKey?)

A volatility envelope centered on a simple moving average. The upper and lower bands are placed k population standard deviations above and below the mean. Default period=20, k=2, priceKey='close'.

Formula
$$\text{Mid} = \frac{1}{n}\sum_{i=0}^{n-1} p_i$$ $$\sigma = \sqrt{\frac{1}{n}\sum_{i=0}^{n-1}(p_i - \text{Mid})^2}$$ $$\text{Upper} = \text{Mid} + k\sigma, \quad \text{Lower} = \text{Mid} - k\sigma$$
Worked example (period=3, k=2)
Closes: [104, 102, 100] → mean = 102.000000
σ = √(8/3) ≈ 1.632993
Upper = 102 + 2 × 1.632993 = 105.265986
Lower = 102 − 2 × 1.632993 = 98.734014
tala().bb(20).run(history)
// result[0].bbUpper20 → upper band
// result[0].bbMid20   → middle band (SMA)
// result[0].bbLower20 → lower band

ATR (Average True Range) - .atr(period?, priceKeys?, setKey?)

Measures market volatility as the Wilder-smoothed average of the True Range. True Range is the greatest of: current high minus low, absolute distance from previous close to current high, and absolute distance from previous close to current low. Default period=14.

Formula
$$TR_i = \max(H_i - L_i,\;|H_i - C_{i+1}|,\;|L_i - C_{i+1}|)$$ $$ATR_i = \text{WEMA}_{\text{period}}(TR)$$
tala().atr(14).run(history)
// result[0].atr14 → ATR value
// result[0].tr    → True Range of each bar

OBV (On-Balance Volume) - .obv(priceKey?, volumeKey?, setKey?)

Cumulative running total of volume, signed by the direction of the price move. When price is up, volume is added; when down, volume is subtracted; when unchanged, OBV is carried forward. Missing volume is treated as 0.

Formula
$$OBV_i = OBV_{i+1} + \begin{cases} +V_i & \text{if } C_i > C_{i+1} \\ -V_i & \text{if } C_i < C_{i+1} \\ 0 & \text{otherwise} \end{cases}$$
Worked example
Bars (newest → oldest): C=102,V=200 | C=101,V=150 | C=103,V=100 | C=100,V=0 (seed)
OBV[seed=3] = 0
OBV[2] = 0 + 100 = 100 (103 > 100)
OBV[1] = 100 − 150 = −50 (101 < 103)
OBV[0] = −50 + 200 = 150 (102 > 101)
tala().obv().run(history)
// result[0].obv → cumulative OBV value

VWAP (Volume-Weighted Average Price) - .vwap(period?, priceKeys?, volumeKey?, setKey?)

Rolling volume-weighted average price computed over a sliding window. Each bar's weight is its volume; the price used is the Typical Price (H+L+C)/3. Missing volume is treated as 0, and the fallback is the plain TP of the anchor bar. Default period=14.

Formula
$$TP_i = \frac{H_i + L_i + C_i}{3}$$ $$VWAP = \frac{\sum_{j=i}^{i+n-1} TP_j \times V_j}{\sum_{j=i}^{i+n-1} V_j}$$
tala().vwap(14).run(history)
// result[0].vwap14 → VWAP value for the 14-bar window

Cross Signals

Cross signals identify the exact bars where one value crosses another value or threshold. They are useful for converting indicator output into discrete events such as bullish, bearish, overbought, or oversold signals.

Important: Cross signals do not predict the market. They only mark where a crossover happened based on the available indicator values.

What counts as a cross?

A cross happens when the relationship between two values changes from the previous bar to the current bar.

Bullish cross
$$A_{t-1} \le B_{t-1} \quad \text{and} \quad A_t > B_t$$
The first value moved from below or equal to the second value, then crossed above it.
Bearish cross
$$A_{t-1} \ge B_{t-1} \quad \text{and} \quad A_t < B_t$$
The first value moved from above or equal to the second value, then crossed below it.

Supported cross helpers

Method Purpose Typical signal
.macdCross() Detects when the MACD line crosses the MACD signal line. Momentum shift
.rsiCross() Detects RSI threshold crossings, commonly around 70 and 30. Overbought or oversold transition
.cciCross() Detects CCI crosses through a configurable level, commonly around 100 or -100. Momentum threshold break
.almaCross() Detects when price crosses the ALMA line. Price versus trend-line shift
.fisherCross() Detects when Fisher Transform crosses its signal line. Short-term reversal timing cue

Signal output shape

Signal arrays return the matching enriched price-history entries. That means each signal item keeps the original OHLC fields and includes whichever indicator fields were calculated before the signal helper ran.

type PriceHistoryEntry = {
  date?: string
  open: number
  high: number
  low: number
  close: number

  // Added by indicators depending on the chain
  sma20?: number
  sma50?: number
  ema12?: number
  ema26?: number
  macd?: number
  signal?: number
  histogram?: number
  rsi14?: number
  changeVal?: number

  // The object may include additional calculated fields
  // based on the indicators used in your chain.
}
Key point: every array under signals contains enriched bars, not separate tiny event objects.

MACD cross — .macdCross()

.macdCross() is used after calculating MACD with a signal line. It checks when macd crosses above or below signal.

Interpretation
Bullish MACD cross: macd crosses above signal. This usually means upside momentum is increasing.
Bearish MACD cross: macd crosses below signal. This usually means upside momentum is weakening or downside momentum is increasing.
Histogram confirmation: The histogram moves above or below zero because histogram = macd - signal.
const { history, signals } = tala()
  .macd(
    { fastPeriod: 12, slowPeriod: 26, signalLength: 9 },
    { includeSignal: true, includeHistogram: true }
  )
  .macdCross()
  .run(priceHistory, { structured: true })

signals.macdCross
// [
 //   {
 //     date: '2026-01-18',
 //     open: 118,
 //     high: 124,
 //     low: 116,
 //     close: 123,
 //     ema12: 116.42,
 //     ema26: 113.08,
 //     macd: 3.34,
 //     signal: 2.91,
 //     histogram: 0.43
 //   }
 // ]

RSI cross — .rsiCross()

.rsiCross() is used after calculating RSI. It is commonly used to detect crosses around overbought and oversold thresholds.

Threshold Meaning Common interpretation
70 Overbought zone RSI crossing down from above 70 may suggest momentum cooling.
30 Oversold zone RSI crossing up from below 30 may suggest downside pressure is easing.
50 Midline RSI crossing above 50 can indicate bullish momentum bias; crossing below 50 can indicate bearish bias.
const { history, signals } = tala()
  .rsi(14)
  .rsiCross()
  .run(priceHistory, { structured: true })

signals.rsiCross
// [
 //   {
 //     date: '2026-01-12',
 //     open: 104,
 //     high: 108,
 //     low: 99,
 //     close: 101,
 //     changeVal: -5,
 //     rsi14: 68.42
 //   }
 // ]

Additional supported helpers

tala also includes cross helpers for CCI, ALMA, and Fisher Transform. These follow the same structured-output pattern as MACD and RSI crosses: calculate the indicator first, then add the cross helper at the end of the chain.

Method Prerequisite indicator Event logic
.cciCross() .cci() CCI crossing a level such as 100.
.almaCross() .alma() Close price crossing the ALMA line.
.fisherCross() .fisher() Fisher Transform crossing its signal line.
const { history, signals } = tala()
  .cci(20)
  .alma(20, 6, 0.9)
  .fisher()
  .cciCross()
  .almaCross(20, 6, 0.9)
  .fisherCross()
  .run(priceHistory, { structured: true })

signals.cciCross
// [
 //   {
 //     date: '2026-01-20',
 //     open: 126,
 //     high: 131,
 //     low: 124,
 //     close: 130,
 //     cci: 114.28
 //   }
 // ]

signals.almaCross
signals.fisherCross

Structured output

When .run() is called with { structured: true }, tala returns both the enriched price history and a grouped signals object.

const output = tala()
  .sma(14)
  .rsi(14)
  .macd({ fastPeriod: 12, slowPeriod: 26, signalLength: 9 }, { includeSignal: true })
  .macdCross()
  .rsiCross()
  .run(priceHistory, { structured: true })

output.history
// Enriched PriceHistoryEntry[] with calculated indicator fields

output.signals
// {
 //   macdCross: [
 //     {
 //       date: '2026-01-18',
 //       open: 118,
 //       high: 124,
 //       low: 116,
 //       close: 123,
 //       sma14: 116.86,
 //       rsi14: 61.74,
 //       macd: 3.34,
 //       signal: 2.91
 //     }
 //   ],
 //   rsiCross: [
 //     {
 //       date: '2026-01-12',
 //       open: 104,
 //       high: 108,
 //       low: 99,
 //       close: 101,
 //       sma14: 109.42,
 //       rsi14: 68.42,
 //       macd: 1.88,
 //       signal: 2.04
 //     }
 //   ]
 // }

Unstructured output

When structured is omitted or set to false, .run() returns the enriched history array directly.

const enriched = tala()
  .rsi(14)
  .rsiCross()
  .run(priceHistory)

enriched[0]
// Most recent enriched bar

Example signal workflow

A practical workflow is to calculate indicators first, then add cross signals at the end of the chain.

const chain = tala()
  .sma(20)
  .sma(50)
  .rsi(14)
  .macd(
    { fastPeriod: 12, slowPeriod: 26, signalLength: 9 },
    { includeSignal: true, includeHistogram: true }
  )
  .rsiCross()
  .macdCross()

const { history, signals } = chain.run(priceHistory, { structured: true })

Best practices

Calculate before detecting. Add the indicator method before adding its related cross helper. For example, call .macd(...) before .macdCross().
Use enough history. Cross signals need at least two valid bars after the indicator has enough lookback data.
Avoid single-signal trading logic. A cross event is usually more useful when combined with trend strength, price levels, or risk rules.

Common Parameters

Most indicators accept the same set of optional parameters for customising input fields and output keys. They make tala work with arbitrary candle shapes (e.g. c/h/l instead of close/high/low) and let you write multiple instances of the same indicator into different keys.

Parameter Type Default Purpose
priceKey string 'close' Single input field. Used by indicators that read one price per bar (SMA, EMA, WEMA, ALMA, TRIX, MACD, histogram).
priceKeys PriceKeys { c: 'close', h: 'high', l: 'low' } OHLC field-name mapping. Used by indicators that need high/low/close (CCI, ADX, Fisher, Stochastic, Williams %R, pivotT, fibRL, TR, DM, TP).
setKey string indicator-specific (e.g. 'sma', 'rsi', 'cci') Base name of the output field written onto each entry. For period-aware indicators the period is appended (e.g. sma14, rsi14, wema14). For period-less indicators the base name is written as-is.
setKey (Stochastic) STSSetKey { k: 'stsK', d: 'stsD' } Output keys for %K and %D lines.
setKeys (Fisher) FisherSetKeys { t: 'fisherTransform', s: 'fisherSignal' } Output keys for the transform and the signal (previous bar's transform).
changeKey string 'changeVal' Field that holds the close-to-close change. Used by RSI; auto-computed when missing.

Renaming the output — setKey

Use setKey to compute the same indicator twice with different parameters and keep both results.

const result = tala()
  .sma(14)
  .sma(14, 'volume', 'volSma')   // SMA(14) of volume → volSma14
  .ema(12, 0, 'close', 'fastEma') // EMA(12) of close → fastEma12
  .run(history)

result[0].sma14      // default close-SMA(14)
result[0].volSma14   // volume-SMA(14)
result[0].fastEma12  // renamed EMA(12)
Period appending. Indicators with a period argument append it to setKey (so setKey: 'fastEma' with period=12 writes fastEma12). Indicators without a period (CCI, ADX base output, Williams %R, ALMA, TRIX, Fisher) write the base name as-is.

Custom OHLC field names — priceKey / priceKeys

If your data uses short names like c/h/l or domain-specific names, override the defaults instead of remapping your data.

// Candles with short field names
const history = [
  { o: 104, h: 110, l: 100, c: 105 },
  { o: 100, h: 108, l: 98,  c: 102 },
  // ...
]

tala()
  .sma(14, 'c')                                  // priceKey = 'c'
  .cci(20, 0.015, { c: 'c', h: 'h', l: 'l' })  // priceKeys
  .adx(14, { c: 'c', h: 'h', l: 'l' })
  .run(history)

Stochastic and Fisher — multi-output keys

Stochastic writes two keys (%K, %D) and Fisher writes two keys (transform, signal). Pass an object to override the defaults.

tala()
  .sts(14, { c: 'close', h: 'high', l: 'low' }, { k: 'stochK', d: 'stochD' })
  .fisher(9, { c: 'close', h: 'high', l: 'low' }, { t: 'fisher', s: 'fisherPrev' })
  .run(history)

result[0].stochK       // renamed %K
result[0].stochD3      // renamed %D (period appended)
result[0].fisher       // renamed transform
result[0].fisherPrev   // renamed signal

RSI — changeKey

RSI consumes changeVal (close-to-close difference). It is auto-computed if missing, so you only need changeKey if you have a pre-computed change field under a different name.

tala()
  .rsi(14)                          // reads/writes default 'changeVal'
  .rsi(14, 'myDelta', 'rsiCustom') // reads 'myDelta', writes rsiCustom14
  .run(history)

Quick reference — defaults per indicator

Indicator Reads Writes (default)
.sma(period, priceKey?, setKey?) close sma{period}
.ema(period, offset?, priceKey?, setKey?) close ema{period}
.wema(period, offset, priceKey?, setKey?) close wema{period}
.alma(period?, sigma?, offset?, priceKey?) close alma
.trix(period?, priceKey?, setKey?) close trix
.macd(periods?, options?, priceKey?, setKey?) close macd (+ signal, histogram)
.rsi(period?, changeKey?, setKey?) changeVal rsi{period}
.cci(period?, constant?, priceKeys?, setKey?) high, low, close cci
.adx(period?, priceKeys?, setKey?) high, low, close adx{period}, di+, di-, atr{period}
.fisher(period?, priceKeys?, setKeys?) high, low fisherTransform, fisherSignal
.sts(period?, priceKeys?, setKey?) high, low, close stsK, stsD{period}
.williamsR(period?, priceKeys?, setKey?) high, low, close williamsR
.pivotT(period?, priceKeys?) high, low, close pp{p}, r1{p}, s1{p}, r2{p}, s2{p}
.fibRL(period?, priceKeys?) high, low, close fib0.236 … fib0.786

Types

import type {
  PriceHistoryEntry,   // candle shape (open, high, low, close, ...)
  PriceKeys,           // { c, h, l } - field name mapping
  MACDPeriods,         // { fastPeriod, slowPeriod, signalLength }
  MACDOptions,         // { includeSignal?, includeHistogram? }
  STSSetKey,           // { k, d } - output key names for Stochastic
  FisherSetKeys,       // { t, s } - output key names for Fisher
  TalaResult,          // { history, signals } - structured run() output
  RunOptions,          // { structured?: boolean }
} from '@jimzandueta/tala'

Utilities

getTrend(priceHist, key, start?, end?, isVector?)

Returns 1 (uptrend), -1 (downtrend), or 0 (sideways) by computing the linear regression slope over the selected window. Does not mutate the array. Imported directly - not on the chain.

import { getTrend } from '@jimzandueta/tala'

const dir = getTrend(history, 'close')           // 1 | -1 | 0
const dir = getTrend(history, 'sma14', 0, 20)    // over the first 20 bars

Input Format

All indicators expect PriceHistoryEntry[] where index 0 is the most recent candle (reverse chronological order).

interface PriceHistoryEntry {
  open:       number
  high:       number
  low:        number
  close:      number
  volume?:    number
  changeVal?: number   // close[i] − close[i+1]; auto-computed by .rsi()
  [key: string]: number | undefined
}
Index 0 = most recent. Bar 0 is today, bar 1 is yesterday, etc. This is opposite to chronological order - sort your data descending by time before passing to tala.