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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "astra",
"version": "2.2.231",
"version": "2.2.250",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "astra",
"version": "2.2.231",
"version": "2.2.250",
"license": "MIT",
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "astra",
"displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
"version": "2.2.247",
"version": "2.2.250",
"publisher": "g1nation",
"license": "MIT",
"icon": "assets/icon.png",
+9 -3
View File
@@ -1,4 +1,5 @@
import { logError, logInfo } from '../../utils';
import { NAVER_INDUSTRY_NAMES } from './stockSectors';
/**
* Naver Finance 비공식 JSON API 로 개별 종목 펀더멘털 fetch.
@@ -38,8 +39,8 @@ export interface Fundamentals {
interface NaverIntegrationResponse {
stockName?: string;
totalInfos?: Array<{ code: string; key: string; value: string }>;
/** 종목 분류 / 업종 정보가 들어있을 수 있음 (옵션). */
industryInfo?: { name?: string };
/** 네이버 업종 코드 (숫자). 통합 응답의 실제 필드 — industryInfo 는 존재하지 않음. */
industryCode?: number | string;
}
interface NaverFinanceAnnualResponse {
@@ -124,7 +125,12 @@ export async function fetchFundamentals(symbol: string, timeoutMs = 10000): Prom
out.currentPrice = parseNumber(map.get('closePrice') || map.get('lastClosePrice'));
out.marketCapEok = parseMarketCapText(map.get('marketValue'));
}
if (integ?.industryInfo?.name) out.sectorHint = integ.industryInfo.name;
// 업종 — 통합 응답의 industryCode(숫자) → 업종명 매핑. (구버전은 없는 industryInfo.name
// 을 읽어 항상 빈 값이었음 → '기술력' 키워드·섹터 표시가 동작 안 했던 버그 수정.)
if (integ?.industryCode !== undefined) {
const code = typeof integ.industryCode === 'string' ? parseInt(integ.industryCode, 10) : integ.industryCode;
if (Number.isFinite(code) && NAVER_INDUSTRY_NAMES[code]) out.sectorHint = NAVER_INDUSTRY_NAMES[code];
}
// finance/annual — rowList 에서 최근 확정 연도 컬럼 값.
if (fin?.financeInfo?.rowList && fin.financeInfo.rowList.length > 0) {
+84
View File
@@ -23,6 +23,7 @@ import { logError, logInfo } from '../../utils';
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const BASE_URL = 'https://m.stock.naver.com/api/stocks/marketValue';
const INDUSTRY_URL = 'https://m.stock.naver.com/api/stocks/industry';
export type Market = 'kospi' | 'kosdaq';
@@ -36,6 +37,8 @@ export interface ScreenerEntry {
/** 종가 (옵션). */
closePrice?: number;
market: Market;
/** 업종명 — 업종별 조회(screenIndustry)로 들어온 경우 채워짐. */
sectorName?: string;
}
/**
@@ -149,3 +152,84 @@ export async function screenMarket(opts: {
logInfo(`Naver screener ${opts.market}: ${collected.length}개 (${minCap}억 ~ ${maxCap}억).`);
return collected;
}
interface NaverIndustryStock {
itemCode: string;
stockName: string;
closePrice?: string;
/** 시가총액 — 억 단위 (콤마 포함 문자열). marketValueHangeul 와 달리 숫자만. 예: "449,667" = 44.97조. */
marketValue?: string;
stockExchangeType?: { nameEng?: string };
}
interface NaverIndustryResponse { stocks?: NaverIndustryStock[]; }
/** 업종 API 의 marketValue("449,667") → 억 단위 정수. */
function parseIndustryMarketCap(text: string | undefined): number {
if (!text) return 0;
const n = parseInt(text.replace(/[^\d]/g, ''), 10);
return Number.isFinite(n) ? n : 0;
}
async function fetchIndustryPage(code: number, page: number): Promise<NaverIndustryStock[]> {
const url = `${INDUSTRY_URL}/${code}?page=${page}&pageSize=100`;
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`Naver industry HTTP ${res.status} (code ${code} p${page})`);
const data = await res.json() as NaverIndustryResponse;
return Array.isArray(data.stocks) ? data.stocks : [];
}
/**
* 업종 코드 묶음으로 종목을 *직접* 조회 (시총 범위 1차 필터). 시총 스크리너 대신
* 섹터 모드 discover 가 사용 — 전 종목 크롤 없이 해당 섹터 종목만 받는다.
* 같은 종목이 여러 코드에 중복되면 dedup. sectorName 채워서 반환.
*/
export async function screenIndustry(opts: {
codes: number[];
sectorName: string;
minMarketCapEok?: number;
maxMarketCapEok?: number;
maxPagesPerCode?: number;
onProgress?: (msg: string) => void;
}): Promise<ScreenerEntry[]> {
const minCap = opts.minMarketCapEok ?? 0;
const maxCap = opts.maxMarketCapEok ?? Number.MAX_SAFE_INTEGER;
const maxPages = opts.maxPagesPerCode ?? 5; // 코드당 최대 500종목
const bySymbol = new Map<string, ScreenerEntry>();
for (const code of opts.codes) {
for (let page = 1; page <= maxPages; page++) {
let items: NaverIndustryStock[];
try {
items = await fetchIndustryPage(code, page);
} catch (e: any) {
logError(`Naver industry code ${code} p${page} 실패.`, { error: e?.message ?? String(e) });
break;
}
if (items.length === 0) break;
for (const it of items) {
const cap = parseIndustryMarketCap(it.marketValue);
if (cap < minCap || cap > maxCap) continue;
if (bySymbol.has(it.itemCode)) continue;
const exch = (it.stockExchangeType?.nameEng || '').toUpperCase();
bySymbol.set(it.itemCode, {
symbol: it.itemCode,
name: it.stockName,
marketCapEok: cap,
closePrice: it.closePrice ? parseFloat(it.closePrice.replace(/,/g, '')) : undefined,
market: exch === 'KOSDAQ' ? 'kosdaq' : 'kospi',
sectorName: opts.sectorName,
});
}
opts.onProgress?.(` 업종 ${code} p${page} → 누적 ${bySymbol.size}`);
if (items.length < 100) break; // 마지막 페이지
await new Promise(r => setTimeout(r, 250));
}
}
const out = Array.from(bySymbol.values());
logInfo(`Naver industry [${opts.codes.join(',')}] (${opts.sectorName}): ${out.length}개 (${minCap}~${maxCap}억).`);
return out;
}
+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 경로 표시',
+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일선 아래 머물다 돌파 = 추세 전환" 신호를 펀더멘털 통과 후보에만
+85
View File
@@ -0,0 +1,85 @@
/**
* 섹터 분류 — `/stocks discover sector <섹터>` 의 그룹 정의.
*
* v2 (근본 수정): 기존엔 전 종목을 시총으로 크롤한 뒤 Fundamentals.sectorHint 로
* 필터했는데, 네이버 통합 API 에 우리가 찾던 industryInfo.name 필드가 없어
* sectorHint 가 항상 비어 결과 0개가 나왔다. 실제로는 ① 통합 응답에 industryCode
* (숫자) 가 있고 ② `m.stock.naver.com/api/stocks/industry/{code}` 가 업종별 종목을
* 직접 반환한다. 그래서 친화 섹터 키 → *네이버 업종 코드 묶음* 으로 매핑하고,
* discover 가 해당 코드의 종목을 직접 받아온다 (전 종목 크롤 불필요 — 빠르고 정확).
*
* 정본은 코드 한 곳(이 파일). 네이버 업종 분류(2026-06 기준, 79개)를 기반으로 작성.
*/
/** 네이버 업종 코드 → 이름 (sise_group upjong 기준). discover 가 섹터명 표시에 사용. */
export const NAVER_INDUSTRY_NAMES: Record<number, string> = {
261: '제약', 262: '생명과학도구및서비스', 263: '게임엔터테인먼트', 264: '백화점과일반상점',
265: '판매업체', 266: '화장품', 267: 'IT서비스', 268: '식품', 269: '디스플레이장비및부품',
270: '자동차부품', 271: '레저용장비와제품', 272: '화학', 273: '자동차', 274: '섬유의류신발',
276: '복합기업', 277: '창업투자', 278: '반도체와반도체장비', 279: '건설', 280: '부동산',
281: '건강관리장비와용품', 282: '전자장비와기기', 283: '전기제품(2차전지)', 284: '우주항공과국방',
285: '방송과엔터테인먼트', 286: '생물공학', 287: '소프트웨어', 288: '건강관리기술', 289: '건축자재',
290: '교육서비스', 291: '조선', 292: '핸드셋', 293: '컴퓨터와주변기기', 294: '통신장비',
295: '에너지장비및서비스', 296: '운송인프라', 299: '기계', 300: '양방향미디어와서비스', 301: '은행',
302: '식품과기본식료품소매', 304: '철강', 305: '항공사', 306: '전기장비', 307: '전자제품',
308: '인터넷과카탈로그소매', 309: '음료', 311: '포장재', 312: '가스유틸리티', 313: '석유와가스',
314: '출판', 315: '손해보험', 316: '건강관리업체및서비스', 318: '종이와목재', 319: '기타금융',
320: '건축제품', 321: '증권', 322: '비철금속', 323: '해운사', 325: '전기유틸리티',
326: '항공화물운송과물류', 327: '디스플레이패널', 328: '전문소매', 329: '도로와철도운송',
330: '생명보험', 331: '복합유틸리티', 333: '무선통신서비스', 336: '다각화된통신서비스',
337: '카드', 338: '사무용전자제품', 339: '다각화된소비자서비스',
};
export interface SectorGroup {
/** 사용자 입력·표시용 친화 키 (한글). */
key: string;
/** 영문/대체 별칭 (사용자가 다르게 입력해도 매칭). */
aliases: string[];
/** 이 섹터에 포함되는 네이버 업종 코드 묶음. discover 가 직접 종목을 받아온다. */
codes: number[];
}
export const SECTOR_GROUPS: SectorGroup[] = [
{ key: '반도체', aliases: ['semiconductor', 'semicon', 'chip', '디스플레이'], codes: [278, 269, 327] },
{ key: '2차전지', aliases: ['battery', '배터리', '전지', 'ev'], codes: [283, 306] },
{ key: '바이오/제약', aliases: ['bio', 'pharma', '제약', '바이오', '헬스케어', 'healthcare'], codes: [261, 286, 262, 281, 288, 316] },
{ key: 'IT/소프트웨어', aliases: ['it', 'software', 'sw', '소프트웨어', '컴퓨터'], codes: [267, 287, 293, 338] },
{ key: '게임/콘텐츠', aliases: ['game', '게임', 'content', 'media', '엔터', 'entertainment'], codes: [263, 285, 300, 314] },
{ key: '자동차', aliases: ['auto', 'car', '자동차부품', 'automobile'], codes: [273, 270] },
{ key: '금융', aliases: ['finance', 'bank', '은행', '증권', '보험', 'insurance'], codes: [301, 321, 330, 315, 337, 319, 277] },
{ key: '화학/소재', aliases: ['chemical', '화학', 'material', '소재'], codes: [272, 311, 318] },
{ key: '철강/금속', aliases: ['steel', '철강', 'metal', '금속'], codes: [304, 322] },
{ key: '조선/기계', aliases: ['shipbuilding', '조선', 'machinery', '기계'], codes: [291, 299] },
{ key: '건설/부동산', aliases: ['construction', '건설', 'realestate', '부동산'], codes: [279, 280, 289, 320] },
{ key: '유통/소비재', aliases: ['retail', '유통', 'consumer', '식품', '화장품', 'food'], codes: [264, 266, 268, 302, 309, 328, 308, 274, 265] },
{ key: '에너지/유틸리티', aliases: ['energy', '에너지', 'utility', '전력', 'power', 'oil'], codes: [313, 325, 312, 295, 331] },
{ key: '통신', aliases: ['telecom', '통신', 'telecommunication'], codes: [294, 333, 336, 292] },
{ key: '운송/물류', aliases: ['logistics', '물류', 'transport', '운송', 'shipping'], codes: [326, 329, 323, 305, 296] },
{ key: '방산/항공우주', aliases: ['defense', '방산', 'aerospace', '항공우주'], codes: [284] },
{ key: '전자부품/장비', aliases: ['electronics', '전자', 'component'], codes: [282, 307] },
];
/** 사용자 입력 → 섹터 그룹. 친화키·별칭 매칭. 없으면 null. */
export function resolveSectorQuery(input: string): SectorGroup | null {
const q = (input || '').trim().toLowerCase();
if (!q) return null;
// 1) 친화 키 정확/부분 일치
for (const g of SECTOR_GROUPS) {
const k = g.key.toLowerCase();
if (k === q || k.includes(q) || q.includes(k)) return g;
}
// 2) 별칭 일치 (정확 또는 포함)
for (const g of SECTOR_GROUPS) {
if (g.aliases.some(a => { const al = a.toLowerCase(); return al === q || al.includes(q) || q.includes(al); })) return g;
}
// 3) 네이버 업종명 직접 입력 (예: "반도체와반도체장비") → 그 코드를 포함한 그룹
for (const g of SECTOR_GROUPS) {
if (g.codes.some(c => { const n = (NAVER_INDUSTRY_NAMES[c] || '').toLowerCase(); return n && (n.includes(q) || q.includes(n)); })) return g;
}
return null;
}
/** 사용 가능한 섹터 키 목록 (CLI 안내·도움말용). */
export function listSectorKeys(): string[] {
return SECTOR_GROUPS.map(g => g.key);
}
+51
View File
@@ -0,0 +1,51 @@
/**
* 섹터 분류·해석 (네이버 업종 코드 기반) — 순수 로직 테스트.
*/
import { resolveSectorQuery, listSectorKeys, SECTOR_GROUPS, NAVER_INDUSTRY_NAMES } from '../src/features/stocks/stockSectors';
describe('resolveSectorQuery', () => {
test('친화 키 직접 입력', () => {
expect(resolveSectorQuery('반도체')?.key).toBe('반도체');
expect(resolveSectorQuery('2차전지')?.key).toBe('2차전지');
expect(resolveSectorQuery('금융')?.key).toBe('금융');
});
test('영문/대체 별칭', () => {
expect(resolveSectorQuery('semiconductor')?.key).toBe('반도체');
expect(resolveSectorQuery('battery')?.key).toBe('2차전지');
expect(resolveSectorQuery('배터리')?.key).toBe('2차전지');
expect(resolveSectorQuery('bio')?.key).toBe('바이오/제약');
});
test('네이버 업종명 직접 입력 → 그 코드를 가진 그룹', () => {
expect(resolveSectorQuery('제약')?.key).toBe('바이오/제약');
expect(resolveSectorQuery('은행')?.key).toBe('금융');
expect(resolveSectorQuery('조선')?.key).toBe('조선/기계');
});
test('대소문자 무시', () => {
expect(resolveSectorQuery('SEMICON')?.key).toBe('반도체');
});
test('미지원 입력 → null', () => {
expect(resolveSectorQuery('zzzqqq')).toBeNull();
expect(resolveSectorQuery('')).toBeNull();
});
});
describe('SECTOR_GROUPS 무결성', () => {
test('모든 그룹이 유효한 네이버 업종 코드를 가진다', () => {
for (const g of SECTOR_GROUPS) {
expect(g.codes.length).toBeGreaterThan(0);
for (const c of g.codes) {
expect(NAVER_INDUSTRY_NAMES[c]).toBeDefined(); // 코드가 실제 업종명에 존재
}
}
});
test('핵심 섹터가 올바른 대표 코드를 가진다', () => {
const semi = SECTOR_GROUPS.find(g => g.key === '반도체')!;
expect(semi.codes).toContain(278); // 반도체와반도체장비
const batt = SECTOR_GROUPS.find(g => g.key === '2차전지')!;
expect(batt.codes).toContain(283); // 전기제품(2차전지)
});
test('listSectorKeys 는 모든 그룹 키', () => {
expect(listSectorKeys()).toEqual(SECTOR_GROUPS.map(g => g.key));
expect(listSectorKeys().length).toBeGreaterThanOrEqual(15);
});
});