Compare commits
1 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86a47bec7f |
@@ -304,46 +304,26 @@ class KISBroker:
|
|||||||
await self._rate_limiter.acquire()
|
await self._rate_limiter.acquire()
|
||||||
session = self._get_session()
|
session = self._get_session()
|
||||||
|
|
||||||
if ranking_type == "volume":
|
# TR_ID for volume ranking
|
||||||
# 거래량순위: FHPST01710000 / /quotations/volume-rank
|
tr_id = "FHPST01710000" if ranking_type == "volume" else "FHPST01710100"
|
||||||
tr_id = "FHPST01710000"
|
|
||||||
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
|
|
||||||
params: dict[str, str] = {
|
|
||||||
"FID_COND_MRKT_DIV_CODE": "J",
|
|
||||||
"FID_COND_SCR_DIV_CODE": "20171",
|
|
||||||
"FID_INPUT_ISCD": "0000",
|
|
||||||
"FID_DIV_CLS_CODE": "0",
|
|
||||||
"FID_BLNG_CLS_CODE": "0",
|
|
||||||
"FID_TRGT_CLS_CODE": "111111111",
|
|
||||||
"FID_TRGT_EXLS_CLS_CODE": "0000000000",
|
|
||||||
"FID_INPUT_PRICE_1": "0",
|
|
||||||
"FID_INPUT_PRICE_2": "0",
|
|
||||||
"FID_VOL_CNT": "0",
|
|
||||||
"FID_INPUT_DATE_1": "",
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# 등락률순위: FHPST01700000 / /ranking/fluctuation (소문자 파라미터)
|
|
||||||
tr_id = "FHPST01700000"
|
|
||||||
url = f"{self._base_url}/uapi/domestic-stock/v1/ranking/fluctuation"
|
|
||||||
params = {
|
|
||||||
"fid_cond_mrkt_div_code": "J",
|
|
||||||
"fid_cond_scr_div_code": "20170",
|
|
||||||
"fid_input_iscd": "0000",
|
|
||||||
"fid_rank_sort_cls_code": "0000",
|
|
||||||
"fid_input_cnt_1": str(limit),
|
|
||||||
"fid_prc_cls_code": "0",
|
|
||||||
"fid_input_price_1": "0",
|
|
||||||
"fid_input_price_2": "0",
|
|
||||||
"fid_vol_cnt": "0",
|
|
||||||
"fid_trgt_cls_code": "0",
|
|
||||||
"fid_trgt_exls_cls_code": "0",
|
|
||||||
"fid_div_cls_code": "0",
|
|
||||||
"fid_rsfl_rate1": "0",
|
|
||||||
"fid_rsfl_rate2": "0",
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = await self._auth_headers(tr_id)
|
headers = await self._auth_headers(tr_id)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"FID_COND_MRKT_DIV_CODE": "J", # Stock/ETF/ETN
|
||||||
|
"FID_COND_SCR_DIV_CODE": "20001", # Volume surge
|
||||||
|
"FID_INPUT_ISCD": "0000", # All stocks
|
||||||
|
"FID_DIV_CLS_CODE": "0", # All types
|
||||||
|
"FID_BLNG_CLS_CODE": "0",
|
||||||
|
"FID_TRGT_CLS_CODE": "111111111",
|
||||||
|
"FID_TRGT_EXLS_CLS_CODE": "000000",
|
||||||
|
"FID_INPUT_PRICE_1": "0",
|
||||||
|
"FID_INPUT_PRICE_2": "0",
|
||||||
|
"FID_VOL_CNT": "0",
|
||||||
|
"FID_INPUT_DATE_1": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session.get(url, headers=headers, params=params) as resp:
|
async with session.get(url, headers=headers, params=params) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ class Settings(BaseSettings):
|
|||||||
# Market selection (comma-separated market codes)
|
# Market selection (comma-separated market codes)
|
||||||
ENABLED_MARKETS: str = "KR,US"
|
ENABLED_MARKETS: str = "KR,US"
|
||||||
|
|
||||||
|
# Fallback stock list for KR domestic market when ranking API returns empty
|
||||||
|
# (KIS VTS does not return data from volume-rank API).
|
||||||
|
# Comma-separated 6-digit stock codes. Override in .env if needed.
|
||||||
|
KR_FALLBACK_STOCKS: str = (
|
||||||
|
"005930,000660,035420,005380,068270," # 삼성전자,SK하이닉스,NAVER,현대차,셀트리온
|
||||||
|
"051910,035720,006400,207940,000270" # LG화학,카카오,삼성SDI,삼성바이오로직스,기아
|
||||||
|
)
|
||||||
|
|
||||||
# Backup and Disaster Recovery (optional)
|
# Backup and Disaster Recovery (optional)
|
||||||
BACKUP_ENABLED: bool = True
|
BACKUP_ENABLED: bool = True
|
||||||
BACKUP_DIR: str = "data/backups"
|
BACKUP_DIR: str = "data/backups"
|
||||||
|
|||||||
@@ -1698,7 +1698,14 @@ async def run(settings: Settings) -> None:
|
|||||||
logger.info("Smart Scanner: Scanning %s market", market.name)
|
logger.info("Smart Scanner: Scanning %s market", market.name)
|
||||||
|
|
||||||
fallback_stocks: list[str] | None = None
|
fallback_stocks: list[str] | None = None
|
||||||
if not market.is_domestic:
|
if market.is_domestic:
|
||||||
|
# KIS VTS ranking API often returns empty for domestic.
|
||||||
|
# Use configured fallback so scanner can still run.
|
||||||
|
raw = settings.KR_FALLBACK_STOCKS if settings else ""
|
||||||
|
fallback_stocks = [
|
||||||
|
c.strip() for c in raw.split(",") if c.strip()
|
||||||
|
] or None
|
||||||
|
else:
|
||||||
fallback_stocks = await build_overseas_symbol_universe(
|
fallback_stocks = await build_overseas_symbol_universe(
|
||||||
db_conn=db_conn,
|
db_conn=db_conn,
|
||||||
overseas_broker=overseas_broker,
|
overseas_broker=overseas_broker,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -296,82 +296,3 @@ class TestHashKey:
|
|||||||
mock_acquire.assert_called_once()
|
mock_acquire.assert_called_once()
|
||||||
|
|
||||||
await broker.close()
|
await broker.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# fetch_market_rankings — TR_ID, path, params (issue #155)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _make_ranking_mock(items: list[dict]) -> AsyncMock:
|
|
||||||
"""Build a mock HTTP response returning ranking items."""
|
|
||||||
mock_resp = AsyncMock()
|
|
||||||
mock_resp.status = 200
|
|
||||||
mock_resp.json = AsyncMock(return_value={"output": items})
|
|
||||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
|
||||||
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
return mock_resp
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchMarketRankings:
|
|
||||||
"""Verify correct TR_ID, API path, and params per ranking_type (issue #155)."""
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def broker(self, settings) -> KISBroker:
|
|
||||||
b = KISBroker(settings)
|
|
||||||
b._access_token = "tok"
|
|
||||||
b._token_expires_at = float("inf")
|
|
||||||
b._rate_limiter.acquire = AsyncMock()
|
|
||||||
return b
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_volume_uses_correct_tr_id_and_path(self, broker: KISBroker) -> None:
|
|
||||||
mock_resp = _make_ranking_mock([])
|
|
||||||
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
|
|
||||||
await broker.fetch_market_rankings(ranking_type="volume")
|
|
||||||
|
|
||||||
call_kwargs = mock_get.call_args
|
|
||||||
url = call_kwargs[0][0] if call_kwargs[0] else call_kwargs[1].get("url", "")
|
|
||||||
headers = call_kwargs[1].get("headers", {})
|
|
||||||
params = call_kwargs[1].get("params", {})
|
|
||||||
|
|
||||||
assert "volume-rank" in url
|
|
||||||
assert headers.get("tr_id") == "FHPST01710000"
|
|
||||||
assert params.get("FID_COND_SCR_DIV_CODE") == "20171"
|
|
||||||
assert params.get("FID_TRGT_EXLS_CLS_CODE") == "0000000000"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fluctuation_uses_correct_tr_id_and_path(self, broker: KISBroker) -> None:
|
|
||||||
mock_resp = _make_ranking_mock([])
|
|
||||||
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
|
|
||||||
await broker.fetch_market_rankings(ranking_type="fluctuation")
|
|
||||||
|
|
||||||
call_kwargs = mock_get.call_args
|
|
||||||
url = call_kwargs[0][0] if call_kwargs[0] else call_kwargs[1].get("url", "")
|
|
||||||
headers = call_kwargs[1].get("headers", {})
|
|
||||||
params = call_kwargs[1].get("params", {})
|
|
||||||
|
|
||||||
assert "ranking/fluctuation" in url
|
|
||||||
assert headers.get("tr_id") == "FHPST01700000"
|
|
||||||
assert params.get("fid_cond_scr_div_code") == "20170"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_volume_returns_parsed_rows(self, broker: KISBroker) -> None:
|
|
||||||
items = [
|
|
||||||
{
|
|
||||||
"mksc_shrn_iscd": "005930",
|
|
||||||
"hts_kor_isnm": "삼성전자",
|
|
||||||
"stck_prpr": "75000",
|
|
||||||
"acml_vol": "10000000",
|
|
||||||
"prdy_ctrt": "2.5",
|
|
||||||
"vol_inrt": "150",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
mock_resp = _make_ranking_mock(items)
|
|
||||||
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
|
|
||||||
result = await broker.fetch_market_rankings(ranking_type="volume")
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["stock_code"] == "005930"
|
|
||||||
assert result[0]["price"] == 75000.0
|
|
||||||
assert result[0]["change_rate"] == 2.5
|
|
||||||
|
|||||||
@@ -1678,3 +1678,43 @@ def test_start_dashboard_server_enabled_starts_thread() -> None:
|
|||||||
assert thread == mock_thread
|
assert thread == mock_thread
|
||||||
mock_thread_cls.assert_called_once()
|
mock_thread_cls.assert_called_once()
|
||||||
mock_thread.start.assert_called_once()
|
mock_thread.start.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# KR fallback stocks config (issue #153)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestKrFallbackStocksConfig:
|
||||||
|
"""Test KR_FALLBACK_STOCKS default value and parsing."""
|
||||||
|
|
||||||
|
def test_default_contains_samsung(self) -> None:
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="k",
|
||||||
|
KIS_APP_SECRET="s",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="g",
|
||||||
|
)
|
||||||
|
codes = [c.strip() for c in settings.KR_FALLBACK_STOCKS.split(",") if c.strip()]
|
||||||
|
assert "005930" in codes # 삼성전자
|
||||||
|
|
||||||
|
def test_default_has_ten_codes(self) -> None:
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="k",
|
||||||
|
KIS_APP_SECRET="s",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="g",
|
||||||
|
)
|
||||||
|
codes = [c.strip() for c in settings.KR_FALLBACK_STOCKS.split(",") if c.strip()]
|
||||||
|
assert len(codes) == 10
|
||||||
|
|
||||||
|
def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("KR_FALLBACK_STOCKS", "005930,000660")
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="k",
|
||||||
|
KIS_APP_SECRET="s",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="g",
|
||||||
|
)
|
||||||
|
codes = [c.strip() for c in settings.KR_FALLBACK_STOCKS.split(",") if c.strip()]
|
||||||
|
assert codes == ["005930", "000660"]
|
||||||
|
|||||||
Reference in New Issue
Block a user