Files
goldenChart/frontend_golden/src/workers/strategyEvaluator.worker.ts
T
2026-05-23 15:11:48 +09:00

500 lines
19 KiB
TypeScript

// 전략 평가 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<EvaluationMessage>) => {
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 {};