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>
This commit is contained in:
2026-06-10 19:15:21 +09:00
parent ef3628c6eb
commit cbc2558550
5 changed files with 110 additions and 22 deletions
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+51 -16
View File
@@ -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<string, string[]> = {
'스윙/중기': ['ROE', '성장성', '유동성', '수익성'],
'장기투자': ['성장성', '유동성', '기술력', '영업효율'],
'저평가우량주': ['PBR', 'ROE', '성장성', '수익성', '안정성'],
'저평가우량주': ['PBR', 'PER', 'ROE', '성장성', '수익성', '안정성'],
};
export interface Verdict {
+20 -3
View File
@@ -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;
+36
View File
@@ -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)');
});
});