feat(stocks): discover sector — 네이버 업종 코드 직접 조회로 재설계 (v2.2.250)

[버그] discover sector 가 항상 0개 반환. 원인: sectorHint 를 통합 API 의
없는 필드(industryInfo.name)에서 읽어 전 종목이 업종 미상 → 필터 전멸.

[근본 수정] "전 종목 시총 크롤 후 sectorHint 필터" → "네이버 업종 코드로
해당 섹터 종목 직접 조회". 실측: 2차전지 1000-5000억 0개 → 36개.
- stockSectors: 친화 섹터키 → 네이버 업종코드 묶음 (업종 79개 코드표). 17개 그룹.
- naverScreener.screenIndustry(): /api/stocks/industry/{code} 직접 수집 + 시총 필터 + dedup.
- naverFundamentals: sectorHint 를 industryCode→이름 매핑으로 수정 (기술력 키워드·judge 복구).
- stockDiscovery: 섹터 모드 3키워드 게이트 완화(≥1, "섹터 내 상대 추천").
- CLI: discover sector <섹터> [min] [max] / discover sectors.

테스트 8건. 라이브 e2e 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 13:55:18 +09:00
parent ae021a8c16
commit 53953fb5f8
8 changed files with 308 additions and 26 deletions
+35 -12
View File
@@ -1,7 +1,8 @@
import { logInfo } from '../../utils';
import { screenMarket, type Market, type ScreenerEntry } from './naverScreener';
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';
/**
@@ -31,6 +32,8 @@ export interface DiscoverOptions {
maxPagesPerMarket?: number;
/** 최종 후보 최대 N개 — sortScore 내림차순 cut. default 20. */
limit?: number;
/** 섹터 필터 — 지정 시 sectorHint 가 이 그룹에 속하는 종목만 (펀더멘털 수집 후 적용). */
sector?: SectorGroup;
/** 진행률 콜백 (UI 가 사용). */
onProgress?: (msg: string) => void;
}
@@ -104,24 +107,34 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise<Discov
const limit = opts.limit ?? 20;
const progress = opts.onProgress ?? (() => {});
progress(`📡 Naver 시가총액 페이지 크롤 시작 — 시장 ${markets.join('+')}, 시총 ${minCap}-${maxCap}`);
// (1)+(2) 시가총액 페이지 → 1차 필터링.
// (1)+(2) 1차 후보 수집 — 섹터 모드면 업종 코드로 직접, 아니면 시총 스크리너.
const allEntries: ScreenerEntry[] = [];
for (const market of markets) {
progress(` · ${market} 스캔 중...`);
const entries = await screenMarket({
market,
maxPages,
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: (page, total) => progress(` p${page} → 누적 ${total}`),
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('⚠️ 시가총액 범위 내 후보 0개. 범위 넓혀서 다시 시도해보세요.');
progress(opts.sector
? `⚠️ 섹터 '${opts.sector.key}' 시총 ${minCap}-${maxCap}억 범위 내 종목 0개. 범위를 넓혀보세요 (예: \`discover sector ${opts.sector.key} 500 50000\`).`
: '⚠️ 시가총액 범위 내 후보 0개. 범위 넓혀서 다시 시도해보세요.');
return [];
}
@@ -134,14 +147,24 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise<Discov
});
// (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 < 3) continue;
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일선 아래 머물다 돌파 = 추세 전환" 신호를 펀더멘털 통과 후보에만