/* * This Source Code Form is subject to the terms of the Mozilla Public License, * v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain * one at https://mozilla.org/MPL/2.0/. * © jdehorty. TypeScript adaptation © 2026 Valence Technologies Limited. * Port of publication revision 6 (2026-06-29), MLExtensions/2, KernelFunctions/2. * Original sources and provenance: third_party/lorentzian/. * The default entry signals and the default (strict, 4-bar) exits are ported. * No dynamic exits, bar colours or statistics. */ export type Candle = { timestamp: number; open: number; high: number; low: number; close: number; volume?: number; }; export interface LorentzianSignal { timestamp: number; side: "buy" | "sell"; price: number; } /** * The end of a trade an entry signal opened. `side` names the trade being * ended, not a new direction: "up" closes an upward entry, "down" a downward * one. "up"/"down" are the only side spellings shown to a reader. */ export interface LorentzianExit { timestamp: number; price: number; side: "up" | "down"; entryTimestamp: number; entryPrice: number; } export interface LorentzianTrades { signals: LorentzianSignal[]; exits: LorentzianExit[]; } export const LORENTZIAN_DEFAULTS = Object.freeze({ source: "close", neighbors: 8, maxBarsBack: 2000, includeFullHistory: false, features: "RSI(14,1), WT(10,11), CCI(20,1), ADX(20), RSI(9,1)", volatility: true, regime: true, regimeThreshold: -0.1, adx: false, ema: false, sma: false, kernel: true, kernelSmoothing: false, lookback: 8, relativeWeight: 8, regression: 25, showExits: true, useDynamicExits: false, }); const nz = (v: number) => (Number.isFinite(v) ? v : 0); /** Pine EMA seeds at the first non-na input, RMA at the first length non-na inputs. */ export function pineAverage( values: number[], length: number, kind: "ema" | "rma", ): number[] { let value = NaN, sum = 0, count = 0; const alpha = kind === "ema" ? 2 / (length + 1) : 1 / length; return values.map((v) => { if (Number.isFinite(v)) { if (Number.isFinite(value)) value = alpha * v + (1 - alpha) * value; else if (kind === "ema") value = v; else { sum += v; if (++count === length) value = sum / length; } } return value; }); } function sma(values: number[], length: number): number[] { const window: number[] = []; return values.map((v) => { if (Number.isFinite(v)) window.push(v); if (window.length > length) window.shift(); return window.length === length ? window.reduce((a, b) => a + b, 0) / length : NaN; }); } function normalize(values: number[]): number[] { let low = 1e11, high = -1e11; return values.map((v) => { if (Number.isFinite(v)) { low = Math.min(low, v); high = Math.max(high, v); } return (v - low) / Math.max(high - low, 1e-9); }); } function rsi(close: number[], length: number): number[] { const up = pineAverage( close.map((v, i) => (i ? Math.max(v - close[i - 1], 0) : NaN)), length, "rma", ); const down = pineAverage( close.map((v, i) => (i ? Math.max(close[i - 1] - v, 0) : NaN)), length, "rma", ); return close.map((_, i) => down[i] === 0 ? 1 : up[i] === 0 ? 0 : 1 - 1 / (1 + up[i] / down[i]), ); } function adx(bars: Candle[], length: number): number[] { let trSmooth = 0, plusSmooth = 0, minusSmooth = 0; const dx = bars.map((b, i) => { const previous = bars[i - 1]; const up = b.high - (previous?.high ?? 0), down = (previous?.low ?? 0) - b.low; const tr = Math.max( b.high - b.low, Math.abs(b.high - (previous?.close ?? 0)), Math.abs(b.low - (previous?.close ?? 0)), ); trSmooth = trSmooth - trSmooth / length + tr; plusSmooth = plusSmooth - plusSmooth / length + (up > down ? Math.max(up, 0) : 0); minusSmooth = minusSmooth - minusSmooth / length + (down > up ? Math.max(down, 0) : 0); const positive = (plusSmooth / trSmooth) * 100, negative = (minusSmooth / trSmooth) * 100; return (Math.abs(positive - negative) / (positive + negative)) * 100; }); return pineAverage(dx, length, "rma").map((v) => v / 100); } export function defaultFeatures(bars: Candle[]): number[][] { const close = bars.map((b) => b.close), hlc3 = bars.map((b) => (b.high + b.low + b.close) / 3); const ema1 = pineAverage(hlc3, 10, "ema"); const ema2 = pineAverage( hlc3.map((v, i) => Math.abs(v - ema1[i])), 10, "ema", ); const wt1 = pineAverage( hlc3.map((v, i) => (v - ema1[i]) / (0.015 * ema2[i])), 11, "ema", ); const wt2 = sma(wt1, 4), mean = sma(close, 20); const cci = close.map((v, i) => { if (i < 19) return NaN; const deviation = close .slice(i - 19, i + 1) .reduce((sum, x) => sum + Math.abs(x - mean[i]), 0) / 20; return (v - mean[i]) / (0.015 * deviation); }); return [ rsi(close, 14), normalize(wt1.map((v, i) => v - wt2[i])), normalize(pineAverage(cci, 1, "ema")), adx(bars, 20), rsi(close, 9), ]; } function regimeFilter(bars: Candle[]): boolean[] { let value1 = 0, value2 = 0, klmf = 0; const src = bars.map((b) => (b.open + b.high + b.low + b.close) / 4); const slopes = bars.map((b, i) => { value1 = 0.2 * (src[i] - src[i - 1]) + 0.8 * nz(value1); value2 = 0.1 * (b.high - b.low) + 0.8 * nz(value2); const omega = Math.abs(value1 / value2); const alpha = (-(omega ** 2) + Math.sqrt(omega ** 4 + 16 * omega ** 2)) / 8; const previous = klmf; klmf = alpha * src[i] + (1 - alpha) * nz(previous); return i ? Math.abs(klmf - previous) : NaN; }); const average = pineAverage(slopes, 200, "ema"); return slopes.map((v, i) => (v - average[i]) / average[i] >= -0.1); } function kernel(close: number[]): number[] { // Pine array.size(array.from(_src)) is 1; inclusive 0..1+25 = 27 weights. const weights = Array.from( { length: 27 }, (_, i) => (1 + i ** 2 / (8 ** 2 * 2 * 8)) ** -8, ); const sum = weights.reduce((a, b) => a + b, 0); return close.map((_, i) => i < 26 ? NaN : weights.reduce((total, w, j) => total + close[i - j] * w, 0) / sum, ); } /** * The script's default exits, as a pure function of the per-bar signal trace. * * `barsHeld` is rebuilt here the way Pine does it -- reset to 0 on every signal * change, incremented otherwise -- so the rule can be checked against a trace * written out by hand. `signalAt`/`entryAt` are parallel to `bars`; `entryAt[i]` * is the entry signal drawn on that bar, or null. */ export function strictExits( bars: Candle[], signalAt: number[], entryAt: (LorentzianSignal | null)[], ): LorentzianExit[] { const exits: LorentzianExit[] = []; let barsHeld = 0; for (let i = 0; i < bars.length; i++) { const signal = signalAt[i] ?? 0, changed = signal !== (i ? (signalAt[i - 1] ?? 0) : 0); barsHeld = changed ? 0 : barsHeld + 1; const heldFourBars = barsHeld === 4, heldLessThanFourBars = barsHeld > 0 && barsHeld < 4; const isNewBuySignal = signal === 1 && changed, isNewSellSignal = signal === -1 && changed; const lastSignal = signalAt[i - 4], lastEntry = entryAt[i - 4] ?? null; const endLongTrade = ((heldFourBars && lastSignal === 1) || (heldLessThanFourBars && isNewSellSignal && lastSignal === 1)) && lastEntry?.side === "buy"; const endShortTrade = ((heldFourBars && lastSignal === -1) || (heldLessThanFourBars && isNewBuySignal && lastSignal === -1)) && lastEntry?.side === "sell"; if ((endLongTrade || endShortTrade) && lastEntry) exits.push({ timestamp: bars[i].timestamp, // location.absolute: the exit bar's own high for an upward trade, its // low for a downward one, exactly as the script's plotshape does. price: endLongTrade ? bars[i].high : bars[i].low, side: endLongTrade ? "up" : "down", entryTimestamp: lastEntry.timestamp, entryPrice: lastEntry.price, }); } return exits; } /** * Caller supplies a fixed, chronological, completed-candle snapshot. Never use * the visible chart range as the training set. This reproduces the published * loop literally, including its reversed labels, persistent queues, modulo * condition and descending Pine loop when startIndex exceeds sizeLoop. * Different feed/history boundaries can change results, as in the Pine script. * * Exits follow the script's `endLongTradeStrict`/`endShortTradeStrict` * (LorentzianClassification.pine:555-556), which need three things at once: * `barsHeld` (reset on every signal change) is exactly 4, the signal four bars * back pointed the same way, and an entry was actually drawn on that bar * (`startLongTrade[4]`). The script's second branch -- * `isHeldLessThanFourBars and isNewSellSignal` -- cannot fire as published, * because `isNewSellSignal` requires `ta.change(signal)` on the same bar that * `barsHeld` is reset to 0 by it, and the branch also demands `barsHeld > 0`. * It is carried here verbatim rather than silently repaired, so the port keeps * matching the published revision: the effective rule is "four bars after a * drawn entry, if the signal has not flipped since". * * `useDynamicExits` is the script's other exit mode (a kernel-slope reversal * instead of the fixed hold). It defaults to false and is not ported; the * branch point is marked below so it can be added without reshaping the loop. */ export function lorentzianTrades( bars: Candle[], options: { useDynamicExits?: boolean } = {}, ): LorentzianTrades { const signals: LorentzianSignal[] = [], exits: LorentzianExit[] = []; if (bars.length < 28) return { signals, exits }; const features = defaultFeatures(bars), close = bars.map((b) => b.close); const regime = regimeFilter(bars), estimate = kernel(close); const tr = bars.map((b, i) => i ? Math.max( b.high - b.low, Math.abs(b.high - close[i - 1]), Math.abs(b.low - close[i - 1]), ) : b.high - b.low, ); const atr10 = pineAverage(tr, 10, "rma"); const labels = close.map((v, i) => close[i - 4] < v ? -1 : close[i - 4] > v ? 1 : 0, ); const startIndex = Math.max(0, bars.length - 1 - 2000); const predictions: number[] = [], distances: number[] = []; // Per-bar history the exit rule reads four bars back, in loop order. const signalAt: number[] = [], entryAt: (LorentzianSignal | null)[] = []; let signal = 0; for (let bar = startIndex; bar < bars.length; bar++) { let lastDistance = -1; const end = Math.min(1999, bar), step = startIndex <= end ? 1 : -1; for (let i = startIndex; step > 0 ? i <= end : i >= end; i += step) { if (i % 4 === 0) continue; let distance = 0; for (const feature of features) distance += Math.log(1 + Math.abs(feature[bar] - feature[i])); if (distance >= lastDistance) { lastDistance = distance; distances.push(distance); predictions.push(labels[i]); if (predictions.length > 8) { lastDistance = distances[6]; distances.shift(); predictions.shift(); } } } const prediction = predictions.reduce((a, b) => a + b, 0), previous = signal; if (tr[bar] > atr10[bar] && regime[bar]) { if (prediction > 0) signal = 1; else if (prediction < 0) signal = -1; } const changed = signal !== previous; let entry: LorentzianSignal | null = null; if (changed) { if (signal === 1 && estimate[bar] > estimate[bar - 1]) entry = { timestamp: bars[bar].timestamp, side: "buy", price: bars[bar].low, }; if (signal === -1 && estimate[bar] < estimate[bar - 1]) entry = { timestamp: bars[bar].timestamp, side: "sell", price: bars[bar].high, }; if (entry) signals.push(entry); } const at = bar - startIndex; signalAt[at] = signal; entryAt[at] = entry; } // Dynamic exits would replace this call, keeping the same output shape. if (!(options.useDynamicExits ?? LORENTZIAN_DEFAULTS.useDynamicExits)) exits.push(...strictExits(bars.slice(startIndex), signalAt, entryAt)); return { signals, exits }; } /** The entry signals alone, unchanged in content and order by the exit port. */ export function lorentzianSignals(bars: Candle[]): LorentzianSignal[] { return lorentzianTrades(bars).signals; }