Spaces:
Running
Running
Optimize SL ATR buffers dynamically, enforce zone freshness, and align backtester spread execution
86afb93 | /** | |
| * @module supplyDemand | |
| * Supply and demand zone detection via Rally-Base-Rally / Drop-Base-Drop | |
| * and reversal patterns (DBR / RBD). | |
| * Operates on arrays of candle objects: { time, open, high, low, close, volume } | |
| */ | |
| import { atr } from './indicators.js'; | |
| /** | |
| * @typedef {Object} Zone | |
| * @property {'demand'|'supply'} type | |
| * @property {'RBR'|'DBR'|'DBD'|'RBD'} pattern | |
| * @property {number} top | |
| * @property {number} bottom | |
| * @property {number} startIndex | |
| * @property {number} endIndex | |
| * @property {number} time | |
| * @property {number} strength - 0-100 | |
| * @property {'fresh'|'tested'|'broken'} status | |
| * @property {number} testCount | |
| */ | |
| /** | |
| * Classify a candle as 'rally', 'drop', or 'base' relative to the current ATR. | |
| * @param {{ open: number, close: number }} candle | |
| * @param {number} atrVal - Current ATR value. | |
| * @returns {'rally'|'drop'|'base'} | |
| */ | |
| function classifyCandle(candle, atrVal) { | |
| const body = candle.close - candle.open; // signed | |
| const absBody = Math.abs(body); | |
| const threshold = 0.5 * atrVal; | |
| if (absBody < threshold) return 'base'; | |
| return body > 0 ? 'rally' : 'drop'; | |
| } | |
| /** | |
| * Detect supply and demand zones. | |
| * | |
| * @param {{ time: number, open: number, high: number, low: number, close: number, volume: number }[]} candles | |
| * @param {number} [atrPeriod=14] | |
| * @returns {Zone[]} | |
| */ | |
| export function detectZones(candles, atrPeriod = 14) { | |
| if (!candles || candles.length < atrPeriod + 5) return []; | |
| const atrValues = atr(candles, atrPeriod); | |
| const zones = []; | |
| // Precompute average volume for VSA filtering | |
| let volumeSum = 0; | |
| let volumeCount = 0; | |
| for (const c of candles) { | |
| if (c.volume > 0) { | |
| volumeSum += c.volume; | |
| volumeCount++; | |
| } | |
| } | |
| const avgVolume = volumeCount > 0 ? volumeSum / volumeCount : 1; | |
| // Classify every candle | |
| const labels = candles.map((c, i) => { | |
| const a = atrValues[i]; | |
| return isNaN(a) || a <= 0 ? 'base' : classifyCandle(c, a); | |
| }); | |
| // Scan for patterns: we need at least 3 segments (move β base β move) | |
| let i = atrPeriod; // start after ATR is valid | |
| while (i < candles.length - 2) { | |
| // 1. Find the start of a directional move (rally or drop) | |
| const firstMoveType = labels[i]; | |
| if (firstMoveType === 'base') { | |
| i++; | |
| continue; | |
| } | |
| // Consume consecutive candles of the same move type | |
| let firstMoveEnd = i; | |
| while (firstMoveEnd + 1 < candles.length && labels[firstMoveEnd + 1] === firstMoveType) { | |
| firstMoveEnd++; | |
| } | |
| // 2. Look for a base zone (1-3 base candles) | |
| const baseStart = firstMoveEnd + 1; | |
| if (baseStart >= candles.length) break; | |
| if (labels[baseStart] !== 'base') { | |
| i = baseStart; | |
| continue; | |
| } | |
| let baseEnd = baseStart; | |
| const maxBaseLen = 3; | |
| while ( | |
| baseEnd + 1 < candles.length && | |
| labels[baseEnd + 1] === 'base' && | |
| baseEnd - baseStart + 1 < maxBaseLen | |
| ) { | |
| baseEnd++; | |
| } | |
| // 3. Look for the second directional move | |
| const secondMoveStart = baseEnd + 1; | |
| if (secondMoveStart >= candles.length) break; | |
| const secondMoveType = labels[secondMoveStart]; | |
| if (secondMoveType === 'base') { | |
| i = secondMoveStart; | |
| continue; | |
| } | |
| // Consume the second move | |
| let secondMoveEnd = secondMoveStart; | |
| while (secondMoveEnd + 1 < candles.length && labels[secondMoveEnd + 1] === secondMoveType) { | |
| secondMoveEnd++; | |
| } | |
| // 4. Determine pattern | |
| let pattern = /** @type {'RBR'|'DBR'|'DBD'|'RBD'|null} */ (null); | |
| let zoneType = /** @type {'demand'|'supply'|null} */ (null); | |
| if (firstMoveType === 'rally' && secondMoveType === 'rally') { | |
| pattern = 'RBR'; | |
| zoneType = 'demand'; | |
| } else if (firstMoveType === 'drop' && secondMoveType === 'rally') { | |
| pattern = 'DBR'; | |
| zoneType = 'demand'; | |
| } else if (firstMoveType === 'drop' && secondMoveType === 'drop') { | |
| pattern = 'DBD'; | |
| zoneType = 'supply'; | |
| } else if (firstMoveType === 'rally' && secondMoveType === 'drop') { | |
| pattern = 'RBD'; | |
| zoneType = 'supply'; | |
| } | |
| if (!pattern || !zoneType) { | |
| i = secondMoveStart; | |
| continue; | |
| } | |
| // Hard VSA Filter: Departure candles must have average volume > avgVolume * 1.2 (if volume data is available) | |
| if (volumeCount > 0) { | |
| let totalDepartureVolume = 0; | |
| for (let m = secondMoveStart; m <= secondMoveEnd; m++) { | |
| totalDepartureVolume += candles[m].volume; | |
| } | |
| const avgDepartureVolume = totalDepartureVolume / (secondMoveEnd - secondMoveStart + 1); | |
| if (avgDepartureVolume < avgVolume * 1.2) { | |
| i = secondMoveStart; | |
| continue; | |
| } | |
| } | |
| // 5. Zone boundaries = high/low of the base candles | |
| let zoneTop = -Infinity; | |
| let zoneBottom = Infinity; | |
| for (let b = baseStart; b <= baseEnd; b++) { | |
| zoneTop = Math.max(zoneTop, candles[b].high); | |
| zoneBottom = Math.min(zoneBottom, candles[b].low); | |
| } | |
| // 6. Strength scoring | |
| const currentATR = atrValues[baseStart] || 1; | |
| let strength = 0; | |
| // a) Departure strength: how fast price left the base (second move body / ATR) β 40 pts | |
| let departureBody = 0; | |
| for (let m = secondMoveStart; m <= secondMoveEnd; m++) { | |
| departureBody += Math.abs(candles[m].close - candles[m].open); | |
| } | |
| const departureRatio = departureBody / currentATR; | |
| strength += Math.min(40, Math.round((departureRatio / 3) * 40)); | |
| // b) Base tightness: smaller base range = stronger β 30 pts | |
| const baseRange = zoneTop - zoneBottom; | |
| if (baseRange > 0) { | |
| const tightnessRatio = currentATR / baseRange; // higher = tighter | |
| strength += Math.min(30, Math.round(Math.min(tightnessRatio, 3) / 3 * 30)); | |
| } else { | |
| strength += 30; // zero-range base is maximally tight | |
| } | |
| // c) Fresh = full 30 pts (will be reduced by updateZoneStatus later) | |
| strength += 30; | |
| strength = Math.max(0, Math.min(100, strength)); | |
| // d) Reversal patterns (DBR / RBD) get a small bonus, capped at 100 | |
| if (pattern === 'DBR' || pattern === 'RBD') { | |
| strength = Math.min(100, strength + 10); | |
| } | |
| zones.push({ | |
| type: zoneType, | |
| pattern, | |
| top: zoneTop, | |
| bottom: zoneBottom, | |
| startIndex: baseStart, | |
| endIndex: baseEnd, | |
| time: candles[baseStart].time, | |
| strength, | |
| status: /** @type {'fresh'} */ ('fresh'), | |
| testCount: 0, | |
| }); | |
| // Advance past this pattern | |
| i = secondMoveEnd + 1; | |
| } | |
| return zones; | |
| } | |
| /** | |
| * Update zone status by checking if price has tested or broken through each zone. | |
| * | |
| * - Touch but bounce β status='tested', testCount++ | |
| * - Close through zone β status='broken' | |
| * - More than 3 tests β reduce strength by 10 per extra test | |
| * | |
| * @param {Zone[]} zones | |
| * @param {{ time: number, open: number, high: number, low: number, close: number, volume: number }[]} candles | |
| * @returns {Zone[]} | |
| */ | |
| export function updateZoneStatus(zones, candles) { | |
| if (!zones || zones.length === 0 || !candles || candles.length === 0) { | |
| return zones || []; | |
| } | |
| return zones.map(zone => { | |
| if (zone.status === 'broken') return zone; | |
| const updated = { ...zone }; | |
| let currentlyInside = false; | |
| // Only check candles after the zone formed | |
| for (let c = zone.endIndex + 1; c < candles.length; c++) { | |
| const candle = candles[c]; | |
| if (zone.type === 'demand') { | |
| // Price dips into zone | |
| const touchesZone = candle.low <= zone.top && candle.low >= zone.bottom; | |
| const closesThrough = candle.close < zone.bottom; | |
| if (closesThrough) { | |
| updated.status = 'broken'; | |
| break; | |
| } | |
| if (touchesZone && candle.close > zone.top) { | |
| // Bounced off the zone (only count if price entered from outside) | |
| if (!currentlyInside) { | |
| updated.status = 'tested'; | |
| updated.testCount++; | |
| if (updated.firstTestIndex === undefined) { | |
| updated.firstTestIndex = c; | |
| } | |
| currentlyInside = true; | |
| } | |
| } else { | |
| currentlyInside = false; | |
| } | |
| } else { | |
| // Supply zone | |
| const touchesZone = candle.high >= zone.bottom && candle.high <= zone.top; | |
| const closesThrough = candle.close > zone.top; | |
| if (closesThrough) { | |
| updated.status = 'broken'; | |
| break; | |
| } | |
| if (touchesZone && candle.close < zone.bottom) { | |
| // Bounced off the zone | |
| if (!currentlyInside) { | |
| updated.status = 'tested'; | |
| updated.testCount++; | |
| if (updated.firstTestIndex === undefined) { | |
| updated.firstTestIndex = c; | |
| } | |
| currentlyInside = true; | |
| } | |
| } else { | |
| currentlyInside = false; | |
| } | |
| } | |
| } | |
| // Penalise over-tested zones | |
| if (updated.testCount > 3) { | |
| const penalty = (updated.testCount - 3) * 10; | |
| updated.strength = Math.max(0, zone.strength - penalty); | |
| } | |
| return updated; | |
| }); | |
| } | |