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:
@@ -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()
|
||||
@@ -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
184
daemons/state_sync.py
Normal 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()
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user