@@ -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