500 lines
19 KiB
TypeScript
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 {};
|
|
|