diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cd972e..9cc6c4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: # 패키지는 pykis/ 에서 kis_agent/ 로 이름이 바뀌었다. 경로가 낡아 있던 동안 # lint/bandit/LOC/커버리지가 존재하지 않는 디렉터리를 검사하며 통과했다. src-path: 'kis_agent/' - test-path: 'tests/unit/' - coverage-threshold: 0 + test-path: 'tests/' + coverage-threshold: 100 max-line-count: 1500 secrets: inherit diff --git a/.github/workflows/coverage-boost.yml b/.github/workflows/coverage-boost.yml index 365310b..bfa2f49 100644 --- a/.github/workflows/coverage-boost.yml +++ b/.github/workflows/coverage-boost.yml @@ -4,18 +4,18 @@ on: pull_request: types: [opened, synchronize] paths: - - "pykis/**/*.py" + - "kis_agent/**/*.py" - "tests/**/*.py" workflow_dispatch: inputs: target_coverage: description: "Target coverage percentage" required: false - default: "70" + default: "100" env: PYTHON_VERSION: "3.12" - TARGET_COVERAGE: ${{ github.event.inputs.target_coverage || '70' }} + TARGET_COVERAGE: ${{ github.event.inputs.target_coverage || '100' }} jobs: coverage-analysis: @@ -46,11 +46,14 @@ jobs: id: coverage run: | # Run tests with coverage - pytest tests/unit/ \ - --cov=pykis \ + pytest tests/ \ + --cov=kis_agent \ + --cov-fail-under="${TARGET_COVERAGE}" \ --cov-report=json:coverage.json \ --cov-report=term-missing \ - -q --tb=no || true + --timeout=60 \ + --disable-warnings \ + -q --tb=no # Extract overall coverage TOTAL_COV=$(python -c "import json; print(json.load(open('coverage.json'))['totals']['percent_covered'])" 2>/dev/null || echo "0") @@ -69,7 +72,7 @@ jobs: low_coverage = [] for filepath, info in data.get('files', {}).items(): - if filepath.startswith('pykis/'): + if filepath.startswith('kis_agent/'): pct = info['summary']['percent_covered'] missing = info['missing_lines'] if pct < TARGET and missing: @@ -175,4 +178,3 @@ jobs: coverage.json coverage_report.md low_coverage_files.txt - diff --git a/kis_agent/core/client.py b/kis_agent/core/client.py index 61b7c45..a29d964 100644 --- a/kis_agent/core/client.py +++ b/kis_agent/core/client.py @@ -381,7 +381,9 @@ def make_request( data = None try: if self.verbose: - logger.info(f"[API] ({method}) {tr_id} 시도 {attempt+1}/{retries}") + logger.info( + f"[API] ({method}) {tr_id} 시도 {attempt + 1}/{retries}" + ) response = httpx.request( method.upper(), @@ -396,7 +398,7 @@ def make_request( data = response.json() except json.JSONDecodeError: logger.error( - f"[{tr_id}] JSON 디코드 실패 (시도 {attempt+1}/{retries})" + f"[{tr_id}] JSON 디코드 실패 (시도 {attempt + 1}/{retries})" ) logger.error( f"[{tr_id}] 원시 응답 텍스트: {response.text[:500]}..." @@ -430,11 +432,11 @@ def make_request( self.rate_limiter.report_success() return data else: - if response.status_code == 200 and rt_cd != "0": + if response.status_code == 200: api_msg = data.get("msg1", "") api_code = data.get("rt_cd") logger.warning( - f"[{tr_id}] API 오류 응답 (시도 {attempt+1}/{retries}): {api_msg} (code: {api_code})" + f"[{tr_id}] API 오류 응답 (시도 {attempt + 1}/{retries}): {api_msg} (code: {api_code})" ) # 유량 제한 에러 체크 @@ -477,7 +479,7 @@ def make_request( if attempt < retries - 1: logger.warning( - f"[{tr_id}] API 유량 제한 감지 (code: {api_code}). 0.5초 대기 후 재시도... ({attempt+1}/{retries})" + f"[{tr_id}] API 유량 제한 감지 (code: {api_code}). 0.5초 대기 후 재시도... ({attempt + 1}/{retries})" ) time.sleep( 0.5 @@ -495,7 +497,7 @@ def make_request( "status_code": response.status_code, "error_type": "ApiError", } - elif response.status_code != 200: + else: http_error_msg = ( data.get("msg1", response.text) if data and isinstance(data, dict) @@ -506,7 +508,7 @@ def make_request( if data and isinstance(data, dict) else None ) - log_entry = f"[{tr_id}] HTTP 오류 응답 (시도 {attempt+1}/{retries}): Status {response.status_code}, Message: {http_error_msg}" + log_entry = f"[{tr_id}] HTTP 오류 응답 (시도 {attempt + 1}/{retries}): Status {response.status_code}, Message: {http_error_msg}" if http_error_code_from_json: log_entry += ( f" (API Code in JSON: {http_error_code_from_json})" @@ -530,13 +532,8 @@ def make_request( "error_type": "HTTPErrorFinal", } ) - else: - logger.error( - f"[{tr_id}] 로직 오류: 예상치 못한 HTTP/API 상태 (시도 {attempt+1}/{retries}). 응답: {data}. HTTP Status: {response.status_code if response else 'N/A'}" - ) - return data except (httpx.RequestError, requests.exceptions.RequestException) as e: - logger.error(f"[{tr_id}] 요청 실패 (시도 {attempt+1}/{retries}): {e}") + logger.error(f"[{tr_id}] 요청 실패 (시도 {attempt + 1}/{retries}): {e}") last_exception = e if attempt < retries - 1: time.sleep( @@ -552,8 +549,6 @@ def make_request( logger.error( f"[{tr_id}] 최종 실패 후 루프 외부 도달: {last_exception if last_exception else '알 수 없는 오류'}" ) - if last_exception: - raise last_exception raise Exception("Unknown error after retries") def refresh_token(self) -> None: diff --git a/kis_agent/core/rate_limiter.py b/kis_agent/core/rate_limiter.py index 243c3cf..e094981 100644 --- a/kis_agent/core/rate_limiter.py +++ b/kis_agent/core/rate_limiter.py @@ -163,8 +163,9 @@ def __init__( # 1초 sliding window 내 N개 제한을 확실히 지키려면 슬롯 간격이 1/RPS보다 # 조금 커야 함. 그렇지 않으면 윈도우 경계와 슬롯이 우연히 정확히 겹치며 # N+1개가 1초 안에 들어올 수 있다 (예: 5 RPS에 200ms 간격이면 윈도우 - # [t-ε, t+1-ε)에 6개). 1ms safety로 이 corner case를 방지. - rps_floor = (1.0 / requests_per_second) + 0.001 + # [t-ε, t+1-ε)에 6개). 실제 sleep의 undershoot와 스레드 재개 편차도 + # 고려해 20ms safety를 둔다. + rps_floor = (1.0 / requests_per_second) + 0.020 self.min_interval = max(min_interval_ms / 1000.0, rps_floor) self.burst_size = burst_size self.enable_adaptive = enable_adaptive @@ -215,8 +216,9 @@ def acquire(self, priority: int = 0) -> float: # 만큼 추가 대기. monotonic 시계와 time.sleep()의 oversleep/undersleep # 누적으로 윈도우 경계 직전에 요청이 몰리는 것을 방지. # macOS/Linux의 time.sleep은 최대 ~10ms undersleep 발생할 수 있어 - # 보수적으로 20ms padding (1초 한도의 2% 비용으로 한도 위반 확실히 차단). - _SAFETY_PADDING_S = 0.020 + # CI/macOS의 스레드 스케줄링 편차까지 고려해 50ms를 둔다. 경계에서 + # 실제 완료 시각이 앞당겨져 sliding-window 한도를 넘는 것을 막는다. + _SAFETY_PADDING_S = 0.050 # 2) 초당 한도: 1초 이내 요청이 RPS 이상이면 가장 오래된 요청 # + 1초 + padding이 다음 가용 시점. burst는 priority>=1에서만 허용하되, diff --git a/kis_agent/core/response_processor.py b/kis_agent/core/response_processor.py index dedd1fa..18c7f3f 100644 --- a/kis_agent/core/response_processor.py +++ b/kis_agent/core/response_processor.py @@ -21,7 +21,7 @@ class ResponseProcessor(ABC): @abstractmethod def process(self, response: Dict, field_type: Optional[str] = None) -> Any: """응답 처리 추상 메서드""" - pass + raise NotImplementedError class DictResponseProcessor(ResponseProcessor): diff --git a/kis_agent/futures/historical.py b/kis_agent/futures/historical.py index 9d16627..8d7c970 100644 --- a/kis_agent/futures/historical.py +++ b/kis_agent/futures/historical.py @@ -332,26 +332,21 @@ def _fetch_page( } bars.append(bar) - # 다음 페이지 정보 (가장 오래된 데이터 기준) - if bars: - oldest = bars[-1] - next_date = oldest.get("date") - next_time = oldest.get("time") - # 1분 이전으로 설정 - if next_time: - try: - t = datetime.strptime(f"{next_date}{next_time}", "%Y%m%d%H%M%S") - t -= timedelta(minutes=1) - next_date = t.strftime("%Y%m%d") - next_time = t.strftime("%H%M%S") - except ValueError: - # 날짜 파싱 실패 시 기존 값 유지하고 로깅 - logger.warning( - f"날짜 파싱 실패: {next_date}{next_time}, 기존 값 유지" - ) - return bars, next_date, next_time - - return bars, None, None + # output2가 비어 있는 경우는 위에서 반환했으므로 bars에는 항상 항목이 있다. + oldest = bars[-1] + next_date = oldest.get("date") + next_time = oldest.get("time") + # 1분 이전으로 설정 + if next_time: + try: + t = datetime.strptime(f"{next_date}{next_time}", "%Y%m%d%H%M%S") + t -= timedelta(minutes=1) + next_date = t.strftime("%Y%m%d") + next_time = t.strftime("%H%M%S") + except ValueError: + # 날짜 파싱 실패 시 기존 값 유지하고 로깅 + logger.warning(f"날짜 파싱 실패: {next_date}{next_time}, 기존 값 유지") + return bars, next_date, next_time def get_contract_history( self, diff --git a/kis_agent/futures/vkospi.py b/kis_agent/futures/vkospi.py index c44117a..f96677c 100644 --- a/kis_agent/futures/vkospi.py +++ b/kis_agent/futures/vkospi.py @@ -140,8 +140,6 @@ def _calc_vega_weighted_iv(rows: list[dict]) -> float | None: return None total_vega = sum(v for _, v in valid) - if total_vega == 0: - return None return sum(iv * v for iv, v in valid) / total_vega diff --git a/kis_agent/stock/api.py b/kis_agent/stock/api.py index e4fbe38..8b8f5f2 100644 --- a/kis_agent/stock/api.py +++ b/kis_agent/stock/api.py @@ -54,25 +54,13 @@ def get_second_thursday(year: int, month: int) -> datetime: if current_month in expiry_months: expiry_date = get_second_thursday(current_year, current_month) if today.date() > expiry_date.date(): - found = False - for month in expiry_months: - if month > current_month: - expiry_month = month - found = True - break - if not found: - expiry_month = 3 + expiry_month = next( + (month for month in expiry_months if month > current_month), 3 + ) else: expiry_month = current_month else: - found = False - for month in expiry_months: - if month > current_month: - expiry_month = month - found = True - break - if not found: - expiry_month = 3 + expiry_month = next(month for month in expiry_months if month > current_month) return f"A016{expiry_month:02d}" @@ -114,8 +102,7 @@ def __init__( ) logger.warning( - "DEPRECATION: pykis.stock.api.StockAPI는 레거시입니다. " - "from kis_agent.stock import StockAPI를 사용하세요." + "DEPRECATION: pykis.stock.api.StockAPI는 레거시입니다. from kis_agent.stock import StockAPI를 사용하세요." ) def __getattr__(self, name: str) -> Any: @@ -137,8 +124,7 @@ def __getattr__(self, name: str) -> Any: return getattr(api, name) raise AttributeError( - f"'{type(self).__name__}' object has no attribute '{name}'. " - f"This is a legacy class. Use 'from kis_agent.stock import StockAPI' instead." + f"'{type(self).__name__}' object has no attribute '{name}'. This is a legacy class. Use 'from kis_agent.stock import StockAPI' instead." ) # ===== 주문 관련 메서드 - AccountAPI로 이동됨 ===== @@ -147,15 +133,13 @@ def __getattr__(self, name: str) -> Any: def order_cash(self, *args, **kwargs): """DEPRECATED: AccountAPI.order_cash를 사용하세요""" raise DeprecationWarning( - "order_cash는 StockAPI에서 제거되었습니다. " - "AccountAPI.order_cash() 또는 agent.order_stock_cash()를 사용하세요." + "order_cash는 StockAPI에서 제거되었습니다. AccountAPI.order_cash() 또는 agent.order_stock_cash()를 사용하세요." ) def order_credit(self, *args, **kwargs): """DEPRECATED: AccountAPI.order_credit를 사용하세요""" raise DeprecationWarning( - "order_credit는 StockAPI에서 제거되었습니다. " - "AccountAPI.order_credit() 또는 agent.order_stock_credit()를 사용하세요." + "order_credit는 StockAPI에서 제거되었습니다. AccountAPI.order_credit() 또는 agent.order_stock_credit()를 사용하세요." ) diff --git a/kis_agent/stock/api_improved.py b/kis_agent/stock/api_improved.py index d9d230e..9b656e9 100644 --- a/kis_agent/stock/api_improved.py +++ b/kis_agent/stock/api_improved.py @@ -276,7 +276,7 @@ def get_foreign_net_buy(self, code: str, date: str = None) -> tuple: # API 호출 response = self._make_request_dict( - endpoint=API_ENDPOINTS["INQUIRE_DAILY_TRADE"], + endpoint=API_ENDPOINTS["INQUIRE_INVESTOR"], tr_id="FHKST01010900", params={ "fid_cond_mrkt_div_code": "J", @@ -332,7 +332,7 @@ def get_holidays(self, year: str = None) -> pd.DataFrame: # DataFrame으로 반환 return self._make_request_dataframe( - endpoint=API_ENDPOINTS["INQUIRE_HOLIDAY"], tr_id="CTCA0903R", params=params + endpoint=API_ENDPOINTS["CHK_HOLIDAY"], tr_id="CTCA0903R", params=params ) diff --git a/kis_agent/stock/condition.py b/kis_agent/stock/condition.py index 4d6f12c..32fda63 100644 --- a/kis_agent/stock/condition.py +++ b/kis_agent/stock/condition.py @@ -208,13 +208,10 @@ def get_condition_stocks_dict(agent) -> Dict[str, List[Dict]]: logging.warning("조건검색식 종목 조회 실패") return {} - # condition API에서 직접 리스트를 반환하므로 바로 사용 + # condition API에서 직접 리스트를 반환하므로 바로 사용한다. + # 빈 결과는 위의 `if not stocks`에서 이미 처리됐다. stock_list = stocks - if not stock_list: - logging.warning("조건검색식 종목이 없습니다.") - return {} - # StockMonitor.py에서 기대하는 형태로 변환 # {조건검색식명: [종목정보리스트]} 형태 condition_stocks = {"기본조건검색식": []} # 기본 조건검색식으로 설정 diff --git a/kis_agent/websocket/client.py b/kis_agent/websocket/client.py index 445d2b3..21a376a 100644 --- a/kis_agent/websocket/client.py +++ b/kis_agent/websocket/client.py @@ -958,7 +958,7 @@ async def poll_final_price(self): f"{ticker}^{trade_time}^{final_price}" ) except Exception as e: - logging.error("최종 가격 REST 호출 오류:", e) + logging.error("최종 가격 REST 호출 오류: %s", e) await asyncio.sleep(1) # 장 종료 시 1초 대기 else: unsubscribed = False # 장중이면 해제 플래그 초기화 @@ -1074,14 +1074,14 @@ def should_exit(self, stock_code): # 디버그 로그 logging.info(f"\n[EXIT CHECK] {stock_code}") logging.info(f"수익률: {profit_ratio:.2f}%") - logging.info( - f"RSI: {rsi_now:.1f} (이전: {rsi_prev:.1f if rsi_prev else 'N/A'})" - ) + previous_rsi = f"{rsi_prev:.1f}" if rsi_prev is not None else "N/A" + logging.info(f"RSI: {rsi_now:.1f} (이전: {previous_rsi})") if len(strength_series) >= 3: logging.info( f"체결강도: {recent_strength[-1]:.1f} (3분전: {recent_strength[0]:.1f})" ) - logging.info(f"ATR: {atr:.2f if atr else 'N/A'}") + atr_text = f"{atr:.2f}" if atr is not None else "N/A" + logging.info(f"ATR: {atr_text}") # 모든 조건 만족 시 익절 신호 if rsi_falling and strength_falling and atr_high: @@ -1253,8 +1253,12 @@ def display_balance_info(self): return self.update_price_and_indicators() - async def connect(self): - """웹소켓 연결 및 체결/호가 구독.""" + async def connect(self, stop_event=None): + """웹소켓 연결 및 체결/호가 구독. + + ``stop_event``가 전달되면 설정 시점에 현재 수신·재연결 루프를 정상 + 종료한다. 기본값 ``None``은 기존의 자동 재연결 동작을 유지한다. + """ self.get_approval() logging.info("\n" + "=" * 50) logging.info("[INFO] 실시간 체결 정보 표시 시작") @@ -1271,7 +1275,7 @@ async def connect(self): from datetime import datetime from datetime import time as dt_time - while True: # 자동 재연결을 위한 외부 루프 + while not (stop_event and stop_event.is_set()): # 자동 재연결을 위한 외부 루프 try: async with websockets.connect( self.url, @@ -1416,7 +1420,7 @@ async def connect(self): sys.stdout.flush() ping_retry_count = 0 # ping/pong 재시도 카운터 초기화 - while True: + while not (stop_event and stop_event.is_set()): # 장 종료 후(15:30)에는 데이터 수신을 일시 중단하고, 다음 거래일 9:00까지 대기 now = datetime.now() if now.time() > dt_time(15, 30): @@ -1424,7 +1428,7 @@ async def connect(self): "[INFO] 장 종료 감지됨. 다음 거래일까지 대기 중..." ) sys.stdout.flush() - while True: + while not (stop_event and stop_event.is_set()): await asyncio.sleep(30) if datetime.now().time() < dt_time(9, 0): break diff --git a/kis_agent/websocket/ws_agent.py b/kis_agent/websocket/ws_agent.py index 9ff47e3..f7ac794 100644 --- a/kis_agent/websocket/ws_agent.py +++ b/kis_agent/websocket/ws_agent.py @@ -701,12 +701,12 @@ def _handle_subscription_response(self, json_data: dict) -> bool: return True # 일반 구독 성공/실패 로그 (대기 중이 아닌 경우) + if "UNSUBSCRIBE" in msg1.upper(): + logger.info(f"구독 해제: {tr_id} ({tr_key})") + return True if "SUBSCRIBE SUCCESS" in msg1.upper(): logger.info(f"구독 성공: {tr_id} ({tr_key})") return True - elif "UNSUBSCRIBE" in msg1.upper(): - logger.info(f"구독 해제: {tr_id} ({tr_key})") - return True return False diff --git a/pyproject.toml b/pyproject.toml index 52e55c8..947c7bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ classifiers = [ ] dependencies = [ "requests>=2.31.0,<3.0.0", + "httpx>=0.24.0,<1.0.0", "pandas>=1.5.0,<3.0.0", "python-dotenv>=0.21.0,<2.0.0", "pycryptodome>=3.23.0,<4.0.0", @@ -49,6 +50,7 @@ dev = [ "pre-commit>=3.3.0,<5.0.0", "openpyxl>=3.0.0,<4.0.0", "pytest-asyncio>=0.21.0,<1.0.0", + "pytest-timeout>=2.2.0,<3.0.0", ] async = [ "aiohttp>=3.9.0,<4.0.0", @@ -109,7 +111,7 @@ markers = [ "slow: marks tests that require network access (deselect with '-m \"not slow\"')", ] python_files = ["test_*.py"] -addopts = "-v --cov=kis_agent --ignore=tests/unit/test_auth.py --ignore=tests/unit/test_client.py" +addopts = "-v --cov=kis_agent --cov-fail-under=100 --ignore=tests/unit/test_auth.py --ignore=tests/unit/test_client.py" [tool.ruff] target-version = "py38" @@ -152,4 +154,3 @@ ignore = [ [tool.ruff.format] quote-style = "double" indent-style = "space" - diff --git a/tests/conftest.py b/tests/conftest.py index fc0888d..42a94c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,17 +9,29 @@ import pytest -from kis_agent import Agent - -# src 디렉토리를 파이썬 경로에 추가하여 패키지를 임포트합니다. +# 저장소 체크아웃에서 직접 실행할 때도 패키지를 임포트할 수 있게 루트를 먼저 추가합니다. ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -SRC_DIR = os.path.join(ROOT_DIR, "src") -if SRC_DIR not in sys.path: - sys.path.insert(0, SRC_DIR) +if ROOT_DIR not in sys.path: + sys.path.insert(0, ROOT_DIR) +from kis_agent import Agent from kis_agent.core.config import KISConfig +def pytest_collection_modifyitems(config, items): + """자격증명이 없는 로컬 실행에서 실계좌 테스트를 안전하게 제외한다.""" + required_credentials = ("KIS_APP_KEY", "KIS_APP_SECRET", "KIS_ACCOUNT_NO") + if all(os.getenv(name) for name in required_credentials): + return + + skip_credentials = pytest.mark.skip( + reason="KIS_APP_KEY, KIS_APP_SECRET, KIS_ACCOUNT_NO 자격증명이 필요합니다" + ) + for item in items: + if item.get_closest_marker("requires_credentials"): + item.add_marker(skip_credentials) + + @pytest.fixture def account_info(): """테스트용 계좌 정보를 반환합니다.""" diff --git a/tests/test_cache_realworld.py b/tests/test_cache_realworld.py index 2480d24..38741b6 100644 --- a/tests/test_cache_realworld.py +++ b/tests/test_cache_realworld.py @@ -38,21 +38,23 @@ def test_price_data_caching(): } ) - cache.set(cache_key, test_data, ttl=ttl) + now = 1_000.0 + with patch( + "kis_agent.core.cache.time.time", side_effect=[now, now, now + 15, now + 31] + ): + cache.set(cache_key, test_data, ttl=ttl) - # 즉시 조회 - 캐시 히트 - cached = cache.get(cache_key) - assert cached is not None, "캐시된 데이터를 찾을 수 없습니다" + # 즉시 조회 - 캐시 히트 + cached = cache.get(cache_key) + assert cached is not None, "캐시된 데이터를 찾을 수 없습니다" - # 15초 후 - 아직 유효 - time.sleep(15) - cached = cache.get(cache_key) - assert cached is not None, "15초 후에도 캐시가 유효해야 합니다" + # 15초 후 - 아직 유효 + cached = cache.get(cache_key) + assert cached is not None, "15초 후에도 캐시가 유효해야 합니다" - # 31초 후 - 만료됨 - time.sleep(16) - cached = cache.get(cache_key) - assert cached is None, "31초 후에는 캐시가 만료되어야 합니다" + # 31초 후 - 만료됨 + cached = cache.get(cache_key) + assert cached is None, "31초 후에는 캐시가 만료되어야 합니다" def _get_test_cases(): @@ -114,11 +116,14 @@ def test_different_context_ttls(): """다양한 컨텍스트별 TTL 동작 테스트""" cache = APICache() test_cases = _get_test_cases() - stored_keys = _store_test_data(cache, test_cases) # 11초 후 체크 - 10초 TTL만 만료 - time.sleep(11) - _verify_ttl_expiry(cache, stored_keys) + now = 1_000.0 + with patch( + "kis_agent.core.cache.time.time", side_effect=[now] * 4 + [now + 11] * 4 + ): + stored_keys = _store_test_data(cache, test_cases) + _verify_ttl_expiry(cache, stored_keys) def test_cache_performance(): diff --git a/tests/test_cache_ttl.py b/tests/test_cache_ttl.py index 94e2ffa..2c4d9c9 100644 --- a/tests/test_cache_ttl.py +++ b/tests/test_cache_ttl.py @@ -4,6 +4,7 @@ """ import time +from unittest.mock import patch import pytest @@ -92,17 +93,19 @@ def test_cache_expiry_behavior(): """ """ cache = APICache(default_ttl=1) # 1 TTL - # - cache.set("test_key", {"data": "test_value"}, ttl=1) + with patch( + "kis_agent.core.cache.time.time", side_effect=[1_000.0, 1_000.0, 1_001.1] + ): + # + cache.set("test_key", {"data": "test_value"}, ttl=1) - # - - assert cache.get("test_key") is not None - assert cache.hits == 1 + # - + assert cache.get("test_key") is not None + assert cache.hits == 1 - # 1.1 - () - time.sleep(1.1) - assert cache.get("test_key") is None - assert cache.misses == 1 + # 1.1 - () + assert cache.get("test_key") is None + assert cache.misses == 1 print(" ") @@ -115,25 +118,27 @@ def test_cache_performance_with_different_ttls(): # 시세 데이터 (30초 TTL) price_endpoint = "/uapi/domestic-stock/v1/quotations/inquire-price" cache_key_price = cache._make_key({"endpoint": price_endpoint, "code": "005930"}) - cache.set( - cache_key_price, - {"price": 70000}, - ttl=cache.get_ttl_for_endpoint(price_endpoint), - ) - # 종목 정보 (3600초 TTL) info_endpoint = "/uapi/domestic-stock/v1/quotations/inquire-stock-info" cache_key_info = cache._make_key({"endpoint": info_endpoint, "code": "005930"}) - cache.set( - cache_key_info, - {"name": "삼성전자"}, - ttl=cache.get_ttl_for_endpoint(info_endpoint), - ) + with patch( + "kis_agent.core.cache.time.time", + side_effect=[1_000.0, 1_000.0, 1_031.0, 1_031.0], + ): + cache.set( + cache_key_price, + {"price": 70000}, + ttl=cache.get_ttl_for_endpoint(price_endpoint), + ) + cache.set( + cache_key_info, + {"name": "삼성전자"}, + ttl=cache.get_ttl_for_endpoint(info_endpoint), + ) - # 31초 후 - 시세는 만료, 종목정보는 유효 - time.sleep(31) - assert cache.get(cache_key_price) is None # 만료됨 - assert cache.get(cache_key_info) is not None # 유효 + # 31초 후 - 시세는 만료, 종목정보는 유효 + assert cache.get(cache_key_price) is None # 만료됨 + assert cache.get(cache_key_info) is not None # 유효 print(" TTL ") diff --git a/tests/unit/test_account_profit_extra.py b/tests/unit/test_account_profit_extra.py new file mode 100644 index 0000000..9534760 --- /dev/null +++ b/tests/unit/test_account_profit_extra.py @@ -0,0 +1,144 @@ +"""AccountProfitAPI의 빈 응답과 오류 반환 경로 회귀 테스트.""" + +from unittest.mock import MagicMock + +from kis_agent.account.profit_api import AccountProfitAPI + + +def _api(): + api = object.__new__(AccountProfitAPI) + api.account = {"CANO": "12345678", "ACNT_PRDT_CD": "01"} + api._make_request_dict = MagicMock() + return api + + +def test_period_profit_methods_return_none_for_empty_and_failed_responses(): + api = _api() + api._make_request_dict.return_value = None + assert api.inquire_period_trade_profit("20250101", "20250131") is None + assert api.inquire_period_profit("20250101", "20250131") is None + assert api.inquire_period_rights("20250101", "20250131") is None + + api._make_request_dict.side_effect = RuntimeError("offline") + assert api.inquire_period_trade_profit("20250101", "20250131") is None + assert api.inquire_period_profit("20250101", "20250131") is None + assert api.inquire_period_rights("20250101", "20250131") is None + + +def test_period_profit_methods_add_response_metadata_to_dataframes(): + api = _api() + api._make_request_dict.return_value = { + "rt_cd": "0", "msg_cd": "OK", "msg1": "success", "output1": [{"value": "1"}] + } + trade = api.inquire_period_trade_profit("20250101", "20250131") + daily = api.inquire_period_profit("20250101", "20250131") + rights = api.inquire_period_rights("20250101", "20250131") + for frame in (trade, daily, rights): + assert frame.loc[0, "msg1"] == "success" + + +def test_daily_ccld_pagination_returns_none_when_request_fails(): + api = _api() + api.client = MagicMock() + api.client.make_request.side_effect = RuntimeError("offline") + assert api.inquire_daily_ccld("20250101", "20250131", pagination=True) is None + + +def test_daily_ccld_pagination_combines_pages_deduplicates_and_calls_callback(): + api = _api() + first_page = [ + { + "ord_dt": "20250102", "ord_tmd": f"09{index:04d}", "odno": str(index), + "pdno": "005930", "ord_qty": "1", "tot_ccld_qty": "1", "tot_ccld_amt": "10", + } + for index in range(100) + ] + second_page = [first_page[0], { + "ord_dt": "20250101", "ord_tmd": "090000", "odno": "new", "pdno": "000660", + "ord_qty": "2", "tot_ccld_qty": "2", "tot_ccld_amt": "20", + }] + api.client = MagicMock() + api.client.make_request.side_effect = [ + {"rt_cd": "0", "msg1": "조회가 계속됩니다", "output1": first_page, "ctx_area_fk100": "fk", "ctx_area_nk100": "nk"}, + {"rt_cd": "0", "msg1": "완료", "output1": second_page, "output2": {"prsm_tlex_smtl": "30"}}, + ] + callback = MagicMock() + + result = api.inquire_daily_ccld( + "20250101", "20250131", pagination=True, page_callback=callback + ) + + assert result["output2"] == { + "tot_ord_qty": "102", "tot_ccld_qty": "102", "tot_ccld_amt": "1020.0", + "page_count": 2, "total_count": 101, "prsm_tlex_smtl": "30", + } + assert len(result["output1"]) == 101 + assert callback.call_count == 2 + assert api.client.make_request.call_args_list[1].kwargs["headers"] == {"tr_cont": "N"} + + +def test_daily_ccld_single_request_and_profit_dict_wrappers(): + api = _api() + api._make_request_dict.return_value = {"rt_cd": "0"} + assert api.inquire_daily_ccld("20240101", "20240131", pdno="005930") == {"rt_cd": "0"} + assert api._make_request_dict.call_args.kwargs["tr_id"] == "CTSC9215R" + + api._make_request_dict.return_value = {"output1": []} + assert api.get_period_trade_profit("20250101", "20250131") == {"output1": []} + assert api.get_period_profit("20250101", "20250131") == {"output1": []} + + +def test_pagination_handles_initial_error_empty_page_and_missing_keys(): + api = _api() + api.client = MagicMock() + api.client.make_request.return_value = {"rt_cd": "1", "msg1": "bad"} + assert api.inquire_daily_ccld("20250101", "20250131", pagination=True) is None + + api.client.make_request.return_value = {"rt_cd": "0", "output1": []} + empty = api.inquire_daily_ccld("20250101", "20250131", pagination=True) + assert empty["msg_cd"] == "NO_DATA" + + api.client.make_request.return_value = { + "rt_cd": "0", "msg1": "조회가 계속됩니다", "output1": [ + {"ord_dt": "20250101", "odno": "1", "pdno": "005930"} + ], + } + result = api.inquire_daily_ccld("20250101", "20250131", pagination=True) + assert result["output2"]["page_count"] == 1 + + +def test_daily_ccld_exception_and_later_page_failure_return_expected_values(): + api = _api() + api._make_request_dict.side_effect = RuntimeError("offline") + assert api.inquire_daily_ccld("20250101", "20250131") is None + + api.client = MagicMock() + page = [{"ord_dt": "20250101", "odno": str(index), "pdno": "005930"} for index in range(100)] + api.client.make_request.side_effect = [ + {"rt_cd": "0", "msg1": "계속", "output1": page, "ctx_area_fk100": "fk"}, + {"rt_cd": "1", "msg1": "bad"}, + ] + result = api.inquire_daily_ccld("20250101", "20250131", pagination=True) + assert result["output2"]["page_count"] == 1 + + +def test_pagination_short_page_and_extra_summary_fields(): + api = _api() + api.client = MagicMock() + api.client.make_request.return_value = { + "rt_cd": "0", "msg1": "계속", "ctx_area_fk100": "fk", "output1": [ + {"ord_dt": "20250101", "odno": "1", "pdno": "005930"} + ], + "output2": {"pchs_avg_pric": "70000"}, + } + result = api.inquire_daily_ccld("20250101", "20250131", pagination=True) + assert result["output2"]["pchs_avg_pric"] == "70000" + + +def test_constructor_delegates_to_base_api(): + client = MagicMock() + api = AccountProfitAPI( + client, {"CANO": "12345678", "ACNT_PRDT_CD": "01"}, _from_agent=True + ) + assert api.client is client + assert api.account["CANO"] == "12345678" diff --git a/tests/unit/test_agent_extra_paths.py b/tests/unit/test_agent_extra_paths.py new file mode 100644 index 0000000..8757a00 --- /dev/null +++ b/tests/unit/test_agent_extra_paths.py @@ -0,0 +1,48 @@ +"""Agent의 경량 위임 및 백그라운드 사전 로드 실패 경로 테스트.""" + +from unittest.mock import MagicMock, patch + +import kis_agent.core.agent as agent_module + + +def _agent_with_apis(): + agent = object.__new__(agent_module.Agent) + agent.stock_api = object() + agent.market_api = type("Market", (), {"market_only": "market"})() + agent.account_api = object() + agent.program_api = object() + agent.investor_api = type("Investor", (), {"investor_only": "investor"})() + agent.interest_api = object() + return agent + + +def test_properties_and_getattr_delegate_to_remaining_apis(): + agent = _agent_with_apis() + agent.overseas_api = "overseas" + + assert agent.overseas == "overseas" + assert agent.market_only == "market" + assert agent.investor_only == "investor" + with patch.object(agent_module, "get_sector_code_by_market", return_value="sectors") as get_codes: + assert agent.get_sector_code_by_market("all") == "sectors" + get_codes.assert_called_once_with(market="all") + + +def test_preload_master_failure_is_logged_without_blocking(): + agent = object.__new__(agent_module.Agent) + agent.logger = MagicMock() + + class ImmediateThread: + def __init__(self, target, daemon): + self.target = target + assert daemon is True + + def start(self): + self.target() + + with patch.object(agent_module, "_load_stock_master", side_effect=RuntimeError("offline")), patch( + "threading.Thread", ImmediateThread + ): + agent._preload_masters() + + agent.logger.warning.assert_called_once() diff --git a/tests/unit/test_auth_extra.py b/tests/unit/test_auth_extra.py new file mode 100644 index 0000000..cb6296f --- /dev/null +++ b/tests/unit/test_auth_extra.py @@ -0,0 +1,177 @@ +"""auth 모듈의 파일 불일치와 환경 분기 회귀 테스트.""" + +import builtins +import hashlib +import importlib +import json +import runpy +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +auth = importlib.import_module("kis_agent.core.auth") + + +def test_read_token_hash_prefix_and_missing_file(tmp_path): + hashed = tmp_path / "token.json" + app_key = "abcdefgh123" + path = auth._get_token_path_for_app_key(app_key, str(hashed)) + with open(path, "w", encoding="utf-8") as handle: + json.dump({"token": "x", "valid-date": "2099-01-01T00:00:00", "app_key_hash": "wrong"}, handle) + assert auth.read_token(str(hashed), app_key) is None + with open(path, "w", encoding="utf-8") as handle: + json.dump({"token": "x", "valid-date": "2099-01-01T00:00:00", "app_key_prefix": "wrong"}, handle) + assert auth.read_token(str(hashed), app_key) is None + with patch("builtins.open", side_effect=FileNotFoundError("gone")): + assert auth.read_token(str(tmp_path / "missing.json")) is None + + +def test_environment_branches_and_auth_failure(monkeypatch): + original = auth._cfg.copy() + original_paper, original_env = auth._isPaper, auth._TRENV + try: + auth._cfg.update({"my_acct_future": "F", "my_paper_stock": "P", "my_paper_future": "PF", "paper_app": "paper", "paper_sec": "secret"}) + auth.changeTREnv("token", "prod", "03") + assert auth.getTREnv().my_acct == "F" + auth.changeTREnv("token", "vps", "01") + assert auth.isPaperTrading() and auth.getTREnv().my_acct == "P" + auth.changeTREnv("token", "vps", "03") + assert auth.getTREnv().my_acct == "PF" + monkeypatch.setattr(auth, "read_token", lambda **kwargs: None) + monkeypatch.setattr(auth.requests, "post", lambda *args, **kwargs: SimpleNamespace(status_code=500, text="bad")) + with __import__("pytest").raises(RuntimeError): + auth.auth(svr="vps", product="01") + monkeypatch.setattr(auth, "read_token", lambda **kwargs: {"access_token": "cached", "access_token_token_expired": "2099-01-01 00:00:00"}) + assert auth.reAuth(svr="vps")["access_token"] == "cached" + finally: + auth._cfg.clear() + auth._cfg.update(original) + auth._isPaper, auth._TRENV = original_paper, original_env + + +def test_require_aiohttp_reports_installation_guidance(monkeypatch): + real_import = builtins.__import__ + + def fail_aiohttp(name, *args, **kwargs): + if name == "aiohttp": + raise ImportError("missing") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fail_aiohttp) + with pytest.raises(ImportError, match="aiohttp is not installed"): + auth._require_aiohttp() + + +def test_read_token_caches_valid_app_specific_file(tmp_path): + app_key = "abcdefgh123" + base_path = tmp_path / "token.json" + path = auth._get_token_path_for_app_key(app_key, str(base_path)) + with open(path, "w", encoding="utf-8") as handle: + json.dump( + { + "token": "cached-token", "valid-date": "2099-01-01T00:00:00", + "app_key_hash": hashlib.sha256(app_key.encode()).hexdigest()[:16], + }, + handle, + ) + auth._token_cache.clear() + assert auth.read_token(str(base_path), app_key)["access_token"] == "cached-token" + assert len(auth._token_cache) == 1 + + +def test_module_initialization_loads_dotenv_and_creates_token_file( + tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / ".env").write_text("KIS_APP_KEY=test\n", encoding="utf-8") + token_path = tmp_path / "new-token.json" + monkeypatch.setenv("KIS_TOKEN_PATH", str(token_path)) + with patch("dotenv.load_dotenv") as load: + runpy.run_module("kis_agent.core.auth", run_name="kis_agent.core._auth_coverage") + load.assert_called_once_with(dotenv_path=str(tmp_path / ".env"), override=False) + assert json.loads(token_path.read_text(encoding="utf-8")) == {} + + +def test_read_token_direct_format_invalid_format_and_open_race(tmp_path): + path = tmp_path / "token.json" + path.write_text(json.dumps({"access_token": "direct"}), encoding="utf-8") + assert auth.read_token(str(path)) == {"access_token": "direct"} + path.write_text(json.dumps({"unexpected": True}), encoding="utf-8") + assert auth.read_token(str(path)) is None + + with patch.object(auth.os.path, "exists", return_value=True), patch( + "builtins.open", side_effect=FileNotFoundError("raced") + ): + assert auth.read_token(str(path)) is None + + +def test_auth_with_config_issues_and_reuses_token(monkeypatch): + config = SimpleNamespace( + APP_KEY="app", + APP_SECRET="secret", + ACCOUNT_NO="12345678", + ACCOUNT_CODE="01", + BASE_URL="https://example.test", + ) + original_cfg = auth._cfg.copy() + original_env = auth._TRENV + original_headers = auth._base_headers.copy() + response = SimpleNamespace( + status_code=200, + json=lambda: { + "access_token": "issued", + "access_token_token_expired": "2099-01-01 00:00:00", + }, + text="ok", + ) + try: + monkeypatch.setattr(auth, "read_token", MagicMock(return_value=None)) + monkeypatch.setattr(auth.requests, "post", MagicMock(return_value=response)) + monkeypatch.setattr(auth, "save_token", MagicMock()) + issued = auth.auth(config=config) + assert issued["access_token"] == "issued" + auth.save_token.assert_called_once_with( + "issued", "2099-01-01 00:00:00", app_key="app" + ) + assert auth.getTREnv().my_url == "https://example.test" + + auth.read_token.return_value = {"access_token": "cached"} + cached = auth.auth(config=config, product="01") + assert cached["access_token"] == "cached" + assert cached["access_token_token_expired"] + finally: + auth._cfg.clear() + auth._cfg.update(original_cfg) + auth._TRENV = original_env + auth._base_headers.clear() + auth._base_headers.update(original_headers) + + +def test_api_response_exposes_original_response(): + response = SimpleNamespace(status_code=500, headers={}) + assert auth.APIResp(response).getResponse() is response + + +def test_auth_uses_global_product_and_debug_output(monkeypatch, capsys): + original_env = auth._TRENV + original_debug = auth._DEBUG + original_headers = auth._base_headers.copy() + try: + monkeypatch.setattr( + auth, + "read_token", + lambda **_kwargs: { + "access_token": "cached", + "access_token_token_expired": "2099-01-01 00:00:00", + }, + ) + auth._DEBUG = True + result = auth.auth(config=None, svr="prod", product=None) + assert result["access_token"] == "cached" + assert "get AUTH Key completed" in capsys.readouterr().out + finally: + auth._TRENV = original_env + auth._DEBUG = original_debug + auth._base_headers.clear() + auth._base_headers.update(original_headers) diff --git a/tests/unit/test_cli_bridge_extra.py b/tests/unit/test_cli_bridge_extra.py new file mode 100644 index 0000000..c60a15c --- /dev/null +++ b/tests/unit/test_cli_bridge_extra.py @@ -0,0 +1,77 @@ +"""CLI bridge 환경 점검과 main 입출력 경로 테스트.""" + +import io +import runpy +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest + +import kis_agent.cli_bridge as bridge + + +def test_python_installation_and_load_env(tmp_path, monkeypatch): + run = MagicMock(side_effect=[FileNotFoundError(), MagicMock(returncode=0)]) + monkeypatch.setattr(bridge.subprocess, "run", run) + assert bridge.check_python_installation() == (True, "python") + monkeypatch.setattr(bridge.subprocess, "run", MagicMock(side_effect=bridge.subprocess.TimeoutExpired("python", 1))) + assert bridge.check_python_installation() == (False, None) + monkeypatch.chdir(tmp_path) + with patch("dotenv.load_dotenv") as load: + bridge.load_env() + load.assert_not_called() + (tmp_path / ".env").write_text("X=1") + bridge.load_env() + load.assert_called_once_with(".env", override=False) + + +def test_main_not_installed_initialization_failure_and_line_loop(monkeypatch, capsys): + monkeypatch.setattr(bridge, "setup_logging", lambda: None) + monkeypatch.setattr(bridge, "check_python_installation", lambda: (False, None)) + with pytest.raises(SystemExit): + bridge.main() + assert "PythonNotFound" in capsys.readouterr().out + + monkeypatch.setattr(bridge, "check_python_installation", lambda: (True, "python")) + monkeypatch.setattr(bridge, "create_agent", lambda: (_ for _ in ()).throw(RuntimeError("bad init"))) + with pytest.raises(SystemExit): + bridge.main() + assert "RuntimeError" in capsys.readouterr().out + + agent = MagicMock() + monkeypatch.setattr(bridge, "create_agent", lambda: agent) + monkeypatch.setattr(bridge, "check_market_status", lambda _: None) + monkeypatch.setattr(bridge.sys, "stdin", io.StringIO("\nfirst\nsecond\n")) + monkeypatch.setattr(bridge, "handle_request", MagicMock(side_effect=["{\"ok\": 1}", RuntimeError("boom")])) + bridge.main() + output = capsys.readouterr().out + assert '"ok": 1' in output and "Unexpected error" in output + + +def test_logging_market_holiday_and_timeout_format(monkeypatch): + with patch.object(bridge.logging, "basicConfig") as config: + bridge.setup_logging() + config.assert_called_once() + + saturday = datetime(2025, 1, 4, 10, 0, 0) + monkeypatch.setattr(bridge, "datetime", MagicMock(now=MagicMock(return_value=saturday))) + bridge._market_status.update({"checked": False, "notice": None, "last_business_day": None}) + agent = MagicMock() + agent.stock_api.is_holiday.side_effect = [True, False] + bridge.check_market_status(agent) + assert bridge._market_status["last_business_day"] == "20250103" + + bridge._market_status.update({"checked": False, "notice": None, "last_business_day": None}) + morning = datetime(2025, 1, 6, 8, 0, 0) + monkeypatch.setattr(bridge, "datetime", MagicMock(now=MagicMock(return_value=morning))) + agent.stock_api.is_holiday.return_value = False + bridge.check_market_status(agent) + assert "장 시작 전" in bridge._market_status["notice"] + assert bridge._format_timeout_error(1000).endswith("1 second") + assert bridge._format_timeout_error(2000).endswith("2 seconds") + + +def test_module_entrypoint_invokes_main(monkeypatch): + monkeypatch.setattr(bridge.subprocess, "run", MagicMock(side_effect=FileNotFoundError())) + with pytest.raises(SystemExit): + runpy.run_path(bridge.__file__, run_name="__main__") diff --git a/tests/unit/test_cli_main_commands.py b/tests/unit/test_cli_main_commands.py new file mode 100644 index 0000000..5a947a2 --- /dev/null +++ b/tests/unit/test_cli_main_commands.py @@ -0,0 +1,197 @@ +"""CLI 공개 조회 명령의 출력 형식과 분기 회귀 테스트.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +import kis_agent.cli.main as cli + + +def _args(**values): + values.setdefault("pretty", False) + return SimpleNamespace(**values) + + +def _agent(): + agent = MagicMock() + agent.stock_api.search_stock_info.return_value = {"output": {"prdt_abrv_name": "삼성"}} + agent.stock_api.get_stock_price.return_value = {"output": {"stck_prpr": "70000"}} + agent.stock_api.inquire_daily_price.return_value = {"output": [{"stck_clpr": "70000"}]} + agent.stock_api.get_orderbook.return_value = {"output": {"askp1": "70100", "askp_rsqn1": "3", "bidp1": "70000", "bidp_rsqn1": "4"}} + agent.account_api.get_account_balance.return_value = {"output1": [{"pdno": "005930"}], "output2": [{"dnca_tot_amt": "10"}]} + agent.overseas_api.get_stock_info.return_value = {"output": {"prdt_name": "Apple"}} + agent.overseas_api.get_price.return_value = {"output": {"last": "10"}} + agent.overseas_api.get_price_detail.return_value = {"output": {"last": "10"}} + agent.overseas_api.get_daily_price.return_value = {"output2": [{"clos": "10"}]} + agent.futures_api.get_price.return_value = {"output": {"prdt_name": "선물", "futs_prpr": "1"}} + agent.overseas_futures_api.get_price.return_value = {"output": {"last": "1"}} + agent.overseas_futures_api.get_option_price.return_value = {"output": {"last": "1"}} + agent.overseas_futures_api.get_futures_orderbook.return_value = {"output1": {"askp1": "2", "askp_rsqn1": "3"}, "output2": {"bidp1": "1", "bidp_rsqn1": "4"}} + return agent + + +def test_format_resolution_and_market_status(monkeypatch, capsys): + assert cli._fmt_date("20250102") == "2025-01-02" + assert cli._fmt_time("093001") == "09:30:01" + assert cli._fmt_number("1000") == "1,000" + assert cli._fmt_number("1.5") == "1.50" + assert cli._fmt_number("bad") == "bad" + monkeypatch.setattr(cli, "resolve_code", lambda _: "005930") + assert cli._resolve("삼성") == "005930" + monkeypatch.setattr(cli, "resolve_code", lambda _: None) + assert cli._resolve("unknown") == "unknown" + cli._market_status["notice"] = "notice" + cli._out({"data": {}}, False) + assert "notice" in capsys.readouterr().out + + +def test_read_commands_emit_mapped_shapes(monkeypatch): + agent, output = _agent(), [] + monkeypatch.setattr(cli, "_create_agent", lambda: agent) + monkeypatch.setattr(cli, "_resolve", lambda code: code) + monkeypatch.setattr(cli, "_out", lambda value, pretty=False: output.append(value)) + + cli.cmd_price(_args(code="005930", daily=True, period="D", days=1)) + assert output.pop()["data"]["stock"]["code"] == "005930" + cli.cmd_balance(_args(holdings=True)) + assert "balance" in output.pop()["data"]["account"] + cli.cmd_orderbook(_args(code="005930")) + assert output.pop()["data"]["stock"]["orderbook"]["asks"] + cli.cmd_overseas(_args(excd="nas", symb="aapl", detail=False, daily=True, days=1)) + assert output.pop()["data"]["overseas"]["symbol"] == "AAPL" + cli.cmd_overseas(_args(excd="nas", symb="aapl", detail=True, daily=False, days=None)) + assert "priceDetail" in output.pop()["data"]["overseas"] + cli.cmd_futures(_args(code="101S03", night=False, overseas=False, option=False, orderbook=False)) + assert "futures" in output.pop()["data"] + cli.cmd_futures(_args(code="ES", night=False, overseas=True, option=False, orderbook=True)) + assert output.pop()["data"]["overseasFutures"]["orderbook"]["bids"] + cli.cmd_futures(_args(code="OPT", night=False, overseas=False, option=True, orderbook=False)) + assert "price" in output.pop()["data"]["overseasFutures"] + + +def test_night_futures_and_name_error_paths(monkeypatch): + agent, output = _agent(), [] + monkeypatch.setattr(cli, "_out", lambda value, pretty=False: output.append(value)) + agent.futures_api.inquire_ngt_balance.return_value = None + agent.futures_api.inquire_ngt_ccnl.return_value = None + cli._cmd_futures_night(agent, _args(balance=True, ccnl=False), "101S03") + assert output.pop()["data"]["nightFutures"]["balance"] == [] + cli._cmd_futures_night(agent, _args(balance=False, ccnl=True), "101S03") + assert output.pop()["data"]["nightFutures"]["executions"] == [] + cli._cmd_futures_night(agent, _args(balance=False, ccnl=False), "101S03") + assert "price" in output.pop()["data"]["nightFutures"] + agent.stock_api.search_stock_info.side_effect = RuntimeError("offline") + agent.overseas_api.get_stock_info.side_effect = RuntimeError("offline") + assert cli._get_name(agent, "005930") is None + assert cli._get_overseas_name(agent, "NAS", "AAPL") is None + + +def test_parser_query_search_schema_and_main_dispatch(monkeypatch, capsys): + parser = cli.build_parser() + assert parser.parse_args(["price", "005930"]).command == "price" + assert parser.parse_args(["order", "buy", "005930", "--qty", "1"]).action == "buy" + + agent, output = _agent(), [] + monkeypatch.setattr(cli, "_create_agent", lambda: agent) + monkeypatch.setattr(cli, "_out", lambda value, pretty=False: output.append(value)) + cli.cmd_query(_args(domain="stock", method="get_stock_price", args=["code=005930"], pretty=False)) + assert output.pop()["data"] == agent.stock_api.get_stock_price.return_value + monkeypatch.setattr(cli, "search_stocks", lambda query, limit: [{"code": query}]) + cli.cmd_search(_args(query="삼성", limit=1, pretty=False)) + assert output.pop()["data"]["search"]["count"] == 1 + monkeypatch.setattr(cli, "get_schema", lambda type_name=None: "type Stock { code: String }") + cli.cmd_schema(_args(type=None, json=True)) + assert output.pop()["types"][0]["name"] == "Stock" + cli.cmd_schema(_args(type="Stock", json=False)) + assert "type Stock" in capsys.readouterr().out + + called = [] + monkeypatch.setattr(cli, "build_parser", lambda: SimpleNamespace(parse_args=lambda: SimpleNamespace(command="search"))) + monkeypatch.setattr(cli, "cmd_search", lambda args: called.append(args.command)) + cli.main() + assert called == ["search"] + + monkeypatch.setattr(cli, "build_parser", lambda: SimpleNamespace(parse_args=lambda: SimpleNamespace(command=None), print_help=lambda: called.append("help"))) + with pytest.raises(SystemExit): + cli.main() + assert "help" in called + + +def test_cli_guard_and_error_paths(monkeypatch): + output = [] + monkeypatch.setattr(cli, "_out", lambda value, pretty=False: output.append(value)) + monkeypatch.setattr("builtins.input", lambda: (_ for _ in ()).throw(EOFError)) + assert not cli._confirm_order("매수", {"종목": "005930"}) + + with pytest.raises(SystemExit): + cli.cmd_order(_args(action="unexpected")) + assert "Unknown action" in output.pop()["error"] + + agent = _agent() + monkeypatch.setattr(cli, "_create_agent", lambda: agent) + with pytest.raises(SystemExit): + cli.cmd_query(_args(domain="invalid", method="x", args=[], pretty=False)) + assert "Unknown domain" in output.pop()["error"] + + agent.stock_api = SimpleNamespace() + with pytest.raises(SystemExit): + cli.cmd_query(_args(domain="stock", method="missing", args=[], pretty=False)) + assert "Unknown method" in output.pop()["error"] + + agent.stock_api = MagicMock() + agent.stock_api.get_stock_price.side_effect = RuntimeError("offline") + with pytest.raises(SystemExit): + cli.cmd_query(_args(domain="stock", method="get_stock_price", args=[], pretty=False)) + assert output.pop()["code"] == "RuntimeError" + + monkeypatch.setattr(cli, "search_stocks", lambda *_args, **_kwargs: []) + cli.cmd_search(_args(query="없음", limit=1, pretty=False)) + assert output.pop()["data"]["search"]["count"] == 0 + + +def test_check_market_status_caches_holiday_and_fallback(monkeypatch): + class MondayMorning(cli.datetime): + @classmethod + def now(cls): + return cls(2025, 1, 6, 8, 0) + + monkeypatch.setattr(cli, "datetime", MondayMorning) + cli._market_status.update(checked=False, is_holiday=None, last_business_day=None, notice=None) + agent = _agent() + agent.stock_api.is_holiday.return_value = False + cli._check_market_status(agent) + assert cli._market_status["last_business_day"] == "20250106" + assert "장 시작 전" in cli._market_status["notice"] + + cli._market_status.update(checked=False, is_holiday=None, last_business_day=None, notice=None) + agent.stock_api.is_holiday.side_effect = [True, RuntimeError("offline")] + cli._check_market_status(agent) + assert cli._market_status["last_business_day"] == "20250103" + assert "공휴일 미확인" in cli._market_status["notice"] + + +def test_order_cancel_and_modify_success_and_failure(monkeypatch): + agent, output = _agent(), [] + monkeypatch.setattr(cli, "_create_agent", lambda: agent) + monkeypatch.setattr(cli, "_out", lambda value, pretty=False: output.append(value)) + agent.account_api.order_rvsecncl.return_value = {"rt_cd": "0", "output": {"odno": "new"}} + cli._cmd_order_cancel(_args(order_no="old", overseas=None, code=None, qty=1, yes=True)) + assert output.pop()["data"]["cancel"]["orderNo"] == "new" + cli._cmd_order_modify(_args(order_no="old", overseas=None, code=None, qty=1, price=100, type="limit", yes=True)) + assert output.pop()["data"]["modify"]["origOrderNo"] == "old" + agent.account_api.order_rvsecncl.return_value = None + cli._cmd_order_cancel(_args(order_no="old", overseas=None, code=None, qty=0, yes=True)) + assert "error" in output.pop() + + +def test_overseas_order_cancel_and_modify(monkeypatch): + agent, output = _agent(), [] + monkeypatch.setattr(cli, "_create_agent", lambda: agent) + monkeypatch.setattr(cli, "_out", lambda value, pretty=False: output.append(value)) + agent.overseas_api.cancel_order.return_value = {"rt_cd": "0", "output": {"odno": "new"}} + agent.overseas_api.modify_order.return_value = {"rt_cd": "0", "output": {"odno": "mod"}} + cli._cmd_order_cancel(_args(order_no="old", overseas="nas", code="aapl", qty=1, yes=True)) + assert output.pop()["data"]["cancel"]["orderNo"] == "new" + cli._cmd_order_modify(_args(order_no="old", overseas="nas", code="aapl", qty=1, price=10, type="limit", yes=True)) + assert output.pop()["data"]["modify"]["orderNo"] == "mod" diff --git a/tests/unit/test_cli_main_remaining_paths.py b/tests/unit/test_cli_main_remaining_paths.py new file mode 100644 index 0000000..ba73d17 --- /dev/null +++ b/tests/unit/test_cli_main_remaining_paths.py @@ -0,0 +1,291 @@ +"""CLI의 실패 응답, 확인 취소, 예외 경로 회귀 테스트.""" + +import runpy +import sys +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +import kis_agent +import kis_agent.cli.main as cli + + +def _args(**values): + values.setdefault("pretty", False) + return SimpleNamespace(**values) + + +def _agent(): + agent = MagicMock() + agent.stock_api.search_stock_info.return_value = {"output": {"prdt_abrv_name": "삼성"}} + return agent + + +def test_create_agent_loads_local_env_and_checks_market(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + (tmp_path / ".env").write_text("KIS_APP_KEY=file\n", encoding="utf-8") + monkeypatch.setenv("KIS_APP_KEY", "app") + monkeypatch.setenv("KIS_APP_SECRET", "secret") + monkeypatch.setenv("KIS_ACCOUNT_NO", "12345678") + monkeypatch.setenv("KIS_ACCOUNT_CODE", "01") + agent = _agent() + check = MagicMock() + + with patch("dotenv.load_dotenv") as load, patch.object( + kis_agent, "Agent", return_value=agent + ) as constructor, patch.object(cli, "_check_market_status", check): + assert cli._create_agent() is agent + + load.assert_called_once_with(".env", override=False) + constructor.assert_called_once_with( + app_key="app", + app_secret="secret", + account_no="12345678", + account_code="01", + ) + check.assert_called_once_with(agent) + + +def test_market_status_cached_holiday_success_and_after_close(monkeypatch): + agent = _agent() + cli._market_status.update(checked=True, is_holiday=False, last_business_day=None, notice=None) + cli._check_market_status(agent) + agent.stock_api.is_holiday.assert_not_called() + + class Monday(datetime): + @classmethod + def now(cls): + return cls(2025, 1, 6, 12, 0) + + monkeypatch.setattr(cli, "datetime", Monday) + cli._market_status.update(checked=False, is_holiday=None, last_business_day=None, notice=None) + agent.stock_api.is_holiday.side_effect = [True, False] + cli._check_market_status(agent) + assert "휴장일" in cli._market_status["notice"] + + class Evening(datetime): + @classmethod + def now(cls): + return cls(2025, 1, 6, 17, 0) + + monkeypatch.setattr(cli, "datetime", Evening) + cli._market_status.update(checked=False, is_holiday=None, last_business_day=None, notice=None) + agent.stock_api.is_holiday.side_effect = None + agent.stock_api.is_holiday.return_value = False + cli._check_market_status(agent) + assert "장 마감 후" in cli._market_status["notice"] + + cli._market_status.update(checked=False, is_holiday=None, last_business_day=None, notice=None) + agent.stock_api.is_holiday.side_effect = RuntimeError("offline") + cli._check_market_status(agent) + assert cli._market_status["is_holiday"] is False + + +def test_read_command_error_paths_and_date_passthrough(monkeypatch): + agent, output = _agent(), [] + monkeypatch.setattr(cli, "_create_agent", lambda: agent) + monkeypatch.setattr(cli, "_out", lambda value, pretty=False: output.append(value)) + + agent.account_api.get_account_balance.return_value = None + cli.cmd_balance(_args(holdings=False)) + assert output.pop()["error"] == "Failed to fetch balance" + + agent.overseas_futures_api.get_price.return_value = {"rt_cd": "1", "msg1": "bad"} + cli.cmd_futures( + _args(code="ES", night=False, overseas=True, option=False, orderbook=False) + ) + assert output.pop()["data"]["overseasFutures"]["error"] == "bad" + assert cli._parse_date("2y").isdigit() + assert cli._parse_date("day") == "day" + + +def _trade_args(**changes): + values = { + "start": "20250101", + "end": "20250131", + "buy": False, + "sell": False, + "stock": "", + "filled": False, + "limit": 0, + "profit": False, + "daily_profit": False, + "pretty": False, + } + values.update(changes) + return _args(**values) + + +def test_trade_empty_and_failure_responses(monkeypatch): + agent, output = _agent(), [] + monkeypatch.setattr(cli, "_create_agent", lambda: agent) + monkeypatch.setattr(cli, "_out", lambda value, pretty=False: output.append(value)) + + agent.account_api.inquire_daily_ccld.return_value = None + cli.cmd_trades(_trade_args()) + assert "error" in output.pop() + agent.account_api.inquire_daily_ccld.return_value = {"rt_cd": "0", "output1": []} + cli.cmd_trades(_trade_args()) + assert output.pop()["data"]["trades"]["count"] == 0 + agent.account_api.inquire_daily_ccld.return_value = { + "rt_cd": "0", + "output1": [{"tot_ccld_qty": "0"}], + } + cli.cmd_trades(_trade_args(filled=True)) + assert output.pop()["data"]["trades"]["items"] == [] + + agent.account_api.get_period_profit.return_value = None + cli.cmd_trades(_trade_args(profit=True, daily_profit=True)) + assert "error" in output.pop() + agent.account_api.get_period_trade_profit.return_value = {"rt_cd": "1", "msg1": "bad"} + cli.cmd_trades(_trade_args(profit=True)) + assert output.pop()["detail"] == "bad" + + +def test_order_list_overseas_and_failure(monkeypatch): + agent, output = _agent(), [] + monkeypatch.setattr(cli, "_create_agent", lambda: agent) + monkeypatch.setattr(cli, "_out", lambda value, pretty=False: output.append(value)) + agent.overseas_api.get_nccs_orders.return_value = None + cli._cmd_order_list(_args(overseas="nas")) + assert "error" in output.pop() + agent.overseas_api.get_nccs_orders.return_value = {"output": []} + cli._cmd_order_list(_args(overseas="nas")) + assert output.pop()["data"]["orders"]["count"] == 0 + + +def _execute_args(**changes): + values = { + "action": "buy", + "code": "005930", + "overseas": "", + "type": "limit", + "qty": 1, + "price": 1000, + "exchange": "krx", + "yes": True, + "pretty": False, + } + values.update(changes) + return _args(**values) + + +def test_domestic_order_cancel_failure_api_error_and_exception(monkeypatch): + agent, output = _agent(), [] + monkeypatch.setattr(cli, "_create_agent", lambda: agent) + monkeypatch.setattr(cli, "_out", lambda value, pretty=False: output.append(value)) + monkeypatch.setattr(cli, "_confirm_order", lambda *_: False) + cli._cmd_order_execute(_execute_args(yes=False)) + assert output.pop()["cancelled"] + + agent.account_api.order_cash.return_value = None + cli._cmd_order_execute(_execute_args()) + assert "응답 없음" in output.pop()["error"] + agent.account_api.order_cash.return_value = {"rt_cd": "1", "msg1": "rejected"} + cli._cmd_order_execute(_execute_args()) + assert output.pop()["error"] == "rejected" + agent.account_api.order_cash.side_effect = RuntimeError("offline") + with pytest.raises(SystemExit): + cli._cmd_order_execute(_execute_args()) + assert output.pop()["code"] == "RuntimeError" + + +def test_overseas_order_guard_cancel_failures_and_sell(monkeypatch): + agent, output = _agent(), [] + monkeypatch.setattr(cli, "_out", lambda value, pretty=False: output.append(value)) + cli._cmd_order_overseas(agent, _execute_args(overseas="nas", type="moo"), True) + assert "매도만" in output.pop()["error"] + + monkeypatch.setattr(cli, "_confirm_order", lambda *_: False) + cli._cmd_order_overseas( + agent, _execute_args(overseas="nas", type="limit", yes=False), True + ) + assert output.pop()["cancelled"] + + agent.overseas_api.sell_order.return_value = None + cli._cmd_order_overseas( + agent, _execute_args(action="sell", overseas="nas"), False + ) + assert "응답 없음" in output.pop()["error"] + agent.overseas_api.sell_order.return_value = {"rt_cd": "1", "msg1": "bad"} + cli._cmd_order_overseas( + agent, _execute_args(action="sell", overseas="nas"), False + ) + assert output.pop()["error"] == "bad" + agent.overseas_api.sell_order.side_effect = RuntimeError("offline") + with pytest.raises(SystemExit): + cli._cmd_order_overseas( + agent, _execute_args(action="sell", overseas="nas"), False + ) + assert output.pop()["code"] == "RuntimeError" + + +def _change_args(**changes): + values = { + "order_no": "old", + "overseas": "", + "code": "005930", + "qty": 1, + "price": 1000, + "type": "limit", + "yes": True, + "pretty": False, + } + values.update(changes) + return _args(**values) + + +def test_cancel_and_modify_confirmation_exception_and_api_error(monkeypatch): + agent, output = _agent(), [] + monkeypatch.setattr(cli, "_create_agent", lambda: agent) + monkeypatch.setattr(cli, "_out", lambda value, pretty=False: output.append(value)) + monkeypatch.setattr(cli, "_confirm_order", lambda *_: False) + cli._cmd_order_cancel(_change_args(yes=False)) + assert output.pop()["cancelled"] + cli._cmd_order_cancel(_change_args(overseas="nas", yes=False)) + assert output.pop()["cancelled"] + cli._cmd_order_modify(_change_args(yes=False)) + assert output.pop()["cancelled"] + cli._cmd_order_modify(_change_args(overseas="nas", yes=False)) + assert output.pop()["cancelled"] + + agent.account_api.order_rvsecncl.side_effect = RuntimeError("domestic") + cli._cmd_order_cancel(_change_args()) + assert output.pop()["code"] == "RuntimeError" + cli._cmd_order_modify(_change_args()) + assert output.pop()["code"] == "RuntimeError" + agent.overseas_api.cancel_order.side_effect = RuntimeError("overseas") + agent.overseas_api.modify_order.side_effect = RuntimeError("overseas") + cli._cmd_order_cancel(_change_args(overseas="nas")) + assert output.pop()["code"] == "RuntimeError" + cli._cmd_order_modify(_change_args(overseas="nas")) + assert output.pop()["code"] == "RuntimeError" + + agent.account_api.order_rvsecncl.side_effect = None + agent.account_api.order_rvsecncl.return_value = {"rt_cd": "1", "msg1": "bad"} + cli._cmd_order_cancel(_change_args()) + assert output.pop()["error"] == "bad" + cli._cmd_order_modify(_change_args()) + assert output.pop()["error"] == "bad" + agent.account_api.order_rvsecncl.return_value = None + cli._cmd_order_modify(_change_args()) + assert "응답 없음" in output.pop()["error"] + + +def test_main_unknown_command_and_module_entrypoint(monkeypatch, capsys): + parser = SimpleNamespace( + parse_args=lambda: SimpleNamespace(command="unknown"), + print_help=MagicMock(), + ) + monkeypatch.setattr(cli, "build_parser", lambda: parser) + with pytest.raises(SystemExit) as exc: + cli.main() + assert exc.value.code == 1 + parser.print_help.assert_called_once() + + monkeypatch.setattr(sys, "argv", ["kis", "schema"]) + with patch("kis_agent.cli.schema.get_schema", return_value="type Stock {}"): + runpy.run_path(cli.__file__, run_name="__main__") + assert "type Stock" in capsys.readouterr().out diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 36e25ad..1febdd3 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -47,7 +47,8 @@ def _create_mock_config(self): @patch("kis_agent.core.client.auth") @patch("kis_agent.core.client.getTREnv") - def test_init_with_config(self, mock_get_tr_env, mock_auth): + @patch("kis_agent.core.client.read_token", return_value=None) + def test_init_with_config(self, mock_read_token, mock_get_tr_env, mock_auth): """config로 클라이언트 초기화""" mock_auth.return_value = { "access_token": "test_token", diff --git a/tests/unit/test_client_extra_paths.py b/tests/unit/test_client_extra_paths.py new file mode 100644 index 0000000..63f18c9 --- /dev/null +++ b/tests/unit/test_client_extra_paths.py @@ -0,0 +1,113 @@ +"""KISClient의 환경변수 기반 캐시 토큰 초기화 회귀 테스트.""" + +import threading +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +import kis_agent.core.client as client_module +from kis_agent.core.client import KISClient + + +def test_cached_token_without_config_reapplies_environment_auth(): + client = object.__new__(KISClient) + client.token_refresh_lock = threading.Lock() + client.token = None + client.token_expired = None + client.config = None + client.svr = "prod" + + cached = { + "access_token": "cached", + "access_token_token_expired": "2099-01-01 00:00:00", + } + with patch("kis_agent.core.client.os.getenv", return_value="key"), patch( + "kis_agent.core.client.read_token", return_value=cached + ), patch("kis_agent.core.client.auth") as auth, patch( + "kis_agent.core.client.resolve_environment", + return_value=("https://example.test", "prod"), + ): + client._initialize_token() + + auth.assert_called_once_with(svr="prod") + assert client.token == "cached" + assert client.base_url == "https://example.test" + + +def test_cached_token_with_explicit_config_reapplies_config_auth(): + client = object.__new__(KISClient) + client.token_refresh_lock = threading.Lock() + client.token = None + client.token_expired = None + client.config = SimpleNamespace(APP_KEY="key", BASE_URL="https://config.test") + client.svr = "prod" + cached = { + "access_token": "cached", + "access_token_token_expired": "2099-01-01 00:00:00", + } + + with patch("kis_agent.core.client.read_token", return_value=cached), patch( + "kis_agent.core.client.auth" + ) as auth: + client._initialize_token() + + auth.assert_called_once_with(config=client.config, svr="prod") + assert client.base_url == "https://config.test" + + +def _request_client(): + client = object.__new__(KISClient) + client.base_url = "https://example.test" + client.is_real = True + client.verbose = False + client.enable_rate_limiter = False + client.rate_limiter = None + client._check_and_refresh_token = MagicMock() + client._enforce_rate_limit = MagicMock() + return client + + +def _response(status_code, payload): + response = MagicMock(status_code=status_code, text="error", headers={}) + response.json.return_value = payload + return response + + +def test_http_error_and_request_exception_retry_then_succeed(): + env = SimpleNamespace(my_token="token", my_app="app", my_sec="secret") + success = _response(200, {"rt_cd": "0"}) + http_error = _response(500, {"rt_cd": "500", "msg1": "server error"}) + request = httpx.Request("GET", "https://example.test/test") + + with patch.object(client_module, "getTREnv", return_value=env), patch.object( + client_module.time, "sleep" + ) as sleep, patch.object( + client_module.httpx, "request", side_effect=[http_error, success] + ): + assert _request_client().make_request("/test", "TR", {}, retries=2) == { + "rt_cd": "0" + } + sleep.assert_called_once_with(0.2) + + with patch.object(client_module, "getTREnv", return_value=env), patch.object( + client_module.time, "sleep" + ) as sleep, patch.object( + client_module.httpx, + "request", + side_effect=[httpx.RequestError("offline", request=request), success], + ): + assert _request_client().make_request("/test", "TR", {}, retries=2) == { + "rt_cd": "0" + } + sleep.assert_called_once_with(0.2) + + +def test_zero_retries_rejects_request_without_network_call(): + env = SimpleNamespace(my_token="token", my_app="app", my_sec="secret") + with patch.object(client_module, "getTREnv", return_value=env), patch.object( + client_module.httpx, "request" + ) as request, pytest.raises(Exception, match="Unknown error after retries"): + _request_client().make_request("/test", "TR", {}, retries=0) + request.assert_not_called() diff --git a/tests/unit/test_data_processor.py b/tests/unit/test_data_processor.py new file mode 100644 index 0000000..9ae0fe9 --- /dev/null +++ b/tests/unit/test_data_processor.py @@ -0,0 +1,88 @@ +"""DataProcessor의 메시지 파싱과 지표 계산 회귀 테스트.""" + +import json + +import pytest +from Crypto.Cipher import AES +from Crypto.Util.Padding import pad + +from kis_agent.websocket.data_processor import DataProcessor + + +def _binary_message(header, body): + header_bytes = json.dumps(header).encode() + return len(header_bytes).to_bytes(2, "big") + header_bytes + body + + +def test_process_message_json_pingpong_key_and_invalid_type(): + processor = DataProcessor() + + pingpong = processor.process_message('{"header": {"tr_id": "PINGPONG"}}') + assert pingpong["type"] == "PINGPONG" + + processor.process_message( + json.dumps({"header": {"tr_id": "H0STCNT0", "tr_key": "MTIzNDU2Nzg5MDEyMzQ1Ng==", "tr_iv": "MTIzNDU2Nzg5MDEyMzQ1Ng=="}}) + ) + assert processor.aes_keys["H0STCNT0"] == (b"1234567890123456", b"1234567890123456") + + with pytest.raises(ValueError, match="지원하지 않는"): + processor.process_message(1) + with pytest.raises(json.JSONDecodeError): + processor.process_message("not-json") + + +def test_process_binary_plain_and_encrypted_messages(): + processor = DataProcessor() + plain = processor.process_message( + _binary_message({"tr_id": "plain"}, b'{"output": {"value": 1}}') + ) + assert plain["tr_id"] == "plain" + assert plain["body"]["output"]["value"] == 1 + + key = iv = b"1234567890123456" + processor.aes_keys["secret"] = (key, iv) + encrypted = AES.new(key, AES.MODE_CBC, iv).encrypt(pad(b'{"ok": true}', AES.block_size)) + decoded = processor.process_message( + _binary_message({"tr_id": "secret", "encrypt": "Y"}, encrypted) + ) + assert decoded["body"] == {"ok": True} + + with pytest.raises(ValueError, match="AES 키"): + processor._decrypt_aes(b"bad", b"", iv) + + +def test_trade_orderbook_index_and_indicators(): + processor = DataProcessor() + assert processor.parse_trade_data({"tr_id": "other"}) is None + assert processor.parse_trade_data({"tr_id": "H0STCNT0", "body": {}}) is None + + output = { + "stck_shrn_iscd": "005930", "stck_bsop_date": "Samsung", "stck_prpr": "100", + "prdy_vrss": "2", "prdy_ctrt": "2.0", "acml_vol": "10", "stck_cntg_hour": "090000", + } + trade = processor.parse_trade_data({"tr_id": "H0STCNT0", "body": {"output": output}}) + assert trade["price"] == 100 + processor.trade_history["005930"] = [{"price": 1}] * 1000 + processor.parse_trade_data({"tr_id": "H0STCNT0", "body": {"output": output}}) + assert len(processor.trade_history["005930"]) == 1000 + processor.trade_history["005930"] = [{"price": value} for value in range(1, 26)] + indicators = processor.calculate_indicators("005930") + assert indicators["rsi"] == 100 and indicators["macd"] is None + processor.trade_history["005930"] = [{"price": value} for value in range(1, 27)] + assert processor.calculate_indicators("005930")["macd"] is not None + assert processor.calculate_indicators("missing") == {} + assert processor._calculate_rsi([1]) is None + assert processor._calculate_rsi([3, 2] * 8) < 100 + + assert processor.parse_orderbook_data({"tr_id": "other"}) is None + assert processor.parse_orderbook_data({"tr_id": "H0STASP0", "body": {}}) is None + orderbook = processor.parse_orderbook_data({ + "tr_id": "H0STASP0", "body": {"output": {"stck_shrn_iscd": "005930", "askp1": "101", "askp_rsqn1": "2", "bidp1": "99"}} + }) + assert orderbook["ask_prices"] == [101] and orderbook["bid_volumes"] == [0] + assert processor.parse_index_data({"tr_id": "other"}) is None + assert processor.parse_index_data({"tr_id": "H0IF1000", "body": {}}) is None + index = processor.parse_index_data({ + "tr_id": "H0IF1000", "body": {"output": {"bstp_nmix_prpr": "2500", "bstp_nmix_prdy_vrss": "10", "prdy_vrss_sign": "0.4"}} + }) + assert index["value"] == 2500.0 diff --git a/tests/unit/test_enhanced_client_extra.py b/tests/unit/test_enhanced_client_extra.py new file mode 100644 index 0000000..3929a4e --- /dev/null +++ b/tests/unit/test_enhanced_client_extra.py @@ -0,0 +1,77 @@ +"""Deprecated EnhancedWebSocketClient의 데이터 변환 회귀 테스트.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from kis_agent.websocket.enhanced_client import EnhancedWebSocketClient + + +def _client(tmp_path): + client = object.__new__(EnhancedWebSocketClient) + client.stock_codes = ["005930"] + client.enable_ask_bid = client.enable_program_trading = client.enable_index = True + client.stock_api = MagicMock() + client.ws_agent = MagicMock() + client.ws_agent.connect = AsyncMock() + client.ws_agent.disconnect = AsyncMock() + client.ws_agent.get_stats.return_value = {} + client.ws_agent.subscriptions = [] + client.market_data = {"stocks": {}, "ask_bid": {}, "index": {}, "program": {}} + client.stock_info = {"005930": "삼성전자"} + client.callbacks = {key: [] for key in ("on_trade", "on_ask_bid", "on_index", "on_program")} + client.data_log_file = str(tmp_path / "market.jsonl") + return client + + +@pytest.mark.asyncio +async def test_subscription_fetch_handlers_summary_and_lifecycle(tmp_path): + client = _client(tmp_path) + client.stock_api.get_stock_info.return_value = __import__("pandas").DataFrame({"prdt_name": ["삼성전자"]}) + await client._init_subscriptions() + assert client.ws_agent.subscribe.call_count == 6 + + client._handle_stock_trade(["005930", "090000", "70000", "x", "1", "0.1"] + ["0"] * 13, {}) + client._handle_stock_ask_bid(["005930", "x", "x"] + ["1"] * 40, {}) + client._handle_index(["0001", "2500", "1", "0.1"] + ["0"] * 6, {}) + client._handle_program_trade(["005930", "090000", "1", "2", "3", "4", "2", "2"] + ["0"] * 3, {}) + assert client.get_market_summary()["stocks"]["005930"]["price"] == 70000.0 + assert client.get_stats()["active_stocks"] == 1 + await client.start() + await client.stop() + assert client.ws_agent.connect.await_count == 1 + assert client.ws_agent.disconnect.await_count == 1 + + +@pytest.mark.asyncio +async def test_enhanced_client_callback_exception_and_dynamic_stock_paths(tmp_path): + client = _client(tmp_path) + client.stock_api.get_stock_info.side_effect = RuntimeError("offline") + await client._fetch_stock_info() + assert client.stock_info["005930"] == "005930" + + for event in client.callbacks: + client.callbacks[event].append(lambda _data: (_ for _ in ()).throw(RuntimeError("callback"))) + client._handle_stock_trade(["005930", "090000", "bad"] + ["0"] * 16, {}) + client._handle_stock_ask_bid(["005930", "x", "x"] + ["bad"] * 40, {}) + client._handle_index(["0001", "bad"] + ["0"] * 8, {}) + client._handle_program_trade(["005930", "090000", "bad"] + ["0"] * 8, {}) + + callbacks = [] + client.callbacks["on_ask_bid"] = [lambda data: callbacks.append(data["code"])] + client.callbacks["on_program"] = [lambda data: callbacks.append(data["code"])] + client._handle_stock_ask_bid(["005930", "x", "x"] + ["1"] * 40, {}) + client._handle_program_trade(["005930", "090000", "1", "2", "3", "4", "2", "2"] + ["0"] * 3, {}) + assert callbacks == ["005930", "005930"] + + client.add_stock("000660") + assert "000660" in client.stock_codes + client.market_data["stocks"]["000660"] = {} + client.remove_stock("000660") + assert "000660" not in client.stock_codes + client.stock_api.get_stock_info.side_effect = None + client.stock_api.get_stock_info.return_value = __import__("pandas").DataFrame({"prdt_name": ["SK하이닉스"]}) + client.add_stock("000661") + assert client.stock_info["000661"] == "SK하이닉스" + client.data_log_file = str(tmp_path) + client._log_data("trade", {}) diff --git a/tests/unit/test_futures_code_generation.py b/tests/unit/test_futures_code_generation.py index db0f0e6..854412b 100644 --- a/tests/unit/test_futures_code_generation.py +++ b/tests/unit/test_futures_code_generation.py @@ -213,6 +213,10 @@ def test_non_expiry_month_november(self): # Assert assert code == "A01612", "11월에는 다음 만기월인 A01612 반환" + def test_non_expiry_month_december_rolls_over_to_march(self): + """12월 만기 전이 아닌 비만기 경로의 연말 롤오버도 3월물을 선택한다.""" + assert get_kospi200_futures_code(datetime(2025, 12, 1)) == "A01612" + def test_edge_case_second_thursday_calculation(self): """ 경계 케이스: 두 번째 목요일 계산 검증 diff --git a/tests/unit/test_futures_code_generator.py b/tests/unit/test_futures_code_generator.py index 827bd87..9eb4477 100644 --- a/tests/unit/test_futures_code_generator.py +++ b/tests/unit/test_futures_code_generator.py @@ -15,6 +15,7 @@ import unittest from datetime import datetime +from types import SimpleNamespace from unittest.mock import patch import pytest @@ -80,6 +81,13 @@ def test_get_current_expiry_month_january(self, mock_datetime): month = FuturesCodeGenerator.get_current_expiry_month() self.assertEqual(month, 3) + @patch("kis_agent.futures.code_generator.datetime") + def test_current_series_and_month_roll_over_after_december(self, mock_datetime): + """방어적 연말 롤오버 분기는 3월물을 반환한다.""" + mock_datetime.now.return_value = SimpleNamespace(month=13) + self.assertEqual(FuturesCodeGenerator.get_current_series(), "S") + self.assertEqual(FuturesCodeGenerator.get_current_expiry_month(), 3) + def test_generate_futures_code_march(self): """3월물 선물 코드 생성""" code = FuturesCodeGenerator.generate_futures_code(expiry_month=3) @@ -123,6 +131,16 @@ def test_generate_futures_code_both_params(self): FuturesCodeGenerator.generate_futures_code(series="S", expiry_month=3) self.assertIn("동시에 사용할 수 없습니다", str(context.exception)) + def test_generate_futures_code_rejects_unknown_product_and_auto_selects(self): + with self.assertRaisesRegex(ValueError, "KOSPI200"): + FuturesCodeGenerator.generate_futures_code(product="UNKNOWN") + with patch.object( + FuturesCodeGenerator, "get_current_series", return_value="M" + ), patch.object( + FuturesCodeGenerator, "get_current_expiry_month", return_value=6 + ): + self.assertEqual(FuturesCodeGenerator.generate_futures_code(), "101M06") + def test_generate_option_code_call(self): """콜옵션 코드 생성""" code = FuturesCodeGenerator.generate_option_code("CALL", 340.0, expiry_month=3) @@ -149,6 +167,29 @@ def test_generate_option_code_invalid_type(self): FuturesCodeGenerator.generate_option_code("INVALID", 340.0) self.assertIn("CALL", str(context.exception)) + def test_generate_option_code_rejects_invalid_product_series_and_month(self): + with self.assertRaisesRegex(ValueError, "KOSPI200"): + FuturesCodeGenerator.generate_option_code( + "CALL", 340.0, product="UNKNOWN" + ) + with self.assertRaisesRegex(ValueError, "동시에"): + FuturesCodeGenerator.generate_option_code( + "CALL", 340.0, series="S", expiry_month=3 + ) + with self.assertRaisesRegex(ValueError, "시리즈"): + FuturesCodeGenerator.generate_option_code("CALL", 340.0, series="X") + with self.assertRaisesRegex(ValueError, "만기월"): + FuturesCodeGenerator.generate_option_code( + "CALL", 340.0, expiry_month=5 + ) + with patch.object( + FuturesCodeGenerator, "get_current_series", return_value="U" + ): + self.assertEqual( + FuturesCodeGenerator.generate_option_code("PUT", 340.0), + "301UP340", + ) + def test_generate_atm_option_codes(self): """ATM 옵션 코드 생성""" result = FuturesCodeGenerator.generate_atm_option_codes(340.25, expiry_month=3) @@ -241,6 +282,20 @@ def test_generate_next_futures(self, mock_generate, mock_get_month): self.assertEqual(result, "101M06") mock_generate.assert_called_once_with(expiry_month=6) + @patch( + "kis_agent.futures.code_generator.FuturesCodeGenerator.get_current_expiry_month", + return_value=12, + ) + @patch( + "kis_agent.futures.code_generator.FuturesCodeGenerator.generate_futures_code", + return_value="101S03", + ) + def test_generate_next_futures_rolls_december_to_march( + self, mock_generate, _mock_get_month + ): + self.assertEqual(generate_next_futures(), "101S03") + mock_generate.assert_called_once_with(expiry_month=3) + @patch("kis_agent.futures.code_generator.FuturesCodeGenerator.generate_option_code") def test_generate_call_option(self, mock_generate): """콜옵션 생성 (편의 함수)""" diff --git a/tests/unit/test_futures_facade_extra.py b/tests/unit/test_futures_facade_extra.py new file mode 100644 index 0000000..30d902e --- /dev/null +++ b/tests/unit/test_futures_facade_extra.py @@ -0,0 +1,55 @@ +"""Futures facade의 자동 코드·야간·과거/VKOSPI 편의 메서드 테스트.""" + +from unittest.mock import MagicMock, patch + +from kis_agent.futures import Futures + + +def _facade(): + facade = Futures(MagicMock(), {"CANO": "1", "ACNT_PRDT_CD": "03"}, enable_cache=False) + facade.price = MagicMock() + facade.account_api = MagicMock() + facade.order = MagicMock() + facade.historical = MagicMock() + facade.code = MagicMock() + return facade + + +def test_night_and_current_master_convenience_methods(): + facade = _facade() + facade.inquire_ngt_balance() + facade.inquire_ngt_ccnl("20250101", "20250102") + facade.inquire_psbl_ngt_order("101S03") + facade.price.get_price.return_value = "price" + facade.price.get_orderbook.return_value = "book" + with patch("kis_agent.futures._get_current_master", return_value=None): + assert facade.get_current_futures_price() is None + assert facade.get_current_futures_orderbook() is None + with patch("kis_agent.futures._get_current_master", return_value={"code": "101S03"}): + assert facade.get_current_futures_price("CM") == "price" + assert facade.get_current_futures_orderbook("CM") == "book" + with patch("kis_agent.utils.futures_master.get_futures_by_month_type", return_value=[]): + assert facade.get_next_futures_price() is None + with patch("kis_agent.utils.futures_master.get_futures_by_month_type", return_value=[{"code": "101M06"}]): + assert facade.get_next_futures_price("CM") == "price" + + +def test_option_order_chart_history_and_vkospi_helpers(): + facade = _facade() + facade.code.generate_option_code.return_value = "201S340" + facade.price.get_price.return_value = "option" + assert facade.get_option_price("CALL", 340.0, 3) == "option" + assert facade.get_call_option_price(340.0) == "option" + assert facade.get_put_option_price(340.0) == "option" + with patch("kis_agent.futures.generate_current_futures", return_value="101S03"): + facade.get_current_futures_chart("20250101", "20250102", "W") + facade.order_current_futures("02", "1", "0", "1") + facade.order_option("PUT", 340.0, "01", "1", "2", 6) + facade.get_historical_minute_bars("20250101", "20250102", "5", 2) + facade.get_contract_minute_bars("101S03", "20250101", "20250102", "5", 2) + facade.price.display_board_callput.side_effect = [None, {}] + assert facade.get_vkospi() is None + facade.price.display_board_callput.side_effect = [{"output": 1}, {"output": 2}] + with patch("kis_agent.futures.VKOSPICalculator") as calculator: + calculator.return_value.calculate.return_value = "vkospi" + assert facade.get_vkospi("202501", "202502") == "vkospi" diff --git a/tests/unit/test_futures_historical.py b/tests/unit/test_futures_historical.py new file mode 100644 index 0000000..785f23b --- /dev/null +++ b/tests/unit/test_futures_historical.py @@ -0,0 +1,101 @@ +"""선물 월물 코드와 과거 분봉 페이지네이션 회귀 테스트.""" + +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +from kis_agent.futures.historical import ( + FuturesContractCode, + FuturesHistoricalAPI, + generate_futures_code, + get_futures_code, +) + + +def test_contract_code_generation_and_boundaries(): + assert FuturesContractCode.get_series_code(3) == "S" + assert FuturesContractCode.get_expiry_date(2025, 3) == datetime(2025, 3, 13) + assert FuturesContractCode.get_front_month_contract(datetime(2025, 3, 13)) == (2025, 3) + assert FuturesContractCode.get_front_month_contract(datetime(2025, 3, 14)) == (2025, 6) + assert FuturesContractCode.get_front_month_contract(datetime(2025, 12, 12)) == (2026, 3) + assert FuturesContractCode.generate_code(2025, 6) == "101M06" + assert FuturesContractCode.get_code_for_date(datetime(2025, 6, 1)) == "101M06" + assert FuturesContractCode.get_previous_contract(2025, 3) == (2024, 12) + assert FuturesContractCode.get_previous_contract(2025, 9) == (2025, 6) + assert FuturesContractCode.parse_code("101Z12")[1] == 12 + assert get_futures_code(datetime(2025, 9, 1)) == "101U09" + assert generate_futures_code(2025, 12) == "101Z12" + for func, arg in ((FuturesContractCode.get_series_code, 1), (lambda _: FuturesContractCode.generate_code(2025, 1), None), (FuturesContractCode.parse_code, "bad"), (FuturesContractCode.parse_code, "101X03")): + with pytest.raises(ValueError): + func(arg) + + +def _api(): + return FuturesHistoricalAPI(MagicMock(), {"CANO": "1", "ACNT_PRDT_CD": "01"}) + + +def test_fetch_page_normalizes_results_and_handles_errors(): + api = _api() + api._make_request_dict = MagicMock(return_value=None) + assert api._fetch_page("101S03", "20250102", "153000") == ([], None, None) + api._make_request_dict.return_value = {"rt_cd": "0", "output2": []} + assert api._fetch_page("101S03", "20250102", "153000") == ([], None, None) + api._make_request_dict.return_value = {"rt_cd": "0", "output2": [{"stck_bsop_date": "20250102", "stck_cntg_hour": "090000", "fuop_prpr": "10"}]} + bars, date, time = api._fetch_page("101S03", "20250102", "153000", include_past=False) + assert bars[0]["contract"] == "101S03" and (date, time) == ("20250102", "085900") + assert api._make_request_dict.call_args.kwargs["params"]["FID_PW_DATA_INCU_YN"] == "N" + api._make_request_dict.return_value = {"rt_cd": "0", "output2": [{"stck_bsop_date": "bad", "stck_cntg_hour": "bad"}]} + assert api._fetch_page("101S03", "20250102", "153000")[1:] == ("bad", "bad") + + +def test_history_paginates_filters_and_sorts(monkeypatch): + api = _api() + pages = iter([ + ([{"date": "20250103", "time": "100000"}, {"date": "20250102", "time": "150000"}], "20250102", "145900"), + ([{"date": "20250101", "time": "090000"}], None, None), + ]) + monkeypatch.setattr(api, "_fetch_page", lambda **kwargs: next(pages)) + bars = api.get_contract_history("101S03", "20250102", "20250103", max_bars=10) + assert [(bar["date"], bar["time"]) for bar in bars] == [("20250102", "150000"), ("20250103", "100000")] + + pages = iter([([], None, None), ([], None, None), ([], None, None)]) + monkeypatch.setattr(api, "_fetch_page", lambda **kwargs: next(pages)) + assert api.get_minute_bars("20250102", "20250103") == [] + + +def test_history_default_dates_and_page_transitions(monkeypatch): + api = _api() + pages = iter([ + ([{"date": "20250103", "time": "153000"}], "20250103", "152900"), + ([{"date": "20250102", "time": "090000"}], None, None), + ([], None, None), + ]) + monkeypatch.setattr(api, "_fetch_page", lambda **kwargs: next(pages)) + bars = api.get_minute_bars("20250102", "20250103", max_bars=10) + assert [bar["date"] for bar in bars] == ["20250102", "20250103"] + + api._fetch_page = MagicMock(return_value=([{"date": "20250101", "time": "090000"}], None, None)) + assert api.get_contract_history("101S03", "20250102", "20250103") == [] + api._fetch_page = MagicMock(return_value=([{"date": "20250103", "time": "090000"}], None, None)) + assert api.get_contract_history("101S03", "20250102", "20250103") + + +def test_default_end_dates_weekend_skip_and_empty_contract_page(monkeypatch): + from kis_agent.futures import historical + + class FixedDateTime(datetime): + @classmethod + def now(cls): + return cls(2025, 1, 6) + + monkeypatch.setattr(historical, "datetime", FixedDateTime) + api = _api() + api._fetch_page = MagicMock(return_value=([], None, None)) + assert api.get_minute_bars("20250103") == [] + assert api._fetch_page.call_args_list[0].kwargs["date"] == "20250106" + assert api.get_contract_history("101S03", "20250101", "") == [] + + pages = iter([([], None, None), ([], None, None), ([], None, None)]) + api._fetch_page = MagicMock(side_effect=lambda **kwargs: next(pages)) + assert api.get_minute_bars("20250102", "20250105") == [] diff --git a/tests/unit/test_futures_master.py b/tests/unit/test_futures_master.py index 1a1cb93..1a0f444 100644 --- a/tests/unit/test_futures_master.py +++ b/tests/unit/test_futures_master.py @@ -8,6 +8,7 @@ import pytest +import kis_agent.utils.futures_master as fm from kis_agent.utils.futures_master import ( _IDX_TYPE_MAP, _download_index_master, @@ -228,3 +229,61 @@ def test_load_futures_full(self): cur = get_current_futures() assert cur is not None assert cur["month_type"] == "1" + + +def test_master_parsers_skip_short_records_and_parse_valid_records(): + index_raw = ( + "short\n" + "1|A01606|STD|F 202606||0|1|2001|KOSPI200" + ).encode("cp949") + commodity_raw = ( + "short\n" + + "11GC2604 ".ljust(11) + + "STD".ljust(12) + + "금선물".ljust(32) + + " " * 8 + + "1" + + "GC " + + "금" + ).encode("cp949") + + def archive(raw): + zf = MagicMock() + zf.__enter__.return_value = zf + zf.namelist.return_value = ["master"] + zf.read.return_value = raw + return zf + + response = MagicMock() + response.__enter__.return_value = response + response.read.return_value = b"zip" + with patch.object(fm.urllib.request, "urlopen", return_value=response), patch.object( + fm.zipfile, "ZipFile", return_value=archive(index_raw) + ): + assert fm._download_index_master()[0]["code"] == "A01606" + with patch.object(fm.urllib.request, "urlopen", return_value=response), patch.object( + fm.zipfile, "ZipFile", return_value=archive(commodity_raw) + ): + assert fm._download_commodity_master()[0]["market"] == "commodity" + + +def test_cache_absence_download_fallback_and_empty_search(tmp_path, monkeypatch): + monkeypatch.setattr(fm, "_get_cache_path", lambda: tmp_path / "missing.csv") + assert fm._load_cache() == [] + assert not fm._is_cache_fresh() + + monkeypatch.setattr(fm, "_download_index_master", MagicMock(side_effect=OSError("offline"))) + monkeypatch.setattr(fm, "_load_cache", MagicMock(return_value=SAMPLE_SYMBOLS)) + assert fm.load_futures(force_refresh=True, markets=["commodity"]) == [SAMPLE_SYMBOLS[-1]] + + fm._futures_cache = [] + fm._cache_date = None + monkeypatch.setattr(fm, "_load_cache", MagicMock(return_value=[])) + assert fm.load_futures(force_refresh=True) == [] + monkeypatch.setattr(fm, "load_futures", MagicMock(return_value=[])) + assert fm.search("anything") == [] + + +def test_search_exact_name_match(): + with patch.object(fm, "load_futures", return_value=SAMPLE_SYMBOLS): + assert fm.search("F 202606") == [SAMPLE_SYMBOLS[0]] diff --git a/tests/unit/test_investor_api_extra_paths.py b/tests/unit/test_investor_api_extra_paths.py new file mode 100644 index 0000000..1e3fdfa --- /dev/null +++ b/tests/unit/test_investor_api_extra_paths.py @@ -0,0 +1,31 @@ +"""StockInvestorAPI의 남은 예외 및 일별 거래원 조회 경로 테스트.""" + +from unittest.mock import MagicMock + +from kis_agent.stock.investor_api import StockInvestorAPI + + +def test_current_foreign_broker_parse_error_returns_none(): + api = StockInvestorAPI(client=MagicMock(), enable_cache=False) + api.get_stock_member = MagicMock(return_value={"output": object()}) + + assert api._get_foreign_broker_current("005930") is None + + +def test_member_trading_daily_forwards_all_parameters(): + api = StockInvestorAPI(client=MagicMock(), enable_cache=False) + api._make_request_dict = MagicMock(return_value={"rt_cd": "0"}) + + result = api.get_member_trading_daily( + "005930", "20250101", "20250131", "001", "NX", "A" + ) + + assert result == {"rt_cd": "0"} + assert api._make_request_dict.call_args.kwargs["params"] == { + "FID_COND_MRKT_DIV_CODE": "NX", + "FID_INPUT_ISCD": "005930", + "FID_INPUT_ISCD_2": "001", + "FID_INPUT_DATE_1": "20250101", + "FID_INPUT_DATE_2": "20250131", + "FID_SCTN_CLS_CODE": "A", + } diff --git a/tests/unit/test_investor_db_extra.py b/tests/unit/test_investor_db_extra.py new file mode 100644 index 0000000..d9f0a30 --- /dev/null +++ b/tests/unit/test_investor_db_extra.py @@ -0,0 +1,61 @@ +"""InvestorPositionDB의 조회, 필터 및 실패 경로 회귀 테스트.""" + +import sqlite3 +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import kis_agent.stock.investor_db as investor_db +from kis_agent.stock.investor_db import InvestorPositionDB, InvestorPositionRecord + + +def test_default_path_and_position_by_date(tmp_path, monkeypatch): + fake_os = SimpleNamespace( + makedirs=lambda *args, **kwargs: None, + path=SimpleNamespace(join=lambda *parts: str(tmp_path / parts[-1])), + ) + monkeypatch.setattr(investor_db, "os", fake_os) + default_db = InvestorPositionDB() + assert default_db.db_path.endswith("investor_positions.db") + + monkeypatch.setattr(investor_db, "os", __import__("os")) + + db = InvestorPositionDB(str(tmp_path / "positions.db")) + record = InvestorPositionRecord("005930", "20250102", foreign_net_vol=9) + assert db.save_daily_position(record) + assert db.get_position_by_date("005930", "20250102").foreign_net_vol == 9 + assert db.get_position_by_date("005930", "20250103") is None + assert db.export_data("005930", "20250101", "20250102") + + +def test_database_error_fallbacks(tmp_path): + db = InvestorPositionDB(str(tmp_path / "positions.db")) + with patch("kis_agent.stock.investor_db.sqlite3.connect", side_effect=sqlite3.Error("offline")): + assert db.get_30day_positions("005930") == [] + assert db.get_position_by_date("005930", "20250101") is None + assert not db.save_market_trend("20250101", "KOSPI", {}) + assert db.get_market_summary("20250101") == {} + assert not db.cleanup_old_data() + assert db.get_database_stats() == {} + assert db.export_data() == [] + + +def test_backup_default_and_error_paths(tmp_path): + db = InvestorPositionDB(str(tmp_path / "positions.db")) + assert db.backup_database(str(tmp_path / "copy.db")) + with patch("shutil.copy2", side_effect=OSError("full")): + assert not db.backup_database(str(tmp_path / "bad.db")) + + +def test_initialize_failure_and_default_backup_name(tmp_path): + db = object.__new__(InvestorPositionDB) + db.db_path = str(tmp_path / "broken.db") + db.logger = MagicMock() + with patch("kis_agent.stock.investor_db.sqlite3.connect", side_effect=sqlite3.Error("broken")): + try: + db._initialize_database() + except sqlite3.Error: + pass + else: + raise AssertionError("sqlite error must propagate") + normal = InvestorPositionDB(str(tmp_path / "positions.db")) + assert normal.backup_database() diff --git a/tests/unit/test_investor_position_extra.py b/tests/unit/test_investor_position_extra.py new file mode 100644 index 0000000..118bc23 --- /dev/null +++ b/tests/unit/test_investor_position_extra.py @@ -0,0 +1,125 @@ +"""InvestorPositionAnalyzer의 DataFrame 해석 및 종합 경로 테스트.""" + +import importlib +import sys +from pathlib import Path +from types import ModuleType +from unittest.mock import MagicMock + +import pandas as pd + +import kis_agent.stock.investor as investor_module +from kis_agent.stock.investor import InvestorPositionAnalyzer + + +def _analyzer(): + return InvestorPositionAnalyzer(MagicMock(), {"CANO": "1"}) + + +def test_examples_path_hint_is_added_deterministically(monkeypatch, tmp_path): + examples_path = tmp_path / "examples_llm" + monkeypatch.setenv("OPEN_TRADING_API_EXAMPLES_PATH", str(examples_path)) + monkeypatch.setattr( + Path, + "exists", + lambda self: self == examples_path, + ) + monkeypatch.setattr(sys, "path", [entry for entry in sys.path if entry != str(examples_path)]) + + importlib.reload(investor_module) + + assert str(examples_path) in sys.path + + +def test_daily_cumulative_and_market_wide_analysis(): + analyzer = _analyzer() + frame = pd.DataFrame( + [ + { + "frgn_shnu_vol": "10", + "frgn_seln_vol": "2", + "frgn_ntby_qty": "8", + "frgn_shnu_tr_pbmn": "100", + "frgn_seln_tr_pbmn": "20", + "frgn_ntby_tr_pbmn": "80", + "inst_shnu_vol": "9", + "inst_seln_vol": "1", + "inst_ntby_qty": "8", + "inst_shnu_tr_pbmn": "90", + "inst_seln_tr_pbmn": "10", + "inst_ntby_tr_pbmn": "80", + "prsn_shnu_vol": "1", + "prsn_seln_vol": "9", + "prsn_ntby_qty": "-8", + "prsn_shnu_tr_pbmn": "10", + "prsn_seln_tr_pbmn": "90", + "prsn_ntby_tr_pbmn": "-80", + "stck_prpr": "70000", + "acml_vol": "123", + } + ] + ) + analyzer.get_stock_investor_data = MagicMock(return_value=frame) + daily = analyzer.analyze_daily_position("005930", "20250101") + assert daily["foreign"]["net_amount"] == 80 + analyzer.analyze_daily_position = MagicMock(return_value=daily) + cumulative = analyzer.get_30day_cumulative_analysis("005930") + assert cumulative["institution"]["daily_data"] == [daily["institution"]] + analyzer.get_daily_market_trends = MagicMock(return_value=frame) + analyzer.get_foreign_institution_aggregate = MagicMock(return_value=frame) + result = analyzer.get_market_wide_trends("20250101") + assert result["date"] == "20250101" and "KOSPI" in result["summary"] + + +def test_context_variant_comprehensive_and_failure(): + analyzer = _analyzer() + daily = { + "foreign": {"net_amount": 1}, + "institution": {"net_amount": 1}, + "individual": {"net_amount": -1}, + } + cumulative = { + "foreign": {"net_amount": -1}, + "institution": {"net_amount": -1}, + "individual": {"net_amount": 0}, + } + assert "패턴 변화" in analyzer.interpret_position_context(daily, cumulative) + analyzer.analyze_daily_position = MagicMock(return_value=daily) + analyzer.get_30day_cumulative_analysis = MagicMock(return_value=cumulative) + result = analyzer.analyze_comprehensive_position("005930") + assert result.stock_code == "005930" + analyzer.analyze_daily_position = MagicMock(side_effect=RuntimeError("offline")) + assert analyzer.analyze_comprehensive_position("005930").score == 0.0 + + +def test_import_success_and_remaining_interpretations(monkeypatch): + analyzer = _analyzer() + modules = { + "domestic_stock.foreign_institution_total.foreign_institution_total": "foreign_institution_total", + "domestic_stock.inquire_investor.inquire_investor": "inquire_investor", + "domestic_stock.inquire_investor_daily_by_market.inquire_investor_daily_by_market": "inquire_investor_daily_by_market", + "domestic_stock.inquire_investor_time_by_market.inquire_investor_time_by_market": "inquire_investor_time_by_market", + } + for module_name, function_name in modules.items(): + fake = ModuleType(module_name) + setattr(fake, function_name, lambda: None) + monkeypatch.setitem(sys.modules, module_name, fake) + assert set(analyzer._import_investor_apis()) == set(modules.values()) + + daily = { + "foreign": {"net_amount": 1}, + "institution": {"net_amount": -1}, + "individual": {"net_amount": -1}, + } + cumulative = { + "foreign": {"net_amount": 1}, + "institution": {"net_amount": 1}, + "individual": {"net_amount": 1}, + } + text = analyzer.interpret_position_context(daily, cumulative) + assert "일시적 매도" in text and "개인: 당일 순매도" in text + text = analyzer.interpret_position_context( + {"foreign": {"net_amount": 1}}, + {"foreign": {"net_amount": 1}, "institution": {"net_amount": -1}}, + ) + assert "일부가 30일간" in text diff --git a/tests/unit/test_message_handlers.py b/tests/unit/test_message_handlers.py new file mode 100644 index 0000000..368274f --- /dev/null +++ b/tests/unit/test_message_handlers.py @@ -0,0 +1,63 @@ +"""WebSocket 전략 핸들러의 파싱 및 라우팅 테스트.""" + +from kis_agent.websocket.message_handlers import ( + IndexHandler, + MessageHandlerRegistry, + OrderbookHandler, + PingPongHandler, + ProgramTradingHandler, + TradeHandler, +) + + +class _BaseProbe(TradeHandler): + """추상 기반 구현의 기본 반환값을 직접 검증하는 최소 서브클래스.""" + + def can_handle(self, message): + from kis_agent.websocket.message_handlers import MessageHandler + + return MessageHandler.can_handle(self, message) + + def handle(self, message): + from kis_agent.websocket.message_handlers import MessageHandler + + return MessageHandler.handle(self, message) + + +def test_individual_handlers_parse_messages_and_empty_output(): + trade = TradeHandler() + assert trade.can_handle({"header": {"tr_id": "H0STCNT0"}}) + assert not trade.can_handle({}) + assert trade.handle({"body": {}}) is None + assert trade.handle({"body": {"output": {"stck_shrn_iscd": "005930", "stck_prpr": "10", "prdy_vrss": "1", "prdy_ctrt": "0.1", "acml_vol": "2", "acml_tr_pbmn": "20", "stck_cntg_hour": "090000"}}})["type"] == "trade" + + orderbook = OrderbookHandler() + assert orderbook.can_handle({"tr_id": "H0STASP0"}) + assert orderbook.handle({"body": {}}) is None + result = orderbook.handle({"body": {"output": {"stck_shrn_iscd": "005930", "askp1": "11", "askp_rsqn1": "2", "bidp1": "10"}}}) + assert result["asks"] == [{"price": 11, "volume": 2}] + assert result["bids"] == [{"price": 10, "volume": 0}] + + index = IndexHandler() + assert index.can_handle({"header": {"tr_id": "H0IF1000"}}) + assert index.handle({"body": {}}) is None + result = index.handle({"header": {"tr_key": "0001"}, "body": {"output": {"bstp_nmix_prpr": "1", "bstp_nmix_prdy_vrss": "2", "prdy_vrss_sign": "3", "bstp_nmix_hgpr": "4", "bstp_nmix_lwpr": "0", "acml_vol": "5"}}}) + assert result["name"] == "KOSPI" and result["volume"] == 5 + + program = ProgramTradingHandler() + assert program.can_handle({"tr_id": "H0GSCNT0"}) + assert program.handle({"body": {}}) is None + assert program.handle({"body": {"output": {"stck_shrn_iscd": "005930", "seln_pbmn": "1", "shnu_pbmn": "2", "ntby_pbmn": "3", "seln_vol": "4", "shnu_vol": "5", "ntby_vol": "6"}}})["net_volume"] == 6 + assert PingPongHandler().handle({"type": "PINGPONG"})["type"] == "PONG" + + +def test_registry_routes_default_and_unknown_messages(): + assert _BaseProbe().can_handle({}) is None + assert _BaseProbe().handle({}) is None + registry = MessageHandlerRegistry() + assert len(registry.handlers) == 5 + assert registry.process({"tr_id": "H0STCNT0", "body": {"output": {"stck_prpr": "0"}}})["type"] == "trade" + assert registry.process({"header": {"tr_id": "PINGPONG"}})["type"] == "PONG" + assert registry.process({"tr_id": "unknown"}) is None + registry.set_default_handler(lambda message: {"type": "default", "source": message["tr_id"]}) + assert registry.process({"tr_id": "unknown"}) == {"type": "default", "source": "unknown"} diff --git a/tests/unit/test_method_discovery_extra.py b/tests/unit/test_method_discovery_extra.py new file mode 100644 index 0000000..4de0482 --- /dev/null +++ b/tests/unit/test_method_discovery_extra.py @@ -0,0 +1,24 @@ +"""MethodDiscoveryMixin의 조회·출력·분류 경로 테스트.""" + +from kis_agent.core.method_discovery import MethodDiscoveryMixin + + +class _Discovery(MethodDiscoveryMixin): + def get_stock_price(self): + """현재가 문서.""" + + +def test_categories_usage_and_broker_classification(capsys): + agent = _Discovery() + simple = agent.get_all_methods(category="stock") + assert set(simple) == {"stock", "_summary"} + assert agent.get_all_methods(category="unknown")["error"] + assert agent.search_methods("price") + agent.show_method_usage("get_stock_price") + assert "현재가 문서" in capsys.readouterr().out + agent.show_method_usage("not-found") + assert "찾을 수 없습니다" in capsys.readouterr().out + assert agent.classify_broker(None) == "N/A" + assert agent.classify_broker("골드만삭스") == "외국계" + assert agent.classify_broker("키움증권") == "리테일/국내기관" + assert agent.classify_broker("무명") == "기타" diff --git a/tests/unit/test_misc_remaining_paths.py b/tests/unit/test_misc_remaining_paths.py new file mode 100644 index 0000000..fecf62a --- /dev/null +++ b/tests/unit/test_misc_remaining_paths.py @@ -0,0 +1,33 @@ +"""작은 모듈의 남은 분기 회귀 테스트.""" + +import pytest + +from kis_agent.core.config import KISConfig +from kis_agent.core.constants import WS_REAL_URL, get_ws_url +from kis_agent.core.rate_limiter_mixin import RateLimiterControlMixin +from kis_agent.message_schema import CliMessageValidator +from kis_agent.program.trade import ProgramTradeAPI + + +def test_config_validation_reports_missing_base_url_on_direct_state(): + config = object.__new__(KISConfig) + config.APP_KEY = config.APP_SECRET = config.ACCOUNT_NO = config.ACCOUNT_CODE = "set" + config.BASE_URL = "" + with pytest.raises(ValueError, match="base_url"): + config._validate() + + +def test_real_ws_url_and_invalid_error_response_id(): + assert get_ws_url(True) == WS_REAL_URL + assert CliMessageValidator.validate_response_error({"id": 1}) == (False, "'id' must be a string or null") + + +def test_rate_limiter_disabled_warning_and_program_hourly_delegation(caplog): + mixin = object.__new__(RateLimiterControlMixin) + mixin.rate_limiter = None + mixin.enable_adaptive_rate_limiting(False) + assert "비활성화 상태" in caplog.text + + api = object.__new__(ProgramTradeAPI) + api.get_program_trade_by_stock = lambda code, ref_date: (code, ref_date) + assert api.get_program_trade_hourly_trend("005930") == ("005930", None) diff --git a/tests/unit/test_overseas_facade_extra.py b/tests/unit/test_overseas_facade_extra.py new file mode 100644 index 0000000..2be7fe4 --- /dev/null +++ b/tests/unit/test_overseas_facade_extra.py @@ -0,0 +1,80 @@ +"""Overseas facade의 미노출 위임 메서드 회귀 테스트.""" + +from unittest.mock import MagicMock + +from kis_agent.overseas.api_facade import OverseasStockAPI +from kis_agent.overseas_futures import OverseasFutures + + +def test_remaining_account_order_and_ranking_delegations(): + api = object.__new__(OverseasStockAPI) + api.price_api = MagicMock() + api.account_api = MagicMock() + api.order_api = MagicMock() + api.ranking_api = MagicMock() + api.get_industry_theme("NAS", "AAPL") + api.search_symbol("NAS", "AAPL") + api.get_balance("NAS") + api.get_order_history("NAS") + api.get_unfilled_orders("NAS") + api.get_buyable_amount("NAS") + api.get_present_balance() + api.get_period_profit() + api.get_reserve_order_list() + api.get_foreign_margin("USD") + api.buy_order("NAS", "AAPL", 1, 1.0) + api.sell_order("NAS", "AAPL", 1, 1.0) + api.modify_order("NAS", "AAPL", "1", 1, 1.0) + api.cancel_order("NAS", "AAPL", "1", 1) + api.reserve_order("NAS", "AAPL", "02", 1, 1.0) + api.modify_reserve_order("1", 1, 1.0) + api.cancel_reserve_order("1") + api.trade_volume_ranking("NAS") + api.trade_amount_ranking("NAS") + api.trade_growth_ranking("NAS") + api.trade_turnover_ranking("NAS") + api.market_cap_ranking("NAS") + api.price_change_ranking("NAS") + api.price_fluctuation_ranking("NAS") + api.new_high_low_ranking("NAS") + api.volume_power_ranking("NAS") + api.volume_surge_ranking("NAS") + assert api.account_api.get_balance.called and api.order_api.buy_order.called and api.ranking_api.volume_surge_ranking.called + + +def test_remaining_overseas_futures_delegations(): + api = object.__new__(OverseasFutures) + api.price = MagicMock() + api.account_api = MagicMock() + + api.get_option_price("OPT") + api.get_option_orderbook("OPT") + api.get_futures_info(["ES"]) + api.get_option_info(["OPT"]) + api.get_margin_detail("USD", "20250101") + api.get_order_amount("ES", "02", "100", "Y") + api.get_today_orders() + api.get_daily_orders("20250101", "20250131") + api.get_daily_executions("20250101", "20250131") + api.get_period_profit("20250101", "20250131") + api.get_period_transactions("20250101", "20250131") + + api.price.get_option_price.assert_called_once_with("OPT") + api.price.get_option_orderbook.assert_called_once_with("OPT") + api.price.get_futures_info.assert_called_once_with(["ES"]) + api.price.get_option_info.assert_called_once_with(["OPT"]) + api.account_api.get_margin_detail.assert_called_once_with("USD", "20250101") + api.account_api.get_order_amount.assert_called_once_with("ES", "02", "100", "Y") + api.account_api.get_today_orders.assert_called_once_with("01", "%%", "00") + api.account_api.get_daily_orders.assert_called_once_with( + "20250101", "20250131", "01", "%%", "00", "" + ) + api.account_api.get_daily_executions.assert_called_once_with( + "20250101", "20250131", "00", "%%", "%%%" + ) + api.account_api.get_period_profit.assert_called_once_with( + "20250101", "20250131", "%%%", "00", "N" + ) + api.account_api.get_period_transactions.assert_called_once_with( + "20250101", "20250131", "1", "%%%" + ) diff --git a/tests/unit/test_overseas_order_extra.py b/tests/unit/test_overseas_order_extra.py new file mode 100644 index 0000000..d41a59e --- /dev/null +++ b/tests/unit/test_overseas_order_extra.py @@ -0,0 +1,16 @@ +"""해외 예약 주문의 오류 전파 회귀 테스트.""" + +from unittest.mock import MagicMock + +import pytest + +from kis_agent.overseas.order_api import OverseasOrderAPI + + +def test_reserve_modify_and_cancel_reraise_request_errors(): + api = OverseasOrderAPI(MagicMock(), {"CANO": "1", "ACNT_PRDT_CD": "01"}, _from_agent=True) + api._make_request_dict = MagicMock(side_effect=RuntimeError("offline")) + with pytest.raises(RuntimeError, match="offline"): + api.modify_reserve_order("1", 1, 1.0) + with pytest.raises(RuntimeError, match="offline"): + api.cancel_reserve_order("1") diff --git a/tests/unit/test_rate_limiter_extra.py b/tests/unit/test_rate_limiter_extra.py new file mode 100644 index 0000000..159b7d5 --- /dev/null +++ b/tests/unit/test_rate_limiter_extra.py @@ -0,0 +1,15 @@ +"""RateLimiter의 비적응형과 분당 경계 분기 테스트.""" + +from unittest.mock import patch + +from kis_agent.core.rate_limiter import RateLimiter + + +def test_non_adaptive_and_minute_window_reservation(): + limiter = RateLimiter(requests_per_second=5, requests_per_minute=1, enable_adaptive=False) + limiter.report_success() + limiter.report_error("EGW00201") + assert limiter.backoff_multiplier == 1.0 + limiter.request_times.append(0.5) + with patch("kis_agent.core.rate_limiter.time.monotonic", return_value=1.0), patch("kis_agent.core.rate_limiter.time.sleep"): + assert limiter.acquire() > 59 diff --git a/tests/unit/test_refactored_client.py b/tests/unit/test_refactored_client.py new file mode 100644 index 0000000..d152555 --- /dev/null +++ b/tests/unit/test_refactored_client.py @@ -0,0 +1,105 @@ +"""리팩토링 WebSocket 클라이언트의 수명주기·구독·라우팅 테스트.""" + +import json +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import kis_agent.websocket.refactored_client as refactored_module +from kis_agent.websocket.event_manager import EventType +from kis_agent.websocket.refactored_client import RefactoredWebSocketClient + + +def _client(metrics=True, recording=False): + connection = MagicMock() + connection.connect = AsyncMock() + connection.disconnect = AsyncMock() + connection.send = AsyncMock() + processor = MagicMock() + events = MagicMock() + registry = MagicMock() + client = RefactoredWebSocketClient("key", connection, processor, events, registry, enable_metrics=metrics, data_recording=recording) + return client, connection, processor, events, registry + + +@pytest.mark.asyncio +async def test_connection_and_subscription_lifecycle(): + client, connection, _, events, _ = _client() + connection.is_alive.return_value = False + with pytest.raises(RuntimeError): + await client.subscribe_stock("005930") + with pytest.raises(RuntimeError): + await client.unsubscribe_stock("005930") + with pytest.raises(RuntimeError): + await client.subscribe_index() + + connection.is_alive.return_value = True + await client.connect() + await client.subscribe_stock("005930", with_orderbook=True) + await client.unsubscribe_stock("005930") + await client.subscribe_index(["0001"]) + await client.subscribe_index() + await client.disconnect() + payloads = [json.loads(call.args[0]) for call in connection.send.await_args_list] + assert [payload["body"]["input"]["tr_id"] for payload in payloads] == ["H0STCNT0", "H0STASP0", "H0STCNT0", "H0IF1000", "H0IF1000", "H0IF1000", "H0IF1000"] + assert client.metrics["start_time"] is not None + assert events.emit.call_count == 2 + + +@pytest.mark.asyncio +async def test_run_routes_messages_records_and_updates_metrics(tmp_path): + client, connection, processor, events, registry = _client(recording=False) + client.data_recording = True + client.data_log_file = tmp_path / "data.jsonl" + connection.is_alive.side_effect = [True, True, True, True, False] + connection.recv = AsyncMock(side_effect=["trade", "book", "index", "program"]) + processor.process_message.side_effect = [{}, {}, {}, {}] + registry.process.side_effect = [ + {"type": "trade", "code": "1"}, {"type": "orderbook"}, {"type": "index"}, {"type": "program_trading"} + ] + await client.run() + emitted_types = [call.args[0] for call in events.emit.call_args_list] + assert emitted_types == [EventType.TRADE_UPDATE, EventType.ORDERBOOK_UPDATE, EventType.INDEX_UPDATE, EventType.PROGRAM_TRADING_UPDATE] + assert client.metrics["messages_received"] == client.metrics["messages_processed"] == 4 + assert len(client.data_log_file.read_text().splitlines()) == 4 + + +def test_helpers_recording_callbacks_metrics_and_error(tmp_path): + client, connection, processor, events, _ = _client() + client._record_data({"x": 1}) # 기록 파일 없는 경우 + client.data_log_file = tmp_path / "data.jsonl" + client._record_data({"x": 1}) + assert client.data_log_file.read_text().strip() == '{"x": 1}' + client.add_stock_subscription("005930") + client.enable_index_subscription() + client.enable_orderbook_subscription() + client.enable_program_trading_subscription() + callback = MagicMock() + client.register_callback(EventType.TRADE_UPDATE, callback) + assert client.get_latest_data("005930") is processor.latest_data.get.return_value + assert client.get_indicators("005930") is processor.calculate_indicators.return_value + client.metrics["start_time"] = datetime.now() + connection.get_stats.return_value = {"connected": True} + assert client.get_metrics()["connection_status"] == {"connected": True} + client._on_connection_opened(MagicMock(timestamp="now")) + client._on_connection_closed(MagicMock(timestamp="now")) + with pytest.raises(RuntimeError, match="boom"): + client._on_error(MagicMock(data="boom")) + assert client.metrics["errors"] == 1 + disabled, *_ = _client(metrics=False) + assert disabled.get_metrics() == {} + + +@pytest.mark.asyncio +async def test_recording_setup_and_run_without_handler_result(tmp_path, monkeypatch): + monkeypatch.setattr(refactored_module, "Path", lambda _: tmp_path) + client, connection, processor, _, registry = _client(recording=True) + assert client.data_log_file.parent == tmp_path + connection.is_alive.side_effect = [True, False] + connection.recv = AsyncMock(return_value="ignored") + processor.process_message.return_value = {} + registry.process.return_value = None + await client.run() + assert client.metrics["messages_received"] == 1 + assert client.metrics["messages_processed"] == 0 diff --git a/tests/unit/test_remaining_small_paths.py b/tests/unit/test_remaining_small_paths.py new file mode 100644 index 0000000..0c66e58 --- /dev/null +++ b/tests/unit/test_remaining_small_paths.py @@ -0,0 +1,143 @@ +"""작은 예외 및 기본값 경로 회귀 테스트.""" + +import builtins +import importlib +import logging +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +import kis_agent.cli.schema as schema_module +import kis_agent.cli_bridge as bridge +import kis_agent.stock as stock_module +from kis_agent.account.api import AccountAPI +from kis_agent.core.base_exception_handler import exception_handler +from kis_agent.core.response_processor import DataFrameResponseProcessor +from kis_agent.futures.account_api import FuturesAccountAPI +from kis_agent.futures.order_api import FuturesOrderAPI +from kis_agent.overseas.price_api import OverseasPriceAPI + + +def test_dataframe_processor_rejects_non_tabular_output(): + processor = DataFrameResponseProcessor(lambda df, response: df, lambda df, field_type: df) + assert processor.process({"rt_cd": "0", "output": "not-tabular"}) is None + + +def test_plain_object_exception_handler_warns_and_returns_default(caplog): + class Plain: + @exception_handler(reraise=False, default_return="fallback", log_level="warning", exceptions=ValueError) + def fail(self): + raise ValueError("bad") + + with caplog.at_level(logging.WARNING): + assert Plain().fail() == "fallback" + assert "fail 실행 실패" in caplog.text + + +def test_futures_account_defaults_without_account_or_base_url(): + api = object.__new__(FuturesAccountAPI) + api.account = None + api.client = object() + assert api._get_account_no() == "" + assert api._is_virtual() is False + + +def test_futures_order_defaults_without_account_or_base_url(): + api = object.__new__(FuturesOrderAPI) + api.account = None + api.client = object() + assert api._get_account_no() == "" + assert api._get_account_code() == "03" + assert api._is_virtual() is False + + +def test_cli_bridge_after_market_close_notice(monkeypatch): + evening = datetime(2025, 1, 6, 17, 0, 0) + monkeypatch.setattr( + bridge, "datetime", MagicMock(now=MagicMock(return_value=evening)) + ) + bridge._market_status.update( + {"checked": False, "notice": None, "last_business_day": None} + ) + agent = MagicMock() + agent.stock_api.is_holiday.return_value = False + + bridge.check_market_status(agent) + + assert bridge._market_status["last_business_day"] == "20250106" + assert "장 마감 후" in bridge._market_status["notice"] + + +def test_schema_includes_type_level_doc_comment(monkeypatch): + monkeypatch.setattr( + schema_module, + "SCHEMA_SDL", + '"""설명"""\ntype Documented {\n value: String\n}\n', + ) + assert schema_module.get_schema("Documented").startswith('"""설명"""') + + +def test_overseas_industry_theme_builds_expected_request(): + api = object.__new__(OverseasPriceAPI) + api._make_request_dict = MagicMock(return_value={"rt_cd": "0"}) + + result = api.get_industry_theme("nas", "aapl", "1", "Y") + + assert result == {"rt_cd": "0"} + assert api._make_request_dict.call_args.kwargs["params"] == { + "AUTH": "", + "EXCD": "NAS", + "SYMB": "AAPL", + "ISCD_COND": "1", + "CO_YN": "Y", + } + + +def test_account_facade_remaining_delegations_and_attribute_errors(): + api = object.__new__(AccountAPI) + api._balance_api = MagicMock() + api._order_api = MagicMock() + api._profit_api = MagicMock() + api._delegate_methods = {"delegated": api._balance_api} + api._balance_api.delegated = "value" + + assert api.delegated == "value" + with pytest.raises(AttributeError): + _ = api._private_missing + with pytest.raises(AttributeError): + _ = api.public_missing + + assert api.inquire_balance_rlz_pl() is api._balance_api.inquire_balance_rlz_pl.return_value + assert api.inquire_psbl_sell("005930") is api._balance_api.inquire_psbl_sell.return_value + assert api.inquire_intgr_margin() is api._balance_api.inquire_intgr_margin.return_value + assert api.inquire_psbl_order(1000, "005930", "00") is api._balance_api.inquire_psbl_order.return_value + assert api.inquire_credit_psamount("005930") is api._balance_api.inquire_credit_psamount.return_value + assert api.order_cash("005930", 1, 1000, "buy") is api._order_api.order_cash.return_value + assert api.order_cash_sor("005930", 1, "buy") is api._order_api.order_cash_sor.return_value + assert api.order_credit_buy("005930", 1, 1000) is api._order_api.order_credit_buy.return_value + assert api.order_credit_sell("005930", 1, 1000) is api._order_api.order_credit_sell.return_value + assert api.inquire_period_rights("20250101", "20250131") is api._profit_api.inquire_period_rights.return_value + + +def test_stock_package_falls_back_when_legacy_api_import_fails(monkeypatch): + original_import = builtins.__import__ + + def fail_legacy_import(name, globals=None, locals=None, fromlist=(), level=0): + if ( + level == 1 + and name == "api" + and globals + and globals.get("__package__") == "kis_agent.stock" + ): + raise ImportError("legacy API unavailable") + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fail_legacy_import) + try: + reloaded = importlib.reload(stock_module) + assert reloaded.LegacyStockAPI is None + assert reloaded.get_kospi200_futures_code is None + finally: + monkeypatch.setattr(builtins, "__import__", original_import) + importlib.reload(stock_module) diff --git a/tests/unit/test_response_processor.py b/tests/unit/test_response_processor.py index 2590f94..da488c4 100644 --- a/tests/unit/test_response_processor.py +++ b/tests/unit/test_response_processor.py @@ -53,6 +53,11 @@ def test_process_none_response(self): self.assertIsNone(result) +def test_abstract_processor_contract_raises_when_called_directly(): + with pytest.raises(NotImplementedError): + ResponseProcessor.process(None, {}) + + class TestDataFrameResponseProcessor(unittest.TestCase): """DataFrameResponseProcessor 테스트""" diff --git a/tests/unit/test_stock_api_facade_extra_paths.py b/tests/unit/test_stock_api_facade_extra_paths.py new file mode 100644 index 0000000..fb867dc --- /dev/null +++ b/tests/unit/test_stock_api_facade_extra_paths.py @@ -0,0 +1,26 @@ +"""StockAPI 파사드의 직접 시세 호출 및 동적 위임 실패 회귀 테스트.""" + +from unittest.mock import MagicMock + +import pytest + +from kis_agent.stock.api_facade import StockAPI + + +def test_direct_index_chart_requests_and_missing_dynamic_attribute(): + api = object.__new__(StockAPI) + api.price_api = MagicMock() + api.market_api = MagicMock() + api.investor_api = MagicMock() + api._make_request_dict = MagicMock(return_value={"rt_cd": "0"}) + + assert api.get_daily_index_chart_price("0007", "20250101", "20250131", "W") == api.price_api.get_daily_index_chart_price.return_value + api.price_api.get_daily_index_chart_price.assert_called_once_with("0007", "20250101", "20250131", "W", "U") + assert api.get_time_index_chart_price("0007", "5") == {"rt_cd": "0"} + assert api._make_request_dict.call_args.kwargs["params"]["fid_period_div_code"] == "5" + + api.price_api = object() + api.market_api = object() + api.investor_api = object() + with pytest.raises(AttributeError, match="has no attribute"): + _ = api.not_available diff --git a/tests/unit/test_stock_api_improved_extra_paths.py b/tests/unit/test_stock_api_improved_extra_paths.py new file mode 100644 index 0000000..89ce5e5 --- /dev/null +++ b/tests/unit/test_stock_api_improved_extra_paths.py @@ -0,0 +1,88 @@ +"""개선된 StockAPI의 기본값과 비정상 응답 경로 회귀 테스트.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from kis_agent.core.exceptions import APIException, ValidationException +from kis_agent.stock import api_improved +from kis_agent.stock.api_improved import StockAPI + + +def _api(): + api = object.__new__(StockAPI) + api._make_request_dict = MagicMock() + api._exception_logger = MagicMock() + return api + + +def test_dataframe_unexpected_output_and_daily_validation(): + api = _api() + api._make_request_dict.return_value = {"output": "bad"} + with pytest.raises(APIException): + api._make_request_dataframe("endpoint", "tr", {}) + with pytest.raises(ValidationException): + api.get_daily_price("short") + + +def test_foreign_net_buy_empty_and_populated_defaults_and_holidays(): + api = _api() + api._make_request_dict.return_value = {"output": []} + assert api.get_foreign_net_buy("005930")[0] == 0 + + api._make_request_dict.return_value = { + "output": {"frgn_ntby_qty": "3", "frgn_hldn_rate": "1.2"} + } + assert api.get_foreign_net_buy("005930", "20250101")[0] == 3 + assert ( + api._make_request_dict.call_args.kwargs["endpoint"] + == api_improved.API_ENDPOINTS["INQUIRE_INVESTOR"] + ) + assert api._make_request_dict.call_args.kwargs["tr_id"] == "FHKST01010900" + + api._make_request_dataframe = MagicMock(return_value="holidays") + assert api.get_holidays() == "holidays" + assert ( + api._make_request_dataframe.call_args.kwargs["endpoint"] + == api_improved.API_ENDPOINTS["CHK_HOLIDAY"] + ) + assert api._make_request_dataframe.call_args.kwargs["tr_id"] == "CTCA0903R" + with pytest.raises(ValidationException): + api.get_foreign_net_buy("005930", "bad") + + +def test_stock_member_raises_when_every_response_is_empty(): + api = _api() + api.client = MagicMock() + api.client.make_request.return_value = None + with pytest.raises(APIException, match="회원사 정보 조회 실패"): + api.get_stock_member("005930", retries=2) + + +@pytest.mark.parametrize( + ("price_error", "foreign_error", "expected"), + [ + (None, None, "삼성전자 현재가"), + (ValidationException("bad input"), None, "입력 오류"), + (APIException("bad api"), None, "API 오류"), + (RuntimeError("bad runtime"), APIException("bad foreign"), "예상치 못한 오류"), + ], +) +def test_example_usage_handles_each_documented_error( + price_error, foreign_error, expected, capsys +): + with patch("kis_agent.core.client.KISClient") as client, patch.object( + api_improved, "StockAPI" + ) as stock_api: + instance = stock_api.return_value + instance.get_stock_price.side_effect = price_error + instance.get_foreign_net_buy.side_effect = foreign_error + if foreign_error is None: + instance.get_foreign_net_buy.return_value = (10, {"code": "005930"}) + api_improved.example_usage() + + assert client.called + output = capsys.readouterr().out + assert expected in output + if foreign_error: + assert "조회 실패" in output diff --git a/tests/unit/test_stock_api_legacy_extra.py b/tests/unit/test_stock_api_legacy_extra.py new file mode 100644 index 0000000..5a8aa35 --- /dev/null +++ b/tests/unit/test_stock_api_legacy_extra.py @@ -0,0 +1,28 @@ +"""레거시 StockAPI의 위임 실패 및 폐기 API 회귀 테스트.""" + +import pytest + +from kis_agent.stock.api import StockAPI + + +def _api(): + api = object.__new__(StockAPI) + api._price_api = object() + api._market_api = object() + api._investor_api = object() + return api + + +def test_legacy_api_getattr_error_paths_and_deprecated_orders(): + api = _api() + api._market_api = type("MarketAPI", (), {"lookup": "delegated"})() + + with pytest.raises(AttributeError, match="_private"): + _ = api._private + assert api.lookup == "delegated" + with pytest.raises(AttributeError, match="legacy class"): + _ = api.not_supported + with pytest.raises(DeprecationWarning): + api.order_cash("005930", 1) + with pytest.raises(DeprecationWarning): + api.order_credit("005930", 1) diff --git a/tests/unit/test_stock_master.py b/tests/unit/test_stock_master.py new file mode 100644 index 0000000..de2988b --- /dev/null +++ b/tests/unit/test_stock_master.py @@ -0,0 +1,72 @@ +"""종목 마스터 다운로드·캐시·검색의 네트워크 없는 회귀 테스트.""" + +import io +import zipfile +from datetime import datetime + +from kis_agent.utils import stock_master + + +def _master_line(code, name): + return code.encode("euc-kr").ljust(9, b" ") + b" " * 12 + name.encode("euc-kr").ljust(40, b" ") + b"\n" + + +class _Response: + def __init__(self, data): + self.data = data + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return self.data + + +def test_download_master_handles_zip_plain_and_invalid_records(monkeypatch): + raw = _master_line("A005930", "삼성전자") + b"short\n" + _master_line("", "잘못된코드") + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("kospi.mst", raw) + monkeypatch.setattr(stock_master.urllib.request, "urlopen", lambda *args, **kwargs: _Response(buffer.getvalue())) + assert stock_master._download_master("kospi") == [{"code": "005930", "name": "삼성전자", "market": "코스피"}] + monkeypatch.setattr(stock_master.urllib.request, "urlopen", lambda *args, **kwargs: _Response(raw)) + assert stock_master._download_master("kosdaq")[0]["market"] == "코스닥" + + +def test_cache_load_refresh_fallback_search_and_resolve(tmp_path, monkeypatch): + monkeypatch.setattr(stock_master, "_CACHE_DIR", tmp_path) + monkeypatch.setattr(stock_master, "_stock_cache", []) + monkeypatch.setattr(stock_master, "_cache_date", None) + assert stock_master._load_cache() == [] + assert not stock_master._is_cache_fresh() + stocks = [ + {"code": "005930", "name": "삼성전자", "market": "코스피"}, + {"code": "005935", "name": "삼성전자우", "market": "코스피"}, + {"code": "000660", "name": "SK하이닉스", "market": "코스피"}, + ] + stock_master._save_cache(stocks) + assert stock_master._load_cache() == stocks + assert stock_master._is_cache_fresh() + assert stock_master.load_stocks() == stocks + assert stock_master.load_stocks() == stocks # 메모리 캐시 + assert stock_master.search("005930") == [stocks[0]] + assert stock_master.search("삼성") == stocks[:2] + assert stock_master.search("닉", limit=1) == [stocks[2]] + assert stock_master.resolve_code("005930") == "005930" + assert stock_master.resolve_code("삼성전자우") == "005935" + assert stock_master.resolve_code("없는종목") is None + + monkeypatch.setattr(stock_master, "_stock_cache", []) + monkeypatch.setattr(stock_master, "_is_cache_fresh", lambda: False) + monkeypatch.setattr(stock_master, "_download_master", lambda exchange: stocks[:1] if exchange == "kospi" else stocks[1:]) + assert stock_master.load_stocks(force_refresh=True) == stocks + monkeypatch.setattr(stock_master, "_stock_cache", []) + monkeypatch.setattr(stock_master, "_download_master", lambda exchange: (_ for _ in ()).throw(RuntimeError("offline"))) + assert stock_master.load_stocks(force_refresh=True) == stocks + monkeypatch.setattr(stock_master, "_load_cache", lambda: []) + assert stock_master.load_stocks(force_refresh=True) == [] + monkeypatch.setattr(stock_master, "load_stocks", lambda: []) + assert stock_master.search("삼성") == [] diff --git a/tests/unit/test_stock_price_extra.py b/tests/unit/test_stock_price_extra.py new file mode 100644 index 0000000..5bc197e --- /dev/null +++ b/tests/unit/test_stock_price_extra.py @@ -0,0 +1,57 @@ +"""회원사 재시도와 전체 일봉 페이지네이션 회귀 테스트.""" + +from unittest.mock import MagicMock + +from kis_agent.stock.price_api import StockPriceAPI + + +def _api(): + return StockPriceAPI(MagicMock(), {"CANO": "1", "ACNT_PRDT_CD": "01"}) + + +def test_member_retries_success_error_none_exception_and_alias(): + api = _api() + api._make_request_dict = MagicMock(side_effect=[{"rt_cd": "1", "msg1": "retry"}, {"rt_cd": "0", "output": []}]) + assert api.get_stock_member("005930", retries=2)["rt_cd"] == "0" + api._make_request_dict.side_effect = [{"rt_cd": "1"}] + assert api.get_stock_member("005930", retries=1)["rt_cd"] == "1" + api._make_request_dict.side_effect = [None] + assert api.get_stock_member("005930", retries=1) is None + api._make_request_dict.side_effect = [None, None] + assert api.get_stock_member("005930", retries=2) is None + api._make_request_dict.side_effect = RuntimeError("offline") + assert api.get_stock_member("005930", retries=1) is None + api._make_request_dict.side_effect = [RuntimeError("offline"), RuntimeError("offline")] + assert api.get_stock_member("005930", retries=2) is None + assert api.get_stock_member("005930", retries=0) is None + api.get_stock_member = MagicMock(return_value="alias") + assert api.get_member("005930", 3, "NX") == "alias" + + +def test_daily_price_all_paginates_deduplicates_and_stops_safely(): + api = _api() + page1 = [{"stck_bsop_date": "20250103"}] * 100 + page1[-1] = {"stck_bsop_date": "20250102"} + page2 = [{"stck_bsop_date": "20250102"}, {"stck_bsop_date": "20250101"}] + api.inquire_daily_itemchartprice = MagicMock(side_effect=[{"rt_cd": "0", "output1": {"name": "x"}, "output2": page1}, {"rt_cd": "0", "output2": page2}]) + result = api.get_daily_price_all("005930", "20250101", "20250103") + assert result["_pagination_info"]["total_calls"] == 2 + assert [row["stck_bsop_date"] for row in result["output2"]] == ["20250103", "20250102", "20250101"] + api.inquire_daily_itemchartprice.side_effect = [{"rt_cd": "0", "output2": []}] + assert api.get_daily_price_all("005930", "20250101", "20250103")["output2"] == [] + api.inquire_daily_itemchartprice.side_effect = [{"rt_cd": "1", "msg1": "bad"}] + assert api.get_daily_price_all("005930", "20250101", "20250103")["_pagination_info"]["total_calls"] == 1 + api.inquire_daily_itemchartprice.side_effect = [{"rt_cd": "0", "output2": [{"stck_bsop_date": ""}] * 100}] + assert api.get_daily_price_all("005930", "20250101", "20250103")["_pagination_info"]["total_calls"] == 1 + api.inquire_daily_itemchartprice.side_effect = [{"rt_cd": "0", "output2": [{"stck_bsop_date": "bad"}] * 100}] + assert api.get_daily_price_all("005930", "20250101", "20250103")["_pagination_info"]["total_calls"] == 1 + api.inquire_daily_itemchartprice.side_effect = [{"rt_cd": "0", "output2": [{"stck_bsop_date": "20250101"}] * 100}] + assert api.get_daily_price_all("005930", "20250101", "20250103")["_pagination_info"]["total_calls"] == 1 + + +def test_index_financial_and_basic_requests_delegate_to_client(): + api = _api() + api._make_request_dict = MagicMock(return_value={"rt_cd": "0"}) + assert api.get_daily_index_chart_price("0007", "20250101", "20250102") == {"rt_cd": "0"} + assert api.get_stock_financial("005930") == {"rt_cd": "0"} + assert api.get_stock_basic("005930") == {"rt_cd": "0"} diff --git a/tests/unit/test_technical_analysis_extra.py b/tests/unit/test_technical_analysis_extra.py new file mode 100644 index 0000000..d76051f --- /dev/null +++ b/tests/unit/test_technical_analysis_extra.py @@ -0,0 +1,94 @@ +"""TechnicalAnalysisMixin의 예외·캐시·CSV 이관 회귀 테스트.""" + +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pandas as pd + +from kis_agent.core.technical_analysis import TechnicalAnalysisMixin + + +class _Agent(TechnicalAnalysisMixin): + def __init__(self): + self.stock_api = MagicMock() + self.is_holiday = MagicMock(return_value=False) + + +def test_init_migrate_and_business_day_fallbacks(tmp_path, monkeypatch): + agent = _Agent() + assert agent.init_minute_db(str(tmp_path / "minute.db")) + with patch("kis_agent.core.technical_analysis.sqlite3.connect", side_effect=RuntimeError("db")): + assert not agent.init_minute_db("bad") + monkeypatch.chdir(tmp_path) + assert agent.migrate_minute_csv_to_db("005930") + Path("cache").mkdir() + Path("cache/005930_minute_data.csv").write_text("stck_prpr\n", encoding="utf-8") + assert agent.migrate_minute_csv_to_db("005930") + pd.DataFrame({"stck_prpr": [1]}).to_csv("cache/005930_minute_data.csv", index=False) + assert agent.migrate_minute_csv_to_db("005930", str(tmp_path / "minute.db")) + assert not Path("cache/005930_minute_data.csv").exists() + Path("cache/005930_minute_data.csv").write_text("bad\n\"", encoding="utf-8") + assert not agent.migrate_minute_csv_to_db("005930") + agent.is_holiday.side_effect = RuntimeError("offline") + assert agent._get_last_business_day("20250106") == "20250106" + agent.is_holiday.side_effect = lambda _: True + assert agent._get_last_business_day("20250106") == "20250106" + + +def test_fetch_cache_and_db_failure_paths(tmp_path, monkeypatch): + agent = _Agent() + now = datetime.now().replace(hour=12, minute=0, second=0, microsecond=0) + path = tmp_path / "today.csv" + pd.DataFrame({"x": [1]}).to_csv(path, index=False) + monkeypatch.setattr("kis_agent.core.technical_analysis.datetime", MagicMock(now=MagicMock(return_value=now), fromtimestamp=datetime.fromtimestamp, strptime=datetime.strptime)) + # 파일 생성 시각과 고정된 테스트 시각의 날짜/시간 차이에 의존하지 않도록 + # 유효 캐시와 만료 캐시의 mtime을 모두 명시적으로 설정한다. + import os + os.utime(path, (now.timestamp(), now.timestamp())) + assert agent._check_cache(str(path), now.strftime("%Y%m%d"), now) is not None + old = now - timedelta(hours=1) + os.utime(path, (old.timestamp(), old.timestamp())) + assert agent._check_cache(str(path), now.strftime("%Y%m%d"), now) is None + agent.stock_api.get_intraday_price.return_value = {"rt_cd": "0", "output2": []} + assert agent.fetch_minute_data("005930", "20250101", str(tmp_path)).empty + with patch("kis_agent.core.technical_analysis.sqlite3.connect", side_effect=RuntimeError("db")): + agent._save_to_db(pd.DataFrame({"x": [1]}), "005930", "20250101") + + +def test_default_date_fetch_cache_modes_and_db_save(tmp_path, monkeypatch): + agent = _Agent() + now = datetime(2025, 1, 6, 10, 0, 0) + fake_datetime = MagicMock(now=MagicMock(return_value=now), fromtimestamp=datetime.fromtimestamp, strptime=datetime.strptime) + monkeypatch.setattr("kis_agent.core.technical_analysis.datetime", fake_datetime) + agent._get_last_business_day = MagicMock(side_effect=["20250106", "20250103"]) + agent.stock_api.get_intraday_price.return_value = {"rt_cd": "0", "output2": []} + assert agent.fetch_minute_data("005930", cache_dir=str(tmp_path)).empty + assert agent._get_last_business_day.call_count == 2 + + path = tmp_path / "bad.csv" + path.write_text("bad\n\"", encoding="utf-8") + assert agent._check_cache(str(path), "20250101", now) is None + + conn = MagicMock() + with patch("kis_agent.core.technical_analysis.sqlite3.connect", return_value=conn), patch.object(pd.DataFrame, "to_sql") as to_sql: + agent._save_to_db(pd.DataFrame({"x": [1]}), "005930", "20250101") + conn.execute.assert_called_once() + conn.close.assert_called_once() + to_sql.assert_called_once() + + +def test_premarket_default_and_after_hours_cache(tmp_path, monkeypatch): + agent = _Agent() + now = datetime(2025, 1, 6, 8, 0, 0) + monkeypatch.setattr("kis_agent.core.technical_analysis.datetime", MagicMock(now=MagicMock(return_value=now), fromtimestamp=datetime.fromtimestamp, strptime=datetime.strptime)) + agent._get_last_business_day = MagicMock(return_value="20250103") + agent.stock_api.get_intraday_price.return_value = {"rt_cd": "0", "output2": []} + assert agent.fetch_minute_data("005930", cache_dir=str(tmp_path)).empty + + import os + path = tmp_path / "same-day.csv" + pd.DataFrame({"x": [1]}).to_csv(path, index=False) + evening = datetime(2025, 1, 6, 18, 0, 0) + os.utime(path, (evening.timestamp(), evening.timestamp())) + assert agent._check_cache(str(path), "20250106", evening) is not None diff --git a/tests/unit/test_vkospi_calculator.py b/tests/unit/test_vkospi_calculator.py index bd654d3..74879f6 100644 --- a/tests/unit/test_vkospi_calculator.py +++ b/tests/unit/test_vkospi_calculator.py @@ -7,6 +7,7 @@ import pytest +import kis_agent.futures.vkospi as vkospi from kis_agent.futures.vkospi import ( VKOSPICalculator, VKOSPIResult, @@ -19,7 +20,6 @@ interpolation_weights, ) - # ── 헬퍼 ──────────────────────────────────────────────────────────────────── def otm_row(iv: str, vega: str = "1.0", vol: str = "100", cls: str = "OTM 콜") -> dict: @@ -279,3 +279,18 @@ def test_calculate_from_single(self): iv = calc.calculate_from_single(resp) assert iv is not None assert 18.0 <= iv <= 22.0 + + +def test_default_dates_and_result_representation(monkeypatch): + class FixedDate(date): + @classmethod + def today(cls): + return cls(2026, 6, 5) + + monkeypatch.setattr(vkospi, "date", FixedDate) + assert vkospi.get_option_expiry_months()[0] == (2026, 6) + assert vkospi.get_days_to_expiry(2026, 6) == 6 + calc = VKOSPICalculator() + response = {"output1": [otm_row("20", vol="100")], "output2": []} + result = calc.calculate(response, response) + assert "VKOSPIResult(value=" in repr(result) diff --git a/tests/unit/test_websocket_client_processing.py b/tests/unit/test_websocket_client_processing.py new file mode 100644 index 0000000..a64114b --- /dev/null +++ b/tests/unit/test_websocket_client_processing.py @@ -0,0 +1,1228 @@ +"""레거시 WebSocket 클라이언트의 순수 메시지·지표 처리 회귀 테스트.""" + +import asyncio +import json +import sys +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pandas as pd +import pytest + +from kis_agent.websocket.client import KisWebSocket + + +def _ws(tmp_path): + ws = object.__new__(KisWebSocket) + ws.client = MagicMock() + ws.approval_key = "approval" + ws.stock_codes = ["005930"] + ws.trade_history = {"005930": []} + ws.latest_trade = {"005930": None} + ws.latest_ask_bid = {} + ws.latest_index = {} + ws.latest_index_expected = {} + ws.latest_program_trading = {} + ws.latest_expected_stock = {} + ws.prev_indicators = {"005930": (None, None)} + ws.subscribed_stocks = {"005930"} + ws.stock_names = {"005930": "삼성전자"} + ws.open_prices = {} + ws.purchase_prices = {} + ws.trade_log_file = str(tmp_path / "trade.jsonl") + ws.enable_ask_bid = ws.enable_index = ws.enable_program_trading = True + ws.enable_expected_index = ws.enable_expected_stock = True + return ws + + +def test_constructor_initializes_all_runtime_state(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + client = MagicMock() + with patch("kis_agent.websocket.client.StockAPI") as stock_api, pytest.deprecated_call(): + ws = KisWebSocket( + client, + {"account": "123"}, + stock_codes=["005930"], + purchase_prices={"005930": (70000, 1)}, + enable_ask_bid=True, + enable_expected_index=True, + enable_expected_stock=True, + ) + + stock_api.assert_called_once_with(client=client, account_info={"account": "123"}) + assert ws.stock_codes == ["005930"] + assert ws.latest_trade == {"005930": None} + assert ws.trade_history == {"005930": []} + assert ws.purchase_prices["005930"] == (70000, 1) + assert ws.subscribed_stocks == {"005930"} + assert ws.enable_ask_bid and ws.enable_expected_index and ws.enable_expected_stock + assert ws.ping_interval == 30 and ws.max_ping_retries == 3 + + +@pytest.mark.asyncio +async def test_connect_sends_enabled_subscriptions_and_stops_on_event(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.url = "ws://example" + ws.ping_interval = ws.ping_timeout = 1 + ws.enable_index = ws.enable_ask_bid = ws.enable_program_trading = True + ws.enable_expected_index = ws.enable_expected_stock = True + for method in ( + "get_approval", + "load_historical_data", + "fetch_stock_names", + "fetch_open_prices", + "load_initial_balance", + "display_balance_info", + ): + monkeypatch.setattr(ws, method, MagicMock()) + + stop_event = asyncio.Event() + + async def recv(_self): + stop_event.set() + return "PINGPONG" + + socket = type("Socket", (), {"send": AsyncMock(), "recv": recv})() + + class Connection: + async def __aenter__(self): + return socket + + async def __aexit__(self, *_args): + return False + + monkeypatch.setattr( + "kis_agent.websocket.client.websockets.connect", + lambda *_args, **_kwargs: Connection(), + ) + sleep_calls = 0 + + async def set_stop(_seconds): + nonlocal sleep_calls + sleep_calls += 1 + if sleep_calls >= 7: + stop_event.set() + + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", set_stop) + + await ws.connect(stop_event=stop_event) + + tr_ids = {json.loads(call.args[0])["body"]["input"]["tr_id"] for call in socket.send.await_args_list} + assert { + "H0IF1000", + "H0UPANC0", + "H0STCNT0", + "H0STASP0", + "H0GSCNT0", + "H0UNANC0", + } <= tr_ids + + +@pytest.mark.asyncio +async def test_connect_receives_trade_message_during_market_session(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.url = "ws://example" + ws.ping_interval = ws.ping_timeout = 1 + ws.enable_index = ws.enable_ask_bid = ws.enable_program_trading = False + ws.enable_expected_index = ws.enable_expected_stock = False + for method in ( + "get_approval", + "load_historical_data", + "fetch_stock_names", + "fetch_open_prices", + "load_initial_balance", + "display_balance_info", + ): + monkeypatch.setattr(ws, method, MagicMock()) + ws.handle_message = MagicMock() + stop_event = asyncio.Event() + + class MarketDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 1, 6, 10, 0, tzinfo=tz) + + async def recv(_self): + stop_event.set() + return "0|H0STCNT0|001|005930^093000^70000" + + socket = type("Socket", (), {"send": AsyncMock(), "recv": recv})() + + class Connection: + async def __aenter__(self): + return socket + + async def __aexit__(self, *_args): + return False + + import datetime as datetime_module + + monkeypatch.setattr(datetime_module, "datetime", MarketDateTime) + monkeypatch.setattr( + "kis_agent.websocket.client.websockets.connect", + lambda *_args, **_kwargs: Connection(), + ) + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", AsyncMock()) + + await ws.connect(stop_event=stop_event) + + ws.handle_message.assert_called_once_with("0|H0STCNT0|001|005930^093000^70000") + + +@pytest.mark.asyncio +async def test_connect_pings_after_receive_timeout(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.url = "ws://example" + ws.ping_interval = ws.ping_timeout = 1 + ws.stock_codes = [] + ws.enable_index = ws.enable_ask_bid = ws.enable_program_trading = False + ws.enable_expected_index = ws.enable_expected_stock = False + for method in ( + "get_approval", + "load_historical_data", + "fetch_stock_names", + "fetch_open_prices", + "load_initial_balance", + "display_balance_info", + ): + monkeypatch.setattr(ws, method, MagicMock()) + stop_event = asyncio.Event() + + class MarketDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 1, 6, 10, 0, tzinfo=tz) + + async def recv(_self): + raise asyncio.TimeoutError + + pings = 0 + + async def ping(_self): + nonlocal pings + pings += 1 + stop_event.set() + future = asyncio.get_running_loop().create_future() + future.set_result(None) + return future + + socket = type("Socket", (), {"send": AsyncMock(), "recv": recv, "ping": ping})() + + class Connection: + async def __aenter__(self): + return socket + + async def __aexit__(self, *_args): + return False + + import datetime as datetime_module + + monkeypatch.setattr(datetime_module, "datetime", MarketDateTime) + monkeypatch.setattr( + "kis_agent.websocket.client.websockets.connect", + lambda *_args, **_kwargs: Connection(), + ) + await ws.connect(stop_event=stop_event) + assert stop_event.is_set() and pings == 1 + + +@pytest.mark.asyncio +async def test_connect_reconnects_after_connection_error(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.url = "ws://example" + ws.ping_interval = ws.ping_timeout = 1 + ws.stock_codes = [] + ws.enable_index = ws.enable_ask_bid = ws.enable_program_trading = False + ws.enable_expected_index = ws.enable_expected_stock = False + for method in ( + "get_approval", + "load_historical_data", + "fetch_stock_names", + "fetch_open_prices", + "load_initial_balance", + "display_balance_info", + ): + monkeypatch.setattr(ws, method, MagicMock()) + stop_event = asyncio.Event() + + async def stop_sleep(_seconds): + stop_event.set() + + class BrokenConnection: + async def __aenter__(self): + raise RuntimeError("offline") + + async def __aexit__(self, *_args): + return False + + monkeypatch.setattr( + "kis_agent.websocket.client.websockets.connect", + lambda *_args, **_kwargs: BrokenConnection(), + ) + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", stop_sleep) + await ws.connect(stop_event=stop_event) + assert stop_event.is_set() + + +@pytest.mark.asyncio +async def test_connect_reconnects_after_ping_error(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.url = "ws://example" + ws.ping_interval = ws.ping_timeout = 1 + ws.max_ping_retries = 1 + ws.stock_codes = [] + ws.enable_index = ws.enable_ask_bid = ws.enable_program_trading = False + ws.enable_expected_index = ws.enable_expected_stock = False + for method in ( + "get_approval", + "load_historical_data", + "fetch_stock_names", + "fetch_open_prices", + "load_initial_balance", + "display_balance_info", + ): + monkeypatch.setattr(ws, method, MagicMock()) + stop_event = asyncio.Event() + + class MarketDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 1, 6, 10, 0, tzinfo=tz) + + async def recv(_self): + raise asyncio.TimeoutError + + async def ping(_self): + raise RuntimeError("ping failed") + + socket = type("Socket", (), {"send": AsyncMock(), "recv": recv, "ping": ping})() + + class Connection: + async def __aenter__(self): + return socket + + async def __aexit__(self, *_args): + return False + + async def stop_sleep(_seconds): + stop_event.set() + + import datetime as datetime_module + + monkeypatch.setattr(datetime_module, "datetime", MarketDateTime) + monkeypatch.setattr( + "kis_agent.websocket.client.websockets.connect", + lambda *_args, **_kwargs: Connection(), + ) + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", stop_sleep) + await ws.connect(stop_event=stop_event) + assert stop_event.is_set() + + +@pytest.mark.asyncio +async def test_poll_final_price_unsubscribes_and_updates_close(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.stock_api = MagicMock() + ws.stock_api.get_stock_price.return_value = {"stck_prpr": "71000"} + ws.unsubscribe_all = MagicMock() + + class AfterCloseDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 1, 6, 15, 21, tzinfo=tz) + + async def cancel_after_update(_seconds): + raise asyncio.CancelledError + + import datetime as datetime_module + + monkeypatch.setattr(datetime_module, "datetime", AfterCloseDateTime) + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", cancel_after_update) + with pytest.raises(asyncio.CancelledError): + await ws.poll_final_price() + + ws.unsubscribe_all.assert_called_once() + assert "71000.0" in ws.latest_trade["005930"] + + +def test_format_approval_history_and_indicators(tmp_path): + ws = _ws(tmp_path) + assert ws.format_price("12345") == "12,345" + assert ws.format_price("1.5") == "1.5" + assert ws.format_price("bad") == "bad" + ws.client.get_ws_approval_key.return_value = "approved" + assert ws.get_approval() == "approved" + ws.client.get_ws_approval_key.return_value = None + with pytest.raises(ValueError, match="approval_key"): + ws.get_approval() + + start = datetime.now().replace(minute=0, second=0, microsecond=0) - timedelta(minutes=30) + for index in range(30): + ws.update_trade_history("005930", start + timedelta(minutes=index), 100 + index, 90 + index) + assert len(Path(ws.trade_log_file).read_text().splitlines()) == 30 + assert ws.compute_RSI("005930") == 100 + assert ws.compute_MACD("005930") is not None + assert len(ws.compute_candles("005930", interval_minutes=5)) == 6 + assert ws.compute_RSI_candles("005930") == 100 + assert ws.compute_MACD_candles("005930") is not None + assert ws.compute_MACD_oscillator_candles("005930", span_long=3, signal_span=2) is not None + assert ws.compute_ATR("005930", period=2) >= 0 + assert ws.compute_trade_strength_candle("005930") + assert ws.compute_RSI("missing") is None + assert ws.compute_MACD("missing") is None + assert ws.compute_ATR("missing") is None + + +def test_handle_all_realtime_and_json_message_types(tmp_path, monkeypatch): + ws = _ws(tmp_path) + monkeypatch.setattr(ws, "update_price_and_indicators", MagicMock()) + monkeypatch.setattr(ws, "display_ask_bid_info", MagicMock()) + monkeypatch.setattr(ws, "display_index_info", MagicMock()) + monkeypatch.setattr(ws, "display_program_trading_info", MagicMock()) + + fields = ["005930", "093000", "70000"] + ["0"] * 15 + ["99"] + ws.handle_message("0|H0STCNT0|001|" + "^".join(fields)) + assert ws.latest_trade["005930"] and ws.trade_history["005930"] + ws.handle_message("0|H0STASP0|001|005930^" + "^".join(["1"] * 50)) + assert "005930" in ws.latest_ask_bid + ws.handle_message("0|H0IF1000|001|0001^2500^1^0.1^0^0^0^0^0^0") + assert "KOSPI" in ws.latest_index + ws.handle_message("0|H0UPANC0|001|2001^x^x") + ws.handle_message("0|H0GSCNT0|001|005930^" + "^".join(["1"] * 10)) + ws.handle_message("0|H0UNANC0|001|005930^x") + assert ws.latest_index_expected and ws.latest_program_trading and ws.latest_expected_stock + ws.handle_message("0|H0STCNI0|001|000660^x") + assert "000660" in ws.stock_codes + + ws.handle_message(json.dumps({"header": {"tr_id": "PINGPONG", "tr_key": "x"}, "body": {}})) + ws.handle_message( + json.dumps( + { + "header": {"tr_id": "x", "tr_key": "x"}, + "body": {"msg1": "SUBSCRIBE SUCCESS"}, + } + ) + ) + ws.handle_message( + json.dumps( + { + "header": {"tr_id": "H0STCNI9", "tr_key": "x"}, + "body": {"output": {"key": "key", "iv": "iv"}}, + } + ) + ) + assert (ws.aes_key, ws.aes_iv) == ("key", "iv") + + +@pytest.mark.asyncio +async def test_notice_subscription_and_malformed_trade_are_isolated(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.ws = type("Socket", (), {"send": AsyncMock()})() + monkeypatch.setattr(ws, "update_price_and_indicators", MagicMock()) + ws.handle_message("0|H0STCNT0|001|005930^bad-time^not-price") + ws.handle_message("0|H0STCNI0|001|000660^x") + await asyncio.sleep(0) + assert ws.ws.send.await_count == 1 + assert "000660" in ws.subscribed_stocks + + +def test_trade_format_and_summary(tmp_path): + ws = _ws(tmp_path) + ws.latest_trade["005930"] = "005930^093000^71000^x^100^0.2^x^x^x^x^x^x^100^x^100000000^x^x^x^99" + ws.purchase_prices["005930"] = (70000, 2) + ws.open_prices["005930"] = 70000 + ws.compute_RSI_candles = MagicMock(return_value=50.0) + ws.compute_MACD_candles = MagicMock(return_value=10.0) + ws.compute_ATR = MagicMock(return_value=3.0) + ws.compute_candles = MagicMock(return_value=[(datetime.now(), 1, 1, 1, 71000)] * 20) + assert "체결가" in ws.format_trade_string(ws.latest_trade["005930"]) + assert ws.trade_summary()["005930"][7] == 142000.0 + + +def test_trade_summary_handles_empty_invalid_and_long_candle_history(tmp_path): + ws = _ws(tmp_path) + ws.stock_codes = ["005930", "000660"] + ws.latest_trade["005930"] = "005930^093000^not-a-price" + ws.latest_trade["000660"] = None + ws.purchase_prices["005930"] = (100, 1) + ws.compute_RSI_candles = MagicMock(return_value=None) + ws.compute_MACD_candles = MagicMock(return_value=None) + ws.compute_ATR = MagicMock(return_value=None) + candles = [(datetime.now(), 1, 1, 1, price) for price in range(120)] + ws.compute_candles = MagicMock(return_value=candles) + + summary = ws.trade_summary() + + assert summary["005930"][2] is None + assert summary["005930"][13] is not None and summary["005930"][14] is not None + assert summary["000660"][2] is None + + +def test_should_exit_on_all_profit_taking_signals(tmp_path): + ws = _ws(tmp_path) + ws.latest_trade["005930"] = "005930^093000^110^x" + ws.purchase_prices["005930"] = (100, 1) + ws.prev_indicators["005930"] = (70.0, None) + ws.compute_RSI_candles = MagicMock(return_value=60.0) + ws.compute_trade_strength_candle = MagicMock(return_value=[(None, 100), (None, 90), (None, 80)]) + ws.compute_ATR = MagicMock(return_value=0.5) + + assert ws.should_exit("005930") is True + + +def test_should_exit_false_for_missing_trade_low_profit_and_missing_purchase(tmp_path): + ws = _ws(tmp_path) + assert ws.should_exit("005930") is False + ws.latest_trade["005930"] = "005930^093000^101^x" + ws.purchase_prices["005930"] = (100, 1) + assert ws.should_exit("005930") is False + ws.purchase_prices.clear() + assert ws.should_exit("005930") is False + + +@pytest.mark.asyncio +async def test_historical_names_open_prices_and_unsubscribe(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.stock_api = MagicMock() + ws.stock_api.inquire_daily_price.return_value = pd.DataFrame( + { + "stck_bsop_date": ["20250101", "bad"], + "stck_cntg_hour": ["090000", "090000"], + "stck_clpr": ["100", "200"], + } + ) + ws.load_historical_data() + assert len(ws.trade_history["005930"]) == 1 + ws.stock_api.get_stock_info.return_value = pd.DataFrame({"prdt_name": ["삼성전자"]}) + ws.fetch_stock_names() + assert ws.stock_names["005930"] == "삼성전자" + ws.stock_api.get_stock_price.return_value = {"output": {"stck_oprc": "70000"}} + ws.fetch_open_prices() + assert ws.open_prices["005930"] == 70000.0 + ws.ws = MagicMock(send=AsyncMock()) + ws.unsubscribe_all() + await asyncio.sleep(0) + assert ws.ws.send.called + ws.ws = None + ws.unsubscribe_all() + + +@pytest.mark.asyncio +async def test_update_holdings_loop_reconciles_subscriptions_once(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.auth = MagicMock() + ws.stock_codes = ["005930"] + ws.purchase_prices = {"005930": (70000.0, 1)} + ws.ws = type("Socket", (), {"send": AsyncMock()})() + ws.load_historical_data_for_stock = MagicMock() + ws.fetch_stock_names = MagicMock() + + class AccountAPI: + def __init__(self, **_kwargs): + pass + + def get_account_balance(self): + return {"output1": [{"pdno": "000660", "pchs_avg_pric": "100", "hldg_qty": "2"}]} + + monkeypatch.setattr("kis_agent.account.api.AccountAPI", AccountAPI) + + async def stop_after_iteration(_seconds): + raise asyncio.CancelledError + + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", stop_after_iteration) + with pytest.raises(asyncio.CancelledError): + await ws.update_holdings_loop() + + assert ws.stock_codes == ["000660"] + assert ws.purchase_prices == {"000660": (100.0, 2)} + sent_ids = [json.loads(call.args[0])["body"]["input"]["tr_id"] for call in ws.ws.send.await_args_list] + assert sent_ids == ["H0STCNT0", "H0STCNT0_UNSUB"] + + +def test_update_price_and_balance_display_paths(tmp_path, monkeypatch, capsys): + ws = _ws(tmp_path) + ws.account_info = {"account": "123"} + ws.balance_info = pd.DataFrame( + [ + { + "pdno": "005930", + "prdt_name": "삼성", + "hldg_qty": "1", + "pchs_avg_pric": "70000", + "prpr": "71000", + "evlu_amt": "71000", + "evlu_pfls_amt": "1000", + "evlu_pfls_rt": "1.4", + }, + { + "pdno": "000660", + "prdt_name": "SK", + "hldg_qty": "0", + "pchs_avg_pric": "1", + "prpr": "1", + "evlu_amt": "0", + "evlu_pfls_amt": "0", + "evlu_pfls_rt": "0", + }, + ] + ) + ws.initial_cash_balance = 500 + ws.last_balance_check = datetime.now() + ws.compute_RSI_candles = MagicMock(return_value=None) + ws.compute_MACD_candles = MagicMock(return_value=None) + monkeypatch.setattr("kis_agent.websocket.client.os.system", lambda *_args: 0) + ws.update_price_and_indicators() + assert "총 자산" in capsys.readouterr().out + + ws.balance_info = None + ws.load_initial_balance = MagicMock(return_value=False) + ws.display_balance_info() + ws.load_initial_balance.assert_called_once() + + +@pytest.mark.asyncio +async def test_exit_and_monitor_loops_have_controlled_exit_paths(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.execute_exit_orders = MagicMock() + + async def cancel_sleep(_seconds): + raise asyncio.CancelledError + + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", cancel_sleep) + with pytest.raises(asyncio.CancelledError): + await ws.exit_watch_loop() + ws.execute_exit_orders.assert_called_once() + + ws.ws = type("Socket", (), {"close": AsyncMock()})() + monkeypatch.setitem(sys.modules, "msvcrt", None) + monkeypatch.setattr("kis_agent.websocket.client.select.select", lambda *_args: ([sys.stdin], [], [])) + monkeypatch.setattr(sys.stdin, "read", lambda _size: "") + with pytest.raises(SystemExit): + await ws.monitor_exit() + ws.ws.close.assert_awaited_once() + + monkeypatch.setitem(sys.modules, "msvcrt", None) + await ws.monitor_esc() + + windows_ws = _ws(tmp_path) + windows_ws.ws = type("Socket", (), {"close": AsyncMock()})() + fake_msvcrt = type( + "Msvcrt", + (), + {"kbhit": staticmethod(lambda: True), "getch": staticmethod(lambda: b"\x1b")}, + )() + monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt) + with pytest.raises(SystemExit): + await windows_ws.monitor_esc() + windows_ws.ws.close.assert_awaited_once() + + +def test_balance_loader_and_active_check(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.account_info = {"account": "123"} + + class AccountAPI: + def __init__(self, **_kwargs): + pass + + def get_account_balance(self): + return { + "output1": [{"pdno": "005930"}], + "output2": [{"dnca_tot_amt": "1234"}], + } + + monkeypatch.setattr("kis_agent.account.api.AccountAPI", AccountAPI) + assert ws.load_initial_balance() + assert ws.initial_cash_balance == 1234 + ws.last_ws_recv_time = datetime.now() + assert ws.is_ws_active() + ws.last_ws_recv_time = datetime.now() - timedelta(seconds=61) + assert not ws.is_ws_active() + + +def test_single_stock_history_and_open_price_error_paths(tmp_path): + ws = _ws(tmp_path) + ws.stock_api = MagicMock() + ws.stock_api.inquire_daily_price.return_value = pd.DataFrame( + { + "stck_bsop_date": ["20250101"], + "stck_cntg_hour": ["090000"], + "stck_clpr": ["100"], + } + ) + ws.load_historical_data_for_stock("005930") + assert ws.trade_history["005930"][0][1] == 100.0 + ws.stock_api.get_stock_price.side_effect = RuntimeError("offline") + ws.fetch_open_prices() + + +def test_websocket_display_helpers_and_trade_log_failure(tmp_path, monkeypatch, capsys): + ws = _ws(tmp_path) + assert ws.get_index_name("0001") == "KOSPI" + assert ws.get_index_name("unknown") == "INDEX_unknown" + ws.print_program_trade_summary(1, "^".join(["1"] * 11)) + ws.print_domestic_hoga("005930^" + "^".join(["1"] * 55)) + assert "프로그램매매" in capsys.readouterr().out + ws.trade_log_file = str(tmp_path) + ws.update_trade_history("005930", datetime.now(), 1) + + +def test_realtime_display_helpers(tmp_path, capsys): + ws = _ws(tmp_path) + ws.display_ask_bid_info("005930", "^".join(["1"] * 50)) + ws.display_index_info("KOSPI", ["0001", "2500", "1", "0.1"] + ["0"] * 6) + ws.display_program_trading_info("005930", "^".join(["1"] * 11)) + output = capsys.readouterr().out + assert "호가" in output and "KOSPI" in output and "프로그램매매" in output + + +def test_indicator_edge_cases_and_trade_summary_display(tmp_path, capsys): + ws = _ws(tmp_path) + start = datetime.now().replace(second=0, microsecond=0) - timedelta(minutes=30) + for index in range(30): + ws.trade_history["005930"].append((start + timedelta(minutes=index), 130 - index, None)) + + assert ws.compute_RSI("005930") == 0 + assert ws.compute_MACD_candles("005930", span_long=40) is None + assert ws.compute_MACD_oscillator_candles("005930", span_long=26, signal_span=9) is None + assert ws.compute_trade_strength_candle("005930") == [] + + +def test_indicator_loss_intervals_and_display_error_paths(tmp_path): + ws = _ws(tmp_path) + start = datetime.now().replace(second=0, microsecond=0) - timedelta(minutes=30) + ws.trade_history["005930"] = [(start + timedelta(minutes=index), 130 - index, 100 - index) for index in range(30)] + assert ws.compute_RSI_candles("005930") == 0 + assert ws.compute_trade_strength_candle("005930", interval_minutes=5) + + with pytest.raises(AttributeError): + ws.display_ask_bid_info("005930", None) + with pytest.raises(TypeError): + ws.display_index_info("KOSPI", None) + ws.display_program_trading_info("005930", None) + + +def test_notice_crypto_wrapper_delegates_to_decryptor(tmp_path, capsys): + ws = _ws(tmp_path) + ws.aes_cbc_base64_dec = MagicMock(return_value="decoded") + assert ws.stocksigningnotice("cipher", "key", "iv") is None + ws.aes_cbc_base64_dec.assert_called_once_with("key", "iv", "cipher") + + ws.latest_trade["005930"] = "005930^093000^not-a-number" + assert "파싱 실패" in ws.format_trade_string(ws.latest_trade["005930"]) + ws.compute_RSI_candles = MagicMock(return_value=None) + ws.compute_MACD_candles = MagicMock(return_value=None) + ws.compute_ATR = MagicMock(return_value=None) + ws.display_trade_summary() + assert "N/A" in capsys.readouterr().out + + +def test_history_name_and_open_price_error_branches(tmp_path): + ws = _ws(tmp_path) + ws.stock_api = MagicMock() + ws.stock_api.inquire_daily_price.side_effect = RuntimeError("offline") + ws.load_historical_data() + ws.load_historical_data_for_stock("005930") + ws.stock_names.clear() + ws.stock_api.get_stock_info.side_effect = RuntimeError("offline") + ws.fetch_stock_names() + assert ws.stock_names == {} + + +def test_balance_display_uses_websocket_and_fallback_prices(tmp_path, monkeypatch, capsys): + ws = _ws(tmp_path) + ws.balance_info = pd.DataFrame( + [ + { + "pdno": "005930", + "prdt_name": "삼성전자", + "hldg_qty": "2", + "pchs_avg_pric": "70000", + "prpr": "71000", + "evlu_amt": "142000", + "evlu_pfls_amt": "2000", + "evlu_pfls_rt": "1.43", + }, + { + "pdno": "000660", + "prdt_name": "SK하이닉스", + "hldg_qty": "1", + "pchs_avg_pric": "100000", + "prpr": "101000", + "evlu_amt": "101000", + "evlu_pfls_amt": "1000", + "evlu_pfls_rt": "1.0", + }, + { + "pdno": "000000", + "prdt_name": "제외", + "hldg_qty": "0", + "pchs_avg_pric": "1", + "prpr": "1", + "evlu_amt": "0", + "evlu_pfls_amt": "0", + "evlu_pfls_rt": "0", + }, + ] + ) + ws.last_balance_check = datetime.now() + ws.initial_cash_balance = 50_000 + ws.latest_trade["005930"] = "005930^093000^72000" + ws.compute_RSI_candles = MagicMock(return_value=55.0) + ws.compute_MACD_candles = MagicMock(return_value=None) + monkeypatch.setattr("kis_agent.websocket.client.os.system", lambda *_: 0) + + ws.update_price_and_indicators() + + output = capsys.readouterr().out + assert "삼성전자" in output and "SK하이닉스" in output + assert "총 자산: 295,000원" in output + + +def test_execute_exit_orders_submits_market_order_and_skips_zero_quantity(tmp_path): + ws = _ws(tmp_path) + ws.account_info = {"account": "123"} + ws.purchase_prices = {"005930": (70000, 2)} + ws.should_exit = MagicMock(return_value=True) + with patch("kis_agent.account.api.AccountAPI") as account_api: + account_api.return_value.order_stock_cash.return_value = {"msg1": "ok"} + ws.execute_exit_orders() + + account_api.return_value.order_stock_cash.assert_called_once_with(ticker="005930", price="0", quantity="2", order_type="01") + ws.purchase_prices["005930"] = (70000, 0) + with patch("kis_agent.account.api.AccountAPI") as account_api: + ws.execute_exit_orders() + account_api.return_value.order_stock_cash.assert_not_called() + + +def test_remaining_small_state_and_indicator_paths(tmp_path, monkeypatch, capsys): + ws = _ws(tmp_path) + with patch("kis_agent.websocket.client.os.system") as system: + ws.clear_console() + system.assert_called_once() + + class BadPrice: + def replace(self, *_args): + raise ValueError("bad") + + assert ws.format_price(BadPrice()) is not None + for index in range(1002): + ws.update_trade_history("000660", datetime.now(), index) + assert len(ws.trade_history["000660"]) == 1000 + assert ws.compute_MACD_oscillator_candles("missing") is None + assert ws.compute_trade_strength_candle("missing") == [] + + ws.display_live_trade("005930", "formatted") + assert "formatted" in capsys.readouterr().out + ws.balance_info = pd.DataFrame() + ws.update_price_and_indicators = MagicMock() + ws.display_balance_info() + ws.update_price_and_indicators.assert_called_once() + + +def test_trade_summary_bad_purchase_and_should_exit_tail_paths(tmp_path): + ws = _ws(tmp_path) + ws.latest_trade["005930"] = "005930^093000^110" + ws.purchase_prices["005930"] = (100,) + ws.compute_RSI_candles = MagicMock(return_value=None) + ws.compute_MACD_candles = MagicMock(return_value=None) + ws.compute_ATR = MagicMock(return_value=None) + ws.compute_candles = MagicMock(return_value=[]) + assert ws.trade_summary()["005930"][5] is None + + ws.purchase_prices["005930"] = (100, 1) + ws.prev_indicators["005930"] = (50, None) + ws.compute_RSI_candles.return_value = 60 + ws.compute_trade_strength_candle = MagicMock(return_value=[]) + assert not ws.should_exit("005930") + assert ws.prev_indicators["005930"] == (60, None) + ws.purchase_prices["005930"] = (0, 1) + assert not ws.should_exit("005930") + + +def test_malformed_trade_name_fallback_and_null_history_row(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.update_price_and_indicators = MagicMock() + fields = ["005930", "bad-time", "not-price"] + ["0"] * 16 + ws.handle_message("0|H0STCNT0|001|" + "^".join(fields)) + ws.stock_names.clear() + ws.stock_api = MagicMock() + ws.stock_api.get_stock_info.return_value = pd.DataFrame({"prdt_name": ["삼성전자"]}) + ws.fetch_stock_names() + assert ws.stock_names["005930"] == "삼성전자" + + ws.stock_api.inquire_daily_price.return_value = pd.DataFrame( + { + "stck_bsop_date": ["bad"], + "stck_cntg_hour": ["bad"], + "stck_clpr": ["100"], + } + ) + ws.load_historical_data_for_stock("005930") + + +def test_aes_decrypt_exit_order_exception_and_empty_balance(tmp_path, monkeypatch): + ws = _ws(tmp_path) + cipher = MagicMock() + cipher.decrypt.return_value = b"padded" + with patch("kis_agent.websocket.client.AES.new", return_value=cipher), patch("kis_agent.websocket.client.b64decode", return_value=b"cipher"), patch( + "kis_agent.websocket.client.unpad", return_value=b"plain" + ): + assert ws.aes_cbc_base64_dec("key", "iv", "cipher") == "plain" + + ws.account_info = {} + ws.should_exit = MagicMock(return_value=True) + ws.purchase_prices["005930"] = (100, 1) + with patch("kis_agent.account.api.AccountAPI") as account_api: + account_api.return_value.order_stock_cash.side_effect = RuntimeError("offline") + ws.execute_exit_orders() + + class AccountAPI: + def __init__(self, **_kwargs): + pass + + def get_account_balance(self): + return {"output1": []} + + monkeypatch.setattr("kis_agent.account.api.AccountAPI", AccountAPI) + assert not ws.load_initial_balance() + ws.balance_info = None + ws.update_price_and_indicators() + + +@pytest.mark.asyncio +async def test_holding_poll_and_final_price_error_paths(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.account_info = {} + ws.auth = MagicMock() + + class BrokenAccountAPI: + def __init__(self, **_kwargs): + pass + + def get_account_balance(self): + raise RuntimeError("offline") + + monkeypatch.setattr("kis_agent.account.api.AccountAPI", BrokenAccountAPI) + + async def cancel_sleep(_seconds): + raise asyncio.CancelledError + + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", cancel_sleep) + with pytest.raises(asyncio.CancelledError): + await ws.update_holdings_loop() + + ws.stock_api = MagicMock() + ws.stock_api.get_stock_price.side_effect = RuntimeError("offline") + + class AfterCloseDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 1, 6, 15, 21, tzinfo=tz) + + import datetime as datetime_module + + monkeypatch.setattr(datetime_module, "datetime", AfterCloseDateTime) + with pytest.raises(asyncio.CancelledError): + await ws.poll_final_price() + + +@pytest.mark.asyncio +async def test_poll_final_price_market_session_and_monitor_sleep_paths(tmp_path, monkeypatch): + ws = _ws(tmp_path) + + class MarketDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 1, 6, 10, 0, tzinfo=tz) + + import datetime as datetime_module + + monkeypatch.setattr(datetime_module, "datetime", MarketDateTime) + + async def cancel_sleep(_seconds): + raise asyncio.CancelledError + + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", cancel_sleep) + with pytest.raises(asyncio.CancelledError): + await ws.poll_final_price() + + fake_msvcrt = type( + "Msvcrt", + (), + {"kbhit": staticmethod(lambda: False), "getch": staticmethod(lambda: b"")}, + )() + monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt) + with pytest.raises(asyncio.CancelledError): + await ws.monitor_esc() + + monkeypatch.setitem(sys.modules, "msvcrt", None) + monkeypatch.setattr("kis_agent.websocket.client.select.select", lambda *_args: ([], [], [])) + with pytest.raises(asyncio.CancelledError): + await ws.monitor_exit() + + +@pytest.mark.asyncio +async def test_monitor_exit_windows_escape(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.ws = type("Socket", (), {"close": AsyncMock()})() + fake_msvcrt = type( + "Msvcrt", + (), + { + "kbhit": staticmethod(lambda: True), + "getch": staticmethod(lambda: b"\x1b"), + }, + )() + monkeypatch.setitem(sys.modules, "msvcrt", fake_msvcrt) + with pytest.raises(SystemExit): + await ws.monitor_exit() + ws.ws.close.assert_awaited_once() + + waiting_msvcrt = type( + "Msvcrt", + (), + { + "kbhit": staticmethod(lambda: False), + "getch": staticmethod(lambda: b""), + }, + )() + monkeypatch.setitem(sys.modules, "msvcrt", waiting_msvcrt) + + async def cancel_sleep(_seconds): + raise asyncio.CancelledError + + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", cancel_sleep) + with pytest.raises(asyncio.CancelledError): + await ws.monitor_exit() + + +@pytest.mark.asyncio +async def test_exit_watch_loop_isolates_execution_error(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.execute_exit_orders = MagicMock(side_effect=RuntimeError("offline")) + + async def cancel_sleep(_seconds): + raise asyncio.CancelledError + + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", cancel_sleep) + with pytest.raises(asyncio.CancelledError): + await ws.exit_watch_loop() + + +def test_balance_refresh_updates_holdings_cash_and_timestamp(tmp_path, monkeypatch): + ws = _ws(tmp_path) + ws.account_info = {} + empty_holding = { + "pdno": "005930", + "prdt_name": "삼성전자", + "hldg_qty": "0", + "pchs_avg_pric": "0", + "prpr": "0", + "evlu_amt": "0", + "evlu_pfls_amt": "0", + "evlu_pfls_rt": "0", + } + ws.balance_info = pd.DataFrame([empty_holding]) + ws.last_balance_check = datetime.now() - timedelta(minutes=2) + ws.initial_cash_balance = None + + class AccountAPI: + def __init__(self, **_kwargs): + pass + + def get_account_balance(self): + return { + "output1": [empty_holding], + "output2": [{"dnca_tot_amt": "1234"}], + } + + monkeypatch.setattr("kis_agent.account.api.AccountAPI", AccountAPI) + monkeypatch.setattr("kis_agent.websocket.client.os.system", lambda *_args: 0) + ws.compute_RSI_candles = MagicMock(return_value=None) + ws.compute_MACD_candles = MagicMock(return_value=None) + ws.update_price_and_indicators() + assert ws.initial_cash_balance == 1234 + + +def _prepare_connect_ws(ws, monkeypatch): + ws.url = "ws://example" + ws.ping_interval = ws.ping_timeout = 0.001 + ws.max_ping_retries = 2 + ws.stock_codes = [] + ws.enable_index = ws.enable_ask_bid = ws.enable_program_trading = False + ws.enable_expected_index = ws.enable_expected_stock = False + for method in ( + "get_approval", + "load_historical_data", + "fetch_stock_names", + "fetch_open_prices", + "load_initial_balance", + "display_balance_info", + ): + monkeypatch.setattr(ws, method, MagicMock()) + + +@pytest.mark.asyncio +async def test_connect_ignores_pingpong_and_subscription_success(tmp_path, monkeypatch): + ws = _ws(tmp_path) + _prepare_connect_ws(ws, monkeypatch) + stop_event = asyncio.Event() + messages = iter(["PINGPONG", "SUBSCRIBE SUCCESS"]) + + class MarketDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 1, 6, 10, 0, tzinfo=tz) + + async def recv(_self): + message = next(messages) + if message == "SUBSCRIBE SUCCESS": + stop_event.set() + return message + + socket = type("Socket", (), {"send": AsyncMock(), "recv": recv})() + + class Connection: + async def __aenter__(self): + return socket + + async def __aexit__(self, *_args): + return False + + monkeypatch.setattr( + "kis_agent.websocket.client.websockets.connect", + lambda *_args, **_kwargs: Connection(), + ) + import datetime as datetime_module + + monkeypatch.setattr(datetime_module, "datetime", MarketDateTime) + await ws.connect(stop_event=stop_event) + assert stop_event.is_set() + + +@pytest.mark.asyncio +async def test_connect_waits_after_market_close_until_morning(tmp_path, monkeypatch): + ws = _ws(tmp_path) + _prepare_connect_ws(ws, monkeypatch) + stop_event = asyncio.Event() + + class SessionDateTime(datetime): + calls = 0 + + @classmethod + def now(cls, tz=None): + cls.calls += 1 + hour = 16 if cls.calls == 1 else 8 + return cls(2025, 1, 6, hour, 0, tzinfo=tz) + + async def recv(_self): + stop_event.set() + return "PINGPONG" + + socket = type("Socket", (), {"send": AsyncMock(), "recv": recv})() + + class Connection: + async def __aenter__(self): + return socket + + async def __aexit__(self, *_args): + return False + + import datetime as datetime_module + + monkeypatch.setattr(datetime_module, "datetime", SessionDateTime) + monkeypatch.setattr( + "kis_agent.websocket.client.websockets.connect", + lambda *_args, **_kwargs: Connection(), + ) + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", AsyncMock()) + await ws.connect(stop_event=stop_event) + assert SessionDateTime.calls >= 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("ping_error", [asyncio.TimeoutError(), RuntimeError("ping")]) +async def test_connect_retries_ping_failure_below_limit(tmp_path, monkeypatch, ping_error): + ws = _ws(tmp_path) + _prepare_connect_ws(ws, monkeypatch) + stop_event = asyncio.Event() + + class MarketDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 1, 6, 10, 0, tzinfo=tz) + + async def recv(_self): + raise asyncio.TimeoutError + + async def ping(_self): + raise ping_error + + socket = type("Socket", (), {"send": AsyncMock(), "recv": recv, "ping": ping})() + + class Connection: + async def __aenter__(self): + return socket + + async def __aexit__(self, *_args): + return False + + async def stop_on_retry(seconds): + if seconds == 1: + stop_event.set() + + import datetime as datetime_module + + monkeypatch.setattr(datetime_module, "datetime", MarketDateTime) + monkeypatch.setattr( + "kis_agent.websocket.client.websockets.connect", + lambda *_args, **_kwargs: Connection(), + ) + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", stop_on_retry) + await ws.connect(stop_event=stop_event) + assert stop_event.is_set() + + +@pytest.mark.asyncio +async def test_connect_reconnects_after_ping_timeout_limit(tmp_path, monkeypatch): + ws = _ws(tmp_path) + _prepare_connect_ws(ws, monkeypatch) + ws.max_ping_retries = 1 + stop_event = asyncio.Event() + + class MarketDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 1, 6, 10, 0, tzinfo=tz) + + async def recv(_self): + raise asyncio.TimeoutError + + async def ping(_self): + return asyncio.get_running_loop().create_future() + + socket = type("Socket", (), {"send": AsyncMock(), "recv": recv, "ping": ping})() + + class Connection: + async def __aenter__(self): + return socket + + async def __aexit__(self, *_args): + return False + + async def stop_reconnect(seconds): + if seconds == 5: + stop_event.set() + + import datetime as datetime_module + + monkeypatch.setattr(datetime_module, "datetime", MarketDateTime) + monkeypatch.setattr( + "kis_agent.websocket.client.websockets.connect", + lambda *_args, **_kwargs: Connection(), + ) + monkeypatch.setattr("kis_agent.websocket.client.asyncio.sleep", stop_reconnect) + await ws.connect(stop_event=stop_event) + assert stop_event.is_set() diff --git a/tests/unit/test_websocket_factory.py b/tests/unit/test_websocket_factory.py new file mode 100644 index 0000000..5fa6154 --- /dev/null +++ b/tests/unit/test_websocket_factory.py @@ -0,0 +1,68 @@ +"""Deprecated WebSocket factory/builder의 구성 계약 테스트.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from kis_agent.websocket.factory import ClientType, WebSocketClientBuilder, WebSocketClientFactory + + +@pytest.fixture +def factory_mocks(): + with patch("kis_agent.websocket.factory.ConnectionManager") as connection, patch( + "kis_agent.websocket.factory.RefactoredWebSocketClient" + ) as client: + client.return_value = MagicMock() + yield connection, client + + +def test_factory_builds_every_client_type(factory_mocks): + connection, client = factory_mocks + basic = WebSocketClientFactory.create_client(ClientType.BASIC, "key", url="ws://basic") + connection.assert_called_with(url="ws://basic", auto_reconnect=False) + assert basic is client.return_value + + realtime = WebSocketClientFactory.create_client( + ClientType.REALTIME, "key", stock_codes=["005930"], enable_orderbook=True, enable_program_trading=True + ) + realtime.add_stock_subscription.assert_called_once_with("005930") + realtime.enable_orderbook_subscription.assert_called_once() + realtime.enable_program_trading_subscription.assert_called_once() + realtime.reset_mock() + + monitoring = WebSocketClientFactory.create_client(ClientType.MONITORING, "key", major_stocks=["000660"]) + monitoring.enable_index_subscription.assert_called_once() + monitoring.enable_program_trading_subscription.assert_called_once() + monitoring.add_stock_subscription.assert_called_once_with("000660") + + WebSocketClientFactory.create_client(ClientType.BACKTEST, "key") + assert client.call_args.kwargs["data_recording"] is True + with pytest.raises(ValueError, match="지원하지 않는"): + WebSocketClientFactory.create_client("bad", "key") + + +def test_builder_fluent_options_build_expected_client(factory_mocks): + connection, client = factory_mocks + built = ( + WebSocketClientBuilder("key") + .with_url("ws://custom") + .with_auto_reconnect(False) + .with_ping_settings(1, 2) + .add_stock("005930") + .add_stocks(["005930", "000660"]) + .with_index_subscription() + .with_orderbook_subscription() + .with_program_trading_subscription() + .with_logging(False) + .with_metrics(False) + .build() + ) + connection.assert_called_once_with(url="ws://custom", auto_reconnect=False, ping_interval=1, ping_timeout=2) + assert built is client.return_value + assert built.add_stock_subscription.call_args_list[0].args == ("005930",) + assert built.add_stock_subscription.call_args_list[1].args == ("000660",) + built.enable_index_subscription.assert_called_once() + built.enable_orderbook_subscription.assert_called_once() + built.enable_program_trading_subscription.assert_called_once() + assert client.call_args.kwargs["enable_logging"] is False + assert client.call_args.kwargs["enable_metrics"] is False diff --git a/tests/unit/test_ws_agent_extra_paths.py b/tests/unit/test_ws_agent_extra_paths.py new file mode 100644 index 0000000..b4ebf58 --- /dev/null +++ b/tests/unit/test_ws_agent_extra_paths.py @@ -0,0 +1,176 @@ +"""WSAgent의 메시지·구독 상태 전이 회귀 테스트.""" + +import asyncio +import json +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from websockets.exceptions import ConnectionClosed + +import kis_agent.websocket.ws_agent as module +from kis_agent.websocket.ws_agent import WSAgent +from kis_agent.websocket.ws_types import Subscription, SubscriptionType + + +@pytest.fixture +def agent(): + return WSAgent("approval", url="ws://example", auto_reconnect=False) + + +def test_market_close_session_windows(monkeypatch): + class SeoulDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 1, 6, 16, 30, tzinfo=tz) + + monkeypatch.setattr(module, "datetime", SeoulDateTime) + assert not module._is_after_market_close() + + class NightDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 1, 6, 21, 0, tzinfo=tz) + + monkeypatch.setattr(module, "datetime", NightDateTime) + assert module._is_after_market_close() + assert not module._is_after_market_close(has_night_session=True) + + class WeekendDateTime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2025, 1, 4, 10, 0, tzinfo=tz) + + monkeypatch.setattr(module, "datetime", WeekendDateTime) + assert module._is_after_market_close() + + +def test_constructor_and_approval_key_guards(agent): + with pytest.raises(ValueError, match="approval_key"): + WSAgent("") + agent.update_approval_key("") + agent.connected = True + agent.update_approval_key("new-approval") + assert agent.approval_key == "new-approval" + + +def test_subscription_response_and_parse_fallbacks(agent): + assert not agent._handle_subscription_response({"header": {}, "body": {}}) + assert not agent._handle_subscription_response({"header": {"tr_id": "H0STCNT0"}, "body": {"msg1": "ok"}}) + event = asyncio.Event() + agent._pending_subscriptions["H0STCNT0_005930"] = event + assert agent._handle_subscription_response({"header": {"tr_id": "H0STCNT0", "tr_key": "005930"}, "body": {"msg1": "SUBSCRIBE SUCCESS"}}) + assert event.is_set() and agent._subscription_results["H0STCNT0_005930"] + assert agent._handle_subscription_response({"header": {"tr_id": "H0STCNT0", "tr_key": "005930"}, "body": {"msg1": "UNSUBSCRIBE SUCCESS"}}) + assert agent._parse_message("") == (None, None, None) + assert agent._parse_message("0|too-short") == (None, None, None) + agent._pending_subscriptions.clear() + assert agent._handle_subscription_response({"header": {"tr_id": "H0STCNT0", "tr_key": "005930"}, "body": {"msg1": "UNSUBSCRIBE SUCCESS"}}) + assert not agent._handle_subscription_response({"header": {"tr_id": "H0STCNT0", "tr_key": "005930"}, "body": {"msg1": "unknown"}}) + + +def test_encrypted_parse_and_aes_decryption(agent): + agent.aes_keys["H0STCNI0"] = ("key", "iv") + agent._decrypt_aes = MagicMock(return_value="005930^filled") + assert agent._parse_message("1|H0STCNI0|1|cipher") == ( + "H0STCNI0", + "005930", + ["005930", "filled"], + ) + + cipher = MagicMock() + cipher.decrypt.return_value = b"padded" + with patch.object(module.AES, "new", return_value=cipher), patch.object( + module, "b64decode", return_value=b"cipher" + ), patch.object(module, "unpad", return_value=b"plain"): + assert WSAgent._decrypt_aes(agent, "key", "iv", "cipher") == "plain" + + +@pytest.mark.asyncio +async def test_subscribe_all_disconnect_failure_and_exception(agent): + subs = [Subscription(SubscriptionType.STOCK_TRADE, key) for key in ("1", "2")] + agent.subscriptions = {f"H0STCNT0_{sub.key}": sub for sub in subs} + assert await agent._subscribe_all() == {"success": [], "failed": ["H0STCNT0_1", "H0STCNT0_2"]} + + agent.ws = MagicMock(close_code=None) + agent._send_subscription = AsyncMock(side_effect=[False, RuntimeError("offline")]) + result = await agent._subscribe_all() + assert result["failed"] == ["H0STCNT0_1", "H0STCNT0_2"] + + many = [Subscription(SubscriptionType.STOCK_TRADE, str(index)) for index in range(10)] + agent.subscriptions = {f"H0STCNT0_{sub.key}": sub for sub in many} + agent._send_subscription = AsyncMock(return_value=True) + result = await agent._subscribe_all() + assert len(result["success"]) == 10 + + +@pytest.mark.asyncio +async def test_unsubscription_error_and_message_handlers(agent): + agent.ws = AsyncMock() + agent.ws.send.side_effect = RuntimeError("closed") + await agent._send_unsubscription(Subscription(SubscriptionType.STOCK_TRADE, "005930")) + + received = [] + agent.set_default_handler(lambda data, meta: received.append((data, meta))) + await agent._handle_message("not-a-protocol-message") + await agent._handle_message(json.dumps({"header": {"tr_id": "UNKNOWN", "tr_key": "x"}, "body": {}})) + assert received and received[-1][1]["tr_id"] == "UNKNOWN" + + await agent._handle_message(json.dumps({"header": {"tr_id": "H0STCNT0", "tr_key": "x"}, "body": {"msg1": "SUBSCRIBE SUCCESS"}})) + await agent._handle_message("{invalid") + + +@pytest.mark.asyncio +async def test_known_message_runs_type_and_default_handlers(agent): + type_handler = AsyncMock() + default_handler = AsyncMock() + agent.register_handler(SubscriptionType.STOCK_TRADE, type_handler) + agent.set_default_handler(default_handler) + await agent._handle_message("0|H0STCNT0|0|005930^value") + type_handler.assert_awaited_once() + default_handler.assert_awaited_once() + + agent._parse_message = MagicMock(side_effect=RuntimeError("parse")) + before = agent.stats["errors"] + await agent._handle_message("message") + assert agent.stats["errors"] == before + 1 + + +@pytest.mark.asyncio +async def test_handler_cancellation_propagates(agent): + async def cancelled(_data, _metadata): + raise asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await agent._call_handler(cancelled, {}, {}) + + +@pytest.mark.asyncio +async def test_subscription_retry_wait_and_connection_closed(agent, monkeypatch): + subscription = Subscription(SubscriptionType.STOCK_TRADE, "005930") + agent.ws = MagicMock(close_code=None) + agent.ws.send = AsyncMock(side_effect=RuntimeError("offline")) + sleep = AsyncMock() + monkeypatch.setattr(module.asyncio, "sleep", sleep) + assert not await agent._send_subscription(subscription, max_retries=2) + sleep.assert_awaited_once_with(0.5) + + agent.ws.send = AsyncMock(side_effect=ConnectionClosed(None, None)) + assert not await agent._send_subscription(subscription, max_retries=1) + + +@pytest.mark.asyncio +async def test_connection_guards_and_disconnect(agent, monkeypatch): + assert agent._ws_closed() + agent.ws = MagicMock(close_code=1000) + assert agent._ws_closed() + agent.ws = AsyncMock() + monkeypatch.setattr(module, "_is_after_market_close", lambda **kwargs: True) + agent.auto_reconnect = True + await agent.connect() + assert not agent.auto_reconnect + + agent.auto_reconnect = True + task = agent._track_task(asyncio.create_task(asyncio.sleep(10))) + await agent.disconnect() + assert agent.ws is None and task.cancelled() diff --git a/tests/unit/test_ws_agent_receive_extra.py b/tests/unit/test_ws_agent_receive_extra.py new file mode 100644 index 0000000..9fe6d70 --- /dev/null +++ b/tests/unit/test_ws_agent_receive_extra.py @@ -0,0 +1,452 @@ +"""WSAgent 수신 루프의 정상·ping·오류 종료 경로 테스트.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import kis_agent.websocket.ws_agent as module +from kis_agent.websocket.ws_agent import WSAgent + + +@pytest.mark.asyncio +async def test_receive_loop_handles_message_and_stops_disconnected(): + agent = WSAgent("approval", auto_reconnect=False) + agent.connected = True + websocket = type("Socket", (), {"recv": AsyncMock(return_value="message")})() + + async def handled(_data): + agent.connected = False + + agent._handle_message = handled + assert await agent._receive_loop(websocket) == "disconnected" + + +@pytest.mark.asyncio +async def test_receive_loop_ping_success_and_generic_error(): + agent = WSAgent("approval", auto_reconnect=False) + agent.connected = True + agent.ping_interval = 0.01 + agent.ping_timeout = 0.01 + + async def timeout_recv(_self): + raise TimeoutError + + async def ping(_self): + agent.connected = False + future = __import__("asyncio").get_running_loop().create_future() + future.set_result(None) + return future + + websocket = type("Socket", (), {"recv": timeout_recv, "ping": ping})() + assert await agent._receive_loop(websocket) == "disconnected" + + agent.connected = True + websocket.recv = AsyncMock(side_effect=RuntimeError("bad socket")) + assert await agent._receive_loop(websocket) == "error" + + +@pytest.mark.asyncio +async def test_connect_fatal_auth_error_cleans_state(monkeypatch): + agent = WSAgent("approval", url="ws://example", auto_reconnect=True, client=None) + + class FailingConnection: + async def __aenter__(self): + raise RuntimeError("403 forbidden") + + async def __aexit__(self, *_args): + return False + + monkeypatch.setattr(module, "_is_after_market_close", lambda **_kwargs: False) + monkeypatch.setattr(module.websockets, "connect", lambda *_args, **_kwargs: FailingConnection()) + await agent.connect() + assert not agent.auto_reconnect + assert not agent.connected and agent.ws is None + + +@pytest.mark.asyncio +async def test_connect_subscribes_then_stops_on_normal_disconnect(monkeypatch): + agent = WSAgent("approval", url="ws://example", auto_reconnect=True) + websocket = AsyncMock() + + class Connection: + async def __aenter__(self): + return websocket + + async def __aexit__(self, *_args): + return False + + async def receive_loop(_websocket): + return "disconnected" + + monkeypatch.setattr(module, "_is_after_market_close", lambda **_kwargs: False) + monkeypatch.setattr(module.websockets, "connect", lambda *_args, **_kwargs: Connection()) + monkeypatch.setattr(module.asyncio, "sleep", AsyncMock()) + agent._receive_loop = receive_loop + agent._subscribe_all = AsyncMock(return_value={}) + + await agent.connect() + + agent._subscribe_all.assert_awaited_once() + assert not agent.connected and agent.ws is None + assert agent.auto_reconnect + + +@pytest.mark.asyncio +async def test_receive_loop_stops_after_repeated_ping_timeouts(monkeypatch): + agent = WSAgent("approval", auto_reconnect=False) + agent.connected = True + agent.ping_interval = 0.001 + agent.ping_timeout = 0.001 + + async def timeout_recv(_self): + raise TimeoutError + + async def ping(_self): + return __import__("asyncio").get_running_loop().create_future() + + websocket = type("Socket", (), {"recv": timeout_recv, "ping": ping})() + monkeypatch.setattr(module.asyncio, "sleep", AsyncMock()) + assert await agent._receive_loop(websocket) == "ping_failed" + + +@pytest.mark.asyncio +async def test_receive_loop_reports_cancelled_and_connection_closed(): + agent = WSAgent("approval", auto_reconnect=False) + agent.connected = True + websocket = type("Socket", (), {"recv": AsyncMock(side_effect=__import__("asyncio").CancelledError())})() + assert await agent._receive_loop(websocket) == "cancelled" + + from websockets.exceptions import ConnectionClosed + + agent.connected = True + websocket.recv = AsyncMock(side_effect=ConnectionClosed(None, None)) + assert await agent._receive_loop(websocket) == "connection_closed" + + +@pytest.mark.asyncio +async def test_receive_loop_stops_after_repeated_ping_errors(monkeypatch): + agent = WSAgent("approval", auto_reconnect=False) + agent.connected = True + agent.ping_interval = 0.001 + agent.ping_timeout = 0.001 + + async def timeout_recv(_self): + raise TimeoutError + + async def broken_ping(_self): + raise RuntimeError("ping") + + websocket = type("Socket", (), {"recv": timeout_recv, "ping": broken_ping})() + monkeypatch.setattr(module.asyncio, "sleep", AsyncMock()) + assert await agent._receive_loop(websocket) == "ping_failed" + + +@pytest.mark.asyncio +async def test_disconnect_cancels_background_tasks_and_exposes_state(): + agent = WSAgent("approval", auto_reconnect=True) + agent.connected = True + agent.active_subscriptions.add("H0STCNT0:005930") + agent.ws = type("Socket", (), {"close": AsyncMock()})() + task = __import__("asyncio").create_task(__import__("asyncio").sleep(60)) + agent._background_tasks.add(task) + + await agent.disconnect() + + agent.ws = None + assert task.cancelled() + assert not agent.is_connected() + assert agent.get_active_subscriptions() == ["H0STCNT0:005930"] + assert isinstance(agent.get_stats(), dict) + + +@pytest.mark.asyncio +async def test_subscription_send_rejects_closed_socket_and_unsubscribes(): + agent = WSAgent("approval", auto_reconnect=False) + sub_id = agent.subscribe(module.SubscriptionType.STOCK_TRADE, "005930") + subscription = agent.subscriptions[sub_id] + assert await agent._send_subscription(subscription, max_retries=1) is False + + agent.ws = type("Socket", (), {"send": AsyncMock()})() + agent.active_subscriptions.add(sub_id) + await agent._send_unsubscription(subscription) + payload = __import__("json").loads(agent.ws.send.await_args.args[0]) + assert payload["header"]["tr_type"] == "2" + assert sub_id not in agent.active_subscriptions + + +@pytest.mark.asyncio +async def test_subscribe_async_handles_disconnected_duplicate_and_failed_send(): + agent = WSAgent("approval", auto_reconnect=False) + sub_type = module.SubscriptionType.STOCK_TRADE + + sub_id, success = await agent.subscribe_async(sub_type, "005930") + assert success is False and sub_id in agent.subscriptions + + duplicate_id, duplicate_success = await agent.subscribe_async(sub_type, "005930") + assert (duplicate_id, duplicate_success) == (sub_id, True) + + agent.connected = True + agent.ws = type("Socket", (), {})() + agent._ws_closed = lambda: False + agent._send_subscription = AsyncMock(return_value=False) + failed_id, failed_success = await agent.subscribe_async(sub_type, "000660") + assert failed_success is False + assert failed_id not in agent.subscriptions + + +@pytest.mark.asyncio +async def test_connect_cancels_slow_subscriptions_after_receive_loop_exits(monkeypatch): + agent = WSAgent( + "approval", url="ws://example", auto_reconnect=True, max_reconnect_attempts=1 + ) + cancelled = False + + class Connection: + async def __aenter__(self): + return AsyncMock() + + async def __aexit__(self, *_args): + return False + + async def receive_loop(_websocket): + return "connection_closed" + + async def slow_subscribe_all(): + nonlocal cancelled + try: + await __import__("asyncio").sleep(60) + except __import__("asyncio").CancelledError: + cancelled = True + raise + + monkeypatch.setattr(module, "_is_after_market_close", lambda **_kwargs: False) + monkeypatch.setattr(module.websockets, "connect", lambda *_args, **_kwargs: Connection()) + agent._receive_loop = receive_loop + agent._subscribe_all = slow_subscribe_all + + await agent.connect() + + assert cancelled + assert not agent.auto_reconnect + assert agent.stats["reconnects"] == 0 + + +@pytest.mark.asyncio +async def test_connect_refreshes_approval_key_after_fatal_error(monkeypatch): + client = type("Client", (), {"get_ws_approval_key": lambda *_args, **_kwargs: "fresh-key"})() + agent = WSAgent( + "stale-key", url="ws://example", auto_reconnect=True, max_reconnect_attempts=1, client=client + ) + + class FailingConnection: + async def __aenter__(self): + raise RuntimeError("403 forbidden") + + async def __aexit__(self, *_args): + return False + + monkeypatch.setattr(module, "_is_after_market_close", lambda **_kwargs: False) + monkeypatch.setattr(module.websockets, "connect", lambda *_args, **_kwargs: FailingConnection()) + + await agent.connect() + + assert agent.approval_key == "fresh-key" + assert not agent.auto_reconnect + assert agent.stats["reconnects"] == 0 + + +@pytest.mark.asyncio +async def test_connect_waits_for_receive_loop_after_subscriptions_finish(monkeypatch): + agent = WSAgent("approval", url="ws://example", auto_reconnect=True) + ready = __import__("asyncio").Event() + + class Connection: + async def __aenter__(self): + return AsyncMock() + + async def __aexit__(self, *_args): + return False + + async def subscribe_all(): + ready.set() + return {} + + async def receive_loop(_websocket): + await ready.wait() + await __import__("asyncio").sleep(0.01) + return "cancelled" + + monkeypatch.setattr(module, "_is_after_market_close", lambda **_kwargs: False) + monkeypatch.setattr( + module.websockets, "connect", lambda *_args, **_kwargs: Connection() + ) + agent._subscribe_all = subscribe_all + agent._receive_loop = receive_loop + + await agent.connect() + + assert not agent.connected + + +@pytest.mark.asyncio +async def test_connect_fatal_refresh_exception_stops(monkeypatch): + client = MagicMock() + client.get_ws_approval_key.side_effect = RuntimeError("refresh") + agent = WSAgent( + "approval", url="ws://example", auto_reconnect=True, client=client + ) + + class FailingConnection: + async def __aenter__(self): + raise RuntimeError("403 forbidden") + + async def __aexit__(self, *_args): + return False + + monkeypatch.setattr(module, "_is_after_market_close", lambda **_kwargs: False) + monkeypatch.setattr( + module.websockets, "connect", lambda *_args, **_kwargs: FailingConnection() + ) + await agent.connect() + assert not agent.auto_reconnect + + +@pytest.mark.asyncio +async def test_connect_cancels_pending_receive_task_during_setup_error(monkeypatch): + agent = WSAgent( + "approval", + url="ws://example", + auto_reconnect=True, + max_reconnect_attempts=1, + ) + receive_cancelled = False + + class Connection: + async def __aenter__(self): + return AsyncMock() + + async def __aexit__(self, *_args): + return False + + async def receive_loop(_websocket): + nonlocal receive_cancelled + try: + await __import__("asyncio").sleep(60) + except __import__("asyncio").CancelledError: + receive_cancelled = True + raise + + original_sleep = module.asyncio.sleep + + async def setup_failure(seconds): + if seconds == 0.1: + raise RuntimeError("setup") + await original_sleep(seconds) + + monkeypatch.setattr(module, "_is_after_market_close", lambda **_kwargs: False) + monkeypatch.setattr( + module.websockets, "connect", lambda *_args, **_kwargs: Connection() + ) + monkeypatch.setattr(module.asyncio, "sleep", setup_failure) + agent._receive_loop = receive_loop + + await agent.connect() + + assert not agent.connected and agent.ws is None + + +@pytest.mark.asyncio +async def test_connect_stops_reconnect_at_market_close(monkeypatch): + agent = WSAgent("approval", url="ws://example", auto_reconnect=True) + + class FailingConnection: + async def __aenter__(self): + raise RuntimeError("offline") + + async def __aexit__(self, *_args): + return False + + market_checks = iter([False, True]) + monkeypatch.setattr( + module, "_is_after_market_close", lambda **_kwargs: next(market_checks) + ) + monkeypatch.setattr( + module.websockets, "connect", lambda *_args, **_kwargs: FailingConnection() + ) + + await agent.connect() + + assert not agent.auto_reconnect + + +@pytest.mark.asyncio +async def test_connect_applies_backoff_before_next_attempt(monkeypatch): + agent = WSAgent( + "approval", + url="ws://example", + auto_reconnect=True, + max_reconnect_attempts=2, + ) + + class FailingConnection: + async def __aenter__(self): + raise RuntimeError("offline") + + async def __aexit__(self, *_args): + return False + + sleep_calls = [] + + async def stop_after_backoff(seconds): + sleep_calls.append(seconds) + agent.auto_reconnect = False + + monkeypatch.setattr(module, "_is_after_market_close", lambda **_kwargs: False) + monkeypatch.setattr( + module.websockets, "connect", lambda *_args, **_kwargs: FailingConnection() + ) + monkeypatch.setattr(module.asyncio, "sleep", stop_after_backoff) + + await agent.connect() + + assert agent.stats["reconnects"] == 1 + assert sleep_calls == [5] + + +@pytest.mark.asyncio +async def test_sync_subscription_and_unsubscribe_manage_background_tasks(): + agent = WSAgent("approval", auto_reconnect=False) + agent.connected = True + agent.ws = type("Socket", (), {"close_code": None})() + agent._send_subscription = AsyncMock(return_value=True) + agent._send_unsubscription = AsyncMock() + + sub_id = agent.subscribe(module.SubscriptionType.STOCK_TRADE, "005930") + await __import__("asyncio").sleep(0) + agent._send_subscription.assert_awaited_once() + + agent.unsubscribe(sub_id) + await __import__("asyncio").sleep(0) + agent._send_unsubscription.assert_awaited_once() + agent.unsubscribe("missing") + + cancelled = __import__("asyncio").create_task(__import__("asyncio").sleep(60)) + cancelled.cancel() + with pytest.raises(__import__("asyncio").CancelledError): + await cancelled + agent._on_subscription_task_done(cancelled, "cancelled") + + +@pytest.mark.asyncio +async def test_send_subscription_records_rejected_response(): + agent = WSAgent("approval", auto_reconnect=False) + subscription = module.Subscription(module.SubscriptionType.STOCK_TRADE, "005930") + + async def send(_self, _message): + sub_id = "H0STCNT0_005930" + agent._subscription_errors[sub_id] = "rejected" + agent._pending_subscriptions[sub_id].set() + + agent.ws = type("Socket", (), {"close_code": None, "send": send})() + assert not await agent._send_subscription(subscription, max_retries=1) + assert not agent._pending_subscriptions diff --git a/tests/unit/test_ws_helpers_extra.py b/tests/unit/test_ws_helpers_extra.py new file mode 100644 index 0000000..1b499c4 --- /dev/null +++ b/tests/unit/test_ws_helpers_extra.py @@ -0,0 +1,21 @@ +"""WebSocket 헬퍼의 미노출 파서·저장소 경로 테스트.""" + +from kis_agent.websocket.ws_helpers import RealtimeDataParser, RealtimeDataStore, WSAgentWithStore +from kis_agent.websocket.ws_types import SubscriptionType + + +def test_parser_empty_and_orderbook_wrapper(): + assert RealtimeDataParser._convert_value("", "stck_prpr") is None + assert RealtimeDataParser.parse_stock_orderbook(["005930", "090000", "0", "70000"])["askp1"] == 70000 + + +def test_store_empty_history_and_auto_store_handlers(): + store = RealtimeDataStore(max_history=1) + assert store.get_history("missing", SubscriptionType.STOCK_TRADE) == [] + agent = WSAgentWithStore("approval", keep_history=True, url="ws://example", auto_reconnect=False) + handler = agent._base_agent.type_handlers[SubscriptionType.STOCK_TRADE][0] + handler(["005930", "090000", "70000"], {}) + handler({"stck_prpr": 71000}, {"tr_key": "005930"}) + handler("raw", {"tr_key": "000660"}) + assert agent.store.get_trade("005930")["stck_prpr"] == 71000 + assert agent.store.get( "000660", SubscriptionType.STOCK_TRADE)["raw"] == "raw" diff --git a/tests/unit/test_ws_subscriptions_extra.py b/tests/unit/test_ws_subscriptions_extra.py new file mode 100644 index 0000000..ba2a2fe --- /dev/null +++ b/tests/unit/test_ws_subscriptions_extra.py @@ -0,0 +1,44 @@ +"""WSSubscriptionMixin의 남은 시장별 편의 구독 경로 테스트.""" + +from kis_agent.websocket.ws_subscriptions import WSSubscriptionMixin + + +class _Host(WSSubscriptionMixin): + def __init__(self): + self.subscriptions = {} + self.calls = [] + + def subscribe(self, sub_type, key, handler=None, **metadata): + sub_id = f"{sub_type.value}_{key}" + self.subscriptions[sub_id] = object() + self.calls.append(sub_id) + return sub_id + + def unsubscribe(self, sub_id): + self.calls.append(f"unsub:{sub_id}") + self.subscriptions.pop(sub_id, None) + + +def test_all_market_convenience_subscriptions_and_unsubscribe(): + host = _Host() + assert len(host.subscribe_stock("005930", with_orderbook=True, with_expected=True, with_program=True, with_member=True)) == 5 + assert len(host.subscribe_stocks(["000660", "035420"], with_orderbook=True)) == 4 + assert len(host.subscribe_stock_nxt("005930", with_orderbook=True, with_expected=True, with_program=True, with_member=True)) == 5 + assert len(host.subscribe_stocks_nxt(["000660", "035420"], with_orderbook=True)) == 4 + host.subscribe_market_operation_nxt() + assert len(host.subscribe_program_trading_nxt(["005930", "000660"])) == 2 + assert len(host.subscribe_member_trading_nxt(["005930", "000660"])) == 2 + assert len(host.subscribe_index(with_expected=True)) == 6 + assert len(host.subscribe_program_trading(["005930"])) == 1 + assert len(host.subscribe_member_trading(["005930"])) == 1 + assert len(host.subscribe_futures("101S03", with_orderbook=True)) == 2 + assert len(host.subscribe_options("201S340", with_orderbook=True)) == 2 + assert len(host.subscribe_stock_futures("111V06", with_orderbook=True, with_expected=True)) == 3 + assert len(host.subscribe_stock_options("211V05059", with_orderbook=True, with_expected=True)) == 3 + assert len(host.subscribe_overtime("005930", with_expected=True)) == 3 + assert len(host.subscribe_overseas_stock("AAPL", with_orderbook=True)) == 2 + assert len(host.subscribe_overseas_futures("ESM25", with_orderbook=True)) == 2 + host.unsubscribe_stock_nxt("005930") + host.unsubscribe_stock("005930") + host.unsubscribe_all() + assert not host.subscriptions