"""tick_trader 핵심 로직 단위 테스트. 실행: .venv/bin/python3 -m pytest tests/test_tick_trader.py -v 테스트 대상: - update_positions: Trail Stop 발동 시점 / peak 초기화 - _advance_stage: cascade 단계 전환 / trail 전환 - check_filled_positions: 체결 확인 / 단계 시간 초과 - enter_position: sell_uuid=None(주문실패)일 때 즉시 Trail Stop 방지 """ import sys, os from datetime import datetime, timedelta from unittest.mock import patch, MagicMock sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # 환경변수 Mock — 실제 API 키 불필요 os.environ.setdefault('ACCESS_KEY', 'test') os.environ.setdefault('SECRET_KEY', 'test') os.environ.setdefault('SIMULATION_MODE', 'true') os.environ.setdefault('MAX_POSITIONS', '3') os.environ.setdefault('MAX_BUDGET', '15000000') os.environ.setdefault('ORACLE_USER', 'x') os.environ.setdefault('ORACLE_PASSWORD', 'x') os.environ.setdefault('ORACLE_DSN', 'x') os.environ.setdefault('TELEGRAM_TRADE_TOKEN', 'x') os.environ.setdefault('TELEGRAM_CHAT_ID', '0') # pyupbit 임포트 전 Mock 처리 with patch('pyupbit.Upbit'), patch('pyupbit.WebSocketManager'): import importlib import daemons.tick_trader as tt TICKER = 'KRW-TEST' def _make_pos(entry_price=1000.0, seconds_ago=0, sell_uuid='uuid-1', stage=0): """테스트용 포지션 딕셔너리 생성.""" return { 'entry_price': entry_price, 'entry_ts': datetime.now() - timedelta(seconds=seconds_ago), 'running_peak': entry_price, 'qty': 100.0, 'stage': stage, 'sell_uuid': sell_uuid, 'sell_price': entry_price * 1.02, 'trail_peak_set': False, } # ── update_positions ────────────────────────────────────────────────────────── class TestUpdatePositions: def setup_method(self): tt.positions.clear() def test_trail_stop_does_not_fire_before_stage3_end(self): """③ 종료(300s) 이전에는 sell_uuid=None이어도 Trail Stop 발동 안 함.""" tt.positions[TICKER] = _make_pos( entry_price=1000, seconds_ago=10, sell_uuid=None, stage=4 ) with patch.object(tt, 'do_sell_market') as mock_sell: tt.update_positions({TICKER: 900.0}) # -10% 하락이어도 mock_sell.assert_not_called() def test_trail_stop_fires_after_stage3_end(self): """300s 경과 후 peak 대비 0.8% 이상 하락 시 Trail Stop 발동.""" tt.positions[TICKER] = _make_pos( entry_price=1000, seconds_ago=310, sell_uuid=None, stage=4 ) # 300s 이후 peak 설정 — 첫 틱에 running_peak = 1100 초기화 tt.positions[TICKER]['trail_peak_set'] = True tt.positions[TICKER]['running_peak'] = 1100.0 with patch.object(tt, 'do_sell_market', return_value=1091.0) as mock_sell, \ patch.object(tt, '_record_exit') as mock_exit: tt.update_positions({TICKER: 1091.0}) # 1100→1091 = -0.82% mock_sell.assert_called_once() mock_exit.assert_called_once_with(TICKER, 1091.0, 'trail') def test_trail_stop_not_fire_with_sell_uuid_set(self): """sell_uuid가 있으면(지정가 대기 중) Trail Stop 발동 안 함.""" tt.positions[TICKER] = _make_pos( entry_price=1000, seconds_ago=400, sell_uuid='uuid-1', stage=3 ) tt.positions[TICKER]['trail_peak_set'] = True tt.positions[TICKER]['running_peak'] = 1100.0 with patch.object(tt, 'do_sell_market') as mock_sell: tt.update_positions({TICKER: 900.0}) mock_sell.assert_not_called() def test_peak_initialized_to_current_price_at_300s(self): """300s 첫 틱에서 running_peak이 진입가 아닌 현재가로 초기화된다.""" pos = _make_pos(entry_price=1000, seconds_ago=305, sell_uuid=None, stage=4) pos['trail_peak_set'] = False tt.positions[TICKER] = pos with patch.object(tt, 'do_sell_market'): tt.update_positions({TICKER: 950.0}) # 진입가보다 낮은 현재가 assert tt.positions[TICKER]['running_peak'] == 950.0, \ "running_peak이 현재가(950)로 초기화되어야 함" assert tt.positions[TICKER]['trail_peak_set'] is True def test_trail_stop_does_not_fire_on_peak_init_tick(self): """peak 초기화 첫 틱에서는 drop=0이므로 Trail Stop 발동 안 함.""" pos = _make_pos(entry_price=1000, seconds_ago=305, sell_uuid=None, stage=4) pos['trail_peak_set'] = False tt.positions[TICKER] = pos with patch.object(tt, 'do_sell_market') as mock_sell: tt.update_positions({TICKER: 850.0}) # 진입가 대비 -15% mock_sell.assert_not_called() # 초기화 틱이므로 발동 안 함 def test_submit_fail_sell_uuid_none_no_trail_before_300s(self): """ [회귀] HOLO 버그 재현: ① 지정가 제출 실패(sell_uuid=None) + 진입 2초 후 → Trail Stop이 발동하면 안 됨. """ tt.positions[TICKER] = _make_pos( entry_price=97, seconds_ago=2, sell_uuid=None, stage=0 ) with patch.object(tt, 'do_sell_market') as mock_sell: tt.update_positions({TICKER: 95.0}) # -2.06% 하락 mock_sell.assert_not_called() # 300s 이전이므로 발동 안 함 # ── _advance_stage ──────────────────────────────────────────────────────────── class TestAdvanceStage: def setup_method(self): tt.positions.clear() def test_advance_from_stage0_to_stage1(self): """① → ② 단계 전환 시 sell_uuid 갱신.""" tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='old-uuid') with patch.object(tt, 'cancel_order_safe'), \ patch.object(tt, 'submit_limit_sell', return_value='new-uuid'): tt._advance_stage(TICKER) pos = tt.positions[TICKER] assert pos['stage'] == 1 assert pos['sell_uuid'] == 'new-uuid' assert abs(pos['sell_price'] - 1000 * 1.01) < 0.01 def test_advance_to_trail_stage(self): """마지막 cascade 단계 → ⑤ Trail 전환 시 sell_uuid=None.""" tt.positions[TICKER] = _make_pos(stage=len(tt.CASCADE_STAGES) - 1) with patch.object(tt, 'cancel_order_safe'), \ patch.object(tt, 'submit_limit_sell'): tt._advance_stage(TICKER) pos = tt.positions[TICKER] assert pos['stage'] == len(tt.CASCADE_STAGES) assert pos['sell_uuid'] is None def test_advance_submit_fail_sell_uuid_none(self): """지정가 재주문 실패(submit_limit_sell=None) 시 sell_uuid=None — Trail 비활성 확인.""" tt.positions[TICKER] = _make_pos(stage=0, seconds_ago=50) with patch.object(tt, 'cancel_order_safe'), \ patch.object(tt, 'submit_limit_sell', return_value=None): tt._advance_stage(TICKER) pos = tt.positions[TICKER] assert pos['sell_uuid'] is None # Trail Stop은 300s 미경과이므로 update_positions에서 발동 안 해야 함 with patch.object(tt, 'do_sell_market') as mock_sell: tt.update_positions({TICKER: 500.0}) # -50% 하락이어도 mock_sell.assert_not_called() # ── check_filled_positions ──────────────────────────────────────────────────── class TestCheckFilledPositions: def setup_method(self): tt.positions.clear() def test_done_order_records_exit(self): """체결 완료(done) 시 _record_exit 호출 (실거래 모드).""" tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='u1', seconds_ago=10) with patch.object(tt, 'check_order_state', return_value=('done', 1020.0)), \ patch.object(tt, '_record_exit') as mock_exit, \ patch.object(tt, 'SIM_MODE', False): tt.check_filled_positions() mock_exit.assert_called_once_with(TICKER, 1020.0, '①') def test_timeout_advances_stage(self): """단계 시간 초과 시 _advance_stage 호출.""" stage_end = tt.CASCADE_STAGES[0][1] # 40s tt.positions[TICKER] = _make_pos(stage=0, sell_uuid='u1', seconds_ago=stage_end + 5) with patch.object(tt, 'check_order_state', return_value=('wait', None)), \ patch.object(tt, '_advance_stage') as mock_advance: tt.check_filled_positions() mock_advance.assert_called_once_with(TICKER) def test_cancelled_order_resubmits(self): """주문 취소(cancel) 감지 시 _advance_stage 호출 (실거래 모드).""" tt.positions[TICKER] = _make_pos(stage=1, sell_uuid='u1', seconds_ago=50) with patch.object(tt, 'check_order_state', return_value=('cancel', None)), \ patch.object(tt, '_advance_stage') as mock_advance, \ patch.object(tt, 'SIM_MODE', False): tt.check_filled_positions() mock_advance.assert_called_once_with(TICKER) def test_trail_stage_skipped(self): """Trail 단계(sell_uuid=None)는 check_filled_positions에서 스킵.""" tt.positions[TICKER] = _make_pos(stage=4, sell_uuid=None, seconds_ago=4000) with patch.object(tt, 'check_order_state') as mock_state: tt.check_filled_positions() mock_state.assert_not_called()