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
parent 8f1ce4d617
commit 1dbd5fd186
7 changed files with 472 additions and 85 deletions
+131
View File
@@ -0,0 +1,131 @@
/**
* 멀티팩터 합성점수 (v2.2.266) — 백분위 랭크·축 합성·value trap 가드·결측 처리.
*/
import { percentileRanks, scoreCandidates, type ScoreInputs } from '../src/features/stocks/compositeScore';
describe('percentileRanks — 후보군 내 백분위', () => {
test('높을수록 좋은 지표 — 최솟값 0, 최댓값 100', () => {
const p = percentileRanks([5, 10, 20, 40]);
expect(p[0]).toBe(0);
expect(p[3]).toBe(100);
expect(p[1]!).toBeGreaterThan(p[0]!);
expect(p[2]!).toBeGreaterThan(p[1]!);
});
test('lowerBetter (PER/PBR/부채) — 낮은 값이 100', () => {
const p = percentileRanks([0.5, 1.0, 3.0], true);
expect(p[0]).toBe(100);
expect(p[2]).toBe(0);
});
test('결측(undefined)은 랭킹 제외 — 0점 취급 안 함', () => {
const p = percentileRanks([10, undefined, 30]);
expect(p[1]).toBeUndefined();
expect(p[0]).toBe(0);
expect(p[2]).toBe(100);
});
test('후보 1개뿐이면 비교 불가 → 중립 50', () => {
const p = percentileRanks([undefined, 15, undefined]);
expect(p[1]).toBe(50);
});
test('동점은 평균 랭크 — 같은 값 같은 점수', () => {
const p = percentileRanks([10, 10, 20]);
expect(p[0]).toBe(p[1]);
expect(p[2]).toBe(100);
});
});
describe('scoreCandidates — 축 합성·가중치', () => {
const base = (over: Partial<ScoreInputs>): ScoreInputs => ({
symbol: over.symbol || '000000',
roe: 10, operatingMargin: 10, opProfitGrowthYoY: 5, debtRatio: 80,
per: 10, pbr: 1.0, retentionRatio: 2000, marketCapEok: 3000,
...over,
});
test('전 지표 우수한 종목이 열등한 종목보다 높은 총점', () => {
const [good, bad] = scoreCandidates([
base({ symbol: 'GOOD', roe: 30, operatingMargin: 25, opProfitGrowthYoY: 40, debtRatio: 30, per: 5, pbr: 0.6, retentionRatio: 8000, marketCapEok: 9000 }),
base({ symbol: 'BAD', roe: 2, operatingMargin: 1, opProfitGrowthYoY: -20, debtRatio: 200, per: 40, pbr: 3.5, retentionRatio: 300, marketCapEok: 1200 }),
]);
expect(good.total).toBeGreaterThan(bad.total);
expect(good.quality!).toBeGreaterThan(bad.quality!);
expect(good.value!).toBeGreaterThan(bad.value!);
});
test('그라데이션 반영 — ROE 35% 가 10.1% 보다 높은 퀄리티 (이진 카운트의 한계 해소)', () => {
const [hi, lo] = scoreCandidates([
base({ symbol: 'HI', roe: 35 }),
base({ symbol: 'LO', roe: 10.1 }),
]);
expect(hi.quality!).toBeGreaterThan(lo.quality!);
});
test('value trap 가드 — 싸지만 품질 미달이면 Value 축 50 캡', () => {
// miniF: ROE<0, OM<0, 성장<0, 부채 200>150 → 0/4 통과 → 가드.
// PBR 0.4/PER 3 으로 배치 내 최저가(=Value 백분위 최고)여도 50 에 캡.
const [trap, normal] = scoreCandidates([
base({ symbol: 'TRAP', roe: -5, operatingMargin: -3, opProfitGrowthYoY: -30, debtRatio: 200, pbr: 0.4, per: 3 }),
base({ symbol: 'NORM', roe: 15, operatingMargin: 12, opProfitGrowthYoY: 10, debtRatio: 60, pbr: 1.2, per: 12 }),
]);
expect(trap.valueTrapGuarded).toBe(true);
expect(trap.value).toBe(50);
expect(normal.valueTrapGuarded).toBe(false);
expect(trap.fScore).toBe('0/4');
});
test('품질 좋은 저평가주는 가드 미발동 — Value 100 유지', () => {
const [cheap] = scoreCandidates([
base({ symbol: 'CHEAP', roe: 20, operatingMargin: 15, opProfitGrowthYoY: 20, debtRatio: 40, pbr: 0.5, per: 4 }),
base({ symbol: 'EXP', pbr: 3.0, per: 30 }),
]);
expect(cheap.valueTrapGuarded).toBe(false);
expect(cheap.value).toBe(100);
});
test('투자성향 가중치 — 모멘텀 강자는 스윙에서, 퀄리티 강자는 장기에서 우위', () => {
const momStar: ScoreInputs = base({
symbol: 'MOM', roe: 5, operatingMargin: 5,
momentum: { return12m: 0.8, alignment: '정배열', ma224: 'passed', rsi: '중립' },
});
const qualStar: ScoreInputs = base({
symbol: 'QUAL', roe: 30, operatingMargin: 25, opProfitGrowthYoY: 30, debtRatio: 20,
momentum: { return12m: -0.3, alignment: '역배열', ma224: 'fail', rsi: '중립' },
});
const asSwing = scoreCandidates([
{ ...momStar, style: '스윙/중기' }, { ...qualStar, style: '스윙/중기' },
]);
const asLong = scoreCandidates([
{ ...momStar, style: '장기투자' }, { ...qualStar, style: '장기투자' },
]);
// 같은 두 종목이라도 성향에 따라 격차가 달라짐 — 스윙에서는 모멘텀 강자가 상대적으로 유리.
const swingGap = asSwing[0].total - asSwing[1].total;
const longGap = asLong[0].total - asLong[1].total;
expect(swingGap).toBeGreaterThan(longGap);
});
test('결측 축은 가중치 재정규화 — 모멘텀 없어도 총점 0~100 유지', () => {
const [s] = scoreCandidates([
base({ symbol: 'NOMOM' }),
base({ symbol: 'OTHER', roe: 20 }),
]);
expect(s.momentum).toBeUndefined();
expect(s.total).toBeGreaterThanOrEqual(0);
expect(s.total).toBeLessThanOrEqual(100);
expect(s.weights.m).toBe(0);
const wSum = s.weights.q + s.weights.v + s.weights.s;
expect(wSum).toBeCloseTo(1, 5);
});
test('전 지표 결측이면 중립 50', () => {
const [empty] = scoreCandidates([{ symbol: 'EMPTY' }]);
expect(empty.total).toBe(50);
});
test('breakdown 문자열 형식 — "Q.. V.. M.. S.."', () => {
const [s] = scoreCandidates([base({ symbol: 'FMT' }), base({ symbol: 'F2', roe: 20 })]);
expect(s.breakdown).toMatch(/^Q(\d+|-) V(\d+|-) M(\d+|-) S(\d+|-)$/);
});
});