Skip to content

Commit 9e5ef5e

Browse files
committed
Update kis.py and add tests for manual holdings integration
- Rename fields for `ManualHolding` (`name` -> `display_name`, `average_price` -> `avg_price`) to improve consistency. - Include tests to verify merging manual holdings with KIS holdings, skipping duplicates, and fetching real-time prices. - Add comprehensive test coverage for API calls and integration scenarios.
1 parent 35e2bda commit 9e5ef5e

File tree

2 files changed

+288
-3
lines changed

2 files changed

+288
-3
lines changed

app/tasks/kis.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -450,10 +450,10 @@ async def _run() -> dict:
450450
# 수동 잔고 종목을 my_stocks에 추가 (한투 형식으로 변환)
451451
my_stocks.append({
452452
'pdno': ticker,
453-
'prdt_name': holding.name or ticker,
453+
'prdt_name': holding.display_name or ticker,
454454
'hldg_qty': str(holding.quantity),
455-
'pchs_avg_pric': str(holding.average_price),
456-
'prpr': str(holding.average_price), # 현재가는 나중에 API로 조회
455+
'pchs_avg_pric': str(holding.avg_price),
456+
'prpr': str(holding.avg_price), # 현재가는 나중에 API로 조회
457457
'_is_manual': True # 수동 잔고 표시
458458
})
459459

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
"""
2+
Tests for manual holdings integration in KIS automation tasks.
3+
4+
수동 잔고(토스 등)를 자동화 태스크에 통합하는 기능 테스트
5+
"""
6+
import pytest
7+
from decimal import Decimal
8+
from unittest.mock import AsyncMock, MagicMock, patch
9+
10+
11+
@pytest.fixture
12+
def mock_manual_holding():
13+
"""Create a mock ManualHolding object"""
14+
holding = MagicMock()
15+
holding.ticker = "005930"
16+
holding.display_name = "삼성전자"
17+
holding.quantity = Decimal("10")
18+
holding.average_price = Decimal("70000")
19+
holding.avg_price = Decimal("70000") # alias
20+
return holding
21+
22+
23+
class TestManualHoldingsIntegration:
24+
"""수동 잔고 통합 테스트"""
25+
26+
def test_manual_holdings_merged_with_kis_holdings(self, monkeypatch):
27+
"""수동 잔고가 KIS 잔고와 병합되어 처리되는지 확인"""
28+
from app.tasks import kis as kis_tasks
29+
from decimal import Decimal
30+
31+
class DummyAnalyzer:
32+
async def analyze_stock_json(self, name):
33+
return {"decision": "hold", "confidence": 65}, "gemini-2.5-pro"
34+
35+
async def close(self):
36+
return None
37+
38+
class DummyKIS:
39+
async def fetch_my_stocks(self):
40+
# KIS에는 삼성전자우만 있음
41+
return [
42+
{
43+
"pdno": "005935",
44+
"prdt_name": "삼성전자우",
45+
"pchs_avg_pric": "73800",
46+
"prpr": "75850",
47+
"hldg_qty": "5",
48+
}
49+
]
50+
51+
async def inquire_korea_orders(self, *args, **kwargs):
52+
return []
53+
54+
async def cancel_korea_order(self, *args, **kwargs):
55+
return {"odno": "0000001"}
56+
57+
async def fetch_fundamental_info(self, code):
58+
# 수동 잔고 종목의 현재가 조회
59+
if code == "005930":
60+
return {"종목명": "삼성전자", "현재가": 71000}
61+
return {"종목명": "Unknown", "현재가": 0}
62+
63+
# Mock manual holding (토스에만 있는 삼성전자)
64+
manual_holding = MagicMock()
65+
manual_holding.ticker = "005930"
66+
manual_holding.display_name = "삼성전자"
67+
manual_holding.quantity = Decimal("10")
68+
manual_holding.avg_price = Decimal("70000")
69+
70+
class MockManualService:
71+
def __init__(self, db):
72+
pass
73+
74+
async def get_holdings_by_user(self, user_id, market_type):
75+
# 토스에 삼성전자가 있음
76+
return [manual_holding]
77+
78+
buy_calls = []
79+
sell_calls = []
80+
81+
async def fake_buy(kis, symbol, current_price, avg_price):
82+
buy_calls.append({"symbol": symbol})
83+
return {"success": False, "message": "조건 미충족", "orders_placed": 0}
84+
85+
async def fake_sell(kis, symbol, current_price, avg_price, qty):
86+
sell_calls.append({"symbol": symbol, "qty": qty})
87+
return {"success": False, "message": "조건 미충족", "orders_placed": 0}
88+
89+
# Mock DB session
90+
mock_db_session = MagicMock()
91+
mock_db_session.__aenter__ = AsyncMock(return_value=MagicMock())
92+
mock_db_session.__aexit__ = AsyncMock(return_value=None)
93+
94+
with patch('app.core.db.AsyncSessionLocal', return_value=mock_db_session), \
95+
patch('app.services.manual_holdings_service.ManualHoldingsService', MockManualService):
96+
97+
monkeypatch.setattr(kis_tasks, "KISClient", DummyKIS)
98+
monkeypatch.setattr(kis_tasks, "KISAnalyzer", DummyAnalyzer)
99+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_buy_orders_with_analysis", fake_buy)
100+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_sell_orders_with_analysis", fake_sell)
101+
monkeypatch.setattr(
102+
kis_tasks.run_per_domestic_stock_automation,
103+
"update_state",
104+
lambda *_, **__: None,
105+
raising=False,
106+
)
107+
108+
result = kis_tasks.run_per_domestic_stock_automation.apply().result
109+
110+
# 태스크가 성공적으로 완료되어야 함
111+
assert result["status"] == "completed"
112+
assert len(result["results"]) == 2, "KIS 1개 + 수동 잔고 1개 = 총 2개"
113+
114+
# 결과에 두 종목이 모두 포함되어야 함
115+
codes = [r["code"] for r in result["results"]]
116+
assert "005935" in codes, "KIS 보유 종목(삼성전자우)이 포함되어야 함"
117+
assert "005930" in codes, "수동 잔고 종목(삼성전자)이 포함되어야 함"
118+
119+
# 매수/매도 함수가 두 종목 모두에 대해 호출되어야 함
120+
assert len(buy_calls) == 2, "두 종목 모두 매수 검토"
121+
assert len(sell_calls) == 2, "두 종목 모두 매도 검토"
122+
123+
def test_manual_holdings_duplicates_skipped(self, monkeypatch):
124+
"""KIS와 수동 잔고에 동일 종목이 있으면 수동 잔고는 스킵"""
125+
from app.tasks import kis as kis_tasks
126+
from decimal import Decimal
127+
128+
class DummyAnalyzer:
129+
async def analyze_stock_json(self, name):
130+
return {"decision": "hold", "confidence": 65}, "gemini-2.5-pro"
131+
132+
async def close(self):
133+
return None
134+
135+
class DummyKIS:
136+
async def fetch_my_stocks(self):
137+
# KIS에 삼성전자가 있음
138+
return [
139+
{
140+
"pdno": "005930",
141+
"prdt_name": "삼성전자",
142+
"pchs_avg_pric": "70000",
143+
"prpr": "71000",
144+
"hldg_qty": "20",
145+
}
146+
]
147+
148+
async def inquire_korea_orders(self, *args, **kwargs):
149+
return []
150+
151+
async def cancel_korea_order(self, *args, **kwargs):
152+
return {"odno": "0000001"}
153+
154+
# Mock manual holding (토스에도 삼성전자 - 중복)
155+
manual_holding = MagicMock()
156+
manual_holding.ticker = "005930"
157+
manual_holding.display_name = "삼성전자"
158+
manual_holding.quantity = Decimal("10")
159+
manual_holding.avg_price = Decimal("69000")
160+
161+
class MockManualService:
162+
def __init__(self, db):
163+
pass
164+
165+
async def get_holdings_by_user(self, user_id, market_type):
166+
return [manual_holding]
167+
168+
buy_calls = []
169+
sell_calls = []
170+
171+
async def fake_buy(kis, symbol, current_price, avg_price):
172+
buy_calls.append({"symbol": symbol})
173+
return {"success": False, "message": "조건 미충족", "orders_placed": 0}
174+
175+
async def fake_sell(kis, symbol, current_price, avg_price, qty):
176+
sell_calls.append({"symbol": symbol})
177+
return {"success": False, "message": "조건 미충족", "orders_placed": 0}
178+
179+
# Mock DB session
180+
mock_db_session = MagicMock()
181+
mock_db_session.__aenter__ = AsyncMock(return_value=MagicMock())
182+
mock_db_session.__aexit__ = AsyncMock(return_value=None)
183+
184+
with patch('app.core.db.AsyncSessionLocal', return_value=mock_db_session), \
185+
patch('app.services.manual_holdings_service.ManualHoldingsService', MockManualService):
186+
187+
monkeypatch.setattr(kis_tasks, "KISClient", DummyKIS)
188+
monkeypatch.setattr(kis_tasks, "KISAnalyzer", DummyAnalyzer)
189+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_buy_orders_with_analysis", fake_buy)
190+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_sell_orders_with_analysis", fake_sell)
191+
monkeypatch.setattr(
192+
kis_tasks.run_per_domestic_stock_automation,
193+
"update_state",
194+
lambda *_, **__: None,
195+
raising=False,
196+
)
197+
198+
result = kis_tasks.run_per_domestic_stock_automation.apply().result
199+
200+
# 태스크가 성공적으로 완료되어야 함
201+
assert result["status"] == "completed"
202+
assert len(result["results"]) == 1, "중복은 제거되고 1개만 처리"
203+
204+
# KIS 종목만 처리되어야 함 (수동 잔고는 스킵)
205+
assert result["results"][0]["code"] == "005930"
206+
207+
# 매수/매도 함수는 한 번만 호출
208+
assert len(buy_calls) == 1
209+
assert len(sell_calls) == 1
210+
211+
def test_manual_holdings_current_price_fetched(self, monkeypatch):
212+
"""수동 잔고 종목의 현재가가 API로 조회되는지 확인"""
213+
from app.tasks import kis as kis_tasks
214+
from decimal import Decimal
215+
216+
class DummyAnalyzer:
217+
async def analyze_stock_json(self, name):
218+
return {"decision": "hold", "confidence": 65}, "gemini-2.5-pro"
219+
220+
async def close(self):
221+
return None
222+
223+
price_fetch_calls = []
224+
225+
class DummyKIS:
226+
async def fetch_my_stocks(self):
227+
return [] # KIS에는 보유 종목 없음
228+
229+
async def inquire_korea_orders(self, *args, **kwargs):
230+
return []
231+
232+
async def cancel_korea_order(self, *args, **kwargs):
233+
return {"odno": "0000001"}
234+
235+
async def fetch_fundamental_info(self, code):
236+
# 현재가 조회 기록
237+
price_fetch_calls.append(code)
238+
return {"종목명": "삼성전자", "현재가": 72000}
239+
240+
# Mock manual holding
241+
manual_holding = MagicMock()
242+
manual_holding.ticker = "005930"
243+
manual_holding.display_name = "삼성전자"
244+
manual_holding.quantity = Decimal("10")
245+
manual_holding.avg_price = Decimal("70000")
246+
247+
class MockManualService:
248+
def __init__(self, db):
249+
pass
250+
251+
async def get_holdings_by_user(self, user_id, market_type):
252+
return [manual_holding]
253+
254+
async def fake_buy(kis, symbol, current_price, avg_price):
255+
return {"success": False, "message": "조건 미충족", "orders_placed": 0}
256+
257+
async def fake_sell(kis, symbol, current_price, avg_price, qty):
258+
return {"success": False, "message": "조건 미충족", "orders_placed": 0}
259+
260+
# Mock DB session
261+
mock_db_session = MagicMock()
262+
mock_db_session.__aenter__ = AsyncMock(return_value=MagicMock())
263+
mock_db_session.__aexit__ = AsyncMock(return_value=None)
264+
265+
with patch('app.core.db.AsyncSessionLocal', return_value=mock_db_session), \
266+
patch('app.services.manual_holdings_service.ManualHoldingsService', MockManualService):
267+
268+
monkeypatch.setattr(kis_tasks, "KISClient", DummyKIS)
269+
monkeypatch.setattr(kis_tasks, "KISAnalyzer", DummyAnalyzer)
270+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_buy_orders_with_analysis", fake_buy)
271+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_sell_orders_with_analysis", fake_sell)
272+
monkeypatch.setattr(
273+
kis_tasks.run_per_domestic_stock_automation,
274+
"update_state",
275+
lambda *_, **__: None,
276+
raising=False,
277+
)
278+
279+
result = kis_tasks.run_per_domestic_stock_automation.apply().result
280+
281+
# 태스크가 성공적으로 완료되어야 함
282+
assert result["status"] == "completed"
283+
284+
# 현재가 조회 API가 호출되어야 함
285+
assert "005930" in price_fetch_calls, "수동 잔고 종목의 현재가를 조회해야 함"

0 commit comments

Comments
 (0)