0a97324f1b
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>
280 lines
13 KiB
TypeScript
280 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|