Skip to content

Commit 4c18458

Browse files
committed
Refresh ord_psbl_qty after sell order cancellation and add test coverage
- Update `kis.py` to re-fetch balance and refresh `ord_psbl_qty` following sell order cancellation to ensure accurate quantities. - Add test to validate proper quantity update after order cancellation and prevent reuse of outdated values. - Ensure updated `ord_psbl_qty` is correctly used in subsequent sell tasks.
1 parent b6c6860 commit 4c18458

File tree

2 files changed

+126
-0
lines changed

2 files changed

+126
-0
lines changed

app/tasks/kis.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,19 +578,33 @@ async def _run() -> dict:
578578

579579
# 4. 기존 미체결 매도 주문 취소
580580
self.update_state(state='PROGRESS', meta={'status': f'{name} 미체결 매도 주문 취소 중...', 'current': index, 'total': total_count, 'percentage': int((index / total_count) * 100)})
581+
sell_orders_cancelled = False
581582
try:
582583
cancel_result = await _cancel_domestic_pending_orders(
583584
kis, code, "sell", all_open_orders
584585
)
585586
if cancel_result['total'] > 0:
586587
logger.info(f"{name} 미체결 매도 주문 취소: {cancel_result['cancelled']}/{cancel_result['total']}건")
587588
stock_steps.append({'step': '매도취소', 'result': {'success': True, **cancel_result}})
589+
sell_orders_cancelled = cancel_result['cancelled'] > 0
588590
# 취소 후 API 동기화를 위해 잠시 대기
589591
await asyncio.sleep(0.5)
590592
except Exception as e:
591593
logger.warning(f"{name} 미체결 매도 주문 취소 실패: {e}")
592594
stock_steps.append({'step': '매도취소', 'result': {'success': False, 'error': str(e)}})
593595

596+
# 4-1. 미체결 매도 주문 취소 성공 시 잔고 재조회하여 ord_psbl_qty 갱신
597+
if sell_orders_cancelled:
598+
try:
599+
latest_holdings = await kis.fetch_my_stocks()
600+
latest = next((s for s in latest_holdings if s.get('pdno') == code), None)
601+
if latest:
602+
refreshed_qty = int(latest.get('ord_psbl_qty', latest.get('hldg_qty', refreshed_qty)))
603+
refreshed_current_price = float(latest.get('prpr', refreshed_current_price))
604+
logger.info(f"{name} 매도 취소 후 잔고 재조회: ord_psbl_qty={refreshed_qty}")
605+
except Exception as refresh_error:
606+
logger.warning(f"{name} 매도 취소 후 잔고 재조회 실패 - 기존 수량 사용: {refresh_error}")
607+
594608
# 5. 매도
595609
self.update_state(state='PROGRESS', meta={'status': f'{name} 매도 주문 중...', 'current': index, 'total': total_count, 'percentage': int((index / total_count) * 100)})
596610
try:

tests/test_kis_tasks.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,3 +1468,115 @@ async def fake_sell(kis, symbol, current_price, avg_price, qty):
14681468
# 재조회 후 ord_psbl_qty(9)가 전달되어야 함
14691469
assert sell_qty_received[0] == 9, \
14701470
f"재조회 후 ord_psbl_qty(9)를 사용해야 하는데 {sell_qty_received[0]}가 전달됨"
1471+
1472+
def test_domestic_automation_refreshes_qty_after_sell_order_cancel(self, monkeypatch):
1473+
"""미체결 매도 주문 취소 후 잔고를 재조회하여 ord_psbl_qty를 갱신해야 함.
1474+
1475+
실제 버그 시나리오 (APBK0986 에러):
1476+
- 보유 8주, 미체결 매도 3주 → ord_psbl_qty=5
1477+
- 매수 후 잔고 재조회 → ord_psbl_qty=5 (미체결 매도 3주 여전히 존재)
1478+
- 미체결 매도 3주 취소 → 실제로는 ord_psbl_qty=8이 되어야 함
1479+
- 기존 코드: 취소 후 재조회 없이 5주로 매도 시도
1480+
- 수정 후: 취소 후 재조회하여 8주로 매도 시도
1481+
"""
1482+
from unittest.mock import AsyncMock, MagicMock, patch
1483+
from app.tasks import kis as kis_tasks
1484+
1485+
class DummyAnalyzer:
1486+
async def analyze_stock_json(self, name):
1487+
return {"decision": "hold", "confidence": 65}, "gemini-2.5-pro"
1488+
1489+
async def close(self):
1490+
return None
1491+
1492+
sell_qty_received = []
1493+
1494+
class DummyKIS:
1495+
def __init__(self):
1496+
self.fetch_count = 0
1497+
self.cancel_sell_called = False
1498+
1499+
async def fetch_my_stocks(self):
1500+
self.fetch_count += 1
1501+
if self.fetch_count <= 2:
1502+
# 첫 번째/두 번째 호출: 미체결 매도 3주가 있어서 ord_psbl_qty=5
1503+
return [
1504+
{
1505+
"pdno": "005935",
1506+
"prdt_name": "삼성전자우",
1507+
"pchs_avg_pric": "76300",
1508+
"prpr": "77500",
1509+
"hldg_qty": "8",
1510+
"ord_psbl_qty": "5", # 미체결 매도 3주가 있는 상태
1511+
}
1512+
]
1513+
else:
1514+
# 세 번째 호출 (미체결 매도 취소 후): ord_psbl_qty=8
1515+
return [
1516+
{
1517+
"pdno": "005935",
1518+
"prdt_name": "삼성전자우",
1519+
"pchs_avg_pric": "76300",
1520+
"prpr": "77500",
1521+
"hldg_qty": "8",
1522+
"ord_psbl_qty": "8", # 미체결 매도 취소 후
1523+
}
1524+
]
1525+
1526+
async def inquire_korea_orders(self, *args, **kwargs):
1527+
# 미체결 매도 주문 3주가 있음
1528+
return [
1529+
{
1530+
"pdno": "005935",
1531+
"sll_buy_dvsn_cd": "01", # 매도
1532+
"odno": "0000001",
1533+
"ord_qty": "3",
1534+
"ord_unpr": "78000",
1535+
}
1536+
]
1537+
1538+
async def cancel_korea_order(self, *args, **kwargs):
1539+
self.cancel_sell_called = True
1540+
return {"odno": "0000001"}
1541+
1542+
class MockManualService:
1543+
def __init__(self, db):
1544+
pass
1545+
1546+
async def get_holdings_by_user(self, user_id, market_type):
1547+
return []
1548+
1549+
async def fake_buy(*_, **__):
1550+
return {"success": False, "message": "1% 매수 조건 미충족", "orders_placed": 0}
1551+
1552+
async def fake_sell(kis, symbol, current_price, avg_price, qty):
1553+
sell_qty_received.append(qty)
1554+
return {"success": True, "message": "매도 완료", "orders_placed": 1}
1555+
1556+
mock_db_session = MagicMock()
1557+
mock_db_session.__aenter__ = AsyncMock(return_value=MagicMock())
1558+
mock_db_session.__aexit__ = AsyncMock(return_value=None)
1559+
1560+
with patch('app.core.db.AsyncSessionLocal', return_value=mock_db_session), \
1561+
patch('app.services.manual_holdings_service.ManualHoldingsService', MockManualService):
1562+
1563+
monkeypatch.setattr(kis_tasks, "KISClient", DummyKIS)
1564+
monkeypatch.setattr(kis_tasks, "KISAnalyzer", DummyAnalyzer)
1565+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_buy_orders_with_analysis", fake_buy)
1566+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_sell_orders_with_analysis", fake_sell)
1567+
monkeypatch.setattr(
1568+
kis_tasks.run_per_domestic_stock_automation,
1569+
"update_state",
1570+
lambda *_, **__: None,
1571+
raising=False,
1572+
)
1573+
1574+
result = kis_tasks.run_per_domestic_stock_automation.apply().result
1575+
1576+
assert result["status"] == "completed"
1577+
assert len(sell_qty_received) == 1
1578+
1579+
# 핵심 검증: 미체결 매도 취소 후 잔고를 재조회하여 ord_psbl_qty(8)가 전달되어야 함
1580+
# 재조회 없이 기존 ord_psbl_qty(5)가 전달되면 버그가 있는 것임
1581+
assert sell_qty_received[0] == 8, \
1582+
f"미체결 매도 취소 후 재조회하여 ord_psbl_qty(8)를 사용해야 하는데 {sell_qty_received[0]}가 전달됨"

0 commit comments

Comments
 (0)