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:
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 경로 표시',
|
||||
|
||||
@@ -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일선 아래 머물다 돌파 = 추세 전환" 신호를 펀더멘털 통과 후보에만
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user