From 6b74e4cc776518b75f4e6b291f371b9246b83ddd Mon Sep 17 00:00:00 2001 From: agentson Date: Mon, 23 Feb 2026 21:12:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=95=B4=EC=99=B8=EC=A3=BC=EC=8B=9D=20?= =?UTF-8?q?=EB=AF=B8=EC=B2=B4=EA=B2=B0=20=EC=A3=BC=EB=AC=B8=20=EA=B0=90?= =?UTF-8?q?=EC=A7=80=20=EB=B0=8F=20=EC=B2=98=EB=A6=AC=20(#229)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OverseasBroker에 get_overseas_pending_orders (TTTS3018R, 실전전용) 및 cancel_overseas_order (거래소별 TR_ID, hashkey 필수) 추가 - TelegramClient에 notify_unfilled_order 추가 (BUY취소=MEDIUM, SELL미체결=HIGH 우선순위) - handle_overseas_pending_orders 함수 추가: · BUY 미체결 → 취소 + 쿨다운 설정 · SELL 미체결 → 취소 후 -0.4% 재주문 (최대 1회) · 미국 거래소(NASD/NYSE/AMEX) 중복 조회 방지 - daily/realtime 두 모드 모두 market 루프 시작 전 호출 - 테스트 13개 추가 (test_overseas_broker.py 8개, test_main.py 5개) Co-Authored-By: Claude Sonnet 4.6 --- src/broker/overseas.py | 139 +++++++++++++++++ src/main.py | 207 +++++++++++++++++++++++++ src/notifications/telegram_client.py | 42 ++++++ tests/test_main.py | 186 +++++++++++++++++++++++ tests/test_overseas_broker.py | 218 +++++++++++++++++++++++++++ 5 files changed, 792 insertions(+) diff --git a/src/broker/overseas.py b/src/broker/overseas.py index 98f4e2d..84b0fdb 100644 --- a/src/broker/overseas.py +++ b/src/broker/overseas.py @@ -29,6 +29,20 @@ _RANKING_EXCHANGE_MAP: dict[str, str] = { # NASD → NAS, NYSE → NYS, AMEX → AMS (confirmed: AMEX returns empty, AMS returns price). _PRICE_EXCHANGE_MAP: dict[str, str] = _RANKING_EXCHANGE_MAP +# Cancel order TR_IDs per exchange code — (live_tr_id, paper_tr_id). +# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 주문취소' 시트 +_CANCEL_TR_ID_MAP: dict[str, tuple[str, str]] = { + "NASD": ("TTTT1004U", "VTTT1004U"), + "NYSE": ("TTTT1004U", "VTTT1004U"), + "AMEX": ("TTTT1004U", "VTTT1004U"), + "SEHK": ("TTTS1003U", "VTTS1003U"), + "TSE": ("TTTS0309U", "VTTS0309U"), + "SHAA": ("TTTS0302U", "VTTS0302U"), + "SZAA": ("TTTS0306U", "VTTS0306U"), + "HNX": ("TTTS0312U", "VTTS0312U"), + "HSX": ("TTTS0312U", "VTTS0312U"), +} + class OverseasBroker: """KIS Overseas Stock API wrapper that reuses KISBroker infrastructure.""" @@ -292,6 +306,131 @@ class OverseasBroker: f"Network error sending overseas order: {exc}" ) from exc + async def get_overseas_pending_orders( + self, exchange_code: str + ) -> list[dict[str, Any]]: + """Fetch unfilled (pending) overseas orders for a given exchange. + + Args: + exchange_code: Exchange code (e.g., "NASD", "SEHK"). + For US markets, NASD returns all US pending orders (NASD/NYSE/AMEX). + + Returns: + List of pending order dicts with fields: odno, pdno, sll_buy_dvsn_cd, + ft_ord_qty, nccs_qty, ft_ord_unpr3, ovrs_excg_cd. + Always returns [] in paper mode (TTTS3018R is live-only). + + Raises: + ConnectionError: On network or API errors (live mode only). + """ + if self._broker._settings.MODE != "live": + logger.debug( + "Pending orders API (TTTS3018R) not supported in paper mode; returning []" + ) + return [] + + await self._broker._rate_limiter.acquire() + session = self._broker._get_session() + + # TTTS3018R: 해외주식 미체결내역조회 (실전 전용) + # Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 미체결조회' 시트 + headers = await self._broker._auth_headers("TTTS3018R") + params = { + "CANO": self._broker._account_no, + "ACNT_PRDT_CD": self._broker._product_cd, + "OVRS_EXCG_CD": exchange_code, + "SORT_SQN": "DS", + "CTX_AREA_FK200": "", + "CTX_AREA_NK200": "", + } + url = ( + f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-nccs" + ) + + try: + async with session.get(url, headers=headers, params=params) as resp: + if resp.status != 200: + text = await resp.text() + raise ConnectionError( + f"get_overseas_pending_orders failed ({resp.status}): {text}" + ) + data = await resp.json() + output = data.get("output", []) + if isinstance(output, list): + return output + return [] + except (TimeoutError, aiohttp.ClientError) as exc: + raise ConnectionError( + f"Network error fetching pending orders: {exc}" + ) from exc + + async def cancel_overseas_order( + self, + exchange_code: str, + stock_code: str, + odno: str, + qty: int, + ) -> dict[str, Any]: + """Cancel an overseas limit order. + + Args: + exchange_code: Exchange code (e.g., "NASD", "SEHK"). + stock_code: Stock ticker symbol. + odno: Original order number to cancel. + qty: Unfilled quantity to cancel. + + Returns: + API response dict containing rt_cd and msg1. + + Raises: + ValueError: If exchange_code has no cancel TR_ID mapping. + ConnectionError: On network or API errors. + """ + tr_ids = _CANCEL_TR_ID_MAP.get(exchange_code) + if tr_ids is None: + raise ValueError(f"No cancel TR_ID mapping for exchange: {exchange_code}") + live_tr_id, paper_tr_id = tr_ids + tr_id = live_tr_id if self._broker._settings.MODE == "live" else paper_tr_id + + await self._broker._rate_limiter.acquire() + session = self._broker._get_session() + + # RVSE_CNCL_DVSN_CD="02" means cancel (not revision). + # OVRS_ORD_UNPR must be "0" for cancellations. + # Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 정정취소주문' 시트 + body = { + "CANO": self._broker._account_no, + "ACNT_PRDT_CD": self._broker._product_cd, + "OVRS_EXCG_CD": exchange_code, + "PDNO": stock_code, + "ORGN_ODNO": odno, + "RVSE_CNCL_DVSN_CD": "02", + "ORD_QTY": str(qty), + "OVRS_ORD_UNPR": "0", + "ORD_SVR_DVSN_CD": "0", + } + + hash_key = await self._broker._get_hash_key(body) + headers = await self._broker._auth_headers(tr_id) + headers["hashkey"] = hash_key + + url = ( + f"{self._broker._base_url}/uapi/overseas-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_overseas_order failed ({resp.status}): {text}" + ) + return await resp.json() + except (TimeoutError, aiohttp.ClientError) as exc: + raise ConnectionError( + f"Network error cancelling overseas order: {exc}" + ) from exc + def _get_currency_code(self, exchange_code: str) -> str: """ Map exchange code to currency code. diff --git a/src/main.py b/src/main.py index f14c308..1fa9d7f 100644 --- a/src/main.py +++ b/src/main.py @@ -978,6 +978,181 @@ async def trading_cycle( ) +async def handle_overseas_pending_orders( + overseas_broker: OverseasBroker, + telegram: TelegramClient, + settings: Settings, + sell_resubmit_counts: dict[str, int], + buy_cooldown: dict[str, float] | None = None, +) -> None: + """Check and handle unfilled (pending) overseas limit orders. + + Called once per market loop iteration before new orders are considered. + In paper mode the KIS pending-orders API (TTTS3018R) is unsupported, so + this function returns immediately without making any 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). Resubmission is attempted at most once per key + per session to avoid infinite retry loops. + + Args: + overseas_broker: OverseasBroker instance. + telegram: TelegramClient for notifications. + settings: Application settings (MODE, ENABLED_MARKETS). + sell_resubmit_counts: Mutable dict tracking SELL resubmission attempts + per "{exchange_code}:{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. + """ + # Determine which exchange codes to query, deduplicating US exchanges. + # NASD alone returns all US (NASD/NYSE/AMEX) pending orders. + us_exchanges = frozenset({"NASD", "NYSE", "AMEX"}) + exchange_codes: list[str] = [] + seen_us = False + for market_code in settings.enabled_market_list: + market_info = MARKETS.get(market_code) + if market_info is None or market_info.is_domestic: + continue + exc_code = market_info.exchange_code + if exc_code in us_exchanges: + if not seen_us: + exchange_codes.append("NASD") + seen_us = True + elif exc_code not in exchange_codes: + exchange_codes.append(exc_code) + + now = asyncio.get_event_loop().time() + + for exchange_code in exchange_codes: + try: + orders = await overseas_broker.get_overseas_pending_orders(exchange_code) + except Exception as exc: + logger.warning( + "Failed to fetch pending orders for %s: %s", exchange_code, exc + ) + continue + + for order in orders: + try: + stock_code = order.get("pdno", "") + odno = order.get("odno", "") + sll_buy = order.get("sll_buy_dvsn_cd", "") # "01"=SELL, "02"=BUY + nccs_qty = int(order.get("nccs_qty", "0") or "0") + order_exchange = order.get("ovrs_excg_cd") or exchange_code + key = f"{order_exchange}:{stock_code}" + + if not stock_code or not odno or nccs_qty <= 0: + continue + + # Cancel the pending order first regardless of direction. + cancel_result = await overseas_broker.cancel_overseas_order( + exchange_code=order_exchange, + stock_code=stock_code, + odno=odno, + qty=nccs_qty, + ) + if cancel_result.get("rt_cd") != "0": + logger.warning( + "Cancel failed for %s %s: rt_cd=%s msg=%s", + order_exchange, + 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=order_exchange, + action="BUY", + quantity=nccs_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 %s %s already resubmitted once — no further resubmit", + order_exchange, + stock_code, + ) + try: + await telegram.notify_unfilled_order( + stock_code=stock_code, + market=order_exchange, + action="SELL", + quantity=nccs_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: + price_data = await overseas_broker.get_overseas_price( + order_exchange, stock_code + ) + last_price = float( + price_data.get("output", {}).get("last", "0") or "0" + ) + if last_price <= 0: + raise ValueError( + f"Invalid price ({last_price}) for {stock_code}" + ) + new_price = round(last_price * 0.996, 4) + await overseas_broker.send_overseas_order( + exchange_code=order_exchange, + stock_code=stock_code, + order_type="SELL", + quantity=nccs_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=order_exchange, + action="SELL", + quantity=nccs_qty, + outcome="resubmitted", + new_price=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 %s %s: %s", + order_exchange, + stock_code, + exc, + ) + + except Exception as exc: + logger.error( + "Error handling pending order for %s: %s", + order.get("pdno", "?"), + exc, + ) + + async def run_daily_session( broker: KISBroker, overseas_broker: OverseasBroker, @@ -1022,11 +1197,27 @@ async def run_daily_session( # BUY cooldown: prevents retrying stocks rejected for insufficient balance daily_buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp + # Tracks SELL resubmission attempts per "{exchange_code}:{stock_code}" (max 1 per session). + sell_resubmit_counts: dict[str, int] = {} + # Process each open market for market in open_markets: # Use market-local date for playbook keying market_today = datetime.now(market.timezone).date() + # Check and handle overseas pending (unfilled) limit orders before new decisions. + if not market.is_domestic: + try: + await handle_overseas_pending_orders( + overseas_broker, + telegram, + settings, + sell_resubmit_counts, + daily_buy_cooldown, + ) + except Exception as exc: + logger.warning("Pending order check failed: %s", exc) + # Dynamic stock discovery via scanner (no static watchlists) candidates_list: list[ScanCandidate] = [] fallback_stocks: list[str] | None = None @@ -2085,6 +2276,9 @@ async def run(settings: Settings) -> None: # BUY cooldown: prevents retrying a stock rejected for insufficient balance buy_cooldown: dict[str, float] = {} # "{market_code}:{stock_code}" -> expiry timestamp + # Tracks SELL resubmission attempts per "{exchange_code}:{stock_code}" (max 1 until restart). + sell_resubmit_counts: dict[str, int] = {} + # Initialize latency control system criticality_assessor = CriticalityAssessor( critical_pnl_threshold=-2.5, # Near circuit breaker at -3.0% @@ -2270,6 +2464,19 @@ async def run(settings: Settings) -> None: logger.warning("Market open notification failed: %s", exc) _market_states[market.code] = True + # Check and handle overseas pending (unfilled) limit orders. + if not market.is_domestic: + try: + await handle_overseas_pending_orders( + overseas_broker, + telegram, + settings, + sell_resubmit_counts, + buy_cooldown, + ) + except Exception as exc: + logger.warning("Pending order check failed: %s", exc) + # Smart Scanner: dynamic stock discovery (no static watchlists) now_timestamp = asyncio.get_event_loop().time() last_scan = last_scan_time.get(market.code, 0.0) diff --git a/src/notifications/telegram_client.py b/src/notifications/telegram_client.py index bc4eca4..0030645 100644 --- a/src/notifications/telegram_client.py +++ b/src/notifications/telegram_client.py @@ -473,6 +473,48 @@ class TelegramClient: NotificationMessage(priority=priority, message=message) ) + async def notify_unfilled_order( + self, + stock_code: str, + market: str, + action: str, + quantity: int, + outcome: str, + new_price: float | None = None, + ) -> None: + """Notify about an unfilled overseas order that was cancelled or resubmitted. + + Args: + stock_code: Stock ticker symbol. + market: Exchange/market code (e.g., "NASD", "SEHK"). + action: "BUY" or "SELL". + quantity: Unfilled quantity. + outcome: "cancelled" or "resubmitted". + new_price: New order price if resubmitted (None if only cancelled). + """ + if not self._filter.trades: + return + # SELL resubmit is high priority — position liquidation at risk. + # BUY cancel is medium priority — only cash is freed. + priority = ( + NotificationPriority.HIGH + if action == "SELL" + else NotificationPriority.MEDIUM + ) + outcome_emoji = "🔄" if outcome == "resubmitted" else "❌" + outcome_label = "재주문" if outcome == "resubmitted" else "취소됨" + action_emoji = "🔴" if action == "SELL" else "🟢" + lines = [ + f"{outcome_emoji} 미체결 주문 {outcome_label}", + f"Symbol: {stock_code} ({market})", + f"Action: {action_emoji} {action}", + f"Quantity: {quantity:,} shares", + ] + if new_price is not None: + lines.append(f"New Price: {new_price:.4f}") + message = "\n".join(lines) + await self._send_notification(NotificationMessage(priority=priority, message=message)) + async def notify_error( self, error_type: str, error_msg: str, context: str ) -> None: diff --git a/tests/test_main.py b/tests/test_main.py index d4ed19c..a05eaeb 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_overseas_pending_orders, run_daily_session, safe_float, sync_positions_from_broker, @@ -3872,3 +3873,188 @@ class TestDomesticBuyDoublePreventionTradingCycle: # BUY must NOT have been executed because broker still holds the stock broker.send_order.assert_not_called() + + +class TestHandleOverseasPendingOrders: + """Tests for handle_overseas_pending_orders function.""" + + def _make_settings(self, markets: str = "US_NASDAQ,US_NYSE,US_AMEX") -> Settings: + return Settings( + KIS_APP_KEY="k", + KIS_APP_SECRET="s", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="g", + ENABLED_MARKETS=markets, + ) + + 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("US_NASDAQ") + telegram = self._make_telegram() + + pending_order = { + "pdno": "AAPL", + "odno": "ORD001", + "sll_buy_dvsn_cd": "02", # BUY + "nccs_qty": "3", + "ovrs_excg_cd": "NASD", + } + overseas_broker = MagicMock() + overseas_broker.get_overseas_pending_orders = AsyncMock( + return_value=[pending_order] + ) + overseas_broker.cancel_overseas_order = AsyncMock( + return_value={"rt_cd": "0", "msg1": "OK"} + ) + + sell_resubmit_counts: dict[str, int] = {} + buy_cooldown: dict[str, float] = {} + + await handle_overseas_pending_orders( + overseas_broker, telegram, settings, sell_resubmit_counts, buy_cooldown + ) + + overseas_broker.cancel_overseas_order.assert_called_once_with( + exchange_code="NASD", + stock_code="AAPL", + odno="ORD001", + qty=3, + ) + assert "NASD:AAPL" 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" + + @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.""" + settings = self._make_settings("US_NASDAQ") + telegram = self._make_telegram() + + pending_order = { + "pdno": "AAPL", + "odno": "ORD002", + "sll_buy_dvsn_cd": "01", # SELL + "nccs_qty": "5", + "ovrs_excg_cd": "NASD", + } + overseas_broker = MagicMock() + overseas_broker.get_overseas_pending_orders = AsyncMock( + return_value=[pending_order] + ) + overseas_broker.cancel_overseas_order = AsyncMock( + return_value={"rt_cd": "0", "msg1": "OK"} + ) + overseas_broker.get_overseas_price = AsyncMock( + return_value={"output": {"last": "200.0"}} + ) + overseas_broker.send_overseas_order = AsyncMock( + return_value={"rt_cd": "0", "msg1": "OK"} + ) + + sell_resubmit_counts: dict[str, int] = {} + + await handle_overseas_pending_orders( + overseas_broker, telegram, settings, sell_resubmit_counts + ) + + overseas_broker.cancel_overseas_order.assert_called_once() + overseas_broker.send_overseas_order.assert_called_once() + resubmit_kwargs = overseas_broker.send_overseas_order.call_args[1] + assert resubmit_kwargs["order_type"] == "SELL" + assert resubmit_kwargs["price"] == round(200.0 * 0.996, 4) + assert sell_resubmit_counts.get("NASD:AAPL") == 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("US_NASDAQ") + telegram = self._make_telegram() + + pending_order = { + "pdno": "AAPL", + "odno": "ORD003", + "sll_buy_dvsn_cd": "01", # SELL + "nccs_qty": "2", + "ovrs_excg_cd": "NASD", + } + overseas_broker = MagicMock() + overseas_broker.get_overseas_pending_orders = AsyncMock( + return_value=[pending_order] + ) + overseas_broker.cancel_overseas_order = AsyncMock( + return_value={"rt_cd": "1", "msg1": "Error"} # failure + ) + overseas_broker.send_overseas_order = AsyncMock() + + sell_resubmit_counts: dict[str, int] = {} + + await handle_overseas_pending_orders( + overseas_broker, telegram, settings, sell_resubmit_counts + ) + + overseas_broker.send_overseas_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("US_NASDAQ") + telegram = self._make_telegram() + + pending_order = { + "pdno": "AAPL", + "odno": "ORD004", + "sll_buy_dvsn_cd": "01", # SELL + "nccs_qty": "4", + "ovrs_excg_cd": "NASD", + } + overseas_broker = MagicMock() + overseas_broker.get_overseas_pending_orders = AsyncMock( + return_value=[pending_order] + ) + overseas_broker.cancel_overseas_order = AsyncMock( + return_value={"rt_cd": "0", "msg1": "OK"} + ) + overseas_broker.send_overseas_order = AsyncMock() + + # Already resubmitted once + sell_resubmit_counts: dict[str, int] = {"NASD:AAPL": 1} + + await handle_overseas_pending_orders( + overseas_broker, telegram, settings, sell_resubmit_counts + ) + + overseas_broker.cancel_overseas_order.assert_called_once() + overseas_broker.send_overseas_order.assert_not_called() + notify_kwargs = telegram.notify_unfilled_order.call_args[1] + assert notify_kwargs["outcome"] == "cancelled" + assert notify_kwargs["action"] == "SELL" + + @pytest.mark.asyncio + async def test_us_exchanges_deduplicated_to_nasd(self) -> None: + """US_NASDAQ, US_NYSE, US_AMEX should result in only one NASD query.""" + settings = self._make_settings("US_NASDAQ,US_NYSE,US_AMEX") + telegram = self._make_telegram() + + overseas_broker = MagicMock() + overseas_broker.get_overseas_pending_orders = AsyncMock(return_value=[]) + + sell_resubmit_counts: dict[str, int] = {} + + await handle_overseas_pending_orders( + overseas_broker, telegram, settings, sell_resubmit_counts + ) + + # 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") diff --git a/tests/test_overseas_broker.py b/tests/test_overseas_broker.py index 681f03f..65d6d60 100644 --- a/tests/test_overseas_broker.py +++ b/tests/test_overseas_broker.py @@ -813,3 +813,221 @@ class TestOverseasTRIDBranching: await broker.send_overseas_order("NASD", "AAPL", "SELL", 1) assert "TTTT1006U" in captured + + +class TestGetOverseasPendingOrders: + """Tests for get_overseas_pending_orders method.""" + + @pytest.mark.asyncio + async def test_paper_mode_returns_empty( + self, overseas_broker: OverseasBroker + ) -> None: + """Paper mode should immediately return [] without any API call.""" + # Default mock_settings has MODE="paper" + overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy( + update={"MODE": "paper"} + ) + mock_session = MagicMock() + _setup_broker_mocks(overseas_broker, mock_session) + + result = await overseas_broker.get_overseas_pending_orders("NASD") + + assert result == [] + mock_session.get.assert_not_called() + + @pytest.mark.asyncio + async def test_live_mode_calls_ttts3018r_with_correct_params( + self, overseas_broker: OverseasBroker + ) -> None: + """Live mode should call TTTS3018R with OVRS_EXCG_CD and return output list.""" + overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy( + update={"MODE": "live"} + ) + captured_tr_id: list[str] = [] + captured_params: list[dict] = [] + + async def mock_auth_headers(tr_id: str) -> dict: + captured_tr_id.append(tr_id) + return {} + + overseas_broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign] + + pending_orders = [ + {"odno": "001", "pdno": "AAPL", "sll_buy_dvsn_cd": "02", "nccs_qty": "5"} + ] + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"output": pending_orders}) + + mock_session = MagicMock() + + def _capture_get(url: str, **kwargs: object) -> MagicMock: + captured_params.append(kwargs.get("params", {})) + return _make_async_cm(mock_resp) + + mock_session.get = MagicMock(side_effect=_capture_get) + overseas_broker._broker._rate_limiter.acquire = AsyncMock() + overseas_broker._broker._get_session = MagicMock(return_value=mock_session) + + result = await overseas_broker.get_overseas_pending_orders("NASD") + + assert result == pending_orders + assert captured_tr_id == ["TTTS3018R"] + assert captured_params[0]["OVRS_EXCG_CD"] == "NASD" + + @pytest.mark.asyncio + async def test_live_mode_connection_error( + self, overseas_broker: OverseasBroker + ) -> None: + """Network error in live mode should raise ConnectionError.""" + overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy( + update={"MODE": "live"} + ) + cm = MagicMock() + cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("timeout")) + cm.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=cm) + _setup_broker_mocks(overseas_broker, mock_session) + + with pytest.raises(ConnectionError, match="Network error fetching pending orders"): + await overseas_broker.get_overseas_pending_orders("NASD") + + +class TestCancelOverseasOrder: + """Tests for cancel_overseas_order method.""" + + def _setup_cancel_mocks( + self, overseas_broker: OverseasBroker, response: dict + ) -> tuple[list[str], MagicMock]: + """Wire up mocks for a successful cancel call; return captured TR_IDs and session.""" + captured_tr_ids: list[str] = [] + + async def mock_auth_headers(tr_id: str) -> dict: + captured_tr_ids.append(tr_id) + return {} + + overseas_broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign] + overseas_broker._broker._get_hash_key = AsyncMock(return_value="hash_val") # type: ignore[method-assign] + overseas_broker._broker._rate_limiter.acquire = AsyncMock() + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value=response) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp)) + overseas_broker._broker._get_session = MagicMock(return_value=mock_session) + + return captured_tr_ids, mock_session + + @pytest.mark.asyncio + async def test_us_live_uses_tttt1004u( + self, overseas_broker: OverseasBroker + ) -> None: + """US exchange in live mode should use TTTT1004U.""" + overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy( + update={"MODE": "live"} + ) + captured, _ = self._setup_cancel_mocks( + overseas_broker, {"rt_cd": "0", "msg1": "OK"} + ) + + await overseas_broker.cancel_overseas_order("NASD", "AAPL", "ORD001", 5) + + assert "TTTT1004U" in captured + + @pytest.mark.asyncio + async def test_us_paper_uses_vttt1004u( + self, overseas_broker: OverseasBroker + ) -> None: + """US exchange in paper mode should use VTTT1004U.""" + # Default mock_settings has MODE="paper" + captured, _ = self._setup_cancel_mocks( + overseas_broker, {"rt_cd": "0", "msg1": "OK"} + ) + + await overseas_broker.cancel_overseas_order("NASD", "AAPL", "ORD001", 5) + + assert "VTTT1004U" in captured + + @pytest.mark.asyncio + async def test_hk_live_uses_ttts1003u( + self, overseas_broker: OverseasBroker + ) -> None: + """SEHK exchange in live mode should use TTTS1003U.""" + overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy( + update={"MODE": "live"} + ) + captured, _ = self._setup_cancel_mocks( + overseas_broker, {"rt_cd": "0", "msg1": "OK"} + ) + + await overseas_broker.cancel_overseas_order("SEHK", "0700", "ORD002", 10) + + assert "TTTS1003U" in captured + + @pytest.mark.asyncio + async def test_cancel_sets_rvse_cncl_dvsn_cd_02( + self, overseas_broker: OverseasBroker + ) -> None: + """Cancel body must include RVSE_CNCL_DVSN_CD='02' and OVRS_ORD_UNPR='0'.""" + captured_body: list[dict] = [] + + async def mock_auth_headers(tr_id: str) -> dict: + return {} + + overseas_broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign] + overseas_broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign] + overseas_broker._broker._rate_limiter.acquire = AsyncMock() + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"rt_cd": "0"}) + + mock_session = MagicMock() + + def _capture_post(url: str, **kwargs: object) -> MagicMock: + captured_body.append(kwargs.get("json", {})) + return _make_async_cm(mock_resp) + + mock_session.post = MagicMock(side_effect=_capture_post) + overseas_broker._broker._get_session = MagicMock(return_value=mock_session) + + await overseas_broker.cancel_overseas_order("NASD", "AAPL", "ORD003", 3) + + assert captured_body[0]["RVSE_CNCL_DVSN_CD"] == "02" + assert captured_body[0]["OVRS_ORD_UNPR"] == "0" + assert captured_body[0]["ORGN_ODNO"] == "ORD003" + + @pytest.mark.asyncio + async def test_cancel_sets_hashkey_header( + self, overseas_broker: OverseasBroker + ) -> None: + """hashkey must be set in the request headers.""" + captured_headers: list[dict] = [] + overseas_broker._broker._get_hash_key = AsyncMock(return_value="test_hash") # type: ignore[method-assign] + overseas_broker._broker._rate_limiter.acquire = AsyncMock() + + async def mock_auth_headers(tr_id: str) -> dict: + return {"tr_id": tr_id} + + overseas_broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign] + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"rt_cd": "0"}) + + mock_session = MagicMock() + + def _capture_post(url: str, **kwargs: object) -> MagicMock: + captured_headers.append(dict(kwargs.get("headers", {}))) + return _make_async_cm(mock_resp) + + mock_session.post = MagicMock(side_effect=_capture_post) + overseas_broker._broker._get_session = MagicMock(return_value=mock_session) + + await overseas_broker.cancel_overseas_order("NASD", "AAPL", "ORD004", 2) + + assert captured_headers[0].get("hashkey") == "test_hash"