diff --git a/package-lock.json b/package-lock.json index c3e7962..b695cb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5938d79..128c45c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/features/stocks/naverFundamentals.ts b/src/features/stocks/naverFundamentals.ts index d23ec65..5722cfb 100644 --- a/src/features/stocks/naverFundamentals.ts +++ b/src/features/stocks/naverFundamentals.ts @@ -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) { diff --git a/src/features/stocks/naverScreener.ts b/src/features/stocks/naverScreener.ts index e706312..43d7b18 100644 --- a/src/features/stocks/naverScreener.ts +++ b/src/features/stocks/naverScreener.ts @@ -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 { + 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 { + const minCap = opts.minMarketCapEok ?? 0; + const maxCap = opts.maxMarketCapEok ?? Number.MAX_SAFE_INTEGER; + const maxPages = opts.maxPagesPerCode ?? 5; // 코드당 최대 500종목 + const bySymbol = new Map(); + + 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; +} diff --git a/src/features/stocks/slashStocks.ts b/src/features/stocks/slashStocks.ts index c5ee360..68eba26 100644 --- a/src/features/stocks/slashStocks.ts +++ b/src/features/stocks/slashStocks.ts @@ -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 { - // 인자 파싱 — `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 뒤에서 파싱 + } + + // 인자 파싱 — `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 경로 표시', diff --git a/src/features/stocks/stockDiscovery.ts b/src/features/stocks/stockDiscovery.ts index c748400..f06cbee 100644 --- a/src/features/stocks/stockDiscovery.ts +++ b/src/features/stocks/stockDiscovery.ts @@ -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 {}); - 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 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일선 아래 머물다 돌파 = 추세 전환" 신호를 펀더멘털 통과 후보에만 diff --git a/src/features/stocks/stockSectors.ts b/src/features/stocks/stockSectors.ts new file mode 100644 index 0000000..6e549f9 --- /dev/null +++ b/src/features/stocks/stockSectors.ts @@ -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 = { + 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); +} diff --git a/tests/stockSectors.test.ts b/tests/stockSectors.test.ts new file mode 100644 index 0000000..f8feb3e --- /dev/null +++ b/tests/stockSectors.test.ts @@ -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); + }); +});