import pytest from unittest.mock import patch, MagicMock from datetime import datetime, timedelta from app.services.trading.kis_executor import ( KISTradeExecutor, OrderResult, AccountBalance, Position, ) @pytest.fixture def executor(): return KISTradeExecutor( app_key="test_key", app_secret="test_secret", account_no="12345678-01", paper_trade=True, ) def _mock_token_response(): mock_resp = MagicMock() mock_resp.json.return_value = { "access_token": "test_token_abc123", "expires_in": 86400, } mock_resp.raise_for_status = MagicMock() return mock_resp def _mock_order_response(success=True, order_no="ORD001"): mock_resp = MagicMock() if success: mock_resp.json.return_value = { "rt_cd": "0", "msg1": "정상처리", "output": {"ODNO": order_no}, } else: mock_resp.json.return_value = { "rt_cd": "1", "msg1": "주문 실패", } mock_resp.raise_for_status = MagicMock() return mock_resp def _mock_balance_response(): mock_resp = MagicMock() mock_resp.json.return_value = { "output1": [ { "pdno": "005930", "prdt_name": "삼성전자", "hldg_qty": "100", "pchs_avg_pric": "70000.00", "prpr": "72000", "evlu_pfls_amt": "200000", "evlu_pfls_rt": "2.86", }, ], "output2": [ { "tot_evlu_amt": "50000000", "dnca_tot_amt": "42800000", "scts_evlu_amt": "7200000", "evlu_pfls_smtl_amt": "200000", }, ], } mock_resp.raise_for_status = MagicMock() return mock_resp class TestKISBuyOrder: def test_buy_order_paper(self, executor): """Paper trade buy order should use correct tr_id and return success.""" with patch("app.services.trading.kis_executor.httpx.Client") as MockClient: mock_client = MagicMock() MockClient.return_value.__enter__ = MagicMock(return_value=mock_client) MockClient.return_value.__exit__ = MagicMock(return_value=False) # First call: token, Second call: order mock_client.post.side_effect = [ _mock_token_response(), _mock_order_response(success=True, order_no="ORD001"), ] result = executor.place_buy_order("005930", qty=10, price=70000) assert result.success is True assert result.order_no == "ORD001" assert result.ticker == "005930" assert result.order_type == "buy" assert result.qty == 10 # Verify the order call used paper trade tr_id order_call = mock_client.post.call_args_list[1] assert "VTTC0802U" in str(order_call) class TestKISSellOrder: def test_sell_order_paper(self, executor): """Paper trade sell order.""" with patch("app.services.trading.kis_executor.httpx.Client") as MockClient: mock_client = MagicMock() MockClient.return_value.__enter__ = MagicMock(return_value=mock_client) MockClient.return_value.__exit__ = MagicMock(return_value=False) mock_client.post.side_effect = [ _mock_token_response(), _mock_order_response(success=True, order_no="ORD002"), ] result = executor.place_sell_order("005930", qty=5, price=72000) assert result.success is True assert result.order_no == "ORD002" assert result.order_type == "sell" class TestKISTokenRefresh: def test_token_refresh(self, executor): """Token should be fetched on first call and cached for subsequent calls.""" with patch("app.services.trading.kis_executor.httpx.Client") as MockClient: mock_client = MagicMock() MockClient.return_value.__enter__ = MagicMock(return_value=mock_client) MockClient.return_value.__exit__ = MagicMock(return_value=False) mock_client.post.side_effect = [ _mock_token_response(), _mock_order_response(), # Second order should reuse token - no token call _mock_order_response(order_no="ORD003"), ] executor.place_buy_order("005930", qty=1, price=70000) # Token should now be cached assert executor._access_token == "test_token_abc123" assert executor._token_expires_at is not None executor.place_buy_order("000660", qty=1, price=130000) # Should have only called token once (2 token + 2 order = no, 1 token + 2 order = 3 calls) assert mock_client.post.call_count == 3 def test_token_expired_refresh(self, executor): """Expired token should trigger refresh.""" executor._access_token = "old_token" executor._token_expires_at = datetime.now() - timedelta(hours=1) assert executor._is_token_valid is False def test_token_valid(self, executor): """Valid token should not trigger refresh.""" executor._access_token = "valid_token" executor._token_expires_at = datetime.now() + timedelta(hours=12) assert executor._is_token_valid is True class TestKISBalance: def test_get_balance(self, executor): """Get account balance with positions.""" with patch("app.services.trading.kis_executor.httpx.Client") as MockClient: mock_client = MagicMock() MockClient.return_value.__enter__ = MagicMock(return_value=mock_client) MockClient.return_value.__exit__ = MagicMock(return_value=False) mock_client.post.return_value = _mock_token_response() mock_client.get.return_value = _mock_balance_response() balance = executor.get_account_balance() assert balance.total_amount == 50_000_000 assert balance.available_amount == 42_800_000 assert len(balance.positions) == 1 assert balance.positions[0].ticker == "005930" assert balance.positions[0].qty == 100 class TestKISAccountParsing: def test_account_no_parsing(self): """Account number should be split correctly.""" exec1 = KISTradeExecutor("k", "s", "12345678-01") assert exec1._cano == "12345678" assert exec1._acnt_prdt_cd == "01" def test_paper_trade_url(self): """Paper trade should use paper URL.""" exec1 = KISTradeExecutor("k", "s", "12345678-01", paper_trade=True) assert exec1._base_url == KISTradeExecutor.BASE_URL_PAPER exec2 = KISTradeExecutor("k", "s", "12345678-01", paper_trade=False) assert exec2._base_url == KISTradeExecutor.BASE_URL_REAL