760 lines
18 KiB
Markdown
760 lines
18 KiB
Markdown
# 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<Account>
|
|
```
|
|
|
|
**처리 흐름**:
|
|
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<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
|
|
}
|
|
```
|
|
|
|
**반환 데이터**:
|
|
```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<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 현재가 조회
|
|
|
|
```typescript
|
|
/**
|
|
* 종목의 현재가 및 호가 정보를 조회합니다
|
|
*/
|
|
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
|
|
}
|
|
```
|
|
|
|
**반환 데이터**:
|
|
```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<PriceBar[]> {
|
|
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<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
|
|
}
|
|
}
|
|
```
|
|
|
|
**주문 검증**:
|
|
```typescript
|
|
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 주문 취소
|
|
|
|
```typescript
|
|
/**
|
|
* 미체결 주문을 취소합니다
|
|
*/
|
|
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 주문 상태 조회
|
|
|
|
```typescript
|
|
/**
|
|
* 주문의 현재 상태를 조회합니다
|
|
*/
|
|
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 인터페이스
|
|
|
|
```typescript
|
|
/**
|
|
* 모든 증권사 어댑터가 구현해야 하는 인터페이스
|
|
*/
|
|
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
|
|
|
|
```typescript
|
|
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 응답을 공통 형식으로 변환
|
|
// ...
|
|
}
|
|
|
|
// ... 기타 메서드 구현
|
|
}
|
|
```
|
|
|
|
## 데이터 모델
|
|
|
|
주요 데이터 모델은 [공통 데이터 모델](../../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<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 구현 필요:
|
|
|
|
```typescript
|
|
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 키 로테이션 주기적 수행
|
|
|
|
## 테스트
|
|
|
|
### 단위 테스트 예시
|
|
|
|
```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)
|