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,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,21 +285,100 @@ 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)
target = entry_price * (1 + lr)
sell_uuid = submit_limit_sell(ticker, qty, target)
positions[ticker] = {
@@ -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]