ai-trading-sys/src/helpers/candles.ts
2025-07-09 10:41:41 +07:00

649 lines
22 KiB
TypeScript

interface Candle {
open: number;
close: number;
low: number;
high: number;
}
type PatternType =
| "Doji"
| "Hammer"
| "Dragon Doji"
| "Shooting Star"
| "Inverse Hammer"
| "Hanging Man" // Thêm Hanging Man
| "Bullish Engulfing"
| "Bearish Engulfing"
| "Piercing Pattern"
| "Dark Cloud Cover"
| "Morning Star"
| "Evening Star"
| "Three White Soldiers"
| "Three Black Crows"
| "None";
type ReversalDirection =
| "Bullish Reversal"
| "Bearish Reversal"
| "Potential Reversal"
| "None";
interface CandleAnalysis {
pattern: PatternType;
reversalDirection: ReversalDirection;
isReversal: boolean;
}
// Global parameters for pattern detection
const BODY_TOLERANCE_PERCENT = 0.05; // For Doji: body size relative to total range
const WICK_FACTOR = 2; // For Hammer/Shooting Star: wick size relative to body size
const SMALL_BODY_MAX_PERCENT = 0.3; // Max body size for patterns like Hammer/Shooting Star (30% of total range)
const BODY_POSITION_THRESHOLD = 0.7; // For Hammer: body in upper 30%
const INVERSE_BODY_POSITION_THRESHOLD = 0.3; // For Inverse Hammer: body in lower 30%
const WICK_MIN_PERCENT_OF_RANGE = 0.6; // For Dragon Doji: lower wick is at least 60% of total range
const PIERCING_DARKCLOUD_THRESHOLD = 0.5; // For Piercing/Dark Cloud: close must be above/below midpoint of previous body
// --- Helper Functions ---
function isBullish(candle: Candle): boolean {
return candle.close > candle.open;
}
function isBearish(candle: Candle): boolean {
return candle.close < candle.open;
}
function getBodySize(candle: Candle): number {
return Math.abs(candle.open - candle.close);
}
function getTotalRange(candle: Candle): number {
return candle.high - candle.low;
}
function getUpperWick(candle: Candle): number {
return candle.high - Math.max(candle.open, candle.close);
}
function getLowerWick(candle: Candle): number {
return Math.min(candle.open, candle.close) - candle.low;
}
// --- Single-Candle Patterns ---
/**
* Doji: open and close are virtually the same.
*/
function isDoji(candle: Candle): boolean {
const range = getTotalRange(candle);
if (range === 0) return true;
const bodySize = getBodySize(candle);
return bodySize <= range * BODY_TOLERANCE_PERCENT;
}
/**
* Hammer: Small body at the top, long lower wick, little/no upper wick. Bullish reversal.
*/
function isHammer(candle: Candle): boolean {
const bodySize = getBodySize(candle);
const totalRange = getTotalRange(candle);
if (totalRange === 0) return false;
const lowerWick = getLowerWick(candle);
const upperWick = getUpperWick(candle);
// Small body
const hasSmallBody = bodySize <= totalRange * SMALL_BODY_MAX_PERCENT;
// Long lower wick (at least WICK_FACTOR times body size)
const hasLongLowerWick = lowerWick >= WICK_FACTOR * bodySize && lowerWick > 0;
// Little/no upper wick
const hasSmallUpperWick =
upperWick < totalRange * 0.1 || upperWick < bodySize * 0.5;
// Body in the upper part (at least 70% from the low)
const isBodyInUpperPart =
(Math.max(candle.open, candle.close) - candle.low) / totalRange >=
BODY_POSITION_THRESHOLD;
return (
hasSmallBody && hasLongLowerWick && hasSmallUpperWick && isBodyInUpperPart
);
}
/**
* Inverse Hammer: Small body at the bottom, long upper wick, little/no lower wick. Bullish reversal.
*/
function isInverseHammer(candle: Candle): boolean {
const bodySize = getBodySize(candle);
const totalRange = getTotalRange(candle);
if (totalRange === 0) return false;
const lowerWick = getLowerWick(candle);
const upperWick = getUpperWick(candle);
// Small body
const hasSmallBody = bodySize <= totalRange * SMALL_BODY_MAX_PERCENT;
// Long upper wick (at least WICK_FACTOR times body size)
const hasLongUpperWick = upperWick >= WICK_FACTOR * bodySize && upperWick > 0;
// Little/no lower wick
const hasSmallLowerWick =
lowerWick < totalRange * 0.1 || lowerWick < bodySize * 0.5;
// Body in the lower part (at most 30% from the low)
const isBodyInLowerPart =
(Math.max(candle.open, candle.close) - candle.low) / totalRange <=
INVERSE_BODY_POSITION_THRESHOLD;
return (
hasSmallBody && hasLongUpperWick && hasSmallLowerWick && isBodyInLowerPart
);
}
/**
* Dragon Doji: Open, close, high are virtually the same, long lower wick. Bullish reversal.
*/
function isDragonDoji(candle: Candle): boolean {
const range = getTotalRange(candle);
if (range === 0) return false;
// Open, close, high are virtually the same (Doji-like body at the high)
const upperBodySimilarity =
Math.abs(candle.open - candle.high) <= range * BODY_TOLERANCE_PERCENT &&
Math.abs(candle.close - candle.high) <= range * BODY_TOLERANCE_PERCENT &&
Math.abs(candle.open - candle.close) <= range * BODY_TOLERANCE_PERCENT;
if (!upperBodySimilarity) return false;
const lowerWick = getLowerWick(candle);
// Long lower wick (significant portion of the candle's total range)
const hasLongLowerWick = lowerWick > range * WICK_MIN_PERCENT_OF_RANGE; // e.g., > 60%
return hasLongLowerWick && lowerWick > 0.001; // Ensure a distinct lower wick exists
}
/**
* Shooting Star: Small body at the bottom, long upper wick, little/no lower wick. Bearish reversal.
*/
function isShootingStar(candle: Candle): boolean {
const bodySize = getBodySize(candle);
const totalRange = getTotalRange(candle);
if (totalRange === 0) return false;
const lowerWick = getLowerWick(candle);
const upperWick = getUpperWick(candle);
// Small body
const hasSmallBody = bodySize <= totalRange * SMALL_BODY_MAX_PERCENT;
// Long upper wick (at least WICK_FACTOR times body size)
const hasLongUpperWick = upperWick >= WICK_FACTOR * bodySize && upperWick > 0;
// Little/no lower wick
const hasSmallLowerWick =
lowerWick < totalRange * 0.1 || lowerWick < bodySize * 0.5;
// Body in the lower part (at most 30% from the high, or 70% from the low)
const isBodyInLowerPart =
(Math.max(candle.open, candle.close) - candle.low) / totalRange <=
INVERSE_BODY_POSITION_THRESHOLD;
return (
hasSmallBody && hasLongUpperWick && hasSmallLowerWick && isBodyInLowerPart
);
}
/**
* Hanging Man: Small body at the top, long lower wick, little/no upper wick. Bearish reversal.
* Similar to Hammer, but appears in an uptrend. The pattern itself is the same, context defines its name/implication.
*/
function isHangingMan(candle: Candle): boolean {
// Hanging Man has the exact same visual characteristics as a Hammer.
// The distinction is the preceding trend (uptrend for Hanging Man, downtrend for Hammer).
// For this function, we only check the candle's shape.
// The `analyzeCandleSequence` will infer the context if you pass previous candles.
return isHammer(candle);
}
// --- Two-Candle Patterns ---
/**
* Bullish Engulfing: A small bearish candle followed by a large bullish candle that completely engulfs the previous body.
*/
function isBullishEngulfing(candles: Candle[]): boolean {
if (candles.length < 2) return false;
const prevCandle = candles[candles.length - 2];
const currentCandle = candles[candles.length - 1];
// Previous candle must be bearish, current must be bullish
if (!isBearish(prevCandle) || !isBullish(currentCandle)) return false;
// Current candle body must engulf previous candle body
const prevBodyMin = Math.min(prevCandle.open, prevCandle.close);
const prevBodyMax = Math.max(prevCandle.open, prevCandle.close);
const currBodyMin = Math.min(currentCandle.open, currentCandle.close);
const currBodyMax = Math.max(currentCandle.open, currentCandle.close);
return currBodyMin <= prevBodyMin && currBodyMax >= prevBodyMax;
}
/**
* Bearish Engulfing: A small bullish candle followed by a large bearish candle that completely engulfs the previous body.
*/
function isBearishEngulfing(candles: Candle[]): boolean {
if (candles.length < 2) return false;
const prevCandle = candles[candles.length - 2];
const currentCandle = candles[candles.length - 1];
// Previous candle must be bullish, current must be bearish
if (!isBullish(prevCandle) || !isBearish(currentCandle)) return false;
// Current candle body must engulf previous candle body
const prevBodyMin = Math.min(prevCandle.open, prevCandle.close);
const prevBodyMax = Math.max(prevCandle.open, prevCandle.close);
const currBodyMin = Math.min(currentCandle.open, currentCandle.close);
const currBodyMax = Math.max(currentCandle.open, currentCandle.close);
return currBodyMin <= prevBodyMin && currBodyMax >= prevBodyMax;
}
/**
* Piercing Pattern: A long bearish candle followed by a bullish candle that opens below the low of the previous and closes above its midpoint.
*/
function isPiercingPattern(candles: Candle[]): boolean {
if (candles.length < 2) return false;
const prevCandle = candles[candles.length - 2];
const currentCandle = candles[candles.length - 1];
// Previous candle must be long and bearish
if (
!isBearish(prevCandle) ||
getBodySize(prevCandle) < getTotalRange(prevCandle) * 0.6
)
return false; // Long body
// Current candle must be bullish
if (!isBullish(currentCandle)) return false;
// Current candle opens below previous candle's low
if (currentCandle.open >= prevCandle.low) return false;
// Current candle closes above the midpoint of the previous candle's body
const prevMidpoint =
prevCandle.close + getBodySize(prevCandle) * PIERCING_DARKCLOUD_THRESHOLD;
return currentCandle.close > prevMidpoint;
}
/**
* Dark Cloud Cover: A long bullish candle followed by a bearish candle that opens above the high of the previous and closes below its midpoint.
*/
function isDarkCloudCover(candles: Candle[]): boolean {
if (candles.length < 2) return false;
const prevCandle = candles[candles.length - 2];
const currentCandle = candles[candles.length - 1];
// Previous candle must be long and bullish
if (
!isBullish(prevCandle) ||
getBodySize(prevCandle) < getTotalRange(prevCandle) * 0.6
)
return false; // Long body
// Current candle must be bearish
if (!isBearish(currentCandle)) return false;
// Current candle opens above previous candle's high
if (currentCandle.open <= prevCandle.high) return false;
// Current candle closes below the midpoint of the previous candle's body
const prevMidpoint =
prevCandle.open - getBodySize(prevCandle) * PIERCING_DARKCLOUD_THRESHOLD;
return currentCandle.close < prevMidpoint;
}
// --- Three-Candle Patterns ---
/**
* Morning Star: Long bearish, small body (gap down), long bullish. Bullish reversal.
*/
function isMorningStar(candles: Candle[]): boolean {
if (candles.length < 3) return false;
const candle1 = candles[candles.length - 3];
const candle2 = candles[candles.length - 2];
const candle3 = candles[candles.length - 1];
// Candle 1: Long bearish candle
if (
!isBearish(candle1) ||
getBodySize(candle1) < getTotalRange(candle1) * 0.6
)
return false;
// Candle 2: Small body (could be Doji or Spinning Top) and gaps down
if (getBodySize(candle2) > getTotalRange(candle2) * 0.3) return false; // Small body
if (candle2.high >= candle1.close) return false; // Gap down (high of candle2 must be below close of candle1)
// Candle 3: Long bullish candle, closes above midpoint of candle 1
if (
!isBullish(candle3) ||
getBodySize(candle3) < getTotalRange(candle3) * 0.6
)
return false;
const candle1Midpoint =
candle1.close + getBodySize(candle1) * PIERCING_DARKCLOUD_THRESHOLD;
if (candle3.close <= candle1Midpoint) return false;
return true;
}
/**
* Evening Star: Long bullish, small body (gap up), long bearish. Bearish reversal.
*/
function isEveningStar(candles: Candle[]): boolean {
if (candles.length < 3) return false;
const candle1 = candles[candles.length - 3];
const candle2 = candles[candles.length - 2];
const candle3 = candles[candles.length - 1];
// Candle 1: Long bullish candle
if (
!isBullish(candle1) ||
getBodySize(candle1) < getTotalRange(candle1) * 0.6
)
return false;
// Candle 2: Small body (could be Doji or Spinning Top) and gaps up
if (getBodySize(candle2) > getTotalRange(candle2) * 0.3) return false; // Small body
if (candle2.low <= candle1.close) return false; // Gap up (low of candle2 must be above close of candle1)
// Candle 3: Long bearish candle, closes below midpoint of candle 1
if (
!isBearish(candle3) ||
getBodySize(candle3) < getTotalRange(candle3) * 0.6
)
return false;
const candle1Midpoint =
candle1.open - getBodySize(candle1) * PIERCING_DARKCLOUD_THRESHOLD;
if (candle3.close >= candle1Midpoint) return false;
return true;
}
/**
* Three White Soldiers: Three consecutive long bullish candles. Bullish reversal.
*/
function isThreeWhiteSoldiers(candles: Candle[]): boolean {
if (candles.length < 3) return false;
const c1 = candles[candles.length - 3];
const c2 = candles[candles.length - 2];
const c3 = candles[candles.length - 1];
// All three must be bullish candles with relatively long bodies
if (!isBullish(c1) || !isBullish(c2) || !isBullish(c3)) return false;
if (
getBodySize(c1) < getTotalRange(c1) * 0.4 ||
getBodySize(c2) < getTotalRange(c2) * 0.4 ||
getBodySize(c3) < getTotalRange(c3) * 0.4
)
return false; // At least 40% of range
// Each opens within previous body and closes higher than previous close
if (c2.open <= c1.open || c2.open >= c1.close) return false; // Open within body of previous
if (c2.close <= c1.close) return false; // Close higher than previous
if (c3.open <= c2.open || c3.open >= c2.close) return false; // Open within body of previous
if (c3.close <= c2.close) return false; // Close higher than previous
// Little to no upper wicks
if (
getUpperWick(c1) > getBodySize(c1) * 0.5 ||
getUpperWick(c2) > getBodySize(c2) * 0.5 ||
getUpperWick(c3) > getBodySize(c3) * 0.5
)
return false;
return true;
}
/**
* Three Black Crows: Three consecutive long bearish candles. Bearish reversal.
*/
function isThreeBlackCrows(candles: Candle[]): boolean {
if (candles.length < 3) return false;
const c1 = candles[candles.length - 3];
const c2 = candles[candles.length - 2];
const c3 = candles[candles.length - 1];
// All three must be bearish candles with relatively long bodies
if (!isBearish(c1) || !isBearish(c2) || !isBearish(c3)) return false;
if (
getBodySize(c1) < getTotalRange(c1) * 0.4 ||
getBodySize(c2) < getTotalRange(c2) * 0.4 ||
getBodySize(c3) < getTotalRange(c3) * 0.4
)
return false; // At least 40% of range
// Each opens within previous body and closes lower than previous close
if (c2.open >= c1.open || c2.open <= c1.close) return false; // Open within body of previous
if (c2.close >= c1.close) return false; // Close lower than previous
if (c3.open >= c2.open || c3.open <= c2.close) return false; // Open within body of previous
if (c3.close >= c2.close) return false; // Close lower than previous
// Little to no lower wicks
if (
getLowerWick(c1) > getBodySize(c1) * 0.5 ||
getLowerWick(c2) > getBodySize(c2) * 0.5 ||
getLowerWick(c3) > getBodySize(c3) * 0.5
)
return false;
return true;
}
// --- Main Analysis Function ---
/**
* Analyzes a sequence of candles to determine its pattern and reversal characteristics.
* IMPORTANT: Pass at least 3 candles for multi-candle patterns to be detected correctly.
* The most recent candle should be at the end of the array.
* @param candles An array of Candle objects.
* @returns An object containing the pattern type, reversal direction, and whether it's a reversal.
*/
export function analyzeCandleSequence(candles: Candle[]): CandleAnalysis {
if (candles.length === 0) {
return { pattern: "None", reversalDirection: "None", isReversal: false };
}
const currentCandle = candles[candles.length - 1]; // The latest candle
// Multi-candle patterns (check with more historical data first)
if (candles.length >= 3) {
if (isMorningStar(candles)) {
return {
pattern: "Morning Star",
reversalDirection: "Bullish Reversal",
isReversal: true,
};
}
if (isEveningStar(candles)) {
return {
pattern: "Evening Star",
reversalDirection: "Bearish Reversal",
isReversal: true,
};
}
if (isThreeWhiteSoldiers(candles)) {
return {
pattern: "Three White Soldiers",
reversalDirection: "Bullish Reversal",
isReversal: true,
};
}
if (isThreeBlackCrows(candles)) {
return {
pattern: "Three Black Crows",
reversalDirection: "Bearish Reversal",
isReversal: true,
};
}
}
if (candles.length >= 2) {
if (isBullishEngulfing(candles)) {
return {
pattern: "Bullish Engulfing",
reversalDirection: "Bullish Reversal",
isReversal: true,
};
}
if (isBearishEngulfing(candles)) {
return {
pattern: "Bearish Engulfing",
reversalDirection: "Bearish Reversal",
isReversal: true,
};
}
if (isPiercingPattern(candles)) {
return {
pattern: "Piercing Pattern",
reversalDirection: "Bullish Reversal",
isReversal: true,
};
}
if (isDarkCloudCover(candles)) {
return {
pattern: "Dark Cloud Cover",
reversalDirection: "Bearish Reversal",
isReversal: true,
};
}
}
// Single-candle patterns
// Order matters: More specific patterns should be checked before more general ones.
// For example, Dragon Doji is a type of Doji, so check it first.
// Hammer and Hanging Man have identical shapes, distinguish by context if needed elsewhere.
if (isHammer(currentCandle)) {
// If you had trend detection, you could differentiate Hammer (downtrend) vs Hanging Man (uptrend) here.
// For now, assuming shape check:
return {
pattern: "Hammer",
reversalDirection: "Bullish Reversal",
isReversal: true,
}; // Default to Hammer if no trend context
}
if (isShootingStar(currentCandle)) {
return {
pattern: "Shooting Star",
reversalDirection: "Bearish Reversal",
isReversal: true,
};
}
if (isDragonDoji(currentCandle)) {
return {
pattern: "Dragon Doji",
reversalDirection: "Bullish Reversal",
isReversal: true,
};
}
if (isInverseHammer(currentCandle)) {
return {
pattern: "Inverse Hammer",
reversalDirection: "Bullish Reversal",
isReversal: true,
};
}
// Doji is checked last among single-candle patterns because other patterns might be more specific forms of Doji (like Dragon Doji)
if (isDoji(currentCandle)) {
return {
pattern: "Doji",
reversalDirection: "Potential Reversal",
isReversal: true,
};
}
return { pattern: "None", reversalDirection: "None", isReversal: false };
}
// Global parameters (có thể điều chỉnh)
const HIGH_VOLATILITY_RANGE_MULTIPLIER = 1.5; // Phạm vi nến phải lớn hơn 1.5 lần so với ATR hoặc trung bình
const HIGH_VOLATILITY_BODY_MULTIPLIER = 1.5; // Thân nến phải lớn hơn 1.5 lần so với trung bình thân nến
const MIN_CANDLES_FOR_AVERAGE = 10; // Số lượng nến tối thiểu để tính trung bình
/**
* Calculates the Average True Range (ATR) for a given set of candles.
* ATR is a measure of market volatility.
* @param candles An array of Candle objects.
* @returns The ATR value.
*/
export function calculateATR(candles: Candle[]): number {
if (candles.length < 2) return 0; // Need at least 2 candles to calculate first TR
let trueRanges: number[] = [];
for (let i = 1; i < candles.length; i++) {
const prevClose = candles[i - 1].close;
const currentCandle = candles[i];
const tr1 = currentCandle.high - currentCandle.low;
const tr2 = Math.abs(currentCandle.high - prevClose);
const tr3 = Math.abs(currentCandle.low - prevClose);
trueRanges.push(Math.max(tr1, tr2, tr3));
}
// Simple Moving Average of True Range (can be replaced with Exponential Moving Average for more responsiveness)
const sumOfTrueRanges = trueRanges.reduce((sum, tr) => sum + tr, 0);
return sumOfTrueRanges / trueRanges.length;
}
/**
* Calculates the average body size for a given set of candles.
* @param candles An array of Candle objects.
* @returns The average body size.
*/
export function calculateAverageBodySize(candles: Candle[]): number {
if (candles.length === 0) return 0;
const totalBodySize = candles.reduce(
(sum, candle) => sum + getBodySize(candle),
0
);
return totalBodySize / candles.length;
}
/**
* Checks if the most recent candle in a sequence exhibits high volatility.
* This function considers both range and body size relative to historical data.
* @param candles An array of Candle objects, with the most recent candle at the end.
* @returns True if the latest candle shows high volatility, false otherwise.
*/
export function isHighVolatilityCandle(candles: Candle[]): boolean {
if (candles.length === 0) return false;
const currentCandle = candles[candles.length - 1];
// If not enough historical candles, we cannot compare against average
if (candles.length < MIN_CANDLES_FOR_AVERAGE) {
// For very few candles, we can define "high volatility" as simply a very large absolute range/body
// You might want to define absolute thresholds here for initial candles.
// E.g., return getTotalRange(currentCandle) > 5 || getBodySize(currentCandle) > 3;
// For now, return false if not enough history for comparative analysis.
return false;
}
// Take a look at historical data (e.g., the last MIN_CANDLES_FOR_AVERAGE candles)
const historicalCandles = candles.slice(-MIN_CANDLES_FOR_AVERAGE - 1, -1); // Exclude the current candle itself
// Calculate average true range (ATR) for volatility comparison
const averageTrueRange = calculateATR(historicalCandles);
// Calculate average body size for comparison
const averageBodySize = calculateAverageBodySize(historicalCandles);
const currentCandleRange = getTotalRange(currentCandle);
const currentCandleBodySize = getBodySize(currentCandle);
// Criteria for high volatility:
// 1. Current candle's total range is significantly larger than historical average range (e.g., ATR)
const isRangeHigh =
currentCandleRange > averageTrueRange * HIGH_VOLATILITY_RANGE_MULTIPLIER;
// 2. Current candle's body size is significantly larger than historical average body size
const isBodySizeHigh =
currentCandleBodySize > averageBodySize * HIGH_VOLATILITY_BODY_MULTIPLIER;
// A candle is considered high volatility if either its range or its body size (or both) are significantly larger
return isRangeHigh || isBodySizeHigh;
}