From 7834b89f105d3789488c102ec554b523517b2503 Mon Sep 17 00:00:00 2001 From: agentson Date: Thu, 19 Feb 2026 12:40:55 +0900 Subject: [PATCH] fix: domestic current price fetching and KRX tick unit rounding (#157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Problem 1 — Current price always 0** get_orderbook() used inquire-asking-price-exp-ccn which has no stck_prpr in output1 (only askp/bidp data). This caused every domestic BUY to be skipped with "no affordable quantity (cash=..., price=0.00)". **Problem 2 — KRX tick unit error on limit orders** Limit order prices were passed unrounded, triggering 호가단위 오류 in VTS. Also ORD_DVSN was wrongly set to "01" (시장가) for limit orders. **Fix** - Add kr_tick_unit(price) and kr_round_down(price) module-level helpers implementing KRX 7-tier price tick rules (1/5/10/50/100/500/1000원). - Add get_current_price(stock_code) → (price, change_pct, foreigner_net) using FHKST01010100 / inquire-price API (works in VTS, returns correct stck_prpr, prdy_ctrt, frgn_ntby_qty). - Fix send_order() ORD_DVSN: "00"=지정가, "01"=시장가 (was "01"/"06"). - Apply kr_round_down() to limit order price inside send_order(). - Replace both get_orderbook() calls in main.py with get_current_price(). - Update all 4 test_main.py mock sites to use get_current_price AsyncMock. **Tests added** (25 new tests, all 646 pass) - TestKrTickUnit: 13 parametrized boundary cases + 7 round-down cases - TestGetCurrentPrice: correct fields, correct API path/TR_ID, HTTP error - TestSendOrderTickRounding: tick rounding, ORD_DVSN 00/01 Co-Authored-By: Claude Sonnet 4.6 --- src/broker/kis_api.py | 96 +++++++++++++++++++- src/main.py | 19 ++-- tests/test_broker.py | 198 ++++++++++++++++++++++++++++++++++++++++++ tests/test_main.py | 24 +---- 4 files changed, 301 insertions(+), 36 deletions(-) diff --git a/src/broker/kis_api.py b/src/broker/kis_api.py index 9c3b136..89fefa2 100644 --- a/src/broker/kis_api.py +++ b/src/broker/kis_api.py @@ -20,6 +20,39 @@ _KIS_VTS_HOST = "openapivts.koreainvestment.com" logger = logging.getLogger(__name__) +def kr_tick_unit(price: float) -> int: + """Return KRX tick size for the given price level. + + KRX price tick rules (domestic stocks): + price < 2,000 → 1원 + 2,000 ≤ price < 5,000 → 5원 + 5,000 ≤ price < 20,000 → 10원 + 20,000 ≤ price < 50,000 → 50원 + 50,000 ≤ price < 200,000 → 100원 + 200,000 ≤ price < 500,000 → 500원 + 500,000 ≤ price → 1,000원 + """ + if price < 2_000: + return 1 + if price < 5_000: + return 5 + if price < 20_000: + return 10 + if price < 50_000: + return 50 + if price < 200_000: + return 100 + if price < 500_000: + return 500 + return 1_000 + + +def kr_round_down(price: float) -> int: + """Round *down* price to the nearest KRX tick unit.""" + tick = kr_tick_unit(price) + return int(price // tick * tick) + + class LeakyBucket: """Simple leaky-bucket rate limiter for async code.""" @@ -198,6 +231,55 @@ class KISBroker: except (TimeoutError, aiohttp.ClientError) as exc: raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc + async def get_current_price( + self, stock_code: str + ) -> tuple[float, float, float]: + """Fetch current price data for a domestic stock. + + Uses the ``inquire-price`` API (FHKST01010100), which works in both + real and VTS environments and returns the actual last-traded price. + + Returns: + (current_price, prdy_ctrt, frgn_ntby_qty) + - current_price: Last traded price in KRW. + - prdy_ctrt: Day change rate (%). + - frgn_ntby_qty: Foreigner net buy quantity. + """ + await self._rate_limiter.acquire() + session = self._get_session() + + headers = await self._auth_headers("FHKST01010100") + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + } + url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-price" + + def _f(val: str | None) -> float: + try: + return float(val or "0") + except ValueError: + return 0.0 + + try: + async with session.get(url, headers=headers, params=params) as resp: + if resp.status != 200: + text = await resp.text() + raise ConnectionError( + f"get_current_price failed ({resp.status}): {text}" + ) + data = await resp.json() + out = data.get("output", {}) + return ( + _f(out.get("stck_prpr")), + _f(out.get("prdy_ctrt")), + _f(out.get("frgn_ntby_qty")), + ) + except (TimeoutError, aiohttp.ClientError) as exc: + raise ConnectionError( + f"Network error fetching current price: {exc}" + ) from exc + async def get_balance(self) -> dict[str, Any]: """Fetch current account balance and holdings.""" await self._rate_limiter.acquire() @@ -249,13 +331,23 @@ class KISBroker: session = self._get_session() tr_id = "VTTC0802U" if order_type == "BUY" else "VTTC0801U" + + # KRX requires limit orders to be rounded down to the tick unit. + # ORD_DVSN: "00"=지정가, "01"=시장가 + if price > 0: + ord_dvsn = "00" # 지정가 + ord_price = kr_round_down(price) + else: + ord_dvsn = "01" # 시장가 + ord_price = 0 + body = { "CANO": self._account_no, "ACNT_PRDT_CD": self._product_cd, "PDNO": stock_code, - "ORD_DVSN": "01" if price > 0 else "06", # 01=지정가, 06=시장가 + "ORD_DVSN": ord_dvsn, "ORD_QTY": str(quantity), - "ORD_UNPR": str(price), + "ORD_UNPR": str(ord_price), } hash_key = await self._get_hash_key(body) diff --git a/src/main.py b/src/main.py index d506f94..3f78ae9 100644 --- a/src/main.py +++ b/src/main.py @@ -204,7 +204,9 @@ async def trading_cycle( # 1. Fetch market data if market.is_domestic: - orderbook = await broker.get_orderbook(stock_code) + current_price, price_change_pct, foreigner_net = await broker.get_current_price( + stock_code + ) balance_data = await broker.get_balance() output2 = balance_data.get("output2", [{}]) @@ -215,10 +217,6 @@ async def trading_cycle( else "0" ) purchase_total = safe_float(output2[0].get("pchs_amt_smtl_amt", "0")) if output2 else 0 - - current_price = safe_float(orderbook.get("output1", {}).get("stck_prpr", "0")) - foreigner_net = safe_float(orderbook.get("output1", {}).get("frgn_ntby_qty", "0")) - price_change_pct = safe_float(orderbook.get("output1", {}).get("prdy_ctrt", "0")) else: # Overseas market price_data = await overseas_broker.get_overseas_price( @@ -726,15 +724,8 @@ async def run_daily_session( for stock_code in watchlist: try: if market.is_domestic: - orderbook = await broker.get_orderbook(stock_code) - current_price = safe_float( - orderbook.get("output1", {}).get("stck_prpr", "0") - ) - foreigner_net = safe_float( - orderbook.get("output1", {}).get("frgn_ntby_qty", "0") - ) - price_change_pct = safe_float( - orderbook.get("output1", {}).get("prdy_ctrt", "0") + current_price, price_change_pct, foreigner_net = ( + await broker.get_current_price(stock_code) ) else: price_data = await overseas_broker.get_overseas_price( diff --git a/tests/test_broker.py b/tests/test_broker.py index 1393a08..a858fd2 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -375,3 +375,201 @@ class TestFetchMarketRankings: assert result[0]["stock_code"] == "005930" assert result[0]["price"] == 75000.0 assert result[0]["change_rate"] == 2.5 + + +# --------------------------------------------------------------------------- +# KRX tick unit / round-down helpers (issue #157) +# --------------------------------------------------------------------------- + + +from src.broker.kis_api import kr_tick_unit, kr_round_down # noqa: E402 + + +class TestKrTickUnit: + """kr_tick_unit and kr_round_down must implement KRX price tick rules.""" + + @pytest.mark.parametrize( + "price, expected_tick", + [ + (1999, 1), + (2000, 5), + (4999, 5), + (5000, 10), + (19999, 10), + (20000, 50), + (49999, 50), + (50000, 100), + (199999, 100), + (200000, 500), + (499999, 500), + (500000, 1000), + (1000000, 1000), + ], + ) + def test_tick_unit_boundaries(self, price: int, expected_tick: int) -> None: + assert kr_tick_unit(price) == expected_tick + + @pytest.mark.parametrize( + "price, expected_rounded", + [ + (188150, 188100), # 100원 단위, 50원 잔여 → 내림 + (188100, 188100), # 이미 정렬됨 + (75050, 75000), # 100원 단위, 50원 잔여 → 내림 + (49950, 49950), # 50원 단위 정렬됨 + (49960, 49950), # 50원 단위, 10원 잔여 → 내림 + (1999, 1999), # 1원 단위 → 그대로 + (5003, 5000), # 10원 단위, 3원 잔여 → 내림 + ], + ) + def test_round_down_to_tick(self, price: int, expected_rounded: int) -> None: + assert kr_round_down(price) == expected_rounded + + +# --------------------------------------------------------------------------- +# get_current_price (issue #157) +# --------------------------------------------------------------------------- + + +class TestGetCurrentPrice: + """get_current_price must use inquire-price API and return (price, change, foreigner).""" + + @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_returns_correct_fields(self, broker: KISBroker) -> None: + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock( + return_value={ + "rt_cd": "0", + "output": { + "stck_prpr": "188600", + "prdy_ctrt": "3.97", + "frgn_ntby_qty": "12345", + }, + } + ) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get: + price, change_pct, foreigner = await broker.get_current_price("005930") + + assert price == 188600.0 + assert change_pct == 3.97 + assert foreigner == 12345.0 + + 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", {}) + assert "inquire-price" in url + assert headers.get("tr_id") == "FHKST01010100" + + @pytest.mark.asyncio + async def test_http_error_raises_connection_error(self, broker: KISBroker) -> None: + mock_resp = AsyncMock() + mock_resp.status = 500 + mock_resp.text = AsyncMock(return_value="Internal Server Error") + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.get", return_value=mock_resp): + with pytest.raises(ConnectionError, match="get_current_price failed"): + await broker.get_current_price("005930") + + +# --------------------------------------------------------------------------- +# send_order tick rounding and ORD_DVSN (issue #157) +# --------------------------------------------------------------------------- + + +class TestSendOrderTickRounding: + """send_order must apply KRX tick rounding and correct ORD_DVSN codes.""" + + @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_limit_order_rounds_down_to_tick(self, broker: KISBroker) -> None: + """Price 188150 (not on 100-won tick) must be rounded to 188100.""" + mock_hash = AsyncMock() + mock_hash.status = 200 + mock_hash.json = AsyncMock(return_value={"HASH": "h"}) + mock_hash.__aenter__ = AsyncMock(return_value=mock_hash) + mock_hash.__aexit__ = AsyncMock(return_value=False) + + mock_order = AsyncMock() + mock_order.status = 200 + mock_order.json = AsyncMock(return_value={"rt_cd": "0"}) + mock_order.__aenter__ = AsyncMock(return_value=mock_order) + mock_order.__aexit__ = AsyncMock(return_value=False) + + with patch( + "aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order] + ) as mock_post: + await broker.send_order("005930", "BUY", 1, price=188150) + + order_call = mock_post.call_args_list[1] + body = order_call[1].get("json", {}) + assert body["ORD_UNPR"] == "188100" # rounded down + assert body["ORD_DVSN"] == "00" # 지정가 + + @pytest.mark.asyncio + async def test_limit_order_ord_dvsn_is_00(self, broker: KISBroker) -> None: + """send_order with price>0 must use ORD_DVSN='00' (지정가).""" + mock_hash = AsyncMock() + mock_hash.status = 200 + mock_hash.json = AsyncMock(return_value={"HASH": "h"}) + mock_hash.__aenter__ = AsyncMock(return_value=mock_hash) + mock_hash.__aexit__ = AsyncMock(return_value=False) + + mock_order = AsyncMock() + mock_order.status = 200 + mock_order.json = AsyncMock(return_value={"rt_cd": "0"}) + mock_order.__aenter__ = AsyncMock(return_value=mock_order) + mock_order.__aexit__ = AsyncMock(return_value=False) + + with patch( + "aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order] + ) as mock_post: + await broker.send_order("005930", "BUY", 1, price=50000) + + order_call = mock_post.call_args_list[1] + body = order_call[1].get("json", {}) + assert body["ORD_DVSN"] == "00" + + @pytest.mark.asyncio + async def test_market_order_ord_dvsn_is_01(self, broker: KISBroker) -> None: + """send_order with price=0 must use ORD_DVSN='01' (시장가).""" + mock_hash = AsyncMock() + mock_hash.status = 200 + mock_hash.json = AsyncMock(return_value={"HASH": "h"}) + mock_hash.__aenter__ = AsyncMock(return_value=mock_hash) + mock_hash.__aexit__ = AsyncMock(return_value=False) + + mock_order = AsyncMock() + mock_order.status = 200 + mock_order.json = AsyncMock(return_value={"rt_cd": "0"}) + mock_order.__aenter__ = AsyncMock(return_value=mock_order) + mock_order.__aexit__ = AsyncMock(return_value=False) + + with patch( + "aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order] + ) as mock_post: + await broker.send_order("005930", "SELL", 1, price=0) + + order_call = mock_post.call_args_list[1] + body = order_call[1].get("json", {}) + assert body["ORD_DVSN"] == "01" + assert body["ORD_UNPR"] == "0" diff --git a/tests/test_main.py b/tests/test_main.py index 2f50702..7f4518a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -111,15 +111,7 @@ class TestTradingCycleTelegramIntegration: def mock_broker(self) -> MagicMock: """Create mock broker.""" broker = MagicMock() - broker.get_orderbook = AsyncMock( - return_value={ - "output1": { - "stck_prpr": "50000", - "frgn_ntby_qty": "100", - "prdy_ctrt": "1.23", - } - } - ) + broker.get_current_price = AsyncMock(return_value=(50000.0, 1.23, 100.0)) broker.get_balance = AsyncMock( return_value={ "output2": [ @@ -823,11 +815,7 @@ class TestScenarioEngineIntegration: def mock_broker(self) -> MagicMock: """Create mock broker with standard domestic data.""" broker = MagicMock() - broker.get_orderbook = AsyncMock( - return_value={ - "output1": {"stck_prpr": "50000", "frgn_ntby_qty": "100", "prdy_ctrt": "2.50"} - } - ) + broker.get_current_price = AsyncMock(return_value=(50000.0, 2.50, 100.0)) broker.get_balance = AsyncMock( return_value={ "output2": [ @@ -1249,9 +1237,7 @@ async def test_sell_updates_original_buy_decision_outcome() -> None: ) broker = MagicMock() - broker.get_orderbook = AsyncMock( - return_value={"output1": {"stck_prpr": "120", "frgn_ntby_qty": "0"}} - ) + broker.get_current_price = AsyncMock(return_value=(120.0, 0.0, 0.0)) broker.get_balance = AsyncMock( return_value={ "output2": [ @@ -1341,9 +1327,7 @@ async def test_hold_overridden_to_sell_when_stop_loss_triggered() -> None: ) broker = MagicMock() - broker.get_orderbook = AsyncMock( - return_value={"output1": {"stck_prpr": "95", "frgn_ntby_qty": "0", "prdy_ctrt": "-5.0"}} - ) + broker.get_current_price = AsyncMock(return_value=(95.0, -5.0, 0.0)) broker.get_balance = AsyncMock( return_value={ "output2": [ -- 2.49.1