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:
joungmin
2026-03-05 21:52:44 +09:00
parent 7f1921441b
commit 19a35e1009
2 changed files with 435 additions and 180 deletions

View File

@@ -1,21 +1,14 @@
"""OpenRouter LLM 기반 매도 목표가 어드바이저.
"""OpenRouter LLM 기반 매매 어드바이저.
1분 주기로 보유 포지션의 OHLCV 흐름을 분석해 최적 지정가 매도 목표가를 반환.
LLM이 주(primary), 기존 cascade 규칙이 fallback.
프롬프트에 포함되는 시장 데이터:
- 오늘 일봉 (고가/저가/거래량)
- 최근 4시간 1시간봉 (가격/볼륨 흐름)
- 최근 20봉 20초봉 (단기 패턴)
매수: 시그널 감지 후 LLM이 매수 여부 + 지정가 결정
매도: 1분 주기로 LLM이 매도 목표가 결정 (cascade fallback)
LLM에게 제공하는 DB Tool (OpenAI function calling):
- get_price_ticks(ticker, minutes): Oracle price_tick 테이블 (최근 N분 가격 틱)
- get_ohlcv(ticker, limit): Oracle backtest_ohlcv 1분봉 (지지/저항 파악용)
- get_ticker_context(ticker): 종목 평판 정보 (가격 변동, 뉴스)
반환값:
float → 새 지정가 목표가
None → hold (현재 주문 유지) 또는 오류
- get_trade_history(ticker): 최근 거래 이력 (승패, 손익)
- get_btc_trend(): BTC 최근 동향 (알트 매수 판단용)
"""
from __future__ import annotations
@@ -121,6 +114,71 @@ def _tool_get_context(ticker: str) -> str:
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 형식) ─────────────────────────────────
_TOOLS = [
@@ -167,7 +225,7 @@ _TOOLS = [
'description': (
'종목의 평판 정보를 조회합니다. 24h/7d 가격 변동률, 거래량 추이, '
'최근 뉴스 등 중장기 컨텍스트를 제공합니다. '
'매도 판단 시 시장 분위기와 종목 상황을 파악하는 데 사용하세요.'
'매매 판단 시 시장 분위기와 종목 상황을 파악하는 데 사용하세요.'
),
'parameters': {
'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':
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}'
@@ -319,7 +417,176 @@ def _build_prompt(
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(
ticker: str,
@@ -339,16 +606,6 @@ def get_exit_price(
float → 새 지정가 (현재 주문가와 MIN_CHANGE_R 이상 차이)
None → hold 또는 오류
"""
import requests as _req
api_key = os.environ.get('OPENROUTER_API_KEY', '')
if not api_key:
log.debug('[LLM] OPENROUTER_API_KEY 없음 → cascade fallback')
return None
model = os.environ.get('LLM_MODEL', 'anthropic/claude-haiku-4-5-20251001')
try:
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
@@ -361,79 +618,18 @@ def get_exit_price(
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')
data = _call_llm(prompt, ticker)
if data is None:
return None
reason = data.get('reason', '')
status = data.get('market_status', '')
if data.get('action') == 'hold':
log.info(f'[LLM] {ticker} → hold | {status} | {reason}')
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}')
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

View File

@@ -1,7 +1,9 @@
"""WebSocket 기반 20초봉 트레이더.
구조:
WebSocket → trade tick 수신 → 20초봉 집계 → 3봉 가속 시그널(VOL≥8x) → cascade 청산
WebSocket → trade tick 수신 → 20초봉 집계
→ 시그널(양봉+VOL≥4x) → LLM 매수 판단 → 지정가 매수
→ LLM primary 매도 + cascade fallback 청산
cascade (초 기준):
① 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
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
@@ -41,7 +43,8 @@ TICKERS = [
BAR_SEC = 20 # 봉 주기 (초)
VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수
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))
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:
"""BAR_SEC마다 봉 확정 + 지정가 체결 확인."""
"""BAR_SEC마다 봉 확정 → 시그널 감지 → LLM 매수 판단 → 체결 확인."""
while True:
time.sleep(BAR_SEC)
now = datetime.now()
signals = []
with bar_lock:
for ticker in list(cur_bar.keys()):
b = cur_bar[ticker]
@@ -125,8 +129,13 @@ def finalize_bars() -> None:
continue
bars[ticker].append(b)
cur_bar[ticker] = _new_bar(b['close'], 0, now)
check_and_enter(ticker)
# 봉 확정 후 지정가 체결 확인 (bar_lock 밖에서)
sig = detect_signal(ticker)
if sig:
signals.append(sig)
# bar_lock 밖에서 LLM 호출 + 체결 확인
for sig in signals:
process_signal(sig)
check_pending_buys()
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
# ── 시그널 감지 ───────────────────────────────────────────────────────────────
def check_and_enter(ticker: str) -> None:
# ── 시그널 감지 (완화 — LLM이 최종 판단) ────────────────────────────────────
def detect_signal(ticker: str) -> Optional[dict]:
"""양봉 + 거래량 VOL_MIN 이상이면 시그널 후보 반환. bar_lock 안에서 호출."""
bar_list = list(bars[ticker])
n = len(bar_list)
if n < VOL_LOOKBACK + 5:
return
if ticker in positions:
return
if len(positions) >= MAX_POS:
return
return None
if ticker in positions or ticker in pending_buys:
return None
if len(positions) + len(pending_buys) >= MAX_POS:
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]):
return
if not (b2['close'] > b1['close'] > b0['close']):
return
vr = calc_vr(bar_list, n - 1)
if vr < VOL_MIN:
return None
vr2 = calc_vr(bar_list, n - 1)
vr1 = calc_vr(bar_list, n - 2)
vr0 = calc_vr(bar_list, n - 3)
if vr2 < VOL_MIN or not (vr2 > vr1 > vr0):
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])
return {
'ticker': ticker,
'price': b['close'],
'vol_ratio': vr,
'bar_list': bar_list,
}
# ── 주문 ──────────────────────────────────────────────────────────────────────
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:
"""Upbit 주문가격 단위로 내림 처리 (invalid_price_ask 방지)."""
if price >= 2_000_000: unit = 1000
@@ -309,19 +285,98 @@ def do_sell_market(ticker: str, qty: float) -> Optional[float]:
return None
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
positions: dict = {}
# ── 지정가 매수 (LLM 판단) ───────────────────────────────────────────────────
pending_buys: dict = {} # ticker → {uuid, price, qty, ts, vol_ratio}
def enter_position(ticker: str, entry_price: float, atr_raw: float, vr: list) -> None:
qty, actual_price = do_buy(ticker)
if qty is None:
log.warning(f"[진입 실패] {ticker}")
def process_signal(sig: dict) -> None:
"""시그널 감지 후 LLM에게 매수 판단 요청 → 지정가 매수 제출."""
ticker = sig['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
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]
target = entry_price * (1 + lr)
sell_uuid = submit_limit_sell(ticker, qty, target)
@@ -334,18 +389,22 @@ def enter_position(ticker: str, entry_price: float, atr_raw: float, vr: list) ->
'stage': 0,
'sell_uuid': sell_uuid,
'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 "
f"지정가 {tag} {target:,.0f}원")
log.info(f"[진입] {ticker} {entry_price:,.0f}원 vol {vol_ratio:.1f}x 지정가 {tag} {target:,.0f}원")
tg(
f"🟢 <b>매수</b> {ticker}\n"
f"🟢 <b>매수 체결</b> {ticker}\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 '[실거래]'}"
)
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
positions: dict = {}
def _advance_stage(ticker: str) -> None:
"""다음 cascade 단계로 전환. 기존 지정가 취소 후 재주문."""
pos = positions[ticker]