feat(stocks): v2.2.160-161 — 저평가 강조 + 224일선 회복 + 낙폭과대 키워드

영상(주식단테 시리즈) 기준을 /stocks discover에 정량 매핑:

v2.2.160:
- 저평가 키워드 2단계 추가 (PBR ≤ 1.0 = 저평가, ≤ 0.7 = 초저평가)
- 정렬 타이브레이커: 통과 키워드 수 desc → PBR asc
- 224회복 보너스 (가격 only): MA224 돌파 + 최근 30일 중 5일+ 아래에 머문 적
- yahooClient: fetchYahooHistory + evalMa224Recovery 신설

v2.2.161:
- 224회복 거래량 검증 추가 (최근 5일 평균 ≥ 60일 평균 × 1.2) — 거짓 돌파 필터
- 신규 낙폭과대 키워드: 1년 고점 대비 -25% AND 60일 저점에서 +10%
- yahooClient: YahooHistory에 volumes, evalDropRecovery 신설

chronicle: ADR-0025 추가.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 10:10:09 +09:00
parent 323537e12b
commit d206293a19
18 changed files with 1279 additions and 993 deletions
+45 -8
View File
@@ -1,6 +1,7 @@
import { logInfo } from '../../utils';
import { screenMarket, type Market, type ScreenerEntry } from './naverScreener';
import { fetchAllFundamentals, type Fundamentals } from './naverFundamentals';
import { fetchAllHistories, evalMa224Recovery, evalDropRecovery } from './yahooClient';
import type { Stock } from './types';
/**
@@ -68,6 +69,10 @@ function evaluateKeywords(f: Fundamentals): string[] {
if (techKeywords.some(k => sector.includes(k)) && pbr >= 2) passed.push('기술력');
if (retention >= 3000 && mktCap >= 5000) passed.push('안정성');
if (pbr > 0 && pbr <= 1.5) passed.push('PBR');
// 저평가 강조 — PBR이 낮을수록 추가 가산. 사용자 요청("저평가된 종목을 기준으로")에 맞춰
// 1.0 / 0.7 두 단계로 elevate. PBR=0.5짜리는 PBR+저평가+초저평가 = 3 키워드 자동 통과.
if (pbr > 0 && pbr <= 1.0) passed.push('저평가');
if (pbr > 0 && pbr <= 0.7) passed.push('초저평가');
return passed;
}
@@ -128,17 +133,42 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise<Discov
if (i % 10 === 0 || i === total) progress(` 펀더멘털 ${i}/${total} 처리 중...`);
});
// (4) 8 키워드 평가.
const candidates: DiscoveredCandidate[] = [];
// (4) 펀더멘털 키워드 평가 — 1차 통과 후보 추림.
const prelim: { entry: ScreenerEntry; f: Fundamentals; passed: string[] }[] = [];
for (const entry of allEntries) {
const f = fundsMap.get(entry.symbol);
if (!f) continue;
const passed = evaluateKeywords(f);
if (passed.length < 3) continue;
// 상위 3개만 골라서 텍스트화 (judge 와 동일 패턴).
prelim.push({ entry, f, passed });
}
// (5) 1년 시세 → 224일선 회복 패턴 보너스 키워드.
// 영상의 "주가가 224일선 아래 머물다 돌파 = 추세 전환" 신호를 펀더멘털 통과 후보에만
// 얹는다(전체 1차후보에 안 돌리는 이유: Yahoo 1초/심볼 throttle 비용 절감).
if (prelim.length > 0) {
progress(`\n📈 ${prelim.length}개 후보의 1년 시세로 224일선 회복 패턴 확인 중 (Yahoo 1초/종목, ~${prelim.length}초)...`);
const histMap = await fetchAllHistories(
prelim.map(p => p.entry.symbol),
(_sym, _ok, i, total) => {
if (i % 10 === 0 || i === total) progress(` 시세 ${i}/${total} 처리 중...`);
},
);
for (const p of prelim) {
const h = histMap.get(p.entry.symbol);
if (!h) continue;
const r = evalMa224Recovery(h);
if (r?.passed) p.passed.push('224회복');
const dr = evalDropRecovery(h);
if (dr?.passed) p.passed.push('낙폭과대');
}
}
// (6) DiscoveredCandidate 변환.
const candidates: DiscoveredCandidate[] = prelim.map(({ entry, f, passed }) => {
const top3 = passed.slice(0, 3);
const filterText = `[발굴 자동] 충족 (${top3.join(', ')})`;
candidates.push({
return {
symbol: entry.symbol,
name: entry.name,
market: entry.market,
@@ -146,11 +176,18 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise<Discov
passedKeywords: passed,
asStock: fundamentalsToStock(entry, f, filterText),
fundamentals: f,
});
}
};
});
// sortScore — 통과 키워드 수 내림차순.
candidates.sort((a, b) => b.passedKeywords.length - a.passedKeywords.length);
// (7) sortScore — 통과 키워드 수 desc, 동점 시 PBR asc(저평가가 위로) 타이브레이커.
candidates.sort((a, b) => {
if (b.passedKeywords.length !== a.passedKeywords.length) {
return b.passedKeywords.length - a.passedKeywords.length;
}
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} 표시.`);