feat(kis-collection): finalize sqlite migration, add fallback resilience, and update WBS documentation

This commit is contained in:
2026-06-22 18:34:56 +09:00
parent c576138829
commit 6c549b7bdc
48 changed files with 34610 additions and 24883 deletions
+74 -70
View File
@@ -1,83 +1,87 @@
"""data_feed 원자료 컬럼(MA/Ret/ATR/수급 5D·20D) 파생 함수 단위 테스트.
사용자 요청(2026-06-22): "json 로딩되는 게 원래는 sqlite에 파이선 코드로 수집돼야
하는거 아니야" — GAS가 계산하던 data_feed 원자료 일부를 Python(kis_data_collection_v1)
으로 옮기는 1단계 작업. 네트워크를 사용하지 않고 순수 계산 로직만 검증한다.
"""
from __future__ import annotations
import json
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.quant_engine.kis_data_collection_v1 import (
_aggregate_flow,
_compute_atr20,
_compute_ma,
_compute_ret_pct,
)
from src.quant_engine import kis_data_collection_v1 as kdc
def _price_rows(closes: list[float], highs: list[float] | None = None, lows: list[float] | None = None) -> list[dict]:
"""closes[0]이 최신 거래일. high/low를 안 주면 close와 동일하게 채운다(ATR=0 케이스 테스트용)."""
highs = highs or closes
lows = lows or closes
return [{"close": c, "high": h, "low": l, "volume": 1000} for c, h, l in zip(closes, highs, lows)]
class TestKisDataCollectionV1(unittest.TestCase):
def setUp(self) -> None:
self._tmp_root = Path(ROOT / "Temp" / "unit_test_kis_data_collection_v1")
self._tmp_root.mkdir(parents=True, exist_ok=True)
self.seed_json = self._tmp_root / "seed.json"
self.seed_json.write_text(
json.dumps(
{"data": {"data_feed": [{"Ticker": "005930", "Name": "삼성전자", "Sector": "반도체"}]}},
ensure_ascii=False,
),
encoding="utf-8",
)
def tearDown(self) -> None:
for path in self._tmp_root.glob("*"):
try:
path.unlink()
except OSError:
pass
try:
self._tmp_root.rmdir()
except OSError:
pass
def test_build_seed_rows(self):
rows = kdc._build_seed_rows(self.seed_json)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["Ticker"], "005930")
def test_resolve_price_source_prefers_kis_then_naver(self):
original_kis = kdc._normalize_kis_fields
original_naver = kdc._normalize_naver_price_history
kdc._normalize_kis_fields = lambda ticker, account: {"status": "OK", "current_price": 70000}
kdc._normalize_naver_price_history = lambda ticker: {"status": "OK", "close": 65000}
try:
kis, naver, source_priority = kdc._resolve_price_source(
"005930",
kis_account="mock",
include_naver=True,
include_live_kis=True,
)
self.assertEqual(kis["status"], "OK")
self.assertEqual(naver["status"], "OK")
self.assertEqual(source_priority[0], "kis_open_api")
self.assertIn("naver_finance", source_priority)
finally:
kdc._normalize_kis_fields = original_kis
kdc._normalize_naver_price_history = original_naver
def test_persist_collection_row_and_failure_helpers(self):
db_path = self._tmp_root / "collector.db"
normalized = {"Name": "삼성전자", "Sector": "반도체", "collection_as_of": "2026-06-22"}
provenance = {"source_priority": ["gathertradingdata_json"]}
kdc._persist_collection_row(
sqlite_db=db_path,
run_id="run-1",
ticker="005930",
normalized=normalized,
provenance=provenance,
)
error = kdc._append_collection_failure(
sqlite_db=db_path,
run_id="run-1",
ticker="005930",
row={"Ticker": "005930"},
exc=RuntimeError("boom"),
)
self.assertEqual(error["ticker"], "005930")
self.assertIn("boom", error["error"])
def test_compute_ma_returns_none_when_insufficient_rows():
rows = _price_rows([100.0, 101.0, 102.0])
assert _compute_ma(rows, 20) is None
def test_compute_ma_averages_most_recent_n_rows():
closes = [110.0] * 5 + [100.0] * 15
rows = _price_rows(closes)
# 최근 5거래일 평균 = 110, 20거래일 평균 = (5*110 + 15*100)/20 = 102.5
assert _compute_ma(rows, 5) == 110.0
assert _compute_ma(rows, 20) == 102.5
def test_compute_ret_pct_against_n_days_ago_close():
closes = [110.0, 109, 108, 107, 106, 100.0]
rows = _price_rows(closes)
# 최신(110) vs 5거래일전(100) → (110/100 - 1) * 100 = 10%
assert _compute_ret_pct(rows, 5) == 10.0
def test_compute_ret_pct_none_when_window_exceeds_rows():
rows = _price_rows([100.0, 99.0])
assert _compute_ret_pct(rows, 20) is None
def test_compute_atr20_requires_full_21_row_window():
rows = _price_rows([100.0] * 20)
assert _compute_atr20(rows) is None # 20행으로는 전일종가 페어 20쌍을 못 만듦(21행 필요)
def test_compute_atr20_computes_true_range_average():
# 21행: high-low가 항상 2, prev_close와의 간극은 그보다 작게 설계 → ATR20 = 2.0
closes = [100.0 + i * 0.1 for i in range(21)]
highs = [c + 1.0 for c in closes]
lows = [c - 1.0 for c in closes]
rows = _price_rows(closes, highs, lows)
atr = _compute_atr20(rows)
assert atr is not None
assert abs(atr - 2.0) < 0.5
def test_aggregate_flow_sums_recent_window():
rows = [{"frgn_net": 100, "inst_net": -50}] * 5 + [{"frgn_net": 1000, "inst_net": 1000}] * 15
frg5, inst5 = _aggregate_flow(rows, 5)
assert frg5 == 500
assert inst5 == -250
def test_aggregate_flow_none_when_window_exceeds_rows():
rows = [{"frgn_net": 10, "inst_net": 10}] * 3
frg, inst = _aggregate_flow(rows, 20)
assert frg is None
assert inst is None
if __name__ == "__main__":
unittest.main()