fix: domestic current price fetching and KRX tick unit rounding (#157)
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
**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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
19
src/main.py
19
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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user