diff --git a/.env.example b/.env.example index ac91c53..218d0b6 100644 --- a/.env.example +++ b/.env.example @@ -1,36 +1,82 @@ +# ============================================================ +# The Ouroboros — Environment Configuration +# ============================================================ +# Copy this file to .env and fill in your values. +# Lines starting with # are comments. + +# ============================================================ # Korea Investment Securities API +# ============================================================ KIS_APP_KEY=your_app_key_here KIS_APP_SECRET=your_app_secret_here KIS_ACCOUNT_NO=12345678-01 -KIS_BASE_URL=https://openapivts.koreainvestment.com:9443 +# Paper trading (VTS): https://openapivts.koreainvestment.com:29443 +# Live trading: https://openapi.koreainvestment.com:9443 +KIS_BASE_URL=https://openapivts.koreainvestment.com:29443 + +# ============================================================ +# Trading Mode +# ============================================================ +# paper = 모의투자 (safe for testing), live = 실전투자 (real money) +MODE=paper + +# daily = batch per session, realtime = per-stock continuous scan +TRADE_MODE=daily + +# Comma-separated market codes: KR, US, JP, HK, CN, VN +ENABLED_MARKETS=KR,US + +# Simulated USD cash for paper (VTS) overseas trading. +# VTS overseas balance API often returns 0; this value is used as fallback. +# Set to 0 to disable fallback (not used in live mode). +PAPER_OVERSEAS_CASH=50000.0 + +# ============================================================ # Google Gemini +# ============================================================ GEMINI_API_KEY=your_gemini_api_key_here -GEMINI_MODEL=gemini-pro +# Recommended: gemini-2.0-flash-exp or gemini-1.5-pro +GEMINI_MODEL=gemini-2.0-flash-exp +# ============================================================ # Risk Management +# ============================================================ CIRCUIT_BREAKER_PCT=-3.0 FAT_FINGER_PCT=30.0 CONFIDENCE_THRESHOLD=80 +# ============================================================ # Database +# ============================================================ DB_PATH=data/trade_logs.db -# Rate Limiting (requests per second for KIS API) -# Reduced to 5.0 to avoid "초당 거래건수 초과" errors (EGW00201) -RATE_LIMIT_RPS=5.0 +# ============================================================ +# Rate Limiting +# ============================================================ +# KIS API real limit is ~2 RPS. Keep at 2.0 for maximum safety. +# Increasing this risks EGW00201 "초당 거래건수 초과" errors. +RATE_LIMIT_RPS=2.0 -# Trading Mode (paper / live) -MODE=paper - -# External Data APIs (optional — for enhanced decision-making) +# ============================================================ +# External Data APIs (optional) +# ============================================================ # NEWS_API_KEY=your_news_api_key_here # NEWS_API_PROVIDER=alphavantage # MARKET_DATA_API_KEY=your_market_data_key_here +# ============================================================ # Telegram Notifications (optional) +# ============================================================ # Get bot token from @BotFather on Telegram # Get chat ID from @userinfobot or your chat # TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz # TELEGRAM_CHAT_ID=123456789 # TELEGRAM_ENABLED=true + +# ============================================================ +# Dashboard (optional) +# ============================================================ +# DASHBOARD_ENABLED=false +# DASHBOARD_HOST=127.0.0.1 +# DASHBOARD_PORT=8080 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/db.py b/src/db.py index 6149eb5..fc2070e 100644 --- a/src/db.py +++ b/src/db.py @@ -14,6 +14,11 @@ def init_db(db_path: str) -> sqlite3.Connection: if db_path != ":memory:": Path(db_path).parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(db_path) + # Enable WAL mode for concurrent read/write (dashboard + trading loop). + # WAL does not apply to in-memory databases. + if db_path != ":memory:": + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") conn.execute( """ CREATE TABLE IF NOT EXISTS trades ( diff --git a/src/main.py b/src/main.py index 3cfc25a..35acfa0 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, @@ -1042,11 +1048,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 % @@ -1981,6 +1988,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: @@ -2298,6 +2309,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_db.py b/tests/test_db.py index ad65e27..ead224a 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,5 +1,8 @@ """Tests for database helper functions.""" +import tempfile +import os + from src.db import get_open_position, init_db, log_trade @@ -60,6 +63,40 @@ def test_get_open_position_returns_none_when_no_trades() -> None: assert get_open_position(conn, "AAPL", "US_NASDAQ") is None +# --------------------------------------------------------------------------- +# WAL mode tests (issue #210) +# --------------------------------------------------------------------------- + + +def test_wal_mode_applied_to_file_db() -> None: + """File-based DB must use WAL journal mode for dashboard concurrent reads.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + db_path = f.name + try: + conn = init_db(db_path) + cursor = conn.execute("PRAGMA journal_mode") + mode = cursor.fetchone()[0] + assert mode == "wal", f"Expected WAL mode, got {mode}" + conn.close() + finally: + os.unlink(db_path) + # Clean up WAL auxiliary files if they exist + for ext in ("-wal", "-shm"): + path = db_path + ext + if os.path.exists(path): + os.unlink(path) + + +def test_wal_mode_not_applied_to_memory_db() -> None: + """:memory: DB must not apply WAL (SQLite does not support WAL for in-memory).""" + conn = init_db(":memory:") + cursor = conn.execute("PRAGMA journal_mode") + mode = cursor.fetchone()[0] + # In-memory DBs default to 'memory' journal mode + assert mode != "wal", "WAL should not be set on in-memory database" + conn.close() + + # --------------------------------------------------------------------------- # mode column tests (issue #212) # --------------------------------------------------------------------------- @@ -122,14 +159,12 @@ def test_mode_column_exists_in_schema() -> None: def test_mode_migration_adds_column_to_existing_db() -> None: """init_db must add mode column to existing DBs that lack it (migration).""" - import tempfile - import os + import sqlite3 with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: db_path = f.name try: # Create DB without mode column (simulate old schema) - import sqlite3 old_conn = sqlite3.connect(db_path) old_conn.execute( """CREATE TABLE trades ( 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