trade-analyzer / src /analysis /supplyDemand.js
Aditya4573's picture
Optimize SL ATR buffers dynamically, enforce zone freshness, and align backtester spread execution
86afb93
Raw
History Blame Contribute Delete
9.03 kB
/**
* @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;
});
}