refactor: MVC 구조 분리 + 미사용 파일 archive 정리

- 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>
This commit is contained in:
joungmin
2026-03-06 20:46:47 +09:00
parent 976c53ed66
commit 6e0c4508fa
69 changed files with 5018 additions and 495 deletions

View File

@@ -0,0 +1,216 @@
"""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()