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"