UNPKG

@gabriel3615/ta_analysis

Version:

stock ta analysis

186 lines (163 loc) 5.35 kB
import type { Candle } from '../../../types.js'; import { calculateATR } from '../../../util/taUtil.js'; import { rangeConfig } from './rangeConfig.js'; import type { RangeAnalysisResult, RangeBox, BreakoutAssessment, } from './rangeTypes.js'; function findRecentRange(data: Candle[]): RangeBox | undefined { const config = rangeConfig; const atr = calculateATR(data, config.atrPeriod); const rangeMaxHeight = atr * config.rangeAtrMaxMultiplier; const latestAllowedEnd = data.length - 2; // 留出至少1根用于突破判断 let bestRange: RangeBox | undefined = undefined; for (let end = latestAllowedEnd; end >= config.minBarsInRange; end--) { for (let start = end - config.minBarsInRange; start >= 0; start--) { const len = end - start + 1; if (len < config.minBarsInRange) continue; const slice = data.slice(start, end + 1); const high = Math.max(...slice.map(c => c.high)); const low = Math.min(...slice.map(c => c.low)); const height = high - low; if (height <= rangeMaxHeight) { // 统计 NR4/NR7 const nr4s = slice.filter( (c, i) => i > 0 && c.high - c.low < data[start + i - 1].high - data[start + i - 1].low ).length; const nr7s = slice.filter( (c, i) => i > 0 && c.high - c.low < data[start + i - 1].high - data[start + i - 1].low ).length; // Simplified, should be last 7 const currentRange = { startIndex: start, endIndex: end, high, low, nr4Count: nr4s, nr7Count: nr7s, }; // 选择结束位置更靠后的区间作为最近区间 if (!bestRange || currentRange.endIndex > bestRange.endIndex) { bestRange = currentRange; } // 如果找到一个就跳出内层循环,加速寻找最近的 break; } } // 如果已经找到了一个range,就不需要再往前找了,因为我们需要最近的 if (bestRange) break; } return bestRange; } function assessBreakout( data: Candle[], box: RangeBox ): BreakoutAssessment | undefined { const config = rangeConfig; const boxSlice = data.slice(box.startIndex, box.endIndex + 1); const avgBoxVolume = boxSlice.reduce((s, c) => s + c.volume, 0) / boxSlice.length; // 在区间末尾附近寻找首次有效突破点(最后 retestBars+followThroughBars 范围内) const searchEnd = Math.min( data.length - 1, box.endIndex + config.retestBars + config.followThroughBars ); let breakoutIndex = -1; let direction: 'up' | 'down' | undefined = undefined; for (let i = box.endIndex + 1; i <= searchEnd; i++) { const c = data[i]; if (c.close > box.high * (1 + config.breakoutThresholdPercent)) { breakoutIndex = i; direction = 'up'; break; } if (c.close < box.low * (1 - config.breakoutThresholdPercent)) { breakoutIndex = i; direction = 'down'; break; } } if (breakoutIndex === -1) { return undefined; } const breakoutCandle = data[breakoutIndex]; // 成交量扩张(相对区间均量) const volumeExpansion = breakoutCandle.volume > avgBoxVolume * config.volumeExpansionRatio; // 跟随:突破后接下来的 N 根是否累计延续 >= 阈值 let followThrough = false; if (breakoutIndex + config.followThroughBars < data.length) { const followSlice = data.slice( breakoutIndex + 1, breakoutIndex + 1 + config.followThroughBars ); const lastFollowCandle = followSlice[followSlice.length - 1]; if (direction === 'up') { followThrough = lastFollowCandle.close > breakoutCandle.close * (1 + config.followThroughMinPercent); } else { followThrough = lastFollowCandle.close < breakoutCandle.close * (1 - config.followThroughMinPercent); } } // 回测:突破后N根内是否回踩区间边界 let retested = false; const retestSlice = data.slice( breakoutIndex + 1, breakoutIndex + 1 + config.retestBars ); for (const c of retestSlice) { if (direction === 'up' && c.low <= box.high) { retested = true; break; } if (direction === 'down' && c.high >= box.low) { retested = true; break; } } // 质量评分:扩张(40)+延续(40)+回测(20) const qualityScore = (volumeExpansion ? 40 : 0) + (followThrough ? 40 : 0) + (retested ? 20 : 0); return { direction: direction!, breakoutIndex, volumeExpansion, followThrough, retested, qualityScore, }; } export function analyzeRange( symbol: string, data: Candle[], timeframe: 'weekly' | 'daily' | '1hour' ): RangeAnalysisResult { const box = findRecentRange(data); if (!box) { return { symbol, timeframe, compressionScore: 0 }; } const breakout = assessBreakout(data, box); // 收缩强度:NR4/NR7命中 + 区间宽度越小越高 const atr = calculateATR(data, rangeConfig.atrPeriod); const compressionScore = Math.max( 0, (1 - (box.high - box.low) / (atr * rangeConfig.rangeAtrMaxMultiplier)) * 80 + (box.nr4Count > 0 ? 10 : 0) + (box.nr7Count > 0 ? 10 : 0) ); return { symbol, timeframe, range: box, compressionScore, breakout, }; }