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
This commit is contained in:
346
backend/tests/test_collect_krx_data_gaps.py
Normal file
346
backend/tests/test_collect_krx_data_gaps.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user