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,169 +0,0 @@
"""1분봉 장기 히스토리 fetch 데몬.
주요 5종목(BTC/ETH/XRP/SOL/DOGE)의 1분봉을 2년치까지 소급 수집.
백그라운드에서 조용히 실행 — API 딜레이 충분히 줘서 다른 작업 방해 안 함.
재시작 시 DB에 이미 있는 범위는 건너뜀.
실행:
.venv/bin/python3 daemons/fetch_1min_history.py [--tickers BTC ETH] [--days 730]
로그:
/tmp/fetch_1min_history.log
"""
import sys, os, time, argparse, logging
from datetime import datetime, timedelta
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
import oracledb
# ── 설정 ──────────────────────────────────────────────────────────────────────
DEFAULT_TICKERS = ['KRW-BTC', 'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE']
BATCH = 200 # API 1회 요청 봉수 (Upbit 최대 200)
DELAY = 0.4 # API 호출 간격 (초) — 넉넉히 줘서 rate limit 회피
RETRY_WAIT = 5.0 # 오류 시 대기 (초)
# ── 로깅 설정 ─────────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('/tmp/fetch_1min_history.log'),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger(__name__)
def _get_conn():
kwargs = dict(user=os.environ["ORACLE_USER"],
password=os.environ["ORACLE_PASSWORD"],
dsn=os.environ["ORACLE_DSN"])
wallet = os.environ.get("ORACLE_WALLET")
if wallet:
kwargs["config_dir"] = wallet
return oracledb.connect(**kwargs)
def get_oldest_ts(conn, ticker: str):
"""DB에 있는 해당 ticker 1분봉의 가장 오래된 ts 반환. 없으면 None."""
cur = conn.cursor()
cur.execute(
"SELECT MIN(ts) FROM backtest_ohlcv "
"WHERE ticker=:t AND interval_cd='minute1'",
{"t": ticker}
)
row = cur.fetchone()
return row[0] if row and row[0] else None
def insert_batch(conn, ticker: str, rows: list) -> int:
"""rows: [(ts, open, high, low, close, volume), ...] — bulk insert, 중복 무시."""
if not rows:
return 0
cur = conn.cursor()
cur.executemany(
"INSERT INTO backtest_ohlcv "
"(ticker,interval_cd,ts,open_p,high_p,low_p,close_p,volume_p) "
"VALUES (:1,'minute1',:2,:3,:4,:5,:6,:7)",
[(ticker, ts, o, h, l, c, v) for ts, o, h, l, c, v in rows],
batcherrors=True,
)
errors = cur.getbatcherrors()
conn.commit()
return len(rows) - len(errors)
def fetch_ticker(conn, ticker: str, cutoff: datetime) -> int:
"""ticker의 cutoff까지 1분봉 소급 fetch.
DB에 이미 있는 범위는 batcherrors로 자동 스킵.
"""
oldest_in_db = get_oldest_ts(conn, ticker)
if oldest_in_db and oldest_in_db <= cutoff:
log.info(f"{ticker}: DB에 이미 {oldest_in_db.date()} 까지 있음 → 스킵")
return 0
# datetime.now()에서 시작해 cutoff까지 역방향 fetch
# 중복은 DB unique constraint + batcherrors가 처리
to_dt = datetime.now()
total = 0
batch_n = 0
if oldest_in_db:
log.info(f"{ticker}: DB 최솟값={oldest_in_db.date()}, {cutoff.date()} 까지 소급 시작")
else:
log.info(f"{ticker}: DB에 데이터 없음, {cutoff.date()} 까지 전체 fetch 시작")
while to_dt > cutoff:
# pyupbit의 to 파라미터는 UTC로 해석됨 — KST에서 9시간 빼서 전달
to_utc = to_dt - timedelta(hours=9)
to_str = to_utc.strftime('%Y-%m-%d %H:%M:%S')
try:
df = pyupbit.get_ohlcv(ticker, interval='minute1', count=BATCH, to=to_str)
time.sleep(DELAY)
except Exception as e:
log.warning(f"{ticker} API 오류: {e}{RETRY_WAIT}s 후 재시도")
time.sleep(RETRY_WAIT)
continue
if df is None or len(df) == 0:
log.info(f"{ticker}: API 데이터 소진 ({to_str})")
break
rows = [
(ts.to_pydatetime(), float(r['open']), float(r['high']),
float(r['low']), float(r['close']), float(r['volume']))
for ts, r in df.iterrows()
]
n = insert_batch(conn, ticker, rows)
total += n
batch_n += 1
oldest = df.index[0].to_pydatetime()
if batch_n % 50 == 0:
log.info(f" {ticker} 배치{batch_n:04d}: {oldest.date()} | 신규 누적 {total:,}")
to_dt = oldest - timedelta(minutes=1)
if oldest <= cutoff:
break
log.info(f"{ticker}: 완료 — 신규 {total:,}행 (배치 {batch_n}회)")
return total
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--tickers', nargs='+', default=DEFAULT_TICKERS,
help='수집 티커 (예: KRW-BTC KRW-ETH)')
parser.add_argument('--days', type=int, default=730,
help='소급 일수 (기본 730일 = 2년)')
args = parser.parse_args()
cutoff = datetime.now() - timedelta(days=args.days)
log.info(f"=== 1분봉 히스토리 데몬 시작 ===")
log.info(f"대상: {args.tickers}")
log.info(f"목표: {cutoff.date()} ({args.days}일) 까지 소급")
conn = _get_conn()
grand_total = 0
t_start = time.time()
for ticker in args.tickers:
t0 = time.time()
try:
n = fetch_ticker(conn, ticker, cutoff)
grand_total += n
elapsed = time.time() - t0
log.info(f"{ticker}: {n:,}행 저장 ({elapsed/60:.1f}분)")
except Exception as e:
log.error(f"{ticker}: 오류 — {e}")
conn.close()
total_min = (time.time() - t_start) / 60
log.info(f"=== 완료: 총 {grand_total:,}행 / {total_min:.0f}분 ===")
if __name__ == '__main__':
main()

View File

@@ -1,363 +0,0 @@
"""실시간 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()

184
daemons/state_sync.py Normal file
View File

@@ -0,0 +1,184 @@
"""10초 주기로 Upbit 잔고/미체결 주문을 조회하여 position_sync 테이블 동기화.
상태:
PENDING_BUY — 매수 주문 제출됨 (미체결)
HOLDING — 보유 중 (매도 주문 없음)
PENDING_SELL — 매도 주문 제출됨 (미체결)
IDLE — 아무 것도 없음 (행 삭제)
tick_trader는 이 테이블을 읽어서 positions/pending_buys를 복구한다.
실행:
.venv/bin/python3 daemons/state_sync.py
로그:
/tmp/state_sync.log
"""
import sys, os, time, logging
from datetime import datetime
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
import oracledb
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',
]
INTERVAL = 10 # 초
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[
logging.FileHandler('/tmp/state_sync.log'),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger(__name__)
upbit = pyupbit.Upbit(os.environ['ACCESS_KEY'], os.environ['SECRET_KEY'])
def get_conn():
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
return oracledb.connect(**kwargs)
def sync_once(conn):
"""Upbit 실제 상태를 조회하여 position_sync 테이블 갱신."""
cur = conn.cursor()
now = datetime.now()
# 1. 잔고 조회 → 보유 종목 파악
balances = upbit.get_balances() or []
held = {} # ticker → {qty, avg_price}
for b in balances:
currency = b.get('currency', '')
if currency == 'KRW':
continue
ticker = f'KRW-{currency}'
if ticker not in TICKERS:
continue
bal = float(b.get('balance', 0))
locked = float(b.get('locked', 0))
total = bal + locked
avg = float(b.get('avg_buy_price', 0))
if total > 0 and avg > 0:
held[ticker] = {'qty': total, 'avg_price': avg, 'invested': int(total * avg)}
# 2. 미체결 주문 조회 → 매수/매도 대기 파악
pending_buys = {} # ticker → {uuid, price, qty}
pending_sells = {} # ticker → {uuid, price, qty}
for ticker in TICKERS:
try:
orders = upbit.get_order(ticker, state='wait') or []
if not isinstance(orders, list):
continue
for o in orders:
side = o.get('side')
uuid = o.get('uuid')
price = float(o.get('price', 0))
rem = float(o.get('remaining_volume', 0))
if price <= 0 or rem <= 0:
continue
if side == 'bid':
pending_buys[ticker] = {'uuid': uuid, 'price': price, 'qty': rem}
elif side == 'ask':
pending_sells[ticker] = {'uuid': uuid, 'price': price, 'qty': rem}
except Exception:
pass
# 3. 상태 결정 및 DB 반영
active_tickers = set(held.keys()) | set(pending_buys.keys()) | set(pending_sells.keys())
for ticker in active_tickers:
if ticker in pending_buys and ticker not in held:
state = 'PENDING_BUY'
pb = pending_buys[ticker]
buy_price = pb['price']
sell_price = None
qty = pb['qty']
order_uuid = pb['uuid']
invested = int(qty * buy_price)
elif ticker in held and ticker in pending_sells:
state = 'PENDING_SELL'
h = held[ticker]
ps = pending_sells[ticker]
buy_price = h['avg_price']
sell_price = ps['price']
qty = h['qty']
order_uuid = ps['uuid']
invested = h['invested']
elif ticker in held:
state = 'HOLDING'
h = held[ticker]
buy_price = h['avg_price']
sell_price = None
qty = h['qty']
order_uuid = None
invested = h['invested']
else:
continue
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, now,
ticker, state, buy_price, sell_price, qty, order_uuid, invested, now]
)
# 4. 이제 없는 종목은 삭제
if active_tickers:
placeholders = ','.join(f"'{t}'" for t in active_tickers)
cur.execute(f"DELETE FROM position_sync WHERE ticker NOT IN ({placeholders})")
else:
cur.execute("DELETE FROM position_sync")
conn.commit()
if active_tickers:
summary = ', '.join(f"{t.split('-')[1]}={cur.execute('SELECT state FROM position_sync WHERE ticker=:1',[t]).fetchone()[0]}" for t in sorted(active_tickers))
log.info(f"[동기화] {summary}")
def main():
log.info(f"=== state_sync 시작 (주기 {INTERVAL}초) ===")
conn = get_conn()
fail_count = 0
while True:
try:
sync_once(conn)
fail_count = 0
except Exception as e:
fail_count += 1
log.error(f"[동기화 오류] {e}", exc_info=(fail_count <= 3))
try:
conn.close()
except Exception:
pass
try:
conn = get_conn()
except Exception:
pass
time.sleep(INTERVAL)
if __name__ == '__main__':
main()

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}")