galaxis-po/backend/tests/unit/test_kis_executor.py
머니페니 34d09d9d34
Some checks failed
Deploy to Production / deploy (push) Failing after 6m46s
feat: 김종봉식 KOSPI 종목발굴 전략 구현
- KOSPIMarketStateDetector: KOSPI MA 기반 시장 상태 판단 (bull/neutral/bear/crash)
- VolumeScreener: 거래대금 2000억+ 스크리닝 (상한가 우선, 희소성 체크, 대형주 예외)
- SectorPortfolioManager: 섹터 기반 비중 배분
- KJBScreeningSignalGenerator: 눌림목 진입, 5MA 손절, 단계적 익절
- KISTradeExecutor: KIS API 자동 매수/매도 (기본값 모의투자)
- ScreeningSignal / AutoOrder DB 모델 추가
- screening API 엔드포인트 추가
- 스케줄러 잡 3종 추가 (08:30/5분/15:35)
- Price.trading_value 컬럼 추가
- MarketIndex 테이블 추가 (KOSPI/KOSDAQ 지수 일봉)
- IndexCollector 추가 (일일 수집 잡 등록)
- intraday_exit_check 시간 필터 추가 (09:05~15:20 KST)
- 드라이런 스크립트 추가 (scripts/screening_dryrun.py)
2026-05-05 23:03:53 +09:00

194 lines
6.8 KiB
Python

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