v2.2.266: /stocks discover 멀티팩터 합성점수 (Quality/Value/Momentum/Stability)

/stocks discover 추천 순위를 업계 표준 방식(Stockopedia StockRanks·S&P 멀티팩터 지수)으로 고도화:

[compositeScore.ts] 신설:
- 후보군 내 백분위 정규화: 각 지표를 0~100 범위로 상대평가
- 4축 분리: Quality(ROE·마진·성장YoY·부채) / Value(PER·PBR) / Momentum(12m수익률·224회복·정배열·RSI·낙폭회복) / Stability(유보율·시총·부채)
- 투자성향별 가중 합성: 스윙/중기=M35%, 장기투자=Q40%, 저평가우량주=V40%
- Value trap 가드: miniF-Score(Piotroski) 절반 미달 시 Value 축 50점 캡 → ⚠️VT가드 표시
- 결측 축 재정규화: 데이터 없는 축은 가중치에서 제외

[stockDiscovery.ts] 배선:
- 모멘텀 입력 수집: 기존 시세 평가(MA224·낙폭회복) + 새로 정배열·RSI·12개월 수익률 계산
- 배치 채점: 후보 전체를 한 번에 scoreCandidates() 호출 → 백분위 정규화
- 정렬: "키워드 개수" → "합성점수" 내림차순 (타이브레이커 PBR 유지)
- DiscoveredCandidate에 score 필드 추가

[discoveryAnalyzer.ts] 재설계 (LLM 서술만):
- Top 5 선정: 코드가 확정(candidates.slice(0, 5)) — 순위 변경 X
- LLM 역할: 각 종목 pitch 한 줄만 서술 (v2.2.211 judge 철학 적용)
- 실패 폴백: LLM 무응답 시 결정론 pitch 자동 생성 (점수 분해 + 최강 축 언급)
- 출력: 점수 분해(Q/V/M/S), mini F-Score, value trap 가드 표시 추가

[slashStocks.ts] 출력:
- 후보 목록: 순위 점수 + 분해 점수 + VT가드 · mini F 표시
- 모멘텀: "키워드 수" → "멀티팩터 합성점수 내림차순" 명시

[tests/compositeScore.test.ts] 신설:
- percentileRanks: 백분위, 동점 평균 랭크, 결측 처리
- scoreCandidates: 그라데이션(이진 카운트 해소), value trap 가드, 성향별 가중 차별, 결측 재정규화
- 총 13개 테스트 케이스

효과:
- 그라데이션 반영: ROE 35% > 10.1% 실감
- 중복 카운트 제거: 영업이익률이 한 지표만 계산
- Value trap 방어: PBR 0.4 + 품질 미달 = 50점 캡
- Top 5 신뢰도: LLM 순위 변조 불가

한계: 문헌 기반 설계이며 한국 시장 백테스트 미실시 — 실전 매수는 참고용 권장.

(검증: tsc ✓ · jest 728/730 ✓ + compositeScore 13개 신규 테스트)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 19:50:06 +09:00
parent 8f1ce4d617
commit 1dbd5fd186
7 changed files with 472 additions and 85 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "astra", "name": "astra",
"displayName": "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.", "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.265", "version": "2.2.266",
"publisher": "g1nation", "publisher": "g1nation",
"license": "MIT", "license": "MIT",
"icon": "assets/icon.png", "icon": "assets/icon.png",
@@ -730,5 +730,3 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
.replace('__VERSION__', version); .replace('__VERSION__', version);
} }
} }
export const SETTINGS_TELEGRAM_TOKEN_SECRET_KEY = TELEGRAM_TOKEN_SECRET_KEY;
+204
View File
@@ -0,0 +1,204 @@
import type { Stock } from './types';
/**
* 멀티팩터 합성 점수 (0~100) — `/stocks discover` 추천 순위의 코어.
*
* v2.2.266 도입 배경: 기존 순위는 "통과 키워드 수" 이진 카운트였는데,
* ① ROE 10.1%와 35%가 같은 1점 (그라데이션 없음)
* ② 영업이익률 하나가 성장성·수익성·영업효율 3키워드에 중복 산입 (한 지표 과대반영)
* ③ 저PBR 무조건 가산 → value trap (싸기만 한 부실주) 무방비
* 를 해결하기 위해 업계 표준(Stockopedia StockRanks·S&P QVM 멀티팩터 지수) 방식인
* **후보군 내 백분위 랭크 정규화 → Quality/Value/Momentum/Stability 4축 → 가중 합성**
* 으로 교체한다. 저PBR 가산은 Piotroski F-Score 방식의 품질 가드를 통과할 때만 유효.
*
* 축별 점수·합성은 전부 결정론(코드) — LLM 은 서술만 담당한다(v2.2.211 judge 철학과 동일).
*/
export interface MomentumInputs {
/** 약 12개월 수익률 (배수-1, 예: 0.35 = +35%). 시세 최초~최후 종가 기반. */
return12m?: number;
/** 224일선 회복 패턴 — passed / notApplicable(추세 이미 확립) / fail. */
ma224?: 'passed' | 'na' | 'fail';
/** 낙폭과대+반등초입 패턴 통과 여부. */
dropRecovery?: boolean;
/** 이평선 배열. */
alignment?: '정배열' | '역배열' | '혼조';
/** RSI(14) 분류. */
rsi?: '과열' | '중립' | '침체';
}
export interface ScoreInputs {
symbol: string;
/** 가중치 결정용 투자성향 — 미지정 시 등가중. */
style?: Stock['투자성향'];
// Quality
roe?: number;
operatingMargin?: number;
revenueGrowthYoY?: number;
opProfitGrowthYoY?: number;
debtRatio?: number; // lower-better
// Value
per?: number; // lower-better
pbr?: number; // lower-better
// Stability
retentionRatio?: number;
marketCapEok?: number;
// Momentum
momentum?: MomentumInputs;
}
export interface AxisWeights { q: number; v: number; m: number; s: number }
export interface CompositeScore {
symbol: string;
/** 0~100 종합 (투자성향 가중). */
total: number;
quality: number | undefined;
value: number | undefined;
momentum: number | undefined;
stability: number | undefined;
/** value trap 가드 발동 여부 — 품질 미달로 Value 축이 50점에 캡핑됨. */
valueTrapGuarded: boolean;
/** mini F-Score "통과/평가가능" (예: "3/4"). 데이터 부족 시 undefined. */
fScore?: string;
/** 실제 적용된 가중치 (결측 축 재정규화 후). */
weights: AxisWeights;
/** "Q92 V81 M76 S88" 형태 — 출력·pitch 용. */
breakdown: string;
}
/** 투자성향별 가중치 — 스윙=모멘텀↑, 장기=퀄리티↑, 저평가우량주=밸류↑. */
const STYLE_WEIGHTS: Record<string, AxisWeights> = {
'스윙/중기': { q: 0.25, v: 0.25, m: 0.35, s: 0.15 },
'장기투자': { q: 0.40, v: 0.20, m: 0.15, s: 0.25 },
'저평가우량주': { q: 0.30, v: 0.40, m: 0.10, s: 0.20 },
};
const EQUAL_WEIGHTS: AxisWeights = { q: 0.25, v: 0.25, m: 0.25, s: 0.25 };
/**
* 후보군 내 백분위 랭크 (0~100, 동점은 평균 랭크). values 에 없는(undefined) 후보는
* 랭킹에서 제외 — 결측을 0점 취급하면 데이터 없는 종목이 부당하게 밀리므로.
* n==1 이면 비교 대상이 없어 중립 50.
*/
export function percentileRanks(values: (number | undefined)[], lowerBetter = false): (number | undefined)[] {
const present = values
.map((v, i) => ({ v, i }))
.filter((x): x is { v: number; i: number } => x.v !== undefined && Number.isFinite(x.v));
const out: (number | undefined)[] = values.map(() => undefined);
const n = present.length;
if (n === 0) return out;
if (n === 1) { out[present[0].i] = 50; return out; }
const sorted = [...present].sort((a, b) => a.v - b.v);
// 동점 평균 랭크
const rankByIdx = new Map<number, number>();
let k = 0;
while (k < n) {
let j = k;
while (j + 1 < n && sorted[j + 1].v === sorted[k].v) j++;
const avgRank = (k + j) / 2;
for (let t = k; t <= j; t++) rankByIdx.set(sorted[t].i, avgRank);
k = j + 1;
}
for (const { i } of present) {
const r = rankByIdx.get(i)!;
const p = (r / (n - 1)) * 100;
out[i] = lowerBetter ? 100 - p : p;
}
return out;
}
/** 사용 가능한 하위 점수들의 평균 — 전부 결측이면 undefined (축 자체를 결측 처리). */
function axisMean(parts: (number | undefined)[]): number | undefined {
const avail = parts.filter((p): p is number => p !== undefined);
if (avail.length === 0) return undefined;
return avail.reduce((a, b) => a + b, 0) / avail.length;
}
/** 모멘텀 하위 신호 → 0~100 점수 변환 (기술 신호는 등급형이라 백분위 대신 고정 매핑). */
function momentumSubScores(m: MomentumInputs | undefined, return12mPct: number | undefined): (number | undefined)[] {
if (!m && return12mPct === undefined) return [];
const alignScore = m?.alignment === '정배열' ? 100 : m?.alignment === '역배열' ? 0 : m?.alignment === '혼조' ? 50 : undefined;
const ma224Score = m?.ma224 === 'passed' ? 100 : m?.ma224 === 'na' ? 50 : m?.ma224 === 'fail' ? 35 : undefined;
// 낙폭회복은 "있으면 강한 진입 신호, 없어도 감점 아님" — passed=100 / 그 외 중립 50.
const dropScore = m?.dropRecovery === true ? 100 : m?.dropRecovery === false ? 50 : undefined;
// RSI 는 역발상 해석 — 과열은 진입 리스크(25), 침체는 반등 여지(75).
const rsiScore = m?.rsi === '과열' ? 25 : m?.rsi === '침체' ? 75 : m?.rsi === '중립' ? 60 : undefined;
return [return12mPct, alignScore, ma224Score, dropScore, rsiScore];
}
/**
* mini F-Score (Piotroski 축약판 — 보유 데이터로 가능한 4개 항목):
* ① ROE > 0 ② 영업이익률 > 0 ③ 성장(매출 or 영업이익 YoY) > 0 ④ 부채비율 ≤ 150%
* 평가 가능한 항목 2개 이상 & 통과가 절반 미만이면 value trap 위험 → Value 축 50점 캡.
*/
function miniFScore(s: ScoreInputs): { passed: number; available: number } {
let passed = 0, available = 0;
const check = (v: number | undefined, ok: (x: number) => boolean) => {
if (v === undefined || !Number.isFinite(v)) return;
available++;
if (ok(v)) passed++;
};
check(s.roe, x => x > 0);
check(s.operatingMargin, x => x > 0);
const growth = s.opProfitGrowthYoY ?? s.revenueGrowthYoY;
check(growth, x => x > 0);
check(s.debtRatio, x => x <= 150);
return { passed, available };
}
/**
* 후보 배치 전체를 한 번에 채점한다 — 백분위 랭크가 cross-sectional(후보군 내 상대평가)
* 이므로 반드시 배치 단위로 호출. 반환 순서는 입력 순서와 동일.
*/
export function scoreCandidates(inputs: ScoreInputs[]): CompositeScore[] {
// 지표별 백분위 (배치 전체 기준)
const pRoe = percentileRanks(inputs.map(s => s.roe));
const pOpm = percentileRanks(inputs.map(s => s.operatingMargin));
const pGrowth = percentileRanks(inputs.map(s => s.opProfitGrowthYoY ?? s.revenueGrowthYoY));
const pDebt = percentileRanks(inputs.map(s => s.debtRatio), true);
const pPer = percentileRanks(inputs.map(s => (s.per !== undefined && s.per > 0 ? s.per : undefined)), true);
const pPbr = percentileRanks(inputs.map(s => (s.pbr !== undefined && s.pbr > 0 ? s.pbr : undefined)), true);
const pRet = percentileRanks(inputs.map(s => s.retentionRatio));
const pCap = percentileRanks(inputs.map(s => s.marketCapEok));
const pR12 = percentileRanks(inputs.map(s => s.momentum?.return12m));
return inputs.map((s, i) => {
const quality = axisMean([pRoe[i], pOpm[i], pGrowth[i], pDebt[i]]);
let value = axisMean([pPer[i], pPbr[i]]);
const momentum = axisMean(momentumSubScores(s.momentum, pR12[i]));
const stability = axisMean([pRet[i], pCap[i], pDebt[i]]);
// value trap 가드 — 품질 체크 절반 미만 통과면 "싸다" 가산을 중립(50)까지로 제한.
const f = miniFScore(s);
let valueTrapGuarded = false;
if (value !== undefined && f.available >= 2 && f.passed < f.available / 2 && value > 50) {
value = 50;
valueTrapGuarded = true;
}
// 결측 축 제외하고 가중치 재정규화.
const base = STYLE_WEIGHTS[s.style || ''] || EQUAL_WEIGHTS;
const axes: { key: keyof AxisWeights; score: number | undefined }[] = [
{ key: 'q', score: quality }, { key: 'v', score: value },
{ key: 'm', score: momentum }, { key: 's', score: stability },
];
const availAxes = axes.filter(a => a.score !== undefined);
const wSum = availAxes.reduce((a, b) => a + base[b.key], 0);
const total = wSum > 0
? availAxes.reduce((acc, a) => acc + (a.score as number) * (base[a.key] / wSum), 0)
: 50; // 전 축 결측 — 중립
const weights: AxisWeights = { q: 0, v: 0, m: 0, s: 0 };
for (const a of availAxes) weights[a.key] = base[a.key] / (wSum || 1);
const fmt = (v: number | undefined) => (v === undefined ? '-' : String(Math.round(v)));
return {
symbol: s.symbol,
total: Math.round(total * 10) / 10,
quality, value, momentum, stability,
valueTrapGuarded,
fScore: f.available >= 2 ? `${f.passed}/${f.available}` : undefined,
weights,
breakdown: `Q${fmt(quality)} V${fmt(value)} M${fmt(momentum)} S${fmt(stability)}`,
};
});
}
+91 -69
View File
@@ -6,17 +6,20 @@ import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
import type { DiscoveredCandidate } from './stockDiscovery'; import type { DiscoveredCandidate } from './stockDiscovery';
/** /**
* Discover 결과를 LLM 에게 던져 *매력도 Top 5* 추출 + 채팅/텔레그램 발송. * Discover 결과 *매력도 Top 5* 정리 + 채팅/텔레그램 발송.
*
* v2.2.266 재설계: Top 5 **선정은 코드**가 한다 — candidates 는 이미 멀티팩터
* 합성점수(compositeScore) 내림차순으로 정렬돼 오므로 상위 5개가 곧 Top 5.
* LLM 의 역할은 각 종목의 *한 줄 매력 포인트(pitch) 서술*뿐이다 (v2.2.211 judge
* 철학과 동일: "수치 비교·순위는 코드로, LLM 은 서술만"). 따라서 LLM 이 실패해도
* Top 5 는 항상 나온다 — pitch 만 결정론 폴백(점수 분해 문자열)으로 대체.
* *
* 호출 흐름 (slashStocks 의 cmdDiscover 끝에 자동 chain): * 호출 흐름 (slashStocks 의 cmdDiscover 끝에 자동 chain):
* 1) buildAnalysisPrompt(candidates) — 20개 후보 데이터를 LLM 입력 형식으로 직렬화 * 1) 상위 5개 확정 (코드) — candidates.slice(0, 5)
* 2) AIService.chat(...) — Astra 의 기본 모델로 평가 * 2) buildPitchPrompt → AIService.chat — 5개 종목의 pitch 서술 요청
* 3) parseTopFive(output) — LLM 응답을 구조화된 5개 항목으로 파싱 (관대) * 3) parsePitches (관대한 번호 매칭) — 실패 항목은 결정론 폴백 pitch
* 4) renderForChat / renderForTelegram — 사용자에게 보일 메시지 두 가지 형식 * 4) renderForChat / renderForTelegram 점수 분해(Q/V/M/S) 포함 표시
* 5) sendToTelegram(...) — 텔레그램 chatId 로 발송 (실패 silent) * 5) sendToTelegram(...) — 텔레그램 발송 (실패 silent)
*
* 작은 모델 (gemma 4B 등) 이 형식을 흩뜨려도 깨지지 않게 — parseTopFive 는 *느슨한*
* 번호 매칭으로 5개 라인만 추출, 실패 시 raw text 그대로 fallback.
*/ */
export interface TopFiveItem { export interface TopFiveItem {
@@ -38,39 +41,39 @@ export interface TopFiveResult {
} }
const SYSTEM_PROMPT = [ const SYSTEM_PROMPT = [
'당신은 한국 주식 발굴 결과를 검토하는 가치 투자 분석가다.', '당신은 한국·미국 주식 발굴 결과를 서술하는 애널리스트 보조 도구다.',
'제공된 후보 종목들의 *재무 지표* 와 *통과 키워드* 를 보고 가장 매력적인 5개를 골라라.', '아래 Top 5 는 코드가 멀티팩터 합성점수(Quality/Value/Momentum/Stability 백분위 랭크,',
'투자성향 가중)로 이미 확정했다 — **순서를 바꾸거나 종목을 교체하지 말 것**.',
'당신의 임무는 각 종목의 *한 줄 매력 포인트(pitch)* 서술뿐이다.',
'', '',
'**평가 기준 (우선순위 순):**', '**pitch 작성 규칙:**',
' 1. 통과 키워드 수 — 많을수록 우수 (3개 통과 < 5개 통과 < 6개 통과)', ' - 제공된 수치·점수만 인용 (새 수치·사실 창작 금지)',
' 2. ROE 절대 수치 — 15% 이상 강하게 가산', ' - 30자 이내, 그 종목이 왜 상위인지 축(Q/V/M/S) 강점 중심으로',
' 3. 영업이익률 — 20% 이상 가산 (가격 결정력 + 마진 안정)', ' - value trap 가드 표시가 있는 종목은 "저평가이나 품질 확인 필요" 뉘앙스 반영',
' 4. 유보율 — 1,000% 이상 통과, 3,000% 이상 안정성 가산',
' 5. PBR — 낮을수록 가산, 단 1.0 미만은 *value trap* 가능성 cross-check',
'', '',
'**출력 형식 (반드시 이대로):**', '**출력 형식 (반드시 이대로, 정확히 5줄 + 종합 1줄):**',
'🎯 매력도 Top 5', '1. <종목명> (<심볼>) — <한 줄 매력 포인트>',
'',
'1. <종목명> (<6자리 심볼>) — <한 줄 매력 포인트 30자 이내>',
' 근거: ROE x%, 영업이익률 y%, 유보율 z%, <통과 N키워드 인용>',
'2. ...', '2. ...',
'...', '3. ...',
'4. ...',
'5. ...', '5. ...',
'',
'종합: <1-2 문장 — 이번 발굴 batch 의 공통 특징 또는 주의점>', '종합: <1-2 문장 — 이번 발굴 batch 의 공통 특징 또는 주의점>',
'', '',
'*다른 텍스트 절대 추가 금지.* 출력 첫 줄은 정확히 "🎯 매력도 Top 5" 이어야 한다.', '*다른 텍스트 절대 추가 금지.*',
].join('\n'); ].join('\n');
function buildAnalysisPrompt(candidates: DiscoveredCandidate[]): string { function buildPitchPrompt(top: DiscoveredCandidate[]): string {
const lines: string[] = [ const lines: string[] = [
`발굴 후보 ${candidates.length}. 매력도 Top 5 골라라.`, `코드가 확정한 Top ${top.length}. 각 종목의 pitch 를 서술하라 (순서 변경 금지).`,
'', '',
]; ];
for (const c of candidates) { for (let i = 0; i < top.length; i++) {
const c = top[i];
const f = c.fundamentals; const f = c.fundamentals;
const sc = c.score;
lines.push( lines.push(
`· ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억]`, `${i + 1}. ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()} · ${c.asStock. ?? '-'}]`,
` 종합 ${sc.total} (${sc.breakdown})${sc.valueTrapGuarded ? ' ⚠️value trap 가드 발동' : ''}${sc.fScore ? ` · miniF ${sc.fScore}` : ''}`,
` ROE ${f.roe?.toFixed(1) ?? '-'}% · 영업이익률 ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보율 ${f.retentionRatio?.toLocaleString() ?? '-'}% · 부채비율 ${f.debtRatio?.toFixed(1) ?? '-'}% · PER ${f.per?.toFixed(1) ?? '-'} · PBR ${f.pbr?.toFixed(1) ?? '-'}`, ` ROE ${f.roe?.toFixed(1) ?? '-'}% · 영업이익률 ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보율 ${f.retentionRatio?.toLocaleString() ?? '-'}% · 부채비율 ${f.debtRatio?.toFixed(1) ?? '-'}% · PER ${f.per?.toFixed(1) ?? '-'} · PBR ${f.pbr?.toFixed(1) ?? '-'}`,
` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}`, ` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}`,
'', '',
@@ -79,37 +82,37 @@ function buildAnalysisPrompt(candidates: DiscoveredCandidate[]): string {
return lines.join('\n'); return lines.join('\n');
} }
/** /** LLM 실패/누락 시 pitch 결정론 폴백 — 점수 분해 + 최강 축 언급. */
* LLM 응답을 5개 항목으로 파싱. 작은 모델이 형식 흩뜨려도 잡힐 수 있게 관대하게: function fallbackPitch(c: DiscoveredCandidate): string {
* - "1. <이름> (<6자리>) — <문구>" / "1) <이름> ..." / "1: ..." 모두 받음 const sc = c.score;
* - 종목 매핑은 6자리 심볼 정규식으로 cross-check const axes: [string, number | undefined][] = [
*/ ['퀄리티', sc.quality], ['밸류', sc.value], ['모멘텀', sc.momentum], ['안정성', sc.stability],
function parseTopFive(raw: string, candidates: DiscoveredCandidate[]): TopFiveResult { ];
const items: TopFiveItem[] = []; const best = axes.filter((a): a is [string, number] => a[1] !== undefined)
const lines = raw.split('\n'); .sort((a, b) => b[1] - a[1])[0];
const symbolMap = new Map(candidates.map(c => [c.symbol, c])); const guard = sc.valueTrapGuarded ? ' (품질 확인 필요)' : '';
return best
? `종합 ${sc.total}${best[0]} 강점 (${sc.breakdown})${guard}`
: `종합 ${sc.total} (${sc.breakdown})${guard}`;
}
const itemRe = /^\s*([1-5])[\.\)\:]\s+(.+?)\s*[\(\[](\d{6})[\)\]]\s*[—\-:]\s*(.+)$/; /**
for (const line of lines) { * LLM 응답에서 순번별 pitch 만 추출 (관대한 번호 매칭). 순위·종목은 코드가 확정했으므로
* LLM 텍스트의 종목명/심볼은 신뢰하지 않는다 — rank 번호로만 pitch 를 매핑.
* - "1. <이름> (<심볼>) — <문구>" / "1) ..." / "1: ..." 모두 받음
*/
export function parsePitches(raw: string, count: number): Map<number, string> {
const pitches = new Map<number, string>();
const itemRe = /^\s*([1-9])[\.\)\:]\s+.*?[—\-:]\s*(.+)$/;
for (const line of raw.split('\n')) {
const m = line.match(itemRe); const m = line.match(itemRe);
if (!m) continue; if (!m) continue;
const rank = parseInt(m[1], 10); const rank = parseInt(m[1], 10);
if (items.find(i => i.rank === rank)) continue; if (rank < 1 || rank > count || pitches.has(rank)) continue;
items.push({ const pitch = m[2].trim();
rank, if (pitch) pitches.set(rank, pitch);
name: m[2].trim(),
symbol: m[3],
pitch: m[4].trim(),
candidate: symbolMap.get(m[3]),
});
} }
items.sort((a, b) => a.rank - b.rank); return pitches;
// 종합 코멘트 추출 — "종합:" 또는 "총평:" 라인.
const summaryMatch = raw.match(/(?:|)\s*[:]\s*(.+?)(?:\n\n|$)/s);
const summary = summaryMatch ? summaryMatch[1].replace(/\s+/g, ' ').trim() : undefined;
return { items, summary, raw };
} }
export async function analyzeTopCandidates( export async function analyzeTopCandidates(
@@ -119,28 +122,43 @@ export async function analyzeTopCandidates(
if (candidates.length === 0) { if (candidates.length === 0) {
return { items: [], raw: '' }; return { items: [], raw: '' };
} }
onProgress?.('🤖 LLM 분석 시작 — 후보 ' + candidates.length + '개 평가 중...'); // (1) Top 5 확정 — 코드. candidates 는 이미 합성점수 내림차순.
const top = candidates.slice(0, 5);
let raw = '';
let summary: string | undefined;
const pitches = new Map<number, string>();
// (2) LLM 은 pitch 서술만 — 실패해도 Top 5 는 그대로 (결정론 폴백 pitch).
onProgress?.('🤖 LLM pitch 서술 중 — Top ' + top.length + ' (순위는 합성점수로 확정됨)...');
const ai = new AIService(); const ai = new AIService();
try { try {
const result = await ai.chat({ const result = await ai.chat({
system: SYSTEM_PROMPT, system: SYSTEM_PROMPT,
user: buildAnalysisPrompt(candidates), user: buildPitchPrompt(top),
timeoutMs: 90_000, timeoutMs: 90_000,
}); });
if (result.empty || !result.content.trim()) { if (!result.empty && result.content.trim()) {
logError('Discovery analyzer: LLM 빈 응답.'); raw = result.content;
return { items: [], raw: '' }; for (const [rank, p] of parsePitches(raw, top.length)) pitches.set(rank, p);
const summaryMatch = raw.match(/(?:|)\s*[:]\s*(.+?)(?:\n\n|$)/s);
summary = summaryMatch ? summaryMatch[1].replace(/\s+/g, ' ').trim() : undefined;
} }
const parsed = parseTopFive(result.content, candidates);
logInfo('Discovery analyzer: parsed Top 5.', {
parsedCount: parsed.items.length,
model: result.model,
});
return parsed;
} catch (e: any) { } catch (e: any) {
logError('Discovery analyzer: LLM 호출 실패.', { error: e?.message ?? String(e) }); logError('Discovery analyzer: LLM pitch 호출 실패 — 결정론 폴백 사용.', { error: e?.message ?? String(e) });
return { items: [], raw: '' };
} }
// (3) 항목 조립 — 순위·종목 = 코드, pitch = LLM 또는 폴백.
const items: TopFiveItem[] = top.map((c, i) => ({
rank: i + 1,
name: c.name,
symbol: c.symbol,
pitch: pitches.get(i + 1) || fallbackPitch(c),
candidate: c,
}));
logInfo('Discovery analyzer: Top 5 확정 (합성점수 순).', {
llmPitches: pitches.size, fallbacks: items.length - pitches.size,
});
return { items, summary, raw };
} }
/** 채팅 webview 용 — markdown 친화적 멀티라인. */ /** 채팅 webview 용 — markdown 친화적 멀티라인. */
@@ -150,11 +168,13 @@ export function renderTopFiveForChat(result: TopFiveResult): string {
? `\n🤖 **LLM 분석 결과** (형식 파싱 실패 — 원문 표시)\n\n${result.raw}\n` ? `\n🤖 **LLM 분석 결과** (형식 파싱 실패 — 원문 표시)\n\n${result.raw}\n`
: '\n⚠️ LLM 분석 실패 (빈 응답 또는 timeout).\n'; : '\n⚠️ LLM 분석 실패 (빈 응답 또는 timeout).\n';
} }
const lines: string[] = ['\n🎯 **Astra 매력도 Top 5**\n']; const lines: string[] = ['\n🎯 **Astra 매력도 Top 5** (멀티팩터 합성점수 순)\n'];
for (const it of result.items) { for (const it of result.items) {
lines.push(`${it.rank}. **${it.name}** (${it.symbol}) — ${it.pitch}`); lines.push(`${it.rank}. **${it.name}** (${it.symbol}) — ${it.pitch}`);
if (it.candidate) { if (it.candidate) {
const f = it.candidate.fundamentals; const f = it.candidate.fundamentals;
const sc = it.candidate.score;
lines.push(` 점수: 종합 **${sc.total}** (${sc.breakdown})${sc.valueTrapGuarded ? ' · ⚠️ value trap 가드' : ''}${sc.fScore ? ` · miniF ${sc.fScore}` : ''}`);
lines.push(` 근거: ROE ${f.roe?.toFixed(1) ?? '-'}%, 영업이익률 ${f.operatingMargin?.toFixed(1) ?? '-'}%, 유보율 ${f.retentionRatio?.toLocaleString() ?? '-'}%, 통과 ${it.candidate.passedKeywords.length}개 (${it.candidate.passedKeywords.join(', ')})`); lines.push(` 근거: ROE ${f.roe?.toFixed(1) ?? '-'}%, 영업이익률 ${f.operatingMargin?.toFixed(1) ?? '-'}%, 유보율 ${f.retentionRatio?.toLocaleString() ?? '-'}%, 통과 ${it.candidate.passedKeywords.length}개 (${it.candidate.passedKeywords.join(', ')})`);
} }
} }
@@ -202,6 +222,8 @@ export function renderTopFiveForTelegram(
lines.push(` ${it.pitch}`); lines.push(` ${it.pitch}`);
if (it.candidate) { if (it.candidate) {
const f = it.candidate.fundamentals; const f = it.candidate.fundamentals;
const sc = it.candidate.score;
lines.push(` 종합 ${sc.total} (${sc.breakdown})${sc.valueTrapGuarded ? ' ⚠️VT가드' : ''}`);
lines.push(` ROE ${f.roe?.toFixed(1) ?? '-'}% · OM ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보 ${f.retentionRatio?.toLocaleString() ?? '-'}%`); lines.push(` ROE ${f.roe?.toFixed(1) ?? '-'}% · OM ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보 ${f.retentionRatio?.toLocaleString() ?? '-'}%`);
} }
lines.push(''); lines.push('');
+4 -3
View File
@@ -573,14 +573,15 @@ async function cmdDiscover(rest: string, view: Webview | undefined, context?: vs
return; return;
} }
chunk(view, `\n📋 **발굴 후보 ${candidates.length}개** (통과 키워드 수 내림차순)\n\n`); chunk(view, `\n📋 **발굴 후보 ${candidates.length}개** (멀티팩터 합성점수 내림차순 — Q퀄리티/V밸류/M모멘텀/S안정성)\n\n`);
for (const c of candidates) { for (const c of candidates) {
const price = c.fundamentals.currentPrice; const price = c.fundamentals.currentPrice;
const priceStr = typeof price === 'number' ? price.toLocaleString() + '원' : '-'; const priceStr = typeof price === 'number' ? price.toLocaleString() + '원' : '-';
const sectorStr = c.fundamentals.sectorHint ? ` · ${c.fundamentals.sectorHint}` : ''; const sectorStr = c.fundamentals.sectorHint ? ` · ${c.fundamentals.sectorHint}` : '';
const weakMark = sectorGroup && c.passedKeywords.length < 3 ? ' ⚠️섹터내상대' : ''; const weakMark = sectorGroup && c.passedKeywords.length < 3 ? ' ⚠️섹터내상대' : '';
chunk(view, ` · ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}${sectorStr}]${weakMark}\n`); const vtMark = c.score.valueTrapGuarded ? ' · ⚠️VT가드' : '';
chunk(view, ` 현재가 ${priceStr} · ROE ${c.fundamentals.roe?.toFixed(1) ?? '-'}% · 영업이익률 ${c.fundamentals.operatingMargin?.toFixed(1) ?? '-'}% · 유보율 ${c.fundamentals.retentionRatio?.toLocaleString() ?? '-'}%\n`); chunk(view, ` · **${c.score.total}점** ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}${sectorStr}]${weakMark}\n`);
chunk(view, ` ${c.score.breakdown}${c.score.fScore ? ` · miniF ${c.score.fScore}` : ''}${vtMark} · 현재가 ${priceStr} · ROE ${c.fundamentals.roe?.toFixed(1) ?? '-'}% · 영업이익률 ${c.fundamentals.operatingMargin?.toFixed(1) ?? '-'}% · 유보율 ${c.fundamentals.retentionRatio?.toLocaleString() ?? '-'}%\n`);
chunk(view, ` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}\n\n`); chunk(view, ` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}\n\n`);
} }
+41 -10
View File
@@ -1,7 +1,8 @@
import { logInfo } from '../../utils'; import { logInfo } from '../../utils';
import { screenMarket, screenIndustry, type Market, type ScreenerEntry } from './naverScreener'; import { screenMarket, screenIndustry, type Market, type ScreenerEntry } from './naverScreener';
import { fetchAllFundamentals, type Fundamentals } from './naverFundamentals'; import { fetchAllFundamentals, type Fundamentals } from './naverFundamentals';
import { fetchAllHistories, evalMa224Recovery, evalDropRecovery } from './yahooClient'; import { fetchAllHistories, evalMa224Recovery, evalDropRecovery, evalMaAlignment, evalRsi14 } from './yahooClient';
import { scoreCandidates, type CompositeScore, type MomentumInputs, type ScoreInputs } from './compositeScore';
import { type SectorGroup } from './stockSectors'; import { type SectorGroup } from './stockSectors';
import type { Stock } from './types'; import type { Stock } from './types';
@@ -48,6 +49,8 @@ export interface DiscoveredCandidate {
/** Stock 으로 변환된 형태 — stocks.json 에 그대로 추가 가능. */ /** Stock 으로 변환된 형태 — stocks.json 에 그대로 추가 가능. */
asStock: Stock; asStock: Stock;
fundamentals: Fundamentals; fundamentals: Fundamentals;
/** 멀티팩터 합성 점수 (v2.2.266) — 추천 순위의 기준. */
score: CompositeScore;
} }
/** 8 키워드 각각의 통과 여부 판정 — llmJudge SYSTEM_PROMPT 의 임계값과 동일. */ /** 8 키워드 각각의 통과 여부 판정 — llmJudge SYSTEM_PROMPT 의 임계값과 동일. */
@@ -151,7 +154,7 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise<Discov
// 3키워드 하드게이트를 완화한다(≥1 통과면 후보). 3키워드 미만이면 점수순 상위만 // 3키워드 하드게이트를 완화한다(≥1 통과면 후보). 3키워드 미만이면 점수순 상위만
// 안내 문구와 함께. 일반 모드는 기존대로 ≥3 (정밀도 우선). // 안내 문구와 함께. 일반 모드는 기존대로 ≥3 (정밀도 우선).
const minKeywords = opts.sector ? 1 : 3; const minKeywords = opts.sector ? 1 : 3;
const prelim: { entry: ScreenerEntry; f: Fundamentals; passed: string[] }[] = []; const prelim: { entry: ScreenerEntry; f: Fundamentals; passed: string[]; momentum?: MomentumInputs }[] = [];
for (const entry of allEntries) { for (const entry of allEntries) {
const f = fundsMap.get(entry.symbol); const f = fundsMap.get(entry.symbol);
if (!f) continue; if (!f) continue;
@@ -184,11 +187,39 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise<Discov
if (r?.passed) p.passed.push('224회복'); if (r?.passed) p.passed.push('224회복');
const dr = evalDropRecovery(h); const dr = evalDropRecovery(h);
if (dr?.passed) p.passed.push('낙폭과대'); if (dr?.passed) p.passed.push('낙폭과대');
// 합성 점수용 모멘텀 입력 수집 — 같은 시세로 정배열·RSI·12개월 수익률까지.
const align = evalMaAlignment(h);
const rsi = evalRsi14(h);
const closes = h.closes;
const first = closes.length >= 2 ? closes[0] : undefined;
const last = closes.length >= 2 ? closes[closes.length - 1] : undefined;
p.momentum = {
return12m: first && last && first > 0 ? last / first - 1 : undefined,
ma224: r ? (r.notApplicable ? 'na' : r.passed ? 'passed' : 'fail') : undefined,
dropRecovery: dr ? dr.passed : undefined,
alignment: align?.alignment,
rsi: rsi?.classification,
};
} }
} }
// (6) DiscoveredCandidate 변환. // (6) 멀티팩터 합성 점수 (v2.2.266) — 백분위 랭크가 후보군 내 상대평가이므로 배치 채점.
const candidates: DiscoveredCandidate[] = prelim.map(({ entry, f, passed }) => { // 투자성향은 fundamentalsToStock 과 동일한 시총 기준 자동 분류를 가중치에 사용.
const styleOf = (capEok: number): Stock['투자성향'] =>
capEok < 3000 ? '저평가우량주' : capEok < 10000 ? '스윙/중기' : '장기투자';
const scoreInputs: ScoreInputs[] = prelim.map(({ entry, f, momentum }) => ({
symbol: entry.symbol,
style: styleOf(entry.marketCapEok),
roe: f.roe, operatingMargin: f.operatingMargin,
revenueGrowthYoY: f.revenueGrowthYoY, opProfitGrowthYoY: f.opProfitGrowthYoY,
debtRatio: f.debtRatio, per: f.per, pbr: f.pbr,
retentionRatio: f.retentionRatio, marketCapEok: f.marketCapEok ?? entry.marketCapEok,
momentum,
}));
const scores = scoreCandidates(scoreInputs);
// (7) DiscoveredCandidate 변환 + 점수 부착.
const candidates: DiscoveredCandidate[] = prelim.map(({ entry, f, passed }, i) => {
const top3 = passed.slice(0, 3); const top3 = passed.slice(0, 3);
const filterText = `[발굴 자동] 충족 (${top3.join(', ')})`; const filterText = `[발굴 자동] 충족 (${top3.join(', ')})`;
return { return {
@@ -199,21 +230,21 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise<Discov
passedKeywords: passed, passedKeywords: passed,
asStock: fundamentalsToStock(entry, f, filterText), asStock: fundamentalsToStock(entry, f, filterText),
fundamentals: f, fundamentals: f,
score: scores[i],
}; };
}); });
// (7) sortScore — 통과 키워드 수 desc, 동점 시 PBR asc(저평가가 위로) 타이브레이커. // (8) 순위 = 합성 점수 desc (기존 "키워드 수" 이진 카운트 대체 — 상관 지표 중복
// 카운트·value trap 문제 해결). 동점 시 PBR asc(저평가가 위로) 타이브레이커 유지.
candidates.sort((a, b) => { candidates.sort((a, b) => {
if (b.passedKeywords.length !== a.passedKeywords.length) { if (b.score.total !== a.score.total) return b.score.total - a.score.total;
return b.passedKeywords.length - a.passedKeywords.length;
}
const pbrA = a.fundamentals.pbr ?? Number.MAX_SAFE_INTEGER; const pbrA = a.fundamentals.pbr ?? Number.MAX_SAFE_INTEGER;
const pbrB = b.fundamentals.pbr ?? Number.MAX_SAFE_INTEGER; const pbrB = b.fundamentals.pbr ?? Number.MAX_SAFE_INTEGER;
return pbrA - pbrB; return pbrA - pbrB;
}); });
const limited = candidates.slice(0, limit); const limited = candidates.slice(0, limit);
logInfo(`Stocks discovery: ${allEntries.length} 1차 후보 → ${candidates.length} 매수후보 → ${limited.length} 표시.`); logInfo(`Stocks discovery: ${allEntries.length} 1차 후보 → ${candidates.length} 매수후보 → ${limited.length} 표시 (합성점수 순).`);
progress(`\n🎯 ${limited.length}개 후보 발굴 완료 (전체 1차후보 ${allEntries.length}개 중 ${candidates.length}가 3 키워드 이상 통과).`); progress(`\n🎯 ${limited.length}개 후보 발굴 완료 (전체 1차후보 ${allEntries.length}개 중 ${candidates.length}개 통과 · 멀티팩터 합성점수 순).`);
return limited; return limited;
} }
+131
View File
@@ -0,0 +1,131 @@
/**
* 멀티팩터 합성점수 (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>): 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+|-)$/);
});
});