- 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>
188 lines
7.2 KiB
Python
188 lines
7.2 KiB
Python
"""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)
|