Files
connectai/src/features/stocks/slashStocks.ts
T
g1nation 0a97324f1b 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>
2026-05-25 09:59:32 +09:00

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;
}
}