refactor: MVC 구조 분리 + 미사용 파일 archive 정리

- tick_trader.py를 Controller로 축소, 로직을 3개 모듈로 분리:
  - core/signal.py: 시그널 감지, 지표 계산 (calc_vr, calc_atr, detect_signal)
  - core/order.py: Upbit 주문 실행 (매수/매도/취소/조회)
  - core/position_manager.py: 포지션 관리, DB sync, 복구, 청산 조건
- type hints, Google docstring, 구체적 예외 타입 적용
- 50줄 초과 함수 분리 (process_signal, restore_positions)
- 미사용 파일 58개 archive/ 폴더로 이동
- README.md 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-06 20:46:47 +09:00
parent 976c53ed66
commit 6e0c4508fa
69 changed files with 5018 additions and 495 deletions

View File

@@ -1,4 +1,4 @@
"""WebSocket 기반 20초봉 트레이더.
"""WebSocket 기반 20초봉 트레이더 (Controller).
구조:
WebSocket -> trade tick 수신 -> 20초봉 집계
@@ -10,8 +10,13 @@
로그:
/tmp/tick_trader.log
"""
import sys, os, time, logging, threading, requests, math
from datetime import datetime, timedelta
import sys
import os
import time
import logging
import threading
import requests
from datetime import datetime
from collections import deque, defaultdict
from typing import Optional
@@ -19,10 +24,18 @@ 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
from core.llm_advisor import get_entry_price
from core.signal import detect_signal, calc_vr
from core.order import (
round_price, submit_limit_buy, cancel_order,
check_order_state, sell_market,
)
from core.position_manager import (
sync_position, calc_remaining_budget,
check_exit_conditions, restore_from_upbit,
)
import pyupbit
import oracledb
# ── 전략 파라미터 ──────────────────────────────────────────────────────────────
TICKERS = [
@@ -30,23 +43,21 @@ TICKERS = [
'KRW-BARD', 'KRW-KITE', 'KRW-CFG', 'KRW-SXP', 'KRW-ARDR',
]
BAR_SEC = 20 # 봉 주기 (초)
VOL_LOOKBACK = 61 # 거래량 평균 기준 봉 수
ATR_LOOKBACK = 28 # ATR 계산 봉 수
VOL_MIN = 5.0 # 거래량 배수 임계값
VOL_KRW_MIN = 5_000_000 # 20초봉 최소 거래대금 (원) — 소액 조작/봇 필터
BUY_TIMEOUT = 180 # 지정가 매수 미체결 타임아웃 (초)
BAR_SEC = 20
VOL_LOOKBACK = 61
VOL_MIN = 5.0
VOL_KRW_MIN = 5_000_000
BUY_TIMEOUT = 180
MAX_POS = int(os.environ.get('MAX_POSITIONS', 5))
MAX_BUDGET = int(os.environ.get('MAX_BUDGET', 1_000_000))
PER_POS = MAX_BUDGET // MAX_POS
FEE = 0.0005
MAX_POS = int(os.environ.get('MAX_POSITIONS', 5))
MAX_BUDGET = int(os.environ.get('MAX_BUDGET', 1_000_000))
PER_POS = MAX_BUDGET // MAX_POS
FEE = 0.0005
# 트레일링 스탑 청산
TRAIL_PCT = 0.015 # 고점 대비 -1.5% 하락 시 매도
MIN_PROFIT_PCT = 0.005 # 트레일 발동 최소 수익률 +0.5%
STOP_LOSS_PCT = 0.02 # -2% 손절
TIMEOUT_SECS = 14400 # 4시간
TRAIL_PCT = 0.015
MIN_PROFIT_PCT = 0.005
STOP_LOSS_PCT = 0.02
TIMEOUT_SECS = 14400
SIM_MODE = os.environ.get('SIMULATION_MODE', 'true').lower() == 'true'
@@ -59,58 +70,21 @@ 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'),
]
handlers=[logging.FileHandler('/tmp/tick_trader.log')],
)
log = logging.getLogger(__name__)
# ── position_sync DB ─────────────────────────────────────────────────────────
_db_conn = None
def _get_db():
global _db_conn
if _db_conn is None:
kwargs = dict(user=os.environ["ORACLE_USER"], password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
if w := os.environ.get("ORACLE_WALLET"):
kwargs["config_dir"] = w
_db_conn = oracledb.connect(**kwargs)
return _db_conn
def sync_position(ticker: str, state: str, buy_price=None, sell_price=None,
qty=None, order_uuid=None, invested_krw=None):
"""position_sync 테이블에 상태 기록. state_sync 데몬과 tick_trader 양쪽에서 갱신."""
try:
conn = _get_db()
cur = conn.cursor()
if state == 'IDLE':
cur.execute("DELETE FROM position_sync WHERE ticker = :1", [ticker])
else:
now = datetime.now()
cur.execute(
"""MERGE INTO position_sync ps
USING (SELECT :1 AS ticker FROM dual) src
ON (ps.ticker = src.ticker)
WHEN MATCHED THEN UPDATE SET
state = :2, buy_price = :3, sell_price = :4,
qty = :5, order_uuid = :6, invested_krw = :7, updated_at = :8
WHEN NOT MATCHED THEN INSERT
(ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, updated_at)
VALUES (:9, :10, :11, :12, :13, :14, :15, :16)""",
[ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now,
ticker, state, buy_price, sell_price, qty, order_uuid, invested_krw, now])
conn.commit()
except Exception as e:
log.warning(f"[sync_position] {ticker} {state} 실패: {e}")
global _db_conn
_db_conn = None
# ── 상태 ──────────────────────────────────────────────────────────────────────
bars: dict = defaultdict(lambda: deque(maxlen=VOL_LOOKBACK + 10))
cur_bar: dict = {}
bar_lock = threading.Lock()
positions: dict = {}
pending_buys: dict = {}
# ── 유틸리티 ──────────────────────────────────────────────────────────────────
def fp(price: float) -> str:
"""가격을 단위에 맞게 포맷. 100원 미만은 소수점 표시."""
"""가격 포맷. 100원 미만은 소수점 표시."""
if price >= 100:
return f"{price:,.0f}"
elif price >= 10:
@@ -120,6 +94,7 @@ def fp(price: float) -> str:
def tg(msg: str) -> None:
"""텔레그램 알림 전송."""
if not TG_TOKEN or not TG_CHAT_ID:
return
try:
@@ -128,35 +103,32 @@ def tg(msg: str) -> None:
json={'chat_id': TG_CHAT_ID, 'text': msg, 'parse_mode': 'HTML'},
timeout=5,
)
except Exception as e:
except (ConnectionError, TimeoutError) 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:
"""WebSocket tick -> 현재 봉에 반영."""
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['high'] = max(b['high'], price)
b['low'] = min(b['low'], price)
b['close'] = price
b['volume'] += volume
def finalize_bars() -> None:
"""BAR_SEC마다 봉 확정 시그널 감지 → LLM 매수 판단 → 체결 확인."""
"""BAR_SEC마다 봉 확정 -> 시그널 감지 -> 매수/청산 처리."""
while True:
time.sleep(BAR_SEC)
now = datetime.now()
@@ -168,296 +140,103 @@ def finalize_bars() -> None:
continue
bars[ticker].append(b)
cur_bar[ticker] = _new_bar(b['close'], 0, now)
sig = detect_signal(ticker)
if ticker in positions or ticker in pending_buys:
continue
if len(positions) + len(pending_buys) >= MAX_POS:
continue
sig = detect_signal(
ticker, list(bars[ticker]),
vol_min=VOL_MIN, vol_lookback=VOL_LOOKBACK,
vol_krw_min=VOL_KRW_MIN,
)
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 = sorted(bar_list[i]['volume'] for i in range(start, end))
if not baseline:
return 0.0
# 상위 10% 스파이크 제거 (trimmed mean) — 볼륨 평균 오염 방지
trim = max(1, len(baseline) // 10)
trimmed = baseline[:len(baseline) - trim]
if not trimmed:
return 0.0
avg = sum(trimmed) / len(trimmed)
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
# 20초봉 거래대금 하드캡: 소량 조작 방지
bar_krw = b['close'] * b['volume']
if bar_krw < VOL_KRW_MIN:
return None
# ── LLM 호출 절감: skip 패턴 사전 필터 ──
# 1) 횡보 (최근 15봉 변동폭 < 0.3%) → 매수 매력 없음
recent = bar_list[-15:]
period_high = max(x['high'] for x in recent)
period_low = min(x['low'] for x in recent)
if period_low > 0:
spread_pct = (period_high - period_low) / period_low * 100
if spread_pct < 0.3:
log.debug(f"[필터/횡보] {ticker} 15봉 변동 {spread_pct:.2f}% → 스킵")
return None
# 2) 상승 추세 이미 진행 (현재가가 구간 고점 대비 90% 이상 도달)
long_bars = bar_list[-90:] # ~30분
long_high = max(x['high'] for x in long_bars)
long_low = min(x['low'] for x in long_bars)
if long_high > long_low:
pos_in_range = (b['close'] - long_low) / (long_high - long_low)
if pos_in_range > 0.9 and (long_high - long_low) / long_low * 100 > 1.0:
log.debug(f"[필터/고점] {ticker} 구간 {pos_in_range:.0%} 위치, 변동 {(long_high-long_low)/long_low*100:.1f}% → 스킵")
return None
# 3) 연속 양봉 필터: 직전 2봉 이상 연속 양봉이어야 진입
prev_greens = 0
for k in range(len(bar_list) - 2, max(len(bar_list) - 12, 0), -1):
if bar_list[k]['close'] > bar_list[k]['open']:
prev_greens += 1
else:
break
if prev_greens < 2:
log.debug(f"[필터/양봉] {ticker} 직전 연속양봉 {prev_greens}개 < 2 → 스킵")
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 >= 100_000: unit = 100
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)
log.debug(f"[매도주문] {ticker} price={price} qty={qty}")
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"지정가 매도 제출 실패 {ticker}: price={price} qty={qty}{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']
"""시그널 감지 후 LLM 매수 판단 -> 지정가 매수 제출."""
ticker = sig['ticker']
cur_price = sig['price']
vol_ratio = sig['vol_ratio']
# 이미 보유/매수대기 중인 종목 중복 방지
if ticker in positions or ticker in pending_buys:
return
# LLM 호출 전 포지션 수 재확인 (동시 진행 방지)
if len(positions) + len(pending_buys) >= MAX_POS:
log.info(f"[시그널] {ticker} 포지션 한도 도달 → 스킵")
return
log.info(f"[시그널] {ticker} {fp(cur_price)}원 vol {vol_ratio:.1f}x LLM 판단 요청")
log.info(f"[시그널] {ticker} {fp(cur_price)}원 vol {vol_ratio:.1f}x -> LLM 판단 요청")
llm_result = get_entry_price(
ticker=ticker,
signal=sig,
bar_list=bar_list,
ticker=ticker, signal=sig, bar_list=sig['bar_list'],
current_price=cur_price,
num_positions=len(positions),
max_positions=MAX_POS,
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"현재가: {fp(cur_price)}원 볼륨: {vol_ratio:.1f}x\n"
f"시장: {status}\n"
f"사유: {reason}"
)
_handle_skip(ticker, cur_price, vol_ratio, llm_result)
return
# LLM 호출 후 포지션 수/중복 재확인
if ticker in positions or ticker in pending_buys:
return
if len(positions) + len(pending_buys) >= MAX_POS:
log.info(f"[매수/LLM] {ticker} 승인됐으나 포지션 한도 도달 스킵")
log.info(f"[매수/LLM] {ticker} -> 승인됐으나 포지션 한도 도달 -> 스킵")
return
buy_price = _round_price(cur_price) # 현재가로 즉시 매수
confidence = llm_result.get('confidence', '?')
reason = llm_result.get('reason', '')
status = llm_result.get('market_status', '')
_submit_buy(ticker, cur_price, vol_ratio, llm_result)
# 예산 체크: MAX_BUDGET - 현재 투자금 합계
invested = sum(p['entry_price'] * p['qty'] for p in positions.values())
invested += sum(p['price'] * p['qty'] for p in pending_buys.values())
remaining = MAX_BUDGET - invested
def _handle_skip(
ticker: str, price: float, vol_ratio: float,
llm_result: Optional[dict],
) -> None:
"""LLM skip 결과 로깅 + 텔레그램 알림."""
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"현재가: {fp(price)}원 볼륨: {vol_ratio:.1f}x\n"
f"시장: {status}\n"
f"사유: {reason}"
)
def _submit_buy(
ticker: str, cur_price: float, vol_ratio: float,
llm_result: dict,
) -> None:
"""LLM 승인 후 예산 체크 -> 지정가 매수 제출."""
buy_price = round_price(cur_price)
confidence = llm_result.get('confidence', '?')
reason = llm_result.get('reason', '')
status = llm_result.get('market_status', '')
remaining = calc_remaining_budget(positions, pending_buys, MAX_BUDGET)
invest_amt = min(PER_POS, remaining)
if invest_amt < 5000:
log.info(f"[매수/예산부족] {ticker} 투자중 {invested:,.0f}원, 남은예산 {remaining:,.0f} 스킵")
log.info(f"[매수/예산부족] {ticker} 남은예산 {remaining:,.0f}-> 스킵")
return
qty = invest_amt * (1 - FEE) / buy_price
log.info(f"[매수/LLM] {ticker} 승인 {fp(buy_price)}원 (현재가 매수)")
log.info(f"[매수/LLM] {ticker} -> 승인 {fp(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
uuid = submit_limit_buy(upbit_client, ticker, buy_price, qty, sim_mode=SIM_MODE)
if uuid is None:
return
pending_buys[ticker] = {
'uuid': uuid,
'price': buy_price,
'qty': qty,
'ts': datetime.now(),
'vol_ratio': vol_ratio,
'uuid': uuid, 'price': buy_price, 'qty': qty,
'ts': datetime.now(), 'vol_ratio': vol_ratio,
}
sync_position(ticker, 'PENDING_BUY', buy_price=buy_price, qty=qty,
order_uuid=uuid, invested_krw=int(qty * buy_price))
log.info(f"[지정가매수] {ticker} {fp(buy_price)}원 수량: {qty:.6f}")
invested = int(qty * buy_price)
sync_position(ticker, 'PENDING_BUY', buy_price=buy_price, qty=qty,
order_uuid=uuid, invested_krw=invested)
log.info(f"[지정가매수] {ticker} {fp(buy_price)}원 수량: {qty:.6f}")
tg(
f"📥 <b>지정가 매수</b> {ticker}\n"
f"지정가: {fp(buy_price)}원 투자: {invested:,}\n"
@@ -468,16 +247,16 @@ def process_signal(sig: dict) -> None:
)
# ── 체결 확인 ─────────────────────────────────────────────────────────────────
def check_pending_buys() -> None:
"""지정가 매수 주문 체결 확인. 체결 시 포지션 등록, 타임아웃/한도초과 시 취소."""
"""미체결 매수 주문 체결 확인. 타임아웃/한도 초과 시 취소."""
for ticker in list(pending_buys.keys()):
pb = pending_buys[ticker]
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}) 도달 취소")
cancel_order(upbit_client, pb['uuid'], sim_mode=SIM_MODE)
log.info(f"[매수취소] {ticker} 포지션 한도({MAX_POS}) 도달 -> 취소")
sync_position(ticker, 'IDLE')
del pending_buys[ticker]
continue
@@ -490,30 +269,31 @@ def check_pending_buys() -> None:
del pending_buys[ticker]
continue
else:
state, avg_price = check_order_state(pb['uuid'])
state, avg_price = check_order_state(upbit_client, pb['uuid'])
if state == 'done':
actual_price = avg_price or pb['price']
actual_qty = upbit_client.get_balance(ticker.split('-')[1]) or pb['qty']
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}초 미체결 취소")
cancel_order(upbit_client, pb['uuid'], sim_mode=SIM_MODE)
log.info(f"[매수취소] {ticker} {elapsed:.0f}초 미체결 -> 취소")
tg(f"❌ <b>매수 취소</b> {ticker}\n{fp(pb['price'])}{elapsed:.0f}초 미체결")
sync_position(ticker, 'IDLE')
del pending_buys[ticker]
def _activate_position(ticker: str, entry_price: float, qty: float, vol_ratio: float) -> None:
"""매수 체결 후 포지션 등록 (트레일링 스탑)."""
def _activate_position(
ticker: str, entry_price: float, qty: float, vol_ratio: float,
) -> None:
"""매수 체결 후 포지션 등록."""
positions[ticker] = {
'entry_price': entry_price,
'entry_ts': datetime.now(),
'entry_price': entry_price,
'entry_ts': datetime.now(),
'running_peak': entry_price,
'qty': qty,
'qty': qty,
}
invested = int(qty * entry_price)
sync_position(ticker, 'PENDING_SELL', buy_price=entry_price,
@@ -528,15 +308,11 @@ def _activate_position(ticker: str, entry_price: float, qty: float, vol_ratio: f
# ── 포지션 관리 ───────────────────────────────────────────────────────────────
positions: dict = {}
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
"""포지션 청산 기록 + 텔레그램 알림."""
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 = {
@@ -545,12 +321,12 @@ def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
}.get(tag, tag)
icon = "" if pnl > 0 else "🔴"
log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}{held}초 보유")
invested = int(pos['qty'] * pos['entry_price'])
log.info(f"[청산/{tag}] {ticker} {fp(exit_price)}원 PNL {pnl:+.2f}% {krw:+,.0f}{held}초 보유")
tg(
f"{icon} <b>청산</b> {ticker} [{reason_tag}]\n"
f"투자: {invested:,}\n"
f"진입: {fp(pos['entry_price'])} 청산: {fp(exit_price)}\n"
f"진입: {fp(pos['entry_price'])}-> 청산: {fp(exit_price)}\n"
f"PNL: <b>{pnl:+.2f}%</b> ({krw:+,.0f}원) {held}초 보유\n"
f"{'[시뮬]' if SIM_MODE else '[실거래]'}"
)
@@ -558,76 +334,56 @@ def _record_exit(ticker: str, exit_price: float, tag: str) -> None:
del positions[ticker]
def _try_exit(ticker: str, price: float) -> None:
"""청산 조건 체크 후 시장가 매도 실행."""
pos = positions[ticker]
pos['running_peak'] = max(pos['running_peak'], price)
tag = check_exit_conditions(
pos, price,
trail_pct=TRAIL_PCT, min_profit_pct=MIN_PROFIT_PCT,
stop_loss_pct=STOP_LOSS_PCT, timeout_secs=TIMEOUT_SECS,
)
if tag is None:
return
exit_price = sell_market(upbit_client, ticker, pos['qty'], sim_mode=SIM_MODE) or price
if tag == 'trail':
peak_pnl = (pos['running_peak'] - pos['entry_price']) / pos['entry_price'] * 100
drop = (pos['running_peak'] - price) / pos['running_peak'] * 100
log.info(f"[트레일] {ticker} 고점 {fp(pos['running_peak'])}원(+{peak_pnl:.1f}%) -> {fp(price)}원 drop {drop:.2f}%")
elif tag == 'stoploss':
profit = (price - pos['entry_price']) / pos['entry_price'] * 100
log.info(f"[손절] {ticker} {fp(price)}원 (진입 대비 {profit:+.2f}%)")
elif tag == 'timeout':
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
log.info(f"[타임아웃] {ticker} {elapsed:.0f}초 경과")
_record_exit(ticker, exit_price, tag)
def check_filled_positions() -> None:
"""20초마다 포지션 관리: 트레일링 스탑 / 손절 / 타임아웃."""
"""20초마다 포지션 체크: 트레일링 스탑 / 손절 / 타임아웃."""
for ticker in list(positions.keys()):
if ticker not in positions:
continue
pos = positions[ticker]
bar_list = list(bars.get(ticker, []))
if not bar_list:
continue
current_price = bar_list[-1]['close']
elapsed = (datetime.now() - pos['entry_ts']).total_seconds()
# peak 갱신
pos['running_peak'] = max(pos['running_peak'], current_price)
profit_pct = (current_price - pos['entry_price']) / pos['entry_price']
drop_from_peak = (pos['running_peak'] - current_price) / pos['running_peak'] if pos['running_peak'] > 0 else 0
# 1. 손절: -2%
if profit_pct <= -STOP_LOSS_PCT:
exit_price = do_sell_market(ticker, pos['qty']) or current_price
log.info(f"[손절] {ticker} {fp(current_price)}원 (진입 대비 {profit_pct*100:+.2f}%)")
_record_exit(ticker, exit_price, 'stoploss')
continue
# 2. 트레일링 스탑: 수익 +0.5% 이상 AND 고점 대비 -1.5%
if profit_pct >= MIN_PROFIT_PCT and drop_from_peak >= TRAIL_PCT:
exit_price = do_sell_market(ticker, pos['qty']) or current_price
peak_pnl = (pos['running_peak'] - pos['entry_price']) / pos['entry_price'] * 100
log.info(f"[트레일] {ticker} 고점 {fp(pos['running_peak'])}원(+{peak_pnl:.1f}%) → {fp(current_price)}원 drop {drop_from_peak*100:.2f}%")
_record_exit(ticker, exit_price, 'trail')
continue
# 3. 타임아웃: 4시간
if elapsed >= TIMEOUT_SECS:
exit_price = do_sell_market(ticker, pos['qty']) or current_price
log.info(f"[타임아웃] {ticker} {elapsed:.0f}초 경과")
_record_exit(ticker, exit_price, 'timeout')
continue
_try_exit(ticker, bar_list[-1]['close'])
def update_positions(current_prices: dict) -> None:
"""tick마다 peak 갱신 (실시간 트레일링)."""
"""tick마다 실시간 peak 갱신 + 손절/트레일 체크."""
for ticker in list(positions.keys()):
if ticker not in current_prices:
continue
pos = positions[ticker]
price = current_prices[ticker]
pos['running_peak'] = max(pos['running_peak'], price)
# 실시간 손절 체크
profit_pct = (price - pos['entry_price']) / pos['entry_price']
if profit_pct <= -STOP_LOSS_PCT:
exit_price = do_sell_market(ticker, pos['qty']) or price
log.info(f"[손절/실시간] {ticker} {fp(price)}원 ({profit_pct*100:+.2f}%)")
_record_exit(ticker, exit_price, 'stoploss')
continue
# 실시간 트레일링 체크
drop = (pos['running_peak'] - price) / pos['running_peak'] if pos['running_peak'] > 0 else 0
if profit_pct >= MIN_PROFIT_PCT and drop >= TRAIL_PCT:
exit_price = do_sell_market(ticker, pos['qty']) or price
log.info(f"[트레일/실시간] {ticker} 고점 {fp(pos['running_peak'])}원 → {fp(price)}")
_record_exit(ticker, exit_price, 'trail')
_try_exit(ticker, current_prices[ticker])
# ── 메인 ──────────────────────────────────────────────────────────────────────
# ── 초기화 ────────────────────────────────────────────────────────────────────
def preload_bars() -> None:
"""REST API 1분봉으로 bars[] 사전 적재."""
need_min = (VOL_LOOKBACK + 10) // 3 + 1
log.info(f"[사전적재] REST API 1분봉 {need_min}개로 bars[] 초기화 중...")
loaded = 0
@@ -647,7 +403,7 @@ def preload_bars() -> None:
bars[ticker].append({'open': o, 'high': h, 'low': l, 'close': c, 'volume': v3, 'ts': ts})
loaded += 1
break
except Exception as e:
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"[사전적재] {ticker} 시도{attempt+1} 실패: {e}")
time.sleep(1)
time.sleep(0.2)
@@ -655,91 +411,22 @@ def preload_bars() -> None:
def restore_positions() -> None:
"""Upbit 잔고 + 미체결 매수에서 포지션/pending_buys 복구 (재시작 대응)."""
"""Upbit 잔고에서 포지션 + 미체결 매수 복구."""
if SIM_MODE:
return
try:
balances = upbit_client.get_balances()
log.info(f"[복구] 잔고 조회: {len(balances)}")
for b in balances:
currency = b.get('currency', '')
bal = float(b.get('balance', 0))
locked = float(b.get('locked', 0))
avg = float(b.get('avg_buy_price', 0))
total = bal + locked
if currency == 'KRW' or total <= 0 or avg <= 0:
continue
ticker = f'KRW-{currency}'
if ticker not in TICKERS:
log.info(f"[복구] {ticker} TICKERS 외 → 스킵")
continue
if ticker in positions:
continue
log.info(f"[복구] {ticker} bal={bal:.6f} locked={locked:.6f} avg={fp(avg)}")
# 기존 미체결 매도 주문 전부 취소 (트레일링으로 관리)
try:
old_orders = upbit_client.get_order(ticker, state='wait') or []
for o in (old_orders if isinstance(old_orders, list) else []):
if o.get('side') == 'ask':
cancel_order_safe(o.get('uuid'))
log.info(f"[복구] {ticker} 기존 매도 주문 취소: {o.get('uuid')}")
except Exception as e:
log.warning(f"[복구] {ticker} 주문 조회/취소 실패: {e}")
# 취소 후 실제 가용 수량 재조회
time.sleep(0.5)
actual_bal = upbit_client.get_balance(currency)
if not actual_bal or actual_bal <= 0:
actual_bal = total
log.warning(f"[복구] {ticker} get_balance 실패, total={total:.6f} 사용")
positions[ticker] = {
'entry_price': avg,
'entry_ts': datetime.now(),
'running_peak': avg,
'qty': actual_bal,
}
log.info(f"[복구] {ticker} 수량:{actual_bal:.6f} 매수평균:{fp(avg)}원 트레일링")
tg(f"♻️ <b>포지션 복구</b> {ticker}\n매수평균: {fp(avg)}원 수량: {actual_bal:.6f}")
# 미체결 매수 주문 복구 → pending_buys
for ticker in TICKERS:
if ticker in positions or ticker in pending_buys:
continue
try:
orders = upbit_client.get_order(ticker, state='wait') or []
for o in (orders if isinstance(orders, list) else []):
if o.get('side') == 'bid':
price = float(o.get('price', 0))
rem = float(o.get('remaining_volume', 0))
if price > 0 and rem > 0:
pending_buys[ticker] = {
'uuid': o.get('uuid'),
'price': price,
'qty': rem,
'ts': datetime.now(),
'vol_ratio': 0,
}
log.info(f"[복구] {ticker} 미체결 매수 복구: {fp(price)}원 수량:{rem:.6f}")
break
except Exception:
pass
restored = len(positions) + len(pending_buys)
if restored:
log.info(f"[복구] 총 {len(positions)}개 포지션 + {len(pending_buys)}개 미체결 매수 복구됨")
# 복구 결과를 position_sync에 반영
for ticker, pos in positions.items():
sync_position(ticker, 'PENDING_SELL', buy_price=pos['entry_price'],
qty=pos['qty'],
invested_krw=int(pos['qty'] * pos['entry_price']))
for ticker, pb in pending_buys.items():
sync_position(ticker, 'PENDING_BUY', buy_price=pb['price'],
qty=pb['qty'], order_uuid=pb.get('uuid'),
invested_krw=int(pb['qty'] * pb['price']))
except Exception as e:
restore_from_upbit(
upbit_client, TICKERS, positions, pending_buys,
cancel_fn=lambda uuid: cancel_order(upbit_client, uuid, sim_mode=SIM_MODE),
fp_fn=fp, tg_fn=tg,
)
except (ConnectionError, TimeoutError, ValueError) as e:
log.warning(f"[복구] 잔고 조회 실패: {e}", exc_info=True)
def main():
# ── 메인 ──────────────────────────────────────────────────────────────────────
def main() -> None:
"""tick_trader 메인 루프."""
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:,}")
@@ -770,7 +457,7 @@ def main():
continue
ticker = data.get('code')
price = data.get('trade_price')
price = data.get('trade_price')
volume = data.get('trade_volume')
if not ticker or price is None or volume is None:
@@ -785,7 +472,7 @@ def main():
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} ({(p['running_peak']-p['entry_price'])/p['entry_price']*100:+.1f}%)"
f"{t.split('-')[1]} {p['entry_price']:,.0f}->{p['running_peak']:,.0f} ({(p['running_peak']-p['entry_price'])/p['entry_price']*100:+.1f}%)"
for t, p in positions.items()
)
log.info(f"[상태] 포지션 {len(positions)}/{MAX_POS} {pos_lines}")