Spaces:
Running
Running
Refactor market structure breaks chronologically and implement priority-based marker deduplicator
851ffda | /** | |
| * @module marketStructure | |
| * Swing point detection and market structure analysis (BOS / CHoCH). | |
| * Operates on arrays of candle objects: { time, open, high, low, close, volume } | |
| */ | |
| import { ema } from './indicators.js'; | |
| /** | |
| * @typedef {Object} SwingPoint | |
| * @property {number} index - Index in the candles array. | |
| * @property {number} price - Price level of the swing. | |
| * @property {number} time - Timestamp of the candle. | |
| */ | |
| /** | |
| * @typedef {Object} StructureBreak | |
| * @property {'BOS'|'CHoCH'} type | |
| * @property {'bullish'|'bearish'} direction | |
| * @property {number} index - Candle index where the break occurred. | |
| * @property {number} price - Price level that was broken. | |
| * @property {number} time | |
| */ | |
| /** | |
| * Find swing highs and swing lows. | |
| * A swing high at index i means candle[i].high is strictly greater than all | |
| * highs in the window [i - lookback, i + lookback]. | |
| * A swing low at index i means candle[i].low is strictly less than all | |
| * lows in the window [i - lookback, i + lookback]. | |
| * | |
| * @param {{ time: number, open: number, high: number, low: number, close: number, volume: number }[]} candles | |
| * @param {number} [lookback=5] | |
| * @returns {{ swingHighs: SwingPoint[], swingLows: SwingPoint[] }} | |
| */ | |
| export function findSwingPoints(candles, lookback = 5) { | |
| if (!candles || candles.length === 0) { | |
| return { swingHighs: [], swingLows: [] }; | |
| } | |
| const swingHighs = []; | |
| const swingLows = []; | |
| for (let i = lookback; i < candles.length - lookback; i++) { | |
| let isSwingHigh = true; | |
| let isSwingLow = true; | |
| for (let j = 1; j <= lookback; j++) { | |
| // Check left side | |
| if (candles[i].high <= candles[i - j].high) isSwingHigh = false; | |
| if (candles[i].low >= candles[i - j].low) isSwingLow = false; | |
| // Check right side | |
| if (candles[i].high <= candles[i + j].high) isSwingHigh = false; | |
| if (candles[i].low >= candles[i + j].low) isSwingLow = false; | |
| // Early exit when neither is possible | |
| if (!isSwingHigh && !isSwingLow) break; | |
| } | |
| if (isSwingHigh) { | |
| swingHighs.push({ index: i, price: candles[i].high, time: candles[i].time }); | |
| } | |
| if (isSwingLow) { | |
| swingLows.push({ index: i, price: candles[i].low, time: candles[i].time }); | |
| } | |
| } | |
| return { swingHighs, swingLows }; | |
| } | |
| /** | |
| * Detect market structure including BOS, CHoCH, and trend. | |
| * | |
| * @param {{ time: number, open: number, high: number, low: number, close: number, volume: number }[]} candles | |
| * @param {number} [lookback=5] | |
| * @returns {{ | |
| * swingHighs: SwingPoint[], | |
| * swingLows: SwingPoint[], | |
| * structureBreaks: StructureBreak[], | |
| * trend: 'bullish'|'bearish'|'ranging', | |
| * higherHighs: boolean, | |
| * higherLows: boolean, | |
| * lowerHighs: boolean, | |
| * lowerLows: boolean | |
| * }} | |
| */ | |
| export function detectStructure(candles, lookback = 5) { | |
| const { swingHighs, swingLows } = findSwingPoints(candles, lookback); | |
| const result = { | |
| swingHighs, | |
| swingLows, | |
| structureBreaks: [], | |
| trend: /** @type {'bullish'|'bearish'|'ranging'} */ ('ranging'), | |
| higherHighs: false, | |
| higherLows: false, | |
| lowerHighs: false, | |
| lowerLows: false, | |
| }; | |
| if (swingHighs.length < 2 && swingLows.length < 2) { | |
| return result; | |
| } | |
| // --- Determine HH / HL / LH / LL patterns from last 2 swing points --- | |
| if (swingHighs.length >= 2) { | |
| const lastH = swingHighs[swingHighs.length - 1]; | |
| const prevH = swingHighs[swingHighs.length - 2]; | |
| result.higherHighs = lastH.price > prevH.price; | |
| result.lowerHighs = lastH.price < prevH.price; | |
| } | |
| if (swingLows.length >= 2) { | |
| const lastL = swingLows[swingLows.length - 1]; | |
| const prevL = swingLows[swingLows.length - 2]; | |
| result.higherLows = lastL.price > prevL.price; | |
| result.lowerLows = lastL.price < prevL.price; | |
| } | |
| // --- Determine overall trend from swing-point sequence --- | |
| if (result.higherHighs && result.higherLows) { | |
| result.trend = 'bullish'; | |
| } else if (result.lowerHighs && result.lowerLows) { | |
| result.trend = 'bearish'; | |
| } else { | |
| result.trend = 'ranging'; | |
| } | |
| // --- Chronological walk to detect structure breaks and update localTrend --- | |
| let localTrend = /** @type {'bullish'|'bearish'|'ranging'} */ ('ranging'); | |
| // Initialise local trend from the overall trend pattern | |
| if (result.higherHighs && result.higherLows) { | |
| localTrend = 'bullish'; | |
| } else if (result.lowerHighs && result.lowerLows) { | |
| localTrend = 'bearish'; | |
| } | |
| const structureBreaks = []; | |
| // Clone active swing points so we can mark them as broken | |
| const activeHighs = swingHighs.map(sh => ({ ...sh, broken: false })); | |
| const activeLows = swingLows.map(sl => ({ ...sl, broken: false })); | |
| // Process candles chronologically | |
| for (let c = 0; c < candles.length; c++) { | |
| const close = candles[c].close; | |
| // 1. Check if candle c breaks any active high established before c | |
| let brokenHigh = null; | |
| for (const sh of activeHighs) { | |
| if (sh.index < c && !sh.broken && close > sh.price) { | |
| sh.broken = true; | |
| // Target the highest broken level at this candle to represent major breakout | |
| if (!brokenHigh || sh.price > brokenHigh.price) { | |
| brokenHigh = sh; | |
| } | |
| } | |
| } | |
| if (brokenHigh) { | |
| const type = (localTrend === 'bearish' || localTrend === 'ranging') ? 'CHoCH' : 'BOS'; | |
| structureBreaks.push({ | |
| type, | |
| direction: /** @type {'bullish'} */ ('bullish'), | |
| index: c, | |
| price: brokenHigh.price, | |
| time: candles[c].time, | |
| }); | |
| localTrend = 'bullish'; | |
| } | |
| // 2. Check if candle c breaks any active low established before c | |
| let brokenLow = null; | |
| for (const sl of activeLows) { | |
| if (sl.index < c && !sl.broken && close < sl.price) { | |
| sl.broken = true; | |
| // Target the lowest broken level at this candle | |
| if (!brokenLow || sl.price < brokenLow.price) { | |
| brokenLow = sl; | |
| } | |
| } | |
| } | |
| if (brokenLow) { | |
| const type = (localTrend === 'bullish' || localTrend === 'ranging') ? 'CHoCH' : 'BOS'; | |
| structureBreaks.push({ | |
| type, | |
| direction: /** @type {'bearish'} */ ('bearish'), | |
| index: c, | |
| price: brokenLow.price, | |
| time: candles[c].time, | |
| }); | |
| localTrend = 'bearish'; | |
| } | |
| } | |
| // Sort structure breaks chronologically (already chronological from the loop, but sort just in case) | |
| structureBreaks.sort((a, b) => a.index - b.index); | |
| result.structureBreaks = structureBreaks; | |
| return result; | |
| } | |