feat: LLM-driven buy decisions with limit orders + robust JSON parsing
- Add get_entry_price() for LLM buy decisions (BTC trend, trade history, context tools) - Replace market buy with LLM-determined limit buy price - Lower signal threshold (VOL_MIN 8→4) — LLM makes final buy/skip decision - Restructure tick_trader: detect_signal() inside lock, LLM call outside - Add pending_buys tracking with timeout cancellation - Remove unused enter_position() and do_buy() functions - Fix JSON parsing: extract JSON from mixed text responses Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,14 @@
|
|||||||
"""OpenRouter LLM 기반 매도 목표가 어드바이저.
|
"""OpenRouter LLM 기반 매매 어드바이저.
|
||||||
|
|
||||||
1분 주기로 보유 포지션의 OHLCV 흐름을 분석해 최적 지정가 매도 목표가를 반환.
|
매수: 시그널 감지 후 LLM이 매수 여부 + 지정가 결정
|
||||||
LLM이 주(primary), 기존 cascade 규칙이 fallback.
|
매도: 1분 주기로 LLM이 매도 목표가 결정 (cascade fallback)
|
||||||
|
|
||||||
프롬프트에 포함되는 시장 데이터:
|
|
||||||
- 오늘 일봉 (고가/저가/거래량)
|
|
||||||
- 최근 4시간 1시간봉 (가격/볼륨 흐름)
|
|
||||||
- 최근 20봉 20초봉 (단기 패턴)
|
|
||||||
|
|
||||||
LLM에게 제공하는 DB Tool (OpenAI function calling):
|
LLM에게 제공하는 DB Tool (OpenAI function calling):
|
||||||
- get_price_ticks(ticker, minutes): Oracle price_tick 테이블 (최근 N분 가격 틱)
|
- get_price_ticks(ticker, minutes): Oracle price_tick 테이블 (최근 N분 가격 틱)
|
||||||
- get_ohlcv(ticker, limit): Oracle backtest_ohlcv 1분봉 (지지/저항 파악용)
|
- get_ohlcv(ticker, limit): Oracle backtest_ohlcv 1분봉 (지지/저항 파악용)
|
||||||
- get_ticker_context(ticker): 종목 평판 정보 (가격 변동, 뉴스)
|
- get_ticker_context(ticker): 종목 평판 정보 (가격 변동, 뉴스)
|
||||||
|
- get_trade_history(ticker): 최근 거래 이력 (승패, 손익)
|
||||||
반환값:
|
- get_btc_trend(): BTC 최근 동향 (알트 매수 판단용)
|
||||||
float → 새 지정가 목표가
|
|
||||||
None → hold (현재 주문 유지) 또는 오류
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -121,6 +114,71 @@ def _tool_get_context(ticker: str) -> str:
|
|||||||
return f"DB 오류: {e}"
|
return f"DB 오류: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 거래 이력 조회 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _tool_get_trade_history(ticker: str, limit: int = 10) -> str:
|
||||||
|
"""trade_results 테이블에서 해당 종목 최근 거래 이력 조회."""
|
||||||
|
try:
|
||||||
|
conn = _get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT traded_at, is_win, pnl_pct, buy_price, sell_price, sell_reason
|
||||||
|
FROM trade_results
|
||||||
|
WHERE ticker = :t
|
||||||
|
ORDER BY traded_at DESC
|
||||||
|
FETCH FIRST :n ROWS ONLY""",
|
||||||
|
{'t': ticker, 'n': limit},
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
if not rows:
|
||||||
|
return f"{ticker} 거래 이력 없음"
|
||||||
|
lines = []
|
||||||
|
wins = sum(1 for r in rows if r[1])
|
||||||
|
for r in rows:
|
||||||
|
ts = r[0].strftime('%m/%d %H:%M') if r[0] else '?'
|
||||||
|
wl = '승' if r[1] else '패'
|
||||||
|
pnl = float(r[2]) if r[2] else 0
|
||||||
|
reason = r[5] or ''
|
||||||
|
lines.append(f" {ts} {wl} {pnl:+.2f}% {reason}")
|
||||||
|
header = f"{ticker} 최근 {len(rows)}건 (승률 {wins}/{len(rows)}={wins/len(rows)*100:.0f}%):"
|
||||||
|
return header + "\n" + "\n".join(lines)
|
||||||
|
except Exception as e:
|
||||||
|
return f"DB 오류: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_get_btc_trend() -> str:
|
||||||
|
"""BTC 최근 동향 (1시간봉 6개 + 일봉)."""
|
||||||
|
try:
|
||||||
|
import pyupbit
|
||||||
|
lines = []
|
||||||
|
# 일봉 2개
|
||||||
|
day_df = pyupbit.get_ohlcv('KRW-BTC', interval='day', count=2)
|
||||||
|
if day_df is not None and len(day_df) >= 2:
|
||||||
|
today = day_df.iloc[-1]
|
||||||
|
prev = day_df.iloc[-2]
|
||||||
|
chg = (today['close'] - prev['close']) / prev['close'] * 100
|
||||||
|
lines.append(f"[BTC 일봉] 현재 {today['close']:,.0f}원 (전일 대비 {chg:+.2f}%)")
|
||||||
|
lines.append(f" 고가 {today['high']:,.0f} 저가 {today['low']:,.0f}")
|
||||||
|
|
||||||
|
# 1시간봉 6개
|
||||||
|
h1_df = pyupbit.get_ohlcv('KRW-BTC', interval='minute60', count=6)
|
||||||
|
if h1_df is not None and not h1_df.empty:
|
||||||
|
first_c = float(h1_df['close'].iloc[0])
|
||||||
|
last_c = float(h1_df['close'].iloc[-1])
|
||||||
|
h_chg = (last_c - first_c) / first_c * 100
|
||||||
|
trend = '상승' if h_chg > 0.5 else '하락' if h_chg < -0.5 else '횡보'
|
||||||
|
lines.append(f"[BTC 6시간 추세] {trend} ({h_chg:+.2f}%)")
|
||||||
|
for ts, row in h1_df.iterrows():
|
||||||
|
lines.append(
|
||||||
|
f" {ts.strftime('%H:%M')} 종{row['close']:>12,.0f} "
|
||||||
|
f"거래량{row['volume']:,.2f}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines) if lines else "BTC 데이터 조회 실패"
|
||||||
|
except Exception as e:
|
||||||
|
return f"BTC 조회 오류: {e}"
|
||||||
|
|
||||||
|
|
||||||
# ── Tool 정의 (OpenAI function calling 형식) ─────────────────────────────────
|
# ── Tool 정의 (OpenAI function calling 형식) ─────────────────────────────────
|
||||||
|
|
||||||
_TOOLS = [
|
_TOOLS = [
|
||||||
@@ -167,7 +225,7 @@ _TOOLS = [
|
|||||||
'description': (
|
'description': (
|
||||||
'종목의 평판 정보를 조회합니다. 24h/7d 가격 변동률, 거래량 추이, '
|
'종목의 평판 정보를 조회합니다. 24h/7d 가격 변동률, 거래량 추이, '
|
||||||
'최근 뉴스 등 중장기 컨텍스트를 제공합니다. '
|
'최근 뉴스 등 중장기 컨텍스트를 제공합니다. '
|
||||||
'매도 판단 시 시장 분위기와 종목 상황을 파악하는 데 사용하세요.'
|
'매매 판단 시 시장 분위기와 종목 상황을 파악하는 데 사용하세요.'
|
||||||
),
|
),
|
||||||
'parameters': {
|
'parameters': {
|
||||||
'type': 'object',
|
'type': 'object',
|
||||||
@@ -178,6 +236,39 @@ _TOOLS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'type': 'function',
|
||||||
|
'function': {
|
||||||
|
'name': 'get_trade_history',
|
||||||
|
'description': (
|
||||||
|
'특정 종목의 최근 거래 이력을 조회합니다. '
|
||||||
|
'승률, 손익, 매도 사유 등을 확인해 이 종목의 과거 성과를 파악하세요.'
|
||||||
|
),
|
||||||
|
'parameters': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'ticker': {'type': 'string', 'description': '종목 코드 (예: KRW-XRP)'},
|
||||||
|
'limit': {'type': 'integer', 'description': '조회 건수 (기본 10)'},
|
||||||
|
},
|
||||||
|
'required': ['ticker'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'function',
|
||||||
|
'function': {
|
||||||
|
'name': 'get_btc_trend',
|
||||||
|
'description': (
|
||||||
|
'BTC(비트코인)의 최근 가격 동향을 조회합니다. '
|
||||||
|
'알트코인 매수 전 BTC 추세를 반드시 확인하세요. '
|
||||||
|
'BTC 하락 시 알트코인 동반 하락 위험이 높습니다.'
|
||||||
|
),
|
||||||
|
'parameters': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -195,6 +286,13 @@ def _execute_tool(tool_name: str, tool_input: dict) -> str:
|
|||||||
)
|
)
|
||||||
if tool_name == 'get_ticker_context':
|
if tool_name == 'get_ticker_context':
|
||||||
return _tool_get_context(ticker=tool_input['ticker'])
|
return _tool_get_context(ticker=tool_input['ticker'])
|
||||||
|
if tool_name == 'get_trade_history':
|
||||||
|
return _tool_get_trade_history(
|
||||||
|
ticker=tool_input['ticker'],
|
||||||
|
limit=tool_input.get('limit', 10),
|
||||||
|
)
|
||||||
|
if tool_name == 'get_btc_trend':
|
||||||
|
return _tool_get_btc_trend()
|
||||||
return f'알 수 없는 tool: {tool_name}'
|
return f'알 수 없는 tool: {tool_name}'
|
||||||
|
|
||||||
|
|
||||||
@@ -319,7 +417,176 @@ def _build_prompt(
|
|||||||
watch_needed: 관망이 필요한 상황이면 true (급변동 예상, 불확실성 높음 등)"""
|
watch_needed: 관망이 필요한 상황이면 true (급변동 예상, 불확실성 높음 등)"""
|
||||||
|
|
||||||
|
|
||||||
# ── 메인 함수 ─────────────────────────────────────────────────────────────────
|
# ── 공통 LLM 호출 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _call_llm(prompt: str, ticker: str) -> Optional[dict]:
|
||||||
|
"""OpenRouter API를 호출하고 JSON 응답을 반환. 실패 시 None."""
|
||||||
|
import requests as _req
|
||||||
|
import re
|
||||||
|
|
||||||
|
api_key = os.environ.get('OPENROUTER_API_KEY', '')
|
||||||
|
if not api_key:
|
||||||
|
log.debug('[LLM] OPENROUTER_API_KEY 없음')
|
||||||
|
return None
|
||||||
|
|
||||||
|
model = os.environ.get('LLM_MODEL', 'anthropic/claude-haiku-4.5')
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {api_key}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
messages = [{'role': 'user', 'content': prompt}]
|
||||||
|
|
||||||
|
try:
|
||||||
|
for _ in range(5):
|
||||||
|
body = {
|
||||||
|
'model': model,
|
||||||
|
'max_tokens': 512,
|
||||||
|
'tools': _TOOLS,
|
||||||
|
'messages': messages,
|
||||||
|
'response_format': {'type': 'json_object'},
|
||||||
|
}
|
||||||
|
resp = _req.post(
|
||||||
|
'https://openrouter.ai/api/v1/chat/completions',
|
||||||
|
headers=headers, json=body, timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
choice = result['choices'][0]
|
||||||
|
message = choice['message']
|
||||||
|
|
||||||
|
tool_calls = message.get('tool_calls')
|
||||||
|
if tool_calls:
|
||||||
|
messages.append(message)
|
||||||
|
for tc in tool_calls:
|
||||||
|
fn_name = tc['function']['name']
|
||||||
|
fn_args = json.loads(tc['function']['arguments'])
|
||||||
|
fn_result = _execute_tool(fn_name, fn_args)
|
||||||
|
log.info(f'[LLM-Tool] {ticker} {fn_name}({fn_args}) 호출')
|
||||||
|
messages.append({
|
||||||
|
'role': 'tool',
|
||||||
|
'tool_call_id': tc['id'],
|
||||||
|
'content': fn_result,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw = (message.get('content') or '').strip()
|
||||||
|
if not raw:
|
||||||
|
log.warning(f'[LLM] {ticker} 빈 응답')
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 코드블록 안 JSON 추출
|
||||||
|
if '```' in raw:
|
||||||
|
m = re.search(r'```(?:json)?\s*(.*?)\s*```', raw, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
raw = m.group(1)
|
||||||
|
# 텍스트에 섞인 JSON 객체 추출
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
m = re.search(r'\{[^{}]*"action"\s*:\s*"[^"]+?"[^{}]*\}', raw, re.DOTALL)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return json.loads(m.group(0))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
log.warning(f'[LLM] {ticker} JSON 파싱 실패: {e} raw={raw[:200]}')
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
log.warning(f'[LLM] {ticker} tool 루프 초과')
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f'[LLM] {ticker} 오류: {e}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 매수 판단 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_entry_price(
|
||||||
|
ticker: str,
|
||||||
|
signal: dict,
|
||||||
|
bar_list: list[dict],
|
||||||
|
current_price: float,
|
||||||
|
fng: int = 50,
|
||||||
|
num_positions: int = 0,
|
||||||
|
max_positions: int = 3,
|
||||||
|
) -> Optional[float]:
|
||||||
|
"""LLM에게 매수 여부 + 지정가를 물어본다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ticker: 종목 코드
|
||||||
|
signal: 시그널 정보 {vol_ratio, prices, ...}
|
||||||
|
bar_list: 최근 20초봉 리스트
|
||||||
|
current_price: 현재 가격
|
||||||
|
fng: 현재 F&G 지수
|
||||||
|
num_positions: 현재 보유 포지션 수
|
||||||
|
max_positions: 최대 포지션 수
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
float → 지정가 매수 가격
|
||||||
|
None → 매수하지 않음
|
||||||
|
"""
|
||||||
|
bar_desc = _describe_bars(bar_list, current_price)
|
||||||
|
mkt_ctx = _get_market_context(ticker)
|
||||||
|
|
||||||
|
vol_ratio = signal.get('vol_ratio', 0)
|
||||||
|
market_section = f'\n{mkt_ctx}\n' if mkt_ctx else ''
|
||||||
|
|
||||||
|
prompt = f"""당신은 암호화폐 단기 트레이더입니다.
|
||||||
|
아래 시그널을 분석해 **매수 여부와 지정가 매수 가격**을 판단하세요.
|
||||||
|
반드시 제공된 DB tool을 호출해 추가 데이터를 조회하세요:
|
||||||
|
- get_btc_trend: BTC 추세 확인 (필수 — BTC 하락 시 알트 매수 위험)
|
||||||
|
- get_ticker_context: 종목 24h/7d 변동, 뉴스 확인
|
||||||
|
- get_trade_history: 이 종목 과거 거래 성과 확인
|
||||||
|
- get_ohlcv: 1분봉으로 지지/저항선 확인
|
||||||
|
|
||||||
|
[시그널 감지]
|
||||||
|
종목 : {ticker}
|
||||||
|
현재가 : {current_price:,.0f}원
|
||||||
|
거래량비: {vol_ratio:.1f}x (61봉 평균 대비)
|
||||||
|
F&G지수: {fng} ({'공포' if fng <= 40 else '중립' if fng <= 50 else '탐욕'})
|
||||||
|
포지션 : {num_positions}/{max_positions}
|
||||||
|
{market_section}
|
||||||
|
[최근 {INPUT_BARS}봉 (20초봉)]
|
||||||
|
{bar_desc}
|
||||||
|
|
||||||
|
[판단 기준]
|
||||||
|
- 거래량 급증이 진짜 매집 신호인지, 일시적 노이즈인지 구분
|
||||||
|
- BTC가 하락 중이면 알트코인 매수 자제
|
||||||
|
- 최근 이 종목에서 연패 중이면 신중하게
|
||||||
|
- 현재가보다 약간 낮은 지정가를 설정해 유리한 가격에 매수
|
||||||
|
- 상승 추세가 이미 많이 진행됐으면 진입 자제
|
||||||
|
|
||||||
|
반드시 아래 JSON 형식으로만 응답하세요. 설명이나 다른 텍스트를 절대 포함하지 마세요.
|
||||||
|
|
||||||
|
매수할 경우:
|
||||||
|
{{"action": "buy", "price": 숫자, "confidence": "high|medium|low", "reason": "판단 근거 한줄 요약", "market_status": "상승|하락|횡보|급등|급락"}}
|
||||||
|
|
||||||
|
매수하지 않을 경우:
|
||||||
|
{{"action": "skip", "reason": "매수하지 않는 이유 한줄 요약", "market_status": "상승|하락|횡보|급등|급락"}}"""
|
||||||
|
|
||||||
|
data = _call_llm(prompt, ticker)
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
reason = data.get('reason', '')
|
||||||
|
status = data.get('market_status', '')
|
||||||
|
|
||||||
|
if data.get('action') == 'skip':
|
||||||
|
log.info(f'[LLM매수] {ticker} → skip | {status} | {reason}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
if data.get('action') == 'buy':
|
||||||
|
price = float(data['price'])
|
||||||
|
confidence = data.get('confidence', '?')
|
||||||
|
log.info(f'[LLM매수] {ticker} → buy {price:,.0f}원 | {confidence} | {status} | {reason}')
|
||||||
|
return price
|
||||||
|
|
||||||
|
log.warning(f'[LLM매수] {ticker} 알 수 없는 action: {data}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 매도 판단 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_exit_price(
|
def get_exit_price(
|
||||||
ticker: str,
|
ticker: str,
|
||||||
@@ -339,101 +606,30 @@ def get_exit_price(
|
|||||||
float → 새 지정가 (현재 주문가와 MIN_CHANGE_R 이상 차이)
|
float → 새 지정가 (현재 주문가와 MIN_CHANGE_R 이상 차이)
|
||||||
None → hold 또는 오류
|
None → hold 또는 오류
|
||||||
"""
|
"""
|
||||||
import requests as _req
|
entry_price = pos['entry_price']
|
||||||
|
elapsed_min = (datetime.now() - pos['entry_ts']).total_seconds() / 60
|
||||||
|
current_target = pos.get('sell_price') or entry_price * 1.005
|
||||||
|
|
||||||
api_key = os.environ.get('OPENROUTER_API_KEY', '')
|
bar_desc = _describe_bars(bar_list, current_price)
|
||||||
if not api_key:
|
mkt_ctx = _get_market_context(ticker)
|
||||||
log.debug('[LLM] OPENROUTER_API_KEY 없음 → cascade fallback')
|
prompt = _build_prompt(
|
||||||
|
ticker, entry_price, current_price,
|
||||||
|
elapsed_min, current_target, bar_desc,
|
||||||
|
market_context=mkt_ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = _call_llm(prompt, ticker)
|
||||||
|
if data is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
model = os.environ.get('LLM_MODEL', 'anthropic/claude-haiku-4-5-20251001')
|
reason = data.get('reason', '')
|
||||||
|
status = data.get('market_status', '')
|
||||||
|
|
||||||
try:
|
if data.get('action') == 'hold':
|
||||||
entry_price = pos['entry_price']
|
log.info(f'[LLM매도] {ticker} → hold | {status} | {reason}')
|
||||||
elapsed_min = (datetime.now() - pos['entry_ts']).total_seconds() / 60
|
|
||||||
current_target = pos.get('sell_price') or entry_price * 1.005
|
|
||||||
|
|
||||||
bar_desc = _describe_bars(bar_list, current_price)
|
|
||||||
mkt_ctx = _get_market_context(ticker)
|
|
||||||
prompt = _build_prompt(
|
|
||||||
ticker, entry_price, current_price,
|
|
||||||
elapsed_min, current_target, bar_desc,
|
|
||||||
market_context=mkt_ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
messages = [{'role': 'user', 'content': prompt}]
|
|
||||||
|
|
||||||
# Tool use 루프: LLM이 tool을 요청하면 실행 후 결과 전달
|
|
||||||
for _ in range(5): # 최대 5회 tool 호출
|
|
||||||
body = {
|
|
||||||
'model': model,
|
|
||||||
'max_tokens': 512,
|
|
||||||
'tools': _TOOLS,
|
|
||||||
'messages': messages,
|
|
||||||
'response_format': {'type': 'json_object'},
|
|
||||||
}
|
|
||||||
resp = _req.post(
|
|
||||||
'https://openrouter.ai/api/v1/chat/completions',
|
|
||||||
headers=headers, json=body, timeout=30,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
result = resp.json()
|
|
||||||
|
|
||||||
choice = result['choices'][0]
|
|
||||||
message = choice['message']
|
|
||||||
|
|
||||||
# tool_calls가 있으면 실행
|
|
||||||
tool_calls = message.get('tool_calls')
|
|
||||||
if tool_calls:
|
|
||||||
messages.append(message) # assistant 메시지 추가
|
|
||||||
for tc in tool_calls:
|
|
||||||
fn_name = tc['function']['name']
|
|
||||||
fn_args = json.loads(tc['function']['arguments'])
|
|
||||||
fn_result = _execute_tool(fn_name, fn_args)
|
|
||||||
log.info(f'[LLM-Tool] {ticker} {fn_name}({fn_args}) 호출')
|
|
||||||
messages.append({
|
|
||||||
'role': 'tool',
|
|
||||||
'tool_call_id': tc['id'],
|
|
||||||
'content': fn_result,
|
|
||||||
})
|
|
||||||
continue # 다시 LLM에게 결과 전달
|
|
||||||
|
|
||||||
# 최종 텍스트 응답
|
|
||||||
raw = (message.get('content') or '').strip()
|
|
||||||
if not raw:
|
|
||||||
log.warning(f'[LLM] {ticker} 빈 응답 → cascade fallback')
|
|
||||||
return None
|
|
||||||
|
|
||||||
# JSON 추출 (```json 블록이나 순수 JSON 모두 처리)
|
|
||||||
if '```' in raw:
|
|
||||||
import re
|
|
||||||
m = re.search(r'```(?:json)?\s*(.*?)\s*```', raw, re.DOTALL)
|
|
||||||
raw = m.group(1) if m else raw
|
|
||||||
data = json.loads(raw)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
log.warning(f'[LLM] {ticker} tool 루프 초과 → cascade fallback')
|
|
||||||
return None
|
|
||||||
|
|
||||||
reason = data.get('reason', '')
|
|
||||||
status = data.get('market_status', '')
|
|
||||||
|
|
||||||
if data.get('action') == 'hold':
|
|
||||||
log.info(f'[LLM] {ticker} → hold | {status} | {reason}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
suggested = float(data['price'])
|
|
||||||
confidence = data.get('confidence', '?')
|
|
||||||
log.info(f'[LLM] {ticker} 지정가 교체: {current_target:,.0f} → {suggested:,.0f}원 | {confidence} | {status} | {reason}')
|
|
||||||
return suggested
|
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
log.warning(f'[LLM] {ticker} JSON 파싱 실패: {e} raw={raw[:100]} → cascade fallback')
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
log.warning(f'[LLM] {ticker} 오류: {e} → cascade fallback')
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
suggested = float(data['price'])
|
||||||
|
confidence = data.get('confidence', '?')
|
||||||
|
log.info(f'[LLM매도] {ticker} 지정가 교체: {current_target:,.0f} → {suggested:,.0f}원 | {confidence} | {status} | {reason}')
|
||||||
|
return suggested
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""WebSocket 기반 20초봉 트레이더.
|
"""WebSocket 기반 20초봉 트레이더.
|
||||||
|
|
||||||
구조:
|
구조:
|
||||||
WebSocket → trade tick 수신 → 20초봉 집계 → 3봉 가속 시그널(VOL≥8x) → cascade 청산
|
WebSocket → trade tick 수신 → 20초봉 집계
|
||||||
|
→ 시그널(양봉+VOL≥4x) → LLM 매수 판단 → 지정가 매수
|
||||||
|
→ LLM primary 매도 + cascade fallback 청산
|
||||||
|
|
||||||
cascade (초 기준):
|
cascade (초 기준):
|
||||||
① 0~ 40초: +2.0% 지정가
|
① 0~ 40초: +2.0% 지정가
|
||||||
@@ -24,7 +26,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env'))
|
||||||
|
|
||||||
from core.llm_advisor import get_exit_price
|
from core.llm_advisor import get_exit_price, get_entry_price
|
||||||
|
|
||||||
import pyupbit
|
import pyupbit
|
||||||
|
|
||||||
@@ -41,7 +43,8 @@ TICKERS = [
|
|||||||
BAR_SEC = 20 # 봉 주기 (초)
|
BAR_SEC = 20 # 봉 주기 (초)
|
||||||
VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수
|
VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수
|
||||||
ATR_LOOKBACK = 28 # ATR 계산 봉 수
|
ATR_LOOKBACK = 28 # ATR 계산 봉 수
|
||||||
VOL_MIN = 8.0 # 거래량 배수 임계값
|
VOL_MIN = 4.0 # 거래량 배수 임계값 (완화 — LLM이 최종 판단)
|
||||||
|
BUY_TIMEOUT = 60 # 지정가 매수 미체결 타임아웃 (초)
|
||||||
|
|
||||||
MAX_POS = int(os.environ.get('MAX_POSITIONS', 3))
|
MAX_POS = int(os.environ.get('MAX_POSITIONS', 3))
|
||||||
PER_POS = int(os.environ.get('MAX_BUDGET', 15_000_000)) // MAX_POS
|
PER_POS = int(os.environ.get('MAX_BUDGET', 15_000_000)) // MAX_POS
|
||||||
@@ -114,10 +117,11 @@ def on_tick(ticker: str, price: float, volume: float) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def finalize_bars() -> None:
|
def finalize_bars() -> None:
|
||||||
"""BAR_SEC마다 봉 확정 + 지정가 체결 확인."""
|
"""BAR_SEC마다 봉 확정 → 시그널 감지 → LLM 매수 판단 → 체결 확인."""
|
||||||
while True:
|
while True:
|
||||||
time.sleep(BAR_SEC)
|
time.sleep(BAR_SEC)
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
signals = []
|
||||||
with bar_lock:
|
with bar_lock:
|
||||||
for ticker in list(cur_bar.keys()):
|
for ticker in list(cur_bar.keys()):
|
||||||
b = cur_bar[ticker]
|
b = cur_bar[ticker]
|
||||||
@@ -125,8 +129,13 @@ def finalize_bars() -> None:
|
|||||||
continue
|
continue
|
||||||
bars[ticker].append(b)
|
bars[ticker].append(b)
|
||||||
cur_bar[ticker] = _new_bar(b['close'], 0, now)
|
cur_bar[ticker] = _new_bar(b['close'], 0, now)
|
||||||
check_and_enter(ticker)
|
sig = detect_signal(ticker)
|
||||||
# 봉 확정 후 지정가 체결 확인 (bar_lock 밖에서)
|
if sig:
|
||||||
|
signals.append(sig)
|
||||||
|
# bar_lock 밖에서 LLM 호출 + 체결 확인
|
||||||
|
for sig in signals:
|
||||||
|
process_signal(sig)
|
||||||
|
check_pending_buys()
|
||||||
check_filled_positions()
|
check_filled_positions()
|
||||||
|
|
||||||
|
|
||||||
@@ -156,69 +165,36 @@ def calc_atr(bar_list: list) -> float:
|
|||||||
return (sum(trs) / len(trs)) / prev_close if prev_close > 0 else 0.0
|
return (sum(trs) / len(trs)) / prev_close if prev_close > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
# ── 시그널 감지 ───────────────────────────────────────────────────────────────
|
# ── 시그널 감지 (완화 — LLM이 최종 판단) ────────────────────────────────────
|
||||||
def check_and_enter(ticker: str) -> None:
|
def detect_signal(ticker: str) -> Optional[dict]:
|
||||||
|
"""양봉 + 거래량 VOL_MIN 이상이면 시그널 후보 반환. bar_lock 안에서 호출."""
|
||||||
bar_list = list(bars[ticker])
|
bar_list = list(bars[ticker])
|
||||||
n = len(bar_list)
|
n = len(bar_list)
|
||||||
|
|
||||||
if n < VOL_LOOKBACK + 5:
|
if n < VOL_LOOKBACK + 5:
|
||||||
return
|
return None
|
||||||
if ticker in positions:
|
if ticker in positions or ticker in pending_buys:
|
||||||
return
|
return None
|
||||||
if len(positions) >= MAX_POS:
|
if len(positions) + len(pending_buys) >= MAX_POS:
|
||||||
return
|
return None
|
||||||
|
|
||||||
b0, b1, b2 = bar_list[-3], bar_list[-2], bar_list[-1]
|
b = bar_list[-1]
|
||||||
|
if b['close'] <= b['open']:
|
||||||
|
return None
|
||||||
|
|
||||||
if not all(b['close'] > b['open'] for b in [b0, b1, b2]):
|
vr = calc_vr(bar_list, n - 1)
|
||||||
return
|
if vr < VOL_MIN:
|
||||||
if not (b2['close'] > b1['close'] > b0['close']):
|
return None
|
||||||
return
|
|
||||||
|
|
||||||
vr2 = calc_vr(bar_list, n - 1)
|
return {
|
||||||
vr1 = calc_vr(bar_list, n - 2)
|
'ticker': ticker,
|
||||||
vr0 = calc_vr(bar_list, n - 3)
|
'price': b['close'],
|
||||||
|
'vol_ratio': vr,
|
||||||
if vr2 < VOL_MIN or not (vr2 > vr1 > vr0):
|
'bar_list': bar_list,
|
||||||
return
|
}
|
||||||
|
|
||||||
atr_raw = calc_atr(bar_list)
|
|
||||||
entry_price = b2['close']
|
|
||||||
|
|
||||||
log.info(f"[시그널] {ticker} {entry_price:,.0f}원 vol {vr2:.1f}x")
|
|
||||||
tg(
|
|
||||||
f"🔔 <b>시그널</b> {ticker}\n"
|
|
||||||
f"가격: {b0['close']:,.0f}→{b1['close']:,.0f}→{b2['close']:,.0f}\n"
|
|
||||||
f"볼륨: {vr0:.1f}x→{vr1:.1f}x→{vr2:.1f}x"
|
|
||||||
)
|
|
||||||
enter_position(ticker, entry_price, atr_raw, [vr0, vr1, vr2])
|
|
||||||
|
|
||||||
|
|
||||||
# ── 주문 ──────────────────────────────────────────────────────────────────────
|
# ── 주문 ──────────────────────────────────────────────────────────────────────
|
||||||
def do_buy(ticker: str) -> tuple:
|
|
||||||
"""시장가 매수. Returns (qty, avg_price)."""
|
|
||||||
if SIM_MODE:
|
|
||||||
price = pyupbit.get_current_price(ticker)
|
|
||||||
qty = PER_POS * (1 - FEE) / price
|
|
||||||
log.info(f"[SIM 매수] {ticker} {PER_POS:,}원 → {qty:.6f}개 @ {price:,.0f}")
|
|
||||||
return qty, price
|
|
||||||
try:
|
|
||||||
order = upbit_client.buy_market_order(ticker, PER_POS)
|
|
||||||
if not order or 'error' in str(order):
|
|
||||||
log.error(f"매수 실패: {order}")
|
|
||||||
return None, None
|
|
||||||
uuid = order.get('uuid')
|
|
||||||
time.sleep(1.5)
|
|
||||||
qty = upbit_client.get_balance(ticker.split('-')[1])
|
|
||||||
avg_price = _avg_price_from_order(uuid) if uuid else None
|
|
||||||
if not avg_price:
|
|
||||||
avg_price = pyupbit.get_current_price(ticker)
|
|
||||||
return (qty if qty and qty > 0 else None), avg_price
|
|
||||||
except Exception as e:
|
|
||||||
log.error(f"매수 오류 {ticker}: {e}")
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def _round_price(price: float) -> float:
|
def _round_price(price: float) -> float:
|
||||||
"""Upbit 주문가격 단위로 내림 처리 (invalid_price_ask 방지)."""
|
"""Upbit 주문가격 단위로 내림 처리 (invalid_price_ask 방지)."""
|
||||||
if price >= 2_000_000: unit = 1000
|
if price >= 2_000_000: unit = 1000
|
||||||
@@ -309,21 +285,100 @@ def do_sell_market(ticker: str, qty: float) -> Optional[float]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
|
# ── 지정가 매수 (LLM 판단) ───────────────────────────────────────────────────
|
||||||
positions: dict = {}
|
pending_buys: dict = {} # ticker → {uuid, price, qty, ts, vol_ratio}
|
||||||
|
|
||||||
|
|
||||||
def enter_position(ticker: str, entry_price: float, atr_raw: float, vr: list) -> None:
|
def process_signal(sig: dict) -> None:
|
||||||
qty, actual_price = do_buy(ticker)
|
"""시그널 감지 후 LLM에게 매수 판단 요청 → 지정가 매수 제출."""
|
||||||
if qty is None:
|
ticker = sig['ticker']
|
||||||
log.warning(f"[진입 실패] {ticker}")
|
bar_list = sig['bar_list']
|
||||||
|
cur_price = sig['price']
|
||||||
|
vol_ratio = sig['vol_ratio']
|
||||||
|
|
||||||
|
log.info(f"[시그널] {ticker} {cur_price:,.0f}원 vol {vol_ratio:.1f}x → LLM 판단 요청")
|
||||||
|
tg(f"🔔 <b>시그널</b> {ticker}\n가격: {cur_price:,.0f}원 볼륨: {vol_ratio:.1f}x\nLLM 판단 요청 중...")
|
||||||
|
|
||||||
|
buy_price = get_entry_price(
|
||||||
|
ticker=ticker,
|
||||||
|
signal=sig,
|
||||||
|
bar_list=bar_list,
|
||||||
|
current_price=cur_price,
|
||||||
|
num_positions=len(positions),
|
||||||
|
max_positions=MAX_POS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if buy_price is None:
|
||||||
|
tg(f"⏭️ <b>매수 스킵</b> {ticker}\nLLM이 매수 거절")
|
||||||
return
|
return
|
||||||
|
|
||||||
entry_price = actual_price or entry_price
|
buy_price = _round_price(buy_price)
|
||||||
|
qty = PER_POS * (1 - FEE) / buy_price
|
||||||
|
|
||||||
# ① 지정가 매도 즉시 제출
|
if SIM_MODE:
|
||||||
|
uuid = f"sim-buy-{ticker}"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
order = upbit_client.buy_limit_order(ticker, buy_price, qty)
|
||||||
|
if not order or 'error' in str(order):
|
||||||
|
log.error(f"지정가 매수 제출 실패: {order}")
|
||||||
|
return
|
||||||
|
uuid = order.get('uuid')
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"지정가 매수 오류 {ticker}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
pending_buys[ticker] = {
|
||||||
|
'uuid': uuid,
|
||||||
|
'price': buy_price,
|
||||||
|
'qty': qty,
|
||||||
|
'ts': datetime.now(),
|
||||||
|
'vol_ratio': vol_ratio,
|
||||||
|
}
|
||||||
|
log.info(f"[지정가매수] {ticker} {buy_price:,.0f}원 수량: {qty:.6f}")
|
||||||
|
tg(
|
||||||
|
f"📥 <b>지정가 매수 제출</b> {ticker}\n"
|
||||||
|
f"가격: {buy_price:,.0f}원 수량: {qty:.6f}\n"
|
||||||
|
f"볼륨: {vol_ratio:.1f}x\n"
|
||||||
|
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_pending_buys() -> None:
|
||||||
|
"""지정가 매수 주문 체결 확인. 체결 시 포지션 등록, 타임아웃 시 취소."""
|
||||||
|
for ticker in list(pending_buys.keys()):
|
||||||
|
pb = pending_buys[ticker]
|
||||||
|
elapsed = (datetime.now() - pb['ts']).total_seconds()
|
||||||
|
|
||||||
|
if SIM_MODE:
|
||||||
|
# SIM: 최근 봉 저가가 매수 지정가 이하이면 체결
|
||||||
|
bar_list = list(bars.get(ticker, []))
|
||||||
|
if bar_list and bar_list[-1]['low'] <= pb['price']:
|
||||||
|
log.info(f"[SIM 매수체결] {ticker} {pb['price']:,.0f}원")
|
||||||
|
_activate_position(ticker, pb['price'], pb['qty'], pb['vol_ratio'])
|
||||||
|
del pending_buys[ticker]
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
state, avg_price = check_order_state(pb['uuid'])
|
||||||
|
if state == 'done':
|
||||||
|
actual_price = avg_price or pb['price']
|
||||||
|
actual_qty = upbit_client.get_balance(ticker.split('-')[1]) or pb['qty']
|
||||||
|
_activate_position(ticker, actual_price, actual_qty, pb['vol_ratio'])
|
||||||
|
del pending_buys[ticker]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 타임아웃
|
||||||
|
if elapsed >= BUY_TIMEOUT:
|
||||||
|
cancel_order_safe(pb['uuid'])
|
||||||
|
log.info(f"[매수취소] {ticker} {elapsed:.0f}초 미체결 → 취소")
|
||||||
|
tg(f"❌ <b>매수 취소</b> {ticker}\n{pb['price']:,.0f}원 {elapsed:.0f}초 미체결")
|
||||||
|
del pending_buys[ticker]
|
||||||
|
|
||||||
|
|
||||||
|
def _activate_position(ticker: str, entry_price: float, qty: float, vol_ratio: float) -> None:
|
||||||
|
"""매수 체결 후 포지션 등록 + cascade 매도 설정."""
|
||||||
_, _, lr, tag = CASCADE_STAGES[0]
|
_, _, lr, tag = CASCADE_STAGES[0]
|
||||||
target = entry_price * (1 + lr)
|
target = entry_price * (1 + lr)
|
||||||
sell_uuid = submit_limit_sell(ticker, qty, target)
|
sell_uuid = submit_limit_sell(ticker, qty, target)
|
||||||
|
|
||||||
positions[ticker] = {
|
positions[ticker] = {
|
||||||
@@ -334,18 +389,22 @@ def enter_position(ticker: str, entry_price: float, atr_raw: float, vr: list) ->
|
|||||||
'stage': 0,
|
'stage': 0,
|
||||||
'sell_uuid': sell_uuid,
|
'sell_uuid': sell_uuid,
|
||||||
'sell_price': target,
|
'sell_price': target,
|
||||||
'llm_last_ts': None, # LLM 마지막 호출 시각
|
'llm_last_ts': None,
|
||||||
|
'llm_active': False,
|
||||||
}
|
}
|
||||||
log.info(f"[진입] {ticker} {entry_price:,.0f}원 vol {vr[2]:.1f}x "
|
log.info(f"[진입] {ticker} {entry_price:,.0f}원 vol {vol_ratio:.1f}x 지정가 {tag} {target:,.0f}원")
|
||||||
f"지정가 {tag} {target:,.0f}원")
|
|
||||||
tg(
|
tg(
|
||||||
f"🟢 <b>매수</b> {ticker}\n"
|
f"🟢 <b>매수 체결</b> {ticker}\n"
|
||||||
f"체결가: {entry_price:,.0f}원 수량: {qty:.6f}\n"
|
f"체결가: {entry_price:,.0f}원 수량: {qty:.6f}\n"
|
||||||
f"지정가 매도 제출: {tag} {target:,.0f}원 (+{lr*100:.1f}%)\n"
|
f"지정가 매도: {tag} {target:,.0f}원 (+{lr*100:.1f}%)\n"
|
||||||
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
|
||||||
|
positions: dict = {}
|
||||||
|
|
||||||
|
|
||||||
def _advance_stage(ticker: str) -> None:
|
def _advance_stage(ticker: str) -> None:
|
||||||
"""다음 cascade 단계로 전환. 기존 지정가 취소 후 재주문."""
|
"""다음 cascade 단계로 전환. 기존 지정가 취소 후 재주문."""
|
||||||
pos = positions[ticker]
|
pos = positions[ticker]
|
||||||
|
|||||||
Reference in New Issue
Block a user