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:
@@ -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} 표시.`);
|
||||
|
||||
@@ -58,3 +58,186 @@ export async function fetchAllPrices(
|
||||
logInfo('Yahoo 일괄 갱신 완료.', { total: symbols.length, success: [...out.values()].filter(p => p !== null).length });
|
||||
return out;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1년 일봉 시세 — 224일 이동평균 + 회복 패턴 판정에 사용.
|
||||
// 영상(주식단테 "224일선 안착 = 추세 전환") 신호를 펀더멘털 발굴에 보조 기준으로 얹기 위한 입력.
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface YahooHistory {
|
||||
/** 종가 배열 (오래된→최신 순). null 종가는 제거됨. */
|
||||
closes: number[];
|
||||
/** 거래량 배열 (closes와 같은 인덱스 정렬, null은 0으로 채움). */
|
||||
volumes: number[];
|
||||
}
|
||||
|
||||
export async function fetchYahooHistory(symbol: string, timeoutMs = 10000): Promise<YahooHistory | null> {
|
||||
if (!symbol) return null;
|
||||
const candidates: string[] = symbol.includes('.')
|
||||
? [symbol]
|
||||
: [`${symbol}.KQ`, `${symbol}.KS`];
|
||||
|
||||
for (const yahooSymbol of candidates) {
|
||||
try {
|
||||
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${yahooSymbol}?range=1y&interval=1d`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const data: any = await res.json();
|
||||
const q = data?.chart?.result?.[0]?.indicators?.quote?.[0];
|
||||
const closesRaw: any[] = q?.close ?? [];
|
||||
const volumesRaw: any[] = q?.volume ?? [];
|
||||
// closes·volumes를 같은 인덱스로 정렬해 유지 — null close는 양쪽 모두 제거,
|
||||
// null volume은 0으로 보전(낙폭과대/거래량 평균 계산이 흔들리지 않게).
|
||||
const closes: number[] = [];
|
||||
const volumes: number[] = [];
|
||||
for (let i = 0; i < closesRaw.length; i++) {
|
||||
const c = closesRaw[i];
|
||||
if (typeof c !== 'number' || !Number.isFinite(c)) continue;
|
||||
closes.push(c);
|
||||
const v = volumesRaw[i];
|
||||
volumes.push(typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
||||
}
|
||||
if (closes.length === 0) continue;
|
||||
return { closes, volumes };
|
||||
} catch (e: any) {
|
||||
if (yahooSymbol === candidates[candidates.length - 1]) {
|
||||
logError('Yahoo Finance 1년 시세 조회 실패.', { symbol, yahooSymbol, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchAllHistories(
|
||||
symbols: string[],
|
||||
onProgress?: (symbol: string, ok: boolean, i: number, total: number) => void,
|
||||
): Promise<Map<string, YahooHistory | null>> {
|
||||
const out = new Map<string, YahooHistory | null>();
|
||||
for (let i = 0; i < symbols.length; i++) {
|
||||
const symbol = symbols[i];
|
||||
const h = await fetchYahooHistory(symbol);
|
||||
out.set(symbol, h);
|
||||
onProgress?.(symbol, h !== null, i + 1, symbols.length);
|
||||
await new Promise(r => setTimeout(r, 1000)); // Yahoo rate limit 보호
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** window 길이 단순 이동평균 (오래된→최신 순 입력, 출력 길이 = arr.length - window + 1). */
|
||||
function rollingMean(arr: number[], window: number): number[] {
|
||||
const out: number[] = [];
|
||||
if (arr.length < window) return out;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < window; i++) sum += arr[i];
|
||||
out.push(sum / window);
|
||||
for (let i = window; i < arr.length; i++) {
|
||||
sum += arr[i] - arr[i - window];
|
||||
out.push(sum / window);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface Ma224RecoveryResult {
|
||||
/** 회복 패턴 + 거래량 확인을 모두 통과했는지. */
|
||||
passed: boolean;
|
||||
/** 오늘 시점 MA224 값. */
|
||||
ma224Today?: number;
|
||||
/** 최근 30거래일 중 종가가 그 시점 MA224 아래였던 일수. */
|
||||
daysBelowLast30?: number;
|
||||
/** 현재 종가. */
|
||||
currentPrice?: number;
|
||||
/** 거래량 확인 통과 여부 (최근 5일 평균 ≥ 60일 평균 × 1.2). 거래량 데이터 부족 시 true. */
|
||||
volumeConfirmed?: boolean;
|
||||
/** 최근 5일 평균 거래량. */
|
||||
vol5dAvg?: number;
|
||||
/** 최근 60일 평균 거래량. */
|
||||
vol60dAvg?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 224일선(약 1년 이평) 회복 패턴 판정.
|
||||
*
|
||||
* 영상의 "주가가 224일선 아래 머물다 → 강한 거래량으로 돌파 → 안착 = 추세 전환" 신호를
|
||||
* 정량화한 단순 버전:
|
||||
* - 현재 종가 ≥ 오늘 시점 MA224 (현재 위에 있음)
|
||||
* - 최근 30거래일 중 5일 이상 그 시점 MA224 아래에 머문 적 있음 (직전까지 저평가 영역)
|
||||
*
|
||||
* 데이터가 부족(시세 224일 미만)하면 null — 호출자가 신호 미적용.
|
||||
*/
|
||||
export function evalMa224Recovery(history: YahooHistory): Ma224RecoveryResult | null {
|
||||
const closes = history.closes;
|
||||
if (closes.length < 224) return null;
|
||||
const ma = rollingMean(closes, 224);
|
||||
const ma224Today = ma[ma.length - 1];
|
||||
const currentPrice = closes[closes.length - 1];
|
||||
if (!Number.isFinite(ma224Today) || !Number.isFinite(currentPrice)) return null;
|
||||
if (currentPrice < ma224Today) {
|
||||
return { passed: false, ma224Today, daysBelowLast30: 0, currentPrice };
|
||||
}
|
||||
const lookback = Math.min(30, ma.length);
|
||||
let daysBelow = 0;
|
||||
for (let i = ma.length - lookback; i < ma.length; i++) {
|
||||
const priceAtI = closes[i + 224 - 1]; // ma[i] = mean of closes[i..i+223]
|
||||
if (priceAtI < ma[i]) daysBelow++;
|
||||
}
|
||||
const priceCondition = daysBelow >= 5;
|
||||
// 거래량 확인 — 영상(주식단테 "강한 거래량으로 돌파") 의 정량화. 거짓 돌파(일시적
|
||||
// 가격 튐) 필터. 데이터 부족(거래량 60일 미만)이면 가격 조건만으로 판정한다.
|
||||
let volumeConfirmed = true;
|
||||
let vol5dAvg: number | undefined;
|
||||
let vol60dAvg: number | undefined;
|
||||
if (history.volumes && history.volumes.length >= 60) {
|
||||
const v5 = history.volumes.slice(-5);
|
||||
const v60 = history.volumes.slice(-60);
|
||||
vol5dAvg = v5.reduce((a, b) => a + b, 0) / 5;
|
||||
vol60dAvg = v60.reduce((a, b) => a + b, 0) / 60;
|
||||
volumeConfirmed = vol60dAvg > 0 && vol5dAvg >= vol60dAvg * 1.2;
|
||||
}
|
||||
return {
|
||||
passed: priceCondition && volumeConfirmed,
|
||||
ma224Today, daysBelowLast30: daysBelow, currentPrice,
|
||||
volumeConfirmed, vol5dAvg, vol60dAvg,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DropRecoveryResult {
|
||||
/** 낙폭과대 + 반등 초입 패턴 통과 여부. */
|
||||
passed: boolean;
|
||||
/** 1년 최고가 (history 전 구간 max). */
|
||||
high1y?: number;
|
||||
/** 60거래일 최저가. */
|
||||
low60d?: number;
|
||||
/** 현재 종가. */
|
||||
currentPrice?: number;
|
||||
/** 1년 최고가 대비 현재가 비율 (0~1+, 낮을수록 많이 빠짐). */
|
||||
fromHighRatio?: number;
|
||||
/** 60일 저점 대비 현재가 비율 (1+, 높을수록 많이 반등). */
|
||||
fromLowRatio?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* "낙폭과대 + 반등 초입" 패턴 판정. 영상(주식단테 "이미 빠진 종목 + 바닥 찍고 회복")의
|
||||
* 정량화. 224회복(추세 전환)과는 다른 각도 — 안전마진 + 회복 초입.
|
||||
*
|
||||
* - 현재가 ≤ 1년 최고가 × 0.75 (= 25% 이상 하락 — 안전마진)
|
||||
* - AND 현재가 ≥ 60일 최저가 × 1.10 (= 최근 저점에서 10% 이상 반등 — 회복 초입)
|
||||
*
|
||||
* 데이터 부족(시세 60일 미만)이면 null — 호출자가 신호 미적용.
|
||||
*/
|
||||
export function evalDropRecovery(history: YahooHistory): DropRecoveryResult | null {
|
||||
const closes = history.closes;
|
||||
if (closes.length < 60) return null;
|
||||
const currentPrice = closes[closes.length - 1];
|
||||
const high1y = Math.max(...closes);
|
||||
const low60d = Math.min(...closes.slice(-60));
|
||||
if (!Number.isFinite(high1y) || !Number.isFinite(low60d) || high1y <= 0 || low60d <= 0) return null;
|
||||
const fromHighRatio = currentPrice / high1y;
|
||||
const fromLowRatio = currentPrice / low60d;
|
||||
return {
|
||||
passed: fromHighRatio <= 0.75 && fromLowRatio >= 1.10,
|
||||
high1y, low60d, currentPrice, fromHighRatio, fromLowRatio,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user