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
+41 -8
View File
@@ -11,6 +11,7 @@ import { judgeStock } from './llmJudge';
import { sendStocksReport, buildReportText } from './telegramReport';
import { runOnceNow } from './stocksWatcher';
import { discoverStocks } from './stockDiscovery';
import { resolveSectorQuery, listSectorKeys, type SectorGroup } from './stockSectors';
import { analyzeTopCandidates, renderTopFiveForChat, renderTopFiveForTelegram, sendTopFiveToTelegram } from './discoveryAnalyzer';
import type { ClassifiedStock, Stock } from './types';
@@ -28,6 +29,7 @@ import type { ClassifiedStock, Stock } from './types';
* /stocks remove <심볼>
* /stocks judge <심볼> — LLM 4-criteria 평가
* /stocks discover [min] [max] — Naver 크롤 발굴 (시총 범위 억 단위, default 1000-5000)
* /stocks discover sector <섹터> [min] [max] — 섹터별 발굴 / discover sectors — 섹터 목록
* /stocks report — 텔레그램 보고서 즉시 발송
* /stocks run — watcher 한 사이클 즉시 실행 (현재가+sync+보고서)
* /stocks path — stocks.json 경로 표시
@@ -504,20 +506,47 @@ async function cmdRun(view: Webview | undefined, context: vscode.ExtensionContex
}
async function cmdDiscover(rest: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise<void> {
// 인자 파싱 — `min max` (둘 다 억 단위) / 안 주면 default.
const parts = rest.split(/\s+/).filter(Boolean);
const minCap = parts[0] ? parseInt(parts[0], 10) : 1000;
const maxCap = parts[1] ? parseInt(parts[1], 10) : 5000;
if (Number.isNaN(minCap) || Number.isNaN(maxCap) || minCap >= maxCap) {
chunk(view, '\n사용법: `/stocks discover [min] [max]` (억 단위, 예: `/stocks discover 1000 5000`)\n');
// `sectors` — 사용 가능한 섹터 키 목록 안내.
if (parts[0]?.toLowerCase() === 'sectors') {
chunk(view, `\n🏷️ 발굴 가능한 섹터:\n ${listSectorKeys().join(' · ')}\n\n사용법: \`/stocks discover sector <섹터> [min] [max]\`\n`);
return;
}
chunk(view, `\n🔍 **Naver 발굴 시작** (시총 ${minCap.toLocaleString()}억 ~ ${maxCap.toLocaleString()}억)\n`);
// `sector <섹터명> [min] [max]` — 섹터 필터 발굴.
let sectorGroup: SectorGroup | undefined;
let argParts = parts;
if (parts[0]?.toLowerCase() === 'sector') {
const sectorName = parts[1];
if (!sectorName) {
chunk(view, `\n섹터명이 필요합니다. \`/stocks discover sector <섹터> [min] [max]\`\n사용 가능: ${listSectorKeys().join(' · ')}\n`);
return;
}
const resolved = resolveSectorQuery(sectorName);
if (!resolved) {
chunk(view, `\n⚠️ '${sectorName}' 섹터를 못 찾았습니다. 사용 가능:\n ${listSectorKeys().join(' · ')}\n`);
return;
}
sectorGroup = resolved;
argParts = parts.slice(2); // min/max 는 sector <name> 뒤에서 파싱
}
// 인자 파싱 — `min max` (둘 다 억 단위) / 안 주면 default.
const minCap = argParts[0] ? parseInt(argParts[0], 10) : 1000;
const maxCap = argParts[1] ? parseInt(argParts[1], 10) : 5000;
if (Number.isNaN(minCap) || Number.isNaN(maxCap) || minCap >= maxCap) {
chunk(view, '\n사용법: `/stocks discover [min] [max]` 또는 `/stocks discover sector <섹터> [min] [max]` (억 단위)\n');
return;
}
const sectorLabel = sectorGroup ? ` · 섹터 '${sectorGroup.key}'` : '';
chunk(view, `\n🔍 **Naver 발굴 시작** (시총 ${minCap.toLocaleString()}억 ~ ${maxCap.toLocaleString()}${sectorLabel})\n`);
const candidates = await discoverStocks({
minMarketCapEok: minCap,
maxMarketCapEok: maxCap,
sector: sectorGroup,
onProgress: (msg) => chunk(view, msg + '\n'),
});
@@ -530,7 +559,9 @@ async function cmdDiscover(rest: string, view: Webview | undefined, context?: vs
for (const c of candidates) {
const price = c.fundamentals.currentPrice;
const priceStr = typeof price === 'number' ? price.toLocaleString() + '원' : '-';
chunk(view, ` · ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억]\n`);
const sectorStr = c.fundamentals.sectorHint ? ` · ${c.fundamentals.sectorHint}` : '';
const weakMark = sectorGroup && c.passedKeywords.length < 3 ? ' ⚠️섹터내상대' : '';
chunk(view, ` · ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}${sectorStr}]${weakMark}\n`);
chunk(view, ` 현재가 ${priceStr} · ROE ${c.fundamentals.roe?.toFixed(1) ?? '-'}% · 영업이익률 ${c.fundamentals.operatingMargin?.toFixed(1) ?? '-'}% · 유보율 ${c.fundamentals.retentionRatio?.toLocaleString() ?? '-'}%\n`);
chunk(view, ` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}\n\n`);
}
@@ -543,7 +574,7 @@ async function cmdDiscover(rest: string, view: Webview | undefined, context?: vs
chunk(view, renderTopFiveForChat(topFive));
if (context) {
const rangeLabel = `시총 ${minCap.toLocaleString()}-${maxCap.toLocaleString()}`;
const rangeLabel = `시총 ${minCap.toLocaleString()}-${maxCap.toLocaleString()}${sectorGroup ? ` · ${sectorGroup.key}` : ''}`;
const tgText = renderTopFiveForTelegram(topFive, rangeLabel);
const tgResult = await sendTopFiveToTelegram(context, tgText);
chunk(view, tgResult.ok
@@ -575,6 +606,8 @@ function cmdHelp(view: Webview | undefined): void {
' `/stocks analysis <심볼>` — 심층 분석 (펀더멘털 + MA 정배열 + RSI + LLM 종합)',
' `/stocks position [심볼] <총자산> <리스크%> <손절%>` — 포지션 사이징 (적정 투자금)',
' `/stocks discover [min] [max]` — Naver 크롤 발굴 (시총 억 단위, default 1000-5000)',
' `/stocks discover sector <섹터> [min] [max]` — 섹터별 발굴 (예: `discover sector 반도체`)',
' `/stocks discover sectors` — 발굴 가능한 섹터 목록',
' `/stocks report` — 텔레그램 보고서 즉시 발송',
' `/stocks run` — Watcher 1회 즉시 실행',
' `/stocks path` — stocks.json 경로 표시',