@cmike444/supply-and-demand-zones
Version:
A library for identifying supply and demand zones in candlestick data.
171 lines (170 loc) • 9.82 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.identifyZones = identifyZones;
const constants_1 = require("../constants");
const atr_1 = require("./atr");
const rallyBaseDrop_1 = require("./rallyBaseDrop");
const dropBaseDrop_1 = require("./dropBaseDrop");
const dropBaseRally_1 = require("./dropBaseRally");
const rallyBaseRally_1 = require("./rallyBaseRally");
/**
* Identifies all supply and demand zones in a given array of candles.
*
* Each zone receives a `confidence` score (0–1) built from seven equally-weighted factors:
*
* **Departure leg (×3 weight, computed per zone):**
* - **countFactor**: proportion of departure candles that are decisive or explosive.
* - **rangeFactor**: average departure candle range normalised by local ATR.
* - **volumeFactor**: departure volume relative to base volume (ratio / (ratio + 1)).
*
* **Structural context (×1 weight each, blended in `identifyZones`):**
* - **positionFactor**: higher for supply zones at elevated prices and demand zones at
* depressed prices — harder for the opposing side to push through.
* - **freshnessFactor**: 1.0 if price has never entered the zone since formation; 0.5 if
* price touched the proximal line but was repelled before the distal line.
* - **timeframeFactor**: log-normalised candle interval — 1m → 0.0, 1w → 1.0. Higher
* timeframe zones carry more institutional significance.
* - **rrScore**: departure-based risk/reward score. Measures how far price actually
* travelled during the departure leg relative to the zone width (stop distance).
* `min(departureExtent / stopDistance / 5, 1)` — a 5:1 R:R maps to 1.0. Also stored
* as a standalone `zone.rrScore` property for direct access when grading setups.
*
* Blend formula: `(departureScore × 3 + positionFactor + freshnessFactor + timeframeFactor) / 6`
* for the first six factors, then `(sixFactorScore × 6 + rrScore) / 7` to include the seventh,
* giving each of the seven factors equal weight (~14.3%).
*
* @param candles - An array of Candle objects to scan.
* @returns An object containing arrays of identified supply and demand zones.
*/
function identifyZones(candles) {
var _a;
const supplyZones = [];
const demandZones = [];
const globalMin = Math.min(...candles.map(c => c.low));
const globalMax = Math.max(...candles.map(c => c.high));
const priceRange = globalMax - globalMin;
/** Normalise a price level to [0, 1] across the chart's full price range. */
const normalise = (price) => priceRange > 0 ? (price - globalMin) / priceRange : 0.5;
/**
* 1.0 = never entered; 0.5 = entered (proximal touched) but not breached.
* Supply: price enters when a candle's high >= proximalLine.
* Demand: price enters when a candle's low <= proximalLine.
*/
const freshnessFactor = (postZoneCandles, proximalLine, isSupply) => {
const entered = isSupply
? postZoneCandles.some(c => c.high >= proximalLine)
: postZoneCandles.some(c => c.low <= proximalLine);
return entered ? 0.5 : 1.0;
};
/**
* Infer timeframe factor from the median interval between consecutive candle timestamps.
* Log-normalised: 1m → ~0.0, 5m → ~0.17, 1h → ~0.48, 1d → ~0.72, 1w → 1.0.
*/
const LOG_1M = Math.log(60000);
const LOG_1W = Math.log(604800000);
let timeframeFactor = 0.5; // fallback for < 2 candles
if (candles.length >= 2) {
const intervals = candles.slice(1).map((c, i) => c.timestamp - candles[i].timestamp).filter(d => d > 0);
intervals.sort((a, b) => a - b);
const medianInterval = (_a = intervals[Math.floor(intervals.length / 2)]) !== null && _a !== void 0 ? _a : 60000;
timeframeFactor = Math.min(1, Math.max(0, (Math.log(medianInterval) - LOG_1M) / (LOG_1W - LOG_1M)));
}
/** Blend the first six factors equally (departure×3, position, freshness, timeframe). */
const blendFactors = (departureConfidence, positionFactor, freshnessScore) => (departureConfidence * 3 + positionFactor + freshnessScore + timeframeFactor) / 6;
for (let i = 0; i < candles.length; i++) {
const remainingCandles = candles.slice(i);
const localATR = (0, atr_1.atr)(candles.slice(Math.max(0, i - constants_1.DEFAULT_ATR_PERIOD), i));
const rallyBaseDropZone = (0, rallyBaseDrop_1.rallyBaseDrop)(remainingCandles, localATR);
if (rallyBaseDropZone) {
const endIdx = remainingCandles.findIndex(c => c.timestamp === rallyBaseDropZone.endTimestamp);
const postZone = candles.slice(i + (endIdx !== -1 ? endIdx : 0) + 1);
rallyBaseDropZone.confidence = blendFactors(rallyBaseDropZone.confidence, normalise(rallyBaseDropZone.proximalLine), freshnessFactor(postZone, rallyBaseDropZone.proximalLine, true));
supplyZones.push(rallyBaseDropZone);
if (endIdx !== -1)
i += endIdx;
continue;
}
const dropBaseDropZone = (0, dropBaseDrop_1.dropBaseDrop)(remainingCandles, localATR);
if (dropBaseDropZone) {
const endIdx = remainingCandles.findIndex(c => c.timestamp === dropBaseDropZone.endTimestamp);
const postZone = candles.slice(i + (endIdx !== -1 ? endIdx : 0) + 1);
dropBaseDropZone.confidence = blendFactors(dropBaseDropZone.confidence, normalise(dropBaseDropZone.proximalLine), freshnessFactor(postZone, dropBaseDropZone.proximalLine, true));
supplyZones.push(dropBaseDropZone);
if (endIdx !== -1)
i += endIdx;
continue;
}
const dropBaseRallyZone = (0, dropBaseRally_1.dropBaseRally)(remainingCandles, localATR);
if (dropBaseRallyZone) {
const endIdx = remainingCandles.findIndex(c => c.timestamp === dropBaseRallyZone.endTimestamp);
const postZone = candles.slice(i + (endIdx !== -1 ? endIdx : 0) + 1);
dropBaseRallyZone.confidence = blendFactors(dropBaseRallyZone.confidence, 1 - normalise(dropBaseRallyZone.proximalLine), freshnessFactor(postZone, dropBaseRallyZone.proximalLine, false));
demandZones.push(dropBaseRallyZone);
if (endIdx !== -1)
i += endIdx;
continue;
}
const rallyBaseRallyZone = (0, rallyBaseRally_1.rallyBaseRally)(remainingCandles, localATR);
if (rallyBaseRallyZone) {
const endIdx = remainingCandles.findIndex(c => c.timestamp === rallyBaseRallyZone.endTimestamp);
const postZone = candles.slice(i + (endIdx !== -1 ? endIdx : 0) + 1);
rallyBaseRallyZone.confidence = blendFactors(rallyBaseRallyZone.confidence, 1 - normalise(rallyBaseRallyZone.proximalLine), freshnessFactor(postZone, rallyBaseRallyZone.proximalLine, false));
demandZones.push(rallyBaseRallyZone);
if (endIdx !== -1)
i += endIdx;
continue;
}
}
// --- Post-processing: rrScore ---
// Departure-based: uses the measured distance price actually travelled away from the zone
// during the departure leg as the proxy for target distance.
//
// stopDistance = zone width (|proximalLine − distalLine|).
// departureExtent = for supply zones: min low of departure candles (price went down);
// for demand zones: max high of departure candles (price went up).
// targetDistance = |departureExtent − proximalLine|.
// rrScore = Math.min(targetDistance / stopDistance / 5, 1) — 5:1 R:R maps to 1.0.
//
// This is always computable from the zone's own candles — no opposing zone required.
// Re-blends confidence as a 7th equal slot: (existingConfidence × 6 + rrScore) / 7.
const computeRRScore = (proximalLine, distalLine, endTimestamp, isSupply) => {
const stopDistance = Math.abs(proximalLine - distalLine);
if (stopDistance === 0)
return 0;
// Departure candles run from proximalLine formation up to (and including) endTimestamp.
// We use all candles from the zone's start up to endTimestamp to find the extreme.
const zoneCandles = candles.filter(c => c.timestamp <= endTimestamp);
if (zoneCandles.length === 0)
return 0;
const departureExtent = isSupply
? Math.min(...zoneCandles.map(c => c.low))
: Math.max(...zoneCandles.map(c => c.high));
const targetDistance = Math.abs(departureExtent - proximalLine);
return Math.min((targetDistance / stopDistance) / 5, 1);
};
for (const zone of supplyZones) {
zone.rrScore = computeRRScore(zone.proximalLine, zone.distalLine, zone.endTimestamp, true);
zone.confidence = (zone.confidence * 6 + zone.rrScore) / 7;
}
for (const zone of demandZones) {
zone.rrScore = computeRRScore(zone.proximalLine, zone.distalLine, zone.endTimestamp, false);
zone.confidence = (zone.confidence * 6 + zone.rrScore) / 7;
}
for (const zone of supplyZones) {
zone.entryPrice = zone.proximalLine;
zone.stopPrice = zone.distalLine;
const nearest = demandZones
.filter(d => d.proximalLine < zone.proximalLine)
.sort((a, b) => b.proximalLine - a.proximalLine)[0];
zone.targetPrice = nearest ? nearest.proximalLine : null;
}
for (const zone of demandZones) {
zone.entryPrice = zone.proximalLine;
zone.stopPrice = zone.distalLine;
const nearest = supplyZones
.filter(s => s.proximalLine > zone.proximalLine)
.sort((a, b) => a.proximalLine - b.proximalLine)[0];
zone.targetPrice = nearest ? nearest.proximalLine : null;
}
return { supplyZones, demandZones };
}