diff --git a/src/broker/kis_api.py b/src/broker/kis_api.py index 8611688..1f4899b 100644 --- a/src/broker/kis_api.py +++ b/src/broker/kis_api.py @@ -8,7 +8,7 @@ from __future__ import annotations import asyncio import logging import ssl -from typing import Any +from typing import Any, cast import aiohttp @@ -478,6 +478,112 @@ class KISBroker: except (TimeoutError, aiohttp.ClientError) as exc: raise ConnectionError(f"Network error fetching rankings: {exc}") from exc + async def get_domestic_pending_orders(self) -> list[dict[str, Any]]: + """Fetch unfilled (pending) domestic limit orders. + + The KIS pending-orders API (TTTC0084R) is unsupported in paper (VTS) + mode, so this method returns an empty list immediately when MODE is + not "live". + + Returns: + List of pending order dicts from the KIS ``output`` field. + Each dict includes keys such as ``odno``, ``orgn_odno``, + ``ord_gno_brno``, ``psbl_qty``, ``sll_buy_dvsn_cd``, ``pdno``. + """ + if self._settings.MODE != "live": + logger.debug( + "get_domestic_pending_orders: paper mode — TTTC0084R unsupported, returning []" + ) + return [] + + await self._rate_limiter.acquire() + session = self._get_session() + + # TR_ID: 실전 TTTC0084R (모의 미지원) + # Source: 한국투자증권 오픈API 전체문서 (20260221) — '주식 미체결조회' 시트 + headers = await self._auth_headers("TTTC0084R") + params = { + "CANO": self._account_no, + "ACNT_PRDT_CD": self._product_cd, + "INQR_DVSN_1": "0", + "INQR_DVSN_2": "0", + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + } + url = f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-psbl-rvsecncl" + + try: + async with session.get(url, headers=headers, params=params) as resp: + if resp.status != 200: + text = await resp.text() + raise ConnectionError( + f"get_domestic_pending_orders failed ({resp.status}): {text}" + ) + data = await resp.json() + return data.get("output", []) or [] + except (TimeoutError, aiohttp.ClientError) as exc: + raise ConnectionError( + f"Network error fetching domestic pending orders: {exc}" + ) from exc + + async def cancel_domestic_order( + self, + stock_code: str, + orgn_odno: str, + krx_fwdg_ord_orgno: str, + qty: int, + ) -> dict[str, Any]: + """Cancel an unfilled domestic limit order. + + Args: + stock_code: 6-digit domestic stock code (``pdno``). + orgn_odno: Original order number from pending-orders response + (``orgn_odno`` field). + krx_fwdg_ord_orgno: KRX forwarding order branch number from + pending-orders response (``ord_gno_brno`` field). + qty: Quantity to cancel (use ``psbl_qty`` from pending order). + + Returns: + Raw KIS API response dict (check ``rt_cd == "0"`` for success). + """ + await self._rate_limiter.acquire() + session = self._get_session() + + # TR_ID: 실전 TTTC0013U, 모의 VTTC0013U + # Source: 한국투자증권 오픈API 전체문서 (20260221) — '주식주문(정정취소)' 시트 + tr_id = "TTTC0013U" if self._settings.MODE == "live" else "VTTC0013U" + + body = { + "CANO": self._account_no, + "ACNT_PRDT_CD": self._product_cd, + "KRX_FWDG_ORD_ORGNO": krx_fwdg_ord_orgno, + "ORGN_ODNO": orgn_odno, + "ORD_DVSN": "00", + "ORD_QTY": str(qty), + "ORD_UNPR": "0", + "RVSE_CNCL_DVSN_CD": "02", + "QTY_ALL_ORD_YN": "Y", + } + + hash_key = await self._get_hash_key(body) + headers = await self._auth_headers(tr_id) + headers["hashkey"] = hash_key + + url = f"{self._base_url}/uapi/domestic-stock/v1/trading/order-rvsecncl" + + try: + async with session.post(url, headers=headers, json=body) as resp: + if resp.status != 200: + text = await resp.text() + raise ConnectionError( + f"cancel_domestic_order failed ({resp.status}): {text}" + ) + return cast(dict[str, Any], await resp.json()) + except (TimeoutError, aiohttp.ClientError) as exc: + raise ConnectionError( + f"Network error cancelling domestic order: {exc}" + ) from exc + async def get_daily_prices( self, stock_code: str, diff --git a/src/main.py b/src/main.py index 1fa9d7f..4ef0e03 100644 --- a/src/main.py +++ b/src/main.py @@ -19,7 +19,7 @@ from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner from src.analysis.volatility import VolatilityAnalyzer from src.brain.context_selector import ContextSelector from src.brain.gemini_client import GeminiClient, TradeDecision -from src.broker.kis_api import KISBroker +from src.broker.kis_api import KISBroker, kr_round_down from src.broker.overseas import OverseasBroker from src.config import Settings from src.context.aggregator import ContextAggregator @@ -853,11 +853,19 @@ async def trading_cycle( # 5. Send order order_succeeded = True if market.is_domestic: + # Use limit orders (지정가) for domestic stocks to avoid market order + # quantity calculation issues. KRX tick rounding applied via kr_round_down. + # BUY: +0.2% — ensures fill even when ask is slightly above last price. + # SELL: -0.2% — ensures fill even when bid is slightly below last price. + if decision.action == "BUY": + order_price = kr_round_down(current_price * 1.002) + else: + order_price = kr_round_down(current_price * 0.998) result = await broker.send_order( stock_code=stock_code, order_type=decision.action, quantity=quantity, - price=0, # market order + price=order_price, ) else: # For overseas orders, always use limit orders (지정가): @@ -867,16 +875,17 @@ async def trading_cycle( # achieving >90% fill rate on large-cap US stocks. # - SELL: -0.2% below last price — ensures fill even when price dips slightly # (placing at exact last price risks no-fill if the bid is just below). + overseas_price: float if decision.action == "BUY": - order_price = round(current_price * 1.002, 4) + overseas_price = round(current_price * 1.002, 4) else: - order_price = round(current_price * 0.998, 4) + overseas_price = round(current_price * 0.998, 4) result = await overseas_broker.send_overseas_order( exchange_code=market.exchange_code, stock_code=stock_code, order_type=decision.action, quantity=quantity, - price=order_price, # limit order + price=overseas_price, # limit order ) # Check if KIS rejected the order (rt_cd != "0") if result.get("rt_cd", "") != "0": @@ -978,6 +987,153 @@ async def trading_cycle( ) +async def handle_domestic_pending_orders( + broker: KISBroker, + telegram: TelegramClient, + settings: Settings, + sell_resubmit_counts: dict[str, int], + buy_cooldown: dict[str, float] | None = None, +) -> None: + """Check and handle unfilled (pending) domestic limit orders. + + Called once per market loop iteration before new orders are considered. + In paper mode the KIS pending-orders API (TTTC0084R) is unsupported, so + ``get_domestic_pending_orders`` returns [] immediately and this function + exits without making further API calls. + + BUY pending → cancel (to free up balance) + optionally set cooldown. + SELL pending → cancel then resubmit at a wider spread (-0.4% from last + price, kr_round_down applied). Resubmission is attempted + at most once per key per session to avoid infinite loops. + + Args: + broker: KISBroker instance. + telegram: TelegramClient for notifications. + settings: Application settings. + sell_resubmit_counts: Mutable dict tracking SELL resubmission attempts + per "KR:{stock_code}" key. Passed by reference so counts persist + across calls within the same session. + buy_cooldown: Optional cooldown dict shared with the main trading loop. + When provided, cancelled BUY orders are added with a + _BUY_COOLDOWN_SECONDS expiry. + """ + try: + orders = await broker.get_domestic_pending_orders() + except Exception as exc: + logger.warning("Failed to fetch domestic pending orders: %s", exc) + return + + now = asyncio.get_event_loop().time() + + for order in orders: + try: + stock_code = order.get("pdno", "") + orgn_odno = order.get("orgn_odno", "") + krx_fwdg_ord_orgno = order.get("ord_gno_brno", "") + sll_buy = order.get("sll_buy_dvsn_cd", "") # "01"=SELL, "02"=BUY + psbl_qty = int(order.get("psbl_qty", "0") or "0") + key = f"KR:{stock_code}" + + if not stock_code or not orgn_odno or psbl_qty <= 0: + continue + + # Cancel the pending order first regardless of direction. + cancel_result = await broker.cancel_domestic_order( + stock_code=stock_code, + orgn_odno=orgn_odno, + krx_fwdg_ord_orgno=krx_fwdg_ord_orgno, + qty=psbl_qty, + ) + if cancel_result.get("rt_cd") != "0": + logger.warning( + "Cancel failed for KR %s: rt_cd=%s msg=%s", + stock_code, + cancel_result.get("rt_cd"), + cancel_result.get("msg1"), + ) + continue + + if sll_buy == "02": + # BUY pending → cancelled; set cooldown to avoid immediate re-buy. + if buy_cooldown is not None: + buy_cooldown[key] = now + _BUY_COOLDOWN_SECONDS + try: + await telegram.notify_unfilled_order( + stock_code=stock_code, + market="KR", + action="BUY", + quantity=psbl_qty, + outcome="cancelled", + ) + except Exception as notify_exc: + logger.warning("notify_unfilled_order failed: %s", notify_exc) + + elif sll_buy == "01": + # SELL pending — attempt one resubmit at a wider spread. + if sell_resubmit_counts.get(key, 0) >= 1: + # Already resubmitted once — only cancel (already done above). + logger.warning( + "SELL KR %s already resubmitted once — no further resubmit", + stock_code, + ) + try: + await telegram.notify_unfilled_order( + stock_code=stock_code, + market="KR", + action="SELL", + quantity=psbl_qty, + outcome="cancelled", + ) + except Exception as notify_exc: + logger.warning( + "notify_unfilled_order failed: %s", notify_exc + ) + else: + # First unfilled SELL → resubmit at last * 0.996 (-0.4%). + try: + last_price, _, _ = await broker.get_current_price(stock_code) + if last_price <= 0: + raise ValueError( + f"Invalid price ({last_price}) for {stock_code}" + ) + new_price = kr_round_down(last_price * 0.996) + await broker.send_order( + stock_code=stock_code, + order_type="SELL", + quantity=psbl_qty, + price=new_price, + ) + sell_resubmit_counts[key] = ( + sell_resubmit_counts.get(key, 0) + 1 + ) + try: + await telegram.notify_unfilled_order( + stock_code=stock_code, + market="KR", + action="SELL", + quantity=psbl_qty, + outcome="resubmitted", + new_price=float(new_price), + ) + except Exception as notify_exc: + logger.warning( + "notify_unfilled_order failed: %s", notify_exc + ) + except Exception as exc: + logger.error( + "SELL resubmit failed for KR %s: %s", + stock_code, + exc, + ) + + except Exception as exc: + logger.error( + "Error handling domestic pending order for %s: %s", + order.get("pdno", "?"), + exc, + ) + + async def handle_overseas_pending_orders( overseas_broker: OverseasBroker, telegram: TelegramClient, @@ -1205,6 +1361,19 @@ async def run_daily_session( # Use market-local date for playbook keying market_today = datetime.now(market.timezone).date() + # Check and handle domestic pending (unfilled) limit orders before new decisions. + if market.is_domestic: + try: + await handle_domestic_pending_orders( + broker, + telegram, + settings, + sell_resubmit_counts, + daily_buy_cooldown, + ) + except Exception as exc: + logger.warning("Domestic pending order check failed: %s", exc) + # Check and handle overseas pending (unfilled) limit orders before new decisions. if not market.is_domestic: try: @@ -1607,11 +1776,21 @@ async def run_daily_session( order_succeeded = True try: if market.is_domestic: + # Use limit orders (지정가) for domestic stocks. + # KRX tick rounding applied via kr_round_down. + if decision.action == "BUY": + order_price = kr_round_down( + stock_data["current_price"] * 1.002 + ) + else: + order_price = kr_round_down( + stock_data["current_price"] * 0.998 + ) result = await broker.send_order( stock_code=stock_code, order_type=decision.action, quantity=quantity, - price=0, # market order + price=order_price, ) else: # KIS VTS only accepts limit orders; use 0.5% premium for BUY @@ -2464,6 +2643,19 @@ async def run(settings: Settings) -> None: logger.warning("Market open notification failed: %s", exc) _market_states[market.code] = True + # Check and handle domestic pending (unfilled) limit orders. + if market.is_domestic: + try: + await handle_domestic_pending_orders( + broker, + telegram, + settings, + sell_resubmit_counts, + buy_cooldown, + ) + except Exception as exc: + logger.warning("Domestic pending order check failed: %s", exc) + # Check and handle overseas pending (unfilled) limit orders. if not market.is_domestic: try: diff --git a/tests/test_broker.py b/tests/test_broker.py index 4f45005..2c15b4c 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -725,3 +725,195 @@ class TestTRIDBranchingDomestic: order_headers = mock_post.call_args_list[1][1].get("headers", {}) assert order_headers["tr_id"] == "TTTC0011U" + + +# --------------------------------------------------------------------------- +# Domestic Pending Orders (get_domestic_pending_orders) +# --------------------------------------------------------------------------- + + +class TestGetDomesticPendingOrders: + """get_domestic_pending_orders must return [] in paper mode and call TTTC0084R in live.""" + + def _make_broker(self, settings, mode: str) -> KISBroker: + from src.config import Settings + + s = Settings( + KIS_APP_KEY=settings.KIS_APP_KEY, + KIS_APP_SECRET=settings.KIS_APP_SECRET, + KIS_ACCOUNT_NO=settings.KIS_ACCOUNT_NO, + GEMINI_API_KEY=settings.GEMINI_API_KEY, + DB_PATH=":memory:", + ENABLED_MARKETS="KR", + MODE=mode, + ) + b = KISBroker(s) + b._access_token = "tok" + b._token_expires_at = float("inf") + b._rate_limiter.acquire = AsyncMock() + return b + + @pytest.mark.asyncio + async def test_paper_mode_returns_empty(self, settings) -> None: + """Paper mode must return [] immediately without any API call.""" + broker = self._make_broker(settings, "paper") + + with patch("aiohttp.ClientSession.get") as mock_get: + result = await broker.get_domestic_pending_orders() + + assert result == [] + mock_get.assert_not_called() + + @pytest.mark.asyncio + async def test_live_mode_calls_tttc0084r_with_correct_params( + self, settings + ) -> None: + """Live mode must call TTTC0084R with INQR_DVSN_1/2 and paging params.""" + broker = self._make_broker(settings, "live") + pending = [{"odno": "001", "pdno": "005930", "psbl_qty": "10"}] + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"output": pending}) + 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: + result = await broker.get_domestic_pending_orders() + + assert result == pending + headers = mock_get.call_args[1].get("headers", {}) + assert headers["tr_id"] == "TTTC0084R" + params = mock_get.call_args[1].get("params", {}) + assert params["INQR_DVSN_1"] == "0" + assert params["INQR_DVSN_2"] == "0" + + @pytest.mark.asyncio + async def test_live_mode_connection_error(self, settings) -> None: + """Network error must raise ConnectionError.""" + import aiohttp as _aiohttp + + broker = self._make_broker(settings, "live") + + with patch( + "aiohttp.ClientSession.get", + side_effect=_aiohttp.ClientError("timeout"), + ): + with pytest.raises(ConnectionError): + await broker.get_domestic_pending_orders() + + +# --------------------------------------------------------------------------- +# Domestic Order Cancellation (cancel_domestic_order) +# --------------------------------------------------------------------------- + + +class TestCancelDomesticOrder: + """cancel_domestic_order must use correct TR_ID and build body correctly.""" + + def _make_broker(self, settings, mode: str) -> KISBroker: + from src.config import Settings + + s = Settings( + KIS_APP_KEY=settings.KIS_APP_KEY, + KIS_APP_SECRET=settings.KIS_APP_SECRET, + KIS_ACCOUNT_NO=settings.KIS_ACCOUNT_NO, + GEMINI_API_KEY=settings.GEMINI_API_KEY, + DB_PATH=":memory:", + ENABLED_MARKETS="KR", + MODE=mode, + ) + b = KISBroker(s) + b._access_token = "tok" + b._token_expires_at = float("inf") + b._rate_limiter.acquire = AsyncMock() + return b + + def _make_post_mocks(self, order_payload: dict) -> tuple: + 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=order_payload) + mock_order.__aenter__ = AsyncMock(return_value=mock_order) + mock_order.__aexit__ = AsyncMock(return_value=False) + + return mock_hash, mock_order + + @pytest.mark.asyncio + async def test_live_uses_tttc0013u(self, settings) -> None: + """Live mode must use TR_ID TTTC0013U.""" + broker = self._make_broker(settings, "live") + mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"}) + + with patch( + "aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order] + ) as mock_post: + await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 5) + + order_headers = mock_post.call_args_list[1][1].get("headers", {}) + assert order_headers["tr_id"] == "TTTC0013U" + + @pytest.mark.asyncio + async def test_paper_uses_vttc0013u(self, settings) -> None: + """Paper mode must use TR_ID VTTC0013U.""" + broker = self._make_broker(settings, "paper") + mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"}) + + with patch( + "aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order] + ) as mock_post: + await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 5) + + order_headers = mock_post.call_args_list[1][1].get("headers", {}) + assert order_headers["tr_id"] == "VTTC0013U" + + @pytest.mark.asyncio + async def test_cancel_sets_rvse_cncl_dvsn_cd_02(self, settings) -> None: + """Body must have RVSE_CNCL_DVSN_CD='02' (취소) and QTY_ALL_ORD_YN='Y'.""" + broker = self._make_broker(settings, "live") + mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"}) + + with patch( + "aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order] + ) as mock_post: + await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 5) + + body = mock_post.call_args_list[1][1].get("json", {}) + assert body["RVSE_CNCL_DVSN_CD"] == "02" + assert body["QTY_ALL_ORD_YN"] == "Y" + assert body["ORD_UNPR"] == "0" + + @pytest.mark.asyncio + async def test_cancel_sets_krx_fwdg_ord_orgno_in_body(self, settings) -> None: + """Body must include KRX_FWDG_ORD_ORGNO and ORGN_ODNO from arguments.""" + broker = self._make_broker(settings, "live") + mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"}) + + with patch( + "aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order] + ) as mock_post: + await broker.cancel_domestic_order("005930", "ORD123", "BRN456", 3) + + body = mock_post.call_args_list[1][1].get("json", {}) + assert body["KRX_FWDG_ORD_ORGNO"] == "BRN456" + assert body["ORGN_ODNO"] == "ORD123" + assert body["ORD_QTY"] == "3" + + @pytest.mark.asyncio + async def test_cancel_sets_hashkey_header(self, settings) -> None: + """Request must include hashkey header (same pattern as send_order).""" + broker = self._make_broker(settings, "live") + mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"}) + + with patch( + "aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order] + ) as mock_post: + await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 2) + + order_headers = mock_post.call_args_list[1][1].get("headers", {}) + assert "hashkey" in order_headers + assert order_headers["hashkey"] == "h" diff --git a/tests/test_main.py b/tests/test_main.py index a05eaeb..4cd61c9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -22,6 +22,7 @@ from src.main import ( _run_context_scheduler, _run_evolution_loop, _start_dashboard_server, + handle_domestic_pending_orders, handle_overseas_pending_orders, run_daily_session, safe_float, @@ -4058,3 +4059,322 @@ class TestHandleOverseasPendingOrders: # Should be called exactly once with "NASD" assert overseas_broker.get_overseas_pending_orders.call_count == 1 overseas_broker.get_overseas_pending_orders.assert_called_once_with("NASD") + + +# --------------------------------------------------------------------------- +# Domestic Pending Order Handling +# --------------------------------------------------------------------------- + + +class TestHandleDomesticPendingOrders: + """Tests for handle_domestic_pending_orders function.""" + + def _make_settings(self) -> Settings: + return Settings( + KIS_APP_KEY="k", + KIS_APP_SECRET="s", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="g", + ENABLED_MARKETS="KR", + ) + + def _make_telegram(self) -> MagicMock: + t = MagicMock() + t.notify_unfilled_order = AsyncMock() + return t + + @pytest.mark.asyncio + async def test_buy_pending_is_cancelled_and_cooldown_set(self) -> None: + """BUY pending order should be cancelled and buy_cooldown should be set.""" + settings = self._make_settings() + telegram = self._make_telegram() + + pending_order = { + "pdno": "005930", + "orgn_odno": "ORD001", + "ord_gno_brno": "BRN01", + "sll_buy_dvsn_cd": "02", # BUY + "psbl_qty": "3", + } + broker = MagicMock() + broker.get_domestic_pending_orders = AsyncMock(return_value=[pending_order]) + broker.cancel_domestic_order = AsyncMock( + return_value={"rt_cd": "0", "msg1": "OK"} + ) + + sell_resubmit_counts: dict[str, int] = {} + buy_cooldown: dict[str, float] = {} + + await handle_domestic_pending_orders( + broker, telegram, settings, sell_resubmit_counts, buy_cooldown + ) + + broker.cancel_domestic_order.assert_called_once_with( + stock_code="005930", + orgn_odno="ORD001", + krx_fwdg_ord_orgno="BRN01", + qty=3, + ) + assert "KR:005930" in buy_cooldown + telegram.notify_unfilled_order.assert_called_once() + call_kwargs = telegram.notify_unfilled_order.call_args[1] + assert call_kwargs["action"] == "BUY" + assert call_kwargs["outcome"] == "cancelled" + assert call_kwargs["market"] == "KR" + + @pytest.mark.asyncio + async def test_sell_pending_is_cancelled_then_resubmitted(self) -> None: + """First unfilled SELL should be cancelled then resubmitted at -0.4% price.""" + from src.broker.kis_api import kr_round_down + + settings = self._make_settings() + telegram = self._make_telegram() + + pending_order = { + "pdno": "005930", + "orgn_odno": "ORD002", + "ord_gno_brno": "BRN02", + "sll_buy_dvsn_cd": "01", # SELL + "psbl_qty": "5", + } + broker = MagicMock() + broker.get_domestic_pending_orders = AsyncMock(return_value=[pending_order]) + broker.cancel_domestic_order = AsyncMock( + return_value={"rt_cd": "0", "msg1": "OK"} + ) + broker.get_current_price = AsyncMock(return_value=(50000.0, 0.0, 0.0)) + broker.send_order = AsyncMock(return_value={"rt_cd": "0"}) + + sell_resubmit_counts: dict[str, int] = {} + + await handle_domestic_pending_orders( + broker, telegram, settings, sell_resubmit_counts + ) + + broker.cancel_domestic_order.assert_called_once() + broker.send_order.assert_called_once() + resubmit_kwargs = broker.send_order.call_args[1] + assert resubmit_kwargs["order_type"] == "SELL" + expected_price = kr_round_down(50000.0 * 0.996) + assert resubmit_kwargs["price"] == expected_price + assert sell_resubmit_counts.get("KR:005930") == 1 + notify_kwargs = telegram.notify_unfilled_order.call_args[1] + assert notify_kwargs["outcome"] == "resubmitted" + + @pytest.mark.asyncio + async def test_sell_cancel_failure_skips_resubmit(self) -> None: + """When cancel returns rt_cd != '0', resubmit should NOT be attempted.""" + settings = self._make_settings() + telegram = self._make_telegram() + + pending_order = { + "pdno": "005930", + "orgn_odno": "ORD003", + "ord_gno_brno": "BRN03", + "sll_buy_dvsn_cd": "01", # SELL + "psbl_qty": "2", + } + broker = MagicMock() + broker.get_domestic_pending_orders = AsyncMock(return_value=[pending_order]) + broker.cancel_domestic_order = AsyncMock( + return_value={"rt_cd": "1", "msg1": "Error"} # failure + ) + broker.send_order = AsyncMock() + + sell_resubmit_counts: dict[str, int] = {} + + await handle_domestic_pending_orders( + broker, telegram, settings, sell_resubmit_counts + ) + + broker.send_order.assert_not_called() + telegram.notify_unfilled_order.assert_not_called() + + @pytest.mark.asyncio + async def test_sell_already_resubmitted_is_only_cancelled(self) -> None: + """Second unfilled SELL (sell_resubmit_counts >= 1) should only cancel, no resubmit.""" + settings = self._make_settings() + telegram = self._make_telegram() + + pending_order = { + "pdno": "005930", + "orgn_odno": "ORD004", + "ord_gno_brno": "BRN04", + "sll_buy_dvsn_cd": "01", # SELL + "psbl_qty": "4", + } + broker = MagicMock() + broker.get_domestic_pending_orders = AsyncMock(return_value=[pending_order]) + broker.cancel_domestic_order = AsyncMock( + return_value={"rt_cd": "0", "msg1": "OK"} + ) + broker.send_order = AsyncMock() + + # Already resubmitted once + sell_resubmit_counts: dict[str, int] = {"KR:005930": 1} + + await handle_domestic_pending_orders( + broker, telegram, settings, sell_resubmit_counts + ) + + broker.cancel_domestic_order.assert_called_once() + broker.send_order.assert_not_called() + notify_kwargs = telegram.notify_unfilled_order.call_args[1] + assert notify_kwargs["outcome"] == "cancelled" + assert notify_kwargs["action"] == "SELL" + + +# --------------------------------------------------------------------------- +# Domestic Limit Order Price in trading_cycle +# --------------------------------------------------------------------------- + + +class TestDomesticLimitOrderPrice: + """trading_cycle must use kr_round_down limit prices for domestic orders.""" + + def _make_market(self) -> MagicMock: + market = MagicMock() + market.name = "Korea" + market.code = "KR" + market.exchange_code = "KRX" + market.is_domestic = True + return market + + def _make_broker(self, current_price: float, balance_data: dict) -> MagicMock: + broker = MagicMock() + broker.get_current_price = AsyncMock(return_value=(current_price, 0.0, 0.0)) + broker.get_balance = AsyncMock(return_value=balance_data) + broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"}) + return broker + + @pytest.mark.asyncio + async def test_trading_cycle_domestic_buy_uses_limit_price(self) -> None: + """BUY order for domestic stock must use kr_round_down(price * 1.002).""" + from src.broker.kis_api import kr_round_down + from src.strategy.models import ScenarioAction + + current_price = 70000.0 + balance_data = { + "output2": [ + { + "tot_evlu_amt": "10000000", + "dnca_tot_amt": "5000000", + "pchs_amt_smtl_amt": "5000000", + } + ] + } + broker = self._make_broker(current_price, balance_data) + market = self._make_market() + + buy_match = ScenarioMatch( + stock_code="005930", + matched_scenario=None, + action=ScenarioAction.BUY, + confidence=85, + rationale="test", + ) + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=buy_match) + + risk = MagicMock() + risk.validate_order = MagicMock() + risk.check_circuit_breaker = MagicMock() + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_fat_finger = AsyncMock() + telegram.notify_circuit_breaker = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + + with patch("src.main.log_trade"): + await trading_cycle( + broker=broker, + overseas_broker=MagicMock(), + scenario_engine=engine, + playbook=_make_playbook(), + risk=risk, + db_conn=MagicMock(), + decision_logger=MagicMock(), + context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)), + criticality_assessor=MagicMock( + assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")), + get_timeout=MagicMock(return_value=5.0), + ), + telegram=telegram, + market=market, + stock_code="005930", + scan_candidates={}, + ) + + broker.send_order.assert_called_once() + call_kwargs = broker.send_order.call_args[1] + expected_price = kr_round_down(current_price * 1.002) + assert call_kwargs["price"] == expected_price + assert call_kwargs["order_type"] == "BUY" + + @pytest.mark.asyncio + async def test_trading_cycle_domestic_sell_uses_limit_price(self) -> None: + """SELL order for domestic stock must use kr_round_down(price * 0.998).""" + from src.broker.kis_api import kr_round_down + from src.strategy.models import ScenarioAction + + current_price = 70000.0 + stock_code = "005930" + balance_data = { + "output1": [ + {"pdno": stock_code, "hldg_qty": "5", "prpr": "70000", "evlu_amt": "350000"} + ], + "output2": [ + { + "tot_evlu_amt": "350000", + "dnca_tot_amt": "0", + "pchs_amt_smtl_amt": "350000", + } + ], + } + broker = self._make_broker(current_price, balance_data) + market = self._make_market() + + sell_match = ScenarioMatch( + stock_code=stock_code, + matched_scenario=None, + action=ScenarioAction.SELL, + confidence=85, + rationale="test", + ) + engine = MagicMock(spec=ScenarioEngine) + engine.evaluate = MagicMock(return_value=sell_match) + + risk = MagicMock() + risk.validate_order = MagicMock() + risk.check_circuit_breaker = MagicMock() + telegram = MagicMock() + telegram.notify_trade_execution = AsyncMock() + telegram.notify_fat_finger = AsyncMock() + telegram.notify_circuit_breaker = AsyncMock() + telegram.notify_scenario_matched = AsyncMock() + + with patch("src.main.log_trade"): + await trading_cycle( + broker=broker, + overseas_broker=MagicMock(), + scenario_engine=engine, + playbook=_make_playbook(), + risk=risk, + db_conn=MagicMock(), + decision_logger=MagicMock(), + context_store=MagicMock(get_latest_timeframe=MagicMock(return_value=None)), + criticality_assessor=MagicMock( + assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")), + get_timeout=MagicMock(return_value=5.0), + ), + telegram=telegram, + market=market, + stock_code=stock_code, + scan_candidates={}, + ) + + broker.send_order.assert_called_once() + call_kwargs = broker.send_order.call_args[1] + expected_price = kr_round_down(current_price * 0.998) + assert call_kwargs["price"] == expected_price + assert call_kwargs["order_type"] == "SELL"