/** * 통합 알림 메시지 파싱 * 형식: "🎯 알림 조건 충족 (N개)\n종목명 × 전략명\n..." * 한 알림에 여러 종목이 포함될 수 있음 */ /** * 메시지에서 조건 충족한 투자전략명 목록 추출 * "종목명 × 전략명" 형식의 각 라인에서 전략명 부분만 추출 */ export function extractStrategyNames(message: string): string[] | null { if (!message || !message.includes('알림 조건 충족') || !message.includes('개)')) return null; const lines = message.split('\n').map((s) => s.trim()).filter(Boolean); const strategyNames: string[] = []; const SEPARATORS = [' × ', ' X ']; for (let i = 1; i < lines.length; i++) { let idx = -1; for (const sep of SEPARATORS) { idx = lines[i].indexOf(sep); if (idx >= 0) break; } if (idx >= 0) { const name = lines[i].substring(idx + 3).trim(); if (name) strategyNames.push(name); } } return strategyNames.length > 0 ? Array.from(new Set(strategyNames)) : null; } /** * 메시지에서 조건 충족한 종목명(한글) 목록 추출 * "종목명 × 전략명" 형식의 각 라인에서 종목명 부분만 추출 */ export function extractAssetNamesFromMessage(message: string): string[] | null { if (!message || !message.includes('알림 조건 충족') || !message.includes('개)')) return null; const lines = message.split('\n').map((s) => s.trim()).filter(Boolean); const names: string[] = []; const SEPARATORS = [' × ', ' X ']; // ×(유니코드), X(라틴) 모두 지원 for (let i = 1; i < lines.length; i++) { let idx = -1; for (const sep of SEPARATORS) { idx = lines[i].indexOf(sep); if (idx >= 0) break; } if (idx >= 0) { const koreanName = lines[i].substring(0, idx).trim(); if (koreanName) names.push(koreanName); } } return names.length > 0 ? Array.from(new Set(names)) : null; } /** * 메시지에서 "종목명 × 전략명" 라인별 항목 추출 (종목+전략 쌍) */ export function extractStockStrategyPairs(message: string): Array<{ koreanName: string; strategyName: string }> | null { if (!message || !message.includes('알림 조건 충족') || !message.includes('개)')) return null; const lines = message.split('\n').map((s) => s.trim()).filter(Boolean); const pairs: Array<{ koreanName: string; strategyName: string }> = []; const SEPARATORS = [' × ', ' X ']; for (let i = 1; i < lines.length; i++) { let idx = -1; for (const sep of SEPARATORS) { idx = lines[i].indexOf(sep); if (idx >= 0) break; } if (idx >= 0) { const koreanName = lines[i].substring(0, idx).trim(); const strategyName = lines[i].substring(idx + 3).trim(); if (koreanName) pairs.push({ koreanName, strategyName: strategyName || '' }); } } return pairs.length > 0 ? pairs : null; } /** * 알림 목록에서 symbol → 전략명[] 매핑 추출 * 최신순 정렬된 알림에서, 각 종목별로 매칭된 전략명 수집 (중복 제거) */ export function extractSymbolToStrategyNames( notifications: Array<{ message?: string; symbol?: string; koreanName?: string; triggeredAt?: string }>, koreanToSymbol: Map ): Record { const symbolToStrategies = new Map>(); const sorted = [...notifications].sort((a, b) => { const at = a.triggeredAt ? new Date(a.triggeredAt).getTime() : 0; const bt = b.triggeredAt ? new Date(b.triggeredAt).getTime() : 0; return bt - at; }); for (const n of sorted) { const pairs = extractStockStrategyPairs(n.message || ''); if (pairs && pairs.length > 0) { for (const { koreanName, strategyName } of pairs) { if (!strategyName?.trim()) continue; const row = resolveStockStrategyPairRow({ koreanName, strategyName }, koreanToSymbol); const s = row.navSymbol; if (s) { if (!symbolToStrategies.has(s)) symbolToStrategies.set(s, new Set()); symbolToStrategies.get(s)!.add(strategyName.trim()); } } } else if (n.symbol && n.koreanName) { // 단일 알림: 전략명은 메시지 첫 줄에서 추출 const firstLine = (n.message || '').split('\n')[0]?.trim() || ''; const strategyName = firstLine.replace(/^[\s🧪🎯✅]*/, '').trim(); if (strategyName) { if (!symbolToStrategies.has(n.symbol)) symbolToStrategies.set(n.symbol, new Set()); symbolToStrategies.get(n.symbol)!.add(strategyName); } } } const result: Record = {}; symbolToStrategies.forEach((strategies, symbol) => { result[symbol] = Array.from(strategies); }); return result; } /** 통합 알림 한 줄의 왼쪽이 마켓코드(KRW-BTC)인 경우 — cryptoMap 키는 한글명이라 resolveSymbols만으로는 매칭 안 됨 */ const UPBIT_MARKET_CODE_RE = /^(KRW|USDT|BTC)-[A-Z0-9]+$/i; function koreanNameForMarketSymbol(symbol: string, cryptoMap: Map, fallback: string): string { const s = symbol.trim().toUpperCase(); const entries = Array.from(cryptoMap.entries()); for (let i = 0; i < entries.length; i++) { const [koreanName, sym] = entries[i]; if (sym.toUpperCase() === s) return koreanName; } return fallback; } /** * 통합 알림 종목 행 표시용: 한글명·심볼·업비트 URL용 navSymbol 정리 * - 메시지가 "KRW-BTC × CCI 1"처럼 마켓코드만 있으면 navSymbol을 채우고 한글 종목명으로 바꿉니다. */ export function resolveStockStrategyPairRow( pair: { koreanName: string; strategyName: string; symbol?: string }, cryptoMap: Map ): { koreanName: string; strategyName: string; symbol?: string; displaySymbol: string; navSymbol?: string } { if (pair.symbol?.trim()) { const navSymbol = pair.symbol.trim().toUpperCase(); const left = pair.koreanName?.trim() || ''; const koreanName = left && !UPBIT_MARKET_CODE_RE.test(left) ? pair.koreanName : koreanNameForMarketSymbol(navSymbol, cryptoMap, left || navSymbol); return { ...pair, koreanName, displaySymbol: navSymbol, navSymbol }; } const raw = pair.koreanName?.trim() || ''; if (UPBIT_MARKET_CODE_RE.test(raw)) { const navSymbol = raw.toUpperCase(); const koreanName = koreanNameForMarketSymbol(navSymbol, cryptoMap, raw); return { ...pair, koreanName, displaySymbol: navSymbol, navSymbol }; } const resolved = resolveSymbols([pair.koreanName], cryptoMap); return { ...pair, displaySymbol: resolved[0]?.symbol ?? pair.strategyName, navSymbol: resolved[0]?.symbol, }; } /** * koreanName → symbol 매핑 (getActiveCryptos 결과 활용) * 공백 정규화 및 부분 일치로 한글명 표기 차이 보정 */ export function resolveSymbols( koreanNames: string[], cryptoMap: Map ): Array<{ symbol: string; koreanName: string }> { const normalize = (s: string) => s.replace(/\s+/g, ' ').trim(); const entries = Array.from(cryptoMap.entries()); const result: Array<{ symbol: string; koreanName: string }> = []; for (const kn of koreanNames) { const n = normalize(kn); let matched = entries.find(([k]) => normalize(k) === n || k === kn); if (!matched) { matched = entries.find(([k]) => normalize(k).includes(n) || n.includes(normalize(k))); } if (matched) result.push({ symbol: matched[1], koreanName: matched[0] }); } return result; }