Files
upbit-trader/daemons/live_trader.py
joungmin 7f1921441b feat: OpenRouter LLM 매도 어드바이저 + 종목 컨텍스트 수집 데몬
- llm_advisor: Anthropic → OpenRouter API 전환 (claude-haiku-4.5)
- llm_advisor: get_ticker_context DB tool 추가 (24h/7d 가격, 뉴스)
- llm_advisor: 구조화 JSON 응답 (confidence, reason, market_status, watch_needed)
- llm_advisor: LLM primary + cascade fallback (llm_active 플래그)
- llm_advisor: SQL bind variable 버그 수정 (INTERVAL → NUMTODSINTERVAL)
- tick_collector: backtest_ohlcv 1분봉 실시간 갱신 추가 (60초 주기)
- context_collector: 신규 데몬 — 1시간마다 price_stats + SearXNG 뉴스 수집
- ecosystem: tick-collector, tick-trader, context-collector PM2 등록

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:39:02 +09:00

364 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""실시간 1분봉 볼륨 가속 트레이더.
4봉 연속 가격+볼륨 가속 시그널(VOL≥8x) 감지 후 실제 매수/매도 + Telegram 알림.
실행:
.venv/bin/python3 daemons/live_trader.py
로그:
/tmp/live_trader.log
"""
import sys, os, time, logging, requests
from datetime import datetime
from typing import Optional
import pandas as pd
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'))
import pyupbit
# ── 전략 파라미터 ──────────────────────────────────────────────────────────────
TICKERS = [
'KRW-XRP', 'KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-DOGE',
'KRW-ADA', 'KRW-SUI', 'KRW-NEAR', 'KRW-KAVA', 'KRW-SXP',
'KRW-AKT', 'KRW-SONIC', 'KRW-IP', 'KRW-ORBS', 'KRW-VIRTUAL',
'KRW-BARD', 'KRW-XPL', 'KRW-KITE', 'KRW-ENSO', 'KRW-0G',
]
VOL_LOOKBACK = 61
ATR_LOOKBACK = 28
FETCH_BARS = 100
VOL_MIN = 8.0
ATR_MULT = 1.0
ATR_MIN_R = 0.030 # 3.0% (ATR 계산용, ⑤ trail에서는 미사용)
ATR_MAX_R = 0.050 # 5.0%
# ── Cascade 청산 파라미터 ──────────────────────────────────────────────────────
# (시작분, 종료분, limit 수익률)
CASCADE_STAGES = [
(0, 2, 0.020), # ① 0~ 2분: 현재가 >= 진입가×1.020 → 청산
(2, 5, 0.010), # ② 2~ 5분: 현재가 >= 진입가×1.010 → 청산
(5, 35, 0.005), # ③ 5~35분: 현재가 >= 진입가×1.005 → 청산
(35, 155, 0.001), # ④ 35~155분: 현재가 >= 진입가×1.001 → 청산 (본전)
]
TRAIL_STOP_R = 0.004 # ⑤ 155분~: Trail Stop 0.4%
MAX_POS = int(os.environ.get('MAX_POSITIONS', 3))
PER_POS = int(os.environ.get('MAX_BUDGET', 15_000_000)) // MAX_POS
FEE = 0.0005
POLL_SEC = 65
API_DELAY = 0.12
TIMEOUT_BARS = 240 # 4시간: ⑤ Trail 구간에서 본전 이하 시 청산
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
# ── Upbit 클라이언트 ───────────────────────────────────────────────────────────
upbit = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY'])
# ── Telegram ──────────────────────────────────────────────────────────────────
TG_TOKEN = os.environ.get('TELEGRAM_TRADE_TOKEN', '')
TG_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '')
def tg(msg: str) -> None:
if not TG_TOKEN or not TG_CHAT_ID:
return
try:
requests.post(
f'https://api.telegram.org/bot{TG_TOKEN}/sendMessage',
json={'chat_id': TG_CHAT_ID, 'text': msg, 'parse_mode': 'HTML'},
timeout=5,
)
except Exception as e:
log.warning(f'Telegram 전송 실패: {e}')
# ── 로깅 ──────────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('/tmp/live_trader.log'),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger(__name__)
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
def compute_indicators(df: pd.DataFrame) -> pd.DataFrame:
vol_ma = df['volume'].rolling(VOL_LOOKBACK, min_periods=30).mean().shift(2)
df = df.copy()
df['vr'] = df['volume'] / vol_ma
prev_close = df['close'].shift(1)
tr = pd.concat([
df['high'] - df['low'],
(df['high'] - prev_close).abs(),
(df['low'] - prev_close).abs(),
], axis=1).max(axis=1)
df['atr_raw'] = tr.rolling(ATR_LOOKBACK, min_periods=10).mean() / prev_close
return df
def check_signal(df: pd.DataFrame) -> Optional[dict]:
"""마지막 3봉(완성봉) 가격+볼륨 가속 조건 체크."""
if len(df) < VOL_LOOKBACK + 10:
return None
b = df.iloc[-4:-1] # 완성된 마지막 3봉
if len(b) < 3:
return None
c = b['close'].values
o = b['open'].values
vr = b['vr'].values
if not all(c[i] > o[i] for i in range(3)): return None # 양봉
if not (c[2] > c[1] > c[0]): return None # 가격 가속
if not (vr[2] > vr[1] > vr[0]): return None # 볼륨 가속
if vr[2] < VOL_MIN: return None # VOL 임계값
atr_raw = float(b['atr_raw'].iloc[-1]) if not pd.isna(b['atr_raw'].iloc[-1]) else 0.0
return {
'sig_ts': b.index[-1],
'sig_price': float(c[2]),
'vr': list(vr),
'prices': list(c),
'atr_raw': atr_raw,
}
# ── 주문 ──────────────────────────────────────────────────────────────────────
def do_buy(ticker: str, krw_amount: int) -> Optional[float]:
"""시장가 매수. 실제 체결 수량 반환. 실패 시 None."""
if SIM_MODE:
current = pyupbit.get_current_price(ticker)
qty = krw_amount * (1 - FEE) / current
log.info(f"[SIM 매수] {ticker} {krw_amount:,}원 → {qty:.6f}개 @ {current:,.0f}")
return qty
try:
krw_bal = upbit.get_balance("KRW")
if krw_bal is None or krw_bal < krw_amount:
log.warning(f"KRW 잔고 부족: {krw_bal:,.0f}원 < {krw_amount:,}")
return None
order = upbit.buy_market_order(ticker, krw_amount)
if not order or 'error' in str(order):
log.error(f"매수 주문 실패: {order}")
return None
# 체결 대기 후 실제 보유량 조회
time.sleep(1.5)
coin = ticker.split('-')[1]
qty = upbit.get_balance(coin)
log.info(f"[매수 완료] {ticker} {krw_amount:,}원 → {qty:.6f}개 uuid={order.get('uuid','')[:8]}")
return qty if qty and qty > 0 else None
except Exception as e:
log.error(f"매수 오류 {ticker}: {e}")
return None
def do_sell(ticker: str, qty: float) -> Optional[float]:
"""시장가 매도. 체결가(추정) 반환. 실패 시 None."""
if SIM_MODE:
current = pyupbit.get_current_price(ticker)
log.info(f"[SIM 매도] {ticker} {qty:.6f}개 @ {current:,.0f}")
return current
try:
order = upbit.sell_market_order(ticker, qty)
if not order or 'error' in str(order):
log.error(f"매도 주문 실패: {order}")
return None
time.sleep(1.5)
current = pyupbit.get_current_price(ticker)
log.info(f"[매도 완료] {ticker} {qty:.6f}개 uuid={order.get('uuid','')[:8]}")
return current
except Exception as e:
log.error(f"매도 오류 {ticker}: {e}")
return None
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
positions: dict = {}
def enter_position(ticker: str, sig: dict, entry_price: float) -> None:
ar = sig['atr_raw']
atr_stop = max(ATR_MIN_R, min(ATR_MAX_R, ar * ATR_MULT)) if ar > 0 else ATR_MAX_R
qty = do_buy(ticker, PER_POS)
if qty is None:
log.warning(f"[진입 실패] {ticker} — 매수 주문 오류")
return
positions[ticker] = {
'entry_price': entry_price,
'entry_ts': datetime.now(),
'running_peak': entry_price,
'qty': qty,
'vr': sig['vr'],
'prices': sig['prices'],
}
log.info(f"[진입] {ticker} {entry_price:,.0f}원 vol {sig['vr'][2]:.1f}x cascade①2%②1%③0.5%④0.1%⑤trail0.4%")
tg(
f"🟢 <b>매수 완료</b> {ticker}\n"
f"체결가: {entry_price:,.0f}원 수량: {qty:.6f}\n"
f"전략: ①2% ②1% ③0.5% ④0.1%(본전) ⑤Trail0.4%\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
def _do_exit(ticker: str, current_price: float, reason: str) -> bool:
"""공통 청산 처리. reason: 'trail' | 'timeout'"""
pos = positions[ticker]
exit_price = do_sell(ticker, pos['qty'])
if exit_price is None:
exit_price = current_price
pnl = (exit_price - pos['entry_price']) / pos['entry_price'] * 100
krw = PER_POS * (pnl / 100) - PER_POS * FEE * 2
held = int((datetime.now() - pos['entry_ts']).total_seconds() / 60)
icon = "" if pnl > 0 else "🔴"
reason_tag = {
'①2%': '① +2.0% 익절',
'②1%': '② +1.0% 익절',
'③0.5%': '③ +0.5% 익절',
'④0.1%': '④ +0.1% 본전',
'⑤trail': '⑤ 트레일스탑',
'timeout': '⑤ 타임아웃',
}.get(reason, reason)
msg = (
f"{icon} <b>청산</b> {ticker} [{reason_tag}]\n"
f"진입: {pos['entry_price']:,.0f}\n"
f"고점: {pos['running_peak']:,.0f}원 ({(pos['running_peak']/pos['entry_price']-1)*100:+.2f}%)\n"
f"청산: {exit_price:,.0f}\n"
f"PNL: <b>{pnl:+.2f}%</b> ({krw:+,.0f}원) {held}분 보유\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
log.info(
f"[청산/{reason}] {ticker} {exit_price:,.0f}"
f"PNL {pnl:+.2f}% {krw:+,.0f}{held}분 보유"
)
tg(msg)
del positions[ticker]
return True
def update_position(ticker: str, current_price: float) -> bool:
"""Cascade 청산 체크. 청산 시 True 반환.
① 0~ 2분: +2.0% limit
② 2~ 5분: +1.0% limit
③ 5~35분: +0.5% limit
④ 35~155분: +0.1% limit (본전)
⑤ 155분~: Trail Stop 0.4%
"""
pos = positions[ticker]
ep = pos['entry_price']
held = int((datetime.now() - pos['entry_ts']).total_seconds() / 60)
# 항상 고점 갱신 (⑤ trail 진입 시 정확한 고점 기준)
pos['running_peak'] = max(pos['running_peak'], current_price)
# ①②③④: cascade limit 단계
stage_labels = {0: '①2%', 2: '②1%', 5: '③0.5%', 35: '④0.1%'}
for start, end, lr in CASCADE_STAGES:
if start <= held < end:
if current_price >= ep * (1 + lr):
return _do_exit(ticker, current_price, stage_labels[start])
return False
# ⑤: Trail Stop 0.4%
drop = (pos['running_peak'] - current_price) / pos['running_peak']
if drop >= TRAIL_STOP_R:
return _do_exit(ticker, current_price, '⑤trail')
# 타임아웃: 4시간 경과 + 본전 이하
if held >= TIMEOUT_BARS and current_price <= ep:
return _do_exit(ticker, current_price, 'timeout')
return False
# ── 메인 루프 ─────────────────────────────────────────────────────────────────
def run_once() -> None:
for ticker in TICKERS:
try:
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=FETCH_BARS)
time.sleep(API_DELAY)
except Exception as e:
log.warning(f"{ticker} API 오류: {e}")
continue
if df is None or len(df) < 30:
continue
df = compute_indicators(df)
current_price = float(df['close'].iloc[-1])
# 열린 포지션: trail stop 체크
if ticker in positions:
update_position(ticker, current_price)
continue
# 신규 진입: 시그널 체크 (슬롯 여부와 관계없이 항상 탐지)
sig = check_signal(df)
if sig:
vr = sig['vr']
pr = sig['prices']
slot_tag = f"→ 매수 진행" if len(positions) < MAX_POS else f"⚠️ 슬롯 {len(positions)}/{MAX_POS} 꽉 참"
# 시그널 감지 즉시 알림
tg(
f"🔔 <b>시그널</b> {ticker}\n"
f"가격: {pr[0]:,.0f}{pr[1]:,.0f}{pr[2]:,.0f}\n"
f"볼륨: {vr[0]:.1f}x→{vr[1]:.1f}x→{vr[2]:.1f}x\n"
f"현재가: {current_price:,.0f}원 ATR: {sig['atr_raw']*100:.2f}%\n"
f"{slot_tag}"
)
log.info(f"[시그널] {ticker} {current_price:,.0f}원 vol {vr[2]:.1f}x {slot_tag}")
if len(positions) < MAX_POS:
enter_position(ticker, sig, current_price)
pos_str = ', '.join(
f"{t}({p['entry_price']:,.0f}{p['running_peak']:,.0f}, {((p['running_peak']/p['entry_price'])-1)*100:+.1f}%)"
for t, p in positions.items()
) or "없음"
log.info(f"[상태] 포지션 {len(positions)}/{MAX_POS}: {pos_str}")
def main():
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
log.info(f"=== 실시간 트레이더 시작 ({mode}) ===")
log.info(f"전략: 3봉 vol가속 VOL≥{VOL_MIN}x, cascade①2%②1%③0.5%④0.1%⑤Trail{TRAIL_STOP_R*100:.1f}%")
log.info(f"종목: {len(TICKERS)}개 포지션당 {PER_POS:,}원 최대 {MAX_POS}")
tg(
f"🚀 <b>트레이더 시작</b> ({mode})\n"
f"3봉 VOL≥{VOL_MIN}x cascade①2%②1%③0.5%④0.1%⑤Trail{TRAIL_STOP_R*100:.1f}%\n"
f"종목 {len(TICKERS)}개 포지션당 {PER_POS:,}원 최대 {MAX_POS}"
)
while True:
t0 = time.time()
try:
run_once()
except Exception as e:
log.error(f"루프 오류: {e}")
elapsed = time.time() - t0
sleep = max(1.0, POLL_SEC - elapsed)
log.info(f"[대기] {sleep:.0f}초 후 다음 체크")
time.sleep(sleep)
if __name__ == '__main__':
main()