Files
auto-stock/backend/tests/test_collect_krx_data_gaps.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

347 lines
13 KiB
Python

"""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<low/close 불일치
assert h >= 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