"""collect_krx_data: 한쪽 시장 누락일 재수집 / 벤치마크 허용 회귀 테스트 (네트워크·DB 없음).""" import asyncio from datetime import date import pandas as pd import pytest import collect_krx_data as ck def _df(code="005930", name="삼성전자"): return pd.DataFrame([{ "stock_code": code, "stock_name": name, "date": date(2026, 9, 14), "open": 1, "high": 1, "low": 1, "close": 1, "volume": 1, "change_rate": 0.0, "market_cap": None, }]) def _run(coro): return asyncio.new_event_loop().run_until_complete(coro) @pytest.fixture def no_sleep(monkeypatch): monkeypatch.setattr(ck, "REQUEST_INTERVAL", 0) def _patch_db(monkeypatch, saved): class _S: async def __aenter__(self): return object() async def __aexit__(self, *a): return False async def _up(db, df): saved.append(len(df)) return len(df) monkeypatch.setattr(ck, "AsyncSessionLocal", lambda: _S()) monkeypatch.setattr(ck, "upsert_daily_prices", _up) def _patch_fetch(monkeypatch, results): async def _f(market, bas_dd): return results[market] monkeypatch.setattr(ck, "fetch_daily_prices_v2", _f) def test_kospi_empty_kosdaq_present_is_failure_but_saved(monkeypatch, no_sleep): saved = [] _patch_db(monkeypatch, saved) _patch_fetch(monkeypatch, {"kospi": (pd.DataFrame(), "empty"), "kosdaq": (_df("247540", "x"), "ok")}) ok, n = _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) assert ok is False and n == 1 and saved == [1] def test_api_error_on_one_market_is_failure(monkeypatch, no_sleep): _patch_db(monkeypatch, []) _patch_fetch(monkeypatch, {"kospi": (pd.DataFrame(), "error"), "kosdaq": (_df("247540", "x"), "ok")}) ok, _ = _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) assert ok is False def test_both_error_is_failure_both_empty_is_holiday(monkeypatch, no_sleep): _patch_db(monkeypatch, []) _patch_fetch(monkeypatch, {"kospi": (pd.DataFrame(), "error"), "kosdaq": (pd.DataFrame(), "error")}) assert _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) == (False, 0) _patch_fetch(monkeypatch, {"kospi": (pd.DataFrame(), "empty"), "kosdaq": (pd.DataFrame(), "empty")}) assert _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) == (True, 0) def test_both_ok_is_success(monkeypatch, no_sleep): _patch_db(monkeypatch, []) _patch_fetch(monkeypatch, {"kospi": (_df(), "ok"), "kosdaq": (_df("247540", "x"), "ok")}) ok, n = _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) assert ok is True and n == 2 def test_single_market_mode_not_partial(monkeypatch, no_sleep): _patch_db(monkeypatch, []) _patch_fetch(monkeypatch, {"kospi": (_df(), "ok")}) assert _run(ck.collect_date(1, 1, "20260914", "kospi", asyncio.Semaphore(1)))[0] is True def test_find_incomplete_dates_detects_partial_and_missing(): counts = {date(2026, 9, 10): 2560, date(2026, 9, 11): 2560, date(2026, 9, 14): 1672, date(2026, 9, 15): 2560} got = ck.find_incomplete_dates(counts, date(2026, 9, 10), date(2026, 9, 16)) assert got == [date(2026, 9, 14), date(2026, 9, 16)] got = ck.find_incomplete_dates(counts, date(2026, 9, 10), date(2026, 9, 16), is_holiday=lambda d: d == date(2026, 9, 16)) assert got == [date(2026, 9, 14)] assert ck.find_incomplete_dates({}, date(2026, 9, 10), date(2026, 9, 16)) == [] class _Resp: def __init__(self, items): self._i = items def raise_for_status(self): pass def json(self): return {"response": {"body": {"totalCount": len(self._i), "items": {"item": self._i}}}} class _Client: def __init__(self, items): self._i = items async def __aenter__(self): return self async def __aexit__(self, *a): return False async def get(self, url, params=None): return _Resp(self._i) def _item(code, name): return {"itmsNm": name, "srtnCd": code, "trqu": "10", "clpr": "100", "fltRt": "1", "mkp": "1", "hipr": "1", "lopr": "1", "mrktTotAmt": "0"} def test_benchmark_etf_whitelist_off_by_default_and_opt_in(monkeypatch): monkeypatch.setattr(ck.settings, "DATAGOKR_API_KEY", "k", raising=False) items = [_item("069500", "KODEX 200"), _item("005930", "삼성전자")] monkeypatch.setattr(ck.httpx, "AsyncClient", lambda **kw: _Client(items)) monkeypatch.setattr(ck, "_include_benchmarks", False) df, st = _run(ck.fetch_daily_prices_v2("kospi", "20260914")) assert st == "ok" and list(df.stock_code) == ["005930"] monkeypatch.setattr(ck, "_include_benchmarks", True) df, _ = _run(ck.fetch_daily_prices_v2("kospi", "20260914")) assert set(df.stock_code) == {"069500", "005930"} def test_http_error_sets_error_status(monkeypatch): monkeypatch.setattr(ck.settings, "DATAGOKR_API_KEY", "k", raising=False) class _Bad(_Client): async def get(self, url, params=None): raise RuntimeError("boom") monkeypatch.setattr(ck.httpx, "AsyncClient", lambda **kw: _Bad([])) df, st = _run(ck.fetch_daily_prices_v2("kospi", "20260914")) assert df.empty and st == "error" # ─── 라운드2: 수정주가 이벤트 / 백필 / 벤치마크 ETF ───────────────── def test_is_price_event_split_vs_normal(): # 5:1 분할: 788000 -> 179200 인데 fltRt 는 +13.7% assert ck.is_price_event(179200, 788000, 0.1371) is True assert ck.is_price_event(10300, 10000, 0.03) is False assert ck.is_price_event(100, 0, 0.0) is False assert ck.is_price_event(100, 100, None) is False def test_detect_price_events_skips_gap_over_4_days(): df = pd.DataFrame([ {"stock_code": "010120", "date": date(2026, 4, 13), "close": 179200, "change_rate": 0.1371}, {"stock_code": "000670", "date": date(2026, 9, 15), "close": 50500, "change_rate": 0.011}, ]) prev = {"010120": (date(2026, 4, 10), 788000), "000670": (date(2026, 9, 1), 38450)} ev = ck.detect_price_events(df, prev) assert [e[0] for e in ev] == ["010120"] def test_collect_all_v2_dates_override_runs_only_given_dates(monkeypatch): seen = [] async def _ct(i, total, d, market, sem): seen.append(d) return True, 1 async def _noop(): return None monkeypatch.setattr(ck, "collect_date", _ct) monkeypatch.setattr(ck, "create_tables", _noop) _run(ck.collect_all_v2(dates_override=["20260720", "20260625", "20260914"])) assert seen == ["20260625", "20260720", "20260914"] def test_benchmark_flag_warns_when_etf_missing(monkeypatch, no_sleep, caplog): import logging saved = [] _patch_db(monkeypatch, saved) _patch_fetch(monkeypatch, {"kospi": (_df(), "ok"), "kosdaq": (_df("247540", "x"), "ok")}) async def _etf(bas_dd): return pd.DataFrame() monkeypatch.setattr(ck, "fetch_benchmark_etf_prices", _etf) monkeypatch.setattr(ck, "_include_benchmarks", True) with caplog.at_level(logging.WARNING): ok, n = _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) assert ok is True assert any("벤치마크 ETF" in r.getMessage() for r in caplog.records) def test_benchmark_etf_rows_merged_when_present(monkeypatch, no_sleep): saved = [] _patch_db(monkeypatch, saved) _patch_fetch(monkeypatch, {"kospi": (_df(), "ok"), "kosdaq": (_df("247540", "x"), "ok")}) async def _etf(bas_dd): return _df("069500", "KODEX 200") monkeypatch.setattr(ck, "fetch_benchmark_etf_prices", _etf) monkeypatch.setattr(ck, "_include_benchmarks", True) ok, n = _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) assert ok is True and saved == [3] # ─── 하드닝 라운드1 ──────────────────────────────────────────────── def test_normalize_ohlc_fills_zero_with_close_and_keeps_consistency(): assert ck.normalize_ohlc(0, 0, 0, 600) == (600, 600, 600, 600) o, h, l, c = ck.normalize_ohlc(100, 90, 95, 120) # high= max(o, c) and l <= min(o, c) and l > 0 assert ck.normalize_ohlc(100, 110, 90, 105) == (100, 110, 90, 105) def test_fetch_zero_ohlc_row_stored_with_close(monkeypatch): monkeypatch.setattr(ck.settings, "DATAGOKR_API_KEY", "k", raising=False) it = _item("089530", "에이티세미콘") it.update({"mkp": "0", "hipr": "0", "lopr": "0", "clpr": "600"}) monkeypatch.setattr(ck.httpx, "AsyncClient", lambda **kw: _Client([it])) df, st = _run(ck.fetch_daily_prices_v2("kosdaq", "20230424")) r = df.iloc[0] assert st == "ok" and (r.open, r.high, r.low, r.close) == (600, 600, 600, 600) def test_incomplete_v2_local_baseline_handles_growth_and_whole_day_missing(): # 종목수 증가 추세: 전역 중앙값 방식이면 과거 정상일이 오탐된다 counts = {} d0 = date(2022, 10, 3) for i in range(0, 400): k = date.fromordinal(d0.toordinal() + i) if k.weekday() < 5: counts[k] = 1900 + i * 2 miss = date(2022, 11, 3) # 정상 거래일인데 0행 half = date(2022, 11, 2) # 한쪽 시장 누락 counts.pop(miss, None) counts[half] = 907 got = ck.find_incomplete_dates_v2(counts, date(2022, 10, 3), date(2023, 10, 1)) assert miss in got and half in got assert len(got) == 2 assert ck.find_incomplete_dates_v2({}, date(2022, 10, 3), date(2022, 10, 7)) == [] def test_default_lookback_covers_more_than_three_weeks(): assert ck.INCOMPLETE_LOOKBACK_DAYS > 21 # ─── 라운드 5: 거래정지 봉 보존 / 유니버스 ETF 수집 ─────────────────── class _Resp: def __init__(self, items): self._items = items def raise_for_status(self): pass def json(self): return {"response": {"body": {"totalCount": len(self._items), "items": {"item": self._items}}}} def _patch_http(monkeypatch, items): class _C: def __init__(self, *a, **k): pass async def __aenter__(self): return self async def __aexit__(self, *a): return False async def get(self, url, params=None): return _Resp(items) monkeypatch.setattr(ck.httpx, "AsyncClient", _C) monkeypatch.setattr(ck.settings, "DATAGOKR_API_KEY", "k", raising=False) monkeypatch.setattr(ck, "REQUEST_INTERVAL", 0) def test_halted_bar_kept_flat_with_zero_volume(monkeypatch): _patch_http(monkeypatch, [ {"itmsNm": "정지종목", "srtnCd": "900120", "trqu": "0", "clpr": "1000", "mkp": "0", "hipr": "0", "lopr": "0", "fltRt": "3.5"}, {"itmsNm": "정상", "srtnCd": "005930", "trqu": "10", "clpr": "2000", "mkp": "1900", "hipr": "2100", "lopr": "1800", "fltRt": "1.0"}, ]) df, st = _run(ck.fetch_daily_prices_v2("kospi", "20260914")) assert st == "ok" and len(df) == 2 h = df[df.stock_code == "900120"].iloc[0] assert h.volume == 0 and h.open == h.high == h.low == h.close == 1000 assert h.change_rate == 0.0 def test_zero_close_still_skipped(monkeypatch): _patch_http(monkeypatch, [ {"itmsNm": "x", "srtnCd": "000001", "trqu": "0", "clpr": "0"}, ]) df, _ = _run(ck.fetch_daily_prices_v2("kospi", "20260914")) assert df.empty def test_halted_aware_ohlc_pure(): assert ck.halted_aware_ohlc(0, 0, 0, 500, 0) == (500, 500, 500, 500) assert ck.halted_aware_ohlc(0, 0, 0, 500, 7) == ck.normalize_ohlc(0, 0, 0, 500) def test_etf_fetch_limited_to_requested_codes(monkeypatch): _patch_http(monkeypatch, [ {"itmsNm": "PLUS K방산", "srtnCd": "449450", "trqu": "100", "clpr": "30000", "mkp": "29900", "hipr": "30100", "lopr": "29800", "fltRt": "0.5"}, {"itmsNm": "다른ETF", "srtnCd": "111111", "trqu": "100", "clpr": "1000"}, ]) df = _run(ck.fetch_etf_prices_v2("20260914", {"449450"})) assert list(df.stock_code) == ["449450"] assert _run(ck.fetch_etf_prices_v2("20260914", set())).empty def test_extra_etf_codes_off_by_default_and_merged_when_set(monkeypatch, no_sleep): saved = [] _patch_db(monkeypatch, saved) _patch_fetch(monkeypatch, {"kospi": (_df(), "ok"), "kosdaq": (_df("247540", "x"), "ok")}) calls = [] async def _etf(bas_dd, codes): calls.append(set(codes)) return _df("449450", "PLUS K방산") monkeypatch.setattr(ck, "fetch_etf_prices_v2", _etf) monkeypatch.setattr(ck, "_extra_etf_codes", frozenset()) _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) assert calls == [] and saved == [2] monkeypatch.setattr(ck, "_extra_etf_codes", frozenset({"449450"})) ok, n = _run(ck.collect_date(1, 1, "20260914", "all", asyncio.Semaphore(1))) assert calls == [{"449450"}] and n == 3 and ok is True