Files
connectai/tests/stocksCriteria.test.ts
koriweb cbc2558550 feat(stocks): 판정 기준 정밀화 — 레버리지 가드·실측 YoY·PER 기둥 (v2.2.212)
퀀트 실증 근거(F-Score 류 품질+가격 결합) 기반 기준 강화:
- ROE: 부채비율 ≤150% 레버리지 가드 — 빚으로 부풀린 ROE 배제 (듀폰분해).
- 성장성: Naver 연간 다년치에서 매출/영업이익 YoY 실측 계산을 1순위로
  (매출 ≥10% 또는 영업이익 ≥15%). 마진 수준·상장연차는 YoY 미확보 시 폴백.
- 안정성: 부채비율 ≤100% 가드 추가 — 유보율은 자본금 왜곡되는 약한 지표.
- PER ≤12배 키워드 신설(보유 데이터인데 미사용이던 가격 매력 축),
  저평가우량주 우선순위에 PBR 다음으로 편입.
- naverFundamentals: revenueGrowthYoY/opProfitGrowthYoY 추출 추가.
- 테스트 12건 (기존 사용자 패턴 8건 유지 + 신규 규칙 4건).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 19:15:21 +09:00

130 lines
7.0 KiB
TypeScript

/**
* criteriaEval — `/stocks judge` 결정론 평가기 테스트.
* 픽스처는 옛 LLM 프롬프트에 명시돼 있던 사용자의 실제 분류 예시 3종
* (마녀공장/기가비스/엔켐) — 코드 판정이 사용자 패턴과 일치해야 한다.
*/
import { evaluateCriteria, buildVerdict, marketCapEok } from '../src/features/stocks/criteriaEval';
import type { Stock } from '../src/features/stocks/types';
const NOW = new Date('2026-06-10');
function judge(stock: Stock, techPass?: boolean) {
const ev = evaluateCriteria(stock, undefined, NOW);
return { ev, verdict: buildVerdict(ev, stock., techPass) };
}
describe('stocks criteriaEval', () => {
test('마녀공장 패턴 — 충족 (ROE, 성장성, 유동성)', () => {
const { verdict } = judge({
: '마녀공장', : '439090', : '스윙/중기',
'ROE(25E)': '15.6%', '영업이익률(25E)': '18.0%', : '5,800%',
PBR: '1.2', : '4,000억',
});
expect(verdict.text).toBe('충족 (ROE, 성장성, 유동성)');
});
test('기가비스 패턴 — ROE 10% 미만이라 빠지고 수익성 개선 표기', () => {
const { verdict } = judge({
: '기가비스', : '420770', : '스윙/중기',
'ROE(25E)': '7.23%', '영업이익률(25E)': '25.7%', : '4,250%',
: '2024-05-24', PBR: '1.3', : '3,000억',
});
// 통과: 성장성(영업이익률≥15), 유동성, 수익성(≥20→개선), PBR — ROE 는 미통과
expect(verdict.passed).not.toContain('ROE');
expect(verdict.text).toBe('충족 (성장성, 유동성, 수익성 개선)');
});
test('엔켐 패턴 — 통과 2개면 미충족', () => {
const { verdict } = judge({
: '엔켐', : '348370', : '스윙/중기',
'ROE(25E)': '12.4%', '영업이익률(25E)': '8.5%', : '1,250%',
PBR: '3.5', : '2조 1,000억',
});
// 통과: ROE, 유동성 (성장성·수익성·영업효율·PBR 미통과, 기술력은 먹거리 미입력→fail)
expect(verdict.passed.sort()).toEqual(['ROE', '유동성']);
expect(verdict.text).toMatch(/^미충족/);
});
test('저평가우량주 — PBR·ROE 우선 선택', () => {
const { verdict } = judge({
: '가상우량', : '000001', : '저평가우량주',
'ROE(25E)': '11%', '영업이익률(25E)': '12%', : '3,500%',
PBR: '0.9', : '6,000억',
});
// 통과: PBR, ROE, 유동성, 수익성, 안정성 → 우선순위로 PBR, ROE 먼저
expect(verdict.text).toBe('충족 (PBR, ROE, 수익성)');
});
test('기술력 — 키워드 명중 시 LLM 없이 통과', () => {
const { ev } = judge({
: '테크주', : '000002', : '장기투자',
'ROE(25E)': '9%', '영업이익률(25E)': '16%', : '1,500%',
PBR: '2.5', '최대 먹거리': 'AI 반도체 설계',
});
const tech = ev.results.find(r => r.keyword === '기술력')!;
expect(tech.state).toBe('pass');
});
test('기술력 — 도메인 모호하면 llm 상태, techPass 반영', () => {
const stock: Stock = {
: '모호주', : '000003', : '장기투자',
'ROE(25E)': '9%', '영업이익률(25E)': '16%', : '1,500%',
PBR: '2.5', '최대 먹거리': '프리미엄 화장품 ODM',
};
const ev = evaluateCriteria(stock, undefined, NOW);
expect(ev.results.find(r => r.keyword === '기술력')!.state).toBe('llm');
// techPass=true → 충족에 기여, false → 제외
const yes = buildVerdict(ev, '장기투자', true);
const no = buildVerdict(ev, '장기투자', false);
expect(yes.passed).toContain('기술력');
expect(no.passed).not.toContain('기술력');
});
test('데이터 없음(unknown)은 통과로 치지 않는다', () => {
const { verdict } = judge({ : '빈데이터', : '000004' });
expect(verdict.text).toMatch(/^미충족/);
});
test('marketCapEok — 조/억 텍스트 파싱', () => {
expect(marketCapEok('5,000억')).toBe(5000);
expect(marketCapEok('1조 2,000억')).toBe(12000);
expect(marketCapEok('2조')).toBe(20000);
});
// ── v2.2.212 정밀화 규칙 ──────────────────────────────────────────────
test('ROE 레버리지 가드 — 부채비율 200% 면 ROE 15% 라도 미통과', () => {
const stock: Stock = { : '레버리지주', : '000010', 'ROE(25E)': '15%' };
const evHigh = evaluateCriteria(stock, { symbol: '000010', roe: 15, debtRatio: 200 }, NOW);
expect(evHigh.results.find(r => r.keyword === 'ROE')!.state).toBe('fail');
const evLow = evaluateCriteria(stock, { symbol: '000010', roe: 15, debtRatio: 80 }, NOW);
expect(evLow.results.find(r => r.keyword === 'ROE')!.state).toBe('pass');
});
test('성장성 — 실측 YoY 가 있으면 마진 폴백 대신 YoY 로 판정', () => {
const stock: Stock = { : '진짜성장주', : '000011', '영업이익률(25E)': '5%' };
// 마진 5% (폴백이면 fail) 이지만 매출 YoY 25% → pass
const grow = evaluateCriteria(stock, { symbol: '000011', operatingMargin: 5, revenueGrowthYoY: 25 }, NOW);
expect(grow.results.find(r => r.keyword === '성장성')!.state).toBe('pass');
// 마진 18% (폴백이면 pass) 이지만 매출 YoY -5%·영업이익 YoY 3% → 실측 우선으로 fail
const noGrow = evaluateCriteria(stock, { symbol: '000011', operatingMargin: 18, revenueGrowthYoY: -5, opProfitGrowthYoY: 3 }, NOW);
expect(noGrow.results.find(r => r.keyword === '성장성')!.state).toBe('fail');
});
test('안정성 부채 가드 — 유보율·시총 통과여도 부채비율 150% 면 미통과', () => {
const stock: Stock = { : '부채대형주', : '000012', : '4,000%', : '1조' };
const ev = evaluateCriteria(stock, { symbol: '000012', retentionRatio: 4000, marketCapEok: 10000, debtRatio: 150 }, NOW);
expect(ev.results.find(r => r.keyword === '안정성')!.state).toBe('fail');
});
test('PER 키워드 — ≤12배 통과, 저평가우량주 우선순위에 포함', () => {
const { ev, verdict } = judge({
: '저PER주', : '000013', : '저평가우량주',
'ROE(25E)': '11%', 'PER(25E)': '8', PBR: '0.9', : '1,200%',
});
expect(ev.results.find(r => r.keyword === 'PER')!.state).toBe('pass');
// 통과: ROE, 유동성, PBR, PER → 우선순위 [PBR, PER, ROE, ...]
expect(verdict.text).toBe('충족 (PBR, PER, ROE)');
});
});