-
Notifications
You must be signed in to change notification settings - Fork 0
Backend Spec
Document Version: 4.0 Last Modified: 2026-01-23 Author: Sam Target Audience: Backend Developers
interface CoreStack {
runtime: {
name: "Node.js";
version: "20 LTS";
reason: "안정성과 생태계 성숙도";
};
language: {
name: "TypeScript";
version: "5.x";
reason: "타입 안정성";
};
framework: {
name: "Nest.js";
version: "10.x";
reason: "모듈 구조, DI, TypeScript 네이티브";
};
orm: {
name: "Prisma";
version: "5.19.1";
reason: "타입 안정성, 직관적 마이그레이션. v7은 설정 파일 분리로 호환성 문제 발생";
};
database: {
name: "PostgreSQL";
version: "18";
reason: "5년 LTS 지원 (2030년까지), JSON 성능 30% 향상, 쿼리 최적화 개선, VACUUM 성능 향상";
};
cache: {
name: "Valkey";
version: "8.1.5";
reason: "100% Redis 호환, Linux Foundation 오픈소스 (라이센스 안정성), 커뮤니티 주도 개발";
};
}interface AsyncComponents {
jobQueue: {
name: "BullMQ";
version: "5.x";
purpose: "Memory extraction, notification sending";
};
cronJobs: {
name: "@nestjs/schedule";
purpose: "Price polling (15-minute intervals)";
};
}interface LLMConfig {
provider: "ollama" | "anthropic"; // Environment-based switching
ollama: {
baseUrl: "http://localhost:11434";
model: "llama2"; // 7B Q4_0 quantization for local dev
purpose: "Local development & testing";
};
anthropic: {
sdk: "@anthropic-ai/sdk";
models: {
fast: "claude-3-5-haiku-latest"; // Manager, Analyst, Risk agents
smart: "claude-sonnet-4-20250514"; // Persona agent
};
purpose: "Production deployment";
};
}LLM Provider Selection:
- Development: Ollama (llama2:latest) - 무료, 로컬 실행, 빠른 테스트
- Production: Anthropic Claude API - 고품질 응답, 한국어 지원
Environment Variables:
LLM_PROVIDER=ollama # or "anthropic"
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=llama2
ANTHROPIC_API_KEY=sk-ant-... # Production onlyinterface ExternalServices {
ai: {
provider: "LLMService (Ollama | Anthropic)";
abstraction: "src/modules/ai-agents/llm.service.ts";
};
marketData: {
primary: {
provider: "Binance";
type: "WebSocket";
streams: "ticker@arr (실시간 가격)";
};
fallback: {
provider: "Gate.io";
type: "WebSocket";
streams: "ticker (실시간 가격)";
};
};
fearGreed: {
provider: "Alternative.me";
type: "REST API";
};
notifications: {
provider: "Discord";
type: "Webhook";
};
}선택 근거:
- 실시간성: WebSocket으로 가격 변동 즉시 감지 (15분 폴링 → 실시간)
- 이중화: Binance 장애 시 Gate.io로 자동 전환 (무중단)
- 비용 절감: WebSocket은 무료, 별도 API 구독 불필요
graph TD
A[Presentation Layer] --> B[Application Layer]
B --> C[Domain Layer]
C --> D[Infrastructure Layer]
A1[Controllers - HTTP/SSE] --> A
B1[Services - Business Logic] --> B
C1[Entities, DTOs] --> C
D1[Prisma, Valkey, External APIs] --> D
sequenceDiagram
participant User
participant Frontend
participant Backend
participant Auth.js
participant Google
participant DB
User->>Frontend: Click "Google Login"
Frontend->>Backend: POST /api/auth/google
Backend->>Auth.js: initiate OAuth flow
Auth.js->>Google: Redirect to Google OAuth
Google->>User: Show consent screen
User->>Google: Grant permission
Google->>Auth.js: Return authorization code
Auth.js->>Google: Exchange code for tokens
Google->>Auth.js: Return user profile
Auth.js->>DB: Upsert user (email, name, image)
DB->>Backend: Create UserConsent record (defaults)
Backend->>Frontend: Set session cookie
Frontend->>User: Redirect to /chat
sequenceDiagram
participant User
participant Frontend
participant ChatController
participant ManagerAgent
participant AnalystAgent
participant PersonaAgent
participant RiskAgent
participant Valkey
participant DB
User->>Frontend: Send message "BTC 지금 어때?"
Frontend->>ChatController: POST /api/chats/:id/messages (SSE)
ChatController->>Valkey: Get recent 20 messages
ChatController->>ManagerAgent: Analyze intent (Haiku 4)
ManagerAgent-->>ChatController: {intent: "advice", needsMarketData: true}
ChatController->>AnalystAgent: Fetch BTC price + F&G (Haiku 4)
AnalystAgent->>Valkey: Check cache (market:price:BTC)
Valkey-->>AnalystAgent: $43,250 (cached)
AnalystAgent-->>ChatController: {facts: {BTC: 43250, fg: 25}}
ChatController->>PersonaAgent: Generate response (Sonnet 4)
PersonaAgent-->>ChatController: "자네, BTC가 $43,250일세..."
ChatController->>RiskAgent: Validate response (Haiku 4)
RiskAgent-->>ChatController: {recommendation: "approve"}
ChatController->>DB: Save user message + assistant message
ChatController->>Frontend: SSE stream response
Frontend->>User: Display streaming response
sequenceDiagram
participant WebSocket
participant MarketService
participant Valkey
participant NotificationQueue
participant BullMQ
participant Discord
participant PWA
Note over WebSocket: Binance/Gate.io 실시간 연결
WebSocket->>MarketService: Price update (실시간)
MarketService->>Valkey: Get previous prices
MarketService->>Valkey: Cache new prices (TTL: 5min)
alt Sudden change detected (>5% for BTC)
MarketService->>NotificationQueue: Add job {symbol: "BTC", change: -7.2}
NotificationQueue->>BullMQ: Process notification job
BullMQ->>Discord: POST webhook (market-alerts channel)
BullMQ->>PWA: Send push to subscribed users
BullMQ->>Valkey: Save notification to DB
end
src/
├── main.ts # App entry point
├── app.module.ts # Root module
│
├── common/ # Common utilities
│ ├── filters/ # Exception Filters
│ ├── guards/ # Auth Guards
│ ├── interceptors/ # Logging, Transform
│ ├── pipes/ # Validation Pipes
│ └── decorators/ # Custom Decorators
│
├── config/ # Configuration
│ ├── database.config.ts
│ ├── valkey.config.ts
│ └── anthropic.config.ts
│
├── modules/ # Feature modules
│ ├── auth/ # Authentication
│ ├── chat/ # Chat functionality
│ ├── ai-agents/ # AI agents
│ ├── market/ # Market data
│ ├── portfolio/ # Shadow portfolio
│ ├── notifications/ # Notifications
│ ├── scheduler/ # Scheduled tasks
│ └── jobs/ # Background Jobs
│
└── prisma/ # Prisma Schema & Migrations
선택 근거:
- 최소한의 초기 데이터: 회원가입 시 설문 없이 Google OAuth만으로 시작
- 대화 기반 프로필 추론: riskProfile과 interests는 채팅에서 자동 추출
- 확장 가능한 JSON: interests를 JSON으로 저장해 유연한 구조 유지
- 관계 중심 설계: 채팅, 포트폴리오, 푸시 구독, 동의 관리를 User와 연결
enum RiskProfile {
CONSERVATIVE
MODERATE
AGGRESSIVE
}
model User {
id String @id @default(uuid())
email String @unique
name String?
image String?
tier String @default("free") // free, pro, premium
// Preferences (inferred from chat)
riskProfile RiskProfile? // ENUM for type safety
interests Json? // ["BTC", "ETH"]
chats Chat[]
shadowTrades ShadowTrade[]
pushSubscriptions PushSubscription[]
consents UserConsent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}RiskProfile ENUM 선택 근거:
- 타입 안정성: DB 레벨에서 잘못된 값 입력 방지
- 쿼리 성능: PostgreSQL이 ENUM 쿼리를 최적화
- 자기 문서화: 별도 문서 없이 허용 가능한 값이 명확함
선택 근거:
- 1:1 대화 구조: MVP는 사용자와 AI 간 단순 1:1 채팅 (그룹 채팅은 Phase 2)
- 자동 제목 생성: 첫 메시지로부터 AI가 채팅 제목 자동 생성
- Cascade 삭제: 채팅 삭제 시 모든 메시지도 함께 삭제
- 인덱스 최적화: userId + createdAt으로 사용자별 최신 채팅 조회 최적화
model Chat {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String @default("새 대화")
messages Message[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, createdAt])
}선택 근거:
- 단순한 role 구조: user/assistant 구분으로 대화 흐름 명확화
- Text 타입: 긴 AI 응답을 위해 @db.Text 사용 (기본 VARCHAR 제한 회피)
- JSON signal: AI 추천을 유연한 구조로 저장 (action, symbol, confidence)
- 컨텍스트 최적화: chatId + createdAt 인덱스로 최근 20개 메시지 빠른 조회
model Message {
id String @id @default(uuid())
chatId String
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
role String // user, assistant
content String @db.Text
// AI Signal (for shadow portfolio)
signal Json? // { action: "buy" | "sell", symbol: "BTC", confidence: 0.8 }
createdAt DateTime @default(now())
@@index([chatId, createdAt])
}선택 근거:
- 투명한 성과 추적: AI 추천을 실제로 담았을 때의 수익률 투명 공개
- Decimal 타입: 암호화폐 가격과 수량의 정확한 소수점 처리 (Float 오차 방지)
- 메시지 참조: 어떤 대화에서 나온 추천인지 추적 가능
- 이중 인덱스: userId로 개인 포트폴리오, symbol로 코인별 집계 최적화
model ShadowTrade {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
symbol String // BTC, ETH, SOL, BNB, DOGE, XRP
action String // buy, sell
price Decimal @db.Decimal(18, 8)
quantity Decimal @db.Decimal(18, 8) @default(1.0)
// Reference to message that triggered this
messageId String?
createdAt DateTime @default(now())
@@index([userId, createdAt])
@@index([symbol])
}선택 근거:
- PWA 푸시 알림: VAPID 프로토콜 기반 웹 푸시 알림 구현
- 멀티 디바이스: 한 사용자가 여러 기기에서 알림 수신 가능
- Unique endpoint: 중복 구독 방지
- 간단한 구조: auth + p256dh로 Web Push API 표준 준수
model PushSubscription {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
endpoint String @unique
auth String
p256dh String
createdAt DateTime @default(now())
@@index([userId])
}선택 근거:
- 브로드캐스트 지원: userId null로 전체 공지 가능
- 타입별 분류: market_alert, portfolio_update, system으로 필터링
- 유연한 data: JSON으로 알림별 추가 정보 저장 (symbol, change 등)
- 읽음 상태: read 플래그로 안 읽은 알림 관리
model Notification {
id String @id @default(uuid())
userId String? // null = broadcast
type String // market_alert, portfolio_update, system
title String
message String @db.Text
data Json? // { symbol: "BTC", change: -5.2 }
read Boolean @default(false)
sentAt DateTime @default(now())
@@index([userId, sentAt])
}선택 근거:
- GDPR Article 7 준수: 동의 시점 타임스탬프 기록 필수
- 감사 추적: IP/User-Agent로 동의 검증 가능
- 세분화된 제어: 마케팅/알림/데이터 처리 개별 동의 관리
- 확장 가능성: 새로운 동의 유형 추가 용이
model UserConsent {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Consent types
marketingEmails Boolean @default(false)
pushNotifications Boolean @default(false)
dataCollection Boolean @default(true) // Required for service
aiAnalysis Boolean @default(true) // Profile inference
// Audit trail
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}interface AuthEndpoints {
googleOAuth: "POST /api/auth/google";
logout: "POST /api/auth/logout";
getSession: "GET /api/auth/session";
}interface ChatEndpoints {
createChat: "POST /api/chats";
listChats: "GET /api/chats";
getChatDetails: "GET /api/chats/:id";
deleteChat: "DELETE /api/chats/:id";
sendMessage: "POST /api/chats/:id/messages"; // SSE response
getMessages: "GET /api/chats/:id/messages"; // with pagination
}interface MarketEndpoints {
getCurrentPrices: "GET /api/market/prices"; // 6 coins
getFearGreed: "GET /api/market/fear-greed";
getPriceHistory: "GET /api/market/history/:symbol"; // 24h
}interface PortfolioEndpoints {
createTrade: "POST /api/shadow-trades";
listTrades: "GET /api/shadow-trades";
getPerformance: "GET /api/shadow-trades/performance";
deleteTrade: "DELETE /api/shadow-trades/:id";
}interface NotificationEndpoints {
subscribePush: "POST /api/push/subscribe";
unsubscribePush: "POST /api/push/unsubscribe";
getNotifications: "GET /api/notifications";
markAsRead: "PATCH /api/notifications/:id/read";
}핵심 비전: SAGE는 월스트리트 퀀트 팀처럼 여러 AI 애널리스트가 협업하는 구조입니다. 각 모듈은 독립적이고 재사용 가능하며, 레고 블록처럼 다양한 파이프라인에 조합됩니다.
데이터 주입 (Data Injection):
- 모듈은 외부에서 데이터를 주입받음 (Factory Pattern)
- 모듈 내부에서 직접 API 호출 X → 느슨한 결합
- 테스트 용이성 및 재사용성 극대화
규칙 기반 분석 (Rule-Based Analysis):
- Analyzer는 명시적 규칙으로 동작 (설명 가능)
- LLM은 최종 응답 포맷팅에만 사용 (Persona)
- 검증 가능하고 일관된 분석 결과
모듈 독립성:
- 각 모듈은 표준 인터페이스를 구현
- 다른 모듈의 구현에 의존하지 않음
- 개별 테스트 및 교체 가능
┌─────────────────────────────────────────────────────┐
│ Output Layer │
│ PersonaFormatter │ ReportGenerator │ SignalEmitter │
├─────────────────────────────────────────────────────┤
│ Analysis Layer │
│ PriceAnalyzer │ SentimentAnalyzer │ TrendAnalyzer │
│ PortfolioAnalyzer │ OnchainAnalyzer │
├─────────────────────────────────────────────────────┤
│ Data Layer │
│ PriceCollector │ SentimentCollector │ OnchainData │
└─────────────────────────────────────────────────────┘
// src/modules/analysis/interfaces/analysis-module.interface.ts
/**
* 모든 분석 모듈이 구현하는 표준 인터페이스
* TInput: 주입받는 데이터 타입
* TOutput: 분석 결과 타입
*/
interface AnalysisModule<TInput, TOutput> {
readonly name: string;
readonly version: string;
/**
* 분석 실행 - 주입된 데이터로 규칙 기반 분석 수행
*/
analyze(input: TInput): Promise<TOutput>;
/**
* 분석 결과에 대한 신뢰도 (0-1)
*/
getConfidence(): number;
}
/**
* 분석 결과 표준 구조
*/
interface AnalysisResult {
module: string; // 모듈 이름
timestamp: Date; // 분석 시점
confidence: number; // 신뢰도 (0-1)
signals: Signal[]; // 추출된 시그널
reasoning: string[]; // 분석 근거 (설명 가능성)
}
interface Signal {
type: 'bullish' | 'bearish' | 'neutral';
strength: number; // 0-1
source: string; // 시그널 출처
description: string; // 설명
}// src/modules/analysis/price/price-analyzer.ts
interface PriceInput {
symbol: string;
currentPrice: number;
priceHistory: { timestamp: Date; price: number }[];
change24h: number;
}
interface PriceAnalysis extends AnalysisResult {
trend: 'uptrend' | 'downtrend' | 'sideways';
volatility: 'low' | 'medium' | 'high';
support: number;
resistance: number;
movingAverages: {
ma7: number;
ma25: number;
ma99: number;
};
}
@Injectable()
export class PriceAnalyzer implements AnalysisModule<PriceInput, PriceAnalysis> {
readonly name = 'PriceAnalyzer';
readonly version = '1.0.0';
private confidence = 0;
/**
* 가격 데이터를 기반으로 기술적 분석 수행
* 규칙 기반 (LLM 미사용)
*/
async analyze(input: PriceInput): Promise<PriceAnalysis> {
const signals: Signal[] = [];
const reasoning: string[] = [];
// 이동평균 계산
const ma7 = this.calculateMA(input.priceHistory, 7);
const ma25 = this.calculateMA(input.priceHistory, 25);
const ma99 = this.calculateMA(input.priceHistory, 99);
// 트렌드 판단 (규칙 기반)
let trend: 'uptrend' | 'downtrend' | 'sideways' = 'sideways';
if (input.currentPrice > ma7 && ma7 > ma25 && ma25 > ma99) {
trend = 'uptrend';
signals.push({
type: 'bullish',
strength: 0.7,
source: 'MA_ALIGNMENT',
description: '이동평균선 정배열 (상승 추세)'
});
reasoning.push('MA7 > MA25 > MA99 정배열 형성');
} else if (input.currentPrice < ma7 && ma7 < ma25 && ma25 < ma99) {
trend = 'downtrend';
signals.push({
type: 'bearish',
strength: 0.7,
source: 'MA_ALIGNMENT',
description: '이동평균선 역배열 (하락 추세)'
});
reasoning.push('MA7 < MA25 < MA99 역배열 형성');
}
// 24시간 변동률 분석
if (input.change24h >= 5) {
signals.push({
type: 'bullish',
strength: Math.min(input.change24h / 10, 1),
source: 'MOMENTUM',
description: `24시간 ${input.change24h.toFixed(2)}% 상승`
});
} else if (input.change24h <= -5) {
signals.push({
type: 'bearish',
strength: Math.min(Math.abs(input.change24h) / 10, 1),
source: 'MOMENTUM',
description: `24시간 ${Math.abs(input.change24h).toFixed(2)}% 하락`
});
}
// 지지/저항 계산
const support = this.calculateSupport(input.priceHistory);
const resistance = this.calculateResistance(input.priceHistory);
// 변동성 계산
const volatility = this.calculateVolatility(input.priceHistory);
this.confidence = this.calculateConfidence(signals);
return {
module: this.name,
timestamp: new Date(),
confidence: this.confidence,
signals,
reasoning,
trend,
volatility,
support,
resistance,
movingAverages: { ma7, ma25, ma99 }
};
}
getConfidence(): number {
return this.confidence;
}
private calculateMA(history: { price: number }[], period: number): number {
const prices = history.slice(-period).map(h => h.price);
return prices.reduce((a, b) => a + b, 0) / prices.length;
}
private calculateSupport(history: { price: number }[]): number {
const prices = history.map(h => h.price);
return Math.min(...prices.slice(-24)); // 24시간 최저가
}
private calculateResistance(history: { price: number }[]): number {
const prices = history.map(h => h.price);
return Math.max(...prices.slice(-24)); // 24시간 최고가
}
private calculateVolatility(history: { price: number }[]): 'low' | 'medium' | 'high' {
const prices = history.map(h => h.price);
const avg = prices.reduce((a, b) => a + b, 0) / prices.length;
const variance = prices.reduce((a, b) => a + Math.pow(b - avg, 2), 0) / prices.length;
const stdDev = Math.sqrt(variance);
const cv = (stdDev / avg) * 100; // Coefficient of Variation (%)
if (cv < 2) return 'low';
if (cv < 5) return 'medium';
return 'high';
}
private calculateConfidence(signals: Signal[]): number {
if (signals.length === 0) return 0.5;
return signals.reduce((a, s) => a + s.strength, 0) / signals.length;
}
}// src/modules/analysis/sentiment/sentiment-analyzer.ts
interface SentimentInput {
fearGreedIndex: number; // 0-100
fearGreedClassification: string; // "Extreme Fear" | "Fear" | "Neutral" | "Greed" | "Extreme Greed"
socialMentions?: number; // 소셜 미디어 언급량 (Phase 2)
}
interface SentimentAnalysis extends AnalysisResult {
marketSentiment: 'extreme_fear' | 'fear' | 'neutral' | 'greed' | 'extreme_greed';
contrarian: boolean; // 역발상 투자 시그널
}
@Injectable()
export class SentimentAnalyzer implements AnalysisModule<SentimentInput, SentimentAnalysis> {
readonly name = 'SentimentAnalyzer';
readonly version = '1.0.0';
private confidence = 0;
/**
* 시장 심리 지표 기반 분석
* Warren Buffett 역발상 투자 원칙 적용
*/
async analyze(input: SentimentInput): Promise<SentimentAnalysis> {
const signals: Signal[] = [];
const reasoning: string[] = [];
// Fear & Greed Index 기반 분석
let marketSentiment: SentimentAnalysis['marketSentiment'];
let contrarian = false;
if (input.fearGreedIndex <= 20) {
marketSentiment = 'extreme_fear';
contrarian = true;
signals.push({
type: 'bullish',
strength: 0.8,
source: 'FEAR_GREED',
description: '극단적 공포 구간 - 역발상 매수 기회'
});
reasoning.push(`F&G Index ${input.fearGreedIndex}: "다른 사람들이 두려워할 때 탐욕스러워라"`);
} else if (input.fearGreedIndex <= 40) {
marketSentiment = 'fear';
signals.push({
type: 'bullish',
strength: 0.5,
source: 'FEAR_GREED',
description: '공포 구간 - 신중한 매수 고려'
});
reasoning.push(`F&G Index ${input.fearGreedIndex}: 공포 구간, 가치 투자 관점 검토`);
} else if (input.fearGreedIndex <= 60) {
marketSentiment = 'neutral';
signals.push({
type: 'neutral',
strength: 0.5,
source: 'FEAR_GREED',
description: '중립 구간 - 시장 관망'
});
reasoning.push(`F&G Index ${input.fearGreedIndex}: 중립 구간`);
} else if (input.fearGreedIndex <= 80) {
marketSentiment = 'greed';
signals.push({
type: 'bearish',
strength: 0.5,
source: 'FEAR_GREED',
description: '탐욕 구간 - 신중한 접근 필요'
});
reasoning.push(`F&G Index ${input.fearGreedIndex}: 탐욕 구간, 리스크 관리 강화`);
} else {
marketSentiment = 'extreme_greed';
contrarian = true;
signals.push({
type: 'bearish',
strength: 0.8,
source: 'FEAR_GREED',
description: '극단적 탐욕 구간 - 역발상 매도 고려'
});
reasoning.push(`F&G Index ${input.fearGreedIndex}: "다른 사람들이 탐욕스러울 때 두려워하라"`);
}
this.confidence = contrarian ? 0.8 : 0.6;
return {
module: this.name,
timestamp: new Date(),
confidence: this.confidence,
signals,
reasoning,
marketSentiment,
contrarian
};
}
getConfidence(): number {
return this.confidence;
}
}// src/modules/analysis/portfolio/portfolio-analyzer.ts
interface PortfolioInput {
holdings: { symbol: string; quantity: number; avgPrice: number }[];
currentPrices: { symbol: string; price: number }[];
totalValue: number;
}
interface PortfolioAnalysis extends AnalysisResult {
totalPnL: number;
totalPnLPercent: number;
holdings: {
symbol: string;
quantity: number;
avgPrice: number;
currentPrice: number;
pnl: number;
pnlPercent: number;
weight: number; // 포트폴리오 내 비중
}[];
diversification: 'poor' | 'moderate' | 'good';
riskLevel: 'low' | 'medium' | 'high';
}
@Injectable()
export class PortfolioAnalyzer implements AnalysisModule<PortfolioInput, PortfolioAnalysis> {
readonly name = 'PortfolioAnalyzer';
readonly version = '1.0.0';
private confidence = 0;
async analyze(input: PortfolioInput): Promise<PortfolioAnalysis> {
const signals: Signal[] = [];
const reasoning: string[] = [];
// 홀딩별 P&L 계산
const holdingsWithPnL = input.holdings.map(h => {
const currentPrice = input.currentPrices.find(p => p.symbol === h.symbol)?.price || 0;
const currentValue = h.quantity * currentPrice;
const costBasis = h.quantity * h.avgPrice;
const pnl = currentValue - costBasis;
const pnlPercent = costBasis > 0 ? (pnl / costBasis) * 100 : 0;
const weight = input.totalValue > 0 ? (currentValue / input.totalValue) * 100 : 0;
return {
...h,
currentPrice,
pnl,
pnlPercent,
weight
};
});
// 전체 P&L
const totalPnL = holdingsWithPnL.reduce((a, h) => a + h.pnl, 0);
const totalCost = input.holdings.reduce((a, h) => a + h.quantity * h.avgPrice, 0);
const totalPnLPercent = totalCost > 0 ? (totalPnL / totalCost) * 100 : 0;
// 분산투자 분석
const maxWeight = Math.max(...holdingsWithPnL.map(h => h.weight));
let diversification: 'poor' | 'moderate' | 'good';
if (input.holdings.length <= 2 || maxWeight > 70) {
diversification = 'poor';
signals.push({
type: 'bearish',
strength: 0.6,
source: 'DIVERSIFICATION',
description: '포트폴리오 집중도가 높음 - 분산 투자 권장'
});
reasoning.push(`최대 비중 ${maxWeight.toFixed(1)}%: 분산 투자 부족`);
} else if (input.holdings.length <= 4 || maxWeight > 50) {
diversification = 'moderate';
reasoning.push(`${input.holdings.length}개 종목, 최대 비중 ${maxWeight.toFixed(1)}%: 적정 분산`);
} else {
diversification = 'good';
signals.push({
type: 'bullish',
strength: 0.5,
source: 'DIVERSIFICATION',
description: '포트폴리오가 잘 분산되어 있음'
});
reasoning.push(`${input.holdings.length}개 종목에 고르게 분산됨`);
}
// 리스크 레벨 계산
const volatileAssets = ['DOGE', 'SOL'];
const volatileWeight = holdingsWithPnL
.filter(h => volatileAssets.includes(h.symbol))
.reduce((a, h) => a + h.weight, 0);
let riskLevel: 'low' | 'medium' | 'high';
if (volatileWeight > 50) {
riskLevel = 'high';
signals.push({
type: 'bearish',
strength: 0.5,
source: 'RISK',
description: '고변동성 자산 비중이 높음'
});
} else if (volatileWeight > 25) {
riskLevel = 'medium';
} else {
riskLevel = 'low';
}
this.confidence = 0.9; // 데이터 기반 계산이므로 높은 신뢰도
return {
module: this.name,
timestamp: new Date(),
confidence: this.confidence,
signals,
reasoning,
totalPnL,
totalPnLPercent,
holdings: holdingsWithPnL,
diversification,
riskLevel
};
}
getConfidence(): number {
return this.confidence;
}
}// src/modules/pipeline/pipeline.service.ts
/**
* 분석 모듈들을 조합하여 파이프라인 구성
* 같은 모듈을 다양한 출력 형태로 재사용
*/
@Injectable()
export class PipelineService {
constructor(
private readonly priceAnalyzer: PriceAnalyzer,
private readonly sentimentAnalyzer: SentimentAnalyzer,
private readonly portfolioAnalyzer: PortfolioAnalyzer,
private readonly personaFormatter: PersonaFormatter,
private readonly reportGenerator: ReportGenerator,
) {}
/**
* 챗봇 파이프라인
* 분석 → Persona 포맷팅 → SSE 스트리밍
*/
async executeChatPipeline(input: ChatPipelineInput): Promise<string> {
// 1. 데이터 수집 (외부에서 주입됨)
const { priceData, sentimentData, userMessage, chatHistory } = input;
// 2. 병렬 분석 실행
const [priceAnalysis, sentimentAnalysis] = await Promise.all([
this.priceAnalyzer.analyze(priceData),
this.sentimentAnalyzer.analyze(sentimentData),
]);
// 3. Persona 포맷팅 (LLM 사용)
return this.personaFormatter.format({
userMessage,
chatHistory,
analyses: [priceAnalysis, sentimentAnalysis],
});
}
/**
* 리포트 파이프라인 (B2B)
* 분석 → 구조화된 JSON/PDF
*/
async executeReportPipeline(input: ReportPipelineInput): Promise<Report> {
const { priceData, sentimentData, portfolioData } = input;
// 병렬 분석 실행
const [priceAnalysis, sentimentAnalysis, portfolioAnalysis] = await Promise.all([
this.priceAnalyzer.analyze(priceData),
this.sentimentAnalyzer.analyze(sentimentData),
this.portfolioAnalyzer.analyze(portfolioData),
]);
// 리포트 생성 (LLM 미사용, 템플릿 기반)
return this.reportGenerator.generate({
analyses: [priceAnalysis, sentimentAnalysis, portfolioAnalysis],
format: 'json', // or 'pdf'
});
}
/**
* 시그널 파이프라인 (알림용)
* 분석 → 시그널 추출 → 알림 트리거
*/
async executeSignalPipeline(input: SignalPipelineInput): Promise<Signal[]> {
const { priceData, sentimentData } = input;
const [priceAnalysis, sentimentAnalysis] = await Promise.all([
this.priceAnalyzer.analyze(priceData),
this.sentimentAnalyzer.analyze(sentimentData),
]);
// 모든 분석에서 시그널 수집
const allSignals = [
...priceAnalysis.signals,
...sentimentAnalysis.signals,
];
// 강한 시그널만 필터링 (strength > 0.7)
return allSignals.filter(s => s.strength > 0.7);
}
}| Phase | Module | Description |
|---|---|---|
| Phase 1 (MVP) | PriceAnalyzer | 가격 기술적 분석 |
| Phase 1 (MVP) | SentimentAnalyzer | Fear & Greed 기반 심리 분석 |
| Phase 1 (MVP) | PortfolioAnalyzer | 포트폴리오 P&L, 분산도 |
| Phase 2 | TrendAnalyzer | 장기 트렌드 패턴 인식 |
| Phase 2 | OnchainAnalyzer | 온체인 데이터 분석 |
| Phase 3 | SocialAnalyzer | 소셜 미디어 감성 분석 |
| Phase 3 | WhaleAnalyzer | 고래 지갑 추적 |
| Phase 4 | CorrelationAnalyzer | 자산 간 상관관계 |
| Phase 4 | MacroAnalyzer | 거시경제 지표 분석 |
Note: 기존 Agent Pipeline은 Modular Architecture 위에서 동작합니다. Manager → Analyst → Persona → Risk 흐름은 유지하되, 내부적으로 Analysis Modules를 활용합니다.
용어 정의
- 페르소나 (Persona): 대화 주체. 캐릭터 + LLM 조합 (예: 월렛 버핏 = Claude, 사토시 현자 = GPT)
- 에이전트 (Agent): 페르소나 내부의 기능 단위 (Manager, Analyst, Persona, Risk)
- 에이전트 파이프라인: 단일 페르소나 내에서 에이전트들이 순차적으로 처리하는 흐름
MVP에서는 월렛 버핏 (Claude) 페르소나 1개만 사용합니다. Phase 2+에서 다른 LLM 기반 페르소나 (ChatGPT, Gemini 등)가 추가됩니다.
graph TD
A[User Message] --> B[Manager Agent - Haiku 4]
B -->|Intent: market_data| C[Analyst Agent - Haiku 4]
B -->|Intent: advice| D[Analyst + Persona Agent - Sonnet 4]
B -->|Intent: general| E[Persona Agent - Sonnet 4]
C --> F[Risk Agent - Haiku 4]
D --> F
E --> F
F --> G[Final Response]
Purpose: 사용자 메시지의 의도 분석 및 엔티티 추출
// src/modules/ai-agents/manager.agent.ts
interface ManagerOutput {
intent: 'market_data' | 'advice' | 'portfolio' | 'general';
entities: {
symbols?: string[]; // ["BTC", "ETH"]
timeframe?: string; // "24h", "7d", "30d", "1y"
};
needsMarketData: boolean;
}
@Injectable()
export class ManagerAgent {
constructor(private readonly llmService: LLMService) {}
/**
* Analyze user intent and extract entities
* Uses LLM (Ollama or Anthropic) for classification
*/
async analyze(userMessage: string): Promise<ManagerOutput> {
if (!this.llmService.isAvailable()) {
return this.fallbackAnalysis(userMessage);
}
// LLM-based intent classification
const response = await this.llmService.chat([
{ role: 'system', content: this.getSystemPrompt() },
{ role: 'user', content: userMessage },
], {
maxTokens: 200,
temperature: 0, // Deterministic for classification
});
return JSON.parse(response.content);
}
/**
* Fallback intent analysis using keyword matching
* Used when LLM is unavailable
*/
private fallbackAnalysis(userMessage: string): ManagerOutput {
const lowerMessage = userMessage.toLowerCase();
// Extract crypto symbols
const symbols: string[] = [];
if (lowerMessage.includes('btc') || lowerMessage.includes('bitcoin')) symbols.push('BTC');
if (lowerMessage.includes('eth') || lowerMessage.includes('ethereum')) symbols.push('ETH');
if (lowerMessage.includes('sol') || lowerMessage.includes('solana')) symbols.push('SOL');
if (lowerMessage.includes('bnb') || lowerMessage.includes('binance')) symbols.push('BNB');
if (lowerMessage.includes('doge') || lowerMessage.includes('dogecoin')) symbols.push('DOGE');
if (lowerMessage.includes('xrp') || lowerMessage.includes('ripple')) symbols.push('XRP');
// Extract timeframe
let timeframe: string | undefined;
if (lowerMessage.includes('today') || lowerMessage.includes('24h')) timeframe = '24h';
else if (lowerMessage.includes('week') || lowerMessage.includes('7d')) timeframe = '7d';
else if (lowerMessage.includes('month') || lowerMessage.includes('30d')) timeframe = '30d';
else if (lowerMessage.includes('year') || lowerMessage.includes('1y')) timeframe = '1y';
// Determine intent by keywords
const priceKeywords = ['price', 'cost', 'value', 'worth', 'chart', 'market'];
const adviceKeywords = ['should', 'recommend', 'advice', 'opinion', 'think'];
const portfolioKeywords = ['portfolio', 'my trades', 'my investments'];
let intent: ManagerOutput['intent'] = 'general';
let needsMarketData = false;
if (portfolioKeywords.some(k => lowerMessage.includes(k))) {
intent = 'portfolio';
} else if (priceKeywords.some(k => lowerMessage.includes(k))) {
intent = 'market_data';
needsMarketData = true;
} else if (adviceKeywords.some(k => lowerMessage.includes(k))) {
intent = 'advice';
needsMarketData = symbols.length > 0;
}
return {
intent,
entities: { symbols: symbols.length > 0 ? symbols : undefined, timeframe },
needsMarketData,
};
}
}Example:
const exampleInput = "비트코인 지금 어때?";
const exampleOutput: ManagerOutput = {
intent: "advice",
entities: { symbols: ["BTC"] },
needsMarketData: true
};Fallback Mechanism:
- LLM 불가 시 키워드 기반 분석으로 자동 전환
- 6개 코인 심볼 및 4가지 타임프레임 인식 가능
Purpose: 실시간 시장 데이터 수집 (LLM 불필요, 순수 데이터 조회)
// src/modules/ai-agents/analyst.agent.ts
interface PriceData {
symbol: string; // "BTC", "ETH", etc.
price: number; // Current USD price
change24h: number; // 24h change percentage
}
interface FearGreedData {
value: number; // 0-100
classification: string; // "Extreme Fear", "Fear", "Neutral", "Greed", "Extreme Greed"
timestamp: Date; // Last update time
}
interface AnalystOutput {
prices: PriceData[]; // Requested symbols' prices
fearGreed?: FearGreedData; // Fear & Greed Index (optional)
}
@Injectable()
export class AnalystAgent {
constructor(
private readonly marketService: MarketService,
private readonly httpService: HttpService,
) {}
/**
* Analyze market data based on Manager's output
* No LLM needed - pure data fetching
*/
async analyze(managerOutput: ManagerOutput): Promise<AnalystOutput> {
const output: AnalystOutput = { prices: [] };
// Fetch prices for requested symbols
const symbols = managerOutput.entities?.symbols || ['BTC'];
for (const symbol of symbols) {
const priceData = await this.marketService.getPrice(symbol);
if (priceData) {
output.prices.push(priceData);
}
}
// Fetch Fear & Greed Index if market data needed
if (managerOutput.needsMarketData) {
output.fearGreed = await this.fetchFearGreedIndex();
}
return output;
}
/**
* Fetch Fear & Greed Index from Alternative.me API
* Cached in Valkey (5-min TTL)
*/
private async fetchFearGreedIndex(): Promise<FearGreedData | undefined> {
// API: https://api.alternative.me/fng/
// Response: { data: [{ value: "25", value_classification: "Extreme Fear", timestamp: "..." }] }
}
}Data Sources:
- Prices: MarketService (Binance/Gate.io WebSocket → Valkey cache)
- Fear & Greed: Alternative.me REST API (5-min TTL cache)
Purpose: Wallet Buffett 캐릭터로 한국어 응답 생성
// src/modules/ai-agents/persona.agent.ts
@Injectable()
export class PersonaAgent {
constructor(private readonly llmService: LLMService) {}
/**
* Generate response as Wallet Buffett persona
* Uses LLM (Ollama or Anthropic) for response generation
*/
async generateResponse(
userMessage: string,
chatHistory: any[] = [],
analystData?: AnalystOutput,
): Promise<string> {
if (!this.llmService.isAvailable()) {
return this.getFallbackResponse();
}
// Build user prompt with market data context
const promptWithContext = this.buildUserPrompt(userMessage, analystData);
const response = await this.llmService.chat([
{ role: 'system', content: this.getSystemPrompt() },
...chatHistory,
{ role: 'user', content: promptWithContext },
], {
maxTokens: 1024,
temperature: 0.7,
});
return response.content;
}
/**
* Inject market data into user prompt
*/
private buildUserPrompt(userMessage: string, analystData?: AnalystOutput): string {
if (!analystData) return userMessage;
let context = '\n\n[Current Market Data]\n';
// Add price data
if (analystData.prices.length > 0) {
context += analystData.prices
.map(p => `- ${p.symbol}: $${p.price.toLocaleString()} (${p.change24h >= 0 ? '+' : ''}${p.change24h.toFixed(2)}%)`)
.join('\n');
}
// Add Fear & Greed
if (analystData.fearGreed) {
context += `\n- Fear & Greed Index: ${analystData.fearGreed.value}/100 (${analystData.fearGreed.classification})`;
}
return `${userMessage}${context}`;
}
/**
* Fallback response when LLM is unavailable
*/
private getFallbackResponse(): string {
return '자네, 죄송하네만 현재 시스템에 일시적인 문제가 있어 응답을 생성할 수 없네. 잠시 후 다시 시도해 주게.';
}
}System Prompt (Korean Persona):
const personaSystemPrompt = `
You are Wallet Buffett (월렛 버핏), an AI investment mentor inspired by Warren Buffett.
Personality:
- Experienced, calm, and wise
- Uses Korean honorific mixing: "자네", "~일세", "~하게"
- Provides insights, not just information
- Focuses on long-term value, not short-term speculation
Speaking Style (Korean):
- Start with "자네," when addressing the user
- End sentences with "~일세", "~하게", "~네" for a wise, elderly tone
- Use expressions like:
- "가치 투자의 관점에서 보자면..."
- "시장은 단기적으로는 투표 기계지만, 장기적으로는 저울일세."
- "다른 사람들이 탐욕스러울 때 두려워하고, 두려워할 때 탐욕스러워야 하네."
- "자네가 이해하지 못하는 것에는 투자하지 말게."
Rules:
- NEVER give direct trading signals ("Buy now", "Sell immediately", "지금 사세요")
- ALWAYS use conditional language ("~할 수 있다", "~를 고려해볼 만하다", "~일 수 있네")
- ALWAYS cite provided market data when available (prices, Fear & Greed Index)
- NEVER hallucinate numbers - use provided data or say "그 데이터는 없네"
- Focus on EDUCATION and helping users develop their own investment thesis
Current focus: Cryptocurrency markets (BTC, ETH, SOL, BNB, DOGE, XRP)
Remember: You are an educational mentor, not a financial advisor. Always emphasize that users should do their own research (DYOR).
`;Purpose: AI 응답 품질 검증 (환각, 직접 신호, 편향 감지)
// src/modules/ai-agents/risk.agent.ts
interface RiskOutput {
approved: boolean;
issues: string[]; // Detected issues list
recommendation: 'approve' | 'revise' | 'reject';
}
@Injectable()
export class RiskAgent {
constructor(private readonly llmService: LLMService) {}
/**
* Validate AI response for hallucination, direct signals, and bias
* Uses LLM for semantic analysis
*/
async validate(
personaResponse: string,
analystData?: AnalystOutput,
): Promise<RiskOutput> {
// Fallback: keyword-based validation if LLM unavailable
if (!this.llmService.isAvailable()) {
return this.fallbackValidation(personaResponse);
}
// LLM-based validation with structured prompt
const systemPrompt = this.getValidationPrompt(analystData);
const response = await this.llmService.chat([
{ role: 'system', content: systemPrompt },
{ role: 'user', content: personaResponse },
], {
maxTokens: 300,
temperature: 0, // Deterministic for consistency
});
return JSON.parse(response.content);
}
/**
* Fallback validation using keyword detection
* Used when LLM is unavailable
*/
private fallbackValidation(response: string): RiskOutput {
const issues: string[] = [];
const lowerResponse = response.toLowerCase();
// Check for direct trading signals
const directSignals = [
'지금 사세요', '즉시 매수', '바로 팔아야',
'buy now', 'sell immediately', 'must buy'
];
if (directSignals.some(signal => lowerResponse.includes(signal.toLowerCase()))) {
issues.push('Direct trading signal detected');
}
// Check for extreme language (bias)
const extremeWords = [
'반드시', '무조건', '확실히', '100%', '절대',
'guaranteed', 'definitely', 'absolutely'
];
if (extremeWords.some(word => lowerResponse.includes(word.toLowerCase()))) {
issues.push('Extreme/biased language detected');
}
return {
approved: issues.length === 0,
issues,
recommendation: issues.length === 0 ? 'approve' : 'revise',
};
}
}Validation Criteria:
- Hallucination Check: 언급된 가격이 Analyst 데이터와 1% 이상 차이나면 거부
- Direct Signal Check: "지금 사세요", "즉시 매수" 등 직접적 거래 권유 감지
- Bias Check: "반드시", "무조건", "100%" 등 극단적 표현 감지
Fallback Mechanism:
- LLM 불가 시 키워드 기반 검증으로 자동 전환
- 기본적인 직접 신호 및 극단적 표현 감지 가능
Note: Valkey is 100% Redis-compatible, so all Redis clients and commands work identically.
interface CacheKeys {
// Market data (TTL: 5 minutes)
marketPrice: `market:price:${symbol}`; // "43250.50"
marketFearGreed: `market:fear-greed`; // "25"
marketHistory: `market:history:${symbol}:24h`; // JSON array
// User context (TTL: 1 hour)
userRecentMessages: `user:${userId}:recent-messages`; // Last 20 messages
userProfile: `user:${userId}:profile`; // Inferred profile
// Rate limiting (TTL: 1 minute)
rateLimit: `ratelimit:${userId}:chat`; // Request count
}interface CacheInvalidation {
marketData: "TTL-based (5 minutes)";
userContext: "Update on new message";
rateLimit: "TTL-based (1 minute)";
}interface JobQueues {
memoryExtraction: {
name: 'memory-extraction';
processor: 'memory-extraction.processor.ts';
trigger: '10 minutes after last message';
priority: 'low';
};
notifications: {
name: 'notifications';
processor: 'notification.processor.ts';
trigger: 'Market sudden change detected';
priority: 'high';
};
marketAnalysis: {
name: 'market-analysis';
processor: 'market-analyzer.processor.ts';
trigger: 'Every 15 minutes (@nestjs/schedule)';
priority: 'medium';
};
}interface MemoryExtractionJob {
chatId: string;
userId: string;
messages: Message[];
}
async function processMemoryExtraction(job: Job<MemoryExtractionJob>) {
const { messages, userId } = job.data;
// Call Haiku 4 to extract profile
const profile = await extractProfile(messages);
// Update user profile
await prisma.user.update({
where: { id: userId },
data: {
riskProfile: profile.riskProfile,
interests: profile.interests
}
});
}interface NotificationJob {
type: 'market_alert' | 'portfolio_update';
userId?: string; // null = broadcast
data: {
symbol: string;
change: number;
message: string;
};
}
async function processNotification(job: Job<NotificationJob>) {
const { type, userId, data } = job.data;
// Send push notification
await pushService.send(userId, {
title: `${data.symbol} Alert`,
body: data.message
});
// Send Discord webhook if change > 7%
if (Math.abs(data.change) > 7) {
await discordService.sendAlert(data);
}
// Save to DB
await prisma.notification.create({ ... });
}선택 근거:
- 실시간성: 가격 변동 즉시 감지 및 알림 (기존 15분 폴링 → <1초 실시간)
- 이중화: Binance 장애 시 Gate.io로 자동 전환 (무중단 운영)
- 비용 효율성: WebSocket은 무료, 별도 API 구독 불필요
- 정확성: 거래소 실제 체결가 사용
interface WebSocketConfig {
primary: {
provider: "Binance";
endpoint: "wss://stream.binance.com:9443/ws";
streams: ["btcusdt@ticker", "ethusdt@ticker", "solusdt@ticker", "bnbusdt@ticker", "dogeusdt@ticker", "xrpusdt@ticker"];
reconnect: {
maxRetries: 3;
backoff: "exponential"; // 1s → 2s → 4s
};
};
fallback: {
provider: "Gate.io";
endpoint: "wss://api.gateio.ws/ws/v4/";
streams: ["spot.tickers"];
reconnect: {
maxRetries: 3;
backoff: "exponential";
};
};
}// market/websocket-price.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import WebSocket from 'ws';
import { EventEmitter } from 'events';
@Injectable()
export class WebSocketPriceService extends EventEmitter implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(WebSocketPriceService.name);
private binanceWs: WebSocket | null = null;
private gateioWs: WebSocket | null = null;
private activeProvider: 'binance' | 'gateio' = 'binance';
private binanceRetries = 0;
private gateioRetries = 0;
private readonly SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT', 'DOGEUSDT', 'XRPUSDT'];
private readonly MAX_RETRIES = 3;
constructor(
@Inject('VALKEY_CLIENT') private readonly valkey: Redis,
private readonly notificationQueue: Queue
) {
super();
}
async onModuleInit() {
await this.connectBinance();
}
async onModuleDestroy() {
this.disconnectAll();
}
// Primary: Binance WebSocket
private async connectBinance() {
try {
const streams = this.SYMBOLS.map(s => `${s.toLowerCase()}@ticker`).join('/');
const url = `wss://stream.binance.com:9443/stream?streams=${streams}`;
this.binanceWs = new WebSocket(url);
this.binanceWs.on('open', () => {
this.logger.log('✅ Binance WebSocket connected');
this.activeProvider = 'binance';
this.binanceRetries = 0;
// Gate.io 연결 끊기 (Primary 정상 동작 시)
if (this.gateioWs) {
this.gateioWs.close();
this.gateioWs = null;
}
});
this.binanceWs.on('message', async (data: WebSocket.Data) => {
if (this.activeProvider !== 'binance') return;
const parsed = JSON.parse(data.toString());
await this.handleBinanceMessage(parsed);
});
this.binanceWs.on('error', (error) => {
this.logger.error(`Binance WebSocket error: ${error.message}`);
});
this.binanceWs.on('close', async () => {
this.logger.warn('⚠️ Binance WebSocket disconnected');
await this.handleBinanceDisconnect();
});
// Heartbeat (ping/pong)
setInterval(() => {
if (this.binanceWs?.readyState === WebSocket.OPEN) {
this.binanceWs.ping();
}
}, 30000); // 30초마다
} catch (error) {
this.logger.error(`Failed to connect Binance: ${error.message}`);
await this.handleBinanceDisconnect();
}
}
// Fallback: Gate.io WebSocket
private async connectGateio() {
try {
this.gateioWs = new WebSocket('wss://api.gateio.ws/ws/v4/');
this.gateioWs.on('open', () => {
this.logger.log('✅ Gate.io WebSocket connected (fallback)');
this.activeProvider = 'gateio';
this.gateioRetries = 0;
// Subscribe to tickers
const subscribeMsg = {
time: Math.floor(Date.now() / 1000),
channel: 'spot.tickers',
event: 'subscribe',
payload: this.SYMBOLS.map(s => s.replace('USDT', '_USDT'))
};
this.gateioWs.send(JSON.stringify(subscribeMsg));
});
this.gateioWs.on('message', async (data: WebSocket.Data) => {
if (this.activeProvider !== 'gateio') return;
const parsed = JSON.parse(data.toString());
await this.handleGateioMessage(parsed);
});
this.gateioWs.on('error', (error) => {
this.logger.error(`Gate.io WebSocket error: ${error.message}`);
});
this.gateioWs.on('close', async () => {
this.logger.warn('⚠️ Gate.io WebSocket disconnected');
await this.handleGateioDisconnect();
});
} catch (error) {
this.logger.error(`Failed to connect Gate.io: ${error.message}`);
await this.handleGateioDisconnect();
}
}
// Binance 메시지 처리
private async handleBinanceMessage(message: any) {
const { data } = message;
if (!data?.s || !data?.c) return;
const symbol = data.s.replace('USDT', ''); // BTCUSDT → BTC
const price = parseFloat(data.c);
const priceChange24h = parseFloat(data.P); // 24h 변동률
await this.updatePrice(symbol, price, priceChange24h);
}
// Gate.io 메시지 처리
private async handleGateioMessage(message: any) {
if (message.event !== 'update' || message.channel !== 'spot.tickers') return;
const { result } = message;
if (!result?.currency_pair || !result?.last) return;
const symbol = result.currency_pair.replace('_USDT', ''); // BTC_USDT → BTC
const price = parseFloat(result.last);
const priceChange24h = parseFloat(result.change_percentage);
await this.updatePrice(symbol, price, priceChange24h);
}
// 가격 업데이트 및 알림 트리거
private async updatePrice(symbol: string, currentPrice: number, change24h: number) {
const cacheKey = `market:price:${symbol}`;
const previousPrice = await this.valkey.get(cacheKey);
// Valkey에 캐싱 (5분 TTL)
await this.valkey.setex(cacheKey, 300, currentPrice.toString());
// 실시간 가격 이벤트 발행 (SSE로 프론트엔드 전송)
this.emit('price_update', { symbol, price: currentPrice, change24h });
// 급격한 변동 감지
if (previousPrice) {
const prev = parseFloat(previousPrice);
const changePercent = ((currentPrice - prev) / prev) * 100;
if (this.isSuddenChange(symbol, changePercent)) {
this.logger.warn(`🚨 ${symbol} sudden change: ${changePercent.toFixed(2)}%`);
await this.notificationQueue.add('market_alert', {
type: 'market_alert',
data: {
symbol,
change: changePercent,
price: currentPrice,
message: `${symbol} ${changePercent > 0 ? '급등' : '급락'} ${Math.abs(changePercent).toFixed(2)}%`
}
});
}
}
}
// 급격한 변동 판단
private isSuddenChange(symbol: string, changePercent: number): boolean {
const thresholds = {
BTC: 5,
ETH: 7,
default: 10
};
const threshold = thresholds[symbol] || thresholds.default;
return Math.abs(changePercent) >= threshold;
}
// Binance 재연결 로직
private async handleBinanceDisconnect() {
this.binanceRetries++;
if (this.binanceRetries <= this.MAX_RETRIES) {
const delay = Math.pow(2, this.binanceRetries - 1) * 1000; // 1s, 2s, 4s
this.logger.log(`Retrying Binance in ${delay}ms (${this.binanceRetries}/${this.MAX_RETRIES})`);
await new Promise(resolve => setTimeout(resolve, delay));
await this.connectBinance();
} else {
this.logger.error(`❌ Binance failed after ${this.MAX_RETRIES} retries. Switching to Gate.io...`);
await this.connectGateio();
}
}
// Gate.io 재연결 로직
private async handleGateioDisconnect() {
this.gateioRetries++;
if (this.gateioRetries <= this.MAX_RETRIES) {
const delay = Math.pow(2, this.gateioRetries - 1) * 1000;
this.logger.log(`Retrying Gate.io in ${delay}ms (${this.gateioRetries}/${this.MAX_RETRIES})`);
await new Promise(resolve => setTimeout(resolve, delay));
await this.connectGateio();
} else {
this.logger.error(`❌ Gate.io failed after ${this.MAX_RETRIES} retries.`);
// Binance 재시도 (무한 루프 방지를 위해 딜레이 후)
await new Promise(resolve => setTimeout(resolve, 30000)); // 30초 대기
this.binanceRetries = 0;
await this.connectBinance();
}
}
// 모든 연결 종료
private disconnectAll() {
if (this.binanceWs) {
this.binanceWs.close();
this.binanceWs = null;
}
if (this.gateioWs) {
this.gateioWs.close();
this.gateioWs = null;
}
}
// 현재 활성 제공자 확인 (모니터링용)
getActiveProvider(): string {
return this.activeProvider;
}
}// market/market.controller.ts
@Get('health/websocket')
async getWebSocketHealth() {
return {
activeProvider: this.websocketService.getActiveProvider(),
timestamp: new Date().toISOString()
};
}class HallucinationDetectedException extends BadRequestException {
constructor(actual: number, claimed: number) {
super(`AI hallucination detected: claimed ${claimed}, actual ${actual}`);
}
}
class RateLimitExceededException extends TooManyRequestsException {
constructor(limit: number) {
super(`Rate limit exceeded: ${limit} requests per minute`);
}
}
class TierLimitException extends ForbiddenException {
constructor(feature: string, requiredTier: string) {
super(`Feature '${feature}' requires ${requiredTier} tier`);
}
}@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status = exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
// Log to Sentry
Sentry.captureException(exception);
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: exception.message || 'Internal server error'
});
}
}interface PerformanceTargets {
apiResponseTime: {
metric: "95th percentile";
target: "< 200ms";
measurement: "Sentry Traces";
};
sseFirstToken: {
metric: "Time to first token";
target: "< 2s";
measurement: "Custom metric";
};
databaseQuery: {
metric: "Query duration";
target: "< 50ms";
measurement: "Prisma Logging";
};
cacheHitRate: {
metric: "Hit rate";
target: "> 80%";
measurement: "Valkey INFO stats";
};
}interface SecurityConfig {
authentication: {
provider: "Auth.js";
method: "Google OAuth";
storage: "PostgreSQL or JWT";
csrfProtection: true;
};
authorization: {
guards: ["AuthGuard", "TierGuard"];
example: "@RequireTier('pro')";
};
}class CreateTradeDto {
@IsIn(['BTC', 'ETH', 'SOL', 'BNB', 'DOGE', 'XRP'])
symbol: string;
@IsIn(['buy', 'sell'])
action: string;
@IsNumber()
@Min(0)
price: number;
}@UseGuards(ThrottlerGuard)
@Throttle(10, 60) // 10 requests per minute
async sendMessage() {
// Implementation
}interface CustomMetrics {
chatResponseTime: "histogram";
aiHallucinationRate: "counter";
marketApiCalls: "counter";
jobsCompleted: "counter";
jobsFailed: "counter";
}interface AlertingRules {
apiErrorRate: "> 5% for 5 minutes";
sseFirstToken: "> 5s for 1 minute";
databaseConnectionPool: "> 90%";
valkeyMemory: "> 80%";
bullmqQueueSize: "> 1000";
}# Application
NODE_ENV=development # development | production
PORT=3000
FRONTEND_URL=http://localhost:3000
# Database
DATABASE_URL="postgresql://user:pass@localhost:5432/sage"
SHADOW_DATABASE_URL="postgresql://user:pass@localhost:5432/sage_shadow"
# Valkey (Redis-compatible)
VALKEY_URL="valkey://localhost:6379"
# AI - LLM Provider (NEW)
LLM_PROVIDER=ollama # ollama | anthropic
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=llama2 # llama2:latest (7B Q4_0)
ANTHROPIC_API_KEY= # Production only
# Auth
GOOGLE_CLIENT_ID="..."
GOOGLE_CLIENT_SECRET="..."
NEXTAUTH_SECRET="..."
# External APIs (WebSocket)
BINANCE_WS_URL="wss://stream.binance.com:9443/ws"
GATEIO_WS_URL="wss://api.gateio.ws/ws/v4/"
# External APIs (REST - Backup)
COINGECKO_API_KEY="..."
DISCORD_WEBHOOK_URL="..."LLM Provider 설정:
-
Development:
LLM_PROVIDER=ollama- 로컬 Ollama 사용 (무료) -
Production:
LLM_PROVIDER=anthropic- Claude API 사용 (ANTHROPIC_API_KEY 필수)
Docker를 사용하면 다음과 같은 이점이 있습니다:
- 환경 격리: 로컬 시스템을 깨끗하게 유지
- 팀 협업: 모든 개발자가 동일한 환경 사용
-
빠른 설정: 새 팀원 온보딩 시
docker compose up한 번으로 완료 - 프로덕션 유사성: 실제 배포 환경과 유사한 컨테이너 기반 개발
- 여러 서비스 관리: PostgreSQL + Valkey를 한 번에 실행
version: '3.8'
services:
# PostgreSQL 18 Database
postgres:
image: postgres:18-alpine
container_name: sage-postgres
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_USER: sage
POSTGRES_PASSWORD: sage123
POSTGRES_DB: sage
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sage"]
interval: 10s
timeout: 5s
retries: 5
# Valkey 8.x (Redis-compatible cache)
valkey:
image: valkey/valkey:8-alpine
container_name: sage-valkey
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- valkey_data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
volumes:
postgres_data:
valkey_data:# 1. Docker 서비스 시작
docker compose up -d
# 2. 상태 확인
docker compose ps
# 3. 로그 확인
docker compose logs -f postgres
docker compose logs -f valkey
# 4. .env 파일 설정
DATABASE_URL=postgresql://sage:sage123@localhost:5432/sage
VALKEY_URL=valkey://localhost:6379
# 5. Prisma 마이그레이션 실행
pnpm prisma:generate
pnpm prisma:migrate
# 6. 개발 서버 시작
pnpm dev# 서비스 시작
docker compose up -d # 백그라운드에서 시작
docker compose up # 포그라운드에서 시작 (로그 실시간 확인)
# 서비스 중지
docker compose stop # 중지 (데이터 유지)
docker compose down # 중지 및 컨테이너 삭제 (데이터는 유지)
docker compose down -v # 중지 및 볼륨까지 삭제 (데이터 완전 삭제)
# 상태 확인
docker compose ps # 서비스 상태
docker compose logs postgres # PostgreSQL 로그
docker compose logs -f valkey # Valkey 로그 (실시간)
# 데이터베이스 초기화 (개발 중 필요 시)
docker compose down -v # 기존 데이터 삭제
docker compose up -d postgres # PostgreSQL만 재시작
pnpm prisma:migrate # 마이그레이션 재실행포트 충돌 시:
# 기존 PostgreSQL 중지
brew services stop postgresql@18
# 또는 docker-compose.yml에서 포트 변경
ports:
- "5433:5432" # 로컬 5433 포트 사용데이터 초기화:
# 모든 데이터 삭제 후 재시작
docker compose down -v
docker compose up -d
pnpm prisma:generate
pnpm prisma:migrate# Development server
pnpm run start:dev # Start with watch mode
pnpm run start:debug # Start in debug mode
# Build
pnpm run build # Production build
pnpm run start:prod # Run production build
# Database (Prisma)
npx prisma generate # Generate Prisma Client
npx prisma migrate dev # Create and apply migration
npx prisma migrate deploy # Apply migrations in production
npx prisma studio # Open Prisma Studio (DB GUI)
# Testing
pnpm run test # Run unit tests
pnpm run test:watch # Run tests in watch mode
pnpm run test:cov # Generate coverage report
pnpm run test:e2e # Run e2e tests
# Linting
pnpm run lint # Run ESLint
pnpm run format # Format with PrettierDocument Version: 4.0 Last Updated: 2026-01-23 Architecture: Modular Architecture (AI Quant Team) + Layered (Clean Lite) Tech Stack: Nest.js 10.x + Prisma 5.19.1 + PostgreSQL 18 + Valkey 8.1.5 Maintainer: Sam (dev@5010.tech)
4.0 (2026-01-23)
-
Modular Architecture 도입 (AI Quant Team 비전)
- 독립적이고 재사용 가능한 Analysis Module 구조
- 데이터 주입 패턴 (Factory Pattern) 적용
- 규칙 기반 분석 + LLM 포맷팅 분리
-
새로운 Analysis Modules:
- PriceAnalyzer: 가격 기술적 분석 (MA, 지지/저항, 변동성)
- SentimentAnalyzer: Fear & Greed 기반 심리 분석
- PortfolioAnalyzer: 포트폴리오 P&L, 분산도, 리스크 분석
-
Pipeline Composition 개념 추가:
- ChatPipeline: 분석 → Persona 포맷팅 → SSE 스트리밍
- ReportPipeline: 분석 → 구조화된 JSON/PDF (B2B)
- SignalPipeline: 분석 → 시그널 추출 → 알림 트리거
- Module Roadmap 추가 (Phase 1-4)
- 기존 Agent Pipeline은 Legacy 호환으로 유지
3.2 (2026-01-07)
- LLM Provider 추상화 추가 (Ollama + Anthropic 지원)
- Agent Pipeline 상세 구현 문서화:
- Manager Agent: LLM 분류 + fallback 키워드 분석
- Analyst Agent: 상세한 AnalystOutput 인터페이스 (PriceData, FearGreedData)
- Persona Agent: LLMService 통합, 한국어 페르소나 프롬프트 상세화
- Risk Agent: LLM 검증 + fallback 키워드 검증
- 모든 Agent에 Fallback 메커니즘 문서화 (LLM 불가 시 대응)
- 환경 변수 추가: LLM_PROVIDER, OLLAMA_BASE_URL, OLLAMA_MODEL
3.1 (2026-01-07)
- Prisma 버전을 5.x → 5.19.1로 구체화
- Prisma v7 호환성 문제 명시 (설정 파일 분리)
- Valkey 버전을 8.x → 8.1.5로 구체화
v3.0 (2025-12-26):
- PostgreSQL 16 → 18: 5-year LTS, JSON 30% faster, query optimizer improvements
- Redis 7.x → Valkey 8.x: License stability, Linux Foundation backing
- Added RiskProfile ENUM type for type safety
- Added UserConsent table for GDPR compliance
- Added architecture flow diagrams (Registration, Chat, Market Analysis)
🏠 Home
- 📚 sage-docs (Documentation)
- 🎨 sage-frontend (React App)
- ⚙️ sage-backend (Nest.js API)
- 🏗️ sage-infra (Pulumi IaC)