- tick_trader.py를 Controller로 축소, 로직을 3개 모듈로 분리: - core/signal.py: 시그널 감지, 지표 계산 (calc_vr, calc_atr, detect_signal) - core/order.py: Upbit 주문 실행 (매수/매도/취소/조회) - core/position_manager.py: 포지션 관리, DB sync, 복구, 청산 조건 - type hints, Google docstring, 구체적 예외 타입 적용 - 50줄 초과 함수 분리 (process_signal, restore_positions) - 미사용 파일 58개 archive/ 폴더로 이동 - README.md 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
217 lines
9.6 KiB
Python
217 lines
9.6 KiB
Python
"""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()
|