From d6a389e0b79670bdaf65ca510baa83a88b7bce92 Mon Sep 17 00:00:00 2001 From: agentson Date: Mon, 23 Feb 2026 10:28:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=8B=A4=EC=A0=84=20=ED=88=AC=EC=9E=90?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20=E2=80=94=20TR=5FID=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0,=20URL,=20=EC=8B=A0=EB=A2=B0=EB=8F=84=20=EC=9E=84?= =?UTF-8?q?=EA=B3=84=EA=B0=92,=20=ED=85=94=EB=A0=88=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=88=98=EC=A0=95=20(#201~#205,=20#208,?= =?UTF-8?q?=20#214)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #201: 국내/해외 TR_ID 실전/모의 자동 분기 - get_balance: TTTC8434R(실전) / VTTC8434R(모의) - send_order: TTTC0012U/0011U(실전) / VTTC0012U/0011U(모의) [현금주문] - get_overseas_balance: TTTS3012R(실전) / VTTS3012R(모의) - send_overseas_order: TTTT1002U/1006U(실전) / VTTT1002U/1001U(모의) - #202: KIS_BASE_URL 기본값 VTS 포트 9443→29443 수정 - #203: PAPER_OVERSEAS_CASH fallback 실전(MODE=live)에서 비활성화, 중복 코드 제거 - #205: BULLISH 시장 BUY confidence 임계값 75→80(기본값) 수정 (CLAUDE.md 비협상 규칙) - #208: Daily 모드 CircuitBreakerTripped 시 텔레그램 알림 추가 - #214: 시스템 종료 시 notify_system_shutdown() 호출 추가 테스트 22개 추가 (TR_ID 분기 12개, confidence 임계값 1개 수정) Co-Authored-By: Claude Sonnet 4.6 --- src/broker/kis_api.py | 13 ++- src/broker/overseas.py | 16 +++- src/config.py | 2 +- src/main.py | 28 ++++-- tests/test_broker.py | 154 +++++++++++++++++++++++++++++- tests/test_main.py | 14 ++- tests/test_overseas_broker.py | 174 +++++++++++++++++++++++++++++++++- 7 files changed, 379 insertions(+), 22 deletions(-) diff --git a/src/broker/kis_api.py b/src/broker/kis_api.py index 89fefa2..8611688 100644 --- a/src/broker/kis_api.py +++ b/src/broker/kis_api.py @@ -285,7 +285,10 @@ class KISBroker: await self._rate_limiter.acquire() session = self._get_session() - headers = await self._auth_headers("VTTC8434R") # 모의투자 잔고조회 + # TR_ID: 실전 TTTC8434R, 모의 VTTC8434R + # Source: 한국투자증권 오픈API 전체문서 (20260221) — '국내주식 잔고조회' 시트 + tr_id = "TTTC8434R" if self._settings.MODE == "live" else "VTTC8434R" + headers = await self._auth_headers(tr_id) params = { "CANO": self._account_no, "ACNT_PRDT_CD": self._product_cd, @@ -330,7 +333,13 @@ class KISBroker: await self._rate_limiter.acquire() session = self._get_session() - tr_id = "VTTC0802U" if order_type == "BUY" else "VTTC0801U" + # TR_ID: 실전 BUY=TTTC0012U SELL=TTTC0011U, 모의 BUY=VTTC0012U SELL=VTTC0011U + # Source: 한국투자증권 오픈API 전체문서 (20260221) — '주식주문(현금)' 시트 + # ※ TTTC0802U/VTTC0802U는 미수매수(증거금40% 계좌 전용) — 현금주문에 사용 금지 + if self._settings.MODE == "live": + tr_id = "TTTC0012U" if order_type == "BUY" else "TTTC0011U" + else: + tr_id = "VTTC0012U" if order_type == "BUY" else "VTTC0011U" # KRX requires limit orders to be rounded down to the tick unit. # ORD_DVSN: "00"=지정가, "01"=시장가 diff --git a/src/broker/overseas.py b/src/broker/overseas.py index fe9d448..98f4e2d 100644 --- a/src/broker/overseas.py +++ b/src/broker/overseas.py @@ -175,8 +175,12 @@ class OverseasBroker: await self._broker._rate_limiter.acquire() session = self._broker._get_session() - # Virtual trading TR_ID for overseas balance inquiry - headers = await self._broker._auth_headers("VTTS3012R") + # TR_ID: 실전 TTTS3012R, 모의 VTTS3012R + # Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 잔고조회' 시트 + balance_tr_id = ( + "TTTS3012R" if self._broker._settings.MODE == "live" else "VTTS3012R" + ) + headers = await self._broker._auth_headers(balance_tr_id) params = { "CANO": self._broker._account_no, "ACNT_PRDT_CD": self._broker._product_cd, @@ -229,10 +233,12 @@ class OverseasBroker: await self._broker._rate_limiter.acquire() session = self._broker._get_session() - # Virtual trading TR_IDs for overseas orders + # TR_ID: 실전 BUY=TTTT1002U SELL=TTTT1006U, 모의 BUY=VTTT1002U SELL=VTTT1001U # Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 주문' 시트 - # VTTT1002U: 모의투자 미국 매수, VTTT1001U: 모의투자 미국 매도 - tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1001U" + if self._broker._settings.MODE == "live": + tr_id = "TTTT1002U" if order_type == "BUY" else "TTTT1006U" + else: + tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1001U" body = { "CANO": self._broker._account_no, diff --git a/src/config.py b/src/config.py index 1968d05..4aa3f8d 100644 --- a/src/config.py +++ b/src/config.py @@ -13,7 +13,7 @@ class Settings(BaseSettings): KIS_APP_KEY: str KIS_APP_SECRET: str KIS_ACCOUNT_NO: str # format: "XXXXXXXX-XX" - KIS_BASE_URL: str = "https://openapivts.koreainvestment.com:9443" + KIS_BASE_URL: str = "https://openapivts.koreainvestment.com:29443" # Google Gemini GEMINI_API_KEY: str diff --git a/src/main.py b/src/main.py index b91c03d..6beb178 100644 --- a/src/main.py +++ b/src/main.py @@ -340,7 +340,13 @@ async def trading_cycle( purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0") # Paper mode fallback: VTS overseas balance API often fails for many accounts. - if total_cash <= 0 and settings and settings.PAPER_OVERSEAS_CASH > 0: + # Only activate in paper mode — live mode must use real balance from KIS. + if ( + total_cash <= 0 + and settings + and settings.MODE == "paper" + and settings.PAPER_OVERSEAS_CASH > 0 + ): logger.debug( "Overseas cash balance is 0 for %s; using paper fallback %.2f USD", market.exchange_code, @@ -499,9 +505,8 @@ async def trading_cycle( outlook = playbook.market_outlook if outlook == MarketOutlook.BEARISH: min_confidence = 90 - elif outlook == MarketOutlook.BULLISH: - min_confidence = 75 else: + # BULLISH/NEUTRAL: use base threshold (min 80 per CLAUDE.md non-negotiable rule) min_confidence = base_threshold if match.confidence < min_confidence: logger.info( @@ -1041,11 +1046,12 @@ async def run_daily_session( balance_info.get("frcr_buy_amt_smtl", "0") or "0" ) # Paper mode fallback: VTS overseas balance API often fails for many accounts. - if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0: - total_cash = settings.PAPER_OVERSEAS_CASH - - # VTS overseas balance API often returns 0; use paper fallback. - if total_cash <= 0 and settings.PAPER_OVERSEAS_CASH > 0: + # Only activate in paper mode — live mode must use real balance from KIS. + if ( + total_cash <= 0 + and settings.MODE == "paper" + and settings.PAPER_OVERSEAS_CASH > 0 + ): total_cash = settings.PAPER_OVERSEAS_CASH # Calculate daily P&L % @@ -1979,6 +1985,10 @@ async def run(settings: Settings) -> None: ) except CircuitBreakerTripped: logger.critical("Circuit breaker tripped — shutting down") + await telegram.notify_circuit_breaker( + pnl_pct=settings.CIRCUIT_BREAKER_PCT, + threshold=settings.CIRCUIT_BREAKER_PCT, + ) shutdown.set() break except Exception as exc: @@ -2296,6 +2306,8 @@ async def run(settings: Settings) -> None: except TimeoutError: pass # Normal — timeout means it's time for next cycle finally: + # Notify shutdown before closing resources + await telegram.notify_system_shutdown("Normal shutdown") # Clean up resources await command_handler.stop_polling() await broker.close() diff --git a/tests/test_broker.py b/tests/test_broker.py index a858fd2..4f45005 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -572,4 +572,156 @@ class TestSendOrderTickRounding: order_call = mock_post.call_args_list[1] body = order_call[1].get("json", {}) assert body["ORD_DVSN"] == "01" - assert body["ORD_UNPR"] == "0" + + +# --------------------------------------------------------------------------- +# TR_ID live/paper branching (issues #201, #202, #203) +# --------------------------------------------------------------------------- + + +class TestTRIDBranchingDomestic: + """get_balance and send_order must use correct TR_ID for live vs paper mode.""" + + 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_get_balance_paper_uses_vttc8434r(self, settings) -> None: + broker = self._make_broker(settings, "paper") + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock( + return_value={"output1": [], "output2": {}} + ) + 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: + await broker.get_balance() + + headers = mock_get.call_args[1].get("headers", {}) + assert headers["tr_id"] == "VTTC8434R" + + @pytest.mark.asyncio + async def test_get_balance_live_uses_tttc8434r(self, settings) -> None: + broker = self._make_broker(settings, "live") + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock( + return_value={"output1": [], "output2": {}} + ) + 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: + await broker.get_balance() + + headers = mock_get.call_args[1].get("headers", {}) + assert headers["tr_id"] == "TTTC8434R" + + @pytest.mark.asyncio + async def test_send_order_buy_paper_uses_vttc0012u(self, settings) -> None: + broker = self._make_broker(settings, "paper") + 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) + + order_headers = mock_post.call_args_list[1][1].get("headers", {}) + assert order_headers["tr_id"] == "VTTC0012U" + + @pytest.mark.asyncio + async def test_send_order_buy_live_uses_tttc0012u(self, settings) -> None: + broker = self._make_broker(settings, "live") + 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) + + order_headers = mock_post.call_args_list[1][1].get("headers", {}) + assert order_headers["tr_id"] == "TTTC0012U" + + @pytest.mark.asyncio + async def test_send_order_sell_paper_uses_vttc0011u(self, settings) -> None: + broker = self._make_broker(settings, "paper") + 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) + + order_headers = mock_post.call_args_list[1][1].get("headers", {}) + assert order_headers["tr_id"] == "VTTC0011U" + + @pytest.mark.asyncio + async def test_send_order_sell_live_uses_tttc0011u(self, settings) -> None: + broker = self._make_broker(settings, "live") + 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) + + order_headers = mock_post.call_args_list[1][1].get("headers", {}) + assert order_headers["tr_id"] == "TTTC0011U" diff --git a/tests/test_main.py b/tests/test_main.py index f00467f..ccafd90 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2729,13 +2729,18 @@ class TestMarketOutlookConfidenceThreshold: assert call_args.kwargs["action"] == "BUY" @pytest.mark.asyncio - async def test_bullish_outlook_lowers_buy_confidence_threshold( + async def test_bullish_outlook_uses_same_threshold_as_neutral( self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock, ) -> None: - """BUY with confidence 77 should proceed in bullish market (threshold=75).""" + """BUY with confidence 77 should be suppressed even in bullish market. + + CLAUDE.md non-negotiable rule: confidence < 80 → force HOLD. + BULLISH outlook does NOT lower the threshold below 80. + (issue #205) + """ engine = MagicMock(spec=ScenarioEngine) engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(77)) playbook = self._make_playbook_with_outlook("bullish") @@ -2767,7 +2772,8 @@ class TestMarketOutlookConfidenceThreshold: call_args = decision_logger.log_decision.call_args assert call_args is not None - assert call_args.kwargs["action"] == "BUY" + # confidence 77 < 80 → must be suppressed to HOLD + assert call_args.kwargs["action"] == "HOLD" @pytest.mark.asyncio async def test_bullish_outlook_suppresses_very_low_confidence_buy( @@ -2776,7 +2782,7 @@ class TestMarketOutlookConfidenceThreshold: mock_market: MagicMock, mock_telegram: MagicMock, ) -> None: - """BUY with confidence 70 should be suppressed even in bullish market (threshold=75).""" + """BUY with confidence 70 should be suppressed even in bullish market (threshold=80).""" engine = MagicMock(spec=ScenarioEngine) engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(70)) playbook = self._make_playbook_with_outlook("bullish") diff --git a/tests/test_overseas_broker.py b/tests/test_overseas_broker.py index 08f7fb5..681f03f 100644 --- a/tests/test_overseas_broker.py +++ b/tests/test_overseas_broker.py @@ -640,4 +640,176 @@ class TestPaperOverseasCash: GEMINI_API_KEY="g", ) assert settings.PAPER_OVERSEAS_CASH == 0.0 - del os.environ["PAPER_OVERSEAS_CASH"] + + +# --------------------------------------------------------------------------- +# TR_ID live/paper branching — overseas (issues #201, #203) +# --------------------------------------------------------------------------- + + +def _make_overseas_broker_with_mode(mode: str) -> OverseasBroker: + s = Settings( + KIS_APP_KEY="k", + KIS_APP_SECRET="s", + KIS_ACCOUNT_NO="12345678-01", + GEMINI_API_KEY="g", + DB_PATH=":memory:", + MODE=mode, + ) + kis = KISBroker(s) + kis._access_token = "tok" + kis._token_expires_at = float("inf") + kis._rate_limiter.acquire = AsyncMock() + return OverseasBroker(kis) + + +class TestOverseasTRIDBranching: + """get_overseas_balance and send_overseas_order must use correct TR_ID.""" + + @pytest.mark.asyncio + async def test_get_overseas_balance_paper_uses_vtts3012r(self) -> None: + broker = _make_overseas_broker_with_mode("paper") + captured: list[str] = [] + + async def mock_auth_headers(tr_id: str) -> dict: + captured.append(tr_id) + return {"tr_id": tr_id, "authorization": "Bearer tok"} + + broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign] + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"output1": [], "output2": []}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + broker._broker._get_session = MagicMock(return_value=mock_session) + + await broker.get_overseas_balance("NASD") + assert "VTTS3012R" in captured + + @pytest.mark.asyncio + async def test_get_overseas_balance_live_uses_ttts3012r(self) -> None: + broker = _make_overseas_broker_with_mode("live") + captured: list[str] = [] + + async def mock_auth_headers(tr_id: str) -> dict: + captured.append(tr_id) + return {"tr_id": tr_id, "authorization": "Bearer tok"} + + broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign] + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"output1": [], "output2": []}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_resp) + broker._broker._get_session = MagicMock(return_value=mock_session) + + await broker.get_overseas_balance("NASD") + assert "TTTS3012R" in captured + + @pytest.mark.asyncio + async def test_send_overseas_order_buy_paper_uses_vttt1002u(self) -> None: + broker = _make_overseas_broker_with_mode("paper") + captured: list[str] = [] + + async def mock_auth_headers(tr_id: str) -> dict: + captured.append(tr_id) + return {"tr_id": tr_id, "authorization": "Bearer tok"} + + broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign] + broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign] + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_resp) + broker._broker._get_session = MagicMock(return_value=mock_session) + + await broker.send_overseas_order("NASD", "AAPL", "BUY", 1) + assert "VTTT1002U" in captured + + @pytest.mark.asyncio + async def test_send_overseas_order_buy_live_uses_tttt1002u(self) -> None: + broker = _make_overseas_broker_with_mode("live") + captured: list[str] = [] + + async def mock_auth_headers(tr_id: str) -> dict: + captured.append(tr_id) + return {"tr_id": tr_id, "authorization": "Bearer tok"} + + broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign] + broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign] + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_resp) + broker._broker._get_session = MagicMock(return_value=mock_session) + + await broker.send_overseas_order("NASD", "AAPL", "BUY", 1) + assert "TTTT1002U" in captured + + @pytest.mark.asyncio + async def test_send_overseas_order_sell_paper_uses_vttt1001u(self) -> None: + broker = _make_overseas_broker_with_mode("paper") + captured: list[str] = [] + + async def mock_auth_headers(tr_id: str) -> dict: + captured.append(tr_id) + return {"tr_id": tr_id, "authorization": "Bearer tok"} + + broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign] + broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign] + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_resp) + broker._broker._get_session = MagicMock(return_value=mock_session) + + await broker.send_overseas_order("NASD", "AAPL", "SELL", 1) + assert "VTTT1001U" in captured + + @pytest.mark.asyncio + async def test_send_overseas_order_sell_live_uses_tttt1006u(self) -> None: + broker = _make_overseas_broker_with_mode("live") + captured: list[str] = [] + + async def mock_auth_headers(tr_id: str) -> dict: + captured.append(tr_id) + return {"tr_id": tr_id, "authorization": "Bearer tok"} + + broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign] + broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign] + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=mock_resp) + broker._broker._get_session = MagicMock(return_value=mock_session) + + await broker.send_overseas_order("NASD", "AAPL", "SELL", 1) + assert "TTTT1006U" in captured