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

@@ -0,0 +1,169 @@
"""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

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