- VOL_MIN 4→8 복원 (시그널 빈도 과다) - process_signal: LLM 호출 전/후 포지션 한도 재확인 - check_pending_buys: 체결 시점 한도 초과면 즉시 취소 - LLM tool 중복 호출 방지 (같은 tool+args → 캐시 응답) - 모든 tool 호출 완료 시 tool 제거해 강제 텍스트 응답 - max_rounds 8→5 축소 - 재시작 시 Upbit 잔고 기반 포지션 자동 복구 - LLM 모델: google/gemini-2.5-flash로 전환 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
742 lines
29 KiB
Python
742 lines
29 KiB
Python
"""WebSocket 기반 20초봉 트레이더.
|
|
|
|
구조:
|
|
WebSocket → trade tick 수신 → 20초봉 집계
|
|
→ 시그널(양봉+VOL≥4x) → LLM 매수 판단 → 지정가 매수
|
|
→ LLM primary 매도 + cascade fallback 청산
|
|
|
|
cascade (초 기준):
|
|
① 0~ 40초: +2.0% 지정가
|
|
② 40~ 100초: +1.0% 지정가
|
|
③ 100~ 300초: +0.5% 지정가
|
|
④ 300~3500초: +0.1% 지정가
|
|
⑤ 3500초~: Trail Stop 0.8% 시장가
|
|
|
|
실행:
|
|
.venv/bin/python3 daemons/tick_trader.py
|
|
로그:
|
|
/tmp/tick_trader.log
|
|
"""
|
|
import sys, os, time, logging, threading, requests, math
|
|
from datetime import datetime, timedelta
|
|
from collections import deque, defaultdict
|
|
from typing import Optional
|
|
|
|
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, get_entry_price
|
|
|
|
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',
|
|
'KRW-MANTRA', 'KRW-EDGE', 'KRW-CFG', 'KRW-ARDR', 'KRW-SIGN',
|
|
'KRW-AZTEC', 'KRW-ATH', 'KRW-HOLO', 'KRW-BREV', 'KRW-SHIB',
|
|
]
|
|
|
|
BAR_SEC = 20 # 봉 주기 (초)
|
|
VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수
|
|
ATR_LOOKBACK = 28 # ATR 계산 봉 수
|
|
VOL_MIN = 8.0 # 거래량 배수 임계값
|
|
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
|
|
FEE = 0.0005
|
|
|
|
# cascade 청산 (초 기준) — 지정가 매도
|
|
CASCADE_STAGES = [
|
|
(0, 40, 0.020, '①'), # 2봉
|
|
(40, 100, 0.010, '②'), # 3봉
|
|
(100, 300, 0.005, '③'), # 10봉
|
|
(300, 3500, 0.001, '④'), # 160봉
|
|
]
|
|
TRAIL_STOP_R = 0.008
|
|
TIMEOUT_SECS = 14400 # 4시간
|
|
LLM_INTERVAL = 60 # LLM 호출 간격 (초)
|
|
LLM_MIN_ELAPSED = 60 # 진입 후 최소 N초 이후부터 LLM 활성
|
|
|
|
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
|
|
|
|
upbit_client = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY'])
|
|
|
|
TG_TOKEN = os.environ.get('TELEGRAM_TRADE_TOKEN', '')
|
|
TG_CHAT_ID = os.environ.get('TELEGRAM_CHAT_ID', '')
|
|
|
|
# ── 로깅 ──────────────────────────────────────────────────────────────────────
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s %(levelname)s %(message)s',
|
|
handlers=[
|
|
logging.FileHandler('/tmp/tick_trader.log'),
|
|
]
|
|
)
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
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}')
|
|
|
|
|
|
# ── 20초봉 집계 ───────────────────────────────────────────────────────────────
|
|
bars: dict = defaultdict(lambda: deque(maxlen=VOL_LOOKBACK + 10))
|
|
cur_bar: dict = {}
|
|
bar_lock = threading.Lock()
|
|
|
|
|
|
def _new_bar(price: float, volume: float, ts: datetime) -> dict:
|
|
return {'open': price, 'high': price, 'low': price,
|
|
'close': price, 'volume': volume, 'ts': ts}
|
|
|
|
|
|
def on_tick(ticker: str, price: float, volume: float) -> None:
|
|
with bar_lock:
|
|
if ticker not in cur_bar:
|
|
cur_bar[ticker] = _new_bar(price, volume, datetime.now())
|
|
return
|
|
b = cur_bar[ticker]
|
|
b['high'] = max(b['high'], price)
|
|
b['low'] = min(b['low'], price)
|
|
b['close'] = price
|
|
b['volume'] += volume
|
|
|
|
|
|
def finalize_bars() -> None:
|
|
"""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]
|
|
if b['volume'] == 0:
|
|
continue
|
|
bars[ticker].append(b)
|
|
cur_bar[ticker] = _new_bar(b['close'], 0, now)
|
|
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()
|
|
|
|
|
|
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
|
|
def calc_vr(bar_list: list, idx: int) -> float:
|
|
start = max(0, idx - VOL_LOOKBACK)
|
|
end = max(0, idx - 2)
|
|
baseline = [bar_list[i]['volume'] for i in range(start, end)]
|
|
if not baseline:
|
|
return 0.0
|
|
avg = sum(baseline) / len(baseline)
|
|
return bar_list[idx]['volume'] / avg if avg > 0 else 0.0
|
|
|
|
|
|
def calc_atr(bar_list: list) -> float:
|
|
if len(bar_list) < ATR_LOOKBACK + 2:
|
|
return 0.0
|
|
trs = []
|
|
for i in range(-ATR_LOOKBACK - 1, -1):
|
|
b = bar_list[i]
|
|
bp = bar_list[i - 1]
|
|
tr = max(b['high'] - b['low'],
|
|
abs(b['high'] - bp['close']),
|
|
abs(b['low'] - bp['close']))
|
|
trs.append(tr)
|
|
prev_close = bar_list[-2]['close']
|
|
return (sum(trs) / len(trs)) / prev_close if prev_close > 0 else 0.0
|
|
|
|
|
|
# ── 시그널 감지 (완화 — 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 None
|
|
if ticker in positions or ticker in pending_buys:
|
|
return None
|
|
if len(positions) + len(pending_buys) >= MAX_POS:
|
|
return None
|
|
|
|
b = bar_list[-1]
|
|
if b['close'] <= b['open']:
|
|
return None
|
|
|
|
vr = calc_vr(bar_list, n - 1)
|
|
if vr < VOL_MIN:
|
|
return None
|
|
|
|
return {
|
|
'ticker': ticker,
|
|
'price': b['close'],
|
|
'vol_ratio': vr,
|
|
'bar_list': bar_list,
|
|
}
|
|
|
|
|
|
# ── 주문 ──────────────────────────────────────────────────────────────────────
|
|
def _round_price(price: float) -> float:
|
|
"""Upbit 주문가격 단위로 내림 처리 (invalid_price_ask 방지)."""
|
|
if price >= 2_000_000: unit = 1000
|
|
elif price >= 1_000_000: unit = 500
|
|
elif price >= 500_000: unit = 100
|
|
elif price >= 100_000: unit = 50
|
|
elif price >= 10_000: unit = 10
|
|
elif price >= 1_000: unit = 5
|
|
elif price >= 100: unit = 1
|
|
elif price >= 10: unit = 0.1
|
|
else: unit = 0.01
|
|
return math.floor(price / unit) * unit
|
|
|
|
|
|
def submit_limit_sell(ticker: str, qty: float, price: float) -> Optional[str]:
|
|
"""지정가 매도 주문. Returns UUID."""
|
|
price = _round_price(price)
|
|
if SIM_MODE:
|
|
return f"sim-{ticker}"
|
|
try:
|
|
order = upbit_client.sell_limit_order(ticker, price, qty)
|
|
if not order or 'error' in str(order):
|
|
log.error(f"지정가 매도 제출 실패: {order}")
|
|
return None
|
|
return order.get('uuid')
|
|
except Exception as e:
|
|
log.error(f"지정가 매도 오류 {ticker}: {e}")
|
|
return None
|
|
|
|
|
|
def cancel_order_safe(uuid: Optional[str]) -> None:
|
|
if SIM_MODE or not uuid or uuid.startswith('sim-'):
|
|
return
|
|
try:
|
|
upbit_client.cancel_order(uuid)
|
|
except Exception as e:
|
|
log.warning(f"주문 취소 실패 {uuid}: {e}")
|
|
|
|
|
|
def check_order_state(uuid: str) -> tuple:
|
|
"""Returns (state, avg_price). state: 'done'|'wait'|'cancel'|None"""
|
|
try:
|
|
detail = upbit_client.get_order(uuid)
|
|
if not detail:
|
|
return None, None
|
|
state = detail.get('state')
|
|
avg_price = float(detail.get('avg_price') or 0) or None
|
|
return state, avg_price
|
|
except Exception as e:
|
|
log.warning(f"주문 조회 실패 {uuid}: {e}")
|
|
return None, None
|
|
|
|
|
|
def _avg_price_from_order(uuid: str) -> Optional[float]:
|
|
try:
|
|
detail = upbit_client.get_order(uuid)
|
|
if not detail:
|
|
return None
|
|
trades = detail.get('trades', [])
|
|
if trades:
|
|
total_funds = sum(float(t['funds']) for t in trades)
|
|
total_vol = sum(float(t['volume']) for t in trades)
|
|
return total_funds / total_vol if total_vol > 0 else None
|
|
avg = detail.get('avg_price')
|
|
return float(avg) if avg else None
|
|
except Exception as e:
|
|
log.warning(f"체결가 조회 실패 {uuid}: {e}")
|
|
return None
|
|
|
|
|
|
def do_sell_market(ticker: str, qty: float) -> Optional[float]:
|
|
"""Trail Stop / Timeout용 시장가 매도."""
|
|
if SIM_MODE:
|
|
price = pyupbit.get_current_price(ticker)
|
|
log.info(f"[SIM 시장가매도] {ticker} {qty:.6f}개 @ {price:,.0f}")
|
|
return price
|
|
try:
|
|
order = upbit_client.sell_market_order(ticker, qty)
|
|
if not order or 'error' in str(order):
|
|
log.error(f"시장가 매도 실패: {order}")
|
|
return None
|
|
uuid = order.get('uuid')
|
|
time.sleep(1.5)
|
|
avg_price = _avg_price_from_order(uuid) if uuid else None
|
|
return avg_price or pyupbit.get_current_price(ticker)
|
|
except Exception as e:
|
|
log.error(f"시장가 매도 오류 {ticker}: {e}")
|
|
return None
|
|
|
|
|
|
# ── 지정가 매수 (LLM 판단) ───────────────────────────────────────────────────
|
|
pending_buys: dict = {} # ticker → {uuid, price, qty, ts, vol_ratio}
|
|
|
|
|
|
def process_signal(sig: dict) -> None:
|
|
"""시그널 감지 후 LLM에게 매수 판단 요청 → 지정가 매수 제출."""
|
|
ticker = sig['ticker']
|
|
bar_list = sig['bar_list']
|
|
cur_price = sig['price']
|
|
vol_ratio = sig['vol_ratio']
|
|
|
|
# LLM 호출 전 포지션 수 재확인 (동시 진행 방지)
|
|
if len(positions) + len(pending_buys) >= MAX_POS:
|
|
log.info(f"[시그널] {ticker} 포지션 한도 도달 → 스킵")
|
|
return
|
|
|
|
log.info(f"[시그널] {ticker} {cur_price:,.0f}원 vol {vol_ratio:.1f}x → LLM 판단 요청")
|
|
|
|
llm_result = get_entry_price(
|
|
ticker=ticker,
|
|
signal=sig,
|
|
bar_list=bar_list,
|
|
current_price=cur_price,
|
|
num_positions=len(positions),
|
|
max_positions=MAX_POS,
|
|
)
|
|
|
|
if llm_result is None or llm_result.get('action') != 'buy':
|
|
reason = llm_result.get('reason', 'LLM 오류') if llm_result else 'LLM 무응답'
|
|
status = llm_result.get('market_status', '') if llm_result else ''
|
|
log.info(f"[매수/LLM] {ticker} → 스킵 | {reason}")
|
|
tg(
|
|
f"⏭️ <b>매수 스킵</b> {ticker}\n"
|
|
f"현재가: {cur_price:,.0f}원 볼륨: {vol_ratio:.1f}x\n"
|
|
f"시장: {status}\n"
|
|
f"사유: {reason}"
|
|
)
|
|
return
|
|
|
|
# LLM 호출 후 포지션 수 재확인
|
|
if len(positions) + len(pending_buys) >= MAX_POS:
|
|
log.info(f"[매수/LLM] {ticker} → 승인됐으나 포지션 한도 도달 → 스킵")
|
|
return
|
|
|
|
buy_price = _round_price(llm_result['price'])
|
|
confidence = llm_result.get('confidence', '?')
|
|
reason = llm_result.get('reason', '')
|
|
status = llm_result.get('market_status', '')
|
|
qty = PER_POS * (1 - FEE) / buy_price
|
|
diff_pct = (buy_price - cur_price) / cur_price * 100
|
|
log.info(f"[매수/LLM] {ticker} → 승인 {buy_price:,.0f}원 (현재가 {cur_price:,.0f}원, 차이 {diff_pct:+.2f}%)")
|
|
|
|
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}원 (현재가 대비 {diff_pct:+.2f}%)\n"
|
|
f"수량: {qty:.6f} 볼륨: {vol_ratio:.1f}x\n"
|
|
f"확신: {confidence} 시장: {status}\n"
|
|
f"LLM: {reason}\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 len(positions) >= MAX_POS:
|
|
cancel_order_safe(pb['uuid'])
|
|
log.info(f"[매수취소] {ticker} 포지션 한도({MAX_POS}) 도달 → 취소")
|
|
del pending_buys[ticker]
|
|
continue
|
|
|
|
if SIM_MODE:
|
|
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)
|
|
|
|
positions[ticker] = {
|
|
'entry_price': entry_price,
|
|
'entry_ts': datetime.now(),
|
|
'running_peak': entry_price,
|
|
'qty': qty,
|
|
'stage': 0,
|
|
'sell_uuid': sell_uuid,
|
|
'sell_price': target,
|
|
'llm_last_ts': None,
|
|
'llm_active': False,
|
|
}
|
|
log.info(f"[진입] {ticker} {entry_price:,.0f}원 vol {vol_ratio:.1f}x 지정가 {tag} {target:,.0f}원")
|
|
tg(
|
|
f"🟢 <b>매수 체결</b> {ticker}\n"
|
|
f"체결가: {entry_price:,.0f}원 수량: {qty:.6f}\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]
|
|
cancel_order_safe(pos.get('sell_uuid'))
|
|
next_stage = pos['stage'] + 1
|
|
pos['stage'] = next_stage
|
|
|
|
if next_stage < len(CASCADE_STAGES):
|
|
_, _, lr, tag = CASCADE_STAGES[next_stage]
|
|
target = pos['entry_price'] * (1 + lr)
|
|
uuid = submit_limit_sell(ticker, pos['qty'], target)
|
|
pos['sell_uuid'] = uuid
|
|
pos['sell_price'] = target
|
|
log.info(f"[단계전환] {ticker} → {tag} 목표가 {target:,.0f}원")
|
|
else:
|
|
pos['sell_uuid'] = None
|
|
pos['sell_price'] = None
|
|
log.info(f"[단계전환] {ticker} → ⑤ Trail Stop")
|
|
|
|
|
|
def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
|
|
"""체결 완료 후 포지션 종료 처리."""
|
|
pos = positions[ticker]
|
|
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())
|
|
|
|
reason_tag = {
|
|
'①': '① +2.0% 익절', '②': '② +1.0% 익절',
|
|
'③': '③ +0.5% 익절', '④': '④ +0.1% 본전',
|
|
'trail': '⑤ 트레일스탑', 'timeout': '⑤ 타임아웃',
|
|
}.get(tag, tag)
|
|
|
|
llm_flag = 'LLM' if pos.get('llm_active') else 'cascade'
|
|
icon = "✅" if pnl > 0 else "🔴"
|
|
log.info(f"[청산/{tag}/{llm_flag}] {ticker} {exit_price:,.0f}원 PNL {pnl:+.2f}% {krw:+,.0f}원 {held}초 보유")
|
|
tg(
|
|
f"{icon} <b>청산</b> {ticker} [{reason_tag}] ({llm_flag})\n"
|
|
f"진입: {pos['entry_price']:,.0f}원\n"
|
|
f"청산: {exit_price:,.0f}원\n"
|
|
f"PNL: <b>{pnl:+.2f}%</b> ({krw:+,.0f}원) {held}초 보유\n"
|
|
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
|
|
)
|
|
del positions[ticker]
|
|
|
|
|
|
def _should_call_llm(pos: dict, elapsed: float) -> bool:
|
|
"""LLM 호출 조건: 진입 후 LLM_MIN_ELAPSED 초 경과 + LLM_INTERVAL 간격."""
|
|
if elapsed < LLM_MIN_ELAPSED:
|
|
return False
|
|
last = pos.get('llm_last_ts')
|
|
if last is None:
|
|
return True
|
|
return (datetime.now() - last).total_seconds() >= LLM_INTERVAL
|
|
|
|
|
|
def check_filled_positions() -> None:
|
|
"""20초마다 지정가 체결 확인.
|
|
|
|
흐름:
|
|
1. 체결 완료 확인
|
|
2. LLM 어드바이저 호출 (1분 주기) → 목표가 반환 시 주문 교체
|
|
3. LLM hold/오류 시 cascade fallback (단계 시간 초과 → 다음 단계)
|
|
"""
|
|
for ticker in list(positions.keys()):
|
|
if ticker not in positions:
|
|
continue
|
|
pos = positions[ticker]
|
|
uuid = pos.get('sell_uuid')
|
|
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
|
|
|
|
if uuid is None:
|
|
# Trail Stop 구간 — update_positions(tick)에서 처리
|
|
continue
|
|
|
|
stage = pos['stage']
|
|
_, end, _, tag = CASCADE_STAGES[stage]
|
|
bar_list = list(bars.get(ticker, []))
|
|
|
|
if SIM_MODE:
|
|
# SIM: 최근 봉 고가가 목표가 이상이면 체결
|
|
if bar_list and bar_list[-1]['high'] >= pos['sell_price']:
|
|
_record_exit(ticker, pos['sell_price'], tag)
|
|
continue
|
|
else:
|
|
# 실거래: API로 체결 확인
|
|
state, avg_price = check_order_state(uuid)
|
|
if state == 'done':
|
|
_record_exit(ticker, avg_price or pos['sell_price'], tag)
|
|
continue
|
|
if state in ('cancel', None):
|
|
_advance_stage(ticker)
|
|
continue
|
|
|
|
# ── LLM 어드바이저 (primary) ──────────────────────────────────────
|
|
if _should_call_llm(pos, elapsed):
|
|
pos['llm_last_ts'] = datetime.now()
|
|
current_price = bar_list[-1]['close'] if bar_list else pos['sell_price']
|
|
llm_sell = get_exit_price(ticker, pos, bar_list, current_price)
|
|
|
|
if llm_sell is not None and llm_sell.get('action') == 'sell':
|
|
new_price = llm_sell['price']
|
|
confidence = llm_sell.get('confidence', '?')
|
|
reason = llm_sell.get('reason', '')
|
|
status = llm_sell.get('market_status', '')
|
|
watch = llm_sell.get('watch_needed', False)
|
|
pnl_pct = (new_price - pos['entry_price']) / pos['entry_price'] * 100
|
|
|
|
cancel_order_safe(uuid)
|
|
new_uuid = submit_limit_sell(ticker, pos['qty'], new_price)
|
|
pos['sell_uuid'] = new_uuid
|
|
pos['sell_price'] = new_price
|
|
pos['llm_active'] = True
|
|
log.info(f"[매도/LLM] {ticker} 지정가 {new_price:,.0f}원 설정")
|
|
tg(
|
|
f"🤖 <b>LLM 매도 설정</b> {ticker}\n"
|
|
f"지정가: {new_price:,.0f}원 (진입 대비 {pnl_pct:+.2f}%)\n"
|
|
f"확신: {confidence} 시장: {status} 관망: {'Y' if watch else 'N'}\n"
|
|
f"LLM: {reason}"
|
|
)
|
|
continue
|
|
else:
|
|
reason = llm_sell.get('reason', 'hold') if llm_sell else '오류/무응답'
|
|
watch = llm_sell.get('watch_needed', False) if llm_sell else False
|
|
pos['llm_active'] = False
|
|
log.info(f"[매도/LLM→fallback] {ticker} {reason} → cascade 대기")
|
|
|
|
# ── Cascade fallback: LLM 실패 시에만 단계 전환 ──────────────────
|
|
if not pos.get('llm_active') and elapsed >= end:
|
|
log.info(f"[매도/cascade] {ticker} {elapsed:.0f}초 경과 → 다음 단계")
|
|
_advance_stage(ticker)
|
|
|
|
|
|
def update_positions(current_prices: dict) -> None:
|
|
"""tick마다 Trail Stop / Timeout 체크 — ③ 종료(300s) 이후에만 동작."""
|
|
stage3_end = CASCADE_STAGES[2][1] # 300초
|
|
|
|
for ticker in list(positions.keys()):
|
|
if ticker not in current_prices:
|
|
continue
|
|
pos = positions[ticker]
|
|
price = current_prices[ticker]
|
|
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
|
|
|
|
# ③ 이전: peak 추적 안 함, Trail Stop 비활성
|
|
if elapsed < stage3_end:
|
|
continue
|
|
|
|
# ③ 종료 직후 첫 틱: peak을 현재가로 초기화 (진입가 기준 제거)
|
|
if not pos.get('trail_peak_set'):
|
|
pos['running_peak'] = price
|
|
pos['trail_peak_set'] = True
|
|
else:
|
|
pos['running_peak'] = max(pos['running_peak'], price)
|
|
|
|
# 지정가 주문 중이면 Trail Stop 비활성
|
|
if pos.get('sell_uuid') is not None:
|
|
continue
|
|
|
|
drop = (pos['running_peak'] - price) / pos['running_peak']
|
|
|
|
if drop >= TRAIL_STOP_R:
|
|
exit_price = do_sell_market(ticker, pos['qty']) or price
|
|
_record_exit(ticker, exit_price, 'trail')
|
|
elif elapsed >= TIMEOUT_SECS and price <= pos['entry_price']:
|
|
exit_price = do_sell_market(ticker, pos['qty']) or price
|
|
_record_exit(ticker, exit_price, 'timeout')
|
|
|
|
|
|
# ── 메인 ──────────────────────────────────────────────────────────────────────
|
|
def preload_bars() -> None:
|
|
need_min = (VOL_LOOKBACK + 10) // 3 + 1
|
|
log.info(f"[사전적재] REST API 1분봉 {need_min}개로 bars[] 초기화 중...")
|
|
loaded = 0
|
|
for ticker in TICKERS:
|
|
for attempt in range(3):
|
|
try:
|
|
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=need_min)
|
|
if df is None or df.empty:
|
|
time.sleep(0.5)
|
|
continue
|
|
with bar_lock:
|
|
for _, row in df.iterrows():
|
|
o, h, l, c = float(row['open']), float(row['high']), float(row['low']), float(row['close'])
|
|
v3 = float(row['volume']) / 3
|
|
ts = row.name.to_pydatetime()
|
|
for _ in range(3):
|
|
bars[ticker].append({'open': o, 'high': h, 'low': l, 'close': c, 'volume': v3, 'ts': ts})
|
|
loaded += 1
|
|
break
|
|
except Exception as e:
|
|
log.warning(f"[사전적재] {ticker} 시도{attempt+1} 실패: {e}")
|
|
time.sleep(1)
|
|
time.sleep(0.2)
|
|
log.info(f"[사전적재] 완료 {loaded}/{len(TICKERS)} 티커")
|
|
|
|
|
|
def restore_positions() -> None:
|
|
"""Upbit 잔고에서 보유 종목을 positions에 복구 (재시작 대응)."""
|
|
if SIM_MODE:
|
|
return
|
|
try:
|
|
balances = upbit_client.get_balances()
|
|
for b in balances:
|
|
currency = b.get('currency', '')
|
|
bal = float(b.get('balance', 0))
|
|
avg = float(b.get('avg_buy_price', 0))
|
|
if currency == 'KRW' or bal <= 0 or avg <= 0:
|
|
continue
|
|
ticker = f'KRW-{currency}'
|
|
if ticker not in TICKERS:
|
|
continue
|
|
if ticker in positions:
|
|
continue
|
|
# cascade ① 지정가 매도 즉시 제출
|
|
_, _, lr, stag = CASCADE_STAGES[0]
|
|
target = avg * (1 + lr)
|
|
sell_uuid = submit_limit_sell(ticker, bal, target)
|
|
positions[ticker] = {
|
|
'entry_price': avg,
|
|
'entry_ts': datetime.now() - timedelta(seconds=LLM_MIN_ELAPSED), # LLM 즉시 활성
|
|
'running_peak': avg,
|
|
'qty': bal,
|
|
'stage': 0,
|
|
'sell_uuid': sell_uuid,
|
|
'sell_price': target,
|
|
'llm_last_ts': None,
|
|
'llm_active': False,
|
|
}
|
|
log.info(f"[복구] {ticker} 수량:{bal:.6f} 매수평균:{avg:,.0f}원")
|
|
tg(f"♻️ <b>포지션 복구</b> {ticker}\n매수평균: {avg:,.0f}원 수량: {bal:.6f}")
|
|
if positions:
|
|
log.info(f"[복구] 총 {len(positions)}개 포지션 복구됨")
|
|
except Exception as e:
|
|
log.warning(f"[복구] 잔고 조회 실패: {e}")
|
|
|
|
|
|
def main():
|
|
mode = "🔴 실거래" if not SIM_MODE else "🟡 시뮬레이션"
|
|
log.info(f"=== tick_trader 시작 ({mode}) ===")
|
|
log.info(f"봉주기: 20초 | VOL >= {VOL_MIN}x | 포지션 최대 {MAX_POS}개 | 1개당 {PER_POS:,}원")
|
|
stage_nums = ['①','②','③','④','⑤','⑥']
|
|
stage_desc = ' → '.join(
|
|
f"{stage_nums[i]} {s[1]}초 +{s[2]*100:.1f}%" for i, s in enumerate(CASCADE_STAGES)
|
|
)
|
|
log.info(f"청산: {stage_desc} → {stage_nums[len(CASCADE_STAGES)]} Trail -{TRAIL_STOP_R*100:.1f}% (지정가→시장가)")
|
|
tg(
|
|
f"🚀 <b>tick_trader 시작</b> ({mode})\n"
|
|
f"봉주기 20초 | VOL ≥ {VOL_MIN}x | 최대 {MAX_POS}포지션\n"
|
|
f"① 40초 +2.0% 지정가\n"
|
|
f"② 100초 +1.0% 지정가\n"
|
|
f"③ 700초 +0.5% 지정가\n"
|
|
f"④ 3100초 +0.1% 지정가\n"
|
|
f"⑤ Trail -{TRAIL_STOP_R*100:.1f}% 시장가"
|
|
)
|
|
|
|
preload_bars()
|
|
restore_positions()
|
|
|
|
t = threading.Thread(target=finalize_bars, daemon=True)
|
|
t.start()
|
|
|
|
ws = pyupbit.WebSocketManager("trade", TICKERS)
|
|
log.info("WebSocket 연결됨")
|
|
|
|
last_pos_log = time.time()
|
|
|
|
while True:
|
|
try:
|
|
data = ws.get()
|
|
if data is None:
|
|
continue
|
|
|
|
ticker = data.get('code')
|
|
price = data.get('trade_price')
|
|
volume = data.get('trade_volume')
|
|
|
|
if not ticker or price is None or volume is None:
|
|
continue
|
|
|
|
on_tick(ticker, float(price), float(volume))
|
|
|
|
if positions:
|
|
update_positions({ticker: float(price)})
|
|
|
|
if time.time() - last_pos_log > 60:
|
|
warmed = sum(1 for t in TICKERS if len(bars[t]) >= VOL_LOOKBACK + 5)
|
|
if positions:
|
|
pos_lines = ' '.join(
|
|
f"{t.split('-')[1]} {p['entry_price']:,.0f}→{p['running_peak']:,.0f} [{CASCADE_STAGES[p['stage']][3] if p['stage'] < len(CASCADE_STAGES) else '⑤'}]"
|
|
for t, p in positions.items()
|
|
)
|
|
log.info(f"[상태] 포지션 {len(positions)}/{MAX_POS} {pos_lines}")
|
|
else:
|
|
log.info(f"[상태] 포지션 없음 ({warmed}/{len(TICKERS)} 준비완료)")
|
|
last_pos_log = time.time()
|
|
|
|
except Exception as e:
|
|
log.error(f"루프 오류: {e}")
|
|
time.sleep(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|