코인 자동매매 봇을 만들어보자 (2) : 거래소 연동 (실시간 시세 조회)

대호 Dorant·2024년 12월 7일
1

코인자동매매봇

목록 보기
2/4

첫번째로는 거래소 연동 (Exchange) 모듈을 개발해보겠습니다.

거래소 연동 (Exchange) 모듈

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

TDD의 핵심은 "Red-Green-Refactor" 사이클입니다.

  • Red: 실패하는 테스트 작성
  • Green: 테스트를 통과하는 최소한의 코드 작성
  • Refactor: 코드 개선

먼저 가장 기본적인 테스트부터 시작해보겠습니다:

메소드 정의

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

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

import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { UpbitService } from './upbit.service';

describe('UpbitService', () => {
  let service: UpbitService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UpbitService,
        {
          provide: ConfigService,
          useValue: {
            get: jest.fn(),
          },
        },
      ],
    }).compile();

    service = module.get<UpbitService>(UpbitService);
  });

  it('should have getCurrentPrice method', () => {
    expect(service.getCurrentPrice).toBeDefined();
  });
});

현재는 UpbitService에 getCurrentPrice가 구현이 되어있지 않기에, 아래와 같이 테스트 실패를 하게 됩니다.

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

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

 FAIL  src/exchange/upbit/upbit.service.spec.ts
  ● Test suite failed to run

    src/exchange/upbit/upbit.service.spec.ts:25:20 - error TS2339: Property 'getCurrentPrice' does not exist on type 'UpbitService'.

    25     expect(service.getCurrentPrice).toBeDefined();
                          ~~~~~~~~~~~~~~~

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        1.391 s, estimated 2 s

2. 최소한의 구현 (Green 단계)

// src/exchange/interfaces/exchange.interface.ts
export interface IExchange {
  getCurrentPrice(symbol: string): Promise<number>;
}

// src/exchange/upbit/upbit.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IExchange } from '../interfaces/exchange.interface';

@Injectable()
export class UpbitService implements IExchange {
  constructor(private readonly configService: ConfigService) {}

  async getCurrentPrice(symbol: string): Promise<number> {
    return 0;
  }
}

인터페이스 및 최소한의 메소드를 구현했습니다. 테스트를 진행해볼까요?

npm run 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)

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

Failed가 passed로 바뀌었네요! 원래는 3단계 리팩토링을 진행해야하지만, 지금 단계에서는 필요가 없다고 판단이 됩니다. 다음 단계로 넘어가보겠습니다.

심볼 파라미터 검증

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

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

...
  it('should throw error when symbol is empty', async () => {
    await expect(service.getCurrentPrice('')).rejects.toThrow(
      'Symbol is required',
    );
  });

  it('should throw error when symbol does not start with KRW-', async () => {
    await expect(service.getCurrentPrice('BTC')).rejects.toThrow(
      'Invalid symbol format',
    );
  });

현재는 구현되어있는 코드가 없기에, 당연히 테스트 실패를 한다는 것 다들 알고 계실거라 생각하고, 테스트 결과는 생략하겠습니다.

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

// src/exchange/upbit/upbit.service.ts

import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IExchange } from '../interfaces/exchange.interface';

@Injectable()
export class UpbitService implements IExchange {
  constructor(private readonly configService: ConfigService) {}

  async getCurrentPrice(symbol: string): Promise<number> {
    if (!symbol) {
      throw new BadRequestException('Symbol is required.');
    }

    if (!symbol.startsWith('KRW-')) {
      throw new BadRequestException('Invalid symbol format');
    }

    return 0;
  }
}

getCurrentPrice 메소드를 수정했습니다. 테스트를 진행해볼까요?

npm run 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 (6 ms)
    ✓ should throw error when symbol is empty (4 ms)
    ✓ should throw error when symbol does not start with KRW- (1 ms)

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

성공입니다.

유효한 심볼 목록 검증

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

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

...

it('should throw error when symbol is not in the valid list', async () => {
  await expect(service.getCurrentPrice('KRW-INVALID')).rejects.toThrow(
    'Invalid symbol: KRW-INVALID',
  );
});

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

// src/exchange/upbit/upbit.service.ts

import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IExchange } from '../interfaces/exchange.interface';

@Injectable()
export class UpbitService implements IExchange {
  private readonly validSymbols = ['KRW-BTC', 'KRW-ETH', 'KRW-XRP'];

  constructor(private readonly configService: ConfigService) {}

  async getCurrentPrice(symbol: string): Promise<number> {
    if (!symbol) {
      throw new BadRequestException('Symbol is required.');
    }

    if (!symbol.startsWith('KRW-')) {
      throw new BadRequestException('Invalid symbol format');
    }

    if (!this.validSymbols.includes(symbol)) {
      throw new BadRequestException(`Invalid symbol: ${symbol}`);
    }

    return 0;
  }
}

💡 현재 설정된 심볼들의 의미
1. KRW-BTC

  • KRW: 원화 마켓
  • BTC: 비트코인 (Bitcoin)
  1. KRW-ETH
  • KRW: 원화 마켓
  • ETH: 이더리움 (Ethereum)
  1. KRW-XRP
  • KRW: 원화 마켓
  • XRP: 리플 (Ripple)

테스트 목적으로는 이 세 가지 메이저 코인만 설정했지만, 실제로는 더 많은 코인 추가 기능 필요합니다. 나중에는 동적으로 유효한 심볼 목록을 가져오도록 구현할 계획입니다.

테스트 결과입니다. 패스!

npm run 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 (6 ms)
    ✓ should throw error when symbol is empty (5 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)

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

반환 값 & 실제 가격 범위 검증

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

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

// 반환 값 검증
describe('return value validation', () => {
  beforeEach(() => {
    mockedAxios.get.mockResolvedValue({
      data: [{ trade_price: 50000000 }],
    });
  });

  it('should return a valid numeric price', async () => {
    const price = await service.getCurrentPrice('KRW-BTC');
    expect(Number.isFinite(price)).toBeTruthy(); //유한한 숫자인지 검증
  });
});

// 실제 가격 범위 검증
  describe('price range validation', () => {
    beforeEach(() => {
      mockedAxios.get.mockImplementation((url, config) => {
        const symbol = config?.params?.markets;
        let mockPrice: number;

        switch (symbol) {
          case 'KRW-BTC':
            mockPrice = 50000000; // 5천만원
            break;
          case 'KRW-ETH':
            mockPrice = 5000000; // 500만원
            break;
          case 'KRW-XRP':
            mockPrice = 500; // 500원
            break;
          default:
            mockPrice = 0;
        }

        return Promise.resolve({
          data: [{ trade_price: mockPrice }],
        });
      });
    });

    it('should return valid BTC price range', async () => {
      const price = await service.getCurrentPrice('KRW-BTC');
      expect(price).toBeGreaterThan(10000000); // 1천만원
      expect(price).toBeLessThan(200000000); // 2억원
    });

    it('should return valid ETH price range', async () => {
      const price = await service.getCurrentPrice('KRW-ETH');
      expect(price).toBeGreaterThan(1000000); // 100만원
      expect(price).toBeLessThan(10000000); // 1천만원
    });

    it('should return valid XRP price range', async () => {
      const price = await service.getCurrentPrice('KRW-XRP');
      expect(price).toBeGreaterThan(100); // 100원
      expect(price).toBeLessThan(10000); // 1만원
    });

    it('should return price with valid decimal places', async () => {
      const price = await service.getCurrentPrice('KRW-BTC');
      expect(Number.isInteger(price)).toBeTruthy();
    });
  });

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

// src/exchange/upbit/upbit.service.ts
...
import { UpbitMarketPrice } from './types/upbit.types';
...

@Injectable()
export class UpbitService implements IExchange {
  private readonly validSymbols = ['KRW-BTC', 'KRW-ETH', 'KRW-XRP'];
  private readonly apiUrl = 'https://api.upbit.com/v1';

  async getCurrentPrice(symbol: string): Promise<number> {
    // ... 이전 검증 코드 ...

    try {
      // 업비트 API 호출
      // GET https://api.upbit.com/v1/ticker?markets=KRW-BTC
      const { data } = await axios.get<UpbitMarketPrice[]>(
        `${this.apiUrl}/ticker`,
        {
          params: { markets: symbol },
        },
      );

      if (!data?.[0]) {
        throw new BadRequestException('Failed to get price data');
      }

      const price = data[0].trade_price;

      // 가격 범위 검증
      if (!this.isValidPrice(symbol, price)) {
        throw new BadRequestException(`Invalid price range for ${symbol}`);
      }

      return price;
    } catch (error) {
      // ... 에러 처리 ...
    }
  }

  // 코인별 가격 범위 검증 메서드
  private isValidPrice(symbol: string, price: number): boolean {
    const ranges = {
      'KRW-BTC': { min: 10000000, max: 200000000 },  // 1천만원 ~ 2억원
      'KRW-ETH': { min: 1000000, max: 10000000 },    // 100만원 ~ 1천만원
      'KRW-XRP': { min: 100, max: 10000 },           // 100원 ~ 1만원
    };

    const range = ranges[symbol];
    return price >= range.min && price <= range.max && Number.isInteger(price);
  }
}

실제 업비트 개발자 센터의 API 응답 예시

[
  {
    "market": "KRW-BTC",
    "trade_date": "20240822",
    "trade_time": "071602",
    "trade_date_kst": "20240822",
    "trade_time_kst": "161602",
    "trade_timestamp": 1724310962713,
    "opening_price": 82900000,
    "high_price": 83000000,
    "low_price": 81280000,
    "trade_price": 82324000,
    "prev_closing_price": 82900000,
    "change": "FALL",
    "change_price": 576000,
    "change_rate": 0.0069481303,
    "signed_change_price": -576000,
    "signed_change_rate": -0.0069481303,
    "trade_volume": 0.00042335,
    "acc_trade_price": 66058843588.46906,
    "acc_trade_price_24h": 250206655398.15125,
    "acc_trade_volume": 803.00214714,
    "acc_trade_volume_24h": 3047.01625142,
    "highest_52_week_price": 105000000,
    "highest_52_week_date": "2024-03-14",
    "lowest_52_week_price": 34100000,
    "lowest_52_week_date": "2023-09-11",
    "timestamp": 1724310962747
  },
  {
    "market": "KRW-ETH",
    "trade_date": "20240822",
    "trade_time": "071600",
    "trade_date_kst": "20240822",
    "trade_time_kst": "161600",
    "trade_timestamp": 1724310960320,
    "opening_price": 3564000,
    "high_price": 3576000,
    "low_price": 3515000,
    "trade_price": 3560000,
    "prev_closing_price": 3564000,
    "change": "FALL",
    "change_price": 4000,
    "change_rate": 0.0011223345,
    "signed_change_price": -4000,
    "signed_change_rate": -0.0011223345,
    "trade_volume": 0.00281214,
    "acc_trade_price": 14864479133.80843,
    "acc_trade_price_24h": 59043494176.58761,
    "acc_trade_volume": 4188.3697943,
    "acc_trade_volume_24h": 16656.93091147,
    "highest_52_week_price": 5783000,
    "highest_52_week_date": "2024-03-13",
    "lowest_52_week_price": 2087000,
    "lowest_52_week_date": "2023-10-12",
    "timestamp": 1724310960351
  }
]

해당 내용은 업비트 개발자센터를 참고하시면 더 정확히 보실 수 있습니다.

테스트 결과입니다. 휴, 다행히 성공이네요.

npm run 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 (6 ms)
    symbol validation
      ✓ should throw error when symbol is empty (7 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)

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

API 통합 테스트

  • 실제 업비트 api와의 통합을 검증하는 테스트입니다.

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

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

// API 통합 테스트
describe('API integration', () => {
  // 올바른 엔드포인트로 API 호출하는지 검증
  it('should make API call to correct endpoint', async () => {
    mockedAxios.get.mockResolvedValueOnce({
      data: [{ trade_price: 50000000 }],
    });
    await service.getCurrentPrice('KRW-BTC');
    expect(mockedAxios.get).toHaveBeenCalledWith(
      'https://api.upbit.com/v1/ticker',  // 정확한 엔드포인트 URL
      expect.any(Object),                 // config 객체는 별도 테스트
    );
  });

  // 요청 파라미터 검증
  it('should include markets parameter in API call', async () => {
    mockedAxios.get.mockResolvedValueOnce({
      data: [{ trade_price: 50000000 }],
    });
    await service.getCurrentPrice('KRW-BTC');
    expect(mockedAxios.get).toHaveBeenCalledWith(
      expect.any(String),
      expect.objectContaining({
        params: { markets: 'KRW-BTC' },  // markets 파라미터 검증
      }),
    );
  });

  // 빈 응답 처리 검증
  it('should handle empty response data', async () => {
    mockedAxios.get.mockResolvedValueOnce({ data: [] });
    await expect(service.getCurrentPrice('KRW-BTC')).rejects.toThrow(
      'Failed to get price data',
    );
  });

  // API 에러 처리 검증
  it('should handle API error', async () => {
    const error = new Error('API request failed') as any;
    error.isAxiosError = true;
    error.response = {
      data: {
        error: {
          message: 'API request failed',
        },
      },
    };

    mockedAxios.get.mockRejectedValueOnce(error);

    await expect(service.getCurrentPrice('KRW-BTC')).rejects.toThrow(
      'Failed to fetch price: API request failed',
    );
  });
});

💡 API 통합 테스트의 주요 검증 포인트
1. 올바른 엔드포인트 URL 사용
2. 필요한 요청 파라미터 포함
3. 빈 응답 데이터 처리
4. API 에러 상황 처리

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

// src/exchange/upbit/upbit.service.ts
...

async getCurrentPrice(symbol: string): Promise<number> {
  // ... 심볼 검증 ...

  try {
    const { data } = await axios.get<UpbitMarketPrice[]>(
      `${this.apiUrl}/ticker`,  // 올바른 엔드포인트
      {
        params: { markets: symbol },  // 필요한 파라미터
      },
    );

    if (!data?.[0]) {  // 빈 응답 처리
      throw new BadRequestException('Failed to get price data');
    }

    // ... 가격 검증 ...

    return data[0].trade_price;
  } catch (error: any) { // API 에러 처리
      const errorMessage =
        error?.response?.data?.error?.message || error.message;
      throw new BadRequestException(`Failed to fetch price: ${errorMessage}`);
  }	
}

테스트 결과입니다.

npm run 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 (6 ms)
    symbol validation
      ✓ should throw error when symbol is empty (6 ms)
      ✓ should throw error when symbol does not start with KRW- (5 ms)
      ✓ should throw error when symbol is not in the valid list (2 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 (1 ms)
      ✓ should include markets parameter in API call (1 ms)
      ✓ should handle empty response data
      ✓ should handle API error (1 ms)

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

실시간 시세 조회 기능을 완성하였습니다. 그러면 이제 mock-test가 아니라 실제로 서비스의 메소드가 잘작동하는지 테스트를 해봐야겠죠? 이는 e2e (end-to-end) 테스트를 통해 진행해보겠습니다!

e2e (end-to-end) 테스트

/test/exchange.e2e-spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { UpbitService } from '../src/exchange/upbit/upbit.service';

describe('Exchange E2E Test', () => {
  let app: INestApplication;
  let upbitService: UpbitService;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [ConfigModule.forRoot()],
      providers: [UpbitService],
    }).compile();

    app = moduleFixture.createNestApplication(); // NestJs 인스턴스 생성
    await app.init();

    upbitService = moduleFixture.get<UpbitService>(UpbitService);
  });

  afterAll(async () => {
    await app.close();
  });

  describe('Upbit Price API', () => {
    const symbols = ['KRW-BTC', 'KRW-ETH', 'KRW-XRP'];

    // 각 심볼에 대해 테스트
    symbols.forEach((symbol) => {
      it(`should fetch real ${symbol} price`, async () => {
        const price = await upbitService.getCurrentPrice(symbol);

        // 가격이 숫자인지 확인
        expect(typeof price).toBe('number');

        // 가격이 양수인지 확인
        expect(price).toBeGreaterThan(0);

        // 정수인지 확인
        expect(Number.isInteger(price)).toBe(true);

        console.log(`${symbol} price: ${price.toLocaleString()}`);
      }, 10000); 
      // 타임아웃 10초, 원래 기본 값은 5초이지만, e2e 테스트시에는 네트워크를 이용하기 때문에,
      // 시간이 더 많이 소요될 것으로 예상되어 10초로 설정.
    });
  });
});

테스트 결과

npm run test:e2e test/exchange.e2e-spec.ts

> auto-coin-trader-bot@0.0.1 test:e2e
> jest --config ./test/jest-e2e.json test/exchange.e2e-spec.ts

  console.log
    KRW-BTC price: 138,608,000원

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

  console.log
    KRW-ETH price: 5,583,000원

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

  console.log
    KRW-XRP price: 3,436원

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

 PASS  test/exchange.e2e-spec.ts
  Exchange E2E Test
    Upbit Price API
      ✓ should fetch real KRW-BTC price (91 ms)
      ✓ should fetch real KRW-ETH price (41 ms)
      ✓ should fetch real KRW-XRP price (32 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        2.062 s
Ran all test suites matching /test\/exchange.e2e-spec.ts/i.
  • 보시다시피, 로그에도 현 시간 시세가 잘찍혔고, 테스트도 모두 성공했습니다!! (근데, 역시나 코인 시세는 비싸군여.)

TDD의 길은 멀고도 험하군여...

그 다음은 잔고 조회 기능 구현으로 찾아오겠습니다.

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

0개의 댓글