Files

14 KiB

mgmt - 컨테이너 관리

개요

mgmt 컴포넌트는 가상 자산 컨테이너 생성 및 운영 관리를 담당합니다.

책임

  • 컨테이너 생명주기 관리 (생성, 수정, 삭제, 활성화/비활성화)
  • 가상 자산 격리 및 밸런스 관리
  • 컨테이너 간 자산 이동
  • 실제 계좌 vs 컨테이너 총합 일치성 검증 (Reconciliation)

핵심 개념

**컨테이너(Container)**는 하나의 실제 계좌 내에서 가상으로 분리된 자산 공간입니다.

graph TB
    subgraph "실제 계좌 (한국투자증권)"
        Account[계좌 총 자산: 1억원]

        subgraph "컨테이너 A"
            A_Cash[현금: 500만원]
            A_Stock[삼성전자: 2500만원]
            A_Total[총 3000만원]
        end

        subgraph "컨테이너 B"
            B_Cash[현금: 1000만원]
            B_ETF[KODEX 200: 4000만원]
            B_Total[총 5000만원]
        end

        subgraph "컨테이너 C"
            C_Cash[현금: 2000만원]
            C_Total[총 2000만원]
        end
    end

    Account --> A_Total
    Account --> B_Total
    Account --> C_Total

    style Account fill:#4CAF50,color:#fff
    style A_Total fill:#2196F3,color:#fff
    style B_Total fill:#2196F3,color:#fff
    style C_Total fill:#2196F3,color:#fff

의존성

graph LR
    Mgmt[mgmt] --> Balance[balance]
    Mgmt --> Risk[risk]
    Mgmt --> DB[(Database)]

    Scheduler[scheduler] --> Mgmt
    Analytics[analytics] --> Mgmt
    Monitor[monitor] --> Mgmt

    style Mgmt fill:#4CAF50,color:#fff

주요 기능

1. 컨테이너 생명주기 관리

1.1 컨테이너 생성

/**
 * 새로운 컨테이너를 생성합니다
 */
async function createContainer(params: {
  accountId: string          // 소속 계좌 ID
  name: string               // 컨테이너 이름
  description?: string       // 설명
  initialAmount: number      // 초기 할당 금액
  cashReserve: number        // 최소 현금 보유량
  constraints: Constraints   // 제약 조건
}): Promise<Container> {
  // 1. 계좌 잔고 확인
  const balance = await balance.getCurrentBalance(params.accountId)

  // 2. 할당 가능 금액 검증
  const allocatedSum = await getTotalAllocated(params.accountId)
  if (allocatedSum + params.initialAmount > balance.totalEquity) {
    throw new Error('할당 가능 금액을 초과합니다')
  }

  // 3. 컨테이너 생성
  const container = await db.containers.create({
    ...params,
    status: 'ACTIVE',
    allocation: {
      initialAmount: params.initialAmount,
      currentEquity: params.initialAmount,
      cashReserve: params.cashReserve
    },
    createdAt: new Date()
  })

  // 4. 초기 가상 잔고 생성
  await createVirtualBalance(container.id, {
    cash: params.initialAmount,
    positions: []
  })

  // 5. 감사 로그
  await audit.logEvent({
    eventType: 'CONFIG_CHANGE',
    action: 'CREATE',
    entity: 'Container',
    entityId: container.id,
    after: container
  })

  return container
}

1.2 컨테이너 상태 관리

stateDiagram-v2
    [*] --> Created: createContainer()

    Created --> Active: activate()
    Created --> [*]: delete()

    Active --> Running: scheduler trigger
    Active --> Paused: pause()
    Active --> [*]: delete()

    Running --> Active: execution complete
    Running --> Error: execution failed
    Running --> Paused: emergency stop

    Paused --> Active: resume()
    Paused --> [*]: delete()

    Error --> Active: resolve & restart
    Error --> Paused: manual intervention
    Error --> [*]: delete()
/**
 * 컨테이너를 일시 정지합니다
 */
async function pauseContainer(containerId: string): Promise<void> {
  const container = await getContainer(containerId)

  if (container.status === 'ARCHIVED') {
    throw new Error('삭제된 컨테이너는 일시 정지할 수 없습니다')
  }

  await updateContainerStatus(containerId, 'PAUSED')

  // 실행 중인 스케줄 일시 정지
  await scheduler.pauseSchedulesByContainer(containerId)

  await audit.logEvent({
    eventType: 'CONFIG_CHANGE',
    action: 'UPDATE',
    entity: 'Container',
    entityId: containerId,
    before: { status: container.status },
    after: { status: 'PAUSED' }
  })
}

2. 가상 자산 격리

2.1 가상 잔고 관리

/**
 * 컨테이너의 가상 잔고를 조회합니다
 */
async function getContainerBalance(containerId: string): Promise<VirtualBalance> {
  const virtualBalance = await db.virtualBalances.findOne({ containerId })

  // 실시간 시세 반영
  const updatedPositions = await Promise.all(
    virtualBalance.positions.map(async (position) => {
      const quote = await balance.getCurrentPrice(position.symbol)
      return {
        ...position,
        currentPrice: quote.price,
        marketValue: position.quantity * quote.price,
        unrealizedPnL: (quote.price - position.averagePrice) * position.quantity
      }
    })
  )

  const totalValue = virtualBalance.cash + updatedPositions.reduce((sum, p) => sum + p.marketValue, 0)

  return {
    containerId,
    cash: virtualBalance.cash,
    positions: updatedPositions,
    totalValue,
    availableCash: virtualBalance.cash,
    allocatedValue: virtualBalance.allocatedValue,
    timestamp: new Date()
  }
}

2.2 포지션 업데이트

/**
 * 주문 체결 후 컨테이너 포지션을 업데이트합니다
 */
async function updatePosition(
  containerId: string,
  update: {
    symbol: string
    quantity: number      // +: 매수, -: 매도
    price: number
    commission: number
  }
): Promise<void> {
  const virtualBalance = await db.virtualBalances.findOne({ containerId })

  if (update.quantity > 0) {
    // 매수
    const totalCost = update.quantity * update.price + update.commission
    if (virtualBalance.cash < totalCost) {
      throw new Error('가상 잔고가 부족합니다')
    }

    virtualBalance.cash -= totalCost

    // 기존 포지션 업데이트 또는 신규 추가
    const existingPosition = virtualBalance.positions.find(p => p.symbol === update.symbol)

    if (existingPosition) {
      // 평균 매입가 재계산
      const totalQuantity = existingPosition.quantity + update.quantity
      const totalCost = existingPosition.averagePrice * existingPosition.quantity + update.price * update.quantity
      existingPosition.averagePrice = totalCost / totalQuantity
      existingPosition.quantity = totalQuantity
    } else {
      virtualBalance.positions.push({
        symbol: update.symbol,
        quantity: update.quantity,
        averagePrice: update.price,
        currentPrice: update.price,
        marketValue: update.quantity * update.price,
        unrealizedPnL: 0,
        unrealizedPnLPct: 0
      })
    }

  } else {
    // 매도
    const position = virtualBalance.positions.find(p => p.symbol === update.symbol)
    if (!position || position.quantity < Math.abs(update.quantity)) {
      throw new Error('매도할 수량이 부족합니다')
    }

    const sellAmount = Math.abs(update.quantity) * update.price - update.commission
    virtualBalance.cash += sellAmount

    position.quantity += update.quantity  // update.quantity는 음수

    // 실현 손익 계산
    const realizedPnL = (update.price - position.averagePrice) * Math.abs(update.quantity) - update.commission

    // 포지션 완전 청산 시 제거
    if (position.quantity === 0) {
      virtualBalance.positions = virtualBalance.positions.filter(p => p.symbol !== update.symbol)
    }
  }

  await db.virtualBalances.update({ containerId }, virtualBalance)
}

3. 컨테이너 간 자산 이동

/**
 * 컨테이너 간 현금을 이동합니다
 */
async function transferCash(params: {
  fromContainerId: string
  toContainerId: string
  amount: number
  reason?: string
}): Promise<Transfer> {
  // 1. 출발 컨테이너 잔고 확인
  const fromBalance = await getContainerBalance(params.fromContainerId)
  if (fromBalance.availableCash < params.amount) {
    throw new Error('이동 가능한 현금이 부족합니다')
  }

  // 2. 두 컨테이너가 같은 계좌 소속인지 확인
  const fromContainer = await getContainer(params.fromContainerId)
  const toContainer = await getContainer(params.toContainerId)

  if (fromContainer.accountId !== toContainer.accountId) {
    throw new Error('같은 계좌 내 컨테이너 간에만 이동 가능합니다')
  }

  // 3. 트랜잭션으로 원자적 실행
  await db.transaction(async (tx) => {
    // 출발 컨테이너에서 차감
    await tx.virtualBalances.update(
      { containerId: params.fromContainerId },
      { $inc: { cash: -params.amount } }
    )

    // 도착 컨테이너에 추가
    await tx.virtualBalances.update(
      { containerId: params.toContainerId },
      { $inc: { cash: params.amount } }
    )

    // 이동 기록 생성
    const transfer = await tx.transfers.create({
      fromContainerId: params.fromContainerId,
      toContainerId: params.toContainerId,
      amount: params.amount,
      reason: params.reason,
      createdAt: new Date()
    })

    return transfer
  })

  // 4. 감사 로그
  await audit.logEvent({
    eventType: 'CONFIG_CHANGE',
    action: 'TRANSFER',
    entity: 'Transfer',
    entityId: transfer.id,
    metadata: params
  })

  return transfer
}

4. 밸런스 조정 (Reconciliation)

/**
 * 실제 계좌 잔고와 컨테이너 총합이 일치하는지 검증합니다
 */
async function reconcileAssets(accountId: string): Promise<ReconciliationReport> {
  // 1. 실제 계좌 잔고 조회
  const actualBalance = await balance.getCurrentBalance(accountId)

  // 2. 모든 컨테이너 잔고 합산
  const containers = await getContainersByAccount(accountId)
  const virtualBalances = await Promise.all(
    containers.map(c => getContainerBalance(c.id))
  )

  const virtualTotal = {
    cash: virtualBalances.reduce((sum, vb) => sum + vb.cash, 0),
    positions: aggregatePositions(virtualBalances.map(vb => vb.positions))
  }

  // 3. 차이 검증
  const cashDiscrepancy = actualBalance.cash.krw - virtualTotal.cash
  const positionDiscrepancies = findPositionDiscrepancies(actualBalance.positions, virtualTotal.positions)

  const isReconciled = Math.abs(cashDiscrepancy) < 100 && positionDiscrepancies.length === 0

  const report: ReconciliationReport = {
    accountId,
    isReconciled,
    actual: {
      cash: actualBalance.cash.krw,
      positions: actualBalance.positions
    },
    virtual: {
      cash: virtualTotal.cash,
      positions: virtualTotal.positions
    },
    discrepancies: {
      cash: cashDiscrepancy,
      positions: positionDiscrepancies
    },
    timestamp: new Date()
  }

  // 4. 불일치 시 알림
  if (!isReconciled) {
    await monitor.sendAlert({
      type: 'SYSTEM',
      severity: 'ERROR',
      message: `밸런스 불일치 감지: 계좌 ${accountId}`,
      metadata: report
    })
  }

  return report
}

function aggregatePositions(positionGroups: Position[][]): Position[] {
  const aggregated = new Map<string, Position>()

  for (const positions of positionGroups) {
    for (const position of positions) {
      if (aggregated.has(position.symbol)) {
        const existing = aggregated.get(position.symbol)!
        existing.quantity += position.quantity
        existing.marketValue += position.marketValue
      } else {
        aggregated.set(position.symbol, { ...position })
      }
    }
  }

  return Array.from(aggregated.values())
}

데이터 모델

주요 데이터 모델:

API 명세

POST /api/containers

컨테이너 생성

요청:

{
  "accountId": "account-123",
  "name": "성장주 전략",
  "initialAmount": 10000000,
  "cashReserve": 1000000,
  "constraints": {
    "maxSinglePositionPct": 20,
    "maxDrawdown": 15,
    "allowedAssetClasses": ["STOCK"]
  }
}

GET /api/containers/:containerId

컨테이너 조회

응답:

{
  "id": "container-456",
  "accountId": "account-123",
  "name": "성장주 전략",
  "strategyId": "strategy-momentum",
  "status": "ACTIVE",
  "allocation": {
    "initialAmount": 10000000,
    "currentEquity": 10500000,
    "cashReserve": 1000000
  }
}

GET /api/containers/:containerId/balance

가상 잔고 조회

응답:

{
  "containerId": "container-456",
  "cash": 3000000,
  "positions": [
    {
      "symbol": "005930",
      "quantity": 100,
      "averagePrice": 70000,
      "currentPrice": 75000,
      "marketValue": 7500000,
      "unrealizedPnL": 500000
    }
  ],
  "totalValue": 10500000,
  "availableCash": 3000000
}

구현 고려사항

1. 원자성 보장

컨테이너 간 자산 이동은 반드시 트랜잭션으로 처리하여 데이터 무결성을 보장해야 합니다.

2. 정기적인 Reconciliation

매일 장 마감 후 자동으로 Reconciliation 실행:

scheduler.scheduleDaily('MARKET_CLOSE', async () => {
  const accounts = await balance.getAllAccounts()

  for (const account of accounts) {
    const report = await mgmt.reconcileAssets(account.id)

    if (!report.isReconciled) {
      await monitor.sendAlert({
        type: 'SYSTEM',
        severity: 'CRITICAL',
        message: `밸런스 불일치: ${account.accountName}`
      })
    }
  }
})

3. 컨테이너 삭제 시 주의사항

컨테이너 삭제 시 보유 포지션이 있다면 경고:

async function deleteContainer(containerId: string): Promise<void> {
  const balance = await getContainerBalance(containerId)

  if (balance.positions.length > 0) {
    throw new Error('포지션이 남아있는 컨테이너는 삭제할 수 없습니다. 먼저 모든 포지션을 청산하세요.')
  }

  // ARCHIVED 상태로 변경 (소프트 삭제)
  await updateContainerStatus(containerId, 'ARCHIVED')
}

관련 문서

관련 컴포넌트