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:
@@ -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일선 아래 머물다 돌파 = 추세 전환" 신호를 펀더멘털 통과 후보에만
|
||||
|
||||
Reference in New Issue
Block a user