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:
@@ -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<Discov
|
||||
// 3키워드 하드게이트를 완화한다(≥1 통과면 후보). 3키워드 미만이면 점수순 상위만
|
||||
// 안내 문구와 함께. 일반 모드는 기존대로 ≥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) {
|
||||
const f = fundsMap.get(entry.symbol);
|
||||
if (!f) continue;
|
||||
@@ -184,11 +187,39 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise<Discov
|
||||
if (r?.passed) p.passed.push('224회복');
|
||||
const dr = evalDropRecovery(h);
|
||||
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 변환.
|
||||
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<Discov
|
||||
passedKeywords: passed,
|
||||
asStock: fundamentalsToStock(entry, f, filterText),
|
||||
fundamentals: f,
|
||||
score: scores[i],
|
||||
};
|
||||
});
|
||||
|
||||
// (7) sortScore — 통과 키워드 수 desc, 동점 시 PBR asc(저평가가 위로) 타이브레이커.
|
||||
// (8) 순위 = 합성 점수 desc (기존 "키워드 수" 이진 카운트 대체 — 상관 지표 중복
|
||||
// 카운트·value trap 문제 해결). 동점 시 PBR asc(저평가가 위로) 타이브레이커 유지.
|
||||
candidates.sort((a, b) => {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user