코인 자동매매 봇을 만들어보자 (4) : 거래소 연동 (주문 실행 (매수/매도))

대호 Dorant·2024년 12월 9일
0

코인자동매매봇

목록 보기
4/4

거래소 연동 (Exchange) 모듈

  • 목적: 거래소와의 모든 통신을 담당
  • 경로: src/exchange
  • 주요 기능:
    1. 실시간 시세 조회
    2. 잔고 조회
    3. 주문 실행 (매수/매도)
  • 확장성:
    1. 인터페이스를 통해 새로운 거래소 쉽게 추가 가능
    2. 현재 업비트 구현, 향후 바이낸스 추가 예정

  지금까지 진행 상황을 되짚어 보겠습니다. 거래소 연동 모듈의 실시간 시세조회, 잔고 조회를 구현해놓은 상태입니다! 조회하는 기능들까지 구현을 해놓았으니 이제는 실제 주문 실행 기능을 구현해보겠습니다! 이제부터는 실제 제 돈이 나가는 기능이니 굉장히 주의를 해야겠습니다.

안그래도 없는 제 돈... 더 없어지면 안되잖아요...?

시작해보겠습니다!

 먼저, 업비트의 주문요청 API 요청, 응답 형식부터 살펴보겠습니다.

💡 주문 요청 API 요청 예시

const request = require('request')
const uuidv4 = require("uuid/v4")
const crypto = require('crypto')
const sign = require('jsonwebtoken').sign
const queryEncode = require("querystring").encode

const access_key = process.env.UPBIT_OPEN_API_ACCESS_KEY
const secret_key = process.env.UPBIT_OPEN_API_SECRET_KEY
const server_url = process.env.UPBIT_OPEN_API_SERVER_URL

const body = {
    market: 'KRW-BTC',
    side: 'bid',
    volume: '0.01',
    price: '100',
    ord_type: 'limit',
}

const query = queryEncode(body)

const hash = crypto.createHash('sha512')
const queryHash = hash.update(query, 'utf-8').digest('hex')

const payload = {
    access_key: access_key,
    nonce: uuidv4(),
    query_hash: queryHash,
    query_hash_alg: 'SHA512',
}

const token = sign(payload, secret_key)

const options = {
    method: "POST",
    url: server_url + "/v1/orders",
    headers: {Authorization: `Bearer ${token}`},
    json: body
}

request(options, (error, response, body) => {
    if (error) throw new Error(error)
    console.log(body)
})

💡 주문 요청 API 응답 예시

// 201 status
{
  "uuid": "cdd92199-2897-4e14-9448-f923320408ad",
  "side": "bid",
  "ord_type": "limit",
  "price": "100.0",
  "state": "wait",
  "market": "KRW-BTC",
  "created_at": "2018-04-10T15:42:23+09:00",
  "volume": "0.01",
  "remaining_volume": "0.01",
  "reserved_fee": "0.0015",
  "remaining_fee": "0.0015",
  "paid_fee": "0.0",
  "locked": "1.0015",
  "executed_volume": "0.0",
  "trades_count": 0
}

// 4XX status
{
  "error": {
    "name": "error name",
    "message": "error message"
  }
}

 이젠, 뭐부터 해야할지 아시죠? 어떤 것부터 해야한다? 테스트 코드 먼저 작성(Red단계)를 먼저 작성한 후, 이를 기반으로 서비스 코드를 작성(Green단계)을 해야한다~ 슬슬 익숙해져가네요!

주문 실행 (매수/매도)

1. 테스트 코드 작성 (Red 단계)

// src/exchange/upbit/upbit.service.spec.ts
  // 주문 실행
  describe('Order API', () => {
    // createOrder 메서드 존재 여부 확인
    it('should have createOrder method', () => {
      expect(service.createOrder).toBeDefined();
    });

    // 파라미터 유효성 검사 테스트
    describe('parameter validation', () => {
      // 마켓 코드(market) 누락 시 에러 발생 테스트
      it('should throw error if market is missing', async () => {
        await expect(
          service.createOrder({
            side: 'bid',
            ord_type: 'limit',
          } as any),
        ).rejects.toThrow('Market is required.');
      });

      // 주문 종류(side: bid/ask) 누락 시 에러 발생 테스트
      it('should throw error if side is missing', async () => {
        await expect(
          service.createOrder({
            market: 'KRW-BTC',
            ord_type: 'limit',
          } as any),
        ).rejects.toThrow('Side is required.');
      });

      // 주문 방식(ord_type: limit/price/market) 누락 시 에러 발생 테스트
      it('should throw error if ord_type is missing', async () => {
        await expect(
          service.createOrder({
            market: 'KRW-BTC',
            side: 'bid',
          } as any),
        ).rejects.toThrow('Order type is required.');
      });
    });

    // API 통합 테스트
    describe('API integration', () => {
      beforeEach(() => {
        // 테스트용 Mock API 키 설정
        jest
          .spyOn(service['configService'], 'get')
          .mockImplementation((key: string) => {
            if (key === 'UPBIT_ACCESS_KEY') return 'test_access_key';
            if (key === 'UPBIT_SECRET_KEY') return 'test_secret_key';
            return process.env[key];
          });

        // 성공적인 주문 응답 Mock 설정
        mockedAxios.post.mockResolvedValue({
          data: {
            uuid: 'test-uuid',
            side: 'bid',
            ord_type: 'limit',
            price: '50000000',
            state: 'wait',
            market: 'KRW-BTC',
            created_at: '2024-01-01T00:00:00+09:00',
            volume: '0.01',
            remaining_volume: '0.01',
            reserved_fee: '250',
            remaining_fee: '250',
            paid_fee: '0',
            locked: '500250',
            executed_volume: '0',
            trades_count: 0,
          },
        });
      });

      // API 엔드포인트 호출 및 응답 검증 테스트
      it('should call the correct API endpoint with proper parameters', async () => {
        const orderParams = {
          market: 'KRW-BTC',
          side: 'bid' as const,
          ord_type: 'limit' as const,
          price: '50000000',
          volume: '0.01',
        };

        const result = await service.createOrder(orderParams);

        // API 호출 검증
        expect(mockedAxios.post).toHaveBeenCalledWith(
          'https://api.upbit.com/v1/orders',
          orderParams,
          expect.objectContaining({
            headers: expect.objectContaining({
              Authorization: expect.any(String),
            }),
          }),
        );

        // API 응답 검증
        expect(result).toEqual(
          expect.objectContaining({
            uuid: expect.any(String),
            side: orderParams.side,
            ord_type: orderParams.ord_type,
            price: orderParams.price,
            market: orderParams.market,
            volume: orderParams.volume,
          }),
        );
      });

      // API 에러 처리 테스트 (잔액 부족 시나리오)
      it('should handle API errors properly', async () => {
        mockedAxios.post.mockRejectedValueOnce({
          response: {
            data: {
              error: {
                name: 'insufficient_funds',
                message: '주문가능한 금액이 부족합니다.',
              },
            },
          },
        });

        await expect(
          service.createOrder({
            market: 'KRW-BTC',
            side: 'bid',
            ord_type: 'limit',
            price: '50000000',
            volume: '0.01',
          }),
        ).rejects.toThrow('주문가능한 금액이 부족합니다.');
      });
    });
  });

2. 테스트 통과를 위한 코드 구현 (Green 단계)

// src/exchange/upbit/upbit.service.ts
...
  async createOrder(request: OrderRequest): Promise<Order> {
    const { market, side, ord_type, price, volume } = request;

    if (!market) {
      throw new BadRequestException('Market is required.');
    }

    if (!side) {
      throw new BadRequestException('Side is required.');
    }

    if (!ord_type) {
      throw new BadRequestException('Order type is required.');
    }

    const accessKey = this.configService.get<string>('UPBIT_ACCESS_KEY');
    const secretKey = this.configService.get<string>('UPBIT_SECRET_KEY');

    if (!accessKey || !secretKey) {
      throw new BadRequestException('API keys are required for private API');
    }

    // 주문 본문 데이터 준비
    let body: {
      market: string;
      side: 'bid' | 'ask';
      ord_type: string;
      price?: string;
      volume?: string;
    };

    // 시장가 매수일 때는 volume 제외, 시장가 매도일 때는 price 제외
    if (side === 'bid' && ord_type === 'price') {
      body = { market, side, ord_type, price };
    } else if (side === 'ask' && ord_type === 'market') {
      body = { market, side, ord_type, volume };
    } else {
      body = { market, side, ord_type, price, volume };
    }

    const query = querystring.encode(body);

    const hash = crypto.createHash('sha512');
    const queryHash = hash.update(query, 'utf-8').digest('hex');

    const payload = {
      access_key: accessKey,
      nonce: uuidv4(),
      query_hash: queryHash,
      query_hash_alg: 'SHA512',
    };

    const token = sign(payload, secretKey);

    try {
      const { data } = await axios.post<Order>(`${this.apiUrl}/orders`, body, {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      });

      return data;
    } catch (error: any) {
      const errorMessage =
        error?.response?.data?.error?.message || error.message;
      throw new BadRequestException(`Failed to create order: ${errorMessage}`);
    }
  }

테스트 결과

npm test src/exchange/upbit/upbit.service.spec.ts  

> auto-coin-trader-bot@0.0.1 test
> jest src/exchange/upbit/upbit.service.spec.ts

 PASS  src/exchange/upbit/upbit.service.spec.ts
  UpbitService
    ✓ should have getCurrentPrice method (5 ms)
    symbol validation
      ✓ should throw error when symbol is empty (8 ms)
      ✓ should throw error when symbol does not start with KRW- (1 ms)
      ✓ should throw error when symbol is not in the valid list (1 ms)
    return value validation
      ✓ should return a valid numeric price (1 ms)
    price range validation
      ✓ should return valid BTC price range (1 ms)
      ✓ should return valid ETH price range (1 ms)
      ✓ should return valid XRP price range (1 ms)
      ✓ should return price with valid decimal places (2 ms)
    API integration
      ✓ should make API call to correct endpoint (2 ms)
      ✓ should include markets parameter in API call (1 ms)
      ✓ should handle empty response data (3 ms)
      ✓ should handle API error
    Balance API
      ✓ should have getBalance method (1 ms)
      ✓ should throw error when API key is not provided (1 ms)
      ✓ should return balance list (2 ms)
    Order API
      ✓ should have createOrder method (1 ms)
      parameter validation
        ✓ should throw error if market is missing (1 ms)
        ✓ should throw error if side is missing
        ✓ should throw error if ord_type is missing
      API integration
        ✓ should call the correct API endpoint with proper parameters (2 ms)
        ✓ should handle API errors properly (2 ms)

Test Suites: 1 passed, 1 total
Tests:       22 passed, 22 total
Snapshots:   0 total
Time:        1.809 s, estimated 2 s
Ran all test suites matching /src\/exchange\/upbit\/upbit.service.spec.ts/i.

여기까지는 무난~하이 왔네요! 자... 이제 단위 테스트, api 통합 테스트를 해봤으니... e2e 테스트를 해봐야겠죠...? 여기에서는 실제로 잘동작하는지 실제 주문을 넣어볼겁니다. 제 실제 잔고를 사용할겁니다 ㅎㄷㄷㄷ

e2e 테스트

매수 주문 테스트

// test/exchange.e2e-spec.ts
...
  describe('Order API', () => {
    // 테스트 전에 환경 변수 확인
    beforeAll(() => {
      // 실제 API 키가 있는지 확인
      const accessKey = process.env.UPBIT_ACCESS_KEY;
      const secretKey = process.env.UPBIT_SECRET_KEY;

      if (!accessKey || !secretKey) {
        throw new Error('API keys are required for E2E testing');
      }

      console.log('Starting order E2E test with minimum amount...');
    });

    // 최소 금액으로 도지코인 매수 테스트
    it('should create a minimal price order for DOGE', async () => {
      // 현재 도지코인 가격 확인
      const currentPrice = await upbitService.getCurrentPrice('KRW-DOGE');
      console.log(`Current DOGE price: ${currentPrice}`);

      // 5000원으로 도지코인 시장가 매수 주문 (최소 주문 금액)
      const order = await upbitService.createOrder({
        market: 'KRW-DOGE',
        side: 'bid',
        ord_type: 'price',
        price: '100',
      });

      // 주문 결과 검증
      expect(order).toHaveProperty('uuid');
      expect(order).toHaveProperty('side', 'bid');
      expect(order).toHaveProperty('ord_type', 'price');
      expect(order).toHaveProperty('market', 'KRW-DOGE');

      console.log('Order created:', {
        uuid: order.uuid,
        market: order.market,
        side: order.side,
        price: order.price,
        state: order.state,
      });

      // 주문 상태가 정상인지 확인
      expect(['wait', 'done']).toContain(order.state);
    }, 20000); // 타임아웃 20초

    // 잔고 변화 확인
    it('should reflect the order in balance', async () => {
      // 잠시 대기 후 잔고 확인 (거래 완료 대기)
      await new Promise((resolve) => setTimeout(resolve, 1000));

      const balances = await upbitService.getBalance();
      const dogeBalance = balances.find((b) => b.currency === 'DOGE');

      expect(dogeBalance).toBeDefined();
      if (dogeBalance) {
        // 현재가 조회
        const currentPrice = await upbitService.getCurrentPrice('KRW-DOGE');
        const totalBalance = dogeBalance.balance + dogeBalance.locked;
        const buyAmount = totalBalance * dogeBalance.avg_buy_price;
        const evaluatedAmount = totalBalance * currentPrice;

        console.log('DOGE Balance:', {
          balance: dogeBalance.balance, // 보유 수량
          locked: dogeBalance.locked, // 주문 중인 수량
          avg_buy_price: `${dogeBalance.avg_buy_price} KRW`, // 평균 매수가
          total_buy_amount: `${Math.round(buyAmount)} KRW`, // 총 매수금액 (보유수량 * 평균매수가)
          current_price: `${currentPrice} KRW`, // 현재가
          evaluated_amount: `${Math.round(evaluatedAmount)} KRW`, // 평가금액 (보유수량 * 현재가)
          profit_loss: `${Math.round(evaluatedAmount - buyAmount)} KRW`, // 평가손익 (평가금액 - 총 매수금액)
          profit_loss_percentage: `${(((evaluatedAmount - buyAmount) / buyAmount) * 100).toFixed(2)}%`, // 수익률
        });
      }
    }, 10000);
  });

저는 이왕 매수 주문 테스트하는겸!! 요즘 가장 핫한 🐕도지코인🐕으로 구매 테스트를 작성했습니다.

테스트 결과

 FAIL  test/exchange.e2e-spec.ts
  Exchange E2E Test
    Upbit Price API
      ✓ should fetch real KRW-BTC price (124 ms)
      ✓ should fetch real KRW-ETH price (42 ms)
      ✓ should fetch real KRW-XRP price (40 ms)
    Upbit Balance API
      ✓ should fetch real balance (86 ms)
    Order API
      ✕ should create a minimal price order for DOGE (151 ms)
      ✓ should reflect the order in balance (1119 ms)

  ● Exchange E2E Test › Order API › should create a minimal price order for DOGE

    BadRequestException: Failed to create order: 최소주문금액 이상으로 주문해주세요

      170 |       const errorMessage =
      171 |         error?.response?.data?.error?.message || error.message;
    > 172 |       throw new BadRequestException(`Failed to create order: ${errorMessage}`);
          |             ^
      173 |     }
      174 |   }
      175 | }

      at UpbitService.createOrder (../src/exchange/upbit/upbit.service.ts:172:13)
      at Object.<anonymous> (exchange.e2e-spec.ts:95:21)
  • 근데, 테스트가 실패를 했네요? 로그를 살펴보니, "최소주문금액 이상으로 주문해주세요" 라는 에러메시지가 떴네요. 쫄보의 100원 주문은 안되는 것 같습니다 ㅎㅎ...

  • 업비트 원화(KRW) 마켓 주문 가격 단위 확인 링크을 확인해보니, 최소 주문 금액이 5000원이더라구요.

  • 그래서 매수 금액을 5000원으로 바꿔서 다시 테스트를 해보았습니다.

...
  console.log
    Starting order E2E test with minimum amount...

      at Object.<anonymous> (exchange.e2e-spec.ts:85:15)

  console.log
    Current DOGE price: 621.2원

      at Object.<anonymous> (exchange.e2e-spec.ts:92:15)

  console.log
    Order created: {
      uuid: 비밀,
      market: 'KRW-DOGE',
      side: 'bid',
      price: '5000',
      state: 'wait'
    }

      at Object.<anonymous> (exchange.e2e-spec.ts:108:15)

  console.log
    DOGE Balance: { balance: 비밀, locked: 비밀, avg_buy_price: 비밀 }

      at Object.<anonymous> (exchange.e2e-spec.ts:130:17)

 PASS  test/exchange.e2e-spec.ts
  Exchange E2E Test
    Upbit Price API
      ✓ should fetch real KRW-BTC price (115 ms)
      ✓ should fetch real KRW-ETH price (35 ms)
      ✓ should fetch real KRW-XRP price (50 ms)
    Upbit Balance API
      ✓ should fetch real balance (93 ms)
    Order API
      ✓ should create a minimal price order for DOGE (135 ms)
      ✓ should reflect the order in balance (1086 ms)

Test Suites: 1 passed, 1 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        3.421 s, estimated 6 s

매도 기능도 확인을 해야겠죠? 매도 금액도 최소 주문 금액이 5000원이라, 5000원 미만의 수량일 때는 거래가 되지 않도록 설정해주었습니다!

매도 주문 테스트

    // 도지코인 시장가 매도 테스트
    it('should create a market sell order for DOGE', async () => {
      // 현재 보유 중인 도지코인 수량 확인
      const balances = await upbitService.getBalance();
      const dogeBalance = balances.find((b) => b.currency === 'DOGE');

      expect(dogeBalance).toBeDefined();
      if (dogeBalance && dogeBalance.balance > 0) {
        // 현재가 조회
        const currentPrice = await upbitService.getCurrentPrice('KRW-DOGE');
        console.log(`Current DOGE price: ${currentPrice}`);

        // 최소 주문 금액(5000원)을 맞추기 위한 최소 수량 계산 (20% 마진 추가)
        const minOrderAmount = 5000;
        const safetyMargin = 1.2; // 20% 추가
        const minVolume =
          Math.ceil(((minOrderAmount * safetyMargin) / currentPrice) * 100) /
          100; // 소수점 2자리까지 올림

        // 보유 수량의 10%와 최소 주문 수량 중 큰 값을 매도
        const sellAmount = String(
          Math.max(dogeBalance.balance * 0.1, minVolume),
        );
        console.log(
          `Selling ${sellAmount} DOGE at market price (estimated value: ${Number(sellAmount) * currentPrice} KRW)`,
        );

        // 매도 수량이 너무 작으면 매도하지 않음
        if (Number(sellAmount) * currentPrice < minOrderAmount * safetyMargin) {
          console.log(
            `Sell amount too small (${Number(sellAmount) * currentPrice} KRW), minimum is ${minOrderAmount * safetyMargin} KRW`,
          );
          return;
        }

        const order = await upbitService.createOrder({
          market: 'KRW-DOGE',
          side: 'ask',
          ord_type: 'market',
          volume: sellAmount,
        });

        // 주문 결과 검증
        expect(order).toHaveProperty('uuid');
        expect(order).toHaveProperty('side', 'ask');
        expect(order).toHaveProperty('ord_type', 'market');
        expect(order).toHaveProperty('market', 'KRW-DOGE');

        console.log('Sell Order created:', {
          uuid: order.uuid,
          market: order.market,
          side: order.side,
          volume: order.volume,
          state: order.state,
        });

        // 주문 상태가 정상인지 확인
        expect(['wait', 'done']).toContain(order.state);
      } else {
        console.log('No DOGE balance available for selling');
      }
    }, 20000); // 타임아웃 20초

테스트 결과

    Sell Order created: {
      uuid: 비밀,
      market: 'KRW-DOGE',
      side: 'ask',
      volume: 비밀,
      state: 'wait'
    }

      at Object.<anonymous> (exchange.e2e-spec.ts:168:17)

  console.log
    DOGE Balance: {
      balance: 비밀,
      locked: 0,
      avg_buy_price: '비밀 KRW',
      total_buy_amount: '비밀 KRW',
      current_price: '비밀 KRW',
      evaluated_amount: '비밀 KRW',
      profit_loss: '비밀 KRW',
      profit_loss_percentage: '비밀 %'
    }

      at Object.<anonymous> (exchange.e2e-spec.ts:199:17)

 PASS  test/exchange.e2e-spec.ts
  Exchange E2E Test
    Upbit Price API
      ✓ should fetch real KRW-BTC price (87 ms)
      ✓ should fetch real KRW-ETH price (40 ms)
      ✓ should fetch real KRW-XRP price (44 ms)
    Upbit Balance API
      ✓ should fetch real balance (65 ms)
    Order API
      ✓ should create a minimal price order for DOGE (127 ms)
      ✓ should create a market sell order for DOGE (156 ms)
      ✓ should reflect the order in balance (1109 ms)

저는 이제 도지코인 오우너입니다.

이렇게, 주문 기능이 완성이 되면서, 거래소 연동 모듈 작업이 완료되었습니다.

도지코인의 떡상을 기원하며 저는 자러가보겠습니다.

profile
안녕하세요. 멋쟁이 백엔드 개발자입니다😎😎

0개의 댓글