diff --git a/package.json b/package.json index aa25e0f..9dc41e4 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.265", + "version": "2.2.266", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/features/settings/settingsPanelProvider.ts b/src/features/settings/settingsPanelProvider.ts index e15bd5d..744431d 100644 --- a/src/features/settings/settingsPanelProvider.ts +++ b/src/features/settings/settingsPanelProvider.ts @@ -730,5 +730,3 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider { .replace('__VERSION__', version); } } - -export const SETTINGS_TELEGRAM_TOKEN_SECRET_KEY = TELEGRAM_TOKEN_SECRET_KEY; diff --git a/src/features/stocks/compositeScore.ts b/src/features/stocks/compositeScore.ts new file mode 100644 index 0000000..5590c3b --- /dev/null +++ b/src/features/stocks/compositeScore.ts @@ -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 = { + '스윙/중기': { 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(); + 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)}`, + }; + }); +} diff --git a/src/features/stocks/discoveryAnalyzer.ts b/src/features/stocks/discoveryAnalyzer.ts index 34a0da4..fe4fa53 100644 --- a/src/features/stocks/discoveryAnalyzer.ts +++ b/src/features/stocks/discoveryAnalyzer.ts @@ -6,17 +6,20 @@ import { TelegramHttpClient } from '../../integrations/telegram/telegramClient'; 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): - * 1) buildAnalysisPrompt(candidates) — 20개 후보 데이터를 LLM 입력 형식으로 직렬화 - * 2) AIService.chat(...) — Astra 의 기본 모델로 평가 - * 3) parseTopFive(output) — LLM 응답을 구조화된 5개 항목으로 파싱 (관대) - * 4) renderForChat / renderForTelegram — 사용자에게 보일 메시지 두 가지 형식 - * 5) sendToTelegram(...) — 텔레그램 chatId 로 발송 (실패 silent) - * - * 작은 모델 (gemma 4B 등) 이 형식을 흩뜨려도 깨지지 않게 — parseTopFive 는 *느슨한* - * 번호 매칭으로 5개 라인만 추출, 실패 시 raw text 그대로 fallback. + * 1) 상위 5개 확정 (코드) — candidates.slice(0, 5) + * 2) buildPitchPrompt → AIService.chat — 5개 종목의 pitch 서술 요청 + * 3) parsePitches (관대한 번호 매칭) — 실패 항목은 결정론 폴백 pitch + * 4) renderForChat / renderForTelegram — 점수 분해(Q/V/M/S) 포함 표시 + * 5) sendToTelegram(...) — 텔레그램 발송 (실패 silent) */ export interface TopFiveItem { @@ -38,39 +41,39 @@ export interface TopFiveResult { } const SYSTEM_PROMPT = [ - '당신은 한국 주식 발굴 결과를 검토하는 가치 투자 분석가다.', - '제공된 후보 종목들의 *재무 지표* 와 *통과 키워드* 를 보고 가장 매력적인 5개를 골라라.', + '당신은 한국·미국 주식 발굴 결과를 서술하는 애널리스트 보조 도구다.', + '아래 Top 5 는 코드가 멀티팩터 합성점수(Quality/Value/Momentum/Stability 백분위 랭크,', + '투자성향 가중)로 이미 확정했다 — **순서를 바꾸거나 종목을 교체하지 말 것**.', + '당신의 임무는 각 종목의 *한 줄 매력 포인트(pitch)* 서술뿐이다.', '', - '**평가 기준 (우선순위 순):**', - ' 1. 통과 키워드 수 — 많을수록 우수 (3개 통과 < 5개 통과 < 6개 통과)', - ' 2. ROE 절대 수치 — 15% 이상 강하게 가산', - ' 3. 영업이익률 — 20% 이상 가산 (가격 결정력 + 마진 안정)', - ' 4. 유보율 — 1,000% 이상 통과, 3,000% 이상 안정성 가산', - ' 5. PBR — 낮을수록 가산, 단 1.0 미만은 *value trap* 가능성 cross-check', + '**pitch 작성 규칙:**', + ' - 제공된 수치·점수만 인용 (새 수치·사실 창작 금지)', + ' - 30자 이내, 그 종목이 왜 상위인지 축(Q/V/M/S) 강점 중심으로', + ' - value trap 가드 표시가 있는 종목은 "저평가이나 품질 확인 필요" 뉘앙스 반영', '', - '**출력 형식 (반드시 이대로):**', - '🎯 매력도 Top 5', - '', - '1. <종목명> (<6자리 심볼>) — <한 줄 매력 포인트 30자 이내>', - ' 근거: ROE x%, 영업이익률 y%, 유보율 z%, <통과 N키워드 인용>', + '**출력 형식 (반드시 이대로, 정확히 5줄 + 종합 1줄):**', + '1. <종목명> (<심볼>) — <한 줄 매력 포인트>', '2. ...', - '...', + '3. ...', + '4. ...', '5. ...', - '', '종합: <1-2 문장 — 이번 발굴 batch 의 공통 특징 또는 주의점>', '', - '*다른 텍스트 절대 추가 금지.* 출력 첫 줄은 정확히 "🎯 매력도 Top 5" 이어야 한다.', + '*다른 텍스트 절대 추가 금지.*', ].join('\n'); -function buildAnalysisPrompt(candidates: DiscoveredCandidate[]): string { +function buildPitchPrompt(top: DiscoveredCandidate[]): 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 sc = c.score; 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) ?? '-'}`, ` 통과 (${c.passedKeywords.length}): ${c.passedKeywords.join(', ')}`, '', @@ -79,37 +82,37 @@ function buildAnalysisPrompt(candidates: DiscoveredCandidate[]): string { return lines.join('\n'); } -/** - * LLM 응답을 5개 항목으로 파싱. 작은 모델이 형식 흩뜨려도 잡힐 수 있게 관대하게: - * - "1. <이름> (<6자리>) — <문구>" / "1) <이름> ..." / "1: ..." 모두 받음 - * - 종목 매핑은 6자리 심볼 정규식으로 cross-check - */ -function parseTopFive(raw: string, candidates: DiscoveredCandidate[]): TopFiveResult { - const items: TopFiveItem[] = []; - const lines = raw.split('\n'); - const symbolMap = new Map(candidates.map(c => [c.symbol, c])); +/** LLM 실패/누락 시 pitch 결정론 폴백 — 점수 분해 + 최강 축 언급. */ +function fallbackPitch(c: DiscoveredCandidate): string { + const sc = c.score; + const axes: [string, number | undefined][] = [ + ['퀄리티', sc.quality], ['밸류', sc.value], ['모멘텀', sc.momentum], ['안정성', sc.stability], + ]; + const best = axes.filter((a): a is [string, number] => a[1] !== undefined) + .sort((a, b) => b[1] - a[1])[0]; + 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 { + const pitches = new Map(); + const itemRe = /^\s*([1-9])[\.\)\:]\s+.*?[—\-:]\s*(.+)$/; + for (const line of raw.split('\n')) { const m = line.match(itemRe); if (!m) continue; const rank = parseInt(m[1], 10); - if (items.find(i => i.rank === rank)) continue; - items.push({ - rank, - name: m[2].trim(), - symbol: m[3], - pitch: m[4].trim(), - candidate: symbolMap.get(m[3]), - }); + if (rank < 1 || rank > count || pitches.has(rank)) continue; + const pitch = m[2].trim(); + if (pitch) pitches.set(rank, pitch); } - items.sort((a, b) => a.rank - b.rank); - - // 종합 코멘트 추출 — "종합:" 또는 "총평:" 라인. - const summaryMatch = raw.match(/(?:종합|총평)\s*[::]\s*(.+?)(?:\n\n|$)/s); - const summary = summaryMatch ? summaryMatch[1].replace(/\s+/g, ' ').trim() : undefined; - - return { items, summary, raw }; + return pitches; } export async function analyzeTopCandidates( @@ -119,28 +122,43 @@ export async function analyzeTopCandidates( if (candidates.length === 0) { 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(); + + // (2) LLM 은 pitch 서술만 — 실패해도 Top 5 는 그대로 (결정론 폴백 pitch). + onProgress?.('🤖 LLM pitch 서술 중 — Top ' + top.length + ' (순위는 합성점수로 확정됨)...'); const ai = new AIService(); try { const result = await ai.chat({ system: SYSTEM_PROMPT, - user: buildAnalysisPrompt(candidates), + user: buildPitchPrompt(top), timeoutMs: 90_000, }); - if (result.empty || !result.content.trim()) { - logError('Discovery analyzer: LLM 빈 응답.'); - return { items: [], raw: '' }; + if (!result.empty && result.content.trim()) { + raw = result.content; + 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) { - logError('Discovery analyzer: LLM 호출 실패.', { error: e?.message ?? String(e) }); - return { items: [], raw: '' }; + logError('Discovery analyzer: LLM pitch 호출 실패 — 결정론 폴백 사용.', { error: e?.message ?? String(e) }); } + + // (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 친화적 멀티라인. */ @@ -150,11 +168,13 @@ export function renderTopFiveForChat(result: TopFiveResult): string { ? `\n🤖 **LLM 분석 결과** (형식 파싱 실패 — 원문 표시)\n\n${result.raw}\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) { lines.push(`${it.rank}. **${it.name}** (${it.symbol}) — ${it.pitch}`); if (it.candidate) { 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(', ')})`); } } @@ -202,6 +222,8 @@ export function renderTopFiveForTelegram( lines.push(` ${it.pitch}`); if (it.candidate) { 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(''); diff --git a/src/features/stocks/slashStocks.ts b/src/features/stocks/slashStocks.ts index 534e32d..ecc9d18 100644 --- a/src/features/stocks/slashStocks.ts +++ b/src/features/stocks/slashStocks.ts @@ -573,14 +573,15 @@ async function cmdDiscover(rest: string, view: Webview | undefined, context?: vs return; } - chunk(view, `\n📋 **발굴 후보 ${candidates.length}개** (통과 키워드 수 내림차순)\n\n`); + chunk(view, `\n📋 **발굴 후보 ${candidates.length}개** (멀티팩터 합성점수 내림차순 — Q퀄리티/V밸류/M모멘텀/S안정성)\n\n`); for (const c of candidates) { const price = c.fundamentals.currentPrice; const priceStr = typeof price === 'number' ? price.toLocaleString() + '원' : '-'; const sectorStr = c.fundamentals.sectorHint ? ` · ${c.fundamentals.sectorHint}` : ''; const weakMark = sectorGroup && c.passedKeywords.length < 3 ? ' ⚠️섹터내상대' : ''; - chunk(view, ` · ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억${sectorStr}]${weakMark}\n`); - chunk(view, ` 현재가 ${priceStr} · ROE ${c.fundamentals.roe?.toFixed(1) ?? '-'}% · 영업이익률 ${c.fundamentals.operatingMargin?.toFixed(1) ?? '-'}% · 유보율 ${c.fundamentals.retentionRatio?.toLocaleString() ?? '-'}%\n`); + const vtMark = c.score.valueTrapGuarded ? ' · ⚠️VT가드' : ''; + 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`); } diff --git a/src/features/stocks/stockDiscovery.ts b/src/features/stocks/stockDiscovery.ts index f06cbee..f764039 100644 --- a/src/features/stocks/stockDiscovery.ts +++ b/src/features/stocks/stockDiscovery.ts @@ -1,7 +1,8 @@ import { logInfo } from '../../utils'; import { screenMarket, screenIndustry, type Market, type ScreenerEntry } from './naverScreener'; 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 { Stock } from './types'; @@ -48,6 +49,8 @@ export interface DiscoveredCandidate { /** Stock 으로 변환된 형태 — stocks.json 에 그대로 추가 가능. */ asStock: Stock; fundamentals: Fundamentals; + /** 멀티팩터 합성 점수 (v2.2.266) — 추천 순위의 기준. */ + score: CompositeScore; } /** 8 키워드 각각의 통과 여부 판정 — llmJudge SYSTEM_PROMPT 의 임계값과 동일. */ @@ -151,7 +154,7 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise= 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 변환. - const candidates: DiscoveredCandidate[] = prelim.map(({ entry, f, passed }) => { + // (6) 멀티팩터 합성 점수 (v2.2.266) — 백분위 랭크가 후보군 내 상대평가이므로 배치 채점. + // 투자성향은 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 filterText = `[발굴 자동] 충족 (${top3.join(', ')})`; return { @@ -199,21 +230,21 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise { - if (b.passedKeywords.length !== a.passedKeywords.length) { - return b.passedKeywords.length - a.passedKeywords.length; - } + if (b.score.total !== a.score.total) return b.score.total - a.score.total; const pbrA = a.fundamentals.pbr ?? Number.MAX_SAFE_INTEGER; const pbrB = b.fundamentals.pbr ?? Number.MAX_SAFE_INTEGER; return pbrA - pbrB; }); const limited = candidates.slice(0, limit); - logInfo(`Stocks discovery: ${allEntries.length} 1차 후보 → ${candidates.length} 매수후보 → ${limited.length} 표시.`); - progress(`\n🎯 ${limited.length}개 후보 발굴 완료 (전체 1차후보 ${allEntries.length}개 중 ${candidates.length}개가 3 키워드 이상 통과).`); + logInfo(`Stocks discovery: ${allEntries.length} 1차 후보 → ${candidates.length} 매수후보 → ${limited.length} 표시 (합성점수 순).`); + progress(`\n🎯 ${limited.length}개 후보 발굴 완료 (전체 1차후보 ${allEntries.length}개 중 ${candidates.length}개 통과 · 멀티팩터 합성점수 순).`); return limited; } diff --git a/tests/compositeScore.test.ts b/tests/compositeScore.test.ts new file mode 100644 index 0000000..f689ae0 --- /dev/null +++ b/tests/compositeScore.test.ts @@ -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 => ({ + 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+|-)$/); + }); +});