- Add pykrx-openapi dependency - New krx_client.py wrapper module - ETFCollector: Open API bulk fetch + pykrx fallback - ETFPriceCollector: Open API date-based bulk + pykrx fallback - StockCollector: Open API base_info + daily_trade + pykrx fallback - PriceCollector: Open API date-based bulk + pykrx fallback - ValuationCollector: pykrx retained (Open API has no PER/PBR) - generate_snapshots.py: Open API + pykrx fallback - Auto-switch based on KRX_OPENAPI_KEY env var - All 278 tests passing
270 lines
11 KiB
Python
270 lines
11 KiB
Python
"""
|
|
Unit tests for ETFCollector and ValuationCollector resilience.
|
|
|
|
Tests verify that collectors handle KRX API failures gracefully:
|
|
- JSONDecodeError, ConnectionError, KeyError from pykrx
|
|
- Retry logic and fallback behavior
|
|
- Existing DB data is never deleted on failure
|
|
- Clear error messages when KRX login is required
|
|
"""
|
|
from datetime import date
|
|
from json import JSONDecodeError
|
|
from unittest.mock import patch, MagicMock, call
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
from app.core.database import Base
|
|
from app.models.stock import ETF, Valuation
|
|
from app.services.collectors.etf_collector import ETFCollector, KRXDataError
|
|
from app.services.collectors.valuation_collector import ValuationCollector
|
|
|
|
|
|
@pytest.fixture
|
|
def db():
|
|
"""In-memory SQLite database for testing."""
|
|
engine = create_engine(
|
|
"sqlite:///:memory:",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
)
|
|
Base.metadata.create_all(bind=engine)
|
|
Session = sessionmaker(bind=engine)
|
|
session = Session()
|
|
yield session
|
|
session.close()
|
|
Base.metadata.drop_all(bind=engine)
|
|
|
|
|
|
# ── ETFCollector Tests (pykrx fallback path, no KRX_OPENAPI_KEY) ──
|
|
|
|
|
|
class TestETFCollectorResilience:
|
|
|
|
@patch("app.services.collectors.etf_collector.get_krx_client", return_value=None)
|
|
@patch("app.services.collectors.etf_collector.time.sleep")
|
|
@patch("pykrx.website.krx.etx.core.ETF_전종목기본종목")
|
|
def test_json_decode_error_retries_once(self, mock_etf_cls, mock_sleep, mock_client, db):
|
|
"""JSONDecodeError on both attempts raises KRXDataError with login hint."""
|
|
mock_fetcher = MagicMock()
|
|
mock_fetcher.fetch.side_effect = [
|
|
JSONDecodeError("msg", "doc", 0),
|
|
JSONDecodeError("msg", "doc", 0),
|
|
]
|
|
mock_etf_cls.return_value = mock_fetcher
|
|
|
|
collector = ETFCollector(db)
|
|
with pytest.raises(KRXDataError, match="KRX_ID/KRX_PW"):
|
|
collector.collect()
|
|
|
|
assert mock_fetcher.fetch.call_count == 2
|
|
mock_sleep.assert_called_once_with(3)
|
|
|
|
@patch("app.services.collectors.etf_collector.get_krx_client", return_value=None)
|
|
@patch("app.services.collectors.etf_collector.time.sleep")
|
|
@patch("pykrx.website.krx.etx.core.ETF_전종목기본종목")
|
|
def test_connection_error_retries_and_raises(self, mock_etf_cls, mock_sleep, mock_client, db):
|
|
"""ConnectionError on both attempts raises KRXDataError."""
|
|
mock_fetcher = MagicMock()
|
|
mock_fetcher.fetch.side_effect = ConnectionError("timeout")
|
|
mock_etf_cls.return_value = mock_fetcher
|
|
|
|
collector = ETFCollector(db)
|
|
with pytest.raises(KRXDataError):
|
|
collector.collect()
|
|
|
|
assert mock_fetcher.fetch.call_count == 2
|
|
|
|
@patch("app.services.collectors.etf_collector.get_krx_client", return_value=None)
|
|
@patch("app.services.collectors.etf_collector.time.sleep")
|
|
@patch("pykrx.website.krx.etx.core.ETF_전종목기본종목")
|
|
def test_retry_succeeds_on_second_attempt(self, mock_etf_cls, mock_sleep, mock_client, db):
|
|
"""If first attempt fails but retry succeeds, data is processed normally."""
|
|
good_df = pd.DataFrame([{
|
|
"ISU_SRT_CD": "069500",
|
|
"ISU_ABBRV": "KODEX 200",
|
|
"IDX_ASST_CLSS_NM": "주식",
|
|
"IDX_MKT_CLSS_NM": "코스피",
|
|
"ETF_TOT_FEE": "0.15",
|
|
}])
|
|
mock_fetcher = MagicMock()
|
|
mock_fetcher.fetch.side_effect = [
|
|
JSONDecodeError("msg", "doc", 0),
|
|
good_df,
|
|
]
|
|
mock_etf_cls.return_value = mock_fetcher
|
|
|
|
collector = ETFCollector(db)
|
|
result = collector.collect()
|
|
|
|
assert result == 1
|
|
|
|
@patch("app.services.collectors.etf_collector.get_krx_client", return_value=None)
|
|
@patch("app.services.collectors.etf_collector.time.sleep")
|
|
@patch("pykrx.website.krx.etx.core.ETF_전종목기본종목")
|
|
def test_failure_does_not_delete_existing_data(self, mock_etf_cls, mock_sleep, mock_client, db):
|
|
"""Existing ETF records are preserved when fetch fails."""
|
|
db.execute(
|
|
ETF.__table__.insert().values(
|
|
ticker="069500", name="KODEX 200",
|
|
asset_class="equity", market="코스피",
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
mock_fetcher = MagicMock()
|
|
mock_fetcher.fetch.side_effect = JSONDecodeError("msg", "doc", 0)
|
|
mock_etf_cls.return_value = mock_fetcher
|
|
|
|
collector = ETFCollector(db)
|
|
with pytest.raises(KRXDataError):
|
|
collector.collect()
|
|
|
|
existing = db.query(ETF).filter_by(ticker="069500").first()
|
|
assert existing is not None
|
|
assert existing.name == "KODEX 200"
|
|
|
|
|
|
# ── ETFCollector Open API Tests ──
|
|
|
|
|
|
class TestETFCollectorOpenAPI:
|
|
|
|
@patch("app.services.collectors.etf_collector.get_krx_client")
|
|
@patch("app.services.collectors.base.BaseCollector._get_latest_biz_day", return_value="20260417")
|
|
def test_openapi_success(self, mock_biz, mock_get_client, db):
|
|
"""Open API returns valid ETF data."""
|
|
mock_client = MagicMock()
|
|
mock_client.get_etf_daily.return_value = pd.DataFrame([{
|
|
"ISU_SRT_CD": "069500",
|
|
"ISU_ABBRV": "KODEX 200",
|
|
"TDD_CLSPRC": 35000.0,
|
|
"ACC_TRDVOL": 1000000,
|
|
}])
|
|
mock_get_client.return_value = mock_client
|
|
|
|
collector = ETFCollector(db)
|
|
result = collector.collect()
|
|
|
|
assert result == 1
|
|
etf = db.query(ETF).filter_by(ticker="069500").first()
|
|
assert etf is not None
|
|
assert etf.name == "KODEX 200"
|
|
|
|
@patch("app.services.collectors.etf_collector.get_krx_client")
|
|
@patch("app.services.collectors.etf_collector.time.sleep")
|
|
@patch("pykrx.website.krx.etx.core.ETF_전종목기본종목")
|
|
@patch("app.services.collectors.base.BaseCollector._get_latest_biz_day", return_value="20260417")
|
|
def test_openapi_failure_falls_back_to_pykrx(self, mock_biz, mock_etf_cls, mock_sleep, mock_get_client, db):
|
|
"""When Open API fails, falls back to pykrx scraping."""
|
|
mock_client = MagicMock()
|
|
mock_client.get_etf_daily.side_effect = Exception("API down")
|
|
mock_get_client.return_value = mock_client
|
|
|
|
good_df = pd.DataFrame([{
|
|
"ISU_SRT_CD": "069500",
|
|
"ISU_ABBRV": "KODEX 200",
|
|
"IDX_ASST_CLSS_NM": "주식",
|
|
"IDX_MKT_CLSS_NM": "코스피",
|
|
"ETF_TOT_FEE": "0.15",
|
|
}])
|
|
mock_fetcher = MagicMock()
|
|
mock_fetcher.fetch.return_value = good_df
|
|
mock_etf_cls.return_value = mock_fetcher
|
|
|
|
collector = ETFCollector(db)
|
|
result = collector.collect()
|
|
|
|
assert result == 1
|
|
|
|
|
|
# ── ValuationCollector Tests ──
|
|
|
|
|
|
class TestValuationCollectorResilience:
|
|
|
|
@patch("app.services.collectors.valuation_collector.pykrx_stock")
|
|
@patch("app.services.collectors.base.BaseCollector._get_latest_biz_day", return_value="20260327")
|
|
def test_key_error_falls_back_to_previous_days(self, mock_biz, mock_pykrx, db):
|
|
"""KeyError triggers fallback to previous business days."""
|
|
good_df = pd.DataFrame(
|
|
{"BPS": [50000], "PER": [10.0], "PBR": [1.5], "EPS": [5000], "DIV": [2.0], "DPS": [1000]},
|
|
index=["005930"],
|
|
)
|
|
mock_pykrx.get_market_fundamental_by_ticker.side_effect = [
|
|
KeyError("BPS"), # day 0
|
|
KeyError("PER"), # day -1
|
|
good_df, # day -2 succeeds
|
|
]
|
|
|
|
collector = ValuationCollector(db, biz_day="20260327")
|
|
result = collector.collect()
|
|
|
|
assert result == 1
|
|
assert mock_pykrx.get_market_fundamental_by_ticker.call_count == 3
|
|
|
|
@patch("app.services.collectors.valuation_collector.pykrx_stock")
|
|
@patch("app.services.collectors.base.BaseCollector._get_latest_biz_day", return_value="20260327")
|
|
def test_all_days_fail_raises_with_login_hint(self, mock_biz, mock_pykrx, db):
|
|
"""When all 4 date attempts fail with KeyError, raises RuntimeError with login hint."""
|
|
mock_pykrx.get_market_fundamental_by_ticker.side_effect = KeyError("BPS")
|
|
|
|
collector = ValuationCollector(db, biz_day="20260327")
|
|
with pytest.raises(RuntimeError, match="KRX_ID"):
|
|
collector.collect()
|
|
|
|
assert mock_pykrx.get_market_fundamental_by_ticker.call_count == 4
|
|
|
|
@patch("app.services.collectors.valuation_collector.pykrx_stock")
|
|
@patch("app.services.collectors.base.BaseCollector._get_latest_biz_day", return_value="20260327")
|
|
def test_json_decode_error_raises_with_login_hint(self, mock_biz, mock_pykrx, db):
|
|
"""JSONDecodeError on all attempts raises RuntimeError with login hint."""
|
|
mock_pykrx.get_market_fundamental_by_ticker.side_effect = JSONDecodeError("msg", "doc", 0)
|
|
|
|
collector = ValuationCollector(db, biz_day="20260327")
|
|
with pytest.raises(RuntimeError, match="KRX requires login"):
|
|
collector.collect()
|
|
|
|
@patch("app.services.collectors.valuation_collector.pykrx_stock")
|
|
@patch("app.services.collectors.base.BaseCollector._get_latest_biz_day", return_value="20260327")
|
|
def test_empty_df_triggers_fallback(self, mock_biz, mock_pykrx, db):
|
|
"""Empty DataFrame (not exception) also triggers fallback."""
|
|
good_df = pd.DataFrame(
|
|
{"BPS": [60000], "PER": [15.0], "PBR": [2.0], "EPS": [4000], "DIV": [1.5], "DPS": [800]},
|
|
index=["005930"],
|
|
)
|
|
mock_pykrx.get_market_fundamental_by_ticker.side_effect = [
|
|
pd.DataFrame(), # empty
|
|
good_df, # day -1 has data
|
|
]
|
|
|
|
collector = ValuationCollector(db, biz_day="20260327")
|
|
result = collector.collect()
|
|
|
|
assert result == 1
|
|
|
|
@patch("app.services.collectors.valuation_collector.pykrx_stock")
|
|
@patch("app.services.collectors.base.BaseCollector._get_latest_biz_day", return_value="20260327")
|
|
def test_failure_preserves_existing_data(self, mock_biz, mock_pykrx, db):
|
|
"""Existing valuation records are preserved when fetch fails."""
|
|
db.execute(
|
|
Valuation.__table__.insert().values(
|
|
ticker="005930", base_date=date(2026, 3, 26),
|
|
per=10.0, pbr=1.5, dividend_yield=2.0,
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
mock_pykrx.get_market_fundamental_by_ticker.side_effect = KeyError("BPS")
|
|
|
|
collector = ValuationCollector(db, biz_day="20260327")
|
|
with pytest.raises(RuntimeError):
|
|
collector.collect()
|
|
|
|
existing = db.query(Valuation).filter_by(ticker="005930").first()
|
|
assert existing is not None
|
|
assert existing.per == 10.0
|