Files
connectai/src/features/stocks/stockDiscovery.ts
T
koriweb 1dbd5fd186 v2.2.266: /stocks discover 멀티팩터 합성점수 (Quality/Value/Momentum/Stability)
/stocks discover 추천 순위를 업계 표준 방식(Stockopedia StockRanks·S&P 멀티팩터 지수)으로 고도화:

[compositeScore.ts] 신설:
- 후보군 내 백분위 정규화: 각 지표를 0~100 범위로 상대평가
- 4축 분리: Quality(ROE·마진·성장YoY·부채) / Value(PER·PBR) / Momentum(12m수익률·224회복·정배열·RSI·낙폭회복) / Stability(유보율·시총·부채)
- 투자성향별 가중 합성: 스윙/중기=M35%, 장기투자=Q40%, 저평가우량주=V40%
- Value trap 가드: miniF-Score(Piotroski) 절반 미달 시 Value 축 50점 캡 → ⚠️VT가드 표시
- 결측 축 재정규화: 데이터 없는 축은 가중치에서 제외

[stockDiscovery.ts] 배선:
- 모멘텀 입력 수집: 기존 시세 평가(MA224·낙폭회복) + 새로 정배열·RSI·12개월 수익률 계산
- 배치 채점: 후보 전체를 한 번에 scoreCandidates() 호출 → 백분위 정규화
- 정렬: "키워드 개수" → "합성점수" 내림차순 (타이브레이커 PBR 유지)
- DiscoveredCandidate에 score 필드 추가

[discoveryAnalyzer.ts] 재설계 (LLM 서술만):
- Top 5 선정: 코드가 확정(candidates.slice(0, 5)) — 순위 변경 X
- LLM 역할: 각 종목 pitch 한 줄만 서술 (v2.2.211 judge 철학 적용)
- 실패 폴백: LLM 무응답 시 결정론 pitch 자동 생성 (점수 분해 + 최강 축 언급)
- 출력: 점수 분해(Q/V/M/S), mini F-Score, value trap 가드 표시 추가

[slashStocks.ts] 출력:
- 후보 목록: 순위 점수 + 분해 점수 + VT가드 · mini F 표시
- 모멘텀: "키워드 수" → "멀티팩터 합성점수 내림차순" 명시

[tests/compositeScore.test.ts] 신설:
- percentileRanks: 백분위, 동점 평균 랭크, 결측 처리
- scoreCandidates: 그라데이션(이진 카운트 해소), value trap 가드, 성향별 가중 차별, 결측 재정규화
- 총 13개 테스트 케이스

효과:
- 그라데이션 반영: ROE 35% > 10.1% 실감
- 중복 카운트 제거: 영업이익률이 한 지표만 계산
- Value trap 방어: PBR 0.4 + 품질 미달 = 50점 캡
- Top 5 신뢰도: LLM 순위 변조 불가

한계: 문헌 기반 설계이며 한국 시장 백테스트 미실시 — 실전 매수는 참고용 권장.

(검증: tsc ✓ · jest 728/730 ✓ + compositeScore 13개 신규 테스트)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:50:06 +09:00

251 lines
13 KiB
TypeScript

import { logInfo } from '../../utils';
import { screenMarket, screenIndustry, type Market, type ScreenerEntry } from './naverScreener';
import { fetchAllFundamentals, type Fundamentals } from './naverFundamentals';
import { fetchAllHistories, evalMa224Recovery, evalDropRecovery, evalMaAlignment, evalRsi14 } from './yahooClient';
import { scoreCandidates, type CompositeScore, type MomentumInputs, type ScoreInputs } from './compositeScore';
import { type SectorGroup } from './stockSectors';
import type { Stock } from './types';
/**
* `/stocks discover` 파이프라인 — Naver 크롤로 후보 발굴.
*
* 단계:
* 1. 시가총액 순위 페이지 크롤 (코스피 + 코스닥) — 종목코드 + 시총만 (가벼움)
* 2. 시가총액 범위로 1차 필터 (예: 1,000억 ~ 5,000억)
* 3. 통과한 종목 (보통 50-200개) 의 main 페이지 크롤 — 깊은 펀더멘털
* 4. 8 키워드 (llmJudge.ts 와 동일 임계값) 적용 → 3개 이상 통과한 종목만 선별
* 5. ScreenedCandidate[] 반환 → 호출자가 사용자 confirm 후 stocks.json 에 추가
*
* llmJudge 와 *같은 임계값* 사용 — discover 와 judge 가 *같은 기준* 으로 동작해야
* 사용자의 mental model 이 일관됨. 임계값이 두 군데에 박혀 있는 건 단점이지만,
* judge 는 LLM 프롬프트 (자연어) 고 discover 는 코드 (정수 비교) 라 형태가 달라 공유가 까다로움.
* 향후 thresholds 를 단일 module 로 추출하는 게 좋음 (now: TODO).
*/
export interface DiscoverOptions {
/** 시가총액 하한 (억). default 1000 (1천억). */
minMarketCapEok?: number;
/** 시가총액 상한 (억). default 5000 (5천억). */
maxMarketCapEok?: number;
/** 시장 — default ['kospi', 'kosdaq']. */
markets?: Market[];
/** 시장당 크롤 페이지 수 — default 10 (페이지당 50개 = 500개/시장). */
maxPagesPerMarket?: number;
/** 최종 후보 최대 N개 — sortScore 내림차순 cut. default 20. */
limit?: number;
/** 섹터 필터 — 지정 시 sectorHint 가 이 그룹에 속하는 종목만 (펀더멘털 수집 후 적용). */
sector?: SectorGroup;
/** 진행률 콜백 (UI 가 사용). */
onProgress?: (msg: string) => void;
}
export interface DiscoveredCandidate {
symbol: string;
name: string;
market: Market;
marketCapEok: number;
/** 통과한 키워드들 (선별 후 최대 3개). */
passedKeywords: string[];
/** Stock 으로 변환된 형태 — stocks.json 에 그대로 추가 가능. */
asStock: Stock;
fundamentals: Fundamentals;
/** 멀티팩터 합성 점수 (v2.2.266) — 추천 순위의 기준. */
score: CompositeScore;
}
/** 8 키워드 각각의 통과 여부 판정 — llmJudge SYSTEM_PROMPT 의 임계값과 동일. */
function evaluateKeywords(f: Fundamentals): string[] {
const passed: string[] = [];
const roe = f.roe ?? 0;
const om = f.operatingMargin ?? 0;
const retention = f.retentionRatio ?? 0;
const pbr = f.pbr ?? Number.MAX_SAFE_INTEGER;
const mktCap = f.marketCapEok ?? 0;
const sector = (f.sectorHint || '').toLowerCase();
if (roe >= 10) passed.push('ROE');
if (om >= 15) passed.push('성장성');
if (retention >= 1000) passed.push('유동성');
if (om >= 10) {
// 10% 이상이면 "수익성", 20% 이상이면 "수익성 개선" 표기.
passed.push(om >= 20 ? '수익성 개선' : '수익성');
}
if (om >= 15 && roe >= 8) passed.push('영업효율');
const techKeywords = ['ai', '반도체', '배터리', '바이오', '기술', 'semicon', 'battery', 'bio'];
if (techKeywords.some(k => sector.includes(k)) && pbr >= 2) passed.push('기술력');
if (retention >= 3000 && mktCap >= 5000) passed.push('안정성');
if (pbr > 0 && pbr <= 1.5) passed.push('PBR');
// 저평가 강조 — PBR이 낮을수록 추가 가산. 사용자 요청("저평가된 종목을 기준으로")에 맞춰
// 1.0 / 0.7 두 단계로 elevate. PBR=0.5짜리는 PBR+저평가+초저평가 = 3 키워드 자동 통과.
if (pbr > 0 && pbr <= 1.0) passed.push('저평가');
if (pbr > 0 && pbr <= 0.7) passed.push('초저평가');
return passed;
}
/** Fundamentals → Stock (stocks.json 호환 형식) 변환. */
function fundamentalsToStock(entry: ScreenerEntry, f: Fundamentals, filterText: string): Stock {
return {
이름: entry.name,
심볼: entry.symbol,
'유보율': f.retentionRatio !== undefined ? `${Math.round(f.retentionRatio).toLocaleString()}%` : undefined,
'ROE(25E)': f.roe !== undefined ? `${f.roe.toFixed(2)}%` : undefined,
'영업이익률(25E)': f.operatingMargin !== undefined ? `${f.operatingMargin.toFixed(1)}%` : undefined,
'PER(25E)': f.per !== undefined ? f.per.toFixed(1) : undefined,
PBR: f.pbr !== undefined ? f.pbr.toFixed(1) : undefined,
'시가총액': f.marketCapEok !== undefined ? `${Math.round(f.marketCapEok).toLocaleString()}` : undefined,
'3/4 필터': filterText,
현재가: f.currentPrice,
// 시가총액 기준 자동 분류 — 사용자가 나중에 수동으로 바꿀 수 있음.
투자성향: entry.marketCapEok < 3000 ? '저평가우량주' :
entry.marketCapEok < 10000 ? '스윙/중기' : '장기투자',
};
}
export async function discoverStocks(opts: DiscoverOptions = {}): Promise<DiscoveredCandidate[]> {
const minCap = opts.minMarketCapEok ?? 1000;
const maxCap = opts.maxMarketCapEok ?? 5000;
const markets = opts.markets ?? ['kospi', 'kosdaq'];
const maxPages = opts.maxPagesPerMarket ?? 10;
const limit = opts.limit ?? 20;
const progress = opts.onProgress ?? (() => {});
// (1)+(2) 1차 후보 수집 — 섹터 모드면 업종 코드로 직접, 아니면 시총 스크리너.
const allEntries: ScreenerEntry[] = [];
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: (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(opts.sector
? `⚠️ 섹터 '${opts.sector.key}' 시총 ${minCap}-${maxCap}억 범위 내 종목 0개. 범위를 넓혀보세요 (예: \`discover sector ${opts.sector.key} 500 50000\`).`
: '⚠️ 시가총액 범위 내 후보 0개. 범위 넓혀서 다시 시도해보세요.');
return [];
}
progress(`\n✅ 1차 후보 ${allEntries.length}개 — 펀더멘털 크롤 시작 (~${Math.ceil(allEntries.length * 0.5)}초 소요).`);
// (3) 개별 펀더멘털 크롤.
const symbols = allEntries.map(e => e.symbol);
const fundsMap = await fetchAllFundamentals(symbols, (sym, _f, i, total) => {
if (i % 10 === 0 || i === total) progress(` 펀더멘털 ${i}/${total} 처리 중...`);
});
// (4) 펀더멘털 키워드 평가 — 1차 통과 후보 추림.
// 섹터 모드는 종목이 이미 해당 섹터로 보장됨 → '추천 가능한 종목'을 주는 게 목적이라
// 3키워드 하드게이트를 완화한다(≥1 통과면 후보). 3키워드 미만이면 점수순 상위만
// 안내 문구와 함께. 일반 모드는 기존대로 ≥3 (정밀도 우선).
const minKeywords = opts.sector ? 1 : 3;
const prelim: { entry: ScreenerEntry; f: Fundamentals; passed: string[]; momentum?: MomentumInputs }[] = [];
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 < 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일선 아래 머물다 돌파 = 추세 전환" 신호를 펀더멘털 통과 후보에만
// 얹는다(전체 1차후보에 안 돌리는 이유: Yahoo 1초/심볼 throttle 비용 절감).
if (prelim.length > 0) {
progress(`\n📈 ${prelim.length}개 후보의 1년 시세로 224일선 회복 패턴 확인 중 (Yahoo 1초/종목, ~${prelim.length}초)...`);
const histMap = await fetchAllHistories(
prelim.map(p => p.entry.symbol),
(_sym, _ok, i, total) => {
if (i % 10 === 0 || i === total) progress(` 시세 ${i}/${total} 처리 중...`);
},
);
for (const p of prelim) {
const h = histMap.get(p.entry.symbol);
if (!h) continue;
const r = evalMa224Recovery(h);
if (r?.passed) p.passed.push('224회복');
const dr = evalDropRecovery(h);
if (dr?.passed) p.passed.push('낙폭과대');
// 합성 점수용 모멘텀 입력 수집 — 같은 시세로 정배열·RSI·12개월 수익률까지.
const align = evalMaAlignment(h);
const rsi = evalRsi14(h);
const closes = h.closes;
const first = closes.length >= 2 ? closes[0] : undefined;
const last = closes.length >= 2 ? closes[closes.length - 1] : undefined;
p.momentum = {
return12m: first && last && first > 0 ? last / first - 1 : undefined,
ma224: r ? (r.notApplicable ? 'na' : r.passed ? 'passed' : 'fail') : undefined,
dropRecovery: dr ? dr.passed : undefined,
alignment: align?.alignment,
rsi: rsi?.classification,
};
}
}
// (6) 멀티팩터 합성 점수 (v2.2.266) — 백분위 랭크가 후보군 내 상대평가이므로 배치 채점.
// 투자성향은 fundamentalsToStock 과 동일한 시총 기준 자동 분류를 가중치에 사용.
const styleOf = (capEok: number): Stock['투자성향'] =>
capEok < 3000 ? '저평가우량주' : capEok < 10000 ? '스윙/중기' : '장기투자';
const scoreInputs: ScoreInputs[] = prelim.map(({ entry, f, momentum }) => ({
symbol: entry.symbol,
style: styleOf(entry.marketCapEok),
roe: f.roe, operatingMargin: f.operatingMargin,
revenueGrowthYoY: f.revenueGrowthYoY, opProfitGrowthYoY: f.opProfitGrowthYoY,
debtRatio: f.debtRatio, per: f.per, pbr: f.pbr,
retentionRatio: f.retentionRatio, marketCapEok: f.marketCapEok ?? entry.marketCapEok,
momentum,
}));
const scores = scoreCandidates(scoreInputs);
// (7) DiscoveredCandidate 변환 + 점수 부착.
const candidates: DiscoveredCandidate[] = prelim.map(({ entry, f, passed }, i) => {
const top3 = passed.slice(0, 3);
const filterText = `[발굴 자동] 충족 (${top3.join(', ')})`;
return {
symbol: entry.symbol,
name: entry.name,
market: entry.market,
marketCapEok: entry.marketCapEok,
passedKeywords: passed,
asStock: fundamentalsToStock(entry, f, filterText),
fundamentals: f,
score: scores[i],
};
});
// (8) 순위 = 합성 점수 desc (기존 "키워드 수" 이진 카운트 대체 — 상관 지표 중복
// 카운트·value trap 문제 해결). 동점 시 PBR asc(저평가가 위로) 타이브레이커 유지.
candidates.sort((a, b) => {
if (b.score.total !== a.score.total) return b.score.total - a.score.total;
const pbrA = a.fundamentals.pbr ?? Number.MAX_SAFE_INTEGER;
const pbrB = b.fundamentals.pbr ?? Number.MAX_SAFE_INTEGER;
return pbrA - pbrB;
});
const limited = candidates.slice(0, limit);
logInfo(`Stocks discovery: ${allEntries.length} 1차 후보 → ${candidates.length} 매수후보 → ${limited.length} 표시 (합성점수 순).`);
progress(`\n🎯 ${limited.length}개 후보 발굴 완료 (전체 1차후보 ${allEntries.length}개 중 ${candidates.length}개 통과 · 멀티팩터 합성점수 순).`);
return limited;
}