Files
system-specs/components/phase1/balance.md

18 KiB

balance - 계좌 관리

개요

balance 컴포넌트는 증권사 API 통합 및 계좌 자산 관리를 담당합니다.

책임

  • 증권사 API 인증 및 세션 관리
  • 다중 계좌 등록/수정/삭제
  • 계좌 잔고 및 포지션 조회
  • 시장 시세 조회 (현재가, 호가, 과거 데이터)
  • 주문 처리 (제출, 취소, 상태 조회)

의존성

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 계좌 등록

/**
 * 새로운 증권사 계좌를 등록합니다
 */
async function registerAccount(params: {
  brokerId: string          // 증권사 식별자 (예: 'korea_investment')
  accountNumber: string     // 계좌 번호
  accountName: string       // 계좌 별칭
  accountType: AccountType  // 계좌 유형
  credentials: {            // 인증 정보
    apiKey: string
    apiSecret: string
    accountPassword?: string
  }
}): Promise<Account>

처리 흐름:

  1. 증권사 식별자 검증
  2. API 키 암호화 (AES-256)
  3. 테스트 연결 수행 (인증 정보 유효성 검증)
  4. 계좌 정보 데이터베이스 저장
  5. 초기 잔고 조회

1.2 API 키 암호화 저장

/**
 * 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 현재 잔고 조회

/**
 * 계좌의 현재 잔고를 조회합니다
 */
async function getCurrentBalance(accountId: string): Promise<Balance> {
  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
}

반환 데이터:

interface Balance {
  accountId: string
  cash: {
    krw: number              // 원화 잔고
    usd: number              // 달러 잔고 (해외 주식용)
  }
  positions: Position[]      // 보유 종목
  totalEquity: number        // 총 자산 평가액
  buyingPower: number        // 매수 가능 금액
  withdrawableCash: number   // 출금 가능 금액
  timestamp: Date           // 조회 시점
}

2.2 포트폴리오 스냅샷

/**
 * 자산 클래스별 비중 및 종목별 손익률을 포함한 포트폴리오 스냅샷
 */
async function getPortfolioSnapshot(accountId: string): Promise<Portfolio> {
  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 현재가 조회

/**
 * 종목의 현재가 및 호가 정보를 조회합니다
 */
async function getCurrentPrice(symbol: string): Promise<Quote> {
  // 캐시 우선 조회 (실시간 시세)
  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
}

반환 데이터:

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 과거 가격 조회

/**
 * 과거 가격 데이터를 조회합니다 (백테스트용)
 */
async function getHistoricalPrices(params: {
  symbol: string
  from: Date
  to: Date
  interval: Interval       // '1d', '1w', '1M' 등
}): Promise<PriceBar[]> {
  const adapter = getBrokerAdapter('default')
  const prices = await adapter.getHistoricalPrices(params)

  // 배당 및 액면분할 조정
  const adjustedPrices = await adjustForCorporateActions(prices, params.symbol)

  return adjustedPrices
}

4. 주문 처리

4.1 주문 제출

/**
 * 주문을 제출합니다
 */
async function placeOrder(order: Order): Promise<OrderResult> {
  // 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
  }
}

주문 검증:

async function validateOrder(order: Order): Promise<void> {
  // 계좌 존재 여부 확인
  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 주문 취소

/**
 * 미체결 주문을 취소합니다
 */
async function cancelOrder(orderId: string): Promise<boolean> {
  // 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 주문 상태 조회

/**
 * 주문의 현재 상태를 조회합니다
 */
async function getOrderStatus(orderId: string): Promise<OrderStatus> {
  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 인터페이스

/**
 * 모든 증권사 어댑터가 구현해야 하는 인터페이스
 */
interface BrokerAdapter {
  /**
   * 증권사 API에 연결하고 세션을 생성합니다
   */
  connect(credentials: Credentials): Promise<Session>

  /**
   * 계좌 잔고를 조회합니다
   */
  getBalance(account: Account): Promise<Balance>

  /**
   * 종목의 현재 시세를 조회합니다
   */
  getQuote(symbol: string): Promise<Quote>

  /**
   * 과거 가격 데이터를 조회합니다
   */
  getHistoricalPrices(params: HistoricalPricesParams): Promise<PriceBar[]>

  /**
   * 주문을 제출합니다
   */
  submitOrder(order: Order): Promise<OrderResult>

  /**
   * 주문을 취소합니다
   */
  cancelOrder(orderId: string): Promise<boolean>

  /**
   * 주문 상태를 조회합니다
   */
  getOrderStatus(orderId: string): Promise<OrderStatus>

  /**
   * 주문 이력을 조회합니다
   */
  getOrderHistory(account: Account, from: Date, to: Date): Promise<Order[]>

  /**
   * 세션을 종료합니다
   */
  disconnect(): Promise<void>
}

구현 예시: 한국투자증권 Adapter

class KoreaInvestmentAdapter implements BrokerAdapter {
  private accessToken?: string
  private tokenExpiry?: Date

  async connect(credentials: Credentials): Promise<Session> {
    // 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<Balance> {
    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<OrderResult> {
    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<void> {
    if (!this.accessToken || !this.tokenExpiry || this.tokenExpiry < new Date()) {
      throw new Error('세션이 만료되었습니다. 다시 연결하세요.')
    }
  }

  private transformBalance(apiResponse: any): Balance {
    // 증권사 API 응답을 공통 형식으로 변환
    // ...
  }

  // ... 기타 메서드 구현
}

데이터 모델

주요 데이터 모델은 공통 데이터 모델을 참조하세요.

API 명세

REST API

GET /api/accounts

계좌 목록 조회

응답:

{
  "accounts": [
    {
      "id": "account-123",
      "brokerId": "korea_investment",
      "accountNumber": "1234567890",
      "accountName": "메인 계좌",
      "accountType": "STOCK",
      "isActive": true
    }
  ]
}

POST /api/accounts

계좌 등록

요청:

{
  "brokerId": "korea_investment",
  "accountNumber": "1234567890",
  "accountName": "메인 계좌",
  "accountType": "STOCK",
  "credentials": {
    "apiKey": "...",
    "apiSecret": "..."
  }
}

GET /api/accounts/:accountId/balance

잔고 조회

응답:

{
  "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

주문 제출

요청:

{
  "accountId": "account-123",
  "symbol": "005930",
  "side": "BUY",
  "orderType": "LIMIT",
  "quantity": 10,
  "price": 74000
}

구현 고려사항

1. 에러 처리 및 재시도

// 지수 백오프 재시도 로직
async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3
): Promise<T> {
  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 구현 필요:

class RateLimiter {
  private queue: Array<() => Promise<any>> = []
  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<T>(fn: () => Promise<T>): Promise<T> {
    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 키 로테이션 주기적 수행

테스트

단위 테스트 예시

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('매수 가능 금액이 부족합니다')
  })
})

관련 문서

관련 컴포넌트