feat: 실전 투자 전환 — TR_ID 분기, URL, 신뢰도 임계값, 텔레그램 알림 수정 (#201~#205, #208, #214)
Some checks failed
CI / test (pull_request) Has been cancelled

- #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 <noreply@anthropic.com>
This commit is contained in:
agentson
2026-02-23 10:28:24 +09:00
parent cd36d53a47
commit d6a389e0b7
7 changed files with 379 additions and 22 deletions

View File

@@ -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"

View File

@@ -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")

View File

@@ -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