feat(kis-collection): finalize sqlite migration, add fallback resilience, and update WBS documentation
This commit is contained in:
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user