diff --git a/package-lock.json b/package-lock.json index d80a3a0..7a322c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astra", - "version": "2.2.211", + "version": "2.2.212", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astra", - "version": "2.2.211", + "version": "2.2.212", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", diff --git a/package.json b/package.json index 1538e29..bfbd458 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.211", + "version": "2.2.212", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/features/stocks/criteriaEval.ts b/src/features/stocks/criteriaEval.ts index daceeeb..77d31e1 100644 --- a/src/features/stocks/criteriaEval.ts +++ b/src/features/stocks/criteriaEval.ts @@ -3,10 +3,16 @@ * * 기존에는 "유보율: 5,800%" 같은 문자열 파싱과 임계값 비교(ROE ≥ 10% 등)까지 * 전부 LLM 에게 맡겼는데, 로컬 소형 모델은 콤마 숫자 파싱·다중 기준 동시 비교에서 - * 자주 틀린다. 8개 키워드 중 7개는 순수 수치 비교라 코드로 100% 정확하게 계산 - * 가능하므로 여기서 평가하고, LLM 은 ① '기술력' 도메인 정성 판단(키워드 매칭이 - * 모호할 때만) ② 근거 문장 서술만 담당한다. 충족/미충족 판정과 대표 키워드 - * 선택은 사용자가 명시한 규칙(투자성향별 우선순위)을 그대로 코드화했다. + * 자주 틀린다. 수치 기준은 코드로 100% 정확하게 계산하고, LLM 은 ① '기술력' + * 도메인 정성 판단(키워드 매칭이 모호할 때만) ② 근거 문장 서술만 담당한다. + * 충족/미충족 판정과 대표 키워드 선택은 사용자가 명시한 규칙(투자성향별 + * 우선순위)을 그대로 코드화했다. + * + * v2.2.212 정밀화 (퀀트 실증 근거 반영): + * - ROE 에 레버리지 가드(부채비율 ≤150%) — 빚으로 부풀린 ROE 배제 (듀폰분해). + * - 성장성을 실측 YoY(매출 ≥10% / 영업이익 ≥15%) 1순위로 — 마진 수준은 성장이 아님. + * - 안정성에 부채비율 ≤100% 가드 — 유보율은 자본금 크기에 왜곡되는 약한 지표. + * - PER ≤12배 키워드 신설 — 보유 데이터인데 미사용이던 가격 매력(이익수익률) 축. * * signalClassifier 의 `.includes("충족")` 계약과 "충족 (A, B, C)" 출력 형식은 * 기존 그대로 유지된다. @@ -77,6 +83,10 @@ export function evaluateCriteria(stock: Stock, fresh?: Fundamentals, now: Date = const opm = fresh?.operatingMargin ?? num(stock['영업이익률(25E)']); const ret = fresh?.retentionRatio ?? num(stock.유보율); const pbr = fresh?.pbr ?? num(stock.PBR); + const per = fresh?.per ?? num(stock['PER(25E)']); + const debt = fresh?.debtRatio; // 부채비율 — fresh 전용 (stocks.json 미보유) + const revYoY = fresh?.revenueGrowthYoY; // 매출 YoY % — fresh 전용 + const opYoY = fresh?.opProfitGrowthYoY; // 영업이익 YoY % — fresh 전용 const cap = fresh?.marketCapEok ?? marketCapEok(stock.시가총액); const listedYears = yearsSinceListing(stock.상장일, now); const biz = (stock['최대 먹거리'] || '').trim(); @@ -86,15 +96,28 @@ export function evaluateCriteria(stock: Stock, fresh?: Fundamentals, now: Date = const results: CriterionResult[] = []; - results.push(R('ROE', roe === undefined ? undefined : roe >= 10, - `ROE ${fmt(roe)} (기준 ≥10%${roe !== undefined && roe >= 15 ? ', 15% 이상 우수' : ''})`)); + // ROE — 듀폰분해상 레버리지로 부풀린 ROE 를 거른다: 부채비율(있으면) ≤150% 동반 요구. + const roeBase = roe === undefined ? undefined : roe >= 10; + const leverageOk = debt === undefined ? true : debt <= 150; + results.push(R('ROE', roeBase === undefined ? undefined : (roeBase && leverageOk), + `ROE ${fmt(roe)} (기준 ≥10%${roe !== undefined && roe >= 15 ? ', 15% 이상 우수' : ''})` + + (debt !== undefined ? ` · 부채비율 ${fmt(debt)} (레버리지 가드 ≤150%)` : ''))); - const growthByMargin = opm === undefined ? undefined : opm >= 15; - const growthByListing = listedYears === undefined ? undefined : listedYears <= 3; - const growth = growthByMargin === true || growthByListing === true ? true - : growthByMargin === undefined && growthByListing === undefined ? undefined : false; - results.push(R('성장성', growth, - `영업이익률 ${fmt(opm)} (기준 ≥15%) 또는 상장 ${listedYears === undefined ? '미상' : listedYears.toFixed(1) + '년'} (기준 ≤3년)`)); + // 성장성 — 실측 YoY(매출 ≥10% 또는 영업이익 ≥15%)를 1순위로. YoY 미확보 시에만 + // 기존 대용물(마진 수준·상장 연차)로 폴백 — 마진 15%는 '수익성 수준'이지 성장이 아님. + let growth: boolean | undefined; + let growthDetail: string; + if (revYoY !== undefined || opYoY !== undefined) { + growth = (revYoY !== undefined && revYoY >= 10) || (opYoY !== undefined && opYoY >= 15); + growthDetail = `매출 YoY ${fmt(revYoY)} (기준 ≥10%) 또는 영업이익 YoY ${fmt(opYoY)} (기준 ≥15%) [실측]`; + } else { + const growthByMargin = opm === undefined ? undefined : opm >= 15; + const growthByListing = listedYears === undefined ? undefined : listedYears <= 3; + growth = growthByMargin === true || growthByListing === true ? true + : growthByMargin === undefined && growthByListing === undefined ? undefined : false; + growthDetail = `영업이익률 ${fmt(opm)} (기준 ≥15%) 또는 상장 ${listedYears === undefined ? '미상' : listedYears.toFixed(1) + '년'} (기준 ≤3년) [YoY 미확보 폴백]`; + } + results.push(R('성장성', growth, growthDetail)); results.push(R('유동성', ret === undefined ? undefined : ret >= 1000, `유보율 ${fmt(ret)} (기준 ≥1,000%)`)); @@ -117,19 +140,31 @@ export function evaluateCriteria(stock: Stock, fresh?: Fundamentals, now: Date = else tech = { keyword: '기술력', state: 'llm', detail: `PBR ${fmt(pbr, '')} ≥2, 최대먹거리 '${biz}' — 기술영역 여부 정성 판단 필요` }; results.push(tech); + // 안정성 — 유보율·시총에 더해 부채비율(있으면) ≤100% 동반 요구. 유보율은 자본금 + // 크기에 왜곡되는 지표라 단독으론 약함 — 부채 가드가 실질 안전판. + const stabilityBase = ret === undefined || cap === undefined ? undefined : (ret >= 3000 && cap >= 5000); + const stabilityDebtOk = debt === undefined ? true : debt <= 100; results.push(R('안정성', - ret === undefined || cap === undefined ? undefined : (ret >= 3000 && cap >= 5000), - `유보율 ${fmt(ret)} ≥3,000% AND 시총 ${cap === undefined ? '-' : cap.toLocaleString() + '억'} ≥5,000억`)); + stabilityBase === undefined ? undefined : (stabilityBase && stabilityDebtOk), + `유보율 ${fmt(ret)} ≥3,000% AND 시총 ${cap === undefined ? '-' : cap.toLocaleString() + '억'} ≥5,000억` + + (debt !== undefined ? ` AND 부채비율 ${fmt(debt)} ≤100%` : ''))); results.push(R('PBR', pbr === undefined ? undefined : pbr <= 1.5, `PBR ${fmt(pbr, '')} (기준 ≤1.5)`)); + // PER — 가격 매력(이익수익률). 데이터가 이미 있는데 안 쓰던 지표. + // 2026 시장 평균 PER ~20배 환경에서 ≤12 는 뚜렷한 저평가 신호. + results.push(R('PER', per === undefined ? undefined : per <= 12, + `PER ${fmt(per, '배')} (기준 ≤12배)`)); + return { results, dataSource: fresh ? `Naver 실시간 ${now.toISOString().slice(0, 10)}` : 'stocks.json 저장값', numbers: { ROE: fmt(roe), 영업이익률: fmt(opm), 유보율: fmt(ret), - PBR: fmt(pbr, ''), 시가총액: cap === undefined ? '-' : `${cap.toLocaleString()}억`, + PBR: fmt(pbr, ''), PER: fmt(per, '배'), + 부채비율: fmt(debt), 시가총액: cap === undefined ? '-' : `${cap.toLocaleString()}억`, + '매출 YoY': fmt(revYoY), '영업이익 YoY': fmt(opYoY), }, }; } @@ -138,7 +173,7 @@ export function evaluateCriteria(stock: Stock, fresh?: Fundamentals, now: Date = const PRIORITY: Record = { '스윙/중기': ['ROE', '성장성', '유동성', '수익성'], '장기투자': ['성장성', '유동성', '기술력', '영업효율'], - '저평가우량주': ['PBR', 'ROE', '성장성', '수익성', '안정성'], + '저평가우량주': ['PBR', 'PER', 'ROE', '성장성', '수익성', '안정성'], }; export interface Verdict { diff --git a/src/features/stocks/naverFundamentals.ts b/src/features/stocks/naverFundamentals.ts index fc18e62..d23ec65 100644 --- a/src/features/stocks/naverFundamentals.ts +++ b/src/features/stocks/naverFundamentals.ts @@ -22,6 +22,9 @@ export interface Fundamentals { operatingMargin?: number; // % (영업이익률) retentionRatio?: number; // % (유보율) debtRatio?: number; // % (부채비율) + /** 최근 확정 2개 연도 기준 YoY 성장률 (%). '성장성' 기준의 실측치 — 마진 수준이 아닌 진짜 성장. */ + revenueGrowthYoY?: number; // 매출액 YoY % + opProfitGrowthYoY?: number; // 영업이익 YoY % /** integration API 의 현재가 + 평가지표. */ per?: number; pbr?: number; @@ -125,18 +128,32 @@ export async function fetchFundamentals(symbol: string, timeoutMs = 10000): Prom // finance/annual — rowList 에서 최근 확정 연도 컬럼 값. if (fin?.financeInfo?.rowList && fin.financeInfo.rowList.length > 0) { - const latestKey = pickLatestConfirmedKey(fin.financeInfo.trTitleList); + const confirmedKeys = (fin.financeInfo.trTitleList || []) + .filter(t => t.isConsensus === 'N').map(t => t.key).sort().reverse(); + const latestKey = confirmedKeys[0] ?? null; + const prevKey = confirmedKeys[1] ?? null; if (latestKey) { const rowByTitle = new Map(fin.financeInfo.rowList.map(r => [r.title, r])); - const valueOf = (title: string): number | undefined => { + const valueOf = (title: string, key: string = latestKey): number | undefined => { const row = rowByTitle.get(title); if (!row) return undefined; - return parseNumber(row.columns[latestKey]?.value); + return parseNumber(row.columns[key]?.value); }; out.roe = valueOf('ROE'); out.operatingMargin = valueOf('영업이익률'); out.retentionRatio = valueOf('유보율'); out.debtRatio = valueOf('부채비율'); + // YoY 성장률 — 최근 확정 2개 연도. '성장성'을 마진 수준이 아닌 실측 성장으로 판정. + if (prevKey) { + const yoy = (title: string): number | undefined => { + const a = valueOf(title, latestKey); + const b = valueOf(title, prevKey); + if (a === undefined || b === undefined || b === 0) return undefined; + return ((a - b) / Math.abs(b)) * 100; + }; + out.revenueGrowthYoY = yoy('매출액'); + out.opProfitGrowthYoY = yoy('영업이익'); + } } } return out; diff --git a/tests/stocksCriteria.test.ts b/tests/stocksCriteria.test.ts index 34b93d1..3e07768 100644 --- a/tests/stocksCriteria.test.ts +++ b/tests/stocksCriteria.test.ts @@ -90,4 +90,40 @@ describe('stocks criteriaEval', () => { 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)'); + }); });