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,237 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { AIService } from '../../core/services';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
|
||||
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
|
||||
import type { DiscoveredCandidate } from './stockDiscovery';
|
||||
|
||||
/**
|
||||
* Discover 결과를 LLM 에게 던져 *매력도 Top 5* 추출 + 채팅/텔레그램 발송.
|
||||
*
|
||||
* 호출 흐름 (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.
|
||||
*/
|
||||
|
||||
export interface TopFiveItem {
|
||||
rank: number;
|
||||
name: string;
|
||||
symbol: string;
|
||||
/** LLM 이 한 줄로 정리한 매력 포인트. */
|
||||
pitch: string;
|
||||
/** 원본 후보 (선택적으로 ROE/영업이익률 등 인용용). */
|
||||
candidate?: DiscoveredCandidate;
|
||||
}
|
||||
|
||||
export interface TopFiveResult {
|
||||
items: TopFiveItem[];
|
||||
/** LLM 의 종합 코멘트 (1-2 문장). 없을 수 있음. */
|
||||
summary?: string;
|
||||
/** LLM 응답 원문 (디버그 / fallback 출력용). */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
'당신은 한국 주식 발굴 결과를 검토하는 가치 투자 분석가다.',
|
||||
'제공된 후보 종목들의 *재무 지표* 와 *통과 키워드* 를 보고 가장 매력적인 5개를 골라라.',
|
||||
'',
|
||||
'**평가 기준 (우선순위 순):**',
|
||||
' 1. 통과 키워드 수 — 많을수록 우수 (3개 통과 < 5개 통과 < 6개 통과)',
|
||||
' 2. ROE 절대 수치 — 15% 이상 강하게 가산',
|
||||
' 3. 영업이익률 — 20% 이상 가산 (가격 결정력 + 마진 안정)',
|
||||
' 4. 유보율 — 1,000% 이상 통과, 3,000% 이상 안정성 가산',
|
||||
' 5. PBR — 낮을수록 가산, 단 1.0 미만은 *value trap* 가능성 cross-check',
|
||||
'',
|
||||
'**출력 형식 (반드시 이대로):**',
|
||||
'🎯 매력도 Top 5',
|
||||
'',
|
||||
'1. <종목명> (<6자리 심볼>) — <한 줄 매력 포인트 30자 이내>',
|
||||
' 근거: ROE x%, 영업이익률 y%, 유보율 z%, <통과 N키워드 인용>',
|
||||
'2. ...',
|
||||
'...',
|
||||
'5. ...',
|
||||
'',
|
||||
'종합: <1-2 문장 — 이번 발굴 batch 의 공통 특징 또는 주의점>',
|
||||
'',
|
||||
'*다른 텍스트 절대 추가 금지.* 출력 첫 줄은 정확히 "🎯 매력도 Top 5" 이어야 한다.',
|
||||
].join('\n');
|
||||
|
||||
function buildAnalysisPrompt(candidates: DiscoveredCandidate[]): string {
|
||||
const lines: string[] = [
|
||||
`발굴 후보 ${candidates.length}개. 매력도 Top 5 골라라.`,
|
||||
'',
|
||||
];
|
||||
for (const c of candidates) {
|
||||
const f = c.fundamentals;
|
||||
lines.push(
|
||||
`· ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억]`,
|
||||
` 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(', ')}`,
|
||||
'',
|
||||
);
|
||||
}
|
||||
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]));
|
||||
|
||||
const itemRe = /^\s*([1-5])[\.\)\:]\s+(.+?)\s*[\(\[](\d{6})[\)\]]\s*[—\-:]\s*(.+)$/;
|
||||
for (const line of lines) {
|
||||
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]),
|
||||
});
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
export async function analyzeTopCandidates(
|
||||
candidates: DiscoveredCandidate[],
|
||||
onProgress?: (msg: string) => void,
|
||||
): Promise<TopFiveResult> {
|
||||
if (candidates.length === 0) {
|
||||
return { items: [], raw: '' };
|
||||
}
|
||||
onProgress?.('🤖 LLM 분석 시작 — 후보 ' + candidates.length + '개 평가 중...');
|
||||
const ai = new AIService();
|
||||
try {
|
||||
const result = await ai.chat({
|
||||
system: SYSTEM_PROMPT,
|
||||
user: buildAnalysisPrompt(candidates),
|
||||
timeoutMs: 90_000,
|
||||
});
|
||||
if (result.empty || !result.content.trim()) {
|
||||
logError('Discovery analyzer: LLM 빈 응답.');
|
||||
return { items: [], raw: '' };
|
||||
}
|
||||
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: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 채팅 webview 용 — markdown 친화적 멀티라인. */
|
||||
export function renderTopFiveForChat(result: TopFiveResult): string {
|
||||
if (result.items.length === 0) {
|
||||
return result.raw
|
||||
? `\n🤖 **LLM 분석 결과** (형식 파싱 실패 — 원문 표시)\n\n${result.raw}\n`
|
||||
: '\n⚠️ LLM 분석 실패 (빈 응답 또는 timeout).\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;
|
||||
lines.push(` 근거: ROE ${f.roe?.toFixed(1) ?? '-'}%, 영업이익률 ${f.operatingMargin?.toFixed(1) ?? '-'}%, 유보율 ${f.retentionRatio?.toLocaleString() ?? '-'}%, 통과 ${it.candidate.passedKeywords.length}개 (${it.candidate.passedKeywords.join(', ')})`);
|
||||
}
|
||||
}
|
||||
if (result.summary) {
|
||||
lines.push('', `💬 ${result.summary}`);
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/** 텔레그램용 — Markdown V1, 짧고 깔끔. 4096자 제한 안에서. */
|
||||
function pickChatIdForReport(): number | null {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
|
||||
if (allowed.length > 0 && Number.isFinite(allowed[0]) && allowed[0] !== 0) {
|
||||
return allowed[0];
|
||||
}
|
||||
const fallback = cfg.get<number>('stocks.telegramChatId', 0);
|
||||
return fallback && Number.isFinite(fallback) ? fallback : null;
|
||||
}
|
||||
|
||||
function formatKstNow(): string {
|
||||
const fmt = new Intl.DateTimeFormat('ko-KR', {
|
||||
timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
});
|
||||
const parts = fmt.formatToParts(new Date());
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
|
||||
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')} KST`;
|
||||
}
|
||||
|
||||
export function renderTopFiveForTelegram(
|
||||
result: TopFiveResult,
|
||||
rangeLabel: string,
|
||||
): string {
|
||||
if (result.items.length === 0) {
|
||||
return `🎯 *Astra 발굴 Top 5* (${rangeLabel})\n${formatKstNow()}\n\n⚠️ LLM 분석 실패 — 채팅 창에서 raw 결과 확인.`;
|
||||
}
|
||||
const lines: string[] = [
|
||||
`🎯 *Astra 발굴 Top 5* (${rangeLabel})`,
|
||||
formatKstNow(),
|
||||
'',
|
||||
];
|
||||
for (const it of result.items) {
|
||||
lines.push(`${it.rank}\\. *${it.name}* (\`${it.symbol}\`)`);
|
||||
lines.push(` ${it.pitch}`);
|
||||
if (it.candidate) {
|
||||
const f = it.candidate.fundamentals;
|
||||
lines.push(` ROE ${f.roe?.toFixed(1) ?? '-'}% · OM ${f.operatingMargin?.toFixed(1) ?? '-'}% · 유보 ${f.retentionRatio?.toLocaleString() ?? '-'}%`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
if (result.summary) {
|
||||
lines.push(`💬 ${result.summary}`);
|
||||
}
|
||||
const joined = lines.join('\n');
|
||||
return joined.length > 3800 ? joined.slice(0, 3800) + '\n…(잘림)' : joined;
|
||||
}
|
||||
|
||||
export async function sendTopFiveToTelegram(
|
||||
context: vscode.ExtensionContext,
|
||||
text: string,
|
||||
): Promise<{ ok: boolean; reason?: string }> {
|
||||
const chatId = pickChatIdForReport();
|
||||
if (chatId === null) {
|
||||
return { ok: false, reason: '텔레그램 chatId 미설정 (allowedChatIds 또는 stocks.telegramChatId)' };
|
||||
}
|
||||
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
|
||||
if (!token.trim()) {
|
||||
return { ok: false, reason: '텔레그램 봇 토큰 없음' };
|
||||
}
|
||||
const client = new TelegramHttpClient({ getToken: () => token });
|
||||
try {
|
||||
await client.sendMessage({ chatId, text, parseMode: 'Markdown' });
|
||||
logInfo('Top 5 텔레그램 발송 완료.', { chatId, chars: text.length });
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
logError('Top 5 텔레그램 발송 실패.', { chatId, error: e?.message ?? String(e) });
|
||||
return { ok: false, reason: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { startStocksWatcher, runOnceNow } from './stocksWatcher';
|
||||
export { handleStocksCommand } from './slashStocks';
|
||||
@@ -0,0 +1,127 @@
|
||||
import { AIService } from '../../core/services';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { readStocksStore, updateStock } from './stocksStore';
|
||||
import type { Stock } from './types';
|
||||
|
||||
/**
|
||||
* `/stocks judge <심볼>` 의 코어 — 종목의 재무 데이터를 LLM 에게 던져
|
||||
* "3/4 필터" 4-criteria (ROE / 성장성 / 유동성 / 수익성) 평가를 받고
|
||||
* stocks.json 의 `"3/4 필터"` 필드를 업데이트.
|
||||
*
|
||||
* LLM 신뢰도 이슈는 사용자가 인지한 trade-off — 자동화 결과를 *제안* 으로 보고
|
||||
* 결과 텍스트에 "자동 평가" prefix 를 박아 수동 판정과 구분 가능하게.
|
||||
*
|
||||
* 출력 형식 (LLM 에게 강제):
|
||||
* 첫 줄: "충족 (ROE, 성장성, 유동성)" 또는 "미충족"
|
||||
* 둘째 줄 ~ : (선택) 이유 한두 줄 — 호출자가 webview 에 같이 보여줌
|
||||
*
|
||||
* `.includes("충족")` 매칭은 unchanged → signalClassifier 가 자동으로 인식.
|
||||
*/
|
||||
|
||||
export interface JudgeResult {
|
||||
ok: boolean;
|
||||
/** stocks.json 에 저장된 새 "3/4 필터" 텍스트. */
|
||||
filterText?: string;
|
||||
/** LLM 이 제시한 평가 근거 (사용자에게 보여줌). */
|
||||
rationale?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
'당신은 한국 주식 가치 평가 보조 도구다. 사용자가 제공한 종목의 *재무 지표만* 보고',
|
||||
'필터 평가를 내린다. 외부 정보 추측 금지 — 주어진 숫자에서만 판단.',
|
||||
'',
|
||||
'**사용 가능한 8개 평가 키워드** (사용자의 실제 분류 패턴):',
|
||||
' - ROE: ROE(25E) ≥ 10% 이면 통과. 15% 이상은 우수 (통과 강하게).',
|
||||
' - 성장성: 영업이익률(25E) ≥ 15% 또는 상장일이 3년 이내 신규성장주.',
|
||||
' - 유동성: 유보율 ≥ 1,000% 이면 통과 (자기자본 충분).',
|
||||
' - 수익성: 영업이익률(25E) ≥ 10% 이면 통과. 20% 이상이면 "수익성 개선" 표기 가능.',
|
||||
' - 영업효율: 영업이익률(25E) ≥ 15% + ROE ≥ 8% 동시 만족 (효율성 시그널).',
|
||||
' - 기술력: "최대 먹거리" 가 AI / 반도체 / 배터리 / 바이오 / 기술 영역이고 PBR ≥ 2 (시장이 기술 premium 인정).',
|
||||
' - 안정성: 유보율 ≥ 3,000% + 시가총액 ≥ 5,000억 (대형주 안전).',
|
||||
' - PBR: PBR ≤ 1.5 이면 통과 (저평가 시그널, 저평가우량주에서 주로 사용).',
|
||||
'',
|
||||
'**투자성향별 우선 적용 키워드:**',
|
||||
' - 스윙/중기: ROE / 성장성 / 유동성 / 수익성 위주.',
|
||||
' - 장기투자: 성장성 / 유동성 / 기술력 / 영업효율 위주.',
|
||||
' - 저평가우량주: PBR / ROE 필수 + (성장성 또는 수익성 또는 안정성) 중 1개.',
|
||||
'',
|
||||
'**판정 규칙:**',
|
||||
' - 위 8개 키워드 중 *3개 이상* 통과 → "충족 (통과한 항목들 콤마 구분)" 형식.',
|
||||
' 예: "충족 (ROE, 성장성, 유동성)" / "충족 (PBR, ROE, 안정성)" / "충족 (성장성, 유동성, 수익성 개선)"',
|
||||
' - 3개 미만 → "미충족 (사유: 핵심 약점 한 줄)"',
|
||||
'',
|
||||
'**키워드 선택 가이드:**',
|
||||
' - 한 종목에서 통과한 키워드 *모두* 나열하지 말고 *그 종목 성격을 가장 잘 보여주는 3개* 만 선택.',
|
||||
' - 투자성향별 우선 키워드를 먼저 포함시키고, 빠진 부분은 다른 키워드로 메움.',
|
||||
'',
|
||||
'**실제 패턴 예시** (학습용 — 사용자가 이미 분류한 종목들):',
|
||||
' - 마녀공장 (ROE 15.6%, 영업이익률 18.0%, 유보율 5,800%, 스윙/중기) → "충족 (ROE, 성장성, 유동성)"',
|
||||
' - 기가비스 (ROE 7.23%, 영업이익률 25.7%, 유보율 4,250%, 신규 상장 2년차, 스윙/중기) → "충족 (성장성, 유동성, 수익성 개선)" (ROE 가 10% 미만이라 빠짐, 대신 수익성 강함)',
|
||||
' - 엔켐 (ROE 12.4%, 영업이익률 8.5%, 유보율 1,250%, 스윙/중기) → "충족 (성장성, 유동성)" (영업이익률이 10% 미만이라 수익성 빠짐)',
|
||||
'',
|
||||
'**출력 형식 (반드시 이대로):**',
|
||||
' 1번째 줄: 위 형식의 판정 문구만 (다른 텍스트 절대 금지)',
|
||||
' 2번째 줄: 빈 줄',
|
||||
' 3번째 줄 이후: 평가 근거 2-3 문장 — 어떤 지표가 어떤 threshold 를 통과/미통과했는지 *구체 수치 인용*.',
|
||||
].join('\n');
|
||||
|
||||
function buildUserPrompt(s: Stock): string {
|
||||
const lines = [
|
||||
`종목: ${s.이름} (${s.심볼})`,
|
||||
`상장일: ${s.상장일 ?? '미상'}`,
|
||||
`투자성향: ${s.투자성향 ?? '미분류'}`,
|
||||
`유보율: ${s.유보율 ?? '-'}`,
|
||||
`ROE(25E): ${s['ROE(25E)'] ?? '-'}`,
|
||||
`영업이익률(25E): ${s['영업이익률(25E)'] ?? '-'}`,
|
||||
`EPS(25E): ${s['EPS(25E)'] ?? '-'}`,
|
||||
`PER(25E): ${s['PER(25E)'] ?? '-'}`,
|
||||
`PBR: ${s.PBR ?? '-'}`,
|
||||
`시가총액: ${s.시가총액 ?? '-'}`,
|
||||
`최대 먹거리: ${s['최대 먹거리'] ?? '-'}`,
|
||||
`특이사항: ${s.특이사항 ?? '-'}`,
|
||||
'',
|
||||
'위 데이터로 4-criteria 필터 판정.',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function judgeStock(symbol: string): Promise<JudgeResult> {
|
||||
const store = readStocksStore();
|
||||
const stock = store.find(s => s.심볼 === symbol);
|
||||
if (!stock) {
|
||||
return { ok: false, error: `심볼 ${symbol} 을 stocks.json 에서 못 찾음` };
|
||||
}
|
||||
|
||||
const ai = new AIService();
|
||||
try {
|
||||
const result = await ai.chat({
|
||||
system: SYSTEM_PROMPT,
|
||||
user: buildUserPrompt(stock),
|
||||
});
|
||||
if (result.empty || !result.content.trim()) {
|
||||
return { ok: false, error: 'LLM 이 빈 응답 반환' };
|
||||
}
|
||||
const lines = result.content.split('\n');
|
||||
const firstLine = (lines[0] || '').trim();
|
||||
const rationale = lines.slice(2).join('\n').trim() || undefined;
|
||||
|
||||
if (!firstLine) return { ok: false, error: 'LLM 응답 형식 오류 (첫 줄 비어있음)' };
|
||||
|
||||
// 텍스트 형식 검증 — "충족" 또는 "미충족" 으로 시작해야 signalClassifier 가 인식.
|
||||
if (!/^(충족|미충족)/.test(firstLine)) {
|
||||
return { ok: false, error: `LLM 응답 형식 오류 (충족/미충족 prefix 없음): ${firstLine.slice(0, 80)}` };
|
||||
}
|
||||
|
||||
// "자동 평가" prefix 박아서 수동/자동 구분 가능하게.
|
||||
const filterText = `[자동 평가] ${firstLine}`;
|
||||
const wrote = updateStock(symbol, { '3/4 필터': filterText });
|
||||
if (!wrote) return { ok: false, error: 'stocks.json 쓰기 실패' };
|
||||
|
||||
logInfo('Stocks LLM judge 완료.', { symbol, filterText, model: result.model });
|
||||
return { ok: true, filterText, rationale };
|
||||
} catch (e: any) {
|
||||
logError('Stocks LLM judge 실패.', { symbol, error: e?.message ?? String(e) });
|
||||
return { ok: false, error: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
/**
|
||||
* Naver Finance 비공식 JSON API 로 개별 종목 펀더멘털 fetch.
|
||||
*
|
||||
* 두 endpoint 합성:
|
||||
* - `/api/stock/<code>/integration` — 시총 텍스트 / PER / PBR / EPS / 현재가
|
||||
* - `/api/stock/<code>/finance/annual` — ROE / 영업이익률 / 유보율 / 부채비율
|
||||
* (rowList 안의 row.title 매칭, 최근 연도 컬럼 값 사용)
|
||||
*
|
||||
* JSON 응답이라 selector 깨질 일이 없고, label 매칭도 정확 (rowList[i].title === 'ROE').
|
||||
* 사용자가 별도 인증 / API 키 필요 없음 — Naver Finance 모바일 페이지가 쓰는 그대로.
|
||||
*/
|
||||
|
||||
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
const INTEGRATION_URL = 'https://m.stock.naver.com/api/stock';
|
||||
|
||||
export interface Fundamentals {
|
||||
symbol: string;
|
||||
/** 연간 재무제표 (최근 *확정* 연도). */
|
||||
roe?: number; // %
|
||||
operatingMargin?: number; // % (영업이익률)
|
||||
retentionRatio?: number; // % (유보율)
|
||||
debtRatio?: number; // % (부채비율)
|
||||
/** integration API 의 현재가 + 평가지표. */
|
||||
per?: number;
|
||||
pbr?: number;
|
||||
eps?: number;
|
||||
marketCapEok?: number; // 억 단위
|
||||
currentPrice?: number;
|
||||
/** 업종 hint — 사용 가능하면 채움 ("기술력" 키워드 매칭 용). */
|
||||
sectorHint?: string;
|
||||
}
|
||||
|
||||
interface NaverIntegrationResponse {
|
||||
stockName?: string;
|
||||
totalInfos?: Array<{ code: string; key: string; value: string }>;
|
||||
/** 종목 분류 / 업종 정보가 들어있을 수 있음 (옵션). */
|
||||
industryInfo?: { name?: string };
|
||||
}
|
||||
|
||||
interface NaverFinanceAnnualResponse {
|
||||
financeInfo?: {
|
||||
trTitleList?: Array<{ isConsensus: 'Y' | 'N'; title: string; key: string }>;
|
||||
rowList?: Array<{ title: string; columns: Record<string, { value: string }> }>;
|
||||
};
|
||||
}
|
||||
|
||||
/** "12,090" / "23.64배" / "5,800%" / "1,710조 365억" / "-" 텍스트에서 숫자 추출. */
|
||||
function parseNumber(raw: string | undefined): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
const cleaned = raw.replace(/,/g, '').replace(/배|%|원|억|조/g, '').trim();
|
||||
if (!cleaned || cleaned === '-' || cleaned === 'N/A') return undefined;
|
||||
const n = parseFloat(cleaned);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/** "1,710조 365억" / "2,787억" / "5조" → 억 단위 정수. */
|
||||
function parseMarketCapText(text: string | undefined): number | undefined {
|
||||
if (!text) return undefined;
|
||||
const cleaned = text.replace(/원|\s/g, '');
|
||||
const joMatch = cleaned.match(/([\d,]+)조/);
|
||||
const eokMatch = cleaned.match(/(?:조)?([\d,]+)억/) || cleaned.match(/([\d,]+)$/);
|
||||
const jo = joMatch ? parseInt(joMatch[1].replace(/,/g, ''), 10) : 0;
|
||||
const eok = eokMatch ? parseInt(eokMatch[1].replace(/,/g, ''), 10) : 0;
|
||||
const total = jo * 10000 + eok;
|
||||
return total > 0 ? total : undefined;
|
||||
}
|
||||
|
||||
/** trTitleList 에서 *최근 확정* (isConsensus = 'N') 컬럼 키 선택. */
|
||||
function pickLatestConfirmedKey(titles?: Array<{ isConsensus: 'Y' | 'N'; key: string }>): string | null {
|
||||
if (!titles || titles.length === 0) return null;
|
||||
// 'N' 만 필터 → key 내림차순 → 첫 번째.
|
||||
const confirmed = titles.filter(t => t.isConsensus === 'N').map(t => t.key).sort().reverse();
|
||||
return confirmed[0] ?? null;
|
||||
}
|
||||
|
||||
async function fetchIntegration(symbol: string, timeoutMs: number): Promise<NaverIntegrationResponse | null> {
|
||||
try {
|
||||
const res = await fetch(`${INTEGRATION_URL}/${symbol}/integration`, {
|
||||
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json() as NaverIntegrationResponse;
|
||||
} catch (e: any) {
|
||||
logError('Naver integration fetch 실패.', { symbol, error: e?.message ?? String(e) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFinanceAnnual(symbol: string, timeoutMs: number): Promise<NaverFinanceAnnualResponse | null> {
|
||||
try {
|
||||
const res = await fetch(`${INTEGRATION_URL}/${symbol}/finance/annual`, {
|
||||
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json() as NaverFinanceAnnualResponse;
|
||||
} catch (e: any) {
|
||||
logError('Naver finance annual fetch 실패.', { symbol, error: e?.message ?? String(e) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchFundamentals(symbol: string, timeoutMs = 10000): Promise<Fundamentals | null> {
|
||||
const [integ, fin] = await Promise.all([
|
||||
fetchIntegration(symbol, timeoutMs),
|
||||
fetchFinanceAnnual(symbol, timeoutMs),
|
||||
]);
|
||||
if (!integ && !fin) return null;
|
||||
|
||||
const out: Fundamentals = { symbol };
|
||||
|
||||
// integration — totalInfos 의 code 로 추출 (key 한글 텍스트보다 안정적).
|
||||
if (integ?.totalInfos) {
|
||||
const map = new Map(integ.totalInfos.map(i => [i.code, i.value]));
|
||||
out.per = parseNumber(map.get('per'));
|
||||
out.pbr = parseNumber(map.get('pbr'));
|
||||
out.eps = parseNumber(map.get('eps'));
|
||||
out.currentPrice = parseNumber(map.get('closePrice') || map.get('lastClosePrice'));
|
||||
out.marketCapEok = parseMarketCapText(map.get('marketValue'));
|
||||
}
|
||||
if (integ?.industryInfo?.name) out.sectorHint = integ.industryInfo.name;
|
||||
|
||||
// finance/annual — rowList 에서 최근 확정 연도 컬럼 값.
|
||||
if (fin?.financeInfo?.rowList && fin.financeInfo.rowList.length > 0) {
|
||||
const latestKey = pickLatestConfirmedKey(fin.financeInfo.trTitleList);
|
||||
if (latestKey) {
|
||||
const rowByTitle = new Map(fin.financeInfo.rowList.map(r => [r.title, r]));
|
||||
const valueOf = (title: string): number | undefined => {
|
||||
const row = rowByTitle.get(title);
|
||||
if (!row) return undefined;
|
||||
return parseNumber(row.columns[latestKey]?.value);
|
||||
};
|
||||
out.roe = valueOf('ROE');
|
||||
out.operatingMargin = valueOf('영업이익률');
|
||||
out.retentionRatio = valueOf('유보율');
|
||||
out.debtRatio = valueOf('부채비율');
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 일괄 fetch — throttle 300ms. JSON API 가 가벼우니 HTML 크롤 500ms 보다 빠르게. */
|
||||
export async function fetchAllFundamentals(
|
||||
symbols: string[],
|
||||
onProgress?: (symbol: string, fund: Fundamentals | null, i: number, total: number) => void,
|
||||
): Promise<Map<string, Fundamentals>> {
|
||||
const out = new Map<string, Fundamentals>();
|
||||
let i = 0;
|
||||
for (const symbol of symbols) {
|
||||
i++;
|
||||
const fund = await fetchFundamentals(symbol);
|
||||
if (fund) out.set(symbol, fund);
|
||||
onProgress?.(symbol, fund, i, symbols.length);
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
logInfo(`Naver fundamentals 일괄 fetch: ${out.size}/${symbols.length} 성공.`);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
/**
|
||||
* Naver Finance *비공식 JSON API* 로 시가총액 순위 fetch.
|
||||
*
|
||||
* - 코스피: `https://m.stock.naver.com/api/stocks/marketValue/KOSPI?page=N&pageSize=50`
|
||||
* - 코스닥: `https://m.stock.naver.com/api/stocks/marketValue/KOSDAQ?page=N&pageSize=50`
|
||||
*
|
||||
* 응답:
|
||||
* { stocks: [ { itemCode, stockName, marketValue, marketValueHangeul, closePrice, ... }, ... ] }
|
||||
*
|
||||
* `marketValueHangeul` 은 "2,787억" / "1,710조 365억" 같은 사람 친화 텍스트.
|
||||
* 우리는 이걸 *억 단위 정수* 로 파싱 — 시가총액 범위 필터 (사용자 옵션) 와 일관성.
|
||||
*
|
||||
* Why JSON over HTML:
|
||||
* - 페이지 디자인 변경 무관 (스키마는 더 안정적)
|
||||
* - EUC-KR 디코딩 불필요 (JSON 은 UTF-8)
|
||||
* - cheerio 의존성 제거
|
||||
* - 더 빠름 (HTML 전체 다운로드 X, JSON 만)
|
||||
*
|
||||
* Caveat: *비공식* — Naver 가 막을 수 있음. 정식 ToS 보장 X. 개인 학습용 가정.
|
||||
*/
|
||||
|
||||
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
const BASE_URL = 'https://m.stock.naver.com/api/stocks/marketValue';
|
||||
|
||||
export type Market = 'kospi' | 'kosdaq';
|
||||
|
||||
export interface ScreenerEntry {
|
||||
/** 6자리 종목코드. */
|
||||
symbol: string;
|
||||
/** 종목명. */
|
||||
name: string;
|
||||
/** 시가총액 (억원 단위 정수). marketValueHangeul 텍스트에서 파싱. */
|
||||
marketCapEok: number;
|
||||
/** 종가 (옵션). */
|
||||
closePrice?: number;
|
||||
market: Market;
|
||||
}
|
||||
|
||||
/**
|
||||
* "1,710조 365억" / "2,787억" / "17조" 같은 텍스트를 *억 단위 정수* 로 환산.
|
||||
* - 조 단위: ×10,000
|
||||
* - 억 단위: ×1
|
||||
*/
|
||||
function parseMarketCapHangeul(text: string | undefined): number {
|
||||
if (!text) return 0;
|
||||
const cleaned = text.replace(/원|\s/g, '');
|
||||
const joMatch = cleaned.match(/([\d,]+)조/);
|
||||
const eokMatch = cleaned.match(/(?:조)?([\d,]+)억/) || cleaned.match(/([\d,]+)$/);
|
||||
const jo = joMatch ? parseInt(joMatch[1].replace(/,/g, ''), 10) : 0;
|
||||
const eok = eokMatch ? parseInt(eokMatch[1].replace(/,/g, ''), 10) : 0;
|
||||
// "1,710조 365억" → jo=1710, eok=365 → 17,103,650 안 됨. 1,710조 365 = 17,103,650 억? 아니다.
|
||||
// 1,710조 = 17,100,000 억. + 365억 = 17,100,365 억.
|
||||
return jo * 10000 + eok;
|
||||
}
|
||||
|
||||
interface NaverStockListItem {
|
||||
itemCode: string;
|
||||
stockName: string;
|
||||
marketValue?: string; // 백만원 단위 (단위 모호, 사용 안 함)
|
||||
marketValueHangeul?: string; // "2,787억원" — 사용
|
||||
closePrice?: string; // "12,090"
|
||||
}
|
||||
|
||||
interface NaverMarketValueResponse {
|
||||
stocks: NaverStockListItem[];
|
||||
totalCount?: number;
|
||||
pageSize?: number;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
async function fetchPage(market: Market, page: number): Promise<NaverStockListItem[]> {
|
||||
const category = market === 'kospi' ? 'KOSPI' : 'KOSDAQ';
|
||||
const url = `${BASE_URL}/${category}?page=${page}&pageSize=50`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Naver screener HTTP ${res.status} (${market} p${page})`);
|
||||
const data = await res.json() as NaverMarketValueResponse;
|
||||
if (!Array.isArray(data.stocks)) {
|
||||
throw new Error(`Naver screener: stocks 필드 누락 (${market} p${page})`);
|
||||
}
|
||||
return data.stocks;
|
||||
}
|
||||
|
||||
function toEntry(item: NaverStockListItem, market: Market): ScreenerEntry {
|
||||
const marketCapEok = parseMarketCapHangeul(item.marketValueHangeul);
|
||||
const closePriceNum = item.closePrice
|
||||
? parseFloat(item.closePrice.replace(/,/g, ''))
|
||||
: undefined;
|
||||
return {
|
||||
symbol: item.itemCode,
|
||||
name: item.stockName,
|
||||
marketCapEok,
|
||||
closePrice: Number.isFinite(closePriceNum as number) ? closePriceNum : undefined,
|
||||
market,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 시장 전체 (또는 maxPages) 의 후보 풀 fetch. 시가총액 범위 (억) 로 1차 필터.
|
||||
* - throttle: 300ms (HTML 크롤 500ms 보다 빠르게 — JSON 이라 가벼움).
|
||||
* - Naver 가 시총 큰 순으로 정렬해 반환하므로, *시총 maxCap 보다 큰 페이지* 는 일찍 종료 가능.
|
||||
*/
|
||||
export async function screenMarket(opts: {
|
||||
market: Market;
|
||||
maxPages?: number;
|
||||
minMarketCapEok?: number;
|
||||
maxMarketCapEok?: number;
|
||||
onProgress?: (page: number, totalSoFar: number) => void;
|
||||
}): Promise<ScreenerEntry[]> {
|
||||
const maxPages = opts.maxPages ?? 20;
|
||||
const minCap = opts.minMarketCapEok ?? 0;
|
||||
const maxCap = opts.maxMarketCapEok ?? Number.MAX_SAFE_INTEGER;
|
||||
const collected: ScreenerEntry[] = [];
|
||||
|
||||
for (let page = 1; page <= maxPages; page++) {
|
||||
try {
|
||||
const items = await fetchPage(opts.market, page);
|
||||
if (items.length === 0) {
|
||||
logInfo(`Naver screener p${page} ${opts.market}: 0 rows — 종료.`);
|
||||
break;
|
||||
}
|
||||
const entries = items.map(it => toEntry(it, opts.market));
|
||||
// 시총 큰 순 → 모든 종목 시총이 maxCap 보다 작아진 페이지부터는 maxCap 위 종목 없음 (정렬 보장).
|
||||
// minCap 보다 작아진 페이지는 그 뒤 모든 종목이 더 작음 → early exit.
|
||||
let pageBelowMin = true;
|
||||
for (const e of entries) {
|
||||
if (e.marketCapEok > maxCap) continue;
|
||||
if (e.marketCapEok < minCap) continue;
|
||||
collected.push(e);
|
||||
pageBelowMin = false;
|
||||
}
|
||||
// 이 페이지의 *모든* 종목이 minCap 미만이면 더 작은 종목들만 남음 → early exit.
|
||||
if (entries.every(e => e.marketCapEok < minCap)) {
|
||||
logInfo(`Naver screener ${opts.market}: p${page} 전부 minCap(${minCap}억) 미만 — early exit.`);
|
||||
opts.onProgress?.(page, collected.length);
|
||||
break;
|
||||
}
|
||||
opts.onProgress?.(page, collected.length);
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
} catch (e: any) {
|
||||
logError(`Naver screener p${page} ${opts.market} 실패.`, { error: e?.message ?? String(e) });
|
||||
// partial 결과라도 반환 — 한 페이지 실패해도 continue.
|
||||
}
|
||||
}
|
||||
logInfo(`Naver screener ${opts.market}: ${collected.length}개 (${minCap}억 ~ ${maxCap}억).`);
|
||||
return collected;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { writeSheetRange, type SheetValues } from '../sheets';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { classifyAll } from './signalClassifier';
|
||||
import { readStocksStore } from './stocksStore';
|
||||
import type { ClassifiedStock } from './types';
|
||||
|
||||
/**
|
||||
* 분류된 종목 리스트 → Google Sheets 동기화.
|
||||
*
|
||||
* invest_results/gs_update_api.js 의 시트 레이아웃을 유지:
|
||||
* - Sheet1!A1:O = 스윙/중기
|
||||
* - Sheet2!A1:O = 장기투자
|
||||
* - Sheet3!A1:O = 저평가우량주
|
||||
* (시트 이름은 spreadsheet 의 1/2/3번째 탭 — 사용자가 본인 spreadsheet 에 미리 만들어 둠.)
|
||||
*
|
||||
* spreadsheet ID 는 설정 `g1nation.stocks.spreadsheetId` 에서 읽음. 미설정이면 skip 안내.
|
||||
* OAuth token 은 calendar 와 공유 (oauth.ts SCOPE 에 spreadsheets 이미 포함).
|
||||
*/
|
||||
|
||||
const HEADER = ['종목명', '심볼', '상장일', '현재가', '적정주가', '매수권장가', '3/4 필터', '매수 신호', 'ROE', 'PBR', '영업이익률', '유보율', 'PER', 'EPS', '특이사항'];
|
||||
|
||||
function buildSheetRows(classified: ClassifiedStock[]): SheetValues {
|
||||
const rows: SheetValues = [HEADER];
|
||||
for (const s of classified) {
|
||||
rows.push([
|
||||
s.이름,
|
||||
s.심볼,
|
||||
s.상장일 ?? '',
|
||||
s.현재가 ?? 0,
|
||||
s.적정주가 ?? '',
|
||||
s.매수권장가 ?? '',
|
||||
s.filterPass ? 'Pass' : 'Fail',
|
||||
s.signalText,
|
||||
s['ROE(25E)'] ?? '',
|
||||
s.PBR ?? '',
|
||||
s['영업이익률(25E)'] ?? '',
|
||||
s.유보율 ?? '',
|
||||
s['PER(25E)'] ?? '',
|
||||
s['EPS(25E)'] ?? '',
|
||||
s.특이사항 ?? '',
|
||||
]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3 시트 일괄 동기화. 결과는 시트별 성공/실패 카운트로 반환 — caller 가 webview 에 표시.
|
||||
*
|
||||
* Sheet 이름은 1/2/3번째 탭 인덱스가 아니라 *이름* 으로 range 를 짜야 안전.
|
||||
* 사용자가 본인 spreadsheet 의 탭 이름을 코드 기본값과 맞추도록 안내:
|
||||
* 'Sheet1' / 'Sheet2' / 'Sheet3' — 또는 설정으로 override 가능.
|
||||
*/
|
||||
export async function syncToSheets(
|
||||
context: vscode.ExtensionContext,
|
||||
): Promise<{ ok: boolean; errors: string[]; updatedRanges: string[] }> {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const spreadsheetId = (cfg.get<string>('stocks.spreadsheetId') || '').trim();
|
||||
if (!spreadsheetId) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: ['Settings 에 g1nation.stocks.spreadsheetId 가 설정되지 않았습니다.'],
|
||||
updatedRanges: [],
|
||||
};
|
||||
}
|
||||
const sheetSwing = (cfg.get<string>('stocks.sheetSwing') || 'Sheet1').trim();
|
||||
const sheetLong = (cfg.get<string>('stocks.sheetLong') || 'Sheet2').trim();
|
||||
const sheetUltra = (cfg.get<string>('stocks.sheetUltraLow') || 'Sheet3').trim();
|
||||
|
||||
const store = readStocksStore();
|
||||
const groups = classifyAll(store);
|
||||
|
||||
const errors: string[] = [];
|
||||
const updatedRanges: string[] = [];
|
||||
|
||||
const tasks: Array<{ tab: string; rows: SheetValues; label: string }> = [
|
||||
{ tab: sheetSwing, rows: buildSheetRows(groups.swing), label: '스윙/중기' },
|
||||
{ tab: sheetLong, rows: buildSheetRows(groups.long), label: '장기투자' },
|
||||
{ tab: sheetUltra, rows: buildSheetRows(groups.ultraLow), label: '저평가우량주' },
|
||||
];
|
||||
|
||||
for (const t of tasks) {
|
||||
if (t.rows.length <= 1) continue;
|
||||
const range = `${t.tab}!A1:O${t.rows.length}`;
|
||||
try {
|
||||
const r = await writeSheetRange(context, spreadsheetId, range, t.rows);
|
||||
if (r.ok) {
|
||||
updatedRanges.push(r.updatedRange);
|
||||
logInfo(`Stocks sheets sync: ${t.label} OK.`, { range: r.updatedRange, cells: r.updatedCells });
|
||||
} else {
|
||||
errors.push(`${t.label}: ${r.error}`);
|
||||
logError(`Stocks sheets sync: ${t.label} 실패.`, { error: r.error });
|
||||
}
|
||||
} catch (e: any) {
|
||||
errors.push(`${t.label}: ${e?.message ?? String(e)}`);
|
||||
}
|
||||
}
|
||||
return { ok: errors.length === 0, errors, updatedRanges };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Stock, ClassifiedStock, Signal } from './types';
|
||||
|
||||
/**
|
||||
* invest_results/gs_update_api.js 의 분류 로직 포팅.
|
||||
*
|
||||
* 1. filterPass = "3/4 필터" 에 "충족" 포함 여부 (정성 필터, 사용자가 사전 평가)
|
||||
* 2. isPriceInZone = 현재가 > 0 && 매수권장가 > 0 && 현재가 <= 매수권장가
|
||||
* 3. signal:
|
||||
* - filterPass + priceInZone → BUY_ZONE (sortScore 2, "🚨 매수사정권!")
|
||||
* - filterPass + !priceInZone → OVERVALUED (sortScore 1, "⚠️ 고평가! 가격 조정 감시 중")
|
||||
* - !filterPass → HOLD (sortScore 0, "관망")
|
||||
*
|
||||
* 텍스트 문구는 사용자 익숙한 텍스트 그대로 유지 (텔레그램 보고서/Sheets 양쪽에 동일하게 보임).
|
||||
*/
|
||||
|
||||
const SIGNAL_TEXT: Record<Signal, string> = {
|
||||
BUY_ZONE: '🚨 매수사정권! (바닥 안착 시 매수 개시)',
|
||||
OVERVALUED: '⚠️ 고평가! 가격 조정 감시 중',
|
||||
HOLD: '관망',
|
||||
};
|
||||
|
||||
/** "23,760" 같은 콤마 포함 텍스트 → number. 빈 문자열 / 파싱 실패 시 0. */
|
||||
function parsePrice(raw: string | number | undefined): number {
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : 0;
|
||||
if (!raw) return 0;
|
||||
const cleaned = String(raw).replace(/,/g, '').trim();
|
||||
const n = parseFloat(cleaned);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
export function classifyStock(s: Stock): ClassifiedStock {
|
||||
const curPrice = parsePrice(s.현재가);
|
||||
const recPrice = parsePrice(s.매수권장가);
|
||||
const isPriceInZone = curPrice > 0 && recPrice > 0 && curPrice <= recPrice;
|
||||
const filterPass = (s['3/4 필터'] || '').toString().includes('충족');
|
||||
|
||||
let signal: Signal = 'HOLD';
|
||||
let sortScore: 0 | 1 | 2 = 0;
|
||||
if (filterPass) {
|
||||
if (isPriceInZone) { signal = 'BUY_ZONE'; sortScore = 2; }
|
||||
else { signal = 'OVERVALUED'; sortScore = 1; }
|
||||
}
|
||||
|
||||
return {
|
||||
...s,
|
||||
signal,
|
||||
sortScore,
|
||||
filterPass,
|
||||
signalText: SIGNAL_TEXT[signal],
|
||||
};
|
||||
}
|
||||
|
||||
/** 전체 분류 + 투자성향별 정렬. 텔레그램 / 시트 동기화 둘 다 이걸 호출. */
|
||||
export function classifyAll(stocks: Stock[]): {
|
||||
swing: ClassifiedStock[];
|
||||
long: ClassifiedStock[];
|
||||
ultraLow: ClassifiedStock[];
|
||||
all: ClassifiedStock[];
|
||||
} {
|
||||
const classified = stocks.map(classifyStock);
|
||||
const filterByProfile = (profile: Stock['투자성향']) =>
|
||||
classified.filter(s => s.투자성향 === profile).sort((a, b) => b.sortScore - a.sortScore);
|
||||
return {
|
||||
swing: filterByProfile('스윙/중기'),
|
||||
long: filterByProfile('장기투자'),
|
||||
ultraLow: filterByProfile('저평가우량주'),
|
||||
all: classified.slice().sort((a, b) => b.sortScore - a.sortScore),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { logInfo } from '../../utils';
|
||||
import { readStocksStore, addStock, removeStock, getStocksFilePath } from './stocksStore';
|
||||
import { fetchAllPrices } from './yahooClient';
|
||||
import { classifyAll } from './signalClassifier';
|
||||
import { writeStocksStore } from './stocksStore';
|
||||
import { syncToSheets } from './sheetsSync';
|
||||
import { judgeStock } from './llmJudge';
|
||||
import { sendStocksReport, buildReportText } from './telegramReport';
|
||||
import { runOnceNow } from './stocksWatcher';
|
||||
import { discoverStocks } from './stockDiscovery';
|
||||
import { analyzeTopCandidates, renderTopFiveForChat, renderTopFiveForTelegram, sendTopFiveToTelegram } from './discoveryAnalyzer';
|
||||
import type { ClassifiedStock, Stock } from './types';
|
||||
|
||||
/**
|
||||
* `/stocks <subcommand> [args]` 라우터 — slashRouter 의 단일 handler 로 등록되어
|
||||
* 첫 단어로 sub-routing.
|
||||
*
|
||||
* Subcommands:
|
||||
* /stocks — 도움말
|
||||
* /stocks list — 종목 + 신호 표시
|
||||
* /stocks check — 현재가 갱신 (Yahoo)
|
||||
* /stocks signal — 매수사정권 종목만
|
||||
* /stocks sync — Google Sheets 동기화
|
||||
* /stocks add <심볼> <이름> [투자성향]
|
||||
* /stocks remove <심볼>
|
||||
* /stocks judge <심볼> — LLM 4-criteria 평가
|
||||
* /stocks discover [min] [max] — Naver 크롤 발굴 (시총 범위 억 단위, default 1000-5000)
|
||||
* /stocks report — 텔레그램 보고서 즉시 발송
|
||||
* /stocks run — watcher 한 사이클 즉시 실행 (현재가+sync+보고서)
|
||||
* /stocks path — stocks.json 경로 표시
|
||||
*/
|
||||
|
||||
interface Webview { postMessage(msg: any): Thenable<boolean> | boolean; }
|
||||
|
||||
function chunk(view: Webview | undefined, value: string) {
|
||||
view?.postMessage({ type: 'streamChunk', value });
|
||||
}
|
||||
|
||||
function formatPrice(n: number | undefined): string {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n)) return '-';
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function renderListLine(s: ClassifiedStock): string {
|
||||
const cur = formatPrice(s.현재가);
|
||||
const rec = s.매수권장가 ?? '-';
|
||||
return ` · ${s.이름} (${s.심볼}): ${cur} / 권장 ${rec} → ${s.signalText}`;
|
||||
}
|
||||
|
||||
async function cmdList(view: Webview | undefined): Promise<void> {
|
||||
const store = readStocksStore();
|
||||
if (store.length === 0) {
|
||||
chunk(view, `\n종목 없음. \`/stocks add <심볼> <이름>\` 으로 추가하세요.\n경로: ${getStocksFilePath() ?? '(워크스페이스 없음)'}\n`);
|
||||
return;
|
||||
}
|
||||
const g = classifyAll(store);
|
||||
const lines: string[] = ['\n📋 **종목 목록 (분류별)**\n'];
|
||||
if (g.swing.length) {
|
||||
lines.push(`\n**스윙/중기** (${g.swing.length}개)`);
|
||||
g.swing.forEach(s => lines.push(renderListLine(s)));
|
||||
}
|
||||
if (g.long.length) {
|
||||
lines.push(`\n**장기투자** (${g.long.length}개)`);
|
||||
g.long.forEach(s => lines.push(renderListLine(s)));
|
||||
}
|
||||
if (g.ultraLow.length) {
|
||||
lines.push(`\n**저평가우량주** (${g.ultraLow.length}개)`);
|
||||
g.ultraLow.forEach(s => lines.push(renderListLine(s)));
|
||||
}
|
||||
chunk(view, lines.join('\n') + '\n');
|
||||
}
|
||||
|
||||
async function cmdCheck(view: Webview | undefined): Promise<void> {
|
||||
const store = readStocksStore();
|
||||
if (store.length === 0) { chunk(view, '\n종목 없음.\n'); return; }
|
||||
chunk(view, `\n🔄 ${store.length}개 종목 현재가 갱신 중 (Yahoo, 1초/종목)...\n`);
|
||||
const symbols = store.map(s => s.심볼).filter(Boolean);
|
||||
const prices = await fetchAllPrices(symbols, (sym, p) => {
|
||||
const name = store.find(s => s.심볼 === sym)?.이름 ?? sym;
|
||||
chunk(view, ` · ${name}: ${p !== null ? p.toLocaleString() + '원' : '조회 실패'}\n`);
|
||||
});
|
||||
for (const s of store) {
|
||||
const p = prices.get(s.심볼);
|
||||
if (typeof p === 'number') s.현재가 = p;
|
||||
}
|
||||
writeStocksStore(store);
|
||||
const updated = [...prices.values()].filter(p => p !== null).length;
|
||||
chunk(view, `\n✅ ${updated}/${store.length}개 종목 갱신 완료.\n`);
|
||||
}
|
||||
|
||||
async function cmdSignal(view: Webview | undefined): Promise<void> {
|
||||
const store = readStocksStore();
|
||||
const g = classifyAll(store);
|
||||
const buyZone = g.all.filter(s => s.signal === 'BUY_ZONE');
|
||||
if (buyZone.length === 0) {
|
||||
chunk(view, '\n🚨 매수사정권 종목 없음.\n');
|
||||
return;
|
||||
}
|
||||
chunk(view, `\n🚨 **매수사정권 ${buyZone.length}개**\n\n`);
|
||||
for (const s of buyZone) {
|
||||
chunk(view, renderListLine(s) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdSync(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
|
||||
chunk(view, '\n📊 Google Sheets 동기화 중...\n');
|
||||
const r = await syncToSheets(context);
|
||||
if (r.ok) {
|
||||
chunk(view, `\n✅ 동기화 완료: ${r.updatedRanges.length}개 시트.\n${r.updatedRanges.map(x => ` · ${x}`).join('\n')}\n`);
|
||||
} else {
|
||||
chunk(view, `\n❌ 동기화 실패:\n${r.errors.map(e => ` · ${e}`).join('\n')}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdAdd(arg: string, view: Webview | undefined): Promise<void> {
|
||||
const parts = arg.split(/\s+/);
|
||||
if (parts.length < 2) {
|
||||
chunk(view, '\n사용법: `/stocks add <심볼> <이름> [투자성향]`\n 투자성향: 스윙/중기 | 장기투자 | 저평가우량주 (기본: 스윙/중기)\n');
|
||||
return;
|
||||
}
|
||||
const [symbol, name, profileRaw] = parts;
|
||||
const profile = (profileRaw as Stock['투자성향']) || '스윙/중기';
|
||||
const r = addStock({ 이름: name, 심볼: symbol, 투자성향: profile });
|
||||
chunk(view, r.ok ? `\n✅ 추가: ${name} (${symbol}, ${profile})\n` : `\n❌ ${r.reason}\n`);
|
||||
}
|
||||
|
||||
async function cmdRemove(arg: string, view: Webview | undefined): Promise<void> {
|
||||
if (!arg.trim()) { chunk(view, '\n사용법: `/stocks remove <심볼>`\n'); return; }
|
||||
const r = removeStock(arg.trim());
|
||||
chunk(view, r.ok ? `\n✅ 제거: ${arg.trim()}\n` : `\n❌ ${r.reason}\n`);
|
||||
}
|
||||
|
||||
async function cmdJudge(arg: string, view: Webview | undefined): Promise<void> {
|
||||
if (!arg.trim()) { chunk(view, '\n사용법: `/stocks judge <심볼>`\n'); return; }
|
||||
const symbol = arg.trim();
|
||||
chunk(view, `\n🤖 LLM 4-criteria 평가 중: ${symbol}...\n`);
|
||||
const r = await judgeStock(symbol);
|
||||
if (!r.ok) {
|
||||
chunk(view, `\n❌ 평가 실패: ${r.error}\n`);
|
||||
return;
|
||||
}
|
||||
chunk(view, `\n✅ 평가 완료: ${r.filterText}\n`);
|
||||
if (r.rationale) chunk(view, `\n근거:\n${r.rationale}\n`);
|
||||
}
|
||||
|
||||
async function cmdReport(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
|
||||
chunk(view, '\n📨 텔레그램 보고서 발송 중...\n');
|
||||
const r = await sendStocksReport(context);
|
||||
chunk(view, r.ok ? '\n✅ 발송 완료.\n' : `\n❌ 발송 실패: ${r.reason}\n`);
|
||||
chunk(view, `\n*Preview:*\n${buildReportText()}\n`);
|
||||
}
|
||||
|
||||
async function cmdRun(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
|
||||
chunk(view, '\n⚡ Watcher 1회 즉시 실행 (현재가 + Sheets + 텔레그램)...\n');
|
||||
await runOnceNow(context);
|
||||
chunk(view, '\n✅ 완료.\n');
|
||||
}
|
||||
|
||||
async function cmdDiscover(rest: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise<void> {
|
||||
// 인자 파싱 — `min max` (둘 다 억 단위) / 안 주면 default.
|
||||
const parts = rest.split(/\s+/).filter(Boolean);
|
||||
const minCap = parts[0] ? parseInt(parts[0], 10) : 1000;
|
||||
const maxCap = parts[1] ? parseInt(parts[1], 10) : 5000;
|
||||
if (Number.isNaN(minCap) || Number.isNaN(maxCap) || minCap >= maxCap) {
|
||||
chunk(view, '\n사용법: `/stocks discover [min] [max]` (억 단위, 예: `/stocks discover 1000 5000`)\n');
|
||||
return;
|
||||
}
|
||||
|
||||
chunk(view, `\n🔍 **Naver 발굴 시작** (시총 ${minCap.toLocaleString()}억 ~ ${maxCap.toLocaleString()}억)\n`);
|
||||
|
||||
const candidates = await discoverStocks({
|
||||
minMarketCapEok: minCap,
|
||||
maxMarketCapEok: maxCap,
|
||||
onProgress: (msg) => chunk(view, msg + '\n'),
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
chunk(view, '\n결과 없음.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
chunk(view, `\n📋 **발굴 후보 ${candidates.length}개** (통과 키워드 수 내림차순)\n\n`);
|
||||
for (const c of candidates) {
|
||||
const price = c.fundamentals.currentPrice;
|
||||
const priceStr = typeof price === 'number' ? price.toLocaleString() + '원' : '-';
|
||||
chunk(view, ` · ${c.name} (${c.symbol}) [${c.market} · ${c.marketCapEok.toLocaleString()}억]\n`);
|
||||
chunk(view, ` 현재가 ${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`);
|
||||
}
|
||||
|
||||
// ── LLM 매력도 분석 + 텔레그램 전송 (자동 chain) ──
|
||||
// 사용자 의도: 발굴 목록이 나오면 *항상* 분석 + 텔레그램. 별도 명령 trigger 불필요.
|
||||
// 실패해도 (LLM timeout / 텔레그램 미설정) 위 발굴 목록은 화면에 그대로 남아 있음.
|
||||
chunk(view, '\n');
|
||||
const topFive = await analyzeTopCandidates(candidates, (msg) => chunk(view, msg + '\n'));
|
||||
chunk(view, renderTopFiveForChat(topFive));
|
||||
|
||||
if (context) {
|
||||
const rangeLabel = `시총 ${minCap.toLocaleString()}-${maxCap.toLocaleString()}억`;
|
||||
const tgText = renderTopFiveForTelegram(topFive, rangeLabel);
|
||||
const tgResult = await sendTopFiveToTelegram(context, tgText);
|
||||
chunk(view, tgResult.ok
|
||||
? '\n📨 텔레그램 발송 완료.\n'
|
||||
: `\n⚠️ 텔레그램 발송 skip: ${tgResult.reason}\n`);
|
||||
} else {
|
||||
chunk(view, '\n⚠️ 텔레그램 발송 skip: ExtensionContext 없음.\n');
|
||||
}
|
||||
|
||||
chunk(view, '\n💡 종목을 stocks.json 에 추가하려면 `/stocks add <심볼> <이름>` 사용.\n');
|
||||
}
|
||||
|
||||
function cmdPath(view: Webview | undefined): void {
|
||||
const p = getStocksFilePath();
|
||||
chunk(view, p ? `\n📂 stocks.json: \`${p}\`\n` : '\n⚠️ 워크스페이스 폴더 없음 — stocks 모듈 사용 불가.\n');
|
||||
}
|
||||
|
||||
function cmdHelp(view: Webview | undefined): void {
|
||||
chunk(view, [
|
||||
'\n📈 **Stocks 명령**',
|
||||
'',
|
||||
' `/stocks list` — 종목 + 신호',
|
||||
' `/stocks check` — 현재가 갱신',
|
||||
' `/stocks signal` — 매수사정권 종목만',
|
||||
' `/stocks sync` — Google Sheets 동기화',
|
||||
' `/stocks add <심볼> <이름>` — 종목 추가',
|
||||
' `/stocks remove <심볼>` — 종목 제거',
|
||||
' `/stocks judge <심볼>` — LLM 4-criteria 평가',
|
||||
' `/stocks discover [min] [max]` — Naver 크롤 발굴 (시총 억 단위, default 1000-5000)',
|
||||
' `/stocks report` — 텔레그램 보고서 즉시 발송',
|
||||
' `/stocks run` — Watcher 1회 즉시 실행',
|
||||
' `/stocks path` — stocks.json 경로 표시',
|
||||
'',
|
||||
'자동 실행: VS Code 시작 시 활성화. KST 09:00 / 15:00 매일 자동.',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
/** slashRouter 가 `/stocks` 로 들어오는 모든 입력을 이 함수 한 곳으로 위임. */
|
||||
export async function handleStocksCommand(
|
||||
arg: string,
|
||||
view: Webview | undefined,
|
||||
context?: vscode.ExtensionContext,
|
||||
): Promise<boolean> {
|
||||
const parts = arg.trim().split(/\s+/);
|
||||
const sub = (parts[0] || '').toLowerCase();
|
||||
const rest = parts.slice(1).join(' ').trim();
|
||||
|
||||
logInfo(`Stocks slash: sub=${sub} rest="${rest.slice(0, 40)}"`);
|
||||
|
||||
try {
|
||||
switch (sub) {
|
||||
case '': cmdHelp(view); return true;
|
||||
case 'list': await cmdList(view); return true;
|
||||
case 'check': await cmdCheck(view); return true;
|
||||
case 'signal': await cmdSignal(view); return true;
|
||||
case 'sync':
|
||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (sync 불가).\n'); return true; }
|
||||
await cmdSync(view, context); return true;
|
||||
case 'add': await cmdAdd(rest, view); return true;
|
||||
case 'remove': case 'rm': await cmdRemove(rest, view); return true;
|
||||
case 'judge': await cmdJudge(rest, view); return true;
|
||||
case 'discover': await cmdDiscover(rest, view, context); return true;
|
||||
case 'report':
|
||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음.\n'); return true; }
|
||||
await cmdReport(view, context); return true;
|
||||
case 'run':
|
||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음.\n'); return true; }
|
||||
await cmdRun(view, context); return true;
|
||||
case 'path': cmdPath(view); return true;
|
||||
default:
|
||||
chunk(view, `\n❌ 알 수 없는 sub-command: \`${sub}\`. \`/stocks\` 로 도움말 보기.\n`);
|
||||
return true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n❌ 에러: ${e?.message ?? String(e)}\n`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import type { Stock, StocksStore } from './types';
|
||||
|
||||
/**
|
||||
* 워크스페이스 루트의 `.astra/stocks.json` 을 source of truth 로 사용.
|
||||
*
|
||||
* 결정 근거 (q1=A): 사용자가 워크스페이스 단위로 다른 종목 리스트를 가질 수 있게.
|
||||
* 워크스페이스가 없으면 (= VS Code 가 폴더 열지 않고 시작) 빈 store 반환 — 이 경우
|
||||
* watcher / slash 명령 모두 silent skip.
|
||||
*
|
||||
* Atomic write: tmp 파일에 쓰고 rename — 동시 read 또는 SIGKILL 중간에도 partial JSON
|
||||
* 으로 안 깨지게.
|
||||
*/
|
||||
|
||||
const STORE_REL_PATH = '.astra/stocks.json';
|
||||
|
||||
export function getStocksFilePath(): string | null {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders || folders.length === 0) return null;
|
||||
return path.join(folders[0].uri.fsPath, STORE_REL_PATH);
|
||||
}
|
||||
|
||||
/** 파일 없으면 빈 배열 반환. 파일 파싱 실패해도 빈 배열 + 에러 로그. */
|
||||
export function readStocksStore(): StocksStore {
|
||||
const filePath = getStocksFilePath();
|
||||
if (!filePath || !fs.existsSync(filePath)) return [];
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
logError('stocks.json 가 배열이 아닙니다 — 빈 store 반환.', { filePath });
|
||||
return [];
|
||||
}
|
||||
return parsed as StocksStore;
|
||||
} catch (e: any) {
|
||||
logError('stocks.json 읽기 실패.', { filePath, error: e?.message ?? String(e) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomic write — tmp + rename. 워크스페이스 없으면 false 반환 (caller 가 안내). */
|
||||
export function writeStocksStore(store: StocksStore): boolean {
|
||||
const filePath = getStocksFilePath();
|
||||
if (!filePath) {
|
||||
logError('워크스페이스 폴더가 없어 stocks.json 쓰기 불가.');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
|
||||
fs.renameSync(tmp, filePath);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logError('stocks.json 쓰기 실패.', { filePath, error: e?.message ?? String(e) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 한 종목 추가. 같은 심볼이 이미 있으면 false (caller 가 안내). */
|
||||
export function addStock(stock: Stock): { ok: boolean; reason?: string } {
|
||||
const store = readStocksStore();
|
||||
if (store.some(s => s.심볼 === stock.심볼)) {
|
||||
return { ok: false, reason: `심볼 ${stock.심볼} 이미 존재` };
|
||||
}
|
||||
store.push(stock);
|
||||
const wrote = writeStocksStore(store);
|
||||
if (!wrote) return { ok: false, reason: '쓰기 실패 (워크스페이스 없음 또는 권한)' };
|
||||
logInfo('Stocks: 종목 추가.', { symbol: stock.심볼, name: stock.이름 });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** 한 종목 제거. 못 찾으면 false. */
|
||||
export function removeStock(symbol: string): { ok: boolean; reason?: string } {
|
||||
const store = readStocksStore();
|
||||
const idx = store.findIndex(s => s.심볼 === symbol);
|
||||
if (idx < 0) return { ok: false, reason: `심볼 ${symbol} 못 찾음` };
|
||||
const removed = store.splice(idx, 1)[0];
|
||||
const wrote = writeStocksStore(store);
|
||||
if (!wrote) return { ok: false, reason: '쓰기 실패' };
|
||||
logInfo('Stocks: 종목 제거.', { symbol, name: removed.이름 });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** 한 종목의 필드 patch — 현재가 갱신 / 필터 업데이트 등. */
|
||||
export function updateStock(symbol: string, patch: Partial<Stock>): boolean {
|
||||
const store = readStocksStore();
|
||||
const idx = store.findIndex(s => s.심볼 === symbol);
|
||||
if (idx < 0) return false;
|
||||
store[idx] = { ...store[idx], ...patch };
|
||||
return writeStocksStore(store);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { readStocksStore, writeStocksStore } from './stocksStore';
|
||||
import { fetchAllPrices } from './yahooClient';
|
||||
import { sendStocksReport } from './telegramReport';
|
||||
import { syncToSheets } from './sheetsSync';
|
||||
|
||||
/**
|
||||
* VS Code 시작 시 자동 활성화 — KST 09:00 / 15:00 에:
|
||||
* 1. Yahoo 로 현재가 일괄 갱신 → stocks.json 업데이트
|
||||
* 2. (선택) Google Sheets 동기화 — g1nation.stocks.spreadsheetId 설정 시
|
||||
* 3. 텔레그램 보고서 발송
|
||||
*
|
||||
* 구현 노트:
|
||||
* - setTimeout 단일 chain — 매 firing 후 다음 알람까지 재계산해서 새 setTimeout.
|
||||
* - VS Code 종료 시 disposable 로 clear.
|
||||
* - 시간대는 Asia/Seoul 강제 — 사용자 macOS timezone 과 무관하게 같은 시각에 동작.
|
||||
* - run_kodari_sync.command 의 sleep-loop 구조를 단일 setTimeout 으로 대체.
|
||||
*
|
||||
* 트리거 시각 변경 시 SCHEDULE 만 수정 — 분 단위.
|
||||
*/
|
||||
|
||||
const SCHEDULE_HOURS_KST = [9, 15]; // 09:00, 15:00 KST
|
||||
|
||||
let _timer: NodeJS.Timeout | undefined;
|
||||
let _disposed = false;
|
||||
|
||||
/** Asia/Seoul 기준 *지금* 의 hour/minute. */
|
||||
function nowInKst(): { date: Date; hour: number; minute: number; ymd: string } {
|
||||
const now = new Date();
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(now);
|
||||
const get = (t: string) => Number(parts.find(p => p.type === t)?.value || '0');
|
||||
return {
|
||||
date: now,
|
||||
hour: get('hour'),
|
||||
minute: get('minute'),
|
||||
ymd: `${get('year')}-${parts.find(p => p.type === 'month')?.value}-${parts.find(p => p.type === 'day')?.value}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금부터 다음 firing 까지 milliseconds 계산.
|
||||
* SCHEDULE_HOURS_KST 중 *오늘 남은 시각* 우선, 다 지났으면 내일 첫 시각.
|
||||
*
|
||||
* 시간대 변환: 사용자 OS 가 KST 가 아닐 수 있으므로 KST 기준 hour/minute 으로 비교 후
|
||||
* 그 차이를 ms 로 변환 (변환은 단순 산수 — 분 차이 × 60000).
|
||||
*/
|
||||
function msUntilNextRun(): number {
|
||||
const kst = nowInKst();
|
||||
const todayMinutes = kst.hour * 60 + kst.minute;
|
||||
|
||||
let bestTodayMinutes: number | null = null;
|
||||
for (const h of SCHEDULE_HOURS_KST) {
|
||||
const targetMinutes = h * 60;
|
||||
if (targetMinutes > todayMinutes) {
|
||||
bestTodayMinutes = targetMinutes;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestTodayMinutes !== null) {
|
||||
return (bestTodayMinutes - todayMinutes) * 60_000;
|
||||
}
|
||||
// 오늘 더 없음 → 내일 첫 시각.
|
||||
const tomorrowFirstMinutes = SCHEDULE_HOURS_KST[0] * 60;
|
||||
const remainingTodayMinutes = 24 * 60 - todayMinutes;
|
||||
return (remainingTodayMinutes + tomorrowFirstMinutes) * 60_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 번 fire — 가격 갱신 + (선택) Sheets sync + 텔레그램 보고서.
|
||||
* 각 단계는 독립적으로 try/catch — 한 단계 실패해도 다른 단계는 계속.
|
||||
*/
|
||||
async function fireOnce(context: vscode.ExtensionContext): Promise<void> {
|
||||
const kst = nowInKst();
|
||||
logInfo('Stocks watcher fire 시작.', { kst: `${kst.hour}:${kst.minute}` });
|
||||
|
||||
// (1) Yahoo 가격 갱신
|
||||
try {
|
||||
const store = readStocksStore();
|
||||
const symbols = store.map(s => s.심볼).filter(Boolean);
|
||||
if (symbols.length === 0) {
|
||||
logInfo('Stocks watcher: 종목 없음 — skip.');
|
||||
} else {
|
||||
const prices = await fetchAllPrices(symbols);
|
||||
for (const s of store) {
|
||||
const p = prices.get(s.심볼);
|
||||
if (typeof p === 'number') s.현재가 = p;
|
||||
}
|
||||
writeStocksStore(store);
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('Stocks watcher: 가격 갱신 실패.', { error: e?.message ?? String(e) });
|
||||
}
|
||||
|
||||
// (2) Sheets sync — 선택 (spreadsheetId 설정 시에만)
|
||||
try {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
if ((cfg.get<string>('stocks.spreadsheetId') || '').trim()) {
|
||||
const r = await syncToSheets(context);
|
||||
if (!r.ok) logError('Stocks watcher: Sheets 동기화 실패.', { errors: r.errors });
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('Stocks watcher: Sheets 호출 실패.', { error: e?.message ?? String(e) });
|
||||
}
|
||||
|
||||
// (3) Telegram 보고서
|
||||
try {
|
||||
const r = await sendStocksReport(context);
|
||||
if (!r.ok) logInfo(`Stocks watcher: 보고서 skip — ${r.reason}`);
|
||||
} catch (e: any) {
|
||||
logError('Stocks watcher: 보고서 호출 실패.', { error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNext(context: vscode.ExtensionContext): void {
|
||||
if (_disposed) return;
|
||||
const ms = msUntilNextRun();
|
||||
const hours = Math.floor(ms / 3600_000);
|
||||
const minutes = Math.floor((ms % 3600_000) / 60_000);
|
||||
logInfo(`Stocks watcher: 다음 firing 까지 ${hours}h ${minutes}m.`);
|
||||
|
||||
_timer = setTimeout(async () => {
|
||||
try {
|
||||
await fireOnce(context);
|
||||
} catch (e: any) {
|
||||
logError('Stocks watcher: fireOnce 예외.', { error: e?.message ?? String(e) });
|
||||
}
|
||||
scheduleNext(context);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* VS Code 시작 시 호출 (extension.ts activate 의 마지막 단계).
|
||||
* 설정 `g1nation.stocks.watcherEnabled` 가 false 이면 활성화 skip.
|
||||
*/
|
||||
export function startStocksWatcher(context: vscode.ExtensionContext): vscode.Disposable {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const enabled = cfg.get<boolean>('stocks.watcherEnabled', true);
|
||||
if (!enabled) {
|
||||
logInfo('Stocks watcher: 비활성 (g1nation.stocks.watcherEnabled=false).');
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
_disposed = false;
|
||||
scheduleNext(context);
|
||||
logInfo('Stocks watcher: 시작됨.', { schedule: SCHEDULE_HOURS_KST });
|
||||
return {
|
||||
dispose: () => {
|
||||
_disposed = true;
|
||||
if (_timer) { clearTimeout(_timer); _timer = undefined; }
|
||||
logInfo('Stocks watcher: dispose.');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 명령으로 즉시 한 번 트리거 (`/stocks watch run` 같은 미래 명령 또는 디버깅). */
|
||||
export async function runOnceNow(context: vscode.ExtensionContext): Promise<void> {
|
||||
await fireOnce(context);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
|
||||
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { readStocksStore } from './stocksStore';
|
||||
import { classifyAll } from './signalClassifier';
|
||||
import type { ClassifiedStock } from './types';
|
||||
|
||||
/**
|
||||
* 09:00 / 15:00 KST 정기 보고서 — Telegram 으로 발송.
|
||||
*
|
||||
* 사용자 결정 (q2=A): chatId 는 `g1nation.telegram.allowedChatIds[0]` 자동 사용.
|
||||
* 없으면 fallback 으로 `g1nation.stocks.telegramChatId` 별도 설정. 둘 다 없으면 skip + 로그.
|
||||
* 토큰은 telegramCommands 의 SecretStorage 키와 공유.
|
||||
*/
|
||||
|
||||
function pickChatId(): number | null {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
|
||||
if (allowed.length > 0) return allowed[0];
|
||||
const dedicated = cfg.get<number>('stocks.telegramChatId', 0);
|
||||
if (dedicated && dedicated !== 0) return dedicated;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 한 카테고리 (스윙/장기/저평가) 의 종목 리스트를 텔레그램 Markdown 으로 렌더. */
|
||||
function renderGroup(label: string, stocks: ClassifiedStock[]): string[] {
|
||||
const buyZone = stocks.filter(s => s.signal === 'BUY_ZONE');
|
||||
const overvalued = stocks.filter(s => s.signal === 'OVERVALUED');
|
||||
const hold = stocks.filter(s => s.signal === 'HOLD');
|
||||
|
||||
const lines: string[] = [`*${label}* (총 ${stocks.length}개)`];
|
||||
if (buyZone.length > 0) {
|
||||
lines.push(`🚨 매수사정권 (${buyZone.length})`);
|
||||
for (const s of buyZone) {
|
||||
const cur = s.현재가 ? s.현재가.toLocaleString() : '?';
|
||||
lines.push(` · ${s.이름} (${s.심볼}): ${cur} / 권장 ${s.매수권장가 ?? '-'}`);
|
||||
}
|
||||
}
|
||||
if (overvalued.length > 0) {
|
||||
lines.push(`⚠️ 가격 조정 감시 (${overvalued.length})`);
|
||||
for (const s of overvalued) {
|
||||
const cur = s.현재가 ? s.현재가.toLocaleString() : '?';
|
||||
lines.push(` · ${s.이름} (${s.심볼}): ${cur} / 권장 ${s.매수권장가 ?? '-'}`);
|
||||
}
|
||||
}
|
||||
if (hold.length > 0 && buyZone.length === 0 && overvalued.length === 0) {
|
||||
// 모두 관망일 때만 그 카운트만 표시 — 종목 일일이 안 나열 (보고서 길이 절약).
|
||||
lines.push(`📊 관망 ${hold.length}건`);
|
||||
} else if (hold.length > 0) {
|
||||
lines.push(`📊 관망 ${hold.length}건 (생략)`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** 보고서 텍스트 생성 (전송과 분리 — 테스트·로그용). */
|
||||
export function buildReportText(now: Date = new Date()): string {
|
||||
const store = readStocksStore();
|
||||
if (store.length === 0) {
|
||||
return '⚠️ stocks.json 에 종목 없음 — `/stocks add <심볼> <이름>` 으로 추가하세요.';
|
||||
}
|
||||
const g = classifyAll(store);
|
||||
const kstStr = formatKstTimestamp(now);
|
||||
|
||||
const out: string[] = [`🦅 *Kodari 정기 보고서* _${kstStr}_`, ''];
|
||||
out.push(...renderGroup('스윙/중기', g.swing), '');
|
||||
out.push(...renderGroup('장기투자', g.long), '');
|
||||
out.push(...renderGroup('저평가우량주', g.ultraLow));
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/** Date → "2026-05-25 09:00 KST" 형식. */
|
||||
function formatKstTimestamp(d: Date): string {
|
||||
const fmt = new Intl.DateTimeFormat('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
});
|
||||
const parts = fmt.formatToParts(d);
|
||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '';
|
||||
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')} KST`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 보고서를 텔레그램으로 전송. 토큰 없거나 chatId 없으면 silent skip (warn 로그).
|
||||
*
|
||||
* 성공/실패 모두 caller (watcher / slash 명령) 가 알 수 있도록 결과 반환.
|
||||
*/
|
||||
export async function sendStocksReport(
|
||||
context: vscode.ExtensionContext,
|
||||
): Promise<{ ok: boolean; reason?: string }> {
|
||||
const chatId = pickChatId();
|
||||
if (chatId === null) {
|
||||
const reason = '텔레그램 chatId 미설정 (g1nation.telegram.allowedChatIds 또는 g1nation.stocks.telegramChatId).';
|
||||
logInfo(`Stocks report skip: ${reason}`);
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || '';
|
||||
if (!token.trim()) {
|
||||
const reason = '텔레그램 봇 토큰 없음 — `Astra: Set Telegram Bot Token` 으로 등록.';
|
||||
logInfo(`Stocks report skip: ${reason}`);
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
const text = buildReportText();
|
||||
const client = new TelegramHttpClient({ getToken: () => token });
|
||||
try {
|
||||
await client.sendMessage({ chatId, text, parseMode: 'Markdown' });
|
||||
logInfo('Stocks report 발송 완료.', { chatId, chars: text.length });
|
||||
return { ok: true };
|
||||
} catch (e: any) {
|
||||
logError('Stocks report 발송 실패.', { chatId, error: e?.message ?? String(e) });
|
||||
return { ok: false, reason: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Stocks 모듈 공유 타입.
|
||||
*
|
||||
* invest_results/target_stocks.json 스키마를 그대로 받아서, ConnectAI 의
|
||||
* `<workspace>/.astra/stocks.json` 으로 옮긴 뒤 같은 필드명을 유지.
|
||||
* 한글 필드명은 사용자의 도메인 데이터라 변경하지 않는다 — 마이그레이션 충돌
|
||||
* 회피 + 사용자가 직접 JSON 편집할 때 friction 최소화.
|
||||
*/
|
||||
|
||||
/** target_stocks.json 의 한 종목 항목. */
|
||||
export interface Stock {
|
||||
이름: string;
|
||||
심볼: string;
|
||||
/** ISO date — 보통 'YYYY-MM-DD'. */
|
||||
상장일?: string;
|
||||
유보율?: string;
|
||||
'ROE(25E)'?: string;
|
||||
'영업이익률(25E)'?: string;
|
||||
'EPS(25E)'?: string;
|
||||
'PER(25E)'?: string;
|
||||
PBR?: string;
|
||||
시가총액?: string;
|
||||
/** 사람이 계산한 적정주가 (텍스트). */
|
||||
적정주가?: string;
|
||||
/** 매수 추천 임계가. signalClassifier 가 현재가와 비교. */
|
||||
매수권장가?: string;
|
||||
설립일?: string;
|
||||
/** 핵심 사업 한 줄. */
|
||||
'최대 먹거리'?: string;
|
||||
특이사항?: string;
|
||||
/** "충족 (ROE, 성장성, 유동성)" 같은 텍스트. signalClassifier 는 `.includes("충족")` 로만 매칭. */
|
||||
'3/4 필터'?: string;
|
||||
/** Yahoo Finance 최근 fetch 한 현재가 (정수). 0 또는 누락이면 미수집. */
|
||||
현재가?: number;
|
||||
/** 분류 시트 — '스윙/중기' / '장기투자' / '저평가우량주' 중 하나. */
|
||||
투자성향?: '스윙/중기' | '장기투자' | '저평가우량주';
|
||||
}
|
||||
|
||||
export type StocksStore = Stock[];
|
||||
|
||||
/** 신호 분류 결과 — UI / 텔레그램 / 시트 동기화 모두 이 모양을 공유. */
|
||||
export type Signal = 'BUY_ZONE' | 'OVERVALUED' | 'HOLD';
|
||||
|
||||
export interface ClassifiedStock extends Stock {
|
||||
/** 정량 + 정성 필터 결합 결과. */
|
||||
signal: Signal;
|
||||
/** 텔레그램·시트 정렬용. 2 = 매수사정권, 1 = 가격 조정 감시, 0 = 관망. */
|
||||
sortScore: 0 | 1 | 2;
|
||||
/** `.includes("충족")` 결과 — 정성 필터 통과 여부. */
|
||||
filterPass: boolean;
|
||||
/** 사용자에게 표시할 한글 신호 문구. */
|
||||
signalText: string;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
/**
|
||||
* Yahoo Finance public chart endpoint 로 현재가 fetch. invest_results/quick_check.js
|
||||
* 의 동일 로직 — symbol 에 suffix 없으면 `.KQ` (코스닥) 우선, 실패 시 `.KS` (코스피) 재시도.
|
||||
*
|
||||
* Yahoo 가 한국 종목은 `<6자리>.KQ` 또는 `<6자리>.KS` 형식. US 종목은 그대로.
|
||||
* symbol 에 이미 `.` 있으면 그대로 사용.
|
||||
*
|
||||
* Returns null 이면 두 suffix 다 실패 — 호출자가 skip 처리.
|
||||
*/
|
||||
export async function fetchYahooPrice(symbol: string, timeoutMs = 8000): Promise<number | 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}`;
|
||||
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 price = data?.chart?.result?.[0]?.meta?.regularMarketPrice;
|
||||
if (typeof price === 'number' && Number.isFinite(price)) {
|
||||
return price;
|
||||
}
|
||||
} catch (e: any) {
|
||||
// suffix 후보가 더 남았으면 계속 시도, 마지막이면 null fallthrough.
|
||||
if (yahooSymbol === candidates[candidates.length - 1]) {
|
||||
logError('Yahoo Finance 현재가 조회 실패.', { symbol, yahooSymbol, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종목 리스트 전체 순회하면서 fetchYahooPrice — 1초 간격으로 throttle (Yahoo rate limit).
|
||||
* partial 갱신 허용: 실패해도 다른 종목은 계속 진행, 결과 Map 반환.
|
||||
*
|
||||
* caller 가 결과를 store 에 일괄 patch.
|
||||
*/
|
||||
export async function fetchAllPrices(
|
||||
symbols: string[],
|
||||
onProgress?: (symbol: string, price: number | null) => void,
|
||||
): Promise<Map<string, number | null>> {
|
||||
const out = new Map<string, number | null>();
|
||||
for (const symbol of symbols) {
|
||||
const price = await fetchYahooPrice(symbol);
|
||||
out.set(symbol, price);
|
||||
onProgress?.(symbol, price);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
logInfo('Yahoo 일괄 갱신 완료.', { total: symbols.length, success: [...out.values()].filter(p => p !== null).length });
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user