// 전략 평가 Web Worker // 별도 스레드에서 전략 조건 평가를 수행하여 메인 UI 스레드 블로킹 방지 interface StrategyConditionDto { id?: number; indicatorType: string; conditionType: string; targetValue?: number; targetValue2?: number; description?: string; enabled?: boolean; } interface ConditionGroupDto { id?: number; name?: string; logicalOperator: 'AND' | 'OR'; conditions: StrategyConditionDto[]; childGroups?: ConditionGroupDto[]; groupType?: 'BUY' | 'SELL'; signalType?: string; } interface StrategyRuleDto { id: number; name: string; enabled: boolean; conditionGroups: ConditionGroupDto[]; buyConditionGroups?: ConditionGroupDto[]; sellConditionGroups?: ConditionGroupDto[]; enableMaBuy?: boolean; enableAuxBuy?: boolean; totalConditionCount?: number; conditionSummary?: string; } interface EvaluationMessage { type: 'evaluate'; strategies: StrategyRuleDto[]; currentData: any; prevData: any | null; currentIndex: number; referenceTime: string; } interface ConditionResult { description: string; matched: boolean; } interface EvaluationResult { buy: { total: number; matched: number; percentage: number; conditions: ConditionResult[]; }; sell: { total: number; matched: number; percentage: number; conditions: ConditionResult[]; }; } interface StrategyEvaluationResult { strategyId: number; strategyName: string; enabled: boolean; evaluation: EvaluationResult; } interface WorkerResponse { type: 'result'; results: StrategyEvaluationResult[]; currentIndex: number; referenceTime: string; } // 단일 조건 평가 함수 function evaluateCondition( condition: StrategyConditionDto, currentData: any, prevData: any | null ): boolean { if (!currentData || condition.enabled === false) return false; const { indicatorType, conditionType, targetValue, targetValue2 } = condition; try { switch (indicatorType) { case 'RSI': { const rsiValue = currentData.rsi; const prevRsi = prevData?.rsi; if (rsiValue == null) return false; switch (conditionType) { case 'ABOVE': return rsiValue > (targetValue || 50); case 'BELOW': return rsiValue < (targetValue || 50); case 'CROSS_UP': return prevRsi != null && prevRsi <= (targetValue || 50) && rsiValue > (targetValue || 50); case 'CROSS_DOWN': return prevRsi != null && prevRsi >= (targetValue || 50) && rsiValue < (targetValue || 50); case 'OVERBOUGHT': return rsiValue >= (targetValue || 70); case 'OVERSOLD': return rsiValue <= (targetValue || 30); case 'RANGE': return rsiValue >= (targetValue || 30) && rsiValue <= (targetValue2 || 70); default: return false; } } case 'STOCHASTIC': case 'STOCHASTIC_K': case 'STOCHASTIC_D': { const kValue = currentData.stochasticK; const dValue = currentData.stochasticD; const prevK = prevData?.stochasticK; const prevD = prevData?.stochasticD; const value = indicatorType === 'STOCHASTIC_D' ? dValue : kValue; const prevValue = indicatorType === 'STOCHASTIC_D' ? prevD : prevK; if (value == null) return false; switch (conditionType) { case 'ABOVE': return value > (targetValue || 50); case 'BELOW': return value < (targetValue || 50); case 'CROSS_UP': return prevValue != null && prevValue <= (targetValue || 50) && value > (targetValue || 50); case 'CROSS_DOWN': return prevValue != null && prevValue >= (targetValue || 50) && value < (targetValue || 50); case 'GOLDEN_CROSS': return prevK != null && prevD != null && kValue != null && dValue != null && prevK <= prevD && kValue > dValue; case 'DEAD_CROSS': return prevK != null && prevD != null && kValue != null && dValue != null && prevK >= prevD && kValue < dValue; case 'OVERBOUGHT': return value >= (targetValue || 80); case 'OVERSOLD': return value <= (targetValue || 20); default: return false; } } case 'MACD': case 'MACD_LINE': case 'MACD_SIGNAL': case 'MACD_HISTOGRAM': { const macdLine = currentData.macdLine; const signalLine = currentData.signalLine; const histogram = currentData.macdHistogram; const prevMacd = prevData?.macdLine; const prevSignal = prevData?.signalLine; const prevHist = prevData?.macdHistogram; let value: number | null = null; let prevValue: number | null = null; if (indicatorType === 'MACD_SIGNAL') { value = signalLine; prevValue = prevSignal; } else if (indicatorType === 'MACD_HISTOGRAM') { value = histogram; prevValue = prevHist; } else { value = macdLine; prevValue = prevMacd; } if (value == null) return false; switch (conditionType) { case 'ABOVE': return value > (targetValue || 0); case 'BELOW': return value < (targetValue || 0); case 'CROSS_UP': return prevValue != null && prevValue <= (targetValue || 0) && value > (targetValue || 0); case 'CROSS_DOWN': return prevValue != null && prevValue >= (targetValue || 0) && value < (targetValue || 0); case 'GOLDEN_CROSS': return prevMacd != null && prevSignal != null && macdLine != null && signalLine != null && prevMacd <= prevSignal && macdLine > signalLine; case 'DEAD_CROSS': return prevMacd != null && prevSignal != null && macdLine != null && signalLine != null && prevMacd >= prevSignal && macdLine < signalLine; case 'HISTOGRAM_POSITIVE': return histogram != null && histogram > 0; case 'HISTOGRAM_NEGATIVE': return histogram != null && histogram < 0; case 'HISTOGRAM_INCREASING': return histogram != null && prevHist != null && histogram > prevHist; case 'HISTOGRAM_DECREASING': return histogram != null && prevHist != null && histogram < prevHist; default: return false; } } case 'ADX': { const adxValue = currentData.adx; if (adxValue == null) return false; switch (conditionType) { case 'ABOVE': return adxValue > (targetValue || 25); case 'BELOW': return adxValue < (targetValue || 25); case 'STRONG_TREND': return adxValue >= (targetValue || 25); case 'WEAK_TREND': return adxValue < (targetValue || 20); default: return false; } } case 'BOLLINGER': case 'BOLLINGER_UPPER': case 'BOLLINGER_LOWER': case 'BOLLINGER_MIDDLE': { const close = currentData.close; const upper = currentData.bollingerUpper; const lower = currentData.bollingerLower; const middle = currentData.bollingerMiddle; if (close == null) return false; switch (conditionType) { case 'ABOVE_UPPER': return upper != null && close > upper; case 'BELOW_LOWER': return lower != null && close < lower; case 'TOUCH_UPPER': return upper != null && Math.abs(close - upper) / upper < 0.001; case 'TOUCH_LOWER': return lower != null && Math.abs(close - lower) / lower < 0.001; case 'INSIDE_BANDS': return upper != null && lower != null && close <= upper && close >= lower; case 'ABOVE_MIDDLE': return middle != null && close > middle; case 'BELOW_MIDDLE': return middle != null && close < middle; default: return false; } } case 'MA': case 'MA5': case 'MA10': case 'MA20': case 'MA60': case 'MA120': { const close = currentData.close; const prevClose = prevData?.close; let maValue: number | null = null; let prevMa: number | null = null; switch (indicatorType) { case 'MA5': maValue = currentData.ma5; prevMa = prevData?.ma5; break; case 'MA10': maValue = currentData.ma10; prevMa = prevData?.ma10; break; case 'MA20': maValue = currentData.ma20; prevMa = prevData?.ma20; break; case 'MA60': maValue = currentData.ma60; prevMa = prevData?.ma60; break; case 'MA120': maValue = currentData.ma120; prevMa = prevData?.ma120; break; default: maValue = currentData.ma20; prevMa = prevData?.ma20; } if (close == null || maValue == null) return false; switch (conditionType) { case 'ABOVE': return close > maValue; case 'BELOW': return close < maValue; case 'CROSS_UP': return prevClose != null && prevMa != null && prevClose <= prevMa && close > maValue; case 'CROSS_DOWN': return prevClose != null && prevMa != null && prevClose >= prevMa && close < maValue; case 'GOLDEN_CROSS': { // 단기MA > 장기MA 교차 const shortMa = currentData.ma5 || currentData.ma10; const longMa = currentData.ma20 || currentData.ma60; const prevShort = prevData?.ma5 || prevData?.ma10; const prevLong = prevData?.ma20 || prevData?.ma60; return shortMa != null && longMa != null && prevShort != null && prevLong != null && prevShort <= prevLong && shortMa > longMa; } case 'DEAD_CROSS': { const shortMa = currentData.ma5 || currentData.ma10; const longMa = currentData.ma20 || currentData.ma60; const prevShort = prevData?.ma5 || prevData?.ma10; const prevLong = prevData?.ma20 || prevData?.ma60; return shortMa != null && longMa != null && prevShort != null && prevLong != null && prevShort >= prevLong && shortMa < longMa; } case 'UPTREND': return maValue > (prevMa || 0); case 'DOWNTREND': return maValue < (prevMa || Infinity); default: return false; } } case 'PRICE': { const close = currentData.close; const prevClose = prevData?.close; if (close == null) return false; switch (conditionType) { case 'ABOVE': return close > (targetValue || 0); case 'BELOW': return close < (targetValue || 0); case 'CHANGE_PERCENT': { if (prevClose == null) return false; const changePercent = ((close - prevClose) / prevClose) * 100; return changePercent >= (targetValue || 0); } case 'RANGE': return close >= (targetValue || 0) && close <= (targetValue2 || Infinity); default: return false; } } case 'VOLUME': { const volume = currentData.volume; const prevVolume = prevData?.volume; if (volume == null) return false; switch (conditionType) { case 'ABOVE': return volume > (targetValue || 0); case 'ABOVE_AVERAGE': { const avgVolume = currentData.volumeAvg || currentData.volumeMa20; return avgVolume != null && volume > avgVolume * (targetValue || 1); } case 'INCREASING': return prevVolume != null && volume > prevVolume; case 'SPIKE': { const avgVolume = currentData.volumeAvg || currentData.volumeMa20; return avgVolume != null && volume > avgVolume * (targetValue || 2); } default: return false; } } case 'CCI': { const cciValue = currentData.cci; const cciSignal = currentData.cciSignal; const prevCci = prevData?.cci; const prevCciSignal = prevData?.cciSignal; if (cciValue == null) return false; switch (conditionType) { case 'ABOVE': return cciValue > (targetValue || 0); case 'BELOW': return cciValue < (targetValue || 0); case 'CROSS_UP': { // targetValue가 있으면 해당 값 돌파, 없으면 -100/0/100 중 하나라도 돌파 if (targetValue != null) { return prevCci != null && prevCci <= targetValue && cciValue > targetValue; } else { // 침체(-100), 중앙(0), 과열(100) 중 하나라도 상향 돌파 const crossOversold = prevCci != null && prevCci < -100 && cciValue >= -100; const crossMiddle = prevCci != null && prevCci < 0 && cciValue >= 0; const crossOverbought = prevCci != null && prevCci < 100 && cciValue >= 100; return crossOversold || crossMiddle || crossOverbought; } } case 'CROSS_DOWN': { // targetValue가 있으면 해당 값 돌파, 없으면 -100/0/100 중 하나라도 돌파 if (targetValue != null) { return prevCci != null && prevCci >= targetValue && cciValue < targetValue; } else { // 침체(-100), 중앙(0), 과열(100) 중 하나라도 하향 돌파 const crossOverbought = prevCci != null && prevCci > 100 && cciValue <= 100; const crossMiddle = prevCci != null && prevCci > 0 && cciValue <= 0; const crossOversold = prevCci != null && prevCci > -100 && cciValue <= -100; return crossOverbought || crossMiddle || crossOversold; } } case 'GOLDEN_CROSS': // CCI가 CCI Signal을 상향 돌파 return cciSignal != null && prevCci != null && prevCciSignal != null && prevCci <= prevCciSignal && cciValue > cciSignal; case 'DEAD_CROSS': // CCI가 CCI Signal을 하향 돌파 return cciSignal != null && prevCci != null && prevCciSignal != null && prevCci >= prevCciSignal && cciValue < cciSignal; case 'OVERBOUGHT': return cciValue >= (targetValue || 100); case 'OVERSOLD': return cciValue <= (targetValue || -100); default: return false; } } case 'WILLIAMS_R': { const wrValue = currentData.williamsR; if (wrValue == null) return false; switch (conditionType) { case 'OVERBOUGHT': return wrValue >= (targetValue || -20); case 'OVERSOLD': return wrValue <= (targetValue || -80); case 'ABOVE': return wrValue > (targetValue || -50); case 'BELOW': return wrValue < (targetValue || -50); default: return false; } } default: return false; } } catch (error) { console.error('Worker: 조건 평가 오류:', error); return false; } } // 조건 그룹 평가 함수 function evaluateConditionGroup( group: ConditionGroupDto, currentData: any, prevData: any | null ): { total: number; matched: number; conditions: ConditionResult[] } { const results: ConditionResult[] = []; let total = 0; let matched = 0; const groupAny = group as any; const conditions = group.conditions || groupAny.strategyConditions || []; for (const condition of conditions) { if (condition.enabled === false) continue; const isMatched = evaluateCondition(condition, currentData, prevData); const description = condition.description || `${condition.indicatorType} ${condition.conditionType}${condition.targetValue ? ` ${condition.targetValue}` : ''}`; results.push({ description, matched: isMatched }); total++; if (isMatched) matched++; } const childGroups = group.childGroups || groupAny.children || []; for (const childGroup of childGroups) { const childResult = evaluateConditionGroup(childGroup, currentData, prevData); total += childResult.total; matched += childResult.matched; results.push(...childResult.conditions); } return { total, matched, conditions: results }; } // 전략 규칙 평가 함수 function evaluateStrategyRule( rule: StrategyRuleDto, currentData: any, prevData: any | null ): EvaluationResult { const buyResult = { total: 0, matched: 0, percentage: 0, conditions: [] as ConditionResult[] }; const sellResult = { total: 0, matched: 0, percentage: 0, conditions: [] as ConditionResult[] }; // buyConditionGroups 처리 if (rule.buyConditionGroups && rule.buyConditionGroups.length > 0) { for (const group of rule.buyConditionGroups) { const result = evaluateConditionGroup(group, currentData, prevData); buyResult.total += result.total; buyResult.matched += result.matched; buyResult.conditions.push(...result.conditions); } } // sellConditionGroups 처리 if (rule.sellConditionGroups && rule.sellConditionGroups.length > 0) { for (const group of rule.sellConditionGroups) { const result = evaluateConditionGroup(group, currentData, prevData); sellResult.total += result.total; sellResult.matched += result.matched; sellResult.conditions.push(...result.conditions); } } // 기존 conditionGroups 처리 if (rule.conditionGroups && rule.conditionGroups.length > 0) { for (const group of rule.conditionGroups) { const result = evaluateConditionGroup(group, currentData, prevData); const groupAny = group as any; const isBuy = group.groupType === 'BUY' || groupAny.signalType === 'BUY'; const isSell = group.groupType === 'SELL' || groupAny.signalType === 'SELL'; if (isSell) { sellResult.total += result.total; sellResult.matched += result.matched; sellResult.conditions.push(...result.conditions); } else { buyResult.total += result.total; buyResult.matched += result.matched; buyResult.conditions.push(...result.conditions); } } } // 비율 계산 buyResult.percentage = buyResult.total > 0 ? Math.round((buyResult.matched / buyResult.total) * 100) : 0; sellResult.percentage = sellResult.total > 0 ? Math.round((sellResult.matched / sellResult.total) * 100) : 0; return { buy: buyResult, sell: sellResult }; } // Worker 메시지 핸들러 self.onmessage = (event: MessageEvent) => { const { type, strategies, currentData, prevData, currentIndex, referenceTime } = event.data; if (type !== 'evaluate') return; try { const results: StrategyEvaluationResult[] = strategies.map(strategy => ({ strategyId: strategy.id, strategyName: strategy.name, enabled: strategy.enabled, evaluation: evaluateStrategyRule(strategy, currentData, prevData) })); const response: WorkerResponse = { type: 'result', results, currentIndex, referenceTime }; self.postMessage(response); } catch (error) { console.error('Worker: 전략 평가 오류:', error); self.postMessage({ type: 'error', error: String(error), currentIndex, referenceTime }); } }; export {};