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 [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; } 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // 인자 파싱 — `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 { 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; } }