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,187 @@
"""core/llm_advisor 단위 테스트.
실행:
.venv/bin/python3 -m pytest tests/test_llm_advisor.py -v
"""
import json
import sys
import os
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('ORACLE_USER', 'x')
os.environ.setdefault('ORACLE_PASSWORD', 'x')
os.environ.setdefault('ORACLE_DSN', 'x')
os.environ.setdefault('ANTHROPIC_API_KEY', 'test-key')
from core.llm_advisor import (
get_exit_price,
_describe_bars,
_build_prompt,
_execute_tool,
)
TICKER = 'KRW-XRP'
def _make_bars(n: int = 20, base: float = 2100.0) -> list[dict]:
now = datetime.now()
bars = []
for i in range(n):
p = base + i * 0.5
bars.append({
'open': p,
'high': p + 2,
'low': p - 2,
'close': p + 1,
'volume': 100.0,
'ts': now - timedelta(seconds=(n - i) * 20),
})
return bars
def _make_pos(entry: float = 2084.0, seconds_ago: int = 120,
sell_price: float = 2126.0) -> dict:
return {
'entry_price': entry,
'entry_ts': datetime.now() - timedelta(seconds=seconds_ago),
'sell_price': sell_price,
'sell_uuid': 'uuid-1',
'qty': 2399.0,
'stage': 0,
'llm_last_ts': None,
}
def _mock_response(action: str, price: float = 0):
"""Anthropic API 응답 Mock."""
if action == 'hold':
text = '{"action": "hold"}'
else:
text = json.dumps({'action': 'sell', 'price': price})
content_block = MagicMock()
content_block.type = 'text'
content_block.text = text
response = MagicMock()
response.content = [content_block]
response.stop_reason = 'end_turn'
return response
# ── _describe_bars ────────────────────────────────────────────────────────────
class TestDescribeBars:
def test_returns_string(self):
bars = _make_bars(20, base=2100.0)
desc = _describe_bars(bars, current_price=2110.0)
assert isinstance(desc, str)
assert '패턴 요약' in desc
def test_empty_bars_returns_fallback(self):
desc = _describe_bars([], current_price=2100.0)
assert desc == '봉 데이터 없음'
def test_trend_direction_detected(self):
bars = _make_bars(20, base=2100.0) # 상승 bars (base + i*0.5)
desc = _describe_bars(bars, current_price=2110.0)
assert '상승▲' in desc
# ── get_exit_price: hold ──────────────────────────────────────────────────────
class TestGetExitPriceHold:
def test_returns_none_on_hold(self):
pos = _make_pos()
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = _mock_response('hold')
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result is None
def test_returns_none_when_no_api_key(self):
pos = _make_pos()
bar_list = _make_bars()
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': ''}):
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result is None
# ── get_exit_price: sell ─────────────────────────────────────────────────────
class TestGetExitPriceSell:
def test_returns_llm_suggested_price(self):
pos = _make_pos(entry=2084.0, sell_price=2126.0)
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = _mock_response('sell', 2112.0)
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result == 2112.0
def test_llm_can_suggest_below_current_price(self):
"""LLM의 판단을 신뢰 — 현재가 이하 제안도 그대로 반환."""
pos = _make_pos(entry=2084.0, sell_price=2126.0)
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = _mock_response('sell', 2080.0)
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result == 2080.0 # 가드 없음 — LLM 신뢰
def test_llm_can_suggest_high_price(self):
"""LLM의 판단을 신뢰 — 진입가 대비 10% 높은 제안도 그대로 반환."""
pos = _make_pos(entry=2084.0, sell_price=2126.0)
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = _mock_response('sell', 2300.0)
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result == 2300.0 # 상한 가드 없음 — LLM 신뢰
# ── get_exit_price: 오류 처리 ─────────────────────────────────────────────────
class TestGetExitPriceErrors:
def test_returns_none_on_json_error(self):
"""JSON 파싱 실패 → None (cascade fallback)."""
pos = _make_pos()
bar_list = _make_bars()
bad_resp = MagicMock()
bad_resp.content = [MagicMock(type='text', text='not json')]
bad_resp.stop_reason = 'end_turn'
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.return_value = bad_resp
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result is None
def test_returns_none_on_api_exception(self):
"""API 오류 → None (cascade fallback)."""
pos = _make_pos()
bar_list = _make_bars()
with patch('anthropic.Anthropic') as MockClient:
MockClient.return_value.messages.create.side_effect = Exception('API Error')
result = get_exit_price(TICKER, pos, bar_list, current_price=2109.0)
assert result is None
# ── tool 실행 ─────────────────────────────────────────────────────────────────
class TestExecuteTool:
def test_unknown_tool_returns_error_string(self):
result = _execute_tool('unknown_tool', {'ticker': TICKER})
assert '알 수 없는 tool' in result
def test_get_price_ticks_db_error_returns_string(self):
"""DB 연결 실패 시 오류 문자열 반환 (예외 아님)."""
result = _execute_tool('get_price_ticks', {'ticker': TICKER, 'minutes': 5})
assert isinstance(result, str)
def test_get_ohlcv_db_error_returns_string(self):
result = _execute_tool('get_ohlcv', {'ticker': TICKER, 'limit': 10})
assert isinstance(result, str)