import { logInfo } from '../../utils'; import { screenMarket, screenIndustry, type Market, type ScreenerEntry } from './naverScreener'; import { fetchAllFundamentals, type Fundamentals } from './naverFundamentals'; import { fetchAllHistories, evalMa224Recovery, evalDropRecovery } from './yahooClient'; import { type SectorGroup } from './stockSectors'; import type { Stock } from './types'; /** * `/stocks discover` 파이프라인 — Naver 크롤로 후보 발굴. * * 단계: * 1. 시가총액 순위 페이지 크롤 (코스피 + 코스닥) — 종목코드 + 시총만 (가벼움) * 2. 시가총액 범위로 1차 필터 (예: 1,000억 ~ 5,000억) * 3. 통과한 종목 (보통 50-200개) 의 main 페이지 크롤 — 깊은 펀더멘털 * 4. 8 키워드 (llmJudge.ts 와 동일 임계값) 적용 → 3개 이상 통과한 종목만 선별 * 5. ScreenedCandidate[] 반환 → 호출자가 사용자 confirm 후 stocks.json 에 추가 * * llmJudge 와 *같은 임계값* 사용 — discover 와 judge 가 *같은 기준* 으로 동작해야 * 사용자의 mental model 이 일관됨. 임계값이 두 군데에 박혀 있는 건 단점이지만, * judge 는 LLM 프롬프트 (자연어) 고 discover 는 코드 (정수 비교) 라 형태가 달라 공유가 까다로움. * 향후 thresholds 를 단일 module 로 추출하는 게 좋음 (now: TODO). */ export interface DiscoverOptions { /** 시가총액 하한 (억). default 1000 (1천억). */ minMarketCapEok?: number; /** 시가총액 상한 (억). default 5000 (5천억). */ maxMarketCapEok?: number; /** 시장 — default ['kospi', 'kosdaq']. */ markets?: Market[]; /** 시장당 크롤 페이지 수 — default 10 (페이지당 50개 = 500개/시장). */ maxPagesPerMarket?: number; /** 최종 후보 최대 N개 — sortScore 내림차순 cut. default 20. */ limit?: number; /** 섹터 필터 — 지정 시 sectorHint 가 이 그룹에 속하는 종목만 (펀더멘털 수집 후 적용). */ sector?: SectorGroup; /** 진행률 콜백 (UI 가 사용). */ onProgress?: (msg: string) => void; } export interface DiscoveredCandidate { symbol: string; name: string; market: Market; marketCapEok: number; /** 통과한 키워드들 (선별 후 최대 3개). */ passedKeywords: string[]; /** Stock 으로 변환된 형태 — stocks.json 에 그대로 추가 가능. */ asStock: Stock; fundamentals: Fundamentals; } /** 8 키워드 각각의 통과 여부 판정 — llmJudge SYSTEM_PROMPT 의 임계값과 동일. */ function evaluateKeywords(f: Fundamentals): string[] { const passed: string[] = []; const roe = f.roe ?? 0; const om = f.operatingMargin ?? 0; const retention = f.retentionRatio ?? 0; const pbr = f.pbr ?? Number.MAX_SAFE_INTEGER; const mktCap = f.marketCapEok ?? 0; const sector = (f.sectorHint || '').toLowerCase(); if (roe >= 10) passed.push('ROE'); if (om >= 15) passed.push('성장성'); if (retention >= 1000) passed.push('유동성'); if (om >= 10) { // 10% 이상이면 "수익성", 20% 이상이면 "수익성 개선" 표기. passed.push(om >= 20 ? '수익성 개선' : '수익성'); } if (om >= 15 && roe >= 8) passed.push('영업효율'); const techKeywords = ['ai', '반도체', '배터리', '바이오', '기술', 'semicon', 'battery', 'bio']; if (techKeywords.some(k => sector.includes(k)) && pbr >= 2) passed.push('기술력'); if (retention >= 3000 && mktCap >= 5000) passed.push('안정성'); if (pbr > 0 && pbr <= 1.5) passed.push('PBR'); // 저평가 강조 — PBR이 낮을수록 추가 가산. 사용자 요청("저평가된 종목을 기준으로")에 맞춰 // 1.0 / 0.7 두 단계로 elevate. PBR=0.5짜리는 PBR+저평가+초저평가 = 3 키워드 자동 통과. if (pbr > 0 && pbr <= 1.0) passed.push('저평가'); if (pbr > 0 && pbr <= 0.7) passed.push('초저평가'); return passed; } /** Fundamentals → Stock (stocks.json 호환 형식) 변환. */ function fundamentalsToStock(entry: ScreenerEntry, f: Fundamentals, filterText: string): Stock { return { 이름: entry.name, 심볼: entry.symbol, '유보율': f.retentionRatio !== undefined ? `${Math.round(f.retentionRatio).toLocaleString()}%` : undefined, 'ROE(25E)': f.roe !== undefined ? `${f.roe.toFixed(2)}%` : undefined, '영업이익률(25E)': f.operatingMargin !== undefined ? `${f.operatingMargin.toFixed(1)}%` : undefined, 'PER(25E)': f.per !== undefined ? f.per.toFixed(1) : undefined, PBR: f.pbr !== undefined ? f.pbr.toFixed(1) : undefined, '시가총액': f.marketCapEok !== undefined ? `${Math.round(f.marketCapEok).toLocaleString()}억` : undefined, '3/4 필터': filterText, 현재가: f.currentPrice, // 시가총액 기준 자동 분류 — 사용자가 나중에 수동으로 바꿀 수 있음. 투자성향: entry.marketCapEok < 3000 ? '저평가우량주' : entry.marketCapEok < 10000 ? '스윙/중기' : '장기투자', }; } export async function discoverStocks(opts: DiscoverOptions = {}): Promise { const minCap = opts.minMarketCapEok ?? 1000; const maxCap = opts.maxMarketCapEok ?? 5000; const markets = opts.markets ?? ['kospi', 'kosdaq']; const maxPages = opts.maxPagesPerMarket ?? 10; const limit = opts.limit ?? 20; const progress = opts.onProgress ?? (() => {}); // (1)+(2) 1차 후보 수집 — 섹터 모드면 업종 코드로 직접, 아니면 시총 스크리너. const allEntries: ScreenerEntry[] = []; if (opts.sector) { progress(`📡 Naver 업종별 조회 — 섹터 '${opts.sector.key}' (업종코드 ${opts.sector.codes.join(',')}), 시총 ${minCap}-${maxCap}억`); const entries = await screenIndustry({ codes: opts.sector.codes, sectorName: opts.sector.key, minMarketCapEok: minCap, maxMarketCapEok: maxCap, onProgress: (msg) => progress(msg), }); allEntries.push(...entries); } else { progress(`📡 Naver 시가총액 페이지 크롤 시작 — 시장 ${markets.join('+')}, 시총 ${minCap}-${maxCap}억`); for (const market of markets) { progress(` · ${market} 스캔 중...`); const entries = await screenMarket({ market, maxPages, minMarketCapEok: minCap, maxMarketCapEok: maxCap, onProgress: (page, total) => progress(` p${page} → 누적 ${total}개`), }); allEntries.push(...entries); } } if (allEntries.length === 0) { progress(opts.sector ? `⚠️ 섹터 '${opts.sector.key}' 시총 ${minCap}-${maxCap}억 범위 내 종목 0개. 범위를 넓혀보세요 (예: \`discover sector ${opts.sector.key} 500 50000\`).` : '⚠️ 시가총액 범위 내 후보 0개. 범위 넓혀서 다시 시도해보세요.'); return []; } progress(`\n✅ 1차 후보 ${allEntries.length}개 — 펀더멘털 크롤 시작 (~${Math.ceil(allEntries.length * 0.5)}초 소요).`); // (3) 개별 펀더멘털 크롤. const symbols = allEntries.map(e => e.symbol); const fundsMap = await fetchAllFundamentals(symbols, (sym, _f, i, total) => { if (i % 10 === 0 || i === total) progress(` 펀더멘털 ${i}/${total} 처리 중...`); }); // (4) 펀더멘털 키워드 평가 — 1차 통과 후보 추림. // 섹터 모드는 종목이 이미 해당 섹터로 보장됨 → '추천 가능한 종목'을 주는 게 목적이라 // 3키워드 하드게이트를 완화한다(≥1 통과면 후보). 3키워드 미만이면 점수순 상위만 // 안내 문구와 함께. 일반 모드는 기존대로 ≥3 (정밀도 우선). const minKeywords = opts.sector ? 1 : 3; const prelim: { entry: ScreenerEntry; f: Fundamentals; passed: string[] }[] = []; for (const entry of allEntries) { const f = fundsMap.get(entry.symbol); if (!f) continue; // 섹터 모드: 업종 조회로 sectorHint 가 비어 있으면 entry.sectorName 으로 보강. if (opts.sector && !f.sectorHint && entry.sectorName) f.sectorHint = entry.sectorName; const passed = evaluateKeywords(f); if (passed.length < minKeywords) continue; prelim.push({ entry, f, passed }); } if (opts.sector) { const strong = prelim.filter(p => p.passed.length >= 3).length; progress(`\n🏷️ 섹터 '${opts.sector.key}': ${allEntries.length}개 중 ${prelim.length}개 후보 (3키워드+ ${strong}개${strong < prelim.length ? `, 1-2키워드 ${prelim.length - strong}개 — 섹터 내 상대 추천` : ''})`); } // (5) 1년 시세 → 224일선 회복 패턴 보너스 키워드. // 영상의 "주가가 224일선 아래 머물다 돌파 = 추세 전환" 신호를 펀더멘털 통과 후보에만 // 얹는다(전체 1차후보에 안 돌리는 이유: Yahoo 1초/심볼 throttle 비용 절감). if (prelim.length > 0) { progress(`\n📈 ${prelim.length}개 후보의 1년 시세로 224일선 회복 패턴 확인 중 (Yahoo 1초/종목, ~${prelim.length}초)...`); const histMap = await fetchAllHistories( prelim.map(p => p.entry.symbol), (_sym, _ok, i, total) => { if (i % 10 === 0 || i === total) progress(` 시세 ${i}/${total} 처리 중...`); }, ); for (const p of prelim) { const h = histMap.get(p.entry.symbol); if (!h) continue; const r = evalMa224Recovery(h); if (r?.passed) p.passed.push('224회복'); const dr = evalDropRecovery(h); if (dr?.passed) p.passed.push('낙폭과대'); } } // (6) DiscoveredCandidate 변환. const candidates: DiscoveredCandidate[] = prelim.map(({ entry, f, passed }) => { const top3 = passed.slice(0, 3); const filterText = `[발굴 자동] 충족 (${top3.join(', ')})`; return { symbol: entry.symbol, name: entry.name, market: entry.market, marketCapEok: entry.marketCapEok, passedKeywords: passed, asStock: fundamentalsToStock(entry, f, filterText), fundamentals: f, }; }); // (7) sortScore — 통과 키워드 수 desc, 동점 시 PBR asc(저평가가 위로) 타이브레이커. candidates.sort((a, b) => { if (b.passedKeywords.length !== a.passedKeywords.length) { return b.passedKeywords.length - a.passedKeywords.length; } const pbrA = a.fundamentals.pbr ?? Number.MAX_SAFE_INTEGER; const pbrB = b.fundamentals.pbr ?? Number.MAX_SAFE_INTEGER; return pbrA - pbrB; }); const limited = candidates.slice(0, limit); logInfo(`Stocks discovery: ${allEntries.length} 1차 후보 → ${candidates.length} 매수후보 → ${limited.length} 표시.`); progress(`\n🎯 ${limited.length}개 후보 발굴 완료 (전체 1차후보 ${allEntries.length}개 중 ${candidates.length}개가 3 키워드 이상 통과).`); return limited; }