# balance - 계좌 관리 ## 개요 **balance** 컴포넌트는 증권사 API 통합 및 계좌 자산 관리를 담당합니다. ### 책임 - 증권사 API 인증 및 세션 관리 - 다중 계좌 등록/수정/삭제 - 계좌 잔고 및 포지션 조회 - 시장 시세 조회 (현재가, 호가, 과거 데이터) - 주문 처리 (제출, 취소, 상태 조회) ### 의존성 ```mermaid graph LR Balance[balance] --> Broker1[한국투자증권 API] Balance --> Broker2[삼성증권 API] Balance --> Broker3[키움증권 API] Mgmt[mgmt] --> Balance Scheduler[scheduler] --> Balance Monitor[monitor] --> Balance Balance --> DB[(Database)] Balance --> Cache[(Redis Cache)] style Balance fill:#00BCD4,color:#fff ``` ## 주요 기능 ### 1. 계좌 연동 관리 #### 1.1 계좌 등록 ```typescript /** * 새로운 증권사 계좌를 등록합니다 */ async function registerAccount(params: { brokerId: string // 증권사 식별자 (예: 'korea_investment') accountNumber: string // 계좌 번호 accountName: string // 계좌 별칭 accountType: AccountType // 계좌 유형 credentials: { // 인증 정보 apiKey: string apiSecret: string accountPassword?: string } }): Promise ``` **처리 흐름**: 1. 증권사 식별자 검증 2. API 키 암호화 (AES-256) 3. 테스트 연결 수행 (인증 정보 유효성 검증) 4. 계좌 정보 데이터베이스 저장 5. 초기 잔고 조회 #### 1.2 API 키 암호화 저장 ```typescript /** * API 키를 AES-256으로 암호화하여 저장 */ function encryptCredentials(credentials: { apiKey: string apiSecret: string accountPassword?: string }): EncryptedCredentials { const iv = crypto.randomBytes(16) const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv) const encrypted = Buffer.concat([ cipher.update(JSON.stringify(credentials), 'utf8'), cipher.final() ]) return { encryptedData: encrypted.toString('base64'), iv: iv.toString('base64'), algorithm: 'aes-256-gcm' } } ``` ### 2. 자산 조회 #### 2.1 현재 잔고 조회 ```typescript /** * 계좌의 현재 잔고를 조회합니다 */ async function getCurrentBalance(accountId: string): Promise { const account = await getAccount(accountId) const adapter = getBrokerAdapter(account.brokerId) // 증권사 API 호출 const balance = await adapter.getBalance(account) // 캐시 업데이트 await cache.set(`balance:${accountId}`, balance, 60) // 60초 TTL return balance } ``` **반환 데이터**: ```typescript interface Balance { accountId: string cash: { krw: number // 원화 잔고 usd: number // 달러 잔고 (해외 주식용) } positions: Position[] // 보유 종목 totalEquity: number // 총 자산 평가액 buyingPower: number // 매수 가능 금액 withdrawableCash: number // 출금 가능 금액 timestamp: Date // 조회 시점 } ``` #### 2.2 포트폴리오 스냅샷 ```typescript /** * 자산 클래스별 비중 및 종목별 손익률을 포함한 포트폴리오 스냅샷 */ async function getPortfolioSnapshot(accountId: string): Promise { const balance = await getCurrentBalance(accountId) // 자산 클래스별 비중 계산 const assetAllocation = calculateAssetAllocation(balance.positions) // 종목별 손익률 계산 const positionsWithPnL = balance.positions.map(position => ({ ...position, unrealizedPnLPct: (position.unrealizedPnL / (position.averagePrice * position.quantity)) * 100 })) return { ...balance, assetAllocation, positions: positionsWithPnL } } ``` ### 3. 시세 조회 #### 3.1 현재가 조회 ```typescript /** * 종목의 현재가 및 호가 정보를 조회합니다 */ async function getCurrentPrice(symbol: string): Promise { // 캐시 우선 조회 (실시간 시세) const cached = await cache.get(`quote:${symbol}`) if (cached && Date.now() - cached.timestamp < 5000) { // 5초 이내 캐시 return cached } // 증권사 API 호출 const adapter = getBrokerAdapter('default') const quote = await adapter.getQuote(symbol) // 캐시 업데이트 await cache.set(`quote:${symbol}`, quote, 10) // 10초 TTL return quote } ``` **반환 데이터**: ```typescript interface Quote { symbol: string // 종목 코드 price: number // 현재가 change: number // 전일 대비 변동 changePct: number // 변동률 (%) volume: number // 거래량 bidPrice: number // 매수 호가 1 askPrice: number // 매도 호가 1 bidSize: number // 매수 호가 수량 askSize: number // 매도 호가 수량 timestamp: Date // 시세 시간 } ``` #### 3.2 과거 가격 조회 ```typescript /** * 과거 가격 데이터를 조회합니다 (백테스트용) */ async function getHistoricalPrices(params: { symbol: string from: Date to: Date interval: Interval // '1d', '1w', '1M' 등 }): Promise { const adapter = getBrokerAdapter('default') const prices = await adapter.getHistoricalPrices(params) // 배당 및 액면분할 조정 const adjustedPrices = await adjustForCorporateActions(prices, params.symbol) return adjustedPrices } ``` ### 4. 주문 처리 #### 4.1 주문 제출 ```typescript /** * 주문을 제출합니다 */ async function placeOrder(order: Order): Promise { // 1. 주문 검증 await validateOrder(order) // 2. 증권사 어댑터 선택 const account = await getAccount(order.accountId) const adapter = getBrokerAdapter(account.brokerId) // 3. 주문 제출 try { const result = await adapter.submitOrder(order) // 4. 주문 상태 저장 await saveOrderStatus(result) // 5. 감사 로그 기록 await audit.logEvent({ eventType: 'ORDER', action: 'CREATE', entity: 'Order', entityId: result.orderId!, after: result }) return result } catch (error) { // 에러 로깅 await logger.error('Order submission failed', { order, error }) // 알림 발송 await monitor.sendAlert({ type: 'EXECUTION', severity: 'ERROR', message: `주문 제출 실패: ${order.symbol} ${order.side} ${order.quantity}`, metadata: { order, error } }) throw error } } ``` **주문 검증**: ```typescript async function validateOrder(order: Order): Promise { // 계좌 존재 여부 확인 const account = await getAccount(order.accountId) if (!account.isActive) { throw new Error('계좌가 비활성 상태입니다') } // 주문 수량 검증 if (order.quantity <= 0) { throw new Error('주문 수량은 0보다 커야 합니다') } // 호가 단위 검증 (지정가 주문인 경우) if (order.orderType === 'LIMIT' && order.price) { validatePriceTick(order.price, order.symbol) } // 잔고 확인 (매수 주문인 경우) if (order.side === 'BUY') { const balance = await getCurrentBalance(order.accountId) const requiredCash = order.quantity * (order.price || await getCurrentPrice(order.symbol).price) if (balance.buyingPower < requiredCash) { throw new Error('매수 가능 금액이 부족합니다') } } } ``` #### 4.2 주문 취소 ```typescript /** * 미체결 주문을 취소합니다 */ async function cancelOrder(orderId: string): Promise { // 1. 주문 정보 조회 const order = await getOrder(orderId) if (!['PENDING', 'SUBMITTED', 'PARTIALLY_FILLED'].includes(order.status)) { throw new Error(`취소 불가능한 주문 상태: ${order.status}`) } // 2. 증권사 어댑터로 취소 요청 const account = await getAccount(order.accountId) const adapter = getBrokerAdapter(account.brokerId) const success = await adapter.cancelOrder(orderId) if (success) { // 3. 주문 상태 업데이트 await updateOrderStatus(orderId, 'CANCELLED') // 4. 감사 로그 기록 await audit.logEvent({ eventType: 'ORDER', action: 'CANCEL', entity: 'Order', entityId: orderId }) } return success } ``` #### 4.3 주문 상태 조회 ```typescript /** * 주문의 현재 상태를 조회합니다 */ async function getOrderStatus(orderId: string): Promise { const order = await getOrder(orderId) // 최종 상태가 아닌 경우 증권사 API에서 최신 상태 조회 if (!['FILLED', 'CANCELLED', 'REJECTED', 'EXPIRED'].includes(order.status)) { const account = await getAccount(order.accountId) const adapter = getBrokerAdapter(account.brokerId) const latestStatus = await adapter.getOrderStatus(orderId) // 상태가 변경된 경우 업데이트 if (latestStatus.status !== order.status) { await updateOrderStatus(orderId, latestStatus.status, latestStatus) } return latestStatus } return { orderId: order.orderId!, status: order.status, filledQuantity: order.filledQuantity, averageFillPrice: order.averageFillPrice } } ``` ## 증권사 추상화 인터페이스 ### BrokerAdapter 인터페이스 ```typescript /** * 모든 증권사 어댑터가 구현해야 하는 인터페이스 */ interface BrokerAdapter { /** * 증권사 API에 연결하고 세션을 생성합니다 */ connect(credentials: Credentials): Promise /** * 계좌 잔고를 조회합니다 */ getBalance(account: Account): Promise /** * 종목의 현재 시세를 조회합니다 */ getQuote(symbol: string): Promise /** * 과거 가격 데이터를 조회합니다 */ getHistoricalPrices(params: HistoricalPricesParams): Promise /** * 주문을 제출합니다 */ submitOrder(order: Order): Promise /** * 주문을 취소합니다 */ cancelOrder(orderId: string): Promise /** * 주문 상태를 조회합니다 */ getOrderStatus(orderId: string): Promise /** * 주문 이력을 조회합니다 */ getOrderHistory(account: Account, from: Date, to: Date): Promise /** * 세션을 종료합니다 */ disconnect(): Promise } ``` ### 구현 예시: 한국투자증권 Adapter ```typescript class KoreaInvestmentAdapter implements BrokerAdapter { private accessToken?: string private tokenExpiry?: Date async connect(credentials: Credentials): Promise { // OAuth 토큰 발급 const response = await axios.post('https://openapi.koreainvestment.com/oauth2/token', { grant_type: 'client_credentials', appkey: credentials.apiKey, appsecret: credentials.apiSecret }) this.accessToken = response.data.access_token this.tokenExpiry = new Date(Date.now() + response.data.expires_in * 1000) return { sessionId: response.data.access_token, expiresAt: this.tokenExpiry } } async getBalance(account: Account): Promise { await this.ensureConnected() const response = await axios.get( 'https://openapi.koreainvestment.com/uapi/domestic-stock/v1/trading/inquire-balance', { headers: { authorization: `Bearer ${this.accessToken}`, appkey: account.credentials.apiKey, appsecret: account.credentials.apiSecret, tr_id: 'TTTC8434R' }, params: { CANO: account.accountNumber.slice(0, 8), ACNT_PRDT_CD: account.accountNumber.slice(8, 10) } } ) // API 응답을 공통 Balance 형식으로 변환 return this.transformBalance(response.data) } async submitOrder(order: Order): Promise { await this.ensureConnected() const response = await axios.post( 'https://openapi.koreainvestment.com/uapi/domestic-stock/v1/trading/order-cash', { CANO: order.accountId.slice(0, 8), ACNT_PRDT_CD: order.accountId.slice(8, 10), PDNO: order.symbol, ORD_DVSN: order.orderType === 'MARKET' ? '01' : '00', ORD_QTY: order.quantity.toString(), ORD_UNPR: order.price?.toString() || '0' }, { headers: { authorization: `Bearer ${this.accessToken}`, tr_id: order.side === 'BUY' ? 'TTTC0802U' : 'TTTC0801U' } } ) return { ...order, orderId: response.data.ODNO, status: 'SUBMITTED', submittedAt: new Date() } } private async ensureConnected(): Promise { if (!this.accessToken || !this.tokenExpiry || this.tokenExpiry < new Date()) { throw new Error('세션이 만료되었습니다. 다시 연결하세요.') } } private transformBalance(apiResponse: any): Balance { // 증권사 API 응답을 공통 형식으로 변환 // ... } // ... 기타 메서드 구현 } ``` ## 데이터 모델 주요 데이터 모델은 [공통 데이터 모델](../../docs/03-data-models.md)을 참조하세요. - [Account](../../docs/03-data-models.md#11-account-계좌) - [Balance](../../docs/03-data-models.md#12-balance-잔고) - [Position](../../docs/03-data-models.md#13-position-포지션) - [Order](../../docs/03-data-models.md#41-order-주문) - [Quote](../../docs/03-data-models.md#52-quote-현재가) ## API 명세 ### REST API #### GET /api/accounts 계좌 목록 조회 **응답**: ```json { "accounts": [ { "id": "account-123", "brokerId": "korea_investment", "accountNumber": "1234567890", "accountName": "메인 계좌", "accountType": "STOCK", "isActive": true } ] } ``` #### POST /api/accounts 계좌 등록 **요청**: ```json { "brokerId": "korea_investment", "accountNumber": "1234567890", "accountName": "메인 계좌", "accountType": "STOCK", "credentials": { "apiKey": "...", "apiSecret": "..." } } ``` #### GET /api/accounts/:accountId/balance 잔고 조회 **응답**: ```json { "accountId": "account-123", "cash": { "krw": 5000000, "usd": 0 }, "positions": [ { "symbol": "005930", "symbolName": "삼성전자", "quantity": 100, "averagePrice": 70000, "currentPrice": 75000, "marketValue": 7500000, "unrealizedPnL": 500000, "unrealizedPnLPct": 7.14 } ], "totalEquity": 12500000, "buyingPower": 4800000, "timestamp": "2024-01-15T09:30:00Z" } ``` #### POST /api/orders 주문 제출 **요청**: ```json { "accountId": "account-123", "symbol": "005930", "side": "BUY", "orderType": "LIMIT", "quantity": 10, "price": 74000 } ``` ## 구현 고려사항 ### 1. 에러 처리 및 재시도 ```typescript // 지수 백오프 재시도 로직 async function retryWithBackoff( fn: () => Promise, maxRetries: number = 3 ): Promise { for (let i = 0; i < maxRetries; i++) { try { return await fn() } catch (error) { if (i === maxRetries - 1) throw error // 재시도 가능한 에러인지 확인 if (!isRetryableError(error)) throw error // 지수 백오프 const delay = Math.pow(2, i) * 1000 await sleep(delay) } } throw new Error('Should not reach here') } function isRetryableError(error: any): boolean { // 네트워크 에러, 타임아웃, 5xx 에러 등 return ( error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT' || (error.response?.status >= 500 && error.response?.status < 600) ) } ``` ### 2. Rate Limiting 증권사 API는 요청 제한이 있으므로 Rate Limiter 구현 필요: ```typescript class RateLimiter { private queue: Array<() => Promise> = [] private running: number = 0 private maxConcurrent: number private minInterval: number constructor(maxConcurrent: number = 5, minInterval: number = 100) { this.maxConcurrent = maxConcurrent this.minInterval = minInterval } async execute(fn: () => Promise): Promise { return new Promise((resolve, reject) => { this.queue.push(async () => { try { const result = await fn() resolve(result) } catch (error) { reject(error) } }) this.processQueue() }) } private async processQueue() { if (this.running >= this.maxConcurrent || this.queue.length === 0) { return } const fn = this.queue.shift()! this.running++ try { await fn() } finally { this.running-- setTimeout(() => this.processQueue(), this.minInterval) } } } ``` ### 3. 캐싱 전략 - **실시간 시세**: Redis에 5-10초 TTL로 캐싱 - **계좌 잔고**: 1분 TTL - **주문 상태**: 캐싱 안함 (항상 최신 데이터) ### 4. 보안 - API 키는 절대 로그에 기록하지 않음 - 평문 API 키는 메모리에만 존재 (복호화 후 즉시 사용) - HTTPS 필수 - API 키 로테이션 주기적 수행 ## 테스트 ### 단위 테스트 예시 ```typescript describe('balance - placeOrder', () => { it('should place a valid buy order', async () => { const order = { accountId: 'test-account', symbol: 'TEST', side: 'BUY', orderType: 'LIMIT', quantity: 10, price: 1000 } const result = await balance.placeOrder(order) expect(result.orderId).toBeDefined() expect(result.status).toBe('SUBMITTED') }) it('should reject order with insufficient balance', async () => { const order = { accountId: 'test-account', symbol: 'TEST', side: 'BUY', orderType: 'LIMIT', quantity: 1000000, price: 100000 } await expect(balance.placeOrder(order)).rejects.toThrow('매수 가능 금액이 부족합니다') }) }) ``` ## 관련 문서 - [시스템 개요](../../docs/01-overview.md) - [전체 아키텍처](../../docs/02-architecture.md) - [공통 데이터 모델](../../docs/03-data-models.md) - [주요 워크플로우](../../docs/04-workflows.md) - [구현 로드맵](../../docs/05-roadmap.md) ### 관련 컴포넌트 - [mgmt - 컨테이너 관리](./mgmt.md) - [data - 데이터 관리](./data.md) - [scheduler - 실행 스케줄러](./scheduler.md)