Skip to content

Commit 59b534a

Browse files
committed
Skip sell step for manual holdings and add test coverage
- Update `kis.py` to bypass the sell step for manual holdings (e.g., Toss stocks) as they cannot be processed via KIS. - Add tests to validate that sell functions are not called for manual holdings while ensuring proper logging and result structure. - Include error prevention and correct messaging for skipped manual holdings in sell automation scenarios.
1 parent 4c18458 commit 59b534a

File tree

2 files changed

+119
-12
lines changed

2 files changed

+119
-12
lines changed

app/tasks/kis.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,13 @@ async def _run() -> dict:
576576
except Exception as refresh_error:
577577
logger.warning("잔고 재조회 실패 - 기존 수량 사용 (%s)", refresh_error)
578578

579+
# 수동 잔고(토스 등)는 KIS에서 매도할 수 없으므로 매도 단계 스킵
580+
if is_manual:
581+
logger.info(f"[수동잔고] {name}({code}) - KIS 매도 불가, 매도 단계 스킵")
582+
stock_steps.append({'step': '매도', 'result': {'success': True, 'message': '수동잔고 - 매도 스킵', 'orders_placed': 0}})
583+
results.append({'name': name, 'code': code, 'steps': stock_steps})
584+
continue
585+
579586
# 4. 기존 미체결 매도 주문 취소
580587
self.update_state(state='PROGRESS', meta={'status': f'{name} 미체결 매도 주문 취소 중...', 'current': index, 'total': total_count, 'percentage': int((index / total_count) * 100)})
581588
sell_orders_cancelled = False

tests/test_kis_manual_holdings_integration.py

Lines changed: 112 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,11 @@ async def fake_sell(kis, symbol, current_price, avg_price, qty):
116116
assert "005935" in codes, "KIS 보유 종목(삼성전자우)이 포함되어야 함"
117117
assert "005930" in codes, "수동 잔고 종목(삼성전자)이 포함되어야 함"
118118

119-
# 매수/매도 함수가 두 종목 모두에 대해 호출되어야 함
119+
# 매수 함수가 두 종목 모두에 대해 호출되어야 함 (분석은 수동 잔고도 수행)
120120
assert len(buy_calls) == 2, "두 종목 모두 매수 검토"
121-
assert len(sell_calls) == 2, "두 종목 모두 매도 검토"
121+
# 매도 함수는 KIS 종목만 호출 (수동 잔고는 KIS에서 매도 불가하므로 스킵)
122+
assert len(sell_calls) == 1, "KIS 종목만 매도 검토 (수동 잔고는 스킵)"
123+
assert sell_calls[0]["symbol"] == "005935", "KIS 종목(삼성전자우)만 매도 함수 호출"
122124

123125
def test_manual_holdings_duplicates_skipped(self, monkeypatch):
124126
"""KIS와 수동 잔고에 동일 종목이 있으면 수동 잔고는 스킵"""
@@ -357,15 +359,18 @@ async def fake_sell(kis, symbol, current_price, avg_price, qty):
357359
# 태스크가 성공적으로 완료되어야 함
358360
assert result["status"] == "completed"
359361

360-
# 매도 함수가 호출되어야
361-
assert len(sell_calls) == 1, "매도 함수가 호출되어야 함"
362+
# 수동 잔고는 KIS에서 매도할 수 없으므로 매도 함수가 호출되지 않아야
363+
assert len(sell_calls) == 0, "수동 잔고는 매도 함수가 호출되면 안 됨"
362364

363-
# qty가 정수 타입이어야 함
364-
assert sell_calls[0]["qty_type"] == "int", f"수량은 int 타입이어야 하는데 {sell_calls[0]['qty_type']} 타입임"
365-
assert sell_calls[0]["qty"] == 2, f"수량은 2여야 하는데 {sell_calls[0]['qty']}임"
365+
# 결과에서 매도 스킵 메시지 확인
366+
assert len(result["results"]) == 1
367+
stock_result = result["results"][0]
368+
sell_step = next((s for s in stock_result["steps"] if s["step"] == "매도"), None)
369+
assert sell_step is not None, "매도 단계가 있어야 함"
370+
assert "수동잔고" in sell_step["result"]["message"], "수동잔고 스킵 메시지가 있어야 함"
366371

367372
def test_manual_holdings_has_orderable_qty(self, monkeypatch):
368-
"""수동 잔고에 ord_psbl_qty 필드가 올바르게 설정되는지 확인"""
373+
"""수동 잔고에 ord_psbl_qty 필드가 올바르게 설정되고, 매도는 스킵되는지 확인"""
369374
from app.tasks import kis as kis_tasks
370375
from decimal import Decimal
371376

@@ -436,8 +441,103 @@ async def fake_sell(kis, symbol, current_price, avg_price, qty):
436441
# 태스크가 성공적으로 완료되어야 함
437442
assert result["status"] == "completed"
438443

439-
# 매도 함수가 호출되어야
440-
assert len(sell_calls) == 1, "매도 함수가 호출되어야 함"
444+
# 수동 잔고는 KIS에서 매도할 수 없으므로 매도 함수가 호출되지 않아야
445+
assert len(sell_calls) == 0, "수동 잔고는 매도 함수가 호출되면 안 됨"
441446

442-
# qty가 정확히 5여야 함 (ord_psbl_qty가 올바르게 설정됨)
443-
assert sell_calls[0]["qty"] == 5, f"수량은 5여야 하는데 {sell_calls[0]['qty']}임"
447+
# 결과에서 매도 스킵 메시지 확인
448+
assert len(result["results"]) == 1
449+
stock_result = result["results"][0]
450+
sell_step = next((s for s in stock_result["steps"] if s["step"] == "매도"), None)
451+
assert sell_step is not None, "매도 단계가 있어야 함"
452+
assert "수동잔고" in sell_step["result"]["message"], "수동잔고 스킵 메시지가 있어야 함"
453+
454+
def test_manual_holdings_skip_sell_order(self, monkeypatch):
455+
"""수동 잔고(토스 등)는 KIS에서 매도할 수 없으므로 매도를 스킵해야 함.
456+
457+
APBK0400 에러 방지:
458+
- 토스 증권에만 있는 주식을 KIS API로 매도 요청하면 "주문 가능한 수량을 초과" 에러 발생
459+
- 수동 잔고 종목은 분석만 하고 매도는 스킵해야 함
460+
"""
461+
from app.tasks import kis as kis_tasks
462+
from decimal import Decimal
463+
464+
class DummyAnalyzer:
465+
async def analyze_stock_json(self, name):
466+
return {"decision": "sell", "confidence": 85}, "gemini-2.5-pro"
467+
468+
async def close(self):
469+
return None
470+
471+
sell_calls = []
472+
473+
class DummyKIS:
474+
async def fetch_my_stocks(self):
475+
# KIS에는 보유 종목 없음 (수동 잔고만 있음)
476+
return []
477+
478+
async def inquire_korea_orders(self, *args, **kwargs):
479+
return []
480+
481+
async def cancel_korea_order(self, *args, **kwargs):
482+
return {"odno": "0000001"}
483+
484+
async def fetch_fundamental_info(self, code):
485+
return {"종목명": "한국전력", "현재가": 25000}
486+
487+
# 수동 잔고 종목 (토스에만 있음)
488+
mock_manual_holding = MagicMock()
489+
mock_manual_holding.ticker = "015760" # 한국전력
490+
mock_manual_holding.display_name = "한국전력"
491+
mock_manual_holding.quantity = Decimal("10")
492+
mock_manual_holding.avg_price = Decimal("23000")
493+
494+
class MockManualService:
495+
def __init__(self, db):
496+
pass
497+
498+
async def get_holdings_by_user(self, user_id, market_type):
499+
return [mock_manual_holding]
500+
501+
async def fake_buy(*_, **__):
502+
return {"success": False, "message": "매수 조건 미충족", "orders_placed": 0}
503+
504+
async def fake_sell(kis, symbol, current_price, avg_price, qty):
505+
sell_calls.append({"symbol": symbol, "qty": qty})
506+
return {"success": True, "message": "매도 완료", "orders_placed": 1}
507+
508+
mock_db_session = MagicMock()
509+
mock_db_session.__aenter__ = AsyncMock(return_value=MagicMock())
510+
mock_db_session.__aexit__ = AsyncMock(return_value=None)
511+
512+
with patch('app.core.db.AsyncSessionLocal', return_value=mock_db_session), \
513+
patch('app.services.manual_holdings_service.ManualHoldingsService', MockManualService):
514+
515+
monkeypatch.setattr(kis_tasks, "KISClient", DummyKIS)
516+
monkeypatch.setattr(kis_tasks, "KISAnalyzer", DummyAnalyzer)
517+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_buy_orders_with_analysis", fake_buy)
518+
monkeypatch.setattr(kis_tasks, "process_kis_domestic_sell_orders_with_analysis", fake_sell)
519+
monkeypatch.setattr(
520+
kis_tasks.run_per_domestic_stock_automation,
521+
"update_state",
522+
lambda *_, **__: None,
523+
raising=False,
524+
)
525+
526+
result = kis_tasks.run_per_domestic_stock_automation.apply().result
527+
528+
# 태스크가 성공적으로 완료되어야 함
529+
assert result["status"] == "completed"
530+
531+
# 수동 잔고 종목의 결과가 있어야 함
532+
assert len(result["results"]) == 1
533+
stock_result = result["results"][0]
534+
assert stock_result["code"] == "015760"
535+
536+
# 핵심 검증: 매도 함수가 호출되지 않아야 함 (수동 잔고이므로)
537+
assert len(sell_calls) == 0, f"수동 잔고는 매도 함수가 호출되면 안 됨. 호출된 횟수: {len(sell_calls)}"
538+
539+
# 매도 단계 결과가 '수동잔고 - 매도 스킵'이어야 함
540+
sell_step = next((s for s in stock_result["steps"] if s["step"] == "매도"), None)
541+
assert sell_step is not None, "매도 단계가 있어야 함"
542+
assert "수동잔고" in sell_step["result"]["message"], \
543+
f"매도 결과에 '수동잔고' 메시지가 있어야 함: {sell_step['result']}"

0 commit comments

Comments
 (0)