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>
This commit is contained in:
2026-07-02 19:50:06 +09:00
co-authored by Claude Fable 5
parent 8f1ce4d617
commit 1dbd5fd186
7 changed files with 472 additions and 85 deletions
+4 -3
View File
@@ -573,14 +573,15 @@ async function cmdDiscover(rest: string, view: Webview | undefined, context?: vs
return;
}
chunk(view, `\n📋 **발굴 후보 ${candidates.length}개** (통과 키워드 수 내림차순)\n\n`);
chunk(view, `\n📋 **발굴 후보 ${candidates.length}개** (멀티팩터 합성점수 내림차순 — Q퀄리티/V밸류/M모멘텀/S안정성)\n\n`);
for (const c of candidates) {
const price = c.fundamentals.currentPrice;
const priceStr = typeof price === 'number' ? price.toLocaleString() + '원' : '-';
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`);
const vtMark = c.score.valueTrapGuarded ? ' · ⚠️VT가드' : '';
chunk(view, ` · **${c.score.total}점** ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억${sectorStr}]${weakMark}\n`);
chunk(view, ` ${c.score.breakdown}${c.score.fScore ? ` · miniF ${c.score.fScore}` : ''}${vtMark} · 현재가 ${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`);
}