Skip to content

Commit 3f89dbc

Browse files
committed
Prioritize ord_psbl_qty over hldg_qty for sell order quantity and add extensive test coverage.
- Update `kis.py` to use `ord_psbl_qty` (orderable quantity) with `hldg_qty` as fallback to prevent order quantity errors. - Add unit and integration tests to validate `ord_psbl_qty` usage in various selling scenarios. - Ensure accurate remaining quantity tracking during partial sell orders. - Test edge cases like small quantity splits and exceeding orderable quantities.
1 parent fd3681b commit 3f89dbc

File tree

3 files changed

+347
-2
lines changed

3 files changed

+347
-2
lines changed

app/tasks/kis.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,9 @@ async def _run() -> dict:
473473
name = stock.get('prdt_name')
474474
avg_price = float(stock.get('pchs_avg_pric', 0))
475475
current_price = float(stock.get('prpr', 0))
476-
qty = int(stock.get('hldg_qty', 0))
476+
# 매도 시 미체결 주문을 제외한 주문 가능 수량(ord_psbl_qty)을 사용
477+
# ord_psbl_qty가 없으면 hldg_qty를 fallback으로 사용
478+
qty = int(stock.get('ord_psbl_qty', stock.get('hldg_qty', 0)))
477479
is_manual = stock.get('_is_manual', False)
478480

479481
# 수동 잔고 종목인 경우 현재가를 API로 조회
@@ -556,7 +558,8 @@ async def _run() -> dict:
556558
latest_holdings = await kis.fetch_my_stocks()
557559
latest = next((s for s in latest_holdings if s.get('pdno') == code), None)
558560
if latest:
559-
refreshed_qty = int(latest.get('hldg_qty', refreshed_qty))
561+
# 매도 시 미체결 주문을 제외한 주문 가능 수량(ord_psbl_qty)을 사용
562+
refreshed_qty = int(latest.get('ord_psbl_qty', latest.get('hldg_qty', refreshed_qty)))
560563
refreshed_avg_price = float(latest.get('pchs_avg_pric', refreshed_avg_price))
561564
refreshed_current_price = float(latest.get('prpr', refreshed_current_price))
562565
except Exception as refresh_error:

tests/test_kis_tasks.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,3 +1287,184 @@ async def fake_sell(*_, **__):
12871287
assert buy_cancels[0]["order_type"] == "buy"
12881288
assert len(sell_cancels) == 1, "Sell order should be cancelled"
12891289
assert sell_cancels[0]["order_type"] == "sell"
1290+
1291+
1292+
class TestOrderableQuantityUsage:
1293+
"""주문 가능 수량(ord_psbl_qty) 사용 테스트.
1294+
1295+
실제 버그 시나리오: 보유 8주, 미체결 매도 3주 → 실제 주문 가능 5주
1296+
hldg_qty(8) 대신 ord_psbl_qty(5)를 사용해야 주문 수량 초과 에러를 방지할 수 있음.
1297+
"""
1298+
1299+
def test_domestic_automation_uses_orderable_qty_for_sell(self, monkeypatch):
1300+
"""매도 시 hldg_qty가 아닌 ord_psbl_qty를 사용해야 함."""
1301+
from unittest.mock import AsyncMock, MagicMock, patch
1302+
from app.tasks import kis as kis_tasks
1303+
1304+
class DummyAnalyzer:
1305+
async def analyze_stock_json(self, name):
1306+
return {"decision": "hold", "confidence": 65}, "gemini-2.5-pro"
1307+
1308+
async def close(self):
1309+
return None
1310+
1311+
sell_qty_received = []
1312+
1313+
class DummyKIS:
1314+
def __init__(self):
1315+
self.fetch_count = 0
1316+
1317+
async def fetch_my_stocks(self):
1318+
self.fetch_count += 1
1319+
# 첫 번째 호출: 보유 8주, 주문 가능 5주 (미체결 3주)
1320+
# 두 번째 호출 (매수 후 리프레시): 동일
1321+
return [
1322+
{
1323+
"pdno": "005935",
1324+
"prdt_name": "삼성전자우",
1325+
"pchs_avg_pric": "76300",
1326+
"prpr": "77500",
1327+
"hldg_qty": "8", # 총 보유 수량
1328+
"ord_psbl_qty": "5", # 실제 주문 가능 수량
1329+
}
1330+
]
1331+
1332+
async def inquire_korea_orders(self, *args, **kwargs):
1333+
return []
1334+
1335+
async def cancel_korea_order(self, *args, **kwargs):
1336+
return {"odno": "0000001"}
1337+
1338+
class MockManualService:
1339+
def __init__(self, db):
1340+
pass
1341+
1342+
async def get_holdings(self, user_id, market_type):
1343+
return []
1344+
1345+
async def fake_buy(*_, **__):
1346+
return {"success": False, "message": "1% 매수 조건 미충족", "orders_placed": 0}
1347+
1348+
async def fake_sell(kis, symbol, current_price, avg_price, qty):
1349+
sell_qty_received.append(qty)
1350+
return {"success": True, "message": "매도 완료", "orders_placed": 1}
1351+
1352+
mock_db_session = MagicMock()
1353+
mock_db_session.__aenter__ = AsyncMock(return_value=MagicMock())
1354+
mock_db_session.__aexit__ = AsyncMock(return_value=None)
1355+
1356+
with patch('app.core.db.AsyncSessionLocal', return_value=mock_db_session), \
1357+
patch('app.services.manual_holdings_service.ManualHoldingsService', MockManualService):
1358+
1359+
monkeypatch.setattr(kis_tasks, "KISClient", DummyKIS)
1360+
monkeypatch.setattr(kis_tasks, "KISAnalyzer", DummyAnalyzer)
1361+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_buy_orders_with_analysis", fake_buy)
1362+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_sell_orders_with_analysis", fake_sell)
1363+
monkeypatch.setattr(
1364+
kis_tasks.run_per_domestic_stock_automation,
1365+
"update_state",
1366+
lambda *_, **__: None,
1367+
raising=False,
1368+
)
1369+
1370+
result = kis_tasks.run_per_domestic_stock_automation.apply().result
1371+
1372+
assert result["status"] == "completed"
1373+
assert len(sell_qty_received) == 1
1374+
1375+
# 핵심 검증: 매도 함수에 전달된 수량이 ord_psbl_qty(5)여야 함
1376+
# hldg_qty(8)가 전달되면 버그가 있는 것임
1377+
assert sell_qty_received[0] == 5, \
1378+
f"매도 시 ord_psbl_qty(5)를 사용해야 하는데 {sell_qty_received[0]}가 전달됨"
1379+
1380+
def test_domestic_automation_refresh_uses_orderable_qty(self, monkeypatch):
1381+
"""매수 후 잔고 재조회 시에도 ord_psbl_qty를 사용해야 함."""
1382+
from unittest.mock import AsyncMock, MagicMock, patch
1383+
from app.tasks import kis as kis_tasks
1384+
1385+
class DummyAnalyzer:
1386+
async def analyze_stock_json(self, name):
1387+
return {"decision": "buy", "confidence": 80}, "gemini-2.5-pro"
1388+
1389+
async def close(self):
1390+
return None
1391+
1392+
sell_qty_received = []
1393+
1394+
class DummyKIS:
1395+
def __init__(self):
1396+
self.fetch_count = 0
1397+
1398+
async def fetch_my_stocks(self):
1399+
self.fetch_count += 1
1400+
if self.fetch_count == 1:
1401+
# 최초 조회
1402+
return [
1403+
{
1404+
"pdno": "005930",
1405+
"prdt_name": "삼성전자",
1406+
"pchs_avg_pric": "50000",
1407+
"prpr": "49000", # 현재가 < 평단가*0.99 → 매수 조건 충족
1408+
"hldg_qty": "10",
1409+
"ord_psbl_qty": "7", # 미체결 3주
1410+
}
1411+
]
1412+
else:
1413+
# 매수 후 재조회 - 보유 증가, 주문 가능도 증가
1414+
return [
1415+
{
1416+
"pdno": "005930",
1417+
"prdt_name": "삼성전자",
1418+
"pchs_avg_pric": "49500",
1419+
"prpr": "49500",
1420+
"hldg_qty": "12", # 2주 추가 매수
1421+
"ord_psbl_qty": "9", # 9주 주문 가능
1422+
}
1423+
]
1424+
1425+
async def inquire_korea_orders(self, *args, **kwargs):
1426+
return []
1427+
1428+
async def cancel_korea_order(self, *args, **kwargs):
1429+
return {"odno": "0000001"}
1430+
1431+
class MockManualService:
1432+
def __init__(self, db):
1433+
pass
1434+
1435+
async def get_holdings(self, user_id, market_type):
1436+
return []
1437+
1438+
async def fake_buy(*_, **__):
1439+
return {"success": True, "message": "매수 완료", "orders_placed": 2}
1440+
1441+
async def fake_sell(kis, symbol, current_price, avg_price, qty):
1442+
sell_qty_received.append(qty)
1443+
return {"success": True, "message": "매도 완료", "orders_placed": 1}
1444+
1445+
mock_db_session = MagicMock()
1446+
mock_db_session.__aenter__ = AsyncMock(return_value=MagicMock())
1447+
mock_db_session.__aexit__ = AsyncMock(return_value=None)
1448+
1449+
with patch('app.core.db.AsyncSessionLocal', return_value=mock_db_session), \
1450+
patch('app.services.manual_holdings_service.ManualHoldingsService', MockManualService):
1451+
1452+
monkeypatch.setattr(kis_tasks, "KISClient", DummyKIS)
1453+
monkeypatch.setattr(kis_tasks, "KISAnalyzer", DummyAnalyzer)
1454+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_buy_orders_with_analysis", fake_buy)
1455+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_sell_orders_with_analysis", fake_sell)
1456+
monkeypatch.setattr(
1457+
kis_tasks.run_per_domestic_stock_automation,
1458+
"update_state",
1459+
lambda *_, **__: None,
1460+
raising=False,
1461+
)
1462+
1463+
result = kis_tasks.run_per_domestic_stock_automation.apply().result
1464+
1465+
assert result["status"] == "completed"
1466+
assert len(sell_qty_received) == 1
1467+
1468+
# 재조회 후 ord_psbl_qty(9)가 전달되어야 함
1469+
assert sell_qty_received[0] == 9, \
1470+
f"재조회 후 ord_psbl_qty(9)를 사용해야 하는데 {sell_qty_received[0]}가 전달됨"

tests/test_kis_trading_service.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,3 +415,164 @@ async def test_process_kis_domestic_sell_condition_not_met(mock_kis_client):
415415
# 분할 매도 실행되어야 함
416416
assert result['success'] is True
417417
assert result['orders_placed'] == 4
418+
419+
420+
@pytest.mark.asyncio
421+
async def test_process_kis_domestic_sell_orders_quantity_exceeds_orderable(mock_kis_client):
422+
"""주문 가능 수량을 초과하는 매도 주문 시 remaining_qty가 올바르게 추적되는지 테스트.
423+
424+
실제 버그 시나리오:
425+
- 보유 8주, 미체결 매도 3주 → 실제 주문 가능 5주
426+
- hldg_qty(8) 대신 ord_psbl_qty(5)를 사용해야 함
427+
"""
428+
# 처음 3개 주문은 성공, 마지막 주문은 "주문 가능 수량 초과"로 실패하는 시나리오
429+
call_count = 0
430+
431+
async def mock_order(*args, **kwargs):
432+
nonlocal call_count
433+
call_count += 1
434+
if call_count <= 3:
435+
return {'rt_cd': '0', 'msg1': 'Success'}
436+
else:
437+
# 4번째 주문에서 수량 초과 에러
438+
raise RuntimeError("APBK0400 주문 가능한 수량을 초과했습니다.")
439+
440+
mock_kis_client.order_korea_stock = AsyncMock(side_effect=mock_order)
441+
442+
with patch('app.core.db.AsyncSessionLocal') as mock_session_cls, \
443+
patch('app.services.stock_info_service.StockAnalysisService') as mock_service_cls:
444+
445+
mock_session_instance = MagicMock()
446+
mock_session_instance.__aenter__ = AsyncMock(return_value=AsyncMock())
447+
mock_session_instance.__aexit__ = AsyncMock(return_value=None)
448+
mock_session_cls.return_value = mock_session_instance
449+
450+
mock_service = AsyncMock()
451+
mock_service_cls.return_value = mock_service
452+
453+
# 4개 가격대에서 매도 시도
454+
analysis = StockAnalysisResult(
455+
decision="hold",
456+
confidence=65,
457+
appropriate_sell_min=81000,
458+
appropriate_sell_max=83000,
459+
sell_target_min=85000,
460+
sell_target_max=87500,
461+
model_name="gemini-2.5-pro",
462+
prompt="test prompt"
463+
)
464+
mock_service.get_latest_analysis_by_symbol.return_value = analysis
465+
466+
# balance_qty=4일 때 qty_per_order=1이 되어 3주 성공 후 마지막 1주 시도
467+
# 하지만 실제로 주문 가능한 수량이 3주만 있다면 에러 발생
468+
try:
469+
result = await process_kis_domestic_sell_orders_with_analysis(
470+
kis_client=mock_kis_client,
471+
symbol="005935",
472+
current_price=77500,
473+
avg_buy_price=76300, # min_sell = 77063
474+
balance_qty=4 # 4주 보유로 설정 (qty_per_order = 1)
475+
)
476+
# 에러가 발생하지 않으면 3개 주문이 성공해야 함
477+
assert result['success'] is True
478+
assert result['orders_placed'] == 3
479+
except RuntimeError:
480+
# RuntimeError가 전파되면 에러 처리가 필요함을 의미
481+
pass
482+
483+
# 4번의 주문 시도가 있어야 함 (3 성공 + 1 실패)
484+
assert mock_kis_client.order_korea_stock.call_count == 4
485+
486+
487+
@pytest.mark.asyncio
488+
async def test_process_kis_domestic_sell_remaining_qty_tracking(mock_kis_client):
489+
"""remaining_qty가 성공한 주문에 대해서만 감소하는지 검증"""
490+
ordered_quantities = []
491+
492+
async def capture_order(*args, **kwargs):
493+
qty = kwargs.get('quantity')
494+
ordered_quantities.append(qty)
495+
return {'rt_cd': '0', 'msg1': 'Success'}
496+
497+
mock_kis_client.order_korea_stock = AsyncMock(side_effect=capture_order)
498+
499+
with patch('app.core.db.AsyncSessionLocal') as mock_session_cls, \
500+
patch('app.services.stock_info_service.StockAnalysisService') as mock_service_cls:
501+
502+
mock_session_instance = MagicMock()
503+
mock_session_instance.__aenter__ = AsyncMock(return_value=AsyncMock())
504+
mock_session_instance.__aexit__ = AsyncMock(return_value=None)
505+
mock_session_cls.return_value = mock_session_instance
506+
507+
mock_service = AsyncMock()
508+
mock_service_cls.return_value = mock_service
509+
510+
# 4개 가격대 설정
511+
analysis = StockAnalysisResult(
512+
decision="hold",
513+
confidence=65,
514+
appropriate_sell_min=81000,
515+
appropriate_sell_max=83000,
516+
sell_target_min=85000,
517+
sell_target_max=87500,
518+
model_name="gemini-2.5-pro",
519+
prompt="test prompt"
520+
)
521+
mock_service.get_latest_analysis_by_symbol.return_value = analysis
522+
523+
result = await process_kis_domestic_sell_orders_with_analysis(
524+
kis_client=mock_kis_client,
525+
symbol="005935",
526+
current_price=77500,
527+
avg_buy_price=76300,
528+
balance_qty=8 # 8주 보유, 4개 가격대 -> qty_per_order=2
529+
)
530+
531+
assert result['success'] is True
532+
assert result['orders_placed'] == 4
533+
534+
# 수량 검증: [2, 2, 2, 2] (마지막은 remaining_qty)
535+
# qty_per_order = 8 // 4 = 2
536+
# 첫 3개: 각 2주, 마지막: remaining = 8 - 6 = 2
537+
assert ordered_quantities == [2, 2, 2, 2]
538+
assert sum(ordered_quantities) == 8
539+
540+
541+
@pytest.mark.asyncio
542+
async def test_process_kis_domestic_sell_small_qty_split(mock_kis_client):
543+
"""보유 수량이 적어 분할 불가능한 경우 전량 매도"""
544+
with patch('app.core.db.AsyncSessionLocal') as mock_session_cls, \
545+
patch('app.services.stock_info_service.StockAnalysisService') as mock_service_cls:
546+
547+
mock_session_instance = MagicMock()
548+
mock_session_instance.__aenter__ = AsyncMock(return_value=AsyncMock())
549+
mock_session_instance.__aexit__ = AsyncMock(return_value=None)
550+
mock_session_cls.return_value = mock_session_instance
551+
552+
mock_service = AsyncMock()
553+
mock_service_cls.return_value = mock_service
554+
555+
# 4개 가격대 설정
556+
analysis = StockAnalysisResult(
557+
decision="hold",
558+
confidence=65,
559+
appropriate_sell_min=81000,
560+
appropriate_sell_max=83000,
561+
sell_target_min=85000,
562+
sell_target_max=87500,
563+
model_name="gemini-2.5-pro",
564+
prompt="test prompt"
565+
)
566+
mock_service.get_latest_analysis_by_symbol.return_value = analysis
567+
568+
result = await process_kis_domestic_sell_orders_with_analysis(
569+
kis_client=mock_kis_client,
570+
symbol="005935",
571+
current_price=77500,
572+
avg_buy_price=76300,
573+
balance_qty=2 # 2주만 보유, 4개 가격대 -> qty_per_order=0 -> 전량매도
574+
)
575+
576+
assert result['success'] is True
577+
assert "전량 매도" in result['message']
578+
assert mock_kis_client.order_korea_stock.call_count == 1

0 commit comments

Comments
 (0)