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
@@ -19,7 +19,7 @@ ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import pytest
import unittest
from src.quant_engine import kis_data_collection_v1 as kdc
from src.quant_engine.data_collection_store_v1 import load_collection_dashboard_state
@@ -35,104 +35,143 @@ SEED_ROWS = [
]
@pytest.fixture()
def seed_json(tmp_path: Path) -> Path:
path = tmp_path / "seed.json"
path.write_text(
json.dumps({"data": {"data_feed": SEED_ROWS}}, ensure_ascii=False),
encoding="utf-8",
)
def _write_seed_json(path: Path) -> Path:
path.write_text(json.dumps({"data": {"data_feed": SEED_ROWS}}, ensure_ascii=False), encoding="utf-8")
return path
def test_kis_collection_writes_sqlite_that_snapshot_admin_dashboard_reads_back(tmp_path: Path, seed_json: Path):
"""1단계: KIS 수집(네트워크 미사용) → SQLite 적재 → snapshot_admin 대시보드 read-back."""
db_path = tmp_path / "data_collection_store_v1.db"
output_json = tmp_path / "kis_data_collection_v1.json"
class TestKisCollectionIntegration(unittest.TestCase):
summary = kdc.collect_to_sqlite(
input_json=seed_json,
sqlite_db=db_path,
output_json=output_json,
kis_account="mock",
include_naver=False,
include_live_kis=False,
)
def test_kis_collection_writes_sqlite_that_snapshot_admin_dashboard_reads_back(self):
"""1단계: KIS 수집(네트워크 미사용) → SQLite 적재 → snapshot_admin 대시보드 read-back."""
import tempfile
assert summary["status"] in {"PASS", "PASS_WITH_WARNINGS"}
assert summary["row_count"] == len(SEED_ROWS)
assert not summary["errors"]
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
seed_json = _write_seed_json(tmp_path / "seed.json")
db_path = tmp_path / "data_collection_store_v1.db"
output_json = tmp_path / "kis_data_collection_v1.json"
dashboard = load_collection_dashboard_state(db_path=db_path, output_json_path=output_json)
assert dashboard["counts"]["collection_runs"] >= 1
assert dashboard["counts"]["collection_snapshots"] == len(SEED_ROWS)
assert dashboard["counts"]["collection_source_errors"] == 0
tickers_in_dashboard = {row["ticker"] for row in dashboard["recent_snapshots"]}
assert {"005930", "000660"} <= tickers_in_dashboard
summary = kdc.collect_to_sqlite(
input_json=seed_json,
sqlite_db=db_path,
output_json=output_json,
kis_account="mock",
include_naver=False,
include_live_kis=False,
)
self.assertIn(summary["status"], {"PASS", "PASS_WITH_WARNINGS"})
self.assertEqual(summary["row_count"], len(SEED_ROWS))
self.assertFalse(summary["errors"])
def test_naver_fetch_exception_degrades_gracefully_without_breaking_batch(tmp_path: Path, seed_json: Path, monkeypatch):
"""Cloudflare 403 등 Naver 폴백 차단 시 graceful degradation 검증 (spec/exit/qualitative_sell_strategy_v1.yaml:81-82 명시 리스크)."""
dashboard = load_collection_dashboard_state(db_path, output_json)
self.assertGreaterEqual(dashboard["counts"]["collection_runs"], 1)
self.assertEqual(dashboard["counts"]["collection_snapshots"], len(SEED_ROWS))
self.assertEqual(dashboard["counts"]["collection_source_errors"], 0)
tickers_in_dashboard = {row["ticker"] for row in dashboard["recent_snapshots"]}
self.assertTrue({"005930", "000660"} <= tickers_in_dashboard)
def _raise_cloudflare_block(_session, _code):
raise RuntimeError("HTTP 403 Forbidden (Cloudflare)")
def test_naver_fetch_exception_degrades_gracefully_without_breaking_batch(self):
"""Cloudflare 403 등 Naver 폴백 차단 시 graceful degradation 검증."""
import tempfile
monkeypatch.setattr(kdc, "fetch_price_history", _raise_cloudflare_block)
# naver_session/fetch_price_history may be None on environments without the optional
# dependency wired; force both non-None so _normalize_naver_price_history actually tries.
monkeypatch.setattr(kdc, "naver_session", lambda: object())
def _raise_cloudflare_block(_session, _code):
raise RuntimeError("HTTP 403 Forbidden (Cloudflare)")
db_path = tmp_path / "data_collection_store_v1.db"
output_json = tmp_path / "kis_data_collection_v1.json"
original_fetch = kdc.fetch_price_history
original_session = kdc.naver_session
kdc.fetch_price_history = _raise_cloudflare_block
kdc.naver_session = lambda: object()
try:
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
seed_json = _write_seed_json(tmp_path / "seed.json")
db_path = tmp_path / "data_collection_store_v1.db"
output_json = tmp_path / "kis_data_collection_v1.json"
summary = kdc.collect_to_sqlite(
input_json=seed_json,
sqlite_db=db_path,
output_json=output_json,
kis_account="mock",
include_naver=True,
include_live_kis=False,
)
self.assertIn(summary["status"], {"PASS", "PASS_WITH_WARNINGS"})
self.assertEqual(summary["row_count"], len(SEED_ROWS))
self.assertFalse(summary["errors"])
finally:
kdc.fetch_price_history = original_fetch
kdc.naver_session = original_session
summary = kdc.collect_to_sqlite(
input_json=seed_json,
sqlite_db=db_path,
output_json=output_json,
kis_account="mock",
include_naver=True,
include_live_kis=False,
)
def test_seed_rows_and_price_source_helpers_are_deterministic(self):
import tempfile
# 배치 전체가 죽지 않고 끝까지 진행되어야 한다 — 개별 ticker의 naver 보강 실패는
# collection_source_errors가 아니라 정상 row로 (naver 필드 없이) 기록된다.
assert summary["status"] in {"PASS", "PASS_WITH_WARNINGS"}
assert summary["row_count"] == len(SEED_ROWS)
assert not summary["errors"], "Naver 차단은 개별 ticker 처리 중 흡수되어야 하며 배치 errors로 전파되면 안 된다"
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
seed_json = _write_seed_json(tmp_path / "seed.json")
rows = kdc._build_seed_rows(seed_json)
self.assertEqual([row["Ticker"] for row in rows], ["005930", "000660"])
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, "volume": 1234}
kdc._normalize_naver_price_history = lambda ticker: {"status": "OK", "close": 65000, "volume": 1111}
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.assertIn("kis_open_api", source_priority)
self.assertIn("naver_finance", source_priority)
self.assertEqual(source_priority[0], "kis_open_api")
def test_qualitative_sell_strategy_decision_round_trips_through_store(tmp_path: Path):
"""2단계: 정성매도전략 평가(순수 함수, 네트워크 미사용) → SQLite 저장 → 조회 round-trip."""
ctx = {
"today": date(2026, 6, 21),
"macro_pressure": 0.5,
"fundamental_trajectory": 0.4,
"short_interest_pressure": 0.6,
"microstructure_pressure": 0.2,
"liquidity_rotation_risk": 0.5,
"rate_trend": "RISING",
}
decision = compute_qualitative_sell_strategy(ctx)
assert decision["action"] in {
"EXIT_REVIEW_FULL",
"TRIM_REVIEW_PARTIAL",
"HOLD_ADD_CONVICTION",
"HOLD_NO_CONFLUENCE",
"INSUFFICIENT_DATA_NO_ACTION",
}
normalized = {"Ticker": "005930", "Name": "삼성전자", "Sector": "반도체"}
kdc._apply_source_fallbacks(normalized, row=normalized, kis=kis, naver=naver)
self.assertEqual(normalized["current_price"], 70000)
self.assertEqual(normalized["volume"], 1234)
finally:
kdc._normalize_kis_fields = original_kis
kdc._normalize_naver_price_history = original_naver
result = {
"code": "005930",
"generated_at": "2026-06-21T15:30:00+09:00",
"decision": decision,
}
def test_qualitative_sell_strategy_decision_round_trips_through_store(self):
"""2단계: 정성매도전략 평가(순수 함수, 네트워크 미사용) → SQLite 저장 → 조회 round-trip."""
ctx = {
"today": date(2026, 6, 21),
"macro_pressure": 0.5,
"fundamental_trajectory": 0.4,
"short_interest_pressure": 0.6,
"microstructure_pressure": 0.2,
"liquidity_rotation_risk": 0.5,
"rate_trend": "RISING",
}
decision = compute_qualitative_sell_strategy(ctx)
self.assertIn(decision["action"], {
"EXIT_REVIEW_FULL",
"TRIM_REVIEW_PARTIAL",
"HOLD_ADD_CONVICTION",
"HOLD_NO_CONFLUENCE",
"INSUFFICIENT_DATA_NO_ACTION",
})
db_path = tmp_path / "qualitative_sell_strategy.db"
insert_sell_strategy_result(db_path, result)
result = {
"code": "005930",
"generated_at": "2026-06-21T15:30:00+09:00",
"decision": decision,
}
fetched = fetch_recent_sell_strategy_results(db_path, "005930", limit=5)
assert len(fetched) == 1
assert fetched[0]["code"] == "005930"
assert fetched[0]["action"] == decision["action"]
assert fetched[0]["conviction"] == decision["conviction"]
assert fetched[0]["market_regime"] == decision["market_regime"]
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "qualitative_sell_strategy.db"
insert_sell_strategy_result(db_path, result)
fetched = fetch_recent_sell_strategy_results(db_path, "005930", limit=5)
self.assertEqual(len(fetched), 1)
self.assertEqual(fetched[0]["code"], "005930")
self.assertEqual(fetched[0]["action"], decision["action"])
self.assertEqual(fetched[0]["conviction"], decision["conviction"])
self.assertEqual(fetched[0]["market_regime"], decision["market_regime"])