/** * 멀티팩터 합성점수 (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 => ({ 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+|-)$/); }); });