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:
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user