OGT Owl Group Trading by Dr. Ken Long
Home About Learn The Loop Code Courses Essays Store Partners FAQ
Indicator Code · Owl Group Trading

MACD Seasons

Classifies each bar into one of four market-cycle seasons (Spring/Summer/Fall/Winter) from the sign and slope of the MACD of the regression line (MACDRL).

Concept: what MACD-SEASONS means →

MACD Seasons builds a MACD oscillator on the linear-regression line of close (MACDRL = SMA(RL(close,10),10) - SMA(RL(close,10),30)), then labels each bar by whether the oscillator is positive or negative and whether it is rising or falling. Rising is approximated as macdrl ≥ SMA(macdrl,2). The four combinations map to a cyclical narrative: Spring (below zero, turning up), Summer (above zero, rising), Fall (above zero, rolling over), and Winter (below zero, falling). It turns a continuous momentum oscillator into a discrete regime label for quick at-a-glance cycle reading.

Verified. Python and JavaScript implementations agree to season labels match 100% (21/21 comparable bars); underlying MACDRL agrees to 4.26e-14 on a 60-bar reference price series (Canonical Python (edge-transform CoreIndicators.calc_macdseason4 / calc_macdrl) vs JS port (edge-canvas indicators.js calcMACDSeason4 / calcMACDRL), 4-season variant, on the identical 60-bar close. Compared (a) max abs diff of the underlying MACDRL value over the finite region and (b) the fraction of bars where the integer season label matches over the comparable region (bars whose slope predecessor is also finite). The only divergences are warmup-edge bars: Python's polars treats float-NaN warmup as comparable (NaN>0 is True) and emits spurious labels, while the JS port emits null there. That is a NaN-vs-null definedness boundary, not a season-definition difference; the math is identical.).

Pythonpermalink →

import numpy as np
import polars as pl


def rl_endpoint(values: np.ndarray, n: int = 10) -> np.ndarray:
    """Linear-regression endpoint over a trailing window of `n` bars.

    Fits a least-squares line to each window [i-n+1 .. i] and returns the
    fitted value at the last position. Warmup bars (< n) are NaN. Vectorized
    via np.correlate with precomputed endpoint weights (matches edge-transform
    CoreIndicators.rl_series).
    """
    values = np.asarray(values, dtype=float)
    length = len(values)
    out = np.full(length, np.nan)
    if n <= 0 or length < n:
        return out
    x = np.arange(n, dtype=float)
    x_mean = (n - 1) / 2.0
    x_diff = x - x_mean
    denom = float(np.sum(x_diff ** 2))
    if denom == 0:
        return out
    # Endpoint weight: intercept term (1/n) + slope contribution at x=n-1.
    w = 1.0 / n + x_mean * x_diff / denom
    out[n - 1:] = np.correlate(values, w, mode="valid")
    return out


def macd_rl(close, short: int = 10, long: int = 30, rl_n: int = 10) -> pl.Series:
    """MACD of the regression line: SMA(RL, short) - SMA(RL, long).

    macdrl = SMA(RL(close, rl_n), short) - SMA(RL(close, rl_n), long)
    (matches edge-transform CoreIndicators.calc_macdrl).
    """
    rl = pl.Series("rl", rl_endpoint(np.asarray(close, dtype=float), n=rl_n))
    sma_short = rl.rolling_mean(short)
    sma_long = rl.rolling_mean(long)
    return (sma_short - sma_long).alias("macdrl")


def macd_seasons(close, short: int = 10, long: int = 30, rl_n: int = 10) -> pl.Series:
    """4-season MACD classification from MACDRL sign and slope.

    Seasons follow a market cycle:
      1 = Spring : macdrl < 0 and rising  (recovery)
      2 = Summer : macdrl > 0 and rising  (expansion)
      3 = Fall   : macdrl > 0 and falling (distribution)
      4 = Winter : macdrl < 0 and falling (contraction)

    Slope is approximated as: rising = macdrl >= SMA(macdrl, 2)
    (matches edge-transform CoreIndicators.calc_macdseason4).
    """
    macdrl = macd_rl(close, short=short, long=long, rl_n=rl_n)
    sma2 = macdrl.rolling_mean(2)
    positive = macdrl > 0
    rising = macdrl >= sma2
    df = pl.DataFrame({"positive": positive, "rising": rising})
    season = df.select(
        pl.when(positive.not_() & rising).then(1)        # Spring
        .when(positive & rising).then(2)                  # Summer
        .when(positive & rising.not_()).then(3)           # Fall
        .when(positive.not_() & rising.not_()).then(4)    # Winter
        .otherwise(None)
        .alias("season")
    )["season"]
    return season

JavaScriptpermalink →

// Linear-regression endpoint over a trailing window of `n` bars.
// Fits a least-squares line to each window and returns the fitted value at the
// last position. Warmup bars (< n) and any window containing null/NaN are null.
// (matches edge-canvas indicators.js calcRL)
function rlEndpoint(closes, n = 10) {
  const len = closes.length;
  const result = new Array(len).fill(null);
  if (n <= 0 || len < n) return result;
  const sumX = (n * (n - 1)) / 2;
  const sumX2 = (n * (n - 1) * (2 * n - 1)) / 6;
  const denom = n * sumX2 - sumX * sumX;
  if (denom === 0) return result;
  for (let end = n; end <= len; end++) {
    const start = end - n;
    let sumY = 0, sumXY = 0, hasNaN = false;
    for (let j = 0; j < n; j++) {
      const v = closes[start + j];
      if (v == null || Number.isNaN(v)) { hasNaN = true; break; }
      sumY += v;
      sumXY += j * v;
    }
    if (hasNaN) continue;
    const slope = (n * sumXY - sumX * sumY) / denom;
    const intercept = (sumY - slope * sumX) / n;
    result[end - 1] = slope * (n - 1) + intercept;
  }
  return result;
}

// Simple moving average over the last n values; null until a full window of
// finite values is available. (matches edge-canvas indicators.js calcSMA)
function sma(values, n) {
  const len = values.length;
  const result = new Array(len).fill(null);
  if (n <= 0 || len < n) return result;
  let sum = 0, count = 0;
  for (let i = 0; i < len; i++) {
    const v = values[i];
    if (v != null && !Number.isNaN(v)) { sum += v; count++; }
    if (i >= n) {
      const old = values[i - n];
      if (old != null && !Number.isNaN(old)) { sum -= old; count--; }
    }
    if (i >= n - 1 && count === n) result[i] = sum / n;
  }
  return result;
}

// MACD of the regression line: SMA(RL, short) - SMA(RL, long).
// (matches edge-canvas indicators.js calcMACDRL)
function macdRL(closes, short_ = 10, long_ = 30, rlN = 10) {
  const rl = rlEndpoint(closes, rlN);
  const smaShort = sma(rl, short_);
  const smaLong = sma(rl, long_);
  const len = closes.length;
  const result = new Array(len).fill(null);
  for (let i = 0; i < len; i++) {
    if (smaShort[i] != null && smaLong[i] != null) result[i] = smaShort[i] - smaLong[i];
  }
  return result;
}

// 4-season MACD classification from MACDRL sign and slope.
//   1 = Spring (macdrl < 0, rising)   2 = Summer (macdrl > 0, rising)
//   3 = Fall   (macdrl > 0, falling)  4 = Winter (macdrl < 0, falling)
// rising = macdrl >= SMA(macdrl, 2). Returns ints 1-4 or null.
// (matches edge-canvas indicators.js calcMACDSeason4)
function macdSeasons(closes, short_ = 10, long_ = 30, rlN = 10) {
  const macdrl = macdRL(closes, short_, long_, rlN);
  const sma2 = sma(macdrl, 2);
  const len = closes.length;
  const result = new Array(len).fill(null);
  for (let i = 0; i < len; i++) {
    if (macdrl[i] == null || sma2[i] == null) continue;
    const positive = macdrl[i] > 0;
    const rising = macdrl[i] >= sma2[i];
    if (!positive && rising) result[i] = 1;        // Spring
    else if (positive && rising) result[i] = 2;    // Summer
    else if (positive && !rising) result[i] = 3;   // Fall
    else result[i] = 4;                            // Winter
  }
  return result;
}