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:
187
archive/tests/test_llm_advisor.py
Normal file
187
archive/tests/test_llm_advisor.py
Normal 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)
|
||||
Reference in New Issue
Block a user