From d6a389e0b79670bdaf65ca510baa83a88b7bce92 Mon Sep 17 00:00:00 2001 From: agentson Date: Mon, 23 Feb 2026 10:28:24 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=8B=A4=EC=A0=84=20=ED=88=AC?= =?UTF-8?q?=EC=9E=90=20=EC=A0=84=ED=99=98=20=E2=80=94=20TR=5FID=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0,=20URL,=20=EC=8B=A0=EB=A2=B0=EB=8F=84=20?= =?UTF-8?q?=EC=9E=84=EA=B3=84=EA=B0=92,=20=ED=85=94=EB=A0=88=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20=EC=95=8C=EB=A6=BC=20=EC=88=98=EC=A0=95=20(#201~#20?= =?UTF-8?q?5,=20#208,=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 From 3c5f1752e6b1138bc633d18ac9d5ea711d7fa11c Mon Sep 17 00:00:00 2001 From: agentson Date: Mon, 23 Feb 2026 10:30:47 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20DB=20WAL=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9,=20.env.example=20=EC=A0=95=EB=A6=AC=20(#210?= =?UTF-8?q?,=20#213,=20#216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #210: init_db()에 WAL 저널 모드 적용 (파일 DB에만, :memory: 제외) - 대시보드(READ)와 거래루프(WRITE) 동시 접근 시 SQLite 락 오류 방지 - busy_timeout=5000ms 설정 - #213: RATE_LIMIT_RPS 기본값 2.0으로 통일 (.env.example이 5.0으로 잘못 표기됨) - #216: .env.example 중요 변수 추가 및 정리 - KIS_BASE_URL 모의/실전 URL 주석 명시 (포트 29443 수정 포함) - MODE, TRADE_MODE, ENABLED_MARKETS, PAPER_OVERSEAS_CASH 추가 - GEMINI_MODEL 업데이트 (gemini-pro → gemini-2.0-flash-exp) - DASHBOARD 설정 섹션 추가 테스트 2개 추가 (WAL 파일 DB 적용, 메모리 DB 미적용 검증) Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 64 +++++++++++++++++++++++++++++++++++++++++------- src/db.py | 5 ++++ tests/test_db.py | 37 ++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) 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/db.py b/src/db.py index 60dae11..5b23a38 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/tests/test_db.py b/tests/test_db.py index fe956eb..af2fcf8 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 @@ -58,3 +61,37 @@ def test_get_open_position_returns_none_when_latest_is_sell() -> None: def test_get_open_position_returns_none_when_no_trades() -> None: conn = init_db(":memory:") 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() From 16b9b6832dafca392f158499ef10abdd9dae8814 Mon Sep 17 00:00:00 2001 From: agentson Date: Mon, 23 Feb 2026 12:30:51 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20BULLISH=20confidence=20=EC=9E=84?= =?UTF-8?q?=EA=B3=84=EA=B0=92=2075=EB=A1=9C=20=EB=B3=B5=EC=9B=90=20(#205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md 규칙 개정에 따라 BULLISH 시장은 75로 유지. 시장 전망별 임계값: BEARISH=90, NEUTRAL=80, BULLISH=75. Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 3 ++- tests/test_main.py | 14 ++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/main.py b/src/main.py index 6beb178..5951617 100644 --- a/src/main.py +++ b/src/main.py @@ -505,8 +505,9 @@ 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( diff --git a/tests/test_main.py b/tests/test_main.py index ccafd90..f00467f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2729,18 +2729,13 @@ class TestMarketOutlookConfidenceThreshold: assert call_args.kwargs["action"] == "BUY" @pytest.mark.asyncio - async def test_bullish_outlook_uses_same_threshold_as_neutral( + async def test_bullish_outlook_lowers_buy_confidence_threshold( self, mock_broker: MagicMock, mock_market: MagicMock, mock_telegram: MagicMock, ) -> None: - """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) - """ + """BUY with confidence 77 should proceed in bullish market (threshold=75).""" engine = MagicMock(spec=ScenarioEngine) engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(77)) playbook = self._make_playbook_with_outlook("bullish") @@ -2772,8 +2767,7 @@ class TestMarketOutlookConfidenceThreshold: call_args = decision_logger.log_decision.call_args assert call_args is not None - # confidence 77 < 80 → must be suppressed to HOLD - assert call_args.kwargs["action"] == "HOLD" + assert call_args.kwargs["action"] == "BUY" @pytest.mark.asyncio async def test_bullish_outlook_suppresses_very_low_confidence_buy( @@ -2782,7 +2776,7 @@ class TestMarketOutlookConfidenceThreshold: mock_market: MagicMock, mock_telegram: MagicMock, ) -> None: - """BUY with confidence 70 should be suppressed even in bullish market (threshold=80).""" + """BUY with confidence 70 should be suppressed even in bullish market (threshold=75).""" engine = MagicMock(spec=ScenarioEngine) engine.evaluate = MagicMock(return_value=self._make_buy_match_with_confidence(70)) playbook = self._make_playbook_with_outlook("bullish")