Some checks failed
Deploy to Production / deploy (push) Failing after 6m46s
- 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)
194 lines
6.8 KiB
Python
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
|