feat: 프로젝트 개요 및 컴포넌트별 명세, 로드맵 등 문서 추가
This commit is contained in:
550
components/phase1/mgmt.md
Normal file
550
components/phase1/mgmt.md
Normal file
@@ -0,0 +1,550 @@
|
||||
# mgmt - 컨테이너 관리
|
||||
|
||||
## 개요
|
||||
|
||||
**mgmt** 컴포넌트는 가상 자산 컨테이너 생성 및 운영 관리를 담당합니다.
|
||||
|
||||
### 책임
|
||||
|
||||
- 컨테이너 생명주기 관리 (생성, 수정, 삭제, 활성화/비활성화)
|
||||
- 가상 자산 격리 및 밸런스 관리
|
||||
- 컨테이너 간 자산 이동
|
||||
- 실제 계좌 vs 컨테이너 총합 일치성 검증 (Reconciliation)
|
||||
|
||||
### 핵심 개념
|
||||
|
||||
**컨테이너(Container)**는 하나의 실제 계좌 내에서 가상으로 분리된 자산 공간입니다.
|
||||
|
||||
```mermaid
|
||||
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
|
||||
```
|
||||
|
||||
### 의존성
|
||||
|
||||
```mermaid
|
||||
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 컨테이너 생성
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 새로운 컨테이너를 생성합니다
|
||||
*/
|
||||
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 컨테이너 상태 관리
|
||||
|
||||
```mermaid
|
||||
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()
|
||||
```
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 컨테이너를 일시 정지합니다
|
||||
*/
|
||||
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 가상 잔고 관리
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 컨테이너의 가상 잔고를 조회합니다
|
||||
*/
|
||||
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 포지션 업데이트
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 주문 체결 후 컨테이너 포지션을 업데이트합니다
|
||||
*/
|
||||
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. 컨테이너 간 자산 이동
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 컨테이너 간 현금을 이동합니다
|
||||
*/
|
||||
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)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 실제 계좌 잔고와 컨테이너 총합이 일치하는지 검증합니다
|
||||
*/
|
||||
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())
|
||||
}
|
||||
```
|
||||
|
||||
## 데이터 모델
|
||||
|
||||
주요 데이터 모델:
|
||||
- [Container](../../docs/03-data-models.md#21-container-컨테이너)
|
||||
- [VirtualBalance](../../docs/03-data-models.md#22-virtualbalance-가상-잔고)
|
||||
|
||||
## API 명세
|
||||
|
||||
### POST /api/containers
|
||||
컨테이너 생성
|
||||
|
||||
**요청**:
|
||||
```json
|
||||
{
|
||||
"accountId": "account-123",
|
||||
"name": "성장주 전략",
|
||||
"initialAmount": 10000000,
|
||||
"cashReserve": 1000000,
|
||||
"constraints": {
|
||||
"maxSinglePositionPct": 20,
|
||||
"maxDrawdown": 15,
|
||||
"allowedAssetClasses": ["STOCK"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/containers/:containerId
|
||||
컨테이너 조회
|
||||
|
||||
**응답**:
|
||||
```json
|
||||
{
|
||||
"id": "container-456",
|
||||
"accountId": "account-123",
|
||||
"name": "성장주 전략",
|
||||
"strategyId": "strategy-momentum",
|
||||
"status": "ACTIVE",
|
||||
"allocation": {
|
||||
"initialAmount": 10000000,
|
||||
"currentEquity": 10500000,
|
||||
"cashReserve": 1000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/containers/:containerId/balance
|
||||
가상 잔고 조회
|
||||
|
||||
**응답**:
|
||||
```json
|
||||
{
|
||||
"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 실행:
|
||||
|
||||
```typescript
|
||||
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. 컨테이너 삭제 시 주의사항
|
||||
|
||||
컨테이너 삭제 시 보유 포지션이 있다면 경고:
|
||||
|
||||
```typescript
|
||||
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')
|
||||
}
|
||||
```
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [시스템 개요](../../docs/01-overview.md)
|
||||
- [전체 아키텍처](../../docs/02-architecture.md)
|
||||
- [공통 데이터 모델](../../docs/03-data-models.md)
|
||||
|
||||
### 관련 컴포넌트
|
||||
- [balance - 계좌 관리](./balance.md)
|
||||
- [strategy - 전략 관리](./strategy.md)
|
||||
- [risk - 리스크 관리](./risk.md)
|
||||
Reference in New Issue
Block a user