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..5951617 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, @@ -1041,11 +1047,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 +1986,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 +2307,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_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