feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성
R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules) · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등) · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks) · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등) R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리) Stocks feature 신규: /stocks slash command (v2.2.152~158) · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신 · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR) · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴 · LLM Top 5 매력도 분석 + Telegram 자동 보고서 · KST 09:00/15:00 watcher 자동 모니터링 대화 연속성 (v2.2.150~157): · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor · thin follow-up 분류 → boilerplate 헤더 suppression · slash 명령 결과 chatHistory mirror (capture wrapper) · echo/parrot 금지 system prompt rule 기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { logInfo } from '../../utils';
|
||||
import { screenMarket, type Market, type ScreenerEntry } from './naverScreener';
|
||||
import { fetchAllFundamentals, type Fundamentals } from './naverFundamentals';
|
||||
import type { Stock } from './types';
|
||||
|
||||
/**
|
||||
* `/stocks discover` 파이프라인 — Naver 크롤로 후보 발굴.
|
||||
*
|
||||
* 단계:
|
||||
* 1. 시가총액 순위 페이지 크롤 (코스피 + 코스닥) — 종목코드 + 시총만 (가벼움)
|
||||
* 2. 시가총액 범위로 1차 필터 (예: 1,000억 ~ 5,000억)
|
||||
* 3. 통과한 종목 (보통 50-200개) 의 main 페이지 크롤 — 깊은 펀더멘털
|
||||
* 4. 8 키워드 (llmJudge.ts 와 동일 임계값) 적용 → 3개 이상 통과한 종목만 선별
|
||||
* 5. ScreenedCandidate[] 반환 → 호출자가 사용자 confirm 후 stocks.json 에 추가
|
||||
*
|
||||
* llmJudge 와 *같은 임계값* 사용 — discover 와 judge 가 *같은 기준* 으로 동작해야
|
||||
* 사용자의 mental model 이 일관됨. 임계값이 두 군데에 박혀 있는 건 단점이지만,
|
||||
* judge 는 LLM 프롬프트 (자연어) 고 discover 는 코드 (정수 비교) 라 형태가 달라 공유가 까다로움.
|
||||
* 향후 thresholds 를 단일 module 로 추출하는 게 좋음 (now: TODO).
|
||||
*/
|
||||
|
||||
export interface DiscoverOptions {
|
||||
/** 시가총액 하한 (억). default 1000 (1천억). */
|
||||
minMarketCapEok?: number;
|
||||
/** 시가총액 상한 (억). default 5000 (5천억). */
|
||||
maxMarketCapEok?: number;
|
||||
/** 시장 — default ['kospi', 'kosdaq']. */
|
||||
markets?: Market[];
|
||||
/** 시장당 크롤 페이지 수 — default 10 (페이지당 50개 = 500개/시장). */
|
||||
maxPagesPerMarket?: number;
|
||||
/** 최종 후보 최대 N개 — sortScore 내림차순 cut. default 20. */
|
||||
limit?: number;
|
||||
/** 진행률 콜백 (UI 가 사용). */
|
||||
onProgress?: (msg: string) => void;
|
||||
}
|
||||
|
||||
export interface DiscoveredCandidate {
|
||||
symbol: string;
|
||||
name: string;
|
||||
market: Market;
|
||||
marketCapEok: number;
|
||||
/** 통과한 키워드들 (선별 후 최대 3개). */
|
||||
passedKeywords: string[];
|
||||
/** Stock 으로 변환된 형태 — stocks.json 에 그대로 추가 가능. */
|
||||
asStock: Stock;
|
||||
fundamentals: Fundamentals;
|
||||
}
|
||||
|
||||
/** 8 키워드 각각의 통과 여부 판정 — llmJudge SYSTEM_PROMPT 의 임계값과 동일. */
|
||||
function evaluateKeywords(f: Fundamentals): string[] {
|
||||
const passed: string[] = [];
|
||||
const roe = f.roe ?? 0;
|
||||
const om = f.operatingMargin ?? 0;
|
||||
const retention = f.retentionRatio ?? 0;
|
||||
const pbr = f.pbr ?? Number.MAX_SAFE_INTEGER;
|
||||
const mktCap = f.marketCapEok ?? 0;
|
||||
const sector = (f.sectorHint || '').toLowerCase();
|
||||
|
||||
if (roe >= 10) passed.push('ROE');
|
||||
if (om >= 15) passed.push('성장성');
|
||||
if (retention >= 1000) passed.push('유동성');
|
||||
if (om >= 10) {
|
||||
// 10% 이상이면 "수익성", 20% 이상이면 "수익성 개선" 표기.
|
||||
passed.push(om >= 20 ? '수익성 개선' : '수익성');
|
||||
}
|
||||
if (om >= 15 && roe >= 8) passed.push('영업효율');
|
||||
const techKeywords = ['ai', '반도체', '배터리', '바이오', '기술', 'semicon', 'battery', 'bio'];
|
||||
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');
|
||||
|
||||
return passed;
|
||||
}
|
||||
|
||||
/** Fundamentals → Stock (stocks.json 호환 형식) 변환. */
|
||||
function fundamentalsToStock(entry: ScreenerEntry, f: Fundamentals, filterText: string): Stock {
|
||||
return {
|
||||
이름: entry.name,
|
||||
심볼: entry.symbol,
|
||||
'유보율': f.retentionRatio !== undefined ? `${Math.round(f.retentionRatio).toLocaleString()}%` : undefined,
|
||||
'ROE(25E)': f.roe !== undefined ? `${f.roe.toFixed(2)}%` : undefined,
|
||||
'영업이익률(25E)': f.operatingMargin !== undefined ? `${f.operatingMargin.toFixed(1)}%` : undefined,
|
||||
'PER(25E)': f.per !== undefined ? f.per.toFixed(1) : undefined,
|
||||
PBR: f.pbr !== undefined ? f.pbr.toFixed(1) : undefined,
|
||||
'시가총액': f.marketCapEok !== undefined ? `${Math.round(f.marketCapEok).toLocaleString()}억` : undefined,
|
||||
'3/4 필터': filterText,
|
||||
현재가: f.currentPrice,
|
||||
// 시가총액 기준 자동 분류 — 사용자가 나중에 수동으로 바꿀 수 있음.
|
||||
투자성향: entry.marketCapEok < 3000 ? '저평가우량주' :
|
||||
entry.marketCapEok < 10000 ? '스윙/중기' : '장기투자',
|
||||
};
|
||||
}
|
||||
|
||||
export async function discoverStocks(opts: DiscoverOptions = {}): Promise<DiscoveredCandidate[]> {
|
||||
const minCap = opts.minMarketCapEok ?? 1000;
|
||||
const maxCap = opts.maxMarketCapEok ?? 5000;
|
||||
const markets = opts.markets ?? ['kospi', 'kosdaq'];
|
||||
const maxPages = opts.maxPagesPerMarket ?? 10;
|
||||
const limit = opts.limit ?? 20;
|
||||
const progress = opts.onProgress ?? (() => {});
|
||||
|
||||
progress(`📡 Naver 시가총액 페이지 크롤 시작 — 시장 ${markets.join('+')}, 시총 ${minCap}-${maxCap}억`);
|
||||
|
||||
// (1)+(2) 시가총액 페이지 → 1차 필터링.
|
||||
const allEntries: ScreenerEntry[] = [];
|
||||
for (const market of markets) {
|
||||
progress(` · ${market} 스캔 중...`);
|
||||
const entries = await screenMarket({
|
||||
market,
|
||||
maxPages,
|
||||
minMarketCapEok: minCap,
|
||||
maxMarketCapEok: maxCap,
|
||||
onProgress: (page, total) => progress(` p${page} → 누적 ${total}개`),
|
||||
});
|
||||
allEntries.push(...entries);
|
||||
}
|
||||
|
||||
if (allEntries.length === 0) {
|
||||
progress('⚠️ 시가총액 범위 내 후보 0개. 범위 넓혀서 다시 시도해보세요.');
|
||||
return [];
|
||||
}
|
||||
|
||||
progress(`\n✅ 1차 후보 ${allEntries.length}개 — 펀더멘털 크롤 시작 (~${Math.ceil(allEntries.length * 0.5)}초 소요).`);
|
||||
|
||||
// (3) 개별 펀더멘털 크롤.
|
||||
const symbols = allEntries.map(e => e.symbol);
|
||||
const fundsMap = await fetchAllFundamentals(symbols, (sym, _f, i, total) => {
|
||||
if (i % 10 === 0 || i === total) progress(` 펀더멘털 ${i}/${total} 처리 중...`);
|
||||
});
|
||||
|
||||
// (4) 8 키워드 평가.
|
||||
const candidates: DiscoveredCandidate[] = [];
|
||||
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 와 동일 패턴).
|
||||
const top3 = passed.slice(0, 3);
|
||||
const filterText = `[발굴 자동] 충족 (${top3.join(', ')})`;
|
||||
candidates.push({
|
||||
symbol: entry.symbol,
|
||||
name: entry.name,
|
||||
market: entry.market,
|
||||
marketCapEok: entry.marketCapEok,
|
||||
passedKeywords: passed,
|
||||
asStock: fundamentalsToStock(entry, f, filterText),
|
||||
fundamentals: f,
|
||||
});
|
||||
}
|
||||
|
||||
// sortScore — 통과 키워드 수 내림차순.
|
||||
candidates.sort((a, b) => b.passedKeywords.length - a.passedKeywords.length);
|
||||
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 키워드 이상 통과).`);
|
||||
return limited;
|
||||
}
|
||||
Reference in New Issue
Block a user